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]>
This commit is contained in:
1 parent
22382d53a2
commit
1971b2d9c4
90 files changed
+8431
-155
No files matched your search
@@ -0,0 +1,165 @@
|
||||
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));
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user