Skip to content
Merged
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
267 changes: 267 additions & 0 deletions mobile/__tests__/api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,267 @@
import { api } from '../utils/api';

// ------------------------------------------------------------------ mock fetch

const mockFetch = jest.fn() as jest.MockedFunction<typeof fetch>;
global.fetch = mockFetch;

function jsonResponse(body: unknown, status = 200): Response {
return {
ok: status >= 200 && status < 300,
status,
text: () => Promise.resolve(JSON.stringify(body)),
} as Response;
}

beforeEach(() => {
mockFetch.mockReset();
});

// ------------------------------------------------------------------ health

describe('api.health', () => {
it('returns status and timestamp', async () => {
const payload = { status: 'ok', timestamp: '2026-04-05T00:00:00Z' };
mockFetch.mockResolvedValue(jsonResponse(payload));

const result = await api.health();
expect(result).toEqual(payload);
expect(mockFetch).toHaveBeenCalledWith(
'http://localhost:3000/health',
expect.objectContaining({ method: 'GET' })
);
});
});

// ------------------------------------------------------------------ farms

describe('api.getFarms', () => {
it('fetches array of farms', async () => {
const farms = [{ id: 'f1', name: 'Finca Alta', location: 'Ahuachapán', altitude_m: 1200, owner_name: 'Carlos', created_at: '2026-01-01' }];
mockFetch.mockResolvedValue(jsonResponse(farms));

const result = await api.getFarms();
expect(result).toEqual(farms);
expect(mockFetch).toHaveBeenCalledWith(
'http://localhost:3000/farm',
expect.objectContaining({ method: 'GET' })
);
});
});

describe('api.getFarm', () => {
it('fetches a single farm by id', async () => {
const farm = { id: 'f1', name: 'Finca Alta' };
mockFetch.mockResolvedValue(jsonResponse(farm));

const result = await api.getFarm('f1');
expect(result).toEqual(farm);
expect(mockFetch).toHaveBeenCalledWith(
'http://localhost:3000/farm/f1',
expect.objectContaining({ method: 'GET' })
);
});
});

describe('api.createFarm', () => {
it('posts farm data', async () => {
const body = { name: 'Finca Nueva', location: 'Sonsonate', altitude_m: 900, owner_name: 'Maria' };
const created = { id: 'f2', ...body, created_at: '2026-04-05' };
mockFetch.mockResolvedValue(jsonResponse(created));

const result = await api.createFarm(body);
expect(result).toEqual(created);
expect(mockFetch).toHaveBeenCalledWith(
'http://localhost:3000/farm',
expect.objectContaining({
method: 'POST',
body: JSON.stringify(body),
})
);
});
});

// ------------------------------------------------------------------ workers

describe('api.getWorkers', () => {
it('fetches all workers when no farmId', async () => {
mockFetch.mockResolvedValue(jsonResponse([]));
await api.getWorkers();
expect(mockFetch).toHaveBeenCalledWith(
'http://localhost:3000/worker',
expect.objectContaining({ method: 'GET' })
);
});

it('filters by farm_id when provided', async () => {
mockFetch.mockResolvedValue(jsonResponse([]));
await api.getWorkers('f1');
expect(mockFetch).toHaveBeenCalledWith(
'http://localhost:3000/worker?farm_id=f1',
expect.objectContaining({ method: 'GET' })
);
});
});

describe('api.createWorker', () => {
it('posts worker data with defaults', async () => {
const body = { farm_id: 'f1', name: 'Pedro' };
const created = { id: 'w1', ...body, role: 'worker', liquid_address: 'lq1abc' };
mockFetch.mockResolvedValue(jsonResponse(created));

const result = await api.createWorker(body);
expect(result).toEqual(created);
});
});

describe('api.updateWorker', () => {
it('sends PUT request', async () => {
const updated = { id: 'w1', name: 'Pedro Updated' };
mockFetch.mockResolvedValue(jsonResponse(updated));

await api.updateWorker('w1', { name: 'Pedro Updated' });
expect(mockFetch).toHaveBeenCalledWith(
'http://localhost:3000/worker/w1',
expect.objectContaining({ method: 'PUT' })
);
});
});

describe('api.getWorkerPayments', () => {
it('fetches payment history', async () => {
const payments = { worker: { id: 'w1', name: 'Pedro' }, payments: [], total_paid_sats: 0 };
mockFetch.mockResolvedValue(jsonResponse(payments));

const result = await api.getWorkerPayments('w1');
expect(result).toEqual(payments);
});
});

// ------------------------------------------------------------------ shifts

describe('api.createShift', () => {
it('posts shift data', async () => {
const body = { farm_id: 'f1', foreman_id: 'w2' };
const shift = { id: 's1', ...body, status: 'open', qr_image: 'data:image/png;base64,...' };
mockFetch.mockResolvedValue(jsonResponse(shift));

const result = await api.createShift(body);
expect(result).toEqual(shift);
});
});

describe('api.checkIn', () => {
it('posts checkin to shift', async () => {
const checkin = { id: 'c1', shift_id: 's1', worker_id: 'w1', checked_in_at: '2026-04-05T08:00:00Z' };
mockFetch.mockResolvedValue(jsonResponse({ checkin, worker: { id: 'w1', name: 'Pedro' } }));

const result = await api.checkIn('s1', { worker_id: 'w1' });
expect(result.checkin).toEqual(checkin);
});
});

describe('api.closeShift', () => {
it('posts close request', async () => {
const resp = { shift: { id: 's1', status: 'closed' }, checkin_count: 5, liquid_record: { asset_id: 'abc' }, workers: [] };
mockFetch.mockResolvedValue(jsonResponse(resp));

const result = await api.closeShift('s1');
expect(result.shift.status).toBe('closed');
});
});

// ------------------------------------------------------------------ lots

describe('api.createLot', () => {
it('posts lot data', async () => {
const body = { shift_id: 's1', weight_kg: 45, grade: 'A' as const };
const resp = { lot: { id: 'l1', ...body }, asset: { asset_id: 'lq-asset' }, provenance_url: '/provenance/l1', qr_image: '' };
mockFetch.mockResolvedValue(jsonResponse(resp));

const result = await api.createLot(body);
expect(result.lot.weight_kg).toBe(45);
});
});

describe('api.transferLot', () => {
it('posts transfer data', async () => {
const body = { to_entity: 'Mill Co', entity_type: 'wet_mill' as const };
const resp = { transfer: { id: 't1', lot_id: 'l1', ...body }, liquid_tx: { tx_id: 'tx123' } };
mockFetch.mockResolvedValue(jsonResponse(resp));

const result = await api.transferLot('l1', body);
expect(result.transfer.to_entity).toBe('Mill Co');
});
});

// ------------------------------------------------------------------ payroll

describe('api.runPayroll', () => {
it('posts payroll request', async () => {
const resp = { shift_id: 's1', shift_date: '2026-04-05', total_workers: 3, paid_count: 3, failed_count: 0, total_sats_paid: 3000, payments: [], lightning_demo_mode: true };
mockFetch.mockResolvedValue(jsonResponse(resp));

const result = await api.runPayroll({ shift_id: 's1' });
expect(result.total_sats_paid).toBe(3000);
expect(result.lightning_demo_mode).toBe(true);
});
});

// ------------------------------------------------------------------ provenance

describe('api.getProvenance', () => {
it('fetches provenance data', async () => {
const resp = { lot: { id: 'l1' }, farm: { id: 'f1' }, shift: { id: 's1' }, workers: [], transfers: [], payment_summary: { worker_count: 0, total_sats: 0 } };
mockFetch.mockResolvedValue(jsonResponse(resp));

const result = await api.getProvenance('l1');
expect(result.lot.id).toBe('l1');
expect(mockFetch).toHaveBeenCalledWith(
'http://localhost:3000/provenance/l1/data',
expect.objectContaining({ method: 'GET' })
);
});
});

// ------------------------------------------------------------------ error handling

describe('error handling', () => {
it('throws on network failure', async () => {
mockFetch.mockRejectedValue(new Error('Network request failed'));
await expect(api.health()).rejects.toThrow('No se pudo conectar al servidor');
});

it('throws on non-JSON response', async () => {
mockFetch.mockResolvedValue({
ok: true,
status: 200,
text: () => Promise.resolve('not json'),
} as Response);
await expect(api.health()).rejects.toThrow('Respuesta inválida del servidor');
});

it('throws server error message', async () => {
mockFetch.mockResolvedValue(jsonResponse({ error: 'Farm not found' }, 404));
await expect(api.getFarm('bad-id')).rejects.toThrow('Farm not found');
});

it('throws generic error when no message in response', async () => {
mockFetch.mockResolvedValue(jsonResponse({}, 500));
await expect(api.health()).rejects.toThrow('Error 500');
});
});

// ------------------------------------------------------------------ headers

describe('request headers', () => {
it('always sends Content-Type: application/json', async () => {
mockFetch.mockResolvedValue(jsonResponse({ status: 'ok' }));
await api.health();
expect(mockFetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
headers: { 'Content-Type': 'application/json' },
})
);
});
});
10 changes: 10 additions & 0 deletions mobile/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/** @type {import('jest').Config} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/__tests__'],
moduleFileExtensions: ['ts', 'tsx', 'js', 'json'],
transform: {
'^.+\\.tsx?$': ['ts-jest', { tsconfig: { jsx: 'react-jsx' } }],
},
};
5 changes: 4 additions & 1 deletion mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"start": "expo start",
"android": "expo run:android",
"ios": "expo run:ios",
"test": "echo \"No tests yet\" && exit 0"
"test": "jest --verbose"
},
"dependencies": {
"@expo-google-fonts/dm-sans": "^0.4.2",
Expand All @@ -32,8 +32,11 @@
},
"devDependencies": {
"@babel/core": "^7.25.2",
"@types/jest": "^30.0.0",
"@types/react": "~19.1.10",
"babel-preset-expo": "^54.0.10",
"jest": "^30.3.0",
"ts-jest": "^29.4.9",
"typescript": "^5.8.2"
},
"engines": {
Expand Down
Loading