@hy_ong/zod-kit 0.2.1 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-MCDESS3T.js → chunk-2JGRV3JO.js} +1 -1
- package/dist/{chunk-4LYZAO3P.js → chunk-3QLUXIY2.js} +1 -1
- package/dist/chunk-3ZF5JO3F.js +61 -0
- package/dist/{chunk-OP4KV3BY.cjs → chunk-4AQB4RSU.cjs} +3 -3
- package/dist/{chunk-UFNVCUPQ.cjs → chunk-53EEWALQ.cjs} +3 -3
- package/dist/{chunk-R5G4V7C6.cjs → chunk-5ZMTAI4G.cjs} +3 -3
- package/dist/{chunk-RKHX3DGH.js → chunk-6DFP7XY2.js} +1 -1
- package/dist/{chunk-P2NONIMS.js → chunk-6IAPM7BP.js} +1 -1
- package/dist/{chunk-AWV2IT66.js → chunk-7SKH66CM.js} +1 -1
- package/dist/chunk-7X3XPK6A.js +92 -0
- package/dist/{chunk-FVO4743A.cjs → chunk-A2GAEU4O.cjs} +3 -3
- package/dist/{chunk-P364KRO5.js → chunk-AI4U42JV.js} +1 -1
- package/dist/{chunk-5ZEKWPSE.cjs → chunk-AYCXAJRA.cjs} +3 -3
- package/dist/{chunk-5LS4DSRQ.cjs → chunk-BZSPJJYT.cjs} +3 -3
- package/dist/chunk-BZWQPSHO.cjs +92 -0
- package/dist/{chunk-KARFFIMP.js → chunk-D55YFP5R.js} +1 -1
- package/dist/{chunk-I2RJMDXN.js → chunk-E52GRXYY.js} +1 -1
- package/dist/{chunk-VDOAPLA6.cjs → chunk-EDTNS2XL.cjs} +3 -3
- package/dist/{chunk-YALLOVNO.cjs → chunk-FEL432I2.cjs} +3 -3
- package/dist/chunk-FMPHW7ID.cjs +61 -0
- package/dist/{chunk-OMFQ7Z63.cjs → chunk-FP4O2ICM.cjs} +3 -3
- package/dist/{chunk-AANSHH2O.cjs → chunk-G747FHUZ.cjs} +3 -3
- package/dist/{chunk-JZ2SHRGZ.js → chunk-G7FLGJYD.js} +1 -1
- package/dist/{chunk-LKPXHW5N.cjs → chunk-H25N5GP6.cjs} +3 -3
- package/dist/{chunk-FC6VDOC7.js → chunk-H6STFX4I.js} +11 -2
- package/dist/{chunk-MAQRXYE6.js → chunk-HQ4RAMSD.js} +1 -1
- package/dist/{chunk-6X22I6NQ.cjs → chunk-HZ2WESSL.cjs} +3 -3
- package/dist/{chunk-W2EWMV3A.cjs → chunk-IWR3H7IH.cjs} +3 -3
- package/dist/{chunk-5GAZQDVS.cjs → chunk-JZEF5Q3W.cjs} +13 -4
- package/dist/{chunk-77KZUPPN.cjs → chunk-KIUO2HIR.cjs} +3 -3
- package/dist/{chunk-OEK7QSQP.js → chunk-LBH5U2DZ.js} +1 -1
- package/dist/{chunk-PGSDXR2I.js → chunk-LC4RNKBM.js} +1 -1
- package/dist/{chunk-YAU6JCYL.cjs → chunk-LNWEJED7.cjs} +3 -3
- package/dist/{chunk-WWRFBLCR.cjs → chunk-LXFRQLH4.cjs} +3 -3
- package/dist/{chunk-B3U5G3AA.js → chunk-NWQSOSNF.js} +1 -1
- package/dist/{chunk-LIQSVJLS.js → chunk-OGU7AIZF.js} +1 -1
- package/dist/{chunk-NKCYXBGX.js → chunk-OKO6WO6M.js} +1 -1
- package/dist/{chunk-6OGDPSWT.js → chunk-OSUPJCBA.js} +1 -1
- package/dist/{chunk-ZBOQCXD4.js → chunk-POIDES2L.js} +190 -0
- package/dist/{chunk-MG25BEV4.cjs → chunk-Q24GYUTO.cjs} +3 -3
- package/dist/{chunk-YWV2BBXN.cjs → chunk-Q7TUNJD4.cjs} +190 -0
- package/dist/{chunk-DRXPGQM6.cjs → chunk-QQWX3ICK.cjs} +3 -3
- package/dist/{chunk-EAU42EVH.js → chunk-RFWCYULE.js} +1 -1
- package/dist/{chunk-VCRKYMJM.js → chunk-RHKBT3M2.js} +1 -1
- package/dist/{chunk-36NWHESN.js → chunk-RVGCMQ4J.js} +1 -1
- package/dist/{chunk-IJEEM3DI.js → chunk-T7PG4JDW.js} +1 -1
- package/dist/{chunk-JBNCMS42.cjs → chunk-TDEXEIHH.cjs} +3 -3
- package/dist/{chunk-VP5CCP5F.cjs → chunk-TPXRQT2H.cjs} +3 -3
- package/dist/{chunk-DPXRMSB2.js → chunk-TRQMRHFM.js} +1 -1
- package/dist/{chunk-ZFQQXWNB.js → chunk-U2PB6XEO.js} +1 -1
- package/dist/{chunk-G6DV7LX7.cjs → chunk-UCPKW43K.cjs} +3 -3
- package/dist/{chunk-PL2GERLG.cjs → chunk-V2KKGSKQ.cjs} +3 -3
- package/dist/{chunk-AI72FMOF.cjs → chunk-VKBNKPFO.cjs} +3 -3
- package/dist/{chunk-5OGW2ERW.js → chunk-XAN4CAVH.js} +1 -1
- package/dist/{chunk-CFFCBWYL.cjs → chunk-ZCX22PY4.cjs} +3 -3
- package/dist/{chunk-TSHL7ZO2.js → chunk-ZXPRRNZR.js} +1 -1
- package/dist/common/boolean.cjs +3 -3
- package/dist/common/boolean.js +2 -2
- package/dist/common/color.cjs +3 -3
- package/dist/common/color.js +2 -2
- package/dist/common/coordinate.cjs +3 -3
- package/dist/common/coordinate.js +2 -2
- package/dist/common/credit-card.cjs +3 -3
- package/dist/common/credit-card.js +2 -2
- package/dist/common/date.cjs +3 -3
- package/dist/common/date.js +2 -2
- package/dist/common/datetime.cjs +3 -3
- package/dist/common/datetime.js +2 -2
- package/dist/common/email.cjs +3 -3
- package/dist/common/email.js +2 -2
- package/dist/common/file.cjs +3 -3
- package/dist/common/file.js +2 -2
- package/dist/common/id.cjs +3 -3
- package/dist/common/id.d.cts +34 -8
- package/dist/common/id.d.ts +34 -8
- package/dist/common/id.js +2 -2
- package/dist/common/ip.cjs +3 -3
- package/dist/common/ip.js +2 -2
- package/dist/common/many-of.cjs +7 -0
- package/dist/common/many-of.d.cts +111 -0
- package/dist/common/many-of.d.ts +111 -0
- package/dist/common/many-of.js +7 -0
- package/dist/common/number.cjs +3 -3
- package/dist/common/number.js +2 -2
- package/dist/common/one-of.cjs +7 -0
- package/dist/common/one-of.d.cts +104 -0
- package/dist/common/one-of.d.ts +104 -0
- package/dist/common/one-of.js +7 -0
- package/dist/common/password.cjs +3 -3
- package/dist/common/password.js +2 -2
- package/dist/common/text.cjs +3 -3
- package/dist/common/text.js +2 -2
- package/dist/common/time.cjs +3 -3
- package/dist/common/time.js +2 -2
- package/dist/common/url.cjs +3 -3
- package/dist/common/url.js +2 -2
- package/dist/index.cjs +35 -27
- package/dist/index.d.cts +2 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +46 -38
- package/dist/taiwan/bank-account.cjs +3 -3
- package/dist/taiwan/bank-account.js +2 -2
- package/dist/taiwan/business-id.cjs +3 -3
- package/dist/taiwan/business-id.js +2 -2
- package/dist/taiwan/fax.cjs +3 -3
- package/dist/taiwan/fax.js +2 -2
- package/dist/taiwan/invoice.cjs +3 -3
- package/dist/taiwan/invoice.js +2 -2
- package/dist/taiwan/license-plate.cjs +3 -3
- package/dist/taiwan/license-plate.js +2 -2
- package/dist/taiwan/mobile.cjs +3 -3
- package/dist/taiwan/mobile.js +2 -2
- package/dist/taiwan/national-id.cjs +3 -3
- package/dist/taiwan/national-id.js +2 -2
- package/dist/taiwan/passport.cjs +3 -3
- package/dist/taiwan/passport.js +2 -2
- package/dist/taiwan/postal-code.cjs +3 -3
- package/dist/taiwan/postal-code.js +2 -2
- package/dist/taiwan/tel.cjs +3 -3
- package/dist/taiwan/tel.js +2 -2
- package/package.json +15 -5
- package/src/i18n/locales/en-GB.json +19 -0
- package/src/i18n/locales/en-US.json +20 -1
- package/src/i18n/locales/id-ID.json +19 -0
- package/src/i18n/locales/ja-JP.json +19 -0
- package/src/i18n/locales/ko-KR.json +19 -0
- package/src/i18n/locales/ms-MY.json +19 -0
- package/src/i18n/locales/th-TH.json +19 -0
- package/src/i18n/locales/vi-VN.json +19 -0
- package/src/i18n/locales/zh-CN.json +19 -0
- package/src/i18n/locales/zh-TW.json +19 -0
- package/src/index.ts +2 -0
- package/src/validators/common/id.ts +45 -3
- package/src/validators/common/many-of.ts +219 -0
- package/src/validators/common/one-of.ts +172 -0
- package/tests/common/id.test.ts +68 -3
- package/tests/common/many-of.test.ts +198 -0
- package/tests/common/one-of.test.ts +136 -0
- package/tsup.config.ts +2 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview OneOf validator for Zod Kit
|
|
3
|
+
*
|
|
4
|
+
* Provides single-select validation that restricts input to a predefined set of allowed values,
|
|
5
|
+
* with support for case-insensitive matching, default values, and transformation.
|
|
6
|
+
*
|
|
7
|
+
* @author Ong Hoe Yuan
|
|
8
|
+
* @version 0.2.2
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { z, ZodType } from "zod"
|
|
12
|
+
import { t } from "../../i18n"
|
|
13
|
+
import { getLocale, type Locale } from "../../config"
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Type definition for oneOf validation error messages
|
|
17
|
+
*
|
|
18
|
+
* @interface OneOfMessages
|
|
19
|
+
* @property {string} [required] - Message when field is required but empty
|
|
20
|
+
* @property {string} [invalid] - Message when value is not in the allowed list
|
|
21
|
+
*/
|
|
22
|
+
export type OneOfMessages = {
|
|
23
|
+
required?: string
|
|
24
|
+
invalid?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Configuration options for oneOf validation
|
|
29
|
+
*
|
|
30
|
+
* @template IsRequired - Whether the field is required (affects return type)
|
|
31
|
+
* @template T - The type of allowed values
|
|
32
|
+
*
|
|
33
|
+
* @interface OneOfOptions
|
|
34
|
+
* @property {T[]} values - Array of allowed values
|
|
35
|
+
* @property {T | null} [defaultValue] - Default value when input is empty
|
|
36
|
+
* @property {boolean} [caseSensitive=true] - Whether string matching is case-sensitive
|
|
37
|
+
* @property {Function} [transform] - Custom transformation function applied after validation
|
|
38
|
+
* @property {Record<Locale, OneOfMessages>} [i18n] - Custom error messages for different locales
|
|
39
|
+
*/
|
|
40
|
+
export type OneOfOptions<IsRequired extends boolean = true, T extends string | number = string | number> = {
|
|
41
|
+
values: T[]
|
|
42
|
+
defaultValue?: IsRequired extends true ? T : T | null
|
|
43
|
+
caseSensitive?: boolean
|
|
44
|
+
transform?: (value: T) => T
|
|
45
|
+
i18n?: Partial<Record<Locale, Partial<OneOfMessages>>>
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Type alias for oneOf validation schema based on required flag
|
|
50
|
+
*
|
|
51
|
+
* @template IsRequired - Whether the field is required
|
|
52
|
+
* @template T - The type of allowed values
|
|
53
|
+
*/
|
|
54
|
+
export type OneOfSchema<IsRequired extends boolean, T> = IsRequired extends true ? ZodType<T> : ZodType<T | null>
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Creates a Zod schema for single-select validation that restricts values to a predefined set
|
|
58
|
+
*
|
|
59
|
+
* @template IsRequired - Whether the field is required (affects return type)
|
|
60
|
+
* @template T - The type of allowed values (string | number)
|
|
61
|
+
* @param {IsRequired} [required=false] - Whether the field is required
|
|
62
|
+
* @param {OneOfOptions<IsRequired, T>} options - Configuration options (values is required)
|
|
63
|
+
* @returns {OneOfSchema<IsRequired, T>} Zod schema for oneOf validation
|
|
64
|
+
*
|
|
65
|
+
* @example
|
|
66
|
+
* ```typescript
|
|
67
|
+
* // Basic single-select validation (optional by default)
|
|
68
|
+
* const roleSchema = oneOf(false, { values: ["admin", "editor", "viewer"] })
|
|
69
|
+
* roleSchema.parse("admin") // ✓ "admin"
|
|
70
|
+
* roleSchema.parse(null) // ✓ null
|
|
71
|
+
*
|
|
72
|
+
* // Required
|
|
73
|
+
* const statusSchema = oneOf(true, { values: ["active", "inactive", "pending"] })
|
|
74
|
+
* statusSchema.parse("active") // ✓ "active"
|
|
75
|
+
* statusSchema.parse(null) // ✗ Required
|
|
76
|
+
* statusSchema.parse("banned") // ✗ Invalid
|
|
77
|
+
*
|
|
78
|
+
* // Numeric values
|
|
79
|
+
* const prioritySchema = oneOf(true, { values: [1, 2, 3, 4, 5] })
|
|
80
|
+
* prioritySchema.parse(3) // ✓ 3
|
|
81
|
+
* prioritySchema.parse(10) // ✗ Invalid
|
|
82
|
+
*
|
|
83
|
+
* // Case-insensitive matching
|
|
84
|
+
* const colorSchema = oneOf(true, {
|
|
85
|
+
* values: ["red", "green", "blue"],
|
|
86
|
+
* caseSensitive: false
|
|
87
|
+
* })
|
|
88
|
+
* colorSchema.parse("RED") // ✓ "red" (normalized to match original)
|
|
89
|
+
* colorSchema.parse("Green") // ✓ "green"
|
|
90
|
+
*
|
|
91
|
+
* // With default value
|
|
92
|
+
* const tierSchema = oneOf(false, {
|
|
93
|
+
* values: ["free", "pro", "enterprise"],
|
|
94
|
+
* defaultValue: "free"
|
|
95
|
+
* })
|
|
96
|
+
* tierSchema.parse(null) // ✓ "free"
|
|
97
|
+
*
|
|
98
|
+
* // With transform
|
|
99
|
+
* const sizeSchema = oneOf(true, {
|
|
100
|
+
* values: ["s", "m", "l", "xl"],
|
|
101
|
+
* transform: (val) => val.toUpperCase() as any
|
|
102
|
+
* })
|
|
103
|
+
* sizeSchema.parse("m") // ✓ "M"
|
|
104
|
+
* ```
|
|
105
|
+
*/
|
|
106
|
+
export function oneOf<IsRequired extends boolean = false, T extends string | number = string | number>(
|
|
107
|
+
required?: IsRequired,
|
|
108
|
+
options?: Omit<OneOfOptions<IsRequired, T>, "required">,
|
|
109
|
+
): OneOfSchema<IsRequired, T> {
|
|
110
|
+
const { values = [] as unknown as T[], defaultValue = null, caseSensitive = true, transform, i18n } = options ?? {}
|
|
111
|
+
|
|
112
|
+
const isRequired = required ?? (false as IsRequired)
|
|
113
|
+
|
|
114
|
+
const getMessage = (key: keyof OneOfMessages, params?: Record<string, any>) => {
|
|
115
|
+
if (i18n) {
|
|
116
|
+
const currentLocale = getLocale()
|
|
117
|
+
const customMessages = i18n[currentLocale]
|
|
118
|
+
if (customMessages && customMessages[key]) {
|
|
119
|
+
const template = customMessages[key]!
|
|
120
|
+
return template.replace(/\$\{(\w+)}/g, (_, k) => params?.[k] ?? "")
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return t(`common.oneOf.${key}`, params)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const preprocessFn = (val: unknown) => {
|
|
127
|
+
if (val === "" || val === null || val === undefined) {
|
|
128
|
+
return defaultValue
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Coerce number strings to numbers when values contains numbers
|
|
132
|
+
const hasNumbers = values.some((v) => typeof v === "number")
|
|
133
|
+
if (hasNumbers && typeof val === "string" && !isNaN(Number(val)) && val.trim() !== "") {
|
|
134
|
+
const numVal = Number(val)
|
|
135
|
+
if ((values as number[]).includes(numVal)) return numVal
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Case-insensitive normalization for string values
|
|
139
|
+
if (!caseSensitive && typeof val === "string") {
|
|
140
|
+
const match = values.find((v) => typeof v === "string" && v.toLowerCase() === val.toLowerCase())
|
|
141
|
+
if (match !== undefined) return match
|
|
142
|
+
return val
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return val
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const baseSchema = z.preprocess(preprocessFn, z.any())
|
|
149
|
+
|
|
150
|
+
const schema = baseSchema
|
|
151
|
+
.superRefine((val, ctx) => {
|
|
152
|
+
if (val === null) {
|
|
153
|
+
if (isRequired) {
|
|
154
|
+
ctx.addIssue({ code: "custom", message: getMessage("required") })
|
|
155
|
+
}
|
|
156
|
+
return
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (!values.includes(val as T)) {
|
|
160
|
+
ctx.addIssue({
|
|
161
|
+
code: "custom",
|
|
162
|
+
message: getMessage("invalid", { values: values.join(", ") }),
|
|
163
|
+
})
|
|
164
|
+
}
|
|
165
|
+
})
|
|
166
|
+
.transform((val) => {
|
|
167
|
+
if (val === null || !transform) return val
|
|
168
|
+
return transform(val as T)
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
return schema as unknown as OneOfSchema<IsRequired, T>
|
|
172
|
+
}
|
package/tests/common/id.test.ts
CHANGED
|
@@ -11,6 +11,9 @@ const locales = [
|
|
|
11
11
|
maxLength: "Must be at most 10 characters",
|
|
12
12
|
numeric: "Must be a numeric ID",
|
|
13
13
|
uuid: "Must be a valid UUID",
|
|
14
|
+
uuidv1: "Must be a valid UUID v1",
|
|
15
|
+
uuidv4: "Must be a valid UUID v4",
|
|
16
|
+
uuidv7: "Must be a valid UUID v7",
|
|
14
17
|
objectId: "Must be a valid MongoDB ObjectId",
|
|
15
18
|
nanoid: "Must be a valid Nano ID",
|
|
16
19
|
snowflake: "Must be a valid Snowflake ID",
|
|
@@ -33,6 +36,9 @@ const locales = [
|
|
|
33
36
|
maxLength: "長度最多 10 字元",
|
|
34
37
|
numeric: "必須為數字 ID",
|
|
35
38
|
uuid: "必須為有效的 UUID",
|
|
39
|
+
uuidv1: "必須為有效的 UUID v1",
|
|
40
|
+
uuidv4: "必須為有效的 UUID v4",
|
|
41
|
+
uuidv7: "必須為有效的 UUID v7",
|
|
36
42
|
objectId: "必須為有效的 MongoDB ObjectId",
|
|
37
43
|
nanoid: "必須為有效的 Nano ID",
|
|
38
44
|
snowflake: "必須為有效的 Snowflake ID",
|
|
@@ -51,7 +57,20 @@ const locales = [
|
|
|
51
57
|
// Valid test IDs for different formats
|
|
52
58
|
const validIds = {
|
|
53
59
|
numeric: ["1", "123", "999999", "0"],
|
|
54
|
-
uuid: [
|
|
60
|
+
uuid: [
|
|
61
|
+
"550e8400-e29b-41d4-a716-446655440000", // v4
|
|
62
|
+
"6ba7b810-9dad-11d1-80b4-00c04fd430c8", // v1
|
|
63
|
+
"f47ac10b-58cc-4372-a567-0e02b2c3d479", // v4
|
|
64
|
+
"01939fee-4b0b-7da3-a9e4-79fb4c267eb8", // v7
|
|
65
|
+
"320c3d4d-cc00-875b-8ec9-32d5f69181c0", // v8
|
|
66
|
+
],
|
|
67
|
+
uuidv1: ["6ba7b810-9dad-11d1-80b4-00c04fd430c8", "f47ac10b-58cc-1372-a567-0e02b2c3d479"],
|
|
68
|
+
uuidv3: ["a3bb189e-8bf9-3888-9912-ace4e6543002"],
|
|
69
|
+
uuidv4: ["550e8400-e29b-41d4-a716-446655440000", "f47ac10b-58cc-4372-a567-0e02b2c3d479"],
|
|
70
|
+
uuidv5: ["886313e1-3b8a-5372-9b90-0c9aee199e5d"],
|
|
71
|
+
uuidv6: ["1ef21d2f-1207-6b90-8b50-a2e4100c5480"],
|
|
72
|
+
uuidv7: ["01939fee-4b0b-7da3-a9e4-79fb4c267eb8", "018fce07-42dc-7dc0-ba2c-9f7e777caee3"],
|
|
73
|
+
uuidv8: ["320c3d4d-cc00-875b-8ec9-32d5f69181c0"],
|
|
55
74
|
objectId: ["507f1f77bcf86cd799439011", "507f191e810c19729de860ea", "5a9427648b0beebeb69579cc"],
|
|
56
75
|
nanoid: ["V1StGXR8_Z5jdHi6B-myT", "3IBBoOd_b1YSlnKdvQ8fK", "9_xnJ2QZt8vKl3_Kj5f7N"],
|
|
57
76
|
snowflake: ["1234567890123456789", "9876543210987654321", "5555555555555555555"],
|
|
@@ -65,11 +84,19 @@ const invalidIds = {
|
|
|
65
84
|
numeric: ["abc", "12a", ""],
|
|
66
85
|
uuid: [
|
|
67
86
|
"550e8400-e29b-41d4-a716-44665544000", // too short
|
|
68
|
-
"550e8400-e29b-41d4-a716-44665544000g", //
|
|
69
|
-
"550e8400-e29b-
|
|
87
|
+
"550e8400-e29b-41d4-a716-44665544000g", // invalid character
|
|
88
|
+
"550e8400-e29b-91d4-a716-446655440000", // wrong version (9)
|
|
70
89
|
"550e8400-e29b-41d4-c716-446655440000", // wrong variant (c instead of 8,9,a,b)
|
|
71
90
|
"not-a-uuid-at-all",
|
|
72
91
|
],
|
|
92
|
+
uuidv4: [
|
|
93
|
+
"6ba7b810-9dad-11d1-80b4-00c04fd430c8", // v1, not v4
|
|
94
|
+
"01939fee-4b0b-7da3-a9e4-79fb4c267eb8", // v7, not v4
|
|
95
|
+
],
|
|
96
|
+
uuidv7: [
|
|
97
|
+
"550e8400-e29b-41d4-a716-446655440000", // v4, not v7
|
|
98
|
+
"6ba7b810-9dad-11d1-80b4-00c04fd430c8", // v1, not v7
|
|
99
|
+
],
|
|
73
100
|
objectId: [
|
|
74
101
|
"507f1f77bcf86cd79943901", // too short
|
|
75
102
|
"507f1f77bcf86cd799439011g", // invalid character
|
|
@@ -192,6 +219,44 @@ describe.each(locales)("id(true) locale: $locale", ({ locale, messages }) => {
|
|
|
192
219
|
})
|
|
193
220
|
})
|
|
194
221
|
|
|
222
|
+
describe("UUID version-specific validation", () => {
|
|
223
|
+
it("should accept valid UUIDv4 and reject other versions", () => {
|
|
224
|
+
const schema = id(true, { type: "uuidv4" })
|
|
225
|
+
validIds.uuidv4.forEach((v) => expect(schema.parse(v)).toBe(v))
|
|
226
|
+
invalidIds.uuidv4.forEach((v) => expect(() => schema.parse(v)).toThrow(messages.uuidv4))
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
it("should accept valid UUIDv7 and reject other versions", () => {
|
|
230
|
+
const schema = id(true, { type: "uuidv7" })
|
|
231
|
+
validIds.uuidv7.forEach((v) => expect(schema.parse(v)).toBe(v))
|
|
232
|
+
invalidIds.uuidv7.forEach((v) => expect(() => schema.parse(v)).toThrow(messages.uuidv7))
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
it("should accept valid UUIDv1", () => {
|
|
236
|
+
const schema = id(true, { type: "uuidv1" })
|
|
237
|
+
validIds.uuidv1.forEach((v) => expect(schema.parse(v)).toBe(v))
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
it("should accept valid UUIDv6", () => {
|
|
241
|
+
const schema = id(true, { type: "uuidv6" })
|
|
242
|
+
validIds.uuidv6.forEach((v) => expect(schema.parse(v)).toBe(v))
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
it("should accept any version with generic uuid type", () => {
|
|
246
|
+
const schema = id(true, { type: "uuid" })
|
|
247
|
+
// All version-specific IDs should pass generic uuid validation
|
|
248
|
+
;[...validIds.uuidv1, ...validIds.uuidv4, ...validIds.uuidv7, ...validIds.uuidv8].forEach((v) => expect(schema.parse(v)).toBe(v))
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
it("should work with allowedTypes for specific versions", () => {
|
|
252
|
+
const schema = id(true, { allowedTypes: ["uuidv4", "uuidv7"] })
|
|
253
|
+
validIds.uuidv4.forEach((v) => expect(schema.parse(v)).toBe(v))
|
|
254
|
+
validIds.uuidv7.forEach((v) => expect(schema.parse(v)).toBe(v))
|
|
255
|
+
// v1 should be rejected
|
|
256
|
+
expect(() => schema.parse("6ba7b810-9dad-11d1-80b4-00c04fd430c8")).toThrow()
|
|
257
|
+
})
|
|
258
|
+
})
|
|
259
|
+
|
|
195
260
|
describe("ObjectId validation", () => {
|
|
196
261
|
it("should accept valid ObjectIds", () => {
|
|
197
262
|
const schema = id(true, { type: "objectId" })
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "vitest"
|
|
2
|
+
import { manyOf, setLocale, Locale } from "../../src"
|
|
3
|
+
|
|
4
|
+
const locales = [
|
|
5
|
+
{
|
|
6
|
+
locale: "en-US",
|
|
7
|
+
messages: {
|
|
8
|
+
required: "Required",
|
|
9
|
+
invalid: "Must be from: react, vue, angular, svelte",
|
|
10
|
+
minSelect: "Must select at least 2 item(s)",
|
|
11
|
+
maxSelect: "Must select at most 3 item(s)",
|
|
12
|
+
duplicate: "Duplicate values are not allowed",
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
locale: "zh-TW",
|
|
17
|
+
messages: {
|
|
18
|
+
required: "必填",
|
|
19
|
+
invalid: "必須為以下值之一:react, vue, angular, svelte",
|
|
20
|
+
minSelect: "至少需選擇 2 項",
|
|
21
|
+
maxSelect: "最多只能選擇 3 項",
|
|
22
|
+
duplicate: "不允許重複的值",
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
] as const
|
|
26
|
+
|
|
27
|
+
const frameworks = ["react", "vue", "angular", "svelte"] as const
|
|
28
|
+
|
|
29
|
+
describe.each(locales)("manyOf(true) locale: $locale", ({ locale, messages }) => {
|
|
30
|
+
beforeEach(() => setLocale(locale as Locale))
|
|
31
|
+
|
|
32
|
+
describe("basic functionality", () => {
|
|
33
|
+
it("should accept valid arrays", () => {
|
|
34
|
+
const schema = manyOf(true, { values: [...frameworks] })
|
|
35
|
+
expect(schema.parse(["react"])).toEqual(["react"])
|
|
36
|
+
expect(schema.parse(["react", "vue"])).toEqual(["react", "vue"])
|
|
37
|
+
expect(schema.parse(["react", "vue", "angular", "svelte"])).toEqual(["react", "vue", "angular", "svelte"])
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it("should reject invalid values in array", () => {
|
|
41
|
+
const schema = manyOf(true, { values: [...frameworks] })
|
|
42
|
+
expect(() => schema.parse(["react", "jquery"])).toThrow(messages.invalid)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it("should fail with empty when required", () => {
|
|
46
|
+
const schema = manyOf(true, { values: [...frameworks] })
|
|
47
|
+
expect(() => schema.parse(null)).toThrow(messages.required)
|
|
48
|
+
expect(() => schema.parse(undefined)).toThrow(messages.required)
|
|
49
|
+
expect(() => schema.parse("")).toThrow(messages.required)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it("should pass with null when not required", () => {
|
|
53
|
+
const schema = manyOf(false, { values: [...frameworks] })
|
|
54
|
+
expect(schema.parse(null)).toBe(null)
|
|
55
|
+
expect(schema.parse(undefined)).toBe(null)
|
|
56
|
+
expect(schema.parse("")).toBe(null)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it("should wrap single value in array", () => {
|
|
60
|
+
const schema = manyOf(true, { values: [...frameworks] })
|
|
61
|
+
expect(schema.parse("react")).toEqual(["react"])
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
describe("min/max selection", () => {
|
|
66
|
+
it("should enforce minimum selections", () => {
|
|
67
|
+
const schema = manyOf(true, { values: [...frameworks], min: 2 })
|
|
68
|
+
expect(() => schema.parse(["react"])).toThrow(messages.minSelect)
|
|
69
|
+
expect(schema.parse(["react", "vue"])).toEqual(["react", "vue"])
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it("should enforce maximum selections", () => {
|
|
73
|
+
const schema = manyOf(true, { values: [...frameworks], max: 3 })
|
|
74
|
+
expect(() => schema.parse(["react", "vue", "angular", "svelte"])).toThrow(messages.maxSelect)
|
|
75
|
+
expect(schema.parse(["react", "vue", "angular"])).toEqual(["react", "vue", "angular"])
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it("should enforce both min and max", () => {
|
|
79
|
+
const schema = manyOf(true, { values: [...frameworks], min: 2, max: 3 })
|
|
80
|
+
expect(() => schema.parse(["react"])).toThrow(messages.minSelect)
|
|
81
|
+
expect(() => schema.parse(["react", "vue", "angular", "svelte"])).toThrow(messages.maxSelect)
|
|
82
|
+
expect(schema.parse(["react", "vue"])).toEqual(["react", "vue"])
|
|
83
|
+
expect(schema.parse(["react", "vue", "angular"])).toEqual(["react", "vue", "angular"])
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
describe("duplicate handling", () => {
|
|
88
|
+
it("should reject duplicates by default", () => {
|
|
89
|
+
const schema = manyOf(true, { values: [...frameworks] })
|
|
90
|
+
expect(() => schema.parse(["react", "react"])).toThrow(messages.duplicate)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it("should allow duplicates when configured", () => {
|
|
94
|
+
const schema = manyOf(true, { values: [...frameworks], allowDuplicates: true })
|
|
95
|
+
expect(schema.parse(["react", "react"])).toEqual(["react", "react"])
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
describe("numeric values", () => {
|
|
100
|
+
it("should accept valid numeric arrays", () => {
|
|
101
|
+
const schema = manyOf(true, { values: [1, 2, 3, 4, 5] })
|
|
102
|
+
expect(schema.parse([1, 3, 5])).toEqual([1, 3, 5])
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it("should reject invalid numeric values", () => {
|
|
106
|
+
const schema = manyOf(true, { values: [1, 2, 3] })
|
|
107
|
+
expect(() => schema.parse([1, 10])).toThrow()
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it("should coerce number strings", () => {
|
|
111
|
+
const schema = manyOf(true, { values: [1, 2, 3] })
|
|
112
|
+
expect(schema.parse(["1", "2"])).toEqual([1, 2])
|
|
113
|
+
})
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
describe("case sensitivity", () => {
|
|
117
|
+
it("should be case sensitive by default", () => {
|
|
118
|
+
const schema = manyOf(true, { values: [...frameworks] })
|
|
119
|
+
expect(() => schema.parse(["React"])).toThrow()
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it("should match case-insensitively when configured", () => {
|
|
123
|
+
const schema = manyOf(true, { values: [...frameworks], caseSensitive: false })
|
|
124
|
+
expect(schema.parse(["REACT", "Vue"])).toEqual(["react", "vue"])
|
|
125
|
+
})
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
describe("default value", () => {
|
|
129
|
+
it("should use default value when input is empty", () => {
|
|
130
|
+
const schema = manyOf(false, { values: [...frameworks], defaultValue: ["react"] })
|
|
131
|
+
expect(schema.parse(null)).toEqual(["react"])
|
|
132
|
+
expect(schema.parse(undefined)).toEqual(["react"])
|
|
133
|
+
expect(schema.parse("")).toEqual(["react"])
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it("should use provided value over default", () => {
|
|
137
|
+
const schema = manyOf(false, { values: [...frameworks], defaultValue: ["react"] })
|
|
138
|
+
expect(schema.parse(["vue", "angular"])).toEqual(["vue", "angular"])
|
|
139
|
+
})
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
describe("transform", () => {
|
|
143
|
+
it("should apply transform to valid arrays", () => {
|
|
144
|
+
const schema = manyOf(true, {
|
|
145
|
+
values: [...frameworks],
|
|
146
|
+
transform: (vals) => vals.map((v) => v.toUpperCase()) as any,
|
|
147
|
+
})
|
|
148
|
+
expect(schema.parse(["react", "vue"])).toEqual(["REACT", "VUE"])
|
|
149
|
+
})
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
describe("custom i18n", () => {
|
|
153
|
+
it("should use custom messages when provided", () => {
|
|
154
|
+
const schema = manyOf(true, {
|
|
155
|
+
values: [...frameworks],
|
|
156
|
+
i18n: {
|
|
157
|
+
"en-US": { required: "Pick frameworks!", duplicate: "No repeats!" },
|
|
158
|
+
"zh-TW": { required: "請選擇框架!", duplicate: "不能重複!" },
|
|
159
|
+
},
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
if (locale === "en-US") {
|
|
163
|
+
expect(() => schema.parse(null)).toThrow("Pick frameworks!")
|
|
164
|
+
expect(() => schema.parse(["react", "react"])).toThrow("No repeats!")
|
|
165
|
+
} else {
|
|
166
|
+
expect(() => schema.parse(null)).toThrow("請選擇框架!")
|
|
167
|
+
expect(() => schema.parse(["react", "react"])).toThrow("不能重複!")
|
|
168
|
+
}
|
|
169
|
+
})
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
describe("complex scenarios", () => {
|
|
173
|
+
it("should work with all options combined", () => {
|
|
174
|
+
const schema = manyOf(true, {
|
|
175
|
+
values: [...frameworks],
|
|
176
|
+
min: 1,
|
|
177
|
+
max: 3,
|
|
178
|
+
caseSensitive: false,
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
expect(schema.parse(["REACT", "Vue"])).toEqual(["react", "vue"])
|
|
182
|
+
expect(() => schema.parse([])).toThrow() // min 1
|
|
183
|
+
expect(() => schema.parse(["react", "vue", "angular", "svelte"])).toThrow() // max 3
|
|
184
|
+
expect(() => schema.parse(["jquery"])).toThrow() // invalid
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it("should handle empty array when required with no min", () => {
|
|
188
|
+
const schema = manyOf(true, { values: [...frameworks] })
|
|
189
|
+
// Empty array is a valid array, just has 0 items — not null
|
|
190
|
+
expect(schema.parse([])).toEqual([])
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it("should handle empty array with min constraint", () => {
|
|
194
|
+
const schema = manyOf(true, { values: [...frameworks], min: 1 })
|
|
195
|
+
expect(() => schema.parse([])).toThrow()
|
|
196
|
+
})
|
|
197
|
+
})
|
|
198
|
+
})
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "vitest"
|
|
2
|
+
import { oneOf, setLocale, Locale } from "../../src"
|
|
3
|
+
|
|
4
|
+
const locales = [
|
|
5
|
+
{
|
|
6
|
+
locale: "en-US",
|
|
7
|
+
messages: {
|
|
8
|
+
required: "Required",
|
|
9
|
+
invalid: "Must be one of: admin, editor, viewer",
|
|
10
|
+
},
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
locale: "zh-TW",
|
|
14
|
+
messages: {
|
|
15
|
+
required: "必填",
|
|
16
|
+
invalid: "必須為以下其中一個值:admin, editor, viewer",
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
] as const
|
|
20
|
+
|
|
21
|
+
const stringValues = ["admin", "editor", "viewer"] as const
|
|
22
|
+
const numericValues = [1, 2, 3, 4, 5] as const
|
|
23
|
+
|
|
24
|
+
describe.each(locales)("oneOf(true) locale: $locale", ({ locale, messages }) => {
|
|
25
|
+
beforeEach(() => setLocale(locale as Locale))
|
|
26
|
+
|
|
27
|
+
describe("basic functionality", () => {
|
|
28
|
+
it("should accept valid values", () => {
|
|
29
|
+
const schema = oneOf(true, { values: [...stringValues] })
|
|
30
|
+
expect(schema.parse("admin")).toBe("admin")
|
|
31
|
+
expect(schema.parse("editor")).toBe("editor")
|
|
32
|
+
expect(schema.parse("viewer")).toBe("viewer")
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it("should reject invalid values", () => {
|
|
36
|
+
const schema = oneOf(true, { values: [...stringValues] })
|
|
37
|
+
expect(() => schema.parse("hacker")).toThrow(messages.invalid)
|
|
38
|
+
expect(() => schema.parse("superadmin")).toThrow(messages.invalid)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it("should fail with empty when required", () => {
|
|
42
|
+
const schema = oneOf(true, { values: [...stringValues] })
|
|
43
|
+
expect(() => schema.parse("")).toThrow(messages.required)
|
|
44
|
+
expect(() => schema.parse(null)).toThrow(messages.required)
|
|
45
|
+
expect(() => schema.parse(undefined)).toThrow(messages.required)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it("should pass with null when not required", () => {
|
|
49
|
+
const schema = oneOf(false, { values: [...stringValues] })
|
|
50
|
+
expect(schema.parse("")).toBe(null)
|
|
51
|
+
expect(schema.parse(null)).toBe(null)
|
|
52
|
+
expect(schema.parse(undefined)).toBe(null)
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
describe("numeric values", () => {
|
|
57
|
+
it("should accept valid numeric values", () => {
|
|
58
|
+
const schema = oneOf(true, { values: [...numericValues] })
|
|
59
|
+
expect(schema.parse(1)).toBe(1)
|
|
60
|
+
expect(schema.parse(3)).toBe(3)
|
|
61
|
+
expect(schema.parse(5)).toBe(5)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it("should reject invalid numeric values", () => {
|
|
65
|
+
const schema = oneOf(true, { values: [...numericValues] })
|
|
66
|
+
expect(() => schema.parse(10)).toThrow()
|
|
67
|
+
expect(() => schema.parse(0)).toThrow()
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it("should coerce number strings to numbers", () => {
|
|
71
|
+
const schema = oneOf(true, { values: [...numericValues] })
|
|
72
|
+
expect(schema.parse("3")).toBe(3)
|
|
73
|
+
expect(schema.parse("5")).toBe(5)
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
describe("case sensitivity", () => {
|
|
78
|
+
it("should be case sensitive by default", () => {
|
|
79
|
+
const schema = oneOf(true, { values: [...stringValues] })
|
|
80
|
+
expect(() => schema.parse("Admin")).toThrow()
|
|
81
|
+
expect(() => schema.parse("EDITOR")).toThrow()
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it("should match case-insensitively when configured", () => {
|
|
85
|
+
const schema = oneOf(true, { values: [...stringValues], caseSensitive: false })
|
|
86
|
+
expect(schema.parse("Admin")).toBe("admin")
|
|
87
|
+
expect(schema.parse("EDITOR")).toBe("editor")
|
|
88
|
+
expect(schema.parse("VIEWER")).toBe("viewer")
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
describe("default value", () => {
|
|
93
|
+
it("should use default value when input is empty", () => {
|
|
94
|
+
const schema = oneOf(false, { values: [...stringValues], defaultValue: "viewer" })
|
|
95
|
+
expect(schema.parse("")).toBe("viewer")
|
|
96
|
+
expect(schema.parse(null)).toBe("viewer")
|
|
97
|
+
expect(schema.parse(undefined)).toBe("viewer")
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it("should use provided value over default", () => {
|
|
101
|
+
const schema = oneOf(false, { values: [...stringValues], defaultValue: "viewer" })
|
|
102
|
+
expect(schema.parse("admin")).toBe("admin")
|
|
103
|
+
})
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
describe("transform", () => {
|
|
107
|
+
it("should apply transform to valid values", () => {
|
|
108
|
+
const schema = oneOf(true, {
|
|
109
|
+
values: [...stringValues],
|
|
110
|
+
transform: (val) => val.toUpperCase() as any,
|
|
111
|
+
})
|
|
112
|
+
expect(schema.parse("admin")).toBe("ADMIN")
|
|
113
|
+
expect(schema.parse("editor")).toBe("EDITOR")
|
|
114
|
+
})
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
describe("custom i18n", () => {
|
|
118
|
+
it("should use custom messages when provided", () => {
|
|
119
|
+
const schema = oneOf(true, {
|
|
120
|
+
values: [...stringValues],
|
|
121
|
+
i18n: {
|
|
122
|
+
"en-US": { required: "Pick a role!", invalid: "Bad role!" },
|
|
123
|
+
"zh-TW": { required: "請選擇角色!", invalid: "無效角色!" },
|
|
124
|
+
},
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
if (locale === "en-US") {
|
|
128
|
+
expect(() => schema.parse("")).toThrow("Pick a role!")
|
|
129
|
+
expect(() => schema.parse("hacker")).toThrow("Bad role!")
|
|
130
|
+
} else {
|
|
131
|
+
expect(() => schema.parse("")).toThrow("請選擇角色!")
|
|
132
|
+
expect(() => schema.parse("hacker")).toThrow("無效角色!")
|
|
133
|
+
}
|
|
134
|
+
})
|
|
135
|
+
})
|
|
136
|
+
})
|
package/tsup.config.ts
CHANGED
|
@@ -9,7 +9,9 @@ export default defineConfig({
|
|
|
9
9
|
"common/email": "src/validators/common/email.ts",
|
|
10
10
|
"common/file": "src/validators/common/file.ts",
|
|
11
11
|
"common/id": "src/validators/common/id.ts",
|
|
12
|
+
"common/many-of": "src/validators/common/many-of.ts",
|
|
12
13
|
"common/number": "src/validators/common/number.ts",
|
|
14
|
+
"common/one-of": "src/validators/common/one-of.ts",
|
|
13
15
|
"common/password": "src/validators/common/password.ts",
|
|
14
16
|
"common/text": "src/validators/common/text.ts",
|
|
15
17
|
"common/time": "src/validators/common/time.ts",
|