mirror of
https://github.com/rommapp/romm.git
synced 2026-06-27 22:35:57 +00:00
The server datetime-tags every slot upload's filename (archival spec), so a slot accrues many rows and the stored file_name never equals the client's untagged canonical name. Keying negotiate's server-save map on file_name meant every client save missed -> perpetual "upload", and every tagged server row went unmatched -> perpetual "download", with save rows growing unbounded. Pair on (rom_id, slot), collapsing each slot to its newest row, so compare_save_state actually runs and content hashes decide the action. Tests: real upload->negotiate round-trip (lets _apply_datetime_tag run, client reports the untagged name) and a 3-device convergence test; both fail against the old file_name keying.
869 lines
32 KiB
Python
869 lines
32 KiB
Python
"""Tests for sync endpoints."""
|
|
|
|
import os
|
|
from datetime import datetime, timedelta, timezone
|
|
from io import BytesIO
|
|
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_save_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,
|
|
"slot": save.slot,
|
|
"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()
|
|
# Fixture save has no content_hash; hash-match no_op is covered by test_negotiate_matches_untagged_client_to_tagged_server_saves
|
|
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,
|
|
"slot": save.slot,
|
|
"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_excludes_archival_null_slot_saves(
|
|
self, client, access_token: str, admin_user: User, archival_save: Save
|
|
):
|
|
"""Null-slot saves (web-UI / archival uploads) must not appear in
|
|
negotiate plans.
|
|
|
|
Archival saves are pure backups; clients can opt in to import them
|
|
outside the sync flow. Surfacing them in negotiate as 'download'
|
|
produces phantom operations on every device that's never synced them.
|
|
"""
|
|
device = db_device_handler.add_device(
|
|
Device(id="neg-archival-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()
|
|
ops_for_archival = [
|
|
op for op in data["operations"] if op.get("save_id") == archival_save.id
|
|
]
|
|
assert ops_for_archival == [], (
|
|
f"Archival null-slot save unexpectedly surfaced in negotiate: "
|
|
f"{ops_for_archival}"
|
|
)
|
|
|
|
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_negotiate_matches_untagged_client_to_tagged_server_saves(
|
|
self, client, access_token: str, admin_user: User, rom: Rom, platform
|
|
):
|
|
"""Spec datetime-tags every slot upload, so a slot accrues many tagged rows and the client reports the untagged canonical name. Pairing must be by (rom_id, slot) on the newest row, else every negotiate yields upload+download forever."""
|
|
device = db_device_handler.add_device(
|
|
Device(id="neg-tagged-dev", user_id=admin_user.id, sync_enabled=True)
|
|
)
|
|
for tag in ("2026-01-01_00-00-00", "2026-02-02_00-00-00"):
|
|
db_save_handler.add_save(
|
|
Save(
|
|
rom_id=rom.id,
|
|
user_id=admin_user.id,
|
|
file_name=f"test_save [{tag}].sav",
|
|
file_name_no_tags="test_save",
|
|
file_name_no_ext=f"test_save [{tag}]",
|
|
file_extension="sav",
|
|
emulator="test_emulator",
|
|
slot="autosave",
|
|
content_hash="HASH_MATCH",
|
|
file_path=f"{platform.slug}/saves/test_emulator",
|
|
file_size_bytes=1.0,
|
|
)
|
|
)
|
|
|
|
response = client.post(
|
|
"/api/sync/negotiate",
|
|
json={
|
|
"device_id": device.id,
|
|
"saves": [
|
|
{
|
|
"rom_id": rom.id,
|
|
"file_name": "test_save.sav",
|
|
"slot": "autosave",
|
|
"content_hash": "HASH_MATCH",
|
|
"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()
|
|
assert data["total_upload"] == 0
|
|
assert data["total_download"] == 0
|
|
rom_ops = [op for op in data["operations"] if op["rom_id"] == rom.id]
|
|
assert len(rom_ops) == 1
|
|
assert rom_ops[0]["action"] == "no_op"
|
|
|
|
def _upload_autosave(
|
|
self, client, access_token, rom, *, filename, content_hash, device_id
|
|
):
|
|
"""Upload through the real add_save so _apply_datetime_tag runs; scan_save is mocked to echo the server-computed (tagged) file_name, never a hand-authored one."""
|
|
|
|
def make_scanned(*, file_name, user, platform_fs_slug, rom_id, emulator):
|
|
return Save(
|
|
file_name=file_name,
|
|
file_name_no_tags=file_name.split(" [")[0],
|
|
file_name_no_ext=os.path.splitext(file_name)[0],
|
|
file_extension="zip",
|
|
file_path=f"{platform_fs_slug}/saves/{emulator or ''}",
|
|
file_size_bytes=100,
|
|
content_hash=content_hash,
|
|
)
|
|
|
|
with mock.patch(
|
|
"endpoints.saves.fs_asset_handler.write_file", new_callable=mock.AsyncMock
|
|
), mock.patch(
|
|
"endpoints.saves.fs_asset_handler.remove_file", new_callable=mock.AsyncMock
|
|
), mock.patch(
|
|
"endpoints.saves.scan_save",
|
|
new=mock.AsyncMock(side_effect=make_scanned),
|
|
):
|
|
return client.post(
|
|
f"/api/saves?rom_id={rom.id}&slot=autosave&emulator=eden"
|
|
f"&device_id={device_id}",
|
|
files={
|
|
"saveFile": (
|
|
filename,
|
|
BytesIO(b"save bytes"),
|
|
"application/octet-stream",
|
|
)
|
|
},
|
|
headers={"Authorization": f"Bearer {access_token}"},
|
|
)
|
|
|
|
def _negotiate(self, client, access_token, device_id, saves):
|
|
resp = client.post(
|
|
"/api/sync/negotiate",
|
|
json={"device_id": device_id, "saves": saves},
|
|
headers={"Authorization": f"Bearer {access_token}"},
|
|
)
|
|
assert resp.status_code == status.HTTP_200_OK
|
|
return resp.json()
|
|
|
|
@staticmethod
|
|
def _autosave_entry(rom, content_hash):
|
|
return {
|
|
"rom_id": rom.id,
|
|
"file_name": "pokemon_violet.zip",
|
|
"slot": "autosave",
|
|
"content_hash": content_hash,
|
|
"updated_at": "2026-03-01T00:00:00Z",
|
|
"file_size_bytes": 100,
|
|
}
|
|
|
|
def test_save_upload_then_negotiate_converges(
|
|
self, client, access_token: str, admin_user: User, rom: Rom, platform
|
|
):
|
|
"""Full round-trip: upload tags the filename (real add_save), then the client reports its untagged canonical name. Must converge to no_op, not re-upload. This is the regression that hand-fed-filename mocks missed."""
|
|
device = db_device_handler.add_device(
|
|
Device(id="conv-rt-dev", user_id=admin_user.id, sync_enabled=True)
|
|
)
|
|
up = self._upload_autosave(
|
|
client,
|
|
access_token,
|
|
rom,
|
|
filename="pokemon_violet.zip",
|
|
content_hash="HASH_RT",
|
|
device_id=device.id,
|
|
)
|
|
assert up.status_code == status.HTTP_200_OK
|
|
stored = up.json()
|
|
assert stored["file_name"] != "pokemon_violet.zip"
|
|
assert " [" in stored["file_name"]
|
|
|
|
data = self._negotiate(
|
|
client, access_token, device.id, [self._autosave_entry(rom, "HASH_RT")]
|
|
)
|
|
assert data["total_upload"] == 0
|
|
assert data["total_download"] == 0
|
|
rom_ops = [op for op in data["operations"] if op["rom_id"] == rom.id]
|
|
assert len(rom_ops) == 1
|
|
assert rom_ops[0]["action"] == "no_op"
|
|
|
|
def test_three_device_sync_converges(
|
|
self, client, access_token: str, admin_user: User, rom: Rom, platform
|
|
):
|
|
"""A uploads; B and C each download exactly once then converge to no_op. The pre-fix tagged-filename keying made every device upload+download forever -- this is the 3-device scenario done with faithful (untagged client / tagged server) names."""
|
|
device_a = db_device_handler.add_device(
|
|
Device(id="conv-a", user_id=admin_user.id, sync_enabled=True)
|
|
)
|
|
up = self._upload_autosave(
|
|
client,
|
|
access_token,
|
|
rom,
|
|
filename="pokemon_violet.zip",
|
|
content_hash="HASH_3D",
|
|
device_id=device_a.id,
|
|
)
|
|
assert up.status_code == status.HTTP_200_OK
|
|
save_id = up.json()["id"]
|
|
|
|
a_data = self._negotiate(
|
|
client, access_token, device_a.id, [self._autosave_entry(rom, "HASH_3D")]
|
|
)
|
|
assert a_data["total_upload"] == 0
|
|
assert a_data["total_download"] == 0
|
|
|
|
for dev_id in ("conv-b", "conv-c"):
|
|
dev = db_device_handler.add_device(
|
|
Device(id=dev_id, user_id=admin_user.id, sync_enabled=True)
|
|
)
|
|
first = self._negotiate(client, access_token, dev.id, [])
|
|
downloads = [
|
|
op
|
|
for op in first["operations"]
|
|
if op["action"] == "download" and op["save_id"] == save_id
|
|
]
|
|
assert len(downloads) == 1
|
|
db_device_save_sync_handler.upsert_sync(
|
|
device_id=dev.id, save_id=save_id, synced_at=datetime.now(timezone.utc)
|
|
)
|
|
second = self._negotiate(
|
|
client, access_token, dev.id, [self._autosave_entry(rom, "HASH_3D")]
|
|
)
|
|
assert second["total_upload"] == 0
|
|
assert second["total_download"] == 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
|