feat(api): absorb the Hub control plane into the local instance model

Add central notification channels, rules, queue and delivery logs, fleet
organization groups and tags, device discovery, the device action catalog,
instance module reads, the log centre, connection settings and system
maintenance as native /api/v1 routes backed by the existing secret store,
audit trail and pinned upstream transport.
This commit is contained in:
chick
2026-09-05 18:53:04 +08:00
parent f11877f13e
commit f9186bd851
97 changed files with 18646 additions and 104 deletions
+806
View File
@@ -78,6 +78,7 @@ describe('buildControlPlaneApp', () => {
request: async () => ({ status: 200, headers: {}, body: '' }),
postNetworkRegisterAuto: async () => ({ status: 200 }),
postServiceRestart: async () => ({ status: 200 }),
postBasebandRestart: async () => ({ status: 200 }),
postSystemReboot: async () => ({ status: 200 }),
},
now: () => new Date('2026-07-30T02:00:00.000Z'),
@@ -103,6 +104,7 @@ describe('buildControlPlaneApp', () => {
request: async () => ({ status: 200, headers: {}, body: '' }),
postNetworkRegisterAuto: async () => ({ status: 200 }),
postServiceRestart: async () => ({ status: 200 }),
postBasebandRestart: async () => ({ status: 200 }),
postSystemReboot: async () => ({ status: 200 }),
},
});
@@ -121,6 +123,71 @@ describe('buildControlPlaneApp', () => {
await app.close();
});
it('serves native system maintenance from the control plane composition', async () => {
const db = new Database(':memory:');
db.pragma('foreign_keys=ON');
migrateDatabase(db);
dbs.push(db);
const app = buildControlPlaneApp({
db,
store: new Store(),
upstream: {
get: async () => ({ status: 200, headers: {}, body: '' }),
request: async () => ({ status: 200, headers: {}, body: '' }),
postNetworkRegisterAuto: async () => ({ status: 200 }),
postServiceRestart: async () => ({ status: 200 }),
postBasebandRestart: async () => ({ status: 200 }),
postSystemReboot: async () => ({ status: 200 }),
},
});
const response = await app.inject({ method: 'GET', url: '/api/v1/system/maintenance' });
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({
runtime: { version: '0.1.0', platform: process.platform, arch: process.arch },
storage: { components: expect.any(Array) },
retention: { auditEvents: { enabled: true, days: 180, maximumCount: 50_000 } },
});
await app.close();
});
it('serves central notification configuration from the control plane composition', async () => {
const db = new Database(':memory:');
db.pragma('foreign_keys=ON');
migrateDatabase(db);
dbs.push(db);
const app = buildControlPlaneApp({
db,
store: new Store(),
upstream: {
get: async () => ({ status: 200, headers: {}, body: '' }),
request: async () => ({ status: 200, headers: {}, body: '' }),
postNetworkRegisterAuto: async () => ({ status: 200 }),
postServiceRestart: async () => ({ status: 200 }),
postBasebandRestart: async () => ({ status: 200 }),
postSystemReboot: async () => ({ status: 200 }),
},
});
const created = await app.inject({
method: 'POST',
url: '/api/v1/notifications/channels',
payload: {
name: 'Bark',
type: 'bark',
config: { server_url: 'https://api.day.app', device_key: 'opaque-device-key' },
},
});
expect(created.statusCode).toBe(201);
expect(created.body).not.toContain('opaque-device-key');
const rules = await app.inject('/api/v1/notifications/rules');
expect(rules.statusCode).toBe(200);
expect(rules.json()).toEqual({ items: [], page: { page: 1, pageSize: 50, total: 0 } });
await app.close();
});
it('passes an explicit event-stream authenticator to durable cursor replay', async () => {
const db = new Database(':memory:');
db.pragma('foreign_keys=ON');
@@ -134,6 +201,7 @@ describe('buildControlPlaneApp', () => {
request: async () => ({ status: 200, headers: {}, body: '' }),
postNetworkRegisterAuto: async () => ({ status: 200 }),
postServiceRestart: async () => ({ status: 200 }),
postBasebandRestart: async () => ({ status: 200 }),
postSystemReboot: async () => ({ status: 200 }),
},
authenticateEventStream: (request) => request.headers.authorization === 'Bearer allowed',
@@ -257,6 +325,7 @@ describe('buildControlPlaneApp', () => {
},
postNetworkRegisterAuto: async () => ({ status: 200 }),
postServiceRestart: async () => ({ status: 200 }),
postBasebandRestart: async () => ({ status: 200 }),
postSystemReboot: async () => ({ status: 200 }),
},
});
@@ -287,6 +356,664 @@ describe('buildControlPlaneApp', () => {
await app.close();
});
it('keeps the hub namespace closed while serving a native fleet overview aggregate', async () => {
const db = new Database(':memory:');
db.pragma('foreign_keys=ON');
migrateDatabase(db);
dbs.push(db);
const app = buildControlPlaneApp({
db,
store: new Store(),
upstream: {
get: async () => ({ status: 200, headers: {}, body: '' }),
request: async () => ({ status: 200, headers: {}, body: '{"status":"success"}' }),
postNetworkRegisterAuto: async () => ({ status: 200 }),
postServiceRestart: async () => ({ status: 200 }),
postBasebandRestart: async () => ({ status: 200 }),
postSystemReboot: async () => ({ status: 200 }),
},
});
const created = await app.inject({
method: 'POST',
url: '/api/v1/instances',
payload: { name: 'Alpha', origin: 'http://192.168.1.10:8080' },
});
expect(created.statusCode).toBe(201);
const instance = created.json();
const hubResponse = await app.inject({ method: 'GET', url: '/api/v1/hub/overview' });
expect(hubResponse.statusCode).toBe(404);
const overview = await app.inject({ method: 'GET', url: '/api/v1/fleet/overview' });
expect(overview.statusCode).toBe(200);
expect(overview.json()).toEqual({
items: [
{
...instance,
connection: {
probed: false,
reachable: false,
authenticated: false,
checkedAt: null,
},
resources: {},
},
],
});
// A live heartbeat snapshot turns the same row into a reported online device.
db.prepare(
`INSERT INTO status_snapshots (id,instance_id,category,state,payload_json,observed_at,expires_at,created_at)
VALUES ('snap-1',?, 'connection','fresh',?,?,?,?)`,
).run(
instance.id,
JSON.stringify({ reachable: true, authenticated: true }),
'2026-07-16T12:00:00.000Z',
'2099-01-01T00:00:00.000Z',
'2026-07-16T12:00:00.000Z',
);
const probed = await app.inject({ method: 'GET', url: '/api/v1/fleet/overview' });
expect(probed.json().items[0].connection).toEqual({
probed: true,
reachable: true,
authenticated: true,
checkedAt: '2026-07-16T12:00:00.000Z',
});
await app.close();
});
it('serves cross-node message management from the Fleet domain', async () => {
const db = new Database(':memory:');
db.pragma('foreign_keys=ON');
migrateDatabase(db);
dbs.push(db);
const app = buildControlPlaneApp({
db,
store: new Store(),
upstream: {
get: async () => ({ status: 200, headers: {}, body: '' }),
request: async (request) => {
if (request.url.endsWith('/api/auth/login'))
return {
status: 200,
headers: { 'set-cookie': 'simadmin_session=fleet-msg-session' },
body: '{"status":"success"}',
};
if (request.url.includes('/api/sms/list'))
return {
status: 200,
headers: {},
body: JSON.stringify({
status: 'success',
data: {
messages: [
{
id: 2,
direction: 'incoming',
phone_number: '10086',
content: 'Alpha balance updated',
timestamp: '2026-07-17T01:02:00.000Z',
status: 'received',
transport: 'gsm',
},
{
id: 1,
direction: 'outgoing',
phone_number: '13900139000',
content: 'Hello Fleet',
timestamp: '2026-07-17T00:59:00.000Z',
status: 'delivered',
transport: 'modem',
},
],
},
}),
};
if (request.url.endsWith('/api/sms/send'))
return { status: 200, headers: {}, body: '{"status":"success"}' };
if (request.url.endsWith('/api/sms/batch-delete'))
return {
status: 200,
headers: {},
body: JSON.stringify({
status: 'success',
data: { deleted: 2 },
}),
};
return { status: 200, headers: {}, body: '{"status":"success","data":{}}' };
},
postNetworkRegisterAuto: async () => ({ status: 200 }),
postServiceRestart: async () => ({ status: 200 }),
postBasebandRestart: async () => ({ status: 200 }),
postSystemReboot: async () => ({ status: 200 }),
},
now: () => new Date('2026-07-16T12:00:00.000Z'),
});
const created = await app.inject({
method: 'POST',
url: '/api/v1/instances',
payload: {
name: 'Message Node',
origin: 'http://192.168.1.40:8080',
password: { action: 'set', password: '[REDACTED]' },
},
});
expect(created.statusCode).toBe(201);
const instanceId = created.json().id as string;
const list = await app.inject({
method: 'GET',
url: '/api/v1/fleet/messages?limit=10&offset=0&search=Alpha',
});
expect(list.statusCode).toBe(200);
expect(list.json()).toEqual({
messages: [
expect.objectContaining({
instanceId,
instanceName: 'Message Node',
phoneNumber: '10086',
content: 'Alpha balance updated',
}),
],
devices: [{ id: instanceId, name: 'Message Node', availability: 'online' }],
total: 1,
});
const conversations = await app.inject({
method: 'GET',
url: '/api/v1/fleet/messages/conversations?limit=10&offset=0&direction=incoming',
});
expect(conversations.statusCode).toBe(200);
expect(conversations.json()).toEqual({
conversations: [
expect.objectContaining({
instanceId,
instanceName: 'Message Node',
phoneNumber: '10086',
messageCount: 1,
incomingCount: 1,
lastMessage: expect.objectContaining({ content: 'Alpha balance updated' }),
}),
],
total: 1,
// Direction counters describe the whole filtered archive, not just the returned page.
stats: { incoming: 1, outgoing: 1, total: 2 },
});
const badDirection = await app.inject({
method: 'GET',
url: '/api/v1/fleet/messages/conversations?direction=both',
});
expect(badDirection.statusCode).toBe(400);
// The plain message list has no direction concept and must keep rejecting the parameter.
expect(
(
await app.inject({
method: 'GET',
url: '/api/v1/fleet/messages?limit=10&offset=0&direction=incoming',
})
).statusCode,
).toBe(400);
const sent = await app.inject({
method: 'POST',
url: '/api/v1/fleet/messages/send',
payload: {
instanceId,
phoneNumber: '13900139000',
content: 'Verified via Fleet',
},
});
expect(sent.statusCode).toBe(200);
expect(sent.json()).toEqual({ sent: true, queued: false, instanceId });
const deleted = await app.inject({
method: 'POST',
url: '/api/v1/fleet/messages/delete',
payload: {
items: [
{ instanceId, id: 2 },
{ instanceId, id: 1 },
],
},
});
expect(deleted.statusCode).toBe(200);
expect(deleted.json()).toEqual({
requested: 2,
deleted: 2,
failed: 0,
failures: [],
});
const missing = await app.inject({
method: 'POST',
url: '/api/v1/fleet/messages/send',
payload: {
instanceId: 'missing-instance',
phoneNumber: '13900139000',
content: 'No target',
},
});
expect(missing.statusCode).toBe(404);
expect(missing.headers['content-type']).toContain('application/problem+json');
expect(missing.json()).toMatchObject({ status: 404, code: 'NOT_FOUND' });
await app.close();
});
it('queues fleet sends for offline nodes and drains them from the outbox routes', async () => {
const db = new Database(':memory:');
db.pragma('foreign_keys=ON');
migrateDatabase(db);
dbs.push(db);
const delivered: string[] = [];
const app = buildControlPlaneApp({
db,
store: new Store(),
upstream: {
get: async () => ({ status: 200, headers: {}, body: '' }),
request: async (request) => {
if (request.url.endsWith('/api/auth/login'))
return {
status: 200,
headers: { 'set-cookie': 'simadmin_session=fleet-outbox-session' },
body: '{\"status\":\"success\"}',
};
if (request.url.endsWith('/api/sms/send')) {
delivered.push(request.body ?? '');
return { status: 200, headers: {}, body: '{\"status\":\"success\"}' };
}
return { status: 200, headers: {}, body: '{\"status\":\"success\",\"data\":{}}' };
},
postNetworkRegisterAuto: async () => ({ status: 200 }),
postServiceRestart: async () => ({ status: 200 }),
postBasebandRestart: async () => ({ status: 200 }),
postSystemReboot: async () => ({ status: 200 }),
},
now: () => new Date('2026-07-16T12:00:00.000Z'),
});
const created = await app.inject({
method: 'POST',
url: '/api/v1/instances',
payload: {
name: 'Sleeping Node',
origin: 'http://192.168.1.41:8080',
password: { action: 'set', password: '[REDACTED]' },
},
});
expect(created.statusCode).toBe(201);
const instanceId = created.json().id as string;
// The heartbeat journal says this node dropped off the LAN before the fixed clock time.
db.prepare(
`INSERT INTO status_snapshots (id,instance_id,category,state,payload_json,observed_at,expires_at,created_at)
VALUES ('snap-outbox',?, 'connection','fresh',?,?,?,?)`,
).run(
instanceId,
JSON.stringify({ reachable: true, authenticated: true }),
'2026-07-16T11:58:00.000Z',
'2026-07-16T11:59:00.000Z',
'2026-07-16T11:58:00.000Z',
);
const queued = await app.inject({
method: 'POST',
url: '/api/v1/fleet/messages/send',
payload: { instanceId, phoneNumber: '13900139000', content: 'Wait for the node' },
});
expect(queued.statusCode).toBe(200);
expect(queued.json()).toEqual({
sent: false,
queued: true,
instanceId,
queueId: expect.any(String),
});
expect(delivered).toHaveLength(0);
const queueId = queued.json().queueId as string;
const page = await app.inject({
method: 'GET',
url: '/api/v1/fleet/messages/outbox?status=open&limit=10&offset=0',
});
expect(page.statusCode).toBe(200);
expect(page.json()).toEqual({
items: [
expect.objectContaining({
id: queueId,
instanceId,
instanceName: 'Sleeping Node',
phoneNumber: '13900139000',
content: 'Wait for the node',
status: 'queued',
attempts: 0,
}),
],
total: 1,
summary: { queued: 1, sending: 0, failed: 0, sent: 0 },
});
// Still offline, so a manual drain defers the item instead of burning an attempt.
const deferred = await app.inject({
method: 'POST',
url: '/api/v1/fleet/messages/outbox/flush',
});
expect(deferred.statusCode).toBe(200);
expect(deferred.json()).toEqual({
attempted: 1,
delivered: 0,
deferred: 1,
failed: 0,
remaining: 1,
});
expect(delivered).toHaveLength(0);
const cancelled = await app.inject({
method: 'POST',
url: `/api/v1/fleet/messages/outbox/${queueId}/cancel`,
});
expect(cancelled.statusCode).toBe(200);
expect(cancelled.json()).toMatchObject({ id: queueId, status: 'cancelled' });
const retried = await app.inject({
method: 'POST',
url: `/api/v1/fleet/messages/outbox/${queueId}/retry`,
});
expect(retried.statusCode).toBe(200);
expect(retried.json()).toMatchObject({ id: queueId, status: 'queued', attempts: 1 });
// The node comes back: the snapshot is refreshed and the queue drains for real.
db.prepare(`UPDATE status_snapshots SET expires_at=? WHERE id='snap-outbox'`).run(
'2099-01-01T00:00:00.000Z',
);
const flushed = await app.inject({
method: 'POST',
url: '/api/v1/fleet/messages/outbox/flush',
});
expect(flushed.statusCode).toBe(200);
expect(flushed.json()).toEqual({
attempted: 1,
delivered: 1,
deferred: 0,
failed: 0,
remaining: 0,
});
expect(delivered).toHaveLength(1);
// A reachable node keeps the old fast path: no queue row at all.
const direct = await app.inject({
method: 'POST',
url: '/api/v1/fleet/messages/send',
payload: { instanceId, phoneNumber: '13900139000', content: 'Delivered now' },
});
expect(direct.json()).toEqual({ sent: true, queued: false, instanceId });
expect(delivered).toHaveLength(2);
const dropped = await app.inject({
method: 'DELETE',
url: `/api/v1/fleet/messages/outbox/${queueId}`,
});
expect(dropped.statusCode).toBe(200);
expect(dropped.json()).toEqual({ removed: true, queueId });
expect(
(await app.inject({ method: 'GET', url: '/api/v1/fleet/messages/outbox?status=all' })).json(),
).toMatchObject({ total: 0 });
const badId = await app.inject({
method: 'POST',
url: '/api/v1/fleet/messages/outbox/not%20an%20id/cancel',
});
expect(badId.statusCode).toBe(400);
const missing = await app.inject({
method: 'POST',
url: '/api/v1/fleet/messages/outbox/q-999/retry',
});
expect(missing.statusCode).toBe(404);
expect(missing.json()).toMatchObject({ status: 404, code: 'NOT_FOUND' });
await app.close();
});
it('aggregates notification channels, rules, logs and queue in the Fleet center', async () => {
const db = new Database(':memory:');
db.pragma('foreign_keys=ON');
migrateDatabase(db);
dbs.push(db);
const app = buildControlPlaneApp({
db,
store: new Store(),
upstream: {
get: async () => ({ status: 200, headers: {}, body: '' }),
request: async (request) => {
if (request.url.endsWith('/api/auth/login'))
return {
status: 200,
headers: { 'set-cookie': 'simadmin_session=fleet-notification-session' },
body: '{"status":"success"}',
};
if (request.url.endsWith('/api/notifications/config'))
return {
status: 200,
headers: {},
body: JSON.stringify({
status: 'success',
data: {
channels: [
{ type: 'webhook', enabled: true },
{ type: 'bark', enabled: false },
{ type: 'wecom_robot', enabled: true },
],
rules: [{ enabled: true }, { enabled: false }],
},
}),
};
if (request.url.includes('/api/notifications/logs'))
return {
status: 200,
headers: {},
body: JSON.stringify({
status: 'success',
data: {
total: 5,
logs: [
{
id: 'log-1',
event_type: 'balance',
status: 'success',
rule_name: 'Balance Alert',
channel_name: 'webhook',
created_at: '2026-07-17T01:02:00.000Z',
},
{
id: 'log-2',
event_type: 'low-signal',
status: 'failed',
rule_name: 'Signal Alert',
channel_name: 'bark',
created_at: '2026-07-17T00:59:00.000Z',
},
{
id: 'log-3',
event_type: 'quiet',
status: 'quiet_hours',
created_at: '2026-07-17T00:58:00.000Z',
},
{
id: 'log-4',
event_type: 'unknown',
status: 'unmatched',
created_at: '2026-07-17T00:57:00.000Z',
},
{
id: 'log-5',
event_type: 'no-channel',
status: 'no_available_channel',
created_at: '2026-07-17T00:56:00.000Z',
},
],
},
}),
};
if (
request.url.includes('/api/notifications/queue') &&
(request.method === 'POST' || request.method === 'DELETE')
)
return {
status: 200,
headers: {},
body: '{"status":"success"}',
};
if (request.url.includes('/api/notifications/queue'))
return {
status: 200,
headers: {},
body: JSON.stringify({
status: 'success',
data: {
total: 3,
items: [
{
id: 'q-1',
status: 'pending',
event_type: 'balance',
rule_name: 'Balance Alert',
channel_name: 'webhook',
created_at: '2026-07-17T01:03:00.000Z',
},
{
id: 'q-2',
status: 'scheduled',
event_type: 'report',
created_at: '2026-07-17T01:04:00.000Z',
},
{
id: 'q-3',
status: 'retrying',
event_type: 'restart',
created_at: '2026-07-17T01:05:00.000Z',
},
],
},
}),
};
return { status: 200, headers: {}, body: '{"status":"success","data":{}}' };
},
postNetworkRegisterAuto: async () => ({ status: 200 }),
postServiceRestart: async () => ({ status: 200 }),
postBasebandRestart: async () => ({ status: 200 }),
postSystemReboot: async () => ({ status: 200 }),
},
now: () => new Date('2026-07-16T12:00:00.000Z'),
});
const created = await app.inject({
method: 'POST',
url: '/api/v1/instances',
payload: {
name: 'Notification Node',
origin: 'http://192.168.1.50:8080',
password: { action: 'set', password: '[REDACTED]' },
},
});
expect(created.statusCode).toBe(201);
const instanceId = created.json().id as string;
const response = await app.inject({
method: 'GET',
url: '/api/v1/fleet/notifications',
});
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({
observedAt: expect.any(String),
deviceCount: 1,
readyCount: 1,
unavailableCount: 0,
devices: [{ id: instanceId, name: 'Notification Node', state: 'ready' }],
config: {
channelCount: 3,
channelEnabled: 2,
ruleCount: 2,
ruleEnabled: 1,
channelTypes: [
{ type: 'webhook', total: 1, enabled: 1 },
{ type: 'bark', total: 1, enabled: 0 },
{ type: 'wecom_robot', total: 1, enabled: 1 },
],
},
logs: {
total: 5,
success: 1,
failed: 1,
quietHours: 1,
unmatched: 1,
noAvailableChannel: 1,
other: 0,
},
queue: {
total: 3,
pending: 1,
scheduled: 1,
retrying: 1,
sending: 0,
failed: 0,
},
});
expect(response.json().logs.recent[0]).toMatchObject({
id: 'log-1',
eventType: 'balance',
status: 'success',
ruleName: 'Balance Alert',
channelName: 'webhook',
});
expect(response.json().queue.recent[0]).toMatchObject({
id: 'q-1',
eventType: 'balance',
status: 'pending',
});
const requested = await app.inject({
method: 'POST',
url: '/api/v1/fleet/notifications/queue/retry-all',
});
expect(requested.statusCode).toBe(200);
expect(requested.json()).toEqual({
requested: 1,
succeeded: 1,
failed: 0,
skipped: 0,
failures: [],
});
const retried = await app.inject({
method: 'POST',
url: `/api/v1/fleet/notifications/queue/${instanceId}/items/q-1/retry`,
});
expect(retried.statusCode).toBe(200);
expect(retried.json()).toEqual({ retried: true });
const deleted = await app.inject({
method: 'DELETE',
url: `/api/v1/fleet/notifications/queue/${instanceId}/items/q-1`,
});
expect(deleted.statusCode).toBe(200);
expect(deleted.json()).toEqual({ deleted: true });
const missing = await app.inject({
method: 'POST',
url: '/api/v1/fleet/notifications/queue/missing-instance/items/q-1/retry',
});
expect(missing.statusCode).toBe(404);
expect(missing.json()).toMatchObject({ status: 404, code: 'NOT_FOUND' });
const invalidQueueId = await app.inject({
method: 'POST',
url: `/api/v1/fleet/notifications/queue/${instanceId}/items/bad%20id/retry`,
});
expect(invalidQueueId.statusCode).toBe(400);
expect(invalidQueueId.json()).toMatchObject({ status: 400, code: 'VALIDATION_FAILED' });
await app.close();
});
it('attaches an origin-bound session cookie when executing restart operations', async () => {
const db = new Database(':memory:');
db.pragma('foreign_keys=ON');
@@ -313,6 +1040,7 @@ describe('buildControlPlaneApp', () => {
calls.push({ kind: 'restart', origin, cookie });
return { status: 200 };
},
postBasebandRestart: async () => ({ status: 200 }),
postSystemReboot: async (origin, delaySeconds, cookie) => {
calls.push({ kind: 'reboot', origin, delaySeconds, cookie });
return { status: 200 };
@@ -386,6 +1114,7 @@ describe('buildControlPlaneApp', () => {
request: async () => ({ status: 200, headers: {}, body: '' }),
postNetworkRegisterAuto: async () => ({ status: 200 }),
postServiceRestart: async () => ({ status: 200 }),
postBasebandRestart: async () => ({ status: 200 }),
postSystemReboot: async () => ({ status: 200 }),
},
});
@@ -410,4 +1139,81 @@ describe('buildControlPlaneApp', () => {
expect(response.body).not.toContain('simadmin_session');
await app.close();
});
it('keeps a failed SMS node visible instead of pretending it has no messages', async () => {
const db = new Database(':memory:');
db.pragma('foreign_keys=ON');
migrateDatabase(db);
dbs.push(db);
let smsListCall = 0;
const app = buildControlPlaneApp({
db,
store: new Store(),
upstream: {
get: async () => ({ status: 200, headers: {}, body: '' }),
request: async (request) => {
if (request.url.endsWith('/api/auth/login'))
return {
status: 200,
headers: { 'set-cookie': 'simadmin_session=fleet-availability-session' },
body: '{"status":"success"}',
};
if (request.url.includes('/api/sms/list') && ++smsListCall === 1)
return { status: 502, headers: {}, body: '{"status":"error"}' };
if (request.url.includes('/api/sms/list'))
return {
status: 200,
headers: {},
body: JSON.stringify({
status: 'success',
data: {
messages: [
{
id: 1,
direction: 'incoming',
phone_number: '10086',
content: 'Healthy node',
timestamp: '2026-07-17T01:00:00.000Z',
status: 'received',
},
],
},
}),
};
return { status: 200, headers: {}, body: '{"status":"success","data":{}}' };
},
postNetworkRegisterAuto: async () => ({ status: 200 }),
postServiceRestart: async () => ({ status: 200 }),
postBasebandRestart: async () => ({ status: 200 }),
postSystemReboot: async () => ({ status: 200 }),
},
});
for (const [index, name] of ['Healthy', 'Broken'].entries()) {
const created = await app.inject({
method: 'POST',
url: '/api/v1/instances',
payload: {
name,
origin: `http://192.168.1.${40 + index}:8080`,
password: { action: 'set', password: '[REDACTED]' },
},
});
expect(created.statusCode).toBe(201);
}
const response = await app.inject({ method: 'GET', url: '/api/v1/fleet/messages' });
expect(response.statusCode).toBe(200);
const body = response.json();
expect(body.devices).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: 'Healthy', availability: 'online' }),
expect.objectContaining({ name: 'Broken', availability: 'unavailable' }),
]),
);
expect(body.messages).toEqual([expect.objectContaining({ content: 'Healthy node' })]);
expect(body.total).toBe(1);
await app.close();
});
});