diff --git a/CHANGELOG.md b/CHANGELOG.md index cc479b2..e145e8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,37 @@ 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. + +### 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 d5ddf9e..f9b63a7 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`) @@ -553,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/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..b144ba5 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' @@ -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.', ) /** @@ -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/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 291052a..9c9149f 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,33 +30,40 @@ 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) 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') }) - 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) 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') }) }) @@ -172,3 +181,250 @@ 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) + }) +}) + +// --------------------------------------------------------------------------- +// 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) + }) +})