- 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]>
257 lines
8.1 KiB
TypeScript
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' },
|
|
});
|
|
});
|
|
});
|
|
});
|