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(); }); }); });