Files
GMW/services/discord-gateway/tests/tinyFishSearch.test.ts
T
asepharyana 5c133f7302 feat(gateway): tinyfish web search as fallback when wikipedia misses
- new tinyFishSearch module: GET api.search.tinyfish.ai with X-API-Key,
  maps top-3 to SearchResult shape, never throws (all failure modes -> [])
- wikipediaSearch: on wiki miss, one tinyfish attempt; hits cached 6h
  under the same key so fallback latency is paid once
- termGlossary: on summary miss, top tinyfish hit becomes the definition
  (persisted permanently like wiki defs); miss keeps 1h sentinel
- config: TINYFISH_API_KEY (empty = fallback disabled), ENABLED,
  BASE_URL, TIMEOUT_MS, LOCATION, LANGUAGE knobs
- tests: 6 coverage for disabled/mapping/non-OK/network/bad-json

API key NOT committed — set TINYFISH_API_KEY in BWS gmw secrets.

Verified: typecheck + lint clean, 216/216 tests pass, live probe
'gubernur jawa barat' returned 3 mapped results
2026-09-14 00:44:38 +07:00

151 lines
5.5 KiB
TypeScript

// ═══════════════════════════════════════════════════════════════════════════
// TinyFish web-search fallback — disabled-by-default network guard + mapping.
// No key in env (vitest config) → isTinyFishEnabled() false → all live calls
// return [] without touching the network. Enabled paths use a stubbed fetch.
// ═══════════════════════════════════════════════════════════════════════════
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
isTinyFishEnabled,
tinyFishSearchLive,
} from "../src/modules/ai-moderation/tinyFishSearch.js";
function jsonResponse(body: unknown, ok = true, status = 200): Response {
return {
ok,
status,
json: async () => body,
} as unknown as Response;
}
const TINYFISH_BODY = {
query: "shirkmaxxing",
results: [
{
position: 1,
title: "Shirkmaxxing explained",
url: "https://example.com/shirkmaxxing",
snippet:
"Shirkmaxxing is internet slang for performative avoidance of work.",
},
{
position: 2,
title: "No URL here",
url: "",
snippet: "dropped by the mapper",
},
],
};
describe("tinyFishSearch fallback", () => {
let prevKey: string;
let prevEnabled: unknown;
beforeEach(async () => {
vi.restoreAllMocks();
// Pin the live config object to a known-disabled state: the shell may
// export a real TINYFISH_API_KEY (dev box), which would flip
// isTinyFishEnabled() and let tests hit the network.
const { config } = await import("../../src/shared/config/config.js");
prevKey = config.TINYFISH_API_KEY;
prevEnabled = config.TINYFISH_SEARCH_ENABLED;
(config as Record<string, unknown>).TINYFISH_API_KEY = "";
(config as Record<string, unknown>).TINYFISH_SEARCH_ENABLED = true;
});
afterEach(async () => {
vi.restoreAllMocks();
const { config } = await import("../../src/shared/config/config.js");
(config as Record<string, unknown>).TINYFISH_API_KEY = prevKey;
(config as Record<string, unknown>).TINYFISH_SEARCH_ENABLED = prevEnabled;
});
async function withTestKey(): Promise<{
config: Record<string, unknown>;
prev: string;
}> {
const { config } = await import("../../src/shared/config/config.js");
const prev = config.TINYFISH_API_KEY;
(config as Record<string, unknown>).TINYFISH_API_KEY = "sk-test-key";
return { config: config as unknown as Record<string, unknown>, prev };
}
it("is disabled without an API key — live search returns [] with no fetch", async () => {
expect(isTinyFishEnabled()).toBe(false);
const fetchMock = vi.spyOn(globalThis, "fetch");
expect(await tinyFishSearchLive("shirkmaxxing")).toEqual([]);
expect(fetchMock).not.toHaveBeenCalled();
});
it("returns [] for blank queries without fetching", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch");
expect(await tinyFishSearchLive(" ")).toEqual([]);
expect(fetchMock).not.toHaveBeenCalled();
});
it("maps results to SearchResult shape and drops hits without URL", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
jsonResponse(TINYFISH_BODY),
);
const { config, prev: prevKey } = await withTestKey();
try {
expect(isTinyFishEnabled()).toBe(true);
const out = await tinyFishSearchLive("shirkmaxxing");
expect(out).toHaveLength(1);
expect(out[0]).toMatchObject({
title: "Shirkmaxxing explained",
url: "https://example.com/shirkmaxxing",
});
expect(out[0].snippet).toContain("performative avoidance");
const calledUrl = String(
(globalThis.fetch as unknown as { mock: { calls: unknown[][] } }).mock
.calls[0][0],
);
expect(calledUrl).toContain("query=shirkmaxxing");
expect(calledUrl).toContain("location=");
expect(calledUrl).toContain("language=");
} finally {
(config as Record<string, unknown>).TINYFISH_API_KEY = prevKey;
}
});
it("returns [] on non-OK status without throwing", async () => {
const { config, prev: prevKey } = await withTestKey();
try {
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
jsonResponse({ error: "boom" }, false, 502),
);
expect(await tinyFishSearchLive("shirkmaxxing")).toEqual([]);
} finally {
(config as Record<string, unknown>).TINYFISH_API_KEY = prevKey;
}
});
it("returns [] on network error without throwing", async () => {
const { config, prev: prevKey } = await withTestKey();
try {
vi.spyOn(globalThis, "fetch").mockRejectedValueOnce(
new Error("network down"),
);
expect(await tinyFishSearchLive("shirkmaxxing")).toEqual([]);
} finally {
(config as Record<string, unknown>).TINYFISH_API_KEY = prevKey;
}
});
it("returns [] on invalid JSON without throwing", async () => {
const { config, prev: prevKey } = await withTestKey();
try {
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => {
throw new Error("bad json");
},
} as unknown as Response);
expect(await tinyFishSearchLive("shirkmaxxing")).toEqual([]);
} finally {
(config as Record<string, unknown>).TINYFISH_API_KEY = prevKey;
}
});
});