Files
promptfoo--promptfoo/test/database/isolation.test.ts
T
wehub-resource-sync 0d3cb498a3
Deploy local.promptfoo.app / Deploy to Cloudflare Pages (push) Waiting to run
Test and Publish Multi-arch Docker Image / test (push) Waiting to run
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-amd64 platform:linux/amd64 runner:ubuntu-latest]) (push) Blocked by required conditions
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-arm64 platform:linux/arm64 runner:ubuntu-24.04-arm]) (push) Blocked by required conditions
Test and Publish Multi-arch Docker Image / merge-docker-digests (push) Blocked by required conditions
Test and Publish Multi-arch Docker Image / Attest Multi-arch Image (push) Blocked by required conditions
Validate Renovate Config / Validate Renovate Configuration (push) Waiting to run
CI / Shell Format Check (push) Has been cancelled
CI / Check Ruby (3.4) (push) Has been cancelled
CI / CI Config (push) Has been cancelled
CI / Test on Node ${{ matrix.node }} and ${{ matrix.os }}${{ matrix.shard && format(' (shard {0}/3)', matrix.shard) || '' }} (push) Has been cancelled
CI / Build on Node ${{ matrix.node }} (push) Has been cancelled
CI / Style Check (push) Has been cancelled
CI / Generate Assets (push) Has been cancelled
CI / Check Python (3.14) (push) Has been cancelled
CI / Check Python (3.9) (push) Has been cancelled
CI / Build Docs (push) Has been cancelled
CI / Code Scan Action (push) Has been cancelled
CI / Site tests (push) Has been cancelled
CI / webui tests (push) Has been cancelled
CI / Run Integration Tests (push) Has been cancelled
CI / Run Smoke Tests (push) Has been cancelled
CI / Go Tests (push) Has been cancelled
CI / Share Test (push) Has been cancelled
CI / Redteam (Production API) (push) Has been cancelled
CI / Redteam (Staging API) (push) Has been cancelled
CI / GitHub Actions Lint (push) Has been cancelled
CI / Check Ruby (3.0) (push) Has been cancelled
release-please / release-please (push) Has been cancelled
release-please / build (push) Has been cancelled
release-please / publish-npm (push) Has been cancelled
release-please / publish-npm-backfill (push) Has been cancelled
release-please / docker (push) Has been cancelled
release-please / publish-code-scan-action (push) Has been cancelled
release-please / attest-code-scan-action (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:24:08 +08:00

173 lines
7.1 KiB
TypeScript

import { createClient } from '@libsql/client/node';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
closeTestDatabaseClient,
closeTestDatabaseClients,
registerTestDatabaseClient,
resetTestDatabaseClient,
} from '../../src/database/testing';
import { sleep } from '../../src/util/time';
import { mockProcessEnv } from '../util/utils';
// Mirror of the testing database URL in src/database/index.ts. The test database is a
// process-wide shared-cache in-memory libsql DB so its internal connections see one schema.
const SHARED_CACHE_MEMORY_URL = 'file::memory:?cache=shared';
describe('database test isolation', () => {
afterEach(async () => {
await closeTestDatabaseClients();
vi.resetModules();
});
it('keeps the testing database intact until the last isolated module instance closes', async () => {
const firstDatabaseModule = await import('../../src/database/index');
const firstDb = await firstDatabaseModule.getDb();
await firstDb.run('CREATE TABLE isolated_module_marker (id TEXT PRIMARY KEY)');
await firstDb.run("INSERT INTO isolated_module_marker VALUES ('first')");
vi.resetModules();
const secondDatabaseModule = await import('../../src/database/index');
const secondDb = await secondDatabaseModule.getDb();
await expect(secondDb.all('SELECT id FROM isolated_module_marker')).resolves.toEqual([
{ id: 'first' },
]);
await firstDatabaseModule.closeDb();
await expect(secondDb.all('SELECT id FROM isolated_module_marker')).resolves.toEqual([
{ id: 'first' },
]);
await secondDatabaseModule.closeDb();
vi.resetModules();
const thirdDatabaseModule = await import('../../src/database/index');
const thirdDb = await thirdDatabaseModule.getDb();
await expect(thirdDb.all('SELECT id FROM isolated_module_marker')).rejects.toThrow();
});
it('drops supported schema objects while escaping their identifiers', async () => {
const execute = vi
.fn()
.mockResolvedValueOnce({
rows: [
{ type: 'table', name: 'table"name' },
{ type: 'view', name: 'isolated_view' },
{ type: 'trigger', name: 'isolated_trigger' },
{ type: 'index', name: 'ignored_index' },
],
})
.mockResolvedValue({ rows: [] });
const client = {
close: vi.fn(),
execute,
} as unknown as Parameters<typeof resetTestDatabaseClient>[0];
await resetTestDatabaseClient(client);
expect(execute).toHaveBeenCalledWith('DROP TABLE IF EXISTS "table""name"');
expect(execute).toHaveBeenCalledWith('DROP VIEW IF EXISTS "isolated_view"');
expect(execute).toHaveBeenCalledWith('DROP TRIGGER IF EXISTS "isolated_trigger"');
expect(execute).not.toHaveBeenCalledWith('DROP INDEX IF EXISTS "ignored_index"');
expect(execute).toHaveBeenLastCalledWith('PRAGMA foreign_keys = ON');
});
it('waits for schema cleanup before registering another client', async () => {
let resolveSchemaQuery!: (value: { rows: never[] }) => void;
const firstClient = {
close: vi.fn(),
execute: vi
.fn()
.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveSchemaQuery = resolve;
}),
)
.mockResolvedValue({ rows: [] }),
} as unknown as Parameters<typeof registerTestDatabaseClient>[0];
const secondClient = {
close: vi.fn(),
execute: vi.fn().mockResolvedValue({ rows: [] }),
} as unknown as Parameters<typeof registerTestDatabaseClient>[0];
await registerTestDatabaseClient(firstClient);
const closePromise = closeTestDatabaseClient(firstClient);
await vi.waitFor(() => expect(firstClient.execute).toHaveBeenCalledTimes(1));
let registrationFinished = false;
const registerPromise = registerTestDatabaseClient(secondClient).then(() => {
registrationFinished = true;
});
await Promise.resolve();
expect(registrationFinished).toBe(false);
resolveSchemaQuery({ rows: [] });
await closePromise;
await registerPromise;
expect(registrationFinished).toBe(true);
await closeTestDatabaseClient(secondClient);
expect(firstClient.close).toHaveBeenCalledOnce();
expect(secondClient.close).toHaveBeenCalledOnce();
});
it('uses the initialization mode when the environment changes before close', async () => {
const firstDatabaseModule = await import('../../src/database/index');
const firstDb = await firstDatabaseModule.getDb();
await firstDb.run('CREATE TABLE env_change_marker (id TEXT PRIMARY KEY)');
const restoreEnv = mockProcessEnv({ IS_TESTING: undefined });
try {
await firstDatabaseModule.closeDb();
} finally {
restoreEnv();
}
vi.resetModules();
const secondDatabaseModule = await import('../../src/database/index');
const secondDb = await secondDatabaseModule.getDb();
await expect(secondDb.all('SELECT id FROM env_change_marker')).rejects.toThrow();
});
// Regression: shared-cache uses table-level locks, so a read that overlaps another
// connection's open write transaction fails immediately with SQLITE_LOCKED_SHAREDCACHE
// (busy_timeout only covers SQLITE_BUSY). libsql runs interactive transactions on their
// own connections, so a prior writer's lock can briefly outlive the JS promise that
// settled it — which is what made `import`'s collision check (Eval.findById's
// `SELECT ... FROM evals`) flake. Top-level statements now retry the transient lock
// instead of throwing, riding through the window without weakening isolation.
it('retries a read locked by another connection until the lock releases, without dirty reads', async () => {
const databaseModule = await import('../../src/database/index');
const db = await databaseModule.getDb();
await db.run('CREATE TABLE lock_probe (id TEXT PRIMARY KEY)');
await db.run("INSERT INTO lock_probe VALUES ('committed')");
// Independent connection to the same shared cache, holding an open write lock on the
// probe table — standing in for libsql's lingering interactive-transaction connection.
const lockHolder = createClient({ url: SHARED_CACHE_MEMORY_URL });
const writeTx = await lockHolder.transaction('write');
await writeTx.execute("INSERT INTO lock_probe VALUES ('uncommitted')");
// Top-level read through the serialized main connection (exactly like Eval.findById).
// It contends with the held lock; without the retry it rejects immediately with
// SQLITE_LOCKED. Capture the outcome so the still-pending read never rejects unhandled.
const readOutcome = db
.all('SELECT id FROM lock_probe ORDER BY id')
.then((rows) => ({ rows }))
.catch((error) => ({ error }));
// Release the lock — discarding the uncommitted row — while the read is still retrying.
await sleep(30);
await writeTx.rollback();
lockHolder.close();
// The read rode through the lock and observed only the committed row: the retry never
// exposed the rolled-back 'uncommitted' write, so isolation stays production-like.
expect(await readOutcome).toEqual({ rows: [{ id: 'committed' }] });
await databaseModule.closeDb();
});
});