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