Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion components/dashboard/profile-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { AuthError } from '@/lib/api/live';
import { isApiError } from '@/lib/api/errors';
import { queryKeys } from '@/lib/query';
import { applyOptimisticProfile } from '@/lib/api/optimistic';
import { validateProfile, type ProfileValidationErrors } from '@/lib/validation/profile';
import { validateProfile, mapServerValidationErrors, type ProfileValidationErrors } from '@/lib/validation/profile';
import {
clearProfileDraft,
loadProfileDraft,
Expand Down Expand Up @@ -309,6 +309,12 @@ export function ProfileEditor({ address }: { address: string }) {
onError: (err, _next, context) => {
qc.setQueryData(queryKeys.profile.byAddress(address), context?.previous);
setRollbackMessage(`Change reverted: ${safeErrorMessage(err)}`);

const serverErrors = mapServerValidationErrors(err);
if (Object.keys(serverErrors).length > 0) {
setErrors(serverErrors);
}

if (err instanceof AuthError) markExpired();
},

Expand Down
62 changes: 62 additions & 0 deletions lib/validation/profile.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { MemberProfile, SocialLink } from '../api/types'
import { isApiError } from '../api/errors'

const DISPLAY_NAME_MAX_LENGTH = 50
const BIO_MAX_LENGTH = 280
Expand Down Expand Up @@ -113,3 +114,64 @@ export function validateProfile(
},
}
}

// The live API is called with snake_case field names (see updateProfile in
// lib/api/live.ts), so a real 422 response's `details` bag may key its
// per-field messages either way depending on backend implementation. Accept
// both so we don't silently drop a server-side rejection.
const SERVER_DETAIL_KEY_TO_FIELD: Record<string, keyof ProfileValidationErrors> = {
displayName: 'displayName',
display_name: 'displayName',
bio: 'bio',
avatar: 'avatar',
avatar_url: 'avatar',
avatarUrl: 'avatar',
socialLinks: 'socialLinks',
social_links: 'socialLinks',
}

function detailToMessage(value: unknown): string | undefined {
if (typeof value === 'string') {
const trimmed = value.trim()
return trimmed ? trimmed : undefined
}
if (Array.isArray(value)) {
const messages = value.filter((v): v is string => typeof v === 'string' && v.trim().length > 0)
return messages.length > 0 ? messages.join(' ') : undefined
}
if (value && typeof value === 'object' && 'message' in value) {
const message = (value as { message?: unknown }).message
return typeof message === 'string' && message.trim() ? message.trim() : undefined
}
return undefined
}

/**
* Maps a failed profile update onto the same field-keyed shape used for
* client-side validation errors, so inline messages render the same way
* whether the rejection happened before the request left the browser (a
* `ProfileValidationError`, e.g. from the mock API's own re-validation) or
* came back from the live backend as a 422 with field-level `details`.
*
* Returns `{}` for anything that isn't a field-level validation error
* (network errors, auth errors, 5xx, etc.) — callers should fall back to a
* generic error message for those instead of clearing/hiding form errors.
*/
export function mapServerValidationErrors(err: unknown): ProfileValidationErrors {
if (err instanceof ProfileValidationError) {
return err.errors
}

if (isApiError(err) && err.code === 'validation_error' && err.details) {
const mapped: ProfileValidationErrors = {}
for (const [key, value] of Object.entries(err.details)) {
const field = SERVER_DETAIL_KEY_TO_FIELD[key]
if (!field) continue
const message = detailToMessage(value)
if (message) mapped[field] = message
}
return mapped
}

return {}
}
91 changes: 91 additions & 0 deletions test/profile-server-error-mapping.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { mapServerValidationErrors, validateProfile, ProfileValidationError } from '../lib/validation/profile'
import { ApiError } from '../lib/api/errors'
import type { MemberProfile } from '../lib/api/types'

const BASE: MemberProfile = { address: '0xabc', badges: [] }

test('valid submission: validateProfile accepts good input and mapServerValidationErrors has nothing to map', () => {
const result = validateProfile({ ...BASE, displayName: 'Ada', bio: 'Builder' })
assert.equal(result.valid, true)

// No error was thrown for a valid submission, so there is nothing for the
// mutation's onError handler to map — mirrors the real call site, which
// only invokes mapServerValidationErrors when the mutation actually fails.
assert.deepEqual(mapServerValidationErrors(undefined), {})
})

test('invalid submission: a ProfileValidationError maps straight through onto field errors', () => {
const result = validateProfile({ ...BASE, avatar: 'not-a-url' })
assert.equal(result.valid, false)
if (result.valid) return

const err = new ProfileValidationError(result.errors)
const mapped = mapServerValidationErrors(err)
assert.equal(mapped.avatar, result.errors.avatar)
assert.equal(Object.keys(mapped).length, 1)
})

test('maps a live-backend 422 validation_error onto field errors using snake_case detail keys', () => {
const err = new ApiError({
status: 422,
code: 'validation_error',
safeMessage: 'Some of the submitted data is invalid.',
details: {
display_name: 'Display name is already taken.',
social_links: ['Duplicate platform: twitter'],
},
})

const mapped = mapServerValidationErrors(err)
assert.equal(mapped.displayName, 'Display name is already taken.')
assert.equal(mapped.socialLinks, 'Duplicate platform: twitter')
})

test('maps a live-backend 422 validation_error using camelCase detail keys too', () => {
const err = new ApiError({
status: 422,
code: 'validation_error',
safeMessage: 'Some of the submitted data is invalid.',
details: { avatarUrl: 'Avatar host is not allowed.' },
})

const mapped = mapServerValidationErrors(err)
assert.equal(mapped.avatar, 'Avatar host is not allowed.')
})

test('ignores unrecognized detail keys instead of throwing', () => {
const err = new ApiError({
status: 422,
code: 'validation_error',
safeMessage: 'Some of the submitted data is invalid.',
details: { someUnrelatedField: 'nope' },
})

assert.deepEqual(mapServerValidationErrors(err), {})
})

test('returns {} for non-validation errors so callers fall back to a generic message', () => {
const networkErr = new ApiError({
code: 'network_error',
safeMessage: 'Unable to connect.',
retryable: true,
})
assert.deepEqual(mapServerValidationErrors(networkErr), {})

const authErr = new ApiError({ status: 401, code: 'unauthorized', safeMessage: 'Session expired.' })
assert.deepEqual(mapServerValidationErrors(authErr), {})

assert.deepEqual(mapServerValidationErrors(new Error('boom')), {})
assert.deepEqual(mapServerValidationErrors(null), {})
})

test('a validation_error with no details maps to {} rather than throwing', () => {
const err = new ApiError({
status: 422,
code: 'validation_error',
safeMessage: 'Some of the submitted data is invalid.',
})
assert.deepEqual(mapServerValidationErrors(err), {})
})