diff --git a/Makefile b/Makefile
index d303d43..1267d8f 100644
--- a/Makefile
+++ b/Makefile
@@ -1,4 +1,4 @@
-.PHONY: dev build up down migrate seed test test-frontend logs shell-api shell-db
+.PHONY: dev build up down migrate seed test test-e2e test-all logs shell-api shell-db
dev:
docker compose up --build
@@ -21,9 +21,11 @@ seed:
test:
docker compose exec api pytest tests/ -v
-test-frontend:
+test-e2e:
docker compose exec web npx cypress run
+test-all: test test-e2e
+
logs:
docker compose logs -f
diff --git a/backend/pyproject.toml b/backend/pyproject.toml
index 90a38bb..59c8e2c 100644
--- a/backend/pyproject.toml
+++ b/backend/pyproject.toml
@@ -29,8 +29,15 @@ dependencies = [
"openpyxl>=3.1",
"trafilatura>=2.0",
"arq>=0.26",
+ "pytest-asyncio>=0.25",
+ "httpx>=0.28",
]
+[tool.pytest.ini_options]
+asyncio_mode = "auto"
+testpaths = ["tests"]
+pythonpath = ["."]
+
[[tool.uv.index]]
url = "https://download.pytorch.org/whl/cpu"
diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py
new file mode 100644
index 0000000..a53dd5b
--- /dev/null
+++ b/backend/tests/conftest.py
@@ -0,0 +1,215 @@
+"""Test fixtures: async SQLite database, factory functions for models."""
+import os
+import uuid
+from datetime import datetime, timedelta, timezone
+
+import pytest
+import pytest_asyncio
+from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
+from sqlmodel import SQLModel
+
+# Force SQLite for tests (must be set before any app imports)
+os.environ.setdefault("DATABASE_URL", "sqlite+aiosqlite:///:memory:")
+os.environ.setdefault("JWT_SECRET_KEY", "test-secret-key")
+os.environ.setdefault("DEV_AUTH_PASSWORD", "testpass")
+
+# Import all SQLModel table classes so metadata is populated
+from models.sql.user import UserModel # noqa: E402
+from models.sql.client import ClientModel # noqa: E402
+from models.sql.team import TeamModel # noqa: E402
+from models.sql.team_membership import TeamMembershipModel # noqa: E402
+from models.sql.brand_config import BrandConfigModel # noqa: E402
+from models.sql.audit_log import AuditLogModel # noqa: E402
+from models.sql.presentation import PresentationModel # noqa: E402
+from models.sql.job import JobModel # noqa: E402
+from models.sql.master_deck import MasterDeckModel # noqa: E402
+
+
+_test_engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
+_test_session_maker = async_sessionmaker(_test_engine, expire_on_commit=False)
+
+
+@pytest_asyncio.fixture(autouse=True)
+async def setup_database():
+ """Create all tables before each test, drop after."""
+ async with _test_engine.begin() as conn:
+ await conn.run_sync(SQLModel.metadata.create_all)
+ yield
+ async with _test_engine.begin() as conn:
+ await conn.run_sync(SQLModel.metadata.drop_all)
+
+
+@pytest_asyncio.fixture
+async def session() -> AsyncSession:
+ async with _test_session_maker() as s:
+ yield s
+
+
+# --------------- Factory Fixtures ---------------
+
+
+@pytest_asyncio.fixture
+async def make_user(session: AsyncSession):
+ async def _factory(
+ email: str = "test@oliver.com",
+ role: str = "user",
+ display_name: str = "Test User",
+ is_active: bool = True,
+ azure_oid: str | None = None,
+ ) -> UserModel:
+ user = UserModel(
+ email=email,
+ display_name=display_name,
+ role=role,
+ is_active=is_active,
+ azure_oid=azure_oid,
+ created_at=datetime.now(timezone.utc),
+ updated_at=datetime.now(timezone.utc),
+ )
+ session.add(user)
+ await session.commit()
+ await session.refresh(user)
+ return user
+ return _factory
+
+
+@pytest_asyncio.fixture
+async def make_client(session: AsyncSession):
+ async def _factory(
+ name: str = "Acme Corp",
+ slug: str | None = None,
+ retention_days: int | None = None,
+ is_active: bool = True,
+ ) -> ClientModel:
+ client = ClientModel(
+ name=name,
+ slug=slug or name.lower().replace(" ", "-"),
+ retention_days=retention_days,
+ is_active=is_active,
+ created_at=datetime.now(timezone.utc),
+ updated_at=datetime.now(timezone.utc),
+ )
+ session.add(client)
+ await session.commit()
+ await session.refresh(client)
+ return client
+ return _factory
+
+
+@pytest_asyncio.fixture
+async def make_team(session: AsyncSession):
+ async def _factory(
+ name: str = "Dev Team",
+ client_id: uuid.UUID | None = None,
+ is_default: bool = False,
+ ) -> TeamModel:
+ team = TeamModel(
+ name=name,
+ client_id=client_id,
+ is_default=is_default,
+ created_at=datetime.now(timezone.utc),
+ )
+ session.add(team)
+ await session.commit()
+ await session.refresh(team)
+ return team
+ return _factory
+
+
+@pytest_asyncio.fixture
+async def make_membership(session: AsyncSession):
+ async def _factory(user_id: uuid.UUID, team_id: uuid.UUID) -> TeamMembershipModel:
+ membership = TeamMembershipModel(
+ user_id=user_id,
+ team_id=team_id,
+ assigned_at=datetime.now(timezone.utc),
+ )
+ session.add(membership)
+ await session.commit()
+ await session.refresh(membership)
+ return membership
+ return _factory
+
+
+@pytest_asyncio.fixture
+async def make_brand_config(session: AsyncSession):
+ async def _factory(
+ client_id: uuid.UUID,
+ primary_colors: list | None = None,
+ secondary_colors: list | None = None,
+ fonts: dict | None = None,
+ logo_paths: list | None = None,
+ voice_rules: str | None = None,
+ voice_examples: list | None = None,
+ ) -> BrandConfigModel:
+ brand = BrandConfigModel(
+ client_id=client_id,
+ primary_colors=primary_colors or ["#5146E5", "#3D35B0"],
+ secondary_colors=secondary_colors or ["#E9E8F8"],
+ fonts=fonts or {"heading": "Inter", "body": "Roboto"},
+ logo_paths=logo_paths,
+ voice_rules=voice_rules,
+ voice_examples=voice_examples,
+ created_at=datetime.now(timezone.utc),
+ updated_at=datetime.now(timezone.utc),
+ )
+ session.add(brand)
+ await session.commit()
+ await session.refresh(brand)
+ return brand
+ return _factory
+
+
+@pytest_asyncio.fixture
+async def make_presentation(session: AsyncSession):
+ async def _factory(
+ owner_id: uuid.UUID | None = None,
+ client_id: uuid.UUID | None = None,
+ title: str = "Test Deck",
+ status: str = "draft",
+ created_at: datetime | None = None,
+ deleted_at: datetime | None = None,
+ ) -> PresentationModel:
+ pres = PresentationModel(
+ content="Test content",
+ n_slides=5,
+ language="English",
+ title=title,
+ owner_id=owner_id,
+ client_id=client_id,
+ status=status,
+ created_at=created_at or datetime.now(timezone.utc),
+ updated_at=datetime.now(timezone.utc),
+ deleted_at=deleted_at,
+ )
+ session.add(pres)
+ await session.commit()
+ await session.refresh(pres)
+ return pres
+ return _factory
+
+
+@pytest_asyncio.fixture
+async def make_job(session: AsyncSession):
+ async def _factory(
+ user_id: uuid.UUID,
+ client_id: uuid.UUID,
+ job_type: str = "generate_presentation",
+ status: str = "queued",
+ progress: int = 0,
+ presentation_id: uuid.UUID | None = None,
+ ) -> JobModel:
+ job = JobModel(
+ user_id=user_id,
+ client_id=client_id,
+ job_type=job_type,
+ status=status,
+ progress=progress,
+ presentation_id=presentation_id,
+ created_at=datetime.now(timezone.utc),
+ )
+ session.add(job)
+ await session.commit()
+ await session.refresh(job)
+ return job
+ return _factory
diff --git a/backend/tests/test_access_service.py b/backend/tests/test_access_service.py
new file mode 100644
index 0000000..9b9c80c
--- /dev/null
+++ b/backend/tests/test_access_service.py
@@ -0,0 +1,73 @@
+"""Tests for RBAC access_service: client access by role and team membership."""
+import uuid
+
+import pytest
+
+from services.access_service import get_accessible_client_ids, get_accessible_clients
+
+
+class TestSuperAdminAccess:
+ async def test_super_admin_sees_all_active_clients(
+ self, session, make_user, make_client
+ ):
+ admin = await make_user(email="admin@test.com", role="super_admin")
+ c1 = await make_client(name="Client A", slug="client-a")
+ c2 = await make_client(name="Client B", slug="client-b")
+ # Inactive client should be excluded
+ await make_client(name="Inactive", slug="inactive", is_active=False)
+
+ ids = await get_accessible_client_ids(admin, session)
+ assert set(ids) == {c1.id, c2.id}
+
+ async def test_super_admin_gets_full_client_objects(
+ self, session, make_user, make_client
+ ):
+ admin = await make_user(email="admin@test.com", role="super_admin")
+ await make_client(name="X Corp", slug="x-corp")
+
+ clients = await get_accessible_clients(admin, session)
+ assert len(clients) == 1
+ assert clients[0].name == "X Corp"
+
+
+class TestRegularUserAccess:
+ async def test_user_sees_only_team_linked_clients(
+ self, session, make_user, make_client, make_team, make_membership
+ ):
+ user = await make_user(email="user@test.com", role="user")
+ c1 = await make_client(name="My Client", slug="my-client")
+ c2 = await make_client(name="Other Client", slug="other-client")
+
+ team = await make_team(name="Team A", client_id=c1.id)
+ await make_membership(user_id=user.id, team_id=team.id)
+
+ ids = await get_accessible_client_ids(user, session)
+ assert c1.id in ids
+ assert c2.id not in ids
+
+ async def test_user_with_no_teams_sees_nothing(
+ self, session, make_user, make_client
+ ):
+ user = await make_user(email="lonely@test.com", role="user")
+ await make_client(name="Some Corp", slug="some-corp")
+
+ ids = await get_accessible_client_ids(user, session)
+ assert ids == []
+
+ clients = await get_accessible_clients(user, session)
+ assert clients == []
+
+ async def test_user_sees_multiple_clients_via_multiple_teams(
+ self, session, make_user, make_client, make_team, make_membership
+ ):
+ user = await make_user(email="multi@test.com", role="user")
+ c1 = await make_client(name="Client 1", slug="client-1")
+ c2 = await make_client(name="Client 2", slug="client-2")
+
+ t1 = await make_team(name="Team 1", client_id=c1.id)
+ t2 = await make_team(name="Team 2", client_id=c2.id)
+ await make_membership(user_id=user.id, team_id=t1.id)
+ await make_membership(user_id=user.id, team_id=t2.id)
+
+ ids = await get_accessible_client_ids(user, session)
+ assert set(ids) == {c1.id, c2.id}
diff --git a/backend/tests/test_analytics.py b/backend/tests/test_analytics.py
new file mode 100644
index 0000000..07896bc
--- /dev/null
+++ b/backend/tests/test_analytics.py
@@ -0,0 +1,99 @@
+"""Tests for analytics endpoints: verify query logic returns correct aggregations."""
+import uuid
+from datetime import datetime, timedelta, timezone
+
+import pytest
+
+from models.sql.job import JobModel
+from models.sql.presentation import PresentationModel
+
+
+class TestAnalyticsData:
+ """Test that analytics data can be correctly queried from our models."""
+
+ async def test_presentation_count(self, session, make_client, make_user, make_presentation):
+ user = await make_user(email="ana@test.com")
+ client = await make_client(name="Ana Corp", slug="ana-corp")
+
+ await make_presentation(owner_id=user.id, client_id=client.id, title="Deck 1")
+ await make_presentation(owner_id=user.id, client_id=client.id, title="Deck 2")
+ await make_presentation(owner_id=user.id, client_id=client.id, title="Deck 3")
+
+ from sqlmodel import select, func
+ result = await session.execute(
+ select(func.count()).select_from(PresentationModel).where(
+ PresentationModel.client_id == client.id,
+ PresentationModel.deleted_at.is_(None),
+ )
+ )
+ count = result.scalar()
+ assert count == 3
+
+ async def test_status_distribution(self, session, make_client, make_user, make_presentation):
+ user = await make_user(email="dist@test.com")
+ client = await make_client(name="Dist Corp", slug="dist-corp")
+
+ await make_presentation(owner_id=user.id, client_id=client.id, status="draft")
+ await make_presentation(owner_id=user.id, client_id=client.id, status="draft")
+ await make_presentation(owner_id=user.id, client_id=client.id, status="in_review")
+ await make_presentation(owner_id=user.id, client_id=client.id, status="approved")
+
+ from sqlmodel import select, func
+ result = await session.execute(
+ select(
+ PresentationModel.status,
+ func.count().label("cnt")
+ ).where(
+ PresentationModel.client_id == client.id,
+ PresentationModel.deleted_at.is_(None),
+ ).group_by(PresentationModel.status)
+ )
+ dist = {row[0]: row[1] for row in result.all()}
+ assert dist["draft"] == 2
+ assert dist["in_review"] == 1
+ assert dist["approved"] == 1
+
+ async def test_job_status_distribution(
+ self, session, make_client, make_user, make_job
+ ):
+ user = await make_user(email="job@test.com")
+ client = await make_client(name="Job Corp", slug="job-corp")
+
+ await make_job(user_id=user.id, client_id=client.id, status="completed")
+ await make_job(user_id=user.id, client_id=client.id, status="completed")
+ await make_job(user_id=user.id, client_id=client.id, status="failed")
+
+ from sqlmodel import select, func
+ result = await session.execute(
+ select(
+ JobModel.status,
+ func.count().label("cnt")
+ ).where(
+ JobModel.client_id == client.id
+ ).group_by(JobModel.status)
+ )
+ dist = {row[0]: row[1] for row in result.all()}
+ assert dist["completed"] == 2
+ assert dist["failed"] == 1
+
+ async def test_deleted_presentations_excluded(
+ self, session, make_client, make_user, make_presentation
+ ):
+ user = await make_user(email="del@test.com")
+ client = await make_client(name="Del Corp", slug="del-corp")
+
+ await make_presentation(owner_id=user.id, client_id=client.id, title="Active")
+ await make_presentation(
+ owner_id=user.id, client_id=client.id, title="Deleted",
+ deleted_at=datetime.now(timezone.utc),
+ )
+
+ from sqlmodel import select, func
+ result = await session.execute(
+ select(func.count()).select_from(PresentationModel).where(
+ PresentationModel.client_id == client.id,
+ PresentationModel.deleted_at.is_(None),
+ )
+ )
+ count = result.scalar()
+ assert count == 1
diff --git a/backend/tests/test_audit_service.py b/backend/tests/test_audit_service.py
new file mode 100644
index 0000000..b4592b5
--- /dev/null
+++ b/backend/tests/test_audit_service.py
@@ -0,0 +1,108 @@
+"""Tests for audit_service: query, export_csv, export_json."""
+import uuid
+from datetime import datetime, timezone
+
+import pytest
+
+from models.sql.audit_log import AuditLogModel
+from services import audit_service
+
+
+class TestAuditQuery:
+ async def test_query_returns_entries(self, session):
+ entry = AuditLogModel(
+ user_id=uuid.uuid4(),
+ action="create",
+ resource_type="presentation",
+ resource_id=uuid.uuid4(),
+ created_at=datetime.now(timezone.utc),
+ )
+ session.add(entry)
+ await session.commit()
+
+ logs = await audit_service.query(session)
+ assert len(logs) == 1
+ assert logs[0].action == "create"
+
+ async def test_query_filter_by_action(self, session):
+ uid = uuid.uuid4()
+ for action in ["create", "update", "delete"]:
+ session.add(AuditLogModel(
+ user_id=uid,
+ action=action,
+ resource_type="presentation",
+ created_at=datetime.now(timezone.utc),
+ ))
+ await session.commit()
+
+ logs = await audit_service.query(session, action="delete")
+ assert len(logs) == 1
+ assert logs[0].action == "delete"
+
+ async def test_query_filter_by_user_id(self, session):
+ uid1 = uuid.uuid4()
+ uid2 = uuid.uuid4()
+ session.add(AuditLogModel(
+ user_id=uid1, action="create", resource_type="deck",
+ created_at=datetime.now(timezone.utc),
+ ))
+ session.add(AuditLogModel(
+ user_id=uid2, action="create", resource_type="deck",
+ created_at=datetime.now(timezone.utc),
+ ))
+ await session.commit()
+
+ logs = await audit_service.query(session, user_id=uid1)
+ assert len(logs) == 1
+
+ async def test_query_pagination(self, session):
+ uid = uuid.uuid4()
+ for i in range(5):
+ session.add(AuditLogModel(
+ user_id=uid, action=f"action_{i}", resource_type="test",
+ created_at=datetime.now(timezone.utc),
+ ))
+ await session.commit()
+
+ page1 = await audit_service.query(session, limit=2, offset=0)
+ page2 = await audit_service.query(session, limit=2, offset=2)
+ assert len(page1) == 2
+ assert len(page2) == 2
+
+
+class TestAuditExport:
+ def test_export_csv(self):
+ entries = [
+ AuditLogModel(
+ id=uuid.uuid4(),
+ user_id=uuid.uuid4(),
+ action="export",
+ resource_type="presentation",
+ created_at=datetime.now(timezone.utc),
+ )
+ ]
+ csv_str = audit_service.export_csv(entries)
+ assert "export" in csv_str
+ assert "presentation" in csv_str
+ # Header row
+ assert "id,user_id,action" in csv_str
+
+ def test_export_json(self):
+ entries = [
+ AuditLogModel(
+ id=uuid.uuid4(),
+ user_id=uuid.uuid4(),
+ action="login",
+ resource_type="session",
+ details={"method": "dev"},
+ created_at=datetime.now(timezone.utc),
+ )
+ ]
+ json_str = audit_service.export_json(entries)
+ assert '"action": "login"' in json_str
+ assert '"method": "dev"' in json_str
+
+ def test_export_csv_empty(self):
+ csv_str = audit_service.export_csv([])
+ lines = csv_str.strip().split("\n")
+ assert len(lines) == 1 # Only header
diff --git a/backend/tests/test_auth_service.py b/backend/tests/test_auth_service.py
new file mode 100644
index 0000000..6081f56
--- /dev/null
+++ b/backend/tests/test_auth_service.py
@@ -0,0 +1,78 @@
+"""Tests for auth_service: JWT creation/validation, dev login, user creation."""
+import uuid
+
+import pytest
+
+from services.auth_service import AuthService
+
+
+@pytest.fixture
+def auth_service():
+ return AuthService()
+
+
+class TestJWT:
+ def test_create_and_validate_token(self, auth_service, make_user):
+ """Token round-trip: create → validate → same payload."""
+ from models.sql.user import UserModel
+
+ user = UserModel(
+ id=uuid.uuid4(),
+ email="jwt@test.com",
+ display_name="JWT User",
+ role="user",
+ is_active=True,
+ )
+ token = auth_service.create_session_jwt(user)
+ payload = auth_service.validate_token(token)
+
+ assert payload is not None
+ assert payload["sub"] == str(user.id)
+ assert payload["email"] == "jwt@test.com"
+ assert payload["role"] == "user"
+
+ def test_validate_invalid_token(self, auth_service):
+ assert auth_service.validate_token("garbage.token.here") is None
+
+ def test_validate_tampered_token(self, auth_service):
+ from models.sql.user import UserModel
+
+ user = UserModel(
+ id=uuid.uuid4(),
+ email="tamper@test.com",
+ display_name="Tamper",
+ role="user",
+ is_active=True,
+ )
+ token = auth_service.create_session_jwt(user)
+ # Tamper with the token
+ tampered = token[:-5] + "XXXXX"
+ assert auth_service.validate_token(tampered) is None
+
+
+class TestDevLogin:
+ async def test_dev_login_success(self, auth_service, session):
+ """Dev mode: correct password creates user."""
+ user = await auth_service.dev_login("dev@test.com", "testpass", session)
+ assert user is not None
+ assert user.email == "dev@test.com"
+
+ async def test_dev_login_wrong_password(self, auth_service, session):
+ user = await auth_service.dev_login("dev@test.com", "wrongpass", session)
+ assert user is None
+
+ async def test_dev_login_idempotent(self, auth_service, session):
+ """Logging in twice with same email returns same user."""
+ user1 = await auth_service.dev_login("dev@test.com", "testpass", session)
+ user2 = await auth_service.dev_login("dev@test.com", "testpass", session)
+ assert user1.id == user2.id
+
+
+class TestDevMode:
+ def test_is_dev_mode_when_no_azure(self, auth_service):
+ """Without AZURE_AD_TENANT_ID, service is in dev mode."""
+ assert auth_service.is_dev_mode is True
+
+ def test_get_authorization_url_raises_in_dev_mode(self, auth_service):
+ with pytest.raises(ValueError, match="dev login"):
+ auth_service.get_authorization_url()
diff --git a/backend/tests/test_brand_enforcement.py b/backend/tests/test_brand_enforcement.py
new file mode 100644
index 0000000..af0f785
--- /dev/null
+++ b/backend/tests/test_brand_enforcement.py
@@ -0,0 +1,168 @@
+"""Tests for BrandEnforcementService: color utilities, font/color/logo enforcement."""
+import uuid
+
+import pytest
+
+from models.pptx_models import (
+ PptxAutoShapeBoxModel,
+ PptxChartBoxModel,
+ PptxFillModel,
+ PptxFontModel,
+ PptxParagraphModel,
+ PptxPositionModel,
+ PptxPresentationModel,
+ PptxSlideModel,
+ PptxTextBoxModel,
+ PptxTextRunModel,
+)
+from models.sql.brand_config import BrandConfigModel
+from pptx.enum.shapes import MSO_AUTO_SHAPE_TYPE
+from services.brand_enforcement_service import (
+ BrandEnforcementService,
+ _contrast_ratio,
+ _hex_to_rgb,
+ _relative_luminance,
+)
+
+
+@pytest.fixture
+def svc():
+ return BrandEnforcementService()
+
+
+@pytest.fixture
+def brand() -> BrandConfigModel:
+ return BrandConfigModel(
+ client_id=uuid.uuid4(),
+ primary_colors=["#FF0000", "#00FF00"],
+ secondary_colors=["#0000FF"],
+ fonts={"heading": "Montserrat", "body": "Open Sans"},
+ logo_paths=["/logos/acme.png"],
+ )
+
+
+class TestColorUtilities:
+ def test_hex_to_rgb_six_chars(self):
+ assert _hex_to_rgb("FF0000") == (255, 0, 0)
+ assert _hex_to_rgb("00FF00") == (0, 255, 0)
+
+ def test_hex_to_rgb_with_hash(self):
+ assert _hex_to_rgb("#0000FF") == (0, 0, 255)
+
+ def test_hex_to_rgb_three_chars(self):
+ assert _hex_to_rgb("F00") == (255, 0, 0)
+
+ def test_white_has_high_luminance(self):
+ assert _relative_luminance("FFFFFF") > 0.9
+
+ def test_black_has_low_luminance(self):
+ assert _relative_luminance("000000") < 0.01
+
+ def test_contrast_ratio_black_vs_white(self):
+ white_lum = _relative_luminance("FFFFFF")
+ black_lum = _relative_luminance("000000")
+ ratio = _contrast_ratio(white_lum, black_lum)
+ assert ratio > 20 # Should be ~21:1
+
+
+class TestBrandColors:
+ def test_get_brand_colors_list(self, svc, brand):
+ colors = svc.get_brand_colors_list(brand)
+ assert "FF0000" in colors
+ assert "00FF00" in colors
+ assert "0000FF" in colors
+
+ def test_fallback_when_no_colors(self, svc):
+ brand = BrandConfigModel(
+ client_id=uuid.uuid4(),
+ primary_colors=None,
+ secondary_colors=None,
+ )
+ colors = svc.get_brand_colors_list(brand)
+ assert len(colors) > 0 # Defaults
+ assert "4472C4" in colors
+
+
+class TestEnforceOnPptxModel:
+ def _make_text_slide(self, font_name="Arial", font_size=14):
+ return PptxSlideModel(shapes=[
+ PptxTextBoxModel(
+ position=PptxPositionModel(left=10, top=10, width=200, height=50),
+ paragraphs=[
+ PptxParagraphModel(
+ text="Hello World",
+ font=PptxFontModel(name=font_name, size=font_size, color="000000"),
+ )
+ ],
+ )
+ ])
+
+ def test_fonts_replaced_with_brand_fonts(self, svc, brand):
+ model = PptxPresentationModel(slides=[
+ self._make_text_slide("Arial", 32), # Title slide (idx=0) → heading font
+ self._make_text_slide("Times", 14), # Content slide → body font
+ ])
+ result = svc.enforce_on_pptx_model(model, brand)
+ # Slide 0 (title): heading font
+ assert result.slides[0].shapes[0].paragraphs[0].font.name == "Montserrat"
+ # Slide 1 (content): body font
+ assert result.slides[1].shapes[0].paragraphs[0].font.name == "Open Sans"
+
+ def test_logo_added_to_content_slides_only(self, svc, brand):
+ model = PptxPresentationModel(slides=[
+ self._make_text_slide(), # Title slide — no logo
+ self._make_text_slide(), # Content slide — should get logo
+ self._make_text_slide(), # Another content slide
+ ])
+ result = svc.enforce_on_pptx_model(model, brand)
+
+ # Slide 0: no logo shape added (still 1 shape)
+ assert len(result.slides[0].shapes) == 1
+ # Slides 1-2: logo added (2 shapes each)
+ assert len(result.slides[1].shapes) == 2
+ assert len(result.slides[2].shapes) == 2
+
+ def test_no_logo_when_no_logo_paths(self, svc):
+ brand_no_logo = BrandConfigModel(
+ client_id=uuid.uuid4(),
+ fonts={"heading": "Inter", "body": "Inter"},
+ logo_paths=None,
+ )
+ model = PptxPresentationModel(slides=[
+ self._make_text_slide(),
+ self._make_text_slide(),
+ ])
+ result = svc.enforce_on_pptx_model(model, brand_no_logo)
+ assert len(result.slides[1].shapes) == 1
+
+ def test_chart_gets_brand_colors(self, svc, brand):
+ chart = PptxChartBoxModel(
+ position=PptxPositionModel(left=0, top=0, width=400, height=300),
+ chart_type="bar",
+ chart_data={"categories": ["A"], "series": [{"name": "S", "values": [1]}]},
+ )
+ model = PptxPresentationModel(slides=[
+ PptxSlideModel(shapes=[chart]),
+ ])
+ result = svc.enforce_on_pptx_model(model, brand)
+ assert result.slides[0].shapes[0].brand_colors is not None
+ assert "FF0000" in result.slides[0].shapes[0].brand_colors
+
+ def test_contrast_fix_white_on_white(self, svc, brand):
+ """White text on white bg should be auto-corrected to black."""
+ slide = PptxSlideModel(shapes=[
+ PptxTextBoxModel(
+ position=PptxPositionModel(left=0, top=0, width=100, height=50),
+ paragraphs=[
+ PptxParagraphModel(
+ text="Invisible",
+ font=PptxFontModel(name="Arial", size=14, color="FFFFFF"),
+ )
+ ],
+ )
+ ])
+ # Default bg is white (FFFFFF)
+ model = PptxPresentationModel(slides=[slide])
+ result = svc.enforce_on_pptx_model(model, brand)
+ # After contrast fix, text should be dark
+ assert result.slides[0].shapes[0].paragraphs[0].font.color == "000000"
diff --git a/backend/tests/test_content_intelligence.py b/backend/tests/test_content_intelligence.py
new file mode 100644
index 0000000..8ae538f
--- /dev/null
+++ b/backend/tests/test_content_intelligence.py
@@ -0,0 +1,103 @@
+"""Tests for ContentIntelligenceService: rule-based content classification."""
+import re
+
+import pytest
+
+from services.content_intelligence_service import (
+ _COMPARISON_RE,
+ _IMAGE_REF_RE,
+ _LIST_RE,
+ _METRIC_RE,
+ _QUOTE_RE,
+ _TABLE_RE,
+ _TIMELINE_RE,
+)
+
+
+class TestMetricRegex:
+ @pytest.mark.parametrize("text", [
+ "$2.3M revenue",
+ "45% growth",
+ "1,200 units",
+ "revenue grew 45%",
+ "profit increased by $2M",
+ "ROI of 340%",
+ "CAGR 12%",
+ ])
+ def test_detects_metrics(self, text):
+ assert _METRIC_RE.search(text), f"Failed to detect metric: {text}"
+
+ @pytest.mark.parametrize("text", [
+ "The cat sat on the mat",
+ "We had a meeting yesterday",
+ ])
+ def test_rejects_non_metrics(self, text):
+ assert not _METRIC_RE.search(text)
+
+
+class TestQuoteRegex:
+ def test_detects_quoted_text(self):
+ text = '"Innovation is the ability to see change as an opportunity" — John Doe'
+ assert _QUOTE_RE.search(text)
+
+ def test_detects_smart_quotes(self):
+ text = '\u201cThis is a quoted statement\u201d'
+ assert _QUOTE_RE.search(text)
+
+ def test_rejects_short_quotes(self):
+ text = '"Hi"'
+ assert not _QUOTE_RE.search(text)
+
+
+class TestTableRegex:
+ def test_detects_markdown_table(self):
+ text = "| Name | Value |\n| --- | --- |\n| A | 1 |"
+ assert _TABLE_RE.search(text)
+
+ def test_rejects_non_table(self):
+ text = "This is just normal text"
+ assert not _TABLE_RE.search(text)
+
+
+class TestTimelineRegex:
+ @pytest.mark.parametrize("text", [
+ "In 2023, we launched the product",
+ "Q1 results were strong",
+ "January 2024 earnings",
+ ])
+ def test_detects_timeline(self, text):
+ assert _TIMELINE_RE.search(text)
+
+
+class TestComparisonRegex:
+ @pytest.mark.parametrize("text", [
+ "Plan A vs. Plan B",
+ "compared to last year",
+ "in contrast to competitors",
+ "on the other hand, they chose",
+ ])
+ def test_detects_comparison(self, text):
+ assert _COMPARISON_RE.search(text)
+
+
+class TestListRegex:
+ def test_detects_bullet_list(self):
+ text = "- Item one\n- Item two\n- Item three"
+ matches = _LIST_RE.findall(text)
+ assert len(matches) == 3
+
+ def test_detects_asterisk_list(self):
+ text = "* First\n* Second"
+ assert _LIST_RE.search(text)
+
+
+class TestImageRefRegex:
+ @pytest.mark.parametrize("text", [
+ "See figure 1 below",
+ "see diagram for details",
+ "image.png",
+ "",
+ "attached image shows",
+ ])
+ def test_detects_image_references(self, text):
+ assert _IMAGE_REF_RE.search(text)
diff --git a/backend/tests/test_retention_service.py b/backend/tests/test_retention_service.py
new file mode 100644
index 0000000..59a671d
--- /dev/null
+++ b/backend/tests/test_retention_service.py
@@ -0,0 +1,127 @@
+"""Tests for RetentionService: cleanup and purge logic.
+
+Uses mocked async_session_maker since RetentionService creates its own sessions.
+"""
+import uuid
+from datetime import datetime, timedelta, timezone
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from models.sql.client import ClientModel
+from models.sql.presentation import PresentationModel
+from services.retention_service import RetentionService
+
+
+@pytest.fixture
+def svc():
+ return RetentionService()
+
+
+class TestRunCleanup:
+ async def test_soft_deletes_expired_presentations(
+ self, session, make_client, make_presentation, make_user
+ ):
+ """Presentations older than client.retention_days get soft-deleted."""
+ client = await make_client(name="RetCorp", slug="retcorp", retention_days=30)
+ user = await make_user(email="ret@test.com")
+
+ # Old presentation (60 days ago) — should be deleted
+ old = await make_presentation(
+ owner_id=user.id,
+ client_id=client.id,
+ title="Old Deck",
+ created_at=datetime.now(timezone.utc) - timedelta(days=60),
+ )
+ # Recent presentation — should survive
+ recent = await make_presentation(
+ owner_id=user.id,
+ client_id=client.id,
+ title="New Deck",
+ created_at=datetime.now(timezone.utc) - timedelta(days=5),
+ )
+
+ # Mock async_session_maker to use our test session
+ with patch("services.retention_service.async_session_maker") as mock_sm:
+ mock_sm.return_value.__aenter__ = AsyncMock(return_value=session)
+ mock_sm.return_value.__aexit__ = AsyncMock(return_value=False)
+
+ result = await svc.run_cleanup()
+
+ assert result["deleted"] == 1
+
+ await session.refresh(old)
+ await session.refresh(recent)
+ assert old.deleted_at is not None
+ assert recent.deleted_at is None
+
+ async def test_no_clients_with_retention_policy(self, session, make_client):
+ """Clients without retention_days are skipped."""
+ await make_client(name="NoPol", slug="nopol", retention_days=None)
+
+ with patch("services.retention_service.async_session_maker") as mock_sm:
+ mock_sm.return_value.__aenter__ = AsyncMock(return_value=session)
+ mock_sm.return_value.__aexit__ = AsyncMock(return_value=False)
+
+ result = await RetentionService().run_cleanup()
+
+ assert result["deleted"] == 0
+
+
+class TestPurgeSoftDeleted:
+ async def test_purges_old_soft_deleted(
+ self, session, make_client, make_presentation, make_user
+ ):
+ """Soft-deleted presentations older than threshold get permanently deleted."""
+ client = await make_client(name="Purge Corp", slug="purge-corp")
+ user = await make_user(email="purge@test.com")
+
+ # Soft-deleted 45 days ago — should be purged
+ pres = await make_presentation(
+ owner_id=user.id,
+ client_id=client.id,
+ title="Dead Deck",
+ deleted_at=datetime.now(timezone.utc) - timedelta(days=45),
+ )
+
+ with patch("services.retention_service.async_session_maker") as mock_sm:
+ mock_sm.return_value.__aenter__ = AsyncMock(return_value=session)
+ mock_sm.return_value.__aexit__ = AsyncMock(return_value=False)
+
+ result = await RetentionService().purge_soft_deleted(days_after_soft_delete=30)
+
+ assert result["purged"] == 1
+
+ # Should be gone from DB
+ deleted = await session.get(PresentationModel, pres.id)
+ assert deleted is None
+
+
+class TestCleanupFiles:
+ def test_cleanup_files_removes_existing_files(self, svc, tmp_path):
+ """_cleanup_files removes files listed in presentation.file_paths."""
+ f = tmp_path / "test.pptx"
+ f.write_text("dummy")
+
+ pres = MagicMock()
+ pres.id = uuid.uuid4()
+ pres.file_paths = [str(f)]
+
+ svc._cleanup_files(pres)
+ assert not f.exists()
+
+ def test_cleanup_files_handles_missing_files(self, svc):
+ """Missing files don't raise errors."""
+ pres = MagicMock()
+ pres.id = uuid.uuid4()
+ pres.file_paths = ["/nonexistent/file.pptx"]
+
+ # Should not raise
+ svc._cleanup_files(pres)
+
+ def test_cleanup_files_handles_none(self, svc):
+ pres = MagicMock()
+ pres.id = uuid.uuid4()
+ pres.file_paths = None
+
+ svc._cleanup_files(pres)
diff --git a/backend/tests/test_review_workflow.py b/backend/tests/test_review_workflow.py
new file mode 100644
index 0000000..cdb728d
--- /dev/null
+++ b/backend/tests/test_review_workflow.py
@@ -0,0 +1,35 @@
+"""Tests for review workflow: status transitions and validation."""
+import pytest
+
+from api.v1.ppt.endpoints.review import VALID_TRANSITIONS, VALID_STATUSES
+
+
+class TestStatusTransitions:
+ def test_valid_statuses(self):
+ assert VALID_STATUSES == {"draft", "in_review", "approved"}
+
+ def test_draft_can_go_to_in_review(self):
+ assert "in_review" in VALID_TRANSITIONS["draft"]
+
+ def test_draft_cannot_go_to_approved_directly(self):
+ assert "approved" not in VALID_TRANSITIONS["draft"]
+
+ def test_in_review_can_go_to_approved(self):
+ assert "approved" in VALID_TRANSITIONS["in_review"]
+
+ def test_in_review_can_go_back_to_draft(self):
+ assert "draft" in VALID_TRANSITIONS["in_review"]
+
+ def test_approved_can_go_back_to_draft(self):
+ assert "draft" in VALID_TRANSITIONS["approved"]
+
+ def test_approved_cannot_go_to_in_review(self):
+ assert "in_review" not in VALID_TRANSITIONS["approved"]
+
+ def test_all_statuses_have_transition_rules(self):
+ for status in VALID_STATUSES:
+ assert status in VALID_TRANSITIONS
+
+ def test_no_self_transitions(self):
+ for status, targets in VALID_TRANSITIONS.items():
+ assert status not in targets, f"{status} should not transition to itself"
diff --git a/backend/tests/test_slide_mapping.py b/backend/tests/test_slide_mapping.py
new file mode 100644
index 0000000..900d562
--- /dev/null
+++ b/backend/tests/test_slide_mapping.py
@@ -0,0 +1,34 @@
+"""Tests for SlideMappingEngine: block-to-layout type mapping."""
+import pytest
+
+from models.content_models import ContentBlock, ContentBlockType
+from services.slide_mapping_engine import _BLOCK_TO_LAYOUT_TYPE
+
+
+class TestBlockToLayoutMapping:
+ """Verify every content block type has layout mappings defined."""
+
+ def test_all_block_types_have_mappings(self):
+ for bt in ContentBlockType:
+ assert bt in _BLOCK_TO_LAYOUT_TYPE, (
+ f"ContentBlockType.{bt.value} missing from _BLOCK_TO_LAYOUT_TYPE"
+ )
+
+ def test_metric_prefers_metrics_layout(self):
+ assert _BLOCK_TO_LAYOUT_TYPE[ContentBlockType.metric][0] == "metrics"
+
+ def test_quote_prefers_quote_layout(self):
+ assert _BLOCK_TO_LAYOUT_TYPE[ContentBlockType.quote][0] == "quote"
+
+ def test_table_prefers_table_layout(self):
+ assert _BLOCK_TO_LAYOUT_TYPE[ContentBlockType.table][0] == "table"
+
+ def test_timeline_prefers_timeline_layout(self):
+ assert _BLOCK_TO_LAYOUT_TYPE[ContentBlockType.timeline][0] == "timeline"
+
+ def test_every_mapping_has_content_fallback(self):
+ """Each block type should have 'content' as a fallback layout."""
+ for bt, layouts in _BLOCK_TO_LAYOUT_TYPE.items():
+ assert "content" in layouts, (
+ f"ContentBlockType.{bt.value} has no 'content' fallback in layout mapping"
+ )
diff --git a/frontend/cypress.config.ts b/frontend/cypress.config.ts
index 2bb1e5a..2b30778 100644
--- a/frontend/cypress.config.ts
+++ b/frontend/cypress.config.ts
@@ -7,4 +7,12 @@ export default defineConfig({
bundler: "webpack",
},
},
+ e2e: {
+ baseUrl: "http://localhost:3000",
+ supportFile: "cypress/support/e2e.ts",
+ specPattern: "cypress/e2e/**/*.cy.{ts,tsx}",
+ viewportWidth: 1280,
+ viewportHeight: 720,
+ defaultCommandTimeout: 10000,
+ },
});
diff --git a/frontend/cypress/e2e/admin.cy.ts b/frontend/cypress/e2e/admin.cy.ts
new file mode 100644
index 0000000..a5ec62f
--- /dev/null
+++ b/frontend/cypress/e2e/admin.cy.ts
@@ -0,0 +1,30 @@
+describe("Admin Panel", () => {
+ beforeEach(() => {
+ cy.devLogin("admin@oliver.com");
+ });
+
+ it("loads admin dashboard", () => {
+ cy.visit("/admin");
+ cy.contains("Admin").should("be.visible");
+ });
+
+ it("navigates to users page", () => {
+ cy.visit("/admin/users");
+ cy.contains("Users").should("be.visible");
+ });
+
+ it("navigates to clients page", () => {
+ cy.visit("/admin/clients");
+ cy.contains("Clients").should("be.visible");
+ });
+
+ it("navigates to analytics page", () => {
+ cy.visit("/admin/analytics");
+ cy.contains("Analytics").should("be.visible");
+ });
+
+ it("navigates to audit page", () => {
+ cy.visit("/admin/audit");
+ cy.contains("Audit").should("be.visible");
+ });
+});
diff --git a/frontend/cypress/e2e/login.cy.ts b/frontend/cypress/e2e/login.cy.ts
new file mode 100644
index 0000000..3810791
--- /dev/null
+++ b/frontend/cypress/e2e/login.cy.ts
@@ -0,0 +1,25 @@
+describe("Login Flow", () => {
+ it("shows login page when not authenticated", () => {
+ cy.visit("/dashboard");
+ // Should redirect to login
+ cy.url().should("include", "/login");
+ });
+
+ it("dev login succeeds and redirects to dashboard", () => {
+ cy.visit("/login");
+ cy.get('input[type="email"]').type("admin@oliver.com");
+ cy.get('input[type="password"]').type("devpass123");
+ cy.get('button[type="submit"]').click();
+ // Should redirect to dashboard after successful login
+ cy.url().should("include", "/dashboard");
+ });
+
+ it("dev login fails with wrong password", () => {
+ cy.visit("/login");
+ cy.get('input[type="email"]').type("admin@oliver.com");
+ cy.get('input[type="password"]').type("wrongpassword");
+ cy.get('button[type="submit"]').click();
+ // Should stay on login page
+ cy.url().should("include", "/login");
+ });
+});
diff --git a/frontend/cypress/e2e/review-workflow.cy.ts b/frontend/cypress/e2e/review-workflow.cy.ts
new file mode 100644
index 0000000..b412a5a
--- /dev/null
+++ b/frontend/cypress/e2e/review-workflow.cy.ts
@@ -0,0 +1,15 @@
+describe("Review Workflow", () => {
+ beforeEach(() => {
+ cy.devLogin("admin@oliver.com");
+ });
+
+ it("shows review status badge on presentation page", () => {
+ // Assume a presentation exists (created by seed or setup)
+ cy.createPresentation("Review Test Deck");
+ cy.visit("/dashboard");
+ // Click on the first presentation
+ cy.get("[class*='cursor-pointer']").first().click();
+ // Review badge should be visible
+ cy.contains(/draft|in review|approved/i).should("be.visible");
+ });
+});
diff --git a/frontend/cypress/e2e/wizard.cy.ts b/frontend/cypress/e2e/wizard.cy.ts
new file mode 100644
index 0000000..2411683
--- /dev/null
+++ b/frontend/cypress/e2e/wizard.cy.ts
@@ -0,0 +1,32 @@
+describe("Generation Wizard", () => {
+ beforeEach(() => {
+ cy.devLogin("admin@oliver.com");
+ });
+
+ it("navigates through wizard steps", () => {
+ cy.visit("/generate/upload");
+ // Step 1: Upload page loads
+ cy.contains("Upload Your Content").should("be.visible");
+
+ // Add brief text
+ cy.get("textarea").type("This is a test presentation about AI in healthcare.");
+
+ // Click Next
+ cy.contains("button", "Next").click();
+
+ // Step 2: Configure page
+ cy.url().should("include", "/generate/configure");
+ cy.contains("Slide Count").should("be.visible");
+ });
+
+ it("preserves wizard state via localStorage", () => {
+ cy.visit("/generate/upload");
+ cy.get("textarea").type("State persistence test");
+
+ // Reload page
+ cy.reload();
+
+ // Brief text should be restored from localStorage
+ cy.get("textarea").should("have.value", "State persistence test");
+ });
+});
diff --git a/frontend/cypress/support/e2e.ts b/frontend/cypress/support/e2e.ts
new file mode 100644
index 0000000..f81f826
--- /dev/null
+++ b/frontend/cypress/support/e2e.ts
@@ -0,0 +1,39 @@
+///
+
+// Custom commands for DeckForge E2E tests
+
+Cypress.Commands.add("devLogin", (email = "admin@oliver.com") => {
+ cy.request("POST", "/api/v1/auth/dev-login", {
+ email,
+ password: Cypress.env("DEV_AUTH_PASSWORD") || "devpass123",
+ }).then((response) => {
+ const { token } = response.body;
+ window.localStorage.setItem("deckforge_token", token);
+ });
+});
+
+Cypress.Commands.add("createPresentation", (title = "E2E Test Deck") => {
+ const token = window.localStorage.getItem("deckforge_token");
+ cy.request({
+ method: "POST",
+ url: "/api/v1/ppt/presentation/create",
+ headers: { Authorization: `Bearer ${token}` },
+ body: {
+ content: "Test brief content for E2E testing.",
+ n_slides: 5,
+ language: "English",
+ title,
+ },
+ });
+});
+
+declare global {
+ namespace Cypress {
+ interface Chainable {
+ devLogin(email?: string): Chainable;
+ createPresentation(title?: string): Chainable;
+ }
+ }
+}
+
+export {};
diff --git a/frontend/package.json b/frontend/package.json
index 44168a3..60ba28d 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -7,7 +7,9 @@
"dev": "next dev ",
"build": "next build",
"start": "next start",
- "lint": "next lint"
+ "lint": "next lint",
+ "test:e2e": "cypress run",
+ "test:e2e:open": "cypress open"
},
"dependencies": {
"@babel/standalone": "^7.28.2",