326 lines
8.9 KiB
TypeScript
326 lines
8.9 KiB
TypeScript
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<Record<string, unknown>> | 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>): 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 }],
|
|
};
|
|
}
|