Changeset 0.37.14 (#226)
Co-authored-by: gobha <jasafpro@gmail.com> Co-committed-by: gobha <jasafpro@gmail.com>
This commit is contained in:
@@ -13,34 +13,32 @@ 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
|
||||
// Normalized: uses standard {data: [...], total: N} envelope.
|
||||
// Has siblings → _unwrap does NOT strip. Frontend reads .data and .total.
|
||||
|
||||
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})
|
||||
// ACTUAL backend response shape from ListUsers handler:
|
||||
// c.JSON(200, gin.H{"data": users, "total": total})
|
||||
const backendResponse = {
|
||||
users: [
|
||||
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 "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('response has "data" array', () => {
|
||||
assert.ok(Array.isArray(backendResponse.data), 'response.data must be an array');
|
||||
});
|
||||
|
||||
it('frontend extraction reads .users with .data fallback', () => {
|
||||
// This mirrors the fixed loadMemberUserDropdown logic
|
||||
const users = backendResponse.users || backendResponse.data || [];
|
||||
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.users) {
|
||||
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');
|
||||
@@ -49,7 +47,7 @@ describe('GET /admin/users response contract', () => {
|
||||
|
||||
it('total is numeric', () => {
|
||||
assert.equal(typeof backendResponse.total, 'number');
|
||||
assert.ok(backendResponse.total >= backendResponse.users.length);
|
||||
assert.ok(backendResponse.total >= backendResponse.data.length);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -319,9 +317,10 @@ describe('GET /teams/:id/members response contract', () => {
|
||||
// All models regardless of visibility.
|
||||
|
||||
describe('GET /admin/models response contract', () => {
|
||||
// This is the ACTUAL CatalogEntry shape from ListAll → ListModelConfigs handler.
|
||||
// ACTUAL CatalogEntry shape from ListAll → ListModelConfigs handler.
|
||||
// Normalized: {data: [...]} sole key → _unwrap strips → frontend gets bare array.
|
||||
const backendResponse = {
|
||||
models: [
|
||||
data: [
|
||||
{
|
||||
id: 'catalog-uuid-1',
|
||||
provider_config_id: 'provider-uuid',
|
||||
@@ -337,36 +336,31 @@ describe('GET /admin/models response contract', () => {
|
||||
],
|
||||
};
|
||||
|
||||
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('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.models) {
|
||||
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.models) {
|
||||
for (const m of backendResponse.data) {
|
||||
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');
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -409,30 +403,30 @@ describe('POST /admin/models/fetch response contract', () => {
|
||||
|
||||
// ── 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');
|
||||
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, 'teams/members uses "data" key');
|
||||
assert.ok('data' in shape);
|
||||
});
|
||||
|
||||
it('models/enabled uses {data:[]}', () => {
|
||||
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('admin/models uses {models:[]}', () => {
|
||||
const shape = { models: [] };
|
||||
assert.ok('models' in shape, 'admin/models must use "models" key');
|
||||
});
|
||||
|
||||
it('personas uses {personas:[]}', () => {
|
||||
const shape = { personas: [] };
|
||||
assert.ok('personas' in shape);
|
||||
it('personas uses {data:[]} (sole key → unwrapped)', () => {
|
||||
const shape = { data: [] };
|
||||
assert.ok('data' in shape);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -241,3 +241,82 @@ describe('init() profile gate', () => {
|
||||
// Without guard: UI renders. With guard: splash shown.
|
||||
});
|
||||
});
|
||||
|
||||
// ── Boot-time 401 redirect suppression ──────
|
||||
// v0.37.14: Stale cookie blank-login-page fix.
|
||||
// When the login page boots the SDK with stale localStorage tokens,
|
||||
// the REST client's 401 handler must NOT redirect to /login (we're
|
||||
// already there). boot() handles 401 gracefully on its own.
|
||||
|
||||
describe('on401Failure redirect suppression', () => {
|
||||
|
||||
// Model of the _on401Failure guard logic
|
||||
function make401Handler() {
|
||||
let _booting = false;
|
||||
let redirectedTo = null;
|
||||
|
||||
return {
|
||||
set booting(v) { _booting = v; },
|
||||
get booting() { return _booting; },
|
||||
get redirectedTo() { return redirectedTo; },
|
||||
reset() { redirectedTo = null; },
|
||||
|
||||
on401Failure(pathname, basePath) {
|
||||
// Mirrors the real _on401Failure logic
|
||||
if (_booting) return;
|
||||
const loginPath = basePath + '/login';
|
||||
const here = pathname.replace(/\/+$/, '');
|
||||
if (here === loginPath) return;
|
||||
redirectedTo = loginPath;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it('suppresses redirect during boot', () => {
|
||||
const h = make401Handler();
|
||||
h.booting = true;
|
||||
h.on401Failure('/chat', '');
|
||||
assert.equal(h.redirectedTo, null, 'must NOT redirect during boot');
|
||||
});
|
||||
|
||||
it('allows redirect after boot completes', () => {
|
||||
const h = make401Handler();
|
||||
h.booting = true;
|
||||
h.on401Failure('/chat', '');
|
||||
assert.equal(h.redirectedTo, null);
|
||||
|
||||
h.booting = false;
|
||||
h.on401Failure('/chat', '');
|
||||
assert.equal(h.redirectedTo, '/login', 'must redirect after boot');
|
||||
});
|
||||
|
||||
it('suppresses redirect when already on /login', () => {
|
||||
const h = make401Handler();
|
||||
h.on401Failure('/login', '');
|
||||
assert.equal(h.redirectedTo, null, 'must NOT redirect to /login from /login');
|
||||
});
|
||||
|
||||
it('suppresses redirect when on /login with trailing slash', () => {
|
||||
const h = make401Handler();
|
||||
h.on401Failure('/login/', '');
|
||||
assert.equal(h.redirectedTo, null, 'must NOT redirect from /login/');
|
||||
});
|
||||
|
||||
it('handles basePath correctly', () => {
|
||||
const h = make401Handler();
|
||||
h.on401Failure('/app/login', '/app');
|
||||
assert.equal(h.redirectedTo, null, 'must NOT redirect from basePath + /login');
|
||||
});
|
||||
|
||||
it('redirects from non-login paths normally', () => {
|
||||
const h = make401Handler();
|
||||
h.on401Failure('/chat', '');
|
||||
assert.equal(h.redirectedTo, '/login');
|
||||
});
|
||||
|
||||
it('redirects from non-login paths with basePath', () => {
|
||||
const h = make401Handler();
|
||||
h.on401Failure('/app/chat', '/app');
|
||||
assert.equal(h.redirectedTo, '/app/login');
|
||||
});
|
||||
});
|
||||
|
||||
149
src/js/__tests__/channel-contracts.test.js
Normal file
149
src/js/__tests__/channel-contracts.test.js
Normal file
@@ -0,0 +1,149 @@
|
||||
// ==========================================
|
||||
// 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'));
|
||||
});
|
||||
});
|
||||
@@ -78,60 +78,53 @@ describe('Policy evaluation logic', () => {
|
||||
// ── Team member dropdown logic ───────────────
|
||||
|
||||
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));
|
||||
// admin/users returns {data:[...], total:N} — has siblings, _unwrap passes through.
|
||||
// teams/members returns {data:[...]} — sole key, _unwrap strips to bare array.
|
||||
function getAvailableUsers(usersResp, membersArr) {
|
||||
const users = usersResp.data || [];
|
||||
const existingIds = new Set((membersArr || []).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: [
|
||||
it('extracts users from {data:[...], total:N} response', () => {
|
||||
const usersResp = { data: [
|
||||
{ id: 'u1', username: 'alice', is_active: true },
|
||||
{ id: 'u2', username: 'bob', is_active: true },
|
||||
]};
|
||||
const membersResp = { data: [] };
|
||||
const available = getAvailableUsers(usersResp, membersResp);
|
||||
], total: 2};
|
||||
const membersArr = [];
|
||||
const available = getAvailableUsers(usersResp, membersArr);
|
||||
assert.equal(available.length, 2);
|
||||
});
|
||||
|
||||
it('falls back to {data:[]} response (backward compat)', () => {
|
||||
it('excludes existing team members', () => {
|
||||
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);
|
||||
], total: 2};
|
||||
const membersArr = [{ user_id: 'u1', role: 'admin' }];
|
||||
const available = getAvailableUsers(usersResp, membersArr);
|
||||
assert.equal(available.length, 1);
|
||||
assert.equal(available[0].id, 'u2');
|
||||
});
|
||||
|
||||
it('excludes inactive users', () => {
|
||||
const usersResp = { users: [
|
||||
const usersResp = { data: [
|
||||
{ id: 'u1', username: 'alice', is_active: true },
|
||||
{ id: 'u2', username: 'disabled', is_active: false },
|
||||
]};
|
||||
const membersResp = { data: [] };
|
||||
const available = getAvailableUsers(usersResp, membersResp);
|
||||
], total: 2};
|
||||
const membersArr = [];
|
||||
const available = getAvailableUsers(usersResp, membersArr);
|
||||
assert.equal(available.length, 1);
|
||||
assert.equal(available[0].id, 'u1');
|
||||
});
|
||||
|
||||
it('returns empty for empty users response', () => {
|
||||
const available = getAvailableUsers({ users: [] }, { data: [] });
|
||||
const available = getAvailableUsers({ data: [] }, []);
|
||||
assert.equal(available.length, 0);
|
||||
});
|
||||
|
||||
it('handles completely missing response fields', () => {
|
||||
const available = getAvailableUsers({}, {});
|
||||
const available = getAvailableUsers({}, []);
|
||||
assert.equal(available.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user