// portal-auth P2 附帶修復回歸測試(design §2.2): // updateRecord 的「grow 新 slot」路徑建 entry 時,必須帶 record 既有 owner_id。 // 修前:createEntry(db, { content, entry_type: 'value' }) → owner_id=NULL 孤兒 entry, // owner-scoped 查詢(searchByTemplate JOIN entries.owner_id / searchEntries owner filter)看不到該 slot 值。 // 測法沿 library-filter.test.ts 慣例:fake D1 按 SQL 形狀路由回應+捕捉 INSERT 參數 // (不解讀 SQL 語意——真語意由本機 miniflare 端到端驗證,PR 附證據)。 import { describe, it, expect } from 'vitest'; import { updateRecord } from '../src/actions/record-crud'; interface Captured { sql: string; params: unknown[] } /** * 可路由 fake D1: * - entry_values JOIN entries 查詢 → 回既有 slot rows(含 owner_id) * - templates 查詢 → 回 template(slots_json 含既有 + 可 grow 的 slot) * - INSERT INTO entries → 捕捉參數(本測試的斷言目標) * - SELECT * FROM entries WHERE id → 回假 entry(createEntry 的 insert 後回讀) * - 其餘(UPDATE / INSERT entry_values / getRecord SELECT)→ 空殼 */ function makeRoutedDB(recordOwnerId: string | null, 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() { if (sql.includes('FROM entry_values ev JOIN entries e')) { return { results: [ { slot_name: 'email', entry_id: 'e_existing', template_id: 'tpl_pu', owner_id: recordOwnerId }, ] as unknown as T[], }; } if (sql.includes('FROM entry_values ev JOIN entries e ON ev.entry_id = e.id')) { return { results: [] as T[] }; } return { results: [] as T[] }; }, async first() { if (sql.includes('FROM templates')) { return { id: 'tpl_pu', name: 'portal_user', description: null, slots_json: JSON.stringify(['email', 'status']), created_by: 'system', } as unknown as T; } if (sql.startsWith('SELECT * FROM entries WHERE id')) { return { id: 'e_new', content: 'active', entry_type: 'value', owner_id: recordOwnerId, parent_id: null, page_name: null, refs_json: '[]', tags_json: '[]', task_status: null, content_hash: null, is_embedded: 0, confidence: null, metadata_json: null, created_at: 1, updated_at: 1, } as unknown as T; } return null as unknown as T; }, async run() { return { success: true }; }, }; return stmt; }; return { prepare } as unknown as D1Database; } describe('updateRecord grow-slot 路徑帶 owner_id(design §2.2 回歸)', () => { it('record 有 owner_id → grow 出的新 entry INSERT 帶同一 owner_id(修前為 null)', async () => { const captured: Captured[] = []; const db = makeRoutedDB('leo::portal', captured); // 'status' slot 在 template 裡但 record 尚無 → 走 grow 路徑 await updateRecord(db, 'rec_1', { status: 'active' }); const insertEntry = captured.find((c) => c.sql.includes('INSERT INTO entries')); expect(insertEntry, 'grow 路徑應 INSERT 新 entry').toBeTruthy(); // entry-crud.ts createEntry 的 INSERT 欄位序:id, content, entry_type, owner_id, ... expect(insertEntry!.params[1]).toBe('active'); // content expect(insertEntry!.params[2]).toBe('value'); // entry_type expect(insertEntry!.params[3]).toBe('leo::portal'); // owner_id ← 本修復的斷言核心 }); it('record 本就無 owner_id(全域資料)→ grow 出的 entry owner_id 維持 null(行為不變)', async () => { const captured: Captured[] = []; const db = makeRoutedDB(null, captured); await updateRecord(db, 'rec_1', { status: 'active' }); const insertEntry = captured.find((c) => c.sql.includes('INSERT INTO entries')); expect(insertEntry).toBeTruthy(); expect(insertEntry!.params[3]).toBeNull(); }); it('既有 slot 更新(非 grow)→ 走 UPDATE entries,不 INSERT 新 entry', async () => { const captured: Captured[] = []; const db = makeRoutedDB('leo::portal', captured); await updateRecord(db, 'rec_1', { email: 'new@example.com' }); expect(captured.some((c) => c.sql.includes('UPDATE entries SET content'))).toBe(true); expect(captured.some((c) => c.sql.includes('INSERT INTO entries'))).toBe(false); }); });