Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/watcher/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"lint:check": "biome check",
"start": "next start",
"tauri": "tauri",
"test": "vitest run",
"type-check": "tsc --noEmit"
},
"dependencies": {
Expand Down
6 changes: 3 additions & 3 deletions apps/watcher/src/app/actions/@form/withdraw/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import type {
ApiWithdrawRequestBody,
ApiWithdrawResponse,
} from '@/types/api';
import { fetchAllAddressAssets } from '@/utils/fetchAllAddressAssets';

import {
type TokenAmountCompatibleFormSchema,
Expand All @@ -48,9 +49,8 @@ const WithdrawForm = () => {
const toast = useToast();

const { data, isLoading: isTokensListLoading } = useSWR<ApiAddressAssetsResponse>(
'/address/assets',
fetcher,
{},
['/address/assets', { all: true }],
() => fetchAllAddressAssets(fetcher),
);

const { token: ergToken, isLoading: isErgTokenLoading } = useToken('erg');
Expand Down
66 changes: 66 additions & 0 deletions apps/watcher/src/utils/fetchAllAddressAssets.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { describe, expect, it, vi } from 'vitest';

import type { ApiAddressAssetsResponse } from '../types/api';
import { fetchAllAddressAssets } from './fetchAllAddressAssets';

type LoadPage = Parameters<typeof fetchAllAddressAssets>[0];

const assets = (count: number): ApiAddressAssetsResponse['items'] =>
Array.from({ length: count }, (_, index) => ({
tokenId: `token-${index}`,
name: `Token ${index}`,
amount: index,
decimals: 0,
isNativeToken: false,
}));

describe('fetchAllAddressAssets', () => {
it.each([0, 1, 20, 21, 40, 45])('fetches all %i assets with no extra request', async (count) => {
const items = assets(count);
const loadPage = vi.fn<LoadPage>(async ([, { offset, limit }]) => ({
total: count,
items: items.slice(offset, offset + limit),
}));

await expect(fetchAllAddressAssets(loadPage)).resolves.toEqual({ total: count, items });
expect(loadPage).toHaveBeenCalledTimes(Math.max(1, Math.ceil(count / 20)));
for (let page = 0; page < loadPage.mock.calls.length; page++) {
expect(loadPage).toHaveBeenNthCalledWith(page + 1, [
'/address/assets',
{ offset: page * 20, limit: 20 },
]);
}
});

it('advances by the returned count when the server caps page size', async () => {
const items = assets(5);
const loadPage = vi.fn<LoadPage>(async ([, { offset }]) => ({
total: items.length,
items: items.slice(offset, offset + 2),
}));

await expect(fetchAllAddressAssets(loadPage)).resolves.toEqual({ total: 5, items });
expect(loadPage.mock.calls.map(([key]) => key[1].offset)).toEqual([0, 2, 4]);
});

it('rejects a later page failure instead of returning a partial list', async () => {
const error = new Error('Page unavailable');
const loadPage = vi
.fn<LoadPage>()
.mockResolvedValueOnce({ total: 21, items: assets(20) })
.mockRejectedValueOnce(error);

await expect(fetchAllAddressAssets(loadPage)).rejects.toBe(error);
expect(loadPage).toHaveBeenCalledTimes(2);
});

it('rejects an empty page before the total instead of looping indefinitely', async () => {
const loadPage = vi
.fn<LoadPage>()
.mockResolvedValueOnce({ total: 21, items: assets(20) })
.mockResolvedValue({ total: 21, items: [] });

await expect(fetchAllAddressAssets(loadPage)).rejects.toThrow('Incomplete token list');
expect(loadPage).toHaveBeenCalledTimes(2);
});
});
24 changes: 24 additions & 0 deletions apps/watcher/src/utils/fetchAllAddressAssets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { ApiAddressAssetsResponse } from '../types/api';

type LoadPage = (
key: [string, { offset: number; limit: number }],
) => Promise<ApiAddressAssetsResponse>;

/** Fetch the complete token list without exposing a partial page to the form. */
export const fetchAllAddressAssets = async (
loadPage: LoadPage,
): Promise<ApiAddressAssetsResponse> => {
const items: ApiAddressAssetsResponse['items'] = [];
let total: number;

do {
const page = await loadPage(['/address/assets', { offset: items.length, limit: 20 }]);
total = page.total;
if (page.items.length === 0 && items.length < total) {
throw new Error('Incomplete token list');
}
items.push(...page.items);
} while (items.length < total);

return { items, total };
};