diff --git a/.changeset/fix-error-output.md b/.changeset/fix-error-output.md new file mode 100644 index 00000000..8ecead91 --- /dev/null +++ b/.changeset/fix-error-output.md @@ -0,0 +1,8 @@ +--- +'@salesforce/b2c-cli': patch +'@salesforce/b2c-tooling-sdk': patch +--- + +Fix HTML response bodies appearing in ERROR log lines. When API requests fail with non-JSON responses (like HTML error pages), error messages now show the HTTP status code (e.g., "HTTP 521 Web Server Is Down") instead of serializing the entire response body. + +Added `getApiErrorMessage(error, response)` utility that extracts clean error messages from ODS, OCAPI, and SCAPI error patterns with HTTP status fallback. diff --git a/.claude/skills/api-client-development/SKILL.md b/.claude/skills/api-client-development/SKILL.md index c61118b8..13162723 100644 --- a/.claude/skills/api-client-development/SKILL.md +++ b/.claude/skills/api-client-development/SKILL.md @@ -380,6 +380,62 @@ it('fetches endpoints', async () => { --- +## Error Handling + +When API requests fail, use `getApiErrorMessage()` to extract clean, user-friendly error messages. This utility handles multiple error formats and ensures HTML response bodies (like error pages from stopped sandboxes) are never shown to users. + +### Using getApiErrorMessage + +```typescript +import {getApiErrorMessage} from '@salesforce/b2c-tooling-sdk/clients'; + +const {data, error, response} = await client.GET('/sites', {...}); + +if (error) { + // Returns structured error message or "HTTP 521 Web Server Is Down" + const message = getApiErrorMessage(error, response); + this.error(`Failed to fetch sites: ${message}`); +} +``` + +### Supported Error Patterns + +The utility extracts messages from these patterns in priority order: + +| API | Error Structure | Message Location | +|-----|-----------------|------------------| +| ODS/SLAS | `{ error: { message } }` | `error.error.message` | +| OCAPI | `{ fault: { message } }` | `error.fault.message` | +| SCAPI/Problem+JSON | `{ title, detail }` | `error.detail` or `error.title` | +| Standard Error | `{ message }` | `error.message` | +| Fallback | Any | `HTTP {status} {statusText}` | + +### Why This Matters + +**Without `getApiErrorMessage`:** +``` +ERROR: Failed to fetch sites: 521 - Sandbox Down... +``` + +**With `getApiErrorMessage`:** +``` +ERROR: Failed to fetch sites: HTTP 521 Web Server Is Down +``` + +### Important: Always Destructure `response` + +When making API calls, always destructure the `response` object alongside `error`: + +```typescript +// GOOD: Include response for error handling +const {data, error, response} = await client.GET('/endpoint', {...}); + +// BAD: Missing response - can't get clean error message +const {data, error} = await client.GET('/endpoint', {...}); +``` + +--- + ## Checklist: New SCAPI Client 1. Add OpenAPI spec to `specs/` diff --git a/.claude/skills/cli-command-development/SKILL.md b/.claude/skills/cli-command-development/SKILL.md index 8a8f1779..9368120d 100644 --- a/.claude/skills/cli-command-development/SKILL.md +++ b/.claude/skills/cli-command-development/SKILL.md @@ -57,6 +57,7 @@ import { InstanceCommand, CartridgeCommand, OdsCommand } from '@salesforce/b2c-t */ import {Args, Flags} from '@oclif/core'; import {InstanceCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {getApiErrorMessage} from '@salesforce/b2c-tooling-sdk'; import {t} from '../../i18n/index.js'; interface MyCommandResponse { @@ -105,27 +106,27 @@ export default class MyCommand extends InstanceCommand { this.log(t('commands.topic.mycommand.working', 'Working on {{name}}...', {name})); // Implementation - const result = await this.instance.ocapi.GET('/some/endpoint'); + const {data, error, response} = await this.instance.ocapi.GET('/some/endpoint'); - if (!result.data) { + if (error) { this.error(t('commands.topic.mycommand.error', 'Failed: {{message}}', { - message: result.response?.statusText || 'Unknown error', + message: getApiErrorMessage(error, response), })); } - const response: MyCommandResponse = { + const result: MyCommandResponse = { success: true, - data: result.data, + data, }; // JSON mode returns the object directly (oclif handles serialization) if (this.jsonEnabled()) { - return response; + return result; } // Human-readable output this.log('Success!'); - return response; + return result; } } ``` @@ -305,14 +306,21 @@ this.error('Config file not found', { // Warning (continues execution) this.warn('Deprecated flag used'); -// Structured API errors -if (result.error) { +// API errors - use getApiErrorMessage for clean messages +import {getApiErrorMessage} from '@salesforce/b2c-tooling-sdk'; + +const {data, error, response} = await this.instance.ocapi.GET('/sites', {...}); +if (error) { this.error(t('commands.topic.cmd.apiError', 'API error: {{message}}', { - message: formatApiError(result.error), + message: getApiErrorMessage(error, response), })); } ``` +**Important:** Always destructure `response` alongside `error` when making API calls. The `getApiErrorMessage` utility extracts clean messages from ODS, OCAPI, and SCAPI error patterns, and falls back to HTTP status (e.g., "HTTP 521 Web Server Is Down") for non-JSON responses like HTML error pages. + +See [API Client Development](../api-client-development/SKILL.md#error-handling) for supported error patterns. + ## Creating a Command Checklist 1. Create file at `packages/b2c-cli/src/commands//.ts` diff --git a/packages/b2c-cli/src/commands/ods/create.ts b/packages/b2c-cli/src/commands/ods/create.ts index d98cb570..a8dc2409 100644 --- a/packages/b2c-cli/src/commands/ods/create.ts +++ b/packages/b2c-cli/src/commands/ods/create.ts @@ -6,7 +6,7 @@ import {Flags, ux} from '@oclif/core'; import cliui from 'cliui'; import {OdsCommand} from '@salesforce/b2c-tooling-sdk/cli'; -import type {OdsComponents} from '@salesforce/b2c-tooling-sdk'; +import {getApiErrorMessage, type OdsComponents} from '@salesforce/b2c-tooling-sdk'; import {t} from '../../i18n/index.js'; type SandboxModel = OdsComponents['schemas']['SandboxModel']; @@ -139,11 +139,9 @@ export default class OdsCreate extends OdsCommand { }); if (!result.data?.data) { - const errorResponse = result.error as OdsComponents['schemas']['ErrorResponse'] | undefined; - const errorMessage = errorResponse?.error?.message || result.response?.statusText || 'Unknown error'; this.error( t('commands.ods.create.error', 'Failed to create sandbox: {{message}}', { - message: errorMessage, + message: getApiErrorMessage(result.error, result.response), }), ); } diff --git a/packages/b2c-cli/src/commands/ods/delete.ts b/packages/b2c-cli/src/commands/ods/delete.ts index 091e1e9a..86223221 100644 --- a/packages/b2c-cli/src/commands/ods/delete.ts +++ b/packages/b2c-cli/src/commands/ods/delete.ts @@ -6,7 +6,7 @@ import * as readline from 'node:readline'; import {Args, Flags} from '@oclif/core'; import {OdsCommand} from '@salesforce/b2c-tooling-sdk/cli'; -import type {OdsComponents} from '@salesforce/b2c-tooling-sdk'; +import {getApiErrorMessage} from '@salesforce/b2c-tooling-sdk'; import {t} from '../../i18n/index.js'; /** @@ -92,11 +92,9 @@ export default class OdsDelete extends OdsCommand { }); if (result.response.status !== 202) { - const errorResponse = result.error as OdsComponents['schemas']['ErrorResponse'] | undefined; - const errorMessage = errorResponse?.error?.message || result.response?.statusText || 'Unknown error'; this.error( t('commands.ods.delete.error', 'Failed to delete sandbox: {{message}}', { - message: errorMessage, + message: getApiErrorMessage(result.error, result.response), }), ); } diff --git a/packages/b2c-cli/src/commands/ods/list.ts b/packages/b2c-cli/src/commands/ods/list.ts index 57a9b783..430157c6 100644 --- a/packages/b2c-cli/src/commands/ods/list.ts +++ b/packages/b2c-cli/src/commands/ods/list.ts @@ -5,7 +5,7 @@ */ import {Flags} from '@oclif/core'; import {OdsCommand, TableRenderer, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; -import type {OdsComponents} from '@salesforce/b2c-tooling-sdk'; +import {getApiErrorMessage, type OdsComponents} from '@salesforce/b2c-tooling-sdk'; import {t} from '../../i18n/index.js'; type SandboxModel = OdsComponents['schemas']['SandboxModel']; @@ -142,11 +142,9 @@ export default class OdsList extends OdsCommand { }); if (result.error) { - const errorResponse = result.error as OdsComponents['schemas']['ErrorResponse'] | undefined; - const errorMessage = errorResponse?.error?.message || result.response?.statusText || 'Unknown error'; this.error( t('commands.ods.list.error', 'Failed to fetch sandboxes: {{message}}', { - message: errorMessage, + message: getApiErrorMessage(result.error, result.response), }), ); } diff --git a/packages/b2c-cli/src/commands/ods/restart.ts b/packages/b2c-cli/src/commands/ods/restart.ts index b6667369..44a51b51 100644 --- a/packages/b2c-cli/src/commands/ods/restart.ts +++ b/packages/b2c-cli/src/commands/ods/restart.ts @@ -5,7 +5,7 @@ */ import {Args} from '@oclif/core'; import {OdsCommand} from '@salesforce/b2c-tooling-sdk/cli'; -import type {OdsComponents} from '@salesforce/b2c-tooling-sdk'; +import {getApiErrorMessage, type OdsComponents} from '@salesforce/b2c-tooling-sdk'; import {t} from '../../i18n/index.js'; type SandboxOperationModel = OdsComponents['schemas']['SandboxOperationModel']; @@ -45,11 +45,9 @@ export default class OdsRestart extends OdsCommand { }); if (!result.data?.data) { - const errorResponse = result.error as OdsComponents['schemas']['ErrorResponse'] | undefined; - const errorMessage = errorResponse?.error?.message || result.response?.statusText || 'Unknown error'; this.error( t('commands.ods.restart.error', 'Failed to restart sandbox: {{message}}', { - message: errorMessage, + message: getApiErrorMessage(result.error, result.response), }), ); } diff --git a/packages/b2c-cli/src/commands/ods/start.ts b/packages/b2c-cli/src/commands/ods/start.ts index 2a1aa8ae..fc3c0698 100644 --- a/packages/b2c-cli/src/commands/ods/start.ts +++ b/packages/b2c-cli/src/commands/ods/start.ts @@ -5,7 +5,7 @@ */ import {Args} from '@oclif/core'; import {OdsCommand} from '@salesforce/b2c-tooling-sdk/cli'; -import type {OdsComponents} from '@salesforce/b2c-tooling-sdk'; +import {getApiErrorMessage, type OdsComponents} from '@salesforce/b2c-tooling-sdk'; import {t} from '../../i18n/index.js'; type SandboxOperationModel = OdsComponents['schemas']['SandboxOperationModel']; @@ -45,11 +45,9 @@ export default class OdsStart extends OdsCommand { }); if (!result.data?.data) { - const errorResponse = result.error as OdsComponents['schemas']['ErrorResponse'] | undefined; - const errorMessage = errorResponse?.error?.message || result.response?.statusText || 'Unknown error'; this.error( t('commands.ods.start.error', 'Failed to start sandbox: {{message}}', { - message: errorMessage, + message: getApiErrorMessage(result.error, result.response), }), ); } diff --git a/packages/b2c-cli/src/commands/ods/stop.ts b/packages/b2c-cli/src/commands/ods/stop.ts index 3160bb05..78d4c053 100644 --- a/packages/b2c-cli/src/commands/ods/stop.ts +++ b/packages/b2c-cli/src/commands/ods/stop.ts @@ -5,7 +5,7 @@ */ import {Args} from '@oclif/core'; import {OdsCommand} from '@salesforce/b2c-tooling-sdk/cli'; -import type {OdsComponents} from '@salesforce/b2c-tooling-sdk'; +import {getApiErrorMessage, type OdsComponents} from '@salesforce/b2c-tooling-sdk'; import {t} from '../../i18n/index.js'; type SandboxOperationModel = OdsComponents['schemas']['SandboxOperationModel']; @@ -45,11 +45,9 @@ export default class OdsStop extends OdsCommand { }); if (!result.data?.data) { - const errorResponse = result.error as OdsComponents['schemas']['ErrorResponse'] | undefined; - const errorMessage = errorResponse?.error?.message || result.response?.statusText || 'Unknown error'; this.error( t('commands.ods.stop.error', 'Failed to stop sandbox: {{message}}', { - message: errorMessage, + message: getApiErrorMessage(result.error, result.response), }), ); } diff --git a/packages/b2c-cli/src/commands/scapi/custom/status.ts b/packages/b2c-cli/src/commands/scapi/custom/status.ts index e8ed0df6..cecb3e67 100644 --- a/packages/b2c-cli/src/commands/scapi/custom/status.ts +++ b/packages/b2c-cli/src/commands/scapi/custom/status.ts @@ -5,7 +5,12 @@ */ import {Command, Flags, ux} from '@oclif/core'; import {OAuthCommand, TableRenderer, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; -import {createCustomApisClient, toOrganizationId, type CustomApisComponents} from '@salesforce/b2c-tooling-sdk'; +import { + createCustomApisClient, + getApiErrorMessage, + toOrganizationId, + type CustomApisComponents, +} from '@salesforce/b2c-tooling-sdk'; import {t} from '../../../i18n/index.js'; type CustomApiEndpoint = CustomApisComponents['schemas']['CustomApiEndpoint']; @@ -242,7 +247,11 @@ export default class ScapiCustomStatus extends ScapiCustomCommand { this.log(t('commands.sites.list.fetching', 'Fetching sites from {{hostname}}...', {hostname})); - const {data, error} = await this.instance.ocapi.GET('/sites', { + const {data, error, response} = await this.instance.ocapi.GET('/sites', { params: {query: {select: '(**)'}}, }); if (error) { - this.error(t('commands.sites.list.error', 'Failed to fetch sites: {{message}}', {message: String(error)})); + this.error( + t('commands.sites.list.error', 'Failed to fetch sites: {{message}}', { + message: getApiErrorMessage(error, response), + }), + ); } const sites = data as Sites; diff --git a/packages/b2c-cli/src/commands/slas/client/create.ts b/packages/b2c-cli/src/commands/slas/client/create.ts index f57e233c..7e6caaa1 100644 --- a/packages/b2c-cli/src/commands/slas/client/create.ts +++ b/packages/b2c-cli/src/commands/slas/client/create.ts @@ -178,7 +178,7 @@ export default class SlasClientCreate extends SlasClientCommand extends OAuthC if (!isTenantNotFound) { this.error( t('commands.slas.client.create.tenantError', 'Failed to check tenant: {{message}}', { - message: formatApiError(error), + message: formatApiError(error, response), }), ); } @@ -149,7 +149,7 @@ export abstract class SlasClientCommand extends OAuthC this.log(t('commands.slas.client.create.creatingTenant', 'Creating SLAS tenant {{tenantId}}...', {tenantId})); } - const {error: createError} = await slasClient.PUT('/tenants/{tenantId}', { + const {error: createError, response: createResponse} = await slasClient.PUT('/tenants/{tenantId}', { params: { path: {tenantId}, }, @@ -166,7 +166,7 @@ export abstract class SlasClientCommand extends OAuthC if (createError) { this.error( t('commands.slas.client.create.tenantCreateError', 'Failed to create tenant: {{message}}', { - message: formatApiError(createError), + message: formatApiError(createError, createResponse), }), ); } diff --git a/packages/b2c-tooling-sdk/src/clients/error-utils.ts b/packages/b2c-tooling-sdk/src/clients/error-utils.ts new file mode 100644 index 00000000..ed57363e --- /dev/null +++ b/packages/b2c-tooling-sdk/src/clients/error-utils.ts @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +/** + * Utilities for extracting error messages from API responses. + * + * @module clients/error-utils + */ + +/** + * Extract a clean error message from an API error response. + * + * Handles multiple API error patterns and falls back to HTTP status. + * This ensures that HTML response bodies (like error pages) are never + * included in user-facing error messages. + * + * Supported error patterns: + * - ODS/SLAS: `{ error: { message: '...' } }` + * - OCAPI: `{ fault: { message: '...' } }` + * - SCAPI/Problem+JSON: `{ detail: '...', title: '...' }` + * - Standard Error: `{ message: '...' }` + * + * @param error - The error object from an API response + * @param response - The HTTP response (for status code fallback) + * @returns A clean, human-readable error message + * + * @example + * ```typescript + * const {data, error, response} = await client.GET('/sites', {...}); + * if (error) { + * const message = getApiErrorMessage(error, response); + * // Returns structured error message or "HTTP 521 Web Server Is Down" + * } + * ``` + */ +export function getApiErrorMessage(error: unknown, response: Response | {status: number; statusText: string}): string { + if (error && typeof error === 'object') { + const err = error as Record; + + // ODS/SLAS pattern: { error: { message: '...' } } + if (err.error && typeof err.error === 'object') { + const nested = err.error as Record; + if (typeof nested.message === 'string' && nested.message) { + return nested.message; + } + } + + // OCAPI fault pattern: { fault: { message: '...' } } + if (err.fault && typeof err.fault === 'object') { + const fault = err.fault as Record; + if (typeof fault.message === 'string' && fault.message) { + return fault.message; + } + } + + // SCAPI/Problem+JSON pattern: { detail: '...', title: '...' } + if (typeof err.detail === 'string' && err.detail) { + return err.detail; + } + if (typeof err.title === 'string' && err.title) { + return err.title; + } + + // Standard Error pattern: { message: '...' } + if (typeof err.message === 'string' && err.message) { + return err.message; + } + } + + // Fallback to HTTP status + return `HTTP ${response.status} ${response.statusText}`; +} diff --git a/packages/b2c-tooling-sdk/src/clients/index.ts b/packages/b2c-tooling-sdk/src/clients/index.ts index 591d3615..688b7195 100644 --- a/packages/b2c-tooling-sdk/src/clients/index.ts +++ b/packages/b2c-tooling-sdk/src/clients/index.ts @@ -195,3 +195,5 @@ export type { paths as ScapiSchemasPaths, components as ScapiSchemasComponents, } from './scapi-schemas.js'; + +export {getApiErrorMessage} from './error-utils.js'; diff --git a/packages/b2c-tooling-sdk/src/index.ts b/packages/b2c-tooling-sdk/src/index.ts index 8b2e5923..4970ef19 100644 --- a/packages/b2c-tooling-sdk/src/index.ts +++ b/packages/b2c-tooling-sdk/src/index.ts @@ -74,6 +74,7 @@ export { toOrganizationId, toTenantId, buildTenantScope, + getApiErrorMessage, ORGANIZATION_ID_PREFIX, SCAPI_TENANT_SCOPE_PREFIX, CUSTOM_APIS_DEFAULT_SCOPES, diff --git a/packages/b2c-tooling-sdk/test/clients/error-utils.test.ts b/packages/b2c-tooling-sdk/test/clients/error-utils.test.ts new file mode 100644 index 00000000..fc51b5e7 --- /dev/null +++ b/packages/b2c-tooling-sdk/test/clients/error-utils.test.ts @@ -0,0 +1,171 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import {expect} from 'chai'; +import {getApiErrorMessage} from '../../src/clients/error-utils.js'; + +describe('getApiErrorMessage', () => { + // Mock response object for testing + const mockResponse = (status: number, statusText: string) => ({ + status, + statusText, + }); + + describe('ODS/SLAS error pattern', () => { + it('extracts message from { error: { message } } structure', () => { + const error = {error: {message: 'Sandbox not found'}}; + const response = mockResponse(404, 'Not Found'); + expect(getApiErrorMessage(error, response)).to.equal('Sandbox not found'); + }); + + it('ignores error.error if message is not a string', () => { + const error = {error: {message: 123}}; + const response = mockResponse(500, 'Internal Server Error'); + expect(getApiErrorMessage(error, response)).to.equal('HTTP 500 Internal Server Error'); + }); + + it('ignores error.error if message is empty', () => { + const error = {error: {message: ''}}; + const response = mockResponse(500, 'Internal Server Error'); + expect(getApiErrorMessage(error, response)).to.equal('HTTP 500 Internal Server Error'); + }); + }); + + describe('OCAPI fault pattern', () => { + it('extracts message from { fault: { message } } structure', () => { + const error = {fault: {type: 'NotFoundException', message: 'Site not found'}}; + const response = mockResponse(404, 'Not Found'); + expect(getApiErrorMessage(error, response)).to.equal('Site not found'); + }); + + it('ignores fault if message is not a string', () => { + const error = {fault: {type: 'Error', message: null}}; + const response = mockResponse(500, 'Internal Server Error'); + expect(getApiErrorMessage(error, response)).to.equal('HTTP 500 Internal Server Error'); + }); + }); + + describe('SCAPI/Problem+JSON pattern', () => { + it('extracts detail from { detail } structure', () => { + const error = {type: 'NotFound', title: 'Resource Not Found', detail: 'The requested schema was not found'}; + const response = mockResponse(404, 'Not Found'); + expect(getApiErrorMessage(error, response)).to.equal('The requested schema was not found'); + }); + + it('falls back to title if detail is missing', () => { + const error = {type: 'NotFound', title: 'Resource Not Found'}; + const response = mockResponse(404, 'Not Found'); + expect(getApiErrorMessage(error, response)).to.equal('Resource Not Found'); + }); + + it('ignores detail if it is empty', () => { + const error = {title: 'Some Title', detail: ''}; + const response = mockResponse(400, 'Bad Request'); + expect(getApiErrorMessage(error, response)).to.equal('Some Title'); + }); + }); + + describe('standard Error pattern', () => { + it('extracts message from { message } structure', () => { + const error = {message: 'Something went wrong'}; + const response = mockResponse(500, 'Internal Server Error'); + expect(getApiErrorMessage(error, response)).to.equal('Something went wrong'); + }); + + it('works with actual Error objects', () => { + const error = new Error('Connection refused'); + const response = mockResponse(503, 'Service Unavailable'); + expect(getApiErrorMessage(error, response)).to.equal('Connection refused'); + }); + }); + + describe('HTTP status fallback', () => { + it('returns HTTP status when error is null', () => { + const response = mockResponse(521, 'Web Server Is Down'); + expect(getApiErrorMessage(null, response)).to.equal('HTTP 521 Web Server Is Down'); + }); + + it('returns HTTP status when error is undefined', () => { + const response = mockResponse(502, 'Bad Gateway'); + expect(getApiErrorMessage(undefined, response)).to.equal('HTTP 502 Bad Gateway'); + }); + + it('returns HTTP status when error is not an object', () => { + const response = mockResponse(500, 'Internal Server Error'); + expect(getApiErrorMessage('some string error', response)).to.equal('HTTP 500 Internal Server Error'); + expect(getApiErrorMessage(123, response)).to.equal('HTTP 500 Internal Server Error'); + }); + + it('returns HTTP status when error object has no recognized fields', () => { + const error = {someField: 'value', html: '...'}; + const response = mockResponse(521, 'Sandbox Down'); + expect(getApiErrorMessage(error, response)).to.equal('HTTP 521 Sandbox Down'); + }); + + it('does not include HTML content in the message', () => { + const error = {body: 'Error page'}; + const response = mockResponse(521, 'Web Server Is Down'); + const message = getApiErrorMessage(error, response); + expect(message).to.equal('HTTP 521 Web Server Is Down'); + expect(message).to.not.include(''); + }); + }); + + describe('priority order', () => { + it('prioritizes error.error.message over other fields', () => { + const error = { + error: {message: 'ODS message'}, + fault: {message: 'OCAPI message'}, + detail: 'SCAPI detail', + message: 'Standard message', + }; + const response = mockResponse(500, 'Error'); + expect(getApiErrorMessage(error, response)).to.equal('ODS message'); + }); + + it('prioritizes fault.message over SCAPI and standard message', () => { + const error = { + fault: {message: 'OCAPI message'}, + detail: 'SCAPI detail', + message: 'Standard message', + }; + const response = mockResponse(500, 'Error'); + expect(getApiErrorMessage(error, response)).to.equal('OCAPI message'); + }); + + it('prioritizes detail over title and standard message', () => { + const error = { + title: 'Error Title', + detail: 'Error Detail', + message: 'Standard message', + }; + const response = mockResponse(500, 'Error'); + expect(getApiErrorMessage(error, response)).to.equal('Error Detail'); + }); + + it('prioritizes title over standard message', () => { + const error = { + title: 'Error Title', + message: 'Standard message', + }; + const response = mockResponse(500, 'Error'); + expect(getApiErrorMessage(error, response)).to.equal('Error Title'); + }); + }); + + describe('works with real Response objects', () => { + it('accepts a Response-like object', () => { + // Simulate what openapi-fetch returns + const error = null; + const response = { + status: 521, + statusText: 'Web Server Is Down', + ok: false, + headers: new Headers(), + }; + expect(getApiErrorMessage(error, response as Response)).to.equal('HTTP 521 Web Server Is Down'); + }); + }); +});