diff --git a/packages/ack-pay/src/schemas.test.ts b/packages/ack-pay/src/schemas.test.ts new file mode 100644 index 0000000..8d931c0 --- /dev/null +++ b/packages/ack-pay/src/schemas.test.ts @@ -0,0 +1,51 @@ +import * as v from "valibot" +import { describe, expect, it } from "vitest" + +import { paymentRequestSchema as valibotPaymentRequestSchema } from "./schemas/valibot" +import { paymentRequestSchema as zodV3PaymentRequestSchema } from "./schemas/zod/v3" +import { paymentRequestSchema as zodV4PaymentRequestSchema } from "./schemas/zod/v4" + +const paymentRequest = { + id: "test-payment-request-id", + paymentOptions: [ + { + id: "test-payment-option-id", + amount: 10, + decimals: 2, + currency: "USD", + recipient: "sol:123", + }, + ], +} + +describe("paymentRequestSchema", () => { + it("reports invalid expiresAt strings as schema errors", () => { + const input = { + ...paymentRequest, + expiresAt: "invalid-date", + } + + expect(v.safeParse(valibotPaymentRequestSchema, input).success).toBe(false) + expect(zodV3PaymentRequestSchema.safeParse(input).success).toBe(false) + expect(zodV4PaymentRequestSchema.safeParse(input).success).toBe(false) + }) + + it("normalizes valid expiresAt inputs to an ISO string", () => { + const expected = "2024-12-31T23:59:59.000Z" + for (const expiresAt of [ + new Date("2024-12-31T23:59:59Z"), + "2024-12-31T23:59:59Z", + ]) { + const input = { ...paymentRequest, expiresAt } + + const valibot = v.safeParse(valibotPaymentRequestSchema, input) + expect(valibot.success && valibot.output.expiresAt).toBe(expected) + + const v3 = zodV3PaymentRequestSchema.safeParse(input) + expect(v3.success && v3.data.expiresAt).toBe(expected) + + const v4 = zodV4PaymentRequestSchema.safeParse(input) + expect(v4.success && v4.data.expiresAt).toBe(expected) + } + }) +}) diff --git a/packages/ack-pay/src/schemas/valibot.ts b/packages/ack-pay/src/schemas/valibot.ts index 06a58f1..d97ed94 100644 --- a/packages/ack-pay/src/schemas/valibot.ts +++ b/packages/ack-pay/src/schemas/valibot.ts @@ -4,6 +4,12 @@ import * as v from "valibot" const urlOrDidUri = v.union([v.pipe(v.string(), v.url()), didUriSchema]) +const timestampSchema = v.pipe( + v.union([v.date(), v.string()]), + v.check((input) => !Number.isNaN(new Date(input).getTime()), "Invalid date"), + v.transform((input) => new Date(input).toISOString()), +) + export const paymentOptionSchema = v.object({ id: v.string(), amount: v.union([v.pipe(v.number(), v.integer(), v.gtValue(0)), v.string()]), @@ -19,12 +25,7 @@ export const paymentRequestSchema = v.object({ id: v.string(), description: v.optional(v.string()), serviceCallback: v.optional(v.pipe(v.string(), v.url())), - expiresAt: v.optional( - v.pipe( - v.union([v.date(), v.string()]), - v.transform((input) => new Date(input).toISOString()), - ), - ), + expiresAt: v.optional(timestampSchema), paymentOptions: v.pipe( v.tupleWithRest([paymentOptionSchema], paymentOptionSchema), v.nonEmpty(), diff --git a/packages/ack-pay/src/schemas/zod/v3.ts b/packages/ack-pay/src/schemas/zod/v3.ts index 2e3ee83..36acd31 100644 --- a/packages/ack-pay/src/schemas/zod/v3.ts +++ b/packages/ack-pay/src/schemas/zod/v3.ts @@ -4,6 +4,21 @@ import { z } from "zod/v3" const urlOrDidUri = z.union([z.string().url(), didUriSchema]) +const timestampSchema = z + .union([z.date(), z.string()]) + .transform((val, ctx) => { + const date = new Date(val) + if (Number.isNaN(date.getTime())) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Invalid date", + }) + return z.NEVER + } + + return date.toISOString() + }) + export const paymentOptionSchema = z.object({ id: z.string(), amount: z.union([z.number().int().positive(), z.string()]), @@ -19,10 +34,7 @@ export const paymentRequestSchema = z.object({ id: z.string(), description: z.string().optional(), serviceCallback: z.string().url().optional(), - expiresAt: z - .union([z.date(), z.string()]) - .transform((val) => new Date(val).toISOString()) - .optional(), + expiresAt: timestampSchema.optional(), paymentOptions: z.array(paymentOptionSchema).nonempty(), }) diff --git a/packages/ack-pay/src/schemas/zod/v4.ts b/packages/ack-pay/src/schemas/zod/v4.ts index 55c19f0..bdd0374 100644 --- a/packages/ack-pay/src/schemas/zod/v4.ts +++ b/packages/ack-pay/src/schemas/zod/v4.ts @@ -4,6 +4,22 @@ import * as z from "zod/v4" const urlOrDidUri = z.union([z.url(), didUriSchema]) +const timestampSchema = z + .union([z.date(), z.string()]) + .transform((val, ctx) => { + const date = new Date(val) + if (Number.isNaN(date.getTime())) { + ctx.addIssue({ + code: "custom", + message: "Invalid date", + input: val, + }) + return z.NEVER + } + + return date.toISOString() + }) + export const paymentOptionSchema = z.object({ id: z.string(), amount: z.union([z.number().int().positive(), z.string()]), @@ -19,10 +35,7 @@ export const paymentRequestSchema = z.object({ id: z.string(), description: z.string().optional(), serviceCallback: z.url().optional(), - expiresAt: z - .union([z.date(), z.string()]) - .transform((val) => new Date(val).toISOString()) - .optional(), + expiresAt: timestampSchema.optional(), paymentOptions: z.array(paymentOptionSchema).nonempty(), })