"""Backend test coverage gaps — Phase 7 Task 7.8. Tests for routes that lack dedicated test coverage: - Currencies CRUD + edge cases (invalid UUID, not found, validation) - Sequences CRUD + edge cases - System settings get/upsert - Contact folders CRUD + edge cases - Notifications edge cases (invalid UUID, not found) - Entity history (not found, invalid entity) - Multi-tenant isolation (cross-tenant access returns 404/403) These tests run in CI/CD with PostgreSQL + Redis. They cannot run in this container (no PostgreSQL/Redis available). """ from __future__ import annotations import pytest from httpx import AsyncClient from tests.conftest import ORIGIN_HEADER, login_client, seed_tenant_and_users # ─── Currencies Tests ─── class TestCurrenciesRoutes: """Tests for /api/v1/currencies — CRUD and edge cases.""" @pytest.mark.asyncio async def test_list_currencies_empty_returns_200(self, client: AsyncClient, db_session): """GET /currencies returns 200 with empty list when no currencies exist.""" await seed_tenant_and_users(db_session) await login_client(client, "admin@tenanta.com") resp = await client.get("/api/v1/currencies", headers=ORIGIN_HEADER) assert resp.status_code == 200 data = resp.json() assert data["items"] == [] assert data["total"] == 0 @pytest.mark.asyncio async def test_create_currency_returns_201(self, client: AsyncClient, db_session): """POST /currencies creates a currency and returns 201.""" await seed_tenant_and_users(db_session) await login_client(client, "admin@tenanta.com") resp = await client.post( "/api/v1/currencies", json={"code": "EUR", "name": "Euro", "symbol": "€", "is_default": True}, headers=ORIGIN_HEADER, ) assert resp.status_code == 201 data = resp.json() assert data["code"] == "EUR" assert data["name"] == "Euro" assert data["symbol"] == "€" assert data["is_default"] is True @pytest.mark.asyncio async def test_create_currency_invalid_code_length_returns_422(self, client: AsyncClient, db_session): """POST /currencies with invalid code length returns 422.""" await seed_tenant_and_users(db_session) await login_client(client, "admin@tenanta.com") resp = await client.post( "/api/v1/currencies", json={"code": "EURO", "name": "Euro", "symbol": "€"}, headers=ORIGIN_HEADER, ) assert resp.status_code == 422 @pytest.mark.asyncio async def test_update_currency_invalid_uuid_returns_400(self, client: AsyncClient, db_session): """PATCH /currencies/{invalid_uuid} returns 400.""" await seed_tenant_and_users(db_session) await login_client(client, "admin@tenanta.com") resp = await client.patch( "/api/v1/currencies/not-a-uuid", json={"name": "Updated"}, headers=ORIGIN_HEADER, ) assert resp.status_code in (400, 404) @pytest.mark.asyncio async def test_delete_currency_not_found_returns_404(self, client: AsyncClient, db_session): """DELETE /currencies/{nonexistent_uuid} returns 404.""" await seed_tenant_and_users(db_session) await login_client(client, "admin@tenanta.com") resp = await client.delete( "/api/v1/currencies/00000000-0000-0000-0000-000000000000", headers=ORIGIN_HEADER, ) assert resp.status_code == 404 @pytest.mark.asyncio async def test_viewer_cannot_create_currency_returns_403(self, client: AsyncClient, db_session): """Viewer role cannot create currencies (requires currencies:write).""" await seed_tenant_and_users(db_session) await login_client(client, "viewer@tenanta.com") resp = await client.post( "/api/v1/currencies", json={"code": "USD", "name": "US Dollar", "symbol": "$"}, headers=ORIGIN_HEADER, ) assert resp.status_code == 403 # ─── Sequences Tests ─── class TestSequencesRoutes: """Tests for /api/v1/sequences — CRUD and edge cases.""" @pytest.mark.asyncio async def test_list_sequences_empty_returns_200(self, client: AsyncClient, db_session): """GET /sequences returns 200 with empty list.""" await seed_tenant_and_users(db_session) await login_client(client, "admin@tenanta.com") resp = await client.get("/api/v1/sequences", headers=ORIGIN_HEADER) assert resp.status_code == 200 data = resp.json() assert data["items"] == [] assert data["total"] == 0 @pytest.mark.asyncio async def test_create_sequence_returns_201(self, client: AsyncClient, db_session): """POST /sequences creates a sequence and returns 201.""" await seed_tenant_and_users(db_session) await login_client(client, "admin@tenanta.com") resp = await client.post( "/api/v1/sequences", json={"name": "invoice", "prefix": "RE-2026-", "padding": 4}, headers=ORIGIN_HEADER, ) assert resp.status_code == 201 data = resp.json() assert data["name"] == "invoice" assert data["prefix"] == "RE-2026-" assert data["padding"] == 4 assert data["next_number"] == 1 @pytest.mark.asyncio async def test_create_sequence_invalid_padding_returns_422(self, client: AsyncClient, db_session): """POST /sequences with padding > 10 returns 422.""" await seed_tenant_and_users(db_session) await login_client(client, "admin@tenanta.com") resp = await client.post( "/api/v1/sequences", json={"name": "test", "prefix": "", "padding": 15}, headers=ORIGIN_HEADER, ) assert resp.status_code == 422 @pytest.mark.asyncio async def test_delete_sequence_not_found_returns_404(self, client: AsyncClient, db_session): """DELETE /sequences/{nonexistent_uuid} returns 404.""" await seed_tenant_and_users(db_session) await login_client(client, "admin@tenanta.com") resp = await client.delete( "/api/v1/sequences/00000000-0000-0000-0000-000000000000", headers=ORIGIN_HEADER, ) assert resp.status_code == 404 # ─── System Settings Tests ─── class TestSystemSettingsRoutes: """Tests for /api/v1/system-settings — get and upsert.""" @pytest.mark.asyncio async def test_get_system_settings_returns_200(self, client: AsyncClient, db_session): """GET /system-settings returns 200 with default settings.""" await seed_tenant_and_users(db_session) await login_client(client, "admin@tenanta.com") resp = await client.get("/api/v1/system-settings", headers=ORIGIN_HEADER) assert resp.status_code == 200 data = resp.json() assert "company_name" in data @pytest.mark.asyncio async def test_upsert_system_settings_returns_200(self, client: AsyncClient, db_session): """PUT /system-settings upserts settings and returns 200.""" await seed_tenant_and_users(db_session) await login_client(client, "admin@tenanta.com") resp = await client.put( "/api/v1/system-settings", json={ "company_name": "Test GmbH", "company_street": "Teststr. 1", "company_city": "Berlin", "company_zip": "10115", "company_country": "DE", }, headers=ORIGIN_HEADER, ) assert resp.status_code == 200 data = resp.json() assert data["company_name"] == "Test GmbH" assert data["company_city"] == "Berlin" @pytest.mark.asyncio async def test_upsert_system_settings_missing_required_field_returns_422( self, client: AsyncClient, db_session ): """PUT /system-settings with missing required field returns 422.""" await seed_tenant_and_users(db_session) await login_client(client, "admin@tenanta.com") resp = await client.put( "/api/v1/system-settings", json={"company_name": "Test GmbH"}, # missing required fields headers=ORIGIN_HEADER, ) assert resp.status_code == 422 @pytest.mark.asyncio async def test_viewer_cannot_upsert_system_settings_returns_403( self, client: AsyncClient, db_session ): """Viewer role cannot upsert system settings (requires settings:write).""" await seed_tenant_and_users(db_session) await login_client(client, "viewer@tenanta.com") resp = await client.put( "/api/v1/system-settings", json={ "company_name": "Test", "company_street": "Test", "company_city": "Test", "company_zip": "12345", "company_country": "DE", }, headers=ORIGIN_HEADER, ) assert resp.status_code == 403 # ─── Contact Folders Tests ─── class TestContactFoldersRoutes: """Tests for /api/v1/contact-folders — CRUD and edge cases.""" @pytest.mark.asyncio async def test_list_contact_folders_empty_returns_200(self, client: AsyncClient, db_session): """GET /contact-folders returns 200 with empty list.""" await seed_tenant_and_users(db_session) await login_client(client, "admin@tenanta.com") resp = await client.get("/api/v1/contact-folders", headers=ORIGIN_HEADER) assert resp.status_code == 200 @pytest.mark.asyncio async def test_create_contact_folder_returns_201(self, client: AsyncClient, db_session): """POST /contact-folders creates a folder and returns 201.""" await seed_tenant_and_users(db_session) await login_client(client, "admin@tenanta.com") resp = await client.post( "/api/v1/contact-folders", json={"name": "VIP Customers"}, headers=ORIGIN_HEADER, ) assert resp.status_code == 201 data = resp.json() assert data["name"] == "VIP Customers" @pytest.mark.asyncio async def test_create_contact_folder_empty_name_returns_422(self, client: AsyncClient, db_session): """POST /contact-folders with empty name returns 422.""" await seed_tenant_and_users(db_session) await login_client(client, "admin@tenanta.com") resp = await client.post( "/api/v1/contact-folders", json={"name": ""}, headers=ORIGIN_HEADER, ) assert resp.status_code == 422 @pytest.mark.asyncio async def test_delete_contact_folder_not_found_returns_404(self, client: AsyncClient, db_session): """DELETE /contact-folders/{nonexistent_uuid} returns 404.""" await seed_tenant_and_users(db_session) await login_client(client, "admin@tenanta.com") resp = await client.delete( "/api/v1/contact-folders/00000000-0000-0000-0000-000000000000", headers=ORIGIN_HEADER, ) assert resp.status_code == 404 # ─── Notifications Edge Cases ─── class TestNotificationsEdgeCases: """Edge case tests for /api/v1/notifications.""" @pytest.mark.asyncio async def test_mark_notification_invalid_uuid_returns_400(self, client: AsyncClient, db_session): """PATCH /notifications/{invalid_uuid}/read returns 400.""" await seed_tenant_and_users(db_session) await login_client(client, "admin@tenanta.com") resp = await client.patch( "/api/v1/notifications/not-a-uuid/read", headers=ORIGIN_HEADER, ) assert resp.status_code in (400, 404) @pytest.mark.asyncio async def test_mark_notification_not_found_returns_404(self, client: AsyncClient, db_session): """PATCH /notifications/{nonexistent_uuid}/read returns 404.""" await seed_tenant_and_users(db_session) await login_client(client, "admin@tenanta.com") resp = await client.patch( "/api/v1/notifications/00000000-0000-0000-0000-000000000000/read", headers=ORIGIN_HEADER, ) assert resp.status_code == 404 @pytest.mark.asyncio async def test_unread_count_returns_integer(self, client: AsyncClient, db_session): """GET /notifications/unread-count returns integer count.""" await seed_tenant_and_users(db_session) await login_client(client, "admin@tenanta.com") resp = await client.get("/api/v1/notifications/unread-count", headers=ORIGIN_HEADER) assert resp.status_code == 200 data = resp.json() assert isinstance(data["count"], int) assert data["count"] >= 0 @pytest.mark.asyncio async def test_list_notification_types_returns_200(self, client: AsyncClient, db_session): """GET /notifications/types returns 200 with list.""" await seed_tenant_and_users(db_session) await login_client(client, "admin@tenanta.com") resp = await client.get("/api/v1/notifications/types", headers=ORIGIN_HEADER) assert resp.status_code == 200 # ─── Entity History Tests ─── class TestEntityHistoryRoutes: """Tests for /api/v1/entity-history — query and edge cases.""" @pytest.mark.asyncio async def test_get_entity_history_not_found_returns_200_empty( self, client: AsyncClient, db_session ): """GET /entity-history/{type}/{id} returns 200 with empty list for nonexistent entity.""" await seed_tenant_and_users(db_session) await login_client(client, "admin@tenanta.com") resp = await client.get( "/api/v1/entity-history/contact/00000000-0000-0000-0000-000000000000", headers=ORIGIN_HEADER, ) assert resp.status_code == 200 @pytest.mark.asyncio async def test_get_entity_history_invalid_uuid_returns_400( self, client: AsyncClient, db_session ): """GET /entity-history/{type}/{invalid_uuid} returns 400.""" await seed_tenant_and_users(db_session) await login_client(client, "admin@tenanta.com") resp = await client.get( "/api/v1/entity-history/contact/not-a-uuid", headers=ORIGIN_HEADER, ) assert resp.status_code in (400, 422) # ─── Multi-Tenant Isolation Tests ─── class TestMultiTenantIsolation: """Cross-tenant access tests — ensure data isolation between tenants.""" @pytest.mark.asyncio async def test_cross_tenant_contact_access_returns_404(self, client: AsyncClient, db_session): """Admin A cannot access contacts from Tenant B.""" seed = await seed_tenant_and_users(db_session) await login_client(client, "admin@tenanta.com") # Try to access company_b (belongs to tenant B) as admin_a resp = await client.get( f"/api/v1/contacts/{seed['company_b'].id}", headers=ORIGIN_HEADER, ) assert resp.status_code == 404 @pytest.mark.asyncio async def test_cross_tenant_currency_isolation(self, client: AsyncClient, db_session): """Currencies created in Tenant A are not visible to Tenant B admin.""" await seed_tenant_and_users(db_session) # Create currency as admin A await login_client(client, "admin@tenanta.com") resp = await client.post( "/api/v1/currencies", json={"code": "EUR", "name": "Euro", "symbol": "€"}, headers=ORIGIN_HEADER, ) assert resp.status_code == 201 currency_id = resp.json()["id"] # Login as admin B — clear cookies to switch session client.cookies.clear() await login_client(client, "admin@tenantb.com") resp = await client.get("/api/v1/currencies", headers=ORIGIN_HEADER) assert resp.status_code == 200 data = resp.json() # Currency from tenant A should not appear currency_ids = [c["id"] for c in data["items"]] assert currency_id not in currency_ids @pytest.mark.asyncio async def test_cross_tenant_sequence_isolation(self, client: AsyncClient, db_session): """Sequences created in Tenant A are not visible to Tenant B.""" await seed_tenant_and_users(db_session) # Create sequence as admin A await login_client(client, "admin@tenanta.com") resp = await client.post( "/api/v1/sequences", json={"name": "invoice", "prefix": "RE-", "padding": 4}, headers=ORIGIN_HEADER, ) assert resp.status_code == 201 # Login as admin B client.cookies.clear() await login_client(client, "admin@tenantb.com") resp = await client.get("/api/v1/sequences", headers=ORIGIN_HEADER) assert resp.status_code == 200 data = resp.json() # No sequences from tenant A should be visible assert data["total"] == 0 @pytest.mark.asyncio async def test_unauthenticated_access_returns_401(self, client: AsyncClient, db_session): """Unauthenticated requests to protected routes return 401.""" await seed_tenant_and_users(db_session) # No login — direct access resp = await client.get("/api/v1/currencies", headers=ORIGIN_HEADER) assert resp.status_code in (401, 403) resp = await client.get("/api/v1/sequences", headers=ORIGIN_HEADER) assert resp.status_code in (401, 403) resp = await client.get("/api/v1/system-settings", headers=ORIGIN_HEADER) assert resp.status_code in (401, 403) resp = await client.get("/api/v1/notifications", headers=ORIGIN_HEADER) assert resp.status_code in (401, 403)