324 lines
12 KiB
Python
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}"
|