Compare commits

...

17 Commits

Author SHA1 Message Date
4ef8fcc7e7 Track e2e fixture PNG (gitignore exception)
All checks were successful
CI / skip-ci-check (pull_request) Successful in 8s
CI / docker-ci (pull_request) Successful in 7s
CI / secret-scan (pull_request) Successful in 15s
CI / viewer-unit (pull_request) Successful in 1m32s
CI / e2e (pull_request) Successful in 1m55s
2026-07-14 19:19:49 -04:00
d9910acdde Fill e2e gaps: storageState, real upload POST, session smoke
Some checks failed
CI / skip-ci-check (pull_request) Successful in 3s
CI / docker-ci (pull_request) Successful in 5s
CI / secret-scan (pull_request) Successful in 12s
CI / viewer-unit (pull_request) Successful in 3m11s
CI / e2e (pull_request) Failing after 5m24s
Add auth setup project, viewer session check, upload that posts a fixture,
FastAPI login spec (skips without E2E_API_*), and repo ROADMAP.md.
2026-07-14 19:17:14 -04:00
988e9accf8 Merge pull request 'Expand e2e auth, Vitest CI, env-defaults, API catalog' (#64) from test/e2e-auth-vitest-ci into dev
All checks were successful
CI / skip-ci-check (push) Successful in 4s
CI / docker-ci (push) Successful in 6s
CI / secret-scan (push) Successful in 11s
CI / viewer-unit (push) Successful in 2m39s
CI / e2e (push) Successful in 4m13s
2026-07-14 18:11:05 -05:00
217b82d912 Bump playkit to v0.3 and use Zod API schemas
All checks were successful
CI / skip-ci-check (pull_request) Successful in 4s
CI / docker-ci (pull_request) Successful in 7s
CI / secret-scan (pull_request) Successful in 12s
CI / viewer-unit (pull_request) Successful in 1m59s
CI / e2e (pull_request) Successful in 1m54s
Adopt assertSchema via ApiClient, playkitFailureArtifacts in Playwright
config, and keep e2e login on e2e@levkine.ca credentials from CI secrets.
2026-07-14 19:02:48 -04:00
47b15049bc Expand e2e auth coverage, Vitest CI, and shared URL defaults
All checks were successful
CI / skip-ci-check (pull_request) Successful in 4s
CI / docker-ci (pull_request) Successful in 6s
CI / secret-scan (pull_request) Successful in 13s
CI / e2e (pull_request) Successful in 2m14s
CI / viewer-unit (pull_request) Successful in 2m38s
Wire viewer-frontend Vitest into Gitea Actions, centralize DEV base URLs
in env-defaults.json, expand playkit API catalog specs, and land auth/mail
flows plus verify-email public-host redirect and idle-logout e2e hooks.
2026-07-14 18:49:42 -04:00
62d5a66ffa Merge pull request 'Add Vitest + React Testing Library setup for viewer-frontend' (#63) from test/viewer-frontend-vitest-setup into dev 2026-07-14 17:39:59 -05:00
975d0c809d Add Vitest + React Testing Library setup for viewer-frontend
All checks were successful
CI / skip-ci-check (pull_request) Successful in 5s
CI / docker-ci (pull_request) Successful in 6s
CI / secret-scan (pull_request) Successful in 11s
CI / e2e (pull_request) Successful in 28s
Establishes frontend unit testing infra (jsdom env, RTL, jest-dom
matchers, common Radix/jsdom polyfills) and seeds it with tests for
lib/utils.ts, lib/photo-utils.ts, hooks/useFocusTrap.ts, and
ThemeToggle.tsx (46 tests total).
2026-07-14 18:35:30 -04:00
05b908241d Merge pull request 'test: add cross-endpoint integration and error-handling suites' (#62) from test/backend-integration-error-suites into dev 2026-07-14 17:24:37 -05:00
5303580107 test: add cross-endpoint integration and error-handling suites
All checks were successful
CI / skip-ci-check (pull_request) Successful in 4s
CI / docker-ci (pull_request) Successful in 5s
CI / secret-scan (pull_request) Successful in 11s
CI / e2e (pull_request) Successful in 26s
Adds test_api_integration.py (person identification, tag+search,
favorites+search, bulk favorites, and role-permission-grant workflows
chained across multiple endpoints) and test_api_errors.py (404/401/403/422
consistency sweeps, SQL-injection/XSS-style input handling, large-payload
handling) per the two suites the existing tests/API_TEST_PLAN.md already
documented but never implemented.

Along the way, found and documented (via a passing test, not a fix) that
GET /api/v1/faces/unidentified has no auth dependency, unlike its sibling
routes.
2026-07-14 18:23:15 -04:00
fc8b47863d Merge pull request 'test: cover 7 previously-untested backend API route modules' (#61) from test/backend-pytest-coverage-gaps into dev 2026-07-14 17:17:20 -05:00
2d6bbf1d13 test: cover 7 previously-untested backend API route modules
All checks were successful
CI / skip-ci-check (pull_request) Successful in 4s
CI / docker-ci (pull_request) Successful in 6s
CI / secret-scan (pull_request) Successful in 11s
CI / e2e (pull_request) Successful in 27s
Adds pytest coverage for metrics, click_log, role_permissions, videos,
pending-identifications, pending-linkages, and reported-photos — the
last three required extending conftest with a second (auth) test
database + DDL mirroring the Prisma schema-auth.prisma tables, since
those endpoints read/write DATABASE_URL_AUTH via raw SQL.

pending-photos.py is intentionally left uncovered for now (heavy
filesystem/hash-based duplicate-detection logic needs its own mocking
strategy) and tracked as a follow-up.
2026-07-14 18:15:15 -04:00
0f24e7e6ba Merge pull request 'test: add Playwright e2e smoke suite for punimtag dev' (#60) from test/e2e-playwright-smoke into dev 2026-07-14 16:13:57 -05:00
0a9223c943 test: add Playwright e2e smoke suite for punimtag dev
All checks were successful
CI / skip-ci-check (pull_request) Successful in 4s
CI / docker-ci (pull_request) Successful in 5s
CI / secret-scan (pull_request) Successful in 10s
CI / e2e (pull_request) Successful in 46s
Add an e2e/ Playwright harness (via @levkin/playkit) that smoke-tests
the public DEV host: login page loads, sign-out never redirects to a
LAN host, FastAPI /health responds, and forgot-password mail lands in
Mailtrap with a public reset link (Kolby #56/#57 regressions).

Wire it into Gitea Actions as a new `e2e` job (self-hosted runner,
Chromium via Playwright), gated behind the existing skip-ci-check.
Add npm root scripts (install:e2e, test:e2e) and gitignore e2e build
artifacts (node_modules, reports, .env).
2026-07-14 17:11:38 -04:00
44f3d696e4 Merge pull request 'feat: add dark mode and audit viewer-frontend accessibility' (#59) from a11y/dark-mode-and-focus-management into dev 2026-07-14 15:25:46 -05:00
c011c8078e feat: add dark mode and audit viewer-frontend accessibility
All checks were successful
CI / skip-ci-check (pull_request) Successful in 4s
CI / docker-ci (pull_request) Successful in 6s
CI / secret-scan (pull_request) Successful in 11s
Add next-themes light/dark/system theming with an accessible toggle
in every header, and close out an a11y pass across the viewer app:
skip-to-content link, main landmarks and heading hierarchy on every
route, focus-trapped dialog semantics for the photo viewer modals,
aria-label/live-region coverage for dynamic messages and icon
buttons, and dark: variants for previously light-only colors.
2026-07-14 16:24:51 -04:00
e54c60912c Merge pull request 'fix: prevent sign-out crash and surface real email-send failures' (#58) from fix/logout-crash-and-email-delivery into dev 2026-07-14 12:14:53 -05:00
bb09159133 fix: prevent sign-out crash and surface real email-send failures
All checks were successful
CI / skip-ci-check (pull_request) Successful in 4s
CI / docker-ci (pull_request) Successful in 6s
CI / secret-scan (pull_request) Successful in 9s
- NextAuth session/jwt callbacks no longer return null (the client
  useSession() hook chokes on a null session, crashing the app on
  sign-out for deactivated/refreshed sessions) — fixes #57
- UserMenu/Header sign-out handlers close the Manage Users overlay and
  popover before calling signOut(), and no longer let a rejected
  signOut() promise go unhandled
- Added an app-level error boundary as a safety net for any remaining
  client render errors
- Registration and forgot-password flows now tell the user honestly
  when the confirmation/reset email failed to send instead of always
  claiming success — related to #56
- lib/email.ts throws a clear, actionable error when neither
  SMTP nor Resend is configured, instead of a cryptic SDK error
- Documented required SMTP/Resend env vars in DEPLOYMENT_CHECKLIST.md
  (were missing entirely, which is why no confirmation/reset emails
  were ever sent in deployed environments) — fixes #56
2026-07-14 12:44:33 -04:00
91 changed files with 5389 additions and 244 deletions

View File

@ -5,9 +5,12 @@ name: CI
on:
push:
branches: [master, main]
branches: [master, main, dev]
pull_request:
types: [opened, synchronize, reopened]
# Daily DEV smoke (~06:00 America/Toronto)
schedule:
- cron: '0 10 * * *'
jobs:
skip-ci-check:
@ -67,3 +70,89 @@ jobs:
fi
docker run --rm -v "$PWD:/repo" ghcr.io/gitleaks/gitleaks:latest \
detect --source /repo --no-banner --redact ${extra}
# Vitest unit tests (viewer-frontend) — landed in PR #63, wired here.
viewer-unit:
needs: skip-ci-check
if: needs.skip-ci-check.outputs.should-skip != '1'
runs-on: [homelab, self-hosted, linux]
defaults:
run:
working-directory: viewer-frontend
steps:
- uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '20'
cache: npm
cache-dependency-path: viewer-frontend/package-lock.json
- name: Install deps
run: npm ci
- name: Run Vitest
run: npm test
# Playwright smoke via @levkin/playkit — public host guards + API health.
# Sign-out auth spec runs only when E2E_ADMIN_* secrets are present.
e2e:
needs: skip-ci-check
if: needs.skip-ci-check.outputs.should-skip != '1'
runs-on: [homelab, self-hosted, linux]
defaults:
run:
working-directory: e2e
steps:
- uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Allow private playkit clone
if: ${{ secrets.PLAYKIT_GIT_TOKEN != '' }}
run: |
git config --global url."https://oauth2:${{ secrets.PLAYKIT_GIT_TOKEN }}@git.levkin.ca/".insteadOf "https://git.levkin.ca/"
- name: Install e2e deps
run: npm ci
- name: Install Chromium
run: npx playwright install --with-deps chromium
- name: Run Playwright smoke
env:
PLAYKIT_BASE_URL: ${{ secrets.PLAYKIT_BASE_URL }}
PLAYKIT_API_BASE_URL: ${{ secrets.PLAYKIT_API_BASE_URL }}
PLAYKIT_PROJECT: punimtag
PLAYKIT_ENV: dev
E2E_ADMIN_EMAIL: ${{ secrets.E2E_ADMIN_EMAIL }}
E2E_ADMIN_PASSWORD: ${{ secrets.E2E_ADMIN_PASSWORD }}
MAILTRAP_API_TOKEN: ${{ secrets.MAILTRAP_API_TOKEN }}
MAILTRAP_ACCOUNT_ID: ${{ secrets.MAILTRAP_ACCOUNT_ID }}
MAILTRAP_INBOX_ID: ${{ secrets.MAILTRAP_INBOX_ID }}
PLAYKIT_MAIL_PROVIDER: ${{ secrets.PLAYKIT_MAIL_PROVIDER }}
MAILPIT_BASE_URL: ${{ secrets.MAILPIT_BASE_URL }}
MAILPIT_USER: ${{ secrets.MAILPIT_USER }}
MAILPIT_PASSWORD: ${{ secrets.MAILPIT_PASSWORD }}
CI: 'true'
run: |
# Fallbacks from e2e/env-defaults.json (single source of truth with TS)
DEFAULT_BASE_URL=$(node -p "require('./env-defaults.json').DEFAULT_BASE_URL")
DEFAULT_API_BASE_URL=$(node -p "require('./env-defaults.json').DEFAULT_API_BASE_URL")
export PLAYKIT_BASE_URL="${PLAYKIT_BASE_URL:-$DEFAULT_BASE_URL}"
export PLAYKIT_API_BASE_URL="${PLAYKIT_API_BASE_URL:-$DEFAULT_API_BASE_URL}"
npx playwright test
- name: Upload report
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: |
e2e/playwright-report/
e2e/test-results/
retention-days: 7

10
.gitignore vendored
View File

@ -63,6 +63,8 @@ Thumbs.db
*.jpg
*.jpeg
*.png
!e2e/fixtures/
!e2e/fixtures/**
*.gif
*.bmp
*.tiff
@ -85,3 +87,11 @@ data/thumbnails/
# PM2 ecosystem config (server-specific paths)
ecosystem.config.js
data/web_videos/
# Playwright e2e
e2e/node_modules/
e2e/playwright-report/
e2e/test-results/
e2e/blob-report/
e2e/.auth/
e2e/.env

View File

@ -31,8 +31,29 @@ DATABASE_URL_AUTH=postgresql://user:password@10.0.10.179:5432/punimtag_auth
NEXTAUTH_URL=http://10.0.10.121:3001
NEXTAUTH_SECRET=your-secret-key-here
AUTH_URL=http://10.0.10.121:3001
# Password reset / email verification emails (lib/email.ts). Without these,
# "Forgot password" and "Confirm your email" never actually send mail — see
# issue #56. Pick ONE provider path:
# Option A: SMTP (primary; falls back to Resend automatically on failure)
EMAIL_PROVIDER=smtp
SMTP_HOST=mail.your-domain.com
SMTP_PORT=465
SMTP_SECURE=true
SMTP_USER=mailer@your-domain.com
SMTP_PASS=your-mailbox-password
SMTP_FROM_EMAIL=noreply@your-domain.com
SMTP_FROM_NAME=PunimTag Viewer
# Option B: Resend only (set EMAIL_PROVIDER=resend, or leave SMTP_* unset above)
RESEND_API_KEY=re_xxx
RESEND_FROM_EMAIL=onboarding@resend.dev
RESEND_FROM_NAME=PunimTag Viewer
```
Verify email sending works with `cd viewer-frontend && npx tsx scripts/test-email-sending.ts` after setting these.
## 2. PM2 Configuration
Copy the template and customize for your server:

45
ROADMAP.md Normal file
View File

@ -0,0 +1,45 @@
# PunimTag roadmap
Living plan for product quality, auth/email reliability, and automation.
## Now — shipped / in flight
- [x] Fix public-host auth redirects (Kolby #57 family) — `NEXTAUTH_URL` + verify-email
- [x] DEV mail trap via Mailpit (not Mailtrap SaaS / not Spamhaus-blocked Mailcow for e2e)
- [x] Playwright e2e suite (`e2e/`) on `@levkin/playkit` against `punimtagdev.levkin.ca`
- [x] Dedicated e2e user `e2e@levkine.ca` (Vault / Infisical / Gitea Actions)
- [x] Vitest unit tests for `viewer-frontend` in Gitea Actions
- [x] API smoke + Zod schema checks (health, people, tags, 401 contracts)
- [x] storageState setup project + real upload smoke
- [x] ROADMAP (this file)
## Next (near-term)
- [ ] **Wire QA/PROD SMTP on live guests** when LXCs 9102/9103 exist (`make punimtag-sync-smtp ENV=qa|prod`)
- [ ] **FastAPI e2e user** — mirror `e2e@levkine.ca` (or service account) into FastAPI user DB; set `E2E_API_USERNAME`/`PASSWORD` in CI
- [ ] **Search / filter e2e** — tag + person filters on public gallery
- [ ] **Role permissions e2e** — viewer vs admin write gates
- [ ] **admin-frontend Vitest** (or Playwright admin project)
- [ ] **PROD smoke** — health + public login page only (no mutating tests)
- [ ] **Stop seeding `admin@admin.com` in docs** as the day-to-day login; keep bootstrap scripts but point operators at Vaultwarden `PunimTag e2e`
## Later
- [ ] Proper DEV deploy (`next start` + CI image) instead of long-lived `next dev`
- [ ] Ansible/app_setup path aligned with `/opt/punimtag/viewer-frontend/.env` (not `/srv/app`)
- [ ] OpenAPI-driven contract suite (playkit / consumer)
- [ ] Multi-browser matrix (firefox/webkit)
- [ ] Hermes/Mattermost report on e2e failure
## Test map
| Layer | Where | Status |
|-------|--------|--------|
| Viewer unit | `viewer-frontend` Vitest | CI `viewer-unit` |
| Backend unit/integration | `tests/` pytest | existing |
| E2E browser + mail | `e2e/tests/*` | CI `e2e` |
| E2E API (unauth + catalog) | `e2e/tests/api.*` | CI |
| E2E FastAPI authed | `api.fastapi-login.spec.ts` | skips without `E2E_API_*` |
| Admin UI | — | gap |
See also: `e2e/README.md`, playkit `ROADMAP.md` (kit-level).

27
e2e/.env.example Normal file
View File

@ -0,0 +1,27 @@
# Defaults also live in env-defaults.json — override here for local runs.
# PLAYKIT_BASE_URL / PLAYKIT_API_BASE_URL fall back to that file when unset.
PLAYKIT_PROJECT=punimtag
PLAYKIT_ENV=dev
# Dedicated e2e user — prefer e2e@levkine.ca (never commit real passwords)
# Do not use admin@admin.com for Playwright; that account is bootstrap-only.
E2E_ADMIN_EMAIL=
E2E_ADMIN_PASSWORD=
# Optional FastAPI bearer tests (separate user DB from NextAuth)
# E2E_API_USERNAME=
# E2E_API_PASSWORD=
# Homelab Mailpit (preferred for DEV e2e)
PLAYKIT_MAIL_PROVIDER=mailpit
MAILPIT_BASE_URL=http://10.0.10.45:8025
MAILPIT_USER=
MAILPIT_PASSWORD=
# E2E_MAIL_TO= # defaults to E2E_ADMIN_EMAIL
# Mailtrap Email Testing (optional SaaS fallback)
# PLAYKIT_MAIL_PROVIDER=mailtrap
# MAILTRAP_API_TOKEN=
# MAILTRAP_ACCOUNT_ID=
# MAILTRAP_INBOX_ID=

3
e2e/.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
.auth/
.env

53
e2e/README.md Normal file
View File

@ -0,0 +1,53 @@
# PunimTag e2e (Playwright + @levkin/playkit)
Smoke tests against the **public** DEV URL so we catch LAN-redirect bugs
(e.g. `NEXTAUTH_URL=http://10.0.10.121:3001` → Kolby #57).
## Quick start
```bash
cd e2e
cp .env.example .env # edit credentials
npm ci
npx playwright install chromium
npm test
```
Private Gitea installs need auth once: set a local git `insteadOf` with a token
before `npm ci`, then unset it. CI does this in `.gitea/workflows/ci.yml`.
Never commit lockfile URLs that embed tokens.
## Environment
| Variable | Required | Purpose |
|----------|----------|---------|
| `PLAYKIT_BASE_URL` | yes (default in `env-defaults.json`) | Public viewer host |
| `PLAYKIT_API_BASE_URL` | no | LAN API (see `env-defaults.json`) |
| `E2E_ADMIN_EMAIL` | for auth specs | Prefer `e2e@levkine.ca`**not** `admin@admin.com` |
| `E2E_ADMIN_PASSWORD` | for auth specs | Dedicated e2e password (Vaultwarden / Infisical) |
| `E2E_API_USERNAME` / `E2E_API_PASSWORD` | optional | FastAPI bearer login (separate DB from NextAuth) |
| `PLAYKIT_MAIL_PROVIDER` | for mail specs | `mailpit` (homelab) |
| `MAILPIT_BASE_URL` | for mail specs | `http://10.0.10.45:8025` |
| `MAILPIT_USER` / `MAILPIT_PASSWORD` | for mail specs | Mailpit basic auth |
**Secrets:** Infisical `LevkinOps``dev``/playkit/punimtag` + Gitea Actions.
See ansible `docs/hardening/SECRETS.md`.
## What we assert
1. Login page loads on the public hostname
2. Sign-out stays on that hostname (never `10.x`)
3. FastAPI `/health``{ "status": "ok" }`
4. Forgot-password / reset-password email in Mailpit; links use public host
5. Register → unverified block → verify email via Mailpit → login
6. Idle logout (`?e2e_idle_ms=`) stays on public host
7. Upload: submit a tiny fixture via `#file-upload` (uses storageState)
8. Manage Users open/close then sign-out (Kolby #57 overlay path)
9. API `/api/v1/auth/me` and `/api/v1/photos` return 401 without token
10. API catalog: people/tags lists, login 422/401, users/roles 401, missing photo 404|401
11. Viewer `/api/auth/session` returns email when storageState is loaded
See repo root [`ROADMAP.md`](../ROADMAP.md) for gaps and next steps.
Defaults for base URLs live in `env-defaults.json` (imported by `env-defaults.ts`,
`fixtures.ts`, `playwright.config.ts`, and the Gitea `e2e` job).

6
e2e/env-defaults.json Normal file
View File

@ -0,0 +1,6 @@
{
"DEFAULT_BASE_URL": "https://punimtagdev.levkin.ca",
"DEFAULT_API_BASE_URL": "http://10.0.10.121:8000",
"DEFAULT_PROJECT": "punimtag",
"DEFAULT_ENV": "dev"
}

27
e2e/env-defaults.ts Normal file
View File

@ -0,0 +1,27 @@
/**
* Canonical punimtag DEV targets single source of truth for fixtures,
* playwright.config.ts, and the Gitea Actions `e2e` job defaults.
*
* Values live in env-defaults.json so CI bash can read them without
* compiling TypeScript (see `.gitea/workflows/ci.yml`).
*/
import defaults from './env-defaults.json' with { type: 'json' };
export const DEFAULT_BASE_URL = defaults.DEFAULT_BASE_URL;
export const DEFAULT_API_BASE_URL = defaults.DEFAULT_API_BASE_URL;
export const DEFAULT_PROJECT = defaults.DEFAULT_PROJECT;
export const DEFAULT_ENV = defaults.DEFAULT_ENV;
/** Env bag for createPlaykitRuntime — fills gaps only, never overrides set vars. */
export function playkitEnvFromProcess(
env: NodeJS.ProcessEnv = process.env,
): NodeJS.ProcessEnv {
return {
...env,
PLAYKIT_PROJECT: env.PLAYKIT_PROJECT || env.CI_PROJECT || DEFAULT_PROJECT,
PLAYKIT_ENV: env.PLAYKIT_ENV || env.APP_ENV || DEFAULT_ENV,
PLAYKIT_BASE_URL: env.PLAYKIT_BASE_URL || env.BASE_URL || DEFAULT_BASE_URL,
PLAYKIT_API_BASE_URL:
env.PLAYKIT_API_BASE_URL || env.API_BASE_URL || DEFAULT_API_BASE_URL,
};
}

47
e2e/fixtures.ts Normal file
View File

@ -0,0 +1,47 @@
import { test as base, expect } from '@playwright/test';
import {
createPlaykitRuntime,
createMailInbox,
type PlaykitFixtures,
type MailInbox,
} from '@levkin/playkit';
import { playkitEnvFromProcess } from './env-defaults';
import { LoginPage } from './pages/LoginPage';
import { AccountMenu } from './pages/AccountMenu';
import { RegisterPage } from './pages/RegisterPage';
const runtime = createPlaykitRuntime(playkitEnvFromProcess());
type PunimtagFixtures = PlaykitFixtures & {
loginPage: LoginPage;
accountMenu: AccountMenu;
registerPage: RegisterPage;
e2eCredentials: { email: string; password: string } | null;
mail: MailInbox | null;
};
export const test = base.extend<PunimtagFixtures>({
playkitConfig: async ({}, use) => use(runtime.playkitConfig),
playkitLog: async ({}, use) => use(runtime.playkitLog),
api: async ({}, use) => use(runtime.api),
timings: async ({}, use) => use(runtime.timings),
loginPage: async ({ page, playkitConfig }, use) =>
use(new LoginPage(page, playkitConfig.baseUrl)),
accountMenu: async ({ page, playkitConfig }, use) =>
use(new AccountMenu(page, playkitConfig.baseUrl)),
registerPage: async ({ page, playkitConfig }, use) =>
use(new RegisterPage(page, playkitConfig.baseUrl)),
e2eCredentials: async ({}, use) => {
const email = process.env.E2E_ADMIN_EMAIL || process.env.E2E_EMAIL || '';
const password = process.env.E2E_ADMIN_PASSWORD || process.env.E2E_PASSWORD || '';
await use(email && password ? { email, password } : null);
},
mail: async ({ playkitLog }, use) => {
await use(createMailInbox(process.env, playkitLog.child({ component: 'mail' })));
},
});
export { expect };

BIN
e2e/fixtures/tiny.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

131
e2e/package-lock.json generated Normal file
View File

@ -0,0 +1,131 @@
{
"name": "punimtag-e2e",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "punimtag-e2e",
"version": "1.0.0",
"devDependencies": {
"@levkin/playkit": "git+https://git.levkin.ca/ilia/playkit.git#v0.3.0",
"@playwright/test": "^1.52.0",
"@types/node": "^22.15.0",
"typescript": "^5.8.0",
"zod": "^3.25.76"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@levkin/playkit": {
"version": "0.3.0",
"resolved": "git+https://git.levkin.ca/ilia/playkit.git#7369b1c5e53e1f7511bea96be9042e663b60b315",
"dev": true,
"license": "MIT",
"dependencies": {
"zod": "^3.24.0"
},
"engines": {
"node": ">=20"
},
"peerDependencies": {
"@playwright/test": ">=1.40.0"
},
"peerDependenciesMeta": {
"@playwright/test": {
"optional": true
}
}
},
"node_modules/@playwright/test": {
"version": "1.61.1",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.61.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@types/node": {
"version": "22.20.1",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.61.1",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.61.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.61.1",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"dev": true,
"license": "MIT"
},
"node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}

22
e2e/package.json Normal file
View File

@ -0,0 +1,22 @@
{
"name": "punimtag-e2e",
"version": "1.0.0",
"private": true,
"description": "PunimTag Playwright e2e \u2014 powered by @levkin/playkit",
"scripts": {
"test": "playwright test",
"test:ui": "playwright test --ui",
"test:headed": "playwright test --headed",
"report": "playwright show-report"
},
"engines": {
"node": ">=20"
},
"devDependencies": {
"@levkin/playkit": "git+https://git.levkin.ca/ilia/playkit.git#v0.3.0",
"@playwright/test": "^1.52.0",
"@types/node": "^22.15.0",
"typescript": "^5.8.0",
"zod": "^3.25.76"
}
}

28
e2e/pages/AccountMenu.ts Normal file
View File

@ -0,0 +1,28 @@
import { type Page } from '@playwright/test';
import { BasePage, waitForVisible } from '@levkin/playkit';
export class AccountMenu extends BasePage {
constructor(page: Page, baseUrl: string) {
super(page, baseUrl);
}
async openMenu(): Promise<void> {
await this.click(this.page.getByLabel('Account menu'));
await waitForVisible(this.page.getByRole('button', { name: 'Sign out' }));
}
async signOut(): Promise<void> {
await this.openMenu();
await this.click(this.page.getByRole('button', { name: 'Sign out' }));
}
async openManageUsers(): Promise<void> {
await this.openMenu();
await this.click(this.page.getByRole('button', { name: 'Manage Users' }));
await waitForVisible(this.page.getByRole('heading', { name: /Manage Users/i }));
}
async closeManageUsers(): Promise<void> {
await this.click(this.page.getByLabel('Close manage users'));
}
}

38
e2e/pages/LoginPage.ts Normal file
View File

@ -0,0 +1,38 @@
import { type Page } from '@playwright/test';
import { BasePage } from '@levkin/playkit';
export class LoginPage extends BasePage {
constructor(page: Page, baseUrl: string) {
super(page, baseUrl);
}
async openLogin(): Promise<void> {
await this.open('/login');
await this.page.waitForLoadState('domcontentloaded');
await this.page.locator('#email').waitFor({ state: 'visible' });
// Wait for client bundle / React hydration (native GET submit otherwise)
await this.page.waitForFunction(() => {
const form = document.querySelector('form');
const btn = document.querySelector('button[type="submit"]');
return !!form && !!btn && !!(window as unknown as { next?: unknown }).next;
}).catch(() => undefined);
await this.page.waitForTimeout(600);
}
async signIn(email: string, password: string): Promise<void> {
for (let attempt = 0; attempt < 3; attempt++) {
await this.fill(this.page.locator('#email'), email);
await this.fill(this.page.locator('#password'), password);
await this.page.waitForTimeout(250 * (attempt + 1));
await this.click(this.page.getByRole('button', { name: /Sign in/i }));
await this.page.waitForTimeout(400);
// Native GET fallback leaks credentials into the query string — retry.
if (!this.page.url().includes('password=')) {
return;
}
this.log.warn('login GET fallback detected; retrying after re-open', { attempt });
await this.openLogin();
}
throw new Error('Login form submitted as GET (page not hydrated)');
}
}

27
e2e/pages/RegisterPage.ts Normal file
View File

@ -0,0 +1,27 @@
import { type Page } from '@playwright/test';
import { BasePage } from '@levkin/playkit';
export class RegisterPage extends BasePage {
constructor(page: Page, baseUrl: string) {
super(page, baseUrl);
}
async openRegister(): Promise<void> {
await this.open('/register');
await this.page.waitForLoadState('domcontentloaded');
await this.page.locator('#email').waitFor({ state: 'visible' });
await this.page.waitForTimeout(300);
}
async register(input: {
name: string;
email: string;
password: string;
}): Promise<void> {
await this.fill(this.page.locator('#name'), input.name);
await this.fill(this.page.locator('#email'), input.email);
await this.fill(this.page.locator('#password'), input.password);
await this.fill(this.page.locator('#confirmPassword'), input.password);
await this.click(this.page.locator('button[type="submit"]'));
}
}

33
e2e/playwright.config.ts Normal file
View File

@ -0,0 +1,33 @@
import { defineConfig, devices } from '@playwright/test';
import { playkitFailureArtifacts } from '@levkin/playkit';
import { DEFAULT_BASE_URL } from './env-defaults';
const baseURL =
process.env.PLAYKIT_BASE_URL || process.env.BASE_URL || DEFAULT_BASE_URL;
export default defineConfig({
testDir: './tests',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
workers: process.env.CI ? 2 : undefined,
reporter: process.env.CI
? [['list'], ['html', { open: 'never' }], ['junit', { outputFile: 'test-results/junit.xml' }]]
: [['list'], ['html', { open: 'on-failure' }]],
timeout: 60_000,
expect: { timeout: 15_000 },
use: {
baseURL,
...playkitFailureArtifacts(),
...devices['Desktop Chrome'],
},
projects: [
{ name: 'setup', testMatch: /auth\.setup\.ts/ },
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
dependencies: ['setup'],
testIgnore: /auth\.setup\.ts/,
},
],
});

View File

@ -0,0 +1,40 @@
import { assertPublicHost, waitForUrlHost } from '@levkin/playkit';
import { test, expect } from '../fixtures';
/**
* Kolby #57 class: Manage Users overlay must close cleanly before sign-out
* and the post-logout URL must stay on the public host.
*/
test.describe('admin users @smoke', () => {
test('manage users then sign-out stays on public host', async ({
page,
playkitConfig,
loginPage,
accountMenu,
e2eCredentials,
timings,
}) => {
test.skip(!e2eCredentials, 'E2E_ADMIN_EMAIL/PASSWORD required (admin user)');
assertPublicHost(playkitConfig.baseUrl);
await timings.measure('login', async () => {
await loginPage.openLogin();
await loginPage.signIn(e2eCredentials!.email, e2eCredentials!.password);
await page.waitForURL((url) => !url.pathname.includes('/login'), { timeout: 30_000 });
await page.getByLabel('Account menu').waitFor({ state: 'visible', timeout: 30_000 });
});
await timings.measure('manage_users', async () => {
await accountMenu.openManageUsers();
await expect(page.getByRole('heading', { name: /Manage Users/i })).toBeVisible();
await accountMenu.closeManageUsers();
});
await timings.measure('sign_out', () => accountMenu.signOut());
await waitForUrlHost(page, playkitConfig.expectedHost);
expect(page.url()).not.toMatch(/10\.\d+\.\d+\.\d+/);
await expect(page.getByRole('button', { name: /Sign in/i })).toBeVisible({
timeout: 15_000,
});
});
});

View File

@ -0,0 +1,18 @@
import { test, expect } from '../fixtures';
test.describe('api auth @smoke', () => {
test('protected /api/v1/auth/me returns 401 without token', async ({ api, timings }) => {
const res = await timings.measure('api_me_unauth', () =>
api.get('/api/v1/auth/me', { expectedStatus: 401 }),
);
expect(res.status).toBe(401);
expect(res.data).toMatchObject({ detail: expect.stringMatching(/not authenticated/i) });
});
test('protected /api/v1/photos returns 401 without token', async ({ api, timings }) => {
const res = await timings.measure('api_photos_unauth', () =>
api.get('/api/v1/photos', { expectedStatus: 401 }),
);
expect(res.status).toBe(401);
});
});

View File

@ -0,0 +1,81 @@
import { z } from 'zod';
import { test, expect } from '../fixtures';
const PeopleList = z.object({
items: z.array(z.object({ id: z.number() }).passthrough()).min(1),
});
const TagsList = z.object({
items: z.array(z.object({ id: z.number() }).passthrough()).min(1),
});
/**
* Public / lightly-gated FastAPI surfaces shape + status contracts via playkit ApiClient.
* Does not require e2e login credentials.
*/
test.describe('api catalog @smoke', () => {
test('GET /api/v1/people returns items list', async ({ api, timings }) => {
const res = await timings.measure('api_people', () =>
api.get('/api/v1/people', { expectedStatus: 200, schema: PeopleList }),
);
expect(res.data.items[0]).toEqual(expect.objectContaining({ id: expect.any(Number) }));
});
test('GET /api/v1/people/with-faces returns items list', async ({ api, timings }) => {
const res = await timings.measure('api_people_faces', () =>
api.get('/api/v1/people/with-faces', {
expectedStatus: 200,
schema: z.object({ items: z.array(z.unknown()) }),
}),
);
expect(Array.isArray(res.data.items)).toBe(true);
});
test('GET /api/v1/tags returns items list', async ({ api, timings }) => {
const res = await timings.measure('api_tags', () =>
api.get('/api/v1/tags', { expectedStatus: 200, schema: TagsList }),
);
expect(res.data.items[0]).toEqual(expect.objectContaining({ id: expect.any(Number) }));
});
test('POST /api/v1/auth/login rejects empty body with 422', async ({ api, timings }) => {
const res = await timings.measure('api_login_empty', () =>
api.post('/api/v1/auth/login', { body: {}, expectedStatus: 422 }),
);
expect(res.status).toBe(422);
});
test('POST /api/v1/auth/login rejects bad credentials with 401', async ({ api, timings }) => {
const res = await timings.measure('api_login_bad', () =>
api.post('/api/v1/auth/login', {
body: { username: 'e2e-no-such-user', password: 'wrong-password' },
expectedStatus: 401,
}),
);
expect(res.status).toBe(401);
expect(res.data).toMatchObject({
detail: expect.stringMatching(/incorrect|password|username/i),
});
});
test('GET /api/v1/users returns 401 without token', async ({ api, timings }) => {
const res = await timings.measure('api_users_unauth', () =>
api.get('/api/v1/users', { expectedStatus: 401 }),
);
expect(res.status).toBe(401);
});
test('GET /api/v1/role-permissions returns 401 without token', async ({ api, timings }) => {
const res = await timings.measure('api_roles_unauth', () =>
api.get('/api/v1/role-permissions', { expectedStatus: 401 }),
);
expect(res.status).toBe(401);
});
test('GET /api/v1/photos/{id} for missing photo is 404 (or 401)', async ({ api, timings }) => {
const res = await timings.measure('api_photo_missing', () =>
api.get('/api/v1/photos/999999999', { expectedStatus: [401, 404] }),
);
expect([401, 404]).toContain(res.status);
});
});

View File

@ -0,0 +1,27 @@
import { test, expect } from '../fixtures';
/**
* FastAPI `/api/v1/auth/login` uses a *separate* user DB from NextAuth.
* Set E2E_API_USERNAME + E2E_API_PASSWORD (or reuse admin FastAPI creds) to enable.
*/
test.describe('fastapi login @smoke', () => {
test('login returns bearer token when API creds set', async ({ api, timings }) => {
const username = process.env.E2E_API_USERNAME || '';
const password = process.env.E2E_API_PASSWORD || '';
test.skip(!username || !password, 'E2E_API_USERNAME/PASSWORD required for FastAPI auth');
const res = await timings.measure('api_login', () =>
api.post<{ access_token: string }>('/api/v1/auth/login', {
body: { username, password },
expectedStatus: 200,
}),
);
expect(res.data.access_token).toBeTruthy();
const authed = api.withAuthBearer(res.data.access_token);
const me = await timings.measure('api_me', () =>
authed.get('/api/v1/auth/me', { expectedStatus: 200 }),
);
expect(me.status).toBe(200);
});
});

View File

@ -0,0 +1,13 @@
import { z } from 'zod';
import { test, expect } from '../fixtures';
const HealthSchema = z.object({ status: z.literal('ok') });
test.describe('api @smoke', () => {
test('backend /health returns ok', async ({ api, timings }) => {
const res = await timings.measure('api_health', () =>
api.get('/health', { expectedStatus: 200, schema: HealthSchema }),
);
expect(res.data).toEqual({ status: 'ok' });
});
});

20
e2e/tests/auth.setup.ts Normal file
View File

@ -0,0 +1,20 @@
import { assertPublicHost, saveStorageState, waitForUrlHost } from '@levkin/playkit';
import { test as setup } from '../fixtures';
import path from 'node:path';
const authFile = path.join(__dirname, '../.auth/admin.json');
setup('authenticate e2e admin', async ({ page, playkitConfig, loginPage, e2eCredentials, timings }) => {
setup.skip(!e2eCredentials, 'E2E_ADMIN_EMAIL/PASSWORD required');
assertPublicHost(playkitConfig.baseUrl);
await timings.measure('setup_login', async () => {
await loginPage.openLogin();
await loginPage.signIn(e2eCredentials!.email, e2eCredentials!.password);
await page.waitForURL((url) => !url.pathname.includes('/login'), { timeout: 30_000 });
await page.getByLabel('Account menu').waitFor({ state: 'visible', timeout: 30_000 });
await waitForUrlHost(page, playkitConfig.expectedHost);
});
await saveStorageState(page, authFile);
});

View File

@ -0,0 +1,59 @@
import {
assertPublicHost,
waitForUrlHost,
} from '@levkin/playkit';
import { test, expect } from '../fixtures';
test.describe('auth @smoke', () => {
test('login page loads on public host', async ({
page,
playkitConfig,
timings,
loginPage,
}) => {
assertPublicHost(playkitConfig.baseUrl);
await timings.measure('login_page', () => loginPage.openLogin());
await expect(page.getByRole('heading', { name: /Sign in/i })).toBeVisible();
await expect(page.locator('#email')).toBeVisible();
await waitForUrlHost(page, playkitConfig.expectedHost);
expect(new URL(page.url()).hostname).toBe(playkitConfig.expectedHost);
});
test('sign-out stays on public host (Kolby #57)', async ({
page,
playkitConfig,
timings,
loginPage,
accountMenu,
e2eCredentials,
}) => {
test.skip(
!e2eCredentials,
'Set E2E_ADMIN_EMAIL and E2E_ADMIN_PASSWORD (Infisical / Gitea secrets)',
);
assertPublicHost(playkitConfig.baseUrl);
await timings.measure('login', async () => {
await loginPage.openLogin();
await loginPage.signIn(e2eCredentials!.email, e2eCredentials!.password);
await page.waitForURL((url) => !url.pathname.includes('/login'), { timeout: 30_000 });
await page.getByLabel('Account menu').waitFor({ state: 'visible', timeout: 30_000 });
});
await waitForUrlHost(page, playkitConfig.expectedHost);
await timings.measure('sign_out', () => accountMenu.signOut());
// After sign-out, NextAuth must redirect to the public host — never 10.x.
await waitForUrlHost(page, playkitConfig.expectedHost);
expect(new URL(page.url()).hostname).toBe(playkitConfig.expectedHost);
expect(page.url()).not.toMatch(/10\.\d+\.\d+\.\d+/);
await expect(page.getByRole('button', { name: /Sign in/i })).toBeVisible({
timeout: 15_000,
});
});
});

View File

@ -0,0 +1,100 @@
import {
assertPublicHost,
firstLinkMatching,
readMailHtml,
waitForUrlHost,
} from '@levkin/playkit';
import { test, expect } from '../fixtures';
/**
* Forgot-password must land in the mail trap (Mailpit) with a public reset link.
*
* Requires PLAYKIT_MAIL_PROVIDER=mailpit + MAILPIT_* (or Mailtrap SaaS envs),
* punimtag DEV SMTP Mailpit, and E2E_ADMIN_EMAIL / E2E_MAIL_TO.
*/
test.describe('mail @smoke', () => {
test('forgot-password email lands in mail trap with public reset link', async ({
request,
playkitConfig,
timings,
mail,
e2eCredentials,
}) => {
test.skip(!mail, 'Set MAILPIT_BASE_URL (+ auth) or MAILTRAP_* secrets');
const to =
process.env.E2E_MAIL_TO ||
e2eCredentials?.email ||
process.env.E2E_ADMIN_EMAIL ||
'';
test.skip(!to, 'Set E2E_MAIL_TO or E2E_ADMIN_EMAIL');
assertPublicHost(playkitConfig.baseUrl);
const after = new Date(Date.now() - 5_000);
const res = await timings.measure('forgot_password_api', () =>
request.post(`${playkitConfig.baseUrl}/api/auth/forgot-password`, {
data: { email: to },
headers: { 'Content-Type': 'application/json' },
}),
);
expect(res.ok()).toBeTruthy();
const msg = await timings.measure('mail_wait', () =>
mail!.waitForEmail({
to,
subject: /reset|password/i,
after,
timeoutMs: 90_000,
pollMs: 2_500,
}),
);
const html = await readMailHtml(mail!, msg as { ID?: string; id?: number; HTML?: string });
const link = firstLinkMatching(html, /reset-password/);
expect(link, 'reset link missing from email HTML').toBeTruthy();
const url = new URL(link!);
expect(url.hostname).toBe(playkitConfig.expectedHost);
expect(url.hostname).not.toMatch(/^10\./);
expect(url.pathname).toMatch(/reset-password/);
});
test('forgot-password UI shows success and mail trap gets mail', async ({
page,
playkitConfig,
timings,
mail,
e2eCredentials,
}) => {
test.skip(!mail, 'Set MAILPIT_BASE_URL (+ auth) or MAILTRAP_* secrets');
const to =
process.env.E2E_MAIL_TO ||
e2eCredentials?.email ||
process.env.E2E_ADMIN_EMAIL ||
'';
test.skip(!to, 'Set E2E_MAIL_TO or E2E_ADMIN_EMAIL');
assertPublicHost(playkitConfig.baseUrl);
const after = new Date(Date.now() - 5_000);
await timings.measure('open_home', () => page.goto(playkitConfig.baseUrl));
await page.getByRole('button', { name: /Sign in/i }).click();
await page.getByRole('button', { name: /Forgot password/i }).click();
await page.locator('#forgot-email').fill(to);
await page.getByRole('button', { name: /Send reset link/i }).click();
await expect(
page.getByText(/Password reset email sent|check your inbox/i).first(),
).toBeVisible({ timeout: 30_000 });
await waitForUrlHost(page, playkitConfig.expectedHost);
const msg = await mail!.waitForEmail({
to,
subject: /reset|password/i,
after,
timeoutMs: 90_000,
});
expect(msg).toBeTruthy();
});
});

View File

@ -0,0 +1,40 @@
import { assertPublicHost, waitForUrlHost } from '@levkin/playkit';
import { test, expect } from '../fixtures';
/**
* Idle logout must redirect to the public host (same class as #57).
* Uses ?e2e_idle_ms= to shorten the IdleLogoutHandler timeout.
*/
test.describe('auth idle @smoke', () => {
test('idle logout stays on public host', async ({
page,
playkitConfig,
loginPage,
e2eCredentials,
timings,
}) => {
test.skip(!e2eCredentials, 'E2E_ADMIN_EMAIL/PASSWORD required');
assertPublicHost(playkitConfig.baseUrl);
await timings.measure('login', async () => {
await loginPage.openLogin();
await loginPage.signIn(e2eCredentials!.email, e2eCredentials!.password);
await page.waitForURL((url) => !url.pathname.includes('/login'), { timeout: 30_000 });
await page.getByLabel('Account menu').waitFor({ state: 'visible', timeout: 30_000 });
});
// Short idle window — requires IdleLogoutHandler e2e_idle_ms support on DEV
await page.goto(`${playkitConfig.baseUrl}/?e2e_idle_ms=2500`);
await waitForUrlHost(page, playkitConfig.expectedHost);
await expect(page.getByLabel('Account menu')).toBeVisible();
// Stop activity; wait for idle sign-out
await page.waitForTimeout(4_000);
await expect(page.getByRole('button', { name: /Sign in/i })).toBeVisible({
timeout: 20_000,
});
await waitForUrlHost(page, playkitConfig.expectedHost);
expect(page.url()).not.toMatch(/10\.\d+\.\d+\.\d+/);
});
});

View File

@ -0,0 +1,82 @@
import {
assertPublicHost,
firstLinkMatching,
readMailHtml,
waitForUrlHost,
} from '@levkin/playkit';
import { test, expect } from '../fixtures';
/**
* Register confirmation mail in Mailpit verify link on public host.
* Also asserts unverified users cannot sign in.
*/
test.describe('auth register @mail', () => {
test('register, block unverified login, verify via mail trap', async ({
page,
playkitConfig,
registerPage,
loginPage,
mail,
timings,
}) => {
test.skip(!mail, 'Mail trap not configured');
assertPublicHost(playkitConfig.baseUrl);
const stamp = Date.now();
const email = `e2e.register.${stamp}@levkin.test`;
const password = `E2eReg${stamp}!a`;
const after = new Date(Date.now() - 5_000);
await timings.measure('register', async () => {
await registerPage.openRegister();
await registerPage.register({
name: `E2E Register ${stamp}`,
email,
password,
});
await page.waitForURL(/\/login/, { timeout: 30_000 });
});
await expect(page.getByText(/Account created|check your email|confirm/i).first()).toBeVisible({
timeout: 15_000,
});
// Unverified login must be blocked
await loginPage.signIn(email, password);
await expect(
page.getByText(/verify your email|confirmation email/i).first(),
).toBeVisible({ timeout: 20_000 });
await waitForUrlHost(page, playkitConfig.expectedHost);
const msg = await timings.measure('mail_wait', () =>
mail!.waitForEmail({
to: email,
subject: /confirm|verify|welcome|account/i,
after,
timeoutMs: 90_000,
}),
);
const html = await readMailHtml(mail!, msg as { ID?: string; id?: number; HTML?: string });
const link = firstLinkMatching(html, /verify-email|confirm|token=/);
expect(link, 'verification link missing from email').toBeTruthy();
const url = new URL(link!);
expect(url.hostname).toBe(playkitConfig.expectedHost);
// DEV next can briefly refuse during HMR — retry navigation
await expect(async () => {
const res = await page.goto(link!, { waitUntil: 'domcontentloaded', timeout: 20_000 });
expect(res?.status() ?? 0).toBeLessThan(500);
}).toPass({ timeout: 45_000 });
await waitForUrlHost(page, playkitConfig.expectedHost);
await expect(page.getByText(/verified|sign in/i).first()).toBeVisible({ timeout: 20_000 });
// After verify, login should succeed
await loginPage.openLogin();
await loginPage.signIn(email, password);
await page.waitForURL((u) => !u.pathname.includes('/login'), { timeout: 30_000 });
await expect(page.getByLabel('Account menu')).toBeVisible({ timeout: 20_000 });
await waitForUrlHost(page, playkitConfig.expectedHost);
});
});

View File

@ -0,0 +1,56 @@
import {
assertPublicHost,
firstLinkMatching,
readMailHtml,
waitForUrlHost,
} from '@levkin/playkit';
import { test, expect } from '../fixtures';
/**
* Assert the reset link from the mail trap opens on the public host with a form.
* Does not submit a new password (avoids rotating shared DEV admin credentials).
*/
test.describe('auth reset @mail', () => {
test('reset-password link opens public form', async ({
page,
request,
playkitConfig,
mail,
e2eCredentials,
timings,
}) => {
test.skip(!mail, 'Mail trap not configured');
const to =
process.env.E2E_MAIL_TO ||
e2eCredentials?.email ||
process.env.E2E_ADMIN_EMAIL ||
'';
test.skip(!to, 'E2E_MAIL_TO or E2E_ADMIN_EMAIL required');
assertPublicHost(playkitConfig.baseUrl);
const after = new Date(Date.now() - 5_000);
const res = await request.post(`${playkitConfig.baseUrl}/api/auth/forgot-password`, {
data: { email: to },
headers: { 'Content-Type': 'application/json' },
});
expect(res.ok()).toBeTruthy();
const msg = await timings.measure('mail_wait', () =>
mail!.waitForEmail({ to, subject: /reset|password/i, after, timeoutMs: 90_000 }),
);
const html = await readMailHtml(mail!, msg as { ID?: string; id?: number; HTML?: string });
const link = firstLinkMatching(html, /reset-password/);
expect(link).toBeTruthy();
const url = new URL(link!);
expect(url.hostname).toBe(playkitConfig.expectedHost);
await page.goto(link!);
await waitForUrlHost(page, playkitConfig.expectedHost);
await expect(page.locator('#password, input[name="password"]').first()).toBeVisible({
timeout: 15_000,
});
expect(page.url()).not.toMatch(/10\.\d+\.\d+\.\d+/);
});
});

View File

@ -0,0 +1,46 @@
import { assertPublicHost, waitForUrlHost } from '@levkin/playkit';
import path from 'node:path';
import { test, expect } from '../fixtures';
const tinyPng = path.join(__dirname, '../fixtures/tiny.png');
test.describe('upload @smoke', () => {
test.use({ storageState: path.join(__dirname, '../.auth/admin.json') });
test('authenticated user can select a file and POST upload', async ({
page,
playkitConfig,
timings,
}) => {
assertPublicHost(playkitConfig.baseUrl);
await timings.measure('open_upload', async () => {
await page.goto(`${playkitConfig.baseUrl}/upload`);
await waitForUrlHost(page, playkitConfig.expectedHost);
});
expect(new URL(page.url()).pathname).toMatch(/\/upload/);
await expect(page.getByLabel('Account menu')).toBeVisible({ timeout: 20_000 });
await timings.measure('upload_file', async () => {
await page.locator('#file-upload').setInputFiles(tinyPng);
await expect(page.getByText(/tiny\.png/i).first()).toBeVisible({ timeout: 15_000 });
const submit = page.getByRole('button', { name: /Submit for Review/i });
await expect(submit).toBeEnabled({ timeout: 15_000 });
const uploadRespPromise = page.waitForResponse(
(r) => r.url().includes('/api/photos/upload') && r.request().method() === 'POST',
{ timeout: 60_000 },
);
await submit.click();
const uploadResp = await uploadRespPromise;
expect(uploadResp.request().method()).toBe('POST');
// Success banner or inline error/alert — either proves the submit path ran.
const outcome = page
.getByText(/submitted successfully|failed|error|not authorized|permission/i)
.first();
await expect(outcome).toBeVisible({ timeout: 30_000 });
});
});
});

View File

@ -0,0 +1,24 @@
import { assertPublicHost, waitForUrlHost } from '@levkin/playkit';
import path from 'node:path';
import { test, expect } from '../fixtures';
/**
* Viewer (NextAuth) session cookies from storageState.
* FastAPI bearer auth is a separate user store; see api.fastapi-login.spec.ts.
*/
test.describe('viewer session @smoke', () => {
test.use({ storageState: path.join(__dirname, '../.auth/admin.json') });
test('session endpoint returns authenticated user', async ({ page, playkitConfig, timings }) => {
assertPublicHost(playkitConfig.baseUrl);
await timings.measure('session', async () => {
await page.goto(`${playkitConfig.baseUrl}/`);
await waitForUrlHost(page, playkitConfig.expectedHost);
const res = await page.request.get(`${playkitConfig.baseUrl}/api/auth/session`);
expect(res.ok()).toBeTruthy();
const body = await res.json();
expect(body?.user?.email).toBeTruthy();
});
});
});

13
e2e/tsconfig.json Normal file
View File

@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"types": ["node"]
},
"include": ["./**/*.ts", "./**/*.json"]
}

View File

@ -19,7 +19,9 @@
"lint:python": "flake8 backend --max-line-length=100 --ignore=E501,W503,W293,E305,F401,F811,W291,W391,E712,W504,F841,E402,F824,E128,E226,F402,F541,E302,E117,E722 || true",
"lint:python:syntax": "find backend -name '*.py' -exec python -m py_compile {} \\;",
"test:backend": "export PYTHONPATH=$(pwd) && export SKIP_DEEPFACE_IN_TESTS=1 && ./venv/bin/python3 -m pytest tests/ -v",
"test:e2e": "npm test --prefix e2e",
"test:all": "npm run test:backend",
"install:e2e": "npm install --prefix e2e",
"ci:local": "npm run lint:all && npm run type-check:viewer && npm run lint:python && npm run test:backend && npm run build:all",
"deploy:dev": "npm run build:all && echo '✅ Build complete. Ready for deployment to dev server (10.0.10.121)'",
"deploy:dev:prepare": "npm run build:all && mkdir -p deploy/package && cp -r backend deploy/package/ && cp -r admin-frontend/dist deploy/package/admin-frontend-dist && cp -r viewer-frontend/.next deploy/package/viewer-frontend-next && cp requirements.txt deploy/package/ && cp .env.example deploy/package/ && echo '✅ Deployment package prepared in deploy/package/'"

View File

@ -16,7 +16,7 @@ os.environ["SKIP_DEEPFACE_IN_TESTS"] = "1"
from backend.app import create_app
from backend.db.base import Base
from backend.db.session import get_db
from backend.db.session import get_db, get_auth_db
# Test database URL - use environment variable or default
TEST_DATABASE_URL = os.getenv(
@ -24,6 +24,82 @@ TEST_DATABASE_URL = os.getenv(
"postgresql+psycopg2://postgres:postgres@localhost:5432/punimtag_test"
)
# Test auth database URL - separate DB, mirrors the Prisma `schema-auth.prisma`
# tables (viewer-frontend owns that schema; we recreate it here via raw DDL
# since the backend only ever talks to it through raw SQL, never SQLAlchemy).
TEST_AUTH_DATABASE_URL = os.getenv(
"DATABASE_URL_AUTH",
"postgresql+psycopg2://postgres:postgres@localhost:5432/punimtag_auth_test",
)
AUTH_DB_DDL = """
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
password_hash TEXT NOT NULL,
is_admin BOOLEAN NOT NULL DEFAULT FALSE,
has_write_access BOOLEAN NOT NULL DEFAULT FALSE,
email_verified BOOLEAN NOT NULL DEFAULT FALSE,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS pending_identifications (
id SERIAL PRIMARY KEY,
face_id INTEGER NOT NULL,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
first_name TEXT NOT NULL,
last_name TEXT NOT NULL,
middle_name TEXT,
maiden_name TEXT,
date_of_birth DATE,
status TEXT NOT NULL DEFAULT 'pending',
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS pending_photos (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
filename TEXT NOT NULL,
original_filename TEXT NOT NULL,
file_path TEXT NOT NULL,
file_size INTEGER NOT NULL,
mime_type TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
submitted_at TIMESTAMP NOT NULL DEFAULT NOW(),
reviewed_at TIMESTAMP,
reviewed_by INTEGER,
rejection_reason TEXT
);
CREATE TABLE IF NOT EXISTS pending_linkages (
id SERIAL PRIMARY KEY,
photo_id INTEGER NOT NULL,
tag_id INTEGER,
tag_name TEXT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
status TEXT NOT NULL DEFAULT 'pending',
notes TEXT,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS inappropriate_photo_reports (
id SERIAL PRIMARY KEY,
photo_id INTEGER NOT NULL,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
status TEXT NOT NULL DEFAULT 'pending',
reported_at TIMESTAMP NOT NULL DEFAULT NOW(),
reviewed_at TIMESTAMP,
reviewed_by INTEGER,
review_notes TEXT,
report_comment TEXT
);
"""
@pytest.fixture(scope="session")
def test_db_engine():
@ -58,15 +134,60 @@ def test_db_session(test_db_engine) -> Generator[Session, None, None]:
connection.close()
@pytest.fixture(scope="session")
def test_auth_db_engine():
"""Create the auth-database engine and (re)create its tables via raw DDL.
The auth DB schema is owned by Prisma (`viewer-frontend/prisma/schema-auth.prisma`);
the backend only ever touches it with raw SQL, so we mirror the same tables
here instead of pulling in Prisma for tests.
"""
engine = create_engine(TEST_AUTH_DATABASE_URL, future=True)
with engine.begin() as conn:
for statement in AUTH_DB_DDL.strip().split(";\n\n"):
if statement.strip():
conn.exec_driver_sql(statement)
yield engine
with engine.begin() as conn:
conn.exec_driver_sql(
"DROP TABLE IF EXISTS inappropriate_photo_reports, pending_linkages, "
"pending_photos, pending_identifications, users CASCADE"
)
engine.dispose()
@pytest.fixture(scope="function")
def test_client(test_db_session: Session) -> Generator[TestClient, None, None]:
"""Create a test client with test database dependency override."""
def test_auth_db_session(test_auth_db_engine) -> Generator[Session, None, None]:
"""Auth-database session with transaction rollback for test isolation."""
connection = test_auth_db_engine.connect()
transaction = connection.begin()
session = sessionmaker(bind=connection, autoflush=False, autocommit=False)()
yield session
session.close()
transaction.rollback()
connection.close()
@pytest.fixture(scope="function")
def test_client(
test_db_session: Session, test_auth_db_session: Session
) -> Generator[TestClient, None, None]:
"""Create a test client with test database dependency overrides."""
app = create_app()
def override_get_db() -> Generator[Session, None, None]:
yield test_db_session
def override_get_auth_db() -> Generator[Session, None, None]:
yield test_auth_db_session
app.dependency_overrides[get_db] = override_get_db
app.dependency_overrides[get_auth_db] = override_get_auth_db
with TestClient(app) as client:
yield client
@ -75,6 +196,36 @@ def test_client(test_db_session: Session) -> Generator[TestClient, None, None]:
app.dependency_overrides.clear()
@pytest.fixture
def auth_db_user(test_auth_db_session: Session):
"""Create a row in the auth `users` table (NextAuth/viewer-frontend account).
Returns the new row's id. Callers that need multiple users should call
this fixture function pattern manually via `_create_auth_db_user`.
"""
return _create_auth_db_user(test_auth_db_session, email="reporter@example.com")
def _create_auth_db_user(
session: Session, email: str = "reporter@example.com", name: str = "Reporter"
) -> int:
"""Insert a row into the auth `users` table and return its id."""
from sqlalchemy import text as sa_text
result = session.execute(
sa_text(
"""
INSERT INTO users (email, name, password_hash)
VALUES (:email, :name, 'not-a-real-hash')
RETURNING id
"""
),
{"email": email, "name": name},
)
session.commit()
return result.scalar_one()
@pytest.fixture
def admin_user(test_db_session: Session):
"""Create an admin user for testing."""

View File

@ -0,0 +1,49 @@
"""Tests for the admin click-logging endpoint (/api/v1/log/click)."""
from __future__ import annotations
class TestLogClickEvent:
def test_log_click_success(self, test_client, admin_user, auth_headers):
"""Verify a well-formed click event is accepted and logged."""
response = test_client.post(
"/api/v1/log/click",
json={
"page": "/identify",
"element_type": "button",
"element_id": "save-btn",
"element_text": "Save",
"context": {"photo_id": 123},
},
headers=auth_headers,
)
assert response.status_code == 200
body = response.json()
assert body["status"] == "ok"
def test_log_click_without_optional_fields(self, test_client, admin_user, auth_headers):
"""Verify optional fields (element_id, element_text, context) can be omitted."""
response = test_client.post(
"/api/v1/log/click",
json={"page": "/search", "element_type": "link"},
headers=auth_headers,
)
assert response.status_code == 200
assert response.json()["status"] == "ok"
def test_log_click_missing_required_field(self, test_client, admin_user, auth_headers):
"""Verify 422 when a required field (element_type) is missing."""
response = test_client.post(
"/api/v1/log/click",
json={"page": "/identify"},
headers=auth_headers,
)
assert response.status_code == 422
def test_log_click_unauthenticated(self, test_client):
"""Verify 401 without an Authorization header."""
response = test_client.post(
"/api/v1/log/click",
json={"page": "/identify", "element_type": "button"},
)
assert response.status_code == 401

195
tests/test_api_errors.py Normal file
View File

@ -0,0 +1,195 @@
"""Cross-endpoint error-handling and edge-case sweeps.
Complements the per-endpoint success/failure tests in the other
`test_api_*.py` files by checking that error *shapes* (status codes, no
crashes, no data corruption) are consistent across the API surface, and by
probing a few classic edge cases (injection-style input, large payloads).
"""
from __future__ import annotations
class TestNotFoundResponses:
"""A made-up ID should be a clean 404 everywhere, never a 500."""
def test_person_not_found(self, test_client, admin_user, auth_headers):
response = test_client.get("/api/v1/people/999999", headers=auth_headers)
assert response.status_code == 404
def test_photo_not_found(self, test_client, admin_user, auth_headers):
response = test_client.get("/api/v1/photos/999999", headers=auth_headers)
assert response.status_code == 404
def test_face_similar_not_found(self, test_client, admin_user, auth_headers):
response = test_client.get("/api/v1/faces/999999/similar", headers=auth_headers)
assert response.status_code == 404
def test_user_not_found(self, test_client, admin_user, auth_headers):
response = test_client.get("/api/v1/users/999999", headers=auth_headers)
assert response.status_code == 404
def test_video_people_not_found(self, test_client):
response = test_client.get("/api/v1/videos/999999/people")
assert response.status_code == 404
def test_tag_update_not_found(self, test_client, admin_user, auth_headers):
response = test_client.put(
"/api/v1/tags/999999", json={"tag_name": "renamed"}, headers=auth_headers
)
assert response.status_code == 400 # ValueError from the service, not a 404 tag lookup
class TestUnauthorizedResponses:
"""Protected endpoints should uniformly return 401 when no token is sent."""
@staticmethod
def _cases():
return [
("get", "/api/v1/photos", {"params": {"search_type": "no_faces"}}),
("get", "/api/v1/users", {}),
("get", "/api/v1/role-permissions", {}),
("get", "/api/v1/videos", {}),
("post", "/api/v1/log/click", {"json": {"page": "/x", "element_type": "button"}}),
]
def test_all_protected_endpoints_reject_missing_token(self, test_client):
for method, path, kwargs in self._cases():
response = getattr(test_client, method)(path, **kwargs)
assert response.status_code == 401, f"{method.upper()} {path} did not return 401"
def test_malformed_bearer_token_rejected(self, test_client):
response = test_client.get(
"/api/v1/users",
headers={"Authorization": "Bearer not-a-real-jwt"},
)
assert response.status_code == 401
def test_unidentified_faces_endpoint_has_no_auth_dependency(self, test_client):
"""Documents current behavior: unlike almost every other /api/v1/faces/*
route, this one has no `Depends(get_current_user)`, so it's reachable
without a token. Flagging via a passing test (not asserting 401) so a
future auth addition here is a deliberate, visible change."""
response = test_client.get("/api/v1/faces/unidentified")
assert response.status_code == 200
class TestForbiddenResponses:
"""Admin-only endpoints should uniformly return 403 for a regular user."""
@staticmethod
def _cases():
return [
("get", "/api/v1/users", {}),
("get", "/api/v1/role-permissions", {}),
("post", "/api/v1/pending-identifications/clear-denied", {}),
("post", "/api/v1/reported-photos/cleanup", {}),
]
def test_all_admin_only_endpoints_reject_regular_user(
self, test_client, admin_user, regular_auth_headers
):
for method, path, kwargs in self._cases():
kwargs["headers"] = regular_auth_headers
response = getattr(test_client, method)(path, **kwargs)
assert response.status_code == 403, f"{method.upper()} {path} did not return 403"
class TestValidationResponses:
def test_malformed_json_types_rejected_with_422(
self, test_client, admin_user, auth_headers
):
"""photo_ids should be a list of ints, not a bare string."""
response = test_client.post(
"/api/v1/photos/bulk-add-favorites",
json={"photo_ids": "not-a-list"},
headers=auth_headers,
)
assert response.status_code == 422
def test_missing_required_body_field_rejected_with_422(
self, test_client, admin_user, auth_headers
):
response = test_client.post("/api/v1/tags", json={}, headers=auth_headers)
assert response.status_code == 422
def test_empty_bulk_delete_list_rejected_with_400(
self, test_client, admin_user, auth_headers
):
response = test_client.post(
"/api/v1/photos/bulk-delete", json={"photo_ids": []}, headers=auth_headers
)
assert response.status_code == 400
class TestInjectionAndUnsafeInputHandling:
"""The API uses parameterized SQL/ORM everywhere; these are smoke checks
that hostile-looking strings are treated as plain data, not executed.
"""
def test_sql_injection_style_tag_name_is_stored_literally(
self, test_client, admin_user, auth_headers
):
payload = "'; DROP TABLE tags; --"
create_resp = test_client.post(
"/api/v1/tags", json={"tag_name": payload}, headers=auth_headers
)
assert create_resp.status_code == 200
assert create_resp.json()["tag_name"] == payload
# Table must still exist and be queryable.
list_resp = test_client.get("/api/v1/tags", headers=auth_headers)
assert list_resp.status_code == 200
assert any(t["tag_name"] == payload for t in list_resp.json()["items"])
def test_script_tag_in_person_name_is_stored_literally_not_executed(
self, test_client, admin_user, auth_headers
):
payload = "<script>alert(1)</script>"
response = test_client.post(
"/api/v1/people",
json={"first_name": payload, "last_name": "XSS"},
headers=auth_headers,
)
assert response.status_code == 201
assert response.json()["first_name"] == payload
def test_sql_injection_style_search_param_returns_normal_response(
self, test_client, admin_user, auth_headers
):
response = test_client.get(
"/api/v1/photos",
params={"search_type": "name", "person_name": "'; DROP TABLE photos; --"},
headers=auth_headers,
)
# No matches expected, but must not error out or corrupt data.
assert response.status_code == 200
assert response.json()["items"] == []
class TestLargePayloadHandling:
def test_large_bulk_favorites_request_handled_gracefully(
self, test_client, admin_user, auth_headers
):
"""A large list of nonexistent photo IDs should 404 cleanly, not hang or crash."""
bogus_ids = list(range(900000, 900500))
response = test_client.post(
"/api/v1/photos/bulk-add-favorites",
json={"photo_ids": bogus_ids},
headers=auth_headers,
)
assert response.status_code == 404
def test_many_decisions_in_one_review_request(
self, test_client, admin_user, auth_headers
):
"""Reviewing a batch of nonexistent pending-linkage IDs shouldn't crash;
every decision should surface as an individual error entry."""
decisions = [{"id": i, "decision": "deny"} for i in range(900000, 900200)]
response = test_client.post(
"/api/v1/pending-linkages/review",
json={"decisions": decisions},
headers=auth_headers,
)
assert response.status_code == 200
body = response.json()
assert len(body["errors"]) == len(decisions)

View File

@ -0,0 +1,171 @@
"""Cross-endpoint workflow ("integration") tests.
Unlike the other `test_api_*.py` files (one endpoint's success/failure paths
per test), these chain several endpoints together the way a real client
would, to catch regressions that only show up when features interact.
Workflows that need Redis/RQ (photo import, face processing, auto-match) or
DeepFace (similarity) are intentionally out of scope here this sandbox has
neither running, and those are already covered where feasible in
`test_api_faces.py`/`test_api_jobs.py`.
"""
from __future__ import annotations
class TestPersonIdentificationWorkflow:
def test_create_person_then_identify_face_removes_it_from_unidentified(
self, test_client, admin_user, auth_headers, test_face
):
create_resp = test_client.post(
"/api/v1/people",
json={"first_name": "Alice", "last_name": "Wonder"},
headers=auth_headers,
)
assert create_resp.status_code == 201
person_id = create_resp.json()["id"]
before = test_client.get("/api/v1/faces/unidentified", headers=auth_headers)
assert test_face.id in {item["id"] for item in before.json()["items"]}
identify_resp = test_client.post(
f"/api/v1/faces/{test_face.id}/identify",
json={"person_id": person_id},
headers=auth_headers,
)
assert identify_resp.status_code == 200
after = test_client.get("/api/v1/faces/unidentified", headers=auth_headers)
assert test_face.id not in {item["id"] for item in after.json()["items"]}
person_faces = test_client.get(
f"/api/v1/people/{person_id}/faces", headers=auth_headers
)
assert person_faces.status_code == 200
assert any(f["id"] == test_face.id for f in person_faces.json()["items"])
class TestTagAndSearchWorkflow:
def test_tag_photo_then_search_by_tag_then_untag(
self, test_client, admin_user, auth_headers, test_photo
):
tag_resp = test_client.post(
"/api/v1/tags", json={"tag_name": "workflow-tag"}, headers=auth_headers
)
assert tag_resp.status_code == 200
add_resp = test_client.post(
"/api/v1/tags/photos/add",
json={"photo_ids": [test_photo.id], "tag_names": ["workflow-tag"]},
headers=auth_headers,
)
assert add_resp.status_code == 200
assert add_resp.json()["photos_updated"] == 1
search_resp = test_client.get(
"/api/v1/photos",
params={"search_type": "tags", "tag_names": "workflow-tag"},
headers=auth_headers,
)
assert search_resp.status_code == 200
assert test_photo.id in {item["id"] for item in search_resp.json()["items"]}
remove_resp = test_client.post(
"/api/v1/tags/photos/remove",
json={"photo_ids": [test_photo.id], "tag_names": ["workflow-tag"]},
headers=auth_headers,
)
assert remove_resp.status_code == 200
after = test_client.get(
"/api/v1/photos",
params={"search_type": "tags", "tag_names": "workflow-tag"},
headers=auth_headers,
)
assert test_photo.id not in {item["id"] for item in after.json()["items"]}
class TestFavoritesWorkflow:
def test_favorite_photo_then_search_favorites_then_unfavorite(
self, test_client, admin_user, auth_headers, test_photo
):
toggle_on = test_client.post(
f"/api/v1/photos/{test_photo.id}/toggle-favorite", headers=auth_headers
)
assert toggle_on.status_code == 200
search_resp = test_client.get(
"/api/v1/photos", params={"search_type": "favorites"}, headers=auth_headers
)
assert search_resp.status_code == 200
assert test_photo.id in {item["id"] for item in search_resp.json()["items"]}
toggle_off = test_client.post(
f"/api/v1/photos/{test_photo.id}/toggle-favorite", headers=auth_headers
)
assert toggle_off.status_code == 200
after = test_client.get(
"/api/v1/photos", params={"search_type": "favorites"}, headers=auth_headers
)
assert test_photo.id not in {item["id"] for item in after.json()["items"]}
def test_favorites_search_requires_auth(self, test_client):
response = test_client.get("/api/v1/photos", params={"search_type": "favorites"})
assert response.status_code == 401
class TestBulkFavoritesWorkflow:
def test_bulk_add_then_bulk_remove(
self, test_client, admin_user, auth_headers, test_photo, test_photo_2
):
add_resp = test_client.post(
"/api/v1/photos/bulk-add-favorites",
json={"photo_ids": [test_photo.id, test_photo_2.id]},
headers=auth_headers,
)
assert add_resp.status_code == 200
assert add_resp.json()["added_count"] == 2
# Re-adding the same photos should be a no-op, not a duplicate.
add_again = test_client.post(
"/api/v1/photos/bulk-add-favorites",
json={"photo_ids": [test_photo.id, test_photo_2.id]},
headers=auth_headers,
)
assert add_again.json()["added_count"] == 0
assert add_again.json()["already_favorite_count"] == 2
remove_resp = test_client.post(
"/api/v1/photos/bulk-remove-favorites",
json={"photo_ids": [test_photo.id, test_photo_2.id]},
headers=auth_headers,
)
assert remove_resp.status_code == 200
assert remove_resp.json()["removed_count"] == 2
class TestUserRolePermissionsWorkflow:
def test_new_viewer_user_gains_access_after_role_permission_grant(
self, test_client, admin_user, auth_headers, regular_user, regular_auth_headers
):
"""`regular_user` is created with the default VIEWER role, which does
not include `user_uploaded` out of the box (see
backend/constants/role_features.py). Granting it should immediately
unlock the pending-photos endpoint for that user, without re-login.
"""
before = test_client.get(
"/api/v1/pending-photos", headers=regular_auth_headers
)
assert before.status_code == 403
grant_resp = test_client.put(
"/api/v1/role-permissions",
json={"permissions": {"viewer": {"user_uploaded": True}}},
headers=auth_headers,
)
assert grant_resp.status_code == 200
assert grant_resp.json()["permissions"]["viewer"]["user_uploaded"] is True
after = test_client.get("/api/v1/pending-photos", headers=regular_auth_headers)
assert after.status_code == 200

18
tests/test_api_metrics.py Normal file
View File

@ -0,0 +1,18 @@
"""Tests for the /metrics endpoint."""
from __future__ import annotations
class TestMetricsEndpoint:
def test_metrics_endpoint_success(self, test_client):
"""Verify /metrics returns 200 with the placeholder status payload."""
response = test_client.get("/metrics")
assert response.status_code == 200
body = response.json()
assert body["status"] == "ok"
assert "message" in body
def test_metrics_endpoint_no_auth_required(self, test_client):
"""Metrics endpoint should be reachable without an Authorization header."""
response = test_client.get("/metrics")
assert response.status_code != 401

View File

@ -0,0 +1,191 @@
"""Tests for the pending-identifications approval workflow (/api/v1/pending-identifications).
These endpoints read/write the separate auth database (raw SQL against tables
owned by `viewer-frontend/prisma/schema-auth.prisma`), so setup here inserts
rows directly via `test_auth_db_session` rather than going through the API.
"""
from __future__ import annotations
from datetime import date
import pytest
from sqlalchemy import text
from tests.conftest import _create_auth_db_user
def _insert_pending_identification(
session,
face_id: int,
user_id: int,
first_name: str = "Jane",
last_name: str = "Doe",
status: str = "pending",
) -> int:
result = session.execute(
text(
"""
INSERT INTO pending_identifications
(face_id, user_id, first_name, last_name, status)
VALUES (:face_id, :user_id, :first_name, :last_name, :status)
RETURNING id
"""
),
{
"face_id": face_id,
"user_id": user_id,
"first_name": first_name,
"last_name": last_name,
"status": status,
},
)
session.commit()
return result.scalar_one()
@pytest.fixture
def auth_reporter(test_auth_db_session):
return _create_auth_db_user(test_auth_db_session, email="viewer@example.com")
class TestListPendingIdentifications:
def test_list_default_excludes_denied(
self, test_client, admin_user, auth_headers, test_auth_db_session, test_face, auth_reporter
):
_insert_pending_identification(test_auth_db_session, test_face.id, auth_reporter, status="pending")
_insert_pending_identification(
test_auth_db_session, test_face.id, auth_reporter, first_name="Denied", status="denied"
)
response = test_client.get("/api/v1/pending-identifications", headers=auth_headers)
assert response.status_code == 200
body = response.json()
names = {item["first_name"] for item in body["items"]}
assert "Jane" in names
assert "Denied" not in names
def test_list_include_denied(
self, test_client, admin_user, auth_headers, test_auth_db_session, test_face, auth_reporter
):
_insert_pending_identification(
test_auth_db_session, test_face.id, auth_reporter, first_name="Denied", status="denied"
)
response = test_client.get(
"/api/v1/pending-identifications",
params={"include_denied": True},
headers=auth_headers,
)
assert response.status_code == 200
names = {item["first_name"] for item in response.json()["items"]}
assert "Denied" in names
def test_list_unauthenticated(self, test_client):
response = test_client.get("/api/v1/pending-identifications")
assert response.status_code == 401
class TestApproveDenyPendingIdentifications:
def test_approve_creates_person_and_identifies_face(
self, test_client, admin_user, auth_headers, test_auth_db_session, test_face, auth_reporter
):
pending_id = _insert_pending_identification(
test_auth_db_session, test_face.id, auth_reporter, first_name="Approved", last_name="Person"
)
response = test_client.post(
"/api/v1/pending-identifications/approve-deny",
json={"decisions": [{"id": pending_id, "decision": "approve"}]},
headers=auth_headers,
)
assert response.status_code == 200
body = response.json()
assert body["approved"] == 1
assert body["denied"] == 0
assert body["errors"] == []
def test_deny_updates_status_only(
self, test_client, admin_user, auth_headers, test_auth_db_session, test_face, auth_reporter
):
pending_id = _insert_pending_identification(test_auth_db_session, test_face.id, auth_reporter)
response = test_client.post(
"/api/v1/pending-identifications/approve-deny",
json={"decisions": [{"id": pending_id, "decision": "deny"}]},
headers=auth_headers,
)
assert response.status_code == 200
body = response.json()
assert body["denied"] == 1
assert body["approved"] == 0
def test_approve_deny_not_found(self, test_client, admin_user, auth_headers):
response = test_client.post(
"/api/v1/pending-identifications/approve-deny",
json={"decisions": [{"id": 999999, "decision": "approve"}]},
headers=auth_headers,
)
assert response.status_code == 200
body = response.json()
assert body["errors"]
assert body["approved"] == 0
def test_approve_deny_unauthenticated(self, test_client):
response = test_client.post(
"/api/v1/pending-identifications/approve-deny",
json={"decisions": [{"id": 1, "decision": "approve"}]},
)
assert response.status_code == 401
class TestIdentificationReport:
def test_report_counts_identified_faces(
self, test_client, admin_user, auth_headers, test_db_session, identified_face
):
identified_face.identified_by_user_id = admin_user.id
test_db_session.add(identified_face)
test_db_session.commit()
response = test_client.get("/api/v1/pending-identifications/report", headers=auth_headers)
assert response.status_code == 200
body = response.json()
assert body["total_faces"] >= 1
assert any(item["user_id"] == admin_user.id for item in body["items"])
def test_report_invalid_date_format(self, test_client, admin_user, auth_headers):
response = test_client.get(
"/api/v1/pending-identifications/report",
params={"date_from": "not-a-date"},
headers=auth_headers,
)
assert response.status_code == 400
class TestClearDeniedIdentifications:
def test_clear_denied_deletes_denied_records(
self, test_client, admin_user, auth_headers, test_auth_db_session, test_face, auth_reporter
):
_insert_pending_identification(
test_auth_db_session, test_face.id, auth_reporter, status="denied"
)
response = test_client.post(
"/api/v1/pending-identifications/clear-denied", headers=auth_headers
)
assert response.status_code == 200
body = response.json()
assert body["deleted_records"] == 1
def test_clear_denied_no_records(self, test_client, admin_user, auth_headers):
response = test_client.post(
"/api/v1/pending-identifications/clear-denied", headers=auth_headers
)
assert response.status_code == 200
assert response.json()["deleted_records"] == 0
def test_clear_denied_non_admin(self, test_client, regular_auth_headers, admin_user):
response = test_client.post(
"/api/v1/pending-identifications/clear-denied", headers=regular_auth_headers
)
assert response.status_code == 403

View File

@ -0,0 +1,156 @@
"""Tests for the pending tag-linkage review workflow (/api/v1/pending-linkages)."""
from __future__ import annotations
import pytest
from sqlalchemy import text
from tests.conftest import _create_auth_db_user
def _insert_pending_linkage(
session,
photo_id: int,
user_id: int,
tag_name: str = "vacation",
tag_id: int | None = None,
status: str = "pending",
) -> int:
result = session.execute(
text(
"""
INSERT INTO pending_linkages (photo_id, tag_id, tag_name, user_id, status)
VALUES (:photo_id, :tag_id, :tag_name, :user_id, :status)
RETURNING id
"""
),
{
"photo_id": photo_id,
"tag_id": tag_id,
"tag_name": tag_name,
"user_id": user_id,
"status": status,
},
)
session.commit()
return result.scalar_one()
@pytest.fixture
def auth_reporter(test_auth_db_session):
return _create_auth_db_user(test_auth_db_session, email="tagger@example.com")
class TestListPendingLinkages:
def test_list_success(
self, test_client, admin_user, auth_headers, test_auth_db_session, test_photo, auth_reporter
):
_insert_pending_linkage(test_auth_db_session, test_photo.id, auth_reporter)
response = test_client.get("/api/v1/pending-linkages", headers=auth_headers)
assert response.status_code == 200
body = response.json()
assert body["total"] >= 1
assert body["items"][0]["proposed_tag_name"] == "vacation"
assert body["items"][0]["photo_filename"] == test_photo.filename
def test_list_filter_by_status(
self, test_client, admin_user, auth_headers, test_auth_db_session, test_photo, auth_reporter
):
_insert_pending_linkage(test_auth_db_session, test_photo.id, auth_reporter, status="denied")
response = test_client.get(
"/api/v1/pending-linkages",
params={"status_filter": "pending"},
headers=auth_headers,
)
assert response.status_code == 200
assert response.json()["total"] == 0
def test_list_invalid_status_filter(self, test_client, admin_user, auth_headers):
response = test_client.get(
"/api/v1/pending-linkages",
params={"status_filter": "not_a_status"},
headers=auth_headers,
)
assert response.status_code == 400
def test_list_unauthenticated(self, test_client):
response = test_client.get("/api/v1/pending-linkages")
assert response.status_code == 401
class TestReviewPendingLinkages:
def test_approve_creates_tag_and_linkage(
self, test_client, admin_user, auth_headers, test_auth_db_session, test_photo, auth_reporter
):
linkage_id = _insert_pending_linkage(
test_auth_db_session, test_photo.id, auth_reporter, tag_name="new-tag"
)
response = test_client.post(
"/api/v1/pending-linkages/review",
json={"decisions": [{"id": linkage_id, "decision": "approve"}]},
headers=auth_headers,
)
assert response.status_code == 200
body = response.json()
assert body["approved"] == 1
assert body["tags_created"] == 1
assert body["linkages_created"] == 1
tags = test_client.get(f"/api/v1/tags/photos/{test_photo.id}")
assert any(t["tag_name"] == "new-tag" for t in tags.json()["tags"])
def test_deny_only_updates_status(
self, test_client, admin_user, auth_headers, test_auth_db_session, test_photo, auth_reporter
):
linkage_id = _insert_pending_linkage(test_auth_db_session, test_photo.id, auth_reporter)
response = test_client.post(
"/api/v1/pending-linkages/review",
json={"decisions": [{"id": linkage_id, "decision": "deny"}]},
headers=auth_headers,
)
assert response.status_code == 200
body = response.json()
assert body["denied"] == 1
assert body["tags_created"] == 0
def test_review_not_found(self, test_client, admin_user, auth_headers):
response = test_client.post(
"/api/v1/pending-linkages/review",
json={"decisions": [{"id": 999999, "decision": "approve"}]},
headers=auth_headers,
)
assert response.status_code == 200
assert response.json()["errors"]
def test_review_unauthenticated(self, test_client):
response = test_client.post(
"/api/v1/pending-linkages/review",
json={"decisions": [{"id": 1, "decision": "approve"}]},
)
assert response.status_code == 401
class TestCleanupPendingLinkages:
def test_cleanup_deletes_resolved_records(
self, test_client, admin_user, auth_headers, test_auth_db_session, test_photo, auth_reporter
):
_insert_pending_linkage(test_auth_db_session, test_photo.id, auth_reporter, status="approved")
_insert_pending_linkage(test_auth_db_session, test_photo.id, auth_reporter, status="pending")
response = test_client.post("/api/v1/pending-linkages/cleanup", headers=auth_headers)
assert response.status_code == 200
assert response.json()["deleted_records"] == 1
remaining = test_client.get("/api/v1/pending-linkages", headers=auth_headers)
assert remaining.json()["total"] == 1
def test_cleanup_no_records_returns_warning(self, test_client, admin_user, auth_headers):
response = test_client.post("/api/v1/pending-linkages/cleanup", headers=auth_headers)
assert response.status_code == 200
body = response.json()
assert body["deleted_records"] == 0
assert body["warnings"]

View File

@ -0,0 +1,157 @@
"""Tests for the inappropriate-photo-report review workflow (/api/v1/reported-photos)."""
from __future__ import annotations
import pytest
from sqlalchemy import text
from tests.conftest import _create_auth_db_user
def _insert_report(
session,
photo_id: int,
user_id: int,
report_comment: str = "inappropriate",
status: str = "pending",
) -> int:
result = session.execute(
text(
"""
INSERT INTO inappropriate_photo_reports (photo_id, user_id, report_comment, status)
VALUES (:photo_id, :user_id, :report_comment, :status)
RETURNING id
"""
),
{
"photo_id": photo_id,
"user_id": user_id,
"report_comment": report_comment,
"status": status,
},
)
session.commit()
return result.scalar_one()
@pytest.fixture
def auth_reporter(test_auth_db_session):
return _create_auth_db_user(test_auth_db_session, email="reporter2@example.com")
class TestListReportedPhotos:
def test_list_success(
self, test_client, admin_user, auth_headers, test_auth_db_session, test_photo, auth_reporter
):
_insert_report(test_auth_db_session, test_photo.id, auth_reporter)
response = test_client.get("/api/v1/reported-photos", headers=auth_headers)
assert response.status_code == 200
body = response.json()
assert body["total"] >= 1
assert body["items"][0]["photo_filename"] == test_photo.filename
def test_list_filter_by_status(
self, test_client, admin_user, auth_headers, test_auth_db_session, test_photo, auth_reporter
):
_insert_report(test_auth_db_session, test_photo.id, auth_reporter, status="dismissed")
response = test_client.get(
"/api/v1/reported-photos", params={"status_filter": "pending"}, headers=auth_headers
)
assert response.status_code == 200
assert response.json()["total"] == 0
def test_list_unauthenticated(self, test_client):
response = test_client.get("/api/v1/reported-photos")
assert response.status_code == 401
class TestReviewReportedPhotos:
def test_keep_marks_reviewed_and_preserves_photo(
self,
test_client,
admin_user,
auth_headers,
test_auth_db_session,
test_db_session,
test_photo,
auth_reporter,
):
report_id = _insert_report(test_auth_db_session, test_photo.id, auth_reporter)
response = test_client.post(
"/api/v1/reported-photos/review",
json={"decisions": [{"id": report_id, "decision": "keep"}]},
headers=auth_headers,
)
assert response.status_code == 200
body = response.json()
assert body["kept"] == 1
assert body["removed"] == 0
# Photo must still exist in the main database.
still_there = test_client.get(f"/api/v1/photos/{test_photo.id}", headers=auth_headers)
assert still_there.status_code == 200
def test_remove_deletes_photo_from_main_db(
self, test_client, admin_user, auth_headers, test_auth_db_session, test_photo, auth_reporter
):
report_id = _insert_report(test_auth_db_session, test_photo.id, auth_reporter)
response = test_client.post(
"/api/v1/reported-photos/review",
json={"decisions": [{"id": report_id, "decision": "remove"}]},
headers=auth_headers,
)
assert response.status_code == 200
body = response.json()
assert body["removed"] == 1
gone = test_client.get(f"/api/v1/photos/{test_photo.id}", headers=auth_headers)
assert gone.status_code == 404
def test_review_not_found(self, test_client, admin_user, auth_headers):
response = test_client.post(
"/api/v1/reported-photos/review",
json={"decisions": [{"id": 999999, "decision": "keep"}]},
headers=auth_headers,
)
assert response.status_code == 200
assert response.json()["errors"]
def test_review_unauthenticated(self, test_client):
response = test_client.post(
"/api/v1/reported-photos/review",
json={"decisions": [{"id": 1, "decision": "keep"}]},
)
assert response.status_code == 401
class TestCleanupReportedPhotos:
def test_cleanup_removes_dismissed_when_filter_is_remove(
self, test_client, admin_user, auth_headers, test_auth_db_session, test_photo, auth_reporter
):
_insert_report(test_auth_db_session, test_photo.id, auth_reporter, status="dismissed")
response = test_client.post(
"/api/v1/reported-photos/cleanup",
params={"status_filter": "remove"},
headers=auth_headers,
)
assert response.status_code == 200
assert response.json()["deleted_records"] == 1
def test_cleanup_invalid_status_filter(self, test_client, admin_user, auth_headers):
response = test_client.post(
"/api/v1/reported-photos/cleanup",
params={"status_filter": "not_a_valid_filter"},
headers=auth_headers,
)
assert response.status_code == 400
def test_cleanup_non_admin(self, test_client, regular_auth_headers, admin_user):
response = test_client.post(
"/api/v1/reported-photos/cleanup", headers=regular_auth_headers
)
assert response.status_code == 403

View File

@ -0,0 +1,79 @@
"""Tests for role/feature permission management (/api/v1/role-permissions)."""
from __future__ import annotations
class TestListRolePermissions:
def test_list_role_permissions_success(self, test_client, admin_user, auth_headers):
"""Verify the full feature/permission matrix is returned for admins."""
response = test_client.get("/api/v1/role-permissions", headers=auth_headers)
assert response.status_code == 200
body = response.json()
assert "features" in body
assert "permissions" in body
assert len(body["features"]) > 0
assert all("key" in f and "label" in f for f in body["features"])
def test_list_role_permissions_non_admin(
self, test_client, regular_auth_headers, admin_user
):
"""Verify 403 for non-admin users."""
response = test_client.get("/api/v1/role-permissions", headers=regular_auth_headers)
assert response.status_code == 403
def test_list_role_permissions_unauthenticated(self, test_client):
"""Verify 401 without a token."""
response = test_client.get("/api/v1/role-permissions")
assert response.status_code == 401
class TestUpdateRolePermissions:
def test_update_role_permissions_success(self, test_client, admin_user, auth_headers):
"""Verify a valid permission update round-trips through the API."""
response = test_client.put(
"/api/v1/role-permissions",
json={"permissions": {"viewer": {"user_identified": False}}},
headers=auth_headers,
)
assert response.status_code == 200
body = response.json()
assert body["permissions"]["viewer"]["user_identified"] is False
# Persisted, not just echoed back
follow_up = test_client.get("/api/v1/role-permissions", headers=auth_headers)
assert follow_up.json()["permissions"]["viewer"]["user_identified"] is False
def test_update_role_permissions_invalid_role(self, test_client, admin_user, auth_headers):
"""Verify an unknown role key is rejected.
The request schema types `permissions` keys as the `UserRole` enum, so
Pydantic itself rejects unknown roles with 422 before the endpoint's
own `Invalid role(s)` 400 check ever runs.
"""
response = test_client.put(
"/api/v1/role-permissions",
json={"permissions": {"not_a_real_role": {"user_identified": True}}},
headers=auth_headers,
)
assert response.status_code == 422
def test_update_role_permissions_invalid_feature(self, test_client, admin_user, auth_headers):
"""Verify 400 when an unknown feature key is submitted."""
response = test_client.put(
"/api/v1/role-permissions",
json={"permissions": {"viewer": {"not_a_real_feature": True}}},
headers=auth_headers,
)
assert response.status_code == 400
assert "Invalid feature" in response.json()["detail"]
def test_update_role_permissions_non_admin(
self, test_client, regular_auth_headers, admin_user
):
"""Verify 403 for non-admin users."""
response = test_client.put(
"/api/v1/role-permissions",
json={"permissions": {"viewer": {"user_identified": True}}},
headers=regular_auth_headers,
)
assert response.status_code == 403

171
tests/test_api_videos.py Normal file
View File

@ -0,0 +1,171 @@
"""Tests for video person-identification endpoints (/api/v1/videos)."""
from __future__ import annotations
from datetime import date
import pytest
@pytest.fixture
def test_video(test_db_session):
"""Create a test video (a Photo row with media_type='video')."""
from backend.db.models import Photo
video = Photo(
path="/test/path/video1.mp4",
filename="video1.mp4",
date_taken=date(2024, 1, 15),
processed=True,
media_type="video",
)
test_db_session.add(video)
test_db_session.commit()
test_db_session.refresh(video)
return video
class TestListVideos:
def test_list_videos_success(self, test_client, admin_user, auth_headers, test_video):
response = test_client.get("/api/v1/videos", headers=auth_headers)
assert response.status_code == 200
body = response.json()
assert body["total"] >= 1
assert any(item["id"] == test_video.id for item in body["items"])
def test_list_videos_unauthenticated(self, test_client):
response = test_client.get("/api/v1/videos")
assert response.status_code == 401
def test_list_videos_invalid_sort_by(self, test_client, admin_user, auth_headers):
response = test_client.get(
"/api/v1/videos", params={"sort_by": "not_a_field"}, headers=auth_headers
)
assert response.status_code == 400
def test_list_videos_invalid_date_format(self, test_client, admin_user, auth_headers):
response = test_client.get(
"/api/v1/videos", params={"date_from": "not-a-date"}, headers=auth_headers
)
assert response.status_code == 400
class TestGetVideoPeople:
def test_get_video_people_no_people(self, test_client, test_video):
"""This endpoint has no auth dependency."""
response = test_client.get(f"/api/v1/videos/{test_video.id}/people")
assert response.status_code == 200
body = response.json()
assert body["video_id"] == test_video.id
assert body["people"] == []
def test_get_video_people_not_found(self, test_client):
response = test_client.get("/api/v1/videos/999999/people")
assert response.status_code == 404
def test_get_video_people_rejects_non_video_photo(self, test_client, test_photo):
"""A regular photo (media_type='image') should not be treated as a video."""
response = test_client.get(f"/api/v1/videos/{test_photo.id}/people")
assert response.status_code == 404
class TestIdentifyPersonInVideo:
def test_identify_creates_new_person(
self, test_client, admin_user, auth_headers, test_video
):
response = test_client.post(
f"/api/v1/videos/{test_video.id}/identify",
json={"first_name": "Jane", "last_name": "Smith"},
headers=auth_headers,
)
assert response.status_code == 200
body = response.json()
assert body["video_id"] == test_video.id
assert body["created_person"] is True
people = test_client.get(f"/api/v1/videos/{test_video.id}/people")
assert people.json()["people"][0]["first_name"] == "Jane"
def test_identify_with_existing_person(
self, test_client, admin_user, auth_headers, test_video, test_person
):
response = test_client.post(
f"/api/v1/videos/{test_video.id}/identify",
json={"person_id": test_person.id},
headers=auth_headers,
)
assert response.status_code == 200
body = response.json()
assert body["created_person"] is False
assert body["person_id"] == test_person.id
def test_identify_unauthenticated(self, test_client, test_video):
response = test_client.post(
f"/api/v1/videos/{test_video.id}/identify",
json={"first_name": "Jane", "last_name": "Smith"},
)
assert response.status_code == 401
class TestRemovePersonFromVideo:
def test_remove_person_success(
self, test_client, admin_user, auth_headers, test_video, test_person
):
test_client.post(
f"/api/v1/videos/{test_video.id}/identify",
json={"person_id": test_person.id},
headers=auth_headers,
)
response = test_client.delete(
f"/api/v1/videos/{test_video.id}/people/{test_person.id}",
headers=auth_headers,
)
assert response.status_code == 200
assert response.json()["removed"] is True
def test_remove_person_not_linked(
self, test_client, admin_user, auth_headers, test_video, test_person
):
response = test_client.delete(
f"/api/v1/videos/{test_video.id}/people/{test_person.id}",
headers=auth_headers,
)
assert response.status_code == 200
assert response.json()["removed"] is False
def test_remove_person_unauthenticated(self, test_client, test_video, test_person):
response = test_client.delete(
f"/api/v1/videos/{test_video.id}/people/{test_person.id}",
)
assert response.status_code == 401
class TestVideoFileEndpoints:
def test_get_video_thumbnail_not_found(self, test_client):
response = test_client.get("/api/v1/videos/999999/thumbnail")
assert response.status_code == 404
def test_get_video_file_not_found(self, test_client):
response = test_client.get("/api/v1/videos/999999/video")
assert response.status_code == 404
def test_get_video_file_missing_on_disk(self, test_client, test_video):
"""Video row exists but the file on disk doesn't (path is fake)."""
response = test_client.get(f"/api/v1/videos/{test_video.id}/video")
assert response.status_code == 404
class TestWebPlayback:
def test_prepare_web_playback_redis_unavailable(
self, test_client, test_video
):
"""No Redis running in the test environment -> 503, not a crash."""
response = test_client.post(
f"/api/v1/videos/{test_video.id}/web-playback/prepare"
)
assert response.status_code == 503
def test_web_playback_status_not_found(self, test_client):
response = test_client.get("/api/v1/videos/999999/web-playback/status")
assert response.status_code == 404

View File

@ -380,6 +380,25 @@ For complete documentation, see:
- `npm run start` - Start production server
- `npm run lint` - Run ESLint
- `npm run check:permissions` - Check database permissions and provide fix instructions
- `npm test` - Run the unit test suite once (Vitest + React Testing Library)
- `npm run test:watch` - Run the unit test suite in watch mode
- `npm run test:coverage` - Run the unit test suite with coverage
### Unit Testing
Unit tests live alongside the code they cover, in `__tests__/` folders (e.g.
`lib/__tests__/`, `hooks/__tests__/`, `components/__tests__/`). They use
[Vitest](https://vitest.dev/) with a `jsdom` environment and
[React Testing Library](https://testing-library.com/react) for components/hooks.
- `vitest.config.ts` — test runner config (jsdom environment, `@/` path alias)
- `vitest.setup.ts` — global test setup: `@testing-library/jest-dom` matchers,
plus jsdom polyfills that Radix UI components and layout-aware hooks need
(`matchMedia`, `ResizeObserver`, pointer capture, `offsetParent`)
This covers frontend *unit* tests only — end-to-end browser tests live in
`../e2e` (Playwright + `@levkin/playkit`), and backend API tests live in
`../tests` (pytest).
### Prisma Commands

View File

@ -997,8 +997,8 @@ export function HomePageContent({ initialPhotos, people, tags }: HomePageContent
/>
<div className="flex-1 min-w-0">
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
<div role="status" aria-label="Loading photos" className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-gray-400 dark:text-gray-500" />
</div>
) : (
<>
@ -1046,9 +1046,9 @@ export function HomePageContent({ initialPhotos, people, tags }: HomePageContent
/>
{/* Infinite scroll sentinel */}
<div ref={observerTarget} className="h-10 flex items-center justify-center">
<div ref={observerTarget} role="status" aria-label={loadingMore ? 'Loading more photos' : undefined} className="h-10 flex items-center justify-center">
{loadingMore && (
<Loader2 className="h-6 w-6 animate-spin text-gray-400" />
<Loader2 className="h-6 w-6 animate-spin text-gray-400 dark:text-gray-500" />
)}
</div>

View File

@ -331,13 +331,13 @@ export function ManageUsersContent() {
</div>
{error && (
<div className="mb-4 rounded-md bg-red-50 p-4 text-red-800 dark:bg-red-900/20 dark:text-red-400">
<div role="alert" className="mb-4 rounded-md bg-red-50 p-4 text-red-800 dark:bg-red-900/20 dark:text-red-400">
{error}
</div>
)}
{successMessage && (
<div className="mb-4 rounded-md bg-green-50 p-4 text-green-800 dark:bg-green-900/20 dark:text-green-400">
<div role="status" className="mb-4 rounded-md bg-green-50 p-4 text-green-800 dark:bg-green-900/20 dark:text-green-400">
{successMessage}
</div>
)}
@ -389,6 +389,7 @@ export function ManageUsersContent() {
variant="ghost"
size="sm"
onClick={() => openEditDialog(user)}
aria-label={`Edit ${user.email}`}
>
<Edit2 className="h-4 w-4" />
</Button>
@ -403,6 +404,7 @@ export function ManageUsersContent() {
setDeleteConfirmOpen(true);
}}
className="text-red-600 hover:text-red-700"
aria-label={`Deactivate ${user.email}`}
>
<Trash2 className="h-4 w-4" />
</Button>
@ -553,7 +555,7 @@ export function ManageUsersContent() {
</div>
<div className="grid gap-2">
<label htmlFor="edit-password" className="text-sm font-medium">
New Password <span className="text-gray-500 font-normal">(leave empty to keep current)</span>
New Password <span className="text-gray-500 dark:text-gray-400 font-normal">(leave empty to keep current)</span>
</label>
<Input
id="edit-password"

View File

@ -8,6 +8,7 @@ import { ManageUsersContent } from './ManageUsersContent';
import Image from 'next/image';
import Link from 'next/link';
import UserMenu from '@/components/UserMenu';
import { ThemeToggle } from '@/components/ThemeToggle';
interface ManageUsersPageClientProps {
onClose?: () => void;
@ -30,38 +31,35 @@ export function ManageUsersPageClient({ onClose }: ManageUsersPageClientProps) {
const overlayContent = (
<div className="fixed inset-0 z-[100] bg-background overflow-y-auto">
<div className="w-full px-4 py-8">
{/* Close button */}
<div className="mb-4 flex items-center justify-end">
<Button
variant="ghost"
size="icon"
onClick={handleClose}
className="h-9 w-9"
aria-label="Close manage users"
>
<X className="h-5 w-5" />
</Button>
</div>
<main id="main-content" className="w-full px-4 pt-3 pb-8">
{/* Header */}
<div className="sticky top-0 z-40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 pb-4 mb-4 border-b">
<div className="mb-4 flex items-center justify-between">
<div className="sticky top-0 z-40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 pb-2 mb-3 border-b">
<div className="mb-2 flex items-center justify-between">
<Link href="/" aria-label="Home">
<Image
src="/logo.png"
alt="PunimTag"
width={300}
height={80}
className="h-20 w-auto cursor-pointer hover:opacity-80 transition-opacity"
className="h-11 w-auto cursor-pointer hover:opacity-80 transition-opacity"
priority
/>
</Link>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1">
<ThemeToggle />
<UserMenu />
<Button
variant="ghost"
size="icon"
onClick={handleClose}
className="h-9 w-9"
aria-label="Close manage users"
>
<X className="h-5 w-5" />
</Button>
</div>
</div>
<p className="text-lg font-medium text-orange-600 dark:text-orange-500 tracking-wide">
<p className="text-sm font-medium text-orange-600 dark:text-orange-500 tracking-wide">
Browse our photo collection
</p>
</div>
@ -70,7 +68,7 @@ export function ManageUsersPageClient({ onClose }: ManageUsersPageClientProps) {
<div className="mt-8">
<ManageUsersContent />
</div>
</div>
</main>
</div>
);

View File

@ -99,7 +99,7 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
token.email = user.email;
token.isAdmin = user.isAdmin;
token.hasWriteAccess = user.hasWriteAccess;
token.exp = Math.floor(Date.now() / 1000) + (24 * 60 * 60); // 24 hours from now
token.isActive = true;
}
// Refresh user data from database on token refresh to get latest hasWriteAccess and isActive
@ -117,12 +117,15 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
},
});
if (dbUser) {
// Check if user is still active (treat null/undefined as true)
if (dbUser.isActive === false) {
// User was deactivated, invalidate token
return null as any;
}
if (!dbUser || dbUser.isActive === false) {
// User was deactivated or deleted. Flag it on the token instead of
// returning `null` here: NextAuth v5's client `useSession()` hook does
// not expect a `null` JWT and throws when it encounters one, which is
// what caused the sign-out crash (issue #57). The `session` callback
// below turns this flag into a clean signed-out session instead.
token.isActive = false;
} else {
token.isActive = true;
token.id = dbUser.id.toString();
token.isAdmin = dbUser.isAdmin;
token.hasWriteAccess = dbUser.hasWriteAccess;
@ -136,9 +139,11 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
return token;
},
async session({ session, token }) {
// If token is null or expired, return null session to force logout
if (!token || (token.exp && token.exp < Math.floor(Date.now() / 1000))) {
return null as any;
// Never return `null` for the whole session object — NextAuth v5's client
// hooks assume a `{ user, expires }` shape and crash on `null` (issue #57).
// Instead, drop `user` so the client treats this as a normal signed-out state.
if (!token || token.isActive === false) {
return { ...session, user: undefined } as any;
}
if (session.user) {

View File

@ -83,19 +83,25 @@ export async function POST(request: NextRequest) {
});
// Send confirmation email
let emailSendFailed = false;
try {
await sendEmailConfirmation(email, name.trim(), confirmationToken);
} catch (emailError) {
console.error('Error sending confirmation email:', emailError);
// Don't fail registration if email fails, but log it
// User can request a resend later
// Don't fail registration if email fails, but let the client know so it
// doesn't tell the user to "check their email" when nothing was sent
// (issue #56). The user can still request a resend later.
emailSendFailed = true;
}
return NextResponse.json(
{
message: 'User created successfully. Please check your email to confirm your account.',
message: emailSendFailed
? 'Account created, but we could not send the confirmation email right now. Use "Resend confirmation email" on the login screen once you try to sign in.'
: 'User created successfully. Please check your email to confirm your account.',
user,
requiresEmailConfirmation: true
requiresEmailConfirmation: true,
emailSendFailed,
},
{ status: 201 }
);

View File

@ -1,43 +1,53 @@
import { NextRequest, NextResponse } from 'next/server';
import { prismaAuth } from '@/lib/db';
/**
* Public site origin for redirects. Behind Caddy, `request.url` is often
* http://localhost:3001 which sends browsers (and e2e) to a LAN/loopback
* host same class of bug as NEXTAUTH_URL 10.x (#57).
*/
function publicOrigin(request: NextRequest): string {
const fromEnv = (process.env.NEXTAUTH_URL || process.env.AUTH_URL || '').replace(/\/$/, '');
if (fromEnv) return fromEnv;
const xfProto = request.headers.get('x-forwarded-proto');
const xfHost = request.headers.get('x-forwarded-host') || request.headers.get('host');
if (xfHost) {
const proto = xfProto || 'https';
return `${proto}://${xfHost.split(',')[0].trim()}`;
}
return request.nextUrl.origin;
}
function redirectLogin(request: NextRequest, query: string): NextResponse {
return NextResponse.redirect(new URL(`/login?${query}`, publicOrigin(request)));
}
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const token = searchParams.get('token');
if (!token) {
return NextResponse.redirect(
new URL('/login?error=missing_token', request.url)
);
return redirectLogin(request, 'error=missing_token');
}
// Find user with this token
const user = await prismaAuth.user.findUnique({
where: { emailConfirmationToken: token },
});
if (!user) {
return NextResponse.redirect(
new URL('/login?error=invalid_token', request.url)
);
return redirectLogin(request, 'error=invalid_token');
}
// Check if token has expired
if (user.emailConfirmationTokenExpiry && user.emailConfirmationTokenExpiry < new Date()) {
return NextResponse.redirect(
new URL('/login?error=token_expired', request.url)
);
return redirectLogin(request, 'error=token_expired');
}
// Check if already verified
if (user.emailVerified) {
return NextResponse.redirect(
new URL('/login?message=already_verified', request.url)
);
return redirectLogin(request, 'message=already_verified');
}
// Verify the email
await prismaAuth.user.update({
where: { id: user.id },
data: {
@ -47,21 +57,9 @@ export async function GET(request: NextRequest) {
},
});
// Redirect to login with success message
return NextResponse.redirect(
new URL('/login?verified=true', request.url)
);
} catch (error: any) {
return redirectLogin(request, 'verified=true');
} catch (error: unknown) {
console.error('Error verifying email:', error);
return NextResponse.redirect(
new URL('/login?error=verification_failed', request.url)
);
return redirectLogin(request, 'error=verification_failed');
}
}

View File

@ -0,0 +1,38 @@
'use client';
import { useEffect } from 'react';
import { Button } from '@/components/ui/button';
/**
* App-level error boundary. Without this, any uncaught client render error
* (e.g. during a sign-out/session transition) produces a hard white-screen
* crash instead of a recoverable UI (issue #57).
*/
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error('[APP_ERROR]', error);
}, [error]);
return (
<main id="main-content" role="alert" className="flex min-h-screen flex-col items-center justify-center gap-4 bg-background p-6 text-center">
<h1 className="text-xl font-semibold text-secondary">Something went wrong</h1>
<p className="max-w-md text-sm text-muted-foreground">
An unexpected error occurred. You can try again, or head back to the homepage.
</p>
<div className="flex gap-3">
<Button variant="outline" onClick={() => reset()}>
Try again
</Button>
<Button onClick={() => { window.location.href = '/'; }}>
Go home
</Button>
</div>
</main>
);
}

View File

@ -1,6 +1,7 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import { SessionProviderWrapper } from "@/components/SessionProviderWrapper";
import { ThemeProviderWrapper } from "@/components/ThemeProviderWrapper";
import "./globals.css";
const inter = Inter({
@ -19,11 +20,19 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en">
<html lang="en" suppressHydrationWarning>
<body className={`${inter.variable} font-sans antialiased`}>
<SessionProviderWrapper>
{children}
</SessionProviderWrapper>
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-[200] focus:rounded-md focus:bg-primary focus:px-4 focus:py-2 focus:text-primary-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
Skip to main content
</a>
<ThemeProviderWrapper>
<SessionProviderWrapper>
{children}
</SessionProviderWrapper>
</ThemeProviderWrapper>
</body>
</html>
);

View File

@ -6,6 +6,7 @@ import { useRouter, useSearchParams } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import Link from 'next/link';
import { ThemeToggle } from '@/components/ThemeToggle';
function LoginForm() {
const router = useRouter();
@ -99,13 +100,14 @@ function LoginForm() {
};
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50 px-4">
<main id="main-content" className="relative flex min-h-screen items-center justify-center bg-background px-4">
<ThemeToggle className="absolute right-4 top-4" />
<div className="w-full max-w-md space-y-8">
<div>
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-secondary">
<h1 className="mt-6 text-center text-3xl font-bold tracking-tight text-secondary">
Sign in to your account
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
</h1>
<p className="mt-2 text-center text-sm text-gray-600 dark:text-gray-400">
Or{' '}
<Link
href="/register"
@ -117,44 +119,44 @@ function LoginForm() {
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
{registered && (
<div className="rounded-md bg-green-50 p-4">
<p className="text-sm text-green-800">
<div role="status" className="rounded-md bg-green-50 dark:bg-green-900/20 p-4">
<p className="text-sm text-green-800 dark:text-green-300">
Account created successfully! Please check your email to confirm your account before signing in.
</p>
</div>
)}
{verified && (
<div className="rounded-md bg-green-50 p-4">
<p className="text-sm text-green-800">
<div role="status" className="rounded-md bg-green-50 dark:bg-green-900/20 p-4">
<p className="text-sm text-green-800 dark:text-green-300">
Email verified successfully! You can now sign in.
</p>
</div>
)}
{passwordReset && (
<div className="rounded-md bg-green-50 p-4">
<p className="text-sm text-green-800">
<div role="status" className="rounded-md bg-green-50 dark:bg-green-900/20 p-4">
<p className="text-sm text-green-800 dark:text-green-300">
Password reset successfully! You can now sign in with your new password.
</p>
</div>
)}
{emailNotVerified && (
<div className="rounded-md bg-yellow-50 p-4">
<p className="text-sm text-yellow-800 mb-2">
<div role="alert" className="rounded-md bg-yellow-50 dark:bg-yellow-900/20 p-4">
<p className="text-sm text-yellow-800 dark:text-yellow-300 mb-2">
Please verify your email address before signing in. Check your inbox for a confirmation email.
</p>
<button
type="button"
onClick={handleResendConfirmation}
disabled={isResending}
className="text-sm text-yellow-900 underline hover:no-underline font-medium"
className="text-sm text-yellow-900 dark:text-yellow-200 underline hover:no-underline font-medium"
>
{isResending ? 'Sending...' : 'Resend confirmation email'}
</button>
</div>
)}
{error && (
<div className="rounded-md bg-red-50 p-4">
<p className="text-sm text-red-800">{error}</p>
<div role="alert" className="rounded-md bg-red-50 dark:bg-red-900/20 p-4">
<p className="text-sm text-red-800 dark:text-red-300">{error}</p>
</div>
)}
<div className="space-y-4 rounded-md shadow-sm">
@ -209,23 +211,23 @@ function LoginForm() {
</div>
</form>
</div>
</div>
</main>
);
}
export default function LoginPage() {
return (
<Suspense fallback={
<div className="flex min-h-screen items-center justify-center bg-gray-50 px-4">
<main id="main-content" className="flex min-h-screen items-center justify-center bg-background px-4">
<div className="w-full max-w-md space-y-8">
<div className="text-center">
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-secondary">
<h1 className="mt-6 text-center text-3xl font-bold tracking-tight text-secondary">
Sign in to your account
</h2>
<p className="mt-2 text-center text-sm text-gray-600">Loading...</p>
</h1>
<p className="mt-2 text-center text-sm text-gray-600 dark:text-gray-400">Loading...</p>
</div>
</div>
</div>
</main>
}>
<LoginForm />
</Suspense>

View File

@ -222,10 +222,10 @@ export default async function HomePage() {
]);
return (
<main className="w-full px-4 py-8">
<main id="main-content" className="w-full px-4 pt-3 pb-8">
{error ? (
<div className="rounded-lg bg-red-50 p-4 text-red-800 dark:bg-red-900/20 dark:text-red-200">
<div role="alert" className="rounded-lg bg-red-50 p-4 text-red-800 dark:bg-red-900/20 dark:text-red-200">
<p className="font-semibold">Error loading photos</p>
<p className="text-sm">{error}</p>
<p className="mt-2 text-xs">

View File

@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import Link from 'next/link';
import { isValidEmail } from '@/lib/utils';
import { ThemeToggle } from '@/components/ThemeToggle';
export default function RegisterPage() {
const router = useRouter();
@ -74,13 +75,14 @@ export default function RegisterPage() {
};
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50 px-4">
<main id="main-content" className="relative flex min-h-screen items-center justify-center bg-background px-4">
<ThemeToggle className="absolute right-4 top-4" />
<div className="w-full max-w-md space-y-8">
<div>
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-secondary">
<h1 className="mt-6 text-center text-3xl font-bold tracking-tight text-secondary">
Create your account
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
</h1>
<p className="mt-2 text-center text-sm text-gray-600 dark:text-gray-400">
Or{' '}
<Link
href="/login"
@ -92,8 +94,8 @@ export default function RegisterPage() {
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
{error && (
<div className="rounded-md bg-red-50 p-4">
<p className="text-sm text-red-800">{error}</p>
<div role="alert" className="rounded-md bg-red-50 dark:bg-red-900/20 p-4">
<p className="text-sm text-red-800 dark:text-red-300">{error}</p>
</div>
)}
<div className="space-y-4 rounded-md shadow-sm">
@ -144,7 +146,7 @@ export default function RegisterPage() {
className="mt-1"
placeholder="••••••••"
/>
<p className="mt-1 text-xs text-gray-500">
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Must be at least 6 characters
</p>
</div>
@ -177,7 +179,7 @@ export default function RegisterPage() {
</div>
</form>
</div>
</div>
</main>
);
}

View File

@ -5,6 +5,7 @@ import { useRouter, useSearchParams } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import Link from 'next/link';
import { ThemeToggle } from '@/components/ThemeToggle';
function ResetPasswordForm() {
const router = useRouter();
@ -71,18 +72,19 @@ function ResetPasswordForm() {
if (success) {
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50 px-4">
<main id="main-content" className="relative flex min-h-screen items-center justify-center bg-background px-4">
<ThemeToggle className="absolute right-4 top-4" />
<div className="w-full max-w-md space-y-8">
<div>
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-secondary">
<h1 className="mt-6 text-center text-3xl font-bold tracking-tight text-secondary">
Password reset successful
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
</h1>
<p className="mt-2 text-center text-sm text-gray-600 dark:text-gray-400">
Your password has been reset successfully. Redirecting to login...
</p>
</div>
<div className="rounded-md bg-green-50 p-4">
<p className="text-sm text-green-800">
<div role="status" className="rounded-md bg-green-50 dark:bg-green-900/20 p-4">
<p className="text-sm text-green-800 dark:text-green-300">
You can now sign in with your new password.
</p>
</div>
@ -95,25 +97,26 @@ function ResetPasswordForm() {
</Link>
</div>
</div>
</div>
</main>
);
}
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50 px-4">
<main id="main-content" className="relative flex min-h-screen items-center justify-center bg-background px-4">
<ThemeToggle className="absolute right-4 top-4" />
<div className="w-full max-w-md space-y-8">
<div>
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-secondary">
<h1 className="mt-6 text-center text-3xl font-bold tracking-tight text-secondary">
Reset your password
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
</h1>
<p className="mt-2 text-center text-sm text-gray-600 dark:text-gray-400">
Enter your new password below
</p>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
{error && (
<div className="rounded-md bg-red-50 p-4">
<p className="text-sm text-red-800">{error}</p>
<div role="alert" className="rounded-md bg-red-50 dark:bg-red-900/20 p-4">
<p className="text-sm text-red-800 dark:text-red-300">{error}</p>
</div>
)}
<div className="space-y-4 rounded-md shadow-sm">
@ -132,7 +135,7 @@ function ResetPasswordForm() {
className="mt-1"
placeholder="••••••••"
/>
<p className="mt-1 text-xs text-gray-500">
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Must be at least 6 characters
</p>
</div>
@ -173,23 +176,23 @@ function ResetPasswordForm() {
</div>
</form>
</div>
</div>
</main>
);
}
export default function ResetPasswordPage() {
return (
<Suspense fallback={
<div className="flex min-h-screen items-center justify-center bg-gray-50 px-4">
<main id="main-content" className="flex min-h-screen items-center justify-center bg-background px-4">
<div className="w-full max-w-md space-y-8">
<div className="text-center">
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-secondary">
<h1 className="mt-6 text-center text-3xl font-bold tracking-tight text-secondary">
Reset your password
</h2>
<p className="mt-2 text-center text-sm text-gray-600">Loading...</p>
</h1>
<p className="mt-2 text-center text-sm text-gray-600 dark:text-gray-400">Loading...</p>
</div>
</div>
</div>
</main>
}>
<ResetPasswordForm />
</Suspense>

View File

@ -146,8 +146,8 @@ export function SearchContent({ people, tags }: SearchContentProps) {
{/* Results */}
<div className="lg:col-span-3">
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
<div role="status" aria-label="Loading photos" className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-gray-400 dark:text-gray-500" />
</div>
) : (
<>
@ -192,11 +192,11 @@ export function SearchContent({ people, tags }: SearchContentProps) {
</>
) : hasActiveFilters ? (
<div className="flex items-center justify-center py-12">
<p className="text-gray-500">No photos found matching your filters</p>
<p className="text-gray-500 dark:text-gray-400">No photos found matching your filters</p>
</div>
) : (
<div className="flex items-center justify-center py-12">
<p className="text-gray-500">Select filters to search photos</p>
<p className="text-gray-500 dark:text-gray-400">Select filters to search photos</p>
</div>
)}
</>

View File

@ -97,7 +97,7 @@ export default async function SearchPage() {
]);
return (
<main className="w-full px-4 py-8">
<main id="main-content" className="w-full px-4 py-8">
<div className="mb-8">
<h1 className="text-4xl font-bold text-secondary dark:text-gray-50">
Search Photos
@ -108,8 +108,8 @@ export default async function SearchPage() {
</div>
<Suspense fallback={
<div className="flex items-center justify-center py-12">
<div className="text-gray-500">Loading search...</div>
<div role="status" className="flex items-center justify-center py-12">
<div className="text-gray-500 dark:text-gray-400">Loading search...</div>
</div>
}>
<SearchContent people={people} tags={tags} />

View File

@ -48,7 +48,7 @@ export default function TestImagesPage() {
];
return (
<main className="container mx-auto px-4 py-8">
<main id="main-content" className="container mx-auto px-4 py-8">
<div className="mb-8">
<h1 className="text-4xl font-bold text-secondary dark:text-gray-50">
Image Source Test Page

View File

@ -350,7 +350,7 @@ export function UploadContent() {
</div>
{allSuccess && (
<div className="rounded-lg bg-green-50 dark:bg-green-900/20 p-4">
<div role="status" className="rounded-lg bg-green-50 dark:bg-green-900/20 p-4">
<div className="flex items-center space-x-2">
<CheckCircle2 className="h-5 w-5 text-green-600 dark:text-green-400" />
<p className="text-sm font-medium text-green-800 dark:text-green-200">

View File

@ -7,6 +7,7 @@ import { UploadContent } from './UploadContent';
import Image from 'next/image';
import Link from 'next/link';
import UserMenu from '@/components/UserMenu';
import { ThemeToggle } from '@/components/ThemeToggle';
export function UploadPageClient() {
const router = useRouter();
@ -17,38 +18,35 @@ export function UploadPageClient() {
return (
<div className="fixed inset-0 z-50 bg-background overflow-y-auto">
<div className="w-full px-4 py-8">
{/* Close button */}
<div className="mb-4 flex items-center justify-end">
<Button
variant="ghost"
size="icon"
onClick={handleClose}
className="h-9 w-9"
aria-label="Close upload"
>
<X className="h-5 w-5" />
</Button>
</div>
<main id="main-content" className="w-full px-4 pt-3 pb-8">
{/* Header */}
<div className="sticky top-0 z-40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 pb-4 mb-4 border-b">
<div className="mb-4 flex items-center justify-between">
<div className="sticky top-0 z-40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 pb-2 mb-3 border-b">
<div className="mb-2 flex items-center justify-between">
<Link href="/" aria-label="Home">
<Image
src="/logo.png"
alt="PunimTag"
width={300}
height={80}
className="h-20 w-auto cursor-pointer hover:opacity-80 transition-opacity"
className="h-11 w-auto cursor-pointer hover:opacity-80 transition-opacity"
priority
/>
</Link>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1">
<ThemeToggle />
<UserMenu />
<Button
variant="ghost"
size="icon"
onClick={handleClose}
className="h-9 w-9"
aria-label="Close upload"
>
<X className="h-5 w-5" />
</Button>
</div>
</div>
<p className="text-lg font-medium text-orange-600 dark:text-orange-500 tracking-wide">
<p className="text-sm font-medium text-orange-600 dark:text-orange-500 tracking-wide">
Browse our photo collection
</p>
</div>
@ -65,7 +63,7 @@ export function UploadPageClient() {
</div>
<UploadContent />
</div>
</div>
</main>
</div>
);
}

View File

@ -91,7 +91,7 @@ export function ForgotPasswordDialog({
</DialogHeader>
{success ? (
<div className="space-y-4">
<div className="rounded-md bg-green-50 p-4 dark:bg-green-900/20">
<div role="status" className="rounded-md bg-green-50 p-4 dark:bg-green-900/20">
<p className="text-sm text-green-800 dark:text-green-200">
Password reset email sent! Please check your inbox and follow the instructions to reset your password.
</p>
@ -105,7 +105,7 @@ export function ForgotPasswordDialog({
) : (
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="rounded-md bg-red-50 p-4 dark:bg-red-900/20">
<div role="alert" className="rounded-md bg-red-50 p-4 dark:bg-red-900/20">
<p className="text-sm text-red-800 dark:text-red-200">{error}</p>
</div>
)}

View File

@ -29,7 +29,17 @@ export function Header() {
const [popoverOpen, setPopoverOpen] = useState(false);
const handleSignOut = async () => {
await signOut({ callbackUrl: '/' });
// Close any overlays tied to the authenticated state (e.g. Manage Users)
// *before* the session clears, otherwise their effects/portals can unmount
// mid-transition and throw (issue #57).
setPopoverOpen(false);
setManageUsersOpen(false);
try {
await signOut({ callbackUrl: '/' });
} catch (error) {
console.error('[SIGN_OUT] Failed to sign out cleanly:', error);
router.push('/');
}
};
return (

View File

@ -273,7 +273,7 @@ export function IdentifyFaceDialog({
</DialogDescription>
</DialogHeader>
<div className="py-4">
<p className="text-sm text-gray-600 mb-4">
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
Please sign in or create an account to continue.
</p>
<div className="flex gap-2">
@ -363,7 +363,7 @@ export function IdentifyFaceDialog({
</DialogDescription>
</DialogHeader>
<div className="py-4">
<p className="text-sm text-gray-600 mb-4">
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
Only users with write access can identify faces. Please contact an administrator to request write access.
</p>
</div>
@ -485,7 +485,7 @@ export function IdentifyFaceDialog({
const fullName = `${person.firstName} ${person.lastName} ${person.middleName || ''} ${person.maidenName || ''}`.toLowerCase();
return fullName.includes(peopleSearchQuery.toLowerCase());
}).length === 0 ? (
<p className="p-2 text-sm text-gray-500">No people found</p>
<p className="p-2 text-sm text-gray-500 dark:text-gray-400">No people found</p>
) : (
<div className="space-y-1">
{people
@ -512,7 +512,7 @@ export function IdentifyFaceDialog({
{person.firstName} {person.lastName}
</div>
{(person.middleName || person.maidenName) && (
<div className="text-xs text-gray-500">
<div className="text-xs text-gray-500 dark:text-gray-400">
{[person.middleName, person.maidenName].filter(Boolean).join(' • ')}
</div>
)}

View File

@ -1,23 +1,24 @@
'use client';
import { useMemo } from 'react';
import { useSearchParams } from 'next/navigation';
import { useIdleLogout } from '@/hooks/useIdleLogout';
/**
* Component that handles idle logout functionality
* Must be rendered inside SessionProvider to use useSession hook
* Idle logout default 2 hours.
* Optional e2e override: ?e2e_idle_ms=3000 (clamped 1s60s) so Playwright can
* assert sign-out stays on the public host without waiting two hours.
*/
export function IdleLogoutHandler() {
// Log out users after 2 hours of inactivity
useIdleLogout(2 * 60 * 60 * 1000); // 2 hours in milliseconds
const searchParams = useSearchParams();
const timeoutMs = useMemo(() => {
const raw = searchParams.get('e2e_idle_ms');
if (!raw) return 2 * 60 * 60 * 1000;
const n = Number(raw);
if (!Number.isFinite(n)) return 2 * 60 * 60 * 1000;
return Math.min(60_000, Math.max(1_000, Math.floor(n)));
}, [searchParams]);
// This component doesn't render anything
useIdleLogout(timeoutMs);
return null;
}

View File

@ -185,21 +185,21 @@ export function LoginDialog({
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
{registered && (
<div className="rounded-md bg-green-50 p-4 dark:bg-green-900/20">
<div role="status" className="rounded-md bg-green-50 p-4 dark:bg-green-900/20">
<p className="text-sm text-green-800 dark:text-green-200">
Account created successfully! Please check your email to confirm your account before signing in.
</p>
</div>
)}
{searchParams.get('verified') === 'true' && (
<div className="rounded-md bg-green-50 p-4 dark:bg-green-900/20">
<div role="status" className="rounded-md bg-green-50 p-4 dark:bg-green-900/20">
<p className="text-sm text-green-800 dark:text-green-200">
Email verified successfully! You can now sign in.
</p>
</div>
)}
{emailNotVerified && (
<div className="rounded-md bg-yellow-50 p-4 dark:bg-yellow-900/20">
<div role="alert" className="rounded-md bg-yellow-50 p-4 dark:bg-yellow-900/20">
<p className="text-sm text-yellow-800 dark:text-yellow-200 mb-2">
Please verify your email address before signing in. Check your inbox for a confirmation email.
</p>
@ -214,7 +214,7 @@ export function LoginDialog({
</div>
)}
{error && (
<div className="rounded-md bg-red-50 p-4 dark:bg-red-900/20">
<div role="alert" className="rounded-md bg-red-50 p-4 dark:bg-red-900/20">
<p className="text-sm text-red-800 dark:text-red-200">{error}</p>
</div>
)}

View File

@ -4,6 +4,7 @@ import Image from 'next/image';
import Link from 'next/link';
import UserMenu from '@/components/UserMenu';
import { ActionButtons } from '@/components/ActionButtons';
import { ThemeToggle } from '@/components/ThemeToggle';
interface PageHeaderProps {
photosCount: number;
@ -33,26 +34,27 @@ export function PageHeader({
onToggleSelectionMode,
}: PageHeaderProps) {
return (
<div className="sticky top-0 z-40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 pb-4 mb-4 border-b">
<div className="mb-4 flex items-center justify-between">
<div className="sticky top-0 z-40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 pb-2 mb-3 border-b">
<div className="mb-2 flex items-center justify-between">
<Link href="/" aria-label="Home">
<Image
src="/logo.png"
alt="PunimTag"
width={300}
height={80}
className="h-20 w-auto cursor-pointer hover:opacity-80 transition-opacity"
className="h-11 w-auto cursor-pointer hover:opacity-80 transition-opacity"
priority
/>
</Link>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1">
<ThemeToggle />
<UserMenu />
</div>
</div>
<div className="flex items-center justify-between gap-4">
<p className="text-lg font-medium text-orange-600 dark:text-orange-500 tracking-wide">
<h1 className="text-sm font-medium text-orange-600 dark:text-orange-500 tracking-wide">
Browse our photo collection
</p>
</h1>
<ActionButtons
photosCount={photosCount}
isLoggedIn={isLoggedIn}

View File

@ -794,13 +794,13 @@ export function PhotoGrid({
value={reportDialogComment}
onChange={(event) => setReportDialogComment(event.target.value)}
maxLength={REPORT_COMMENT_MAX_LENGTH}
className="mt-2 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-900"
className="mt-2 w-full rounded-md border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 text-secondary px-3 py-2 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-900 dark:focus-visible:ring-gray-100"
rows={4}
placeholder="Add a short note about why this photo should be reviewed..."
/>
<div className="mt-1 flex justify-between text-xs text-gray-500">
<div className="mt-1 flex justify-between text-xs text-gray-500 dark:text-gray-400">
<span>{`${reportDialogComment.length}/${REPORT_COMMENT_MAX_LENGTH} characters`}</span>
{reportDialogError && <span className="text-red-600">{reportDialogError}</span>}
{reportDialogError && <span role="alert" className="text-red-600 dark:text-red-400">{reportDialogError}</span>}
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
@ -836,7 +836,7 @@ export function PhotoGrid({
</DialogDescription>
</DialogHeader>
<div className="py-4">
<p className="text-sm text-gray-600 mb-4">
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
Please sign in or create an account to continue.
</p>
<div className="flex gap-2">

View File

@ -1,6 +1,7 @@
'use client';
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import { useFocusTrap } from '@/hooks/useFocusTrap';
import { useSession } from 'next-auth/react';
import { useRouter } from 'next/navigation';
import Image from 'next/image';
@ -32,6 +33,8 @@ export function PhotoViewer({ photo, previousId, nextId }: PhotoViewerProps) {
const [loading, setLoading] = useState(false);
const { data: session } = useSession();
const isLoggedIn = Boolean(session);
const dialogRef = useRef<HTMLDivElement>(null);
useFocusTrap(dialogRef, true);
// Keyboard navigation
useEffect(() => {
@ -80,7 +83,14 @@ export function PhotoViewer({ photo, previousId, nextId }: PhotoViewerProps) {
const tags = (photo as any).PhotoTagLinkage?.map((pt: any) => pt.Tag.tag_name) || [];
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black">
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-label={`Photo viewer: ${photo.filename}`}
tabIndex={-1}
className="fixed inset-0 z-50 flex items-center justify-center bg-black outline-none"
>
{/* Close Button */}
<Button
variant="ghost"

View File

@ -1,6 +1,7 @@
'use client';
import { useState, useEffect, useRef, useCallback } from 'react';
import { useFocusTrap } from '@/hooks/useFocusTrap';
import { useRouter } from 'next/navigation';
import { useSession } from 'next-auth/react';
import Image from 'next/image';
@ -97,6 +98,8 @@ export function PhotoViewerClient({
const router = useRouter();
const { data: session, update } = useSession();
const isLoggedIn = Boolean(session);
const dialogRef = useRef<HTMLDivElement>(null);
useFocusTrap(dialogRef, true);
// Check if user has write access
const hasWriteAccess = session?.user?.hasWriteAccess === true;
@ -1189,7 +1192,14 @@ export function PhotoViewerClient({
const hasNext = allPhotos.length > 0 && currentIdx < allPhotos.length - 1;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black">
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-label={`Photo viewer: ${currentPhoto.filename}`}
tabIndex={-1}
className="fixed inset-0 z-50 flex items-center justify-center bg-black outline-none"
>
{/* Close Button */}
<Button
variant="ghost"

View File

@ -107,7 +107,11 @@ export function RegisterDialog({
setError('');
// Show success state
alert('Account created successfully! Please check your email to confirm your account before signing in.');
alert(
data.emailSendFailed
? 'Account created, but we could not send the confirmation email right now. Once you try to sign in, use "Resend confirmation email" to try again.'
: 'Account created successfully! Please check your email to confirm your account before signing in.'
);
onOpenChange(false);
if (onOpenLogin) {
@ -163,7 +167,7 @@ export function RegisterDialog({
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="rounded-md bg-red-50 p-4 dark:bg-red-900/20">
<div role="alert" className="rounded-md bg-red-50 p-4 dark:bg-red-900/20">
<p className="text-sm text-red-800 dark:text-red-200">{error}</p>
</div>
)}

View File

@ -1,5 +1,6 @@
'use client';
import { Suspense } from 'react';
import { SessionProvider } from 'next-auth/react';
import { IdleLogoutHandler } from '@/components/IdleLogoutHandler';
@ -10,11 +11,10 @@ export function SessionProviderWrapper({
}) {
return (
<SessionProvider>
<IdleLogoutHandler />
<Suspense fallback={null}>
<IdleLogoutHandler />
</Suspense>
{children}
</SessionProvider>
);
}

View File

@ -3,26 +3,28 @@
import Image from 'next/image';
import Link from 'next/link';
import UserMenu from '@/components/UserMenu';
import { ThemeToggle } from '@/components/ThemeToggle';
export function SimpleHeader() {
return (
<div className="sticky top-0 z-40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 pb-4 mb-4 border-b">
<div className="mb-4 flex items-center justify-between">
<div className="sticky top-0 z-40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 pb-2 mb-3 border-b">
<div className="mb-2 flex items-center justify-between">
<Link href="/" aria-label="Home">
<Image
src="/logo.png"
alt="PunimTag"
width={300}
height={80}
className="h-20 w-auto cursor-pointer hover:opacity-80 transition-opacity"
className="h-11 w-auto cursor-pointer hover:opacity-80 transition-opacity"
priority
/>
</Link>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1">
<ThemeToggle />
<UserMenu />
</div>
</div>
<p className="text-lg font-medium text-orange-600 dark:text-orange-500 tracking-wide">
<p className="text-sm font-medium text-orange-600 dark:text-orange-500 tracking-wide">
Browse our photo collection
</p>
</div>

View File

@ -0,0 +1,20 @@
'use client';
import { ThemeProvider } from 'next-themes';
export function ThemeProviderWrapper({
children,
}: {
children: React.ReactNode;
}) {
return (
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
</ThemeProvider>
);
}

View File

@ -0,0 +1,83 @@
'use client';
import { useSyncExternalStore } from 'react';
import { useTheme } from 'next-themes';
import { Moon, Sun } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
interface ThemeToggleProps {
className?: string;
}
const emptySubscribe = () => () => {};
/**
* True once React has hydrated on the client. Using useSyncExternalStore
* (rather than a useState+useEffect pair) avoids the "setState during an
* effect" pitfall while still letting the server render a neutral
* placeholder so the theme icon never mismatches during hydration.
*/
function useMounted() {
return useSyncExternalStore(
emptySubscribe,
() => true,
() => false
);
}
/**
* Accessible light/dark mode toggle. Renders a neutral placeholder until
* mounted so the server-rendered markup never guesses the wrong icon and
* causes a hydration mismatch (theme is only known client-side).
*/
export function ThemeToggle({ className }: ThemeToggleProps) {
const { resolvedTheme, setTheme } = useTheme();
const mounted = useMounted();
if (!mounted) {
return (
<Button
variant="ghost"
size="icon"
className={className}
aria-hidden="true"
tabIndex={-1}
disabled
>
<Sun className="h-5 w-5 opacity-0" />
</Button>
);
}
const isDark = resolvedTheme === 'dark';
const label = isDark ? 'Switch to light mode' : 'Switch to dark mode';
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => setTheme(isDark ? 'light' : 'dark')}
aria-label={label}
aria-pressed={isDark}
className={className}
>
{isDark ? (
<Sun className="h-5 w-5" />
) : (
<Moon className="h-5 w-5" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{label}</p>
</TooltipContent>
</Tooltip>
);
}

View File

@ -29,7 +29,17 @@ function UserMenu() {
const [popoverOpen, setPopoverOpen] = useState(false);
const handleSignOut = async () => {
await signOut({ callbackUrl: '/' });
// Close any overlays tied to the authenticated state (e.g. Manage Users)
// *before* the session clears, otherwise their effects/portals can unmount
// mid-transition and throw (issue #57).
setPopoverOpen(false);
setManageUsersOpen(false);
try {
await signOut({ callbackUrl: '/' });
} catch (error) {
console.error('[SIGN_OUT] Failed to sign out cleanly:', error);
router.push('/');
}
};
if (status === 'loading') {

View File

@ -0,0 +1,66 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ThemeToggle } from '@/components/ThemeToggle';
const { useThemeMock } = vi.hoisted(() => ({ useThemeMock: vi.fn() }));
vi.mock('next-themes', () => ({
useTheme: useThemeMock,
}));
describe('ThemeToggle', () => {
afterEach(() => {
useThemeMock.mockReset();
});
it('renders a light-mode toggle with the correct accessible label', () => {
const setTheme = vi.fn();
useThemeMock.mockReturnValue({ resolvedTheme: 'light', setTheme });
render(<ThemeToggle />);
const button = screen.getByRole('button', { name: 'Switch to dark mode' });
expect(button).toHaveAttribute('aria-pressed', 'false');
});
it('renders a dark-mode toggle with the correct accessible label', () => {
const setTheme = vi.fn();
useThemeMock.mockReturnValue({ resolvedTheme: 'dark', setTheme });
render(<ThemeToggle />);
const button = screen.getByRole('button', { name: 'Switch to light mode' });
expect(button).toHaveAttribute('aria-pressed', 'true');
});
it('calls setTheme with the opposite theme when clicked', async () => {
const user = userEvent.setup();
const setTheme = vi.fn();
useThemeMock.mockReturnValue({ resolvedTheme: 'light', setTheme });
render(<ThemeToggle />);
await user.click(screen.getByRole('button', { name: 'Switch to dark mode' }));
expect(setTheme).toHaveBeenCalledWith('dark');
});
it('toggles from dark back to light', async () => {
const user = userEvent.setup();
const setTheme = vi.fn();
useThemeMock.mockReturnValue({ resolvedTheme: 'dark', setTheme });
render(<ThemeToggle />);
await user.click(screen.getByRole('button', { name: 'Switch to light mode' }));
expect(setTheme).toHaveBeenCalledWith('light');
});
it('forwards a custom className to the trigger button', () => {
useThemeMock.mockReturnValue({ resolvedTheme: 'light', setTheme: vi.fn() });
render(<ThemeToggle className="my-toggle-class" />);
expect(screen.getByRole('button')).toHaveClass('my-toggle-class');
});
});

View File

@ -36,7 +36,7 @@ export function CollapsibleSearch({ people, tags, filters, onFiltersChange }: Co
{isExpanded ? (
<>
<div className="flex items-center gap-2">
<Search className="h-4 w-4" />
<Search className="h-4 w-4" aria-hidden="true" />
<span className="font-medium text-secondary">Search & Filter</span>
{hasActiveFilters && (
<span className="ml-2 rounded-full bg-primary px-2 py-0.5 text-xs text-primary-foreground">
@ -53,6 +53,8 @@ export function CollapsibleSearch({ people, tags, filters, onFiltersChange }: Co
size="sm"
onClick={() => setIsExpanded(false)}
className="h-8 w-8 p-0"
aria-label="Collapse search and filter panel"
aria-expanded="true"
>
<ChevronLeft className="h-4 w-4" />
</Button>
@ -65,6 +67,8 @@ export function CollapsibleSearch({ people, tags, filters, onFiltersChange }: Co
onClick={() => setIsExpanded(true)}
className="h-8 w-8 p-0 relative"
title="Expand search"
aria-label="Expand search and filter panel"
aria-expanded="false"
>
<ChevronRight className="h-4 w-4" />
{hasActiveFilters && (

View File

@ -74,7 +74,7 @@ export function PeopleFilter({ people, selected, mode, onSelectionChange, onMode
/>
<div className="max-h-[300px] overflow-y-auto">
{filteredPeople.length === 0 ? (
<p className="p-2 text-sm text-gray-500">No people found</p>
<p className="p-2 text-sm text-gray-500 dark:text-gray-400">No people found</p>
) : (
<div className="space-y-1">
{filteredPeople.map((person) => {

View File

@ -24,7 +24,7 @@ export function SearchBar({ onSearch, placeholder = 'Search photos...', defaultV
return (
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400 dark:text-gray-500" />
<Input
type="text"
placeholder={placeholder}

View File

@ -79,7 +79,7 @@ export function TagFilter({ tags, selected, mode, onSelectionChange, onModeChang
/>
<div className="max-h-[300px] overflow-y-auto">
{filteredTags.length === 0 ? (
<p className="p-2 text-sm text-gray-500">No tags found</p>
<p className="p-2 text-sm text-gray-500 dark:text-gray-400">No tags found</p>
) : (
<div className="space-y-1">
{filteredTags.map((tag) => {

View File

@ -0,0 +1,87 @@
import { useRef } from 'react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { useFocusTrap } from '@/hooks/useFocusTrap';
function TrapHarness({ active }: { active: boolean }) {
const containerRef = useRef<HTMLDivElement>(null);
useFocusTrap(containerRef, active);
return (
<div>
<button>outside-before</button>
<div ref={containerRef} tabIndex={-1}>
<button>first</button>
<button>middle</button>
<button>last</button>
</div>
<button>outside-after</button>
</div>
);
}
describe('useFocusTrap', () => {
it('moves focus into the container once activated', async () => {
render(<TrapHarness active={true} />);
await waitFor(() => {
expect(screen.getByText('first')).toHaveFocus();
});
});
it('does nothing when inactive, leaving focus wherever it was', () => {
render(<TrapHarness active={false} />);
expect(document.body).toHaveFocus();
});
it('wraps Tab from the last element back to the first', async () => {
render(<TrapHarness active={true} />);
await waitFor(() => {
expect(screen.getByText('first')).toHaveFocus();
});
const last = screen.getByText('last');
last.focus();
fireEvent.keyDown(last, { key: 'Tab', code: 'Tab' });
expect(screen.getByText('first')).toHaveFocus();
});
it('wraps Shift+Tab from the first element back to the last', async () => {
render(<TrapHarness active={true} />);
await waitFor(() => {
expect(screen.getByText('first')).toHaveFocus();
});
const first = screen.getByText('first');
fireEvent.keyDown(first, { key: 'Tab', code: 'Tab', shiftKey: true });
expect(screen.getByText('last')).toHaveFocus();
});
it('restores focus to the previously-focused element once deactivated', async () => {
function Wrapper({ active }: { active: boolean }) {
return (
<div>
<button>trigger</button>
<TrapHarness active={active} />
</div>
);
}
const { rerender } = render(<Wrapper active={false} />);
const trigger = screen.getByText('trigger');
trigger.focus();
expect(trigger).toHaveFocus();
rerender(<Wrapper active={true} />);
await waitFor(() => {
expect(screen.getByText('first')).toHaveFocus();
});
rerender(<Wrapper active={false} />);
expect(trigger).toHaveFocus();
});
});

View File

@ -0,0 +1,68 @@
import { useEffect, type RefObject } from 'react';
const FOCUSABLE_SELECTOR =
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
/**
* Traps keyboard focus inside a container while it's active (e.g. a
* full-screen custom modal that isn't built on Radix Dialog). Moves focus
* into the container on activation and restores focus to whatever was
* focused beforehand once the trap deactivates/unmounts.
*/
export function useFocusTrap(
containerRef: RefObject<HTMLElement | null>,
active: boolean
) {
useEffect(() => {
if (!active) return;
const container = containerRef.current;
if (!container) return;
const previouslyFocused = document.activeElement as HTMLElement | null;
const focusFirstElement = () => {
const focusable = container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR);
if (focusable.length > 0) {
focusable[0].focus();
} else {
container.focus();
}
};
// Defer so the container's children have mounted.
const raf = requestAnimationFrame(focusFirstElement);
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return;
const focusable = Array.from(
container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)
).filter((el) => el.offsetParent !== null || el === document.activeElement);
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
const activeElement = document.activeElement;
if (e.shiftKey) {
if (activeElement === first || !container.contains(activeElement)) {
e.preventDefault();
last.focus();
}
} else if (activeElement === last || !container.contains(activeElement)) {
e.preventDefault();
first.focus();
}
};
document.addEventListener('keydown', handleKeyDown, true);
return () => {
cancelAnimationFrame(raf);
document.removeEventListener('keydown', handleKeyDown, true);
previouslyFocused?.focus?.();
};
}, [active, containerRef]);
}

View File

@ -23,7 +23,9 @@ export function useIdleLogout(timeoutMs: number = 2 * 60 * 60 * 1000, enabled: b
if (session && enabled) {
timerRef.current = setTimeout(() => {
console.log('[IDLE_LOGOUT] User inactive for', timeoutMs / 1000 / 60, 'minutes, logging out...');
signOut({ callbackUrl: '/' });
signOut({ callbackUrl: '/' }).catch((error) => {
console.error('[IDLE_LOGOUT] Failed to sign out cleanly:', error);
});
}, timeoutMs);
}
}, [session, enabled, timeoutMs]);

View File

@ -0,0 +1,92 @@
import { describe, expect, it } from 'vitest';
import type { Photo } from '@prisma/client';
import {
getImageSrc,
getVideoSrc,
getWebPlaybackStreamUrl,
isUrl,
isVideo,
} from '@/lib/photo-utils';
function makePhoto(overrides: Partial<Photo> = {}): Photo {
return {
id: 1,
path: '/data/photos/2024/img.jpg',
media_type: 'image',
...overrides,
} as Photo;
}
describe('isUrl', () => {
it('treats http(s) paths as URLs', () => {
expect(isUrl('http://example.com/a.jpg')).toBe(true);
expect(isUrl('https://example.com/a.jpg')).toBe(true);
});
it('treats filesystem paths as non-URLs', () => {
expect(isUrl('/data/photos/img.jpg')).toBe(false);
expect(isUrl('data/photos/img.jpg')).toBe(false);
});
});
describe('isVideo', () => {
it('recognizes the snake_case media_type field (raw DB access / Prisma default)', () => {
expect(isVideo(makePhoto({ media_type: 'video' }))).toBe(true);
});
it('recognizes a camelCase mediaType field, in case a caller normalizes casing', () => {
const photo = { ...makePhoto(), media_type: null, mediaType: 'video' } as unknown as Photo;
expect(isVideo(photo)).toBe(true);
});
it('returns false for images', () => {
expect(isVideo(makePhoto({ media_type: 'image' }))).toBe(false);
});
});
describe('getImageSrc', () => {
it('returns the raw URL directly for URL-backed photos', () => {
const photo = makePhoto({ path: 'https://cdn.example.com/a.jpg' });
expect(getImageSrc(photo)).toBe('https://cdn.example.com/a.jpg');
});
it('routes filesystem-backed photos through the API proxy', () => {
const photo = makePhoto({ id: 42 });
expect(getImageSrc(photo)).toBe('/api/photos/42/image');
});
it('adds a watermark query param when requested', () => {
const photo = makePhoto({ id: 42 });
expect(getImageSrc(photo, { watermark: true })).toBe('/api/photos/42/image?watermark=true');
});
it('prefers the thumbnail endpoint for grid display, ignoring the URL/proxy split', () => {
const urlPhoto = makePhoto({ id: 5, path: 'https://cdn.example.com/a.jpg' });
expect(getImageSrc(urlPhoto, { thumbnail: true })).toBe('/api/photos/5/image?thumbnail=true');
});
it('combines thumbnail and watermark params', () => {
const photo = makePhoto({ id: 7 });
const src = getImageSrc(photo, { thumbnail: true, watermark: true });
expect(src).toContain('thumbnail=true');
expect(src).toContain('watermark=true');
});
});
describe('getVideoSrc', () => {
it('returns the raw URL directly for URL-backed videos', () => {
const photo = makePhoto({ path: 'https://cdn.example.com/clip.mp4' });
expect(getVideoSrc(photo)).toBe('https://cdn.example.com/clip.mp4');
});
it('routes filesystem-backed videos through the API proxy', () => {
const photo = makePhoto({ id: 9 });
expect(getVideoSrc(photo)).toBe('/api/photos/9/image');
});
});
describe('getWebPlaybackStreamUrl', () => {
it('builds the transcoded-playback endpoint for the given photo id', () => {
expect(getWebPlaybackStreamUrl(makePhoto({ id: 13 }))).toBe('/api/photos/13/web-playback');
});
});

View File

@ -0,0 +1,66 @@
import { describe, expect, it } from 'vitest';
import { cn, isValidEmail } from '@/lib/utils';
describe('cn', () => {
it('merges class names', () => {
expect(cn('a', 'b')).toBe('a b');
});
it('drops falsy values', () => {
expect(cn('a', false, undefined, null, '', 'b')).toBe('a b');
});
it('resolves conflicting tailwind classes, keeping the last one', () => {
expect(cn('p-2', 'p-4')).toBe('p-4');
});
it('applies conditional classes via object syntax', () => {
expect(cn('base', { active: true, hidden: false })).toBe('base active');
});
});
describe('isValidEmail', () => {
it.each([
'user@example.com',
'first.last@example.co.uk',
'user+tag@example.com',
'user_name@example.io',
])('accepts a valid address: %s', (email) => {
expect(isValidEmail(email)).toBe(true);
});
it('trims surrounding whitespace before validating', () => {
expect(isValidEmail(' user@example.com ')).toBe(true);
});
it.each([
['', 'empty string'],
['not-an-email', 'missing @ and domain'],
['user@', 'missing domain'],
['@example.com', 'missing local part'],
['user@example', 'domain missing TLD'],
['user..name@example.com', 'consecutive dots in local part'],
['.user@example.com', 'local part starts with a dot'],
['user.@example.com', 'local part ends with a dot'],
['user@example..com', 'consecutive dots in domain'],
['user@.example.com', 'domain starts with a dot'],
['user@example.c', 'TLD shorter than 2 chars'],
])('rejects an invalid address: %s (%s)', (email) => {
expect(isValidEmail(email)).toBe(false);
});
it('rejects non-string input without throwing', () => {
expect(isValidEmail(null as unknown as string)).toBe(false);
expect(isValidEmail(undefined as unknown as string)).toBe(false);
});
it('rejects a local part longer than 64 characters', () => {
const longLocal = 'a'.repeat(65);
expect(isValidEmail(`${longLocal}@example.com`)).toBe(false);
});
it('rejects an address longer than 254 characters overall', () => {
const longDomain = 'a'.repeat(250) + '.com';
expect(isValidEmail(`user@${longDomain}`)).toBe(false);
});
});

View File

@ -73,6 +73,13 @@ async function sendViaSmtp(payload: EmailPayload): Promise<void> {
}
async function sendViaResend(payload: EmailPayload): Promise<void> {
if (!process.env.RESEND_API_KEY) {
throw new Error(
'Missing Resend configuration (RESEND_API_KEY). Set SMTP_HOST/SMTP_USER/SMTP_PASS ' +
'or RESEND_API_KEY so password reset / email verification can actually send mail.'
);
}
const fromEmail = process.env.RESEND_FROM_EMAIL || process.env.SMTP_FROM_EMAIL || 'onboarding@resend.dev';
const fromName = process.env.RESEND_FROM_NAME || process.env.SMTP_FROM_NAME;
const replyTo = process.env.RESEND_REPLY_TO || process.env.SMTP_REPLY_TO || fromEmail;

File diff suppressed because it is too large Load Diff

View File

@ -10,6 +10,9 @@
"start:3001": "PORT=3001 ./scripts/with-sharp-libpath.sh next start",
"lint": "next lint",
"type-check": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"prisma:generate": "prisma generate",
"prisma:generate:auth": "prisma generate --schema=prisma/schema-auth.prisma",
"prisma:generate:all": "prisma generate && prisma generate --schema=prisma/schema-auth.prisma",
@ -38,6 +41,7 @@
"lucide-react": "^0.553.0",
"next": "^16.1.1",
"next-auth": "^5.0.0-beta.30",
"next-themes": "^0.4.6",
"nodemailer": "^7.0.13",
"prisma": "^6.19.0",
"react": "19.2.0",
@ -51,18 +55,24 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^20",
"@types/nodemailer": "^7.0.11",
"@types/react": "^19",
"@types/react-dom": "^19",
"@vitejs/plugin-react": "^6.0.3",
"dotenv": "^17.2.3",
"eslint": "^9",
"eslint-config-next": "16.0.3",
"jsdom": "^29.1.1",
"node-addon-api": "^8.5.0",
"node-gyp": "^12.1.0",
"tailwindcss": "^4",
"tsx": "^4.20.6",
"tw-animate-css": "^1.4.0",
"typescript": "^5"
"typescript": "^5",
"vitest": "^4.1.10"
}
}

View File

@ -13,6 +13,7 @@
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"types": ["vitest/globals", "@testing-library/jest-dom"],
"plugins": [
{
"name": "next"

View File

@ -0,0 +1,19 @@
import path from 'path';
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vitest/config';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./vitest.setup.ts'],
include: ['**/*.test.{ts,tsx}'],
exclude: ['node_modules', '.next', 'e2e'],
},
resolve: {
alias: {
'@': path.resolve(__dirname, '.'),
},
},
});

View File

@ -0,0 +1,54 @@
import '@testing-library/jest-dom/vitest';
// jsdom doesn't implement these, but Radix UI primitives (Tooltip, Popover,
// Select, etc.) call them during mount/positioning. Stub them out so
// component tests don't have to know about Radix's internals.
if (typeof window.matchMedia !== 'function') {
window.matchMedia = (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
}) as unknown as MediaQueryList;
}
if (typeof (globalThis as any).ResizeObserver === 'undefined') {
class ResizeObserverStub {
observe() {}
unobserve() {}
disconnect() {}
}
(globalThis as any).ResizeObserver = ResizeObserverStub;
}
for (const method of ['hasPointerCapture', 'setPointerCapture', 'releasePointerCapture'] as const) {
if (!(method in Element.prototype)) {
Object.defineProperty(Element.prototype, method, {
value: () => false,
writable: true,
});
}
}
if (!('scrollIntoView' in Element.prototype)) {
Object.defineProperty(Element.prototype, 'scrollIntoView', {
value: () => {},
writable: true,
});
}
// jsdom never computes layout, so its native `offsetParent` getter always
// returns null. Code that uses it as a "is this element visible?" check
// (e.g. useFocusTrap's focusable-element filter) would otherwise treat
// everything as hidden. Override it to approximate "visible" as "attached
// to the document" instead.
Object.defineProperty(HTMLElement.prototype, 'offsetParent', {
get() {
return this.parentNode;
},
configurable: true,
});