feat: initial public release
ConsentOS — a privacy-first cookie consent management platform. Self-hosted, source-available alternative to OneTrust, Cookiebot, and CookieYes. Full standards coverage (IAB TCF v2.2, GPP v1, Google Consent Mode v2, GPC, Shopify Customer Privacy API), multi-tenant architecture with role-based access, configuration cascade (system → org → group → site → region), dark-pattern detection in the scanner, and a tamper-evident consent record audit trail. This is the initial public release. Prior development history is retained internally. See README.md for the feature list, architecture overview, and quick-start instructions. Licensed under the Elastic Licence 2.0 — self-host freely; do not resell as a managed service.
This commit is contained in:
277
apps/api/tests/test_routers_translations.py
Normal file
277
apps/api/tests/test_routers_translations.py
Normal file
@@ -0,0 +1,277 @@
|
||||
"""Unit tests for translations router — 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
|
||||
|
||||
ORG_ID = uuid.uuid4()
|
||||
USER_ID = uuid.uuid4()
|
||||
SITE_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_site(**overrides):
|
||||
site = MagicMock()
|
||||
site.id = overrides.get("id", SITE_ID)
|
||||
site.organisation_id = overrides.get("organisation_id", ORG_ID)
|
||||
site.domain = "example.com"
|
||||
site.display_name = "Example"
|
||||
site.is_active = True
|
||||
site.deleted_at = None
|
||||
site.additional_domains = None
|
||||
site.site_group_id = None
|
||||
site.created_at = datetime.now(UTC)
|
||||
site.updated_at = datetime.now(UTC)
|
||||
return site
|
||||
|
||||
|
||||
def _mock_translation(**overrides):
|
||||
t = MagicMock()
|
||||
t.id = overrides.get("id", uuid.uuid4())
|
||||
t.site_id = overrides.get("site_id", SITE_ID)
|
||||
t.locale = overrides.get("locale", "fr")
|
||||
t.strings = overrides.get(
|
||||
"strings",
|
||||
{"title": "Nous utilisons des cookies", "acceptAll": "Tout accepter"},
|
||||
)
|
||||
t.created_at = datetime.now(UTC)
|
||||
t.updated_at = datetime.now(UTC)
|
||||
return t
|
||||
|
||||
|
||||
@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")
|
||||
|
||||
|
||||
def _mock_db_sequence(*results):
|
||||
"""Create a mock session returning different results on successive execute() calls."""
|
||||
session = AsyncMock()
|
||||
mock_results = []
|
||||
for r in results:
|
||||
result = MagicMock()
|
||||
if isinstance(r, list):
|
||||
scalars_obj = MagicMock()
|
||||
scalars_obj.all.return_value = r
|
||||
result.scalars.return_value = scalars_obj
|
||||
result.scalar_one_or_none.return_value = r[0] if r else None
|
||||
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, "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
|
||||
|
||||
|
||||
class TestListTranslations:
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_translations(self, mock_app):
|
||||
"""GET /sites/{id}/translations/ returns all translations."""
|
||||
site = _mock_site()
|
||||
fr = _mock_translation(locale="fr")
|
||||
de = _mock_translation(locale="de")
|
||||
db = _mock_db_sequence(site, [fr, de])
|
||||
async with await _client(mock_app, db) as client:
|
||||
resp = await client.get(
|
||||
f"/api/v1/sites/{SITE_ID}/translations/",
|
||||
headers=_auth_headers(),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data) == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_translations_empty(self, mock_app):
|
||||
"""GET /sites/{id}/translations/ returns empty list when no translations."""
|
||||
site = _mock_site()
|
||||
db = _mock_db_sequence(site, [])
|
||||
async with await _client(mock_app, db) as client:
|
||||
resp = await client.get(
|
||||
f"/api/v1/sites/{SITE_ID}/translations/",
|
||||
headers=_auth_headers(),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == []
|
||||
|
||||
|
||||
class TestGetTranslation:
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_translation(self, mock_app):
|
||||
"""GET /sites/{id}/translations/fr returns the French translation."""
|
||||
site = _mock_site()
|
||||
fr = _mock_translation(locale="fr")
|
||||
db = _mock_db_sequence(site, fr)
|
||||
async with await _client(mock_app, db) as client:
|
||||
resp = await client.get(
|
||||
f"/api/v1/sites/{SITE_ID}/translations/fr",
|
||||
headers=_auth_headers(),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["locale"] == "fr"
|
||||
assert "title" in data["strings"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_translation_not_found(self, mock_app):
|
||||
"""GET /sites/{id}/translations/xx returns 404."""
|
||||
site = _mock_site()
|
||||
db = _mock_db_sequence(site, None)
|
||||
async with await _client(mock_app, db) as client:
|
||||
resp = await client.get(
|
||||
f"/api/v1/sites/{SITE_ID}/translations/xx",
|
||||
headers=_auth_headers(),
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
class TestCreateTranslation:
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_translation(self, mock_app):
|
||||
"""POST /sites/{id}/translations/ creates a new translation."""
|
||||
site = _mock_site()
|
||||
db = _mock_db_sequence(site, None) # site lookup, duplicate check
|
||||
async with await _client(mock_app, db) as client:
|
||||
resp = await client.post(
|
||||
f"/api/v1/sites/{SITE_ID}/translations/",
|
||||
json={
|
||||
"locale": "de",
|
||||
"strings": {"title": "Wir verwenden Cookies"},
|
||||
},
|
||||
headers=_auth_headers(),
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["locale"] == "de"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_translation_conflict(self, mock_app):
|
||||
"""POST returns 409 when locale already exists."""
|
||||
site = _mock_site()
|
||||
existing = _mock_translation(locale="fr")
|
||||
db = _mock_db_sequence(site, existing)
|
||||
async with await _client(mock_app, db) as client:
|
||||
resp = await client.post(
|
||||
f"/api/v1/sites/{SITE_ID}/translations/",
|
||||
json={"locale": "fr", "strings": {"title": "test"}},
|
||||
headers=_auth_headers(),
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
|
||||
|
||||
class TestUpdateTranslation:
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_translation(self, mock_app):
|
||||
"""PUT /sites/{id}/translations/fr updates the strings."""
|
||||
site = _mock_site()
|
||||
fr = _mock_translation(locale="fr")
|
||||
db = _mock_db_sequence(site, fr)
|
||||
async with await _client(mock_app, db) as client:
|
||||
resp = await client.put(
|
||||
f"/api/v1/sites/{SITE_ID}/translations/fr",
|
||||
json={"strings": {"title": "Updated title"}},
|
||||
headers=_auth_headers(),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert fr.strings == {"title": "Updated title"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_translation_not_found(self, mock_app):
|
||||
"""PUT returns 404 when locale does not exist."""
|
||||
site = _mock_site()
|
||||
db = _mock_db_sequence(site, None)
|
||||
async with await _client(mock_app, db) as client:
|
||||
resp = await client.put(
|
||||
f"/api/v1/sites/{SITE_ID}/translations/xx",
|
||||
json={"strings": {"title": "test"}},
|
||||
headers=_auth_headers(),
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
class TestDeleteTranslation:
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_translation(self, mock_app):
|
||||
"""DELETE /sites/{id}/translations/fr removes the translation."""
|
||||
site = _mock_site()
|
||||
fr = _mock_translation(locale="fr")
|
||||
db = _mock_db_sequence(site, fr)
|
||||
async with await _client(mock_app, db) as client:
|
||||
resp = await client.delete(
|
||||
f"/api/v1/sites/{SITE_ID}/translations/fr",
|
||||
headers=_auth_headers(),
|
||||
)
|
||||
assert resp.status_code == 204
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_requires_admin(self, mock_app):
|
||||
"""DELETE returns 403 for editors."""
|
||||
db = _mock_db_sequence()
|
||||
async with await _client(mock_app, db) as client:
|
||||
resp = await client.delete(
|
||||
f"/api/v1/sites/{SITE_ID}/translations/fr",
|
||||
headers=_auth_headers(role="editor"),
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
class TestPublicTranslation:
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_public_translation(self, mock_app):
|
||||
"""GET /translations/{site_id}/fr returns raw strings (no auth)."""
|
||||
fr = _mock_translation(locale="fr")
|
||||
db = _mock_db_sequence(fr)
|
||||
async with await _client(mock_app, db) as client:
|
||||
resp = await client.get(f"/api/v1/translations/{SITE_ID}/fr")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "title" in data
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_public_translation_not_found(self, mock_app):
|
||||
"""GET /translations/{site_id}/xx returns 404."""
|
||||
db = _mock_db_sequence(None)
|
||||
async with await _client(mock_app, db) as client:
|
||||
resp = await client.get(f"/api/v1/translations/{SITE_ID}/xx")
|
||||
assert resp.status_code == 404
|
||||
Reference in New Issue
Block a user