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:
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;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user