diff --git a/.cursorrules b/.cursorrules index aa78301..e5e2dfa 100644 --- a/.cursorrules +++ b/.cursorrules @@ -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**: `` - **User**: appuser -- **Password**: C0caC0la ### Dev PostgreSQL -- **Host**: 10.0.10.181 +- **Host**: `` - **Port**: 5432 - **User**: ladmin -- **Password**: C0caC0la ## Deployment - Use deployment scripts in package.json diff --git a/.env.example b/.env.example index d90f2a9..888ce01 100644 --- a/.env.example +++ b/.env.example @@ -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: /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 diff --git a/.env_example b/.env_example deleted file mode 100644 index fb6cb1b..0000000 --- a/.env_example +++ /dev/null @@ -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: /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 - - diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 483cc69..bf17eaf 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore index 2816edf..4134027 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/.notes/deepface_migration_plan.md b/.notes/deepface_migration_plan.md deleted file mode 100644 index cf73aab..0000000 --- a/.notes/deepface_migration_plan.md +++ /dev/null @@ -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. - diff --git a/.notes/directory_structure.md b/.notes/directory_structure.md deleted file mode 100644 index 3da8f3d..0000000 --- a/.notes/directory_structure.md +++ /dev/null @@ -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/` - diff --git a/.notes/meeting_notes.md b/.notes/meeting_notes.md deleted file mode 100644 index f3ebdb2..0000000 --- a/.notes/meeting_notes.md +++ /dev/null @@ -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 - diff --git a/.notes/phase1_quickstart.md b/.notes/phase1_quickstart.md deleted file mode 100644 index 05a349c..0000000 --- a/.notes/phase1_quickstart.md +++ /dev/null @@ -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! - - diff --git a/.notes/phase2_quickstart.md b/.notes/phase2_quickstart.md deleted file mode 100644 index 0b1fbf1..0000000 --- a/.notes/phase2_quickstart.md +++ /dev/null @@ -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! - - diff --git a/.notes/project_overview.md b/.notes/project_overview.md deleted file mode 100644 index 028d7c2..0000000 --- a/.notes/project_overview.md +++ /dev/null @@ -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` - diff --git a/.notes/restructure_migration.md b/.notes/restructure_migration.md deleted file mode 100644 index 8e398c2..0000000 --- a/.notes/restructure_migration.md +++ /dev/null @@ -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 - diff --git a/.notes/task_list.md b/.notes/task_list.md deleted file mode 100644 index c24e692..0000000 --- a/.notes/task_list.md +++ /dev/null @@ -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 diff --git a/DEPLOYMENT_CHECKLIST.md b/DEPLOYMENT_CHECKLIST.md index f69deed..8d57d52 100644 --- a/DEPLOYMENT_CHECKLIST.md +++ b/DEPLOYMENT_CHECKLIST.md @@ -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: diff --git a/README.md b/README.md index 503b8f9..8bc15b0 100644 --- a/README.md +++ b/README.md @@ -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**: `` - **Port**: 5432 - **User**: ladmin - **Password**: [Contact administrator for password] **Development Server:** -- **Host**: 10.0.10.121 +- **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]@: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]@: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 '';" 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:@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:@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]@: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]@: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:@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:@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:@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:@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= # 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:@localhost:5432/punimtag # Auth database connection (PostgreSQL - required) -DATABASE_URL_AUTH=postgresql://punimtag:punimtag_password@localhost:5432/punimtag_auth +DATABASE_URL_AUTH=postgresql://punimtag:@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:@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**: `` - **User**: appuser - **Password**: [Contact administrator for password] **Development Database:** -- **Host**: 10.0.10.179 (postgresQA / pve201 VM 109; legacy `.181` is dead) +- **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@:/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** diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..7442606 --- /dev/null +++ b/ROADMAP.md @@ -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`. diff --git a/admin-frontend/.env_example b/admin-frontend/.env.example similarity index 100% rename from admin-frontend/.env_example rename to admin-frontend/.env.example diff --git a/admin-frontend/package-lock.json b/admin-frontend/package-lock.json index d8c6ab0..ffb4527 100644 --- a/admin-frontend/package-lock.json +++ b/admin-frontend/package-lock.json @@ -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", diff --git a/admin-frontend/package.json b/admin-frontend/package.json index 867b5dd..a2bffea 100644 --- a/admin-frontend/package.json +++ b/admin-frontend/package.json @@ -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" } } diff --git a/admin-frontend/src/lib/fastapi-path.test.ts b/admin-frontend/src/lib/fastapi-path.test.ts new file mode 100644 index 0000000..889c33f --- /dev/null +++ b/admin-frontend/src/lib/fastapi-path.test.ts @@ -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/'); + }); +}); diff --git a/admin-frontend/src/lib/media-path.test.ts b/admin-frontend/src/lib/media-path.test.ts new file mode 100644 index 0000000..500f150 --- /dev/null +++ b/admin-frontend/src/lib/media-path.test.ts @@ -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); + }); +}); diff --git a/admin-frontend/vitest.config.ts b/admin-frontend/vitest.config.ts new file mode 100644 index 0000000..6f40787 --- /dev/null +++ b/admin-frontend/vitest.config.ts @@ -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'], + }, +}); diff --git a/docs/DEPLOY_CPANEL_STEP_BY_STEP.md b/docs/DEPLOY_CPANEL_STEP_BY_STEP.md index 32e5432..8fa8c4e 100644 --- a/docs/DEPLOY_CPANEL_STEP_BY_STEP.md +++ b/docs/DEPLOY_CPANEL_STEP_BY_STEP.md @@ -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 diff --git a/docs/DEPLOY_FROM_SCRATCH.md b/docs/DEPLOY_FROM_SCRATCH.md index e343c14..1e46784 100644 --- a/docs/DEPLOY_FROM_SCRATCH.md +++ b/docs/DEPLOY_FROM_SCRATCH.md @@ -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`: diff --git a/e2e/.env.example b/e2e/.env.example new file mode 100644 index 0000000..a901ec7 --- /dev/null +++ b/e2e/.env.example @@ -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= diff --git a/e2e/.gitignore b/e2e/.gitignore new file mode 100644 index 0000000..95885a2 --- /dev/null +++ b/e2e/.gitignore @@ -0,0 +1,3 @@ +.auth/ +.env + diff --git a/e2e/README.md b/e2e/README.md new file mode 100644 index 0000000..619d3d2 --- /dev/null +++ b/e2e/README.md @@ -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). diff --git a/e2e/env-defaults.json b/e2e/env-defaults.json new file mode 100644 index 0000000..8c214da --- /dev/null +++ b/e2e/env-defaults.json @@ -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" +} diff --git a/e2e/env-defaults.ts b/e2e/env-defaults.ts new file mode 100644 index 0000000..a3d5e27 --- /dev/null +++ b/e2e/env-defaults.ts @@ -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, + }; +} diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts new file mode 100644 index 0000000..356acfa --- /dev/null +++ b/e2e/fixtures.ts @@ -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({ + 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 }; diff --git a/e2e/fixtures/tiny.png b/e2e/fixtures/tiny.png new file mode 100644 index 0000000..f37764b Binary files /dev/null and b/e2e/fixtures/tiny.png differ diff --git a/e2e/package-lock.json b/e2e/package-lock.json new file mode 100644 index 0000000..9464c7a --- /dev/null +++ b/e2e/package-lock.json @@ -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" + } + } + } +} diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 0000000..e6a11ea --- /dev/null +++ b/e2e/package.json @@ -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" + } +} diff --git a/e2e/pages/AccountMenu.ts b/e2e/pages/AccountMenu.ts new file mode 100644 index 0000000..06e1a61 --- /dev/null +++ b/e2e/pages/AccountMenu.ts @@ -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 { + await this.click(this.page.getByLabel('Account menu')); + await waitForVisible(this.page.getByRole('button', { name: 'Sign out' })); + } + + async signOut(): Promise { + await this.openMenu(); + await this.click(this.page.getByRole('button', { name: 'Sign out' })); + } + + async openManageUsers(): Promise { + 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 { + await this.click(this.page.getByLabel('Close manage users')); + } +} diff --git a/e2e/pages/LoginPage.ts b/e2e/pages/LoginPage.ts new file mode 100644 index 0000000..ed61113 --- /dev/null +++ b/e2e/pages/LoginPage.ts @@ -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 { + 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 { + 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)'); + } +} diff --git a/e2e/pages/ManageUsersPanel.ts b/e2e/pages/ManageUsersPanel.ts new file mode 100644 index 0000000..775bace --- /dev/null +++ b/e2e/pages/ManageUsersPanel.ts @@ -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 { + 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 { + await this.click(this.actionButton(email, 'first')); + await waitForVisible(this.dialog('Edit User')); + } + + private async setCheckbox(locator: Locator, checked: boolean): Promise { + const isChecked = await locator.isChecked(); + if (isChecked !== checked) { + await this.click(locator); + } + } + + async setActive(active: boolean): Promise { + await this.setCheckbox(this.dialog('Edit User').locator('#edit-active'), active); + } + + async setWriteAccess(enabled: boolean): Promise { + await this.setCheckbox(this.dialog('Edit User').locator('#edit-write-access'), enabled); + } + + async saveEdit(): Promise { + 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 { + 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 { + return (await this.row(email).locator('td').nth(2).innerText()).trim(); + } + + async roleBadgeText(email: string): Promise { + return (await this.row(email).locator('td').nth(3).innerText()).trim(); + } + + async writeAccessText(email: string): Promise { + return (await this.row(email).locator('td').nth(4).innerText()).trim(); + } + + async waitForRowGone(email: string, timeout = 15_000): Promise { + 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 { + 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 })); + } +} diff --git a/e2e/pages/RegisterPage.ts b/e2e/pages/RegisterPage.ts new file mode 100644 index 0000000..55bd2f0 --- /dev/null +++ b/e2e/pages/RegisterPage.ts @@ -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 { + 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 { + 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"]')); + } +} diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts new file mode 100644 index 0000000..04088f9 --- /dev/null +++ b/e2e/playwright.config.ts @@ -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/, + }, + ], +}); diff --git a/e2e/tests/admin.manage-users-actions.spec.ts b/e2e/tests/admin.manage-users-actions.spec.ts new file mode 100644 index 0000000..f4f9b31 --- /dev/null +++ b/e2e/tests/admin.manage-users-actions.spec.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 { + 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); + } + }); +}); diff --git a/e2e/tests/admin.manage-users.spec.ts b/e2e/tests/admin.manage-users.spec.ts new file mode 100644 index 0000000..58386d7 --- /dev/null +++ b/e2e/tests/admin.manage-users.spec.ts @@ -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, + }); + }); +}); diff --git a/e2e/tests/api.auth.spec.ts b/e2e/tests/api.auth.spec.ts new file mode 100644 index 0000000..2e7b38a --- /dev/null +++ b/e2e/tests/api.auth.spec.ts @@ -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); + }); +}); diff --git a/e2e/tests/api.catalog.spec.ts b/e2e/tests/api.catalog.spec.ts new file mode 100644 index 0000000..ddc3f83 --- /dev/null +++ b/e2e/tests/api.catalog.spec.ts @@ -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; + +const TagsList = z.object({ + items: z.array(z.object({ id: z.number() }).passthrough()).min(1), +}); +type TagsList = z.infer; + +const ItemsList = z.object({ items: z.array(z.unknown()) }); +type ItemsList = z.infer; + +/** + * Public / lightly-gated FastAPI surfaces — shape + status contracts via playkit ApiClient. + * Does not require e2e login credentials. + */ +test.describe('api catalog @smoke', () => { + test('GET /api/v1/people returns items list', async ({ api, timings }) => { + const res = await timings.measure('api_people', () => + api.get('/api/v1/people', { expectedStatus: 200, schema: PeopleList }), + ); + 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('/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('/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); + }); +}); diff --git a/e2e/tests/api.fastapi-login.spec.ts b/e2e/tests/api.fastapi-login.spec.ts new file mode 100644 index 0000000..f0ef36d --- /dev/null +++ b/e2e/tests/api.fastapi-login.spec.ts @@ -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; +type UserResponse = z.infer; + +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('/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('/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); + }); +}); diff --git a/e2e/tests/api.health.spec.ts b/e2e/tests/api.health.spec.ts new file mode 100644 index 0000000..8853e69 --- /dev/null +++ b/e2e/tests/api.health.spec.ts @@ -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' }); + }); +}); diff --git a/e2e/tests/api.role-permissions.spec.ts b/e2e/tests/api.role-permissions.spec.ts new file mode 100644 index 0000000..1b48ee5 --- /dev/null +++ b/e2e/tests/api.role-permissions.spec.ts @@ -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 { + 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] }); + }); +}); diff --git a/e2e/tests/auth.setup.ts b/e2e/tests/auth.setup.ts new file mode 100644 index 0000000..d4a72cc --- /dev/null +++ b/e2e/tests/auth.setup.ts @@ -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); + }, +); diff --git a/e2e/tests/auth.signout.spec.ts b/e2e/tests/auth.signout.spec.ts new file mode 100644 index 0000000..1f27796 --- /dev/null +++ b/e2e/tests/auth.signout.spec.ts @@ -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, + }); + }); +}); diff --git a/e2e/tests/forgot-password.mail.spec.ts b/e2e/tests/forgot-password.mail.spec.ts new file mode 100644 index 0000000..d52a560 --- /dev/null +++ b/e2e/tests/forgot-password.mail.spec.ts @@ -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('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(); + }); +}); diff --git a/e2e/tests/gallery.filters.authed.spec.ts b/e2e/tests/gallery.filters.authed.spec.ts new file mode 100644 index 0000000..b639da9 --- /dev/null +++ b/e2e/tests/gallery.filters.authed.spec.ts @@ -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 }); + }); +}); diff --git a/e2e/tests/gallery.search-filters.spec.ts b/e2e/tests/gallery.search-filters.spec.ts new file mode 100644 index 0000000..b27eca1 --- /dev/null +++ b/e2e/tests/gallery.search-filters.spec.ts @@ -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; + +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 }); + }); +}); diff --git a/e2e/tests/idle.logout.spec.ts b/e2e/tests/idle.logout.spec.ts new file mode 100644 index 0000000..da3630f --- /dev/null +++ b/e2e/tests/idle.logout.spec.ts @@ -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+/); + }); +}); diff --git a/e2e/tests/network-errors.spec.ts b/e2e/tests/network-errors.spec.ts new file mode 100644 index 0000000..adf89fd --- /dev/null +++ b/e2e/tests/network-errors.spec.ts @@ -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(); + } + }); +}); diff --git a/e2e/tests/prod.smoke.spec.ts b/e2e/tests/prod.smoke.spec.ts new file mode 100644 index 0000000..8b22524 --- /dev/null +++ b/e2e/tests/prod.smoke.spec.ts @@ -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, + }); + }); +}); diff --git a/e2e/tests/register.verify.mail.spec.ts b/e2e/tests/register.verify.mail.spec.ts new file mode 100644 index 0000000..645e5f4 --- /dev/null +++ b/e2e/tests/register.verify.mail.spec.ts @@ -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('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); + }); +}); diff --git a/e2e/tests/reset-password.flow.spec.ts b/e2e/tests/reset-password.flow.spec.ts new file mode 100644 index 0000000..12fbb0b --- /dev/null +++ b/e2e/tests/reset-password.flow.spec.ts @@ -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('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+/); + }); +}); diff --git a/e2e/tests/upload.smoke.spec.ts b/e2e/tests/upload.smoke.spec.ts new file mode 100644 index 0000000..eb25847 --- /dev/null +++ b/e2e/tests/upload.smoke.spec.ts @@ -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); + }); +}); diff --git a/e2e/tests/viewer.session.spec.ts b/e2e/tests/viewer.session.spec.ts new file mode 100644 index 0000000..4b5b3a7 --- /dev/null +++ b/e2e/tests/viewer.session.spec.ts @@ -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); + }); +}); diff --git a/e2e/tests/viewer.write-gates.spec.ts b/e2e/tests/viewer.write-gates.spec.ts new file mode 100644 index 0000000..eae204f --- /dev/null +++ b/e2e/tests/viewer.write-gates.spec.ts @@ -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) }); + }); +}); diff --git a/e2e/timing-budgets.ts b/e2e/timing-budgets.ts new file mode 100644 index 0000000..08d6ddf --- /dev/null +++ b/e2e/timing-budgets.ts @@ -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; diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json new file mode 100644 index 0000000..977101f --- /dev/null +++ b/e2e/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "types": ["node"] + }, + "include": ["./**/*.ts", "./**/*.json"] +} diff --git a/frontend/.vite/deps/_metadata.json b/frontend/.vite/deps/_metadata.json deleted file mode 100644 index 8e8a44d..0000000 --- a/frontend/.vite/deps/_metadata.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "hash": "ffc8c959", - "configHash": "afa43417", - "lockfileHash": "e3b0c442", - "browserHash": "1131262f", - "optimized": {}, - "chunks": {} -} \ No newline at end of file diff --git a/frontend/.vite/deps/package.json b/frontend/.vite/deps/package.json deleted file mode 100644 index 3dbc1ca..0000000 --- a/frontend/.vite/deps/package.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "type": "module" -} diff --git a/package.json b/package.json index d0eeba9..5d12fe7 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,9 @@ "lint:python": "flake8 backend --max-line-length=100 --ignore=E501,W503,W293,E305,F401,F811,W291,W391,E712,W504,F841,E402,F824,E128,E226,F402,F541,E302,E117,E722 || true", "lint:python:syntax": "find backend -name '*.py' -exec python -m py_compile {} \\;", "test:backend": "export PYTHONPATH=$(pwd) && export SKIP_DEEPFACE_IN_TESTS=1 && ./venv/bin/python3 -m pytest tests/ -v", + "test:e2e": "npm test --prefix e2e", "test:all": "npm run test:backend", + "install:e2e": "npm install --prefix e2e", "ci:local": "npm run lint:all && npm run type-check:viewer && npm run lint:python && npm run test:backend && npm run build:all", "deploy:dev": "npm run build:all && echo '✅ Build complete. Ready for deployment to dev server (10.0.10.121)'", "deploy:dev:prepare": "npm run build:all && mkdir -p deploy/package && cp -r backend deploy/package/ && cp -r admin-frontend/dist deploy/package/admin-frontend-dist && cp -r viewer-frontend/.next deploy/package/viewer-frontend-next && cp requirements.txt deploy/package/ && cp .env.example deploy/package/ && echo '✅ Deployment package prepared in deploy/package/'" diff --git a/scripts/deploy_from_scratch.sh b/scripts/deploy_from_scratch.sh index 07210e6..ed07a63 100755 --- a/scripts/deploy_from_scratch.sh +++ b/scripts/deploy_from_scratch.sh @@ -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 "" diff --git a/tests/conftest.py b/tests/conftest.py index f28a63a..8b7cb7f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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.""" diff --git a/tests/test_api_click_log.py b/tests/test_api_click_log.py new file mode 100644 index 0000000..ccad443 --- /dev/null +++ b/tests/test_api_click_log.py @@ -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 diff --git a/tests/test_api_errors.py b/tests/test_api_errors.py new file mode 100644 index 0000000..ab38814 --- /dev/null +++ b/tests/test_api_errors.py @@ -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 = "" + 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) diff --git a/tests/test_api_integration.py b/tests/test_api_integration.py new file mode 100644 index 0000000..5b1c63e --- /dev/null +++ b/tests/test_api_integration.py @@ -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 diff --git a/tests/test_api_metrics.py b/tests/test_api_metrics.py new file mode 100644 index 0000000..bd06955 --- /dev/null +++ b/tests/test_api_metrics.py @@ -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 diff --git a/tests/test_api_pending_identifications.py b/tests/test_api_pending_identifications.py new file mode 100644 index 0000000..df10043 --- /dev/null +++ b/tests/test_api_pending_identifications.py @@ -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 diff --git a/tests/test_api_pending_linkages.py b/tests/test_api_pending_linkages.py new file mode 100644 index 0000000..7ae3b7a --- /dev/null +++ b/tests/test_api_pending_linkages.py @@ -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"] diff --git a/tests/test_api_reported_photos.py b/tests/test_api_reported_photos.py new file mode 100644 index 0000000..4267baa --- /dev/null +++ b/tests/test_api_reported_photos.py @@ -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 diff --git a/tests/test_api_role_permissions.py b/tests/test_api_role_permissions.py new file mode 100644 index 0000000..6758d3d --- /dev/null +++ b/tests/test_api_role_permissions.py @@ -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 diff --git a/tests/test_api_videos.py b/tests/test_api_videos.py new file mode 100644 index 0000000..757057a --- /dev/null +++ b/tests/test_api_videos.py @@ -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 diff --git a/viewer-frontend/.env.example b/viewer-frontend/.env.example index 6873d58..a16cd44 100644 --- a/viewer-frontend/.env.example +++ b/viewer-frontend/.env.example @@ -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" diff --git a/viewer-frontend/.env_example b/viewer-frontend/.env_example deleted file mode 100644 index 6c31389..0000000 --- a/viewer-frontend/.env_example +++ /dev/null @@ -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" diff --git a/viewer-frontend/README.md b/viewer-frontend/README.md index f72ddbd..1a6005d 100644 --- a/viewer-frontend/README.md +++ b/viewer-frontend/README.md @@ -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 diff --git a/viewer-frontend/app/HomePageContent.tsx b/viewer-frontend/app/HomePageContent.tsx index 544d702..725787f 100644 --- a/viewer-frontend/app/HomePageContent.tsx +++ b/viewer-frontend/app/HomePageContent.tsx @@ -997,8 +997,8 @@ export function HomePageContent({ initialPhotos, people, tags }: HomePageContent />
{loading ? ( -
- +
+
) : ( <> @@ -1046,9 +1046,9 @@ export function HomePageContent({ initialPhotos, people, tags }: HomePageContent /> {/* Infinite scroll sentinel */} -
+
{loadingMore && ( - + )}
diff --git a/viewer-frontend/app/admin/users/ManageUsersContent.tsx b/viewer-frontend/app/admin/users/ManageUsersContent.tsx index 6254c67..3337fe5 100644 --- a/viewer-frontend/app/admin/users/ManageUsersContent.tsx +++ b/viewer-frontend/app/admin/users/ManageUsersContent.tsx @@ -331,13 +331,13 @@ export function ManageUsersContent() {
{error && ( -
+
{error}
)} {successMessage && ( -
+
{successMessage}
)} @@ -389,6 +389,7 @@ export function ManageUsersContent() { variant="ghost" size="sm" onClick={() => openEditDialog(user)} + aria-label={`Edit ${user.email}`} > @@ -403,6 +404,7 @@ export function ManageUsersContent() { setDeleteConfirmOpen(true); }} className="text-red-600 hover:text-red-700" + aria-label={`Deactivate ${user.email}`} > @@ -553,7 +555,7 @@ export function ManageUsersContent() {
void; @@ -30,38 +31,35 @@ export function ManageUsersPageClient({ onClose }: ManageUsersPageClientProps) { const overlayContent = (
-
- {/* Close button */} -
- -
- +
{/* Header */} -
-
+
+
PunimTag -
+
+ +
-

+

Browse our photo collection

@@ -70,7 +68,7 @@ export function ManageUsersPageClient({ onClose }: ManageUsersPageClientProps) {
-
+
); diff --git a/viewer-frontend/app/api/auth/[...nextauth]/route.ts b/viewer-frontend/app/api/auth/[...nextauth]/route.ts index 244c9b4..f70bec7 100644 --- a/viewer-frontend/app/api/auth/[...nextauth]/route.ts +++ b/viewer-frontend/app/api/auth/[...nextauth]/route.ts @@ -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) { diff --git a/viewer-frontend/app/api/auth/register/route.ts b/viewer-frontend/app/api/auth/register/route.ts index f4f1567..c70bef0 100644 --- a/viewer-frontend/app/api/auth/register/route.ts +++ b/viewer-frontend/app/api/auth/register/route.ts @@ -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 } ); diff --git a/viewer-frontend/app/api/auth/verify-email/route.ts b/viewer-frontend/app/api/auth/verify-email/route.ts index b4e17ae..39d2849 100644 --- a/viewer-frontend/app/api/auth/verify-email/route.ts +++ b/viewer-frontend/app/api/auth/verify-email/route.ts @@ -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'); } } - - - - - - - diff --git a/viewer-frontend/app/error.tsx b/viewer-frontend/app/error.tsx new file mode 100644 index 0000000..251131f --- /dev/null +++ b/viewer-frontend/app/error.tsx @@ -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 ( +
+

Something went wrong

+

+ An unexpected error occurred. You can try again, or head back to the homepage. +

+
+ + +
+
+ ); +} diff --git a/viewer-frontend/app/layout.tsx b/viewer-frontend/app/layout.tsx index 952656d..f1d7b0e 100644 --- a/viewer-frontend/app/layout.tsx +++ b/viewer-frontend/app/layout.tsx @@ -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 ( - + - - {children} - + + Skip to main content + + + + {children} + + ); diff --git a/viewer-frontend/app/login/page.tsx b/viewer-frontend/app/login/page.tsx index e9e605b..8769abe 100644 --- a/viewer-frontend/app/login/page.tsx +++ b/viewer-frontend/app/login/page.tsx @@ -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 ( -
+
+
-

+

Sign in to your account -

-

+ +

Or{' '}

{registered && ( -
-

+

+

Account created successfully! Please check your email to confirm your account before signing in.

)} {verified && ( -
-

+

+

Email verified successfully! You can now sign in.

)} {passwordReset && ( -
-

+

+

Password reset successfully! You can now sign in with your new password.

)} {emailNotVerified && ( -
-

+

+

Please verify your email address before signing in. Check your inbox for a confirmation email.

)} {error && ( -
-

{error}

+
+

{error}

)}
@@ -209,23 +211,23 @@ function LoginForm() {
-
+
); } export default function LoginPage() { return ( +
-

+

Sign in to your account -

-

Loading...

+ +

Loading...

-
+ }> diff --git a/viewer-frontend/app/page.tsx b/viewer-frontend/app/page.tsx index 03e02a7..4a0acce 100644 --- a/viewer-frontend/app/page.tsx +++ b/viewer-frontend/app/page.tsx @@ -222,10 +222,10 @@ export default async function HomePage() { ]); return ( -
+
{error ? ( -
+

Error loading photos

{error}

diff --git a/viewer-frontend/app/register/page.tsx b/viewer-frontend/app/register/page.tsx index 8f64cd9..cde8d8f 100644 --- a/viewer-frontend/app/register/page.tsx +++ b/viewer-frontend/app/register/page.tsx @@ -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 ( -

+
+
-

+

Create your account -

-

+ +

Or{' '}

{error && ( -
-

{error}

+
+

{error}

)}
@@ -144,7 +146,7 @@ export default function RegisterPage() { className="mt-1" placeholder="••••••••" /> -

+

Must be at least 6 characters

@@ -177,7 +179,7 @@ export default function RegisterPage() {
-
+
); } diff --git a/viewer-frontend/app/reset-password/page.tsx b/viewer-frontend/app/reset-password/page.tsx index 65b39da..6afafdd 100644 --- a/viewer-frontend/app/reset-password/page.tsx +++ b/viewer-frontend/app/reset-password/page.tsx @@ -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 ( -
+
+
-

+

Password reset successful -

-

+ +

Your password has been reset successfully. Redirecting to login...

-
-

+

+

You can now sign in with your new password.

@@ -95,25 +97,26 @@ function ResetPasswordForm() {
-
+
); } return ( -
+
+
-

+

Reset your password -

-

+ +

Enter your new password below

{error && ( -
-

{error}

+
+

{error}

)}
@@ -132,7 +135,7 @@ function ResetPasswordForm() { className="mt-1" placeholder="••••••••" /> -

+

Must be at least 6 characters

@@ -173,23 +176,23 @@ function ResetPasswordForm() {
-
+
); } export default function ResetPasswordPage() { return ( +
-

+

Reset your password -

-

Loading...

+ +

Loading...

-
+ }> diff --git a/viewer-frontend/app/search/SearchContent.tsx b/viewer-frontend/app/search/SearchContent.tsx index edd02a6..05d755e 100644 --- a/viewer-frontend/app/search/SearchContent.tsx +++ b/viewer-frontend/app/search/SearchContent.tsx @@ -146,8 +146,8 @@ export function SearchContent({ people, tags }: SearchContentProps) { {/* Results */}
{loading ? ( -
- +
+
) : ( <> @@ -192,11 +192,11 @@ export function SearchContent({ people, tags }: SearchContentProps) { ) : hasActiveFilters ? (
-

No photos found matching your filters

+

No photos found matching your filters

) : (
-

Select filters to search photos

+

Select filters to search photos

)} diff --git a/viewer-frontend/app/search/page.tsx b/viewer-frontend/app/search/page.tsx index 379cddd..dda07c2 100644 --- a/viewer-frontend/app/search/page.tsx +++ b/viewer-frontend/app/search/page.tsx @@ -97,7 +97,7 @@ export default async function SearchPage() { ]); return ( -
+

Search Photos @@ -108,8 +108,8 @@ export default async function SearchPage() {

-
Loading search...
+
+
Loading search...
}> diff --git a/viewer-frontend/app/test-images/page.tsx b/viewer-frontend/app/test-images/page.tsx index 4516386..2a1683f 100644 --- a/viewer-frontend/app/test-images/page.tsx +++ b/viewer-frontend/app/test-images/page.tsx @@ -48,7 +48,7 @@ export default function TestImagesPage() { ]; return ( -
+

Image Source Test Page diff --git a/viewer-frontend/app/upload/UploadContent.tsx b/viewer-frontend/app/upload/UploadContent.tsx index edc8adb..dc3460c 100644 --- a/viewer-frontend/app/upload/UploadContent.tsx +++ b/viewer-frontend/app/upload/UploadContent.tsx @@ -350,7 +350,7 @@ export function UploadContent() {

{allSuccess && ( -
+

diff --git a/viewer-frontend/app/upload/UploadPageClient.tsx b/viewer-frontend/app/upload/UploadPageClient.tsx index fd40515..ab0a39a 100644 --- a/viewer-frontend/app/upload/UploadPageClient.tsx +++ b/viewer-frontend/app/upload/UploadPageClient.tsx @@ -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 (

-
- {/* Close button */} -
- -
- +
{/* Header */} -
-
+
+
PunimTag -
+
+ +
-

+

Browse our photo collection

@@ -65,7 +63,7 @@ export function UploadPageClient() {
-
+
); } diff --git a/viewer-frontend/components/ForgotPasswordDialog.tsx b/viewer-frontend/components/ForgotPasswordDialog.tsx index 45babe1..596d815 100644 --- a/viewer-frontend/components/ForgotPasswordDialog.tsx +++ b/viewer-frontend/components/ForgotPasswordDialog.tsx @@ -91,7 +91,7 @@ export function ForgotPasswordDialog({ {success ? (
-
+

Password reset email sent! Please check your inbox and follow the instructions to reset your password.

@@ -105,7 +105,7 @@ export function ForgotPasswordDialog({ ) : (
{error && ( -
+

{error}

)} diff --git a/viewer-frontend/components/Header.tsx b/viewer-frontend/components/Header.tsx index f908e88..08c8c91 100644 --- a/viewer-frontend/components/Header.tsx +++ b/viewer-frontend/components/Header.tsx @@ -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 ( diff --git a/viewer-frontend/components/IdentifyFaceDialog.tsx b/viewer-frontend/components/IdentifyFaceDialog.tsx index f4f9acd..5bc6975 100644 --- a/viewer-frontend/components/IdentifyFaceDialog.tsx +++ b/viewer-frontend/components/IdentifyFaceDialog.tsx @@ -273,7 +273,7 @@ export function IdentifyFaceDialog({
-

+

Please sign in or create an account to continue.

@@ -363,7 +363,7 @@ export function IdentifyFaceDialog({
-

+

Only users with write access can identify faces. Please contact an administrator to request write access.

@@ -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 ? ( -

No people found

+

No people found

) : (
{people @@ -512,7 +512,7 @@ export function IdentifyFaceDialog({ {person.firstName} {person.lastName}
{(person.middleName || person.maidenName) && ( -
+
{[person.middleName, person.maidenName].filter(Boolean).join(' • ')}
)} diff --git a/viewer-frontend/components/IdleLogoutHandler.tsx b/viewer-frontend/components/IdleLogoutHandler.tsx index 77a3c61..f486f49 100644 --- a/viewer-frontend/components/IdleLogoutHandler.tsx +++ b/viewer-frontend/components/IdleLogoutHandler.tsx @@ -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; } - - - - - - - - diff --git a/viewer-frontend/components/LoginDialog.tsx b/viewer-frontend/components/LoginDialog.tsx index 0604613..1fc5925 100644 --- a/viewer-frontend/components/LoginDialog.tsx +++ b/viewer-frontend/components/LoginDialog.tsx @@ -185,21 +185,21 @@ export function LoginDialog({ {registered && ( -
+

Account created successfully! Please check your email to confirm your account before signing in.

)} {searchParams.get('verified') === 'true' && ( -
+

Email verified successfully! You can now sign in.

)} {emailNotVerified && ( -
+

Please verify your email address before signing in. Check your inbox for a confirmation email.

@@ -214,7 +214,7 @@ export function LoginDialog({
)} {error && ( -
+

{error}

)} diff --git a/viewer-frontend/components/PageHeader.tsx b/viewer-frontend/components/PageHeader.tsx index cdc210a..b948490 100644 --- a/viewer-frontend/components/PageHeader.tsx +++ b/viewer-frontend/components/PageHeader.tsx @@ -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 ( -
-
+
+
PunimTag -
+
+
-

+

Browse our photo collection -

+

setReportDialogComment(event.target.value)} maxLength={REPORT_COMMENT_MAX_LENGTH} - className="mt-2 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-900" + className="mt-2 w-full rounded-md border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 text-secondary px-3 py-2 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-900 dark:focus-visible:ring-gray-100" rows={4} placeholder="Add a short note about why this photo should be reviewed..." /> -
+
{`${reportDialogComment.length}/${REPORT_COMMENT_MAX_LENGTH} characters`} - {reportDialogError && {reportDialogError}} + {reportDialogError && {reportDialogError}}
@@ -836,7 +836,7 @@ export function PhotoGrid({
-

+

Please sign in or create an account to continue.

diff --git a/viewer-frontend/components/PhotoViewer.tsx b/viewer-frontend/components/PhotoViewer.tsx index 6c19370..29fae0a 100644 --- a/viewer-frontend/components/PhotoViewer.tsx +++ b/viewer-frontend/components/PhotoViewer.tsx @@ -1,6 +1,7 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; +import { useFocusTrap } from '@/hooks/useFocusTrap'; import { useSession } from 'next-auth/react'; import { useRouter } from 'next/navigation'; import Image from 'next/image'; @@ -32,6 +33,8 @@ export function PhotoViewer({ photo, previousId, nextId }: PhotoViewerProps) { const [loading, setLoading] = useState(false); const { data: session } = useSession(); const isLoggedIn = Boolean(session); + const dialogRef = useRef(null); + useFocusTrap(dialogRef, true); // Keyboard navigation useEffect(() => { @@ -80,7 +83,14 @@ export function PhotoViewer({ photo, previousId, nextId }: PhotoViewerProps) { const tags = (photo as any).PhotoTagLinkage?.map((pt: any) => pt.Tag.tag_name) || []; return ( -
+
{/* Close Button */} + ); + } + + const isDark = resolvedTheme === 'dark'; + const label = isDark ? 'Switch to light mode' : 'Switch to dark mode'; + + return ( + + + + + +

{label}

+
+
+ ); +} diff --git a/viewer-frontend/components/UserMenu.tsx b/viewer-frontend/components/UserMenu.tsx index 6bc0506..408c31a 100644 --- a/viewer-frontend/components/UserMenu.tsx +++ b/viewer-frontend/components/UserMenu.tsx @@ -29,7 +29,17 @@ function UserMenu() { const [popoverOpen, setPopoverOpen] = useState(false); const handleSignOut = async () => { - await signOut({ callbackUrl: '/' }); + // Close any overlays tied to the authenticated state (e.g. Manage Users) + // *before* the session clears, otherwise their effects/portals can unmount + // mid-transition and throw (issue #57). + setPopoverOpen(false); + setManageUsersOpen(false); + try { + await signOut({ callbackUrl: '/' }); + } catch (error) { + console.error('[SIGN_OUT] Failed to sign out cleanly:', error); + router.push('/'); + } }; if (status === 'loading') { diff --git a/viewer-frontend/components/__tests__/ThemeToggle.test.tsx b/viewer-frontend/components/__tests__/ThemeToggle.test.tsx new file mode 100644 index 0000000..a56e645 --- /dev/null +++ b/viewer-frontend/components/__tests__/ThemeToggle.test.tsx @@ -0,0 +1,66 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { ThemeToggle } from '@/components/ThemeToggle'; + +const { useThemeMock } = vi.hoisted(() => ({ useThemeMock: vi.fn() })); + +vi.mock('next-themes', () => ({ + useTheme: useThemeMock, +})); + +describe('ThemeToggle', () => { + afterEach(() => { + useThemeMock.mockReset(); + }); + + it('renders a light-mode toggle with the correct accessible label', () => { + const setTheme = vi.fn(); + useThemeMock.mockReturnValue({ resolvedTheme: 'light', setTheme }); + + render(); + + const button = screen.getByRole('button', { name: 'Switch to dark mode' }); + expect(button).toHaveAttribute('aria-pressed', 'false'); + }); + + it('renders a dark-mode toggle with the correct accessible label', () => { + const setTheme = vi.fn(); + useThemeMock.mockReturnValue({ resolvedTheme: 'dark', setTheme }); + + render(); + + const button = screen.getByRole('button', { name: 'Switch to light mode' }); + expect(button).toHaveAttribute('aria-pressed', 'true'); + }); + + it('calls setTheme with the opposite theme when clicked', async () => { + const user = userEvent.setup(); + const setTheme = vi.fn(); + useThemeMock.mockReturnValue({ resolvedTheme: 'light', setTheme }); + + render(); + await user.click(screen.getByRole('button', { name: 'Switch to dark mode' })); + + expect(setTheme).toHaveBeenCalledWith('dark'); + }); + + it('toggles from dark back to light', async () => { + const user = userEvent.setup(); + const setTheme = vi.fn(); + useThemeMock.mockReturnValue({ resolvedTheme: 'dark', setTheme }); + + render(); + await user.click(screen.getByRole('button', { name: 'Switch to light mode' })); + + expect(setTheme).toHaveBeenCalledWith('light'); + }); + + it('forwards a custom className to the trigger button', () => { + useThemeMock.mockReturnValue({ resolvedTheme: 'light', setTheme: vi.fn() }); + + render(); + + expect(screen.getByRole('button')).toHaveClass('my-toggle-class'); + }); +}); diff --git a/viewer-frontend/components/search/CollapsibleSearch.tsx b/viewer-frontend/components/search/CollapsibleSearch.tsx index e556892..a74e146 100644 --- a/viewer-frontend/components/search/CollapsibleSearch.tsx +++ b/viewer-frontend/components/search/CollapsibleSearch.tsx @@ -36,7 +36,7 @@ export function CollapsibleSearch({ people, tags, filters, onFiltersChange }: Co {isExpanded ? ( <>
- +