Merge paired WeChat binding and compound unbinding workflow
This commit is contained in:
commit
1bf831ef97
10 files changed
+2372
-769
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -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' } } }));
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user