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
@@ -131,5 +131,8 @@ ENABLE_SWAGGER=false
|
||||
# einbinden. Ein fehlerhaftes Token stoppt nichts: Telegram bleibt aus, der
|
||||
# Worker loggt einen Fehler (nie den Wert). Bei BotFather Gruppen ausschalten
|
||||
# (/setjoingroups) und keinen Webhook setzen.
|
||||
# Je Umgebung ein eigener Bot. Im Cluster steht der Name in der ConfigMap
|
||||
# (TELEGRAM_BOT_USERNAME, leer = Telegram in der App nicht eingerichtet); den
|
||||
# Zustand zeigt Verwaltung > System (Telegram-Karte, nur system:manage).
|
||||
# TELEGRAM_BOT_USERNAME=
|
||||
# TELEGRAM_BOT_TOKEN=
|
||||
@@ -0,0 +1,87 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import {
|
||||
bestBeforeFromShelfLife,
|
||||
dayOfDate,
|
||||
jarSizeWarnings,
|
||||
resolveJarAndBestBefore,
|
||||
} from './bottling-defaults';
|
||||
|
||||
const article = { id: 'p1', packagingSize: 500, shelfLifeMonths: 24 };
|
||||
|
||||
describe('bottling defaults', () => {
|
||||
it('a timestamp counts as the Berlin day, a plain day stays', () => {
|
||||
expect(dayOfDate('2026-07-31')).toBe('2026-07-31');
|
||||
expect(dayOfDate('2026-07-31T22:30:00.000Z')).toBe('2026-08-01');
|
||||
});
|
||||
|
||||
it('best before is the day plus the shelf life, cut at the month end', () => {
|
||||
expect(bestBeforeFromShelfLife('2027-01-31', 1)).toBe('2027-02-28');
|
||||
expect(bestBeforeFromShelfLife('2026-05-10', 24)).toBe('2028-05-10');
|
||||
expect(bestBeforeFromShelfLife('2026-05-10', null)).toBeNull();
|
||||
});
|
||||
|
||||
it('takes both values from the article', () => {
|
||||
expect(resolveJarAndBestBefore({ date: '2026-05-10' }, article)).toEqual({
|
||||
jarSize: 500,
|
||||
bestBeforeDate: '2028-05-10',
|
||||
warnings: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('sent values win', () => {
|
||||
const result = resolveJarAndBestBefore(
|
||||
{ date: '2026-05-10', jarSize: 250, bestBeforeDate: '2027-01-01' },
|
||||
article,
|
||||
);
|
||||
expect(result.jarSize).toBe(250);
|
||||
expect(result.bestBeforeDate).toBe('2027-01-01');
|
||||
expect(result.warnings.map((w) => w.code)).toEqual(['JAR_SIZE_DIFFERS']);
|
||||
});
|
||||
|
||||
it('the same jar size gives no hint, an article without a size neither', () => {
|
||||
expect(jarSizeWarnings(500, article)).toEqual([]);
|
||||
expect(jarSizeWarnings(250, { ...article, packagingSize: null })).toEqual(
|
||||
[],
|
||||
);
|
||||
expect(jarSizeWarnings(250, null)).toEqual([]);
|
||||
});
|
||||
|
||||
it('names what is missing', () => {
|
||||
expect(() => resolveJarAndBestBefore({ date: '2026-05-10' }, null)).toThrow(
|
||||
new BadRequestException(
|
||||
'Glasgröße und MHD fehlen. Bitte einen Artikel wählen oder beides eintragen.',
|
||||
),
|
||||
);
|
||||
expect(() =>
|
||||
resolveJarAndBestBefore(
|
||||
{ date: '2026-05-10' },
|
||||
{ ...article, shelfLifeMonths: null },
|
||||
),
|
||||
).toThrow('Ohne Haltbarkeit am Artikel bitte ein MHD eintragen.');
|
||||
expect(() =>
|
||||
resolveJarAndBestBefore(
|
||||
{ date: '2026-05-10' },
|
||||
{ ...article, packagingSize: null },
|
||||
),
|
||||
).toThrow('Ohne Packungsgröße am Artikel');
|
||||
});
|
||||
});
|
||||
|
||||
describe('impossible days', () => {
|
||||
it('are a 400, never a 500', () => {
|
||||
expect(() => dayOfDate('2026-02-30')).toThrow(BadRequestException);
|
||||
expect(() => dayOfDate('not a date')).toThrow(BadRequestException);
|
||||
expect(() =>
|
||||
resolveJarAndBestBefore(
|
||||
{ date: '2026-02-30', jarSize: 500, bestBeforeDate: '2027-01-01' },
|
||||
null,
|
||||
),
|
||||
).toThrow(BadRequestException);
|
||||
expect(() =>
|
||||
resolveJarAndBestBefore(
|
||||
{ date: '2026-02-01', jarSize: 500, bestBeforeDate: '2027-02-30' },
|
||||
null,
|
||||
),
|
||||
).toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { addMonthsClamped, berlinDayOf, dayToDate } from '../common/dates';
|
||||
import {
|
||||
WARNING_SEVERITY,
|
||||
type PlausibilityWarning,
|
||||
} from '../common/warnings/plausibility-warning';
|
||||
import { formatNumberDe } from '../suggestions/texts/format-number';
|
||||
|
||||
/** What a bottling takes over from its article (spec 7.8). */
|
||||
export interface ArticleDefaults {
|
||||
id: string;
|
||||
packagingSize: number | null;
|
||||
shelfLifeMonths: number | null;
|
||||
}
|
||||
|
||||
const DAY = /^\d{4}-\d{2}-\d{2}$/;
|
||||
const INVALID_DATE =
|
||||
'Das Datum ist ungültig. Bitte ein Datum wie 2026-07-31 angeben.';
|
||||
|
||||
/** The calendar day of a date or timestamp; a timestamp counts in Berlin. */
|
||||
export function dayOfDate(date: string): string {
|
||||
try {
|
||||
const day = DAY.test(date) ? date : berlinDayOf(new Date(date));
|
||||
dayToDate(day);
|
||||
return day;
|
||||
} catch {
|
||||
throw new BadRequestException(INVALID_DATE);
|
||||
}
|
||||
}
|
||||
|
||||
/** Bottling day plus the article's shelf life; null without a shelf life. */
|
||||
export function bestBeforeFromShelfLife(
|
||||
date: string,
|
||||
shelfLifeMonths: number | null,
|
||||
): string | null {
|
||||
return shelfLifeMonths === null
|
||||
? null
|
||||
: addMonthsClamped(dayOfDate(date), shelfLifeMonths);
|
||||
}
|
||||
|
||||
export interface ResolvedJarAndBestBefore {
|
||||
jarSize: number;
|
||||
/** YYYY-MM-DD, or the text the client sent. */
|
||||
bestBeforeDate: string;
|
||||
warnings: PlausibilityWarning[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Jar size and best-before date of a bottling: what was sent wins, else the
|
||||
* article's packaging size and date + shelf life. There is no default shelf
|
||||
* life: without one the beekeeper enters the date (spec 7.8).
|
||||
*/
|
||||
export function resolveJarAndBestBefore(
|
||||
sent: { jarSize?: number; bestBeforeDate?: string; date: string },
|
||||
article: ArticleDefaults | null,
|
||||
): ResolvedJarAndBestBefore {
|
||||
// An impossible day is the client's error (400), never a 500.
|
||||
dayOfDate(sent.date);
|
||||
if (sent.bestBeforeDate !== undefined) {
|
||||
dayOfDate(sent.bestBeforeDate);
|
||||
}
|
||||
const jarSize = sent.jarSize ?? article?.packagingSize ?? null;
|
||||
const bestBeforeDate =
|
||||
sent.bestBeforeDate ??
|
||||
bestBeforeFromShelfLife(sent.date, article?.shelfLifeMonths ?? null);
|
||||
if (jarSize === null || bestBeforeDate === null) {
|
||||
throw new BadRequestException(
|
||||
missingMessage(jarSize, bestBeforeDate, article),
|
||||
);
|
||||
}
|
||||
const warnings =
|
||||
sent.jarSize === undefined ? [] : jarSizeWarnings(sent.jarSize, article);
|
||||
return { jarSize, bestBeforeDate, warnings };
|
||||
}
|
||||
|
||||
function missingMessage(
|
||||
jarSize: number | null,
|
||||
bestBeforeDate: string | null,
|
||||
article: ArticleDefaults | null,
|
||||
): string {
|
||||
if (article === null || (jarSize === null && bestBeforeDate === null)) {
|
||||
return 'Glasgröße und MHD fehlen. Bitte einen Artikel wählen oder beides eintragen.';
|
||||
}
|
||||
return jarSize === null
|
||||
? 'Ohne Packungsgröße am Artikel bitte eine Glasgröße eintragen.'
|
||||
: 'Ohne Haltbarkeit am Artikel bitte ein MHD eintragen.';
|
||||
}
|
||||
|
||||
/** The hint for a jar size that differs from the article's packaging size. */
|
||||
export function jarSizeWarnings(
|
||||
jarSize: number,
|
||||
article: ArticleDefaults | null,
|
||||
): PlausibilityWarning[] {
|
||||
if (
|
||||
article === null ||
|
||||
article.packagingSize === null ||
|
||||
article.packagingSize === jarSize
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
code: 'JAR_SIZE_DIFFERS',
|
||||
severity: WARNING_SEVERITY.JAR_SIZE_DIFFERS,
|
||||
message: `Die Glasgröße (${formatNumberDe(jarSize, 0)} g) weicht vom Artikel ab (${formatNumberDe(article.packagingSize, 0)} g).`,
|
||||
refs: [{ type: 'Product', id: article.id }],
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import type { PrismaService } from '../prisma/prisma.service';
|
||||
import { BottlingPrefillService } from './bottling-prefill.service';
|
||||
|
||||
describe('BottlingPrefillService', () => {
|
||||
let service: BottlingPrefillService;
|
||||
let prisma: {
|
||||
honeyBatch: { findUnique: jest.Mock };
|
||||
honeyMeasurement: { findMany: jest.Mock };
|
||||
automationRule: { findUnique: jest.Mock };
|
||||
product: { findUnique: jest.Mock };
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
prisma = {
|
||||
honeyBatch: {
|
||||
findUnique: jest.fn().mockResolvedValue({
|
||||
id: 'h1',
|
||||
honeyType: null,
|
||||
waterContent: 19,
|
||||
}),
|
||||
},
|
||||
honeyMeasurement: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
automationRule: { findUnique: jest.fn().mockResolvedValue(null) },
|
||||
product: { findUnique: jest.fn() },
|
||||
};
|
||||
service = new BottlingPrefillService(prisma as unknown as PrismaService);
|
||||
});
|
||||
|
||||
it('offers jar size, best before and water content with the warning', async () => {
|
||||
prisma.product.findUnique.mockResolvedValue({
|
||||
kind: 'BOTTLED_HONEY',
|
||||
packagingSize: 500,
|
||||
shelfLifeMonths: 24,
|
||||
});
|
||||
|
||||
const result = await service.prefill({
|
||||
honeyBatchId: 'h1',
|
||||
productId: 'p1',
|
||||
date: '2026-05-10',
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
jarSize: 500,
|
||||
jarSizeSource: 'PRODUCT',
|
||||
bestBeforeDate: '2028-05-10',
|
||||
shelfLifeMonths: 24,
|
||||
waterContent: 19,
|
||||
});
|
||||
expect(result.warnings.map((w) => w.code)).toEqual(['WATER_CONTENT_HIGH']);
|
||||
});
|
||||
|
||||
it('without an article only the water content is offered', async () => {
|
||||
const result = await service.prefill({ honeyBatchId: 'h1' });
|
||||
|
||||
expect(result).toMatchObject({
|
||||
jarSize: null,
|
||||
jarSizeSource: null,
|
||||
bestBeforeDate: null,
|
||||
shelfLifeMonths: null,
|
||||
waterContent: 19,
|
||||
});
|
||||
});
|
||||
|
||||
it('an article without a shelf life gives no best before', async () => {
|
||||
prisma.product.findUnique.mockResolvedValue({
|
||||
kind: 'BOTTLED_HONEY',
|
||||
packagingSize: 250,
|
||||
shelfLifeMonths: null,
|
||||
});
|
||||
|
||||
const result = await service.prefill({
|
||||
honeyBatchId: 'h1',
|
||||
productId: 'p1',
|
||||
});
|
||||
|
||||
expect(result.jarSize).toBe(250);
|
||||
expect(result.bestBeforeDate).toBeNull();
|
||||
});
|
||||
|
||||
it('reports an unknown honey batch or article', async () => {
|
||||
prisma.honeyBatch.findUnique.mockResolvedValue(null);
|
||||
await expect(
|
||||
service.prefill({ honeyBatchId: 'missing' }),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
|
||||
prisma.honeyBatch.findUnique.mockResolvedValue({ id: 'h1' });
|
||||
prisma.product.findUnique.mockResolvedValue(null);
|
||||
await expect(
|
||||
service.prefill({ honeyBatchId: 'h1', productId: 'missing' }),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('refuses an article that is not bottled honey', async () => {
|
||||
prisma.product.findUnique.mockResolvedValue({
|
||||
kind: 'MANUFACTURED',
|
||||
packagingSize: null,
|
||||
shelfLifeMonths: null,
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.prefill({ honeyBatchId: 'h1', productId: 'p2' }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { berlinToday } from '../common/dates';
|
||||
import type { PlausibilityWarning } from '../common/warnings/plausibility-warning';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { bestBeforeFromShelfLife } from './bottling-defaults';
|
||||
import type { BottlingPrefillQuery } from './dto/bottling-prefill.query';
|
||||
import { waterContentWarningsOfBatch } from '../common/quality/water-content';
|
||||
|
||||
const SOURCE_NOTE =
|
||||
'Stammdaten des Artikels, keine Beobachtung. Das Formular nennt die Herkunft.';
|
||||
|
||||
/** What the bottling form may fill in for a honey batch and an article. */
|
||||
export class BottlingPrefill {
|
||||
@ApiProperty({ type: Number, nullable: true, description: SOURCE_NOTE })
|
||||
jarSize!: number | null;
|
||||
|
||||
@ApiProperty({ enum: ['PRODUCT'], nullable: true })
|
||||
jarSizeSource!: 'PRODUCT' | null;
|
||||
|
||||
@ApiProperty({
|
||||
type: String,
|
||||
nullable: true,
|
||||
description: 'Abfülldatum plus Haltbarkeit des Artikels (YYYY-MM-DD)',
|
||||
})
|
||||
bestBeforeDate!: string | null;
|
||||
|
||||
@ApiProperty({ type: Number, nullable: true })
|
||||
shelfLifeMonths!: number | null;
|
||||
|
||||
@ApiProperty({
|
||||
type: Number,
|
||||
nullable: true,
|
||||
description: 'Jüngste Messung der Charge, sonst ihr eigener Wert',
|
||||
})
|
||||
waterContent!: number | null;
|
||||
|
||||
@ApiProperty({ type: 'array', items: { type: 'object' } })
|
||||
warnings!: PlausibilityWarning[];
|
||||
}
|
||||
|
||||
export type BottlingPrefillInput = Pick<
|
||||
BottlingPrefillQuery,
|
||||
'honeyBatchId' | 'productId' | 'date'
|
||||
>;
|
||||
|
||||
/** Read only: nothing is written, nothing is locked. */
|
||||
@Injectable()
|
||||
export class BottlingPrefillService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async prefill(query: BottlingPrefillInput): Promise<BottlingPrefill> {
|
||||
const water = await waterContentWarningsOfBatch(
|
||||
this.prisma,
|
||||
query.honeyBatchId,
|
||||
);
|
||||
if (water === null) {
|
||||
throw new NotFoundException('Honey batch not found');
|
||||
}
|
||||
const product =
|
||||
query.productId === undefined
|
||||
? null
|
||||
: await this.prisma.product.findUnique({
|
||||
where: { id: query.productId },
|
||||
select: {
|
||||
kind: true,
|
||||
packagingSize: true,
|
||||
shelfLifeMonths: true,
|
||||
},
|
||||
});
|
||||
if (query.productId !== undefined) {
|
||||
if (!product) {
|
||||
throw new NotFoundException('Product not found');
|
||||
}
|
||||
if (product.kind !== 'BOTTLED_HONEY') {
|
||||
throw new BadRequestException(
|
||||
'Abfüllungen nur für Honig-Artikel - hergestellte Waren bekommen Herstellungschargen',
|
||||
);
|
||||
}
|
||||
}
|
||||
const shelfLifeMonths = product?.shelfLifeMonths ?? null;
|
||||
const jarSize = product?.packagingSize ?? null;
|
||||
return {
|
||||
jarSize,
|
||||
jarSizeSource: jarSize === null ? null : 'PRODUCT',
|
||||
bestBeforeDate: bestBeforeFromShelfLife(
|
||||
query.date ?? berlinToday(),
|
||||
shelfLifeMonths,
|
||||
),
|
||||
shelfLifeMonths,
|
||||
waterContent: water.waterContent,
|
||||
warnings: water.warnings,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,8 @@ import type { AuthenticatedUser } from '../auth/types/authenticated-user';
|
||||
import { CreateBottlingBatchDto } from './dto/create-bottling-batch.dto';
|
||||
import { UpdateBottlingBatchDto } from './dto/update-bottling-batch.dto';
|
||||
import { BottlingService } from './bottling.service';
|
||||
import { BottlingPrefillService } from './bottling-prefill.service';
|
||||
import { BottlingPrefillQuery } from './dto/bottling-prefill.query';
|
||||
import { Idempotent } from '../common/idempotency/idempotent.decorator';
|
||||
import { StockService } from '../stock/stock.service';
|
||||
import { WithdrawStockDto } from '../stock/dto/withdraw-stock.dto';
|
||||
@@ -26,6 +28,7 @@ import { CorrectStockDto } from '../stock/dto/correct-stock.dto';
|
||||
export class BottlingController {
|
||||
constructor(
|
||||
private readonly bottlingService: BottlingService,
|
||||
private readonly prefillService: BottlingPrefillService,
|
||||
private readonly stock: StockService,
|
||||
) {}
|
||||
|
||||
@@ -35,6 +38,15 @@ export class BottlingController {
|
||||
return this.bottlingService.findAll({ honeyBatchId });
|
||||
}
|
||||
|
||||
// Before ':id', so 'prefill' is not read as an id. Needs the create
|
||||
// permission, like the other prefill routes: it offers what the create
|
||||
// flow needs.
|
||||
@RequirePermission('bottling:create')
|
||||
@Get('prefill')
|
||||
prefill(@Query() query: BottlingPrefillQuery) {
|
||||
return this.prefillService.prefill(query);
|
||||
}
|
||||
|
||||
@RequirePermission('bottling:read')
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { StockModule } from '../stock/stock.module';
|
||||
import { WithdrawalModule } from '../withdrawal/withdrawal.module';
|
||||
import { BottlingController } from './bottling.controller';
|
||||
import { BottlingService } from './bottling.service';
|
||||
import { BottlingPrefillService } from './bottling-prefill.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -14,6 +15,6 @@ import { BottlingService } from './bottling.service';
|
||||
WithdrawalModule,
|
||||
],
|
||||
controllers: [BottlingController],
|
||||
providers: [BottlingService],
|
||||
providers: [BottlingService, BottlingPrefillService],
|
||||
})
|
||||
export class BottlingModule {}
|
||||
@@ -70,6 +70,8 @@ describe('BottlingService', () => {
|
||||
update: jest.Mock;
|
||||
delete: jest.Mock;
|
||||
};
|
||||
honeyMeasurement: { findMany: jest.Mock };
|
||||
automationRule: { findUnique: jest.Mock };
|
||||
honeyBatch: { findUnique: jest.Mock; update: jest.Mock };
|
||||
batchMovement: { count: jest.Mock };
|
||||
};
|
||||
@@ -94,6 +96,9 @@ describe('BottlingService', () => {
|
||||
return Promise.resolve([{ id: 'row' }]);
|
||||
}),
|
||||
product: { findUnique: jest.fn() },
|
||||
// No measurements and no rule row: the built-in limits apply.
|
||||
honeyMeasurement: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
automationRule: { findUnique: jest.fn().mockResolvedValue(null) },
|
||||
// By default a bottling has drawn from the ledger (own BOTTLING booking).
|
||||
batchMovement: { count: jest.fn().mockResolvedValue(1) },
|
||||
bottlingBatch: {
|
||||
@@ -1006,4 +1011,311 @@ describe('BottlingService', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('quality before bottling (spec 7.8)', () => {
|
||||
const base = {
|
||||
honeyBatchId: 'h1',
|
||||
date: '2026-01-31',
|
||||
jarCount: 10,
|
||||
lotNumber: 'L-1',
|
||||
};
|
||||
|
||||
/** A honey batch in the transaction with the given water content. */
|
||||
function batchWith(waterContent: number | null, honeyType: string | null) {
|
||||
prisma.honeyBatch.findUnique.mockResolvedValueOnce({ id: 'h1' });
|
||||
honeyBatches.set('h1', { amount: 25 });
|
||||
tx.honeyBatch.findUnique.mockImplementation(
|
||||
({ where }: { where: { id: string } }) =>
|
||||
Promise.resolve({
|
||||
id: where.id,
|
||||
amount: 25,
|
||||
waterContent,
|
||||
honeyType,
|
||||
}),
|
||||
);
|
||||
tx.bottlingBatch.create.mockResolvedValue({
|
||||
id: 'b1',
|
||||
honeyBatchId: 'h1',
|
||||
});
|
||||
tx.bottlingBatch.findUnique.mockResolvedValue({ id: 'b1' });
|
||||
}
|
||||
|
||||
function article(extra: Record<string, unknown> = {}) {
|
||||
const product = {
|
||||
id: 'p1',
|
||||
kind: 'BOTTLED_HONEY',
|
||||
packagingSize: 500,
|
||||
shelfLifeMonths: 24,
|
||||
...extra,
|
||||
};
|
||||
prisma.product.findUnique.mockResolvedValue(product);
|
||||
return product;
|
||||
}
|
||||
|
||||
it('19 % is written and answers WATER_CONTENT_HIGH', async () => {
|
||||
batchWith(19, null);
|
||||
|
||||
const result = await service.create(
|
||||
{ ...base, jarSize: 500, bestBeforeDate: '2027-01-01' },
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(result.warnings.map((w) => w.code)).toEqual([
|
||||
'WATER_CONTENT_HIGH',
|
||||
]);
|
||||
expect(tx.bottlingBatch.create).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('above the legal limit it refuses with CONFIRMATION_REQUIRED and writes nothing', async () => {
|
||||
batchWith(20.5, null);
|
||||
|
||||
const error = await service
|
||||
.create(
|
||||
{ ...base, jarSize: 500, bestBeforeDate: '2027-01-01' },
|
||||
'user-1',
|
||||
)
|
||||
.catch((e: unknown) => e);
|
||||
|
||||
expect(error).toBeInstanceOf(ConflictException);
|
||||
const body = (error as ConflictException).getResponse() as {
|
||||
code: string;
|
||||
message: string;
|
||||
warnings: PlausibilityWarning[];
|
||||
};
|
||||
expect(body.code).toBe('CONFIRMATION_REQUIRED');
|
||||
expect(body.warnings.map((w) => w.code)).toEqual([
|
||||
'WATER_CONTENT_ABOVE_LEGAL',
|
||||
]);
|
||||
expect(typeof body.message).toBe('string');
|
||||
expect(tx.bottlingBatch.create).not.toHaveBeenCalled();
|
||||
expect(ledger.bookIn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('the confirmed repeat is written and still reports the finding', async () => {
|
||||
batchWith(20.5, null);
|
||||
|
||||
const result = await service.create(
|
||||
{
|
||||
...base,
|
||||
jarSize: 500,
|
||||
bestBeforeDate: '2027-01-01',
|
||||
acknowledgedWarnings: ['WATER_CONTENT_ABOVE_LEGAL'],
|
||||
acknowledgedWaterContent: 20.5,
|
||||
},
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(result.warnings.map((w) => w.code)).toEqual([
|
||||
'WATER_CONTENT_ABOVE_LEGAL',
|
||||
]);
|
||||
expect(tx.bottlingBatch.create).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('a newer measurement decides, read under the batch lock', async () => {
|
||||
batchWith(21, null);
|
||||
tx.honeyMeasurement.findMany.mockResolvedValue([
|
||||
{ waterContent: 17, measuredAt: new Date('2026-01-20') },
|
||||
]);
|
||||
|
||||
const result = await service.create(
|
||||
{ ...base, jarSize: 500, bestBeforeDate: '2027-01-01' },
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(result.warnings).toEqual([]);
|
||||
expect(steps[0]).toBe('lock');
|
||||
});
|
||||
|
||||
it('a missing water content is a hint, not a stop', async () => {
|
||||
batchWith(null, null);
|
||||
|
||||
const result = await service.create(
|
||||
{ ...base, jarSize: 500, bestBeforeDate: '2027-01-01' },
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(result.warnings.map((w) => w.code)).toEqual([
|
||||
'WATER_CONTENT_MISSING',
|
||||
]);
|
||||
});
|
||||
|
||||
it('takes jar size and best before from the article, month end cut', async () => {
|
||||
batchWith(17, null);
|
||||
article({ shelfLifeMonths: 1 });
|
||||
|
||||
await service.create({ ...base, productId: 'p1' }, 'user-1');
|
||||
|
||||
expect(tx.bottlingBatch.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
jarSize: 500,
|
||||
totalAmount: 5,
|
||||
bestBeforeDate: new Date('2026-02-28'),
|
||||
}) as unknown,
|
||||
});
|
||||
});
|
||||
|
||||
it('a sent value wins, and another jar size gives JAR_SIZE_DIFFERS', async () => {
|
||||
batchWith(17, null);
|
||||
article();
|
||||
|
||||
const result = await service.create(
|
||||
{
|
||||
...base,
|
||||
productId: 'p1',
|
||||
jarSize: 250,
|
||||
bestBeforeDate: '2027-05-05',
|
||||
},
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(result.warnings.map((w) => w.code)).toEqual(['JAR_SIZE_DIFFERS']);
|
||||
expect(tx.bottlingBatch.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
jarSize: 250,
|
||||
bestBeforeDate: new Date('2027-05-05'),
|
||||
}) as unknown,
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses with a clear text when neither jar size nor best before is known', async () => {
|
||||
prisma.honeyBatch.findUnique.mockResolvedValueOnce({ id: 'h1' });
|
||||
|
||||
await expect(service.create(base, 'user-1')).rejects.toThrow(
|
||||
new BadRequestException(
|
||||
'Glasgröße und MHD fehlen. Bitte einen Artikel wählen oder beides eintragen.',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses without a shelf life on the article when no best before is sent', async () => {
|
||||
prisma.honeyBatch.findUnique.mockResolvedValueOnce({ id: 'h1' });
|
||||
article({ shelfLifeMonths: null });
|
||||
|
||||
await expect(
|
||||
service.create({ ...base, productId: 'p1' }, 'user-1'),
|
||||
).rejects.toThrow(
|
||||
new BadRequestException(
|
||||
'Ohne Haltbarkeit am Artikel bitte ein MHD eintragen.',
|
||||
),
|
||||
);
|
||||
expect(tx.bottlingBatch.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('a correction only reports: never a 409', async () => {
|
||||
const before = {
|
||||
id: 'b1',
|
||||
lotNumber: 'L-1',
|
||||
honeyBatchId: 'h1',
|
||||
productId: null,
|
||||
honeyBatch: { id: 'h1', amount: 10 },
|
||||
jarSize: 500,
|
||||
jarCount: 10,
|
||||
totalAmount: 5,
|
||||
};
|
||||
lockedRows(before, before);
|
||||
tx.honeyBatch.findUnique.mockResolvedValue({
|
||||
id: 'h1',
|
||||
amount: 10,
|
||||
waterContent: 22,
|
||||
honeyType: null,
|
||||
});
|
||||
|
||||
honeyBatches.set('h1', { amount: 10 });
|
||||
const result = await service.update('b1', { jarSize: 250 }, 'user-1');
|
||||
|
||||
expect(result.warnings.map((w) => w.code)).toEqual([
|
||||
'WATER_CONTENT_ABOVE_LEGAL',
|
||||
]);
|
||||
});
|
||||
|
||||
it('a correction that touches neither honey batch nor jar size reports no quality finding', async () => {
|
||||
const before = {
|
||||
id: 'b1',
|
||||
lotNumber: 'L-1',
|
||||
honeyBatchId: 'h1',
|
||||
productId: null,
|
||||
honeyBatch: { id: 'h1', amount: 10 },
|
||||
jarSize: 500,
|
||||
jarCount: 10,
|
||||
totalAmount: 5,
|
||||
};
|
||||
lockedRows(before, before);
|
||||
tx.honeyBatch.findUnique.mockResolvedValue({
|
||||
id: 'h1',
|
||||
amount: 10,
|
||||
waterContent: 22,
|
||||
honeyType: null,
|
||||
});
|
||||
|
||||
const result = await service.update('b1', { lotNumber: 'L-2' }, 'user-1');
|
||||
|
||||
expect(result.warnings).toEqual([]);
|
||||
});
|
||||
|
||||
async function refusal(
|
||||
extra: Record<string, unknown>,
|
||||
): Promise<{ waterContent: number; code: string }> {
|
||||
batchWith(20.5, null);
|
||||
const error = await service
|
||||
.create(
|
||||
{
|
||||
...base,
|
||||
jarSize: 500,
|
||||
bestBeforeDate: '2027-01-01',
|
||||
acknowledgedWarnings: ['WATER_CONTENT_ABOVE_LEGAL'],
|
||||
...extra,
|
||||
},
|
||||
'user-1',
|
||||
)
|
||||
.catch((e: unknown) => e);
|
||||
expect(error).toBeInstanceOf(ConflictException);
|
||||
return (error as ConflictException).getResponse() as {
|
||||
waterContent: number;
|
||||
code: string;
|
||||
};
|
||||
}
|
||||
|
||||
it('the 409 names the checked value', async () => {
|
||||
batchWith(20.5, null);
|
||||
const error = await service
|
||||
.create(
|
||||
{ ...base, jarSize: 500, bestBeforeDate: '2027-01-01' },
|
||||
'user-1',
|
||||
)
|
||||
.catch((e: unknown) => e);
|
||||
expect(
|
||||
((error as ConflictException).getResponse() as { waterContent: number })
|
||||
.waterContent,
|
||||
).toBe(20.5);
|
||||
});
|
||||
|
||||
it('a confirmation for another value gets a fresh 409 with the new value', async () => {
|
||||
const body = await refusal({ acknowledgedWaterContent: 20.2 });
|
||||
expect(body).toMatchObject({
|
||||
code: 'CONFIRMATION_REQUIRED',
|
||||
waterContent: 20.5,
|
||||
});
|
||||
expect(tx.bottlingBatch.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('the code without the value is not a confirmation', async () => {
|
||||
const body = await refusal({});
|
||||
expect(body.code).toBe('CONFIRMATION_REQUIRED');
|
||||
});
|
||||
|
||||
it('an impossible day is a 400', async () => {
|
||||
prisma.honeyBatch.findUnique.mockResolvedValueOnce({ id: 'h1' });
|
||||
await expect(
|
||||
service.create(
|
||||
{
|
||||
...base,
|
||||
date: '2026-02-30',
|
||||
jarSize: 500,
|
||||
bestBeforeDate: '2027-01-01',
|
||||
},
|
||||
'user-1',
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -12,10 +12,15 @@ import { DomainEventsService } from '../domain-events/domain-events.service';
|
||||
import type { AnyNewDomainEvent } from '../domain-events/domain-event-types';
|
||||
import { AutomationService } from '../automation/automation.service';
|
||||
import { StockService } from '../stock/stock.service';
|
||||
import { withWarnings } from '../common/warnings/plausibility-warning';
|
||||
import {
|
||||
withWarnings,
|
||||
type PlausibilityWarning,
|
||||
} from '../common/warnings/plausibility-warning';
|
||||
import { WithdrawalCheckService } from '../withdrawal/withdrawal-check.service';
|
||||
import { Prisma } from '../../generated/prisma';
|
||||
import { HoneyBatchLedgerService } from '../honey-batches/honey-batch-ledger.service';
|
||||
import { jarSizeWarnings, resolveJarAndBestBefore } from './bottling-defaults';
|
||||
import { waterContentWarningsOfBatch } from '../common/quality/water-content';
|
||||
import { CreateBottlingBatchDto } from './dto/create-bottling-batch.dto';
|
||||
import { UpdateBottlingBatchDto } from './dto/update-bottling-batch.dto';
|
||||
|
||||
@@ -114,15 +119,36 @@ export class BottlingService {
|
||||
db: DbClient,
|
||||
honeyBatchId: string,
|
||||
bottling: T,
|
||||
qualityWarnings: readonly PlausibilityWarning[] = [],
|
||||
) {
|
||||
const conflicts = await this.withdrawalCheck.conflictsForHoneyBatch(
|
||||
db,
|
||||
honeyBatchId,
|
||||
);
|
||||
return withWarnings(
|
||||
bottling,
|
||||
await this.withdrawalCheck.warningsFor(db, conflicts),
|
||||
);
|
||||
return withWarnings(bottling, [
|
||||
...qualityWarnings,
|
||||
...(await this.withdrawalCheck.warningsFor(db, conflicts)),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The quality findings for the honey batch as it stands in the caller's
|
||||
* transaction: its newest water content against the rule (spec 7.8) and
|
||||
* the hint for a jar size that differs from the article.
|
||||
*/
|
||||
private async qualityWarnings(
|
||||
tx: Tx,
|
||||
honeyBatchId: string,
|
||||
jarWarnings: readonly PlausibilityWarning[],
|
||||
): Promise<{
|
||||
warnings: PlausibilityWarning[];
|
||||
waterContent: number | null;
|
||||
}> {
|
||||
const water = await waterContentWarningsOfBatch(tx, honeyBatchId);
|
||||
return {
|
||||
warnings: [...(water?.warnings ?? []), ...jarWarnings],
|
||||
waterContent: water?.waterContent ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async findAll(filter: BottlingBatchFilter = {}) {
|
||||
@@ -158,10 +184,7 @@ export class BottlingService {
|
||||
}
|
||||
|
||||
/** Honey is bottled; other articles keep their stock in production batches. */
|
||||
private async assertBottledProduct(
|
||||
productId: string,
|
||||
tx?: Tx,
|
||||
): Promise<void> {
|
||||
private async assertBottledProduct(productId: string, tx?: Tx) {
|
||||
const db: DbClient = tx ?? this.prisma;
|
||||
const product = await db.product.findUnique({
|
||||
where: { id: productId },
|
||||
@@ -174,6 +197,7 @@ export class BottlingService {
|
||||
'Abfüllungen nur für Honig-Artikel - hergestellte Waren bekommen Herstellungschargen',
|
||||
);
|
||||
}
|
||||
return product;
|
||||
}
|
||||
|
||||
async create(dto: CreateBottlingBatchDto, actingUserId: string) {
|
||||
@@ -184,11 +208,11 @@ export class BottlingService {
|
||||
if (!honeyBatch) {
|
||||
throw new NotFoundException('Honey batch not found');
|
||||
}
|
||||
if (dto.productId) {
|
||||
await this.assertBottledProduct(dto.productId);
|
||||
}
|
||||
|
||||
const totalAmount = roundKg((dto.jarSize * dto.jarCount) / 1000);
|
||||
const article = dto.productId
|
||||
? await this.assertBottledProduct(dto.productId)
|
||||
: null;
|
||||
const resolved = resolveJarAndBestBefore(dto, article);
|
||||
const totalAmount = roundKg((resolved.jarSize * dto.jarCount) / 1000);
|
||||
|
||||
const lotNumber =
|
||||
dto.lotNumber ??
|
||||
@@ -205,15 +229,22 @@ export class BottlingService {
|
||||
if (!(await lockRowForUpdate(tx, 'HoneyBatch', dto.honeyBatchId))) {
|
||||
throw new NotFoundException('Honey batch not found');
|
||||
}
|
||||
// Under the batch lock: a measurement entered meanwhile counts.
|
||||
const quality = await this.qualityWarnings(
|
||||
tx,
|
||||
dto.honeyBatchId,
|
||||
resolved.warnings,
|
||||
);
|
||||
this.assertConfirmed(quality, dto);
|
||||
const created = await tx.bottlingBatch.create({
|
||||
data: {
|
||||
honeyBatchId: dto.honeyBatchId,
|
||||
productId: dto.productId,
|
||||
date: new Date(dto.date),
|
||||
jarSize: dto.jarSize,
|
||||
jarSize: resolved.jarSize,
|
||||
jarCount: dto.jarCount,
|
||||
totalAmount,
|
||||
bestBeforeDate: new Date(dto.bestBeforeDate),
|
||||
bestBeforeDate: new Date(resolved.bestBeforeDate),
|
||||
lotNumber,
|
||||
userId: actingUserId,
|
||||
},
|
||||
@@ -247,10 +278,52 @@ export class BottlingService {
|
||||
tx,
|
||||
created.honeyBatchId,
|
||||
await this.findById(created.id, tx),
|
||||
quality.warnings,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A finding that needs a confirmation (BESTAETIGEN) stops the write with
|
||||
* 409 CONFIRMATION_REQUIRED, unless the request names its code in
|
||||
* `acknowledgedWarnings` AND the checked value in `acknowledgedWaterContent`:
|
||||
* a confirmation belongs to the value the beekeeper saw. If the value under
|
||||
* the lock differs (a new measurement meanwhile), a fresh 409 with the new
|
||||
* value follows. The body carries every finding of the request, so the form
|
||||
* can show them in one dialog. The idempotency interceptor frees the key on
|
||||
* this error: the confirmed repeat may use the same key.
|
||||
*/
|
||||
private assertConfirmed(
|
||||
quality: {
|
||||
warnings: readonly PlausibilityWarning[];
|
||||
waterContent: number | null;
|
||||
},
|
||||
sent: Pick<
|
||||
CreateBottlingBatchDto,
|
||||
'acknowledgedWarnings' | 'acknowledgedWaterContent'
|
||||
>,
|
||||
): void {
|
||||
const acknowledged: readonly string[] = sent.acknowledgedWarnings ?? [];
|
||||
const open = quality.warnings.filter(
|
||||
(warning) =>
|
||||
warning.severity === 'BESTAETIGEN' &&
|
||||
!(
|
||||
acknowledged.includes(warning.code) &&
|
||||
sent.acknowledgedWaterContent !== undefined &&
|
||||
sent.acknowledgedWaterContent === quality.waterContent
|
||||
),
|
||||
);
|
||||
if (open.length > 0) {
|
||||
throw new ConflictException({
|
||||
statusCode: 409,
|
||||
code: 'CONFIRMATION_REQUIRED',
|
||||
message: open.map((warning) => warning.message).join(' '),
|
||||
waterContent: quality.waterContent,
|
||||
warnings: quality.warnings,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Corrects any field of an existing bottling batch. Changing the honey
|
||||
* batch, the jar size or the jar count also rebalances the remaining amount
|
||||
@@ -269,9 +342,11 @@ export class BottlingService {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
await lockRowForUpdate(tx, 'BottlingBatch', id);
|
||||
const before = await this.findById(id, tx);
|
||||
if (dto.productId) {
|
||||
await this.assertBottledProduct(dto.productId, tx);
|
||||
}
|
||||
const article = dto.productId
|
||||
? await this.assertBottledProduct(dto.productId, tx)
|
||||
: dto.productId === undefined && before.productId
|
||||
? await tx.product.findUnique({ where: { id: before.productId } })
|
||||
: null;
|
||||
|
||||
const honeyBatchId = dto.honeyBatchId ?? before.honeyBatchId;
|
||||
const movedToOtherHoneyBatch = honeyBatchId !== before.honeyBatchId;
|
||||
@@ -342,6 +417,20 @@ export class BottlingService {
|
||||
});
|
||||
|
||||
const bottlingBatch = await this.findById(id, tx);
|
||||
// A correction only reports (spec 7.8): the confirmation belongs to
|
||||
// the creation.
|
||||
// Only when the correction touches what the findings are about.
|
||||
const qualityChecked =
|
||||
movedToOtherHoneyBatch || jarSize !== before.jarSize;
|
||||
const quality = qualityChecked
|
||||
? (
|
||||
await this.qualityWarnings(
|
||||
tx,
|
||||
honeyBatchId,
|
||||
jarSizeWarnings(jarSize, article),
|
||||
)
|
||||
).warnings
|
||||
: [];
|
||||
await this.auditLog.record(
|
||||
{
|
||||
entityType: 'BottlingBatch',
|
||||
@@ -362,7 +451,12 @@ export class BottlingService {
|
||||
payload: { honeyBatchId, removed: false },
|
||||
actorUserId: actingUserId,
|
||||
});
|
||||
return this.withWithdrawalWarnings(tx, honeyBatchId, bottlingBatch);
|
||||
return this.withWithdrawalWarnings(
|
||||
tx,
|
||||
honeyBatchId,
|
||||
bottlingBatch,
|
||||
quality,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
IsISO8601,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
|
||||
const MAX_ID_LENGTH = 64;
|
||||
const ID_PATTERN = /^[A-Za-z0-9_-]+$/;
|
||||
|
||||
export class BottlingPrefillQuery {
|
||||
@ApiProperty({ description: 'Die Honigcharge, die abgefüllt wird' })
|
||||
@IsString()
|
||||
@MaxLength(MAX_ID_LENGTH)
|
||||
@Matches(ID_PATTERN)
|
||||
honeyBatchId!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Der Artikel (Honig-Artikel)' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(MAX_ID_LENGTH)
|
||||
@Matches(ID_PATTERN)
|
||||
productId?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Abfülldatum (Standard: heute), Grundlage für das MHD',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsISO8601({ strict: true })
|
||||
date?: string;
|
||||
}
|
||||
@@ -1,6 +1,12 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
Max,
|
||||
Min,
|
||||
ArrayMaxSize,
|
||||
ArrayUnique,
|
||||
IsArray,
|
||||
IsDateString,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
@@ -8,6 +14,12 @@ import {
|
||||
IsString,
|
||||
} from 'class-validator';
|
||||
|
||||
export const BOTTLING_ACKNOWLEDGEABLE_WARNINGS = [
|
||||
'WATER_CONTENT_ABOVE_LEGAL',
|
||||
] as const;
|
||||
export type AcknowledgeableWarning =
|
||||
(typeof BOTTLING_ACKNOWLEDGEABLE_WARNINGS)[number];
|
||||
|
||||
export class CreateBottlingBatchDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@@ -19,22 +31,30 @@ export class CreateBottlingBatchDto {
|
||||
productId?: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsDateString()
|
||||
@IsDateString({ strict: true })
|
||||
date!: string;
|
||||
|
||||
@ApiProperty({ description: 'Glasgröße in Gramm' })
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Glasgröße in Gramm. Fehlt sie, gilt die Packungsgröße des Artikels.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@IsPositive()
|
||||
jarSize!: number;
|
||||
jarSize?: number;
|
||||
|
||||
@ApiProperty()
|
||||
@IsInt()
|
||||
@IsPositive()
|
||||
jarCount!: number;
|
||||
|
||||
@ApiProperty()
|
||||
@IsDateString()
|
||||
bestBeforeDate!: string;
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'MHD. Fehlt es, gilt Abfülldatum plus Haltbarkeit des Artikels (Monatsende abgeschnitten).',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsDateString({ strict: true })
|
||||
bestBeforeDate?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
@@ -43,4 +63,27 @@ export class CreateBottlingBatchDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
lotNumber?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: BOTTLING_ACKNOWLEDGEABLE_WARNINGS,
|
||||
isArray: true,
|
||||
description:
|
||||
'Bestätigte Warnungen. Über der gesetzlichen Wassergehaltsgrenze nötig, sonst 409 CONFIRMATION_REQUIRED.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(1)
|
||||
@ArrayUnique()
|
||||
@IsIn(BOTTLING_ACKNOWLEDGEABLE_WARNINGS, { each: true })
|
||||
acknowledgedWarnings?: AcknowledgeableWarning[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Der Wassergehalt (%), den die Bestätigung meint: der Wert aus der 409-Antwort. Weicht der geprüfte Wert ab, folgt eine neue 409.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Max(100)
|
||||
acknowledgedWaterContent?: number;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
addDays,
|
||||
addMonthsClamped,
|
||||
berlinDateTime,
|
||||
berlinDayOf,
|
||||
berlinToday,
|
||||
@@ -97,4 +98,19 @@ describe('dates', () => {
|
||||
it('daysBetween counts whole days across a DST change', () => {
|
||||
expect(daysBetween('2026-03-28', '2026-03-30')).toBe(2);
|
||||
});
|
||||
|
||||
it('best before 2027-01-31 plus 1 month is 2027-02-28', () => {
|
||||
expect(addMonthsClamped('2027-01-31', 1)).toBe('2027-02-28');
|
||||
});
|
||||
|
||||
it('addMonthsClamped keeps the leap day and crosses year ends', () => {
|
||||
expect(addMonthsClamped('2028-01-31', 1)).toBe('2028-02-29');
|
||||
expect(addMonthsClamped('2026-11-15', 3)).toBe('2027-02-15');
|
||||
expect(addMonthsClamped('2026-03-31', 24)).toBe('2028-03-31');
|
||||
expect(addMonthsClamped('2026-08-31', 1)).toBe('2026-09-30');
|
||||
});
|
||||
|
||||
it('addMonthsClamped rejects an impossible day', () => {
|
||||
expect(() => addMonthsClamped('2026-02-30', 1)).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -55,6 +55,27 @@ export function addDays(day: string, days: number): string {
|
||||
return result.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calendar months on a YYYY-MM-DD string. A day the target month does not
|
||||
* have is cut to its last day: 2027-01-31 + 1 month is 2027-02-28.
|
||||
*/
|
||||
export function addMonthsClamped(day: string, months: number): string {
|
||||
const start = dayToDate(day);
|
||||
const monthIndex = start.getUTCMonth() + months;
|
||||
const lastDay = new Date(
|
||||
Date.UTC(start.getUTCFullYear(), monthIndex + 1, 0),
|
||||
).getUTCDate();
|
||||
return new Date(
|
||||
Date.UTC(
|
||||
start.getUTCFullYear(),
|
||||
monthIndex,
|
||||
Math.min(start.getUTCDate(), lastDay),
|
||||
),
|
||||
)
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
}
|
||||
|
||||
/** Whole calendar days from `from` to `to` (negative when `to` is earlier). */
|
||||
export function daysBetween(from: string, to: string): number {
|
||||
return dayNumber(to) - dayNumber(from);
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import type { WaterContentConfig } from '../../automation/rule-config/rule-config.types';
|
||||
import { latestWaterContent, waterContentWarnings } from './water-content';
|
||||
|
||||
const config: WaterContentConfig = {
|
||||
warnAbovePercent: 18,
|
||||
confirmAbovePercent: 20,
|
||||
byHoneyType: {},
|
||||
};
|
||||
const refs = [{ type: 'HoneyBatch', id: 'hb1' }];
|
||||
|
||||
describe('latestWaterContent', () => {
|
||||
it('the newest measurement wins over the batch value', () => {
|
||||
expect(
|
||||
latestWaterContent({ waterContent: 17 }, [
|
||||
{ waterContent: 19, measuredAt: new Date('2026-08-01') },
|
||||
{ waterContent: 21, measuredAt: new Date('2026-09-01') },
|
||||
{ waterContent: 18, measuredAt: new Date('2026-07-01') },
|
||||
]),
|
||||
).toBe(21);
|
||||
});
|
||||
|
||||
it('falls back to the batch value without measurements', () => {
|
||||
expect(latestWaterContent({ waterContent: 17.5 }, [])).toBe(17.5);
|
||||
});
|
||||
|
||||
it('is null without any value', () => {
|
||||
expect(latestWaterContent({ waterContent: null }, [])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('waterContentWarnings', () => {
|
||||
it('stays silent at the warning limit and below', () => {
|
||||
expect(waterContentWarnings(18, 'Blütenhonig', config, refs)).toEqual([]);
|
||||
expect(waterContentWarnings(12, null, config, refs)).toEqual([]);
|
||||
});
|
||||
|
||||
it('warns above 18 %', () => {
|
||||
const [warning, ...rest] = waterContentWarnings(19, null, config, refs);
|
||||
expect(rest).toEqual([]);
|
||||
expect(warning).toMatchObject({
|
||||
code: 'WATER_CONTENT_HIGH',
|
||||
severity: 'WARNUNG',
|
||||
refs,
|
||||
});
|
||||
});
|
||||
|
||||
it('asks for a confirmation above 20 %', () => {
|
||||
const [warning, ...rest] = waterContentWarnings(20.5, null, config, refs);
|
||||
expect(rest).toEqual([]);
|
||||
expect(warning).toMatchObject({
|
||||
code: 'WATER_CONTENT_ABOVE_LEGAL',
|
||||
severity: 'BESTAETIGEN',
|
||||
});
|
||||
});
|
||||
|
||||
it('20.0 % itself only warns', () => {
|
||||
expect(
|
||||
waterContentWarnings(20, null, config, refs).map((w) => w.code),
|
||||
).toEqual(['WATER_CONTENT_HIGH']);
|
||||
});
|
||||
|
||||
it('hints at a missing value', () => {
|
||||
expect(
|
||||
waterContentWarnings(null, null, config, refs).map((w) => [
|
||||
w.code,
|
||||
w.severity,
|
||||
]),
|
||||
).toEqual([['WATER_CONTENT_MISSING', 'HINWEIS']]);
|
||||
});
|
||||
|
||||
it('a configured heather honey confirms only above 23 %', () => {
|
||||
const heather: WaterContentConfig = {
|
||||
...config,
|
||||
byHoneyType: { Heidehonig: { confirmAbovePercent: 23 } },
|
||||
};
|
||||
expect(
|
||||
waterContentWarnings(22, 'heidehonig', heather, refs).map((w) => w.code),
|
||||
).toEqual(['WATER_CONTENT_HIGH']);
|
||||
expect(
|
||||
waterContentWarnings(23.5, 'HEIDEHONIG', heather, refs).map(
|
||||
(w) => w.code,
|
||||
),
|
||||
).toEqual(['WATER_CONTENT_ABOVE_LEGAL']);
|
||||
// Another type keeps the general limits; a near name is not a match.
|
||||
expect(
|
||||
waterContentWarnings(22, 'Heidehonig-Mix', heather, refs).map(
|
||||
(w) => w.code,
|
||||
),
|
||||
).toEqual(['WATER_CONTENT_ABOVE_LEGAL']);
|
||||
});
|
||||
|
||||
it('a type entry with a warning limit only keeps the general confirm limit', () => {
|
||||
const custom: WaterContentConfig = {
|
||||
...config,
|
||||
byHoneyType: { Raps: { warnAbovePercent: 17 } },
|
||||
};
|
||||
expect(
|
||||
waterContentWarnings(17.5, 'Raps', custom, refs).map((w) => w.code),
|
||||
).toEqual(['WATER_CONTENT_HIGH']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('water content messages', () => {
|
||||
it('shows two decimals when one decimal would hide the difference to the limit', () => {
|
||||
const [warning] = waterContentWarnings(20.04, null, config, refs);
|
||||
expect(warning.message).toContain('(20,04 %)');
|
||||
expect(warning.message).toContain('Grenze von 20 %');
|
||||
});
|
||||
|
||||
it('keeps one decimal otherwise', () => {
|
||||
const [warning] = waterContentWarnings(20.5, null, config, refs);
|
||||
expect(warning.message).toContain('(20,5 %)');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
import type { HoneyBatch, HoneyMeasurement } from '../../../generated/prisma';
|
||||
import type { WaterContentConfig } from '../../automation/rule-config/rule-config.types';
|
||||
import { readRule } from '../../automation/rule-config/rule-config.reader';
|
||||
import { formatNumberDe } from '../../suggestions/texts/format-number';
|
||||
import type { DbClient } from '../../prisma/db-client';
|
||||
import {
|
||||
WARNING_SEVERITY,
|
||||
type PlausibilityWarning,
|
||||
} from '../warnings/plausibility-warning';
|
||||
|
||||
type Ref = { type: string; id: string };
|
||||
|
||||
/**
|
||||
* The water content that counts (spec 7.8): the newest measurement by
|
||||
* `measuredAt`, else the value on the batch itself.
|
||||
*/
|
||||
export function latestWaterContent(
|
||||
batch: Pick<HoneyBatch, 'waterContent'>,
|
||||
measurements: readonly Pick<
|
||||
HoneyMeasurement,
|
||||
'waterContent' | 'measuredAt'
|
||||
>[],
|
||||
): number | null {
|
||||
let newest: Pick<HoneyMeasurement, 'waterContent' | 'measuredAt'> | null =
|
||||
null;
|
||||
for (const measurement of measurements) {
|
||||
if (newest === null || measurement.measuredAt > newest.measuredAt) {
|
||||
newest = measurement;
|
||||
}
|
||||
}
|
||||
return newest === null ? batch.waterContent : newest.waterContent;
|
||||
}
|
||||
|
||||
/** The limits for a honey type: its own entry (any case, exact name) first. */
|
||||
function limitsFor(
|
||||
honeyType: string | null,
|
||||
config: WaterContentConfig,
|
||||
): { warn: number; confirm: number } {
|
||||
const wanted = honeyType?.trim().toLowerCase();
|
||||
const own =
|
||||
wanted === undefined || wanted === ''
|
||||
? undefined
|
||||
: Object.entries(config.byHoneyType).find(
|
||||
([name]) => name.toLowerCase() === wanted,
|
||||
)?.[1];
|
||||
return {
|
||||
warn: own?.warnAbovePercent ?? config.warnAbovePercent,
|
||||
confirm: own?.confirmAbovePercent ?? config.confirmAbovePercent,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A percentage with one decimal; with more when one decimal would round the
|
||||
* value onto the limit and hide that it is above it (20.04 % is "20,04 %").
|
||||
*/
|
||||
export function percentText(value: number, limit?: number): string {
|
||||
const maxDigits = 3;
|
||||
let digits = 1;
|
||||
while (
|
||||
limit !== undefined &&
|
||||
value !== limit &&
|
||||
Number(value.toFixed(digits)) === limit &&
|
||||
digits < maxDigits
|
||||
) {
|
||||
digits += 1;
|
||||
}
|
||||
return `${formatNumberDe(value, digits)} %`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Water content against the limits of the rule HONEY_WATER_CONTENT. Above
|
||||
* the confirm limit only WATER_CONTENT_ABOVE_LEGAL is reported (the stronger
|
||||
* finding); a warning never blocks, the confirmation is asked for by
|
||||
* POST /bottling-batches only.
|
||||
*/
|
||||
export function waterContentWarnings(
|
||||
value: number | null,
|
||||
honeyType: string | null,
|
||||
config: WaterContentConfig,
|
||||
refs: Ref[],
|
||||
): PlausibilityWarning[] {
|
||||
if (value === null) {
|
||||
return [
|
||||
{
|
||||
code: 'WATER_CONTENT_MISSING',
|
||||
severity: WARNING_SEVERITY.WATER_CONTENT_MISSING,
|
||||
message:
|
||||
'Für diese Honigcharge ist kein Wassergehalt erfasst. Bitte vor dem Abfüllen messen.',
|
||||
refs,
|
||||
},
|
||||
];
|
||||
}
|
||||
const { warn, confirm } = limitsFor(honeyType, config);
|
||||
if (value > confirm) {
|
||||
return [
|
||||
{
|
||||
code: 'WATER_CONTENT_ABOVE_LEGAL',
|
||||
severity: WARNING_SEVERITY.WATER_CONTENT_ABOVE_LEGAL,
|
||||
message: `Der Wassergehalt (${percentText(value, confirm)}) liegt über der gesetzlichen Grenze von ${percentText(confirm)}. Abfüllen nur mit Bestätigung.`,
|
||||
refs,
|
||||
},
|
||||
];
|
||||
}
|
||||
if (value > warn) {
|
||||
return [
|
||||
{
|
||||
code: 'WATER_CONTENT_HIGH',
|
||||
severity: WARNING_SEVERITY.WATER_CONTENT_HIGH,
|
||||
message: `Der Wassergehalt (${percentText(value, warn)}) liegt über ${percentText(warn)}. Das Warenzeichen „Echter Deutscher Honig“ verlangt höchstens 18 %.`,
|
||||
refs,
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* The warnings for a honey batch as it stands now: its newest water content
|
||||
* against the rule in force. Read only; null when the batch does not exist.
|
||||
*/
|
||||
export async function waterContentWarningsOfBatch(
|
||||
db: DbClient,
|
||||
honeyBatchId: string,
|
||||
): Promise<{
|
||||
waterContent: number | null;
|
||||
warnings: PlausibilityWarning[];
|
||||
} | null> {
|
||||
const batch = await db.honeyBatch.findUnique({
|
||||
where: { id: honeyBatchId },
|
||||
select: { id: true, honeyType: true, waterContent: true },
|
||||
});
|
||||
if (!batch) {
|
||||
return null;
|
||||
}
|
||||
const [measurements, rule] = await Promise.all([
|
||||
db.honeyMeasurement.findMany({
|
||||
where: { honeyBatchId },
|
||||
select: { waterContent: true, measuredAt: true },
|
||||
}),
|
||||
readRule(db, 'HONEY_WATER_CONTENT'),
|
||||
]);
|
||||
const waterContent = latestWaterContent(batch, measurements);
|
||||
return {
|
||||
waterContent,
|
||||
warnings: waterContentWarnings(
|
||||
waterContent,
|
||||
batch.honeyType ?? null,
|
||||
rule.config,
|
||||
[{ type: 'HoneyBatch', id: batch.id }],
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -209,7 +209,14 @@ const contracts: Record<string, NullContract> = {
|
||||
UpdateProductDto: {
|
||||
dto: UpdateProductDto,
|
||||
notNull: ['name', 'status', 'kind', 'shopCategory'],
|
||||
clearable: ['description', 'sku', 'packagingSize', 'weight', 'salesUnit'],
|
||||
clearable: [
|
||||
'description',
|
||||
'sku',
|
||||
'packagingSize',
|
||||
'shelfLifeMonths',
|
||||
'weight',
|
||||
'salesUnit',
|
||||
],
|
||||
},
|
||||
// apiaryId: null widens the report to all apiaries; notes: null clears them.
|
||||
UpdatePhenologyReportDto: {
|
||||
|
||||
@@ -22,9 +22,11 @@ describe('HoneyBatchesService', () => {
|
||||
};
|
||||
honeyMeasurement: {
|
||||
create: jest.Mock;
|
||||
findMany: jest.Mock;
|
||||
findUnique: jest.Mock;
|
||||
update: jest.Mock;
|
||||
};
|
||||
automationRule: { findUnique: jest.Mock };
|
||||
extraction: { findUnique: jest.Mock };
|
||||
storageContainer: { findUnique: jest.Mock };
|
||||
honeyBatchRelocation: { create: jest.Mock; findMany: jest.Mock };
|
||||
@@ -85,9 +87,12 @@ describe('HoneyBatchesService', () => {
|
||||
},
|
||||
honeyMeasurement: {
|
||||
create: jest.fn(),
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
findUnique: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
// No rule row: the built-in water content limits apply.
|
||||
automationRule: { findUnique: jest.fn().mockResolvedValue(null) },
|
||||
extraction: { findUnique: jest.fn() },
|
||||
storageContainer: { findUnique: jest.fn() },
|
||||
honeyBatchRelocation: { create: jest.fn(), findMany: jest.fn() },
|
||||
@@ -949,4 +954,45 @@ describe('HoneyBatchesService', () => {
|
||||
service.updateMeasurement('missing', { waterContent: 17.5 }, 'user-1'),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
describe('water content warnings on a measurement', () => {
|
||||
function measured(newest: number) {
|
||||
prisma.honeyBatch.findUnique.mockResolvedValue({
|
||||
...honeyBatch,
|
||||
honeyType: null,
|
||||
waterContent: null,
|
||||
});
|
||||
prisma.honeyMeasurement.create.mockResolvedValue({ id: 'm1' });
|
||||
prisma.honeyMeasurement.findMany.mockResolvedValue([
|
||||
{ waterContent: newest, measuredAt: new Date('2026-08-01') },
|
||||
]);
|
||||
}
|
||||
|
||||
it('addMeasurement above the legal limit answers a warning and never refuses', async () => {
|
||||
measured(21);
|
||||
|
||||
const result = await service.addMeasurement(
|
||||
'batch-1',
|
||||
{ waterContent: 21, measuredAt: '2026-08-01T10:00:00.000Z' },
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(result.warnings.map((w) => w.code)).toEqual([
|
||||
'WATER_CONTENT_ABOVE_LEGAL',
|
||||
]);
|
||||
expect(prisma.honeyMeasurement.create).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('a good measurement answers no warning', async () => {
|
||||
measured(16.5);
|
||||
|
||||
const result = await service.addMeasurement(
|
||||
'batch-1',
|
||||
{ waterContent: 16.5, measuredAt: '2026-08-01T10:00:00.000Z' },
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(result.warnings).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,7 @@ import { CreateHoneyMeasurementDto } from './dto/create-honey-measurement.dto';
|
||||
import { UpdateHoneyMeasurementDto } from './dto/update-honey-measurement.dto';
|
||||
import { RelocateHoneyBatchDto } from './dto/relocate-honey-batch.dto';
|
||||
import { SetHoneyBatchContainerAllocationDto } from './dto/set-honey-batch-container-allocation.dto';
|
||||
import { waterContentWarningsOfBatch } from '../common/quality/water-content';
|
||||
import { honeyBatchInclude } from './honey-batch-include';
|
||||
import {
|
||||
batchWarnings,
|
||||
@@ -403,6 +404,17 @@ export class HoneyBatchesService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The honey batch as the answer to a measurement, with the water content
|
||||
* findings of its newest value (spec 7.8). Only a report: a measurement is
|
||||
* a fact and is never refused or held back for a confirmation.
|
||||
*/
|
||||
private async withWaterWarnings(honeyBatchId: string) {
|
||||
const batch = await this.findById(honeyBatchId);
|
||||
const water = await waterContentWarningsOfBatch(this.prisma, honeyBatchId);
|
||||
return withWarnings(batch, water?.warnings ?? []);
|
||||
}
|
||||
|
||||
async updateMeasurement(
|
||||
measurementId: string,
|
||||
dto: UpdateHoneyMeasurementDto,
|
||||
@@ -429,7 +441,7 @@ export class HoneyBatchesService {
|
||||
beforeData: before,
|
||||
afterData: measurement,
|
||||
});
|
||||
return this.findById(before.honeyBatchId);
|
||||
return this.withWaterWarnings(before.honeyBatchId);
|
||||
}
|
||||
|
||||
async addMeasurement(
|
||||
@@ -453,7 +465,7 @@ export class HoneyBatchesService {
|
||||
userId: actingUserId,
|
||||
afterData: measurement,
|
||||
});
|
||||
return this.findById(id);
|
||||
return this.withWaterWarnings(id);
|
||||
}
|
||||
|
||||
async relocate(id: string, dto: RelocateHoneyBatchDto, actingUserId: string) {
|
||||
|
||||
@@ -137,7 +137,7 @@ describe('CleanupJob', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('cleanup deletes terminal NotificationDelivery rows after 30 days (FAILED after 90) and TelegramLinkToken rows expired for 30 days', async () => {
|
||||
it('cleanup deletes terminal NotificationDelivery rows after 30 days (FAILED after 90) and TelegramLinkToken rows used or expired for 24 hours', async () => {
|
||||
await job.run();
|
||||
|
||||
const failed = statementOn('NotificationDelivery', 'FAILED');
|
||||
@@ -153,7 +153,10 @@ describe('CleanupJob', () => {
|
||||
expect(delivered?.values).toEqual([WEBHOOK_DELIVERY_RETENTION_DAYS]);
|
||||
expect(delivered?.sql).toContain('"createdAt" < now()');
|
||||
const tokens = statementOn('TelegramLinkToken');
|
||||
expect(tokens.sql).toContain('"expiresAt" < now() - interval \'30 days\'');
|
||||
expect(tokens.sql).toContain('"expiresAt" < now() - interval \'24 hours\'');
|
||||
expect(tokens.sql).toContain(
|
||||
'"consumedAt" < now() - interval \'24 hours\'',
|
||||
);
|
||||
expect(tokens.values).toEqual([]);
|
||||
});
|
||||
|
||||
|
||||
@@ -84,10 +84,12 @@ export class CleanupJob implements Job {
|
||||
`Removed ${notificationDeliveriesPending} Telegram deliveries that stayed pending for over ${PENDING_DELIVERY_MAX_AGE_DAYS} days`,
|
||||
);
|
||||
}
|
||||
// A link token lives 10 minutes; used or not, it is of no use after that.
|
||||
// A link token lives 10 minutes and works once: a day after it was used
|
||||
// or expired it is of no use (and the hash is not worth keeping).
|
||||
const telegramLinkTokens = await this.prisma.$executeRaw`
|
||||
DELETE FROM "TelegramLinkToken"
|
||||
WHERE "expiresAt" < now() - interval '30 days'`;
|
||||
WHERE "expiresAt" < now() - interval '24 hours'
|
||||
OR "consumedAt" < now() - interval '24 hours'`;
|
||||
// After abortStale: no run this old can still be RUNNING.
|
||||
const jobRunsDeleted = await this.prisma.$executeRaw`
|
||||
DELETE FROM "JobRun"
|
||||
|
||||
@@ -31,6 +31,7 @@ describe('NotificationPreferencesService', () => {
|
||||
};
|
||||
let prisma: {
|
||||
notificationPreference: { upsert: jest.Mock };
|
||||
telegramLink: { findUnique: jest.Mock };
|
||||
$transaction: jest.Mock;
|
||||
};
|
||||
const order: string[] = [];
|
||||
@@ -62,6 +63,7 @@ describe('NotificationPreferencesService', () => {
|
||||
notificationPreference: {
|
||||
upsert: jest.fn().mockResolvedValue(stored),
|
||||
},
|
||||
telegramLink: { findUnique: jest.fn().mockResolvedValue(null) },
|
||||
$transaction: jest
|
||||
.fn()
|
||||
.mockImplementation((work: (t: unknown) => Promise<unknown>) =>
|
||||
@@ -180,4 +182,75 @@ describe('NotificationPreferencesService', () => {
|
||||
expect(arg.data).not.toHaveProperty('telegramEnabled');
|
||||
expect(arg.data.telegramCategories).toEqual(['SYSTEM']);
|
||||
});
|
||||
describe('toView (Telegram status)', () => {
|
||||
const originalUsername = process.env.TELEGRAM_BOT_USERNAME;
|
||||
const originalToken = process.env.TELEGRAM_BOT_TOKEN;
|
||||
|
||||
afterEach(() => {
|
||||
if (originalUsername === undefined) {
|
||||
delete process.env.TELEGRAM_BOT_USERNAME;
|
||||
} else {
|
||||
process.env.TELEGRAM_BOT_USERNAME = originalUsername;
|
||||
}
|
||||
if (originalToken === undefined) {
|
||||
delete process.env.TELEGRAM_BOT_TOKEN;
|
||||
} else {
|
||||
process.env.TELEGRAM_BOT_TOKEN = originalToken;
|
||||
}
|
||||
});
|
||||
|
||||
it('NotificationSettingsView.telegram.configured is true with the username set and no token in the API process', async () => {
|
||||
process.env.TELEGRAM_BOT_USERNAME = '@apicore_test_bot';
|
||||
delete process.env.TELEGRAM_BOT_TOKEN;
|
||||
|
||||
const view = await service.toView(stored as never);
|
||||
|
||||
expect(view.telegram).toEqual({
|
||||
configured: true,
|
||||
botUsername: 'apicore_test_bot',
|
||||
linked: false,
|
||||
firstName: null,
|
||||
linkedAt: null,
|
||||
enabled: false,
|
||||
});
|
||||
expect(JSON.stringify(view)).not.toMatch(/token/i);
|
||||
});
|
||||
|
||||
it('is not configured without a bot username, even with a token', async () => {
|
||||
process.env.TELEGRAM_BOT_USERNAME = '';
|
||||
process.env.TELEGRAM_BOT_TOKEN =
|
||||
'123456789:AAEhBOweik6ad9r_QXMENQjcrGbqCr4K-RM';
|
||||
|
||||
const view = await service.toView(stored as never);
|
||||
|
||||
expect(view.telegram.configured).toBe(false);
|
||||
expect(view.telegram.botUsername).toBeNull();
|
||||
});
|
||||
|
||||
it('shows the link without the chat id', async () => {
|
||||
process.env.TELEGRAM_BOT_USERNAME = 'apicore_test_bot';
|
||||
prisma.telegramLink.findUnique.mockResolvedValue({
|
||||
userId: 'u1',
|
||||
chatId: 4_711_000_001n,
|
||||
telegramFirstName: 'Anna',
|
||||
linkedAt: new Date('2026-10-14T08:00:00.000Z'),
|
||||
});
|
||||
|
||||
const view = await service.toView({
|
||||
...stored,
|
||||
telegramEnabled: true,
|
||||
} as never);
|
||||
|
||||
expect(view.telegram).toMatchObject({
|
||||
linked: true,
|
||||
firstName: 'Anna',
|
||||
linkedAt: '2026-10-14T08:00:00.000Z',
|
||||
enabled: true,
|
||||
});
|
||||
expect(JSON.stringify(view)).not.toContain('4711000001');
|
||||
expect(prisma.telegramLink.findUnique).toHaveBeenCalledWith({
|
||||
where: { userId: 'u1' },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,10 +2,12 @@ import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import {
|
||||
NotificationCategory,
|
||||
NotificationPreference,
|
||||
TelegramLink,
|
||||
} from '../../generated/prisma';
|
||||
import { DbClient } from '../prisma/db-client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { lockRowForUpdate } from '../prisma/row-lock';
|
||||
import { telegramConfig } from '../telegram/telegram-config';
|
||||
import { UpdateNotificationSettingsDto } from './dto/update-notification-settings.dto';
|
||||
|
||||
/** The digest time the owner may choose, Europe/Berlin (inclusive). */
|
||||
@@ -16,6 +18,19 @@ const DIGEST_TIME_MESSAGE =
|
||||
const QUIET_PAIR_MESSAGE =
|
||||
'Bitte Beginn und Ende der Ruhezeit zusammen leeren.';
|
||||
|
||||
/**
|
||||
* Telegram as the API sees it. "configured" means the bot name is known
|
||||
* (the API pod never holds the bot token); the chat id never leaves the server.
|
||||
*/
|
||||
export interface TelegramStatusView {
|
||||
configured: boolean;
|
||||
botUsername: string | null;
|
||||
linked: boolean;
|
||||
firstName: string | null;
|
||||
linkedAt: string | null;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface NotificationSettingsView {
|
||||
taskDueEnabled: boolean;
|
||||
digestEnabled: boolean;
|
||||
@@ -24,10 +39,27 @@ export interface NotificationSettingsView {
|
||||
quietUntil: string | null;
|
||||
telegramCategories: NotificationCategory[];
|
||||
telegramDetail: boolean;
|
||||
telegram: TelegramStatusView;
|
||||
}
|
||||
|
||||
function toTelegramStatus(
|
||||
preference: NotificationPreference,
|
||||
link: Pick<TelegramLink, 'telegramFirstName' | 'linkedAt'> | null,
|
||||
): TelegramStatusView {
|
||||
const { botUsername } = telegramConfig();
|
||||
return {
|
||||
configured: botUsername !== null,
|
||||
botUsername,
|
||||
linked: link !== null,
|
||||
firstName: link?.telegramFirstName ?? null,
|
||||
linkedAt: link?.linkedAt.toISOString() ?? null,
|
||||
enabled: preference.telegramEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
export function toNotificationSettingsView(
|
||||
preference: NotificationPreference,
|
||||
link: Pick<TelegramLink, 'telegramFirstName' | 'linkedAt'> | null,
|
||||
): NotificationSettingsView {
|
||||
return {
|
||||
taskDueEnabled: preference.taskDueEnabled,
|
||||
@@ -37,6 +69,7 @@ export function toNotificationSettingsView(
|
||||
quietUntil: preference.quietUntil,
|
||||
telegramCategories: preference.telegramCategories,
|
||||
telegramDetail: preference.telegramDetail,
|
||||
telegram: toTelegramStatus(preference, link),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -63,6 +96,16 @@ export class NotificationPreferencesService {
|
||||
});
|
||||
}
|
||||
|
||||
/** The settings as the API returns them, with the Telegram status. */
|
||||
async toView(
|
||||
preference: NotificationPreference,
|
||||
): Promise<NotificationSettingsView> {
|
||||
const link = await this.prisma.telegramLink.findUnique({
|
||||
where: { userId: preference.userId },
|
||||
});
|
||||
return toNotificationSettingsView(preference, link);
|
||||
}
|
||||
|
||||
async update(
|
||||
userId: string,
|
||||
dto: UpdateNotificationSettingsDto,
|
||||
|
||||
@@ -7,7 +7,6 @@ import { UpdateNotificationSettingsDto } from './dto/update-notification-setting
|
||||
import {
|
||||
NotificationPreferencesService,
|
||||
NotificationSettingsView,
|
||||
toNotificationSettingsView,
|
||||
} from './notification-preferences.service';
|
||||
|
||||
/**
|
||||
@@ -25,7 +24,7 @@ export class NotificationSettingsController {
|
||||
async get(
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
): Promise<NotificationSettingsView> {
|
||||
return toNotificationSettingsView(
|
||||
return this.preferences.toView(
|
||||
await this.preferences.getOrCreate(actor.id),
|
||||
);
|
||||
}
|
||||
@@ -35,7 +34,7 @@ export class NotificationSettingsController {
|
||||
@Body() dto: UpdateNotificationSettingsDto,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
): Promise<NotificationSettingsView> {
|
||||
return toNotificationSettingsView(
|
||||
return this.preferences.toView(
|
||||
await this.preferences.update(actor.id, dto),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsEnum, IsNumber, IsOptional, IsString } from 'class-validator';
|
||||
import {
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
import { ProductKind, ShopCategory } from '../../../generated/prisma';
|
||||
import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null';
|
||||
|
||||
@@ -23,6 +31,16 @@ export class CreateProductDto {
|
||||
@IsNumber()
|
||||
packagingSize?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Haltbarkeit in Monaten: Grundlage für das MHD beim Abfüllen (kein Standardwert)',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(60)
|
||||
shelfLifeMonths?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsOptional, IsString, Matches, MaxLength } from 'class-validator';
|
||||
|
||||
/** Step-up proof for a new Telegram link token (Spec 9a.5). */
|
||||
export class TelegramLinkTokenDto {
|
||||
@ApiProperty({ description: 'Aktuelles Passwort' })
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
password!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Aktueller TOTP-Code, falls MFA aktiv' })
|
||||
@IsOptional()
|
||||
@Matches(/^\d{6}$/, { message: 'mfaCode must be a 6-digit number' })
|
||||
mfaCode?: string;
|
||||
}
|
||||
@@ -1,13 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MfaModule } from '../auth/mfa/mfa.module';
|
||||
import { ReauthModule } from '../auth/reauth/reauth.module';
|
||||
import { TelegramModule } from '../telegram/telegram.module';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
import { ProfileController } from './profile.controller';
|
||||
import { ProfileService } from './profile.service';
|
||||
import { TelegramController } from './telegram.controller';
|
||||
|
||||
@Module({
|
||||
imports: [MfaModule, ReauthModule, UsersModule],
|
||||
controllers: [ProfileController],
|
||||
imports: [MfaModule, ReauthModule, TelegramModule, UsersModule],
|
||||
controllers: [ProfileController, TelegramController],
|
||||
providers: [ProfileService],
|
||||
})
|
||||
export class ProfileModule {}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Body, Controller, Header, HttpCode, Post } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import { AnyAuthenticatedUser } from '../auth/decorators/any-authenticated-user.decorator';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { ReauthService } from '../auth/reauth/reauth.service';
|
||||
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
|
||||
import { SENSITIVE_ACTION_THROTTLE } from '../auth/util/throttle';
|
||||
import { TelegramLinkService } from '../telegram/telegram-link.service';
|
||||
import { TelegramLinkTokenDto } from './dto/telegram-link-token.dto';
|
||||
|
||||
/**
|
||||
* Connect and disconnect the caller's own Telegram chat. Self-scoped like
|
||||
* the rest of /profile (no permission applies); @AnyAuthenticatedUser keeps
|
||||
* API keys out. A new channel to the outside is a sensitive change, hence
|
||||
* the step-up. The link-token answer holds a secret, so the route is not
|
||||
* @Idempotent() (no stored replay).
|
||||
*/
|
||||
@ApiTags('profile')
|
||||
@ApiBearerAuth()
|
||||
@AnyAuthenticatedUser()
|
||||
@Controller('profile/telegram')
|
||||
export class TelegramController {
|
||||
constructor(
|
||||
private readonly links: TelegramLinkService,
|
||||
private readonly reauth: ReauthService,
|
||||
) {}
|
||||
|
||||
@Throttle(SENSITIVE_ACTION_THROTTLE)
|
||||
// The answer holds a secret: no cache, no proxy may keep it.
|
||||
@Header('Cache-Control', 'no-store')
|
||||
@Post('link-token')
|
||||
async createLinkToken(
|
||||
@Body() proof: TelegramLinkTokenDto,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
): Promise<{ deepLink: string; expiresAt: Date }> {
|
||||
await this.reauth.verify(actor.id, proof);
|
||||
return this.links.createLinkToken(actor.id);
|
||||
}
|
||||
|
||||
@Post('unlink')
|
||||
@HttpCode(204)
|
||||
async unlink(@CurrentUser() actor: AuthenticatedUser): Promise<void> {
|
||||
await this.links.unlink(actor.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsEnum, IsOptional } from 'class-validator';
|
||||
import { NotificationDeliveryStatus } from '../../../generated/prisma';
|
||||
|
||||
export class NotificationDeliveriesQueryDto {
|
||||
@ApiPropertyOptional({
|
||||
enum: NotificationDeliveryStatus,
|
||||
description: 'Nur Zustellungen mit diesem Status',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsEnum(NotificationDeliveryStatus)
|
||||
status?: NotificationDeliveryStatus;
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
import { ConflictException, NotFoundException } from '@nestjs/common';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { lockRowForUpdate, lockRowsForUpdate } from '../prisma/row-lock';
|
||||
import {
|
||||
DELIVERY_RETRY_ALL_LIMIT,
|
||||
NotificationDeliveriesService,
|
||||
} from './notification-deliveries.service';
|
||||
|
||||
jest.mock('../prisma/row-lock', () => ({
|
||||
lockRowForUpdate: jest.fn().mockResolvedValue(true),
|
||||
lockRowsForUpdate: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
type RawCall = [readonly string[], ...unknown[]];
|
||||
|
||||
const lockRow = jest.mocked(lockRowForUpdate);
|
||||
const lockRows = jest.mocked(lockRowsForUpdate);
|
||||
|
||||
describe('NotificationDeliveriesService', () => {
|
||||
let tx: {
|
||||
$queryRaw: jest.Mock<Promise<unknown>, RawCall>;
|
||||
$executeRaw: jest.Mock<Promise<number>, RawCall>;
|
||||
notificationDelivery: { findUnique: jest.Mock; findMany: jest.Mock };
|
||||
};
|
||||
let prisma: {
|
||||
$queryRaw: jest.Mock<Promise<unknown>, RawCall>;
|
||||
$transaction: jest.Mock;
|
||||
notificationDelivery: { findMany: jest.Mock };
|
||||
};
|
||||
let audit: { record: jest.Mock };
|
||||
let service: NotificationDeliveriesService;
|
||||
const originalUsername = process.env.TELEGRAM_BOT_USERNAME;
|
||||
const originalToken = process.env.TELEGRAM_BOT_TOKEN;
|
||||
|
||||
const sqlText = (call: RawCall): string => call[0].join('?');
|
||||
|
||||
const pollRow = (overrides: Record<string, unknown> = {}) => ({
|
||||
leaseOwner: null,
|
||||
lastPollAgeSeconds: null,
|
||||
lastError: null,
|
||||
...overrides,
|
||||
});
|
||||
const senderRow = (overrides: Record<string, unknown> = {}) => ({
|
||||
pending: 0,
|
||||
oldestPendingAgeSeconds: null,
|
||||
failed24h: 0,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
/** overview(): poll state, sender counts, last errors (in this order). */
|
||||
function mockOverview(
|
||||
poll: Record<string, unknown> | null,
|
||||
sender: Record<string, unknown>,
|
||||
errors: unknown[] = [],
|
||||
): void {
|
||||
prisma.$queryRaw
|
||||
.mockResolvedValueOnce(poll ? [poll] : [])
|
||||
.mockResolvedValueOnce([sender])
|
||||
.mockResolvedValueOnce(errors);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
lockRow.mockResolvedValue(true);
|
||||
delete process.env.TELEGRAM_BOT_USERNAME;
|
||||
delete process.env.TELEGRAM_BOT_TOKEN;
|
||||
tx = {
|
||||
$queryRaw: jest.fn<Promise<unknown>, RawCall>().mockResolvedValue([]),
|
||||
$executeRaw: jest.fn<Promise<number>, RawCall>().mockResolvedValue(1),
|
||||
notificationDelivery: { findUnique: jest.fn(), findMany: jest.fn() },
|
||||
};
|
||||
prisma = {
|
||||
$queryRaw: jest.fn<Promise<unknown>, RawCall>(),
|
||||
$transaction: jest.fn((fn: (t: typeof tx) => Promise<unknown>) => fn(tx)),
|
||||
notificationDelivery: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
};
|
||||
audit = { record: jest.fn().mockResolvedValue(undefined) };
|
||||
service = new NotificationDeliveriesService(
|
||||
prisma as unknown as PrismaService,
|
||||
audit as unknown as AuditLogService,
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (originalUsername === undefined) {
|
||||
delete process.env.TELEGRAM_BOT_USERNAME;
|
||||
} else {
|
||||
process.env.TELEGRAM_BOT_USERNAME = originalUsername;
|
||||
}
|
||||
if (originalToken === undefined) {
|
||||
delete process.env.TELEGRAM_BOT_TOKEN;
|
||||
} else {
|
||||
process.env.TELEGRAM_BOT_TOKEN = originalToken;
|
||||
}
|
||||
});
|
||||
|
||||
it('shows the oldest pending age from the database clock and never a chat id or token', async () => {
|
||||
mockOverview(
|
||||
pollRow(),
|
||||
senderRow({ pending: 3, oldestPendingAgeSeconds: 42.5, failed24h: 1 }),
|
||||
[
|
||||
{
|
||||
lastStatusCode: 502,
|
||||
lastError: 'HTTP 502',
|
||||
createdAt: new Date('2026-10-14T08:00:00Z'),
|
||||
},
|
||||
],
|
||||
);
|
||||
prisma.notificationDelivery.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'd1',
|
||||
status: 'FAILED',
|
||||
templateCode: null,
|
||||
attempts: 8,
|
||||
availableAt: new Date('2026-10-14T08:00:00Z'),
|
||||
lastStatusCode: 502,
|
||||
lastError: 'HTTP 502',
|
||||
createdAt: new Date('2026-10-14T07:00:00Z'),
|
||||
deliveredAt: null,
|
||||
notification: { category: 'TASK_DUE' },
|
||||
},
|
||||
]);
|
||||
|
||||
const { telegram, deliveries } = await service.overview();
|
||||
|
||||
expect(telegram.sender).toEqual({
|
||||
pending: 3,
|
||||
oldestPendingAgeSeconds: 42.5,
|
||||
failed24h: 1,
|
||||
lastErrors: [
|
||||
{
|
||||
statusCode: 502,
|
||||
error: 'HTTP 502',
|
||||
createdAt: new Date('2026-10-14T08:00:00Z'),
|
||||
},
|
||||
],
|
||||
});
|
||||
const senderSql = sqlText(prisma.$queryRaw.mock.calls[1]);
|
||||
expect(senderSql).toContain(
|
||||
'EXTRACT(EPOCH FROM now() - MIN("availableAt")',
|
||||
);
|
||||
expect(senderSql).toContain('"NotificationDelivery"');
|
||||
expect(deliveries).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'd1',
|
||||
category: 'TASK_DUE',
|
||||
templateCode: null,
|
||||
attempts: 8,
|
||||
}),
|
||||
]);
|
||||
const raw = JSON.stringify({ telegram, deliveries });
|
||||
expect(raw).not.toMatch(/chatId|token/i);
|
||||
expect(deliveries[0]).not.toHaveProperty('notification');
|
||||
const args = (
|
||||
prisma.notificationDelivery.findMany.mock.calls as unknown[][]
|
||||
)[0][0] as { select: Record<string, unknown>; where: unknown };
|
||||
expect(args.select).not.toHaveProperty('chatId');
|
||||
expect(args.select).not.toHaveProperty('userId');
|
||||
});
|
||||
|
||||
it('configured follows the bot username, not the token, and receiver.lastPollAgeSeconds is null while the worker never polled', async () => {
|
||||
process.env.TELEGRAM_BOT_USERNAME = 'apicore_test_bot';
|
||||
mockOverview(pollRow(), senderRow());
|
||||
const configured = await service.overview();
|
||||
expect(configured.telegram).toMatchObject({
|
||||
botNameSet: true,
|
||||
botUsername: 'apicore_test_bot',
|
||||
receiver: {
|
||||
leaseOwner: null,
|
||||
lastPollAgeSeconds: null,
|
||||
lastError: null,
|
||||
},
|
||||
});
|
||||
|
||||
delete process.env.TELEGRAM_BOT_USERNAME;
|
||||
process.env.TELEGRAM_BOT_TOKEN = '123456789:' + 'A'.repeat(35);
|
||||
mockOverview(null, senderRow());
|
||||
const tokenOnly = await service.overview();
|
||||
expect(tokenOnly.telegram.botNameSet).toBe(false);
|
||||
expect(tokenOnly.telegram.botUsername).toBeNull();
|
||||
expect(tokenOnly.telegram.receiver.lastPollAgeSeconds).toBeNull();
|
||||
expect(JSON.stringify(tokenOnly)).not.toContain('AAAAAAAAAA');
|
||||
});
|
||||
|
||||
it('reports the receiver lease owner and poll age from the database clock', async () => {
|
||||
mockOverview(
|
||||
pollRow({ leaseOwner: 'pod-1', lastPollAgeSeconds: 7.25 }),
|
||||
senderRow(),
|
||||
);
|
||||
const { telegram } = await service.overview();
|
||||
expect(telegram.receiver.leaseOwner).toBe('pod-1');
|
||||
expect(telegram.receiver.lastPollAgeSeconds).toBe(7.25);
|
||||
const pollSql = sqlText(prisma.$queryRaw.mock.calls[0]);
|
||||
expect(pollSql).toContain('"TelegramPollState"');
|
||||
expect(pollSql).toContain('EXTRACT(EPOCH FROM now() - "lastPollAt")');
|
||||
expect(pollSql).toContain('"leaseUntil" > now()');
|
||||
});
|
||||
|
||||
it('filters the list by status and caps it', async () => {
|
||||
mockOverview(pollRow(), senderRow());
|
||||
await service.overview('FAILED');
|
||||
const args = (
|
||||
prisma.notificationDelivery.findMany.mock.calls as unknown[][]
|
||||
)[0][0] as { where: unknown; take: number };
|
||||
expect(args.where).toEqual({ status: 'FAILED' });
|
||||
expect(args.take).toBe(100);
|
||||
|
||||
mockOverview(pollRow(), senderRow());
|
||||
await service.overview();
|
||||
const unfiltered = (
|
||||
prisma.notificationDelivery.findMany.mock.calls as unknown[][]
|
||||
)[1][0] as { where: unknown };
|
||||
expect(unfiltered.where).toEqual({});
|
||||
});
|
||||
|
||||
it('retry moves FAILED back to PENDING, writes an audit entry and wakes the worker', async () => {
|
||||
tx.notificationDelivery.findUnique.mockResolvedValue({
|
||||
status: 'FAILED',
|
||||
attempts: 8,
|
||||
});
|
||||
|
||||
await service.retry('d1', 'user-1');
|
||||
|
||||
expect(lockRow).toHaveBeenCalledWith(tx, 'NotificationDelivery', 'd1');
|
||||
const update = sqlText(tx.$executeRaw.mock.calls[0]);
|
||||
expect(update).toContain('"status" = \'PENDING\'');
|
||||
expect(update).toContain('"attempts" = 0');
|
||||
expect(update).toContain('"availableAt" = now()');
|
||||
expect(update).toContain('"claimedAt" = NULL');
|
||||
expect(audit.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
entityType: 'NotificationDelivery',
|
||||
entityId: 'd1',
|
||||
action: 'UPDATE',
|
||||
userId: 'user-1',
|
||||
beforeData: { status: 'FAILED', attempts: 8 },
|
||||
afterData: { status: 'PENDING', attempts: 0 },
|
||||
}),
|
||||
tx,
|
||||
);
|
||||
expect(
|
||||
tx.$executeRaw.mock.calls.some((c) =>
|
||||
c.slice(1).includes('notification_delivery'),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('retry of a non-FAILED delivery is 409 and of an unknown one 404', async () => {
|
||||
tx.notificationDelivery.findUnique.mockResolvedValueOnce({
|
||||
status: 'DELIVERED',
|
||||
attempts: 1,
|
||||
});
|
||||
await expect(service.retry('d1', 'u')).rejects.toBeInstanceOf(
|
||||
ConflictException,
|
||||
);
|
||||
tx.notificationDelivery.findUnique.mockResolvedValueOnce(null);
|
||||
await expect(service.retry('nope', 'u')).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
expect(audit.record).not.toHaveBeenCalled();
|
||||
expect(tx.$executeRaw).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('retryFailed requeues every FAILED delivery under lock and reports the count', async () => {
|
||||
tx.notificationDelivery.findMany.mockResolvedValue([
|
||||
{ id: 'a' },
|
||||
{ id: 'b' },
|
||||
]);
|
||||
tx.$queryRaw.mockResolvedValueOnce([
|
||||
{ id: 'a', attempts: 8 },
|
||||
{ id: 'b', attempts: 3 },
|
||||
]);
|
||||
|
||||
await expect(service.retryFailed('user-1')).resolves.toEqual({
|
||||
retried: 2,
|
||||
});
|
||||
|
||||
expect(lockRows).toHaveBeenCalledWith(tx, 'NotificationDelivery', [
|
||||
'a',
|
||||
'b',
|
||||
]);
|
||||
const update = sqlText(tx.$queryRaw.mock.calls[0]);
|
||||
expect(update).toContain('"status" = \'FAILED\'');
|
||||
expect(update).toContain('"availableAt" = now()');
|
||||
expect(audit.record).toHaveBeenCalledTimes(2);
|
||||
expect(
|
||||
tx.$executeRaw.mock.calls.some((c) =>
|
||||
c.slice(1).includes('notification_delivery'),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('retryFailed with nothing failed retries nothing and wakes nobody', async () => {
|
||||
tx.notificationDelivery.findMany.mockResolvedValue([]);
|
||||
await expect(service.retryFailed('user-1')).resolves.toEqual({
|
||||
retried: 0,
|
||||
});
|
||||
expect(audit.record).not.toHaveBeenCalled();
|
||||
expect(tx.$executeRaw).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('retryFailed takes the newest FAILED ones, at most the limit', async () => {
|
||||
tx.notificationDelivery.findMany.mockResolvedValue([{ id: 'a' }]);
|
||||
tx.$queryRaw.mockResolvedValueOnce([{ id: 'a', attempts: 8 }]);
|
||||
await service.retryFailed('user-1');
|
||||
const args = (
|
||||
tx.notificationDelivery.findMany.mock.calls as unknown[][]
|
||||
)[0][0] as { take: number; orderBy: unknown; where: unknown };
|
||||
expect(args.take).toBe(DELIVERY_RETRY_ALL_LIMIT);
|
||||
expect(args.where).toEqual({ status: 'FAILED' });
|
||||
expect(args.orderBy).toEqual([{ createdAt: 'desc' }, { id: 'desc' }]);
|
||||
});
|
||||
|
||||
it('retryFailed reports 0 when the re-check after the lock finds nothing FAILED', async () => {
|
||||
tx.notificationDelivery.findMany.mockResolvedValue([{ id: 'a' }]);
|
||||
tx.$queryRaw.mockResolvedValueOnce([]);
|
||||
await expect(service.retryFailed('user-1')).resolves.toEqual({
|
||||
retried: 0,
|
||||
});
|
||||
expect(lockRows).toHaveBeenCalled();
|
||||
expect(audit.record).not.toHaveBeenCalled();
|
||||
expect(tx.$executeRaw).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,254 @@
|
||||
import {
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
NotificationCategory,
|
||||
NotificationDeliveryStatus,
|
||||
} from '../../generated/prisma';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { notifyWorker, WAKE_CHANNELS } from '../common/worker-wakeup';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { lockRowForUpdate, lockRowsForUpdate } from '../prisma/row-lock';
|
||||
import {
|
||||
telegramBotAvailable,
|
||||
telegramConfig,
|
||||
} from '../telegram/telegram-config';
|
||||
|
||||
export interface TelegramStatusView {
|
||||
/**
|
||||
* The bot name is set. The API pod never has the token, so it cannot know
|
||||
* whether sending works: that runs only in the worker, when the secret exists.
|
||||
*/
|
||||
botNameSet: boolean;
|
||||
botUsername: string | null;
|
||||
/** Whether updates are received shows only the poll state of the worker. */
|
||||
receiver: {
|
||||
leaseOwner: string | null;
|
||||
lastPollAgeSeconds: number | null;
|
||||
lastError: string | null;
|
||||
};
|
||||
sender: {
|
||||
pending: number;
|
||||
oldestPendingAgeSeconds: number | null;
|
||||
failed24h: number;
|
||||
lastErrors: { statusCode: number | null; error: string; createdAt: Date }[];
|
||||
};
|
||||
}
|
||||
|
||||
/** A delivery for the System page - never the chat id and never the text. */
|
||||
export interface NotificationDeliveryView {
|
||||
id: string;
|
||||
status: NotificationDeliveryStatus;
|
||||
category: NotificationCategory | null;
|
||||
templateCode: string | null;
|
||||
attempts: number;
|
||||
availableAt: Date;
|
||||
lastStatusCode: number | null;
|
||||
lastError: string | null;
|
||||
createdAt: Date;
|
||||
deliveredAt: Date | null;
|
||||
}
|
||||
|
||||
export interface NotificationDeliveriesOverview {
|
||||
telegram: TelegramStatusView;
|
||||
deliveries: NotificationDeliveryView[];
|
||||
}
|
||||
|
||||
/** Most deliveries one list or one "retry all" call handles. */
|
||||
export const DELIVERY_LIST_LIMIT = 100;
|
||||
export const DELIVERY_RETRY_ALL_LIMIT = 100;
|
||||
const LAST_ERRORS_LIMIT = 5;
|
||||
|
||||
interface PollRow {
|
||||
leaseOwner: string | null;
|
||||
lastPollAgeSeconds: number | null;
|
||||
lastError: string | null;
|
||||
}
|
||||
|
||||
interface SenderRow {
|
||||
pending: number;
|
||||
oldestPendingAgeSeconds: number | null;
|
||||
failed24h: number;
|
||||
}
|
||||
|
||||
interface RetriedRow {
|
||||
id: string;
|
||||
attempts: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read model and retry actions of the Telegram card on the System page. All
|
||||
* ages are computed by the database: the API pod's clock may differ.
|
||||
*/
|
||||
@Injectable()
|
||||
export class NotificationDeliveriesService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly audit: AuditLogService,
|
||||
) {}
|
||||
|
||||
async overview(
|
||||
status?: NotificationDeliveryStatus,
|
||||
): Promise<NotificationDeliveriesOverview> {
|
||||
const [[poll], [sender], errors, rows] = await Promise.all([
|
||||
// The lease owner counts only while its lease runs; no row = never polled.
|
||||
this.prisma.$queryRaw<PollRow[]>`
|
||||
SELECT
|
||||
CASE WHEN "leaseUntil" > now() THEN "leaseOwner" END AS "leaseOwner",
|
||||
EXTRACT(EPOCH FROM now() - "lastPollAt")::float8 AS "lastPollAgeSeconds",
|
||||
"lastError"
|
||||
FROM "TelegramPollState"
|
||||
WHERE "id" = 'singleton'`,
|
||||
// Oldest pending = the longest overdue one; a delivery held back until
|
||||
// the end of the quiet hours is not late yet.
|
||||
this.prisma.$queryRaw<SenderRow[]>`
|
||||
SELECT
|
||||
count(*) FILTER (WHERE "status" = 'PENDING')::int AS "pending",
|
||||
EXTRACT(EPOCH FROM now() - MIN("availableAt") FILTER (WHERE "status" = 'PENDING' AND "availableAt" <= now()))::float8 AS "oldestPendingAgeSeconds",
|
||||
count(*) FILTER (WHERE "status" = 'FAILED' AND "createdAt" > now() - interval '24 hours')::int AS "failed24h"
|
||||
FROM "NotificationDelivery"`,
|
||||
this.prisma.$queryRaw<
|
||||
{ lastStatusCode: number | null; lastError: string; createdAt: Date }[]
|
||||
>`
|
||||
SELECT "lastStatusCode", "lastError", "createdAt"
|
||||
FROM "NotificationDelivery"
|
||||
WHERE "lastError" IS NOT NULL
|
||||
ORDER BY "createdAt" DESC, "id" DESC
|
||||
LIMIT ${LAST_ERRORS_LIMIT}`,
|
||||
this.prisma.notificationDelivery.findMany({
|
||||
where: status ? { status } : {},
|
||||
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
||||
take: DELIVERY_LIST_LIMIT,
|
||||
// Never chatId, userId or a text: only what the System page shows.
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
templateCode: true,
|
||||
attempts: true,
|
||||
availableAt: true,
|
||||
lastStatusCode: true,
|
||||
lastError: true,
|
||||
createdAt: true,
|
||||
deliveredAt: true,
|
||||
notification: { select: { category: true } },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
telegram: {
|
||||
botNameSet: telegramBotAvailable(),
|
||||
botUsername: telegramConfig().botUsername,
|
||||
receiver: {
|
||||
leaseOwner: poll?.leaseOwner ?? null,
|
||||
lastPollAgeSeconds: poll?.lastPollAgeSeconds ?? null,
|
||||
lastError: poll?.lastError ?? null,
|
||||
},
|
||||
sender: {
|
||||
pending: sender?.pending ?? 0,
|
||||
oldestPendingAgeSeconds: sender?.oldestPendingAgeSeconds ?? null,
|
||||
failed24h: sender?.failed24h ?? 0,
|
||||
lastErrors: errors.map((row) => ({
|
||||
statusCode: row.lastStatusCode,
|
||||
error: row.lastError,
|
||||
createdAt: row.createdAt,
|
||||
})),
|
||||
},
|
||||
},
|
||||
deliveries: rows.map(({ notification, ...delivery }) => ({
|
||||
...delivery,
|
||||
category: notification?.category ?? null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-queues one FAILED delivery: lock, re-read, then PENDING with
|
||||
* attempts 0 and availableAt from the database clock; audit in the same
|
||||
* transaction, then wake the worker. 404 unknown, 409 not FAILED.
|
||||
*/
|
||||
async retry(id: string, actingUserId: string): Promise<void> {
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await lockRowForUpdate(tx, 'NotificationDelivery', id);
|
||||
const before = await tx.notificationDelivery.findUnique({
|
||||
where: { id },
|
||||
select: { status: true, attempts: true },
|
||||
});
|
||||
if (!before) {
|
||||
throw new NotFoundException('Notification delivery not found');
|
||||
}
|
||||
if (before.status !== 'FAILED') {
|
||||
throw new ConflictException('Notification delivery is not FAILED');
|
||||
}
|
||||
await tx.$executeRaw`
|
||||
UPDATE "NotificationDelivery"
|
||||
SET "status" = 'PENDING', "attempts" = 0, "availableAt" = now(),
|
||||
"claimedAt" = NULL, "lastStatusCode" = NULL, "lastError" = NULL
|
||||
WHERE "id" = ${id}`;
|
||||
await this.audit.record(
|
||||
{
|
||||
entityType: 'NotificationDelivery',
|
||||
entityId: id,
|
||||
action: 'UPDATE',
|
||||
userId: actingUserId,
|
||||
beforeData: { status: 'FAILED', attempts: before.attempts },
|
||||
afterData: { status: 'PENDING', attempts: 0 },
|
||||
},
|
||||
tx,
|
||||
);
|
||||
await notifyWorker(tx, WAKE_CHANNELS.notificationDeliveries);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-queues the newest FAILED deliveries (at most DELIVERY_RETRY_ALL_LIMIT
|
||||
* per call; call again for more).
|
||||
*/
|
||||
async retryFailed(actingUserId: string): Promise<{ retried: number }> {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const candidates = await tx.notificationDelivery.findMany({
|
||||
where: { status: 'FAILED' },
|
||||
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
||||
take: DELIVERY_RETRY_ALL_LIMIT,
|
||||
select: { id: true },
|
||||
});
|
||||
const ids = candidates.map((row) => row.id);
|
||||
if (ids.length === 0) {
|
||||
return { retried: 0 };
|
||||
}
|
||||
await lockRowsForUpdate(tx, 'NotificationDelivery', ids);
|
||||
// The status is checked again after the lock: a row retried or sent
|
||||
// meanwhile is left alone.
|
||||
const retried = await tx.$queryRaw<RetriedRow[]>`
|
||||
WITH old AS (
|
||||
SELECT "id", "attempts" FROM "NotificationDelivery"
|
||||
WHERE "id" = ANY(${ids}) AND "status" = 'FAILED'
|
||||
)
|
||||
UPDATE "NotificationDelivery" d
|
||||
SET "status" = 'PENDING', "attempts" = 0, "availableAt" = now(),
|
||||
"claimedAt" = NULL, "lastStatusCode" = NULL, "lastError" = NULL
|
||||
FROM old
|
||||
WHERE d."id" = old."id"
|
||||
RETURNING d."id" AS "id", old."attempts" AS "attempts"`;
|
||||
for (const row of retried) {
|
||||
await this.audit.record(
|
||||
{
|
||||
entityType: 'NotificationDelivery',
|
||||
entityId: row.id,
|
||||
action: 'UPDATE',
|
||||
userId: actingUserId,
|
||||
beforeData: { status: 'FAILED', attempts: row.attempts },
|
||||
afterData: { status: 'PENDING', attempts: 0 },
|
||||
},
|
||||
tx,
|
||||
);
|
||||
}
|
||||
if (retried.length > 0) {
|
||||
await notifyWorker(tx, WAKE_CHANNELS.notificationDeliveries);
|
||||
}
|
||||
return { retried: retried.length };
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,11 @@ import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
|
||||
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
|
||||
import { NotificationDeliveriesQueryDto } from './dto/notification-deliveries.query';
|
||||
import {
|
||||
NotificationDeliveriesOverview,
|
||||
NotificationDeliveriesService,
|
||||
} from './notification-deliveries.service';
|
||||
import {
|
||||
JobRunListQueryDto,
|
||||
SYSTEM_LIST_DEFAULT_LIMIT,
|
||||
@@ -28,7 +33,10 @@ import {
|
||||
@ApiBearerAuth()
|
||||
@Controller('system')
|
||||
export class SystemController {
|
||||
constructor(private readonly queue: SystemQueueService) {}
|
||||
constructor(
|
||||
private readonly queue: SystemQueueService,
|
||||
private readonly deliveries: NotificationDeliveriesService,
|
||||
) {}
|
||||
|
||||
@RequirePermission('system:read')
|
||||
@Get('overview')
|
||||
@@ -84,4 +92,32 @@ export class SystemController {
|
||||
): Promise<void> {
|
||||
return this.queue.retryWebhookDelivery(id, actor.id);
|
||||
}
|
||||
|
||||
@RequirePermission('system:manage')
|
||||
@Get('notification-deliveries')
|
||||
notificationDeliveries(
|
||||
@Query() query: NotificationDeliveriesQueryDto,
|
||||
): Promise<NotificationDeliveriesOverview> {
|
||||
return this.deliveries.overview(query.status);
|
||||
}
|
||||
|
||||
// Declared before ':id/retry' so that "retry-failed" is never read as an id.
|
||||
@RequirePermission('system:manage')
|
||||
@Post('notification-deliveries/retry-failed')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
retryFailedNotificationDeliveries(
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
): Promise<{ retried: number }> {
|
||||
return this.deliveries.retryFailed(actor.id);
|
||||
}
|
||||
|
||||
@RequirePermission('system:manage')
|
||||
@Post('notification-deliveries/:id/retry')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
retryNotificationDelivery(
|
||||
@Param('id') id: string,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
): Promise<void> {
|
||||
return this.deliveries.retry(id, actor.id);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { JobRunService } from './job-run.service';
|
||||
import { NotificationDeliveriesService } from './notification-deliveries.service';
|
||||
import { SystemController } from './system.controller';
|
||||
import { SystemQueueService } from './system-queue.service';
|
||||
|
||||
@@ -11,7 +12,7 @@ import { SystemQueueService } from './system-queue.service';
|
||||
@Module({
|
||||
imports: [NotificationsModule],
|
||||
controllers: [SystemController],
|
||||
providers: [JobRunService, SystemQueueService],
|
||||
providers: [JobRunService, SystemQueueService, NotificationDeliveriesService],
|
||||
exports: [JobRunService],
|
||||
})
|
||||
export class SystemModule {}
|
||||
@@ -286,7 +286,7 @@ describe('TelegramClient', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const updates = await client.getUpdates(100n);
|
||||
const { updates, highestUpdateId } = await client.getUpdates(100n);
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toBe(`https://api.telegram.org/bot${TOKEN}/getUpdates`);
|
||||
@@ -295,6 +295,8 @@ describe('TelegramClient', () => {
|
||||
timeout: 25,
|
||||
allowed_updates: ['message'],
|
||||
});
|
||||
// The dropped update 101 is not in the list, but 102 is the highest.
|
||||
expect(highestUpdateId).toBe(102n);
|
||||
expect(updates).toEqual([
|
||||
{
|
||||
updateId: 100n,
|
||||
@@ -313,9 +315,29 @@ describe('TelegramClient', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('reports the highest update_id even when every update was dropped', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
jsonResponse({
|
||||
ok: true,
|
||||
result: [
|
||||
{ update_id: 7, edited_message: {} },
|
||||
{ update_id: 9, callback_query: {} },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(client.getUpdates(7n)).resolves.toEqual({
|
||||
updates: [],
|
||||
highestUpdateId: 9n,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns an empty list when nothing arrived', async () => {
|
||||
fetchMock.mockResolvedValue(jsonResponse({ ok: true, result: [] }));
|
||||
await expect(client.getUpdates(0n)).resolves.toEqual([]);
|
||||
await expect(client.getUpdates(0n)).resolves.toEqual({
|
||||
updates: [],
|
||||
highestUpdateId: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,6 +27,17 @@ export interface TelegramUpdate {
|
||||
firstName: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* What one getUpdates call returned: the updates this code understands and
|
||||
* the highest raw update_id of the answer, also of updates that were dropped
|
||||
* (no chat, no id of the right type). The receiver advances the offset past
|
||||
* it, so a dropped update is never fetched again.
|
||||
*/
|
||||
export interface TelegramUpdateBatch {
|
||||
updates: TelegramUpdate[];
|
||||
highestUpdateId: bigint | null;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const CONTROL_CHARACTERS = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g;
|
||||
// Zero-width characters, bidi marks, overrides and isolates: invisible, and
|
||||
@@ -104,6 +115,17 @@ function retryAfterOf(parsed: JsonObject): number | null {
|
||||
);
|
||||
}
|
||||
|
||||
function highestRawUpdateId(raw: unknown[]): bigint | null {
|
||||
let highest: bigint | null = null;
|
||||
for (const entry of raw) {
|
||||
const id = asInteger(asObject(entry)?.update_id);
|
||||
if (id !== null && (highest === null || id > highest)) {
|
||||
highest = id;
|
||||
}
|
||||
}
|
||||
return highest;
|
||||
}
|
||||
|
||||
function parseUpdate(raw: unknown): TelegramUpdate | null {
|
||||
const update = asObject(raw);
|
||||
const updateId = asInteger(update?.update_id);
|
||||
@@ -166,7 +188,7 @@ export class TelegramClient {
|
||||
async getUpdates(
|
||||
offset: bigint,
|
||||
signal?: AbortSignal,
|
||||
): Promise<TelegramUpdate[]> {
|
||||
): Promise<TelegramUpdateBatch> {
|
||||
const result = await this.call(
|
||||
'getUpdates',
|
||||
{
|
||||
@@ -180,9 +202,12 @@ export class TelegramClient {
|
||||
if (!Array.isArray(result)) {
|
||||
throw new TelegramApiError('getUpdates', null, INVALID_RESPONSE, null);
|
||||
}
|
||||
return result
|
||||
.map(parseUpdate)
|
||||
.filter((update): update is TelegramUpdate => update !== null);
|
||||
return {
|
||||
updates: result
|
||||
.map(parseUpdate)
|
||||
.filter((update): update is TelegramUpdate => update !== null),
|
||||
highestUpdateId: highestRawUpdateId(result),
|
||||
};
|
||||
}
|
||||
|
||||
private jsonNumber(method: string, value: bigint): number {
|
||||
|
||||
@@ -0,0 +1,435 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { ConflictException } from '@nestjs/common';
|
||||
import type { NotificationPreferencesService } from '../notifications/notification-preferences.service';
|
||||
import type { NotificationsService } from '../notifications/notifications.service';
|
||||
import type { PrismaService } from '../prisma/prisma.service';
|
||||
import * as rowLock from '../prisma/row-lock';
|
||||
import {
|
||||
LINK_TOKEN_BYTES,
|
||||
LINK_TOKEN_TTL_MS,
|
||||
TelegramLinkService,
|
||||
} from './telegram-link.service';
|
||||
|
||||
jest.mock('../prisma/row-lock');
|
||||
|
||||
const lockRowsForUpdate = jest.mocked(rowLock.lockRowsForUpdate);
|
||||
|
||||
const CHAT = 4_711_000_001n;
|
||||
const TOKEN = 'A'.repeat(43);
|
||||
const TOKEN_HASH = createHash('sha256').update(TOKEN).digest('hex');
|
||||
|
||||
interface FakeTx {
|
||||
$queryRaw: jest.Mock;
|
||||
$executeRaw: jest.Mock;
|
||||
telegramLinkToken: { create: jest.Mock; findUnique: jest.Mock };
|
||||
telegramLink: {
|
||||
findUnique: jest.Mock;
|
||||
delete: jest.Mock;
|
||||
deleteMany: jest.Mock;
|
||||
create: jest.Mock;
|
||||
};
|
||||
notificationPreference: { updateMany: jest.Mock };
|
||||
notificationDelivery: { create: jest.Mock; updateMany: jest.Mock };
|
||||
user: { findFirst: jest.Mock };
|
||||
}
|
||||
|
||||
/** Flattens a tagged-template call to its SQL text and bound values. */
|
||||
function sqlOf(call: unknown[]): { text: string; values: unknown[] } {
|
||||
const [first, ...values] = call as [unknown, ...unknown[]];
|
||||
if (Array.isArray(first)) {
|
||||
return { text: (first as string[]).join('?'), values };
|
||||
}
|
||||
const sql = first as { strings: string[]; values: unknown[] };
|
||||
return { text: sql.strings.join('?'), values: sql.values };
|
||||
}
|
||||
|
||||
/** The arguments of one call of a mock (negative: counted from the end). */
|
||||
function callOf(mock: jest.Mock, index: number): unknown[] {
|
||||
const calls = mock.mock.calls as unknown[][];
|
||||
return calls.at(index) ?? [];
|
||||
}
|
||||
|
||||
describe('TelegramLinkService', () => {
|
||||
let tx: FakeTx;
|
||||
let prisma: { $transaction: jest.Mock };
|
||||
let preferences: { getOrCreate: jest.Mock };
|
||||
let notifications: { record: jest.Mock };
|
||||
let service: TelegramLinkService;
|
||||
const originalUsername = process.env.TELEGRAM_BOT_USERNAME;
|
||||
const originalToken = process.env.TELEGRAM_BOT_TOKEN;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
process.env.TELEGRAM_BOT_USERNAME = 'apicore_test_bot';
|
||||
delete process.env.TELEGRAM_BOT_TOKEN;
|
||||
tx = {
|
||||
$queryRaw: jest.fn().mockResolvedValue([]),
|
||||
$executeRaw: jest.fn().mockResolvedValue(1),
|
||||
telegramLinkToken: {
|
||||
create: jest.fn().mockResolvedValue({ id: 'tok-1' }),
|
||||
findUnique: jest.fn().mockResolvedValue(null),
|
||||
},
|
||||
telegramLink: {
|
||||
findUnique: jest.fn().mockResolvedValue(null),
|
||||
delete: jest.fn().mockResolvedValue({}),
|
||||
deleteMany: jest.fn().mockResolvedValue({ count: 1 }),
|
||||
create: jest.fn().mockResolvedValue({}),
|
||||
},
|
||||
notificationPreference: {
|
||||
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
|
||||
},
|
||||
notificationDelivery: {
|
||||
create: jest.fn().mockResolvedValue({}),
|
||||
updateMany: jest.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
user: { findFirst: jest.fn().mockResolvedValue({ id: 'u1' }) },
|
||||
};
|
||||
prisma = {
|
||||
$transaction: jest
|
||||
.fn()
|
||||
.mockImplementation((work: (t: unknown) => unknown) =>
|
||||
Promise.resolve(work(tx)),
|
||||
),
|
||||
};
|
||||
preferences = { getOrCreate: jest.fn().mockResolvedValue({}) };
|
||||
notifications = { record: jest.fn().mockResolvedValue({}) };
|
||||
service = new TelegramLinkService(
|
||||
prisma as unknown as PrismaService,
|
||||
preferences as unknown as NotificationPreferencesService,
|
||||
notifications as unknown as NotificationsService,
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (originalUsername === undefined) {
|
||||
delete process.env.TELEGRAM_BOT_USERNAME;
|
||||
} else {
|
||||
process.env.TELEGRAM_BOT_USERNAME = originalUsername;
|
||||
}
|
||||
if (originalToken !== undefined) {
|
||||
process.env.TELEGRAM_BOT_TOKEN = originalToken;
|
||||
}
|
||||
});
|
||||
|
||||
describe('createLinkToken', () => {
|
||||
beforeEach(() => {
|
||||
tx.$queryRaw.mockResolvedValue([
|
||||
{ expiresAt: new Date('2026-10-14T10:10:00.000Z') },
|
||||
]);
|
||||
});
|
||||
|
||||
it('creates a 43-character base64url token, stores only its SHA-256 and invalidates older open tokens', async () => {
|
||||
const result = await service.createLinkToken('u1');
|
||||
|
||||
const prefix = 'https://t.me/apicore_test_bot?start=';
|
||||
expect(result.deepLink.startsWith(prefix)).toBe(true);
|
||||
const token = result.deepLink.slice(prefix.length);
|
||||
expect(LINK_TOKEN_BYTES).toBe(32);
|
||||
expect(token).toMatch(/^[A-Za-z0-9_-]{43}$/);
|
||||
expect(result.expiresAt).toEqual(new Date('2026-10-14T10:10:00.000Z'));
|
||||
|
||||
const stored = callOf(tx.telegramLinkToken.create, 0)[0] as {
|
||||
data: { userId: string; tokenHash: string };
|
||||
};
|
||||
expect(stored.data.tokenHash).toBe(
|
||||
createHash('sha256').update(token).digest('hex'),
|
||||
);
|
||||
expect(JSON.stringify(stored)).not.toContain(token);
|
||||
// Older open tokens end in the same transaction, before the new one.
|
||||
const invalidate = sqlOf(callOf(tx.$executeRaw, 0));
|
||||
expect(invalidate.text).toMatch(/"consumedAt" = now\(\)/);
|
||||
expect(invalidate.text).toMatch(/"consumedAt" IS NULL/);
|
||||
expect(invalidate.values).toEqual(['u1']);
|
||||
expect(tx.$executeRaw.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
tx.telegramLinkToken.create.mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
|
||||
it('sets the expiry with the database clock, ten minutes ahead', async () => {
|
||||
await service.createLinkToken('u1');
|
||||
|
||||
expect(LINK_TOKEN_TTL_MS).toBe(600_000);
|
||||
const expiry = sqlOf(callOf(tx.$queryRaw, 0));
|
||||
expect(expiry.text).toMatch(/now\(\) \+ make_interval/);
|
||||
expect(expiry.values.some((value) => value instanceof Date)).toBe(false);
|
||||
});
|
||||
|
||||
it('locks the preference row of the user first', async () => {
|
||||
await service.createLinkToken('u1');
|
||||
|
||||
expect(preferences.getOrCreate).toHaveBeenCalledWith('u1', tx);
|
||||
expect(lockRowsForUpdate).toHaveBeenCalledWith(
|
||||
tx,
|
||||
'NotificationPreference',
|
||||
['u1'],
|
||||
);
|
||||
expect(lockRowsForUpdate.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
tx.$executeRaw.mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
|
||||
it('createLinkToken answers 409 only when the bot username is empty, not when the token is unset', async () => {
|
||||
// Token unset (the API pod never has it) but username set: works.
|
||||
await expect(service.createLinkToken('u1')).resolves.toBeDefined();
|
||||
|
||||
process.env.TELEGRAM_BOT_USERNAME = '';
|
||||
await expect(service.createLinkToken('u1')).rejects.toThrow(
|
||||
new ConflictException('Telegram ist nicht eingerichtet.'),
|
||||
);
|
||||
// Even with a valid token: no username, no link.
|
||||
process.env.TELEGRAM_BOT_TOKEN =
|
||||
'123456789:AAEhBOweik6ad9r_QXMENQjcrGbqCr4K-RM';
|
||||
await expect(service.createLinkToken('u1')).rejects.toBeInstanceOf(
|
||||
ConflictException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('linkFromStartIn', () => {
|
||||
/** A valid, unused token of user u1. */
|
||||
function validToken(userId = 'u1'): void {
|
||||
tx.telegramLinkToken.findUnique.mockResolvedValue({ userId });
|
||||
tx.$queryRaw.mockResolvedValue([{ userId }]);
|
||||
}
|
||||
|
||||
it('an expired or used token answers LINK_INVALID without naming an account', async () => {
|
||||
tx.telegramLinkToken.findUnique.mockResolvedValue({ userId: 'u1' });
|
||||
tx.$queryRaw.mockResolvedValue([]); // CAS found nothing: used or expired
|
||||
|
||||
const result = await service.linkFromStartIn(
|
||||
tx as never,
|
||||
TOKEN,
|
||||
CHAT,
|
||||
'Anna',
|
||||
);
|
||||
|
||||
expect(result).toBe('LINK_INVALID');
|
||||
expect(tx.telegramLink.create).not.toHaveBeenCalled();
|
||||
expect(notifications.record).not.toHaveBeenCalled();
|
||||
expect(tx.notificationPreference.updateMany).not.toHaveBeenCalled();
|
||||
const cas = sqlOf(callOf(tx.$queryRaw, 0));
|
||||
expect(cas.text).toMatch(/"consumedAt" IS NULL/);
|
||||
expect(cas.text).toMatch(/"expiresAt" > now\(\)/);
|
||||
expect(cas.text).toMatch(/RETURNING "userId"/);
|
||||
expect(cas.values).toEqual([TOKEN_HASH]);
|
||||
});
|
||||
|
||||
it('an unknown or malformed token answers LINK_INVALID without a lookup of its format', async () => {
|
||||
await expect(
|
||||
service.linkFromStartIn(tx as never, 'short', CHAT, null),
|
||||
).resolves.toBe('LINK_INVALID');
|
||||
tx.telegramLinkToken.findUnique.mockResolvedValue(null);
|
||||
await expect(
|
||||
service.linkFromStartIn(tx as never, TOKEN, CHAT, null),
|
||||
).resolves.toBe('LINK_INVALID');
|
||||
expect(tx.telegramLink.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('a new link shows an in-app notice with the Telegram first name', async () => {
|
||||
validToken();
|
||||
|
||||
const result = await service.linkFromStartIn(
|
||||
tx as never,
|
||||
TOKEN,
|
||||
CHAT,
|
||||
' Anna\u0007 \u202e Beck ',
|
||||
);
|
||||
|
||||
expect(result).toBe('LINKED');
|
||||
expect(tx.telegramLink.create).toHaveBeenCalledWith({
|
||||
data: { userId: 'u1', chatId: CHAT, telegramFirstName: 'Anna Beck' },
|
||||
});
|
||||
expect(tx.notificationPreference.updateMany).toHaveBeenCalledWith({
|
||||
where: { userId: 'u1' },
|
||||
data: { telegramEnabled: true },
|
||||
});
|
||||
const [notice, noticeTx] = notifications.record.mock.calls[0] as [
|
||||
{ userId: string; category: string; message: string; link: string },
|
||||
unknown,
|
||||
];
|
||||
expect(noticeTx).toBe(tx);
|
||||
expect(notice.userId).toBe('u1');
|
||||
expect(notice.category).toBe('SYSTEM');
|
||||
expect(notice.link).toBe('/profile');
|
||||
expect(notice.message).toMatch(
|
||||
/^Telegram verbunden mit „Anna Beck“ am \d{2}\.\d{2}\.\d{4}\. Warst du das nicht\? In den Einstellungen trennen\.$/,
|
||||
);
|
||||
});
|
||||
|
||||
it('cuts a long first name to 64 characters', async () => {
|
||||
validToken();
|
||||
|
||||
await service.linkFromStartIn(tx as never, TOKEN, CHAT, 'x'.repeat(200));
|
||||
|
||||
const data = (
|
||||
callOf(tx.telegramLink.create, 0)[0] as {
|
||||
data: { telegramFirstName: string };
|
||||
}
|
||||
).data;
|
||||
expect(data.telegramFirstName).toHaveLength(64);
|
||||
});
|
||||
|
||||
it('a chat that moves to another user unlinks the previous user with an in-app notice', async () => {
|
||||
validToken('u2');
|
||||
tx.telegramLink.findUnique.mockResolvedValue({
|
||||
userId: 'u1',
|
||||
chatId: CHAT,
|
||||
});
|
||||
|
||||
const result = await service.linkFromStartIn(
|
||||
tx as never,
|
||||
TOKEN,
|
||||
CHAT,
|
||||
'Ben',
|
||||
);
|
||||
|
||||
expect(result).toBe('LINKED');
|
||||
// Both users are locked in one statement, after their rows exist.
|
||||
expect(preferences.getOrCreate).toHaveBeenCalledWith('u1', tx);
|
||||
expect(preferences.getOrCreate).toHaveBeenCalledWith('u2', tx);
|
||||
expect(lockRowsForUpdate).toHaveBeenCalledTimes(1);
|
||||
expect(lockRowsForUpdate).toHaveBeenCalledWith(
|
||||
tx,
|
||||
'NotificationPreference',
|
||||
expect.arrayContaining(['u1', 'u2']),
|
||||
);
|
||||
expect(tx.telegramLink.delete).toHaveBeenCalledWith({
|
||||
where: { userId: 'u1' },
|
||||
});
|
||||
expect(tx.notificationPreference.updateMany).toHaveBeenCalledWith({
|
||||
where: { userId: 'u1' },
|
||||
data: { telegramEnabled: false },
|
||||
});
|
||||
const notices = notifications.record.mock.calls.map(
|
||||
([input]) => input as { userId: string; message: string },
|
||||
);
|
||||
expect(notices.find((notice) => notice.userId === 'u1')?.message).toBe(
|
||||
'Deine Telegram-Verbindung wurde getrennt, weil der Chat mit einem anderen Konto verbunden wurde.',
|
||||
);
|
||||
expect(notices.find((notice) => notice.userId === 'u2')?.message).toMatch(
|
||||
/^Telegram verbunden mit „Ben“/,
|
||||
);
|
||||
// The old link goes before the new one: the chat id is unique.
|
||||
expect(tx.telegramLink.delete.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
tx.telegramLink.create.mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
|
||||
it('reads the link of the chat before and after taking the locks and refuses a chat that changed owner', async () => {
|
||||
validToken('u2');
|
||||
tx.telegramLink.findUnique
|
||||
.mockResolvedValueOnce(null) // peek: free
|
||||
.mockResolvedValueOnce({ userId: 'u9', chatId: CHAT }); // after lock: taken
|
||||
|
||||
await expect(
|
||||
service.linkFromStartIn(tx as never, TOKEN, CHAT, 'Ben'),
|
||||
).rejects.toThrow('changed owner');
|
||||
expect(tx.telegramLink.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('an inactive user answers LINK_INVALID', async () => {
|
||||
validToken();
|
||||
tx.user.findFirst.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.linkFromStartIn(tx as never, TOKEN, CHAT, 'Anna'),
|
||||
).resolves.toBe('LINK_INVALID');
|
||||
expect(tx.telegramLink.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('locks first, reads the link afterwards', async () => {
|
||||
validToken();
|
||||
|
||||
await service.linkFromStartIn(tx as never, TOKEN, CHAT, 'Anna');
|
||||
|
||||
const lockOrder = lockRowsForUpdate.mock.invocationCallOrder[0];
|
||||
const casOrder = tx.$queryRaw.mock.invocationCallOrder[0];
|
||||
expect(lockOrder).toBeLessThan(casOrder);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stopIn / unlink', () => {
|
||||
it('stopIn deletes the link of the chat and switches Telegram off under the preference lock', async () => {
|
||||
tx.telegramLink.findUnique.mockResolvedValue({
|
||||
userId: 'u1',
|
||||
chatId: CHAT,
|
||||
});
|
||||
|
||||
await service.stopIn(tx as never, CHAT);
|
||||
|
||||
expect(lockRowsForUpdate).toHaveBeenCalledWith(
|
||||
tx,
|
||||
'NotificationPreference',
|
||||
['u1'],
|
||||
);
|
||||
expect(tx.telegramLink.delete).toHaveBeenCalledWith({
|
||||
where: { userId: 'u1' },
|
||||
});
|
||||
expect(tx.notificationPreference.updateMany).toHaveBeenCalledWith({
|
||||
where: { userId: 'u1' },
|
||||
data: { telegramEnabled: false },
|
||||
});
|
||||
});
|
||||
|
||||
it('stopIn for an unknown chat changes nothing', async () => {
|
||||
await service.stopIn(tx as never, CHAT);
|
||||
|
||||
expect(tx.telegramLink.delete).not.toHaveBeenCalled();
|
||||
expect(tx.notificationPreference.updateMany).not.toHaveBeenCalled();
|
||||
expect(lockRowsForUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('unlink deletes the link, switches Telegram off and queues STOPPED for the chat', async () => {
|
||||
tx.telegramLink.findUnique.mockResolvedValue({
|
||||
userId: 'u1',
|
||||
chatId: CHAT,
|
||||
});
|
||||
|
||||
await service.unlink('u1');
|
||||
|
||||
expect(preferences.getOrCreate).toHaveBeenCalledWith('u1', tx);
|
||||
expect(lockRowsForUpdate).toHaveBeenCalledWith(
|
||||
tx,
|
||||
'NotificationPreference',
|
||||
['u1'],
|
||||
);
|
||||
expect(tx.telegramLink.delete).toHaveBeenCalledWith({
|
||||
where: { userId: 'u1' },
|
||||
});
|
||||
expect(tx.notificationPreference.updateMany).toHaveBeenCalledWith({
|
||||
where: { userId: 'u1' },
|
||||
data: { telegramEnabled: false },
|
||||
});
|
||||
expect(tx.notificationDelivery.create).toHaveBeenCalledWith({
|
||||
data: { channel: 'TELEGRAM', chatId: CHAT, templateCode: 'STOPPED' },
|
||||
});
|
||||
});
|
||||
|
||||
it('every disconnect marks the pending Telegram deliveries of the user SKIPPED', async () => {
|
||||
tx.telegramLink.findUnique.mockResolvedValue({
|
||||
userId: 'u1',
|
||||
chatId: CHAT,
|
||||
});
|
||||
|
||||
await service.unlink('u1');
|
||||
await service.stopIn(tx as never, CHAT);
|
||||
|
||||
expect(tx.notificationDelivery.updateMany).toHaveBeenCalledTimes(2);
|
||||
expect(tx.notificationDelivery.updateMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
userId: 'u1',
|
||||
channel: 'TELEGRAM',
|
||||
status: { in: ['PENDING', 'PROCESSING'] },
|
||||
},
|
||||
data: { status: 'SKIPPED', lastError: 'unlinked' },
|
||||
});
|
||||
});
|
||||
|
||||
it('unlink without a link is a no-op', async () => {
|
||||
await service.unlink('u1');
|
||||
|
||||
expect(tx.telegramLink.delete).not.toHaveBeenCalled();
|
||||
expect(tx.notificationDelivery.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,277 @@
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import { ConflictException, Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '../../generated/prisma';
|
||||
import { berlinToday } from '../common/dates';
|
||||
import { notifyWorker, WAKE_CHANNELS } from '../common/worker-wakeup';
|
||||
import { assertTransactionClient } from '../prisma/db-client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { lockRowsForUpdate } from '../prisma/row-lock';
|
||||
import { NotificationPreferencesService } from '../notifications/notification-preferences.service';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { telegramConfig } from './telegram-config';
|
||||
import type { TelegramTemplateCode } from './telegram-texts';
|
||||
|
||||
export const LINK_TOKEN_TTL_MS = 600_000;
|
||||
export const LINK_TOKEN_BYTES = 32;
|
||||
|
||||
/** 32 random bytes as base64url. */
|
||||
const LINK_TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/;
|
||||
const MAX_FIRST_NAME_CHARS = 64;
|
||||
const NOT_CONFIGURED_MESSAGE = 'Telegram ist nicht eingerichtet.';
|
||||
const UNLINKED_NOTICE =
|
||||
'Deine Telegram-Verbindung wurde getrennt, weil der Chat mit einem anderen Konto verbunden wurde.';
|
||||
const PROFILE_LINK = '/profile';
|
||||
const UNKNOWN_FIRST_NAME = 'Telegram';
|
||||
|
||||
export type LinkResult = 'LINKED' | 'LINK_INVALID';
|
||||
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f]/g;
|
||||
const INVISIBLE_CHARACTERS = /[\u200b-\u200f\u202a-\u202e\u2066-\u2069]/g;
|
||||
|
||||
function hashToken(token: string): string {
|
||||
return createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* The first name Telegram reports: trimmed, no control or invisible
|
||||
* characters, whitespace collapsed, at most 64 characters (by code point).
|
||||
* It ends up in a notice of the app, so nothing else may ride along.
|
||||
*/
|
||||
function sanitizeFirstName(name: string | null): string | null {
|
||||
if (name === null) {
|
||||
return null;
|
||||
}
|
||||
const clean = name
|
||||
.replace(CONTROL_CHARACTERS, ' ')
|
||||
.replace(INVISIBLE_CHARACTERS, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
const characters = [...clean].slice(0, MAX_FIRST_NAME_CHARS).join('').trim();
|
||||
return characters === '' ? null : characters;
|
||||
}
|
||||
|
||||
/** 2026-10-14 -> 14.10.2026 */
|
||||
function germanDate(day: string): string {
|
||||
const [year, month, date] = day.split('-');
|
||||
return `${date}.${month}.${year}`;
|
||||
}
|
||||
|
||||
function uniqueIds(ids: readonly (string | undefined)[]): string[] {
|
||||
return [...new Set(ids.filter((id): id is string => id !== undefined))];
|
||||
}
|
||||
|
||||
/**
|
||||
* Connects and disconnects a user's private Telegram chat (Spec 9a.5).
|
||||
* The one-time link token lives 10 minutes and is stored only as SHA-256.
|
||||
* Every change of a connection takes the preference row of each user it
|
||||
* touches first (the sender's 403 path does the same), in one statement in
|
||||
* ascending key order, and reads link and preference only afterwards.
|
||||
* Never logs a token, a link code or a chat id.
|
||||
*/
|
||||
@Injectable()
|
||||
export class TelegramLinkService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly preferences: NotificationPreferencesService,
|
||||
private readonly notifications: NotificationsService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* A fresh link token for the user; older open tokens end. 409 exactly
|
||||
* when no bot username is configured: the API pod never has the token,
|
||||
* so it cannot be what "set up" means here.
|
||||
*/
|
||||
async createLinkToken(
|
||||
userId: string,
|
||||
): Promise<{ deepLink: string; expiresAt: Date }> {
|
||||
const { botUsername } = telegramConfig();
|
||||
if (botUsername === null) {
|
||||
throw new ConflictException(NOT_CONFIGURED_MESSAGE);
|
||||
}
|
||||
const token = randomBytes(LINK_TOKEN_BYTES).toString('base64url');
|
||||
const expiresAt = await this.prisma.$transaction(async (tx) => {
|
||||
await this.lockPreferences(tx, [userId]);
|
||||
await tx.$executeRaw`UPDATE "TelegramLinkToken" SET "consumedAt" = now() WHERE "userId" = ${userId} AND "consumedAt" IS NULL`;
|
||||
const { id } = await tx.telegramLinkToken.create({
|
||||
data: { userId, tokenHash: hashToken(token), expiresAt: new Date() },
|
||||
select: { id: true },
|
||||
});
|
||||
// The expiry comes from the database clock, like every comparison.
|
||||
const [row] = await tx.$queryRaw<{ expiresAt: Date }[]>`
|
||||
UPDATE "TelegramLinkToken"
|
||||
SET "expiresAt" = now() + make_interval(secs => ${LINK_TOKEN_TTL_MS / 1000}::double precision)
|
||||
WHERE "id" = ${id}
|
||||
RETURNING "expiresAt"`;
|
||||
return row.expiresAt;
|
||||
});
|
||||
return {
|
||||
deepLink: `https://t.me/${botUsername}?start=${token}`,
|
||||
expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
/** Disconnects in the app: the link goes, Telegram is off, the chat hears STOPPED. */
|
||||
async unlink(userId: string): Promise<void> {
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await this.lockPreferences(tx, [userId]);
|
||||
const link = await tx.telegramLink.findUnique({ where: { userId } });
|
||||
if (!link) {
|
||||
return;
|
||||
}
|
||||
await this.removeLinkIn(tx, link.userId);
|
||||
await this.queueReplyIn(tx, link.chatId, 'STOPPED');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* /start <token> from a private chat (receiver transaction). Consumes the
|
||||
* token by compare-and-set, replaces the link of the user, takes the chat
|
||||
* from a previous user if needed and tells the app. Anything unusable
|
||||
* answers LINK_INVALID, never naming an account.
|
||||
*/
|
||||
async linkFromStartIn(
|
||||
tx: Prisma.TransactionClient,
|
||||
token: string,
|
||||
chatId: bigint,
|
||||
firstName: string | null,
|
||||
): Promise<LinkResult> {
|
||||
assertTransactionClient(tx, 'TelegramLinkService.linkFromStartIn');
|
||||
if (!LINK_TOKEN_PATTERN.test(token)) {
|
||||
return 'LINK_INVALID';
|
||||
}
|
||||
const tokenHash = hashToken(token);
|
||||
const candidate = await tx.telegramLinkToken.findUnique({
|
||||
where: { tokenHash },
|
||||
select: { userId: true },
|
||||
});
|
||||
if (!candidate) {
|
||||
return 'LINK_INVALID';
|
||||
}
|
||||
const peeked = await tx.telegramLink.findUnique({
|
||||
where: { chatId },
|
||||
select: { userId: true },
|
||||
});
|
||||
const userId = candidate.userId;
|
||||
await this.lockPreferences(tx, uniqueIds([userId, peeked?.userId]));
|
||||
// Everything below reads after the locks.
|
||||
const [consumed] = await tx.$queryRaw<{ userId: string }[]>`
|
||||
UPDATE "TelegramLinkToken" SET "consumedAt" = now()
|
||||
WHERE "tokenHash" = ${tokenHash} AND "consumedAt" IS NULL AND "expiresAt" > now()
|
||||
RETURNING "userId"`;
|
||||
if (!consumed) {
|
||||
return 'LINK_INVALID';
|
||||
}
|
||||
const owner = await tx.telegramLink.findUnique({ where: { chatId } });
|
||||
if (owner && owner.userId !== userId && owner.userId !== peeked?.userId) {
|
||||
// Not locked: the transaction fails and the update is handled again.
|
||||
throw new Error('Telegram chat changed owner while linking');
|
||||
}
|
||||
const active = await tx.user.findFirst({
|
||||
where: { id: userId, isActive: true, role: { status: 'ACTIVE' } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!active) {
|
||||
return 'LINK_INVALID';
|
||||
}
|
||||
if (owner && owner.userId !== userId) {
|
||||
await this.removeLinkIn(tx, owner.userId);
|
||||
await this.notifications.record(
|
||||
{
|
||||
userId: owner.userId,
|
||||
priority: 'HINWEIS',
|
||||
category: 'SYSTEM',
|
||||
message: UNLINKED_NOTICE,
|
||||
link: PROFILE_LINK,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
}
|
||||
const name = sanitizeFirstName(firstName);
|
||||
// The user's own earlier chat is replaced; linkedAt starts again.
|
||||
await tx.telegramLink.deleteMany({ where: { userId } });
|
||||
await tx.telegramLink.create({
|
||||
data: { userId, chatId, telegramFirstName: name },
|
||||
});
|
||||
await tx.notificationPreference.updateMany({
|
||||
where: { userId },
|
||||
data: { telegramEnabled: true },
|
||||
});
|
||||
// The notice that exposes a link someone else used (Spec 9a.5).
|
||||
await this.notifications.record(
|
||||
{
|
||||
userId,
|
||||
priority: 'HINWEIS',
|
||||
category: 'SYSTEM',
|
||||
message: `Telegram verbunden mit „${name ?? UNKNOWN_FIRST_NAME}“ am ${germanDate(berlinToday())}. Warst du das nicht? In den Einstellungen trennen.`,
|
||||
link: PROFILE_LINK,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
return 'LINKED';
|
||||
}
|
||||
|
||||
/** /stop from a chat (receiver transaction): link gone, Telegram off. */
|
||||
async stopIn(tx: Prisma.TransactionClient, chatId: bigint): Promise<void> {
|
||||
assertTransactionClient(tx, 'TelegramLinkService.stopIn');
|
||||
const peeked = await tx.telegramLink.findUnique({
|
||||
where: { chatId },
|
||||
select: { userId: true },
|
||||
});
|
||||
if (!peeked) {
|
||||
return;
|
||||
}
|
||||
await this.lockPreferences(tx, [peeked.userId]);
|
||||
const link = await tx.telegramLink.findUnique({
|
||||
where: { chatId },
|
||||
select: { userId: true },
|
||||
});
|
||||
if (link) {
|
||||
await this.removeLinkIn(tx, link.userId);
|
||||
}
|
||||
}
|
||||
|
||||
/** Queues a bot reply to a chat (it ignores quiet hours) and wakes the sender. */
|
||||
async queueReplyIn(
|
||||
tx: Prisma.TransactionClient,
|
||||
chatId: bigint,
|
||||
code: TelegramTemplateCode,
|
||||
): Promise<void> {
|
||||
assertTransactionClient(tx, 'TelegramLinkService.queueReplyIn');
|
||||
await tx.notificationDelivery.create({
|
||||
data: { channel: 'TELEGRAM', chatId, templateCode: code },
|
||||
});
|
||||
await notifyWorker(tx, WAKE_CHANNELS.notificationDeliveries);
|
||||
}
|
||||
|
||||
private async removeLinkIn(
|
||||
tx: Prisma.TransactionClient,
|
||||
userId: string,
|
||||
): Promise<void> {
|
||||
await tx.telegramLink.delete({ where: { userId } });
|
||||
await tx.notificationPreference.updateMany({
|
||||
where: { userId },
|
||||
data: { telegramEnabled: false },
|
||||
});
|
||||
// Whatever was queued for the old chat must never reach a chat linked later.
|
||||
await tx.notificationDelivery.updateMany({
|
||||
where: {
|
||||
userId,
|
||||
channel: 'TELEGRAM',
|
||||
status: { in: ['PENDING', 'PROCESSING'] },
|
||||
},
|
||||
data: { status: 'SKIPPED', lastError: 'unlinked' },
|
||||
});
|
||||
}
|
||||
|
||||
/** Rows must exist, then one statement locks them all in key order. */
|
||||
private async lockPreferences(
|
||||
tx: Prisma.TransactionClient,
|
||||
userIds: readonly string[],
|
||||
): Promise<void> {
|
||||
for (const userId of userIds) {
|
||||
await this.preferences.getOrCreate(userId, tx);
|
||||
}
|
||||
await lockRowsForUpdate(tx, 'NotificationPreference', userIds);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,557 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import type { PrismaService } from '../prisma/prisma.service';
|
||||
import { TelegramApiError } from './telegram-api-error';
|
||||
import type {
|
||||
TelegramClient,
|
||||
TelegramUpdate,
|
||||
TelegramUpdateBatch,
|
||||
} from './telegram-client';
|
||||
import type { TelegramLinkService } from './telegram-link.service';
|
||||
import {
|
||||
HELP_REPLIES_PER_CHAT,
|
||||
MAX_UPDATE_ATTEMPTS,
|
||||
MAX_UPDATE_RETRY_MS,
|
||||
SKIPPED_UPDATE_TEXT,
|
||||
HELP_WINDOW_MS,
|
||||
LEASE_SECONDS,
|
||||
NO_LEASE_WAIT_MS,
|
||||
TelegramUpdatesService,
|
||||
} from './telegram-updates.service';
|
||||
|
||||
const TOKEN = 'B'.repeat(43);
|
||||
const PRIVATE_CHAT = 5_000_000_001n;
|
||||
const GROUP_CHAT = -1_000_000_000_001n;
|
||||
|
||||
function update(
|
||||
updateId: number,
|
||||
text: string | null,
|
||||
options: { chatId?: bigint; chatType?: string; firstName?: string } = {},
|
||||
): TelegramUpdate {
|
||||
return {
|
||||
updateId: BigInt(updateId),
|
||||
chatId: options.chatId ?? PRIVATE_CHAT,
|
||||
chatType: options.chatType ?? 'private',
|
||||
text,
|
||||
firstName: options.firstName ?? 'Anna',
|
||||
};
|
||||
}
|
||||
|
||||
/** Flattens a tagged-template call to its SQL text and bound values. */
|
||||
function sqlOf(call: unknown[]): { text: string; values: unknown[] } {
|
||||
const [first, ...values] = call as [unknown, ...unknown[]];
|
||||
if (Array.isArray(first)) {
|
||||
return { text: (first as string[]).join('?'), values };
|
||||
}
|
||||
const sql = first as { strings: string[]; values: unknown[] };
|
||||
return { text: sql.strings.join('?'), values: sql.values };
|
||||
}
|
||||
|
||||
/** The arguments of one call of a mock (negative: counted from the end). */
|
||||
function callOf(mock: jest.Mock, index: number): unknown[] {
|
||||
const calls = mock.mock.calls as unknown[][];
|
||||
return calls.at(index) ?? [];
|
||||
}
|
||||
|
||||
describe('TelegramUpdatesService', () => {
|
||||
let prisma: {
|
||||
$queryRaw: jest.Mock;
|
||||
$executeRaw: jest.Mock;
|
||||
$transaction: jest.Mock;
|
||||
};
|
||||
let tx: { $executeRaw: jest.Mock };
|
||||
let client: { isConfigured: jest.Mock; getUpdates: jest.Mock };
|
||||
let links: {
|
||||
linkFromStartIn: jest.Mock;
|
||||
stopIn: jest.Mock;
|
||||
queueReplyIn: jest.Mock;
|
||||
};
|
||||
let service: TelegramUpdatesService;
|
||||
let signal: AbortSignal;
|
||||
|
||||
/** The lease is free and the stored offset is `offset`. */
|
||||
function leaseGranted(offset: bigint | null = 7n): void {
|
||||
prisma.$queryRaw.mockResolvedValue(
|
||||
offset === null ? [] : [{ updateOffset: offset }],
|
||||
);
|
||||
}
|
||||
|
||||
/** What the next getUpdates calls return; the highest id counts too. */
|
||||
function giveUpdates(updates: TelegramUpdate[], highest?: number): void {
|
||||
const ids = updates.map((entry) => entry.updateId);
|
||||
const batch: TelegramUpdateBatch = {
|
||||
updates,
|
||||
highestUpdateId:
|
||||
highest !== undefined
|
||||
? BigInt(highest)
|
||||
: ids.length > 0
|
||||
? ids.reduce((a, b) => (a > b ? a : b))
|
||||
: null,
|
||||
};
|
||||
client.getUpdates.mockResolvedValue(batch);
|
||||
}
|
||||
|
||||
const replies = (): string[] =>
|
||||
(links.queueReplyIn.mock.calls as unknown[][]).map(
|
||||
(call) => call[2] as string,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||
signal = new AbortController().signal;
|
||||
tx = { $executeRaw: jest.fn().mockResolvedValue(1) };
|
||||
prisma = {
|
||||
$queryRaw: jest.fn().mockResolvedValue([]),
|
||||
$executeRaw: jest.fn().mockResolvedValue(1),
|
||||
$transaction: jest
|
||||
.fn()
|
||||
.mockImplementation((work: (t: unknown) => unknown) =>
|
||||
Promise.resolve(work(tx)),
|
||||
),
|
||||
};
|
||||
client = {
|
||||
isConfigured: jest.fn().mockReturnValue(true),
|
||||
getUpdates: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ updates: [], highestUpdateId: null }),
|
||||
};
|
||||
links = {
|
||||
linkFromStartIn: jest.fn().mockResolvedValue('LINKED'),
|
||||
stopIn: jest.fn().mockResolvedValue(undefined),
|
||||
queueReplyIn: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
service = new TelegramUpdatesService(
|
||||
prisma as unknown as PrismaService,
|
||||
client as unknown as TelegramClient,
|
||||
links as unknown as TelegramLinkService,
|
||||
);
|
||||
leaseGranted();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('exposes the documented constants', () => {
|
||||
expect(LEASE_SECONDS).toBe(60);
|
||||
expect(NO_LEASE_WAIT_MS).toBe(30_000);
|
||||
expect(HELP_REPLIES_PER_CHAT).toBe(3);
|
||||
expect(HELP_WINDOW_MS).toBe(600_000);
|
||||
});
|
||||
|
||||
it('does nothing without a configured bot', async () => {
|
||||
client.isConfigured.mockReturnValue(false);
|
||||
|
||||
await expect(service.pollOnce(signal)).resolves.toBe('disabled');
|
||||
expect(prisma.$queryRaw).not.toHaveBeenCalled();
|
||||
expect(client.getUpdates).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('takes the lease with the database clock only', async () => {
|
||||
await service.pollOnce(signal);
|
||||
|
||||
const lease = sqlOf(callOf(prisma.$queryRaw, 0));
|
||||
expect(lease.text).toMatch(/"leaseUntil" = now\(\) \+ make_interval/);
|
||||
expect(lease.text).toMatch(/"id" = 'singleton'/);
|
||||
expect(lease.text).toMatch(
|
||||
/\("leaseUntil" IS NULL OR "leaseUntil" < now\(\) OR "leaseOwner" = /,
|
||||
);
|
||||
expect(lease.text).toMatch(/RETURNING "updateOffset"/);
|
||||
// Owner and lease seconds are the only parameters: no Date, no clock.
|
||||
expect(lease.values).toHaveLength(3);
|
||||
expect(lease.values.some((value) => value instanceof Date)).toBe(false);
|
||||
expect(lease.values).toContain(LEASE_SECONDS);
|
||||
});
|
||||
|
||||
it('waits without the lease and does not call Telegram', async () => {
|
||||
leaseGranted(null);
|
||||
|
||||
await expect(service.pollOnce(signal)).resolves.toBe('no-lease');
|
||||
expect(client.getUpdates).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('asks Telegram from the stored offset and reports polled', async () => {
|
||||
leaseGranted(42n);
|
||||
|
||||
await expect(service.pollOnce(signal)).resolves.toBe('polled');
|
||||
expect(client.getUpdates).toHaveBeenCalledWith(42n, signal);
|
||||
// lastPollAt is written with the database clock, lastError is cleared
|
||||
// only after the batch was applied.
|
||||
const stamp = sqlOf(callOf(prisma.$executeRaw, 0));
|
||||
expect(stamp.text).toMatch(/"lastPollAt" = now\(\)/);
|
||||
expect(stamp.text).not.toMatch(/lastError/);
|
||||
const cleared = sqlOf(callOf(prisma.$executeRaw, 1));
|
||||
expect(cleared.text).toMatch(/"lastError" = NULL/);
|
||||
});
|
||||
|
||||
it('handles /start, /stop and /hilfe and ignores groups', async () => {
|
||||
giveUpdates([
|
||||
update(10, `/start ${TOKEN}`, { firstName: 'Anna' }),
|
||||
update(11, '/stop'),
|
||||
update(12, '/hilfe'),
|
||||
update(13, '/help'),
|
||||
update(14, 'hallo'),
|
||||
update(15, '/start'),
|
||||
update(16, `/start ${TOKEN}`, {
|
||||
chatId: GROUP_CHAT,
|
||||
chatType: 'group',
|
||||
}),
|
||||
update(17, '/stop', { chatId: GROUP_CHAT, chatType: 'supergroup' }),
|
||||
]);
|
||||
links.linkFromStartIn.mockResolvedValueOnce('LINKED');
|
||||
|
||||
await expect(service.pollOnce(signal)).resolves.toBe('polled');
|
||||
|
||||
expect(links.linkFromStartIn).toHaveBeenCalledTimes(1);
|
||||
expect(links.linkFromStartIn).toHaveBeenCalledWith(
|
||||
tx,
|
||||
TOKEN,
|
||||
PRIVATE_CHAT,
|
||||
'Anna',
|
||||
);
|
||||
expect(links.stopIn).toHaveBeenCalledTimes(1);
|
||||
expect(links.stopIn).toHaveBeenCalledWith(tx, PRIVATE_CHAT);
|
||||
// LINKED, STOPPED, then HELP for /hilfe, /help (3rd reply of the
|
||||
// window) - the next ones stay silent; groups get nothing at all.
|
||||
expect(replies()).toEqual(['LINKED', 'STOPPED', 'HELP', 'HELP']);
|
||||
expect(
|
||||
(links.queueReplyIn.mock.calls as unknown[][]).every(
|
||||
(call) => call[1] === PRIVATE_CHAT,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('answers a start with an unusable token with LINK_INVALID and /start@bot with a token like /start', async () => {
|
||||
links.linkFromStartIn.mockResolvedValue('LINK_INVALID');
|
||||
giveUpdates([update(10, `/start@apicore_test_bot ${TOKEN}`)]);
|
||||
|
||||
await service.pollOnce(signal);
|
||||
|
||||
expect(links.linkFromStartIn).toHaveBeenCalledWith(
|
||||
tx,
|
||||
TOKEN,
|
||||
PRIVATE_CHAT,
|
||||
'Anna',
|
||||
);
|
||||
expect(replies()).toEqual(['LINK_INVALID']);
|
||||
});
|
||||
|
||||
it('answers at most 3 times per chat in 10 minutes', async () => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
giveUpdates([20, 21, 22, 23, 24].map((id) => update(id, 'hallo')));
|
||||
await service.pollOnce(signal);
|
||||
expect(replies()).toEqual(['HELP', 'HELP', 'HELP']);
|
||||
|
||||
// Another chat has its own count.
|
||||
links.queueReplyIn.mockClear();
|
||||
giveUpdates([update(25, 'hallo', { chatId: 5_000_000_002n })]);
|
||||
await service.pollOnce(signal);
|
||||
expect(replies()).toEqual(['HELP']);
|
||||
|
||||
// After the window the chat is answered again.
|
||||
links.queueReplyIn.mockClear();
|
||||
jest.advanceTimersByTime(HELP_WINDOW_MS + 1);
|
||||
giveUpdates([update(26, 'hallo')]);
|
||||
await service.pollOnce(signal);
|
||||
expect(replies()).toEqual(['HELP']);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('moves the offset in the same transaction as the effect', async () => {
|
||||
giveUpdates([update(10, '/stop'), update(11, 'x')]);
|
||||
leaseGranted(10n);
|
||||
|
||||
await service.pollOnce(signal);
|
||||
|
||||
expect(prisma.$transaction).toHaveBeenCalledTimes(2);
|
||||
// Per update: offset first, then the effect, both on the same tx.
|
||||
const offsets = (tx.$executeRaw.mock.calls as unknown[][]).map((call) =>
|
||||
sqlOf(call),
|
||||
);
|
||||
expect(offsets).toHaveLength(2);
|
||||
expect(offsets[0].values).toEqual(expect.arrayContaining([11n, 10n]));
|
||||
expect(offsets[1].values).toEqual(expect.arrayContaining([12n, 11n]));
|
||||
expect(tx.$executeRaw.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
links.stopIn.mock.invocationCallOrder[0],
|
||||
);
|
||||
expect(links.stopIn).toHaveBeenCalledWith(tx, PRIVATE_CHAT);
|
||||
});
|
||||
|
||||
it('the offset advance is a compare-and-set on leaseOwner and the old offset; a lost lease rolls the effect back', async () => {
|
||||
giveUpdates([update(10, '/stop'), update(11, 'x')]);
|
||||
leaseGranted(10n);
|
||||
|
||||
await service.pollOnce(signal);
|
||||
|
||||
const cas = sqlOf(callOf(tx.$executeRaw, 0));
|
||||
expect(cas.text).toMatch(/UPDATE "TelegramPollState"/);
|
||||
expect(cas.text).toMatch(/SET "updateOffset" = /);
|
||||
expect(cas.text).toMatch(/"id" = 'singleton'/);
|
||||
expect(cas.text).toMatch(/"leaseOwner" = /);
|
||||
expect(cas.text).toMatch(/"leaseUntil" > now\(\)/);
|
||||
expect(cas.text).toMatch(/"updateOffset" = /);
|
||||
expect(cas.values.some((value) => value instanceof Date)).toBe(false);
|
||||
|
||||
// Lease lost (count 0): the transaction throws - effect and offset roll
|
||||
// back together - and the cycle ends; the rest is not touched.
|
||||
tx.$executeRaw.mockReset().mockResolvedValue(0);
|
||||
links.stopIn.mockClear();
|
||||
links.queueReplyIn.mockClear();
|
||||
prisma.$transaction.mockClear();
|
||||
|
||||
await expect(service.pollOnce(signal)).resolves.toBe('no-lease');
|
||||
expect(links.stopIn).not.toHaveBeenCalled();
|
||||
expect(links.queueReplyIn).not.toHaveBeenCalled();
|
||||
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('a failing effect keeps the offset and ends the cycle with error', async () => {
|
||||
giveUpdates([update(10, '/stop'), update(11, 'x')]);
|
||||
links.stopIn.mockRejectedValue(new Error('db down'));
|
||||
|
||||
await expect(service.pollOnce(signal)).resolves.toBe('error');
|
||||
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
|
||||
// The failure is recorded for the system page, without ids.
|
||||
const failure = sqlOf(callOf(prisma.$executeRaw, -1));
|
||||
expect(failure.text).toMatch(/"lastError" = /);
|
||||
expect(JSON.stringify(failure.values)).not.toContain('5000000001');
|
||||
// Fixed text plus the error class: never the message of the error.
|
||||
expect(failure.values).toContain('update not handled: Error');
|
||||
expect(JSON.stringify(failure.values)).not.toContain('db down');
|
||||
});
|
||||
|
||||
it('answers conflict on 409 and stores the description as lastError', async () => {
|
||||
client.getUpdates.mockRejectedValue(
|
||||
new TelegramApiError(
|
||||
'getUpdates',
|
||||
409,
|
||||
'Conflict: terminated by other getUpdates request',
|
||||
null,
|
||||
),
|
||||
);
|
||||
|
||||
await expect(service.pollOnce(signal)).resolves.toBe('conflict');
|
||||
const stored = sqlOf(callOf(prisma.$executeRaw, 0));
|
||||
expect(stored.text).toMatch(/"lastError" = /);
|
||||
// Fixed text plus the error class, not the description from Telegram.
|
||||
expect(stored.values).toContain(
|
||||
'getUpdates conflict (409): TelegramApiError',
|
||||
);
|
||||
expect(JSON.stringify(stored.values)).not.toContain('terminated');
|
||||
});
|
||||
|
||||
it('answers error on other failures and never stores a token', async () => {
|
||||
client.getUpdates.mockRejectedValue(
|
||||
new TelegramApiError('getUpdates', null, 'timeout', null),
|
||||
);
|
||||
|
||||
await expect(service.pollOnce(signal)).resolves.toBe('error');
|
||||
expect(JSON.stringify(prisma.$executeRaw.mock.calls)).not.toMatch(
|
||||
/\d{5,}:[A-Za-z0-9_-]{20,}/,
|
||||
);
|
||||
});
|
||||
|
||||
it('an aborted poll (shutdown) records no error and hands the lease back', async () => {
|
||||
const controller = new AbortController();
|
||||
client.getUpdates.mockImplementation(() => {
|
||||
controller.abort();
|
||||
return Promise.reject(
|
||||
new TelegramApiError('getUpdates', null, 'aborted', null),
|
||||
);
|
||||
});
|
||||
|
||||
await expect(service.pollOnce(controller.signal)).resolves.toBe('error');
|
||||
|
||||
// Only the release: no lastError, no lastPollAt.
|
||||
expect(prisma.$executeRaw).toHaveBeenCalledTimes(1);
|
||||
const release = sqlOf(callOf(prisma.$executeRaw, 0));
|
||||
expect(release.text).toMatch(/SET "leaseUntil" = now\(\)/);
|
||||
expect(release.text).toMatch(/"leaseOwner" = /);
|
||||
expect(release.text).not.toMatch(/lastError/);
|
||||
});
|
||||
|
||||
it('does not touch the lease on shutdown when it never held it', async () => {
|
||||
leaseGranted(null);
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
|
||||
await service.pollOnce(controller.signal);
|
||||
|
||||
expect(prisma.$executeRaw).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('poison updates', () => {
|
||||
it('exposes the documented limits', () => {
|
||||
expect(MAX_UPDATE_ATTEMPTS).toBe(5);
|
||||
expect(MAX_UPDATE_RETRY_MS).toBe(600_000);
|
||||
expect(SKIPPED_UPDATE_TEXT).toBe('Telegram-Nachricht übersprungen');
|
||||
});
|
||||
|
||||
it('retries a failing update and skips it after 5 attempts in its own transaction', async () => {
|
||||
const warn = jest.spyOn(Logger.prototype, 'warn');
|
||||
giveUpdates([update(10, '/stop'), update(11, 'x')]);
|
||||
links.stopIn.mockRejectedValue(new Error('private text of Anna'));
|
||||
|
||||
for (let attempt = 1; attempt < MAX_UPDATE_ATTEMPTS; attempt++) {
|
||||
await expect(service.pollOnce(signal)).resolves.toBe('error');
|
||||
}
|
||||
expect(links.stopIn).toHaveBeenCalledTimes(MAX_UPDATE_ATTEMPTS - 1);
|
||||
tx.$executeRaw.mockClear();
|
||||
prisma.$executeRaw.mockClear();
|
||||
|
||||
// The fifth failure skips: offset past it, fixed lastError, the next one runs.
|
||||
await expect(service.pollOnce(signal)).resolves.toBe('polled');
|
||||
|
||||
const statements = (tx.$executeRaw.mock.calls as unknown[][]).map(
|
||||
(call) => sqlOf(call),
|
||||
);
|
||||
const skipOffset = statements.find(
|
||||
(statement) =>
|
||||
/SET "updateOffset"/.test(statement.text) &&
|
||||
statement.values.includes(11n),
|
||||
);
|
||||
expect(skipOffset).toBeDefined();
|
||||
expect(
|
||||
statements.some((statement) =>
|
||||
statement.values.includes(SKIPPED_UPDATE_TEXT),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'Telegram update skipped after repeated failures',
|
||||
);
|
||||
// No id and no text anywhere in the log.
|
||||
expect(JSON.stringify(warn.mock.calls)).not.toMatch(/10|Anna/);
|
||||
// The following update was handled, and the skip keeps its lastError.
|
||||
expect(replies()).toContain('HELP');
|
||||
expect(
|
||||
(prisma.$executeRaw.mock.calls as unknown[][]).some((call) =>
|
||||
sqlOf(call).text.includes('"lastError" = NULL'),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('skips after the time limit even with fewer attempts', async () => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
giveUpdates([update(10, '/stop')]);
|
||||
links.stopIn.mockRejectedValue(new Error('boom'));
|
||||
await expect(service.pollOnce(signal)).resolves.toBe('error');
|
||||
|
||||
jest.advanceTimersByTime(MAX_UPDATE_RETRY_MS + 1);
|
||||
|
||||
await expect(service.pollOnce(signal)).resolves.toBe('polled');
|
||||
expect(links.stopIn).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('a lost lease is no failure of the update', async () => {
|
||||
giveUpdates([update(10, '/stop')]);
|
||||
tx.$executeRaw.mockResolvedValue(0);
|
||||
|
||||
for (let attempt = 0; attempt < MAX_UPDATE_ATTEMPTS + 2; attempt++) {
|
||||
await expect(service.pollOnce(signal)).resolves.toBe('no-lease');
|
||||
}
|
||||
expect(links.stopIn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('moves the offset past a batch of only unparseable updates', async () => {
|
||||
leaseGranted(7n);
|
||||
giveUpdates([], 12);
|
||||
|
||||
await expect(service.pollOnce(signal)).resolves.toBe('polled');
|
||||
|
||||
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
|
||||
const cas = sqlOf(callOf(tx.$executeRaw, 0));
|
||||
expect(cas.text).toMatch(/SET "updateOffset"/);
|
||||
expect(cas.values).toEqual(expect.arrayContaining([13n, 7n]));
|
||||
expect(links.queueReplyIn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('moves the offset past dropped updates behind the last handled one, once', async () => {
|
||||
leaseGranted(10n);
|
||||
giveUpdates([update(10, 'x')], 14);
|
||||
|
||||
await service.pollOnce(signal);
|
||||
|
||||
const offsets = (tx.$executeRaw.mock.calls as unknown[][]).map((call) =>
|
||||
sqlOf(call),
|
||||
);
|
||||
expect(offsets).toHaveLength(2);
|
||||
expect(offsets[0].values).toEqual(expect.arrayContaining([11n, 10n]));
|
||||
expect(offsets[1].values).toEqual(expect.arrayContaining([15n, 11n]));
|
||||
});
|
||||
|
||||
it('counts the reply quota only after the transaction committed', async () => {
|
||||
giveUpdates([update(10, 'hallo')]);
|
||||
// The commit fails after the reply was queued.
|
||||
prisma.$transaction.mockImplementationOnce(
|
||||
async (work: (t: unknown) => Promise<unknown>) => {
|
||||
await work(tx);
|
||||
throw new Error('commit failed');
|
||||
},
|
||||
);
|
||||
await service.pollOnce(signal);
|
||||
links.queueReplyIn.mockClear();
|
||||
|
||||
// Three helps are still allowed: the rolled-back one was not counted.
|
||||
for (const id of [11, 12, 13, 14]) {
|
||||
leaseGranted(BigInt(id));
|
||||
giveUpdates([update(id, 'hallo')]);
|
||||
await service.pollOnce(signal);
|
||||
}
|
||||
expect(replies()).toEqual(['HELP', 'HELP', 'HELP']);
|
||||
});
|
||||
|
||||
describe('429 from Telegram', () => {
|
||||
it('stays away until retry_after has passed', async () => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
client.getUpdates.mockRejectedValueOnce(
|
||||
new TelegramApiError('getUpdates', 429, 'Too Many Requests', 120),
|
||||
);
|
||||
await expect(service.pollOnce(signal)).resolves.toBe('error');
|
||||
const stored = sqlOf(callOf(prisma.$executeRaw, 0));
|
||||
expect(stored.values).toContain(
|
||||
'getUpdates rate limited (429): TelegramApiError',
|
||||
);
|
||||
|
||||
// Cycles inside the pause do not even ask for the lease.
|
||||
prisma.$queryRaw.mockClear();
|
||||
client.getUpdates.mockClear();
|
||||
jest.advanceTimersByTime(119_000);
|
||||
await expect(service.pollOnce(signal)).resolves.toBe('error');
|
||||
expect(prisma.$queryRaw).not.toHaveBeenCalled();
|
||||
expect(client.getUpdates).not.toHaveBeenCalled();
|
||||
|
||||
jest.advanceTimersByTime(1_001);
|
||||
await expect(service.pollOnce(signal)).resolves.toBe('polled');
|
||||
expect(client.getUpdates).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('caps the pause at one hour', async () => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
client.getUpdates.mockRejectedValueOnce(
|
||||
new TelegramApiError('getUpdates', 429, 'x', 86_400),
|
||||
);
|
||||
await service.pollOnce(signal);
|
||||
|
||||
jest.advanceTimersByTime(3_600_001);
|
||||
client.getUpdates.mockClear();
|
||||
await expect(service.pollOnce(signal)).resolves.toBe('polled');
|
||||
expect(client.getUpdates).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,446 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Prisma } from '../../generated/prisma';
|
||||
import { loggableError } from '../common/loggable-error';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { TelegramApiError } from './telegram-api-error';
|
||||
import {
|
||||
TelegramClient,
|
||||
TelegramUpdate,
|
||||
TelegramUpdateBatch,
|
||||
} from './telegram-client';
|
||||
import { TelegramLinkService } from './telegram-link.service';
|
||||
import type { TelegramTemplateCode } from './telegram-texts';
|
||||
|
||||
export const LEASE_SECONDS = 60;
|
||||
/** Loop interval: also how long a receiver without the lease (or after a 409) waits. */
|
||||
export const NO_LEASE_WAIT_MS = 30_000;
|
||||
export const HELP_REPLIES_PER_CHAT = 3;
|
||||
export const HELP_WINDOW_MS = 600_000;
|
||||
|
||||
/** An update that keeps failing is skipped after this many attempts or this long. */
|
||||
export const MAX_UPDATE_ATTEMPTS = 5;
|
||||
export const MAX_UPDATE_RETRY_MS = 600_000;
|
||||
const MAX_RATE_LIMIT_PAUSE_MS = 3_600_000;
|
||||
/** Fixed texts for the system page: no ids, no message text. */
|
||||
export const SKIPPED_UPDATE_TEXT = 'Telegram-Nachricht übersprungen';
|
||||
const MAX_LAST_ERROR_CHARS = 300;
|
||||
const START_COMMAND = /^\/([A-Za-z]+)(?:@[A-Za-z0-9_]+)?(?:\s+(\S+))?/;
|
||||
|
||||
export type PollOutcome =
|
||||
'disabled' | 'no-lease' | 'polled' | 'conflict' | 'error';
|
||||
|
||||
type Command =
|
||||
{ name: 'start'; token: string | null } | { name: 'stop' } | { name: 'help' };
|
||||
|
||||
/** The lease or the stored offset changed: nothing of this update may stay. */
|
||||
class LeaseLostError extends Error {
|
||||
constructor() {
|
||||
super('Telegram poll lease lost');
|
||||
this.name = 'LeaseLostError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What may go to a log or lastError: Prisma errors by name, code and model
|
||||
* (loggableError), every other error by its class name only - a message can
|
||||
* quote user data.
|
||||
*/
|
||||
function describeError(error: unknown): string {
|
||||
if (
|
||||
error instanceof Prisma.PrismaClientKnownRequestError ||
|
||||
error instanceof Prisma.PrismaClientUnknownRequestError ||
|
||||
error instanceof Prisma.PrismaClientValidationError ||
|
||||
error instanceof Prisma.PrismaClientInitializationError ||
|
||||
error instanceof Prisma.PrismaClientRustPanicError
|
||||
) {
|
||||
return loggableError(error).message;
|
||||
}
|
||||
return error instanceof Error ? error.name : 'unknown error';
|
||||
}
|
||||
|
||||
function parseCommand(text: string | null): Command {
|
||||
const match = START_COMMAND.exec(text?.trim() ?? '');
|
||||
const name = match?.[1].toLowerCase();
|
||||
if (name === 'start') {
|
||||
return { name: 'start', token: match?.[2] ?? null };
|
||||
}
|
||||
return name === 'stop' ? { name: 'stop' } : { name: 'help' };
|
||||
}
|
||||
|
||||
/**
|
||||
* At most HELP_REPLIES_PER_CHAT replies per chat in HELP_WINDOW_MS, counted
|
||||
* in memory (there is exactly one receiver, the lease sees to that). A
|
||||
* stranger writing to the bot cannot make it talk without end. Monotonic
|
||||
* clock, so a clock step changes nothing.
|
||||
*/
|
||||
class ReplyLimiter {
|
||||
private readonly sentAt = new Map<string, number[]>();
|
||||
|
||||
/** Would one more reply to this chat be within the limit? Counts nothing. */
|
||||
allows(chatId: bigint): boolean {
|
||||
this.prune();
|
||||
return (
|
||||
(this.sentAt.get(String(chatId))?.length ?? 0) < HELP_REPLIES_PER_CHAT
|
||||
);
|
||||
}
|
||||
|
||||
/** Counts a reply that was queued and committed. */
|
||||
record(chatId: bigint): void {
|
||||
const key = String(chatId);
|
||||
this.sentAt.set(key, [...(this.sentAt.get(key) ?? []), performance.now()]);
|
||||
}
|
||||
|
||||
private prune(): void {
|
||||
const now = performance.now();
|
||||
for (const [chat, times] of this.sentAt) {
|
||||
const recent = times.filter((time) => now - time < HELP_WINDOW_MS);
|
||||
if (recent.length === 0) {
|
||||
this.sentAt.delete(chat);
|
||||
} else {
|
||||
this.sentAt.set(chat, recent);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Receives what people write to the bot (Spec 9a.5, 9a.7): long polling
|
||||
* getUpdates in the worker, no public endpoint. Exactly one receiver per
|
||||
* bot: the lease in TelegramPollState (database clock only) says who. Each
|
||||
* update runs in its own transaction together with the offset advance, which
|
||||
* is a compare-and-set on lease owner and old offset - a receiver that lost
|
||||
* the lease changes nothing, so every update takes effect once. Replies are
|
||||
* queued as deliveries (the sender talks to Telegram, not this class).
|
||||
* Never logs a token, a link code, a chat id or a message text.
|
||||
*/
|
||||
@Injectable()
|
||||
export class TelegramUpdatesService {
|
||||
private readonly logger = new Logger(TelegramUpdatesService.name);
|
||||
/** Names this receiver in the lease. */
|
||||
private readonly instanceId = randomUUID();
|
||||
private readonly replies = new ReplyLimiter();
|
||||
private holdsLease = false;
|
||||
/** Monotonic time until which Telegram asked us to stay away (429). */
|
||||
private rateLimitedUntil = 0;
|
||||
/** Failed attempts per update, in memory: a restart gives them a fresh start. */
|
||||
private readonly failures = new Map<
|
||||
bigint,
|
||||
{ attempts: number; firstAt: number }
|
||||
>();
|
||||
/** Test seam: after how many attempts / how long a failing update is skipped. */
|
||||
retryLimits = {
|
||||
attempts: MAX_UPDATE_ATTEMPTS,
|
||||
windowMs: MAX_UPDATE_RETRY_MS,
|
||||
};
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly client: TelegramClient,
|
||||
private readonly links: TelegramLinkService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* One cycle: take or extend the lease, fetch updates (long poll), handle
|
||||
* them one by one. 'polled' means the next cycle may start at once. A
|
||||
* cycle that ends because of shutdown hands the lease back.
|
||||
*/
|
||||
async pollOnce(signal: AbortSignal): Promise<PollOutcome> {
|
||||
try {
|
||||
return await this.cycle(signal);
|
||||
} finally {
|
||||
if (signal.aborted && this.holdsLease) {
|
||||
await this.releaseLease();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async cycle(signal: AbortSignal): Promise<PollOutcome> {
|
||||
if (!this.client.isConfigured()) {
|
||||
return 'disabled';
|
||||
}
|
||||
if (signal.aborted || performance.now() < this.rateLimitedUntil) {
|
||||
return 'error';
|
||||
}
|
||||
const offset = await this.acquireLease();
|
||||
if (offset === null) {
|
||||
return 'no-lease';
|
||||
}
|
||||
let batch: TelegramUpdateBatch;
|
||||
try {
|
||||
batch = await this.client.getUpdates(offset, signal);
|
||||
} catch (error: unknown) {
|
||||
return this.onFetchError(error, signal);
|
||||
}
|
||||
await this.markPolled();
|
||||
return this.applyBatch(batch, offset, signal);
|
||||
}
|
||||
|
||||
/** The updates in order, then the offset past dropped ones; a clean batch clears lastError. */
|
||||
private async applyBatch(
|
||||
batch: TelegramUpdateBatch,
|
||||
offset: bigint,
|
||||
signal: AbortSignal,
|
||||
): Promise<PollOutcome> {
|
||||
let current = offset;
|
||||
let skipped = false;
|
||||
for (const update of batch.updates) {
|
||||
if (signal.aborted) {
|
||||
return 'polled';
|
||||
}
|
||||
try {
|
||||
const reply = await this.prisma.$transaction((tx) =>
|
||||
this.applyUpdate(tx, update, current),
|
||||
);
|
||||
// Counted only now: a rolled-back reply was never queued.
|
||||
if (reply !== null) {
|
||||
this.replies.record(reply);
|
||||
}
|
||||
this.failures.delete(update.updateId);
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof LeaseLostError) {
|
||||
return 'no-lease';
|
||||
}
|
||||
if (!(await this.shouldSkip(update, error))) {
|
||||
return 'error';
|
||||
}
|
||||
try {
|
||||
await this.skipUpdate(update, current);
|
||||
} catch (skipError: unknown) {
|
||||
return this.onUpdateError(skipError);
|
||||
}
|
||||
skipped = true;
|
||||
}
|
||||
current = update.updateId + 1n;
|
||||
}
|
||||
const dropped = await this.skipDropped(batch, current, signal);
|
||||
if (dropped !== 'ok') {
|
||||
return dropped;
|
||||
}
|
||||
if (!skipped) {
|
||||
await this.clearLastError();
|
||||
}
|
||||
return 'polled';
|
||||
}
|
||||
|
||||
/** Dropped updates (no chat, wrong shape): move the offset past them, never fetch them again. */
|
||||
private async skipDropped(
|
||||
batch: TelegramUpdateBatch,
|
||||
current: bigint,
|
||||
signal: AbortSignal,
|
||||
): Promise<'ok' | PollOutcome> {
|
||||
const highest = batch.highestUpdateId;
|
||||
if (highest === null || highest < current || signal.aborted) {
|
||||
return 'ok';
|
||||
}
|
||||
try {
|
||||
await this.prisma.$transaction((tx) =>
|
||||
this.advanceOffsetIn(tx, current, highest + 1n),
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
return this.onUpdateError(error);
|
||||
}
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
/** Lease SQL, database clock only. The stored offset, or null without the lease. */
|
||||
private async acquireLease(): Promise<bigint | null> {
|
||||
const rows = await this.prisma.$queryRaw<{ updateOffset: bigint }[]>`
|
||||
UPDATE "TelegramPollState"
|
||||
SET "leaseOwner" = ${this.instanceId},
|
||||
"leaseUntil" = now() + make_interval(secs => ${LEASE_SECONDS})
|
||||
WHERE "id" = 'singleton'
|
||||
AND ("leaseUntil" IS NULL OR "leaseUntil" < now() OR "leaseOwner" = ${this.instanceId})
|
||||
RETURNING "updateOffset"`;
|
||||
this.holdsLease = rows.length > 0;
|
||||
return rows[0]?.updateOffset ?? null;
|
||||
}
|
||||
|
||||
/** Shutdown: the next receiver need not wait for the lease to run out. */
|
||||
private async releaseLease(): Promise<void> {
|
||||
this.holdsLease = false;
|
||||
try {
|
||||
await this.prisma.$executeRaw`
|
||||
UPDATE "TelegramPollState"
|
||||
SET "leaseUntil" = now()
|
||||
WHERE "id" = 'singleton' AND "leaseOwner" = ${this.instanceId}`;
|
||||
} catch (error: unknown) {
|
||||
this.logger.warn(`Telegram lease not released: ${describeError(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async markPolled(): Promise<void> {
|
||||
await this.prisma.$executeRaw`
|
||||
UPDATE "TelegramPollState"
|
||||
SET "lastPollAt" = now()
|
||||
WHERE "id" = 'singleton' AND "leaseOwner" = ${this.instanceId}`;
|
||||
}
|
||||
|
||||
private async clearLastError(): Promise<void> {
|
||||
await this.prisma.$executeRaw`
|
||||
UPDATE "TelegramPollState"
|
||||
SET "lastError" = NULL
|
||||
WHERE "id" = 'singleton' AND "leaseOwner" = ${this.instanceId}`;
|
||||
}
|
||||
|
||||
private async setLastError(text: string): Promise<void> {
|
||||
await this.prisma.$executeRaw`
|
||||
UPDATE "TelegramPollState"
|
||||
SET "lastError" = ${text.slice(0, MAX_LAST_ERROR_CHARS)}
|
||||
WHERE "id" = 'singleton' AND "leaseOwner" = ${this.instanceId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 409: another getUpdates or a webhook; wait. 429: stay away until
|
||||
* retry_after has passed (at most an hour). The texts are fixed plus the
|
||||
* error class: nothing from the answer, no token, no URL.
|
||||
*/
|
||||
private async onFetchError(
|
||||
error: unknown,
|
||||
signal: AbortSignal,
|
||||
): Promise<PollOutcome> {
|
||||
if (signal.aborted) {
|
||||
return 'error';
|
||||
}
|
||||
const status = error instanceof TelegramApiError ? error.statusCode : null;
|
||||
if (status === 429) {
|
||||
const seconds =
|
||||
error instanceof TelegramApiError ? error.retryAfterSec : null;
|
||||
this.rateLimitedUntil =
|
||||
performance.now() +
|
||||
Math.min(
|
||||
(seconds ?? NO_LEASE_WAIT_MS / 1_000) * 1_000,
|
||||
MAX_RATE_LIMIT_PAUSE_MS,
|
||||
);
|
||||
}
|
||||
let label = 'getUpdates failed';
|
||||
if (status === 409) {
|
||||
label = 'getUpdates conflict (409)';
|
||||
} else if (status === 429) {
|
||||
label = 'getUpdates rate limited (429)';
|
||||
}
|
||||
await this.setLastError(`${label}: ${describeError(error)}`);
|
||||
return status === 409 ? 'conflict' : 'error';
|
||||
}
|
||||
|
||||
private async onUpdateError(error: unknown): Promise<PollOutcome> {
|
||||
if (error instanceof LeaseLostError) {
|
||||
return 'no-lease';
|
||||
}
|
||||
// The offset stays: the update is tried again in the next cycle.
|
||||
const text = `update not handled: ${describeError(error)}`;
|
||||
this.logger.error(`Telegram ${text}`);
|
||||
await this.setLastError(text);
|
||||
return 'error';
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts a failed attempt of an update; true once it failed too often or
|
||||
* for too long and has to be skipped (otherwise the failure is recorded).
|
||||
*/
|
||||
private async shouldSkip(
|
||||
update: TelegramUpdate,
|
||||
error: unknown,
|
||||
): Promise<boolean> {
|
||||
const now = performance.now();
|
||||
const known = this.failures.get(update.updateId);
|
||||
const state = {
|
||||
attempts: (known?.attempts ?? 0) + 1,
|
||||
firstAt: known?.firstAt ?? now,
|
||||
};
|
||||
this.failures.set(update.updateId, state);
|
||||
const exhausted =
|
||||
state.attempts >= this.retryLimits.attempts ||
|
||||
now - state.firstAt >= this.retryLimits.windowMs;
|
||||
if (!exhausted) {
|
||||
await this.onUpdateError(error);
|
||||
}
|
||||
return exhausted;
|
||||
}
|
||||
|
||||
/** Offset past a poison update in its own transaction, so the queue moves on. */
|
||||
private async skipUpdate(
|
||||
update: TelegramUpdate,
|
||||
oldOffset: bigint,
|
||||
): Promise<void> {
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await this.advanceOffsetIn(tx, oldOffset, update.updateId + 1n);
|
||||
await tx.$executeRaw`
|
||||
UPDATE "TelegramPollState"
|
||||
SET "lastError" = ${SKIPPED_UPDATE_TEXT}
|
||||
WHERE "id" = 'singleton' AND "leaseOwner" = ${this.instanceId}`;
|
||||
});
|
||||
this.failures.delete(update.updateId);
|
||||
this.logger.warn('Telegram update skipped after repeated failures');
|
||||
}
|
||||
|
||||
/** Offset compare-and-set on lease owner and old offset; count 0: lease lost. */
|
||||
private async advanceOffsetIn(
|
||||
tx: Prisma.TransactionClient,
|
||||
oldOffset: bigint,
|
||||
newOffset: bigint,
|
||||
): Promise<void> {
|
||||
const advanced = await tx.$executeRaw`
|
||||
UPDATE "TelegramPollState"
|
||||
SET "updateOffset" = ${newOffset}
|
||||
WHERE "id" = 'singleton'
|
||||
AND "leaseOwner" = ${this.instanceId}
|
||||
AND "leaseUntil" > now()
|
||||
AND "updateOffset" = ${oldOffset}`;
|
||||
if (advanced === 0) {
|
||||
throw new LeaseLostError();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Offset first (a lost lease ends here), then the effect, all in `tx`.
|
||||
* Returns the chat whose reply quota must be counted once `tx` commits.
|
||||
*/
|
||||
private async applyUpdate(
|
||||
tx: Prisma.TransactionClient,
|
||||
update: TelegramUpdate,
|
||||
oldOffset: bigint,
|
||||
): Promise<bigint | null> {
|
||||
await this.advanceOffsetIn(tx, oldOffset, update.updateId + 1n);
|
||||
// Groups and channels: the bot is not for them.
|
||||
if (update.chatType !== 'private') {
|
||||
return null;
|
||||
}
|
||||
const command = parseCommand(update.text);
|
||||
if (command.name === 'stop') {
|
||||
await this.links.stopIn(tx, update.chatId);
|
||||
return this.reply(tx, update.chatId, 'STOPPED');
|
||||
}
|
||||
if (command.name === 'start' && command.token !== null) {
|
||||
const result = await this.links.linkFromStartIn(
|
||||
tx,
|
||||
command.token,
|
||||
update.chatId,
|
||||
update.firstName,
|
||||
);
|
||||
return this.reply(tx, update.chatId, result);
|
||||
}
|
||||
return this.reply(tx, update.chatId, 'HELP');
|
||||
}
|
||||
|
||||
/**
|
||||
* LINKED always goes out (it needs a valid one-time token, so no
|
||||
* stranger can trigger it); every other reply counts against the limit.
|
||||
* Returns the chat to count after the commit, or null.
|
||||
*/
|
||||
private async reply(
|
||||
tx: Prisma.TransactionClient,
|
||||
chatId: bigint,
|
||||
code: TelegramTemplateCode,
|
||||
): Promise<bigint | null> {
|
||||
if (code === 'LINKED') {
|
||||
await this.links.queueReplyIn(tx, chatId, code);
|
||||
return null;
|
||||
}
|
||||
if (!this.replies.allows(chatId)) {
|
||||
return null;
|
||||
}
|
||||
await this.links.queueReplyIn(tx, chatId, code);
|
||||
return chatId;
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,13 @@ import { Module } from '@nestjs/common';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { telegramConfig } from './telegram-config';
|
||||
import { TELEGRAM_CONFIG, TelegramClient } from './telegram-client';
|
||||
import { TelegramLinkService } from './telegram-link.service';
|
||||
import { TelegramSender } from './telegram-sender';
|
||||
import { TelegramUpdatesService } from './telegram-updates.service';
|
||||
|
||||
/**
|
||||
* The Bot API client for the worker's Telegram loops. Reads the token from
|
||||
* The Bot API client for the worker's Telegram loops (send, receive) and the
|
||||
* connect/disconnect logic the profile routes use. Reads the token from
|
||||
* the environment once; without it the client reports isConfigured() false
|
||||
* and the loops do not run. The sender needs the permission resolver, which
|
||||
* the global JobPermissionsModule (worker) or PermissionsModule (API) provides:
|
||||
@@ -18,7 +21,14 @@ import { TelegramSender } from './telegram-sender';
|
||||
{ provide: TELEGRAM_CONFIG, useFactory: () => telegramConfig() },
|
||||
TelegramClient,
|
||||
TelegramSender,
|
||||
TelegramLinkService,
|
||||
TelegramUpdatesService,
|
||||
],
|
||||
exports: [
|
||||
TelegramClient,
|
||||
TelegramSender,
|
||||
TelegramLinkService,
|
||||
TelegramUpdatesService,
|
||||
],
|
||||
exports: [TelegramClient, TelegramSender],
|
||||
})
|
||||
export class TelegramModule {}
|
||||
@@ -12,6 +12,10 @@ import {
|
||||
TELEGRAM_SEND_POLL_MS,
|
||||
type TelegramSender,
|
||||
} from '../telegram/telegram-sender';
|
||||
import {
|
||||
NO_LEASE_WAIT_MS,
|
||||
type TelegramUpdatesService,
|
||||
} from '../telegram/telegram-updates.service';
|
||||
import type { WeatherHistoryService } from '../weather/history/weather-history.service';
|
||||
import {
|
||||
WEATHER_FIRST_TICK_DELAY_MS,
|
||||
@@ -69,6 +73,12 @@ class FakeTelegramSender {
|
||||
drain = jest.fn<Promise<number>, unknown[]>().mockResolvedValue(0);
|
||||
}
|
||||
|
||||
class FakeTelegramUpdates {
|
||||
pollOnce = jest
|
||||
.fn<Promise<string>, [AbortSignal]>()
|
||||
.mockResolvedValue('no-lease');
|
||||
}
|
||||
|
||||
describe('WorkerRuntime', () => {
|
||||
const ListenerMock = PgListener as unknown as jest.Mock;
|
||||
const originalDatabaseUrl = process.env.DATABASE_URL;
|
||||
@@ -82,6 +92,7 @@ describe('WorkerRuntime', () => {
|
||||
let weatherHistory: { syncOnce: jest.Mock };
|
||||
let telegramConfigured: boolean;
|
||||
let telegramSender: FakeTelegramSender;
|
||||
let telegramUpdates: FakeTelegramUpdates;
|
||||
let runtime: WorkerRuntime;
|
||||
let logError: jest.SpyInstance;
|
||||
|
||||
@@ -122,6 +133,7 @@ describe('WorkerRuntime', () => {
|
||||
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||
telegramConfigured = false;
|
||||
telegramSender = new FakeTelegramSender();
|
||||
telegramUpdates = new FakeTelegramUpdates();
|
||||
runtime = createRuntime();
|
||||
});
|
||||
|
||||
@@ -134,6 +146,7 @@ describe('WorkerRuntime', () => {
|
||||
weatherHistory as unknown as WeatherHistoryService,
|
||||
{ isConfigured: () => telegramConfigured } as unknown as TelegramClient,
|
||||
telegramSender as unknown as TelegramSender,
|
||||
telegramUpdates as unknown as TelegramUpdatesService,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -269,6 +282,85 @@ describe('WorkerRuntime', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('telegram-updates loop', () => {
|
||||
it('is not registered without a bot token', async () => {
|
||||
await bootstrap();
|
||||
await jest.advanceTimersByTimeAsync(NO_LEASE_WAIT_MS * 2);
|
||||
|
||||
expect(telegramUpdates.pollOnce).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('with a bot token', () => {
|
||||
beforeEach(async () => {
|
||||
await runtime.onModuleDestroy();
|
||||
telegramConfigured = true;
|
||||
runtime = createRuntime();
|
||||
});
|
||||
|
||||
it('polls with the stop signal and has no wake channel', async () => {
|
||||
await bootstrap();
|
||||
|
||||
expect(telegramUpdates.pollOnce).toHaveBeenCalledTimes(1);
|
||||
expect(telegramUpdates.pollOnce).toHaveBeenCalledWith(
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
expect(listenerOptions().channels).toEqual([
|
||||
'domain_event',
|
||||
'calendar_sync',
|
||||
'webhook_delivery',
|
||||
'notification_delivery',
|
||||
]);
|
||||
});
|
||||
|
||||
it('waits 30 s after no-lease, conflict and error', async () => {
|
||||
for (const outcome of ['no-lease', 'conflict', 'error']) {
|
||||
telegramUpdates.pollOnce.mockClear().mockResolvedValue(outcome);
|
||||
await runtime.onModuleDestroy();
|
||||
runtime = createRuntime();
|
||||
await bootstrap();
|
||||
expect(telegramUpdates.pollOnce).toHaveBeenCalledTimes(1);
|
||||
|
||||
await jest.advanceTimersByTimeAsync(NO_LEASE_WAIT_MS - 1);
|
||||
expect(telegramUpdates.pollOnce).toHaveBeenCalledTimes(1);
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
expect(telegramUpdates.pollOnce).toHaveBeenCalledTimes(2);
|
||||
}
|
||||
});
|
||||
|
||||
it('polls again at once after a successful poll', async () => {
|
||||
telegramUpdates.pollOnce
|
||||
.mockResolvedValueOnce('polled')
|
||||
.mockResolvedValueOnce('polled')
|
||||
.mockResolvedValue('no-lease');
|
||||
|
||||
await bootstrap();
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
|
||||
expect(telegramUpdates.pollOnce).toHaveBeenCalledTimes(3);
|
||||
await jest.advanceTimersByTimeAsync(NO_LEASE_WAIT_MS - 1);
|
||||
expect(telegramUpdates.pollOnce).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('a failing poll does not make the worker unhealthy', async () => {
|
||||
telegramUpdates.pollOnce.mockRejectedValue(new Error('boom'));
|
||||
await bootstrap();
|
||||
|
||||
await expect(runtime.health()).resolves.toEqual({ ok: true });
|
||||
expect(logError).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('aborts the running poll at shutdown', async () => {
|
||||
await bootstrap();
|
||||
const [signal] = telegramUpdates.pollOnce.mock.calls[0];
|
||||
|
||||
await runtime.onModuleDestroy();
|
||||
|
||||
expect(signal.aborted).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('polls webhook deliveries every 5 s', async () => {
|
||||
await bootstrap();
|
||||
expect(webhookDeliveries.drain).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -19,6 +19,10 @@ import {
|
||||
TELEGRAM_SEND_POLL_MS,
|
||||
TelegramSender,
|
||||
} from '../telegram/telegram-sender';
|
||||
import {
|
||||
NO_LEASE_WAIT_MS,
|
||||
TelegramUpdatesService,
|
||||
} from '../telegram/telegram-updates.service';
|
||||
import {
|
||||
WEATHER_FIRST_TICK_DELAY_MS,
|
||||
WEATHER_HISTORY_POLL_MS,
|
||||
@@ -32,6 +36,7 @@ import { HEALTH_STALE_MS, WorkerHealth } from './worker-health';
|
||||
export const DOMAIN_EVENT_POLL_MS = 5_000;
|
||||
export const CALENDAR_OUTBOX_POLL_MS = 60_000;
|
||||
export const WEBHOOK_DELIVERY_POLL_MS = 5_000;
|
||||
const TELEGRAM_UPDATES_LOOP = 'telegram-updates';
|
||||
/** The health check's SELECT 1 must answer within this. */
|
||||
export const HEALTH_QUERY_TIMEOUT_MS = 5_000;
|
||||
|
||||
@@ -84,6 +89,7 @@ export class WorkerRuntime implements OnApplicationBootstrap, OnModuleDestroy {
|
||||
private readonly weatherHistory: WeatherHistoryService,
|
||||
private readonly telegramClient: TelegramClient,
|
||||
private readonly telegramSender: TelegramSender,
|
||||
private readonly telegramUpdates: TelegramUpdatesService,
|
||||
) {
|
||||
const loops = this.workerLoops().map((definition) => ({
|
||||
definition,
|
||||
@@ -135,6 +141,14 @@ export class WorkerRuntime implements OnApplicationBootstrap, OnModuleDestroy {
|
||||
channel: WAKE_CHANNELS.notificationDeliveries,
|
||||
tick: () => this.sendTelegram(),
|
||||
},
|
||||
{
|
||||
// Long polling getUpdates; no wake channel. After a poll the
|
||||
// next one starts at once, otherwise the interval is the wait
|
||||
// (no lease, 409, error).
|
||||
name: TELEGRAM_UPDATES_LOOP,
|
||||
intervalMs: NO_LEASE_WAIT_MS,
|
||||
tick: () => this.receiveTelegram(),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
@@ -239,6 +253,13 @@ export class WorkerRuntime implements OnApplicationBootstrap, OnModuleDestroy {
|
||||
await this.telegramSender.drain(this.stopping.signal);
|
||||
}
|
||||
|
||||
private async receiveTelegram(): Promise<void> {
|
||||
const outcome = await this.telegramUpdates.pollOnce(this.stopping.signal);
|
||||
if (outcome === 'polled') {
|
||||
this.loops.get(TELEGRAM_UPDATES_LOOP)?.wake();
|
||||
}
|
||||
}
|
||||
|
||||
private async deliverWebhooks(): Promise<void> {
|
||||
await this.webhookDeliveries.releaseStaleClaims();
|
||||
// No filter: every due delivery of the database.
|
||||
|
||||
@@ -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