Sync master: scrub public docs, untrack internal notes/caches, roadmap update #71
+6
-4
@@ -219,16 +219,18 @@ Types:
|
||||
|
||||
## Development Environment
|
||||
|
||||
Credentials are NOT stored in this file (or anywhere in git). See Infisical
|
||||
(`LevkinOps` project) / ansible vault for dev server SSH and Postgres
|
||||
credentials — ask if you need the lookup path.
|
||||
|
||||
### Dev Server
|
||||
- **Host**: 10.0.10.121
|
||||
- **Host**: `<backend-host>`
|
||||
- **User**: appuser
|
||||
- **Password**: C0caC0la
|
||||
|
||||
### Dev PostgreSQL
|
||||
- **Host**: 10.0.10.181
|
||||
- **Host**: `<db-host>`
|
||||
- **Port**: 5432
|
||||
- **User**: ladmin
|
||||
- **Password**: C0caC0la
|
||||
|
||||
## Deployment
|
||||
- Use deployment scripts in package.json
|
||||
|
||||
+19
-11
@@ -1,16 +1,24 @@
|
||||
# Database Configuration
|
||||
# PostgreSQL (for network database)
|
||||
DATABASE_URL=postgresql+psycopg2://punimtag:punimtag_password@localhost:5432/punimtag
|
||||
# PunimTag root environment (copy to ".env" and edit values)
|
||||
|
||||
# Or use SQLite for local development (default if DATABASE_URL not set)
|
||||
# DATABASE_URL=sqlite:///data/punimtag.db
|
||||
# PostgreSQL (main application DB)
|
||||
DATABASE_URL=postgresql+psycopg2://punimtag:CHANGE_ME@127.0.0.1:5432/punimtag
|
||||
|
||||
# Photo Storage
|
||||
# PostgreSQL (auth DB)
|
||||
DATABASE_URL_AUTH=postgresql+psycopg2://punimtag_auth:CHANGE_ME@127.0.0.1:5432/punimtag_auth
|
||||
|
||||
# JWT / bootstrap admin (change these!)
|
||||
SECRET_KEY=CHANGE_ME_TO_A_LONG_RANDOM_STRING
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=CHANGE_ME
|
||||
|
||||
# Photo storage (default for local dev: data/uploads)
|
||||
PHOTO_STORAGE_DIR=data/uploads
|
||||
|
||||
# JWT Secrets (change in production!)
|
||||
SECRET_KEY=your-secret-key-here-change-in-production
|
||||
# Pending viewer uploads (same value as viewer UPLOAD_DIR when using that feature).
|
||||
# Web transcode cache defaults to a sibling folder: <parent>/web_videos next to
|
||||
# .../pending-photos (override with WEB_VIDEO_CACHE_DIR if needed).
|
||||
# UPLOAD_DIR=/mnt/db-server-uploads/pending-photos
|
||||
# WEB_VIDEO_CACHE_DIR=/mnt/db-server-uploads/web_videos
|
||||
|
||||
# Single-user credentials (change in production!)
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=admin
|
||||
# Redis (RQ jobs)
|
||||
REDIS_URL=redis://127.0.0.1:6379/0
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
# PunimTag root environment (copy to ".env" and edit values)
|
||||
|
||||
# PostgreSQL (main application DB)
|
||||
DATABASE_URL=postgresql+psycopg2://punimtag:CHANGE_ME@127.0.0.1:5432/punimtag
|
||||
|
||||
# PostgreSQL (auth DB)
|
||||
DATABASE_URL_AUTH=postgresql+psycopg2://punimtag_auth:CHANGE_ME@127.0.0.1:5432/punimtag_auth
|
||||
|
||||
# JWT / bootstrap admin (change these!)
|
||||
SECRET_KEY=CHANGE_ME_TO_A_LONG_RANDOM_STRING
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=CHANGE_ME
|
||||
|
||||
# Photo storage
|
||||
PHOTO_STORAGE_DIR=/opt/punimtag/data/uploads
|
||||
|
||||
# Pending viewer uploads (same value as viewer UPLOAD_DIR when using that feature).
|
||||
# Web transcode cache defaults to a sibling folder: <parent>/web_videos next to
|
||||
# .../pending-photos (override with WEB_VIDEO_CACHE_DIR if needed).
|
||||
# UPLOAD_DIR=/mnt/db-server-uploads/pending-photos
|
||||
# WEB_VIDEO_CACHE_DIR=/mnt/db-server-uploads/web_videos
|
||||
|
||||
# Redis (RQ jobs)
|
||||
REDIS_URL=redis://127.0.0.1:6379/0
|
||||
|
||||
|
||||
+148
-1
@@ -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,147 @@ 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
|
||||
# act_runner's shared npm cache has corrupted platform-native tarballs
|
||||
# before (@next/swc-linux-x64-musl) and reds this job with no product
|
||||
# bug involved — clear the cache and retry once before giving up.
|
||||
run: |
|
||||
npm ci || {
|
||||
echo "npm ci failed, clearing npm cache and retrying once..."
|
||||
npm cache clean --force
|
||||
npm ci
|
||||
}
|
||||
|
||||
- name: Run Vitest
|
||||
run: npm test
|
||||
|
||||
# Vitest unit tests (admin-frontend) — Vite/React FastAPI admin tool, no
|
||||
# test infra before this; started with pure lib/ helpers.
|
||||
admin-unit:
|
||||
needs: skip-ci-check
|
||||
if: needs.skip-ci-check.outputs.should-skip != '1'
|
||||
runs-on: [homelab, self-hosted, linux]
|
||||
defaults:
|
||||
run:
|
||||
working-directory: admin-frontend
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: npm
|
||||
cache-dependency-path: admin-frontend/package-lock.json
|
||||
|
||||
- name: Install deps
|
||||
# Same shared act_runner npm cache corruption as viewer-unit — retry
|
||||
# once with a clean cache rather than redding CI for an infra flake.
|
||||
run: |
|
||||
npm ci || {
|
||||
echo "npm ci failed, clearing npm cache and retrying once..."
|
||||
npm cache clean --force
|
||||
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
|
||||
# Same shared act_runner npm cache corruption as viewer-unit/admin-unit.
|
||||
run: |
|
||||
npm ci || {
|
||||
echo "npm ci failed, clearing npm cache and retrying once..."
|
||||
npm cache clean --force
|
||||
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 }}
|
||||
# FastAPI main-DB bearer creds (separate store from NextAuth above) —
|
||||
# admin mirrors e2e@levkine.ca; viewer is role-gate test coverage only.
|
||||
E2E_API_USERNAME: ${{ secrets.E2E_API_USERNAME }}
|
||||
E2E_API_PASSWORD: ${{ secrets.E2E_API_PASSWORD }}
|
||||
E2E_API_VIEWER_USERNAME: ${{ secrets.E2E_API_VIEWER_USERNAME }}
|
||||
E2E_API_VIEWER_PASSWORD: ${{ secrets.E2E_API_VIEWER_PASSWORD }}
|
||||
# NextAuth auth-DB viewer (hasWriteAccess=false) — third, independent
|
||||
# user store; used only by viewer.write-gates.spec.ts.
|
||||
E2E_VIEWER_EMAIL: ${{ secrets.E2E_VIEWER_EMAIL }}
|
||||
E2E_VIEWER_PASSWORD: ${{ secrets.E2E_VIEWER_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()
|
||||
# v4 breaks on this Gitea/act runner (act_runner's artifact API doesn't
|
||||
# speak v4's backend protocol) — pin v3.
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: playwright-report
|
||||
path: |
|
||||
e2e/playwright-report/
|
||||
e2e/test-results/
|
||||
retention-days: 7
|
||||
|
||||
+15
@@ -63,6 +63,8 @@ Thumbs.db
|
||||
*.jpg
|
||||
*.jpeg
|
||||
*.png
|
||||
!e2e/fixtures/
|
||||
!e2e/fixtures/**
|
||||
*.gif
|
||||
*.bmp
|
||||
*.tiff
|
||||
@@ -85,3 +87,16 @@ 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
|
||||
|
||||
# Internal notes and caches
|
||||
.notes/
|
||||
logs/
|
||||
.vite/
|
||||
|
||||
@@ -1,805 +0,0 @@
|
||||
# Migration Plan: Replace face_recognition with DeepFace in PunimTag
|
||||
|
||||
**Version:** 1.0
|
||||
**Created:** October 15, 2025
|
||||
**Status:** Planning Phase
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
This plan outlines the complete migration from `face_recognition` library to `DeepFace` library for the PunimTag photo tagging application. Based on testing in `test_deepface_gui.py`, DeepFace provides superior accuracy using the ArcFace model with configurable detector backends.
|
||||
|
||||
**Key Changes:**
|
||||
- Face encoding dimensions: 128 → 512 (ArcFace model)
|
||||
- Detection method: HOG/CNN → RetinaFace/MTCNN/OpenCV/SSD (configurable)
|
||||
- Similarity metric: Euclidean distance → Cosine similarity
|
||||
- Face location format: (top, right, bottom, left) → {x, y, w, h}
|
||||
- No backward compatibility - fresh start with new database
|
||||
|
||||
---
|
||||
|
||||
## PHASE 1: Database Schema Updates
|
||||
|
||||
### Step 1.1: Update Database Schema
|
||||
**File:** `src/core/database.py`
|
||||
|
||||
**Actions:**
|
||||
1. **Modify `faces` table** to add DeepFace-specific columns:
|
||||
```sql
|
||||
ALTER TABLE faces ADD COLUMN detector_backend TEXT DEFAULT 'retinaface';
|
||||
ALTER TABLE faces ADD COLUMN model_name TEXT DEFAULT 'ArcFace';
|
||||
ALTER TABLE faces ADD COLUMN face_confidence REAL DEFAULT 0.0;
|
||||
```
|
||||
|
||||
2. **Update `person_encodings` table** similarly:
|
||||
```sql
|
||||
ALTER TABLE person_encodings ADD COLUMN detector_backend TEXT DEFAULT 'retinaface';
|
||||
ALTER TABLE person_encodings ADD COLUMN model_name TEXT DEFAULT 'ArcFace';
|
||||
```
|
||||
|
||||
3. **Update `init_database()` method** in `DatabaseManager` class:
|
||||
- Add new columns to CREATE TABLE statements for `faces` and `person_encodings`
|
||||
- Add indices for new columns if needed
|
||||
|
||||
**Expected encoding size change:**
|
||||
- face_recognition: 128 floats × 8 bytes = 1,024 bytes per encoding
|
||||
- DeepFace ArcFace: 512 floats × 8 bytes = 4,096 bytes per encoding
|
||||
|
||||
### Step 1.2: Update Database Methods
|
||||
**File:** `src/core/database.py`
|
||||
|
||||
**Actions:**
|
||||
1. **Modify `add_face()` method signature:**
|
||||
```python
|
||||
def add_face(self, photo_id: int, encoding: bytes, location: str,
|
||||
confidence: float = 0.0, quality_score: float = 0.0,
|
||||
person_id: Optional[int] = None,
|
||||
detector_backend: str = 'retinaface',
|
||||
model_name: str = 'ArcFace',
|
||||
face_confidence: float = 0.0) -> int:
|
||||
```
|
||||
|
||||
2. **Modify `add_person_encoding()` method signature:**
|
||||
```python
|
||||
def add_person_encoding(self, person_id: int, face_id: int, encoding: bytes,
|
||||
quality_score: float,
|
||||
detector_backend: str = 'retinaface',
|
||||
model_name: str = 'ArcFace'):
|
||||
```
|
||||
|
||||
3. **Update all database queries** that insert/update faces to include new fields
|
||||
|
||||
---
|
||||
|
||||
## PHASE 2: Configuration Updates
|
||||
|
||||
### Step 2.1: Update Configuration Constants
|
||||
**File:** `src/core/config.py`
|
||||
|
||||
**Actions:**
|
||||
1. **Replace face_recognition settings:**
|
||||
```python
|
||||
# OLD - Remove these:
|
||||
# DEFAULT_FACE_DETECTION_MODEL = "hog"
|
||||
# DEFAULT_FACE_TOLERANCE = 0.6
|
||||
|
||||
# NEW - Add these:
|
||||
DEEPFACE_DETECTOR_BACKEND = "retinaface" # Options: retinaface, mtcnn, opencv, ssd
|
||||
DEEPFACE_MODEL_NAME = "ArcFace" # Best accuracy model
|
||||
DEEPFACE_DISTANCE_METRIC = "cosine" # For similarity calculation
|
||||
DEEPFACE_ENFORCE_DETECTION = False # Don't fail if no faces found
|
||||
DEEPFACE_ALIGN_FACES = True # Face alignment for better accuracy
|
||||
|
||||
# Tolerance/threshold adjustments for DeepFace
|
||||
DEFAULT_FACE_TOLERANCE = 0.4 # Lower for DeepFace (was 0.6 for face_recognition)
|
||||
DEEPFACE_SIMILARITY_THRESHOLD = 60 # Minimum similarity percentage (0-100)
|
||||
|
||||
# Environment settings for TensorFlow (DeepFace uses TensorFlow backend)
|
||||
import os
|
||||
os.environ['TF_CPP_MIN_LOG_LEVEL'] = '3' # Suppress TensorFlow warnings
|
||||
```
|
||||
|
||||
2. **Add detector backend options:**
|
||||
```python
|
||||
DEEPFACE_DETECTOR_OPTIONS = ["retinaface", "mtcnn", "opencv", "ssd"]
|
||||
DEEPFACE_MODEL_OPTIONS = ["ArcFace", "Facenet", "Facenet512", "VGG-Face"]
|
||||
```
|
||||
|
||||
### Step 2.2: Add TensorFlow Suppression
|
||||
**File:** Add to all main entry points (dashboard_gui.py, photo_tagger.py, etc.)
|
||||
|
||||
**Actions:**
|
||||
```python
|
||||
# At the top of file, before other imports
|
||||
import os
|
||||
import warnings
|
||||
os.environ['TF_CPP_MIN_LOG_LEVEL'] = '3'
|
||||
warnings.filterwarnings('ignore')
|
||||
|
||||
# Then import DeepFace
|
||||
from deepface import DeepFace
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PHASE 3: Face Processing Core Migration
|
||||
|
||||
### Step 3.1: Replace Face Detection and Encoding
|
||||
**File:** `src/core/face_processing.py` → `FaceProcessor` class
|
||||
|
||||
**Method:** `process_faces()`
|
||||
|
||||
**Current Implementation (lines 59-144):**
|
||||
```python
|
||||
# OLD CODE:
|
||||
image = face_recognition.load_image_file(photo_path)
|
||||
face_locations = face_recognition.face_locations(image, model=model)
|
||||
face_encodings = face_recognition.face_encodings(image, face_locations)
|
||||
```
|
||||
|
||||
**New Implementation:**
|
||||
```python
|
||||
# NEW CODE:
|
||||
try:
|
||||
# Use DeepFace.represent() to get face detection and encodings
|
||||
results = DeepFace.represent(
|
||||
img_path=photo_path,
|
||||
model_name=DEEPFACE_MODEL_NAME, # 'ArcFace'
|
||||
detector_backend=DEEPFACE_DETECTOR_BACKEND, # 'retinaface'
|
||||
enforce_detection=DEEPFACE_ENFORCE_DETECTION, # False
|
||||
align=DEEPFACE_ALIGN_FACES # True
|
||||
)
|
||||
|
||||
if not results:
|
||||
if self.verbose >= 1:
|
||||
print(f" 👤 No faces found")
|
||||
# Mark as processed even with no faces
|
||||
self.db.mark_photo_processed(photo_id)
|
||||
continue
|
||||
|
||||
if self.verbose >= 1:
|
||||
print(f" 👤 Found {len(results)} faces")
|
||||
|
||||
# Process each detected face
|
||||
for i, result in enumerate(results):
|
||||
# Extract face region info from DeepFace result
|
||||
facial_area = result.get('facial_area', {})
|
||||
face_confidence = result.get('face_confidence', 0.0)
|
||||
embedding = np.array(result['embedding'])
|
||||
|
||||
# Convert DeepFace facial_area {x, y, w, h} to our location format
|
||||
# Store as dict for consistency
|
||||
location = {
|
||||
'x': facial_area.get('x', 0),
|
||||
'y': facial_area.get('y', 0),
|
||||
'w': facial_area.get('w', 0),
|
||||
'h': facial_area.get('h', 0)
|
||||
}
|
||||
|
||||
# Calculate face quality score (reuse existing method)
|
||||
# Convert facial_area to (top, right, bottom, left) for quality calculation
|
||||
face_location_tuple = (
|
||||
facial_area.get('y', 0), # top
|
||||
facial_area.get('x', 0) + facial_area.get('w', 0), # right
|
||||
facial_area.get('y', 0) + facial_area.get('h', 0), # bottom
|
||||
facial_area.get('x', 0) # left
|
||||
)
|
||||
|
||||
# Load image for quality calculation
|
||||
image = Image.open(photo_path)
|
||||
image_np = np.array(image)
|
||||
quality_score = self._calculate_face_quality_score(image_np, face_location_tuple)
|
||||
|
||||
# Store in database with new format
|
||||
self.db.add_face(
|
||||
photo_id=photo_id,
|
||||
encoding=embedding.tobytes(),
|
||||
location=str(location), # Store as string representation of dict
|
||||
confidence=0.0, # Legacy field, keep for compatibility
|
||||
quality_score=quality_score,
|
||||
person_id=None,
|
||||
detector_backend=DEEPFACE_DETECTOR_BACKEND,
|
||||
model_name=DEEPFACE_MODEL_NAME,
|
||||
face_confidence=face_confidence
|
||||
)
|
||||
|
||||
if self.verbose >= 3:
|
||||
print(f" Face {i+1}: {location} (quality: {quality_score:.2f}, confidence: {face_confidence:.2f})")
|
||||
|
||||
# Mark as processed
|
||||
self.db.mark_photo_processed(photo_id)
|
||||
processed_count += 1
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error processing {filename}: {e}")
|
||||
self.db.mark_photo_processed(photo_id)
|
||||
```
|
||||
|
||||
### Step 3.2: Update Face Location Handling
|
||||
**File:** `src/core/face_processing.py`
|
||||
|
||||
**Method:** `_extract_face_crop()` (appears twice, lines 212-271 and 541-600)
|
||||
|
||||
**Current Implementation:**
|
||||
```python
|
||||
# OLD: face_recognition format (top, right, bottom, left)
|
||||
top, right, bottom, left = location
|
||||
```
|
||||
|
||||
**New Implementation:**
|
||||
```python
|
||||
# NEW: DeepFace format {x, y, w, h}
|
||||
# Parse location from string if needed
|
||||
if isinstance(location, str):
|
||||
location = eval(location) # Convert string to dict
|
||||
|
||||
# Handle both formats for compatibility during migration
|
||||
if isinstance(location, dict):
|
||||
# DeepFace format
|
||||
left = location.get('x', 0)
|
||||
top = location.get('y', 0)
|
||||
width = location.get('w', 0)
|
||||
height = location.get('h', 0)
|
||||
right = left + width
|
||||
bottom = top + height
|
||||
else:
|
||||
# Legacy face_recognition format (top, right, bottom, left)
|
||||
top, right, bottom, left = location
|
||||
|
||||
# Rest of the method remains the same
|
||||
face_width = right - left
|
||||
face_height = bottom - top
|
||||
# ... etc
|
||||
```
|
||||
|
||||
### Step 3.3: Replace Similarity Calculation
|
||||
**File:** `src/core/face_processing.py`
|
||||
|
||||
**Method:** `find_similar_faces()` and helper methods
|
||||
|
||||
**Current Implementation (line 457):**
|
||||
```python
|
||||
# OLD:
|
||||
distance = face_recognition.face_distance([target_encoding], other_enc)[0]
|
||||
```
|
||||
|
||||
**New Implementation:**
|
||||
```python
|
||||
# NEW: Use cosine similarity (same as test_deepface_gui.py)
|
||||
def _calculate_cosine_similarity(self, encoding1: np.ndarray, encoding2: np.ndarray) -> float:
|
||||
"""Calculate cosine similarity between two face encodings"""
|
||||
try:
|
||||
# Ensure encodings are numpy arrays
|
||||
enc1 = np.array(encoding1).flatten()
|
||||
enc2 = np.array(encoding2).flatten()
|
||||
|
||||
# Check if encodings have the same length
|
||||
if len(enc1) != len(enc2):
|
||||
print(f"Warning: Encoding length mismatch: {len(enc1)} vs {len(enc2)}")
|
||||
return 0.0
|
||||
|
||||
# Normalize encodings
|
||||
enc1_norm = enc1 / (np.linalg.norm(enc1) + 1e-8)
|
||||
enc2_norm = enc2 / (np.linalg.norm(enc2) + 1e-8)
|
||||
|
||||
# Calculate cosine similarity
|
||||
cosine_sim = np.dot(enc1_norm, enc2_norm)
|
||||
|
||||
# Clamp to valid range [-1, 1]
|
||||
cosine_sim = np.clip(cosine_sim, -1.0, 1.0)
|
||||
|
||||
# Convert to distance (0 = identical, 2 = opposite)
|
||||
# For consistency with face_recognition's distance metric
|
||||
distance = 1.0 - cosine_sim # Range [0, 2], where 0 is perfect match
|
||||
|
||||
return distance
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error calculating similarity: {e}")
|
||||
return 2.0 # Maximum distance on error
|
||||
|
||||
# Replace in find_similar_faces():
|
||||
distance = self._calculate_cosine_similarity(target_encoding, other_enc)
|
||||
```
|
||||
|
||||
**Update adaptive tolerance calculation (line 333-351):**
|
||||
```python
|
||||
def _calculate_adaptive_tolerance(self, base_tolerance: float, face_quality: float, match_confidence: float = None) -> float:
|
||||
"""Calculate adaptive tolerance based on face quality and match confidence
|
||||
|
||||
Note: For DeepFace, tolerance values are generally lower than face_recognition
|
||||
"""
|
||||
# Start with base tolerance (e.g., 0.4 instead of 0.6)
|
||||
tolerance = base_tolerance
|
||||
|
||||
# Adjust based on face quality
|
||||
quality_factor = 0.9 + (face_quality * 0.2) # Range: 0.9 to 1.1
|
||||
tolerance *= quality_factor
|
||||
|
||||
# Adjust based on match confidence if provided
|
||||
if match_confidence is not None:
|
||||
confidence_factor = 0.95 + (match_confidence * 0.1)
|
||||
tolerance *= confidence_factor
|
||||
|
||||
# Ensure tolerance stays within reasonable bounds for DeepFace
|
||||
return max(0.2, min(0.6, tolerance)) # Lower range for DeepFace
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PHASE 4: GUI Integration Updates
|
||||
|
||||
### Step 4.1: Add DeepFace Settings to Dashboard
|
||||
**File:** `src/gui/dashboard_gui.py`
|
||||
|
||||
**Actions:**
|
||||
1. **Add detector selection to menu or settings:**
|
||||
```python
|
||||
# Add to settings menu or control panel
|
||||
detector_frame = ttk.Frame(settings_panel)
|
||||
detector_frame.pack(fill=tk.X, padx=5, pady=5)
|
||||
|
||||
ttk.Label(detector_frame, text="Face Detector:").pack(side=tk.LEFT, padx=5)
|
||||
self.detector_var = tk.StringVar(value=DEEPFACE_DETECTOR_BACKEND)
|
||||
detector_combo = ttk.Combobox(detector_frame, textvariable=self.detector_var,
|
||||
values=DEEPFACE_DETECTOR_OPTIONS,
|
||||
state="readonly", width=12)
|
||||
detector_combo.pack(side=tk.LEFT, padx=5)
|
||||
|
||||
ttk.Label(detector_frame, text="Model:").pack(side=tk.LEFT, padx=5)
|
||||
self.model_var = tk.StringVar(value=DEEPFACE_MODEL_NAME)
|
||||
model_combo = ttk.Combobox(detector_frame, textvariable=self.model_var,
|
||||
values=DEEPFACE_MODEL_OPTIONS,
|
||||
state="readonly", width=12)
|
||||
model_combo.pack(side=tk.LEFT, padx=5)
|
||||
```
|
||||
|
||||
2. **Update process_faces calls to use selected settings:**
|
||||
```python
|
||||
# When calling face processor
|
||||
detector = self.detector_var.get()
|
||||
model = self.model_var.get()
|
||||
|
||||
# Pass to FaceProcessor (need to update FaceProcessor.__init__ to accept these)
|
||||
self.face_processor = FaceProcessor(
|
||||
db_manager=self.db,
|
||||
verbose=self.verbose,
|
||||
detector_backend=detector,
|
||||
model_name=model
|
||||
)
|
||||
```
|
||||
|
||||
### Step 4.2: Update Face Processor Initialization
|
||||
**File:** `src/core/face_processing.py`
|
||||
|
||||
**Class:** `FaceProcessor.__init__()`
|
||||
|
||||
**Actions:**
|
||||
```python
|
||||
def __init__(self, db_manager: DatabaseManager, verbose: int = 0,
|
||||
detector_backend: str = None, model_name: str = None):
|
||||
"""Initialize face processor with DeepFace settings"""
|
||||
self.db = db_manager
|
||||
self.verbose = verbose
|
||||
self.detector_backend = detector_backend or DEEPFACE_DETECTOR_BACKEND
|
||||
self.model_name = model_name or DEEPFACE_MODEL_NAME
|
||||
self._face_encoding_cache = {}
|
||||
self._image_cache = {}
|
||||
```
|
||||
|
||||
### Step 4.3: Update GUI Display Methods
|
||||
**File:** Multiple panel files (identify_panel.py, auto_match_panel.py, modify_panel.py)
|
||||
|
||||
**Actions:**
|
||||
1. **Update all face thumbnail extraction** to handle new location format
|
||||
2. **Update confidence display** to use cosine similarity percentages
|
||||
3. **Update any hardcoded face_recognition references**
|
||||
|
||||
**Example for identify_panel.py:**
|
||||
```python
|
||||
# In display methods, convert distance to percentage
|
||||
confidence_pct = (1 - distance) * 100 # Already done correctly
|
||||
# But ensure distance calculation uses cosine similarity
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PHASE 5: Dependencies and Installation
|
||||
|
||||
### Step 5.1: Update requirements.txt
|
||||
**File:** `requirements.txt`
|
||||
|
||||
**Actions:**
|
||||
```python
|
||||
# REMOVE these:
|
||||
# face-recognition==1.3.0
|
||||
# face-recognition-models==0.3.0
|
||||
# dlib>=20.0.0
|
||||
|
||||
# ADD these:
|
||||
deepface>=0.0.79
|
||||
tensorflow>=2.13.0 # Required by DeepFace
|
||||
opencv-python>=4.8.0 # Required by DeepFace
|
||||
retina-face>=0.0.13 # For RetinaFace detector (best accuracy)
|
||||
|
||||
# KEEP these:
|
||||
numpy>=1.21.0
|
||||
pillow>=8.0.0
|
||||
click>=8.0.0
|
||||
setuptools>=40.0.0
|
||||
```
|
||||
|
||||
### Step 5.2: Create Migration Script
|
||||
**File:** `scripts/migrate_to_deepface.py` (new file)
|
||||
|
||||
**Purpose:** Drop all tables and reinitialize database for fresh start
|
||||
|
||||
**Actions:**
|
||||
```python
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Migration script to prepare database for DeepFace
|
||||
Drops all existing tables and recreates with new schema
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add parent directory to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from src.core.database import DatabaseManager
|
||||
from src.core.config import DEFAULT_DB_PATH
|
||||
|
||||
def migrate_database():
|
||||
"""Drop all tables and reinitialize with DeepFace schema"""
|
||||
print("⚠️ WARNING: This will delete all existing data!")
|
||||
response = input("Type 'DELETE ALL DATA' to confirm: ")
|
||||
|
||||
if response != "DELETE ALL DATA":
|
||||
print("Migration cancelled.")
|
||||
return
|
||||
|
||||
print("\n🗑️ Dropping all existing tables...")
|
||||
|
||||
# Connect directly to database
|
||||
conn = sqlite3.connect(DEFAULT_DB_PATH)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Drop all tables
|
||||
tables = ['phototaglinkage', 'person_encodings', 'faces', 'tags', 'people', 'photos']
|
||||
for table in tables:
|
||||
cursor.execute(f'DROP TABLE IF EXISTS {table}')
|
||||
print(f" Dropped table: {table}")
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
print("\n✅ All tables dropped successfully")
|
||||
print("\n🔄 Reinitializing database with DeepFace schema...")
|
||||
|
||||
# Reinitialize with new schema
|
||||
db = DatabaseManager(DEFAULT_DB_PATH, verbose=1)
|
||||
|
||||
print("\n✅ Database migration complete!")
|
||||
print("\nNext steps:")
|
||||
print("1. Add photos using the dashboard")
|
||||
print("2. Process faces with DeepFace")
|
||||
print("3. Identify people")
|
||||
|
||||
if __name__ == "__main__":
|
||||
migrate_database()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PHASE 6: Testing and Validation
|
||||
|
||||
### Step 6.1: Create Test Suite
|
||||
**File:** `tests/test_deepface_integration.py` (new file)
|
||||
|
||||
**Actions:**
|
||||
```python
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test DeepFace integration in PunimTag
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Suppress TensorFlow warnings
|
||||
os.environ['TF_CPP_MIN_LOG_LEVEL'] = '3'
|
||||
import warnings
|
||||
warnings.filterwarnings('ignore')
|
||||
|
||||
from src.core.database import DatabaseManager
|
||||
from src.core.face_processing import FaceProcessor
|
||||
from src.core.config import DEEPFACE_DETECTOR_BACKEND, DEEPFACE_MODEL_NAME
|
||||
|
||||
def test_face_detection():
|
||||
"""Test face detection with DeepFace"""
|
||||
print("🧪 Testing DeepFace face detection...")
|
||||
|
||||
db = DatabaseManager(":memory:", verbose=0) # In-memory database for testing
|
||||
processor = FaceProcessor(db, verbose=1)
|
||||
|
||||
# Test with a sample image
|
||||
test_image = "demo_photos/2019-11-22_0011.jpg"
|
||||
if not os.path.exists(test_image):
|
||||
print(f"❌ Test image not found: {test_image}")
|
||||
return False
|
||||
|
||||
# Add photo to database
|
||||
photo_id = db.add_photo(test_image, Path(test_image).name, None)
|
||||
|
||||
# Process faces
|
||||
count = processor.process_faces(limit=1)
|
||||
|
||||
# Verify results
|
||||
stats = db.get_statistics()
|
||||
print(f"✅ Processed {count} photos, found {stats['total_faces']} faces")
|
||||
|
||||
return stats['total_faces'] > 0
|
||||
|
||||
def test_face_matching():
|
||||
"""Test face matching with DeepFace"""
|
||||
print("\n🧪 Testing DeepFace face matching...")
|
||||
|
||||
db = DatabaseManager(":memory:", verbose=0)
|
||||
processor = FaceProcessor(db, verbose=1)
|
||||
|
||||
# Test with multiple images
|
||||
test_images = [
|
||||
"demo_photos/2019-11-22_0011.jpg",
|
||||
"demo_photos/2019-11-22_0012.jpg"
|
||||
]
|
||||
|
||||
for img in test_images:
|
||||
if os.path.exists(img):
|
||||
photo_id = db.add_photo(img, Path(img).name, None)
|
||||
|
||||
# Process all faces
|
||||
processor.process_faces(limit=10)
|
||||
|
||||
# Find similar faces
|
||||
faces = db.get_all_face_encodings()
|
||||
if len(faces) >= 2:
|
||||
matches = processor.find_similar_faces(faces[0][0])
|
||||
print(f"✅ Found {len(matches)} similar faces")
|
||||
return len(matches) >= 0
|
||||
|
||||
return False
|
||||
|
||||
def run_all_tests():
|
||||
"""Run all tests"""
|
||||
print("=" * 60)
|
||||
print("DeepFace Integration Test Suite")
|
||||
print("=" * 60)
|
||||
|
||||
tests = [
|
||||
test_face_detection,
|
||||
test_face_matching
|
||||
]
|
||||
|
||||
results = []
|
||||
for test in tests:
|
||||
try:
|
||||
result = test()
|
||||
results.append(result)
|
||||
except Exception as e:
|
||||
print(f"❌ Test failed with error: {e}")
|
||||
results.append(False)
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print(f"Tests passed: {sum(results)}/{len(results)}")
|
||||
print("=" * 60)
|
||||
|
||||
return all(results)
|
||||
|
||||
if __name__ == "__main__":
|
||||
success = run_all_tests()
|
||||
sys.exit(0 if success else 1)
|
||||
```
|
||||
|
||||
### Step 6.2: Validation Checklist
|
||||
1. **Face Detection:**
|
||||
- [ ] DeepFace successfully detects faces in test images
|
||||
- [ ] Face locations are correctly stored in new format
|
||||
- [ ] Face encodings are 512-dimensional (ArcFace)
|
||||
- [ ] Multiple detector backends work (retinaface, mtcnn, etc.)
|
||||
|
||||
2. **Face Matching:**
|
||||
- [ ] Similar faces are correctly identified
|
||||
- [ ] Cosine similarity produces reasonable confidence scores
|
||||
- [ ] Adaptive tolerance works with new metric
|
||||
- [ ] No false positives at default threshold
|
||||
|
||||
3. **GUI Integration:**
|
||||
- [ ] All panels display faces correctly
|
||||
- [ ] Face thumbnails extract properly with new location format
|
||||
- [ ] Confidence scores display correctly
|
||||
- [ ] Detector/model selection works in settings
|
||||
|
||||
4. **Database:**
|
||||
- [ ] New columns are created correctly
|
||||
- [ ] Encodings are stored as 4096-byte BLOBs
|
||||
- [ ] Queries work with new schema
|
||||
- [ ] Indices improve performance
|
||||
|
||||
---
|
||||
|
||||
## PHASE 7: Implementation Order
|
||||
|
||||
**Execute in this order to minimize issues:**
|
||||
|
||||
1. **Day 1: Database & Configuration**
|
||||
- Update `requirements.txt`
|
||||
- Install DeepFace: `pip install deepface tensorflow opencv-python retina-face`
|
||||
- Update `src/core/config.py` with DeepFace settings
|
||||
- Update `src/core/database.py` schema and methods
|
||||
- Create and run `scripts/migrate_to_deepface.py`
|
||||
|
||||
2. **Day 2: Core Face Processing**
|
||||
- Update `src/core/face_processing.py` `process_faces()` method
|
||||
- Update `_extract_face_crop()` to handle new location format
|
||||
- Implement `_calculate_cosine_similarity()` method
|
||||
- Update `find_similar_faces()` to use new similarity
|
||||
- Update `_calculate_adaptive_tolerance()` for DeepFace ranges
|
||||
|
||||
3. **Day 3: GUI Updates**
|
||||
- Add detector/model selection to dashboard
|
||||
- Update `FaceProcessor.__init__()` to accept settings
|
||||
- Test face processing with GUI
|
||||
|
||||
4. **Day 4: Panel Updates**
|
||||
- Update `src/gui/identify_panel.py` for new format
|
||||
- Update `src/gui/auto_match_panel.py` for new format
|
||||
- Update `src/gui/modify_panel.py` for new format
|
||||
- Verify all face displays work correctly
|
||||
|
||||
5. **Day 5: Testing & Refinement**
|
||||
- Create `tests/test_deepface_integration.py`
|
||||
- Run all tests and fix issues
|
||||
- Process test photos from `demo_photos/testdeepface/`
|
||||
- Validate matching accuracy
|
||||
- Adjust thresholds if needed
|
||||
|
||||
6. **Day 6: Documentation & Cleanup**
|
||||
- Update README.md with DeepFace information
|
||||
- Document detector backend options
|
||||
- Document model options and trade-offs
|
||||
- Remove old face_recognition references
|
||||
- Final testing
|
||||
|
||||
---
|
||||
|
||||
## PHASE 8: Key Differences and Gotchas
|
||||
|
||||
### Encoding Size Change
|
||||
- **face_recognition:** 128 floats = 1,024 bytes
|
||||
- **DeepFace ArcFace:** 512 floats = 4,096 bytes
|
||||
- **Impact:** Database size will be ~4x larger for encodings
|
||||
- **Action:** Ensure sufficient disk space
|
||||
|
||||
### Location Format Change
|
||||
- **face_recognition:** tuple `(top, right, bottom, left)`
|
||||
- **DeepFace:** dict `{'x': x, 'y': y, 'w': w, 'h': h}`
|
||||
- **Impact:** All location parsing code must be updated
|
||||
- **Action:** Create helper function to convert between formats
|
||||
|
||||
### Tolerance/Threshold Adjustments
|
||||
- **face_recognition:** Default 0.6 works well
|
||||
- **DeepFace:** Lower tolerance needed (0.4 recommended)
|
||||
- **Impact:** Matching sensitivity changes
|
||||
- **Action:** Test and adjust `DEFAULT_FACE_TOLERANCE` in config
|
||||
|
||||
### Performance Considerations
|
||||
- **DeepFace:** Slower than face_recognition (uses deep learning)
|
||||
- **Mitigation:** Use GPU if available, cache results, process in batches
|
||||
- **Action:** Add progress indicators, allow cancellation
|
||||
|
||||
### Dependencies
|
||||
- **DeepFace requires:** TensorFlow, OpenCV, specific detectors
|
||||
- **Size:** ~500MB+ of additional packages and models
|
||||
- **Action:** Warn users about download size during installation
|
||||
|
||||
---
|
||||
|
||||
## PHASE 9: Rollback Plan (If Needed)
|
||||
|
||||
Since we're starting fresh (no backward compatibility), rollback is simple:
|
||||
|
||||
1. **Restore database:**
|
||||
```bash
|
||||
rm data/photos.db
|
||||
cp data/photos.db.backup data/photos.db # If backup exists
|
||||
```
|
||||
|
||||
2. **Restore code:**
|
||||
```bash
|
||||
git checkout HEAD -- src/core/face_processing.py src/core/database.py src/core/config.py requirements.txt
|
||||
```
|
||||
|
||||
3. **Reinstall dependencies:**
|
||||
```bash
|
||||
pip uninstall deepface tensorflow
|
||||
pip install face-recognition
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PHASE 10: Success Criteria
|
||||
|
||||
Migration is complete when:
|
||||
|
||||
- [ ] All face_recognition imports removed
|
||||
- [ ] DeepFace successfully detects faces in test images
|
||||
- [ ] Face matching produces accurate results
|
||||
- [ ] All GUI panels work with new format
|
||||
- [ ] Database stores DeepFace encodings correctly
|
||||
- [ ] Test suite passes all tests
|
||||
- [ ] Documentation updated
|
||||
- [ ] No regression in core functionality
|
||||
- [ ] Performance is acceptable (may be slower but accurate)
|
||||
|
||||
---
|
||||
|
||||
## Additional Notes for Agent
|
||||
|
||||
1. **Import Order Matters:**
|
||||
```python
|
||||
# ALWAYS import in this order:
|
||||
import os
|
||||
import warnings
|
||||
os.environ['TF_CPP_MIN_LOG_LEVEL'] = '3'
|
||||
warnings.filterwarnings('ignore')
|
||||
# THEN import DeepFace
|
||||
from deepface import DeepFace
|
||||
```
|
||||
|
||||
2. **Error Handling:**
|
||||
- DeepFace can throw various TensorFlow errors
|
||||
- Always wrap DeepFace calls in try-except
|
||||
- Use `enforce_detection=False` to avoid crashes on no-face images
|
||||
|
||||
3. **Model Downloads:**
|
||||
- First run will download models (~100MB+)
|
||||
- Store in `~/.deepface/weights/`
|
||||
- Plan for initial download time
|
||||
|
||||
4. **Testing Strategy:**
|
||||
- Use `demo_photos/testdeepface/` for initial testing
|
||||
- These were already tested in `test_deepface_gui.py`
|
||||
- Known good results for comparison
|
||||
|
||||
5. **Code Quality:**
|
||||
- Maintain existing code style
|
||||
- Keep verbose levels consistent
|
||||
- Preserve all existing functionality
|
||||
- Add comments explaining DeepFace-specific code
|
||||
|
||||
---
|
||||
|
||||
## Files to Modify (Summary)
|
||||
|
||||
1. **`requirements.txt`** - Update dependencies
|
||||
2. **`src/core/config.py`** - Add DeepFace configuration
|
||||
3. **`src/core/database.py`** - Update schema and methods
|
||||
4. **`src/core/face_processing.py`** - Replace all face_recognition code
|
||||
5. **`src/gui/dashboard_gui.py`** - Add detector/model selection
|
||||
6. **`src/gui/identify_panel.py`** - Update for new formats
|
||||
7. **`src/gui/auto_match_panel.py`** - Update for new formats
|
||||
8. **`src/gui/modify_panel.py`** - Update for new formats
|
||||
9. **`scripts/migrate_to_deepface.py`** - New migration script
|
||||
10. **`tests/test_deepface_integration.py`** - New test suite
|
||||
|
||||
---
|
||||
|
||||
**END OF MIGRATION PLAN**
|
||||
|
||||
This plan provides a complete, step-by-step guide for migrating from face_recognition to DeepFace. Execute phases in order, test thoroughly, and refer to `tests/test_deepface_gui.py` for working DeepFace implementation examples.
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
# Directory Structure
|
||||
|
||||
## Overview
|
||||
```
|
||||
punimtag/
|
||||
├── .notes/ # Project notes and planning
|
||||
│ ├── project_overview.md # High-level project info
|
||||
│ ├── task_list.md # Task tracking
|
||||
│ ├── directory_structure.md # This file
|
||||
│ └── meeting_notes.md # Meeting records
|
||||
│
|
||||
├── src/ # Source code
|
||||
│ ├── __init__.py
|
||||
│ ├── photo_tagger.py # CLI entry point
|
||||
│ ├── setup.py # Package setup
|
||||
│ │
|
||||
│ ├── core/ # Business logic
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── config.py # Configuration
|
||||
│ │ ├── database.py # Database manager
|
||||
│ │ ├── face_processing.py # Face recognition
|
||||
│ │ ├── photo_management.py # Photo operations
|
||||
│ │ ├── tag_management.py # Tag operations
|
||||
│ │ └── search_stats.py # Search & analytics
|
||||
│ │
|
||||
│ ├── gui/ # GUI components
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── dashboard_gui.py # Main dashboard
|
||||
│ │ ├── gui_core.py # Common utilities
|
||||
│ │ ├── identify_panel.py # Identification UI
|
||||
│ │ ├── auto_match_panel.py # Auto-matching UI
|
||||
│ │ ├── modify_panel.py # Person editing UI
|
||||
│ │ └── tag_manager_panel.py # Tag management UI
|
||||
│ │
|
||||
│ └── utils/ # Utility functions
|
||||
│ ├── __init__.py
|
||||
│ └── path_utils.py # Path operations
|
||||
│
|
||||
├── tests/ # Test suite
|
||||
│ ├── __init__.py
|
||||
│ ├── test_deepface_gui.py # DeepFace testing
|
||||
│ ├── test_face_recognition.py # Face rec tests
|
||||
│ ├── test_simple_gui.py # GUI tests
|
||||
│ ├── test_thumbnail_sizes.py # UI tests
|
||||
│ ├── debug_face_detection.py # Debug tools
|
||||
│ └── show_large_thumbnails.py # Debug tools
|
||||
│
|
||||
├── docs/ # Documentation
|
||||
│ ├── README.md # Main documentation
|
||||
│ ├── ARCHITECTURE.md # System architecture
|
||||
│ ├── DEMO.md # Demo guide
|
||||
│ └── README_UNIFIED_DASHBOARD.md
|
||||
│
|
||||
├── data/ # Application data
|
||||
│ └── photos.db # SQLite database
|
||||
│
|
||||
├── demo_photos/ # Sample photos for testing
|
||||
│ ├── events/
|
||||
│ ├── more_photos/
|
||||
│ └── testdeepface/
|
||||
│
|
||||
├── scripts/ # Utility scripts
|
||||
│ └── drop_all_tables.py # Database utilities
|
||||
│
|
||||
├── archive/ # Legacy/backup files
|
||||
│ ├── *_backup.py # Old versions
|
||||
│ └── *_gui.py # Legacy GUIs
|
||||
│
|
||||
├── logs/ # Application logs
|
||||
│
|
||||
├── venv/ # Virtual environment
|
||||
│
|
||||
├── .git/ # Git repository
|
||||
├── .gitignore # Git ignore rules
|
||||
├── .cursorrules # Cursor AI rules
|
||||
├── .cursorignore # Cursor ignore rules
|
||||
├── requirements.txt # Python dependencies
|
||||
├── gui_config.json # GUI preferences
|
||||
├── demo.sh # Demo script
|
||||
└── run_deepface_gui.sh # Run script
|
||||
```
|
||||
|
||||
## Import Path Examples
|
||||
|
||||
### From core modules:
|
||||
```python
|
||||
from src.core.database import DatabaseManager
|
||||
from src.core.face_processing import FaceProcessor
|
||||
from src.core.config import DEFAULT_DB_PATH
|
||||
```
|
||||
|
||||
### From GUI modules:
|
||||
```python
|
||||
from src.gui.dashboard_gui import DashboardGUI
|
||||
from src.gui.gui_core import GUICore
|
||||
```
|
||||
|
||||
### From utils:
|
||||
```python
|
||||
from src.utils.path_utils import normalize_path
|
||||
```
|
||||
|
||||
## Entry Points
|
||||
|
||||
### GUI Application
|
||||
```bash
|
||||
python src/gui/dashboard_gui.py
|
||||
```
|
||||
|
||||
### CLI Application
|
||||
```bash
|
||||
python src/photo_tagger.py
|
||||
```
|
||||
|
||||
### Tests
|
||||
```bash
|
||||
python -m pytest tests/
|
||||
|
||||
```
|
||||
|
||||
## Notes
|
||||
- All source code in `src/` directory
|
||||
- Tests separate from source code
|
||||
- Documentation in `docs/`
|
||||
- Project notes in `.notes/`
|
||||
- Legacy code archived in `archive/`
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
# Meeting Notes
|
||||
|
||||
## 2025-10-15: Project Restructuring
|
||||
|
||||
### Attendees
|
||||
- Development Team
|
||||
|
||||
### Discussion
|
||||
- Agreed to restructure project for better organization
|
||||
- Adopted standard Python project layout
|
||||
- Separated concerns: core, gui, utils, tests
|
||||
- Created .notes directory for project management
|
||||
|
||||
### Decisions
|
||||
1. Move all business logic to `src/core/`
|
||||
2. Move all GUI components to `src/gui/`
|
||||
3. Move utilities to `src/utils/`
|
||||
4. Consolidate tests in `tests/`
|
||||
5. Move documentation to `docs/`
|
||||
6. Archive legacy code instead of deleting
|
||||
|
||||
### Action Items
|
||||
- [x] Create new directory structure
|
||||
- [x] Move files to appropriate locations
|
||||
- [x] Create __init__.py files for packages
|
||||
- [x] Create project notes
|
||||
- [ ] Update import statements
|
||||
- [ ] Test all functionality
|
||||
- [ ] Update documentation
|
||||
|
||||
---
|
||||
|
||||
## 2025-10-15: DeepFace Migration Planning
|
||||
|
||||
### Attendees
|
||||
- Development Team
|
||||
|
||||
### Discussion
|
||||
- Analyzed test_deepface_gui.py results
|
||||
- DeepFace shows better accuracy than face_recognition
|
||||
- ArcFace model recommended for best results
|
||||
- RetinaFace detector provides best face detection
|
||||
|
||||
### Decisions
|
||||
1. Migrate from face_recognition to DeepFace
|
||||
2. Use ArcFace model (512-dim encodings)
|
||||
3. Use RetinaFace detector as default
|
||||
4. Support multiple detector backends
|
||||
5. No backward compatibility - fresh start
|
||||
|
||||
### Action Items
|
||||
- [x] Document migration plan
|
||||
- [x] Create architecture document
|
||||
- [ ] Update database schema
|
||||
- [ ] Implement DeepFace integration
|
||||
- [ ] Create migration script
|
||||
- [ ] Test with demo photos
|
||||
|
||||
### Technical Notes
|
||||
- Encoding size: 128 → 512 dimensions
|
||||
- Similarity metric: Euclidean → Cosine
|
||||
- Location format: tuple → dict
|
||||
- Tolerance adjustment: 0.6 → 0.4
|
||||
|
||||
---
|
||||
|
||||
## Future Topics
|
||||
- Web interface design
|
||||
- Cloud storage integration
|
||||
- Performance optimization
|
||||
- Multi-user support
|
||||
- Mobile app development
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
# Phase 1 Quick Start Guide
|
||||
|
||||
## What Was Done
|
||||
|
||||
Phase 1: Database Schema Updates ✅ COMPLETE
|
||||
|
||||
All database tables and methods updated to support DeepFace:
|
||||
- New columns for detector backend and model name
|
||||
- Support for 512-dimensional encodings (ArcFace)
|
||||
- Enhanced face confidence tracking
|
||||
- Migration script ready to use
|
||||
|
||||
## Quick Commands
|
||||
|
||||
### Run Tests
|
||||
```bash
|
||||
cd /home/ladmin/Code/punimtag
|
||||
source venv/bin/activate
|
||||
python3 tests/test_phase1_schema.py
|
||||
```
|
||||
|
||||
### Migrate Existing Database (⚠️ DELETES ALL DATA)
|
||||
```bash
|
||||
cd /home/ladmin/Code/punimtag
|
||||
source venv/bin/activate
|
||||
python3 scripts/migrate_to_deepface.py
|
||||
```
|
||||
|
||||
### Install New Dependencies (for Phase 2+)
|
||||
```bash
|
||||
cd /home/ladmin/Code/punimtag
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
## Files Modified
|
||||
|
||||
1. **requirements.txt** - DeepFace dependencies
|
||||
2. **src/core/config.py** - DeepFace configuration
|
||||
3. **src/core/database.py** - Schema + method updates
|
||||
|
||||
## Files Created
|
||||
|
||||
1. **scripts/migrate_to_deepface.py** - Migration script
|
||||
2. **tests/test_phase1_schema.py** - Test suite
|
||||
3. **PHASE1_COMPLETE.md** - Full documentation
|
||||
|
||||
## Next Steps
|
||||
|
||||
Ready to proceed to **Phase 2** or **Phase 3**:
|
||||
|
||||
### Phase 2: Configuration Updates
|
||||
- Add TensorFlow suppression to entry points
|
||||
- Update GUI with detector/model selection
|
||||
|
||||
### Phase 3: Core Face Processing
|
||||
- Replace face_recognition with DeepFace
|
||||
- Update process_faces() method
|
||||
- Implement cosine similarity
|
||||
|
||||
## Quick Verification
|
||||
|
||||
```bash
|
||||
# Check schema has new columns
|
||||
cd /home/ladmin/Code/punimtag
|
||||
source venv/bin/activate
|
||||
python3 -c "
|
||||
from src.core.database import DatabaseManager
|
||||
import tempfile
|
||||
with tempfile.NamedTemporaryFile(suffix='.db') as tmp:
|
||||
db = DatabaseManager(tmp.name, verbose=0)
|
||||
print('✅ Database initialized with DeepFace schema')
|
||||
"
|
||||
```
|
||||
|
||||
## Test Results
|
||||
|
||||
```
|
||||
Tests passed: 4/4
|
||||
✅ PASS: Schema Columns
|
||||
✅ PASS: add_face() Method
|
||||
✅ PASS: add_person_encoding() Method
|
||||
✅ PASS: Config Constants
|
||||
```
|
||||
|
||||
All systems ready for DeepFace implementation!
|
||||
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
# Phase 2 Quick Start Guide
|
||||
|
||||
## What Was Done
|
||||
|
||||
Phase 2: Configuration Updates ✅ COMPLETE
|
||||
|
||||
Added GUI controls for DeepFace settings and updated FaceProcessor to accept them.
|
||||
|
||||
## Quick Commands
|
||||
|
||||
### Run Tests
|
||||
```bash
|
||||
cd /home/ladmin/Code/punimtag
|
||||
source venv/bin/activate
|
||||
python3 tests/test_phase2_config.py
|
||||
```
|
||||
|
||||
Expected: **5/5 tests passing**
|
||||
|
||||
### Test GUI (Visual Check)
|
||||
```bash
|
||||
cd /home/ladmin/Code/punimtag
|
||||
source venv/bin/activate
|
||||
python3 run_dashboard.py
|
||||
```
|
||||
|
||||
Then:
|
||||
1. Click "🔍 Process" button
|
||||
2. Look for "DeepFace Settings" section
|
||||
3. Verify two dropdowns:
|
||||
- Face Detector: [retinaface ▼]
|
||||
- Recognition Model: [ArcFace ▼]
|
||||
|
||||
## Files Modified
|
||||
|
||||
1. **run_dashboard.py** - Callback passes detector/model
|
||||
2. **src/gui/dashboard_gui.py** - GUI controls added
|
||||
3. **src/photo_tagger.py** - TF suppression
|
||||
4. **src/core/face_processing.py** - Accepts detector/model params
|
||||
|
||||
## Files Created
|
||||
|
||||
1. **tests/test_phase2_config.py** - 5 tests
|
||||
2. **PHASE2_COMPLETE.md** - Full documentation
|
||||
|
||||
## What Changed
|
||||
|
||||
### Before Phase 2:
|
||||
```python
|
||||
processor = FaceProcessor(db_manager)
|
||||
```
|
||||
|
||||
### After Phase 2:
|
||||
```python
|
||||
processor = FaceProcessor(db_manager,
|
||||
detector_backend='retinaface',
|
||||
model_name='ArcFace')
|
||||
```
|
||||
|
||||
### GUI Process Panel:
|
||||
Now includes DeepFace Settings section with:
|
||||
- Detector selection dropdown (4 options)
|
||||
- Model selection dropdown (4 options)
|
||||
- Help text for each option
|
||||
|
||||
## Test Results
|
||||
|
||||
```
|
||||
✅ PASS: TensorFlow Suppression
|
||||
✅ PASS: FaceProcessor Initialization
|
||||
✅ PASS: Config Imports
|
||||
✅ PASS: Entry Point Imports
|
||||
✅ PASS: GUI Config Constants
|
||||
|
||||
Tests passed: 5/5
|
||||
```
|
||||
|
||||
## Available Options
|
||||
|
||||
### Detectors:
|
||||
- retinaface (default, best accuracy)
|
||||
- mtcnn
|
||||
- opencv
|
||||
- ssd
|
||||
|
||||
### Models:
|
||||
- ArcFace (default, 512-dim, best accuracy)
|
||||
- Facenet (128-dim)
|
||||
- Facenet512 (512-dim)
|
||||
- VGG-Face (2622-dim)
|
||||
|
||||
## Important Note
|
||||
|
||||
⚠️ **Phase 2 adds UI/config only**
|
||||
|
||||
The GUI captures settings, but actual DeepFace processing happens in **Phase 3**. Currently still using face_recognition for processing.
|
||||
|
||||
## Next Steps
|
||||
|
||||
Ready for **Phase 3: Core Face Processing**
|
||||
- Replace face_recognition with DeepFace
|
||||
- Implement actual detector/model usage
|
||||
- Update face location handling
|
||||
- Implement cosine similarity
|
||||
|
||||
## Quick Verification
|
||||
|
||||
```bash
|
||||
# Test FaceProcessor accepts params
|
||||
cd /home/ladmin/Code/punimtag
|
||||
source venv/bin/activate
|
||||
python3 -c "
|
||||
from src.core.database import DatabaseManager
|
||||
from src.core.face_processing import FaceProcessor
|
||||
db = DatabaseManager(':memory:', verbose=0)
|
||||
p = FaceProcessor(db, verbose=0, detector_backend='mtcnn', model_name='Facenet')
|
||||
print(f'Detector: {p.detector_backend}')
|
||||
print(f'Model: {p.model_name}')
|
||||
print('✅ Phase 2 working!')
|
||||
"
|
||||
```
|
||||
|
||||
Expected output:
|
||||
```
|
||||
Detector: mtcnn
|
||||
Model: Facenet
|
||||
✅ Phase 2 working!
|
||||
```
|
||||
|
||||
All systems ready for Phase 3!
|
||||
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
# PunimTag - Project Overview
|
||||
|
||||
## Mission Statement
|
||||
PunimTag is a desktop photo management application that leverages facial recognition AI to help users organize, tag, and search their photo collections efficiently.
|
||||
|
||||
## Core Capabilities
|
||||
- Automated face detection and recognition
|
||||
- Person identification and management
|
||||
- Custom tagging system
|
||||
- Advanced search functionality
|
||||
- Batch processing
|
||||
|
||||
## Current Status
|
||||
- **Version**: 1.0 (Development)
|
||||
- **Stage**: Active Development
|
||||
- **Next Major Feature**: DeepFace Migration
|
||||
|
||||
## Key Technologies
|
||||
- Python 3.12+
|
||||
- Tkinter (GUI)
|
||||
- SQLite (Database)
|
||||
- face_recognition (Current - to be replaced)
|
||||
- DeepFace (Planned migration)
|
||||
|
||||
## Project Goals
|
||||
1. Make photo organization effortless
|
||||
2. Provide accurate face recognition
|
||||
3. Enable powerful search capabilities
|
||||
4. Maintain user privacy (local-only by default)
|
||||
5. Scale to large photo collections (50K+ photos)
|
||||
|
||||
## Success Metrics
|
||||
- Face recognition accuracy > 95%
|
||||
- Process 1000+ photos per hour
|
||||
- Search response time < 1 second
|
||||
- Zero data loss
|
||||
- User-friendly interface
|
||||
|
||||
## Links
|
||||
- Architecture: `docs/ARCHITECTURE.md`
|
||||
- Main README: `docs/README.md`
|
||||
- Demo Guide: `docs/DEMO.md`
|
||||
|
||||
@@ -1,342 +0,0 @@
|
||||
# Project Restructure Migration Guide
|
||||
|
||||
## Overview
|
||||
The project has been restructured to follow Python best practices with a clean separation of concerns.
|
||||
|
||||
---
|
||||
|
||||
## Directory Changes
|
||||
|
||||
### Before → After
|
||||
|
||||
```
|
||||
Root Directory Files → Organized Structure
|
||||
```
|
||||
|
||||
| Old Location | New Location | Type |
|
||||
|-------------|--------------|------|
|
||||
| `config.py` | `src/core/config.py` | Core |
|
||||
| `database.py` | `src/core/database.py` | Core |
|
||||
| `face_processing.py` | `src/core/face_processing.py` | Core |
|
||||
| `photo_management.py` | `src/core/photo_management.py` | Core |
|
||||
| `tag_management.py` | `src/core/tag_management.py` | Core |
|
||||
| `search_stats.py` | `src/core/search_stats.py` | Core |
|
||||
| `dashboard_gui.py` | `src/gui/dashboard_gui.py` | GUI |
|
||||
| `gui_core.py` | `src/gui/gui_core.py` | GUI |
|
||||
| `identify_panel.py` | `src/gui/identify_panel.py` | GUI |
|
||||
| `auto_match_panel.py` | `src/gui/auto_match_panel.py` | GUI |
|
||||
| `modify_panel.py` | `src/gui/modify_panel.py` | GUI |
|
||||
| `tag_manager_panel.py` | `src/gui/tag_manager_panel.py` | GUI |
|
||||
| `path_utils.py` | `src/utils/path_utils.py` | Utils |
|
||||
| `photo_tagger.py` | `src/photo_tagger.py` | Entry |
|
||||
| `test_*.py` | `tests/test_*.py` | Tests |
|
||||
| `README.md` | `docs/README.md` | Docs |
|
||||
| `ARCHITECTURE.md` | `docs/ARCHITECTURE.md` | Docs |
|
||||
|
||||
---
|
||||
|
||||
## Import Path Changes
|
||||
|
||||
### Core Modules
|
||||
|
||||
**Before:**
|
||||
```python
|
||||
from config import DEFAULT_DB_PATH
|
||||
from database import DatabaseManager
|
||||
from face_processing import FaceProcessor
|
||||
from photo_management import PhotoManager
|
||||
from tag_management import TagManager
|
||||
from search_stats import SearchStats
|
||||
```
|
||||
|
||||
**After:**
|
||||
```python
|
||||
from src.core.config import DEFAULT_DB_PATH
|
||||
from src.core.database import DatabaseManager
|
||||
from src.core.face_processing import FaceProcessor
|
||||
from src.core.photo_management import PhotoManager
|
||||
from src.core.tag_management import TagManager
|
||||
from src.core.search_stats import SearchStats
|
||||
```
|
||||
|
||||
### GUI Modules
|
||||
|
||||
**Before:**
|
||||
```python
|
||||
from gui_core import GUICore
|
||||
from identify_panel import IdentifyPanel
|
||||
from auto_match_panel import AutoMatchPanel
|
||||
from modify_panel import ModifyPanel
|
||||
from tag_manager_panel import TagManagerPanel
|
||||
```
|
||||
|
||||
**After:**
|
||||
```python
|
||||
from src.gui.gui_core import GUICore
|
||||
from src.gui.identify_panel import IdentifyPanel
|
||||
from src.gui.auto_match_panel import AutoMatchPanel
|
||||
from src.gui.modify_panel import ModifyPanel
|
||||
from src.gui.tag_manager_panel import TagManagerPanel
|
||||
```
|
||||
|
||||
### Utility Modules
|
||||
|
||||
**Before:**
|
||||
```python
|
||||
from path_utils import normalize_path, validate_path_exists
|
||||
```
|
||||
|
||||
**After:**
|
||||
```python
|
||||
from src.utils.path_utils import normalize_path, validate_path_exists
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Files Requiring Import Updates
|
||||
|
||||
### Priority 1 - Core Files
|
||||
- [ ] `src/core/face_processing.py`
|
||||
- [ ] `src/core/photo_management.py`
|
||||
- [ ] `src/core/tag_management.py`
|
||||
- [ ] `src/core/search_stats.py`
|
||||
- [ ] `src/core/database.py`
|
||||
|
||||
### Priority 2 - GUI Files
|
||||
- [ ] `src/gui/dashboard_gui.py`
|
||||
- [ ] `src/gui/identify_panel.py`
|
||||
- [ ] `src/gui/auto_match_panel.py`
|
||||
- [ ] `src/gui/modify_panel.py`
|
||||
- [ ] `src/gui/tag_manager_panel.py`
|
||||
- [ ] `src/gui/gui_core.py`
|
||||
|
||||
### Priority 3 - Entry Points
|
||||
- [ ] `src/photo_tagger.py`
|
||||
- [ ] `src/setup.py`
|
||||
|
||||
### Priority 4 - Tests
|
||||
- [ ] `tests/test_deepface_gui.py`
|
||||
- [ ] `tests/test_face_recognition.py`
|
||||
- [ ] `tests/test_simple_gui.py`
|
||||
|
||||
---
|
||||
|
||||
## Search & Replace Patterns
|
||||
|
||||
Use these patterns to update imports systematically:
|
||||
|
||||
### Pattern 1: Core imports
|
||||
```bash
|
||||
# Find
|
||||
from config import
|
||||
from database import
|
||||
from face_processing import
|
||||
from photo_management import
|
||||
from tag_management import
|
||||
from search_stats import
|
||||
|
||||
# Replace with
|
||||
from src.core.config import
|
||||
from src.core.database import
|
||||
from src.core.face_processing import
|
||||
from src.core.photo_management import
|
||||
from src.core.tag_management import
|
||||
from src.core.search_stats import
|
||||
```
|
||||
|
||||
### Pattern 2: GUI imports
|
||||
```bash
|
||||
# Find
|
||||
from gui_core import
|
||||
from identify_panel import
|
||||
from auto_match_panel import
|
||||
from modify_panel import
|
||||
from tag_manager_panel import
|
||||
|
||||
# Replace with
|
||||
from src.gui.gui_core import
|
||||
from src.gui.identify_panel import
|
||||
from src.gui.auto_match_panel import
|
||||
from src.gui.modify_panel import
|
||||
from src.gui.tag_manager_panel import
|
||||
```
|
||||
|
||||
### Pattern 3: Utils imports
|
||||
```bash
|
||||
# Find
|
||||
from path_utils import
|
||||
|
||||
# Replace with
|
||||
from src.utils.path_utils import
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Running the Application After Restructure
|
||||
|
||||
### GUI Dashboard
|
||||
```bash
|
||||
# Old
|
||||
python dashboard_gui.py
|
||||
|
||||
# New
|
||||
python src/gui/dashboard_gui.py
|
||||
# OR
|
||||
python -m src.gui.dashboard_gui
|
||||
```
|
||||
|
||||
### CLI Tool
|
||||
```bash
|
||||
# Old
|
||||
python photo_tagger.py
|
||||
|
||||
# New
|
||||
python src/photo_tagger.py
|
||||
# OR
|
||||
python -m src.photo_tagger
|
||||
```
|
||||
|
||||
### Tests
|
||||
```bash
|
||||
# Old
|
||||
python test_deepface_gui.py
|
||||
|
||||
# New
|
||||
python tests/test_deepface_gui.py
|
||||
# OR
|
||||
python -m pytest tests/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verification Steps
|
||||
|
||||
### 1. Check Import Errors
|
||||
```bash
|
||||
cd /home/ladmin/Code/punimtag
|
||||
python -c "from src.core import DatabaseManager; print('Core imports OK')"
|
||||
python -c "from src.gui import GUICore; print('GUI imports OK')"
|
||||
python -c "from src.utils import normalize_path; print('Utils imports OK')"
|
||||
```
|
||||
|
||||
### 2. Test Each Module
|
||||
```bash
|
||||
# Test core modules
|
||||
python -c "from src.core.database import DatabaseManager; db = DatabaseManager(':memory:'); print('Database OK')"
|
||||
|
||||
# Test GUI modules (may need display)
|
||||
python -c "from src.gui.gui_core import GUICore; print('GUI Core OK')"
|
||||
```
|
||||
|
||||
### 3. Run Application
|
||||
```bash
|
||||
# Try to launch dashboard
|
||||
python src/gui/dashboard_gui.py
|
||||
```
|
||||
|
||||
### 4. Run Tests
|
||||
```bash
|
||||
# Run test suite
|
||||
python -m pytest tests/ -v
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Common Issues and Solutions
|
||||
|
||||
### Issue 1: ModuleNotFoundError
|
||||
```
|
||||
ModuleNotFoundError: No module named 'config'
|
||||
```
|
||||
|
||||
**Solution:** Update import from `from config import` to `from src.core.config import`
|
||||
|
||||
### Issue 2: Relative Import Error
|
||||
```
|
||||
ImportError: attempted relative import with no known parent package
|
||||
```
|
||||
|
||||
**Solution:** Use absolute imports with `src.` prefix
|
||||
|
||||
### Issue 3: Circular Import
|
||||
```
|
||||
ImportError: cannot import name 'X' from partially initialized module
|
||||
```
|
||||
|
||||
**Solution:** Check for circular dependencies, may need to refactor
|
||||
|
||||
### Issue 4: sys.path Issues
|
||||
If imports still fail, add to top of file:
|
||||
```python
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add project root to path
|
||||
project_root = Path(__file__).parent.parent # Adjust based on file location
|
||||
sys.path.insert(0, str(project_root))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Rollback Plan
|
||||
|
||||
If issues arise, you can rollback:
|
||||
|
||||
```bash
|
||||
# Revert git changes
|
||||
git checkout HEAD -- .
|
||||
|
||||
# Or manually move files back
|
||||
mv src/core/*.py .
|
||||
mv src/gui/*.py .
|
||||
mv src/utils/*.py .
|
||||
mv tests/*.py .
|
||||
mv docs/*.md .
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Benefits of New Structure
|
||||
|
||||
✅ **Better Organization**: Clear separation of concerns
|
||||
✅ **Easier Navigation**: Files grouped by function
|
||||
✅ **Professional**: Follows Python community standards
|
||||
✅ **Scalable**: Easy to add new modules
|
||||
✅ **Testable**: Tests separate from source
|
||||
✅ **Maintainable**: Clear dependencies
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. ✅ Directory structure created
|
||||
2. ✅ Files moved to new locations
|
||||
3. ✅ __init__.py files created
|
||||
4. ✅ Documentation updated
|
||||
5. ⏳ Update import statements (NEXT)
|
||||
6. ⏳ Test all functionality
|
||||
7. ⏳ Update scripts and launchers
|
||||
8. ⏳ Commit changes
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
After updating imports, verify:
|
||||
|
||||
- [ ] Dashboard GUI launches
|
||||
- [ ] Can scan for photos
|
||||
- [ ] Face processing works
|
||||
- [ ] Face identification works
|
||||
- [ ] Auto-matching works
|
||||
- [ ] Tag management works
|
||||
- [ ] Search functionality works
|
||||
- [ ] Database operations work
|
||||
- [ ] All tests pass
|
||||
|
||||
---
|
||||
|
||||
**Status**: Files moved, imports need updating
|
||||
**Last Updated**: 2025-10-15
|
||||
**Next Action**: Update import statements in all files
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
# Task List
|
||||
|
||||
> **SoT for current work:** open [Gitea issues](https://git.levkin.ca/ilia/punimtag/issues) and [docs/FIXES_2026-07-09.md](../docs/FIXES_2026-07-09.md).
|
||||
> This file is historical; do not treat unchecked DeepFace items as the active backlog.
|
||||
|
||||
## Active (2026-07)
|
||||
|
||||
Blocked on client only:
|
||||
|
||||
- **#44** JRCC watermark — need SVG/PNG + placement
|
||||
- **#43** Tag without account — need product rules
|
||||
|
||||
Ops (no ticket): production Next.js viewer on LXC 9101 (`next start`); keep DB host `.179`
|
||||
|
||||
## Historical / backlog ideas
|
||||
|
||||
### Performance
|
||||
- [ ] Pre-generate thumbnails at Process time
|
||||
- [ ] Lazy-load face metadata on home page SSR
|
||||
- [ ] Run viewer with `next start` (not `next dev`)
|
||||
|
||||
### Features
|
||||
- [ ] Guest “suggest name” (#43) after product rules
|
||||
- [ ] JRCC watermark swap (#44) when asset arrives
|
||||
|
||||
## Completed (selected)
|
||||
|
||||
- [x] 2026-07-09 open-issue triage (#45/#46/#47 + #21/#26/#30/#31/#33) — PRs #51/#52
|
||||
- [x] Shared Postgres retarget `.181` → `.179` (ansible #81)
|
||||
- [x] Kuma + Beszel for PunimTag (ansible #82)
|
||||
- [x] Docs Postgres `.179` + next-steps (punimtag #53)
|
||||
- [x] Grid thumbnail + disk cache
|
||||
@@ -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:
|
||||
|
||||
@@ -8,28 +8,28 @@ A fast, simple, and modern web application for organizing and tagging photos usi
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Features
|
||||
## Features
|
||||
|
||||
- **🌐 Web-Based**: Modern React frontend with FastAPI backend
|
||||
- **🔥 DeepFace AI**: State-of-the-art face detection with RetinaFace and ArcFace models
|
||||
- **🎯 Superior Accuracy**: 512-dimensional embeddings (4x more detailed than face_recognition)
|
||||
- **⚙️ Multiple Detectors**: Choose from RetinaFace, MTCNN, OpenCV, or SSD detectors
|
||||
- **🎨 Flexible Models**: Select ArcFace, Facenet, Facenet512, or VGG-Face recognition models
|
||||
- **👤 Person Identification**: Identify and tag people across your photo collection
|
||||
- **🤖 Smart Auto-Matching**: Intelligent face matching with quality scoring and cosine similarity
|
||||
- **📊 Confidence Calibration**: Empirical-based confidence scores for realistic match probabilities
|
||||
- **🔍 Advanced Search**: Search by people, dates, tags, and folders
|
||||
- **🏷️ Tag Management**: Organize photos with hierarchical tags
|
||||
- **⚡ Batch Processing**: Process thousands of photos efficiently
|
||||
- **🎯 Unique Faces Filter**: Hide duplicate faces to focus on unique individuals
|
||||
- **🔄 Real-time Updates**: Live progress tracking and job status updates
|
||||
- **🌐 Network Path Support**: Browse and scan folders on network shares (UNC paths on Windows, mounted shares on Linux)
|
||||
- **📁 Native Folder Picker**: Browse button uses native OS folder picker with full absolute path support
|
||||
- **🔒 Privacy-First**: All data stored locally, no cloud dependencies
|
||||
- **Web-Based**: Modern React frontend with FastAPI backend
|
||||
- **DeepFace AI**: State-of-the-art face detection with RetinaFace and ArcFace models
|
||||
- **Superior Accuracy**: 512-dimensional embeddings (4x more detailed than face_recognition)
|
||||
- **Multiple Detectors**: Choose from RetinaFace, MTCNN, OpenCV, or SSD detectors
|
||||
- **Flexible Models**: Select ArcFace, Facenet, Facenet512, or VGG-Face recognition models
|
||||
- **Person Identification**: Identify and tag people across your photo collection
|
||||
- **Smart Auto-Matching**: Intelligent face matching with quality scoring and cosine similarity
|
||||
- **Confidence Calibration**: Empirical-based confidence scores for realistic match probabilities
|
||||
- **Advanced Search**: Search by people, dates, tags, and folders
|
||||
- **Tag Management**: Organize photos with hierarchical tags
|
||||
- **Batch Processing**: Process thousands of photos efficiently
|
||||
- **Unique Faces Filter**: Hide duplicate faces to focus on unique individuals
|
||||
- **Real-time Updates**: Live progress tracking and job status updates
|
||||
- **Network Path Support**: Browse and scan folders on network shares (UNC paths on Windows, mounted shares on Linux)
|
||||
- **Native Folder Picker**: Browse button uses native OS folder picker with full absolute path support
|
||||
- **Privacy-First**: All data stored locally, no cloud dependencies
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Quick Start
|
||||
## Quick Start
|
||||
|
||||
### Prerequisites
|
||||
|
||||
@@ -57,14 +57,14 @@ cd punimtag
|
||||
```
|
||||
|
||||
The script will:
|
||||
- ✅ Check prerequisites (Python 3.12+, Node.js 18+)
|
||||
- ✅ Install system dependencies (PostgreSQL, Redis, Python tkinter) on Ubuntu/Debian
|
||||
- ✅ Set up PostgreSQL databases (main + auth)
|
||||
- ✅ Create Python virtual environment
|
||||
- ✅ Install all Python dependencies
|
||||
- ✅ Install all frontend dependencies (admin-frontend and viewer-frontend)
|
||||
- ✅ Create `.env` configuration files
|
||||
- ✅ Create necessary data directories
|
||||
- Check prerequisites (Python 3.12+, Node.js 18+)
|
||||
- Install system dependencies (PostgreSQL, Redis, Python tkinter) on Ubuntu/Debian
|
||||
- Set up PostgreSQL databases (main + auth)
|
||||
- Create Python virtual environment
|
||||
- Install all Python dependencies
|
||||
- Install all frontend dependencies (admin-frontend and viewer-frontend)
|
||||
- Create `.env` configuration files
|
||||
- Create necessary data directories
|
||||
|
||||
**Note:** After installation, you'll need to generate Prisma clients for the viewer-frontend:
|
||||
```bash
|
||||
@@ -120,23 +120,23 @@ Both database connections are configured via the `.env` file.
|
||||
For development, you can use the shared development PostgreSQL server:
|
||||
|
||||
**Dev PostgreSQL Server:**
|
||||
- **Host**: 10.0.10.179 (postgresQA / pve201 VM 109; legacy `.181` is dead)
|
||||
- **Host**: `<db-host>`
|
||||
- **Port**: 5432
|
||||
- **User**: ladmin
|
||||
- **Password**: [Contact administrator for password]
|
||||
|
||||
**Development Server:**
|
||||
- **Host**: 10.0.10.121
|
||||
- **Host**: `<backend-host>`
|
||||
- **User**: appuser
|
||||
- **Password**: [Contact administrator for password]
|
||||
|
||||
Configure your `.env` file for development:
|
||||
```bash
|
||||
# Main database (dev)
|
||||
DATABASE_URL=postgresql+psycopg2://ladmin:[PASSWORD]@10.0.10.179:5432/punimtag
|
||||
DATABASE_URL=postgresql+psycopg2://ladmin:[PASSWORD]@<db-host>:5432/punimtag
|
||||
|
||||
# Auth database (dev)
|
||||
DATABASE_URL_AUTH=postgresql+psycopg2://ladmin:[PASSWORD]@10.0.10.179:5432/punimtag_auth
|
||||
DATABASE_URL_AUTH=postgresql+psycopg2://ladmin:[PASSWORD]@<db-host>:5432/punimtag_auth
|
||||
```
|
||||
|
||||
**Install PostgreSQL (if not installed):**
|
||||
@@ -152,7 +152,7 @@ sudo systemctl enable postgresql
|
||||
|
||||
**Create Main Database and User:**
|
||||
```bash
|
||||
sudo -u postgres psql -c "CREATE USER punimtag WITH PASSWORD 'punimtag_password';"
|
||||
sudo -u postgres psql -c "CREATE USER punimtag WITH PASSWORD '<choose-a-password>';"
|
||||
sudo -u postgres psql -c "CREATE DATABASE punimtag OWNER punimtag;"
|
||||
sudo -u postgres psql -c "GRANT ALL PRIVILEGES ON DATABASE punimtag TO punimtag;"
|
||||
```
|
||||
@@ -192,19 +192,19 @@ The `.env` file in the project root contains database connection strings:
|
||||
**Local Development:**
|
||||
```bash
|
||||
# Main application database (PostgreSQL - required)
|
||||
DATABASE_URL=postgresql+psycopg2://punimtag:punimtag_password@localhost:5432/punimtag
|
||||
DATABASE_URL=postgresql+psycopg2://punimtag:<choose-a-password>@localhost:5432/punimtag
|
||||
|
||||
# Auth database (PostgreSQL - required for frontend website users)
|
||||
DATABASE_URL_AUTH=postgresql+psycopg2://punimtag:punimtag_password@localhost:5432/punimtag_auth
|
||||
DATABASE_URL_AUTH=postgresql+psycopg2://punimtag:<choose-a-password>@localhost:5432/punimtag_auth
|
||||
```
|
||||
|
||||
**Development Server:**
|
||||
```bash
|
||||
# Main database (dev PostgreSQL server)
|
||||
DATABASE_URL=postgresql+psycopg2://ladmin:[PASSWORD]@10.0.10.179:5432/punimtag
|
||||
DATABASE_URL=postgresql+psycopg2://ladmin:[PASSWORD]@<db-host>:5432/punimtag
|
||||
|
||||
# Auth database (dev PostgreSQL server)
|
||||
DATABASE_URL_AUTH=postgresql+psycopg2://ladmin:[PASSWORD]@10.0.10.179:5432/punimtag_auth
|
||||
DATABASE_URL_AUTH=postgresql+psycopg2://ladmin:[PASSWORD]@<db-host>:5432/punimtag_auth
|
||||
```
|
||||
|
||||
**Automatic Initialization:**
|
||||
@@ -355,9 +355,7 @@ npm run dev
|
||||
#### Access the Applications
|
||||
|
||||
1. **Admin Interface**: Open your browser to **http://localhost:3000**
|
||||
- Login with default credentials:
|
||||
- Username: `admin`
|
||||
- Password: `admin`
|
||||
- Log in with the credentials configured in your `.env` (`ADMIN_USERNAME` / `ADMIN_PASSWORD`)
|
||||
2. **Viewer Interface** (Optional): Open your browser to **http://localhost:3001**
|
||||
- Public photo viewing interface
|
||||
- Separate authentication system
|
||||
@@ -457,14 +455,14 @@ export DISPLAY=localhost:10.0
|
||||
|
||||
---
|
||||
|
||||
## 📖 Documentation
|
||||
## Documentation
|
||||
|
||||
- **[Architecture](docs/ARCHITECTURE.md)**: System design and technical details
|
||||
|
||||
|
||||
*
|
||||
|
||||
## 🏗️ Project Structure
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
punimtag/
|
||||
@@ -503,125 +501,125 @@ punimtag/
|
||||
|
||||
---
|
||||
|
||||
## 📊 Current Status
|
||||
## Current Status
|
||||
|
||||
### Foundations
|
||||
|
||||
**Backend:**
|
||||
- ✅ FastAPI application with CORS middleware
|
||||
- ✅ Health, version, and metrics endpoints
|
||||
- ✅ JWT authentication (login, refresh, user info)
|
||||
- ✅ Job management endpoints (RQ/Redis integration)
|
||||
- ✅ SQLAlchemy models for all entities
|
||||
- ✅ Alembic migrations configured and applied
|
||||
- ✅ Database initialized (PostgreSQL required)
|
||||
- ✅ RQ worker auto-start (starts automatically with API server)
|
||||
- ✅ Pending linkage moderation API for user tag suggestions
|
||||
- FastAPI application with CORS middleware
|
||||
- Health, version, and metrics endpoints
|
||||
- JWT authentication (login, refresh, user info)
|
||||
- Job management endpoints (RQ/Redis integration)
|
||||
- SQLAlchemy models for all entities
|
||||
- Alembic migrations configured and applied
|
||||
- Database initialized (PostgreSQL required)
|
||||
- RQ worker auto-start (starts automatically with API server)
|
||||
- Pending linkage moderation API for user tag suggestions
|
||||
|
||||
**Frontend:**
|
||||
- ✅ React + Vite + TypeScript setup
|
||||
- ✅ Tailwind CSS configured
|
||||
- ✅ Authentication flow with login page
|
||||
- ✅ Protected routes with auth context
|
||||
- ✅ Navigation layout (left sidebar + top bar)
|
||||
- ✅ All page routes (Dashboard, Scan, Process, Search, Identify, Auto-Match, Tags, Settings)
|
||||
- ✅ User Tagged Photos moderation tab for approving/denying pending tag linkages
|
||||
- React + Vite + TypeScript setup
|
||||
- Tailwind CSS configured
|
||||
- Authentication flow with login page
|
||||
- Protected routes with auth context
|
||||
- Navigation layout (left sidebar + top bar)
|
||||
- All page routes (Dashboard, Scan, Process, Search, Identify, Auto-Match, Tags, Settings)
|
||||
- User Tagged Photos moderation tab for approving/denying pending tag linkages
|
||||
|
||||
**Database:**
|
||||
- ✅ All tables created automatically on startup: `photos`, `faces`, `people`, `person_encodings`, `tags`, `phototaglinkage`
|
||||
- ✅ Schema matches desktop version exactly for full compatibility
|
||||
- ✅ Indices configured for performance
|
||||
- ✅ PostgreSQL database (required for both development and production)
|
||||
- ✅ Separate auth database (PostgreSQL) for frontend user accounts
|
||||
- All tables created automatically on startup: `photos`, `faces`, `people`, `person_encodings`, `tags`, `phototaglinkage`
|
||||
- Schema matches desktop version exactly for full compatibility
|
||||
- Indices configured for performance
|
||||
- PostgreSQL database (required for both development and production)
|
||||
- Separate auth database (PostgreSQL) for frontend user accounts
|
||||
|
||||
### Image Ingestion & Processing
|
||||
|
||||
**Backend:**
|
||||
- ✅ Photo import service with checksum computation
|
||||
- ✅ EXIF date extraction and image metadata
|
||||
- ✅ Folder scanning with recursive option
|
||||
- ✅ File upload support
|
||||
- ✅ Background job processing with RQ
|
||||
- ✅ Real-time job progress via SSE (Server-Sent Events)
|
||||
- ✅ Duplicate detection (by path and checksum)
|
||||
- ✅ Photo storage configuration (`PHOTO_STORAGE_DIR`)
|
||||
- ✅ **DeepFace pipeline integration**
|
||||
- ✅ **Face detection (RetinaFace, MTCNN, OpenCV, SSD)**
|
||||
- ✅ **Face embeddings computation (ArcFace, Facenet, Facenet512, VGG-Face)**
|
||||
- ✅ **Face processing service with configurable detectors/models**
|
||||
- ✅ **EXIF orientation handling**
|
||||
- ✅ **Face quality scoring and validation**
|
||||
- ✅ **Batch processing with progress tracking**
|
||||
- ✅ **Job cancellation support**
|
||||
- Photo import service with checksum computation
|
||||
- EXIF date extraction and image metadata
|
||||
- Folder scanning with recursive option
|
||||
- File upload support
|
||||
- Background job processing with RQ
|
||||
- Real-time job progress via SSE (Server-Sent Events)
|
||||
- Duplicate detection (by path and checksum)
|
||||
- Photo storage configuration (`PHOTO_STORAGE_DIR`)
|
||||
- **DeepFace pipeline integration**
|
||||
- **Face detection (RetinaFace, MTCNN, OpenCV, SSD)**
|
||||
- **Face embeddings computation (ArcFace, Facenet, Facenet512, VGG-Face)**
|
||||
- **Face processing service with configurable detectors/models**
|
||||
- **EXIF orientation handling**
|
||||
- **Face quality scoring and validation**
|
||||
- **Batch processing with progress tracking**
|
||||
- **Job cancellation support**
|
||||
|
||||
**Frontend:**
|
||||
- ✅ Scan tab UI with folder selection
|
||||
- ✅ **Native folder picker (Browse button)** - Uses tkinter for native OS folder selection
|
||||
- ✅ **Network path support** - Handles UNC paths (Windows: `\\server\share\folder`) and mounted network shares (Linux: `/mnt/nfs-share/photos`)
|
||||
- ✅ **Full absolute path handling** - Automatically normalizes and validates paths
|
||||
- ✅ Drag-and-drop file upload
|
||||
- ✅ Recursive scan toggle
|
||||
- ✅ Real-time job progress with progress bar
|
||||
- ✅ Job status monitoring (SSE integration)
|
||||
- ✅ Results display (added/existing counts)
|
||||
- ✅ Error handling and user feedback
|
||||
- ✅ **Process tab UI with configuration controls**
|
||||
- ✅ **Detector/model selection dropdowns**
|
||||
- ✅ **Batch size configuration**
|
||||
- ✅ **Start/Stop processing controls**
|
||||
- ✅ **Processing progress display with photo count**
|
||||
- ✅ **Results summary (faces detected, faces stored)**
|
||||
- ✅ **Job cancellation support**
|
||||
- Scan tab UI with folder selection
|
||||
- **Native folder picker (Browse button)** - Uses tkinter for native OS folder selection
|
||||
- **Network path support** - Handles UNC paths (Windows: `\\server\share\folder`) and mounted network shares (Linux: `/mnt/nfs-share/photos`)
|
||||
- **Full absolute path handling** - Automatically normalizes and validates paths
|
||||
- Drag-and-drop file upload
|
||||
- Recursive scan toggle
|
||||
- Real-time job progress with progress bar
|
||||
- Job status monitoring (SSE integration)
|
||||
- Results display (added/existing counts)
|
||||
- Error handling and user feedback
|
||||
- **Process tab UI with configuration controls**
|
||||
- **Detector/model selection dropdowns**
|
||||
- **Batch size configuration**
|
||||
- **Start/Stop processing controls**
|
||||
- **Processing progress display with photo count**
|
||||
- **Results summary (faces detected, faces stored)**
|
||||
- **Job cancellation support**
|
||||
|
||||
**Worker:**
|
||||
- ✅ RQ worker auto-starts with API server
|
||||
- ✅ Unique worker names to avoid conflicts
|
||||
- ✅ Graceful shutdown handling
|
||||
- ✅ **String-based function paths for reliable serialization**
|
||||
- RQ worker auto-starts with API server
|
||||
- Unique worker names to avoid conflicts
|
||||
- Graceful shutdown handling
|
||||
- **String-based function paths for reliable serialization**
|
||||
|
||||
### Identify Workflow & Auto-Match
|
||||
|
||||
**Backend:**
|
||||
- ✅ Identify face endpoints with person creation
|
||||
- ✅ Auto-match engine with similarity thresholds
|
||||
- ✅ Unidentified faces management and filtering
|
||||
- ✅ Person creation and linking
|
||||
- ✅ Batch identification support
|
||||
- ✅ Similar faces search with cosine similarity
|
||||
- ✅ Confidence calibration system (empirical-based)
|
||||
- ✅ Face unmatch/removal functionality
|
||||
- ✅ Batch similarity calculations
|
||||
- Identify face endpoints with person creation
|
||||
- Auto-match engine with similarity thresholds
|
||||
- Unidentified faces management and filtering
|
||||
- Person creation and linking
|
||||
- Batch identification support
|
||||
- Similar faces search with cosine similarity
|
||||
- Confidence calibration system (empirical-based)
|
||||
- Face unmatch/removal functionality
|
||||
- Batch similarity calculations
|
||||
|
||||
**Frontend:**
|
||||
- ✅ Identify page UI with face navigation
|
||||
- ✅ Person creation and editing
|
||||
- ✅ Similar faces panel with confidence display
|
||||
- ✅ Auto-Match page with person-centric view
|
||||
- ✅ Checkbox selection for batch identification
|
||||
- ✅ Confidence percentages with color coding
|
||||
- ✅ Unique faces filter (hide duplicates)
|
||||
- ✅ Date filtering for faces
|
||||
- ✅ Real-time face matching and display
|
||||
- Identify page UI with face navigation
|
||||
- Person creation and editing
|
||||
- Similar faces panel with confidence display
|
||||
- Auto-Match page with person-centric view
|
||||
- Checkbox selection for batch identification
|
||||
- Confidence percentages with color coding
|
||||
- Unique faces filter (hide duplicates)
|
||||
- Date filtering for faces
|
||||
- Real-time face matching and display
|
||||
|
||||
### PSearch & Tags
|
||||
|
||||
**Backend:**
|
||||
- ✅ Search endpoints with filters (people, dates, tags, folders)
|
||||
- ✅ Tag management endpoints (create, update, delete)
|
||||
- ✅ Photo-tag linkage system
|
||||
- ✅ Advanced filtering and querying
|
||||
- ✅ Photo grid endpoints with pagination
|
||||
- Search endpoints with filters (people, dates, tags, folders)
|
||||
- Tag management endpoints (create, update, delete)
|
||||
- Photo-tag linkage system
|
||||
- Advanced filtering and querying
|
||||
- Photo grid endpoints with pagination
|
||||
|
||||
**Frontend:**
|
||||
- ✅ Search page with advanced filters
|
||||
- ✅ Tag management UI
|
||||
- ✅ Photo grid with virtualized rendering
|
||||
- ✅ Filter by people, dates, tags, and folders
|
||||
- ✅ Search results display
|
||||
- Search page with advanced filters
|
||||
- Tag management UI
|
||||
- Photo grid with virtualized rendering
|
||||
- Filter by people, dates, tags, and folders
|
||||
- Search results display
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Configuration
|
||||
## Configuration
|
||||
|
||||
### Database
|
||||
|
||||
@@ -629,10 +627,10 @@ punimtag/
|
||||
Both databases use PostgreSQL. Configure via the `.env` file:
|
||||
```bash
|
||||
# Main application database (PostgreSQL - required)
|
||||
DATABASE_URL=postgresql+psycopg2://punimtag:punimtag_password@localhost:5432/punimtag
|
||||
DATABASE_URL=postgresql+psycopg2://punimtag:<choose-a-password>@localhost:5432/punimtag
|
||||
|
||||
# Auth database (PostgreSQL - required for frontend website users)
|
||||
DATABASE_URL_AUTH=postgresql+psycopg2://punimtag:punimtag_password@localhost:5432/punimtag_auth
|
||||
DATABASE_URL_AUTH=postgresql+psycopg2://punimtag:<choose-a-password>@localhost:5432/punimtag_auth
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
@@ -642,17 +640,17 @@ Configuration is managed via the `.env` file in the project root. A `.env.exampl
|
||||
**Required Configuration:**
|
||||
```bash
|
||||
# Main Database (PostgreSQL - required)
|
||||
DATABASE_URL=postgresql+psycopg2://punimtag:punimtag_password@localhost:5432/punimtag
|
||||
DATABASE_URL=postgresql+psycopg2://punimtag:<choose-a-password>@localhost:5432/punimtag
|
||||
|
||||
# Auth Database (PostgreSQL - required for frontend website user accounts)
|
||||
DATABASE_URL_AUTH=postgresql+psycopg2://punimtag:punimtag_password@localhost:5432/punimtag_auth
|
||||
DATABASE_URL_AUTH=postgresql+psycopg2://punimtag:<choose-a-password>@localhost:5432/punimtag_auth
|
||||
|
||||
# JWT Secrets (change in production!)
|
||||
SECRET_KEY=dev-secret-key-change-in-production
|
||||
|
||||
# Single-user credentials (change in production!)
|
||||
# Single-user credentials (set your own values!)
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=admin
|
||||
ADMIN_PASSWORD=<choose-a-password>
|
||||
|
||||
# Photo storage directory (default: data/uploads)
|
||||
PHOTO_STORAGE_DIR=data/uploads
|
||||
@@ -669,13 +667,13 @@ VITE_API_URL=http://127.0.0.1:8000
|
||||
Create a `.env` file in the `viewer-frontend/` directory:
|
||||
```bash
|
||||
# Main database connection (PostgreSQL - required)
|
||||
DATABASE_URL=postgresql://punimtag:punimtag_password@localhost:5432/punimtag
|
||||
DATABASE_URL=postgresql://punimtag:<choose-a-password>@localhost:5432/punimtag
|
||||
|
||||
# Auth database connection (PostgreSQL - required)
|
||||
DATABASE_URL_AUTH=postgresql://punimtag:punimtag_password@localhost:5432/punimtag_auth
|
||||
DATABASE_URL_AUTH=postgresql://punimtag:<choose-a-password>@localhost:5432/punimtag_auth
|
||||
|
||||
# Write-capable database connection (optional, falls back to DATABASE_URL if not set)
|
||||
DATABASE_URL_WRITE=postgresql://punimtag:punimtag_password@localhost:5432/punimtag
|
||||
DATABASE_URL_WRITE=postgresql://punimtag:<choose-a-password>@localhost:5432/punimtag
|
||||
|
||||
# NextAuth configuration
|
||||
NEXTAUTH_URL=http://localhost:3001
|
||||
@@ -700,7 +698,7 @@ npx prisma generate
|
||||
|
||||
---
|
||||
|
||||
### 🔄 Phase 5: Polish & Release (In Progress)
|
||||
### Phase 5: Polish & Release (In Progress)
|
||||
- Performance optimization
|
||||
- Accessibility improvements
|
||||
- Production deployment
|
||||
@@ -708,7 +706,7 @@ npx prisma generate
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Architecture
|
||||
## Architecture
|
||||
|
||||
**Backend:**
|
||||
- **Framework**: FastAPI (Python 3.12+)
|
||||
@@ -731,7 +729,7 @@ npx prisma generate
|
||||
|
||||
---
|
||||
|
||||
## 📦 Dependencies
|
||||
## Dependencies
|
||||
|
||||
**Backend:**
|
||||
- `fastapi==0.115.0`
|
||||
@@ -762,7 +760,7 @@ npx prisma generate
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Security
|
||||
## Security
|
||||
|
||||
- JWT-based authentication with refresh tokens
|
||||
- Password hashing with bcrypt
|
||||
@@ -771,11 +769,11 @@ npx prisma generate
|
||||
- Input validation via Pydantic schemas
|
||||
- Separate auth database for frontend website user accounts
|
||||
|
||||
**⚠️ Note**: Default credentials (`admin`/`admin`) are for development only. Change in production!
|
||||
**Note**: Set strong values for `ADMIN_USERNAME`, `ADMIN_PASSWORD`, and `SECRET_KEY` before deploying.
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Known Limitations
|
||||
## Known Limitations
|
||||
|
||||
- Multi-user support with role-based permissions (single-user mode deprecated)
|
||||
- PostgreSQL for both development and production
|
||||
@@ -787,13 +785,13 @@ npx prisma generate
|
||||
|
||||
---
|
||||
|
||||
## 📝 License
|
||||
## License
|
||||
|
||||
[Add your license here]
|
||||
|
||||
---
|
||||
|
||||
## 👥 Authors
|
||||
## Authors
|
||||
|
||||
PunimTag Development Team
|
||||
|
||||
@@ -801,7 +799,7 @@ PunimTag Development Team
|
||||
|
||||
|
||||
|
||||
## 🙏 Acknowledgments
|
||||
## Acknowledgments
|
||||
|
||||
- **DeepFace** library by Sefik Ilkin Serengil - Modern face recognition framework
|
||||
- **ArcFace** - Additive Angular Margin Loss for Deep Face Recognition
|
||||
@@ -810,19 +808,19 @@ PunimTag Development Team
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Deployment
|
||||
## Deployment
|
||||
|
||||
### Development Server Deployment
|
||||
|
||||
The project includes scripts for deploying to the development server.
|
||||
|
||||
**Development Server:**
|
||||
- **Host**: 10.0.10.121
|
||||
- **Host**: `<backend-host>`
|
||||
- **User**: appuser
|
||||
- **Password**: [Contact administrator for password]
|
||||
|
||||
**Development Database:**
|
||||
- **Host**: 10.0.10.179 (postgresQA / pve201 VM 109; legacy `.181` is dead)
|
||||
- **Host**: `<db-host>`
|
||||
- **Port**: 5432
|
||||
- **User**: ladmin
|
||||
- **Password**: [Contact administrator for password]
|
||||
@@ -854,7 +852,7 @@ The deployment script will:
|
||||
2. **Transfer files to server:**
|
||||
```bash
|
||||
# Transfer backend and built frontends
|
||||
scp -r backend admin-frontend/dist viewer-frontend/.next appuser@10.0.10.121:/path/to/deployment
|
||||
scp -r backend admin-frontend/dist viewer-frontend/.next appuser@<backend-host>:/path/to/deployment
|
||||
```
|
||||
|
||||
3. **Set up environment on server:**
|
||||
@@ -884,7 +882,7 @@ See `docs/DEPLOYMENT.md` for complete production deployment guide.
|
||||
|
||||
---
|
||||
|
||||
## 📧 Support
|
||||
## Support
|
||||
|
||||
For questions or issues:
|
||||
1. Check documentation in `docs/`
|
||||
@@ -893,6 +891,6 @@ For questions or issues:
|
||||
|
||||
---
|
||||
|
||||
**Made with ❤️ for photo enthusiasts**
|
||||
**Made for photo enthusiasts**
|
||||
|
||||
|
||||
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
# 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] **FastAPI e2e users** — `e2e` (admin, mirrors `e2e@levkine.ca`) + `e2e-viewer` (viewer) in FastAPI main DB; `E2E_API_USERNAME`/`PASSWORD` + `E2E_API_VIEWER_USERNAME`/`PASSWORD` in CI
|
||||
- [x] **Search / filter e2e** — tag + person filters on public gallery (`gallery.search-filters.spec.ts`)
|
||||
- [x] **Role permissions e2e** — viewer vs admin write gates (`api.role-permissions.spec.ts`)
|
||||
- [x] **Ansible FastAPI-e2e work committed** — ansible PR #127 merged (provisioning script, SMTP quote fix, vault/docs)
|
||||
- [x] **Infisical `/playkit/punimtag` E2E_API_* keys created** — self-hosted build's `/api/v3/secrets/raw` create route needs client-side E2EE ciphertext (422 on plaintext); fixed by switching to `/api/v4/secrets` (plaintext) in `provision-punimtag-e2e-user.py` — see ansible `docs/hardening/SECRETS.md`
|
||||
- [x] **Vaultwarden sync for FastAPI e2e logins** — `PunimTag FastAPI e2e admin/viewer (DEV)` entries created
|
||||
- [x] **Playkit bumped to `v0.3.1`** — tagged, released (Gitea release job needed a `RELEASE_TOKEN` secret rename — `GITEA_TOKEN` is a reserved name Gitea's Actions API rejects), Outline `QA & Dev → Playkit` page created
|
||||
- [x] **Network silent-failure monitor** — `startNetworkErrorMonitor` on gallery home, search+filter, upload, Manage Users (`network-errors.spec.ts`)
|
||||
- [x] **Favorites + People filter UI e2e** — `gallery.filters.authed.spec.ts` (uses storageState)
|
||||
- [x] **admin-frontend Vitest** — separate Vite/React app; `admin-unit` CI job + `fastapi-path`/`media-path` lib tests
|
||||
- [x] **`interceptNetworkCall` on upload + search filter UI** — typed status/JSON spies replace `waitForResponse`/text scraping (`upload.smoke.spec.ts`, `gallery.search-filters.spec.ts`)
|
||||
- [x] **Widened Zod schemas** — FastAPI login `TokenResponse` + `/auth/me` `UserResponse` (`api.fastapi-login.spec.ts`), gallery `SearchResponse` (`gallery.search-filters.spec.ts`)
|
||||
- [x] **PROD smoke spec** — `prod.smoke.spec.ts`, opt-in via `PROD_BASE_URL`; skips (no-op) until the PROD LXC exists — see Ops/docs debt
|
||||
- [x] **NextAuth (viewer) write gates** — third user store (auth DB, `hasWriteAccess=false`) provisioned via `provision-punimtag-e2e-user.py`; `viewer.write-gates.spec.ts` proves 403 (viewer) vs pass-through (admin) on `POST /api/faces/{id}/identify`
|
||||
- [x] **Manage Users real CRUD + causal cross-session e2e** — `admin.manage-users-actions.spec.ts`: admin creates/edits/deletes a user through the actual panel (the older spec only opened/closed it), and deactivating a live user immediately revokes their *already open* session (jwt callback re-checks `isActive`); disposable `e2e-manage-test-*` accounts, always cleaned up
|
||||
- [x] **Timing budgets** — `e2e/timing-budgets.ts` `expectWithinBudget()` gates every `timings.measure()` call site against a shared `BUDGET_MS` bucket (`api`/`uiAction`/`uiLogin`/`heavyCrud`); a step getting order-of-magnitude slower now reds the test instead of only feeding an unwatched sample
|
||||
- [x] **CI: `actions/upload-artifact@v4` pinned to `v3`** — v4 doesn't work against this Gitea/act runner's artifact backend; report upload on e2e failure was silently broken
|
||||
- [x] **CI: npm cache corruption retry** — `viewer-unit`/`admin-unit`/`e2e` all retry `npm ci` once after `npm cache clean --force` on first failure (shared act_runner cache has corrupted `@next/swc-linux-x64-musl` before, redding CI with no product bug)
|
||||
- [x] ROADMAP (this file)
|
||||
|
||||
## Next (near-term)
|
||||
|
||||
### Ops / docs debt
|
||||
|
||||
- [ ] **Wire QA/PROD SMTP on live guests** when LXCs 9102/9103 exist (`make punimtag-sync-smtp ENV=qa|prod`) — confirmed via `pct list` on `10.0.10.201`: only DEV (`9101`) exists today, so this (and PROD smoke actually running, and the PROD `NEXTAUTH_URL` hostname) stays dormant-but-ready until QA/PROD LXCs are provisioned
|
||||
- [ ] **Stop seeding `admin@admin.com` in docs** as the day-to-day login; keep bootstrap scripts but point operators at Vaultwarden `PunimTag e2e`
|
||||
- [ ] Set the new `E2E_VIEWER_EMAIL`/`E2E_VIEWER_PASSWORD` Gitea secrets from Infisical/Vaultwarden if CI doesn't already have them synced (script sets Gitea directly, so likely already OK — verify on next CI run)
|
||||
- [x] ~~**pve10 RAM overcommit / CI OOM under bursts**~~ — dropped from backlog 2026-07-15 (user); git-ci-01 already at 8 GB (ansible PR #128). Revisit only if OOM storms return.
|
||||
- [x] **DEV LXC (9101) redeploy** — 2026-07-15: `e54c609` → `23f0616` (`origin/dev`, +24 commits incl. `c011c80` a11y/aria-labels). Stashed 3 dirty viewer files; `npm ci` + `prisma:generate:all`; `pm2 restart punimtag-viewer`; local + `punimtagdev.levkin.ca` → **200**. Manage-Users `aria-label`s present. *(Note: `punimtag-api` still PM2 `waiting restart` — pre-existing, not introduced by this pull.)*
|
||||
|
||||
### Playkit adoption (pin is `v0.3.1` today)
|
||||
|
||||
| Kit feature | In pin? | PunimTag use |
|
||||
|-------------|---------|--------------|
|
||||
| Zod `schema` on `ApiClient` | yes | health, people/tags catalog, FastAPI login `TokenResponse` + `/auth/me`, gallery `SearchResponse` |
|
||||
| `saveStorageState` / `storageStateUse` | yes | setup uses `saveStorageState` for admin **and** viewer (`hasWriteAccess=false`); specs use raw `test.use({ storageState })` (`storageStateUse` optional cosmetic) |
|
||||
| `playkitFailureArtifacts()` | yes | `playwright.config.ts` |
|
||||
| Mailpit / `createMailInbox` | yes | mail specs |
|
||||
| `interceptNetworkCall` / `startNetworkErrorMonitor` | yes | both in use — `network-errors.spec.ts`, `upload.smoke.spec.ts`, `gallery.search-filters.spec.ts` |
|
||||
| Timing → Pushgateway | yes API | **not wired** — needs Pushgateway deployed (ansible `deploy/observability/`) + CI `PLAYKIT_METRICS_*` env |
|
||||
|
||||
### Product / coverage gaps
|
||||
|
||||
- [ ] **PROD smoke actually running** — spec exists (`prod.smoke.spec.ts`) and is verified against a real public host, but stays skipped until `PROD_BASE_URL` is set (blocked on the LXC existing — see Ops/docs debt)
|
||||
- [ ] Consider a non-admin **manager/editor**-role FastAPI + NextAuth user if finer-grained role coverage is ever needed (today's viewer/admin split covers the binary write-access gate)
|
||||
- [x] ~~No latency/perf budget assertions anywhere~~ — closed via `expectWithinBudget()` (see "Now — shipped / in flight"). Still no Core Web Vitals (LCP/CLS/INP) collection — separate gap, not addressed.
|
||||
|
||||
### Deferred (soak policy)
|
||||
|
||||
- [ ] **Burn-in / deploy-smoke CLI** (playkit ROADMAP v0.4/v0.5+) — playkit's own ROADMAP has an explicit "adoption pause" until the `v0.3.1` network helpers + release job soak for a few days in this repo's CI; revisit once that settles rather than adding more moving parts at once.
|
||||
|
||||
## 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
|
||||
- [ ] Enable playkit timing metrics in CI once Pushgateway is live
|
||||
|
||||
## 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` | CI (secrets set) |
|
||||
| E2E role-permission gates | `api.role-permissions.spec.ts` | CI (secrets set) |
|
||||
| E2E gallery search filters | `gallery.search-filters.spec.ts` | CI (public, no login) |
|
||||
| E2E favorites + people filter UI | `gallery.filters.authed.spec.ts` | CI (storageState) |
|
||||
| E2E network silent-failure monitor | `network-errors.spec.ts` | CI |
|
||||
| E2E NextAuth write gates (viewer vs admin) | `viewer.write-gates.spec.ts` | CI (secrets set) |
|
||||
| E2E PROD smoke (health + login) | `prod.smoke.spec.ts` | dormant — needs `PROD_BASE_URL` (PROD LXC) |
|
||||
| Admin UI unit | `admin-frontend` Vitest | CI `admin-unit` |
|
||||
| Timing metrics push | — | gap (ops + CI env) |
|
||||
|
||||
See also: `e2e/README.md`, playkit `ROADMAP.md` / `docs/NETWORK.md` / `docs/IDEAS.md`.
|
||||
Generated
+536
-16
@@ -21,6 +21,7 @@
|
||||
"@typescript-eslint/eslint-plugin": "^6.10.0",
|
||||
"@typescript-eslint/parser": "^6.10.0",
|
||||
"@vitejs/plugin-react": "^4.2.0",
|
||||
"@vitest/coverage-v8": "^4.1.10",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"eslint": "^8.53.0",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
@@ -29,7 +30,8 @@
|
||||
"postcss": "^8.4.31",
|
||||
"tailwindcss": "^3.3.5",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^7.3.1"
|
||||
"vite": "^7.3.1",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@alloc/quick-lru": {
|
||||
@@ -208,9 +210,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-string-parser": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
|
||||
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
|
||||
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -218,9 +220,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-validator-identifier": {
|
||||
"version": "7.28.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
|
||||
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
|
||||
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -252,13 +254,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/parser": {
|
||||
"version": "7.28.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz",
|
||||
"integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
|
||||
"integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.28.5"
|
||||
"@babel/types": "^7.29.7"
|
||||
},
|
||||
"bin": {
|
||||
"parser": "bin/babel-parser.js"
|
||||
@@ -334,19 +336,29 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/types": {
|
||||
"version": "7.28.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz",
|
||||
"integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
|
||||
"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-string-parser": "^7.27.1",
|
||||
"@babel/helper-validator-identifier": "^7.28.5"
|
||||
"@babel/helper-string-parser": "^7.29.7",
|
||||
"@babel/helper-validator-identifier": "^7.29.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@bcoe/v8-coverage": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
|
||||
"integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
|
||||
@@ -1350,6 +1362,13 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tanstack/query-core": {
|
||||
"version": "5.90.15",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.15.tgz",
|
||||
@@ -1421,6 +1440,24 @@
|
||||
"@babel/types": "^7.28.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/chai": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/deep-eql": "*",
|
||||
"assertion-error": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/deep-eql": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
@@ -1696,6 +1733,150 @@
|
||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/coverage-v8": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz",
|
||||
"integrity": "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@bcoe/v8-coverage": "^1.0.2",
|
||||
"@vitest/utils": "4.1.10",
|
||||
"ast-v8-to-istanbul": "^1.0.0",
|
||||
"istanbul-lib-coverage": "^3.2.2",
|
||||
"istanbul-lib-report": "^3.0.1",
|
||||
"istanbul-reports": "^3.2.0",
|
||||
"magicast": "^0.5.2",
|
||||
"obug": "^2.1.1",
|
||||
"std-env": "^4.0.0-rc.1",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@vitest/browser": "4.1.10",
|
||||
"vitest": "4.1.10"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@vitest/browser": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz",
|
||||
"integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/spy": "4.1.10",
|
||||
"@vitest/utils": "4.1.10",
|
||||
"chai": "^6.2.2",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz",
|
||||
"integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "4.1.10",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.21"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"msw": "^2.4.9",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"msw": {
|
||||
"optional": true
|
||||
},
|
||||
"vite": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/pretty-format": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz",
|
||||
"integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/runner": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz",
|
||||
"integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/utils": "4.1.10",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/snapshot": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz",
|
||||
"integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.10",
|
||||
"@vitest/utils": "4.1.10",
|
||||
"magic-string": "^0.30.21",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz",
|
||||
"integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/utils": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz",
|
||||
"integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.10",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.15.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||
@@ -1945,6 +2126,35 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/assertion-error": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/ast-v8-to-istanbul": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.5.tgz",
|
||||
"integrity": "sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/trace-mapping": "^0.3.31",
|
||||
"estree-walker": "^3.0.3",
|
||||
"js-tokens": "^10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ast-v8-to-istanbul/node_modules/js-tokens": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
|
||||
"integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/async-function": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz",
|
||||
@@ -2202,6 +2412,16 @@
|
||||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/chai": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
|
||||
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
@@ -2648,6 +2868,13 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-module-lexer": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz",
|
||||
"integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||
@@ -3039,6 +3266,16 @@
|
||||
"node": ">=4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/estree-walker": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
|
||||
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/esutils": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
|
||||
@@ -3055,6 +3292,16 @@
|
||||
"integrity": "sha512-g/aje2noHivrRSLbAUtBPWFbxKdKhgj/xr1vATDdUXPOFYJlQ62Ft0oy+72V6XLIpDJfHs6gXLbBLAolqOXYRw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/expect-type": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
|
||||
"integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
@@ -3607,6 +3854,13 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/html-escaper": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
|
||||
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ignore": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||
@@ -4107,6 +4361,45 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/istanbul-lib-coverage": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
|
||||
"integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/istanbul-lib-report": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
|
||||
"integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"istanbul-lib-coverage": "^3.0.0",
|
||||
"make-dir": "^4.0.0",
|
||||
"supports-color": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/istanbul-reports": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
|
||||
"integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"html-escaper": "^2.0.0",
|
||||
"istanbul-lib-report": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/iterator.prototype": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz",
|
||||
@@ -4306,6 +4599,44 @@
|
||||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/magicast": {
|
||||
"version": "0.5.3",
|
||||
"resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz",
|
||||
"integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.29.3",
|
||||
"@babel/types": "^7.29.0",
|
||||
"source-map-js": "^1.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/make-dir": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
|
||||
"integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"semver": "^7.5.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
@@ -4556,6 +4887,20 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/obug": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz",
|
||||
"integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
"https://github.com/sponsors/sxzz",
|
||||
"https://opencollective.com/debug"
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/once": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||
@@ -4694,6 +5039,13 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/pathe": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -5475,6 +5827,13 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/siginfo": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
||||
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/slash": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
|
||||
@@ -5495,6 +5854,20 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/stackback": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
||||
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/std-env": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz",
|
||||
"integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/stop-iteration-iterator": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
|
||||
@@ -5771,6 +6144,23 @@
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/tinybench": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyexec": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz",
|
||||
"integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.15",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||
@@ -5819,6 +6209,16 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyrainbow": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
|
||||
"integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/to-regex-range": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
@@ -6143,6 +6543,109 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz",
|
||||
"integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/expect": "4.1.10",
|
||||
"@vitest/mocker": "4.1.10",
|
||||
"@vitest/pretty-format": "4.1.10",
|
||||
"@vitest/runner": "4.1.10",
|
||||
"@vitest/snapshot": "4.1.10",
|
||||
"@vitest/spy": "4.1.10",
|
||||
"@vitest/utils": "4.1.10",
|
||||
"es-module-lexer": "^2.0.0",
|
||||
"expect-type": "^1.3.0",
|
||||
"magic-string": "^0.30.21",
|
||||
"obug": "^2.1.1",
|
||||
"pathe": "^2.0.3",
|
||||
"picomatch": "^4.0.3",
|
||||
"std-env": "^4.0.0-rc.1",
|
||||
"tinybench": "^2.9.0",
|
||||
"tinyexec": "^1.0.2",
|
||||
"tinyglobby": "^0.2.15",
|
||||
"tinyrainbow": "^3.1.0",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
|
||||
"why-is-node-running": "^2.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"vitest": "vitest.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.0.0 || ^22.0.0 || >=24.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@edge-runtime/vm": "*",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
|
||||
"@vitest/browser-playwright": "4.1.10",
|
||||
"@vitest/browser-preview": "4.1.10",
|
||||
"@vitest/browser-webdriverio": "4.1.10",
|
||||
"@vitest/coverage-istanbul": "4.1.10",
|
||||
"@vitest/coverage-v8": "4.1.10",
|
||||
"@vitest/ui": "4.1.10",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edge-runtime/vm": {
|
||||
"optional": true
|
||||
},
|
||||
"@opentelemetry/api": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser-playwright": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser-preview": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser-webdriverio": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/coverage-istanbul": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/coverage-v8": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/ui": {
|
||||
"optional": true
|
||||
},
|
||||
"happy-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"jsdom": {
|
||||
"optional": true
|
||||
},
|
||||
"vite": {
|
||||
"optional": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/picomatch": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
|
||||
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
@@ -6248,6 +6751,23 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/why-is-node-running": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"siginfo": "^2.0.0",
|
||||
"stackback": "0.0.2"
|
||||
},
|
||||
"bin": {
|
||||
"why-is-node-running": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/word-wrap": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --ext ts,tsx"
|
||||
"lint": "eslint . --ext ts,tsx",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.8.4",
|
||||
@@ -23,6 +24,7 @@
|
||||
"@typescript-eslint/eslint-plugin": "^6.10.0",
|
||||
"@typescript-eslint/parser": "^6.10.0",
|
||||
"@vitejs/plugin-react": "^4.2.0",
|
||||
"@vitest/coverage-v8": "^4.1.10",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"eslint": "^8.53.0",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
@@ -31,6 +33,7 @@
|
||||
"postcss": "^8.4.31",
|
||||
"tailwindcss": "^3.3.5",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^7.3.1"
|
||||
"vite": "^7.3.1",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { fastApiV1Path } from './fastapi-path';
|
||||
|
||||
describe('fastApiV1Path', () => {
|
||||
it('prefixes a leading-slash suffix with /api/v1', () => {
|
||||
expect(fastApiV1Path('/photos')).toBe('/api/v1/photos');
|
||||
});
|
||||
|
||||
it('adds the missing leading slash before prefixing', () => {
|
||||
expect(fastApiV1Path('photos')).toBe('/api/v1/photos');
|
||||
});
|
||||
|
||||
it('handles nested paths', () => {
|
||||
expect(fastApiV1Path('/photos/123/tags')).toBe('/api/v1/photos/123/tags');
|
||||
});
|
||||
|
||||
it('handles an empty suffix', () => {
|
||||
expect(fastApiV1Path('')).toBe('/api/v1/');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isRemoteMediaPath } from './media-path';
|
||||
|
||||
describe('isRemoteMediaPath', () => {
|
||||
it('treats http URLs as remote', () => {
|
||||
expect(isRemoteMediaPath('http://example.com/a.jpg')).toBe(true);
|
||||
});
|
||||
|
||||
it('treats https URLs as remote', () => {
|
||||
expect(isRemoteMediaPath('https://example.com/a.jpg')).toBe(true);
|
||||
});
|
||||
|
||||
it('treats a local filesystem path as not remote', () => {
|
||||
expect(isRemoteMediaPath('/srv/media/library/a.jpg')).toBe(false);
|
||||
});
|
||||
|
||||
it('is case-insensitive for the scheme', () => {
|
||||
expect(isRemoteMediaPath('HTTPS://example.com/a.jpg')).toBe(true);
|
||||
});
|
||||
|
||||
it('trims surrounding whitespace before checking', () => {
|
||||
expect(isRemoteMediaPath(' https://example.com/a.jpg ')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
globals: true,
|
||||
include: ['src/**/*.test.{ts,tsx}'],
|
||||
exclude: ['node_modules', 'dist'],
|
||||
},
|
||||
});
|
||||
@@ -242,13 +242,13 @@ cd ~/punimtag
|
||||
|
||||
```bash
|
||||
# Root .env
|
||||
cp .env_example .env
|
||||
cp .env.example .env
|
||||
|
||||
# Admin frontend .env
|
||||
cp admin-frontend/.env_example admin-frontend/.env
|
||||
cp admin-frontend/.env.example admin-frontend/.env
|
||||
|
||||
# Viewer frontend .env
|
||||
cp viewer-frontend/.env_example viewer-frontend/.env
|
||||
cp viewer-frontend/.env.example viewer-frontend/.env
|
||||
```
|
||||
|
||||
### 5.3 Edit Root .env File
|
||||
|
||||
@@ -160,7 +160,7 @@ sudo chown -R $USER:$USER /opt/punimtag
|
||||
|
||||
```bash
|
||||
cd /opt/punimtag
|
||||
cp .env_example .env
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
2. Edit `.env` and set the real values. The template includes **at least**:
|
||||
@@ -197,7 +197,7 @@ Notes:
|
||||
|
||||
```bash
|
||||
cd /opt/punimtag/admin-frontend
|
||||
cp .env_example .env
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
2. Edit `.env`:
|
||||
@@ -221,7 +221,7 @@ VITE_API_URL=
|
||||
|
||||
```bash
|
||||
cd /opt/punimtag/viewer-frontend
|
||||
cp .env_example .env
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
2. Edit `.env`:
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# 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 mirrors e2e@levkine.ca (admin role) in the FastAPI main DB.
|
||||
# E2E_API_VIEWER_USERNAME is a viewer-role account used only for role-permission
|
||||
# (write-gate) checks — see api.role-permissions.spec.ts.
|
||||
# E2E_API_USERNAME=
|
||||
# E2E_API_PASSWORD=
|
||||
# E2E_API_VIEWER_USERNAME=
|
||||
# E2E_API_VIEWER_PASSWORD=
|
||||
|
||||
# Optional NextAuth (browser-session) viewer — third, independent user store
|
||||
# (auth DB, hasWriteAccess=false). Used only for viewer.write-gates.spec.ts.
|
||||
# E2E_VIEWER_EMAIL=
|
||||
# E2E_VIEWER_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=
|
||||
@@ -0,0 +1,3 @@
|
||||
.auth/
|
||||
.env
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
# 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, admin role (separate DB from NextAuth; mirrors `e2e@levkine.ca`) |
|
||||
| `E2E_API_VIEWER_USERNAME` / `E2E_API_VIEWER_PASSWORD` | optional | FastAPI bearer login, viewer role — role-permission gate specs only |
|
||||
| `E2E_VIEWER_EMAIL` / `E2E_VIEWER_PASSWORD` | optional | NextAuth auth-DB viewer, `hasWriteAccess=false` (third, independent store) — `viewer.write-gates.spec.ts` only |
|
||||
| `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`.
|
||||
|
||||
**Playkit pin:** `@levkin/playkit@v0.3.1` (git tag). Zod schema option, storageState
|
||||
helpers, `playkitFailureArtifacts`, Mailpit, and network interception
|
||||
(`interceptNetworkCall`, `startNetworkErrorMonitor` — see playkit `docs/NETWORK.md`)
|
||||
are all in this pin — see `network-errors.spec.ts` for the silent-4xx/5xx monitors on
|
||||
gallery/search/upload/manage-users. Timing → Pushgateway is supported by the kit but
|
||||
not enabled in this CI job yet.
|
||||
|
||||
## 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
|
||||
12. FastAPI role-permission write gates: viewer 403 vs admin 200 on users, role-permissions, bulk-delete
|
||||
13. Public gallery search (`/api/search`): tag filter, person filter, and combined-filter narrowing return correct subsets; UI tag-filter interaction updates URL + result count
|
||||
14. Logged-in-only filters: Favorites checkbox toggles `favoritesOnly` URL param; People filter UI selection updates URL + result count (uses storageState)
|
||||
15. Silent-failure network monitor (`startNetworkErrorMonitor`): gallery home, search+filter, upload, and Manage Users overlay all stay free of background 4xx/5xx while the UI loads/interacts
|
||||
16. `interceptNetworkCall` spies on the real upload POST (status + `{ message, photos }` shape) and the filtered `/api/search?tags=` GET (status + Zod-validated shape) instead of brittle `waitForResponse`/text scraping
|
||||
17. Zod-validated API responses: FastAPI login `TokenResponse` + `/auth/me` `UserResponse` (`api.fastapi-login.spec.ts`), gallery search `SearchResponse` (`gallery.search-filters.spec.ts`)
|
||||
18. NextAuth (browser-session) write gates: viewer (`hasWriteAccess=false`) gets 403, admin gets past the gate, on `POST /api/faces/{id}/identify` (`viewer.write-gates.spec.ts`) — distinct from the FastAPI role-permission gates in #12
|
||||
19. PROD smoke (`prod.smoke.spec.ts`, opt-in via `PROD_BASE_URL`): `/api/health` + login page load on the public PROD host — dormant until the PROD LXC exists (see ROADMAP)
|
||||
20. Manage Users real CRUD + causal cross-session effect (`admin.manage-users-actions.spec.ts`): admin creates/edits/deletes a user through the actual panel (not just open/close), and deactivating a user via the UI immediately revokes that user's *already open* NextAuth session (jwt callback re-checks `isActive`, Kolby #57 fix) — every run creates a disposable `e2e-manage-test-*` account and deletes it in a `finally`, never touching the shared punimtagdev/MirrorMatch DB's real accounts
|
||||
21. Timing budgets (`timing-budgets.ts`): every `timings.measure()` call site is followed by `expectWithinBudget()` against a shared `BUDGET_MS` bucket — a step getting order-of-magnitude slower reds the test, not just a Pushgateway sample nobody's watching
|
||||
|
||||
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).
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
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';
|
||||
import { ManageUsersPanel } from './pages/ManageUsersPanel';
|
||||
|
||||
const runtime = createPlaykitRuntime(playkitEnvFromProcess());
|
||||
|
||||
type PunimtagFixtures = PlaykitFixtures & {
|
||||
loginPage: LoginPage;
|
||||
accountMenu: AccountMenu;
|
||||
registerPage: RegisterPage;
|
||||
manageUsersPanel: ManageUsersPanel;
|
||||
e2eCredentials: { email: string; password: string } | null;
|
||||
e2eViewerCredentials: { 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)),
|
||||
manageUsersPanel: async ({ page, playkitConfig }, use) =>
|
||||
use(new ManageUsersPanel(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);
|
||||
},
|
||||
|
||||
// NextAuth auth-DB viewer (hasWriteAccess=false) — distinct from
|
||||
// E2E_API_VIEWER_* (FastAPI bearer). See docs/hardening/SECRETS.md (ansible).
|
||||
e2eViewerCredentials: async ({}, use) => {
|
||||
const email = process.env.E2E_VIEWER_EMAIL || '';
|
||||
const password = process.env.E2E_VIEWER_PASSWORD || '';
|
||||
await use(email && password ? { email, password } : null);
|
||||
},
|
||||
|
||||
mail: async ({ playkitLog }, use) => {
|
||||
await use(createMailInbox(process.env, playkitLog.child({ component: 'mail' })));
|
||||
},
|
||||
});
|
||||
|
||||
export { expect };
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 70 B |
Generated
+131
@@ -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.1",
|
||||
"@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.1",
|
||||
"resolved": "git+https://git.levkin.ca/ilia/playkit.git#3380ba95115423950b4ba0855cc9367475d7f7cf",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "punimtag-e2e",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "PunimTag Playwright e2e — powered by @levkin/playkit",
|
||||
"scripts": {
|
||||
"test": "playwright test",
|
||||
"report": "playwright show-report"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@levkin/playkit": "git+https://git.levkin.ca/ilia/playkit.git#v0.3.1",
|
||||
"@playwright/test": "^1.52.0",
|
||||
"@types/node": "^22.15.0",
|
||||
"typescript": "^5.8.0",
|
||||
"zod": "^3.25.76"
|
||||
}
|
||||
}
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
@@ -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)');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { type Page, type Locator } from '@playwright/test';
|
||||
import { BasePage, waitForVisible, waitForHidden } from '@levkin/playkit';
|
||||
|
||||
export interface NewUserInput {
|
||||
email: string;
|
||||
password: string;
|
||||
name: string;
|
||||
isAdmin?: boolean;
|
||||
hasWriteAccess?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Page object for the "Manage Users" overlay content
|
||||
* (`viewer-frontend/app/admin/users/ManageUsersContent.tsx`).
|
||||
*
|
||||
* Talks to the NextAuth-backed `/api/users` store — a separate account
|
||||
* database from the FastAPI `/api/v1/users` store covered by
|
||||
* `api.role-permissions.spec.ts`.
|
||||
*/
|
||||
export class ManageUsersPanel extends BasePage {
|
||||
constructor(page: Page, baseUrl: string) {
|
||||
super(page, baseUrl);
|
||||
}
|
||||
|
||||
row(email: string): Locator {
|
||||
return this.page.locator('tbody tr').filter({ hasText: email });
|
||||
}
|
||||
|
||||
/** The row's action buttons (Edit is always first; Deactivate/Delete is
|
||||
* second and only rendered for active users). Positional rather than
|
||||
* aria-label-based — proved more reliable against the live table than
|
||||
* exact accessible-name matching. */
|
||||
private actionButton(email: string, position: 'first' | 'last'): Locator {
|
||||
const buttons = this.row(email).getByRole('button');
|
||||
return position === 'first' ? buttons.first() : buttons.last();
|
||||
}
|
||||
|
||||
/** Scopes to the currently-open dialog matching a heading, to avoid
|
||||
* ambiguous matches against hidden/portalled sibling dialogs. */
|
||||
private dialog(headingText: string): Locator {
|
||||
return this.page
|
||||
.getByRole('dialog')
|
||||
.filter({ has: this.page.getByRole('heading', { name: headingText, exact: true }) });
|
||||
}
|
||||
|
||||
async createUser(input: NewUserInput): Promise<void> {
|
||||
await this.click(this.page.getByRole('button', { name: 'Add User', exact: true }));
|
||||
const dialog = this.dialog('Add New User');
|
||||
await waitForVisible(dialog);
|
||||
await this.fill(dialog.locator('#add-email'), input.email);
|
||||
await this.fill(dialog.locator('#add-password'), input.password);
|
||||
await this.fill(dialog.locator('#add-name'), input.name);
|
||||
if (input.isAdmin) {
|
||||
await this.click(dialog.locator('#add-role'));
|
||||
await this.click(this.page.getByRole('option', { name: 'Admin', exact: true }));
|
||||
}
|
||||
if (input.hasWriteAccess) {
|
||||
await this.click(dialog.locator('#add-write-access'));
|
||||
}
|
||||
await this.click(dialog.getByRole('button', { name: 'Create User', exact: true }));
|
||||
await waitForVisible(this.row(input.email), { timeout: 15_000 });
|
||||
}
|
||||
|
||||
async openEditDialog(email: string): Promise<void> {
|
||||
await this.click(this.actionButton(email, 'first'));
|
||||
await waitForVisible(this.dialog('Edit User'));
|
||||
}
|
||||
|
||||
private async setCheckbox(locator: Locator, checked: boolean): Promise<void> {
|
||||
const isChecked = await locator.isChecked();
|
||||
if (isChecked !== checked) {
|
||||
await this.click(locator);
|
||||
}
|
||||
}
|
||||
|
||||
async setActive(active: boolean): Promise<void> {
|
||||
await this.setCheckbox(this.dialog('Edit User').locator('#edit-active'), active);
|
||||
}
|
||||
|
||||
async setWriteAccess(enabled: boolean): Promise<void> {
|
||||
await this.setCheckbox(this.dialog('Edit User').locator('#edit-write-access'), enabled);
|
||||
}
|
||||
|
||||
async saveEdit(): Promise<void> {
|
||||
const dialog = this.dialog('Edit User');
|
||||
await this.click(dialog.getByRole('button', { name: 'Save Changes', exact: true }));
|
||||
await waitForHidden(dialog);
|
||||
}
|
||||
|
||||
/** Clicks the row's delete/deactivate icon and confirms in the dialog.
|
||||
* Hard-deletes if the account has no related records, otherwise the
|
||||
* backend soft-deactivates it — either way `isActive` ends up false. */
|
||||
async deleteUser(email: string): Promise<void> {
|
||||
await this.click(this.actionButton(email, 'last'));
|
||||
const dialog = this.dialog('Delete User');
|
||||
await waitForVisible(dialog);
|
||||
await this.click(dialog.getByRole('button', { name: 'Delete', exact: true }));
|
||||
await waitForHidden(dialog);
|
||||
}
|
||||
|
||||
async statusBadgeText(email: string): Promise<string> {
|
||||
return (await this.row(email).locator('td').nth(2).innerText()).trim();
|
||||
}
|
||||
|
||||
async roleBadgeText(email: string): Promise<string> {
|
||||
return (await this.row(email).locator('td').nth(3).innerText()).trim();
|
||||
}
|
||||
|
||||
async writeAccessText(email: string): Promise<string> {
|
||||
return (await this.row(email).locator('td').nth(4).innerText()).trim();
|
||||
}
|
||||
|
||||
async waitForRowGone(email: string, timeout = 15_000): Promise<void> {
|
||||
await waitForHidden(this.row(email), { timeout });
|
||||
}
|
||||
|
||||
/** The table defaults to the "Active only" filter and refetches with it on
|
||||
* every save (`ManageUsersContent.handleEditUser` → `fetchUsers()`), so a
|
||||
* just-deactivated row silently drops out of view. Switch to "All" before
|
||||
* reading a status badge you expect to still be visible post-deactivation. */
|
||||
async showAllUsers(): Promise<void> {
|
||||
await this.click(this.page.locator('#status-filter'));
|
||||
await this.click(this.page.getByRole('option', { name: 'All', exact: true }));
|
||||
await waitForHidden(this.page.getByRole('option', { name: 'All', exact: true }));
|
||||
}
|
||||
}
|
||||
@@ -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"]'));
|
||||
}
|
||||
}
|
||||
@@ -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/,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import { test, expect } from '../fixtures';
|
||||
import { LoginPage } from '../pages/LoginPage';
|
||||
import { BUDGET_MS, expectWithinBudget } from '../timing-budgets';
|
||||
|
||||
/**
|
||||
* Real Manage Users CRUD through the admin UI (`admin.manage-users.spec.ts`
|
||||
* only opens/closes the panel — it never exercises create/edit/delete), plus
|
||||
* a causal cross-session check: an admin deactivating a user immediately
|
||||
* signs that user out of their *already open* session, because the NextAuth
|
||||
* `jwt` callback re-checks `isActive` from the DB on every request (see
|
||||
* `viewer-frontend/app/api/auth/[...nextauth]/route.ts`, issue #57 fix).
|
||||
*
|
||||
* punimtagdev shares its Postgres DB with MirrorMatch and reuses the same
|
||||
* pre-seeded accounts as other specs (E2E_ADMIN_EMAIL, E2E_VIEWER_EMAIL), so
|
||||
* every test here creates its own disposable `e2e-manage-test-*` account and
|
||||
* deletes it in a `finally`, regardless of outcome.
|
||||
*/
|
||||
function throwawayEmail(tag: string): string {
|
||||
return `e2e-manage-test-${tag}-${Date.now()}@e2e.invalid`;
|
||||
}
|
||||
|
||||
const THROWAWAY_PASSWORD = 'E2eThrowaway!123';
|
||||
|
||||
interface AdminUser {
|
||||
id: number;
|
||||
email: string;
|
||||
}
|
||||
|
||||
/** Best-effort cleanup — looks the account up by email since we may not
|
||||
* have its id if the test failed before/during creation. */
|
||||
async function deleteUserByEmail(page: Page, baseUrl: string, email: string): Promise<void> {
|
||||
const res = await page.request.get(`${baseUrl}/api/users?status=all`);
|
||||
if (!res.ok()) return;
|
||||
const body = (await res.json()) as { users?: AdminUser[] };
|
||||
const match = body.users?.find((u) => u.email === email);
|
||||
if (match) {
|
||||
await page.request.delete(`${baseUrl}/api/users/${match.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('admin manage users: create/edit/delete @smoke', () => {
|
||||
test('admin can create, edit, and delete a user through the panel', async ({
|
||||
page,
|
||||
playkitConfig,
|
||||
loginPage,
|
||||
accountMenu,
|
||||
manageUsersPanel,
|
||||
e2eCredentials,
|
||||
timings,
|
||||
}) => {
|
||||
test.skip(!e2eCredentials, 'E2E_ADMIN_EMAIL/PASSWORD required (admin user)');
|
||||
const email = throwawayEmail('crud');
|
||||
|
||||
try {
|
||||
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 accountMenu.openManageUsers();
|
||||
|
||||
await timings.measure('create_user', () =>
|
||||
manageUsersPanel.createUser({ email, password: THROWAWAY_PASSWORD, name: 'E2E Throwaway' }),
|
||||
);
|
||||
// Generous bucket — shared-CI DEV LXC under concurrent load (see file header).
|
||||
expectWithinBudget(timings, 'create_user', BUDGET_MS.heavyCrud);
|
||||
expect(await manageUsersPanel.statusBadgeText(email)).toMatch(/active/i);
|
||||
expect(await manageUsersPanel.roleBadgeText(email)).toMatch(/user/i);
|
||||
expect(await manageUsersPanel.writeAccessText(email)).toMatch(/no/i);
|
||||
|
||||
await timings.measure('edit_user', async () => {
|
||||
await manageUsersPanel.openEditDialog(email);
|
||||
await manageUsersPanel.setWriteAccess(true);
|
||||
await manageUsersPanel.saveEdit();
|
||||
});
|
||||
expectWithinBudget(timings, 'edit_user', BUDGET_MS.heavyCrud);
|
||||
expect(await manageUsersPanel.writeAccessText(email)).toMatch(/yes/i);
|
||||
|
||||
await timings.measure('delete_user', () => manageUsersPanel.deleteUser(email));
|
||||
expectWithinBudget(timings, 'delete_user', BUDGET_MS.heavyCrud);
|
||||
await manageUsersPanel.waitForRowGone(email);
|
||||
|
||||
await accountMenu.closeManageUsers();
|
||||
} finally {
|
||||
await deleteUserByEmail(page, playkitConfig.baseUrl, email).catch(() => undefined);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('admin manage users: causal cross-session effect @smoke', () => {
|
||||
test('deactivating a user via admin UI immediately revokes their open session', async ({
|
||||
browser,
|
||||
page,
|
||||
playkitConfig,
|
||||
loginPage,
|
||||
accountMenu,
|
||||
manageUsersPanel,
|
||||
e2eCredentials,
|
||||
timings,
|
||||
}) => {
|
||||
test.skip(!e2eCredentials, 'E2E_ADMIN_EMAIL/PASSWORD required (admin user)');
|
||||
// Two full UI logins + create/edit/verify against a shared-CI DEV LXC push
|
||||
// this past the default 60s under load (observed: a single login step
|
||||
// alone took ~90s during a concurrent CI burst) — give it more headroom.
|
||||
test.setTimeout(120_000);
|
||||
const email = throwawayEmail('deactivate');
|
||||
let userContext: import('@playwright/test').BrowserContext | undefined;
|
||||
|
||||
try {
|
||||
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 accountMenu.openManageUsers();
|
||||
await timings.measure('create_throwaway', () =>
|
||||
manageUsersPanel.createUser({ email, password: THROWAWAY_PASSWORD, name: 'E2E Throwaway' }),
|
||||
);
|
||||
expectWithinBudget(timings, 'create_throwaway', BUDGET_MS.heavyCrud);
|
||||
await accountMenu.closeManageUsers();
|
||||
|
||||
// Independent second session/actor — the throwaway user, logged in
|
||||
// for real, concurrently with the admin.
|
||||
userContext = await browser.newContext();
|
||||
const userPage = await userContext.newPage();
|
||||
const userLogin = new LoginPage(userPage, playkitConfig.baseUrl);
|
||||
|
||||
await timings.measure('throwaway_login', async () => {
|
||||
await userLogin.openLogin();
|
||||
await userLogin.signIn(email, THROWAWAY_PASSWORD);
|
||||
await userPage.waitForURL((url) => !url.pathname.includes('/login'), { timeout: 30_000 });
|
||||
await userPage.getByLabel('Account menu').waitFor({ state: 'visible', timeout: 30_000 });
|
||||
});
|
||||
// Login flows on this file have hit ~90s under a concurrent CI burst
|
||||
// (see comment above) — budget matches the file's own 120s headroom.
|
||||
expectWithinBudget(timings, 'throwaway_login', BUDGET_MS.heavyCrud);
|
||||
|
||||
const preRes = await userPage.request.get(`${playkitConfig.baseUrl}/api/auth/session`);
|
||||
const preBody = await preRes.json();
|
||||
expect(preBody?.user?.email).toBe(email);
|
||||
|
||||
// Admin deactivates the throwaway user. The throwaway user's session
|
||||
// is never touched directly — no sign-out, no reload triggered by us.
|
||||
await accountMenu.openManageUsers();
|
||||
await timings.measure('deactivate_user', async () => {
|
||||
await manageUsersPanel.openEditDialog(email);
|
||||
await manageUsersPanel.setActive(false);
|
||||
await manageUsersPanel.saveEdit();
|
||||
});
|
||||
expectWithinBudget(timings, 'deactivate_user', BUDGET_MS.heavyCrud);
|
||||
// The table defaults to (and refetches with) the "Active only" filter,
|
||||
// so the just-deactivated row drops out of view immediately — switch to
|
||||
// "All" before reading its badge, or this hangs forever waiting for a
|
||||
// row that the current filter will never show again.
|
||||
await manageUsersPanel.showAllUsers();
|
||||
expect(await manageUsersPanel.statusBadgeText(email)).toMatch(/inactive/i);
|
||||
await accountMenu.closeManageUsers();
|
||||
|
||||
// The already-open session must now read as signed-out: the jwt
|
||||
// callback re-checks `isActive` against the DB on every request.
|
||||
await timings.measure('session_revoked', async () => {
|
||||
const postRes = await userPage.request.get(`${playkitConfig.baseUrl}/api/auth/session`);
|
||||
const postBody = await postRes.json();
|
||||
expect(postBody?.user).toBeFalsy();
|
||||
});
|
||||
expectWithinBudget(timings, 'session_revoked', BUDGET_MS.uiAction);
|
||||
} finally {
|
||||
await userContext?.close();
|
||||
await deleteUserByEmail(page, playkitConfig.baseUrl, email).catch(() => undefined);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { assertPublicHost, waitForUrlHost } from '@levkin/playkit';
|
||||
import { test, expect } from '../fixtures';
|
||||
import { BUDGET_MS, expectWithinBudget } from '../timing-budgets';
|
||||
|
||||
/**
|
||||
* 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 });
|
||||
});
|
||||
expectWithinBudget(timings, 'login', BUDGET_MS.uiLogin);
|
||||
|
||||
await timings.measure('manage_users', async () => {
|
||||
await accountMenu.openManageUsers();
|
||||
await expect(page.getByRole('heading', { name: /Manage Users/i })).toBeVisible();
|
||||
await accountMenu.closeManageUsers();
|
||||
});
|
||||
expectWithinBudget(timings, 'manage_users', BUDGET_MS.uiAction);
|
||||
|
||||
await timings.measure('sign_out', () => accountMenu.signOut());
|
||||
expectWithinBudget(timings, 'sign_out', BUDGET_MS.uiAction);
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { test, expect } from '../fixtures';
|
||||
import { BUDGET_MS, expectWithinBudget } from '../timing-budgets';
|
||||
|
||||
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 }),
|
||||
);
|
||||
expectWithinBudget(timings, 'api_me_unauth', BUDGET_MS.api);
|
||||
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 }),
|
||||
);
|
||||
expectWithinBudget(timings, 'api_photos_unauth', BUDGET_MS.api);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { z } from 'zod';
|
||||
import { test, expect } from '../fixtures';
|
||||
import { BUDGET_MS, expectWithinBudget } from '../timing-budgets';
|
||||
|
||||
const PeopleList = z.object({
|
||||
items: z.array(z.object({ id: z.number() }).passthrough()).min(1),
|
||||
});
|
||||
type PeopleList = z.infer<typeof PeopleList>;
|
||||
|
||||
const TagsList = z.object({
|
||||
items: z.array(z.object({ id: z.number() }).passthrough()).min(1),
|
||||
});
|
||||
type TagsList = z.infer<typeof TagsList>;
|
||||
|
||||
const ItemsList = z.object({ items: z.array(z.unknown()) });
|
||||
type ItemsList = z.infer<typeof ItemsList>;
|
||||
|
||||
/**
|
||||
* 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<PeopleList>('/api/v1/people', { expectedStatus: 200, schema: PeopleList }),
|
||||
);
|
||||
expectWithinBudget(timings, 'api_people', BUDGET_MS.api);
|
||||
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<ItemsList>('/api/v1/people/with-faces', {
|
||||
expectedStatus: 200,
|
||||
schema: ItemsList,
|
||||
}),
|
||||
);
|
||||
expectWithinBudget(timings, 'api_people_faces', BUDGET_MS.api);
|
||||
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<TagsList>('/api/v1/tags', { expectedStatus: 200, schema: TagsList }),
|
||||
);
|
||||
expectWithinBudget(timings, 'api_tags', BUDGET_MS.api);
|
||||
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 }),
|
||||
);
|
||||
expectWithinBudget(timings, 'api_login_empty', BUDGET_MS.api);
|
||||
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,
|
||||
}),
|
||||
);
|
||||
expectWithinBudget(timings, 'api_login_bad', BUDGET_MS.api);
|
||||
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 }),
|
||||
);
|
||||
expectWithinBudget(timings, 'api_users_unauth', BUDGET_MS.api);
|
||||
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 }),
|
||||
);
|
||||
expectWithinBudget(timings, 'api_roles_unauth', BUDGET_MS.api);
|
||||
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] }),
|
||||
);
|
||||
expectWithinBudget(timings, 'api_photo_missing', BUDGET_MS.api);
|
||||
expect([401, 404]).toContain(res.status);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { z } from 'zod';
|
||||
import { test, expect } from '../fixtures';
|
||||
import { BUDGET_MS, expectWithinBudget } from '../timing-budgets';
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* Schemas mirror `backend/schemas/auth.py` (`TokenResponse` / `UserResponse`).
|
||||
*/
|
||||
const TokenResponse = z.object({
|
||||
access_token: z.string().min(1),
|
||||
refresh_token: z.string().min(1),
|
||||
password_change_required: z.boolean(),
|
||||
});
|
||||
|
||||
const UserResponse = z.object({
|
||||
username: z.string().min(1),
|
||||
is_admin: z.boolean(),
|
||||
role: z.string().min(1),
|
||||
permissions: z.record(z.boolean()),
|
||||
});
|
||||
type TokenResponse = z.infer<typeof TokenResponse>;
|
||||
type UserResponse = z.infer<typeof UserResponse>;
|
||||
|
||||
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<TokenResponse>('/api/v1/auth/login', {
|
||||
body: { username, password },
|
||||
expectedStatus: 200,
|
||||
schema: TokenResponse,
|
||||
}),
|
||||
);
|
||||
expectWithinBudget(timings, 'api_login', BUDGET_MS.api);
|
||||
expect(res.data.access_token).toBeTruthy();
|
||||
|
||||
const authed = api.withAuthBearer(res.data.access_token);
|
||||
const me = await timings.measure('api_me', () =>
|
||||
authed.get<UserResponse>('/api/v1/auth/me', { expectedStatus: 200, schema: UserResponse }),
|
||||
);
|
||||
expectWithinBudget(timings, 'api_me', BUDGET_MS.api);
|
||||
expect(me.status).toBe(200);
|
||||
expect(me.data.username).toBe(username);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { z } from 'zod';
|
||||
import { test, expect } from '../fixtures';
|
||||
import { BUDGET_MS, expectWithinBudget } from '../timing-budgets';
|
||||
|
||||
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 }),
|
||||
);
|
||||
expectWithinBudget(timings, 'api_health', BUDGET_MS.api);
|
||||
expect(res.data).toEqual({ status: 'ok' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { ApiClient } from '@levkin/playkit';
|
||||
import { test, expect } from '../fixtures';
|
||||
import { BUDGET_MS, expectWithinBudget } from '../timing-budgets';
|
||||
|
||||
/**
|
||||
* FastAPI role-permission write gates: `admin`-role users pass
|
||||
* `Depends(get_current_admin_user)`, `viewer`-role users get 403.
|
||||
*
|
||||
* Requires both FastAPI accounts (separate store from NextAuth):
|
||||
* E2E_API_USERNAME/PASSWORD — admin role, mirrors e2e@levkine.ca
|
||||
* E2E_API_VIEWER_USERNAME/PASSWORD — viewer role, gate-testing only
|
||||
*/
|
||||
async function login(api: ApiClient, username: string, password: string): Promise<string> {
|
||||
const res = await api.post<{ access_token: string }>('/api/v1/auth/login', {
|
||||
body: { username, password },
|
||||
expectedStatus: 200,
|
||||
});
|
||||
return res.data.access_token;
|
||||
}
|
||||
|
||||
test.describe('role permissions @smoke', () => {
|
||||
const adminUser = process.env.E2E_API_USERNAME || '';
|
||||
const adminPass = process.env.E2E_API_PASSWORD || '';
|
||||
const viewerUser = process.env.E2E_API_VIEWER_USERNAME || '';
|
||||
const viewerPass = process.env.E2E_API_VIEWER_PASSWORD || '';
|
||||
const ready = Boolean(adminUser && adminPass && viewerUser && viewerPass);
|
||||
|
||||
test('viewer is denied, admin is allowed on GET /api/v1/users', async ({ api, timings }) => {
|
||||
test.skip(!ready, 'E2E_API_* and E2E_API_VIEWER_* required');
|
||||
|
||||
const adminToken = await timings.measure('login_admin', () => login(api, adminUser, adminPass));
|
||||
expectWithinBudget(timings, 'login_admin', BUDGET_MS.api);
|
||||
const viewerToken = await timings.measure('login_viewer', () => login(api, viewerUser, viewerPass));
|
||||
expectWithinBudget(timings, 'login_viewer', BUDGET_MS.api);
|
||||
|
||||
const viewerRes = await timings.measure('viewer_users', () =>
|
||||
api.withAuthBearer(viewerToken).get('/api/v1/users', { expectedStatus: 403 }),
|
||||
);
|
||||
expectWithinBudget(timings, 'viewer_users', BUDGET_MS.api);
|
||||
expect(viewerRes.status).toBe(403);
|
||||
expect(viewerRes.data).toMatchObject({ detail: expect.stringMatching(/admin/i) });
|
||||
|
||||
const adminRes = await timings.measure('admin_users', () =>
|
||||
api.withAuthBearer(adminToken).get<{ items: unknown[] }>('/api/v1/users', {
|
||||
expectedStatus: 200,
|
||||
}),
|
||||
);
|
||||
expectWithinBudget(timings, 'admin_users', BUDGET_MS.api);
|
||||
expect(adminRes.status).toBe(200);
|
||||
expect(Array.isArray(adminRes.data.items)).toBe(true);
|
||||
});
|
||||
|
||||
test('viewer is denied, admin is allowed on GET /api/v1/role-permissions', async ({
|
||||
api,
|
||||
timings,
|
||||
}) => {
|
||||
test.skip(!ready, 'E2E_API_* and E2E_API_VIEWER_* required');
|
||||
|
||||
const adminToken = await timings.measure('login_admin', () => login(api, adminUser, adminPass));
|
||||
expectWithinBudget(timings, 'login_admin', BUDGET_MS.api);
|
||||
const viewerToken = await timings.measure('login_viewer', () => login(api, viewerUser, viewerPass));
|
||||
expectWithinBudget(timings, 'login_viewer', BUDGET_MS.api);
|
||||
|
||||
const viewerRes = await timings.measure('viewer_role_perms', () =>
|
||||
api.withAuthBearer(viewerToken).get('/api/v1/role-permissions', { expectedStatus: 403 }),
|
||||
);
|
||||
expectWithinBudget(timings, 'viewer_role_perms', BUDGET_MS.api);
|
||||
expect(viewerRes.status).toBe(403);
|
||||
|
||||
const adminRes = await timings.measure('admin_role_perms', () =>
|
||||
api.withAuthBearer(adminToken).get('/api/v1/role-permissions', { expectedStatus: 200 }),
|
||||
);
|
||||
expectWithinBudget(timings, 'admin_role_perms', BUDGET_MS.api);
|
||||
expect(adminRes.status).toBe(200);
|
||||
expect(adminRes.data).toHaveProperty('permissions');
|
||||
});
|
||||
|
||||
test('viewer is denied, admin is allowed on POST /api/v1/photos/bulk-delete', async ({
|
||||
api,
|
||||
timings,
|
||||
}) => {
|
||||
test.skip(!ready, 'E2E_API_* and E2E_API_VIEWER_* required');
|
||||
|
||||
const adminToken = await timings.measure('login_admin', () => login(api, adminUser, adminPass));
|
||||
expectWithinBudget(timings, 'login_admin', BUDGET_MS.api);
|
||||
const viewerToken = await timings.measure('login_viewer', () => login(api, viewerUser, viewerPass));
|
||||
expectWithinBudget(timings, 'login_viewer', BUDGET_MS.api);
|
||||
|
||||
// Use a photo id that cannot exist — proves the write *gate*, not deletion.
|
||||
// The admin dependency runs before the handler body, so this never touches
|
||||
// real data: viewer never reaches the handler, admin gets a safe no-op.
|
||||
const nonExistentId = 999999999;
|
||||
|
||||
const viewerRes = await timings.measure('viewer_bulk_delete', () =>
|
||||
api.withAuthBearer(viewerToken).post('/api/v1/photos/bulk-delete', {
|
||||
body: { photo_ids: [nonExistentId] },
|
||||
expectedStatus: 403,
|
||||
}),
|
||||
);
|
||||
expectWithinBudget(timings, 'viewer_bulk_delete', BUDGET_MS.api);
|
||||
expect(viewerRes.status).toBe(403);
|
||||
|
||||
const adminRes = await timings.measure('admin_bulk_delete', () =>
|
||||
api.withAuthBearer(adminToken).post('/api/v1/photos/bulk-delete', {
|
||||
body: { photo_ids: [nonExistentId] },
|
||||
expectedStatus: 200,
|
||||
}),
|
||||
);
|
||||
expectWithinBudget(timings, 'admin_bulk_delete', BUDGET_MS.api);
|
||||
expect(adminRes.status).toBe(200);
|
||||
expect(adminRes.data).toMatchObject({ deleted_count: 0, missing_photo_ids: [nonExistentId] });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { assertPublicHost, saveStorageState, waitForUrlHost } from '@levkin/playkit';
|
||||
import { test as setup } from '../fixtures';
|
||||
import path from 'node:path';
|
||||
import { BUDGET_MS, expectWithinBudget } from '../timing-budgets';
|
||||
|
||||
const authFile = path.join(__dirname, '../.auth/admin.json');
|
||||
const viewerAuthFile = path.join(__dirname, '../.auth/viewer.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);
|
||||
});
|
||||
expectWithinBudget(timings, 'setup_login', BUDGET_MS.uiLogin);
|
||||
|
||||
await saveStorageState(page, authFile);
|
||||
});
|
||||
|
||||
// NextAuth auth-DB viewer (hasWriteAccess=false) — optional, only needed by
|
||||
// viewer.write-gates.spec.ts. Skips (not fails) when E2E_VIEWER_* is unset.
|
||||
setup(
|
||||
'authenticate e2e viewer (no write access)',
|
||||
async ({ page, playkitConfig, loginPage, e2eViewerCredentials, timings }) => {
|
||||
setup.skip(!e2eViewerCredentials, 'E2E_VIEWER_EMAIL/PASSWORD required');
|
||||
assertPublicHost(playkitConfig.baseUrl);
|
||||
|
||||
await timings.measure('setup_login_viewer', async () => {
|
||||
await loginPage.openLogin();
|
||||
await loginPage.signIn(e2eViewerCredentials!.email, e2eViewerCredentials!.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);
|
||||
});
|
||||
expectWithinBudget(timings, 'setup_login_viewer', BUDGET_MS.uiLogin);
|
||||
|
||||
await saveStorageState(page, viewerAuthFile);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,63 @@
|
||||
import {
|
||||
assertPublicHost,
|
||||
waitForUrlHost,
|
||||
} from '@levkin/playkit';
|
||||
import { test, expect } from '../fixtures';
|
||||
import { BUDGET_MS, expectWithinBudget } from '../timing-budgets';
|
||||
|
||||
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());
|
||||
expectWithinBudget(timings, 'login_page', BUDGET_MS.uiAction);
|
||||
|
||||
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 });
|
||||
});
|
||||
expectWithinBudget(timings, 'login', BUDGET_MS.uiLogin);
|
||||
|
||||
await waitForUrlHost(page, playkitConfig.expectedHost);
|
||||
|
||||
await timings.measure('sign_out', () => accountMenu.signOut());
|
||||
expectWithinBudget(timings, 'sign_out', BUDGET_MS.uiAction);
|
||||
|
||||
// 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import {
|
||||
assertPublicHost,
|
||||
firstLinkMatching,
|
||||
readMailHtml,
|
||||
waitForUrlHost,
|
||||
type MailpitMessage,
|
||||
type MailtrapMessage,
|
||||
} from '@levkin/playkit';
|
||||
import { test, expect } from '../fixtures';
|
||||
import { BUDGET_MS, expectWithinBudget } from '../timing-budgets';
|
||||
|
||||
/**
|
||||
* 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' },
|
||||
}),
|
||||
);
|
||||
expectWithinBudget(timings, 'forgot_password_api', BUDGET_MS.api);
|
||||
expect(res.ok()).toBeTruthy();
|
||||
|
||||
const msg = await timings.measure<MailpitMessage | MailtrapMessage>('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));
|
||||
expectWithinBudget(timings, 'open_home', BUDGET_MS.uiAction);
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import path from 'node:path';
|
||||
import { test, expect } from '../fixtures';
|
||||
import { BUDGET_MS, expectWithinBudget } from '../timing-budgets';
|
||||
|
||||
/**
|
||||
* Logged-in-only gallery filters: Favorites and People are hidden from the
|
||||
* `FilterPanel` for anonymous visitors (see `FilterPanel.tsx` `isLoggedIn`
|
||||
* gate), so these need the saved admin `storageState` from `auth.setup.ts`.
|
||||
* Public tag/person query-param filtering is covered without login in
|
||||
* `gallery.search-filters.spec.ts`.
|
||||
*/
|
||||
test.describe('gallery filters (authed) @smoke', () => {
|
||||
test.use({ storageState: path.join(__dirname, '../.auth/admin.json') });
|
||||
|
||||
test('favorites filter checkbox is visible and toggles the URL param', async ({
|
||||
page,
|
||||
playkitConfig,
|
||||
timings,
|
||||
}) => {
|
||||
await timings.measure('open_search', async () => {
|
||||
await page.goto(`${playkitConfig.baseUrl}/search`);
|
||||
});
|
||||
expectWithinBudget(timings, 'open_search', BUDGET_MS.uiAction);
|
||||
|
||||
const favoritesCheckbox = page.locator('#favorites-only');
|
||||
await expect(favoritesCheckbox).toBeVisible({ timeout: 20_000 });
|
||||
await expect(favoritesCheckbox).not.toBeChecked();
|
||||
|
||||
await page.getByText('Show favorites only').click();
|
||||
|
||||
await expect(favoritesCheckbox).toBeChecked();
|
||||
await expect(page).toHaveURL(/favoritesOnly=true/);
|
||||
await expect(
|
||||
page.getByText(/Found \d+ photos?|No photos found matching your filters/i).first(),
|
||||
).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
// Toggling back off should drop the param again.
|
||||
await page.getByText('Show favorites only').click();
|
||||
await expect(favoritesCheckbox).not.toBeChecked();
|
||||
await expect(page).not.toHaveURL(/favoritesOnly=true/);
|
||||
});
|
||||
|
||||
test('people filter UI: selecting a person updates URL and result count', async ({
|
||||
page,
|
||||
playkitConfig,
|
||||
api,
|
||||
timings,
|
||||
}) => {
|
||||
const peopleRes = await api.get<{ items: Array<{ id: number; first_name: string; last_name: string }> }>(
|
||||
'/api/v1/people',
|
||||
{ expectedStatus: 200 },
|
||||
);
|
||||
const person = peopleRes.data.items[0];
|
||||
expect(person).toBeTruthy();
|
||||
|
||||
await timings.measure('open_search', async () => {
|
||||
await page.goto(`${playkitConfig.baseUrl}/search`);
|
||||
});
|
||||
expectWithinBudget(timings, 'open_search', BUDGET_MS.uiAction);
|
||||
|
||||
await page.getByRole('button', { name: /select people/i }).click();
|
||||
await page.getByPlaceholder('Search people...').fill(person.first_name);
|
||||
await page
|
||||
.getByText(`${person.first_name} ${person.last_name}`, { exact: true })
|
||||
.click();
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`people=${person.id}(&|$)`));
|
||||
await expect(
|
||||
page.getByText(/Found \d+ photos?|No photos found matching your filters/i).first(),
|
||||
).toBeVisible({ timeout: 20_000 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
import { interceptNetworkCall } from '@levkin/playkit';
|
||||
import { z } from 'zod';
|
||||
import { test, expect } from '../fixtures';
|
||||
import { BUDGET_MS, expectWithinBudget } from '../timing-budgets';
|
||||
|
||||
/**
|
||||
* Public gallery search (`viewer-frontend` `/api/search`, backed by its own
|
||||
* Prisma "main" DB — not the FastAPI backend) — tag + person filters.
|
||||
* No login required: the route only gates the `favoritesOnly` param on a
|
||||
* session, so these run against the anonymous "chromium" project.
|
||||
*
|
||||
* Schema mirrors `viewer-frontend/app/api/search/route.ts`'s
|
||||
* `NextResponse.json({ photos, total, page, pageSize, totalPages })` — a
|
||||
* Prisma-backed shape distinct from the FastAPI `SearchPhotosResponse`.
|
||||
*/
|
||||
const SearchPhoto = z
|
||||
.object({
|
||||
id: z.number(),
|
||||
PhotoTagLinkage: z.array(z.object({ tag_id: z.number() }).passthrough()).optional(),
|
||||
Face: z.array(z.object({ person_id: z.number().nullable() }).passthrough()).optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const SearchResponse = z.object({
|
||||
photos: z.array(SearchPhoto),
|
||||
total: z.number(),
|
||||
page: z.number(),
|
||||
pageSize: z.number(),
|
||||
totalPages: z.number(),
|
||||
});
|
||||
type SearchResponse = z.infer<typeof SearchResponse>;
|
||||
|
||||
test.describe('gallery search filters @smoke', () => {
|
||||
test('filtering by tag_id only returns photos carrying that tag', async ({
|
||||
api,
|
||||
page,
|
||||
playkitConfig,
|
||||
timings,
|
||||
}) => {
|
||||
const tagsRes = await timings.measure('api_tags', () =>
|
||||
api.get<{ items: Array<{ id: number; tag_name: string }> }>('/api/v1/tags', {
|
||||
expectedStatus: 200,
|
||||
}),
|
||||
);
|
||||
const tag = tagsRes.data.items[0];
|
||||
expect(tag).toBeTruthy();
|
||||
expectWithinBudget(timings, 'api_tags', BUDGET_MS.api);
|
||||
|
||||
const res = await timings.measure('search_by_tag', () =>
|
||||
page.request.get(`${playkitConfig.baseUrl}/api/search`, {
|
||||
params: { tags: String(tag.id) },
|
||||
}),
|
||||
);
|
||||
expectWithinBudget(timings, 'search_by_tag', BUDGET_MS.api);
|
||||
expect(res.ok()).toBeTruthy();
|
||||
const body = SearchResponse.parse(await res.json());
|
||||
for (const photo of body.photos) {
|
||||
const tagIds = (photo.PhotoTagLinkage ?? []).map((l) => l.tag_id);
|
||||
expect(tagIds).toContain(tag.id);
|
||||
}
|
||||
});
|
||||
|
||||
test('filtering by person_id only returns photos with that person tagged', async ({
|
||||
api,
|
||||
page,
|
||||
playkitConfig,
|
||||
timings,
|
||||
}) => {
|
||||
const peopleRes = await timings.measure('api_people', () =>
|
||||
api.get<{ items: Array<{ id: number; first_name: string }> }>('/api/v1/people', {
|
||||
expectedStatus: 200,
|
||||
}),
|
||||
);
|
||||
const person = peopleRes.data.items[0];
|
||||
expect(person).toBeTruthy();
|
||||
expectWithinBudget(timings, 'api_people', BUDGET_MS.api);
|
||||
|
||||
const res = await timings.measure('search_by_person', () =>
|
||||
page.request.get(`${playkitConfig.baseUrl}/api/search`, {
|
||||
params: { people: String(person.id) },
|
||||
}),
|
||||
);
|
||||
expectWithinBudget(timings, 'search_by_person', BUDGET_MS.api);
|
||||
expect(res.ok()).toBeTruthy();
|
||||
const body = SearchResponse.parse(await res.json());
|
||||
for (const photo of body.photos) {
|
||||
const personIds = (photo.Face ?? []).map((f) => f.person_id);
|
||||
expect(personIds).toContain(person.id);
|
||||
}
|
||||
});
|
||||
|
||||
test('combined tag+person filters (match-all) narrow results at least as much as either alone', async ({
|
||||
api,
|
||||
page,
|
||||
playkitConfig,
|
||||
timings,
|
||||
}) => {
|
||||
const [tagsRes, peopleRes] = await Promise.all([
|
||||
api.get<{ items: Array<{ id: number }> }>('/api/v1/tags', { expectedStatus: 200 }),
|
||||
api.get<{ items: Array<{ id: number }> }>('/api/v1/people', { expectedStatus: 200 }),
|
||||
]);
|
||||
const tag = tagsRes.data.items[0];
|
||||
const person = peopleRes.data.items[0];
|
||||
expect(tag).toBeTruthy();
|
||||
expect(person).toBeTruthy();
|
||||
|
||||
const [tagOnly, combined] = await Promise.all([
|
||||
page.request
|
||||
.get(`${playkitConfig.baseUrl}/api/search`, { params: { tags: String(tag.id) } })
|
||||
.then((r) => r.json())
|
||||
.then((json) => SearchResponse.parse(json)),
|
||||
page.request
|
||||
.get(`${playkitConfig.baseUrl}/api/search`, {
|
||||
params: { tags: String(tag.id), people: String(person.id), peopleMode: 'all' },
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((json) => SearchResponse.parse(json)),
|
||||
]);
|
||||
expect(combined.total).toBeLessThanOrEqual(tagOnly.total);
|
||||
});
|
||||
|
||||
test('gallery search UI: selecting a tag updates URL and result count', async ({
|
||||
page,
|
||||
playkitConfig,
|
||||
api,
|
||||
timings,
|
||||
}) => {
|
||||
const tagsRes = await api.get<{ items: Array<{ id: number; tag_name: string }> }>(
|
||||
'/api/v1/tags',
|
||||
{ expectedStatus: 200 },
|
||||
);
|
||||
const tag = tagsRes.data.items[0];
|
||||
expect(tag).toBeTruthy();
|
||||
|
||||
await timings.measure('open_search', async () => {
|
||||
await page.goto(`${playkitConfig.baseUrl}/search`);
|
||||
});
|
||||
expectWithinBudget(timings, 'open_search', BUDGET_MS.uiAction);
|
||||
|
||||
// Match only the *filtered* request (query includes `tags=`) — the page
|
||||
// also fires an unfiltered `/api/search?page=1&pageSize=30` on mount.
|
||||
const searchCall = interceptNetworkCall({
|
||||
page,
|
||||
url: /\/api\/search\?.*tags=/,
|
||||
method: 'GET',
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: /select tags/i }).click();
|
||||
await page.getByPlaceholder('Search tags...').fill(tag.tag_name);
|
||||
await page.getByText(tag.tag_name, { exact: true }).click();
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
const { status, request, responseJson } = await searchCall;
|
||||
expect(status).toBe(200);
|
||||
expect(new URL(request.url()).searchParams.get('tags')).toBe(String(tag.id));
|
||||
SearchResponse.parse(responseJson);
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`tags=${tag.id}(&|$)`));
|
||||
await expect(
|
||||
page.getByText(/Found \d+ photos?|No photos found matching your filters/i).first(),
|
||||
).toBeVisible({ timeout: 20_000 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { assertPublicHost, waitForUrlHost } from '@levkin/playkit';
|
||||
import { test, expect } from '../fixtures';
|
||||
import { BUDGET_MS, expectWithinBudget } from '../timing-budgets';
|
||||
|
||||
/**
|
||||
* 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 });
|
||||
});
|
||||
expectWithinBudget(timings, 'login', BUDGET_MS.uiLogin);
|
||||
|
||||
// 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+/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import path from 'node:path';
|
||||
import { startNetworkErrorMonitor } from '@levkin/playkit';
|
||||
import { test, expect } from '../fixtures';
|
||||
import { BUDGET_MS, expectWithinBudget } from '../timing-budgets';
|
||||
|
||||
/**
|
||||
* Silent-failure guard: these pages can *look* fine in the UI while a
|
||||
* background XHR/fetch quietly 4xx/5xxs (the exact class of bug
|
||||
* `startNetworkErrorMonitor` exists to catch — see playkit `docs/NETWORK.md`).
|
||||
* Requires playkit >= 0.3.1 (network helpers landed after the 0.3.0 tag).
|
||||
*
|
||||
* `/api/auth/session` 401s on the anonymous project by design (no cookie) —
|
||||
* excluded rather than asserted on, since it's not the failure mode we're
|
||||
* guarding against here.
|
||||
*/
|
||||
const excludeAnonSession = [/\/api\/auth\/session/];
|
||||
|
||||
test.describe('network errors (public pages) @smoke', () => {
|
||||
test('gallery home stays clean', async ({ page, playkitConfig, timings }) => {
|
||||
const net = startNetworkErrorMonitor(page, { excludePatterns: excludeAnonSession });
|
||||
try {
|
||||
await timings.measure('open_gallery', async () => {
|
||||
await page.goto(`${playkitConfig.baseUrl}/`);
|
||||
await expect(page.getByLabel('Account menu').or(page.getByRole('button', { name: /Sign in/i })).first()).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
});
|
||||
expectWithinBudget(timings, 'open_gallery', BUDGET_MS.uiAction);
|
||||
} finally {
|
||||
net.assertNoErrors();
|
||||
}
|
||||
});
|
||||
|
||||
test('search page with a tag filter stays clean', async ({ page, playkitConfig, api, timings }) => {
|
||||
const tagsRes = await api.get<{ items: Array<{ id: number; tag_name: string }> }>(
|
||||
'/api/v1/tags',
|
||||
{ expectedStatus: 200 },
|
||||
);
|
||||
const tag = tagsRes.data.items[0];
|
||||
expect(tag).toBeTruthy();
|
||||
|
||||
const net = startNetworkErrorMonitor(page, { excludePatterns: excludeAnonSession });
|
||||
try {
|
||||
await timings.measure('search_tag_filter', async () => {
|
||||
await page.goto(`${playkitConfig.baseUrl}/search?tags=${tag.id}`);
|
||||
await expect(
|
||||
page.getByText(/Found \d+ photos?|No photos found matching your filters/i).first(),
|
||||
).toBeVisible({ timeout: 20_000 });
|
||||
});
|
||||
expectWithinBudget(timings, 'search_tag_filter', BUDGET_MS.uiAction);
|
||||
} finally {
|
||||
net.assertNoErrors();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('network errors (authed) @smoke', () => {
|
||||
test.use({ storageState: path.join(__dirname, '../.auth/admin.json') });
|
||||
|
||||
test('upload page stays clean on load', async ({ page, playkitConfig, timings }) => {
|
||||
const net = startNetworkErrorMonitor(page);
|
||||
try {
|
||||
await timings.measure('open_upload', async () => {
|
||||
await page.goto(`${playkitConfig.baseUrl}/upload`);
|
||||
await expect(page.getByLabel('Account menu')).toBeVisible({ timeout: 20_000 });
|
||||
});
|
||||
expectWithinBudget(timings, 'open_upload', BUDGET_MS.uiAction);
|
||||
} finally {
|
||||
net.assertNoErrors();
|
||||
}
|
||||
});
|
||||
|
||||
test('manage users overlay stays clean', async ({ page, playkitConfig, accountMenu, timings }) => {
|
||||
await page.goto(`${playkitConfig.baseUrl}/`);
|
||||
await expect(page.getByLabel('Account menu')).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
const net = startNetworkErrorMonitor(page);
|
||||
try {
|
||||
await timings.measure('manage_users', async () => {
|
||||
await accountMenu.openManageUsers();
|
||||
await expect(page.getByRole('heading', { name: /Manage Users/i })).toBeVisible();
|
||||
await accountMenu.closeManageUsers();
|
||||
});
|
||||
expectWithinBudget(timings, 'manage_users', BUDGET_MS.uiAction);
|
||||
} finally {
|
||||
net.assertNoErrors();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { test as base, expect } from '@playwright/test';
|
||||
import { assertPublicHost } from '@levkin/playkit';
|
||||
|
||||
/**
|
||||
* PROD smoke: health + public login page only — no mutating actions, no
|
||||
* login attempt, no LAN-only FastAPI dependency. Deliberately does NOT use
|
||||
* the shared `fixtures.ts` (`api`/`playkitConfig` there resolve to DEV via
|
||||
* `env-defaults.json`); PROD has its own base URL, set only when the PROD
|
||||
* LXC actually exists (`vault_punimtag_nextauth_url_prod` is unset today —
|
||||
* LXC 9103 hasn't been provisioned, see ROADMAP "Ops / docs debt").
|
||||
*
|
||||
* Set `PROD_BASE_URL` (e.g. `https://punimtag.levkin.ca`) to enable; skips
|
||||
* otherwise so this spec is a no-op until PROD ships.
|
||||
*/
|
||||
const prodBaseUrl = process.env.PROD_BASE_URL || '';
|
||||
|
||||
base.describe('PROD smoke @prod', () => {
|
||||
base.skip(!prodBaseUrl, 'PROD_BASE_URL not set — PROD LXC not provisioned yet');
|
||||
|
||||
base('health endpoint reports ok', async ({ request }) => {
|
||||
assertPublicHost(prodBaseUrl);
|
||||
const res = await request.get(`${prodBaseUrl}/api/health`);
|
||||
expect(res.ok()).toBeTruthy();
|
||||
const body = await res.json();
|
||||
expect(body.status).toBe('ok');
|
||||
});
|
||||
|
||||
base('login page loads on the public host', async ({ page }) => {
|
||||
assertPublicHost(prodBaseUrl);
|
||||
await page.goto(`${prodBaseUrl}/login`);
|
||||
expect(new URL(page.url()).hostname).toBe(new URL(prodBaseUrl).hostname);
|
||||
await expect(page.getByRole('button', { name: /Sign in/i })).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import {
|
||||
assertPublicHost,
|
||||
firstLinkMatching,
|
||||
readMailHtml,
|
||||
waitForUrlHost,
|
||||
type MailpitMessage,
|
||||
type MailtrapMessage,
|
||||
} from '@levkin/playkit';
|
||||
import { test, expect } from '../fixtures';
|
||||
import { BUDGET_MS, expectWithinBudget } from '../timing-budgets';
|
||||
|
||||
/**
|
||||
* 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 });
|
||||
});
|
||||
expectWithinBudget(timings, 'register', BUDGET_MS.uiLogin);
|
||||
|
||||
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<MailpitMessage | MailtrapMessage>('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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
assertPublicHost,
|
||||
firstLinkMatching,
|
||||
readMailHtml,
|
||||
waitForUrlHost,
|
||||
type MailpitMessage,
|
||||
type MailtrapMessage,
|
||||
} 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<MailpitMessage | MailtrapMessage>('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+/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { assertPublicHost, interceptNetworkCall, waitForUrlHost } from '@levkin/playkit';
|
||||
import path from 'node:path';
|
||||
import { test, expect } from '../fixtures';
|
||||
import { BUDGET_MS, expectWithinBudget } from '../timing-budgets';
|
||||
|
||||
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);
|
||||
});
|
||||
expectWithinBudget(timings, 'open_upload', BUDGET_MS.uiAction);
|
||||
|
||||
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 uploadCall = interceptNetworkCall({
|
||||
page,
|
||||
url: '**/api/photos/upload',
|
||||
method: 'POST',
|
||||
timeout: 60_000,
|
||||
});
|
||||
await submit.click();
|
||||
const { status, responseJson } = await uploadCall;
|
||||
expect([200, 201, 401, 403]).toContain(status);
|
||||
if (status < 300) {
|
||||
// viewer-frontend's own /api/photos/upload route (not FastAPI) —
|
||||
// { message, photos: [...] } on success, see viewer-frontend route.ts.
|
||||
expect(responseJson).toMatchObject({
|
||||
message: expect.any(String),
|
||||
photos: expect.any(Array),
|
||||
});
|
||||
}
|
||||
|
||||
// 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 });
|
||||
});
|
||||
// Network round trip + UI settle — same magnitude as a login flow.
|
||||
expectWithinBudget(timings, 'upload_file', BUDGET_MS.uiLogin);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { assertPublicHost, waitForUrlHost } from '@levkin/playkit';
|
||||
import path from 'node:path';
|
||||
import { test, expect } from '../fixtures';
|
||||
import { BUDGET_MS, expectWithinBudget } from '../timing-budgets';
|
||||
|
||||
/**
|
||||
* 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();
|
||||
});
|
||||
expectWithinBudget(timings, 'session', BUDGET_MS.uiAction);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import path from 'node:path';
|
||||
import { test, expect } from '../fixtures';
|
||||
import { BUDGET_MS, expectWithinBudget } from '../timing-budgets';
|
||||
|
||||
/**
|
||||
* NextAuth (browser-session) write gates — `session.user.hasWriteAccess`
|
||||
* checks in viewer-frontend route handlers (see
|
||||
* `app/api/faces/[id]/identify/route.ts`). Distinct from the FastAPI
|
||||
* role-permission gates in `api.role-permissions.spec.ts` (separate user
|
||||
* store, bearer auth instead of session cookies).
|
||||
*
|
||||
* Requires `E2E_VIEWER_EMAIL`/`PASSWORD` (auth-DB viewer, hasWriteAccess=false)
|
||||
* and the admin storageState from `auth.setup.ts`.
|
||||
*
|
||||
* Uses a nonexistent face id so the *write-access* gate is what's being
|
||||
* proven, not a real mutation: the route checks `hasWriteAccess` before
|
||||
* loading the face, so viewer never reaches the 404 branch.
|
||||
*/
|
||||
const nonExistentFaceId = 999999999;
|
||||
const viewerReady = Boolean(process.env.E2E_VIEWER_EMAIL && process.env.E2E_VIEWER_PASSWORD);
|
||||
|
||||
test.describe('viewer write gates (NextAuth, viewer) @smoke', () => {
|
||||
test.use({ storageState: path.join(__dirname, '../.auth/viewer.json') });
|
||||
test.skip(!viewerReady, 'E2E_VIEWER_EMAIL/PASSWORD required');
|
||||
|
||||
test('viewer without write access is denied on POST /api/faces/{id}/identify', async ({
|
||||
page,
|
||||
playkitConfig,
|
||||
timings,
|
||||
}) => {
|
||||
const res = await timings.measure('viewer_identify', () =>
|
||||
page.request.post(`${playkitConfig.baseUrl}/api/faces/${nonExistentFaceId}/identify`, {
|
||||
data: { firstName: 'Test', lastName: 'Viewer' },
|
||||
}),
|
||||
);
|
||||
expectWithinBudget(timings, 'viewer_identify', BUDGET_MS.api);
|
||||
expect(res.status()).toBe(403);
|
||||
const body = await res.json();
|
||||
expect(body).toMatchObject({ error: expect.stringMatching(/write access/i) });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('viewer write gates (NextAuth, admin) @smoke', () => {
|
||||
test.use({ storageState: path.join(__dirname, '../.auth/admin.json') });
|
||||
|
||||
test('admin (write access) passes the gate on POST /api/faces/{id}/identify', async ({
|
||||
page,
|
||||
playkitConfig,
|
||||
timings,
|
||||
}) => {
|
||||
const res = await timings.measure('admin_identify', () =>
|
||||
page.request.post(`${playkitConfig.baseUrl}/api/faces/${nonExistentFaceId}/identify`, {
|
||||
data: { firstName: 'Test', lastName: 'Admin' },
|
||||
}),
|
||||
);
|
||||
expectWithinBudget(timings, 'admin_identify', BUDGET_MS.api);
|
||||
// Admin clears the write-access gate; a nonexistent face id then 404s —
|
||||
// proves the gate didn't block a legitimate write-access user.
|
||||
expect(res.status()).toBe(404);
|
||||
const body = await res.json();
|
||||
expect(body).toMatchObject({ error: expect.stringMatching(/face not found/i) });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { expect } from '@playwright/test';
|
||||
import type { TimingCollector } from '@levkin/playkit';
|
||||
|
||||
/**
|
||||
* Perf-budget gate for a `timings.measure(name, ...)` step.
|
||||
*
|
||||
* `timings.measure()` on its own only *records* durations (for the
|
||||
* Pushgateway export, see ROADMAP "Timing -> Pushgateway") — nothing ever
|
||||
* failed CI for a step getting slow. This asserts the most recently
|
||||
* recorded sample for `name` against `maxMs`, so a regression shows up as a
|
||||
* red test instead of only a graph nobody's watching.
|
||||
*
|
||||
* Budgets are intentionally generous (shared CI runner + DEV LXC under
|
||||
* concurrent PR load — see docs/guides/ci-runners-and-control.md in the
|
||||
* ansible repo) — this catches order-of-magnitude regressions, not
|
||||
* micro-optimization. Pick the closest `BUDGET_MS` bucket rather than
|
||||
* inventing a new one-off number per call site.
|
||||
*/
|
||||
export function expectWithinBudget(timings: TimingCollector, name: string, maxMs: number): void {
|
||||
const samples = timings.getSamples().filter((sample) => sample.name === name);
|
||||
const sample = samples[samples.length - 1];
|
||||
expect(sample, `no timing sample recorded for "${name}" — call timings.measure() first`).toBeTruthy();
|
||||
expect(
|
||||
sample!.durationMs,
|
||||
`"${name}" took ${Math.round(sample!.durationMs)}ms, budget is ${maxMs}ms`,
|
||||
).toBeLessThanOrEqual(maxMs);
|
||||
}
|
||||
|
||||
/** Shared budget buckets — see `expectWithinBudget` for rationale. */
|
||||
export const BUDGET_MS = {
|
||||
/** Direct FastAPI/NextAuth-route calls (LAN, no browser). */
|
||||
api: 8_000,
|
||||
/** Single UI interaction: open a page/panel, apply a filter, sign out. */
|
||||
uiAction: 20_000,
|
||||
/** Full login flow (navigate + submit + redirect wait). */
|
||||
uiLogin: 45_000,
|
||||
/** Multi-step UI CRUD (create/edit/deactivate + list refetch). */
|
||||
heavyCrud: 60_000,
|
||||
} as const;
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["./**/*.ts", "./**/*.json"]
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"hash": "ffc8c959",
|
||||
"configHash": "afa43417",
|
||||
"lockfileHash": "e3b0c442",
|
||||
"browserHash": "1131262f",
|
||||
"optimized": {},
|
||||
"chunks": {}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"type": "module"
|
||||
}
|
||||
@@ -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/'"
|
||||
|
||||
@@ -116,10 +116,10 @@ echo "⏭️ Continuing with deployment. Ensure PostgreSQL is configured before
|
||||
|
||||
echo ""
|
||||
echo "== 4) Ensure env files exist (copied from *_example) =="
|
||||
ensure_file_from_example "${PROJECT_ROOT}/.env_example" "${PROJECT_ROOT}/.env"
|
||||
ensure_file_from_example "${PROJECT_ROOT}/admin-frontend/.env_example" \
|
||||
ensure_file_from_example "${PROJECT_ROOT}/.env.example" "${PROJECT_ROOT}/.env"
|
||||
ensure_file_from_example "${PROJECT_ROOT}/admin-frontend/.env.example" \
|
||||
"${PROJECT_ROOT}/admin-frontend/.env"
|
||||
ensure_file_from_example "${PROJECT_ROOT}/viewer-frontend/.env_example" \
|
||||
ensure_file_from_example "${PROJECT_ROOT}/viewer-frontend/.env.example" \
|
||||
"${PROJECT_ROOT}/viewer-frontend/.env"
|
||||
|
||||
echo ""
|
||||
|
||||
+154
-3
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1,37 +1,48 @@
|
||||
# Database Configuration
|
||||
# Read-only database connection (for reading photos, faces, people, tags)
|
||||
DATABASE_URL="postgresql://viewer_readonly:password@localhost:5432/punimtag"
|
||||
# Viewer frontend env (copy to ".env" and edit values)
|
||||
|
||||
# Write-capable database connection (for user registration, pending identifications)
|
||||
# If not set, will fall back to DATABASE_URL
|
||||
# Option 1: Use the same user (after granting write permissions)
|
||||
# DATABASE_URL_WRITE="postgresql://viewer_readonly:password@localhost:5432/punimtag"
|
||||
# Option 2: Use a separate write user (recommended)
|
||||
DATABASE_URL_WRITE="postgresql://viewer_write:password@localhost:5432/punimtag"
|
||||
# Prisma DB URLs (note: no "+psycopg2" here)
|
||||
DATABASE_URL=postgresql://punimtag:CHANGE_ME@127.0.0.1:5432/punimtag
|
||||
DATABASE_URL_AUTH=postgresql://punimtag_auth:CHANGE_ME@127.0.0.1:5432/punimtag_auth
|
||||
|
||||
# NextAuth Configuration
|
||||
# Generate a secure secret using: openssl rand -base64 32
|
||||
NEXTAUTH_SECRET="your-secret-key-here-generate-with-openssl-rand-base64-32"
|
||||
NEXTAUTH_URL="http://localhost:3001"
|
||||
# Write-capable database connection (optional; falls back to DATABASE_URL if not set).
|
||||
# Recommended for production: a separate write user.
|
||||
# DATABASE_URL_WRITE=postgresql://punimtag_write:CHANGE_ME@127.0.0.1:5432/punimtag
|
||||
|
||||
# FastAPI mount URL for server-side proxies (thumbnails, web-playback)—no trailing slash.
|
||||
# Local: origin only (helper adds /api/v1/...).
|
||||
BACKEND_BASE_URL=http://127.0.0.1:8000
|
||||
# Production (public URL through your reverse proxy), e.g.:
|
||||
# BACKEND_BASE_URL=https://example.org/punim-api
|
||||
# Same machine as Next: http://127.0.0.1:8000 is also valid.
|
||||
|
||||
# Production subpath for the Next app (no trailing slash). Local: leave unset.
|
||||
# NEXT_BASE_PATH=/punim-viewer
|
||||
|
||||
|
||||
# NextAuth
|
||||
NEXTAUTH_URL=http://127.0.0.1:3001
|
||||
NEXTAUTH_SECRET=CHANGE_ME_TO_A_LONG_RANDOM_STRING
|
||||
AUTH_URL=http://127.0.0.1:3001
|
||||
|
||||
# Email provider (smtp or resend)
|
||||
EMAIL_PROVIDER="smtp"
|
||||
EMAIL_PROVIDER=smtp
|
||||
|
||||
# SMTP (primary)
|
||||
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"
|
||||
SMTP_REPLY_TO="support@your-domain.com"
|
||||
SMTP_HOST=mail.your-domain.com
|
||||
SMTP_PORT=465
|
||||
SMTP_SECURE=true
|
||||
SMTP_USER=mailer@your-domain.com
|
||||
SMTP_PASS=CHANGE_ME
|
||||
SMTP_FROM_EMAIL=noreply@your-domain.com
|
||||
SMTP_FROM_NAME=PunimTag
|
||||
SMTP_REPLY_TO=support@your-domain.com
|
||||
|
||||
# Resend (fallback)
|
||||
RESEND_API_KEY="re_xxx"
|
||||
RESEND_API_KEY=CHANGE_ME_secret-key
|
||||
RESEND_FROM_EMAIL="onboarding@resend.dev"
|
||||
RESEND_FROM_NAME="PunimTag Viewer"
|
||||
RESEND_REPLY_TO="support@your-domain.com"
|
||||
RESEND_FROM_NAME=PunimTag
|
||||
RESEND_REPLY_TO=support@your-domain.com
|
||||
UPLOAD_DIR="/mnt/db-server-uploads/pending-photos"
|
||||
|
||||
# Site Configuration
|
||||
NEXT_PUBLIC_SITE_NAME="PunimTag Photo Viewer"
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
# Viewer frontend env (copy to ".env" and edit values)
|
||||
|
||||
# Prisma DB URLs (note: no "+psycopg2" here)
|
||||
DATABASE_URL=postgresql://punimtag:CHANGE_ME@127.0.0.1:5432/punimtag
|
||||
DATABASE_URL_AUTH=postgresql://punimtag_auth:CHANGE_ME@127.0.0.1:5432/punimtag_auth
|
||||
|
||||
# FastAPI mount URL for server-side proxies (thumbnails, web-playback)—no trailing slash.
|
||||
# Local: origin only (helper adds /api/v1/...).
|
||||
BACKEND_BASE_URL=http://127.0.0.1:8000
|
||||
# Production (public URL through your reverse proxy), e.g.:
|
||||
# BACKEND_BASE_URL=https://jrccphotos.org/punim-api
|
||||
# Same machine as Next: http://127.0.0.1:8000 is also valid.
|
||||
|
||||
# Production subpath for the Next app (no trailing slash). Local: leave unset.
|
||||
# NEXT_BASE_PATH=/punim-viewer
|
||||
|
||||
|
||||
# NextAuth
|
||||
NEXTAUTH_URL=http://127.0.0.1:3001
|
||||
NEXTAUTH_SECRET=CHANGE_ME_TO_A_LONG_RANDOM_STRING
|
||||
AUTH_URL=http://127.0.0.1:3001
|
||||
|
||||
# Email provider (smtp or resend)
|
||||
EMAIL_PROVIDER=smtp
|
||||
|
||||
# SMTP (primary)
|
||||
SMTP_HOST=mail.your-domain.com
|
||||
SMTP_PORT=465
|
||||
SMTP_SECURE=true
|
||||
SMTP_USER=mailer@your-domain.com
|
||||
SMTP_PASS=CHANGE_ME
|
||||
SMTP_FROM_EMAIL=noreply@your-domain.com
|
||||
SMTP_FROM_NAME=PunimTag
|
||||
SMTP_REPLY_TO=support@your-domain.com
|
||||
|
||||
# Resend (fallback)
|
||||
RESEND_API_KEY=CHANGE_ME_secret-key
|
||||
RESEND_FROM_EMAIL="onboarding@resend.dev"
|
||||
RESEND_FROM_NAME=PunimTag
|
||||
RESEND_REPLY_TO=support@your-domain.com
|
||||
UPLOAD_DIR="/mnt/db-server-uploads/pending-photos"
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user