/** * Single-origin transport contract. * * The client talks to one instance origin and four paths under it. These are * pure-string assertions: they pin what the client will send, not that a live * Maestro accepts the upgrade — that is the E2E harness's job. */ import { configureApi, get } from '../src/services/apiClient'; import { alarmSocketUrl, normalizeInstanceUrl, servicePath } from '../src/services/instanceUrl'; import { listSubscriptions, removeSubscription } from '../src/services/alarmApi'; import { probe } from '../src/services/connection'; // The probe module reads settings from storage; the URL helpers above do not, // which is why they live in a storage-free module. jest.mock('@react-native-async-storage/async-storage', () => ({ getItem: jest.fn(async () => null), setItem: jest.fn(async () => undefined), })); describe('normalizeInstanceUrl', () => { it('accepts a bare host and adds http', () => { expect(normalizeInstanceUrl('localhost:8080')).toBe('http://localhost:8080'); }); it('keeps https and strips trailing slashes', () => { expect(normalizeInstanceUrl(' https://dcntrlized.example/ ')).toBe('https://dcntrlized.example'); expect(normalizeInstanceUrl('http://10.0.2.2:8080///')).toBe('http://10.0.2.2:8080'); }); it('refuses non-http schemes, whitespace and empty values', () => { expect(normalizeInstanceUrl('')).toBeNull(); expect(normalizeInstanceUrl(' ')).toBeNull(); expect(normalizeInstanceUrl('ftp://host')).toBeNull(); expect(normalizeInstanceUrl('http://')).toBeNull(); expect(normalizeInstanceUrl('http://host with space')).toBeNull(); }); }); describe('servicePath', () => { it('prefixes actions and auth with the service name', () => { expect(servicePath('/action/createAlert')).toBe('/api/alarm/action/createAlert'); expect(servicePath('/auth/login')).toBe('/api/alarm/auth/login'); expect(servicePath('action/getAlert')).toBe('/api/alarm/action/getAlert'); }); }); describe('alarmSocketUrl', () => { it('carries the instance prefix and the frozen query', () => { expect( alarmSocketUrl({ baseUrl: 'https://instance.example' }, 'a1 b', 'tok/en'), ).toBe('wss://instance.example/api/alarm/ws/alarm?alertId=a1%20b&token=tok%2Fen'); }); it('keeps ws for a plain-http dev instance', () => { expect(alarmSocketUrl({ baseUrl: 'http://127.0.0.1:8080' }, 'a', 't')).toBe( 'ws://127.0.0.1:8080/api/alarm/ws/alarm?alertId=a&token=t', ); }); it('is null when no instance is configured', () => { expect(alarmSocketUrl({ baseUrl: '' }, 'a', 't')).toBeNull(); }); }); describe('probe', () => { it('reports offline with no base URL when nothing is configured', async () => { await expect(probe({ instanceUrl: '' })).resolves.toEqual({ baseUrl: '', online: false }); }); }); describe('HTTP paths and bodies', () => { const g = globalThis as unknown as { fetch: typeof fetch }; const realFetch = g.fetch; const seen: { url: string; body: string | undefined }[] = []; beforeEach(() => { seen.length = 0; configureApi({ baseUrl: 'https://instance.example', token: 'tok', refreshToken: null, onUnauthorized: () => {}, onSessionUpdate: () => {}, }); g.fetch = (async (url: string, init?: { body?: string }) => { seen.push({ url, body: init?.body }); return { ok: true, status: 200, json: async () => ({ items: [] }), text: async () => '', }; }) as unknown as typeof fetch; }); afterEach(() => { g.fetch = realFetch; }); it('sends actions through /api/alarm/action with the payload envelope', async () => { await get('/action/getAlert?alertId=a1'); expect(seen[0].url).toBe('https://instance.example/api/alarm/action/getAlert?alertId=a1'); }); it('sends revocation with the wire subscription id inside the payload envelope', async () => { // The service parses `subscriptionId` as a uuid; before the type matched the // wire, the client sent `undefined` here and the revoke failed with a 400. await removeSubscription({ alertId: 'a1', subscriptionId: 's1' }); expect(seen[0].url).toBe( 'https://instance.example/api/alarm/action/removeSubscription', ); expect(JSON.parse(String(seen[0].body))).toEqual({ payload: { alertId: 'a1', subscriptionId: 's1' }, }); }); it('unwraps the listSubscriptions `{items}` envelope', async () => { g.fetch = (async () => ({ ok: true, status: 200, json: async () => ({ items: [ { id: 's1', deviceLabel: 'Android phone', platform: 'android', enabled: true, created: '2026-10-18T21:00:00Z', }, ], }), text: async () => '', })) as unknown as typeof fetch; await expect(listSubscriptions('a1')).resolves.toEqual([ { id: 's1', deviceLabel: 'Android phone', platform: 'android', enabled: true, created: '2026-10-18T21:00:00Z', }, ]); }); });