fix: treat empty DataForSEO backlinks results as valid empty data (#109)
This commit is contained in:
parent
53505c0a5b
commit
fb1291537c
@ -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: [] });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user