Skip to content
Open
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
85 changes: 85 additions & 0 deletions app/mobile/src/__tests__/aidApi.test.ts
Original file line number Diff line number Diff line change
@@ -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',
);
});
});
72 changes: 72 additions & 0 deletions app/mobile/src/__tests__/aidCache.test.ts
Original file line number Diff line number Diff line change
@@ -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<void> }).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();
});
});
120 changes: 120 additions & 0 deletions app/mobile/src/__tests__/syncQueueNetwork.test.ts
Original file line number Diff line number Diff line change
@@ -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<void> }).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);
});
});