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();
|