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 { import {
fetchBacklinksHistoryRaw,
fetchBacklinksRowsRaw,
fetchBacklinksSummaryRaw, fetchBacklinksSummaryRaw,
normalizeBacklinksTarget, normalizeBacklinksTarget,
} from "@/server/lib/dataforseoBacklinks"; } 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( vi.mocked(fetch).mockResolvedValue(
new Response( new Response(
JSON.stringify({ JSON.stringify({
@ -168,7 +170,83 @@ describe("fetchBacklinksSummaryRaw", () => {
fetchBacklinksSummaryRaw({ fetchBacklinksSummaryRaw({
target: "not-a-real-input.example", 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, backlinksItemSchema,
backlinksSummaryItemSchema, backlinksSummaryItemSchema,
domainPageSummaryItemSchema, domainPageSummaryItemSchema,
parseFirstResult,
parseItems, parseItems,
referringDomainItemSchema, referringDomainItemSchema,
responseSchema, responseSchema,
@ -179,11 +178,21 @@ export async function fetchBacklinksSummaryRaw(input: BacklinksRequest) {
const response = await postBacklinks("/v3/backlinks/summary/live", [ const response = await postBacklinks("/v3/backlinks/summary/live", [
buildCommonPayload(input), buildCommonPayload(input),
]); ]);
const data = parseFirstResult( const firstResult = response.results[0];
"backlinks-summary-live", const parsed = firstResult
response.results, ? backlinksSummaryItemSchema.safeParse(firstResult)
backlinksSummaryItemSchema, : 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 { return {
data, data,
billing: response.billing, billing: response.billing,

View File

@ -210,8 +210,7 @@ export function parseItems<T extends z.ZodTypeAny>(
): Array<z.infer<T>> { ): Array<z.infer<T>> {
const firstResult = results[0] ?? null; const firstResult = results[0] ?? null;
if (firstResult == null) { if (firstResult == null) {
console.warn(`dataforseo.${endpointName}.empty-result`); return [];
throw new AppError("VALIDATION_ERROR", "Backlinks target is invalid");
} }
const parsed = z.array(itemSchema).safeParse(firstResult.items ?? []); const parsed = z.array(itemSchema).safeParse(firstResult.items ?? []);
@ -228,29 +227,3 @@ export function parseItems<T extends z.ZodTypeAny>(
return parsed.data; 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;
}