feat: return workshop metadata with mod details
Include the Workshop title and preview image in the items response so the mod header survives a page refresh, not just navigation from search. Resolve both from the same Steam API call.
This commit is contained in:
@@ -1,10 +1,18 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ItemNotFoundError, SteamApiError, TtsError } from '@tts/tts';
|
||||
import type { TTSMod } from '@tts/shared';
|
||||
import type { ModDetails } from '@tts/shared';
|
||||
import items from './items.js';
|
||||
|
||||
const env = { STEAM_API_KEY: 'test-key', PORT: 3000 };
|
||||
|
||||
const mod = {
|
||||
GameMode: 'Tabletop',
|
||||
Date: '2024-01-01',
|
||||
ObjectStates: [],
|
||||
};
|
||||
|
||||
const details: ModDetails = { mod };
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
@@ -17,7 +25,7 @@ describe('items route', () => {
|
||||
});
|
||||
|
||||
it('returns 500 when the API key is missing and no fileUrl is given', async () => {
|
||||
vi.spyOn(await import('@tts/tts'), 'fetchMod').mockRejectedValue(
|
||||
vi.spyOn(await import('@tts/tts'), 'fetchModDetails').mockRejectedValue(
|
||||
new TtsError('STEAM_API_KEY is not configured', 500),
|
||||
);
|
||||
const res = await items.request('/123', {}, { STEAM_API_KEY: '', PORT: 3000 });
|
||||
@@ -26,11 +34,6 @@ describe('items route', () => {
|
||||
});
|
||||
|
||||
it('loads from a fileUrl without an API key', async () => {
|
||||
const mod: TTSMod = {
|
||||
GameMode: 'Tabletop',
|
||||
Date: '2024-01-01',
|
||||
ObjectStates: [],
|
||||
};
|
||||
const fetchModFromUrl = vi
|
||||
.spyOn(await import('@tts/tts'), 'fetchModFromUrl')
|
||||
.mockResolvedValue(mod);
|
||||
@@ -40,26 +43,26 @@ describe('items route', () => {
|
||||
{ STEAM_API_KEY: '', PORT: 3000 },
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual(mod);
|
||||
expect(await res.json()).toEqual(details);
|
||||
expect(fetchModFromUrl).toHaveBeenCalledWith('https://example.com/save.json');
|
||||
});
|
||||
|
||||
it('returns the parsed mod on success', async () => {
|
||||
const mod: TTSMod = {
|
||||
GameMode: 'Tabletop',
|
||||
Date: '2024-01-01',
|
||||
ObjectStates: [],
|
||||
it('returns the parsed mod with metadata on success', async () => {
|
||||
const withMeta: ModDetails = {
|
||||
mod,
|
||||
title: 'My Mod',
|
||||
previewImageUrl: 'https://example.com/preview.png',
|
||||
};
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('{}')));
|
||||
vi.spyOn(await import('@tts/tts'), 'fetchMod').mockResolvedValue(mod);
|
||||
vi.spyOn(await import('@tts/tts'), 'fetchModDetails').mockResolvedValue(withMeta);
|
||||
|
||||
const res = await items.request('/123', {}, env);
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual(mod);
|
||||
expect(await res.json()).toEqual(withMeta);
|
||||
});
|
||||
|
||||
it('maps TtsError subclasses to their status', async () => {
|
||||
vi.spyOn(await import('@tts/tts'), 'fetchMod').mockRejectedValue(
|
||||
vi.spyOn(await import('@tts/tts'), 'fetchModDetails').mockRejectedValue(
|
||||
new ItemNotFoundError('123'),
|
||||
);
|
||||
const res = await items.request('/123', {}, env);
|
||||
@@ -70,7 +73,7 @@ describe('items route', () => {
|
||||
});
|
||||
|
||||
it('returns 500 for unexpected errors', async () => {
|
||||
vi.spyOn(await import('@tts/tts'), 'fetchMod').mockRejectedValue(
|
||||
vi.spyOn(await import('@tts/tts'), 'fetchModDetails').mockRejectedValue(
|
||||
new Error('boom'),
|
||||
);
|
||||
const res = await items.request('/123', {}, env);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Hono } from 'hono';
|
||||
import { itemIdSchema, type TTSMod } from '@tts/shared';
|
||||
import { itemIdSchema, type ModDetails } from '@tts/shared';
|
||||
import {
|
||||
fetchMod,
|
||||
fetchModDetails,
|
||||
fetchModFile,
|
||||
fetchModFileFromUrl,
|
||||
fetchModFromUrl,
|
||||
@@ -20,11 +20,12 @@ app.get('/:id', async (c) => {
|
||||
try {
|
||||
const fileUrl = c.req.query('fileUrl');
|
||||
// With no Steam API key, the caller must supply the save URL (e.g. from a
|
||||
// search result). Otherwise resolve it via the Steam API.
|
||||
const mod: TTSMod = fileUrl
|
||||
? await fetchModFromUrl(fileUrl)
|
||||
: await fetchMod(id, c.env.STEAM_API_KEY ?? '');
|
||||
return c.json<TTSMod>(mod);
|
||||
// search result). Otherwise resolve it via the Steam API, which also
|
||||
// yields the Workshop title and preview image.
|
||||
const details: ModDetails = fileUrl
|
||||
? { mod: await fetchModFromUrl(fileUrl) }
|
||||
: await fetchModDetails(id, c.env.STEAM_API_KEY ?? '');
|
||||
return c.json<ModDetails>(details);
|
||||
} catch (err) {
|
||||
if (err instanceof TtsError) {
|
||||
return c.json({ error: err.message }, err.status as 400 | 404 | 500 | 502);
|
||||
|
||||
+4
-4
@@ -1,4 +1,4 @@
|
||||
import type { SearchResult, TTSMod } from '@tts/shared';
|
||||
import type { ModDetails, SearchResult } from '@tts/shared';
|
||||
import { traceImage } from '@tts/http';
|
||||
|
||||
export { traceImage };
|
||||
@@ -21,13 +21,13 @@ export function searchWorkshop(q: string, page = 1): Promise<SearchResult> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a full parsed TTS save.
|
||||
* Fetch a full parsed TTS save plus its Workshop metadata.
|
||||
*/
|
||||
export function fetchMod(id: string, fileUrl?: string): Promise<TTSMod> {
|
||||
export function fetchMod(id: string, fileUrl?: string): Promise<ModDetails> {
|
||||
const params = new URLSearchParams();
|
||||
if (fileUrl) params.set('fileUrl', fileUrl);
|
||||
const qs = params.toString();
|
||||
return getJson<TTSMod>(`/items/${id}${qs ? `?${qs}` : ''}`);
|
||||
return getJson<ModDetails>(`/items/${id}${qs ? `?${qs}` : ''}`);
|
||||
}
|
||||
|
||||
/** Build a URL for the raw save file download. */
|
||||
|
||||
@@ -25,8 +25,8 @@ export default function ModPage() {
|
||||
if (id) load(id, item?.fileUrl);
|
||||
}, [id, item?.fileUrl, load]);
|
||||
|
||||
const tree = useMemo(() => (mod ? buildTree(mod) : []), [mod]);
|
||||
const refs = useMemo(() => (mod ? collectRefs(mod) : []), [mod]);
|
||||
const tree = useMemo(() => (mod ? buildTree(mod.mod) : []), [mod]);
|
||||
const refs = useMemo(() => (mod ? collectRefs(mod.mod) : []), [mod]);
|
||||
|
||||
const selected = useMemo(
|
||||
() => (mod ? findInTree(tree, selectedPath) : undefined),
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { create } from 'zustand';
|
||||
import type { TTSMod } from '@tts/shared';
|
||||
import type { ModDetails } from '@tts/shared';
|
||||
import { fetchMod } from '../api';
|
||||
|
||||
interface ModState {
|
||||
id: string | null;
|
||||
fileUrl?: string;
|
||||
mod: TTSMod | null;
|
||||
mod: ModDetails | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
load: (id: string, fileUrl?: string) => Promise<void>;
|
||||
@@ -21,11 +21,11 @@ export const useModStore = create<ModState>((set) => ({
|
||||
load: async (id, fileUrl) => {
|
||||
set({ loading: true, error: null, id, fileUrl });
|
||||
try {
|
||||
const mod = await fetchMod(id, fileUrl);
|
||||
set({ mod, loading: false });
|
||||
const details = await fetchMod(id, fileUrl);
|
||||
set({ mod: details, loading: false });
|
||||
} catch (err) {
|
||||
set({ loading: false, error: String(err) });
|
||||
}
|
||||
},
|
||||
clear: () => set({ id: null, fileUrl: undefined, mod: null, error: null, loading: false }),
|
||||
clear: () => set({ id: null, fileUrl: undefined, mod: null, loading: false, error: null }),
|
||||
}));
|
||||
Reference in New Issue
Block a user