diff --git a/.env.example b/.env.example
index 0143d348..bb6f6b2b 100644
--- a/.env.example
+++ b/.env.example
@@ -49,4 +49,8 @@ FACE_ENCRYPTION_KEY=hkbribvfirirbvivbibvib
CORS_ORIGINS=["http://localhost:3000", "http://localhost:5173", "http://127.0.0.1:3000", "http://127.0.0.1:5173"]
# Firebase
-FIREBASE_CREDENTIALS_PATH=path/to/firebase-credentials.json
\ No newline at end of file
+FIREBASE_CREDENTIALS_PATH=path/to/firebase-credentials.json
+
+# Resend Email Configuration
+RESEND_API_KEY=
+EMAIL_FROM=
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 5593ad32..16566588 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,3 +9,9 @@ db.txt
.venv
multiai-c9380-firebase-adminsdk-fbsvc-cb6e5ce41b.json
+
+# Local Test & Debug Files
+.coverage
+dummy.json
+test_output.log
+*.log
diff --git a/app/core/config.py b/app/core/config.py
index ce424981..b04e3ef9 100644
--- a/app/core/config.py
+++ b/app/core/config.py
@@ -33,6 +33,8 @@ class Settings(BaseSettings):
POSTGRES_HOST: str = "localhost"
POSTGRES_PORT: int = 5432
+ PHOTO_APPROVAL_TIMEOUT_DAYS: int = 7
+
# Mobile auth/session defaults
MOBILE_SESSION_LIMIT: int = 3
MOBILE_SESSION_TTL_SECONDS: int = 180
@@ -57,8 +59,8 @@ class Settings(BaseSettings):
# Face embedding model
FACE_EMBEDDING_MODEL_NAME: str = "buffalo_l"
- FACE_EMBEDDING_PROVIDERS: str = "CPUExecutionProvider"
- FACE_EMBEDDING_CTX_ID: int = -1
+ FACE_EMBEDDING_PROVIDERS: str = "CUDAExecutionProvider,CPUExecutionProvider"
+ FACE_EMBEDDING_CTX_ID: int = 0
FACE_EMBEDDING_DET_WIDTH: int = 640
FACE_EMBEDDING_DET_HEIGHT: int = 640
@@ -73,6 +75,10 @@ class Settings(BaseSettings):
FACE_ENCRYPTION_KEY: str
FIREBASE_CREDENTIALS_PATH: str
+ # Resend Email Configuration
+ RESEND_API_KEY: str = ""
+ EMAIL_FROM: str = "onboarding@resend.dev"
+
model_config = SettingsConfigDict(
env_file=".env",
extra="ignore",
diff --git a/app/core/constant.py b/app/core/constant.py
index b1ffd281..847c1195 100644
--- a/app/core/constant.py
+++ b/app/core/constant.py
@@ -22,6 +22,7 @@ class AuditEventType(str, Enum):
USER_SIGNUP = "user.signup"
USER_LOGIN = "user.login"
USER_LOGOUT = "user.logout"
+ FACE_ENROLLMENT_ATTEMPT = "face_enrollment.attempt"
UPLOAD_REQUEST_CREATED = "upload_request.created"
UPLOAD_REQUEST_APPROVED = "upload_request.approved"
UPLOAD_REQUEST_REJECTED = "upload_request.rejected"
@@ -50,5 +51,11 @@ class AuditEventType(str, Enum):
GOOGLE_DRIVE_FILES_URL = "https://www.googleapis.com/drive/v3/files/{file_id}"
MAX_IMAGE_SIZE = 5 * 1024 * 1024
+MIN_IMAGE_DIM = 64
+MAX_IMAGE_DIM = 4096
MIN_ENROLL_IMAGES = 3
MAX_ENROLL_IMAGES = 5
+
+ENROLL_RATE_LIMIT_MAX = 5
+ENROLL_RATE_LIMIT_WINDOW = 3600
+ENROLL_IN_PROGRESS_TTL_SECONDS = 300
diff --git a/app/deps/rate_limit.py b/app/deps/rate_limit.py
new file mode 100644
index 00000000..a21e2051
--- /dev/null
+++ b/app/deps/rate_limit.py
@@ -0,0 +1,36 @@
+from fastapi import Request, HTTPException
+from typing import Callable
+
+from app.infra.redis import RedisClient
+from app.core.config import settings
+
+def _get_client_ip(request: Request) -> str:
+ if settings.TRUST_PROXY_HEADERS:
+ forwarded_for = request.headers.get("x-forwarded-for")
+ if forwarded_for:
+ return forwarded_for.split(",", maxsplit=1)[0].strip()
+ real_ip = request.headers.get("x-real-ip")
+ if real_ip:
+ return real_ip.strip()
+ return request.client.host if request.client else "127.0.0.1"
+
+
+def RateLimiter(requests: int, window: int) -> Callable:
+ async def _rate_limit_dependency(request: Request) -> None:
+ client_ip = _get_client_ip(request)
+ # For simplicity, IP based rate limit on the endpoint
+ path = request.url.path
+ key = f"rate_limit:{path}:{client_ip}"
+
+ redis = RedisClient.get_instance()
+
+ # Increment request count
+ current = await redis.incr(key)
+ if current == 1:
+ # Set expiry for the window if it's the first request
+ await redis.expire(key, window)
+
+ if current > requests:
+ raise HTTPException(status_code=429, detail="Too Many Requests")
+
+ return _rate_limit_dependency
diff --git a/app/infra/email.py b/app/infra/email.py
new file mode 100644
index 00000000..b2beaf55
--- /dev/null
+++ b/app/infra/email.py
@@ -0,0 +1,61 @@
+import json
+import urllib.request
+import urllib.error
+import asyncio
+from app.core.config import settings
+from app.core.logger import logger
+
+class EmailSender:
+ @staticmethod
+ async def send_otp_email(to_email: str, otp: str) -> bool:
+ if not settings.RESEND_API_KEY:
+ logger.warning("RESEND_API_KEY is not set. Skipping email sending.")
+ # During development without an API key, just log the OTP
+ logger.info("MOCK EMAIL to %s: Your OTP is %s", to_email, otp)
+ return True
+
+ url = "https://api.resend.com/emails"
+ headers = {
+ "Authorization": f"Bearer {settings.RESEND_API_KEY}",
+ "Content-Type": "application/json",
+ "User-Agent": "multAI-Backend/1.0"
+ }
+
+ html_content = f"""
+
+
Bienvenue sur multAI !
+
Voici votre code de vérification :
+
{otp}
+
Ce code est valide pendant 10 minutes.
+
+ """
+
+ data = {
+ "from": settings.EMAIL_FROM,
+ "to": [to_email],
+ "subject": "Votre code de vérification multAI",
+ "html": html_content
+ }
+
+ req = urllib.request.Request(
+ url,
+ data=json.dumps(data).encode("utf-8"),
+ headers=headers,
+ method="POST"
+ )
+
+ def _send() -> bool:
+ try:
+ with urllib.request.urlopen(req, timeout=10) as response:
+ res_body = response.read()
+ logger.info("Email sent via Resend. Response: %s", res_body)
+ return True
+ except urllib.error.HTTPError as e:
+ err_body = e.read()
+ logger.error("Failed to send email via Resend: %s - %s", e.code, err_body)
+ return False
+ except Exception as e:
+ logger.error("Error sending email: %s", str(e))
+ return False
+
+ return await asyncio.to_thread(_send)
diff --git a/app/infra/redis.py b/app/infra/redis.py
index acfaa28d..64852557 100644
--- a/app/infra/redis.py
+++ b/app/infra/redis.py
@@ -64,15 +64,15 @@ async def incr(self, key: RedisKey | str) -> int:
async def sadd(self, key: RedisKey | str, *values: str) -> int:
- result = self._client.sadd(key, *values)
+ result = await self._client.sadd(key, *values) # type: ignore[misc]
return int(cast(int, result))
async def sismember(self, key: RedisKey | str, value: str) -> bool:
- result = self._client.sismember(key, value)
+ result = await self._client.sismember(key, value) # type: ignore[misc]
return int(cast(int, result)) == 1
async def srem(self, key: RedisKey | str, *values: str) -> int:
- result = self._client.srem(key, *values)
+ result = await self._client.srem(key, *values) # type: ignore[misc]
return int(cast(int, result))
diff --git a/app/main.py b/app/main.py
index 1e06dcba..34d64d37 100644
--- a/app/main.py
+++ b/app/main.py
@@ -8,6 +8,7 @@
from fastapi.middleware.cors import CORSMiddleware
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from app.core.config import settings
+from app.infra.database import engine
from app.infra.minio import init_minio_client
from app.infra.nats import NatsClient
from app.infra.redis import RedisClient
@@ -48,6 +49,18 @@ async def dispatch(
+async def _approval_expiry_loop() -> None:
+ while True:
+ await asyncio.sleep(3600)
+ try:
+ async with engine.begin() as conn:
+ from app.container import Container
+ container = Container(conn)
+ await container.photo_approval_service.expire_stale(settings.PHOTO_APPROVAL_TIMEOUT_DAYS)
+ except Exception as exc:
+ logger.warning("Approval expiry task failed: %s", exc)
+
+
MAX_RETRIES = 5
RETRY_DELAY = 2 # seconds
@asynccontextmanager
@@ -77,8 +90,12 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
await NatsClient.connect()
get_face_embedding_service()
+ expiry_task = asyncio.create_task(_approval_expiry_loop())
+
yield
+ expiry_task.cancel()
+ await asyncio.gather(expiry_task, return_exceptions=True)
await RedisClient.get_instance().close()
await NatsClient.close()
diff --git a/app/router/mobile/auth.py b/app/router/mobile/auth.py
index 8a9a1a00..01f6c4a2 100644
--- a/app/router/mobile/auth.py
+++ b/app/router/mobile/auth.py
@@ -7,15 +7,18 @@
from app.core.config import settings
from app.core.constant import AuditEventType
from app.deps.token_auth import MobileUserSchema, get_current_mobile_user
+from app.deps.rate_limit import RateLimiter
from app.schema.request.mobile.auth import (
MobileLoginRequest,
MobileRegisterRequest,
+ RegisterVerifyRequest,
+ ResendOtpRequest,
RefreshTokenRequest,
UpdateDeviceTokenRequest,
InactivateDeviceRequest,
)
-from app.schema.response.mobile.auth import MeResponse, DeviceSchema, MobileAuthResponse, SessionSchema, UserSchema
+from app.schema.response.mobile.auth import MeResponse, DeviceSchema, MobileAuthResponse, SessionSchema, UserSchema, RegisterPendingResponse
router = APIRouter(prefix="/auth")
@@ -33,23 +36,45 @@ def _get_client_ip(request: Request) -> str | None:
return request.client.host if request.client else None
-@router.post("/register", response_model=MobileAuthResponse)
+@router.post("/register", response_model=RegisterPendingResponse, dependencies=[Depends(RateLimiter(requests=5, window=60))])
async def mobile_register(
req: MobileRegisterRequest,
request: Request,
container: Container = Depends(get_container),
-) -> MobileAuthResponse:
+) -> RegisterPendingResponse:
client_ip = _get_client_ip(request)
result = await container.auth_service.mobile_register(container.redis, req, client_ip=client_ip)
+ return result
+
+
+@router.post("/register/resend-otp", response_model=RegisterPendingResponse, dependencies=[Depends(RateLimiter(requests=5, window=60))])
+async def mobile_register_resend_otp(
+ req: ResendOtpRequest,
+ request: Request,
+ container: Container = Depends(get_container),
+) -> RegisterPendingResponse:
+ client_ip = _get_client_ip(request)
+ result = await container.auth_service.mobile_register_resend_otp(container.redis, req.email, client_ip=client_ip)
+ return result
+
+
+@router.post("/register/verify", response_model=MobileAuthResponse, dependencies=[Depends(RateLimiter(requests=10, window=60))])
+async def mobile_register_verify(
+ req: RegisterVerifyRequest,
+ request: Request,
+ container: Container = Depends(get_container),
+) -> MobileAuthResponse:
+ client_ip = _get_client_ip(request)
+ result = await container.auth_service.verify_mobile_register(container.redis, req, client_ip=client_ip)
await container.audit_service.create_record(
event_type=AuditEventType.USER_SIGNUP,
user_id=result.user_id,
- metadata={"endpoint": "register"},
+ metadata={"endpoint": "register_verify"},
)
return result
-@router.post("/login", response_model=MobileAuthResponse)
+@router.post("/login", response_model=MobileAuthResponse, dependencies=[Depends(RateLimiter(requests=5, window=60))])
async def mobile_login(
req: MobileLoginRequest,
request: Request,
diff --git a/app/router/mobile/enrollement.py b/app/router/mobile/enrollement.py
index 1a5f652c..2fcb5307 100644
--- a/app/router/mobile/enrollement.py
+++ b/app/router/mobile/enrollement.py
@@ -1,75 +1,297 @@
+import re
+import time
+import uuid
+from collections.abc import AsyncIterator
+from io import BytesIO
from typing import Annotated, List
-from fastapi import APIRouter, File, UploadFile, Depends
+import filetype # type: ignore[import-untyped]
+import pillow_heif # type: ignore[import-untyped]
+from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
+from fastapi.concurrency import run_in_threadpool
+from PIL import Image
+from pydantic import BaseModel
from app.container import Container, get_container
-from app.deps.token_auth import MobileUserSchema, get_current_mobile_user
-from app.core.exceptions import AppException
from app.core.constant import (
- DEFAULT_CONTENT_TYPE,
+ ENROLL_IN_PROGRESS_TTL_SECONDS,
+ AuditEventType,
+ ENROLL_RATE_LIMIT_MAX,
+ ENROLL_RATE_LIMIT_WINDOW,
IMAGE_ALLOWED_TYPES,
MAX_ENROLL_IMAGES,
MAX_IMAGE_SIZE,
+ MAX_IMAGE_DIM,
MIN_ENROLL_IMAGES,
+ MIN_IMAGE_DIM,
)
+from app.core.exceptions import AppException
+from app.core.logger import logger
+from app.deps.token_auth import MobileUserSchema, get_current_mobile_user
from app.service.face_embedding import FaceImagePayload
-from db.generated.models import User
+
+
+pillow_heif.register_heif_opener()
+
+
+Image.MAX_IMAGE_PIXELS = MAX_IMAGE_DIM * MAX_IMAGE_DIM
+
+
+class EnrollmentResponse(BaseModel):
+ id: uuid.UUID
+
+ model_config = {"from_attributes": True}
+
router = APIRouter()
-@router.post("/enroll")
+def _sanitise_filename(raw: str | None, extension: str) -> str:
+ prefix = str(uuid.uuid4())
+ if not raw:
+ return f"{prefix}.{extension}"
+ name = re.sub(r'[\\/:*?"<>|\x00-\x1f]', "_", raw)
+ name = name.replace("..", "_")
+ name = name.lstrip(".")[:128]
+ return f"{prefix}_{name}"
+
+
+def _validate_dimensions(contents: bytes) -> None:
+
+ try:
+ img = Image.open(BytesIO(contents))
+ w, h = img.size
+ except Exception as e:
+ raise AppException.image_format_error(
+ "File could not be decoded as a valid image"
+ ) from e
+
+ max_pixels = Image.MAX_IMAGE_PIXELS
+
+ if max_pixels is not None and w * h > max_pixels:
+ raise AppException.bad_request(
+ f"Image exceeds maximum allowed resolution of {max_pixels} total pixels."
+ )
+
+ try:
+ img.load()
+ except Exception as e:
+ raise AppException.image_format_error(
+ "File contains corrupted or incomplete pixel data"
+ ) from e
+
+ if w < MIN_IMAGE_DIM or h < MIN_IMAGE_DIM:
+ raise AppException.bad_request(
+ f"Image too small — minimum {MIN_IMAGE_DIM}x{MIN_IMAGE_DIM} px"
+ )
+ if w > MAX_IMAGE_DIM or h > MAX_IMAGE_DIM:
+ raise AppException.bad_request(
+ f"Image too large — maximum {MAX_IMAGE_DIM}x{MAX_IMAGE_DIM} px"
+ )
+
+
+async def read_limited(file: UploadFile, limit: int) -> bytes:
+ chunks: list[bytes] = []
+ total = 0
+ while True:
+ chunk = await file.read(65536)
+ if not chunk:
+ break
+ total += len(chunk)
+ if total > limit:
+ raise AppException.bad_request(
+ f"File exceeds maximum allowed size of {limit} bytes"
+ )
+ chunks.append(chunk)
+
+ await file.seek(0)
+ return b"".join(chunks)
+
+
+def _precheck_upload_headers(file: UploadFile) -> None:
+ content_type = file.content_type
+ if not content_type:
+ raise AppException.image_format_error("Missing image Content-Type header")
+
+ normalized_content_type = content_type.split(";", maxsplit=1)[0].strip().lower()
+ if normalized_content_type not in IMAGE_ALLOWED_TYPES:
+ allowed = ", ".join(IMAGE_ALLOWED_TYPES)
+ raise AppException.image_format_error(
+ f"Unsupported Content-Type header. Allowed types: {allowed}"
+ )
+
+ content_length = file.headers.get("content-length")
+ if content_length is None:
+ return
+
+ try:
+ declared_size = int(content_length)
+ except ValueError as exc:
+ raise AppException.bad_request("Invalid image Content-Length header") from exc
+
+ if declared_size > MAX_IMAGE_SIZE:
+ raise AppException.bad_request(
+ f"File exceeds maximum allowed size of {MAX_IMAGE_SIZE} bytes"
+ )
+
+
+async def _build_face_image_payload(file: UploadFile) -> FaceImagePayload:
+ _precheck_upload_headers(file)
+ contents = await read_limited(file, MAX_IMAGE_SIZE)
+
+ kind = filetype.guess(contents)
+ if kind is None or kind.mime not in IMAGE_ALLOWED_TYPES:
+ raise AppException.image_format_error(
+ f"Unsupported format. Allowed types: {', '.join(IMAGE_ALLOWED_TYPES)}"
+ )
+
+ await run_in_threadpool(_validate_dimensions, contents)
+
+ return FaceImagePayload(
+ filename=_sanitise_filename(file.filename, kind.extension),
+ content_type=kind.mime,
+ bytes=contents,
+ )
+
+
+async def _record_enrollment_audit(
+ *,
+ container: Container,
+ user_id: uuid.UUID,
+ image_count: int,
+ outcome: str,
+ duration_ms: int,
+ error_category: str | None = None,
+) -> None:
+ metadata: dict[str, object] = {
+ "endpoint": "enroll",
+ "image_count": image_count,
+ "outcome": outcome,
+ "duration_ms": duration_ms,
+ }
+ if error_category is not None:
+ metadata["error_category"] = error_category
+
+ try:
+ await container.audit_service.create_record(
+ event_type=AuditEventType.FACE_ENROLLMENT_ATTEMPT,
+ user_id=user_id,
+ metadata=metadata,
+ )
+ except Exception as exc:
+ logger.warning(
+ "Failed to publish enrollment audit for user %s: %s", user_id, exc
+ )
+
+
+def _enrollment_lock_key(user_id: uuid.UUID) -> str:
+ return f"enroll:in_progress:{user_id}"
+
+
+async def _release_enrollment_lock(
+ *,
+ container: Container,
+ lock_key: str,
+ lock_value: str,
+) -> None:
+ try:
+ if await container.redis.get(lock_key) == lock_value:
+ await container.redis.delete(lock_key)
+ except Exception as exc:
+ logger.warning("Failed to release enrollment lock %s: %s", lock_key, exc)
+
+
+@router.post("/enroll", response_model=EnrollmentResponse)
async def enroll_face(
- files: Annotated[
+ files: Annotated[
List[UploadFile],
File(
- description="Upload one or more face images",
- openapi_examples={
- "single_file": {
- "summary": "One file example",
- "description": "Example of uploading one file",
- "value": "example.jpg"
- },
- "multiple_files": {
- "summary": "Multiple files example",
- "description": "Example of uploading multiple files",
- "value": ["face1.png", "face2.png"]
- },
- },
+ description=(
+ f"Between {MIN_ENROLL_IMAGES} and {MAX_ENROLL_IMAGES} face images "
+ f"(JPEG, PNG, HEIC, or HEIF). "
+ f"Each file must be under {MAX_IMAGE_SIZE // (1024 * 1024)} MB "
+ f"and at least {MIN_IMAGE_DIM}x{MIN_IMAGE_DIM} px."
+ ),
),
],
container: Container = Depends(get_container),
user: MobileUserSchema = Depends(get_current_mobile_user),
-) -> User:
-
- if not (MIN_ENROLL_IMAGES <= len(files) <= MAX_ENROLL_IMAGES):
- raise AppException.bad_request(
- f"You must upload between {MIN_ENROLL_IMAGES} and {MAX_ENROLL_IMAGES} images for enrollment."
- )
+) -> EnrollmentResponse:
+ start_time = time.perf_counter()
+ image_count = len(files)
+ lock_key: str | None = None
+ lock_value: str | None = None
+ lock_acquired = False
+ async def image_payloads() -> AsyncIterator[FaceImagePayload]:
+ for file in files:
+ yield await _build_face_image_payload(file)
- image_payloads: list[FaceImagePayload] = []
- for file in files:
- if file.content_type not in IMAGE_ALLOWED_TYPES:
- raise AppException.image_format_error(
- f"File {file.filename} has unsupported format {file.content_type}"
- )
+ try:
+ await container.auth_service.check_rate_limit(
+ redis=container.redis,
+ key=f"rate:enroll:{user.user_id}",
+ max_requests=ENROLL_RATE_LIMIT_MAX,
+ window_seconds=ENROLL_RATE_LIMIT_WINDOW,
+ )
- contents = await file.read()
- if len(contents) > MAX_IMAGE_SIZE:
+ if not (MIN_ENROLL_IMAGES <= image_count <= MAX_ENROLL_IMAGES):
raise AppException.bad_request(
- f"File {file.filename} exceeds maximum size of {MAX_IMAGE_SIZE} bytes"
+ f"You must upload between {MIN_ENROLL_IMAGES} and "
+ f"{MAX_ENROLL_IMAGES} images for enrollment."
)
- payload: FaceImagePayload = FaceImagePayload(
- filename=file.filename or "unknown",
- content_type=file.content_type or DEFAULT_CONTENT_TYPE,
- bytes=contents,
+ lock_key = _enrollment_lock_key(user.user_id)
+ lock_value = str(uuid.uuid4())
+ lock_acquired = await container.redis.set(
+ lock_key,
+ lock_value,
+ expire=ENROLL_IN_PROGRESS_TTL_SECONDS,
+ nx=True,
)
+ if not lock_acquired:
+ raise AppException.conflict(
+ "Enrollment already in progress. Please wait for it to finish."
+ )
- image_payloads.append(payload)
-
- return await container.auth_service.add_embbed_user(
- user.user_id,
- image_payloads,
- )
+ updated_user = await container.auth_service.add_embbed_user(
+ user.user_id,
+ image_payloads(),
+ )
+ await _record_enrollment_audit(
+ container=container,
+ user_id=user.user_id,
+ image_count=image_count,
+ outcome="success",
+ duration_ms=int((time.perf_counter() - start_time) * 1000),
+ )
+ return EnrollmentResponse.model_validate(updated_user)
+ except HTTPException as exc:
+ await _record_enrollment_audit(
+ container=container,
+ user_id=user.user_id,
+ image_count=image_count,
+ outcome="failure",
+ duration_ms=int((time.perf_counter() - start_time) * 1000),
+ error_category=f"http_{exc.status_code}",
+ )
+ raise
+ except Exception as e:
+ await _record_enrollment_audit(
+ container=container,
+ user_id=user.user_id,
+ image_count=image_count,
+ outcome="failure",
+ duration_ms=int((time.perf_counter() - start_time) * 1000),
+ error_category="unexpected_error",
+ )
+ raise AppException.internal_error(
+ "Enrollment failed due to an internal error"
+ ) from e
+ finally:
+ if lock_acquired and lock_key is not None and lock_value is not None:
+ await _release_enrollment_lock(
+ container=container,
+ lock_key=lock_key,
+ lock_value=lock_value,
+ )
diff --git a/app/router/mobile/photos.py b/app/router/mobile/photos.py
index 01bdfe63..00113f97 100644
--- a/app/router/mobile/photos.py
+++ b/app/router/mobile/photos.py
@@ -6,11 +6,12 @@
from app.container import Container, get_container
from app.deps.token_auth import MobileUserSchema, get_current_mobile_user
+from app.deps.rate_limit import RateLimiter
router = APIRouter(prefix="/photos")
-@router.get("")
+@router.get("", dependencies=[Depends(RateLimiter(requests=20, window=60))])
async def list_my_photos(
event_id: UUID | None = Query(default=None),
sort: Literal["asc", "desc"] = Query(default="desc"),
diff --git a/app/router/staff/__init__.py b/app/router/staff/__init__.py
index e17b02c2..35cbe0ec 100644
--- a/app/router/staff/__init__.py
+++ b/app/router/staff/__init__.py
@@ -4,7 +4,7 @@
from app.router.staff.notifications import router as staff_notifications_router
from app.router.staff.uploads import router as staff_uploads_router
-router = APIRouter(prefix="/stuff", tags=["stuff"])
+router = APIRouter(prefix="/staff", tags=["staff"])
router.include_router(staff_drive_router)
router.include_router(staff_notifications_router)
router.include_router(staff_uploads_router)
diff --git a/app/router/web/auth.py b/app/router/web/auth.py
index 0eecad77..7629cf93 100644
--- a/app/router/web/auth.py
+++ b/app/router/web/auth.py
@@ -3,6 +3,7 @@
from fastapi import Response
from app.deps.cookie_auth import get_current_staff_user
+from app.deps.rate_limit import RateLimiter
from app.schema.request.web.auth import WebAuthRequest
from app.schema.response.web.auth import WebAuthResponse
from app.schema.response.web.staff_user import StaffUserSchema
@@ -10,7 +11,7 @@
router = APIRouter(prefix="/auth")
-@router.post("/login", response_model=WebAuthResponse,description="so here both the dahbsoard will authneticate from this endpoitn ")
+@router.post("/login", response_model=WebAuthResponse, description="so here both the dahbsoard will authneticate from this endpoitn ", dependencies=[Depends(RateLimiter(requests=5, window=60))])
async def admin_login(
req: WebAuthRequest,
r:Response,
diff --git a/app/schema/request/mobile/auth.py b/app/schema/request/mobile/auth.py
index b272f2b7..23a13717 100644
--- a/app/schema/request/mobile/auth.py
+++ b/app/schema/request/mobile/auth.py
@@ -63,9 +63,24 @@ class MobileLoginRequest(MobileAuthBaseRequest):
pass
+class RegisterVerifyRequest(MobileAuthBaseRequest):
+ otp: str = Field(..., min_length=6, max_length=6, description="The 6-digit OTP code sent via email")
+
+
+class ResendOtpRequest(BaseModel):
+ email: EmailStr = Field(..., max_length=255)
+
+ @field_validator("email", mode="before")
+ @classmethod
+ def _normalize_email(cls, value: object) -> object:
+ if not isinstance(value, str):
+ return value
+ return value.strip().lower()
+
+
class RefreshTokenRequest(BaseModel):
refresh_token: str
diff --git a/app/schema/response/mobile/auth.py b/app/schema/response/mobile/auth.py
index 154f9a48..e03a0745 100644
--- a/app/schema/response/mobile/auth.py
+++ b/app/schema/response/mobile/auth.py
@@ -25,6 +25,10 @@ class MeResponse(BaseModel):
sessions: Optional[SessionSchema]
+class RegisterPendingResponse(BaseModel):
+ message: str
+ status: str
+ email: str
class MobileAuthResponse(BaseModel):
access_token: str
diff --git a/app/service/face_embedding.py b/app/service/face_embedding.py
index e5333a0d..a571f195 100644
--- a/app/service/face_embedding.py
+++ b/app/service/face_embedding.py
@@ -1,6 +1,7 @@
from __future__ import annotations
import asyncio
+from collections.abc import AsyncIterable, AsyncIterator
from dataclasses import dataclass
from typing import List, Literal, Optional, Sequence, Tuple, TypedDict
@@ -134,20 +135,27 @@ async def compute_average_embedding(
self,
payloads: Sequence[FaceImagePayload],
) -> list[float]:
+ async def iter_payloads() -> AsyncIterator[FaceImagePayload]:
+ for payload in payloads:
+ yield payload
- if not payloads:
- raise AppException.bad_request(
- "At least one image is required for enrollment"
- )
+ return await self.compute_average_embedding_stream(iter_payloads())
+
+ async def compute_average_embedding_stream(
+ self,
+ payloads: AsyncIterable[FaceImagePayload],
+ ) -> list[float]:
+ has_payload = False
embeddings: list[np.ndarray] = []
- for payload in payloads:
+ async for payload in payloads:
+ has_payload = True
image = self._decode_image(payload)
image_rgb = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
# Single detection pass — model.get() already returns embeddings
- faces: list[FaceStub] = await asyncio.to_thread( # type: ignore
+ faces: list[FaceStub] = await asyncio.to_thread( # type: ignore
self.face_embedding.model.get, image_rgb # type: ignore
)
@@ -165,6 +173,11 @@ async def compute_average_embedding(
embeddings.append(face.embedding.astype(np.float32))
+ if not has_payload:
+ raise AppException.bad_request(
+ "At least one image is required for enrollment"
+ )
+
stacked = np.stack(embeddings, axis=0)
averaged = np.mean(stacked, axis=0)
diff --git a/app/service/photo_approval.py b/app/service/photo_approval.py
index dd3f68e3..f3eb809c 100644
--- a/app/service/photo_approval.py
+++ b/app/service/photo_approval.py
@@ -64,6 +64,14 @@ async def decide(
await self._photo_querier.update_photo_status(id=photo_id, status="approved")
return "approved"
+ async def expire_stale(self, timeout_days: int) -> int:
+ count = 0
+ async for _ in self._approval_querier.expire_stale_approvals(timeout_days=timeout_days):
+ count += 1
+ if count:
+ logger.info("Auto-expired %d stale pending photo(s)", count)
+ return count
+
async def _delete_photo_storage(self, photo_id: UUID) -> None:
photo = await self._photo_querier.get_photo_by_id(id=photo_id)
if photo is None:
diff --git a/app/service/staff_drive.py b/app/service/staff_drive.py
index fb83da59..9cb6ba95 100644
--- a/app/service/staff_drive.py
+++ b/app/service/staff_drive.py
@@ -1,3 +1,4 @@
+import asyncio
import base64
import hashlib
import json
@@ -236,44 +237,59 @@ async def import_images_from_drive(
if not selected_files:
return []
+ if len(selected_files) > 1000:
+ raise AppException.bad_request(
+ "Cannot import more than 1000 files at once. Please select fewer files."
+ )
+
access_token = await self.get_access_token_for_staff_user(staff_user.id)
bucket = ImageBucket(f"{DRIVE_BUCKET_PREFIX}/{staff_user.id}")
- results: list[DriveImportResult] = []
+ semaphore = asyncio.Semaphore(10)
- for selected in selected_files:
+ async def process_file(selected: SelectedDriveFile) -> DriveImportResult:
if selected.mime_type and selected.mime_type not in IMAGE_ALLOWED_TYPES:
raise AppException.bad_request(
f"File '{selected.name}' has unsupported type '{selected.mime_type}'. "
f"Allowed: {', '.join(sorted(IMAGE_ALLOWED_TYPES))}"
)
- download = await GoogleDriveClient.download_file(
- access_token=access_token,
- file_id=selected.id,
- )
-
- if len(download.content) > MAX_IMPORT_FILE_SIZE_BYTES:
- raise AppException.bad_request(
- f"File '{selected.name}' exceeds the 20 MB size limit"
+ async with semaphore:
+ download = await GoogleDriveClient.download_file(
+ access_token=access_token,
+ file_id=selected.id,
)
- object_name = self._generate_object_name(selected.name)
- content_type = selected.mime_type or download.metadata.mime_type
+ if len(download.content) > MAX_IMPORT_FILE_SIZE_BYTES:
+ raise AppException.bad_request(
+ f"File '{selected.name}' exceeds the 20 MB size limit"
+ )
- await bucket.put_bytes(
- data=download.content,
- object_name=object_name,
- content_type=content_type,
- filename=selected.name,
- )
+ object_name = self._generate_object_name(selected.name)
+ content_type = selected.mime_type or download.metadata.mime_type
+
+ await bucket.put_bytes(
+ data=download.content,
+ object_name=object_name,
+ content_type=content_type,
+ filename=selected.name,
+ )
- results.append(DriveImportResult(
+ return DriveImportResult(
drive_file_id=selected.id,
original_file_name=selected.name,
minio_bucket=bucket.bucket_name,
minio_object_name=object_name,
minio_object_path=f"{bucket.file_prefix}/{object_name}",
- ))
+ )
+
+ results: list[DriveImportResult] = []
+ # Process in chunks of 50 to prevent creating too many asyncio Task objects in memory
+ chunk_size = 50
+ for i in range(0, len(selected_files), chunk_size):
+ chunk = selected_files[i : i + chunk_size]
+ tasks = [process_file(selected) for selected in chunk]
+ chunk_results = await asyncio.gather(*tasks)
+ results.extend(chunk_results)
return results
diff --git a/app/service/staff_user.py b/app/service/staff_user.py
index 62418184..3a589e45 100644
--- a/app/service/staff_user.py
+++ b/app/service/staff_user.py
@@ -24,8 +24,9 @@ async def create_staff_user(
) -> StaffUser:
try:
hashed_password = hash_password(password)
+ normalized_email = email.strip().lower() if email else None
user = await self.staff_user_querier.create_multi(
- email=email,
+ email=normalized_email,
password=hashed_password,
role=role,
)
@@ -108,10 +109,10 @@ async def admin_login(
email: str,
password: str,
) -> WebAuthResponse:
- print("hello")
- staff: StaffUser | None = await self.staff_user_querier.get_staff_user_by_email(email=email)
+ normalized_email = email.strip().lower()
+ staff: StaffUser | None = await self.staff_user_querier.get_staff_user_by_email(email=normalized_email)
if staff is None or not verify_password(password, staff.password):
- logger.info("admin login failed for email %s", email)
+ logger.info("admin login failed for email %s", normalized_email)
raise AppException.unauthorized("Invalid email or password")
@@ -126,7 +127,7 @@ async def admin_login(
role=staff.role,
)
- async def Get_stuff_user(
+ async def get_staff_user(
self,
stuff_id:uuid.UUID
)->StaffUser:
diff --git a/app/service/user_notification.py b/app/service/user_notification.py
index a269ff17..f8181bc0 100644
--- a/app/service/user_notification.py
+++ b/app/service/user_notification.py
@@ -1,3 +1,4 @@
+import json
from typing import Any
import uuid
@@ -41,7 +42,7 @@ async def create_notification(
notification_record = await self.notification_querier.create_notification(
user_id=user_id,
type=type,
- payload=payload,
+ payload=json.dumps(payload),
)
if notification_record is None:
raise AppException.internal_error("Failed to create user notification")
diff --git a/app/service/users.py b/app/service/users.py
index 2237d8c8..a713b8af 100644
--- a/app/service/users.py
+++ b/app/service/users.py
@@ -1,5 +1,6 @@
from datetime import datetime, timedelta, timezone
import uuid
+from collections.abc import AsyncIterable
from typing import Optional
from sqlalchemy.exc import SQLAlchemyError
@@ -21,8 +22,12 @@
MobileAuthBaseRequest,
MobileLoginRequest,
MobileRegisterRequest,
+ RegisterVerifyRequest,
)
-from app.schema.response.mobile.auth import MobileAuthResponse
+from app.schema.response.mobile.auth import MobileAuthResponse, RegisterPendingResponse
+from app.infra.nats import NatsClient
+import secrets
+import json
from db.generated import user as user_queries
from db.generated import devices as device_queries
from db.generated import session as session_queries
@@ -130,7 +135,7 @@ async def mobile_register(
redis: RedisClient,
req: MobileRegisterRequest,
client_ip: Optional[str] = None,
- ) -> MobileAuthResponse:
+ ) -> RegisterPendingResponse:
logger.info("mobile_register attempt")
max_attempts = settings.RATE_LIMIT_LOGIN_MAX_ATTEMPTS
window = settings.RATE_LIMIT_LOGIN_WINDOW_SECONDS
@@ -153,16 +158,105 @@ async def mobile_register(
if existing_user is not None:
logger.warning("register attempt: email_already_in_use")
raise AppException.conflict("Email already in use; please login instead")
+
hashed = hash_password(req.password)
- logger.info("register attempt: creating_new_user")
+ otp = "".join(secrets.choice("0123456789") for _ in range(6))
+
+ pending_key = f"pending_user:{req.email}"
+ pending_data = {
+ "hashed_password": hashed,
+ }
+
+ # Save in Redis for 10 minutes (600 seconds)
+ await redis.set(pending_key, json.dumps(pending_data), expire=600)
+ await redis.set(f"otp:{req.email}", otp, expire=600)
+
+ # Send to NATS
+ await NatsClient.publish("email.send_otp", json.dumps({"email": req.email, "otp": otp}).encode("utf-8"))
+
+ logger.info("register success, OTP sent")
+ return RegisterPendingResponse(
+ message="OTP sent to email",
+ status="pending_verification",
+ email=req.email
+ )
+
+ async def mobile_register_resend_otp(
+ self,
+ redis: RedisClient,
+ email: str,
+ client_ip: Optional[str] = None,
+ ) -> RegisterPendingResponse:
+ logger.info("resend_otp attempt for %s", email)
+ max_attempts = settings.RATE_LIMIT_LOGIN_MAX_ATTEMPTS
+ window = settings.RATE_LIMIT_LOGIN_WINDOW_SECONDS
+
+ if client_ip:
+ await self.check_rate_limit(
+ redis,
+ f"rate:ip:{client_ip}",
+ max_attempts,
+ window,
+ )
+ await self.check_rate_limit(
+ redis,
+ f"rate:email:{email}",
+ max_attempts,
+ window,
+ )
+
+ pending_key = f"pending_user:{email}"
+ raw_data = await redis.get(pending_key)
+ if not raw_data:
+ raise AppException.not_found("No pending registration found for this email")
+
+ otp = "".join(secrets.choice("0123456789") for _ in range(6))
+
+ # Regenerate OTP with 10 mins TTL, without touching the pending_user TTL
+ await redis.set(f"otp:{email}", otp, expire=600)
+
+ # Send to NATS
+ await NatsClient.publish("email.send_otp", json.dumps({"email": email, "otp": otp}).encode("utf-8"))
+
+ logger.info("resend_otp success, new OTP sent to %s", email)
+ return RegisterPendingResponse(
+ message="New OTP sent to email",
+ status="pending_verification",
+ email=email
+ )
+
+ async def verify_mobile_register(
+ self,
+ redis: RedisClient,
+ req: RegisterVerifyRequest,
+ client_ip: Optional[str] = None,
+ ) -> MobileAuthResponse:
+ otp_key = f"otp:{req.email}"
+ stored_otp = await redis.get(otp_key)
+
+ if not stored_otp or stored_otp != req.otp:
+ raise AppException.unauthorized("Invalid or expired OTP")
+
+ pending_key = f"pending_user:{req.email}"
+ raw_data = await redis.get(pending_key)
+ if not raw_data:
+ raise AppException.unauthorized("Registration session expired")
+
+ data = json.loads(raw_data)
+
try:
- user = await self.user_querier.create_user(email=req.email, hashed_password=hashed)
+ user = await self.user_querier.create_user(email=req.email, hashed_password=data["hashed_password"])
if not user:
raise AppException.internal_error("Failed to create user")
except SQLAlchemyError as exc:
logger.error("Failed to create user: %s", exc)
raise DBException.handle(exc)
- logger.info("register success user_id=%s", user.id)
+
+ # Clean up redis
+ await redis.delete(otp_key)
+ await redis.delete(pending_key)
+
+ logger.info("register verify success user_id=%s", user.id)
return await self._create_mobile_session(
redis=redis,
user=user,
@@ -216,7 +310,6 @@ async def _create_mobile_session(
expiry = Get_expiry_time()
logger.info("session_created session_id=%s user_id=%s", session.id, user_id)
- # Populate Redis auth cache for fast-path validation
await SessionService.cache_session_for_auth(
redis=redis,
session_id=session.id,
@@ -286,14 +379,33 @@ async def logout(
async def add_embbed_user(
self,
user_id: uuid.UUID,
- image_payloads: list[FaceImagePayload],
+ image_payloads: AsyncIterable[FaceImagePayload],
) -> User:
logger.info("Generating face embeddings for user %s", user_id)
- averaging = await self.face_embedding_service.compute_average_embedding(
+ existing = await self.user_querier.get_user_by_id(id=user_id)
+ if not existing:
+ raise AppException.not_found("User not found")
+ if existing.face_embedding is not None:
+ raise AppException.conflict(
+ "User already has an active face enrollment. "
+ "Delete the existing enrollment before re-enrolling."
+ )
+
+ averaging = await self.face_embedding_service.compute_average_embedding_stream(
image_payloads
)
vector_literal = "[" + ", ".join(str(x) for x in averaging) + "]"
+
+ locked_existing = await self.user_querier.get_user_by_id_for_update(id=user_id)
+ if not locked_existing:
+ raise AppException.not_found("User not found")
+ if locked_existing.face_embedding is not None:
+ raise AppException.conflict(
+ "User already has an active face enrollment. "
+ "Delete the existing enrollment before re-enrolling."
+ )
+
user = await self.user_querier.set_user_embedding(
dollar_1=vector_literal,
id=user_id,
@@ -410,7 +522,6 @@ async def delete_user(self, *, redis: RedisClient, user_id: uuid.UUID) -> User:
session_key = constant.RedisKey.UserSessionByUser.value.format(
user_id=user_id
)
- # Best-effort: also invalidate the per-session MobileSessionCache.
raw_session_id = await redis.get(session_key)
if raw_session_id:
try:
@@ -431,15 +542,13 @@ async def block_user(self, *, redis: RedisClient, user_id: uuid.UUID) -> User:
raise AppException.not_found("User not found")
session_key = constant.RedisKey.UserSessionByUser.value.format(user_id=user_id)
- # Best-effort: retrieve the session_id from UserSessionByUser cache to also
- # invalidate the per-session MobileSessionCache entry.
raw_session_id = await redis.get(session_key)
if raw_session_id:
try:
session_id = uuid.UUID(raw_session_id)
await SessionService.delete_session_cache(redis=redis, session_id=session_id)
except (ValueError, Exception):
- pass # non-blocking: session cache will expire naturally
+ pass
await redis.delete(session_key)
return user
@@ -474,9 +583,9 @@ async def check_rate_limit(
) -> None:
"""Enforce rate limiting using Redis INCR + EXPIRE.
- Increments a counter for ``key``. On the first increment the key
+ Increments a counter for ``key``. On the first increment the key
is given a TTL of ``window_seconds`` so the window resets
- automatically. If the counter exceeds ``max_requests`` a 429
+ automatically. If the counter exceeds ``max_requests`` a 429
response is raised with a ``Retry-After`` header.
"""
current_count = await redis.incr(key)
diff --git a/app/worker/email_worker/main.py b/app/worker/email_worker/main.py
new file mode 100644
index 00000000..129d1aba
--- /dev/null
+++ b/app/worker/email_worker/main.py
@@ -0,0 +1,62 @@
+import asyncio
+import json
+
+from app.core.config import settings
+from app.core.logger import logger
+from app.infra.nats import NatsClient
+from app.infra.email import EmailSender
+
+
+async def handle_message(raw_payload: bytes | str) -> None:
+ try:
+ if isinstance(raw_payload, bytes):
+ raw_payload = raw_payload.decode()
+
+ payload = json.loads(raw_payload)
+ email = payload.get("email")
+ otp = payload.get("otp")
+
+ if not email or not otp:
+ logger.error("Invalid email.send_otp payload: %s", raw_payload)
+ return
+
+ success = await EmailSender.send_otp_email(to_email=email, otp=otp)
+ if success:
+ logger.info("Successfully sent OTP email to %s", email)
+ else:
+ logger.error("Failed to send OTP email to %s", email)
+
+ except Exception:
+ logger.exception("Unexpected error in email worker")
+
+
+async def run_worker() -> None:
+ logger.info("Email worker started")
+
+ async def wrapped_handler(msg: bytes | str) -> None:
+ await handle_message(msg)
+
+ # Subscribe to the email.send_otp subject
+ await NatsClient.subscribe("email.send_otp", wrapped_handler)
+
+ # Keep the worker running
+ await asyncio.Event().wait()
+
+
+async def main() -> None:
+ await NatsClient.connect(
+ host=settings.NATS_HOST,
+ port=settings.NATS_PORT,
+ user=settings.NATS_USER,
+ password=settings.NATS_PASSWORD,
+ )
+
+ try:
+ await run_worker()
+ finally:
+ await NatsClient.close()
+ logger.info("Email Worker shutdown")
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git a/app/worker/photo_worker/main.py b/app/worker/photo_worker/main.py
index ba7e9600..59a116bb 100644
--- a/app/worker/photo_worker/main.py
+++ b/app/worker/photo_worker/main.py
@@ -126,6 +126,8 @@ async def _handle_single_face(self, event: PhotoProcessEvent, face: DetectedFace
async def _handle_group_photo(self, event: PhotoProcessEvent, faces: list[DetectedFace]) -> None:
logger.info("Processing group photo %s with %d faces", event.photo_id, len(faces))
+ approvals_created = 0
+
for face_index, face in enumerate(faces):
bbox_json = json.dumps({
"x1": float(face.bbox[0]),
@@ -151,6 +153,8 @@ async def _handle_group_photo(self, event: PhotoProcessEvent, faces: list[Detect
logger.info("No match for face %d in photo %s", face_index, event.photo_id)
continue
+ approvals_created += 1
+
try:
await self._notification_service.create_notification(
user_id=approval.user_id,
@@ -174,6 +178,9 @@ async def _handle_group_photo(self, event: PhotoProcessEvent, faces: list[Detect
approval.user_id, event.photo_id, exc,
)
+ if approvals_created == 0:
+ logger.info("No users matched in group photo %s, leaving as pending", event.photo_id)
+
async def _create_job(self, event: PhotoProcessEvent) -> models.ProcessingJob | None:
if self._pj_querier is None:
diff --git a/db/generated/photo_approvals.py b/db/generated/photo_approvals.py
index f7123928..d3776c39 100644
--- a/db/generated/photo_approvals.py
+++ b/db/generated/photo_approvals.py
@@ -11,6 +11,25 @@
from db.generated import models
+EXPIRE_STALE_APPROVALS = """-- name: expire_stale_approvals \\:many
+WITH stale_photos AS (
+ SELECT id FROM photos
+ WHERE status = 'pending'
+ AND created_at < now() - make_interval(days => :p1::int)
+),
+_update_approvals AS (
+ UPDATE photo_approvals
+ SET decision = 'approved', decided_at = now()
+ WHERE photo_id IN (SELECT id FROM stale_photos)
+ AND decision = 'pending'
+)
+UPDATE photos
+SET status = 'approved'
+WHERE id IN (SELECT id FROM stale_photos)
+RETURNING id
+"""
+
+
CREATE_PHOTO_APPROVAL = """-- name: create_photo_approval \\:one
INSERT INTO photo_approvals (
photo_id,
@@ -49,6 +68,11 @@ class AsyncQuerier:
def __init__(self, conn: sqlalchemy.ext.asyncio.AsyncConnection):
self._conn = conn
+ async def expire_stale_approvals(self, *, timeout_days: int) -> AsyncIterator[uuid.UUID]:
+ result = await self._conn.stream(sqlalchemy.text(EXPIRE_STALE_APPROVALS), {"p1": timeout_days})
+ async for row in result:
+ yield row[0]
+
async def create_photo_approval(self, *, photo_id: uuid.UUID, user_id: uuid.UUID, decision: str) -> Optional[models.PhotoApproval]:
row = (await self._conn.execute(sqlalchemy.text(CREATE_PHOTO_APPROVAL), {"p1": photo_id, "p2": user_id, "p3": decision})).first()
if row is None:
diff --git a/db/generated/photo_faces.py b/db/generated/photo_faces.py
index 507e6c6a..6578ef8d 100644
--- a/db/generated/photo_faces.py
+++ b/db/generated/photo_faces.py
@@ -79,6 +79,7 @@ class InsertPhotoFaceWithApprovalParams:
inserted_match AS (
INSERT INTO face_matches (photo_face_id, user_id, confidence)
SELECT upserted_photo_face.id, :p5, :p6
+ FROM upserted_photo_face
WHERE NOT EXISTS (SELECT 1 FROM existing_match)
RETURNING id
)
diff --git a/db/generated/user.py b/db/generated/user.py
index b2368579..674d4b4a 100644
--- a/db/generated/user.py
+++ b/db/generated/user.py
@@ -55,6 +55,14 @@ class FindClosestUserByEmbeddingRow:
"""
+GET_USER_BY_ID_FOR_UPDATE = """-- name: get_user_by_id_for_update \\:one
+SELECT id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked
+FROM users
+WHERE id = :p1
+FOR UPDATE
+"""
+
+
LIST_USERS = """-- name: list_users \\:many
SELECT id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked
FROM users
@@ -179,6 +187,22 @@ async def get_user_by_id(self, *, id: uuid.UUID) -> Optional[models.User]:
blocked=row[8],
)
+ async def get_user_by_id_for_update(self, *, id: uuid.UUID) -> Optional[models.User]:
+ row = (await self._conn.execute(sqlalchemy.text(GET_USER_BY_ID_FOR_UPDATE), {"p1": id})).first()
+ if row is None:
+ return None
+ return models.User(
+ id=row[0],
+ email=row[1],
+ hashed_password=row[2],
+ created_at=row[3],
+ updated_at=row[4],
+ display_name=row[5],
+ face_embedding=row[6],
+ deleted_at=row[7],
+ blocked=row[8],
+ )
+
async def list_users(self, *, limit: int, offset: int) -> AsyncIterator[models.User]:
result = await self._conn.stream(sqlalchemy.text(LIST_USERS), {"p1": limit, "p2": offset})
async for row in result:
diff --git a/db/queries/photo_approvals.sql b/db/queries/photo_approvals.sql
index ac6b787d..298f1fce 100644
--- a/db/queries/photo_approvals.sql
+++ b/db/queries/photo_approvals.sql
@@ -17,6 +17,23 @@ RETURNING *;
-- name: GetPhotoApprovalsByPhotoId :many
SELECT * FROM photo_approvals WHERE photo_id = $1;
+-- name: ExpireStaleApprovals :many
+WITH stale_photos AS (
+ SELECT id FROM photos
+ WHERE status = 'pending'
+ AND created_at < now() - make_interval(days => $1::int)
+),
+_update_approvals AS (
+ UPDATE photo_approvals
+ SET decision = 'approved', decided_at = now()
+ WHERE photo_id IN (SELECT id FROM stale_photos)
+ AND decision = 'pending'
+)
+UPDATE photos
+SET status = 'approved'
+WHERE id IN (SELECT id FROM stale_photos)
+RETURNING id;
+
-- name: ListApprovalsByUserAndStatus :many
SELECT * FROM photo_approvals
WHERE user_id = $1
diff --git a/db/queries/photo_faces.sql b/db/queries/photo_faces.sql
index a6286d9a..5fab5ce1 100644
--- a/db/queries/photo_faces.sql
+++ b/db/queries/photo_faces.sql
@@ -66,6 +66,7 @@ existing_match AS (
inserted_match AS (
INSERT INTO face_matches (photo_face_id, user_id, confidence)
SELECT upserted_photo_face.id, $5, $6
+ FROM upserted_photo_face
WHERE NOT EXISTS (SELECT 1 FROM existing_match)
RETURNING id
)
diff --git a/db/queries/user.sql b/db/queries/user.sql
index a46577be..fc906dd8 100644
--- a/db/queries/user.sql
+++ b/db/queries/user.sql
@@ -8,6 +8,12 @@ SELECT *
FROM users
WHERE id = $1;
+-- name: GetUserByIdForUpdate :one
+SELECT *
+FROM users
+WHERE id = $1
+FOR UPDATE;
+
-- name: GetUserByEmail :one
SELECT *
FROM users
diff --git a/docker-compose.staging.local.yml b/docker-compose.staging.local.yml
index 135eb75b..0d3c3781 100644
--- a/docker-compose.staging.local.yml
+++ b/docker-compose.staging.local.yml
@@ -14,5 +14,13 @@ services:
dockerfile: Dockerfile
pull_policy: never
+ email-worker:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ pull_policy: never
+ volumes:
+ - ./:/app
+
volumes:
insightface_cache:
diff --git a/docker-compose.staging.yml b/docker-compose.staging.yml
index be3ff4ce..93435d84 100644
--- a/docker-compose.staging.yml
+++ b/docker-compose.staging.yml
@@ -96,6 +96,19 @@ services:
networks:
- multi_network
+ email-worker:
+ image: ghcr.io/microclub-usthb/multai-back:latest
+ container_name: multi_email_worker
+ restart: unless-stopped
+ env_file:
+ - .env.staging
+ depends_on:
+ - nats
+ - redis
+ command: ["uv", "run", "python", "-m", "app.worker.email_worker.main"]
+ networks:
+ - multi_network
+
volumes:
postgres_data:
minio_data:
diff --git a/pyproject.toml b/pyproject.toml
index cb5cd014..67db89d5 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -29,6 +29,9 @@ dependencies = [
"firebase-admin>=6.8.0",
"pywebpush>=2.3.0",
"opencv-python>=4.13.0.92",
+ "filetype>=1.2.0",
+ "pillow-heif>=1.3.0",
+ "sqlalchemy>=2.0.47",
]
[tool.ruff]
@@ -60,7 +63,9 @@ warn_redundant_casts = true
follow_imports = "silent"
files = ["app", "db", "migrations"]
exclude = [
- "db/generated"
+ "db/generated",
+ "tests",
+ "scripts"
]
[dependency-groups]
dev = [
diff --git a/scripts/check_ai_results.py b/scripts/check_ai_results.py
new file mode 100644
index 00000000..fa179436
--- /dev/null
+++ b/scripts/check_ai_results.py
@@ -0,0 +1,23 @@
+import asyncio
+from sqlalchemy.ext.asyncio import create_async_engine
+from app.core.config import settings
+
+async def main():
+ url = f"postgresql+asyncpg://{settings.POSTGRES_USER}:{settings.POSTGRES_PASSWORD}@{settings.POSTGRES_HOST}:{settings.POSTGRES_PORT}/{settings.POSTGRES_DB}"
+ engine = create_async_engine(url)
+
+ async with engine.connect() as conn:
+ import sqlalchemy
+
+ # Check processing jobs
+ jobs = (await conn.execute(sqlalchemy.text("SELECT status, count(*) FROM processing_jobs GROUP BY status"))).fetchall()
+ print("=== PROCESSING JOBS STATUS ===")
+ for j in jobs:
+ print(f"Status: {j[0]}, Count: {j[1]}")
+
+ # Check photo faces
+ faces = (await conn.execute(sqlalchemy.text("SELECT count(*) FROM photo_faces"))).scalar()
+ print("\n=== VISAGES DETECTES ===")
+ print(f"Nombre total de visages isolés et enregistrés par l'IA : {faces}")
+
+asyncio.run(main())
diff --git a/scripts/check_scopes.py b/scripts/check_scopes.py
new file mode 100644
index 00000000..1835786e
--- /dev/null
+++ b/scripts/check_scopes.py
@@ -0,0 +1,17 @@
+import asyncio
+from sqlalchemy.ext.asyncio import create_async_engine
+from app.core.config import settings
+
+async def main():
+ url = f"postgresql+asyncpg://{settings.POSTGRES_USER}:{settings.POSTGRES_PASSWORD}@{settings.POSTGRES_HOST}:{settings.POSTGRES_PORT}/{settings.POSTGRES_DB}"
+ engine = create_async_engine(url)
+
+ async with engine.connect() as conn:
+ import sqlalchemy
+ row = (await conn.execute(sqlalchemy.text("SELECT google_email, scopes FROM staff_drive_connections LIMIT 1"))).fetchone()
+ if row:
+ print(f"Email: {row[0]}, Scopes: {row[1]}")
+ else:
+ print("No connection found")
+
+asyncio.run(main())
diff --git a/scripts/generate_drive_url.py b/scripts/generate_drive_url.py
new file mode 100644
index 00000000..91a58023
--- /dev/null
+++ b/scripts/generate_drive_url.py
@@ -0,0 +1,44 @@
+import asyncio
+from sqlalchemy.ext.asyncio import create_async_engine
+from app.core.config import settings
+from db.generated import stuff_user as staff_queries
+from app.infra.redis import RedisClient
+from app.service.staff_drive import StaffDriveService
+from db.generated import staff_drive_connections as drive_queries
+
+async def main():
+ url = f"postgresql+asyncpg://{settings.POSTGRES_USER}:{settings.POSTGRES_PASSWORD}@{settings.POSTGRES_HOST}:{settings.POSTGRES_PORT}/{settings.POSTGRES_DB}"
+ engine = create_async_engine(url)
+
+ # Init redis
+ RedisClient.init(host=settings.REDIS_HOST, port=settings.REDIS_PORT, password=settings.REDIS_PASSWORD or "")
+ redis = RedisClient.get_instance()
+
+ async with engine.connect() as conn:
+ q = staff_queries.AsyncQuerier(conn)
+ import sqlalchemy
+ row = (await conn.execute(sqlalchemy.text("SELECT id FROM staff_users LIMIT 1"))).fetchone()
+
+ if not row:
+ print("No staff user found! Creating one...")
+ res = await q.create_staff_user(email="testadmin@example.com", hashed_password="pw", display_name="Admin", role="admin")
+ staff_user_id = res.id
+ else:
+ staff_user_id = row[0]
+
+ class DummyUser:
+ pass
+ staff_user = DummyUser()
+ staff_user.id = staff_user_id
+
+ drive_service = StaffDriveService(
+ staff_user_querier=q,
+ drive_connection_querier=drive_queries.AsyncQuerier(conn),
+ redis=redis
+ )
+ url, state = await drive_service.create_connect_url(staff_user)
+ print("========================")
+ print("GOOGLE_AUTH_URL:", url)
+ print("========================")
+
+asyncio.run(main())
diff --git a/scripts/list_drive.py b/scripts/list_drive.py
new file mode 100644
index 00000000..d4940bfc
--- /dev/null
+++ b/scripts/list_drive.py
@@ -0,0 +1,50 @@
+import asyncio
+from sqlalchemy.ext.asyncio import create_async_engine
+from app.core.config import settings
+from db.generated import stuff_user as staff_queries
+from app.infra.redis import RedisClient
+from app.infra.google_drive import GoogleDriveClient
+from app.service.staff_drive import StaffDriveService
+from db.generated import staff_drive_connections as drive_queries
+
+async def main():
+ url = f"postgresql+asyncpg://{settings.POSTGRES_USER}:{settings.POSTGRES_PASSWORD}@{settings.POSTGRES_HOST}:{settings.POSTGRES_PORT}/{settings.POSTGRES_DB}"
+ engine = create_async_engine(url)
+
+ # Init redis
+ try:
+ RedisClient.init(host=settings.REDIS_HOST, port=settings.REDIS_PORT, password=settings.REDIS_PASSWORD or "")
+ except RuntimeError:
+ pass
+ redis = RedisClient.get_instance()
+
+ async with engine.connect() as conn:
+ q = staff_queries.AsyncQuerier(conn)
+ import sqlalchemy
+ row = (await conn.execute(sqlalchemy.text("SELECT id FROM staff_users LIMIT 1"))).fetchone()
+ staff_user_id = row[0]
+
+ class DummyUser:
+ pass
+ staff_user = DummyUser()
+ staff_user.id = staff_user_id
+
+ drive_service = StaffDriveService(
+ staff_user_querier=q,
+ drive_connection_querier=drive_queries.AsyncQuerier(conn),
+ redis=redis
+ )
+
+ access_token = await drive_service.get_access_token_for_staff_user(staff_user_id)
+
+ # List root folders
+ print("=== DOSSIERS ET FICHIERS A LA RACINE DU DRIVE ===")
+ items = await GoogleDriveClient.list_folder_contents(access_token=access_token)
+ for i in items[:20]:
+ type_str = "📁 DOSSIER" if i.mime_type == "application/vnd.google-apps.folder" else "📄 FICHIER"
+ print(f"{type_str} | ID: {i.id} | NOM: {i.name}")
+
+ if not items:
+ print("Aucun fichier trouvé.")
+
+asyncio.run(main())
diff --git a/scripts/test_routes.py b/scripts/test_routes.py
new file mode 100644
index 00000000..f793df6f
--- /dev/null
+++ b/scripts/test_routes.py
@@ -0,0 +1,8 @@
+from fastapi.testclient import TestClient
+from app.main import app
+
+with TestClient(app) as client:
+ resp = client.get("/user/photos")
+ print(f"/user/photos -> {resp.status_code} {resp.text}")
+ resp2 = client.get("/user/photos/")
+ print(f"/user/photos/ -> {resp2.status_code} {resp2.text}")
diff --git a/scripts/trigger_import.py b/scripts/trigger_import.py
new file mode 100644
index 00000000..f3098789
--- /dev/null
+++ b/scripts/trigger_import.py
@@ -0,0 +1,73 @@
+import asyncio
+from sqlalchemy.ext.asyncio import create_async_engine
+from app.core.config import settings
+from db.generated import stuff_user as staff_queries
+from app.infra.redis import RedisClient
+from app.infra.google_drive import GoogleDriveClient
+from app.infra.minio import init_minio_client
+from app.service.staff_drive import StaffDriveService, SelectedDriveFile
+from db.generated import staff_drive_connections as drive_queries
+
+async def main():
+ url = f"postgresql+asyncpg://{settings.POSTGRES_USER}:{settings.POSTGRES_PASSWORD}@{settings.POSTGRES_HOST}:{settings.POSTGRES_PORT}/{settings.POSTGRES_DB}"
+ engine = create_async_engine(url)
+
+ try:
+ RedisClient.init(host=settings.REDIS_HOST, port=settings.REDIS_PORT, password=settings.REDIS_PASSWORD or "")
+ except RuntimeError:
+ pass
+ redis = RedisClient.get_instance()
+
+ # Init minio
+ await init_minio_client(
+ minio_host=settings.MINIO_HOST,
+ minio_port=settings.MINIO_API_PORT,
+ minio_root_user=settings.MINIO_ROOT_USER,
+ minio_root_password=settings.MINIO_ROOT_PASSWORD
+ )
+
+ async with engine.connect() as conn:
+ q = staff_queries.AsyncQuerier(conn)
+ import sqlalchemy
+ row = (await conn.execute(sqlalchemy.text("SELECT id FROM staff_users LIMIT 1"))).fetchone()
+ staff_user_id = row[0]
+
+ class DummyUser:
+ pass
+ staff_user = DummyUser()
+ staff_user.id = staff_user_id
+
+ drive_service = StaffDriveService(
+ staff_user_querier=q,
+ drive_connection_querier=drive_queries.AsyncQuerier(conn),
+ redis=redis
+ )
+
+ access_token = await drive_service.get_access_token_for_staff_user(staff_user_id)
+
+ print("Fetching images from Drive...")
+ items = await GoogleDriveClient.list_folder_contents(access_token=access_token)
+
+ # Filter images
+ image_items = [i for i in items if i.mime_type.startswith("image/")]
+
+ if not image_items:
+ print("No images found to import.")
+ return
+
+ print(f"Found {len(image_items)} images. Initiating import...")
+ selections = [
+ SelectedDriveFile(id=f.id, name=f.name, mime_type=f.mime_type)
+ for f in image_items[:20] # Let's import up to 20 for this test
+ ]
+
+ results = await drive_service.import_images_from_drive(
+ staff_user=staff_user,
+ selected_files=selections,
+ )
+
+ print(f"Successfully imported {len(results)} images to MinIO!")
+ for r in results:
+ print(f" -> {r.original_file_name} stored as {r.minio_object_name}")
+
+asyncio.run(main())
diff --git a/scripts/trigger_photo_worker.py b/scripts/trigger_photo_worker.py
new file mode 100644
index 00000000..b1eba585
--- /dev/null
+++ b/scripts/trigger_photo_worker.py
@@ -0,0 +1,80 @@
+import asyncio
+import json
+import uuid
+from sqlalchemy.ext.asyncio import create_async_engine
+from app.core.config import settings
+from app.infra.redis import RedisClient
+from app.infra.minio import init_minio_client
+from app.infra.nats import NatsClient, NatsSubjects
+
+async def main():
+ url = f"postgresql+asyncpg://{settings.POSTGRES_USER}:{settings.POSTGRES_PASSWORD}@{settings.POSTGRES_HOST}:{settings.POSTGRES_PORT}/{settings.POSTGRES_DB}"
+ engine = create_async_engine(url)
+
+ try:
+ RedisClient.init(host=settings.REDIS_HOST, port=settings.REDIS_PORT, password=settings.REDIS_PASSWORD or "")
+ except RuntimeError:
+ pass
+
+ await init_minio_client(
+ minio_host=settings.MINIO_HOST,
+ minio_port=settings.MINIO_API_PORT,
+ minio_root_user=settings.MINIO_ROOT_USER,
+ minio_root_password=settings.MINIO_ROOT_PASSWORD
+ )
+
+ await NatsClient.connect(
+ host=settings.NATS_HOST,
+ port=settings.NATS_PORT,
+ user=settings.NATS_USER,
+ password=settings.NATS_PASSWORD
+ )
+
+ async with engine.connect() as conn:
+ # staff_queries not used here but kept for context
+ import sqlalchemy
+ row = (await conn.execute(sqlalchemy.text("SELECT id FROM staff_users LIMIT 1"))).fetchone()
+ staff_user_id = row[0]
+
+ event_row = (await conn.execute(sqlalchemy.text("SELECT id FROM events LIMIT 1"))).fetchone()
+ if not event_row:
+ print("Creating dummy event...")
+ ev_id = uuid.uuid4()
+ await conn.execute(sqlalchemy.text("INSERT INTO events (id, title, date, location) VALUES (:id, 'Test Event', now(), 'Test Location')"), {"id": ev_id})
+ event_id = ev_id
+ else:
+ event_id = event_row[0]
+
+ from app.infra.minio import ImageBucket
+ bucket = ImageBucket(f"staff-drive/{staff_user_id}")
+
+ objects = bucket.client.list_objects(bucket.bucket_name, prefix=bucket.file_prefix + "/", recursive=True)
+ count = 0
+ async for obj in objects:
+ storage_key = obj.object_name
+ print(f"Injecting photo {storage_key} into AI pipeline...")
+
+ # Create photo in DB
+ new_id = uuid.uuid4()
+ await conn.execute(
+ sqlalchemy.text("INSERT INTO photos (id, event_id, storage_key, visibility) VALUES (:id, :event_id, :storage_key, 'public')"),
+ {"id": new_id, "event_id": event_id, "storage_key": storage_key}
+ )
+
+ # Publish event
+ await NatsClient.publish(
+ NatsSubjects.PHOTO_PROCESS,
+ json.dumps({
+ "photo_id": str(new_id),
+ "image_ref": storage_key,
+ "event_id": str(event_id)
+ }).encode("utf-8")
+ )
+ count += 1
+ if count >= 20:
+ break
+
+ await conn.commit()
+ print(f"Successfully injected {count} photos to the AI worker!")
+
+asyncio.run(main())
diff --git a/scripts/trigger_upload_request.py b/scripts/trigger_upload_request.py
new file mode 100644
index 00000000..639579e1
--- /dev/null
+++ b/scripts/trigger_upload_request.py
@@ -0,0 +1,117 @@
+import asyncio
+from sqlalchemy.ext.asyncio import create_async_engine
+from app.core.config import settings
+from db.generated import stuff_user as staff_queries
+from db.generated import upload_request_groups as group_queries
+from db.generated import upload_requests as request_queries
+from db.generated import upload_request_photos as request_photo_queries
+from db.generated import photos as photo_queries
+from db.generated import staff_drive_connections as drive_queries
+from db.generated import staff_notifications as notif_queries
+from db.generated import audit as audit_queries
+from app.service.upload_requests import UploadRequestsService
+from app.service.staged_upload_storage import StagedUploadStorageService
+from app.service.staff_drive import StaffDriveService
+from app.service.staff_notifications import StaffNotificationsService
+from app.service.audit import AuditService
+from app.schema.request.staff.uploads import CreateUploadRequestPhotoRequest
+from app.infra.redis import RedisClient
+from app.infra.minio import init_minio_client
+from app.infra.nats import NatsClient
+import uuid
+
+async def main():
+ url = f"postgresql+asyncpg://{settings.POSTGRES_USER}:{settings.POSTGRES_PASSWORD}@{settings.POSTGRES_HOST}:{settings.POSTGRES_PORT}/{settings.POSTGRES_DB}"
+ engine = create_async_engine(url)
+
+ try:
+ RedisClient.init(host=settings.REDIS_HOST, port=settings.REDIS_PORT, password=settings.REDIS_PASSWORD or "")
+ except RuntimeError:
+ pass
+ redis = RedisClient.get_instance()
+
+ await init_minio_client(
+ minio_host=settings.MINIO_HOST,
+ minio_port=settings.MINIO_API_PORT,
+ minio_root_user=settings.MINIO_ROOT_USER,
+ minio_root_password=settings.MINIO_ROOT_PASSWORD
+ )
+
+ await NatsClient.connect(
+ host=settings.NATS_HOST,
+ port=settings.NATS_PORT,
+ user=settings.NATS_USER,
+ password=settings.NATS_PASSWORD
+ )
+
+ async with engine.connect() as conn:
+ q = staff_queries.AsyncQuerier(conn)
+ import sqlalchemy
+ row = (await conn.execute(sqlalchemy.text("SELECT id FROM staff_users LIMIT 1"))).fetchone()
+ staff_user_id = row[0]
+
+ class DummyUser:
+ pass
+ staff_user = DummyUser()
+ staff_user.id = staff_user_id
+ staff_user.role = "multi_team_lead" # Important for approval!
+
+ # Need an event
+ event_row = (await conn.execute(sqlalchemy.text("SELECT id FROM events LIMIT 1"))).fetchone()
+ if not event_row:
+ print("Creating dummy event...")
+ ev_id = uuid.uuid4()
+ await conn.execute(sqlalchemy.text("INSERT INTO events (id, title, date, location) VALUES (:id, 'Test Event', now(), 'Test Location')"), {"id": ev_id})
+ event_id = ev_id
+ else:
+ event_id = event_row[0]
+
+ upload_service = UploadRequestsService(
+ upload_request_group_querier=group_queries.AsyncQuerier(conn),
+ upload_request_querier=request_queries.AsyncQuerier(conn),
+ upload_request_photo_querier=request_photo_queries.AsyncQuerier(conn),
+ photo_querier=photo_queries.AsyncQuerier(conn),
+ staged_upload_storage=StagedUploadStorageService(),
+ staff_drive_service=StaffDriveService(staff_user_querier=q, drive_connection_querier=drive_queries.AsyncQuerier(conn), redis=redis),
+ staff_notifications_service=StaffNotificationsService(notif_queries.AsyncQuerier(conn)),
+ audit_service=AuditService(audit_queries.AsyncQuerier(conn), None),
+ )
+
+ # Get staged photos from minio bucket for this staff user
+ from app.infra.minio import ImageBucket
+ bucket = ImageBucket(f"staff-drive/{staff_user_id}")
+
+ objects = bucket.client.list_objects(bucket.bucket_name, prefix=bucket.file_prefix + "/", recursive=True)
+ photo_inputs = []
+ async for obj in objects:
+ name = obj.object_name.split("/")[-1]
+ photo_inputs.append(CreateUploadRequestPhotoRequest(
+ staged_object_name=name,
+ original_file_name=name,
+ ))
+ if len(photo_inputs) >= 20:
+ break
+
+ if not photo_inputs:
+ print("No staged photos found in minio.")
+ return
+
+ print(f"Submitting {len(photo_inputs)} photos to upload request...")
+
+ req_details = await upload_service.create_upload(
+ event_id=event_id,
+ folder_id="drive-import",
+ photos=photo_inputs,
+ visibility="public",
+ day_number=1,
+ requested_by=staff_user,
+ )
+
+ print(f"Created upload request! ID: {req_details.id}")
+ print("Approving upload request to trigger AI pipeline...")
+
+ await upload_service.approve_request(request_id=req_details.id, approved_by=staff_user)
+
+ print("Done! Photos should now be processed by AI.")
+
+asyncio.run(main())
diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py
new file mode 100644
index 00000000..b783ba4d
--- /dev/null
+++ b/tests/e2e/conftest.py
@@ -0,0 +1,119 @@
+import asyncio
+import os
+import uuid
+from collections.abc import AsyncGenerator
+from pathlib import Path
+
+import pytest
+from sqlalchemy import text
+from sqlalchemy.ext.asyncio import AsyncConnection
+
+from app.core.config import settings
+from app.infra.database import engine
+from app.infra.minio import init_minio_client
+from app.infra.nats import NatsClient
+
+FIXTURE_DIR = Path(__file__).parent.parent / "fixtures" / "images"
+
+# ── guard: only run when explicitly requested ─────────────────────────
+def pytest_configure(config: pytest.Config) -> None:
+ config.addinivalue_line("markers", "e2e: mark test as an end-to-end test")
+
+@pytest.fixture(autouse=True)
+async def setup_infra() -> AsyncGenerator[None, None]:
+ if os.getenv("MULTAI_RUN_E2E") != "1":
+ pytest.skip("set MULTAI_RUN_E2E=1 to run live e2e tests")
+
+ await init_minio_client(
+ minio_host=settings.MINIO_HOST,
+ minio_port=settings.MINIO_API_PORT,
+ minio_root_user=settings.MINIO_ROOT_USER,
+ minio_root_password=settings.MINIO_ROOT_PASSWORD,
+ )
+ await NatsClient.connect()
+ yield
+ await NatsClient.close()
+ NatsClient._nc = None # type: ignore[attr-defined]
+ await engine.dispose()
+
+
+# ── shared helpers ────────────────────────────────────────────────────
+
+async def _seed_event_and_photo(
+ conn: AsyncConnection,
+ *,
+ photo_id: uuid.UUID,
+ storage_key: str,
+) -> uuid.UUID:
+ """Insert a staff user, a new event, and a photo. Returns event_id."""
+ event_id = uuid.uuid4()
+ await conn.execute( # type: ignore[union-attr]
+ text(
+ """
+ INSERT INTO staff_users (id, email, password, role)
+ VALUES ('00000000-0000-0000-0000-000000000001'::uuid,
+ 'e2e@test.com', 'hash', 'admin')
+ ON CONFLICT (id) DO NOTHING
+ """
+ )
+ )
+ event_code = f"E2E-{str(uuid.uuid4())[:6].upper()}"
+ await conn.execute( # type: ignore[union-attr]
+ text(
+ """
+ INSERT INTO events (id, name, event_code, event_date, status, created_by)
+ VALUES (:id, 'E2E Test Event', :code, NOW(), 'draft',
+ '00000000-0000-0000-0000-000000000001'::uuid)
+ """
+ ),
+ {"id": event_id, "code": event_code},
+ )
+ await conn.execute( # type: ignore[union-attr]
+ text(
+ """
+ INSERT INTO photos (id, event_id, storage_key, visibility, status)
+ VALUES (:id, :event_id, :key, 'private', 'pending')
+ """
+ ),
+ {"id": photo_id, "event_id": event_id, "key": storage_key},
+ )
+ return event_id
+
+
+async def _wait_for_job(photo_id: uuid.UUID, timeout_s: int = 60) -> str:
+ """Poll processing_jobs until terminal status. Returns 'completed', 'failed', or 'timeout'."""
+ deadline = asyncio.get_event_loop().time() + timeout_s
+ async with engine.connect() as conn:
+ while asyncio.get_event_loop().time() < deadline:
+ row = (
+ await conn.execute(
+ text(
+ "SELECT status FROM processing_jobs "
+ "WHERE photo_id = :pid AND job_type = 'face_detection'"
+ ),
+ {"pid": photo_id},
+ )
+ ).fetchone()
+ if row and row[0] in ("completed", "failed"):
+ return str(row[0])
+ await asyncio.sleep(1.0)
+ return "timeout"
+
+
+async def _cleanup(
+ conn: AsyncConnection,
+ *,
+ photo_id: uuid.UUID,
+ event_id: uuid.UUID,
+ user_id: str | None = None,
+) -> None:
+ """Delete all rows created during a test, in FK-safe order."""
+ if user_id:
+ await conn.execute(text("DELETE FROM notifications WHERE user_id = :uid"), {"uid": user_id}) # type: ignore[union-attr]
+ await conn.execute(text("DELETE FROM face_matches WHERE user_id = :uid"), {"uid": user_id}) # type: ignore[union-attr]
+ await conn.execute(text("DELETE FROM users WHERE id = :uid"), {"uid": user_id}) # type: ignore[union-attr]
+ await conn.execute(text("DELETE FROM face_matches fm USING photo_faces pf WHERE pf.id = fm.photo_face_id AND pf.photo_id = :pid"), {"pid": photo_id}) # type: ignore[union-attr]
+ await conn.execute(text("DELETE FROM photo_faces WHERE photo_id = :pid"), {"pid": photo_id}) # type: ignore[union-attr]
+ await conn.execute(text("DELETE FROM processing_jobs WHERE photo_id = :pid"), {"pid": photo_id}) # type: ignore[union-attr]
+ await conn.execute(text("DELETE FROM photos WHERE id = :pid"), {"pid": photo_id}) # type: ignore[union-attr]
+ await conn.execute(text("DELETE FROM events WHERE id = :eid"), {"eid": event_id}) # type: ignore[union-attr]
diff --git a/tests/e2e/test_mobile_auth_intent_e2e.py b/tests/e2e/test_mobile_auth_intent_e2e.py
index 4d569784..9bdaa76f 100644
--- a/tests/e2e/test_mobile_auth_intent_e2e.py
+++ b/tests/e2e/test_mobile_auth_intent_e2e.py
@@ -58,16 +58,39 @@ def test_register_with_existing_email_fails(self) -> None:
headers=self.headers,
)
assert response1.status_code == 200
- assert response1.json()["is_new_user"] is True
+ assert response1.json()["status"] == "pending_verification"
- # Second registration with same email fails
+ # Second registration with same email should also succeed and resend OTP
+ # Wait, if they are still pending, it just overwrites the pending data.
+ # But if they are FULLY registered, it returns 409.
+ # Let's verify them first to fully register them.
+ import redis
+ r = redis.Redis(host="localhost", port=6379, decode_responses=True)
+ otp = r.get(f"otp:{email}")
+
+ verify_payload = {
+ "email": email,
+ "password": "ValidPass@123",
+ "otp": otp,
+ "device_name": "TestDevice",
+ "device_type": "android",
+ "device_id": device_id,
+ }
+ verify_response = requests.post(
+ f"{self.base_url}/user/auth/register/verify",
+ json=verify_payload,
+ headers=self.headers,
+ )
+ assert verify_response.status_code == 200
+
+ # Now second registration with same email fails
response2 = requests.post(
f"{self.base_url}/user/auth/register",
json=register_payload,
headers=self.headers,
)
assert response2.status_code == 409
- assert "already" in response2.json()["detail"].lower()
+ assert "already in use" in response2.json()["detail"].lower()
def test_register_then_login_succeeds(self) -> None:
"""Test full flow: register then login."""
@@ -89,8 +112,28 @@ def test_register_then_login_succeeds(self) -> None:
headers=self.headers,
)
assert register_response.status_code == 200
- assert register_response.json()["is_new_user"] is True
- register_token = register_response.json()["access_token"]
+ assert register_response.json()["status"] == "pending_verification"
+
+ import redis
+ r = redis.Redis(host="localhost", port=6379, decode_responses=True)
+ otp = r.get(f"otp:{email}")
+
+ verify_payload = {
+ "email": email,
+ "password": password,
+ "otp": otp,
+ "device_name": "TestDevice",
+ "device_type": "android",
+ "device_id": device_id,
+ }
+ verify_response = requests.post(
+ f"{self.base_url}/user/auth/register/verify",
+ json=verify_payload,
+ headers=self.headers,
+ )
+ assert verify_response.status_code == 200
+ assert verify_response.json()["is_new_user"] is True
+ register_token = verify_response.json()["access_token"]
# Login with same credentials
login_payload = {
@@ -134,6 +177,25 @@ def test_login_with_wrong_password_fails(self) -> None:
)
assert register_response.status_code == 200
+ import redis
+ r = redis.Redis(host="localhost", port=6379, decode_responses=True)
+ otp = r.get(f"otp:{email}")
+
+ verify_payload = {
+ "email": email,
+ "password": password,
+ "otp": otp,
+ "device_name": "TestDevice",
+ "device_type": "android",
+ "device_id": device_id,
+ }
+ verify_response = requests.post(
+ f"{self.base_url}/user/auth/register/verify",
+ json=verify_payload,
+ headers=self.headers,
+ )
+ assert verify_response.status_code == 200
+
# Try to login with wrong password
login_payload = {
"email": email,
diff --git a/tests/e2e/test_mobile_auth_request_validation_e2e.py b/tests/e2e/test_mobile_auth_request_validation_e2e.py
index 79907c00..96c7f0e2 100644
--- a/tests/e2e/test_mobile_auth_request_validation_e2e.py
+++ b/tests/e2e/test_mobile_auth_request_validation_e2e.py
@@ -40,7 +40,8 @@ def test_live_register_login_rejects_empty_required_text_fields(
payload = _valid_payload()
payload[field] = value
- response = httpx.post(url, json=payload, timeout=10.0)
+ headers = {"X-Forwarded-For": f"203.0.113.{uuid.uuid4().int % 250 + 1}"}
+ response = httpx.post(url, json=payload, headers=headers, timeout=10.0)
assert response.status_code == 422
@@ -50,6 +51,7 @@ def test_live_register_login_rejects_padded_short_password(url: str) -> None:
payload = _valid_payload()
payload["password"] = " a"
- response = httpx.post(url, json=payload, timeout=10.0)
+ headers = {"X-Forwarded-For": f"203.0.113.{uuid.uuid4().int % 250 + 1}"}
+ response = httpx.post(url, json=payload, headers=headers, timeout=10.0)
assert response.status_code == 422
diff --git a/tests/e2e/test_photo_ai_edge_cases.py b/tests/e2e/test_photo_ai_edge_cases.py
new file mode 100644
index 00000000..ed3e0766
--- /dev/null
+++ b/tests/e2e/test_photo_ai_edge_cases.py
@@ -0,0 +1,212 @@
+import json
+import uuid
+
+from sqlalchemy import text
+
+from app.infra.database import engine
+from app.infra.minio import Bucket, IMAGES_BUCKET_NAME
+from app.infra.nats import NatsClient, NatsSubjects
+
+from tests.e2e.conftest import _seed_event_and_photo, _wait_for_job, _cleanup, FIXTURE_DIR
+
+# ── tests ─────────────────────────────────────────────────────────────
+
+
+async def test_photo_ai_pipeline_detects_0_faces() -> None:
+ """Photo with no faces → auto-approved, visibility set to public."""
+ photo_id = uuid.uuid4()
+ storage_key = f"e2e_noface_{photo_id}.jpg"
+ image_path = FIXTURE_DIR / "noface.jpg"
+ assert image_path.exists(), f"Fixture not found: {image_path}"
+
+ bucket = Bucket(IMAGES_BUCKET_NAME, "")
+ await bucket.put_bytes(
+ object_name=storage_key,
+ data=image_path.read_bytes(),
+ content_type="image/jpeg",
+ )
+ async with engine.begin() as conn:
+ event_id = await _seed_event_and_photo(
+ conn, photo_id=photo_id, storage_key=storage_key
+ )
+
+ payload = {"photo_id": str(photo_id), "image_ref": storage_key, "event_id": str(event_id)}
+ await NatsClient.publish(NatsSubjects.PHOTO_PROCESS, json.dumps(payload).encode("utf-8"))
+
+ try:
+ final_status = await _wait_for_job(photo_id)
+ assert final_status == "completed", f"Job ended with: {final_status}"
+
+ async with engine.connect() as conn:
+ row = (
+ await conn.execute(
+ text("SELECT status, visibility FROM photos WHERE id = :pid"),
+ {"pid": photo_id},
+ )
+ ).fetchone()
+ assert row is not None
+ assert row[0] == "approved", f"Expected 'approved', got {row[0]}"
+ assert row[1] == "public", f"Expected visibility 'public', got {row[1]}"
+ finally:
+ async with engine.begin() as conn:
+ await _cleanup(conn, photo_id=photo_id, event_id=event_id)
+ try:
+ await bucket.delete(storage_key)
+ except Exception:
+ pass
+
+
+async def test_photo_ai_pipeline_detects_multiple_faces() -> None:
+ """Group photo (multiple faces) → status stays 'pending' awaiting approval."""
+ photo_id = uuid.uuid4()
+ storage_key = f"e2e_group_{photo_id}.jpg"
+ image_path = FIXTURE_DIR / "group.jpg"
+ assert image_path.exists(), f"Fixture not found: {image_path}"
+
+ bucket = Bucket(IMAGES_BUCKET_NAME, "")
+ await bucket.put_bytes(
+ object_name=storage_key,
+ data=image_path.read_bytes(),
+ content_type="image/jpeg",
+ )
+ async with engine.begin() as conn:
+ event_id = await _seed_event_and_photo(
+ conn, photo_id=photo_id, storage_key=storage_key
+ )
+
+ payload = {"photo_id": str(photo_id), "image_ref": storage_key, "event_id": str(event_id)}
+ await NatsClient.publish(NatsSubjects.PHOTO_PROCESS, json.dumps(payload).encode("utf-8"))
+
+ try:
+ final_status = await _wait_for_job(photo_id)
+ assert final_status == "completed", f"Job ended with: {final_status}"
+
+ async with engine.connect() as conn:
+ photo_status = (
+ await conn.execute(
+ text("SELECT status FROM photos WHERE id = :pid"),
+ {"pid": photo_id},
+ )
+ ).scalar()
+ # Group photo → pending because multiple unverified faces require human approval
+ assert photo_status == "pending", f"Expected 'pending', got {photo_status}"
+ finally:
+ async with engine.begin() as conn:
+ await _cleanup(conn, photo_id=photo_id, event_id=event_id)
+ try:
+ await bucket.delete(storage_key)
+ except Exception:
+ pass
+
+
+async def test_photo_ai_pipeline_matched_user() -> None:
+ """Single face matching an enrolled user → 'approved' + notification created."""
+ from app.service.face_embedding import FaceEmbeddingService, FaceImagePayload
+
+ photo_id = uuid.uuid4()
+ storage_key = f"e2e_matched_{photo_id}.jpg"
+ image_path = FIXTURE_DIR / "face.jpg"
+ assert image_path.exists(), f"Fixture not found: {image_path}"
+
+ image_bytes = image_path.read_bytes()
+
+ # Pre-compute the embedding so we can plant a matching user
+ face_service = FaceEmbeddingService()
+ payload_face = FaceImagePayload(
+ filename="face.jpg", content_type="image/jpeg", bytes=image_bytes
+ )
+ faces = await face_service.detect_faces(payload_face)
+ assert len(faces) == 1, f"Expected exactly 1 face in face.jpg, got {len(faces)}"
+ embedding_literal = "[" + ", ".join(str(x) for x in faces[0].embedding) + "]"
+
+ matched_user_id = "00000000-0000-0000-0000-000000000002"
+
+ bucket = Bucket(IMAGES_BUCKET_NAME, "")
+ await bucket.put_bytes(
+ object_name=storage_key, data=image_bytes, content_type="image/jpeg"
+ )
+
+ async with engine.begin() as conn:
+ event_id = await _seed_event_and_photo(
+ conn, photo_id=photo_id, storage_key=storage_key
+ )
+ # Clean up any previous failed run artifacts for this user
+ await conn.execute(
+ text("DELETE FROM notifications WHERE user_id = :uid"),
+ {"uid": matched_user_id},
+ )
+ await conn.execute(
+ text("DELETE FROM face_matches WHERE user_id = :uid"),
+ {"uid": matched_user_id},
+ )
+ await conn.execute(
+ text("DELETE FROM users WHERE id = :uid"), {"uid": matched_user_id}
+ )
+ # Insert a user with the exact same embedding
+ await conn.execute(
+ text(
+ """
+ INSERT INTO users (id, email, hashed_password, face_embedding)
+ VALUES (:uid, 'matched@test.com', 'hash', :emb)
+ """
+ ),
+ {"uid": matched_user_id, "emb": embedding_literal},
+ )
+
+ payload = {"photo_id": str(photo_id), "image_ref": storage_key, "event_id": str(event_id)}
+ await NatsClient.publish(NatsSubjects.PHOTO_PROCESS, json.dumps(payload).encode("utf-8"))
+
+ try:
+ final_status = await _wait_for_job(photo_id)
+ assert final_status == "completed", f"Job ended with: {final_status}"
+
+ async with engine.connect() as conn:
+ photo_status = (
+ await conn.execute(
+ text("SELECT status FROM photos WHERE id = :pid"),
+ {"pid": photo_id},
+ )
+ ).scalar()
+ assert photo_status == "approved", (
+ f"Expected 'approved', got {photo_status}"
+ )
+
+ matched_db_user = (
+ await conn.execute(
+ text(
+ """
+ SELECT fm.user_id
+ FROM face_matches fm
+ JOIN photo_faces pf ON pf.id = fm.photo_face_id
+ WHERE pf.photo_id = :pid
+ """
+ ),
+ {"pid": photo_id},
+ )
+ ).scalar()
+ assert str(matched_db_user) == matched_user_id, (
+ f"Expected matched user {matched_user_id}, got {matched_db_user}"
+ )
+
+ notif_count = (
+ await conn.execute(
+ text(
+ "SELECT count(*) FROM notifications "
+ "WHERE user_id = :uid AND type = 'face_match'"
+ ),
+ {"uid": matched_user_id},
+ )
+ ).scalar()
+ assert notif_count == 1, f"Expected 1 notification, got {notif_count}"
+ finally:
+ async with engine.begin() as conn:
+ await _cleanup(
+ conn,
+ photo_id=photo_id,
+ event_id=event_id,
+ user_id=matched_user_id,
+ )
+ try:
+ await bucket.delete(storage_key)
+ except Exception:
+ pass
diff --git a/tests/e2e/test_photo_ai_load.py b/tests/e2e/test_photo_ai_load.py
new file mode 100644
index 00000000..c3ca9c1e
--- /dev/null
+++ b/tests/e2e/test_photo_ai_load.py
@@ -0,0 +1,218 @@
+import asyncio
+import json
+import os
+import random
+import uuid
+from collections.abc import AsyncGenerator
+from pathlib import Path
+
+import pytest
+from sqlalchemy import text
+from sqlalchemy.ext.asyncio import AsyncConnection
+
+from app.core.config import settings
+from app.infra.database import engine
+from app.infra.minio import Bucket, IMAGES_BUCKET_NAME, init_minio_client
+from app.infra.nats import NatsClient, NatsSubjects
+
+# ── guard: only run when explicitly requested ─────────────────────────
+pytestmark = [
+ pytest.mark.e2e,
+ pytest.mark.asyncio,
+ pytest.mark.skipif(
+ os.getenv("MULTAI_RUN_E2E") != "1",
+ reason="set MULTAI_RUN_E2E=1 to run live e2e tests",
+ ),
+]
+
+FIXTURE_DIR = Path(__file__).parent.parent / "fixtures" / "images"
+
+
+@pytest.fixture(scope="function")
+async def setup_infra() -> AsyncGenerator[None, None]:
+ await init_minio_client(
+ minio_host=settings.MINIO_HOST,
+ minio_port=settings.MINIO_API_PORT,
+ minio_root_user=settings.MINIO_ROOT_USER,
+ minio_root_password=settings.MINIO_ROOT_PASSWORD,
+ )
+ await NatsClient.connect()
+ yield
+ await NatsClient.close()
+ NatsClient._nc = None # type: ignore[attr-defined]
+ await engine.dispose()
+
+
+async def _setup_event(conn: AsyncConnection) -> uuid.UUID:
+ event_id = uuid.uuid4()
+ await conn.execute( # type: ignore[union-attr]
+ text(
+ """
+ INSERT INTO staff_users (id, email, password, role)
+ VALUES ('00000000-0000-0000-0000-000000000001'::uuid,
+ 'e2e_load@test.com', 'hash', 'admin')
+ ON CONFLICT (id) DO NOTHING
+ """
+ )
+ )
+ event_code = f"LOAD-{str(uuid.uuid4())[:6].upper()}"
+ await conn.execute( # type: ignore[union-attr]
+ text(
+ """
+ INSERT INTO events (id, name, event_code, event_date, status, created_by)
+ VALUES (:id, 'E2E Load Event', :code, NOW(), 'draft',
+ '00000000-0000-0000-0000-000000000001'::uuid)
+ """
+ ),
+ {"id": event_id, "code": event_code},
+ )
+ return event_id
+
+
+async def _wait_for_jobs(
+ photo_ids: list[uuid.UUID], timeout: int = 180
+) -> dict[str, int]:
+ """Return a status→count dict once all jobs have a terminal status."""
+ deadline = asyncio.get_event_loop().time() + timeout
+ async with engine.connect() as conn:
+ while asyncio.get_event_loop().time() < deadline:
+ rows = (
+ await conn.execute(
+ text(
+ """
+ SELECT status, count(*)
+ FROM processing_jobs
+ WHERE photo_id = ANY(:ids)
+ AND job_type = 'face_detection'
+ GROUP BY status
+ """
+ ),
+ {"ids": photo_ids},
+ )
+ ).fetchall()
+ status_counts: dict[str, int] = {row[0]: int(row[1]) for row in rows}
+ total = sum(status_counts.values())
+ if total == len(photo_ids):
+ completed = status_counts.get("completed", 0)
+ failed = status_counts.get("failed", 0)
+ if completed + failed == len(photo_ids):
+ return status_counts
+ await asyncio.sleep(2.0)
+ return {"timeout": 1}
+
+
+async def test_photo_ai_load_20_photos(setup_infra: None) -> None: # noqa: ARG001
+ """Process 20 photos concurrently; expect 0 failures within 3 minutes."""
+ image_files = ["face.jpg", "group.jpg", "noface.jpg"]
+ image_contents: dict[str, bytes] = {}
+ for img in image_files:
+ path = FIXTURE_DIR / img
+ assert path.exists(), f"Fixture not found: {path}"
+ image_contents[img] = path.read_bytes()
+
+ num_photos = 20
+ photo_tasks: list[dict[str, object]] = []
+ event_id: uuid.UUID | None = None
+
+ async with engine.begin() as conn:
+ event_id = await _setup_event(conn)
+ for _ in range(num_photos):
+ photo_id = uuid.uuid4()
+ selected_img = random.choice(image_files)
+ storage_key = f"load-test/{event_id}/{photo_id}.jpg"
+ photo_tasks.append(
+ {"photo_id": photo_id, "storage_key": storage_key, "content": image_contents[selected_img]}
+ )
+ await conn.execute(
+ text(
+ """
+ INSERT INTO photos (id, event_id, storage_key, visibility, status)
+ VALUES (:id, :event_id, :key, 'private', 'pending')
+ """
+ ),
+ {"id": photo_id, "event_id": event_id, "key": storage_key},
+ )
+
+ assert event_id is not None
+ bucket = Bucket(IMAGES_BUCKET_NAME, "")
+
+ try:
+ # 1. Upload all photos to MinIO concurrently
+ await asyncio.gather(
+ *[
+ bucket.put_bytes(
+ object_name=p["storage_key"], # type: ignore[arg-type]
+ data=p["content"], # type: ignore[arg-type]
+ content_type="image/jpeg",
+ )
+ for p in photo_tasks
+ ]
+ )
+
+ # 2. Publish 20 NATS messages concurrently
+ await asyncio.gather(
+ *[
+ NatsClient.publish(
+ NatsSubjects.PHOTO_PROCESS.value,
+ json.dumps(
+ {
+ "photo_id": str(p["photo_id"]),
+ "image_ref": p["storage_key"],
+ "event_id": str(event_id),
+ }
+ ).encode("utf-8"),
+ )
+ for p in photo_tasks
+ ]
+ )
+
+ # 3. Wait for all jobs
+ photo_ids: list[uuid.UUID] = [
+ p["photo_id"] for p in photo_tasks # type: ignore[misc]
+ ]
+ status_counts = await _wait_for_jobs(photo_ids, timeout=180)
+
+ assert "timeout" not in status_counts, (
+ "Load test timed out waiting for jobs to complete"
+ )
+ failed = status_counts.get("failed", 0)
+ completed = status_counts.get("completed", 0)
+ assert failed == 0, f"Expected 0 failed jobs, got {failed}"
+ assert completed == num_photos, (
+ f"Expected {num_photos} completed jobs, got {completed}"
+ )
+ finally:
+ # Always clean up DB and MinIO regardless of test outcome
+ async with engine.begin() as conn:
+ photo_ids_list = [p["photo_id"] for p in photo_tasks]
+ if photo_ids_list:
+ await conn.execute(
+ text(
+ "DELETE FROM face_matches fm "
+ "USING photo_faces pf "
+ "WHERE pf.id = fm.photo_face_id "
+ "AND pf.photo_id = ANY(:ids)"
+ ),
+ {"ids": photo_ids_list},
+ )
+ await conn.execute(
+ text("DELETE FROM photo_faces WHERE photo_id = ANY(:ids)"),
+ {"ids": photo_ids_list},
+ )
+ await conn.execute(
+ text("DELETE FROM processing_jobs WHERE photo_id = ANY(:ids)"),
+ {"ids": photo_ids_list},
+ )
+ await conn.execute(
+ text("DELETE FROM photos WHERE event_id = :eid"),
+ {"eid": event_id},
+ )
+ await conn.execute(
+ text("DELETE FROM events WHERE id = :eid"), {"eid": event_id}
+ )
+ # Clean up MinIO objects
+ for p in photo_tasks:
+ try:
+ await bucket.delete(p["storage_key"]) # type: ignore[arg-type]
+ except Exception:
+ pass
diff --git a/tests/e2e/test_photo_ai_pipeline_e2e.py b/tests/e2e/test_photo_ai_pipeline_e2e.py
new file mode 100644
index 00000000..0a7b3b5a
--- /dev/null
+++ b/tests/e2e/test_photo_ai_pipeline_e2e.py
@@ -0,0 +1,106 @@
+import json
+import uuid
+
+from sqlalchemy import text
+
+from app.infra.database import engine
+from app.infra.minio import Bucket, IMAGES_BUCKET_NAME
+from app.infra.nats import NatsClient, NatsSubjects
+
+from tests.e2e.conftest import _seed_event_and_photo, _wait_for_job, _cleanup, FIXTURE_DIR
+
+
+async def test_photo_ai_pipeline_detects_single_face() -> None:
+ """Single face in photo with no enrolled users → auto-approved."""
+ photo_id = uuid.uuid4()
+ storage_key = f"e2e_test_{photo_id}.jpg"
+ image_path = FIXTURE_DIR / "face.jpg"
+
+ assert image_path.exists(), f"Test fixture not found: {image_path}"
+
+ bucket = Bucket(IMAGES_BUCKET_NAME, "")
+
+ # 1. Seed MinIO + DB
+ await bucket.put_bytes(
+ object_name=storage_key,
+ data=image_path.read_bytes(),
+ content_type="image/jpeg",
+ )
+ async with engine.begin() as conn:
+ event_id = await _seed_event_and_photo(
+ conn, photo_id=photo_id, storage_key=storage_key
+ )
+
+ # 2. Trigger worker
+ payload = {
+ "photo_id": str(photo_id),
+ "image_ref": storage_key,
+ "event_id": str(event_id),
+ }
+ await NatsClient.publish(
+ NatsSubjects.PHOTO_PROCESS, json.dumps(payload).encode("utf-8")
+ )
+
+ # 3. Assertions + Cleanup — always run cleanup via try/finally
+ try:
+ final_status = await _wait_for_job(photo_id, timeout_s=60)
+ assert final_status == "completed", f"Processing job ended with: {final_status}"
+
+ async with engine.connect() as conn:
+ photo_status = (
+ await conn.execute(
+ text("SELECT status FROM photos WHERE id = :pid"),
+ {"pid": photo_id},
+ )
+ ).scalar()
+ assert photo_status == "approved", (
+ f"Expected photo status 'approved', got {photo_status}"
+ )
+ finally:
+ async with engine.begin() as conn:
+ await _cleanup(conn, photo_id=photo_id, event_id=event_id)
+ try:
+ await bucket.delete(storage_key)
+ except Exception:
+ pass
+
+
+async def test_photo_ai_pipeline_corrupt_image() -> None:
+ """Corrupt image → processing job fails, photo status remains pending or marked as error."""
+ photo_id = uuid.uuid4()
+ storage_key = f"e2e_corrupt_{photo_id}.jpg"
+
+ bucket = Bucket(IMAGES_BUCKET_NAME, "")
+
+ # 1. Seed MinIO with corrupt data + DB
+ await bucket.put_bytes(
+ object_name=storage_key,
+ data=b"this is not a valid image file",
+ content_type="image/jpeg",
+ )
+ async with engine.begin() as conn:
+ event_id = await _seed_event_and_photo(
+ conn, photo_id=photo_id, storage_key=storage_key
+ )
+
+ # 2. Trigger worker
+ payload = {
+ "photo_id": str(photo_id),
+ "image_ref": storage_key,
+ "event_id": str(event_id),
+ }
+ await NatsClient.publish(
+ NatsSubjects.PHOTO_PROCESS, json.dumps(payload).encode("utf-8")
+ )
+
+ # 3. Assertions + Cleanup
+ try:
+ final_status = await _wait_for_job(photo_id, timeout_s=30)
+ assert final_status == "failed", f"Expected job to fail, but ended with: {final_status}"
+ finally:
+ async with engine.begin() as conn:
+ await _cleanup(conn, photo_id=photo_id, event_id=event_id)
+ try:
+ await bucket.delete(storage_key)
+ except Exception:
+ pass
diff --git a/tests/fixtures/images/face.jpg b/tests/fixtures/images/face.jpg
new file mode 100644
index 00000000..f746a3cb
Binary files /dev/null and b/tests/fixtures/images/face.jpg differ
diff --git a/tests/fixtures/images/group.jpg b/tests/fixtures/images/group.jpg
new file mode 100644
index 00000000..7991f1a0
Binary files /dev/null and b/tests/fixtures/images/group.jpg differ
diff --git a/tests/fixtures/images/noface.jpg b/tests/fixtures/images/noface.jpg
new file mode 100644
index 00000000..4a58e542
Binary files /dev/null and b/tests/fixtures/images/noface.jpg differ
diff --git a/tests/integration/test_enrollment_flow.py b/tests/integration/test_enrollment_flow.py
new file mode 100644
index 00000000..e1356f32
--- /dev/null
+++ b/tests/integration/test_enrollment_flow.py
@@ -0,0 +1,103 @@
+"""
+Integration tests for the Enrollment Flow.
+
+These tests use a real PostgreSQL database to verify that
+the user's face embedding is correctly persisted in the database.
+"""
+
+import uuid
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+from sqlalchemy import text
+
+from app.service.users import AuthService
+from app.service.face_embedding import FaceImagePayload
+from db.generated import user as user_queries
+
+
+# ===========================================================================
+# Fixtures
+# ===========================================================================
+
+
+@pytest.fixture
+def mock_face_embedding() -> AsyncMock:
+ from app.service.face_embedding import FaceEmbeddingService
+ svc = MagicMock(spec=FaceEmbeddingService)
+ # Return a dummy embedding of size 512
+ svc.compute_average_embedding_stream = AsyncMock(return_value=[0.1] * 512)
+ return svc
+
+
+@pytest.fixture
+async def db_conn():
+ from sqlalchemy.ext.asyncio import create_async_engine
+ from app.core.config import settings
+ url = f"postgresql+asyncpg://{settings.POSTGRES_USER}:{settings.POSTGRES_PASSWORD}@{settings.POSTGRES_HOST}:{settings.POSTGRES_PORT}/{settings.POSTGRES_DB}"
+ engine = create_async_engine(url, pool_pre_ping=True)
+ async with engine.connect() as conn:
+ yield conn
+ await engine.dispose()
+
+@pytest.fixture
+def auth_service(mock_face_embedding: AsyncMock, db_conn) -> AuthService:
+ from db.generated import session as session_queries
+ from db.generated import devices as device_queries
+
+ return AuthService(
+ user_querier=user_queries.AsyncQuerier(db_conn),
+ session_querier=session_queries.AsyncQuerier(db_conn),
+ device_querier=device_queries.AsyncQuerier(db_conn),
+ face_embedding_service=mock_face_embedding,
+ )
+
+
+# ===========================================================================
+# Tests
+# ===========================================================================
+
+# Mark these as integration tests (require DB)
+pytestmark = pytest.mark.integration
+
+
+@pytest.mark.asyncio
+async def test_enrollment_persists_embedding(
+ auth_service: AuthService,
+ mock_face_embedding: AsyncMock,
+ db_conn,
+) -> None:
+ """Test the happy path: add_embbed_user updates the user's face_embedding."""
+ # 1. Setup: Create a user without an embedding
+ user = await user_queries.AsyncQuerier(db_conn).create_user(
+ email=f"test-enroll-{uuid.uuid4()}@multai.com",
+ hashed_password="hash",
+ )
+ assert user is not None
+ user_id = user.id
+
+ # 2. Execute enrollment
+ payload = FaceImagePayload(image_bytes=b"fake-image", filename="face.jpg")
+
+ try:
+ await auth_service.add_embbed_user(
+ user_id=user_id,
+ image_payloads=[payload],
+ )
+
+ # 3. Verify: The user should now have an embedding
+ updated_user = await user_queries.AsyncQuerier(db_conn).get_user_by_id(id=user_id)
+ assert updated_user is not None
+ assert updated_user.face_embedding is not None
+ assert "0.1" in str(updated_user.face_embedding)
+
+ # Face embedding service should have been called
+ mock_face_embedding.compute_average_embedding_stream.assert_called_once()
+
+ finally:
+ # Cleanup
+ await db_conn.rollback()
+ await db_conn.execute(
+ text(f"DELETE FROM users WHERE id = '{user_id}'")
+ )
+ await db_conn.commit()
diff --git a/tests/integration/test_photo_approval_flow.py b/tests/integration/test_photo_approval_flow.py
new file mode 100644
index 00000000..2606df8b
--- /dev/null
+++ b/tests/integration/test_photo_approval_flow.py
@@ -0,0 +1,227 @@
+"""
+Integration tests for the Photo Approval Flow.
+
+These tests use a real PostgreSQL database but mock MinIO and NATS.
+They verify the full lifecycle of a group photo requiring multi-user approval.
+"""
+
+import uuid
+import datetime
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+from sqlalchemy import text
+
+from app.service.photo_approval import PhotoApprovalService
+from db.generated import user as user_queries
+from db.generated import stuff_user as staff_queries
+from db.generated import events as event_queries
+from db.generated import photos as photo_queries
+from db.generated import photo_approvals as approval_queries
+
+
+# ===========================================================================
+# Fixtures
+# ===========================================================================
+
+
+@pytest.fixture
+def mock_storage() -> AsyncMock:
+ from app.service.staged_upload_storage import StagedUploadStorageService
+ svc = MagicMock(spec=StagedUploadStorageService)
+ svc.delete_storage_key = AsyncMock()
+ return svc
+
+
+@pytest.fixture
+def mock_audit() -> AsyncMock:
+ from app.service.audit import AuditService
+ svc = MagicMock(spec=AuditService)
+ svc.create_record = AsyncMock()
+ return svc
+
+
+@pytest.fixture
+async def db_conn():
+ from sqlalchemy.ext.asyncio import create_async_engine
+ from app.core.config import settings
+ url = f"postgresql+asyncpg://{settings.POSTGRES_USER}:{settings.POSTGRES_PASSWORD}@{settings.POSTGRES_HOST}:{settings.POSTGRES_PORT}/{settings.POSTGRES_DB}"
+ engine = create_async_engine(url, pool_pre_ping=True)
+ async with engine.connect() as conn:
+ yield conn
+ await engine.dispose()
+
+@pytest.fixture
+def approval_service(
+ mock_storage: AsyncMock,
+ mock_audit: AsyncMock,
+ db_conn,
+) -> PhotoApprovalService:
+ return PhotoApprovalService(
+ photo_approval_querier=approval_queries.AsyncQuerier(db_conn),
+ photo_querier=photo_queries.AsyncQuerier(db_conn),
+ storage_service=mock_storage,
+ audit_service=mock_audit,
+ )
+
+
+# ===========================================================================
+# Tests
+# ===========================================================================
+
+# Mark these as integration tests (require DB)
+pytestmark = pytest.mark.integration
+
+
+@pytest.mark.asyncio
+async def test_group_photo_approval_lifecycle(
+ approval_service: PhotoApprovalService,
+ mock_storage: AsyncMock,
+ mock_audit: AsyncMock,
+ db_conn,
+) -> None:
+ """Test that a photo becomes 'approved' only when all pending approvals are approved."""
+ event_id = uuid.uuid4()
+ photo_id = uuid.uuid4()
+
+ sq = staff_queries.AsyncQuerier(db_conn)
+ uq = user_queries.AsyncQuerier(db_conn)
+ eq = event_queries.AsyncQuerier(db_conn)
+ pq = photo_queries.AsyncQuerier(db_conn)
+ aq = approval_queries.AsyncQuerier(db_conn)
+
+ staff = await sq.create_admin(email=f"admin-{uuid.uuid4()}@test.com", password="hash")
+ event_creator_id = staff.id
+
+ user_ids = []
+ for i in range(3):
+ u = await uq.create_user(email=f"approval-{uuid.uuid4()}@test.com", hashed_password="hash")
+ user_ids.append(u.id)
+
+ uploader_id, user1_id, user2_id = user_ids
+
+ event = await eq.create_event(
+ event_queries.CreateEventParams(
+ name="Approval Test Event",
+ event_code=f"APP{str(event_id)[:4]}",
+ event_date=datetime.datetime.now(datetime.timezone.utc),
+ status="scheduled",
+ created_by=event_creator_id
+ )
+ )
+ event_id = event.id
+
+ await pq.create_photo(
+ photo_queries.CreatePhotoParams(
+ event_id=event_id,
+ storage_key="test/group.jpg",
+ taken_at=None,
+ day_number=None,
+ visibility="public"
+ )
+ )
+
+ # Set status to pending and id
+ await db_conn.execute(
+ text(f"UPDATE photos SET id = '{photo_id}', status = 'pending', uploaded_by = '{uploader_id}' WHERE storage_key = 'test/group.jpg'")
+ )
+
+ await aq.create_photo_approval(photo_id=photo_id, user_id=user1_id, decision="pending")
+ await aq.create_photo_approval(photo_id=photo_id, user_id=user2_id, decision="pending")
+
+ try:
+ # 2. User 1 approves
+ result1 = await approval_service.decide(photo_id=photo_id, user_id=user1_id, decision="approved")
+ assert result1 == "pending", "Photo should remain pending because User 2 hasn't approved yet"
+
+ photo = await photo_queries.AsyncQuerier(db_conn).get_photo_by_id(id=photo_id)
+ assert photo.status == "pending"
+
+ # 3. User 2 approves
+ result2 = await approval_service.decide(photo_id=photo_id, user_id=user2_id, decision="approved")
+ assert result2 == "approved", "Photo should be approved since all users approved"
+
+ photo = await photo_queries.AsyncQuerier(db_conn).get_photo_by_id(id=photo_id)
+ assert photo.status == "approved"
+
+ finally:
+ # 4. Cleanup
+ await db_conn.execute(text(f"DELETE FROM photo_approvals WHERE photo_id = '{photo_id}'"))
+ await db_conn.execute(text(f"DELETE FROM photos WHERE id = '{photo_id}'"))
+ await db_conn.execute(text(f"DELETE FROM events WHERE id = '{event_id}'"))
+ await db_conn.execute(text(f"DELETE FROM users WHERE id IN ('{user1_id}', '{user2_id}')"))
+ await db_conn.execute(text(f"DELETE FROM staff_users WHERE id = '{event_creator_id}'"))
+ await db_conn.commit()
+
+
+@pytest.mark.asyncio
+async def test_group_photo_rejection_deletes_storage(
+ approval_service: PhotoApprovalService,
+ mock_storage: AsyncMock,
+ db_conn,
+) -> None:
+ """Test that a single rejection sets the photo to 'rejected' and deletes from MinIO."""
+ event_id = uuid.uuid4()
+ photo_id = uuid.uuid4()
+
+ sq = staff_queries.AsyncQuerier(db_conn)
+ uq = user_queries.AsyncQuerier(db_conn)
+ eq = event_queries.AsyncQuerier(db_conn)
+ pq = photo_queries.AsyncQuerier(db_conn)
+ aq = approval_queries.AsyncQuerier(db_conn)
+
+ staff = await sq.create_admin(email=f"admin-{uuid.uuid4()}@test.com", password="hash")
+ event_creator_id = staff.id
+
+ user_ids = []
+ for i in range(2):
+ u = await uq.create_user(email=f"reject-{uuid.uuid4()}@test.com", hashed_password="hash")
+ user_ids.append(u.id)
+
+ uploader_id, user1_id = user_ids
+
+ event = await eq.create_event(
+ event_queries.CreateEventParams(
+ name="Reject Test Event",
+ event_code=f"REJ{str(event_id)[:4]}",
+ event_date=datetime.datetime.now(datetime.timezone.utc),
+ status="scheduled",
+ created_by=event_creator_id
+ )
+ )
+ event_id = event.id
+
+ await pq.create_photo(
+ photo_queries.CreatePhotoParams(
+ event_id=event_id,
+ storage_key="test/reject.jpg",
+ taken_at=None,
+ day_number=None,
+ visibility="public"
+ )
+ )
+
+ await db_conn.execute(
+ text(f"UPDATE photos SET id = '{photo_id}', status = 'pending', uploaded_by = '{uploader_id}' WHERE storage_key = 'test/reject.jpg'")
+ )
+
+ await aq.create_photo_approval(photo_id=photo_id, user_id=user1_id, decision="pending")
+
+ try:
+ # 2. User 1 rejects
+ result = await approval_service.decide(photo_id=photo_id, user_id=user1_id, decision="rejected")
+
+ # 3. Verify
+ assert result == "rejected"
+ mock_storage.delete_storage_key.assert_called_once_with("test/reject.jpg")
+
+ photo = await photo_queries.AsyncQuerier(db_conn).get_photo_by_id(id=photo_id)
+ assert photo.status == "rejected"
+
+ finally:
+ await db_conn.execute(text(f"DELETE FROM photo_approvals WHERE photo_id = '{photo_id}'"))
+ await db_conn.execute(text(f"DELETE FROM photos WHERE id = '{photo_id}'"))
+ await db_conn.execute(text(f"DELETE FROM events WHERE id = '{event_id}'"))
+ await db_conn.execute(text(f"DELETE FROM users WHERE id IN ('{uploader_id}', '{user1_id}')"))
+ await db_conn.execute(text(f"DELETE FROM staff_users WHERE id = '{event_creator_id}'"))
+ await db_conn.commit()
diff --git a/tests/security/test_auth_security.py b/tests/security/test_auth_security.py
new file mode 100644
index 00000000..76a3a0bc
--- /dev/null
+++ b/tests/security/test_auth_security.py
@@ -0,0 +1,137 @@
+import uuid
+import jwt
+from datetime import datetime, timedelta, timezone
+
+import pytest
+from httpx import AsyncClient, ASGITransport
+from sqlalchemy import text
+
+from app.main import app
+from app.core.config import settings
+from db.generated import user as user_queries
+from app.infra.database import engine
+from app.infra.redis import RedisClient
+
+pytestmark = pytest.mark.asyncio(loop_scope="session")
+
+@pytest.fixture(scope="session", autouse=True)
+async def setup_infra():
+ # We must init Redis since ASGITransport doesn't trigger the lifespan
+ try:
+ RedisClient.init(
+ host=settings.REDIS_HOST,
+ port=settings.REDIS_PORT,
+ password=settings.REDIS_PASSWORD,
+ )
+ except RuntimeError:
+ pass # Already initialized
+ yield
+ await RedisClient.get_instance().close()
+
+@pytest.fixture(scope="session")
+async def client():
+ async with AsyncClient(transport=ASGITransport(app=app), base_url="http://testserver") as c:
+ yield c
+
+def create_mock_jwt(user_id: str, exp_delta_hours: int = 24) -> str:
+ payload = {
+ "sub": user_id,
+ "exp": datetime.now(timezone.utc) + timedelta(hours=exp_delta_hours),
+ }
+ return jwt.encode(payload, settings.jwt_secret, algorithm="HS256")
+
+async def test_jwt_validation_invalid_signature(client):
+ """Test that a JWT with an invalid signature is rejected."""
+ payload = {
+ "sub": str(uuid.uuid4()),
+ "exp": datetime.now(timezone.utc) + timedelta(hours=1),
+ }
+ invalid_token = jwt.encode(payload, "wrong_secret_key", algorithm="HS256")
+
+ # We must use "Bearer "
+ response = await client.get(
+ "/user/photos",
+ headers={"Authorization": f"Bearer {invalid_token}"}
+ )
+ assert response.status_code == 401
+ assert "Invalid token" in response.text
+
+async def test_jwt_validation_expired_token(client):
+ """Test that an expired JWT is rejected."""
+ expired_token = create_mock_jwt(str(uuid.uuid4()), exp_delta_hours=-1)
+
+ response = await client.get(
+ "/user/photos",
+ headers={"Authorization": f"Bearer {expired_token}"}
+ )
+ assert response.status_code == 401
+ assert "Token has expired" in response.text
+
+async def test_blocked_user_access(client):
+ """Test that a blocked user cannot access protected endpoints."""
+ async with engine.begin() as conn:
+ uq = user_queries.AsyncQuerier(conn)
+ user = await uq.create_user(
+ email=f"blocked-{uuid.uuid4()}@test.com",
+ hashed_password="hash"
+ )
+ user_id = user.id
+
+ # We need a session ID to put in the JWT, otherwise the auth dependency fails with "Invalid token"
+ session_id = uuid.uuid4()
+
+ await uq.set_user_blocked(blocked=True, id=user_id)
+
+ payload = {
+ "session_id": str(session_id),
+ "exp": datetime.now(timezone.utc) + timedelta(hours=1),
+ }
+ token = jwt.encode(payload, settings.jwt_secret, algorithm="HS256")
+
+ try:
+ response = await client.get(
+ "/user/photos",
+ headers={"Authorization": f"Bearer {token}"}
+ )
+ assert response.status_code in (401, 403), f"Expected 401 or 403, got {response.status_code}"
+ finally:
+ async with engine.begin() as conn:
+ await conn.execute(text(f"DELETE FROM users WHERE id = '{user_id}'"))
+
+async def test_rate_limiting(client):
+ """Test that multiple requests within a short timeframe hit rate limits."""
+ async with engine.begin() as conn:
+ uq = user_queries.AsyncQuerier(conn)
+ user = await uq.create_user(
+ email=f"rate-{uuid.uuid4()}@test.com",
+ hashed_password="hash"
+ )
+ user_id = user.id
+ session_id = uuid.uuid4()
+
+ payload = {
+ "session_id": str(session_id),
+ "exp": datetime.now(timezone.utc) + timedelta(hours=1),
+ }
+ token = jwt.encode(payload, settings.jwt_secret, algorithm="HS256")
+
+ try:
+ responses = []
+ # We test with enough requests to hit the 20/min limit
+ for _ in range(25):
+ res = await client.get(
+ "/user/photos",
+ headers={"Authorization": f"Bearer {token}"}
+ )
+ responses.append(res.status_code)
+
+ assert 429 in responses, "Expected to hit rate limit (429) after multiple rapid requests"
+ finally:
+ async with engine.begin() as conn:
+ await conn.execute(text(f"DELETE FROM users WHERE id = '{user_id}'"))
+ try:
+ redis = RedisClient.get_instance()
+ await redis._client.delete("rate_limit:/user/photos:127.0.0.1")
+ await redis._client.delete("rate_limit:/user/photos:testclient")
+ except Exception:
+ pass
diff --git a/tests/unit/test_auth_email_otp.py b/tests/unit/test_auth_email_otp.py
new file mode 100644
index 00000000..9a6f825a
--- /dev/null
+++ b/tests/unit/test_auth_email_otp.py
@@ -0,0 +1,171 @@
+import uuid
+import json
+from unittest.mock import AsyncMock, patch, ANY
+import pytest
+
+from app.service.users import AuthService
+from app.schema.request.mobile.auth import MobileRegisterRequest, RegisterVerifyRequest
+
+@pytest.fixture
+def mock_user_querier() -> AsyncMock:
+ return AsyncMock()
+
+@pytest.fixture
+def mock_device_querier() -> AsyncMock:
+ return AsyncMock()
+
+@pytest.fixture
+def mock_session_querier() -> AsyncMock:
+ return AsyncMock()
+
+@pytest.fixture
+def mock_face_embedding_service() -> AsyncMock:
+ return AsyncMock()
+
+@pytest.fixture
+def mock_redis() -> AsyncMock:
+ return AsyncMock()
+
+@pytest.fixture
+def auth_service(
+ mock_user_querier: AsyncMock,
+ mock_device_querier: AsyncMock,
+ mock_session_querier: AsyncMock,
+ mock_face_embedding_service: AsyncMock,
+) -> AuthService:
+ return AuthService(
+ user_querier=mock_user_querier,
+ device_querier=mock_device_querier,
+ session_querier=mock_session_querier,
+ face_embedding_service=mock_face_embedding_service,
+ )
+
+@pytest.mark.asyncio
+@patch("app.service.users.NatsClient.publish")
+async def test_mobile_register_sends_otp(
+ mock_publish: AsyncMock,
+ auth_service: AuthService,
+ mock_redis: AsyncMock,
+ mock_user_querier: AsyncMock,
+) -> None:
+ # Arrange
+ req = MobileRegisterRequest(
+ email="test@example.com",
+ password="Password1!",
+ device_name="iPhone",
+ device_type="iOS",
+ device_id=uuid.uuid4(),
+ )
+ mock_user_querier.get_user_by_email.return_value = None # User does not exist
+ mock_redis.incr.return_value = 1 # Rate limit check passes
+
+ # Act
+ res = await auth_service.mobile_register(redis=mock_redis, req=req)
+
+ # Assert
+ assert res.status == "pending_verification"
+ assert res.email == "test@example.com"
+
+ # Verify redis was called to save pending user and OTP
+ assert mock_redis.set.call_count == 2
+
+ # Verify NATS publish was called
+ mock_publish.assert_called_once()
+ args, _ = mock_publish.call_args
+ assert args[0] == "email.send_otp"
+ payload = json.loads(args[1])
+ assert payload["email"] == "test@example.com"
+ assert "otp" in payload
+
+@pytest.mark.asyncio
+async def test_verify_mobile_register_success(
+ auth_service: AuthService,
+ mock_redis: AsyncMock,
+ mock_user_querier: AsyncMock,
+ mock_device_querier: AsyncMock,
+ mock_session_querier: AsyncMock,
+) -> None:
+ # Arrange
+ device_id = uuid.uuid4()
+ req = RegisterVerifyRequest(
+ email="test@example.com",
+ password="Password1!",
+ device_name="iPhone",
+ device_type="iOS",
+ device_id=device_id,
+ otp="123456"
+ )
+
+ mock_redis.get.side_effect = [
+ "123456", # First call gets OTP
+ json.dumps({"hashed_password": "hashed_pass"}) # Second call gets pending user
+ ]
+
+ mock_user = AsyncMock()
+ mock_user.id = uuid.uuid4()
+ mock_user.email = "test@example.com"
+ mock_user.blocked = False
+ mock_user_querier.create_user.return_value = mock_user
+
+ mock_session_querier.count_user_sessions.return_value = 0
+ mock_session = AsyncMock()
+ mock_session.id = uuid.uuid4()
+ mock_session_querier.upsert_session.return_value = mock_session
+ mock_device_querier.get_device_by_id.return_value = None
+
+ # Act
+ with patch("app.service.users.SessionService.cache_session_for_auth", new_callable=AsyncMock):
+ res = await auth_service.verify_mobile_register(redis=mock_redis, req=req)
+
+ # Assert
+ assert res.is_new_user is True
+ assert res.user_id == mock_user.id
+
+ # Verify user was created
+ mock_user_querier.create_user.assert_called_once_with(email="test@example.com", hashed_password="hashed_pass")
+
+ # Verify redis cleanup
+ assert mock_redis.delete.call_count == 2
+
+
+@pytest.mark.asyncio
+async def test_mobile_register_resend_otp_success(
+ auth_service: AuthService,
+ mock_redis: AsyncMock,
+) -> None:
+ # Arrange
+ email = "test@example.com"
+ mock_redis.get.return_value = '{"hashed_password": "fake"}'
+ mock_redis.incr.return_value = 1
+
+ # Act
+ with patch("app.infra.nats.NatsClient.publish", new_callable=AsyncMock) as mock_publish:
+ res = await auth_service.mobile_register_resend_otp(redis=mock_redis, email=email)
+
+ # Assert
+ assert res.status == "pending_verification"
+ assert res.message == "New OTP sent to email"
+ assert res.email == email
+
+ mock_redis.get.assert_called_with(f"pending_user:{email}")
+ mock_redis.set.assert_called_with(f"otp:{email}", ANY, expire=600)
+ mock_publish.assert_called_once()
+
+
+@pytest.mark.asyncio
+async def test_mobile_register_resend_otp_not_found(
+ auth_service: AuthService,
+ mock_redis: AsyncMock,
+) -> None:
+ from fastapi import HTTPException
+ # Arrange
+ email = "test@example.com"
+ mock_redis.incr.return_value = 1
+ mock_redis.get.return_value = None # No pending user
+
+ # Act & Assert
+ with pytest.raises(HTTPException) as exc_info:
+ await auth_service.mobile_register_resend_otp(redis=mock_redis, email=email)
+
+ assert exc_info.value.status_code == 404
+ assert "No pending registration found" in exc_info.value.detail
diff --git a/tests/unit/test_auth_service.py b/tests/unit/test_auth_service.py
new file mode 100644
index 00000000..2024ba61
--- /dev/null
+++ b/tests/unit/test_auth_service.py
@@ -0,0 +1,460 @@
+"""
+Unit tests for AuthService.
+
+Tests cover the core mobile auth flow: login, registration, password validation,
+blocked user enforcement, session limits, logout, refresh token, and face embedding.
+All dependencies (DB queriers, Redis, FaceEmbeddingService) are mocked.
+"""
+
+import uuid
+from datetime import datetime, timedelta, timezone
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+from fastapi.exceptions import HTTPException
+
+from app.service.users import AuthService
+from app.core.securite import hash_password
+from app.schema.request.mobile.auth import MobileLoginRequest, MobileRegisterRequest
+
+
+# ---------------------------------------------------------------------------
+# Factories
+# ---------------------------------------------------------------------------
+
+
+def _make_user(
+ *,
+ user_id: uuid.UUID | None = None,
+ email: str = "user@test.com",
+ password: str = "Secret123!",
+ blocked: bool = False,
+ face_embedding: str | None = None,
+) -> MagicMock:
+ u = MagicMock()
+ u.id = user_id or uuid.uuid4()
+ u.email = email
+ u.hashed_password = hash_password(password)
+ u.blocked = blocked
+ u.face_embedding = face_embedding
+ u.display_name = None
+ return u
+
+
+def _make_session(
+ *,
+ session_id: uuid.UUID | None = None,
+ user_id: uuid.UUID | None = None,
+ expires_at: datetime | None = None,
+) -> MagicMock:
+ s = MagicMock()
+ s.id = session_id or uuid.uuid4()
+ s.user_id = user_id or uuid.uuid4()
+ s.device_id = uuid.uuid4()
+ s.expires_at = expires_at or datetime.now(timezone.utc) + timedelta(days=30)
+ s.last_active = datetime.now(timezone.utc)
+ return s
+
+
+def _make_device() -> MagicMock:
+ d = MagicMock()
+ d.id = uuid.uuid4()
+ d.user_id = uuid.uuid4()
+ d.is_invalid_token = False
+ d.is_active = True
+ return d
+
+
+def _make_login_request(
+ *,
+ email: str = "user@test.com",
+ password: str = "Secret123!",
+) -> MobileLoginRequest:
+ return MobileLoginRequest(
+ email=email,
+ password=password,
+ device_id=uuid.uuid4(),
+ device_name="iPhone 15",
+ device_type="ios",
+ )
+
+
+def _make_register_request(
+ *,
+ email: str = "user@test.com",
+ password: str = "Secret123!",
+) -> MobileRegisterRequest:
+ return MobileRegisterRequest(
+ email=email,
+ password=password,
+ device_id=uuid.uuid4(),
+ device_name="iPhone 15",
+ device_type="ios",
+ )
+
+
+# ---------------------------------------------------------------------------
+# Fixtures
+# ---------------------------------------------------------------------------
+
+
+@pytest.fixture
+def user_querier() -> AsyncMock:
+ from db.generated import user as user_queries
+ q = MagicMock(spec=user_queries.AsyncQuerier)
+ q.get_user_by_email = AsyncMock(return_value=None)
+ q.create_user = AsyncMock()
+ q.get_user_by_id = AsyncMock()
+ q.find_closest_user_by_embedding = AsyncMock(return_value=None)
+ q.set_user_embedding = AsyncMock()
+ return q
+
+
+@pytest.fixture
+def device_querier() -> AsyncMock:
+ from db.generated import devices as device_queries
+ q = MagicMock(spec=device_queries.AsyncQuerier)
+ q.get_device_by_id = AsyncMock(return_value=None)
+ q.create_device = AsyncMock(return_value=_make_device())
+ q.activate_device = AsyncMock()
+ return q
+
+
+@pytest.fixture
+def session_querier() -> AsyncMock:
+ from db.generated import session as session_queries
+ q = MagicMock(spec=session_queries.AsyncQuerier)
+ q.count_user_sessions = AsyncMock(return_value=0)
+ q.upsert_session = AsyncMock(return_value=_make_session())
+ q.get_session_by_id = AsyncMock()
+ return q
+
+
+@pytest.fixture
+def face_service() -> AsyncMock:
+ from app.service.face_embedding import FaceEmbeddingService
+ svc = MagicMock(spec=FaceEmbeddingService)
+ svc.compute_average_embedding = AsyncMock(return_value=[0.1] * 512)
+ return svc
+
+
+@pytest.fixture
+def redis() -> AsyncMock:
+ r = MagicMock()
+ r.set = AsyncMock()
+ r.get = AsyncMock(return_value=None)
+ r.delete = AsyncMock()
+ r.incr = AsyncMock(return_value=1)
+ r.expire = AsyncMock()
+ return r
+
+
+@pytest.fixture
+def auth_service(
+ user_querier: AsyncMock,
+ device_querier: AsyncMock,
+ session_querier: AsyncMock,
+ face_service: AsyncMock,
+) -> AuthService:
+ return AuthService(
+ user_querier=user_querier,
+ device_querier=device_querier,
+ session_querier=session_querier,
+ face_embedding_service=face_service,
+ )
+
+
+# ===========================================================================
+# 1. Registration — new user
+# ===========================================================================
+
+
+class TestRegisterNewUser:
+ @pytest.mark.asyncio
+ async def test_new_user_is_created(
+ self,
+ auth_service: AuthService,
+ user_querier: AsyncMock,
+ redis: AsyncMock,
+ ) -> None:
+ new_user = _make_user()
+ user_querier.get_user_by_email.return_value = None
+ user_querier.create_user.return_value = new_user
+
+ req = _make_register_request()
+ result = await auth_service.mobile_register(redis, req)
+
+ user_querier.create_user.assert_not_called()
+ assert result.status == "pending_verification"
+
+ @pytest.mark.asyncio
+ async def test_pending_status_returned_on_register(
+ self,
+ auth_service: AuthService,
+ user_querier: AsyncMock,
+ redis: AsyncMock,
+ ) -> None:
+ user_querier.get_user_by_email.return_value = None
+
+ result = await auth_service.mobile_register(redis, _make_register_request())
+
+ assert result.status == "pending_verification"
+ assert result.message == "OTP sent to email"
+
+ @pytest.mark.asyncio
+ async def test_session_cached_in_redis_on_register(
+ self,
+ auth_service: AuthService,
+ user_querier: AsyncMock,
+ redis: AsyncMock,
+ ) -> None:
+ new_user = _make_user()
+ user_querier.get_user_by_email.return_value = None
+ user_querier.create_user.return_value = new_user
+
+ await auth_service.mobile_register(redis, _make_register_request())
+
+ # Redis.set must be called at least once (session key)
+ redis.set.assert_called()
+
+
+# ===========================================================================
+# 2. Login — existing user
+# ===========================================================================
+
+
+class TestLoginExistingUser:
+ @pytest.mark.asyncio
+ async def test_valid_credentials_return_tokens(
+ self,
+ auth_service: AuthService,
+ user_querier: AsyncMock,
+ redis: AsyncMock,
+ ) -> None:
+ existing = _make_user(password="Correctpass1!")
+ user_querier.get_user_by_email.return_value = existing
+
+ result = await auth_service.mobile_login(
+ redis, _make_login_request(password="Correctpass1!")
+ )
+
+ assert result.is_new_user is False
+ assert result.access_token
+
+ @pytest.mark.asyncio
+ async def test_wrong_password_raises_401(
+ self,
+ auth_service: AuthService,
+ user_querier: AsyncMock,
+ redis: AsyncMock,
+ ) -> None:
+ existing = _make_user(password="Rightpassword1!")
+ user_querier.get_user_by_email.return_value = existing
+
+ with pytest.raises(HTTPException) as exc_info:
+ await auth_service.mobile_login(
+ redis, _make_login_request(password="Wrongpassword1!")
+ )
+ assert exc_info.value.status_code == 401
+
+ @pytest.mark.asyncio
+ async def test_blocked_user_raises_403(
+ self,
+ auth_service: AuthService,
+ user_querier: AsyncMock,
+ redis: AsyncMock,
+ ) -> None:
+ blocked = _make_user(password="Secret123!", blocked=True)
+ user_querier.get_user_by_email.return_value = blocked
+
+ with pytest.raises(HTTPException) as exc_info:
+ await auth_service.mobile_login(redis, _make_login_request())
+ assert exc_info.value.status_code == 403
+
+
+# ===========================================================================
+# 3. Session limit enforcement
+# ===========================================================================
+
+
+class TestSessionLimit:
+ @pytest.mark.asyncio
+ async def test_exceeding_session_limit_raises_403(
+ self,
+ auth_service: AuthService,
+ user_querier: AsyncMock,
+ session_querier: AsyncMock,
+ redis: AsyncMock,
+ ) -> None:
+ user = _make_user()
+ user_querier.get_user_by_email.return_value = user
+ # Return a count >= SESSION_LIMIT
+ session_querier.count_user_sessions.return_value = AuthService.SESSION_LIMIT
+
+ with pytest.raises(HTTPException) as exc_info:
+ await auth_service.mobile_login(redis, _make_login_request())
+ assert exc_info.value.status_code == 403
+ assert "session limit" in exc_info.value.detail.lower()
+
+ @pytest.mark.asyncio
+ async def test_within_session_limit_succeeds(
+ self,
+ auth_service: AuthService,
+ user_querier: AsyncMock,
+ session_querier: AsyncMock,
+ redis: AsyncMock,
+ ) -> None:
+ user = _make_user()
+ user_querier.get_user_by_email.return_value = user
+ session_querier.count_user_sessions.return_value = AuthService.SESSION_LIMIT - 1
+
+ result = await auth_service.mobile_login(redis, _make_login_request())
+ assert result.access_token
+
+
+# ===========================================================================
+# 4. Logout
+# ===========================================================================
+
+
+class TestLogout:
+ @pytest.mark.asyncio
+ async def test_logout_deletes_session_key_from_redis(
+ self,
+ auth_service: AuthService,
+ redis: AsyncMock,
+ ) -> None:
+ user_id = str(uuid.uuid4())
+ session_id = str(uuid.uuid4())
+
+ await auth_service.logout(redis, user_id, session_id)
+
+ redis.delete.assert_called_once()
+ key_used = redis.delete.call_args.args[0]
+ assert user_id in key_used
+
+ @pytest.mark.asyncio
+ async def test_logout_returns_success_message(
+ self,
+ auth_service: AuthService,
+ redis: AsyncMock,
+ ) -> None:
+ result = await auth_service.logout(redis, str(uuid.uuid4()), str(uuid.uuid4()))
+ assert "message" in result
+ assert "logged out" in result["message"].lower()
+
+
+# ===========================================================================
+# 5. Refresh token
+# ===========================================================================
+
+
+class TestRefreshToken:
+ @pytest.mark.asyncio
+ async def test_valid_refresh_returns_new_tokens(
+ self,
+ auth_service: AuthService,
+ user_querier: AsyncMock,
+ session_querier: AsyncMock,
+ redis: AsyncMock,
+ ) -> None:
+ from app.core.securite import create_refresh_mobile_token
+
+ session = _make_session()
+ session_querier.get_session_by_id.return_value = session
+ user_querier.get_user_by_id.return_value = _make_user(user_id=session.user_id)
+
+ refresh_token = create_refresh_mobile_token(str(session.id))
+ result = await auth_service.refresh_token(redis, refresh_token)
+
+ assert result.access_token
+ assert result.refresh_token
+
+ @pytest.mark.asyncio
+ async def test_expired_session_raises_401(
+ self,
+ auth_service: AuthService,
+ user_querier: AsyncMock,
+ session_querier: AsyncMock,
+ redis: AsyncMock,
+ ) -> None:
+ from app.core.securite import create_refresh_mobile_token
+
+ past_session = _make_session(
+ expires_at=datetime.now(timezone.utc) - timedelta(days=1)
+ )
+ session_querier.get_session_by_id.return_value = past_session
+
+ refresh_token = create_refresh_mobile_token(str(past_session.id))
+
+ with pytest.raises(HTTPException) as exc_info:
+ await auth_service.refresh_token(redis, refresh_token)
+ assert exc_info.value.status_code == 401
+
+ @pytest.mark.asyncio
+ async def test_blocked_user_on_refresh_raises_403(
+ self,
+ auth_service: AuthService,
+ user_querier: AsyncMock,
+ session_querier: AsyncMock,
+ redis: AsyncMock,
+ ) -> None:
+ from app.core.securite import create_refresh_mobile_token
+
+ session = _make_session()
+ session_querier.get_session_by_id.return_value = session
+ user_querier.get_user_by_id.return_value = _make_user(
+ user_id=session.user_id, blocked=True
+ )
+
+ refresh_token = create_refresh_mobile_token(str(session.id))
+
+ with pytest.raises(HTTPException) as exc_info:
+ await auth_service.refresh_token(redis, refresh_token)
+ assert exc_info.value.status_code == 403
+
+ @pytest.mark.asyncio
+ async def test_invalid_refresh_token_raises_401(
+ self,
+ auth_service: AuthService,
+ redis: AsyncMock,
+ ) -> None:
+ with pytest.raises(HTTPException) as exc_info:
+ await auth_service.refresh_token(redis, "completely.invalid.token")
+ assert exc_info.value.status_code == 401
+
+
+# ===========================================================================
+# 6. find_closest_user
+# ===========================================================================
+
+
+class TestFindClosestUser:
+ @pytest.mark.asyncio
+ async def test_returns_none_when_no_row(
+ self,
+ auth_service: AuthService,
+ user_querier: AsyncMock,
+ ) -> None:
+ user_querier.find_closest_user_by_embedding.return_value = None
+
+ result = await auth_service.find_closest_user(embedding_literal="[0.1, 0.2]")
+
+ assert result is None
+
+ @pytest.mark.asyncio
+ async def test_returns_closest_user_match(
+ self,
+ auth_service: AuthService,
+ user_querier: AsyncMock,
+ ) -> None:
+ row = MagicMock()
+ row.id = uuid.uuid4()
+ row.distance = 0.25
+ user_querier.find_closest_user_by_embedding.return_value = row
+
+ result = await auth_service.find_closest_user(embedding_literal="[0.1, 0.2]")
+
+ assert result is not None
+ assert result.user_id == row.id
+ assert result.distance == 0.25
diff --git a/tests/unit/test_enroll_security.py b/tests/unit/test_enroll_security.py
new file mode 100644
index 00000000..4f31584d
--- /dev/null
+++ b/tests/unit/test_enroll_security.py
@@ -0,0 +1,306 @@
+"""
+Unit tests for the enrollment security helpers introduced in fix/enroll.
+
+These helpers only exist on the fix/enroll branch. On other branches,
+all tests are automatically skipped via pytest.importorskip.
+
+Run with: uv run pytest tests/unit/test_enroll_security.py -v
+"""
+
+import io
+import uuid
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+from fastapi import UploadFile
+from fastapi.exceptions import HTTPException
+
+# Guard: skip gracefully if the helpers don't exist on this branch
+_router_mod = pytest.importorskip(
+ "app.router.mobile.enrollement",
+ reason="fix/enroll branch required for enrollment security helpers",
+)
+
+_sanitise_filename = getattr(_router_mod, "_sanitise_filename", None)
+_validate_dimensions = getattr(_router_mod, "_validate_dimensions", None)
+_precheck_upload_headers = getattr(_router_mod, "_precheck_upload_headers", None)
+_enrollment_lock_key = getattr(_router_mod, "_enrollment_lock_key", None)
+read_limited = getattr(_router_mod, "read_limited", None)
+
+# If any helper is missing, skip the whole module
+if any(fn is None for fn in [_sanitise_filename, _validate_dimensions,
+ _precheck_upload_headers, _enrollment_lock_key, read_limited]):
+ pytest.skip(
+ "Enrollment security helpers not available on this branch",
+ allow_module_level=True,
+ )
+
+from app.core.constant import MAX_IMAGE_SIZE, MIN_IMAGE_DIM, MAX_IMAGE_DIM # noqa: E402
+
+
+# ---------------------------------------------------------------------------
+# Factories
+# ---------------------------------------------------------------------------
+
+
+def _make_jpeg_bytes(width: int = 200, height: int = 200) -> bytes:
+ """Generate a minimal valid JPEG in memory using Pillow."""
+ from PIL import Image
+
+ img = Image.new("RGB", (width, height), color=(100, 149, 237))
+ buf = io.BytesIO()
+ img.save(buf, format="JPEG")
+ return buf.getvalue()
+
+
+def _make_upload_file(
+ content: bytes,
+ filename: str = "face.jpg",
+ content_type: str | None = "image/jpeg",
+ content_length: int | None = None,
+) -> UploadFile:
+ headers: dict[str, str] = {}
+ if content_length is not None:
+ headers["content-length"] = str(content_length)
+
+ buf = io.BytesIO(content)
+ mock_file = MagicMock(spec=UploadFile)
+ mock_file.filename = filename
+ mock_file.content_type = content_type
+ mock_file.headers = headers
+ mock_file.read = AsyncMock(side_effect=lambda n=-1: buf.read(n) if n == -1 else buf.read(n))
+ mock_file.seek = AsyncMock(side_effect=lambda pos: buf.seek(pos))
+ return mock_file # type: ignore[return-value]
+
+
+# ===========================================================================
+# 1. _sanitise_filename
+# ===========================================================================
+
+
+class TestSanitiseFilename:
+ def test_normal_name_gets_uuid_prefix(self) -> None:
+ result = _sanitise_filename("portrait.jpg", "jpg")
+ parts = result.split("_", 1)
+ assert len(parts) == 2
+ uuid.UUID(parts[0]) # raises if not a valid UUID
+ assert parts[1] == "portrait.jpg"
+
+ def test_path_traversal_is_neutralised(self) -> None:
+ result = _sanitise_filename("../../../etc/passwd.jpg", "jpg")
+ assert ".." not in result
+ assert "/" not in result
+
+ def test_null_bytes_are_replaced(self) -> None:
+ assert "\x00" not in _sanitise_filename("face\x00evil.jpg", "jpg")
+
+ def test_control_characters_are_replaced(self) -> None:
+ assert "\x1f" not in _sanitise_filename("face\x1fmalicious.jpg", "jpg")
+
+ def test_windows_reserved_chars_are_replaced(self) -> None:
+ for char in r'\/:*?"<>|':
+ assert char not in _sanitise_filename(f"face{char}name.jpg", "jpg"), \
+ f"char {char!r} must be replaced"
+
+ def test_none_filename_returns_uuid_only(self) -> None:
+ result = _sanitise_filename(None, "png")
+ base, ext = result.rsplit(".", 1)
+ uuid.UUID(base)
+ assert ext == "png"
+
+ def test_empty_filename_returns_uuid_only(self) -> None:
+ result = _sanitise_filename("", "jpg")
+ base, ext = result.rsplit(".", 1)
+ uuid.UUID(base)
+ assert ext == "jpg"
+
+ def test_long_filename_is_truncated(self) -> None:
+ result = _sanitise_filename("a" * 200, "jpg")
+ name_part = result.split("_", 1)[1]
+ assert len(name_part) <= 128
+
+ def test_leading_dots_stripped(self) -> None:
+ result = _sanitise_filename("...hidden.jpg", "jpg")
+ name_part = result.split("_", 1)[1]
+ assert not name_part.startswith(".")
+
+
+# ===========================================================================
+# 2. _validate_dimensions
+# ===========================================================================
+
+
+class TestValidateDimensions:
+ def test_valid_image_passes(self) -> None:
+ _validate_dimensions(_make_jpeg_bytes(200, 200)) # must not raise
+
+ def test_too_small_width_raises_400(self) -> None:
+ with pytest.raises(HTTPException) as exc_info:
+ _validate_dimensions(_make_jpeg_bytes(MIN_IMAGE_DIM - 1, 200))
+ assert exc_info.value.status_code == 400
+ assert "too small" in exc_info.value.detail.lower()
+
+ def test_too_small_height_raises_400(self) -> None:
+ with pytest.raises(HTTPException) as exc_info:
+ _validate_dimensions(_make_jpeg_bytes(200, MIN_IMAGE_DIM - 1))
+ assert exc_info.value.status_code == 400
+
+ def test_too_large_width_raises_400(self) -> None:
+ with pytest.raises(HTTPException) as exc_info:
+ _validate_dimensions(_make_jpeg_bytes(MAX_IMAGE_DIM + 1, 200))
+ assert exc_info.value.status_code == 400
+ assert "too large" in exc_info.value.detail.lower()
+
+ def test_corrupt_bytes_raises_400(self) -> None:
+ with pytest.raises(HTTPException) as exc_info:
+ _validate_dimensions(b"this-is-not-an-image")
+ assert exc_info.value.status_code == 400
+
+ def test_boundary_min_dimension_passes(self) -> None:
+ _validate_dimensions(_make_jpeg_bytes(MIN_IMAGE_DIM, MIN_IMAGE_DIM))
+
+ def test_boundary_max_dimension_passes(self) -> None:
+ _validate_dimensions(_make_jpeg_bytes(MAX_IMAGE_DIM, MAX_IMAGE_DIM))
+
+
+# ===========================================================================
+# 3. _precheck_upload_headers
+# ===========================================================================
+
+
+class TestPrecheckUploadHeaders:
+ def test_valid_jpeg_header_passes(self) -> None:
+ _precheck_upload_headers(_make_upload_file(b"", content_type="image/jpeg"))
+
+ def test_valid_png_header_passes(self) -> None:
+ _precheck_upload_headers(_make_upload_file(b"", content_type="image/png"))
+
+ def test_missing_content_type_raises_400(self) -> None:
+ f = _make_upload_file(b"", content_type=None)
+ with pytest.raises(HTTPException) as exc_info:
+ _precheck_upload_headers(f)
+ assert exc_info.value.status_code == 400
+
+ def test_unsupported_content_type_raises_400(self) -> None:
+ with pytest.raises(HTTPException) as exc_info:
+ _precheck_upload_headers(_make_upload_file(b"", content_type="application/pdf"))
+ assert exc_info.value.status_code == 400
+
+ def test_content_type_with_charset_param_accepted(self) -> None:
+ # "image/jpeg; charset=utf-8" should normalise to "image/jpeg"
+ _precheck_upload_headers(
+ _make_upload_file(b"", content_type="image/jpeg; charset=utf-8")
+ )
+
+ def test_oversized_content_length_raises_400(self) -> None:
+ with pytest.raises(HTTPException) as exc_info:
+ _precheck_upload_headers(
+ _make_upload_file(b"", content_type="image/jpeg", content_length=MAX_IMAGE_SIZE + 1)
+ )
+ assert exc_info.value.status_code == 400
+
+ def test_valid_content_length_passes(self) -> None:
+ _precheck_upload_headers(
+ _make_upload_file(b"", content_type="image/jpeg", content_length=1024)
+ )
+
+ def test_invalid_content_length_string_raises_400(self) -> None:
+ f = _make_upload_file(b"", content_type="image/jpeg")
+ f.headers = {"content-length": "not_a_number"}
+ with pytest.raises(HTTPException) as exc_info:
+ _precheck_upload_headers(f)
+ assert exc_info.value.status_code == 400
+
+
+# ===========================================================================
+# 4. read_limited
+# ===========================================================================
+
+
+class TestReadLimited:
+ @pytest.mark.asyncio
+ async def test_small_file_returns_full_bytes(self) -> None:
+ data = b"hello world"
+ result = await read_limited(_make_upload_file(data), MAX_IMAGE_SIZE)
+ assert result == data
+
+ @pytest.mark.asyncio
+ async def test_exceeds_limit_raises_400(self) -> None:
+ data = b"x" * (MAX_IMAGE_SIZE + 1)
+ with pytest.raises(HTTPException) as exc_info:
+ await read_limited(_make_upload_file(data), MAX_IMAGE_SIZE)
+ assert exc_info.value.status_code == 400
+
+ @pytest.mark.asyncio
+ async def test_exactly_at_limit_passes(self) -> None:
+ data = b"x" * MAX_IMAGE_SIZE
+ result = await read_limited(_make_upload_file(data), MAX_IMAGE_SIZE)
+ assert len(result) == MAX_IMAGE_SIZE
+
+ @pytest.mark.asyncio
+ async def test_empty_file_returns_empty_bytes(self) -> None:
+ result = await read_limited(_make_upload_file(b""), MAX_IMAGE_SIZE)
+ assert result == b""
+
+ @pytest.mark.asyncio
+ async def test_aborts_early_without_reading_entire_stream(self) -> None:
+ """Must abort after exceeding the limit — not exhaust the stream."""
+ call_count = 0
+ chunk = b"x" * 65536
+
+ async def mock_read(n: int = -1) -> bytes:
+ nonlocal call_count
+ call_count += 1
+ if call_count > 10:
+ return b""
+ return chunk
+
+ f = MagicMock(spec=UploadFile)
+ f.read = mock_read
+ f.seek = AsyncMock()
+
+ limit = 65536 * 5 # 5 chunks
+ with pytest.raises(HTTPException):
+ await read_limited(f, limit)
+
+ assert call_count <= 7, "read_limited must abort early"
+
+
+# ===========================================================================
+# 5. _enrollment_lock_key
+# ===========================================================================
+
+
+class TestEnrollmentLockKey:
+ def test_key_contains_user_id(self) -> None:
+ user_id = uuid.uuid4()
+ assert str(user_id) in _enrollment_lock_key(user_id)
+
+ def test_different_users_have_different_keys(self) -> None:
+ assert _enrollment_lock_key(uuid.uuid4()) != _enrollment_lock_key(uuid.uuid4())
+
+ def test_key_format(self) -> None:
+ user_id = uuid.uuid4()
+ assert _enrollment_lock_key(user_id) == f"enroll:in_progress:{user_id}"
+
+
+# ===========================================================================
+# 6. Magic byte sniffing (unit-level verification)
+# ===========================================================================
+
+
+class TestMagicByteSniffing:
+ def test_pdf_bytes_not_classified_as_image(self) -> None:
+ import filetype # type: ignore[import-untyped]
+
+ pdf_bytes = b"%PDF-1.4 fake pdf content"
+ kind = filetype.guess(pdf_bytes)
+ allowed = {"image/jpeg", "image/png", "image/heic", "image/heif"}
+ assert kind is None or kind.mime not in allowed
+
+ def test_real_jpeg_classified_as_jpeg(self) -> None:
+ import filetype # type: ignore[import-untyped]
+
+ kind = filetype.guess(_make_jpeg_bytes(100, 100))
+ assert kind is not None
+ assert kind.mime == "image/jpeg"
diff --git a/tests/unit/test_face_match_service.py b/tests/unit/test_face_match_service.py
new file mode 100644
index 00000000..8de215b0
--- /dev/null
+++ b/tests/unit/test_face_match_service.py
@@ -0,0 +1,404 @@
+"""
+Unit tests for SingleFaceMatchService.
+
+All database and service collaborators are mocked — no live infrastructure required.
+Tests cover all decision branches: auto-approve, threshold rejection, happy-path match,
+idempotency guards, and notification side-effects.
+"""
+
+import json
+import uuid
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+
+from app.service.face_match import SingleFaceMatchService
+from app.schema.internal.single_face_match import BBoxPayload, SingleFaceMatchJob
+
+
+# ---------------------------------------------------------------------------
+# Fixtures
+# ---------------------------------------------------------------------------
+
+
+@pytest.fixture
+def photo_face_querier() -> AsyncMock:
+ from db.generated import photo_faces as pf_queries
+ q = MagicMock(spec=pf_queries.AsyncQuerier)
+ q.photo_faces_photo_exists = AsyncMock(return_value=object()) # truthy → photo exists
+ q.photo_faces_match_exists_for_photo = AsyncMock(return_value=None) # no duplicate
+ q.photo_faces_ensure_face_match = AsyncMock()
+ return q
+
+
+@pytest.fixture
+def photo_querier() -> AsyncMock:
+ from db.generated import photos as photo_queries
+ q = MagicMock(spec=photo_queries.AsyncQuerier)
+ q.update_photo_status = AsyncMock(return_value=None)
+ return q
+
+
+@pytest.fixture
+def user_match_service() -> AsyncMock:
+ from app.service.users import AuthService
+ svc = MagicMock(spec=AuthService)
+ svc.find_closest_user = AsyncMock()
+ return svc
+
+
+@pytest.fixture
+def notification_service() -> AsyncMock:
+ from app.service.user_notification import UserNotificationService
+ svc = MagicMock(spec=UserNotificationService)
+ svc.create_notification = AsyncMock()
+ return svc
+
+
+@pytest.fixture
+def service(
+ photo_face_querier: AsyncMock,
+ photo_querier: AsyncMock,
+ user_match_service: AsyncMock,
+ notification_service: AsyncMock,
+) -> SingleFaceMatchService:
+ return SingleFaceMatchService(
+ conn=MagicMock(),
+ photo_face_querier=photo_face_querier,
+ photo_querier=photo_querier,
+ user_match_service=user_match_service,
+ user_notification_service=notification_service,
+ )
+
+
+@pytest.fixture
+def job() -> SingleFaceMatchJob:
+ return SingleFaceMatchJob(
+ photo_id=uuid.uuid4(),
+ image_ref="photos/test.jpg",
+ face_index=0,
+ )
+
+
+@pytest.fixture
+def bbox() -> BBoxPayload:
+ return BBoxPayload(x1=10.0, y1=20.0, x2=100.0, y2=200.0)
+
+
+def _make_embedding(value: float = 0.5) -> list[float]:
+ return [value] * 512
+
+
+def _closest_match(distance: float = 0.3) -> object:
+ from app.schema.internal.single_face_match import ClosestUserMatch
+ return ClosestUserMatch(user_id=uuid.uuid4(), distance=distance)
+
+
+# ===========================================================================
+# 1. Auto-approve — no enrolled users in DB
+# ===========================================================================
+
+
+class TestAutoApproveNoUsers:
+ @pytest.mark.asyncio
+ async def test_photo_approved_when_no_users(
+ self,
+ service: SingleFaceMatchService,
+ job: SingleFaceMatchJob,
+ photo_querier: AsyncMock,
+ user_match_service: AsyncMock,
+ ) -> None:
+ user_match_service.find_closest_user.return_value = None # empty DB
+
+ await service.process_detected_face(job, _make_embedding(), bbox=None)
+
+ photo_querier.update_photo_status.assert_called_once_with(
+ id=job.photo_id, status="approved"
+ )
+
+ @pytest.mark.asyncio
+ async def test_no_notification_when_auto_approved(
+ self,
+ service: SingleFaceMatchService,
+ job: SingleFaceMatchJob,
+ user_match_service: AsyncMock,
+ notification_service: AsyncMock,
+ ) -> None:
+ user_match_service.find_closest_user.return_value = None
+
+ await service.process_detected_face(job, _make_embedding(), bbox=None)
+
+ notification_service.create_notification.assert_not_called()
+
+
+# ===========================================================================
+# 2. Auto-approve — distance above threshold
+# ===========================================================================
+
+
+class TestAutoApproveDistanceThreshold:
+ @pytest.mark.asyncio
+ async def test_photo_approved_when_distance_exceeds_threshold(
+ self,
+ service: SingleFaceMatchService,
+ job: SingleFaceMatchJob,
+ photo_querier: AsyncMock,
+ user_match_service: AsyncMock,
+ ) -> None:
+ from app.worker.photo_worker.settings import settings as worker_settings
+
+ # Distance just above the threshold → no match → auto-approve
+ bad_match = _closest_match(distance=worker_settings.similarity_threshold + 0.01)
+ user_match_service.find_closest_user.return_value = bad_match
+
+ await service.process_detected_face(job, _make_embedding(), bbox=None)
+
+ photo_querier.update_photo_status.assert_called_once_with(
+ id=job.photo_id, status="approved"
+ )
+
+ @pytest.mark.asyncio
+ async def test_no_db_write_when_distance_exceeds_threshold(
+ self,
+ service: SingleFaceMatchService,
+ job: SingleFaceMatchJob,
+ photo_face_querier: AsyncMock,
+ user_match_service: AsyncMock,
+ ) -> None:
+ from app.worker.photo_worker.settings import settings as worker_settings
+
+ bad_match = _closest_match(distance=worker_settings.similarity_threshold + 0.01)
+ user_match_service.find_closest_user.return_value = bad_match
+
+ await service.process_detected_face(job, _make_embedding(), bbox=None)
+
+ photo_face_querier.photo_faces_ensure_face_match.assert_not_called()
+
+
+# ===========================================================================
+# 3. Happy-path match → notification sent
+# ===========================================================================
+
+
+class TestSuccessfulMatch:
+ @pytest.mark.asyncio
+ async def test_face_match_stored_in_db(
+ self,
+ service: SingleFaceMatchService,
+ job: SingleFaceMatchJob,
+ photo_face_querier: AsyncMock,
+ user_match_service: AsyncMock,
+ ) -> None:
+ good_match = _closest_match(distance=0.1)
+ user_match_service.find_closest_user.return_value = good_match
+
+ face_match_result = MagicMock()
+ face_match_result.face_match_id = uuid.uuid4()
+ photo_face_querier.photo_faces_ensure_face_match.return_value = face_match_result
+
+ await service.process_detected_face(job, _make_embedding(), bbox=None)
+
+ photo_face_querier.photo_faces_ensure_face_match.assert_called_once()
+
+ @pytest.mark.asyncio
+ async def test_photo_status_set_to_approved_on_match(
+ self,
+ service: SingleFaceMatchService,
+ job: SingleFaceMatchJob,
+ photo_face_querier: AsyncMock,
+ photo_querier: AsyncMock,
+ user_match_service: AsyncMock,
+ ) -> None:
+ good_match = _closest_match(distance=0.1)
+ user_match_service.find_closest_user.return_value = good_match
+
+ face_match_result = MagicMock()
+ face_match_result.face_match_id = uuid.uuid4()
+ photo_face_querier.photo_faces_ensure_face_match.return_value = face_match_result
+
+ await service.process_detected_face(job, _make_embedding(), bbox=None)
+
+ photo_querier.update_photo_status.assert_called_once_with(
+ id=job.photo_id, status="approved"
+ )
+
+ @pytest.mark.asyncio
+ async def test_notification_sent_on_successful_match(
+ self,
+ service: SingleFaceMatchService,
+ job: SingleFaceMatchJob,
+ photo_face_querier: AsyncMock,
+ user_match_service: AsyncMock,
+ notification_service: AsyncMock,
+ ) -> None:
+ good_match = _closest_match(distance=0.1)
+ user_match_service.find_closest_user.return_value = good_match
+
+ face_match_result = MagicMock()
+ face_match_result.face_match_id = uuid.uuid4()
+ photo_face_querier.photo_faces_ensure_face_match.return_value = face_match_result
+
+ await service.process_detected_face(job, _make_embedding(), bbox=None)
+
+ notification_service.create_notification.assert_called_once()
+ call_kwargs = notification_service.create_notification.call_args.kwargs
+ assert call_kwargs["type"] == "face_match"
+ assert call_kwargs["user_id"] == good_match.user_id # type: ignore[union-attr]
+
+ @pytest.mark.asyncio
+ async def test_bbox_serialised_as_json(
+ self,
+ service: SingleFaceMatchService,
+ job: SingleFaceMatchJob,
+ photo_face_querier: AsyncMock,
+ user_match_service: AsyncMock,
+ bbox: BBoxPayload,
+ ) -> None:
+ good_match = _closest_match(distance=0.1)
+ user_match_service.find_closest_user.return_value = good_match
+
+ face_match_result = MagicMock()
+ face_match_result.face_match_id = uuid.uuid4()
+ photo_face_querier.photo_faces_ensure_face_match.return_value = face_match_result
+
+ await service.process_detected_face(job, _make_embedding(), bbox=bbox)
+
+ call_args = photo_face_querier.photo_faces_ensure_face_match.call_args
+ params = call_args.args[0]
+ parsed_bbox = json.loads(params.bbox)
+ assert parsed_bbox == {"x1": 10.0, "y1": 20.0, "x2": 100.0, "y2": 200.0}
+
+
+# ===========================================================================
+# 4. Idempotency guards
+# ===========================================================================
+
+
+class TestIdempotencyGuards:
+ @pytest.mark.asyncio
+ async def test_skips_if_photo_not_found(
+ self,
+ service: SingleFaceMatchService,
+ job: SingleFaceMatchJob,
+ photo_face_querier: AsyncMock,
+ photo_querier: AsyncMock,
+ ) -> None:
+ photo_face_querier.photo_faces_photo_exists.return_value = None # not found
+
+ await service.process_detected_face(job, _make_embedding(), bbox=None)
+
+ photo_querier.update_photo_status.assert_not_called()
+ photo_face_querier.photo_faces_ensure_face_match.assert_not_called()
+
+ @pytest.mark.asyncio
+ async def test_skips_if_match_already_exists(
+ self,
+ service: SingleFaceMatchService,
+ job: SingleFaceMatchJob,
+ photo_face_querier: AsyncMock,
+ photo_querier: AsyncMock,
+ ) -> None:
+ photo_face_querier.photo_faces_match_exists_for_photo.return_value = object() # exists
+
+ await service.process_detected_face(job, _make_embedding(), bbox=None)
+
+ photo_querier.update_photo_status.assert_not_called()
+ photo_face_querier.photo_faces_ensure_face_match.assert_not_called()
+
+ @pytest.mark.asyncio
+ async def test_skips_if_missing_image_ref(
+ self,
+ service: SingleFaceMatchService,
+ photo_querier: AsyncMock,
+ ) -> None:
+ job_no_ref = SingleFaceMatchJob(
+ photo_id=uuid.uuid4(),
+ image_ref="", # empty
+ face_index=0,
+ )
+
+ await service.process_detected_face(job_no_ref, _make_embedding(), bbox=None)
+
+ photo_querier.update_photo_status.assert_not_called()
+
+ @pytest.mark.asyncio
+ async def test_no_notification_if_face_match_already_existed(
+ self,
+ service: SingleFaceMatchService,
+ job: SingleFaceMatchJob,
+ photo_face_querier: AsyncMock,
+ user_match_service: AsyncMock,
+ notification_service: AsyncMock,
+ ) -> None:
+ """If ensure_face_match returns a result with face_match_id=None, it means
+ the match was already there — no duplicate notification should be sent."""
+ good_match = _closest_match(distance=0.1)
+ user_match_service.find_closest_user.return_value = good_match
+
+ result_already_existed = MagicMock()
+ result_already_existed.face_match_id = None # already existed
+ photo_face_querier.photo_faces_ensure_face_match.return_value = result_already_existed
+
+ await service.process_detected_face(job, _make_embedding(), bbox=None)
+
+ notification_service.create_notification.assert_not_called()
+
+
+# ===========================================================================
+# 5. Resilience — DB errors don't crash the worker
+# ===========================================================================
+
+
+class TestResilience:
+ @pytest.mark.asyncio
+ async def test_db_error_is_handled_gracefully(
+ self,
+ service: SingleFaceMatchService,
+ job: SingleFaceMatchJob,
+ photo_face_querier: AsyncMock,
+ user_match_service: AsyncMock,
+ notification_service: AsyncMock,
+ ) -> None:
+ from sqlalchemy.exc import SQLAlchemyError
+
+ good_match = _closest_match(distance=0.1)
+ user_match_service.find_closest_user.return_value = good_match
+ photo_face_querier.photo_faces_ensure_face_match.side_effect = SQLAlchemyError("DB down")
+
+ # Should not raise — worker must stay alive
+ await service.process_detected_face(job, _make_embedding(), bbox=None)
+ notification_service.create_notification.assert_not_called()
+
+ @pytest.mark.asyncio
+ async def test_memory_error_is_handled_gracefully(
+ self,
+ service: SingleFaceMatchService,
+ job: SingleFaceMatchJob,
+ user_match_service: AsyncMock,
+ ) -> None:
+ user_match_service.find_closest_user.side_effect = MemoryError("OOM")
+
+ # Should not raise
+ await service.process_detected_face(job, _make_embedding(), bbox=None)
+
+
+# ===========================================================================
+# 6. Static helpers
+# ===========================================================================
+
+
+class TestStaticHelpers:
+ def test_vector_literal_format(self) -> None:
+ embedding = [0.1, 0.2, 0.3]
+ result = SingleFaceMatchService._vector_literal(embedding)
+ assert result == "[0.1, 0.2, 0.3]"
+
+ def test_serialize_bbox_none_returns_none(self) -> None:
+ assert SingleFaceMatchService._serialize_bbox(None) is None
+
+ def test_serialize_bbox_valid(self) -> None:
+ bbox = BBoxPayload(x1=1.0, y1=2.0, x2=3.0, y2=4.0)
+ result = SingleFaceMatchService._serialize_bbox(bbox)
+ assert result is not None
+ parsed = json.loads(result)
+ assert parsed == {"x1": 1.0, "y1": 2.0, "x2": 3.0, "y2": 4.0}
diff --git a/tests/unit/test_mobile_auth_email_logging.py b/tests/unit/test_mobile_auth_email_logging.py
index 696e095b..1d3b11a3 100644
--- a/tests/unit/test_mobile_auth_email_logging.py
+++ b/tests/unit/test_mobile_auth_email_logging.py
@@ -133,7 +133,5 @@ async def _noop_cache_session_for_auth(**_: object) -> None:
# Verify no plaintext email in logs
assert req.email not in caplog.text
assert "user@example.com" not in caplog.text
- # Verify user_id is logged instead
- assert "user_id=" in caplog.text
- assert "session_id=" in caplog.text
+ assert "mobile_register attempt" in caplog.text
diff --git a/tests/unit/test_mobile_auth_intent_validation.py b/tests/unit/test_mobile_auth_intent_validation.py
index 0f81e97d..4d58ce16 100644
--- a/tests/unit/test_mobile_auth_intent_validation.py
+++ b/tests/unit/test_mobile_auth_intent_validation.py
@@ -113,6 +113,9 @@ async def ttl(self, key: str) -> int:
async def set(self, key: str, value: str, expire: int) -> None:
return None
+ async def delete(self, key: str) -> None:
+ self._store.pop(key, None)
+
class FakeFaceEmbeddingService:
pass
@@ -233,9 +236,7 @@ async def _noop_cache_session_for_auth(**_: object) -> None:
monkeypatch.setattr(users_module, "Get_expiry_time", lambda: 3600)
result = asyncio.run(service.mobile_register(FakeRedis(), req))
- assert result.access_token == "access"
- assert result.refresh_token == "refresh"
- assert result.is_new_user is True
+ assert result.status == "pending_verification"
def test_register_then_login_same_device_succeeds(
@@ -270,13 +271,38 @@ async def _noop_cache_session_for_auth(**_: object) -> None:
device_type="android",
device_id=device_id,
)
- result1 = asyncio.run(service.mobile_register(FakeRedis(), register_req))
- assert result1.is_new_user is True
+ fake_redis = FakeRedis()
+ result1 = asyncio.run(service.mobile_register(fake_redis, register_req))
+ assert result1.status == "pending_verification"
+
+ # Try to register again (should just resend OTP, not fail with 409 because user is not yet fully enrolled)
+ # Actually, we expect 200 with pending_verification again if they try to register while pending, OR it might
+ # just succeed. Wait, the actual flow drops it in Redis and returns pending. So it won't raise 409 unless
+ # the user is IN THE DB. Since FakeUserQuerier won't have it, it won't raise 409.
+ # We will just verify OTP instead.
+
+ # Now verify to fully create user
+ from app.schema.request.mobile.auth import RegisterVerifyRequest
+ verify_req = RegisterVerifyRequest(
+ email="newuser@example.com",
+ password=password,
+ otp="123456",
+ device_name="TestDevice",
+ device_type="android",
+ device_id=device_id,
+ )
+ # Fake Redis returning the raw data and otp
+ import json
+ fake_redis._store["otp:newuser@example.com"] = "123456"
+ fake_redis._store["pending_user:newuser@example.com"] = json.dumps({"hashed_password": hash_password(password)})
- # Try to register again (should fail)
- with pytest.raises(HTTPException) as exc_info:
- asyncio.run(service.mobile_register(FakeRedis(), register_req))
- assert exc_info.value.status_code == 409
+ # We have to stub get() on FakeRedis since it doesn't support it by default
+ async def fake_get(key: str) -> str | None:
+ return fake_redis._store.get(key)
+ fake_redis.get = fake_get # type: ignore
+
+ verify_result = asyncio.run(service.verify_mobile_register(fake_redis, verify_req))
+ assert verify_result.is_new_user is True
# Now login
login_req = MobileLoginRequest(
@@ -384,16 +410,28 @@ async def _raise_integrity_error(*args: Any, **kwargs: Any) -> Any:
face_embedding_service=FakeFaceEmbeddingService(),
)
- req = MobileRegisterRequest(
+ # Stub create_user to raise IntegrityError during VERIFY, because mobile_register
+ # no longer calls create_user directly!
+ from app.schema.request.mobile.auth import RegisterVerifyRequest
+ verify_req = RegisterVerifyRequest(
email="newuser@example.com",
password="ValidPass@123",
+ otp="123456",
device_name="Pixel 8",
device_type="android",
device_id=uuid.uuid4(),
)
+ fake_redis = FakeRedis()
+ import json
+ fake_redis._store["otp:newuser@example.com"] = "123456"
+ fake_redis._store["pending_user:newuser@example.com"] = json.dumps({"hashed_password": hash_password("ValidPass@123")})
+ async def fake_get(key: str) -> str | None:
+ return fake_redis._store.get(key)
+ fake_redis.get = fake_get # type: ignore
+
with pytest.raises(HTTPException) as exc_info:
- asyncio.run(service.mobile_register(FakeRedis(), req))
+ asyncio.run(service.verify_mobile_register(fake_redis, verify_req))
assert exc_info.value.status_code == 409
assert "already in use" in exc_info.value.detail.lower()
diff --git a/tests/unit/test_mobile_auth_request_validation.py b/tests/unit/test_mobile_auth_request_validation.py
index eb61902c..56d3a82e 100644
--- a/tests/unit/test_mobile_auth_request_validation.py
+++ b/tests/unit/test_mobile_auth_request_validation.py
@@ -4,11 +4,12 @@
import pytest
from fastapi.testclient import TestClient
+from unittest.mock import AsyncMock, patch
from app.container import get_container
from app.main import app
from app.schema.request.mobile.auth import MobileLoginRequest, MobileRegisterRequest
-from app.schema.response.mobile.auth import MobileAuthResponse
+from app.schema.response.mobile.auth import MobileAuthResponse, RegisterPendingResponse
class FakeAuthService:
@@ -23,16 +24,13 @@ async def mobile_register(
redis: object,
req: MobileRegisterRequest,
client_ip: object = None,
- ) -> MobileAuthResponse:
+ ) -> RegisterPendingResponse:
self.register_request = req
self.register_client_ip = client_ip
- return MobileAuthResponse(
- access_token="access",
- refresh_token="refresh",
- session_id=str(uuid.uuid4()),
- expires_in=3600,
- user_id=uuid.uuid4(),
- is_new_user=True,
+ return RegisterPendingResponse(
+ message="OTP sent",
+ status="pending_verification",
+ email=req.email,
)
async def mobile_login(
@@ -70,13 +68,18 @@ def fake_container() -> FakeContainer:
return FakeContainer()
+
@pytest.fixture
def client(fake_container: FakeContainer) -> Iterator[TestClient]:
app.dependency_overrides[get_container] = lambda: fake_container
- try:
- yield TestClient(app)
- finally:
- app.dependency_overrides.clear()
+ with patch("app.deps.rate_limit.RedisClient.get_instance") as mock_get_instance:
+ mock_redis = AsyncMock()
+ mock_redis.incr.return_value = 1
+ mock_get_instance.return_value = mock_redis
+ try:
+ yield TestClient(app)
+ finally:
+ app.dependency_overrides.clear()
def _valid_payload() -> dict[str, object]:
diff --git a/tests/unit/test_photo_approval_lifecycle.py b/tests/unit/test_photo_approval_lifecycle.py
new file mode 100644
index 00000000..00577e6a
--- /dev/null
+++ b/tests/unit/test_photo_approval_lifecycle.py
@@ -0,0 +1,166 @@
+import uuid
+from unittest.mock import AsyncMock, MagicMock
+import pytest
+
+from app.worker.photo_worker.main import PhotoWorker
+from app.worker.photo_worker.schema.event import PhotoProcessEvent
+from app.service.face_embedding import DetectedFace
+from app.service.photo_approval import PhotoApprovalService
+
+@pytest.fixture
+def mock_conn() -> AsyncMock:
+ return AsyncMock()
+
+@pytest.fixture
+def mock_face_embedding_service() -> AsyncMock:
+ return AsyncMock()
+
+@pytest.fixture
+def mock_single_face_service() -> AsyncMock:
+ return AsyncMock()
+
+@pytest.fixture
+def mock_notification_service() -> AsyncMock:
+ return AsyncMock()
+
+@pytest.fixture
+def mock_photo_face_querier() -> AsyncMock:
+ return AsyncMock()
+
+@pytest.fixture
+def mock_photo_querier() -> AsyncMock:
+ return AsyncMock()
+
+@pytest.fixture
+def mock_photo_approval_querier() -> AsyncMock:
+ return AsyncMock()
+
+@pytest.fixture
+def mock_processing_job_querier() -> AsyncMock:
+ return AsyncMock()
+
+@pytest.fixture
+def mock_staged_upload_storage_service() -> AsyncMock:
+ return AsyncMock()
+
+
+@pytest.mark.asyncio
+async def test_group_photo_pending_no_users(
+ mock_conn: AsyncMock,
+ mock_face_embedding_service: AsyncMock,
+ mock_single_face_service: AsyncMock,
+ mock_notification_service: AsyncMock,
+ mock_photo_face_querier: AsyncMock,
+ mock_photo_querier: AsyncMock,
+ mock_processing_job_querier: AsyncMock,
+) -> None:
+ """
+ Test: Group photo with no enrolled users -> photo becomes approved + public.
+ """
+ worker = PhotoWorker(
+ conn=mock_conn,
+ face_embedding_service=mock_face_embedding_service,
+ single_face_service=mock_single_face_service,
+ user_notification_service=mock_notification_service,
+ photo_face_querier=mock_photo_face_querier,
+ photo_querier=mock_photo_querier,
+ processing_job_querier=mock_processing_job_querier,
+ )
+
+ photo_id = uuid.uuid4()
+ event = PhotoProcessEvent(photo_id=photo_id, image_ref="test.jpg")
+
+ # 2 faces detected
+ faces = [
+ DetectedFace(bbox=(0.0, 0.0, 10.0, 10.0), embedding=[0.1, 0.2]),
+ DetectedFace(bbox=(10.0, 10.0, 20.0, 20.0), embedding=[0.3, 0.4]),
+ ]
+
+ # No face matches any user -> insert_photo_face_with_approval returns None
+ mock_photo_face_querier.insert_photo_face_with_approval.return_value = None
+
+ await worker._handle_group_photo(event, faces)
+
+ # Verify notifications were NOT sent
+ mock_notification_service.create_notification.assert_not_called()
+
+ # Verify photo stays pending (not marked public or approved)
+ mock_photo_querier.update_photo_status.assert_not_called()
+ mock_photo_querier.update_photo_visibility.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_group_photo_pending_with_enrolled_users(
+ mock_conn: AsyncMock,
+ mock_face_embedding_service: AsyncMock,
+ mock_single_face_service: AsyncMock,
+ mock_notification_service: AsyncMock,
+ mock_photo_face_querier: AsyncMock,
+ mock_photo_querier: AsyncMock,
+ mock_processing_job_querier: AsyncMock,
+) -> None:
+ """
+ Test: Group photo with at least one enrolled user -> approval records created, notifications sent, photo stays pending.
+ """
+ worker = PhotoWorker(
+ conn=mock_conn,
+ face_embedding_service=mock_face_embedding_service,
+ single_face_service=mock_single_face_service,
+ user_notification_service=mock_notification_service,
+ photo_face_querier=mock_photo_face_querier,
+ photo_querier=mock_photo_querier,
+ processing_job_querier=mock_processing_job_querier,
+ )
+
+ photo_id = uuid.uuid4()
+ event = PhotoProcessEvent(photo_id=photo_id, image_ref="test.jpg")
+
+ faces = [
+ DetectedFace(bbox=(0.0, 0.0, 10.0, 10.0), embedding=[0.1, 0.2]),
+ ]
+
+ # Mock DB returning an approval record
+ mock_approval = MagicMock()
+ mock_approval.user_id = uuid.uuid4()
+ mock_approval.photo_id = photo_id
+ mock_photo_face_querier.insert_photo_face_with_approval.return_value = mock_approval
+
+ await worker._handle_group_photo(event, faces)
+
+ # Verify notification WAS sent
+ mock_notification_service.create_notification.assert_called_once()
+ args, kwargs = mock_notification_service.create_notification.call_args
+ assert kwargs["user_id"] == mock_approval.user_id
+
+ # Verify photo status is NOT updated to approved (stays pending)
+ mock_photo_querier.update_photo_status.assert_not_called()
+ mock_photo_querier.update_photo_visibility.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_expire_stale_marks_photos_approved(
+ mock_photo_approval_querier: AsyncMock,
+ mock_photo_querier: AsyncMock,
+ mock_staged_upload_storage_service: AsyncMock,
+) -> None:
+ """
+ Test: After PHOTO_APPROVAL_TIMEOUT_DAYS days, expire_stale marks photos approved
+ """
+ service = PhotoApprovalService(
+ photo_approval_querier=mock_photo_approval_querier,
+ photo_querier=mock_photo_querier,
+ storage_service=mock_staged_upload_storage_service,
+ )
+
+ from typing import Any, AsyncIterator
+ # Mock the generator for expire_stale_approvals
+ async def mock_generator(*args: Any, **kwargs: Any) -> AsyncIterator[uuid.UUID]:
+ yield uuid.uuid4()
+ yield uuid.uuid4()
+
+ mock_photo_approval_querier.expire_stale_approvals = MagicMock(side_effect=mock_generator)
+
+ count = await service.expire_stale(timeout_days=7)
+
+ assert count == 2
+ mock_photo_approval_querier.expire_stale_approvals.assert_called_once_with(timeout_days=7)
diff --git a/tests/unit/test_photo_approval_service.py b/tests/unit/test_photo_approval_service.py
new file mode 100644
index 00000000..d10c62bf
--- /dev/null
+++ b/tests/unit/test_photo_approval_service.py
@@ -0,0 +1,333 @@
+"""
+Unit tests for PhotoApprovalService.
+
+All queriers and the storage service are mocked — no live infrastructure.
+Tests cover: approve/reject/pending decision logic, storage cleanup on rejection,
+audit logging, and error resilience.
+"""
+
+import uuid
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+from fastapi.exceptions import HTTPException
+
+from app.service.photo_approval import PhotoApprovalService
+from app.core.constant import AuditEventType
+
+
+# ---------------------------------------------------------------------------
+# Minimal stubs for DB models
+# ---------------------------------------------------------------------------
+
+
+def _make_approval(decision: str, user_id: uuid.UUID | None = None) -> MagicMock:
+ a = MagicMock()
+ a.decision = decision
+ a.user_id = user_id or uuid.uuid4()
+ return a
+
+
+def _make_photo(storage_key: str = "photos/test.jpg") -> MagicMock:
+ p = MagicMock()
+ p.storage_key = storage_key
+ return p
+
+
+# ---------------------------------------------------------------------------
+# Fixtures
+# ---------------------------------------------------------------------------
+
+
+@pytest.fixture
+def approval_querier() -> AsyncMock:
+ from db.generated import photo_approvals as pa_queries
+ q = MagicMock(spec=pa_queries.AsyncQuerier)
+ q.update_photo_approval_decision = AsyncMock()
+ q.get_photo_approvals_by_photo_id = MagicMock() # async generator
+ return q
+
+
+@pytest.fixture
+def photo_querier() -> AsyncMock:
+ from db.generated import photos as photo_queries
+ q = MagicMock(spec=photo_queries.AsyncQuerier)
+ q.update_photo_status = AsyncMock(return_value=None)
+ q.get_photo_by_id = AsyncMock(return_value=_make_photo())
+ return q
+
+
+@pytest.fixture
+def storage_service() -> AsyncMock:
+ from app.service.staged_upload_storage import StagedUploadStorageService
+ svc = MagicMock(spec=StagedUploadStorageService)
+ svc.delete_storage_key = AsyncMock()
+ return svc
+
+
+@pytest.fixture
+def audit_service() -> AsyncMock:
+ from app.service.audit import AuditService
+ svc = MagicMock(spec=AuditService)
+ svc.create_record = AsyncMock()
+ return svc
+
+
+def _make_service(
+ approval_querier: AsyncMock,
+ photo_querier: AsyncMock,
+ storage_service: AsyncMock,
+ audit_service: AsyncMock | None = None,
+) -> PhotoApprovalService:
+ return PhotoApprovalService(
+ photo_approval_querier=approval_querier,
+ photo_querier=photo_querier,
+ storage_service=storage_service,
+ audit_service=audit_service,
+ )
+
+
+def _mock_async_iter(items: list[object]): # type: ignore[type-arg]
+ """Return a MagicMock that behaves like an async for loop."""
+ async def _gen(): # type: ignore[return]
+ for item in items:
+ yield item
+ return _gen()
+
+
+# ===========================================================================
+# 1. All decisions == "approved" → photo status becomes "approved"
+# ===========================================================================
+
+
+class TestApproveDecision:
+ @pytest.mark.asyncio
+ async def test_all_approved_sets_photo_status(
+ self,
+ approval_querier: AsyncMock,
+ photo_querier: AsyncMock,
+ storage_service: AsyncMock,
+ ) -> None:
+ photo_id = uuid.uuid4()
+ user_id = uuid.uuid4()
+ approvals = [_make_approval("approved"), _make_approval("approved")]
+
+ approval_querier.update_photo_approval_decision.return_value = MagicMock()
+ approval_querier.get_photo_approvals_by_photo_id.return_value = _mock_async_iter(approvals)
+
+ service = _make_service(approval_querier, photo_querier, storage_service)
+ result = await service.decide(photo_id=photo_id, user_id=user_id, decision="approved")
+
+ assert result == "approved"
+ photo_querier.update_photo_status.assert_called_once_with(
+ id=photo_id, status="approved"
+ )
+
+ @pytest.mark.asyncio
+ async def test_all_approved_does_not_delete_storage(
+ self,
+ approval_querier: AsyncMock,
+ photo_querier: AsyncMock,
+ storage_service: AsyncMock,
+ ) -> None:
+ photo_id = uuid.uuid4()
+ user_id = uuid.uuid4()
+ approvals = [_make_approval("approved")]
+
+ approval_querier.update_photo_approval_decision.return_value = MagicMock()
+ approval_querier.get_photo_approvals_by_photo_id.return_value = _mock_async_iter(approvals)
+
+ service = _make_service(approval_querier, photo_querier, storage_service)
+ await service.decide(photo_id=photo_id, user_id=user_id, decision="approved")
+
+ storage_service.delete_storage_key.assert_not_called()
+
+
+# ===========================================================================
+# 2. One rejection → photo status becomes "rejected" + storage deleted
+# ===========================================================================
+
+
+class TestRejectDecision:
+ @pytest.mark.asyncio
+ async def test_one_rejection_sets_photo_status_rejected(
+ self,
+ approval_querier: AsyncMock,
+ photo_querier: AsyncMock,
+ storage_service: AsyncMock,
+ ) -> None:
+ photo_id = uuid.uuid4()
+ user_id = uuid.uuid4()
+ approvals = [_make_approval("approved"), _make_approval("rejected")]
+
+ approval_querier.update_photo_approval_decision.return_value = MagicMock()
+ approval_querier.get_photo_approvals_by_photo_id.return_value = _mock_async_iter(approvals)
+
+ service = _make_service(approval_querier, photo_querier, storage_service)
+ result = await service.decide(photo_id=photo_id, user_id=user_id, decision="rejected")
+
+ assert result == "rejected"
+ photo_querier.update_photo_status.assert_called_once_with(
+ id=photo_id, status="rejected"
+ )
+
+ @pytest.mark.asyncio
+ async def test_rejection_triggers_storage_deletion(
+ self,
+ approval_querier: AsyncMock,
+ photo_querier: AsyncMock,
+ storage_service: AsyncMock,
+ ) -> None:
+ photo_id = uuid.uuid4()
+ user_id = uuid.uuid4()
+ approvals = [_make_approval("rejected")]
+ storage_key = "photos/reject-me.jpg"
+ photo_querier.get_photo_by_id.return_value = _make_photo(storage_key=storage_key)
+
+ approval_querier.update_photo_approval_decision.return_value = MagicMock()
+ approval_querier.get_photo_approvals_by_photo_id.return_value = _mock_async_iter(approvals)
+
+ service = _make_service(approval_querier, photo_querier, storage_service)
+ await service.decide(photo_id=photo_id, user_id=user_id, decision="rejected")
+
+ storage_service.delete_storage_key.assert_called_once_with(storage_key)
+
+ @pytest.mark.asyncio
+ async def test_storage_deletion_failure_does_not_raise(
+ self,
+ approval_querier: AsyncMock,
+ photo_querier: AsyncMock,
+ storage_service: AsyncMock,
+ ) -> None:
+ """Storage errors must be swallowed — they should not surface to the client."""
+ photo_id = uuid.uuid4()
+ user_id = uuid.uuid4()
+ approvals = [_make_approval("rejected")]
+
+ approval_querier.update_photo_approval_decision.return_value = MagicMock()
+ approval_querier.get_photo_approvals_by_photo_id.return_value = _mock_async_iter(approvals)
+ storage_service.delete_storage_key.side_effect = Exception("MinIO unavailable")
+
+ service = _make_service(approval_querier, photo_querier, storage_service)
+ # Must not raise despite MinIO being unavailable
+ result = await service.decide(photo_id=photo_id, user_id=user_id, decision="rejected")
+ assert result == "rejected"
+
+
+# ===========================================================================
+# 3. Pending approvals → returns "pending", no status update
+# ===========================================================================
+
+
+class TestPendingDecision:
+ @pytest.mark.asyncio
+ async def test_pending_approval_returns_pending(
+ self,
+ approval_querier: AsyncMock,
+ photo_querier: AsyncMock,
+ storage_service: AsyncMock,
+ ) -> None:
+ photo_id = uuid.uuid4()
+ user_id = uuid.uuid4()
+ approvals = [_make_approval("approved"), _make_approval("pending")]
+
+ approval_querier.update_photo_approval_decision.return_value = MagicMock()
+ approval_querier.get_photo_approvals_by_photo_id.return_value = _mock_async_iter(approvals)
+
+ service = _make_service(approval_querier, photo_querier, storage_service)
+ result = await service.decide(photo_id=photo_id, user_id=user_id, decision="approved")
+
+ assert result == "pending"
+
+ @pytest.mark.asyncio
+ async def test_pending_does_not_update_photo_status(
+ self,
+ approval_querier: AsyncMock,
+ photo_querier: AsyncMock,
+ storage_service: AsyncMock,
+ ) -> None:
+ photo_id = uuid.uuid4()
+ user_id = uuid.uuid4()
+ approvals = [_make_approval("pending"), _make_approval("pending")]
+
+ approval_querier.update_photo_approval_decision.return_value = MagicMock()
+ approval_querier.get_photo_approvals_by_photo_id.return_value = _mock_async_iter(approvals)
+
+ service = _make_service(approval_querier, photo_querier, storage_service)
+ await service.decide(photo_id=photo_id, user_id=user_id, decision="approved")
+
+ photo_querier.update_photo_status.assert_not_called()
+
+
+# ===========================================================================
+# 4. Not found → raises 404
+# ===========================================================================
+
+
+class TestNotFound:
+ @pytest.mark.asyncio
+ async def test_not_found_raises_404(
+ self,
+ approval_querier: AsyncMock,
+ photo_querier: AsyncMock,
+ storage_service: AsyncMock,
+ ) -> None:
+ photo_id = uuid.uuid4()
+ approval_querier.update_photo_approval_decision.return_value = None # not found
+
+ service = _make_service(approval_querier, photo_querier, storage_service)
+ with pytest.raises(HTTPException) as exc_info:
+ await service.decide(
+ photo_id=photo_id, user_id=uuid.uuid4(), decision="approved"
+ )
+ assert exc_info.value.status_code == 404
+
+
+# ===========================================================================
+# 5. Audit logging
+# ===========================================================================
+
+
+class TestAuditLogging:
+ @pytest.mark.asyncio
+ async def test_audit_called_with_correct_event_type(
+ self,
+ approval_querier: AsyncMock,
+ photo_querier: AsyncMock,
+ storage_service: AsyncMock,
+ audit_service: AsyncMock,
+ ) -> None:
+ photo_id = uuid.uuid4()
+ user_id = uuid.uuid4()
+ approvals = [_make_approval("approved")]
+
+ approval_querier.update_photo_approval_decision.return_value = MagicMock()
+ approval_querier.get_photo_approvals_by_photo_id.return_value = _mock_async_iter(approvals)
+
+ service = _make_service(approval_querier, photo_querier, storage_service, audit_service)
+ await service.decide(photo_id=photo_id, user_id=user_id, decision="approved")
+
+ audit_service.create_record.assert_called_once()
+ call_kwargs = audit_service.create_record.call_args.kwargs
+ assert call_kwargs["event_type"] == AuditEventType.PHOTO_APPROVAL_DECIDED
+ assert call_kwargs["user_id"] == user_id
+ assert str(photo_id) in str(call_kwargs["metadata"])
+
+ @pytest.mark.asyncio
+ async def test_no_audit_without_audit_service(
+ self,
+ approval_querier: AsyncMock,
+ photo_querier: AsyncMock,
+ storage_service: AsyncMock,
+ ) -> None:
+ """When audit_service=None, no error must be raised."""
+ photo_id = uuid.uuid4()
+ approvals = [_make_approval("approved")]
+
+ approval_querier.update_photo_approval_decision.return_value = MagicMock()
+ approval_querier.get_photo_approvals_by_photo_id.return_value = _mock_async_iter(approvals)
+
+ # No audit_service passed → audit_service=None
+ service = _make_service(approval_querier, photo_querier, storage_service, audit_service=None)
+ await service.decide(photo_id=photo_id, user_id=uuid.uuid4(), decision="approved")
+ # no assertion needed — just must not raise
diff --git a/uv.lock b/uv.lock
index afaadba3..f7b00087 100644
--- a/uv.lock
+++ b/uv.lock
@@ -305,6 +305,7 @@ dependencies = [
{ name = "bcrypt" },
{ name = "cryptography" },
{ name = "fastapi", extra = ["standard"] },
+ { name = "filetype" },
{ name = "firebase-admin" },
{ name = "greenlet" },
{ name = "insightface" },
@@ -315,6 +316,7 @@ dependencies = [
{ name = "opencv-python" },
{ name = "opencv-python-headless" },
{ name = "passlib", extra = ["bcrypt"] },
+ { name = "pillow-heif" },
{ name = "psycopg" },
{ name = "pydantic" },
{ name = "pydantic-settings" },
@@ -324,6 +326,7 @@ dependencies = [
{ name = "pywebpush" },
{ name = "redis" },
{ name = "setuptools" },
+ { name = "sqlalchemy" },
]
[package.dev-dependencies]
@@ -342,6 +345,7 @@ requires-dist = [
{ name = "bcrypt", specifier = "==4.3.0" },
{ name = "cryptography", specifier = ">=46.0.5" },
{ name = "fastapi", extras = ["standard"], specifier = ">=0.135.1" },
+ { name = "filetype", specifier = ">=1.2.0" },
{ name = "firebase-admin", specifier = ">=6.8.0" },
{ name = "greenlet", specifier = ">=3.3.2" },
{ name = "insightface", specifier = ">=0.7.3" },
@@ -352,6 +356,7 @@ requires-dist = [
{ name = "opencv-python", specifier = ">=4.13.0.92" },
{ name = "opencv-python-headless", specifier = ">=4.13.0.92" },
{ name = "passlib", extras = ["bcrypt"], specifier = ">=1.7.4" },
+ { name = "pillow-heif", specifier = ">=1.3.0" },
{ name = "psycopg", specifier = ">=3.3.3" },
{ name = "pydantic", specifier = ">=2.12.5" },
{ name = "pydantic-settings", specifier = ">=2.13.1" },
@@ -361,6 +366,7 @@ requires-dist = [
{ name = "pywebpush", specifier = ">=2.3.0" },
{ name = "redis", specifier = ">=7.2.1" },
{ name = "setuptools", specifier = ">=82.0.0" },
+ { name = "sqlalchemy", specifier = ">=2.0.47" },
]
[package.metadata.requires-dev]
@@ -919,6 +925,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/85/11/0aa8455af26f0ae89e42be67f3a874255ee5d7f0f026fc86e8d56f76b428/fastar-0.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:e59673307b6a08210987059a2bdea2614fe26e3335d0e5d1a3d95f49a05b1418", size = 460467, upload-time = "2025-11-26T02:36:07.978Z" },
]
+[[package]]
+name = "filetype"
+version = "1.2.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/bb/29/745f7d30d47fe0f251d3ad3dc2978a23141917661998763bebb6da007eb1/filetype-1.2.0.tar.gz", hash = "sha256:66b56cd6474bf41d8c54660347d37afcc3f7d1970648de365c102ef77548aadb", size = 998020, upload-time = "2022-11-02T17:34:04.141Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/18/79/1b8fa1bb3568781e84c9200f951c735f3f157429f44be0495da55894d620/filetype-1.2.0-py2.py3-none-any.whl", hash = "sha256:7ce71b6880181241cf7ac8697a2f1eb6a8bd9b429f7ad6d27b8db9ba5f1c2d25", size = 19970, upload-time = "2022-11-02T17:34:01.425Z" },
+]
+
[[package]]
name = "firebase-admin"
version = "6.8.0"
@@ -2318,6 +2333,45 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" },
]
+[[package]]
+name = "pillow-heif"
+version = "1.3.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pillow" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/cd/58/2df4fc42840633e01c97b75965cb1bc6e14425973b92382391650e97e4b7/pillow_heif-1.3.0.tar.gz", hash = "sha256:af8d2bda85e395677d5bb50d7bda3b5655c946cc95b913b5e7222fabacbb467f", size = 17133211, upload-time = "2026-02-27T12:21:36.465Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/67/f7/e0b13500470421536fdfe01acfc4c56daccd3d23655605aa04cfb30cc58c/pillow_heif-1.3.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:079abbcaeb42ef0849a33f35c1a96ccd431feb56b242a0d4f8435a1c8ca02c7d", size = 4667382, upload-time = "2026-02-27T12:20:45.149Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/fb/beb62f26231e7c76d235e993d18b4ddb3d2d427e93539b8e6eb8dc188fa7/pillow_heif-1.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:76c33f80ec111492642b98309db98516a7fba9677dcda9ec5fe9111b7e38d720", size = 3392733, upload-time = "2026-02-27T12:20:46.606Z" },
+ { url = "https://files.pythonhosted.org/packages/14/58/3d86e237b3a20c909f62a50e8cb3492ed6206675136d1ebddb168920261b/pillow_heif-1.3.0-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:33b838d06e2fd730f806af5a76bfc4cd3de9d146d88d37572e40f7a4c4ff8221", size = 5844247, upload-time = "2026-02-27T12:20:49.669Z" },
+ { url = "https://files.pythonhosted.org/packages/58/2a/826faf3df8c9ef9a19dc96bdfff34cf76f8b025540d5f931903d2c64f25c/pillow_heif-1.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f92b387af891cf5d98f52e79eeaf51ee7955a54fe2deeec12bfb7519e41464b5", size = 5578692, upload-time = "2026-02-27T12:20:51.219Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/4e/59f74fede18e3e06f98a448488df2fa4e5de1c159419d47ca345a51a0da0/pillow_heif-1.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f9f73246836f93f99343cbc3052b61d212d27e59ddf40262d494a1e3e54af31a", size = 6885928, upload-time = "2026-02-27T12:20:52.934Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/e4/bce83d2f4d703f418d252b509a6c9d6de52dc7c4eedcf6a286f871dea824/pillow_heif-1.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:84c3816742c2e49176e651895e73c555b9c3b0f3561d60230242f3be0c9d272b", size = 6511151, upload-time = "2026-02-27T12:20:54.236Z" },
+ { url = "https://files.pythonhosted.org/packages/41/c2/87d433a9681c79e0926d8a113ea153d592ec49d1d7aa7278ee798bc490f2/pillow_heif-1.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:5f0db0bf49162fb1d73d13340a9576b3a2805bde026a9a40038bcc1a0878d710", size = 5483578, upload-time = "2026-02-27T12:20:55.715Z" },
+ { url = "https://files.pythonhosted.org/packages/81/c3/9effa6ab5c2c2ffb80228143c578a9a2a8e2f059dd9d067ec6ff6f6c89db/pillow_heif-1.3.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:641c50a064aa9ad6626a6b2b914b65855202f937d573d53838e344feb2e8c6d1", size = 4667379, upload-time = "2026-02-27T12:20:57.561Z" },
+ { url = "https://files.pythonhosted.org/packages/23/eb/b6b52e3655f366b95301f18aecd2d35487cace18d17134b80ad0f70cc1eb/pillow_heif-1.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9390dd7987887aa09779fbd88bbab715c732c9ad3a71d6707284035e3ca93379", size = 3392725, upload-time = "2026-02-27T12:20:59.52Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/b3/b69610e9565fc8bcaf2303f412e857c0439d23cc18cf866c72a96ec6b2e6/pillow_heif-1.3.0-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e8444ccb330015e1db930207d269886e4b6c666121cd9e5fdad88735950b09f", size = 5844285, upload-time = "2026-02-27T12:21:00.771Z" },
+ { url = "https://files.pythonhosted.org/packages/47/8c/be44f6dea425a9756ff418cb03f5ee75ed1c7dd1ff9bee1f3893b2b82da4/pillow_heif-1.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d30054ccc97ecbe5ee3fa486a505ccc33bfbb27f005ad624ddb4c17b80ddd57", size = 5578691, upload-time = "2026-02-27T12:21:02.193Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/74/e12d49346a39e2204b408a835b31b2fd9a5d51f97ce3a6015cf22ca09a54/pillow_heif-1.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:dc1b9c9efdf8345d703118449ff69696d0827bdf28e3b52f82015f5714f7c23e", size = 6885923, upload-time = "2026-02-27T12:21:03.782Z" },
+ { url = "https://files.pythonhosted.org/packages/80/a6/51c937a9433f5ae9c625b686ee338bdf0080a1661f7eb34daaf75424ee77/pillow_heif-1.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee26b2155721e7f5f7b10fa93ca2ad3be59547c5c5e5d9d50e6ea17531b81d60", size = 6511216, upload-time = "2026-02-27T12:21:05.134Z" },
+ { url = "https://files.pythonhosted.org/packages/63/0a/bb8435e127f75b434166022471bbabf11c8c1fc3d48c8595fd6ab36c2785/pillow_heif-1.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:17ecbbadfe10ea12a65c1c12354dc1ed8ae1e5d1b7092ea753641b029f7d6f9e", size = 5483570, upload-time = "2026-02-27T12:21:06.566Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/17/aa056f8edb71396dd1131abcd0c6feab00097ceec89a12fc62d2dbc3ccf5/pillow_heif-1.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:8267a73d3b2d07a47a96428bd8cd4c406e1637a94f29d4c16ce08b31b8e50a07", size = 4667395, upload-time = "2026-02-27T12:21:08.16Z" },
+ { url = "https://files.pythonhosted.org/packages/19/1f/da50ccd271a2878d17df359301dc2f7a79ec1cbb6e92c19ccc8c6219d497/pillow_heif-1.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:36bbea7679467caa3a154db11c04f1ca2fa8591e886f06f40f7831c14b58d771", size = 3392800, upload-time = "2026-02-27T12:21:09.668Z" },
+ { url = "https://files.pythonhosted.org/packages/11/bc/1f89d927c1293cf283bc5d0ae6735d268d2de9749aa6fb94342ec838a457/pillow_heif-1.3.0-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea3a4b2de4b6c63407af72afdac901616807c6e6a030fe77851d227bca3727a", size = 5844547, upload-time = "2026-02-27T12:21:10.826Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/04/d781b23f8bff125c8dd8da63d928a35e38f2b727e89582a1fd323664e968/pillow_heif-1.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05149bd26b08dae5af7a389af6db13cef4f12c7871db73d84e40a1f3c83b0142", size = 5578827, upload-time = "2026-02-27T12:21:12.06Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/98/8dcdaafcf9bd8b26ed0569dc93653dc20a06faef7bfbdd4ba05c091c5b60/pillow_heif-1.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f8b7a50058fc3152f42b68aa2b30601249f61aa5c6c27876af076785c7051fd9", size = 6886088, upload-time = "2026-02-27T12:21:13.635Z" },
+ { url = "https://files.pythonhosted.org/packages/99/26/93f3c8bfffb7e8fe0244bf86117235c49c23980e61320e7484c03ac836e2/pillow_heif-1.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:edb3ef437e8841475db14721f0529e600bb55c41b549ad1794a0831e28f33bac", size = 6511291, upload-time = "2026-02-27T12:21:15.354Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/f9/a8c72619ec212eb2612730fa2b3068e2d4b59e0a0957c2e8418aa4cff59e/pillow_heif-1.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:bdd6695d5be0d98ae0e9a5f88fe26f1a6eca0a5b6d43d0a92a97f89fea5842f7", size = 5640949, upload-time = "2026-02-27T12:21:16.647Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/31/92ce30e1ada892e18a03042bd5a8414f655304a78a36790e657f14265fed/pillow_heif-1.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:65c5d05cb7f5e1eadbe9c605ae3a4dd3ef953adb33e7d809d5fb56f8a6753588", size = 4668365, upload-time = "2026-02-27T12:21:18.004Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/2b/789fa3c82063a780e84de667771b8ec30bc328511855f15a83a3c77011ec/pillow_heif-1.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:dc177fbdf598770cad4afa99c082a30b9d090e60c39656904338717803ae59b2", size = 3393554, upload-time = "2026-02-27T12:21:19.642Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/a4/4f8075f03c1d06d7afd674e263a3f57b7b24130c39b1544555b3b03ed369/pillow_heif-1.3.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:71f88d180547bb5112b56310c8c5e338d8358320a402c80afabc6b2f39eadddb", size = 5849609, upload-time = "2026-02-27T12:21:20.953Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/08/e33a10bc84ade1b4ec56bdc765735bbfd452513e33537df68107edc0eb86/pillow_heif-1.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9acee893186bdde6140d30a7dc6d7c928e4ad3007989764f6e54a7a517faa332", size = 5582931, upload-time = "2026-02-27T12:21:22.571Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/45/6afc0f29701e0c9b911b33a35760ae6e2c581fc49b431dcce22ed18abfba/pillow_heif-1.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7cf893689132bec18f0c55a505da9ebf3a8feb33dd354fe2ac050f20f4f862e0", size = 6891268, upload-time = "2026-02-27T12:21:24.021Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/0a/0d6a69f76f277692555d0e687dbf3e31d03cf76fffa3ced1fea51a18c481/pillow_heif-1.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:54404c9b6f0323114527579f54cc966b47206f99d943e47d73e1091ab0b9d2ba", size = 6515405, upload-time = "2026-02-27T12:21:25.336Z" },
+ { url = "https://files.pythonhosted.org/packages/39/21/716856a36c1cc30a8f1354bf6423f251b1f50851af3e13b9cf084a13d2e3/pillow_heif-1.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:18c7c35a9d98ed9eaaf2db601ee43425ebccc698801df9c008aa04e00756a22e", size = 5641581, upload-time = "2026-02-27T12:21:26.642Z" },
+]
+
[[package]]
name = "pluggy"
version = "1.6.0"