Files
hermes-web-ui/tests/client/auth-avatar.test.ts
ekko 91bb68dc41 Feat: user avatar upload and group-chat avatar sync (#1273)
* test: add user avatar API unit tests

* fix: resolve group-chat avatar by numeric userId instead of display name

Previously group-chat avatar resolution relied on findUserByUsername(),
which silently fails when the display name ('Mr. Tang') differs from the
login username ('admin').  Now the web UI passes the authenticated user's
numeric id via Socket.IO auth, so handleJoin() uses getUserAvatar(id)
directly.  getRoomMembers() gains a fallback that queries any user with
a non-empty avatar, covering existing room members whose display names
don't match any login username.

* fix: show user avatar in group-chat header stack and member popover

GroupChatPanel.vue was rendering the user avatar with only :name,
forcing multiavatar fallback even when the member list carried a
custom avatar.  Added userMemberAvatar computed that reads the
current user's avatar JSON from store.members, matching by userId.

Also added the missing 'avatar' field to the in-memory Member
interface and wired it through addOrUpdateMember / handleJoin so
the store actually sees member avatars.

* fix: cache current user avatar in group-chat store for no-room state

When no room is active, store.members is empty so the header
avatar falls back to multiavatar.  Now connect() fetches the
current user's avatar via /api/auth/me and stores it in a ref.
GroupChatPanel.userMemberAvatar falls back to this cached value
when the member list doesn't have a match.

* fix: prevent group chat avatar mixups

* fix: complete user avatar settings coverage

---------

Co-authored-by: tangxuan <seizeday@yeah.net>
2026-06-03 12:25:09 +08:00

121 lines
3.8 KiB
TypeScript

// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mockFetch = vi.fn()
vi.stubGlobal('fetch', mockFetch)
vi.mock('@/router', () => ({
default: {
currentRoute: { value: { name: 'hermes.chat' } },
replace: vi.fn(),
},
}))
import {
fetchMyAvatar,
resetMyAvatar,
updateMyAvatar,
} from '../../packages/client/src/api/auth'
import { setApiKey } from '../../packages/client/src/api/client'
function makeResponse(status: number, body: any, ok = status >= 200 && status < 300) {
return {
ok,
status,
text: () => Promise.resolve(typeof body === 'string' ? body : JSON.stringify(body)),
json: () => Promise.resolve(body),
}
}
describe('client auth API: avatar endpoints', () => {
beforeEach(() => {
localStorage.clear()
vi.clearAllMocks()
setApiKey('test-token')
})
describe('fetchMyAvatar', () => {
it('returns null when the server responds with an empty avatar string', async () => {
mockFetch.mockResolvedValueOnce(makeResponse(200, { avatar: '' }))
const avatar = await fetchMyAvatar()
expect(avatar).toBeNull()
expect(mockFetch).toHaveBeenCalledOnce()
const [url] = mockFetch.mock.calls[0]
expect(url).toBe('/api/auth/avatar')
})
it('returns null when the server returns an unparseable JSON string', async () => {
mockFetch.mockResolvedValueOnce(makeResponse(200, { avatar: '{not valid json' }))
const avatar = await fetchMyAvatar()
expect(avatar).toBeNull()
})
it('returns null when the parsed avatar has an unknown type', async () => {
mockFetch.mockResolvedValueOnce(
makeResponse(200, { avatar: JSON.stringify({ type: 'emoji', seed: 'x' }) }),
)
const avatar = await fetchMyAvatar()
expect(avatar).toBeNull()
})
it('parses a valid image avatar payload from the server', async () => {
const payload = { type: 'image' as const, dataUrl: 'data:image/png;base64,AAAA' }
mockFetch.mockResolvedValueOnce(makeResponse(200, { avatar: JSON.stringify(payload) }))
const avatar = await fetchMyAvatar()
expect(avatar).toEqual(payload)
})
it('parses a valid default avatar payload from the server', async () => {
const payload = { type: 'default' as const, seed: 'abc' }
mockFetch.mockResolvedValueOnce(makeResponse(200, { avatar: JSON.stringify(payload) }))
const avatar = await fetchMyAvatar()
expect(avatar).toEqual(payload)
})
})
describe('updateMyAvatar', () => {
it('PUTs the avatar as a JSON-string field to /api/auth/avatar', async () => {
mockFetch.mockResolvedValueOnce(makeResponse(200, { success: true, avatar: '' }))
const payload = { type: 'image' as const, dataUrl: 'data:image/png;base64,HI==' }
await updateMyAvatar(payload)
expect(mockFetch).toHaveBeenCalledOnce()
const [url, options] = mockFetch.mock.calls[0]
expect(url).toBe('/api/auth/avatar')
expect(options.method).toBe('PUT')
expect(options.headers['Content-Type']).toBe('application/json')
const body = JSON.parse(options.body)
expect(body.avatar).toBe(JSON.stringify(payload))
// The string must round-trip parse to the same object
expect(JSON.parse(body.avatar)).toEqual(payload)
})
})
describe('resetMyAvatar', () => {
it('PUTs a default avatar object to /api/auth/avatar', async () => {
mockFetch.mockResolvedValueOnce(makeResponse(200, { success: true }))
await resetMyAvatar()
expect(mockFetch).toHaveBeenCalledOnce()
const [url, options] = mockFetch.mock.calls[0]
expect(url).toBe('/api/auth/avatar')
expect(options.method).toBe('PUT')
const body = JSON.parse(options.body)
expect(body).toEqual({ avatar: { type: 'default' } })
})
})
})