Files
romm/backend/tests/endpoints/test_sync.py
T
2026-04-06 21:32:13 -04:00

654 lines
23 KiB
Python

"""Tests for sync endpoints."""
from datetime import datetime, timedelta, timezone
from unittest import mock
from fastapi import status
from handler.database import (
db_device_handler,
db_device_save_sync_handler,
db_play_session_handler,
db_sync_session_handler,
)
from models.assets import Save
from models.device import Device, SyncMode
from models.rom import Rom
from models.user import User
class TestSyncNegotiate:
def test_negotiate_new_client_save(
self, client, access_token: str, admin_user: User, rom: Rom
):
"""Client has a save the server doesn't -> upload."""
device = db_device_handler.add_device(
Device(id="neg-dev-1", user_id=admin_user.id, sync_enabled=True)
)
response = client.post(
"/api/sync/negotiate",
json={
"device_id": device.id,
"saves": [
{
"rom_id": rom.id,
"file_name": "new_save.sav",
"updated_at": "2026-01-10T00:00:00Z",
"file_size_bytes": 1024,
}
],
},
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["total_upload"] == 1
assert data["operations"][0]["action"] == "upload"
def test_negotiate_server_has_save_client_doesnt(
self, client, access_token: str, admin_user: User, save: Save
):
"""Server has a save the client doesn't mention -> download."""
device = db_device_handler.add_device(
Device(id="neg-dev-2", user_id=admin_user.id, sync_enabled=True)
)
response = client.post(
"/api/sync/negotiate",
json={
"device_id": device.id,
"saves": [],
},
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["total_download"] >= 1
def test_negotiate_identical_hashes(
self, client, access_token: str, admin_user: User, rom: Rom, save: Save
):
"""Matching hash -> no_op."""
device = db_device_handler.add_device(
Device(id="neg-dev-3", user_id=admin_user.id, sync_enabled=True)
)
response = client.post(
"/api/sync/negotiate",
json={
"device_id": device.id,
"saves": [
{
"rom_id": save.rom_id,
"file_name": save.file_name,
"content_hash": save.content_hash,
"updated_at": save.updated_at.isoformat(),
"file_size_bytes": 100,
}
],
},
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
# If save has a hash, should be no_op; otherwise download/upload by timestamp
assert "session_id" in data
def test_negotiate_device_not_found(self, client, access_token: str):
response = client.post(
"/api/sync/negotiate",
json={"device_id": "nonexistent", "saves": []},
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_negotiate_sync_disabled(self, client, access_token: str, admin_user: User):
device = db_device_handler.add_device(
Device(id="neg-dev-disabled", user_id=admin_user.id, sync_enabled=False)
)
response = client.post(
"/api/sync/negotiate",
json={"device_id": device.id, "saves": []},
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
def test_negotiate_creates_session(
self, client, access_token: str, admin_user: User
):
device = db_device_handler.add_device(
Device(id="neg-dev-session", user_id=admin_user.id, sync_enabled=True)
)
response = client.post(
"/api/sync/negotiate",
json={"device_id": device.id, "saves": []},
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert "session_id" in data
assert data["session_id"] > 0
class TestSyncSessions:
def test_complete_session(self, client, access_token: str, admin_user: User):
device = db_device_handler.add_device(
Device(id="session-dev-1", user_id=admin_user.id)
)
sync_session = db_sync_session_handler.create_session(
device_id=device.id, user_id=admin_user.id
)
response = client.post(
f"/api/sync/sessions/{sync_session.id}/complete",
json={"operations_completed": 5, "operations_failed": 1},
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["session"]["status"] == "COMPLETED"
assert data["session"]["operations_completed"] == 5
assert data["session"]["operations_failed"] == 1
assert data["play_session_ingest"] is None
def test_complete_session_not_found(self, client, access_token: str):
response = client.post(
"/api/sync/sessions/99999/complete",
json={"operations_completed": 0, "operations_failed": 0},
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_complete_already_completed_session(
self, client, access_token: str, admin_user: User
):
device = db_device_handler.add_device(
Device(id="session-dev-completed", user_id=admin_user.id)
)
sync_session = db_sync_session_handler.create_session(
device_id=device.id, user_id=admin_user.id
)
db_sync_session_handler.complete_session(session_id=sync_session.id)
response = client.post(
f"/api/sync/sessions/{sync_session.id}/complete",
json={"operations_completed": 0, "operations_failed": 0},
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
def test_list_sessions(self, client, access_token: str, admin_user: User):
device = db_device_handler.add_device(
Device(id="session-dev-list", user_id=admin_user.id)
)
db_sync_session_handler.create_session(
device_id=device.id, user_id=admin_user.id
)
db_sync_session_handler.create_session(
device_id=device.id, user_id=admin_user.id
)
response = client.get(
"/api/sync/sessions",
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert len(data) == 2
def test_list_sessions_filter_by_device(
self, client, access_token: str, admin_user: User
):
dev_a = db_device_handler.add_device(
Device(id="session-dev-a", user_id=admin_user.id)
)
dev_b = db_device_handler.add_device(
Device(id="session-dev-b", user_id=admin_user.id)
)
db_sync_session_handler.create_session(
device_id=dev_a.id, user_id=admin_user.id
)
db_sync_session_handler.create_session(
device_id=dev_b.id, user_id=admin_user.id
)
response = client.get(
f"/api/sync/sessions?device_id={dev_a.id}",
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert len(data) == 1
assert data[0]["device_id"] == dev_a.id
def test_get_session(self, client, access_token: str, admin_user: User):
device = db_device_handler.add_device(
Device(id="session-dev-get", user_id=admin_user.id)
)
sync_session = db_sync_session_handler.create_session(
device_id=device.id, user_id=admin_user.id
)
response = client.get(
f"/api/sync/sessions/{sync_session.id}",
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["id"] == sync_session.id
assert data["device_id"] == device.id
def test_get_session_not_found(self, client, access_token: str):
response = client.get(
"/api/sync/sessions/99999",
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_404_NOT_FOUND
class TestSyncUserIsolation:
def test_cannot_negotiate_with_other_users_device(
self, client, editor_access_token: str, admin_user: User
):
device = db_device_handler.add_device(
Device(id="admin-sync-dev", user_id=admin_user.id, sync_enabled=True)
)
response = client.post(
"/api/sync/negotiate",
json={"device_id": device.id, "saves": []},
headers={"Authorization": f"Bearer {editor_access_token}"},
)
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_cannot_complete_other_users_session(
self, client, editor_access_token: str, admin_user: User
):
device = db_device_handler.add_device(
Device(id="admin-session-dev", user_id=admin_user.id)
)
sync_session = db_sync_session_handler.create_session(
device_id=device.id, user_id=admin_user.id
)
response = client.post(
f"/api/sync/sessions/{sync_session.id}/complete",
json={"operations_completed": 0, "operations_failed": 0},
headers={"Authorization": f"Bearer {editor_access_token}"},
)
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_sessions_only_return_own(
self,
client,
access_token: str,
editor_access_token: str,
admin_user: User,
editor_user: User,
):
admin_dev = db_device_handler.add_device(
Device(id="admin-iso-dev", user_id=admin_user.id)
)
editor_dev = db_device_handler.add_device(
Device(id="editor-iso-dev", user_id=editor_user.id)
)
db_sync_session_handler.create_session(
device_id=admin_dev.id, user_id=admin_user.id
)
db_sync_session_handler.create_session(
device_id=editor_dev.id, user_id=editor_user.id
)
admin_resp = client.get(
"/api/sync/sessions",
headers={"Authorization": f"Bearer {access_token}"},
)
editor_resp = client.get(
"/api/sync/sessions",
headers={"Authorization": f"Bearer {editor_access_token}"},
)
assert len(admin_resp.json()) == 1
assert admin_resp.json()[0]["device_id"] == admin_dev.id
assert len(editor_resp.json()) == 1
assert editor_resp.json()[0]["device_id"] == editor_dev.id
class TestPushPullTrigger:
def test_trigger_push_pull(self, client, access_token: str, admin_user: User):
device = db_device_handler.add_device(
Device(
id="pp-dev-1",
user_id=admin_user.id,
sync_mode=SyncMode.PUSH_PULL,
sync_enabled=True,
)
)
with mock.patch("endpoints.sync.high_prio_queue") as mock_queue:
response = client.post(
f"/api/sync/devices/{device.id}/push-pull",
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["device_id"] == device.id
assert data["status"] == "PENDING"
mock_queue.enqueue.assert_called_once()
def test_trigger_push_pull_wrong_mode(
self, client, access_token: str, admin_user: User
):
device = db_device_handler.add_device(
Device(
id="pp-dev-wrong-mode",
user_id=admin_user.id,
sync_mode=SyncMode.API,
sync_enabled=True,
)
)
response = client.post(
f"/api/sync/devices/{device.id}/push-pull",
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
def test_trigger_push_pull_sync_disabled(
self, client, access_token: str, admin_user: User
):
device = db_device_handler.add_device(
Device(
id="pp-dev-disabled",
user_id=admin_user.id,
sync_mode=SyncMode.PUSH_PULL,
sync_enabled=False,
)
)
response = client.post(
f"/api/sync/devices/{device.id}/push-pull",
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
def test_trigger_push_pull_device_not_found(self, client, access_token: str):
response = client.post(
"/api/sync/devices/nonexistent/push-pull",
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_trigger_push_pull_passes_session_id(
self, client, access_token: str, admin_user: User
):
device = db_device_handler.add_device(
Device(
id="pp-dev-sid",
user_id=admin_user.id,
sync_mode=SyncMode.PUSH_PULL,
sync_enabled=True,
)
)
with mock.patch("endpoints.sync.high_prio_queue") as mock_queue:
response = client.post(
f"/api/sync/devices/{device.id}/push-pull",
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_200_OK
call_kwargs = mock_queue.enqueue.call_args
assert "session_id" in call_kwargs.kwargs
class TestNegotiateAdvanced:
def test_negotiate_untracked_save_returns_noop(
self, client, access_token: str, admin_user: User, save: Save
):
device = db_device_handler.add_device(
Device(id="neg-untrack-dev", user_id=admin_user.id, sync_enabled=True)
)
db_device_save_sync_handler.set_untracked(
device_id=device.id, save_id=save.id, untracked=True
)
response = client.post(
"/api/sync/negotiate",
json={
"device_id": device.id,
"saves": [
{
"rom_id": save.rom_id,
"file_name": save.file_name,
"content_hash": "different_hash",
"updated_at": "2026-03-01T00:00:00Z",
"file_size_bytes": 100,
}
],
},
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
noop_ops = [op for op in data["operations"] if op["action"] == "no_op"]
assert len(noop_ops) >= 1
def test_negotiate_server_save_not_mentioned_by_client(
self, client, access_token: str, admin_user: User, save: Save
):
device = db_device_handler.add_device(
Device(id="neg-miss-dev", user_id=admin_user.id, sync_enabled=True)
)
response = client.post(
"/api/sync/negotiate",
json={"device_id": device.id, "saves": []},
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
download_ops = [op for op in data["operations"] if op["action"] == "download"]
assert len(download_ops) >= 1
assert any(op["save_id"] == save.id for op in download_ops)
def test_negotiate_deleted_by_client_skipped(
self, client, access_token: str, admin_user: User, save: Save
):
device = db_device_handler.add_device(
Device(id="neg-del-dev", user_id=admin_user.id, sync_enabled=True)
)
db_device_save_sync_handler.upsert_sync(
device_id=device.id,
save_id=save.id,
synced_at=datetime.now(timezone.utc),
)
response = client.post(
"/api/sync/negotiate",
json={"device_id": device.id, "saves": []},
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
ops_for_save = [op for op in data["operations"] if op.get("save_id") == save.id]
assert len(ops_for_save) == 0
def test_complete_failed_session_rejected(
self, client, access_token: str, admin_user: User
):
device = db_device_handler.add_device(
Device(id="sess-failed-dev", user_id=admin_user.id)
)
sync_session = db_sync_session_handler.create_session(
device_id=device.id, user_id=admin_user.id
)
db_sync_session_handler.fail_session(sync_session.id, error_message="test")
response = client.post(
f"/api/sync/sessions/{sync_session.id}/complete",
json={"operations_completed": 0, "operations_failed": 0},
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
def test_complete_cancelled_session_rejected(
self, client, access_token: str, admin_user: User
):
device = db_device_handler.add_device(
Device(id="sess-cancel-dev", user_id=admin_user.id)
)
db_sync_session_handler.create_session(
device_id=device.id, user_id=admin_user.id
)
db_sync_session_handler.cancel_active_sessions(device.id, admin_user.id)
sessions = db_sync_session_handler.get_sessions(
admin_user.id, device_id=device.id
)
cancelled = sessions[0]
response = client.post(
f"/api/sync/sessions/{cancelled.id}/complete",
json={"operations_completed": 0, "operations_failed": 0},
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
def _play_session(rom_id=None, start_offset_hours=-1, duration_minutes=30):
now = datetime.now(timezone.utc)
start = now + timedelta(hours=start_offset_hours)
end = start + timedelta(minutes=duration_minutes)
return {
"rom_id": rom_id,
"start_time": start.isoformat(),
"end_time": end.isoformat(),
"duration_ms": duration_minutes * 60 * 1000,
}
class TestSyncCompleteWithPlaySessions:
def test_complete_with_play_sessions(
self, client, access_token: str, admin_user: User, rom: Rom
):
device = db_device_handler.add_device(
Device(id="sync-ps-dev-1", user_id=admin_user.id)
)
sync_session = db_sync_session_handler.create_session(
device_id=device.id, user_id=admin_user.id
)
response = client.post(
f"/api/sync/sessions/{sync_session.id}/complete",
json={
"operations_completed": 1,
"operations_failed": 0,
"play_sessions": [
_play_session(rom_id=rom.id, start_offset_hours=-2),
_play_session(rom_id=rom.id, start_offset_hours=-4),
],
},
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["session"]["status"] == "COMPLETED"
assert data["play_session_ingest"] is not None
assert data["play_session_ingest"]["created_count"] == 2
assert data["play_session_ingest"]["skipped_count"] == 0
def test_play_sessions_have_sync_session_id(
self, client, access_token: str, admin_user: User, rom: Rom
):
device = db_device_handler.add_device(
Device(id="sync-ps-dev-2", user_id=admin_user.id)
)
sync_session = db_sync_session_handler.create_session(
device_id=device.id, user_id=admin_user.id
)
client.post(
f"/api/sync/sessions/{sync_session.id}/complete",
json={
"operations_completed": 0,
"operations_failed": 0,
"play_sessions": [
_play_session(rom_id=rom.id, start_offset_hours=-3),
],
},
headers={"Authorization": f"Bearer {access_token}"},
)
sessions = db_play_session_handler.get_sessions(
user_id=admin_user.id, rom_id=rom.id
)
assert len(sessions) >= 1
linked = [s for s in sessions if s.sync_session_id == sync_session.id]
assert len(linked) == 1
def test_play_sessions_use_device_from_sync_session(
self, client, access_token: str, admin_user: User, rom: Rom
):
device = db_device_handler.add_device(
Device(id="sync-ps-dev-3", user_id=admin_user.id)
)
sync_session = db_sync_session_handler.create_session(
device_id=device.id, user_id=admin_user.id
)
client.post(
f"/api/sync/sessions/{sync_session.id}/complete",
json={
"operations_completed": 0,
"operations_failed": 0,
"play_sessions": [
_play_session(rom_id=rom.id, start_offset_hours=-5),
],
},
headers={"Authorization": f"Bearer {access_token}"},
)
sessions = db_play_session_handler.get_sessions(
user_id=admin_user.id, rom_id=rom.id
)
assert len(sessions) >= 1
assert sessions[0].device_id == device.id
def test_complete_without_play_sessions_backward_compatible(
self, client, access_token: str, admin_user: User
):
device = db_device_handler.add_device(
Device(id="sync-ps-dev-4", user_id=admin_user.id)
)
sync_session = db_sync_session_handler.create_session(
device_id=device.id, user_id=admin_user.id
)
response = client.post(
f"/api/sync/sessions/{sync_session.id}/complete",
json={"operations_completed": 3, "operations_failed": 0},
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["session"]["status"] == "COMPLETED"
assert data["play_session_ingest"] is None