metatron-open-seo/src/server/lib/dataforseoBacklinks.test.ts

267 lines
7.2 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AppError } from "@/server/lib/errors";
import type * as DataforseoBacklinksSupport from "@/server/lib/dataforseoBacklinksSupport";
vi.mock("@/server/lib/runtime-env", () => ({
getRequiredEnvValue: vi.fn(async () => "test-api-key"),
}));
const { classifyBacklinksError } = vi.hoisted(() => ({
classifyBacklinksError: vi.fn(),
}));
vi.mock("@/server/lib/dataforseoBacklinksSupport", async () => {
const actual = await vi.importActual<typeof DataforseoBacklinksSupport>(
"@/server/lib/dataforseoBacklinksSupport",
);
return { ...actual, classifyBacklinksError };
});
import {
fetchBacklinksHistoryRaw,
fetchBacklinksRowsRaw,
fetchBacklinksSummaryRaw,
normalizeBacklinksTarget,
} from "@/server/lib/dataforseoBacklinks";
describe("normalizeBacklinksTarget", () => {
it("treats explicit homepage URLs as page lookups", () => {
expect(normalizeBacklinksTarget("https://Example.com/")).toEqual({
apiTarget: "https://example.com/",
displayTarget: "https://example.com/",
scope: "page",
});
});
it("trims trailing slashes from non-root page URLs", () => {
expect(
normalizeBacklinksTarget("https://github.com/every-app/open-seo/"),
).toEqual({
apiTarget: "https://github.com/every-app/open-seo",
displayTarget: "https://github.com/every-app/open-seo",
scope: "page",
});
});
it("keeps trailing slashes for root page URLs", () => {
expect(normalizeBacklinksTarget("https://example.com/")).toEqual({
apiTarget: "https://example.com/",
displayTarget: "https://example.com/",
scope: "page",
});
});
it("treats bare hostnames as domain lookups", () => {
expect(normalizeBacklinksTarget("Example.com")).toEqual({
apiTarget: "example.com",
displayTarget: "example.com",
scope: "domain",
});
});
it("lets callers force domain scope for full URLs", () => {
expect(
normalizeBacklinksTarget("https://Example.com/pricing", {
scope: "domain",
}),
).toEqual({
apiTarget: "example.com",
displayTarget: "example.com",
scope: "domain",
});
});
it("normalizes domain scope for URLs with query strings or fragments", () => {
expect(
normalizeBacklinksTarget(
"https://Example.com/pricing?utm_source=newsletter#hero",
{
scope: "domain",
},
),
).toEqual({
apiTarget: "example.com",
displayTarget: "example.com",
scope: "domain",
});
});
it("lets callers force page scope for bare hostnames", () => {
expect(normalizeBacklinksTarget("Example.com", { scope: "page" })).toEqual({
apiTarget: "https://example.com/",
displayTarget: "https://example.com/",
scope: "page",
});
});
it("rejects page targets with query strings or fragments", () => {
expectValidationError(() =>
normalizeBacklinksTarget("https://example.com/pricing?token=secret#hero"),
);
});
it("rejects page targets with embedded credentials", () => {
expectValidationError(() =>
normalizeBacklinksTarget("https://user:pass@example.com/private"),
);
});
});
describe("fetchBacklinksSummaryRaw", () => {
beforeEach(() => {
vi.stubGlobal("fetch", vi.fn());
});
afterEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
});
it("classifies top-level DataForSEO body errors using status_code", async () => {
vi.mocked(fetch).mockResolvedValue(
new Response(
JSON.stringify({
status_code: 40204,
status_message: "Backlinks subscription required",
tasks: [],
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
),
);
classifyBacklinksError.mockImplementation((status: number | undefined) => {
if (status === 40204) {
return new AppError(
"BACKLINKS_NOT_ENABLED",
"Backlinks is not enabled",
);
}
return null;
});
await expect(
fetchBacklinksSummaryRaw({
target: "example.com",
}),
).rejects.toMatchObject({ code: "BACKLINKS_NOT_ENABLED" });
expect(classifyBacklinksError).toHaveBeenCalledWith(
40204,
expect.stringContaining("Backlinks subscription required"),
"/v3/backlinks/summary/live",
);
});
it("treats null summary results as a valid zero-data response", async () => {
vi.mocked(fetch).mockResolvedValue(
new Response(
JSON.stringify({
status_code: 20000,
status_message: "Ok.",
tasks: [
{
status_code: 20000,
status_message: "Ok.",
result: [null],
},
],
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
),
);
classifyBacklinksError.mockReturnValue(null);
await expect(
fetchBacklinksSummaryRaw({
target: "not-a-real-input.example",
}),
).resolves.toMatchObject({ data: {} });
});
it("treats empty summary results as a valid zero-data response", async () => {
vi.mocked(fetch).mockResolvedValue(
new Response(
JSON.stringify({
status_code: 20000,
status_message: "Ok.",
tasks: [
{
status_code: 20000,
status_message: "Ok.",
result: [],
},
],
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
),
);
classifyBacklinksError.mockReturnValue(null);
await expect(
fetchBacklinksSummaryRaw({
target: "example.com",
}),
).resolves.toMatchObject({ data: {} });
});
it("treats empty backlinks rows and history results as valid empty arrays", async () => {
vi.mocked(fetch)
.mockResolvedValueOnce(
new Response(
JSON.stringify({
status_code: 20000,
status_message: "Ok.",
tasks: [
{
status_code: 20000,
status_message: "Ok.",
result: [],
},
],
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
),
)
.mockResolvedValueOnce(
new Response(
JSON.stringify({
status_code: 20000,
status_message: "Ok.",
tasks: [
{
status_code: 20000,
status_message: "Ok.",
result: [],
},
],
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
),
);
classifyBacklinksError.mockReturnValue(null);
await expect(
fetchBacklinksRowsRaw({
target: "example.com",
}),
).resolves.toMatchObject({ data: [] });
await expect(
fetchBacklinksHistoryRaw({
target: "example.com",
dateFrom: "2025-01-01",
dateTo: "2025-12-31",
}),
).resolves.toMatchObject({ data: [] });
});
});
function expectValidationError(fn: () => unknown) {
try {
fn();
} catch (error) {
expect(error).toMatchObject({ code: "VALIDATION_ERROR" });
return;
}
throw new Error("Expected normalizeBacklinksTarget to throw");
}