Skip to content

Commit 6895f2b

Browse files
committed
test(valibot): add Elixir constraint tests and wire up all shouldFail runners
- Add valibot_constraints_test.exs mirroring ZodConstraintsTest with Valibot-specific assertions (v.pipe composition, v.optional wrapping, v.picklist enums, no method chaining) - Wire up 28 previously exported-but-uncalled functions in runValibotTests.ts (regex, float, CiString, optional, boundary tests)
1 parent b9a8b2d commit 6895f2b

File tree

2 files changed

+249
-0
lines changed

2 files changed

+249
-0
lines changed
Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
# SPDX-FileCopyrightText: 2025 ash_typescript contributors <https://github.com/ash-project/ash_typescript/graphs/contributors>
2+
#
3+
# SPDX-License-Identifier: MIT
4+
5+
defmodule AshTypescript.Rpc.ValibotConstraintsTest do
6+
@moduledoc """
7+
Tests for Valibot schema generation with type constraints.
8+
9+
Mirrors `ZodConstraintsTest` but verifies Valibot-specific output:
10+
- Constraints use `v.pipe()` composition, not method chaining
11+
- Optional fields wrap as `v.optional(schema)`, not `schema.optional()`
12+
- Enums use `v.picklist([...])`, not `z.enum([...])`
13+
- UUID uses `v.pipe(v.string(), v.uuid())`, not `z.uuid()`
14+
- Required non-nullable strings get `v.pipe(v.string(), v.minLength(1))`, not `z.string().min(1)`
15+
"""
16+
use ExUnit.Case, async: true
17+
18+
alias AshTypescript.Codegen.ValibotSchemaGenerator
19+
alias AshTypescript.Test.OrgTodo
20+
21+
describe "Integer constraints in Valibot schemas" do
22+
test "generates min and max constraints for integer arguments via v.pipe" do
23+
action = Ash.Resource.Info.action(OrgTodo, :create)
24+
schema = ValibotSchemaGenerator.generate_valibot_schema(OrgTodo, action, "create_org_todo")
25+
26+
# number_of_employees has constraints [min: 1, max: 1000]
27+
assert schema =~ "numberOfEmployees: v.pipe(v.number(), v.integer(), v.minValue(1), v.maxValue(1000))"
28+
end
29+
30+
test "integer without constraints generates plain v.pipe(v.number(), v.integer())" do
31+
action = Ash.Resource.Info.action(OrgTodo, :create)
32+
schema = ValibotSchemaGenerator.generate_valibot_schema(OrgTodo, action, "create_org_todo")
33+
34+
# uuid fields should not have minValue/maxValue
35+
refute schema =~ ~r/userId.*v\.minValue/
36+
refute schema =~ ~r/userId.*v\.maxValue/
37+
end
38+
end
39+
40+
describe "String constraints in Valibot schemas" do
41+
test "generates min and max length constraints via v.pipe" do
42+
action = Ash.Resource.Info.action(OrgTodo, :create)
43+
schema = ValibotSchemaGenerator.generate_valibot_schema(OrgTodo, action, "create_org_todo")
44+
45+
assert schema =~ "someString: v.pipe(v.string(), v.minLength(1), v.maxLength(100))"
46+
end
47+
48+
test "required string without explicit min_length gets v.pipe(v.string(), v.minLength(1))" do
49+
action = Ash.Resource.Info.action(OrgTodo, :create)
50+
schema = ValibotSchemaGenerator.generate_valibot_schema(OrgTodo, action, "create_org_todo")
51+
52+
assert schema =~ "title: v.pipe(v.string(), v.minLength(1))"
53+
end
54+
55+
test "optional string without constraints generates v.optional(v.string())" do
56+
action = Ash.Resource.Info.action(OrgTodo, :create)
57+
schema = ValibotSchemaGenerator.generate_valibot_schema(OrgTodo, action, "create_org_todo")
58+
59+
assert schema =~ "description: v.optional(v.string())"
60+
refute schema =~ ~r/description.*v\.minLength/
61+
refute schema =~ ~r/description.*v\.maxLength/
62+
end
63+
64+
test "does NOT use method chaining for non-empty required string" do
65+
action = Ash.Resource.Info.action(OrgTodo, :create)
66+
schema = ValibotSchemaGenerator.generate_valibot_schema(OrgTodo, action, "create_org_todo")
67+
68+
# Regression: the old buggy output was v.string().min(1) — must never appear
69+
refute schema =~ "v.string().min("
70+
end
71+
end
72+
73+
describe "Float constraints in Valibot schemas" do
74+
test "generates min and max constraints for float arguments via v.pipe" do
75+
action = Ash.Resource.Info.action(OrgTodo, :create)
76+
schema = ValibotSchemaGenerator.generate_valibot_schema(OrgTodo, action, "create_org_todo")
77+
78+
# price has constraints [min: 0.0, max: 999999.99]
79+
assert schema =~ "price: v.pipe(v.number(), v.minValue(0.0), v.maxValue(999999.99))"
80+
end
81+
82+
test "generates gt/lt constraints for float arguments via v.pipe" do
83+
action = Ash.Resource.Info.action(OrgTodo, :create)
84+
schema = ValibotSchemaGenerator.generate_valibot_schema(OrgTodo, action, "create_org_todo")
85+
86+
# temperature has constraints [greater_than: -273.15, less_than: 1_000_000.0]
87+
assert schema =~ "temperature: v.pipe(v.number(), v.gtValue(-273.15), v.ltValue(1000000.0))"
88+
end
89+
90+
test "generates min and max constraints for percentage" do
91+
action = Ash.Resource.Info.action(OrgTodo, :create)
92+
schema = ValibotSchemaGenerator.generate_valibot_schema(OrgTodo, action, "create_org_todo")
93+
94+
assert schema =~ "percentage: v.pipe(v.number(), v.minValue(0.0), v.maxValue(100.0))"
95+
end
96+
97+
test "float without constraints generates plain v.number()" do
98+
action = Ash.Resource.Info.action(OrgTodo, :create)
99+
schema = ValibotSchemaGenerator.generate_valibot_schema(OrgTodo, action, "create_org_todo")
100+
101+
refute schema =~ ~r/price.*v\.gtValue/
102+
refute schema =~ ~r/price.*v\.ltValue/
103+
end
104+
end
105+
106+
describe "Optional fields in Valibot schemas" do
107+
test "optional fields wrap with v.optional(schema), not schema.optional()" do
108+
action = Ash.Resource.Info.action(OrgTodo, :create)
109+
schema = ValibotSchemaGenerator.generate_valibot_schema(OrgTodo, action, "create_org_todo")
110+
111+
assert schema =~ "v.optional("
112+
refute schema =~ ".optional()"
113+
end
114+
end
115+
116+
describe "UUID type in Valibot schemas" do
117+
test "UUID uses v.pipe(v.string(), v.uuid())" do
118+
action = Ash.Resource.Info.action(OrgTodo, :create)
119+
schema = ValibotSchemaGenerator.generate_valibot_schema(OrgTodo, action, "create_org_todo")
120+
121+
assert schema =~ "userId: v.pipe(v.string(), v.uuid())"
122+
end
123+
end
124+
125+
describe "Constraint priority and ordering" do
126+
test "integer: integer() validator comes before min/max in the pipe" do
127+
action = Ash.Resource.Info.action(OrgTodo, :create)
128+
schema = ValibotSchemaGenerator.generate_valibot_schema(OrgTodo, action, "create_org_todo")
129+
130+
# v.integer() must appear before v.minValue/v.maxValue
131+
assert schema =~ "v.pipe(v.number(), v.integer(), v.minValue(1), v.maxValue(1000))"
132+
end
133+
134+
test "string: min length appears before max length in the pipe" do
135+
action = Ash.Resource.Info.action(OrgTodo, :create)
136+
schema = ValibotSchemaGenerator.generate_valibot_schema(OrgTodo, action, "create_org_todo")
137+
138+
assert schema =~ "v.pipe(v.string(), v.minLength(1), v.maxLength(100))"
139+
end
140+
141+
test "each argument gets its own independent pipe chain" do
142+
action = Ash.Resource.Info.action(OrgTodo, :create)
143+
schema = ValibotSchemaGenerator.generate_valibot_schema(OrgTodo, action, "create_org_todo")
144+
145+
assert schema =~ "numberOfEmployees: v.pipe(v.number(), v.integer(), v.minValue(1), v.maxValue(1000))"
146+
assert schema =~ "someString: v.pipe(v.string(), v.minLength(1), v.maxLength(100))"
147+
148+
# Constraints must not bleed across fields
149+
refute schema =~ ~r/numberOfEmployees.*v\.maxLength/
150+
refute schema =~ ~r/someString.*v\.maxValue/
151+
end
152+
end
153+
154+
describe "Schema structure" do
155+
test "schema declaration uses v.object()" do
156+
action = Ash.Resource.Info.action(OrgTodo, :create)
157+
schema = ValibotSchemaGenerator.generate_valibot_schema(OrgTodo, action, "create_org_todo")
158+
159+
assert schema =~ "export const createOrgTodoValibotSchema = v.object({"
160+
assert schema =~ "});"
161+
end
162+
163+
test "field names are camelCase" do
164+
action = Ash.Resource.Info.action(OrgTodo, :create)
165+
schema = ValibotSchemaGenerator.generate_valibot_schema(OrgTodo, action, "create_org_todo")
166+
167+
assert schema =~ "numberOfEmployees:"
168+
refute schema =~ "number_of_employees:"
169+
assert schema =~ "someString:"
170+
refute schema =~ "some_string:"
171+
end
172+
173+
test "each field line ends with a comma" do
174+
action = Ash.Resource.Info.action(OrgTodo, :create)
175+
schema = ValibotSchemaGenerator.generate_valibot_schema(OrgTodo, action, "create_org_todo")
176+
177+
lines = String.split(schema, "\n")
178+
field_lines = Enum.filter(lines, &String.contains?(&1, ": v."))
179+
180+
for line <- field_lines do
181+
assert String.ends_with?(String.trim(line), ","),
182+
"Field line should end with comma: #{line}"
183+
end
184+
end
185+
end
186+
187+
describe "Regex constraints in Valibot schemas" do
188+
test "regex constraints are emitted as v.regex(...) inside a pipe" do
189+
action = Ash.Resource.Info.action(OrgTodo, :create)
190+
schema = ValibotSchemaGenerator.generate_valibot_schema(OrgTodo, action, "create_org_todo")
191+
192+
# Fields with regex match constraints should use v.regex inside v.pipe
193+
assert schema =~ "v.regex("
194+
end
195+
196+
test "no method chaining is used anywhere in the schema" do
197+
action = Ash.Resource.Info.action(OrgTodo, :create)
198+
schema = ValibotSchemaGenerator.generate_valibot_schema(OrgTodo, action, "create_org_todo")
199+
200+
# Valibot doesn't support method chaining — nothing like .min(), .max(), .regex() etc.
201+
refute schema =~ ~r/v\.[a-z]+\(\)\.[a-z]+\(/
202+
end
203+
end
204+
205+
describe "Constraint definitions match Ash resource" do
206+
test "generated constraints exactly match the Ash attribute definitions" do
207+
action = Ash.Resource.Info.action(OrgTodo, :create)
208+
209+
number_arg = Enum.find(action.arguments, &(&1.public? && &1.name == :number_of_employees))
210+
assert number_arg.constraints == [min: 1, max: 1000]
211+
212+
string_arg = Enum.find(action.arguments, &(&1.public? && &1.name == :some_string))
213+
assert string_arg.constraints == [min_length: 1, max_length: 100, trim?: true, allow_empty?: false]
214+
215+
schema = ValibotSchemaGenerator.generate_valibot_schema(OrgTodo, action, "create_org_todo")
216+
217+
assert schema =~ "numberOfEmployees: v.pipe(v.number(), v.integer(), v.minValue(1), v.maxValue(1000))"
218+
assert schema =~ "someString: v.pipe(v.string(), v.minLength(1), v.maxLength(100))"
219+
end
220+
end
221+
end

test/ts/runValibotTests.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,34 @@ runTest("testSafeParseConstraintViolation", () => shouldFail.testSafeParseConstr
6464
runTest("testIntegerFloatingPoint", () => shouldFail.testIntegerFloatingPoint());
6565
runTest("testBoundaryViolations", () => shouldFail.testBoundaryViolations());
6666
runTest("testRequiredFieldMissing", () => shouldFail.testRequiredFieldMissing());
67+
runTest("testInvalidEmailNoAt", () => shouldFail.testInvalidEmailNoAt());
68+
runTest("testInvalidEmailNoDomain", () => shouldFail.testInvalidEmailNoDomain());
69+
runTest("testInvalidPhoneStartsWithZero", () => shouldFail.testInvalidPhoneStartsWithZero());
70+
runTest("testInvalidPhoneTooShort", () => shouldFail.testInvalidPhoneTooShort());
71+
runTest("testInvalidHexColorLength", () => shouldFail.testInvalidHexColorLength());
72+
runTest("testInvalidHexColorNoHash", () => shouldFail.testInvalidHexColorNoHash());
73+
runTest("testInvalidSlugUppercase", () => shouldFail.testInvalidSlugUppercase());
74+
runTest("testInvalidSlugStartsWithHyphen", () => shouldFail.testInvalidSlugStartsWithHyphen());
75+
runTest("testInvalidVersionMissingPatch", () => shouldFail.testInvalidVersionMissingPatch());
76+
runTest("testInvalidVersionWithLetters", () => shouldFail.testInvalidVersionWithLetters());
77+
runTest("testInvalidCodeWrongFormat", () => shouldFail.testInvalidCodeWrongFormat());
78+
runTest("testInvalidOptionalUrlWrongProtocol", () => shouldFail.testInvalidOptionalUrlWrongProtocol());
79+
runTest("testFloatPriceBelowMin", () => shouldFail.testFloatPriceBelowMin());
80+
runTest("testFloatPriceAboveMax", () => shouldFail.testFloatPriceAboveMax());
81+
runTest("testFloatTemperatureAtGtBoundary", () => shouldFail.testFloatTemperatureAtGtBoundary());
82+
runTest("testFloatTemperatureAtLtBoundary", () => shouldFail.testFloatTemperatureAtLtBoundary());
83+
runTest("testFloatPercentageBelowMin", () => shouldFail.testFloatPercentageBelowMin());
84+
runTest("testFloatPercentageAboveMax", () => shouldFail.testFloatPercentageAboveMax());
85+
runTest("testOptionalFloatInvalid", () => shouldFail.testOptionalFloatInvalid());
86+
runTest("testMultipleFloatViolations", () => shouldFail.testMultipleFloatViolations());
87+
runTest("testCiStringUsernameTooShort", () => shouldFail.testCiStringUsernameTooShort());
88+
runTest("testCiStringUsernameTooLong", () => shouldFail.testCiStringUsernameTooLong());
89+
runTest("testCiStringCompanyNameInvalidChars", () => shouldFail.testCiStringCompanyNameInvalidChars());
90+
runTest("testCiStringCompanyNameTooShort", () => shouldFail.testCiStringCompanyNameTooShort());
91+
runTest("testCiStringCountryCodeWrongLength", () => shouldFail.testCiStringCountryCodeWrongLength());
92+
runTest("testCiStringCountryCodeWithNumber", () => shouldFail.testCiStringCountryCodeWithNumber());
93+
runTest("testOptionalCiStringInvalid", () => shouldFail.testOptionalCiStringInvalid());
94+
runTest("testMultipleCiStringViolations", () => shouldFail.testMultipleCiStringViolations());
6795

6896
console.log("\n========================================");
6997
console.log("Test Results Summary");

0 commit comments

Comments
 (0)