diff --git a/backend/crates/application/src/tests/scheduler_tests.rs b/backend/crates/application/src/tests/scheduler_tests.rs index 4f5efee..7bd241b 100644 --- a/backend/crates/application/src/tests/scheduler_tests.rs +++ b/backend/crates/application/src/tests/scheduler_tests.rs @@ -31,3 +31,50 @@ fn due_when_a_fire_time_lies_between_last_run_and_now() { assert!(!is_due(hourly, None, now + Duration::minutes(10), 300)); assert!(!is_due("bad", None, now, 300)); } + +/// The UI offers simple choices ("every Sunday at 3:00") and turns them into these +/// expressions, so the dialect they have to match is pinned down here. +#[test] +fn documents_the_cron_dialect_the_ui_generates() { + let fires_at = |expr: &str, after: chrono::DateTime| next_fire(expr, after).unwrap(); + let monday = Utc.with_ymd_and_hms(2026, 9, 7, 0, 0, 0).unwrap(); // a Monday + + // every day at 3:00 + assert_eq!( + fires_at("0 0 3 * * *", monday), + Utc.with_ymd_and_hms(2026, 9, 7, 3, 0, 0).unwrap() + ); + // every six hours, on the hour + assert_eq!( + fires_at("0 0 */6 * * *", monday), + Utc.with_ymd_and_hms(2026, 9, 7, 6, 0, 0).unwrap() + ); + // every hour at half past + assert_eq!( + fires_at("0 30 * * * *", monday), + Utc.with_ymd_and_hms(2026, 9, 7, 0, 30, 0).unwrap() + ); + // on the first day of the month at 3:30 + assert_eq!( + fires_at("0 30 3 1 * *", monday), + Utc.with_ymd_and_hms(2026, 10, 1, 3, 30, 0).unwrap() + ); + + // Weekdays are 1-7 with Sunday as 1, and names work; a zero is not a valid day. + let sunday = Utc.with_ymd_and_hms(2026, 9, 13, 3, 0, 0).unwrap(); + assert_eq!(fires_at("0 0 3 * * SUN", monday), sunday); + assert_eq!( + fires_at("0 0 3 * * 1", monday), + sunday, + "1 is Sunday, not Monday" + ); + assert_eq!( + fires_at("0 0 3 * * MON", monday), + Utc.with_ymd_and_hms(2026, 9, 7, 3, 0, 0).unwrap() + ); + assert!( + validate_cron("0 0 3 * * 0").is_err(), + "a zero weekday is rejected" + ); + assert_eq!(next_fire("0 0 3 * * 0", monday), None); +} diff --git a/frontend/e2e/app-backup.spec.ts b/frontend/e2e/app-backup.spec.ts index 48f7c12..918fa74 100644 --- a/frontend/e2e/app-backup.spec.ts +++ b/frontend/e2e/app-backup.spec.ts @@ -28,12 +28,14 @@ test('an admin backs up Gitea data and database in one flow', async ({ page }) = // Gitea → select → schedule → target → save await gitea.getByRole('button', { name: 'Back up' }).click() await expect(page.getByRole('dialog')).toContainText('gitea-shared-storage') + // the schedule reads as time, not as cron + await expect(page.getByRole('dialog')).toContainText('Every day at 03:00') await page.getByLabel('Target', { exact: true }).selectOption({ label: name }) await page.getByRole('button', { name: 'Create backup plan' }).click() await expect(page.getByText(/2 backup strategies created/)).toBeVisible() await expect(page.getByRole('row', { name: /Gitea – Gitea volume/ })).toBeVisible() await expect(page.getByRole('row', { name: /Gitea – PostgreSQL dump/ })).toContainText( - '0 0 3 * * *', + 'Every day at 03:00', ) }) diff --git a/frontend/e2e/backups.spec.ts b/frontend/e2e/backups.spec.ts index d9aee5f..fc3fe5d 100644 --- a/frontend/e2e/backups.spec.ts +++ b/frontend/e2e/backups.spec.ts @@ -26,7 +26,7 @@ test('admin creates a target and a strategy, runs it and sees the backup', async await page.getByLabel('Source type', { exact: true }).selectOption('postgres_dump') await page.getByLabel('Namespace', { exact: true }).fill('gitea') await page.getByLabel('Pod', { exact: true }).fill('gitea-postgresql-0') - await page.getByLabel('Schedule', { exact: true }).fill('0 0 2 * * *') + await page.getByLabel('At', { exact: true }).fill('02:00') await page.getByLabel('Target', { exact: true }).selectOption({ label: name }) await page.getByLabel('Keep last', { exact: true }).fill('3') await page.getByRole('button', { name: 'Save strategy' }).click() @@ -36,4 +36,5 @@ test('admin creates a target and a strategy, runs it and sees the backup', async await expect(row).toContainText(/gitea-database_.*\.sql\.gz/, { timeout: 20_000 }) await row.getByRole('button', { name: 'History' }).click() await expect(page.getByTestId('backup-records')).toContainText('sql.gz') + await expect(row).toContainText('Every day at 02:00') }) diff --git a/frontend/e2e/settings-jobs.spec.ts b/frontend/e2e/settings-jobs.spec.ts index 597afe9..a401a9d 100644 --- a/frontend/e2e/settings-jobs.spec.ts +++ b/frontend/e2e/settings-jobs.spec.ts @@ -23,10 +23,14 @@ test('admin saves SMTP settings and a schedule', async ({ page }) => { await expect(page.getByLabel('SMTP host')).toHaveValue('mail.example.com') await expect(page.getByText('leave empty to keep')).toBeVisible() - const cron = page.getByLabel('Package refresh schedule') - await cron.fill('0 */30 * * * *') + await expect(page.getByTestId('schedule-summary').first()).toHaveText('Every hour') + await page.getByLabel('Repeat').first().selectOption('daily') + await expect(page.getByTestId('schedule-summary').first()).toContainText('Every day at') + await page.getByLabel('At', { exact: true }).first().fill('04:30') await page.getByRole('button', { name: 'Save schedules' }).click() - await expect(page.getByRole('status')).toContainText('Schedules saved') + await expect(page.getByText(/Schedules saved/)).toBeVisible() + await page.reload() + await expect(page.getByLabel('At', { exact: true }).first()).toHaveValue('04:30') }) test('admin runs a job manually and sees the log', async ({ page }) => { diff --git a/frontend/src/components/ApplicationBackupForm.test.ts b/frontend/src/components/ApplicationBackupForm.test.ts index e2804dc..1747ac2 100644 --- a/frontend/src/components/ApplicationBackupForm.test.ts +++ b/frontend/src/components/ApplicationBackupForm.test.ts @@ -53,7 +53,9 @@ describe('ApplicationBackupForm', () => { it('defaults to a nightly backup at three and submits the selection', async () => { const w = mount(ApplicationBackupForm, { props }) - expect((w.find('input[name=schedule]').element as HTMLInputElement).value).toBe('0 0 3 * * *') + // the schedule is offered as plain time, not as a cron expression + expect(w.text()).toContain('Every day at 03:00') + expect((w.find('input[name=time]').element as HTMLInputElement).value).toBe('03:00') await w.find('select[name=target_id]').setValue('t2') await w.find('form').trigger('submit') expect(w.emitted('submit')![0][0]).toEqual({ @@ -66,7 +68,7 @@ describe('ApplicationBackupForm', () => { }) }) - it('refuses an empty selection and offers a daily preset', async () => { + it('refuses an empty selection and lets the time be changed', async () => { const w = mount(ApplicationBackupForm, { props }) for (const b of w.findAll('input[type=checkbox]')) await b.setValue(false) await w.find('form').trigger('submit') @@ -74,8 +76,12 @@ describe('ApplicationBackupForm', () => { expect(w.text()).toContain('Select at least one') await w.findAll('input[type=checkbox]')[0].setValue(true) - await w.find('select[name=preset]').setValue('0 0 */6 * * *') - expect((w.find('input[name=schedule]').element as HTMLInputElement).value).toBe('0 0 */6 * * *') + await w.find('select[name=frequency]').setValue('hours') + await w.find('select[name=interval]').setValue('12') + await w.find('form').trigger('submit') + expect((w.emitted('submit')!.at(-1)![0] as { schedule: string }).schedule).toBe( + '0 0 */12 * * *', + ) }) it('says when no target exists yet', () => { diff --git a/frontend/src/components/ApplicationBackupForm.vue b/frontend/src/components/ApplicationBackupForm.vue index 68eeb93..694ab53 100644 --- a/frontend/src/components/ApplicationBackupForm.vue +++ b/frontend/src/components/ApplicationBackupForm.vue @@ -1,6 +1,7 @@ + + diff --git a/frontend/src/components/StrategyForm.test.ts b/frontend/src/components/StrategyForm.test.ts index f6aa402..1e852b2 100644 --- a/frontend/src/components/StrategyForm.test.ts +++ b/frontend/src/components/StrategyForm.test.ts @@ -13,7 +13,7 @@ describe('StrategyForm', () => { await w.find('select[name=source_type]').setValue('postgres_dump') await w.find('input[name=namespace]').setValue('gitea') await w.find('input[name=pod]').setValue('gitea-postgresql-0') - await w.find('input[name=schedule]').setValue('0 0 2 * * *') + await w.find('input[name=time]').setValue('02:00') await w.find('select[name=target_id]').setValue('t2') await w.find('input[name=retention]').setValue('7') await w.find('input[name=passphrase]').setValue('a-long-passphrase!') diff --git a/frontend/src/components/StrategyForm.vue b/frontend/src/components/StrategyForm.vue index 3081e5e..840ef10 100644 --- a/frontend/src/components/StrategyForm.vue +++ b/frontend/src/components/StrategyForm.vue @@ -1,6 +1,7 @@