233 lines
6.6 KiB
TypeScript
233 lines
6.6 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { AppError } from "@/server/lib/errors";
|
|
|
|
vi.mock("@/server/lib/runtime-env", () => ({
|
|
getRequiredEnvValue: vi.fn(async () => "test-api-key"),
|
|
}));
|
|
|
|
const { classifyBacklinksError } = vi.hoisted(() => ({
|
|
classifyBacklinksError: vi.fn(),
|
|
}));
|
|
|
|
// The classifier is built inside backlinks.ts via createDataforseoBillingClassifier;
|
|
// returning our hoisted mock lets the test drive classification.
|
|
vi.mock("@/server/lib/dataforseoBillingClassification", () => ({
|
|
createDataforseoBillingClassifier: () => classifyBacklinksError,
|
|
}));
|
|
|
|
import {
|
|
fetchBacklinksHistory,
|
|
fetchBacklinksRows,
|
|
fetchBacklinksSummary,
|
|
} from "@/server/lib/dataforseo/backlinks";
|
|
import { normalizeBacklinksTarget } from "@/server/lib/dataforseoBacklinksTarget";
|
|
|
|
// A successful DataForSEO task always carries billing metadata (path + cost).
|
|
const billed = {
|
|
path: ["v3", "backlinks", "summary", "live"],
|
|
cost: 0.02,
|
|
result_count: 0,
|
|
};
|
|
|
|
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("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("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"),
|
|
);
|
|
});
|
|
|
|
it("rejects hostnames with unrecognized public suffixes before provider calls", () => {
|
|
expectValidationError(() => normalizeBacklinksTarget("example.invalidtld"));
|
|
});
|
|
});
|
|
|
|
describe("fetchBacklinksSummary", () => {
|
|
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: 40200,
|
|
status_message: "Account balance is too low",
|
|
tasks: [],
|
|
}),
|
|
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
),
|
|
);
|
|
classifyBacklinksError.mockImplementation((status: number | undefined) => {
|
|
if (status === 40200) {
|
|
return new AppError(
|
|
"BACKLINKS_BILLING_ISSUE",
|
|
"The connected DataForSEO account has a billing or balance issue",
|
|
);
|
|
}
|
|
return null;
|
|
});
|
|
|
|
await expect(
|
|
fetchBacklinksSummary({ target: "example.com" }),
|
|
).rejects.toMatchObject({ code: "BACKLINKS_BILLING_ISSUE" });
|
|
|
|
expect(classifyBacklinksError).toHaveBeenCalledWith(
|
|
40200,
|
|
expect.stringContaining("Account balance is too low"),
|
|
"/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.",
|
|
...billed,
|
|
result: [null],
|
|
},
|
|
],
|
|
}),
|
|
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
),
|
|
);
|
|
classifyBacklinksError.mockReturnValue(null);
|
|
|
|
await expect(
|
|
fetchBacklinksSummary({ 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.",
|
|
...billed,
|
|
result: [],
|
|
},
|
|
],
|
|
}),
|
|
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
),
|
|
);
|
|
classifyBacklinksError.mockReturnValue(null);
|
|
|
|
await expect(
|
|
fetchBacklinksSummary({ target: "example.com" }),
|
|
).resolves.toMatchObject({ data: {} });
|
|
});
|
|
|
|
it("treats empty backlinks rows and history results as valid empty arrays", async () => {
|
|
const emptyOk = () =>
|
|
new Response(
|
|
JSON.stringify({
|
|
status_code: 20000,
|
|
status_message: "Ok.",
|
|
tasks: [
|
|
{
|
|
status_code: 20000,
|
|
status_message: "Ok.",
|
|
...billed,
|
|
result: [],
|
|
},
|
|
],
|
|
}),
|
|
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
);
|
|
vi.mocked(fetch)
|
|
.mockResolvedValueOnce(emptyOk())
|
|
.mockResolvedValueOnce(emptyOk());
|
|
classifyBacklinksError.mockReturnValue(null);
|
|
|
|
await expect(
|
|
fetchBacklinksRows({ target: "example.com" }),
|
|
).resolves.toMatchObject({ data: { items: [], totalCount: null } });
|
|
await expect(
|
|
fetchBacklinksHistory({
|
|
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");
|
|
}
|