mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 08:06:13 +00:00
`PushSubscription/get` ran with the default properties, which leave out `emailPush`, so the stored filter never compared equal to the wanted one and every launch sent it again. The calls also never named the emailpush capability in `using`. Ask for `emailPush` explicitly and send `urn:ietf:params:jmap:emailpush` with every call that reads or writes it, as the webmail's `lib/jmap/client.ts` does. Servers without the capability keep the default properties and never see the property, so the filter is only patched when it differs from what the server holds.
201 lines
6.9 KiB
TypeScript
201 lines
6.9 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
|
|
vi.mock('../jmap-client', () => ({
|
|
jmapClient: {
|
|
accountId: 'acc-1',
|
|
currentSession: {
|
|
eventSourceUrl: 'https://mail.example.com/eventsource/?types={types}&closeafter={closeafter}&ping={ping}',
|
|
},
|
|
authHeader: 'Basic dXNlcjpwYXNz',
|
|
request: vi.fn(),
|
|
hasCapability: vi.fn(() => false),
|
|
},
|
|
}));
|
|
|
|
import { jmapClient } from '../jmap-client';
|
|
import {
|
|
createPushSubscription,
|
|
listPushSubscriptions,
|
|
startPolling,
|
|
updatePushSubscription,
|
|
} from '../push';
|
|
|
|
const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>;
|
|
const mockHasCapability = jmapClient.hasCapability as ReturnType<typeof vi.fn>;
|
|
const EMAIL_PUSH = 'urn:ietf:params:jmap:emailpush';
|
|
|
|
describe('push operations', () => {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
describe('emailPush read-back', () => {
|
|
const FILTER = {
|
|
filter: { operator: 'AND', conditions: [{ notKeyword: '$junk' }] },
|
|
properties: ['id', 'threadId'],
|
|
urgency: 'high' as const,
|
|
};
|
|
|
|
beforeEach(() => {
|
|
mockHasCapability.mockImplementation((urn: string) => urn === EMAIL_PUSH);
|
|
});
|
|
|
|
afterEach(() => {
|
|
mockHasCapability.mockImplementation(() => false);
|
|
});
|
|
|
|
it('asks for emailPush explicitly under the emailpush capability', async () => {
|
|
mockRequest.mockResolvedValue({
|
|
methodResponses: [['PushSubscription/get', { list: [{ id: 's1', emailPush: { a: FILTER } }] }, '0']],
|
|
});
|
|
const list = await listPushSubscriptions();
|
|
const [calls, using] = mockRequest.mock.calls[0];
|
|
expect(calls[0][1].properties).toContain('emailPush');
|
|
expect(using).toEqual(['urn:ietf:params:jmap:core', EMAIL_PUSH]);
|
|
expect(list[0].emailPush).toEqual({ a: FILTER });
|
|
});
|
|
|
|
it('keeps the default properties on servers without emailpush', async () => {
|
|
mockHasCapability.mockImplementation(() => false);
|
|
mockRequest.mockResolvedValue({ methodResponses: [['PushSubscription/get', { list: [] }, '0']] });
|
|
await listPushSubscriptions();
|
|
const [calls, using] = mockRequest.mock.calls[0];
|
|
expect(calls[0][1]).toEqual({ ids: null });
|
|
expect(using).toEqual(['urn:ietf:params:jmap:core']);
|
|
});
|
|
|
|
it('sends the capability with an emailPush create and update', async () => {
|
|
mockRequest.mockResolvedValueOnce({
|
|
methodResponses: [['PushSubscription/set', { created: { new: { id: 's1' } } }, '0']],
|
|
});
|
|
await createPushSubscription({
|
|
deviceClientId: 'd', url: 'https://relay/x', types: ['EmailDelivery'], emailPush: { a: FILTER },
|
|
});
|
|
expect(mockRequest.mock.calls[0][1]).toEqual(['urn:ietf:params:jmap:core', EMAIL_PUSH]);
|
|
|
|
mockRequest.mockResolvedValueOnce({
|
|
methodResponses: [['PushSubscription/set', { updated: { s1: null } }, '0']],
|
|
});
|
|
await updatePushSubscription('s1', { expires: '2026-10-01T00:00:00Z' });
|
|
// An update that leaves the filter alone doesn't need the capability.
|
|
expect(mockRequest.mock.calls[1][1]).toEqual(['urn:ietf:params:jmap:core']);
|
|
});
|
|
|
|
it('drops emailPush from writes on servers without the capability', async () => {
|
|
mockHasCapability.mockImplementation(() => false);
|
|
mockRequest.mockResolvedValue({
|
|
methodResponses: [['PushSubscription/set', { updated: { s1: null } }, '0']],
|
|
});
|
|
await updatePushSubscription('s1', { expires: '2026-10-01T00:00:00Z', emailPush: { a: FILTER } });
|
|
const [calls, using] = mockRequest.mock.calls[0];
|
|
expect(calls[0][1].update.s1).toEqual({ expires: '2026-10-01T00:00:00Z' });
|
|
expect(using).toEqual(['urn:ietf:params:jmap:core']);
|
|
});
|
|
});
|
|
|
|
describe('PushSubscription/set refusals', () => {
|
|
const forbidden = {
|
|
type: 'forbidden',
|
|
description: 'No access to one of the accounts in the emailPush map.',
|
|
};
|
|
|
|
it('surfaces the SetError type when a create is refused', async () => {
|
|
mockRequest.mockResolvedValue({
|
|
methodResponses: [['PushSubscription/set', { notCreated: { new: forbidden } }, '0']],
|
|
});
|
|
await expect(
|
|
createPushSubscription({ deviceClientId: 'd', url: 'https://relay/x', types: ['EmailDelivery'] }),
|
|
).rejects.toMatchObject({ name: 'JMAPMethodError', type: 'forbidden' });
|
|
});
|
|
|
|
it('surfaces the SetError type when an update is refused', async () => {
|
|
mockRequest.mockResolvedValue({
|
|
methodResponses: [['PushSubscription/set', { notUpdated: { s1: forbidden } }, '0']],
|
|
});
|
|
await expect(updatePushSubscription('s1', { expires: '2026-10-01T00:00:00Z' }))
|
|
.rejects.toMatchObject({ type: 'forbidden' });
|
|
});
|
|
|
|
it('resolves when the update is applied', async () => {
|
|
mockRequest.mockResolvedValue({
|
|
methodResponses: [['PushSubscription/set', { updated: { s1: null } }, '0']],
|
|
});
|
|
await expect(updatePushSubscription('s1', { expires: '2026-10-01T00:00:00Z' })).resolves.toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('startPolling', () => {
|
|
it('should poll at the specified interval', async () => {
|
|
mockRequest.mockResolvedValue({
|
|
methodResponses: [
|
|
['Mailbox/get', { state: 'state-1' }, 'm'],
|
|
['Email/get', { state: 'state-1' }, 'e'],
|
|
],
|
|
});
|
|
|
|
const handler = vi.fn();
|
|
const stop = startPolling(handler, 1000);
|
|
|
|
// First poll
|
|
await vi.advanceTimersByTimeAsync(1000);
|
|
expect(mockRequest).toHaveBeenCalledTimes(1);
|
|
|
|
// Second poll with same state - no notification
|
|
await vi.advanceTimersByTimeAsync(1000);
|
|
expect(handler).not.toHaveBeenCalled();
|
|
|
|
stop();
|
|
});
|
|
|
|
it('should notify on state change', async () => {
|
|
let callCount = 0;
|
|
mockRequest.mockImplementation(async () => {
|
|
callCount++;
|
|
return {
|
|
methodResponses: [
|
|
['Mailbox/get', { state: callCount === 1 ? 'state-1' : 'state-2' }, 'm'],
|
|
['Email/get', { state: 'state-1' }, 'e'],
|
|
],
|
|
};
|
|
});
|
|
|
|
const handler = vi.fn();
|
|
const stop = startPolling(handler, 1000);
|
|
|
|
// First poll (establishes baseline)
|
|
await vi.advanceTimersByTimeAsync(1000);
|
|
expect(handler).not.toHaveBeenCalled();
|
|
|
|
// Second poll (state changed for Mailbox)
|
|
await vi.advanceTimersByTimeAsync(1000);
|
|
expect(handler).toHaveBeenCalledTimes(1);
|
|
expect(handler).toHaveBeenCalledWith({
|
|
'@type': 'StateChange',
|
|
changed: {
|
|
'acc-1': { Mailbox: 'state-2' },
|
|
},
|
|
});
|
|
|
|
stop();
|
|
});
|
|
|
|
it('should handle errors silently', async () => {
|
|
mockRequest.mockRejectedValue(new Error('Network error'));
|
|
|
|
const handler = vi.fn();
|
|
const stop = startPolling(handler, 1000);
|
|
|
|
// Should not throw
|
|
await vi.advanceTimersByTimeAsync(1000);
|
|
expect(handler).not.toHaveBeenCalled();
|
|
|
|
stop();
|
|
});
|
|
});
|
|
});
|