metatron-open-seo/src/server/mcp/tools/saved-keywords-tools.test.ts

208 lines
5.6 KiB
TypeScript

import { z } from "zod";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { listSavedKeywordsTool } from "./list-saved-keywords";
import { saveKeywordsTool } from "./save-keywords";
import { makeToolContext } from "./tool-test-support";
const mocks = vi.hoisted(() => ({
getProjectForOrganization: vi.fn(),
getSavedKeywords: vi.fn(),
saveKeywords: vi.fn(),
}));
vi.mock("@/server/features/projects/services/ProjectService", () => ({
ProjectService: {
getProjectForOrganization: mocks.getProjectForOrganization,
},
}));
// project-auth imports the repository for user-scoped (API key) credentials;
// unused here (pinned context) but keeps the db out of the module graph.
vi.mock("@/server/auth/repositories/AuthRepository", () => ({
AuthRepository: { getMembership: vi.fn() },
}));
vi.mock("@/server/features/keywords/services/KeywordResearchService", () => ({
KeywordResearchService: {
getSavedKeywords: mocks.getSavedKeywords,
saveKeywords: mocks.saveKeywords,
},
}));
const toolContext = makeToolContext();
describe("saved keyword MCP tools", () => {
beforeEach(() => {
mocks.getProjectForOrganization.mockResolvedValue({
id: "project_1",
locationCode: 2840,
languageCode: "en",
});
});
it("passes tags through save_keywords", async () => {
mocks.saveKeywords.mockResolvedValue({
success: true,
savedKeywordIds: ["saved_1"],
});
const result = await saveKeywordsTool.handler(
{
projectId: "project_1",
keywords: ["technical seo"],
tags: ["Content"],
},
toolContext,
);
expect(mocks.saveKeywords).toHaveBeenCalledWith({
projectId: "project_1",
keywords: ["technical seo"],
tags: ["Content"],
tagMode: "append",
locationCode: 2840,
languageCode: "en",
});
expect(result.structuredContent).toMatchObject({
savedCount: 1,
tags: ["Content"],
tagMode: "append",
});
});
it("accepts and passes keyword metrics through save_keywords", async () => {
mocks.saveKeywords.mockResolvedValue({
success: true,
savedKeywordIds: ["saved_1"],
});
const metrics = [
{
keyword: "technical seo",
searchVolume: 120,
keywordDifficulty: 18,
cpc: 2.5,
competition: 0.42,
intent: "commercial" as const,
monthlySearches: [
{ year: 2026, month: 7, searchVolume: 110 },
{ year: 2026, month: 8, searchVolume: 120 },
],
},
];
const args = z.object(saveKeywordsTool.config.inputSchema).parse({
projectId: "project_1",
keywords: ["technical seo"],
metrics,
});
await saveKeywordsTool.handler(args, toolContext);
expect(mocks.saveKeywords).toHaveBeenCalledWith({
projectId: "project_1",
keywords: ["technical seo"],
metrics,
tagMode: "append",
locationCode: 2840,
languageCode: "en",
});
});
it("replaces tags through save_keywords when requested", async () => {
mocks.saveKeywords.mockResolvedValue({
success: true,
savedKeywordIds: ["saved_1", "saved_2"],
});
const result = await saveKeywordsTool.handler(
{
projectId: "project_1",
keywords: ["semrush alternative", "semrush pricing"],
tags: ["cluster: affordable semrush alternatives"],
tagMode: "replace",
},
toolContext,
);
expect(mocks.saveKeywords).toHaveBeenCalledWith({
projectId: "project_1",
keywords: ["semrush alternative", "semrush pricing"],
tags: ["cluster: affordable semrush alternatives"],
tagMode: "replace",
locationCode: 2840,
languageCode: "en",
});
expect(result.structuredContent).toMatchObject({
savedCount: 2,
tags: ["cluster: affordable semrush alternatives"],
tagMode: "replace",
});
});
it("rejects replace mode without replacement tags before saving", async () => {
await expect(() =>
saveKeywordsTool.handler(
{
projectId: "project_1",
keywords: ["semrush alternative"],
tagMode: "replace",
},
toolContext,
),
).rejects.toThrow("Replacement tags are required");
expect(mocks.saveKeywords).not.toHaveBeenCalled();
});
it("filters list_saved_keywords by search and tag names", async () => {
mocks.getSavedKeywords.mockResolvedValue({
totalCount: 1,
tags: [
{
id: "tag_1",
name: "Content",
normalizedName: "content",
keywordCount: 1,
},
],
rows: [
{
id: "saved_1",
keyword: "technical seo",
searchVolume: 120,
keywordDifficulty: 18,
cpc: 2.5,
tags: [{ id: "tag_1", name: "Content", normalizedName: "content" }],
},
],
});
const result = await listSavedKeywordsTool.handler(
{
projectId: "project_1",
search: "technical",
tags: ["Content"],
limit: 50,
},
toolContext,
);
expect(mocks.getSavedKeywords).toHaveBeenCalledWith({
projectId: "project_1",
search: "technical",
tagNames: ["Content"],
page: 1,
pageSize: 50,
sort: "createdAt",
order: "desc",
});
expect(result.structuredContent).toMatchObject({
totalCount: 1,
rows: [{ keyword: "technical seo" }],
});
const [content] = result.content;
expect(content).toMatchObject({ type: "text" });
expect(content?.type === "text" ? content.text : "").toContain(
"tags:Content",
);
});
});