From e865b353e402062813160c714fa8c559ef0545f4 Mon Sep 17 00:00:00 2001 From: Jeffrey Smith Date: Thu, 26 Mar 2026 11:29:33 +0000 Subject: [PATCH] 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) --- src/js/__tests__/api-contracts.test.js | 432 ------------------- src/js/__tests__/channel-contracts.test.js | 149 ------- src/js/__tests__/model-processing.test.js | 319 -------------- src/js/__tests__/policy-gating.test.js | 52 +-- src/js/__tests__/user-journey-models.test.js | 426 ------------------ 5 files changed, 9 insertions(+), 1369 deletions(-) delete mode 100644 src/js/__tests__/api-contracts.test.js delete mode 100644 src/js/__tests__/channel-contracts.test.js delete mode 100644 src/js/__tests__/model-processing.test.js delete mode 100644 src/js/__tests__/user-journey-models.test.js diff --git a/src/js/__tests__/api-contracts.test.js b/src/js/__tests__/api-contracts.test.js deleted file mode 100644 index f7c7b17..0000000 --- a/src/js/__tests__/api-contracts.test.js +++ /dev/null @@ -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); - }); -}); diff --git a/src/js/__tests__/channel-contracts.test.js b/src/js/__tests__/channel-contracts.test.js deleted file mode 100644 index e2ccc21..0000000 --- a/src/js/__tests__/channel-contracts.test.js +++ /dev/null @@ -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')); - }); -}); diff --git a/src/js/__tests__/model-processing.test.js b/src/js/__tests__/model-processing.test.js deleted file mode 100644 index 3f6e193..0000000 --- a/src/js/__tests__/model-processing.test.js +++ /dev/null @@ -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'); - }); -}); diff --git a/src/js/__tests__/policy-gating.test.js b/src/js/__tests__/policy-gating.test.js index 90ef274..7d36428 100644 --- a/src/js/__tests__/policy-gating.test.js +++ b/src/js/__tests__/policy-gating.test.js @@ -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'); }); }); diff --git a/src/js/__tests__/user-journey-models.test.js b/src/js/__tests__/user-journey-models.test.js deleted file mode 100644 index 29378c3..0000000 --- a/src/js/__tests__/user-journey-models.test.js +++ /dev/null @@ -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`); - } - } - }); - } -});