feat: T4 wave 10 – bottling quality, phenology and honey chain UI, Telegram link/receive and ops

- Bottling quality: water content above the legal limit (strict >) answers
  409 CONFIRMATION_REQUIRED bound to the measured value; prefill for jar
  size, best-before (shelfLifeMonths, month-end clamping) and water content;
  impossible dates give 400; water-content checks in src/common/quality.
- Frontend phenology: "Entwicklung melden" sheet, IndexedDB v4 queue,
  season header on the apiary page, confirmed revoke for all apiaries.
- Frontend honey chain: "Vorschlag aus …" hints with explicit take-over,
  prefill status note, movement ledger with withdraw/correct dialogs.
- Telegram: link tokens (hashed, single use), private-chat receiver with
  lease and offset CAS, bounded poison-update skip, Retry-After, pending
  deliveries skipped on disconnect; system API for deliveries and retry;
  worker-only secret wiring and manifest policy tests.
- ARCHITECTURE.md §7, §9, §10, §11.11–11.13, Annahmen 51–55.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-14 22:47:17 +02:00
1 parent 16cc557462
commit fab7f7e12f
91 files changed
+8463 -104

No files matched your search

@@ -0,0 +1,563 @@
import { INestApplication } from '@nestjs/common';
import * as bcrypt from 'bcryptjs';
import { randomUUID } from 'node:crypto';
import request from 'supertest';
import { App } from 'supertest/types';
import { PrismaService } from '../src/prisma/prisma.service';
import { createTestApp } from './utils/create-test-app';
import { login } from './utils/api';
interface Warning {
code: string;
severity: string;
message: string;
refs: { type: string; id: string }[];
}
interface BottlingBody {
id: string;
jarSize: number;
bestBeforeDate: string;
warnings: Warning[];
}
interface ConfirmationBody {
statusCode: number;
code: string;
waterContent: number;
message: string;
warnings: Warning[];
}
interface PrefillBody {
jarSize: number | null;
jarSizeSource: string | null;
bestBeforeDate: string | null;
shelfLifeMonths: number | null;
waterContent: number | null;
warnings: Warning[];
}
describe('Bottling quality (e2e)', () => {
let app: INestApplication<App>;
let prisma: PrismaService;
const stamp = Date.now();
const email = `bottling-quality-e2e-${stamp}@example.com`;
const password = 'SuperSecret123!';
let userId: string;
let token: string;
let staffId: string;
let staffToken: string;
let extractionId: string;
const honeyBatchIds: string[] = [];
const productIds: string[] = [];
const bottlingIds: string[] = [];
const api = () => request(app.getHttpServer());
const auth = (req: request.Test) =>
req.set('Authorization', `Bearer ${token}`);
async function newBatch(
waterContent: number | null,
honeyType: string | null = null,
): Promise<string> {
const batch = await prisma.honeyBatch.create({
data: {
batchNumber: `BQ-${stamp}-${honeyBatchIds.length}`,
extractionId,
amount: 25,
initialAmount: 25,
waterContent,
honeyType,
},
});
honeyBatchIds.push(batch.id);
return batch.id;
}
async function newProduct(data: {
packagingSize?: number;
shelfLifeMonths?: number;
}): Promise<string> {
const product = await prisma.product.create({
data: {
name: `Qualität ${stamp}-${productIds.length}`,
kind: 'BOTTLED_HONEY',
...data,
},
});
productIds.push(product.id);
return product.id;
}
/** What a write would leave behind for the batch: movements, events, audit. */
async function writtenCounts(honeyBatchId: string) {
return {
movements: await prisma.batchMovement.count({ where: { honeyBatchId } }),
events: await prisma.domainEvent.count({
where: {
aggregateType: 'BottlingBatch',
payload: { path: ['honeyBatchId'], equals: honeyBatchId },
},
}),
audits: await prisma.auditLog.count({
where: { userId, entityType: 'BottlingBatch', action: 'CREATE' },
}),
};
}
function create(body: Record<string, unknown>, key?: string): request.Test {
const req = auth(api().post('/api/v1/bottling-batches'));
if (key) {
req.set('Idempotency-Key', key);
}
return req.send({
date: '2026-01-31',
jarCount: 2,
lotNumber: `BQ-L-${stamp}-${randomUUID()}`,
...body,
});
}
function remember(response: { body: unknown }): BottlingBody {
const body = response.body as BottlingBody;
bottlingIds.push(body.id);
return body;
}
beforeAll(async () => {
app = await createTestApp();
prisma = app.get(PrismaService);
const user = await prisma.user.create({
data: {
email,
passwordHash: await bcrypt.hash(password, 12),
name: 'Quality',
role: { connect: { key: 'BEEKEEPER' } },
},
});
userId = user.id;
token = (await login(app, email, password)).accessToken;
const extraction = await prisma.extraction.create({
data: { date: new Date(), userId },
});
extractionId = extraction.id;
// STAFF has no bottling:create.
const staff = await prisma.user.create({
data: {
email: `bottling-quality-staff-${stamp}@example.com`,
passwordHash: await bcrypt.hash(password, 12),
name: 'Staff',
role: { connect: { key: 'STAFF' } },
},
});
staffId = staff.id;
staffToken = (await login(app, staff.email, password)).accessToken;
});
afterAll(async () => {
await prisma.domainEvent.deleteMany({
where: {
aggregateType: 'BottlingBatch',
aggregateId: { in: bottlingIds },
},
});
await prisma.bottlingBatch.deleteMany({
where: { id: { in: bottlingIds } },
});
await prisma.batchMovement.deleteMany({
where: { honeyBatchId: { in: honeyBatchIds } },
});
await prisma.honeyMeasurement.deleteMany({
where: { honeyBatchId: { in: honeyBatchIds } },
});
await prisma.honeyBatch.deleteMany({
where: { id: { in: honeyBatchIds } },
});
await prisma.extraction.deleteMany({ where: { id: extractionId } });
await prisma.product.deleteMany({ where: { id: { in: productIds } } });
await prisma.idempotencyRecord.deleteMany({ where: { userId } });
const userIds = [userId, staffId];
await prisma.refreshToken.deleteMany({
where: { userId: { in: userIds } },
});
await prisma.auditLog.deleteMany({ where: { userId: { in: userIds } } });
await prisma.user.deleteMany({ where: { id: { in: userIds } } });
await app.close();
});
it('17 % -> 201 without a quality warning', async () => {
const honeyBatchId = await newBatch(17);
const response = await create({
honeyBatchId,
jarSize: 500,
bestBeforeDate: '2027-01-31',
}).expect(201);
expect(remember(response).warnings).toEqual([]);
});
it('19 % -> 201 with WATER_CONTENT_HIGH', async () => {
const honeyBatchId = await newBatch(19);
const response = await create({
honeyBatchId,
jarSize: 500,
bestBeforeDate: '2027-01-31',
}).expect(201);
const warnings = remember(response).warnings;
expect(warnings.map((w) => w.code)).toEqual(['WATER_CONTENT_HIGH']);
expect(warnings[0].severity).toBe('WARNUNG');
});
it('no water content at all -> 201 with the hint WATER_CONTENT_MISSING', async () => {
const honeyBatchId = await newBatch(null);
const response = await create({
honeyBatchId,
jarSize: 500,
bestBeforeDate: '2027-01-31',
}).expect(201);
expect(remember(response).warnings.map((w) => w.code)).toEqual([
'WATER_CONTENT_MISSING',
]);
});
it('20.5 % -> 409 CONFIRMATION_REQUIRED; confirmed retry with the same key -> 201', async () => {
const honeyBatchId = await newBatch(20.5);
const key = randomUUID();
const body = { honeyBatchId, jarSize: 500, bestBeforeDate: '2027-01-31' };
const countsBefore = await writtenCounts(honeyBatchId);
const refused = await create(body, key).expect(409);
const refusal = refused.body as ConfirmationBody;
expect(refusal.code).toBe('CONFIRMATION_REQUIRED');
expect(refusal.warnings.map((w) => [w.code, w.severity])).toEqual([
['WATER_CONTENT_ABOVE_LEGAL', 'BESTAETIGEN'],
]);
expect(refusal.warnings[0].refs).toEqual([
{ type: 'HoneyBatch', id: honeyBatchId },
]);
expect(refusal.waterContent).toBe(20.5);
// Nothing was written: no bottling, the rest is untouched.
expect(await prisma.bottlingBatch.count({ where: { honeyBatchId } })).toBe(
0,
);
expect(
(
await prisma.honeyBatch.findUniqueOrThrow({
where: { id: honeyBatchId },
})
).amount,
).toBe(25);
expect(await writtenCounts(honeyBatchId)).toEqual(countsBefore);
const confirmed = await create(
{
...body,
acknowledgedWarnings: ['WATER_CONTENT_ABOVE_LEGAL'],
acknowledgedWaterContent: 20.5,
},
key,
).expect(201);
expect(remember(confirmed).warnings.map((w) => w.code)).toEqual([
'WATER_CONTENT_ABOVE_LEGAL',
]);
expect(await prisma.bottlingBatch.count({ where: { honeyBatchId } })).toBe(
1,
);
// A third call with the same key replays the stored 201.
const replay = await create(
{
...body,
acknowledgedWarnings: ['WATER_CONTENT_ABOVE_LEGAL'],
acknowledgedWaterContent: 20.5,
},
key,
).expect(201);
expect((replay.body as BottlingBody).id).toBe(
(confirmed.body as BottlingBody).id,
);
expect(await prisma.bottlingBatch.count({ where: { honeyBatchId } })).toBe(
1,
);
});
it('a confirmation is bound to the value: another value gets a fresh 409', async () => {
const honeyBatchId = await newBatch(21);
const body = {
honeyBatchId,
jarSize: 500,
bestBeforeDate: '2027-01-31',
acknowledgedWarnings: ['WATER_CONTENT_ABOVE_LEGAL'],
};
// Confirmed for 20.5 %, but the batch holds 21 %.
const stale = await create({
...body,
acknowledgedWaterContent: 20.5,
}).expect(409);
expect((stale.body as ConfirmationBody).waterContent).toBe(21);
// The code alone is no confirmation either.
await create(body).expect(409);
await create({ ...body, acknowledgedWaterContent: 101 }).expect(400);
expect(await prisma.bottlingBatch.count({ where: { honeyBatchId } })).toBe(
0,
);
remember(
await create({ ...body, acknowledgedWaterContent: 21 }).expect(201),
);
});
it('exactly 20.0 % only warns: 201 with WATER_CONTENT_HIGH', async () => {
const honeyBatchId = await newBatch(20);
const response = await create({
honeyBatchId,
jarSize: 500,
bestBeforeDate: '2027-01-31',
}).expect(201);
expect(remember(response).warnings.map((w) => w.code)).toEqual([
'WATER_CONTENT_HIGH',
]);
});
it('an impossible day is a 400, never a 500', async () => {
const honeyBatchId = await newBatch(17);
await create({
honeyBatchId,
jarSize: 500,
date: '2026-02-30',
bestBeforeDate: '2027-01-31',
}).expect(400);
await create({
honeyBatchId,
jarSize: 500,
bestBeforeDate: '2027-02-30',
}).expect(400);
await auth(api().get('/api/v1/bottling-batches/prefill'))
.query({ honeyBatchId, date: '2026-02-30' })
.expect(400);
});
it('a newer low measurement lifts the stop; a newer high one sets it', async () => {
const honeyBatchId = await newBatch(21);
const userRow = { honeyBatchId, userId };
await prisma.honeyMeasurement.create({
data: {
...userRow,
waterContent: 16.8,
measuredAt: new Date('2026-08-01'),
},
});
const body = { honeyBatchId, jarSize: 500, bestBeforeDate: '2027-01-31' };
remember(await create(body).expect(201));
await prisma.honeyMeasurement.create({
data: {
...userRow,
waterContent: 22,
measuredAt: new Date('2026-09-01'),
},
});
const refused = await create(body).expect(409);
expect((refused.body as ConfirmationBody).code).toBe(
'CONFIRMATION_REQUIRED',
);
});
it('rejects an unknown acknowledged code and more than one', async () => {
const honeyBatchId = await newBatch(17);
const body = { honeyBatchId, jarSize: 500, bestBeforeDate: '2027-01-31' };
await create({ ...body, acknowledgedWarnings: ['SOMETHING'] }).expect(400);
await create({
...body,
acknowledgedWarnings: [
'WATER_CONTENT_ABOVE_LEGAL',
'WATER_CONTENT_ABOVE_LEGAL',
],
}).expect(400);
});
it('a correction reports quality findings only when it changes the jar size or honey batch', async () => {
const honeyBatchId = await newBatch(17);
const created = remember(
await create({
honeyBatchId,
jarSize: 500,
bestBeforeDate: '2027-01-31',
}).expect(201),
);
await prisma.honeyBatch.update({
where: { id: honeyBatchId },
data: { waterContent: 21 },
});
const lotOnly = await auth(
api().patch(`/api/v1/bottling-batches/${created.id}`),
)
.send({ lotNumber: `BQ-L-${stamp}-${randomUUID()}` })
.expect(200);
expect((lotOnly.body as BottlingBody).warnings).toEqual([]);
const patched = await auth(
api().patch(`/api/v1/bottling-batches/${created.id}`),
)
.send({ jarSize: 250 })
.expect(200);
expect((patched.body as BottlingBody).warnings.map((w) => w.code)).toEqual([
'WATER_CONTENT_ABOVE_LEGAL',
]);
});
it('a honey measurement above the limit answers warnings and is never refused', async () => {
const honeyBatchId = await newBatch(17);
const response = await auth(
api().post(`/api/v1/honey-batches/${honeyBatchId}/measurements`),
)
.send({ waterContent: 21, measuredAt: '2026-10-01T09:00:00.000Z' })
.expect(201);
const warnings = (response.body as { warnings: Warning[] }).warnings;
expect(warnings.map((w) => w.code)).toEqual(['WATER_CONTENT_ABOVE_LEGAL']);
expect(
await prisma.honeyMeasurement.count({ where: { honeyBatchId } }),
).toBe(1);
});
it('best before comes from shelfLifeMonths and a different jar size gives a hint', async () => {
const honeyBatchId = await newBatch(17);
const productId = await newProduct({
packagingSize: 500,
shelfLifeMonths: 1,
});
const fromArticle = remember(
await create({ honeyBatchId, productId }).expect(201),
);
expect(fromArticle.jarSize).toBe(500);
// 2026-01-31 plus one month, cut at the month end.
expect(fromArticle.bestBeforeDate.slice(0, 10)).toBe('2026-02-28');
expect(fromArticle.warnings).toEqual([]);
const otherJar = remember(
await create({ honeyBatchId, productId, jarSize: 250 }).expect(201),
);
expect(otherJar.jarSize).toBe(250);
expect(otherJar.warnings.map((w) => w.code)).toEqual(['JAR_SIZE_DIFFERS']);
const sentDate = remember(
await create({
honeyBatchId,
productId,
bestBeforeDate: '2030-05-05',
}).expect(201),
);
expect(sentDate.bestBeforeDate.slice(0, 10)).toBe('2030-05-05');
});
it('refuses with a clear text when jar size and best before are unknown', async () => {
const honeyBatchId = await newBatch(17);
const noShelfLife = await newProduct({ packagingSize: 500 });
const none = await create({ honeyBatchId }).expect(400);
expect((none.body as { message: string }).message).toBe(
'Glasgröße und MHD fehlen. Bitte einen Artikel wählen oder beides eintragen.',
);
const noDate = await create({
honeyBatchId,
productId: noShelfLife,
}).expect(400);
expect((noDate.body as { message: string }).message).toBe(
'Ohne Haltbarkeit am Artikel bitte ein MHD eintragen.',
);
});
describe('GET /bottling-batches/prefill', () => {
it('prefill returns jar size and best before from the product', async () => {
const honeyBatchId = await newBatch(19);
const productId = await newProduct({
packagingSize: 250,
shelfLifeMonths: 24,
});
const response = await auth(api().get('/api/v1/bottling-batches/prefill'))
.query({ honeyBatchId, productId, date: '2026-05-10' })
.expect(200);
const prefill = response.body as PrefillBody;
expect(prefill).toMatchObject({
jarSize: 250,
jarSizeSource: 'PRODUCT',
bestBeforeDate: '2028-05-10',
shelfLifeMonths: 24,
waterContent: 19,
});
expect(prefill.warnings.map((w) => w.code)).toEqual([
'WATER_CONTENT_HIGH',
]);
// Read only.
expect(
await prisma.bottlingBatch.count({ where: { honeyBatchId } }),
).toBe(0);
});
it('without an article it still reports the water content', async () => {
const honeyBatchId = await newBatch(null);
const response = await auth(api().get('/api/v1/bottling-batches/prefill'))
.query({ honeyBatchId })
.expect(200);
const prefill = response.body as PrefillBody;
expect(prefill.jarSize).toBeNull();
expect(prefill.bestBeforeDate).toBeNull();
expect(prefill.warnings.map((w) => w.code)).toEqual([
'WATER_CONTENT_MISSING',
]);
});
it('needs bottling:create: 403 for STAFF', async () => {
const honeyBatchId = await newBatch(17);
await api()
.get('/api/v1/bottling-batches/prefill')
.set('Authorization', `Bearer ${staffToken}`)
.query({ honeyBatchId })
.expect(403);
});
it('refuses an article that is not bottled honey with 400', async () => {
const honeyBatchId = await newBatch(17);
const product = await prisma.product.create({
data: { name: `Kerze ${stamp}`, kind: 'MANUFACTURED' },
});
productIds.push(product.id);
await auth(api().get('/api/v1/bottling-batches/prefill'))
.query({ honeyBatchId, productId: product.id })
.expect(400);
});
it('answers 404 for unknown ids and 400 without a honey batch', async () => {
await auth(api().get('/api/v1/bottling-batches/prefill'))
.query({ honeyBatchId: 'missing' })
.expect(404);
await auth(api().get('/api/v1/bottling-batches/prefill')).expect(400);
});
});
});
@@ -132,6 +132,8 @@ describe('Bottling (e2e)', () => {
amount: 25,
// The movement book keeps this batch: bookings need a start amount.
initialAmount: 25,
// Below the warning limit: this file expects no quality warning.
waterContent: 17,
},
});
honeyBatchId = honeyBatch.id;
+12 -1
View File
@@ -348,7 +348,10 @@ describe('Notifications (e2e)', () => {
.set(as('bare'))
.expect(200);
expect(response.body).toEqual({
const { telegram, ...settings } = response.body as {
telegram: Record<string, unknown>;
};
expect(settings).toEqual({
taskDueEnabled: true,
digestEnabled: true,
digestTime: '06:30',
@@ -357,6 +360,14 @@ describe('Notifications (e2e)', () => {
telegramCategories: ['TASK_DUE', 'DIGEST'],
telegramDetail: true,
});
// configured / botUsername follow the environment of the process.
expect(telegram).toMatchObject({
linked: false,
firstName: null,
linkedAt: null,
enabled: false,
});
expect(typeof telegram.configured).toBe('boolean');
});
it('PATCH notification-settings stores digest time, quiet hours and Telegram categories', async () => {
+40
View File
@@ -104,4 +104,44 @@ describe('Products (e2e)', () => {
const product = response.body as ProductBody;
expect(product.status).toBe('DISCONTINUED');
});
it('keeps the shelf life in months and lets it be cleared', async () => {
const created = await request(app.getHttpServer())
.post('/api/v1/products')
.set('Authorization', `Bearer ${beekeeperToken}`)
.send({
name: `Haltbar ${Date.now()}`,
packagingSize: 500,
shelfLifeMonths: 24,
})
.expect(201);
const id = (created.body as ProductBody).id;
try {
expect(
(created.body as ProductBody & { shelfLifeMonths: number })
.shelfLifeMonths,
).toBe(24);
const cleared = await request(app.getHttpServer())
.patch(`/api/v1/products/${id}`)
.set('Authorization', `Bearer ${beekeeperToken}`)
.send({ shelfLifeMonths: null })
.expect(200);
expect(
(cleared.body as { shelfLifeMonths: number | null }).shelfLifeMonths,
).toBeNull();
} finally {
await prisma.product.deleteMany({ where: { id } });
}
});
it('rejects a shelf life outside 1 to 60 months', async () => {
for (const shelfLifeMonths of [0, 61, 1.5]) {
await request(app.getHttpServer())
.post('/api/v1/products')
.set('Authorization', `Bearer ${beekeeperToken}`)
.send({ name: `Ungültig ${Date.now()}`, shelfLifeMonths })
.expect(400);
}
});
});
+224
View File
@@ -19,6 +19,14 @@ interface FailedDeliveryBody {
[key: string]: unknown;
}
interface DeliveriesBody {
telegram: {
botNameSet: boolean;
sender: { failed24h: number };
};
deliveries: { id: string; [key: string]: unknown }[];
}
interface OverviewBody {
jobs: unknown[];
domainEvents: { failed: number; oldestPendingAgeSeconds: number | null };
@@ -234,4 +242,220 @@ describe('System API (e2e)', () => {
await prisma.webhookSubscription.delete({ where: { id: subscription.id } });
});
describe('notification deliveries (Telegram card)', () => {
const CHAT_ID = 987654321n;
let userId: string;
const createdNotificationIds: string[] = [];
const templateDeliveryIds: string[] = [];
/** A bot reply: addressed by chat id, no user, no notification. */
async function createTemplateDelivery(): Promise<string> {
const delivery = await prisma.notificationDelivery.create({
data: {
channel: 'TELEGRAM',
chatId: CHAT_ID,
templateCode: 'HELP',
status: 'FAILED',
attempts: 8,
lastError: 'HTTP 502',
},
});
templateDeliveryIds.push(delivery.id);
return delivery.id;
}
async function createDelivery(
status: 'FAILED' | 'DELIVERED',
): Promise<string> {
const notification = await prisma.notification.create({
data: {
userId,
priority: 'HINWEIS',
category: 'TASK_DUE',
message: 'private reminder text',
dedupeKey: `system-e2e:${suffix}:${createdNotificationIds.length}:${Date.now()}`,
},
});
createdNotificationIds.push(notification.id);
const delivery = await prisma.notificationDelivery.create({
data: {
channel: 'TELEGRAM',
userId,
notificationId: notification.id,
status,
attempts: status === 'FAILED' ? 8 : 1,
lastStatusCode: status === 'FAILED' ? 502 : null,
lastError: status === 'FAILED' ? 'HTTP 502' : null,
},
});
return delivery.id;
}
beforeAll(async () => {
const admin = await prisma.user.findUniqueOrThrow({
where: { email: `system-admin-${suffix}@example.com` },
select: { id: true },
});
userId = admin.id;
});
afterAll(async () => {
// Only this block's own notifications (deliveries cascade).
await prisma.notification.deleteMany({
where: { id: { in: createdNotificationIds } },
});
await prisma.notificationDelivery.deleteMany({
where: { id: { in: templateDeliveryIds } },
});
});
it('GET /system/notification-deliveries shows FAILED entries and retry moves them back to PENDING', async () => {
const failedId = await createDelivery('FAILED');
const deliveredId = await createDelivery('DELIVERED');
const templateId = await createTemplateDelivery();
const response = await request(server())
.get('/api/v1/system/notification-deliveries?status=FAILED')
.set(auth(adminToken))
.expect(200);
const body = response.body as DeliveriesBody;
const listed = body.deliveries.find((d) => d.id === failedId);
expect(listed).toMatchObject({
status: 'FAILED',
category: 'TASK_DUE',
attempts: 8,
lastStatusCode: 502,
});
expect(body.deliveries.some((d) => d.id === deliveredId)).toBe(false);
expect(body.deliveries.find((d) => d.id === templateId)).toMatchObject({
category: null,
templateCode: 'HELP',
});
expect(typeof body.telegram.botNameSet).toBe('boolean');
expect(body.telegram.sender.failed24h).toBeGreaterThanOrEqual(1);
const raw = JSON.stringify(body);
expect(raw).not.toContain(CHAT_ID.toString());
expect(raw).not.toContain('private reminder text');
expect(listed).not.toHaveProperty('chatId');
await request(server())
.get('/api/v1/system/notification-deliveries?status=NOPE')
.set(auth(adminToken))
.expect(400);
await request(server())
.post(`/api/v1/system/notification-deliveries/${failedId}/retry`)
.set(auth(adminToken))
.expect(204);
const reset = await prisma.notificationDelivery.findUniqueOrThrow({
where: { id: failedId },
});
expect(reset).toMatchObject({
status: 'PENDING',
attempts: 0,
lastError: null,
lastStatusCode: null,
claimedAt: null,
});
expect(reset.availableAt.getTime()).toBeLessThanOrEqual(
Date.now() + 5_000,
);
await expect(
prisma.auditLog.count({
where: { entityType: 'NotificationDelivery', entityId: failedId },
}),
).resolves.toBe(1);
// PENDING is not FAILED any more, a DELIVERED one never was.
await request(server())
.post(`/api/v1/system/notification-deliveries/${failedId}/retry`)
.set(auth(adminToken))
.expect(409);
await request(server())
.post(`/api/v1/system/notification-deliveries/${deliveredId}/retry`)
.set(auth(adminToken))
.expect(409);
await request(server())
.post('/api/v1/system/notification-deliveries/does-not-exist/retry')
.set(auth(adminToken))
.expect(404);
});
it('retry-failed requeues the FAILED entries and reports how many', async () => {
const own = [
await createDelivery('FAILED'),
await createDelivery('FAILED'),
];
// Other files share this DB: remember their FAILED rows to restore them.
const others = await prisma.notificationDelivery.findMany({
where: { status: 'FAILED', id: { notIn: own } },
});
const ownAudits = () =>
prisma.auditLog.count({
where: { entityType: 'NotificationDelivery', entityId: { in: own } },
});
try {
const response = await request(server())
.post('/api/v1/system/notification-deliveries/retry-failed')
.set(auth(adminToken))
.expect(200);
expect(
(response.body as { retried: number }).retried,
).toBeGreaterThanOrEqual(2);
const rows = await prisma.notificationDelivery.findMany({
where: { id: { in: own } },
select: { status: true, attempts: true },
});
expect(rows).toEqual([
{ status: 'PENDING', attempts: 0 },
{ status: 'PENDING', attempts: 0 },
]);
await expect(ownAudits()).resolves.toBe(2);
// A second call leaves the own rows alone: no new audit rows.
await request(server())
.post('/api/v1/system/notification-deliveries/retry-failed')
.set(auth(adminToken))
.expect(200);
await expect(
prisma.notificationDelivery.count({
where: { id: { in: own }, status: 'PENDING' },
}),
).resolves.toBe(2);
await expect(ownAudits()).resolves.toBe(2);
} finally {
for (const row of others) {
await prisma.notificationDelivery.updateMany({
where: { id: row.id },
data: {
status: row.status,
attempts: row.attempts,
availableAt: row.availableAt,
claimedAt: row.claimedAt,
lastStatusCode: row.lastStatusCode,
lastError: row.lastError,
},
});
}
}
});
it('staff without system:manage gets 403', async () => {
await request(server())
.get('/api/v1/system/notification-deliveries')
.set(auth(staffToken))
.expect(403);
await request(server())
.post('/api/v1/system/notification-deliveries/x/retry')
.set(auth(staffToken))
.expect(403);
await request(server())
.post('/api/v1/system/notification-deliveries/retry-failed')
.set(auth(staffToken))
.expect(403);
});
});
});
+527
View File
@@ -1,5 +1,7 @@
import { createHash } from 'node:crypto';
import { INestApplication } from '@nestjs/common';
import * as bcrypt from 'bcryptjs';
import request from 'supertest';
import { App } from 'supertest/types';
import type { NotificationCategory } from '../generated/prisma';
import { berlinDateTime, berlinToday, dayToDate } from '../src/common/dates';
@@ -11,7 +13,11 @@ import {
} from '../src/notifications/delivery-time';
import { NotificationsService } from '../src/notifications/notifications.service';
import { PrismaService } from '../src/prisma/prisma.service';
import { TelegramClient } from '../src/telegram/telegram-client';
import { TelegramLinkService } from '../src/telegram/telegram-link.service';
import { TelegramSender } from '../src/telegram/telegram-sender';
import { TelegramUpdatesService } from '../src/telegram/telegram-updates.service';
import { login } from './utils/api';
import { drainDomainEvents } from './utils/domain-events';
import { createTelegramTestApp, FakeTelegramClient } from './utils/telegram';
@@ -489,4 +495,525 @@ describe('Telegram delivery (e2e)', () => {
).toBeGreaterThanOrEqual(1_000);
});
});
describe('connect, disconnect and receive', () => {
const PASSWORD = 'SuperSecret123!';
const API = '/api/v1';
const BOT = 'apicore_e2e_bot';
const OFFSET_BASE = 9_000;
const originalUsername = process.env.TELEGRAM_BOT_USERNAME;
let updates: TelegramUpdatesService;
let nextUpdateId = OFFSET_BASE;
let savedPollState: {
updateOffset: bigint;
leaseOwner: string | null;
leaseUntil: Date | null;
};
const emailOf = (label: string) =>
`telegram-e2e-${label}-${suffix}@example.com`;
async function tokenOf(label: string): Promise<string> {
return (await login(app, emailOf(label), PASSWORD)).accessToken;
}
function requestLinkToken(
accessToken: string,
body: object = { password: PASSWORD },
) {
return request(app.getHttpServer())
.post(`${API}/profile/telegram/link-token`)
.set('Authorization', `Bearer ${accessToken}`)
.send(body);
}
/**
* A link token for the user, made by the service (the route's budget of
* 10 per minute is kept for the tests of the route itself).
*/
async function newLinkToken(userId: string): Promise<string> {
const { deepLink } = await app
.get(TelegramLinkService)
.createLinkToken(userId);
return deepLink.slice(`https://t.me/${BOT}?start=`.length);
}
function say(
chatId: bigint,
text: string,
options: { chatType?: string; firstName?: string } = {},
): void {
telegram.queueUpdate({
updateId: BigInt(nextUpdateId++),
chatId,
chatType: options.chatType ?? 'private',
text,
firstName: options.firstName ?? 'Anna',
});
}
/** Fresh poll state: the offset where the next update id starts, no lease. */
async function resetPollState(): Promise<void> {
await prisma.telegramPollState.update({
where: { id: 'singleton' },
data: {
updateOffset: BigInt(nextUpdateId),
leaseOwner: null,
leaseUntil: null,
},
});
}
const repliesTo = async (chatId: bigint) =>
(
await prisma.notificationDelivery.findMany({
where: { chatId },
orderBy: { createdAt: 'asc' },
})
).map((row) => row.templateCode);
const poll = () => updates.pollOnce(NEVER_ABORTED);
/** A user without Telegram link and with Telegram switched off. */
async function createUnlinkedUser(label: string) {
const user = await createUser(label, [], {}, false);
await prisma.notificationPreference.update({
where: { userId: user.id },
data: { telegramEnabled: false },
});
return user;
}
beforeAll(async () => {
process.env.TELEGRAM_BOT_USERNAME = BOT;
updates = app.get(TelegramUpdatesService);
savedPollState = await prisma.telegramPollState.findUniqueOrThrow({
where: { id: 'singleton' },
select: { updateOffset: true, leaseOwner: true, leaseUntil: true },
});
});
afterAll(async () => {
if (originalUsername === undefined) {
delete process.env.TELEGRAM_BOT_USERNAME;
} else {
process.env.TELEGRAM_BOT_USERNAME = originalUsername;
}
await prisma.telegramPollState.update({
where: { id: 'singleton' },
data: { ...savedPollState, lastError: null },
});
await prisma.notificationDelivery.deleteMany({
where: { chatId: { gte: chatBase, lte: chatBase + 10_000n } },
});
// Logins of this block leave refresh tokens behind.
await prisma.refreshToken.deleteMany({
where: { userId: { in: userIds } },
});
});
beforeEach(async () => {
await resetPollState();
telegram.sent.length = 0;
});
it('link token -> /start links the chat once; a second /start with the same token answers LINK_INVALID', async () => {
const user = await createUnlinkedUser('connect');
const accessToken = await tokenOf('connect');
const response = await requestLinkToken(accessToken).expect(201);
// The answer holds a secret: never cached.
expect(response.headers['cache-control']).toBe('no-store');
const { deepLink, expiresAt } = response.body as {
deepLink: string;
expiresAt: string;
};
expect(deepLink).toMatch(
new RegExp(`^https://t\\.me/${BOT}\\?start=[A-Za-z0-9_-]{43}$`),
);
const token = deepLink.slice(`https://t.me/${BOT}?start=`.length);
// Ten minutes from the database clock; only the hash is stored.
const ttlMs = new Date(expiresAt).getTime() - Date.now();
expect(ttlMs).toBeGreaterThan(9 * 60_000);
expect(ttlMs).toBeLessThanOrEqual(10 * 60_000 + CLOCK_TOLERANCE_MS);
const stored = await prisma.telegramLinkToken.findMany({
where: { userId: user.id },
});
expect(stored).toHaveLength(1);
expect(stored[0].tokenHash).toBe(
createHash('sha256').update(token).digest('hex'),
);
expect(stored[0].tokenHash).not.toBe(token);
say(user.chatId, `/start ${token}`, { firstName: 'Anna' });
await expect(poll()).resolves.toBe('polled');
const link = await prisma.telegramLink.findUniqueOrThrow({
where: { userId: user.id },
});
expect(link.chatId).toBe(user.chatId);
expect(link.telegramFirstName).toBe('Anna');
const preference = await prisma.notificationPreference.findUniqueOrThrow({
where: { userId: user.id },
});
expect(preference.telegramEnabled).toBe(true);
const notice = await prisma.notification.findFirstOrThrow({
where: { userId: user.id, category: 'SYSTEM' },
});
expect(notice.message).toMatch(
/^Telegram verbunden mit „Anna“ am \d{2}\.\d{2}\.\d{4}\. Warst du das nicht\? In den Einstellungen trennen\.$/,
);
expect(notice.link).toBe('/profile');
expect(await repliesTo(user.chatId)).toEqual(['LINKED']);
const state = await prisma.telegramPollState.findUniqueOrThrow({
where: { id: 'singleton' },
});
expect(state.updateOffset).toBe(BigInt(nextUpdateId));
expect(state.lastPollAt).not.toBeNull();
// The same token again - from the same or another chat - is spent.
const stranger = chatBase + 9_001n;
say(user.chatId, `/start ${token}`);
say(stranger, `/start ${token}`);
await poll();
expect(await repliesTo(user.chatId)).toEqual(['LINKED', 'LINK_INVALID']);
expect(await repliesTo(stranger)).toEqual(['LINK_INVALID']);
expect(
await prisma.telegramLink.count({ where: { chatId: stranger } }),
).toBe(0);
});
it('a new link token makes the older open one invalid, and a chat that moves unlinks the previous user', async () => {
const first = await createUnlinkedUser('move-a');
const second = await createUnlinkedUser('move-b');
const old = await newLinkToken(first.id);
const fresh = await newLinkToken(first.id);
const other = await newLinkToken(second.id);
say(first.chatId, `/start ${old}`);
say(first.chatId, `/start ${fresh}`);
await poll();
expect(await repliesTo(first.chatId)).toEqual(['LINK_INVALID', 'LINKED']);
// User B connects from the chat of user A.
say(first.chatId, `/start ${other}`);
await poll();
const links = await prisma.telegramLink.findMany({
where: { userId: { in: [first.id, second.id] } },
});
expect(links).toHaveLength(1);
expect(links[0].userId).toBe(second.id);
expect(links[0].chatId).toBe(first.chatId);
const previous = await prisma.notificationPreference.findUniqueOrThrow({
where: { userId: first.id },
});
expect(previous.telegramEnabled).toBe(false);
const notice = await prisma.notification.findFirstOrThrow({
where: { userId: first.id, message: { contains: 'getrennt' } },
});
expect(notice.message).toBe(
'Deine Telegram-Verbindung wurde getrennt, weil der Chat mit einem anderen Konto verbunden wurde.',
);
});
it('link-token answers 409 when the bot username is not set', async () => {
await createUnlinkedUser('nobot');
const accessToken = await tokenOf('nobot');
process.env.TELEGRAM_BOT_USERNAME = '';
try {
const response = await requestLinkToken(accessToken).expect(409);
expect((response.body as { message: string }).message).toBe(
'Telegram ist nicht eingerichtet.',
);
} finally {
process.env.TELEGRAM_BOT_USERNAME = BOT;
}
});
it('only the lease holder polls; a second worker waits', async () => {
const links = app.get(TelegramLinkService);
const workerA = new TelegramUpdatesService(
prisma,
telegram as unknown as TelegramClient,
links,
);
const workerB = new TelegramUpdatesService(
prisma,
telegram as unknown as TelegramClient,
links,
);
telegram.polledOffsets.length = 0;
await expect(workerA.pollOnce(NEVER_ABORTED)).resolves.toBe('polled');
await expect(workerB.pollOnce(NEVER_ABORTED)).resolves.toBe('no-lease');
expect(telegram.polledOffsets).toHaveLength(1);
// Every round extends the holder's lease.
await expect(workerA.pollOnce(NEVER_ABORTED)).resolves.toBe('polled');
await expect(workerB.pollOnce(NEVER_ABORTED)).resolves.toBe('no-lease');
// An expired lease (database clock) passes to the next one.
await prisma.$executeRaw`UPDATE "TelegramPollState" SET "leaseUntil" = now() - interval '1 second' WHERE "id" = 'singleton'`;
await expect(workerB.pollOnce(NEVER_ABORTED)).resolves.toBe('polled');
await expect(workerA.pollOnce(NEVER_ABORTED)).resolves.toBe('no-lease');
});
it('a lease lost during the cycle rolls the update back and the next holder handles it once', async () => {
const user = await createUnlinkedUser('lostlease');
const token = await newLinkToken(user.id);
say(user.chatId, `/start ${token}`);
// Another receiver takes over right after the updates were fetched.
telegram.onGetUpdates = async () => {
await prisma.telegramPollState.update({
where: { id: 'singleton' },
data: { leaseOwner: 'someone-else' },
});
};
try {
await expect(poll()).resolves.toBe('no-lease');
} finally {
telegram.onGetUpdates = null;
}
expect(
await prisma.telegramLink.count({ where: { userId: user.id } }),
).toBe(0);
expect(await repliesTo(user.chatId)).toEqual([]);
const open = await prisma.telegramLinkToken.findFirstOrThrow({
where: { userId: user.id },
});
expect(open.consumedAt).toBeNull();
const state = await prisma.telegramPollState.findUniqueOrThrow({
where: { id: 'singleton' },
});
expect(state.updateOffset).toBe(BigInt(nextUpdateId - 1));
// The other lease ran out: the update is handled, exactly once.
await prisma.$executeRaw`UPDATE "TelegramPollState" SET "leaseUntil" = now() - interval '1 second' WHERE "id" = 'singleton'`;
await expect(poll()).resolves.toBe('polled');
await expect(poll()).resolves.toBe('polled');
expect(await repliesTo(user.chatId)).toEqual(['LINKED']);
});
it('/stop in the chat unlinks, and groups are ignored', async () => {
const user = await createUser('stopchat', []);
const group = chatBase + 9_100n;
say(group, '/stop', { chatType: 'group' });
say(group, '/start abc', { chatType: 'supergroup' });
say(user.chatId, '/stop');
await poll();
expect(
await prisma.telegramLink.count({ where: { userId: user.id } }),
).toBe(0);
const preference = await prisma.notificationPreference.findUniqueOrThrow({
where: { userId: user.id },
});
expect(preference.telegramEnabled).toBe(false);
expect(await repliesTo(user.chatId)).toEqual(['STOPPED']);
expect(await repliesTo(group)).toEqual([]);
});
it('unlink sends STOPPED and switches Telegram off', async () => {
const user = await createUser('unlink', []);
const accessToken = await tokenOf('unlink');
const view = await request(app.getHttpServer())
.get(`${API}/profile/notification-settings`)
.set('Authorization', `Bearer ${accessToken}`)
.expect(200);
expect((view.body as { telegram: object }).telegram).toMatchObject({
configured: true,
botUsername: BOT,
linked: true,
firstName: 'unlink',
enabled: true,
});
expect(JSON.stringify(view.body)).not.toContain(String(user.chatId));
// A message still waiting for the old chat must never arrive later.
const waiting = await queue(user.id, { message: 'wartet noch' });
await request(app.getHttpServer())
.post(`${API}/profile/telegram/unlink`)
.set('Authorization', `Bearer ${accessToken}`)
.expect(204);
expect(
await prisma.telegramLink.count({ where: { userId: user.id } }),
).toBe(0);
const dropped = await prisma.notificationDelivery.findUniqueOrThrow({
where: { id: waiting.deliveryId },
});
expect(dropped.status).toBe('SKIPPED');
expect(dropped.lastError).toBe('unlinked');
await drainFor(user.id);
expect(telegram.sentTo(user.chatId)).toHaveLength(0);
const preference = await prisma.notificationPreference.findUniqueOrThrow({
where: { userId: user.id },
});
expect(preference.telegramEnabled).toBe(false);
expect(await repliesTo(user.chatId)).toEqual(['STOPPED']);
// Unlinking again changes nothing.
await request(app.getHttpServer())
.post(`${API}/profile/telegram/unlink`)
.set('Authorization', `Bearer ${accessToken}`)
.expect(204);
expect(await repliesTo(user.chatId)).toEqual(['STOPPED']);
await request(app.getHttpServer())
.post(`${API}/profile/telegram/unlink`)
.expect(401);
});
it('the same token claimed at once by two different chats links exactly one', async () => {
const user = await createUnlinkedUser('race-chats');
const token = await newLinkToken(user.id);
const links = app.get(TelegramLinkService);
const chatA = chatBase + 9_200n;
const chatB = chatBase + 9_201n;
const results = await Promise.all(
[chatA, chatB].map((chat) =>
prisma.$transaction((tx) =>
links.linkFromStartIn(tx, token, chat, 'Racer'),
),
),
);
expect([...results].sort()).toEqual(['LINKED', 'LINK_INVALID']);
const stored = await prisma.telegramLink.findMany({
where: { chatId: { in: [chatA, chatB] } },
});
expect(stored).toHaveLength(1);
expect(stored[0].userId).toBe(user.id);
});
it('two /start with the same token and chat in separate transactions: exactly one LINKED', async () => {
const user = await createUnlinkedUser('race-same');
const token = await newLinkToken(user.id);
const links = app.get(TelegramLinkService);
const results = await Promise.all(
[1, 2].map(() =>
prisma.$transaction((tx) =>
links.linkFromStartIn(tx, token, user.chatId, 'Racer'),
),
),
);
expect([...results].sort()).toEqual(['LINKED', 'LINK_INVALID']);
expect(
await prisma.telegramLink.count({ where: { userId: user.id } }),
).toBe(1);
expect(
await prisma.notification.count({
where: {
userId: user.id,
message: { startsWith: 'Telegram verbunden' },
},
}),
).toBe(1);
});
it('an expired token answers LINK_INVALID and links nothing', async () => {
const user = await createUnlinkedUser('expired');
const token = await newLinkToken(user.id);
await prisma.$executeRaw`UPDATE "TelegramLinkToken" SET "expiresAt" = now() - interval '1 minute' WHERE "userId" = ${user.id}`;
say(user.chatId, `/start ${token}`);
await poll();
expect(await repliesTo(user.chatId)).toEqual(['LINK_INVALID']);
expect(
await prisma.telegramLink.count({ where: { userId: user.id } }),
).toBe(0);
const row = await prisma.telegramLinkToken.findFirstOrThrow({
where: { userId: user.id },
});
expect(row.consumedAt).toBeNull();
});
it('a poison update is skipped after the attempt limit and the queue moves on', async () => {
const user = await createUser('poison', []);
const other = chatBase + 9_300n;
const links = app.get(TelegramLinkService);
const stop = jest
.spyOn(links, 'stopIn')
.mockRejectedValue(new Error('boom'));
updates.retryLimits = { attempts: 2, windowMs: 600_000 };
const first = nextUpdateId;
say(user.chatId, '/stop');
say(other, 'hallo');
try {
await expect(poll()).resolves.toBe('error');
const failing = await prisma.telegramPollState.findUniqueOrThrow({
where: { id: 'singleton' },
});
expect(failing.updateOffset).toBe(BigInt(first));
expect(failing.lastError).toBe('update not handled: Error');
await expect(poll()).resolves.toBe('polled');
} finally {
stop.mockRestore();
updates.retryLimits = { attempts: 5, windowMs: 600_000 };
}
const state = await prisma.telegramPollState.findUniqueOrThrow({
where: { id: 'singleton' },
});
expect(state.updateOffset).toBe(BigInt(nextUpdateId));
expect(state.lastError).toBe('Telegram-Nachricht übersprungen');
// The skipped /stop did nothing; the update behind it was answered.
expect(
await prisma.telegramLink.count({ where: { userId: user.id } }),
).toBe(1);
expect(await repliesTo(other)).toEqual(['HELP']);
// A clean batch afterwards clears the notice.
await poll();
const cleared = await prisma.telegramPollState.findUniqueOrThrow({
where: { id: 'singleton' },
});
expect(cleared.lastError).toBeNull();
});
// Last on purpose: it uses up the 10-per-minute budget of the route.
it('link-token requires step-up and is throttled', async () => {
await createUnlinkedUser('stepup');
const accessToken = await tokenOf('stepup');
await request(app.getHttpServer())
.post(`${API}/profile/telegram/link-token`)
.send({ password: PASSWORD })
.expect(401);
// A missing password is a malformed request.
await requestLinkToken(accessToken, {}).expect(400);
await requestLinkToken(accessToken, {
password: 'wrong-password',
}).expect(401);
expect(
await prisma.telegramLinkToken.count({
where: { user: { email: emailOf('stepup') } },
}),
).toBe(0);
// 10 per minute: the 429 arrives within 11 attempts, after 401s only.
const statuses: number[] = [];
for (
let attempt = 0;
attempt < 11 && !statuses.includes(429);
attempt++
) {
const { status } = await requestLinkToken(accessToken, {
password: 'wrong-password',
});
statuses.push(status);
}
expect(statuses.at(-1)).toBe(429);
expect(statuses.slice(0, -1).every((status) => status === 401)).toBe(
true,
);
});
});
});
+39 -1
View File
@@ -7,7 +7,11 @@ import { AppModule } from '../../src/app.module';
import { configureApp } from '../../src/app.config';
import { THROTTLER_NAMESPACE } from '../../src/common/throttler/prisma-throttler.storage';
import { TelegramApiError } from '../../src/telegram/telegram-api-error';
import { TelegramClient } from '../../src/telegram/telegram-client';
import {
TelegramClient,
type TelegramUpdate,
type TelegramUpdateBatch,
} from '../../src/telegram/telegram-client';
import { TelegramModule } from '../../src/telegram/telegram.module';
export interface SentMessage {
@@ -23,6 +27,12 @@ export interface SentMessage {
export class FakeTelegramClient {
readonly sent: SentMessage[] = [];
private readonly failures: { chatId: bigint | null; error: Error }[] = [];
private readonly updates: TelegramUpdate[] = [];
/** Every offset getUpdates was asked for, in order. */
readonly polledOffsets: bigint[] = [];
/** Runs inside getUpdates, after the updates are chosen (e.g. steal the lease). */
onGetUpdates: (() => Promise<void>) | null = null;
private getUpdatesError: Error | null = null;
isConfigured(): boolean {
return true;
@@ -47,6 +57,34 @@ export class FakeTelegramClient {
return Promise.resolve();
}
/** What the next getUpdates calls return (from `offset` on, like Telegram). */
queueUpdate(update: TelegramUpdate): void {
this.updates.push(update);
}
failNextGetUpdates(error: Error): void {
this.getUpdatesError = error;
}
async getUpdates(offset: bigint): Promise<TelegramUpdateBatch> {
this.polledOffsets.push(offset);
if (this.getUpdatesError !== null) {
const error = this.getUpdatesError;
this.getUpdatesError = null;
throw error;
}
const due = this.updates.filter((update) => update.updateId >= offset);
await this.onGetUpdates?.();
const highestUpdateId = due.reduce<bigint | null>(
(highest, update) =>
highest === null || update.updateId > highest
? update.updateId
: highest,
null,
);
return { updates: due, highestUpdateId };
}
sentTo(chatId: bigint): SentMessage[] {
return this.sent.filter((message) => message.chatId === chatId);
}
@@ -308,6 +308,7 @@ describe('Withdrawal chain (e2e)', () => {
batchNumber: `WD-HC-${suffix}`,
extractionId: extraction.id,
amount: 20,
waterContent: 17,
},
});
honeyBatchIds.push(honeyBatch.id);