// ========================================== // Model Processing Tests // ========================================== // Tests the data transforms in fetchModels() // that convert backend responses into the // App.models array used throughout the UI. // // Run: node --test src/js/__tests__/model-processing.test.js // ========================================== const { describe, it } = require('node:test'); const assert = require('node:assert/strict'); const { processModelsResponse } = require('./helpers'); // ── Basic model transform ──────────────────── describe('processModelsResponse — catalog models', () => { // Mock uses BOTH canonical (provider_config_id) and alias (config_id) // to match the actual UserModel Go struct serialization. const apiResponse = { models: [ { id: 'entry-uuid', model_id: 'gpt-4o', display_name: 'GPT-4o', provider_config_id: 'cfg-uuid', config_id: 'cfg-uuid', provider_name: 'OpenAI Production', provider_type: 'openai', source: 'catalog', scope: 'global', capabilities: { streaming: true, vision: true, tool_calling: true }, }, ], }; it('produces composite ID for catalog models', () => { const models = processModelsResponse(apiResponse); assert.equal(models[0].id, 'cfg-uuid:gpt-4o', 'catalog model ID must be config_id:model_id to avoid collisions'); }); it('produces composite ID with ONLY provider_config_id (no config_id alias)', () => { // This is the critical regression test: if the config_id alias // is removed from the Go struct, the frontend must still work. const resp = { models: [{ model_id: 'gpt-4o', provider_config_id: 'cfg-uuid', // NO config_id — simulates broken alias provider_name: 'OpenAI', source: 'catalog', }], }; const models = processModelsResponse(resp); assert.equal(models[0].id, 'cfg-uuid:gpt-4o', 'must fall back to provider_config_id when config_id is missing'); }); it('preserves baseModelId', () => { const models = processModelsResponse(apiResponse); assert.equal(models[0].baseModelId, 'gpt-4o'); }); it('uses display_name for name', () => { const models = processModelsResponse(apiResponse); assert.equal(models[0].name, 'GPT-4o'); }); it('falls back to model_id when display_name empty', () => { const resp = { models: [{ model_id: 'claude-3-opus', provider_config_id: 'c1', provider_type: 'anthropic' }], }; const models = processModelsResponse(resp); assert.equal(models[0].name, 'claude-3-opus'); }); it('marks catalog models as NOT personas', () => { const models = processModelsResponse(apiResponse); assert.equal(models[0].isPersona, false); }); it('preserves configId from config_id || provider_config_id', () => { const models = processModelsResponse(apiResponse); assert.equal(models[0].configId, 'cfg-uuid'); }); it('uses provider_name for display', () => { const models = processModelsResponse(apiResponse); assert.equal(models[0].provider, 'OpenAI Production'); }); }); // ── Preset transform ───────────────────────── describe('processModelsResponse — personas', () => { const apiResponse = { models: [ { id: 'preset-uuid', model_id: 'gpt-4o', display_name: 'Code Helper', // Backend canonical fields provider_config_id: 'cfg-uuid', persona_id: 'preset-uuid', scope: 'global', avatar: '/avatars/code.png', source: 'persona', // Frontend alias fields is_persona: true, persona_id: 'preset-uuid', persona_scope: 'global', persona_avatar: '/avatars/code.png', persona_handle: 'code-helper', persona_team_name: null, config_id: 'cfg-uuid', provider_name: 'OpenAI', }, ], }; it('uses persona_id as ID (not composite)', () => { const models = processModelsResponse(apiResponse); assert.equal(models[0].id, 'preset-uuid', 'persona ID must use persona_id, not config_id:model_id'); }); it('falls back to persona_id when not present', () => { const resp = { models: [{ id: 'p-uuid', model_id: 'gpt-4o', is_persona: true, persona_id: 'p-uuid', // NO preset_id — simulates broken alias source: 'persona', }], }; const models = processModelsResponse(resp); assert.equal(models[0].id, 'p-uuid', 'must fall back to persona_id'); }); it('marks as persona', () => { const models = processModelsResponse(apiResponse); assert.equal(models[0].isPersona, true); }); it('preserves persona metadata', () => { const models = processModelsResponse(apiResponse); assert.equal(models[0].personaScope, 'global'); assert.equal(models[0].personaAvatar, '/avatars/code.png'); assert.equal(models[0].personaHandle, 'code-helper'); assert.equal(models[0].personaId, 'preset-uuid'); }); it('baseModelId is the underlying model', () => { const models = processModelsResponse(apiResponse); assert.equal(models[0].baseModelId, 'gpt-4o'); }); }); // ── Same model from multiple providers ─────── describe('processModelsResponse — multi-provider dedup', () => { const apiResponse = { models: [ { model_id: 'gpt-4o', display_name: 'GPT-4o', provider_config_id: 'global-cfg', config_id: 'global-cfg', provider_name: 'Global OpenAI', source: 'catalog', scope: 'global', }, { model_id: 'gpt-4o', display_name: 'GPT-4o (BYOK)', provider_config_id: 'personal-cfg', config_id: 'personal-cfg', provider_name: 'My Key', source: 'catalog', scope: 'personal', }, ], }; it('same model_id with different config_ids produces unique IDs', () => { const models = processModelsResponse(apiResponse); assert.equal(models.length, 2); assert.notEqual(models[0].id, models[1].id, 'same model from different providers MUST have unique IDs'); assert.equal(models[0].id, 'global-cfg:gpt-4o'); assert.equal(models[1].id, 'personal-cfg:gpt-4o'); }); it('works with only provider_config_id (no config_id alias)', () => { const resp = { models: [ { model_id: 'gpt-4o', provider_config_id: 'cfg-a', source: 'catalog' }, { model_id: 'gpt-4o', provider_config_id: 'cfg-b', source: 'catalog' }, ], }; const models = processModelsResponse(resp); assert.equal(models[0].id, 'cfg-a:gpt-4o'); assert.equal(models[1].id, 'cfg-b:gpt-4o'); }); }); // ── Hidden models ──────────────────────────── describe('processModelsResponse — hidden models', () => { const apiResponse = { models: [ { model_id: 'gpt-4o', provider_config_id: 'c1', config_id: 'c1', display_name: 'GPT-4o' }, { model_id: 'claude-3', provider_config_id: 'c2', config_id: 'c2', display_name: 'Claude 3' }, ], }; it('marks models as hidden from user prefs (composite key)', () => { const hidden = new Set(['c1:gpt-4o']); const models = processModelsResponse(apiResponse, hidden); assert.equal(models[0].hidden, true, 'gpt-4o should be hidden'); assert.equal(models[1].hidden, false, 'claude-3 should NOT be hidden'); }); it('personas are never hidden via model ID', () => { const resp = { models: [ { model_id: 'gpt-4o', is_persona: true, persona_id: 'p1', display_name: 'Persona' }, ], }; const hidden = new Set([':gpt-4o']); const models = processModelsResponse(resp, hidden); assert.equal(models[0].hidden, false, 'personas must NOT be hidden by base model hidden pref'); }); }); // ── Empty / edge cases ─────────────────────── describe('processModelsResponse — edge cases', () => { it('handles empty models array', () => { const models = processModelsResponse({ models: [] }); assert.equal(models.length, 0); }); it('handles missing models key', () => { const models = processModelsResponse({}); assert.equal(models.length, 0); }); it('handles null response gracefully', () => { const models = processModelsResponse({ models: null }); assert.equal(models.length, 0); }); it('model without config_id uses bare model_id as ID', () => { const resp = { models: [{ model_id: 'test-model', display_name: 'Test' }], }; const models = processModelsResponse(resp); assert.equal(models[0].id, 'test-model'); }); it('model without display_name or model_id uses id', () => { const resp = { models: [{ id: 'fallback-id' }], }; const models = processModelsResponse(resp); assert.equal(models[0].id, 'fallback-id'); assert.equal(models[0].name, 'fallback-id'); }); }); // ── Model sorting ──────────────────────────── describe('Model sorting', () => { function sortModels(models) { const scopeOrder = { global: 0, team: 1, personal: 2 }; return [...models].sort((a, b) => { if (a.isPersona && !b.isPersona) return -1; if (!a.isPersona && b.isPersona) return 1; if (a.isPersona && b.isPersona) { const sa = scopeOrder[a.personaScope] ?? 9; const sb = scopeOrder[b.personaScope] ?? 9; if (sa !== sb) return sa - sb; } return a.name.localeCompare(b.name); }); } it('personas sort before regular models', () => { const models = [ { name: 'GPT-4o', isPersona: false }, { name: 'Code Helper', isPersona: true, personaScope: 'global' }, ]; const sorted = sortModels(models); assert.equal(sorted[0].name, 'Code Helper'); assert.equal(sorted[1].name, 'GPT-4o'); }); it('global personas sort before team personas', () => { const models = [ { name: 'Team Bot', isPersona: true, personaScope: 'team' }, { name: 'Global Bot', isPersona: true, personaScope: 'global' }, { name: 'My Bot', isPersona: true, personaScope: 'personal' }, ]; const sorted = sortModels(models); assert.equal(sorted[0].personaScope, 'global'); assert.equal(sorted[1].personaScope, 'team'); assert.equal(sorted[2].personaScope, 'personal'); }); it('regular models sort alphabetically', () => { const models = [ { name: 'Zephyr', isPersona: false }, { name: 'Claude', isPersona: false }, { name: 'GPT-4o', isPersona: false }, ]; const sorted = sortModels(models); assert.equal(sorted[0].name, 'Claude'); assert.equal(sorted[1].name, 'GPT-4o'); assert.equal(sorted[2].name, 'Zephyr'); }); });