582 lines
20 KiB
TypeScript
582 lines
20 KiB
TypeScript
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { EventEmitter } from 'node:events';
|
|
import { PassThrough, Writable } from 'node:stream';
|
|
import fs from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import { ERROR_CODES } from '@insforge/shared-schemas';
|
|
|
|
const STORAGE_DIR = '/tmp/insforge-backup-service-tests';
|
|
|
|
const {
|
|
queryMock,
|
|
connectMock,
|
|
clientQueryMock,
|
|
releaseMock,
|
|
spawnMock,
|
|
clearColumnTypeCacheMock,
|
|
} = vi.hoisted(() => ({
|
|
queryMock: vi.fn(),
|
|
connectMock: vi.fn(),
|
|
clientQueryMock: vi.fn(),
|
|
releaseMock: vi.fn(),
|
|
spawnMock: vi.fn(),
|
|
clearColumnTypeCacheMock: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../../src/infra/database/database.manager', () => ({
|
|
DatabaseManager: {
|
|
getInstance: vi.fn(() => ({
|
|
getPool: vi.fn(() => ({
|
|
query: queryMock,
|
|
connect: connectMock,
|
|
})),
|
|
})),
|
|
clearColumnTypeCache: clearColumnTypeCacheMock,
|
|
},
|
|
}));
|
|
|
|
vi.mock('../../src/infra/config/app.config', () => ({
|
|
appConfig: {
|
|
storage: {
|
|
s3Bucket: undefined,
|
|
appKey: 'local',
|
|
awsRegion: 'us-east-2',
|
|
storageDir: '/tmp/insforge-backup-service-tests',
|
|
},
|
|
database: {
|
|
host: 'localhost',
|
|
port: 5432,
|
|
name: 'insforge',
|
|
user: 'postgres',
|
|
password: 'postgres',
|
|
},
|
|
},
|
|
}));
|
|
|
|
vi.mock('../../src/providers/storage/s3.provider', () => ({
|
|
S3StorageProvider: vi.fn(),
|
|
}));
|
|
|
|
const { loggerMock } = vi.hoisted(() => ({
|
|
loggerMock: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
|
}));
|
|
|
|
vi.mock('../../src/utils/logger', () => ({ default: loggerMock, logger: loggerMock }));
|
|
|
|
vi.mock('node:child_process', () => ({
|
|
spawn: spawnMock,
|
|
}));
|
|
|
|
import { DatabaseBackupService } from '../../src/services/database/database-backup.service';
|
|
|
|
interface FakeChildOptions {
|
|
exitCode?: number;
|
|
stdoutData?: Buffer | null;
|
|
stderrData?: string;
|
|
autoClose?: boolean;
|
|
}
|
|
|
|
function makeFakeChild(options: FakeChildOptions = {}) {
|
|
const { exitCode = 0, stdoutData = Buffer.from('dump-bytes'), stderrData = '' } = options;
|
|
const child = new EventEmitter() as EventEmitter & {
|
|
stdout: PassThrough;
|
|
stderr: PassThrough;
|
|
stdin: Writable;
|
|
};
|
|
child.stdout = new PassThrough();
|
|
child.stderr = new PassThrough();
|
|
child.stdin = new Writable({
|
|
write(_chunk, _encoding, callback) {
|
|
callback();
|
|
},
|
|
});
|
|
|
|
const finish = () => {
|
|
if (stderrData) {
|
|
child.stderr.write(stderrData);
|
|
}
|
|
child.stderr.end();
|
|
if (stdoutData) {
|
|
child.stdout.write(stdoutData);
|
|
}
|
|
child.stdout.end();
|
|
setImmediate(() => child.emit('close', exitCode));
|
|
};
|
|
|
|
if (options.autoClose !== false) {
|
|
setImmediate(finish);
|
|
}
|
|
|
|
return { child, finish };
|
|
}
|
|
|
|
async function waitForIdle(service: DatabaseBackupService) {
|
|
await vi.waitFor(() => {
|
|
expect((service as unknown as { activeBackupId: string | null }).activeBackupId).toBeNull();
|
|
});
|
|
}
|
|
|
|
function backupRow(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
id: '00000000-0000-4000-8000-000000000001',
|
|
name: null,
|
|
triggerSource: 'manual',
|
|
status: 'running',
|
|
sizeBytes: null,
|
|
errorMessage: null,
|
|
createdAt: new Date('2026-06-10T00:00:00Z'),
|
|
completedAt: null,
|
|
createdBy: 'admin',
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe('DatabaseBackupService', () => {
|
|
beforeAll(async () => {
|
|
await fs.mkdir(STORAGE_DIR, { recursive: true });
|
|
});
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
connectMock.mockResolvedValue({ query: clientQueryMock, release: releaseMock });
|
|
clientQueryMock.mockResolvedValue({ rows: [] });
|
|
});
|
|
|
|
describe('listBackups', () => {
|
|
it('sweeps orphaned backup temp dirs when no backup is in flight', async () => {
|
|
const orphanDir = path.join(STORAGE_DIR, '.backup-tmp-orphan');
|
|
await fs.mkdir(orphanDir, { recursive: true });
|
|
|
|
queryMock.mockResolvedValue({ rows: [] });
|
|
const service = DatabaseBackupService.getInstance();
|
|
await service.listBackups();
|
|
|
|
await expect(fs.stat(orphanDir)).rejects.toMatchObject({ code: 'ENOENT' });
|
|
});
|
|
|
|
it('never sweeps temp dirs while a backup is in flight', async () => {
|
|
const liveDir = path.join(STORAGE_DIR, '.backup-tmp-live');
|
|
await fs.mkdir(liveDir, { recursive: true });
|
|
|
|
queryMock.mockImplementation((sql: string) => {
|
|
if (sql.includes('INSERT INTO system.database_backups')) {
|
|
return Promise.resolve({ rows: [backupRow()] });
|
|
}
|
|
return Promise.resolve({ rows: [] });
|
|
});
|
|
const pending = makeFakeChild({ autoClose: false });
|
|
spawnMock.mockImplementation(() => pending.child);
|
|
|
|
const service = DatabaseBackupService.getInstance();
|
|
await service.createBackup({}, 'admin');
|
|
|
|
await service.listBackups();
|
|
await expect(fs.stat(liveDir)).resolves.toBeTruthy();
|
|
|
|
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalled());
|
|
pending.finish();
|
|
await waitForIdle(service);
|
|
await fs.rm(liveDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('fails interrupted running rows, then returns serialized backups', async () => {
|
|
queryMock.mockImplementation((sql: string) => {
|
|
if (sql.includes("SET status = 'failed'")) {
|
|
return Promise.resolve({ rows: [] });
|
|
}
|
|
return Promise.resolve({
|
|
rows: [backupRow({ status: 'completed', sizeBytes: 1234 })],
|
|
});
|
|
});
|
|
|
|
const service = DatabaseBackupService.getInstance();
|
|
const result = await service.listBackups();
|
|
|
|
expect(queryMock.mock.calls[0][0]).toContain('Interrupted by a server restart.');
|
|
expect(result.backups).toEqual([
|
|
expect.objectContaining({
|
|
id: '00000000-0000-4000-8000-000000000001',
|
|
status: 'completed',
|
|
sizeBytes: 1234,
|
|
createdAt: '2026-06-10T00:00:00.000Z',
|
|
completedAt: null,
|
|
}),
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('createBackup', () => {
|
|
it('creates a backup and marks it completed once pg_dump succeeds', async () => {
|
|
queryMock.mockImplementation((sql: string) => {
|
|
if (sql.includes('INSERT INTO system.database_backups')) {
|
|
return Promise.resolve({ rows: [backupRow({ name: 'before-upgrade' })] });
|
|
}
|
|
return Promise.resolve({ rows: [] });
|
|
});
|
|
spawnMock.mockImplementation(() => makeFakeChild().child);
|
|
|
|
const service = DatabaseBackupService.getInstance();
|
|
const backup = await service.createBackup({ name: 'before-upgrade' }, 'admin');
|
|
|
|
expect(backup).toMatchObject({ name: 'before-upgrade', status: 'running' });
|
|
|
|
// The dump runs asynchronously after createBackup returns, so wait for
|
|
// the terminal status update before asserting on the side effects.
|
|
await vi.waitFor(() => {
|
|
const completedCall = queryMock.mock.calls.find(([sql]) =>
|
|
(sql as string).includes("SET status = 'completed'")
|
|
);
|
|
expect(completedCall).toBeDefined();
|
|
});
|
|
|
|
expect(spawnMock).toHaveBeenCalledWith(
|
|
'pg_dump',
|
|
expect.arrayContaining(['--format=custom', '-d', 'insforge']),
|
|
expect.objectContaining({ env: expect.objectContaining({ PGPASSWORD: 'postgres' }) })
|
|
);
|
|
|
|
const completedCall = queryMock.mock.calls.find(([sql]) =>
|
|
(sql as string).includes("SET status = 'completed'")
|
|
);
|
|
const [, params] = completedCall as [string, unknown[]];
|
|
const storageKey = params[1] as string;
|
|
expect(storageKey).toMatch(/^\d{8}_\d{6}_[0-9a-f-]{36}\.dump$/);
|
|
expect(params[2]).toBe(Buffer.from('dump-bytes').length);
|
|
|
|
const artifact = await fs.readFile(
|
|
path.join(STORAGE_DIR, '_database_backups', storageKey),
|
|
'utf8'
|
|
);
|
|
expect(artifact).toBe('dump-bytes');
|
|
|
|
await waitForIdle(service);
|
|
});
|
|
|
|
it('marks the backup failed when pg_dump exits nonzero', async () => {
|
|
queryMock.mockImplementation((sql: string) => {
|
|
if (sql.includes('INSERT INTO system.database_backups')) {
|
|
return Promise.resolve({ rows: [backupRow()] });
|
|
}
|
|
return Promise.resolve({ rows: [] });
|
|
});
|
|
spawnMock.mockImplementation(
|
|
() => makeFakeChild({ exitCode: 1, stderrData: 'connection refused' }).child
|
|
);
|
|
|
|
const service = DatabaseBackupService.getInstance();
|
|
await service.createBackup({}, 'admin');
|
|
|
|
await vi.waitFor(() => {
|
|
const failedCall = queryMock.mock.calls.find(([sql]) =>
|
|
(sql as string).includes("SET status = 'failed'")
|
|
);
|
|
expect(failedCall).toBeDefined();
|
|
});
|
|
|
|
const failedCall = queryMock.mock.calls.find(([sql]) =>
|
|
(sql as string).includes("SET status = 'failed'")
|
|
);
|
|
const [, params] = failedCall as [string, unknown[]];
|
|
expect(params[1]).toContain('pg_dump exited with code 1');
|
|
expect(params[1]).toContain('connection refused');
|
|
|
|
await waitForIdle(service);
|
|
});
|
|
|
|
it('rejects a duplicate backup name with a 409', async () => {
|
|
queryMock.mockRejectedValueOnce(
|
|
Object.assign(new Error('duplicate key value'), { code: '23505' })
|
|
);
|
|
|
|
const service = DatabaseBackupService.getInstance();
|
|
await expect(service.createBackup({ name: 'dup' }, 'admin')).rejects.toMatchObject({
|
|
statusCode: 409,
|
|
code: ERROR_CODES.DATABASE_DUPLICATE,
|
|
});
|
|
});
|
|
|
|
it('rejects concurrent backups while one is still running', async () => {
|
|
queryMock.mockImplementation((sql: string) => {
|
|
if (sql.includes('INSERT INTO system.database_backups')) {
|
|
return Promise.resolve({ rows: [backupRow()] });
|
|
}
|
|
return Promise.resolve({ rows: [] });
|
|
});
|
|
const pending = makeFakeChild({ autoClose: false });
|
|
spawnMock.mockImplementation(() => pending.child);
|
|
|
|
const service = DatabaseBackupService.getInstance();
|
|
await service.createBackup({}, 'admin');
|
|
|
|
await expect(service.createBackup({}, 'admin')).rejects.toMatchObject({
|
|
statusCode: 409,
|
|
code: ERROR_CODES.DATABASE_CONSTRAINT_VIOLATION,
|
|
});
|
|
|
|
// Let the running dump finish only after the service has wired up the
|
|
// child process, otherwise the 'close' event fires with no listener.
|
|
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalled());
|
|
pending.finish();
|
|
await waitForIdle(service);
|
|
});
|
|
|
|
it('reserves the concurrency guard before the first await', async () => {
|
|
queryMock.mockImplementation((sql: string) => {
|
|
if (sql.includes('INSERT INTO system.database_backups')) {
|
|
return Promise.resolve({ rows: [backupRow()] });
|
|
}
|
|
return Promise.resolve({ rows: [] });
|
|
});
|
|
spawnMock.mockImplementation(() => makeFakeChild().child);
|
|
|
|
const service = DatabaseBackupService.getInstance();
|
|
// Fire both before either INSERT resolves; without a synchronous
|
|
// reservation both would pass the guard.
|
|
const first = service.createBackup({}, 'admin');
|
|
const second = service.createBackup({}, 'admin');
|
|
|
|
await expect(second).rejects.toMatchObject({
|
|
statusCode: 409,
|
|
code: ERROR_CODES.DATABASE_CONSTRAINT_VIOLATION,
|
|
});
|
|
await first;
|
|
await waitForIdle(service);
|
|
});
|
|
});
|
|
|
|
describe('restoreBackup', () => {
|
|
it('refuses to restore a backup that is not completed', async () => {
|
|
queryMock.mockImplementation((sql: string) => {
|
|
if (sql.includes('WHERE id = $1')) {
|
|
return Promise.resolve({ rows: [backupRow({ status: 'failed' })] });
|
|
}
|
|
return Promise.resolve({ rows: [] });
|
|
});
|
|
|
|
const service = DatabaseBackupService.getInstance();
|
|
await expect(service.restoreBackup('some-id')).rejects.toMatchObject({
|
|
statusCode: 409,
|
|
code: ERROR_CODES.DATABASE_CONSTRAINT_VIOLATION,
|
|
});
|
|
});
|
|
|
|
it('blocks a restore while a rename or delete is in flight', async () => {
|
|
let releaseDelete!: (value: { rows: unknown[] }) => void;
|
|
const gatedRow = new Promise<{ rows: unknown[] }>((resolve) => {
|
|
releaseDelete = resolve;
|
|
});
|
|
queryMock.mockImplementation((sql: string) => {
|
|
if (sql.includes('storage_key AS "storageKey"')) {
|
|
return gatedRow;
|
|
}
|
|
return Promise.resolve({ rows: [] });
|
|
});
|
|
|
|
const service = DatabaseBackupService.getInstance();
|
|
const deletion = service.deleteBackup('some-id');
|
|
|
|
// The delete has passed its guard but is still awaiting the row lookup;
|
|
// a restore starting now would snapshot metadata the delete is about to
|
|
// change, so it must be rejected.
|
|
await expect(service.restoreBackup('some-id')).rejects.toMatchObject({
|
|
statusCode: 409,
|
|
code: ERROR_CODES.DATABASE_CONSTRAINT_VIOLATION,
|
|
});
|
|
|
|
releaseDelete({ rows: [backupRow({ status: 'completed', storageKey: 'gone.dump' })] });
|
|
await deletion;
|
|
});
|
|
|
|
it('blocks concurrent restores and metadata mutations while a restore runs', async () => {
|
|
const storageKey = '20260610_040506.dump';
|
|
const artifactDir = path.join(STORAGE_DIR, '_database_backups');
|
|
await fs.mkdir(artifactDir, { recursive: true });
|
|
await fs.writeFile(path.join(artifactDir, storageKey), 'archive-bytes');
|
|
|
|
queryMock.mockImplementation((sql: string) => {
|
|
if (sql.includes('WHERE id = $1')) {
|
|
return Promise.resolve({
|
|
rows: [backupRow({ status: 'completed', storageKey })],
|
|
});
|
|
}
|
|
return Promise.resolve({ rows: [] });
|
|
});
|
|
const pending = makeFakeChild({ stdoutData: null, autoClose: false });
|
|
spawnMock.mockImplementation(() => pending.child);
|
|
|
|
const service = DatabaseBackupService.getInstance();
|
|
const restore = service.restoreBackup('some-id');
|
|
|
|
const conflict = {
|
|
statusCode: 409,
|
|
code: ERROR_CODES.DATABASE_CONSTRAINT_VIOLATION,
|
|
};
|
|
await expect(service.restoreBackup('some-id')).rejects.toMatchObject(conflict);
|
|
await expect(service.deleteBackup('some-id')).rejects.toMatchObject(conflict);
|
|
await expect(service.renameBackup('some-id', 'x')).rejects.toMatchObject(conflict);
|
|
await expect(service.createBackup({}, 'admin')).rejects.toMatchObject(conflict);
|
|
|
|
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalled());
|
|
pending.finish();
|
|
await restore;
|
|
});
|
|
|
|
it('restores the archive and reinstates the backup metadata snapshot', async () => {
|
|
const storageKey = '20260610_010203.dump';
|
|
const artifactDir = path.join(STORAGE_DIR, '_database_backups');
|
|
await fs.mkdir(artifactDir, { recursive: true });
|
|
await fs.writeFile(path.join(artifactDir, storageKey), 'archive-bytes');
|
|
|
|
const snapshotRow = {
|
|
id: '00000000-0000-4000-8000-000000000002',
|
|
name: 'kept',
|
|
trigger_source: 'manual',
|
|
status: 'completed',
|
|
storage_key: storageKey,
|
|
size_bytes: 42,
|
|
error_message: null,
|
|
created_by: 'admin',
|
|
completed_at: new Date('2026-06-09T00:00:00Z'),
|
|
created_at: new Date('2026-06-09T00:00:00Z'),
|
|
updated_at: new Date('2026-06-09T00:00:00Z'),
|
|
};
|
|
|
|
queryMock.mockImplementation((sql: string) => {
|
|
if (sql.includes('WHERE id = $1')) {
|
|
return Promise.resolve({
|
|
rows: [backupRow({ status: 'completed', storageKey })],
|
|
});
|
|
}
|
|
return Promise.resolve({ rows: [snapshotRow] });
|
|
});
|
|
spawnMock.mockImplementation(() => makeFakeChild({ stdoutData: null }).child);
|
|
|
|
const service = DatabaseBackupService.getInstance();
|
|
await service.restoreBackup('some-id');
|
|
|
|
expect(spawnMock).toHaveBeenCalledWith(
|
|
'pg_restore',
|
|
expect.arrayContaining(['--clean', '--if-exists', '--single-transaction']),
|
|
expect.objectContaining({
|
|
env: expect.objectContaining({ PGAPPNAME: 'insforge-backup-restore' }),
|
|
})
|
|
);
|
|
// Terminating other sessions would crash the backend's own long-lived
|
|
// clients (realtime LISTEN), so the restore flow must never do it.
|
|
expect(
|
|
queryMock.mock.calls.some(([sql]) => (sql as string).includes('pg_terminate_backend'))
|
|
).toBe(false);
|
|
|
|
const clientSql = clientQueryMock.mock.calls.map(([sql]) => sql as string);
|
|
expect(clientSql).toContain('BEGIN');
|
|
expect(clientSql.some((sql) => sql.includes('TRUNCATE system.database_backups'))).toBe(true);
|
|
expect(clientSql.some((sql) => sql.includes('INSERT INTO system.database_backups'))).toBe(
|
|
true
|
|
);
|
|
expect(clientSql).toContain('COMMIT');
|
|
// The PostgREST reload runs outside the write-back transaction so a
|
|
// write-back failure cannot leave PostgREST on a stale schema.
|
|
expect(queryMock.mock.calls.some(([sql]) => (sql as string).includes('NOTIFY pgrst'))).toBe(
|
|
true
|
|
);
|
|
expect(clearColumnTypeCacheMock).toHaveBeenCalled();
|
|
expect(releaseMock).toHaveBeenCalled();
|
|
});
|
|
|
|
it('terminates a restore stuck waiting on a database lock', async () => {
|
|
const storageKey = '20260611_050607.dump';
|
|
const artifactDir = path.join(STORAGE_DIR, '_database_backups');
|
|
await fs.mkdir(artifactDir, { recursive: true });
|
|
await fs.writeFile(path.join(artifactDir, storageKey), 'archive-bytes');
|
|
|
|
const terminateCalls: unknown[][] = [];
|
|
queryMock.mockImplementation((sql: string, params?: unknown[]) => {
|
|
if (sql.includes('WHERE id = $1') && !sql.includes('pg_terminate_backend')) {
|
|
return Promise.resolve({
|
|
rows: [backupRow({ status: 'completed', storageKey })],
|
|
});
|
|
}
|
|
if (sql.includes('pg_stat_activity')) {
|
|
return Promise.resolve({ rows: [{ pid: 4242, waitEventType: 'Lock' }] });
|
|
}
|
|
if (sql.includes('pg_terminate_backend')) {
|
|
terminateCalls.push(params ?? []);
|
|
return Promise.resolve({ rows: [] });
|
|
}
|
|
return Promise.resolve({ rows: [] });
|
|
});
|
|
const pending = makeFakeChild({
|
|
stdoutData: null,
|
|
autoClose: false,
|
|
exitCode: 1,
|
|
stderrData: 'FATAL: terminating connection due to administrator command',
|
|
});
|
|
spawnMock.mockImplementation(() => pending.child);
|
|
|
|
vi.useFakeTimers();
|
|
try {
|
|
const service = DatabaseBackupService.getInstance();
|
|
const restore = service.restoreBackup('some-id');
|
|
restore.catch(() => {});
|
|
|
|
// 5s polls; the session reports Lock-waiting every tick, so the
|
|
// watchdog should terminate it once 30s of continuous waiting passes.
|
|
await vi.advanceTimersByTimeAsync(45_000);
|
|
expect(terminateCalls).toHaveLength(1);
|
|
expect(terminateCalls[0]).toEqual([4242]);
|
|
|
|
vi.useRealTimers();
|
|
// The terminated pg_restore exits nonzero; the service maps it to a
|
|
// clear lock-timeout error instead of the raw stderr.
|
|
pending.finish();
|
|
await expect(restore).rejects.toMatchObject({
|
|
statusCode: 409,
|
|
message: expect.stringContaining('waiting more than 30s for a database lock'),
|
|
});
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it('still reports success when the metadata write-back fails after retries', async () => {
|
|
const storageKey = '20260611_010203.dump';
|
|
const artifactDir = path.join(STORAGE_DIR, '_database_backups');
|
|
await fs.mkdir(artifactDir, { recursive: true });
|
|
await fs.writeFile(path.join(artifactDir, storageKey), 'archive-bytes');
|
|
|
|
queryMock.mockImplementation((sql: string) => {
|
|
if (sql.includes('WHERE id = $1')) {
|
|
return Promise.resolve({
|
|
rows: [backupRow({ status: 'completed', storageKey })],
|
|
});
|
|
}
|
|
return Promise.resolve({ rows: [] });
|
|
});
|
|
clientQueryMock.mockImplementation((sql: string) => {
|
|
if (sql.includes('TRUNCATE')) {
|
|
return Promise.reject(new Error('deadlock detected'));
|
|
}
|
|
return Promise.resolve({ rows: [] });
|
|
});
|
|
spawnMock.mockImplementation(() => makeFakeChild({ stdoutData: null }).child);
|
|
|
|
const service = DatabaseBackupService.getInstance();
|
|
await expect(service.restoreBackup('some-id')).resolves.toBeUndefined();
|
|
|
|
// Three attempts, each rolled back.
|
|
const truncates = clientQueryMock.mock.calls.filter(([sql]) =>
|
|
(sql as string).includes('TRUNCATE')
|
|
);
|
|
expect(truncates).toHaveLength(3);
|
|
expect(loggerMock.error).toHaveBeenCalledWith(
|
|
expect.stringContaining('Failed to write back backup metadata'),
|
|
expect.objectContaining({ error: 'deadlock detected' })
|
|
);
|
|
// PostgREST is still reloaded even though the write-back failed.
|
|
expect(queryMock.mock.calls.some(([sql]) => (sql as string).includes('NOTIFY pgrst'))).toBe(
|
|
true
|
|
);
|
|
});
|
|
});
|
|
});
|