- 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]>
166 lines
4.3 KiB
TypeScript
166 lines
4.3 KiB
TypeScript
import { Prisma } from '../../../generated/prisma';
|
|
import { deferClaim, settleSkipped, settleSuccess } from './outbox-claims';
|
|
|
|
function fakeDb(count = 1) {
|
|
return { $executeRaw: jest.fn().mockResolvedValue(count) };
|
|
}
|
|
|
|
function sqlOf(db: ReturnType<typeof fakeDb>): Prisma.Sql {
|
|
return (db.$executeRaw.mock.calls as [Prisma.Sql][])[0][0];
|
|
}
|
|
|
|
describe('NotificationDelivery outbox helpers', () => {
|
|
const claimedAt = new Date('2026-10-15T10:00:00.000Z');
|
|
|
|
it('settleSuccess settles NotificationDelivery as DELIVERED into deliveredAt', async () => {
|
|
const db = fakeDb();
|
|
|
|
await expect(
|
|
settleSuccess(
|
|
db as never,
|
|
'NotificationDelivery',
|
|
'd1',
|
|
claimedAt,
|
|
'DELIVERED',
|
|
),
|
|
).resolves.toBe(true);
|
|
|
|
const query = sqlOf(db);
|
|
expect(query.sql).toContain('UPDATE "NotificationDelivery"');
|
|
expect(query.sql).toContain('"deliveredAt" = now()');
|
|
expect(query.sql).toContain('"NotificationDeliveryStatus"');
|
|
});
|
|
|
|
it('settleSuccess refuses DONE for NotificationDelivery', async () => {
|
|
await expect(
|
|
settleSuccess(
|
|
fakeDb() as never,
|
|
'NotificationDelivery',
|
|
'd1',
|
|
claimedAt,
|
|
'DONE',
|
|
),
|
|
).rejects.toThrow('NotificationDelivery settles as DELIVERED, not DONE');
|
|
});
|
|
|
|
it('settleSkipped is compare-and-set on id and claimedAt and keeps attempts', async () => {
|
|
const db = fakeDb();
|
|
|
|
await expect(
|
|
settleSkipped(
|
|
db as never,
|
|
'NotificationDelivery',
|
|
'd1',
|
|
claimedAt,
|
|
'chat gone',
|
|
),
|
|
).resolves.toBe(true);
|
|
|
|
const query = sqlOf(db);
|
|
expect(query.sql).toContain('UPDATE "NotificationDelivery"');
|
|
expect(query.sql).toContain(`CAST(? AS "NotificationDeliveryStatus")`);
|
|
expect(query.sql).toContain(
|
|
`WHERE "id" = ? AND "status" = 'PROCESSING' AND "claimedAt" = ?`,
|
|
);
|
|
expect(query.sql).not.toContain('"attempts"');
|
|
expect(query.values).toEqual(['SKIPPED', 'chat gone', 'd1', claimedAt]);
|
|
});
|
|
|
|
it('settleSkipped reports a lost claim', async () => {
|
|
await expect(
|
|
settleSkipped(
|
|
fakeDb(0) as never,
|
|
'NotificationDelivery',
|
|
'd1',
|
|
claimedAt,
|
|
'x',
|
|
),
|
|
).resolves.toBe(false);
|
|
});
|
|
|
|
it('deferClaim puts the entry back to PENDING after the delay by the database clock, attempts unchanged, compare-and-set', async () => {
|
|
const db = fakeDb();
|
|
|
|
await expect(
|
|
deferClaim(
|
|
db as never,
|
|
'NotificationDelivery',
|
|
'd1',
|
|
claimedAt,
|
|
15 * 60_000,
|
|
),
|
|
).resolves.toBe(true);
|
|
|
|
const query = sqlOf(db);
|
|
expect(query.sql).toContain('UPDATE "NotificationDelivery"');
|
|
expect(query.sql).toContain(`"status" = 'PENDING'`);
|
|
expect(query.sql).toContain(
|
|
`"availableAt" = now() + (? * interval '1 millisecond')`,
|
|
);
|
|
expect(query.sql).toContain('"claimedAt" = NULL');
|
|
expect(query.sql).not.toContain('"attempts"');
|
|
expect(query.sql).toContain(
|
|
`WHERE "id" = ? AND "status" = 'PROCESSING' AND "claimedAt" = ?`,
|
|
);
|
|
expect(query.values).toEqual([900_000, 'd1', claimedAt]);
|
|
});
|
|
|
|
it('deferClaim reports a lost claim and rejects a negative or non-integer delay', async () => {
|
|
await expect(
|
|
deferClaim(
|
|
fakeDb(0) as never,
|
|
'NotificationDelivery',
|
|
'd1',
|
|
claimedAt,
|
|
1,
|
|
),
|
|
).resolves.toBe(false);
|
|
await expect(
|
|
deferClaim(
|
|
fakeDb() as never,
|
|
'NotificationDelivery',
|
|
'd1',
|
|
claimedAt,
|
|
-1,
|
|
),
|
|
).rejects.toThrow(RangeError);
|
|
await expect(
|
|
deferClaim(
|
|
fakeDb() as never,
|
|
'NotificationDelivery',
|
|
'd1',
|
|
claimedAt,
|
|
1.5,
|
|
),
|
|
).rejects.toThrow(RangeError);
|
|
});
|
|
|
|
it('deferClaim caps the delay at 24 hours', async () => {
|
|
const db = fakeDb();
|
|
|
|
await deferClaim(
|
|
db as never,
|
|
'NotificationDelivery',
|
|
'd1',
|
|
claimedAt,
|
|
30 * 24 * 3_600_000,
|
|
);
|
|
|
|
expect(sqlOf(db).values[0]).toBe(24 * 3_600_000);
|
|
});
|
|
|
|
it('settleSkipped truncates the reason to 200 characters', async () => {
|
|
const db = fakeDb();
|
|
|
|
await settleSkipped(
|
|
db as never,
|
|
'NotificationDelivery',
|
|
'd1',
|
|
claimedAt,
|
|
'r'.repeat(500),
|
|
);
|
|
|
|
expect(sqlOf(db).values[1]).toBe('r'.repeat(200));
|
|
});
|
|
});
|