mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-08-01 16:43:37 +08:00
The token usage profile header had too much top spacing, the edit action competed with the content from the top-right corner, and the profile subtitle was fixed to the default project link. This keeps the profile controls close to the identity block, moves the modal scrim to the document body so it covers the whole desktop shell, and persists a user-editable second line with URL auto-linking. Constraint: Preserve existing profile preferences and backfill older records that do not have a subtitle field. Rejected: Keep the top-right edit button | it kept the action visually detached from the profile content. Rejected: Store the second line only in desktop local state | the preference already has a server-backed profile shape. Confidence: high Scope-risk: narrow Directive: Keep profile preference schema changes backward-compatible with older desktop-ui preference files. Tested: cd desktop && bun run test --run src/pages/ActivitySettings.test.tsx src/api/desktopUiPreferences.test.ts Tested: bun test src/server/__tests__/desktop-ui-preferences.test.ts Tested: bun run check:persistence-upgrade Tested: bun run check:server Tested: bun run check:desktop Tested: git diff --check Not-tested: Full Tauri native shell smoke; renderer flow was smoke-tested in browser only.
117 lines
4.3 KiB
TypeScript
117 lines
4.3 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import { getDefaultBaseUrl, setAuthToken, setBaseUrl } from './client'
|
|
import { desktopUiPreferencesApi, getProfileAvatarUrl } from './desktopUiPreferences'
|
|
|
|
const preferences = {
|
|
schemaVersion: 2,
|
|
profile: {
|
|
displayName: 'cc-haha',
|
|
subtitle: 'github.com/NanmiCoder/cc-haha',
|
|
avatarFile: null,
|
|
avatarUpdatedAt: null,
|
|
},
|
|
sidebar: {
|
|
projectOrder: [],
|
|
pinnedProjects: [],
|
|
hiddenProjects: [],
|
|
projectOrganization: 'recentProject',
|
|
projectSortBy: 'updatedAt',
|
|
},
|
|
}
|
|
|
|
describe('desktopUiPreferencesApi', () => {
|
|
afterEach(() => {
|
|
setAuthToken(null)
|
|
setBaseUrl(getDefaultBaseUrl())
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
it('wraps preference reads and profile updates with the configured API base URL', async () => {
|
|
setBaseUrl('http://127.0.0.1:49237')
|
|
const fetchMock = vi.spyOn(globalThis, 'fetch')
|
|
fetchMock
|
|
.mockResolvedValueOnce(new Response(JSON.stringify({ exists: true, preferences }), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
}))
|
|
.mockResolvedValueOnce(new Response(JSON.stringify({ ok: true, preferences }), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
}))
|
|
.mockResolvedValueOnce(new Response(JSON.stringify({ ok: true, preferences }), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
}))
|
|
|
|
await expect(desktopUiPreferencesApi.getPreferences()).resolves.toEqual({ exists: true, preferences })
|
|
await expect(desktopUiPreferencesApi.updateProfilePreferences({
|
|
displayName: 'Local Captain',
|
|
subtitle: 'local.example',
|
|
})).resolves.toEqual({
|
|
ok: true,
|
|
preferences,
|
|
})
|
|
await expect(desktopUiPreferencesApi.deleteProfileAvatar()).resolves.toEqual({ ok: true, preferences })
|
|
|
|
expect(fetchMock).toHaveBeenNthCalledWith(1, 'http://127.0.0.1:49237/api/desktop-ui/preferences', expect.objectContaining({
|
|
method: 'GET',
|
|
}))
|
|
expect(fetchMock).toHaveBeenNthCalledWith(2, 'http://127.0.0.1:49237/api/desktop-ui/preferences/profile', expect.objectContaining({
|
|
method: 'PUT',
|
|
body: JSON.stringify({ displayName: 'Local Captain', subtitle: 'local.example' }),
|
|
}))
|
|
expect(fetchMock).toHaveBeenNthCalledWith(3, 'http://127.0.0.1:49237/api/desktop-ui/preferences/profile/avatar', expect.objectContaining({
|
|
method: 'DELETE',
|
|
}))
|
|
})
|
|
|
|
it('uploads profile avatars with the file content type and auth token', async () => {
|
|
setBaseUrl('http://127.0.0.1:49237')
|
|
setAuthToken('h5_avatar_token')
|
|
const fetchMock = vi.spyOn(globalThis, 'fetch')
|
|
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ ok: true, preferences }), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
}))
|
|
|
|
const file = new File([new Uint8Array([137, 80, 78, 71])], 'avatar.png', { type: 'image/png' })
|
|
await expect(desktopUiPreferencesApi.uploadProfileAvatar(file)).resolves.toEqual({ ok: true, preferences })
|
|
|
|
const [url, init] = fetchMock.mock.calls[0]!
|
|
expect(url).toBe('http://127.0.0.1:49237/api/desktop-ui/preferences/profile/avatar')
|
|
expect(init).toMatchObject({
|
|
method: 'PUT',
|
|
headers: {
|
|
'Content-Type': 'image/png',
|
|
Authorization: 'Bearer h5_avatar_token',
|
|
},
|
|
body: file,
|
|
})
|
|
})
|
|
|
|
it('surfaces avatar upload API errors and builds cache-busted avatar URLs', async () => {
|
|
setBaseUrl('http://127.0.0.1:49237')
|
|
const fetchMock = vi.spyOn(globalThis, 'fetch')
|
|
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ message: 'Too large' }), {
|
|
status: 413,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
}))
|
|
|
|
const file = new File(['bad'], 'avatar.bin', { type: '' })
|
|
await expect(desktopUiPreferencesApi.uploadProfileAvatar(file)).rejects.toThrow('Too large')
|
|
|
|
const [, init] = fetchMock.mock.calls[0]!
|
|
expect(init).toMatchObject({
|
|
method: 'PUT',
|
|
headers: {
|
|
'Content-Type': 'application/octet-stream',
|
|
},
|
|
body: file,
|
|
})
|
|
expect(getProfileAvatarUrl('2026-05-30T15:37:51.649Z')).toBe(
|
|
'http://127.0.0.1:49237/api/desktop-ui/preferences/profile/avatar?v=2026-05-30T15%3A37%3A51.649Z',
|
|
)
|
|
expect(getProfileAvatarUrl(null)).toBe('http://127.0.0.1:49237/api/desktop-ui/preferences/profile/avatar')
|
|
})
|
|
})
|