Files
leocrm/tests/test_backend_coverage_gaps.py
T

449 lines
18 KiB
Python

"""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)