Files
ApiCore/ApiCore-Backend/src/notifications/notification-preferences.service.spec.ts
T
Nick SzaunigandClaude Opus 5.5 9760ce154f
Backend Image / build-and-push (push) Successful in 4m27s
Frontend Image / build-and-push (push) Successful in 9m34s
Manifests / validate (push) Successful in 19s
feat: T4 wave 11 – bottling form with article values and confirmation, Telegram in the app
- Bottling form: jar size and best-before from the product with source,
  differs-hints before saving, prefill status, water-content confirmation
  naming the measured value, idempotency key renewed on body change;
  product form gets shelf life (months) and whole-gram jar weight.
- Telegram frontend: link card with step-up dialog, t.me-only deep link,
  status polling, unlink; system Telegram card with receiver/sender state
  and confirmed retry; StepUpDialog shared with the backup import.
- Reauth calls never refresh-and-retry on 401 (no double lockout count);
  German texts for expired session and 429; service worker never caches
  Telegram, notification settings and system responses.
- Telegram backend round 2: shared link removal on every disconnect path,
  lease released on shutdown, connection errors not counted as poison.
- ARCHITECTURE.md §2, §6 (Annahmen 56–58), §10, §11.12, §11.13; TASKS.md T4.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 01:21:08 +02:00

257 lines
8.1 KiB
TypeScript

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<unknown>) =>
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<string, unknown> }],
];
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' },
});
});
});
});