Adds pytest coverage for metrics, click_log, role_permissions, videos, pending-identifications, pending-linkages, and reported-photos — the last three required extending conftest with a second (auth) test database + DDL mirroring the Prisma schema-auth.prisma tables, since those endpoints read/write DATABASE_URL_AUTH via raw SQL. pending-photos.py is intentionally left uncovered for now (heavy filesystem/hash-based duplicate-detection logic needs its own mocking strategy) and tracked as a follow-up.
479 lines
14 KiB
Python
479 lines
14 KiB
Python
"""Test configuration and fixtures for PunimTag backend tests."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from typing import Generator
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
from sqlalchemy import create_engine
|
|
from sqlalchemy.orm import sessionmaker, Session
|
|
|
|
# Prevent DeepFace/TensorFlow from loading during tests (causes illegal instruction on some CPUs)
|
|
# Set environment variable BEFORE any backend imports that might trigger DeepFace/TensorFlow
|
|
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, get_auth_db
|
|
|
|
# Test database URL - use environment variable or default
|
|
TEST_DATABASE_URL = os.getenv(
|
|
"DATABASE_URL",
|
|
"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():
|
|
"""Create test database engine and initialize schema."""
|
|
engine = create_engine(TEST_DATABASE_URL, future=True)
|
|
|
|
# Create all tables
|
|
Base.metadata.create_all(bind=engine)
|
|
|
|
yield engine
|
|
|
|
# Cleanup: drop all tables after tests
|
|
Base.metadata.drop_all(bind=engine)
|
|
engine.dispose()
|
|
|
|
|
|
@pytest.fixture(scope="function")
|
|
def test_db_session(test_db_engine) -> Generator[Session, None, None]:
|
|
"""Create a test database session with transaction rollback.
|
|
|
|
Each test gets a fresh session that rolls back after the test completes.
|
|
"""
|
|
connection = test_db_engine.connect()
|
|
transaction = connection.begin()
|
|
session = sessionmaker(bind=connection, autoflush=False, autocommit=False)()
|
|
|
|
yield session
|
|
|
|
# Rollback transaction and close connection
|
|
session.close()
|
|
transaction.rollback()
|
|
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_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
|
|
|
|
# Clear dependency overrides after test
|
|
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."""
|
|
from backend.db.models import User
|
|
from backend.utils.password import hash_password
|
|
from backend.constants.roles import DEFAULT_ADMIN_ROLE
|
|
|
|
user = User(
|
|
username="testadmin",
|
|
email="testadmin@example.com",
|
|
password_hash=hash_password("testpass"),
|
|
full_name="Test Admin",
|
|
is_admin=True,
|
|
is_active=True,
|
|
role=DEFAULT_ADMIN_ROLE,
|
|
)
|
|
test_db_session.add(user)
|
|
test_db_session.commit()
|
|
test_db_session.refresh(user)
|
|
return user
|
|
|
|
|
|
@pytest.fixture
|
|
def regular_user(test_db_session: Session):
|
|
"""Create a regular user for testing."""
|
|
from backend.db.models import User
|
|
from backend.utils.password import hash_password
|
|
from backend.constants.roles import DEFAULT_USER_ROLE
|
|
|
|
user = User(
|
|
username="testuser",
|
|
email="testuser@example.com",
|
|
password_hash=hash_password("testpass"),
|
|
full_name="Test User",
|
|
is_admin=False,
|
|
is_active=True,
|
|
role=DEFAULT_USER_ROLE,
|
|
)
|
|
test_db_session.add(user)
|
|
test_db_session.commit()
|
|
test_db_session.refresh(user)
|
|
return user
|
|
|
|
|
|
@pytest.fixture
|
|
def inactive_user(test_db_session: Session):
|
|
"""Create an inactive user for testing."""
|
|
from backend.db.models import User
|
|
from backend.utils.password import hash_password
|
|
from backend.constants.roles import DEFAULT_USER_ROLE
|
|
|
|
user = User(
|
|
username="inactiveuser",
|
|
email="inactiveuser@example.com",
|
|
password_hash=hash_password("testpass"),
|
|
full_name="Inactive User",
|
|
is_admin=False,
|
|
is_active=False,
|
|
role=DEFAULT_USER_ROLE,
|
|
)
|
|
test_db_session.add(user)
|
|
test_db_session.commit()
|
|
test_db_session.refresh(user)
|
|
return user
|
|
|
|
|
|
@pytest.fixture
|
|
def auth_token(test_client: TestClient, admin_user) -> str:
|
|
"""Get authentication token for admin user."""
|
|
response = test_client.post(
|
|
"/api/v1/auth/login",
|
|
json={"username": "testadmin", "password": "testpass"}
|
|
)
|
|
assert response.status_code == 200
|
|
return response.json()["access_token"]
|
|
|
|
|
|
@pytest.fixture
|
|
def regular_auth_token(test_client: TestClient, regular_user) -> str:
|
|
"""Get authentication token for regular user."""
|
|
response = test_client.post(
|
|
"/api/v1/auth/login",
|
|
json={"username": "testuser", "password": "testpass"}
|
|
)
|
|
assert response.status_code == 200
|
|
return response.json()["access_token"]
|
|
|
|
|
|
@pytest.fixture
|
|
def auth_headers(auth_token: str) -> dict[str, str]:
|
|
"""Get authentication headers for admin user."""
|
|
return {"Authorization": f"Bearer {auth_token}"}
|
|
|
|
|
|
@pytest.fixture
|
|
def regular_auth_headers(regular_auth_token: str) -> dict[str, str]:
|
|
"""Get authentication headers for regular user."""
|
|
return {"Authorization": f"Bearer {regular_auth_token}"}
|
|
|
|
|
|
@pytest.fixture
|
|
def test_photo(test_db_session: Session):
|
|
"""Create a test photo."""
|
|
from backend.db.models import Photo
|
|
from datetime import date
|
|
|
|
photo = Photo(
|
|
path="/test/path/photo1.jpg",
|
|
filename="photo1.jpg",
|
|
date_taken=date(2024, 1, 15),
|
|
processed=True,
|
|
media_type="image",
|
|
)
|
|
test_db_session.add(photo)
|
|
test_db_session.commit()
|
|
test_db_session.refresh(photo)
|
|
return photo
|
|
|
|
|
|
@pytest.fixture
|
|
def test_photo_2(test_db_session: Session):
|
|
"""Create a second test photo."""
|
|
from backend.db.models import Photo
|
|
from datetime import date
|
|
|
|
photo = Photo(
|
|
path="/test/path/photo2.jpg",
|
|
filename="photo2.jpg",
|
|
date_taken=date(2024, 1, 16),
|
|
processed=True,
|
|
media_type="image",
|
|
)
|
|
test_db_session.add(photo)
|
|
test_db_session.commit()
|
|
test_db_session.refresh(photo)
|
|
return photo
|
|
|
|
|
|
@pytest.fixture
|
|
def test_face(test_db_session: Session, test_photo):
|
|
"""Create a test face (unidentified)."""
|
|
from backend.db.models import Face
|
|
import numpy as np
|
|
|
|
# Create a dummy encoding (128-dimensional vector like DeepFace)
|
|
encoding = np.random.rand(128).astype(np.float32).tobytes()
|
|
|
|
face = Face(
|
|
photo_id=test_photo.id,
|
|
person_id=None, # Unidentified
|
|
encoding=encoding,
|
|
location='{"x": 100, "y": 100, "w": 200, "h": 200}',
|
|
quality_score=0.85,
|
|
face_confidence=0.95,
|
|
detector_backend="retinaface",
|
|
model_name="VGG-Face",
|
|
pose_mode="frontal",
|
|
excluded=False,
|
|
)
|
|
test_db_session.add(face)
|
|
test_db_session.commit()
|
|
test_db_session.refresh(face)
|
|
return face
|
|
|
|
|
|
@pytest.fixture
|
|
def test_face_2(test_db_session: Session, test_photo_2):
|
|
"""Create a second test face (unidentified)."""
|
|
from backend.db.models import Face
|
|
import numpy as np
|
|
|
|
# Create a similar encoding (for similarity testing)
|
|
encoding = np.random.rand(128).astype(np.float32).tobytes()
|
|
|
|
face = Face(
|
|
photo_id=test_photo_2.id,
|
|
person_id=None, # Unidentified
|
|
encoding=encoding,
|
|
location='{"x": 150, "y": 150, "w": 200, "h": 200}',
|
|
quality_score=0.80,
|
|
face_confidence=0.90,
|
|
detector_backend="retinaface",
|
|
model_name="VGG-Face",
|
|
pose_mode="frontal",
|
|
excluded=False,
|
|
)
|
|
test_db_session.add(face)
|
|
test_db_session.commit()
|
|
test_db_session.refresh(face)
|
|
return face
|
|
|
|
|
|
@pytest.fixture
|
|
def test_person(test_db_session: Session):
|
|
"""Create a test person."""
|
|
from backend.db.models import Person
|
|
from datetime import date, datetime
|
|
|
|
person = Person(
|
|
first_name="John",
|
|
last_name="Doe",
|
|
middle_name="Middle",
|
|
maiden_name=None,
|
|
date_of_birth=date(1990, 1, 1),
|
|
created_date=datetime.utcnow(),
|
|
)
|
|
test_db_session.add(person)
|
|
test_db_session.commit()
|
|
test_db_session.refresh(person)
|
|
return person
|
|
|
|
|
|
@pytest.fixture
|
|
def identified_face(test_db_session: Session, test_photo, test_person):
|
|
"""Create an identified face (already linked to a person)."""
|
|
from backend.db.models import Face, PersonEncoding
|
|
import numpy as np
|
|
|
|
# Create encoding
|
|
encoding = np.random.rand(128).astype(np.float32).tobytes()
|
|
|
|
face = Face(
|
|
photo_id=test_photo.id,
|
|
person_id=test_person.id,
|
|
encoding=encoding,
|
|
location='{"x": 200, "y": 200, "w": 200, "h": 200}',
|
|
quality_score=0.90,
|
|
face_confidence=0.98,
|
|
detector_backend="retinaface",
|
|
model_name="VGG-Face",
|
|
pose_mode="frontal",
|
|
excluded=False,
|
|
)
|
|
test_db_session.add(face)
|
|
test_db_session.flush()
|
|
|
|
# Create person encoding
|
|
person_encoding = PersonEncoding(
|
|
person_id=test_person.id,
|
|
face_id=face.id,
|
|
encoding=encoding,
|
|
quality_score=0.90,
|
|
detector_backend="retinaface",
|
|
model_name="VGG-Face",
|
|
)
|
|
test_db_session.add(person_encoding)
|
|
test_db_session.commit()
|
|
test_db_session.refresh(face)
|
|
return face
|
|
|