V13-FE-006: consolidate approved UI and contract hardening
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
import type { ZodType } from 'zod'
|
||||
import type { ProblemDetails } from '../api/problem'
|
||||
|
||||
export interface FormValidationFailure {
|
||||
summary: string
|
||||
fields: Record<string, string[]>
|
||||
}
|
||||
|
||||
export type FormValidationResult<T> =
|
||||
| { success: true; data: T }
|
||||
| { success: false; error: FormValidationFailure }
|
||||
|
||||
/** Parse at the feature submit boundary; the generic screen shell stays presentation-only. */
|
||||
export function validateFormSubmission<T>(schema: ZodType<T>, input: unknown): FormValidationResult<T> {
|
||||
const result = schema.safeParse(input)
|
||||
if (result.success) return result
|
||||
|
||||
const fields: Record<string, string[]> = {}
|
||||
for (const issue of result.error.issues) {
|
||||
const field = issue.path.length > 0 ? issue.path.join('.') : '_form'
|
||||
fields[field] ??= []
|
||||
fields[field].push(issue.message)
|
||||
}
|
||||
return { success: false, error: { summary: '입력값을 확인해 주세요.', fields } }
|
||||
}
|
||||
|
||||
export function mapProblemToFormFailure(problem: Pick<ProblemDetails, 'title' | 'detail' | 'errors'>): FormValidationFailure {
|
||||
return {
|
||||
summary: problem.detail?.trim() || problem.title,
|
||||
fields: Object.fromEntries(Object.entries(problem.errors ?? {}).map(([field, messages]) => [field, [...messages]]))
|
||||
}
|
||||
}
|
||||
@@ -5,12 +5,18 @@ const positiveInt = (value: string | null, fallback: number): number => {
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback
|
||||
}
|
||||
|
||||
export function decodeCrudQuery(params: URLSearchParams, fallback: CrudListQuery): CrudListQuery {
|
||||
export interface CrudQueryCodecOptions {
|
||||
readonly allowedSortFields?: ReadonlySet<string>
|
||||
readonly allowedFilterFields?: ReadonlySet<string>
|
||||
readonly allowedFilterOperators?: ReadonlySet<string>
|
||||
}
|
||||
|
||||
export function decodeCrudQuery(params: URLSearchParams, fallback: CrudListQuery, options: CrudQueryCodecOptions = {}): CrudListQuery {
|
||||
const sorts = params.getAll('sort').flatMap(value => {
|
||||
const parts = value.split(':')
|
||||
const field = parts[0]
|
||||
const direction = parts[1]
|
||||
if (field && (direction === 'asc' || direction === 'desc')) {
|
||||
if (field && (direction === 'asc' || direction === 'desc') && (!options.allowedSortFields || options.allowedSortFields.has(field))) {
|
||||
return [{ field, direction: direction as 'asc' | 'desc' }]
|
||||
}
|
||||
return []
|
||||
@@ -19,7 +25,11 @@ export function decodeCrudQuery(params: URLSearchParams, fallback: CrudListQuery
|
||||
const first = value.indexOf(':')
|
||||
const second = value.indexOf(':', first + 1)
|
||||
if (first <= 0 || second <= first) return []
|
||||
return [{ field: value.slice(0, first), operator: value.slice(first + 1, second), value: value.slice(second + 1) }]
|
||||
const field = value.slice(0, first)
|
||||
const operator = value.slice(first + 1, second)
|
||||
if (options.allowedFilterFields && !options.allowedFilterFields.has(field)) return []
|
||||
if (options.allowedFilterOperators && !options.allowedFilterOperators.has(operator)) return []
|
||||
return [{ field, operator, value: value.slice(second + 1) }]
|
||||
})
|
||||
return {
|
||||
page: positiveInt(params.get('page'), fallback.page),
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { z } from 'zod'
|
||||
import { mapProblemToFormFailure, validateFormSubmission } from '../formValidation'
|
||||
|
||||
const schema = z.object({ name: z.string().min(1, '이름은 필수입니다.'), quantity: z.number().int().positive('수량은 양수여야 합니다.') })
|
||||
|
||||
describe('validateFormSubmission', () => {
|
||||
it('returns typed data for valid input', () => {
|
||||
expect(validateFormSubmission(schema, { name: 'item', quantity: 2 })).toEqual({ success: true, data: { name: 'item', quantity: 2 } })
|
||||
})
|
||||
|
||||
it('maps field errors and summary for invalid input', () => {
|
||||
const result = validateFormSubmission(schema, { name: '', quantity: 0 })
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(result.error.summary).toBe('입력값을 확인해 주세요.')
|
||||
expect(result.error.fields.name).toContain('이름은 필수입니다.')
|
||||
expect(result.error.fields.quantity).toContain('수량은 양수여야 합니다.')
|
||||
}
|
||||
})
|
||||
|
||||
it('uses a form-level bucket for root validation errors', () => {
|
||||
const result = validateFormSubmission(z.string().min(3, '너무 짧습니다.'), 'x')
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) expect(result.error.fields._form).toContain('너무 짧습니다.')
|
||||
})
|
||||
|
||||
it('maps server ProblemDetails field errors without mutating the response', () => {
|
||||
const problem = { title: '검증 실패', detail: '입력값 오류', errors: { name: ['중복 이름'] } }
|
||||
const mapped = mapProblemToFormFailure(problem)
|
||||
expect(mapped).toEqual({ summary: '입력값 오류', fields: { name: ['중복 이름'] } })
|
||||
expect(mapped.fields.name).not.toBe(problem.errors.name)
|
||||
})
|
||||
})
|
||||
@@ -9,4 +9,18 @@ describe('CRUD URL codec', () => {
|
||||
it('fails closed to approved defaults for invalid paging', () => {
|
||||
expect(decodeCrudQuery(new URLSearchParams('page=0&pageSize=-1'), fallback)).toEqual({ ...fallback, search: undefined })
|
||||
})
|
||||
|
||||
it('drops unapproved sort fields, filter fields, and operators when a whitelist is supplied', () => {
|
||||
const params = new URLSearchParams('sort=secret:desc&sort=asOf:asc&filter=secret:eq:x&filter=status:contains:READY&filter=status:eq:READY')
|
||||
expect(decodeCrudQuery(params, fallback, {
|
||||
allowedSortFields: new Set(['asOf']),
|
||||
allowedFilterFields: new Set(['status']),
|
||||
allowedFilterOperators: new Set(['eq'])
|
||||
})).toEqual({
|
||||
...fallback,
|
||||
sorts: [{ field: 'asOf', direction: 'asc' }],
|
||||
filters: [{ field: 'status', operator: 'eq', value: 'READY' }],
|
||||
search: undefined
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user