mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-15 05:00:47 +08:00
fix: improve WhatsApp settings and isolate IM host authentication
Some checks are pending
CI / Test, build, and verify package (push) Waiting to run
Some checks are pending
CI / Test, build, and verify package (push) Waiting to run
This commit is contained in:
parent
bcbe85fdb1
commit
bf73b0c0af
28 changed files with 848 additions and 116 deletions
178
test/channels/whatsapp/access-grant-settings.test.mjs
Normal file
178
test/channels/whatsapp/access-grant-settings.test.mjs
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import React from 'react';
|
||||
import TestRenderer from 'react-test-renderer';
|
||||
|
||||
import { AccessGrantSettingsPage } from '../../../plugin-src/client/access-grant-settings.js';
|
||||
import { en, setImTranslator } from '../../../plugin-src/client/i18n.js';
|
||||
|
||||
const { act, create } = TestRenderer;
|
||||
const groupJid = '120363111111111111@g.us';
|
||||
const otherGroupJid = '120363222222222222@g.us';
|
||||
const ownerPhone = '8618111111111';
|
||||
const textOf = node => typeof node === 'string' || typeof node === 'number'
|
||||
? String(node) : node?.children?.map(textOf).join('') ?? '';
|
||||
const button = (root, text) => root.findAllByType('button').find(node => textOf(node) === text);
|
||||
const tab = (root, label) => root.findAllByProps({ role: 'tab' })
|
||||
.find(node => textOf(node).startsWith(label));
|
||||
async function flush() { for (let i = 0; i < 12; i += 1) await Promise.resolve(); }
|
||||
|
||||
function fixture() {
|
||||
return {
|
||||
version: 1, globalAdmins: [ownerPhone],
|
||||
directMembers: [{ phone: '8618222222222', canExecuteCommands: true }],
|
||||
groups: {
|
||||
[groupJid]: { title: 'Ops', agentPreset: 'netxops', admins: [], members: [] },
|
||||
[otherGroupJid]: { title: 'Support', admins: [], members: [] },
|
||||
},
|
||||
pending: [{ id: 'p_test01', kind: 'direct', phone: '8618444444444',
|
||||
createdAt: '2026-10-11T00:00:00.000Z', status: 'pending', notifyRefs: [] }],
|
||||
contacts: Array.from({ length: 35 }, (_, index) => ({
|
||||
phone: `861900000${String(index).padStart(4, '0')}`,
|
||||
pushName: index === 34 ? 'Alice' : `Contact ${index}`,
|
||||
scenes: ['direct'], groupJids: [], lids: [], lastSeenAt: '2026-10-11T00:00:00.000Z',
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async function mount(t, options = {}) {
|
||||
const calls = [];
|
||||
let savedGrant = fixture();
|
||||
let savedScope = 'user_in_chat';
|
||||
const rpcCall = async (endpoint, payload) => {
|
||||
calls.push({ endpoint, payload });
|
||||
if (endpoint === 'connection.status' && options.load) return options.load;
|
||||
if (endpoint === 'bot.access-grant.set') {
|
||||
if (options.failSave) throw new Error('Save rejected');
|
||||
savedGrant = payload.grant;
|
||||
}
|
||||
if (endpoint === 'bot.group-session-scope.set') savedScope = payload.groupSessionScope;
|
||||
return { ok: true, value: {
|
||||
bots: [{ botId: 'wa-test', accessGrant: savedGrant, groupSessionScope: savedScope }],
|
||||
agentPresetCatalog: { defaultId: 'netxops', items: [{ id: 'netxops' }, { id: 'safe' }] },
|
||||
} };
|
||||
};
|
||||
function Page() {
|
||||
const [accessGrant, setAccessGrant] = React.useState(savedGrant);
|
||||
return React.createElement(AccessGrantSettingsPage, {
|
||||
channel: 'whatsapp', account: {
|
||||
botId: 'wa-test', phone: ownerPhone, accessGrant, groupSessionScope: 'user_in_chat',
|
||||
}, rpcCall, onSaved: setAccessGrant,
|
||||
});
|
||||
}
|
||||
let renderer;
|
||||
await act(async () => { renderer = create(React.createElement(Page)); await flush(); });
|
||||
t.after(async () => { await act(async () => renderer.unmount()); });
|
||||
return { renderer, calls };
|
||||
}
|
||||
|
||||
test('access sections support keyboard navigation and preserve edits across sections', async t => {
|
||||
const { renderer, calls } = await mount(t);
|
||||
const root = renderer.root;
|
||||
assert.equal(root.findAllByProps({ role: 'tabpanel' }).filter(node => !node.props.hidden).length, 1);
|
||||
assert.equal(button(root, '保存分级访问设置').props.disabled, true);
|
||||
await act(async () => tab(root, '私聊与管理员').props.onKeyDown({ key: 'ArrowRight', preventDefault() {} }));
|
||||
assert.equal(tab(root, '群授权').props['aria-selected'], true);
|
||||
const preset = root.findAllByType('select').find(node => node.props['aria-label'] === '群 Agent Preset');
|
||||
await act(async () => preset.props.onChange({ target: { value: 'safe' } }));
|
||||
await act(async () => tab(root, '最近联系人').props.onClick());
|
||||
await act(async () => tab(root, '群授权').props.onClick());
|
||||
assert.equal(root.findAllByType('select').find(node => node.props['aria-label'] === '群 Agent Preset').props.value, 'safe');
|
||||
assert.match(textOf(root.findByProps({ className: 'dim-waAccessSaveState' })), /未保存/);
|
||||
assert.equal(calls.filter(call => call.endpoint !== 'connection.status').length, 0);
|
||||
});
|
||||
|
||||
test('saving retains the group preset and session scope when the parent updates its grant', async t => {
|
||||
const { renderer, calls } = await mount(t);
|
||||
const root = renderer.root;
|
||||
await act(async () => {
|
||||
root.findAllByType('select').find(node => node.props['aria-label'] === '群 Agent Preset')
|
||||
.props.onChange({ target: { value: 'safe' } });
|
||||
root.findAllByType('select').find(node => node.props.value === 'user_in_chat')
|
||||
.props.onChange({ target: { value: 'chat' } });
|
||||
});
|
||||
await act(async () => { root.findByType('form').props.onSubmit({ preventDefault() {} }); await flush(); });
|
||||
const grantCall = calls.find(call => call.endpoint === 'bot.access-grant.set');
|
||||
assert.equal(grantCall.payload.grant.groups[groupJid].agentPreset, 'safe');
|
||||
assert.deepEqual(calls.find(call => call.endpoint === 'bot.group-session-scope.set').payload,
|
||||
{ botId: 'wa-test', groupSessionScope: 'chat' });
|
||||
assert.ok(root.findAllByType('select').some(node => node.props.value === 'chat'));
|
||||
assert.equal(button(root, '保存分级访问设置').props.disabled, true);
|
||||
assert.match(textOf(root.findByProps({ className: 'dim-waAccessSaveState' })), /所有修改已保存/);
|
||||
});
|
||||
|
||||
test('unsaved edits block approval and group sync; discard restores the last saved configuration', async t => {
|
||||
const { renderer, calls } = await mount(t);
|
||||
const root = renderer.root;
|
||||
await act(async () => root.findAllByType('input').find(node => node.props.value === 'Ops')
|
||||
.props.onChange({ target: { value: 'Draft title' } }));
|
||||
assert.equal(button(root, '批准').props.disabled, true);
|
||||
assert.equal(button(root, '同步群名').props.disabled, true);
|
||||
await act(async () => { button(root, '批准').props.onClick(); button(root, '同步群名').props.onClick(); await flush(); });
|
||||
assert.equal(calls.filter(call => call.endpoint !== 'connection.status').length, 0);
|
||||
await act(async () => button(root, '撤销修改').props.onClick());
|
||||
assert.ok(root.findAllByType('input').some(node => node.props.value === 'Ops'));
|
||||
assert.equal(button(root, '批准').props.disabled, false);
|
||||
assert.equal(button(root, '同步群名').props.disabled, false);
|
||||
assert.equal(button(root, '保存分级访问设置').props.disabled, true);
|
||||
});
|
||||
|
||||
test('contact search reaches beyond the first thirty contacts and groups match names or JIDs', async t => {
|
||||
const { renderer } = await mount(t);
|
||||
const root = renderer.root;
|
||||
const contactTable = () => root.findByProps({ 'data-kind': 'contact' });
|
||||
assert.doesNotMatch(textOf(contactTable()), /Alice/);
|
||||
await act(async () => button(root, '显示更多联系人').props.onClick());
|
||||
assert.match(textOf(contactTable()), /Alice/);
|
||||
const contactSearch = root.findAllByType('input').find(node => node.props.placeholder === '搜索昵称、电话或群名称');
|
||||
await act(async () => contactSearch.props.onChange({ target: { value: 'alice' } }));
|
||||
assert.match(textOf(contactTable()), /Alice/);
|
||||
assert.doesNotMatch(textOf(contactTable()), /Contact 0/);
|
||||
const groupSearch = root.findAllByType('input').find(node => node.props.placeholder === '搜索群名称或 JID');
|
||||
await act(async () => groupSearch.props.onChange({ target: { value: 'Support' } }));
|
||||
assert.equal(root.findAllByType('details').length, 1);
|
||||
assert.match(textOf(root.findByType('details')), /Support/);
|
||||
await act(async () => groupSearch.props.onChange({ target: { value: groupJid } }));
|
||||
assert.match(textOf(root.findByType('details')), /Ops/);
|
||||
});
|
||||
|
||||
test('failed saves retain the draft, keep it dirty and expose the error', async t => {
|
||||
const { renderer } = await mount(t, { failSave: true });
|
||||
const root = renderer.root;
|
||||
await act(async () => root.findAllByType('input').find(node => node.props.value === 'Ops')
|
||||
.props.onChange({ target: { value: 'Unsaved title' } }));
|
||||
await act(async () => { root.findByType('form').props.onSubmit({ preventDefault() {} }); await flush(); });
|
||||
assert.equal(textOf(root.findByProps({ role: 'alert' })), 'Save rejected');
|
||||
assert.ok(root.findAllByType('input').some(node => node.props.value === 'Unsaved title'));
|
||||
assert.equal(button(root, '保存分级访问设置').props.disabled, false);
|
||||
});
|
||||
|
||||
test('phone suggestions filter names without matching every contact through an empty digit query', async t => {
|
||||
const { renderer } = await mount(t);
|
||||
const input = renderer.root.findAllByType('input').find(node => node.props.value === '8618222222222');
|
||||
await act(async () => {
|
||||
input.props.onFocus();
|
||||
input.props.onChange({ target: { value: 'Alice' } });
|
||||
});
|
||||
const suggestions = renderer.root.findByProps({ role: 'listbox' });
|
||||
assert.match(textOf(suggestions), /Alice/);
|
||||
assert.doesNotMatch(textOf(suggestions), /Contact 0/);
|
||||
});
|
||||
|
||||
test('access editing waits for status and new section labels translate into English', async t => {
|
||||
let resolveLoad;
|
||||
const load = new Promise(resolve => { resolveLoad = resolve; });
|
||||
setImTranslator(key => en[key] ?? key);
|
||||
t.after(() => setImTranslator(null));
|
||||
const { renderer, calls } = await mount(t, { load });
|
||||
assert.equal(renderer.root.findByProps({ className: 'dim-waAccessEditor' }).props.disabled, true);
|
||||
assert.doesNotMatch(textOf(renderer.root.findByProps({ role: 'tablist' })), /[\p{Script=Han}]/u);
|
||||
await act(async () => { renderer.root.findByType('form').props.onSubmit({ preventDefault() {} }); await flush(); });
|
||||
assert.equal(calls.length, 1);
|
||||
await act(async () => {
|
||||
resolveLoad({ ok: true, value: { bots: [{ botId: 'wa-test', accessGrant: fixture(), groupSessionScope: 'user_in_chat' }] } });
|
||||
await flush();
|
||||
});
|
||||
assert.equal(renderer.root.findByProps({ className: 'dim-waAccessEditor' }).props.disabled, false);
|
||||
assert.equal(textOf(renderer.root.findByProps({ className: 'dim-waAccessSaveState' })), 'All changes saved');
|
||||
});
|
||||
|
|
@ -73,11 +73,27 @@ test('WhatsApp account card uses the unified compact channel layout', () => {
|
|||
assert.match(markup, /class="dim-presetSelect"/);
|
||||
assert.match(markup, /默认模型/);
|
||||
assert.match(markup, /Agent Preset/);
|
||||
assert.match(markup, /会话默认设置/);
|
||||
assert.match(markup, /修改后自动保存/);
|
||||
assert.match(markup, /dwa-settingsGrid/);
|
||||
assert.match(markup, /<code>\/new<\/code>/);
|
||||
assert.match(markup, /aria-label="更多机器人设置"/);
|
||||
assert.doesNotMatch(markup, /仅自己模式(默认)|指定联系人模式|开放响应模式/);
|
||||
assert.match(markup, /role="status"[^>]*>测试消息已发送/);
|
||||
});
|
||||
|
||||
test('a connection check shows progress even when the last snapshot was connected', () => {
|
||||
const markup = renderToStaticMarkup(React.createElement(WhatsappAccountCard, {
|
||||
account: {
|
||||
botId: 'wa-checking', connected: true, state: 'connected',
|
||||
bot: { name: 'Bot', idMasked: '1650••••0123' }, health: {},
|
||||
}, busy: 'reconnect',
|
||||
}));
|
||||
assert.match(markup, /正在检查连接/);
|
||||
assert.doesNotMatch(markup, /运行正常/);
|
||||
assert.match(markup, /data-tone="warning"/);
|
||||
});
|
||||
|
||||
test('WhatsApp connection check requests a test message from the existing reconnect endpoint', async () => {
|
||||
const source = await readFile(new URL(
|
||||
'../../../plugin-src/client/channels/whatsapp/index.js',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue