haixunMaster/lib/search/search.test.ts

340 lines
9.7 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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