@crewhaus/queue-protocol 0.1.4 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,729 +0,0 @@
1
- /**
2
- * Section 30 — contract tests for the new queue adapters.
3
- *
4
- * Each adapter is wired to a stub client that mimics the SDK shape.
5
- * The contract corpus exercises the same lifecycle the in-memory
6
- * adapter passes: pull → ack ; pull → nack(transient) → re-pull ;
7
- * pull → nack(permanent) → dead-letter.
8
- */
9
- import { afterEach, describe, expect, mock, test } from "bun:test";
10
- import { QueueProtocolError } from "../index";
11
- import { type PostgresClientLike, createPostgresAdapter } from "./postgres";
12
- import { type RedisClientLike, createRedisStreamsAdapter } from "./redis-streams";
13
- import { type SqsClientLike, createSqsAdapter } from "./sqs";
14
-
15
- // These adapters use plain dependency injection (`_client`) — no real
16
- // Postgres/Redis/SQS connection, no real clock dependency in assertions, and
17
- // no leaked handles/sockets. We still restore any module mock after each test
18
- // so a future `mock.module(...)` here can never bleed into a sibling test.
19
- afterEach(() => {
20
- mock.restore();
21
- });
22
-
23
- describe("sqs adapter — T2 contract", () => {
24
- test("pull returns deserialized jobs", async () => {
25
- const client: SqsClientLike = {
26
- receiveMessage: async () => ({
27
- Messages: [
28
- {
29
- MessageId: "msg-1",
30
- ReceiptHandle: "rh-1",
31
- Body: JSON.stringify({ job: "value" }),
32
- },
33
- ],
34
- }),
35
- deleteMessage: async () => undefined,
36
- changeMessageVisibility: async () => undefined,
37
- };
38
- const adapter = createSqsAdapter({
39
- queueUrl: "https://sqs.test/queue",
40
- region: "us-east-1",
41
- _client: client,
42
- });
43
- const jobs = await adapter.pull({ maxJobs: 5, visibilityTimeoutMs: 30_000 });
44
- expect(jobs.length).toBe(1);
45
- expect(jobs[0]?.id).toBe("msg-1");
46
- expect(jobs[0]?.input).toEqual({ job: "value" });
47
- });
48
-
49
- test("ack deletes the message", async () => {
50
- let deleteCalls = 0;
51
- const client: SqsClientLike = {
52
- receiveMessage: async () => ({
53
- Messages: [{ MessageId: "msg-1", ReceiptHandle: "rh-1", Body: "{}" }],
54
- }),
55
- deleteMessage: async () => {
56
- deleteCalls++;
57
- },
58
- changeMessageVisibility: async () => undefined,
59
- };
60
- const adapter = createSqsAdapter({
61
- queueUrl: "https://sqs.test/queue",
62
- region: "us-east-1",
63
- _client: client,
64
- });
65
- const jobs = await adapter.pull({});
66
- if (!jobs[0]) throw new Error("expected job");
67
- await adapter.ack(jobs[0].id);
68
- expect(deleteCalls).toBe(1);
69
- });
70
-
71
- test("nack(transient) resets visibility to 0", async () => {
72
- let resetTo: number | undefined;
73
- const client: SqsClientLike = {
74
- receiveMessage: async () => ({
75
- Messages: [{ MessageId: "msg-1", ReceiptHandle: "rh-1", Body: "{}" }],
76
- }),
77
- deleteMessage: async () => undefined,
78
- changeMessageVisibility: async (input) => {
79
- resetTo = input.VisibilityTimeout;
80
- },
81
- };
82
- const adapter = createSqsAdapter({
83
- queueUrl: "u",
84
- region: "r",
85
- _client: client,
86
- });
87
- const jobs = await adapter.pull({});
88
- if (!jobs[0]) throw new Error("expected job");
89
- await adapter.nack(jobs[0].id, "transient");
90
- expect(resetTo).toBe(0);
91
- });
92
-
93
- test("nack(permanent) deletes (relying on SQS redrive policy for DLQ)", async () => {
94
- let deleteCalls = 0;
95
- const client: SqsClientLike = {
96
- receiveMessage: async () => ({
97
- Messages: [{ MessageId: "msg-1", ReceiptHandle: "rh-1", Body: "{}" }],
98
- }),
99
- deleteMessage: async () => {
100
- deleteCalls++;
101
- },
102
- changeMessageVisibility: async () => undefined,
103
- };
104
- const adapter = createSqsAdapter({
105
- queueUrl: "u",
106
- region: "r",
107
- _client: client,
108
- });
109
- const jobs = await adapter.pull({});
110
- if (!jobs[0]) throw new Error("expected job");
111
- await adapter.nack(jobs[0].id, "permanent");
112
- const stats = await adapter.stats();
113
- expect(stats.deadLetter).toBe(1);
114
- expect(deleteCalls).toBe(1);
115
- });
116
-
117
- test("missing queueUrl throws", () => {
118
- expect(() =>
119
- createSqsAdapter({ queueUrl: "", region: "us-east-1", _client: {} as never }),
120
- ).toThrow(QueueProtocolError);
121
- });
122
- });
123
-
124
- describe("redis-streams adapter — T2 contract", () => {
125
- test("pull deserializes payloads", async () => {
126
- const client: RedisClientLike = {
127
- xreadgroup: async () => [
128
- {
129
- stream: "s",
130
- messages: [{ id: "1-0", fields: { payload: JSON.stringify({ job: "value" }) } }],
131
- },
132
- ],
133
- xack: async () => 1,
134
- xadd: async () => "1-1",
135
- };
136
- const adapter = createRedisStreamsAdapter({
137
- streamKey: "s",
138
- consumerGroup: "g",
139
- consumerName: "c",
140
- _client: client,
141
- });
142
- const jobs = await adapter.pull({ maxJobs: 1 });
143
- expect(jobs.length).toBe(1);
144
- expect(jobs[0]?.input).toEqual({ job: "value" });
145
- });
146
-
147
- test("nack(permanent) writes to dead-letter stream when configured", async () => {
148
- let dlqWrites = 0;
149
- const client: RedisClientLike = {
150
- xreadgroup: async () => null,
151
- xack: async () => 1,
152
- xadd: async (streamKey: string) => {
153
- if (streamKey === "dlq") dlqWrites++;
154
- return "1-0";
155
- },
156
- };
157
- const adapter = createRedisStreamsAdapter({
158
- streamKey: "s",
159
- consumerGroup: "g",
160
- consumerName: "c",
161
- deadLetterStream: "dlq",
162
- _client: client,
163
- });
164
- await adapter.nack("1-0", "permanent");
165
- expect(dlqWrites).toBe(1);
166
- });
167
-
168
- test("missing streamKey throws", () => {
169
- expect(() =>
170
- createRedisStreamsAdapter({
171
- streamKey: "",
172
- consumerGroup: "g",
173
- consumerName: "c",
174
- _client: {} as never,
175
- }),
176
- ).toThrow(QueueProtocolError);
177
- });
178
- });
179
-
180
- describe("postgres adapter — T2 contract", () => {
181
- test("pull issues SELECT … FOR UPDATE SKIP LOCKED", async () => {
182
- const queries: string[] = [];
183
- const client: PostgresClientLike = {
184
- query: async (text) => {
185
- queries.push(text);
186
- if (text.includes("UPDATE")) {
187
- return {
188
- rows: [
189
- {
190
- id: "j1",
191
- payload: JSON.stringify({ job: "value" }),
192
- enqueued_at: new Date().toISOString(),
193
- visibility_expires_at: new Date(Date.now() + 60_000).toISOString(),
194
- attempt: 1,
195
- },
196
- ],
197
- };
198
- }
199
- return { rows: [] };
200
- },
201
- };
202
- const adapter = createPostgresAdapter({ tableName: "jobs", _client: client });
203
- const jobs = await adapter.pull({ maxJobs: 5 });
204
- expect(jobs.length).toBe(1);
205
- expect(jobs[0]?.input).toEqual({ job: "value" });
206
- expect(queries[0]).toContain("FOR UPDATE SKIP LOCKED");
207
- });
208
-
209
- test("ack deletes the row", async () => {
210
- let deletes = 0;
211
- const client: PostgresClientLike = {
212
- query: async (text) => {
213
- if (text.includes("DELETE")) deletes++;
214
- return { rows: [] };
215
- },
216
- };
217
- const adapter = createPostgresAdapter({ tableName: "jobs", _client: client });
218
- await adapter.ack("j1");
219
- expect(deletes).toBe(1);
220
- });
221
-
222
- test("nack(permanent) inserts into dead-letter table", async () => {
223
- let dlqInsert = false;
224
- const client: PostgresClientLike = {
225
- query: async (text) => {
226
- if (text.includes("INSERT INTO dead_letter")) dlqInsert = true;
227
- return { rows: [] };
228
- },
229
- };
230
- const adapter = createPostgresAdapter({
231
- tableName: "jobs",
232
- deadLetterTable: "dead_letter",
233
- _client: client,
234
- });
235
- await adapter.nack("j1", "permanent");
236
- expect(dlqInsert).toBe(true);
237
- });
238
-
239
- test("rejects sql-injection in tableName", () => {
240
- expect(() =>
241
- createPostgresAdapter({
242
- tableName: "jobs; DROP TABLE users;",
243
- _client: {} as never,
244
- }),
245
- ).toThrow(QueueProtocolError);
246
- });
247
- });
248
-
249
- describe("queue-protocol — adapter requireClient stubs throw without SDKs", () => {
250
- test("sqs without _client throws QueueProtocolError on construction", () => {
251
- expect(() => createSqsAdapter({ queueUrl: "u", region: "r" })).toThrow(QueueProtocolError);
252
- });
253
-
254
- test("redis-streams without _client throws", () => {
255
- expect(() =>
256
- createRedisStreamsAdapter({ streamKey: "s", consumerGroup: "g", consumerName: "c" }),
257
- ).toThrow(QueueProtocolError);
258
- });
259
-
260
- test("postgres without _client throws", () => {
261
- expect(() => createPostgresAdapter({ tableName: "jobs" })).toThrow(QueueProtocolError);
262
- });
263
- });
264
-
265
- // ---------------------------------------------------------------------------
266
- // Section 30 — STRICT coverage: every adapter line + function, including the
267
- // error/retry paths and the no-op / counter branches.
268
- // ---------------------------------------------------------------------------
269
-
270
- describe("sqs adapter — full lifecycle coverage", () => {
271
- /** Build a stub SQS client that records every call for assertions. */
272
- function makeClient(overrides: Partial<SqsClientLike> = {}): {
273
- client: SqsClientLike;
274
- deleted: Array<{ QueueUrl: string; ReceiptHandle: string }>;
275
- visibilityChanges: Array<{ ReceiptHandle: string; VisibilityTimeout: number }>;
276
- } {
277
- const deleted: Array<{ QueueUrl: string; ReceiptHandle: string }> = [];
278
- const visibilityChanges: Array<{ ReceiptHandle: string; VisibilityTimeout: number }> = [];
279
- const client: SqsClientLike = {
280
- receiveMessage: async () => ({
281
- Messages: [{ MessageId: "msg-1", ReceiptHandle: "rh-1", Body: JSON.stringify({ k: "v" }) }],
282
- }),
283
- deleteMessage: async (input) => {
284
- deleted.push(input);
285
- },
286
- changeMessageVisibility: async (input) => {
287
- visibilityChanges.push({
288
- ReceiptHandle: input.ReceiptHandle,
289
- VisibilityTimeout: input.VisibilityTimeout,
290
- });
291
- },
292
- ...overrides,
293
- };
294
- return { client, deleted, visibilityChanges };
295
- }
296
-
297
- test("pull falls back to raw Body when payload is not JSON (line 75)", async () => {
298
- const { client } = makeClient({
299
- receiveMessage: async () => ({
300
- Messages: [{ MessageId: "msg-raw", ReceiptHandle: "rh-raw", Body: "not-json-at-all" }],
301
- }),
302
- });
303
- const adapter = createSqsAdapter({ queueUrl: "u", region: "r", _client: client });
304
- const jobs = await adapter.pull({});
305
- expect(jobs.length).toBe(1);
306
- // JSON.parse throws → catch keeps the raw string.
307
- expect(jobs[0]?.input).toBe("not-json-at-all");
308
- });
309
-
310
- test("pull skips messages missing MessageId/ReceiptHandle/Body", async () => {
311
- const { client } = makeClient({
312
- receiveMessage: async () => ({
313
- Messages: [
314
- { ReceiptHandle: "rh", Body: "{}" }, // no MessageId
315
- { MessageId: "m", Body: "{}" }, // no ReceiptHandle
316
- { MessageId: "m", ReceiptHandle: "rh" }, // no Body
317
- { MessageId: "ok", ReceiptHandle: "rh-ok", Body: "{}" },
318
- ],
319
- }),
320
- });
321
- const adapter = createSqsAdapter({ queueUrl: "u", region: "r", _client: client });
322
- const jobs = await adapter.pull({});
323
- expect(jobs.map((j) => j.id)).toEqual(["ok"]);
324
- });
325
-
326
- test("pull handles an empty receive (no Messages field)", async () => {
327
- const { client } = makeClient({ receiveMessage: async () => ({}) });
328
- const adapter = createSqsAdapter({ queueUrl: "u", region: "r", _client: client });
329
- expect(await adapter.pull({})).toEqual([]);
330
- });
331
-
332
- test("nack(transient) resets visibility, drops the receipt, bumps nacked (lines 103-112)", async () => {
333
- const { client, visibilityChanges } = makeClient();
334
- const adapter = createSqsAdapter({ queueUrl: "u", region: "r", _client: client });
335
- const [job] = await adapter.pull({});
336
- if (!job) throw new Error("expected job");
337
- await adapter.nack(job.id, "transient");
338
- expect(visibilityChanges).toEqual([{ ReceiptHandle: "rh-1", VisibilityTimeout: 0 }]);
339
- const stats = await adapter.stats();
340
- expect(stats.nacked).toBe(1);
341
- expect(stats.deadLetter).toBe(0);
342
- // Receipt was deleted from the in-flight map.
343
- expect(stats.inFlight).toBe(0);
344
- // A second nack for the same id now has no receipt → throws.
345
- await expect(adapter.nack(job.id, "transient")).rejects.toThrow(QueueProtocolError);
346
- });
347
-
348
- test("nack(permanent) deletes from main queue and bumps deadLetter+nacked", async () => {
349
- const { client, deleted } = makeClient();
350
- const adapter = createSqsAdapter({ queueUrl: "u", region: "r", _client: client });
351
- const [job] = await adapter.pull({});
352
- if (!job) throw new Error("expected job");
353
- await adapter.nack(job.id, "permanent");
354
- expect(deleted).toEqual([{ QueueUrl: "u", ReceiptHandle: "rh-1" }]);
355
- const stats = await adapter.stats();
356
- expect(stats.nacked).toBe(1);
357
- expect(stats.deadLetter).toBe(1);
358
- });
359
-
360
- test("nack without a known receipt throws (line 97)", async () => {
361
- const { client } = makeClient();
362
- const adapter = createSqsAdapter({ queueUrl: "u", region: "r", _client: client });
363
- await expect(adapter.nack("never-pulled", "transient")).rejects.toThrow(
364
- /sqs nack: receipt for never-pulled not found/,
365
- );
366
- });
367
-
368
- test("extendVisibility pushes the lease forward (lines 114-122)", async () => {
369
- const { client, visibilityChanges } = makeClient();
370
- const adapter = createSqsAdapter({ queueUrl: "u", region: "r", _client: client });
371
- const [job] = await adapter.pull({});
372
- if (!job) throw new Error("expected job");
373
- // 4500ms → ceil to 5 seconds.
374
- await adapter.extendVisibility(job.id, 4_500);
375
- expect(visibilityChanges).toEqual([{ ReceiptHandle: "rh-1", VisibilityTimeout: 5 }]);
376
- });
377
-
378
- test("extendVisibility without a known receipt throws (lines 116-117)", async () => {
379
- const { client } = makeClient();
380
- const adapter = createSqsAdapter({ queueUrl: "u", region: "r", _client: client });
381
- await expect(adapter.extendVisibility("ghost", 1_000)).rejects.toThrow(
382
- /sqs extendVisibility: receipt for ghost not found/,
383
- );
384
- });
385
-
386
- test("ack without a known receipt throws (line 90)", async () => {
387
- const { client } = makeClient();
388
- const adapter = createSqsAdapter({ queueUrl: "u", region: "r", _client: client });
389
- await expect(adapter.ack("ghost")).rejects.toThrow(/sqs ack: receipt for ghost not found/);
390
- });
391
-
392
- test("stats reports inFlight from the receipt map", async () => {
393
- const { client } = makeClient({
394
- receiveMessage: async () => ({
395
- Messages: [
396
- { MessageId: "a", ReceiptHandle: "rh-a", Body: "{}" },
397
- { MessageId: "b", ReceiptHandle: "rh-b", Body: "{}" },
398
- ],
399
- }),
400
- });
401
- const adapter = createSqsAdapter({ queueUrl: "u", region: "r", _client: client });
402
- await adapter.pull({});
403
- const stats = await adapter.stats();
404
- expect(stats).toEqual({ pending: 0, inFlight: 2, acked: 0, nacked: 0, deadLetter: 0 });
405
- });
406
- });
407
-
408
- describe("redis-streams adapter — full lifecycle coverage", () => {
409
- function makeClient(overrides: Partial<RedisClientLike> = {}): {
410
- client: RedisClientLike;
411
- acks: Array<{ streamKey: string; group: string; ids: string[] }>;
412
- adds: Array<{ streamKey: string; id: string; fields: string[] }>;
413
- } {
414
- const acks: Array<{ streamKey: string; group: string; ids: string[] }> = [];
415
- const adds: Array<{ streamKey: string; id: string; fields: string[] }> = [];
416
- const client: RedisClientLike = {
417
- xreadgroup: async () => null,
418
- xack: async (streamKey, group, ...ids) => {
419
- acks.push({ streamKey, group, ids });
420
- return ids.length;
421
- },
422
- xadd: async (streamKey, id, ...fields) => {
423
- adds.push({ streamKey, id, fields });
424
- return "1-0";
425
- },
426
- ...overrides,
427
- };
428
- return { client, acks, adds };
429
- }
430
-
431
- test("pull falls back to raw payload when not JSON (line 72)", async () => {
432
- const { client } = makeClient({
433
- xreadgroup: async () => [
434
- { stream: "s", messages: [{ id: "1-0", fields: { payload: "<<raw>>" } }] },
435
- ],
436
- });
437
- const adapter = createRedisStreamsAdapter({
438
- streamKey: "s",
439
- consumerGroup: "g",
440
- consumerName: "c",
441
- _client: client,
442
- });
443
- const jobs = await adapter.pull({ maxJobs: 1 });
444
- expect(jobs.length).toBe(1);
445
- expect(jobs[0]?.input).toBe("<<raw>>");
446
- expect(jobs[0]?.attempt).toBe(1);
447
- });
448
-
449
- test("pull defaults missing payload field to {} and maps multiple streams (lines 84-85)", async () => {
450
- const { client } = makeClient({
451
- xreadgroup: async () => [
452
- { stream: "s1", messages: [{ id: "1-0", fields: {} }] },
453
- { stream: "s2", messages: [{ id: "2-0", fields: { payload: '{"x":1}' } }] },
454
- ],
455
- });
456
- const adapter = createRedisStreamsAdapter({
457
- streamKey: "s",
458
- consumerGroup: "g",
459
- consumerName: "c",
460
- _client: client,
461
- });
462
- const jobs = await adapter.pull({});
463
- expect(jobs.map((j) => j.id)).toEqual(["1-0", "2-0"]);
464
- expect(jobs[0]?.input).toEqual({});
465
- expect(jobs[1]?.input).toEqual({ x: 1 });
466
- });
467
-
468
- test("ack issues XACK and bumps acked (lines 86-88)", async () => {
469
- const { client, acks } = makeClient();
470
- const adapter = createRedisStreamsAdapter({
471
- streamKey: "s",
472
- consumerGroup: "g",
473
- consumerName: "c",
474
- _client: client,
475
- });
476
- await adapter.ack("1-0");
477
- expect(acks).toEqual([{ streamKey: "s", group: "g", ids: ["1-0"] }]);
478
- const stats = await adapter.stats();
479
- expect(stats.acked).toBe(1);
480
- });
481
-
482
- test("nack(transient) re-publishes to the stream tail then XACKs (lines 94-99)", async () => {
483
- const { client, acks, adds } = makeClient();
484
- const adapter = createRedisStreamsAdapter({
485
- streamKey: "s",
486
- consumerGroup: "g",
487
- consumerName: "c",
488
- _client: client,
489
- });
490
- await adapter.nack("1-0", "transient");
491
- // Re-published to the main stream tail with a retry marker…
492
- expect(adds).toEqual([
493
- { streamKey: "s", id: "*", fields: ["payload", JSON.stringify({ retry: "1-0" })] },
494
- ]);
495
- // …and acked on the consumer group so the original is removed.
496
- expect(acks).toEqual([{ streamKey: "s", group: "g", ids: ["1-0"] }]);
497
- const stats = await adapter.stats();
498
- expect(stats.nacked).toBe(1);
499
- expect(stats.deadLetter).toBe(0);
500
- });
501
-
502
- test("nack(permanent) without a configured DLQ falls back to re-publish", async () => {
503
- // deadLetterStream undefined → the `else` (re-publish) branch runs even
504
- // though the reason is permanent.
505
- const { client, adds } = makeClient();
506
- const adapter = createRedisStreamsAdapter({
507
- streamKey: "s",
508
- consumerGroup: "g",
509
- consumerName: "c",
510
- _client: client,
511
- });
512
- await adapter.nack("9-9", "permanent");
513
- expect(adds).toEqual([
514
- { streamKey: "s", id: "*", fields: ["payload", JSON.stringify({ retry: "9-9" })] },
515
- ]);
516
- const stats = await adapter.stats();
517
- expect(stats.deadLetter).toBe(0);
518
- expect(stats.nacked).toBe(1);
519
- });
520
-
521
- test("nack(permanent) with DLQ writes to dead-letter, bumps counters (lines 91-93)", async () => {
522
- const { client, adds, acks } = makeClient();
523
- const adapter = createRedisStreamsAdapter({
524
- streamKey: "s",
525
- consumerGroup: "g",
526
- consumerName: "c",
527
- deadLetterStream: "dlq",
528
- _client: client,
529
- });
530
- await adapter.nack("7-7", "permanent");
531
- expect(adds).toEqual([
532
- { streamKey: "dlq", id: "*", fields: ["payload", JSON.stringify({ jobId: "7-7" })] },
533
- ]);
534
- expect(acks).toEqual([{ streamKey: "s", group: "g", ids: ["7-7"] }]);
535
- const stats = await adapter.stats();
536
- expect(stats.deadLetter).toBe(1);
537
- expect(stats.nacked).toBe(1);
538
- });
539
-
540
- test("extendVisibility is a no-op and resolves (lines 101-103)", async () => {
541
- const { client, acks, adds } = makeClient();
542
- const adapter = createRedisStreamsAdapter({
543
- streamKey: "s",
544
- consumerGroup: "g",
545
- consumerName: "c",
546
- _client: client,
547
- });
548
- await expect(adapter.extendVisibility("1-0", 60_000)).resolves.toBeUndefined();
549
- // Truly a no-op: no Redis calls were made.
550
- expect(acks).toEqual([]);
551
- expect(adds).toEqual([]);
552
- });
553
-
554
- test("missing consumerGroup throws (line 47-48)", () => {
555
- expect(() =>
556
- createRedisStreamsAdapter({
557
- streamKey: "s",
558
- consumerGroup: "",
559
- consumerName: "c",
560
- _client: {} as never,
561
- }),
562
- ).toThrow(QueueProtocolError);
563
- });
564
-
565
- test("stats returns local counters with pending/inFlight = 0 (lines 104-112)", async () => {
566
- const { client } = makeClient();
567
- const adapter = createRedisStreamsAdapter({
568
- streamKey: "s",
569
- consumerGroup: "g",
570
- consumerName: "c",
571
- _client: client,
572
- });
573
- await adapter.ack("a");
574
- await adapter.nack("b", "transient");
575
- expect(await adapter.stats()).toEqual({
576
- pending: 0,
577
- inFlight: 0,
578
- acked: 1,
579
- nacked: 1,
580
- deadLetter: 0,
581
- });
582
- });
583
- });
584
-
585
- describe("postgres adapter — full lifecycle coverage", () => {
586
- /** A query stub that records SQL text + params and returns canned rows. */
587
- function makeClient(
588
- handler: (text: string, params?: unknown[]) => { rows: unknown[] } = () => ({ rows: [] }),
589
- ): { client: PostgresClientLike; calls: Array<{ text: string; params?: unknown[] }> } {
590
- const calls: Array<{ text: string; params?: unknown[] }> = [];
591
- const client: PostgresClientLike = {
592
- query: async (text, params) => {
593
- calls.push({ text, params });
594
- return handler(text, params) as { rows: never[] };
595
- },
596
- };
597
- return { client, calls };
598
- }
599
-
600
- test("pull falls back to raw payload when not JSON (line 72)", async () => {
601
- const { client } = makeClient(() => ({
602
- rows: [
603
- {
604
- id: "j-raw",
605
- payload: "definitely-not-json",
606
- enqueued_at: new Date(0).toISOString(),
607
- visibility_expires_at: new Date(60_000).toISOString(),
608
- attempt: 2,
609
- },
610
- ],
611
- }));
612
- const adapter = createPostgresAdapter({ tableName: "jobs", _client: client });
613
- const jobs = await adapter.pull({ maxJobs: 1, visibilityTimeoutMs: 5_000 });
614
- expect(jobs.length).toBe(1);
615
- expect(jobs[0]?.input).toBe("definitely-not-json");
616
- expect(jobs[0]?.attempt).toBe(2);
617
- // Timestamps are converted to epoch millis.
618
- expect(jobs[0]?.enqueuedAt).toBe(0);
619
- expect(jobs[0]?.visibilityExpiresAt).toBe(60_000);
620
- });
621
-
622
- test("pull computes visibility seconds from ms (ceil) into the SQL", async () => {
623
- const { client, calls } = makeClient(() => ({ rows: [] }));
624
- const adapter = createPostgresAdapter({ tableName: "jobs", _client: client });
625
- await adapter.pull({ visibilityTimeoutMs: 1_500 }); // ceil(1500/1000) = 2
626
- expect(calls[0]?.text).toContain("INTERVAL '2 seconds'");
627
- expect(calls[0]?.params).toEqual([10]); // default maxJobs
628
- });
629
-
630
- test("nack(transient) UPDATEs visibility to NOW and bumps nacked (lines 101-107)", async () => {
631
- const { client, calls } = makeClient();
632
- const adapter = createPostgresAdapter({ tableName: "jobs", _client: client });
633
- await adapter.nack("j1", "transient");
634
- expect(calls.length).toBe(1);
635
- expect(calls[0]?.text).toContain("SET visibility_expires_at = NOW() WHERE id = $1");
636
- expect(calls[0]?.params).toEqual(["j1"]);
637
- expect((await adapter.stats()).nacked).toBe(1);
638
- });
639
-
640
- test("nack(permanent) without a DLQ table takes the transient UPDATE branch", async () => {
641
- // deadLetterTable undefined → the `if (reason === "permanent" && dlqTable)`
642
- // guard is false, so it UPDATEs (re-enqueues) instead of dead-lettering.
643
- const { client, calls } = makeClient();
644
- const adapter = createPostgresAdapter({ tableName: "jobs", _client: client });
645
- await adapter.nack("j1", "permanent");
646
- expect(calls[0]?.text).toContain("SET visibility_expires_at = NOW()");
647
- expect((await adapter.stats()).deadLetter).toBe(0);
648
- });
649
-
650
- test("nack(permanent) with a DLQ table INSERTs then DELETEs (lines 94-100)", async () => {
651
- const { client, calls } = makeClient();
652
- const adapter = createPostgresAdapter({
653
- tableName: "jobs",
654
- deadLetterTable: "dead_letter_jobs",
655
- _client: client,
656
- });
657
- await adapter.nack("j1", "permanent");
658
- expect(calls[0]?.text).toContain("INSERT INTO dead_letter_jobs");
659
- expect(calls[1]?.text).toContain("DELETE FROM jobs WHERE id = $1");
660
- const stats = await adapter.stats();
661
- expect(stats.deadLetter).toBe(1);
662
- expect(stats.nacked).toBe(1);
663
- });
664
-
665
- test("nack(permanent) rejects an injection-y deadLetterTable (line 92)", async () => {
666
- const { client } = makeClient();
667
- const adapter = createPostgresAdapter({
668
- tableName: "jobs",
669
- deadLetterTable: "dlq; DROP TABLE users;",
670
- _client: client,
671
- });
672
- await expect(adapter.nack("j1", "permanent")).rejects.toThrow(
673
- /invalid deadLetterTable "dlq; DROP TABLE users;"/,
674
- );
675
- });
676
-
677
- test("ack DELETEs the row and bumps acked", async () => {
678
- const { client, calls } = makeClient();
679
- const adapter = createPostgresAdapter({ tableName: "jobs", _client: client });
680
- await adapter.ack("j1");
681
- expect(calls[0]?.text).toContain("DELETE FROM jobs WHERE id = $1");
682
- expect(calls[0]?.params).toEqual(["j1"]);
683
- expect((await adapter.stats()).acked).toBe(1);
684
- });
685
-
686
- test("extendVisibility UPDATEs with ceil(ms/1000) seconds (lines 109-115)", async () => {
687
- const { client, calls } = makeClient();
688
- const adapter = createPostgresAdapter({ tableName: "jobs", _client: client });
689
- await adapter.extendVisibility("j1", 2_001); // ceil → 3
690
- expect(calls[0]?.text).toContain("INTERVAL '3 seconds'");
691
- expect(calls[0]?.text).toContain("WHERE id = $1");
692
- expect(calls[0]?.params).toEqual(["j1"]);
693
- });
694
-
695
- test("stats parses pending + inFlight counts from COUNT(*) queries (lines 116-134)", async () => {
696
- const { client, calls } = makeClient((text) => {
697
- if (text.includes("<= NOW()")) return { rows: [{ count: "4" }] };
698
- if (text.includes("> NOW()")) return { rows: [{ count: "2" }] };
699
- return { rows: [] };
700
- });
701
- const adapter = createPostgresAdapter({ tableName: "jobs", _client: client });
702
- // Drive the terminal counters too.
703
- await adapter.ack("a");
704
- await adapter.nack("b", "transient");
705
- const stats = await adapter.stats();
706
- expect(stats).toEqual({ pending: 4, inFlight: 2, acked: 1, nacked: 1, deadLetter: 0 });
707
- // Two COUNT queries were issued (plus ack DELETE + nack UPDATE before).
708
- const countQueries = calls.filter((c) => c.text.includes("COUNT(*)"));
709
- expect(countQueries.length).toBe(2);
710
- });
711
-
712
- test("stats defaults to 0 when COUNT returns no rows (?? '0' fallback)", async () => {
713
- const { client } = makeClient(() => ({ rows: [] }));
714
- const adapter = createPostgresAdapter({ tableName: "jobs", _client: client });
715
- expect(await adapter.stats()).toEqual({
716
- pending: 0,
717
- inFlight: 0,
718
- acked: 0,
719
- nacked: 0,
720
- deadLetter: 0,
721
- });
722
- });
723
-
724
- test("missing tableName throws (line 37)", () => {
725
- expect(() => createPostgresAdapter({ tableName: "", _client: {} as never })).toThrow(
726
- /requires tableName/,
727
- );
728
- });
729
- });