A very simple CLI tool for scanning your followers and ranking by your reply engagement
1import { test, expect, describe, beforeEach, afterEach } from "bun:test";
2import {
3 scoreFreshness,
4 scoreEngagement,
5 scoreRecency,
6 combineScores,
7 formatOutput,
8 getProfile,
9 getAllFollows,
10 getAllPosts,
11 getAllFollowRecords,
12 resolvePds,
13 setFetchImpl,
14 resetFetchImpl,
15 SCORE_DIRECT_REPLY,
16 SCORE_THREAD_REPLY,
17 SCORE_FRESHNESS_MAX,
18 SCORE_RECENCY_PENALTY_PER_DAY,
19 type Follow,
20 type FeedItem,
21 type ScoredEntry,
22} from "./index";
23
24describe("scoreFreshness", () => {
25 test("returns 0 for follows without a date", () => {
26 const follows = new Map<string, Follow>([
27 ["did:plc:abc", { did: "did:plc:abc", handle: "alice.bsky.social" }],
28 ]);
29 const followDates = new Map<string, Date>();
30
31 const scores = scoreFreshness(followDates, follows);
32
33 expect(scores.get("did:plc:abc")).toBe(0);
34 });
35
36 test("returns max score for follows from today", () => {
37 const follows = new Map<string, Follow>([
38 ["did:plc:abc", { did: "did:plc:abc", handle: "alice.bsky.social" }],
39 ]);
40 const followDates = new Map<string, Date>([
41 ["did:plc:abc", new Date()],
42 ]);
43
44 const scores = scoreFreshness(followDates, follows);
45
46 expect(scores.get("did:plc:abc")).toBe(SCORE_FRESHNESS_MAX);
47 });
48
49 test("decays score by 2 points per day", () => {
50 const follows = new Map<string, Follow>([
51 ["did:plc:abc", { did: "did:plc:abc", handle: "alice.bsky.social" }],
52 ]);
53 const fiveDaysAgo = new Date(Date.now() - 5 * 24 * 60 * 60 * 1000);
54 const followDates = new Map<string, Date>([
55 ["did:plc:abc", fiveDaysAgo],
56 ]);
57
58 const scores = scoreFreshness(followDates, follows);
59
60 expect(scores.get("did:plc:abc")).toBe(SCORE_FRESHNESS_MAX - 10);
61 });
62
63 test("returns 0 for very old follows", () => {
64 const follows = new Map<string, Follow>([
65 ["did:plc:abc", { did: "did:plc:abc", handle: "alice.bsky.social" }],
66 ]);
67 const longAgo = new Date(Date.now() - 100 * 24 * 60 * 60 * 1000);
68 const followDates = new Map<string, Date>([
69 ["did:plc:abc", longAgo],
70 ]);
71
72 const scores = scoreFreshness(followDates, follows);
73
74 expect(scores.get("did:plc:abc")).toBe(0);
75 });
76});
77
78describe("scoreEngagement", () => {
79 const myDid = "did:plc:me";
80
81 test("returns 0 for follows with no replies", () => {
82 const follows = new Map<string, Follow>([
83 ["did:plc:alice", { did: "did:plc:alice", handle: "alice.bsky.social" }],
84 ]);
85 const posts: FeedItem[] = [];
86
87 const scores = scoreEngagement(posts, follows, myDid);
88
89 expect(scores.get("did:plc:alice")).toBe(0);
90 });
91
92 test("scores direct replies with SCORE_DIRECT_REPLY points", () => {
93 const follows = new Map<string, Follow>([
94 ["did:plc:alice", { did: "did:plc:alice", handle: "alice.bsky.social" }],
95 ]);
96 const posts: FeedItem[] = [
97 {
98 post: {
99 uri: "at://did:plc:me/app.bsky.feed.post/1",
100 author: { did: myDid, handle: "me.bsky.social" },
101 record: {
102 reply: {
103 parent: { uri: "at://did:plc:alice/app.bsky.feed.post/1", cid: "cid1" },
104 root: { uri: "at://did:plc:alice/app.bsky.feed.post/1", cid: "cid1" },
105 },
106 },
107 },
108 reply: {
109 parent: { author: { did: "did:plc:alice", handle: "alice.bsky.social" } },
110 root: { author: { did: "did:plc:alice", handle: "alice.bsky.social" } },
111 },
112 },
113 ];
114
115 const scores = scoreEngagement(posts, follows, myDid);
116
117 expect(scores.get("did:plc:alice")).toBe(SCORE_DIRECT_REPLY);
118 });
119
120 test("scores thread replies with SCORE_THREAD_REPLY points", () => {
121 const follows = new Map<string, Follow>([
122 ["did:plc:alice", { did: "did:plc:alice", handle: "alice.bsky.social" }],
123 ["did:plc:bob", { did: "did:plc:bob", handle: "bob.bsky.social" }],
124 ]);
125 const posts: FeedItem[] = [
126 {
127 post: {
128 uri: "at://did:plc:me/app.bsky.feed.post/1",
129 author: { did: myDid, handle: "me.bsky.social" },
130 record: {
131 reply: {
132 parent: { uri: "at://did:plc:bob/app.bsky.feed.post/2", cid: "cid2" },
133 root: { uri: "at://did:plc:alice/app.bsky.feed.post/1", cid: "cid1" },
134 },
135 },
136 },
137 reply: {
138 parent: { author: { did: "did:plc:bob", handle: "bob.bsky.social" } },
139 root: { author: { did: "did:plc:alice", handle: "alice.bsky.social" } },
140 },
141 },
142 ];
143
144 const scores = scoreEngagement(posts, follows, myDid);
145
146 expect(scores.get("did:plc:alice")).toBe(SCORE_THREAD_REPLY);
147 expect(scores.get("did:plc:bob")).toBe(SCORE_DIRECT_REPLY);
148 });
149
150 test("accumulates scores for multiple replies", () => {
151 const follows = new Map<string, Follow>([
152 ["did:plc:alice", { did: "did:plc:alice", handle: "alice.bsky.social" }],
153 ]);
154 const posts: FeedItem[] = [
155 {
156 post: {
157 uri: "at://did:plc:me/app.bsky.feed.post/1",
158 author: { did: myDid, handle: "me.bsky.social" },
159 record: {
160 reply: {
161 parent: { uri: "at://did:plc:alice/app.bsky.feed.post/1", cid: "cid1" },
162 root: { uri: "at://did:plc:alice/app.bsky.feed.post/1", cid: "cid1" },
163 },
164 },
165 },
166 reply: {
167 parent: { author: { did: "did:plc:alice", handle: "alice.bsky.social" } },
168 root: { author: { did: "did:plc:alice", handle: "alice.bsky.social" } },
169 },
170 },
171 {
172 post: {
173 uri: "at://did:plc:me/app.bsky.feed.post/2",
174 author: { did: myDid, handle: "me.bsky.social" },
175 record: {
176 reply: {
177 parent: { uri: "at://did:plc:alice/app.bsky.feed.post/2", cid: "cid2" },
178 root: { uri: "at://did:plc:alice/app.bsky.feed.post/2", cid: "cid2" },
179 },
180 },
181 },
182 reply: {
183 parent: { author: { did: "did:plc:alice", handle: "alice.bsky.social" } },
184 root: { author: { did: "did:plc:alice", handle: "alice.bsky.social" } },
185 },
186 },
187 ];
188
189 const scores = scoreEngagement(posts, follows, myDid);
190
191 expect(scores.get("did:plc:alice")).toBe(SCORE_DIRECT_REPLY * 2);
192 });
193
194 test("ignores replies to self", () => {
195 const follows = new Map<string, Follow>([
196 [myDid, { did: myDid, handle: "me.bsky.social" }],
197 ]);
198 const posts: FeedItem[] = [
199 {
200 post: {
201 uri: "at://did:plc:me/app.bsky.feed.post/2",
202 author: { did: myDid, handle: "me.bsky.social" },
203 record: {
204 reply: {
205 parent: { uri: "at://did:plc:me/app.bsky.feed.post/1", cid: "cid1" },
206 root: { uri: "at://did:plc:me/app.bsky.feed.post/1", cid: "cid1" },
207 },
208 },
209 },
210 reply: {
211 parent: { author: { did: myDid, handle: "me.bsky.social" } },
212 root: { author: { did: myDid, handle: "me.bsky.social" } },
213 },
214 },
215 ];
216
217 const scores = scoreEngagement(posts, follows, myDid);
218
219 expect(scores.get(myDid)).toBe(0);
220 });
221
222 test("ignores replies to accounts not followed", () => {
223 const follows = new Map<string, Follow>([
224 ["did:plc:alice", { did: "did:plc:alice", handle: "alice.bsky.social" }],
225 ]);
226 const posts: FeedItem[] = [
227 {
228 post: {
229 uri: "at://did:plc:me/app.bsky.feed.post/1",
230 author: { did: myDid, handle: "me.bsky.social" },
231 record: {
232 reply: {
233 parent: { uri: "at://did:plc:stranger/app.bsky.feed.post/1", cid: "cid1" },
234 root: { uri: "at://did:plc:stranger/app.bsky.feed.post/1", cid: "cid1" },
235 },
236 },
237 },
238 reply: {
239 parent: { author: { did: "did:plc:stranger", handle: "stranger.bsky.social" } },
240 root: { author: { did: "did:plc:stranger", handle: "stranger.bsky.social" } },
241 },
242 },
243 ];
244
245 const scores = scoreEngagement(posts, follows, myDid);
246
247 expect(scores.get("did:plc:alice")).toBe(0);
248 });
249
250 test("skips posts without replies", () => {
251 const follows = new Map<string, Follow>([
252 ["did:plc:alice", { did: "did:plc:alice", handle: "alice.bsky.social" }],
253 ]);
254 const posts: FeedItem[] = [
255 {
256 post: {
257 uri: "at://did:plc:me/app.bsky.feed.post/1",
258 author: { did: myDid, handle: "me.bsky.social" },
259 record: {},
260 },
261 },
262 ];
263
264 const scores = scoreEngagement(posts, follows, myDid);
265
266 expect(scores.get("did:plc:alice")).toBe(0);
267 });
268});
269
270describe("scoreRecency", () => {
271 const myDid = "did:plc:me";
272 const now = new Date("2025-01-15T12:00:00Z");
273
274 test("penalizes follows with no engagement based on follow age", () => {
275 const follows = new Map<string, Follow>([
276 ["did:plc:alice", { did: "did:plc:alice", handle: "alice.bsky.social" }],
277 ]);
278 const followDates = new Map<string, Date>([
279 ["did:plc:alice", new Date("2025-01-05T12:00:00Z")], // 10 days ago
280 ]);
281 const posts: FeedItem[] = [];
282
283 const scores = scoreRecency(posts, follows, myDid, followDates, now);
284
285 expect(scores.get("did:plc:alice")).toBe(-10 * SCORE_RECENCY_PENALTY_PER_DAY);
286 });
287
288 test("returns 0 for follows with no engagement and no follow date", () => {
289 const follows = new Map<string, Follow>([
290 ["did:plc:alice", { did: "did:plc:alice", handle: "alice.bsky.social" }],
291 ]);
292 const followDates = new Map<string, Date>();
293 const posts: FeedItem[] = [];
294
295 const scores = scoreRecency(posts, follows, myDid, followDates, now);
296
297 expect(scores.get("did:plc:alice")).toBe(0);
298 });
299
300 test("returns 0 penalty for engagement from today", () => {
301 const follows = new Map<string, Follow>([
302 ["did:plc:alice", { did: "did:plc:alice", handle: "alice.bsky.social" }],
303 ]);
304 const followDates = new Map<string, Date>();
305 const posts: FeedItem[] = [
306 {
307 post: {
308 uri: "at://did:plc:me/app.bsky.feed.post/1",
309 author: { did: myDid, handle: "me.bsky.social" },
310 record: {
311 createdAt: "2025-01-15T10:00:00Z",
312 reply: {
313 parent: { uri: "at://did:plc:alice/app.bsky.feed.post/1", cid: "cid1" },
314 root: { uri: "at://did:plc:alice/app.bsky.feed.post/1", cid: "cid1" },
315 },
316 },
317 },
318 reply: {
319 parent: { author: { did: "did:plc:alice", handle: "alice.bsky.social" } },
320 root: { author: { did: "did:plc:alice", handle: "alice.bsky.social" } },
321 },
322 },
323 ];
324
325 const scores = scoreRecency(posts, follows, myDid, followDates, now);
326
327 expect(scores.get("did:plc:alice")).toBe(0);
328 });
329
330 test("subtracts 1 point per day since last engagement", () => {
331 const follows = new Map<string, Follow>([
332 ["did:plc:alice", { did: "did:plc:alice", handle: "alice.bsky.social" }],
333 ]);
334 const followDates = new Map<string, Date>();
335 // Post from 10 days ago
336 const posts: FeedItem[] = [
337 {
338 post: {
339 uri: "at://did:plc:me/app.bsky.feed.post/1",
340 author: { did: myDid, handle: "me.bsky.social" },
341 record: {
342 createdAt: "2025-01-05T10:00:00Z",
343 reply: {
344 parent: { uri: "at://did:plc:alice/app.bsky.feed.post/1", cid: "cid1" },
345 root: { uri: "at://did:plc:alice/app.bsky.feed.post/1", cid: "cid1" },
346 },
347 },
348 },
349 reply: {
350 parent: { author: { did: "did:plc:alice", handle: "alice.bsky.social" } },
351 root: { author: { did: "did:plc:alice", handle: "alice.bsky.social" } },
352 },
353 },
354 ];
355
356 const scores = scoreRecency(posts, follows, myDid, followDates, now);
357
358 expect(scores.get("did:plc:alice")).toBe(-10 * SCORE_RECENCY_PENALTY_PER_DAY);
359 });
360
361 test("uses most recent engagement when multiple replies exist", () => {
362 const follows = new Map<string, Follow>([
363 ["did:plc:alice", { did: "did:plc:alice", handle: "alice.bsky.social" }],
364 ]);
365 const followDates = new Map<string, Date>();
366 const posts: FeedItem[] = [
367 // Older post from 20 days ago
368 {
369 post: {
370 uri: "at://did:plc:me/app.bsky.feed.post/1",
371 author: { did: myDid, handle: "me.bsky.social" },
372 record: {
373 createdAt: "2024-12-26T10:00:00Z",
374 reply: {
375 parent: { uri: "at://did:plc:alice/app.bsky.feed.post/1", cid: "cid1" },
376 root: { uri: "at://did:plc:alice/app.bsky.feed.post/1", cid: "cid1" },
377 },
378 },
379 },
380 reply: {
381 parent: { author: { did: "did:plc:alice", handle: "alice.bsky.social" } },
382 root: { author: { did: "did:plc:alice", handle: "alice.bsky.social" } },
383 },
384 },
385 // More recent post from 5 days ago
386 {
387 post: {
388 uri: "at://did:plc:me/app.bsky.feed.post/2",
389 author: { did: myDid, handle: "me.bsky.social" },
390 record: {
391 createdAt: "2025-01-10T10:00:00Z",
392 reply: {
393 parent: { uri: "at://did:plc:alice/app.bsky.feed.post/2", cid: "cid2" },
394 root: { uri: "at://did:plc:alice/app.bsky.feed.post/2", cid: "cid2" },
395 },
396 },
397 },
398 reply: {
399 parent: { author: { did: "did:plc:alice", handle: "alice.bsky.social" } },
400 root: { author: { did: "did:plc:alice", handle: "alice.bsky.social" } },
401 },
402 },
403 ];
404
405 const scores = scoreRecency(posts, follows, myDid, followDates, now);
406
407 // Should use the 5-day-old engagement, not the 20-day-old one
408 expect(scores.get("did:plc:alice")).toBe(-5 * SCORE_RECENCY_PENALTY_PER_DAY);
409 });
410
411 test("tracks thread participation for recency", () => {
412 const follows = new Map<string, Follow>([
413 ["did:plc:alice", { did: "did:plc:alice", handle: "alice.bsky.social" }],
414 ["did:plc:bob", { did: "did:plc:bob", handle: "bob.bsky.social" }],
415 ]);
416 const followDates = new Map<string, Date>();
417 // Reply to bob in alice's thread from 7 days ago
418 const posts: FeedItem[] = [
419 {
420 post: {
421 uri: "at://did:plc:me/app.bsky.feed.post/1",
422 author: { did: myDid, handle: "me.bsky.social" },
423 record: {
424 createdAt: "2025-01-08T10:00:00Z",
425 reply: {
426 parent: { uri: "at://did:plc:bob/app.bsky.feed.post/2", cid: "cid2" },
427 root: { uri: "at://did:plc:alice/app.bsky.feed.post/1", cid: "cid1" },
428 },
429 },
430 },
431 reply: {
432 parent: { author: { did: "did:plc:bob", handle: "bob.bsky.social" } },
433 root: { author: { did: "did:plc:alice", handle: "alice.bsky.social" } },
434 },
435 },
436 ];
437
438 const scores = scoreRecency(posts, follows, myDid, followDates, now);
439
440 // Both should have recency tracked
441 expect(scores.get("did:plc:alice")).toBe(-7 * SCORE_RECENCY_PENALTY_PER_DAY);
442 expect(scores.get("did:plc:bob")).toBe(-7 * SCORE_RECENCY_PENALTY_PER_DAY);
443 });
444
445 test("ignores replies to self", () => {
446 const follows = new Map<string, Follow>([
447 [myDid, { did: myDid, handle: "me.bsky.social" }],
448 ]);
449 const followDates = new Map<string, Date>();
450 const posts: FeedItem[] = [
451 {
452 post: {
453 uri: "at://did:plc:me/app.bsky.feed.post/2",
454 author: { did: myDid, handle: "me.bsky.social" },
455 record: {
456 createdAt: "2025-01-10T10:00:00Z",
457 reply: {
458 parent: { uri: "at://did:plc:me/app.bsky.feed.post/1", cid: "cid1" },
459 root: { uri: "at://did:plc:me/app.bsky.feed.post/1", cid: "cid1" },
460 },
461 },
462 },
463 reply: {
464 parent: { author: { did: myDid, handle: "me.bsky.social" } },
465 root: { author: { did: myDid, handle: "me.bsky.social" } },
466 },
467 },
468 ];
469
470 const scores = scoreRecency(posts, follows, myDid, followDates, now);
471
472 expect(scores.get(myDid)).toBe(0);
473 });
474
475 test("ignores posts without createdAt and uses follow date for penalty", () => {
476 const follows = new Map<string, Follow>([
477 ["did:plc:alice", { did: "did:plc:alice", handle: "alice.bsky.social" }],
478 ]);
479 const followDates = new Map<string, Date>([
480 ["did:plc:alice", new Date("2025-01-10T12:00:00Z")], // 5 days ago
481 ]);
482 const posts: FeedItem[] = [
483 {
484 post: {
485 uri: "at://did:plc:me/app.bsky.feed.post/1",
486 author: { did: myDid, handle: "me.bsky.social" },
487 record: {
488 // No createdAt
489 reply: {
490 parent: { uri: "at://did:plc:alice/app.bsky.feed.post/1", cid: "cid1" },
491 root: { uri: "at://did:plc:alice/app.bsky.feed.post/1", cid: "cid1" },
492 },
493 },
494 },
495 reply: {
496 parent: { author: { did: "did:plc:alice", handle: "alice.bsky.social" } },
497 root: { author: { did: "did:plc:alice", handle: "alice.bsky.social" } },
498 },
499 },
500 ];
501
502 const scores = scoreRecency(posts, follows, myDid, followDates, now);
503
504 // No valid engagement due to missing createdAt, so penalty based on follow date (5 days)
505 expect(scores.get("did:plc:alice")).toBe(-5 * SCORE_RECENCY_PENALTY_PER_DAY);
506 });
507});
508
509describe("combineScores", () => {
510 test("skips recency penalty when freshness is greater than zero", () => {
511 const follows = new Map<string, Follow>([
512 ["did:plc:fresh", { did: "did:plc:fresh", handle: "fresh.bsky.social" }],
513 ["did:plc:stale", { did: "did:plc:stale", handle: "stale.bsky.social" }],
514 ]);
515 const engagementScores = new Map<string, number>([
516 ["did:plc:fresh", 20],
517 ["did:plc:stale", 20],
518 ]);
519 const freshnessScores = new Map<string, number>([
520 ["did:plc:fresh", 30], // Fresh follow (freshness > 0)
521 ["did:plc:stale", 0], // Stale follow (freshness = 0)
522 ]);
523 const recencyScores = new Map<string, number>([
524 ["did:plc:fresh", -15], // Would be penalized, but freshness > 0
525 ["did:plc:stale", -15], // Should be penalized
526 ]);
527
528 const results = combineScores(follows, engagementScores, freshnessScores, recencyScores);
529
530 const freshEntry = results.find(e => e.handle === "fresh.bsky.social")!;
531 const staleEntry = results.find(e => e.handle === "stale.bsky.social")!;
532
533 // Fresh follow: recency should be 0 (skipped), score = 20 + 30 + 0 = 50
534 expect(freshEntry.recency).toBe(0);
535 expect(freshEntry.score).toBe(50);
536
537 // Stale follow: recency should be applied, score = 20 + 0 + (-15) = 5
538 expect(staleEntry.recency).toBe(-15);
539 expect(staleEntry.score).toBe(5);
540 });
541
542 test("applies recency penalty when freshness is zero", () => {
543 const follows = new Map<string, Follow>([
544 ["did:plc:alice", { did: "did:plc:alice", handle: "alice.bsky.social" }],
545 ]);
546 const engagementScores = new Map<string, number>([["did:plc:alice", 10]]);
547 const freshnessScores = new Map<string, number>([["did:plc:alice", 0]]);
548 const recencyScores = new Map<string, number>([["did:plc:alice", -5]]);
549
550 const results = combineScores(follows, engagementScores, freshnessScores, recencyScores);
551
552 expect(results[0].recency).toBe(-5);
553 expect(results[0].score).toBe(5); // 10 + 0 + (-5)
554 });
555
556 test("sorts results by score descending", () => {
557 const follows = new Map<string, Follow>([
558 ["did:plc:low", { did: "did:plc:low", handle: "low.bsky.social" }],
559 ["did:plc:high", { did: "did:plc:high", handle: "high.bsky.social" }],
560 ["did:plc:mid", { did: "did:plc:mid", handle: "mid.bsky.social" }],
561 ]);
562 const engagementScores = new Map<string, number>([
563 ["did:plc:low", 5],
564 ["did:plc:high", 50],
565 ["did:plc:mid", 25],
566 ]);
567 const freshnessScores = new Map<string, number>();
568 const recencyScores = new Map<string, number>();
569
570 const results = combineScores(follows, engagementScores, freshnessScores, recencyScores);
571
572 expect(results[0].handle).toBe("high.bsky.social");
573 expect(results[1].handle).toBe("mid.bsky.social");
574 expect(results[2].handle).toBe("low.bsky.social");
575 });
576});
577
578describe("formatOutput", () => {
579 test("outputs header as first line", () => {
580 const entries: ScoredEntry[] = [];
581
582 const output = formatOutput(entries);
583
584 expect(output).toBe("handle score engagement freshness recency");
585 });
586
587 test("formats single entry correctly", () => {
588 const entries: ScoredEntry[] = [
589 { handle: "alice.bsky.social", score: 50, engagement: 30, freshness: 40, recency: -20 },
590 ];
591
592 const output = formatOutput(entries);
593 const lines = output.split("\n");
594
595 expect(lines).toHaveLength(2);
596 expect(lines[0]).toBe("handle score engagement freshness recency");
597 expect(lines[1]).toBe("alice.bsky.social 50 30 40 -20");
598 });
599
600 test("formats multiple entries in order", () => {
601 const entries: ScoredEntry[] = [
602 { handle: "alice.bsky.social", score: 50, engagement: 30, freshness: 40, recency: -20 },
603 { handle: "bob.bsky.social", score: 25, engagement: 10, freshness: 20, recency: -5 },
604 { handle: "carol.bsky.social", score: 0, engagement: 0, freshness: 0, recency: 0 },
605 ];
606
607 const output = formatOutput(entries);
608 const lines = output.split("\n");
609
610 expect(lines).toHaveLength(4);
611 expect(lines[0]).toBe("handle score engagement freshness recency");
612 expect(lines[1]).toBe("alice.bsky.social 50 30 40 -20");
613 expect(lines[2]).toBe("bob.bsky.social 25 10 20 -5");
614 expect(lines[3]).toBe("carol.bsky.social 0 0 0 0");
615 });
616
617 test("handles negative scores correctly", () => {
618 const entries: ScoredEntry[] = [
619 { handle: "alice.bsky.social", score: -15, engagement: 10, freshness: 5, recency: -30 },
620 ];
621
622 const output = formatOutput(entries);
623 const lines = output.split("\n");
624
625 expect(lines[1]).toBe("alice.bsky.social -15 10 5 -30");
626 });
627
628 test("output is space-separated and parseable", () => {
629 const entries: ScoredEntry[] = [
630 { handle: "alice.bsky.social", score: 100, engagement: 50, freshness: 50, recency: 0 },
631 ];
632
633 const output = formatOutput(entries);
634 const lines = output.split("\n");
635 const headerFields = lines[0].split(" ");
636 const dataFields = lines[1].split(" ");
637
638 expect(headerFields).toEqual(["handle", "score", "engagement", "freshness", "recency"]);
639 expect(dataFields).toHaveLength(5);
640 expect(dataFields[0]).toBe("alice.bsky.social");
641 expect(parseInt(dataFields[1])).toBe(100);
642 expect(parseInt(dataFields[2])).toBe(50);
643 expect(parseInt(dataFields[3])).toBe(50);
644 expect(parseInt(dataFields[4])).toBe(0);
645 });
646});
647
648// Integration tests with mock fetch
649describe("API functions with mock fetch", () => {
650 afterEach(() => {
651 resetFetchImpl();
652 });
653
654 test("getProfile returns profile data", async () => {
655 const mockProfile = {
656 did: "did:plc:alice",
657 handle: "alice.bsky.social",
658 displayName: "Alice",
659 };
660
661 setFetchImpl(async () => new Response(JSON.stringify(mockProfile)));
662
663 const profile = await getProfile("alice.bsky.social");
664
665 expect(profile.did).toBe("did:plc:alice");
666 expect(profile.handle).toBe("alice.bsky.social");
667 });
668
669 test("resolvePds extracts PDS URL from DID document", async () => {
670 const mockDidDoc = {
671 id: "did:plc:alice",
672 service: [
673 {
674 id: "#atproto_pds",
675 type: "AtprotoPersonalDataServer",
676 serviceEndpoint: "https://pds.example.com",
677 },
678 ],
679 };
680
681 setFetchImpl(async () => new Response(JSON.stringify(mockDidDoc)));
682
683 const pdsUrl = await resolvePds("did:plc:alice");
684
685 expect(pdsUrl).toBe("https://pds.example.com");
686 });
687
688 test("getAllFollows paginates through all follows", async () => {
689 let callCount = 0;
690 setFetchImpl(async () => {
691 callCount++;
692 if (callCount === 1) {
693 return new Response(JSON.stringify({
694 follows: [
695 { did: "did:plc:bob", handle: "bob.bsky.social" },
696 { did: "did:plc:carol", handle: "carol.bsky.social" },
697 ],
698 cursor: "page2",
699 }));
700 }
701 return new Response(JSON.stringify({
702 follows: [
703 { did: "did:plc:dave", handle: "dave.bsky.social" },
704 ],
705 }));
706 });
707
708 const follows = await getAllFollows("did:plc:alice");
709
710 expect(follows.size).toBe(3);
711 expect(follows.has("did:plc:bob")).toBe(true);
712 expect(follows.has("did:plc:carol")).toBe(true);
713 expect(follows.has("did:plc:dave")).toBe(true);
714 expect(callCount).toBe(2);
715 });
716
717 test("getAllPosts paginates through all posts", async () => {
718 let callCount = 0;
719 setFetchImpl(async () => {
720 callCount++;
721 if (callCount === 1) {
722 return new Response(JSON.stringify({
723 feed: [
724 { post: { uri: "at://did:plc:alice/post/1", author: { did: "did:plc:alice", handle: "alice.bsky.social" }, record: {} } },
725 ],
726 cursor: "page2",
727 }));
728 }
729 return new Response(JSON.stringify({
730 feed: [
731 { post: { uri: "at://did:plc:alice/post/2", author: { did: "did:plc:alice", handle: "alice.bsky.social" }, record: {} } },
732 ],
733 }));
734 });
735
736 const posts = await getAllPosts("did:plc:alice");
737
738 expect(posts.length).toBe(2);
739 expect(callCount).toBe(2);
740 });
741});
742
743// Live integration tests against the real Bluesky API
744describe("live integration with alice.bsky.social", () => {
745 test("fetches profile for alice.bsky.social", async () => {
746 const profile = await getProfile("alice.bsky.social");
747
748 expect(profile.did).toStartWith("did:plc:");
749 expect(profile.handle).toBe("alice.bsky.social");
750 });
751
752 test("resolves PDS for alice.bsky.social", async () => {
753 const profile = await getProfile("alice.bsky.social");
754 const pdsUrl = await resolvePds(profile.did);
755
756 expect(pdsUrl).toStartWith("https://");
757 });
758
759 test("fetches follows for alice.bsky.social", async () => {
760 const profile = await getProfile("alice.bsky.social");
761 const follows = await getAllFollows(profile.did);
762
763 // Alice follows people, so we should get some results
764 expect(follows.size).toBeGreaterThan(0);
765
766 // Verify the structure of a follow entry
767 const firstFollow = follows.values().next().value;
768 expect(firstFollow.did).toStartWith("did:");
769 expect(typeof firstFollow.handle).toBe("string");
770 });
771
772 test("fetches posts for alice.bsky.social", async () => {
773 const profile = await getProfile("alice.bsky.social");
774 const posts = await getAllPosts(profile.did);
775
776 // Alice has posted, so we should get some results
777 expect(posts.length).toBeGreaterThan(0);
778
779 // Verify the structure of a post entry
780 const firstPost = posts[0];
781 expect(firstPost.post.uri).toStartWith("at://");
782 expect(firstPost.post.author.did).toBe(profile.did);
783 });
784
785 test("fetches follow records with dates for alice.bsky.social", async () => {
786 const profile = await getProfile("alice.bsky.social");
787 const pdsUrl = await resolvePds(profile.did);
788 const followDates = await getAllFollowRecords(pdsUrl, profile.did);
789
790 // Should have timestamps for follows
791 expect(followDates.size).toBeGreaterThan(0);
792
793 // Verify dates are valid
794 const firstDate = followDates.values().next().value;
795 expect(firstDate).toBeInstanceOf(Date);
796 expect(firstDate.getTime()).toBeGreaterThan(0);
797 });
798
799 test("runs full scoring pipeline for alice.bsky.social", async () => {
800 const profile = await getProfile("alice.bsky.social");
801 const pdsUrl = await resolvePds(profile.did);
802
803 const follows = await getAllFollows(profile.did);
804 const followDates = await getAllFollowRecords(pdsUrl, profile.did);
805 const posts = await getAllPosts(profile.did);
806
807 // Run scoring
808 const engagementScores = scoreEngagement(posts, follows, profile.did);
809 const freshnessScores = scoreFreshness(followDates, follows);
810 const recencyScores = scoreRecency(posts, follows, profile.did, followDates);
811
812 // Combine and format
813 const results = combineScores(follows, engagementScores, freshnessScores, recencyScores);
814 const output = formatOutput(results);
815
816 // Verify output structure
817 const lines = output.split("\n");
818 expect(lines[0]).toBe("handle score engagement freshness recency");
819 expect(lines.length).toBeGreaterThan(1);
820
821 // Verify each data line has 5 fields
822 for (let i = 1; i < lines.length; i++) {
823 const fields = lines[i].split(" ");
824 expect(fields.length).toBe(5);
825 expect(fields[0]).toContain("."); // handle has a dot
826 expect(Number.isInteger(parseInt(fields[1]))).toBe(true); // score is a number
827 }
828 });
829});