// arcrun-rag#46「刪掉的知識搜尋還撈得到」第 4 點:中途失敗要看得出來。 // // DELETE /entries/:id 舊版把向量刪除包成 fire-and-forget(waitUntil(...).catch(()=>{}))—— // 呼叫端完全看不到向量清除是否成功。本測試鎖住新行為:同步 await+回應帶 vector_deleted, // 且無論向量刪除成功或失敗,D1 本體都要真的被刪掉(不因向量失敗而擋下本體刪除)。 // // 測試手法同 search-deprecated-filter.test.ts:fake D1 捕捉 SQL 形狀;mock VECTORIZE 可控 // deleteByIds 成功/失敗,驗證 route 層如何把結果誠實透傳給呼叫端。 import { describe, it, expect } from 'vitest'; import { Hono } from 'hono'; import { entryRoutes } from '../src/routes/entries'; import type { Bindings } from '../src/types'; 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 null as unknown as T; }, async run() { return { success: true }; }, }; return stmt; }; return { prepare } as unknown as D1Database; } function makeApp(captured: Captured[], extraEnv: Record = {}) { const app = new Hono<{ Bindings: Bindings }>(); app.route('/entries', entryRoutes); const env = { DB: makeCaptureDB(captured), ENVIRONMENT: 'test', ...extraEnv } as unknown as Bindings; return { app, env }; } describe('arcrun-rag#46 — DELETE /entries/:id 失敗可見性', () => { it('embed 模組未開 → vector_deleted:null(不適用,非「清成功了」的謊)+ D1 仍照刪', async () => { const captured: Captured[] = []; const { app, env } = makeApp(captured); // 無 VECTORIZE/AI const res = await app.request('/entries/e123', { method: 'DELETE' }, env); expect(res.status).toBe(200); const body = (await res.json()) as { success: boolean; vector_deleted: boolean | null }; expect(body.success).toBe(true); expect(body.vector_deleted).toBe(null); expect(captured.some((c) => c.sql.includes('DELETE FROM entries WHERE id = ?'))).toBe(true); }); it('模組開+向量刪除成功 → vector_deleted:true(同步等到結果才回應,不是猜的)', async () => { const captured: Captured[] = []; const deletedIds: string[][] = []; const { app, env } = makeApp(captured, { VECTORIZE: { async deleteByIds(ids: string[]) { deletedIds.push(ids); return { count: ids.length }; }, }, AI: { async run() { return { data: [[0.1]] }; } }, }); const res = await app.request('/entries/e123', { method: 'DELETE' }, env); const body = (await res.json()) as { success: boolean; vector_deleted: boolean | null }; expect(body.success).toBe(true); expect(body.vector_deleted).toBe(true); expect(deletedIds).toEqual([['e123']]); // 真的呼叫了、帶對 id,不是没做就回真 }); it('🔴 模組開+向量刪除失敗 → vector_deleted:false 誠實回報,且 D1 本體仍真的刪掉', async () => { const captured: Captured[] = []; const { app, env } = makeApp(captured, { VECTORIZE: { async deleteByIds() { throw new Error('Vectorize 503(模擬故障)'); }, }, AI: { async run() { return { data: [[0.1]] }; } }, }); const res = await app.request('/entries/e123', { method: 'DELETE' }, env); // 舊版這裡的失敗會被 waitUntil(...).catch(()=>{}) 吞掉、caller 永遠看不到; // 新版:HTTP 仍是 200(D1 本體真的刪了,這件事沒有失敗),但誠實標出向量那一步失敗了。 expect(res.status).toBe(200); const body = (await res.json()) as { success: boolean; vector_deleted: boolean | null }; expect(body.success).toBe(true); expect(body.vector_deleted).toBe(false); // D1 本體不因向量失敗而被擋下——刪除的「本體一定會消失」承諾不打折扣。 expect(captured.some((c) => c.sql.includes('DELETE FROM entries WHERE id = ?'))).toBe(true); }); });