import { BadRequestException } from '@nestjs/common'; import { NotificationPreferencesService } from './notification-preferences.service'; import { PrismaService } from '../prisma/prisma.service'; import { lockRowForUpdate } from '../prisma/row-lock'; jest.mock('../prisma/row-lock', () => ({ lockRowForUpdate: jest.fn().mockResolvedValue(true), })); const stored = { userId: 'u1', taskDueEnabled: true, digestEnabled: true, digestTime: '06:30', quietFrom: '21:00' as string | null, quietUntil: '06:30' as string | null, telegramEnabled: false, telegramCategories: ['TASK_DUE', 'DIGEST'], telegramDetail: true, updatedAt: new Date(0), }; describe('NotificationPreferencesService', () => { let service: NotificationPreferencesService; let tx: { notificationPreference: { upsert: jest.Mock; findUniqueOrThrow: jest.Mock; update: jest.Mock; }; }; let prisma: { notificationPreference: { upsert: jest.Mock }; telegramLink: { findUnique: jest.Mock }; $transaction: jest.Mock; }; const order: string[] = []; beforeEach(() => { order.length = 0; (lockRowForUpdate as jest.Mock).mockImplementation(() => { order.push('lock'); return Promise.resolve(true); }); tx = { notificationPreference: { upsert: jest.fn().mockImplementation(() => { order.push('upsert'); return Promise.resolve(stored); }), findUniqueOrThrow: jest.fn().mockImplementation(() => { order.push('read'); return Promise.resolve(stored); }), update: jest .fn() .mockImplementation(({ data }: { data: object }) => Promise.resolve({ ...stored, ...data }), ), }, }; prisma = { notificationPreference: { upsert: jest.fn().mockResolvedValue(stored), }, telegramLink: { findUnique: jest.fn().mockResolvedValue(null) }, $transaction: jest .fn() .mockImplementation((work: (t: unknown) => Promise) => work(tx), ), }; service = new NotificationPreferencesService( prisma as unknown as PrismaService, ); }); it('creates the defaults on first read', async () => { await expect(service.getOrCreate('u1')).resolves.toBe(stored); expect(prisma.notificationPreference.upsert).toHaveBeenCalledWith({ where: { userId: 'u1' }, create: { userId: 'u1' }, update: {}, }); }); it('uses the given client for the first read', async () => { await service.getOrCreate('u1', tx as never); expect(tx.notificationPreference.upsert).toHaveBeenCalledTimes(1); expect(prisma.notificationPreference.upsert).not.toHaveBeenCalled(); }); it.each(['04:30', '12:01', '00:00', '23:59'])( 'rejects %s as digest time with the German message', async (digestTime) => { const failure = service.update('u1', { digestTime }); await expect(failure).rejects.toBeInstanceOf(BadRequestException); await expect(failure).rejects.toThrow( 'Bitte eine Uhrzeit zwischen 05:00 und 12:00 wählen.', ); expect(tx.notificationPreference.update).not.toHaveBeenCalled(); }, ); it.each(['05:00', '12:00', '09:15'])( 'accepts %s as digest time', async (digestTime) => { await service.update('u1', { digestTime }); expect(tx.notificationPreference.update).toHaveBeenCalledWith({ where: { userId: 'u1' }, data: expect.objectContaining({ digestTime }) as unknown, }); }, ); it('clears quiet hours only as a pair', async () => { const failure = service.update('u1', { quietFrom: null }); await expect(failure).rejects.toBeInstanceOf(BadRequestException); await expect(failure).rejects.toThrow( 'Bitte Beginn und Ende der Ruhezeit zusammen leeren.', ); await service.update('u1', { quietFrom: null, quietUntil: null }); expect(tx.notificationPreference.update).toHaveBeenCalledWith({ where: { userId: 'u1' }, data: expect.objectContaining({ quietFrom: null, quietUntil: null, }) as unknown, }); }); it('judges the pair rule on the merged values, not on the dto alone', async () => { tx.notificationPreference.findUniqueOrThrow.mockResolvedValue({ ...stored, quietFrom: null, quietUntil: null, }); await expect( service.update('u1', { quietFrom: '22:00' }), ).rejects.toBeInstanceOf(BadRequestException); await expect( service.update('u1', { quietFrom: '22:00', quietUntil: '05:30' }), ).resolves.toBeDefined(); }); it('update locks the preference row by userId and merges with the stored values read inside the transaction', async () => { tx.notificationPreference.findUniqueOrThrow.mockImplementation(() => { order.push('read'); return Promise.resolve({ ...stored, digestTime: '07:00' }); }); const result = await service.update('u1', { telegramDetail: false }); expect(order).toEqual(['upsert', 'lock', 'read']); expect(lockRowForUpdate).toHaveBeenCalledWith( tx, 'NotificationPreference', 'u1', ); expect(tx.notificationPreference.update).toHaveBeenCalledWith({ where: { userId: 'u1' }, data: { taskDueEnabled: true, digestEnabled: true, digestTime: '07:00', quietFrom: '21:00', quietUntil: '06:30', telegramCategories: ['TASK_DUE', 'DIGEST'], telegramDetail: false, }, }); expect(result.telegramDetail).toBe(false); }); it('never writes telegramEnabled', async () => { await service.update('u1', { telegramCategories: ['SYSTEM'] }); const [[arg]] = tx.notificationPreference.update.mock.calls as [ [{ data: Record }], ]; expect(arg.data).not.toHaveProperty('telegramEnabled'); expect(arg.data.telegramCategories).toEqual(['SYSTEM']); }); describe('toView (Telegram status)', () => { const originalUsername = process.env.TELEGRAM_BOT_USERNAME; const originalToken = process.env.TELEGRAM_BOT_TOKEN; afterEach(() => { if (originalUsername === undefined) { delete process.env.TELEGRAM_BOT_USERNAME; } else { process.env.TELEGRAM_BOT_USERNAME = originalUsername; } if (originalToken === undefined) { delete process.env.TELEGRAM_BOT_TOKEN; } else { process.env.TELEGRAM_BOT_TOKEN = originalToken; } }); it('NotificationSettingsView.telegram.botNameSet is true with the username set and no token in the API process', async () => { process.env.TELEGRAM_BOT_USERNAME = '@apicore_test_bot'; delete process.env.TELEGRAM_BOT_TOKEN; const view = await service.toView(stored as never); expect(view.telegram).toEqual({ botNameSet: true, botUsername: 'apicore_test_bot', linked: false, firstName: null, linkedAt: null, enabled: false, }); expect(JSON.stringify(view)).not.toMatch(/token/i); }); it('is not configured without a bot username, even with a token', async () => { process.env.TELEGRAM_BOT_USERNAME = ''; process.env.TELEGRAM_BOT_TOKEN = '123456789:AAEhBOweik6ad9r_QXMENQjcrGbqCr4K-RM'; const view = await service.toView(stored as never); expect(view.telegram.botNameSet).toBe(false); expect(view.telegram.botUsername).toBeNull(); }); it('shows the link without the chat id', async () => { process.env.TELEGRAM_BOT_USERNAME = 'apicore_test_bot'; prisma.telegramLink.findUnique.mockResolvedValue({ userId: 'u1', chatId: 4_711_000_001n, telegramFirstName: 'Anna', linkedAt: new Date('2026-10-14T08:00:00.000Z'), }); const view = await service.toView({ ...stored, telegramEnabled: true, } as never); expect(view.telegram).toMatchObject({ linked: true, firstName: 'Anna', linkedAt: '2026-10-14T08:00:00.000Z', enabled: true, }); expect(JSON.stringify(view)).not.toContain('4711000001'); expect(prisma.telegramLink.findUnique).toHaveBeenCalledWith({ where: { userId: 'u1' }, }); }); }); });