Auto-indexing service and GraphQL API for AT Protocol Records
quickslice.slices.network/
atproto
gleam
graphql
1/// End-to-end tests for notifications GraphQL query
2///
3/// Tests verify that:
4/// - notifications query returns records mentioning the given DID
5/// - Self-authored records are excluded
6/// - Collection filtering works correctly
7/// - Union type resolution works across different record types
8import database/repositories/actors
9import database/repositories/lexicons
10import database/repositories/records
11import gleam/json
12import gleam/option
13import gleam/string
14import gleeunit/should
15import graphql/lexicon/schema as lexicon_schema
16import lib/oauth/did_cache
17import test_helpers
18
19// Helper to create a post lexicon JSON
20fn create_post_lexicon() -> String {
21 json.object([
22 #("lexicon", json.int(1)),
23 #("id", json.string("app.bsky.feed.post")),
24 #(
25 "defs",
26 json.object([
27 #(
28 "main",
29 json.object([
30 #("type", json.string("record")),
31 #("key", json.string("tid")),
32 #(
33 "record",
34 json.object([
35 #("type", json.string("object")),
36 #(
37 "required",
38 json.array([json.string("text")], of: fn(x) { x }),
39 ),
40 #(
41 "properties",
42 json.object([
43 #(
44 "text",
45 json.object([
46 #("type", json.string("string")),
47 #("maxLength", json.int(300)),
48 ]),
49 ),
50 #(
51 "createdAt",
52 json.object([
53 #("type", json.string("string")),
54 #("format", json.string("datetime")),
55 ]),
56 ),
57 ]),
58 ),
59 ]),
60 ),
61 ]),
62 ),
63 ]),
64 ),
65 ])
66 |> json.to_string
67}
68
69// Helper to create a like lexicon JSON with subject field
70fn create_like_lexicon() -> String {
71 json.object([
72 #("lexicon", json.int(1)),
73 #("id", json.string("app.bsky.feed.like")),
74 #(
75 "defs",
76 json.object([
77 #(
78 "main",
79 json.object([
80 #("type", json.string("record")),
81 #("key", json.string("tid")),
82 #(
83 "record",
84 json.object([
85 #("type", json.string("object")),
86 #(
87 "required",
88 json.array([json.string("subject")], of: fn(x) { x }),
89 ),
90 #(
91 "properties",
92 json.object([
93 #(
94 "subject",
95 json.object([
96 #("type", json.string("string")),
97 #("format", json.string("at-uri")),
98 ]),
99 ),
100 #(
101 "createdAt",
102 json.object([
103 #("type", json.string("string")),
104 #("format", json.string("datetime")),
105 ]),
106 ),
107 ]),
108 ),
109 ]),
110 ),
111 ]),
112 ),
113 ]),
114 ),
115 ])
116 |> json.to_string
117}
118
119// Helper to create a follow lexicon JSON with subject field (DID)
120fn create_follow_lexicon() -> String {
121 json.object([
122 #("lexicon", json.int(1)),
123 #("id", json.string("app.bsky.graph.follow")),
124 #(
125 "defs",
126 json.object([
127 #(
128 "main",
129 json.object([
130 #("type", json.string("record")),
131 #("key", json.string("tid")),
132 #(
133 "record",
134 json.object([
135 #("type", json.string("object")),
136 #(
137 "required",
138 json.array([json.string("subject")], of: fn(x) { x }),
139 ),
140 #(
141 "properties",
142 json.object([
143 #(
144 "subject",
145 json.object([#("type", json.string("string"))]),
146 ),
147 #(
148 "createdAt",
149 json.object([
150 #("type", json.string("string")),
151 #("format", json.string("datetime")),
152 ]),
153 ),
154 ]),
155 ),
156 ]),
157 ),
158 ]),
159 ),
160 ]),
161 ),
162 ])
163 |> json.to_string
164}
165
166// Test: notifications query returns records mentioning the target DID
167pub fn notifications_returns_mentioning_records_test() {
168 // Setup database
169 let assert Ok(exec) = test_helpers.create_test_db()
170 let assert Ok(_) = test_helpers.create_lexicon_table(exec)
171 let assert Ok(_) = test_helpers.create_record_table(exec)
172 let assert Ok(_) = test_helpers.create_actor_table(exec)
173 let assert Ok(_) = test_helpers.create_oauth_tables(exec)
174 let assert Ok(_) =
175 test_helpers.insert_test_token(
176 exec,
177 "test-notification-token",
178 "did:plc:target",
179 )
180
181 // Insert lexicons
182 let assert Ok(_) =
183 lexicons.insert(exec, "app.bsky.feed.post", create_post_lexicon())
184 let assert Ok(_) =
185 lexicons.insert(exec, "app.bsky.feed.like", create_like_lexicon())
186 let assert Ok(_) =
187 lexicons.insert(exec, "app.bsky.graph.follow", create_follow_lexicon())
188
189 // Setup actors
190 let assert Ok(_) = actors.upsert(exec, "did:plc:target", "target.bsky.social")
191 let assert Ok(_) = actors.upsert(exec, "did:plc:alice", "alice.bsky.social")
192 let assert Ok(_) = actors.upsert(exec, "did:plc:bob", "bob.bsky.social")
193
194 // Target's own post (should NOT appear in notifications)
195 let assert Ok(_) =
196 records.insert(
197 exec,
198 "at://did:plc:target/app.bsky.feed.post/post1",
199 "bafy001",
200 "did:plc:target",
201 "app.bsky.feed.post",
202 "{\"text\":\"Hello world\",\"createdAt\":\"2024-01-01T00:00:00Z\"}",
203 )
204
205 // Alice likes target's post (SHOULD appear)
206 let assert Ok(_) =
207 records.insert(
208 exec,
209 "at://did:plc:alice/app.bsky.feed.like/like1",
210 "bafy002",
211 "did:plc:alice",
212 "app.bsky.feed.like",
213 "{\"subject\":\"at://did:plc:target/app.bsky.feed.post/post1\",\"createdAt\":\"2024-01-02T00:00:00Z\"}",
214 )
215
216 // Bob follows target (SHOULD appear)
217 let assert Ok(_) =
218 records.insert(
219 exec,
220 "at://did:plc:bob/app.bsky.graph.follow/follow1",
221 "bafy003",
222 "did:plc:bob",
223 "app.bsky.graph.follow",
224 "{\"subject\":\"did:plc:target\",\"createdAt\":\"2024-01-03T00:00:00Z\"}",
225 )
226
227 // Alice's unrelated post (should NOT appear)
228 let assert Ok(_) =
229 records.insert(
230 exec,
231 "at://did:plc:alice/app.bsky.feed.post/post2",
232 "bafy004",
233 "did:plc:alice",
234 "app.bsky.feed.post",
235 "{\"text\":\"Unrelated post\",\"createdAt\":\"2024-01-04T00:00:00Z\"}",
236 )
237
238 // Query all notifications - verify union type resolution works correctly
239 let query =
240 "
241 query {
242 notifications(first: 10) {
243 edges {
244 cursor
245 node {
246 __typename
247 ... on AppBskyFeedLike {
248 uri
249 }
250 ... on AppBskyGraphFollow {
251 uri
252 }
253 }
254 }
255 pageInfo {
256 hasNextPage
257 hasPreviousPage
258 }
259 }
260 }
261 "
262
263 let assert Ok(cache) = did_cache.start()
264 let assert Ok(response_json) =
265 lexicon_schema.execute_query_with_db(
266 exec,
267 query,
268 "{}",
269 Ok("test-notification-token"),
270 cache,
271 option.None,
272 "",
273 "https://plc.directory",
274 )
275
276 // Verify union type resolution returns concrete types
277 string.contains(response_json, "AppBskyFeedLike")
278 |> should.be_true
279
280 string.contains(response_json, "AppBskyGraphFollow")
281 |> should.be_true
282
283 // Verify URIs are returned from inline fragments
284 string.contains(response_json, "like1")
285 |> should.be_true
286
287 string.contains(response_json, "follow1")
288 |> should.be_true
289
290 // Should have pagination info
291 string.contains(response_json, "hasNextPage")
292 |> should.be_true
293}
294
295// Test: notifications query with collection filter
296pub fn notifications_filters_by_collection_test() {
297 // Setup database
298 let assert Ok(exec) = test_helpers.create_test_db()
299 let assert Ok(_) = test_helpers.create_lexicon_table(exec)
300 let assert Ok(_) = test_helpers.create_record_table(exec)
301 let assert Ok(_) = test_helpers.create_actor_table(exec)
302 let assert Ok(_) = test_helpers.create_oauth_tables(exec)
303 let assert Ok(_) =
304 test_helpers.insert_test_token(
305 exec,
306 "test-notification-token",
307 "did:plc:target",
308 )
309
310 // Insert lexicons
311 let assert Ok(_) =
312 lexicons.insert(exec, "app.bsky.feed.post", create_post_lexicon())
313 let assert Ok(_) =
314 lexicons.insert(exec, "app.bsky.feed.like", create_like_lexicon())
315 let assert Ok(_) =
316 lexicons.insert(exec, "app.bsky.graph.follow", create_follow_lexicon())
317
318 // Setup actors
319 let assert Ok(_) = actors.upsert(exec, "did:plc:target", "target.bsky.social")
320 let assert Ok(_) = actors.upsert(exec, "did:plc:alice", "alice.bsky.social")
321 let assert Ok(_) = actors.upsert(exec, "did:plc:bob", "bob.bsky.social")
322
323 // Alice likes target's post
324 let assert Ok(_) =
325 records.insert(
326 exec,
327 "at://did:plc:alice/app.bsky.feed.like/like1",
328 "bafy002",
329 "did:plc:alice",
330 "app.bsky.feed.like",
331 "{\"subject\":\"at://did:plc:target/app.bsky.feed.post/post1\",\"createdAt\":\"2024-01-02T00:00:00Z\"}",
332 )
333
334 // Bob follows target
335 let assert Ok(_) =
336 records.insert(
337 exec,
338 "at://did:plc:bob/app.bsky.graph.follow/follow1",
339 "bafy003",
340 "did:plc:bob",
341 "app.bsky.graph.follow",
342 "{\"subject\":\"did:plc:target\",\"createdAt\":\"2024-01-03T00:00:00Z\"}",
343 )
344
345 // Query only likes (not follows)
346 let query =
347 "
348 query {
349 notifications(collections: [APP_BSKY_FEED_LIKE], first: 10) {
350 edges {
351 cursor
352 node {
353 __typename
354 ... on AppBskyFeedLike {
355 uri
356 }
357 }
358 }
359 }
360 }
361 "
362
363 let assert Ok(cache) = did_cache.start()
364 let assert Ok(response_json) =
365 lexicon_schema.execute_query_with_db(
366 exec,
367 query,
368 "{}",
369 Ok("test-notification-token"),
370 cache,
371 option.None,
372 "",
373 "https://plc.directory",
374 )
375
376 // Should have the like with correct type
377 string.contains(response_json, "AppBskyFeedLike")
378 |> should.be_true
379
380 string.contains(response_json, "like1")
381 |> should.be_true
382
383 // Should NOT have the follow (filtered out)
384 string.contains(response_json, "follow1")
385 |> should.be_false
386
387 string.contains(response_json, "AppBskyGraphFollow")
388 |> should.be_false
389}
390
391// Test: notifications query excludes self-authored records
392pub fn notifications_excludes_self_authored_test() {
393 // Setup database
394 let assert Ok(exec) = test_helpers.create_test_db()
395 let assert Ok(_) = test_helpers.create_lexicon_table(exec)
396 let assert Ok(_) = test_helpers.create_record_table(exec)
397 let assert Ok(_) = test_helpers.create_actor_table(exec)
398 let assert Ok(_) = test_helpers.create_oauth_tables(exec)
399 let assert Ok(_) =
400 test_helpers.insert_test_token(
401 exec,
402 "test-notification-token",
403 "did:plc:target",
404 )
405
406 // Insert lexicons
407 let assert Ok(_) =
408 lexicons.insert(exec, "app.bsky.feed.post", create_post_lexicon())
409
410 // Setup actors
411 let assert Ok(_) = actors.upsert(exec, "did:plc:target", "target.bsky.social")
412
413 // Target's own post that mentions themselves (should NOT appear)
414 let assert Ok(_) =
415 records.insert(
416 exec,
417 "at://did:plc:target/app.bsky.feed.post/post1",
418 "bafy001",
419 "did:plc:target",
420 "app.bsky.feed.post",
421 "{\"text\":\"Talking about did:plc:target\",\"createdAt\":\"2024-01-01T00:00:00Z\"}",
422 )
423
424 let query =
425 "
426 query {
427 notifications(first: 10) {
428 edges {
429 cursor
430 node {
431 __typename
432 }
433 }
434 }
435 }
436 "
437
438 let assert Ok(cache) = did_cache.start()
439 let assert Ok(response_json) =
440 lexicon_schema.execute_query_with_db(
441 exec,
442 query,
443 "{}",
444 Ok("test-notification-token"),
445 cache,
446 option.None,
447 "",
448 "https://plc.directory",
449 )
450
451 // Should have empty edges since self-authored is excluded
452 // Check for empty edges array (with space after colon)
453 string.contains(response_json, "\"edges\": []")
454 |> should.be_true
455}