/** * 同步查詢 trigger(sync query)測試 — webhooks-named.ts 的 /query 與 /q/* 端點。 * * 驗證重點(對應交辦 Part 1): * 1. 同步回「最終節點輸出」本身(非 202、非 {success,data,...} 信封)— GET(query string) 與 POST(body) 皆是。 * 2. namespace 走 path 的公開形態(/q/:ns/:name、POST /webhooks/named/:ns/:name/query)。 * 3. 節點失敗 → 誠實回錯誤 + trace(500),非假綠。 * 4. 認證:缺 X-Arcrun-API-Key → 401;workflow 不存在 → 404。 * * 用內建零件(comp_uppercase / comp_passthrough,純記憶體、無外部 fetch)當「極簡 workflow」, * 因此本測試就是「Part 1 同步 trigger 機制本身可用」的證據(確實同步回值而非 202)。 */ import { describe, it, expect, beforeAll } from 'vitest'; import { env, SELF } from 'cloudflare:test'; const API_KEY = 'test-tenant-query'; function kvKey(name: string, apiKey = API_KEY): string { return `${apiKey}:wf:${name}`; } // 極簡 workflow:單一 comp_uppercase 節點。caller input(text)→ 大寫 → 當最終輸出回。 // 無 Input/Output 節點:最終節點輸出即 comp_uppercase 的回傳,乾淨可斷言。 const UPPER_WF = { name: 'q_upper', graph: { id: 'q_upper', name: 'sync query upper', nodes: [{ id: 'upper', type: 'Component', componentId: 'comp_uppercase' }], edges: [], }, description: '同步查詢:把 text 轉大寫回傳(測試用)', created_at: new Date().toISOString(), }; // 會失敗的 workflow:引用不存在的零件 → 節點執行失敗。 const FAIL_WF = { name: 'q_fail', graph: { id: 'q_fail', name: 'sync query fail', nodes: [{ id: 'boom', type: 'Component', componentId: 'comp_does_not_exist' }], edges: [], }, description: '同步查詢:故意引用不存在零件(測試失敗路徑)', created_at: new Date().toISOString(), }; beforeAll(async () => { await env.WEBHOOKS.put(kvKey(UPPER_WF.name), JSON.stringify(UPPER_WF)); await env.WEBHOOKS.put(kvKey(FAIL_WF.name), JSON.stringify(FAIL_WF)); }); describe('同步查詢 trigger — 成功回最終節點輸出(非 202、非信封)', () => { it('POST /webhooks/named/:name/query(header 認證,body input)同步回輸出', async () => { const res = await SELF.fetch('http://localhost/webhooks/named/q_upper/query', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Arcrun-API-Key': API_KEY }, body: JSON.stringify({ text: 'hello' }), }); expect(res.status).toBe(200); // 關鍵:非 202 const data = await res.json() as Record; // 回的是「最終節點輸出本身」:頂層直接有 text=HELLO,而非包在 { data: ... } 信封裡 expect(data.text).toBe('HELLO'); expect(data).not.toHaveProperty('duration_ms'); // 信封欄位不該出現在 body // duration 走 header,不污染輸出 expect(res.headers.get('X-Arcrun-Duration-Ms')).not.toBeNull(); }); it('GET /webhooks/named/:name/query(header 認證,query string input)同步回輸出', async () => { const res = await SELF.fetch('http://localhost/webhooks/named/q_upper/query?text=world', { headers: { 'X-Arcrun-API-Key': API_KEY }, }); expect(res.status).toBe(200); const data = await res.json() as Record; expect(data.text).toBe('WORLD'); }); it('GET /q/:ns/:name(namespace 走 path,query string input)同步回輸出', async () => { const res = await SELF.fetch(`http://localhost/q/${API_KEY}/q_upper?text=abc`); expect(res.status).toBe(200); const data = await res.json() as Record; expect(data.text).toBe('ABC'); }); it('POST /webhooks/named/:ns/:name/query(namespace 走 path,body input)同步回輸出', async () => { const res = await SELF.fetch(`http://localhost/webhooks/named/${API_KEY}/q_upper/query`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: 'path' }), }); expect(res.status).toBe(200); const data = await res.json() as Record; expect(data.text).toBe('PATH'); }); }); describe('同步查詢 trigger — 誠實錯誤(不假綠)', () => { it('節點失敗 → 500 + error + trace(非把錯誤當輸出)', async () => { const res = await SELF.fetch('http://localhost/webhooks/named/q_fail/query', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Arcrun-API-Key': API_KEY }, body: JSON.stringify({}), }); expect(res.status).toBe(500); const data = await res.json() as { success: boolean; error: string; trace: unknown }; expect(data.success).toBe(false); expect(typeof data.error).toBe('string'); expect(data.error.length).toBeGreaterThan(0); expect(data.trace).toBeDefined(); }); }); describe('同步查詢 trigger — 認證與存在性', () => { it('缺 X-Arcrun-API-Key(header 形態)→ 401', async () => { const res = await SELF.fetch('http://localhost/webhooks/named/q_upper/query', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: 'x' }), }); expect(res.status).toBe(401); }); it('workflow 不存在 → 404', async () => { const res = await SELF.fetch('http://localhost/webhooks/named/no_such_wf/query', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Arcrun-API-Key': API_KEY }, body: JSON.stringify({}), }); expect(res.status).toBe(404); }); });