feat(sms): add real instance message workflow
This commit is contained in:
@@ -1,215 +1,58 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, render, screen, within } from '@testing-library/react';
|
||||
import { cleanup, render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { InstanceContext } from '../app-shell.js';
|
||||
import {
|
||||
MessagesModule,
|
||||
type MessagesDataSource,
|
||||
type MessagesSnapshot,
|
||||
} from './messages-module.js';
|
||||
import { MessagesModule, type MessagesDataSource } from './messages-module.js';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const owner: InstanceContext = {
|
||||
const instance: InstanceContext = {
|
||||
id: 'alpha',
|
||||
name: 'Alpha',
|
||||
origin: 'https://alpha.example',
|
||||
status: 'online',
|
||||
authentication: 'authenticated',
|
||||
freshness: 'fresh',
|
||||
origin: 'http://192.168.1.2',
|
||||
status: 'unknown',
|
||||
authentication: 'unknown',
|
||||
freshness: 'unknown',
|
||||
};
|
||||
afterEach(cleanup);
|
||||
|
||||
const snapshot: MessagesSnapshot = {
|
||||
observedAt: '2026-07-17T12:00:00Z',
|
||||
sms: {
|
||||
total: 42,
|
||||
inbound: 25,
|
||||
outbound: 17,
|
||||
unread: 3,
|
||||
failed: 2,
|
||||
queued: 1,
|
||||
lastActivityAt: '2026-07-17T11:55:00Z',
|
||||
// Deliberate excess fields: payloads and recipient data must never reach the DOM.
|
||||
body: 'private message body',
|
||||
content: 'private message content',
|
||||
recipient: '+15550199',
|
||||
recipientCredential: 'secret-recipient-token',
|
||||
},
|
||||
devices: [
|
||||
{
|
||||
deviceId: 'modem-1',
|
||||
label: 'Primary modem',
|
||||
state: 'online',
|
||||
total: 30,
|
||||
inbound: 18,
|
||||
outbound: 12,
|
||||
unread: 2,
|
||||
failed: 1,
|
||||
queued: 0,
|
||||
lastActivityAt: '2026-07-17T11:54:00Z',
|
||||
body: 'device body must not render',
|
||||
phoneNumber: '+15550123',
|
||||
password: 'device credential',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function deferredSource() {
|
||||
let resolve!: (value: MessagesSnapshot) => void;
|
||||
let reject!: (reason: unknown) => void;
|
||||
const load = vi.fn<MessagesDataSource['load']>(
|
||||
(_instanceId, _signal) =>
|
||||
new Promise((done, fail) => {
|
||||
void _instanceId;
|
||||
void _signal;
|
||||
resolve = done;
|
||||
reject = fail;
|
||||
describe('MessagesModule', () => {
|
||||
it('shows the real message list including content and never exposes PDU', async () => {
|
||||
const dataSource: MessagesDataSource = {
|
||||
load: vi.fn().mockResolvedValue({
|
||||
messages: [
|
||||
{
|
||||
id: '32',
|
||||
direction: 'incoming',
|
||||
phoneNumber: '10086',
|
||||
content: '余额提醒',
|
||||
timestamp: '2026-07-18 09:09:20',
|
||||
status: 'received',
|
||||
pdu: 'secret-pdu',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
return {
|
||||
source: { load },
|
||||
load,
|
||||
resolve: (value: MessagesSnapshot) => resolve(value),
|
||||
reject: (reason: unknown) => reject(reason),
|
||||
};
|
||||
}
|
||||
|
||||
describe('Messages isolated read module', () => {
|
||||
it('loads only through the injected exact-owner source and renders aggregate SMS/device metadata', async () => {
|
||||
const pending = deferredSource();
|
||||
render(<MessagesModule instance={owner} dataSource={pending.source} />);
|
||||
|
||||
expect(screen.getByRole('status', { name: '消息加载状态' })).toBeTruthy();
|
||||
expect(pending.load).toHaveBeenCalledWith('alpha', expect.any(AbortSignal));
|
||||
pending.resolve(snapshot);
|
||||
|
||||
const sms = await screen.findByRole('region', { name: '短信汇总' });
|
||||
expect(within(sms).getByText('42')).toBeTruthy();
|
||||
expect(within(sms).getByText('2026-07-17T11:55:00Z')).toBeTruthy();
|
||||
const devices = screen.getByRole('region', { name: '设备消息汇总' });
|
||||
expect(within(devices).getByText('modem-1')).toBeTruthy();
|
||||
expect(within(devices).getByText('Primary modem')).toBeTruthy();
|
||||
expect(within(devices).getByText('在线')).toBeTruthy();
|
||||
expect(within(devices).queryByText('online')).toBeNull();
|
||||
expect(screen.getByText(/观测时间:2026-07-17T12:00:00Z/)).toBeTruthy();
|
||||
send: vi.fn(),
|
||||
};
|
||||
render(<MessagesModule instance={instance} dataSource={dataSource} />);
|
||||
expect(await screen.findByText('余额提醒')).toBeTruthy();
|
||||
expect(screen.getByText(/收到 · 10086/)).toBeTruthy();
|
||||
expect(document.body.textContent).not.toContain('secret-pdu');
|
||||
});
|
||||
|
||||
it('uses an explicit safe allowlist and exposes no bodies, content, recipients, credentials, or write actions', async () => {
|
||||
render(<MessagesModule instance={owner} dataSource={{ load: async () => snapshot }} />);
|
||||
expect(await screen.findByText('Primary modem')).toBeTruthy();
|
||||
const text = document.body.textContent ?? '';
|
||||
for (const secret of [
|
||||
'private message body',
|
||||
'private message content',
|
||||
'+15550199',
|
||||
'secret-recipient-token',
|
||||
'device body must not render',
|
||||
'+15550123',
|
||||
'device credential',
|
||||
]) {
|
||||
expect(text).not.toContain(secret);
|
||||
}
|
||||
expect(screen.queryByText(/recipient|phone number|password/i)).toBeNull();
|
||||
expect(screen.queryByRole('button')).toBeNull();
|
||||
expect(screen.getByText(/仅显示汇总元数据/i)).toBeTruthy();
|
||||
expect(screen.getByText(/此只读模块不支持发送、删除或修改消息/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('does not invent an endpoint when no source is injected', () => {
|
||||
render(<MessagesModule instance={owner} />);
|
||||
expect(screen.getByRole('status', { name: '消息不可用' }).textContent).toMatch(
|
||||
/没有可用的安全消息只读数据源.*未签订契约的生产端点/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('requires the exact owner to remain authenticated and clears prior owner data', async () => {
|
||||
const load = vi.fn<MessagesDataSource['load']>().mockResolvedValue(snapshot);
|
||||
const { rerender } = render(<MessagesModule instance={owner} dataSource={{ load }} />);
|
||||
expect(await screen.findByText('Primary modem')).toBeTruthy();
|
||||
|
||||
rerender(
|
||||
<MessagesModule
|
||||
instance={{ ...owner, id: 'bravo', name: 'Bravo', authentication: 'auth-required' }}
|
||||
dataSource={{ load }}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole('alert').textContent).toMatch(/需要先完成认证/i);
|
||||
expect(screen.queryByText('Primary modem')).toBeNull();
|
||||
expect(load).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('uses a fixed safe error and retries without exposing rejection details', async () => {
|
||||
it('provides an intentional send form and submits exactly once', async () => {
|
||||
const user = userEvent.setup();
|
||||
const load = vi
|
||||
.fn<MessagesDataSource['load']>()
|
||||
.mockRejectedValueOnce(new Error('secret URL and recipient credential'))
|
||||
.mockResolvedValueOnce(snapshot);
|
||||
render(<MessagesModule instance={owner} dataSource={{ load }} />);
|
||||
|
||||
const alert = await screen.findByRole('alert');
|
||||
expect(alert.textContent).toContain('无法加载消息数据。');
|
||||
expect(alert.textContent).not.toContain('secret URL');
|
||||
await user.click(screen.getByRole('button', { name: '重试加载消息' }));
|
||||
expect(await screen.findByText('Primary modem')).toBeTruthy();
|
||||
expect(load).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('retains only the same owner last-good snapshot during refresh and after refresh failure', async () => {
|
||||
const user = userEvent.setup();
|
||||
const load = vi
|
||||
.fn<MessagesDataSource['load']>()
|
||||
.mockResolvedValueOnce(snapshot)
|
||||
.mockRejectedValueOnce(new Error('unsafe detail'))
|
||||
.mockResolvedValueOnce({ ...snapshot, devices: [{ label: 'Replacement modem', total: 4 }] });
|
||||
const source = { load };
|
||||
const { rerender } = render(
|
||||
<MessagesModule instance={owner} dataSource={source} refreshSignal={0} />,
|
||||
);
|
||||
expect(await screen.findByText('Primary modem')).toBeTruthy();
|
||||
|
||||
rerender(<MessagesModule instance={owner} dataSource={source} refreshSignal={1} />);
|
||||
expect(screen.getByText('Primary modem')).toBeTruthy();
|
||||
expect((await screen.findByRole('alert')).textContent).toMatch(/正在显示上次已知/i);
|
||||
expect(screen.getByText('Primary modem')).toBeTruthy();
|
||||
await user.click(screen.getByRole('button', { name: '重试加载消息' }));
|
||||
expect(await screen.findByText('Replacement modem')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('aborts replaced reads and fences late responses from another owner', async () => {
|
||||
const alpha = deferredSource();
|
||||
const bravo = deferredSource();
|
||||
const load = vi.fn<MessagesDataSource['load']>((instanceId, signal) =>
|
||||
instanceId === 'alpha'
|
||||
? alpha.source.load(instanceId, signal)
|
||||
: bravo.source.load(instanceId, signal),
|
||||
);
|
||||
const source = { load };
|
||||
const { rerender } = render(<MessagesModule instance={owner} dataSource={source} />);
|
||||
const alphaSignal = load.mock.calls[0]?.[1];
|
||||
|
||||
rerender(
|
||||
<MessagesModule instance={{ ...owner, id: 'bravo', name: 'Bravo' }} dataSource={source} />,
|
||||
);
|
||||
expect(alphaSignal?.aborted).toBe(true);
|
||||
expect(screen.queryByText('Primary modem')).toBeNull();
|
||||
alpha.resolve(snapshot);
|
||||
bravo.resolve({ ...snapshot, devices: [{ label: 'Bravo modem', total: 9 }] });
|
||||
expect(await screen.findByText('Bravo modem')).toBeTruthy();
|
||||
expect(screen.queryByText('Primary modem')).toBeNull();
|
||||
});
|
||||
|
||||
it('marks owner-declared stale data while preserving aggregates', async () => {
|
||||
render(
|
||||
<MessagesModule
|
||||
instance={{ ...owner, freshness: 'stale' }}
|
||||
dataSource={{ load: async () => snapshot }}
|
||||
/>,
|
||||
);
|
||||
expect((await screen.findByRole('status', { name: '消息数据新鲜度' })).textContent).toMatch(
|
||||
/可能已过期/i,
|
||||
);
|
||||
expect(screen.getByText('Primary modem')).toBeTruthy();
|
||||
const send = vi.fn().mockResolvedValue(undefined);
|
||||
const dataSource: MessagesDataSource = {
|
||||
load: vi.fn().mockResolvedValue({ messages: [] }),
|
||||
send,
|
||||
};
|
||||
render(<MessagesModule instance={instance} dataSource={dataSource} />);
|
||||
await user.type(screen.getByRole('textbox', { name: '手机号' }), '10086');
|
||||
await user.type(screen.getByRole('textbox', { name: '短信内容' }), 'CXLL');
|
||||
await user.click(screen.getByRole('button', { name: '发送短信' }));
|
||||
expect(send).toHaveBeenCalledTimes(1);
|
||||
expect(send).toHaveBeenCalledWith('alpha', { phoneNumber: '10086', content: 'CXLL' });
|
||||
expect((await screen.findByRole('status')).textContent).toContain('短信已提交发送');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user