190 lines
9.0 KiB
TypeScript
190 lines
9.0 KiB
TypeScript
// portal-auth P1 — 「庫」filter 地基(design §3.2/§3.3,Gitea #24/#25)。
|
||
// 覆蓋:D1 filter SQL 形狀(單值/多值/NULL→general fallback 謂詞)、route 參數解析(含向後相容:
|
||
// 不帶 library = SQL 一字不變)、semantic 路徑 Vectorize $in filter(mock VECTORIZE)、
|
||
// embed 寫入端 library metadata 正規化(未標記→'general')。
|
||
// D1 真實 SQL 語意(COALESCE/json_extract 實際執行)由本機 miniflare + wrangler d1 驗證(PR 驗收證據)。
|
||
import { describe, it, expect } from 'vitest';
|
||
import { Hono } from 'hono';
|
||
import { entryRoutes } from '../src/routes/entries';
|
||
import { listEntries, searchEntries } from '../src/actions/entry-crud';
|
||
import { semanticSearch, embedOnWrite } from '../src/embed';
|
||
import type { Bindings, Entry } from '../src/types';
|
||
|
||
const LIB_PREDICATE = "COALESCE(json_extract(metadata_json, '$.library'), 'general') IN";
|
||
|
||
// ── fake D1:只捕捉 prepared SQL 與 bound params(不解讀語意——真語意交給 miniflare 實跑)──
|
||
interface Captured { sql: string; params: unknown[] }
|
||
function makeCaptureDB(captured: Captured[]) {
|
||
const prepare = (sql: string) => {
|
||
const rec: Captured = { sql, params: [] };
|
||
captured.push(rec);
|
||
const stmt = {
|
||
bind(...args: unknown[]) { rec.params = args; return stmt; },
|
||
async all<T>() { return { results: [] as T[] }; },
|
||
async first<T>() { return { total: 0, c: 0 } as unknown as T; },
|
||
async run() { return { success: true }; },
|
||
};
|
||
return stmt;
|
||
};
|
||
return { prepare } as unknown as D1Database;
|
||
}
|
||
|
||
function mkEntry(id: string, metadata_json: string | null): Entry {
|
||
return {
|
||
id, content: 'some content', entry_type: 'block', owner_id: 'tenant1', parent_id: null,
|
||
page_name: null, refs_json: '[]', tags_json: '[]', task_status: null, content_hash: null,
|
||
is_embedded: 0, confidence: null, metadata_json, created_at: 1, updated_at: 1,
|
||
};
|
||
}
|
||
|
||
describe('D1 library filter — SQL 形狀(entry-crud)', () => {
|
||
it('listEntries 帶 library 多值 → COALESCE…IN (?,?) 謂詞+參數', async () => {
|
||
const captured: Captured[] = [];
|
||
await listEntries(makeCaptureDB(captured), { library: ['finance', 'hr'] });
|
||
const select = captured.find((c) => c.sql.startsWith('SELECT *'))!;
|
||
expect(select.sql).toContain(`${LIB_PREDICATE} (?,?)`);
|
||
expect(select.params.slice(0, 2)).toEqual(['finance', 'hr']);
|
||
// COUNT 查詢同謂詞(total 與分頁一致)
|
||
const count = captured.find((c) => c.sql.includes('COUNT(*)'))!;
|
||
expect(count.sql).toContain(`${LIB_PREDICATE} (?,?)`);
|
||
});
|
||
|
||
it('listEntries 不帶 library → SQL 無庫謂詞(向後相容:行為一字不變)', async () => {
|
||
const captured: Captured[] = [];
|
||
await listEntries(makeCaptureDB(captured), { owner_id: 'tenant1' });
|
||
for (const c of captured) expect(c.sql).not.toContain('$.library');
|
||
});
|
||
|
||
it('searchEntries 帶 library → LIKE+庫謂詞;不帶 → 原樣', async () => {
|
||
const withLib: Captured[] = [];
|
||
await searchEntries(makeCaptureDB(withLib), '遷移', 'tenant1', undefined, undefined, ['general']);
|
||
expect(withLib[0].sql).toContain('content LIKE ?');
|
||
expect(withLib[0].sql).toContain(`${LIB_PREDICATE} (?)`);
|
||
expect(withLib[0].params).toContain('general');
|
||
|
||
const without: Captured[] = [];
|
||
await searchEntries(makeCaptureDB(without), '遷移', 'tenant1');
|
||
expect(without[0].sql).not.toContain('$.library');
|
||
});
|
||
});
|
||
|
||
describe('route 參數解析(GET /entries、/entries/search)', () => {
|
||
function makeApp(captured: Captured[]) {
|
||
const app = new Hono<{ Bindings: Bindings }>();
|
||
app.route('/entries', entryRoutes);
|
||
const env = { DB: makeCaptureDB(captured), ENVIRONMENT: 'test' } as unknown as Bindings;
|
||
return { app, env };
|
||
}
|
||
|
||
it('GET /entries?library=finance,hr →(含空白容忍)庫謂詞+兩參數', async () => {
|
||
const captured: Captured[] = [];
|
||
const { app, env } = makeApp(captured);
|
||
const res = await app.request('/entries?library=finance,%20hr', {}, env);
|
||
expect(res.status).toBe(200);
|
||
const select = captured.find((c) => c.sql.startsWith('SELECT *'))!;
|
||
expect(select.sql).toContain(`${LIB_PREDICATE} (?,?)`);
|
||
expect(select.params).toContain('finance');
|
||
expect(select.params).toContain('hr');
|
||
});
|
||
|
||
it('GET /entries 不帶 library / library=空 → SQL 無庫謂詞(向後相容)', async () => {
|
||
for (const qs of ['', '?library=', '?library=%20,%20']) {
|
||
const captured: Captured[] = [];
|
||
const { app, env } = makeApp(captured);
|
||
const res = await app.request(`/entries${qs}`, {}, env);
|
||
expect(res.status).toBe(200);
|
||
for (const c of captured) expect(c.sql).not.toContain('$.library');
|
||
}
|
||
});
|
||
|
||
it('GET /entries/search?q=x&library=finance(keyword 模式)→ 庫謂詞下到 searchEntries', async () => {
|
||
const captured: Captured[] = [];
|
||
const { app, env } = makeApp(captured);
|
||
const res = await app.request('/entries/search?q=x&library=finance', {}, env);
|
||
expect(res.status).toBe(200);
|
||
expect(captured[0].sql).toContain(`${LIB_PREDICATE} (?)`);
|
||
expect(captured[0].params).toContain('finance');
|
||
});
|
||
|
||
it('semantic 模組未開+帶 library → 誠實降級 keyword 仍套庫 filter(不因降級洩庫)', async () => {
|
||
const captured: Captured[] = [];
|
||
const { app, env } = makeApp(captured); // 無 VECTORIZE/AI binding → semanticSearch 回 null
|
||
const res = await app.request('/entries/search?q=x&mode=semantic&library=finance', {}, env);
|
||
expect(res.status).toBe(200);
|
||
const body = (await res.json()) as { mode: string; requested_mode?: string };
|
||
expect(body.mode).toBe('keyword');
|
||
expect(body.requested_mode).toBe('semantic');
|
||
expect(captured[0].sql).toContain(`${LIB_PREDICATE} (?)`);
|
||
expect(captured[0].params).toContain('finance');
|
||
});
|
||
});
|
||
|
||
describe('semantic 路徑 — Vectorize $in filter(mock VECTORIZE)', () => {
|
||
function makeSemanticEnv(queryCalls: { vec: number[]; opts: Record<string, unknown> }[]) {
|
||
return {
|
||
DB: makeCaptureDB([]),
|
||
ENVIRONMENT: 'test',
|
||
AI: { async run() { return { data: [[0.1, 0.2, 0.3]] }; } },
|
||
VECTORIZE: {
|
||
async query(vec: number[], opts: Record<string, unknown>) {
|
||
queryCalls.push({ vec, opts });
|
||
return { matches: [{ id: 'e1', score: 0.9, metadata: { library: 'finance' } }] };
|
||
},
|
||
async upsert(v: unknown[]) { return { count: (v as unknown[]).length }; },
|
||
},
|
||
} as unknown as Bindings;
|
||
}
|
||
|
||
it('帶 library 多值 → filter.library = { $in: [...] }(主路徑,不 fan-out)', async () => {
|
||
const calls: { vec: number[]; opts: Record<string, unknown> }[] = [];
|
||
const env = makeSemanticEnv(calls);
|
||
const hits = await semanticSearch(env, 'query', { owner_id: 'tenant1', library: ['finance', 'hr'] });
|
||
expect(calls.length).toBe(1); // 單次 query(非每庫 fan-out)
|
||
const filter = calls[0].opts.filter as Record<string, unknown>;
|
||
expect(filter.owner_id).toBe('tenant1');
|
||
expect(filter.library).toEqual({ $in: ['finance', 'hr'] });
|
||
expect(hits?.[0]?.library).toBe('finance'); // hit 帶回 library metadata
|
||
});
|
||
|
||
it('不帶 library → filter 無 library 鍵(行為不變)', async () => {
|
||
const calls: { vec: number[]; opts: Record<string, unknown> }[] = [];
|
||
const env = makeSemanticEnv(calls);
|
||
await semanticSearch(env, 'query', { owner_id: 'tenant1' });
|
||
const filter = calls[0].opts.filter as Record<string, unknown>;
|
||
expect('library' in filter).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('embed 寫入端 — library metadata 正規化', () => {
|
||
function makeUpsertEnv(upserts: { id: string; metadata: Record<string, unknown> }[]) {
|
||
const db = {
|
||
prepare: () => {
|
||
const stmt = { bind: () => stmt, run: async () => ({ success: true }), all: async () => ({ results: [] }), first: async () => null };
|
||
return stmt;
|
||
},
|
||
} as unknown as D1Database;
|
||
return {
|
||
DB: db,
|
||
ENVIRONMENT: 'test',
|
||
AI: { async run(_m: string, i: { text: string[] }) { return { data: i.text.map(() => [0.1, 0.2]) }; } },
|
||
VECTORIZE: { async upsert(v: { id: string; metadata: Record<string, unknown> }[]) { upserts.push(...v); return { count: v.length }; } },
|
||
} as unknown as Bindings;
|
||
}
|
||
|
||
it('metadata_json 有 library → upsert metadata.library 原值', async () => {
|
||
const upserts: { id: string; metadata: Record<string, unknown> }[] = [];
|
||
const env = makeUpsertEnv(upserts);
|
||
await embedOnWrite(env, mkEntry('e1', JSON.stringify({ embed: true, library: 'finance' })));
|
||
expect(upserts[0].metadata.library).toBe('finance');
|
||
});
|
||
|
||
it('未標記 / 空字串 / 非字串 → 正規化為 general(design §3.2 舊資料歸 general)', async () => {
|
||
for (const meta of [{ embed: true }, { embed: true, library: '' }, { embed: true, library: 42 }]) {
|
||
const upserts: { id: string; metadata: Record<string, unknown> }[] = [];
|
||
const env = makeUpsertEnv(upserts);
|
||
await embedOnWrite(env, mkEntry('e1', JSON.stringify(meta)));
|
||
expect(upserts[0].metadata.library).toBe('general');
|
||
}
|
||
});
|
||
});
|