Files
promptfoo--promptfoo/test/database/index.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

686 lines
25 KiB
TypeScript

import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import cliState from '../../src/cliState';
import {
closeDb,
DrizzleLogWriter,
getDb,
getDbPath,
getDbSignalPath,
isDbOpen,
} from '../../src/database/index';
import { getEnvBool } from '../../src/envars';
import logger from '../../src/logger';
import { getConfigDirectoryPath } from '../../src/util/config/manage';
import { mockProcessEnv } from '../util/utils';
vi.mock('../../src/envars', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/envars')>();
return { ...actual, getEnvBool: vi.fn(actual.getEnvBool) };
});
vi.mock('../../src/logger');
vi.mock('../../src/util/config/manage');
vi.mock('os', async (importOriginal) => {
const actual = await importOriginal<typeof import('os')>();
return { ...actual, homedir: vi.fn(actual.homedir) };
});
// Passthrough fs mock with a fault-injection switch for statSync; the ESM namespace
// itself cannot be spied on.
const statSyncFault = vi.hoisted(() => ({ error: undefined as Error | undefined }));
vi.mock('fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('fs')>();
return {
...actual,
statSync: ((...args: Parameters<typeof actual.statSync>) => {
if (statSyncFault.error) {
throw statSyncFault.error;
}
return actual.statSync(...args);
}) as typeof actual.statSync,
};
});
const ORIGINAL_HOME_DIR = os.homedir();
const VITEST_WORKER_MARKER = '__vitest_worker__';
const JEST_NATIVE_PROMISE_MARKER = Symbol.for('jest-native-promise');
function withTestRunnerMarkers<T>(
markers: { vitest?: boolean; jest?: boolean },
callback: () => T,
): T {
const markerKeys: PropertyKey[] = [VITEST_WORKER_MARKER, JEST_NATIVE_PROMISE_MARKER];
const descriptors = markerKeys.map(
(key) => [key, Object.getOwnPropertyDescriptor(globalThis, key)] as const,
);
try {
for (const key of markerKeys) {
Reflect.deleteProperty(globalThis, key);
}
if (markers.vitest) {
Object.defineProperty(globalThis, VITEST_WORKER_MARKER, {
configurable: true,
value: true,
});
}
if (markers.jest) {
Object.defineProperty(globalThis, JEST_NATIVE_PROMISE_MARKER, {
configurable: true,
value: Promise,
});
}
return callback();
} finally {
for (const [key, descriptor] of descriptors) {
if (descriptor) {
Object.defineProperty(globalThis, key, descriptor);
} else {
Reflect.deleteProperty(globalThis, key);
}
}
}
}
describe('database', () => {
let tempConfigDir: string;
beforeEach(async () => {
vi.clearAllMocks();
statSyncFault.error = undefined;
vi.mocked(os.homedir).mockReset();
vi.mocked(os.homedir).mockReturnValue(ORIGINAL_HOME_DIR);
vi.mocked(getConfigDirectoryPath).mockReset();
vi.mocked(getEnvBool).mockReset();
await closeDb();
cliState.config = undefined;
tempConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), 'promptfoo-db-index-'));
vi.mocked(getConfigDirectoryPath).mockReturnValue(tempConfigDir);
vi.mocked(getEnvBool).mockImplementation((key) => {
if (key === 'IS_TESTING') {
return true;
}
return false;
});
});
afterEach(async () => {
await closeDb();
cliState.config = undefined;
vi.unstubAllEnvs();
fs.rmSync(tempConfigDir, { force: true, recursive: true });
});
describe('getDbPath', () => {
it('should return path in config directory', () => {
const configPath = '/test/config/path';
vi.mocked(getConfigDirectoryPath).mockReturnValue(configPath);
expect(getDbPath()).toBe(path.resolve(configPath, 'promptfoo.db'));
});
it('should allow a missing isolated database directory', () => {
const configPath = path.join(tempConfigDir, 'missing-config');
vi.mocked(getConfigDirectoryPath).mockImplementation((createIfNotExists = false) => {
if (createIfNotExists) {
fs.mkdirSync(configPath, { recursive: true });
}
return configPath;
});
vi.stubEnv('VITEST', 'true');
expect(getDbPath()).toBe(path.join(configPath, 'promptfoo.db'));
expect(fs.existsSync(configPath)).toBe(true);
});
it('should refuse to use the default user database when the process is running tests', () => {
vi.mocked(getConfigDirectoryPath).mockReturnValue(path.join(os.homedir(), '.promptfoo'));
vi.stubEnv('VITEST', 'true');
expect(() => withTestRunnerMarkers({}, () => getDbPath())).toThrow(
'Refusing to open the default Promptfoo database while running tests',
);
});
it('should allow the default user database when only NODE_ENV is test', () => {
const defaultConfigDir = path.join(os.homedir(), '.promptfoo');
vi.mocked(getConfigDirectoryPath).mockReturnValue(defaultConfigDir);
vi.stubEnv('NODE_ENV', 'test');
vi.stubEnv('VITEST', undefined);
vi.stubEnv('JEST_WORKER_ID', undefined);
const dbPath = withTestRunnerMarkers({}, () => getDbPath());
expect(dbPath).toBe(path.join(defaultConfigDir, 'promptfoo.db'));
});
it('should not use Promptfoo env overrides to identify a test process', () => {
const defaultConfigDir = path.join(os.homedir(), '.promptfoo');
vi.mocked(getConfigDirectoryPath).mockReturnValue(defaultConfigDir);
cliState.config = { env: { VITEST: 'true', JEST_WORKER_ID: '1' } };
vi.stubEnv('VITEST', undefined);
vi.stubEnv('JEST_WORKER_ID', undefined);
const dbPath = withTestRunnerMarkers({}, () => getDbPath());
expect(dbPath).toBe(path.join(defaultConfigDir, 'promptfoo.db'));
});
it('should detect Vitest after the process environment is cleared', () => {
vi.mocked(getConfigDirectoryPath).mockReturnValue(path.join(os.homedir(), '.promptfoo'));
const restoreEnv = mockProcessEnv({}, { clear: true });
let error: unknown;
try {
getDbPath();
} catch (caught) {
error = caught;
} finally {
restoreEnv();
}
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toContain(
'Refusing to open the default Promptfoo database while running tests',
);
});
it('should detect Jest after the process environment is cleared', () => {
vi.mocked(getConfigDirectoryPath).mockReturnValue(path.join(os.homedir(), '.promptfoo'));
const restoreEnv = mockProcessEnv({}, { clear: true });
let error: unknown;
try {
withTestRunnerMarkers({ jest: true }, () => getDbPath());
} catch (caught) {
error = caught;
} finally {
restoreEnv();
}
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toContain(
'Refusing to open the default Promptfoo database while running tests',
);
});
it('should not identify generic Jest workers as test processes', () => {
const defaultConfigDir = path.join(os.homedir(), '.promptfoo');
vi.mocked(getConfigDirectoryPath).mockReturnValue(defaultConfigDir);
vi.stubEnv('NODE_ENV', 'production');
vi.stubEnv('VITEST', undefined);
vi.stubEnv('JEST_WORKER_ID', '1');
const dbPath = withTestRunnerMarkers({}, () => getDbPath());
expect(dbPath).toBe(path.join(defaultConfigDir, 'promptfoo.db'));
});
it.each(['', '0', 'false'])('should ignore VITEST=%j outside Vitest', (value) => {
const defaultConfigDir = path.join(os.homedir(), '.promptfoo');
vi.mocked(getConfigDirectoryPath).mockReturnValue(defaultConfigDir);
vi.stubEnv('VITEST', value);
vi.stubEnv('JEST_WORKER_ID', undefined);
const dbPath = withTestRunnerMarkers({}, () => getDbPath());
expect(dbPath).toBe(path.join(defaultConfigDir, 'promptfoo.db'));
});
it('should refuse an alias that resolves to the default user database', () => {
const fakeHomeDir = path.join(tempConfigDir, 'home');
const defaultConfigDir = path.join(fakeHomeDir, '.promptfoo');
const aliasedConfigDir = path.join(tempConfigDir, 'aliased-config');
fs.mkdirSync(defaultConfigDir, { recursive: true });
fs.symlinkSync(
defaultConfigDir,
aliasedConfigDir,
process.platform === 'win32' ? 'junction' : 'dir',
);
vi.mocked(os.homedir).mockReturnValue(fakeHomeDir);
vi.mocked(getConfigDirectoryPath).mockReturnValue(aliasedConfigDir);
vi.stubEnv('VITEST', 'true');
expect(() => getDbPath()).toThrow(
'Refusing to open the default Promptfoo database while running tests',
);
});
it('should refuse an alias that resolves to a missing default database directory', () => {
const fakeHomeDir = path.join(tempConfigDir, 'missing-home');
const aliasedHomeDir = path.join(tempConfigDir, 'aliased-home');
const aliasedConfigDir = path.join(aliasedHomeDir, '.promptfoo');
fs.mkdirSync(fakeHomeDir);
fs.symlinkSync(
fakeHomeDir,
aliasedHomeDir,
process.platform === 'win32' ? 'junction' : 'dir',
);
vi.mocked(os.homedir).mockReturnValue(fakeHomeDir);
vi.mocked(getConfigDirectoryPath).mockImplementation((createIfNotExists = false) => {
if (createIfNotExists) {
fs.mkdirSync(aliasedConfigDir, { recursive: true });
}
return aliasedConfigDir;
});
vi.stubEnv('VITEST', 'true');
expect(() => getDbPath()).toThrow(
'Refusing to open the default Promptfoo database while running tests',
);
expect(fs.existsSync(path.join(fakeHomeDir, '.promptfoo', 'promptfoo.db'))).toBe(false);
});
it('should refuse a hard link to the default user database', () => {
const fakeHomeDir = path.join(tempConfigDir, 'hard-link-home');
const defaultConfigDir = path.join(fakeHomeDir, '.promptfoo');
const aliasedConfigDir = path.join(tempConfigDir, 'hard-link-config');
fs.mkdirSync(defaultConfigDir, { recursive: true });
fs.mkdirSync(aliasedConfigDir, { recursive: true });
fs.writeFileSync(path.join(defaultConfigDir, 'promptfoo.db'), 'database');
fs.linkSync(
path.join(defaultConfigDir, 'promptfoo.db'),
path.join(aliasedConfigDir, 'promptfoo.db'),
);
vi.mocked(os.homedir).mockReturnValue(fakeHomeDir);
vi.mocked(getConfigDirectoryPath).mockReturnValue(aliasedConfigDir);
vi.stubEnv('VITEST', 'true');
expect(() => getDbPath()).toThrow(
'Refusing to open the default Promptfoo database while running tests',
);
});
it('should refuse a dangling file symlink to the default user database', () => {
const fakeHomeDir = path.join(tempConfigDir, 'dangling-link-home');
const defaultConfigDir = path.join(fakeHomeDir, '.promptfoo');
const aliasedConfigDir = path.join(tempConfigDir, 'dangling-link-config');
const defaultDbPath = path.join(defaultConfigDir, 'promptfoo.db');
fs.mkdirSync(defaultConfigDir, { recursive: true });
fs.mkdirSync(aliasedConfigDir, { recursive: true });
fs.symlinkSync(defaultDbPath, path.join(aliasedConfigDir, 'promptfoo.db'), 'file');
vi.mocked(os.homedir).mockReturnValue(fakeHomeDir);
vi.mocked(getConfigDirectoryPath).mockReturnValue(aliasedConfigDir);
vi.stubEnv('VITEST', 'true');
expect(() => getDbPath()).toThrow(
'Refusing to open the default Promptfoo database while running tests',
);
expect(fs.existsSync(defaultDbPath)).toBe(false);
});
it('should refuse a relative dangling file symlink chain to the default user database', () => {
const fakeHomeDir = path.join(tempConfigDir, 'relative-link-home');
const defaultConfigDir = path.join(fakeHomeDir, '.promptfoo');
const aliasedConfigDir = path.join(tempConfigDir, 'relative-link-config');
const linkDirectory = path.join(tempConfigDir, 'relative-link-hop');
const defaultDbPath = path.join(defaultConfigDir, 'promptfoo.db');
const intermediateDbPath = path.join(linkDirectory, 'promptfoo.db');
fs.mkdirSync(defaultConfigDir, { recursive: true });
fs.mkdirSync(aliasedConfigDir, { recursive: true });
fs.mkdirSync(linkDirectory, { recursive: true });
fs.symlinkSync(path.relative(linkDirectory, defaultDbPath), intermediateDbPath, 'file');
fs.symlinkSync(
path.relative(aliasedConfigDir, intermediateDbPath),
path.join(aliasedConfigDir, 'promptfoo.db'),
'file',
);
vi.mocked(os.homedir).mockReturnValue(fakeHomeDir);
vi.mocked(getConfigDirectoryPath).mockReturnValue(aliasedConfigDir);
vi.stubEnv('VITEST', 'true');
expect(() => getDbPath()).toThrow(
'Refusing to open the default Promptfoo database while running tests',
);
expect(fs.existsSync(defaultDbPath)).toBe(false);
});
it('should preserve filesystem semantics for dangling symlinks containing dot-dot', () => {
const fakeHomeDir = path.join(tempConfigDir, 'pivot-link-home');
const defaultConfigDir = path.join(fakeHomeDir, '.promptfoo');
const pivotTarget = path.join(fakeHomeDir, 'subdir');
const aliasedConfigDir = path.join(tempConfigDir, 'pivot-link-config');
const isolatedConfigDir = path.join(aliasedConfigDir, '.promptfoo');
const defaultDbPath = path.join(defaultConfigDir, 'promptfoo.db');
fs.mkdirSync(defaultConfigDir, { recursive: true });
fs.mkdirSync(pivotTarget, { recursive: true });
fs.mkdirSync(aliasedConfigDir, { recursive: true });
fs.mkdirSync(isolatedConfigDir, { recursive: true });
fs.symlinkSync(
pivotTarget,
path.join(aliasedConfigDir, 'pivot'),
process.platform === 'win32' ? 'junction' : 'dir',
);
fs.symlinkSync(
['pivot', '..', '.promptfoo', 'promptfoo.db'].join(path.sep),
path.join(aliasedConfigDir, 'promptfoo.db'),
'file',
);
vi.mocked(os.homedir).mockReturnValue(fakeHomeDir);
vi.mocked(getConfigDirectoryPath).mockReturnValue(aliasedConfigDir);
vi.stubEnv('VITEST', 'true');
const linkTarget = fs.readlinkSync(path.join(aliasedConfigDir, 'promptfoo.db'));
const pivotedConfigDir = path.isAbsolute(linkTarget)
? path.dirname(linkTarget)
: `${aliasedConfigDir}${path.sep}${path.dirname(linkTarget)}`;
const reachesDefaultConfig =
fs.realpathSync.native(pivotedConfigDir) === fs.realpathSync.native(defaultConfigDir);
if (reachesDefaultConfig) {
expect(() => getDbPath()).toThrow(
'Refusing to open the default Promptfoo database while running tests',
);
} else {
expect(getDbPath()).toBe(path.join(aliasedConfigDir, 'promptfoo.db'));
}
expect(fs.existsSync(defaultDbPath)).toBe(false);
});
it('should fail closed when stat-based identity checks error', () => {
const fakeHomeDir = path.join(tempConfigDir, 'eio-home');
const defaultConfigDir = path.join(fakeHomeDir, '.promptfoo');
const aliasedConfigDir = path.join(tempConfigDir, 'eio-aliased-config');
fs.mkdirSync(defaultConfigDir, { recursive: true });
fs.symlinkSync(
defaultConfigDir,
aliasedConfigDir,
process.platform === 'win32' ? 'junction' : 'dir',
);
vi.mocked(os.homedir).mockReturnValue(fakeHomeDir);
vi.mocked(getConfigDirectoryPath).mockReturnValue(aliasedConfigDir);
vi.stubEnv('VITEST', 'true');
statSyncFault.error = Object.assign(new Error('EIO: i/o error, stat'), { code: 'EIO' });
try {
// An indeterminate identity error must propagate instead of being read
// as "different files", which would let the alias reach the user DB.
expect(() => getDbPath()).toThrow('EIO');
} finally {
statSyncFault.error = undefined;
}
});
it('should fail closed when realpath-based identity checks error', () => {
const fakeHomeDir = path.join(tempConfigDir, 'estale-home');
const aliasedConfigDir = path.join(tempConfigDir, 'estale-aliased-config');
fs.mkdirSync(path.join(fakeHomeDir, '.promptfoo'), { recursive: true });
fs.mkdirSync(aliasedConfigDir, { recursive: true });
vi.mocked(os.homedir).mockReturnValue(fakeHomeDir);
vi.mocked(getConfigDirectoryPath).mockReturnValue(aliasedConfigDir);
vi.stubEnv('VITEST', 'true');
const injectedError = Object.assign(new Error('ESTALE: stale file handle'), {
code: 'ESTALE',
});
const realpathSpy = vi.spyOn(fs.realpathSync, 'native').mockImplementation(() => {
throw injectedError;
});
try {
expect(() => getDbPath()).toThrow('ESTALE');
} finally {
realpathSpy.mockRestore();
}
});
it('should respect filesystem case sensitivity for existing databases', () => {
const fakeHomeDir = path.join(tempConfigDir, 'case-home');
const defaultConfigDir = path.join(fakeHomeDir, '.promptfoo');
const differentlyCasedConfigDir = path.join(fakeHomeDir, '.PROMPTFOO');
fs.mkdirSync(defaultConfigDir, { recursive: true });
fs.writeFileSync(path.join(defaultConfigDir, 'promptfoo.db'), 'default database');
const isCaseInsensitive = fs.existsSync(differentlyCasedConfigDir);
if (!isCaseInsensitive) {
fs.mkdirSync(differentlyCasedConfigDir);
fs.writeFileSync(path.join(differentlyCasedConfigDir, 'promptfoo.db'), 'other database');
}
vi.mocked(os.homedir).mockReturnValue(fakeHomeDir);
vi.mocked(getConfigDirectoryPath).mockReturnValue(differentlyCasedConfigDir);
vi.stubEnv('VITEST', 'true');
if (isCaseInsensitive) {
expect(() => getDbPath()).toThrow(
'Refusing to open the default Promptfoo database while running tests',
);
} else {
expect(getDbPath()).toBe(path.join(differentlyCasedConfigDir, 'promptfoo.db'));
}
});
});
describe('getDbSignalPath', () => {
it('should return evalLastWritten path in config directory', () => {
const configPath = '/test/config/path';
vi.mocked(getConfigDirectoryPath).mockReturnValue(configPath);
expect(getDbSignalPath()).toBe(path.resolve(configPath, 'evalLastWritten'));
});
it('should allow the default signal path for in-memory tests', () => {
const defaultConfigDir = path.join(os.homedir(), '.promptfoo');
vi.mocked(getConfigDirectoryPath).mockReturnValue(defaultConfigDir);
vi.stubEnv('VITEST', 'true');
expect(getDbSignalPath()).toBe(path.join(defaultConfigDir, 'evalLastWritten'));
});
});
describe('getDb', () => {
beforeEach(() => {
vi.mocked(getEnvBool).mockImplementation((key) => {
if (key === 'IS_TESTING') {
return true;
}
return false;
});
});
it('should return a database when testing', async () => {
const db = await getDb();
expect(db).toBeDefined();
});
it('should use an in-memory database when testing', async () => {
await getDb();
expect(fs.existsSync(getDbPath())).toBe(false);
});
it('should initialize database with WAL mode', async () => {
const db = await getDb();
expect(db).toBeDefined();
});
it('should return same instance on subsequent calls', async () => {
const db1 = await getDb();
const db2 = await getDb();
expect(db1).toBe(db2);
});
it('should serialize concurrent top-level transactions', async () => {
const db = await getDb();
await db.run('CREATE TABLE transaction_queue_test (id TEXT PRIMARY KEY)');
await Promise.all([
db.transaction(async (tx) => {
await tx.run("INSERT INTO transaction_queue_test (id) VALUES ('a')");
}),
db.transaction(async (tx) => {
await tx.run("INSERT INTO transaction_queue_test (id) VALUES ('b')");
}),
]);
await expect(
db.all<{ id: string }>('SELECT id FROM transaction_queue_test ORDER BY id'),
).resolves.toEqual([{ id: 'a' }, { id: 'b' }]);
});
it('should serialize plain statements with top-level transactions', async () => {
const db = await getDb();
await db.run('CREATE TABLE transaction_plain_statement_test (id TEXT PRIMARY KEY)');
let markTransactionStarted: () => void;
const transactionStarted = new Promise<void>((resolve) => {
markTransactionStarted = resolve;
});
let releaseTransaction!: () => void;
const transactionRelease = new Promise<void>((resolve) => {
releaseTransaction = resolve;
});
const transactionPromise = db.transaction(async (tx) => {
await tx.run("INSERT INTO transaction_plain_statement_test (id) VALUES ('transaction')");
markTransactionStarted();
await transactionRelease;
});
await transactionStarted;
const statementPromise = db.run(
"INSERT INTO transaction_plain_statement_test (id) VALUES ('statement')",
);
releaseTransaction();
await Promise.all([transactionPromise, statementPromise]);
await expect(
db.all<{ id: string }>('SELECT id FROM transaction_plain_statement_test ORDER BY id'),
).resolves.toEqual([{ id: 'statement' }, { id: 'transaction' }]);
});
it('should reuse the active transaction for nested root transactions', async () => {
const db = await getDb();
await db.run('CREATE TABLE nested_transaction_test (id TEXT PRIMARY KEY)');
await expect(
Promise.race([
db.transaction(async (tx) => {
await tx.run("INSERT INTO nested_transaction_test (id) VALUES ('outer')");
await db.transaction(async (nestedTx) => {
await nestedTx.run("INSERT INTO nested_transaction_test (id) VALUES ('inner')");
});
}),
new Promise((_, reject) => {
setTimeout(() => reject(new Error('nested transaction timed out')), 1_000);
}),
]),
).resolves.toBeUndefined();
await expect(
db.all<{ id: string }>('SELECT id FROM nested_transaction_test ORDER BY id'),
).resolves.toEqual([{ id: 'inner' }, { id: 'outer' }]);
});
it('does not deadlock when a transaction callback calls root db.* helpers', async () => {
const db = await getDb();
await db.run('CREATE TABLE root_call_inside_tx_test (id INTEGER PRIMARY KEY, val TEXT)');
await expect(
Promise.race([
db.transaction(async (tx) => {
await tx.run("INSERT INTO root_call_inside_tx_test (id, val) VALUES (1, 'in-tx')");
const rows = await db.all<{ value: number }>('SELECT 1 AS value');
expect(rows[0]?.value).toBe(1);
}),
new Promise((_, reject) => {
setTimeout(
() => reject(new Error('root db call inside transaction deadlocked')),
1_000,
);
}),
]),
).resolves.toBeUndefined();
});
it('should enforce foreign keys inside top-level transactions', async () => {
const db = await getDb();
await db.run('CREATE TABLE transaction_fk_parent (id TEXT PRIMARY KEY)');
await db.run(`
CREATE TABLE transaction_fk_child (
id TEXT PRIMARY KEY,
parent_id TEXT NOT NULL REFERENCES transaction_fk_parent(id)
)
`);
await expect(
db.transaction(async (tx) => {
await tx.run(
"INSERT INTO transaction_fk_child (id, parent_id) VALUES ('child', 'missing')",
);
}),
).rejects.toThrow();
});
});
describe('DrizzleLogWriter', () => {
it('should log debug message when database logs enabled', () => {
vi.mocked(getEnvBool).mockImplementation((key) => {
if (key === 'PROMPTFOO_ENABLE_DATABASE_LOGS') {
return true;
}
return false;
});
const writer = new DrizzleLogWriter();
writer.write('test message');
expect(logger.debug).toHaveBeenCalledWith('Drizzle: test message');
});
it('should not log debug message when database logs disabled', () => {
vi.mocked(getEnvBool).mockReturnValue(false);
const writer = new DrizzleLogWriter();
writer.write('test message');
expect(logger.debug).not.toHaveBeenCalled();
});
});
describe('closeDb', () => {
it('should close database connection and reset instances', async () => {
const _db = await getDb();
expect(isDbOpen()).toBe(true);
await closeDb();
expect(isDbOpen()).toBe(false);
const newDb = await getDb();
expect(newDb).toBeDefined();
expect(isDbOpen()).toBe(true);
});
it('should handle errors when closing database', async () => {
const _db = await getDb();
await closeDb();
await closeDb(); // Second close should be handled gracefully
expect(logger.error).not.toHaveBeenCalled();
});
it('should handle close errors gracefully', async () => {
const _db = await getDb();
// Force an error by closing twice
await closeDb();
await closeDb();
expect(logger.error).not.toHaveBeenCalled();
});
});
describe('isDbOpen', () => {
it('should return false when database is not initialized', async () => {
await closeDb(); // Ensure clean state
expect(isDbOpen()).toBe(false);
});
it('should return true when database is open', async () => {
const _db = await getDb();
expect(isDbOpen()).toBe(true);
});
it('should return false after closing database', async () => {
const _db = await getDb();
expect(isDbOpen()).toBe(true);
await closeDb();
expect(isDbOpen()).toBe(false);
});
});
});