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
@@ -19,6 +19,14 @@ interface FailedDeliveryBody {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface DeliveriesBody {
|
||||
telegram: {
|
||||
botNameSet: boolean;
|
||||
sender: { failed24h: number };
|
||||
};
|
||||
deliveries: { id: string; [key: string]: unknown }[];
|
||||
}
|
||||
|
||||
interface OverviewBody {
|
||||
jobs: unknown[];
|
||||
domainEvents: { failed: number; oldestPendingAgeSeconds: number | null };
|
||||
@@ -234,4 +242,220 @@ describe('System API (e2e)', () => {
|
||||
|
||||
await prisma.webhookSubscription.delete({ where: { id: subscription.id } });
|
||||
});
|
||||
|
||||
describe('notification deliveries (Telegram card)', () => {
|
||||
const CHAT_ID = 987654321n;
|
||||
let userId: string;
|
||||
const createdNotificationIds: string[] = [];
|
||||
|
||||
const templateDeliveryIds: string[] = [];
|
||||
|
||||
/** A bot reply: addressed by chat id, no user, no notification. */
|
||||
async function createTemplateDelivery(): Promise<string> {
|
||||
const delivery = await prisma.notificationDelivery.create({
|
||||
data: {
|
||||
channel: 'TELEGRAM',
|
||||
chatId: CHAT_ID,
|
||||
templateCode: 'HELP',
|
||||
status: 'FAILED',
|
||||
attempts: 8,
|
||||
lastError: 'HTTP 502',
|
||||
},
|
||||
});
|
||||
templateDeliveryIds.push(delivery.id);
|
||||
return delivery.id;
|
||||
}
|
||||
|
||||
async function createDelivery(
|
||||
status: 'FAILED' | 'DELIVERED',
|
||||
): Promise<string> {
|
||||
const notification = await prisma.notification.create({
|
||||
data: {
|
||||
userId,
|
||||
priority: 'HINWEIS',
|
||||
category: 'TASK_DUE',
|
||||
message: 'private reminder text',
|
||||
dedupeKey: `system-e2e:${suffix}:${createdNotificationIds.length}:${Date.now()}`,
|
||||
},
|
||||
});
|
||||
createdNotificationIds.push(notification.id);
|
||||
const delivery = await prisma.notificationDelivery.create({
|
||||
data: {
|
||||
channel: 'TELEGRAM',
|
||||
userId,
|
||||
notificationId: notification.id,
|
||||
status,
|
||||
attempts: status === 'FAILED' ? 8 : 1,
|
||||
lastStatusCode: status === 'FAILED' ? 502 : null,
|
||||
lastError: status === 'FAILED' ? 'HTTP 502' : null,
|
||||
},
|
||||
});
|
||||
return delivery.id;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
const admin = await prisma.user.findUniqueOrThrow({
|
||||
where: { email: `system-admin-${suffix}@example.com` },
|
||||
select: { id: true },
|
||||
});
|
||||
userId = admin.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Only this block's own notifications (deliveries cascade).
|
||||
await prisma.notification.deleteMany({
|
||||
where: { id: { in: createdNotificationIds } },
|
||||
});
|
||||
await prisma.notificationDelivery.deleteMany({
|
||||
where: { id: { in: templateDeliveryIds } },
|
||||
});
|
||||
});
|
||||
|
||||
it('GET /system/notification-deliveries shows FAILED entries and retry moves them back to PENDING', async () => {
|
||||
const failedId = await createDelivery('FAILED');
|
||||
const deliveredId = await createDelivery('DELIVERED');
|
||||
const templateId = await createTemplateDelivery();
|
||||
|
||||
const response = await request(server())
|
||||
.get('/api/v1/system/notification-deliveries?status=FAILED')
|
||||
.set(auth(adminToken))
|
||||
.expect(200);
|
||||
const body = response.body as DeliveriesBody;
|
||||
const listed = body.deliveries.find((d) => d.id === failedId);
|
||||
expect(listed).toMatchObject({
|
||||
status: 'FAILED',
|
||||
category: 'TASK_DUE',
|
||||
attempts: 8,
|
||||
lastStatusCode: 502,
|
||||
});
|
||||
expect(body.deliveries.some((d) => d.id === deliveredId)).toBe(false);
|
||||
expect(body.deliveries.find((d) => d.id === templateId)).toMatchObject({
|
||||
category: null,
|
||||
templateCode: 'HELP',
|
||||
});
|
||||
expect(typeof body.telegram.botNameSet).toBe('boolean');
|
||||
expect(body.telegram.sender.failed24h).toBeGreaterThanOrEqual(1);
|
||||
const raw = JSON.stringify(body);
|
||||
expect(raw).not.toContain(CHAT_ID.toString());
|
||||
expect(raw).not.toContain('private reminder text');
|
||||
expect(listed).not.toHaveProperty('chatId');
|
||||
|
||||
await request(server())
|
||||
.get('/api/v1/system/notification-deliveries?status=NOPE')
|
||||
.set(auth(adminToken))
|
||||
.expect(400);
|
||||
|
||||
await request(server())
|
||||
.post(`/api/v1/system/notification-deliveries/${failedId}/retry`)
|
||||
.set(auth(adminToken))
|
||||
.expect(204);
|
||||
const reset = await prisma.notificationDelivery.findUniqueOrThrow({
|
||||
where: { id: failedId },
|
||||
});
|
||||
expect(reset).toMatchObject({
|
||||
status: 'PENDING',
|
||||
attempts: 0,
|
||||
lastError: null,
|
||||
lastStatusCode: null,
|
||||
claimedAt: null,
|
||||
});
|
||||
expect(reset.availableAt.getTime()).toBeLessThanOrEqual(
|
||||
Date.now() + 5_000,
|
||||
);
|
||||
await expect(
|
||||
prisma.auditLog.count({
|
||||
where: { entityType: 'NotificationDelivery', entityId: failedId },
|
||||
}),
|
||||
).resolves.toBe(1);
|
||||
|
||||
// PENDING is not FAILED any more, a DELIVERED one never was.
|
||||
await request(server())
|
||||
.post(`/api/v1/system/notification-deliveries/${failedId}/retry`)
|
||||
.set(auth(adminToken))
|
||||
.expect(409);
|
||||
await request(server())
|
||||
.post(`/api/v1/system/notification-deliveries/${deliveredId}/retry`)
|
||||
.set(auth(adminToken))
|
||||
.expect(409);
|
||||
await request(server())
|
||||
.post('/api/v1/system/notification-deliveries/does-not-exist/retry')
|
||||
.set(auth(adminToken))
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it('retry-failed requeues the FAILED entries and reports how many', async () => {
|
||||
const own = [
|
||||
await createDelivery('FAILED'),
|
||||
await createDelivery('FAILED'),
|
||||
];
|
||||
// Other files share this DB: remember their FAILED rows to restore them.
|
||||
const others = await prisma.notificationDelivery.findMany({
|
||||
where: { status: 'FAILED', id: { notIn: own } },
|
||||
});
|
||||
const ownAudits = () =>
|
||||
prisma.auditLog.count({
|
||||
where: { entityType: 'NotificationDelivery', entityId: { in: own } },
|
||||
});
|
||||
try {
|
||||
const response = await request(server())
|
||||
.post('/api/v1/system/notification-deliveries/retry-failed')
|
||||
.set(auth(adminToken))
|
||||
.expect(200);
|
||||
expect(
|
||||
(response.body as { retried: number }).retried,
|
||||
).toBeGreaterThanOrEqual(2);
|
||||
|
||||
const rows = await prisma.notificationDelivery.findMany({
|
||||
where: { id: { in: own } },
|
||||
select: { status: true, attempts: true },
|
||||
});
|
||||
expect(rows).toEqual([
|
||||
{ status: 'PENDING', attempts: 0 },
|
||||
{ status: 'PENDING', attempts: 0 },
|
||||
]);
|
||||
await expect(ownAudits()).resolves.toBe(2);
|
||||
|
||||
// A second call leaves the own rows alone: no new audit rows.
|
||||
await request(server())
|
||||
.post('/api/v1/system/notification-deliveries/retry-failed')
|
||||
.set(auth(adminToken))
|
||||
.expect(200);
|
||||
await expect(
|
||||
prisma.notificationDelivery.count({
|
||||
where: { id: { in: own }, status: 'PENDING' },
|
||||
}),
|
||||
).resolves.toBe(2);
|
||||
await expect(ownAudits()).resolves.toBe(2);
|
||||
} finally {
|
||||
for (const row of others) {
|
||||
await prisma.notificationDelivery.updateMany({
|
||||
where: { id: row.id },
|
||||
data: {
|
||||
status: row.status,
|
||||
attempts: row.attempts,
|
||||
availableAt: row.availableAt,
|
||||
claimedAt: row.claimedAt,
|
||||
lastStatusCode: row.lastStatusCode,
|
||||
lastError: row.lastError,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('staff without system:manage gets 403', async () => {
|
||||
await request(server())
|
||||
.get('/api/v1/system/notification-deliveries')
|
||||
.set(auth(staffToken))
|
||||
.expect(403);
|
||||
await request(server())
|
||||
.post('/api/v1/system/notification-deliveries/x/retry')
|
||||
.set(auth(staffToken))
|
||||
.expect(403);
|
||||
await request(server())
|
||||
.post('/api/v1/system/notification-deliveries/retry-failed')
|
||||
.set(auth(staffToken))
|
||||
.expect(403);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user