340 lines
9.7 KiB
TypeScript
340 lines
9.7 KiB
TypeScript
/**
|
||
* 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<void>) {
|
||
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<SearchResponse> => {
|
||
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();
|