import { describe, test, expect, beforeEach, afterEach, spyOn } from 'bun:test'; import { mkdtempSync, rmSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; import { Hono } from 'hono'; import { ResourceRegistry } from '../src/services/registry.ts'; import { Cache } from '../src/services/cache.ts'; import { createNovelRouter, registerNovelFromConfig } from '../src/routes/novel.ts'; import type { ChapterView } from '../src/types.ts'; import type { AppConfig, NovelDefinition } from '../src/config/types.ts'; // Cache is not exercised by route tests — pass a stub that satisfies the type const stubCache = {} as import('../src/services/cache.ts').Cache; const stubAppConfig: AppConfig = { server: { port: 8080 }, selenium: { remoteUrl: 'http://selenium:4444/wd/hub', browser: 'firefox' }, cache: { basePath: '/tmp' }, addons: { directory: './addons' }, }; function makeView(id: number, min: number, max: number): ChapterView { return { id, min, max, chapters: [] }; } let registry: ResourceRegistry; let app: Hono; beforeEach(() => { registry = new ResourceRegistry(); const router = createNovelRouter(registry, stubCache, stubAppConfig); app = new Hono(); app.route('/qa/requests/novel', router); }); describe('GET /qa/requests/novel/available', () => { test('returns empty object when nothing registered', async () => { const res = await app.request('/qa/requests/novel/available'); expect(res.status).toBe(200); const body = await res.json(); expect(body).toEqual({}); }); test('returns registered resources (without fetch fn)', async () => { registry.register({ hash: 'abc123', slug: 'my-novel', startsAt: 0, endsAt: 100, fetch: async () => makeView(0, 0, 0), }); const res = await app.request('/qa/requests/novel/available'); const body = await res.json() as Record; expect(Object.keys(body)).toHaveLength(1); expect((body['0'] as Record)['slug']).toBe('my-novel'); expect((body['0'] as Record)['fetch']).toBeUndefined(); }); }); describe('GET /qa/requests/novel/:novel', () => { beforeEach(() => { registry.register({ hash: 'deadbeef', slug: 'test-novel', startsAt: 0, endsAt: 9, fetch: async (from, to) => makeView(0, from, to), }); }); test('returns 400 when r param is missing', async () => { const res = await app.request('/qa/requests/novel/0'); expect(res.status).toBe(400); const body = await res.json() as { error: string }; expect(body.error).toContain('Missing'); }); test('returns 400 for malformed r param', async () => { const res = await app.request('/qa/requests/novel/0?r=notanumber'); expect(res.status).toBe(400); }); test('returns 400 when min > max', async () => { const res = await app.request('/qa/requests/novel/0?r=10,5'); expect(res.status).toBe(400); }); test('returns 422 for unknown novel id', async () => { const res = await app.request('/qa/requests/novel/999?r=0,5'); expect(res.status).toBe(422); const body = await res.json() as { error: string }; expect(body.error).toContain('Unknown'); }); test('returns 400 for non-integer novel id', async () => { const res = await app.request('/qa/requests/novel/abc?r=0,5'); expect(res.status).toBe(400); }); test('returns 200 html by default', async () => { const res = await app.request('/qa/requests/novel/0?r=0,0'); // fetch returns empty chapters so HTML template would be rendered (template read from disk) // We just verify it doesn't 5xx from the route layer itself expect([200, 500]).toContain(res.status); }); test('returns 200 json when f=json', async () => { const res = await app.request('/qa/requests/novel/0?r=0,0&f=json'); expect([200, 500]).toContain(res.status); if (res.status === 200) { expect(res.headers.get('content-type')).toContain('application/json'); } }); test('returns 500 when fetch throws', async () => { registry.register({ hash: 'badfetch', slug: 'broken-novel', startsAt: 0, endsAt: 0, fetch: async () => { throw new Error('something went wrong internally'); }, }); const res = await app.request('/qa/requests/novel/1?r=0,0'); expect(res.status).toBe(500); const body = await res.json() as { error: string; detail: string }; expect(body.error).toBeDefined(); expect(body.detail).toBe('something went wrong internally'); }); }); describe('POST /qa/requests/novel/new', () => { test('returns 400 for non-JSON body', async () => { const res = await app.request('/qa/requests/novel/new', { method: 'POST', body: 'not json', headers: { 'Content-Type': 'text/plain' }, }); expect(res.status).toBe(400); }); test('returns 400 when type is missing', async () => { const res = await app.request('/qa/requests/novel/new', { method: 'POST', body: JSON.stringify({ slug: 'test', urls: ['http://x.com/book.epub'] }), headers: { 'Content-Type': 'application/json' }, }); expect(res.status).toBe(400); }); test('returns 400 for unsupported type', async () => { const res = await app.request('/qa/requests/novel/new', { method: 'POST', body: JSON.stringify({ type: 'novel', slug: 'test', urls: ['http://x.com'] }), headers: { 'Content-Type': 'application/json' }, }); expect(res.status).toBe(400); const body = await res.json() as { error: string }; expect(body.error).toContain('Unsupported'); }); test('registers an epub and returns numeric id', async () => { const res = await app.request('/qa/requests/novel/new', { method: 'POST', body: JSON.stringify({ type: 'epub', slug: 'my-book', urls: ['http://x.com/book.epub'] }), headers: { 'Content-Type': 'application/json' }, }); expect(res.status).toBe(200); const text = await res.text(); expect(parseInt(text, 10)).toBeGreaterThanOrEqual(0); }); test('returns 409 on duplicate registration', async () => { const body = JSON.stringify({ type: 'epub', slug: 'dup', urls: ['http://x.com/dup.epub'] }); const headers = { 'Content-Type': 'application/json' }; await app.request('/qa/requests/novel/new', { method: 'POST', body, headers }); const res = await app.request('/qa/requests/novel/new', { method: 'POST', body, headers }); expect(res.status).toBe(409); }); }); describe('registerNovelFromConfig — increment strategy', () => { const DEF: NovelDefinition = { slug: 'increment-novel', type: 'web-scraped', source: { urlTemplate: 'https://example.com/chapter-{n}', startIndex: 1, }, selectors: { content: { scope: '#content' } }, download: { parallelism: 1, minContentBytes: 5 }, }; let tmpDir: string; let incRegistry: ResourceRegistry; let incCache: Cache; beforeEach(() => { tmpDir = mkdtempSync(join(tmpdir(), 'qa-inc-test-')); incRegistry = new ResourceRegistry(); incCache = new Cache(tmpDir); }); afterEach(() => { rmSync(tmpDir, { recursive: true, force: true }); }); test('registers with startsAt = startIndex and endsAt = MAX_SAFE_INTEGER', () => { const id = registerNovelFromConfig(DEF, incRegistry, incCache, stubAppConfig); const res = incRegistry.get(id); expect(res.startsAt).toBe(1); expect(res.endsAt).toBe(Number.MAX_SAFE_INTEGER); expect(res.slug).toBe('increment-novel'); }); test('fetch generates URLs from template and downloads chapters', async () => { const id = registerNovelFromConfig(DEF, incRegistry, incCache, stubAppConfig); const spy = spyOn(globalThis, 'fetch').mockImplementation(() => Promise.resolve( new Response('
Chapter text content here.
', { status: 200 }), ), ); const view = await incRegistry.get(id).fetch(1, 3); expect(view.min).toBe(1); expect(view.max).toBe(3); expect(view.chapters).toHaveLength(3); const urls = spy.mock.calls.map(c => c[0] as string); expect(urls).toContain('https://example.com/chapter-1'); expect(urls).toContain('https://example.com/chapter-2'); expect(urls).toContain('https://example.com/chapter-3'); spy.mockRestore(); }); test('fetch result is served from cache on second call', async () => { const id = registerNovelFromConfig(DEF, incRegistry, incCache, stubAppConfig); const spy = spyOn(globalThis, 'fetch').mockImplementation(() => Promise.resolve( new Response('
Cached chapter content.
', { status: 200 }), ), ); await incRegistry.get(id).fetch(1, 2); spy.mockReset(); await incRegistry.get(id).fetch(1, 2); expect(spy).not.toHaveBeenCalled(); spy.mockRestore(); }); });