[feat](client): address one instance origin instead of connection modes

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.
This commit is contained in:
2026-10-19 02:30:51 +01:00
parent 2df34ab938
commit a29d9b775e
9 changed files with 398 additions and 296 deletions
+144
View File
@@ -0,0 +1,144 @@
/**
* 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',
},
]);
});
});