mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-14 05:18:54 +00:00
Chart and ranking fetches carry a request id, and a superseded response is dropped. Changing a selection clears the old data at once, so old numbers never show under new labels or units. The auto-refresh tick skips while a fetch is still in flight. "Loading..." shows only until the first result for a selection arrives. Dashboard polls do not overlap. Users in several groups are counted and searched in every group. The Models tab labels its windows as the current UTC bucket. Hour labels are marked UTC. The Members column sorts numerically. Target access shows "All groups" for null and "No groups" for []. The UI shows the server's own error message. Cost axis ticks use the currency formatter. npm run dev proxies /admin/api. Nine unused shadcn components are removed. admin_assets_gen.go is regenerated from the new build. Claude-Session: https://claude.ai/code/session_01V5jYJNxpDFdJkyyExpADrv
104 lines
3.6 KiB
TypeScript
104 lines
3.6 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
||
|
||
import { groupMatches, groupNameMatches, matchingMembersOfGroup, membersOfGroup, userMatches } from './usage-search'
|
||
import type { AdminUsageEntryView } from '@/types/api'
|
||
|
||
/** entry builds a minimal AdminUsageEntryView — only id/groupName/groups matter to this module, the rest are never read. */
|
||
function entry(id: string, groupName?: string, groups?: string[]): AdminUsageEntryView {
|
||
return {
|
||
kind: groupName === undefined ? 'group' : 'user',
|
||
id,
|
||
groupName,
|
||
groups,
|
||
requestsPerMinute: 0,
|
||
requestsPerDay: 0,
|
||
tokensInPerDay: 0,
|
||
tokensOutPerDay: 0,
|
||
tokensInPerMonth: 0,
|
||
tokensOutPerMonth: 0,
|
||
costPerDayMicroUsd: 0,
|
||
costPerMonthMicroUsd: 0,
|
||
}
|
||
}
|
||
|
||
const alice = entry('alice', 'team-a')
|
||
const bob = entry('bob', 'team-a')
|
||
const carol = entry('carol', 'team-b')
|
||
const teamA = entry('team-a')
|
||
const teamB = entry('team-b')
|
||
const users = [alice, bob, carol]
|
||
|
||
describe('userMatches', () => {
|
||
it('matches a case-insensitive substring of the id (query pre-lowercased by the caller)', () => {
|
||
expect(userMatches(alice, 'lic')).toBe(true)
|
||
})
|
||
|
||
it('does not match an unrelated query', () => {
|
||
expect(userMatches(alice, 'zzz')).toBe(false)
|
||
})
|
||
})
|
||
|
||
describe('groupNameMatches', () => {
|
||
it('matches on the group id alone, ignoring members entirely', () => {
|
||
expect(groupNameMatches(teamA, 'team-a')).toBe(true)
|
||
})
|
||
|
||
it('does not match a query that only a member would match', () => {
|
||
expect(groupNameMatches(teamA, 'alice')).toBe(false)
|
||
})
|
||
})
|
||
|
||
describe('membersOfGroup', () => {
|
||
it('joins users to a group on groupName === group.id', () => {
|
||
expect(membersOfGroup(teamA, users)).toEqual([alice, bob])
|
||
expect(membersOfGroup(teamB, users)).toEqual([carol])
|
||
})
|
||
|
||
// Multi-group support (review finding 4): a user's full membership lives
|
||
// in `groups` (admin.go's adminUsageEntryView.Groups); groupName alone
|
||
// only ever carries the FIRST one. Before this fix, membersOfGroup's
|
||
// `groupName === group.id` join silently dropped a multi-group user from
|
||
// every group but their first.
|
||
it('joins a multi-group user into EVERY one of their member groups via `groups`, not just groupName', () => {
|
||
const dave = entry('dave', 'team-a', ['team-a', 'team-b'])
|
||
const roster = [alice, bob, carol, dave]
|
||
|
||
expect(membersOfGroup(teamA, roster)).toEqual([alice, bob, dave])
|
||
expect(membersOfGroup(teamB, roster)).toEqual([carol, dave])
|
||
})
|
||
|
||
it('falls back to [groupName] when `groups` is absent (a single-group user, or a fixture predating the field)', () => {
|
||
expect(membersOfGroup(teamA, users)).toEqual([alice, bob])
|
||
})
|
||
})
|
||
|
||
describe('matchingMembersOfGroup', () => {
|
||
it('returns only the group members that themselves match query', () => {
|
||
expect(matchingMembersOfGroup(teamA, users, 'alice')).toEqual([alice])
|
||
})
|
||
|
||
it('returns an empty array when no member matches', () => {
|
||
expect(matchingMembersOfGroup(teamA, users, 'zzz')).toEqual([])
|
||
})
|
||
})
|
||
|
||
describe('groupMatches', () => {
|
||
it('matches via the group’s own name, with no matching member', () => {
|
||
expect(groupMatches(teamA, users, 'team-a')).toBe(true)
|
||
})
|
||
|
||
it('matches via a member, with no name match', () => {
|
||
expect(groupMatches(teamA, users, 'alice')).toBe(true)
|
||
})
|
||
|
||
it('does not match when neither the name nor any member matches', () => {
|
||
expect(groupMatches(teamA, users, 'zzz')).toBe(false)
|
||
})
|
||
|
||
it('a group with no members only matches by name', () => {
|
||
const lonely = entry('lonely-group')
|
||
expect(groupMatches(lonely, users, 'lonely')).toBe(true)
|
||
expect(groupMatches(lonely, users, 'alice')).toBe(false)
|
||
})
|
||
})
|