Daily Bluesky bot for AT Mot. Invites players and congratulates yesterday's solvers.
0

Configure Feed

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

test: cover idempotency matching and backlink pagination

The two riskiest untested paths flagged by review. post.test.ts asserts
alreadyPosted matches today's marker, never cross-matches EN vs FR
markers for the same puzzle, and allows the post when the listRecords
read fails. constellation.test.ts exercises collectBacklinks cursor
following, cap slicing with the truncated flag, and mid-walk page
failure surfacing as truncated.

+121
+79
test/constellation.test.ts
··· 1 + import { describe, it, expect, vi, afterEach } from 'vitest'; 2 + import { dailyResultBacklinks, dailyPlayerCount } from '../src/constellation.js'; 3 + 4 + interface Page { 5 + records: Array<{ did: string; collection: string; rkey: string }>; 6 + cursor?: string | null; 7 + } 8 + 9 + const rec = (did: string) => ({ did, collection: 'bzh.herve.atmot.result', rkey: 'r' }); 10 + 11 + /** Stub fetch to serve a sequence of getBacklinks pages keyed by cursor, plus a fixed count. */ 12 + function stubFetch(pages: Page[], opts: { total?: number; failOnCursor?: string } = {}) { 13 + vi.stubGlobal('fetch', async (url: URL) => { 14 + const u = new URL(url); 15 + if (u.pathname.includes('distinct-dids')) { 16 + return new Response(JSON.stringify({ total: opts.total ?? 0 }), { status: 200 }); 17 + } 18 + const cursor = u.searchParams.get('cursor'); 19 + if (opts.failOnCursor !== undefined && cursor === opts.failOnCursor) { 20 + return new Response('boom', { status: 500 }); 21 + } 22 + const idx = cursor ? Number(cursor) : 0; 23 + const page = pages[idx] ?? { records: [], cursor: null }; 24 + return new Response(JSON.stringify(page), { status: 200 }); 25 + }); 26 + } 27 + 28 + afterEach(() => vi.unstubAllGlobals()); 29 + 30 + describe('dailyResultBacklinks pagination', () => { 31 + it('returns a single complete page as not truncated', async () => { 32 + stubFetch([{ records: [rec('did:a'), rec('did:b')], cursor: null }]); 33 + const { records, truncated } = await dailyResultBacklinks('en', 5, 20); 34 + expect(records.map((r) => r.did)).toEqual(['did:a', 'did:b']); 35 + expect(truncated).toBe(false); 36 + }); 37 + 38 + it('follows the cursor across pages until it runs out', async () => { 39 + stubFetch([ 40 + { records: [rec('did:a')], cursor: '1' }, 41 + { records: [rec('did:b')], cursor: null }, 42 + ]); 43 + const { records, truncated } = await dailyResultBacklinks('en', 5, 20); 44 + expect(records.map((r) => r.did)).toEqual(['did:a', 'did:b']); 45 + expect(truncated).toBe(false); 46 + }); 47 + 48 + it('slices to cap and reports truncated when more than cap records exist', async () => { 49 + stubFetch([{ records: [rec('did:a'), rec('did:b'), rec('did:c')], cursor: null }]); 50 + const { records, truncated } = await dailyResultBacklinks('en', 5, 2); 51 + expect(records).toHaveLength(2); 52 + expect(truncated).toBe(true); 53 + }); 54 + 55 + it('reports truncated when a page fetch fails mid-walk', async () => { 56 + stubFetch( 57 + [ 58 + { records: [rec('did:a')], cursor: '1' }, 59 + { records: [rec('did:b')], cursor: '2' }, 60 + ], 61 + { failOnCursor: '1' }, 62 + ); 63 + const { records, truncated } = await dailyResultBacklinks('en', 5, 20); 64 + expect(records.map((r) => r.did)).toEqual(['did:a']); 65 + expect(truncated).toBe(true); 66 + }); 67 + }); 68 + 69 + describe('dailyPlayerCount', () => { 70 + it('reads the distinct-DID total', async () => { 71 + stubFetch([], { total: 42 }); 72 + expect(await dailyPlayerCount('en', 5)).toBe(42); 73 + }); 74 + 75 + it('returns null on a server error', async () => { 76 + vi.stubGlobal('fetch', async () => new Response('nope', { status: 503 })); 77 + expect(await dailyPlayerCount('en', 5)).toBeNull(); 78 + }); 79 + });
+42
test/post.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { alreadyPosted, type BotSession } from '../src/post.js'; 3 + import { postMarker } from '../src/compose.js'; 4 + 5 + /** A fake session whose listRecords returns `texts` (or fails when `ok` is false). */ 6 + function fakeSession(texts: string[], ok = true): BotSession { 7 + return { 8 + did: 'did:plc:bot', 9 + client: { 10 + get: async () => ({ 11 + ok, 12 + data: { records: texts.map((text) => ({ value: { text } })) }, 13 + }), 14 + }, 15 + } as unknown as BotSession; 16 + } 17 + 18 + describe('alreadyPosted', () => { 19 + it('detects today’s marker in a recent post', async () => { 20 + const marker = postMarker('en', 5); // "AT Mot #5" 21 + const session = fakeSession([`🟩 ${marker} is live! ...`]); 22 + expect(await alreadyPosted(session, marker)).toBe(true); 23 + }); 24 + 25 + it('returns false when no recent post carries the marker', async () => { 26 + const session = fakeSession(['🟩 AT Mot #4 is live! ...', 'unrelated post']); 27 + expect(await alreadyPosted(session, postMarker('en', 5))).toBe(false); 28 + }); 29 + 30 + it('does not cross-match EN and FR markers for the same puzzle', async () => { 31 + const enSession = fakeSession([`🟩 ${postMarker('fr', 5)} est en ligne ! ...`]); 32 + expect(await alreadyPosted(enSession, postMarker('en', 5))).toBe(false); 33 + 34 + const frSession = fakeSession([`🟩 ${postMarker('en', 5)} is live! ...`]); 35 + expect(await alreadyPosted(frSession, postMarker('fr', 5))).toBe(false); 36 + }); 37 + 38 + it('allows the post (returns false) when the listRecords read fails', async () => { 39 + const session = fakeSession([`🟩 ${postMarker('en', 5)} is live! ...`], false); 40 + expect(await alreadyPosted(session, postMarker('en', 5))).toBe(false); 41 + }); 42 + });