feat: T4 wave 10 – bottling quality, phenology and honey chain UI, Telegram link/receive and ops
- Bottling quality: water content above the legal limit (strict >) answers 409 CONFIRMATION_REQUIRED bound to the measured value; prefill for jar size, best-before (shelfLifeMonths, month-end clamping) and water content; impossible dates give 400; water-content checks in src/common/quality. - Frontend phenology: "Entwicklung melden" sheet, IndexedDB v4 queue, season header on the apiary page, confirmed revoke for all apiaries. - Frontend honey chain: "Vorschlag aus …" hints with explicit take-over, prefill status note, movement ledger with withdraw/correct dialogs. - Telegram: link tokens (hashed, single use), private-chat receiver with lease and offset CAS, bounded poison-update skip, Retry-After, pending deliveries skipped on disconnect; system API for deliveries and retry; worker-only secret wiring and manifest policy tests. - ARCHITECTURE.md §7, §9, §10, §11.11–11.13, Annahmen 51–55. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
16cc557462
commit
fab7f7e12f
91 files changed
+8463
-104
No files matched your search
@@ -0,0 +1,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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user