From 1fe4ecadd2a45f867e5bcbc87925238f4b7676c1 Mon Sep 17 00:00:00 2001 From: SudoMock Labs Date: Sat, 19 Sep 2026 14:55:54 +0300 Subject: [PATCH 1/2] =?UTF-8?q?S=C3=BCr=C3=BCm=202.7.1:=20eski=20eri=C5=9F?= =?UTF-8?q?imciler=20yay=C4=B1mland=C4=B1klar=C4=B1=20uca=20geri=20d=C3=B6?= =?UTF-8?q?nd=C3=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit client.ai ve client.mockups 2.7.0'da aile yollarına yönlendirilmişti. Paketi yükseltip kodunu değiştirmeyen çağıranın açtığı yeni işler sessizce photo_mockup_* kind'ıyla dönüyor, job.kind'a göre dallanan kod hiçbir hata vermeden eşleşmez oluyordu. İkisi de yeniden /api/v1/sudoai/2d-mockups ve /api/v1/mockups üzerinde; photoMockups ve psdMockups aile yollarında kalıyor. İki eski erişimci setter kazandı. Getter-only olduklarından test sahtesi enjekte eden atama ES modülünde TypeError atıyor, düz CommonJS'te (sloppy mod) sessizce düşüyordu; ikincisinde çağrı testten çıkıp gerçek API'ye gidiyor ve gerçek kredi harcıyordu. Testler iki dalı da kanıtlıyor: eski erişimcinin her yöntemi kendi yolunda, atama ise hem strict hem sloppy modda. --- CHANGELOG.md | 18 ++++ README.md | 6 +- package-lock.json | 4 +- package.json | 2 +- src/index.ts | 35 +++++-- src/resources/ai.ts | 23 +++-- src/resources/mockups.ts | 18 +++- tests/resource-names.test.ts | 187 +++++++++++++++++++++++++++++++++-- 8 files changed, 263 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cc479b2..602be25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,24 @@ project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +## [2.7.1] - 2026-09-19 + +### Fixed +- `client.ai` and `client.mockups` are pinned to the paths they were published + on: `/api/v1/sudoai/2d-mockups` and `/api/v1/mockups`. 2.7.0 routed them to + the family paths, so upgrading the package alone moved a caller's new jobs to + the `photo_mockup_*` kinds without an error anywhere, and code branching on + `job.kind === '2d_create'` stopped matching in silence. An async job opened + through an earlier name again resolves with `2d_create` / `2d_render`, as + 2.6.x documented. `client.photoMockups` and `client.psdMockups` keep the + family paths and the `photo_mockup_*` kinds; move to them to adopt the new + names deliberately. +- `client.ai` and `client.mockups` accept assignment. They were getter-only, so + injecting a test double threw a `TypeError` in an ES module and was dropped + without a word in plain CommonJS -- where the call then left the test and + reached the live API. Assigning to either now replaces only that accessor; + `client.photoMockups` / `client.psdMockups` are untouched. + ## [2.7.0] - 2026-09-19 ### Added diff --git a/README.md b/README.md index d5ddf9e..1e9aa01 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,11 @@ The API key can be passed as the first argument or via the `SUDOMOCK_API_KEY` en Every request identifies itself with `X-SudoMock-Client: node-sdk/` (and the same value as `User-Agent` where the runtime allows it), so you can pick the SDK's traffic out of your own logs and proxy rules. `client.ai` and `client.mockups` are the earlier names of `client.photoMockups` -and `client.psdMockups`. They return the same objects and warn once per process. +and `client.psdMockups`. Each stays on the endpoint it was published on, so an +async job you open through an earlier name comes back under the `2d_*` kind it +always did, and each warns once per process. Switch to `client.photoMockups` / +`client.psdMockups` to move to the family paths and the `photo_mockup_*` kinds. +Both earlier names accept assignment, so a test double can replace them. ### PSD Mockups (`client.psdMockups`) diff --git a/package-lock.json b/package-lock.json index 069cef3..8b746a2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sudomock", - "version": "2.7.0", + "version": "2.7.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sudomock", - "version": "2.7.0", + "version": "2.7.1", "license": "MIT", "devDependencies": { "@types/node": "^22.19.15", diff --git a/package.json b/package.json index 014a690..a63c439 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sudomock", - "version": "2.7.0", + "version": "2.7.1", "description": "Official Node.js/TypeScript SDK for the SudoMock API - mockup generation from PSD files", "type": "module", "main": "./dist/index.cjs", diff --git a/src/index.ts b/src/index.ts index 48d7cdf..b2f6bda 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,9 @@ import { deprecate } from 'node:util' import { HttpClient } from './client' import { SudoMockError } from './errors' -import { MockupsResource } from './resources/mockups' +import { EARLIER_PSD_MOCKUPS_PATH, MockupsResource } from './resources/mockups' import { RendersResource } from './resources/renders' -import { AIResource } from './resources/ai' +import { AIResource, EARLIER_PHOTO_MOCKUPS_PATH } from './resources/ai' import { ImagesResource } from './resources/images' import { UploadsResource } from './resources/uploads' import { AccountResource } from './resources/account' @@ -70,6 +70,12 @@ class SudoMock { /** Manage webhook endpoints and their deliveries */ readonly webhooks: WebhooksResource + // Backing objects for the earlier accessor names. They speak to the paths + // those names were published on, so an upgrade alone never moves a caller's + // work to a different family. Writable, so a test double can replace them. + private earlierPhotoMockups: AIResource + private earlierPsdMockups: MockupsResource + constructor(apiKey?: string, options: SudoMockOptions = {}) { const resolvedKey = apiKey ?? options.apiKey ?? process.env['SUDOMOCK_API_KEY'] ?? '' @@ -95,18 +101,35 @@ class SudoMock { this.studio = new StudioResource(client) this.jobs = new JobsResource(client) this.webhooks = new WebhooksResource(client) + + this.earlierPhotoMockups = new AIResource(client, EARLIER_PHOTO_MOCKUPS_PATH) + this.earlierPsdMockups = new MockupsResource(client, EARLIER_PSD_MOCKUPS_PATH) } - /** @deprecated Earlier name of {@link SudoMock.psdMockups}. The same object; warns once per process. */ + /** + * @deprecated Earlier name of {@link SudoMock.psdMockups}. Same methods, on + * the path this name was published on; warns once per process. + */ get mockups(): MockupsResource { warnMockups() - return this.psdMockups + return this.earlierPsdMockups + } + + set mockups(resource: MockupsResource) { + this.earlierPsdMockups = resource } - /** @deprecated Earlier name of {@link SudoMock.photoMockups}. The same object; warns once per process. */ + /** + * @deprecated Earlier name of {@link SudoMock.photoMockups}. Same methods, on + * the path this name was published on; warns once per process. + */ get ai(): AIResource { warnAi() - return this.photoMockups + return this.earlierPhotoMockups + } + + set ai(resource: AIResource) { + this.earlierPhotoMockups = resource } } diff --git a/src/resources/ai.ts b/src/resources/ai.ts index f077f9d..03287e6 100644 --- a/src/resources/ai.ts +++ b/src/resources/ai.ts @@ -145,8 +145,17 @@ function wirePlacement( * manage that catalog. Creation costs 25 credits. Rendering costs 5 credits * per call. */ +/** Path the photo-mockup family is served on. */ +export const PHOTO_MOCKUPS_PATH = '/api/v1/photo-mockups' +/** Path `client.ai` was published on; it still answers, with the job kinds and + * event names a caller pinned to that accessor already branches on. */ +export const EARLIER_PHOTO_MOCKUPS_PATH = '/api/v1/sudoai/2d-mockups' + export class AIResource { - constructor(private readonly client: HttpClient) {} + constructor( + private readonly client: HttpClient, + private readonly basePath: string = PHOTO_MOCKUPS_PATH, + ) {} /** * Create a reusable 2D mockup from a public URL or base64 image. @@ -211,7 +220,7 @@ export class AIResource { TwoDMockupDetails | Job >({ method: 'POST', - path: '/api/v1/photo-mockups', + path: this.basePath, body, headers: { 'Idempotency-Key': idempotencyKey }, }) @@ -352,7 +361,7 @@ export class AIResource { AIRenderResult | Job >({ method: 'POST', - path: `/api/v1/photo-mockups/${params.mockupId}/render`, + path: `${this.basePath}/${params.mockupId}/render`, body, timeout: AI_RENDER_TIMEOUT, }) @@ -400,7 +409,7 @@ export class AIResource { offset?: number }>({ method: 'GET', - path: '/api/v1/photo-mockups', + path: this.basePath, query: { limit: params.limit, offset: params.offset, @@ -423,7 +432,7 @@ export class AIResource { async get(mockupId: string): Promise { const mockup = await this.client.request({ method: 'GET', - path: `/api/v1/photo-mockups/${mockupId}`, + path: `${this.basePath}/${mockupId}`, }) return publicMockupDetails(mockup) } @@ -444,7 +453,7 @@ export class AIResource { const result = await this.client.request({ method: 'PUT', - path: `/api/v1/photo-mockups/${mockupId}/print-areas`, + path: `${this.basePath}/${mockupId}/print-areas`, body: { printAreas }, }) return { @@ -459,7 +468,7 @@ export class AIResource { async delete(mockupId: string): Promise { await this.client.request({ method: 'DELETE', - path: `/api/v1/photo-mockups/${mockupId}`, + path: `${this.basePath}/${mockupId}`, }) } } diff --git a/src/resources/mockups.ts b/src/resources/mockups.ts index e04d47f..7820143 100644 --- a/src/resources/mockups.ts +++ b/src/resources/mockups.ts @@ -74,8 +74,16 @@ export function toPublicMockup(mockup: Mockup): Mockup { } } +/** Path the PSD-mockup family is served on. */ +export const PSD_MOCKUPS_PATH = '/api/v1/psd-mockups' +/** Path `client.mockups` was published on; it still answers. */ +export const EARLIER_PSD_MOCKUPS_PATH = '/api/v1/mockups' + export class MockupsResource { - constructor(private readonly client: HttpClient) {} + constructor( + private readonly client: HttpClient, + private readonly basePath: string = PSD_MOCKUPS_PATH, + ) {} /** * List mockups with pagination and filtering. @@ -88,7 +96,7 @@ export class MockupsResource { async list(params: ListMockupsParams = {}): Promise { const result = await this.client.request({ method: 'GET', - path: '/api/v1/psd-mockups', + path: this.basePath, query: { limit: params.limit, offset: params.offset, @@ -119,7 +127,7 @@ export class MockupsResource { async get(uuid: string): Promise { const mockup = await this.client.request({ method: 'GET', - path: `/api/v1/psd-mockups/${uuid}`, + path: `${this.basePath}/${uuid}`, }) return toPublicMockup(mockup) } @@ -135,7 +143,7 @@ export class MockupsResource { async update(uuid: string, params: { name: string }): Promise { const mockup = await this.client.request({ method: 'PATCH', - path: `/api/v1/psd-mockups/${uuid}`, + path: `${this.basePath}/${uuid}`, body: params, }) return toPublicMockup(mockup) @@ -152,7 +160,7 @@ export class MockupsResource { async delete(uuid: string): Promise { await this.client.request({ method: 'DELETE', - path: `/api/v1/psd-mockups/${uuid}`, + path: `${this.basePath}/${uuid}`, }) } } diff --git a/tests/resource-names.test.ts b/tests/resource-names.test.ts index 291052a..f6af746 100644 --- a/tests/resource-names.test.ts +++ b/tests/resource-names.test.ts @@ -1,6 +1,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { http, HttpResponse } from 'msw' import SudoMock from '../src/index' +import { AIResource } from '../src/resources/ai' +import { MockupsResource } from '../src/resources/mockups' import { MOCK_2D_MOCKUP, MOCK_AI_RENDER_RESPONSE, @@ -19,8 +21,8 @@ const PSD_ID = MOCK_MOCKUP.uuid // --------------------------------------------------------------------------- // client.photoMockups / client.psdMockups are the accessors. client.ai and -// client.mockups return the same objects and warn once, so a caller on the -// earlier names keeps working and learns the new ones from the warning. +// client.mockups keep working and warn once, so a caller on the earlier names +// learns the new ones from the warning. // --------------------------------------------------------------------------- describe('deprecated accessors', () => { @@ -28,12 +30,12 @@ describe('deprecated accessors', () => { vi.restoreAllMocks() }) - it('client.ai is client.photoMockups and warns once', () => { + it('client.ai is a photo-mockup resource and warns once', () => { const warn = vi.spyOn(process, 'emitWarning').mockImplementation(() => {}) const client = createClient() - expect(client.ai).toBe(client.photoMockups) - expect(client.ai).toBe(client.photoMockups) + expect(client.ai).toBeInstanceOf(AIResource) + expect(client.ai).toBe(client.ai) expect(createClient().ai).toBeDefined() expect(warn).toHaveBeenCalledTimes(1) @@ -43,12 +45,12 @@ describe('deprecated accessors', () => { expect(call?.[1]).toBe('DeprecationWarning') }) - it('client.mockups is client.psdMockups and warns once', () => { + it('client.mockups is a PSD-mockup resource and warns once', () => { const warn = vi.spyOn(process, 'emitWarning').mockImplementation(() => {}) const client = createClient() - expect(client.mockups).toBe(client.psdMockups) - expect(client.mockups).toBe(client.psdMockups) + expect(client.mockups).toBeInstanceOf(MockupsResource) + expect(client.mockups).toBe(client.mockups) expect(createClient().mockups).toBeDefined() expect(warn).toHaveBeenCalledTimes(1) @@ -172,3 +174,172 @@ describe('family paths on the wire', () => { ]) }) }) + +// --------------------------------------------------------------------------- +// The earlier accessors stay on the path they were published on. A project +// that upgrades the package without touching its code keeps opening the jobs +// it opened before, under the kinds and event names it already branches on. +// --------------------------------------------------------------------------- + +describe('earlier accessors stay on the published path', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('client.ai sends every call to /api/v1/sudoai/2d-mockups', async () => { + vi.spyOn(process, 'emitWarning').mockImplementation(() => {}) + const hits: string[] = [] + const record = (request: Request) => { + hits.push(`${request.method} ${new URL(request.url).pathname}`) + } + const base = `${TEST_BASE_URL}/api/v1/sudoai/2d-mockups` + server.use( + http.post(base, ({ request }) => { + record(request) + return HttpResponse.json( + { success: true, data: MOCK_2D_MOCKUP }, + { status: 201 }, + ) + }), + http.get(base, ({ request }) => { + record(request) + return HttpResponse.json({ + success: true, + data: [MOCK_2D_MOCKUP], + total: 1, + limit: 20, + offset: 0, + }) + }), + http.get(`${base}/:id`, ({ request }) => { + record(request) + return HttpResponse.json({ success: true, data: MOCK_2D_MOCKUP }) + }), + http.put(`${base}/:id/print-areas`, ({ request }) => { + record(request) + return HttpResponse.json({ + success: true, + data: { mockup_id: PHOTO_ID, print_areas: [] }, + }) + }), + http.post(`${base}/:id/render`, ({ request }) => { + record(request) + return HttpResponse.json(MOCK_AI_RENDER_RESPONSE) + }), + http.delete(`${base}/:id`, ({ request }) => { + record(request) + return HttpResponse.json({ success: true, data: { deleted: true } }) + }), + ) + + const earlier = createClient().ai + await earlier.create({ sourceUrl: 'https://example.com/product.jpg' }) + await earlier.list() + await earlier.get(PHOTO_ID) + await earlier.updatePrintAreas(PHOTO_ID, []) + await earlier.render({ + mockupId: PHOTO_ID, + printAreas: [{ uuid: 'pa-uuid', artworkUrl: 'https://example.com/design.png' }], + }) + await earlier.delete(PHOTO_ID) + + expect(hits).toEqual([ + 'POST /api/v1/sudoai/2d-mockups', + 'GET /api/v1/sudoai/2d-mockups', + `GET /api/v1/sudoai/2d-mockups/${PHOTO_ID}`, + `PUT /api/v1/sudoai/2d-mockups/${PHOTO_ID}/print-areas`, + `POST /api/v1/sudoai/2d-mockups/${PHOTO_ID}/render`, + `DELETE /api/v1/sudoai/2d-mockups/${PHOTO_ID}`, + ]) + }) + + it('client.mockups sends every call to /api/v1/mockups', async () => { + vi.spyOn(process, 'emitWarning').mockImplementation(() => {}) + const hits: string[] = [] + const record = (request: Request) => { + hits.push(`${request.method} ${new URL(request.url).pathname}`) + } + const base = `${TEST_BASE_URL}/api/v1/mockups` + server.use( + http.get(base, ({ request }) => { + record(request) + return HttpResponse.json({ + success: true, + data: { mockups: [MOCK_MOCKUP], total: 1, limit: 20, offset: 0 }, + }) + }), + http.get(`${base}/:uuid`, ({ request }) => { + record(request) + return HttpResponse.json({ success: true, data: MOCK_MOCKUP }) + }), + http.patch(`${base}/:uuid`, ({ request }) => { + record(request) + return HttpResponse.json({ success: true, data: MOCK_MOCKUP }) + }), + http.delete(`${base}/:uuid`, ({ request }) => { + record(request) + return new HttpResponse(null, { status: 204 }) + }), + ) + + const earlier = createClient().mockups + await earlier.list() + await earlier.get(PSD_ID) + await earlier.update(PSD_ID, { name: 'Renamed' }) + await earlier.delete(PSD_ID) + + expect(hits).toEqual([ + 'GET /api/v1/mockups', + `GET /api/v1/mockups/${PSD_ID}`, + `PATCH /api/v1/mockups/${PSD_ID}`, + `DELETE /api/v1/mockups/${PSD_ID}`, + ]) + }) +}) + +// --------------------------------------------------------------------------- +// Assigning to an earlier accessor is how a test double gets injected. Without +// a setter the assignment throws in an ES module (strict mode) and is dropped +// without a word in plain CommonJS (sloppy mode) -- where the call then leaves +// the test and reaches the real API on the caller's own credits. +// --------------------------------------------------------------------------- + +describe('earlier accessors accept assignment', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + // A function built with `new Function` has a sloppy-mode body, the same mode + // a plain CommonJS file runs in. + const sloppyAssign = new Function( + 'target', + 'name', + 'value', + 'target[name] = value; return target[name]', + ) as (target: unknown, name: string, value: unknown) => unknown + + it('takes the assignment in strict mode (ES module)', () => { + vi.spyOn(process, 'emitWarning').mockImplementation(() => {}) + const client = createClient() + const double = { list: () => Promise.resolve(null) } as unknown as AIResource + + expect(() => { + client.ai = double + }).not.toThrow() + expect(client.ai).toBe(double) + expect(client.photoMockups).not.toBe(double) + }) + + it('takes the assignment in sloppy mode (plain CommonJS)', () => { + vi.spyOn(process, 'emitWarning').mockImplementation(() => {}) + const client = createClient() + const photoDouble = { list: () => Promise.resolve(null) } + const psdDouble = { list: () => Promise.resolve(null) } + + expect(sloppyAssign(client, 'ai', photoDouble)).toBe(photoDouble) + expect(client.ai).toBe(photoDouble) + + expect(sloppyAssign(client, 'mockups', psdDouble)).toBe(psdDouble) + expect(client.mockups).toBe(psdDouble) + }) +}) From 17183c665795b7824cad4011bc6bfaf6b7a4ac26 Mon Sep 17 00:00:00 2001 From: SudoMock Labs Date: Sat, 19 Sep 2026 15:25:46 +0300 Subject: [PATCH 2/2] =?UTF-8?q?Yay=C4=B1mlanan=20metin:=20eski=20eri=C5=9F?= =?UTF-8?q?imci=20"ayn=C4=B1=20nesne"=20de=C4=9Fil,=20webhook=20pini=20t?= =?UTF-8?q?=C3=BCretiliyor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit client.ai / client.mockups kullanımdan kaldırma uyarısı iki adı "aynı nesne" diye anlatıyordu. 2.7.1'den beri doğru değil: her ad kendi yayımlandığı yolda duruyor, eski ad 2d_* kind'larını, aile adı photo_mockup_* kind'larını döndürüyor. "Bedava takas" okuması, uyarıyı okuyup adı değiştiren çağıranı tam da 2.7.1'in kapattığı sessiz kind değişikliğine sokuyordu; uyarı artık yolu söylüyor. WebhookEventNaming ve CreateWebhookEndpointParams.eventNaming "yeni uç her zaman current'a sabitlenir" diyordu. Pin gönderilmediğinde varsayılan artık event_types yazımından türetiliyor: aile adları current, eski adlar legacy, boş ya da karışık liste legacy. Tip açıklamaları, README ve CHANGELOG bunu anlatıyor. Bu pakette davranış değişmedi; anlatılan API'nin davranışı. Eski erişimcilerde getter/setter simetrisi bekçi testle kayda geçti: setter hangi alanı yazıyorsa getter onu okuyor, aile erişimcisine dokunulmuyor, bu yüzden kaydet-geri yükle turu iki erişimciyi de başlangıç haline döndürüyor. Setter'ı aile alanını da yazacak şekilde bozan mutasyonda iki tur testi de kırılıyor. vi.spyOn geri alması ve instance üstünden delete aynı testte. --- CHANGELOG.md | 13 ++++++ README.md | 11 +++-- src/index.ts | 8 ++-- src/types.ts | 16 ++++--- tests/resource-names.test.ts | 85 ++++++++++++++++++++++++++++++++++++ 5 files changed, 120 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 602be25..e145e8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,19 @@ project adheres to [Semantic Versioning](https://semver.org/). reached the live API. Assigning to either now replaces only that accessor; `client.photoMockups` / `client.psdMockups` are untouched. +### Documentation +- The deprecation warning no longer calls an earlier name and its family name + "the same object". They are two objects, each on the path its own name was + published on, and the warning now says so -- the earlier wording read as a + free swap and sent readers into the `2d_*` / `photo_mockup_*` kind change the + release above is about. +- `WebhookEventNaming` and `CreateWebhookEndpointParams.eventNaming` describe + the real default. An endpoint you do not pin yourself follows the spelling of + the `eventTypes` you subscribe in: family names give `current`, the earlier + `2d_mockup.*` / `2d_render.*` names give `legacy`, and an empty or mixed list + gives `legacy`. The docs said a new endpoint was always pinned to `current`. + Behaviour is the API's; nothing in this package changed. + ## [2.7.0] - 2026-09-19 ### Added diff --git a/README.md b/README.md index 1e9aa01..f9b63a7 100644 --- a/README.md +++ b/README.md @@ -557,10 +557,13 @@ const events = await client.webhooks.listEvents({ status: 'failed', limit: 100 } Photo-mockup events are `photo_mockup.ready` / `photo_mockup.rejected` / `photo_mockup.failed` and `photo_mockup_render.succeeded` / `photo_mockup_render.failed`. Each endpoint is pinned to one spelling -(`eventNaming`): a new endpoint receives these names; an endpoint created -before they existed keeps receiving `2d_mockup.*` / `2d_render.*` until you -re-pin it. Subscribe with either spelling and the delivery carries the -endpoint's own. +(`eventNaming`). Pin a new endpoint yourself on create, or leave `eventNaming` +out and the pin follows the spelling you subscribed in: a list in these names +pins the endpoint to `current`, a list in the earlier `2d_mockup.*` / +`2d_render.*` names pins it to `legacy`, and an empty or mixed list pins it to +`legacy`. An endpoint created before these names existed keeps receiving +`2d_mockup.*` / `2d_render.*` until you re-pin it. Subscribe with either +spelling and the delivery carries the endpoint's own. ```typescript // A handler that still expects the legacy names: diff --git a/src/index.ts b/src/index.ts index b2f6bda..b144ba5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,15 +16,15 @@ const DEFAULT_BASE_URL = 'https://api.sudomock.com' const DEFAULT_TIMEOUT = 30_000 const DEFAULT_MAX_RETRIES = 2 -// Earlier accessor names. Each warns once per process, then behaves exactly -// like the name it stands for. +// Earlier accessor names. Each warns once per process, then serves the same +// methods on the path its own name was published on. const warnAi = deprecate( () => {}, - 'client.ai is deprecated, use client.photoMockups (the same object).', + 'client.ai is deprecated, use client.photoMockups. client.ai keeps the path it was published on and the 2d_* job kinds; client.photoMockups is the photo-mockup family path.', ) const warnMockups = deprecate( () => {}, - 'client.mockups is deprecated, use client.psdMockups (the same object).', + 'client.mockups is deprecated, use client.psdMockups. client.mockups keeps the path it was published on; client.psdMockups is the PSD-mockup family path.', ) /** diff --git a/src/types.ts b/src/types.ts index 8384de1..f204312 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1023,8 +1023,13 @@ export type WebhookEvent = * - `'legacy'` -- `2d_mockup.*` / `2d_render.*`; the payload's `kind` is * `2d_create` / `2d_render`. * - * A new endpoint is pinned to `'current'` unless created otherwise; an endpoint - * that predates the current names stays on `'legacy'` until re-pinned. + * Pass it on create to pin an endpoint yourself. Left out, the pin follows the + * spelling of the event types you subscribe to: a list written in the family + * names (`photo_mockup.*` / `photo_mockup_render.*`) pins the endpoint to + * `'current'`, a list written in the earlier names (`2d_mockup.*` / + * `2d_render.*`) to `'legacy'`, and an empty or mixed list to `'legacy'` -- + * so subscribing the way your handler already reads keeps it reading. An + * endpoint that predates the current names stays on `'legacy'` until re-pinned. */ export type WebhookEventNaming = 'legacy' | 'current' @@ -1064,9 +1069,10 @@ export interface CreateWebhookEndpointParams { /** Optional description. */ description?: string /** - * Which spelling of the photo-mockup events this endpoint receives. Omit to - * take the API default (`'current'`); pass `'legacy'` for a handler that - * still expects `2d_mockup.*` / `2d_render.*`. + * Which spelling of the photo-mockup events this endpoint receives. Omit it + * and the pin follows `eventTypes`: family names give `'current'`, the + * earlier `2d_mockup.*` / `2d_render.*` names give `'legacy'`, an empty or + * mixed list gives `'legacy'`. Pass it to pin the endpoint yourself. */ eventNaming?: WebhookEventNaming } diff --git a/tests/resource-names.test.ts b/tests/resource-names.test.ts index f6af746..9c9149f 100644 --- a/tests/resource-names.test.ts +++ b/tests/resource-names.test.ts @@ -42,6 +42,11 @@ describe('deprecated accessors', () => { const call = warn.mock.calls[0] as unknown[] | undefined expect(String(call?.[0])).toContain('client.ai') expect(String(call?.[0])).toContain('client.photoMockups') + // The two are not one object: each speaks to the path its name was + // published on. A warning that called them the same would read as a free + // swap and quietly move the reader's jobs to the other family's kinds. + expect(String(call?.[0])).not.toContain('same object') + expect(String(call?.[0])).toContain('published on') expect(call?.[1]).toBe('DeprecationWarning') }) @@ -57,6 +62,8 @@ describe('deprecated accessors', () => { const call = warn.mock.calls[0] as unknown[] | undefined expect(String(call?.[0])).toContain('client.mockups') expect(String(call?.[0])).toContain('client.psdMockups') + expect(String(call?.[0])).not.toContain('same object') + expect(String(call?.[0])).toContain('published on') expect(call?.[1]).toBe('DeprecationWarning') }) }) @@ -343,3 +350,81 @@ describe('earlier accessors accept assignment', () => { expect(client.mockups).toBe(psdDouble) }) }) + +// --------------------------------------------------------------------------- +// An earlier accessor reads back exactly the field its setter writes, and no +// other. A save-restore round trip -- the shape every test double uses, and the +// shape vi.spyOn puts the property back in -- therefore leaves both the earlier +// and the family accessor where it found them. A setter that also wrote the +// family field would survive the assignment but not the restore: the restore +// would park the earlier-path object on the family name for the rest of the +// process, and every later call on the family name would leave its own path +// without a word. +// --------------------------------------------------------------------------- + +describe('earlier accessors are symmetric', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('a save-restore round trip leaves client.ai and client.photoMockups where they started', () => { + vi.spyOn(process, 'emitWarning').mockImplementation(() => {}) + const client = createClient() + const earlier = client.ai + const family = client.photoMockups + const double = { list: () => Promise.resolve(null) } as unknown as AIResource + + client.ai = double + expect(client.ai).toBe(double) + expect(client.photoMockups).toBe(family) + + client.ai = earlier + expect(client.ai).toBe(earlier) + expect(client.photoMockups).toBe(family) + }) + + it('a save-restore round trip leaves client.mockups and client.psdMockups where they started', () => { + vi.spyOn(process, 'emitWarning').mockImplementation(() => {}) + const client = createClient() + const earlier = client.mockups + const family = client.psdMockups + const double = { list: () => Promise.resolve(null) } as unknown as MockupsResource + + client.mockups = double + expect(client.mockups).toBe(double) + expect(client.psdMockups).toBe(family) + + client.mockups = earlier + expect(client.mockups).toBe(earlier) + expect(client.psdMockups).toBe(family) + }) + + it('vi.spyOn over an earlier accessor restores both accessors', () => { + vi.spyOn(process, 'emitWarning').mockImplementation(() => {}) + const client = createClient() + const earlier = client.ai + const family = client.photoMockups + const double = { list: () => Promise.resolve(null) } as unknown as AIResource + + const spy = vi.spyOn(client, 'ai', 'get').mockReturnValue(double) + expect(client.ai).toBe(double) + expect(client.photoMockups).toBe(family) + + spy.mockRestore() + expect(client.ai).toBe(earlier) + expect(client.photoMockups).toBe(family) + }) + + it('an earlier accessor can be deleted off the instance without throwing', () => { + vi.spyOn(process, 'emitWarning').mockImplementation(() => {}) + const client = createClient() + const family = client.photoMockups + + expect(() => { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete (client as unknown as Record)['ai'] + }).not.toThrow() + expect(client.ai).toBeInstanceOf(AIResource) + expect(client.photoMockups).toBe(family) + }) +})