diff --git a/server/api/simkl/index.test.ts b/server/api/simkl/index.test.ts new file mode 100644 index 0000000000..118de3726b --- /dev/null +++ b/server/api/simkl/index.test.ts @@ -0,0 +1,84 @@ +import SimklAPI, { + SimklRequestError, + simklErrorDetail, +} from '@server/api/simkl'; +import axios, { AxiosError, type AxiosAdapter } from 'axios'; +import assert from 'node:assert/strict'; +import { afterEach, describe, it } from 'node:test'; + +const previousAxiosAdapter = axios.defaults.adapter; + +afterEach(() => { + axios.defaults.adapter = previousAxiosAdapter; +}); + +/** The client builds its axios instance on construction, so stub first. */ +const clientAnswering = (status: number, data: unknown) => { + const urls: string[] = []; + axios.defaults.adapter = (async (config) => { + urls.push(String(config.url)); + const response = { + status, + statusText: '', + headers: {}, + config, + data, + }; + if (status < 400) return response; + throw new AxiosError( + `Request failed with status code ${status}`, + AxiosError.ERR_BAD_REQUEST, + config, + undefined, + response + ); + }) as AxiosAdapter; + return { client: new SimklAPI({ clientId: 'test-client' }), urls }; +}; + +describe('SimklAPI errors', () => { + it('keeps Simkl’s own reason for a rejected request', async () => { + const { client } = clientAnswering(412, { + error: 'client_id_failed', + code: 412, + message: 'Your client_id is wrong. Try another one', + }); + await assert.rejects(client.requestPinCode(), (error: unknown) => { + assert.ok(error instanceof SimklRequestError); + assert.equal(error.status, 412); + assert.equal( + error.message, + 'Simkl rejected the request (HTTP 412): client_id_failed — Your client_id is wrong. Try another one' + ); + return true; + }); + }); + + it('reduces a proxy’s HTML error page to its text', () => { + assert.equal( + simklErrorDetail( + '
Invalid header
' + ), + '400 Bad Request Invalid header' + ); + assert.equal(simklErrorDetail(''), undefined); + assert.equal(simklErrorDetail({}), undefined); + }); + + it('caps a long JSON reason like a text one', () => { + assert.equal( + simklErrorDetail({ error: 'bad', message: 'x'.repeat(500) })?.length, + 200 + ); + }); + + it('validates the Client ID against the PIN endpoint', async () => { + const { client, urls } = clientAnswering(200, { + result: 'OK', + user_code: 'ABCDE', + }); + await client.validateClientId(); + assert.equal(urls.length, 1); + assert.match(urls[0], /^\/oauth\/pin\?client_id=test-client&/); + }); +}); diff --git a/server/api/simkl/index.ts b/server/api/simkl/index.ts index 81058e5488..16b56d12ae 100644 --- a/server/api/simkl/index.ts +++ b/server/api/simkl/index.ts @@ -49,6 +49,42 @@ export class SimklTemporarilyUnavailableError extends Error { } } +/** Simkl (or a proxy on the way) refused the request; keeps its explanation. */ +export class SimklRequestError extends Error { + constructor( + public readonly status: number, + detail?: string, + cause?: unknown + ) { + super( + `Simkl rejected the request (HTTP ${status})${detail ? `: ${detail}` : ''}` + ); + this.name = 'SimklRequestError'; + this.cause = cause; + } +} + +const SIMKL_ERROR_DETAIL_LIMIT = 200; + +/** Simkl answers `{ error, message }`; proxies tend to answer HTML or text. */ +export const simklErrorDetail = (data: unknown): string | undefined => { + let text = ''; + if (data && typeof data === 'object') { + const { message, error } = data as { message?: unknown; error?: unknown }; + text = [error, message] + .filter( + (part): part is string => typeof part === 'string' && part.length > 0 + ) + .join(' — '); + } else if (typeof data === 'string') { + text = data + .replace(/<[^>]*>/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + } + return text ? text.slice(0, SIMKL_ERROR_DETAIL_LIMIT) : undefined; +}; + type SimklOptions = { clientId?: string; accessToken?: string; @@ -138,14 +174,22 @@ export default class SimklAPI extends ExternalAPI { } if (!status || status >= 500) throw new SimklTemporarilyUnavailableError(error); - throw error; + throw new SimklRequestError( + status, + simklErrorDetail(error.response?.data), + error + ); } } } - /** Requires a real Client ID; `/movies/trending` succeeds without one. */ + /** + * The PIN endpoint is the only one found to check the Client ID (412 + * `client_id_failed`); catalogs and title lookups accept any value. The + * code it issues just expires unused. + */ public async validateClientId(): Promise