385 lines
11 KiB
TypeScript
385 lines
11 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { z } from "zod";
|
|
import type { fetchKeywordMetricsForList as FetchKeywordMetricsForList } from "@/server/lib/dataforseo/keyword-metrics";
|
|
import * as researchTools from "./dataforseo-research-tools";
|
|
import { makeToolExtra, textContent } from "./tool-test-support";
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
createDataforseoClient: vi.fn(),
|
|
getProjectForOrganization: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("cloudflare:workers", () => ({
|
|
env: {},
|
|
}));
|
|
|
|
// Keep the real fetchKeywordMetricsForList (it only routes provider calls onto
|
|
// the supplied client) so the handler's normalization is exercised end-to-end.
|
|
vi.mock("@/server/lib/dataforseo", async () => {
|
|
const keywordMetrics = await vi.importActual<{
|
|
fetchKeywordMetricsForList: typeof FetchKeywordMetricsForList;
|
|
}>("@/server/lib/dataforseo/keyword-metrics");
|
|
return {
|
|
createDataforseoClient: mocks.createDataforseoClient,
|
|
fetchKeywordMetricsForList: keywordMetrics.fetchKeywordMetricsForList,
|
|
};
|
|
});
|
|
|
|
vi.mock("@/server/features/projects/services/ProjectService", () => ({
|
|
ProjectService: {
|
|
getProjectForOrganization: mocks.getProjectForOrganization,
|
|
},
|
|
}));
|
|
|
|
const toolExtra = makeToolExtra();
|
|
|
|
const usProjectRow = {
|
|
id: "project_1",
|
|
locationCode: 2840,
|
|
languageCode: "en",
|
|
};
|
|
|
|
describe("DataForSEO research MCP tools", () => {
|
|
beforeEach(() => {
|
|
mocks.getProjectForOrganization.mockResolvedValue(usProjectRow);
|
|
});
|
|
|
|
it("searches local businesses without running rankings or Q&A", async () => {
|
|
const businessListings = vi
|
|
.fn()
|
|
.mockResolvedValue([
|
|
{ title: "Acme Cafe", url: "https://acme-cafe.example" },
|
|
]);
|
|
const local = vi.fn();
|
|
const questionsAnswers = vi.fn();
|
|
|
|
mocks.createDataforseoClient.mockReturnValue({
|
|
business: { businessListings, questionsAnswers },
|
|
serp: { local },
|
|
});
|
|
const { searchLocalBusinessesTool } = researchTools;
|
|
|
|
const result = await searchLocalBusinessesTool.handler(
|
|
{
|
|
projectId: "project_1",
|
|
query: "Acme Cafe",
|
|
near: {
|
|
latitude: 33.123456789,
|
|
longitude: -84.987654321,
|
|
radiusKm: 5,
|
|
},
|
|
categories: ["cafe"],
|
|
},
|
|
toolExtra,
|
|
);
|
|
|
|
expect(businessListings).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
locationCoordinate: "33.1234568,-84.9876543,5",
|
|
categories: ["cafe"],
|
|
}),
|
|
);
|
|
expect(local).not.toHaveBeenCalled();
|
|
expect(questionsAnswers).not.toHaveBeenCalled();
|
|
|
|
const content = z
|
|
.object({ businesses: z.array(z.object({ title: z.string() })) })
|
|
.passthrough()
|
|
.parse(result.structuredContent);
|
|
expect(content.businesses).toEqual([{ title: "Acme Cafe" }]);
|
|
expect(textContent(result)).toContain("title | category");
|
|
expect(textContent(result)).toContain("Acme Cafe");
|
|
});
|
|
|
|
it("fetches one local SERP with search_places disabled", async () => {
|
|
const local = vi.fn().mockResolvedValue([
|
|
{
|
|
type: "maps_search",
|
|
title: "Acme Cafe",
|
|
rank_group: 1,
|
|
rank_absolute: 2,
|
|
},
|
|
]);
|
|
|
|
mocks.createDataforseoClient.mockReturnValue({
|
|
serp: { local },
|
|
});
|
|
const { getLocalSerpResultsTool } = researchTools;
|
|
|
|
const result = await getLocalSerpResultsTool.handler(
|
|
{
|
|
projectId: "project_1",
|
|
keyword: "coffee",
|
|
near: {
|
|
latitude: 33.123456789,
|
|
longitude: -84.987654321,
|
|
zoom: 14,
|
|
},
|
|
},
|
|
toolExtra,
|
|
);
|
|
|
|
expect(local).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
locationCoordinate: "33.1234568,-84.9876543,14z",
|
|
searchPlaces: false,
|
|
searchType: "maps",
|
|
device: "desktop",
|
|
}),
|
|
);
|
|
|
|
const content = z
|
|
.object({
|
|
results: z.array(
|
|
z.object({ rank_group: z.number(), rank_absolute: z.number() }),
|
|
),
|
|
})
|
|
.passthrough()
|
|
.parse(result.structuredContent);
|
|
expect(content.results[0]).toMatchObject({
|
|
rank_group: 1,
|
|
rank_absolute: 2,
|
|
});
|
|
expect(textContent(result)).toContain("rank | title | rating");
|
|
expect(textContent(result)).toContain("Acme Cafe");
|
|
});
|
|
|
|
it("fetches Google Business Q&A as an explicit tool", async () => {
|
|
const questionsAnswers = vi
|
|
.fn()
|
|
.mockResolvedValue([{ question_text: "Do you serve breakfast?" }]);
|
|
|
|
mocks.createDataforseoClient.mockReturnValue({
|
|
business: { questionsAnswers },
|
|
});
|
|
const { getGoogleBusinessQuestionsTool } = researchTools;
|
|
|
|
const result = await getGoogleBusinessQuestionsTool.handler(
|
|
{
|
|
projectId: "project_1",
|
|
keyword: "Acme Cafe",
|
|
near: {
|
|
latitude: 33.123456789,
|
|
longitude: -84.987654321,
|
|
radiusKm: 5,
|
|
},
|
|
},
|
|
toolExtra,
|
|
);
|
|
|
|
expect(questionsAnswers).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
keyword: "Acme Cafe",
|
|
locationCoordinate: "33.1234568,-84.9876543,5000",
|
|
}),
|
|
);
|
|
const content = z
|
|
.object({ questions: z.array(z.object({ question_text: z.string() })) })
|
|
.passthrough()
|
|
.parse(result.structuredContent);
|
|
expect(content.questions).toEqual([
|
|
{ question_text: "Do you serve breakfast?" },
|
|
]);
|
|
expect(textContent(result)).toContain("question | asked by");
|
|
expect(textContent(result)).toContain("Do you serve breakfast?");
|
|
});
|
|
|
|
it("passes only explicit brand exclusions to ranked keyword filters", async () => {
|
|
const rankedKeywords = vi.fn().mockResolvedValue({
|
|
items: [],
|
|
totalCount: 0,
|
|
});
|
|
|
|
mocks.createDataforseoClient.mockReturnValue({
|
|
domain: { rankedKeywords },
|
|
});
|
|
const { getRankedKeywordsTool } = researchTools;
|
|
|
|
await getRankedKeywordsTool.handler(
|
|
{
|
|
projectId: "project_1",
|
|
target: "acmeexample.com",
|
|
excludeBrandTerms: ["acme"],
|
|
},
|
|
toolExtra,
|
|
);
|
|
|
|
expect(rankedKeywords).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
filters: [["keyword_data.keyword", "not_ilike", "%acme%"]],
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("filters SERP competitors only by explicit excluded domains", async () => {
|
|
const serpCompetitors = vi.fn().mockResolvedValue([
|
|
{ domain: "directory.example", visibility: 10 },
|
|
{ domain: "competitor.example", visibility: 5 },
|
|
]);
|
|
|
|
mocks.createDataforseoClient.mockReturnValue({
|
|
labs: { serpCompetitors },
|
|
});
|
|
const { findSerpCompetitorsTool } = researchTools;
|
|
|
|
const result = await findSerpCompetitorsTool.handler(
|
|
{
|
|
projectId: "project_1",
|
|
keywords: ["coffee"],
|
|
excludeDomains: ["directory.example"],
|
|
},
|
|
toolExtra,
|
|
);
|
|
|
|
const content = z
|
|
.object({ competitors: z.array(z.object({ domain: z.string() })) })
|
|
.passthrough()
|
|
.parse(result.structuredContent);
|
|
expect(content.competitors.map((row) => row.domain)).toEqual([
|
|
"competitor.example",
|
|
]);
|
|
expect(textContent(result)).toContain("domain | keywords | avg pos");
|
|
expect(textContent(result)).toContain("competitor.example");
|
|
});
|
|
|
|
it("keeps AI overview result types out of SERP competitors", async () => {
|
|
const { findSerpCompetitorsTool, getRankedKeywordsTool } = researchTools;
|
|
|
|
expect(
|
|
getRankedKeywordsTool.config.inputSchema.resultTypes.safeParse([
|
|
"ai_overview_reference",
|
|
]).success,
|
|
).toBe(true);
|
|
expect(
|
|
findSerpCompetitorsTool.config.inputSchema.resultTypes.safeParse([
|
|
"ai_overview_reference",
|
|
]).success,
|
|
).toBe(false);
|
|
expect(
|
|
findSerpCompetitorsTool.config.inputSchema.resultTypes.safeParse([
|
|
"organic",
|
|
"local_pack",
|
|
]).success,
|
|
).toBe(true);
|
|
});
|
|
|
|
it("normalizes keyword_overview rows with difficulty and intent", async () => {
|
|
const keywordOverview = vi.fn().mockResolvedValue([
|
|
{
|
|
keyword: "seo automation",
|
|
keyword_info: {
|
|
search_volume: 2400,
|
|
cpc: 25.6,
|
|
competition: 0.24,
|
|
competition_level: "LOW",
|
|
},
|
|
keyword_properties: { keyword_difficulty: 18 },
|
|
search_intent_info: { main_intent: "commercial" },
|
|
},
|
|
]);
|
|
|
|
mocks.createDataforseoClient.mockReturnValue({
|
|
labs: { keywordOverview },
|
|
});
|
|
const { getKeywordMetricsTool } = researchTools;
|
|
|
|
const result = await getKeywordMetricsTool.handler(
|
|
{ projectId: "project_1", keywords: ["seo automation"] },
|
|
toolExtra,
|
|
);
|
|
|
|
expect(keywordOverview).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
keywords: ["seo automation"],
|
|
locationCode: 2840,
|
|
languageCode: "en",
|
|
creditFeature: "keyword_research",
|
|
}),
|
|
);
|
|
const rows = z
|
|
.object({
|
|
keywords: z.array(
|
|
z
|
|
.object({
|
|
keyword: z.string(),
|
|
search_volume: z.number().nullable(),
|
|
keyword_difficulty: z.number().nullable(),
|
|
main_intent: z.string().nullable(),
|
|
})
|
|
.passthrough(),
|
|
),
|
|
})
|
|
.passthrough()
|
|
.parse(result.structuredContent).keywords;
|
|
expect(rows[0]).toMatchObject({
|
|
keyword: "seo automation",
|
|
search_volume: 2400,
|
|
keyword_difficulty: 18,
|
|
main_intent: "commercial",
|
|
});
|
|
const out = textContent(result);
|
|
expect(out).toContain("keyword | volume | KD | CPC | competition | intent");
|
|
expect(out).toContain("seo automation");
|
|
});
|
|
|
|
it("sorts keyword metric rows by the requested numeric field", async () => {
|
|
const keywordOverview = vi.fn().mockResolvedValue([
|
|
{ keyword: "low", keyword_info: { search_volume: 10 } },
|
|
{ keyword: "high", keyword_info: { search_volume: 90 } },
|
|
{ keyword: "medium", keyword_info: { search_volume: 50 } },
|
|
]);
|
|
|
|
mocks.createDataforseoClient.mockReturnValue({
|
|
labs: { keywordOverview },
|
|
});
|
|
const { getKeywordMetricsTool } = researchTools;
|
|
|
|
const result = await getKeywordMetricsTool.handler(
|
|
{
|
|
projectId: "project_1",
|
|
keywords: ["low", "high", "medium"],
|
|
sortBy: "search_volume",
|
|
},
|
|
toolExtra,
|
|
);
|
|
|
|
const rows = z
|
|
.object({ keywords: z.array(z.object({ keyword: z.string() })) })
|
|
.passthrough()
|
|
.parse(result.structuredContent).keywords;
|
|
expect(rows.map((row) => row.keyword)).toEqual(["high", "medium", "low"]);
|
|
});
|
|
|
|
it("drops monthly trends when includeMonthlyTrends is false", async () => {
|
|
const keywordOverview = vi.fn().mockResolvedValue([
|
|
{
|
|
keyword: "seo",
|
|
keyword_info: {
|
|
search_volume: 100,
|
|
monthly_searches: [{ year: 2026, month: 1, search_volume: 100 }],
|
|
},
|
|
},
|
|
]);
|
|
|
|
mocks.createDataforseoClient.mockReturnValue({
|
|
labs: { keywordOverview },
|
|
});
|
|
const { getKeywordMetricsTool } = researchTools;
|
|
|
|
const result = await getKeywordMetricsTool.handler(
|
|
{
|
|
projectId: "project_1",
|
|
keywords: ["seo"],
|
|
includeMonthlyTrends: false,
|
|
},
|
|
toolExtra,
|
|
);
|
|
|
|
const rows = z
|
|
.object({ keywords: z.array(z.record(z.string(), z.unknown())) })
|
|
.passthrough()
|
|
.parse(result.structuredContent).keywords;
|
|
expect(rows[0]).not.toHaveProperty("monthly_searches");
|
|
});
|
|
});
|