test(7.8): add backend test coverage gaps (currencies, sequences, system settings, contact folders, notifications, entity history, multi-tenant isolation)
This commit is contained in:
@@ -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)
|
||||||
Reference in New Issue
Block a user