From 28be4fa0f8d934ecc0fd161ba6e4873f1344238a Mon Sep 17 00:00:00 2001 From: raondevbaek <326270729+raondevbaek@users.noreply.github.com> Date: Wed, 9 Sep 2026 20:40:48 +0900 Subject: [PATCH] fix(watcher): fetch all withdrawal token pages --- apps/watcher/package.json | 1 + .../src/app/actions/@form/withdraw/page.tsx | 6 +- .../src/utils/fetchAllAddressAssets.test.ts | 66 +++++++++++++++++++ .../src/utils/fetchAllAddressAssets.ts | 24 +++++++ 4 files changed, 94 insertions(+), 3 deletions(-) create mode 100644 apps/watcher/src/utils/fetchAllAddressAssets.test.ts create mode 100644 apps/watcher/src/utils/fetchAllAddressAssets.ts diff --git a/apps/watcher/package.json b/apps/watcher/package.json index ab2c3b23f..026dd5fc5 100644 --- a/apps/watcher/package.json +++ b/apps/watcher/package.json @@ -16,6 +16,7 @@ "lint:check": "biome check", "start": "next start", "tauri": "tauri", + "test": "vitest run", "type-check": "tsc --noEmit" }, "dependencies": { diff --git a/apps/watcher/src/app/actions/@form/withdraw/page.tsx b/apps/watcher/src/app/actions/@form/withdraw/page.tsx index e92063d50..a82e3c7fd 100644 --- a/apps/watcher/src/app/actions/@form/withdraw/page.tsx +++ b/apps/watcher/src/app/actions/@form/withdraw/page.tsx @@ -32,6 +32,7 @@ import type { ApiWithdrawRequestBody, ApiWithdrawResponse, } from '@/types/api'; +import { fetchAllAddressAssets } from '@/utils/fetchAllAddressAssets'; import { type TokenAmountCompatibleFormSchema, @@ -48,9 +49,8 @@ const WithdrawForm = () => { const toast = useToast(); const { data, isLoading: isTokensListLoading } = useSWR( - '/address/assets', - fetcher, - {}, + ['/address/assets', { all: true }], + () => fetchAllAddressAssets(fetcher), ); const { token: ergToken, isLoading: isErgTokenLoading } = useToken('erg'); diff --git a/apps/watcher/src/utils/fetchAllAddressAssets.test.ts b/apps/watcher/src/utils/fetchAllAddressAssets.test.ts new file mode 100644 index 000000000..20aae6a46 --- /dev/null +++ b/apps/watcher/src/utils/fetchAllAddressAssets.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { ApiAddressAssetsResponse } from '../types/api'; +import { fetchAllAddressAssets } from './fetchAllAddressAssets'; + +type LoadPage = Parameters[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(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(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() + .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() + .mockResolvedValueOnce({ total: 21, items: assets(20) }) + .mockResolvedValue({ total: 21, items: [] }); + + await expect(fetchAllAddressAssets(loadPage)).rejects.toThrow('Incomplete token list'); + expect(loadPage).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/watcher/src/utils/fetchAllAddressAssets.ts b/apps/watcher/src/utils/fetchAllAddressAssets.ts new file mode 100644 index 000000000..280a8b1fd --- /dev/null +++ b/apps/watcher/src/utils/fetchAllAddressAssets.ts @@ -0,0 +1,24 @@ +import type { ApiAddressAssetsResponse } from '../types/api'; + +type LoadPage = ( + key: [string, { offset: number; limit: number }], +) => Promise; + +/** Fetch the complete token list without exposing a partial page to the form. */ +export const fetchAllAddressAssets = async ( + loadPage: LoadPage, +): Promise => { + 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 }; +};