Files
romm/backend/tests/endpoints/test_sync.py
nendo f1d44c3321 SaveSync: pair negotiate on (rom_id, slot), not tagged file_name
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.
2026-05-30 07:39:51 +09:00

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