""" Step 17 — Transaction Atomicity Tests (R1 + R2) R1 — Batch transactions for multi-statement repository writes: - test_set_items_roundtrips_correctly: setItems persists items and they can be retrieved - test_batch_constraint_violation_rolls_back: A mid-batch constraint error leaves DB unchanged - test_set_exercises_roundtrips_correctly: comboRepository.setExercises is atomic - test_set_sessions_roundtrips_correctly: collectionRepository.setSessions is atomic R2 — Atomic full-DB restore: - test_valid_restore_preserves_data: A valid backup restores all entities - test_corrupt_restore_preserves_original_data: A partially corrupt import leaves original data intact """ import sqlite3 import tempfile import os import pytest from playwright.sync_api import Page, expect def _wait_for_db(page: Page, app_url: str): page.goto(app_url) page.wait_for_function( "document.documentElement.getAttribute('data-db-ready') === 'true'", timeout=15000, ) def _clean_all(page: Page): page.evaluate("""async () => { const exercises = await window.__repos.exercises.getAll() for (const e of exercises) await window.__repos.exercises.delete(e.id) const combos = await window.__repos.combos.getAll() for (const c of combos) await window.__repos.combos.delete(c.id) const sessions = await window.__repos.sessions.getAll() for (const s of sessions) await window.__repos.sessions.delete(s.id) const collections = await window.__repos.collections.getAll() for (const c of collections) await window.__repos.collections.delete(c.id) }""") def _create_exercise(page: Page, title: str) -> str: return page.evaluate( """async ([title]) => { const userId = await window.__repos.settings.get('user_uuid') return window.__repos.exercises.create({ title, description: '', asymmetric: false, alternate: false, image_urls: '[]', video_urls: '[]', default_reps: 10, default_weight: 0, created_by: userId }) }""", [title], ) def _create_session(page: Page, title: str) -> str: return page.evaluate( """async ([title]) => { const userId = await window.__repos.settings.get('user_uuid') return window.__repos.sessions.create({title, description: '', created_by: userId}) }""", [title], ) def _create_combo(page: Page, title: str) -> str: return page.evaluate( """async ([title]) => { const userId = await window.__repos.settings.get('user_uuid') return window.__repos.combos.create({ title, description: '', type: 'NONE', timer_config: {}, created_by: userId }) }""", [title], ) def _create_collection(page: Page, label: str) -> str: return page.evaluate( """async ([label]) => { const userId = await window.__repos.settings.get('user_uuid') return window.__repos.collections.create({label, created_by: userId}) }""", [label], ) # ── R1 tests ────────────────────────────────────────────────────────────────── def test_set_items_roundtrips_correctly(page: Page, app_url: str): """setItems atomically replaces session items; the full set is retrievable.""" _wait_for_db(page, app_url) _clean_all(page) ex1 = _create_exercise(page, "Squat") ex2 = _create_exercise(page, "Lunge") sess_id = _create_session(page, "Leg Day") items = [ {"item_id": ex1, "item_type": "exercise", "position": 0, "repetitions": 10, "weight": 0}, {"item_id": ex2, "item_type": "exercise", "position": 1, "repetitions": 12, "weight": 0}, ] page.evaluate( """async ([sessId, items]) => window.__repos.sessions.setItems(sessId, items)""", [sess_id, items], ) result = page.evaluate( """async ([sessId]) => { const s = await window.__repos.sessions.getById(sessId) return s ? s.items : [] }""", [sess_id], ) assert len(result) == 2, f"Expected 2 items, got {len(result)}" assert result[0]["item_id"] == ex1 assert result[1]["item_id"] == ex2 def test_batch_constraint_violation_rolls_back(page: Page, app_url: str): """A batch where any statement fails leaves the DB unchanged (full rollback).""" _wait_for_db(page, app_url) _clean_all(page) ex_id = _create_exercise(page, "Bench Press") # Build a batch: first a valid INSERT into exercise, then one that violates UNIQUE result = page.evaluate( """async ([exId]) => { try { await window.__db.batch([ { sql: "INSERT INTO exercise (id, title, description, asymmetric, alternate, image_urls, video_urls, default_reps, default_weight, created_by) VALUES (?, ?, '', 0, 0, '[]', '[]', 5, 0, 'test')", params: ['new-ex-1', 'New Exercise 1'] }, { // UNIQUE violation: same title as existing exercise sql: "INSERT INTO exercise (id, title, description, asymmetric, alternate, image_urls, video_urls, default_reps, default_weight, created_by) VALUES (?, ?, '', 0, 0, '[]', '[]', 5, 0, 'test')", params: ['new-ex-2', 'Bench Press'] } ]) return { threw: false } } catch (e) { return { threw: true, message: e.message } } }""", [ex_id], ) assert result["threw"], f"Batch should have thrown, but got: {result}" # Verify neither the valid nor invalid row was committed count = page.evaluate( """async () => { const rows = await window.__db.selectAll('SELECT id FROM exercise') return rows.length }""" ) assert count == 1, f"Only the original exercise should exist, got {count}" def test_set_exercises_roundtrips_correctly(page: Page, app_url: str): """comboRepository.setExercises atomically replaces exercises on a combo.""" _wait_for_db(page, app_url) _clean_all(page) ex1 = _create_exercise(page, "Pull-up") ex2 = _create_exercise(page, "Dip") combo_id = _create_combo(page, "Upper Pull") exercises = [ {"exerciseId": ex1, "defaultReps": 8, "defaultWeight": 0}, {"exerciseId": ex2, "defaultReps": 10, "defaultWeight": 0}, ] page.evaluate( """async ([comboId, exercises]) => window.__repos.combos.setExercises(comboId, exercises)""", [combo_id, exercises], ) result = page.evaluate( """async ([comboId]) => { const c = await window.__repos.combos.getById(comboId) return c ? c.exercises : [] }""", [combo_id], ) assert len(result) == 2, f"Expected 2 exercises in combo, got {len(result)}" def test_set_sessions_roundtrips_correctly(page: Page, app_url: str): """collectionRepository.setSessions atomically replaces sessions on a collection.""" _wait_for_db(page, app_url) _clean_all(page) s1 = _create_session(page, "Session Alpha") s2 = _create_session(page, "Session Beta") coll_id = _create_collection(page, "My Week") page.evaluate( """async ([collId, s1, s2]) => window.__repos.collections.setSessions(collId, [s1, s2])""", [coll_id, s1, s2], ) result = page.evaluate( """async ([collId]) => { const c = await window.__repos.collections.getById(collId) return c ? c.sessions : [] }""", [coll_id], ) assert len(result) == 2, f"Expected 2 sessions in collection, got {len(result)}" # ── R2 tests ────────────────────────────────────────────────────────────────── def _build_corrupt_db_bytes(tmp_path: str) -> bytes: """Create a SQLite DB that passes validation but has an exercise row with NULL created_by. The live DB has `created_by TEXT NOT NULL`, so inserting this row during restore will trigger a NOT NULL constraint failure mid-transaction. """ db_path = os.path.join(tmp_path, "corrupt_import.db") conn = sqlite3.connect(db_path) # Required tables (REQUIRED_TABLES = ['schema_version', 'settings']) conn.execute( "CREATE TABLE schema_version (version INTEGER PRIMARY KEY, applied_at TEXT)" ) conn.execute("INSERT INTO schema_version VALUES (1, datetime('now'))") conn.execute("CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT)") # exercise table without NOT NULL on created_by (SQLite won't enforce it here) conn.execute("""CREATE TABLE exercise ( id TEXT PRIMARY KEY, title TEXT NOT NULL UNIQUE, description TEXT DEFAULT '', asymmetric INTEGER DEFAULT 0, alternate INTEGER DEFAULT 0, image_urls TEXT DEFAULT '[]', video_urls TEXT DEFAULT '[]', default_reps INTEGER DEFAULT 0, default_weight REAL DEFAULT 0.0, created_by TEXT )""") # Insert a row where created_by = NULL — will fail NOT NULL in the live schema conn.execute( "INSERT INTO exercise VALUES ('corrupt-id', 'Corrupt Exercise', '', 0, 0, '[]', '[]', 5, 0.0, NULL)" ) conn.commit() conn.close() with open(db_path, "rb") as f: return f.read() def test_valid_restore_preserves_data(page: Page, app_url: str, tmp_path): """A valid DB backup can be exported and re-imported without data loss.""" _wait_for_db(page, app_url) _clean_all(page) ex_id = _create_exercise(page, "Deadlift") # Export the DB as bytes via the worker db_bytes_list = page.evaluate( """async () => { const bytes = await window.__db.exportDatabase() return Array.from(bytes) }""" ) # Import the exported bytes back — should succeed result = page.evaluate( """async (bytes) => window.__db.importDatabase(new Uint8Array(bytes))""", db_bytes_list, ) assert result.get("success") is True, f"Valid restore should succeed, got: {result}" # After restore the exercise should still exist exercises = page.evaluate( """async () => window.__repos.exercises.getAll()""" ) ids = [e["id"] for e in exercises] assert ex_id in ids, f"Exercise {ex_id} should survive a valid restore" def test_corrupt_restore_preserves_original_data(page: Page, app_url: str, tmp_path): """A restore that fails mid-way (constraint violation) leaves the original data intact.""" _wait_for_db(page, app_url) _clean_all(page) # Create a known exercise in the live DB ex_id = _create_exercise(page, "Original Exercise") # Build a corrupt DB file and pass bytes into the browser corrupt_bytes = _build_corrupt_db_bytes(str(tmp_path)) corrupt_list = list(corrupt_bytes) result = page.evaluate( """async (bytes) => { try { return await window.__db.importDatabase(new Uint8Array(bytes)) } catch (e) { return { success: false, error: 'THREW', message: e.message } } }""", corrupt_list, ) # The import should have failed assert result.get("success") is False, f"Corrupt import should fail, got: {result}" # Original exercise must still be present (transaction was rolled back) exercises = page.evaluate("""async () => window.__repos.exercises.getAll()""") ids = [e["id"] for e in exercises] assert ex_id in ids, ( f"Original exercise should survive a failed restore. " f"Got exercises: {exercises}, result: {result}" ) assert len(exercises) == 1, f"Only original exercise should remain, got: {exercises}"