Apps reach every service through Maestro on one port; direct service addressing is no longer a supported deployment, and Maestro has no per-service hostname or host-port route. Replace the maestro/direct modes and the serviceRef lookup with one configured origin and the fixed paths /api/alarm/{action,auth,ws}, defaulting dev builds to 127.0.0.1:8080 through adb reverse. The old mode-specific body wrapping and the direct-mode socket branch go with it.
145 lines
5.0 KiB
TypeScript
145 lines
5.0 KiB
TypeScript
/**
|
|
* 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',
|
|
},
|
|
]);
|
|
});
|
|
});
|