Files
multi-simadmin/apps/web/src/fleet/fleet-messages-page.test.ts
T
chick a75badeb58 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.
2026-09-05 18:53:04 +08:00

534 lines
16 KiB
TypeScript

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();
});
});