feat(notifications): Implement PushChannel class with send/subscribe logic and unit tests for push notification support

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-04-08 21:17:44 -07:00
parent 8937abd63f
commit a2f866b707

View file

@ -0,0 +1,171 @@
import { ConfigService } from '@nestjs/config';
import { PushChannel } from '../channels/push.channel';
import type { Reminder } from '../entities/reminder.entity';
function makeConfigService(values: Record<string, string> = {}): ConfigService {
return {
get: jest.fn((key: string, defaultVal?: unknown) => values[key] ?? defaultVal),
} as unknown as ConfigService;
}
function makeReminder(overrides: Partial<Reminder> = {}): Reminder {
return {
id: 'reminder-uuid-1',
targetType: 'medication',
targetId: 'med-uuid-1',
title: 'Take your meds',
message: 'Naltrexone time.',
channels: ['push'],
triggerType: 'recurring',
triggerConfig: {},
status: 'pending',
firedAt: null,
nextFireAt: null,
retryCount: 0,
externalMessageId: null,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
} as Reminder;
}
describe('PushChannel', () => {
let channel: PushChannel;
let mockFetch: jest.Mock;
beforeEach(() => {
mockFetch = jest.fn();
global.fetch = mockFetch;
});
afterEach(() => {
jest.restoreAllMocks();
});
describe('dispatch', () => {
it('POSTs the correct payload to companion-api /api/push/fire', async () => {
channel = new PushChannel(
makeConfigService({
COMPANION_API_URL: 'http://localhost:3850',
COMPANION_PUSH_FIRE_TOKEN: 'secret-token',
}),
);
mockFetch.mockResolvedValue({
ok: true,
json: jest.fn().mockResolvedValue({ sent: 1, failed: 0 }),
});
const reminder = makeReminder();
await channel.dispatch(reminder);
expect(mockFetch).toHaveBeenCalledOnce();
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
expect(url).toBe('http://localhost:3850/api/push/fire');
expect(init.method).toBe('POST');
expect((init.headers as Record<string, string>)['X-Service-Token']).toBe('secret-token');
expect((init.headers as Record<string, string>)['Content-Type']).toBe('application/json');
const body = JSON.parse(init.body as string) as Record<string, unknown>;
expect(body.title).toBe('Take your meds');
expect(body.body).toBe('Naltrexone time.');
expect(body.tag).toBe('nudge-medication');
expect((body.data as Record<string, unknown>).reminderId).toBe('reminder-uuid-1');
expect((body.data as Record<string, unknown>).targetType).toBe('medication');
expect((body.data as Record<string, unknown>).targetId).toBe('med-uuid-1');
});
it('uses empty string for body when message is null', async () => {
channel = new PushChannel(
makeConfigService({
COMPANION_API_URL: 'http://localhost:3850',
COMPANION_PUSH_FIRE_TOKEN: 'secret-token',
}),
);
mockFetch.mockResolvedValue({
ok: true,
json: jest.fn().mockResolvedValue({ sent: 1, failed: 0 }),
});
const reminder = makeReminder({ message: null });
await channel.dispatch(reminder);
const body = JSON.parse((mockFetch.mock.calls[0] as [string, RequestInit])[1].body as string) as Record<string, unknown>;
expect(body.body).toBe('');
});
it('skips dispatch and logs warning when COMPANION_API_URL is missing', async () => {
channel = new PushChannel(
makeConfigService({ COMPANION_PUSH_FIRE_TOKEN: 'secret-token' }),
);
await channel.dispatch(makeReminder());
expect(mockFetch).not.toHaveBeenCalled();
});
it('skips dispatch and logs warning when COMPANION_PUSH_FIRE_TOKEN is missing', async () => {
channel = new PushChannel(
makeConfigService({ COMPANION_API_URL: 'http://localhost:3850' }),
);
await channel.dispatch(makeReminder());
expect(mockFetch).not.toHaveBeenCalled();
});
it('does not throw on non-200 response — logs warning only', async () => {
channel = new PushChannel(
makeConfigService({
COMPANION_API_URL: 'http://localhost:3850',
COMPANION_PUSH_FIRE_TOKEN: 'secret-token',
}),
);
mockFetch.mockResolvedValue({
ok: false,
status: 403,
statusText: 'Forbidden',
text: jest.fn().mockResolvedValue('Access denied'),
});
await expect(channel.dispatch(makeReminder())).resolves.toBeUndefined();
});
it('does not throw on fetch network error — logs warning only', async () => {
channel = new PushChannel(
makeConfigService({
COMPANION_API_URL: 'http://localhost:3850',
COMPANION_PUSH_FIRE_TOKEN: 'secret-token',
}),
);
mockFetch.mockRejectedValue(new Error('ECONNREFUSED'));
await expect(channel.dispatch(makeReminder())).resolves.toBeUndefined();
});
it('omits targetId from data payload when targetId is null', async () => {
channel = new PushChannel(
makeConfigService({
COMPANION_API_URL: 'http://localhost:3850',
COMPANION_PUSH_FIRE_TOKEN: 'secret-token',
}),
);
mockFetch.mockResolvedValue({
ok: true,
json: jest.fn().mockResolvedValue({ sent: 1, failed: 0 }),
});
const reminder = makeReminder({ targetId: null });
await channel.dispatch(reminder);
const body = JSON.parse((mockFetch.mock.calls[0] as [string, RequestInit])[1].body as string) as Record<string, unknown>;
const data = body.data as Record<string, unknown>;
expect('targetId' in data).toBe(false);
expect(data.reminderId).toBe('reminder-uuid-1');
});
});
});