fix(web): route avatar to profile management
All checks were successful
CI / validate (push) Successful in 19m38s
All checks were successful
CI / validate (push) Successful in 19m38s
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
88
apps/web/composables/useDngAuth.test.ts
Normal file
88
apps/web/composables/useDngAuth.test.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useDngAuth } from './useDngAuth'
|
||||
|
||||
const states = new Map<string, { value: unknown }>()
|
||||
|
||||
function activeSession() {
|
||||
return {
|
||||
accessToken: 'access-token',
|
||||
refreshToken: 'refresh-token',
|
||||
expiresAt: Date.now() + 600_000,
|
||||
user: {
|
||||
id: 'user-123',
|
||||
email: 'old-name@example.com',
|
||||
user_metadata: { display_name: 'Old Name' },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('useDngAuth profile updates', () => {
|
||||
beforeEach(() => {
|
||||
states.clear()
|
||||
vi.restoreAllMocks()
|
||||
vi.stubGlobal('useRuntimeConfig', () => ({
|
||||
public: {
|
||||
supabaseUrl: 'https://supabase.example',
|
||||
supabaseAnonKey: 'anon-key',
|
||||
},
|
||||
}))
|
||||
vi.stubGlobal('useState', (key: string, factory: () => unknown) => {
|
||||
if (!states.has(key)) states.set(key, { value: factory() })
|
||||
return states.get(key)
|
||||
})
|
||||
})
|
||||
|
||||
it('updates both the public profile and authenticated user metadata', async () => {
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce({
|
||||
id: 'user-123',
|
||||
email: 'old-name@example.com',
|
||||
user_metadata: { display_name: 'New Name' },
|
||||
})
|
||||
vi.stubGlobal('$fetch', fetchMock)
|
||||
|
||||
const auth = useDngAuth()
|
||||
auth.session.value = activeSession()
|
||||
|
||||
await auth.updateProfile(' New Name ')
|
||||
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(1, 'https://supabase.example/rest/v1/profiles?id=eq.user-123', expect.objectContaining({
|
||||
method: 'PATCH',
|
||||
body: { display_name: 'New Name' },
|
||||
}))
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(2, 'https://supabase.example/auth/v1/user', expect.objectContaining({
|
||||
method: 'PUT',
|
||||
body: { data: { display_name: 'New Name' } },
|
||||
}))
|
||||
expect(auth.session.value?.user.user_metadata?.display_name).toBe('New Name')
|
||||
})
|
||||
|
||||
it('rolls the public profile back when the auth metadata update fails', async () => {
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockRejectedValueOnce(new Error('Auth update failed'))
|
||||
.mockResolvedValueOnce(undefined)
|
||||
vi.stubGlobal('$fetch', fetchMock)
|
||||
|
||||
const auth = useDngAuth()
|
||||
auth.session.value = activeSession()
|
||||
|
||||
await expect(auth.updateProfile('New Name')).rejects.toThrow('Auth update failed')
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(3, 'https://supabase.example/rest/v1/profiles?id=eq.user-123', expect.objectContaining({
|
||||
body: { display_name: 'Old Name' },
|
||||
}))
|
||||
expect(auth.session.value?.user.user_metadata?.display_name).toBe('Old Name')
|
||||
})
|
||||
|
||||
it('rejects invalid display names before making a request', async () => {
|
||||
const fetchMock = vi.fn()
|
||||
vi.stubGlobal('$fetch', fetchMock)
|
||||
|
||||
const auth = useDngAuth()
|
||||
auth.session.value = activeSession()
|
||||
|
||||
await expect(auth.updateProfile(' ')).rejects.toThrow('between 2 and 80 characters')
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -211,6 +211,44 @@ export function useDngAuth() {
|
||||
return user
|
||||
}
|
||||
|
||||
async function updateProfile(displayName: string) {
|
||||
const nextDisplayName = displayName.trim()
|
||||
if (nextDisplayName.length < 2 || nextDisplayName.length > 80) {
|
||||
throw new Error('Display name must contain between 2 and 80 characters.')
|
||||
}
|
||||
|
||||
const token = await accessToken()
|
||||
if (!session.value) throw new Error('Enter the alpha to continue.')
|
||||
const activeSession = session.value
|
||||
|
||||
await $fetch(`${config.public.supabaseUrl}/rest/v1/profiles?id=eq.${encodeURIComponent(activeSession.user.id)}`, {
|
||||
method: 'PATCH',
|
||||
headers: { ...authHeaders(token), Prefer: 'return=minimal' },
|
||||
body: { display_name: nextDisplayName },
|
||||
})
|
||||
|
||||
try {
|
||||
const user = normalizeUser(await $fetch<DngAuthUser>(`${config.public.supabaseUrl}/auth/v1/user`, {
|
||||
method: 'PUT',
|
||||
headers: authHeaders(token),
|
||||
body: { data: { display_name: nextDisplayName } },
|
||||
}))
|
||||
persist({ ...activeSession, user })
|
||||
return user
|
||||
} catch (cause) {
|
||||
const previousDisplayName = activeSession.user.user_metadata?.display_name
|
||||
const rollbackDisplayName = typeof previousDisplayName === 'string' && previousDisplayName.trim()
|
||||
? previousDisplayName.trim()
|
||||
: activeSession.user.email?.split('@')[0] || 'Adventurer'
|
||||
await $fetch(`${config.public.supabaseUrl}/rest/v1/profiles?id=eq.${encodeURIComponent(activeSession.user.id)}`, {
|
||||
method: 'PATCH',
|
||||
headers: { ...authHeaders(token), Prefer: 'return=minimal' },
|
||||
body: { display_name: rollbackDisplayName },
|
||||
}).catch(() => undefined)
|
||||
throw cause
|
||||
}
|
||||
}
|
||||
|
||||
async function accessToken() {
|
||||
await restore()
|
||||
if (!session.value) throw new Error('Enter the alpha to continue.')
|
||||
@@ -238,5 +276,5 @@ export function useDngAuth() {
|
||||
hydrated.value = true
|
||||
}
|
||||
|
||||
return { session, hydrated, restore, refresh, signUp, signIn, requestPasswordReset, updatePassword, accessToken, signOut, invalidate }
|
||||
return { session, hydrated, restore, refresh, signUp, signIn, requestPasswordReset, updatePassword, updateProfile, accessToken, signOut, invalidate }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user