Add device-based save synchronization

Implement device registration and save sync tracking to enable
multi-device save management with conflict detection.

- Device CRUD endpoints (POST/GET/PUT/DELETE /api/devices)
- Save sync state tracking per device
- Conflict detection on upload (409 when device has stale sync)
- Download sync tracking (optimistic and confirmed modes)
- Track/untrack saves per device
- DEVICES_READ/WRITE scopes for authorization
This commit is contained in:
nendo
2026-01-18 13:11:11 +09:00
parent b34117591a
commit 36eec298d1
18 changed files with 2015 additions and 39 deletions

View File

@@ -0,0 +1,96 @@
"""Add device-based save synchronization
Revision ID: 0065_save_sync
Revises: 0064_add_updated_at_indexes
Create Date: 2026-01-17
"""
import sqlalchemy as sa
from alembic import op
revision = "0065_save_sync"
down_revision = "0064_add_updated_at_indexes"
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"devices",
sa.Column("id", sa.String(255), primary_key=True),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(255), nullable=True),
sa.Column("platform", sa.String(50), nullable=True),
sa.Column("client", sa.String(50), nullable=True),
sa.Column("client_version", sa.String(50), nullable=True),
sa.Column("ip_address", sa.String(45), nullable=True),
sa.Column("mac_address", sa.String(17), nullable=True),
sa.Column("hostname", sa.String(255), nullable=True),
sa.Column(
"sync_mode",
sa.Enum("API", "FILE_TRANSFER", "PUSH_PULL", name="syncmode"),
nullable=False,
server_default="API",
),
sa.Column("sync_enabled", sa.Boolean(), nullable=False, server_default="1"),
sa.Column("last_seen", sa.TIMESTAMP(timezone=True), nullable=True),
sa.Column(
"created_at",
sa.TIMESTAMP(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.TIMESTAMP(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
)
op.create_table(
"device_save_sync",
sa.Column("device_id", sa.String(255), nullable=False),
sa.Column("save_id", sa.Integer(), nullable=False),
sa.Column("last_synced_at", sa.TIMESTAMP(timezone=True), nullable=False),
sa.Column("is_untracked", sa.Boolean(), nullable=False, server_default="0"),
sa.Column(
"created_at",
sa.TIMESTAMP(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.TIMESTAMP(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.ForeignKeyConstraint(["device_id"], ["devices.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["save_id"], ["saves.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("device_id", "save_id"),
)
with op.batch_alter_table("saves", schema=None) as batch_op:
batch_op.add_column(sa.Column("save_name", sa.String(255), 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"])
def downgrade():
op.drop_index("ix_saves_save_name", "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")
op.drop_table("device_save_sync")
op.drop_table("devices")
op.execute("DROP TYPE IF EXISTS syncmode")

View File

@@ -216,7 +216,7 @@ def get_collection(request: Request, id: int) -> CollectionSchema:
if collection.user_id != request.user.id and not collection.is_public:
raise CollectionPermissionError(id)
return CollectionSchema.model_validate(collection)
@@ -414,7 +414,7 @@ async def delete_collection(
collection = db_collection_handler.get_collection(id)
if not collection:
raise CollectionNotFoundInDatabaseException(id)
if collection.user_id != request.user.id:
raise CollectionPermissionError(id)

143
backend/endpoints/device.py Normal file
View File

@@ -0,0 +1,143 @@
import uuid
from datetime import datetime, timezone
from typing import Any
from fastapi import Body, HTTPException, Request, status
from decorators.auth import protected_route
from endpoints.responses.device import DeviceCreateResponse, DeviceSchema
from handler.auth.constants import Scope
from handler.database import db_device_handler
from logger.logger import log
from models.device import Device
from utils.router import APIRouter
router = APIRouter(
prefix="/devices",
tags=["devices"],
)
@protected_route(
router.post,
"",
[Scope.DEVICES_WRITE],
status_code=status.HTTP_201_CREATED,
)
def register_device(
request: Request,
name: str | None = Body(None, embed=True),
platform: str | None = Body(None, embed=True),
client: str | None = Body(None, embed=True),
client_version: str | None = Body(None, embed=True),
ip_address: str | None = Body(None, embed=True),
mac_address: str | None = Body(None, embed=True),
hostname: str | None = Body(None, embed=True),
) -> DeviceCreateResponse:
device_id = str(uuid.uuid4())
now = datetime.now(timezone.utc)
device = Device(
id=device_id,
user_id=request.user.id,
name=name,
platform=platform,
client=client,
client_version=client_version,
ip_address=ip_address,
mac_address=mac_address,
hostname=hostname,
last_seen=now,
)
db_device = db_device_handler.add_device(device)
log.info(f"Registered device {device_id} for user {request.user.username}")
return DeviceCreateResponse(
device_id=db_device.id,
name=db_device.name,
created_at=db_device.created_at,
)
@protected_route(router.get, "", [Scope.DEVICES_READ])
def get_devices(request: Request) -> list[DeviceSchema]:
devices = db_device_handler.get_devices(user_id=request.user.id)
return [DeviceSchema.model_validate(device) for device in devices]
@protected_route(router.get, "/{device_id}", [Scope.DEVICES_READ])
def get_device(request: Request, device_id: str) -> DeviceSchema:
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",
)
return DeviceSchema.model_validate(device)
@protected_route(router.put, "/{device_id}", [Scope.DEVICES_WRITE])
def update_device(
request: Request,
device_id: str,
name: str | None = Body(None, embed=True),
platform: str | None = Body(None, embed=True),
client: str | None = Body(None, embed=True),
client_version: str | None = Body(None, embed=True),
ip_address: str | None = Body(None, embed=True),
mac_address: str | None = Body(None, embed=True),
hostname: str | None = Body(None, embed=True),
sync_enabled: bool | None = Body(None, embed=True),
) -> DeviceSchema:
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",
)
update_data: dict[str, Any] = {}
if name is not None:
update_data["name"] = name
if platform is not None:
update_data["platform"] = platform
if client is not None:
update_data["client"] = client
if client_version is not None:
update_data["client_version"] = client_version
if ip_address is not None:
update_data["ip_address"] = ip_address
if mac_address is not None:
update_data["mac_address"] = mac_address
if hostname is not None:
update_data["hostname"] = hostname
if sync_enabled is not None:
update_data["sync_enabled"] = sync_enabled
if update_data:
device = db_device_handler.update_device(
device_id=device_id,
user_id=request.user.id,
data=update_data,
)
return DeviceSchema.model_validate(device)
@protected_route(
router.delete,
"/{device_id}",
[Scope.DEVICES_WRITE],
status_code=status.HTTP_204_NO_CONTENT,
)
def delete_device(request: Request, device_id: str) -> None:
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",
)
db_device_handler.delete_device(device_id=device_id, user_id=request.user.id)
log.info(f"Deleted device {device_id} for user {request.user.username}")

View File

@@ -1,6 +1,7 @@
from datetime import datetime
from .base import BaseModel
from .device import DeviceSyncSchema
class BaseAsset(BaseModel):
@@ -31,7 +32,9 @@ class ScreenshotSchema(BaseAsset):
class SaveSchema(BaseAsset):
emulator: str | None
save_name: str | None = None
screenshot: ScreenshotSchema | None
device_syncs: list[DeviceSyncSchema] = []
class StateSchema(BaseAsset):

View File

@@ -0,0 +1,42 @@
from datetime import datetime
from models.device import SyncMode
from .base import BaseModel
class DeviceSyncSchema(BaseModel):
device_id: str
device_name: str | None
last_synced_at: datetime
is_untracked: bool
is_current: bool
class Config:
from_attributes = True
class DeviceSchema(BaseModel):
id: str
user_id: int
name: str | None
platform: str | None
client: str | None
client_version: str | None
ip_address: str | None
mac_address: str | None
hostname: str | None
sync_mode: SyncMode
sync_enabled: bool
last_seen: datetime | None
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class DeviceCreateResponse(BaseModel):
device_id: str
name: str | None
created_at: datetime

View File

@@ -2,19 +2,68 @@ from datetime import datetime, timezone
from typing import Annotated
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.device import DeviceSyncSchema
from exceptions.endpoint_exceptions import RomNotFoundInDatabaseException
from handler.auth.constants import Scope
from handler.database import db_rom_handler, db_save_handler, db_screenshot_handler
from handler.database import (
db_device_handler,
db_device_save_sync_handler,
db_rom_handler,
db_save_handler,
db_screenshot_handler,
)
from handler.filesystem import fs_asset_handler
from handler.scan_handler import scan_save, scan_screenshot
from logger.formatter import BLUE
from logger.formatter import highlight as hl
from logger.logger import log
from models.assets import Save
from models.device import Device
from models.device_save_sync import DeviceSaveSync
from utils.router import APIRouter
def _normalize_datetime(dt: datetime) -> datetime:
if dt.tzinfo is not None:
return dt.replace(tzinfo=None)
return dt
def _build_save_schema(
save: Save,
device: Device | None = None,
sync: DeviceSaveSync | None = None,
) -> SaveSchema:
device_syncs: list[DeviceSyncSchema] = []
if device:
last_synced = sync.last_synced_at if sync else save.updated_at
is_current = _normalize_datetime(last_synced) >= _normalize_datetime(
save.updated_at
)
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_current=is_current,
)
)
save_data = {
key: getattr(save, key)
for key in SaveSchema.model_fields
if key != "device_syncs" and hasattr(save, key)
}
save_data["device_syncs"] = device_syncs
return SaveSchema.model_validate(save_data)
router = APIRouter(
prefix="/saves",
tags=["saves"],
@@ -26,22 +75,30 @@ async def add_save(
request: Request,
rom_id: int,
emulator: str | None = None,
save_name: str | None = None,
device_id: str | None = None,
overwrite: bool = False,
) -> 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",
)
data = await request.form()
rom = db_rom_handler.get_rom(rom_id)
if not rom:
raise RomNotFoundInDatabaseException(rom_id)
log.info(f"Uploading save of {rom.name}")
saves_path = fs_asset_handler.build_saves_file_path(
user=request.user,
platform_fs_slug=rom.platform.fs_slug,
rom_id=rom_id,
emulator=emulator,
)
if "saveFile" not in data:
log.error("No save file provided")
raise HTTPException(
@@ -56,9 +113,25 @@ async def add_save(
status_code=status.HTTP_400_BAD_REQUEST, detail="Save file has no filename"
)
rom = db_rom_handler.get_rom(rom_id)
if not rom:
raise RomNotFoundInDatabaseException(rom_id)
db_save = db_save_handler.get_save_by_filename(
user_id=request.user.id, rom_id=rom.id, file_name=saveFile.filename
)
if 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:
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(),
},
)
log.info(
f"Uploading save {hl(saveFile.filename)} for {hl(str(rom.name), color=BLUE)}"
@@ -73,7 +146,6 @@ async def add_save(
await fs_asset_handler.write_file(file=saveFile, path=saves_path)
# Scan or update save
scanned_save = await scan_save(
file_name=saveFile.filename,
user=request.user,
@@ -81,19 +153,25 @@ async def add_save(
rom_id=rom_id,
emulator=emulator,
)
db_save = db_save_handler.get_save_by_filename(
user_id=request.user.id, rom_id=rom.id, file_name=saveFile.filename
)
if db_save:
db_save = db_save_handler.update_save(
db_save.id, {"file_size_bytes": scanned_save.file_size_bytes}
)
update_data = {"file_size_bytes": scanned_save.file_size_bytes}
if save_name is not None:
update_data["save_name"] = save_name
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
db_save = db_save_handler.add_save(save=scanned_save)
if device:
db_device_save_sync_handler.upsert_sync(
device_id=device.id, save_id=db_save.id, synced_at=db_save.updated_at
)
db_device_handler.update_last_seen(device_id=device.id, user_id=request.user.id)
screenshotFile: UploadFile | None = data.get("screenshotFile", None) # type: ignore
if screenshotFile and screenshotFile.filename:
screenshots_path = fs_asset_handler.build_screenshots_file_path(
@@ -102,7 +180,6 @@ async def add_save(
await fs_asset_handler.write_file(file=screenshotFile, path=screenshots_path)
# Scan or update screenshot
scanned_screenshot = await scan_screenshot(
file_name=screenshotFile.filename,
user=request.user,
@@ -124,7 +201,6 @@ async def add_save(
scanned_screenshot.user_id = request.user.id
db_screenshot_handler.add_screenshot(screenshot=scanned_screenshot)
# Set the last played time for the current user
rom_user = db_rom_handler.get_rom_user(rom_id=rom.id, user_id=request.user.id)
if not rom_user:
rom_user = db_rom_handler.add_rom_user(rom_id=rom.id, user_id=request.user.id)
@@ -132,35 +208,167 @@ async def add_save(
rom_user.id, {"last_played": datetime.now(timezone.utc)}
)
# Refetch the rom to get updated saves
rom = db_rom_handler.get_rom(rom_id)
if not rom:
raise RomNotFoundInDatabaseException(rom_id)
return SaveSchema.model_validate(db_save)
sync = None
if device:
sync = db_device_save_sync_handler.get_sync(
device_id=device.id, save_id=db_save.id
)
return _build_save_schema(db_save, device, sync)
@protected_route(router.get, "", [Scope.ASSETS_READ])
def get_saves(
request: Request, rom_id: int | None = None, platform_id: int | None = None
request: Request,
rom_id: int | None = None,
platform_id: int | None = None,
device_id: 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",
)
saves = db_save_handler.get_saves(
user_id=request.user.id, rom_id=rom_id, platform_id=platform_id
)
return [SaveSchema.model_validate(save) for save in saves]
if not device:
return [_build_save_schema(save) for save in saves]
syncs = db_device_save_sync_handler.get_syncs_for_device_and_saves(
device_id=device.id, save_ids=[s.id for s in saves]
)
sync_by_save_id = {s.save_id: s for s in syncs}
return [
_build_save_schema(save, device, sync_by_save_id.get(save.id)) for save in saves
]
@protected_route(router.get, "/{id}", [Scope.ASSETS_READ])
def get_save(request: Request, id: int) -> SaveSchema:
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",
)
save = db_save_handler.get_save(user_id=request.user.id, id=id)
if not save:
error = f"Save with ID {id} not found"
log.error(error)
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=error)
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Save with ID {id} not found",
)
return SaveSchema.model_validate(save)
sync = None
if device:
sync = db_device_save_sync_handler.get_sync(
device_id=device.id, save_id=save.id
)
return _build_save_schema(save, device, sync)
@protected_route(router.get, "/{id}/content", [Scope.ASSETS_READ])
def download_save(
request: Request,
id: int,
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",
)
save = db_save_handler.get_save(user_id=request.user.id, id=id)
if not save:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Save with ID {id} not found",
)
try:
file_path = fs_asset_handler.validate_path(save.full_path)
except ValueError:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Save file not found",
) from None
if not file_path.exists() or not file_path.is_file():
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Save file not found on disk",
)
if device and optimistic:
db_device_save_sync_handler.upsert_sync(
device_id=device.id,
save_id=save.id,
synced_at=save.updated_at,
)
db_device_handler.update_last_seen(device_id=device.id, user_id=request.user.id)
return FileResponse(path=str(file_path), filename=save.file_name)
@protected_route(router.post, "/{id}/downloaded", [Scope.ASSETS_READ])
def confirm_download(
request: Request,
id: int,
device_id: str = Body(..., embed=True),
) -> SaveSchema:
if Scope.DEVICES_WRITE not in request.auth.scopes:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
save = db_save_handler.get_save(user_id=request.user.id, id=id)
if not save:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
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",
)
sync = db_device_save_sync_handler.upsert_sync(
device_id=device_id,
save_id=save.id,
synced_at=save.updated_at,
)
db_device_handler.update_last_seen(device_id=device_id, user_id=request.user.id)
return _build_save_schema(save, device, sync)
@protected_route(router.put, "/{id}", [Scope.ASSETS_WRITE])
@@ -279,3 +487,63 @@ async def delete_saves(
log.error(error)
return saves
@protected_route(router.post, "/{id}/track", [Scope.ASSETS_WRITE])
def track_save(
request: Request,
id: int,
device_id: str = Body(..., embed=True),
) -> SaveSchema:
if Scope.DEVICES_WRITE not in request.auth.scopes:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
save = db_save_handler.get_save(user_id=request.user.id, id=id)
if not save:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
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",
)
sync = db_device_save_sync_handler.set_untracked(
device_id=device_id, save_id=id, untracked=False
)
return _build_save_schema(save, device, sync)
@protected_route(router.post, "/{id}/untrack", [Scope.ASSETS_WRITE])
def untrack_save(
request: Request,
id: int,
device_id: str = Body(..., embed=True),
) -> SaveSchema:
if Scope.DEVICES_WRITE not in request.auth.scopes:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
save = db_save_handler.get_save(user_id=request.user.id, id=id)
if not save:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
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",
)
sync = db_device_save_sync_handler.set_untracked(
device_id=device_id, save_id=id, untracked=True
)
return _build_save_schema(save, device, sync)

View File

@@ -17,6 +17,8 @@ class Scope(enum.StrEnum):
PLATFORMS_WRITE = "platforms.write"
ASSETS_READ = "assets.read"
ASSETS_WRITE = "assets.write"
DEVICES_READ = "devices.read"
DEVICES_WRITE = "devices.write"
FIRMWARE_READ = "firmware.read"
FIRMWARE_WRITE = "firmware.write"
COLLECTIONS_READ = "collections.read"
@@ -31,6 +33,7 @@ READ_SCOPES_MAP: Final = {
Scope.ROMS_READ: "View ROMs",
Scope.PLATFORMS_READ: "View platforms",
Scope.ASSETS_READ: "View assets",
Scope.DEVICES_READ: "View devices",
Scope.FIRMWARE_READ: "View firmware",
Scope.ROMS_USER_READ: "View user-rom properties",
Scope.COLLECTIONS_READ: "View collections",
@@ -39,6 +42,7 @@ READ_SCOPES_MAP: Final = {
WRITE_SCOPES_MAP: Final = {
Scope.ME_WRITE: "Modify your profile",
Scope.ASSETS_WRITE: "Modify assets",
Scope.DEVICES_WRITE: "Modify devices",
Scope.ROMS_USER_WRITE: "Modify user-rom properties",
Scope.COLLECTIONS_WRITE: "Modify collections",
}

View File

@@ -1,4 +1,6 @@
from .collections_handler import DBCollectionsHandler
from .device_save_sync_handler import DBDeviceSaveSyncHandler
from .devices_handler import DBDevicesHandler
from .firmware_handler import DBFirmwareHandler
from .platforms_handler import DBPlatformsHandler
from .roms_handler import DBRomsHandler
@@ -8,6 +10,9 @@ from .states_handler import DBStatesHandler
from .stats_handler import DBStatsHandler
from .users_handler import DBUsersHandler
db_collection_handler = DBCollectionsHandler()
db_device_handler = DBDevicesHandler()
db_device_save_sync_handler = DBDeviceSaveSyncHandler()
db_firmware_handler = DBFirmwareHandler()
db_platform_handler = DBPlatformsHandler()
db_rom_handler = DBRomsHandler()
@@ -16,4 +21,3 @@ db_screenshot_handler = DBScreenshotsHandler()
db_state_handler = DBStatesHandler()
db_stats_handler = DBStatsHandler()
db_user_handler = DBUsersHandler()
db_collection_handler = DBCollectionsHandler()

View File

@@ -0,0 +1,117 @@
from collections.abc import Sequence
from datetime import datetime, timezone
from sqlalchemy import select, update
from sqlalchemy.orm import Session
from decorators.database import begin_session
from models.device_save_sync import DeviceSaveSync
from .base_handler import DBBaseHandler
class DBDeviceSaveSyncHandler(DBBaseHandler):
@begin_session
def get_sync(
self,
device_id: str,
save_id: int,
session: Session = None, # type: ignore
) -> DeviceSaveSync | None:
return session.scalar(
select(DeviceSaveSync)
.filter_by(device_id=device_id, save_id=save_id)
.limit(1)
)
@begin_session
def get_syncs_for_device_and_saves(
self,
device_id: str,
save_ids: list[int],
session: Session = None, # type: ignore
) -> Sequence[DeviceSaveSync]:
if not save_ids:
return []
return session.scalars(
select(DeviceSaveSync).filter(
DeviceSaveSync.device_id == device_id,
DeviceSaveSync.save_id.in_(save_ids),
)
).all()
@begin_session
def upsert_sync(
self,
device_id: str,
save_id: int,
synced_at: datetime | None = None,
session: Session = None, # type: ignore
) -> DeviceSaveSync:
now = synced_at or datetime.now(timezone.utc)
existing = session.scalar(
select(DeviceSaveSync)
.filter_by(device_id=device_id, save_id=save_id)
.limit(1)
)
if existing:
session.execute(
update(DeviceSaveSync)
.where(
DeviceSaveSync.device_id == device_id,
DeviceSaveSync.save_id == save_id,
)
.values(last_synced_at=now, is_untracked=False)
.execution_options(synchronize_session="evaluate")
)
existing.last_synced_at = now
existing.is_untracked = False
return existing
else:
sync = DeviceSaveSync(
device_id=device_id,
save_id=save_id,
last_synced_at=now,
is_untracked=False,
)
session.add(sync)
session.flush()
return sync
@begin_session
def set_untracked(
self,
device_id: str,
save_id: int,
untracked: bool,
session: Session = None, # type: ignore
) -> DeviceSaveSync | None:
existing = session.scalar(
select(DeviceSaveSync)
.filter_by(device_id=device_id, save_id=save_id)
.limit(1)
)
if existing:
session.execute(
update(DeviceSaveSync)
.where(
DeviceSaveSync.device_id == device_id,
DeviceSaveSync.save_id == save_id,
)
.values(is_untracked=untracked)
.execution_options(synchronize_session="evaluate")
)
existing.is_untracked = untracked
return existing
elif untracked:
now = datetime.now(timezone.utc)
sync = DeviceSaveSync(
device_id=device_id,
save_id=save_id,
last_synced_at=now,
is_untracked=True,
)
session.add(sync)
session.flush()
return sync
return None

View File

@@ -0,0 +1,84 @@
from collections.abc import Sequence
from datetime import datetime, timezone
from sqlalchemy import delete, select, update
from sqlalchemy.orm import Session
from decorators.database import begin_session
from models.device import Device
from .base_handler import DBBaseHandler
class DBDevicesHandler(DBBaseHandler):
@begin_session
def add_device(
self,
device: Device,
session: Session = None, # type: ignore
) -> Device:
return session.merge(device)
@begin_session
def get_device(
self,
device_id: str,
user_id: int,
session: Session = None, # type: ignore
) -> Device | None:
return session.scalar(
select(Device).filter_by(id=device_id, user_id=user_id).limit(1)
)
@begin_session
def get_devices(
self,
user_id: int,
session: Session = None, # type: ignore
) -> Sequence[Device]:
return session.scalars(select(Device).filter_by(user_id=user_id)).all()
@begin_session
def update_device(
self,
device_id: str,
user_id: int,
data: dict,
session: Session = None, # type: ignore
) -> Device | None:
session.execute(
update(Device)
.where(Device.id == device_id, Device.user_id == user_id)
.values(**data)
.execution_options(synchronize_session="evaluate")
)
return session.scalar(
select(Device).filter_by(id=device_id, user_id=user_id).limit(1)
)
@begin_session
def update_last_seen(
self,
device_id: str,
user_id: int,
session: Session = None, # type: ignore
) -> None:
session.execute(
update(Device)
.where(Device.id == device_id, Device.user_id == user_id)
.values(last_seen=datetime.now(timezone.utc))
.execution_options(synchronize_session="evaluate")
)
@begin_session
def delete_device(
self,
device_id: str,
user_id: int,
session: Session = None, # type: ignore
) -> None:
session.execute(
delete(Device)
.where(Device.id == device_id, Device.user_id == user_id)
.execution_options(synchronize_session="evaluate")
)

View File

@@ -28,6 +28,7 @@ from endpoints import (
auth,
collections,
configs,
device,
feeds,
firmware,
gamelist,
@@ -122,6 +123,7 @@ app.middleware("http")(set_context_middleware)
app.include_router(heartbeat.router, prefix="/api")
app.include_router(auth.router, prefix="/api")
app.include_router(user.router, prefix="/api")
app.include_router(device.router, prefix="/api")
app.include_router(platform.router, prefix="/api")
app.include_router(rom.router, prefix="/api")
app.include_router(search.router, prefix="/api")

View File

@@ -14,6 +14,7 @@ from models.base import (
)
if TYPE_CHECKING:
from models.device_save_sync import DeviceSaveSync
from models.rom import Rom
from models.user import User
@@ -54,9 +55,15 @@ 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))
rom: Mapped[Rom] = relationship(lazy="joined", back_populates="saves")
user: Mapped[User] = relationship(lazy="joined", back_populates="saves")
device_syncs: Mapped[list[DeviceSaveSync]] = relationship(
back_populates="save",
cascade="all, delete-orphan",
lazy="raise",
)
@cached_property
def screenshot(self) -> Screenshot | None:

49
backend/models/device.py Normal file
View File

@@ -0,0 +1,49 @@
from __future__ import annotations
import enum
from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import TIMESTAMP, Boolean, Enum, ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from models.base import BaseModel
if TYPE_CHECKING:
from models.device_save_sync import DeviceSaveSync
from models.user import User
class SyncMode(enum.StrEnum):
API = "api"
FILE_TRANSFER = "file_transfer"
PUSH_PULL = "push_pull"
class Device(BaseModel):
__tablename__ = "devices"
__table_args__ = {"extend_existing": True}
id: Mapped[str] = mapped_column(String(255), primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
name: Mapped[str | None] = mapped_column(String(255))
platform: Mapped[str | None] = mapped_column(String(50))
client: Mapped[str | None] = mapped_column(String(50))
client_version: Mapped[str | None] = mapped_column(String(50))
ip_address: Mapped[str | None] = mapped_column(String(45))
mac_address: Mapped[str | None] = mapped_column(String(17))
hostname: Mapped[str | None] = mapped_column(String(255))
sync_mode: Mapped[SyncMode] = mapped_column(Enum(SyncMode), default=SyncMode.API)
sync_enabled: Mapped[bool] = mapped_column(Boolean, default=True)
last_seen: Mapped[datetime | None] = mapped_column(TIMESTAMP(timezone=True))
user: Mapped[User] = relationship(lazy="joined")
save_syncs: Mapped[list[DeviceSaveSync]] = relationship(
back_populates="device",
cascade="all, delete-orphan",
lazy="raise",
)

View File

@@ -0,0 +1,34 @@
from __future__ import annotations
from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import TIMESTAMP, Boolean, ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from models.base import BaseModel
if TYPE_CHECKING:
from models.assets import Save
from models.device import Device
class DeviceSaveSync(BaseModel):
__tablename__ = "device_save_sync"
__table_args__ = {"extend_existing": True}
device_id: Mapped[str] = mapped_column(
String(255),
ForeignKey("devices.id", ondelete="CASCADE"),
primary_key=True,
)
save_id: Mapped[int] = mapped_column(
ForeignKey("saves.id", ondelete="CASCADE"),
primary_key=True,
)
last_synced_at: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True))
is_untracked: Mapped[bool] = mapped_column(Boolean, default=False)
device: Mapped[Device] = relationship(back_populates="save_syncs", lazy="raise")
save: Mapped[Save] = relationship(back_populates="device_syncs", lazy="raise")

View File

@@ -22,6 +22,7 @@ from utils.database import CustomJSON
if TYPE_CHECKING:
from models.assets import Save, Screenshot, State
from models.collection import Collection, SmartCollection
from models.device import Device
from models.rom import RomNote, RomUser
@@ -79,6 +80,9 @@ class User(BaseModel, SimpleUser):
smart_collections: Mapped[list["SmartCollection"]] = relationship(
lazy="raise", back_populates="user"
)
devices: Mapped[list["Device"]] = relationship(
lazy="raise", back_populates="user", cascade="all, delete-orphan"
)
@classmethod
def kiosk_mode_user(cls) -> User:

View File

@@ -14,6 +14,8 @@ from handler.database import (
db_user_handler,
)
from models.assets import Save, Screenshot, State
from models.device import Device
from models.device_save_sync import DeviceSaveSync
from models.platform import Platform
from models.rom import Rom
from models.user import Role, User
@@ -30,6 +32,8 @@ def setup_database():
@pytest.fixture(autouse=True)
def clear_database():
with session.begin() as s:
s.query(DeviceSaveSync).delete(synchronize_session="evaluate")
s.query(Device).delete(synchronize_session="evaluate")
s.query(Save).delete(synchronize_session="evaluate")
s.query(State).delete(synchronize_session="evaluate")
s.query(Screenshot).delete(synchronize_session="evaluate")

View File

@@ -0,0 +1,281 @@
from datetime import timedelta
import pytest
from fastapi import status
from fastapi.testclient import TestClient
from main import app
from endpoints.auth import ACCESS_TOKEN_EXPIRE_MINUTES
from handler.auth import oauth_handler
from handler.database import db_device_handler
from handler.redis_handler import sync_cache
from models.device import Device
from models.user import User
@pytest.fixture
def client():
with TestClient(app) as client:
yield client
@pytest.fixture(autouse=True)
def clear_cache():
yield
sync_cache.flushall()
@pytest.fixture
def editor_access_token(editor_user: User):
return oauth_handler.create_oauth_token(
data={
"sub": editor_user.username,
"iss": "romm:oauth",
"scopes": " ".join(editor_user.oauth_scopes),
"type": "access",
},
expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES),
)
class TestDeviceEndpoints:
def test_register_device(self, client, access_token: str):
response = client.post(
"/api/devices",
json={
"name": "Test Device",
"platform": "android",
"client": "argosy",
"client_version": "0.16.0",
},
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_201_CREATED
data = response.json()
assert data["name"] == "Test Device"
assert "device_id" in data
assert "created_at" in data
def test_register_device_minimal(self, client, access_token: str):
response = client.post(
"/api/devices",
json={},
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_201_CREATED
data = response.json()
assert data["name"] is None
assert "device_id" in data
def test_list_devices(self, client, access_token: str, admin_user: User):
db_device_handler.add_device(
Device(
id="test-device-1",
user_id=admin_user.id,
name="Device 1",
)
)
db_device_handler.add_device(
Device(
id="test-device-2",
user_id=admin_user.id,
name="Device 2",
)
)
response = client.get(
"/api/devices",
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert len(data) == 2
names = [d["name"] for d in data]
assert "Device 1" in names
assert "Device 2" in names
def test_get_device(self, client, access_token: str, admin_user: User):
device = db_device_handler.add_device(
Device(
id="test-device-get",
user_id=admin_user.id,
name="Get Test Device",
platform="linux",
)
)
response = client.get(
f"/api/devices/{device.id}",
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["id"] == "test-device-get"
assert data["name"] == "Get Test Device"
assert data["platform"] == "linux"
def test_get_device_not_found(self, client, access_token: str):
response = client.get(
"/api/devices/nonexistent-device",
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_update_device(self, client, access_token: str, admin_user: User):
device = db_device_handler.add_device(
Device(
id="test-device-update",
user_id=admin_user.id,
name="Original Name",
)
)
response = client.put(
f"/api/devices/{device.id}",
json={
"name": "Updated Name",
"platform": "android",
"client": "daijishou",
"client_version": "4.0.0",
"ip_address": "192.168.1.100",
"mac_address": "AA:BB:CC:DD:EE:FF",
"hostname": "my-odin3",
"sync_enabled": False,
},
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["name"] == "Updated Name"
assert data["platform"] == "android"
assert data["client"] == "daijishou"
assert data["client_version"] == "4.0.0"
assert data["ip_address"] == "192.168.1.100"
assert data["mac_address"] == "AA:BB:CC:DD:EE:FF"
assert data["hostname"] == "my-odin3"
assert data["sync_enabled"] is False
def test_delete_device(self, client, access_token: str, admin_user: User):
device = db_device_handler.add_device(
Device(
id="test-device-delete",
user_id=admin_user.id,
name="To Delete",
)
)
response = client.delete(
f"/api/devices/{device.id}",
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_204_NO_CONTENT
get_response = client.get(
f"/api/devices/{device.id}",
headers={"Authorization": f"Bearer {access_token}"},
)
assert get_response.status_code == status.HTTP_404_NOT_FOUND
class TestDeviceUserIsolation:
def test_list_devices_only_returns_own_devices(
self,
client,
access_token: str,
editor_access_token: str,
admin_user: User,
editor_user: User,
):
db_device_handler.add_device(
Device(id="admin-device", user_id=admin_user.id, name="Admin Device")
)
db_device_handler.add_device(
Device(id="editor-device", user_id=editor_user.id, name="Editor Device")
)
admin_response = client.get(
"/api/devices",
headers={"Authorization": f"Bearer {access_token}"},
)
assert admin_response.status_code == status.HTTP_200_OK
admin_devices = admin_response.json()
assert len(admin_devices) == 1
assert admin_devices[0]["name"] == "Admin Device"
editor_response = client.get(
"/api/devices",
headers={"Authorization": f"Bearer {editor_access_token}"},
)
assert editor_response.status_code == status.HTTP_200_OK
editor_devices = editor_response.json()
assert len(editor_devices) == 1
assert editor_devices[0]["name"] == "Editor Device"
def test_cannot_get_other_users_device(
self,
client,
editor_access_token: str,
admin_user: User,
):
device = db_device_handler.add_device(
Device(id="admin-only-device", user_id=admin_user.id, name="Admin Only")
)
response = client.get(
f"/api/devices/{device.id}",
headers={"Authorization": f"Bearer {editor_access_token}"},
)
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_cannot_update_other_users_device(
self,
client,
editor_access_token: str,
admin_user: User,
):
device = db_device_handler.add_device(
Device(id="admin-protected-device", user_id=admin_user.id, name="Protected")
)
response = client.put(
f"/api/devices/{device.id}",
json={"name": "Hacked Name"},
headers={"Authorization": f"Bearer {editor_access_token}"},
)
assert response.status_code == status.HTTP_404_NOT_FOUND
original = db_device_handler.get_device(
device_id=device.id, user_id=admin_user.id
)
assert original.name == "Protected"
def test_cannot_delete_other_users_device(
self,
client,
editor_access_token: str,
admin_user: User,
):
device = db_device_handler.add_device(
Device(id="admin-nodelete-device", user_id=admin_user.id, name="No Delete")
)
response = client.delete(
f"/api/devices/{device.id}",
headers={"Authorization": f"Bearer {editor_access_token}"},
)
assert response.status_code == status.HTTP_404_NOT_FOUND
still_exists = db_device_handler.get_device(
device_id=device.id, user_id=admin_user.id
)
assert still_exists is not None

View File

@@ -0,0 +1,834 @@
from datetime import timedelta
from io import BytesIO
from unittest import mock
import pytest
from fastapi import status
from fastapi.testclient import TestClient
from main import app
from endpoints.auth import ACCESS_TOKEN_EXPIRE_MINUTES
from handler.auth import oauth_handler
from handler.auth.constants import Scope
from handler.database import db_device_handler, db_device_save_sync_handler
from handler.redis_handler import sync_cache
from models.assets import Save
from models.device import Device
from models.platform import Platform
from models.rom import Rom
from models.user import User
@pytest.fixture
def client():
with TestClient(app) as client:
yield client
@pytest.fixture(autouse=True)
def clear_cache():
yield
sync_cache.flushall()
@pytest.fixture
def device(admin_user: User):
return db_device_handler.add_device(
Device(
id="test-sync-device",
user_id=admin_user.id,
name="Sync Test Device",
)
)
@pytest.fixture
def token_without_device_scopes(admin_user: User):
scopes = [
s
for s in admin_user.oauth_scopes
if s not in (Scope.DEVICES_READ, Scope.DEVICES_WRITE)
]
return oauth_handler.create_oauth_token(
data={
"sub": admin_user.username,
"iss": "romm:oauth",
"scopes": " ".join(scopes),
"type": "access",
},
expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES),
)
class TestSaveSyncEndpoints:
def test_get_saves_without_device_id(self, client, access_token: str, save: 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) == 1
assert data[0]["id"] == save.id
assert data[0]["device_syncs"] == []
def test_get_saves_with_device_id_no_sync(
self, client, access_token: str, save: Save, device: Device
):
response = client.get(
f"/api/saves?device_id={device.id}",
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert len(data) == 1
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
def test_get_saves_with_device_id_synced(
self, client, access_token: str, save: Save, device: Device
):
db_device_save_sync_handler.upsert_sync(device_id=device.id, save_id=save.id)
response = client.get(
f"/api/saves?device_id={device.id}",
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert len(data[0]["device_syncs"]) == 1
assert data[0]["device_syncs"][0]["is_untracked"] is False
def test_get_single_save_with_device_id(
self, client, access_token: str, save: Save, device: Device
):
response = client.get(
f"/api/saves/{save.id}?device_id={device.id}",
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["id"] == save.id
assert len(data["device_syncs"]) == 1
assert data["device_syncs"][0]["is_untracked"] is False
def test_track_save(self, client, access_token: str, save: Save, device: Device):
db_device_save_sync_handler.set_untracked(
device_id=device.id, save_id=save.id, untracked=True
)
response = client.post(
f"/api/saves/{save.id}/track",
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]["is_untracked"] is False
def test_untrack_save(self, client, access_token: str, save: Save, device: Device):
response = client.post(
f"/api/saves/{save.id}/untrack",
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]["is_untracked"] is True
def test_track_save_not_found(self, client, access_token: str, device: Device):
response = client.post(
"/api/saves/99999/track",
json={"device_id": device.id},
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_track_save_device_not_found(self, client, access_token: str, save: Save):
response = client.post(
f"/api/saves/{save.id}/track",
json={"device_id": "nonexistent-device"},
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_get_saves_with_invalid_device_id_returns_404(
self, client, access_token: str, save: Save
):
response = client.get(
"/api/saves?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"]
def test_get_saves_with_device_id_no_saves(
self, client, access_token: str, device: Device
):
"""Test empty save_ids path in get_syncs_for_device_and_saves."""
response = client.get(
f"/api/saves?device_id={device.id}&rom_id=99999",
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_200_OK
assert response.json() == []
def test_untrack_save_never_synced_creates_untracked_record(
self, client, access_token: str, save: Save, device: Device
):
"""Untracking a save that was never synced creates a new untracked record."""
sync = db_device_save_sync_handler.get_sync(
device_id=device.id, save_id=save.id
)
assert sync is None
response = client.post(
f"/api/saves/{save.id}/untrack",
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]["is_untracked"] is True
sync = db_device_save_sync_handler.get_sync(
device_id=device.id, save_id=save.id
)
assert sync is not None
assert sync.is_untracked is True
def test_track_save_never_synced_is_noop(
self, client, access_token: str, save: Save, device: Device
):
"""Tracking a save that was never synced doesn't create a DB record.
The response still includes a synthetic sync entry (is_untracked=False)
but no actual record is created in the database.
"""
sync = db_device_save_sync_handler.get_sync(
device_id=device.id, save_id=save.id
)
assert sync is None
response = client.post(
f"/api/saves/{save.id}/track",
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]["is_untracked"] is False
sync = db_device_save_sync_handler.get_sync(
device_id=device.id, save_id=save.id
)
assert sync is None
def test_get_single_save_with_invalid_device_id_returns_404(
self, client, access_token: str, save: Save
):
response = client.get(
f"/api/saves/{save.id}?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 TestSaveUploadWithSync:
@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_save_without_device_id(
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
data = response.json()
assert data["device_syncs"] == []
@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_save_with_device_id(
self,
mock_scan,
_mock_write,
client,
access_token: str,
rom: Rom,
platform: Platform,
admin_user: User,
device: Device,
):
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}&device_id={device.id}",
files={"saveFile": ("test.sav", file_content, "application/octet-stream")},
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
assert data["device_syncs"][0]["is_untracked"] is False
def test_upload_save_with_invalid_device_id_returns_404(
self,
client,
access_token: str,
rom: Rom,
):
file_content = BytesIO(b"test save data")
response = client.post(
f"/api/saves?rom_id={rom.id}&device_id=nonexistent-device",
files={"saveFile": ("test.sav", file_content, "application/octet-stream")},
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.write_file", new_callable=mock.AsyncMock
)
@mock.patch("endpoints.saves.scan_save", new_callable=mock.AsyncMock)
def test_upload_save_with_save_name(
self,
mock_scan,
_mock_write,
client,
access_token: str,
rom: Rom,
platform: Platform,
admin_user: User,
):
mock_save = Save(
file_name="slot1.sav",
file_name_no_tags="slot1",
file_name_no_ext="slot1",
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}&save_name=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"
class TestSaveConflictDetection:
@pytest.fixture
def device_b(self, admin_user: User):
return db_device_handler.add_device(
Device(
id="test-sync-device-b",
user_id=admin_user.id,
name="Device B",
)
)
@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_from_device_no_sync_exists(
self,
mock_scan,
_mock_write,
client,
access_token: str,
rom: Rom,
platform: Platform,
admin_user: User,
save: Save,
device: Device,
):
"""Scenario 1: First upload from device (no sync record exists) should succeed."""
mock_scan.return_value = save
file_content = BytesIO(b"save data from device")
response = client.post(
f"/api/saves?rom_id={rom.id}&device_id={device.id}",
files={
"saveFile": (save.file_name, file_content, "application/octet-stream")
},
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
@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_sync_equals_updated_at_no_conflict(
self,
mock_scan,
_mock_write,
client,
access_token: str,
rom: Rom,
platform: Platform,
admin_user: User,
save: Save,
device: Device,
):
"""Scenario 2: Device sync timestamp equals save.updated_at should succeed."""
db_device_save_sync_handler.upsert_sync(
device_id=device.id, save_id=save.id, synced_at=save.updated_at
)
mock_scan.return_value = save
file_content = BytesIO(b"updated save data")
response = client.post(
f"/api/saves?rom_id={rom.id}&device_id={device.id}",
files={
"saveFile": (save.file_name, 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_upload_without_device_id_always_succeeds(
self,
mock_scan,
_mock_write,
client,
access_token: str,
rom: Rom,
platform: Platform,
admin_user: User,
save: Save,
):
"""Scenario 3: Upload without device_id bypasses conflict detection."""
mock_scan.return_value = save
file_content = BytesIO(b"updated from web ui")
response = client.post(
f"/api/saves?rom_id={rom.id}",
files={
"saveFile": (save.file_name, file_content, "application/octet-stream")
},
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["device_syncs"] == []
@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_new_save_with_device_id_succeeds(
self,
mock_scan,
_mock_write,
client,
access_token: str,
rom: Rom,
platform: Platform,
admin_user: User,
device: Device,
):
"""Scenario 4: Creating a new save with device_id always succeeds."""
new_save = Save(
file_name="brand_new_save.sav",
file_name_no_tags="brand_new_save",
file_name_no_ext="brand_new_save",
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 = new_save
file_content = BytesIO(b"brand new save data")
response = client.post(
f"/api/saves?rom_id={rom.id}&device_id={device.id}",
files={
"saveFile": (
"brand_new_save.sav",
file_content,
"application/octet-stream",
)
},
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert len(data["device_syncs"]) == 1
@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_device_b_downloads_then_uploads_no_conflict(
self,
mock_scan,
_mock_write,
client,
access_token: str,
rom: Rom,
platform: Platform,
admin_user: User,
save: Save,
device: Device,
device_b: Device,
):
"""Scenario 5: Device A uploads, Device B downloads (syncs), Device B uploads.
Device B should succeed because it has the latest sync timestamp.
"""
db_device_save_sync_handler.upsert_sync(
device_id=device.id, save_id=save.id, synced_at=save.updated_at
)
db_device_save_sync_handler.upsert_sync(
device_id=device_b.id, save_id=save.id, synced_at=save.updated_at
)
mock_scan.return_value = save
file_content = BytesIO(b"save from device b after download")
response = client.post(
f"/api/saves?rom_id={rom.id}&device_id={device_b.id}",
files={
"saveFile": (save.file_name, file_content, "application/octet-stream")
},
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["device_syncs"][0]["device_id"] == device_b.id
@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_device_b_uploads_without_download_conflict(
self,
mock_scan,
_mock_write,
client,
access_token: str,
rom: Rom,
platform: Platform,
admin_user: User,
save: Save,
device: Device,
device_b: Device,
):
"""Scenario 6: Device A uploads, Device B uploads without downloading first.
Device B has an old sync from before Device A's upload, so conflict.
"""
from datetime import datetime, timedelta, timezone
old_sync_time = datetime.now(timezone.utc) - timedelta(hours=2)
db_device_save_sync_handler.upsert_sync(
device_id=device_b.id, save_id=save.id, synced_at=old_sync_time
)
db_device_save_sync_handler.upsert_sync(
device_id=device.id, save_id=save.id, synced_at=save.updated_at
)
mock_scan.return_value = save
file_content = BytesIO(b"stale save from device b")
response = client.post(
f"/api/saves?rom_id={rom.id}&device_id={device_b.id}",
files={
"saveFile": (save.file_name, file_content, "application/octet-stream")
},
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_409_CONFLICT
data = response.json()
assert data["detail"]["error"] == "conflict"
assert "device_sync_time" 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_web_ui_uploads_then_device_with_old_sync_conflict(
self,
mock_scan,
_mock_write,
client,
access_token: str,
rom: Rom,
platform: Platform,
admin_user: User,
save: Save,
device: Device,
):
"""Scenario 7: Web UI uploads (no device_id), device with old sync uploads.
Device A synced the save, then web UI uploaded a new version (without device_id).
Device A tries to upload without re-downloading - should conflict.
"""
from datetime import datetime, timedelta, timezone
old_sync_time = datetime.now(timezone.utc) - timedelta(hours=1)
db_device_save_sync_handler.upsert_sync(
device_id=device.id, save_id=save.id, synced_at=old_sync_time
)
mock_scan.return_value = save
file_content = BytesIO(b"stale save from device after web update")
response = client.post(
f"/api/saves?rom_id={rom.id}&device_id={device.id}",
files={
"saveFile": (save.file_name, file_content, "application/octet-stream")
},
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_409_CONFLICT
data = response.json()
assert data["detail"]["error"] == "conflict"
@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_conflict_bypassed_with_overwrite(
self,
mock_scan,
_mock_write,
client,
access_token: str,
rom: Rom,
platform: Platform,
admin_user: User,
save: Save,
device: Device,
):
"""Verify overwrite=true bypasses conflict detection."""
from datetime import datetime, timedelta, timezone
old_sync_time = datetime.now(timezone.utc) - timedelta(hours=1)
db_device_save_sync_handler.upsert_sync(
device_id=device.id, save_id=save.id, synced_at=old_sync_time
)
mock_scan.return_value = save
file_content = BytesIO(b"forced overwrite")
response = client.post(
f"/api/saves?rom_id={rom.id}&device_id={device.id}&overwrite=true",
files={
"saveFile": (save.file_name, 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_conflict_response_contains_details(
self,
mock_scan,
_mock_write,
client,
access_token: str,
rom: Rom,
platform: Platform,
admin_user: User,
save: Save,
device: Device,
):
"""Verify conflict response contains all necessary details for client handling."""
from datetime import datetime, timedelta, timezone
old_sync_time = datetime.now(timezone.utc) - timedelta(hours=1)
db_device_save_sync_handler.upsert_sync(
device_id=device.id, save_id=save.id, synced_at=old_sync_time
)
mock_scan.return_value = save
file_content = BytesIO(b"conflicting save")
response = client.post(
f"/api/saves?rom_id={rom.id}&device_id={device.id}",
files={
"saveFile": (save.file_name, file_content, "application/octet-stream")
},
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_409_CONFLICT
data = response.json()
detail = 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
class TestDeviceScopeEnforcement:
def test_get_saves_with_device_id_requires_scope(
self, client, token_without_device_scopes: str, save: Save, device: Device
):
response = client.get(
f"/api/saves?device_id={device.id}",
headers={"Authorization": f"Bearer {token_without_device_scopes}"},
)
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_get_single_save_with_device_id_requires_scope(
self, client, token_without_device_scopes: str, save: Save, device: Device
):
response = client.get(
f"/api/saves/{save.id}?device_id={device.id}",
headers={"Authorization": f"Bearer {token_without_device_scopes}"},
)
assert response.status_code == status.HTTP_403_FORBIDDEN
@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_save_with_device_id_requires_scope(
self,
mock_scan,
_mock_write,
client,
token_without_device_scopes: str,
rom: Rom,
platform: Platform,
admin_user: User,
device: Device,
):
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}&device_id={device.id}",
files={"saveFile": ("test.sav", file_content, "application/octet-stream")},
headers={"Authorization": f"Bearer {token_without_device_scopes}"},
)
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_track_save_requires_scope(
self, client, token_without_device_scopes: str, save: Save, device: Device
):
response = client.post(
f"/api/saves/{save.id}/track",
json={"device_id": device.id},
headers={"Authorization": f"Bearer {token_without_device_scopes}"},
)
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_untrack_save_requires_scope(
self, client, token_without_device_scopes: str, save: Save, device: Device
):
response = client.post(
f"/api/saves/{save.id}/untrack",
json={"device_id": device.id},
headers={"Authorization": f"Bearer {token_without_device_scopes}"},
)
assert response.status_code == status.HTTP_403_FORBIDDEN