Files
ApiCore/ApiCore-Backend/test/telegram-schema.e2e-spec.ts
Nick SzaunigandClaude Opus 5.5 1971b2d9c4 feat: T4b/T4c wave 8 - weather history, phenology, honey chain, Telegram base
- Weather history from Open-Meteo behind an adapter: worker loop
  phenology-weather with backoff, Retry-After, User-Agent, chunked
  writes and forecast-to-archive correction
- Phenology: growing degree sum per DWD, phase from reports, GTS and
  calendar, calibration from previous years, daily job step,
  SeasonService reads the stored state
- Honey chain: prefill for extractions and batches (proposals only),
  plausibility and withdrawal warnings, Extraction lock, chain events
  and batch.created webhook from HoneyBatchRecorded
- Telegram base: schema, NotificationDelivery outbox, hardened bot
  client, startup check without crash, no-network-outside-loops guard
- ARCHITECTURE.md: sections 11.11-11.13, assumptions 42-46, env table

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 20:00:46 +02:00

327 lines
9.7 KiB
TypeScript

import { Prisma } from '../generated/prisma';
import {
deferClaim,
claimBatch,
releaseStaleClaims,
settleSkipped,
settleSuccess,
} from '../src/common/outbox/outbox-claims';
import { PrismaService } from '../src/prisma/prisma.service';
interface Claimed {
id: string;
claimedAt: Date;
}
/**
* Schema-level guarantees of the T4 Telegram migration and the outbox
* helpers on NotificationDelivery: the target CHECK, the poll-state
* singleton, cascade with the user, compare-and-set settling.
*/
describe('Telegram schema and delivery outbox (e2e)', () => {
let prisma: PrismaService;
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
let userId: string;
let notificationId: string;
const chatBase = BigInt(9_000_000_000) + BigInt(Date.now() % 1_000_000);
beforeAll(async () => {
prisma = new PrismaService();
const user = await prisma.user.create({
data: {
email: `tg-schema-${suffix}@example.com`,
passwordHash: 'x',
name: 'Telegram Schema',
role: { connect: { key: 'READONLY' } },
},
});
userId = user.id;
const notification = await prisma.notification.create({
data: {
userId,
priority: 'INFO',
category: 'SYSTEM',
message: 'm',
},
});
notificationId = notification.id;
});
afterAll(async () => {
await prisma.user.deleteMany({ where: { id: userId } });
await prisma.$disconnect();
});
afterEach(async () => {
await prisma.notificationDelivery.deleteMany({
where: { OR: [{ userId }, { chatId: { gte: chatBase } }] },
});
});
it('has exactly one TelegramPollState row, created by the migration', async () => {
const rows = await prisma.telegramPollState.findMany();
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({ id: 'singleton', updateOffset: 0n });
});
describe('NotificationDelivery target CHECK', () => {
const base = { channel: 'TELEGRAM' } as const;
it('accepts a notification for a user', async () => {
await expect(
prisma.notificationDelivery.create({
data: { ...base, userId, notificationId },
}),
).resolves.toMatchObject({ status: 'PENDING', attempts: 0 });
});
it('accepts a system reply for a chat', async () => {
await expect(
prisma.notificationDelivery.create({
data: { ...base, chatId: chatBase, templateCode: 'HELP' },
}),
).resolves.toMatchObject({ status: 'PENDING' });
});
it.each([
['an empty row', {}],
['notification without user', { notificationId: 'n' }],
['template without chat', { templateCode: 'HELP' }],
['chat without template', { chatId: chatBase }],
[
'notification and template',
{
userId: 'u',
notificationId: 'n',
templateCode: 'HELP',
chatId: chatBase,
},
],
])('rejects %s', async (_name, extra) => {
const insert = prisma.$executeRaw`INSERT INTO "NotificationDelivery" ("id","channel","userId","chatId","notificationId","templateCode")
VALUES (${`chk-${suffix}-${Math.random()}`}, 'TELEGRAM',
${(extra as { userId?: string }).userId ?? null},
${(extra as { chatId?: bigint }).chatId ?? null},
${(extra as { notificationId?: string }).notificationId ?? null},
${(extra as { templateCode?: string }).templateCode ?? null})`;
await expect(insert).rejects.toThrow();
});
});
it('allows only the singleton row in TelegramPollState', async () => {
await expect(
prisma.$executeRaw`INSERT INTO "TelegramPollState" ("id") VALUES (${`other-${suffix}`})`,
).rejects.toThrow();
});
it('creates at most one delivery per notification and channel', async () => {
await prisma.notificationDelivery.create({
data: { channel: 'TELEGRAM', userId, notificationId },
});
await expect(
prisma.notificationDelivery.create({
data: { channel: 'TELEGRAM', userId, notificationId },
}),
).rejects.toThrow();
});
it('indexes NotificationDelivery by notificationId', async () => {
const rows = await prisma.$queryRaw<{ indexdef: string }[]>`
SELECT indexdef FROM pg_indexes
WHERE tablename = 'NotificationDelivery' AND indexdef LIKE '%("notificationId")%'`;
expect(rows.length).toBeGreaterThanOrEqual(1);
});
describe('outbox helpers', () => {
async function claimOne(): Promise<Claimed> {
const created = await prisma.notificationDelivery.create({
data: { channel: 'TELEGRAM', userId, notificationId },
});
const [claimed] = await claimBatch<Claimed>(
prisma,
'NotificationDelivery',
{
limit: 1,
filter: Prisma.sql`"id" = ${created.id}`,
orderBy: 'createdAt',
},
);
expect(claimed.id).toBe(created.id);
return claimed;
}
it('settleSkipped needs the matching claim and records the reason', async () => {
const claimed = await claimOne();
await expect(
settleSkipped(
prisma,
'NotificationDelivery',
claimed.id,
new Date(claimed.claimedAt.getTime() + 1),
'nope',
),
).resolves.toBe(false);
await expect(
settleSkipped(
prisma,
'NotificationDelivery',
claimed.id,
claimed.claimedAt,
'chat gone',
),
).resolves.toBe(true);
await expect(
prisma.notificationDelivery.findUnique({ where: { id: claimed.id } }),
).resolves.toMatchObject({
status: 'SKIPPED',
lastError: 'chat gone',
claimedAt: null,
attempts: 0,
});
});
it('deferClaim puts the entry back for later without counting an attempt', async () => {
const claimed = await claimOne();
await expect(
deferClaim(
prisma,
'NotificationDelivery',
claimed.id,
new Date(0),
1000,
),
).resolves.toBe(false);
await expect(
deferClaim(
prisma,
'NotificationDelivery',
claimed.id,
claimed.claimedAt,
60_000,
),
).resolves.toBe(true);
const row = await prisma.notificationDelivery.findUniqueOrThrow({
where: { id: claimed.id },
});
expect(row).toMatchObject({
status: 'PENDING',
attempts: 0,
claimedAt: null,
});
const [{ ahead }] = await prisma.$queryRaw<{ ahead: number }[]>`
SELECT EXTRACT(EPOCH FROM ("availableAt" - now()))::float8 AS ahead
FROM "NotificationDelivery" WHERE "id" = ${claimed.id}`;
expect(ahead).toBeGreaterThan(50);
expect(ahead).toBeLessThanOrEqual(61);
});
it('settleSuccess marks DELIVERED with deliveredAt', async () => {
const claimed = await claimOne();
await expect(
settleSuccess(
prisma,
'NotificationDelivery',
claimed.id,
claimed.claimedAt,
'DELIVERED',
),
).resolves.toBe(true);
const row = await prisma.notificationDelivery.findUniqueOrThrow({
where: { id: claimed.id },
});
expect(row.status).toBe('DELIVERED');
expect(row.deliveredAt).not.toBeNull();
});
it('the stale release works on NotificationDelivery', async () => {
const claimed = await claimOne();
await prisma.$executeRaw`UPDATE "NotificationDelivery" SET "claimedAt" = now() - interval '1 day' WHERE "id" = ${claimed.id}`;
expect(
await releaseStaleClaims(prisma, 'NotificationDelivery'),
).toBeGreaterThanOrEqual(1);
await expect(
prisma.notificationDelivery.findUniqueOrThrow({
where: { id: claimed.id },
}),
).resolves.toMatchObject({ status: 'PENDING', attempts: 1 });
});
});
it('deleting the user removes the chat binding, tokens and deliveries', async () => {
const other = await prisma.user.create({
data: {
email: `tg-cascade-${suffix}@example.com`,
passwordHash: 'x',
name: 'Cascade',
role: { connect: { key: 'READONLY' } },
},
});
const note = await prisma.notification.create({
data: {
userId: other.id,
priority: 'INFO',
category: 'SYSTEM',
message: 'm',
},
});
await prisma.telegramLink.create({
data: { userId: other.id, chatId: chatBase + 1n },
});
await prisma.telegramLinkToken.create({
data: {
userId: other.id,
tokenHash: `hash-${suffix}`,
expiresAt: new Date(Date.now() + 60_000),
},
});
await prisma.notificationDelivery.create({
data: { channel: 'TELEGRAM', userId: other.id, notificationId: note.id },
});
await prisma.user.delete({ where: { id: other.id } });
expect(
await prisma.telegramLink.count({ where: { userId: other.id } }),
).toBe(0);
expect(
await prisma.telegramLinkToken.count({ where: { userId: other.id } }),
).toBe(0);
expect(
await prisma.notificationDelivery.count({ where: { userId: other.id } }),
).toBe(0);
});
it('one chat belongs to exactly one user', async () => {
const link = await prisma.telegramLink.create({
data: { userId, chatId: chatBase + 2n },
});
const second = await prisma.user.create({
data: {
email: `tg-chat-${suffix}@example.com`,
passwordHash: 'x',
name: 'Second',
role: { connect: { key: 'READONLY' } },
},
});
try {
await expect(
prisma.telegramLink.create({
data: { userId: second.id, chatId: link.chatId },
}),
).rejects.toThrow();
} finally {
await prisma.telegramLink.deleteMany({ where: { userId } });
await prisma.user.delete({ where: { id: second.id } });
}
});
});