fix: treat empty DataForSEO backlinks results as valid empty data (#109)

This commit is contained in:
Ben Senescu 2026-04-09 16:44:34 -04:00 committed by GitHub
parent 53505c0a5b
commit fb1291537c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 96 additions and 36 deletions

View File

@ -10,6 +10,8 @@ vi.mock("@/server/lib/dataforseoBacklinksAccount", () => ({
}));
import {
fetchBacklinksHistoryRaw,
fetchBacklinksRowsRaw,
fetchBacklinksSummaryRaw,
normalizeBacklinksTarget,
} from "@/server/lib/dataforseoBacklinks";
@ -145,7 +147,7 @@ describe("fetchBacklinksSummaryRaw", () => {
);
});
it("treats null summary results as validation errors", async () => {
it("treats null summary results as a valid zero-data response", async () => {
vi.mocked(fetch).mockResolvedValue(
new Response(
JSON.stringify({
@ -168,7 +170,83 @@ describe("fetchBacklinksSummaryRaw", () => {
fetchBacklinksSummaryRaw({
target: "not-a-real-input.example",
}),
).rejects.toMatchObject({ code: "VALIDATION_ERROR" });
).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" } },
),
);
vi.mocked(classifyBacklinksErrorWithAccountState).mockResolvedValue(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" } },
),
);
vi.mocked(classifyBacklinksErrorWithAccountState).mockResolvedValue(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: [] });
});
});

View File

@ -14,7 +14,6 @@ import {
backlinksItemSchema,
backlinksSummaryItemSchema,
domainPageSummaryItemSchema,
parseFirstResult,
parseItems,
referringDomainItemSchema,
responseSchema,
@ -179,11 +178,21 @@ export async function fetchBacklinksSummaryRaw(input: BacklinksRequest) {
const response = await postBacklinks("/v3/backlinks/summary/live", [
buildCommonPayload(input),
]);
const data = parseFirstResult(
"backlinks-summary-live",
response.results,
backlinksSummaryItemSchema,
const firstResult = response.results[0];
const parsed = firstResult
? backlinksSummaryItemSchema.safeParse(firstResult)
: null;
if (parsed && !parsed.success) {
console.error(
"dataforseo.backlinks-summary-live.invalid-result",
parsed.error.issues.slice(0, 5),
);
throw new AppError(
"INTERNAL_ERROR",
"DataForSEO backlinks-summary-live returned an invalid response shape",
);
}
const data = parsed?.data ?? {};
return {
data,
billing: response.billing,

View File

@ -210,8 +210,7 @@ export function parseItems<T extends z.ZodTypeAny>(
): Array<z.infer<T>> {
const firstResult = results[0] ?? null;
if (firstResult == null) {
console.warn(`dataforseo.${endpointName}.empty-result`);
throw new AppError("VALIDATION_ERROR", "Backlinks target is invalid");
return [];
}
const parsed = z.array(itemSchema).safeParse(firstResult.items ?? []);
@ -228,29 +227,3 @@ export function parseItems<T extends z.ZodTypeAny>(
return parsed.data;
}
export function parseFirstResult<T extends z.ZodTypeAny>(
endpointName: string,
results: BacklinksTaskResult[],
resultSchema: T,
): z.infer<T> {
const firstResult = results[0] ?? null;
if (firstResult == null) {
console.warn(`dataforseo.${endpointName}.empty-result`);
throw new AppError("VALIDATION_ERROR", "Backlinks target is invalid");
}
const parsed = resultSchema.safeParse(firstResult);
if (!parsed.success) {
console.error(
`dataforseo.${endpointName}.invalid-result`,
parsed.error.issues.slice(0, 5),
);
throw new AppError(
"INTERNAL_ERROR",
`DataForSEO ${endpointName} returned an invalid response shape`,
);
}
return parsed.data;
}