diff --git a/manifest.json b/manifest.json index 5e7baa1..6977d30 100644 --- a/manifest.json +++ b/manifest.json @@ -2,7 +2,7 @@ "manifest_version": "0.3", "name": "dyspatch-mcp", "display_name": "Dyspatch", - "version": "0.0.9", + "version": "0.0.10", "description": "MCP server for the Dyspatch.io API — manage email, SMS, push, voice, and live activity templates, drafts, localizations, and renders.", "author": { "name": "Dyspatch", @@ -31,7 +31,9 @@ "entry_point": "mcpb-dist/server.js", "mcp_config": { "command": "node", - "args": ["${__dirname}/mcpb-dist/server.js"], + "args": [ + "${__dirname}/mcpb-dist/server.js" + ], "env": { "DYSPATCH_API_KEY": "${user_config.dyspatch_api_key}" } diff --git a/package.json b/package.json index aa8f7bb..b653bdb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dyspatch-mcp", - "version": "0.0.9", + "version": "0.0.10", "description": "MCP server for the Dyspatch API for www.dyspatch.io", "type": "module", "main": "dist/index.js", diff --git a/src/client.ts b/src/client.ts index 21865bb..fd09178 100644 --- a/src/client.ts +++ b/src/client.ts @@ -4,7 +4,7 @@ const require = createRequire(import.meta.url) const { version: MCP_VERSION } = require('../package.json') as { version: string } const BASE_URL = 'https://api.dyspatch.io' -const DYSPATCH_API_VERSION = '2026.01' +const DYSPATCH_API_VERSION = '2026.06' const REQUEST_TIMEOUT_MS = 30_000 const USER_AGENT = `dyspatch-mcp-${MCP_VERSION}` diff --git a/src/server.ts b/src/server.ts index 0910671..c45afa2 100644 --- a/src/server.ts +++ b/src/server.ts @@ -26,6 +26,8 @@ import { localizationTools } from './tools/localizations.js' import { blockTools } from './tools/blocks.js' import { workspaceTools } from './tools/workspaces.js' import { tagTools } from './tools/tags.js' +import { customerProfileTools } from './tools/customerProfiles.js' +import { themeTools } from './tools/themes.js' export interface ToolAnnotations { title?: string @@ -80,6 +82,8 @@ export function createMcpServer(): Server { ...blockTools(client), ...workspaceTools(client), ...tagTools(client), + ...customerProfileTools(client), + ...themeTools(client), ] const toolMap = new Map(allTools.map((t) => [t.name, t])) diff --git a/src/tools/blocks.ts b/src/tools/blocks.ts index 9e06138..59d917c 100644 --- a/src/tools/blocks.ts +++ b/src/tools/blocks.ts @@ -121,6 +121,22 @@ export function blockTools(client: DyspatchClient): ToolDefinition[] { return client.delete(`/blocks/${blockId}/localizations/${languageId}`) }, }, + { + name: 'get_block_translations', + description: + 'Get all translations for a localization on a block. Returns a flat key/value map of string keys to translated strings.', + inputSchema: blockLocalizationRefSchema, + annotations: { + title: 'Get Block Translations', + readOnlyHint: true, + idempotentHint: true, + openWorldHint: true, + }, + async handler(args) { + const { blockId, languageId } = blockLocalizationRefSchema.parse(args) + return client.get(`/blocks/${blockId}/localizations/${languageId}/translations`) + }, + }, { name: 'set_block_translations', description: diff --git a/src/tools/customerProfiles.ts b/src/tools/customerProfiles.ts new file mode 100644 index 0000000..f91c60d --- /dev/null +++ b/src/tools/customerProfiles.ts @@ -0,0 +1,50 @@ +import { z } from 'zod' +import type { DyspatchClient } from '../client.js' +import type { ToolDefinition } from '../index.js' +import { CURSOR_DESCRIPTION } from '../constants.js' + +const listCustomerProfilesSchema = z.object({ + cursor: z.string().optional().describe(CURSOR_DESCRIPTION), + workspaceId: z.string().optional().describe('Filter customer profiles by workspace ID'), +}) + +const getCustomerProfileSchema = z.object({ + customerProfileId: z.string().describe('Customer profile ID (e.g. dat_xxxx)'), +}) + +export function customerProfileTools(client: DyspatchClient): ToolDefinition[] { + return [ + { + name: 'list_customer_profiles', + description: + 'List customer profiles for the organization. Results can be filtered by workspace ID. The list may include an unnamed customer profile (name is empty string) which represents global variables available to all templates. Returns paginated results. Each profile includes workspaceIds (direct assignments) and effectiveWorkspaceIds (including folder-inherited assignments).', + inputSchema: listCustomerProfilesSchema, + annotations: { + title: 'List Customer Profiles', + readOnlyHint: true, + idempotentHint: true, + openWorldHint: true, + }, + async handler(args) { + const { cursor, workspaceId } = listCustomerProfilesSchema.parse(args) + return client.get('/customerprofiles', { cursor, workspaceId }) + }, + }, + { + name: 'get_customer_profile', + description: + 'Get a customer profile by ID, including its JSON data content and workspace associations. Returns workspaceIds (direct assignments) and effectiveWorkspaceIds (including workspaces inherited from parent folders).', + inputSchema: getCustomerProfileSchema, + annotations: { + title: 'Get Customer Profile', + readOnlyHint: true, + idempotentHint: true, + openWorldHint: true, + }, + async handler(args) { + const { customerProfileId } = getCustomerProfileSchema.parse(args) + return client.get(`/customerprofiles/${customerProfileId}`) + }, + }, + ] +} diff --git a/src/tools/drafts.ts b/src/tools/drafts.ts index 43804e8..8bb8955 100644 --- a/src/tools/drafts.ts +++ b/src/tools/drafts.ts @@ -24,9 +24,10 @@ const listDraftsSchema = z.object({ type: TemplateType.describe(TEMPLATE_TYPE_DESCRIPTION), cursor: z.string().optional().describe(CURSOR_DESCRIPTION), status: z - .enum(['IN_PROGRESS', 'PENDING_APPROVAL', 'LOCKED_FOR_TRANSLATION']) + .enum(['LOCKED_FOR_TRANSLATION']) .optional() - .describe('Filter by draft status'), + .describe('Filter drafts to only those locked for translation'), + templateId: z.string().optional().describe('Filter drafts by template ID'), }) const getDraftSchema = typeAndDraft.extend({ @@ -42,7 +43,7 @@ export function draftTools(client: DyspatchClient): ToolDefinition[] { return [ { name: 'list_drafts', - description: 'List all drafts for a given channel type. Returns paginated results.', + description: 'List all drafts for a given channel type. Optionally filter by template ID or translation lock status. Returns paginated results.', inputSchema: listDraftsSchema, annotations: { title: 'List Drafts', @@ -51,9 +52,9 @@ export function draftTools(client: DyspatchClient): ToolDefinition[] { openWorldHint: true, }, async handler(args) { - const { type, cursor, status } = listDraftsSchema.parse(args) + const { type, cursor, status, templateId } = listDraftsSchema.parse(args) const prefix = typePath(type) - return client.get(`${prefix}/drafts`, { cursor, status }) + return client.get(`${prefix}/drafts`, { cursor, status, templateId }) }, }, { diff --git a/src/tools/localizations.ts b/src/tools/localizations.ts index 63eb0ad..efd1074 100644 --- a/src/tools/localizations.ts +++ b/src/tools/localizations.ts @@ -108,6 +108,25 @@ export function localizationTools(client: DyspatchClient): ToolDefinition[] { return client.delete(`${prefix}/drafts/${draftId}/localizations/${languageId}`) }, }, + { + name: 'get_translations', + description: + 'Get all translations for a localization on a draft. Returns a flat key/value map of string keys to translated strings.', + inputSchema: localizationRefSchema, + annotations: { + title: 'Get Translations', + readOnlyHint: true, + idempotentHint: true, + openWorldHint: true, + }, + async handler(args) { + const { type, draftId, languageId } = localizationRefSchema.parse(args) + const prefix = typePath(type) + return client.get( + `${prefix}/drafts/${draftId}/localizations/${languageId}/translations`, + ) + }, + }, { name: 'set_translations', description: diff --git a/src/tools/templates.ts b/src/tools/templates.ts index a30f5cb..31968f9 100644 --- a/src/tools/templates.ts +++ b/src/tools/templates.ts @@ -16,6 +16,9 @@ import { const listTemplatesSchema = z.object({ type: TemplateType.describe(TEMPLATE_TYPE_DESCRIPTION), cursor: z.string().optional().describe(CURSOR_DESCRIPTION), + name: z.string().optional().describe('Filter templates by name (case-insensitive partial match)'), + folderId: z.string().optional().describe('Filter templates by folder ID (immediate children only)'), + workspaceId: z.string().optional().describe('Filter templates by workspace ID (includes all nested folders)'), }) const getTemplateSchema = z.object({ @@ -43,7 +46,7 @@ export function templateTools(client: DyspatchClient): ToolDefinition[] { { name: 'list_templates', description: - 'List published templates of a given channel type (email, sms, push, voice, or liveactivity). Returns paginated results.', + 'List published templates of a given channel type (email, sms, push, voice, or liveactivity). Optionally filter by name, folder, or workspace. Returns paginated results.', inputSchema: listTemplatesSchema, annotations: { title: 'List Templates', @@ -52,9 +55,9 @@ export function templateTools(client: DyspatchClient): ToolDefinition[] { openWorldHint: true, }, async handler(args) { - const { type, cursor } = listTemplatesSchema.parse(args) + const { type, cursor, name, folderId, workspaceId } = listTemplatesSchema.parse(args) const prefix = typePath(type) - return client.get(`${prefix}/templates`, { cursor }) + return client.get(`${prefix}/templates`, { cursor, name, folderId, workspaceId }) }, }, { diff --git a/src/tools/themes.ts b/src/tools/themes.ts new file mode 100644 index 0000000..aec2923 --- /dev/null +++ b/src/tools/themes.ts @@ -0,0 +1,47 @@ +import { z } from 'zod' +import type { DyspatchClient } from '../client.js' +import type { ToolDefinition } from '../index.js' +import { CURSOR_DESCRIPTION } from '../constants.js' + +const listThemesSchema = z.object({ + cursor: z.string().optional().describe(CURSOR_DESCRIPTION), +}) + +const getThemeSchema = z.object({ + themeId: z.string().describe('Theme ID (e.g. thm_xxxx)'), +}) + +export function themeTools(client: DyspatchClient): ToolDefinition[] { + return [ + { + name: 'list_themes', + description: 'List all themes for the organization. Returns paginated results.', + inputSchema: listThemesSchema, + annotations: { + title: 'List Themes', + readOnlyHint: true, + idempotentHint: true, + openWorldHint: true, + }, + async handler(args) { + const { cursor } = listThemesSchema.parse(args) + return client.get('/themes', { cursor }) + }, + }, + { + name: 'get_theme', + description: 'Get a theme by ID, including its name, description, and Figma URL.', + inputSchema: getThemeSchema, + annotations: { + title: 'Get Theme', + readOnlyHint: true, + idempotentHint: true, + openWorldHint: true, + }, + async handler(args) { + const { themeId } = getThemeSchema.parse(args) + return client.get(`/themes/${themeId}`) + }, + }, + ] +} diff --git a/tests/client.test.ts b/tests/client.test.ts index dd20c7e..8936fde 100644 --- a/tests/client.test.ts +++ b/tests/client.test.ts @@ -33,7 +33,7 @@ describe('DyspatchClient', () => { }), ) await makeClient().get('/test') - expect(capturedAccept).toBe('application/vnd.dyspatch.2026.01+json') + expect(capturedAccept).toBe('application/vnd.dyspatch.2026.06+json') }) }) diff --git a/tests/integration/tools.test.ts b/tests/integration/tools.test.ts index 5b04237..c1845bd 100644 --- a/tests/integration/tools.test.ts +++ b/tests/integration/tools.test.ts @@ -328,19 +328,12 @@ describe.skipIf(!apiKey || !workspaceId)('MCP tools — comprehensive integratio expect(result.id).toBe(firstDraftId) }) - it('list_drafts — status param is forwarded (invalid values rejected by API)', async () => { - // The status param is validated server-side; we test that the tool correctly - // forwards it. An invalid_parameter error confirms the param was received. - try { - const result = await getTool(drftTools, 'list_drafts').handler({ - type: 'email', - status: 'PENDING_APPROVAL', - }) as any - expect(Array.isArray(result.data)).toBe(true) - } catch (e: any) { - // API rejects unknown status values — confirms the param was forwarded - expect(e.code).toBe('invalid_parameter') - } + it('list_drafts — status filter narrows results', async () => { + const result = await getTool(drftTools, 'list_drafts').handler({ + type: 'email', + status: 'LOCKED_FOR_TRANSLATION', + }) as any + expect(Array.isArray(result.data)).toBe(true) }) it('get_draft_localization_keys — returns array of keys', async () => { diff --git a/tests/tools/blocks.test.ts b/tests/tools/blocks.test.ts index 8f82cf0..a65244b 100644 --- a/tests/tools/blocks.test.ts +++ b/tests/tools/blocks.test.ts @@ -113,6 +113,27 @@ describe('delete_block_localization', () => { }) }) +describe('get_block_translations', () => { + let ctx: ReturnType + beforeEach(() => { + ctx = setup() + ctx.client.get = vi.fn().mockResolvedValue({ greeting: 'Bonjour' }) + }) + + it('GET /blocks/{blockId}/localizations/{lang}/translations', async () => { + await ctx.get('get_block_translations').handler({ blockId: 'blo_abc', languageId: 'fr-FR' }) + expect(ctx.client.get).toHaveBeenCalledWith('/blocks/blo_abc/localizations/fr-FR/translations') + }) + + it('throws on missing languageId', async () => { + await expect(ctx.get('get_block_translations').handler({ blockId: 'blo_abc' })).rejects.toThrow() + }) + + it('throws on missing blockId', async () => { + await expect(ctx.get('get_block_translations').handler({ languageId: 'fr-FR' })).rejects.toThrow() + }) +}) + describe('set_block_translations', () => { let ctx: ReturnType beforeEach(() => { diff --git a/tests/tools/customerProfiles.test.ts b/tests/tools/customerProfiles.test.ts new file mode 100644 index 0000000..91bd77e --- /dev/null +++ b/tests/tools/customerProfiles.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { vi } from 'vitest' +import { customerProfileTools } from '../../src/tools/customerProfiles.js' +import { makeMockClient } from '../helpers.js' + +function setup() { + const client = makeMockClient() + const tools = customerProfileTools(client) + const get = (name: string) => tools.find((t) => t.name === name)! + return { client, get } +} + +describe('list_customer_profiles', () => { + let ctx: ReturnType + beforeEach(() => { + ctx = setup() + ctx.client.get = vi.fn().mockResolvedValue({ data: [] }) + }) + + it('GET /customerprofiles with no params', async () => { + await ctx.get('list_customer_profiles').handler({}) + expect(ctx.client.get).toHaveBeenCalledWith('/customerprofiles', { cursor: undefined, workspaceId: undefined }) + }) + + it('forwards cursor', async () => { + await ctx.get('list_customer_profiles').handler({ cursor: 'page2' }) + expect(ctx.client.get).toHaveBeenCalledWith('/customerprofiles', { cursor: 'page2', workspaceId: undefined }) + }) + + it('forwards workspaceId filter', async () => { + await ctx.get('list_customer_profiles').handler({ workspaceId: 'fdr_abc' }) + expect(ctx.client.get).toHaveBeenCalledWith('/customerprofiles', { cursor: undefined, workspaceId: 'fdr_abc' }) + }) +}) + +describe('get_customer_profile', () => { + let ctx: ReturnType + beforeEach(() => { + ctx = setup() + ctx.client.get = vi.fn().mockResolvedValue({}) + }) + + it('GET /customerprofiles/{id}', async () => { + await ctx.get('get_customer_profile').handler({ customerProfileId: 'dat_abc' }) + expect(ctx.client.get).toHaveBeenCalledWith('/customerprofiles/dat_abc') + }) + + it('throws on missing customerProfileId', async () => { + await expect(ctx.get('get_customer_profile').handler({})).rejects.toThrow() + }) +}) diff --git a/tests/tools/drafts.test.ts b/tests/tools/drafts.test.ts index b64f165..2e5fb23 100644 --- a/tests/tools/drafts.test.ts +++ b/tests/tools/drafts.test.ts @@ -21,22 +21,32 @@ describe('list_drafts', () => { it('GET /drafts for email', async () => { await ctx.get('list_drafts').handler({ type: 'email' }) - expect(ctx.client.get).toHaveBeenCalledWith('/drafts', { cursor: undefined, status: undefined }) + expect(ctx.client.get).toHaveBeenCalledWith('/drafts', { cursor: undefined, status: undefined, templateId: undefined }) }) it('GET /sms/drafts for sms', async () => { await ctx.get('list_drafts').handler({ type: 'sms' }) - expect(ctx.client.get).toHaveBeenCalledWith('/sms/drafts', { cursor: undefined, status: undefined }) + expect(ctx.client.get).toHaveBeenCalledWith('/sms/drafts', { cursor: undefined, status: undefined, templateId: undefined }) }) it('forwards cursor', async () => { await ctx.get('list_drafts').handler({ type: 'email', cursor: 'abc' }) - expect(ctx.client.get).toHaveBeenCalledWith('/drafts', { cursor: 'abc', status: undefined }) + expect(ctx.client.get).toHaveBeenCalledWith('/drafts', { cursor: 'abc', status: undefined, templateId: undefined }) }) it('forwards status filter', async () => { - await ctx.get('list_drafts').handler({ type: 'email', status: 'PENDING_APPROVAL' }) - expect(ctx.client.get).toHaveBeenCalledWith('/drafts', { cursor: undefined, status: 'PENDING_APPROVAL' }) + await ctx.get('list_drafts').handler({ type: 'email', status: 'LOCKED_FOR_TRANSLATION' }) + expect(ctx.client.get).toHaveBeenCalledWith('/drafts', { cursor: undefined, status: 'LOCKED_FOR_TRANSLATION', templateId: undefined }) + }) + + it('rejects invalid status values', async () => { + await expect(ctx.get('list_drafts').handler({ type: 'email', status: 'PENDING_APPROVAL' })).rejects.toThrow() + await expect(ctx.get('list_drafts').handler({ type: 'email', status: 'IN_PROGRESS' })).rejects.toThrow() + }) + + it('forwards templateId filter', async () => { + await ctx.get('list_drafts').handler({ type: 'email', templateId: 'tem_abc' }) + expect(ctx.client.get).toHaveBeenCalledWith('/drafts', { cursor: undefined, status: undefined, templateId: 'tem_abc' }) }) it('throws on invalid type', async () => { diff --git a/tests/tools/localizations.test.ts b/tests/tools/localizations.test.ts index bb86722..74ea708 100644 --- a/tests/tools/localizations.test.ts +++ b/tests/tools/localizations.test.ts @@ -109,6 +109,32 @@ describe('delete_localization', () => { }) }) +describe('get_translations', () => { + let ctx: ReturnType + beforeEach(() => { + ctx = setup() + ctx.client.get = vi.fn().mockResolvedValue({ greeting: 'Bonjour' }) + }) + + it('GET /drafts/{id}/localizations/{lang}/translations', async () => { + await ctx.get('get_translations').handler(LANG_ARGS) + expect(ctx.client.get).toHaveBeenCalledWith( + '/drafts/tdft_123/localizations/fr-FR/translations', + ) + }) + + it('uses channel prefix for sms', async () => { + await ctx.get('get_translations').handler({ type: 'sms', draftId: 'tdft_abc', languageId: 'de-DE' }) + expect(ctx.client.get).toHaveBeenCalledWith( + '/sms/drafts/tdft_abc/localizations/de-DE/translations', + ) + }) + + it('throws on missing languageId', async () => { + await expect(ctx.get('get_translations').handler(DRAFT_ARGS)).rejects.toThrow() + }) +}) + describe('set_translations', () => { let ctx: ReturnType beforeEach(() => { diff --git a/tests/tools/templates.test.ts b/tests/tools/templates.test.ts index 5dca125..137e437 100644 --- a/tests/tools/templates.test.ts +++ b/tests/tools/templates.test.ts @@ -19,32 +19,47 @@ describe('list_templates', () => { it('GET /templates for email (no prefix)', async () => { await ctx.get('list_templates').handler({ type: 'email' }) - expect(ctx.client.get).toHaveBeenCalledWith('/templates', { cursor: undefined }) + expect(ctx.client.get).toHaveBeenCalledWith('/templates', { cursor: undefined, name: undefined, folderId: undefined, workspaceId: undefined }) }) it('GET /sms/templates for sms', async () => { await ctx.get('list_templates').handler({ type: 'sms' }) - expect(ctx.client.get).toHaveBeenCalledWith('/sms/templates', { cursor: undefined }) + expect(ctx.client.get).toHaveBeenCalledWith('/sms/templates', { cursor: undefined, name: undefined, folderId: undefined, workspaceId: undefined }) }) it('GET /push/templates for push', async () => { await ctx.get('list_templates').handler({ type: 'push' }) - expect(ctx.client.get).toHaveBeenCalledWith('/push/templates', { cursor: undefined }) + expect(ctx.client.get).toHaveBeenCalledWith('/push/templates', { cursor: undefined, name: undefined, folderId: undefined, workspaceId: undefined }) }) it('GET /voice/templates for voice', async () => { await ctx.get('list_templates').handler({ type: 'voice' }) - expect(ctx.client.get).toHaveBeenCalledWith('/voice/templates', { cursor: undefined }) + expect(ctx.client.get).toHaveBeenCalledWith('/voice/templates', { cursor: undefined, name: undefined, folderId: undefined, workspaceId: undefined }) }) it('GET /liveactivity/templates for liveactivity', async () => { await ctx.get('list_templates').handler({ type: 'liveactivity' }) - expect(ctx.client.get).toHaveBeenCalledWith('/liveactivity/templates', { cursor: undefined }) + expect(ctx.client.get).toHaveBeenCalledWith('/liveactivity/templates', { cursor: undefined, name: undefined, folderId: undefined, workspaceId: undefined }) }) it('forwards pagination cursor', async () => { await ctx.get('list_templates').handler({ type: 'email', cursor: 'page2' }) - expect(ctx.client.get).toHaveBeenCalledWith('/templates', { cursor: 'page2' }) + expect(ctx.client.get).toHaveBeenCalledWith('/templates', { cursor: 'page2', name: undefined, folderId: undefined, workspaceId: undefined }) + }) + + it('forwards name filter', async () => { + await ctx.get('list_templates').handler({ type: 'email', name: 'welcome' }) + expect(ctx.client.get).toHaveBeenCalledWith('/templates', { cursor: undefined, name: 'welcome', folderId: undefined, workspaceId: undefined }) + }) + + it('forwards folderId filter', async () => { + await ctx.get('list_templates').handler({ type: 'email', folderId: 'fdr_abc' }) + expect(ctx.client.get).toHaveBeenCalledWith('/templates', { cursor: undefined, name: undefined, folderId: 'fdr_abc', workspaceId: undefined }) + }) + + it('forwards workspaceId filter', async () => { + await ctx.get('list_templates').handler({ type: 'email', workspaceId: 'fdr_xyz' }) + expect(ctx.client.get).toHaveBeenCalledWith('/templates', { cursor: undefined, name: undefined, folderId: undefined, workspaceId: 'fdr_xyz' }) }) it('throws on invalid type', async () => { diff --git a/tests/tools/themes.test.ts b/tests/tools/themes.test.ts new file mode 100644 index 0000000..1276641 --- /dev/null +++ b/tests/tools/themes.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { vi } from 'vitest' +import { themeTools } from '../../src/tools/themes.js' +import { makeMockClient } from '../helpers.js' + +function setup() { + const client = makeMockClient() + const tools = themeTools(client) + const get = (name: string) => tools.find((t) => t.name === name)! + return { client, get } +} + +describe('list_themes', () => { + let ctx: ReturnType + beforeEach(() => { + ctx = setup() + ctx.client.get = vi.fn().mockResolvedValue({ data: [] }) + }) + + it('GET /themes with no cursor', async () => { + await ctx.get('list_themes').handler({}) + expect(ctx.client.get).toHaveBeenCalledWith('/themes', { cursor: undefined }) + }) + + it('forwards cursor', async () => { + await ctx.get('list_themes').handler({ cursor: 'page2' }) + expect(ctx.client.get).toHaveBeenCalledWith('/themes', { cursor: 'page2' }) + }) +}) + +describe('get_theme', () => { + let ctx: ReturnType + beforeEach(() => { + ctx = setup() + ctx.client.get = vi.fn().mockResolvedValue({}) + }) + + it('GET /themes/{themeId}', async () => { + await ctx.get('get_theme').handler({ themeId: 'thm_abc' }) + expect(ctx.client.get).toHaveBeenCalledWith('/themes/thm_abc') + }) + + it('throws on missing themeId', async () => { + await expect(ctx.get('get_theme').handler({})).rejects.toThrow() + }) +})