From b32cb53c67791503cfde4ec7ca01e01a805e2430 Mon Sep 17 00:00:00 2001 From: hypercross Date: Sat, 8 Aug 2026 11:15:42 +0800 Subject: [PATCH] test: add proxy route and env tests Cover health, search, and items routes via Hono's app.request, plus env validation. --- apps/proxy/package.json | 1 + apps/proxy/src/env.test.ts | 28 ++++++++ apps/proxy/src/routes/health.test.ts | 10 +++ apps/proxy/src/routes/items.test.ts | 85 ++++++++++++++++++++++ apps/proxy/src/routes/search.test.ts | 103 +++++++++++++++++++++++++++ 5 files changed, 227 insertions(+) create mode 100644 apps/proxy/src/env.test.ts create mode 100644 apps/proxy/src/routes/health.test.ts create mode 100644 apps/proxy/src/routes/items.test.ts create mode 100644 apps/proxy/src/routes/search.test.ts diff --git a/apps/proxy/package.json b/apps/proxy/package.json index ea74655..6750f5a 100644 --- a/apps/proxy/package.json +++ b/apps/proxy/package.json @@ -9,6 +9,7 @@ "build": "tsc -p tsconfig.json", "start": "node dist/index.js", "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "vitest run", "lint": "echo \"no lint configured\"" }, "dependencies": { diff --git a/apps/proxy/src/env.test.ts b/apps/proxy/src/env.test.ts new file mode 100644 index 0000000..fa8591a --- /dev/null +++ b/apps/proxy/src/env.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { loadEnv } from './env.js'; + +describe('loadEnv', () => { + it('parses a valid environment', () => { + expect(loadEnv({ STEAM_API_KEY: 'key', PORT: '4000' })).toEqual({ + STEAM_API_KEY: 'key', + PORT: 4000, + }); + }); + + it('defaults PORT to 3000', () => { + expect(loadEnv({ STEAM_API_KEY: 'key' })).toEqual({ + STEAM_API_KEY: 'key', + PORT: 3000, + }); + }); + + it('throws when STEAM_API_KEY is missing', () => { + expect(() => loadEnv({})).toThrow(/STEAM_API_KEY/); + }); + + it('throws on an invalid PORT', () => { + expect(() => loadEnv({ STEAM_API_KEY: 'key', PORT: '0' })).toThrow( + /PORT/, + ); + }); +}); \ No newline at end of file diff --git a/apps/proxy/src/routes/health.test.ts b/apps/proxy/src/routes/health.test.ts new file mode 100644 index 0000000..e79c7b6 --- /dev/null +++ b/apps/proxy/src/routes/health.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from 'vitest'; +import health from './health.js'; + +describe('health route', () => { + it('returns ok', async () => { + const res = await health.request('/'); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ status: 'ok' }); + }); +}); \ No newline at end of file diff --git a/apps/proxy/src/routes/items.test.ts b/apps/proxy/src/routes/items.test.ts new file mode 100644 index 0000000..e072208 --- /dev/null +++ b/apps/proxy/src/routes/items.test.ts @@ -0,0 +1,85 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { ItemNotFoundError, SteamApiError } from '@tts/tts'; +import type { TTSMod } from '@tts/shared'; +import items from './items.js'; + +const env = { STEAM_API_KEY: 'test-key', PORT: 3000 }; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('items route', () => { + it('rejects a non-numeric id', async () => { + const res = await items.request('/abc', {}, env); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: 'Item ID must be a number' }); + }); + + it('returns 500 when the API key is missing', async () => { + const res = await items.request('/123', {}, { STEAM_API_KEY: '', PORT: 3000 }); + expect(res.status).toBe(500); + expect(await res.json()).toEqual({ error: 'STEAM_API_KEY is not configured' }); + }); + + it('returns the parsed mod on success', async () => { + const mod: TTSMod = { + GameMode: 'Tabletop', + Date: '2024-01-01', + ObjectStates: [], + }; + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('{}'))); + vi.spyOn(await import('@tts/tts'), 'fetchMod').mockResolvedValue(mod); + + const res = await items.request('/123', {}, env); + expect(res.status).toBe(200); + expect(await res.json()).toEqual(mod); + }); + + it('maps TtsError subclasses to their status', async () => { + vi.spyOn(await import('@tts/tts'), 'fetchMod').mockRejectedValue( + new ItemNotFoundError('123'), + ); + const res = await items.request('/123', {}, env); + expect(res.status).toBe(404); + expect(await res.json()).toEqual({ + error: 'No Workshop item found for id 123', + }); + }); + + it('returns 500 for unexpected errors', async () => { + vi.spyOn(await import('@tts/tts'), 'fetchMod').mockRejectedValue( + new Error('boom'), + ); + const res = await items.request('/123', {}, env); + expect(res.status).toBe(500); + }); +}); + +describe('items file route', () => { + it('rejects a non-numeric id', async () => { + const res = await items.request('/abc/file', {}, env); + expect(res.status).toBe(400); + }); + + it('returns the raw file with a content-disposition header', async () => { + vi.spyOn(await import('@tts/tts'), 'fetchModFile').mockResolvedValue({ + data: new Uint8Array([1, 2, 3]).buffer, + filename: 'mod.json', + }); + const res = await items.request('/123/file', {}, env); + expect(res.status).toBe(200); + expect(res.headers.get('content-disposition')).toBe( + 'attachment; filename="mod.json"', + ); + expect(await res.arrayBuffer()).toEqual(new Uint8Array([1, 2, 3]).buffer); + }); + + it('maps SteamApiError to 502', async () => { + vi.spyOn(await import('@tts/tts'), 'fetchModFile').mockRejectedValue( + new SteamApiError('Steam API responded 500'), + ); + const res = await items.request('/123/file', {}, env); + expect(res.status).toBe(502); + }); +}); \ No newline at end of file diff --git a/apps/proxy/src/routes/search.test.ts b/apps/proxy/src/routes/search.test.ts new file mode 100644 index 0000000..4c1d7bc --- /dev/null +++ b/apps/proxy/src/routes/search.test.ts @@ -0,0 +1,103 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import search from './search.js'; + +function renderContextHtml(results: unknown, totalPages = 2): string { + const queryData = JSON.stringify({ + queries: [ + { + queryKey: ['workshop_browse', { searchtext: 'wingspan' }], + state: { + data: { + current_page: 1, + total_pages: totalPages, + total_count: 20, + results, + next_cursor: 'abc', + }, + }, + }, + ], + }); + const renderContext = JSON.stringify({ queryData }); + // Steam embeds the renderContext as a JS-escaped JSON string literal. + const escaped = JSON.stringify(renderContext).slice(1, -1); + return ``; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('search route', () => { + it('rejects a missing query', async () => { + const res = await search.request('/'); + expect(res.status).toBe(400); + }); + + it('returns items parsed from the browse page', async () => { + const html = renderContextHtml([ + { + publishedfileid: '123', + title: 'Wingspan', + preview_url: 'https://example.com/p.png', + file_url: 'https://example.com/save.json', + tags: [{ tag: 'card' }], + time_created: 100, + time_updated: 200, + }, + ]); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(html))); + + const res = await search.request('/?q=wingspan'); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + items: [ + { + id: '123', + title: 'Wingspan', + author: '', + previewImageUrl: 'https://example.com/p.png', + fileUrl: 'https://example.com/save.json', + tags: ['card'], + timeCreated: 100, + timeUpdated: 200, + }, + ], + page: 1, + hasMore: true, + }); + }); + + it('reports hasMore false on the last page', async () => { + const html = renderContextHtml( + [ + { + publishedfileid: '1', + title: 'A', + preview_url: 'https://example.com/a.png', + }, + ], + 1, + ); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(html))); + + const res = await search.request('/?q=wingspan&page=2'); + const body = await res.json(); + expect(body.hasMore).toBe(false); + }); + + it('returns 502 when the browse page fails', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(new Response('error', { status: 500 })), + ); + const res = await search.request('/?q=wingspan'); + expect(res.status).toBe(502); + }); + + it('returns 502 when results cannot be parsed', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(''))); + const res = await search.request('/?q=wingspan'); + expect(res.status).toBe(502); + }); +}); \ No newline at end of file