import asyncio from datetime import timedelta from pathlib import Path from uuid import UUID import pytest from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from backend.models import Attachment, BackupImportEntity from tests.test_backup_v2 import _preflight from tests.test_mvp_backend import boot async def _attachment_state(attachment_id: str) -> tuple[Attachment, int]: from backend.db import get_engine async with AsyncSession(get_engine()) as db: attachment = await db.get(Attachment, UUID(attachment_id)) ledger_count = await db.scalar( select(func.count()).select_from(BackupImportEntity).where( BackupImportEntity.entity_type == "attachments", BackupImportEntity.source_id == UUID(attachment_id), ) ) return attachment, ledger_count or 0 @pytest.mark.parametrize("damage", ["missing", "corrupt"]) def test_merge_existing_attachment_without_ledger_never_certifies_bad_file( client, tmp_path, damage ): from backend.config import get_settings inbox = boot(client) root = tmp_path / "attachments" get_settings().attachment_dir = str(root) task = client.post( "/api/v1/tasks", json={"title": "attachment collision", "list_id": inbox["id"]} ).json() payload = b"trusted attachment bytes" attachment = client.post( f"/api/v1/tasks/{task['id']}/attachments", files={"file": ("proof.txt", payload, "text/plain")}, ).json() archive = client.get("/api/v1/backup/export.zip").content row, ledger_count = asyncio.run(_attachment_state(attachment["id"])) assert ledger_count == 0 stored = root / row.storage_name if damage == "missing": stored.unlink() else: stored.write_bytes(b"x" * len(payload)) token = _preflight(client, archive, "merge").json()["preflight_token"] response = client.post( "/api/v1/backup/restore", json={"preflight_token": token, "mode": "merge"}, ) assert response.status_code == 409 assert response.json()["detail"]["code"] in { "backup_entity_missing", "backup_entity_conflict", } _, ledger_count = asyncio.run(_attachment_state(attachment["id"])) assert ledger_count == 0 def test_merge_existing_attachment_without_ledger_hashes_equal_bytes_before_ledger( client, tmp_path ): from backend.config import get_settings inbox = boot(client) root = tmp_path / "attachments" get_settings().attachment_dir = str(root) task = client.post( "/api/v1/tasks", json={"title": "attachment collision", "list_id": inbox["id"]} ).json() payload = b"trusted attachment bytes" attachment = client.post( f"/api/v1/tasks/{task['id']}/attachments", files={"file": ("proof.txt", payload, "text/plain")}, ).json() archive = client.get("/api/v1/backup/export.zip").content token = _preflight(client, archive, "merge").json()["preflight_token"] response = client.post( "/api/v1/backup/restore", json={"preflight_token": token, "mode": "merge"}, ) assert response.status_code == 200, response.text row, ledger_count = asyncio.run(_attachment_state(attachment["id"])) assert ledger_count == 1 assert Path(root / row.storage_name).read_bytes() == payload async def _business_counts() -> tuple[int, ...]: from backend.backup.service import ENTITY_MODELS from backend.db import get_engine async with AsyncSession(get_engine()) as db: counts = [] for model in ENTITY_MODELS.values(): counts.append((await db.scalar(select(func.count()).select_from(model))) or 0) return tuple(counts) def _base_graph() -> dict[str, list[dict]]: list_id = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" task_id = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" recurrence_id = "cccccccc-cccc-4ccc-8ccc-cccccccccccc" return { "lists": [{"id": list_id, "name": "Inbox", "is_inbox": True, "position": 0}], "tasks": [{ "id": task_id, "list_id": list_id, "parent_id": None, "title": "valid task", "description": "", "priority": 0, "completed": False, "completed_at": None, "due_at": "2026-09-20T08:00:00+00:00", "due_has_time": True, "version": 1, "position": 0, "external_id": None, }], "recurrences": [{ "id": recurrence_id, "task_id": task_id, "rrule": "FREQ=WEEKLY;BYDAY=MO", "starts_at": "2026-09-20T08:00:00+00:00", "ends_at": None, "trigger_mode": "scheduled", "after_completion_days": None, "last_completed_at": None, }], } @pytest.mark.parametrize( ("mutate", "label"), [ (lambda graph: graph["recurrences"][0].update(trigger_mode="after_completion", rrule=None, after_completion_days=None), "after completion days required"), (lambda graph: graph["recurrences"][0].update(trigger_mode="after_completion", rrule="FREQ=DAILY", after_completion_days=1), "after completion excludes rrule"), (lambda graph: graph["recurrences"][0].update(trigger_mode="after_completion", rrule=None, after_completion_days=0), "after completion range"), (lambda graph: graph["recurrences"][0].update(trigger_mode="scheduled", rrule=None, after_completion_days=None), "scheduled requires rrule"), (lambda graph: graph["recurrences"][0].update(trigger_mode="scheduled", after_completion_days=1), "scheduled excludes days"), (lambda graph: graph["recurrences"][0].update(rrule="FREQ=NOPE"), "rrule parses"), (lambda graph: graph["recurrences"][0].update(ends_at="2026-09-19T08:00:00+00:00"), "ends after starts"), (lambda graph: graph["recurrences"][0].update(last_completed_at="2026-09-21T08:00:00+00:00"), "last completion before start"), (lambda graph: graph["tasks"][0].update(due_at=None, due_has_time=False), "recurring task has due"), (lambda graph: graph["tasks"][0].update(parent_id="dddddddd-dddd-4ddd-8ddd-dddddddddddd"), "recurring task top level"), ], ) def test_recurrence_preflight_rejects_invalid_contract_without_business_writes(client, mutate, label): boot(client) graph = _base_graph() if label == "recurring task top level": graph["tasks"].append({ **graph["tasks"][0], "id": "dddddddd-dddd-4ddd-8ddd-dddddddddddd", "title": "parent", "parent_id": None, }) mutate(graph) before = asyncio.run(_business_counts()) content = _make_archive_from_graph(graph) response = _preflight(client, content, "replace") assert response.status_code == 422, (label, response.text) assert response.json()["detail"]["code"] == "backup_recurrence_invalid" assert asyncio.run(_business_counts()) == before def _make_archive_from_graph( graph: dict[str, list[dict]], files: dict[str, bytes] | None = None, *, backup_id: str = "11111111-1111-4111-8111-111111111111", ) -> bytes: from tests.test_backup_v2 import _make_zip entries = { f"data/{entity}.json": __import__("json").dumps(rows).encode() for entity, rows in graph.items() } entries.update(files or {}) return _make_zip(entries, backup_id=backup_id) def _task_tree_graph(parent_ids: list[str | None]) -> dict[str, list[dict]]: graph = _base_graph() graph["recurrences"] = [] template = graph["tasks"][0] task_ids = [ "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", "cccccccc-cccc-4ccc-8ccc-cccccccccccc", "dddddddd-dddd-4ddd-8ddd-dddddddddddd", ] graph["tasks"] = [ { **template, "id": task_id, "parent_id": parent_id, "title": f"tree task {index}", "position": index, } for index, (task_id, parent_id) in enumerate(zip(task_ids, parent_ids, strict=True)) ] return graph def test_preflight_rejects_three_level_task_tree_without_business_writes(client): boot(client) graph = _task_tree_graph([ None, "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", "cccccccc-cccc-4ccc-8ccc-cccccccccccc", ]) before = asyncio.run(_business_counts()) response = _preflight(client, _make_archive_from_graph(graph), "replace") assert response.status_code == 422, response.text assert response.json()["detail"]["code"] == "backup_constraint_invalid" assert asyncio.run(_business_counts()) == before def test_parent_with_multiple_children_round_trips_in_arbitrary_zip_order(client): boot(client) parent_id = "cccccccc-cccc-4ccc-8ccc-cccccccccccc" graph = _task_tree_graph([parent_id, None, parent_id]) graph["tasks"] = [graph["tasks"][2], graph["tasks"][1], graph["tasks"][0]] preflight = _preflight(client, _make_archive_from_graph(graph), "replace") assert preflight.status_code == 200, preflight.text restored = client.post( "/api/v1/backup/restore", json={"preflight_token": preflight.json()["preflight_token"], "mode": "replace"}, ) assert restored.status_code == 200, restored.text from tests.test_backup_v2 import _archive_rows rows = _archive_rows(client.get("/api/v1/backup/export.zip").content, "tasks") by_id = {row["id"]: row for row in rows} assert by_id[parent_id]["parent_id"] is None assert { row["id"] for row in rows if row["parent_id"] == parent_id } == { "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", "dddddddd-dddd-4ddd-8ddd-dddddddddddd", } items = client.get("/api/v1/tasks", params={"limit": 100}).json()["items"] restored_parent = next(item for item in items if item["id"] == parent_id) assert {child["id"] for child in restored_parent["subtasks"]} == { "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", "dddddddd-dddd-4ddd-8ddd-dddddddddddd", } @pytest.mark.parametrize( "recurrence", [ { "rrule": "FREQ=MONTHLY;BYMONTHDAY=1,15;COUNT=8", "trigger_mode": "scheduled", "after_completion_days": None, }, { "rrule": None, "trigger_mode": "after_completion", "after_completion_days": 30, }, ], ) def test_both_recurrence_modes_round_trip_through_replace(client, recurrence): boot(client) graph = _base_graph() graph["recurrences"][0].update(recurrence) content = _make_archive_from_graph(graph) preflight = _preflight(client, content, "replace") assert preflight.status_code == 200, preflight.text restored = client.post( "/api/v1/backup/restore", json={"preflight_token": preflight.json()["preflight_token"], "mode": "replace"}, ) assert restored.status_code == 200, restored.text exported = client.get("/api/v1/backup/export.zip").content from tests.test_backup_v2 import _archive_rows row = _archive_rows(exported, "recurrences")[0] assert row["rrule"] == recurrence["rrule"] assert row["trigger_mode"] == recurrence["trigger_mode"] assert row["after_completion_days"] == recurrence["after_completion_days"] def _entity_case(entity: str, row: dict, files: dict[str, bytes] | None = None) -> bytes: graph = _base_graph() graph[entity] = [row] return _make_archive_from_graph(graph, files) @pytest.mark.parametrize( ("entity", "row", "files"), [ ("folders", {"id": "10101010-1010-4010-8010-101010101010", "name": " ", "position": 0}, None), ("folders", {"id": "10101010-1010-4010-8010-101010101010", "name": "x" * 121, "position": 0}, None), ("folders", {"id": "10101010-1010-4010-8010-101010101010", "name": "x", "position": -1}, None), ("lists", {"id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", "name": "x" * 121, "is_inbox": True, "position": 0}, None), ("lists", {"id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", "name": "Inbox", "is_inbox": True, "position": -1}, None), ("tasks", {**_base_graph()["tasks"][0], "title": " "}, None), ("tasks", {**_base_graph()["tasks"][0], "priority": 4}, None), ("tasks", {**_base_graph()["tasks"][0], "version": 0}, None), ("tasks", {**_base_graph()["tasks"][0], "position": -1}, None), ("tasks", {**_base_graph()["tasks"][0], "due_at": None, "due_has_time": True}, None), ("tasks", {**_base_graph()["tasks"][0], "completed": False, "completed_at": "2026-09-20T08:00:00+00:00"}, None), ("tasks", {**_base_graph()["tasks"][0], "created_at": "2026-09-21T08:00:00+00:00", "updated_at": "2026-09-20T08:00:00+00:00"}, None), ("countdowns", {"id": "20202020-2020-4020-8020-202020202020", "title": "x", "event_date": "2026-09-20", "calendar_mode": "bad", "lunar_month": None, "lunar_day": None, "ignore_year": False, "kind": "countdown", "repeat_rule": "none", "icon": "x", "pinned": False}, None), ("countdowns", {"id": "20202020-2020-4020-8020-202020202020", "title": "x", "event_date": "2026-09-20", "calendar_mode": "solar", "lunar_month": 1, "lunar_day": 1, "ignore_year": False, "kind": "countdown", "repeat_rule": "none", "icon": "x", "pinned": False}, None), ("memos", {"id": "30303030-3030-4030-8030-303030303030", "title": " ", "content": "", "version": 1}, None), ("memos", {"id": "30303030-3030-4030-8030-303030303030", "title": "x", "content": "", "version": 0}, None), ("memos", {"id": "30303030-3030-4030-8030-303030303030", "title": "x", "content": "", "version": 1, "created_at": "2026-09-21T08:00:00+00:00", "updated_at": "2026-09-20T08:00:00+00:00"}, None), ("attachments", {"id": "40404040-4040-4040-8040-404040404040", "task_id": "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", "filename": "x" * 256, "mime_type": "text/plain", "size": 1, "archive_path": "attachments/40404040-4040-4040-8040-404040404040/content"}, {"attachments/40404040-4040-4040-8040-404040404040/content": b"x"}), ("attachments", {"id": "40404040-4040-4040-8040-404040404040", "task_id": "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", "filename": "x", "mime_type": "application/x-danger", "size": 1, "archive_path": "attachments/40404040-4040-4040-8040-404040404040/content"}, {"attachments/40404040-4040-4040-8040-404040404040/content": b"x"}), ("attachments", {"id": "40404040-4040-4040-8040-404040404040", "task_id": "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", "filename": "x", "mime_type": "text/plain", "size": -1, "archive_path": "attachments/40404040-4040-4040-8040-404040404040/content"}, {"attachments/40404040-4040-4040-8040-404040404040/content": b"x"}), ], ) def test_all_entity_contracts_fail_preflight_without_business_writes(client, entity, row, files): boot(client) before = asyncio.run(_business_counts()) response = _preflight(client, _entity_case(entity, row, files), "replace") assert response.status_code == 422, (entity, response.text) assert asyncio.run(_business_counts()) == before def test_legacy_replace_is_rejected_without_mutating_data(client): inbox = boot(client) task = client.post( "/api/v1/tasks", json={"title": "must survive", "list_id": inbox["id"]} ).json() payload = client.get("/api/v1/export").json() response = client.post("/api/v1/restore?mode=replace", json=payload) assert response.status_code == 422 assert response.json()["detail"] == { "code": "legacy_replace_unsupported", "message": "旧版备份仅支持合并恢复", } assert client.get(f"/api/v1/tasks/{task['id']}").status_code == 200 def test_legacy_csv_upload_has_streaming_hard_limit(client, monkeypatch): boot(client) from backend import mvp monkeypatch.setattr(mvp, "LEGACY_BACKUP_MAX_BYTES", 32) response = client.post( "/api/v1/restore.csv?mode=merge", files={"file": ("backup.csv", b"entity,data\n" + b"x" * 33, "text/csv")}, ) assert response.status_code == 422 assert response.json()["detail"] == { "code": "legacy_backup_too_large", "message": "旧版备份文件过大", } def test_legacy_json_rejects_excessive_records(client, monkeypatch): boot(client) from backend import mvp monkeypatch.setattr(mvp, "LEGACY_BACKUP_MAX_RECORDS", 1) payload = {"version": 1, "folders": [], "lists": [], "tasks": [ {"id": "11111111-1111-4111-8111-111111111111"}, {"id": "22222222-2222-4222-8222-222222222222"}, ]} response = client.post("/api/v1/restore?mode=merge", json=payload) assert response.status_code == 422 assert response.json()["detail"]["code"] == "legacy_backup_too_many_records" def test_v2_preflight_rejects_multiple_active_pinned_countdowns(client): boot(client) graph = _base_graph() graph["recurrences"] = [] graph["countdowns"] = [ { "id": f"{index:08d}-2020-4020-8020-202020202020", "title": f"pinned {index}", "event_date": "2026-09-20", "calendar_mode": "solar", "lunar_month": None, "lunar_day": None, "ignore_year": False, "kind": "countdown", "repeat_rule": "none", "icon": "x", "pinned": True, "archived_at": None, } for index in (1, 2) ] response = _preflight(client, _make_archive_from_graph(graph), "merge") assert response.status_code == 422 assert response.json()["detail"]["code"] == "backup_constraint_invalid" def test_reaper_removes_failed_and_stranded_staging_and_repairs_cleanup(client, tmp_path): boot(client) from backend.backup.router import _prune from backend.config import get_settings from backend.db import get_engine from backend.models import BackupPreflight, utcnow staging_root = tmp_path / "staging" staging_root.mkdir() get_settings().backup_staging_dir = str(staging_root) failed_file = staging_root / "failed.zip" consuming_file = staging_root / "consuming.zip" failed_file.write_bytes(b"failed") consuming_file.write_bytes(b"consuming") cleanup = tmp_path / "cleanup" cleanup.mkdir() (cleanup / "old").write_bytes(b"old") async def exercise(): async with AsyncSession(get_engine()) as db: user_id = await db.scalar(select(__import__("backend.models", fromlist=["User"]).User.id)) now = utcnow() - timedelta(hours=1) rows = [ BackupPreflight( token_hash=str(index) * 64, user_id=user_id, backup_id=UUID(f"00000000-0000-4000-8000-00000000000{index}"), archive_sha256="0" * 64, archive_size=10, staging_path=str(path), mode="merge", status=status, expires_at=now, consumed_at=now, cleanup_path=str(cleanup) if status == "cleanup_pending" else None, ) for index, (status, path) in enumerate( [("failed", failed_file), ("consuming", consuming_file), ("cleanup_pending", staging_root / "cleanup.zip")], start=1 ) ] db.add_all(rows) await db.flush() row_ids = [row.id for row in rows] await db.commit() await _prune(db) statuses = { str(row_id): await db.scalar( select(BackupPreflight.status).where(BackupPreflight.id == row_id) ) for row_id in row_ids } return row_ids, statuses row_ids, statuses = asyncio.run(exercise()) assert not failed_file.exists() assert not consuming_file.exists() assert not cleanup.exists() assert statuses[str(row_ids[2])] == "consumed" def test_expired_repair_pending_is_repaired_not_deleted_with_quarantine(client, tmp_path): boot(client) from backend.backup.router import _prune from backend.config import get_settings from backend.db import get_engine from backend.models import BackupPreflight, User, utcnow staging_root = tmp_path / "staging" attachment_root = tmp_path / "attachments" quarantine = tmp_path / "quarantine" staging_root.mkdir() attachment_root.mkdir() quarantine.mkdir() staging = staging_root / "repair.zip" staging.write_bytes(b"staged") (quarantine / "restored.bin").write_bytes(b"original") get_settings().backup_staging_dir = str(staging_root) get_settings().attachment_dir = str(attachment_root) async def exercise(): async with AsyncSession(get_engine()) as db: user_id = await db.scalar(select(User.id)) row = BackupPreflight( token_hash="9" * 64, user_id=user_id, backup_id=UUID("99999999-9999-4999-8999-999999999999"), archive_sha256="0" * 64, archive_size=7, staging_path=str(staging), mode="replace", status="repair_pending", expires_at=utcnow() - timedelta(hours=1), cleanup_path=str(quarantine), ) db.add(row) await db.flush() row_id = row.id await db.commit() await _prune(db) repaired = await db.get(BackupPreflight, row_id) return repaired.status, repaired.cleanup_path status, cleanup_path = asyncio.run(exercise()) assert (attachment_root / "restored.bin").read_bytes() == b"original" assert not quarantine.exists() assert not staging.exists() assert status == "failed" assert cleanup_path is None def test_repair_pending_counts_against_preflight_quota(client, tmp_path, monkeypatch): boot(client) import importlib from backend.config import get_settings from backend.db import get_engine from backend.models import BackupPreflight, User, utcnow router_module = importlib.import_module("backend.backup.router") monkeypatch.setattr(router_module, "MAX_PENDING_PREFLIGHTS_PER_USER", 1) staging_root = tmp_path / "staging" staging_root.mkdir() get_settings().backup_staging_dir = str(staging_root) staged = staging_root / "pending.zip" staged.write_bytes(b"pending") async def seed(): async with AsyncSession(get_engine()) as db: user_id = await db.scalar(select(User.id)) db.add(BackupPreflight( token_hash="8" * 64, user_id=user_id, backup_id=UUID("88888888-8888-4888-8888-888888888888"), archive_sha256="0" * 64, archive_size=7, staging_path=str(staged), mode="replace", status="repair_pending", expires_at=utcnow() + timedelta(hours=1), cleanup_path=str(tmp_path / "quarantine"), )) await db.commit() asyncio.run(seed()) content = client.get("/api/v1/backup/export.zip").content response = _preflight(client, content, "merge") assert response.status_code == 429 assert response.json()["detail"]["code"] == "backup_preflight_quota" def test_repair_pending_exception_survives_router_and_same_token_only_repairs( client, tmp_path, monkeypatch ): inbox = boot(client) import importlib backup_router = importlib.import_module("backend.backup.router") from backend.backup import service from backend.config import get_settings from backend.db import get_engine from backend.models import Attachment, BackupPreflight root = tmp_path / "attachments" get_settings().attachment_dir = str(root) task = client.post("/api/v1/tasks", json={"title": "old", "list_id": inbox["id"]}).json() uploaded = client.post( f"/api/v1/tasks/{task['id']}/attachments", files={"file": ("old.txt", b"old", "text/plain")}, ).json() content = client.get("/api/v1/backup/export.zip").content token = _preflight(client, content, "replace").json()["preflight_token"] real_restore = service.restore_quarantine monkeypatch.setattr(service, "contained_file", lambda *_: (_ for _ in ()).throw(OSError("write"))) monkeypatch.setattr(service, "restore_quarantine", lambda *_: (_ for _ in ()).throw(OSError("repair"))) response = client.post( "/api/v1/backup/restore", json={"preflight_token": token, "mode": "replace"} ) assert response.status_code == 500 assert response.json()["detail"]["code"] == "backup_repair_pending" async def state(): async with AsyncSession(get_engine()) as db: row = await db.scalar(select(BackupPreflight).where(BackupPreflight.status == "repair_pending")) attachment = await db.get(Attachment, UUID(uploaded["id"])) return row.status, row.cleanup_path, root / attachment.storage_name status, cleanup_path, old_path = asyncio.run(state()) assert status == "repair_pending" assert cleanup_path assert not old_path.exists() monkeypatch.setattr(service, "restore_quarantine", real_restore) monkeypatch.setattr( backup_router, "restore_v2", lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("DB restore repeated")), ) retried = client.post( "/api/v1/backup/restore", json={"preflight_token": token, "mode": "replace"} ) assert retried.status_code == 409 assert retried.json()["detail"]["code"] == "backup_restore_retry" assert old_path.read_bytes() == b"old" async def repaired_state(): async with AsyncSession(get_engine()) as db: row = await db.scalar(select(BackupPreflight).where(BackupPreflight.token_hash.is_not(None))) return row.status, row.cleanup_path assert asyncio.run(repaired_state()) == ("failed", None) def test_merge_preflight_rejects_archive_pin_when_user_has_different_active_pin(client): boot(client) existing = client.post( "/api/v1/countdowns", json={"title": "existing pin", "event_date": "2026-09-20", "pinned": True}, ).json() graph = _base_graph() graph["recurrences"] = [] graph["countdowns"] = [{ "id": "77777777-7777-4777-8777-777777777777", "title": "incoming pin", "event_date": "2026-09-21", "calendar_mode": "solar", "lunar_month": None, "lunar_day": None, "ignore_year": False, "kind": "countdown", "repeat_rule": "none", "icon": "x", "pinned": True, "archived_at": None, }] response = _preflight(client, _make_archive_from_graph(graph), "merge") assert response.status_code == 422 assert response.json()["detail"]["code"] == "backup_constraint_invalid" active = client.get("/api/v1/countdowns").json() assert [row["id"] for row in active if row["pinned"]] == [existing["id"]] def test_restore_target_ids_are_unique_across_entity_tables(client): boot(client) shared = "66666666-6666-4666-8666-666666666666" async def seed_folder(): from backend.db import get_engine from backend.models import Folder, User async with AsyncSession(get_engine()) as db: user_id = await db.scalar(select(User.id)) db.add(Folder( id=UUID(shared), user_id=user_id, name="existing entity id", position=0 )) await db.commit() asyncio.run(seed_folder()) current = client.get("/api/v1/backup/export.zip").content from tests.test_backup_v2 import _archive_rows, _replace_entities lists = _archive_rows(current, "lists") lists.append({ "id": shared, "name": "cross-table collision", "is_inbox": False, "position": 1, }) second_content = _replace_entities(current, {"lists": lists}) entries = __import__("tests.test_backup_v2", fromlist=["_zip_entries"])._zip_entries(second_content) entities = { name.removeprefix("data/").removesuffix(".json"): __import__("json").loads(value) for name, value in entries.items() if name.startswith("data/") and name.endswith(".json") } second = _preflight( client, _make_archive_from_graph( entities, { name: value for name, value in entries.items() if name.startswith("attachments/") }, backup_id="55555555-5555-4555-8555-555555555555", ), "merge", ) assert second.status_code == 200, second.text restored = client.post( "/api/v1/backup/restore", json={"preflight_token": second.json()["preflight_token"], "mode": "merge"}, ) assert restored.status_code == 200, restored.text async def ids(): from backend.db import get_engine from backend.models import Folder, TaskList async with AsyncSession(get_engine()) as db: folder_id = await db.scalar(select(Folder.id).where(Folder.name == "existing entity id")) list_id = await db.scalar(select(TaskList.id).where(TaskList.name == "cross-table collision")) return folder_id, list_id folder_id, list_id = asyncio.run(ids()) assert folder_id == UUID(shared) assert list_id != folder_id