diff --git a/api_v1/__init__.py b/api_v1/__init__.py index a65f0b9..268fd62 100644 --- a/api_v1/__init__.py +++ b/api_v1/__init__.py @@ -5,6 +5,7 @@ Sprint 3-4: HRT-29 — Refacto API /v1/ Sprint 5-6: HRT-31 — Billing Stripe HRT-79: Alertes Telegram configurables (user blueprint) HRT-80: API Token personnel + Webhook alertes (Pro) +HRT-82: Multi-compte / Organisation Pro (max 5 users) Registers sub-blueprints: /api/v1/health — public health-check @@ -19,6 +20,7 @@ Registers sub-blueprints: /api/v1/user/api-token — Personal API token (Pro) /api/v1/user/webhook — Webhook config (Pro) /api/v1/history — historique préd. ML (Free:7j, Premium:90j, Pro:illimité) + /api/v1/org/ — organisations Pro (multi-compte, max 5 users) /api/v1/docs — Swagger UI (via flasgger, registered on app) """ @@ -35,6 +37,7 @@ from .routes.billing import billing_bp from .routes.user import user_bp from .routes.user_tokens import user_tokens_bp from .routes.history import history_bp +from .routes.org import org_bp # Master blueprint that aggregates all sub-routes under /api/v1 api_v1_bp = Blueprint("api_v1", __name__, url_prefix="/api/v1") @@ -53,3 +56,4 @@ def register_api_v1(app): app.register_blueprint(user_bp) app.register_blueprint(user_tokens_bp) app.register_blueprint(history_bp) + app.register_blueprint(org_bp) diff --git a/api_v1/routes/org.py b/api_v1/routes/org.py new file mode 100644 index 0000000..ec8d277 --- /dev/null +++ b/api_v1/routes/org.py @@ -0,0 +1,536 @@ +#!/usr/bin/env python3 +""" +Org Blueprint — Multi-compte / Organisations Pro +Sprint: HRT-82 + +Endpoints: + POST /api/v1/org — créer une organisation (Pro only, 1 max par owner) + GET /api/v1/org — infos org courante + DELETE /api/v1/org — supprimer l'org (owner only) + POST /api/v1/org/invite — inviter un membre par email (max 5 totaux) + GET /api/v1/org/members — liste des membres + DELETE /api/v1/org/members/ — retirer un membre (owner only) + +Plan enforcement: + - Toutes les routes nécessitent plan=pro via plan_required('pro') + - Limite : 1 org par owner, 5 membres max (owner inclus) +""" + +import secrets +import logging +from datetime import datetime, timezone + +from flask import Blueprint, jsonify, request + +from saas_auth import require_auth as jwt_required_middleware +from org_db import get_db, migrate_org_tables + +logger = logging.getLogger("turf_saas.org") + +org_bp = Blueprint("org", __name__, url_prefix="/api/v1/org") + +MAX_MEMBERS = 5 # max membres totaux owner inclus + +# ────────────────────────────────────────────────────────────── +# Decorator: plan Pro requis +# ────────────────────────────────────────────────────────────── + + +def _require_pro(fn): + """Vérifie que l'utilisateur courant est sur le plan 'pro'.""" + from functools import wraps + + @wraps(fn) + def wrapper(*args, **kwargs): + user = getattr(request, "current_user", None) + if not user: + return jsonify({"error": "Non authentifié"}), 401 + if user.get("plan") != "pro": + return jsonify( + { + "error": "Plan insuffisant", + "required": "pro", + "current_plan": user.get("plan", "free"), + "upgrade_url": "/api/v1/billing/checkout", + } + ), 403 + return fn(*args, **kwargs) + + return wrapper + + +# ────────────────────────────────────────────────────────────── +# Helpers DB +# ────────────────────────────────────────────────────────────── + + +def _get_org_by_owner(db, owner_id: str): + return db.execute( + "SELECT * FROM organizations WHERE owner_id = ?", (owner_id,) + ).fetchone() + + +def _get_org_by_id(db, org_id: str): + return db.execute("SELECT * FROM organizations WHERE id = ?", (org_id,)).fetchone() + + +def _get_member_org(db, user_id: str): + """Retourne l'org dont user_id est membre (owner ou member).""" + row = db.execute( + """SELECT o.* FROM organizations o + JOIN org_members m ON m.org_id = o.id + WHERE m.user_id = ? + LIMIT 1""", + (user_id,), + ).fetchone() + return row + + +def _count_org_members(db, org_id: str) -> int: + row = db.execute( + "SELECT COUNT(*) AS cnt FROM org_members WHERE org_id = ?", (org_id,) + ).fetchone() + return row["cnt"] if row else 0 + + +def _get_user_by_email(db, email: str): + """Lookup dans saas_users par email.""" + return db.execute( + "SELECT * FROM saas_users WHERE email = ?", (email.lower().strip(),) + ).fetchone() + + +def _org_to_dict(org) -> dict: + return { + "id": org["id"], + "owner_id": org["owner_id"], + "name": org["name"], + "max_members": org["max_members"], + "created_at": org["created_at"], + } + + +def _member_to_dict(m) -> dict: + return { + "id": m["id"], + "org_id": m["org_id"], + "user_id": m["user_id"], + "role": m["role"], + "invited_at": m["invited_at"], + "joined_at": m["joined_at"], + } + + +# ────────────────────────────────────────────────────────────── +# POST /api/v1/org — créer une organisation +# ────────────────────────────────────────────────────────────── + + +@org_bp.route("", methods=["POST"]) +@jwt_required_middleware +@_require_pro +def create_org(): + """ + Crée une organisation. + --- + tags: + - Organisation + security: + - Bearer: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [name] + properties: + name: + type: string + description: Nom de l'organisation (1-100 caractères) + responses: + 201: + description: Organisation créée + 400: + description: Paramètre manquant ou invalide + 403: + description: Plan insuffisant + 409: + description: L'utilisateur possède déjà une organisation + """ + user = request.current_user + owner_id = user["id"] + + data = request.get_json(silent=True) or {} + name = (data.get("name") or "").strip() + if not name or len(name) > 100: + return jsonify({"error": "Le nom est requis (1-100 caractères)"}), 400 + + db = get_db() + try: + # 1 org max par owner + existing = _get_org_by_owner(db, owner_id) + if existing: + return jsonify( + { + "error": "Vous possédez déjà une organisation", + "org_id": existing["id"], + } + ), 409 + + org_id = secrets.token_hex(16) + now = datetime.now(timezone.utc).isoformat() + + db.execute( + "INSERT INTO organizations (id, owner_id, name, max_members, created_at) " + "VALUES (?, ?, ?, ?, ?)", + (org_id, owner_id, name, MAX_MEMBERS, now), + ) + # Ajouter l'owner comme premier membre avec rôle 'owner' + db.execute( + "INSERT INTO org_members (org_id, user_id, role, invited_at, joined_at) " + "VALUES (?, ?, 'owner', ?, ?)", + (org_id, owner_id, now, now), + ) + db.commit() + + org = _get_org_by_id(db, org_id) + logger.info("Org créée: %s par user %s", org_id, owner_id) + return jsonify({"org": _org_to_dict(org)}), 201 + + except Exception as e: + db.rollback() + logger.error("create_org error: %s", e) + return jsonify({"error": "Erreur interne"}), 500 + finally: + db.close() + + +# ────────────────────────────────────────────────────────────── +# GET /api/v1/org — infos org courante +# ────────────────────────────────────────────────────────────── + + +@org_bp.route("", methods=["GET"]) +@jwt_required_middleware +@_require_pro +def get_org(): + """ + Retourne l'organisation dont l'utilisateur est owner ou membre. + --- + tags: + - Organisation + security: + - Bearer: [] + responses: + 200: + description: Infos de l'organisation + 404: + description: Aucune organisation trouvée + """ + user = request.current_user + db = get_db() + try: + org = _get_org_by_owner(db, user["id"]) or _get_member_org(db, user["id"]) + if not org: + return jsonify({"error": "Aucune organisation trouvée"}), 404 + + member_count = _count_org_members(db, org["id"]) + result = _org_to_dict(org) + result["member_count"] = member_count + return jsonify({"org": result}), 200 + finally: + db.close() + + +# ────────────────────────────────────────────────────────────── +# DELETE /api/v1/org — supprimer l'organisation +# ────────────────────────────────────────────────────────────── + + +@org_bp.route("", methods=["DELETE"]) +@jwt_required_middleware +@_require_pro +def delete_org(): + """ + Supprime l'organisation (owner uniquement). + --- + tags: + - Organisation + security: + - Bearer: [] + responses: + 200: + description: Organisation supprimée + 403: + description: Seul l'owner peut supprimer l'organisation + 404: + description: Organisation introuvable + """ + user = request.current_user + db = get_db() + try: + org = _get_org_by_owner(db, user["id"]) + if not org: + return jsonify({"error": "Vous n'êtes pas owner d'une organisation"}), 403 + + # CASCADE supprime org_members automatiquement (FK ON DELETE CASCADE) + db.execute("DELETE FROM organizations WHERE id = ?", (org["id"],)) + db.commit() + logger.info("Org %s supprimée par user %s", org["id"], user["id"]) + return jsonify({"ok": True, "deleted_org_id": org["id"]}), 200 + + except Exception as e: + db.rollback() + logger.error("delete_org error: %s", e) + return jsonify({"error": "Erreur interne"}), 500 + finally: + db.close() + + +# ────────────────────────────────────────────────────────────── +# POST /api/v1/org/invite — inviter un membre par email +# ────────────────────────────────────────────────────────────── + + +@org_bp.route("/invite", methods=["POST"]) +@jwt_required_middleware +@_require_pro +def invite_member(): + """ + Invite un utilisateur dans l'organisation par email (owner uniquement). + Limite : 5 membres totaux (owner inclus). + --- + tags: + - Organisation + security: + - Bearer: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [email] + properties: + email: + type: string + description: Email de l'utilisateur à inviter + responses: + 201: + description: Membre ajouté + 400: + description: Paramètre manquant ou invalide + 403: + description: Seul l'owner peut inviter / limite de membres atteinte + 404: + description: Utilisateur introuvable ou organisation inexistante + 409: + description: L'utilisateur est déjà membre + """ + user = request.current_user + data = request.get_json(silent=True) or {} + email = (data.get("email") or "").strip().lower() + + if not email or "@" not in email: + return jsonify({"error": "Email invalide"}), 400 + + db = get_db() + try: + # Vérifier que l'appelant est bien owner d'une org + org = _get_org_by_owner(db, user["id"]) + if not org: + return jsonify({"error": "Vous n'êtes pas owner d'une organisation"}), 403 + + # Vérifier la limite de membres + current_count = _count_org_members(db, org["id"]) + if current_count >= org["max_members"]: + return jsonify( + { + "error": f"Limite de {org['max_members']} membres atteinte", + "current_count": current_count, + } + ), 403 + + # Résoudre l'utilisateur cible + target_user = _get_user_by_email(db, email) + if not target_user: + return jsonify({"error": "Utilisateur introuvable avec cet email"}), 404 + + target_id = target_user["id"] + + # Vérifier que l'utilisateur n'est pas déjà membre de CETTE org + existing_member = db.execute( + "SELECT id FROM org_members WHERE org_id = ? AND user_id = ?", + (org["id"], target_id), + ).fetchone() + if existing_member: + return jsonify( + {"error": "Cet utilisateur est déjà membre de l'organisation"} + ), 409 + + now = datetime.now(timezone.utc).isoformat() + db.execute( + "INSERT INTO org_members (org_id, user_id, role, invited_at, joined_at) " + "VALUES (?, ?, 'member', ?, ?)", + (org["id"], target_id, now, now), + ) + db.commit() + + member_row = db.execute( + "SELECT * FROM org_members WHERE org_id = ? AND user_id = ?", + (org["id"], target_id), + ).fetchone() + logger.info( + "User %s invité dans org %s par %s", target_id, org["id"], user["id"] + ) + return jsonify({"member": _member_to_dict(member_row)}), 201 + + except Exception as e: + db.rollback() + logger.error("invite_member error: %s", e) + return jsonify({"error": "Erreur interne"}), 500 + finally: + db.close() + + +# ────────────────────────────────────────────────────────────── +# GET /api/v1/org/members — liste des membres +# ────────────────────────────────────────────────────────────── + + +@org_bp.route("/members", methods=["GET"]) +@jwt_required_middleware +@_require_pro +def list_members(): + """ + Liste les membres de l'organisation courante. + --- + tags: + - Organisation + security: + - Bearer: [] + responses: + 200: + description: Liste des membres + 404: + description: Organisation introuvable + """ + user = request.current_user + db = get_db() + try: + org = _get_org_by_owner(db, user["id"]) or _get_member_org(db, user["id"]) + if not org: + return jsonify({"error": "Aucune organisation trouvée"}), 404 + + members = db.execute( + "SELECT m.*, u.email, u.firstname, u.lastname " + "FROM org_members m " + "LEFT JOIN saas_users u ON u.id = m.user_id " + "WHERE m.org_id = ? " + "ORDER BY m.invited_at ASC", + (org["id"],), + ).fetchall() + + result = [] + for m in members: + d = _member_to_dict(m) + d["email"] = m["email"] + d["firstname"] = m["firstname"] or "" + d["lastname"] = m["lastname"] or "" + result.append(d) + + return jsonify( + { + "org_id": org["id"], + "members": result, + "count": len(result), + "max_members": org["max_members"], + } + ), 200 + finally: + db.close() + + +# ────────────────────────────────────────────────────────────── +# DELETE /api/v1/org/members/ — retirer un membre +# ────────────────────────────────────────────────────────────── + + +@org_bp.route("/members/", methods=["DELETE"]) +@jwt_required_middleware +@_require_pro +def remove_member(target_user_id: str): + """ + Retire un membre de l'organisation (owner uniquement). + L'owner ne peut pas se retirer lui-même. + --- + tags: + - Organisation + security: + - Bearer: [] + parameters: + - in: path + name: user_id + type: string + required: true + description: ID de l'utilisateur à retirer + responses: + 200: + description: Membre retiré + 400: + description: Tentative de retirer l'owner lui-même + 403: + description: Seul l'owner peut retirer des membres + 404: + description: Membre ou organisation introuvable + """ + user = request.current_user + db = get_db() + try: + org = _get_org_by_owner(db, user["id"]) + if not org: + return jsonify({"error": "Vous n'êtes pas owner d'une organisation"}), 403 + + # L'owner ne peut pas se retirer lui-même (utiliser DELETE /api/v1/org à la place) + if target_user_id == user["id"]: + return jsonify( + { + "error": "L'owner ne peut pas se retirer lui-même. " + "Utilisez DELETE /api/v1/org pour supprimer l'organisation." + } + ), 400 + + member = db.execute( + "SELECT * FROM org_members WHERE org_id = ? AND user_id = ?", + (org["id"], target_user_id), + ).fetchone() + if not member: + return jsonify({"error": "Membre introuvable dans cette organisation"}), 404 + + db.execute( + "DELETE FROM org_members WHERE org_id = ? AND user_id = ?", + (org["id"], target_user_id), + ) + db.commit() + logger.info( + "User %s retiré de l'org %s par %s", target_user_id, org["id"], user["id"] + ) + return jsonify({"ok": True, "removed_user_id": target_user_id}), 200 + + except Exception as e: + db.rollback() + logger.error("remove_member error: %s", e) + return jsonify({"error": "Erreur interne"}), 500 + finally: + db.close() + + +# ────────────────────────────────────────────────────────────── +# On-import : migration idempotente +# ────────────────────────────────────────────────────────────── + +try: + migrate_org_tables() +except Exception as _e: + logger.warning("org_db migration skipped (test env?): %s", _e) diff --git a/org_db.py b/org_db.py new file mode 100644 index 0000000..86f10de --- /dev/null +++ b/org_db.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +""" +Org DB — Multi-compte / Organisations Pro +Sprint: HRT-82 + +Migration idempotente : crée les tables organizations et org_members +dans turf_saas.db si elles n'existent pas. + +Run une seule fois : + ./venv/bin/python org_db.py +""" + +import sqlite3 +import os +import logging + +DB_PATH = os.environ.get("TURF_SAAS_DB", "/home/h3r7/turf_saas/turf_saas.db") +logger = logging.getLogger("turf_saas.org_db") + + +def get_db(): + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA foreign_keys = ON") + return conn + + +def migrate_org_tables(): + """ + Migration idempotente : crée organizations + org_members. + + - organizations : 1 org max par owner (enforced en Python + UNIQUE owner_id) + - org_members : max 5 membres totaux (owner inclus, enforced en Python) + - UNIQUE(org_id, user_id) empêche les doublons de membres + """ + conn = get_db() + c = conn.cursor() + + c.executescript(""" + CREATE TABLE IF NOT EXISTS organizations ( + id TEXT PRIMARY KEY, + owner_id TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + max_members INTEGER NOT NULL DEFAULT 5, + created_at DATETIME NOT NULL DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS org_members ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + org_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + user_id TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'member' + CHECK(role IN ('owner', 'member')), + invited_at DATETIME NOT NULL DEFAULT (datetime('now')), + joined_at DATETIME, + UNIQUE(org_id, user_id) + ); + + CREATE INDEX IF NOT EXISTS idx_org_owner ON organizations(owner_id); + CREATE INDEX IF NOT EXISTS idx_orgmem_org ON org_members(org_id); + CREATE INDEX IF NOT EXISTS idx_orgmem_user ON org_members(user_id); + """) + + conn.commit() + conn.close() + logger.info("[org_db] Tables organizations + org_members créées/vérifiées.") + print("[org_db] Migration OK: organizations, org_members.") + + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO) + migrate_org_tables() diff --git a/saas_api_v1.py b/saas_api_v1.py index 186ad89..52e54b3 100644 --- a/saas_api_v1.py +++ b/saas_api_v1.py @@ -268,15 +268,33 @@ try: @api_v1_bp.record_once def _init_jwt(state): app = state.app - if not app.config.get('JWT_SECRET_KEY'): + if not app.config.get("JWT_SECRET_KEY"): import os - app.config['JWT_SECRET_KEY'] = os.environ.get('JWT_SECRET_KEY', 'turf-saas-secret-key-change-in-prod') - if 'flask_jwt_extended' not in app.extensions: + + app.config["JWT_SECRET_KEY"] = os.environ.get( + "JWT_SECRET_KEY", "turf-saas-secret-key-change-in-prod" + ) + if "flask_jwt_extended" not in app.extensions: JWTManager(app) # Register billing blueprint with url_prefix='/billing' # (parent api_v1_bp has '/api/v1', so result is /api/v1/billing/*) - api_v1_bp.register_blueprint(billing_bp, url_prefix='/billing') - print('[saas_api_v1] Billing blueprint (Stripe) + JWT registered ✅') + api_v1_bp.register_blueprint(billing_bp, url_prefix="/billing") + print("[saas_api_v1] Billing blueprint (Stripe) + JWT registered ✅") except Exception as _billing_err: - print(f'[saas_api_v1] Warning: billing blueprint not loaded: {_billing_err}') + print(f"[saas_api_v1] Warning: billing blueprint not loaded: {_billing_err}") + + +# ─── Org Blueprint — HRT-82 ─────────────────────────────────────────────────── +# Registers /api/v1/org/* routes (Pro plan only, multi-compte max 5 users) +try: + from api_v1.routes.org import org_bp + + @api_v1_bp.record_once + def _register_org_bp(state): + app = state.app + app.register_blueprint(org_bp) + + print("[saas_api_v1] Org blueprint (multi-compte Pro) registered ✅") +except Exception as _org_err: + print(f"[saas_api_v1] Warning: org blueprint not loaded: {_org_err}") diff --git a/tests/test_org.py b/tests/test_org.py new file mode 100644 index 0000000..727091a --- /dev/null +++ b/tests/test_org.py @@ -0,0 +1,533 @@ +#!/usr/bin/env python3 +""" +Tests — Multi-compte / Organisations Pro +Sprint: HRT-82 + +Couvre : + - Migration DB (tables organizations + org_members) + - POST /api/v1/org + - GET /api/v1/org + - DELETE /api/v1/org + - POST /api/v1/org/invite + - GET /api/v1/org/members + - DELETE /api/v1/org/members/ + - Plan enforcement (plan != pro → 403) + - Contraintes métier (1 org/owner, max 5 membres, doublons, etc.) + +Run: + ./venv/bin/pytest tests/test_org.py -v --tb=short +""" + +import os +import sys +import tempfile +import secrets + +import pytest + +# ─── Isolated temp DB ──────────────────────────────────────────────────────── + +_tmp_db = tempfile.NamedTemporaryFile(suffix=".db", delete=False) +_tmp_db.close() +os.environ["TURF_SAAS_DB"] = _tmp_db.name + +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) + +# ─── App import (après configuration env) ──────────────────────────────────── + +import sqlite3 +from org_db import get_db, migrate_org_tables +from saas_auth import get_db as auth_get_db, init_users_table, generate_token + + +# ─── Helpers ───────────────────────────────────────────────────────────────── + + +def _create_user(email: str, plan: str = "free") -> dict: + """Crée un utilisateur directement en DB et retourne son token + id.""" + init_users_table() + uid = secrets.token_hex(16) + pw_hash = "hashed" + conn = auth_get_db() + conn.execute( + "INSERT OR IGNORE INTO saas_users (id, email, firstname, lastname, password_hash, plan) " + "VALUES (?,?,?,?,?,?)", + (uid, email, "Test", "User", pw_hash, plan), + ) + conn.commit() + conn.close() + token = generate_token(uid) + return {"id": uid, "email": email, "token": token, "plan": plan} + + +def _auth_header(token: str) -> dict: + return {"Authorization": f"Bearer {token}"} + + +# ─── Flask app fixture ─────────────────────────────────────────────────────── + + +@pytest.fixture(scope="module") +def app(): + """Crée l'app Flask avec les blueprints org enregistrés.""" + from flask import Flask + from flask_cors import CORS + from saas_auth import auth_bp + from api_v1.routes.org import org_bp + + application = Flask(__name__) + CORS(application) + application.config["TESTING"] = True + + # S'assurer que la migration a tourné + migrate_org_tables() + + application.register_blueprint(auth_bp) + application.register_blueprint(org_bp) + + yield application + + +@pytest.fixture(scope="module") +def client(app): + return app.test_client() + + +# ─── Users fixtures ─────────────────────────────────────────────────────────── + + +@pytest.fixture(scope="module") +def pro_owner(app): + """Un utilisateur Pro qui va créer une org.""" + with app.app_context(): + return _create_user("owner_pro@test.com", plan="pro") + + +@pytest.fixture(scope="module") +def pro_user2(app): + """Un 2e utilisateur Pro à inviter.""" + with app.app_context(): + return _create_user("member2_pro@test.com", plan="pro") + + +@pytest.fixture(scope="module") +def pro_user3(app): + with app.app_context(): + return _create_user("member3_pro@test.com", plan="pro") + + +@pytest.fixture(scope="module") +def pro_user4(app): + with app.app_context(): + return _create_user("member4_pro@test.com", plan="pro") + + +@pytest.fixture(scope="module") +def pro_user5(app): + with app.app_context(): + return _create_user("member5_pro@test.com", plan="pro") + + +@pytest.fixture(scope="module") +def pro_user6(app): + """6e utilisateur pour tester la limite MAX_MEMBERS.""" + with app.app_context(): + return _create_user("member6_pro@test.com", plan="pro") + + +@pytest.fixture(scope="module") +def free_user(app): + with app.app_context(): + return _create_user("free_user@test.com", plan="free") + + +@pytest.fixture(scope="module") +def other_pro_owner(app): + """Un 2e owner Pro (pour tester conflits inter-orgs).""" + with app.app_context(): + return _create_user("other_owner@test.com", plan="pro") + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Tests DB migration +# ═══════════════════════════════════════════════════════════════════════════════ + + +class TestOrgDbMigration: + def test_tables_exist(self): + """Les tables organizations et org_members doivent exister.""" + conn = get_db() + tables = { + row[0] + for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'") + } + conn.close() + assert "organizations" in tables, "Table organizations manquante" + assert "org_members" in tables, "Table org_members manquante" + + def test_migration_idempotent(self): + """Appeler migrate_org_tables() deux fois ne doit pas lever d'erreur.""" + migrate_org_tables() # 2e appel — doit être silencieux + self.test_tables_exist() + + def test_org_members_unique_constraint(self): + """UNIQUE(org_id, user_id) doit être présent.""" + conn = get_db() + indexes = [row[1] for row in conn.execute("PRAGMA index_list(org_members)")] + conn.close() + # Il doit y avoir un index d'unicité + assert ( + any( + "unique" in idx.lower() or "org_members" in idx.lower() + for idx in indexes + ) + or True + ) + # On vérifie via insertion en double + conn = get_db() + oid = "test_org_unique" + uid = "test_uid_unique" + try: + conn.execute( + "INSERT OR IGNORE INTO organizations (id, owner_id, name) VALUES (?,?,?)", + (oid, uid, "TestOrg"), + ) + conn.execute( + "INSERT INTO org_members (org_id, user_id, role, invited_at, joined_at) " + "VALUES (?,?,'member',datetime('now'),datetime('now'))", + (oid, uid), + ) + conn.commit() + # 2e insertion doit lever IntegrityError + with pytest.raises(sqlite3.IntegrityError): + conn.execute( + "INSERT INTO org_members (org_id, user_id, role, invited_at, joined_at) " + "VALUES (?,?,'member',datetime('now'),datetime('now'))", + (oid, uid), + ) + conn.commit() + finally: + conn.execute("DELETE FROM org_members WHERE org_id=?", (oid,)) + conn.execute("DELETE FROM organizations WHERE id=?", (oid,)) + conn.commit() + conn.close() + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Tests plan enforcement +# ═══════════════════════════════════════════════════════════════════════════════ + + +class TestPlanEnforcement: + def test_create_org_free_plan_403(self, client, free_user): + """Un utilisateur free ne peut pas créer une org.""" + resp = client.post( + "/api/v1/org", + json={"name": "FreePlanOrg"}, + headers=_auth_header(free_user["token"]), + ) + assert resp.status_code == 403 + data = resp.get_json() + assert data["required"] == "pro" + + def test_get_org_free_plan_403(self, client, free_user): + resp = client.get("/api/v1/org", headers=_auth_header(free_user["token"])) + assert resp.status_code == 403 + + def test_invite_free_plan_403(self, client, free_user): + resp = client.post( + "/api/v1/org/invite", + json={"email": "someone@test.com"}, + headers=_auth_header(free_user["token"]), + ) + assert resp.status_code == 403 + + def test_members_free_plan_403(self, client, free_user): + resp = client.get( + "/api/v1/org/members", headers=_auth_header(free_user["token"]) + ) + assert resp.status_code == 403 + + def test_no_token_401(self, client): + resp = client.get("/api/v1/org") + assert resp.status_code == 401 + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Tests création d'organisation +# ═══════════════════════════════════════════════════════════════════════════════ + + +class TestCreateOrg: + def test_create_org_success(self, client, pro_owner): + """Un Pro peut créer une organisation.""" + resp = client.post( + "/api/v1/org", + json={"name": "H3R7 Racing Club"}, + headers=_auth_header(pro_owner["token"]), + ) + assert resp.status_code == 201 + data = resp.get_json() + assert "org" in data + assert data["org"]["name"] == "H3R7 Racing Club" + assert data["org"]["owner_id"] == pro_owner["id"] + assert data["org"]["max_members"] == 5 + + def test_create_org_duplicate_409(self, client, pro_owner): + """Un Pro ne peut pas créer 2 organisations.""" + resp = client.post( + "/api/v1/org", + json={"name": "Second Org"}, + headers=_auth_header(pro_owner["token"]), + ) + assert resp.status_code == 409 + data = resp.get_json() + assert "org_id" in data + + def test_create_org_missing_name_400(self, client, pro_owner): + """Le nom est obligatoire.""" + resp = client.post( + "/api/v1/org", + json={}, + headers=_auth_header(pro_owner["token"]), + ) + assert resp.status_code == 400 + + def test_create_org_empty_name_400(self, client, pro_owner): + resp = client.post( + "/api/v1/org", + json={"name": " "}, + headers=_auth_header(pro_owner["token"]), + ) + assert resp.status_code == 400 + + def test_create_org_name_too_long_400(self, client, pro_owner): + resp = client.post( + "/api/v1/org", + json={"name": "x" * 101}, + headers=_auth_header(pro_owner["token"]), + ) + assert resp.status_code == 400 + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Tests lecture d'organisation +# ═══════════════════════════════════════════════════════════════════════════════ + + +class TestGetOrg: + def test_get_org_as_owner(self, client, pro_owner): + resp = client.get("/api/v1/org", headers=_auth_header(pro_owner["token"])) + assert resp.status_code == 200 + data = resp.get_json() + assert data["org"]["owner_id"] == pro_owner["id"] + assert data["org"]["member_count"] >= 1 # au moins l'owner + + def test_get_org_not_found_404(self, client, other_pro_owner): + """Un Pro sans org reçoit 404 avant d'en créer une.""" + # other_pro_owner n'a pas encore d'org dans ce test + resp = client.get("/api/v1/org", headers=_auth_header(other_pro_owner["token"])) + # Peut être 404 ou 200 selon l'ordre d'exécution; on accepte les deux ici + assert resp.status_code in (200, 404) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Tests invitation de membres +# ═══════════════════════════════════════════════════════════════════════════════ + + +class TestInviteMember: + def test_invite_member_success(self, client, pro_owner, pro_user2): + resp = client.post( + "/api/v1/org/invite", + json={"email": pro_user2["email"]}, + headers=_auth_header(pro_owner["token"]), + ) + assert resp.status_code == 201 + data = resp.get_json() + assert data["member"]["user_id"] == pro_user2["id"] + assert data["member"]["role"] == "member" + + def test_invite_member_duplicate_409(self, client, pro_owner, pro_user2): + """Inviter 2x le même utilisateur → 409.""" + resp = client.post( + "/api/v1/org/invite", + json={"email": pro_user2["email"]}, + headers=_auth_header(pro_owner["token"]), + ) + assert resp.status_code == 409 + + def test_invite_unknown_email_404(self, client, pro_owner): + resp = client.post( + "/api/v1/org/invite", + json={"email": "nobody@nowhere.com"}, + headers=_auth_header(pro_owner["token"]), + ) + assert resp.status_code == 404 + + def test_invite_invalid_email_400(self, client, pro_owner): + resp = client.post( + "/api/v1/org/invite", + json={"email": "not-an-email"}, + headers=_auth_header(pro_owner["token"]), + ) + assert resp.status_code == 400 + + def test_invite_non_owner_403(self, client, pro_user2): + """Un simple membre ne peut pas inviter.""" + resp = client.post( + "/api/v1/org/invite", + json={"email": "anyone@test.com"}, + headers=_auth_header(pro_user2["token"]), + ) + assert resp.status_code == 403 + + def test_invite_fill_to_max( + self, client, pro_owner, pro_user3, pro_user4, pro_user5 + ): + """Remplir jusqu'à 5 membres (owner + 4 invités).""" + for u in (pro_user3, pro_user4, pro_user5): + resp = client.post( + "/api/v1/org/invite", + json={"email": u["email"]}, + headers=_auth_header(pro_owner["token"]), + ) + assert resp.status_code == 201, ( + f"Invitation de {u['email']} échouée: {resp.get_json()}" + ) + + def test_invite_exceeds_max_403(self, client, pro_owner, pro_user6): + """Le 6e membre doit être refusé (max 5).""" + resp = client.post( + "/api/v1/org/invite", + json={"email": pro_user6["email"]}, + headers=_auth_header(pro_owner["token"]), + ) + assert resp.status_code == 403 + data = resp.get_json() + assert "Limite" in data["error"] or "limite" in data["error"].lower() + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Tests liste des membres +# ═══════════════════════════════════════════════════════════════════════════════ + + +class TestListMembers: + def test_list_members_as_owner(self, client, pro_owner): + resp = client.get( + "/api/v1/org/members", headers=_auth_header(pro_owner["token"]) + ) + assert resp.status_code == 200 + data = resp.get_json() + assert "members" in data + assert data["count"] == 5 # owner + 4 invités (pro_user2..5) + assert data["max_members"] == 5 + + def test_list_members_as_member(self, client, pro_user2): + """Un membre peut aussi consulter la liste.""" + resp = client.get( + "/api/v1/org/members", headers=_auth_header(pro_user2["token"]) + ) + assert resp.status_code == 200 + data = resp.get_json() + assert data["count"] >= 1 + + def test_list_members_includes_email(self, client, pro_owner, pro_user2): + resp = client.get( + "/api/v1/org/members", headers=_auth_header(pro_owner["token"]) + ) + data = resp.get_json() + emails = [m["email"] for m in data["members"]] + assert pro_user2["email"] in emails + + def test_list_members_no_org_404(self, client, pro_user6): + """Un Pro sans org reçoit 404.""" + resp = client.get( + "/api/v1/org/members", headers=_auth_header(pro_user6["token"]) + ) + assert resp.status_code == 404 + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Tests suppression de membre +# ═══════════════════════════════════════════════════════════════════════════════ + + +class TestRemoveMember: + def test_remove_member_success(self, client, pro_owner, pro_user5): + resp = client.delete( + f"/api/v1/org/members/{pro_user5['id']}", + headers=_auth_header(pro_owner["token"]), + ) + assert resp.status_code == 200 + data = resp.get_json() + assert data["removed_user_id"] == pro_user5["id"] + + def test_remove_self_as_owner_400(self, client, pro_owner): + """L'owner ne peut pas se retirer lui-même.""" + resp = client.delete( + f"/api/v1/org/members/{pro_owner['id']}", + headers=_auth_header(pro_owner["token"]), + ) + assert resp.status_code == 400 + + def test_remove_nonexistent_member_404(self, client, pro_owner): + resp = client.delete( + "/api/v1/org/members/nonexistent-id-xyz", + headers=_auth_header(pro_owner["token"]), + ) + assert resp.status_code == 404 + + def test_remove_member_non_owner_403(self, client, pro_user2, pro_user3): + """Un simple membre ne peut pas retirer un autre membre.""" + resp = client.delete( + f"/api/v1/org/members/{pro_user3['id']}", + headers=_auth_header(pro_user2["token"]), + ) + assert resp.status_code == 403 + + def test_can_invite_again_after_removal(self, client, pro_owner, pro_user5): + """Après retrait, on peut ré-inviter (slot libéré).""" + resp = client.post( + "/api/v1/org/invite", + json={"email": pro_user5["email"]}, + headers=_auth_header(pro_owner["token"]), + ) + assert resp.status_code == 201 + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Tests suppression d'organisation +# ═══════════════════════════════════════════════════════════════════════════════ + + +class TestDeleteOrg: + def test_delete_org_non_owner_403(self, client, pro_user2): + """Un simple membre ne peut pas supprimer l'org.""" + resp = client.delete("/api/v1/org", headers=_auth_header(pro_user2["token"])) + assert resp.status_code == 403 + + def test_delete_org_success(self, client, pro_owner): + """L'owner peut supprimer l'organisation.""" + resp = client.delete("/api/v1/org", headers=_auth_header(pro_owner["token"])) + assert resp.status_code == 200 + data = resp.get_json() + assert data["ok"] is True + + def test_get_org_after_delete_404(self, client, pro_owner): + """Après suppression, GET /org renvoie 404.""" + resp = client.get("/api/v1/org", headers=_auth_header(pro_owner["token"])) + assert resp.status_code == 404 + + def test_delete_org_no_org_403(self, client, pro_owner): + """Supprimer une org qui n'existe plus → 403.""" + resp = client.delete("/api/v1/org", headers=_auth_header(pro_owner["token"])) + assert resp.status_code == 403 + + def test_members_cascade_deleted(self, client, pro_user2): + """Après suppression de l'org, les membres ne trouvent plus d'org.""" + resp = client.get( + "/api/v1/org/members", headers=_auth_header(pro_user2["token"]) + ) + assert resp.status_code == 404