/** * POST /kbdb/records — `entry_ids` 通道(Arcrun#128) * * 背景:基本盤 kbdb 的 createRecord 現在接受 `entry_ids`(slot 指向**既有** entry 的 id, * 不新建、不複製)。這條 proxy 之前寫死只轉發 `values`,且沒有 values 就 400 * ⇒ 走 X-Arcrun-API-Key 的呼叫者(#129 的 wiki 寫入端、#130 的三元組正規化)打不到新能力, * 等於基本盤補好了、通道沒開(同 b6ef0f0 那次 PATCH 的形狀)。 * * 驗的是 IO 接線(判斷真身在基本盤,這裡只測轉發,比照 kbdb-records-patch-proxy.test.ts): * 1. 租戶閘:無 X-Arcrun-API-Key → 401 不碰 KBDB * 2. 只給 entry_ids(沒有 values)→ 轉發成功(舊版這裡是 400) * 3. 轉發的 body:帶 entry_ids + **注入租戶當 owner_id**(caller 自帶的 owner_id 被忽略) * 4. values 與 entry_ids 混用 → 兩個都轉過去 * 5. 兩個都沒給 → 400,不轉發 * 6. base 擋跨租戶(400)→ 原樣透傳,不假裝成功 * * KBDB 打 fetchMock 假 host(wrangler.test.toml KBDB_BASE_URL=https://kbdb.test)+ * disableNetConnect——測試絕不外連。 */ import { SELF, fetchMock } from 'cloudflare:test'; import { beforeAll, afterEach, describe, it, expect } from 'vitest'; const KEY = { 'X-Arcrun-API-Key': 'leo', 'Content-Type': 'application/json' }; beforeAll(() => { fetchMock.activate(); fetchMock.disableNetConnect(); }); afterEach(() => fetchMock.assertNoPendingInterceptors()); describe('POST /kbdb/records(entry_ids)— 租戶閘與參數', () => { it('無 X-Arcrun-API-Key → 401,不碰 KBDB', async () => { const res = await SELF.fetch('http://localhost/kbdb/records', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ template: 'wiki', entry_ids: { gloss: 'e_1' } }), }); expect(res.status).toBe(401); }); it('values 與 entry_ids 都沒給 → 400,不轉發', async () => { const res = await SELF.fetch('http://localhost/kbdb/records', { method: 'POST', headers: KEY, body: JSON.stringify({ template: 'wiki' }), }); expect(res.status).toBe(400); }); }); describe('POST /kbdb/records(entry_ids)— 轉發', () => { it('只給 entry_ids(沒有 values)→ 轉發,且 owner_id 由租戶身份注入', async () => { fetchMock .get('https://kbdb.test') .intercept({ path: '/records', method: 'POST', // 沒有 values 這個 key(不要憑空塞一個空物件給基本盤) body: JSON.stringify({ template: 'wiki', entry_ids: { gloss: 'e_1', points: 'e_2' }, owner_id: 'leo' }), }) .reply(200, { success: true, record: { record_id: 'rec_1', template_id: 'tpl-wiki', values: { gloss: '既有', points: '既有2' }, owner_id: 'leo' }, }); const res = await SELF.fetch('http://localhost/kbdb/records', { method: 'POST', headers: KEY, // caller 自帶 owner_id:必須被忽略(防跨租戶寫入,本檔既有慣例) body: JSON.stringify({ template: 'wiki', entry_ids: { gloss: 'e_1', points: 'e_2' }, owner_id: 'alice' }), }); expect(res.status).toBe(200); const data = (await res.json()) as { success: boolean; record: { values: Record } }; expect(data.success).toBe(true); expect(data.record.values.gloss).toBe('既有'); }); it('values 與 entry_ids 混用 → 兩個都轉過去', async () => { fetchMock .get('https://kbdb.test') .intercept({ path: '/records', method: 'POST', body: JSON.stringify({ template: 'wiki', values: { title: '新建' }, entry_ids: { gloss: 'e_1' }, owner_id: 'leo' }), }) .reply(200, { success: true, record: { record_id: 'rec_2', template_id: 'tpl-wiki', values: {}, owner_id: 'leo' } }); const res = await SELF.fetch('http://localhost/kbdb/records', { method: 'POST', headers: KEY, body: JSON.stringify({ template: 'wiki', values: { title: '新建' }, entry_ids: { gloss: 'e_1' } }), }); expect(res.status).toBe(200); }); it('舊呼叫端(只給 values)→ 轉發的 body 不夾帶 entry_ids,行為與過去相同', async () => { fetchMock .get('https://kbdb.test') .intercept({ path: '/records', method: 'POST', body: JSON.stringify({ template: 'triplet', values: { subject: 'A', predicate: 'r', object: 'B' }, owner_id: 'leo' }), }) .reply(200, { success: true, record: { record_id: 'rec_3', template_id: 'tpl-triplet', values: {}, owner_id: 'leo' } }); const res = await SELF.fetch('http://localhost/kbdb/records', { method: 'POST', headers: KEY, body: JSON.stringify({ template: 'triplet', values: { subject: 'A', predicate: 'r', object: 'B' } }), }); expect(res.status).toBe(200); }); it('base 擋下跨租戶參照(400)→ 原樣透傳,不假裝成功', async () => { fetchMock .get('https://kbdb.test') .intercept({ path: '/records', method: 'POST' }) .reply(400, { success: false, error: 'entry owner mismatch: e_x(alice) != leo' }); const res = await SELF.fetch('http://localhost/kbdb/records', { method: 'POST', headers: KEY, body: JSON.stringify({ template: 'wiki', entry_ids: { gloss: 'e_x' } }), }); expect(res.status).toBe(400); const data = (await res.json()) as { success: boolean; error: string }; expect(data.success).toBe(false); expect(data.error).toContain('owner mismatch'); }); });