Compare commits
17 Commits
master
...
test/e2e-g
| Author | SHA1 | Date | |
|---|---|---|---|
| 4ef8fcc7e7 | |||
| d9910acdde | |||
| 988e9accf8 | |||
| 217b82d912 | |||
| 47b15049bc | |||
| 62d5a66ffa | |||
| 975d0c809d | |||
| 05b908241d | |||
| 5303580107 | |||
| fc8b47863d | |||
| 2d6bbf1d13 | |||
| 0f24e7e6ba | |||
| 0a9223c943 | |||
| 44f3d696e4 | |||
| c011c8078e | |||
| e54c60912c | |||
| bb09159133 |
@ -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
10
.gitignore
vendored
@ -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
|
||||
|
||||
@ -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
45
ROADMAP.md
Normal 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
27
e2e/.env.example
Normal 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
3
e2e/.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
.auth/
|
||||
.env
|
||||
|
||||
53
e2e/README.md
Normal file
53
e2e/README.md
Normal 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
6
e2e/env-defaults.json
Normal 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
27
e2e/env-defaults.ts
Normal 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
47
e2e/fixtures.ts
Normal 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
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
131
e2e/package-lock.json
generated
Normal 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
22
e2e/package.json
Normal 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
28
e2e/pages/AccountMenu.ts
Normal 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
38
e2e/pages/LoginPage.ts
Normal 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
27
e2e/pages/RegisterPage.ts
Normal 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
33
e2e/playwright.config.ts
Normal 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/,
|
||||
},
|
||||
],
|
||||
});
|
||||
40
e2e/tests/admin.manage-users.spec.ts
Normal file
40
e2e/tests/admin.manage-users.spec.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
18
e2e/tests/api.auth.spec.ts
Normal file
18
e2e/tests/api.auth.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
81
e2e/tests/api.catalog.spec.ts
Normal file
81
e2e/tests/api.catalog.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
27
e2e/tests/api.fastapi-login.spec.ts
Normal file
27
e2e/tests/api.fastapi-login.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
13
e2e/tests/api.health.spec.ts
Normal file
13
e2e/tests/api.health.spec.ts
Normal 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
20
e2e/tests/auth.setup.ts
Normal 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);
|
||||
});
|
||||
59
e2e/tests/auth.signout.spec.ts
Normal file
59
e2e/tests/auth.signout.spec.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
100
e2e/tests/forgot-password.mail.spec.ts
Normal file
100
e2e/tests/forgot-password.mail.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
40
e2e/tests/idle.logout.spec.ts
Normal file
40
e2e/tests/idle.logout.spec.ts
Normal 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+/);
|
||||
});
|
||||
});
|
||||
82
e2e/tests/register.verify.mail.spec.ts
Normal file
82
e2e/tests/register.verify.mail.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
56
e2e/tests/reset-password.flow.spec.ts
Normal file
56
e2e/tests/reset-password.flow.spec.ts
Normal 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+/);
|
||||
});
|
||||
});
|
||||
46
e2e/tests/upload.smoke.spec.ts
Normal file
46
e2e/tests/upload.smoke.spec.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
24
e2e/tests/viewer.session.spec.ts
Normal file
24
e2e/tests/viewer.session.spec.ts
Normal 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
13
e2e/tsconfig.json
Normal 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"]
|
||||
}
|
||||
@ -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/'"
|
||||
|
||||
@ -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."""
|
||||
|
||||
49
tests/test_api_click_log.py
Normal file
49
tests/test_api_click_log.py
Normal 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
195
tests/test_api_errors.py
Normal 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)
|
||||
171
tests/test_api_integration.py
Normal file
171
tests/test_api_integration.py
Normal 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
18
tests/test_api_metrics.py
Normal 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
|
||||
191
tests/test_api_pending_identifications.py
Normal file
191
tests/test_api_pending_identifications.py
Normal 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
|
||||
156
tests/test_api_pending_linkages.py
Normal file
156
tests/test_api_pending_linkages.py
Normal 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"]
|
||||
157
tests/test_api_reported_photos.py
Normal file
157
tests/test_api_reported_photos.py
Normal 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
|
||||
79
tests/test_api_role_permissions.py
Normal file
79
tests/test_api_role_permissions.py
Normal 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
171
tests/test_api_videos.py
Normal 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
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 }
|
||||
);
|
||||
|
||||
@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
38
viewer-frontend/app/error.tsx
Normal file
38
viewer-frontend/app/error.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
</>
|
||||
|
||||
@ -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} />
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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 1s–60s) 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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
20
viewer-frontend/components/ThemeProviderWrapper.tsx
Normal file
20
viewer-frontend/components/ThemeProviderWrapper.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
83
viewer-frontend/components/ThemeToggle.tsx
Normal file
83
viewer-frontend/components/ThemeToggle.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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') {
|
||||
|
||||
66
viewer-frontend/components/__tests__/ThemeToggle.test.tsx
Normal file
66
viewer-frontend/components/__tests__/ThemeToggle.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
@ -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 && (
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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) => {
|
||||
|
||||
87
viewer-frontend/hooks/__tests__/useFocusTrap.test.tsx
Normal file
87
viewer-frontend/hooks/__tests__/useFocusTrap.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
68
viewer-frontend/hooks/useFocusTrap.ts
Normal file
68
viewer-frontend/hooks/useFocusTrap.ts
Normal 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]);
|
||||
}
|
||||
@ -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]);
|
||||
|
||||
92
viewer-frontend/lib/__tests__/photo-utils.test.ts
Normal file
92
viewer-frontend/lib/__tests__/photo-utils.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
66
viewer-frontend/lib/__tests__/utils.test.ts
Normal file
66
viewer-frontend/lib/__tests__/utils.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
|
||||
1929
viewer-frontend/package-lock.json
generated
1929
viewer-frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,6 +13,7 @@
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"types": ["vitest/globals", "@testing-library/jest-dom"],
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
|
||||
19
viewer-frontend/vitest.config.ts
Normal file
19
viewer-frontend/vitest.config.ts
Normal 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, '.'),
|
||||
},
|
||||
},
|
||||
});
|
||||
54
viewer-frontend/vitest.setup.ts
Normal file
54
viewer-frontend/vitest.setup.ts
Normal 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,
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user