Auto-indexing service and GraphQL API for AT Protocol Records
quickslice.slices.network/
atproto
gleam
graphql
1/// Integration tests for GraphQL handler with database
2///
3/// These tests verify the full GraphQL query flow:
4/// 1. Database setup with lexicons and records
5/// 2. GraphQL schema building from database lexicons
6/// 3. Query execution and result formatting
7/// 4. JSON parsing and encoding throughout the pipeline
8import database/repositories/actors
9import database/repositories/lexicons
10import database/repositories/records
11import gleam/http
12import gleam/int
13import gleam/json
14import gleam/list
15import gleam/option.{None}
16import gleam/string
17import gleeunit/should
18import handlers/graphql as graphql_handler
19import lib/oauth/did_cache
20import test_helpers
21import wisp
22import wisp/simulate
23
24// Helper to create a status lexicon
25fn create_status_lexicon() -> String {
26 json.object([
27 #("lexicon", json.int(1)),
28 #("id", json.string("xyz.statusphere.status")),
29 #(
30 "defs",
31 json.object([
32 #(
33 "main",
34 json.object([
35 #("type", json.string("record")),
36 #("key", json.string("tid")),
37 #(
38 "record",
39 json.object([
40 #("type", json.string("object")),
41 #(
42 "required",
43 json.array(
44 [json.string("status"), json.string("createdAt")],
45 of: fn(x) { x },
46 ),
47 ),
48 #(
49 "properties",
50 json.object([
51 #(
52 "status",
53 json.object([
54 #("type", json.string("string")),
55 #("minLength", json.int(1)),
56 #("maxGraphemes", json.int(1)),
57 #("maxLength", json.int(32)),
58 ]),
59 ),
60 #(
61 "createdAt",
62 json.object([
63 #("type", json.string("string")),
64 #("format", json.string("datetime")),
65 ]),
66 ),
67 ]),
68 ),
69 ]),
70 ),
71 ]),
72 ),
73 ]),
74 ),
75 ])
76 |> json.to_string
77}
78
79// Helper to create a simple lexicon with just properties
80fn create_simple_lexicon(nsid: String) -> String {
81 json.object([
82 #("lexicon", json.int(1)),
83 #("id", json.string(nsid)),
84 #(
85 "defs",
86 json.object([
87 #(
88 "main",
89 json.object([
90 #("type", json.string("record")),
91 #(
92 "record",
93 json.object([
94 #(
95 "properties",
96 json.object([
97 #("status", json.object([#("type", json.string("string"))])),
98 ]),
99 ),
100 ]),
101 ),
102 ]),
103 ),
104 ]),
105 ),
106 ])
107 |> json.to_string
108}
109
110pub fn graphql_post_request_with_records_test() {
111 // Create in-memory database
112 let assert Ok(exec) = test_helpers.create_test_db()
113 let assert Ok(_) = test_helpers.create_lexicon_table(exec)
114 let assert Ok(_) = test_helpers.create_record_table(exec)
115
116 // Insert a lexicon for xyz.statusphere.status
117 let lexicon = create_status_lexicon()
118 let assert Ok(_) = lexicons.insert(exec, "xyz.statusphere.status", lexicon)
119
120 // Insert some test records
121 let record1_json =
122 json.object([
123 #("status", json.string("🎉")),
124 #("createdAt", json.string("2024-01-01T00:00:00Z")),
125 ])
126 |> json.to_string
127
128 let assert Ok(_) =
129 records.insert(
130 exec,
131 "at://did:plc:test1/xyz.statusphere.status/123",
132 "cid1",
133 "did:plc:test1",
134 "xyz.statusphere.status",
135 record1_json,
136 )
137
138 let record2_json =
139 json.object([
140 #("status", json.string("🔥")),
141 #("createdAt", json.string("2024-01-02T00:00:00Z")),
142 ])
143 |> json.to_string
144
145 let assert Ok(_) =
146 records.insert(
147 exec,
148 "at://did:plc:test2/xyz.statusphere.status/456",
149 "cid2",
150 "did:plc:test2",
151 "xyz.statusphere.status",
152 record2_json,
153 )
154
155 // Create GraphQL query request with Connection structure
156 let query =
157 json.object([
158 #(
159 "query",
160 json.string(
161 "{ xyzStatusphereStatus { edges { node { uri cid did collection status createdAt } cursor } pageInfo { hasNextPage hasPreviousPage startCursor endCursor } } }",
162 ),
163 ),
164 ])
165 |> json.to_string
166
167 let request =
168 simulate.request(http.Post, "/graphql")
169 |> simulate.string_body(query)
170 |> simulate.header("content-type", "application/json")
171
172 let assert Ok(cache) = did_cache.start()
173 let response =
174 graphql_handler.handle_graphql_request(
175 request,
176 exec,
177 cache,
178 None,
179 "",
180 "https://plc.directory",
181 )
182
183 // Verify response
184 response.status
185 |> should.equal(200)
186
187 // Get response body
188 let assert wisp.Text(body) = response.body
189
190 // Verify response contains data structure
191 body
192 |> should.not_equal("")
193
194 // Response should contain "data"
195 string.contains(body, "data")
196 |> should.be_true
197
198 // Response should contain field name
199 string.contains(body, "xyzStatusphereStatus")
200 |> should.be_true
201
202 // Response should contain our test URIs
203 string.contains(body, "at://did:plc:test1/xyz.statusphere.status/123")
204 |> should.be_true
205
206 string.contains(body, "at://did:plc:test2/xyz.statusphere.status/456")
207 |> should.be_true
208
209 // Response should contain our test data
210 string.contains(body, "🎉")
211 |> should.be_true
212
213 string.contains(body, "🔥")
214 |> should.be_true
215 // Clean up handled automatically
216}
217
218pub fn graphql_post_request_empty_results_test() {
219 // Create in-memory database
220 let assert Ok(exec) = test_helpers.create_test_db()
221 let assert Ok(_) = test_helpers.create_lexicon_table(exec)
222 let assert Ok(_) = test_helpers.create_record_table(exec)
223
224 // Insert a lexicon but no records
225 let lexicon = create_simple_lexicon("xyz.statusphere.status")
226 let assert Ok(_) = lexicons.insert(exec, "xyz.statusphere.status", lexicon)
227
228 // Create GraphQL query request with Connection structure
229 let query =
230 json.object([
231 #(
232 "query",
233 json.string(
234 "{ xyzStatusphereStatus { edges { node { uri } } pageInfo { hasNextPage } } }",
235 ),
236 ),
237 ])
238 |> json.to_string
239
240 let request =
241 simulate.request(http.Post, "/graphql")
242 |> simulate.string_body(query)
243 |> simulate.header("content-type", "application/json")
244
245 let assert Ok(cache) = did_cache.start()
246 let response =
247 graphql_handler.handle_graphql_request(
248 request,
249 exec,
250 cache,
251 None,
252 "",
253 "https://plc.directory",
254 )
255
256 // Verify response
257 response.status
258 |> should.equal(200)
259
260 // Get response body
261 let assert wisp.Text(body) = response.body
262
263 // Should return empty array
264 string.contains(body, "[]")
265 |> should.be_true
266 // Clean up handled automatically
267}
268
269pub fn graphql_get_request_test() {
270 // Create in-memory database
271 let assert Ok(exec) = test_helpers.create_test_db()
272 let assert Ok(_) = test_helpers.create_lexicon_table(exec)
273 let assert Ok(_) = test_helpers.create_record_table(exec)
274
275 // Insert a lexicon
276 let lexicon = create_simple_lexicon("xyz.statusphere.status")
277 let assert Ok(_) = lexicons.insert(exec, "xyz.statusphere.status", lexicon)
278
279 // Create GraphQL GET request with query parameter
280 let request =
281 simulate.request(
282 http.Get,
283 "/graphql?query={ xyzStatusphereStatus { edges { node { uri } } } }",
284 )
285
286 let assert Ok(cache) = did_cache.start()
287 let response =
288 graphql_handler.handle_graphql_request(
289 request,
290 exec,
291 cache,
292 None,
293 "",
294 "https://plc.directory",
295 )
296
297 // Verify response
298 response.status
299 |> should.equal(200)
300
301 // Get response body
302 let assert wisp.Text(body) = response.body
303
304 // Should contain data
305 string.contains(body, "data")
306 |> should.be_true
307 // Clean up handled automatically
308}
309
310pub fn graphql_invalid_json_request_test() {
311 // Create in-memory database
312 let assert Ok(exec) = test_helpers.create_test_db()
313 let assert Ok(_) = test_helpers.create_lexicon_table(exec)
314
315 // Create request with invalid JSON
316 let request =
317 simulate.request(http.Post, "/graphql")
318 |> simulate.string_body("not valid json")
319 |> simulate.header("content-type", "application/json")
320
321 let assert Ok(cache) = did_cache.start()
322 let response =
323 graphql_handler.handle_graphql_request(
324 request,
325 exec,
326 cache,
327 None,
328 "",
329 "https://plc.directory",
330 )
331
332 // Should return 400 Bad Request
333 response.status
334 |> should.equal(400)
335
336 // Get response body
337 let assert wisp.Text(body) = response.body
338
339 // Should contain error
340 string.contains(body, "error")
341 |> should.be_true
342 // Clean up handled automatically
343}
344
345pub fn graphql_missing_query_field_test() {
346 // Create in-memory database
347 let assert Ok(exec) = test_helpers.create_test_db()
348 let assert Ok(_) = test_helpers.create_lexicon_table(exec)
349
350 // Create request with JSON but no query field
351 let body_json =
352 json.object([#("foo", json.string("bar"))])
353 |> json.to_string
354
355 let request =
356 simulate.request(http.Post, "/graphql")
357 |> simulate.string_body(body_json)
358 |> simulate.header("content-type", "application/json")
359
360 let assert Ok(cache) = did_cache.start()
361 let response =
362 graphql_handler.handle_graphql_request(
363 request,
364 exec,
365 cache,
366 None,
367 "",
368 "https://plc.directory",
369 )
370
371 // Should return 400 Bad Request
372 response.status
373 |> should.equal(400)
374
375 // Get response body
376 let assert wisp.Text(body) = response.body
377
378 // Should contain error about missing query
379 string.contains(body, "query")
380 |> should.be_true
381 // Clean up handled automatically
382}
383
384pub fn graphql_method_not_allowed_test() {
385 // Create in-memory database
386 let assert Ok(exec) = test_helpers.create_test_db()
387
388 // Create DELETE request (not allowed)
389 let request = simulate.request(http.Delete, "/graphql")
390
391 let assert Ok(cache) = did_cache.start()
392 let response =
393 graphql_handler.handle_graphql_request(
394 request,
395 exec,
396 cache,
397 None,
398 "",
399 "https://plc.directory",
400 )
401
402 // Should return 405 Method Not Allowed
403 response.status
404 |> should.equal(405)
405
406 // Get response body
407 let assert wisp.Text(body) = response.body
408
409 // Should contain error
410 string.contains(body, "MethodNotAllowed")
411 |> should.be_true
412 // Clean up handled automatically
413}
414
415pub fn graphql_multiple_lexicons_test() {
416 // Create in-memory database
417 let assert Ok(exec) = test_helpers.create_test_db()
418 let assert Ok(_) = test_helpers.create_lexicon_table(exec)
419 let assert Ok(_) = test_helpers.create_record_table(exec)
420
421 // Insert multiple lexicons
422 let lexicon1 = create_simple_lexicon("xyz.statusphere.status")
423 let lexicon2 =
424 json.object([
425 #("lexicon", json.int(1)),
426 #("id", json.string("app.bsky.feed.post")),
427 #(
428 "defs",
429 json.object([
430 #(
431 "main",
432 json.object([
433 #("type", json.string("record")),
434 #(
435 "record",
436 json.object([
437 #(
438 "properties",
439 json.object([
440 #("text", json.object([#("type", json.string("string"))])),
441 #(
442 "createdAt",
443 json.object([#("type", json.string("string"))]),
444 ),
445 ]),
446 ),
447 ]),
448 ),
449 ]),
450 ),
451 ]),
452 ),
453 ])
454 |> json.to_string
455
456 let assert Ok(_) = lexicons.insert(exec, "xyz.statusphere.status", lexicon1)
457 let assert Ok(_) = lexicons.insert(exec, "app.bsky.feed.post", lexicon2)
458
459 // Insert records for first collection
460 let record1_json =
461 json.object([#("status", json.string("✨"))])
462 |> json.to_string
463
464 let assert Ok(_) =
465 records.insert(
466 exec,
467 "at://did:plc:test/xyz.statusphere.status/1",
468 "cid1",
469 "did:plc:test",
470 "xyz.statusphere.status",
471 record1_json,
472 )
473
474 // Query the first collection
475 let query1 =
476 json.object([
477 #(
478 "query",
479 json.string(
480 "{ xyzStatusphereStatus { edges { node { uri } } pageInfo { hasNextPage } } }",
481 ),
482 ),
483 ])
484 |> json.to_string
485 let request1 =
486 simulate.request(http.Post, "/graphql")
487 |> simulate.string_body(query1)
488 |> simulate.header("content-type", "application/json")
489
490 let assert Ok(cache1) = did_cache.start()
491 let response1 =
492 graphql_handler.handle_graphql_request(
493 request1,
494 exec,
495 cache1,
496 None,
497 "",
498 "https://plc.directory",
499 )
500
501 response1.status
502 |> should.equal(200)
503
504 let assert wisp.Text(body1) = response1.body
505
506 string.contains(body1, "xyzStatusphereStatus")
507 |> should.be_true
508
509 // Insert records for second collection
510 let record2_json =
511 json.object([
512 #("text", json.string("Hello World")),
513 #("createdAt", json.string("2024-01-01T00:00:00Z")),
514 ])
515 |> json.to_string
516
517 let assert Ok(_) =
518 records.insert(
519 exec,
520 "at://did:plc:test/app.bsky.feed.post/1",
521 "cid2",
522 "did:plc:test",
523 "app.bsky.feed.post",
524 record2_json,
525 )
526
527 // Query the second collection
528 let query2 =
529 json.object([
530 #(
531 "query",
532 json.string(
533 "{ appBskyFeedPost { edges { node { uri } } pageInfo { hasNextPage } } }",
534 ),
535 ),
536 ])
537 |> json.to_string
538 let request2 =
539 simulate.request(http.Post, "/graphql")
540 |> simulate.string_body(query2)
541 |> simulate.header("content-type", "application/json")
542
543 let assert Ok(cache2) = did_cache.start()
544 let response2 =
545 graphql_handler.handle_graphql_request(
546 request2,
547 exec,
548 cache2,
549 None,
550 "",
551 "https://plc.directory",
552 )
553
554 response2.status
555 |> should.equal(200)
556
557 let assert wisp.Text(body2) = response2.body
558
559 string.contains(body2, "appBskyFeedPost")
560 |> should.be_true
561 // Clean up handled automatically
562}
563
564pub fn graphql_record_limit_test() {
565 // Create in-memory database
566 let assert Ok(exec) = test_helpers.create_test_db()
567 let assert Ok(_) = test_helpers.create_lexicon_table(exec)
568 let assert Ok(_) = test_helpers.create_record_table(exec)
569
570 // Insert a lexicon
571 let lexicon = create_simple_lexicon("xyz.statusphere.status")
572 let assert Ok(_) = lexicons.insert(exec, "xyz.statusphere.status", lexicon)
573
574 // Insert 150 records (handler should limit to 100)
575 let _ =
576 list_range(1, 150)
577 |> list.each(fn(i) {
578 let uri = "at://did:plc:test/xyz.statusphere.status/" <> int.to_string(i)
579 let cid = "cid" <> int.to_string(i)
580 let json_data =
581 json.object([#("status", json.string(int.to_string(i)))])
582 |> json.to_string
583 let assert Ok(_) =
584 records.insert(
585 exec,
586 uri,
587 cid,
588 "did:plc:test",
589 "xyz.statusphere.status",
590 json_data,
591 )
592 Nil
593 })
594
595 // Query all records with Connection structure
596 let query =
597 json.object([
598 #(
599 "query",
600 json.string(
601 "{ xyzStatusphereStatus { edges { node { uri } } pageInfo { hasNextPage } } }",
602 ),
603 ),
604 ])
605 |> json.to_string
606 let request =
607 simulate.request(http.Post, "/graphql")
608 |> simulate.string_body(query)
609 |> simulate.header("content-type", "application/json")
610
611 let assert Ok(cache) = did_cache.start()
612 let response =
613 graphql_handler.handle_graphql_request(
614 request,
615 exec,
616 cache,
617 None,
618 "",
619 "https://plc.directory",
620 )
621
622 response.status
623 |> should.equal(200)
624
625 let assert wisp.Text(body) = response.body
626
627 // Count how many URIs are in the response
628 // With default pagination (50 items), we should get 50 records
629 let uri_count = count_occurrences(body, "\"uri\"")
630
631 // Should return 50 records (the default page size)
632 uri_count
633 |> should.equal(50)
634 // Clean up handled automatically
635}
636
637// Helper function to create a range of integers
638fn list_range(from: Int, to: Int) -> List(Int) {
639 list_range_helper(from, to, [])
640 |> list.reverse
641}
642
643fn list_range_helper(current: Int, to: Int, acc: List(Int)) -> List(Int) {
644 case current > to {
645 True -> acc
646 False -> list_range_helper(current + 1, to, [current, ..acc])
647 }
648}
649
650// Helper to count occurrences of a substring
651fn count_occurrences(text: String, pattern: String) -> Int {
652 string.split(text, pattern)
653 |> list.length
654 |> fn(n) { n - 1 }
655}
656
657pub fn graphql_actor_handle_lookup_test() {
658 // Create in-memory database
659 let assert Ok(exec) = test_helpers.create_test_db()
660 let assert Ok(_) = test_helpers.create_lexicon_table(exec)
661 let assert Ok(_) = test_helpers.create_record_table(exec)
662 let assert Ok(_) = test_helpers.create_actor_table(exec)
663
664 // Insert a lexicon for xyz.statusphere.status
665 let lexicon = create_status_lexicon()
666 let assert Ok(_) = lexicons.insert(exec, "xyz.statusphere.status", lexicon)
667
668 // Insert test actors
669 let assert Ok(_) = actors.upsert(exec, "did:plc:alice", "alice.bsky.social")
670 let assert Ok(_) = actors.upsert(exec, "did:plc:bob", "bob.bsky.social")
671
672 // Insert test records with those DIDs
673 let record1_json =
674 json.object([
675 #("status", json.string("👍")),
676 #("createdAt", json.string("2024-01-01T00:00:00Z")),
677 ])
678 |> json.to_string
679
680 let assert Ok(_) =
681 records.insert(
682 exec,
683 "at://did:plc:alice/xyz.statusphere.status/123",
684 "cid1",
685 "did:plc:alice",
686 "xyz.statusphere.status",
687 record1_json,
688 )
689
690 let record2_json =
691 json.object([
692 #("status", json.string("🔥")),
693 #("createdAt", json.string("2024-01-02T00:00:00Z")),
694 ])
695 |> json.to_string
696
697 let assert Ok(_) =
698 records.insert(
699 exec,
700 "at://did:plc:bob/xyz.statusphere.status/456",
701 "cid2",
702 "did:plc:bob",
703 "xyz.statusphere.status",
704 record2_json,
705 )
706
707 // Query with actorHandle field
708 let query =
709 json.object([
710 #(
711 "query",
712 json.string(
713 "{ xyzStatusphereStatus(where: {status: {contains: \"👍\"}}, sortBy: [{direction: DESC, field: createdAt}]) { edges { node { actorHandle did status createdAt } cursor } pageInfo { hasNextPage } } }",
714 ),
715 ),
716 ])
717 |> json.to_string
718
719 let request =
720 simulate.request(http.Post, "/graphql")
721 |> simulate.string_body(query)
722 |> simulate.header("content-type", "application/json")
723
724 let assert Ok(cache) = did_cache.start()
725 let response =
726 graphql_handler.handle_graphql_request(
727 request,
728 exec,
729 cache,
730 None,
731 "",
732 "https://plc.directory",
733 )
734
735 // Verify response
736 response.status
737 |> should.equal(200)
738
739 let assert wisp.Text(body) = response.body
740
741 // Should contain the actor handle
742 string.contains(body, "alice.bsky.social")
743 |> should.be_true
744
745 // Should contain the record data
746 string.contains(body, "did:plc:alice")
747 |> should.be_true
748
749 string.contains(body, "👍")
750 |> should.be_true
751 // Clean up handled automatically
752}
753
754pub fn graphql_filter_by_actor_handle_test() {
755 // Create in-memory database
756 let assert Ok(exec) = test_helpers.create_test_db()
757 let assert Ok(_) = test_helpers.create_lexicon_table(exec)
758 let assert Ok(_) = test_helpers.create_record_table(exec)
759 let assert Ok(_) = test_helpers.create_actor_table(exec)
760
761 // Insert a lexicon for xyz.statusphere.status
762 let lexicon = create_status_lexicon()
763 let assert Ok(_) = lexicons.insert(exec, "xyz.statusphere.status", lexicon)
764
765 // Insert test actors
766 let assert Ok(_) = actors.upsert(exec, "did:plc:alice", "alice.bsky.social")
767 let assert Ok(_) = actors.upsert(exec, "did:plc:bob", "bob.bsky.social")
768 let assert Ok(_) =
769 actors.upsert(exec, "did:plc:charlie", "charlie.bsky.social")
770
771 // Insert test records with those DIDs
772 let record1_json =
773 json.object([
774 #("status", json.string("👍")),
775 #("createdAt", json.string("2024-01-01T00:00:00Z")),
776 ])
777 |> json.to_string
778
779 let assert Ok(_) =
780 records.insert(
781 exec,
782 "at://did:plc:alice/xyz.statusphere.status/123",
783 "cid1",
784 "did:plc:alice",
785 "xyz.statusphere.status",
786 record1_json,
787 )
788
789 let record2_json =
790 json.object([
791 #("status", json.string("🔥")),
792 #("createdAt", json.string("2024-01-02T00:00:00Z")),
793 ])
794 |> json.to_string
795
796 let assert Ok(_) =
797 records.insert(
798 exec,
799 "at://did:plc:bob/xyz.statusphere.status/456",
800 "cid2",
801 "did:plc:bob",
802 "xyz.statusphere.status",
803 record2_json,
804 )
805
806 let record3_json =
807 json.object([
808 #("status", json.string("⭐")),
809 #("createdAt", json.string("2024-01-03T00:00:00Z")),
810 ])
811 |> json.to_string
812
813 let assert Ok(_) =
814 records.insert(
815 exec,
816 "at://did:plc:charlie/xyz.statusphere.status/789",
817 "cid3",
818 "did:plc:charlie",
819 "xyz.statusphere.status",
820 record3_json,
821 )
822
823 // Query filtering by actorHandle
824 let query =
825 json.object([
826 #(
827 "query",
828 json.string(
829 "{ xyzStatusphereStatus(where: {actorHandle: {eq: \"alice.bsky.social\"}}) { edges { node { actorHandle did status } } } }",
830 ),
831 ),
832 ])
833 |> json.to_string
834
835 let request =
836 simulate.request(http.Post, "/graphql")
837 |> simulate.string_body(query)
838 |> simulate.header("content-type", "application/json")
839
840 let assert Ok(cache) = did_cache.start()
841 let response =
842 graphql_handler.handle_graphql_request(
843 request,
844 exec,
845 cache,
846 None,
847 "",
848 "https://plc.directory",
849 )
850
851 // Verify response
852 response.status
853 |> should.equal(200)
854
855 let assert wisp.Text(body) = response.body
856
857 // Should contain alice's handle and record
858 string.contains(body, "alice.bsky.social")
859 |> should.be_true
860
861 string.contains(body, "did:plc:alice")
862 |> should.be_true
863
864 string.contains(body, "👍")
865 |> should.be_true
866
867 // Should NOT contain bob or charlie's records
868 string.contains(body, "bob.bsky.social")
869 |> should.be_false
870
871 string.contains(body, "charlie.bsky.social")
872 |> should.be_false
873 // Clean up handled automatically
874}