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

396 lines
15 KiB
Python

"""
Step 3c — Backup Filename & Reminder Tests
- test_download_uses_new_filename: Download uses the new backup filename pattern
- test_download_sets_last_backup_at: After download, last_backup_at is set in DB
- test_reminder_hidden_when_fresh: Reminder is hidden when backup is recent
- test_reminder_visible_when_stale: Reminder appears when backup is older than threshold
- test_reminder_visible_when_never: Reminder appears when no backup has been done and db_created_at is old
- test_reminder_hidden_when_db_fresh_no_backup: Reminder hidden when no backup but db_created_at is recent
- test_reminder_click_navigates_to_settings: Clicking reminder navigates to settings page
- test_restore_updates_last_backup_at: Restore updates last_backup_at timestamp
- test_custom_threshold_is_respected: Custom reminder days threshold is respected
- test_invalid_threshold_rejected: Invalid threshold values are rejected
- test_filename_is_filesystem_safe: Filename contains no colons and is zero-padded
- test_startup_modal_appears_when_stale: Startup modal appears on load when backup is overdue
- test_startup_modal_dismiss: Clicking Dismiss closes the startup modal
- test_startup_modal_go_to_settings: Clicking Go to Settings navigates to settings page
- test_startup_modal_does_not_reappear: Modal does not reappear when navigating after dismissal
"""
import re
from datetime import datetime, timedelta, timezone
from playwright.sync_api import Page, expect
def _iso(days_ago: int) -> str:
"""Return an ISO-8601 UTC timestamp for N days in the past."""
dt = datetime.now(timezone.utc) - timedelta(days=days_ago)
return dt.strftime("%Y-%m-%dT%H:%M:%S.000Z")
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 _go_to_settings(page: Page, app_url: str):
"""Navigate to the settings page and wait for DB."""
_wait_for_db(page, app_url)
page.click('a[href="#/settings"]')
page.wait_for_selector('[data-testid="settings-language"]')
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_download_uses_new_filename(page: Page, app_url: str):
"""Download uses the new backup filename pattern with version and timestamp."""
_go_to_settings(page, app_url)
download = _download_via_button(page, '[data-testid="settings-download-db"]')
filename = download.suggested_filename
# Match pattern: trainus-{version}-backup-YYYYMMDD-HHhMMmSSs.sqlite3
pattern = r'^trainus-\d+\.\d+\.\d+-backup-\d{8}-\d{2}h\d{2}m\d{2}s\.sqlite3$'
assert re.match(pattern, filename), f"Filename '{filename}' does not match expected pattern"
def test_download_sets_last_backup_at(page: Page, app_url: str):
"""After download, last_backup_at is set and Settings shows the backup date."""
_go_to_settings(page, app_url)
# Click download
_download_via_button(page, '[data-testid="settings-download-db"]')
# Wait for the backup status to update
page.wait_for_timeout(1000)
# Check that last_backup_at is set in DB
last_backup = page.evaluate("async () => await window.__repos.settings.get('last_backup_at')")
assert last_backup is not None, "last_backup_at should be set after download"
# Settings should show the backup date
backup_info = page.locator('[data-testid="backup-last-backup"]')
expect(backup_info).to_be_visible()
text = backup_info.text_content()
assert 'Never backed up' not in text, "Should show backup date, not 'Never backed up'"
def test_reminder_hidden_when_fresh(page: Page, app_url: str):
"""Reminder is hidden when backup is recent (within threshold)."""
_go_to_settings(page, app_url)
# Do a download to create a fresh backup
_download_via_button(page, '[data-testid="settings-download-db"]')
page.wait_for_timeout(1000)
# Navigate away and back to check reminder visibility
page.click('a[href="#/home"]')
page.wait_for_function(
"document.documentElement.getAttribute('data-db-ready') === 'true'",
timeout=15000,
)
# Reminder should not be visible
reminder = page.locator('[data-testid="backup-reminder"]')
expect(reminder).not_to_be_visible()
def test_reminder_visible_when_stale(page: Page, app_url: str):
"""Reminder appears when backup is older than the threshold."""
_go_to_settings(page, app_url)
# Set last_backup_at to 8 days ago (default threshold is 7)
page.evaluate(f"window.__repos.settings.set('last_backup_at', '{_iso(8)}')")
# Go to home page to check reminder
page.click('a[href="#/home"]')
page.wait_for_function(
"document.documentElement.getAttribute('data-db-ready') === 'true'",
timeout=15000,
)
# Reminder should be visible
reminder = page.locator('[data-testid="backup-reminder"]')
expect(reminder).to_be_visible()
def test_reminder_visible_when_never(page: Page, app_url: str):
"""Reminder appears when no backup has been done and db_created_at is old."""
page.goto(app_url)
page.wait_for_function(
"document.documentElement.getAttribute('data-db-ready') === 'true'",
timeout=15000,
)
# Remove both last_backup_at AND set db_created_at to an old date
# so the fallback makes the reminder appear
page.evaluate("async () => { await window.__repos.settings.set('last_backup_at', null) }")
page.evaluate(f"async () => {{ await window.__repos.settings.set('db_created_at', '{_iso(30)}') }}")
# Reload to pick up the changed settings
page.reload()
page.wait_for_function(
"document.documentElement.getAttribute('data-db-ready') === 'true'",
timeout=15000,
)
# Reminder should be visible
reminder = page.locator('[data-testid="backup-reminder"]')
expect(reminder).to_be_visible()
# Restore db_created_at to a recent value so subsequent tests on the same
# browser page (same OPFS database) are not affected by the stale date.
page.evaluate(f"async () => {{ await window.__repos.settings.set('db_created_at', '{_iso(0)}') }}")
page.reload()
page.wait_for_function(
"document.documentElement.getAttribute('data-db-ready') === 'true'",
timeout=15000,
)
def test_reminder_hidden_when_db_fresh_no_backup(page: Page, app_url: str):
"""Reminder is hidden when no backup exists but db_created_at is recent."""
_wait_for_db(page, app_url)
# Clear only last_backup_at, leaving db_created_at intact (recent)
page.evaluate("async () => { await window.__repos.settings.set('last_backup_at', null) }")
# Reload to pick up the changed settings
page.reload()
page.wait_for_function(
"document.documentElement.getAttribute('data-db-ready') === 'true'",
timeout=15000,
)
# Reminder should NOT be visible (db_created_at is recent)
reminder = page.locator('[data-testid="backup-reminder"]')
expect(reminder).not_to_be_visible()
def test_reminder_click_navigates_to_settings(page: Page, app_url: str):
"""Clicking reminder navigates to the settings page."""
_go_to_settings(page, app_url)
# Set a stale backup date
page.evaluate(f"window.__repos.settings.set('last_backup_at', '{_iso(8)}')")
# Go to home page
page.click('a[href="#/home"]')
page.wait_for_timeout(1000)
# Click the reminder
page.locator('[data-testid="backup-reminder"]').click()
# Should navigate to settings
page.wait_for_url(re.compile(r'#/settings'), timeout=5000)
# Backup section should be visible
backup_section = page.locator('#settings-backup-section')
expect(backup_section).to_be_visible()
def test_restore_updates_last_backup_at(page: Page, app_url: str, tmp_path_factory):
"""Successful restore updates last_backup_at timestamp."""
_go_to_settings(page, app_url)
# Download DB and save to a known path (download.path() can be None under xdist)
tmp_dir = tmp_path_factory.mktemp("restore")
db_path = str(tmp_dir / "backup.sqlite3")
download = _download_via_button(page, '[data-testid="settings-download-db"]')
download.save_as(db_path)
# Wait for the download's markBackedUp() to persist, then clear last_backup_at
page.wait_for_timeout(500)
page.evaluate("async () => { await window.__repos.settings.set('last_backup_at', null) }")
# Verify it is actually null before proceeding
val = page.evaluate("async () => await window.__repos.settings.get('last_backup_at')")
assert val is None, f"last_backup_at should be null before restore, got: {val}"
# Restore the DB — use once() to avoid handler accumulation across tests
page.once('dialog', lambda dialog: dialog.accept())
with page.expect_file_chooser() as fc_info:
page.locator('[data-testid="settings-restore-db"]').click()
file_chooser = fc_info.value
file_chooser.set_files(db_path)
# Wait for the spinner to disappear — indicates the full async chain completed
page.wait_for_selector('[data-testid="restore-spinner"]', state="hidden", timeout=15000)
# last_backup_at should be set by markBackedUp() after successful restore
last_backup = page.evaluate("async () => await window.__repos.settings.get('last_backup_at')")
assert last_backup is not None, "last_backup_at should be set after restore"
def test_custom_threshold_is_respected(page: Page, app_url: str):
"""Custom reminder days threshold is respected."""
_go_to_settings(page, app_url)
# Set last_backup_at to 3 days ago
page.evaluate(f"window.__repos.settings.set('last_backup_at', '{_iso(3)}')")
# Set threshold to 2 days
page.locator('[data-testid="backup-reminder-days"]').fill('2')
page.locator('[data-testid="backup-reminder-days"]').blur()
page.wait_for_timeout(1000)
# Reload the page to pick up new threshold
page.reload()
page.wait_for_function(
"document.documentElement.getAttribute('data-db-ready') === 'true'",
timeout=15000,
)
# Reminder should be visible (3 days > 2 day threshold)
reminder = page.locator('[data-testid="backup-reminder"]')
expect(reminder).to_be_visible()
# The startup modal appears on reload — dismiss it before navigating
page.locator('[data-testid="backup-modal-dismiss"]').click()
# Go back to settings and change threshold to 30 days
page.click('a[href="#/settings"]')
page.wait_for_selector('[data-testid="settings-language"]')
page.locator('[data-testid="backup-reminder-days"]').fill('30')
page.locator('[data-testid="backup-reminder-days"]').blur()
page.wait_for_timeout(1000)
# Reload the page to pick up new threshold
page.reload()
page.wait_for_function(
"document.documentElement.getAttribute('data-db-ready') === 'true'",
timeout=15000,
)
# Reminder should be hidden (3 days < 30 day threshold)
expect(reminder).not_to_be_visible()
def test_invalid_threshold_rejected(page: Page, app_url: str):
"""Invalid threshold values (0, -1, abc, 366) are rejected."""
_go_to_settings(page, app_url)
# Set a known valid value first
page.locator('[data-testid="backup-reminder-days"]').fill('7')
page.locator('[data-testid="backup-reminder-days"]').blur()
page.wait_for_timeout(500)
# Test invalid values
invalid_values = ['0', '-1', 'abc', '366']
for value in invalid_values:
page.locator('[data-testid="backup-reminder-days"]').fill(value)
page.locator('[data-testid="backup-reminder-days"]').blur()
page.wait_for_timeout(500)
# Error should be visible
error = page.locator('[data-testid="backup-reminder-error"]')
expect(error).to_be_visible()
# DB should still have the last valid value (7)
stored = page.evaluate("async () => await window.__repos.settings.get('backup_reminder_days')")
assert stored == '7', f"backup_reminder_days should be '7', got '{stored}'"
def test_filename_is_filesystem_safe(page: Page, app_url: str):
"""Filename contains no colons and is zero-padded."""
_go_to_settings(page, app_url)
download = _download_via_button(page, '[data-testid="settings-download-db"]')
filename = download.suggested_filename
# No colons
assert ':' not in filename, f"Filename '{filename}' contains colons"
# Zero-padded: check the timestamp portion
# Extract the timestamp part: YYYYMMDD-HHhMMmSSs
match = re.search(r'backup-(\d{8}-\d{2}h\d{2}m\d{2}s)', filename)
assert match, f"Filename '{filename}' does not have zero-padded timestamp"
def _set_stale_and_reload(page: Page, app_url: str):
"""Set db_created_at to 30 days ago (no last_backup_at) then reload."""
page.goto(app_url)
page.wait_for_function(
"document.documentElement.getAttribute('data-db-ready') === 'true'",
timeout=15000,
)
page.evaluate("async () => { await window.__repos.settings.set('last_backup_at', null) }")
page.evaluate(f"async () => {{ await window.__repos.settings.set('db_created_at', '{_iso(30)}') }}")
page.reload()
page.wait_for_function(
"document.documentElement.getAttribute('data-db-ready') === 'true'",
timeout=15000,
)
def _cleanup_stale(page: Page):
"""Reset db_created_at to today so the stale state does not leak."""
page.evaluate(f"async () => {{ await window.__repos.settings.set('db_created_at', '{_iso(0)}') }}")
def test_startup_modal_appears_when_stale(page: Page, app_url: str):
"""Startup modal appears on page load when backup is overdue."""
_set_stale_and_reload(page, app_url)
expect(page.locator('[data-testid="backup-modal"]')).to_be_visible()
_cleanup_stale(page)
def test_startup_modal_dismiss(page: Page, app_url: str):
"""Clicking Dismiss closes the startup modal."""
_set_stale_and_reload(page, app_url)
modal = page.locator('[data-testid="backup-modal"]')
expect(modal).to_be_visible()
page.locator('[data-testid="backup-modal-dismiss"]').click()
expect(modal).not_to_be_visible()
_cleanup_stale(page)
def test_startup_modal_go_to_settings(page: Page, app_url: str):
"""Clicking Go to Settings in the startup modal navigates to the settings page and closes the modal."""
_set_stale_and_reload(page, app_url)
modal = page.locator('[data-testid="backup-modal"]')
expect(modal).to_be_visible()
page.locator('[data-testid="backup-modal-settings"]').click()
expect(modal).not_to_be_visible()
page.wait_for_url(re.compile(r'#/settings'), timeout=5000)
_cleanup_stale(page)
def test_startup_modal_does_not_reappear(page: Page, app_url: str):
"""After dismissal the startup modal does not reappear when navigating within the same session."""
_set_stale_and_reload(page, app_url)
page.locator('[data-testid="backup-modal-dismiss"]').click()
modal = page.locator('[data-testid="backup-modal"]')
# Navigate away and back — component remounts each time due to :key binding
page.click('a[href="#/exercises"]')
page.wait_for_timeout(500)
page.click('a[href="#/home"]')
page.wait_for_timeout(500)
expect(modal).not_to_be_visible()
_cleanup_stale(page)