diff --git a/frontend/src/lib/bucketInput.test.ts b/frontend/src/lib/bucketInput.test.ts new file mode 100644 index 0000000..72f9755 --- /dev/null +++ b/frontend/src/lib/bucketInput.test.ts @@ -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) + }) +}) diff --git a/frontend/src/lib/bucketInput.ts b/frontend/src/lib/bucketInput.ts new file mode 100644 index 0000000..6557ac1 --- /dev/null +++ b/frontend/src/lib/bucketInput.ts @@ -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) +} diff --git a/frontend/src/lib/fieldErrors.test.ts b/frontend/src/lib/fieldErrors.test.ts index a9b1a2e..3605c6e 100644 --- a/frontend/src/lib/fieldErrors.test.ts +++ b/frontend/src/lib/fieldErrors.test.ts @@ -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() + }) +}) diff --git a/frontend/src/lib/fieldErrors.ts b/frontend/src/lib/fieldErrors.ts index 2e573fd..e8e32fa 100644 --- a/frontend/src/lib/fieldErrors.ts +++ b/frontend/src/lib/fieldErrors.ts @@ -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 +}