feat(web): add messages and calls module slices

This commit is contained in:
chick
2026-07-17 20:27:53 +08:00
parent d0fd943082
commit 0c84e8c88e
6 changed files with 856 additions and 0 deletions
@@ -0,0 +1,215 @@
// @vitest-environment jsdom
import { cleanup, render, screen, within } 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';
afterEach(cleanup);
const owner: InstanceContext = {
id: 'alpha',
name: 'Alpha',
origin: 'https://alpha.example',
status: 'online',
authentication: 'authenticated',
freshness: 'fresh',
};
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;
}),
);
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: 'Messages loading status' })).toBeTruthy();
expect(pending.load).toHaveBeenCalledWith('alpha', expect.any(AbortSignal));
pending.resolve(snapshot);
const sms = await screen.findByRole('region', { name: 'SMS aggregate' });
expect(within(sms).getByText('42')).toBeTruthy();
expect(within(sms).getByText('2026-07-17T11:55:00Z')).toBeTruthy();
const devices = screen.getByRole('region', { name: 'Device message aggregates' });
expect(within(devices).getByText('modem-1')).toBeTruthy();
expect(within(devices).getByText('Primary modem')).toBeTruthy();
expect(screen.getByText(/Observed 2026-07-17T12:00:00Z/)).toBeTruthy();
});
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(/aggregate metadata only/i)).toBeTruthy();
expect(
screen.getByText(/sending, deleting, and changing messages are unavailable/i),
).toBeTruthy();
});
it('does not invent an endpoint when no source is injected', () => {
render(<MessagesModule instance={owner} />);
expect(screen.getByRole('status', { name: 'Messages unavailable' }).textContent).toMatch(
/no safe messages read data source.*uncontracted production endpoint/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(/authentication is required/i);
expect(screen.queryByText('Primary modem')).toBeNull();
expect(load).toHaveBeenCalledTimes(1);
});
it('uses a fixed safe error and retries without exposing rejection details', 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('Messages data could not be loaded.');
expect(alert.textContent).not.toContain('secret URL');
await user.click(screen.getByRole('button', { name: 'Retry loading Messages' }));
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(/showing the last known/i);
expect(screen.getByText('Primary modem')).toBeTruthy();
await user.click(screen.getByRole('button', { name: 'Retry loading Messages' }));
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: 'Messages freshness' })).textContent).toMatch(
/stale/i,
);
expect(screen.getByText('Primary modem')).toBeTruthy();
});
});