- Bottling form: jar size and best-before from the product with source, differs-hints before saving, prefill status, water-content confirmation naming the measured value, idempotency key renewed on body change; product form gets shelf life (months) and whole-gram jar weight. - Telegram frontend: link card with step-up dialog, t.me-only deep link, status polling, unlink; system Telegram card with receiver/sender state and confirmed retry; StepUpDialog shared with the backup import. - Reauth calls never refresh-and-retry on 401 (no double lockout count); German texts for expired session and 429; service worker never caches Telegram, notification settings and system responses. - Telegram backend round 2: shared link removal on every disconnect path, lease released on shutdown, connection errors not counted as poison. - ARCHITECTURE.md §2, §6 (Annahmen 56–58), §10, §11.12, §11.13; TASKS.md T4. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
118 lines
3.8 KiB
TypeScript
118 lines
3.8 KiB
TypeScript
import { randomUUID } from 'node:crypto';
|
|
import { INestApplication } from '@nestjs/common';
|
|
import { NestExpressApplication } from '@nestjs/platform-express';
|
|
import { Test } from '@nestjs/testing';
|
|
import { App } from 'supertest/types';
|
|
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,
|
|
type TelegramUpdate,
|
|
type TelegramUpdateBatch,
|
|
} from '../../src/telegram/telegram-client';
|
|
import { TelegramModule } from '../../src/telegram/telegram.module';
|
|
|
|
export interface SentMessage {
|
|
chatId: bigint;
|
|
text: string;
|
|
}
|
|
|
|
/**
|
|
* Stands in for the Bot API: no network. Every sendMessage is recorded;
|
|
* `failWith` scripts the next answers (an error is thrown once per entry,
|
|
* in order, for the given chat or any chat).
|
|
*/
|
|
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;
|
|
}
|
|
|
|
failWith(statusCode: number, description: string, chatId?: bigint): void {
|
|
this.failures.push({
|
|
chatId: chatId ?? null,
|
|
error: new TelegramApiError('sendMessage', statusCode, description, null),
|
|
});
|
|
}
|
|
|
|
sendMessage(chatId: bigint, text: string): Promise<void> {
|
|
const index = this.failures.findIndex(
|
|
(failure) => failure.chatId === null || failure.chatId === chatId,
|
|
);
|
|
if (index >= 0) {
|
|
const [failure] = this.failures.splice(index, 1);
|
|
return Promise.reject(failure.error);
|
|
}
|
|
this.sent.push({ chatId, text });
|
|
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, received: due.length };
|
|
}
|
|
|
|
sentTo(chatId: bigint): SentMessage[] {
|
|
return this.sent.filter((message) => message.chatId === chatId);
|
|
}
|
|
}
|
|
|
|
export interface TelegramTestApp {
|
|
app: INestApplication<App>;
|
|
telegram: FakeTelegramClient;
|
|
}
|
|
|
|
/**
|
|
* The whole API plus TelegramModule, with the Bot API client replaced by a
|
|
* FakeTelegramClient. The TelegramSender is taken from the app and driven
|
|
* by the test (no worker loop runs).
|
|
*/
|
|
export async function createTelegramTestApp(): Promise<TelegramTestApp> {
|
|
const telegram = new FakeTelegramClient();
|
|
const moduleFixture = await Test.createTestingModule({
|
|
imports: [AppModule, TelegramModule],
|
|
})
|
|
.overrideProvider(THROTTLER_NAMESPACE)
|
|
.useValue(randomUUID())
|
|
.overrideProvider(TelegramClient)
|
|
.useValue(telegram)
|
|
.compile();
|
|
const app = moduleFixture.createNestApplication<NestExpressApplication>();
|
|
configureApp(app);
|
|
await app.init();
|
|
return { app, telegram };
|
|
}
|