// ingest 寫入端 — 走 mock KbdbClient(API-as-Wall),零 SQL、不打網路。 // 覆蓋 T3.4 五案:正常 envelope / 同 hash no-op / 新 hash deprecate / 污染 envelope 422 / rollback。 import { describe, it, expect } from 'vitest'; import { ingestEnvelope, IngestEnvelopeSchema, type IngestEnvelope } from '../src/actions/triplet-ingest'; import { queryTriplets } from '../src/actions/triplet-crud'; import { mockClient } from './mock-client'; function envelope(hash: string, triplets: IngestEnvelope['triplets']): IngestEnvelope { return { source: { uri: 'github:uncle6me-web/wiki@a.md', content_hash: hash }, extractor: { model: 'claude-sonnet-4-6', tier: 'deep' }, triplets, }; } describe('ingestEnvelope — 正常 envelope', () => { it('append 全部 triplet 為 active,記 source_uri/content_hash', async () => { const c = mockClient(); const res = await ingestEnvelope(c, envelope('h1', [ { subject: 'A', predicate: 'rel', object: 'B' }, { subject: 'B', predicate: 'rel', object: 'C' }, ])); expect(res).toEqual({ skipped: false, ingested: 2, deprecated: 0 }); const { triplets } = await queryTriplets(c, {}); expect(triplets.length).toBe(2); expect(triplets.every((t) => t.status === 'active')).toBe(true); expect(triplets[0].source_uri).toBe('github:uncle6me-web/wiki@a.md'); expect(triplets[0].content_hash).toBe('h1'); }); }); describe('ingestEnvelope — 同 hash no-op', () => { it('同 uri+hash 再送 → skipped,不新增', async () => { const c = mockClient(); await ingestEnvelope(c, envelope('h1', [{ subject: 'A', predicate: 'r', object: 'B' }])); const res = await ingestEnvelope(c, envelope('h1', [{ subject: 'A', predicate: 'r', object: 'B' }])); expect(res.skipped).toBe(true); const { triplets } = await queryTriplets(c, {}); expect(triplets.length).toBe(1); // 沒有重複 append }); }); describe('ingestEnvelope — 新 hash deprecate-then-append', () => { it('同 uri 新 hash → 舊批轉 deprecated、新批 active;查詢 active-only', async () => { const c = mockClient(); await ingestEnvelope(c, envelope('h1', [{ subject: 'A', predicate: 'r', object: 'old' }])); const res = await ingestEnvelope(c, envelope('h2', [{ subject: 'A', predicate: 'r', object: 'new' }])); expect(res).toEqual({ skipped: false, ingested: 1, deprecated: 1 }); // active-only 查詢只見新批。 const active = await queryTriplets(c, {}); expect(active.triplets.length).toBe(1); expect(active.triplets[0].object).toBe('new'); // 舊批仍在(deprecated),可考古/rollback。 const all = await queryTriplets(c, { includeDeprecated: true }); expect(all.triplets.length).toBe(2); const deprecated = all.triplets.find((t) => t.status === 'deprecated'); expect(deprecated?.object).toBe('old'); expect(deprecated?.superseded_by).toBe('h2'); }); }); describe('ingestEnvelope — 污染 envelope 422(契約 strict)', () => { it('triplet 帶 graph 領域欄位 bridge_score → schema 拒收', () => { const polluted = { source: { uri: 'u', content_hash: 'h' }, extractor: { model: 'm', tier: 'deep' }, triplets: [{ subject: 'A', predicate: 'r', object: 'B', bridge_score: 3 }], }; const parsed = IngestEnvelopeSchema.safeParse(polluted); expect(parsed.success).toBe(false); }); it('envelope 頂層帶禁止欄位 id → 拒收', () => { const polluted = { id: 'should-not-send', source: { uri: 'u', content_hash: 'h' }, extractor: { model: 'm', tier: 'deep' }, triplets: [{ subject: 'A', predicate: 'r', object: 'B' }], }; expect(IngestEnvelopeSchema.safeParse(polluted).success).toBe(false); }); }); describe('ingestEnvelope — rollback(翻回 status)', () => { it('把 deprecated 翻回 active 後,active 查詢重新見到它', async () => { const c = mockClient(); await ingestEnvelope(c, envelope('h1', [{ subject: 'A', predicate: 'r', object: 'old' }])); await ingestEnvelope(c, envelope('h2', [{ subject: 'A', predicate: 'r', object: 'new' }])); // 取出被 deprecate 的舊批 id,手動 rollback(翻回 active、清 superseded_by)。 const all = await queryTriplets(c, { includeDeprecated: true }); const old = all.triplets.find((t) => t.status === 'deprecated')!; await c.updateRecord(old.id, { status: 'active', superseded_by: '' }); const active = await queryTriplets(c, {}); expect(active.triplets.map((t) => t.object).sort()).toEqual(['new', 'old']); }); });