import { afterEach, describe, expect, test } from 'bun:test'; import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { runCli } from '../src/cli.ts'; import type { FeedSnapshot, PostNode } from '../src/model.ts'; import { ReadingStore } from '../src/store.ts'; const VIEWER_DID = 'did:plc:ewvi7nxzyoun6zhxrhs64oiz'; const OTHER_VIEWER_DID = 'did:plc:js47frzswl2ezb6v235hlcz2'; const AUTHOR_DID = 'did:plc:ragtjsm2j2vknwkz3zp4oxrd'; const REPOSTER_DID = 'did:plc:yckmmocpnqkrdwkp2q5rdipl'; const CID = 'bafyreigh2akiscaildcw453k5p2q5w2dmqnj4dnrz3llrqmk5f3x7tdpiy'; const PDS = 'https://pds.example'; const POST_ONE = `at://${AUTHOR_DID}/app.bsky.feed.post/one`; const POST_TWO = `at://${AUTHOR_DID}/app.bsky.feed.post/two`; const POST_THREE = `at://${AUTHOR_DID}/app.bsky.feed.post/three`; const originalFetch = globalThis.fetch; const originalLog = console.log; const temporaryDirectories: string[] = []; type FetchResponder = (request: Request) => Response | Promise; interface AuthFixture { path: string; identifier: string; password: string; accessValue: string; refreshValue: string; } function temporaryDirectory(): string { const directory = mkdtempSync(join(tmpdir(), 'perch-feed-cli-')); temporaryDirectories.push(directory); return directory; } function authFixture(): AuthFixture { const directory = temporaryDirectory(); const fixture = { path: join(directory, 'mcp.json'), identifier: `fixture-${crypto.randomUUID()}.invalid`, password: `fixture-${crypto.randomUUID()}`, accessValue: `fixture-${crypto.randomUUID()}`, refreshValue: `fixture-${crypto.randomUUID()}`, }; writeFileSync(fixture.path, JSON.stringify({ mcpServers: { atproto: { env: { BSKY_IDENTIFIER: fixture.identifier, BSKY_APP_PASSWORD: fixture.password, }, }, }, }), { mode: 0o600 }); return fixture; } function apiPost(uri: string, text: string): Record { return { $type: 'app.bsky.feed.defs#postView', uri, cid: CID, author: { did: AUTHOR_DID, handle: 'author.test', displayName: 'Author' }, record: { $type: 'app.bsky.feed.post', text, createdAt: '2026-09-05T10:00:00.000Z', }, indexedAt: '2026-09-05T10:00:01.000Z', replyCount: 0, likeCount: 0, repostCount: 0, quoteCount: 0, }; } function jsonResponse(data: unknown, status = 200): Response { return new Response(JSON.stringify(data), { status, headers: { 'content-type': 'application/json' }, }); } function installFetch(responder: FetchResponder): Request[] { const requests: Request[] = []; globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { const request = input instanceof Request && init === undefined ? input : new Request(input, init); requests.push(request.clone()); return responder(request); }) as typeof fetch; return requests; } function sessionResponse(auth: AuthFixture, viewerDid = VIEWER_DID): Response { return jsonResponse({ did: viewerDid, handle: 'viewer.test', accessJwt: auth.accessValue, refreshJwt: auth.refreshValue, active: true, }); } function authFlags(auth: AuthFixture): string[] { return ['--auth', '--auth-config', auth.path, '--pds', PDS]; } async function runJson(database: string, arguments_: string[]): Promise { const lines: string[] = []; console.log = (...values: unknown[]) => lines.push(values.map(String).join(' ')); try { await runCli([...arguments_, '--db', database, '--json']); } finally { console.log = originalLog; } return JSON.parse(lines.join('\n')); } function storedFeed(source: FeedSnapshot['source'], cursor?: string): FeedSnapshot { const node: PostNode = { uri: POST_ONE, availability: 'available', author: { did: AUTHOR_DID, handle: 'author.test', displayName: 'Author' }, text: 'stored feed', createdAt: '2026-09-05T10:00:00.000Z', indexedAt: '2026-09-05T10:00:01.000Z', replies: [], repliesFetched: false, facets: [], media: [], quotes: [], cards: [], labels: [], unsupported: [], counts: {}, }; return { kind: 'feed', feed: 'following', fetchedAt: '2026-09-05T10:01:00.000Z', source, entries: [{ id: '1', postUri: POST_ONE }], nodes: [node], limit: 20, ...(cursor !== undefined ? { cursor } : {}), maxNodes: 600, locallyTruncated: false, warnings: [], }; } afterEach(() => { globalThis.fetch = originalFetch; console.log = originalLog; for (const directory of temporaryDirectories.splice(0)) rmSync(directory, { recursive: true, force: true }); }); describe('following-feed CLI navigation', () => { test('requires explicit authentication and a bounded limit before any request or session movement', async () => { const directory = temporaryDirectory(); const database = join(directory, 'reader.sqlite'); const requests = installFetch(() => jsonResponse({ error: 'UnexpectedRequest' }, 500)); await expect(runCli(['following', '--db', database, '--json'])) .rejects.toThrow('following requires explicit --auth.'); await expect(runCli(['following', '--auth', '--limit', '0', '--db', database, '--json'])) .rejects.toThrow('--limit must be an integer from 1 to 100.'); await expect(runCli(['following', '--auth', '--limit', '101', '--db', database, '--json'])) .rejects.toThrow('--limit must be an integer from 1 to 100.'); expect(requests).toHaveLength(0); const store = new ReadingStore(database); expect(store.sessions()).toEqual([]); store.close(); }); test('stores separate pages, opens an occurrence encounter, and restores exact local state', async () => { const directory = temporaryDirectory(); const database = join(directory, 'reader.sqlite'); const auth = authFixture(); let newestReads = 0; const requests = installFetch((request) => { const url = new URL(request.url); if (url.pathname.endsWith('/com.atproto.server.createSession')) return sessionResponse(auth); if (url.pathname.endsWith('/app.bsky.feed.getTimeline')) { const cursor = url.searchParams.get('cursor'); if (cursor === 'cursor-one') { return jsonResponse({ cursor: 'cursor-two', feed: [{ post: apiPost(POST_TWO, 'second page'), reason: { $type: 'app.bsky.feed.defs#reasonRepost', by: { did: REPOSTER_DID, handle: 'reposter.test', displayName: 'Reposter' }, indexedAt: '2026-09-05T10:05:00.000Z', }, }], }); } newestReads += 1; return newestReads === 1 ? jsonResponse({ cursor: 'cursor-one', feed: [{ post: apiPost(POST_ONE, 'first page') }] }) : jsonResponse({ cursor: 'cursor-refresh', feed: [{ post: apiPost(POST_THREE, 'refreshed page') }] }); } if (url.pathname.endsWith('/app.bsky.feed.getPostThread')) { const uri = url.searchParams.get('uri') ?? POST_TWO; return jsonResponse({ thread: { $type: 'app.bsky.feed.defs#threadViewPost', post: apiPost(uri, 'opened thread'), replies: [], }, }); } return jsonResponse({ error: 'UnexpectedRequest', message: url.pathname }, 500); }); const first = await runJson(database, ['following', ...authFlags(auth), '--limit', '2']); expect(first.id).toBe('v1'); expect(first.snapshot).toMatchObject({ kind: 'feed', limit: 2, cursor: 'cursor-one', source: { service: PDS, mode: 'authenticated', viewerDid: VIEWER_DID }, }); expect(JSON.stringify(first)).not.toContain(auth.password); expect(JSON.stringify(first)).not.toContain(auth.accessValue); const firstOccurrenceRef = Object.keys(first.refs).find((ref) => ref.endsWith('-e1'))!; const beforePeekRequests = requests.length; expect(await runJson(database, ['peek', firstOccurrenceRef])).toEqual(first.refs[firstOccurrenceRef]); expect(requests).toHaveLength(beforePeekRequests); const second = await runJson(database, ['more', ...authFlags(auth), '--limit', '1']); expect(second.id).toBe('v2'); expect(second.snapshot).toMatchObject({ kind: 'feed', limit: 1, requestCursor: 'cursor-one', cursor: 'cursor-two', }); const secondOccurrenceRef = Object.keys(second.refs).find((ref) => ref.endsWith('-e1'))!; const opened = await runJson(database, [ 'open', secondOccurrenceRef, ...authFlags(auth), '--depth', '0', '--parents', '0', '--max-nodes', '10', ]); expect(opened.snapshot).toMatchObject({ kind: 'thread', focusUri: POST_TWO, encounter: { feedViewId: 'v2', occurrenceId: '1', postUri: POST_TWO, reason: { kind: 'repost', by: { did: REPOSTER_DID, handle: 'reposter.test', displayName: 'Reposter' }, indexedAt: '2026-09-05T10:05:00.000Z', }, }, }); const beforeBackRequests = requests.length; expect(await runJson(database, ['back'])).toEqual(second); expect(await runJson(database, ['peek', firstOccurrenceRef])).toEqual(first.refs[firstOccurrenceRef]); expect(requests).toHaveLength(beforeBackRequests); const refreshed = await runJson(database, ['refresh', ...authFlags(auth), '--limit', '1']); expect(refreshed.id).toBe('v4'); expect(refreshed.snapshot).toMatchObject({ kind: 'feed', limit: 1, cursor: 'cursor-refresh' }); expect(refreshed.snapshot.requestCursor).toBeUndefined(); const timelineUrls = requests .map((request) => new URL(request.url)) .filter((url) => url.pathname.endsWith('/app.bsky.feed.getTimeline')); expect(timelineUrls.map((url) => ({ limit: url.searchParams.get('limit'), cursor: url.searchParams.get('cursor') }))).toEqual([ { limit: '2', cursor: null }, { limit: '1', cursor: 'cursor-one' }, { limit: '1', cursor: null }, ]); const store = new ReadingStore(database); expect(store.position('main').session).toEqual({ name: 'main', viewId: 'v4', history: ['v1', 'v2'] }); expect(store.getView('v1')).toEqual(first); expect(store.getView('v2')).toEqual(second); store.close(); }); test('reports end, cursor-loop, and network failures without moving the original feed', async () => { const directory = temporaryDirectory(); const noCursorDatabase = join(directory, 'no-cursor.sqlite'); const auth = authFixture(); const noCursorRequests = installFetch((request) => { const url = new URL(request.url); if (url.pathname.endsWith('/com.atproto.server.createSession')) return sessionResponse(auth); if (url.pathname.endsWith('/app.bsky.feed.getTimeline')) { return jsonResponse({ feed: [{ post: apiPost(POST_ONE, 'only page') }] }); } return jsonResponse({ error: 'UnexpectedRequest' }, 500); }); const endPage = await runJson(noCursorDatabase, ['following', ...authFlags(auth)]); const requestsAtEnd = noCursorRequests.length; await expect(runCli(['more', ...authFlags(auth), '--db', noCursorDatabase, '--json'])) .rejects.toThrow('Current feed page has no next cursor; continuation is unavailable.'); expect(noCursorRequests).toHaveLength(requestsAtEnd); const endStore = new ReadingStore(noCursorDatabase); expect(endStore.current('main')).toEqual(endPage); expect(endStore.getView('v2')).toBeNull(); endStore.close(); globalThis.fetch = originalFetch; const loopDatabase = join(directory, 'loop.sqlite'); let continuationReads = 0; installFetch((request) => { const url = new URL(request.url); if (url.pathname.endsWith('/com.atproto.server.createSession')) return sessionResponse(auth); if (url.pathname.endsWith('/app.bsky.feed.getTimeline')) { if (!url.searchParams.has('cursor')) { return jsonResponse({ cursor: 'loop-cursor', feed: [{ post: apiPost(POST_ONE, 'loop page') }] }); } continuationReads += 1; return continuationReads === 1 ? jsonResponse({ cursor: 'loop-cursor', feed: [{ post: apiPost(POST_TWO, 'looped page') }] }) : jsonResponse({ error: 'UpstreamUnavailable', message: 'temporary failure' }, 503); } return jsonResponse({ error: 'UnexpectedRequest' }, 500); }); const loopPage = await runJson(loopDatabase, ['following', ...authFlags(auth)]); await expect(runCli(['more', ...authFlags(auth), '--db', loopDatabase, '--json'])) .rejects.toThrow('refusing a silent loop'); await expect(runCli(['more', ...authFlags(auth), '--db', loopDatabase, '--json'])) .rejects.toThrow('Fetching following feed failed'); const loopStore = new ReadingStore(loopDatabase); expect(loopStore.current('main')).toEqual(loopPage); expect(loopStore.position('main').session.history).toEqual([]); expect(loopStore.getView('v2')).toBeNull(); loopStore.close(); }); test('rejects a changed service or viewer after login but before cursor timeline retrieval', async () => { const directory = temporaryDirectory(); const auth = authFixture(); const requests = installFetch((request) => { const url = new URL(request.url); if (url.pathname.endsWith('/com.atproto.server.createSession')) return sessionResponse(auth, OTHER_VIEWER_DID); return jsonResponse({ error: 'TimelineMustNotRun', message: url.pathname }, 500); }); const serviceDatabase = join(directory, 'service.sqlite'); const serviceStore = new ReadingStore(serviceDatabase); const serviceView = serviceStore.commit('main', storedFeed({ service: 'https://stored.example', mode: 'authenticated', viewerDid: OTHER_VIEWER_DID, }, 'stored-cursor'), serviceStore.position('main').revision); serviceStore.close(); await expect(runCli(['more', ...authFlags(auth), '--db', serviceDatabase, '--json'])) .rejects.toThrow('Feed source service changed'); const viewerDatabase = join(directory, 'viewer.sqlite'); const viewerStore = new ReadingStore(viewerDatabase); const viewerView = viewerStore.commit('main', storedFeed({ service: PDS, mode: 'authenticated', viewerDid: VIEWER_DID, }, 'stored-cursor'), viewerStore.position('main').revision); viewerStore.close(); await expect(runCli(['more', ...authFlags(auth), '--db', viewerDatabase, '--json'])) .rejects.toThrow('Feed viewer changed'); expect(requests.filter((request) => new URL(request.url).pathname.endsWith('/app.bsky.feed.getTimeline'))).toHaveLength(0); const reopenedService = new ReadingStore(serviceDatabase); const reopenedViewer = new ReadingStore(viewerDatabase); expect(reopenedService.current('main')).toEqual(serviceView); expect(reopenedViewer.current('main')).toEqual(viewerView); expect(reopenedService.getView('v2')).toBeNull(); expect(reopenedViewer.getView('v2')).toBeNull(); reopenedService.close(); reopenedViewer.close(); }); test('refuses author-feed reference DID substitution before fetching or moving the session', async () => { const directory = temporaryDirectory(); const database = join(directory, 'reader.sqlite'); const store = new ReadingStore(database); const profileView = store.commit('main', { kind: 'profile', requested: AUTHOR_DID, fetchedAt: '2026-09-05T10:00:00.000Z', source: { service: 'https://public.api.bsky.app', mode: 'public' }, author: { did: AUTHOR_DID, handle: 'author.test', displayName: 'Author' }, counts: {}, labels: [], enrichments: [], warnings: [], }, store.position('main').revision); const authorFeedReference = Object.entries(profileView.refs).find(([, target]) => ( target.kind === 'author-feed' ))?.[0]; expect(authorFeedReference).toBeDefined(); store.close(); const requests = installFetch((request) => { const url = new URL(request.url); if (url.pathname.endsWith('/app.bsky.actor.getProfile')) { return jsonResponse({ did: OTHER_VIEWER_DID, handle: 'substituted.test' }); } return jsonResponse({ error: 'AuthorFeedMustNotRun', message: url.pathname }, 500); }); await expect(runCli(['open', authorFeedReference!, '--db', database, '--json'])) .rejects.toThrow('refusing target substitution'); expect(requests.filter((request) => ( new URL(request.url).pathname.endsWith('/app.bsky.feed.getAuthorFeed') ))).toHaveLength(0); const reopened = new ReadingStore(database); expect(reopened.current('main')).toEqual(profileView); expect(reopened.getView('v2')).toBeNull(); reopened.close(); }); });