Files
kbdb-graph-plugin/tests/triplet-ingest.test.ts
T
Leo 27f7448914 feat(ingest): POST /triplets/ingest 寫入端 + deprecate-then-append (T3.2-3.5)
對應 issue #1 T3 B 段。

- templates: TRIPLET_SLOTS 加 status/superseded_by/source_uri/content_hash;
  ENTITY_SLOTS 加 gloss;recordToTriplet 映射新欄位(缺省 status=active 相容舊資料)
- kbdb-client: ensureTemplate 改 slot-diff 補丁(既有 template 走 PATCH /templates/:id
  補缺 slot,取代 early-return → 免遷移腳本);新增 updateRecord(PATCH /records/:id)
- triplet-ingest action(88 行純函式):Zod strict 鏡射 ingest-candidate 契約 →
  idempotency(uri+hash 同→no-op)→ 先 append 後 deprecate(無「全無 active」空窗)
- POST /triplets/ingest route:strict 驗證失敗 → 422(禁送 graph 領域欄位)
- queryTriplets 預設 active-only(traverse/search/neighbors 皆經此),
  includeDeprecated opt-out 供 rollback/考古
- 6 測試案全綠(vitest 16 passed);mock-client 同步 slot-diff + updateRecord

gates: zero SQL / zero migration / 無 D1·Vectorize·AI 綁定 / dry-run bundle 乾淨

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 18:13:49 +08:00

103 lines
4.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ingest 寫入端 — 走 mock KbdbClientAPI-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']);
});
});