Skip to content

Backend Architecture

Overview

The backend is a FastAPI application providing email services and API endpoints.

Technology Stack

  • Framework: FastAPI
  • Language: Python 3.11
  • ASGI Server: Uvicorn
  • Validation: Pydantic v2 + pydantic-settings
  • Email: SMTP (Gmail) via Jinja2-rendered HTML templates
  • Rate Limiting: slowapi (in-memory, keyed by IP; single-replica only)
  • Database ORM: SQLAlchemy 2 (async), asyncpg driver
  • Migrations: Alembic
  • Logging: loguru (structured JSON in production, pretty in dev)

Project Structure

The backend follows an ArjanCodes-inspired layered architecture (PRs #370, #375, #377, #379, #380, #381): core → schemas → repositories → services → routers. main.py is now a ~150-line create_app() factory that registers routers, middleware, and exception handlers. See Architecture Pattern below for the design rationale.

api/
├── main.py              # ~150-line create_app() factory
├── feature_flags.py     # Azure App Config feature flag cache (30 s TTL)
├── requirements.txt     # Python dependencies
├── __init__.py
├── core/
│   ├── config.py        # Settings (pydantic-settings); `settings` singleton imported everywhere
│   ├── exceptions.py    # PortfolioError, NotFoundError, ConflictError, ServiceUnavailableError
│   │                    # + register_exception_handlers()
│   ├── limiter.py       # Shared slowapi Limiter instance (in-memory, single-replica)
│   ├── logging.py       # configure_logging() — loguru + InterceptHandler for stdlib/uvicorn
│   ├── middleware.py    # RequestIdMiddleware — reads/generates X-Request-Id header
│   └── openapi.py       # custom_openapi() — x-uigen-id / x-uigen-view annotations
├── db/
│   ├── engine.py        # Async engine + get_db() + get_optional_db()
│   └── models.py        # Project, ProjectPhase, WorkEntry, SkillCategory, Skill,
│                        # Award, Certification, UigenToken
├── routers/
│   ├── admin.py         # Write endpoints (bearer-token protected), mounted at /api
│   ├── auth.py          # POST /auth/login/aad, GET /auth/me — registered at /api/auth AND /auth
│   ├── awards.py        # GET /api/awards
│   ├── certifications.py # GET /api/certifications
│   ├── contact.py       # POST /api/contact
│   ├── cv.py            # GET /api/cv/status, PATCH /api/cv/generate
│   ├── experience.py    # GET /api/experience[/{index}]
│   ├── feature_flags.py # GET + PATCH /api/feature-flags
│   ├── health.py        # GET /health (infra probe), GET /api/health
│   ├── projects.py      # GET /api/projects[/{slug}]
│   ├── skills.py        # GET /api/skills
│   ├── sync.py          # POST /api/admin/sync-certifications
│   └── uigen.py         # GET /api/uigen-token
├── repositories/
│   ├── awards.py        # list_awards(db) — DB or seed fallback
│   ├── certifications.py # list_certifications(db) — DB or seed fallback
│   ├── experience.py    # list_entries(db), get_by_id(db, id) — DB or seed fallback
│   ├── projects.py      # list_cards(db), get_by_slug(db, slug) — selectinload(Project.phases)
│   └── skills.py        # list_categories(db) — DB or seed fallback
├── schemas/
│   ├── experience.py    # Create/update request schemas for experience
│   ├── projects.py      # Create/update request schemas + ProjectPhaseCreate
│   ├── responses.py     # 11 Pydantic v2 response schemas (from_attributes=True):
│   │                    # ProjectCard, ProjectDetail, ProjectPhase, WorkEntry, Skill,
│   │                    # SkillCategory, Award, Certification, ContactResponse,
│   │                    # HealthResponse, FeatureFlagsResponse
│   └── skills.py        # Create/update request schemas for skills
├── services/
│   ├── credentials_sync.py  # Credly / Databricks / MS Learn fetch + upsert
│   ├── cv.py            # generate_cv() via asyncio.create_subprocess_exec
│   ├── email.py         # EmailService (Jinja2 + SMTP)
│   └── uigen_tokens.py  # issue(), validate(), revoke() — SHA-256 hashed, DB-backed
├── templates/
│   ├── contact_admin.html  # Jinja2 template — notification email to portfolio owner
│   └── contact_user.html   # Jinja2 template — confirmation email to form submitter
├── jobs/
│   └── sync_certifications.py  # ACA job entrypoint
└── data/
    ├── seed.json        # Canonical static fallback for all content (projects, experience,
    │                    # skills, awards, certifications)
    └── seed.py          # lru_cache loader: load_projects(), load_work_entries(), etc.
alembic/
└── versions/
    ├── 0001_initial_schema.py
    ├── 0002_seed_initial_data.py
    ├── 0003_add_iac_platform_project.py
    ├── 0004_add_awards_certifications.py
    ├── 0005_project_phases_table.py   # Replaces timeline_encoded ARRAY with child table
    ├── 0006_uigen_tokens_table.py     # uigen_tokens table for DB-backed token store
    ├── 0007_add_metrics_counters.py       # CV download + generation OTel counters
    ├── 0008_update_experience_wb_hai.py   # Add W+B DevOps role, update Databricks to completed
    ├── 0009_split_albron_hai_update_wb_devops.py  # Split Albron/HAI, expand W+B DevOps entry
    ├── 0010_update_wb_role_title.py
    └── 0011_add_certification_sync_fields.py

API Endpoints

Health Check

python
GET /api/health     # Primary — accessible via nginx proxy, included in OpenAPI schema
GET /health         # Internal — for infra probes only, excluded from OpenAPI schema
Response: {"status": "healthy", "message": "Portfolio API is running"}

Contact Form

python
POST /api/contact
Request: {
  "name": "string",
  "email": "string",
  "message": "string",
  "company": "string" (optional)
}
Response: {
  "success": true,
  "message": "Thank you for your message!"
}

CV Download

python
GET /api/download-cv?lang=english|nederlands
Response: PDF file (application/pdf)
# Returns 404 if the PDF file is not present in the container

CV PDFs are copied into the container at build time (client/public/*.pdf → /app/public/). The lang query parameter selects English or Dutch. Defaults to english if omitted or unrecognised.

Projects

python
GET /api/projects                   # list of project cards (from database)
GET /api/projects/{project_id}      # detail by slug (e.g. "genai-framework")
# Returns 404 if the slug is not found

Experience

python
GET /api/experience                 # list of work history entries (from database)
GET /api/experience?top=N           # return only the N most recent entries
GET /api/experience/{index}         # single entry by 0-based index
# Returns 404 if index is out of range

Skills

python
GET /api/skills                     # list of skill categories with nested skills (from database)

Awards and Certifications

python
GET /api/awards                     # list of awards (from database)
GET /api/certifications             # list of certifications (from database)

Data for all groups is read from PostgreSQL via the SQLAlchemy async ORM (see Database section below). See API Endpoints for full request/response shapes.

Certification responses include source (credly/databricks/microsoft/manual), badgeImageUrl, issuedAt, and expiresAt for synced entries. Sort order: manual entries first (by sort_order), then synced by source group, each sorted by issued_at DESC.

Admin API

The admin write endpoints are protected by a bearer token enforced at the FastAPI router level via dependencies=[Depends(_require_admin)]. They share the same /api/ prefix as the read endpoints — there is no separate /api/admin/ prefix.

python
# Projects (admin)
POST   /api/projects                    # create a project record
PUT    /api/projects/{slug}             # replace a project (full update)
PATCH  /api/projects/{slug}             # partial update a project
DELETE /api/projects/{slug}             # delete a project
PATCH  /api/projects/reorder            # set sort order for all projects

# Experience (admin)
POST   /api/experience                  # create a work entry
PUT    /api/experience/{entry_id}       # replace a work entry (full update)
PATCH  /api/experience/{entry_id}       # partial update a work entry
DELETE /api/experience/{entry_id}       # delete a work entry
PATCH  /api/experience/reorder          # set sort order for all entries

# Skills (admin)
POST   /api/skills/categories                        # create a skill category
PUT    /api/skills/categories/{category_id}          # replace a skill category
PATCH  /api/skills/categories/{category_id}          # partial update a skill category
DELETE /api/skills/categories/{category_id}          # delete a skill category
POST   /api/skills/categories/{category_id}/skills   # add a skill to a category
PUT    /api/skills/{skill_id}                        # replace a skill
PATCH  /api/skills/{skill_id}                        # partial update a skill
DELETE /api/skills/{skill_id}                        # delete a skill

# All admin routes require: Authorization: Bearer <api-admin-token>

The admin token is stored in Azure Key Vault (api-admin-token secret) and injected as an environment variable via Bicep. These endpoints are used for seeding and updating database content without direct DB access.

The entry_id path parameter for experience is a UUID (matching the database primary key), not a 0-based index. The read endpoint GET /api/experience/{index} still uses a 0-based integer index.

python
# Credential sync (admin)
POST /api/admin/sync-certifications   # trigger Credly + Databricks + MS Learn sync
# Returns: {"synced": {"credly": N, "microsoft": N, "databricks": N}, "errors": [...]}
# Rate limited: 10/hour

Credential Sync

POST /api/admin/sync-certifications triggers a full credential sync from three sources:

Source Method Auth
Credly Public JSON API (credly.com/users/sven-relijveld/badges.json) None
Databricks Playwright headless Chromium intercepts Accredible wallet API response None (browser UA + request signing handled by browser)
Microsoft Learn OAuth refresh token stored in Key Vault (mslearn-refresh-token) Entra delegated — see scripts/get_mslearn_token.py for one-time setup

Sync runs automatically every Monday 06:00 UTC via an ACA scheduled job (dna-{env}-portfolio-we-job-cert-sync) that reuses the backend image with entrypoint python -m api.jobs.sync_certifications. Rows with sync_enabled=False are never overwritten (used for the three manual fallback entries).

UIGen Token

python
GET /api/uigen-token
# No Authorization header → issues a guest token (read-only + contact/CV)
# Valid Bearer <api-admin-token> → echoes back as admin token (full access)
# Invalid Bearer → 401
Response: {"token": "<token>", "role": "guest|admin"}

The AdminModal in the frontend calls this endpoint to get a scoped token, then opens https://admin.sven-relijveld.com/auth?token=<token>. The UIGen auth shim writes the token to sessionStorage["uigen_auth"] and redirects to UIGen root. Guest tokens expire after 1 hour.

Token storage is handled by api/services/uigen_tokens.py (issue(), validate(), revoke()). Tokens are stored as SHA-256 hashes in the uigen_tokens database table (migration 0006) when a DB is available, falling back to an in-process dict when the DB is unavailable (local dev / DB down). The DB-backed store survives container restarts and works correctly with multi-replica deployments; the in-process fallback is single-replica only.

Feature Flags

python
GET  /api/feature-flags
# Response: {"cvGeneration": true/false, "portfolioContentApi": true/false, "heroRoleCycler": true/false, "projectPageV2": true/false}

PATCH /api/feature-flags
# Request: {"cvGeneration": true/false, "portfolioContentApi": true/false, "heroRoleCycler": true/false, "projectPageV2": true/false}
# Requires: Authorization: Bearer <api-admin-token>
# Response: updated flag state

GET reads current flag values from Azure App Configuration (dna-portfolio-we-appcs). The backend uses managed identity (App Configuration Data Reader RBAC) with a 30-second TTL cache. Labels are environment-specific (dev/prod). Falls back to the FEATURE_CV_GENERATION env var if App Config is unavailable.

PATCH writes flag updates back to Azure App Configuration via ff.set_flag(). The admin bearer token is required. Both flags are optional in the request body — only supplied fields are updated. The backend MI requires App Configuration Data Owner RBAC (not just Data Reader) on the App Configuration store for writes to succeed. The 30-second TTL cache is explicitly busted after a successful PATCH so the next GET reflects the updated values immediately.

Flag Key in App Config Frontend effect
cvGeneration cv-generation Shows CV generate button in CV download dialog
portfolioContentApi portfolio-content-api Fetches projects/experience/skills from the API instead of static data
heroRoleCycler hero-role-cycler Enables the hero section role cycler UI feature
projectPageV2 project-page-v2 Gates project-page-v2 route (V2 project detail pages backed by Strapi CMS)

CV Generation (feature-flagged)

python
GET   /api/cv/status                # returns {"enabled": true/false}
PATCH /api/cv/generate              # triggers Bun publishpipe build, returns PDF
# Request body: {"lang": "english" | "nederlands"}
# Only available when cv-generation feature flag is enabled in Azure App Config

The CV generation pipeline (cv/build.ts) uses Bun + pagedjs-cli to render a custom Nunjucks template into PDF. The PATCH method is used rather than GET because UIGen's view-hint classifier maps PATCH without a path parameter to an action button, which renders correctly as a generate button in the admin portal. Controlled by the cv-generation feature flag in Azure App Configuration (dev=on, prod=on as of v0.10.0).

Rate Limiting

All endpoints are rate-limited via slowapi (keyed by client IP). Limits: contact 5/min, CV download 10/min, read endpoints 60/min. Exceeding a limit returns 429 {"detail": "Rate limit exceeded"}.

Database

The backend uses Azure PostgreSQL Flexible Server (production) with SQLAlchemy 2 async and asyncpg as the driver. Alembic manages schema migrations.

Models

Model Table Description
Project projects Portfolio project cards and detail pages
ProjectPhase project_phases Ordered timeline phases for a project (FK to projects, added in migration 0005)
WorkEntry work_entries Professional experience timeline entries
SkillCategory skill_categories Skill groups with icon names and color metadata
Skill skills Individual skills, FK to skill_categories
Award awards Awards and recognition entries
Certification certifications Professional certifications with link, icon, and sync metadata (source, external_id, badge_image_url, issued_at, expires_at, last_synced_at, sync_enabled)
UigenToken uigen_tokens SHA-256 hashed UIGen guest/admin tokens with expiry (added in migration 0006)

All tables use UUID primary keys generated by the database (gen_random_uuid()). Skill has a cascade-delete foreign key to SkillCategory. ProjectPhase has a cascade-delete foreign key to Project. Project.phases is loaded via selectinload to avoid N+1 queries on project list endpoints.

Connection

api/db/engine.py creates an async SQLAlchemy engine via _make_engine(database_url, use_managed_identity):

  • USE_MANAGED_IDENTITY=true (Azure): uses ManagedIdentityCredential from azure-identity to fetch a short-lived AAD token and passes it as the asyncpg password callable. The MANAGED_IDENTITY_CLIENT_ID env var (set by Bicep) is passed to ManagedIdentityCredential(client_id=...) to target the specific user-assigned identity.
  • USE_MANAGED_IDENTITY=false (local): uses a plain username/password from DATABASE_URL (docker-compose postgres service).

Important: The DATABASE_URL username must be the MI's principal name (e.g. dna-prd-portfolio-we-id-be), not its client ID UUID. PostgreSQL AAD authentication matches on the principal name. The value is written to Key Vault by the deploy workflow using the MI's principal name.

Two async generator dependencies are provided for route handlers:

  • get_db() — raises ServiceUnavailableError (→ 503) if the engine is not configured. Used by write endpoints (admin) and any read endpoint with no static fallback.
  • get_optional_db() — yields AsyncSession | None; returns None when the engine is not configured. Used by read endpoints that fall back to api/data/seed.json via api/data/seed.py.

Migrations

bash
# Apply all pending migrations
cd api && alembic upgrade head

# Generate a new migration after model changes
cd api && alembic revision --autogenerate -m "description"

alembic/env.py uses _make_engine() (not a bare create_async_engine) so Alembic uses the same AAD token logic as the app runtime when USE_MANAGED_IDENTITY=true.

Architecture Pattern

The backend uses an ArjanCodes-inspired layered architecture. The dependency flow is strictly one-directional:

core (config, exceptions, logging, middleware)
schemas (Pydantic v2 request + response models)
repositories (DB queries; fall back to seed.json when DB unavailable)
services (EmailService, CvService, UigenTokenService)
routers (thin handlers: parse → call repo/service → return schema)

Key design decisions:

  • No generic Repository[T] ABC — each aggregate has plain async def functions in its own module. Generic bases only pay off with multiple backend implementations; we have one (PostgreSQL via SQLAlchemy).
  • No DI container — FastAPI's Depends() covers every injection need.
  • Domain exceptions translate at the edge — repositories raise NotFoundError, ConflictError, etc.; register_exception_handlers() in api/core/exceptions.py maps them to HTTP status codes. Handlers focus on the happy path only.
  • DB-or-fallback in repositories, not in handlers — each read repository function accepts db: AsyncSession | None; when None, it returns data from api/data/seed.json via seed.py. Pydantic v2 from_attributes=True schemas validate both ORM rows and plain dicts without a special case at the router edge.
  • main.py is a thin factorycreate_app() registers routers, middleware, and exception handlers; module-level side effects are limited to app = create_app().

See docs/planning/plan-api-arjan-refactor.md (archived) for the full rationale and ArjanCodes reference map.

Structured Logging and Request IDs

Logging (Phase 6, PR #381)

api/core/logging.py provides configure_logging(), called once in create_app():

  • Installs an InterceptHandler on the root stdlib logger so that uvicorn, SQLAlchemy, and all third-party libraries funnel through loguru.
  • In production (settings.environment != "development"): JSON sink to stdout — structured output suitable for Azure Monitor / Log Analytics.
  • In development: pretty sink with color and human-readable timestamps.

Request ID Middleware (Phase 6, PR #381)

api/core/middleware.py provides RequestIdMiddleware:

  • On each incoming request: reads X-Request-Id from the request headers; if absent, generates a new UUID v4.
  • Binds the request ID to a contextvars.ContextVar so it is accessible anywhere in the call stack (repositories, services) without explicit threading.
  • Echoes the ID back in the response X-Request-Id header.
  • Loguru's context filter pulls the current request ID into every log line emitted during request handling, enabling full log correlation by request ID in Log Analytics.

Usage: clients that set X-Request-Id on outbound requests (e.g. the frontend, load balancers) will see their ID round-tripped in the response. Without a client-supplied ID the middleware generates one transparently.

Email Service

Features

  • Dual Emails: Notification + Confirmation
  • HTML Templates: Professional branded emails
  • Error Handling: Graceful fallbacks
  • Logging: Comprehensive error logging

Email Flow

sequenceDiagram participant User participant Frontend participant Backend participant Gmail participant Recipient User->>Frontend: Submit contact form Frontend->>Backend: POST /api/contact Backend->>Gmail: Send notification email Gmail->>Recipient: Deliver to sven.relijveld@onedna.nl Backend->>Gmail: Send confirmation email Gmail->>User: Deliver confirmation Backend->>Frontend: Success response Frontend->>User: Show success message

SMTP Configuration

python
SMTP_SERVER = "smtp.gmail.com"
SMTP_PORT = 587
EMAIL_ADDRESS = os.getenv("EMAIL_ADDRESS")
EMAIL_PASSWORD = os.getenv("EMAIL_PASSWORD")  # App Password

Security

Secrets Management

  • Azure Key Vault: Production secrets
  • Environment Variables: Development
  • No Hardcoding: All secrets externalized

CORS Configuration

python
app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],  # Configured per environment
    allow_credentials=False,
    allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
    allow_headers=["*"],
)

Docker Build

dockerfile
# Stage 1: Bun + cv/ workspace (publishpipe for on-demand CV generation)
FROM oven/bun:1-slim AS bun-stage
WORKDIR /app/cv
COPY cv/package.json cv/bun.lock* ./
RUN bun install --frozen-lockfile

# Stage 2: Python runtime
FROM python:3.11-slim

# System deps: gcc for C extensions, Chromium for pagedjs-cli and Playwright,
# plus Playwright's required system libraries, and Node.js for pagedjs-cli runtime
RUN apt-get update && apt-get install -y --no-install-recommends \
    gcc \
    chromium \
    chromium-common \
    libnss3 \
    libatk-bridge2.0-0 \
    libdrm2 \
    libxkbcommon0 \
    libgbm1 \
    libasound2 \
    nodejs \
    && rm -rf /var/lib/apt/lists/*

WORKDIR /app
COPY api/requirements.txt .

# Install Python deps + Playwright Chromium at a fixed path accessible to appuser
ENV PLAYWRIGHT_BROWSERS_PATH=/opt/playwright-browsers
RUN pip install --no-cache-dir --upgrade pip && \
    pip install --no-cache-dir -r requirements.txt && \
    playwright install chromium && \
    chmod -R 755 /opt/playwright-browsers

# Copy Bun binary and cv/ workspace from bun-stage
COPY --from=bun-stage /usr/local/bin/bun /usr/local/bin/bun
COPY --from=bun-stage /app/cv /app/cv
COPY cv/build.ts /app/cv/build.ts
COPY cv/content /app/cv/content
COPY cv/templates /app/cv/templates
COPY cv/assets /app/cv/assets

COPY api/ ./api/

# CV PDFs for /api/download-cv
COPY client/public/*.pdf ./public/

# Tell pagedjs-cli / puppeteer where the system Chromium is
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium

RUN useradd -m -u 1001 appuser && chown -R appuser:appuser /app
USER appuser

EXPOSE 8000

HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
  CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1

# Skip Alembic when DATABASE_URL is not set (e.g. the cert-sync ACA job entrypoint)
CMD ["sh", "-c", "if [ -n \"$DATABASE_URL\" ]; then n=0; until alembic -c api/alembic.ini upgrade head; do n=$((n+1)); [ $n -ge 5 ] && echo 'Alembic failed after 5 attempts' && exit 1; echo \"Alembic attempt $n failed, retrying in 15s...\"; sleep 15; done; else echo 'DATABASE_URL not set — skipping Alembic migrations'; fi && uvicorn api.main:app --host 0.0.0.0 --port 8000 --workers 1"]

Performance

  • Async/Await: Non-blocking I/O throughout (FastAPI + SQLAlchemy async + asyncpg)
  • Connection Pooling: SQLAlchemy async engine manages the DB connection pool; efficient SMTP connections for email
  • Monitoring: Application Insights integration

Error Handling

SMTPAuthenticationError is caught and logged but does not fail the request — the API always returns success: true so the contact form shows a success message regardless of email delivery. This prevents credential misconfigurations from breaking the UX.

python
try:
    # Email sending logic
    return {"success": True}
except SMTPAuthenticationError:
    logger.error("Authentication failed")
    return {"success": True}  # always succeed from the user's perspective
except Exception as e:
    logger.error(f"Unexpected error: {e}")
    raise HTTPException(status_code=500)

Testing

Tests are split across two files and share a conftest.py that sets required environment variables before api.main is imported.

tests/conftest.py — sets EMAIL_ADDRESS and EMAIL_PASSWORD defaults so pydantic-settings can instantiate without real credentials.

tests/test_api_endpoints.py — uses FastAPI's TestClient with smtplib.SMTP mocked via unittest.mock.patch. A reset_rate_limiter autouse fixture clears slowapi state before each test. No running server is required.

Test What it asserts
test_health GET /api/health → 200, status: healthy
test_contact_valid POST /api/contact → 200, success: true
test_contact_with_optional_company Company field accepted
test_contact_invalid_email Invalid email → 422
test_contact_missing_* Missing required fields → 422
test_email_uses_real_credentials Non-placeholder creds → login() called
test_email_demo_mode_skips_login Placeholder creds → login() not called
test_email_auth_error_does_not_fail_request SMTPAuthenticationError → still success: true
test_both_notification_and_confirmation_sent smtplib.SMTP constructed twice
test_projects_returns_list GET /api/projects → list of 6 cards
test_project_detail_valid GET /api/projects/genai-framework → correct detail
test_project_detail_not_found Unknown slug → 404
test_experience_returns_list GET /api/experience → list of entries (count varies with seed data)
test_experience_entry_valid GET /api/experience/0 → Witteveen+Bos entry
test_experience_entry_not_found Out-of-range index → 404
test_skills_returns_list GET /api/skills → list of 5 categories
test_contact_rate_limit 6th POST /api/contact within a minute → 429

tests/test_cv_download.py — tests the /api/download-cv endpoint by patching CV_FILES to use a temporary PDF on disk.

Test What it asserts
test_download_cv_english GET /api/download-cv → 200, application/pdf, correct filename
test_download_cv_nederlands GET /api/download-cv?lang=nederlands → Dutch CV PDF
test_download_cv_defaults_to_english_for_unknown_lang Unknown lang param falls back to English
test_download_cv_returns_404_when_file_missing Missing file on disk → 404, CV file not found

tests/test_cv_generation.py — tests the feature-flagged CV generation endpoints.

Test What it asserts
test_cv_status_disabled GET /api/cv/status{"enabled": false} when flag off
test_cv_status_enabled GET /api/cv/status{"enabled": true} when flag on
test_cv_generate_disabled GET /api/cv/generate → 404 when flag off

tests/test_cv_files_exist.py — validates that CV PDF files are present in the container's public/ directory.

Test What it asserts
test_english_cv_file_exists public/CV_Sven_Relijveld_English.pdf is present
test_nederlands_cv_file_exists public/CV_Sven_Relijveld_Nederlands.pdf is present

Running locally:

bash
pytest tests/ -v

CI: test.yml runs on PR (jobs: api, unit, typecheck, links); test-backend job in deploy-pr-preview.yml runs after each preview deploy.

See Also

Page history

Field Value
Last updated 2026-07-03

Changelog

Date PRs Summary
2026-03-03 #55 Add frontmatter; fix SMTPAuthenticationError handling to show success:true (always returns success); add Testing section Incorrect error handling example that returned success:false for SMTPAuthenticationError
2026-03-04 #56 Add GET /api/download-cv endpoint; update Docker Build snippet to include PDF COPY step
2026-03-20 #85 Add projects, experience, skills endpoints and rate limiting; add slowapi to tech stack; expand testing table to 25 tests Inaccurate 'Caching: Response caching where applicable' from Performance section
2026-03-22 #89 Add GET /api/health as the primary health route (proxied through nginx); GET /health retained for infra probes but excluded from OpenAPI schema; add dev server to OpenAPI servers block
2026-04-12 #181 Add PostgreSQL database layer: SQLAlchemy async ORM, Alembic migrations, asyncpg driver; update endpoints to reflect DB-backed data; add Database section Inaccurate statement that projects/experience/skills data is hard-coded in main.py
2026-04-13 #183 Fix Docker CMD snippet to include alembic migration step and non-root user; add test_cv_download.py and conftest.py to Testing section; update pytest run command
2026-04-17 Add Admin API section (POST /api/admin/*); add CV Generation section (feature-flagged endpoints); add test_cv_generation.py and test_cv_files_exist.py to Testing table; update CI reference from test-api.yml to test.yml
2026-04-21 #206, #207, #247, #243, #253, #254 Add UIGen token endpoint, feature-flags endpoint, Feature Flags section (Azure App Config); update CV Generation to reflect App Config backing; update Database connection section with MANAGED_IDENTITY_CLIENT_ID and Alembic token fix; update project structure for api/routers/ and api/feature_flags.py Inaccurate statement that CV_GENERATION_ENABLED is a direct env var (now App Config feature flag)
2026-04-26 #265, #267, #269, #270, #273, #274, #277, #299, #300, #304 Update Database connection section: replace IMDS urllib with azure-identity ManagedIdentityCredential; fix Postgres username to MI principal name (not UUID); update Docker CMD to show Alembic retry; add API annotations note to UIGen Token section; add feature-flags retry note; update Technology Stack zod version Inaccurate description of IMDS token fetch using raw urllib (now uses azure-identity)
2026-05-03 #358, #362 Remove /admin/ prefix from all admin write endpoints (now /api/projects, /api/experience, /api/skills/ — bearer token still enforced); add awards and certifications read endpoints; add portfolioContentApi to feature-flags; add PATCH /api/feature-flags admin endpoint; update DB models table to include Award, Certification, Skill; update project structure; update CV Generation method from GET to PATCH _Stale /api/admin/ prefix on admin endpoints; GET /api/cv/generate replaced by PATCH /api/cv/generate_
2026-05-04 #370 Update Project Structure section to reflect Phase 1 refactor: api/core/ (config, openapi, exception/logging placeholders), api/templates/ (Jinja2 email templates), api/repositories/, api/services/, api/data/ skeleton packages Flat project structure listing that did not show core/, templates/, repositories/, services/, data/ packages
2026-05-05 #375, #377, #379, #380, #381 Full Arjan refactor Phases 2–6: Pydantic v2 response schemas (Phase 2); per-domain routers, domain exception hierarchy, shared Limiter (Phase 3); repository + service layers, EmailService, async CV gen, get_optional_db (Phase 4); seed.json single source of truth, ProjectPhase child table, migration 0005 (Phase 5); loguru structured logging, RequestIdMiddleware, DB-backed UIGen token store with SHA-256 hashing, migration 0006 (Phase 6) Stale Phase 1 project structure showing skeleton placeholders; static models table missing ProjectPhase and UigenToken
2026-05-19 #438, #439, #455, #456 Add auth router registration note (root + /api/auth); update feature-flags section: App Configuration Data Owner for writes, TTL cache bust after PATCH; update Alembic migrations table to include 0008 and 0009; update experience test count
2026-06-02 #485 Add projectPageV2 feature flag to Feature Flags section and endpoint response example
2026-07-03 #606 Add certification sync: credentials_sync.py service, sync.py admin router, jobs/sync_certifications.py ACA job entrypoint, migration 0011 columns; update Certification model description, API endpoint docs, Docker build section; add Credential Sync section