From a236123e4ff706832d2ef1cc4c5f07200e2db397 Mon Sep 17 00:00:00 2001 From: nendo Date: Sat, 31 Jan 2026 21:57:22 +0900 Subject: [PATCH] feat(saves): add slot-based save sync with content hash deduplication - Add device registration and save synchronization - Implement slot-based save organization with datetime tagging - Add conflict detection for multi-device sync scenarios - Add content hash computation for save deduplication - Support ZIP inner-file hashing for consistent deduplication - Add confirm_download endpoint for sync state management - Add overwrite parameter to bypass conflict checks --- backend/alembic/versions/0068_save_sync.py | 14 +- backend/endpoints/responses/assets.py | 14 +- backend/endpoints/saves.py | 283 ++-- backend/handler/database/saves_handler.py | 49 +- backend/handler/scan_handler.py | 45 +- backend/models/assets.py | 3 +- backend/tests/endpoints/test_saves.py | 1329 ++++++++++++++++- .../handler/database/test_saves_handler.py | 265 ++++ 8 files changed, 1868 insertions(+), 134 deletions(-) diff --git a/backend/alembic/versions/0068_save_sync.py b/backend/alembic/versions/0068_save_sync.py index 33bb411d2..863fbc107 100644 --- a/backend/alembic/versions/0068_save_sync.py +++ b/backend/alembic/versions/0068_save_sync.py @@ -74,22 +74,28 @@ def upgrade(): ) with op.batch_alter_table("saves", schema=None) as batch_op: - batch_op.add_column(sa.Column("save_name", sa.String(255), nullable=True)) + batch_op.add_column(sa.Column("slot", sa.String(255), nullable=True)) + batch_op.add_column(sa.Column("content_hash", sa.String(32), nullable=True)) op.create_index("ix_devices_user_id", "devices", ["user_id"]) op.create_index("ix_devices_last_seen", "devices", ["last_seen"]) op.create_index("ix_device_save_sync_save_id", "device_save_sync", ["save_id"]) - op.create_index("ix_saves_save_name", "saves", ["save_name"]) + op.create_index("ix_saves_slot", "saves", ["slot"]) + op.create_index( + "ix_saves_rom_user_hash", "saves", ["rom_id", "user_id", "content_hash"] + ) def downgrade(): - op.drop_index("ix_saves_save_name", "saves") + op.drop_index("ix_saves_rom_user_hash", "saves") + op.drop_index("ix_saves_slot", "saves") op.drop_index("ix_device_save_sync_save_id", "device_save_sync") op.drop_index("ix_devices_last_seen", "devices") op.drop_index("ix_devices_user_id", "devices") with op.batch_alter_table("saves", schema=None) as batch_op: - batch_op.drop_column("save_name") + batch_op.drop_column("content_hash") + batch_op.drop_column("slot") op.drop_table("device_save_sync") op.drop_table("devices") diff --git a/backend/endpoints/responses/assets.py b/backend/endpoints/responses/assets.py index 0b7d5cc3d..99b9b3697 100644 --- a/backend/endpoints/responses/assets.py +++ b/backend/endpoints/responses/assets.py @@ -32,11 +32,23 @@ class ScreenshotSchema(BaseAsset): class SaveSchema(BaseAsset): emulator: str | None - save_name: str | None = None + slot: str | None = None + content_hash: str | None = None screenshot: ScreenshotSchema | None device_syncs: list[DeviceSyncSchema] = [] +class SlotSummarySchema(BaseModel): + slot: str | None + count: int + latest: SaveSchema + + +class SaveSummarySchema(BaseModel): + total_count: int + slots: list[SlotSummarySchema] + + class StateSchema(BaseAsset): emulator: str | None screenshot: ScreenshotSchema | None diff --git a/backend/endpoints/saves.py b/backend/endpoints/saves.py index 4c1c2dbc9..03337b3d8 100644 --- a/backend/endpoints/saves.py +++ b/backend/endpoints/saves.py @@ -1,3 +1,5 @@ +import os +import re from datetime import datetime, timezone from typing import Annotated @@ -5,7 +7,7 @@ from fastapi import Body, HTTPException, Request, UploadFile, status from fastapi.responses import FileResponse from decorators.auth import protected_route -from endpoints.responses.assets import SaveSchema +from endpoints.responses.assets import SaveSchema, SaveSummarySchema, SlotSummarySchema from endpoints.responses.device import DeviceSyncSchema from exceptions.endpoint_exceptions import RomNotFoundInDatabaseException from handler.auth.constants import Scope @@ -41,14 +43,21 @@ def _build_save_schema( device_syncs: list[DeviceSyncSchema] = [] if device: - last_synced = sync.last_synced_at if sync else save.updated_at - is_current = _to_utc(last_synced) >= _to_utc(save.updated_at) + if sync: + is_current = _to_utc(sync.last_synced_at) >= _to_utc(save.updated_at) + last_synced = sync.last_synced_at + is_untracked = sync.is_untracked + else: + is_current = False + last_synced = save.updated_at + is_untracked = False + device_syncs.append( DeviceSyncSchema( device_id=device.id, device_name=device.name, last_synced_at=last_synced, - is_untracked=sync.is_untracked if sync else False, + is_untracked=is_untracked, is_current=is_current, ) ) @@ -62,6 +71,40 @@ def _build_save_schema( return SaveSchema.model_validate(save_data) +DATETIME_TAG_PATTERN = re.compile(r" \[\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}\]") + + +def _apply_datetime_tag(filename: str) -> str: + name, ext = os.path.splitext(filename) + timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d_%H-%M-%S") + + if DATETIME_TAG_PATTERN.search(name): + name = DATETIME_TAG_PATTERN.sub("", name) + + return f"{name} [{timestamp}]{ext}" + + +def _resolve_device( + device_id: str | None, + user_id: int, + scopes: set[str] | None = None, + required_scope: Scope | None = None, +) -> Device | None: + if not device_id: + return None + + if required_scope and scopes and required_scope not in scopes: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden") + + device = db_device_handler.get_device(device_id=device_id, user_id=user_id) + if not device: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Device with ID {device_id} not found", + ) + return device + + router = APIRouter( prefix="/saves", tags=["saves"], @@ -73,23 +116,16 @@ async def add_save( request: Request, rom_id: int, emulator: str | None = None, - save_name: str | None = None, + slot: str | None = None, device_id: str | None = None, overwrite: bool = False, + autocleanup: bool = False, + autocleanup_limit: int = 10, ) -> SaveSchema: - if device_id and Scope.DEVICES_WRITE not in request.auth.scopes: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden") - - device = None - if device_id: - device = db_device_handler.get_device( - device_id=device_id, user_id=request.user.id - ) - if not device: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Device with ID {device_id} not found", - ) + """Upload a save file for a ROM.""" + device = _resolve_device( + device_id, request.user.id, request.auth.scopes, Scope.DEVICES_WRITE + ) data = await request.form() @@ -111,28 +147,45 @@ async def add_save( status_code=status.HTTP_400_BAD_REQUEST, detail="Save file has no filename" ) + actual_filename = saveFile.filename + if slot: + actual_filename = _apply_datetime_tag(saveFile.filename) + db_save = db_save_handler.get_save_by_filename( - user_id=request.user.id, rom_id=rom.id, file_name=saveFile.filename + user_id=request.user.id, rom_id=rom.id, file_name=actual_filename ) - if device and db_save and not overwrite: + if device and slot and not overwrite: + slot_saves = db_save_handler.get_saves( + user_id=request.user.id, + rom_id=rom.id, + slot=slot, + order_by_updated_at_desc=True, + ) + if slot_saves: + latest_in_slot = slot_saves[0] + sync = db_device_save_sync_handler.get_sync( + device_id=device.id, save_id=latest_in_slot.id + ) + if not sync or _to_utc(sync.last_synced_at) < _to_utc( + latest_in_slot.updated_at + ): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Slot has a newer save since your last sync", + ) + elif device and db_save and not overwrite: sync = db_device_save_sync_handler.get_sync( device_id=device.id, save_id=db_save.id ) - if sync and sync.last_synced_at < db_save.updated_at: + if sync and _to_utc(sync.last_synced_at) < _to_utc(db_save.updated_at): raise HTTPException( status_code=status.HTTP_409_CONFLICT, - detail={ - "error": "conflict", - "message": "Save has been updated since last sync", - "save_id": db_save.id, - "current_save_time": db_save.updated_at.isoformat(), - "device_sync_time": sync.last_synced_at.isoformat(), - }, + detail="Save has been updated since your last sync", ) log.info( - f"Uploading save {hl(saveFile.filename)} for {hl(str(rom.name), color=BLUE)}" + f"Uploading save {hl(actual_filename)} for {hl(str(rom.name), color=BLUE)}" ) saves_path = fs_asset_handler.build_saves_file_path( @@ -142,29 +195,49 @@ async def add_save( emulator=emulator, ) - await fs_asset_handler.write_file(file=saveFile, path=saves_path) + await fs_asset_handler.write_file( + file=saveFile, path=saves_path, filename=actual_filename + ) scanned_save = await scan_save( - file_name=saveFile.filename, + file_name=actual_filename, user=request.user, platform_fs_slug=rom.platform.fs_slug, rom_id=rom_id, emulator=emulator, ) - if db_save: - db_save = db_save_handler.update_save( - db_save.id, - { - "file_size_bytes": scanned_save.file_size_bytes, - "save_name": save_name or db_save.save_name, - }, + if slot and scanned_save.content_hash and not overwrite: + existing_by_hash = db_save_handler.get_save_by_content_hash( + user_id=request.user.id, + rom_id=rom.id, + content_hash=scanned_save.content_hash, ) + if existing_by_hash: + try: + await fs_asset_handler.remove_file(f"{saves_path}/{actual_filename}") + except FileNotFoundError: + pass + sync = None + if device: + sync = db_device_save_sync_handler.get_sync( + device_id=device.id, save_id=existing_by_hash.id + ) + return _build_save_schema(existing_by_hash, device, sync) + + if db_save: + update_data: dict = { + "file_size_bytes": scanned_save.file_size_bytes, + "content_hash": scanned_save.content_hash, + } + if slot is not None: + update_data["slot"] = slot + db_save = db_save_handler.update_save(db_save.id, update_data) else: scanned_save.rom_id = rom.id scanned_save.user_id = request.user.id scanned_save.emulator = emulator - scanned_save.save_name = save_name + scanned_save.slot = slot db_save = db_save_handler.add_save(save=scanned_save) if device: @@ -173,6 +246,21 @@ async def add_save( ) db_device_handler.update_last_seen(device_id=device.id, user_id=request.user.id) + if slot and autocleanup: + slot_saves = db_save_handler.get_saves( + user_id=request.user.id, + rom_id=rom.id, + slot=slot, + order_by_updated_at_desc=True, + ) + if len(slot_saves) > autocleanup_limit: + for old_save in slot_saves[autocleanup_limit:]: + db_save_handler.delete_save(old_save.id) + try: + await fs_asset_handler.remove_file(old_save.full_path) + except FileNotFoundError: + log.warning(f"Could not delete old save file: {old_save.full_path}") + screenshotFile: UploadFile | None = data.get("screenshotFile", None) # type: ignore if screenshotFile and screenshotFile.filename: screenshots_path = fs_asset_handler.build_screenshots_file_path( @@ -223,23 +311,15 @@ def get_saves( rom_id: int | None = None, platform_id: int | None = None, device_id: str | None = None, + slot: str | None = None, ) -> list[SaveSchema]: - if device_id and Scope.DEVICES_READ not in request.auth.scopes: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden") - - device = None - if device_id: - device = db_device_handler.get_device( - device_id=device_id, user_id=request.user.id - ) - if not device: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Device with ID {device_id} not found", - ) + """Retrieve saves for the current user.""" + device = _resolve_device( + device_id, request.user.id, request.auth.scopes, Scope.DEVICES_READ + ) saves = db_save_handler.get_saves( - user_id=request.user.id, rom_id=rom_id, platform_id=platform_id + user_id=request.user.id, rom_id=rom_id, platform_id=platform_id, slot=slot ) if not device: @@ -256,17 +336,8 @@ def get_saves( @protected_route(router.get, "/identifiers", [Scope.ASSETS_READ]) -def get_save_identifiers( - request: Request, -) -> list[int]: - """Get save identifiers endpoint - - Args: - request (Request): Fastapi Request object - - Returns: - list[int]: List of save IDs - """ +def get_save_identifiers(request: Request) -> list[int]: + """Retrieve save identifiers.""" saves = db_save_handler.get_saves( user_id=request.user.id, only_fields=[Save.id], @@ -275,21 +346,31 @@ def get_save_identifiers( return [save.id for save in saves] +@protected_route(router.get, "/summary", [Scope.ASSETS_READ]) +def get_saves_summary(request: Request, rom_id: int) -> SaveSummarySchema: + """Retrieve saves summary grouped by slot.""" + summary_data = db_save_handler.get_saves_summary( + user_id=request.user.id, rom_id=rom_id + ) + + slots = [ + SlotSummarySchema( + slot=slot_data["slot"], + count=slot_data["count"], + latest=_build_save_schema(slot_data["latest"]), + ) + for slot_data in summary_data["slots"] + ] + + return SaveSummarySchema(total_count=summary_data["total_count"], slots=slots) + + @protected_route(router.get, "/{id}", [Scope.ASSETS_READ]) def get_save(request: Request, id: int, device_id: str | None = None) -> SaveSchema: - if device_id and Scope.DEVICES_READ not in request.auth.scopes: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden") - - device = None - if device_id: - device = db_device_handler.get_device( - device_id=device_id, user_id=request.user.id - ) - if not device: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Device with ID {device_id} not found", - ) + """Retrieve a save by ID.""" + device = _resolve_device( + device_id, request.user.id, request.auth.scopes, Scope.DEVICES_READ + ) save = db_save_handler.get_save(user_id=request.user.id, id=id) if not save: @@ -313,19 +394,10 @@ def download_save( device_id: str | None = None, optimistic: bool = True, ) -> FileResponse: - if device_id and Scope.DEVICES_READ not in request.auth.scopes: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden") - - device = None - if device_id: - device = db_device_handler.get_device( - device_id=device_id, user_id=request.user.id - ) - if not device: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Device with ID {device_id} not found", - ) + """Download a save file.""" + device = _resolve_device( + device_id, request.user.id, request.auth.scopes, Scope.DEVICES_READ + ) save = db_save_handler.get_save(user_id=request.user.id, id=id) if not save: @@ -365,7 +437,7 @@ def confirm_download( id: int, device_id: str = Body(..., embed=True), ) -> SaveSchema: - + """Confirm a save was downloaded successfully.""" save = db_save_handler.get_save(user_id=request.user.id, id=id) if not save: raise HTTPException( @@ -373,12 +445,8 @@ def confirm_download( detail=f"Save with ID {id} not found", ) - device = db_device_handler.get_device(device_id=device_id, user_id=request.user.id) - if not device: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Device with ID {device_id} not found", - ) + device = _resolve_device(device_id, request.user.id) + assert device is not None sync = db_device_save_sync_handler.upsert_sync( device_id=device_id, @@ -392,6 +460,7 @@ def confirm_download( @protected_route(router.put, "/{id}", [Scope.ASSETS_WRITE]) async def update_save(request: Request, id: int) -> SaveSchema: + """Update a save file.""" data = await request.form() db_save = db_save_handler.get_save(user_id=request.user.id, id=id) @@ -514,7 +583,7 @@ def track_save( id: int, device_id: str = Body(..., embed=True), ) -> SaveSchema: - + """Re-enable sync tracking for a save on a device.""" save = db_save_handler.get_save(user_id=request.user.id, id=id) if not save: raise HTTPException( @@ -522,12 +591,8 @@ def track_save( detail=f"Save with ID {id} not found", ) - device = db_device_handler.get_device(device_id=device_id, user_id=request.user.id) - if not device: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Device with ID {device_id} not found", - ) + device = _resolve_device(device_id, request.user.id) + assert device is not None sync = db_device_save_sync_handler.set_untracked( device_id=device_id, save_id=id, untracked=False @@ -542,7 +607,7 @@ def untrack_save( id: int, device_id: str = Body(..., embed=True), ) -> SaveSchema: - + """Disable sync tracking for a save on a device.""" save = db_save_handler.get_save(user_id=request.user.id, id=id) if not save: raise HTTPException( @@ -550,12 +615,8 @@ def untrack_save( detail=f"Save with ID {id} not found", ) - device = db_device_handler.get_device(device_id=device_id, user_id=request.user.id) - if not device: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Device with ID {device_id} not found", - ) + device = _resolve_device(device_id, request.user.id) + assert device is not None sync = db_device_save_sync_handler.set_untracked( device_id=device_id, save_id=id, untracked=True diff --git a/backend/handler/database/saves_handler.py b/backend/handler/database/saves_handler.py index 4a06ffeb9..9b4996be7 100644 --- a/backend/handler/database/saves_handler.py +++ b/backend/handler/database/saves_handler.py @@ -1,6 +1,6 @@ from collections.abc import Sequence -from sqlalchemy import and_, delete, select, update +from sqlalchemy import and_, delete, desc, select, update from sqlalchemy.orm import QueryableAttribute, Session, load_only from decorators.database import begin_session @@ -42,12 +42,28 @@ class DBSavesHandler(DBBaseHandler): .limit(1) ).first() + @begin_session + def get_save_by_content_hash( + self, + user_id: int, + rom_id: int, + content_hash: str, + session: Session = None, # type: ignore + ) -> Save | None: + return session.scalar( + select(Save) + .filter_by(rom_id=rom_id, user_id=user_id, content_hash=content_hash) + .limit(1) + ) + @begin_session def get_saves( self, user_id: int, rom_id: int | None = None, platform_id: int | None = None, + slot: str | None = None, + order_by_updated_at_desc: bool = False, only_fields: Sequence[QueryableAttribute] | None = None, session: Session = None, # type: ignore ) -> Sequence[Save]: @@ -61,6 +77,12 @@ class DBSavesHandler(DBBaseHandler): Rom.platform_id == platform_id ) + if slot is not None: + query = query.filter(Save.slot == slot) + + if order_by_updated_at_desc: + query = query.order_by(desc(Save.updated_at)) + if only_fields: query = query.options(load_only(*only_fields)) @@ -125,3 +147,28 @@ class DBSavesHandler(DBBaseHandler): ) return missing_saves + + @begin_session + def get_saves_summary( + self, + user_id: int, + rom_id: int, + session: Session = None, # type: ignore + ) -> dict: + saves = session.scalars( + select(Save) + .filter_by(user_id=user_id, rom_id=rom_id) + .order_by(desc(Save.updated_at)) + ).all() + + slots_data: dict[str | None, dict] = {} + for save in saves: + slot_key = save.slot + if slot_key not in slots_data: + slots_data[slot_key] = {"slot": slot_key, "count": 0, "latest": save} + slots_data[slot_key]["count"] += 1 + + return { + "total_count": len(saves), + "slots": list(slots_data.values()), + } diff --git a/backend/handler/scan_handler.py b/backend/handler/scan_handler.py index 6e1c7b26b..2ac170ded 100644 --- a/backend/handler/scan_handler.py +++ b/backend/handler/scan_handler.py @@ -1,9 +1,12 @@ import asyncio import enum +import hashlib +import zipfile from typing import Any import socketio # type: ignore +from config import ASSETS_BASE_PATH from config.config_manager import config_manager as cm from endpoints.responses.rom import SimpleRomSchema from handler.database import db_platform_handler, db_rom_handler @@ -817,11 +820,41 @@ async def scan_rom( return Rom(**rom_attrs) -async def _scan_asset(file_name: str, asset_path: str): +def _compute_file_hash(file_path: str) -> str: + hash_obj = hashlib.md5(usedforsecurity=False) + with open(file_path, "rb") as f: + for chunk in iter(lambda: f.read(8192), b""): + hash_obj.update(chunk) + return hash_obj.hexdigest() + + +def _compute_zip_hash(zip_path: str) -> str: + with zipfile.ZipFile(zip_path, "r") as zf: + file_hashes = [] + for name in sorted(zf.namelist()): + if not name.endswith("/"): + content = zf.read(name) + file_hash = hashlib.md5(content, usedforsecurity=False).hexdigest() + file_hashes.append(f"{name}:{file_hash}") + combined = "\n".join(file_hashes) + return hashlib.md5(combined.encode(), usedforsecurity=False).hexdigest() + + +def _compute_content_hash(file_path: str) -> str | None: + try: + if zipfile.is_zipfile(file_path): + return _compute_zip_hash(file_path) + return _compute_file_hash(file_path) + except Exception as e: + log.debug(f"Failed to compute content hash for {file_path}: {e}") + return None + + +async def _scan_asset(file_name: str, asset_path: str, compute_hash: bool = False): file_path = f"{asset_path}/{file_name}" file_size = await fs_asset_handler.get_file_size(file_path) - return { + result = { "file_path": asset_path, "file_name": file_name, "file_name_no_tags": fs_asset_handler.get_file_name_with_no_tags(file_name), @@ -830,6 +863,12 @@ async def _scan_asset(file_name: str, asset_path: str): "file_size_bytes": file_size, } + if compute_hash: + absolute_path = f"{ASSETS_BASE_PATH}/{file_path}" + result["content_hash"] = _compute_content_hash(absolute_path) + + return result + async def scan_save( file_name: str, @@ -841,7 +880,7 @@ async def scan_save( saves_path = fs_asset_handler.build_saves_file_path( user=user, platform_fs_slug=platform_fs_slug, rom_id=rom_id, emulator=emulator ) - scanned_asset = await _scan_asset(file_name, saves_path) + scanned_asset = await _scan_asset(file_name, saves_path, compute_hash=True) return Save(**scanned_asset) diff --git a/backend/models/assets.py b/backend/models/assets.py index 1cc50c2bd..4311c069b 100644 --- a/backend/models/assets.py +++ b/backend/models/assets.py @@ -55,7 +55,8 @@ class Save(RomAsset): __table_args__ = {"extend_existing": True} emulator: Mapped[str | None] = mapped_column(String(length=50)) - save_name: Mapped[str | None] = mapped_column(String(length=255)) + slot: Mapped[str | None] = mapped_column(String(length=255)) + content_hash: Mapped[str | None] = mapped_column(String(length=32)) rom: Mapped[Rom] = relationship(lazy="joined", back_populates="saves") user: Mapped[User] = relationship(lazy="joined", back_populates="saves") diff --git a/backend/tests/endpoints/test_saves.py b/backend/tests/endpoints/test_saves.py index 22543131b..123b1774a 100644 --- a/backend/tests/endpoints/test_saves.py +++ b/backend/tests/endpoints/test_saves.py @@ -87,6 +87,7 @@ class TestSaveSyncEndpoints: assert len(data[0]["device_syncs"]) == 1 assert data[0]["device_syncs"][0]["device_id"] == device.id assert data[0]["device_syncs"][0]["is_untracked"] is False + assert data[0]["device_syncs"][0]["is_current"] is False def test_get_saves_with_device_id_synced( self, client, access_token: str, save: Save, device: Device @@ -102,6 +103,7 @@ class TestSaveSyncEndpoints: data = response.json() assert len(data[0]["device_syncs"]) == 1 assert data[0]["device_syncs"][0]["is_untracked"] is False + assert data[0]["device_syncs"][0]["is_current"] is True def test_get_single_save_with_device_id( self, client, access_token: str, save: Save, device: Device @@ -351,7 +353,7 @@ class TestSaveUploadWithSync: "endpoints.saves.fs_asset_handler.write_file", new_callable=mock.AsyncMock ) @mock.patch("endpoints.saves.scan_save", new_callable=mock.AsyncMock) - def test_upload_save_with_save_name( + def test_upload_save_with_slot( self, mock_scan, _mock_write, @@ -370,19 +372,20 @@ class TestSaveUploadWithSync: file_size_bytes=100, rom_id=rom.id, user_id=admin_user.id, + slot="Slot 1", ) mock_scan.return_value = mock_save file_content = BytesIO(b"test save data") response = client.post( - f"/api/saves?rom_id={rom.id}&save_name=Slot%201", + f"/api/saves?rom_id={rom.id}&slot=Slot%201", files={"saveFile": ("slot1.sav", file_content, "application/octet-stream")}, headers={"Authorization": f"Bearer {access_token}"}, ) assert response.status_code == status.HTTP_200_OK data = response.json() - assert data["save_name"] == "Slot 1" + assert data["slot"] == "Slot 1" class TestSaveConflictDetection: @@ -629,8 +632,7 @@ class TestSaveConflictDetection: assert response.status_code == status.HTTP_409_CONFLICT data = response.json() - assert data["detail"]["error"] == "conflict" - assert "device_sync_time" in data["detail"] + assert "since your last sync" in data["detail"] @mock.patch( "endpoints.saves.fs_asset_handler.write_file", new_callable=mock.AsyncMock @@ -673,7 +675,7 @@ class TestSaveConflictDetection: assert response.status_code == status.HTTP_409_CONFLICT data = response.json() - assert data["detail"]["error"] == "conflict" + assert "since your last sync" in data["detail"] @mock.patch( "endpoints.saves.fs_asset_handler.write_file", new_callable=mock.AsyncMock @@ -749,14 +751,243 @@ class TestSaveConflictDetection: assert response.status_code == status.HTTP_409_CONFLICT data = response.json() - detail = data["detail"] + assert "since your last sync" in data["detail"] - assert detail["error"] == "conflict" - assert "message" in detail - assert "save_id" in detail - assert detail["save_id"] == save.id - assert "current_save_time" in detail - assert "device_sync_time" in detail + @mock.patch( + "endpoints.saves.fs_asset_handler.write_file", new_callable=mock.AsyncMock + ) + @mock.patch("endpoints.saves.scan_save", new_callable=mock.AsyncMock) + def test_out_of_sync_response_with_slot( + self, + mock_scan, + _mock_write, + client, + access_token: str, + rom: Rom, + platform: Platform, + admin_user: User, + device: Device, + ): + """Verify out_of_sync response when uploading with slot (non-destructive). + + Slot conflict detection checks if device has synced the latest save in the slot, + not by exact filename (since datetime tags make each upload unique). + """ + from datetime import datetime, timedelta, timezone + + from handler.database import db_save_handler + + existing_slot_save = Save( + file_name="existing_slot_save.sav", + file_name_no_tags="existing_slot_save", + file_name_no_ext="existing_slot_save", + file_extension="sav", + file_path=f"{platform.slug}/saves", + file_size_bytes=100, + rom_id=rom.id, + user_id=admin_user.id, + slot="TestSlot", + ) + db_slot_save = db_save_handler.add_save(existing_slot_save) + + old_sync_time = datetime.now(timezone.utc) - timedelta(hours=1) + db_device_save_sync_handler.upsert_sync( + device_id=device.id, save_id=db_slot_save.id, synced_at=old_sync_time + ) + + mock_scan.return_value = Save( + file_name="new_upload.sav", + file_name_no_tags="new_upload", + file_name_no_ext="new_upload", + file_extension="sav", + file_path=f"{platform.slug}/saves", + file_size_bytes=100, + rom_id=rom.id, + user_id=admin_user.id, + slot="TestSlot", + ) + + file_content = BytesIO(b"out of sync save") + response = client.post( + f"/api/saves?rom_id={rom.id}&device_id={device.id}&slot=TestSlot", + files={ + "saveFile": ("new_upload.sav", file_content, "application/octet-stream") + }, + headers={"Authorization": f"Bearer {access_token}"}, + ) + + assert response.status_code == status.HTTP_409_CONFLICT + data = response.json() + assert "newer save since your last sync" in data["detail"] + + @mock.patch( + "endpoints.saves.fs_asset_handler.write_file", new_callable=mock.AsyncMock + ) + @mock.patch("endpoints.saves.scan_save", new_callable=mock.AsyncMock) + def test_first_upload_to_slot_succeeds( + self, + mock_scan, + _mock_write, + client, + access_token: str, + rom: Rom, + platform: Platform, + admin_user: User, + device: Device, + ): + """First upload to a slot (no existing saves) should succeed.""" + mock_scan.return_value = Save( + file_name="first_in_slot.sav", + file_name_no_tags="first_in_slot", + file_name_no_ext="first_in_slot", + file_extension="sav", + file_path=f"{platform.slug}/saves", + file_size_bytes=100, + rom_id=rom.id, + user_id=admin_user.id, + slot="BrandNewSlot", + ) + + file_content = BytesIO(b"first save in slot") + response = client.post( + f"/api/saves?rom_id={rom.id}&device_id={device.id}&slot=BrandNewSlot", + files={ + "saveFile": ( + "first_in_slot.sav", + file_content, + "application/octet-stream", + ) + }, + headers={"Authorization": f"Bearer {access_token}"}, + ) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data["slot"] == "BrandNewSlot" + + @mock.patch( + "endpoints.saves.fs_asset_handler.write_file", new_callable=mock.AsyncMock + ) + @mock.patch("endpoints.saves.scan_save", new_callable=mock.AsyncMock) + def test_upload_to_slot_with_current_sync_succeeds( + self, + mock_scan, + _mock_write, + client, + access_token: str, + rom: Rom, + platform: Platform, + admin_user: User, + device: Device, + ): + """Upload to slot succeeds when device has synced the latest save.""" + from handler.database import db_save_handler + + existing_slot_save = Save( + file_name="synced_save.sav", + file_name_no_tags="synced_save", + file_name_no_ext="synced_save", + file_extension="sav", + file_path=f"{platform.slug}/saves", + file_size_bytes=100, + rom_id=rom.id, + user_id=admin_user.id, + slot="SyncedSlot", + ) + db_slot_save = db_save_handler.add_save(existing_slot_save) + + db_device_save_sync_handler.upsert_sync( + device_id=device.id, + save_id=db_slot_save.id, + synced_at=db_slot_save.updated_at, + ) + + mock_scan.return_value = Save( + file_name="next_upload.sav", + file_name_no_tags="next_upload", + file_name_no_ext="next_upload", + file_extension="sav", + file_path=f"{platform.slug}/saves", + file_size_bytes=100, + rom_id=rom.id, + user_id=admin_user.id, + slot="SyncedSlot", + ) + + file_content = BytesIO(b"next save in slot") + response = client.post( + f"/api/saves?rom_id={rom.id}&device_id={device.id}&slot=SyncedSlot", + files={ + "saveFile": ( + "next_upload.sav", + file_content, + "application/octet-stream", + ) + }, + headers={"Authorization": f"Bearer {access_token}"}, + ) + + assert response.status_code == status.HTTP_200_OK + + @mock.patch( + "endpoints.saves.fs_asset_handler.write_file", new_callable=mock.AsyncMock + ) + @mock.patch("endpoints.saves.scan_save", new_callable=mock.AsyncMock) + def test_out_of_sync_with_no_prior_device_sync( + self, + mock_scan, + _mock_write, + client, + access_token: str, + rom: Rom, + platform: Platform, + admin_user: User, + device: Device, + ): + """Device that never synced any save in slot should get out_of_sync.""" + from handler.database import db_save_handler + + existing_slot_save = Save( + file_name="never_synced.sav", + file_name_no_tags="never_synced", + file_name_no_ext="never_synced", + file_extension="sav", + file_path=f"{platform.slug}/saves", + file_size_bytes=100, + rom_id=rom.id, + user_id=admin_user.id, + slot="NeverSyncedSlot", + ) + db_save_handler.add_save(existing_slot_save) + + mock_scan.return_value = Save( + file_name="upload_attempt.sav", + file_name_no_tags="upload_attempt", + file_name_no_ext="upload_attempt", + file_extension="sav", + file_path=f"{platform.slug}/saves", + file_size_bytes=100, + rom_id=rom.id, + user_id=admin_user.id, + slot="NeverSyncedSlot", + ) + + file_content = BytesIO(b"upload without prior sync") + response = client.post( + f"/api/saves?rom_id={rom.id}&device_id={device.id}&slot=NeverSyncedSlot", + files={ + "saveFile": ( + "upload_attempt.sav", + file_content, + "application/octet-stream", + ) + }, + headers={"Authorization": f"Bearer {access_token}"}, + ) + + assert response.status_code == status.HTTP_409_CONFLICT + data = response.json() + assert "newer save since your last sync" in data["detail"] class TestDeviceScopeEnforcement: @@ -832,3 +1063,1075 @@ class TestDeviceScopeEnforcement: headers={"Authorization": f"Bearer {token_without_device_scopes}"}, ) assert response.status_code == status.HTTP_403_FORBIDDEN + + +class TestSlotFiltering: + @pytest.fixture + def saves_with_slots( + self, admin_user: User, rom: Rom, platform: Platform + ) -> list[Save]: + from handler.database import db_save_handler + + saves = [] + for i, slot in enumerate([None, "Slot 1", "Slot 1", "Slot 2"]): + save = Save( + file_name=f"save_{i}.sav", + file_name_no_tags=f"save_{i}", + file_name_no_ext=f"save_{i}", + file_extension="sav", + file_path=f"{platform.slug}/saves", + file_size_bytes=100 + i, + rom_id=rom.id, + user_id=admin_user.id, + slot=slot, + ) + saves.append(db_save_handler.add_save(save)) + return saves + + def test_get_saves_without_slot_filter( + self, client, access_token: str, saves_with_slots: list[Save] + ): + response = client.get( + "/api/saves", + headers={"Authorization": f"Bearer {access_token}"}, + ) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert len(data) >= 4 + for item in data: + assert "slot" in item + assert "id" in item + assert "rom_id" in item + + def test_get_saves_with_slot_filter( + self, client, access_token: str, rom: Rom, saves_with_slots: list[Save] + ): + response = client.get( + f"/api/saves?rom_id={rom.id}&slot=Slot%201", + headers={"Authorization": f"Bearer {access_token}"}, + ) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert len(data) == 2 + for item in data: + assert item["slot"] == "Slot 1" + + def test_get_saves_with_nonexistent_slot( + self, client, access_token: str, rom: Rom, saves_with_slots: list[Save] + ): + response = client.get( + f"/api/saves?rom_id={rom.id}&slot=NonexistentSlot", + headers={"Authorization": f"Bearer {access_token}"}, + ) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert len(data) == 0 + + +class TestDatetimeTagging: + @mock.patch( + "endpoints.saves.fs_asset_handler.write_file", new_callable=mock.AsyncMock + ) + @mock.patch("endpoints.saves.scan_save", new_callable=mock.AsyncMock) + def test_upload_with_slot_applies_datetime_tag( + self, + mock_scan, + mock_write, + client, + access_token: str, + rom: Rom, + platform: Platform, + admin_user: User, + ): + import re + + mock_save = Save( + file_name="test [2026-01-31_12-00-00].sav", + file_name_no_tags="test", + file_name_no_ext="test [2026-01-31_12-00-00]", + file_extension="sav", + file_path=f"{platform.slug}/saves", + file_size_bytes=100, + rom_id=rom.id, + user_id=admin_user.id, + slot="main", + ) + mock_scan.return_value = mock_save + + file_content = BytesIO(b"test save data") + response = client.post( + f"/api/saves?rom_id={rom.id}&slot=main", + files={"saveFile": ("test.sav", file_content, "application/octet-stream")}, + headers={"Authorization": f"Bearer {access_token}"}, + ) + + assert response.status_code == status.HTTP_200_OK + mock_write.assert_called_once() + call_args = mock_write.call_args + written_filename = call_args[1].get("filename") or call_args[0][2] + assert re.search(r" \[\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}\]", written_filename) + + @mock.patch( + "endpoints.saves.fs_asset_handler.write_file", new_callable=mock.AsyncMock + ) + @mock.patch("endpoints.saves.scan_save", new_callable=mock.AsyncMock) + def test_upload_without_slot_no_datetime_tag( + self, + mock_scan, + mock_write, + client, + access_token: str, + rom: Rom, + platform: Platform, + admin_user: User, + ): + mock_save = Save( + file_name="test.sav", + file_name_no_tags="test", + file_name_no_ext="test", + file_extension="sav", + file_path=f"{platform.slug}/saves", + file_size_bytes=100, + rom_id=rom.id, + user_id=admin_user.id, + ) + mock_scan.return_value = mock_save + + file_content = BytesIO(b"test save data") + response = client.post( + f"/api/saves?rom_id={rom.id}", + files={"saveFile": ("test.sav", file_content, "application/octet-stream")}, + headers={"Authorization": f"Bearer {access_token}"}, + ) + + assert response.status_code == status.HTTP_200_OK + mock_write.assert_called_once() + call_args = mock_write.call_args + written_filename = call_args[1].get("filename") or call_args[0][2] + assert written_filename == "test.sav" + + @mock.patch( + "endpoints.saves.fs_asset_handler.write_file", new_callable=mock.AsyncMock + ) + @mock.patch("endpoints.saves.scan_save", new_callable=mock.AsyncMock) + def test_upload_with_existing_datetime_tag_replaces_it( + self, + mock_scan, + mock_write, + client, + access_token: str, + rom: Rom, + platform: Platform, + admin_user: User, + ): + import re + + mock_save = Save( + file_name="test [2026-01-31_12-00-00].sav", + file_name_no_tags="test", + file_name_no_ext="test [2026-01-31_12-00-00]", + file_extension="sav", + file_path=f"{platform.slug}/saves", + file_size_bytes=100, + rom_id=rom.id, + user_id=admin_user.id, + slot="main", + ) + mock_scan.return_value = mock_save + + file_content = BytesIO(b"test save data") + response = client.post( + f"/api/saves?rom_id={rom.id}&slot=main", + files={ + "saveFile": ( + "test [2020-01-01_00-00-00].sav", + file_content, + "application/octet-stream", + ) + }, + headers={"Authorization": f"Bearer {access_token}"}, + ) + + assert response.status_code == status.HTTP_200_OK + mock_write.assert_called_once() + call_args = mock_write.call_args + written_filename = call_args[1].get("filename") or call_args[0][2] + datetime_matches = re.findall( + r"\[\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}\]", written_filename + ) + assert len(datetime_matches) == 1 + assert "2020-01-01" not in written_filename + + +class TestAutocleanup: + @pytest.fixture + def slot_saves(self, admin_user: User, rom: Rom, platform: Platform) -> list[Save]: + from datetime import datetime, timedelta, timezone + + from handler.database import db_save_handler + + saves = [] + base_time = datetime.now(timezone.utc) - timedelta(hours=20) + for i in range(15): + save = Save( + file_name=f"autosave_{i}.sav", + file_name_no_tags=f"autosave_{i}", + file_name_no_ext=f"autosave_{i}", + file_extension="sav", + file_path=f"{platform.slug}/saves", + file_size_bytes=100 + i, + rom_id=rom.id, + user_id=admin_user.id, + slot="autosave", + ) + created = db_save_handler.add_save(save) + db_save_handler.update_save( + created.id, {"updated_at": base_time + timedelta(hours=i)} + ) + saves.append(created) + return saves + + @mock.patch( + "endpoints.saves.fs_asset_handler.write_file", new_callable=mock.AsyncMock + ) + @mock.patch( + "endpoints.saves.fs_asset_handler.remove_file", new_callable=mock.AsyncMock + ) + @mock.patch("endpoints.saves.scan_save", new_callable=mock.AsyncMock) + def test_autocleanup_deletes_old_saves( + self, + mock_scan, + mock_remove, + mock_write, + client, + access_token: str, + rom: Rom, + platform: Platform, + admin_user: User, + slot_saves: list[Save], + ): + from handler.database import db_save_handler + + initial_saves = db_save_handler.get_saves( + user_id=admin_user.id, rom_id=rom.id, slot="autosave" + ) + assert len(initial_saves) == 15 + + mock_save = Save( + file_name="new_autosave.sav", + file_name_no_tags="new_autosave", + file_name_no_ext="new_autosave", + file_extension="sav", + file_path=f"{platform.slug}/saves", + file_size_bytes=100, + rom_id=rom.id, + user_id=admin_user.id, + slot="autosave", + ) + mock_scan.return_value = mock_save + + file_content = BytesIO(b"new save") + response = client.post( + f"/api/saves?rom_id={rom.id}&slot=autosave&autocleanup=true&autocleanup_limit=10", + files={ + "saveFile": ( + "new_autosave.sav", + file_content, + "application/octet-stream", + ) + }, + headers={"Authorization": f"Bearer {access_token}"}, + ) + + assert response.status_code == status.HTTP_200_OK + assert mock_remove.call_count == 6 + + @mock.patch( + "endpoints.saves.fs_asset_handler.write_file", new_callable=mock.AsyncMock + ) + @mock.patch( + "endpoints.saves.fs_asset_handler.remove_file", new_callable=mock.AsyncMock + ) + @mock.patch("endpoints.saves.scan_save", new_callable=mock.AsyncMock) + def test_autocleanup_disabled_by_default( + self, + mock_scan, + mock_remove, + mock_write, + client, + access_token: str, + rom: Rom, + platform: Platform, + admin_user: User, + slot_saves: list[Save], + ): + mock_save = Save( + file_name="new_save.sav", + file_name_no_tags="new_save", + file_name_no_ext="new_save", + file_extension="sav", + file_path=f"{platform.slug}/saves", + file_size_bytes=100, + rom_id=rom.id, + user_id=admin_user.id, + slot="autosave", + ) + mock_scan.return_value = mock_save + + file_content = BytesIO(b"new save") + response = client.post( + f"/api/saves?rom_id={rom.id}&slot=autosave", + files={ + "saveFile": ("new_save.sav", file_content, "application/octet-stream") + }, + headers={"Authorization": f"Bearer {access_token}"}, + ) + + assert response.status_code == status.HTTP_200_OK + mock_remove.assert_not_called() + + @mock.patch( + "endpoints.saves.fs_asset_handler.write_file", new_callable=mock.AsyncMock + ) + @mock.patch( + "endpoints.saves.fs_asset_handler.remove_file", new_callable=mock.AsyncMock + ) + @mock.patch("endpoints.saves.scan_save", new_callable=mock.AsyncMock) + def test_autocleanup_without_slot_does_nothing( + self, + mock_scan, + mock_remove, + mock_write, + client, + access_token: str, + rom: Rom, + platform: Platform, + admin_user: User, + ): + mock_save = Save( + file_name="noslotsave.sav", + file_name_no_tags="noslotsave", + file_name_no_ext="noslotsave", + file_extension="sav", + file_path=f"{platform.slug}/saves", + file_size_bytes=100, + rom_id=rom.id, + user_id=admin_user.id, + ) + mock_scan.return_value = mock_save + + file_content = BytesIO(b"no slot save") + response = client.post( + f"/api/saves?rom_id={rom.id}&autocleanup=true&autocleanup_limit=5", + files={ + "saveFile": ("noslotsave.sav", file_content, "application/octet-stream") + }, + headers={"Authorization": f"Bearer {access_token}"}, + ) + + assert response.status_code == status.HTTP_200_OK + mock_remove.assert_not_called() + + +class TestSavesSummaryEndpoint: + @pytest.fixture + def summary_saves( + self, admin_user: User, rom: Rom, platform: Platform + ) -> list[Save]: + from datetime import datetime, timedelta, timezone + + from handler.database import db_save_handler + + saves = [] + base_time = datetime.now(timezone.utc) - timedelta(hours=10) + + configs = [ + (None, 0), + (None, 1), + (None, 2), + ("Slot A", 3), + ("Slot A", 4), + ("Slot B", 5), + ] + + for slot, offset in configs: + save = Save( + file_name=f"summary_save_{offset}.sav", + file_name_no_tags=f"summary_save_{offset}", + file_name_no_ext=f"summary_save_{offset}", + file_extension="sav", + file_path=f"{platform.slug}/saves", + file_size_bytes=100 + offset, + rom_id=rom.id, + user_id=admin_user.id, + slot=slot, + ) + created = db_save_handler.add_save(save) + db_save_handler.update_save( + created.id, {"updated_at": base_time + timedelta(hours=offset)} + ) + saves.append(created) + return saves + + def test_get_saves_summary( + self, client, access_token: str, rom: Rom, summary_saves: list[Save] + ): + response = client.get( + f"/api/saves/summary?rom_id={rom.id}", + headers={"Authorization": f"Bearer {access_token}"}, + ) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + + assert "total_count" in data + assert "slots" in data + assert data["total_count"] == 6 + assert isinstance(data["slots"], list) + assert len(data["slots"]) == 3 + + slot_map = {s["slot"]: s for s in data["slots"]} + assert None in slot_map or "null" in str(slot_map.keys()) + assert "Slot A" in slot_map + assert "Slot B" in slot_map + + def test_get_saves_summary_validates_response_schema( + self, client, access_token: str, rom: Rom, summary_saves: list[Save] + ): + response = client.get( + f"/api/saves/summary?rom_id={rom.id}", + headers={"Authorization": f"Bearer {access_token}"}, + ) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + + assert isinstance(data["total_count"], int) + assert isinstance(data["slots"], list) + + for slot_info in data["slots"]: + assert "slot" in slot_info + assert "count" in slot_info + assert "latest" in slot_info + + assert isinstance(slot_info["count"], int) + assert slot_info["count"] > 0 + + latest = slot_info["latest"] + assert "id" in latest + assert "rom_id" in latest + assert "user_id" in latest + assert "file_name" in latest + assert "created_at" in latest + assert "updated_at" in latest + + def test_get_saves_summary_latest_is_most_recent( + self, client, access_token: str, rom: Rom, summary_saves: list[Save] + ): + response = client.get( + f"/api/saves/summary?rom_id={rom.id}", + headers={"Authorization": f"Bearer {access_token}"}, + ) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + + slot_a_info = next((s for s in data["slots"] if s["slot"] == "Slot A"), None) + assert slot_a_info is not None + assert slot_a_info["count"] == 2 + assert "summary_save_4" in slot_a_info["latest"]["file_name"] + + def test_get_saves_summary_requires_rom_id(self, client, access_token: str): + response = client.get( + "/api/saves/summary", + headers={"Authorization": f"Bearer {access_token}"}, + ) + + assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + data = response.json() + assert "detail" in data + assert any("rom_id" in str(err).lower() for err in data["detail"]) + + def test_get_saves_summary_empty_rom(self, client, access_token: str): + response = client.get( + "/api/saves/summary?rom_id=999999", + headers={"Authorization": f"Bearer {access_token}"}, + ) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data["total_count"] == 0 + assert data["slots"] == [] + + def test_get_saves_summary_requires_auth(self, client, rom: Rom): + response = client.get(f"/api/saves/summary?rom_id={rom.id}") + + assert response.status_code == status.HTTP_403_FORBIDDEN + + +class TestSaveDownload: + @mock.patch("endpoints.saves.fs_asset_handler.validate_path") + def test_download_save_without_device_returns_file( + self, + mock_validate_path, + client, + access_token: str, + save: Save, + tmp_path, + ): + test_file = tmp_path / "test.sav" + test_file.write_bytes(b"save file content") + mock_validate_path.return_value = test_file + + response = client.get( + f"/api/saves/{save.id}/content", + headers={"Authorization": f"Bearer {access_token}"}, + ) + + assert response.status_code == status.HTTP_200_OK + assert response.content == b"save file content" + + sync = db_device_save_sync_handler.get_sync(device_id="any", save_id=save.id) + assert sync is None + + @mock.patch("endpoints.saves.fs_asset_handler.validate_path") + def test_download_save_with_device_returns_file( + self, + mock_validate_path, + client, + access_token: str, + save: Save, + device: Device, + tmp_path, + ): + test_file = tmp_path / "test.sav" + test_file.write_bytes(b"save file content") + mock_validate_path.return_value = test_file + + response = client.get( + f"/api/saves/{save.id}/content?device_id={device.id}", + headers={"Authorization": f"Bearer {access_token}"}, + ) + + assert response.status_code == status.HTTP_200_OK + assert response.content == b"save file content" + + def test_download_save_not_found(self, client, access_token: str): + response = client.get( + "/api/saves/99999/content", + headers={"Authorization": f"Bearer {access_token}"}, + ) + + assert response.status_code == status.HTTP_404_NOT_FOUND + assert "99999" in response.json()["detail"] + + @mock.patch("endpoints.saves.fs_asset_handler.validate_path") + def test_download_save_file_missing_on_disk( + self, + mock_validate_path, + client, + access_token: str, + save: Save, + tmp_path, + ): + missing_file = tmp_path / "nonexistent.sav" + mock_validate_path.return_value = missing_file + + response = client.get( + f"/api/saves/{save.id}/content", + headers={"Authorization": f"Bearer {access_token}"}, + ) + + assert response.status_code == status.HTTP_404_NOT_FOUND + assert "not found on disk" in response.json()["detail"] + + @mock.patch("endpoints.saves.fs_asset_handler.validate_path") + def test_download_save_validate_path_raises( + self, + mock_validate_path, + client, + access_token: str, + save: Save, + ): + mock_validate_path.side_effect = ValueError("Invalid path") + + response = client.get( + f"/api/saves/{save.id}/content", + headers={"Authorization": f"Bearer {access_token}"}, + ) + + assert response.status_code == status.HTTP_404_NOT_FOUND + assert "not found" in response.json()["detail"].lower() + + @mock.patch("endpoints.saves.fs_asset_handler.validate_path") + def test_download_with_device_id_optimistic_true_updates_sync( + self, + mock_validate_path, + client, + access_token: str, + save: Save, + device: Device, + tmp_path, + ): + test_file = tmp_path / "test.sav" + test_file.write_bytes(b"save content") + mock_validate_path.return_value = test_file + + sync_before = db_device_save_sync_handler.get_sync( + device_id=device.id, save_id=save.id + ) + assert sync_before is None + + response = client.get( + f"/api/saves/{save.id}/content?device_id={device.id}", + headers={"Authorization": f"Bearer {access_token}"}, + ) + + assert response.status_code == status.HTTP_200_OK + + sync_after = db_device_save_sync_handler.get_sync( + device_id=device.id, save_id=save.id + ) + assert sync_after is not None + assert sync_after.last_synced_at.replace( + microsecond=0, tzinfo=None + ) == save.updated_at.replace(microsecond=0, tzinfo=None) + + @mock.patch("endpoints.saves.fs_asset_handler.validate_path") + def test_download_with_device_id_optimistic_false_no_sync_update( + self, + mock_validate_path, + client, + access_token: str, + save: Save, + device: Device, + tmp_path, + ): + test_file = tmp_path / "test.sav" + test_file.write_bytes(b"save content") + mock_validate_path.return_value = test_file + + response = client.get( + f"/api/saves/{save.id}/content?device_id={device.id}&optimistic=false", + headers={"Authorization": f"Bearer {access_token}"}, + ) + + assert response.status_code == status.HTTP_200_OK + + sync = db_device_save_sync_handler.get_sync( + device_id=device.id, save_id=save.id + ) + assert sync is None + + @mock.patch("endpoints.saves.fs_asset_handler.validate_path") + def test_download_with_invalid_device_id_returns_404( + self, + mock_validate_path, + client, + access_token: str, + save: Save, + tmp_path, + ): + test_file = tmp_path / "test.sav" + test_file.write_bytes(b"save content") + mock_validate_path.return_value = test_file + + response = client.get( + f"/api/saves/{save.id}/content?device_id=nonexistent-device", + headers={"Authorization": f"Bearer {access_token}"}, + ) + + assert response.status_code == status.HTTP_404_NOT_FOUND + assert "nonexistent-device" in response.json()["detail"] + + @mock.patch("endpoints.saves.fs_asset_handler.validate_path") + def test_download_without_device_scope_forbidden( + self, + mock_validate_path, + client, + token_without_device_scopes: str, + save: Save, + device: Device, + tmp_path, + ): + test_file = tmp_path / "test.sav" + test_file.write_bytes(b"save content") + mock_validate_path.return_value = test_file + + response = client.get( + f"/api/saves/{save.id}/content?device_id={device.id}", + headers={"Authorization": f"Bearer {token_without_device_scopes}"}, + ) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + +class TestConfirmDownload: + def test_confirm_download_creates_sync_record( + self, + client, + access_token: str, + save: Save, + device: Device, + ): + sync_before = db_device_save_sync_handler.get_sync( + device_id=device.id, save_id=save.id + ) + assert sync_before is None + + response = client.post( + f"/api/saves/{save.id}/downloaded", + json={"device_id": device.id}, + headers={"Authorization": f"Bearer {access_token}"}, + ) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert len(data["device_syncs"]) == 1 + assert data["device_syncs"][0]["device_id"] == device.id + + sync_after = db_device_save_sync_handler.get_sync( + device_id=device.id, save_id=save.id + ) + assert sync_after is not None + assert sync_after.last_synced_at.replace( + microsecond=0, tzinfo=None + ) == save.updated_at.replace(microsecond=0, tzinfo=None) + + def test_confirm_download_updates_existing_sync( + self, + client, + access_token: str, + save: Save, + device: Device, + ): + from datetime import datetime, timedelta, timezone + + old_sync_time = datetime.now(timezone.utc) - timedelta(hours=5) + db_device_save_sync_handler.upsert_sync( + device_id=device.id, save_id=save.id, synced_at=old_sync_time + ) + + response = client.post( + f"/api/saves/{save.id}/downloaded", + json={"device_id": device.id}, + headers={"Authorization": f"Bearer {access_token}"}, + ) + + assert response.status_code == status.HTTP_200_OK + + sync = db_device_save_sync_handler.get_sync( + device_id=device.id, save_id=save.id + ) + assert sync.last_synced_at.replace( + microsecond=0, tzinfo=None + ) == save.updated_at.replace(microsecond=0, tzinfo=None) + assert sync.last_synced_at.replace( + microsecond=0, tzinfo=None + ) != old_sync_time.replace(microsecond=0, tzinfo=None) + + def test_confirm_download_updates_device_last_seen( + self, + client, + access_token: str, + save: Save, + device: Device, + ): + original_last_seen = device.last_seen + + response = client.post( + f"/api/saves/{save.id}/downloaded", + json={"device_id": device.id}, + headers={"Authorization": f"Bearer {access_token}"}, + ) + + assert response.status_code == status.HTTP_200_OK + + updated_device = db_device_handler.get_device( + device_id=device.id, user_id=device.user_id + ) + if original_last_seen: + assert updated_device.last_seen > original_last_seen + else: + assert updated_device.last_seen is not None + + def test_confirm_download_save_not_found( + self, + client, + access_token: str, + device: Device, + ): + response = client.post( + "/api/saves/99999/downloaded", + json={"device_id": device.id}, + headers={"Authorization": f"Bearer {access_token}"}, + ) + + assert response.status_code == status.HTTP_404_NOT_FOUND + assert "99999" in response.json()["detail"] + + def test_confirm_download_device_not_found( + self, + client, + access_token: str, + save: Save, + ): + response = client.post( + f"/api/saves/{save.id}/downloaded", + json={"device_id": "nonexistent-device"}, + headers={"Authorization": f"Bearer {access_token}"}, + ) + + assert response.status_code == status.HTTP_404_NOT_FOUND + assert "nonexistent-device" in response.json()["detail"] + + +class TestContentHashDeduplication: + @mock.patch( + "endpoints.saves.fs_asset_handler.write_file", new_callable=mock.AsyncMock + ) + @mock.patch("endpoints.saves.scan_save") + def test_slot_upload_includes_content_hash( + self, + mock_scan_save, + mock_write_file, + client, + access_token: str, + rom: Rom, + ): + from models.assets import Save as SaveModel + + mock_save = SaveModel( + id=999, + file_name="test [2026-01-31_12-00-00].sav", + file_name_no_tags="test.sav", + file_name_no_ext="test [2026-01-31_12-00-00]", + file_extension="sav", + file_path="/saves/path", + file_size_bytes=1024, + content_hash="abc123def456789012345678901234ab", + rom_id=rom.id, + user_id=1, + ) + mock_scan_save.return_value = mock_save + + response = client.post( + "/api/saves", + params={"rom_id": rom.id, "slot": "Slot1"}, + files={ + "saveFile": ( + "test.sav", + BytesIO(b"save content"), + "application/octet-stream", + ) + }, + headers={"Authorization": f"Bearer {access_token}"}, + ) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert "content_hash" in data + assert data["content_hash"] == "abc123def456789012345678901234ab" + + @mock.patch( + "endpoints.saves.fs_asset_handler.write_file", new_callable=mock.AsyncMock + ) + @mock.patch( + "endpoints.saves.fs_asset_handler.remove_file", new_callable=mock.AsyncMock + ) + @mock.patch("endpoints.saves.scan_save") + def test_duplicate_hash_returns_existing_save( + self, + mock_scan_save, + mock_remove_file, + mock_write_file, + client, + access_token: str, + rom: Rom, + save: Save, + ): + from handler.database import db_save_handler + + db_save_handler.update_save( + save.id, {"content_hash": "duplicate_hash_12345678901234"} + ) + + from models.assets import Save as SaveModel + + mock_save = SaveModel( + id=None, + file_name="new [2026-01-31_12-00-00].sav", + file_name_no_tags="new.sav", + file_name_no_ext="new [2026-01-31_12-00-00]", + file_extension="sav", + file_path="/saves/path", + file_size_bytes=1024, + content_hash="duplicate_hash_12345678901234", + rom_id=rom.id, + user_id=1, + ) + mock_scan_save.return_value = mock_save + + response = client.post( + "/api/saves", + params={"rom_id": rom.id, "slot": "Slot1"}, + files={ + "saveFile": ( + "new.sav", + BytesIO(b"save content"), + "application/octet-stream", + ) + }, + headers={"Authorization": f"Bearer {access_token}"}, + ) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data["id"] == save.id + assert data["content_hash"] == "duplicate_hash_12345678901234" + mock_remove_file.assert_called_once() + + @mock.patch( + "endpoints.saves.fs_asset_handler.write_file", new_callable=mock.AsyncMock + ) + @mock.patch("endpoints.saves.scan_save") + def test_duplicate_hash_with_overwrite_succeeds( + self, + mock_scan_save, + mock_write_file, + client, + access_token: str, + rom: Rom, + save: Save, + ): + from handler.database import db_save_handler + + db_save_handler.update_save( + save.id, {"content_hash": "duplicate_hash_12345678901234"} + ) + + from models.assets import Save as SaveModel + + mock_save = SaveModel( + id=None, + file_name="new [2026-01-31_12-00-00].sav", + file_name_no_tags="new.sav", + file_name_no_ext="new [2026-01-31_12-00-00]", + file_extension="sav", + file_path="/saves/path", + file_size_bytes=1024, + content_hash="duplicate_hash_12345678901234", + rom_id=rom.id, + user_id=1, + ) + mock_scan_save.return_value = mock_save + + response = client.post( + "/api/saves", + params={"rom_id": rom.id, "slot": "Slot1", "overwrite": True}, + files={ + "saveFile": ( + "new.sav", + BytesIO(b"save content"), + "application/octet-stream", + ) + }, + headers={"Authorization": f"Bearer {access_token}"}, + ) + + assert response.status_code == status.HTTP_200_OK + + @mock.patch( + "endpoints.saves.fs_asset_handler.write_file", new_callable=mock.AsyncMock + ) + @mock.patch("endpoints.saves.scan_save") + def test_non_slot_upload_no_dedup_block( + self, + mock_scan_save, + mock_write_file, + client, + access_token: str, + rom: Rom, + save: Save, + ): + from handler.database import db_save_handler + + db_save_handler.update_save( + save.id, {"content_hash": "duplicate_hash_12345678901234"} + ) + + from models.assets import Save as SaveModel + + mock_save = SaveModel( + id=None, + file_name="new.sav", + file_name_no_tags="new.sav", + file_name_no_ext="new", + file_extension="sav", + file_path="/saves/path", + file_size_bytes=1024, + content_hash="duplicate_hash_12345678901234", + rom_id=rom.id, + user_id=1, + ) + mock_scan_save.return_value = mock_save + + response = client.post( + "/api/saves", + params={"rom_id": rom.id}, + files={ + "saveFile": ( + "new.sav", + BytesIO(b"save content"), + "application/octet-stream", + ) + }, + headers={"Authorization": f"Bearer {access_token}"}, + ) + + assert response.status_code == status.HTTP_200_OK + + +class TestContentHashComputation: + def test_compute_file_hash(self, tmp_path): + from handler.scan_handler import _compute_file_hash + + test_file = tmp_path / "test.sav" + test_file.write_bytes(b"test content for hashing") + + hash_result = _compute_file_hash(str(test_file)) + + assert hash_result is not None + assert len(hash_result) == 32 + + hash_result2 = _compute_file_hash(str(test_file)) + assert hash_result == hash_result2 + + def test_same_content_produces_same_hash(self, tmp_path): + from handler.scan_handler import _compute_file_hash + + file1 = tmp_path / "save1.sav" + file2 = tmp_path / "save2.sav" + file1.write_bytes(b"identical content") + file2.write_bytes(b"identical content") + + hash1 = _compute_file_hash(str(file1)) + hash2 = _compute_file_hash(str(file2)) + + assert hash1 == hash2 + + def test_different_content_produces_different_hash(self, tmp_path): + from handler.scan_handler import _compute_file_hash + + file1 = tmp_path / "save1.sav" + file2 = tmp_path / "save2.sav" + file1.write_bytes(b"content A") + file2.write_bytes(b"content B") + + hash1 = _compute_file_hash(str(file1)) + hash2 = _compute_file_hash(str(file2)) + + assert hash1 != hash2 diff --git a/backend/tests/handler/database/test_saves_handler.py b/backend/tests/handler/database/test_saves_handler.py index 066cb35d6..36765648c 100644 --- a/backend/tests/handler/database/test_saves_handler.py +++ b/backend/tests/handler/database/test_saves_handler.py @@ -166,3 +166,268 @@ class TestDBSavesHandlerPlatformFiltering: # Verify the save is associated with the correct platform through ROM assert retrieved_save.rom.platform_id == platform.id + + +class TestDBSavesHandlerSlotFiltering: + def test_get_saves_with_slot_filter(self, admin_user: User, rom: Rom): + save1 = Save( + rom_id=rom.id, + user_id=admin_user.id, + file_name="slot_test_1.sav", + file_name_no_tags="slot_test_1", + file_name_no_ext="slot_test_1", + file_extension="sav", + emulator="test_emu", + file_path=f"{rom.platform_slug}/saves", + file_size_bytes=100, + slot="Slot A", + ) + save2 = Save( + rom_id=rom.id, + user_id=admin_user.id, + file_name="slot_test_2.sav", + file_name_no_tags="slot_test_2", + file_name_no_ext="slot_test_2", + file_extension="sav", + emulator="test_emu", + file_path=f"{rom.platform_slug}/saves", + file_size_bytes=100, + slot="Slot A", + ) + save3 = Save( + rom_id=rom.id, + user_id=admin_user.id, + file_name="slot_test_3.sav", + file_name_no_tags="slot_test_3", + file_name_no_ext="slot_test_3", + file_extension="sav", + emulator="test_emu", + file_path=f"{rom.platform_slug}/saves", + file_size_bytes=100, + slot="Slot B", + ) + + db_save_handler.add_save(save1) + db_save_handler.add_save(save2) + db_save_handler.add_save(save3) + + slot_a_saves = db_save_handler.get_saves( + user_id=admin_user.id, rom_id=rom.id, slot="Slot A" + ) + assert len(slot_a_saves) == 2 + assert all(s.slot == "Slot A" for s in slot_a_saves) + + slot_b_saves = db_save_handler.get_saves( + user_id=admin_user.id, rom_id=rom.id, slot="Slot B" + ) + assert len(slot_b_saves) == 1 + assert slot_b_saves[0].slot == "Slot B" + + def test_get_saves_with_null_slot_filter(self, admin_user: User, rom: Rom): + save_with_slot = Save( + rom_id=rom.id, + user_id=admin_user.id, + file_name="with_slot.sav", + file_name_no_tags="with_slot", + file_name_no_ext="with_slot", + file_extension="sav", + emulator="test_emu", + file_path=f"{rom.platform_slug}/saves", + file_size_bytes=100, + slot="Main", + ) + save_without_slot = Save( + rom_id=rom.id, + user_id=admin_user.id, + file_name="without_slot.sav", + file_name_no_tags="without_slot", + file_name_no_ext="without_slot", + file_extension="sav", + emulator="test_emu", + file_path=f"{rom.platform_slug}/saves", + file_size_bytes=100, + slot=None, + ) + + db_save_handler.add_save(save_with_slot) + db_save_handler.add_save(save_without_slot) + + all_saves = db_save_handler.get_saves(user_id=admin_user.id, rom_id=rom.id) + assert len(all_saves) >= 2 + + def test_get_saves_order_by_updated_at_desc(self, admin_user: User, rom: Rom): + from datetime import datetime, timedelta, timezone + + base_time = datetime.now(timezone.utc) + + save1 = Save( + rom_id=rom.id, + user_id=admin_user.id, + file_name="order_test_1.sav", + file_name_no_tags="order_test_1", + file_name_no_ext="order_test_1", + file_extension="sav", + emulator="test_emu", + file_path=f"{rom.platform_slug}/saves", + file_size_bytes=100, + slot="order_test", + ) + save2 = Save( + rom_id=rom.id, + user_id=admin_user.id, + file_name="order_test_2.sav", + file_name_no_tags="order_test_2", + file_name_no_ext="order_test_2", + file_extension="sav", + emulator="test_emu", + file_path=f"{rom.platform_slug}/saves", + file_size_bytes=100, + slot="order_test", + ) + + created1 = db_save_handler.add_save(save1) + created2 = db_save_handler.add_save(save2) + + db_save_handler.update_save( + created1.id, {"updated_at": base_time - timedelta(hours=2)} + ) + db_save_handler.update_save( + created2.id, {"updated_at": base_time - timedelta(hours=1)} + ) + + ordered_saves = db_save_handler.get_saves( + user_id=admin_user.id, + rom_id=rom.id, + slot="order_test", + order_by_updated_at_desc=True, + ) + + assert len(ordered_saves) == 2 + assert ordered_saves[0].id == created2.id + assert ordered_saves[1].id == created1.id + + +class TestDBSavesHandlerSummary: + def test_get_saves_summary_basic(self, admin_user: User, rom: Rom): + from datetime import datetime, timedelta, timezone + + base_time = datetime.now(timezone.utc) + + configs = [ + ("summary_a_1.sav", "Slot A", -3), + ("summary_a_2.sav", "Slot A", -1), + ("summary_b_1.sav", "Slot B", -2), + ("summary_none_1.sav", None, -4), + ] + + for filename, slot, hours_offset in configs: + save = Save( + rom_id=rom.id, + user_id=admin_user.id, + file_name=filename, + file_name_no_tags=filename.replace(".sav", ""), + file_name_no_ext=filename.replace(".sav", ""), + file_extension="sav", + emulator="test_emu", + file_path=f"{rom.platform_slug}/saves", + file_size_bytes=100, + slot=slot, + ) + created = db_save_handler.add_save(save) + db_save_handler.update_save( + created.id, {"updated_at": base_time + timedelta(hours=hours_offset)} + ) + + summary = db_save_handler.get_saves_summary( + user_id=admin_user.id, rom_id=rom.id + ) + + assert "total_count" in summary + assert "slots" in summary + assert summary["total_count"] == 4 + assert len(summary["slots"]) == 3 + + def test_get_saves_summary_latest_per_slot(self, admin_user: User, rom: Rom): + from datetime import datetime, timedelta, timezone + + base_time = datetime.now(timezone.utc) + + old_save = Save( + rom_id=rom.id, + user_id=admin_user.id, + file_name="latest_test_old.sav", + file_name_no_tags="latest_test_old", + file_name_no_ext="latest_test_old", + file_extension="sav", + emulator="test_emu", + file_path=f"{rom.platform_slug}/saves", + file_size_bytes=100, + slot="latest_test", + ) + new_save = Save( + rom_id=rom.id, + user_id=admin_user.id, + file_name="latest_test_new.sav", + file_name_no_tags="latest_test_new", + file_name_no_ext="latest_test_new", + file_extension="sav", + emulator="test_emu", + file_path=f"{rom.platform_slug}/saves", + file_size_bytes=100, + slot="latest_test", + ) + + old_created = db_save_handler.add_save(old_save) + new_created = db_save_handler.add_save(new_save) + + db_save_handler.update_save( + old_created.id, {"updated_at": base_time - timedelta(hours=5)} + ) + db_save_handler.update_save( + new_created.id, {"updated_at": base_time - timedelta(hours=1)} + ) + + summary = db_save_handler.get_saves_summary( + user_id=admin_user.id, rom_id=rom.id + ) + + latest_slot = next( + (s for s in summary["slots"] if s["slot"] == "latest_test"), None + ) + assert latest_slot is not None + assert latest_slot["count"] == 2 + assert latest_slot["latest"].file_name == "latest_test_new.sav" + + def test_get_saves_summary_empty_rom(self, admin_user: User): + summary = db_save_handler.get_saves_summary( + user_id=admin_user.id, rom_id=999999 + ) + + assert summary["total_count"] == 0 + assert summary["slots"] == [] + + def test_get_saves_summary_count_accuracy(self, admin_user: User, rom: Rom): + for i in range(5): + save = Save( + rom_id=rom.id, + user_id=admin_user.id, + file_name=f"count_test_{i}.sav", + file_name_no_tags=f"count_test_{i}", + file_name_no_ext=f"count_test_{i}", + file_extension="sav", + emulator="test_emu", + file_path=f"{rom.platform_slug}/saves", + file_size_bytes=100, + slot="count_test", + ) + db_save_handler.add_save(save) + + summary = db_save_handler.get_saves_summary( + user_id=admin_user.id, rom_id=rom.id + ) + + count_slot = next( + (s for s in summary["slots"] if s["slot"] == "count_test"), None + ) + assert count_slot is not None + assert count_slot["count"] == 5