Files
hermes-ui/tests/server/user-avatar.test.ts
T
toor 1e9ea89d86
Build / build (push) Canceled after 0s
NPM Lockfile Check / npm ci --ignore-scripts (push) Canceled after 0s
Playwright / e2e (push) Canceled after 0s
chore: bootstrap hermes-ui skeleton from 0.6.29 snapshot
Import the client, server, skills, tests, and build scripts as the starting
point for the rebuild, then trim the surfaces this project does not ship:
Electron desktop packaging, the marketing website, and the ESP32 firmware
tree. CI keeps the web build, e2e, lockfile, release, and Docker workflows.

Rename the package and CLI entry points to hermes-ui, drop the prepare hook so
npm ci no longer triggers a build, and drop the removed tsconfig and nodemon
references. Rewrite AGENTS.md, ARCHITECTURE.md, DEVELOPMENT.md, and the READMEs
so the documented package map and hard rules match this repository.

reference_code/ is ignored: it is a read-only snapshot, not an input to the build.
2026-10-16 10:34:35 +08:00

292 lines
10 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
describe('user avatar storage and controller', () => {
let db: any = null
beforeEach(async () => {
vi.resetModules()
vi.stubEnv('AUTH_JWT_SECRET', 'test-secret')
const { DatabaseSync } = await import('node:sqlite')
db = new DatabaseSync(':memory:')
vi.doMock('../../packages/server/src/db/index', () => ({
getDb: () => db,
getStoragePath: () => ':memory:',
}))
})
afterEach(() => {
db?.close()
db = null
vi.doUnmock('../../packages/server/src/db/index')
vi.doUnmock('../../packages/server/src/services/hermes/hermes-profile')
vi.unstubAllEnvs()
vi.resetModules()
})
async function initUsers() {
const schemas = await import('../../packages/server/src/db/hermes/schemas')
schemas.initAllHermesTables()
return {
schemas,
users: await import('../../packages/server/src/db/hermes/users-store'),
ctrl: await import('../../packages/server/src/controllers/auth'),
}
}
function makeControllerCtx(user: any | null | undefined, body: any = {}) {
return {
state: user ? { user } : {},
request: { body },
status: 200,
body: null,
} as any
}
describe('users-store avatar helpers', () => {
it('returns an empty string for a brand new user', async () => {
const { users } = await initUsers()
const admin = users.bootstrapDefaultSuperAdmin('admin', '123456')!
expect(users.getUserAvatar(admin.id)).toBe('')
})
it('round-trips avatar JSON through set/getUserAvatar', async () => {
const { users } = await initUsers()
const admin = users.bootstrapDefaultSuperAdmin('admin', '123456')!
const payload = JSON.stringify({ type: 'image', dataUrl: 'data:image/png;base64,AAA' })
const ok = users.setUserAvatar(admin.id, payload)
expect(ok).toBe(true)
expect(users.getUserAvatar(admin.id)).toBe(payload)
})
it('setUserAvatar returns false when the user does not exist', async () => {
const { users } = await initUsers()
// create a user so the table exists, but use a clearly non-existent id
users.bootstrapDefaultSuperAdmin('admin', '123456')
expect(users.setUserAvatar(9999, '{"type":"default"}')).toBe(false)
expect(users.getUserAvatar(9999)).toBe('')
})
it('setUserAvatar returns false when given a non-numeric userId', async () => {
const { users } = await initUsers()
users.bootstrapDefaultSuperAdmin('admin', '123456')
expect(users.setUserAvatar('not-a-number', '{"type":"default"}')).toBe(false)
expect(users.setUserAvatar(-1, '{"type":"default"}')).toBe(false)
expect(users.setUserAvatar(0, '{"type":"default"}')).toBe(false)
})
it('persists the avatar in the users table', async () => {
const { users, schemas } = await initUsers()
const admin = users.bootstrapDefaultSuperAdmin('admin', '123456')!
const payload = JSON.stringify({ type: 'default' })
users.setUserAvatar(admin.id, payload)
const row = db
.prepare(`SELECT avatar FROM ${schemas.USERS_TABLE} WHERE id = ?`)
.get(admin.id) as { avatar?: string }
expect(row?.avatar).toBe(payload)
})
})
describe('getMyAvatar controller', () => {
it('returns 401 when the request is unauthenticated', async () => {
const { ctrl } = await initUsers()
const ctx = makeControllerCtx(null)
await ctrl.getMyAvatar(ctx)
expect(ctx.status).toBe(401)
expect(ctx.body).toEqual({ error: 'Unauthorized' })
})
it('returns the avatar string for the authenticated user', async () => {
const { users, ctrl } = await initUsers()
const admin = users.bootstrapDefaultSuperAdmin('admin', '123456')!
const payload = JSON.stringify({ type: 'image', dataUrl: 'data:image/png;base64,HI==' })
users.setUserAvatar(admin.id, payload)
const ctx = makeControllerCtx({ id: admin.id, username: 'admin', role: 'super_admin' })
await ctrl.getMyAvatar(ctx)
expect(ctx.status).toBe(200)
expect(ctx.body).toEqual({ avatar: payload })
})
it('returns an empty avatar string for an authenticated user without an avatar', async () => {
const { users, ctrl } = await initUsers()
const admin = users.bootstrapDefaultSuperAdmin('admin', '123456')!
const ctx = makeControllerCtx({ id: admin.id, username: 'admin', role: 'super_admin' })
await ctrl.getMyAvatar(ctx)
expect(ctx.status).toBe(200)
expect(ctx.body).toEqual({ avatar: '' })
})
})
describe('updateMyAvatar controller', () => {
it('persists a default avatar payload sent as a direct object', async () => {
const { users, ctrl } = await initUsers()
const admin = users.bootstrapDefaultSuperAdmin('admin', '123456')!
const ctx = makeControllerCtx(
{ id: admin.id, username: 'admin', role: 'super_admin' },
{ type: 'default' },
)
await ctrl.updateMyAvatar(ctx)
expect(ctx.status).toBe(200)
expect(ctx.body.success).toBe(true)
const stored = users.getUserAvatar(admin.id)
expect(JSON.parse(stored)).toEqual({ type: 'default' })
})
it('persists an image avatar payload sent as a direct object', async () => {
const { users, ctrl } = await initUsers()
const admin = users.bootstrapDefaultSuperAdmin('admin', '123456')!
const dataUrl = 'data:image/png;base64,iVBORw0KGgo='
const ctx = makeControllerCtx(
{ id: admin.id, username: 'admin', role: 'super_admin' },
{ type: 'image', dataUrl },
)
await ctrl.updateMyAvatar(ctx)
expect(ctx.status).toBe(200)
expect(ctx.body.success).toBe(true)
const stored = users.getUserAvatar(admin.id)
expect(JSON.parse(stored)).toEqual({ type: 'image', dataUrl })
})
it('persists a JSON-string avatar passed in the avatar field', async () => {
const { users, ctrl } = await initUsers()
const admin = users.bootstrapDefaultSuperAdmin('admin', '123456')!
const serialized = JSON.stringify({ type: 'default', seed: 'abc' })
const ctx = makeControllerCtx(
{ id: admin.id, username: 'admin', role: 'super_admin' },
{ avatar: serialized },
)
await ctrl.updateMyAvatar(ctx)
expect(ctx.status).toBe(200)
expect(ctx.body.avatar).toBe(serialized)
expect(users.getUserAvatar(admin.id)).toBe(serialized)
})
it('rejects an unknown type with HTTP 400', async () => {
const { users, ctrl } = await initUsers()
const admin = users.bootstrapDefaultSuperAdmin('admin', '123456')!
const ctx = makeControllerCtx(
{ id: admin.id, username: 'admin', role: 'super_admin' },
{ type: 'emoji', dataUrl: '🦊' },
)
await ctrl.updateMyAvatar(ctx)
expect(ctx.status).toBe(400)
expect(ctx.body.error).toMatch(/image.*default/)
expect(users.getUserAvatar(admin.id)).toBe('')
})
it('rejects an image payload whose dataUrl exceeds 500KB with HTTP 400', async () => {
const { users, ctrl } = await initUsers()
const admin = users.bootstrapDefaultSuperAdmin('admin', '123456')!
const oversized = 'data:image/png;base64,' + 'A'.repeat(500 * 1024 + 16)
const ctx = makeControllerCtx(
{ id: admin.id, username: 'admin', role: 'super_admin' },
{ type: 'image', dataUrl: oversized },
)
await ctrl.updateMyAvatar(ctx)
expect(ctx.status).toBe(400)
expect(ctx.body.error).toMatch(/too large|bytes/)
expect(users.getUserAvatar(admin.id)).toBe('')
})
it('rejects an image payload with no dataUrl with HTTP 400', async () => {
const { users, ctrl } = await initUsers()
const admin = users.bootstrapDefaultSuperAdmin('admin', '123456')!
const ctx = makeControllerCtx(
{ id: admin.id, username: 'admin', role: 'super_admin' },
{ type: 'image' },
)
await ctrl.updateMyAvatar(ctx)
expect(ctx.status).toBe(400)
expect(ctx.body.error).toMatch(/dataUrl/)
expect(users.getUserAvatar(admin.id)).toBe('')
})
it('rejects an image payload whose dataUrl is not a data URL with HTTP 400', async () => {
const { users, ctrl } = await initUsers()
const admin = users.bootstrapDefaultSuperAdmin('admin', '123456')!
const ctx = makeControllerCtx(
{ id: admin.id, username: 'admin', role: 'super_admin' },
{ type: 'image', dataUrl: 'https://example.com/avatar.png' },
)
await ctrl.updateMyAvatar(ctx)
expect(ctx.status).toBe(400)
expect(ctx.body.error).toMatch(/dataUrl/)
})
it('rejects a non-object body with HTTP 400', async () => {
const { ctrl } = await initUsers()
const ctx = makeControllerCtx(
{ id: 1, username: 'admin', role: 'super_admin' },
'just-a-string',
)
await ctrl.updateMyAvatar(ctx)
expect(ctx.status).toBe(400)
// A bare string is not valid JSON, so the controller rejects it via the JSON.parse path.
expect(ctx.body.error).toMatch(/not valid JSON|Invalid avatar payload|too large/)
})
it('rejects a malformed JSON string in the avatar field with HTTP 400', async () => {
const { ctrl } = await initUsers()
const ctx = makeControllerCtx(
{ id: 1, username: 'admin', role: 'super_admin' },
{ avatar: '{not valid json' },
)
await ctrl.updateMyAvatar(ctx)
expect(ctx.status).toBe(400)
expect(ctx.body.error).toMatch(/not valid JSON|Invalid/)
})
it('rejects a non-string seed with HTTP 400', async () => {
const { ctrl } = await initUsers()
const ctx = makeControllerCtx(
{ id: 1, username: 'admin', role: 'super_admin' },
{ type: 'default', seed: 123 },
)
await ctrl.updateMyAvatar(ctx)
expect(ctx.status).toBe(400)
expect(ctx.body.error).toMatch(/seed/)
})
it('returns 401 when the request is unauthenticated', async () => {
const { ctrl } = await initUsers()
const ctx = makeControllerCtx(null, { type: 'default' })
await ctrl.updateMyAvatar(ctx)
expect(ctx.status).toBe(401)
expect(ctx.body).toEqual({ error: 'Unauthorized' })
})
})
})