mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-28 06:23:01 +00:00
155 lines
6.5 KiB
TypeScript
155 lines
6.5 KiB
TypeScript
// Phase 8 (D1-D3 + cherry-2 + cherry-3 + Sec5 + Eng-1C): importImageFile
|
|
// + withImportTransaction shared helper. Verifies the core ingest path on
|
|
// PGLite without a real Voyage API key (uses noEmbed=true).
|
|
//
|
|
// Real-API embedding is exercised in test/e2e/voyage-multimodal.test.ts (gated
|
|
// VOYAGE_API_KEY) and the dual-engine parity gate lands in Phase 10.
|
|
|
|
import { describe, expect, test, beforeAll, afterAll, beforeEach } from 'bun:test';
|
|
import { mkdtempSync, writeFileSync, copyFileSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { tmpdir } from 'node:os';
|
|
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
|
import { importImageFile, isImageFilePath, pLimit, SUPPORTED_IMAGE_EXTS } from '../src/core/import-file.ts';
|
|
import { resetPgliteState } from './helpers/reset-pglite.ts';
|
|
|
|
let engine: PGLiteEngine;
|
|
let tmpDir: string;
|
|
|
|
beforeAll(async () => {
|
|
engine = new PGLiteEngine();
|
|
await engine.connect({});
|
|
await engine.initSchema();
|
|
tmpDir = mkdtempSync(join(tmpdir(), 'gbrain-img-test-'));
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await engine.disconnect();
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
await resetPgliteState(engine);
|
|
});
|
|
|
|
describe('isImageFilePath / SUPPORTED_IMAGE_EXTS', () => {
|
|
test('recognizes all supported extensions', () => {
|
|
for (const ext of SUPPORTED_IMAGE_EXTS) {
|
|
expect(isImageFilePath(`some/path/foo${ext}`)).toBe(true);
|
|
expect(isImageFilePath(`some/path/FOO${ext.toUpperCase()}`)).toBe(true);
|
|
}
|
|
});
|
|
|
|
test('rejects non-image extensions', () => {
|
|
expect(isImageFilePath('readme.md')).toBe(false);
|
|
expect(isImageFilePath('script.ts')).toBe(false);
|
|
expect(isImageFilePath('image_no_ext')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('pLimit semaphore (Eng-1C)', () => {
|
|
test('serializes work to the configured concurrency', async () => {
|
|
const limit = pLimit(2);
|
|
const order: string[] = [];
|
|
const sleep = (ms: number) => new Promise(r => setTimeout(r, ms));
|
|
|
|
const tasks = [
|
|
limit(async () => { order.push('A-start'); await sleep(20); order.push('A-end'); }),
|
|
limit(async () => { order.push('B-start'); await sleep(20); order.push('B-end'); }),
|
|
limit(async () => { order.push('C-start'); await sleep(5); order.push('C-end'); }),
|
|
limit(async () => { order.push('D-start'); await sleep(5); order.push('D-end'); }),
|
|
];
|
|
|
|
await Promise.all(tasks);
|
|
|
|
// First two start before either finishes (concurrency=2). C/D wait.
|
|
expect(order.indexOf('A-start')).toBeLessThan(order.indexOf('C-start'));
|
|
expect(order.indexOf('B-start')).toBeLessThan(order.indexOf('C-start'));
|
|
// All four eventually run.
|
|
expect(order.filter(s => s.endsWith('-end')).length).toBe(4);
|
|
});
|
|
|
|
test('propagates rejections without leaving the slot held', async () => {
|
|
const limit = pLimit(1);
|
|
const reject = limit(async () => { throw new Error('boom'); });
|
|
let caught: unknown;
|
|
try { await reject; } catch (e) { caught = e; }
|
|
expect((caught as Error).message).toBe('boom');
|
|
// Slot must release; the next call should run promptly.
|
|
const ok = await limit(async () => 'ok');
|
|
expect(ok).toBe('ok');
|
|
});
|
|
});
|
|
|
|
describe('importImageFile happy path (noEmbed)', () => {
|
|
test('imports a PNG fixture, creates a single image chunk + files row', async () => {
|
|
// Copy the tiny.avif fixture as a stand-in for a generic image; the test
|
|
// runs noEmbed:true so no decode/voyage call fires. Rename to .png so the
|
|
// dispatcher routes correctly without needing actual decode.
|
|
const target = join(tmpDir, 'photo.png');
|
|
copyFileSync('test/fixtures/images/tiny.avif', target);
|
|
|
|
const result = await importImageFile(engine, target, 'originals/photos/photo.png', { noEmbed: true });
|
|
expect(result.status).toBe('imported');
|
|
expect(result.chunks).toBe(1);
|
|
|
|
const page = await engine.getPage('originals/photos/photo.png');
|
|
expect(page).not.toBeNull();
|
|
expect(page!.type).toBe('image');
|
|
expect((page!.frontmatter as Record<string, unknown>).mime_type).toBe('image/png');
|
|
|
|
const file = await engine.getFile('default', 'originals/photos/photo.png');
|
|
expect(file).not.toBeNull();
|
|
expect(file!.filename).toBe('photo.png');
|
|
expect(file!.mime_type).toBe('image/png');
|
|
expect(file!.page_id).toBe(page!.id);
|
|
|
|
const chunks = await engine.getChunks('originals/photos/photo.png');
|
|
expect(chunks.length).toBe(1);
|
|
expect((chunks[0] as { chunk_source: string }).chunk_source).toBe('image_asset');
|
|
// chunk_text falls back to filename when OCR is off (default).
|
|
expect(chunks[0].chunk_text).toBe('photo.png');
|
|
});
|
|
|
|
test('idempotent on content_hash: re-import same bytes returns skipped', async () => {
|
|
const target = join(tmpDir, 'photo2.png');
|
|
writeFileSync(target, Buffer.from('fake-png-bytes-stable'));
|
|
|
|
const r1 = await importImageFile(engine, target, 'photos/photo2.png', { noEmbed: true });
|
|
expect(r1.status).toBe('imported');
|
|
const r2 = await importImageFile(engine, target, 'photos/photo2.png', { noEmbed: true });
|
|
expect(r2.status).toBe('skipped');
|
|
});
|
|
|
|
test('routes page, chunks, and file metadata to the requested source (#2706)', async () => {
|
|
await engine.executeRaw(
|
|
`INSERT INTO sources (id, name) VALUES ($1, $2) ON CONFLICT (id) DO NOTHING`,
|
|
['image-source', 'Image Source'],
|
|
);
|
|
const target = join(tmpDir, 'source-photo.png');
|
|
writeFileSync(target, Buffer.from('fake-png-bytes-source-scoped'));
|
|
|
|
const result = await importImageFile(engine, target, 'photos/source-photo.png', {
|
|
noEmbed: true,
|
|
sourceId: 'image-source',
|
|
});
|
|
|
|
expect(result.status).toBe('imported');
|
|
expect(await engine.getPage('photos/source-photo.png', { sourceId: 'default' })).toBeNull();
|
|
const page = await engine.getPage('photos/source-photo.png', { sourceId: 'image-source' });
|
|
expect(page).not.toBeNull();
|
|
expect(await engine.getChunks('photos/source-photo.png', { sourceId: 'default' })).toHaveLength(0);
|
|
expect(await engine.getChunks('photos/source-photo.png', { sourceId: 'image-source' })).toHaveLength(1);
|
|
expect(await engine.getFile('default', 'photos/source-photo.png')).toBeNull();
|
|
expect(await engine.getFile('image-source', 'photos/source-photo.png')).not.toBeNull();
|
|
});
|
|
|
|
test('refuses oversized files (>20MB)', async () => {
|
|
const target = join(tmpDir, 'huge.png');
|
|
// Write a 21MB file. Buffer.alloc is fast.
|
|
writeFileSync(target, Buffer.alloc(21 * 1024 * 1024));
|
|
const result = await importImageFile(engine, target, 'photos/huge.png', { noEmbed: true });
|
|
expect(result.status).toBe('skipped');
|
|
expect(result.error).toMatch(/Image too large/);
|
|
});
|
|
});
|