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().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().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().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() .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().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() .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().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().mockResolvedValueOnce(json({}, 500)), ).load(new AbortController().signal), ).rejects.toThrow('Fleet message request failed (500).'); await expect( createFleetMessagesCenterApiDataSource( vi.fn().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().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() .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().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().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() .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(); 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(); }); });