393 lines
9.8 KiB
TypeScript
393 lines
9.8 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 AionUi (aionui.com)
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*
|
|
* Unit tests for common/adapter/apiModelMapper.ts (T1 in N3 test checklist).
|
|
* Tests model transformation between frontend and backend formats.
|
|
*/
|
|
|
|
import { describe, it, expect } from 'vitest';
|
|
import {
|
|
toApiModel,
|
|
toApiModelOptional,
|
|
fromApiModel,
|
|
fromApiConversation,
|
|
fromApiPaginatedConversations,
|
|
buildCreateConversationBody,
|
|
type ApiProviderWithModel,
|
|
} from '@/common/adapter/apiModelMapper';
|
|
import type { TProviderWithModel } from '@/common/config/storage';
|
|
|
|
describe('apiModelMapper', () => {
|
|
describe('toApiModel', () => {
|
|
it('maps required fields from frontend to backend format', () => {
|
|
const input: TProviderWithModel = {
|
|
id: 'openai',
|
|
use_model: 'gpt-5',
|
|
platform: 'openai',
|
|
name: 'x',
|
|
base_url: 'y',
|
|
api_key: 'z',
|
|
};
|
|
|
|
const result = toApiModel(input);
|
|
|
|
expect(result).toEqual({
|
|
provider_id: 'openai',
|
|
model: 'gpt-5',
|
|
});
|
|
// Ensure no extra fields
|
|
expect(Object.keys(result)).toEqual(['provider_id', 'model']);
|
|
});
|
|
});
|
|
|
|
describe('toApiModelOptional', () => {
|
|
it('returns undefined when input is undefined', () => {
|
|
expect(toApiModelOptional(undefined)).toBeUndefined();
|
|
});
|
|
|
|
it('returns undefined when id is empty', () => {
|
|
const input = {
|
|
id: '',
|
|
use_model: 'gpt',
|
|
platform: 'openai',
|
|
name: 'x',
|
|
base_url: 'y',
|
|
api_key: 'z',
|
|
} as TProviderWithModel;
|
|
|
|
expect(toApiModelOptional(input)).toBeUndefined();
|
|
});
|
|
|
|
it('returns undefined when id is whitespace', () => {
|
|
const input = {
|
|
id: ' ',
|
|
use_model: 'gpt',
|
|
platform: 'openai',
|
|
name: 'x',
|
|
base_url: 'y',
|
|
api_key: 'z',
|
|
} as TProviderWithModel;
|
|
|
|
expect(toApiModelOptional(input)).toBeUndefined();
|
|
});
|
|
|
|
it('returns undefined when use_model is empty', () => {
|
|
const input = {
|
|
id: 'x',
|
|
use_model: '',
|
|
platform: 'openai',
|
|
name: 'x',
|
|
base_url: 'y',
|
|
api_key: 'z',
|
|
} as TProviderWithModel;
|
|
|
|
expect(toApiModelOptional(input)).toBeUndefined();
|
|
});
|
|
|
|
it('returns undefined when use_model is whitespace', () => {
|
|
const input = {
|
|
id: 'x',
|
|
use_model: ' ',
|
|
platform: 'openai',
|
|
name: 'x',
|
|
base_url: 'y',
|
|
api_key: 'z',
|
|
} as TProviderWithModel;
|
|
|
|
expect(toApiModelOptional(input)).toBeUndefined();
|
|
});
|
|
|
|
it('maps to ApiProviderWithModel when complete', () => {
|
|
const input = {
|
|
id: 'x',
|
|
use_model: 'gpt',
|
|
platform: 'openai',
|
|
name: 'x',
|
|
base_url: 'y',
|
|
api_key: 'z',
|
|
} as TProviderWithModel;
|
|
|
|
const result = toApiModelOptional(input);
|
|
|
|
expect(result).toEqual({
|
|
provider_id: 'x',
|
|
model: 'gpt',
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('buildCreateConversationBody', () => {
|
|
const aionrsModel = {
|
|
id: 'openai',
|
|
use_model: 'gemini-2.5-pro',
|
|
platform: 'openai',
|
|
name: 'x',
|
|
base_url: 'y',
|
|
api_key: 'z',
|
|
} as TProviderWithModel;
|
|
|
|
it('keeps top-level model for assistant-first creates without forwarding type', () => {
|
|
// Regression: aionrs creates must keep the selected top-level model,
|
|
// otherwise the backend persists a NULL model and warmup later fails with
|
|
// "Provider '' not found".
|
|
const body = buildCreateConversationBody({
|
|
name: 'hello',
|
|
model: aionrsModel,
|
|
assistant: { id: 'bare:aionrs' },
|
|
extra: { workspace: '' },
|
|
});
|
|
|
|
expect(body.type).toBeUndefined();
|
|
expect(body.model).toEqual({ provider_id: 'openai', model: 'gemini-2.5-pro' });
|
|
});
|
|
|
|
it('omits type for assistant-first ACP creates', () => {
|
|
const body = buildCreateConversationBody({
|
|
name: 'hello',
|
|
assistant: { id: 'bare:claude' },
|
|
extra: {},
|
|
});
|
|
|
|
expect(body.type).toBeUndefined();
|
|
expect('model' in body).toBe(false);
|
|
});
|
|
|
|
it('strips legacy type when assistant identity is present', () => {
|
|
const body = buildCreateConversationBody({
|
|
type: 'acp',
|
|
name: 'hello',
|
|
assistant: { id: 'bare:claude' },
|
|
extra: {},
|
|
});
|
|
|
|
expect(body.type).toBeUndefined();
|
|
});
|
|
|
|
it('omits the top-level model for ACP creates that pass an empty placeholder', () => {
|
|
const body = buildCreateConversationBody({
|
|
name: 'hello',
|
|
model: {} as TProviderWithModel,
|
|
assistant: { id: 'bare:claude' },
|
|
extra: {},
|
|
});
|
|
|
|
expect(body.type).toBeUndefined();
|
|
expect('model' in body).toBe(false);
|
|
});
|
|
|
|
it('omits the top-level model when no model is provided', () => {
|
|
const body = buildCreateConversationBody({
|
|
name: 'hello',
|
|
assistant: { id: 'bare:claude' },
|
|
extra: {},
|
|
});
|
|
|
|
expect('model' in body).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('fromApiModel', () => {
|
|
it('maps backend format to frontend and fills empty provider fields', () => {
|
|
const input: ApiProviderWithModel = {
|
|
provider_id: 'p',
|
|
model: 'm',
|
|
};
|
|
|
|
const result = fromApiModel(input);
|
|
|
|
expect(result).toEqual({
|
|
id: 'p',
|
|
platform: '',
|
|
name: '',
|
|
base_url: '',
|
|
api_key: '',
|
|
use_model: 'm',
|
|
});
|
|
});
|
|
|
|
it('uses use_model field when present instead of model', () => {
|
|
const input: ApiProviderWithModel = {
|
|
provider_id: 'p',
|
|
model: 'm-fallback',
|
|
use_model: 'm-primary',
|
|
};
|
|
|
|
const result = fromApiModel(input);
|
|
|
|
expect(result.use_model).toBe('m-primary');
|
|
});
|
|
|
|
it('falls back to model field when use_model is not present', () => {
|
|
const input: ApiProviderWithModel = {
|
|
provider_id: 'p',
|
|
model: 'm-fallback',
|
|
};
|
|
|
|
const result = fromApiModel(input);
|
|
|
|
expect(result.use_model).toBe('m-fallback');
|
|
});
|
|
});
|
|
|
|
describe('fromApiConversation', () => {
|
|
it('transforms model from ApiProviderWithModel to TProviderWithModel', () => {
|
|
const raw = {
|
|
id: 'conv1',
|
|
model: {
|
|
provider_id: 'p',
|
|
model: 'm',
|
|
} as ApiProviderWithModel,
|
|
};
|
|
|
|
const result = fromApiConversation(raw);
|
|
|
|
expect(result.model).toEqual({
|
|
id: 'p',
|
|
platform: '',
|
|
name: '',
|
|
base_url: '',
|
|
api_key: '',
|
|
use_model: 'm',
|
|
});
|
|
});
|
|
|
|
it('handles missing model field', () => {
|
|
const raw = {
|
|
id: 'conv1',
|
|
};
|
|
|
|
const result = fromApiConversation(raw);
|
|
|
|
expect(result.model).toBeUndefined();
|
|
});
|
|
|
|
it('handles null model field', () => {
|
|
const raw = {
|
|
id: 'conv1',
|
|
model: null,
|
|
};
|
|
|
|
const result = fromApiConversation(raw);
|
|
|
|
expect(result.model).toBeUndefined();
|
|
});
|
|
|
|
it('infers custom_workspace=true when workspace is non-empty and not temporary', () => {
|
|
const raw = {
|
|
id: 'conv1',
|
|
extra: {
|
|
workspace: '/tmp',
|
|
is_temporary_workspace: false,
|
|
},
|
|
};
|
|
|
|
const result = fromApiConversation(raw);
|
|
|
|
expect(result.extra?.custom_workspace).toBe(true);
|
|
});
|
|
|
|
it('infers custom_workspace=false when is_temporary_workspace=true', () => {
|
|
const raw = {
|
|
id: 'conv1',
|
|
extra: {
|
|
workspace: '/tmp',
|
|
is_temporary_workspace: true,
|
|
},
|
|
};
|
|
|
|
const result = fromApiConversation(raw);
|
|
|
|
expect(result.extra?.custom_workspace).toBe(false);
|
|
});
|
|
|
|
it('infers custom_workspace=false when workspace is empty string', () => {
|
|
const raw = {
|
|
id: 'conv1',
|
|
extra: {
|
|
workspace: '',
|
|
is_temporary_workspace: false,
|
|
},
|
|
};
|
|
|
|
const result = fromApiConversation(raw);
|
|
|
|
expect(result.extra?.custom_workspace).toBe(false);
|
|
});
|
|
|
|
it('does not overwrite existing custom_workspace field', () => {
|
|
const raw = {
|
|
id: 'conv1',
|
|
extra: {
|
|
workspace: '/tmp',
|
|
is_temporary_workspace: false,
|
|
custom_workspace: true,
|
|
},
|
|
};
|
|
|
|
const result = fromApiConversation(raw);
|
|
|
|
// Should preserve the existing value
|
|
expect(result.extra?.custom_workspace).toBe(true);
|
|
});
|
|
|
|
it('returns input unchanged for non-object types', () => {
|
|
expect(fromApiConversation(null)).toBeNull();
|
|
expect(fromApiConversation(undefined)).toBeUndefined();
|
|
expect(fromApiConversation('string')).toBe('string');
|
|
expect(fromApiConversation(123)).toBe(123);
|
|
});
|
|
});
|
|
|
|
describe('fromApiPaginatedConversations', () => {
|
|
it('maps items through fromApiConversation and preserves total/has_more', () => {
|
|
const input = {
|
|
items: [
|
|
{
|
|
id: 'conv1',
|
|
model: {
|
|
provider_id: 'p1',
|
|
model: 'm1',
|
|
} as ApiProviderWithModel,
|
|
},
|
|
{
|
|
id: 'conv2',
|
|
},
|
|
],
|
|
total: 2,
|
|
has_more: false,
|
|
};
|
|
|
|
const result = fromApiPaginatedConversations(input);
|
|
|
|
expect(result.total).toBe(2);
|
|
expect(result.has_more).toBe(false);
|
|
expect(result.items).toHaveLength(2);
|
|
expect(result.items[0].model).toEqual({
|
|
id: 'p1',
|
|
platform: '',
|
|
name: '',
|
|
base_url: '',
|
|
api_key: '',
|
|
use_model: 'm1',
|
|
});
|
|
expect(result.items[1].model).toBeUndefined();
|
|
});
|
|
|
|
it('handles empty items array', () => {
|
|
const input = {
|
|
items: [],
|
|
total: 0,
|
|
has_more: false,
|
|
};
|
|
|
|
const result = fromApiPaginatedConversations(input);
|
|
|
|
expect(result.items).toHaveLength(0);
|
|
expect(result.total).toBe(0);
|
|
expect(result.has_more).toBe(false);
|
|
});
|
|
});
|
|
});
|