"""Unit tests for organisation and user routers — mocked database.""" import uuid from datetime import UTC, datetime from unittest.mock import AsyncMock, MagicMock import pytest from httpx import ASGITransport, AsyncClient from src.main import create_app from src.services.auth import create_access_token, hash_password ORG_ID = uuid.uuid4() USER_ID = uuid.uuid4() def _auth_headers(role="owner"): token = create_access_token( user_id=USER_ID, organisation_id=ORG_ID, role=role, email="admin@test.com" ) return {"Authorization": f"Bearer {token}"} def _mock_org(**overrides): org = MagicMock(spec=[]) org.id = overrides.get("id", ORG_ID) org.name = overrides.get("name", "Test Org") org.slug = overrides.get("slug", "test-org") org.contact_email = overrides.get("contact_email") org.billing_plan = overrides.get("billing_plan", "free") org.deleted_at = None org.created_at = datetime.now(UTC) org.updated_at = datetime.now(UTC) return org def _mock_user(**overrides): user = MagicMock(spec=[]) user.id = overrides.get("id", uuid.uuid4()) user.organisation_id = overrides.get("organisation_id", ORG_ID) user.email = overrides.get("email", "user@test.com") user.password_hash = overrides.get("password_hash", hash_password("Pass123")) user.full_name = overrides.get("full_name", "Test User") user.role = overrides.get("role", "editor") user.is_active = True user.deleted_at = None user.created_at = datetime.now(UTC) user.updated_at = datetime.now(UTC) return user def _mock_db_sequence(*results): session = AsyncMock() mock_results = [] for r in results: result = MagicMock() if isinstance(r, list): result.scalar_one_or_none.return_value = r[0] if r else None scalars_obj = MagicMock() scalars_obj.all.return_value = r result.scalars.return_value = scalars_obj else: result.scalar_one_or_none.return_value = r mock_results.append(result) session.execute = AsyncMock(side_effect=mock_results) _added = [] def _fake_add(obj): _added.append(obj) session.add = MagicMock(side_effect=_fake_add) async def _fake_flush(): for obj in _added: if getattr(obj, "id", None) is None: obj.id = uuid.uuid4() if hasattr(obj, "is_active") and getattr(obj, "is_active", None) is None: obj.is_active = True if hasattr(obj, "created_at") and getattr(obj, "created_at", None) is None: obj.created_at = datetime.now(UTC) if hasattr(obj, "updated_at") and getattr(obj, "updated_at", None) is None: obj.updated_at = datetime.now(UTC) session.flush = AsyncMock(side_effect=_fake_flush) session.refresh = AsyncMock() session.delete = AsyncMock() return session @pytest.fixture def mock_app(): return create_app() async def _client(app, mock_session): from src.db import get_db async def _override(): yield mock_session app.dependency_overrides[get_db] = _override transport = ASGITransport(app=app) return AsyncClient(transport=transport, base_url="http://test") _BOOTSTRAP_TOKEN = "test-bootstrap-token-xyz" _BOOTSTRAP_HEADERS = {"X-Admin-Bootstrap-Token": _BOOTSTRAP_TOKEN} @pytest.fixture def _bootstrap_enabled(monkeypatch): """Configure the bootstrap token so org creation is permitted.""" from src.config import settings as settings_mod monkeypatch.setattr( settings_mod.get_settings(), "admin_bootstrap_token", _BOOTSTRAP_TOKEN, ) yield class TestOrganisationRouter: @pytest.mark.asyncio async def test_create_org(self, mock_app, _bootstrap_enabled): db = _mock_db_sequence(None) # no duplicate slug async with await _client(mock_app, db) as client: resp = await client.post( "/api/v1/organisations/", json={"name": "New Org", "slug": "new-org"}, headers=_BOOTSTRAP_HEADERS, ) assert resp.status_code == 201 @pytest.mark.asyncio async def test_create_org_duplicate_slug(self, mock_app, _bootstrap_enabled): existing = _mock_org(slug="dup-slug") db = _mock_db_sequence(existing) async with await _client(mock_app, db) as client: resp = await client.post( "/api/v1/organisations/", json={"name": "Another", "slug": "dup-slug"}, headers=_BOOTSTRAP_HEADERS, ) assert resp.status_code == 409 @pytest.mark.asyncio async def test_create_org_disabled_without_token(self, mock_app): """With no ``admin_bootstrap_token`` configured, creation is forbidden.""" db = _mock_db_sequence(None) async with await _client(mock_app, db) as client: resp = await client.post( "/api/v1/organisations/", json={"name": "X", "slug": "x"}, ) assert resp.status_code == 403 @pytest.mark.asyncio async def test_create_org_wrong_token(self, mock_app, _bootstrap_enabled): """With an incorrect token, creation is unauthorised.""" db = _mock_db_sequence(None) async with await _client(mock_app, db) as client: resp = await client.post( "/api/v1/organisations/", json={"name": "X", "slug": "x"}, headers={"X-Admin-Bootstrap-Token": "wrong"}, ) assert resp.status_code == 401 @pytest.mark.asyncio async def test_get_my_org(self, mock_app): org = _mock_org() db = _mock_db_sequence(org) async with await _client(mock_app, db) as client: resp = await client.get("/api/v1/organisations/me", headers=_auth_headers()) assert resp.status_code == 200 @pytest.mark.asyncio async def test_get_my_org_not_found(self, mock_app): db = _mock_db_sequence(None) async with await _client(mock_app, db) as client: resp = await client.get("/api/v1/organisations/me", headers=_auth_headers()) assert resp.status_code == 404 @pytest.mark.asyncio async def test_update_my_org(self, mock_app): org = _mock_org() db = _mock_db_sequence(org) async with await _client(mock_app, db) as client: resp = await client.patch( "/api/v1/organisations/me", json={"name": "Updated Name"}, headers=_auth_headers(), ) assert resp.status_code == 200 @pytest.mark.asyncio async def test_update_my_org_not_found(self, mock_app): db = _mock_db_sequence(None) async with await _client(mock_app, db) as client: resp = await client.patch( "/api/v1/organisations/me", json={"name": "Updated"}, headers=_auth_headers(), ) assert resp.status_code == 404 @pytest.mark.asyncio async def test_update_my_org_requires_admin(self, mock_app): db = _mock_db_sequence() async with await _client(mock_app, db) as client: resp = await client.patch( "/api/v1/organisations/me", json={"name": "Updated"}, headers=_auth_headers(role="viewer"), ) assert resp.status_code == 403 class TestUserRouter: @pytest.mark.asyncio async def test_create_user(self, mock_app): db = _mock_db_sequence(None) # no duplicate email async with await _client(mock_app, db) as client: resp = await client.post( "/api/v1/users/", json={ "email": "new@test.com", "password": "SecurePass123", "full_name": "New User", "role": "editor", }, headers=_auth_headers(), ) assert resp.status_code == 201 @pytest.mark.asyncio async def test_create_user_duplicate_email(self, mock_app): existing = _mock_user(email="dup@test.com") db = _mock_db_sequence(existing) async with await _client(mock_app, db) as client: resp = await client.post( "/api/v1/users/", json={ "email": "dup@test.com", "password": "SecurePass123", "full_name": "Dup User", "role": "viewer", }, headers=_auth_headers(), ) assert resp.status_code == 409 @pytest.mark.asyncio async def test_list_users(self, mock_app): users = [_mock_user(), _mock_user(email="two@test.com")] db = _mock_db_sequence(users) async with await _client(mock_app, db) as client: resp = await client.get("/api/v1/users/", headers=_auth_headers()) assert resp.status_code == 200 @pytest.mark.asyncio async def test_get_user(self, mock_app): user = _mock_user() db = _mock_db_sequence(user) async with await _client(mock_app, db) as client: resp = await client.get(f"/api/v1/users/{user.id}", headers=_auth_headers()) assert resp.status_code == 200 @pytest.mark.asyncio async def test_get_user_not_found(self, mock_app): db = _mock_db_sequence(None) async with await _client(mock_app, db) as client: resp = await client.get(f"/api/v1/users/{uuid.uuid4()}", headers=_auth_headers()) assert resp.status_code == 404 @pytest.mark.asyncio async def test_update_user(self, mock_app): user = _mock_user() db = _mock_db_sequence(user) async with await _client(mock_app, db) as client: resp = await client.patch( f"/api/v1/users/{user.id}", json={"full_name": "Updated Name", "role": "admin"}, headers=_auth_headers(), ) assert resp.status_code == 200 @pytest.mark.asyncio async def test_update_user_not_found(self, mock_app): db = _mock_db_sequence(None) async with await _client(mock_app, db) as client: resp = await client.patch( f"/api/v1/users/{uuid.uuid4()}", json={"full_name": "Nope"}, headers=_auth_headers(), ) assert resp.status_code == 404 @pytest.mark.asyncio async def test_delete_user(self, mock_app): user = _mock_user(id=uuid.uuid4()) db = _mock_db_sequence(user) async with await _client(mock_app, db) as client: resp = await client.delete(f"/api/v1/users/{user.id}", headers=_auth_headers()) assert resp.status_code == 204 @pytest.mark.asyncio async def test_delete_self_rejected(self, mock_app): db = _mock_db_sequence() async with await _client(mock_app, db) as client: resp = await client.delete(f"/api/v1/users/{USER_ID}", headers=_auth_headers()) assert resp.status_code == 400 assert "yourself" in resp.json()["detail"] @pytest.mark.asyncio async def test_delete_user_not_found(self, mock_app): db = _mock_db_sequence(None) async with await _client(mock_app, db) as client: resp = await client.delete(f"/api/v1/users/{uuid.uuid4()}", headers=_auth_headers()) assert resp.status_code == 404 @pytest.mark.asyncio async def test_users_require_auth(self, mock_app): db = _mock_db_sequence() async with await _client(mock_app, db) as client: resp = await client.get("/api/v1/users/") assert resp.status_code in (401, 403) @pytest.mark.asyncio async def test_create_user_viewer_forbidden(self, mock_app): db = _mock_db_sequence() async with await _client(mock_app, db) as client: resp = await client.post( "/api/v1/users/", json={ "email": "new@test.com", "password": "SecurePass123", "role": "viewer", }, headers=_auth_headers(role="viewer"), ) assert resp.status_code == 403