Auto-indexing service and GraphQL API for AT Protocol Records
at main 838 lines 22 kB view raw
1/// Integration tests for sortBy and where on nested join connections 2/// 3/// Tests verify that: 4/// - sortBy works on nested DID joins and reverse joins 5/// - where filters work on nested joins 6/// - totalCount reflects filtered results 7/// - Combination of sortBy + where works correctly 8import database/repositories/actors 9import database/repositories/lexicons 10import database/repositories/records 11import gleam/int 12import gleam/json 13import gleam/list 14import gleam/option 15import gleam/string 16import gleeunit/should 17import graphql/lexicon/schema as lexicon_schema 18import lib/oauth/did_cache 19import test_helpers 20 21// Helper to create a status lexicon with createdAt field 22fn create_status_lexicon() -> String { 23 json.object([ 24 #("lexicon", json.int(1)), 25 #("id", json.string("xyz.statusphere.status")), 26 #( 27 "defs", 28 json.object([ 29 #( 30 "main", 31 json.object([ 32 #("type", json.string("record")), 33 #("key", json.string("tid")), 34 #( 35 "record", 36 json.object([ 37 #("type", json.string("object")), 38 #( 39 "required", 40 json.array([json.string("status")], of: fn(x) { x }), 41 ), 42 #( 43 "properties", 44 json.object([ 45 #( 46 "status", 47 json.object([ 48 #("type", json.string("string")), 49 #("maxLength", json.int(300)), 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 a profile lexicon 72fn create_profile_lexicon() -> String { 73 json.object([ 74 #("lexicon", json.int(1)), 75 #("id", json.string("app.bsky.actor.profile")), 76 #( 77 "defs", 78 json.object([ 79 #( 80 "main", 81 json.object([ 82 #("type", json.string("record")), 83 #("key", json.string("literal:self")), 84 #( 85 "record", 86 json.object([ 87 #("type", json.string("object")), 88 #( 89 "properties", 90 json.object([ 91 #( 92 "displayName", 93 json.object([#("type", json.string("string"))]), 94 ), 95 ]), 96 ), 97 ]), 98 ), 99 ]), 100 ), 101 ]), 102 ), 103 ]) 104 |> json.to_string 105} 106 107// Test: DID join with sortBy on createdAt DESC 108pub fn did_join_sortby_createdat_desc_test() { 109 // Setup database 110 let assert Ok(exec) = test_helpers.create_test_db() 111 let assert Ok(_) = test_helpers.create_lexicon_table(exec) 112 let assert Ok(_) = test_helpers.create_record_table(exec) 113 let assert Ok(_) = test_helpers.create_actor_table(exec) 114 115 // Insert lexicons 116 let status_lexicon = create_status_lexicon() 117 let profile_lexicon = create_profile_lexicon() 118 let assert Ok(_) = 119 lexicons.insert(exec, "xyz.statusphere.status", status_lexicon) 120 let assert Ok(_) = 121 lexicons.insert(exec, "app.bsky.actor.profile", profile_lexicon) 122 123 // Insert a profile 124 let profile_uri = "at://did:plc:user1/app.bsky.actor.profile/self" 125 let profile_json = 126 json.object([#("displayName", json.string("User One"))]) 127 |> json.to_string 128 129 let assert Ok(_) = 130 records.insert( 131 exec, 132 profile_uri, 133 "cid_profile", 134 "did:plc:user1", 135 "app.bsky.actor.profile", 136 profile_json, 137 ) 138 139 // Insert 5 statuses with DIFFERENT createdAt timestamps 140 // Status 1: 2024-01-01 (oldest) 141 // Status 2: 2024-01-02 142 // Status 3: 2024-01-03 143 // Status 4: 2024-01-04 144 // Status 5: 2024-01-05 (newest) 145 list.range(1, 5) 146 |> list.each(fn(i) { 147 let status_uri = 148 "at://did:plc:user1/xyz.statusphere.status/status" <> int.to_string(i) 149 let status_json = 150 json.object([ 151 #("status", json.string("Status #" <> int.to_string(i))), 152 #( 153 "createdAt", 154 json.string("2024-01-0" <> int.to_string(i) <> "T12:00:00Z"), 155 ), 156 ]) 157 |> json.to_string 158 159 let assert Ok(_) = 160 records.insert( 161 exec, 162 status_uri, 163 "cid_status" <> int.to_string(i), 164 "did:plc:user1", 165 "xyz.statusphere.status", 166 status_json, 167 ) 168 Nil 169 }) 170 171 // Execute GraphQL query with sortBy createdAt DESC and first:3 172 let query = 173 " 174 { 175 appBskyActorProfile { 176 edges { 177 node { 178 uri 179 statuses: xyzStatusphereStatusByDid( 180 first: 3 181 sortBy: [{field: \"createdAt\", direction: DESC}] 182 ) { 183 totalCount 184 edges { 185 node { 186 status 187 createdAt 188 } 189 } 190 } 191 } 192 } 193 } 194 } 195 " 196 197 let assert Ok(cache) = did_cache.start() 198 let assert Ok(response_json) = 199 lexicon_schema.execute_query_with_db( 200 exec, 201 query, 202 "{}", 203 Error(Nil), 204 cache, 205 option.None, 206 "", 207 "https://plc.directory", 208 ) 209 210 // Verify totalCount is 5 (all statuses) 211 { 212 string.contains(response_json, "\"totalCount\":5") 213 || string.contains(response_json, "\"totalCount\": 5") 214 } 215 |> should.be_true 216 217 // With sortBy createdAt DESC, first:3 should return Status 5, 4, 3 (newest first) 218 string.contains(response_json, "Status #5") 219 |> should.be_true 220 221 string.contains(response_json, "Status #4") 222 |> should.be_true 223 224 string.contains(response_json, "Status #3") 225 |> should.be_true 226 227 // Should NOT contain Status 1 or 2 (they're older) 228 string.contains(response_json, "Status #1") 229 |> should.be_false 230 231 string.contains(response_json, "Status #2") 232 |> should.be_false 233 234 // Verify order: Status 5 should appear before Status 4 235 let pos_5 = case string.split(response_json, "Status #5") { 236 [before, ..] -> string.length(before) 237 [] -> 999_999 238 } 239 240 let pos_4 = case string.split(response_json, "Status #4") { 241 [before, ..] -> string.length(before) 242 [] -> 999_999 243 } 244 245 let pos_3 = case string.split(response_json, "Status #3") { 246 [before, ..] -> string.length(before) 247 [] -> 999_999 248 } 249 250 // Status 5 should come before Status 4 251 { pos_5 < pos_4 } 252 |> should.be_true 253 254 // Status 4 should come before Status 3 255 { pos_4 < pos_3 } 256 |> should.be_true 257} 258 259// Test: DID join with sortBy createdAt ASC 260pub fn did_join_sortby_createdat_asc_test() { 261 // Setup database 262 let assert Ok(exec) = test_helpers.create_test_db() 263 let assert Ok(_) = test_helpers.create_lexicon_table(exec) 264 let assert Ok(_) = test_helpers.create_record_table(exec) 265 let assert Ok(_) = test_helpers.create_actor_table(exec) 266 267 // Insert lexicons 268 let status_lexicon = create_status_lexicon() 269 let profile_lexicon = create_profile_lexicon() 270 let assert Ok(_) = 271 lexicons.insert(exec, "xyz.statusphere.status", status_lexicon) 272 let assert Ok(_) = 273 lexicons.insert(exec, "app.bsky.actor.profile", profile_lexicon) 274 275 // Insert a profile 276 let profile_uri = "at://did:plc:user1/app.bsky.actor.profile/self" 277 let profile_json = 278 json.object([#("displayName", json.string("User One"))]) 279 |> json.to_string 280 281 let assert Ok(_) = 282 records.insert( 283 exec, 284 profile_uri, 285 "cid_profile", 286 "did:plc:user1", 287 "app.bsky.actor.profile", 288 profile_json, 289 ) 290 291 // Insert 5 statuses with different timestamps 292 list.range(1, 5) 293 |> list.each(fn(i) { 294 let status_uri = 295 "at://did:plc:user1/xyz.statusphere.status/status" <> int.to_string(i) 296 let status_json = 297 json.object([ 298 #("status", json.string("Status #" <> int.to_string(i))), 299 #( 300 "createdAt", 301 json.string("2024-01-0" <> int.to_string(i) <> "T12:00:00Z"), 302 ), 303 ]) 304 |> json.to_string 305 306 let assert Ok(_) = 307 records.insert( 308 exec, 309 status_uri, 310 "cid_status" <> int.to_string(i), 311 "did:plc:user1", 312 "xyz.statusphere.status", 313 status_json, 314 ) 315 Nil 316 }) 317 318 // Execute GraphQL query with sortBy createdAt ASC and first:3 319 let query = 320 " 321 { 322 appBskyActorProfile { 323 edges { 324 node { 325 statuses: xyzStatusphereStatusByDid( 326 first: 3 327 sortBy: [{field: \"createdAt\", direction: ASC}] 328 ) { 329 totalCount 330 edges { 331 node { 332 status 333 createdAt 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 // With sortBy createdAt ASC, first:3 should return Status 1, 2, 3 (oldest first) 357 string.contains(response_json, "Status #1") 358 |> should.be_true 359 360 string.contains(response_json, "Status #2") 361 |> should.be_true 362 363 string.contains(response_json, "Status #3") 364 |> should.be_true 365 366 // Should NOT contain Status 4 or 5 367 string.contains(response_json, "Status #4") 368 |> should.be_false 369 370 string.contains(response_json, "Status #5") 371 |> should.be_false 372} 373 374// Test: DID join with where filter on status field 375pub fn did_join_where_filter_test() { 376 // Setup database 377 let assert Ok(exec) = test_helpers.create_test_db() 378 let assert Ok(_) = test_helpers.create_lexicon_table(exec) 379 let assert Ok(_) = test_helpers.create_record_table(exec) 380 let assert Ok(_) = test_helpers.create_actor_table(exec) 381 382 // Insert lexicons 383 let status_lexicon = create_status_lexicon() 384 let profile_lexicon = create_profile_lexicon() 385 let assert Ok(_) = 386 lexicons.insert(exec, "xyz.statusphere.status", status_lexicon) 387 let assert Ok(_) = 388 lexicons.insert(exec, "app.bsky.actor.profile", profile_lexicon) 389 390 // Insert a profile 391 let profile_uri = "at://did:plc:user1/app.bsky.actor.profile/self" 392 let profile_json = 393 json.object([#("displayName", json.string("User One"))]) 394 |> json.to_string 395 396 let assert Ok(_) = 397 records.insert( 398 exec, 399 profile_uri, 400 "cid_profile", 401 "did:plc:user1", 402 "app.bsky.actor.profile", 403 profile_json, 404 ) 405 406 // Insert 5 statuses: 3 containing "gleam", 2 not 407 let statuses = [ 408 #("Status about gleam programming", "2024-01-01T12:00:00Z"), 409 #("Random post", "2024-01-02T12:00:00Z"), 410 #("Learning gleam today", "2024-01-03T12:00:00Z"), 411 #("Another random post", "2024-01-04T12:00:00Z"), 412 #("Gleam is awesome", "2024-01-05T12:00:00Z"), 413 ] 414 415 list.index_map(statuses, fn(status_data, idx) { 416 let #(status_text, created_at) = status_data 417 let i = idx + 1 418 let status_uri = 419 "at://did:plc:user1/xyz.statusphere.status/status" <> int.to_string(i) 420 let status_json = 421 json.object([ 422 #("status", json.string(status_text)), 423 #("createdAt", json.string(created_at)), 424 ]) 425 |> json.to_string 426 427 let assert Ok(_) = 428 records.insert( 429 exec, 430 status_uri, 431 "cid_status" <> int.to_string(i), 432 "did:plc:user1", 433 "xyz.statusphere.status", 434 status_json, 435 ) 436 Nil 437 }) 438 439 // Execute GraphQL query with where filter: status contains "gleam" 440 let query = 441 " 442 { 443 appBskyActorProfile { 444 edges { 445 node { 446 statuses: xyzStatusphereStatusByDid( 447 sortBy: [{field: \"createdAt\", direction: DESC}] 448 where: {status: {contains: \"gleam\"}} 449 ) { 450 totalCount 451 edges { 452 node { 453 status 454 createdAt 455 } 456 } 457 } 458 } 459 } 460 } 461 } 462 " 463 464 let assert Ok(cache) = did_cache.start() 465 let assert Ok(response_json) = 466 lexicon_schema.execute_query_with_db( 467 exec, 468 query, 469 "{}", 470 Error(Nil), 471 cache, 472 option.None, 473 "", 474 "https://plc.directory", 475 ) 476 477 // totalCount should be 3 (only statuses containing "gleam") 478 { 479 string.contains(response_json, "\"totalCount\":3") 480 || string.contains(response_json, "\"totalCount\": 3") 481 } 482 |> should.be_true 483 484 // Should contain statuses with "gleam" 485 string.contains(response_json, "gleam programming") 486 |> should.be_true 487 488 string.contains(response_json, "Learning gleam") 489 |> should.be_true 490 491 string.contains(response_json, "Gleam is awesome") 492 |> should.be_true 493 494 // Should NOT contain "Random post" 495 string.contains(response_json, "Random post") 496 |> should.be_false 497} 498 499// Test: Combination of sortBy + where + first 500pub fn did_join_sortby_where_first_test() { 501 // Setup database 502 let assert Ok(exec) = test_helpers.create_test_db() 503 let assert Ok(_) = test_helpers.create_lexicon_table(exec) 504 let assert Ok(_) = test_helpers.create_record_table(exec) 505 let assert Ok(_) = test_helpers.create_actor_table(exec) 506 507 // Insert lexicons 508 let status_lexicon = create_status_lexicon() 509 let profile_lexicon = create_profile_lexicon() 510 let assert Ok(_) = 511 lexicons.insert(exec, "xyz.statusphere.status", status_lexicon) 512 let assert Ok(_) = 513 lexicons.insert(exec, "app.bsky.actor.profile", profile_lexicon) 514 515 // Insert a profile 516 let profile_uri = "at://did:plc:user1/app.bsky.actor.profile/self" 517 let profile_json = 518 json.object([#("displayName", json.string("User One"))]) 519 |> json.to_string 520 521 let assert Ok(_) = 522 records.insert( 523 exec, 524 profile_uri, 525 "cid_profile", 526 "did:plc:user1", 527 "app.bsky.actor.profile", 528 profile_json, 529 ) 530 531 // Insert 5 statuses: 3 containing "rust", 2 not 532 let statuses = [ 533 #("Learning rust basics", "2024-01-01T12:00:00Z"), 534 #("Random gleam post", "2024-01-02T12:00:00Z"), 535 #("Rust ownership is cool", "2024-01-03T12:00:00Z"), 536 #("Another elixir post", "2024-01-04T12:00:00Z"), 537 #("Rust async programming", "2024-01-05T12:00:00Z"), 538 ] 539 540 list.index_map(statuses, fn(status_data, idx) { 541 let #(status_text, created_at) = status_data 542 let i = idx + 1 543 let status_uri = 544 "at://did:plc:user1/xyz.statusphere.status/status" <> int.to_string(i) 545 let status_json = 546 json.object([ 547 #("status", json.string(status_text)), 548 #("createdAt", json.string(created_at)), 549 ]) 550 |> json.to_string 551 552 let assert Ok(_) = 553 records.insert( 554 exec, 555 status_uri, 556 "cid_status" <> int.to_string(i), 557 "did:plc:user1", 558 "xyz.statusphere.status", 559 status_json, 560 ) 561 Nil 562 }) 563 564 // Execute query: where status contains "rust", sortBy createdAt DESC, first: 2 565 // Should return the 2 newest rust posts 566 let query = 567 " 568 { 569 appBskyActorProfile { 570 edges { 571 node { 572 statuses: xyzStatusphereStatusByDid( 573 first: 2 574 sortBy: [{field: \"createdAt\", direction: DESC}] 575 where: {status: {contains: \"rust\"}} 576 ) { 577 totalCount 578 edges { 579 node { 580 status 581 createdAt 582 } 583 } 584 pageInfo { 585 hasNextPage 586 } 587 } 588 } 589 } 590 } 591 } 592 " 593 594 let assert Ok(cache) = did_cache.start() 595 let assert Ok(response_json) = 596 lexicon_schema.execute_query_with_db( 597 exec, 598 query, 599 "{}", 600 Error(Nil), 601 cache, 602 option.None, 603 "", 604 "https://plc.directory", 605 ) 606 607 // totalCount should be 3 (all rust posts) 608 { 609 string.contains(response_json, "\"totalCount\":3") 610 || string.contains(response_json, "\"totalCount\": 3") 611 } 612 |> should.be_true 613 614 // Should contain the 2 newest rust posts 615 string.contains(response_json, "Rust async programming") 616 |> should.be_true 617 618 string.contains(response_json, "Rust ownership") 619 |> should.be_true 620 621 // Should NOT contain the oldest rust post 622 string.contains(response_json, "Learning rust basics") 623 |> should.be_false 624 625 // Should NOT contain non-rust posts 626 string.contains(response_json, "elixir") 627 |> should.be_false 628 629 string.contains(response_json, "gleam") 630 |> should.be_false 631 632 // hasNextPage should be true (1 more rust post available) 633 { 634 string.contains(response_json, "\"hasNextPage\":true") 635 || string.contains(response_json, "\"hasNextPage\": true") 636 } 637 |> should.be_true 638} 639 640// Test: Exact query pattern from user - top-level where + nested sortBy 641pub fn user_query_pattern_test() { 642 // Setup database 643 let assert Ok(exec) = test_helpers.create_test_db() 644 let assert Ok(_) = test_helpers.create_lexicon_table(exec) 645 let assert Ok(_) = test_helpers.create_record_table(exec) 646 let assert Ok(_) = test_helpers.create_actor_table(exec) 647 648 // Insert lexicons 649 let status_lexicon = create_status_lexicon() 650 let profile_lexicon = create_profile_lexicon() 651 let assert Ok(_) = 652 lexicons.insert(exec, "xyz.statusphere.status", status_lexicon) 653 let assert Ok(_) = 654 lexicons.insert(exec, "app.bsky.actor.profile", profile_lexicon) 655 656 // Insert 2 profiles with different handles 657 let profile1_uri = "at://did:plc:user1/app.bsky.actor.profile/self" 658 let profile1_json = 659 json.object([#("displayName", json.string("User One"))]) 660 |> json.to_string 661 662 let assert Ok(_) = 663 records.insert( 664 exec, 665 profile1_uri, 666 "cid_profile1", 667 "did:plc:user1", 668 "app.bsky.actor.profile", 669 profile1_json, 670 ) 671 672 let assert Ok(_) = actors.upsert(exec, "did:plc:user1", "chadtmiller.com") 673 674 let profile2_uri = "at://did:plc:user2/app.bsky.actor.profile/self" 675 let profile2_json = 676 json.object([#("displayName", json.string("User Two"))]) 677 |> json.to_string 678 679 let assert Ok(_) = 680 records.insert( 681 exec, 682 profile2_uri, 683 "cid_profile2", 684 "did:plc:user2", 685 "app.bsky.actor.profile", 686 profile2_json, 687 ) 688 689 let assert Ok(_) = actors.upsert(exec, "did:plc:user2", "other.com") 690 691 // Insert statuses for user1 (chadtmiller.com) 692 let statuses1 = [ 693 #("First status", "2024-01-01T12:00:00Z"), 694 #("Second status", "2024-01-02T12:00:00Z"), 695 #("Third status", "2024-01-03T12:00:00Z"), 696 ] 697 698 list.index_map(statuses1, fn(status_data, idx) { 699 let #(status_text, created_at) = status_data 700 let i = idx + 1 701 let status_uri = 702 "at://did:plc:user1/xyz.statusphere.status/status" <> int.to_string(i) 703 let status_json = 704 json.object([ 705 #("status", json.string(status_text)), 706 #("createdAt", json.string(created_at)), 707 ]) 708 |> json.to_string 709 710 let assert Ok(_) = 711 records.insert( 712 exec, 713 status_uri, 714 "cid_status1_" <> int.to_string(i), 715 "did:plc:user1", 716 "xyz.statusphere.status", 717 status_json, 718 ) 719 Nil 720 }) 721 722 // Insert statuses for user2 (should be filtered out) 723 let assert Ok(_) = 724 records.insert( 725 exec, 726 "at://did:plc:user2/xyz.statusphere.status/status1", 727 "cid_status2_1", 728 "did:plc:user2", 729 "xyz.statusphere.status", 730 json.object([ 731 #("status", json.string("Other user status")), 732 #("createdAt", json.string("2024-01-01T12:00:00Z")), 733 ]) 734 |> json.to_string, 735 ) 736 737 // Execute query matching user's pattern: top-level where + nested sortBy 738 let query = 739 " 740 { 741 appBskyActorProfile(where: {actorHandle: {eq: \"chadtmiller.com\"}}) { 742 totalCount 743 edges { 744 node { 745 actorHandle 746 statuses: xyzStatusphereStatusByDid( 747 first: 12 748 sortBy: [{field: \"createdAt\", direction: DESC}] 749 ) { 750 totalCount 751 edges { 752 node { 753 status 754 createdAt 755 } 756 } 757 } 758 } 759 } 760 } 761 } 762 " 763 764 let assert Ok(cache) = did_cache.start() 765 let assert Ok(response_json) = 766 lexicon_schema.execute_query_with_db( 767 exec, 768 query, 769 "{}", 770 Error(Nil), 771 cache, 772 option.None, 773 "", 774 "https://plc.directory", 775 ) 776 777 // Should only return 1 profile (chadtmiller.com) 778 { 779 string.contains(response_json, "\"totalCount\":1") 780 || string.contains(response_json, "\"totalCount\": 1") 781 } 782 |> should.be_true 783 784 // Should contain chadtmiller.com handle 785 string.contains(response_json, "chadtmiller.com") 786 |> should.be_true 787 788 // Should NOT contain other.com 789 string.contains(response_json, "other.com") 790 |> should.be_false 791 792 // Nested statuses should have totalCount 3 793 // Count occurrences of totalCount in response 794 let total_count_occurrences = 795 string.split(response_json, "\"totalCount\"") 796 |> list.length 797 |> fn(x) { x - 1 } 798 799 // Should have 2 totalCount fields (one for profiles, one for statuses) 800 total_count_occurrences 801 |> should.equal(2) 802 803 // Statuses should be in DESC order: Third, Second, First 804 string.contains(response_json, "Third status") 805 |> should.be_true 806 807 string.contains(response_json, "Second status") 808 |> should.be_true 809 810 string.contains(response_json, "First status") 811 |> should.be_true 812 813 // Verify order: Third should come before Second 814 let pos_third = case string.split(response_json, "Third status") { 815 [before, ..] -> string.length(before) 816 [] -> 999_999 817 } 818 819 let pos_second = case string.split(response_json, "Second status") { 820 [before, ..] -> string.length(before) 821 [] -> 999_999 822 } 823 824 let pos_first = case string.split(response_json, "First status") { 825 [before, ..] -> string.length(before) 826 [] -> 999_999 827 } 828 829 { pos_third < pos_second } 830 |> should.be_true 831 832 { pos_second < pos_first } 833 |> should.be_true 834 835 // Should NOT contain other user's status 836 string.contains(response_json, "Other user status") 837 |> should.be_false 838}