import { describe, expect, it, vi } from 'vitest'; import { getRegistry } from '@jackwener/opencli/registry'; import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors'; import { extractRedditMedia, parseRedditHomeLimit } from './home.js'; import './home.js'; function makePage(result) { return { goto: vi.fn().mockResolvedValue(undefined), evaluate: vi.fn().mockResolvedValue(result), }; } function makeEntry(id, overrides = {}) { return { data: { id, title: `Title for ${id}`, subreddit_name_prefixed: 'r/dummy', score: 100, num_comments: 10, author: 'someone', permalink: `/r/dummy/comments/${id}/title/`, ...overrides, }, }; } describe('reddit home command', () => { const command = getRegistry().get('reddit/home'); it('registers with the expected shape', () => { expect(command).toBeDefined(); expect(command.access).toBe('read'); expect(command.browser).toBe(true); expect(command.columns).toEqual([ 'rank', 'title', 'subreddit', 'score', 'comments', 'postId', 'author', 'url', 'post_hint', 'url_overridden_by_dest', 'preview_image_url', 'gallery_urls', ]); }); it('parseRedditHomeLimit accepts [1,100] and rejects out-of-range / non-integer without silent clamp', () => { expect(parseRedditHomeLimit(undefined)).toBe(25); expect(parseRedditHomeLimit(null)).toBe(25); expect(parseRedditHomeLimit('')).toBe(25); expect(parseRedditHomeLimit(1)).toBe(1); expect(parseRedditHomeLimit(25)).toBe(25); expect(parseRedditHomeLimit(100)).toBe(100); for (const bad of [0, -1, 101, 1.5, NaN, 'abc']) { expect(() => parseRedditHomeLimit(bad)).toThrow(ArgumentError); } }); it('rejects a bad limit BEFORE navigating', async () => { const page = makePage({ kind: 'ok', entries: [] }); await expect(command.func(page, { limit: 101 })).rejects.toBeInstanceOf(ArgumentError); expect(page.goto).not.toHaveBeenCalled(); expect(page.evaluate).not.toHaveBeenCalled(); }); it('throws AuthRequiredError when logged out (401/403 or missing identity)', async () => { await expect(command.func(makePage({ kind: 'auth', detail: 'login required' }), { limit: 25 })) .rejects.toBeInstanceOf(AuthRequiredError); }); it('throws CommandExecutionError on HTTP / exception failure modes', async () => { await expect(command.func(makePage({ kind: 'http', httpStatus: 503, where: '/best.json' }), { limit: 25 })) .rejects.toBeInstanceOf(CommandExecutionError); await expect(command.func(makePage({ kind: 'exception', detail: 'network' }), { limit: 25 })) .rejects.toBeInstanceOf(CommandExecutionError); }); it('throws EmptyResultError when Reddit returns no posts', async () => { await expect(command.func(makePage({ kind: 'ok', entries: [] }), { limit: 25 })) .rejects.toBeInstanceOf(EmptyResultError); }); it('maps entries to row shape with 1-based rank, full URLs, and typed numbers', async () => { const entries = [makeEntry('a1'), makeEntry('b2', { score: 250, num_comments: 42 })]; const rows = await command.func(makePage({ kind: 'ok', entries }), { limit: 25 }); expect(rows).toEqual([ { rank: 1, title: 'Title for a1', subreddit: 'r/dummy', score: 100, comments: 10, postId: 'a1', author: 'someone', url: 'https://www.reddit.com/r/dummy/comments/a1/title/', post_hint: '', url_overridden_by_dest: '', preview_image_url: '', gallery_urls: [], }, { rank: 2, title: 'Title for b2', subreddit: 'r/dummy', score: 250, comments: 42, postId: 'b2', author: 'someone', url: 'https://www.reddit.com/r/dummy/comments/b2/title/', post_hint: '', url_overridden_by_dest: '', preview_image_url: '', gallery_urls: [], }, ]); // Row shape must match declared columns exactly. for (const row of rows) { expect(Object.keys(row).sort()).toEqual( [ 'author', 'comments', 'gallery_urls', 'postId', 'post_hint', 'preview_image_url', 'rank', 'score', 'subreddit', 'title', 'url', 'url_overridden_by_dest', ], ); } }); it('surfaces media route fields from the personalized home feed', async () => { const entries = [makeEntry('a1', { post_hint: 'image', url_overridden_by_dest: 'https://i.redd.it/a.jpg', preview: { images: [{ source: { url: 'https://preview.redd.it/a.jpg?x=1&y=2' } }], }, gallery_data: { items: [{ media_id: 'm2' }, { media_id: 'm1' }] }, media_metadata: { m1: { s: { u: 'https://preview.redd.it/m1.jpg?x=1&y=1' } }, m2: { s: { u: 'https://preview.redd.it/m2.jpg?x=1&y=2' } }, }, })]; const rows = await command.func(makePage({ kind: 'ok', entries }), { limit: 25 }); expect(rows[0]).toMatchObject({ post_hint: 'image', url_overridden_by_dest: 'https://i.redd.it/a.jpg', preview_image_url: 'https://preview.redd.it/a.jpg?x=1&y=2', gallery_urls: [ 'https://preview.redd.it/m2.jpg?x=1&y=2', 'https://preview.redd.it/m1.jpg?x=1&y=1', ], }); }); it('extractRedditMedia tolerates missing media without throwing', () => { expect(extractRedditMedia({ is_self: true })).toEqual({ post_hint: '', url_overridden_by_dest: '', preview_image_url: '', gallery_urls: [], }); }); it('applies the post-fetch limit slice (defence in depth vs Reddit overshoot)', async () => { const entries = Array.from({ length: 30 }, (_, i) => makeEntry(`p${i}`)); const rows = await command.func(makePage({ kind: 'ok', entries }), { limit: 5 }); expect(rows).toHaveLength(5); expect(rows[0].postId).toBe('p0'); expect(rows[4].postId).toBe('p4'); }); it('drops entries with no data.id rather than silently emitting sentinels', async () => { const entries = [makeEntry('keep1'), { data: { title: 'no id' } }, makeEntry('keep2')]; const rows = await command.func(makePage({ kind: 'ok', entries }), { limit: 25 }); expect(rows.map((r) => r.postId)).toEqual(['keep1', 'keep2']); }); it('throws CommandExecutionError when all home entries are missing post ids', async () => { const entries = [{ data: { title: 'no id' } }, { data: { title: 'also no id' } }]; await expect(command.func(makePage({ kind: 'ok', entries }), { limit: 25 })) .rejects.toMatchObject({ code: 'COMMAND_EXEC', message: expect.stringContaining('required post id anchors'), }); }); it('embeds the requested limit literally inside the evaluate script', async () => { const page = makePage({ kind: 'ok', entries: [makeEntry('x')] }); await command.func(page, { limit: 7 }); const script = page.evaluate.mock.calls[0][0]; expect(script).toContain('const limit = 7'); }); });