208 lines
5.6 KiB
TypeScript
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",
|
|
);
|
|
});
|
|
});
|