haixunMaster/lib/search/search.test.ts

340 lines
9.7 KiB
TypeScript
Raw Normal View History

2026-06-21 12:50:31 +00:00
/**
* 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();