fix frontend tests: remove gutted chat/model/channel tests
Delete 4 stale test files: - channel-contracts.test.js (channel API contracts) - model-processing.test.js (model catalog transforms) - user-journey-models.test.js (provider/model E2E) - api-contracts.test.js (model/persona API contracts) Update policy-gating.test.js: - Replace chat surface template assertions with admin surface checks - Remove settings BYOK/persona policy gating checks (features removed) Remaining 4 frontend test files all pass (16 tests). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,432 +0,0 @@
|
||||
// ==========================================
|
||||
// API Response Contract Tests
|
||||
// ==========================================
|
||||
// These tests validate that frontend code
|
||||
// correctly handles the actual backend
|
||||
// response shapes. Every bug from the v0.9.0
|
||||
// rollout was a contract mismatch.
|
||||
//
|
||||
// Run: node --test src/js/__tests__/api-contracts.test.js
|
||||
// ==========================================
|
||||
|
||||
const { describe, it } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
// ── Admin /users response ────────────────────
|
||||
// Normalized: uses standard {data: [...], total: N} envelope.
|
||||
// Has siblings → _unwrap does NOT strip. Frontend reads .data and .total.
|
||||
|
||||
describe('GET /admin/users response contract', () => {
|
||||
// ACTUAL backend response shape from ListUsers handler:
|
||||
// c.JSON(200, gin.H{"data": users, "total": total})
|
||||
const backendResponse = {
|
||||
data: [
|
||||
{ id: 'u1', username: 'alice', email: 'alice@test.com', role: 'user', is_active: true },
|
||||
{ id: 'u2', username: 'admin', email: 'admin@test.com', role: 'admin', is_active: true },
|
||||
],
|
||||
total: 2,
|
||||
};
|
||||
|
||||
it('response has "data" array', () => {
|
||||
assert.ok(Array.isArray(backendResponse.data), 'response.data must be an array');
|
||||
});
|
||||
|
||||
it('frontend extraction reads .data (siblings present, _unwrap passes through)', () => {
|
||||
const users = backendResponse.data || [];
|
||||
assert.equal(users.length, 2);
|
||||
assert.equal(users[0].id, 'u1');
|
||||
});
|
||||
|
||||
it('each user has required fields for member dropdown', () => {
|
||||
for (const u of backendResponse.data) {
|
||||
assert.ok(u.id, 'user must have id');
|
||||
assert.ok(u.username || u.email, 'user must have username or email');
|
||||
assert.equal(typeof u.is_active, 'boolean', 'is_active must be boolean');
|
||||
}
|
||||
});
|
||||
|
||||
it('total is numeric', () => {
|
||||
assert.equal(typeof backendResponse.total, 'number');
|
||||
assert.ok(backendResponse.total >= backendResponse.data.length);
|
||||
});
|
||||
});
|
||||
|
||||
// ── /models/enabled response ─────────────────
|
||||
// Returns {data: [...], default_model: "..."}
|
||||
|
||||
describe('GET /models/enabled response contract', () => {
|
||||
// ACTUAL backend UserModel struct sends BOTH provider_config_id AND config_id.
|
||||
// Mock uses provider_config_id as primary (Go struct canonical name)
|
||||
// and config_id as the alias (populated by resolver for frontend compat).
|
||||
const backendResponse = {
|
||||
data: [
|
||||
{
|
||||
id: 'cfg1:gpt-4o',
|
||||
model_id: 'gpt-4o',
|
||||
display_name: 'GPT-4o',
|
||||
provider_config_id: 'cfg1',
|
||||
config_id: 'cfg1', // Alias — MUST match provider_config_id
|
||||
provider_name: 'OpenAI',
|
||||
provider_type: 'openai',
|
||||
source: 'catalog',
|
||||
scope: 'global',
|
||||
capabilities: { streaming: true, vision: true, tool_calling: true },
|
||||
pricing: {},
|
||||
},
|
||||
],
|
||||
default_model: '',
|
||||
};
|
||||
|
||||
it('response has "data" array', () => {
|
||||
assert.ok(Array.isArray(backendResponse.data));
|
||||
});
|
||||
|
||||
it('each model has required fields for selector', () => {
|
||||
for (const m of backendResponse.data) {
|
||||
assert.ok(m.model_id || m.id, 'must have model_id or id');
|
||||
assert.ok(m.display_name || m.model_id, 'must have display_name or model_id');
|
||||
}
|
||||
});
|
||||
|
||||
it('catalog models have provider_config_id (backend canonical)', () => {
|
||||
const catalogModels = backendResponse.data.filter(m => m.source === 'catalog');
|
||||
for (const m of catalogModels) {
|
||||
assert.ok(m.provider_config_id,
|
||||
'catalog model must have provider_config_id (Go struct canonical name)');
|
||||
}
|
||||
});
|
||||
|
||||
it('catalog models have config_id alias matching provider_config_id', () => {
|
||||
const catalogModels = backendResponse.data.filter(m => m.source === 'catalog');
|
||||
for (const m of catalogModels) {
|
||||
assert.ok(m.config_id,
|
||||
'catalog model must have config_id (frontend alias)');
|
||||
assert.equal(m.config_id, m.provider_config_id,
|
||||
'config_id alias must equal provider_config_id');
|
||||
}
|
||||
});
|
||||
|
||||
it('capabilities is an object (not null)', () => {
|
||||
for (const m of backendResponse.data) {
|
||||
assert.equal(typeof m.capabilities, 'object');
|
||||
assert.notEqual(m.capabilities, null);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── /models/enabled with persona response ────
|
||||
|
||||
describe('GET /models/enabled persona contract', () => {
|
||||
// ACTUAL backend UserModel for a persona (v0.22.8+: unified naming, no aliases).
|
||||
const personaModel = {
|
||||
id: 'persona-uuid-123',
|
||||
model_id: 'gpt-4o',
|
||||
display_name: 'Code Helper',
|
||||
source: 'persona',
|
||||
// Backend canonical fields (v0.22.8: all use persona_ prefix)
|
||||
provider_config_id: 'cfg1',
|
||||
is_persona: true,
|
||||
persona_id: 'persona-uuid-123',
|
||||
persona_scope: 'global',
|
||||
persona_avatar: null,
|
||||
persona_team_name: null,
|
||||
config_id: 'cfg1',
|
||||
provider_name: 'OpenAI',
|
||||
capabilities: { streaming: true },
|
||||
};
|
||||
|
||||
it('persona has is_persona = true', () => {
|
||||
assert.equal(personaModel.is_persona, true);
|
||||
});
|
||||
|
||||
it('persona has persona_id', () => {
|
||||
assert.ok(personaModel.persona_id);
|
||||
});
|
||||
|
||||
it('persona has persona_scope', () => {
|
||||
assert.ok(['global', 'team', 'personal'].includes(personaModel.persona_scope));
|
||||
});
|
||||
|
||||
it('persona ID uses persona_id not composite', () => {
|
||||
// Frontend: id = isPersona ? (m.persona_id || m.id) : composite
|
||||
const id = personaModel.is_persona
|
||||
? (personaModel.persona_id || personaModel.id)
|
||||
: `${personaModel.config_id || personaModel.provider_config_id}:${personaModel.model_id}`;
|
||||
assert.equal(id, 'persona-uuid-123');
|
||||
});
|
||||
|
||||
it('frontend works with ONLY canonical fields (no aliases)', () => {
|
||||
// v0.22.8: no more dual-key aliases — backend sends is_persona directly.
|
||||
const rawBackend = {
|
||||
id: 'persona-uuid-123',
|
||||
model_id: 'gpt-4o',
|
||||
source: 'persona',
|
||||
provider_config_id: 'cfg1',
|
||||
is_persona: true,
|
||||
persona_id: 'persona-uuid-123',
|
||||
persona_scope: 'global',
|
||||
};
|
||||
const isPersona = !!rawBackend.is_persona;
|
||||
assert.equal(isPersona, true,
|
||||
'is_persona field must be present for correct identification');
|
||||
});
|
||||
});
|
||||
|
||||
// ── /settings/public response ────────────────
|
||||
// Bug: allow_user_personas not exposed → no toggle in UI
|
||||
|
||||
describe('GET /settings/public response contract', () => {
|
||||
const backendResponse = {
|
||||
banner: { enabled: false },
|
||||
branding: null,
|
||||
policies: {
|
||||
allow_registration: 'true',
|
||||
allow_user_byok: 'true',
|
||||
allow_user_personas: 'false',
|
||||
},
|
||||
};
|
||||
|
||||
it('response has policies object', () => {
|
||||
assert.equal(typeof backendResponse.policies, 'object');
|
||||
assert.notEqual(backendResponse.policies, null);
|
||||
});
|
||||
|
||||
it('policies includes allow_user_byok', () => {
|
||||
assert.ok('allow_user_byok' in backendResponse.policies,
|
||||
'MISSING: allow_user_byok — user provider tab will break');
|
||||
});
|
||||
|
||||
it('policies includes allow_user_personas', () => {
|
||||
assert.ok('allow_user_personas' in backendResponse.policies,
|
||||
'MISSING: allow_user_personas — preset creation gating will break');
|
||||
});
|
||||
|
||||
it('policies includes allow_registration', () => {
|
||||
assert.ok('allow_registration' in backendResponse.policies);
|
||||
});
|
||||
|
||||
it('policy values are string "true"/"false" (not boolean)', () => {
|
||||
for (const [key, val] of Object.entries(backendResponse.policies)) {
|
||||
assert.equal(typeof val, 'string',
|
||||
`policy ${key} must be string, got ${typeof val}`);
|
||||
assert.ok(['true', 'false'].includes(val),
|
||||
`policy ${key} must be "true" or "false", got "${val}"`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── /admin/settings response ─────────────────
|
||||
|
||||
describe('GET /admin/settings response contract', () => {
|
||||
const backendResponse = {
|
||||
settings: {
|
||||
banner: { enabled: false, text: '', position: 'both', bg: '#007a33', fg: '#ffffff' },
|
||||
},
|
||||
policies: {
|
||||
allow_registration: 'true',
|
||||
default_user_active: 'false',
|
||||
allow_user_byok: 'false',
|
||||
allow_user_personas: 'false',
|
||||
allow_team_providers: 'true',
|
||||
},
|
||||
};
|
||||
|
||||
it('response has settings and policies', () => {
|
||||
assert.ok(backendResponse.settings, 'must have settings');
|
||||
assert.ok(backendResponse.policies, 'must have policies');
|
||||
});
|
||||
|
||||
const requiredPolicies = [
|
||||
'allow_registration',
|
||||
'default_user_active',
|
||||
'allow_user_byok',
|
||||
'allow_user_personas',
|
||||
];
|
||||
|
||||
for (const key of requiredPolicies) {
|
||||
it(`policies includes ${key}`, () => {
|
||||
assert.ok(key in backendResponse.policies,
|
||||
`MISSING policy: ${key} — admin settings UI will be incomplete`);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ── /api-configs (user) response ─────────────
|
||||
// Bug: ListConfigs returned global + personal; should be personal only
|
||||
|
||||
describe('GET /api-configs response contract (user scope)', () => {
|
||||
const backendResponse = [
|
||||
{
|
||||
id: 'cfg-personal-1',
|
||||
name: 'My OpenAI Key',
|
||||
provider: 'openai',
|
||||
endpoint: 'https://api.openai.com/v1',
|
||||
scope: 'personal',
|
||||
is_active: true,
|
||||
},
|
||||
];
|
||||
|
||||
it('all configs are personal scope', () => {
|
||||
for (const cfg of backendResponse) {
|
||||
assert.equal(cfg.scope, 'personal',
|
||||
`user /api-configs must only return personal scope, got: ${cfg.scope}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('no global configs leak to user list', () => {
|
||||
const global = backendResponse.filter(c => c.scope === 'global');
|
||||
assert.equal(global.length, 0,
|
||||
'SECURITY: global configs must NOT appear in user /api-configs');
|
||||
});
|
||||
|
||||
it('each config has required fields', () => {
|
||||
for (const cfg of backendResponse) {
|
||||
assert.ok(cfg.id, 'must have id');
|
||||
assert.ok(cfg.name, 'must have name');
|
||||
assert.ok(cfg.provider, 'must have provider');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── /teams/:id/members response ──────────────
|
||||
|
||||
describe('GET /teams/:id/members response contract', () => {
|
||||
const backendResponse = {
|
||||
data: [
|
||||
{
|
||||
id: 'tm1', user_id: 'u1', role: 'admin',
|
||||
email: 'alice@test.com', display_name: 'Alice', joined_at: '2026-01-01T00:00:00Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it('response has "data" array', () => {
|
||||
assert.ok(Array.isArray(backendResponse.data));
|
||||
});
|
||||
|
||||
it('each member has user_id for exclusion filtering', () => {
|
||||
for (const m of backendResponse.data) {
|
||||
assert.ok(m.user_id, 'member must have user_id');
|
||||
assert.ok(m.role, 'member must have role');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── /admin/models response ──────────────────
|
||||
// Returns CatalogEntry objects (not UserModel).
|
||||
// All models regardless of visibility.
|
||||
|
||||
describe('GET /admin/models response contract', () => {
|
||||
// ACTUAL CatalogEntry shape from ListAll → ListModelConfigs handler.
|
||||
// Normalized: {data: [...]} sole key → _unwrap strips → frontend gets bare array.
|
||||
const backendResponse = {
|
||||
data: [
|
||||
{
|
||||
id: 'catalog-uuid-1',
|
||||
provider_config_id: 'provider-uuid',
|
||||
model_id: 'gpt-4o',
|
||||
display_name: 'GPT-4o',
|
||||
capabilities: { streaming: true, tool_calling: true },
|
||||
pricing: null,
|
||||
visibility: 'disabled',
|
||||
last_synced_at: '2026-02-22T00:00:00Z',
|
||||
created_at: '2026-02-22T00:00:00Z',
|
||||
updated_at: '2026-02-22T00:00:00Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it('response has "data" array (not null)', () => {
|
||||
assert.ok(Array.isArray(backendResponse.data),
|
||||
'CRITICAL: data must be [] not null');
|
||||
});
|
||||
|
||||
it('each model has id and model_id', () => {
|
||||
for (const m of backendResponse.data) {
|
||||
assert.ok(m.id, 'must have id (UUID) for visibility toggle onclick');
|
||||
assert.ok(m.model_id, 'must have model_id for display');
|
||||
}
|
||||
});
|
||||
|
||||
it('each model has visibility', () => {
|
||||
for (const m of backendResponse.data) {
|
||||
assert.ok(['enabled', 'disabled', 'team'].includes(m.visibility),
|
||||
`visibility must be enabled/disabled/team, got: ${m.visibility}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('_unwrap strips sole-key {data:[]} → frontend gets bare array', () => {
|
||||
// After _unwrap, frontend receives the bare array, uses `resp || []`
|
||||
const unwrapped = backendResponse.data; // simulates _unwrap
|
||||
const list = unwrapped || [];
|
||||
assert.ok(Array.isArray(list));
|
||||
assert.equal(list.length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── /admin/models/fetch response ────────────
|
||||
|
||||
describe('POST /admin/models/fetch response contract', () => {
|
||||
const successResponse = {
|
||||
message: 'models synced',
|
||||
added: 15,
|
||||
updated: 3,
|
||||
total: 18,
|
||||
};
|
||||
|
||||
const errorResponse = {
|
||||
message: 'models synced',
|
||||
added: 0,
|
||||
updated: 0,
|
||||
total: 0,
|
||||
errors: ['venice: HTTP 401: invalid API key'],
|
||||
};
|
||||
|
||||
it('success response has counts', () => {
|
||||
assert.equal(typeof successResponse.added, 'number');
|
||||
assert.equal(typeof successResponse.updated, 'number');
|
||||
assert.equal(typeof successResponse.total, 'number');
|
||||
});
|
||||
|
||||
it('error response has errors array', () => {
|
||||
assert.ok(Array.isArray(errorResponse.errors));
|
||||
assert.ok(errorResponse.errors.length > 0);
|
||||
});
|
||||
|
||||
it('frontend must check errors array (not just HTTP status)', () => {
|
||||
// The handler returns 200 even with errors — frontend must inspect body
|
||||
const errs = errorResponse.errors || [];
|
||||
assert.ok(errs.length > 0,
|
||||
'CRITICAL: fetch can return 200 with errors — frontend must surface them');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Cross-endpoint consistency ───────────────
|
||||
|
||||
describe('Response shape consistency — all endpoints use {data:[...]} envelope', () => {
|
||||
it('admin/users uses {data:[], total:N} (siblings → not unwrapped)', () => {
|
||||
const shape = { data: [], total: 0 };
|
||||
assert.ok('data' in shape);
|
||||
assert.ok('total' in shape);
|
||||
});
|
||||
|
||||
it('teams/members uses {data:[]}', () => {
|
||||
const shape = { data: [] };
|
||||
assert.ok('data' in shape);
|
||||
});
|
||||
|
||||
it('models/enabled uses {data:[], default_model:""} (siblings → not unwrapped)', () => {
|
||||
const shape = { data: [], default_model: '' };
|
||||
assert.ok('data' in shape);
|
||||
});
|
||||
|
||||
it('admin/models uses {data:[]} (sole key → unwrapped)', () => {
|
||||
const shape = { data: [] };
|
||||
assert.ok('data' in shape);
|
||||
});
|
||||
|
||||
it('personas uses {data:[]} (sole key → unwrapped)', () => {
|
||||
const shape = { data: [] };
|
||||
assert.ok('data' in shape);
|
||||
});
|
||||
});
|
||||
@@ -1,149 +0,0 @@
|
||||
// ==========================================
|
||||
// Channel API Contract Tests
|
||||
// ==========================================
|
||||
// Validates channel listing, type filtering, and
|
||||
// the sidebar's data flow. Catches the channel_type
|
||||
// vs type parameter mismatch (v0.37.14.2 fix).
|
||||
//
|
||||
// Run: node --test src/js/__tests__/channel-contracts.test.js
|
||||
// ==========================================
|
||||
|
||||
const { describe, it } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
// ── Channel list response shape ──────────────
|
||||
|
||||
describe('GET /channels response contract', () => {
|
||||
// Backend response from ListChannels handler:
|
||||
// c.JSON(200, gin.H{"data": channels, "total": total, ...})
|
||||
const backendResponse = {
|
||||
data: [
|
||||
{ id: 'c1', title: 'General', type: 'channel', folder_id: null },
|
||||
{ id: 'c2', title: 'AI Chat', type: 'direct', folder_id: 'f1' },
|
||||
{ id: 'c3', title: 'Dev Group', type: 'group', folder_id: null },
|
||||
{ id: 'c4', title: 'DM: Alice', type: 'dm', folder_id: null },
|
||||
],
|
||||
total: 4,
|
||||
page: 1,
|
||||
per_page: 100,
|
||||
};
|
||||
|
||||
it('response has "data" array', () => {
|
||||
assert.ok(Array.isArray(backendResponse.data), 'response.data must be an array');
|
||||
});
|
||||
|
||||
it('each channel has required fields', () => {
|
||||
for (const ch of backendResponse.data) {
|
||||
assert.ok(ch.id, 'channel must have id');
|
||||
assert.ok(ch.type, 'channel must have type');
|
||||
assert.ok(['direct', 'dm', 'group', 'channel', 'workflow'].includes(ch.type),
|
||||
`unknown type: ${ch.type}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── Type filter parameter naming ──────────────
|
||||
|
||||
describe('Channel list type filter', () => {
|
||||
// Simulates URL parameter building (like _qs in api-domains.js)
|
||||
function buildQueryString(opts) {
|
||||
const p = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(opts)) {
|
||||
if (v != null) p.set(k, String(v));
|
||||
}
|
||||
const s = p.toString();
|
||||
return s ? '?' + s : '';
|
||||
}
|
||||
|
||||
it('should use "type" param (not "channel_type")', () => {
|
||||
// The Go handler expects "type" or "types"
|
||||
const qs = buildQueryString({ page: 1, per_page: 100, type: 'direct' });
|
||||
assert.ok(qs.includes('type=direct'), `query should contain type=direct, got: ${qs}`);
|
||||
assert.ok(!qs.includes('channel_type'), 'should NOT use channel_type param');
|
||||
});
|
||||
|
||||
it('should use "types" for multi-type filter', () => {
|
||||
const qs = buildQueryString({ page: 1, per_page: 200, types: 'direct,dm,group,channel' });
|
||||
assert.ok(qs.includes('types=direct'), `query should contain types param, got: ${qs}`);
|
||||
});
|
||||
|
||||
it('single type results should all match requested type', () => {
|
||||
const channels = [
|
||||
{ id: 'c1', type: 'direct' },
|
||||
{ id: 'c2', type: 'direct' },
|
||||
];
|
||||
for (const ch of channels) {
|
||||
assert.equal(ch.type, 'direct', `type filter leaked: ${ch.type}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('multi-type results should only contain requested types', () => {
|
||||
const channels = [
|
||||
{ id: 'c1', type: 'direct' },
|
||||
{ id: 'c2', type: 'group' },
|
||||
{ id: 'c3', type: 'channel' },
|
||||
{ id: 'c4', type: 'dm' },
|
||||
];
|
||||
const allowed = new Set(['direct', 'dm', 'group', 'channel']);
|
||||
for (const ch of channels) {
|
||||
assert.ok(allowed.has(ch.type), `unexpected type: ${ch.type}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── Sidebar merge deduplication ───────────────
|
||||
|
||||
describe('Sidebar channel merge', () => {
|
||||
it('single request should not produce duplicates', () => {
|
||||
// After fix: sidebar uses a single request with types=direct,dm,group,channel
|
||||
const apiResponse = [
|
||||
{ id: 'c1', title: 'Chat 1', type: 'direct' },
|
||||
{ id: 'c2', title: 'General', type: 'channel' },
|
||||
{ id: 'c3', title: 'DM: Bob', type: 'dm' },
|
||||
];
|
||||
const ids = apiResponse.map(ch => ch.id);
|
||||
const uniqueIds = new Set(ids);
|
||||
assert.equal(ids.length, uniqueIds.size, 'no duplicate ids expected');
|
||||
});
|
||||
|
||||
it('_extract handles nested .data or flat array', () => {
|
||||
// Mirrors use-sidebar.js _extract pattern
|
||||
function extract(resp) {
|
||||
if (!resp) return [];
|
||||
if (Array.isArray(resp)) return resp;
|
||||
if (resp.data && Array.isArray(resp.data)) return resp.data;
|
||||
return [];
|
||||
}
|
||||
|
||||
assert.deepEqual(extract({ data: [{ id: '1' }] }), [{ id: '1' }]);
|
||||
assert.deepEqual(extract([{ id: '2' }]), [{ id: '2' }]);
|
||||
assert.deepEqual(extract(null), []);
|
||||
assert.deepEqual(extract({}), []);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Channel type feature matrix ───────────────
|
||||
|
||||
describe('Channel type feature matrix', () => {
|
||||
const AI_TYPES = new Set(['direct', 'workflow']);
|
||||
|
||||
it('direct type shows model selector', () => {
|
||||
assert.ok(AI_TYPES.has('direct'));
|
||||
});
|
||||
|
||||
it('workflow type shows model selector', () => {
|
||||
assert.ok(AI_TYPES.has('workflow'));
|
||||
});
|
||||
|
||||
it('dm type hides model selector', () => {
|
||||
assert.ok(!AI_TYPES.has('dm'));
|
||||
});
|
||||
|
||||
it('group type hides model selector', () => {
|
||||
assert.ok(!AI_TYPES.has('group'));
|
||||
});
|
||||
|
||||
it('channel type hides model selector', () => {
|
||||
assert.ok(!AI_TYPES.has('channel'));
|
||||
});
|
||||
});
|
||||
@@ -1,319 +0,0 @@
|
||||
// ==========================================
|
||||
// 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');
|
||||
});
|
||||
});
|
||||
@@ -129,16 +129,16 @@ describe('Team member dropdown population', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── Chat surface template ────────────────────
|
||||
// v0.37.10: Chat surface is now Preact-rendered. Verify the mount
|
||||
// point exists and the old SPA scaffold is gone.
|
||||
// ── Kernel surface template ──────────────────
|
||||
// v0.1.0: Chat surface removed. Verify admin mount exists and
|
||||
// old SPA scaffold is gone.
|
||||
|
||||
describe('Chat surface template (v0.37.10)', () => {
|
||||
describe('Kernel surface templates (v0.1.0)', () => {
|
||||
const templateSrc = readAllTemplates();
|
||||
|
||||
it('chat-mount div exists', () => {
|
||||
assert.ok(templateSrc.includes('id="chat-mount"'),
|
||||
'MISSING: #chat-mount — Preact chat surface cannot render');
|
||||
it('admin-mount div exists', () => {
|
||||
assert.ok(templateSrc.includes('id="admin-mount"'),
|
||||
'MISSING: #admin-mount — Preact admin surface cannot render');
|
||||
});
|
||||
|
||||
it('old appContainer div is gone', () => {
|
||||
@@ -146,43 +146,9 @@ describe('Chat surface template (v0.37.10)', () => {
|
||||
'STALE: #appContainer still in templates — old SPA scaffold not removed');
|
||||
});
|
||||
|
||||
it('chat template loads Preact surface module', () => {
|
||||
assert.ok(templateSrc.includes('sw/surfaces/chat/index.js'),
|
||||
'MISSING: chat surface module import in template');
|
||||
});
|
||||
|
||||
it('chat template loads SDK boot', () => {
|
||||
it('admin template loads SDK boot', () => {
|
||||
assert.ok(templateSrc.includes('sw/sdk/index.js'),
|
||||
'MISSING: SDK boot import in chat template');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Preact surface policy gating ─────────────
|
||||
// v0.37.5: Settings surface elements are now rendered by Preact
|
||||
// components. Verify the components handle policy gating.
|
||||
|
||||
describe('Settings Preact surface handles policy gating', () => {
|
||||
const SURFACES = path.join(SRC, 'sw', 'surfaces', 'settings');
|
||||
const indexSrc = fs.readFileSync(path.join(SURFACES, 'index.js'), 'utf-8');
|
||||
|
||||
it('settings index checks allow_user_byok policy', () => {
|
||||
assert.ok(indexSrc.includes('allow_user_byok'),
|
||||
'MISSING: allow_user_byok check — BYOK nav items will show when disabled');
|
||||
});
|
||||
|
||||
it('settings index checks allow_user_personas policy', () => {
|
||||
assert.ok(indexSrc.includes('allow_user_personas'),
|
||||
'MISSING: allow_user_personas check — Personas nav will show when disabled');
|
||||
});
|
||||
|
||||
it('settings index has byokEnabled state', () => {
|
||||
assert.ok(indexSrc.includes('byokEnabled'),
|
||||
'MISSING: byokEnabled state — BYOK section gating broken');
|
||||
});
|
||||
|
||||
it('settings index has personasEnabled state', () => {
|
||||
assert.ok(indexSrc.includes('personasEnabled'),
|
||||
'MISSING: personasEnabled state — Personas section gating broken');
|
||||
'MISSING: SDK boot import in admin template');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,426 +0,0 @@
|
||||
// ==========================================
|
||||
// User Journey Model Tests — E2E Frontend
|
||||
// ==========================================
|
||||
// These tests feed the ACTUAL processModelsResponse()
|
||||
// (same code as fetchModels() in app.js) with backend
|
||||
// response data shaped EXACTLY like GET /models/enabled
|
||||
// for every user permutation.
|
||||
//
|
||||
// If these pass but the UI is broken, the bug is in
|
||||
// the DOM rendering — not the data pipeline.
|
||||
//
|
||||
// Run: node --test src/js/__tests__/user-journey-models.test.js
|
||||
// ==========================================
|
||||
|
||||
const { describe, it } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { processModelsResponse } = require('./helpers');
|
||||
|
||||
// ── Backend response factories ──────────────────
|
||||
// These match the EXACT JSON shape from GET /models/enabled
|
||||
// (Go struct: models.UserModel serialized as JSON)
|
||||
|
||||
function globalModel(modelId, configId, providerName) {
|
||||
return {
|
||||
id: modelId,
|
||||
model_id: modelId,
|
||||
display_name: modelId,
|
||||
provider_config_id: configId,
|
||||
config_id: configId,
|
||||
provider_name: providerName,
|
||||
provider_type: 'openai',
|
||||
source: 'catalog',
|
||||
scope: 'global',
|
||||
is_persona: false,
|
||||
capabilities: { streaming: true },
|
||||
hidden: false,
|
||||
};
|
||||
}
|
||||
|
||||
function teamModel(modelId, configId, providerName, teamName) {
|
||||
return {
|
||||
id: modelId,
|
||||
model_id: modelId,
|
||||
display_name: modelId,
|
||||
provider_config_id: configId,
|
||||
config_id: configId,
|
||||
provider_name: providerName,
|
||||
provider_type: 'venice',
|
||||
source: 'catalog',
|
||||
scope: 'team',
|
||||
is_persona: false,
|
||||
capabilities: { streaming: true },
|
||||
hidden: false,
|
||||
team_name: teamName,
|
||||
};
|
||||
}
|
||||
|
||||
function personalModel(modelId, configId, providerName) {
|
||||
return {
|
||||
id: modelId,
|
||||
model_id: modelId,
|
||||
display_name: modelId,
|
||||
provider_config_id: configId,
|
||||
config_id: configId,
|
||||
provider_name: providerName,
|
||||
provider_type: 'venice',
|
||||
source: 'catalog',
|
||||
scope: 'personal',
|
||||
is_persona: false,
|
||||
capabilities: { streaming: true },
|
||||
hidden: false,
|
||||
};
|
||||
}
|
||||
|
||||
function personaModel(personaId, baseModelId, scope, teamName) {
|
||||
return {
|
||||
id: personaId,
|
||||
model_id: baseModelId,
|
||||
display_name: `Persona: ${baseModelId}`,
|
||||
persona_id: personaId,
|
||||
persona_scope: scope,
|
||||
persona_team_name: teamName || '',
|
||||
is_persona: true,
|
||||
source: 'persona',
|
||||
scope: scope,
|
||||
capabilities: { streaming: true },
|
||||
};
|
||||
}
|
||||
|
||||
// ── Shared fixtures ──────────────────────────────
|
||||
|
||||
const GLOBAL_CONFIG_ID = 'cfg-global-openai';
|
||||
const TEAM_CONFIG_ID = 'cfg-team-venice';
|
||||
const BYOK_CONFIG_ID = 'cfg-personal-venice';
|
||||
|
||||
const GLOBAL_MODELS = [
|
||||
globalModel('gpt-4o', GLOBAL_CONFIG_ID, 'OpenAI Production'),
|
||||
globalModel('gpt-4o-mini', GLOBAL_CONFIG_ID, 'OpenAI Production'),
|
||||
];
|
||||
|
||||
const TEAM_MODELS = [
|
||||
teamModel('llama-3.3-70b', TEAM_CONFIG_ID, 'Team Venice', 'Engineering'),
|
||||
];
|
||||
|
||||
const BYOK_MODELS = [
|
||||
personalModel('llama-3.3-70b', BYOK_CONFIG_ID, 'My Venice Key'),
|
||||
personalModel('deepseek-r1', BYOK_CONFIG_ID, 'My Venice Key'),
|
||||
];
|
||||
|
||||
// ═══════════════════════════════════════════════
|
||||
// USER PERMUTATION TESTS
|
||||
// ═══════════════════════════════════════════════
|
||||
|
||||
describe('User Journey: Platform Admin (no team, no BYOK)', () => {
|
||||
// Backend returns: 2 global enabled models
|
||||
const apiResponse = { models: [...GLOBAL_MODELS] };
|
||||
const models = processModelsResponse(apiResponse);
|
||||
|
||||
it('sees exactly 2 models', () => {
|
||||
assert.equal(models.length, 2);
|
||||
});
|
||||
|
||||
it('all models have composite IDs (configId:modelId)', () => {
|
||||
assert.equal(models[0].id, `${GLOBAL_CONFIG_ID}:gpt-4o`);
|
||||
assert.equal(models[1].id, `${GLOBAL_CONFIG_ID}:gpt-4o-mini`);
|
||||
});
|
||||
|
||||
it('all models have configId for routing', () => {
|
||||
for (const m of models) {
|
||||
assert.ok(m.configId, `model ${m.baseModelId} must have configId`);
|
||||
assert.equal(m.configId, GLOBAL_CONFIG_ID);
|
||||
}
|
||||
});
|
||||
|
||||
it('all models have provider name for display', () => {
|
||||
for (const m of models) {
|
||||
assert.equal(m.provider, 'OpenAI Production');
|
||||
}
|
||||
});
|
||||
|
||||
it('no models are hidden by default', () => {
|
||||
for (const m of models) {
|
||||
assert.equal(m.hidden, false);
|
||||
}
|
||||
});
|
||||
|
||||
it('source is catalog (not persona)', () => {
|
||||
for (const m of models) {
|
||||
assert.equal(m.isPersona, false);
|
||||
assert.equal(m.source, 'catalog');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('User Journey: Team Member (global + team)', () => {
|
||||
// Backend returns: 2 global + 1 team model
|
||||
const apiResponse = { models: [...GLOBAL_MODELS, ...TEAM_MODELS] };
|
||||
const models = processModelsResponse(apiResponse);
|
||||
|
||||
it('sees exactly 3 models', () => {
|
||||
assert.equal(models.length, 3);
|
||||
});
|
||||
|
||||
it('team model has different configId from global', () => {
|
||||
const teamModel = models.find(m => m.baseModelId === 'llama-3.3-70b');
|
||||
assert.ok(teamModel, 'llama-3.3-70b should be present');
|
||||
assert.equal(teamModel.configId, TEAM_CONFIG_ID);
|
||||
assert.notEqual(teamModel.configId, GLOBAL_CONFIG_ID);
|
||||
});
|
||||
|
||||
it('team model composite ID is unique from global with same model_id', () => {
|
||||
// If both global AND team have "llama-3.3-70b", they must have different composite IDs
|
||||
const globalLlama = globalModel('llama-3.3-70b', GLOBAL_CONFIG_ID, 'OpenAI');
|
||||
const resp = { models: [globalLlama, ...TEAM_MODELS] };
|
||||
const result = processModelsResponse(resp);
|
||||
|
||||
const ids = result.map(m => m.id);
|
||||
const uniqueIds = new Set(ids);
|
||||
assert.equal(ids.length, uniqueIds.size,
|
||||
`IDs must be unique: ${JSON.stringify(ids)}`);
|
||||
});
|
||||
|
||||
it('team model has correct source', () => {
|
||||
const teamModel = models.find(m => m.baseModelId === 'llama-3.3-70b');
|
||||
// source comes from backend — "catalog" for both global and team
|
||||
assert.equal(teamModel.source, 'catalog');
|
||||
});
|
||||
});
|
||||
|
||||
describe('User Journey: BYOK User (global + personal)', () => {
|
||||
// Backend returns: 2 global + 2 personal models
|
||||
const apiResponse = { models: [...GLOBAL_MODELS, ...BYOK_MODELS] };
|
||||
const models = processModelsResponse(apiResponse);
|
||||
|
||||
it('sees exactly 4 models', () => {
|
||||
assert.equal(models.length, 4);
|
||||
});
|
||||
|
||||
it('personal models have unique composite IDs', () => {
|
||||
const personal = models.filter(m => m.configId === BYOK_CONFIG_ID);
|
||||
assert.equal(personal.length, 2, 'should have 2 personal models');
|
||||
assert.equal(personal[0].id, `${BYOK_CONFIG_ID}:llama-3.3-70b`);
|
||||
assert.equal(personal[1].id, `${BYOK_CONFIG_ID}:deepseek-r1`);
|
||||
});
|
||||
|
||||
it('personal and global models with same model_id have different composite IDs', () => {
|
||||
// Both global and personal might have "llama-3.3-70b" — must not collide
|
||||
const globalLlama = globalModel('llama-3.3-70b', GLOBAL_CONFIG_ID, 'OpenAI');
|
||||
const personalLlama = personalModel('llama-3.3-70b', BYOK_CONFIG_ID, 'My Key');
|
||||
const resp = { models: [globalLlama, personalLlama] };
|
||||
const result = processModelsResponse(resp);
|
||||
|
||||
assert.equal(result[0].id, `${GLOBAL_CONFIG_ID}:llama-3.3-70b`);
|
||||
assert.equal(result[1].id, `${BYOK_CONFIG_ID}:llama-3.3-70b`);
|
||||
assert.notEqual(result[0].id, result[1].id);
|
||||
});
|
||||
|
||||
it('personal model provider name shows user key name, not global', () => {
|
||||
const personal = models.find(m => m.configId === BYOK_CONFIG_ID);
|
||||
assert.equal(personal.provider, 'My Venice Key');
|
||||
});
|
||||
});
|
||||
|
||||
describe('User Journey: Team Member + BYOK (global + team + personal)', () => {
|
||||
// Backend returns: 2 global + 1 team + 2 personal
|
||||
const apiResponse = { models: [...GLOBAL_MODELS, ...TEAM_MODELS, ...BYOK_MODELS] };
|
||||
const models = processModelsResponse(apiResponse);
|
||||
|
||||
it('sees exactly 5 models', () => {
|
||||
assert.equal(models.length, 5);
|
||||
});
|
||||
|
||||
it('all three config IDs are represented', () => {
|
||||
const configIds = new Set(models.map(m => m.configId));
|
||||
assert.ok(configIds.has(GLOBAL_CONFIG_ID), 'missing global');
|
||||
assert.ok(configIds.has(TEAM_CONFIG_ID), 'missing team');
|
||||
assert.ok(configIds.has(BYOK_CONFIG_ID), 'missing personal');
|
||||
});
|
||||
|
||||
it('no ID collisions across scopes', () => {
|
||||
const ids = models.map(m => m.id);
|
||||
const uniqueIds = new Set(ids);
|
||||
assert.equal(ids.length, uniqueIds.size,
|
||||
`composite IDs must be unique across scopes: ${JSON.stringify(ids)}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('User Journey: Outsider (global only, no team, no BYOK)', () => {
|
||||
const apiResponse = { models: [...GLOBAL_MODELS] };
|
||||
const models = processModelsResponse(apiResponse);
|
||||
|
||||
it('sees exactly 2 global models', () => {
|
||||
assert.equal(models.length, 2);
|
||||
});
|
||||
|
||||
it('no team or personal models leak in', () => {
|
||||
for (const m of models) {
|
||||
assert.equal(m.configId, GLOBAL_CONFIG_ID);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════
|
||||
// EDGE CASES FROM REAL BUGS
|
||||
// ═══════════════════════════════════════════════
|
||||
|
||||
describe('Edge: Empty model list', () => {
|
||||
it('handles empty array', () => {
|
||||
const models = processModelsResponse({ models: [] });
|
||||
assert.equal(models.length, 0);
|
||||
});
|
||||
|
||||
it('handles null models', () => {
|
||||
const models = processModelsResponse({ models: null });
|
||||
assert.equal(models.length, 0);
|
||||
});
|
||||
|
||||
it('handles missing models key', () => {
|
||||
const models = processModelsResponse({});
|
||||
assert.equal(models.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge: Missing provider_config_id', () => {
|
||||
it('falls back to model_id only (no composite ID)', () => {
|
||||
const resp = { models: [{
|
||||
model_id: 'gpt-4o',
|
||||
display_name: 'GPT-4o',
|
||||
// NO provider_config_id, NO config_id
|
||||
source: 'catalog',
|
||||
scope: 'global',
|
||||
}]};
|
||||
const models = processModelsResponse(resp);
|
||||
assert.equal(models[0].id, 'gpt-4o',
|
||||
'without configId, ID must fall back to bare model_id');
|
||||
assert.equal(models[0].configId, null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge: provider_config_id only (no config_id alias)', () => {
|
||||
it('uses provider_config_id for composite ID', () => {
|
||||
const resp = { models: [{
|
||||
model_id: 'gpt-4o',
|
||||
provider_config_id: 'cfg-uuid',
|
||||
// NO config_id alias
|
||||
provider_name: 'OpenAI',
|
||||
source: 'catalog',
|
||||
}]};
|
||||
const models = processModelsResponse(resp);
|
||||
assert.equal(models[0].id, 'cfg-uuid:gpt-4o');
|
||||
assert.equal(models[0].configId, 'cfg-uuid');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge: Hidden model preferences', () => {
|
||||
it('marks hidden models correctly', () => {
|
||||
const hidden = new Set(['cfg-global-openai:gpt-4o-mini']);
|
||||
const models = processModelsResponse({ models: GLOBAL_MODELS }, hidden);
|
||||
const mini = models.find(m => m.baseModelId === 'gpt-4o-mini');
|
||||
const main = models.find(m => m.baseModelId === 'gpt-4o');
|
||||
assert.equal(mini.hidden, true, 'gpt-4o-mini should be hidden');
|
||||
assert.equal(main.hidden, false, 'gpt-4o should not be hidden');
|
||||
});
|
||||
|
||||
it('personas are never hidden', () => {
|
||||
const persona = personaModel('preset-1', 'gpt-4o', 'global');
|
||||
const hidden = new Set([':gpt-4o']);
|
||||
const models = processModelsResponse({ models: [persona] }, hidden);
|
||||
assert.equal(models[0].hidden, false, 'personas must never be hidden');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge: Personas mixed with catalog models', () => {
|
||||
const apiResponse = {
|
||||
models: [
|
||||
...GLOBAL_MODELS,
|
||||
personaModel('preset-code', 'gpt-4o', 'global'),
|
||||
personaModel('preset-team', 'llama-3.3-70b', 'team', 'Engineering'),
|
||||
],
|
||||
};
|
||||
const models = processModelsResponse(apiResponse);
|
||||
|
||||
it('personas use persona_id as ID, not composite', () => {
|
||||
const persona = models.find(m => m.personaId === 'preset-code');
|
||||
assert.ok(persona, 'preset-code should be present');
|
||||
assert.equal(persona.id, 'preset-code');
|
||||
assert.equal(persona.isPersona, true);
|
||||
});
|
||||
|
||||
it('persona and catalog model with same model_id have different IDs', () => {
|
||||
const catalog = models.find(m => !m.isPersona && m.baseModelId === 'gpt-4o');
|
||||
const persona = models.find(m => m.isPersona && m.baseModelId === 'gpt-4o');
|
||||
assert.ok(catalog, 'catalog gpt-4o should exist');
|
||||
assert.ok(persona, 'preset gpt-4o should exist');
|
||||
assert.notEqual(catalog.id, persona.id);
|
||||
});
|
||||
|
||||
it('persona team name flows through', () => {
|
||||
const teamPersona = models.find(m => m.personaId === 'preset-team');
|
||||
assert.equal(teamPersona.personaTeamName, 'Engineering');
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════
|
||||
// FULL MATRIX: 4 actors × expected model counts
|
||||
// ═══════════════════════════════════════════════
|
||||
// This matches the backend TestUserJourney_FullMatrix
|
||||
// and proves the frontend handles the same data correctly.
|
||||
|
||||
describe('Full Matrix: 4 actors × backend response → frontend model count', () => {
|
||||
const matrix = [
|
||||
{
|
||||
actor: 'platformAdmin',
|
||||
models: [...GLOBAL_MODELS],
|
||||
expectedCount: 2,
|
||||
desc: 'global only',
|
||||
},
|
||||
{
|
||||
actor: 'teamAdmin',
|
||||
models: [...GLOBAL_MODELS, ...TEAM_MODELS],
|
||||
expectedCount: 3,
|
||||
desc: 'global + team',
|
||||
},
|
||||
{
|
||||
actor: 'teamMember',
|
||||
models: [...GLOBAL_MODELS, ...TEAM_MODELS, ...BYOK_MODELS],
|
||||
expectedCount: 5,
|
||||
desc: 'global + team + personal',
|
||||
},
|
||||
{
|
||||
actor: 'outsider',
|
||||
models: [...GLOBAL_MODELS,
|
||||
personalModel('llama-outsider-byok', 'cfg-outsider', 'Outsider Key')],
|
||||
expectedCount: 3,
|
||||
desc: 'global + own BYOK',
|
||||
},
|
||||
];
|
||||
|
||||
for (const tc of matrix) {
|
||||
it(`${tc.actor} (${tc.desc}): ${tc.expectedCount} models`, () => {
|
||||
const result = processModelsResponse({ models: tc.models });
|
||||
assert.equal(result.length, tc.expectedCount,
|
||||
`${tc.actor} should see ${tc.expectedCount}, got ${result.length}: ` +
|
||||
result.map(m => `${m.baseModelId}(${m.configId})`).join(', '));
|
||||
});
|
||||
|
||||
it(`${tc.actor}: all IDs unique`, () => {
|
||||
const result = processModelsResponse({ models: tc.models });
|
||||
const ids = result.map(m => m.id);
|
||||
const unique = new Set(ids);
|
||||
assert.equal(ids.length, unique.size,
|
||||
`${tc.actor}: duplicate IDs: ${JSON.stringify(ids)}`);
|
||||
});
|
||||
|
||||
it(`${tc.actor}: all models have configId or are personas`, () => {
|
||||
const result = processModelsResponse({ models: tc.models });
|
||||
for (const m of result) {
|
||||
if (!m.isPersona) {
|
||||
assert.ok(m.configId,
|
||||
`${tc.actor}: model ${m.baseModelId} missing configId — ` +
|
||||
`frontend will fail to route completions`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user