Auto-indexing service and GraphQL API for AT Protocol Records quickslice.slices.network/
atproto gleam graphql
at main 874 lines 22 kB view raw
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}