Auto-indexing service and GraphQL API for AT Protocol Records
at main 377 lines 9.9 kB view raw
1/// Integration tests for Blob type in GraphQL queries 2/// 3/// Tests the full flow of querying blob fields: 4/// 1. Create a lexicon with blob fields 5/// 2. Insert records with blob data in AT Protocol format 6/// 3. Execute GraphQL queries with blob field selection 7/// 4. Verify blob fields are resolved correctly with all sub-fields 8import database/repositories/lexicons 9import database/repositories/records 10import gleam/http 11import gleam/json 12import gleam/option 13import gleam/string 14import gleeunit/should 15import handlers/graphql as graphql_handler 16import lib/oauth/did_cache 17import test_helpers 18import wisp 19import wisp/simulate 20 21/// Create a lexicon with a blob field (profile with avatar) 22fn create_profile_lexicon() -> String { 23 json.object([ 24 #("lexicon", json.int(1)), 25 #("id", json.string("app.test.profile")), 26 #( 27 "defs", 28 json.object([ 29 #( 30 "main", 31 json.object([ 32 #("type", json.string("record")), 33 #("key", json.string("self")), 34 #( 35 "record", 36 json.object([ 37 #("type", json.string("object")), 38 #( 39 "required", 40 json.array([json.string("displayName")], of: fn(x) { x }), 41 ), 42 #( 43 "properties", 44 json.object([ 45 #( 46 "displayName", 47 json.object([#("type", json.string("string"))]), 48 ), 49 #( 50 "description", 51 json.object([#("type", json.string("string"))]), 52 ), 53 #("avatar", json.object([#("type", json.string("blob"))])), 54 #("banner", json.object([#("type", json.string("blob"))])), 55 ]), 56 ), 57 ]), 58 ), 59 ]), 60 ), 61 ]), 62 ), 63 ]) 64 |> json.to_string 65} 66 67pub fn blob_field_query_test() { 68 // Create in-memory database 69 let assert Ok(exec) = test_helpers.create_test_db() 70 let assert Ok(_) = test_helpers.create_lexicon_table(exec) 71 let assert Ok(_) = test_helpers.create_record_table(exec) 72 73 // Insert profile lexicon with blob fields 74 let lexicon = create_profile_lexicon() 75 let assert Ok(_) = lexicons.insert(exec, "app.test.profile", lexicon) 76 77 // Insert a profile record with avatar blob 78 // AT Protocol blob format: { ref: { $link: "cid" }, mimeType: "...", size: 123 } 79 let record_json = 80 json.object([ 81 #("displayName", json.string("Alice")), 82 #("description", json.string("Software developer")), 83 #( 84 "avatar", 85 json.object([ 86 #("ref", json.object([#("$link", json.string("bafyreiabc123"))])), 87 #("mimeType", json.string("image/jpeg")), 88 #("size", json.int(45_678)), 89 ]), 90 ), 91 ]) 92 |> json.to_string 93 94 let assert Ok(_) = 95 records.insert( 96 exec, 97 "at://did:plc:alice123/app.test.profile/self", 98 "cidprofile1", 99 "did:plc:alice123", 100 "app.test.profile", 101 record_json, 102 ) 103 104 // Query blob fields with all sub-fields 105 let query = 106 json.object([ 107 #( 108 "query", 109 json.string( 110 "{ appTestProfile { edges { node { displayName avatar { ref mimeType size url(preset: \"avatar\") } } } } }", 111 ), 112 ), 113 ]) 114 |> json.to_string 115 116 let request = 117 simulate.request(http.Post, "/graphql") 118 |> simulate.string_body(query) 119 |> simulate.header("content-type", "application/json") 120 121 let assert Ok(cache) = did_cache.start() 122 let response = 123 graphql_handler.handle_graphql_request( 124 request, 125 exec, 126 cache, 127 option.None, 128 "", 129 "https://plc.directory", 130 ) 131 132 // Verify response 133 response.status 134 |> should.equal(200) 135 136 let assert wisp.Text(body) = response.body 137 138 // Verify response contains data 139 string.contains(body, "\"data\"") 140 |> should.be_true 141 142 // Verify displayName is present 143 string.contains(body, "Alice") 144 |> should.be_true 145 146 // Verify blob ref field 147 string.contains(body, "bafyreiabc123") 148 |> should.be_true 149 150 // Verify blob mimeType field 151 string.contains(body, "image/jpeg") 152 |> should.be_true 153 154 // Verify blob size field 155 string.contains(body, "45678") 156 |> should.be_true 157 158 // Verify blob url field contains CDN URL with avatar preset 159 string.contains( 160 body, 161 "https://cdn.bsky.app/img/avatar/plain/did:plc:alice123/bafyreiabc123@jpeg", 162 ) 163 |> should.be_true 164} 165 166pub fn blob_field_with_different_presets_test() { 167 // Create in-memory database 168 let assert Ok(exec) = test_helpers.create_test_db() 169 let assert Ok(_) = test_helpers.create_lexicon_table(exec) 170 let assert Ok(_) = test_helpers.create_record_table(exec) 171 172 // Insert profile lexicon 173 let lexicon = create_profile_lexicon() 174 let assert Ok(_) = lexicons.insert(exec, "app.test.profile", lexicon) 175 176 // Insert a profile with banner blob 177 let record_json = 178 json.object([ 179 #("displayName", json.string("Bob")), 180 #( 181 "banner", 182 json.object([ 183 #("ref", json.object([#("$link", json.string("bafyreibanner789"))])), 184 #("mimeType", json.string("image/png")), 185 #("size", json.int(98_765)), 186 ]), 187 ), 188 ]) 189 |> json.to_string 190 191 let assert Ok(_) = 192 records.insert( 193 exec, 194 "at://did:plc:bob456/app.test.profile/self", 195 "cidbanner1", 196 "did:plc:bob456", 197 "app.test.profile", 198 record_json, 199 ) 200 201 // Query with banner preset 202 let query = 203 json.object([ 204 #( 205 "query", 206 json.string( 207 "{ appTestProfile { edges { node { banner { url(preset: \"banner\") } } } } }", 208 ), 209 ), 210 ]) 211 |> json.to_string 212 213 let request = 214 simulate.request(http.Post, "/graphql") 215 |> simulate.string_body(query) 216 |> simulate.header("content-type", "application/json") 217 218 let assert Ok(cache) = did_cache.start() 219 let response = 220 graphql_handler.handle_graphql_request( 221 request, 222 exec, 223 cache, 224 option.None, 225 "", 226 "https://plc.directory", 227 ) 228 229 response.status 230 |> should.equal(200) 231 232 let assert wisp.Text(body) = response.body 233 234 // Verify banner URL with banner preset 235 string.contains( 236 body, 237 "https://cdn.bsky.app/img/banner/plain/did:plc:bob456/bafyreibanner789@jpeg", 238 ) 239 |> should.be_true 240} 241 242pub fn blob_field_default_preset_test() { 243 // Test that when no preset is specified, feed_fullsize is used 244 let assert Ok(exec) = test_helpers.create_test_db() 245 let assert Ok(_) = test_helpers.create_lexicon_table(exec) 246 let assert Ok(_) = test_helpers.create_record_table(exec) 247 248 let lexicon = create_profile_lexicon() 249 let assert Ok(_) = lexicons.insert(exec, "app.test.profile", lexicon) 250 251 let record_json = 252 json.object([ 253 #("displayName", json.string("Charlie")), 254 #( 255 "avatar", 256 json.object([ 257 #("ref", json.object([#("$link", json.string("bafyreidefault"))])), 258 #("mimeType", json.string("image/jpeg")), 259 #("size", json.int(12_345)), 260 ]), 261 ), 262 ]) 263 |> json.to_string 264 265 let assert Ok(_) = 266 records.insert( 267 exec, 268 "at://did:plc:charlie/app.test.profile/self", 269 "cidcharlie", 270 "did:plc:charlie", 271 "app.test.profile", 272 record_json, 273 ) 274 275 // Query without specifying preset (should default to feed_fullsize) 276 let query = 277 json.object([ 278 #( 279 "query", 280 json.string("{ appTestProfile { edges { node { avatar { url } } } } }"), 281 ), 282 ]) 283 |> json.to_string 284 285 let request = 286 simulate.request(http.Post, "/graphql") 287 |> simulate.string_body(query) 288 |> simulate.header("content-type", "application/json") 289 290 let assert Ok(cache) = did_cache.start() 291 let response = 292 graphql_handler.handle_graphql_request( 293 request, 294 exec, 295 cache, 296 option.None, 297 "", 298 "https://plc.directory", 299 ) 300 301 response.status 302 |> should.equal(200) 303 304 let assert wisp.Text(body) = response.body 305 306 // Verify default preset is feed_fullsize 307 string.contains( 308 body, 309 "https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:charlie/bafyreidefault@jpeg", 310 ) 311 |> should.be_true 312} 313 314pub fn blob_field_null_when_missing_test() { 315 // Test that blob fields return null when not present in record 316 let assert Ok(exec) = test_helpers.create_test_db() 317 let assert Ok(_) = test_helpers.create_lexicon_table(exec) 318 let assert Ok(_) = test_helpers.create_record_table(exec) 319 320 let lexicon = create_profile_lexicon() 321 let assert Ok(_) = lexicons.insert(exec, "app.test.profile", lexicon) 322 323 // Insert record without avatar field 324 let record_json = 325 json.object([#("displayName", json.string("Dave"))]) 326 |> json.to_string 327 328 let assert Ok(_) = 329 records.insert( 330 exec, 331 "at://did:plc:dave/app.test.profile/self", 332 "ciddave", 333 "did:plc:dave", 334 "app.test.profile", 335 record_json, 336 ) 337 338 let query = 339 json.object([ 340 #( 341 "query", 342 json.string( 343 "{ appTestProfile { edges { node { displayName avatar { ref } } } } }", 344 ), 345 ), 346 ]) 347 |> json.to_string 348 349 let request = 350 simulate.request(http.Post, "/graphql") 351 |> simulate.string_body(query) 352 |> simulate.header("content-type", "application/json") 353 354 let assert Ok(cache) = did_cache.start() 355 let response = 356 graphql_handler.handle_graphql_request( 357 request, 358 exec, 359 cache, 360 option.None, 361 "", 362 "https://plc.directory", 363 ) 364 365 response.status 366 |> should.equal(200) 367 368 let assert wisp.Text(body) = response.body 369 370 // Verify displayName is present 371 string.contains(body, "Dave") 372 |> should.be_true 373 374 // Verify avatar is null (JSON encoder adds space after colon) 375 string.contains(body, "\"avatar\": null") 376 |> should.be_true 377}