V13-FE-006: consolidate approved UI and contract hardening
deploy / deploy (push) Successful in 1m52s
deploy / notify (push) Successful in 1s

This commit is contained in:
2026-08-13 02:41:00 +09:00
parent d79edae546
commit 3f293d8aa8
1278 changed files with 14384 additions and 1664 deletions
@@ -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]]))
}
}
+13 -3
View File
@@ -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
})
})
})