diff --git a/src/api/__tests__/push.test.ts b/src/api/__tests__/push.test.ts index a19e1dfe..4aa8fc78 100644 --- a/src/api/__tests__/push.test.ts +++ b/src/api/__tests__/push.test.ts @@ -15,9 +15,11 @@ vi.mock('../jmap-client', () => ({ import { jmapClient } from '../jmap-client'; import { createPushSubscription, + destroyPushSubscription, listPushSubscriptions, startPolling, updatePushSubscription, + verifyPushSubscription, } from '../push'; const mockRequest = jmapClient.request as ReturnType; @@ -121,6 +123,44 @@ describe('push operations', () => { .rejects.toMatchObject({ type: 'forbidden' }); }); + it('throws when the verification code is refused', async () => { + mockRequest.mockResolvedValue({ + methodResponses: [[ + 'PushSubscription/set', + { notUpdated: { s1: { type: 'invalidProperties', description: 'Verification code does not match.' } } }, + '0', + ]], + }); + await expect(verifyPushSubscription('s1', 'CODE')).rejects.toThrow('Verification code does not match.'); + }); + + it('throws when a destroy is refused', async () => { + mockRequest.mockResolvedValue({ + methodResponses: [['PushSubscription/set', { notDestroyed: { s1: forbidden } }, '0']], + }); + await expect(destroyPushSubscription('s1')).rejects.toMatchObject({ type: 'forbidden' }); + }); + + it('treats destroying a subscription that is already gone as done', async () => { + mockRequest.mockResolvedValue({ + methodResponses: [['PushSubscription/set', { notDestroyed: { s1: { type: 'notFound' } } }, '0']], + }); + await expect(destroyPushSubscription('s1')).resolves.toBeUndefined(); + }); + + it.each([ + ['list', () => listPushSubscriptions()], + ['create', () => createPushSubscription({ deviceClientId: 'd', url: 'https://relay/x', types: ['EmailDelivery'] })], + ['update', () => updatePushSubscription('s1', { expires: '2026-10-01T00:00:00Z' })], + ['verify', () => verifyPushSubscription('s1', 'CODE')], + ['destroy', () => destroyPushSubscription('s1')], + ])('%s throws on a method-level error response', async (_name, call) => { + mockRequest.mockResolvedValue({ + methodResponses: [['error', { type: 'serverFail', description: 'Store unavailable' }, '0']], + }); + await expect(call()).rejects.toMatchObject({ name: 'JMAPMethodError', type: 'serverFail' }); + }); + it('resolves when the update is applied', async () => { mockRequest.mockResolvedValue({ methodResponses: [['PushSubscription/set', { updated: { s1: null } }, '0']], diff --git a/src/api/push.ts b/src/api/push.ts index 3ed43c42..c2c0abe3 100644 --- a/src/api/push.ts +++ b/src/api/push.ts @@ -1,5 +1,5 @@ import { jmapClient } from './jmap-client'; -import { assertSetResult, JMAPMethodError } from './jmap-result'; +import { assertSetResult, JMAPMethodError, requireMethodResult } from './jmap-result'; import { CAPABILITIES } from './types'; import type { EmailPushConfig, PushSubscription, StateChange } from './types'; @@ -33,8 +33,8 @@ export async function listPushSubscriptions(): Promise { [['PushSubscription/get', args, '0']], pushUsing(withEmailPush), ); - const [, body] = res.methodResponses[0] ?? []; - return (body?.list as PushSubscription[]) ?? []; + const body = requireMethodResult(res, '0', 'PushSubscription/get'); + return (body.list as PushSubscription[]) ?? []; } /** @@ -74,9 +74,9 @@ export async function createPushSubscription(params: { ], pushUsing(withEmailPush), ); - const [, body] = res.methodResponses[0] ?? []; + const body = requireMethodResult(res, '0', 'PushSubscription/set'); assertSetResult(body, ['new'], 'push subscription'); - const result = body?.created?.new as { id?: string } | undefined; + const result = body.created?.new as { id?: string } | undefined; if (!result?.id) { throw new Error(`PushSubscription/set create failed: ${JSON.stringify(body)}`); } @@ -107,9 +107,9 @@ export async function updatePushSubscription( ], pushUsing(withEmailPush), ); - const [, body] = res.methodResponses[0] ?? []; + const body = requireMethodResult(res, '0', 'PushSubscription/set'); assertSetResult(body, [id], 'push subscription'); - if (body?.updated?.[id] === undefined) { + if (body.updated?.[id] === undefined) { throw new JMAPMethodError('notUpdated', `PushSubscription/set did not update ${id}`); } } @@ -132,19 +132,19 @@ export async function verifyPushSubscription( ], [CAPABILITIES.CORE], ); - const [, body] = res.methodResponses[0] ?? []; - if (body?.notUpdated?.[id]) { - throw new Error( - `PushSubscription verification failed: ${JSON.stringify(body.notUpdated[id])}`, - ); - } + const body = requireMethodResult(res, '0', 'PushSubscription/set'); + assertSetResult(body, [id], 'push subscription'); } export async function destroyPushSubscription(id: string): Promise { - await jmapClient.request( + const res = await jmapClient.request( [['PushSubscription/set', { destroy: [id] }, '0']], [CAPABILITIES.CORE], ); + const body = requireMethodResult(res, '0', 'PushSubscription/set'); + // Already gone is what the caller asked for. + if (body.notDestroyed?.[id]?.type === 'notFound') return; + assertSetResult(body, [id], 'push subscription'); } // ─── Live updates ────────────────────────────────────────