/** * asset-keys 單元測試 — 「哪些 KV key 是使用者的資產」這張表本身 * * KV 退休(Leo/Arcrun#16 + #17)。這支測的是純函式,沒有 KV / KBDB / 網路, * 因為它要守的東西也很單純:**分類錯了,資產就會被留在會被換掉的那一層。** * * 兩個方向都要測,而且分量一樣重: * - 資產不可以被誤判成衍生(漏收=下次更新就不見了,這是 2026-08-12 的病) * - 衍生不可以被誤判成資產(多收=KBDB 長出算得出來的垃圾列) */ import { describe, it, expect } from 'vitest'; import { classifyAssetKey, classifyListPrefix, assetKvKey } from '../src/lib/asset-keys'; import { CRON_INDEX_KEY } from '../src/lib/cron-index'; describe('classifyAssetKey — 資產', () => { it('具名工作流 {api_key}:wf:{name} → workflow_def,租戶與名字都拆得出來', () => { const ref = classifyAssetKey('leo:wf:rag_chat'); expect(ref).not.toBeNull(); expect(ref!.entry_type).toBe('workflow_def'); expect(ref!.owner_id).toBe('leo'); expect(ref!.page_name).toBe('rag_chat'); expect(ref!.kv_key).toBe('leo:wf:rag_chat'); }); it('api recipe(uuid key 與 migration 前的 canonical key 都算資產)', () => { expect(classifyAssetKey('recipe:8f3b-uuid')!.entry_type).toBe('api_recipe'); expect(classifyAssetKey('recipe:telegram_send')!.entry_type).toBe('api_recipe'); }); it('auth recipe / prompt recipe', () => { expect(classifyAssetKey('auth_recipe:notion')!.entry_type).toBe('auth_recipe'); expect(classifyAssetKey('auth_recipe:notion')!.page_name).toBe('notion'); expect(classifyAssetKey('prompt_recipe:wiki_synthesis')!.entry_type).toBe('prompt_recipe'); }); it('同一個 key 永遠對到同一個 entry_id(冪等的根據——重跑遷移不會長出重複列)', () => { expect(classifyAssetKey('leo:wf:a')!.entry_id).toBe(classifyAssetKey('leo:wf:a')!.entry_id); expect(classifyAssetKey('leo:wf:a')!.entry_id).not.toBe(classifyAssetKey('evan:wf:a')!.entry_id); }); }); describe('classifyAssetKey — 衍生資料不可誤收', () => { it('recipe 反查索引 idx:* 全部不是資產(算得回來,見 rehydrateRecipeIndices)', () => { expect(classifyAssetKey('idx:rec_f7e2a1b3')).toBeNull(); expect(classifyAssetKey('idx:canonical:telegram_send')).toBeNull(); expect(classifyAssetKey('idx:installed:telegram_send')).toBeNull(); }); it('cron 索引不是資產', () => { expect(classifyAssetKey(CRON_INDEX_KEY)).toBeNull(); expect(classifyAssetKey('cron-idx:leo:daily')).toBeNull(); }); it('匿名 webhook token / 其他暫存 key 不是資產(本卷不碰,非漏收)', () => { expect(classifyAssetKey('a1b2c3d4e5f6')).toBeNull(); expect(classifyAssetKey('daemon-active:leo')).toBeNull(); }); it('殘缺的 key 不當資產(寧可退回原本的 KV 行為,也不要建出半截的列)', () => { expect(classifyAssetKey('recipe:')).toBeNull(); expect(classifyAssetKey('auth_recipe:')).toBeNull(); expect(classifyAssetKey(':wf:orphan')).toBeNull(); expect(classifyAssetKey('leo:wf:')).toBeNull(); }); }); describe('assetKvKey — 從 KBDB 反推回 KV key(回填快取與 list 都靠它)', () => { it('四型都能原路折返', () => { for (const key of ['leo:wf:rag_chat', 'recipe:telegram_send', 'auth_recipe:notion', 'prompt_recipe:x']) { const ref = classifyAssetKey(key)!; expect(assetKvKey(ref.entry_type, ref.owner_id, ref.page_name)).toBe(key); } }); }); describe('classifyListPrefix — 列舉走哪一邊', () => { it('租戶工作流列舉 → 走 KBDB(帶 owner)', () => { expect(classifyListPrefix('leo:wf:')).toEqual({ entry_type: 'workflow_def', owner_id: 'leo' }); }); it('recipe / auth_recipe 列舉 → 走 KBDB', () => { expect(classifyListPrefix('recipe:')).toEqual({ entry_type: 'api_recipe' }); expect(classifyListPrefix('auth_recipe:')).toEqual({ entry_type: 'auth_recipe' }); }); it('索引與無 prefix 的全域列舉 → 維持原本的 KV 行為', () => { expect(classifyListPrefix('idx:')).toBeNull(); expect(classifyListPrefix('cron-idx:')).toBeNull(); expect(classifyListPrefix(undefined)).toBeNull(); }); });