diff --git a/.changeset/template-image-variants.md b/.changeset/template-image-variants.md new file mode 100644 index 0000000..a364e9b --- /dev/null +++ b/.changeset/template-image-variants.md @@ -0,0 +1,9 @@ +--- +'@passmint/node': minor +--- + +Add template image variants. `templates.uploadImage(id, slot, data, { variant })` uploads a PNG/JPEG (`Uint8Array`, `ArrayBuffer` or `Buffer`, base64-encoded without depending on `Buffer`, so it works on Workers) and `templates.deleteImage(id, slot, { variant })` removes one. `passes.create` and `passes.update` accept `imageVariant` (`null` on update clears it). `Pass` gains `image_variant`, `Template` gains `image_variants`, and the `TemplateImage`, `TemplateImageSlot`, `VariantImageSlot` and `TemplateImageOptions` types are exported. + +Also add the optional `changeMessage` to `TemplateField`: a `%@` format string that makes Apple Wallet show a lock-screen notification when the field changes on update. + +Add per-pass images. `passes.uploadImage(id, slot, data)` gives one pass its own `strip`, `thumbnail` or `background` image (PNG/JPEG/WebP, up to 8 MB), overriding its variant and template image, and `passes.deleteImage(id, slot)` removes it. Both return the updated `Pass`. `Pass` gains `images`, which reports whether each resolved slot comes from the pass, its variant or the template, and `EventPass` gains `image_variant`. The `PassImageSlot`, `PassImageSource` and `PassImages` types are exported. diff --git a/README.md b/README.md index ae37ffc..6f397e0 100644 --- a/README.md +++ b/README.md @@ -132,12 +132,31 @@ API keys starting with `pmk_test_` run against test mode, `pmk_live_` run agains | `list(params?)` | List passes, filter by template or holder email. | | `update(id, params, options?)` | Update field values or metadata on a pass. | | `void(id)` | Void a pass. | +| `uploadImage(id, slot, data)` | Give this pass its own `strip`, `thumbnail` or `background` image. Returns the updated pass. | +| `deleteImage(id, slot)` | Remove the pass's own image for a slot. Returns the updated pass. | | `events(id)` | List raw lifecycle events for a pass (internal vocabulary, e.g. `installed`). Use `passmint.events` for the canonical `pass.*` stream. | `create` and `update` accept an optional `idempotencyKey` in `options`. If omitted, the SDK generates one for you so retries are safe by default. `create` also accepts `platforms` (`['apple', 'google']`) and `certificateSetId` overrides. The created pass includes `warnings` for platforms that could not be delivered. +`create` and `update` accept `imageVariant` to render the pass with one of its template's image variants (see below). On `update`, `null` clears it back to the base images. + +#### Per-pass images + +A pass can have its own `strip`, `thumbnail` or `background` image, such as a member photo: + +```ts +import { readFile } from 'node:fs/promises' + +const photoBytes = await readFile('./members/ada.jpg') +await passmint.passes.uploadImage(pass.id, 'thumbnail', photoBytes) +``` + +Each slot resolves in three layers: the pass's own image wins, then the template image variant the pass uses (`imageVariant`), then the template's base image. `pass.images` reports the source of each resolved slot (`{ thumbnail: { source: 'pass' }, logo: { source: 'template' } }`). `deleteImage(pass.id, 'thumbnail')` removes the override so the slot falls back to the next layer. Use it to erase a holder's photo for privacy requests too. + +Images are PNG, JPEG or WebP, up to 8 MB. Both methods re-render the pass and push it to wallets. Which slots are allowed depends on the template's Apple style; a slot the style doesn't support fails with a 400 `image_slot_not_allowed` error, and updating a voided pass fails with a 409. On Google Wallet, `thumbnail` shows as the avatar on generic passes and as a card image on the other pass types. + ### Templates — `passmint.templates` | Method | Description | @@ -147,6 +166,34 @@ API keys starting with `pmk_test_` run against test mode, `pmk_live_` run agains | `list()` | List all templates. | | `update(id, params)` | Update name, design, platforms, credentials, or archive state. | | `archive(id)` | Archive a template. | +| `uploadImage(id, slot, data, options?)` | Upload a PNG/JPEG (`Uint8Array`, `ArrayBuffer` or `Buffer`) to an image slot, optionally as a named `variant`. | +| `deleteImage(id, slot, options?)` | Remove a slot's image, or one named `variant` of it. | + +Template fields accept an optional `changeMessage`, a format string containing `%@` (e.g. `'You now have %@ stamps'`). When that field's value changes on a pass update, Apple Wallet shows it as a lock-screen notification with `%@` replaced by the new value. Fields without one update silently. + +Image slots are `icon`, `logo`, `strip`, `thumbnail`, `background` and `footer`. The `strip`, `thumbnail` and `background` slots can also hold named variants (`^[a-z0-9][a-z0-9_-]{0,31}$`), listed on `template.image_variants`. A pass picks one with `imageVariant`, so the image can change without a separate template per state. Asking for a variant the template doesn't have fails with a 400 `unknown_image_variant` error. + +For example, a stamp card with one strip per stamp count. Upload the strips once: + +```ts +import { readFile } from 'node:fs/promises' + +for (let count = 0; count <= 10; count++) { + const strip = await readFile(`./strips/stamps-${count}.png`) + await passmint.templates.uploadImage(templateId, 'strip', strip, { variant: String(count) }) +} +``` + +Then switch the strip each time a stamp is added: + +```ts +await passmint.passes.update(passId, { + fieldValues: { stamps: String(count) }, + imageVariant: String(count), +}) +``` + +The SDK base64-encodes the bytes itself without depending on `Buffer`, so `uploadImage` runs on Node, Cloudflare Workers, Deno and Bun. ### Events — `passmint.events` diff --git a/ecosystem-tests/mock-server.mjs b/ecosystem-tests/mock-server.mjs index 8edee48..a0cf6f7 100644 --- a/ecosystem-tests/mock-server.mjs +++ b/ecosystem-tests/mock-server.mjs @@ -53,6 +53,8 @@ function pass(id, overrides = {}) { url: 'https://passmint.com/p/shrt_abc', download_url: 'https://passmint.com/p/shrt_abc/download', google_wallet_url: null, + image_variant: null, + images: { icon: { source: 'template' } }, created_at: '2026-07-01T12:00:00.000Z', ...overrides, } @@ -80,6 +82,7 @@ function canonicalEvent(id) { url: 'https://passmint.com/p/shrt_abc', download_url: 'https://passmint.com/p/shrt_abc/download', google_wallet_url: null, + image_variant: null, }, }, }, diff --git a/src/client.ts b/src/client.ts index 53ebc54..1583380 100644 --- a/src/client.ts +++ b/src/client.ts @@ -16,7 +16,7 @@ export interface PassmintOptions { } export interface InternalRequest { - method: 'GET' | 'POST' | 'PATCH' | 'DELETE' + method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' path: string query?: Record body?: unknown diff --git a/src/encoding.ts b/src/encoding.ts new file mode 100644 index 0000000..1e73685 --- /dev/null +++ b/src/encoding.ts @@ -0,0 +1,18 @@ +// Chunk size for String.fromCharCode.apply — keeps us well under engine +// argument-count limits for multi-megabyte images. +const CHUNK = 0x8000 + +/** + * Base64-encode raw bytes without relying on Node's `Buffer`, so it works the + * same on Node, Cloudflare Workers, Deno and Bun (all expose `btoa`). A Node + * `Buffer` is a `Uint8Array`, so it is accepted as-is (respecting its offset + * into any shared pool). + */ +export function toBase64(data: Uint8Array | ArrayBuffer): string { + const bytes = data instanceof Uint8Array ? data : new Uint8Array(data) + let binary = '' + for (let i = 0; i < bytes.length; i += CHUNK) { + binary += String.fromCharCode.apply(null, bytes.subarray(i, i + CHUNK) as unknown as number[]) + } + return btoa(binary) +} diff --git a/src/index.ts b/src/index.ts index 201ad11..9c7fe42 100644 --- a/src/index.ts +++ b/src/index.ts @@ -35,6 +35,9 @@ export type { MinimizedHolder, Pass, PassEvent, + PassImages, + PassImageSlot, + PassImageSource, PassmintEvent, PassmintEventType, PassmintMode, @@ -44,10 +47,14 @@ export type { Template, TemplateDesign, TemplateField, + TemplateImage, + TemplateImageOptions, + TemplateImageSlot, TemplateType, UpdatePassParams, UpdateTemplateParams, UpdateWebhookParams, + VariantImageSlot, WalletPlatform, Webhook, WebhookDelivery, diff --git a/src/resources/passes.ts b/src/resources/passes.ts index 81f81b1..41c0b49 100644 --- a/src/resources/passes.ts +++ b/src/resources/passes.ts @@ -1,10 +1,12 @@ import { type PassmintHttpClient, generateIdempotencyKey } from '../client' +import { toBase64 } from '../encoding' import type { CreatePassParams, ListPassesParams, ListResponse, Pass, PassEvent, + PassImageSlot, RequestOptions, UpdatePassParams, } from '../types' @@ -27,6 +29,7 @@ export class PassesResource { // override (dev cert), so only omit the key when truly unset. if (params.certificateSetId !== undefined) body.certificate_set_id = params.certificateSetId if (params.platforms !== undefined) body.platforms = params.platforms + if (params.imageVariant !== undefined) body.image_variant = params.imageVariant return this.http.request({ method: 'POST', path: '/v1/passes', @@ -55,13 +58,16 @@ export class PassesResource { } update(id: string, params: UpdatePassParams, options: RequestOptions = {}): Promise { + const body: Record = {} + if (params.fieldValues !== undefined) body.field_values = params.fieldValues + if (params.metadata !== undefined) body.metadata = params.metadata + // null clears the variant back to the base images, so only omit the key + // when truly unset. + if (params.imageVariant !== undefined) body.image_variant = params.imageVariant return this.http.request({ method: 'PATCH', path: `/v1/passes/${encodeURIComponent(id)}`, - body: { - field_values: params.fieldValues, - metadata: params.metadata, - }, + body, idempotencyKey: options.idempotencyKey ?? generateIdempotencyKey(), }) } @@ -73,6 +79,33 @@ export class PassesResource { }) } + /** + * Upload a PNG, JPEG or WebP (max 8 MB) as this pass's own image for a slot, + * overriding its template variant and base image. The pass is re-rendered + * and pushed to wallets; resolves with the updated pass. Re-uploading + * replaces the override. + */ + uploadImage(passId: string, slot: PassImageSlot, data: Uint8Array | ArrayBuffer): Promise { + // PUT is idempotent, so no Idempotency-Key. + return this.http.request({ + method: 'PUT', + path: `/v1/passes/${encodeURIComponent(passId)}/images/${encodeURIComponent(slot)}`, + body: { data: toBase64(data) }, + }) + } + + /** + * Remove this pass's image override for a slot, falling back to its + * template variant or base image. Also use it to erase a holder's photo. + * Resolves with the updated pass. + */ + deleteImage(passId: string, slot: PassImageSlot): Promise { + return this.http.request({ + method: 'DELETE', + path: `/v1/passes/${encodeURIComponent(passId)}/images/${encodeURIComponent(slot)}`, + }) + } + events(id: string): Promise> { return this.http.request>({ method: 'GET', diff --git a/src/resources/templates.ts b/src/resources/templates.ts index 7bbb525..0f4fee9 100644 --- a/src/resources/templates.ts +++ b/src/resources/templates.ts @@ -1,10 +1,15 @@ import { type PassmintHttpClient, generateIdempotencyKey } from '../client' +import { toBase64 } from '../encoding' import type { CreateTemplateParams, ListResponse, RequestOptions, Template, + TemplateImage, + TemplateImageOptions, + TemplateImageSlot, UpdateTemplateParams, + VariantImageSlot, } from '../types' export class TemplatesResource { @@ -64,4 +69,57 @@ export class TemplatesResource { path: `/v1/templates/${encodeURIComponent(id)}`, }) } + + /** + * Upload a PNG or JPEG to a template image slot. With `options.variant`, + * stores a named variant (strip / thumbnail / background only) that passes + * can opt into via `imageVariant`. Re-uploading replaces the existing image. + */ + uploadImage( + templateId: string, + slot: VariantImageSlot, + data: Uint8Array | ArrayBuffer, + options?: TemplateImageOptions, + ): Promise + uploadImage( + templateId: string, + slot: TemplateImageSlot, + data: Uint8Array | ArrayBuffer, + ): Promise + uploadImage( + templateId: string, + slot: TemplateImageSlot, + data: Uint8Array | ArrayBuffer, + options: TemplateImageOptions = {}, + ): Promise { + const body: Record = { data: toBase64(data) } + if (options.variant !== undefined) body.variant = options.variant + return this.http.request({ + method: 'PUT', + path: `/v1/templates/${encodeURIComponent(templateId)}/images/${encodeURIComponent(slot)}`, + body, + }) + } + + /** Remove a template image, or one named variant of it with `options.variant`. */ + deleteImage( + templateId: string, + slot: VariantImageSlot, + options?: TemplateImageOptions, + ): Promise + deleteImage( + templateId: string, + slot: TemplateImageSlot, + ): Promise + deleteImage( + templateId: string, + slot: TemplateImageSlot, + options: TemplateImageOptions = {}, + ): Promise { + return this.http.request({ + method: 'DELETE', + path: `/v1/templates/${encodeURIComponent(templateId)}/images/${encodeURIComponent(slot)}`, + query: { variant: options.variant }, + }) + } } diff --git a/src/types.ts b/src/types.ts index 8719013..d53b5b9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -23,6 +23,13 @@ export interface TemplateField { defaultValue: string | null textAlignment: 'left' | 'center' | 'right' | 'natural' required: boolean + /** + * Apple Wallet change message: a format string shown as a lock-screen + * notification when this field's value changes on a pass update. Must + * contain `%@`, which is replaced with the new value (e.g. + * `"You now have %@ stamps"`). Without it, Apple updates the pass silently. + */ + changeMessage?: string } export interface TemplateDesign { @@ -60,10 +67,50 @@ export interface Template { platforms: WalletPlatform[] certificate_set_id: string | null google_issuer_id: string | null + /** + * Names of the image variants uploaded to this template (see + * `templates.uploadImage`). Passes opt into one via `image_variant`. + */ + image_variants: string[] created_at: string updated_at: string } +/** Every template image slot that accepts a base (non-variant) image. */ +export type TemplateImageSlot = 'icon' | 'logo' | 'strip' | 'thumbnail' | 'background' | 'footer' + +/** The subset of image slots that can also hold named variants. */ +export type VariantImageSlot = 'strip' | 'thumbnail' | 'background' + +/** Returned by `templates.uploadImage` (and, with `deleted: true`, `templates.deleteImage`). */ +export interface TemplateImage { + object: 'template_image' + template_id: string + slot: TemplateImageSlot + /** The variant name, or `null` for the slot's base image. */ + variant: string | null +} + +export interface TemplateImageOptions { + /** + * Named variant to target instead of the base image. Only valid on + * `VariantImageSlot`s. Must match `^[a-z0-9][a-z0-9_-]{0,31}$`. + */ + variant?: string +} + +/** Image slots a single pass can override (see `passes.uploadImage`). Same set as `VariantImageSlot`. */ +export type PassImageSlot = VariantImageSlot + +/** + * Which layer a pass's resolved image comes from: a per-pass override, the + * pass's template image variant, or the template's base image. + */ +export type PassImageSource = 'pass' | 'variant' | 'template' + +/** The resolved image for each slot the pass renders with. Slots without an image are absent. */ +export type PassImages = Partial> + export interface Pass { id: string object: 'pass' @@ -95,6 +142,13 @@ export interface Pass { * to `url`. */ google_wallet_url: string | null + /** + * The template image variant this pass renders with, or `null` for the + * template's base images. + */ + image_variant: string | null + /** Where each of the pass's images resolves from (pass → variant → template). */ + images: PassImages created_at: string } @@ -172,6 +226,8 @@ export interface EventPass { download_url: string | null /** Google Wallet "Save" link; `null` when Google wasn't delivered. Falls back to `url`. */ google_wallet_url: string | null + /** The template image variant the pass renders with, or `null` for the base images. */ + image_variant: string | null } /** @@ -204,6 +260,11 @@ export interface CreatePassParams { metadata?: Record /** Override the template's wallet platforms for this pass. */ platforms?: WalletPlatform[] + /** + * Render the pass with one of the template's image variants (see + * `Template.image_variants`). Omit to use the base images. + */ + imageVariant?: string } export interface CreateTemplateParams { @@ -230,6 +291,11 @@ export interface UpdateTemplateParams { export interface UpdatePassParams { fieldValues?: Record metadata?: Record | null + /** + * Switch the pass to a template image variant. Pass `null` to clear back to + * the base images; omit to leave it unchanged. + */ + imageVariant?: string | null } export interface ListPassesParams { diff --git a/test/client.test.ts b/test/client.test.ts index 21d577a..ea465e0 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -181,6 +181,27 @@ describe('PassmintHttpClient.request', () => { expect(impl).toHaveBeenCalledTimes(1) }) + it('maps a 409 (e.g. image update on a voided pass) to PassmintAPIError without retrying', async () => { + const { impl, calls } = makeFetch([ + { + status: 409, + body: { error: { type: 'invalid_request_error', code: 'pass_voided', message: 'voided' } }, + }, + ]) + const client = new PassmintHttpClient({ apiKey: 'pmk_test_1', fetch: impl, maxRetries: 3 }) + const p = client + .request({ method: 'PUT', path: '/v1/passes/pass_1/images/thumbnail', body: { data: 'x' } }) + .catch((e) => e) + await flush() + const err = await p + expect(err).toBeInstanceOf(PassmintAPIError) + expect((err as PassmintAPIError).status).toBe(409) + expect((err as PassmintAPIError).code).toBe('pass_voided') + expect(impl).toHaveBeenCalledTimes(1) + const headers = calls[0]?.init.headers as Record + expect(headers['Idempotency-Key']).toBeUndefined() + }) + it('maps 429 to PassmintRateLimitError with retry-after header', async () => { const { impl } = makeFetch([ { diff --git a/test/resources.test.ts b/test/resources.test.ts index 4911d9d..451670e 100644 --- a/test/resources.test.ts +++ b/test/resources.test.ts @@ -93,6 +93,15 @@ describe('PassesResource', () => { expect(calls[0]?.body).toMatchObject({ certificate_set_id: null }) }) + it('create: maps imageVariant to image_variant, omitting it when unset', async () => { + const { http, calls } = fakeHttp() + const passes = new PassesResource(http) + await passes.create({ templateId: 'tmpl_1', imageVariant: '3' }) + await passes.create({ templateId: 'tmpl_1' }) + expect(calls[0]?.body).toMatchObject({ image_variant: '3' }) + expect(calls[1]?.body).not.toHaveProperty('image_variant') + }) + it('retrieve: GETs and URL-encodes the id', async () => { const { http, calls } = fakeHttp() const passes = new PassesResource(http) @@ -124,6 +133,28 @@ describe('PassesResource', () => { expect(calls[0]?.idempotencyKey).toBeDefined() }) + it('update: sends image_variant alone (a variant-only PATCH is valid)', async () => { + const { http, calls } = fakeHttp() + const passes = new PassesResource(http) + await passes.update('pass_1', { imageVariant: '7' }) + expect(calls[0]?.body).toStrictEqual({ image_variant: '7' }) + }) + + it('update: sends an explicit null image_variant to clear it', async () => { + const { http, calls } = fakeHttp() + const passes = new PassesResource(http) + await passes.update('pass_1', { fieldValues: { stamps: '0' }, imageVariant: null }) + expect(calls[0]?.body).toStrictEqual({ field_values: { stamps: '0' }, image_variant: null }) + expect(JSON.stringify(calls[0]?.body)).toContain('"image_variant":null') + }) + + it('update: omits image_variant (and other unset keys) when undefined', async () => { + const { http, calls } = fakeHttp() + const passes = new PassesResource(http) + await passes.update('pass_1', { fieldValues: { stamps: '2' } }) + expect(calls[0]?.body).toStrictEqual({ field_values: { stamps: '2' } }) + }) + it('void: DELETEs /v1/passes/:id', async () => { const { http, calls } = fakeHttp() const passes = new PassesResource(http) @@ -214,6 +245,159 @@ describe('TemplatesResource', () => { }) }) +describe('TemplatesResource images', () => { + // 0x89 'P' 'N' 'G' \r \n 0x1a \n — the PNG signature, base64 "iVBORw0KGgo=" + const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) + const pngBase64 = 'iVBORw0KGgo=' + + it('uploadImage: PUTs base64 data to /v1/templates/:id/images/:slot', async () => { + const { http, calls } = fakeHttp({ + object: 'template_image', + template_id: 'tmpl_1', + slot: 'logo', + variant: null, + }) + const templates = new TemplatesResource(http) + const image = await templates.uploadImage('tmpl_1', 'logo', png) + expect(calls[0]).toMatchObject({ method: 'PUT', path: '/v1/templates/tmpl_1/images/logo' }) + expect(calls[0]?.body).toStrictEqual({ data: pngBase64 }) + expect(calls[0]?.query).toBeUndefined() + expect(image.variant).toBeNull() + }) + + it('uploadImage: includes the variant when provided', async () => { + const { http, calls } = fakeHttp() + const templates = new TemplatesResource(http) + await templates.uploadImage('tmpl/1', 'strip', png, { variant: '10' }) + expect(calls[0]?.path).toBe('/v1/templates/tmpl%2F1/images/strip') + expect(calls[0]?.body).toStrictEqual({ data: pngBase64, variant: '10' }) + }) + + it('uploadImage: accepts an ArrayBuffer', async () => { + const { http, calls } = fakeHttp() + const templates = new TemplatesResource(http) + await templates.uploadImage('tmpl_1', 'icon', png.slice().buffer) + expect(calls[0]?.body).toStrictEqual({ data: pngBase64 }) + }) + + it('uploadImage: encodes only the viewed bytes of a Buffer / offset Uint8Array', async () => { + const { http, calls } = fakeHttp() + const templates = new TemplatesResource(http) + const backing = new Uint8Array([0xff, 0xff, ...png, 0xff]) + await templates.uploadImage('tmpl_1', 'thumbnail', backing.subarray(2, 2 + png.length)) + await templates.uploadImage('tmpl_1', 'thumbnail', Buffer.from(png)) + expect(calls[0]?.body).toStrictEqual({ data: pngBase64 }) + expect(calls[1]?.body).toStrictEqual({ data: pngBase64 }) + }) + + it('uploadImage: encodes large images without Buffer (matches Node base64)', async () => { + const { http, calls } = fakeHttp() + const templates = new TemplatesResource(http) + const big = new Uint8Array(200_000).map((_, i) => (i * 31) % 256) + const RealBuffer = globalThis.Buffer + vi.stubGlobal('Buffer', undefined) + try { + await templates.uploadImage('tmpl_1', 'background', big) + } finally { + vi.stubGlobal('Buffer', RealBuffer) + } + expect((calls[0]?.body as { data: string }).data).toBe(RealBuffer.from(big).toString('base64')) + }) + + it('deleteImage: DELETEs with the variant as a query param', async () => { + const { http, calls } = fakeHttp({ + object: 'template_image', + template_id: 'tmpl_1', + slot: 'strip', + variant: '3', + deleted: true, + }) + const templates = new TemplatesResource(http) + const res = await templates.deleteImage('tmpl_1', 'strip', { variant: '3' }) + expect(calls[0]).toMatchObject({ + method: 'DELETE', + path: '/v1/templates/tmpl_1/images/strip', + query: { variant: '3' }, + }) + expect(calls[0]?.body).toBeUndefined() + expect(res.deleted).toBe(true) + }) + + it('deleteImage: leaves variant unset for the base image', async () => { + const { http, calls } = fakeHttp() + const templates = new TemplatesResource(http) + await templates.deleteImage('tmpl_1', 'footer') + expect(calls[0]).toMatchObject({ method: 'DELETE', path: '/v1/templates/tmpl_1/images/footer' }) + expect(calls[0]?.query?.variant).toBeUndefined() + }) +}) + +describe('PassesResource images', () => { + // 0x89 'P' 'N' 'G' \r \n 0x1a \n — the PNG signature, base64 "iVBORw0KGgo=" + const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) + const pngBase64 = 'iVBORw0KGgo=' + const updatedPass = { + id: 'pass_1', + object: 'pass', + image_variant: null, + images: { icon: { source: 'template' }, thumbnail: { source: 'pass' } }, + } + + it('uploadImage: PUTs base64 data to /v1/passes/:id/images/:slot, no idempotency key', async () => { + const { http, calls } = fakeHttp(updatedPass) + const passes = new PassesResource(http) + const pass = await passes.uploadImage('pass_1', 'thumbnail', png) + expect(calls[0]).toMatchObject({ method: 'PUT', path: '/v1/passes/pass_1/images/thumbnail' }) + expect(calls[0]?.body).toStrictEqual({ data: pngBase64 }) + expect(calls[0]?.query).toBeUndefined() + expect(calls[0]?.idempotencyKey).toBeUndefined() + expect(pass.images.thumbnail?.source).toBe('pass') + }) + + it('uploadImage: URL-encodes the pass id and accepts an ArrayBuffer', async () => { + const { http, calls } = fakeHttp() + const passes = new PassesResource(http) + await passes.uploadImage('pass/1', 'strip', png.slice().buffer) + expect(calls[0]?.path).toBe('/v1/passes/pass%2F1/images/strip') + expect(calls[0]?.body).toStrictEqual({ data: pngBase64 }) + }) + + it('uploadImage: encodes only the viewed bytes of a Buffer / offset Uint8Array', async () => { + const { http, calls } = fakeHttp() + const passes = new PassesResource(http) + const backing = new Uint8Array([0xff, 0xff, ...png, 0xff]) + await passes.uploadImage('pass_1', 'background', backing.subarray(2, 2 + png.length)) + await passes.uploadImage('pass_1', 'background', Buffer.from(png)) + expect(calls[0]?.body).toStrictEqual({ data: pngBase64 }) + expect(calls[1]?.body).toStrictEqual({ data: pngBase64 }) + }) + + it('uploadImage: encodes large images without Buffer (matches Node base64)', async () => { + const { http, calls } = fakeHttp() + const passes = new PassesResource(http) + const big = new Uint8Array(200_000).map((_, i) => (i * 31) % 256) + const RealBuffer = globalThis.Buffer + vi.stubGlobal('Buffer', undefined) + try { + await passes.uploadImage('pass_1', 'thumbnail', big) + } finally { + vi.stubGlobal('Buffer', RealBuffer) + } + expect((calls[0]?.body as { data: string }).data).toBe(RealBuffer.from(big).toString('base64')) + }) + + it('deleteImage: DELETEs /v1/passes/:id/images/:slot and returns the pass', async () => { + const { http, calls } = fakeHttp({ ...updatedPass, images: { icon: { source: 'template' } } }) + const passes = new PassesResource(http) + const pass = await passes.deleteImage('pass_1', 'thumbnail') + expect(calls[0]).toMatchObject({ method: 'DELETE', path: '/v1/passes/pass_1/images/thumbnail' }) + expect(calls[0]?.body).toBeUndefined() + expect(calls[0]?.query).toBeUndefined() + expect(calls[0]?.idempotencyKey).toBeUndefined() + expect(pass.images.thumbnail).toBeUndefined() + }) +}) + describe('WebhooksResource API methods', () => { it('create: POSTs /v1/webhooks with url and events, no idempotency key', async () => { const { http, calls } = fakeHttp({ id: 'whk_1', secret: 'whsec_x' }) diff --git a/test/types.test-d.ts b/test/types.test-d.ts index 2a2316a..0271e24 100644 --- a/test/types.test-d.ts +++ b/test/types.test-d.ts @@ -1,11 +1,22 @@ import { describe, expectTypeOf, it } from 'vitest' import type { PassesResource } from '../src/resources/passes' +import type { TemplatesResource } from '../src/resources/templates' import type { + CreatePassParams, + EventPass, Pass, PassEvent, + PassImageSlot, + PassImageSource, + PassImages, PassPlatformStatus, PlatformDeliveryStatus, Template, + TemplateField, + TemplateImage, + TemplateImageSlot, + UpdatePassParams, + VariantImageSlot, WalletPlatform, } from '../src/types' @@ -33,6 +44,12 @@ describe('Template', () => { }) }) +describe('TemplateField', () => { + it('accepts an optional Apple change message', () => { + expectTypeOf().toEqualTypeOf() + }) +}) + describe('PassEvent', () => { it('includes the full internal event vocabulary', () => { expectTypeOf<'update_delivered'>().toExtend() @@ -48,3 +65,68 @@ describe('platform primitives', () => { >() }) }) + +describe('image variants', () => { + it('surfaces variants on passes, templates and params', () => { + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + }) + + it('types the image slots and responses', () => { + expectTypeOf().toExtend() + expectTypeOf().toEqualTypeOf() + expectTypeOf>>().toEqualTypeOf< + TemplateImage & { deleted: true } + >() + }) + + it('only allows a variant on variant-able slots', () => { + const templates = {} as TemplatesResource + const bytes = new Uint8Array() + // Type-level only; never executed. + void (() => { + templates.uploadImage('tmpl_1', 'strip', bytes, { variant: '1' }) + templates.uploadImage('tmpl_1', 'logo', bytes.buffer) + // @ts-expect-error logo has no variants + templates.uploadImage('tmpl_1', 'logo', bytes, { variant: '1' }) + templates.deleteImage('tmpl_1', 'background', { variant: 'gold' }) + // @ts-expect-error icon has no variants + templates.deleteImage('tmpl_1', 'icon', { variant: 'gold' }) + }) + }) +}) + +describe('per-pass images', () => { + it('types the pass image slots, sources and resolved images', () => { + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf<'pass' | 'variant' | 'template'>() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf<{ source: PassImageSource } | undefined>() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + }) + + it('returns the updated pass from uploadImage and deleteImage', () => { + expectTypeOf>>().toEqualTypeOf() + expectTypeOf>>().toEqualTypeOf() + }) + + it('only allows strip, thumbnail and background', () => { + const passes = {} as PassesResource + const bytes = new Uint8Array() + // Type-level only; never executed. + void (() => { + passes.uploadImage('pass_1', 'thumbnail', bytes) + passes.uploadImage('pass_1', 'strip', bytes.buffer) + passes.deleteImage('pass_1', 'background') + // @ts-expect-error logo can't be overridden per pass + passes.uploadImage('pass_1', 'logo', bytes) + // @ts-expect-error icon can't be overridden per pass + passes.deleteImage('pass_1', 'icon') + // @ts-expect-error no variant option on per-pass images + passes.uploadImage('pass_1', 'strip', bytes, { variant: '1' }) + }) + }) +}) diff --git a/test/webhooks.test.ts b/test/webhooks.test.ts index 72c1c14..f089cf0 100644 --- a/test/webhooks.test.ts +++ b/test/webhooks.test.ts @@ -104,6 +104,7 @@ describe('WebhooksResource.constructEvent', () => { url: 'https://passmint.com/p/shrt1', download_url: null, google_wallet_url: null, + image_variant: null, }, }, },