Skip to content

Commit be03093

Browse files
committed
test(importer): apiservice pagination
1 parent 4539b52 commit be03093

5 files changed

Lines changed: 210 additions & 58 deletions

File tree

Lines changed: 24 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,30 @@
1-
import {test, expect} from 'vitest';
2-
import {CSVParser} from "$lib/CSVParser";
1+
import { test, expect } from 'vitest';
2+
import { CSVParser } from "$lib/CSVParser";
33

44
test('CSVParser should call column parsers', async () => {
5-
const csvParser = new CSVParser({code: ""});
6-
csvParser.addColumn(/^device code$/g, () => (context, value) => {
7-
context.userData.code = value;
8-
})
9-
const file = 'other,data,notrelevant\ndevice code,device description,device properties\n1,2,3\n'
10-
const result = await csvParser.parse(file)
11-
expect(result.code == "1")
5+
const csvParser = new CSVParser({ code: "" });
6+
csvParser.addColumn(/^device code$/g, () => (context, value) => {
7+
context.userData.code = value;
8+
})
9+
const file = 'other,data,notrelevant\ndevice code,device description,device properties\n1,2,3\n'
10+
const result = await csvParser.parse(file)
11+
expect(result.code == "1")
1212
})
1313

1414
test('CSVParser should pass context to every onRowFinish', async () => {
15-
const csvParser = new CSVParser({code: "", other: "no"});
16-
csvParser.addColumn(/^device code$/g, () => (context, value) => {
17-
context.userData.code = value;
18-
})
19-
let pass = 0
20-
csvParser.afterRowParse = (context) => {
21-
if (pass === 0) {
22-
context.userData.other = "yes"
23-
} else {
24-
expect(context.userData.other === "yes");
25-
}
26-
pass++;
15+
const csvParser = new CSVParser({ code: "", other: "no" });
16+
csvParser.addColumn(/^device code$/g, () => (context, value) => {
17+
context.userData.code = value;
18+
})
19+
let pass = 0
20+
csvParser.afterRowParse = (context) => {
21+
if (pass === 0) {
22+
context.userData.other = "yes"
23+
} else {
24+
expect(context.userData.other === "yes");
2725
}
28-
const file = 'device code,device description,device properties\n1,2,3\n1,2,3\n'
29-
await csvParser.parse(file)
30-
})
26+
pass++;
27+
}
28+
const file = 'device code,device description,device properties\n1,2,3\n1,2,3\n'
29+
await csvParser.parse(file)
30+
})
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
/// <reference types="bun-types/test-globals" />
2+
import { test, expect, describe, jest, afterEach, beforeEach } from "bun:test";
3+
import { collect } from "./APIService";
4+
5+
// --- Code to Test ---
6+
// (Assuming this is imported from './collect.ts')
7+
// I'm including it here for a self-contained example.
8+
9+
// Helper types (assuming definitions based on the function)
10+
declare namespace API {
11+
export interface PaginatedResponse {
12+
links?: {
13+
next?: string | null;
14+
};
15+
}
16+
}
17+
18+
// This is a guess at the RequestResult type based on the function's usage
19+
type RequestResult<TData, TError, ThrowOnError> =
20+
| { data: TData; error?: undefined }
21+
| { data: undefined; error: TError };
22+
23+
// --- Test Setup ---
24+
25+
// Define a concrete type for our tests
26+
interface Device {
27+
id: number;
28+
name: string;
29+
}
30+
31+
// Define concrete response types for our mock
32+
type DeviceResponse = API.PaginatedResponse & { data: Device[] };
33+
type PaginatedData = Record<number, DeviceResponse>;
34+
type PaginatedError = Record<number, { message: string }>;
35+
36+
// Mock the non-standard URL.parse function
37+
// We'll make it behave like `new URL()` for the test
38+
const mockUrlParse = jest.fn((url: string) => {
39+
// Handle the '?? ""' case, which would throw in `new URL()`
40+
if (!url) {
41+
return { searchParams: new URLSearchParams() };
42+
}
43+
// Use the standard URL object to parse the URL string
44+
return new URL(url);
45+
});
46+
47+
describe("collect", () => {
48+
// Spy on the global URL object to mock 'parse'
49+
let urlParseSpy: any;
50+
51+
beforeEach(() => {
52+
// Mock URL.parse before each test
53+
urlParseSpy = jest
54+
.spyOn(globalThis.URL, "parse")
55+
.mockImplementation(mockUrlParse as any);
56+
});
57+
58+
afterEach(() => {
59+
// Restore the original implementation and clear mocks
60+
urlParseSpy.mockRestore();
61+
jest.clearAllMocks();
62+
});
63+
64+
// Test 1: Single page of data
65+
test("should collect data from a single page", async () => {
66+
const page1Data: Device[] = [{ id: 1, name: "Device A" }];
67+
const mockCall = jest.fn().mockResolvedValue({
68+
data: {
69+
data: page1Data,
70+
links: { next: null }, // No next page
71+
},
72+
});
73+
74+
const result = await collect(
75+
mockCall as any,
76+
);
77+
78+
expect(result).toEqual(page1Data);
79+
expect(mockCall).toHaveBeenCalledTimes(1);
80+
expect(mockCall).toHaveBeenCalledWith(undefined); // First call has no cursor
81+
});
82+
83+
// Test 2: Multiple pages of data
84+
test("should collect and concatenate data from multiple pages", async () => {
85+
const page1Data: Device[] = [{ id: 1, name: "Device A" }];
86+
const page2Data: Device[] = [{ id: 2, name: "Device B" }];
87+
88+
const mockCall = jest
89+
.fn()
90+
// First call response
91+
.mockResolvedValueOnce({
92+
data: {
93+
data: page1Data,
94+
links: { next: "https://api.example.com/devices?cursor=page2" },
95+
},
96+
})
97+
// Second call response
98+
.mockResolvedValueOnce({
99+
data: {
100+
data: page2Data,
101+
links: { next: null }, // No more pages
102+
},
103+
});
104+
105+
const result = await collect(
106+
mockCall as any,
107+
);
108+
109+
// Check final concatenated data
110+
expect(result).toEqual([
111+
{ id: 1, name: "Device A" },
112+
{ id: 2, name: "Device B" },
113+
]);
114+
115+
// Check that the mock was called correctly
116+
expect(mockCall).toHaveBeenCalledTimes(2);
117+
expect(mockCall).toHaveBeenNthCalledWith(1, undefined);
118+
expect(mockCall).toHaveBeenNthCalledWith(2, "page2");
119+
120+
// Check that our URL.parse mock was used
121+
expect(mockUrlParse).toHaveBeenCalledWith(
122+
"https://api.example.com/devices?cursor=page2",
123+
);
124+
});
125+
126+
// Test 3: Empty response
127+
test("should return an empty array if no data is found", async () => {
128+
const mockCall = jest.fn().mockResolvedValue({
129+
data: {
130+
data: [], // Empty data array
131+
links: { next: null },
132+
},
133+
});
134+
135+
const result = await collect(
136+
mockCall as any,
137+
);
138+
139+
expect(result).toEqual([]);
140+
expect(mockCall).toHaveBeenCalledTimes(1);
141+
});
142+
143+
// Test 4: API Error
144+
test("should return an Error if the API call fails", async () => {
145+
const apiError = { message: "Internal Server Error" };
146+
const mockCall = jest.fn().mockResolvedValue({
147+
data: undefined,
148+
error: apiError,
149+
});
150+
151+
const result = await collect(
152+
mockCall as any,
153+
);
154+
155+
expect(result).toBeInstanceOf(Error);
156+
expect(mockCall).toHaveBeenCalledTimes(1);
157+
});
158+
159+
// Test 5: Gracefully handles empty 'next' string
160+
test("should stop paginating if 'next' link is an empty string", async () => {
161+
const page1Data: Device[] = [{ id: 1, name: "Device A" }];
162+
const mockCall = jest.fn().mockResolvedValue({
163+
data: {
164+
data: page1Data,
165+
links: { next: "" }, // Empty string
166+
},
167+
});
168+
169+
const result = await collect(
170+
mockCall as any,
171+
);
172+
173+
expect(result).toEqual(page1Data);
174+
expect(mockCall).toHaveBeenCalledTimes(1);
175+
expect(mockUrlParse).toHaveBeenCalledWith(""); // Our mock handles this
176+
});
177+
});

services/web-importer/src/lib/services/APIService.ts

Lines changed: 5 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,8 @@ import * as API from "$lib/sensorbucket";
22
import type { Reconciliation, ReconciliationDevice, ReconciliationSensor } from "$lib/reconciliation";
33
import type { With } from "$lib/types";
44
import type { CSVFeatureOfInterest } from "$lib/CSVFeatureOfInterestParser";
5-
import { type Device } from "$lib/sensorbucket/index";
6-
import { createClient, type Client, type ResponseStyle } from "$lib/sensorbucket/client";
7-
import type { RequestResult, TDataShape } from "@hey-api/client-fetch";
5+
import { createClient, type Client } from "$lib/sensorbucket/client";
6+
import type { RequestResult } from "@hey-api/client-fetch";
87

98
/**
109
* Service for handling API operations related to devices and sensors
@@ -272,46 +271,18 @@ export class _ApiService {
272271
}
273272
}
274273

275-
// interface TDataShape {
276-
// body?: unknown;
277-
// headers?: unknown;
278-
// path?: unknown;
279-
// query?: unknown;
280-
// url: string;
281-
// }
282-
// type OmitKeys<T, K> = Pick<T, Exclude<keyof T, K>>;
283-
// type API.Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean, TResponseStyle extends ResponseStyle = 'fields'> = OmitKeys<RequestOptions<TResponseStyle, ThrowOnError>, 'body' | 'path' | 'query' | 'url'> & Omit<TData, 'url'>;
284-
285-
// type RequestResult<TData = unknown, TError = unknown, ThrowOnError extends boolean = boolean, TResponseStyle extends ResponseStyle = 'fields'> = ThrowOnError extends true ? Promise<TResponseStyle extends 'data' ? TData extends Record<string, unknown> ? TData[keyof TData] : TData : {
286-
// data: TData extends Record<string, unknown> ? TData[keyof TData] : TData;
287-
// request: Request;
288-
// response: Response;
289-
// }> : Promise<TResponseStyle extends 'data' ? (TData extends Record<string, unknown> ? TData[keyof TData] : TData) | undefined : ({
290-
// data: TData extends Record<string, unknown> ? TData[keyof TData] : TData;
291-
// error: undefined;
292-
// } | {
293-
// data: undefined;
294-
// error: TError extends Record<string, unknown> ? TError[keyof TError] : TError;
295-
// }) & {
296-
// request: Request;
297-
// response: Response;
298-
// }>;
299-
300-
const collect = async <Data, TData extends Record<number, API.PaginatedResponse & { data: Data[] }>, TError extends Record<number, unknown>, ThrowOnError extends boolean = false>(call: (cursor?: string) => RequestResult<TData, TError, ThrowOnError>) => {
274+
export const collect = async <Data, TData extends Record<number, API.PaginatedResponse & { data: Data[] }>, TError extends Record<number, unknown>, ThrowOnError extends boolean = false>(call: (cursor?: string) => RequestResult<TData, TError, ThrowOnError>) => {
301275
const devices: Data[] = []
302276

303277
let cursor: string | undefined;
304278
do {
305279
const res = await call(cursor);
306280

307-
if (res.data === undefined) return new Error("Error listing devices: " + (res as any).error);
281+
if (res.data === undefined || (res as any).error !== undefined) return new Error("Error listing devices: " + (res as any).error);
308282

309283
devices.push(...res.data.data as Data[])
310284

311-
if (res.data.links?.next)
312-
cursor = URL.parse(res.data.links?.next)?.searchParams.get("cursor") || undefined;
313-
else
314-
cursor = undefined
285+
cursor = URL.parse(res.data?.links?.next ?? '')?.searchParams.get("cursor") || undefined;
315286
} while (cursor);
316287

317288
return devices;

services/web-importer/src/main.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// for information about these interfaces
33
/// <reference types="svelte" />
44
/// <reference types="vite/client" />
5+
/// <reference types="bun-types/test-globals" />
56

67
declare global {
78
namespace App {

services/web-importer/tsconfig.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
{
2+
"compilerOptionts": {
3+
"types": ["bun-types"]
4+
},
25
"files": [],
36
"references": [
47
{

0 commit comments

Comments
 (0)