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