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:
@@ -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": {
|
||||
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user