// 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() { return { results: [] as T[] }; }, async first() { 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 }[]) { return { DB: makeCaptureDB([]), ENVIRONMENT: 'test', AI: { async run() { return { data: [[0.1, 0.2, 0.3]] }; } }, VECTORIZE: { async query(vec: number[], opts: Record) { 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 }[] = []; 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; 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 }[] = []; const env = makeSemanticEnv(calls); await semanticSearch(env, 'query', { owner_id: 'tenant1' }); const filter = calls[0].opts.filter as Record; expect('library' in filter).toBe(false); }); }); describe('embed 寫入端 — library metadata 正規化', () => { function makeUpsertEnv(upserts: { id: string; metadata: Record }[]) { 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 }[]) { 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 }[] = []; 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 }[] = []; const env = makeUpsertEnv(upserts); await embedOnWrite(env, mkEntry('e1', JSON.stringify(meta))); expect(upserts[0].metadata.library).toBe('general'); } }); });