Changeset 0.9.0 (#50)
This commit is contained in:
448
src/js/__tests__/api-contracts.test.js
Normal file
448
src/js/__tests__/api-contracts.test.js
Normal file
@@ -0,0 +1,448 @@
|
||||
// ==========================================
|
||||
// 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 ────────────────────
|
||||
// Bug: Frontend read resp.data, backend sends resp.users
|
||||
// Fix: Read resp.users || resp.data
|
||||
|
||||
describe('GET /admin/users response contract', () => {
|
||||
// This is the ACTUAL backend response shape from ListUsers handler:
|
||||
// c.JSON(200, gin.H{"users": users, "total": total})
|
||||
const backendResponse = {
|
||||
users: [
|
||||
{ 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 "users" key (not "data")', () => {
|
||||
assert.ok(Array.isArray(backendResponse.users), 'response.users must be an array');
|
||||
assert.equal(backendResponse.data, undefined, 'response.data must NOT exist');
|
||||
});
|
||||
|
||||
it('frontend extraction reads .users with .data fallback', () => {
|
||||
// This mirrors the fixed loadMemberUserDropdown logic
|
||||
const users = backendResponse.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.users) {
|
||||
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.users.length);
|
||||
});
|
||||
});
|
||||
|
||||
// ── /models/enabled response ─────────────────
|
||||
// Bug: 500 from ambiguous SQL columns in JOIN
|
||||
// After fix: returns {models: [...]}
|
||||
|
||||
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 = {
|
||||
models: [
|
||||
{
|
||||
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: {},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it('response has "models" array', () => {
|
||||
assert.ok(Array.isArray(backendResponse.models));
|
||||
});
|
||||
|
||||
it('each model has required fields for selector', () => {
|
||||
for (const m of backendResponse.models) {
|
||||
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.models.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.models.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.models) {
|
||||
assert.equal(typeof m.capabilities, 'object');
|
||||
assert.notEqual(m.capabilities, null);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── /models/enabled with preset response ─────
|
||||
|
||||
describe('GET /models/enabled preset contract', () => {
|
||||
// ACTUAL backend UserModel for a persona includes both canonical and alias fields.
|
||||
const presetModel = {
|
||||
id: 'preset-uuid-123',
|
||||
model_id: 'gpt-4o',
|
||||
display_name: 'Code Helper',
|
||||
source: 'persona',
|
||||
// Backend canonical fields
|
||||
provider_config_id: 'cfg1',
|
||||
persona_id: 'preset-uuid-123',
|
||||
scope: 'global',
|
||||
avatar: null,
|
||||
// Frontend alias fields (populated by resolver)
|
||||
is_preset: true,
|
||||
preset_id: 'preset-uuid-123',
|
||||
preset_scope: 'global',
|
||||
preset_avatar: null,
|
||||
config_id: 'cfg1',
|
||||
provider_name: 'OpenAI',
|
||||
capabilities: { streaming: true },
|
||||
};
|
||||
|
||||
it('preset has is_preset = true', () => {
|
||||
assert.equal(presetModel.is_preset, true);
|
||||
});
|
||||
|
||||
it('preset has preset_id matching persona_id', () => {
|
||||
assert.ok(presetModel.preset_id);
|
||||
assert.equal(presetModel.preset_id, presetModel.persona_id,
|
||||
'preset_id alias must match persona_id');
|
||||
});
|
||||
|
||||
it('preset has preset_scope matching scope', () => {
|
||||
assert.ok(['global', 'team', 'personal'].includes(presetModel.preset_scope));
|
||||
assert.equal(presetModel.preset_scope, presetModel.scope,
|
||||
'preset_scope alias must match scope');
|
||||
});
|
||||
|
||||
it('preset ID uses preset_id not composite', () => {
|
||||
// Frontend: id = isPreset ? (m.preset_id || m.persona_id || m.id) : composite
|
||||
const id = presetModel.is_preset
|
||||
? (presetModel.preset_id || presetModel.persona_id || presetModel.id)
|
||||
: `${presetModel.config_id || presetModel.provider_config_id}:${presetModel.model_id}`;
|
||||
assert.equal(id, 'preset-uuid-123');
|
||||
});
|
||||
|
||||
it('frontend works with ONLY canonical fields (no aliases)', () => {
|
||||
// If someone breaks the alias population in resolver.go,
|
||||
// the frontend fallback chain must still produce correct IDs.
|
||||
const rawBackend = {
|
||||
id: 'preset-uuid-123',
|
||||
model_id: 'gpt-4o',
|
||||
source: 'persona',
|
||||
provider_config_id: 'cfg1',
|
||||
persona_id: 'preset-uuid-123',
|
||||
scope: 'global',
|
||||
// NO is_preset, NO preset_id, NO config_id aliases
|
||||
};
|
||||
// is_preset would be undefined/falsy → treated as catalog model
|
||||
// This test documents the FAILURE MODE if aliases break
|
||||
const isPreset = !!rawBackend.is_preset; // false!
|
||||
assert.equal(isPreset, false,
|
||||
'Without is_preset alias, persona is misidentified as catalog model');
|
||||
});
|
||||
});
|
||||
|
||||
// ── /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', () => {
|
||||
// This is the ACTUAL CatalogEntry shape from ListAll → ListModelConfigs handler.
|
||||
const backendResponse = {
|
||||
models: [
|
||||
{
|
||||
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 "models" array (not null)', () => {
|
||||
assert.ok(Array.isArray(backendResponse.models),
|
||||
'CRITICAL: models must be [] not null — null causes frontend fallback chain to pick wrong object');
|
||||
});
|
||||
|
||||
it('each model has id and model_id', () => {
|
||||
for (const m of backendResponse.models) {
|
||||
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.models) {
|
||||
assert.ok(['enabled', 'disabled', 'team'].includes(m.visibility),
|
||||
`visibility must be enabled/disabled/team, got: ${m.visibility}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('frontend extraction handles both null and empty array', () => {
|
||||
// This is the exact fallback chain from loadAdminModels
|
||||
const nullResp = { models: null };
|
||||
const list1 = nullResp.models || nullResp.data || nullResp || [];
|
||||
const arr1 = Array.isArray(list1) ? list1 : [];
|
||||
assert.equal(arr1.length, 0, 'null models must produce empty array');
|
||||
|
||||
const emptyResp = { models: [] };
|
||||
const list2 = emptyResp.models || emptyResp.data || emptyResp || [];
|
||||
const arr2 = Array.isArray(list2) ? list2 : [];
|
||||
assert.equal(arr2.length, 0, 'empty models must produce empty array');
|
||||
});
|
||||
});
|
||||
|
||||
// ── /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', () => {
|
||||
it('admin/users uses {users:[]} not {data:[]}', () => {
|
||||
// This is the contract the team member dropdown depends on
|
||||
const shape = { users: [], total: 0 };
|
||||
assert.ok('users' in shape, 'admin/users must use "users" key');
|
||||
});
|
||||
|
||||
it('teams/members uses {data:[]}', () => {
|
||||
const shape = { data: [] };
|
||||
assert.ok('data' in shape, 'teams/members uses "data" key');
|
||||
});
|
||||
|
||||
it('models/enabled uses {models:[]}', () => {
|
||||
const shape = { models: [] };
|
||||
assert.ok('models' in shape);
|
||||
});
|
||||
|
||||
it('admin/models uses {models:[]}', () => {
|
||||
const shape = { models: [] };
|
||||
assert.ok('models' in shape, 'admin/models must use "models" key');
|
||||
});
|
||||
|
||||
it('presets uses {presets:[]}', () => {
|
||||
const shape = { presets: [] };
|
||||
assert.ok('presets' in shape);
|
||||
});
|
||||
});
|
||||
160
src/js/__tests__/helpers.js
Normal file
160
src/js/__tests__/helpers.js
Normal file
@@ -0,0 +1,160 @@
|
||||
// ==========================================
|
||||
// Test Helper — Mock Browser Context
|
||||
// ==========================================
|
||||
// Loads the vanilla JS source files into a
|
||||
// simulated browser environment so tests run
|
||||
// against the ACTUAL frontend code.
|
||||
// ==========================================
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
|
||||
const SRC = path.join(__dirname, '..');
|
||||
|
||||
/**
|
||||
* Creates a minimal browser-like context with stubs for DOM, localStorage, etc.
|
||||
* Returns the context sandbox so tests can inspect globals like API, App, UI.
|
||||
*/
|
||||
function createBrowserContext(overrides = {}) {
|
||||
const storage = {};
|
||||
const elements = {};
|
||||
|
||||
const sandbox = {
|
||||
// Window / globals
|
||||
window: {},
|
||||
document: {
|
||||
getElementById(id) {
|
||||
return elements[id] || null;
|
||||
},
|
||||
querySelector() { return null; },
|
||||
querySelectorAll() { return []; },
|
||||
addEventListener() {},
|
||||
documentElement: { style: { setProperty() {} } },
|
||||
createElement(tag) {
|
||||
return {
|
||||
tagName: tag.toUpperCase(),
|
||||
style: {}, classList: { add() {}, remove() {}, toggle() {}, contains() { return false; } },
|
||||
setAttribute() {}, getAttribute() { return null; },
|
||||
appendChild() {}, removeChild() {}, replaceChildren() {},
|
||||
addEventListener() {},
|
||||
innerHTML: '', textContent: '', value: '',
|
||||
children: [], childNodes: [],
|
||||
querySelector() { return null; },
|
||||
querySelectorAll() { return []; },
|
||||
};
|
||||
},
|
||||
},
|
||||
localStorage: {
|
||||
_data: storage,
|
||||
getItem(k) { return storage[k] || null; },
|
||||
setItem(k, v) { storage[k] = String(v); },
|
||||
removeItem(k) { delete storage[k]; },
|
||||
},
|
||||
console,
|
||||
setTimeout: globalThis.setTimeout,
|
||||
clearTimeout: globalThis.clearTimeout,
|
||||
setInterval: globalThis.setInterval,
|
||||
clearInterval: globalThis.clearInterval,
|
||||
requestAnimationFrame(cb) { cb(); },
|
||||
fetch: async () => ({ ok: true, status: 200, json: async () => ({}) }),
|
||||
alert() {},
|
||||
confirm() { return true; },
|
||||
prompt() { return ''; },
|
||||
navigator: { userAgent: 'test', clipboard: { writeText() {} } },
|
||||
location: { href: 'http://localhost/dev/', pathname: '/dev/' },
|
||||
URL: globalThis.URL,
|
||||
Blob: globalThis.Blob,
|
||||
FileReader: class { readAsDataURL() {} readAsText() {} },
|
||||
WebSocket: class { addEventListener() {} send() {} close() {} },
|
||||
Image: class { set src(v) {} },
|
||||
MutationObserver: class { observe() {} disconnect() {} },
|
||||
IntersectionObserver: class { observe() {} disconnect() {} },
|
||||
// DOMPurify stub
|
||||
DOMPurify: { sanitize: (s) => s },
|
||||
// marked stub
|
||||
marked: { parse: (s) => s, setOptions() {} },
|
||||
// hljs stub
|
||||
hljs: { highlightElement() {}, getLanguage() { return null; } },
|
||||
|
||||
__BASE__: '/dev',
|
||||
|
||||
...overrides,
|
||||
};
|
||||
|
||||
// Self-references
|
||||
sandbox.window = sandbox;
|
||||
sandbox.globalThis = sandbox;
|
||||
sandbox.self = sandbox;
|
||||
|
||||
// Register mock elements
|
||||
sandbox._setElement = (id, props = {}) => {
|
||||
const el = sandbox.document.createElement('div');
|
||||
Object.assign(el, props);
|
||||
el.id = id;
|
||||
elements[id] = el;
|
||||
return el;
|
||||
};
|
||||
|
||||
return sandbox;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a source JS file into the sandbox context.
|
||||
* Returns the sandbox for inspection.
|
||||
*/
|
||||
function loadSource(sandbox, filename) {
|
||||
const code = fs.readFileSync(path.join(SRC, filename), 'utf-8');
|
||||
const ctx = vm.createContext(sandbox);
|
||||
vm.runInContext(code, ctx, { filename });
|
||||
return sandbox;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads API + App modules into a fresh context.
|
||||
* Returns { API, App, sandbox }.
|
||||
*/
|
||||
function loadAppModules(overrides = {}) {
|
||||
const sandbox = createBrowserContext(overrides);
|
||||
loadSource(sandbox, 'api.js');
|
||||
loadSource(sandbox, 'app.js');
|
||||
return { API: sandbox.API, App: sandbox.App, sandbox };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the model-processing transform from fetchModels.
|
||||
* This is the core mapping logic that converts API response → App.models.
|
||||
*/
|
||||
function processModelsResponse(data, hiddenModels = new Set()) {
|
||||
return (data.models || []).map(m => {
|
||||
const isPreset = !!m.is_preset;
|
||||
const baseModelId = m.model_id || m.id;
|
||||
const cfgId = m.config_id || m.provider_config_id;
|
||||
const id = isPreset
|
||||
? (m.preset_id || m.persona_id || m.id)
|
||||
: (cfgId ? `${cfgId}:${baseModelId}` : baseModelId);
|
||||
return {
|
||||
id,
|
||||
baseModelId,
|
||||
name: m.display_name || baseModelId,
|
||||
provider: m.provider_name || m.provider || '',
|
||||
configId: cfgId || null,
|
||||
isPreset,
|
||||
presetId: m.preset_id || m.persona_id || null,
|
||||
presetScope: m.preset_scope || (isPreset ? m.scope : null) || null,
|
||||
presetAvatar: m.preset_avatar || (isPreset ? m.avatar : null) || null,
|
||||
presetTeamName: m.preset_team_name || (isPreset ? m.team_name : null) || null,
|
||||
source: m.source || (isPreset ? 'preset' : 'global'),
|
||||
teamName: m.preset_team_name || (isPreset ? m.team_name : null) || null,
|
||||
hidden: !isPreset && hiddenModels.has(baseModelId),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createBrowserContext,
|
||||
loadSource,
|
||||
loadAppModules,
|
||||
processModelsResponse,
|
||||
SRC,
|
||||
};
|
||||
317
src/js/__tests__/model-processing.test.js
Normal file
317
src/js/__tests__/model-processing.test.js
Normal file
@@ -0,0 +1,317 @@
|
||||
// ==========================================
|
||||
// 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 presets', () => {
|
||||
const models = processModelsResponse(apiResponse);
|
||||
assert.equal(models[0].isPreset, 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 — presets', () => {
|
||||
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_preset: true,
|
||||
preset_id: 'preset-uuid',
|
||||
preset_scope: 'global',
|
||||
preset_avatar: '/avatars/code.png',
|
||||
preset_team_name: null,
|
||||
config_id: 'cfg-uuid',
|
||||
provider_name: 'OpenAI',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it('uses preset_id as ID (not composite)', () => {
|
||||
const models = processModelsResponse(apiResponse);
|
||||
assert.equal(models[0].id, 'preset-uuid',
|
||||
'preset ID must use preset_id, not config_id:model_id');
|
||||
});
|
||||
|
||||
it('falls back to persona_id when preset_id is missing', () => {
|
||||
const resp = {
|
||||
models: [{
|
||||
id: 'p-uuid', model_id: 'gpt-4o',
|
||||
is_preset: 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 when preset_id is missing');
|
||||
});
|
||||
|
||||
it('marks as preset', () => {
|
||||
const models = processModelsResponse(apiResponse);
|
||||
assert.equal(models[0].isPreset, true);
|
||||
});
|
||||
|
||||
it('preserves preset metadata', () => {
|
||||
const models = processModelsResponse(apiResponse);
|
||||
assert.equal(models[0].presetScope, 'global');
|
||||
assert.equal(models[0].presetAvatar, '/avatars/code.png');
|
||||
assert.equal(models[0].presetId, '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', () => {
|
||||
const hidden = new Set(['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('presets are never hidden via model ID', () => {
|
||||
const resp = {
|
||||
models: [
|
||||
{ model_id: 'gpt-4o', is_preset: true, preset_id: 'p1', display_name: 'Preset' },
|
||||
],
|
||||
};
|
||||
const hidden = new Set(['gpt-4o']);
|
||||
const models = processModelsResponse(resp, hidden);
|
||||
assert.equal(models[0].hidden, false,
|
||||
'presets 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.isPreset && !b.isPreset) return -1;
|
||||
if (!a.isPreset && b.isPreset) return 1;
|
||||
if (a.isPreset && b.isPreset) {
|
||||
const sa = scopeOrder[a.presetScope] ?? 9;
|
||||
const sb = scopeOrder[b.presetScope] ?? 9;
|
||||
if (sa !== sb) return sa - sb;
|
||||
}
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}
|
||||
|
||||
it('presets sort before regular models', () => {
|
||||
const models = [
|
||||
{ name: 'GPT-4o', isPreset: false },
|
||||
{ name: 'Code Helper', isPreset: true, presetScope: 'global' },
|
||||
];
|
||||
const sorted = sortModels(models);
|
||||
assert.equal(sorted[0].name, 'Code Helper');
|
||||
assert.equal(sorted[1].name, 'GPT-4o');
|
||||
});
|
||||
|
||||
it('global presets sort before team presets', () => {
|
||||
const models = [
|
||||
{ name: 'Team Bot', isPreset: true, presetScope: 'team' },
|
||||
{ name: 'Global Bot', isPreset: true, presetScope: 'global' },
|
||||
{ name: 'My Bot', isPreset: true, presetScope: 'personal' },
|
||||
];
|
||||
const sorted = sortModels(models);
|
||||
assert.equal(sorted[0].presetScope, 'global');
|
||||
assert.equal(sorted[1].presetScope, 'team');
|
||||
assert.equal(sorted[2].presetScope, 'personal');
|
||||
});
|
||||
|
||||
it('regular models sort alphabetically', () => {
|
||||
const models = [
|
||||
{ name: 'Zephyr', isPreset: false },
|
||||
{ name: 'Claude', isPreset: false },
|
||||
{ name: 'GPT-4o', isPreset: false },
|
||||
];
|
||||
const sorted = sortModels(models);
|
||||
assert.equal(sorted[0].name, 'Claude');
|
||||
assert.equal(sorted[1].name, 'GPT-4o');
|
||||
assert.equal(sorted[2].name, 'Zephyr');
|
||||
});
|
||||
});
|
||||
247
src/js/__tests__/policy-gating.test.js
Normal file
247
src/js/__tests__/policy-gating.test.js
Normal file
@@ -0,0 +1,247 @@
|
||||
// ==========================================
|
||||
// Policy Gating & UI Logic Tests
|
||||
// ==========================================
|
||||
// Validates that admin policies correctly
|
||||
// gate user-facing features. These catch
|
||||
// the exact class of bugs where a backend
|
||||
// policy exists but the frontend doesn't
|
||||
// check it.
|
||||
//
|
||||
// Run: node --test src/js/__tests__/policy-gating.test.js
|
||||
// ==========================================
|
||||
|
||||
const { describe, it } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const SRC = path.join(__dirname, '..');
|
||||
|
||||
// ── Source code audits ───────────────────────
|
||||
// These tests read the actual source files and verify that required
|
||||
// wiring exists. If someone removes a policy check, CI breaks.
|
||||
|
||||
describe('Policy wiring audit — source code', () => {
|
||||
const uiSrc = fs.readFileSync(path.join(SRC, 'ui.js'), 'utf-8');
|
||||
const appSrc = fs.readFileSync(path.join(SRC, 'app.js'), 'utf-8');
|
||||
const indexSrc = fs.readFileSync(path.join(SRC, '..', 'index.html'), 'utf-8');
|
||||
|
||||
// ── allow_user_byok ──
|
||||
|
||||
it('ui.js has checkUserProvidersAllowed function', () => {
|
||||
assert.ok(uiSrc.includes('checkUserProvidersAllowed'),
|
||||
'MISSING: checkUserProvidersAllowed — provider tab will show when policy is off');
|
||||
});
|
||||
|
||||
it('checkUserProvidersAllowed reads App.policies.allow_user_byok', () => {
|
||||
assert.ok(uiSrc.includes('allow_user_byok'),
|
||||
'MISSING: allow_user_byok check in UI');
|
||||
});
|
||||
|
||||
// ── allow_user_personas ──
|
||||
|
||||
it('ui.js has checkUserPresetsAllowed function', () => {
|
||||
assert.ok(uiSrc.includes('checkUserPresetsAllowed'),
|
||||
'MISSING: checkUserPresetsAllowed — preset button will show when policy is off');
|
||||
});
|
||||
|
||||
it('checkUserPresetsAllowed reads App.policies.allow_user_personas', () => {
|
||||
assert.ok(uiSrc.includes('allow_user_personas'),
|
||||
'MISSING: allow_user_personas check in UI');
|
||||
});
|
||||
|
||||
it('admin settings UI has adminUserPresetsToggle', () => {
|
||||
assert.ok(indexSrc.includes('adminUserPresetsToggle'),
|
||||
'MISSING: preset toggle in admin settings HTML');
|
||||
});
|
||||
|
||||
it('admin settings load reads allow_user_personas', () => {
|
||||
assert.ok(uiSrc.includes("adminUserPresetsToggle"),
|
||||
'MISSING: loadAdminSettings must read allow_user_personas into toggle');
|
||||
});
|
||||
|
||||
it('admin settings save writes allow_user_personas', () => {
|
||||
assert.ok(appSrc.includes("allow_user_personas"),
|
||||
'MISSING: handleSaveAdminSettings must write allow_user_personas');
|
||||
});
|
||||
|
||||
// ── Settings live-apply ──
|
||||
|
||||
it('admin save calls initBanners for policy refresh', () => {
|
||||
assert.ok(appSrc.includes('await initBanners()'),
|
||||
'MISSING: initBanners call after admin save — policies won\'t refresh');
|
||||
});
|
||||
|
||||
it('admin save calls checkUserProvidersAllowed', () => {
|
||||
// Find in handleSaveAdminSettings
|
||||
const saveFunc = appSrc.slice(appSrc.indexOf('handleSaveAdminSettings'));
|
||||
assert.ok(saveFunc.includes('checkUserProvidersAllowed'),
|
||||
'MISSING: checkUserProvidersAllowed call after admin save');
|
||||
});
|
||||
|
||||
it('admin save calls checkUserPresetsAllowed', () => {
|
||||
const saveFunc = appSrc.slice(appSrc.indexOf('handleSaveAdminSettings'));
|
||||
assert.ok(saveFunc.includes('checkUserPresetsAllowed'),
|
||||
'MISSING: checkUserPresetsAllowed call after admin save');
|
||||
});
|
||||
|
||||
it('admin save calls fetchModels to refresh model list', () => {
|
||||
const saveFunc = appSrc.slice(appSrc.indexOf('handleSaveAdminSettings'));
|
||||
assert.ok(saveFunc.includes('fetchModels'),
|
||||
'MISSING: fetchModels call after admin save — model list stale after settings change');
|
||||
});
|
||||
|
||||
// ── Models tab calls preset check ──
|
||||
|
||||
it('models tab switch calls checkUserPresetsAllowed', () => {
|
||||
assert.ok(uiSrc.includes('checkUserPresetsAllowed'),
|
||||
'MISSING: checkUserPresetsAllowed call on models tab switch');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Policy evaluation logic ──────────────────
|
||||
|
||||
describe('Policy evaluation logic', () => {
|
||||
function isPolicyEnabled(policies, key) {
|
||||
return policies?.[key] === 'true';
|
||||
}
|
||||
|
||||
it('policy "true" (string) → enabled', () => {
|
||||
assert.equal(isPolicyEnabled({ allow_user_byok: 'true' }, 'allow_user_byok'), true);
|
||||
});
|
||||
|
||||
it('policy "false" (string) → disabled', () => {
|
||||
assert.equal(isPolicyEnabled({ allow_user_byok: 'false' }, 'allow_user_byok'), false);
|
||||
});
|
||||
|
||||
it('policy boolean true → NOT enabled (must be string)', () => {
|
||||
assert.equal(isPolicyEnabled({ allow_user_byok: true }, 'allow_user_byok'), false,
|
||||
'DANGER: policies are string "true"/"false", not boolean');
|
||||
});
|
||||
|
||||
it('missing policy → disabled', () => {
|
||||
assert.equal(isPolicyEnabled({}, 'allow_user_byok'), false);
|
||||
});
|
||||
|
||||
it('null policies → disabled', () => {
|
||||
assert.equal(isPolicyEnabled(null, 'allow_user_byok'), false);
|
||||
});
|
||||
|
||||
it('undefined policies → disabled', () => {
|
||||
assert.equal(isPolicyEnabled(undefined, 'allow_user_byok'), false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Team member dropdown logic ───────────────
|
||||
// Bug: Empty dropdown because resp.data used instead of resp.users
|
||||
|
||||
describe('Team member dropdown population', () => {
|
||||
function getAvailableUsers(usersResp, membersResp) {
|
||||
const users = usersResp.users || usersResp.data || [];
|
||||
const existingIds = new Set((membersResp.data || []).map(m => m.user_id));
|
||||
return users.filter(u => u.is_active && !existingIds.has(u.id));
|
||||
}
|
||||
|
||||
it('extracts users from {users:[]} response', () => {
|
||||
const usersResp = { users: [
|
||||
{ id: 'u1', username: 'alice', is_active: true },
|
||||
{ id: 'u2', username: 'bob', is_active: true },
|
||||
]};
|
||||
const membersResp = { data: [] };
|
||||
const available = getAvailableUsers(usersResp, membersResp);
|
||||
assert.equal(available.length, 2);
|
||||
});
|
||||
|
||||
it('falls back to {data:[]} response (backward compat)', () => {
|
||||
const usersResp = { data: [
|
||||
{ id: 'u1', username: 'alice', is_active: true },
|
||||
]};
|
||||
const membersResp = { data: [] };
|
||||
const available = getAvailableUsers(usersResp, membersResp);
|
||||
assert.equal(available.length, 1);
|
||||
});
|
||||
|
||||
it('excludes existing team members', () => {
|
||||
const usersResp = { users: [
|
||||
{ id: 'u1', username: 'alice', is_active: true },
|
||||
{ id: 'u2', username: 'bob', is_active: true },
|
||||
]};
|
||||
const membersResp = { data: [{ user_id: 'u1', role: 'admin' }] };
|
||||
const available = getAvailableUsers(usersResp, membersResp);
|
||||
assert.equal(available.length, 1);
|
||||
assert.equal(available[0].id, 'u2');
|
||||
});
|
||||
|
||||
it('excludes inactive users', () => {
|
||||
const usersResp = { users: [
|
||||
{ id: 'u1', username: 'alice', is_active: true },
|
||||
{ id: 'u2', username: 'disabled', is_active: false },
|
||||
]};
|
||||
const membersResp = { data: [] };
|
||||
const available = getAvailableUsers(usersResp, membersResp);
|
||||
assert.equal(available.length, 1);
|
||||
assert.equal(available[0].id, 'u1');
|
||||
});
|
||||
|
||||
it('returns empty for empty users response', () => {
|
||||
const available = getAvailableUsers({ users: [] }, { data: [] });
|
||||
assert.equal(available.length, 0);
|
||||
});
|
||||
|
||||
it('handles completely missing response fields', () => {
|
||||
const available = getAvailableUsers({}, {});
|
||||
assert.equal(available.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Admin settings field mapping ─────────────
|
||||
|
||||
describe('Admin settings field mapping', () => {
|
||||
// Maps what the frontend sends to what the backend expects
|
||||
const settingsFieldMap = {
|
||||
'adminRegToggle': 'allow_registration',
|
||||
'adminRegDefaultState': 'default_user_active',
|
||||
'adminUserProvidersToggle': 'allow_user_byok',
|
||||
'adminUserPresetsToggle': 'allow_user_personas',
|
||||
'adminBannerEnabled': 'banner',
|
||||
};
|
||||
|
||||
const appSrc = fs.readFileSync(path.join(SRC, 'app.js'), 'utf-8');
|
||||
const indexSrc = fs.readFileSync(path.join(SRC, '..', 'index.html'), 'utf-8');
|
||||
|
||||
for (const [elementId, settingKey] of Object.entries(settingsFieldMap)) {
|
||||
it(`HTML has element #${elementId}`, () => {
|
||||
assert.ok(indexSrc.includes(`id="${elementId}"`),
|
||||
`MISSING: #${elementId} in index.html — admin settings incomplete`);
|
||||
});
|
||||
|
||||
it(`app.js writes setting "${settingKey}"`, () => {
|
||||
assert.ok(appSrc.includes(settingKey),
|
||||
`MISSING: "${settingKey}" in handleSaveAdminSettings`);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ── HTML element existence checks ────────────
|
||||
|
||||
describe('Critical HTML elements exist', () => {
|
||||
const indexSrc = fs.readFileSync(path.join(SRC, '..', 'index.html'), 'utf-8');
|
||||
|
||||
const requiredElements = [
|
||||
'adminMemberUser', // Team member user dropdown
|
||||
'userPresetList', // User preset list container
|
||||
'userAddPresetBtn', // New preset button (policy-gated)
|
||||
'userAddPresetForm', // Preset form container
|
||||
'userProvidersDisabled', // BYOK disabled notice
|
||||
'providerShowAddBtn', // Add provider button (policy-gated)
|
||||
'adminUserProvidersToggle', // Admin toggle for BYOK
|
||||
'adminUserPresetsToggle', // Admin toggle for presets
|
||||
];
|
||||
|
||||
for (const id of requiredElements) {
|
||||
it(`#${id} exists in index.html`, () => {
|
||||
assert.ok(indexSrc.includes(`id="${id}"`),
|
||||
`MISSING element: #${id} — UI feature will break`);
|
||||
});
|
||||
}
|
||||
});
|
||||
426
src/js/__tests__/user-journey-models.test.js
Normal file
426
src/js/__tests__/user-journey-models.test.js
Normal file
@@ -0,0 +1,426 @@
|
||||
// ==========================================
|
||||
// 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_preset: 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_preset: 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_preset: false,
|
||||
capabilities: { streaming: true },
|
||||
hidden: false,
|
||||
};
|
||||
}
|
||||
|
||||
function presetModel(presetId, baseModelId, scope, teamName) {
|
||||
return {
|
||||
id: presetId,
|
||||
model_id: baseModelId,
|
||||
display_name: `Preset: ${baseModelId}`,
|
||||
preset_id: presetId,
|
||||
preset_scope: scope,
|
||||
preset_team_name: teamName || '',
|
||||
is_preset: 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 preset)', () => {
|
||||
for (const m of models) {
|
||||
assert.equal(m.isPreset, 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(['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('presets are never hidden', () => {
|
||||
const preset = presetModel('preset-1', 'gpt-4o', 'global');
|
||||
const hidden = new Set(['gpt-4o']);
|
||||
const models = processModelsResponse({ models: [preset] }, hidden);
|
||||
assert.equal(models[0].hidden, false, 'presets must never be hidden');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge: Presets mixed with catalog models', () => {
|
||||
const apiResponse = {
|
||||
models: [
|
||||
...GLOBAL_MODELS,
|
||||
presetModel('preset-code', 'gpt-4o', 'global'),
|
||||
presetModel('preset-team', 'llama-3.3-70b', 'team', 'Engineering'),
|
||||
],
|
||||
};
|
||||
const models = processModelsResponse(apiResponse);
|
||||
|
||||
it('presets use preset_id as ID, not composite', () => {
|
||||
const preset = models.find(m => m.presetId === 'preset-code');
|
||||
assert.ok(preset, 'preset-code should be present');
|
||||
assert.equal(preset.id, 'preset-code');
|
||||
assert.equal(preset.isPreset, true);
|
||||
});
|
||||
|
||||
it('preset and catalog model with same model_id have different IDs', () => {
|
||||
const catalog = models.find(m => !m.isPreset && m.baseModelId === 'gpt-4o');
|
||||
const preset = models.find(m => m.isPreset && m.baseModelId === 'gpt-4o');
|
||||
assert.ok(catalog, 'catalog gpt-4o should exist');
|
||||
assert.ok(preset, 'preset gpt-4o should exist');
|
||||
assert.notEqual(catalog.id, preset.id);
|
||||
});
|
||||
|
||||
it('preset team name flows through', () => {
|
||||
const teamPreset = models.find(m => m.presetId === 'preset-team');
|
||||
assert.equal(teamPreset.presetTeamName, '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 presets`, () => {
|
||||
const result = processModelsResponse({ models: tc.models });
|
||||
for (const m of result) {
|
||||
if (!m.isPreset) {
|
||||
assert.ok(m.configId,
|
||||
`${tc.actor}: model ${m.baseModelId} missing configId — ` +
|
||||
`frontend will fail to route completions`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -136,7 +136,7 @@ const API = {
|
||||
const body = {};
|
||||
if (model) body.model = model;
|
||||
if (presetId) body.preset_id = presetId;
|
||||
if (apiConfigId) body.api_config_id = apiConfigId;
|
||||
if (apiConfigId) body.provider_config_id = apiConfigId;
|
||||
|
||||
let resp = await fetch(BASE + `/api/v1/channels/${channelId}/messages/${messageId}/regenerate`, {
|
||||
method: 'POST',
|
||||
@@ -182,7 +182,7 @@ const API = {
|
||||
} else {
|
||||
if (model) body.model = model;
|
||||
}
|
||||
if (apiConfigId) body.api_config_id = apiConfigId;
|
||||
if (apiConfigId) body.provider_config_id = apiConfigId;
|
||||
// Only send max_tokens if user explicitly set it (non-zero = override)
|
||||
if (App.settings.maxTokens > 0) body.max_tokens = App.settings.maxTokens;
|
||||
|
||||
@@ -228,12 +228,16 @@ const API = {
|
||||
// ── API Configs (user providers) ─────────
|
||||
|
||||
listConfigs() { return this._get('/api/v1/api-configs'); },
|
||||
getConfig(id) { return this._get(`/api/v1/api-configs/${id}`); },
|
||||
createConfig(name, provider, endpoint, apiKey, modelDefault) {
|
||||
return this._post('/api/v1/api-configs', {
|
||||
name, provider, endpoint, api_key: apiKey, model_default: modelDefault
|
||||
});
|
||||
},
|
||||
deleteConfig(id) { return this._del(`/api/v1/api-configs/${id}`); },
|
||||
updateConfig(id, patch) { return this._put(`/api/v1/api-configs/${id}`, patch); },
|
||||
listProviderModels(id) { return this._get(`/api/v1/api-configs/${id}/models`); },
|
||||
fetchProviderModels(id) { return this._post(`/api/v1/api-configs/${id}/models/fetch`); },
|
||||
|
||||
// ── Profile & Settings ───────────────────
|
||||
|
||||
|
||||
138
src/js/app.js
138
src/js/app.js
@@ -10,6 +10,7 @@ const App = {
|
||||
isGenerating: false,
|
||||
abortController: null,
|
||||
serverSettings: {},
|
||||
policies: {},
|
||||
|
||||
// Find model by composite ID, with fallback to bare model_id match
|
||||
findModel(id) {
|
||||
@@ -226,19 +227,19 @@ async function fetchModels() {
|
||||
// collisions when the same model exists across team/personal/global providers
|
||||
const id = isPreset
|
||||
? (m.preset_id || m.id)
|
||||
: (m.config_id ? `${m.config_id}:${baseModelId}` : baseModelId);
|
||||
: ((m.config_id || m.provider_config_id) ? `${m.config_id || m.provider_config_id}:${baseModelId}` : baseModelId);
|
||||
return {
|
||||
id,
|
||||
baseModelId,
|
||||
name: m.display_name || baseModelId,
|
||||
provider: m.provider_name || m.provider || '',
|
||||
configId: m.config_id || null,
|
||||
configId: m.config_id || m.provider_config_id || null,
|
||||
capabilities: resolveCapabilities(m.capabilities, baseModelId),
|
||||
isPreset,
|
||||
presetId: m.preset_id || null,
|
||||
presetScope: m.preset_scope || null,
|
||||
presetAvatar: m.preset_avatar || null,
|
||||
presetTeamName: m.preset_team_name || null,
|
||||
presetId: m.preset_id || m.persona_id || null,
|
||||
presetScope: m.preset_scope || (isPreset ? m.scope : null) || null,
|
||||
presetAvatar: m.preset_avatar || (isPreset ? m.avatar : null) || null,
|
||||
presetTeamName: m.preset_team_name || (isPreset ? m.team_name : null) || null,
|
||||
source: m.source || (isPreset ? 'preset' : 'global'),
|
||||
teamName: m.preset_team_name || null,
|
||||
hidden: !isPreset && App.hiddenModels.has(baseModelId),
|
||||
@@ -1511,14 +1512,39 @@ async function handleCreateProvider() {
|
||||
const endpoint = document.getElementById('providerEndpoint').value.trim();
|
||||
const apiKey = document.getElementById('providerApiKey').value.trim();
|
||||
const model = document.getElementById('providerDefaultModel').value.trim();
|
||||
if (!name || !endpoint || !apiKey) return UI.toast('Fill in required fields', 'warning');
|
||||
try {
|
||||
await API.createConfig(name, provider, endpoint, apiKey, model);
|
||||
UI.toast('Provider added', 'success');
|
||||
UI.hideProviderForm();
|
||||
UI.loadProviderList();
|
||||
fetchModels();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
|
||||
const editId = UI._editingProviderId;
|
||||
|
||||
if (editId) {
|
||||
// Update mode — api_key optional (blank = keep existing)
|
||||
if (!name || !endpoint) return UI.toast('Fill in required fields', 'warning');
|
||||
const patch = { name, provider, endpoint, model_default: model };
|
||||
if (apiKey) patch.api_key = apiKey;
|
||||
try {
|
||||
await API.updateConfig(editId, patch);
|
||||
UI.toast('Provider updated', 'success');
|
||||
UI._editingProviderId = null;
|
||||
document.getElementById('providerApiKey').placeholder = 'API key';
|
||||
UI.hideProviderForm();
|
||||
UI.loadProviderList();
|
||||
fetchModels();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
} else {
|
||||
// Create mode
|
||||
if (!name || !endpoint || !apiKey) return UI.toast('Fill in required fields', 'warning');
|
||||
try {
|
||||
const result = await API.createConfig(name, provider, endpoint, apiKey, model);
|
||||
if (result.warning) {
|
||||
UI.toast(`Provider added — ${result.warning}`, 'warning');
|
||||
} else {
|
||||
const count = result.models_fetched || 0;
|
||||
UI.toast(`Provider added — ${count} model${count !== 1 ? 's' : ''} synced`, 'success');
|
||||
}
|
||||
UI.hideProviderForm();
|
||||
UI.loadProviderList();
|
||||
fetchModels();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteProvider(id, name) {
|
||||
@@ -1531,21 +1557,53 @@ async function deleteProvider(id, name) {
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function refreshProviderModels(id, name) {
|
||||
try {
|
||||
UI.toast(`Fetching models for ${name}...`, 'info');
|
||||
const result = await API.fetchProviderModels(id);
|
||||
const total = result.total || 0;
|
||||
UI.toast(`${name}: ${total} model${total !== 1 ? 's' : ''} synced`, 'success');
|
||||
fetchModels(); // refresh the model selector
|
||||
} catch (e) {
|
||||
UI.toast(`Failed to fetch models: ${e.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function editProvider(id) {
|
||||
try {
|
||||
const cfg = await API.getConfig(id);
|
||||
// Populate the add form with existing values
|
||||
document.getElementById('providerName').value = cfg.name || '';
|
||||
document.getElementById('providerType').value = cfg.provider || 'openai';
|
||||
document.getElementById('providerEndpoint').value = cfg.endpoint || '';
|
||||
document.getElementById('providerApiKey').value = ''; // Don't expose key
|
||||
document.getElementById('providerApiKey').placeholder = cfg.has_key ? '(unchanged — leave blank to keep)' : 'API key';
|
||||
document.getElementById('providerDefaultModel').value = cfg.model_default || '';
|
||||
// Show form and switch to edit mode
|
||||
UI.showProviderForm();
|
||||
UI._editingProviderId = id;
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function handleSaveAdminSettings() {
|
||||
try {
|
||||
// Registration
|
||||
// Policies — send as string "true"/"false" so backend routes to platform_policies
|
||||
const reg = document.getElementById('adminRegToggle').checked;
|
||||
await API.adminUpdateSetting('registration_enabled', { value: reg });
|
||||
await API.adminUpdateSetting('allow_registration', { value: reg ? 'true' : 'false' });
|
||||
|
||||
// Registration default state
|
||||
// Registration default state → default_user_active policy
|
||||
const regState = document.getElementById('adminRegDefaultState').value;
|
||||
await API.adminUpdateSetting('registration_default_state', { value: regState });
|
||||
await API.adminUpdateSetting('default_user_active', { value: regState === 'active' ? 'true' : 'false' });
|
||||
|
||||
// User providers
|
||||
// User BYOK providers → allow_user_byok policy
|
||||
const userProviders = document.getElementById('adminUserProvidersToggle').checked;
|
||||
await API.adminUpdateSetting('user_providers_enabled', { value: userProviders });
|
||||
await API.adminUpdateSetting('allow_user_byok', { value: userProviders ? 'true' : 'false' });
|
||||
|
||||
// Banner
|
||||
// User presets → allow_user_personas policy
|
||||
const userPresets = document.getElementById('adminUserPresetsToggle').checked;
|
||||
await API.adminUpdateSetting('allow_user_personas', { value: userPresets ? 'true' : 'false' });
|
||||
|
||||
// Banner → global_settings (JSON value)
|
||||
const banner = {
|
||||
enabled: document.getElementById('adminBannerEnabled').checked,
|
||||
text: document.getElementById('adminBannerText').value,
|
||||
@@ -1556,8 +1614,11 @@ async function handleSaveAdminSettings() {
|
||||
await API.adminUpdateSetting('banner', { value: banner });
|
||||
|
||||
UI.toast('Settings saved', 'success');
|
||||
// Live-apply banner
|
||||
initBanners();
|
||||
// Live-apply: refresh policies and dependent UI
|
||||
await initBanners();
|
||||
UI.checkUserProvidersAllowed();
|
||||
UI.checkUserPresetsAllowed();
|
||||
fetchModels();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
@@ -1695,7 +1756,17 @@ async function createGlobalProvider() {
|
||||
async function fetchAdminModels() {
|
||||
const hint = document.getElementById('adminModelsHint');
|
||||
hint.textContent = 'Fetching...';
|
||||
try { await API.adminFetchModels(); UI.toast('Models synced', 'success'); hint.textContent = ''; await UI.loadAdminModels(); }
|
||||
try {
|
||||
const resp = await API.adminFetchModels();
|
||||
const errs = resp.errors || [];
|
||||
if (errs.length > 0) {
|
||||
UI.toast('Fetch errors: ' + errs.join('; '), 'error');
|
||||
} else {
|
||||
UI.toast(`Models synced — ${resp.added || 0} added, ${resp.updated || 0} updated`, 'success');
|
||||
}
|
||||
hint.textContent = '';
|
||||
await UI.loadAdminModels();
|
||||
}
|
||||
catch (e) { UI.toast(e.message, 'error'); hint.textContent = 'Failed'; }
|
||||
}
|
||||
async function cycleModelVisibility(id, current) {
|
||||
@@ -1860,7 +1931,7 @@ async function createAdminPreset(vals) {
|
||||
description: vals.description || '',
|
||||
system_prompt: vals.system_prompt || '',
|
||||
};
|
||||
if (vals.api_config_id) preset.api_config_id = vals.api_config_id;
|
||||
if (vals.provider_config_id) preset.provider_config_id = vals.provider_config_id;
|
||||
if (vals.temperature != null) preset.temperature = vals.temperature;
|
||||
if (vals.max_tokens != null) preset.max_tokens = vals.max_tokens;
|
||||
|
||||
@@ -2425,19 +2496,18 @@ function initBranding() {
|
||||
|
||||
async function initBanners() {
|
||||
try {
|
||||
const data = await API.getPublicSettings?.() || [];
|
||||
const settings = data.settings || data || [];
|
||||
const arr = Array.isArray(settings) ? settings : [];
|
||||
const data = await API.getPublicSettings?.() || {};
|
||||
|
||||
// Store globally for user-facing checks — unwrap {value: X} wrapper
|
||||
// Store policies for user-facing checks (allow_user_byok, etc.)
|
||||
App.policies = data.policies || {};
|
||||
|
||||
// Also flatten into serverSettings for backward compat
|
||||
App.serverSettings = {};
|
||||
arr.forEach(s => {
|
||||
const v = s.value;
|
||||
App.serverSettings[s.key] = (v && typeof v === 'object' && 'value' in v) ? v.value : v;
|
||||
});
|
||||
if (data.banner) App.serverSettings.banner = data.banner;
|
||||
if (data.branding) App.serverSettings.branding = data.branding;
|
||||
|
||||
const root = document.documentElement;
|
||||
const banner = App.serverSettings.banner;
|
||||
const banner = data.banner;
|
||||
|
||||
// Clear previous banner state
|
||||
['bannerTop', 'bannerBottom'].forEach(id => {
|
||||
|
||||
73
src/js/ui.js
73
src/js/ui.js
@@ -98,7 +98,7 @@ function renderPresetForm(containerEl, options = {}) {
|
||||
};
|
||||
if (showConfig) {
|
||||
const cfgId = document.getElementById(`${pfx}_config`)?.value;
|
||||
if (cfgId) v.api_config_id = cfgId;
|
||||
if (cfgId) v.provider_config_id = cfgId;
|
||||
}
|
||||
const temp = parseFloat(document.getElementById(`${pfx}_temp`)?.value);
|
||||
if (!isNaN(temp)) v.temperature = temp;
|
||||
@@ -117,7 +117,7 @@ function renderPresetForm(containerEl, options = {}) {
|
||||
if (el('temp')) el('temp').value = p.temperature != null ? p.temperature : '';
|
||||
if (el('maxTokens')) el('maxTokens').value = p.max_tokens != null ? p.max_tokens : '';
|
||||
if (el('model')) el('model').value = p.base_model_id || '';
|
||||
if (showConfig && el('config')) el('config').value = p.api_config_id || '';
|
||||
if (showConfig && el('config')) el('config').value = p.provider_config_id || '';
|
||||
if (showAvatar) form.updateAvatarPreview(p.avatar || null);
|
||||
},
|
||||
clearForm() {
|
||||
@@ -786,7 +786,7 @@ const UI = {
|
||||
UI.loadProviderList();
|
||||
UI.checkUserProvidersAllowed();
|
||||
}
|
||||
if (tab === 'models') { UI.loadUserModels(); UI.loadUserPresets(); }
|
||||
if (tab === 'models') { UI.loadUserModels(); UI.loadUserPresets(); UI.checkUserPresetsAllowed(); }
|
||||
if (tab === 'appearance') UI.loadAppearanceSettings();
|
||||
if (tab === 'teams') UI.loadTeamsTab();
|
||||
},
|
||||
@@ -843,12 +843,20 @@ const UI = {
|
||||
const addBtn = document.getElementById('providerShowAddBtn');
|
||||
const tabBtn = document.getElementById('settingsProvidersTabBtn');
|
||||
if (!notice) return;
|
||||
const allowed = App.serverSettings?.user_providers_enabled !== false;
|
||||
const allowed = App.policies?.allow_user_byok === 'true';
|
||||
notice.style.display = allowed ? 'none' : '';
|
||||
if (addBtn) addBtn.style.display = allowed ? '' : 'none';
|
||||
if (tabBtn) tabBtn.style.display = allowed ? '' : 'none';
|
||||
},
|
||||
|
||||
checkUserPresetsAllowed() {
|
||||
const addBtn = document.getElementById('userAddPresetBtn');
|
||||
const addForm = document.getElementById('userAddPresetForm');
|
||||
const allowed = App.policies?.allow_user_personas === 'true';
|
||||
if (addBtn) addBtn.style.display = allowed ? '' : 'none';
|
||||
if (addForm && !allowed) addForm.style.display = 'none';
|
||||
},
|
||||
|
||||
async loadProfileIntoSettings() {
|
||||
try {
|
||||
const p = await API.getProfile();
|
||||
@@ -1075,9 +1083,9 @@ const UI = {
|
||||
</div>
|
||||
<div class="provider-actions">
|
||||
${c.has_key ? '🔑' : '⚠️'}
|
||||
${c.user_id
|
||||
? `<button class="btn-small btn-danger" onclick="deleteProvider('${c.id}','${esc(c.name)}')">Remove</button>`
|
||||
: '<span class="badge-global">global</span>'}
|
||||
<button class="btn-small" onclick="refreshProviderModels('${c.id}','${esc(c.name)}')">Refresh Models</button>
|
||||
<button class="btn-small" onclick="editProvider('${c.id}')">Edit</button>
|
||||
<button class="btn-small btn-danger" onclick="deleteProvider('${c.id}','${esc(c.name)}')">Remove</button>
|
||||
</div>
|
||||
</div>`).join('');
|
||||
} catch (e) {
|
||||
@@ -1086,7 +1094,11 @@ const UI = {
|
||||
},
|
||||
|
||||
showProviderForm() { document.getElementById('providerAddForm').style.display = ''; },
|
||||
hideProviderForm() { document.getElementById('providerAddForm').style.display = 'none'; },
|
||||
hideProviderForm() {
|
||||
document.getElementById('providerAddForm').style.display = 'none';
|
||||
UI._editingProviderId = null;
|
||||
document.getElementById('providerApiKey').placeholder = 'API key';
|
||||
},
|
||||
|
||||
// ── Admin Modal ──────────────────────────
|
||||
|
||||
@@ -1114,7 +1126,7 @@ const UI = {
|
||||
if (!quiet) el.innerHTML = '<div class="loading">Loading...</div>';
|
||||
try {
|
||||
const resp = await API.adminListUsers();
|
||||
const users = resp.data || [];
|
||||
const users = resp.users || resp.data || [];
|
||||
el.innerHTML = users.map(u => {
|
||||
const teamBadges = (u.teams || []).map(t =>
|
||||
`<span class="badge-team" title="${esc(t.role)}">${esc(t.team_name)}</span>`
|
||||
@@ -1153,7 +1165,7 @@ const UI = {
|
||||
el.innerHTML = '<div class="loading">Loading...</div>';
|
||||
try {
|
||||
const s = await API.adminGetStats();
|
||||
const labels = { total_users: 'Users', active_users: 'Active Users', api_configs: 'Providers', total_channels: 'Channels', total_messages: 'Messages' };
|
||||
const labels = { users: 'Users', channels: 'Channels', messages: 'Messages' };
|
||||
el.innerHTML = '<div class="stats-grid">' +
|
||||
Object.entries(s).map(([k, v]) => `
|
||||
<div class="stat-card">
|
||||
@@ -1456,7 +1468,7 @@ const UI = {
|
||||
sel.innerHTML = '<option value="">Loading...</option>';
|
||||
try {
|
||||
const resp = await API.adminListUsers(1, 200);
|
||||
const users = resp.data || [];
|
||||
const users = resp.users || resp.data || [];
|
||||
// Also get existing members to exclude
|
||||
const memberResp = await API.adminListMembers(teamId);
|
||||
const existingIds = new Set((memberResp.data || []).map(m => m.user_id));
|
||||
@@ -1469,28 +1481,33 @@ const UI = {
|
||||
async loadAdminSettings() {
|
||||
try {
|
||||
const data = await API.adminGetSettings();
|
||||
const settings = data.settings || data || [];
|
||||
const arr = Array.isArray(settings) ? settings : [];
|
||||
// v0.9: { settings: { banner: {...}, ... }, policies: { allow_registration: "true", ... } }
|
||||
const settings = data.settings || {};
|
||||
const policies = data.policies || {};
|
||||
|
||||
// Unwrap {value: X} wrapper from backend storage
|
||||
const get = (key, fallback) => {
|
||||
const s = arr.find(s => s.key === key);
|
||||
if (!s) return fallback;
|
||||
const v = s.value;
|
||||
// Helper to read from settings map (JSONB values)
|
||||
const getSetting = (key, fallback) => {
|
||||
const v = settings[key];
|
||||
if (v === undefined || v === null) return fallback;
|
||||
// Unwrap {value: X} wrapper if present
|
||||
return (v && typeof v === 'object' && 'value' in v) ? v.value : v;
|
||||
};
|
||||
|
||||
// Registration
|
||||
document.getElementById('adminRegToggle').checked = get('registration_enabled', true) !== false;
|
||||
// Registration (policy: allow_registration)
|
||||
document.getElementById('adminRegToggle').checked = policies.allow_registration === 'true';
|
||||
|
||||
// Registration default state
|
||||
document.getElementById('adminRegDefaultState').value = get('registration_default_state', 'active') || 'active';
|
||||
// Registration default state (policy: default_user_active → 'true' means auto-active)
|
||||
const defaultActive = policies.default_user_active === 'true';
|
||||
document.getElementById('adminRegDefaultState').value = defaultActive ? 'active' : 'pending';
|
||||
|
||||
// User providers
|
||||
document.getElementById('adminUserProvidersToggle').checked = get('user_providers_enabled', true) !== false;
|
||||
// User providers / BYOK (policy: allow_user_byok)
|
||||
document.getElementById('adminUserProvidersToggle').checked = policies.allow_user_byok === 'true';
|
||||
|
||||
// Banner
|
||||
const banner = get('banner', {}) || {};
|
||||
// User presets / personas (policy: allow_user_personas)
|
||||
document.getElementById('adminUserPresetsToggle').checked = policies.allow_user_personas === 'true';
|
||||
|
||||
// Banner (global_settings)
|
||||
const banner = getSetting('banner', {}) || {};
|
||||
document.getElementById('adminBannerEnabled').checked = !!banner.enabled;
|
||||
document.getElementById('adminBannerText').value = banner.text || '';
|
||||
document.getElementById('adminBannerPosition').value = banner.position || 'both';
|
||||
@@ -1501,8 +1518,8 @@ const UI = {
|
||||
document.getElementById('bannerConfigFields').style.display = banner.enabled ? '' : 'none';
|
||||
UI.updateBannerPreview();
|
||||
|
||||
// Load presets
|
||||
const presets = get('banner_presets', {}) || {};
|
||||
// Load banner presets (global_settings)
|
||||
const presets = getSetting('banner_presets', {}) || {};
|
||||
const sel = document.getElementById('adminBannerPreset');
|
||||
sel.innerHTML = '<option value="">Custom</option>';
|
||||
Object.entries(presets).forEach(([key, p]) => {
|
||||
|
||||
Reference in New Issue
Block a user