trainUs/tests/test_step16_migration.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

200 lines
8.2 KiB
Python

"""
Step 16 — Single Deployment + Startup DB Migration Tests
Green paths:
- test_fresh_install_initializes: Fresh DB gets the schema, schema_version stamped, defaults present
- test_data_persists_across_reload: Data survives a reload (stable OPFS location)
- test_opfs_directory_unversioned: OPFS pool directory carries no version suffix
- test_full_migration_flow: Pending migration -> forced backup download -> Update -> app loads
with data intact (uses the v1 no-op migration by rewinding schema_version to 0)
Red paths:
- test_db_newer_than_app: dbVersion > SCHEMA_VERSION shows the blocking DB_NEWER_THAN_APP
error overlay without touching the data
- test_migration_screen_no_bypass: Update stays disabled before a backup download, and a
reload lands back on the migration screen with data untouched
Deferred:
- Migration-failure rollback test: cannot be exercised realistically until a real v2
migration script exists (faking it would require test-only scripts in production code).
"""
import re
from playwright.sync_api import Page, expect
def _wait_for_db(page: Page, app_url: str):
"""Navigate and wait for the database to be ready."""
page.goto(app_url)
page.wait_for_function(
"document.documentElement.getAttribute('data-db-ready') === 'true'", timeout=15000
)
def _set_db_version(page: Page, version: int):
"""Rewrite the schema_version table so it contains only `version`."""
page.evaluate(f"""
(async () => {{
await window.__db.run("DELETE FROM schema_version");
await window.__db.run("INSERT INTO schema_version (version) VALUES ({version})");
}})()
""")
def _create_exercise(page: Page, title: str) -> str:
return page.evaluate(f"""
window.__repos.exercises.create({{
title: '{title}',
description: '',
created_by: 'test'
}})
""")
def _download_via_button(page: Page, trigger_selector: str):
"""Click a button that opens the save-filename dialog, accept the default name, return the Download."""
page.locator(trigger_selector).click()
page.wait_for_selector('[data-testid="save-file-confirm"]')
with page.expect_download() as download_info:
page.locator('[data-testid="save-file-confirm"]').click()
return download_info.value
def test_fresh_install_initializes(page: Page, app_url: str):
"""A fresh install creates the schema, stamps SCHEMA_VERSION, and seeds defaults."""
_wait_for_db(page, app_url)
versions = page.evaluate(
'window.__db.selectAll("SELECT version FROM schema_version ORDER BY version")'
)
assert len(versions) == 1, f"Expected a single schema_version row, got {versions}"
assert versions[0]["version"] == 1
uuid = page.evaluate("async () => await window.__repos.settings.get('user_uuid')")
assert uuid is not None, "Default settings missing after fresh install"
def test_data_persists_across_reload(page: Page, app_url: str):
"""Data persists across reload now that the OPFS location is version-independent."""
_wait_for_db(page, app_url)
ex_id = _create_exercise(page, "Step16 Persistent Exercise")
page.reload()
page.wait_for_function(
"document.documentElement.getAttribute('data-db-ready') === 'true'", timeout=15000
)
exercise = page.evaluate(f"window.__repos.exercises.getById('{ex_id}')")
assert exercise is not None, "Exercise lost after reload"
assert exercise["title"] == "Step16 Persistent Exercise"
def test_opfs_directory_unversioned(page: Page, app_url: str):
"""The OPFS pool directory is named .trainus with no app-version suffix."""
_wait_for_db(page, app_url)
dir_names = page.evaluate("""
(async () => {
const root = await navigator.storage.getDirectory();
const names = [];
for await (const name of root.keys()) names.push(name);
return names;
})()
""")
assert ".trainus" in dir_names, f"Expected '.trainus' OPFS directory, found: {dir_names}"
versioned = [n for n in dir_names if re.match(r"^\.trainus-", n)]
assert versioned == [], f"Found versioned OPFS directories: {versioned}"
def test_full_migration_flow(page: Page, app_url: str):
"""Pending migration forces a backup download before Update, then migrates and keeps data."""
_wait_for_db(page, app_url)
ex_id = _create_exercise(page, "Step16 Migration Exercise")
_set_db_version(page, 0)
page.reload()
page.wait_for_function(
"document.documentElement.getAttribute('data-db-migration') === 'pending'", timeout=15000
)
expect(page.locator('[data-testid="db-migration"]')).to_be_visible()
# Update is locked until a backup has been downloaded
expect(page.locator('[data-testid="migration-update"]')).to_be_disabled()
download = _download_via_button(page, '[data-testid="migration-backup"]')
filename = download.suggested_filename
assert re.match(
r"^trainus-\d+\.\d+\.\d+-backup-\d{8}-\d{2}h\d{2}m\d{2}s\.sqlite3$", filename
), f"Unexpected backup filename: {filename}"
expect(page.locator('[data-testid="migration-update"]')).to_be_enabled()
page.locator('[data-testid="migration-update"]').click()
page.wait_for_function(
"document.documentElement.getAttribute('data-db-ready') === 'true'", timeout=15000
)
max_version = page.evaluate(
'window.__db.selectOne("SELECT MAX(version) AS v FROM schema_version")'
)
assert max_version["v"] == 1, f"schema_version not migrated: {max_version}"
exercise = page.evaluate(f"window.__repos.exercises.getById('{ex_id}')")
assert exercise is not None, "Data lost during migration"
last_backup = page.evaluate("async () => await window.__repos.settings.get('last_backup_at')")
assert last_backup is not None, "last_backup_at was not set by the forced backup"
def test_db_newer_than_app(page: Page, app_url: str):
"""A DB created by a newer app version blocks with DB_NEWER_THAN_APP and stays untouched."""
_wait_for_db(page, app_url)
ex_id = _create_exercise(page, "Step16 Newer DB Exercise")
page.evaluate('window.__db.run("INSERT INTO schema_version (version) VALUES (2)")')
page.reload()
page.wait_for_function(
"document.documentElement.getAttribute('data-db-error') !== null", timeout=15000
)
error = page.evaluate("document.documentElement.getAttribute('data-db-error')")
assert error == "DB_NEWER_THAN_APP", f"Unexpected error code: {error}"
expect(page.locator('[data-testid="db-error"]')).to_be_visible()
# Data untouched: removing the fake newer version makes the app load again
page.evaluate('window.__db.run("DELETE FROM schema_version WHERE version = 2")')
page.reload()
page.wait_for_function(
"document.documentElement.getAttribute('data-db-ready') === 'true'", timeout=15000
)
exercise = page.evaluate(f"window.__repos.exercises.getById('{ex_id}')")
assert exercise is not None, "Data lost while DB was flagged as newer than the app"
def test_migration_screen_no_bypass(page: Page, app_url: str):
"""The migration screen cannot be bypassed: Update stays disabled and survives reloads."""
_wait_for_db(page, app_url)
ex_id = _create_exercise(page, "Step16 NoBypass Exercise")
_set_db_version(page, 0)
page.reload()
page.wait_for_function(
"document.documentElement.getAttribute('data-db-migration') === 'pending'", timeout=15000
)
expect(page.locator('[data-testid="migration-update"]')).to_be_disabled()
# Reloading without downloading a backup lands back on the migration screen
page.reload()
page.wait_for_function(
"document.documentElement.getAttribute('data-db-migration') === 'pending'", timeout=15000
)
expect(page.locator('[data-testid="db-migration"]')).to_be_visible()
expect(page.locator('[data-testid="migration-update"]')).to_be_disabled()
# No data was touched while paused
version = page.evaluate(
'window.__db.selectOne("SELECT MAX(version) AS v FROM schema_version")'
)
assert version["v"] == 0, f"schema_version changed without user action: {version}"
exercise = page.evaluate(f"window.__repos.exercises.getById('{ex_id}')")
assert exercise is not None, "Data touched while migration was pending"