diff --git a/.env.example b/.env.example index bb6f6b2..22ec4e4 100644 --- a/.env.example +++ b/.env.example @@ -41,7 +41,7 @@ totp_issuer=MultiAI GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= -GOOGLE_REDIRECT_URI=http://127.0.0.1:8000/stuff/drive/callback +GOOGLE_REDIRECT_URI=http://127.0.0.1:8000/staff/drive/callback GOOGLE_OAUTH_SCOPES=https://www.googleapis.com/auth/drive.readonly openid email profile FACE_ENCRYPTION_KEY=hkbribvfirirbvivbibvib diff --git a/.env.staging.example b/.env.staging.example index ec25849..c33d0f7 100644 --- a/.env.staging.example +++ b/.env.staging.example @@ -49,4 +49,6 @@ GOOGLE_CLIENT_SECRET= GOOGLE_REDIRECT_URI=http://127.0.0.1:8000/staff/drive/callback GOOGLE_OAUTH_SCOPES=https://www.googleapis.com/auth/drive.readonly openid email profile -FACE_ENCRYPTION_KEY=base64-encoded-32-byte-key \ No newline at end of file +FACE_ENCRYPTION_KEY=base64-encoded-32-byte-key + +FIREBASE_CREDENTIALS_PATH=/app/firebase-credentials.json \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0c11c44..b1543f8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,5 +26,82 @@ jobs: - name: Type Check run: uv run mypy . - # - name: Run tests - # run: uv run pytest --maxfail=1 --disable-warnings \ No newline at end of file + - name: Test Docker Build + run: docker build -t multai-back-test . + + + test: + name: Run Tests + runs-on: ubuntu-latest + needs: lint-and-typecheck + env: + POSTGRES_USER: test + POSTGRES_PASSWORD: test + POSTGRES_DB: test_db + POSTGRES_PORT: 5432 + POSTGRES_HOST: localhost + REDIS_HOST: localhost + REDIS_PORT: 6379 + NATS_PORT: 4222 + NATS_HOST: localhost + NATS_PASSWORD: dummy + NATS_USER: dummy + MINIO_API_PORT: 9000 + MINIO_ROOT_USER: dummy + MINIO_ROOT_PASSWORD: dummy + MINIO_HOST: localhost + jwt_secret: test_secret + encryption_key: test_encryption_key + FACE_ENCRYPTION_KEY: test_face_encryption_key + FIREBASE_CREDENTIALS_PATH: dummy.json + services: + postgres: + image: pgvector/pgvector:pg16 + env: + POSTGRES_USER: test + POSTGRES_PASSWORD: test + POSTGRES_DB: test_db + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + redis: + image: redis:7-alpine + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 6379:6379 + nats: + image: nats:2.10-alpine + command: --user dummy --pass dummy + ports: + - 4222:4222 + minio: + image: minio/minio:latest + env: + MINIO_ROOT_USER: dummy + MINIO_ROOT_PASSWORD: dummy + command: server /data + ports: + - 9000:9000 + + steps: + - uses: actions/checkout@v6 + - name: Install uv + uses: astral-sh/setup-uv@v7 + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version-file: "pyproject.toml" + - name: Install dependencies + run: uv sync --locked --all-extras --dev + - name: Run Migrations + run: uv run alembic upgrade head + - name: Run Tests + run: uv run pytest tests/ \ No newline at end of file diff --git a/.gitignore b/.gitignore index 5593ad3..ccfbe50 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,16 @@ .env .env.staging +.env.mobile __pycache__ db/schema.sql - .vscode/settings.json multiai-c9380-firebase-adminsdk-fbsvc-cb6e5ce41b.json +firebase-credentials.json db.txt - .venv -multiai-c9380-firebase-adminsdk-fbsvc-cb6e5ce41b.json + +# Local Test & Debug Files +.coverage +dummy.json +test_output.log +*.log \ No newline at end of file diff --git a/app/container.py b/app/container.py index 5311498..86e0d46 100644 --- a/app/container.py +++ b/app/container.py @@ -26,15 +26,14 @@ from db.generated import session as session_queries from db.generated import staff_drive_connections as staff_drive_queries from db.generated import staff_notifications as staff_notification_queries -from db.generated import stuff_user as staff_user_queries +from db.generated import staff_user as staff_user_queries from db.generated import upload_request_groups as upload_request_group_queries from db.generated import upload_request_photos as upload_request_photo_queries from db.generated import upload_requests as upload_request_queries from db.generated import user as user_queries from db.generated import events as event_queries -from db.generated import eventParticipant as participant_queries -from db.generated import stuff_user as staff_queries +from db.generated import event_participant as participant_queries from db.generated import notifications as notification_queries from db.generated import audit as audit_queries from db.generated import stats as stats_queries @@ -72,7 +71,6 @@ def __init__( self.audit_querier = audit_queries.AsyncQuerier(conn) self.event_querier = event_queries.AsyncQuerier(conn) self.participant_querier = participant_queries.AsyncQuerier(conn) - self.staff_querier = staff_queries.AsyncQuerier(conn) self.stats_querier = stats_queries.AsyncQuerier(conn) # services diff --git a/app/core/image_validation.py b/app/core/image_validation.py new file mode 100644 index 0000000..7e427c7 --- /dev/null +++ b/app/core/image_validation.py @@ -0,0 +1,131 @@ +import re +import uuid +from dataclasses import dataclass +from io import BytesIO + +import filetype # type: ignore[import-untyped] +from fastapi import UploadFile +from fastapi.concurrency import run_in_threadpool +from PIL import Image + +from app.core.constant import ( + IMAGE_ALLOWED_TYPES, + MAX_IMAGE_DIM, + MAX_IMAGE_SIZE, + MIN_IMAGE_DIM, +) +from app.core.exceptions import AppException + + +@dataclass +class ImagePayload: + filename: str + content_type: str + bytes: bytes + + +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.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_image_payload( + file: UploadFile, *, max_size: int = MAX_IMAGE_SIZE +) -> ImagePayload: + precheck_upload_headers(file) + contents = await read_limited(file, max_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 ImagePayload( + filename=sanitise_filename(file.filename, kind.extension), + content_type=kind.mime, + bytes=contents, + ) diff --git a/app/infra/nats.py b/app/infra/nats.py index 06e0f28..e17bd50 100644 --- a/app/infra/nats.py +++ b/app/infra/nats.py @@ -62,11 +62,12 @@ async def connect( @staticmethod async def close() -> None: if NatsClient._nc and not NatsClient._nc.is_closed: - await NatsClient._nc.drain() - await NatsClient._nc.close() - NatsClient._nc = None - NatsClient._js = None - + try: + await NatsClient._nc.drain() + await NatsClient._nc.close() + finally: + NatsClient._nc = None + NatsClient._js = None @staticmethod async def publish(subject: NatsSubjects | str, message: bytes) -> None: diff --git a/app/infra/redis.py b/app/infra/redis.py index d3116be..7e90101 100644 --- a/app/infra/redis.py +++ b/app/infra/redis.py @@ -77,3 +77,4 @@ async def srem(self, key: RedisKey | str, *values: str) -> int: async def close(self) -> None: await self._client.close() + type(self)._instance = None diff --git a/app/router/mobile/auth.py b/app/router/mobile/auth.py index 01f6c4a..8da3672 100644 --- a/app/router/mobile/auth.py +++ b/app/router/mobile/auth.py @@ -1,6 +1,8 @@ from typing import Optional -from fastapi import APIRouter, Depends, Request +from fastapi import APIRouter, Depends, Request, UploadFile +from fastapi.responses import Response +from app.core.image_validation import build_image_payload from uuid import UUID from app.container import get_container, Container @@ -202,7 +204,57 @@ async def get_me( ) return MeResponse( - user=UserSchema(id=user.id, email=user.email), + user=UserSchema( + id=user.id, + email=user.email, + name=user.display_name, + avatar_url="/user/auth/me/avatar/image" if user.avatar_key else None, + ), devices=device_list, sessions=session_schema, ) + +@router.post("/me/avatar", response_model=UserSchema) +async def upload_avatar( + file: UploadFile, + current_user: MobileUserSchema = Depends(get_current_mobile_user), + container: Container = Depends(get_container), +) -> UserSchema: + payload = await build_image_payload(file) + object_name = str(current_user.user_id) + + await container.auth_service.upload_avatar_bytes( + avatar_key=object_name, + data=payload.bytes, + content_type=payload.content_type, + filename=payload.filename, + ) + try: + user = await container.auth_service.update_avatar( + user_id=current_user.user_id, avatar_key=object_name, + ) + except Exception: + await container.auth_service.delete_avatar_bytes(avatar_key=object_name) + raise + + return UserSchema( + id=user.id, + email=user.email, + name=user.display_name, + avatar_url="/user/auth/me/avatar/image", + ) + + +@router.get("/me/avatar/image") +async def get_avatar_image( + current_user: MobileUserSchema = Depends(get_current_mobile_user), + container: Container = Depends(get_container), +) -> Response: + data, filename, content_type = await container.auth_service.get_avatar_bytes( + user_id=current_user.user_id, + ) + return Response( + content=data, + media_type=content_type, + headers={"Content-Disposition": f'inline; filename="{filename}"'}, + ) diff --git a/app/router/mobile/enrollement.py b/app/router/mobile/enrollement.py index 6ebb13d..f8e2ac1 100644 --- a/app/router/mobile/enrollement.py +++ b/app/router/mobile/enrollement.py @@ -1,14 +1,10 @@ -import re import time import uuid from collections.abc import AsyncIterator -from io import BytesIO from typing import Annotated, List -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 @@ -18,7 +14,6 @@ AuditEventType, ENROLL_RATE_LIMIT_MAX, ENROLL_RATE_LIMIT_WINDOW, - IMAGE_ALLOWED_TYPES, MAX_ENROLL_IMAGES, MAX_IMAGE_SIZE, MAX_IMAGE_DIM, @@ -26,6 +21,7 @@ MIN_IMAGE_DIM, ) from app.core.exceptions import AppException +from app.core.image_validation import build_image_payload from app.core.logger import logger from app.deps.token_auth import MobileUserSchema, get_current_mobile_user from app.service.face_embedding import FaceImagePayload @@ -45,111 +41,12 @@ class EnrollmentResponse(BaseModel): router = APIRouter() - -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.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) - + payload = await build_image_payload(file) return FaceImagePayload( - filename=_sanitise_filename(file.filename, kind.extension), - content_type=kind.mime, - bytes=contents, + filename=payload.filename, + content_type=payload.content_type, + bytes=payload.bytes, ) diff --git a/app/router/staff/__init__.py b/app/router/staff/__init__.py index e17b02c..35cbe0e 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/staff/uploads.py b/app/router/staff/uploads.py index 0ec0029..5ded556 100644 --- a/app/router/staff/uploads.py +++ b/app/router/staff/uploads.py @@ -14,9 +14,9 @@ RejectUploadRequestRequest, ) from app.schema.response.staff.upload_groups import ( - UploadRequestGroupListResponse, UploadRequestGroupPhotoListResponse, UploadRequestGroupSchema, + UploadRequestGroupSummaryListResponse, ) from app.schema.response.staff.uploads import ( UploadRequestListResponse, @@ -67,19 +67,19 @@ async def list_upload_requests( ) -@router.get("/groups", response_model=UploadRequestGroupListResponse) +@router.get("/groups", response_model=UploadRequestGroupSummaryListResponse) async def list_upload_request_groups( scope: Literal["my", "all"] = Query(default="my"), status: UploadRequestStatus | None = Query(default=None), current_staff_user: StaffUser = Depends(get_current_staff_user), container: Container = Depends(get_container), -) -> UploadRequestGroupListResponse: +) -> UploadRequestGroupSummaryListResponse: groups = await container.upload_requests_service.list_groups( current_staff_user=current_staff_user, scope=scope, status=status.value if status is not None else None, ) - return UploadRequestGroupListResponse.from_details_list(groups) + return UploadRequestGroupSummaryListResponse.from_groups(groups) @router.get("/groups/{group_id}", response_model=UploadRequestGroupSchema) diff --git a/app/schema/response/mobile/audit.py b/app/schema/response/mobile/audit.py index 0119653..76983b2 100644 --- a/app/schema/response/mobile/audit.py +++ b/app/schema/response/mobile/audit.py @@ -8,10 +8,11 @@ from db.generated.models import AuditEvent, User from app.core.constant import AuditEventType -from app.schema.response.mobile.auth import UserSchema -class AuditActorSchema(UserSchema): +class AuditActorSchema(BaseModel): + id: UUID + email: str display_name: str | None @classmethod @@ -22,7 +23,6 @@ def from_user(cls, user: User) -> "AuditActorSchema": display_name=user.display_name, ) - class AuditEventSchema(BaseModel): id: UUID event_type: AuditEventType diff --git a/app/schema/response/mobile/auth.py b/app/schema/response/mobile/auth.py index e03a074..d00d26a 100644 --- a/app/schema/response/mobile/auth.py +++ b/app/schema/response/mobile/auth.py @@ -18,6 +18,8 @@ class SessionSchema(BaseModel): class UserSchema(BaseModel): id: uuid.UUID email: str + name: str | None + avatar_url: str | None class MeResponse(BaseModel): user: UserSchema diff --git a/app/schema/response/staff/upload_groups.py b/app/schema/response/staff/upload_groups.py index 02f111a..7a264c2 100644 --- a/app/schema/response/staff/upload_groups.py +++ b/app/schema/response/staff/upload_groups.py @@ -10,7 +10,7 @@ UploadRequestSchema, ) from app.service.upload_requests import UploadRequestGroupDetails -from db.generated.models import UploadRequestPhoto +from db.generated.models import UploadRequestGroup, UploadRequestPhoto class UploadRequestGroupSchema(BaseModel): @@ -67,3 +67,32 @@ def from_photos( return cls( items=[UploadRequestPhotoSchema.model_validate(p) for p in photos] ) + +class UploadRequestGroupSummarySchema(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: UUID + event_id: UUID + folder_id: str + status: str + processing_status: str + total_photo_count: int + batch_count: int + processed_photo_count: int + failed_photo_count: int + created_at: datetime + approved_at: datetime | None + rejection_reason: str | None + error_message: str | None + + @field_validator("status", mode="before") + @classmethod + def coerce_status(cls, v: object) -> str: + return getattr(v, "value", str(v)) + + +class UploadRequestGroupSummaryListResponse(BaseModel): + items: list[UploadRequestGroupSummarySchema] + + @classmethod + def from_groups(cls, groups: list["UploadRequestGroup"]) -> "UploadRequestGroupSummaryListResponse": + return cls(items=[UploadRequestGroupSummarySchema.model_validate(g) for g in groups]) diff --git a/app/schema/response/web/audit.py b/app/schema/response/web/audit.py index 0119653..76983b2 100644 --- a/app/schema/response/web/audit.py +++ b/app/schema/response/web/audit.py @@ -8,10 +8,11 @@ from db.generated.models import AuditEvent, User from app.core.constant import AuditEventType -from app.schema.response.mobile.auth import UserSchema -class AuditActorSchema(UserSchema): +class AuditActorSchema(BaseModel): + id: UUID + email: str display_name: str | None @classmethod @@ -22,7 +23,6 @@ def from_user(cls, user: User) -> "AuditActorSchema": display_name=user.display_name, ) - class AuditEventSchema(BaseModel): id: UUID event_type: AuditEventType diff --git a/app/service/event.py b/app/service/event.py index 9723a06..e697ddd 100644 --- a/app/service/event.py +++ b/app/service/event.py @@ -13,7 +13,7 @@ ParticipantResponse ) from db.generated import events as event_queries -from db.generated import eventParticipant as participant_queries +from db.generated import event_participant as participant_queries from db.generated import models class EventService: diff --git a/app/service/staff_drive.py b/app/service/staff_drive.py index 9cb6ba9..f3b2ba0 100644 --- a/app/service/staff_drive.py +++ b/app/service/staff_drive.py @@ -17,7 +17,7 @@ from app.infra.minio import ImageBucket from app.infra.redis import RedisClient from db.generated import staff_drive_connections as drive_queries -from db.generated import stuff_user as staff_queries +from db.generated import staff_user as staff_queries from db.generated.models import StaffDriveConnection, StaffUser diff --git a/app/service/staff_user.py b/app/service/staff_user.py index 3a589e4..5675668 100644 --- a/app/service/staff_user.py +++ b/app/service/staff_user.py @@ -8,8 +8,8 @@ from app.core.exceptions import AppException, DBException, DBExceptionImpl from app.core.securite import create_access_staff_token, hash_password, verify_password from app.schema.response.web.auth import WebAuthResponse -from db.generated import stuff_user as staff_queries -from db.generated.stuff_user import ListStaffUsersParams +from db.generated import staff_user as staff_queries +from db.generated.staff_user import ListStaffUsersParams from db.generated.models import StaffUser, StaffRole diff --git a/app/service/upload_requests.py b/app/service/upload_requests.py index f794378..0ab404d 100644 --- a/app/service/upload_requests.py +++ b/app/service/upload_requests.py @@ -890,7 +890,7 @@ async def list_groups( current_staff_user: StaffUser, scope: Literal["my", "all"], status: str | None, - ) -> list[UploadRequestGroupDetails]: + ) -> list[UploadRequestGroup]: if scope == "all" and self._role_value(current_staff_user.role) != StaffRole.MULTI_TEAM_LEAD.value: raise AppException.forbidden("Multi team lead access required") @@ -915,15 +915,7 @@ async def list_groups( async for group in iterator: groups.append(group) - details: list[UploadRequestGroupDetails] = [] - for group in groups: - details.append( - await self.get_group_details( - group_id=group.id, - current_staff_user=current_staff_user, - ) - ) - return details + return groups async def list_group_photos( self, diff --git a/app/service/users.py b/app/service/users.py index 0ce7ca6..8d3a80a 100644 --- a/app/service/users.py +++ b/app/service/users.py @@ -3,6 +3,7 @@ from collections.abc import AsyncIterable from typing import Optional +from fastapi import HTTPException from sqlalchemy.exc import SQLAlchemyError from app.core.exceptions import AppException, DBException @@ -17,7 +18,7 @@ from app.core import constant from app.core.config import settings from app.infra.redis import RedisClient - +from app.infra.minio import Bucket, IMAGES_BUCKET_NAME from app.schema.request.mobile.auth import ( MobileAuthBaseRequest, MobileLoginRequest, @@ -174,7 +175,7 @@ async def mobile_register( # 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 to %s", req.email) + logger.info("register success, OTP sent") return RegisterPendingResponse( message="OTP sent to email", status="pending_verification", @@ -513,6 +514,53 @@ async def update_user( logger.error("Failed to update user: %s", exc) raise DBException.handle(exc) + async def update_avatar(self, *, user_id: uuid.UUID, avatar_key: str) -> User: + try: + user = await self.user_querier.update_user_avatar( + avatar_key=avatar_key, + id=user_id, + ) + if not user: + raise AppException.not_found("User not found") + return user + except Exception as exc: + logger.error("Failed to update avatar for user %s: %s", user_id, exc) + raise DBException.handle(exc) + + async def upload_avatar_bytes( + self, *, avatar_key: str, data: bytes, content_type: str, filename: str + ) -> None: + try: + bucket = Bucket(IMAGES_BUCKET_NAME, "avatars") + await bucket.put_bytes( + data=data, + object_name=avatar_key, + content_type=content_type, + filename=filename, + ) + except Exception as exc: + raise AppException.storage_error("Failed to upload avatar image") from exc + + async def get_avatar_bytes(self, *, user_id: uuid.UUID) -> tuple[bytes, str, str]: + user = await self.get_user(user_id=user_id) + if not user.avatar_key: + raise AppException.not_found("No avatar set") + + bucket = Bucket(IMAGES_BUCKET_NAME, "avatars") + try: + return await bucket.get(user.avatar_key) + except HTTPException: + raise + except Exception as exc: + raise AppException.storage_error("Failed to retrieve avatar image") from exc + + async def delete_avatar_bytes(self, *, avatar_key: str) -> None: + try: + bucket = Bucket(IMAGES_BUCKET_NAME, "avatars") + await bucket.delete(avatar_key) + except Exception as exc: + logger.warning("Failed to clean up orphaned avatar %s: %s", avatar_key, exc) + async def delete_user(self, *, redis: RedisClient, user_id: uuid.UUID) -> User: try: existing = await self.user_querier.get_user_by_id(id=user_id) @@ -581,13 +629,7 @@ async def check_rate_limit( max_requests: int, window_seconds: int, ) -> None: - """Enforce rate limiting using Redis INCR + EXPIRE. - - 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 - response is raised with a ``Retry-After`` header. - """ + """Enforce rate limiting using Redis INCR + EXPIRE.""" current_count = await redis.incr(key) if current_count == 1: await redis.expire(key, window_seconds) diff --git a/app/worker/notification/settings.py b/app/worker/notification/settings.py index 7bb2aa8..df8ca9e 100644 --- a/app/worker/notification/settings.py +++ b/app/worker/notification/settings.py @@ -1,24 +1,25 @@ from __future__ import annotations - from typing import ClassVar, Sequence - from pydantic import Field from pydantic_settings import BaseSettings, SettingsConfigDict - from app.schema.internal.notification import NotificationPriority, PRIORITY_ORDER class NotificationWorkerSettings(BaseSettings): subject_prefix: str = Field("notifications.delivery") queue_group: str | None = Field(None) - redis_host: str = Field("localhost") - redis_port: int = Field(6379) - redis_password: str = Field("") - nats_host: str = Field("localhost") - nats_port: int = Field(4222) - nats_user: str = Field("") - nats_password: str = Field("") + + redis_host: str + redis_port: int + redis_password: str = "" + + nats_host: str + nats_port: int + nats_user: str + nats_password: str + firebase_credentials_path: str | None = Field(None) + MAX_SEND_ATTEMPTS: ClassVar[int] = 5 BASE_RETRY_DELAY: ClassVar[int] = 2 TTL_SECONDS: ClassVar[int] = 30 * 24 * 3600 @@ -26,7 +27,7 @@ class NotificationWorkerSettings(BaseSettings): RATE_LIMIT: ClassVar[int] = 50 RATE_PERIOD: ClassVar[float] = 1.0 - model_config = SettingsConfigDict(env_prefix="NOTIFICATIONS_") + model_config = SettingsConfigDict(env_file=".env", extra="ignore") def subject_for(self, priority: NotificationPriority) -> str: return f"{self.subject_prefix}.{priority.value}" @@ -34,4 +35,5 @@ def subject_for(self, priority: NotificationPriority) -> str: def priority_subjects(self) -> Sequence[str]: return [self.subject_for(priority) for priority in PRIORITY_ORDER] + NotifSetting = NotificationWorkerSettings() # type: ignore diff --git a/app/worker/photo_worker/main.py b/app/worker/photo_worker/main.py index 15e0586..59a116b 100644 --- a/app/worker/photo_worker/main.py +++ b/app/worker/photo_worker/main.py @@ -179,9 +179,7 @@ async def _handle_group_photo(self, event: PhotoProcessEvent, faces: list[Detect ) if approvals_created == 0: - logger.info("No users matched in group photo %s, auto-approving as public", event.photo_id) - await self._photo_querier.update_photo_status(id=event.photo_id, status="approved") - await self._photo_querier.update_photo_visibility(id=event.photo_id, visibility="public") + 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: diff --git a/app/worker/photo_worker/tests/test_photo_worker.py b/app/worker/photo_worker/tests/test_photo_worker.py index eecda3a..f113a70 100644 --- a/app/worker/photo_worker/tests/test_photo_worker.py +++ b/app/worker/photo_worker/tests/test_photo_worker.py @@ -5,9 +5,14 @@ import pytest -sys.modules["db.generated.user"] = MagicMock() -sys.modules["app.worker.notification.settings"] = MagicMock() -sys.modules["app.worker.notification.notification_queue"] = MagicMock() +_MOCKED_MODULES = ( + "db.generated.user", + "app.worker.notification.settings", + "app.worker.notification.notification_queue", +) +_original_modules = {name: sys.modules.get(name) for name in _MOCKED_MODULES} +for _name in _MOCKED_MODULES: + sys.modules[_name] = MagicMock() from app.service.face_embedding import DetectedFace, FaceEmbeddingService, FaceImagePayload # noqa: E402 from app.service.face_match import SingleFaceMatchService # noqa: E402 @@ -16,6 +21,13 @@ from app.worker.photo_worker.schema.event import PhotoProcessEvent # noqa: E402 from db.generated import photo_faces as photo_face_queries # noqa: E402 +# Restore sys.modules so other test files importing these modules +# (e.g. db.generated.user) get the real thing, not this leaked mock. +for _name, _original in _original_modules.items(): + if _original is None: + sys.modules.pop(_name, None) + else: + sys.modules[_name] = _original # ── fixtures ────────────────────────────────────────────────────────── diff --git a/app/worker/storage_cleaner/main.py b/app/worker/storage_cleaner/main.py index 344049c..eb723d2 100644 --- a/app/worker/storage_cleaner/main.py +++ b/app/worker/storage_cleaner/main.py @@ -3,9 +3,8 @@ import asyncio import json import uuid -from typing import Iterable, Optional, Set, Tuple +from typing import Iterable, Optional, Set -import sqlalchemy.ext.asyncio from fastapi import HTTPException from pydantic import BaseModel, ValidationError @@ -26,19 +25,6 @@ class FinalBucketCleanupPayload(BaseModel): storage_service = StagedUploadStorageService() -async def create_photo_querier() -> Tuple[ - sqlalchemy.ext.asyncio.AsyncConnection, - upload_request_photo_queries.AsyncQuerier, -]: - conn = await engine.connect() - querier = upload_request_photo_queries.AsyncQuerier(conn) - return conn, querier - - -async def close_connection(conn: sqlalchemy.ext.asyncio.AsyncConnection) -> None: - await conn.close() - - def _parse_payload(raw_data: bytes | str) -> Optional[FinalBucketCleanupPayload]: if isinstance(raw_data, bytes): try: @@ -131,11 +117,12 @@ async def _handle_cleanup_event( async def main() -> None: - conn, querier = await create_photo_querier() await NatsClient.connect() try: async def _jetstream_handler(data: bytes | str) -> None: - await _handle_cleanup_event(data, querier) + async with engine.begin() as conn: + querier = upload_request_photo_queries.AsyncQuerier(conn) + await _handle_cleanup_event(data, querier) await NatsClient.js_subscribe( subject=settings.subject_enum, @@ -150,7 +137,6 @@ async def _jetstream_handler(data: bytes | str) -> None: ) await asyncio.Event().wait() finally: - await close_connection(conn) await NatsClient.close() diff --git a/db/generated/eventParticipant.py b/db/generated/event_participant.py similarity index 99% rename from db/generated/eventParticipant.py rename to db/generated/event_participant.py index 0e6fd9c..65129c7 100644 --- a/db/generated/eventParticipant.py +++ b/db/generated/event_participant.py @@ -1,7 +1,7 @@ # Code generated by sqlc. DO NOT EDIT. # versions: # sqlc v1.31.1 -# source: eventParticipant.sql +# source: event_participant.sql import dataclasses import datetime from typing import Any, AsyncIterator, Optional diff --git a/db/generated/models.py b/db/generated/models.py index 07418cb..9908c54 100644 --- a/db/generated/models.py +++ b/db/generated/models.py @@ -245,6 +245,7 @@ class User: face_embedding: Optional[Any] deleted_at: Optional[datetime.datetime] blocked: bool + avatar_key: Optional[str] @dataclasses.dataclass() diff --git a/db/generated/stuff_user.py b/db/generated/staff_user.py similarity index 99% rename from db/generated/stuff_user.py rename to db/generated/staff_user.py index d541078..74c85f9 100644 --- a/db/generated/stuff_user.py +++ b/db/generated/staff_user.py @@ -1,7 +1,7 @@ # Code generated by sqlc. DO NOT EDIT. # versions: # sqlc v1.31.1 -# source: stuff_user.sql +# source: staff_user.sql import dataclasses from typing import Any, AsyncIterator, Optional import uuid diff --git a/db/generated/user.py b/db/generated/user.py index d0ab815..e922f86 100644 --- a/db/generated/user.py +++ b/db/generated/user.py @@ -15,7 +15,7 @@ CREATE_USER = """-- name: create_user \\:one INSERT INTO users (email, hashed_password) VALUES (:p1, :p2) -RETURNING id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked +RETURNING id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked, avatar_key """ @@ -42,21 +42,21 @@ class FindClosestUserByEmbeddingRow: GET_USER_BY_EMAIL = """-- name: get_user_by_email \\:one -SELECT id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked +SELECT id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked, avatar_key FROM users WHERE email = :p1 """ GET_USER_BY_ID = """-- name: get_user_by_id \\:one -SELECT id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked +SELECT id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked, avatar_key FROM users WHERE id = :p1 """ 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 +SELECT id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked, avatar_key FROM users WHERE id = :p1 FOR UPDATE @@ -64,7 +64,7 @@ class FindClosestUserByEmbeddingRow: LIST_USERS = """-- name: list_users \\:many -SELECT id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked +SELECT id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked, avatar_key FROM users ORDER BY created_at DESC LIMIT :p1 OFFSET :p2 @@ -90,7 +90,7 @@ class ListUsersWithEmbeddingRow: SET blocked = :p1, updated_at = NOW() WHERE id = :p2 -RETURNING id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked +RETURNING id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked, avatar_key """ @@ -99,7 +99,7 @@ class ListUsersWithEmbeddingRow: SET face_embedding = :p1\\:\\:vector, updated_at = NOW() WHERE id = :p2 -RETURNING id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked +RETURNING id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked, avatar_key """ @@ -110,7 +110,16 @@ class ListUsersWithEmbeddingRow: blocked = COALESCE(:p3, blocked), updated_at = NOW() WHERE id = :p4 -RETURNING id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked +RETURNING id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked, avatar_key +""" + + +UPDATE_USER_AVATAR = """-- name: update_user_avatar \\:one +UPDATE users +SET avatar_key = :p1, + updated_at = NOW() +WHERE id = :p2 +RETURNING id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked, avatar_key """ @@ -119,7 +128,7 @@ class ListUsersWithEmbeddingRow: SET hashed_password = :p1, updated_at = NOW() WHERE id = :p2 -RETURNING id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked +RETURNING id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked, avatar_key """ @@ -141,6 +150,7 @@ async def create_user(self, *, email: str, hashed_password: Optional[str]) -> Op face_embedding=row[6], deleted_at=row[7], blocked=row[8], + avatar_key=row[9], ) async def delete_user(self, *, id: uuid.UUID) -> None: @@ -169,6 +179,7 @@ async def get_user_by_email(self, *, email: str) -> Optional[models.User]: face_embedding=row[6], deleted_at=row[7], blocked=row[8], + avatar_key=row[9], ) async def get_user_by_id(self, *, id: uuid.UUID) -> Optional[models.User]: @@ -185,6 +196,7 @@ async def get_user_by_id(self, *, id: uuid.UUID) -> Optional[models.User]: face_embedding=row[6], deleted_at=row[7], blocked=row[8], + avatar_key=row[9], ) async def get_user_by_id_for_update(self, *, id: uuid.UUID) -> Optional[models.User]: @@ -201,6 +213,7 @@ async def get_user_by_id_for_update(self, *, id: uuid.UUID) -> Optional[models.U face_embedding=row[6], deleted_at=row[7], blocked=row[8], + avatar_key=row[9], ) async def list_users(self, *, limit: int, offset: int) -> AsyncIterator[models.User]: @@ -216,6 +229,7 @@ async def list_users(self, *, limit: int, offset: int) -> AsyncIterator[models.U face_embedding=row[6], deleted_at=row[7], blocked=row[8], + avatar_key=row[9], ) async def list_users_with_embedding(self) -> AsyncIterator[ListUsersWithEmbeddingRow]: @@ -240,6 +254,7 @@ async def set_user_blocked(self, *, blocked: bool, id: uuid.UUID) -> Optional[mo face_embedding=row[6], deleted_at=row[7], blocked=row[8], + avatar_key=row[9], ) async def set_user_embedding(self, *, dollar_1: Any, id: uuid.UUID) -> Optional[models.User]: @@ -256,6 +271,7 @@ async def set_user_embedding(self, *, dollar_1: Any, id: uuid.UUID) -> Optional[ face_embedding=row[6], deleted_at=row[7], blocked=row[8], + avatar_key=row[9], ) async def update_user(self, *, email: str, display_name: Optional[str], blocked: bool, id: uuid.UUID) -> Optional[models.User]: @@ -277,6 +293,24 @@ async def update_user(self, *, email: str, display_name: Optional[str], blocked: face_embedding=row[6], deleted_at=row[7], blocked=row[8], + avatar_key=row[9], + ) + + async def update_user_avatar(self, *, avatar_key: Optional[str], id: uuid.UUID) -> Optional[models.User]: + row = (await self._conn.execute(sqlalchemy.text(UPDATE_USER_AVATAR), {"p1": avatar_key, "p2": 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], + avatar_key=row[9], ) async def update_user_password(self, *, hashed_password: Optional[str], id: uuid.UUID) -> Optional[models.User]: @@ -293,4 +327,5 @@ async def update_user_password(self, *, hashed_password: Optional[str], id: uuid face_embedding=row[6], deleted_at=row[7], blocked=row[8], + avatar_key=row[9], ) diff --git a/db/queries/eventParticipant.sql b/db/queries/event_participant.sql similarity index 100% rename from db/queries/eventParticipant.sql rename to db/queries/event_participant.sql diff --git a/db/queries/stuff_user.sql b/db/queries/staff_user.sql similarity index 100% rename from db/queries/stuff_user.sql rename to db/queries/staff_user.sql diff --git a/db/queries/user.sql b/db/queries/user.sql index fc906dd..61c7c59 100644 --- a/db/queries/user.sql +++ b/db/queries/user.sql @@ -26,6 +26,13 @@ SET hashed_password = $1, WHERE id = $2 RETURNING *; +-- name: UpdateUserAvatar :one +UPDATE users +SET avatar_key = $1, + updated_at = NOW() +WHERE id = $2 +RETURNING *; + -- name: UpdateUser :one UPDATE users SET email = COALESCE($1, email), diff --git a/docker-compose.staging.local.yml b/docker-compose.staging.local.yml index 0d3c378..1a0a012 100644 --- a/docker-compose.staging.local.yml +++ b/docker-compose.staging.local.yml @@ -6,6 +6,7 @@ services: pull_policy: never volumes: - ./:/app + - /app/.venv - insightface_cache:/root/.insightface migrate: @@ -13,6 +14,9 @@ services: context: . dockerfile: Dockerfile pull_policy: never + volumes: + - ./:/app + - /app/.venv email-worker: build: @@ -21,6 +25,55 @@ services: pull_policy: never volumes: - ./:/app + - /app/.venv + + photo-worker: + build: + context: . + dockerfile: Dockerfile + pull_policy: never + volumes: + - ./:/app + - /app/.venv + - insightface_cache:/root/.insightface + + notification-worker: + profiles: ["firebase"] + build: + context: . + dockerfile: Dockerfile + pull_policy: never + volumes: + - ./:/app + - /app/.venv + - ./firebase-credentials.json:/app/firebase-credentials.json + + upload-group-worker: + build: + context: . + dockerfile: Dockerfile + pull_policy: never + volumes: + - ./:/app + - /app/.venv + + audit-worker: + build: + context: . + dockerfile: Dockerfile + pull_policy: never + volumes: + - ./:/app + - /app/.venv + + storage-cleaner: + build: + context: . + dockerfile: Dockerfile + pull_policy: never + volumes: + - ./:/app + - /app/.venv volumes: - insightface_cache: + insightface_cache: \ No newline at end of file diff --git a/docker-compose.staging.yml b/docker-compose.staging.yml index 93435d8..18b7e66 100644 --- a/docker-compose.staging.yml +++ b/docker-compose.staging.yml @@ -1,3 +1,4 @@ +name: multai-staging services: postgres: image: pgvector/pgvector:pg16 @@ -19,8 +20,10 @@ services: nats: image: nats:2.10-alpine container_name: multi_nats + restart: unless-stopped command: > -js + -sd /data -a 0.0.0.0 -m ${NATS_MONITOR_PORT} --user ${NATS_USER} @@ -28,6 +31,8 @@ services: ports: - "${NATS_PORT}:4222" - "${NATS_MONITOR_PORT}:8222" + volumes: + - nats_data:/data networks: - multi_network @@ -62,6 +67,8 @@ services: image: redis:7-alpine container_name: multi_redis restart: unless-stopped + env_file: + - .env.staging ports: - "${REDIS_PORT}:6379" networks: @@ -109,11 +116,82 @@ services: networks: - multi_network + photo-worker: + image: ghcr.io/microclub-usthb/multai-back:latest + container_name: multi_photo_worker + restart: unless-stopped + env_file: + - .env.staging + depends_on: + - postgres + - redis + - nats + - minio + command: ["uv", "run", "python", "-m", "app.worker.photo_worker.main"] + networks: + - multi_network + + notification-worker: + profiles: ["firebase"] + image: ghcr.io/microclub-usthb/multai-back:latest + container_name: multi_notification_worker + restart: unless-stopped + env_file: + - .env.staging + depends_on: + - nats + - redis + command: ["uv", "run", "python", "-m", "app.worker.notification.main"] + networks: + - multi_network + + upload-group-worker: + image: ghcr.io/microclub-usthb/multai-back:latest + container_name: multi_upload_group_worker + restart: unless-stopped + env_file: + - .env.staging + depends_on: + - postgres + - redis + - nats + - minio + command: ["uv", "run", "python", "-m", "app.worker.upload_group_worker.main"] + networks: + - multi_network + + audit-worker: + image: ghcr.io/microclub-usthb/multai-back:latest + container_name: multi_audit_worker + restart: unless-stopped + env_file: + - .env.staging + depends_on: + - postgres + - nats + command: ["uv", "run", "python", "-m", "app.worker.audit.main"] + networks: + - multi_network + + storage-cleaner: + image: ghcr.io/microclub-usthb/multai-back:latest + container_name: multi_storage_cleaner + restart: unless-stopped + env_file: + - .env.staging + depends_on: + - postgres + - nats + command: ["uv", "run", "python", "-m", "app.worker.storage_cleaner.main"] + networks: + - multi_network + volumes: postgres_data: minio_data: redis_data: + nats_data: networks: multi_network: - driver: bridge + driver: bridge \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 3be7bab..0056126 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,3 +1,4 @@ +name: multai-dev services: postgres: image: pgvector/pgvector:pg16 diff --git a/makefile b/makefile index 343f4c0..ce53087 100644 --- a/makefile +++ b/makefile @@ -64,8 +64,9 @@ run-workers: uv run python -m app.worker.upload_group_worker.main & \ uv run python -m app.worker.photo_worker.main & \ uv run python -m app.worker.storage_cleaner.main & \ + uv run python -m app.worker.email_worker.main & \ wait - + lint: uv run ruff check . diff --git a/migrations/sql/down/add_avatar_key_to_users.sql b/migrations/sql/down/add_avatar_key_to_users.sql new file mode 100644 index 0000000..b708389 --- /dev/null +++ b/migrations/sql/down/add_avatar_key_to_users.sql @@ -0,0 +1,2 @@ +ALTER TABLE public.users +DROP COLUMN avatar_key; \ No newline at end of file diff --git a/migrations/sql/up/add_avatar_key_to_users.sql b/migrations/sql/up/add_avatar_key_to_users.sql new file mode 100644 index 0000000..dd8d7e7 --- /dev/null +++ b/migrations/sql/up/add_avatar_key_to_users.sql @@ -0,0 +1,2 @@ +ALTER TABLE public.users +ADD COLUMN avatar_key character varying(255) DEFAULT NULL; \ No newline at end of file diff --git a/migrations/versions/f0fa13623f6c_add_avatar_key_to_users.py b/migrations/versions/f0fa13623f6c_add_avatar_key_to_users.py new file mode 100644 index 0000000..a79910c --- /dev/null +++ b/migrations/versions/f0fa13623f6c_add_avatar_key_to_users.py @@ -0,0 +1,24 @@ +"""add_avatar_key_to_users + +Revision ID: f0fa13623f6c +Revises: f3a1b7c9d201 +Create Date: 2026-07-03 01:46:31.410873 + +""" +from typing import Sequence, Union + +from migrations.helper import run_sql_down, run_sql_up + +# revision identifiers, used by Alembic. +revision: str = 'f0fa13623f6c' +down_revision: Union[str, Sequence[str], None] = 'f3a1b7c9d201' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + run_sql_up("add_avatar_key_to_users") + + +def downgrade() -> None: + run_sql_down("add_avatar_key_to_users") \ No newline at end of file diff --git a/mobile-quickstart/.env.mobile.example b/mobile-quickstart/.env.mobile.example index f3cecb5..95e55f2 100644 --- a/mobile-quickstart/.env.mobile.example +++ b/mobile-quickstart/.env.mobile.example @@ -58,4 +58,4 @@ CORS_ORIGINS=["http://localhost:3000", "http://localhost:5173", "http://127.0.0. # ========================= # Firebase # ========================= -FIREBASE_CREDENTIALS_PATH= +FIREBASE_CREDENTIALS_PATH=/app/firebase-credentials.json diff --git a/mobile-quickstart/docker-compose.mobile.yml b/mobile-quickstart/docker-compose.mobile.yml index bc65f66..88ceb22 100644 --- a/mobile-quickstart/docker-compose.mobile.yml +++ b/mobile-quickstart/docker-compose.mobile.yml @@ -1,6 +1,8 @@ +name: multai-mobile-test services: postgres: image: pgvector/pgvector:pg16 + container_name: mobile_postgres restart: unless-stopped env_file: .env.mobile ports: @@ -16,14 +18,18 @@ services: redis: image: redis:7-alpine + container_name: mobile_redis restart: unless-stopped ports: - "6379:6379" + volumes: + - redis_data:/data networks: - multi_network minio: image: minio/minio:latest + container_name: mobile_minio restart: unless-stopped env_file: .env.mobile command: server /data --console-address ":9001" @@ -37,19 +43,26 @@ services: nats: image: nats:2.10-alpine + container_name: mobile_nats + restart: unless-stopped command: > -js + -sd /data -a 0.0.0.0 -m 8222 --user testuser --pass testpassword ports: - "4222:4222" + - "8222:8222" + volumes: + - nats_data:/data networks: - multi_network migrate: image: ghcr.io/microclub-usthb/multai-back:latest + container_name: mobile_migrate env_file: .env.mobile depends_on: postgres: @@ -60,6 +73,7 @@ services: fastapi: image: ghcr.io/microclub-usthb/multai-back:latest + container_name: mobile_fastapi restart: unless-stopped env_file: .env.mobile depends_on: @@ -76,9 +90,89 @@ services: networks: - multi_network + # ========================================== + # BACKGROUND WORKERS POOL (Added for full app feature completeness) + # ========================================== + email-worker: + image: ghcr.io/microclub-usthb/multai-back:latest + container_name: mobile_email_worker + restart: unless-stopped + env_file: .env.mobile + depends_on: + fastapi: + condition: service_started + command: ["uv", "run", "python", "-m", "app.worker.email_worker.main"] + networks: + - multi_network + + photo-worker: + image: ghcr.io/microclub-usthb/multai-back:latest + container_name: mobile_photo_worker + restart: unless-stopped + env_file: .env.mobile + depends_on: + fastapi: + condition: service_started + command: ["uv", "run", "python", "-m", "app.worker.photo_worker.main"] + networks: + - multi_network + + notification-worker: + image: ghcr.io/microclub-usthb/multai-back:latest + container_name: mobile_notification_worker + profiles: ["firebase"] + restart: unless-stopped + env_file: .env.mobile + volumes: + - ../firebase-credentials.json:/app/firebase-credentials.json:ro + depends_on: + fastapi: + condition: service_started + command: ["uv", "run", "python", "-m", "app.worker.notification.main"] + networks: + - multi_network + + upload-group-worker: + image: ghcr.io/microclub-usthb/multai-back:latest + container_name: mobile_upload_group_worker + restart: unless-stopped + env_file: .env.mobile + depends_on: + fastapi: + condition: service_started + command: ["uv", "run", "python", "-m", "app.worker.upload_group_worker.main"] + networks: + - multi_network + + audit-worker: + image: ghcr.io/microclub-usthb/multai-back:latest + container_name: mobile_audit_worker + restart: unless-stopped + env_file: .env.mobile + depends_on: + fastapi: + condition: service_started + command: ["uv", "run", "python", "-m", "app.worker.audit.main"] + networks: + - multi_network + + storage-cleaner: + image: ghcr.io/microclub-usthb/multai-back:latest + container_name: mobile_storage_cleaner + restart: unless-stopped + env_file: .env.mobile + depends_on: + fastapi: + condition: service_started + command: ["uv", "run", "python", "-m", "app.worker.storage_cleaner.main"] + networks: + - multi_network + volumes: postgres_data: minio_data: + redis_data: + nats_data: networks: multi_network: diff --git a/mobile-quickstart/readme.md b/mobile-quickstart/readme.md index 7232b0a..63717d5 100644 --- a/mobile-quickstart/readme.md +++ b/mobile-quickstart/readme.md @@ -15,7 +15,7 @@ This folder contains everything you need to run the multAI backend locally. You * `docker-compose.mobile.yml` — defines all backend services * `.env.mobile` — environment variables (copy from `.env.mobile.example`) -* `seed.py` — database seed script +* `seed.py` — database seed script (already baked into the backend image, no manual copying needed) * `.mypy_cache/` — local Python type-check cache (safe to ignore/delete) * `README.md` — this file @@ -44,21 +44,55 @@ This pulls and starts the following containers: * **migrate** — runs database migrations once then exits * **fastapi** — the API server -### 3. Copy the seed script into the container +### 3. Seed the database ```bash -docker cp seed.py multai-mobile-test-fastapi-1:/app/seed.py +docker compose -f docker-compose.mobile.yml exec -e PYTHONPATH=/app fastapi uv run python mobile-quickstart/seed.py ``` -> **Note:** Wait for all containers to show as running before doing this. +This creates all the test data you need: users, events, photos, and notifications. + +--- + +## Background workers + +In addition to `postgres`, `redis`, `minio`, `nats`, `migrate`, and `fastapi`, the stack also starts these background workers by default: + +* **email-worker** — sends transactional emails (OTP codes, notifications) +* **photo-worker** — processes uploaded photos and face recognition +* **audit-worker** — records audit log events +* **upload-group-worker** — manages grouped photo upload requests +* **storage-cleaner** — periodically cleans up orphaned files in MinIO + +You don't need to do anything special for these — they start automatically with `docker compose up -d` and don't require any extra configuration. -### 4. Seed the database +## Notification worker (optional) +`notification-worker` is different — it needs a real Firebase service account file to run, so it's **off by default**. The rest of the backend (including all workers above) works fine without it. + +If you need push notifications working locally: + +1. Get `firebase-credentials.json` from the backend team. +2. Place it in the folder that **contains** `mobile-quickstart/` — one level above wherever you put this folder, not inside it. + + For example, if you copied `mobile-quickstart/` into your own project at `my-app/mobile-quickstart/`, put the file at `my-app/firebase-credentials.json`: + + ``` + my-app/ + ├── firebase-credentials.json ✅ correct location + ├── src/ + └── mobile-quickstart/ (this folder) + ├── docker-compose.mobile.yml + ├── .env.mobile + └── seed.py + ``` + +3. Start with the profile enabled: ```bash -docker compose -f docker-compose.mobile.yml exec fastapi uv run python seed.py +docker compose -f docker-compose.mobile.yml --profile firebase up -d ``` -This creates all the test data you need: users, events, photos, and notifications. +If you forget the `--profile firebase` flag, `notification-worker` simply won't appear in `docker compose ps` — that's expected, not an error. --- @@ -127,10 +161,9 @@ docker compose -f docker-compose.mobile.yml down -v docker compose -f docker-compose.mobile.yml pull docker compose -f docker-compose.mobile.yml up -d docker compose -f docker-compose.mobile.yml ps -docker cp seed.py multai-mobile-test-fastapi-1:/app/seed.py -docker compose -f docker-compose.mobile.yml exec fastapi uv run python seed.py +docker compose -f docker-compose.mobile.yml exec -e PYTHONPATH=/app fastapi uv run python mobile-quickstart/seed.py ``` -> `down -v` removes all volumes. `pull` grabs the latest backend image before recreating containers. Check the `ps` output shows all containers as `Up`/`Healthy` before running the seed step — copying the seed script too early (before the fastapi container is ready) will fail. +> `down -v` removes all volumes. `pull` grabs the latest backend image before recreating containers. Check the `ps` output shows all containers as `Up`/`Healthy` before running the seed step — running it too early (before the fastapi container is ready) will fail. --- @@ -140,7 +173,6 @@ When the backend team pushes a new version: docker compose -f docker-compose.mobile.yml pull docker compose -f docker-compose.mobile.yml up -d docker compose -f docker-compose.mobile.yml ps -docker cp seed.py multai-mobile-test-fastapi-1:/app/seed.py -docker compose -f docker-compose.mobile.yml exec fastapi uv run python seed.py +docker compose -f docker-compose.mobile.yml exec -e PYTHONPATH=/app fastapi uv run python mobile-quickstart/seed.py ``` -> Re-copy the seed script after updating since the container is recreated. Run `ps` first to confirm the new container is actually up before copying/seeding. \ No newline at end of file +> Run `ps` first to confirm the new container is actually up before seeding — the seed script ships inside the image, so no manual copying is needed. \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 2b0f8b2..89d4ad8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,6 @@ dependencies = [ "pyjwt>=2.11.0", "pyotp>=2.9.0", "redis>=7.2.1", - "setuptools>=82.0.0", "opencv-python-headless>=4.13.0.92", "numpy>=2.4.3", "insightface>=0.7.3", @@ -28,10 +27,10 @@ dependencies = [ "python-multipart>=0.0.22", "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", + "setuptools>=83.0.0", ] [tool.ruff] @@ -63,7 +62,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 0000000..fa17943 --- /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 0000000..1835786 --- /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 0000000..319afdd --- /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 staff_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 0000000..8651bdd --- /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 staff_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 0000000..f793df6 --- /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 0000000..86bb226 --- /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 staff_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 0000000..b1eba58 --- /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 0000000..2b6361a --- /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 staff_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 0000000..8428d2b --- /dev/null +++ b/tests/e2e/conftest.py @@ -0,0 +1,120 @@ +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'.""" + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout_s + async with engine.connect() as conn: + while 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 new file mode 100644 index 0000000..9bdaa76 --- /dev/null +++ b/tests/e2e/test_mobile_auth_intent_e2e.py @@ -0,0 +1,245 @@ +import os +import uuid +import requests # type: ignore[import-untyped] +import pytest + + +@pytest.mark.skipif( + not os.getenv("MULTAI_RUN_E2E"), + reason="E2E disabled (set MULTAI_RUN_E2E=1 to run)", +) +class TestMobileAuthEndpointsE2E: + """End-to-end tests for mobile auth register/login endpoints. + + These tests run against a live API. Set MULTAI_RUN_E2E=1 and + MULTAI_E2E_BASE_URL=http://localhost:8000 to enable. + """ + + @pytest.fixture(autouse=True) + def setup(self) -> None: + self.base_url = os.getenv("MULTAI_E2E_BASE_URL", "http://localhost:8000") + self.headers = { + "X-Forwarded-For": f"203.0.113.{uuid.uuid4().int % 250 + 1}", + } + + def test_login_with_unknown_email_fails(self) -> None: + """Test that login with unknown email returns 401.""" + payload = { + "email": f"nonexistent_{uuid.uuid4()}@example.com", + "password": "anypassword", + "device_name": "TestDevice", + "device_type": "android", + "device_id": str(uuid.uuid4()), + } + response = requests.post( + f"{self.base_url}/user/auth/login", + json=payload, + headers=self.headers, + ) + assert response.status_code == 401 + assert "not found" in response.json()["detail"].lower() + + def test_register_with_existing_email_fails(self) -> None: + """Test that registration with existing email returns 409.""" + email = f"testuser_{uuid.uuid4()}@example.com" + device_id = str(uuid.uuid4()) + + # First registration succeeds + register_payload = { + "email": email, + "password": "ValidPass@123", + "device_name": "TestDevice", + "device_type": "android", + "device_id": device_id, + } + response1 = requests.post( + f"{self.base_url}/user/auth/register", + json=register_payload, + headers=self.headers, + ) + assert response1.status_code == 200 + assert response1.json()["status"] == "pending_verification" + + # 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 use" in response2.json()["detail"].lower() + + def test_register_then_login_succeeds(self) -> None: + """Test full flow: register then login.""" + email = f"user_{uuid.uuid4()}@example.com" + password = "ValidPass@123" + device_id = str(uuid.uuid4()) + + # Register + register_payload = { + "email": email, + "password": password, + "device_name": "TestDevice", + "device_type": "android", + "device_id": device_id, + } + register_response = requests.post( + f"{self.base_url}/user/auth/register", + json=register_payload, + headers=self.headers, + ) + assert register_response.status_code == 200 + 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 = { + "email": email, + "password": password, + "device_name": "TestDevice", + "device_type": "android", + "device_id": device_id, + } + login_response = requests.post( + f"{self.base_url}/user/auth/login", + json=login_payload, + headers=self.headers, + ) + assert login_response.status_code == 200 + assert login_response.json()["is_new_user"] is False + login_token = login_response.json()["access_token"] + + # Both tokens should work + assert register_token + assert login_token + + def test_login_with_wrong_password_fails(self) -> None: + """Test that login with wrong password returns 401.""" + email = f"user_{uuid.uuid4()}@example.com" + password = "CorrectPass@123" + device_id = str(uuid.uuid4()) + + # Register first + register_payload = { + "email": email, + "password": password, + "device_name": "TestDevice", + "device_type": "android", + "device_id": device_id, + } + register_response = requests.post( + f"{self.base_url}/user/auth/register", + json=register_payload, + headers=self.headers, + ) + 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, + "password": "WrongPass@123", + "device_name": "TestDevice", + "device_type": "android", + "device_id": device_id, + } + response = requests.post( + f"{self.base_url}/user/auth/login", + json=login_payload, + headers=self.headers, + ) + assert response.status_code == 401 + assert "invalid" in response.json()["detail"].lower() + + def test_register_requires_password(self) -> None: + """Test that register requires a password.""" + payload = { + "email": "user@example.com", + "device_name": "TestDevice", + "device_type": "android", + "device_id": str(uuid.uuid4()), + # Missing password + } + response = requests.post( + f"{self.base_url}/user/auth/register", + json=payload, + headers=self.headers, + ) + assert response.status_code == 422 + + def test_login_requires_device_type(self) -> None: + """Test that login requires device_type.""" + payload = { + "email": "user@example.com", + "password": "ValidPass@123", + "device_name": "TestDevice", + "device_id": str(uuid.uuid4()), + # Missing device_type + } + response = requests.post( + f"{self.base_url}/user/auth/login", + json=payload, + headers=self.headers, + ) + assert response.status_code == 422 diff --git a/tests/e2e/test_mobile_auth_request_validation_e2e.py b/tests/e2e/test_mobile_auth_request_validation_e2e.py new file mode 100644 index 0000000..96c7f0e --- /dev/null +++ b/tests/e2e/test_mobile_auth_request_validation_e2e.py @@ -0,0 +1,57 @@ +import os +import uuid + +import httpx +import pytest + + +pytestmark = [ + pytest.mark.e2e, + pytest.mark.skipif( + os.getenv("MULTAI_RUN_E2E") != "1", + reason="set MULTAI_RUN_E2E=1 to run live e2e tests", + ), +] + + +BASE_URL = os.getenv("MULTAI_E2E_BASE_URL", "http://localhost:8000").rstrip("/") +REGISTER_URL = f"{BASE_URL}/user/auth/register" +LOGIN_URL = f"{BASE_URL}/user/auth/login" + + +def _valid_payload() -> dict[str, object]: + return { + "email": f"e2e-{uuid.uuid4()}@example.com", + "password": "ValidPass@123", + "device_name": "Pixel 8", + "device_type": "android", + "device_id": str(uuid.uuid4()), + } + + +@pytest.mark.parametrize("field", ["password", "device_name", "device_type"]) +@pytest.mark.parametrize("value", ["", " "]) +@pytest.mark.parametrize("url", [REGISTER_URL, LOGIN_URL]) +def test_live_register_login_rejects_empty_required_text_fields( + field: str, + value: str, + url: str, +) -> None: + payload = _valid_payload() + payload[field] = value + + 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 + + +@pytest.mark.parametrize("url", [REGISTER_URL, LOGIN_URL]) +def test_live_register_login_rejects_padded_short_password(url: str) -> None: + payload = _valid_payload() + payload["password"] = " a" + + 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 0000000..ed3e076 --- /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 0000000..0f9437b --- /dev/null +++ b/tests/e2e/test_photo_ai_load.py @@ -0,0 +1,219 @@ +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.""" + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + async with engine.connect() as conn: + while 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 0000000..0a7b3b5 --- /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/e2e/test_stats_endpoint.py b/tests/e2e/test_stats_endpoint.py index f16ca7b..8620b0e 100644 --- a/tests/e2e/test_stats_endpoint.py +++ b/tests/e2e/test_stats_endpoint.py @@ -1,4 +1,8 @@ import pytest +import os +if os.getenv("MULTAI_RUN_E2E") != "1": + pytest.skip("set MULTAI_RUN_E2E=1 to run live e2e tests", allow_module_level=True) + import uuid from fastapi.testclient import TestClient from datetime import datetime, timezone @@ -9,6 +13,9 @@ @pytest.fixture(scope="module") def client() -> Generator[TestClient, None, None]: + import os + if os.getenv("MULTAI_RUN_E2E") != "1": + pytest.skip("set MULTAI_RUN_E2E=1 to run live e2e tests") # Override the dependency to bypass cookie auth mock_admin = StaffUser( id=uuid.uuid4(), diff --git a/tests/fixtures/images/face.jpg b/tests/fixtures/images/face.jpg new file mode 100644 index 0000000..f746a3c 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 0000000..7991f1a 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 0000000..4a58e54 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 0000000..836f1ff --- /dev/null +++ b/tests/integration/test_enrollment_flow.py @@ -0,0 +1,102 @@ +""" +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(bytes=b"fake-image", filename="face.jpg", content_type="image/jpeg") + + 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.execute( + text("DELETE FROM users WHERE id = :uid"), {"uid": 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 0000000..2cfb9f4 --- /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 staff_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 0000000..d010e70 --- /dev/null +++ b/tests/security/test_auth_security.py @@ -0,0 +1,160 @@ +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 +from app.service.session import SessionService + +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) + + redis = RedisClient.get_instance() + await SessionService.cache_session_for_auth( + redis=redis, + session_id=session_id, + user_id=user_id, + email="blocked@test.com", + expires_at=datetime.now(timezone.utc) + timedelta(hours=1), + blocked=True, + ttl=3600, + ) + + 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("DELETE FROM users WHERE id = :uid"), {"uid": 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() + + redis = RedisClient.get_instance() + await SessionService.cache_session_for_auth( + redis=redis, + session_id=session_id, + user_id=user_id, + email="rate@test.com", + expires_at=datetime.now(timezone.utc) + timedelta(hours=1), + blocked=False, + ttl=3600, + ) + + 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("DELETE FROM users WHERE id = :uid"), {"uid": 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 index 9a6f825..008a13a 100644 --- a/tests/unit/test_auth_email_otp.py +++ b/tests/unit/test_auth_email_otp.py @@ -139,7 +139,7 @@ async def test_mobile_register_resend_otp_success( mock_redis.incr.return_value = 1 # Act - with patch("app.infra.nats.NatsClient.publish", new_callable=AsyncMock) as mock_publish: + with patch("app.service.users.NatsClient.publish", new_callable=AsyncMock) as mock_publish: res = await auth_service.mobile_register_resend_otp(redis=mock_redis, email=email) # Assert diff --git a/tests/unit/test_auth_service.py b/tests/unit/test_auth_service.py new file mode 100644 index 0000000..2024ba6 --- /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_bcrypt_truncation.py b/tests/unit/test_bcrypt_truncation.py new file mode 100644 index 0000000..d0e994c --- /dev/null +++ b/tests/unit/test_bcrypt_truncation.py @@ -0,0 +1,20 @@ +from app.core.securite import hash_password, verify_password + + +def test_bcrypt_72_byte_truncation_is_prevented() -> None: + # Create two passwords that are longer than 72 bytes and share the same 72-byte prefix + prefix = "a" * 72 + pass1 = prefix + "X" + pass2 = prefix + "Y" + + # Hash both passwords + hash1 = hash_password(pass1) + hash2 = hash_password(pass2) + + # They must produce different hashes (or at least pass2 must not verify with hash1) + assert not verify_password(pass2, hash1) + assert not verify_password(pass1, hash2) + + # Each must verify with its own hash + assert verify_password(pass1, hash1) + assert verify_password(pass2, hash2) diff --git a/tests/unit/test_enroll_security.py b/tests/unit/test_enroll_security.py new file mode 100644 index 0000000..4f31584 --- /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 0000000..8de215b --- /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 new file mode 100644 index 0000000..1d3b11a --- /dev/null +++ b/tests/unit/test_mobile_auth_email_logging.py @@ -0,0 +1,137 @@ + +# Test doubles intentionally implement only the AuthService methods exercised here. +# They do not subclass the generated queriers, so mypy would otherwise flag each +# constructor injection as an arg-type mismatch. +# mypy: disable-error-code=arg-type + +import asyncio +import logging +import uuid +from datetime import datetime, timezone + +import pytest + +import app.service.users as users_module +from app.schema.request.mobile.auth import MobileRegisterRequest +from app.service.session import SessionService +from app.service.users import AuthService + + +class FakeUser: + def __init__(self, email: str) -> None: + self.id = uuid.uuid4() + self.email = email + self.blocked = False + self.hashed_password = "hashed" + + +class FakeDevice: + is_invalid_token = False + is_active = True + + +class FakeSession: + def __init__(self) -> None: + self.id = uuid.uuid4() + self.expires_at = datetime.now(timezone.utc) + + +class FakeUserQuerier: + def __init__(self, user: FakeUser) -> None: + self._user = user + + async def get_user_by_email(self, email: str) -> FakeUser | None: + return None + + async def create_user(self, *, email: str, hashed_password: str) -> FakeUser: + self._user.email = email + self._user.hashed_password = hashed_password + return self._user + + +class FakeDeviceQuerier: + async def get_device_by_id(self, id: uuid.UUID) -> FakeDevice | None: + return None + + async def create_device(self, arg: object) -> FakeDevice: + return FakeDevice() + + +class FakeSessionQuerier: + def __init__(self, session: FakeSession) -> None: + self._session = session + + async def count_user_sessions(self, user_id: uuid.UUID) -> int: + return 0 + + async def upsert_session( + self, + *, + user_id: uuid.UUID, + device_id: uuid.UUID, + expires_at: datetime, + ) -> FakeSession: + self._session.expires_at = expires_at + return self._session + + +class FakeRedis: + def __init__(self) -> None: + self._store: dict[str, int] = {} + + async def incr(self, key: str) -> int: + self._store[key] = self._store.get(key, 0) + 1 + return self._store[key] + + async def expire(self, key: str, seconds: int) -> None: + pass + + async def ttl(self, key: str) -> int: + return -1 + + async def set(self, key: str, value: str, expire: int) -> None: + return None + +class FakeFaceEmbeddingService: + pass + + +def test_mobile_register_logs_without_plaintext_email( + caplog: pytest.LogCaptureFixture, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify that plaintext email is never logged; use user_id instead.""" + caplog.set_level(logging.INFO, logger="multAI") + + user = FakeUser(email="user@example.com") + session = FakeSession() + service = AuthService( + user_querier=FakeUserQuerier(user), + device_querier=FakeDeviceQuerier(), + session_querier=FakeSessionQuerier(session), + face_embedding_service=FakeFaceEmbeddingService(), + ) + + req = MobileRegisterRequest( + email="USER@Example.COM", + password="ValidPass@123", + device_name="Pixel 8", + device_type="android", + device_id=uuid.uuid4(), + ) + + async def _noop_cache_session_for_auth(**_: object) -> None: + return None + + monkeypatch.setattr(SessionService, "cache_session_for_auth", _noop_cache_session_for_auth) + monkeypatch.setattr(users_module, "create_acces_mobile_token", lambda _: "access") + monkeypatch.setattr(users_module, "create_refresh_mobile_token", lambda _: "refresh") + monkeypatch.setattr(users_module, "Get_expiry_time", lambda: 3600) + + asyncio.run(service.mobile_register(FakeRedis(), req)) + + # Verify no plaintext email in logs + assert req.email not in caplog.text + assert "user@example.com" not 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 new file mode 100644 index 0000000..4d58ce1 --- /dev/null +++ b/tests/unit/test_mobile_auth_intent_validation.py @@ -0,0 +1,438 @@ +from typing import Any + +# Test doubles intentionally implement only the AuthService methods exercised here. +# They do not subclass the generated queriers, so mypy would otherwise flag each +# constructor injection as an arg-type mismatch. +# mypy: disable-error-code=arg-type + +import asyncio +import logging +import uuid +from datetime import datetime, timezone + +import pytest +from fastapi import HTTPException + +import app.service.users as users_module +from app.core.securite import hash_password +from app.schema.request.mobile.auth import MobileLoginRequest, MobileRegisterRequest +from app.service.session import SessionService +from app.service.users import AuthService + + +class FakeUser: + def __init__(self, email: str, exists: bool = True, password: str = "ValidPass@123") -> None: + self.id = uuid.uuid4() + self.email = email + self.blocked = False + self.hashed_password = hash_password(password) + self.exists = exists + + +class FakeDevice: + is_invalid_token = False + is_active = True + + +class FakeSession: + def __init__(self) -> None: + self.id = uuid.uuid4() + self.expires_at = datetime.now(timezone.utc) + + +class FakeUserQuerier: + def __init__(self, user: FakeUser) -> None: + self._user = user + self._created_users: dict[str, FakeUser] = {} + + async def get_user_by_email(self, email: str) -> FakeUser | None: + if self._user.exists and self._user.email == email: + return self._user + if email in self._created_users: + return self._created_users[email] + return None + + async def get_user_by_id(self, id: uuid.UUID) -> FakeUser | None: + if self._user.id == id: + return self._user + return None + + async def create_user(self, *, email: str, hashed_password: str) -> FakeUser: + new_user = FakeUser(email=email, exists=True) + new_user.hashed_password = hashed_password + self._created_users[email] = new_user + return new_user + + +class FakeDeviceQuerier: + async def get_device_by_id(self, id: uuid.UUID) -> FakeDevice | None: + return None + + async def create_device(self, arg: object) -> FakeDevice: + return FakeDevice() + + async def activate_device(self, id: uuid.UUID, user_id: uuid.UUID) -> None: + return None + + +class FakeSessionQuerier: + def __init__(self, session: FakeSession) -> None: + self._session = session + + async def count_user_sessions(self, user_id: uuid.UUID) -> int: + return 0 + + async def get_session_by_id(self, id: uuid.UUID) -> FakeSession | None: + return self._session + + async def upsert_session( + self, + *, + user_id: uuid.UUID, + device_id: uuid.UUID, + expires_at: datetime, + ) -> FakeSession: + self._session.expires_at = expires_at + return self._session + + +class FakeRedis: + def __init__(self) -> None: + self._store: dict[str, int] = {} + + async def incr(self, key: str) -> int: + self._store[key] = self._store.get(key, 0) + 1 + return self._store[key] + + async def expire(self, key: str, seconds: int) -> None: + pass + + async def ttl(self, key: str) -> int: + return -1 + + 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 + + +def test_login_with_unknown_email_is_rejected() -> None: + """Test that login with unknown email fails.""" + user = FakeUser(email="user@example.com", exists=True) + session = FakeSession() + service = AuthService( + user_querier=FakeUserQuerier(user), + device_querier=FakeDeviceQuerier(), + session_querier=FakeSessionQuerier(session), + face_embedding_service=FakeFaceEmbeddingService(), + ) + + req = MobileLoginRequest( + email="unknown@example.com", + password="ValidPass@123", + device_name="Pixel 8", + device_type="android", + device_id=uuid.uuid4(), + ) + + with pytest.raises(HTTPException) as exc_info: + asyncio.run(service.mobile_login(FakeRedis(), req)) + assert exc_info.value.status_code == 401 + assert "not found" in exc_info.value.detail.lower() + + +def test_register_with_existing_email_is_rejected() -> None: + """Test that registration with existing email fails.""" + user = FakeUser(email="user@example.com", exists=True) + session = FakeSession() + service = AuthService( + user_querier=FakeUserQuerier(user), + device_querier=FakeDeviceQuerier(), + session_querier=FakeSessionQuerier(session), + face_embedding_service=FakeFaceEmbeddingService(), + ) + + req = MobileRegisterRequest( + email="user@example.com", + password="ValidPass@123", + device_name="Pixel 8", + device_type="android", + device_id=uuid.uuid4(), + ) + + with pytest.raises(HTTPException) as exc_info: + asyncio.run(service.mobile_register(FakeRedis(), req)) + assert exc_info.value.status_code == 409 + assert "already" in exc_info.value.detail.lower() + + +def test_login_with_correct_credentials_succeeds( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test that login with correct credentials succeeds.""" + user = FakeUser(email="user@example.com", exists=True) + session = FakeSession() + service = AuthService( + user_querier=FakeUserQuerier(user), + device_querier=FakeDeviceQuerier(), + session_querier=FakeSessionQuerier(session), + face_embedding_service=FakeFaceEmbeddingService(), + ) + + req = MobileLoginRequest( + email="user@example.com", + password="ValidPass@123", + device_name="Pixel 8", + device_type="android", + device_id=uuid.uuid4(), + ) + + async def _noop_cache_session_for_auth(**_: object) -> None: + return None + + monkeypatch.setattr(SessionService, "cache_session_for_auth", _noop_cache_session_for_auth) + monkeypatch.setattr(users_module, "create_acces_mobile_token", lambda _: "access") + monkeypatch.setattr(users_module, "create_refresh_mobile_token", lambda _: "refresh") + monkeypatch.setattr(users_module, "Get_expiry_time", lambda: 3600) + + result = asyncio.run(service.mobile_login(FakeRedis(), req)) + assert result.access_token == "access" + assert result.refresh_token == "refresh" + assert result.is_new_user is False + + +def test_register_with_new_email_succeeds( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test that registration with new email succeeds.""" + user = FakeUser(email="user@example.com", exists=False) + session = FakeSession() + service = AuthService( + user_querier=FakeUserQuerier(user), + device_querier=FakeDeviceQuerier(), + session_querier=FakeSessionQuerier(session), + face_embedding_service=FakeFaceEmbeddingService(), + ) + + req = MobileRegisterRequest( + email="newuser@example.com", + password="ValidPass@123", + device_name="Pixel 8", + device_type="android", + device_id=uuid.uuid4(), + ) + + async def _noop_cache_session_for_auth(**_: object) -> None: + return None + + monkeypatch.setattr(SessionService, "cache_session_for_auth", _noop_cache_session_for_auth) + monkeypatch.setattr(users_module, "create_acces_mobile_token", lambda _: "access") + monkeypatch.setattr(users_module, "create_refresh_mobile_token", lambda _: "refresh") + monkeypatch.setattr(users_module, "Get_expiry_time", lambda: 3600) + + result = asyncio.run(service.mobile_register(FakeRedis(), req)) + assert result.status == "pending_verification" + + +def test_register_then_login_same_device_succeeds( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test full flow: register then login with same device.""" + user = FakeUser(email="newuser@example.com", exists=False) + session = FakeSession() + service = AuthService( + user_querier=FakeUserQuerier(user), + device_querier=FakeDeviceQuerier(), + session_querier=FakeSessionQuerier(session), + face_embedding_service=FakeFaceEmbeddingService(), + ) + + async def _noop_cache_session_for_auth(**_: object) -> None: + return None + + monkeypatch.setattr(SessionService, "cache_session_for_auth", _noop_cache_session_for_auth) + monkeypatch.setattr(users_module, "create_acces_mobile_token", lambda _: "access") + monkeypatch.setattr(users_module, "create_refresh_mobile_token", lambda _: "refresh") + monkeypatch.setattr(users_module, "Get_expiry_time", lambda: 3600) + + device_id = uuid.uuid4() + password = "ValidPass@123" + + # Register + register_req = MobileRegisterRequest( + email="newuser@example.com", + password=password, + device_name="TestDevice", + device_type="android", + device_id=device_id, + ) + 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)}) + + # 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( + email="newuser@example.com", + password=password, + device_name="TestDevice", + device_type="android", + device_id=device_id, + ) + result2 = asyncio.run(service.mobile_login(FakeRedis(), login_req)) + assert result2.is_new_user is False + + +def test_login_with_wrong_password_fails() -> None: + """Test that login with wrong password fails.""" + user = FakeUser(email="user@example.com", exists=True) + session = FakeSession() + service = AuthService( + user_querier=FakeUserQuerier(user), + device_querier=FakeDeviceQuerier(), + session_querier=FakeSessionQuerier(session), + face_embedding_service=FakeFaceEmbeddingService(), + ) + + req = MobileLoginRequest( + email="user@example.com", + password="wrongpassword", + device_name="Pixel 8", + device_type="android", + device_id=uuid.uuid4(), + ) + + with pytest.raises(HTTPException) as exc_info: + asyncio.run(service.mobile_login(FakeRedis(), req)) + assert exc_info.value.status_code == 401 + assert "invalid" in exc_info.value.detail.lower() + + +def test_login_logs_correctly( + caplog: pytest.LogCaptureFixture, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test that login emits audit-friendly logs without email.""" + caplog.set_level(logging.INFO, logger="multAI") + + user = FakeUser(email="user@example.com", exists=True) + session = FakeSession() + service = AuthService( + user_querier=FakeUserQuerier(user), + device_querier=FakeDeviceQuerier(), + session_querier=FakeSessionQuerier(session), + face_embedding_service=FakeFaceEmbeddingService(), + ) + + req = MobileLoginRequest( + email="USER@Example.COM", + password="ValidPass@123", + device_name="Pixel 8", + device_type="android", + device_id=uuid.uuid4(), + ) + + async def _noop_cache_session_for_auth(**_: object) -> None: + return None + + monkeypatch.setattr(SessionService, "cache_session_for_auth", _noop_cache_session_for_auth) + monkeypatch.setattr(users_module, "create_acces_mobile_token", lambda _: "access") + monkeypatch.setattr(users_module, "create_refresh_mobile_token", lambda _: "refresh") + monkeypatch.setattr(users_module, "Get_expiry_time", lambda: 3600) + + asyncio.run(service.mobile_login(FakeRedis(), req)) + + assert "mobile_login attempt" in caplog.text + assert "login success user_id=" in caplog.text + assert "session_id=" in caplog.text + assert "user@example.com" not in caplog.text + + +def test_register_concurrent_signup_integrity_error() -> None: + """Test that concurrent signup IntegrityError is caught and raised as 409.""" + from sqlalchemy.exc import IntegrityError + + class FakeOrigException(Exception): + sqlstate = "23505" + constraint_name = "idx_users_email" + + user = FakeUser(email="user@example.com", exists=False) + session = FakeSession() + + async def _raise_integrity_error(*args: Any, **kwargs: Any) -> Any: + raise IntegrityError( + statement="INSERT INTO users", + params={}, + orig=FakeOrigException("duplicate key value violates unique constraint idx_users_email") + ) + + user_querier = FakeUserQuerier(user) + # Stub create_user to raise IntegrityError + user_querier.create_user = _raise_integrity_error # type: ignore + + service = AuthService( + user_querier=user_querier, + device_querier=FakeDeviceQuerier(), + session_querier=FakeSessionQuerier(session), + face_embedding_service=FakeFaceEmbeddingService(), + ) + + # 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.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_rate_limiting.py b/tests/unit/test_mobile_auth_rate_limiting.py new file mode 100644 index 0000000..e5f227e --- /dev/null +++ b/tests/unit/test_mobile_auth_rate_limiting.py @@ -0,0 +1,101 @@ +import asyncio +import uuid +from typing import Any + +import pytest +from fastapi import HTTPException +from app.service.users import AuthService +from app.schema.request.mobile.auth import MobileLoginRequest + +# Test doubles intentionally implement only the AuthService methods exercised here. +# They do not subclass the generated queriers, so mypy would otherwise flag each +# constructor injection as an arg-type mismatch. +# mypy: disable-error-code=arg-type + + +class MockRedis: + def __init__(self) -> None: + self.data: dict[str, int] = {} + self.ttls: dict[str, int] = {} + + async def incr(self, key: str) -> int: + self.data[key] = self.data.get(key, 0) + 1 + return self.data[key] + + async def expire(self, key: str, seconds: int) -> bool: + self.ttls[key] = seconds + return True + + +class FakeUser: + def __init__(self) -> None: + self.id = uuid.uuid4() + self.email = "test@example.com" + from app.core.securite import hash_password + self.hashed_password = hash_password("ValidPass@123") + self.blocked = False + + +class FakeUserQuerier: + async def get_user_by_email(self, email: str) -> FakeUser: + return FakeUser() + + +class FakeDeviceQuerier: + pass + + +class FakeSessionQuerier: + pass + + +class FakeFaceEmbeddingService: + pass + + +def test_rate_limiting_triggered_after_max_attempts() -> None: + # Set up mocks + redis = MockRedis() + service = AuthService( + user_querier=FakeUserQuerier(), + device_querier=FakeDeviceQuerier(), + session_querier=FakeSessionQuerier(), + face_embedding_service=FakeFaceEmbeddingService(), + ) + + # Stub session creation to avoid database / redis dependencies + async def _dummy_create_session(*args: object, **kwargs: object) -> Any: + from app.schema.response.mobile.auth import MobileAuthResponse + return MobileAuthResponse( + access_token="access", + refresh_token="refresh", + session_id=str(uuid.uuid4()), + expires_in=3600, + user_id=uuid.uuid4(), + is_new_user=False, + ) + service._create_mobile_session = _dummy_create_session # type: ignore + + req = MobileLoginRequest( + email="test@example.com", + password="ValidPass@123", + device_name="Pixel 8", + device_type="android", + device_id=uuid.uuid4(), + ) + + # Call mobile_login 5 times (which is the default max limit in settings) + # The first 5 should succeed without raising exceptions + for i in range(5): + res = asyncio.run(service.mobile_login(redis, req, client_ip="127.0.0.1")) + assert res.access_token == "access" + + # The 6th call must trigger the rate limit and raise 429! + with pytest.raises(HTTPException) as exc_info: + asyncio.run(service.mobile_login(redis, req, client_ip="127.0.0.1")) + assert exc_info.value.status_code == 429 + assert "too many requests" in exc_info.value.detail.lower() + + # The ttls for the keys should have been set + assert redis.ttls["rate:ip:127.0.0.1"] == 60 + assert redis.ttls["rate:email:test@example.com"] == 60 diff --git a/tests/unit/test_mobile_auth_request_validation.py b/tests/unit/test_mobile_auth_request_validation.py new file mode 100644 index 0000000..56d3a82 --- /dev/null +++ b/tests/unit/test_mobile_auth_request_validation.py @@ -0,0 +1,233 @@ +import uuid +from collections.abc import Iterator +from typing import Any + +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, RegisterPendingResponse + + +class FakeAuthService: + def __init__(self) -> None: + self.register_request: MobileRegisterRequest | None = None + self.login_request: MobileLoginRequest | None = None + self.register_client_ip: object = None + self.login_client_ip: object = None + + async def mobile_register( + self, + redis: object, + req: MobileRegisterRequest, + client_ip: object = None, + ) -> RegisterPendingResponse: + self.register_request = req + self.register_client_ip = client_ip + return RegisterPendingResponse( + message="OTP sent", + status="pending_verification", + email=req.email, + ) + + async def mobile_login( + self, + redis: object, + req: MobileLoginRequest, + client_ip: object = None, + ) -> MobileAuthResponse: + self.login_request = req + self.login_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=False, + ) + + +class FakeAuditService: + async def create_record(self, **kwargs: Any) -> None: + return None + + +class FakeContainer: + def __init__(self) -> None: + self.redis = object() + self.auth_service = FakeAuthService() + self.audit_service = FakeAuditService() + + +@pytest.fixture +def fake_container() -> FakeContainer: + return FakeContainer() + + + +@pytest.fixture +def client(fake_container: FakeContainer) -> Iterator[TestClient]: + app.dependency_overrides[get_container] = lambda: fake_container + 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]: + return { + "email": "USER@Example.COM", + "password": "ValidPass@123", + "device_name": "Pixel 8", + "device_type": "android", + "device_id": str(uuid.uuid4()), + } + + +@pytest.mark.parametrize("field", ["password", "device_name", "device_type"]) +@pytest.mark.parametrize("value", ["", " "]) +def test_register_login_rejects_empty_required_text_fields( + client: TestClient, + fake_container: FakeContainer, + field: str, + value: str, +) -> None: + for endpoint, attr in ( + ("/user/auth/register", "register_request"), + ("/user/auth/login", "login_request"), + ): + payload = _valid_payload() + payload[field] = value + + response = client.post(endpoint, json=payload) + + assert response.status_code == 422 + assert getattr(fake_container.auth_service, attr) is None + + +def test_register_login_passes_normalized_input_to_service( + client: TestClient, + fake_container: FakeContainer, +) -> None: + for endpoint, attr in ( + ("/user/auth/register", "register_request"), + ("/user/auth/login", "login_request"), + ): + payload = _valid_payload() + payload.update( + { + "email": " USER@Example.COM ", + "device_name": " Pixel 8 ", + "device_type": " ANDROID ", + } + ) + + response = client.post(endpoint, json=payload) + + assert response.status_code == 200 + req = getattr(fake_container.auth_service, attr) + assert req is not None + assert req.email == "user@example.com" + assert req.device_name == "Pixel 8" + assert req.device_type == "android" + + +def test_register_login_password_length_is_checked_after_trimming( + client: TestClient, + fake_container: FakeContainer, +) -> None: + for endpoint, attr in ( + ("/user/auth/register", "register_request"), + ("/user/auth/login", "login_request"), + ): + payload = _valid_payload() + payload["password"] = " a" + + response = client.post(endpoint, json=payload) + + assert response.status_code == 422 + assert getattr(fake_container.auth_service, attr) is None + + +def test_register_login_rejects_oversized_email( + client: TestClient, + fake_container: FakeContainer, +) -> None: + for endpoint, attr in ( + ("/user/auth/register", "register_request"), + ("/user/auth/login", "login_request"), + ): + payload = _valid_payload() + # Create an email address with a length > 255 chars + # username part is 250 'a's, domain is "@example.com" + payload["email"] = "a" * 250 + "@example.com" + + response = client.post(endpoint, json=payload) + + assert response.status_code == 422 + assert getattr(fake_container.auth_service, attr) is None + + +def test_register_enforces_password_complexity( + client: TestClient, + fake_container: FakeContainer, +) -> None: + # Valid complex password + payload = _valid_payload() + payload["password"] = "P@ssword123" + response = client.post("/user/auth/register", json=payload) + assert response.status_code == 200 + assert fake_container.auth_service.register_request is not None + + # Invalid passwords lacking different criteria + invalid_passwords = [ + "p@ssword123", # missing uppercase + "P@SSWORD123", # missing lowercase + "P@sswordabc", # missing digit + "Password123", # missing special char + ] + for pw in invalid_passwords: + fake_container.auth_service.register_request = None + payload = _valid_payload() + payload["password"] = pw + response = client.post("/user/auth/register", json=payload) + assert response.status_code == 422 + assert fake_container.auth_service.register_request is None + + +def test_login_does_not_enforce_password_complexity( + client: TestClient, + fake_container: FakeContainer, +) -> None: + # Simple password should still be allowed to attempt log in + payload = _valid_payload() + payload["password"] = "Password123" # missing special character + response = client.post("/user/auth/login", json=payload) + assert response.status_code == 200 + assert fake_container.auth_service.login_request is not None + + +def test_mobile_auth_uses_forwarded_ip_for_rate_limit_identity( + client: TestClient, + fake_container: FakeContainer, +) -> None: + payload = _valid_payload() + + response = client.post( + "/user/auth/login", + json=payload, + headers={"X-Forwarded-For": "203.0.113.10, 10.0.0.1"}, + ) + + assert response.status_code == 200 + assert fake_container.auth_service.login_client_ip == "203.0.113.10" + + diff --git a/tests/unit/test_photo_approval_lifecycle.py b/tests/unit/test_photo_approval_lifecycle.py index 52bcd68..00577e6 100644 --- a/tests/unit/test_photo_approval_lifecycle.py +++ b/tests/unit/test_photo_approval_lifecycle.py @@ -45,7 +45,7 @@ def mock_staged_upload_storage_service() -> AsyncMock: @pytest.mark.asyncio -async def test_group_photo_auto_approve_no_users( +async def test_group_photo_pending_no_users( mock_conn: AsyncMock, mock_face_embedding_service: AsyncMock, mock_single_face_service: AsyncMock, @@ -84,9 +84,9 @@ async def test_group_photo_auto_approve_no_users( # Verify notifications were NOT sent mock_notification_service.create_notification.assert_not_called() - # Verify photo is marked public and approved - mock_photo_querier.update_photo_status.assert_called_once_with(id=photo_id, status="approved") - mock_photo_querier.update_photo_visibility.assert_called_once_with(id=photo_id, visibility="public") + # 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 diff --git a/tests/unit/test_photo_approval_service.py b/tests/unit/test_photo_approval_service.py new file mode 100644 index 0000000..d10c62b --- /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 87c2bf7..b403b75 100644 --- a/uv.lock +++ b/uv.lock @@ -313,7 +313,6 @@ dependencies = [ { name = "nats-py" }, { name = "numpy" }, { name = "onnxruntime" }, - { name = "opencv-python" }, { name = "opencv-python-headless" }, { name = "passlib", extra = ["bcrypt"] }, { name = "pillow-heif" }, @@ -353,7 +352,6 @@ requires-dist = [ { name = "nats-py", specifier = ">=2.14.0" }, { name = "numpy", specifier = ">=2.4.3" }, { name = "onnxruntime", specifier = ">=1.24.4" }, - { 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" }, @@ -365,7 +363,7 @@ requires-dist = [ { name = "python-multipart", specifier = ">=0.0.22" }, { name = "pywebpush", specifier = ">=2.3.0" }, { name = "redis", specifier = ">=7.2.1" }, - { name = "setuptools", specifier = ">=82.0.0" }, + { name = "setuptools", specifier = ">=83.0.0" }, { name = "sqlalchemy", specifier = ">=2.0.47" }, ] @@ -2196,24 +2194,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6c/1d/1666dc64e78d8587d168fec4e3b7922b92eb286a2ddeebcf6acb55c7dc82/onnxruntime-1.24.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e1cc6a518255f012134bc791975a6294806be9a3b20c4a54cca25194c90cf731", size = 17247021, upload-time = "2026-03-17T22:04:52.377Z" }, ] -[[package]] -name = "opencv-python" -version = "4.13.0.92" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/fc/6f/5a28fef4c4a382be06afe3938c64cc168223016fa520c5abaf37e8862aa5/opencv_python-4.13.0.92-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:caf60c071ec391ba51ed00a4a920f996d0b64e3e46068aac1f646b5de0326a19", size = 46247052, upload-time = "2026-02-05T07:01:25.046Z" }, - { url = "https://files.pythonhosted.org/packages/08/ac/6c98c44c650b8114a0fb901691351cfb3956d502e8e9b5cd27f4ee7fbf2f/opencv_python-4.13.0.92-cp37-abi3-macosx_14_0_x86_64.whl", hash = "sha256:5868a8c028a0b37561579bfb8ac1875babdc69546d236249fff296a8c010ccf9", size = 32568781, upload-time = "2026-02-05T07:01:41.379Z" }, - { url = "https://files.pythonhosted.org/packages/3e/51/82fed528b45173bf629fa44effb76dff8bc9f4eeaee759038362dfa60237/opencv_python-4.13.0.92-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0bc2596e68f972ca452d80f444bc404e08807d021fbba40df26b61b18e01838a", size = 47685527, upload-time = "2026-02-05T06:59:11.24Z" }, - { url = "https://files.pythonhosted.org/packages/db/07/90b34a8e2cf9c50fe8ed25cac9011cde0676b4d9d9c973751ac7616223a2/opencv_python-4.13.0.92-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:402033cddf9d294693094de5ef532339f14ce821da3ad7df7c9f6e8316da32cf", size = 70460872, upload-time = "2026-02-05T06:59:19.162Z" }, - { url = "https://files.pythonhosted.org/packages/02/6d/7a9cc719b3eaf4377b9c2e3edeb7ed3a81de41f96421510c0a169ca3cfd4/opencv_python-4.13.0.92-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:bccaabf9eb7f897ca61880ce2869dcd9b25b72129c28478e7f2a5e8dee945616", size = 46708208, upload-time = "2026-02-05T06:59:15.419Z" }, - { url = "https://files.pythonhosted.org/packages/fd/55/b3b49a1b97aabcfbbd6c7326df9cb0b6fa0c0aefa8e89d500939e04aa229/opencv_python-4.13.0.92-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:620d602b8f7d8b8dab5f4b99c6eb353e78d3fb8b0f53db1bd258bb1aa001c1d5", size = 72927042, upload-time = "2026-02-05T06:59:23.389Z" }, - { url = "https://files.pythonhosted.org/packages/fb/17/de5458312bcb07ddf434d7bfcb24bb52c59635ad58c6e7c751b48949b009/opencv_python-4.13.0.92-cp37-abi3-win32.whl", hash = "sha256:372fe164a3148ac1ca51e5f3ad0541a4a276452273f503441d718fab9c5e5f59", size = 30932638, upload-time = "2026-02-05T07:02:14.98Z" }, - { url = "https://files.pythonhosted.org/packages/e9/a5/1be1516390333ff9be3a9cb648c9f33df79d5096e5884b5df71a588af463/opencv_python-4.13.0.92-cp37-abi3-win_amd64.whl", hash = "sha256:423d934c9fafb91aad38edf26efb46da91ffbc05f3f59c4b0c72e699720706f5", size = 40212062, upload-time = "2026-02-05T07:02:12.724Z" }, -] - [[package]] name = "opencv-python-headless" version = "4.13.0.92" @@ -3236,11 +3216,11 @@ wheels = [ [[package]] name = "setuptools" -version = "82.0.0" +version = "83.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/f3/748f4d6f65d1756b9ae577f329c951cda23fb900e4de9f70900ced962085/setuptools-82.0.0.tar.gz", hash = "sha256:22e0a2d69474c6ae4feb01951cb69d515ed23728cf96d05513d36e42b62b37cb", size = 1144893, upload-time = "2026-02-08T15:08:40.206Z" } +sdist = { url = "https://files.pythonhosted.org/packages/34/26/f5d29e25ffdb535afef2d35cdb55b325298f96debd670da4c325e08d70f4/setuptools-83.0.0.tar.gz", hash = "sha256:025bccbbf0fa05b6192bc64ae1e7b16e001fd6d6d4d5de03c97b1c1ade523bef", size = 1154254, upload-time = "2026-07-04T15:31:22.699Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e1/c6/76dc613121b793286a3f91621d7b75a2b493e0390ddca50f11993eadf192/setuptools-82.0.0-py3-none-any.whl", hash = "sha256:70b18734b607bd1da571d097d236cfcfacaf01de45717d59e6e04b96877532e0", size = 1003468, upload-time = "2026-02-08T15:08:38.723Z" }, + { url = "https://files.pythonhosted.org/packages/5d/40/e1e72872c6354b306daef1703549e8e83b4d43cfea356311bf722a043752/setuptools-83.0.0-py3-none-any.whl", hash = "sha256:29b23c360f22f414dc7336bb39178cc7bcbf6021ed2733cde173f09dba19abb3", size = 1008090, upload-time = "2026-07-04T15:31:20.885Z" }, ] [[package]]