teqo-backup/tests/unit/dbStart.unit.spec.ts

180 lines
5.7 KiB
TypeScript

// @vitest-environment node
import { beforeEach, describe, expect, it, vi } from 'vitest'
type ExecArgs = [command: string, args: string[], options: { cwd: string }]
const execFileSyncMock = vi.fn<(...args: ExecArgs) => unknown>()
vi.mock('node:child_process', () => ({
execFileSync: (...args: ExecArgs) => execFileSyncMock(...args),
}))
import {
parsePostgresContainerHealth,
shouldSkipStart,
startSharedPostgres,
waitForHealthyPostgres,
} from '../../scripts/lib/db-start.mjs'
/** The `docker inspect --format '{{json .State}}'` shape, not a custom one. */
const stateJson = ({ running = true, health = 'healthy' } = {}) =>
JSON.stringify({
Status: running ? 'running' : 'exited',
Running: running,
...(health ? { Health: { Status: health } } : {}),
})
/** Throws exactly what docker does when the container does not exist. */
const throwNoSuchObject = () => {
const error = new Error('No such object') as Error & { stderr: Buffer }
error.stderr = Buffer.from('No such object: teqo-postgres-1')
throw error
}
describe('shouldSkipStart', () => {
it('skips when the container is running and healthy', () => {
expect(shouldSkipStart({ running: true, health: 'healthy' })).toBe(true)
})
it('skips when the container is running and still starting (peer just created it)', () => {
expect(shouldSkipStart({ running: true, health: 'starting' })).toBe(true)
})
it('does not skip when the container is unhealthy', () => {
expect(shouldSkipStart({ running: true, health: 'unhealthy' })).toBe(false)
})
it('does not skip when the container predates the healthcheck (no Health object)', () => {
expect(shouldSkipStart({ running: true, health: '' })).toBe(false)
})
it('does not skip when the container is stopped', () => {
expect(shouldSkipStart({ running: false, health: 'healthy' })).toBe(false)
})
})
describe('parsePostgresContainerHealth', () => {
it('parses a running healthy container', () => {
expect(parsePostgresContainerHealth(stateJson({ health: 'healthy' }))).toEqual({
running: true,
health: 'healthy',
})
})
it('parses a stopped container without a Health object', () => {
expect(parsePostgresContainerHealth(stateJson({ running: false, health: '' }))).toEqual({
running: false,
health: '',
})
})
it('parses a running container without a Health object (legacy pre-healthcheck container)', () => {
expect(parsePostgresContainerHealth(stateJson({ health: '' }))).toEqual({
running: true,
health: '',
})
})
it('treats an explicit null Health as no healthcheck', () => {
expect(
parsePostgresContainerHealth('{"Status":"running","Running":true,"Health":null}'),
).toEqual({
running: true,
health: '',
})
})
it('returns null on unparseable output', () => {
expect(parsePostgresContainerHealth('not json')).toBeNull()
expect(parsePostgresContainerHealth('')).toBeNull()
})
})
describe('startSharedPostgres', () => {
beforeEach(() => {
execFileSyncMock.mockReset()
})
const composeArgs = () =>
execFileSyncMock.mock.calls.find((call) => call[1][0] === 'compose')?.[1]
it('is a no-op when the container is already healthy (never touches compose)', async () => {
execFileSyncMock.mockReturnValue(stateJson({ health: 'healthy' }))
await startSharedPostgres({ cwd: '/repo' })
expect(execFileSyncMock.mock.calls).toHaveLength(1)
expect(execFileSyncMock.mock.calls[0][1]).toEqual([
'inspect',
'--format',
'{{json .State}}',
'teqo-postgres-1',
])
})
it('runs compose up when the container does not exist', async () => {
execFileSyncMock.mockImplementationOnce(throwNoSuchObject).mockReturnValueOnce('')
await startSharedPostgres({ cwd: '/repo' })
expect(composeArgs()).toEqual([
'compose',
'-p',
'teqo',
'up',
'-d',
'--wait',
'--wait-timeout',
'120',
'postgres',
])
const composeCall = execFileSyncMock.mock.calls.find((call) => call[1][0] === 'compose')
expect(composeCall?.[2].cwd).toBe('/repo')
})
it('force-recreate bypasses the healthy skip', async () => {
execFileSyncMock.mockReturnValue(stateJson({ health: 'healthy' })).mockReturnValueOnce('')
await startSharedPostgres({ cwd: '/repo', forceRecreate: true })
expect(composeArgs()).toEqual([
'compose',
'-p',
'teqo',
'up',
'-d',
'--force-recreate',
'--wait',
'--wait-timeout',
'120',
'postgres',
])
})
})
describe('waitForHealthyPostgres', () => {
beforeEach(() => {
execFileSyncMock.mockReset()
})
it('waits for a starting container and returns true when it becomes healthy', async () => {
execFileSyncMock
.mockReturnValueOnce(stateJson({ health: 'starting' }))
.mockReturnValue(stateJson({ health: 'healthy' }))
expect(await waitForHealthyPostgres({ timeoutMs: 50, intervalMs: 1 })).toBe(true)
})
it('falls back (false) when a starting container never becomes healthy', async () => {
execFileSyncMock.mockReturnValue(stateJson({ health: 'starting' }))
expect(await waitForHealthyPostgres({ timeoutMs: 10, intervalMs: 1 })).toBe(false)
})
it('falls back (false) when the container turns unhealthy', async () => {
execFileSyncMock
.mockReturnValueOnce(stateJson({ health: 'starting' }))
.mockReturnValue(stateJson({ health: 'unhealthy' }))
expect(await waitForHealthyPostgres({ timeoutMs: 50, intervalMs: 1 })).toBe(false)
})
it('falls back (false) when the container disappears', async () => {
execFileSyncMock
.mockReturnValueOnce(stateJson({ health: 'starting' }))
.mockImplementation(throwNoSuchObject)
expect(await waitForHealthyPostgres({ timeoutMs: 50, intervalMs: 1 })).toBe(false)
})
})