54 - Add violations-parser and dollar/percent wire converters
This commit is contained in:
parent
b1bc41c6d5
commit
44731e7388
4 changed files with 174 additions and 1 deletions
46
frontend/src/lib/bucketInput.test.ts
Normal file
46
frontend/src/lib/bucketInput.test.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { dollarsToCents, percentToBasisPoints } from './bucketInput'
|
||||
|
||||
describe('dollarsToCents', () => {
|
||||
it('converts whole dollars to cents', () => {
|
||||
expect(dollarsToCents('100')).toBe(10000)
|
||||
})
|
||||
|
||||
it('converts a dollar amount with cents', () => {
|
||||
expect(dollarsToCents('99.99')).toBe(9999)
|
||||
})
|
||||
|
||||
it('rounds to the nearest cent', () => {
|
||||
expect(dollarsToCents('1.005')).toBe(101)
|
||||
})
|
||||
|
||||
it('converts zero', () => {
|
||||
expect(dollarsToCents('0')).toBe(0)
|
||||
})
|
||||
|
||||
it('treats an empty string as zero', () => {
|
||||
expect(dollarsToCents('')).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('percentToBasisPoints', () => {
|
||||
it('converts a whole percent to basis points', () => {
|
||||
expect(percentToBasisPoints('25')).toBe(2500)
|
||||
})
|
||||
|
||||
it('converts a fractional percent to basis points', () => {
|
||||
expect(percentToBasisPoints('0.5')).toBe(50)
|
||||
})
|
||||
|
||||
it('converts 100% to 10000 basis points', () => {
|
||||
expect(percentToBasisPoints('100')).toBe(10000)
|
||||
})
|
||||
|
||||
it('converts zero', () => {
|
||||
expect(percentToBasisPoints('0')).toBe(0)
|
||||
})
|
||||
|
||||
it('treats an empty string as zero', () => {
|
||||
expect(percentToBasisPoints('')).toBe(0)
|
||||
})
|
||||
})
|
||||
22
frontend/src/lib/bucketInput.ts
Normal file
22
frontend/src/lib/bucketInput.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
/**
|
||||
* Convert human-entered form values into the integer wire units the API stores.
|
||||
* The inverse direction of `bucketDisplay.ts` (which formats wire units for
|
||||
* display): here dollars become cents and percent becomes basis points.
|
||||
*
|
||||
* An empty string is treated as zero (a not-yet-filled field), and rounding is
|
||||
* done via a fixed-precision string to sidestep IEEE-754 drift (`1.005 * 100`
|
||||
* is `100.4999…`, which would truncate the half-cent down without this guard).
|
||||
*/
|
||||
function toScaledInteger(value: string): number {
|
||||
return Math.round(Number((Number(value) * 100).toFixed(4)))
|
||||
}
|
||||
|
||||
/** Dollars (`'99.99'`) → integer cents (`9999`). */
|
||||
export function dollarsToCents(value: string): number {
|
||||
return toScaledInteger(value)
|
||||
}
|
||||
|
||||
/** Percent (`'25'`) → integer basis points (`2500`). */
|
||||
export function percentToBasisPoints(value: string): number {
|
||||
return toScaledInteger(value)
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { ApiError } from './api'
|
||||
import { parseFieldErrors } from './fieldErrors'
|
||||
import { parseFieldErrors, parseViolations } from './fieldErrors'
|
||||
|
||||
function error422(body: unknown): ApiError {
|
||||
return new ApiError(422, JSON.stringify(body))
|
||||
|
|
@ -44,3 +44,46 @@ describe('parseFieldErrors', () => {
|
|||
expect(parseFieldErrors(error422({ errors: { email: [42] } }))).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseViolations', () => {
|
||||
it('groups a Hydra ConstraintViolationList into a per-field error map, keyed by propertyPath', () => {
|
||||
const result = parseViolations(
|
||||
error422({
|
||||
violations: [
|
||||
{ propertyPath: 'name', message: 'x' },
|
||||
{ propertyPath: 'name', message: 'y' },
|
||||
{ propertyPath: 'priority', message: 'z' },
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result).toEqual({
|
||||
name: ['x', 'y'],
|
||||
priority: ['z'],
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null for a non-422 ApiError', () => {
|
||||
expect(parseViolations(new ApiError(500, 'server error'))).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for a non-ApiError', () => {
|
||||
expect(parseViolations(new Error('boom'))).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for the auth field-error shape, not the violations shape', () => {
|
||||
expect(
|
||||
parseViolations(error422({ errors: { email: ['Already taken.'] } })),
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when the body has no violations key', () => {
|
||||
expect(parseViolations(error422({ message: 'bad' }))).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when a violation entry is missing propertyPath or message', () => {
|
||||
expect(
|
||||
parseViolations(error422({ violations: [{ propertyPath: 'name' }] })),
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -43,3 +43,65 @@ export function parseFieldErrors(error: unknown): FieldErrors | null {
|
|||
|
||||
return parsed.errors as FieldErrors
|
||||
}
|
||||
|
||||
function isViolation(
|
||||
value: unknown,
|
||||
): value is { propertyPath: string; message: string } {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
'propertyPath' in value &&
|
||||
typeof value.propertyPath === 'string' &&
|
||||
'message' in value &&
|
||||
typeof value.message === 'string'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the API Platform `{ violations: [{ propertyPath, message }] }` list
|
||||
* from a 422 ApiError body, grouped into a per-field map keyed by propertyPath
|
||||
* (multiple messages for the same path are collected in order).
|
||||
*
|
||||
* This is the sibling of {@link parseFieldErrors}: API Platform resources emit
|
||||
* this violations shape, while the hand-rolled auth endpoints emit the
|
||||
* `{ errors: { field: [...] } }` shape. Returns null for anything that isn't a
|
||||
* well-formed 422 violations response — including the auth shape — so the
|
||||
* caller can fall back to a generic error message.
|
||||
*/
|
||||
export function parseViolations(error: unknown): FieldErrors | null {
|
||||
if (!(error instanceof ApiError) || error.status !== 422) {
|
||||
return null
|
||||
}
|
||||
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(error.body)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
if (
|
||||
typeof parsed !== 'object' ||
|
||||
parsed === null ||
|
||||
!('violations' in parsed) ||
|
||||
!Array.isArray(parsed.violations)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Only accept the contract shape: every entry has a string propertyPath and
|
||||
// message. A malformed entry rejects the whole body rather than dropping it.
|
||||
if (!parsed.violations.every(isViolation)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const fieldErrors: FieldErrors = {}
|
||||
for (const { propertyPath, message } of parsed.violations) {
|
||||
if (!fieldErrors[propertyPath]) {
|
||||
fieldErrors[propertyPath] = []
|
||||
}
|
||||
fieldErrors[propertyPath].push(message)
|
||||
}
|
||||
|
||||
return fieldErrors
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue