metatron-open-seo/src/server/mcp/tools/dataforseo-research-tools.test.ts

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");
});
});