Files
iofficeai--aionui/tests/unit/renderer/buildSendFailureError.test.ts
2026-07-13 13:20:22 +08:00

158 lines
6.0 KiB
TypeScript

/**
* @license
* Copyright 2025 AionUi (aionui.com)
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it } from 'vitest';
import { BackendHttpError } from '@/common/adapter/httpBridge';
import { buildSendFailureError } from '@/renderer/pages/conversation/platforms/acp/buildSendFailureError';
const httpError = (status: number, code: string, error: string, details?: unknown) =>
new BackendHttpError({
method: 'POST',
path: '/api/conversations/abc/messages',
status,
body: { success: false, code, error, details },
});
describe('buildSendFailureError', () => {
it('classifies 409 already-processing as AIONUI_CONVERSATION_BUSY (wait, not retry)', () => {
const err = httpError(409, 'CONFLICT', 'Conflict: Conversation is already processing a message');
const result = buildSendFailureError(err, 'Conflict: Conversation is already processing a message');
expect(result).toEqual({
message: 'Conflict: Conversation is already processing a message',
code: 'AIONUI_CONVERSATION_BUSY',
ownership: 'aionui',
detail: 'Conflict: Conversation is already processing a message',
retryable: false,
feedback_recommended: false,
resolution: { kind: 'wait_for_current_response' },
});
});
it('classifies 502 BAD_GATEWAY as UNKNOWN_UPSTREAM_ERROR (retryable)', () => {
const err = httpError(502, 'BAD_GATEWAY', 'Bad gateway: upstream timeout');
const result = buildSendFailureError(err, 'Bad gateway: upstream timeout');
expect(result.code).toBe('UNKNOWN_UPSTREAM_ERROR');
expect(result.ownership).toBe('unknown_upstream');
expect(result.retryable).toBe(true);
});
it('classifies ACP protocol not connected as USER_AGENT_DISCONNECTED', () => {
const err = httpError(502, 'BAD_GATEWAY', 'Bad gateway: ACP protocol is not connected.');
const result = buildSendFailureError(err, 'Bad gateway: ACP protocol is not connected.');
expect(result).toEqual({
message: 'Bad gateway: ACP protocol is not connected.',
code: 'USER_AGENT_DISCONNECTED',
ownership: 'user_agent',
detail: 'Bad gateway: ACP protocol is not connected.',
retryable: true,
feedback_recommended: false,
resolution: { kind: 'reconnect_agent', target: 'agent_settings' },
});
});
it('classifies ACP protocol not connected before generic BAD_GATEWAY', () => {
const err = httpError(502, 'BAD_GATEWAY', 'ACP protocol not connected');
const result = buildSendFailureError(err, 'ACP protocol not connected');
expect(result.code).toBe('USER_AGENT_DISCONNECTED');
expect(result.code).not.toBe('UNKNOWN_UPSTREAM_ERROR');
});
it('preserves workspace-path validation code as a structured non-retryable error', () => {
const err = httpError(
400,
'WORKSPACE_PATH_RUNTIME_UNAVAILABLE',
'Workspace path contains whitespace in one or more directory names and is no longer supported for send or warmup',
{ workspace_path: '/tmp/Archive ' }
);
const result = buildSendFailureError(
err,
'The existing workspace path "/tmp/Archive " is no longer supported for send or warmup.'
);
expect(result).toEqual({
message: 'The existing workspace path "/tmp/Archive " is no longer supported for send or warmup.',
code: 'WORKSPACE_PATH_RUNTIME_UNAVAILABLE',
ownership: 'aionui',
detail: 'The existing workspace path "/tmp/Archive " is no longer supported for send or warmup.',
workspacePath: '/tmp/Archive ',
retryable: false,
feedback_recommended: false,
});
});
it('falls back to AIONUI_INTERNAL_ERROR for non-conflict 409 (different message)', () => {
const err = httpError(409, 'CONFLICT', 'Conflict: WebSocket not connected; nothing to cancel');
const result = buildSendFailureError(err, 'Conflict: WebSocket not connected; nothing to cancel');
expect(result.code).toBe('AIONUI_INTERNAL_ERROR');
expect(result.retryable).toBe(true);
});
it('falls back to AIONUI_INTERNAL_ERROR for non-HTTP errors', () => {
const result = buildSendFailureError(new Error('boom'), 'boom');
expect(result.code).toBe('AIONUI_INTERNAL_ERROR');
expect(result.ownership).toBe('aionui');
expect(result.retryable).toBe(true);
});
it('preserves a redacted summary of the original error in the fallback branch', () => {
const original = Object.assign(new Error('connect ECONNREFUSED 127.0.0.1:8080'), { code: 'ECONNREFUSED' });
const result = buildSendFailureError(original, 'Something went wrong, please try again.');
expect(result.code).toBe('AIONUI_INTERNAL_ERROR');
expect(result.rawError).toEqual({
name: 'Error',
message: 'connect ECONNREFUSED 127.0.0.1:8080',
code: 'ECONNREFUSED',
stack: expect.any(String),
});
});
it('carries backend http diagnostics into the fallback rawError for unclassified HTTP errors', () => {
const err = httpError(409, 'CONFLICT', 'Conflict: WebSocket not connected; nothing to cancel');
const result = buildSendFailureError(err, 'Conflict: WebSocket not connected; nothing to cancel');
expect(result.code).toBe('AIONUI_INTERNAL_ERROR');
expect(result.rawError).toMatchObject({
name: 'BackendHttpError',
status: 409,
code: 'CONFLICT',
message: 'Conflict: WebSocket not connected; nothing to cancel',
});
});
it('redacts secrets from the fallback rawError summary', () => {
const original = new Error('auth failed for key sk-ant-api03-shouldNotLeak123456');
const result = buildSendFailureError(original, 'failed');
expect(result.rawError?.message).not.toContain('sk-ant-api03-shouldNotLeak123456');
expect(result.rawError?.message).toContain('[REDACTED_KEY]');
});
it('does not attach rawError to classified (non-fallback) branches', () => {
const err = httpError(502, 'BAD_GATEWAY', 'Bad gateway: upstream timeout');
const result = buildSendFailureError(err, 'Bad gateway: upstream timeout');
expect(result.code).toBe('UNKNOWN_UPSTREAM_ERROR');
expect(result.rawError).toBeUndefined();
});
});