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.
534 lines
16 KiB
TypeScript
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();
|
|
});
|
|
});
|