mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-14 00:48:18 +00:00
277 lines
11 KiB
TypeScript
277 lines
11 KiB
TypeScript
/**
|
|
* Unit tests for the silent-failure doctor check batch (#2250, #2784, #2788).
|
|
* Hermetic PGLite; temp dirs stand in for source repos. Postgres parity for
|
|
* the same checks is pinned by test/e2e/doctor-silent-death-parity.test.ts.
|
|
*/
|
|
|
|
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
|
|
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
|
import { resetPgliteState } from './helpers/reset-pglite.ts';
|
|
import {
|
|
checkContentHashDuplicates,
|
|
checkUndeclaredDbOnlyPages,
|
|
checkDbOnlyCollectorCollision,
|
|
} from '../src/commands/doctor.ts';
|
|
import {
|
|
DERIVE_PHASE_DB_ONLY_DEFAULTS,
|
|
effectiveDbOnlyDirs,
|
|
findDbOnlyCollisions,
|
|
} from '../src/core/storage-config.ts';
|
|
|
|
let engine: PGLiteEngine;
|
|
const tempDirs: string[] = [];
|
|
|
|
function makeRepo(): string {
|
|
const dir = mkdtempSync(join(tmpdir(), 'gbrain-doctor-'));
|
|
tempDirs.push(dir);
|
|
return dir;
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
engine = new PGLiteEngine();
|
|
await engine.connect({});
|
|
await engine.initSchema();
|
|
}, 60_000);
|
|
|
|
afterAll(async () => {
|
|
if (engine) await engine.disconnect();
|
|
for (const d of tempDirs) rmSync(d, { recursive: true, force: true });
|
|
}, 60_000);
|
|
|
|
beforeEach(async () => {
|
|
await resetPgliteState(engine);
|
|
});
|
|
|
|
async function addSource(id: string, localPath: string | null): Promise<void> {
|
|
await engine.executeRaw(
|
|
`INSERT INTO sources (id, name, local_path, config) VALUES ($1, $1, $2, '{}'::jsonb)
|
|
ON CONFLICT (id) DO UPDATE SET local_path = EXCLUDED.local_path`,
|
|
[id, localPath],
|
|
);
|
|
}
|
|
|
|
async function addPage(
|
|
slug: string,
|
|
opts: { sourceId?: string; hash?: string | null; pageKind?: string; deleted?: boolean } = {},
|
|
): Promise<void> {
|
|
await engine.executeRaw(
|
|
`INSERT INTO pages (slug, source_id, type, page_kind, title, compiled_truth, timeline, frontmatter, content_hash, deleted_at)
|
|
VALUES ($1, $2, 'concept', $3, $1, 'body', '', '{}'::jsonb, $4, $5)`,
|
|
[
|
|
slug,
|
|
opts.sourceId ?? 'default',
|
|
opts.pageKind ?? 'markdown',
|
|
opts.hash === undefined ? `h-${slug}` : opts.hash,
|
|
opts.deleted ? new Date().toISOString() : null,
|
|
],
|
|
);
|
|
}
|
|
|
|
describe('content_hash_duplicates (#2250)', () => {
|
|
test('distinct hashes → ok', async () => {
|
|
await addPage('people/alice-example');
|
|
await addPage('projects/widget-co');
|
|
const c = await checkContentHashDuplicates(engine);
|
|
expect(c.status).toBe('ok');
|
|
});
|
|
|
|
test('bare + path-prefixed twins with same hash → warn with pair + remediation', async () => {
|
|
await addPage('people/alice-example', { hash: 'same' });
|
|
await addPage('alice-example', { hash: 'same' });
|
|
const c = await checkContentHashDuplicates(engine);
|
|
expect(c.status).toBe('warn');
|
|
expect(c.message).toContain('alice-example <-> people/alice-example');
|
|
expect(c.message).toContain('gbrain pages delete <bare-slug>');
|
|
expect(c.message).toContain('gbrain pages purge-deleted --older-than 0');
|
|
expect((c.details as any).pair_count).toBe(1);
|
|
});
|
|
|
|
test('multiple wrong-root pairs all counted', async () => {
|
|
await addPage('people/alice-example', { hash: 'h1' });
|
|
await addPage('alice-example', { hash: 'h1' });
|
|
await addPage('projects/my-project', { hash: 'h2' });
|
|
await addPage('my-project', { hash: 'h2' });
|
|
const c = await checkContentHashDuplicates(engine);
|
|
expect(c.status).toBe('warn');
|
|
expect((c.details as any).pair_count).toBe(2);
|
|
expect(c.message).toContain('my-project <-> projects/my-project');
|
|
});
|
|
|
|
test('two path-prefixed pages with same hash → ok (not the wrong-root pattern)', async () => {
|
|
await addPage('people/alice-example', { hash: 'same' });
|
|
await addPage('archive/people/alice-example', { hash: 'same' });
|
|
const c = await checkContentHashDuplicates(engine);
|
|
expect(c.status).toBe('ok');
|
|
});
|
|
|
|
test('soft-deleted twin is ignored', async () => {
|
|
await addPage('people/alice-example', { hash: 'same' });
|
|
await addPage('alice-example', { hash: 'same', deleted: true });
|
|
const c = await checkContentHashDuplicates(engine);
|
|
expect(c.status).toBe('ok');
|
|
});
|
|
|
|
test('NULL / empty content_hash never groups', async () => {
|
|
await addPage('people/alice-example', { hash: null });
|
|
await addPage('alice-example', { hash: null });
|
|
await addPage('people/bob-example', { hash: '' });
|
|
await addPage('bob-example', { hash: '' });
|
|
const c = await checkContentHashDuplicates(engine);
|
|
expect(c.status).toBe('ok');
|
|
});
|
|
|
|
test('same hash across DIFFERENT sources is not flagged (per-source grouping)', async () => {
|
|
await addSource('other', null);
|
|
await addPage('people/alice-example', { hash: 'same', sourceId: 'default' });
|
|
await addPage('alice-example', { hash: 'same', sourceId: 'other' });
|
|
const c = await checkContentHashDuplicates(engine);
|
|
expect(c.status).toBe('ok');
|
|
});
|
|
});
|
|
|
|
describe('undeclared_db_only_pages (#2784)', () => {
|
|
test('no sources with local_path → ok (not applicable)', async () => {
|
|
await addPage('floating/page');
|
|
const c = await checkUndeclaredDbOnlyPages(engine);
|
|
expect(c.status).toBe('ok');
|
|
expect(c.message).toContain('Not applicable');
|
|
});
|
|
|
|
test('file-backed page → ok', async () => {
|
|
const repo = makeRepo();
|
|
mkdirSync(join(repo, 'people'), { recursive: true });
|
|
writeFileSync(join(repo, 'people', 'alice-example.md'), '# Alice');
|
|
await addSource('src-a', repo);
|
|
await addPage('people/alice-example', { sourceId: 'src-a' });
|
|
const c = await checkUndeclaredDbOnlyPages(engine);
|
|
expect(c.status).toBe('ok');
|
|
});
|
|
|
|
test('file-backed page under a canonical hidden directory → ok', async () => {
|
|
const repo = makeRepo();
|
|
mkdirSync(join(repo, '.archive', 'people'), { recursive: true });
|
|
writeFileSync(join(repo, '.archive', 'people', 'alice-example.md'), '# Alice');
|
|
await addSource('src-a', repo);
|
|
await addPage('.archive/people/alice-example', { sourceId: 'src-a' });
|
|
const c = await checkUndeclaredDbOnlyPages(engine);
|
|
expect(c.status).toBe('ok');
|
|
});
|
|
|
|
test('derive-phase default prefixes are implicitly declared', async () => {
|
|
const repo = makeRepo();
|
|
await addSource('src-a', repo);
|
|
for (const prefix of DERIVE_PHASE_DB_ONLY_DEFAULTS) {
|
|
await addPage(`${prefix}page-1`, { sourceId: 'src-a' });
|
|
}
|
|
const c = await checkUndeclaredDbOnlyPages(engine);
|
|
expect(c.status).toBe('ok');
|
|
expect(c.message).toContain('life/events/');
|
|
});
|
|
|
|
test('declared db_only prefix in gbrain.yml keeps the check quiet', async () => {
|
|
const repo = makeRepo();
|
|
writeFileSync(join(repo, 'gbrain.yml'), 'storage:\n db_only:\n - notes/\n');
|
|
await addSource('src-a', repo);
|
|
await addPage('notes/db-resident', { sourceId: 'src-a' });
|
|
const c = await checkUndeclaredDbOnlyPages(engine);
|
|
expect(c.status).toBe('ok');
|
|
});
|
|
|
|
test('page with no backing file outside every db_only path → warn with sample + fix', async () => {
|
|
const repo = makeRepo();
|
|
await addSource('src-a', repo);
|
|
await addPage('people/ghost-page', { sourceId: 'src-a' });
|
|
const c = await checkUndeclaredDbOnlyPages(engine);
|
|
expect(c.status).toBe('warn');
|
|
expect(c.message).toContain('people/ghost-page');
|
|
expect(c.message).toContain('storage.db_only');
|
|
expect((c.details as any).total).toBe(1);
|
|
expect((c.details as any).per_source['src-a']).toBe(1);
|
|
});
|
|
|
|
test('code pages are excluded (different slug scheme)', async () => {
|
|
const repo = makeRepo();
|
|
await addSource('src-a', repo);
|
|
await addPage('src-core-thing-ts', { sourceId: 'src-a', pageKind: 'code' });
|
|
const c = await checkUndeclaredDbOnlyPages(engine);
|
|
expect(c.status).toBe('ok');
|
|
});
|
|
|
|
test('source whose local_path is missing on this host is skipped', async () => {
|
|
await addSource('src-gone', '/nonexistent/gbrain-test-path');
|
|
await addPage('people/ghost-page', { sourceId: 'src-gone' });
|
|
const c = await checkUndeclaredDbOnlyPages(engine);
|
|
expect(c.status).toBe('ok');
|
|
expect(c.message).toContain('Not applicable');
|
|
});
|
|
|
|
test('effectiveDbOnlyDirs unions declared + defaults, deduped', () => {
|
|
const dirs = effectiveDbOnlyDirs(['notes/', 'atoms/']);
|
|
expect(dirs.filter(d => d === 'atoms/').length).toBe(1);
|
|
expect(dirs).toContain('notes/');
|
|
for (const d of DERIVE_PHASE_DB_ONLY_DEFAULTS) expect(dirs).toContain(d);
|
|
});
|
|
});
|
|
|
|
describe('db_only_collector_collision (#2788)', () => {
|
|
test('no collectors declare output paths → ok', async () => {
|
|
const c = await checkDbOnlyCollectorCollision(engine, { collectors: [] });
|
|
expect(c.status).toBe('ok');
|
|
});
|
|
|
|
test('collector output inside a db_only path → warn naming collector, path, and fix', async () => {
|
|
const repo = makeRepo();
|
|
writeFileSync(join(repo, 'gbrain.yml'), 'storage:\n db_only:\n - daily/\n');
|
|
await addSource('src-a', repo);
|
|
const c = await checkDbOnlyCollectorCollision(engine, {
|
|
collectors: [{ id: 'calendar-to-brain', output_path: 'daily/calendar/' }],
|
|
});
|
|
expect(c.status).toBe('warn');
|
|
expect(c.message).toContain("collector 'calendar-to-brain'");
|
|
expect(c.message).toContain("'daily/calendar/'");
|
|
expect(c.message).toContain("db_only path 'daily/'");
|
|
expect(c.message).toContain('silently skip');
|
|
expect(c.message).toContain('storage.db_only');
|
|
});
|
|
|
|
test('exact-match db_only dir also collides', async () => {
|
|
const repo = makeRepo();
|
|
writeFileSync(join(repo, 'gbrain.yml'), 'storage:\n db_only:\n - daily/calendar/\n');
|
|
await addSource('src-a', repo);
|
|
const c = await checkDbOnlyCollectorCollision(engine, {
|
|
collectors: [{ id: 'calendar-to-brain', output_path: 'daily/calendar/' }],
|
|
});
|
|
expect(c.status).toBe('warn');
|
|
});
|
|
|
|
test('db_only elsewhere → ok', async () => {
|
|
const repo = makeRepo();
|
|
writeFileSync(join(repo, 'gbrain.yml'), 'storage:\n db_only:\n - media/x/\n');
|
|
await addSource('src-a', repo);
|
|
const c = await checkDbOnlyCollectorCollision(engine, {
|
|
collectors: [{ id: 'calendar-to-brain', output_path: 'daily/calendar/' }],
|
|
});
|
|
expect(c.status).toBe('ok');
|
|
});
|
|
|
|
test('sibling prefix does NOT collide (daily/calendar-x vs daily/calendar/)', () => {
|
|
const hits = findDbOnlyCollisions(
|
|
[{ id: 'x', output_path: 'daily/calendar-extra/' }],
|
|
['daily/calendar/'],
|
|
);
|
|
expect(hits.length).toBe(0);
|
|
});
|
|
|
|
test('findDbOnlyCollisions tolerates missing trailing slashes', () => {
|
|
const hits = findDbOnlyCollisions(
|
|
[{ id: 'x', output_path: 'daily/calendar' }],
|
|
['daily'],
|
|
);
|
|
expect(hits.length).toBe(1);
|
|
expect(hits[0].db_only_dir).toBe('daily');
|
|
});
|
|
});
|