A very simple CLI tool for scanning your followers and ranking by your reply engagement
7
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 829 lines 29 kB view raw
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});