diff --git a/CHANGELOG.md b/CHANGELOG.md index 693081f..64f1047 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.3.0] - 2026-08-11 + +### Added +- `imageUrl` on `EditParams`, `AiVectorizeParams` and `RemoveBackgroundParams`, and `generationId` on edit and remove-background, so an image can be supplied without reading a local file +- `uploadId` on the same three, accepting an id from the temporary upload endpoint +- `client.upload.prepare({ filename })`, which returns a short-lived multipart upload URL and its expiry +- Exactly one image source must be set. Existing callers passing `image`/`file` validate unchanged + ### Fixed +- `EditClient`, `AIVectorizeClient` and `GenerateClient` sent a hardcoded `x-api-key` header on their stream paths, which returned a guaranteed 401 for OAuth callers. They now use `buildAuthHeaders()` +- A URL no longer reaches `addFileToForm`, which resolves every string against the local filesystem and threw `File not found: https://...` - Fix AI-enhanced release notes failing silently due to missing `models: read` permission in release job - Add test workflow for verifying GitHub Models API access diff --git a/package-lock.json b/package-lock.json index 2a0ac3b..5ff76dc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@genwave/svgmaker-sdk", - "version": "1.2.0", + "version": "1.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@genwave/svgmaker-sdk", - "version": "1.2.0", + "version": "1.3.0", "license": "MIT", "dependencies": { "async-retry": "^1.3.3", diff --git a/package.json b/package.json index a6c1330..32b5bb3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@genwave/svgmaker-sdk", - "version": "1.2.0", + "version": "1.3.0", "description": "Official Node.js SDK for SVGMaker API", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", diff --git a/src/clients/BaseClient.ts b/src/clients/BaseClient.ts index 7003df7..05efdab 100644 --- a/src/clients/BaseClient.ts +++ b/src/clients/BaseClient.ts @@ -151,6 +151,39 @@ export abstract class BaseClient { } } + /** + * Append the single image source the params carry. addFileToForm resolves + * every string against the local filesystem, so a URL or an id must never + * reach it — the schemas guarantee exactly one source is set. + * @param formData Form data + * @param params Validated request params + * @param fileField Field name the endpoint expects the raw file under + */ + protected async appendImageSource( + formData: FormData, + params: { + imageUrl?: string; + generationId?: string; + uploadId?: string; + [key: string]: unknown; + }, + fileField: 'image' | 'file' + ): Promise { + if (params.imageUrl) { + formData.append('imageUrl', params.imageUrl); + } else if (params.generationId) { + formData.append('generationId', params.generationId); + } else if (params.uploadId) { + formData.append('uploadId', params.uploadId); + } else { + await this.addFileToForm( + formData, + fileField, + params[fileField] as string | Buffer | Readable + ); + } + } + /** * Get MIME type from filename * @param filename File name diff --git a/src/clients/EditClient.ts b/src/clients/EditClient.ts index 034c92b..b065026 100644 --- a/src/clients/EditClient.ts +++ b/src/clients/EditClient.ts @@ -10,7 +10,10 @@ import { decodeSvgContent, decodeBase64Png } from '../utils/base64'; */ const editParamsSchema = z .object({ - image: z.union([z.string(), z.instanceof(Buffer), z.instanceof(Readable)]), + image: z.union([z.string(), z.instanceof(Buffer), z.instanceof(Readable)]).optional(), + imageUrl: z.string().optional(), + generationId: z.string().optional(), + uploadId: z.string().optional(), prompt: z.string().optional(), styleParams: z .object({ @@ -44,6 +47,18 @@ const editParamsSchema = z model: z.string().optional(), raster: z.boolean().optional(), }) + .refine( + data => + (data.image ? 1 : 0) + + (data.imageUrl ? 1 : 0) + + (data.generationId ? 1 : 0) + + (data.uploadId ? 1 : 0) === + 1, + { + message: + "Provide exactly one image source: 'image', 'imageUrl', 'generationId' or 'uploadId'", + } + ) .refine(data => data.prompt || data.styleParams, { message: 'Either prompt or styleParams must be provided', }) @@ -89,8 +104,7 @@ export class EditClient extends BaseClient { // Prepare form data const formData = new FormData(); - // Add image file - await this.addFileToForm(formData, 'image', this.params.image!); + await this.appendImageSource(formData, this.params, 'image'); // Add styleParams if present (requires JSON.stringify) if (this.params.styleParams) { @@ -205,8 +219,7 @@ export class EditClient extends BaseClient { // Prepare form data const formData = new FormData(); - // Add image file - await this.addFileToForm(formData, 'image', client.params.image!); + await this.appendImageSource(formData, client.params, 'image'); // Add prompt if present if (client.params.prompt) { @@ -253,12 +266,13 @@ export class EditClient extends BaseClient { formData.append('raster', String(client.params.raster)); } - // Make request to the streaming endpoint using native fetch + // Make request to the streaming endpoint using native fetch. Auth headers + // prefer the OAuth Bearer token when present, else the x-api-key. const response = await fetch(`${this.config.baseUrl}/v1/edit`, { method: 'POST', headers: { Accept: 'text/event-stream', - 'x-api-key': this.config.apiKey, + ...this.buildAuthHeaders(), }, body: formData, }); diff --git a/src/clients/GenerateClient.ts b/src/clients/GenerateClient.ts index 95e9f5a..1acfea7 100644 --- a/src/clients/GenerateClient.ts +++ b/src/clients/GenerateClient.ts @@ -172,13 +172,14 @@ export class GenerateClient extends BaseClient { // Execute the request and handle streaming (async () => { try { - // Make request to the streaming endpoint using native fetch + // Make request to the streaming endpoint using native fetch. Auth headers + // prefer the OAuth Bearer token when present, else the x-api-key. const response = await fetch(`${this.config.baseUrl}/v1/generate`, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'text/event-stream', - 'x-api-key': this.config.apiKey, + ...this.buildAuthHeaders(), }, body: JSON.stringify(client.params), }); diff --git a/src/clients/RemoveBackgroundClient.ts b/src/clients/RemoveBackgroundClient.ts index e4d9b44..13f02cd 100644 --- a/src/clients/RemoveBackgroundClient.ts +++ b/src/clients/RemoveBackgroundClient.ts @@ -12,12 +12,27 @@ import { decodeSvgContent } from '../utils/base64'; /** * Schema for validating remove background parameters */ -const removeBackgroundParamsSchema = z.object({ - file: z.union([z.string(), z.instanceof(Buffer), z.instanceof(Readable)]), - stream: z.boolean().optional(), - svgText: z.boolean().optional(), - storage: z.boolean().optional(), -}); +const removeBackgroundParamsSchema = z + .object({ + file: z.union([z.string(), z.instanceof(Buffer), z.instanceof(Readable)]).optional(), + imageUrl: z.string().optional(), + generationId: z.string().optional(), + uploadId: z.string().optional(), + stream: z.boolean().optional(), + svgText: z.boolean().optional(), + storage: z.boolean().optional(), + }) + .refine( + data => + (data.file ? 1 : 0) + + (data.imageUrl ? 1 : 0) + + (data.generationId ? 1 : 0) + + (data.uploadId ? 1 : 0) === + 1, + { + message: "Provide exactly one image source: 'file', 'imageUrl', 'generationId' or 'uploadId'", + } + ); /** * Client for the Remove Background API @@ -52,8 +67,7 @@ export class RemoveBackgroundClient extends BaseClient { // Prepare form data const formData = new FormData(); - // Add file - await this.addFileToForm(formData, 'file', this.params.file!); + await this.appendImageSource(formData, this.params, 'file'); // Add optional parameters this.appendOptionalParams(formData, this.params as Record, [ @@ -127,8 +141,7 @@ export class RemoveBackgroundClient extends BaseClient { // Prepare form data const formData = new FormData(); - // Add file - await this.addFileToForm(formData, 'file', client.params.file!); + await this.appendImageSource(formData, client.params, 'file'); // Add storage option if present if (client.params.storage !== undefined) { diff --git a/src/clients/UploadClient.ts b/src/clients/UploadClient.ts new file mode 100644 index 0000000..9828e8f --- /dev/null +++ b/src/clients/UploadClient.ts @@ -0,0 +1,90 @@ +import { BaseClient } from './BaseClient'; +import { UploadPrepareParams, UploadPrepareResponse } from '../types/api'; +import { SVGMakerClient } from '../core/SVGMakerClient'; +import { APIError } from '../errors/CustomErrors'; +import { z } from 'zod'; + +/** + * Schema for validating upload preparation parameters + */ +const uploadPrepareParamsSchema = z.object({ + filename: z.string().min(1), +}); + +/** + * Client for preparing temporary uploads through the SVGMaker API. + */ +export class UploadClient extends BaseClient { + /** + * Create a new Upload client + * @param client Parent SVGMaker client + */ + constructor(client: SVGMakerClient) { + super(client); + } + + /** + * Prepare a temporary upload endpoint. + * @param params Original file metadata + * @returns Short-lived multipart upload URL + */ + public async prepare(params: UploadPrepareParams): Promise { + this.logger.debug('Preparing temporary upload', { + filename: params.filename, + }); + + this.validateRequest(params, uploadPrepareParamsSchema); + + // Raw fetch bypasses the retry/timeout wrapper applied to httpClient.request. + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), this.config.timeout); + + let response: globalThis.Response; + try { + response = await fetch(`${this.config.baseUrl}/v1/upload/prepare`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...this.buildAuthHeaders(), + }, + body: JSON.stringify({ filename: params.filename }), + signal: controller.signal, + }); + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + throw new APIError( + `Upload preparation request timed out after ${this.config.timeout}ms`, + 408 + ); + } + throw error; + } finally { + clearTimeout(timer); + } + + if (!response.ok) { + await this.handleFetchErrorResponse(response); + } + + const rawResult = await response.json(); + const { data, metadata: responseMetadata } = this.unwrapEnvelope(rawResult); + + this.logger.debug('Temporary upload prepared'); + + return { + uploadUrl: data.upload_url, + expiresIn: data.expires_in, + metadata: responseMetadata, + }; + } + + /** + * Create a clone of this client + * @returns New client instance + */ + protected clone(): UploadClient { + const client = new UploadClient(this.client); + this.copyTo(client); + return client; + } +} diff --git a/src/clients/convert/AIVectorizeClient.ts b/src/clients/convert/AIVectorizeClient.ts index c2beca6..e8d389a 100644 --- a/src/clients/convert/AIVectorizeClient.ts +++ b/src/clients/convert/AIVectorizeClient.ts @@ -8,12 +8,18 @@ import { decodeSvgContent } from '../../utils/base64'; /** * Schema for validating AI vectorize parameters */ -const aiVectorizeParamsSchema = z.object({ - file: z.union([z.string(), z.instanceof(Buffer), z.instanceof(Readable)]), - storage: z.boolean().optional(), - stream: z.boolean().optional(), - svgText: z.boolean().optional(), -}); +const aiVectorizeParamsSchema = z + .object({ + file: z.union([z.string(), z.instanceof(Buffer), z.instanceof(Readable)]).optional(), + imageUrl: z.string().optional(), + uploadId: z.string().optional(), + storage: z.boolean().optional(), + stream: z.boolean().optional(), + svgText: z.boolean().optional(), + }) + .refine(data => (data.file ? 1 : 0) + (data.imageUrl ? 1 : 0) + (data.uploadId ? 1 : 0) === 1, { + message: "Provide exactly one image source: 'file', 'imageUrl' or 'uploadId'", + }); /** * Client for the AI Vectorize (Convert Image to SVG) API @@ -45,8 +51,7 @@ export class AIVectorizeClient extends BaseClient { // Prepare form data const formData = new FormData(); - // Add file - await this.addFileToForm(formData, 'file', this.params.file!); + await this.appendImageSource(formData, this.params, 'file'); // Add optional parameters this.appendOptionalParams(formData, this.params as Record, [ @@ -120,8 +125,7 @@ export class AIVectorizeClient extends BaseClient { // Prepare form data const formData = new FormData(); - // Add file - await this.addFileToForm(formData, 'file', client.params.file!); + await this.appendImageSource(formData, client.params, 'file'); // Add storage option if present if (client.params.storage !== undefined) { @@ -135,12 +139,13 @@ export class AIVectorizeClient extends BaseClient { formData.append('svgText', String(client.params.svgText)); } - // Make request to the streaming endpoint using native fetch + // Make request to the streaming endpoint using native fetch. Auth headers + // prefer the OAuth Bearer token when present, else the x-api-key. const response = await fetch(`${this.config.baseUrl}/v1/convert/ai-vectorize`, { method: 'POST', headers: { Accept: 'text/event-stream', - 'x-api-key': this.config.apiKey, + ...this.buildAuthHeaders(), }, body: formData, }); diff --git a/src/core/SVGMakerClient.ts b/src/core/SVGMakerClient.ts index b294f81..288edf8 100644 --- a/src/core/SVGMakerClient.ts +++ b/src/core/SVGMakerClient.ts @@ -14,6 +14,7 @@ import { GalleryClient } from '../clients/GalleryClient'; import { AccountClient } from '../clients/AccountClient'; import { OptimizeSvgClient } from '../clients/OptimizeSvgClient'; import { RemoveBackgroundClient } from '../clients/RemoveBackgroundClient'; +import { UploadClient } from '../clients/UploadClient'; import { createRetryWrapper } from '../utils/retry'; import { createRateLimiter } from '../utils/rateLimit'; import { Logger, createLogger } from '../utils/logger'; @@ -113,6 +114,11 @@ export class SVGMakerClient { */ public readonly enhancePrompt: EnhancePromptClient; + /** + * Upload client for minting signed upload tickets + */ + public readonly upload: UploadClient; + /** * Create a new SVGMaker client * @param apiKey API key for authentication @@ -163,6 +169,7 @@ export class SVGMakerClient { this.gallery = new GalleryClient(this); this.account = new AccountClient(this); this.optimizeSvg = new OptimizeSvgClient(this); + this.upload = new UploadClient(this); this.logger.info('SVGMaker SDK initialized'); } diff --git a/src/index.ts b/src/index.ts index f995a0e..d5dd25e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,6 +17,7 @@ import { RasterToRasterClient } from './clients/convert/RasterToRasterClient'; import { BatchConvertClient } from './clients/convert/BatchConvertClient'; import { EnhancePromptClient } from './clients/EnhancePromptClient'; import { RemoveBackgroundClient } from './clients/RemoveBackgroundClient'; +import { UploadClient } from './clients/UploadClient'; // Export error classes import * as Errors from './errors/CustomErrors'; @@ -46,6 +47,7 @@ export { BatchConvertClient, EnhancePromptClient, RemoveBackgroundClient, + UploadClient, // Utils HttpClient, diff --git a/src/types/api.ts b/src/types/api.ts index 2c5da0e..7df0e42 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -101,8 +101,17 @@ export interface GenerateParams { * Edit SVG/Image request parameters */ export interface EditParams { - /** Required: Image file to edit */ - image: string | Buffer | Readable; + /** Image file to edit. Provide exactly one of `image`, `imageUrl`, `generationId` or `uploadId`. */ + image?: string | Buffer | Readable; + + /** Publicly reachable https URL the API fetches itself. Provide exactly one image source. */ + imageUrl?: string; + + /** Id of a previous generation the API resolves internally. Provide exactly one image source. */ + generationId?: string; + + /** Id returned by the temporary upload endpoint. Provide exactly one image source. */ + uploadId?: string; /** Optional: Edit instructions as a simple text string */ prompt?: string; @@ -142,8 +151,14 @@ export interface EditParams { * AI Vectorize (Convert Image to SVG) request parameters */ export interface AiVectorizeParams { - /** Required: File to convert to SVG */ - file: string | Buffer | Readable; + /** File to convert to SVG. Provide exactly one of `file`, `imageUrl` or `uploadId`. */ + file?: string | Buffer | Readable; + + /** Publicly reachable https URL the API fetches itself. Provide exactly one image source. */ + imageUrl?: string; + + /** Id returned by the temporary upload endpoint. Provide exactly one image source. */ + uploadId?: string; /** Optional: Enable streaming response (default: false) */ stream?: boolean; @@ -168,8 +183,17 @@ export type ConvertParams = AiVectorizeParams; * transparency. Accepts any raster image (PNG, JPEG, WebP, etc.) or SVG. */ export interface RemoveBackgroundParams { - /** Required: Image file to remove the background from */ - file: string | Buffer | Readable; + /** Image file to remove the background from. Provide exactly one of `file`, `imageUrl`, `generationId` or `uploadId`. */ + file?: string | Buffer | Readable; + + /** Publicly reachable https URL the API fetches itself. Provide exactly one image source. */ + imageUrl?: string; + + /** Id of a previous generation the API resolves internally. Provide exactly one image source. */ + generationId?: string; + + /** Id returned by the temporary upload endpoint. Provide exactly one image source. */ + uploadId?: string; /** Optional: Enable streaming response (default: false) */ stream?: boolean; @@ -286,6 +310,22 @@ export interface OptimizeSvgResponse { metadata: ResponseMetadata; } +// --- Temporary Upload Types --- + +export interface UploadPrepareParams { + /** Required: Original file name, used to prepare a temporary upload endpoint. */ + filename: string; +} + +export interface UploadPrepareResponse { + /** Short-lived SVGMaker endpoint that accepts a multipart `file` upload. */ + uploadUrl: string; + /** Number of seconds before the upload endpoint expires. */ + expiresIn: number; + /** Response metadata */ + metadata: ResponseMetadata; +} + // --- Convert Results Types (shared by trace, svg-to-vector, raster-to-raster, batch) --- export interface ConvertResultItem { diff --git a/tests/clients/EditClient.test.ts b/tests/clients/EditClient.test.ts index 5add784..bdba632 100644 --- a/tests/clients/EditClient.test.ts +++ b/tests/clients/EditClient.test.ts @@ -9,6 +9,8 @@ import { mockFetchStreamResponse, createMockEditResponse, createMockStreamEvents, + createOAuthTestClient, + TEST_ACCESS_TOKEN, } from '../setup'; import { ValidationError, @@ -588,4 +590,240 @@ describe('EditClient', () => { expect(generatedEvent.pngImageData).toBeInstanceOf(Buffer); }); }); + + // ========================================================================== + // imageUrl source + // ========================================================================== + + describe('imageUrl', () => { + const IMAGE_URL = 'https://storage.example.com/temp-uploads/user/abc.png'; + let originalExistsSync: any; + + beforeEach(() => { + originalExistsSync = fs.existsSync; + fs.existsSync = jest.fn().mockReturnValue(false); + }); + + afterEach(() => { + fs.existsSync = originalExistsSync; + }); + + it('sends imageUrl as a form field without touching the filesystem', async () => { + const client = createTestClient(); + mockFetchJsonResponse(createMockEditResponse()); + + await client.edit.configure({ imageUrl: IMAGE_URL, prompt: 'Make it blue' }).execute(); + + const formData = fetchMock.mock.calls[0][1].body as FormData; + expect(formData.get('imageUrl')).toBe(IMAGE_URL); + expect(formData.get('image')).toBeNull(); + expect(fs.existsSync).not.toHaveBeenCalled(); + }); + + it('sends imageUrl on the streaming path', async () => { + const client = createTestClient(); + mockFetchStreamResponse([{ status: 'complete', message: 'done' }]); + + const stream = client.edit + .configure({ imageUrl: IMAGE_URL, prompt: 'Make it blue' }) + .stream(); + + await new Promise((resolve, reject) => { + stream.on('data', () => {}); + stream.on('end', resolve); + stream.on('error', reject); + }); + + const formData = fetchMock.mock.calls[0][1].body as FormData; + expect(formData.get('imageUrl')).toBe(IMAGE_URL); + expect(fs.existsSync).not.toHaveBeenCalled(); + }); + + it('throws ValidationError when neither image nor imageUrl is provided', async () => { + const client = createTestClient(); + + await expect(client.edit.configure({ prompt: 'Make it blue' }).execute()).rejects.toThrow( + ValidationError + ); + }); + + it('throws ValidationError when both image and imageUrl are provided', async () => { + const client = createTestClient(); + + await expect( + client.edit + .configure({ image: testImageBuffer, imageUrl: IMAGE_URL, prompt: 'Make it blue' }) + .execute() + ).rejects.toThrow(ValidationError); + }); + + it('accepts an image-only call', async () => { + const client = createTestClient(); + mockFetchJsonResponse(createMockEditResponse()); + + await client.edit.configure({ image: testImageBuffer, prompt: 'Make it blue' }).execute(); + + const formData = fetchMock.mock.calls[0][1].body as FormData; + expect(formData.get('image')).toBeTruthy(); + expect(formData.get('imageUrl')).toBeNull(); + }); + }); + + // ========================================================================== + // generationId source + // ========================================================================== + + describe('generationId', () => { + const GENERATION_ID = 'gen-abc-123'; + const IMAGE_URL = 'https://storage.example.com/temp-uploads/user/abc.png'; + let originalExistsSync: any; + + beforeEach(() => { + originalExistsSync = fs.existsSync; + fs.existsSync = jest.fn().mockReturnValue(false); + }); + + afterEach(() => { + fs.existsSync = originalExistsSync; + }); + + it('sends generationId as a form field without touching the filesystem', async () => { + const client = createTestClient(); + mockFetchJsonResponse(createMockEditResponse()); + + await client.edit + .configure({ generationId: GENERATION_ID, prompt: 'Make it blue' }) + .execute(); + + const formData = fetchMock.mock.calls[0][1].body as FormData; + expect(formData.get('generationId')).toBe(GENERATION_ID); + expect(formData.get('image')).toBeNull(); + expect(formData.get('imageUrl')).toBeNull(); + expect(fs.existsSync).not.toHaveBeenCalled(); + }); + + it('sends generationId on the streaming path', async () => { + const client = createTestClient(); + mockFetchStreamResponse([{ status: 'complete', message: 'done' }]); + + const stream = client.edit + .configure({ generationId: GENERATION_ID, prompt: 'Make it blue' }) + .stream(); + + await new Promise((resolve, reject) => { + stream.on('data', () => {}); + stream.on('end', resolve); + stream.on('error', reject); + }); + + const formData = fetchMock.mock.calls[0][1].body as FormData; + expect(formData.get('generationId')).toBe(GENERATION_ID); + expect(formData.get('image')).toBeNull(); + expect(fs.existsSync).not.toHaveBeenCalled(); + }); + + it('throws ValidationError when both generationId and image are provided', async () => { + const client = createTestClient(); + + await expect( + client.edit + .configure({ + image: testImageBuffer, + generationId: GENERATION_ID, + prompt: 'Make it blue', + }) + .execute() + ).rejects.toThrow(ValidationError); + }); + + it('throws ValidationError when both generationId and imageUrl are provided', async () => { + const client = createTestClient(); + + await expect( + client.edit + .configure({ + imageUrl: IMAGE_URL, + generationId: GENERATION_ID, + prompt: 'Make it blue', + }) + .execute() + ).rejects.toThrow(ValidationError); + }); + }); + + describe('uploadId', () => { + const UPLOAD_ID = 'upl_abc123'; + + it('sends uploadId as a form field without touching the filesystem', async () => { + const originalExistsSync = fs.existsSync; + fs.existsSync = jest.fn().mockReturnValue(false); + + try { + const client = createTestClient(); + mockFetchJsonResponse(createMockEditResponse()); + + await client.edit.configure({ uploadId: UPLOAD_ID, prompt: 'Make it blue' }).execute(); + + const formData = fetchMock.mock.calls[0][1].body as FormData; + expect(formData.get('uploadId')).toBe(UPLOAD_ID); + expect(formData.get('image')).toBeNull(); + expect(fs.existsSync).not.toHaveBeenCalled(); + } finally { + fs.existsSync = originalExistsSync; + } + }); + + it('sends uploadId on the streaming path', async () => { + const client = createTestClient(); + mockFetchStreamResponse([{ status: 'complete', message: 'done' }]); + + const stream = client.edit + .configure({ uploadId: UPLOAD_ID, prompt: 'Make it blue' }) + .stream(); + + await new Promise((resolve, reject) => { + stream.on('data', () => {}); + stream.on('end', resolve); + stream.on('error', reject); + }); + + const formData = fetchMock.mock.calls[0][1].body as FormData; + expect(formData.get('uploadId')).toBe(UPLOAD_ID); + }); + + it('rejects uploadId combined with another image source', async () => { + const client = createTestClient(); + + await expect( + client.edit + .configure({ image: testImageBuffer, uploadId: UPLOAD_ID, prompt: 'Make it blue' }) + .execute() + ).rejects.toThrow(ValidationError); + }); + }); + + // ========================================================================== + // Authentication + // ========================================================================== + + describe('authentication', () => { + it('sends a Bearer token on the streaming path when an access token is configured', async () => { + const client = createOAuthTestClient(); + mockFetchStreamResponse([{ status: 'complete', message: 'done' }]); + + const stream = client.edit + .configure({ image: testImageBuffer, prompt: 'Edit this' }) + .stream(); + + await new Promise((resolve, reject) => { + stream.on('data', () => {}); + stream.on('end', resolve); + stream.on('error', reject); + }); + + const [, options] = fetchMock.mock.calls[0]; + expect(options.headers.Authorization).toBe(`Bearer ${TEST_ACCESS_TOKEN}`); + expect(options.headers['x-api-key']).toBeUndefined(); + }); + }); }); diff --git a/tests/clients/GenerateClient.test.ts b/tests/clients/GenerateClient.test.ts index f3388b4..5d494dd 100644 --- a/tests/clients/GenerateClient.test.ts +++ b/tests/clients/GenerateClient.test.ts @@ -8,6 +8,8 @@ import { mockFetchStreamResponse, createMockGenerateResponse, createMockStreamEvents, + createOAuthTestClient, + TEST_ACCESS_TOKEN, } from '../setup'; import { ValidationError, RateLimitError, APIError } from '../../src/errors/CustomErrors'; @@ -551,4 +553,26 @@ describe('GenerateClient', () => { expect(completeEvent.generationId).toBe('gen-stream-001'); }); }); + // ========================================================================== + // Authentication + // ========================================================================== + + describe('authentication', () => { + it('sends a Bearer token on the streaming path when an access token is configured', async () => { + const client = createOAuthTestClient(); + mockFetchStreamResponse([{ status: 'complete', message: 'done' }]); + + const stream = client.generate.configure({ prompt: 'A cat' }).stream(); + + await new Promise((resolve, reject) => { + stream.on('data', () => {}); + stream.on('end', resolve); + stream.on('error', reject); + }); + + const [, options] = fetchMock.mock.calls[0]; + expect(options.headers.Authorization).toBe(`Bearer ${TEST_ACCESS_TOKEN}`); + expect(options.headers['x-api-key']).toBeUndefined(); + }); + }); }); diff --git a/tests/clients/RemoveBackgroundClient.test.ts b/tests/clients/RemoveBackgroundClient.test.ts new file mode 100644 index 0000000..3171683 --- /dev/null +++ b/tests/clients/RemoveBackgroundClient.test.ts @@ -0,0 +1,391 @@ +import fs from 'fs'; +import { Readable } from 'stream'; +import { + createTestClient, + setupFetchMock, + cleanupMocks, + mockFetchJsonResponse, + mockFetchErrorResponse, + mockFetchStreamResponse, + createOAuthTestClient, + TEST_ACCESS_TOKEN, +} from '../setup'; +import { + ValidationError, + AuthError, + InsufficientCreditsError, + RateLimitError, + APIError, +} from '../../src/errors/CustomErrors'; + +const IMAGE_URL = 'https://storage.example.com/temp-uploads/user/abc.png'; + +function mockRemoveBackgroundData(overrides?: Record): Record { + return { + svgUrl: 'https://storage.example.com/no-background.svg', + creditCost: 1, + message: 'Background removed successfully', + svgUrlExpiresIn: '24h', + generationId: 'rmbg-test-123', + ...overrides, + }; +} + +describe('RemoveBackgroundClient', () => { + let fetchMock: jest.Mock; + const testFileBuffer = Buffer.from('fake-png-image-data'); + + beforeEach(() => { + fetchMock = setupFetchMock(); + }); + + afterEach(() => { + cleanupMocks(); + }); + + describe('.configure()', () => { + it('returns a new instance (immutability)', () => { + const client = createTestClient(); + const original = client.removeBackground; + + expect(original.configure({ file: testFileBuffer })).not.toBe(original); + }); + }); + + describe('.execute()', () => { + it('sends POST to /v1/remove-background with FormData', async () => { + const client = createTestClient(); + mockFetchJsonResponse(mockRemoveBackgroundData()); + + await client.removeBackground.configure({ file: testFileBuffer }).execute(); + + const [url, options] = fetchMock.mock.calls[0]; + expect(url).toContain('/v1/remove-background'); + expect(options.method).toBe('POST'); + expect(options.body).toBeInstanceOf(FormData); + }); + + it('appends optional params to FormData', async () => { + const client = createTestClient(); + mockFetchJsonResponse(mockRemoveBackgroundData()); + + await client.removeBackground + .configure({ file: testFileBuffer, storage: true, svgText: true }) + .execute(); + + const formData = fetchMock.mock.calls[0][1].body as FormData; + expect(formData.get('file')).toBeTruthy(); + expect(formData.get('storage')).toBe('true'); + expect(formData.get('svgText')).toBe('true'); + }); + + it('returns a properly shaped RemoveBackgroundResponse', async () => { + const client = createTestClient(); + const rawSvg = ''; + mockFetchJsonResponse(mockRemoveBackgroundData({ svgText: rawSvg })); + + const result = await client.removeBackground.configure({ file: testFileBuffer }).execute(); + + expect(result.svgUrl).toBe('https://storage.example.com/no-background.svg'); + expect(result.creditCost).toBe(1); + expect(result.generationId).toBe('rmbg-test-123'); + expect(result.svgText).toBe(rawSvg); + expect(result.metadata).toBeDefined(); + }); + + it('accepts file as a Readable stream', async () => { + const client = createTestClient(); + mockFetchJsonResponse(mockRemoveBackgroundData()); + + const readable = new Readable(); + readable.push(Buffer.from('fake-stream-data')); + readable.push(null); + + await client.removeBackground.configure({ file: readable }).execute(); + + const formData = fetchMock.mock.calls[0][1].body as FormData; + expect(formData.get('file')).toBeTruthy(); + }); + }); + + describe('imageUrl', () => { + let originalExistsSync: any; + + beforeEach(() => { + originalExistsSync = fs.existsSync; + fs.existsSync = jest.fn().mockReturnValue(false); + }); + + afterEach(() => { + fs.existsSync = originalExistsSync; + }); + + it('sends imageUrl as a form field without touching the filesystem', async () => { + const client = createTestClient(); + mockFetchJsonResponse(mockRemoveBackgroundData()); + + await client.removeBackground.configure({ imageUrl: IMAGE_URL }).execute(); + + const formData = fetchMock.mock.calls[0][1].body as FormData; + expect(formData.get('imageUrl')).toBe(IMAGE_URL); + expect(formData.get('file')).toBeNull(); + expect(fs.existsSync).not.toHaveBeenCalled(); + }); + + it('sends imageUrl on the streaming path', async () => { + const client = createTestClient(); + mockFetchStreamResponse([{ status: 'complete', message: 'done' }]); + + const stream = client.removeBackground.configure({ imageUrl: IMAGE_URL }).stream(); + + await new Promise((resolve, reject) => { + stream.on('data', () => {}); + stream.on('end', resolve); + stream.on('error', reject); + }); + + const formData = fetchMock.mock.calls[0][1].body as FormData; + expect(formData.get('imageUrl')).toBe(IMAGE_URL); + expect(fs.existsSync).not.toHaveBeenCalled(); + }); + + it('throws ValidationError when neither file nor imageUrl is provided', async () => { + const client = createTestClient(); + + await expect(client.removeBackground.execute()).rejects.toThrow(ValidationError); + }); + + it('throws ValidationError when both file and imageUrl are provided', async () => { + const client = createTestClient(); + + await expect( + client.removeBackground.configure({ file: testFileBuffer, imageUrl: IMAGE_URL }).execute() + ).rejects.toThrow(ValidationError); + }); + + it('accepts a file-only call', async () => { + const client = createTestClient(); + mockFetchJsonResponse(mockRemoveBackgroundData()); + + await client.removeBackground.configure({ file: testFileBuffer }).execute(); + + const formData = fetchMock.mock.calls[0][1].body as FormData; + expect(formData.get('file')).toBeTruthy(); + expect(formData.get('imageUrl')).toBeNull(); + }); + }); + + describe('generationId', () => { + const GENERATION_ID = 'gen-abc-123'; + let originalExistsSync: any; + + beforeEach(() => { + originalExistsSync = fs.existsSync; + fs.existsSync = jest.fn().mockReturnValue(false); + }); + + afterEach(() => { + fs.existsSync = originalExistsSync; + }); + + it('sends generationId as a form field without touching the filesystem', async () => { + const client = createTestClient(); + mockFetchJsonResponse(mockRemoveBackgroundData()); + + await client.removeBackground.configure({ generationId: GENERATION_ID }).execute(); + + const formData = fetchMock.mock.calls[0][1].body as FormData; + expect(formData.get('generationId')).toBe(GENERATION_ID); + expect(formData.get('file')).toBeNull(); + expect(formData.get('imageUrl')).toBeNull(); + expect(fs.existsSync).not.toHaveBeenCalled(); + }); + + it('sends generationId on the streaming path', async () => { + const client = createTestClient(); + mockFetchStreamResponse([{ status: 'complete', message: 'done' }]); + + const stream = client.removeBackground.configure({ generationId: GENERATION_ID }).stream(); + + await new Promise((resolve, reject) => { + stream.on('data', () => {}); + stream.on('end', resolve); + stream.on('error', reject); + }); + + const formData = fetchMock.mock.calls[0][1].body as FormData; + expect(formData.get('generationId')).toBe(GENERATION_ID); + expect(formData.get('file')).toBeNull(); + expect(fs.existsSync).not.toHaveBeenCalled(); + }); + + it('throws ValidationError when both generationId and file are provided', async () => { + const client = createTestClient(); + + await expect( + client.removeBackground + .configure({ file: testFileBuffer, generationId: GENERATION_ID }) + .execute() + ).rejects.toThrow(ValidationError); + }); + + it('throws ValidationError when both generationId and imageUrl are provided', async () => { + const client = createTestClient(); + + await expect( + client.removeBackground + .configure({ imageUrl: IMAGE_URL, generationId: GENERATION_ID }) + .execute() + ).rejects.toThrow(ValidationError); + }); + }); + + describe('uploadId', () => { + const UPLOAD_ID = 'upl_abc123'; + + it('sends uploadId as a form field without touching the filesystem', async () => { + const originalExistsSync = fs.existsSync; + fs.existsSync = jest.fn().mockReturnValue(false); + + try { + const client = createTestClient(); + mockFetchJsonResponse(mockRemoveBackgroundData()); + + await client.removeBackground.configure({ uploadId: UPLOAD_ID }).execute(); + + const formData = fetchMock.mock.calls[0][1].body as FormData; + expect(formData.get('uploadId')).toBe(UPLOAD_ID); + expect(formData.get('file')).toBeNull(); + expect(fs.existsSync).not.toHaveBeenCalled(); + } finally { + fs.existsSync = originalExistsSync; + } + }); + + it('sends uploadId on the streaming path', async () => { + const client = createTestClient(); + mockFetchStreamResponse([{ status: 'complete', message: 'done' }]); + + const stream = client.removeBackground.configure({ uploadId: UPLOAD_ID }).stream(); + + await new Promise((resolve, reject) => { + stream.on('data', () => {}); + stream.on('end', resolve); + stream.on('error', reject); + }); + + const formData = fetchMock.mock.calls[0][1].body as FormData; + expect(formData.get('uploadId')).toBe(UPLOAD_ID); + }); + + it('rejects uploadId combined with another image source', async () => { + const client = createTestClient(); + + await expect( + client.removeBackground.configure({ file: testFileBuffer, uploadId: UPLOAD_ID }).execute() + ).rejects.toThrow(ValidationError); + }); + }); + + describe('.stream()', () => { + it('emits accumulated fields on the complete event', async () => { + const client = createTestClient(); + const rawSvg = ''; + mockFetchStreamResponse([ + { status: 'processing', message: 'Removing background...' }, + { status: 'generated', message: 'SVG generated', svgText: rawSvg, creditCost: 1 }, + { status: 'complete', message: 'Done', generationId: 'rmbg-stream-001' }, + ]); + + const stream = client.removeBackground + .configure({ file: testFileBuffer, svgText: true, storage: true }) + .stream(); + + const events: any[] = []; + await new Promise((resolve, reject) => { + stream.on('data', (event: any) => events.push(event)); + stream.on('end', resolve); + stream.on('error', reject); + }); + + const completeEvent = events.find(e => e.status === 'complete'); + expect(completeEvent.svgText).toBe(rawSvg); + expect(completeEvent.creditCost).toBe(1); + + const formData = fetchMock.mock.calls[0][1].body as FormData; + expect(formData.get('stream')).toBe('true'); + }); + + it('emits an error on a non-ok response', async () => { + const client = createTestClient(); + mockFetchErrorResponse('INTERNAL_ERROR', 500); + + const stream = client.removeBackground.configure({ file: testFileBuffer }).stream(); + + await expect( + new Promise((resolve, reject) => { + stream.on('data', () => {}); + stream.on('end', resolve); + stream.on('error', reject); + }) + ).rejects.toBeDefined(); + }); + }); + + describe('authentication', () => { + it('sends a Bearer token on the streaming path when an access token is configured', async () => { + const client = createOAuthTestClient(); + mockFetchStreamResponse([{ status: 'complete', message: 'done' }]); + + const stream = client.removeBackground.configure({ file: testFileBuffer }).stream(); + + await new Promise((resolve, reject) => { + stream.on('data', () => {}); + stream.on('end', resolve); + stream.on('error', reject); + }); + + const [, options] = fetchMock.mock.calls[0]; + expect(options.headers.Authorization).toBe(`Bearer ${TEST_ACCESS_TOKEN}`); + expect(options.headers['x-api-key']).toBeUndefined(); + }); + }); + + describe('error handling', () => { + it('throws AuthError on INVALID_API_KEY', async () => { + const client = createTestClient(); + mockFetchErrorResponse('INVALID_API_KEY', 401); + + await expect( + client.removeBackground.configure({ file: testFileBuffer }).execute() + ).rejects.toThrow(AuthError); + }); + + it('throws InsufficientCreditsError on INSUFFICIENT_CREDITS', async () => { + const client = createTestClient(); + mockFetchErrorResponse('INSUFFICIENT_CREDITS', 402); + + await expect( + client.removeBackground.configure({ file: testFileBuffer }).execute() + ).rejects.toThrow(InsufficientCreditsError); + }); + + it('throws RateLimitError on RATE_LIMIT_EXCEEDED', async () => { + const client = createTestClient(); + mockFetchErrorResponse('RATE_LIMIT_EXCEEDED', 429); + + await expect( + client.removeBackground.configure({ file: testFileBuffer }).execute() + ).rejects.toThrow(RateLimitError); + }); + + it('throws APIError on a generic server error', async () => { + const client = createTestClient(); + mockFetchErrorResponse('INTERNAL_ERROR', 500); + + await expect( + client.removeBackground.configure({ file: testFileBuffer }).execute() + ).rejects.toThrow(APIError); + }); + }); +}); diff --git a/tests/clients/UploadClient.test.ts b/tests/clients/UploadClient.test.ts new file mode 100644 index 0000000..8105d85 --- /dev/null +++ b/tests/clients/UploadClient.test.ts @@ -0,0 +1,132 @@ +import { + createTestClient, + setupFetchMock, + cleanupMocks, + mockFetchJsonResponse, + mockFetchErrorResponse, + createOAuthTestClient, + TEST_ACCESS_TOKEN, +} from '../setup'; +import { + ValidationError, + AuthError, + RateLimitError, + APIError, +} from '../../src/errors/CustomErrors'; + +const PREPARED_UPLOAD = { + upload_url: 'https://api.svgmaker.io/v1/upload/signed-temporary-token', + expires_in: 300, +}; + +const VALID_PARAMS = { filename: 'panda.png' }; + +describe('UploadClient', () => { + let fetchMock: jest.Mock; + + beforeEach(() => { + fetchMock = setupFetchMock(); + }); + + afterEach(() => { + cleanupMocks(); + }); + + describe('.prepare()', () => { + it('sends a JSON POST to /v1/upload/prepare', async () => { + const client = createTestClient(); + mockFetchJsonResponse(PREPARED_UPLOAD); + + await client.upload.prepare(VALID_PARAMS); + + const [url, options] = fetchMock.mock.calls[0]; + expect(url).toContain('/v1/upload/prepare'); + expect(options.method).toBe('POST'); + expect(options.headers['Content-Type']).toBe('application/json'); + expect(options.headers['x-api-key']).toBe('test-api-key-123'); + expect(JSON.parse(options.body)).toEqual({ filename: 'panda.png' }); + }); + + it('passes an abort signal so a hung request cannot wedge the caller', async () => { + const client = createTestClient(); + mockFetchJsonResponse(PREPARED_UPLOAD); + + await client.upload.prepare(VALID_PARAMS); + + const [, options] = fetchMock.mock.calls[0]; + expect(options.signal).toBeInstanceOf(AbortSignal); + }); + + it('maps the snake_case envelope to camelCase', async () => { + const client = createTestClient(); + mockFetchJsonResponse(PREPARED_UPLOAD); + + const result = await client.upload.prepare(VALID_PARAMS); + + expect(result.uploadUrl).toBe(PREPARED_UPLOAD.upload_url); + expect(result.expiresIn).toBe(PREPARED_UPLOAD.expires_in); + expect(result.metadata.requestId).toBe('test-req-id'); + }); + + it('sends a Bearer token when an access token is configured', async () => { + const client = createOAuthTestClient(); + mockFetchJsonResponse(PREPARED_UPLOAD); + + await client.upload.prepare(VALID_PARAMS); + + const [, options] = fetchMock.mock.calls[0]; + expect(options.headers.Authorization).toBe(`Bearer ${TEST_ACCESS_TOKEN}`); + expect(options.headers['x-api-key']).toBeUndefined(); + }); + }); + + describe('validation', () => { + it('throws ValidationError when filename is missing or empty', async () => { + const client = createTestClient(); + + await expect(client.upload.prepare({ filename: '' })).rejects.toThrow(ValidationError); + await expect(client.upload.prepare({} as unknown as { filename: string })).rejects.toThrow( + ValidationError + ); + }); + }); + + describe('error handling', () => { + it('throws AuthError on INVALID_API_KEY', async () => { + const client = createTestClient(); + mockFetchErrorResponse('INVALID_API_KEY', 401); + + await expect(client.upload.prepare(VALID_PARAMS)).rejects.toThrow(AuthError); + }); + + it('throws RateLimitError on RATE_LIMIT_EXCEEDED', async () => { + const client = createTestClient(); + mockFetchErrorResponse('RATE_LIMIT_EXCEEDED', 429); + + await expect(client.upload.prepare(VALID_PARAMS)).rejects.toThrow(RateLimitError); + }); + + it('throws APIError on VALIDATION_ERROR from the API', async () => { + const client = createTestClient(); + mockFetchErrorResponse('VALIDATION_ERROR', 400, 'filename is invalid.'); + + await expect(client.upload.prepare(VALID_PARAMS)).rejects.toThrow(APIError); + }); + + it('throws a 408 APIError when the request is aborted', async () => { + const client = createTestClient(); + const abortError = new Error('aborted'); + abortError.name = 'AbortError'; + fetchMock.mockRejectedValue(abortError); + + await expect(client.upload.prepare(VALID_PARAMS)).rejects.toThrow(/timed out/); + }); + + it('rethrows non-abort network errors unchanged', async () => { + const client = createTestClient(); + fetchMock.mockRejectedValue(new Error('ECONNREFUSED')); + + await expect(client.upload.prepare(VALID_PARAMS)).rejects.toThrow('ECONNREFUSED'); + }); + }); +}); diff --git a/tests/clients/convert/AIVectorizeClient.test.ts b/tests/clients/convert/AIVectorizeClient.test.ts index 351ceaf..2e33a3c 100644 --- a/tests/clients/convert/AIVectorizeClient.test.ts +++ b/tests/clients/convert/AIVectorizeClient.test.ts @@ -9,6 +9,8 @@ import { mockFetchStreamResponse, createMockAiVectorizeResponse, createMockAiVectorizeStreamEvents, + createOAuthTestClient, + TEST_ACCESS_TOKEN, } from '../../setup'; import { ValidationError, @@ -398,4 +400,138 @@ describe('AIVectorizeClient', () => { ).rejects.toBeDefined(); }); }); + // ========================================================================== + // imageUrl source + // ========================================================================== + + describe('imageUrl', () => { + const IMAGE_URL = 'https://storage.example.com/temp-uploads/user/abc.png'; + let originalExistsSync: any; + + beforeEach(() => { + originalExistsSync = fs.existsSync; + fs.existsSync = jest.fn().mockReturnValue(false); + }); + + afterEach(() => { + fs.existsSync = originalExistsSync; + }); + + it('sends imageUrl as a form field without touching the filesystem', async () => { + const client = createTestClient(); + mockFetchJsonResponse(createMockAiVectorizeResponse()); + + await client.convert.aiVectorize.configure({ imageUrl: IMAGE_URL }).execute(); + + const formData = fetchMock.mock.calls[0][1].body as FormData; + expect(formData.get('imageUrl')).toBe(IMAGE_URL); + expect(formData.get('file')).toBeNull(); + expect(fs.existsSync).not.toHaveBeenCalled(); + }); + + it('sends imageUrl on the streaming path', async () => { + const client = createTestClient(); + mockFetchStreamResponse([{ status: 'complete', message: 'done' }]); + + const stream = client.convert.aiVectorize.configure({ imageUrl: IMAGE_URL }).stream(); + + await new Promise((resolve, reject) => { + stream.on('data', () => {}); + stream.on('end', resolve); + stream.on('error', reject); + }); + + const formData = fetchMock.mock.calls[0][1].body as FormData; + expect(formData.get('imageUrl')).toBe(IMAGE_URL); + expect(fs.existsSync).not.toHaveBeenCalled(); + }); + + it('throws ValidationError when neither file nor imageUrl is provided', async () => { + const client = createTestClient(); + + await expect(client.convert.aiVectorize.execute()).rejects.toThrow(ValidationError); + }); + + it('throws ValidationError when both file and imageUrl are provided', async () => { + const client = createTestClient(); + + await expect( + client.convert.aiVectorize + .configure({ file: testFileBuffer, imageUrl: IMAGE_URL }) + .execute() + ).rejects.toThrow(ValidationError); + }); + }); + + describe('uploadId', () => { + const UPLOAD_ID = 'upl_abc123'; + + it('sends uploadId as a form field without touching the filesystem', async () => { + const originalExistsSync = fs.existsSync; + fs.existsSync = jest.fn().mockReturnValue(false); + + try { + const client = createTestClient(); + mockFetchJsonResponse(createMockAiVectorizeResponse()); + + await client.convert.aiVectorize.configure({ uploadId: UPLOAD_ID }).execute(); + + const formData = fetchMock.mock.calls[0][1].body as FormData; + expect(formData.get('uploadId')).toBe(UPLOAD_ID); + expect(formData.get('file')).toBeNull(); + expect(fs.existsSync).not.toHaveBeenCalled(); + } finally { + fs.existsSync = originalExistsSync; + } + }); + + it('sends uploadId on the streaming path', async () => { + const client = createTestClient(); + mockFetchStreamResponse([{ status: 'complete', message: 'done' }]); + + const stream = client.convert.aiVectorize.configure({ uploadId: UPLOAD_ID }).stream(); + + await new Promise((resolve, reject) => { + stream.on('data', () => {}); + stream.on('end', resolve); + stream.on('error', reject); + }); + + const formData = fetchMock.mock.calls[0][1].body as FormData; + expect(formData.get('uploadId')).toBe(UPLOAD_ID); + }); + + it('rejects uploadId combined with another image source', async () => { + const client = createTestClient(); + + await expect( + client.convert.aiVectorize + .configure({ file: testFileBuffer, uploadId: UPLOAD_ID }) + .execute() + ).rejects.toThrow(ValidationError); + }); + }); + + // ========================================================================== + // Authentication + // ========================================================================== + + describe('authentication', () => { + it('sends a Bearer token on the streaming path when an access token is configured', async () => { + const client = createOAuthTestClient(); + mockFetchStreamResponse([{ status: 'complete', message: 'done' }]); + + const stream = client.convert.aiVectorize.configure({ file: testFileBuffer }).stream(); + + await new Promise((resolve, reject) => { + stream.on('data', () => {}); + stream.on('end', resolve); + stream.on('error', reject); + }); + + const [, options] = fetchMock.mock.calls[0]; + expect(options.headers.Authorization).toBe(`Bearer ${TEST_ACCESS_TOKEN}`); + expect(options.headers['x-api-key']).toBeUndefined(); + }); + }); }); diff --git a/tests/setup/test-setup.ts b/tests/setup/test-setup.ts index 78b9bd4..90e636a 100644 --- a/tests/setup/test-setup.ts +++ b/tests/setup/test-setup.ts @@ -7,6 +7,7 @@ import { SVGMakerClient } from '../../src/core/SVGMakerClient'; export const TEST_API_KEY = 'test-api-key-123'; export const TEST_BASE_URL = 'https://api.svgmaker.io'; +export const TEST_ACCESS_TOKEN = 'test-access-token-abc'; /** * Default test configuration with retries disabled @@ -35,6 +36,13 @@ export function createTestClient(): SVGMakerClient { return new SVGMakerClient(TEST_API_KEY, DEFAULT_TEST_CONFIG); } +/** + * Create a client authenticated with an OAuth access token and no API key + */ +export function createOAuthTestClient(): SVGMakerClient { + return new SVGMakerClient('', { ...DEFAULT_TEST_CONFIG, accessToken: TEST_ACCESS_TOKEN }); +} + // ============================================================================ // Fetch Mock Helpers // ============================================================================