|
6 | 6 | listTasksForTests, |
7 | 7 | resetInboxProcessingStoreForTests, |
8 | 8 | resetIncomingTelegramIngressStoreForTests, |
9 | | - seedInboxItemForProcessingTests |
| 9 | + seedInboxItemForProcessingTests, |
| 10 | + type FollowUpRuntimeStore |
10 | 11 | } from "@atlas/db"; |
11 | 12 |
|
12 | 13 | import { runBundledFollowUps } from "./follow-up"; |
@@ -187,4 +188,87 @@ describe("runBundledFollowUps", () => { |
187 | 188 | taskIds: [...initialTaskIds, ...laterTaskIds] |
188 | 189 | }); |
189 | 190 | }); |
| 191 | + |
| 192 | + it("deduplicates identical follow-up bundles across repeated runner attempts", async () => { |
| 193 | + const reservedKeys = new Set<string>(); |
| 194 | + const deliveryStore = { |
| 195 | + reserveOutgoingIfAbsent: vi.fn(async (event) => { |
| 196 | + if (reservedKeys.has(event.idempotencyKey)) { |
| 197 | + return { status: "duplicate" as const }; |
| 198 | + } |
| 199 | + |
| 200 | + reservedKeys.add(event.idempotencyKey); |
| 201 | + return { status: "reserved" as const, eventId: "event-1" }; |
| 202 | + }), |
| 203 | + updateOutgoing: vi.fn(async () => undefined) |
| 204 | + }; |
| 205 | + const sender = vi.fn().mockResolvedValue({ |
| 206 | + ok: true, |
| 207 | + result: { |
| 208 | + message_id: 88, |
| 209 | + date: 1_700_000_000, |
| 210 | + chat: { |
| 211 | + id: "123", |
| 212 | + type: "private" |
| 213 | + }, |
| 214 | + text: "sent" |
| 215 | + } |
| 216 | + }); |
| 217 | + const dueTask = { |
| 218 | + id: "task-1", |
| 219 | + userId: "123", |
| 220 | + sourceInboxItemId: "inbox-a", |
| 221 | + lastInboxItemId: "inbox-a", |
| 222 | + title: "Review launch checklist", |
| 223 | + lifecycleState: "scheduled" as const, |
| 224 | + externalCalendarEventId: null, |
| 225 | + externalCalendarId: null, |
| 226 | + scheduledStartAt: "2026-03-20T16:00:00.000Z", |
| 227 | + scheduledEndAt: "2026-03-20T17:00:00.000Z", |
| 228 | + calendarSyncStatus: "in_sync" as const, |
| 229 | + calendarSyncUpdatedAt: null, |
| 230 | + rescheduleCount: 0, |
| 231 | + lastFollowupAt: null, |
| 232 | + followupReminderSentAt: null, |
| 233 | + completedAt: null, |
| 234 | + archivedAt: null, |
| 235 | + priority: "medium" as const, |
| 236 | + urgency: "medium" as const, |
| 237 | + createdAt: "2026-03-20T16:00:00.000Z", |
| 238 | + dueType: "initial" as const |
| 239 | + }; |
| 240 | + const store: FollowUpRuntimeStore = { |
| 241 | + listDueFollowUpTasks: vi.fn(async () => [dueTask]), |
| 242 | + listOutstandingFollowUpTasks: vi.fn(async () => []), |
| 243 | + hasInFlightInboxItem: vi.fn(async () => false), |
| 244 | + markFollowUpSent: vi.fn(async () => undefined), |
| 245 | + markFollowUpReminderSent: vi.fn(async () => undefined) |
| 246 | + }; |
| 247 | + |
| 248 | + await runBundledFollowUps("2026-03-20T19:00:00.000Z", { |
| 249 | + store, |
| 250 | + deliveryStore, |
| 251 | + sender |
| 252 | + }); |
| 253 | + await runBundledFollowUps("2026-03-20T19:00:00.000Z", { |
| 254 | + store, |
| 255 | + deliveryStore, |
| 256 | + sender |
| 257 | + }); |
| 258 | + |
| 259 | + expect(sender).toHaveBeenCalledTimes(1); |
| 260 | + expect(deliveryStore.reserveOutgoingIfAbsent).toHaveBeenCalledTimes(2); |
| 261 | + expect(deliveryStore.reserveOutgoingIfAbsent).toHaveBeenNthCalledWith( |
| 262 | + 1, |
| 263 | + expect.objectContaining({ |
| 264 | + idempotencyKey: "telegram:followup:inbox-item:initial:task-1" |
| 265 | + }) |
| 266 | + ); |
| 267 | + expect(deliveryStore.reserveOutgoingIfAbsent).toHaveBeenNthCalledWith( |
| 268 | + 2, |
| 269 | + expect.objectContaining({ |
| 270 | + idempotencyKey: "telegram:followup:inbox-item:initial:task-1" |
| 271 | + }) |
| 272 | + ); |
| 273 | + }); |
190 | 274 | }); |
0 commit comments