Merge paired WeChat binding and compound unbinding workflow

This commit is contained in:
鲨鱼辣椒 committed 2026-10-13 20:02:12 +08:00
commit 1bf831ef97
10 files changed
+2372 -769

No files matched your search

File diff suppressed because it is too large. Load diff
+62
View File
@@ -592,6 +592,68 @@ describe('Main cloud Agents boundary', () => {
.toBe('/api/makelore/channel-operations/op-route');
});
it('limits unified WeChat unbinding to its owned account and revision contract', () => {
const plan = operationPlan({ operation: 'unbindChannelAccount', input: {
channel_account_id: 'wechat-1', operation_id: input.operation_id, expected_revision: 2,
target_agent_slug: draft.slug, access_token: 'private',
} });
expect(plan.path).toBe('/api/makelore/channel-accounts/wechat-1/unbind');
expect(plan.method).toBe('POST');
expect(plan.body).toEqual({ operation_id: input.operation_id, expected_revision: 2 });
expect(plan.project({ channel: { id: 'wechat-1', desired_state: 'paused', sync_state: 'ready', access_token: 'private' },
unbound: true, disconnected: true, wechat: { status: 'empty', token: 'private' } })).toEqual({
channel: { id: 'wechat-1', desired_state: 'paused', sync_state: 'ready' },
unbound: true, disconnected: true, wechat: { status: 'empty' },
});
for (const invalid of [
{ operation_id: input.operation_id, expected_revision: 2 },
{ channel_account_id: 'wechat-1', expected_revision: 2 },
{ channel_account_id: 'wechat-1', operation_id: input.operation_id },
{ channel_account_id: '../other', operation_id: input.operation_id, expected_revision: 2 },
]) expect(() => operationPlan({ operation: 'unbindChannelAccount', input: invalid })).toThrow();
});
it.each([
{ health: 'connected' }, { enabled: true }, { target_agent_slug: draft.slug }, { id: 'another-account' },
])('keeps unified unbinding pending when a success response contains unfinished account state %o', async unfinished => {
const channel = { id: 'wechat-1', enabled: false, target_agent_slug: null, health: 'login_required', ...unfinished };
const fetchImpl = vi.fn().mockResolvedValueOnce(session())
.mockResolvedValueOnce(json({ channel, unbound: true, disconnected: true }));
const module = moduleFor(fetchImpl);
const original = { channel_account_id: 'wechat-1', operation_id: input.operation_id, expected_revision: 2 };
await expect(module.execute({ operation: 'unbindChannelAccount', input: original })).rejects.toMatchObject({
code: 'channel_operation_unconfirmed', commandOutcome: 'unknown',
});
expect((await module.recovery()).pending).toEqual([expect.objectContaining({
operation: 'unbindChannelAccount', input: original,
})]);
});
it('requires compound completion and retries the same unbind intent after a partial success response', async () => {
const channel = { id: 'wechat-1', enabled: false, target_agent_slug: null, health: 'login_required' };
const fetchImpl = vi.fn().mockResolvedValueOnce(session())
.mockResolvedValueOnce(json({ channel, disconnected: true }))
.mockResolvedValueOnce(json({ channel, unbound: true, disconnected: true }));
const module = moduleFor(fetchImpl);
const original = { channel_account_id: 'wechat-1', operation_id: input.operation_id, expected_revision: 2 };
await expect(module.execute({ operation: 'unbindChannelAccount', input: original })).rejects.toMatchObject({
code: 'channel_operation_unconfirmed',
});
const pending = (await module.recovery()).pending[0];
expect(await module.resolvePending({ id: pending.id })).toMatchObject({
operation: 'unbindChannelAccount', input: original, result: { channel, unbound: true, disconnected: true },
});
expect((await module.recovery()).pending).toEqual([]);
expect(fetchImpl.mock.calls.slice(1).map(call => [call[0], JSON.parse(call[1].body)])).toEqual([
['https://agents.example.test/api/makelore/channel-accounts/wechat-1/unbind', {
operation_id: input.operation_id, expected_revision: 2,
}],
['https://agents.example.test/api/makelore/channel-accounts/wechat-1/unbind', {
operation_id: input.operation_id, expected_revision: 2,
}],
]);
});
it('recovers a completed WeChat verification from its receipt without replaying the code', async () => {
const fetchImpl = vi.fn().mockResolvedValueOnce(session()).mockRejectedValueOnce(new Error('lost response'))
.mockResolvedValueOnce(json({ operation_id: input.operation_id, status: 'completed', result: { channel: { id: 'channel-1' } } }));
+15
View File
@@ -184,6 +184,21 @@ it('offers only explicit discard for retired operations and explains the retired
expect(api.resolvePending).toHaveBeenCalledWith(retired.id, false);
});
it('keeps a channel operation visibly pending without claiming a confirmed result', async () => {
const operation: CloudPendingOperation = { id: 'unbind:original', operation: 'unbindChannelAccount',
input: { channel_account_id: 'wechat-1', operation_id: 'original-unbind', expected_revision: 2 },
created_at: '2026-10-13T04:00:00Z' };
api.resolvePending.mockResolvedValue({ operation: operation.operation, pending: true, result: { status: 'pending' } });
render(<CloudRecovery pending={[operation]} onResolved={resolved} />);
const dialog = await openRecords();
expect(within(dialog).getByRole('article', { name: '解除微信绑定' })).toBeVisible();
fireEvent.click(within(dialog).getByRole('button', { name: '再试一次' }));
expect(await within(dialog).findByRole('status')).toHaveTextContent('这次操作还在确认中');
expect(within(dialog).queryByText(/结果已确认/)).not.toBeInTheDocument();
expect(within(dialog).getByRole('article', { name: '解除微信绑定' })).toBeVisible();
expect(resolved).toHaveBeenCalledOnce();
});
it('renders no entry or dialog when there are no pending records or retained results', () => {
render(<CloudRecovery pending={[]} onResolved={resolved} />);
expect(screen.queryByRole('button')).not.toBeInTheDocument();