diff --git a/app/mobile/src/__tests__/aidApi.test.ts b/app/mobile/src/__tests__/aidApi.test.ts new file mode 100644 index 0000000..95ad03a --- /dev/null +++ b/app/mobile/src/__tests__/aidApi.test.ts @@ -0,0 +1,85 @@ +import { + fetchAidDetails, + fetchAidList, + submitClaim, +} from '../services/aidApi'; + +const mockFetch = jest.fn(); +global.fetch = mockFetch as typeof fetch; + +beforeEach(() => { + mockFetch.mockReset(); +}); + +describe('aidApi service', () => { + it('fetches the aid overview list from the backend', async () => { + const payload = [ + { + id: 'aid-1', + title: 'Emergency Food Supply', + description: 'Food package distribution', + status: 'active', + location: 'Sector A', + createdAt: '2026-01-01T00:00:00Z', + }, + ]; + mockFetch.mockResolvedValueOnce({ ok: true, json: async () => payload }); + + await expect(fetchAidList()).resolves.toEqual(payload); + expect(mockFetch).toHaveBeenCalledWith(expect.stringMatching(/\/aid$/)); + }); + + it('throws a status-specific error when the aid list request fails', async () => { + mockFetch.mockResolvedValueOnce({ ok: false, status: 503 }); + + await expect(fetchAidList()).rejects.toThrow('HTTP error! status: 503'); + }); + + it('fetches aid details for the selected aid id', async () => { + const payload = { + id: 'aid-42', + title: 'Medical Aid Convoy', + description: 'Mobile medical support', + recipient: { + name: 'Amina Yusuf', + id: 'REC-2041', + wallet: 'GAKD...Q9X2', + }, + tokenType: 'USDC', + amount: '150', + expiryDate: '2026-02-01T00:00:00Z', + status: 'verified', + claimId: 'claim-42', + createdAt: '2026-01-01T00:00:00Z', + }; + mockFetch.mockResolvedValueOnce({ ok: true, json: async () => payload }); + + await expect(fetchAidDetails('aid-42')).resolves.toEqual(payload); + expect(mockFetch).toHaveBeenCalledWith(expect.stringMatching(/\/aid\/aid-42$/)); + }); + + it('submits claims with an idempotency key header', async () => { + const payload = { status: 'accepted', claimId: 'claim-123' }; + mockFetch.mockResolvedValueOnce({ ok: true, json: async () => payload }); + + await expect(submitClaim('claim-123', 'idem-123')).resolves.toEqual(payload); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringMatching(/\/claims\/claim-123\/submit$/), + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Idempotency-Key': 'idem-123', + }, + }, + ); + }); + + it('throws a status-specific error when claim submission fails', async () => { + mockFetch.mockResolvedValueOnce({ ok: false, status: 409 }); + + await expect(submitClaim('claim-123', 'idem-123')).rejects.toThrow( + 'HTTP error! status: 409', + ); + }); +}); diff --git a/app/mobile/src/__tests__/aidCache.test.ts b/app/mobile/src/__tests__/aidCache.test.ts new file mode 100644 index 0000000..80c22c7 --- /dev/null +++ b/app/mobile/src/__tests__/aidCache.test.ts @@ -0,0 +1,72 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { + cacheAidList, + clearAidCache, + getCacheTimestamp, + loadCachedAidList, +} from '../services/aidCache'; +import { AidPackage } from '../services/api'; + +const clearStorage = async () => { + await (AsyncStorage as unknown as { clear: () => Promise }).clear(); +}; + +describe('aidCache service', () => { + beforeEach(async () => { + await clearStorage(); + jest.restoreAllMocks(); + }); + + it('returns null when no aid list is cached', async () => { + await expect(loadCachedAidList()).resolves.toBeNull(); + }); + + it('persists and loads the aid overview list', async () => { + const aidList: AidPackage[] = [ + { + id: 'aid-1', + title: 'Food Aid', + amount: 500, + status: 'active', + date: '2026-01-01', + }, + { + id: 'aid-2', + title: 'Medical Aid', + amount: 1200, + status: 'pending', + date: '2026-01-02', + }, + ]; + + await cacheAidList(aidList); + + await expect(loadCachedAidList()).resolves.toEqual(aidList); + }); + + it('records the cache write timestamp', async () => { + const now = Date.UTC(2026, 0, 1, 12, 0, 0); + jest.spyOn(Date, 'now').mockReturnValue(now); + + await cacheAidList([]); + + await expect(getCacheTimestamp()).resolves.toBe(new Date(now).toLocaleString()); + }); + + it('clears cached aid data and timestamp together', async () => { + await cacheAidList([ + { + id: 'aid-1', + title: 'Food Aid', + amount: 500, + status: 'active', + date: '2026-01-01', + }, + ]); + + await clearAidCache(); + + await expect(loadCachedAidList()).resolves.toBeNull(); + await expect(getCacheTimestamp()).resolves.toBeNull(); + }); +}); diff --git a/app/mobile/src/__tests__/syncQueueNetwork.test.ts b/app/mobile/src/__tests__/syncQueueNetwork.test.ts new file mode 100644 index 0000000..5610007 --- /dev/null +++ b/app/mobile/src/__tests__/syncQueueNetwork.test.ts @@ -0,0 +1,120 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; + +const QUEUE_KEY = '@chainforge/sync-queue'; +const mockFetch = jest.fn(); +global.fetch = mockFetch as typeof fetch; + +type SyncQueueModule = typeof import('../services/syncQueue'); + +const clearStorage = async () => { + await (AsyncStorage as unknown as { clear: () => Promise }).clear(); +}; + +const loadFreshQueue = (): SyncQueueModule => { + let mod!: SyncQueueModule; + jest.isolateModules(() => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + mod = require('../services/syncQueue') as SyncQueueModule; + }); + return mod; +}; + +describe('syncQueue network behavior', () => { + beforeEach(async () => { + await clearStorage(); + mockFetch.mockReset(); + }); + + it('queues claim confirmation while offline and flushes it when online', async () => { + const { + dispatchNetworkAction, + flushPendingNetworkActions, + getSyncQueueState, + } = loadFreshQueue(); + + const queued = await dispatchNetworkAction( + { + type: 'claim-confirmation', + payload: { aidId: 'aid-1', claimId: 'claim-1' }, + }, + { online: false }, + ); + expect(queued.status).toBe('queued'); + expect((await getSyncQueueState()).items).toHaveLength(1); + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ status: 'verified' }), + }); + + await flushPendingNetworkActions({ online: true }); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringMatching(/\/claims\/claim-1\/verify$/), + { method: 'POST' }, + ); + const state = await getSyncQueueState(); + expect(state.items).toHaveLength(0); + expect(state.lastSyncError).toBeNull(); + }); + + it('marks retryable failures as retrying and persists the last error', async () => { + const { + dispatchNetworkAction, + flushPendingNetworkActions, + getSyncQueueState, + } = loadFreshQueue(); + + await dispatchNetworkAction( + { + type: 'claim-confirmation', + payload: { aidId: 'aid-1', claimId: 'claim-1' }, + }, + { online: false }, + ); + mockFetch.mockResolvedValueOnce({ ok: false, status: 503 }); + + await flushPendingNetworkActions({ online: true }); + + const [item] = (await getSyncQueueState()).items; + expect(item.state).toBe('retrying'); + expect(item.retryCount).toBe(1); + expect(item.lastError).toBe('HTTP error! status: 503'); + }); + + it('limits saver-mode flushes to two network actions per cycle', async () => { + const { + dispatchNetworkAction, + flushPendingNetworkActions, + getSyncQueueState, + } = loadFreshQueue(); + + for (const id of ['one', 'two', 'three']) { + await dispatchNetworkAction( + { + type: 'evidence-upload', + payload: { + aidId: `aid-${id}`, + url: `https://example.test/upload/${id}`, + body: id, + }, + }, + { online: false }, + ); + } + + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ uploaded: true }), + }); + + await flushPendingNetworkActions({ online: true, saverMode: true }); + + expect(mockFetch).toHaveBeenCalledTimes(2); + const state = await getSyncQueueState(); + expect(state.items).toHaveLength(1); + expect( + JSON.parse((await AsyncStorage.getItem(QUEUE_KEY)) ?? '[]'), + ).toHaveLength(1); + }); +});