mirror of
https://github.com/rommapp/romm.git
synced 2026-03-03 02:27:00 +00:00
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:
96
backend/alembic/versions/0065_save_sync.py
Normal file
96
backend/alembic/versions/0065_save_sync.py
Normal 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")
|
||||
@@ -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
143
backend/endpoints/device.py
Normal 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}")
|
||||
@@ -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):
|
||||
|
||||
42
backend/endpoints/responses/device.py
Normal file
42
backend/endpoints/responses/device.py
Normal 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
117
backend/handler/database/device_save_sync_handler.py
Normal file
117
backend/handler/database/device_save_sync_handler.py
Normal 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
|
||||
84
backend/handler/database/devices_handler.py
Normal file
84
backend/handler/database/devices_handler.py
Normal 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")
|
||||
)
|
||||
@@ -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")
|
||||
|
||||
@@ -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
49
backend/models/device.py
Normal 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",
|
||||
)
|
||||
34
backend/models/device_save_sync.py
Normal file
34
backend/models/device_save_sync.py
Normal 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")
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
281
backend/tests/endpoints/test_device.py
Normal file
281
backend/tests/endpoints/test_device.py
Normal 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
|
||||
834
backend/tests/endpoints/test_saves.py
Normal file
834
backend/tests/endpoints/test_saves.py
Normal 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
|
||||
Reference in New Issue
Block a user