Skip to content

Commit dac35aa

Browse files
committed
add regression test
1 parent f9d4b68 commit dac35aa

File tree

1 file changed

+300
-0
lines changed

1 file changed

+300
-0
lines changed
Lines changed: 300 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,300 @@
1+
defmodule Test.Acceptance.RecursiveEmbeddedInputRegressionTest do
2+
use ExUnit.Case, async: true
3+
4+
alias OpenApiSpex.{OpenApi, Operation}
5+
6+
# Embedded resource with self-reference for testing recursive input schema generation
7+
defmodule Comment do
8+
use Ash.Resource,
9+
data_layer: :embedded,
10+
extensions: [AshJsonApi.Resource]
11+
12+
json_api do
13+
type("comment")
14+
end
15+
16+
attributes do
17+
attribute(:id, :uuid, public?: true, primary_key?: true, allow_nil?: false, default: &Ash.UUID.generate/0)
18+
attribute(:content, :string, public?: true, allow_nil?: false)
19+
attribute(:parent_comment, :struct, constraints: [instance_of: __MODULE__], public?: true)
20+
attribute(:replies, {:array, :struct}, constraints: [items: [instance_of: __MODULE__]], public?: true, default: [])
21+
end
22+
23+
actions do
24+
default_accept([:content, :parent_comment, :replies])
25+
defaults([:create, :update])
26+
end
27+
end
28+
29+
# Embedded resource with multiple self-references for deeper testing
30+
defmodule TreeNode do
31+
use Ash.Resource,
32+
data_layer: :embedded,
33+
extensions: [AshJsonApi.Resource]
34+
35+
json_api do
36+
type("tree_node")
37+
end
38+
39+
attributes do
40+
attribute(:id, :uuid, public?: true, primary_key?: true, allow_nil?: false, default: &Ash.UUID.generate/0)
41+
attribute(:name, :string, public?: true, allow_nil?: false)
42+
attribute(:left_child, :struct, constraints: [instance_of: __MODULE__], public?: true)
43+
attribute(:right_child, :struct, constraints: [instance_of: __MODULE__], public?: true)
44+
attribute(:children, {:array, :struct}, constraints: [items: [instance_of: __MODULE__]], public?: true, default: [])
45+
end
46+
47+
actions do
48+
default_accept([:name, :left_child, :right_child, :children])
49+
defaults([:create, :update])
50+
end
51+
end
52+
53+
# Main resource for testing create/update operations with recursive embedded inputs
54+
defmodule BlogPost do
55+
use Ash.Resource,
56+
domain: Test.Acceptance.RecursiveEmbeddedInputRegressionTest.Blog,
57+
data_layer: Ash.DataLayer.Ets,
58+
extensions: [AshJsonApi.Resource]
59+
60+
ets do
61+
private?(true)
62+
end
63+
64+
json_api do
65+
type("blog_post")
66+
67+
routes do
68+
base("/blog_posts")
69+
get(:read)
70+
index(:read)
71+
post(:create)
72+
patch(:update)
73+
end
74+
end
75+
76+
attributes do
77+
uuid_primary_key(:id, writable?: true)
78+
attribute(:title, :string, public?: true, allow_nil?: false)
79+
attribute(:main_comment, Comment, public?: true)
80+
attribute(:comment_tree, TreeNode, public?: true)
81+
attribute(:all_comments, {:array, Comment}, public?: true, default: [])
82+
end
83+
84+
actions do
85+
default_accept([:title, :main_comment, :comment_tree, :all_comments])
86+
defaults([:read, :destroy])
87+
88+
create :create do
89+
primary?(true)
90+
accept([:title, :main_comment, :comment_tree, :all_comments])
91+
end
92+
93+
update :update do
94+
primary?(true)
95+
accept([:title, :main_comment, :comment_tree, :all_comments])
96+
require_atomic?(false)
97+
end
98+
end
99+
end
100+
101+
defmodule Blog do
102+
use Ash.Domain,
103+
otp_app: :ash_json_api,
104+
extensions: [AshJsonApi.Domain]
105+
106+
json_api do
107+
log_errors?(false)
108+
end
109+
110+
resources do
111+
resource(BlogPost)
112+
end
113+
end
114+
115+
describe "recursive embedded input types regression test" do
116+
test "spec generation completes without stack overflow or infinite loops" do
117+
# This is the core regression test - before the fix, this would cause:
118+
# 1. Stack overflow from infinite recursion
119+
# 2. Infinite loop that would hang the test
120+
# 3. Out of memory errors
121+
122+
start_time = System.monotonic_time(:millisecond)
123+
124+
# Generate spec - this should complete successfully
125+
assert %OpenApi{} = AshJsonApi.OpenApi.spec(domain: [Blog])
126+
127+
end_time = System.monotonic_time(:millisecond)
128+
duration = end_time - start_time
129+
130+
# Should complete in reasonable time (generous limit for CI environments)
131+
assert duration < 30_000, "Spec generation took #{duration}ms, indicating possible infinite recursion"
132+
end
133+
134+
test "recursive embedded inputs use $ref references instead of inline expansion" do
135+
spec = AshJsonApi.OpenApi.spec(domain: [Blog])
136+
137+
# Check create operation
138+
create_operation = spec.paths["/blog_posts"].post
139+
assert %Operation{} = create_operation
140+
141+
# Verify request body schema exists
142+
request_body = create_operation.requestBody
143+
assert request_body != nil
144+
145+
schema = request_body.content["application/vnd.api+json"].schema
146+
attributes = schema.properties.data.properties.attributes
147+
148+
# Test main_comment attribute - should use $ref to prevent infinite expansion
149+
main_comment_prop = attributes.properties.main_comment
150+
assert Map.has_key?(main_comment_prop, "anyOf")
151+
152+
# Should contain a $ref to an input schema (even if that schema has issues)
153+
ref_found =
154+
main_comment_prop["anyOf"]
155+
|> Enum.any?(fn item ->
156+
is_map(item) && Map.has_key?(item, "$ref") &&
157+
String.contains?(item["$ref"], "comment-input-create")
158+
end)
159+
160+
assert ref_found, "Expected $ref to comment-input-create in main_comment property"
161+
162+
# Test all_comments array attribute
163+
all_comments_prop = attributes.properties.all_comments
164+
assert Map.has_key?(all_comments_prop, "anyOf")
165+
166+
# Should contain an array that uses $ref for items
167+
array_with_ref =
168+
all_comments_prop["anyOf"]
169+
|> Enum.find(fn item ->
170+
match?(%OpenApiSpex.Schema{type: :array}, item) &&
171+
is_map(item.items) &&
172+
Map.has_key?(item.items, "$ref") &&
173+
String.contains?(item.items["$ref"], "comment-input-create")
174+
end)
175+
176+
assert array_with_ref != nil, "Expected array with $ref items in all_comments property"
177+
178+
# Test comment_tree with TreeNode
179+
comment_tree_prop = attributes.properties.comment_tree
180+
assert Map.has_key?(comment_tree_prop, "anyOf")
181+
182+
tree_ref_found =
183+
comment_tree_prop["anyOf"]
184+
|> Enum.any?(fn item ->
185+
is_map(item) && Map.has_key?(item, "$ref") &&
186+
String.contains?(item["$ref"], "tree_node-input-create")
187+
end)
188+
189+
assert tree_ref_found, "Expected $ref to tree_node-input-create in comment_tree property"
190+
end
191+
192+
test "update operations also use $ref references for recursive inputs" do
193+
spec = AshJsonApi.OpenApi.spec(domain: [Blog])
194+
195+
# Check update operation
196+
update_operation = spec.paths["/blog_posts/{id}"].patch
197+
assert %Operation{} = update_operation
198+
199+
request_body = update_operation.requestBody
200+
assert request_body != nil
201+
202+
schema = request_body.content["application/vnd.api+json"].schema
203+
attributes = schema.properties.data.properties.attributes
204+
205+
# Test that update operations use different input schema names
206+
main_comment_prop = attributes.properties.main_comment
207+
assert Map.has_key?(main_comment_prop, "anyOf")
208+
209+
update_ref_found =
210+
main_comment_prop["anyOf"]
211+
|> Enum.any?(fn item ->
212+
is_map(item) && Map.has_key?(item, "$ref") &&
213+
String.contains?(item["$ref"], "comment-input-update")
214+
end)
215+
216+
assert update_ref_found, "Expected $ref to comment-input-update in update operation"
217+
end
218+
219+
test "multiple levels of recursive nesting are handled" do
220+
spec = AshJsonApi.OpenApi.spec(domain: [Blog])
221+
222+
create_operation = spec.paths["/blog_posts"].post
223+
schema = create_operation.requestBody.content["application/vnd.api+json"].schema
224+
attributes = schema.properties.data.properties.attributes
225+
226+
# TreeNode has multiple recursive references (left_child, right_child, children)
227+
comment_tree_prop = attributes.properties.comment_tree
228+
229+
# Should use $ref instead of deeply nested inline schemas
230+
tree_ref_found =
231+
comment_tree_prop["anyOf"]
232+
|> Enum.any?(fn item ->
233+
is_map(item) && Map.has_key?(item, "$ref") &&
234+
String.contains?(item["$ref"], "tree_node-input")
235+
end)
236+
237+
assert tree_ref_found, "Expected TreeNode recursive references to use $ref"
238+
end
239+
240+
test "logs appropriate warnings when recursive embedded inputs are detected" do
241+
import ExUnit.CaptureLog
242+
243+
log = capture_log(fn ->
244+
AshJsonApi.OpenApi.spec(domain: [Blog])
245+
end)
246+
247+
# Should log warnings about recursive types being detected
248+
assert log =~ "Detected recursive embedded input type"
249+
assert log =~ "Comment"
250+
assert log =~ "TreeNode"
251+
assert log =~ "action: create"
252+
assert log =~ "action: update"
253+
end
254+
255+
test "generated spec is still a valid OpenAPI specification" do
256+
spec = AshJsonApi.OpenApi.spec(domain: [Blog])
257+
258+
# Basic validation that the spec structure is correct
259+
assert %OpenApi{} = spec
260+
assert is_map(spec.paths)
261+
assert is_map(spec.components.schemas)
262+
assert spec.info.title != nil
263+
assert spec.info.version != nil
264+
265+
# Verify we have the expected operations
266+
assert Map.has_key?(spec.paths, "/blog_posts")
267+
assert Map.has_key?(spec.paths, "/blog_posts/{id}")
268+
269+
# Verify basic resource schemas exist
270+
assert Map.has_key?(spec.components.schemas, "blog_post")
271+
assert Map.has_key?(spec.components.schemas, "comment") # read schema
272+
assert Map.has_key?(spec.components.schemas, "tree_node") # read schema
273+
end
274+
275+
test "performance regression - multiple spec generations complete quickly" do
276+
# Test that repeated spec generation doesn't degrade performance
277+
# (which could indicate memory leaks or accumulating state)
278+
279+
times = for _ <- 1..5 do
280+
start_time = System.monotonic_time(:millisecond)
281+
AshJsonApi.OpenApi.spec(domain: [Blog])
282+
end_time = System.monotonic_time(:millisecond)
283+
end_time - start_time
284+
end
285+
286+
# Each generation should complete reasonably quickly
287+
Enum.each(times, fn time ->
288+
assert time < 10_000, "Spec generation took #{time}ms, which may indicate performance regression"
289+
end)
290+
291+
# Later generations shouldn't be significantly slower than earlier ones
292+
avg_first_half = Enum.take(times, 2) |> Enum.sum() |> div(2)
293+
avg_last_half = Enum.drop(times, 3) |> Enum.sum() |> div(2)
294+
295+
# Allow some variance but not dramatic slowdown
296+
assert avg_last_half <= avg_first_half * 3,
297+
"Performance degraded significantly: first half #{avg_first_half}ms, last half #{avg_last_half}ms"
298+
end
299+
end
300+
end

0 commit comments

Comments
 (0)