feat(web): rebuild the console around the fused control plane
Render the Hub feature set in the existing Animal Island styling: fleet messages, notification centre, log centre, organisation panel, per-instance device module panels and the settings backup, connection and maintenance surfaces.
This commit is contained in:
@@ -0,0 +1,533 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
createFleetMessagesCenterApiDataSource,
|
||||
sanitizeFleetMessagesDeleteResult,
|
||||
sanitizeFleetMessagesSnapshot,
|
||||
sanitizeFleetOutboxFlushResult,
|
||||
sanitizeFleetOutboxPage,
|
||||
} from './fleet-messages-page.js';
|
||||
|
||||
function json(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const smsResponse = {
|
||||
data: {
|
||||
messages: [
|
||||
{
|
||||
id: 'm2',
|
||||
instanceId: 'device-1',
|
||||
instanceName: 'Modem A',
|
||||
direction: 'incoming',
|
||||
phoneNumber: '10086',
|
||||
content: '余额已更新',
|
||||
timestamp: '2026-07-29 12:00:00',
|
||||
status: 'received',
|
||||
transport: 'gsm',
|
||||
},
|
||||
{
|
||||
id: 'm1',
|
||||
instanceId: 'device-2',
|
||||
instanceName: 'Modem B',
|
||||
direction: 'outgoing',
|
||||
phoneNumber: '13900139000',
|
||||
content: 'Hello',
|
||||
timestamp: '2026-07-29T11:59:00.000Z',
|
||||
status: 'delivered',
|
||||
},
|
||||
],
|
||||
devices: [
|
||||
{ id: 'device-1', name: 'Modem A', availability: 'online' },
|
||||
{ id: 'device-2', name: 'Modem B', availability: 'unavailable' },
|
||||
],
|
||||
total: 42,
|
||||
},
|
||||
};
|
||||
|
||||
describe('Fleet message center', () => {
|
||||
it('loads aggregate SMS through the local control-plane endpoint', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>().mockResolvedValueOnce(json(smsResponse));
|
||||
const source = createFleetMessagesCenterApiDataSource(fetcher);
|
||||
|
||||
const snapshot = await source.load(new AbortController().signal, {
|
||||
limit: 24,
|
||||
offset: 0,
|
||||
search: '余额',
|
||||
});
|
||||
|
||||
expect(snapshot).toEqual({
|
||||
messages: [
|
||||
expect.objectContaining({
|
||||
id: 'm2',
|
||||
instanceName: 'Modem A',
|
||||
phoneNumber: '10086',
|
||||
transport: 'gsm',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: 'm1',
|
||||
instanceName: 'Modem B',
|
||||
transport: 'modem',
|
||||
}),
|
||||
],
|
||||
devices: [
|
||||
{ id: 'device-1', name: 'Modem A', availability: 'online' },
|
||||
{ id: 'device-2', name: 'Modem B', availability: 'unavailable' },
|
||||
],
|
||||
total: 42,
|
||||
});
|
||||
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||
expect(fetcher).toHaveBeenCalledWith(
|
||||
'/api/v1/fleet/messages?limit=24&offset=0&search=%E4%BD%99%E9%A2%9D',
|
||||
{
|
||||
method: 'GET',
|
||||
credentials: 'same-origin',
|
||||
headers: { accept: 'application/json' },
|
||||
signal: expect.any(AbortSignal),
|
||||
},
|
||||
);
|
||||
expect(fetcher).not.toHaveBeenCalledWith('/hub-api/messages', expect.anything());
|
||||
});
|
||||
|
||||
it('reads server-grouped conversations from the local Fleet endpoint', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>().mockResolvedValueOnce(
|
||||
json({
|
||||
conversations: [
|
||||
{
|
||||
instanceId: 'device-1',
|
||||
instanceName: 'Modem A',
|
||||
phoneNumber: '10086',
|
||||
messageCount: 7,
|
||||
incomingCount: 3,
|
||||
lastMessage: smsResponse.data.messages[0],
|
||||
},
|
||||
],
|
||||
total: 12,
|
||||
}),
|
||||
);
|
||||
const source = createFleetMessagesCenterApiDataSource(fetcher);
|
||||
|
||||
const page = await source.listConversations?.(new AbortController().signal, {
|
||||
limit: 40,
|
||||
offset: 40,
|
||||
search: '余额',
|
||||
instanceId: 'device-1',
|
||||
});
|
||||
|
||||
expect(page?.total).toBe(12);
|
||||
expect(page?.conversations[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
instanceId: 'device-1',
|
||||
instanceName: 'Modem A',
|
||||
phoneNumber: '10086',
|
||||
messageCount: 7,
|
||||
incomingCount: 3,
|
||||
}),
|
||||
);
|
||||
expect(fetcher).toHaveBeenCalledWith(
|
||||
'/api/v1/fleet/messages/conversations?limit=40&offset=40&search=%E4%BD%99%E9%A2%9D&instanceId=device-1',
|
||||
expect.objectContaining({ method: 'GET' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('sends the direction filter and reads back archive-wide counters', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>().mockResolvedValueOnce(
|
||||
json({
|
||||
conversations: [],
|
||||
total: 0,
|
||||
stats: { incoming: 18, outgoing: 5, total: 23 },
|
||||
}),
|
||||
);
|
||||
const source = createFleetMessagesCenterApiDataSource(fetcher);
|
||||
|
||||
const page = await source.listConversations?.(new AbortController().signal, {
|
||||
limit: 24,
|
||||
offset: 0,
|
||||
direction: 'outgoing',
|
||||
});
|
||||
|
||||
expect(page?.stats).toEqual({ incoming: 18, outgoing: 5, total: 23 });
|
||||
expect(fetcher).toHaveBeenCalledWith(
|
||||
'/api/v1/fleet/messages/conversations?limit=24&offset=0&direction=outgoing',
|
||||
expect.objectContaining({ method: 'GET' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('degrades to no counters when the stats block is unusable', async () => {
|
||||
const source = createFleetMessagesCenterApiDataSource(
|
||||
vi
|
||||
.fn<typeof fetch>()
|
||||
.mockResolvedValueOnce(
|
||||
json({
|
||||
conversations: [],
|
||||
total: 0,
|
||||
stats: { incoming: 'many', outgoing: 5, total: 23 },
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(json({ conversations: [], total: 0 })),
|
||||
);
|
||||
|
||||
await expect(
|
||||
source.listConversations?.(new AbortController().signal, { direction: 'incoming' }),
|
||||
).resolves.toMatchObject({ total: 0 });
|
||||
const page = await source.listConversations?.(new AbortController().signal, {});
|
||||
expect(page?.stats).toBeUndefined();
|
||||
});
|
||||
|
||||
it('fails closed when the conversation page is malformed', async () => {
|
||||
const source = createFleetMessagesCenterApiDataSource(
|
||||
vi.fn<typeof fetch>().mockResolvedValueOnce(
|
||||
json({
|
||||
conversations: [{ instanceId: 'device-1', phoneNumber: '10086' }],
|
||||
total: 1,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await expect(
|
||||
source.listConversations?.(new AbortController().signal, { limit: 40, offset: 0 }),
|
||||
).rejects.toThrow('Fleet conversation response is invalid.');
|
||||
});
|
||||
|
||||
it('posts a send action to the local Fleet endpoint', async () => {
|
||||
const fetcher = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockResolvedValueOnce(json({ sent: true, queued: false, instanceId: 'device-1' }));
|
||||
const source = createFleetMessagesCenterApiDataSource(fetcher);
|
||||
|
||||
await expect(
|
||||
source.send({
|
||||
instanceId: 'device-1',
|
||||
phoneNumber: '13900139000',
|
||||
content: 'Verified via Hub',
|
||||
}),
|
||||
).resolves.toEqual({ delivered: true });
|
||||
|
||||
expect(fetcher).toHaveBeenCalledWith('/api/v1/fleet/messages/send', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
instanceId: 'device-1',
|
||||
phoneNumber: '13900139000',
|
||||
content: 'Verified via Hub',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('posts batch delete items to the local Fleet endpoint', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>().mockResolvedValueOnce(
|
||||
json({
|
||||
requested: 2,
|
||||
deleted: 1,
|
||||
failed: 1,
|
||||
failures: [
|
||||
{
|
||||
instanceId: 'device-2',
|
||||
instanceName: 'Modem B',
|
||||
id: 'm1',
|
||||
code: 'UPSTREAM_FAILED',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const source = createFleetMessagesCenterApiDataSource(fetcher);
|
||||
|
||||
await expect(
|
||||
source.deleteMany({
|
||||
items: [
|
||||
{ instanceId: 'device-1', id: 'm2' },
|
||||
{ instanceId: 'device-2', id: 'm1' },
|
||||
],
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
requested: 2,
|
||||
deleted: 1,
|
||||
failed: 1,
|
||||
failures: [
|
||||
{
|
||||
instanceId: 'device-2',
|
||||
instanceName: 'Modem B',
|
||||
id: 'm1',
|
||||
code: 'UPSTREAM_FAILED',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(fetcher).toHaveBeenCalledWith('/api/v1/fleet/messages/delete', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
items: [
|
||||
{ instanceId: 'device-1', id: 'm2' },
|
||||
{ instanceId: 'device-2', id: 'm1' },
|
||||
],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('sanitizes aggregate messages and fails closed on invalid pages', () => {
|
||||
const snapshot = sanitizeFleetMessagesSnapshot(smsResponse);
|
||||
expect(snapshot?.messages).toHaveLength(2);
|
||||
expect(snapshot?.devices[1]).toEqual({
|
||||
id: 'device-2',
|
||||
name: 'Modem B',
|
||||
availability: 'unavailable',
|
||||
});
|
||||
expect(
|
||||
sanitizeFleetMessagesSnapshot({ data: { messages: [], devices: [], total: 1 } }),
|
||||
).toEqual({
|
||||
messages: [],
|
||||
devices: [],
|
||||
total: 1,
|
||||
});
|
||||
expect(
|
||||
sanitizeFleetMessagesSnapshot({ data: { messages: [], devices: [], total: -1 } }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
sanitizeFleetMessagesSnapshot({
|
||||
data: { messages: [{ id: 'x' }], devices: [], total: 1 },
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
sanitizeFleetMessagesSnapshot({
|
||||
data: {
|
||||
messages: [],
|
||||
devices: [{ id: 'device-1', name: 'Modem A' }],
|
||||
total: 0,
|
||||
},
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('sanitizes delete results and rejects inconsistent failure summaries', () => {
|
||||
expect(
|
||||
sanitizeFleetMessagesDeleteResult({
|
||||
requested: 2,
|
||||
deleted: 1,
|
||||
failed: 1,
|
||||
failures: [
|
||||
{
|
||||
instanceId: 'device-2',
|
||||
instanceName: 'Modem B',
|
||||
id: 'm1',
|
||||
code: 'UPSTREAM_FAILED',
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toEqual({
|
||||
requested: 2,
|
||||
deleted: 1,
|
||||
failed: 1,
|
||||
failures: [
|
||||
{
|
||||
instanceId: 'device-2',
|
||||
instanceName: 'Modem B',
|
||||
id: 'm1',
|
||||
code: 'UPSTREAM_FAILED',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(
|
||||
sanitizeFleetMessagesDeleteResult({
|
||||
requested: 2,
|
||||
deleted: 1,
|
||||
failed: 0,
|
||||
failures: [],
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
sanitizeFleetMessagesDeleteResult({
|
||||
requested: 1,
|
||||
deleted: 1,
|
||||
failed: 0,
|
||||
failures: [{ instanceId: 'device-1' }],
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects non-2xx responses and malformed send replies', async () => {
|
||||
await expect(
|
||||
createFleetMessagesCenterApiDataSource(
|
||||
vi.fn<typeof fetch>().mockResolvedValueOnce(json({}, 500)),
|
||||
).load(new AbortController().signal),
|
||||
).rejects.toThrow('Fleet message request failed (500).');
|
||||
await expect(
|
||||
createFleetMessagesCenterApiDataSource(
|
||||
vi.fn<typeof fetch>().mockResolvedValueOnce(json({ ok: true })),
|
||||
).send({
|
||||
instanceId: 'device-1',
|
||||
phoneNumber: '13900139000',
|
||||
content: 'Hello',
|
||||
}),
|
||||
).rejects.toThrow('Fleet message send response is invalid.');
|
||||
await expect(
|
||||
createFleetMessagesCenterApiDataSource(
|
||||
vi.fn<typeof fetch>().mockResolvedValueOnce(json({ requested: 1 })),
|
||||
).deleteMany({
|
||||
items: [{ instanceId: 'device-1', id: 'm1' }],
|
||||
}),
|
||||
).rejects.toThrow('Fleet message delete response is invalid.');
|
||||
});
|
||||
});
|
||||
|
||||
const outboxItem = {
|
||||
id: 'q-1',
|
||||
instanceId: 'device-2',
|
||||
instanceName: 'Modem B',
|
||||
phoneNumber: '13900139000',
|
||||
content: 'Queued while offline',
|
||||
status: 'queued',
|
||||
attempts: 2,
|
||||
maxAttempts: 24,
|
||||
lastError: 'DEVICE_OFFLINE',
|
||||
availableAt: '2026-07-29T12:05:00.000Z',
|
||||
sentAt: '',
|
||||
createdAt: '2026-07-29T12:00:00.000Z',
|
||||
updatedAt: '2026-07-29T12:04:00.000Z',
|
||||
};
|
||||
|
||||
describe('Fleet offline send queue', () => {
|
||||
it('reports a queued send with its queue id', async () => {
|
||||
const fetcher = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockResolvedValueOnce(
|
||||
json({ sent: false, queued: true, instanceId: 'device-2', queueId: 'q-1' }),
|
||||
);
|
||||
const source = createFleetMessagesCenterApiDataSource(fetcher);
|
||||
|
||||
await expect(
|
||||
source.send({
|
||||
instanceId: 'device-2',
|
||||
phoneNumber: '13900139000',
|
||||
content: 'Queued while offline',
|
||||
}),
|
||||
).resolves.toEqual({ delivered: false, queueId: 'q-1' });
|
||||
});
|
||||
|
||||
it('rejects a send reply without the queued flag', async () => {
|
||||
const source = createFleetMessagesCenterApiDataSource(
|
||||
vi.fn<typeof fetch>().mockResolvedValueOnce(json({ sent: true })),
|
||||
);
|
||||
|
||||
await expect(
|
||||
source.send({ instanceId: 'device-1', phoneNumber: '13900139000', content: 'Hello' }),
|
||||
).rejects.toThrow('Fleet message send response is invalid.');
|
||||
});
|
||||
|
||||
it('reads the queue from the local Fleet endpoint', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>().mockResolvedValueOnce(
|
||||
json({
|
||||
items: [outboxItem],
|
||||
total: 3,
|
||||
summary: { queued: 2, sending: 0, failed: 1, sent: 9 },
|
||||
}),
|
||||
);
|
||||
const source = createFleetMessagesCenterApiDataSource(fetcher);
|
||||
|
||||
const page = await source.listOutbox?.(new AbortController().signal, { status: 'open' });
|
||||
|
||||
expect(page?.total).toBe(3);
|
||||
expect(page?.items[0]).toEqual(expect.objectContaining({ id: 'q-1', attempts: 2 }));
|
||||
expect(page?.summary).toEqual({ queued: 2, sending: 0, failed: 1, sent: 9 });
|
||||
expect(fetcher).toHaveBeenCalledWith(
|
||||
'/api/v1/fleet/messages/outbox?status=open&limit=10&offset=0',
|
||||
expect.objectContaining({ method: 'GET' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('cancels, retries and flushes the queue through local Fleet routes', async () => {
|
||||
const fetcher = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockResolvedValueOnce(json({ ...outboxItem, status: 'cancelled' }))
|
||||
.mockResolvedValueOnce(json({ ...outboxItem, attempts: 0 }))
|
||||
.mockResolvedValueOnce(
|
||||
json({ attempted: 2, delivered: 1, deferred: 1, failed: 0, remaining: 0 }),
|
||||
);
|
||||
const source = createFleetMessagesCenterApiDataSource(fetcher);
|
||||
|
||||
await expect(source.cancelOutbox?.('q-1')).resolves.toEqual(
|
||||
expect.objectContaining({ id: 'q-1', status: 'cancelled' }),
|
||||
);
|
||||
await expect(source.retryOutbox?.('q-1')).resolves.toEqual(
|
||||
expect.objectContaining({ id: 'q-1', status: 'queued', attempts: 0 }),
|
||||
);
|
||||
await expect(source.flushOutbox?.(new AbortController().signal)).resolves.toEqual({
|
||||
attempted: 2,
|
||||
delivered: 1,
|
||||
deferred: 1,
|
||||
failed: 0,
|
||||
remaining: 0,
|
||||
});
|
||||
|
||||
expect(fetcher.mock.calls[0]?.[0]).toBe('/api/v1/fleet/messages/outbox/q-1/cancel');
|
||||
expect(fetcher.mock.calls[1]?.[0]).toBe('/api/v1/fleet/messages/outbox/q-1/retry');
|
||||
expect(fetcher.mock.calls[2]?.[0]).toBe('/api/v1/fleet/messages/outbox/flush');
|
||||
});
|
||||
|
||||
it('refuses to build a url from an unsafe queue id', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>();
|
||||
const source = createFleetMessagesCenterApiDataSource(fetcher);
|
||||
|
||||
await expect(source.retryOutbox?.('../../etc/passwd')).rejects.toThrow(
|
||||
'Fleet send queue id is invalid.',
|
||||
);
|
||||
expect(fetcher).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Fleet send queue sanitizers', () => {
|
||||
it('freezes a valid queue page and fails closed otherwise', () => {
|
||||
const page = sanitizeFleetOutboxPage({
|
||||
items: [outboxItem],
|
||||
total: 1,
|
||||
summary: { queued: 1, sending: 0, failed: 0, sent: 0 },
|
||||
});
|
||||
expect(page?.items[0]?.instanceName).toBe('Modem B');
|
||||
expect(Object.isFrozen(page)).toBe(true);
|
||||
expect(Object.isFrozen(page?.items)).toBe(true);
|
||||
|
||||
expect(sanitizeFleetOutboxPage({ items: [], total: 0 })).toEqual({
|
||||
items: [],
|
||||
total: 0,
|
||||
summary: { queued: 0, sending: 0, failed: 0, sent: 0 },
|
||||
});
|
||||
expect(
|
||||
sanitizeFleetOutboxPage({ items: [{ ...outboxItem, status: 'exploded' }], total: 1 }),
|
||||
).toBeNull();
|
||||
expect(sanitizeFleetOutboxPage({ items: [], total: 'many' })).toBeNull();
|
||||
expect(sanitizeFleetOutboxPage({ items: {}, total: 0 })).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects a flush result whose parts exceed the attempt count', () => {
|
||||
expect(
|
||||
sanitizeFleetOutboxFlushResult({
|
||||
attempted: 1,
|
||||
delivered: 1,
|
||||
deferred: 1,
|
||||
failed: 0,
|
||||
remaining: 0,
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
sanitizeFleetOutboxFlushResult({
|
||||
attempted: 2,
|
||||
delivered: 1,
|
||||
deferred: 1,
|
||||
failed: 0,
|
||||
remaining: 3,
|
||||
}),
|
||||
).toEqual({ attempted: 2, delivered: 1, deferred: 1, failed: 0, remaining: 3 });
|
||||
expect(sanitizeFleetOutboxFlushResult({ attempted: 1 })).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user