feat: test a database backup without touching the install
CI and images / lint (push) Successful in 2s
CI and images / extension-version (push) Successful in 2s
CI and images / frontend-build (push) Successful in 22s
CI and images / extension-test (push) Successful in 24s
CI and images / backend-lint-and-test (push) Failing after 47s
CI and images / integration (push) Failing after 2m42s
CI and images / sign-extension (push) Skipped
CI and images / build-web (push) Skipped
CI and images / smoke-web (push) Skipped
CI and images / promote (push) Skipped
CI and images / refresh-dev (push) Skipped
CI and images / unattended-failure (push) Skipped
CI and images / build-agent (push) Skipped

"Test a backup" on the Backups card loads a dump into a separate, temporary
database on the same Postgres server, the same way a restore does, checks every
table against the row counts recorded with the dump, brings the copy up to this
version, and drops it. The live database is only read.

Every dump now records its row counts, read in the snapshot pg_dump dumps from
(pg_export_snapshot + --snapshot), so the comparison is exact. Off-site the
counts sit beside the dump as <name>.counts.json; locally they go in the
manifest file. A dump taken before this is compared with the live database
instead.

A test cut off by a restart is ended, and its database dropped, when the long
maintenance lane starts. A test doesn't block backups; a restore and a second
test wait for it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-07 20:21:25 -05:00
co-authored by Claude Opus 5.5
parent 66045794f0
commit 75008ad048
16 changed files with 1213 additions and 92 deletions
@@ -0,0 +1,59 @@
// @vitest-environment happy-dom
import { describe, it, expect } from 'vitest'
import BackupTestSection from '../../src/components/settings/BackupTestSection.vue'
import { useBackupStore } from '../../src/stores/backup.js'
import { mountWithStore } from '../support/mountComponent.js'
const FINISHED = {
id: 9, kind: 'test', status: 'error', started_at: new Date().toISOString(),
finished_at: new Date().toISOString(), duration_seconds: 300, size_bytes: 2048,
error: '1 table(s) came back different: post (7 in the dump, 6 restored).',
offsite: null,
manifest: {
phase: 'done',
dump: { ts: '20261107T030000Z' },
summary: '1 table(s) came back different: post (7 in the dump, 6 restored).',
counted: true,
mismatched: ['post'],
alembic_dump: '0120', alembic_now: '0121',
tables: [
{ table: 'artist', dump: 10, restored: 10, live: 12 },
{ table: 'post', dump: 7, restored: 6, live: 7 },
],
},
}
function mount(runs) {
return mountWithStore(BackupTestSection, () => {
useBackupStore().testRuns = runs
}, { props: { bucket: 'curator' } })
}
describe('BackupTestSection', () => {
it('leads with the last test\'s verdict and how its schema was brought up', () => {
const t = mount([FINISHED]).text()
expect(t).toContain('Last test')
expect(t).toContain('post (7 in the dump, 6 restored)')
expect(t).toContain('brought up from 0120 to 0121')
expect(t).not.toContain('artist')
})
it('lists every table on request, with the dump, restored and live counts', async () => {
const w = mount([FINISHED])
const btn = w.findAll('v-btn').find((b) => b.text().includes('each table'))
await btn.trigger('click')
const rows = w.findAll('tr').filter((r) => r.text().includes('post'))
expect(rows.some((r) => r.classes().includes('text-error'))).toBe(true)
expect(w.text()).toContain('artist')
})
it('says what a running test is doing', () => {
const running = {
...FINISHED, id: 10, status: 'running', finished_at: null, error: null,
manifest: { phase: 'loading', dump: { ts: '20261107T030000Z' } },
}
const t = mount([running, FINISHED]).text()
expect(t).toContain('loading it into a temporary database')
})
})