diff --git a/mobile/__tests__/api.test.ts b/mobile/__tests__/api.test.ts new file mode 100644 index 0000000..3ab1f91 --- /dev/null +++ b/mobile/__tests__/api.test.ts @@ -0,0 +1,267 @@ +import { api } from '../utils/api'; + +// ------------------------------------------------------------------ mock fetch + +const mockFetch = jest.fn() as jest.MockedFunction; +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' }, + }) + ); + }); +}); diff --git a/mobile/jest.config.js b/mobile/jest.config.js new file mode 100644 index 0000000..3f40cbe --- /dev/null +++ b/mobile/jest.config.js @@ -0,0 +1,10 @@ +/** @type {import('jest').Config} */ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: ['/__tests__'], + moduleFileExtensions: ['ts', 'tsx', 'js', 'json'], + transform: { + '^.+\\.tsx?$': ['ts-jest', { tsconfig: { jsx: 'react-jsx' } }], + }, +}; diff --git a/mobile/package.json b/mobile/package.json index 3ab058e..6dfb522 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -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", @@ -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": {