Auto-indexing service and GraphQL API for AT Protocol Records
quickslice.slices.network/
atproto
gleam
graphql
1/// Regression tests for reverse join field resolution bugs
2///
3/// Tests verify fixes for:
4/// 1. Forward join fields (like itemResolved) available through reverse joins
5/// 2. Integer and object fields resolved correctly (not always converted to strings)
6/// 3. Nested queries work correctly: profile → galleries → items → photos
7import database/repositories/lexicons
8import database/repositories/records
9import gleam/bool
10import gleam/json
11import gleam/option
12import gleam/string
13import gleeunit/should
14import graphql/lexicon/schema as lexicon_schema
15import lib/oauth/did_cache
16import test_helpers
17
18// Helper to create gallery lexicon
19fn create_gallery_lexicon() -> String {
20 json.object([
21 #("lexicon", json.int(1)),
22 #("id", json.string("social.grain.gallery")),
23 #(
24 "defs",
25 json.object([
26 #(
27 "main",
28 json.object([
29 #("type", json.string("record")),
30 #("key", json.string("tid")),
31 #(
32 "record",
33 json.object([
34 #("type", json.string("object")),
35 #(
36 "required",
37 json.array(
38 [json.string("title"), json.string("createdAt")],
39 of: fn(x) { x },
40 ),
41 ),
42 #(
43 "properties",
44 json.object([
45 #(
46 "title",
47 json.object([
48 #("type", json.string("string")),
49 #("maxLength", json.int(100)),
50 ]),
51 ),
52 #(
53 "createdAt",
54 json.object([
55 #("type", json.string("string")),
56 #("format", json.string("datetime")),
57 ]),
58 ),
59 ]),
60 ),
61 ]),
62 ),
63 ]),
64 ),
65 ]),
66 ),
67 ])
68 |> json.to_string
69}
70
71// Helper to create gallery item lexicon with position field (integer)
72fn create_gallery_item_lexicon() -> String {
73 json.object([
74 #("lexicon", json.int(1)),
75 #("id", json.string("social.grain.gallery.item")),
76 #(
77 "defs",
78 json.object([
79 #(
80 "main",
81 json.object([
82 #("type", json.string("record")),
83 #("key", json.string("tid")),
84 #(
85 "record",
86 json.object([
87 #("type", json.string("object")),
88 #(
89 "required",
90 json.array(
91 [
92 json.string("createdAt"),
93 json.string("gallery"),
94 json.string("item"),
95 ],
96 of: fn(x) { x },
97 ),
98 ),
99 #(
100 "properties",
101 json.object([
102 #(
103 "createdAt",
104 json.object([
105 #("type", json.string("string")),
106 #("format", json.string("datetime")),
107 ]),
108 ),
109 #(
110 "gallery",
111 json.object([
112 #("type", json.string("string")),
113 #("format", json.string("at-uri")),
114 ]),
115 ),
116 #(
117 "item",
118 json.object([
119 #("type", json.string("string")),
120 #("format", json.string("at-uri")),
121 ]),
122 ),
123 #(
124 "position",
125 json.object([
126 #("type", json.string("integer")),
127 #("default", json.int(0)),
128 ]),
129 ),
130 ]),
131 ),
132 ]),
133 ),
134 ]),
135 ),
136 ]),
137 ),
138 ])
139 |> json.to_string
140}
141
142// Helper to create photo lexicon
143fn create_photo_lexicon() -> String {
144 json.object([
145 #("lexicon", json.int(1)),
146 #("id", json.string("social.grain.photo")),
147 #(
148 "defs",
149 json.object([
150 #(
151 "main",
152 json.object([
153 #("type", json.string("record")),
154 #("key", json.string("tid")),
155 #(
156 "record",
157 json.object([
158 #("type", json.string("object")),
159 #(
160 "required",
161 json.array([json.string("createdAt")], of: fn(x) { x }),
162 ),
163 #(
164 "properties",
165 json.object([
166 #("alt", json.object([#("type", json.string("string"))])),
167 #(
168 "createdAt",
169 json.object([
170 #("type", json.string("string")),
171 #("format", json.string("datetime")),
172 ]),
173 ),
174 ]),
175 ),
176 ]),
177 ),
178 ]),
179 ),
180 ]),
181 ),
182 ])
183 |> json.to_string
184}
185
186// Helper to create profile lexicon
187fn create_profile_lexicon() -> String {
188 json.object([
189 #("lexicon", json.int(1)),
190 #("id", json.string("social.grain.actor.profile")),
191 #(
192 "defs",
193 json.object([
194 #(
195 "main",
196 json.object([
197 #("type", json.string("record")),
198 #("key", json.string("literal:self")),
199 #(
200 "record",
201 json.object([
202 #("type", json.string("object")),
203 #(
204 "required",
205 json.array(
206 [json.string("displayName"), json.string("createdAt")],
207 of: fn(x) { x },
208 ),
209 ),
210 #(
211 "properties",
212 json.object([
213 #(
214 "displayName",
215 json.object([
216 #("type", json.string("string")),
217 #("maxGraphemes", json.int(64)),
218 ]),
219 ),
220 #(
221 "createdAt",
222 json.object([
223 #("type", json.string("string")),
224 #("format", json.string("datetime")),
225 ]),
226 ),
227 ]),
228 ),
229 ]),
230 ),
231 ]),
232 ),
233 ]),
234 ),
235 ])
236 |> json.to_string
237}
238
239/// Test that forward join fields (itemResolved) are available through reverse joins
240/// This tests the fix for the circular dependency issue in schema building
241pub fn reverse_join_includes_forward_join_fields_test() {
242 let assert Ok(exec) = test_helpers.create_test_db()
243 let assert Ok(_) = test_helpers.create_lexicon_table(exec)
244 let assert Ok(_) = test_helpers.create_record_table(exec)
245 let assert Ok(_) = test_helpers.create_actor_table(exec)
246
247 // Insert lexicons
248 let assert Ok(_) =
249 lexicons.insert(exec, "social.grain.gallery", create_gallery_lexicon())
250 let assert Ok(_) =
251 lexicons.insert(
252 exec,
253 "social.grain.gallery.item",
254 create_gallery_item_lexicon(),
255 )
256 let assert Ok(_) =
257 lexicons.insert(exec, "social.grain.photo", create_photo_lexicon())
258
259 // Create test data
260 let did1 = "did:test:user1"
261 let gallery_uri = "at://" <> did1 <> "/social.grain.gallery/gallery1"
262 let item_uri = "at://" <> did1 <> "/social.grain.gallery.item/item1"
263 let photo_uri = "at://" <> did1 <> "/social.grain.photo/photo1"
264
265 // Insert gallery
266 let gallery_json =
267 json.object([
268 #("title", json.string("Test Gallery")),
269 #("createdAt", json.string("2024-01-01T00:00:00.000Z")),
270 ])
271 |> json.to_string
272 let assert Ok(_) =
273 records.insert(
274 exec,
275 gallery_uri,
276 "cid1",
277 did1,
278 "social.grain.gallery",
279 gallery_json,
280 )
281
282 // Insert photo
283 let photo_json =
284 json.object([
285 #("alt", json.string("A beautiful sunset")),
286 #("createdAt", json.string("2024-01-01T00:00:00.000Z")),
287 ])
288 |> json.to_string
289 let assert Ok(_) =
290 records.insert(
291 exec,
292 photo_uri,
293 "cid2",
294 did1,
295 "social.grain.photo",
296 photo_json,
297 )
298
299 // Insert gallery item linking gallery to photo
300 let item_json =
301 json.object([
302 #("gallery", json.string(gallery_uri)),
303 #("item", json.string(photo_uri)),
304 #("position", json.int(0)),
305 #("createdAt", json.string("2024-01-01T00:00:00.000Z")),
306 ])
307 |> json.to_string
308 let assert Ok(_) =
309 records.insert(
310 exec,
311 item_uri,
312 "cid3",
313 did1,
314 "social.grain.gallery.item",
315 item_json,
316 )
317
318 // Query gallery with reverse join to items, then forward join to photos
319 let query =
320 "{
321 socialGrainGallery {
322 edges {
323 node {
324 title
325 socialGrainGalleryItemViaGallery {
326 edges {
327 node {
328 uri
329 itemResolved {
330 ... on SocialGrainPhoto {
331 uri
332 alt
333 }
334 }
335 }
336 }
337 }
338 }
339 }
340 }
341 }"
342
343 let assert Ok(cache) = did_cache.start()
344 let assert Ok(response_json) =
345 lexicon_schema.execute_query_with_db(
346 exec,
347 query,
348 "{}",
349 Error(Nil),
350 cache,
351 option.None,
352 "",
353 "https://plc.directory",
354 )
355
356 // Verify the response includes the gallery
357 string.contains(response_json, "Test Gallery")
358 |> should.be_true
359
360 // Verify the reverse join worked (gallery item is present)
361 string.contains(response_json, item_uri)
362 |> should.be_true
363
364 // CRITICAL: Verify itemResolved field exists and resolved the photo
365 // This tests the fix for forward join fields being available through reverse joins
366 string.contains(response_json, photo_uri)
367 |> should.be_true
368
369 string.contains(response_json, "A beautiful sunset")
370 |> should.be_true
371}
372
373/// Test that integer fields are correctly resolved (not converted to strings)
374/// This tests the fix for field value type handling
375pub fn integer_field_resolves_correctly_test() {
376 let assert Ok(exec) = test_helpers.create_test_db()
377 let assert Ok(_) = test_helpers.create_lexicon_table(exec)
378 let assert Ok(_) = test_helpers.create_record_table(exec)
379 let assert Ok(_) = test_helpers.create_actor_table(exec)
380
381 let assert Ok(_) =
382 lexicons.insert(
383 exec,
384 "social.grain.gallery.item",
385 create_gallery_item_lexicon(),
386 )
387
388 let did1 = "did:test:user1"
389 let gallery_uri = "at://" <> did1 <> "/social.grain.gallery/gallery1"
390 let item_uri = "at://" <> did1 <> "/social.grain.gallery.item/item1"
391 let photo_uri = "at://" <> did1 <> "/social.grain.photo/photo1"
392
393 // Insert gallery item with position = 42
394 let item_json =
395 json.object([
396 #("gallery", json.string(gallery_uri)),
397 #("item", json.string(photo_uri)),
398 #("position", json.int(42)),
399 #("createdAt", json.string("2024-01-01T00:00:00.000Z")),
400 ])
401 |> json.to_string
402
403 let assert Ok(_) =
404 records.insert(
405 exec,
406 item_uri,
407 "cid1",
408 did1,
409 "social.grain.gallery.item",
410 item_json,
411 )
412
413 let query =
414 "{
415 socialGrainGalleryItem {
416 edges {
417 node {
418 uri
419 position
420 }
421 }
422 }
423 }"
424
425 let assert Ok(cache) = did_cache.start()
426 let assert Ok(response_json) =
427 lexicon_schema.execute_query_with_db(
428 exec,
429 query,
430 "{}",
431 Error(Nil),
432 cache,
433 option.None,
434 "",
435 "https://plc.directory",
436 )
437
438 // Verify position is returned as integer, not string or null
439 { string.contains(response_json, "\"position\":42") }
440 |> bool.or(string.contains(response_json, "\"position\": 42"))
441 |> should.be_true
442
443 // Ensure it's not returned as null
444 { string.contains(response_json, "\"position\":null") }
445 |> bool.or(string.contains(response_json, "\"position\": null"))
446 |> should.be_false
447}
448
449/// Test complete nested query: profile → galleries → items → photos with sorting
450/// This is the actual use case that was failing before the fixes
451pub fn nested_query_profile_to_photos_test() {
452 let assert Ok(exec) = test_helpers.create_test_db()
453 let assert Ok(_) = test_helpers.create_lexicon_table(exec)
454 let assert Ok(_) = test_helpers.create_record_table(exec)
455 let assert Ok(_) = test_helpers.create_actor_table(exec)
456
457 // Insert all lexicons
458 let assert Ok(_) =
459 lexicons.insert(
460 exec,
461 "social.grain.actor.profile",
462 create_profile_lexicon(),
463 )
464 let assert Ok(_) =
465 lexicons.insert(exec, "social.grain.gallery", create_gallery_lexicon())
466 let assert Ok(_) =
467 lexicons.insert(
468 exec,
469 "social.grain.gallery.item",
470 create_gallery_item_lexicon(),
471 )
472 let assert Ok(_) =
473 lexicons.insert(exec, "social.grain.photo", create_photo_lexicon())
474
475 let did1 = "did:test:alice"
476 let profile_uri = "at://" <> did1 <> "/social.grain.actor.profile/self"
477 let gallery_uri = "at://" <> did1 <> "/social.grain.gallery/vacation"
478 let photo1_uri = "at://" <> did1 <> "/social.grain.photo/photo1"
479 let photo2_uri = "at://" <> did1 <> "/social.grain.photo/photo2"
480 let item1_uri = "at://" <> did1 <> "/social.grain.gallery.item/item1"
481 let item2_uri = "at://" <> did1 <> "/social.grain.gallery.item/item2"
482
483 // Insert profile
484 let assert Ok(_) =
485 records.insert(
486 exec,
487 profile_uri,
488 "cid1",
489 did1,
490 "social.grain.actor.profile",
491 "{\"displayName\":\"Alice\",\"createdAt\":\"2024-01-01T00:00:00.000Z\"}",
492 )
493
494 // Insert gallery
495 let assert Ok(_) =
496 records.insert(
497 exec,
498 gallery_uri,
499 "cid2",
500 did1,
501 "social.grain.gallery",
502 "{\"title\":\"Summer Vacation\",\"createdAt\":\"2024-01-01T00:00:00.000Z\"}",
503 )
504
505 // Insert photos
506 let assert Ok(_) =
507 records.insert(
508 exec,
509 photo1_uri,
510 "cid3",
511 did1,
512 "social.grain.photo",
513 "{\"alt\":\"Beach\",\"createdAt\":\"2024-01-02T00:00:00.000Z\"}",
514 )
515 let assert Ok(_) =
516 records.insert(
517 exec,
518 photo2_uri,
519 "cid4",
520 did1,
521 "social.grain.photo",
522 "{\"alt\":\"Mountains\",\"createdAt\":\"2024-01-03T00:00:00.000Z\"}",
523 )
524
525 // Insert gallery items with positions
526 let assert Ok(_) =
527 records.insert(
528 exec,
529 item1_uri,
530 "cid5",
531 did1,
532 "social.grain.gallery.item",
533 "{\"gallery\":\""
534 <> gallery_uri
535 <> "\",\"item\":\""
536 <> photo1_uri
537 <> "\",\"position\":1,\"createdAt\":\"2024-01-01T00:00:00.000Z\"}",
538 )
539 let assert Ok(_) =
540 records.insert(
541 exec,
542 item2_uri,
543 "cid6",
544 did1,
545 "social.grain.gallery.item",
546 "{\"gallery\":\""
547 <> gallery_uri
548 <> "\",\"item\":\""
549 <> photo2_uri
550 <> "\",\"position\":0,\"createdAt\":\"2024-01-01T00:00:00.000Z\"}",
551 )
552
553 // The complete nested query that was failing
554 let query =
555 "{
556 socialGrainActorProfile {
557 edges {
558 node {
559 displayName
560 socialGrainGalleryByDid {
561 edges {
562 node {
563 title
564 socialGrainGalleryItemViaGallery(
565 sortBy: [{ field: \"position\", direction: ASC }]
566 ) {
567 edges {
568 node {
569 position
570 itemResolved {
571 ... on SocialGrainPhoto {
572 uri
573 alt
574 }
575 }
576 }
577 }
578 }
579 }
580 }
581 }
582 }
583 }
584 }
585 }"
586
587 let assert Ok(cache) = did_cache.start()
588 let assert Ok(response_json) =
589 lexicon_schema.execute_query_with_db(
590 exec,
591 query,
592 "{}",
593 Error(Nil),
594 cache,
595 option.None,
596 "",
597 "https://plc.directory",
598 )
599
600 // Verify all levels of nesting work
601 string.contains(response_json, "Alice")
602 |> should.be_true
603
604 string.contains(response_json, "Summer Vacation")
605 |> should.be_true
606
607 // Verify positions are integers
608 { string.contains(response_json, "\"position\":0") }
609 |> bool.or(string.contains(response_json, "\"position\": 0"))
610 |> should.be_true
611
612 { string.contains(response_json, "\"position\":1") }
613 |> bool.or(string.contains(response_json, "\"position\": 1"))
614 |> should.be_true
615
616 // CRITICAL: Verify itemResolved works through the reverse join
617 string.contains(response_json, photo1_uri)
618 |> should.be_true
619
620 string.contains(response_json, photo2_uri)
621 |> should.be_true
622
623 string.contains(response_json, "Beach")
624 |> should.be_true
625
626 string.contains(response_json, "Mountains")
627 |> should.be_true
628}