/** * Search provider 單元測試 * 執行:npm run test:search */ import assert from "node:assert/strict"; import { clearSearchCacheForTests } from "./cache"; import { LEGACY_SEARCH_ENV_KEYS } from "./config"; import { dedupeKeyForResult, dedupeResults, filterNotifyDuplicates, isNotifyDuplicate, markNotified, normalizeThreadsUrl, } from "./dedupe"; import { executeBraveSearch } from "./brave-execute"; import { evaluateNotifyRules, filterNotifiableResults } from "./notify-rules"; import { runOrchestratedSearch } from "./orchestrator-search"; import { resetQuotaForTests } from "./quota"; import { modeAllowsBrave, modeAllowsCrawler, modeAllowsThreads, } from "./source-mode"; import type { SearchProvider, SearchResponse, SearchResult } from "./types"; let passed = 0; let failed = 0; function test(name: string, fn: () => void | Promise) { return (async () => { try { await fn(); passed++; console.log(` ✓ ${name}`); } catch (error) { failed++; console.error(` ✗ ${name}`); console.error(` ${error instanceof Error ? error.message : error}`); } })(); } function mockProvider( name: "threads" | "brave" | "crawler", results: SearchResult[], enabled = true ): SearchProvider & { getCallCount: () => number } { let calls = 0; return { name: () => name, enabled: () => enabled, search: async (): Promise => { calls++; return { provider: name, results, status: "success" }; }, getCallCount: () => calls, }; } async function run() { console.log("\n=== Search Provider Tests ===\n"); await test("Threads 成功時不呼叫 Brave", async () => { const threads = mockProvider("threads", [ { title: "t", url: "https://threads.com/@a/post/1", snippet: "s", author: "a", source: "threads" }, ]); const brave = mockProvider("brave", [ { title: "b", url: "https://threads.com/@b/post/2", snippet: "s", author: "b", source: "brave" }, ]); const crawler = mockProvider("crawler", []); const out = await runOrchestratedSearch( { threads, brave, crawler }, { value: "狗洗澡", limit: 10, priority: "high" } ); assert.equal(out.providersUsed.join(","), "threads"); assert.equal(brave.getCallCount(), 0); }); await test("Threads 失敗時 high priority 會 fallback Brave", async () => { const threads = mockProvider("threads", []); const brave = mockProvider("brave", [ { title: "b", url: "https://threads.com/@b/post/2", snippet: "s", author: "b", source: "brave", threadId: "2" }, ]); const crawler = mockProvider("crawler", []); const out = await runOrchestratedSearch( { threads, brave, crawler }, { value: "狗洗澡", limit: 10, priority: "high" }, { skipCrawler: true } ); assert.ok(out.providersUsed.includes("brave")); assert.equal(out.results.length, 1); }); await test("medium priority 不會使用 Brave", async () => { const threads = mockProvider("threads", []); let braveCalled = false; const brave: SearchProvider = { name: () => "brave", enabled: () => true, search: async () => { braveCalled = true; return { provider: "brave", results: [], status: "success" }; }, }; await runOrchestratedSearch( { threads, brave, crawler: mockProvider("crawler", []) }, { value: "狗洗澡", limit: 10, priority: "medium" }, { skipCrawler: true } ); assert.equal(braveCalled, false); }); await test("low priority 不會使用 Brave", async () => { let braveCalled = false; const brave: SearchProvider = { name: () => "brave", enabled: () => true, search: async () => { braveCalled = true; return { provider: "brave", results: [], status: "success" }; }, }; await runOrchestratedSearch( { threads: mockProvider("threads", []), brave, crawler: mockProvider("crawler", []), }, { value: "狗洗澡", limit: 10, priority: "low" }, { skipCrawler: true } ); assert.equal(braveCalled, false); }); await test("Brave 超過每日額度時不呼叫 API", async () => { clearSearchCacheForTests(); resetQuotaForTests(); process.env.BRAVE_SEARCH_ENABLED = "true"; process.env.BRAVE_SEARCH_API_KEY = "test-key"; process.env.BRAVE_DAILY_LIMIT = "1"; let fetchCalled = false; const originalFetch = globalThis.fetch; globalThis.fetch = async () => { fetchCalled = true; return new Response(JSON.stringify({ web: { results: [] } }), { status: 200 }); }; try { await executeBraveSearch({ keyword: "first", limit: 5, priority: "high", patrolMode: true, }); const second = await executeBraveSearch({ keyword: "second", limit: 5, priority: "high", patrolMode: true, }); assert.equal(second.status, "skipped"); assert.equal(second.skipReason, "daily_limit_reached"); assert.equal(fetchCalled, true); } finally { globalThis.fetch = originalFetch; delete process.env.BRAVE_DAILY_LIMIT; } }); await test("Brave query cache 命中時不呼叫 API", async () => { clearSearchCacheForTests(); resetQuotaForTests(); process.env.BRAVE_SEARCH_ENABLED = "true"; process.env.BRAVE_SEARCH_API_KEY = "test-key"; process.env.BRAVE_DAILY_LIMIT = "30"; let fetchCount = 0; const originalFetch = globalThis.fetch; globalThis.fetch = async () => { fetchCount++; return new Response( JSON.stringify({ web: { results: [ { title: "t", description: "d", url: "https://www.threads.com/@u/post/abc", }, ], }, }), { status: 200 } ); }; try { await executeBraveSearch({ keyword: "cache-test", query: 'site:threads.com "cache-test"', limit: 5, priority: "high", patrolMode: true, }); await executeBraveSearch({ keyword: "cache-test", query: 'site:threads.com "cache-test"', limit: 5, priority: "high", patrolMode: true, }); assert.equal(fetchCount, 1); } finally { globalThis.fetch = originalFetch; } }); await test("相同 thread_id 會被 dedupe", () => { const results: SearchResult[] = [ { title: "a", url: "https://threads.com/@u/post/same123", snippet: "1", author: "u", source: "threads", threadId: "same123", }, { title: "b", url: "https://www.threads.com/@u/post/same123/", snippet: "2", author: "u", source: "brave", }, ]; const deduped = dedupeResults(results); assert.equal(deduped.length, 1); assert.equal(dedupeKeyForResult(results[0]), "thread:same123"); }); await test("相同 normalized_url 會被 dedupe", () => { const urlA = "https://www.threads.com/@u/post/x?utm=1#frag"; const urlB = "https://threads.com/@u/post/x"; assert.equal(normalizeThreadsUrl(urlA), normalizeThreadsUrl(urlB)); const results: SearchResult[] = [ { title: "a", url: urlA, snippet: "1", author: "u", source: "brave" }, { title: "b", url: urlB, snippet: "2", author: "u", source: "brave" }, ]; assert.equal(dedupeResults(results).length, 1); }); await test("未達通知標準不通知", () => { const result: SearchResult = { title: "今天天氣真好", url: "https://threads.com/@u/post/1", snippet: "心情不錯", author: "u", source: "threads", }; const verdict = evaluateNotifyRules(result, { brandTerms: ["超級品牌"] }); assert.equal(verdict.matched, false); assert.equal(filterNotifiableResults([result], { brandTerms: ["超級品牌"] }).length, 0); }); await test("命中負面詞會通知", () => { const result: SearchResult = { title: "這產品踩雷了", url: "https://threads.com/@u/post/2", snippet: "真的很失望", author: "u", source: "brave", }; const matched = filterNotifiableResults([result], {}); assert.equal(matched.length, 1); assert.ok(matched[0].matchedRules.includes("negative_word")); }); await test("舊 provider 環境變數已標記 deprecated", () => { const expected = [ "SERPAPI_API_KEY", "SERPER_API_KEY", "GOOGLE_SEARCH_API_KEY", "TAVILY_API_KEY", "EXA_API_KEY", "SEARXNG_BASE_URL", "DUCKDUCKGO_ENABLED", ]; for (const key of expected) { assert.ok( (LEGACY_SEARCH_ENV_KEYS as readonly string[]).includes(key), `missing legacy key ${key}` ); } }); await test("threads 模式不允許 Brave / 爬蟲", () => { assert.equal(modeAllowsThreads("threads"), true); assert.equal(modeAllowsBrave("threads"), false); assert.equal(modeAllowsCrawler("threads"), false); }); await test("mixed 模式允許三種來源", () => { assert.equal(modeAllowsThreads("mixed"), true); assert.equal(modeAllowsBrave("mixed"), true); assert.equal(modeAllowsCrawler("mixed"), true); }); await test("24h notify dedupe 會跳過重複", () => { clearSearchCacheForTests(); const result: SearchResult = { title: "踩雷", url: "https://threads.com/@u/post/dedupe1", snippet: "不推", author: "u", source: "brave", threadId: "dedupe1", }; const key = dedupeKeyForResult(result); markNotified(key, 60_000); assert.equal(isNotifyDuplicate(key), true); assert.equal(filterNotifyDuplicates([result]).length, 0); }); console.log(`\n${passed} passed, ${failed} failed\n`); if (failed > 0) process.exit(1); } run();