396 lines
15 KiB
Python
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)
|