[READ ONLY MIRROR] Spark Social AppView Server
github.com/sprksocial/server
atproto
deno
hono
lexicon
1import { assertEquals } from "@std/assert";
2import { createTestApp, TEST_USERS } from "./util.ts";
3import { $OutputBody } from "../lex/so/sprk/feed/getCrosspostThread.ts";
4
5Deno.test({
6 name: "Crosspost thread endpoint",
7 sanitizeOps: false,
8 sanitizeResources: false,
9 fn: async (t) => {
10 const { app, ctx, cleanup } = await createTestApp({
11 actors: true,
12 profiles: false,
13 posts: false,
14 replies: false,
15 stories: false,
16 likes: false,
17 reposts: false,
18 follows: false,
19 blocks: false,
20 audio: false,
21 generators: false,
22 preferences: false,
23 records: false,
24 actorSync: false,
25 });
26
27 try {
28 const parentUri = `at://${TEST_USERS[0].did}/so.sprk.feed.post/post1`;
29 const validCid =
30 "bafyreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku";
31 const parentCid = validCid;
32 const reply1Uri = `at://${TEST_USERS[1].did}/app.bsky.feed.post/cross1`;
33 const reply2Uri = `at://${TEST_USERS[2].did}/app.bsky.feed.post/cross2`;
34 const reply3Uri = `at://${TEST_USERS[2].did}/app.bsky.feed.post/cross3`;
35 const reply4Uri = `at://${TEST_USERS[3].did}/app.bsky.feed.post/cross4`;
36 const cycleAUri = `at://${TEST_USERS[1].did}/app.bsky.feed.post/cycleA`;
37 const cycleBUri = `at://${TEST_USERS[2].did}/app.bsky.feed.post/cycleB`;
38 const reply1Cid = validCid;
39 const reply2Cid = validCid;
40 const reply3Cid = validCid;
41 const reply4Cid = validCid;
42 const cycleACid = validCid;
43 const cycleBCid = validCid;
44
45 const time0 = new Date("2026-01-01T00:00:00.000Z").toISOString();
46 const time1 = new Date("2026-01-01T00:01:00.000Z").toISOString();
47 const time2 = new Date("2026-01-01T00:02:00.000Z").toISOString();
48 const time3 = new Date("2026-01-01T00:03:00.000Z").toISOString();
49 const time4 = new Date("2026-01-01T00:04:00.000Z").toISOString();
50 const time5 = new Date("2026-01-01T00:05:00.000Z").toISOString();
51 const time6 = new Date("2026-01-01T00:06:00.000Z").toISOString();
52
53 await ctx.db.models.Post.create({
54 uri: parentUri,
55 cid: parentCid,
56 authorDid: TEST_USERS[0].did,
57 caption: { text: "root" },
58 media: {
59 $type: "so.sprk.media.images",
60 images: [],
61 },
62 createdAt: time0,
63 indexedAt: time0,
64 likeCount: 1,
65 replyCount: 2,
66 repostCount: 0,
67 });
68
69 await ctx.db.models.CrosspostReply.create([
70 {
71 uri: reply1Uri,
72 cid: reply1Cid,
73 authorDid: TEST_USERS[1].did,
74 text: "reply-1",
75 reply: {
76 root: { uri: parentUri, cid: parentCid },
77 parent: { uri: parentUri, cid: parentCid },
78 },
79 createdAt: time1,
80 indexedAt: time1,
81 likeCount: 2,
82 replyCount: 1,
83 },
84 {
85 uri: reply2Uri,
86 cid: reply2Cid,
87 authorDid: TEST_USERS[2].did,
88 text: "reply-2",
89 reply: {
90 root: { uri: parentUri, cid: parentCid },
91 parent: { uri: reply1Uri, cid: reply1Cid },
92 },
93 createdAt: time2,
94 indexedAt: time2,
95 likeCount: 3,
96 replyCount: 0,
97 },
98 {
99 uri: reply3Uri,
100 cid: reply3Cid,
101 authorDid: TEST_USERS[2].did,
102 text: "reply-3",
103 reply: {
104 root: { uri: parentUri, cid: parentCid },
105 parent: { uri: parentUri, cid: parentCid },
106 },
107 createdAt: time3,
108 indexedAt: time3,
109 likeCount: 1,
110 replyCount: 0,
111 },
112 {
113 uri: reply4Uri,
114 cid: reply4Cid,
115 authorDid: TEST_USERS[3].did,
116 text: "reply-4",
117 reply: {
118 root: { uri: parentUri, cid: parentCid },
119 parent: { uri: parentUri, cid: parentCid },
120 },
121 createdAt: time4,
122 indexedAt: time4,
123 likeCount: 10,
124 replyCount: 0,
125 },
126 {
127 uri: cycleAUri,
128 cid: cycleACid,
129 authorDid: TEST_USERS[1].did,
130 text: "cycle-a",
131 reply: {
132 root: { uri: parentUri, cid: parentCid },
133 parent: { uri: cycleBUri, cid: cycleBCid },
134 },
135 createdAt: time5,
136 indexedAt: time5,
137 likeCount: 0,
138 replyCount: 0,
139 },
140 {
141 uri: cycleBUri,
142 cid: cycleBCid,
143 authorDid: TEST_USERS[2].did,
144 text: "cycle-b",
145 reply: {
146 root: { uri: parentUri, cid: parentCid },
147 parent: { uri: cycleAUri, cid: cycleACid },
148 },
149 createdAt: time6,
150 indexedAt: time6,
151 likeCount: 0,
152 replyCount: 0,
153 },
154 ]);
155
156 const blockUri = `at://${TEST_USERS[1].did}/so.sprk.graph.block/block1`;
157 await ctx.db.models.Block.create({
158 uri: blockUri,
159 cid: validCid,
160 authorDid: TEST_USERS[1].did,
161 subject: TEST_USERS[2].did,
162 createdAt: time6,
163 indexedAt: time6,
164 });
165
166 await t.step(
167 "returns thread-style descendants from a post anchor",
168 async () => {
169 const res = await app.request(
170 `/xrpc/so.sprk.feed.getCrosspostThread?anchor=${
171 encodeURIComponent(parentUri)
172 }&depth=5&parentHeight=5&sort=oldest&limit=50`,
173 );
174 assertEquals(res.status, 200);
175
176 const body = await res.json() as $OutputBody;
177 assertEquals(body.thread.length, 5);
178 assertEquals(body.thread[0].uri, parentUri);
179 assertEquals(body.thread[0].depth, 0);
180 assertEquals(body.thread[1].uri, reply1Uri);
181 assertEquals(body.thread[1].depth, 1);
182 assertEquals(body.thread[2].uri, reply3Uri);
183 assertEquals(body.thread[2].depth, 1);
184 assertEquals(body.thread[3].uri, reply4Uri);
185 assertEquals(body.thread[3].depth, 1);
186 assertEquals(body.thread[4].uri, reply2Uri);
187 assertEquals(body.thread[4].depth, 2);
188 },
189 );
190
191 await t.step("applies limit and cursor pagination", async () => {
192 const firstRes = await app.request(
193 `/xrpc/so.sprk.feed.getCrosspostThread?anchor=${
194 encodeURIComponent(parentUri)
195 }&depth=5&parentHeight=5&sort=oldest&limit=2`,
196 );
197 assertEquals(firstRes.status, 200);
198 const firstBody = await firstRes.json() as $OutputBody;
199 assertEquals(firstBody.thread.length, 2);
200 assertEquals(firstBody.thread[0].uri, parentUri);
201 assertEquals(firstBody.thread[1].uri, reply1Uri);
202 assertEquals(firstBody.cursor, "2");
203
204 const secondRes = await app.request(
205 `/xrpc/so.sprk.feed.getCrosspostThread?anchor=${
206 encodeURIComponent(parentUri)
207 }&depth=5&parentHeight=5&sort=oldest&limit=2&cursor=${firstBody.cursor}`,
208 );
209 assertEquals(secondRes.status, 200);
210 const secondBody = await secondRes.json() as $OutputBody;
211 assertEquals(secondBody.thread.length, 2);
212 assertEquals(secondBody.thread[0].uri, reply3Uri);
213 assertEquals(secondBody.thread[1].uri, reply4Uri);
214 assertEquals(secondBody.cursor, "4");
215
216 const thirdRes = await app.request(
217 `/xrpc/so.sprk.feed.getCrosspostThread?anchor=${
218 encodeURIComponent(parentUri)
219 }&depth=5&parentHeight=5&sort=oldest&limit=2&cursor=${secondBody.cursor}`,
220 );
221 assertEquals(thirdRes.status, 200);
222 const thirdBody = await thirdRes.json() as $OutputBody;
223 assertEquals(thirdBody.thread.length, 1);
224 assertEquals(thirdBody.thread[0].uri, reply2Uri);
225 assertEquals(thirdBody.cursor, undefined);
226 });
227
228 await t.step("respects newest sibling ordering", async () => {
229 const res = await app.request(
230 `/xrpc/so.sprk.feed.getCrosspostThread?anchor=${
231 encodeURIComponent(parentUri)
232 }&depth=1&parentHeight=5&sort=newest&limit=50`,
233 );
234 assertEquals(res.status, 200);
235
236 const body = await res.json() as $OutputBody;
237 assertEquals(body.thread.length, 4);
238 assertEquals(body.thread[0].uri, parentUri);
239 assertEquals(body.thread[1].uri, reply4Uri);
240 assertEquals(body.thread[2].uri, reply3Uri);
241 assertEquals(body.thread[3].uri, reply1Uri);
242 });
243
244 await t.step("respects top sibling ordering", async () => {
245 const res = await app.request(
246 `/xrpc/so.sprk.feed.getCrosspostThread?anchor=${
247 encodeURIComponent(parentUri)
248 }&depth=1&parentHeight=5&sort=top&limit=50`,
249 );
250 assertEquals(res.status, 200);
251
252 const body = await res.json() as $OutputBody;
253 assertEquals(body.thread.length, 4);
254 assertEquals(body.thread[0].uri, parentUri);
255 assertEquals(body.thread[1].uri, reply4Uri);
256 assertEquals(body.thread[2].uri, reply1Uri);
257 assertEquals(body.thread[3].uri, reply3Uri);
258 });
259
260 await t.step("includes ancestors for reply anchor", async () => {
261 const res = await app.request(
262 `/xrpc/so.sprk.feed.getCrosspostThread?anchor=${
263 encodeURIComponent(reply2Uri)
264 }&depth=0&parentHeight=5&sort=oldest&limit=50`,
265 );
266 assertEquals(res.status, 200);
267
268 const body = await res.json() as $OutputBody;
269 assertEquals(body.thread.length, 3);
270 assertEquals(body.thread[0].uri, parentUri);
271 assertEquals(body.thread[0].depth, -2);
272 assertEquals(body.thread[1].uri, reply1Uri);
273 assertEquals(body.thread[1].depth, -1);
274 assertEquals(body.thread[2].uri, reply2Uri);
275 assertEquals(body.thread[2].depth, 0);
276 });
277
278 await t.step("applies parent/root 3p-block moderation", async () => {
279 const res = await app.request(
280 `/xrpc/so.sprk.feed.getCrosspostThread?anchor=${
281 encodeURIComponent(parentUri)
282 }&depth=5&parentHeight=5&sort=oldest&limit=50`,
283 );
284 assertEquals(res.status, 200);
285
286 const body = await res.json() as $OutputBody;
287 const blocked = body.thread.find((item) => item.uri === reply2Uri);
288 assertEquals(
289 blocked?.value.$type,
290 "so.sprk.feed.defs#blockedPost",
291 );
292 });
293
294 await t.step(
295 "hides taken-down thread records for standard viewers",
296 async () => {
297 await ctx.db.models.Record.create({
298 uri: reply4Uri,
299 cid: reply4Cid,
300 did: TEST_USERS[3].did,
301 collectionName: "app.bsky.feed.post",
302 rkey: "cross4",
303 createdAt: time4,
304 indexedAt: time4,
305 json: JSON.stringify({
306 $type: "app.bsky.feed.post",
307 text: "reply-4",
308 createdAt: time4,
309 }),
310 takedownRef: "TAKEDOWN",
311 });
312
313 const res = await app.request(
314 `/xrpc/so.sprk.feed.getCrosspostThread?anchor=${
315 encodeURIComponent(parentUri)
316 }&depth=5&parentHeight=5&sort=oldest&limit=50`,
317 );
318 assertEquals(res.status, 200);
319 const body = await res.json() as $OutputBody;
320 assertEquals(
321 body.thread.some((item) => item.uri === reply4Uri),
322 false,
323 );
324 },
325 );
326
327 await t.step(
328 "stops on cyclic ancestors and keeps anchor at depth 0",
329 async () => {
330 const res = await app.request(
331 `/xrpc/so.sprk.feed.getCrosspostThread?anchor=${
332 encodeURIComponent(cycleAUri)
333 }&depth=0&parentHeight=10&sort=oldest&limit=50`,
334 );
335 assertEquals(res.status, 200);
336
337 const body = await res.json() as $OutputBody;
338 assertEquals(body.thread.length, 2);
339 assertEquals(body.thread[0].uri, cycleBUri);
340 assertEquals(body.thread[0].depth, -1);
341 assertEquals(body.thread[1].uri, cycleAUri);
342 assertEquals(body.thread[1].depth, 0);
343 },
344 );
345 } finally {
346 await cleanup();
347 }
348 },
349});