mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-14 00:48:18 +00:00
Wave-assembled from PR #3755 by @alexey-metaengage. Co-Authored-By: Alexey <alexey@metaengage.ai>
206 lines
7.8 KiB
TypeScript
206 lines
7.8 KiB
TypeScript
/**
|
|
* Tests for export.ts --restore-only resolution chain — step 9 of v0.22.3.
|
|
*
|
|
* D5: --repo → sources.getDefault() → hard error. Never fall through to
|
|
* cwd. Issue #9: bare try/catch removed from storage.ts:37.
|
|
*
|
|
* Tests use PGLite in-memory and a captured-output approach (process.exit
|
|
* is intercepted) to verify the resolution chain produces the right
|
|
* repoPath OR the right error.
|
|
*
|
|
* Also covers per-source scoping of the two sidecar reads in the export loop
|
|
* (tags + raw data), which are keyed by slug and so cross source boundaries
|
|
* unless pinned to the page's own source.
|
|
*/
|
|
|
|
import { describe, test, expect, beforeEach, afterEach, beforeAll, afterAll } from 'bun:test';
|
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs';
|
|
import { join } from 'path';
|
|
import { tmpdir } from 'os';
|
|
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
|
import { runExport } from '../src/commands/export.ts';
|
|
import { __resetMissingStorageWarning } from '../src/core/storage-config.ts';
|
|
|
|
let engine: PGLiteEngine;
|
|
let tmp: string;
|
|
let outDir: string;
|
|
let exitCode: number | null;
|
|
let originalExit: typeof process.exit;
|
|
let originalErr: typeof console.error;
|
|
let originalLog: typeof console.log;
|
|
let stderr: string[];
|
|
let stdout: string[];
|
|
|
|
beforeAll(async () => {
|
|
engine = new PGLiteEngine();
|
|
await engine.connect({});
|
|
await engine.initSchema();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await engine.disconnect();
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
tmp = mkdtempSync(join(tmpdir(), 'gbrain-export-test-'));
|
|
outDir = join(tmp, 'out');
|
|
exitCode = null;
|
|
stderr = [];
|
|
stdout = [];
|
|
__resetMissingStorageWarning();
|
|
|
|
originalExit = process.exit;
|
|
process.exit = ((code?: number) => {
|
|
exitCode = code ?? 0;
|
|
throw new Error(`__test_exit__:${code}`);
|
|
}) as typeof process.exit;
|
|
|
|
originalErr = console.error;
|
|
console.error = (...args: unknown[]) => {
|
|
stderr.push(args.map(String).join(' '));
|
|
};
|
|
|
|
originalLog = console.log;
|
|
console.log = (...args: unknown[]) => {
|
|
stdout.push(args.map(String).join(' '));
|
|
};
|
|
|
|
// Reset DB state between tests
|
|
const tables = ['content_chunks', 'links', 'tags', 'raw_data', 'timeline_entries', 'page_versions', 'ingest_log', 'pages', 'sources'];
|
|
for (const t of tables) {
|
|
await (engine as unknown as { db: { exec(sql: string): Promise<unknown> } }).db.exec(`DELETE FROM ${t}`);
|
|
}
|
|
// Recreate the default source (the schema seed but truncated above).
|
|
await engine.executeRaw(
|
|
`INSERT INTO sources (id, name) VALUES ('default', 'Default') ON CONFLICT DO NOTHING`,
|
|
);
|
|
});
|
|
|
|
afterEach(() => {
|
|
process.exit = originalExit;
|
|
console.error = originalErr;
|
|
console.log = originalLog;
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
});
|
|
|
|
async function tryRunExport(args: string[]): Promise<void> {
|
|
try {
|
|
await runExport(engine, args);
|
|
} catch (e) {
|
|
// Swallow only the test-exit sentinel; rethrow others for visibility.
|
|
if (!(e instanceof Error && e.message.startsWith('__test_exit__:'))) {
|
|
throw e;
|
|
}
|
|
}
|
|
}
|
|
|
|
describe('export --restore-only resolution chain (D5)', () => {
|
|
test('hard-errors when --restore-only has no --repo and no default source path', async () => {
|
|
// sources.default has no local_path (the seeded shape).
|
|
await tryRunExport(['--dir', outDir, '--restore-only']);
|
|
expect(exitCode).toBe(1);
|
|
expect(stderr.join('\n')).toMatch(/requires --repo|configured default source/);
|
|
});
|
|
|
|
test('uses explicit --repo when provided', async () => {
|
|
// Make a brain repo with gbrain.yml that has empty db_only — so we
|
|
// exit through the "0 pages to restore" path without needing real data.
|
|
writeFileSync(
|
|
join(tmp, 'gbrain.yml'),
|
|
`storage:
|
|
db_tracked: []
|
|
db_only: []
|
|
`,
|
|
);
|
|
await tryRunExport(['--dir', outDir, '--restore-only', '--repo', tmp]);
|
|
expect(exitCode).toBeNull(); // no exit
|
|
expect(stdout.some((line) => line.includes('Restoring 0'))).toBe(true);
|
|
});
|
|
|
|
test('falls back to sources default local_path when --repo absent', async () => {
|
|
// Configure default source path, write a real gbrain.yml so the storage
|
|
// config check passes — without gbrain.yml the Codex-P0 guard correctly
|
|
// refuses --restore-only (no storage config to scope to).
|
|
await engine.executeRaw(`UPDATE sources SET local_path = $1 WHERE id = 'default'`, [tmp]);
|
|
writeFileSync(
|
|
join(tmp, 'gbrain.yml'),
|
|
`storage:\n db_tracked: []\n db_only:\n - media/x/\n`,
|
|
);
|
|
await tryRunExport(['--dir', outDir, '--restore-only']);
|
|
expect(exitCode).toBeNull(); // resolution succeeded
|
|
});
|
|
|
|
test('refuses --restore-only when no storage config is present (Codex P0)', async () => {
|
|
// Default source has a path but no gbrain.yml. Without a storage config,
|
|
// --restore-only would silently fall through to a full export — exactly
|
|
// the silent-footgun D5 was supposed to prevent.
|
|
await engine.executeRaw(`UPDATE sources SET local_path = $1 WHERE id = 'default'`, [tmp]);
|
|
await tryRunExport(['--dir', outDir, '--restore-only']);
|
|
expect(exitCode).toBe(1);
|
|
expect(stderr.join('\n')).toMatch(/storage tiering config|gbrain\.yml/);
|
|
});
|
|
|
|
test('non-restore export does NOT require --repo (D26)', async () => {
|
|
// Regular export works without --repo since it dumps everything from DB.
|
|
// Pages table is empty → exports 0 pages, no error.
|
|
await tryRunExport(['--dir', outDir]);
|
|
expect(exitCode).toBeNull();
|
|
expect(stdout.some((line) => line.includes('Exporting 0'))).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('export sidecar reads are scoped to the page owning source', () => {
|
|
// Both fixtures use the SAME slug in two sources — the only shape where a
|
|
// slug-keyed read can cross a boundary, since slugs are unique per source
|
|
// rather than brain-wide.
|
|
const SLUG = 'notes/shared';
|
|
|
|
beforeEach(async () => {
|
|
await engine.executeRaw(
|
|
`INSERT INTO sources (id, name) VALUES ('other', 'Other') ON CONFLICT DO NOTHING`,
|
|
);
|
|
for (const sourceId of ['default', 'other']) {
|
|
await engine.putPage(
|
|
SLUG,
|
|
{ type: 'note', title: `${sourceId} title`, compiled_truth: 'body' },
|
|
{ sourceId },
|
|
);
|
|
await engine.addTag(SLUG, `tag-${sourceId}`, { sourceId });
|
|
await engine.putRawData(SLUG, `feed-${sourceId}`, { owner: sourceId }, { sourceId });
|
|
}
|
|
|
|
// One slug means one output path, so the two pages overwrite each other
|
|
// and only the last one written survives on disk. Pin the export order
|
|
// (listPages defaults to updated_desc) so `other` is the survivor and the
|
|
// assertions below read a NON-default page — the only page whose sidecars
|
|
// an unscoped, `'default'`-defaulting read would get wrong.
|
|
await engine.executeRaw(
|
|
`UPDATE pages SET updated_at = updated_at + interval '1 hour' WHERE source_id = 'default'`,
|
|
);
|
|
});
|
|
|
|
test("a non-default page exports its own tags, not the default source's", async () => {
|
|
await tryRunExport(['--dir', outDir]);
|
|
expect(exitCode).toBeNull();
|
|
|
|
const md = readFileSync(join(outDir, SLUG + '.md'), 'utf-8');
|
|
// Guards the ordering assumption itself: if `default` ever wins the race
|
|
// the tag assertions stop discriminating, so fail loudly here instead.
|
|
expect(md).toContain('other title');
|
|
expect(md).toContain('tag-other');
|
|
expect(md).not.toContain('tag-default');
|
|
});
|
|
|
|
test('raw-data sidecars carry only the owning source rows', async () => {
|
|
await tryRunExport(['--dir', outDir]);
|
|
expect(exitCode).toBeNull();
|
|
|
|
// Unscoped, getRawData applies no source predicate at all: both sources'
|
|
// rows come back and the export loop merges them into a single object
|
|
// keyed by `rd.source`.
|
|
const raw = JSON.parse(readFileSync(join(outDir, 'notes', '.raw', 'shared.json'), 'utf-8'));
|
|
expect(Object.keys(raw)).toEqual(['feed-other']);
|
|
expect(raw['feed-other']).toEqual({ owner: 'other' });
|
|
});
|
|
});
|