import { describe, expect, it, vi } from "vitest"; vi.mock("cloudflare:workers", () => ({ waitUntil: vi.fn() })); const { dataforseoClientMock, cacheMock } = vi.hoisted(() => ({ dataforseoClientMock: { aiSearch: { aggregatedMetrics: vi.fn(), topPages: vi.fn(), mentionsSearch: vi.fn(), crossAggregatedMetrics: vi.fn(), }, }, cacheMock: { buildCacheKey: vi.fn(async (_prefix: string, params: unknown) => JSON.stringify(params), ), getCached: vi.fn(), setCached: vi.fn(async () => undefined), }, })); vi.mock("@/server/lib/dataforseo", () => { return { CHATGPT_LANGUAGE_CODE: "en", CHATGPT_LOCATION_CODE: 2840, buildLlmTarget: vi.fn( ({ type, value, includeSubdomains, }: { type: "domain" | "keyword"; value: string; includeSubdomains?: boolean; }) => type === "domain" ? { domain: value, include_subdomains: includeSubdomains ?? true } : { keyword: value }, ), createDataforseoClient: vi.fn(() => dataforseoClientMock), }; }); vi.mock("@/server/lib/r2-cache", () => cacheMock); import { getBrandLookup } from "./brandLookup"; import { shapeResult, type ShapeArgs } from "./brandLookupShaping"; import { resolveCompetitorGroups } from "./shareOfVoice"; import { brandLookupSearchSchema } from "@/types/schemas/ai-search"; import type { LlmMentionItem, LlmTopPagesItem, } from "@/server/lib/dataforseoLlmSchemas"; import type { BillingCustomerContext } from "@/server/billing/subscription"; const billingCustomer: BillingCustomerContext = { organizationId: "org_123", userId: "user_123", userEmail: "alice@example.com", }; type PlatformBundle = { aggregated: { platform?: Array> | null }; topPages: LlmTopPagesItem[]; mentions: LlmMentionItem[]; complete: boolean; }; function platformBundle( platform: "chat_gpt" | "google", mentions: number | null, aiSearchVolume: number | null, ): ShapeArgs["platformBundles"][number] { return { platform, status: "success", bundle: { aggregated: { platform: [ { key: platform, mentions, ai_search_volume: aiSearchVolume, // Deprecated field still present in upstream payloads; must be // ignored end-to-end. impressions: 999, }, ], }, topPages: [], mentions: [], complete: true, } as PlatformBundle, }; } function baseArgs(overrides: Partial): ShapeArgs { return { query: "acme", detected: { type: "keyword", value: "acme" }, researchTarget: null, platformBundles: [ platformBundle("chat_gpt", 10, 100), platformBundle("google", 5, 50), ], crossOutcomes: [], competitorKeys: [], userLocationCode: 2840, userLanguageCode: "en", ...overrides, }; } function resetBrandLookupMocks(): void { vi.clearAllMocks(); cacheMock.getCached.mockResolvedValue(null); cacheMock.setCached.mockResolvedValue(undefined); dataforseoClientMock.aiSearch.aggregatedMetrics.mockResolvedValue({ platform: [{ key: "google", mentions: 5, ai_search_volume: 50 }], }); dataforseoClientMock.aiSearch.topPages.mockImplementation( async ({ platform }: { platform: "chat_gpt" | "google" }) => [ topPage(`https://${platform}.example/source`, platform, 3, 300), ], ); dataforseoClientMock.aiSearch.mentionsSearch.mockImplementation( async ({ platform }: { platform: "chat_gpt" | "google" }) => [ citedMention("best source", 100, [`https://${platform}.example/source`]), ], ); dataforseoClientMock.aiSearch.crossAggregatedMetrics.mockResolvedValue([]); } describe("getBrandLookup", () => { it("fetches top_pages as part of the base lookup", async () => { resetBrandLookupMocks(); await getBrandLookup( { projectId: "project_123", query: "acme.com", competitors: [], locationCode: 2840, languageCode: "en", }, billingCustomer, ); expect(dataforseoClientMock.aiSearch.topPages).toHaveBeenCalledTimes(2); expect(dataforseoClientMock.aiSearch.topPages).toHaveBeenCalledWith( expect.objectContaining({ platform: "chat_gpt", itemsListLimit: 10, }), ); expect(dataforseoClientMock.aiSearch.topPages).toHaveBeenCalledWith( expect.objectContaining({ platform: "google", itemsListLimit: 10, }), ); expect(cacheMock.setCached).toHaveBeenCalledTimes(1); }); it("does not cache a renderable partial result when top_pages fails", async () => { resetBrandLookupMocks(); const consoleError = vi .spyOn(console, "error") .mockImplementation(() => undefined); dataforseoClientMock.aiSearch.topPages.mockRejectedValueOnce( new Error("top pages failed"), ); const result = await getBrandLookup( { projectId: "project_123", query: "acme.com", competitors: [], locationCode: 2840, languageCode: "en", }, billingCustomer, ); expect(result.hasData).toBe(true); expect(cacheMock.setCached).not.toHaveBeenCalled(); consoleError.mockRestore(); }); it("uses semantic cache keys and reapplies the current display query", async () => { resetBrandLookupMocks(); cacheMock.getCached.mockResolvedValueOnce({ ...shapeResult(baseArgs({})), query: "Nike", resolvedTarget: "Nike", }); const result = await getBrandLookup( { projectId: "project_123", query: "nike", competitors: ["ADIDAS"], locationCode: 2840, languageCode: "en", }, billingCustomer, ); expect(result.query).toBe("nike"); expect(cacheMock.buildCacheKey).toHaveBeenCalledWith( "ai-search:brand-lookup", expect.objectContaining({ targetValue: "nike", competitors: "adidas", }), ); expect( dataforseoClientMock.aiSearch.aggregatedMetrics, ).not.toHaveBeenCalled(); }); it("drops subdomains and keys the cache on scope for a URL query", async () => { resetBrandLookupMocks(); const result = await getBrandLookup( { projectId: "project_123", query: "https://acme.com/blog", competitors: [], locationCode: 2840, languageCode: "en", }, billingCustomer, ); // No URL-level targeting exists upstream: the call is domain-only with // subdomains excluded, and page rows are filtered in shaping. expect( dataforseoClientMock.aiSearch.aggregatedMetrics, ).toHaveBeenCalledWith( expect.objectContaining({ target: { domain: "acme.com", include_subdomains: false }, }), ); expect(cacheMock.buildCacheKey).toHaveBeenCalledWith( "ai-search:brand-lookup", expect.objectContaining({ scope: "subfolder", path: "/blog" }), ); expect(result.resolvedTarget).toBe("acme.com/blog"); expect(result.aggregatesAreDomainLevel).toBe(true); }); }); describe("resolveCompetitorGroups", () => { it("dedupes case-insensitively and drops target collisions", () => { // DataForSEO matches keyword targets case-insensitively, so "Nike" and // "nike" would be two paid groups returning identical counts. const groups = resolveCompetitorGroups("Nike", [ "nike", "Adidas", "ADIDAS", "puma.com", "www.PUMA.com", ]); expect(groups.map((g) => g.label)).toEqual(["Adidas", "puma.com"]); }); }); describe("brandLookupSearchSchema — `c` competitor param", () => { it("parses a raw comma-separated string from the URL", () => { expect(brandLookupSearchSchema.parse({ c: "nike, adidas" }).c).toEqual([ "nike", "adidas", ]); }); it("accepts an already-parsed array (TanStack re-validates its own output)", () => { // navigate() feeds the previous transformed output (a string[]) back through // validateSearch — this must not throw "expected string, received array". expect(brandLookupSearchSchema.parse({ c: ["nike", "adidas"] }).c).toEqual([ "nike", "adidas", ]); }); it("dedupes and caps at 5 regardless of input form", () => { const many = ["a", "a", "b", "c", "d", "e", "f"]; expect(brandLookupSearchSchema.parse({ c: many }).c).toEqual([ "a", "b", "c", "d", "e", ]); }); it("leaves `c` undefined when absent", () => { expect(brandLookupSearchSchema.parse({}).c).toBeUndefined(); }); }); function citedMention( question: string, aiSearchVolume: number | null, urls: string[], ): LlmMentionItem { return { question, ai_search_volume: aiSearchVolume, sources: urls.map((url) => ({ url })), }; } function topPage( url: string, platform: "chat_gpt" | "google", mentions: number | null, aiSearchVolume: number | null, ): LlmTopPagesItem { return { key: url, platform: [{ key: platform, mentions, ai_search_volume: aiSearchVolume }], }; }