test: add proxy route and env tests

Cover health, search, and items routes via Hono's app.request, plus env validation.
This commit is contained in:
2026-08-08 11:15:42 +08:00
parent 92f11db415
commit b32cb53c67
5 changed files with 227 additions and 0 deletions
+1
View File
@@ -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": {
+28
View File
@@ -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/,
);
});
});
+10
View File
@@ -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' });
});
});
+85
View File
@@ -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);
});
});
+103
View File
@@ -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 `<html><script>window.SSR.renderContext=JSON.parse("${escaped}");</script></html>`;
}
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('<html></html>')));
const res = await search.request('/?q=wingspan');
expect(res.status).toBe(502);
});
});