diff --git a/src/server/lib/dataforseoBacklinks.test.ts b/src/server/lib/dataforseoBacklinks.test.ts index 4b90e00..fd31f60 100644 --- a/src/server/lib/dataforseoBacklinks.test.ts +++ b/src/server/lib/dataforseoBacklinks.test.ts @@ -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: [] }); }); }); diff --git a/src/server/lib/dataforseoBacklinks.ts b/src/server/lib/dataforseoBacklinks.ts index 96630c0..3710704 100644 --- a/src/server/lib/dataforseoBacklinks.ts +++ b/src/server/lib/dataforseoBacklinks.ts @@ -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, diff --git a/src/server/lib/dataforseoBacklinksSupport.ts b/src/server/lib/dataforseoBacklinksSupport.ts index a6fe15e..5e323a4 100644 --- a/src/server/lib/dataforseoBacklinksSupport.ts +++ b/src/server/lib/dataforseoBacklinksSupport.ts @@ -210,8 +210,7 @@ export function parseItems( ): Array> { 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( return parsed.data; } - -export function parseFirstResult( - endpointName: string, - results: BacklinksTaskResult[], - resultSchema: T, -): z.infer { - 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; -}