trainUs/tests/test_step17_transactions.py
David 352a3d5e88
Some checks are pending
CI / Lint & Format (push) Waiting to run
CI / Build (push) Waiting to run
CI / Tests (${{ matrix.browser }}) (chromium) (push) Waiting to run
CI / Tests (${{ matrix.browser }}) (firefox) (push) Waiting to run
Initial commit
2026-06-18 13:06:57 +02:00

324 lines
12 KiB
Python

"""
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}"