diff --git a/tests/test_backend_coverage_gaps.py b/tests/test_backend_coverage_gaps.py new file mode 100644 index 0000000..8940374 --- /dev/null +++ b/tests/test_backend_coverage_gaps.py @@ -0,0 +1,448 @@ +"""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)