mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-08 22:22:52 +00:00
Some checks are pending
Gitleaks / gitleaks (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
867 lines
28 KiB
TypeScript
867 lines
28 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
const makeErrnoError = <TCode extends string>(code: TCode, message: string) =>
|
|
Object.assign(new Error(message), { code });
|
|
|
|
/** Stub file handle returned by mocked `fs.open` for the init lock. */
|
|
const makeOpenMock = () =>
|
|
vi.fn(async () => ({
|
|
writeFile: vi.fn(async () => {}),
|
|
close: vi.fn(async () => {}),
|
|
}));
|
|
|
|
/** Mock prepared statement shape for executePrepared/prepare+execute paths. */
|
|
const makePreparedStatement = (sql: string) => ({
|
|
sql,
|
|
isSuccess: () => true,
|
|
getErrorMessage: () => '',
|
|
});
|
|
|
|
/** Mock connection supporting both query() and prepare/execute() call paths. */
|
|
const makeConn = (runQuery: (sql: string) => Promise<unknown>) => {
|
|
const query = vi.fn(runQuery);
|
|
return {
|
|
query,
|
|
prepare: vi.fn(async (sql: string) => makePreparedStatement(sql)),
|
|
execute: vi.fn(async (statement: { sql: string }) => query(statement.sql)),
|
|
close: vi.fn(async () => {}),
|
|
};
|
|
};
|
|
|
|
/** Standard `fs/promises` mock for tests that only need doInitLbug to succeed. */
|
|
const mockFsForInit = (dbPath: string) => {
|
|
const ENOENT_ERROR = makeErrnoError(
|
|
'ENOENT',
|
|
`ENOENT: no such file or directory, lstat '${dbPath}'`,
|
|
);
|
|
vi.doMock('fs/promises', () => ({
|
|
default: {
|
|
lstat: vi.fn(async () => {
|
|
throw ENOENT_ERROR;
|
|
}),
|
|
access: vi.fn(async () => {
|
|
throw ENOENT_ERROR;
|
|
}),
|
|
unlink: vi.fn(async () => {}),
|
|
mkdir: vi.fn(async () => {}),
|
|
open: makeOpenMock(),
|
|
readdir: vi.fn(async () => []),
|
|
},
|
|
}));
|
|
};
|
|
|
|
describe('lbug adapter CHECKPOINT lifecycle', () => {
|
|
afterEach(() => {
|
|
vi.doUnmock('fs/promises');
|
|
vi.doUnmock('../../src/core/lbug/lbug-config.js');
|
|
vi.doUnmock('../../src/core/lbug/extension-loader.js');
|
|
vi.doUnmock('../../src/core/logger.js');
|
|
vi.resetModules();
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it('removes orphan sidecars when main DB file is missing before opening LadybugDB', async () => {
|
|
vi.resetModules();
|
|
|
|
const dbPath = '/tmp/gitnexus-lbug-orphan-sidecar/lbug';
|
|
const ENOENT_ERROR = makeErrnoError(
|
|
'ENOENT',
|
|
`ENOENT: no such file or directory, access '${dbPath}'`,
|
|
);
|
|
const queryResult = { getAll: vi.fn(async () => []), close: vi.fn() };
|
|
const conn = makeConn(async () => queryResult);
|
|
const db = { close: vi.fn(async () => {}) };
|
|
|
|
const unlinkMock = vi.fn(async () => {});
|
|
const accessMock = vi.fn(async () => {
|
|
throw ENOENT_ERROR;
|
|
});
|
|
|
|
vi.doMock('fs/promises', () => ({
|
|
default: {
|
|
lstat: vi.fn(async () => {
|
|
throw ENOENT_ERROR;
|
|
}),
|
|
access: accessMock,
|
|
unlink: unlinkMock,
|
|
mkdir: vi.fn(async () => {}),
|
|
open: makeOpenMock(),
|
|
readdir: vi.fn(async () => []),
|
|
},
|
|
}));
|
|
vi.doMock('../../src/core/lbug/lbug-config.js', () => ({
|
|
openLbugConnection: vi.fn(async () => ({ db, conn })),
|
|
closeLbugConnection: vi.fn(async () => {}),
|
|
isDbBusyError: vi.fn((err: unknown) => String(err).toLowerCase().includes('lock')),
|
|
isOpenRetryExhausted: vi.fn(() => false),
|
|
waitForWindowsHandleRelease: vi.fn(async () => true),
|
|
}));
|
|
vi.doMock('../../src/core/lbug/extension-loader.js', () => ({
|
|
extensionManager: {
|
|
ensure: vi.fn(async () => true),
|
|
getCapabilities: vi.fn(() => []),
|
|
reset: vi.fn(),
|
|
},
|
|
}));
|
|
const warnMock = vi.fn();
|
|
vi.doMock('../../src/core/logger.js', () => ({
|
|
logger: {
|
|
warn: warnMock,
|
|
info: vi.fn(),
|
|
error: vi.fn(),
|
|
debug: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
|
await adapter.initLbug(dbPath);
|
|
|
|
expect(accessMock).toHaveBeenCalledWith(dbPath);
|
|
// Unlink called for: .shadow sidecar, .wal.checkpoint sidecar, init lock release
|
|
expect(unlinkMock).toHaveBeenCalledWith(`${dbPath}.shadow`);
|
|
expect(unlinkMock).toHaveBeenCalledWith(`${dbPath}.wal.checkpoint`);
|
|
expect(unlinkMock).toHaveBeenCalledWith(`${dbPath}.init.lock`);
|
|
expect(warnMock).toHaveBeenCalledTimes(2);
|
|
expect(warnMock).toHaveBeenCalledWith(
|
|
'GitNexus: removed orphan sidecar lbug.shadow (no main DB file present)',
|
|
);
|
|
expect(warnMock).toHaveBeenCalledWith(
|
|
'GitNexus: removed orphan sidecar lbug.wal.checkpoint (no main DB file present)',
|
|
);
|
|
|
|
await adapter.closeLbug();
|
|
});
|
|
|
|
it('skips orphan sidecar cleanup when db access fails with non-ENOENT errors', async () => {
|
|
vi.resetModules();
|
|
|
|
const dbPath = '/tmp/gitnexus-lbug-orphan-sidecar-eacces/lbug';
|
|
const ENOENT_ERROR = makeErrnoError(
|
|
'ENOENT',
|
|
`ENOENT: no such file or directory, access '${dbPath}'`,
|
|
);
|
|
const EACCES_ERROR = makeErrnoError('EACCES', `EACCES: permission denied, access '${dbPath}'`);
|
|
const queryResult = { getAll: vi.fn(async () => []), close: vi.fn() };
|
|
const conn = makeConn(async () => queryResult);
|
|
const db = { close: vi.fn(async () => {}) };
|
|
const accessMock = vi.fn(async () => {
|
|
throw EACCES_ERROR;
|
|
});
|
|
const unlinkMock = vi.fn(async () => {});
|
|
|
|
vi.doMock('fs/promises', () => ({
|
|
default: {
|
|
lstat: vi.fn(async () => {
|
|
throw ENOENT_ERROR;
|
|
}),
|
|
access: accessMock,
|
|
unlink: unlinkMock,
|
|
mkdir: vi.fn(async () => {}),
|
|
open: makeOpenMock(),
|
|
readdir: vi.fn(async () => []),
|
|
},
|
|
}));
|
|
vi.doMock('../../src/core/lbug/lbug-config.js', () => ({
|
|
openLbugConnection: vi.fn(async () => ({ db, conn })),
|
|
closeLbugConnection: vi.fn(async () => {}),
|
|
isDbBusyError: vi.fn((err: unknown) => String(err).toLowerCase().includes('lock')),
|
|
isOpenRetryExhausted: vi.fn(() => false),
|
|
waitForWindowsHandleRelease: vi.fn(async () => true),
|
|
}));
|
|
vi.doMock('../../src/core/lbug/extension-loader.js', () => ({
|
|
extensionManager: {
|
|
ensure: vi.fn(async () => true),
|
|
getCapabilities: vi.fn(() => []),
|
|
reset: vi.fn(),
|
|
},
|
|
}));
|
|
const warnMock = vi.fn();
|
|
vi.doMock('../../src/core/logger.js', () => ({
|
|
logger: {
|
|
warn: warnMock,
|
|
info: vi.fn(),
|
|
error: vi.fn(),
|
|
debug: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
|
await adapter.initLbug(dbPath);
|
|
|
|
expect(accessMock).toHaveBeenCalledWith(dbPath);
|
|
// Only the init lock release calls unlink — sidecar cleanup was skipped
|
|
expect(unlinkMock).toHaveBeenCalledTimes(1);
|
|
expect(unlinkMock).toHaveBeenCalledWith(`${dbPath}.init.lock`);
|
|
expect(warnMock).toHaveBeenCalledTimes(1);
|
|
expect(warnMock.mock.calls[0]?.[0]).toContain(
|
|
'GitNexus: unable to verify main DB file before orphan sidecar cleanup (EACCES); skipping cleanup:',
|
|
);
|
|
|
|
await adapter.closeLbug();
|
|
});
|
|
|
|
it('does not remove sidecars when main db file is present', async () => {
|
|
vi.resetModules();
|
|
|
|
const dbPath = '/tmp/gitnexus-lbug-present/lbug';
|
|
const ENOENT_ERROR = makeErrnoError(
|
|
'ENOENT',
|
|
`ENOENT: no such file or directory, access '${dbPath}'`,
|
|
);
|
|
const queryResult = { getAll: vi.fn(async () => []), close: vi.fn() };
|
|
const conn = makeConn(async () => queryResult);
|
|
const db = { close: vi.fn(async () => {}) };
|
|
const accessMock = vi.fn(async () => {});
|
|
const unlinkMock = vi.fn(async () => {});
|
|
|
|
vi.doMock('fs/promises', () => ({
|
|
default: {
|
|
lstat: vi.fn(async () => {
|
|
throw ENOENT_ERROR;
|
|
}),
|
|
access: accessMock,
|
|
unlink: unlinkMock,
|
|
mkdir: vi.fn(async () => {}),
|
|
open: makeOpenMock(),
|
|
readdir: vi.fn(async () => []),
|
|
},
|
|
}));
|
|
vi.doMock('../../src/core/lbug/lbug-config.js', () => ({
|
|
openLbugConnection: vi.fn(async () => ({ db, conn })),
|
|
closeLbugConnection: vi.fn(async () => {}),
|
|
isDbBusyError: vi.fn((err: unknown) => String(err).toLowerCase().includes('lock')),
|
|
isOpenRetryExhausted: vi.fn(() => false),
|
|
waitForWindowsHandleRelease: vi.fn(async () => true),
|
|
}));
|
|
vi.doMock('../../src/core/lbug/extension-loader.js', () => ({
|
|
extensionManager: {
|
|
ensure: vi.fn(async () => true),
|
|
getCapabilities: vi.fn(() => []),
|
|
reset: vi.fn(),
|
|
},
|
|
}));
|
|
const warnMock = vi.fn();
|
|
vi.doMock('../../src/core/logger.js', () => ({
|
|
logger: {
|
|
warn: warnMock,
|
|
info: vi.fn(),
|
|
error: vi.fn(),
|
|
debug: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
|
await adapter.initLbug(dbPath);
|
|
|
|
expect(accessMock).toHaveBeenCalledWith(dbPath);
|
|
// Only the init lock release calls unlink — no sidecar cleanup needed
|
|
expect(unlinkMock).toHaveBeenCalledTimes(1);
|
|
expect(unlinkMock).toHaveBeenCalledWith(`${dbPath}.init.lock`);
|
|
expect(warnMock).not.toHaveBeenCalled();
|
|
|
|
await adapter.closeLbug();
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
code: 'EPERM',
|
|
message: 'operation not permitted',
|
|
dbPath: '/tmp/gitnexus-lbug-lstat-eperm/lbug',
|
|
},
|
|
{
|
|
code: 'EACCES',
|
|
message: 'permission denied',
|
|
dbPath: '/tmp/gitnexus-lbug-lstat-eacces/lbug',
|
|
},
|
|
])('throws when db path lstat fails with non-ENOENT %s', async ({ code, message, dbPath }) => {
|
|
vi.resetModules();
|
|
|
|
const LSTAT_ERROR = makeErrnoError(code, `${code}: ${message}, lstat '${dbPath}'`);
|
|
const accessMock = vi.fn(async () => {});
|
|
const unlinkMock = vi.fn(async () => {});
|
|
|
|
vi.doMock('fs/promises', () => ({
|
|
default: {
|
|
lstat: vi.fn(async () => {
|
|
throw LSTAT_ERROR;
|
|
}),
|
|
access: accessMock,
|
|
unlink: unlinkMock,
|
|
mkdir: vi.fn(async () => {}),
|
|
open: makeOpenMock(),
|
|
readdir: vi.fn(async () => []),
|
|
},
|
|
}));
|
|
vi.doMock('../../src/core/lbug/lbug-config.js', () => ({
|
|
openLbugConnection: vi.fn(async () => {
|
|
throw new Error('should not be called');
|
|
}),
|
|
closeLbugConnection: vi.fn(async () => {}),
|
|
isDbBusyError: vi.fn((err: unknown) => String(err).toLowerCase().includes('lock')),
|
|
isOpenRetryExhausted: vi.fn(() => false),
|
|
waitForWindowsHandleRelease: vi.fn(async () => true),
|
|
}));
|
|
vi.doMock('../../src/core/lbug/extension-loader.js', () => ({
|
|
extensionManager: {
|
|
ensure: vi.fn(async () => true),
|
|
getCapabilities: vi.fn(() => []),
|
|
reset: vi.fn(),
|
|
},
|
|
}));
|
|
vi.doMock('../../src/core/logger.js', () => ({
|
|
logger: {
|
|
warn: vi.fn(),
|
|
info: vi.fn(),
|
|
error: vi.fn(),
|
|
debug: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
|
await expect(adapter.initLbug(dbPath)).rejects.toThrow(new RegExp(message, 'i'));
|
|
expect(accessMock).not.toHaveBeenCalled();
|
|
expect(unlinkMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('handles partial orphan sidecar state and removes only present sidecars', async () => {
|
|
vi.resetModules();
|
|
|
|
const dbPath = '/tmp/gitnexus-lbug-partial-sidecar/lbug';
|
|
const ENOENT_ERROR = makeErrnoError(
|
|
'ENOENT',
|
|
`ENOENT: no such file or directory, access '${dbPath}'`,
|
|
);
|
|
const queryResult = { getAll: vi.fn(async () => []), close: vi.fn() };
|
|
const conn = makeConn(async () => queryResult);
|
|
const db = { close: vi.fn(async () => {}) };
|
|
const accessMock = vi.fn(async () => {
|
|
throw ENOENT_ERROR;
|
|
});
|
|
const unlinkMock = vi.fn(async (target: string) => {
|
|
if (target.endsWith('.shadow')) throw ENOENT_ERROR;
|
|
});
|
|
|
|
vi.doMock('fs/promises', () => ({
|
|
default: {
|
|
lstat: vi.fn(async () => {
|
|
throw ENOENT_ERROR;
|
|
}),
|
|
access: accessMock,
|
|
unlink: unlinkMock,
|
|
mkdir: vi.fn(async () => {}),
|
|
open: makeOpenMock(),
|
|
readdir: vi.fn(async () => []),
|
|
},
|
|
}));
|
|
vi.doMock('../../src/core/lbug/lbug-config.js', () => ({
|
|
openLbugConnection: vi.fn(async () => ({ db, conn })),
|
|
closeLbugConnection: vi.fn(async () => {}),
|
|
isDbBusyError: vi.fn((err: unknown) => String(err).toLowerCase().includes('lock')),
|
|
isOpenRetryExhausted: vi.fn(() => false),
|
|
waitForWindowsHandleRelease: vi.fn(async () => true),
|
|
}));
|
|
vi.doMock('../../src/core/lbug/extension-loader.js', () => ({
|
|
extensionManager: {
|
|
ensure: vi.fn(async () => true),
|
|
getCapabilities: vi.fn(() => []),
|
|
reset: vi.fn(),
|
|
},
|
|
}));
|
|
const warnMock = vi.fn();
|
|
vi.doMock('../../src/core/logger.js', () => ({
|
|
logger: {
|
|
warn: warnMock,
|
|
info: vi.fn(),
|
|
error: vi.fn(),
|
|
debug: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
|
await adapter.initLbug(dbPath);
|
|
|
|
expect(unlinkMock).toHaveBeenCalledWith(`${dbPath}.shadow`);
|
|
expect(unlinkMock).toHaveBeenCalledWith(`${dbPath}.wal.checkpoint`);
|
|
expect(warnMock).toHaveBeenCalledTimes(1);
|
|
expect(warnMock).toHaveBeenCalledWith(
|
|
'GitNexus: removed orphan sidecar lbug.wal.checkpoint (no main DB file present)',
|
|
);
|
|
|
|
await adapter.closeLbug();
|
|
});
|
|
|
|
it('proceeds to openLbugConnection when orphan sidecar unlink fails', async () => {
|
|
vi.resetModules();
|
|
|
|
const dbPath = '/tmp/gitnexus-lbug-sidecar-unlink-fail/lbug';
|
|
const ENOENT_ERROR = makeErrnoError(
|
|
'ENOENT',
|
|
`ENOENT: no such file or directory, access '${dbPath}'`,
|
|
);
|
|
const EPERM_ERROR = makeErrnoError(
|
|
'EPERM',
|
|
`EPERM: operation not permitted, unlink '${dbPath}.shadow'`,
|
|
);
|
|
const queryResult = { getAll: vi.fn(async () => []), close: vi.fn() };
|
|
const conn = makeConn(async () => queryResult);
|
|
const db = { close: vi.fn(async () => {}) };
|
|
const accessMock = vi.fn(async () => {
|
|
throw ENOENT_ERROR;
|
|
});
|
|
const unlinkMock = vi.fn(async () => {
|
|
throw EPERM_ERROR;
|
|
});
|
|
|
|
vi.doMock('fs/promises', () => ({
|
|
default: {
|
|
lstat: vi.fn(async () => {
|
|
throw ENOENT_ERROR;
|
|
}),
|
|
access: accessMock,
|
|
unlink: unlinkMock,
|
|
mkdir: vi.fn(async () => {}),
|
|
open: makeOpenMock(),
|
|
readdir: vi.fn(async () => []),
|
|
},
|
|
}));
|
|
const openLbugConnectionMock = vi.fn(async () => ({ db, conn }));
|
|
vi.doMock('../../src/core/lbug/lbug-config.js', () => ({
|
|
openLbugConnection: openLbugConnectionMock,
|
|
closeLbugConnection: vi.fn(async () => {}),
|
|
isDbBusyError: vi.fn((err: unknown) => String(err).toLowerCase().includes('lock')),
|
|
isOpenRetryExhausted: vi.fn(() => false),
|
|
waitForWindowsHandleRelease: vi.fn(async () => true),
|
|
}));
|
|
vi.doMock('../../src/core/lbug/extension-loader.js', () => ({
|
|
extensionManager: {
|
|
ensure: vi.fn(async () => true),
|
|
getCapabilities: vi.fn(() => []),
|
|
reset: vi.fn(),
|
|
},
|
|
}));
|
|
const warnMock = vi.fn();
|
|
vi.doMock('../../src/core/logger.js', () => ({
|
|
logger: {
|
|
warn: warnMock,
|
|
info: vi.fn(),
|
|
error: vi.fn(),
|
|
debug: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
|
await adapter.initLbug(dbPath);
|
|
|
|
expect(unlinkMock).toHaveBeenCalledTimes(3);
|
|
expect(warnMock).toHaveBeenCalledTimes(3);
|
|
expect(warnMock.mock.calls[0]?.[0]).toContain(
|
|
'GitNexus: failed to remove orphan sidecar lbug.shadow (EPERM) while main DB file is missing; LadybugDB open may still fail:',
|
|
);
|
|
expect(warnMock.mock.calls[1]?.[0]).toContain(
|
|
'GitNexus: failed to remove orphan sidecar lbug.wal.checkpoint (EPERM) while main DB file is missing; LadybugDB open may still fail:',
|
|
);
|
|
expect(warnMock.mock.calls[2]?.[0]).toContain('GitNexus: failed to release init lock (EPERM)');
|
|
expect(openLbugConnectionMock).toHaveBeenCalledWith(expect.anything(), dbPath);
|
|
|
|
await adapter.closeLbug();
|
|
});
|
|
|
|
it('drains and closes CHECKPOINT result before closing connection and database handles', async () => {
|
|
vi.resetModules();
|
|
|
|
const events: string[] = [];
|
|
const checkpointResult = {
|
|
getAll: vi.fn(async () => {
|
|
events.push('checkpoint:getAll');
|
|
return [];
|
|
}),
|
|
close: vi.fn(() => {
|
|
events.push('checkpoint:close');
|
|
}),
|
|
};
|
|
const genericResult = {
|
|
getAll: vi.fn(async () => []),
|
|
close: vi.fn(),
|
|
};
|
|
const conn = makeConn(async (sql: string) => {
|
|
if (sql === 'CHECKPOINT') {
|
|
events.push('checkpoint:query');
|
|
return checkpointResult;
|
|
}
|
|
return genericResult;
|
|
});
|
|
conn.close = vi.fn(async () => {
|
|
events.push('conn:close');
|
|
});
|
|
const db = {
|
|
close: vi.fn(async () => {
|
|
events.push('db:close');
|
|
}),
|
|
};
|
|
|
|
mockFsForInit('/tmp/gitnexus-lbug-checkpoint-lifecycle/lbug');
|
|
vi.doMock('../../src/core/lbug/lbug-config.js', () => ({
|
|
openLbugConnection: vi.fn(async () => ({ db, conn })),
|
|
closeLbugConnection: vi.fn(async () => {}),
|
|
isDbBusyError: vi.fn((err: unknown) => String(err).toLowerCase().includes('lock')),
|
|
isOpenRetryExhausted: vi.fn(() => false),
|
|
waitForWindowsHandleRelease: vi.fn(async () => true),
|
|
}));
|
|
vi.doMock('../../src/core/lbug/extension-loader.js', () => ({
|
|
extensionManager: {
|
|
ensure: vi.fn(async () => true),
|
|
getCapabilities: vi.fn(() => []),
|
|
reset: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
|
await adapter.initLbug('/tmp/gitnexus-lbug-checkpoint-lifecycle/lbug');
|
|
|
|
events.length = 0;
|
|
await adapter.closeLbug();
|
|
|
|
expect(events).toEqual([
|
|
'checkpoint:query',
|
|
'checkpoint:getAll',
|
|
'checkpoint:close',
|
|
'conn:close',
|
|
'db:close',
|
|
]);
|
|
});
|
|
|
|
it('closes normal query results after reading rows', async () => {
|
|
vi.resetModules();
|
|
|
|
const events: string[] = [];
|
|
const queryResult = {
|
|
getAll: vi.fn(async () => {
|
|
events.push('query:getAll');
|
|
return [{ id: 'file:a' }];
|
|
}),
|
|
close: vi.fn(() => {
|
|
events.push('query:close');
|
|
}),
|
|
};
|
|
const genericResult = {
|
|
getAll: vi.fn(async () => []),
|
|
close: vi.fn(),
|
|
};
|
|
const conn = makeConn(async (sql: string) => {
|
|
if (sql === 'MATCH (n:File) RETURN n.id AS id') {
|
|
events.push('query:run');
|
|
return queryResult;
|
|
}
|
|
return genericResult;
|
|
});
|
|
const db = {
|
|
close: vi.fn(async () => {}),
|
|
};
|
|
|
|
mockFsForInit('/tmp/gitnexus-lbug-query-lifecycle/lbug');
|
|
vi.doMock('../../src/core/lbug/lbug-config.js', () => ({
|
|
openLbugConnection: vi.fn(async () => ({ db, conn })),
|
|
closeLbugConnection: vi.fn(async () => {}),
|
|
isDbBusyError: vi.fn((err: unknown) => String(err).toLowerCase().includes('lock')),
|
|
isOpenRetryExhausted: vi.fn(() => false),
|
|
waitForWindowsHandleRelease: vi.fn(async () => true),
|
|
}));
|
|
vi.doMock('../../src/core/lbug/extension-loader.js', () => ({
|
|
extensionManager: {
|
|
ensure: vi.fn(async () => true),
|
|
getCapabilities: vi.fn(() => []),
|
|
reset: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
|
await adapter.initLbug('/tmp/gitnexus-lbug-query-lifecycle/lbug');
|
|
|
|
events.length = 0;
|
|
await expect(adapter.executeQuery('MATCH (n:File) RETURN n.id AS id')).resolves.toEqual([
|
|
{ id: 'file:a' },
|
|
]);
|
|
|
|
expect(events).toEqual(['query:run', 'query:getAll', 'query:close']);
|
|
|
|
await adapter.closeLbug();
|
|
});
|
|
|
|
it('treats synchronous query result close errors as best-effort cleanup', async () => {
|
|
vi.resetModules();
|
|
|
|
const queryResult = {
|
|
getAll: vi.fn(async () => [{ id: 'file:a' }]),
|
|
close: vi.fn(() => {
|
|
throw new Error('close failed');
|
|
}),
|
|
};
|
|
const genericResult = {
|
|
getAll: vi.fn(async () => []),
|
|
close: vi.fn(),
|
|
};
|
|
const conn = makeConn(async (sql: string) => {
|
|
if (sql === 'MATCH (n:File) RETURN n.id AS id') {
|
|
return queryResult;
|
|
}
|
|
return genericResult;
|
|
});
|
|
const db = {
|
|
close: vi.fn(async () => {}),
|
|
};
|
|
|
|
mockFsForInit('/tmp/gitnexus-lbug-sync-close-lifecycle/lbug');
|
|
vi.doMock('../../src/core/lbug/lbug-config.js', () => ({
|
|
openLbugConnection: vi.fn(async () => ({ db, conn })),
|
|
closeLbugConnection: vi.fn(async () => {}),
|
|
isDbBusyError: vi.fn((err: unknown) => String(err).toLowerCase().includes('lock')),
|
|
isOpenRetryExhausted: vi.fn(() => false),
|
|
waitForWindowsHandleRelease: vi.fn(async () => true),
|
|
}));
|
|
vi.doMock('../../src/core/lbug/extension-loader.js', () => ({
|
|
extensionManager: {
|
|
ensure: vi.fn(async () => true),
|
|
getCapabilities: vi.fn(() => []),
|
|
reset: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
|
await adapter.initLbug('/tmp/gitnexus-lbug-sync-close-lifecycle/lbug');
|
|
|
|
await expect(adapter.executeQuery('MATCH (n:File) RETURN n.id AS id')).resolves.toEqual([
|
|
{ id: 'file:a' },
|
|
]);
|
|
expect(queryResult.close).toHaveBeenCalledOnce();
|
|
|
|
await adapter.closeLbug();
|
|
});
|
|
|
|
it('closes later query results when an earlier array result fails to read', async () => {
|
|
vi.resetModules();
|
|
|
|
const events: string[] = [];
|
|
const firstResult = {
|
|
getAll: vi.fn(async () => {
|
|
events.push('first:getAll');
|
|
throw new Error('read failed');
|
|
}),
|
|
close: vi.fn(() => {
|
|
events.push('first:close');
|
|
}),
|
|
};
|
|
const secondResult = {
|
|
getAll: vi.fn(async () => {
|
|
events.push('second:getAll');
|
|
return [];
|
|
}),
|
|
close: vi.fn(() => {
|
|
events.push('second:close');
|
|
}),
|
|
};
|
|
const genericResult = {
|
|
getAll: vi.fn(async () => []),
|
|
close: vi.fn(),
|
|
};
|
|
const conn = makeConn(async (sql: string) => {
|
|
if (sql === 'MATCH (n:File) RETURN n.id AS id') {
|
|
return [firstResult, secondResult];
|
|
}
|
|
return genericResult;
|
|
});
|
|
const db = {
|
|
close: vi.fn(async () => {}),
|
|
};
|
|
|
|
mockFsForInit('/tmp/gitnexus-lbug-array-error-lifecycle/lbug');
|
|
vi.doMock('../../src/core/lbug/lbug-config.js', () => ({
|
|
openLbugConnection: vi.fn(async () => ({ db, conn })),
|
|
closeLbugConnection: vi.fn(async () => {}),
|
|
isDbBusyError: vi.fn((err: unknown) => String(err).toLowerCase().includes('lock')),
|
|
isOpenRetryExhausted: vi.fn(() => false),
|
|
waitForWindowsHandleRelease: vi.fn(async () => true),
|
|
}));
|
|
vi.doMock('../../src/core/lbug/extension-loader.js', () => ({
|
|
extensionManager: {
|
|
ensure: vi.fn(async () => true),
|
|
getCapabilities: vi.fn(() => []),
|
|
reset: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
|
await adapter.initLbug('/tmp/gitnexus-lbug-array-error-lifecycle/lbug');
|
|
|
|
await expect(adapter.executeQuery('MATCH (n:File) RETURN n.id AS id')).rejects.toThrow(
|
|
'read failed',
|
|
);
|
|
expect(events).toEqual(['first:getAll', 'first:close', 'second:getAll', 'second:close']);
|
|
|
|
await adapter.closeLbug();
|
|
});
|
|
|
|
it('closes non-first stream query results when LadybugDB returns an array', async () => {
|
|
vi.resetModules();
|
|
|
|
const events: string[] = [];
|
|
const firstResult = {
|
|
hasNext: vi
|
|
.fn()
|
|
.mockImplementationOnce(() => {
|
|
events.push('first:hasNext:true');
|
|
return true;
|
|
})
|
|
.mockImplementationOnce(() => {
|
|
events.push('first:hasNext:false');
|
|
return false;
|
|
}),
|
|
getNext: vi.fn(async () => {
|
|
events.push('first:getNext');
|
|
return { id: 'file:a' };
|
|
}),
|
|
getAll: vi.fn(async () => {
|
|
events.push('first:getAll');
|
|
return [];
|
|
}),
|
|
close: vi.fn(() => {
|
|
events.push('first:close');
|
|
}),
|
|
};
|
|
const secondResult = {
|
|
getAll: vi.fn(async () => {
|
|
events.push('second:getAll');
|
|
return [];
|
|
}),
|
|
close: vi.fn(() => {
|
|
events.push('second:close');
|
|
}),
|
|
};
|
|
const genericResult = {
|
|
getAll: vi.fn(async () => []),
|
|
close: vi.fn(),
|
|
};
|
|
const conn = makeConn(async (sql: string) => {
|
|
if (sql === 'MATCH (n:File) RETURN n.id AS id') {
|
|
events.push('stream:query');
|
|
return [firstResult, secondResult];
|
|
}
|
|
return genericResult;
|
|
});
|
|
const db = {
|
|
close: vi.fn(async () => {}),
|
|
};
|
|
|
|
mockFsForInit('/tmp/gitnexus-lbug-stream-lifecycle/lbug');
|
|
vi.doMock('../../src/core/lbug/lbug-config.js', () => ({
|
|
openLbugConnection: vi.fn(async () => ({ db, conn })),
|
|
closeLbugConnection: vi.fn(async () => {}),
|
|
isDbBusyError: vi.fn((err: unknown) => String(err).toLowerCase().includes('lock')),
|
|
isOpenRetryExhausted: vi.fn(() => false),
|
|
waitForWindowsHandleRelease: vi.fn(async () => true),
|
|
}));
|
|
vi.doMock('../../src/core/lbug/extension-loader.js', () => ({
|
|
extensionManager: {
|
|
ensure: vi.fn(async () => true),
|
|
getCapabilities: vi.fn(() => []),
|
|
reset: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
|
await adapter.initLbug('/tmp/gitnexus-lbug-stream-lifecycle/lbug');
|
|
|
|
const rows: unknown[] = [];
|
|
events.length = 0;
|
|
await expect(
|
|
adapter.streamQuery('MATCH (n:File) RETURN n.id AS id', (row) => {
|
|
rows.push(row);
|
|
}),
|
|
).resolves.toBe(1);
|
|
|
|
expect(rows).toEqual([{ id: 'file:a' }]);
|
|
expect(events).toEqual([
|
|
'stream:query',
|
|
'first:hasNext:true',
|
|
'first:getNext',
|
|
'first:hasNext:false',
|
|
'first:getAll',
|
|
'first:close',
|
|
'second:getAll',
|
|
'second:close',
|
|
]);
|
|
|
|
await adapter.closeLbug();
|
|
});
|
|
|
|
it('drains stream query results when row handling fails before the result is exhausted', async () => {
|
|
vi.resetModules();
|
|
|
|
const events: string[] = [];
|
|
const queryResult = {
|
|
hasNext: vi.fn(() => {
|
|
events.push('stream:hasNext');
|
|
return true;
|
|
}),
|
|
getNext: vi.fn(async () => {
|
|
events.push('stream:getNext');
|
|
return { id: 'file:a' };
|
|
}),
|
|
getAll: vi.fn(async () => {
|
|
events.push('stream:getAll');
|
|
return [{ id: 'file:b' }];
|
|
}),
|
|
close: vi.fn(() => {
|
|
events.push('stream:close');
|
|
}),
|
|
};
|
|
const genericResult = {
|
|
getAll: vi.fn(async () => []),
|
|
close: vi.fn(),
|
|
};
|
|
const conn = makeConn(async (sql: string) => {
|
|
if (sql === 'MATCH (n:File) RETURN n.id AS id') {
|
|
events.push('stream:query');
|
|
return queryResult;
|
|
}
|
|
return genericResult;
|
|
});
|
|
const db = {
|
|
close: vi.fn(async () => {}),
|
|
};
|
|
|
|
mockFsForInit('/tmp/gitnexus-lbug-stream-error-lifecycle/lbug');
|
|
vi.doMock('../../src/core/lbug/lbug-config.js', () => ({
|
|
openLbugConnection: vi.fn(async () => ({ db, conn })),
|
|
closeLbugConnection: vi.fn(async () => {}),
|
|
isDbBusyError: vi.fn((err: unknown) => String(err).toLowerCase().includes('lock')),
|
|
isOpenRetryExhausted: vi.fn(() => false),
|
|
waitForWindowsHandleRelease: vi.fn(async () => true),
|
|
}));
|
|
vi.doMock('../../src/core/lbug/extension-loader.js', () => ({
|
|
extensionManager: {
|
|
ensure: vi.fn(async () => true),
|
|
getCapabilities: vi.fn(() => []),
|
|
reset: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
|
await adapter.initLbug('/tmp/gitnexus-lbug-stream-error-lifecycle/lbug');
|
|
|
|
await expect(
|
|
adapter.streamQuery('MATCH (n:File) RETURN n.id AS id', () => {
|
|
throw new Error('client disconnected');
|
|
}),
|
|
).rejects.toThrow('client disconnected');
|
|
|
|
expect(events).toEqual([
|
|
'stream:query',
|
|
'stream:hasNext',
|
|
'stream:getNext',
|
|
'stream:getAll',
|
|
'stream:close',
|
|
]);
|
|
|
|
await adapter.closeLbug();
|
|
});
|
|
});
|