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