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