@nicnocquee/dataqueue 1.30.0 → 1.32.0

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.
@@ -383,6 +383,46 @@ describe('Redis backend integration', () => {
383
383
  expect(job).toBeNull();
384
384
  });
385
385
 
386
+ it('should cleanup old completed jobs in batches', async () => {
387
+ const ids: number[] = [];
388
+ for (let i = 0; i < 5; i++) {
389
+ const jobId = await jobQueue.addJob({
390
+ jobType: 'test',
391
+ payload: { foo: `batch-${i}` },
392
+ });
393
+ ids.push(jobId);
394
+ }
395
+ // Complete all jobs
396
+ const processor = jobQueue.createProcessor({
397
+ email: vi.fn(async () => {}),
398
+ sms: vi.fn(async () => {}),
399
+ test: vi.fn(async () => {}),
400
+ });
401
+ await processor.start();
402
+ for (const id of ids) {
403
+ const job = await jobQueue.getJob(id);
404
+ expect(job?.status).toBe('completed');
405
+ }
406
+
407
+ // Backdate all to 31 days ago
408
+ const oldMs = Date.now() - 31 * 24 * 60 * 60 * 1000;
409
+ for (const id of ids) {
410
+ await redisClient.hset(
411
+ `${prefix}job:${id}`,
412
+ 'updatedAt',
413
+ oldMs.toString(),
414
+ );
415
+ }
416
+
417
+ // Cleanup with small batchSize to force multiple SSCAN iterations
418
+ const deleted = await jobQueue.cleanupOldJobs(30, 2);
419
+ expect(deleted).toBe(5);
420
+ for (const id of ids) {
421
+ const job = await jobQueue.getJob(id);
422
+ expect(job).toBeNull();
423
+ }
424
+ });
425
+
386
426
  it('should reclaim stuck jobs', async () => {
387
427
  const jobId = await jobQueue.addJob({
388
428
  jobType: 'email',
@@ -541,3 +581,934 @@ describe('Redis backend integration', () => {
541
581
  expect(job?.status).toBe('pending');
542
582
  });
543
583
  });
584
+
585
+ describe('Redis cron schedules integration', () => {
586
+ let prefix: string;
587
+ let jobQueue: ReturnType<typeof initJobQueue<TestPayloadMap>>;
588
+ let redisClient: any;
589
+
590
+ beforeEach(async () => {
591
+ prefix = createRedisTestPrefix();
592
+ const config: RedisJobQueueConfig = {
593
+ backend: 'redis',
594
+ redisConfig: {
595
+ url: REDIS_URL,
596
+ keyPrefix: prefix,
597
+ },
598
+ };
599
+ jobQueue = initJobQueue<TestPayloadMap>(config);
600
+ redisClient = jobQueue.getRedisClient();
601
+ });
602
+
603
+ afterEach(async () => {
604
+ vi.restoreAllMocks();
605
+ await cleanupRedisPrefix(redisClient, prefix);
606
+ await redisClient.quit();
607
+ });
608
+
609
+ it('creates a cron schedule and retrieves it by ID', async () => {
610
+ // Act
611
+ const id = await jobQueue.addCronJob({
612
+ scheduleName: 'every-5-min-email',
613
+ cronExpression: '*/5 * * * *',
614
+ jobType: 'email',
615
+ payload: { to: 'cron@example.com' },
616
+ });
617
+
618
+ // Assert
619
+ const schedule = await jobQueue.getCronJob(id);
620
+ expect(schedule).not.toBeNull();
621
+ expect(schedule!.scheduleName).toBe('every-5-min-email');
622
+ expect(schedule!.cronExpression).toBe('*/5 * * * *');
623
+ expect(schedule!.jobType).toBe('email');
624
+ expect(schedule!.payload).toEqual({ to: 'cron@example.com' });
625
+ expect(schedule!.status).toBe('active');
626
+ expect(schedule!.allowOverlap).toBe(false);
627
+ expect(schedule!.timezone).toBe('UTC');
628
+ expect(schedule!.nextRunAt).toBeInstanceOf(Date);
629
+ });
630
+
631
+ it('retrieves a cron schedule by name', async () => {
632
+ // Setup
633
+ await jobQueue.addCronJob({
634
+ scheduleName: 'my-schedule',
635
+ cronExpression: '0 * * * *',
636
+ jobType: 'email',
637
+ payload: { to: 'test@example.com' },
638
+ });
639
+
640
+ // Act
641
+ const schedule = await jobQueue.getCronJobByName('my-schedule');
642
+
643
+ // Assert
644
+ expect(schedule).not.toBeNull();
645
+ expect(schedule!.scheduleName).toBe('my-schedule');
646
+ });
647
+
648
+ it('returns null for nonexistent schedule', async () => {
649
+ // Act
650
+ const byId = await jobQueue.getCronJob(99999);
651
+ const byName = await jobQueue.getCronJobByName('nonexistent');
652
+
653
+ // Assert
654
+ expect(byId).toBeNull();
655
+ expect(byName).toBeNull();
656
+ });
657
+
658
+ it('rejects duplicate schedule names', async () => {
659
+ // Setup
660
+ await jobQueue.addCronJob({
661
+ scheduleName: 'unique-name',
662
+ cronExpression: '* * * * *',
663
+ jobType: 'email',
664
+ payload: { to: 'a@example.com' },
665
+ });
666
+
667
+ // Act & Assert
668
+ await expect(
669
+ jobQueue.addCronJob({
670
+ scheduleName: 'unique-name',
671
+ cronExpression: '*/5 * * * *',
672
+ jobType: 'sms',
673
+ payload: { to: 'b@example.com' },
674
+ }),
675
+ ).rejects.toThrow();
676
+ });
677
+
678
+ it('rejects invalid cron expressions', async () => {
679
+ // Act & Assert
680
+ await expect(
681
+ jobQueue.addCronJob({
682
+ scheduleName: 'bad-cron',
683
+ cronExpression: 'not a cron',
684
+ jobType: 'email',
685
+ payload: { to: 'a@example.com' },
686
+ }),
687
+ ).rejects.toThrow('Invalid cron expression');
688
+ });
689
+
690
+ it('lists active and paused schedules', async () => {
691
+ // Setup
692
+ const id1 = await jobQueue.addCronJob({
693
+ scheduleName: 'schedule-1',
694
+ cronExpression: '* * * * *',
695
+ jobType: 'email',
696
+ payload: { to: 'a@example.com' },
697
+ });
698
+ await jobQueue.addCronJob({
699
+ scheduleName: 'schedule-2',
700
+ cronExpression: '*/5 * * * *',
701
+ jobType: 'sms',
702
+ payload: { to: 'b@example.com' },
703
+ });
704
+ await jobQueue.pauseCronJob(id1);
705
+
706
+ // Act
707
+ const all = await jobQueue.listCronJobs();
708
+ const active = await jobQueue.listCronJobs('active');
709
+ const paused = await jobQueue.listCronJobs('paused');
710
+
711
+ // Assert
712
+ expect(all).toHaveLength(2);
713
+ expect(active).toHaveLength(1);
714
+ expect(active[0].scheduleName).toBe('schedule-2');
715
+ expect(paused).toHaveLength(1);
716
+ expect(paused[0].scheduleName).toBe('schedule-1');
717
+ });
718
+
719
+ it('pauses and resumes a schedule', async () => {
720
+ // Setup
721
+ const id = await jobQueue.addCronJob({
722
+ scheduleName: 'pausable',
723
+ cronExpression: '* * * * *',
724
+ jobType: 'email',
725
+ payload: { to: 'a@example.com' },
726
+ });
727
+
728
+ // Act — pause
729
+ await jobQueue.pauseCronJob(id);
730
+ const paused = await jobQueue.getCronJob(id);
731
+
732
+ // Assert
733
+ expect(paused!.status).toBe('paused');
734
+
735
+ // Act — resume
736
+ await jobQueue.resumeCronJob(id);
737
+ const resumed = await jobQueue.getCronJob(id);
738
+
739
+ // Assert
740
+ expect(resumed!.status).toBe('active');
741
+ });
742
+
743
+ it('edits a schedule and recalculates nextRunAt when expression changes', async () => {
744
+ // Setup
745
+ const id = await jobQueue.addCronJob({
746
+ scheduleName: 'editable',
747
+ cronExpression: '* * * * *',
748
+ jobType: 'email',
749
+ payload: { to: 'old@example.com' },
750
+ });
751
+ const before = await jobQueue.getCronJob(id);
752
+
753
+ // Act
754
+ await jobQueue.editCronJob(id, {
755
+ cronExpression: '0 0 * * *',
756
+ payload: { to: 'new@example.com' },
757
+ });
758
+
759
+ // Assert
760
+ const after = await jobQueue.getCronJob(id);
761
+ expect(after!.cronExpression).toBe('0 0 * * *');
762
+ expect(after!.payload).toEqual({ to: 'new@example.com' });
763
+ expect(after!.nextRunAt!.getTime()).not.toBe(before!.nextRunAt!.getTime());
764
+ });
765
+
766
+ it('removes a schedule', async () => {
767
+ // Setup
768
+ const id = await jobQueue.addCronJob({
769
+ scheduleName: 'removable',
770
+ cronExpression: '* * * * *',
771
+ jobType: 'email',
772
+ payload: { to: 'a@example.com' },
773
+ });
774
+
775
+ // Act
776
+ await jobQueue.removeCronJob(id);
777
+
778
+ // Assert
779
+ const removed = await jobQueue.getCronJob(id);
780
+ expect(removed).toBeNull();
781
+ });
782
+
783
+ it('enqueueDueCronJobs enqueues a job when nextRunAt is due', async () => {
784
+ // Setup — create schedule then force nextRunAt into the past
785
+ const id = await jobQueue.addCronJob({
786
+ scheduleName: 'due-now',
787
+ cronExpression: '* * * * *',
788
+ jobType: 'email',
789
+ payload: { to: 'due@example.com' },
790
+ });
791
+ const pastMs = (Date.now() - 60_000).toString();
792
+ await redisClient.hset(`${prefix}cron:${id}`, 'nextRunAt', pastMs);
793
+ await redisClient.zadd(`${prefix}cron_due`, Number(pastMs), id.toString());
794
+
795
+ // Act
796
+ const count = await jobQueue.enqueueDueCronJobs();
797
+
798
+ // Assert
799
+ expect(count).toBe(1);
800
+ const jobs = await jobQueue.getJobsByStatus('pending');
801
+ const cronJob = jobs.find(
802
+ (j) =>
803
+ j.jobType === 'email' && (j.payload as any).to === 'due@example.com',
804
+ );
805
+ expect(cronJob).toBeDefined();
806
+ });
807
+
808
+ it('enqueueDueCronJobs advances nextRunAt and sets lastJobId', async () => {
809
+ // Setup
810
+ const id = await jobQueue.addCronJob({
811
+ scheduleName: 'advance-test',
812
+ cronExpression: '* * * * *',
813
+ jobType: 'email',
814
+ payload: { to: 'advance@example.com' },
815
+ });
816
+ const pastMs = (Date.now() - 60_000).toString();
817
+ await redisClient.hset(`${prefix}cron:${id}`, 'nextRunAt', pastMs);
818
+ await redisClient.zadd(`${prefix}cron_due`, Number(pastMs), id.toString());
819
+
820
+ // Act
821
+ await jobQueue.enqueueDueCronJobs();
822
+
823
+ // Assert
824
+ const schedule = await jobQueue.getCronJob(id);
825
+ expect(schedule!.lastJobId).not.toBeNull();
826
+ expect(schedule!.lastEnqueuedAt).toBeInstanceOf(Date);
827
+ expect(schedule!.nextRunAt).toBeInstanceOf(Date);
828
+ expect(schedule!.nextRunAt!.getTime()).toBeGreaterThan(Date.now() - 5000);
829
+ });
830
+
831
+ it('enqueueDueCronJobs skips paused schedules', async () => {
832
+ // Setup
833
+ const id = await jobQueue.addCronJob({
834
+ scheduleName: 'paused-skip',
835
+ cronExpression: '* * * * *',
836
+ jobType: 'email',
837
+ payload: { to: 'paused@example.com' },
838
+ });
839
+ const pastMs = (Date.now() - 60_000).toString();
840
+ await redisClient.hset(`${prefix}cron:${id}`, 'nextRunAt', pastMs);
841
+ await redisClient.zadd(`${prefix}cron_due`, Number(pastMs), id.toString());
842
+ await jobQueue.pauseCronJob(id);
843
+
844
+ // Act
845
+ const count = await jobQueue.enqueueDueCronJobs();
846
+
847
+ // Assert
848
+ expect(count).toBe(0);
849
+ });
850
+
851
+ it('enqueueDueCronJobs skips schedules not yet due', async () => {
852
+ // Setup — nextRunAt is in the future by default
853
+ await jobQueue.addCronJob({
854
+ scheduleName: 'future-schedule',
855
+ cronExpression: '0 0 1 1 *',
856
+ jobType: 'email',
857
+ payload: { to: 'future@example.com' },
858
+ });
859
+
860
+ // Act
861
+ const count = await jobQueue.enqueueDueCronJobs();
862
+
863
+ // Assert
864
+ expect(count).toBe(0);
865
+ });
866
+
867
+ it('enqueueDueCronJobs skips when allowOverlap=false and last job is still active', async () => {
868
+ // Setup
869
+ const id = await jobQueue.addCronJob({
870
+ scheduleName: 'no-overlap',
871
+ cronExpression: '* * * * *',
872
+ jobType: 'email',
873
+ payload: { to: 'overlap@example.com' },
874
+ allowOverlap: false,
875
+ });
876
+ const pastMs = (Date.now() - 60_000).toString();
877
+ await redisClient.hset(`${prefix}cron:${id}`, 'nextRunAt', pastMs);
878
+ await redisClient.zadd(`${prefix}cron_due`, Number(pastMs), id.toString());
879
+
880
+ // First enqueue should succeed
881
+ const count1 = await jobQueue.enqueueDueCronJobs();
882
+ expect(count1).toBe(1);
883
+
884
+ // Force nextRunAt into the past again
885
+ const pastMs2 = (Date.now() - 60_000).toString();
886
+ await redisClient.hset(`${prefix}cron:${id}`, 'nextRunAt', pastMs2);
887
+ await redisClient.zadd(`${prefix}cron_due`, Number(pastMs2), id.toString());
888
+
889
+ // Act — second enqueue should be skipped because previous job is pending
890
+ const count2 = await jobQueue.enqueueDueCronJobs();
891
+
892
+ // Assert
893
+ expect(count2).toBe(0);
894
+ });
895
+
896
+ it('enqueueDueCronJobs enqueues when allowOverlap=true even if last job is still active', async () => {
897
+ // Setup
898
+ const id = await jobQueue.addCronJob({
899
+ scheduleName: 'with-overlap',
900
+ cronExpression: '* * * * *',
901
+ jobType: 'email',
902
+ payload: { to: 'overlap@example.com' },
903
+ allowOverlap: true,
904
+ });
905
+ const pastMs = (Date.now() - 60_000).toString();
906
+ await redisClient.hset(`${prefix}cron:${id}`, 'nextRunAt', pastMs);
907
+ await redisClient.zadd(`${prefix}cron_due`, Number(pastMs), id.toString());
908
+
909
+ // First enqueue
910
+ const count1 = await jobQueue.enqueueDueCronJobs();
911
+ expect(count1).toBe(1);
912
+
913
+ // Force nextRunAt into the past again
914
+ const pastMs2 = (Date.now() - 60_000).toString();
915
+ await redisClient.hset(`${prefix}cron:${id}`, 'nextRunAt', pastMs2);
916
+ await redisClient.zadd(`${prefix}cron_due`, Number(pastMs2), id.toString());
917
+
918
+ // Act — second enqueue should succeed because allowOverlap=true
919
+ const count2 = await jobQueue.enqueueDueCronJobs();
920
+
921
+ // Assert
922
+ expect(count2).toBe(1);
923
+
924
+ // Verify two pending jobs
925
+ const jobs = await jobQueue.getJobsByStatus('pending');
926
+ const cronJobs = jobs.filter(
927
+ (j) =>
928
+ j.jobType === 'email' &&
929
+ (j.payload as any).to === 'overlap@example.com',
930
+ );
931
+ expect(cronJobs).toHaveLength(2);
932
+ });
933
+ });
934
+
935
+ describe('Redis parity features', () => {
936
+ let prefix: string;
937
+ let jobQueue: ReturnType<typeof initJobQueue<TestPayloadMap>>;
938
+ let redisClient: any;
939
+
940
+ beforeEach(async () => {
941
+ prefix = createRedisTestPrefix();
942
+ const config: RedisJobQueueConfig = {
943
+ backend: 'redis',
944
+ redisConfig: {
945
+ url: REDIS_URL,
946
+ keyPrefix: prefix,
947
+ },
948
+ };
949
+ jobQueue = initJobQueue<TestPayloadMap>(config);
950
+ redisClient = jobQueue.getRedisClient();
951
+ });
952
+
953
+ afterEach(async () => {
954
+ vi.restoreAllMocks();
955
+ await cleanupRedisPrefix(redisClient, prefix);
956
+ await redisClient.quit();
957
+ });
958
+
959
+ // ── Cursor-based pagination ─────────────────────────────────────────
960
+
961
+ it('getJobs supports cursor-based pagination', async () => {
962
+ // Setup
963
+ const id1 = await jobQueue.addJob({
964
+ jobType: 'email',
965
+ payload: { to: 'a@example.com' },
966
+ });
967
+ const id2 = await jobQueue.addJob({
968
+ jobType: 'email',
969
+ payload: { to: 'b@example.com' },
970
+ });
971
+ const id3 = await jobQueue.addJob({
972
+ jobType: 'email',
973
+ payload: { to: 'c@example.com' },
974
+ });
975
+
976
+ // Act — first page (no cursor, limit 2)
977
+ const page1 = await jobQueue.getJobs({}, 2);
978
+
979
+ // Assert
980
+ expect(page1).toHaveLength(2);
981
+ // Descending by id: id3, id2
982
+ expect(page1[0].id).toBe(id3);
983
+ expect(page1[1].id).toBe(id2);
984
+
985
+ // Act — second page using cursor
986
+ const page2 = await jobQueue.getJobs({ cursor: page1[1].id }, 2);
987
+
988
+ // Assert
989
+ expect(page2).toHaveLength(1);
990
+ expect(page2[0].id).toBe(id1);
991
+ });
992
+
993
+ // ── retryJob status validation ──────────────────────────────────────
994
+
995
+ it('retryJob only retries failed or processing jobs', async () => {
996
+ // Setup — completed job
997
+ const jobId = await jobQueue.addJob({
998
+ jobType: 'test',
999
+ payload: { foo: 'retry-test' },
1000
+ });
1001
+ const processor = jobQueue.createProcessor({
1002
+ email: vi.fn(async () => {}),
1003
+ sms: vi.fn(async () => {}),
1004
+ test: vi.fn(async () => {}),
1005
+ });
1006
+ await processor.start();
1007
+ const completedJob = await jobQueue.getJob(jobId);
1008
+ expect(completedJob?.status).toBe('completed');
1009
+
1010
+ // Act — retry a completed job (should be a no-op)
1011
+ await jobQueue.retryJob(jobId);
1012
+
1013
+ // Assert — still completed
1014
+ const job = await jobQueue.getJob(jobId);
1015
+ expect(job?.status).toBe('completed');
1016
+ });
1017
+
1018
+ it('retryJob retries a failed job', async () => {
1019
+ // Setup
1020
+ const jobId = await jobQueue.addJob({
1021
+ jobType: 'email',
1022
+ payload: { to: 'fail-retry@example.com' },
1023
+ });
1024
+ const processor = jobQueue.createProcessor({
1025
+ email: async () => {
1026
+ throw new Error('boom');
1027
+ },
1028
+ sms: vi.fn(async () => {}),
1029
+ test: vi.fn(async () => {}),
1030
+ });
1031
+ await processor.start();
1032
+ const failedJob = await jobQueue.getJob(jobId);
1033
+ expect(failedJob?.status).toBe('failed');
1034
+
1035
+ // Act
1036
+ await jobQueue.retryJob(jobId);
1037
+
1038
+ // Assert
1039
+ const job = await jobQueue.getJob(jobId);
1040
+ expect(job?.status).toBe('pending');
1041
+ });
1042
+
1043
+ // ── cancelJob with waiting status ───────────────────────────────────
1044
+
1045
+ it('cancelJob cancels a waiting job', async () => {
1046
+ // Setup — add a job and manually set it to waiting
1047
+ const jobId = await jobQueue.addJob({
1048
+ jobType: 'email',
1049
+ payload: { to: 'waiting-cancel@example.com' },
1050
+ });
1051
+ const futureMs = Date.now() + 60_000;
1052
+ await redisClient.hmset(
1053
+ `${prefix}job:${jobId}`,
1054
+ 'status',
1055
+ 'waiting',
1056
+ 'waitUntil',
1057
+ futureMs.toString(),
1058
+ );
1059
+ await redisClient.srem(`${prefix}status:pending`, jobId.toString());
1060
+ await redisClient.sadd(`${prefix}status:waiting`, jobId.toString());
1061
+ await redisClient.zrem(`${prefix}queue`, jobId.toString());
1062
+
1063
+ // Act
1064
+ await jobQueue.cancelJob(jobId);
1065
+
1066
+ // Assert
1067
+ const job = await jobQueue.getJob(jobId);
1068
+ expect(job?.status).toBe('cancelled');
1069
+ expect(job?.waitUntil).toBeNull();
1070
+ expect(job?.waitTokenId).toBeNull();
1071
+ });
1072
+
1073
+ // ── completeJob clears wait fields ──────────────────────────────────
1074
+
1075
+ it('completeJob clears wait-related fields', async () => {
1076
+ // Setup
1077
+ const jobId = await jobQueue.addJob({
1078
+ jobType: 'test',
1079
+ payload: { foo: 'wait-clear' },
1080
+ });
1081
+ // Manually set wait fields
1082
+ await redisClient.hmset(
1083
+ `${prefix}job:${jobId}`,
1084
+ 'stepData',
1085
+ JSON.stringify({ step1: { __completed: true, result: 42 } }),
1086
+ 'waitUntil',
1087
+ (Date.now() + 60000).toString(),
1088
+ 'waitTokenId',
1089
+ 'wp_test',
1090
+ );
1091
+
1092
+ // Process the job to completion
1093
+ const processor = jobQueue.createProcessor({
1094
+ email: vi.fn(async () => {}),
1095
+ sms: vi.fn(async () => {}),
1096
+ test: vi.fn(async () => {}),
1097
+ });
1098
+ await processor.start();
1099
+
1100
+ // Assert
1101
+ const job = await jobQueue.getJob(jobId);
1102
+ expect(job?.status).toBe('completed');
1103
+ expect(job?.stepData).toBeUndefined();
1104
+ expect(job?.waitUntil).toBeNull();
1105
+ expect(job?.waitTokenId).toBeNull();
1106
+ });
1107
+
1108
+ // ── cleanupOldJobEvents ─────────────────────────────────────────────
1109
+
1110
+ it('cleanupOldJobEvents removes old events', async () => {
1111
+ // Setup
1112
+ const jobId = await jobQueue.addJob({
1113
+ jobType: 'email',
1114
+ payload: { to: 'events-cleanup@example.com' },
1115
+ });
1116
+
1117
+ // Create an old event (31 days ago)
1118
+ const oldMs = Date.now() - 31 * 24 * 60 * 60 * 1000;
1119
+ const oldEvent = JSON.stringify({
1120
+ id: 999,
1121
+ jobId,
1122
+ eventType: 'added',
1123
+ createdAt: oldMs,
1124
+ metadata: null,
1125
+ });
1126
+ await redisClient.rpush(`${prefix}events:${jobId}`, oldEvent);
1127
+
1128
+ // Get events before cleanup
1129
+ const eventsBefore = await jobQueue.getJobEvents(jobId);
1130
+ const countBefore = eventsBefore.length;
1131
+ expect(countBefore).toBeGreaterThanOrEqual(2); // at least the original 'added' + our old event
1132
+
1133
+ // Act
1134
+ const deleted = await jobQueue.cleanupOldJobEvents(30);
1135
+
1136
+ // Assert
1137
+ expect(deleted).toBeGreaterThanOrEqual(1);
1138
+ const eventsAfter = await jobQueue.getJobEvents(jobId);
1139
+ expect(eventsAfter.length).toBeLessThan(countBefore);
1140
+ });
1141
+
1142
+ it('cleanupOldJobEvents removes orphaned event lists', async () => {
1143
+ // Setup — create events for a non-existent job
1144
+ const orphanEvent = JSON.stringify({
1145
+ id: 888,
1146
+ jobId: 99999,
1147
+ eventType: 'added',
1148
+ createdAt: Date.now(),
1149
+ metadata: null,
1150
+ });
1151
+ await redisClient.rpush(`${prefix}events:99999`, orphanEvent);
1152
+
1153
+ // Act
1154
+ const deleted = await jobQueue.cleanupOldJobEvents(30);
1155
+
1156
+ // Assert
1157
+ expect(deleted).toBe(1);
1158
+ const remaining = await redisClient.llen(`${prefix}events:99999`);
1159
+ expect(remaining).toBe(0);
1160
+ });
1161
+
1162
+ // ── Waiting system ──────────────────────────────────────────────────
1163
+
1164
+ it('createToken and getToken work via the public API', async () => {
1165
+ // Act
1166
+ const token = await jobQueue.createToken({ timeout: '10m' });
1167
+
1168
+ // Assert
1169
+ expect(token.id).toMatch(/^wp_/);
1170
+ const record = await jobQueue.getToken(token.id);
1171
+ expect(record).not.toBeNull();
1172
+ expect(record!.status).toBe('waiting');
1173
+ expect(record!.timeoutAt).toBeInstanceOf(Date);
1174
+ });
1175
+
1176
+ it('completeToken completes the token and provides data', async () => {
1177
+ // Setup
1178
+ const token = await jobQueue.createToken();
1179
+
1180
+ // Act
1181
+ await jobQueue.completeToken(token.id, { result: 'success' });
1182
+
1183
+ // Assert
1184
+ const record = await jobQueue.getToken(token.id);
1185
+ expect(record!.status).toBe('completed');
1186
+ expect(record!.output).toEqual({ result: 'success' });
1187
+ });
1188
+
1189
+ it('completeToken resumes a waiting job', async () => {
1190
+ // Setup — add a job, process it to create a token, then manually put it in waiting
1191
+ const jobId = await jobQueue.addJob({
1192
+ jobType: 'email',
1193
+ payload: { to: 'token-resume@example.com' },
1194
+ });
1195
+
1196
+ // Create a token associated with this job
1197
+ // We need to use the backend directly since createToken from public API uses null jobId
1198
+ const backend = jobQueue as any; // accessing the backend is tricky from the public API
1199
+ // Instead, create a token, then manually associate it
1200
+ const token = await jobQueue.createToken();
1201
+
1202
+ // Manually update the token's jobId and put the job in waiting state
1203
+ await redisClient.hset(
1204
+ `${prefix}waitpoint:${token.id}`,
1205
+ 'jobId',
1206
+ jobId.toString(),
1207
+ );
1208
+ await redisClient.hmset(
1209
+ `${prefix}job:${jobId}`,
1210
+ 'status',
1211
+ 'waiting',
1212
+ 'waitTokenId',
1213
+ token.id,
1214
+ );
1215
+ await redisClient.srem(`${prefix}status:pending`, jobId.toString());
1216
+ await redisClient.sadd(`${prefix}status:waiting`, jobId.toString());
1217
+ await redisClient.zrem(`${prefix}queue`, jobId.toString());
1218
+
1219
+ // Act
1220
+ await jobQueue.completeToken(token.id, { data: 42 });
1221
+
1222
+ // Assert
1223
+ const job = await jobQueue.getJob(jobId);
1224
+ expect(job?.status).toBe('pending');
1225
+ expect(job?.waitTokenId).toBeNull();
1226
+ });
1227
+
1228
+ it('expireTimedOutTokens expires tokens past their timeout', async () => {
1229
+ // Setup — create a token with a very short timeout, then backdate it
1230
+ const token = await jobQueue.createToken({ timeout: '1s' });
1231
+ // Force the timeout to be in the past
1232
+ const pastMs = Date.now() - 10_000;
1233
+ await redisClient.hset(
1234
+ `${prefix}waitpoint:${token.id}`,
1235
+ 'timeoutAt',
1236
+ pastMs.toString(),
1237
+ );
1238
+ await redisClient.zadd(`${prefix}waitpoint_timeout`, pastMs, token.id);
1239
+
1240
+ // Act
1241
+ const expired = await jobQueue.expireTimedOutTokens();
1242
+
1243
+ // Assert
1244
+ expect(expired).toBe(1);
1245
+ const record = await jobQueue.getToken(token.id);
1246
+ expect(record!.status).toBe('timed_out');
1247
+ });
1248
+
1249
+ it('expireTimedOutTokens resumes a waiting job when its token times out', async () => {
1250
+ // Setup
1251
+ const jobId = await jobQueue.addJob({
1252
+ jobType: 'email',
1253
+ payload: { to: 'timeout-resume@example.com' },
1254
+ });
1255
+ const token = await jobQueue.createToken({ timeout: '1s' });
1256
+
1257
+ // Associate token with job and put job in waiting
1258
+ await redisClient.hset(
1259
+ `${prefix}waitpoint:${token.id}`,
1260
+ 'jobId',
1261
+ jobId.toString(),
1262
+ );
1263
+ await redisClient.hmset(
1264
+ `${prefix}job:${jobId}`,
1265
+ 'status',
1266
+ 'waiting',
1267
+ 'waitTokenId',
1268
+ token.id,
1269
+ );
1270
+ await redisClient.srem(`${prefix}status:pending`, jobId.toString());
1271
+ await redisClient.sadd(`${prefix}status:waiting`, jobId.toString());
1272
+ await redisClient.zrem(`${prefix}queue`, jobId.toString());
1273
+
1274
+ // Force the timeout to be in the past
1275
+ const pastMs = Date.now() - 10_000;
1276
+ await redisClient.hset(
1277
+ `${prefix}waitpoint:${token.id}`,
1278
+ 'timeoutAt',
1279
+ pastMs.toString(),
1280
+ );
1281
+ await redisClient.zadd(`${prefix}waitpoint_timeout`, pastMs, token.id);
1282
+
1283
+ // Act
1284
+ await jobQueue.expireTimedOutTokens();
1285
+
1286
+ // Assert
1287
+ const job = await jobQueue.getJob(jobId);
1288
+ expect(job?.status).toBe('pending');
1289
+ expect(job?.waitTokenId).toBeNull();
1290
+ });
1291
+
1292
+ it('getNextBatch promotes time-based waiting jobs', async () => {
1293
+ // Setup — add a job and manually set it to waiting with a past waitUntil
1294
+ const jobId = await jobQueue.addJob({
1295
+ jobType: 'test',
1296
+ payload: { foo: 'wait-promote' },
1297
+ });
1298
+ const pastMs = Date.now() - 5000;
1299
+ await redisClient.hmset(
1300
+ `${prefix}job:${jobId}`,
1301
+ 'status',
1302
+ 'waiting',
1303
+ 'waitUntil',
1304
+ pastMs.toString(),
1305
+ 'waitTokenId',
1306
+ 'null',
1307
+ );
1308
+ await redisClient.srem(`${prefix}status:pending`, jobId.toString());
1309
+ await redisClient.sadd(`${prefix}status:waiting`, jobId.toString());
1310
+ await redisClient.zrem(`${prefix}queue`, jobId.toString());
1311
+ await redisClient.zadd(`${prefix}waiting`, pastMs, jobId.toString());
1312
+
1313
+ // Act — process jobs, the waiting job should get promoted and processed
1314
+ const handler = vi.fn(async () => {});
1315
+ const processor = jobQueue.createProcessor({
1316
+ email: vi.fn(async () => {}),
1317
+ sms: vi.fn(async () => {}),
1318
+ test: handler,
1319
+ });
1320
+ const processed = await processor.start();
1321
+
1322
+ // Assert
1323
+ expect(processed).toBe(1);
1324
+ expect(handler).toHaveBeenCalled();
1325
+ const job = await jobQueue.getJob(jobId);
1326
+ expect(job?.status).toBe('completed');
1327
+ });
1328
+
1329
+ it('getNextBatch does NOT promote token-based waiting jobs', async () => {
1330
+ // Setup — add a job waiting for a token
1331
+ const jobId = await jobQueue.addJob({
1332
+ jobType: 'test',
1333
+ payload: { foo: 'token-wait-nopromote' },
1334
+ });
1335
+ const pastMs = Date.now() - 5000;
1336
+ await redisClient.hmset(
1337
+ `${prefix}job:${jobId}`,
1338
+ 'status',
1339
+ 'waiting',
1340
+ 'waitUntil',
1341
+ pastMs.toString(),
1342
+ 'waitTokenId',
1343
+ 'wp_some_token',
1344
+ );
1345
+ await redisClient.srem(`${prefix}status:pending`, jobId.toString());
1346
+ await redisClient.sadd(`${prefix}status:waiting`, jobId.toString());
1347
+ await redisClient.zrem(`${prefix}queue`, jobId.toString());
1348
+ await redisClient.zadd(`${prefix}waiting`, pastMs, jobId.toString());
1349
+
1350
+ // Act
1351
+ const processor = jobQueue.createProcessor({
1352
+ email: vi.fn(async () => {}),
1353
+ sms: vi.fn(async () => {}),
1354
+ test: vi.fn(async () => {}),
1355
+ });
1356
+ const processed = await processor.start();
1357
+
1358
+ // Assert — should not pick up the token-based waiting job
1359
+ expect(processed).toBe(0);
1360
+ const job = await jobQueue.getJob(jobId);
1361
+ expect(job?.status).toBe('waiting');
1362
+ });
1363
+
1364
+ it('waitFor pauses a job and resumes after time elapses', async () => {
1365
+ // Setup
1366
+ let invocationCount = 0;
1367
+ const jobId = await jobQueue.addJob({
1368
+ jobType: 'test',
1369
+ payload: { foo: 'waitfor-test' },
1370
+ });
1371
+
1372
+ // First invocation: handler calls ctx.waitFor
1373
+ const handler = vi.fn(async (_payload: any, _signal: any, ctx: any) => {
1374
+ invocationCount++;
1375
+ if (invocationCount === 1) {
1376
+ await ctx.waitFor({ seconds: 1 });
1377
+ }
1378
+ });
1379
+
1380
+ const processor = jobQueue.createProcessor({
1381
+ email: vi.fn(async () => {}),
1382
+ sms: vi.fn(async () => {}),
1383
+ test: handler,
1384
+ });
1385
+ await processor.start();
1386
+
1387
+ // Assert — job should be in waiting state
1388
+ let job = await jobQueue.getJob(jobId);
1389
+ expect(job?.status).toBe('waiting');
1390
+ expect(job?.waitUntil).toBeInstanceOf(Date);
1391
+ expect(job?.stepData).toBeDefined();
1392
+
1393
+ // Manually advance: set waitUntil to past and add to waiting sorted set
1394
+ const pastMs = Date.now() - 5000;
1395
+ await redisClient.hset(
1396
+ `${prefix}job:${jobId}`,
1397
+ 'waitUntil',
1398
+ pastMs.toString(),
1399
+ );
1400
+ await redisClient.zadd(`${prefix}waiting`, pastMs, jobId.toString());
1401
+
1402
+ // Second invocation: job resumes and completes
1403
+ await processor.start();
1404
+
1405
+ // Assert
1406
+ job = await jobQueue.getJob(jobId);
1407
+ expect(job?.status).toBe('completed');
1408
+ expect(invocationCount).toBe(2);
1409
+ });
1410
+
1411
+ it('ctx.run memoizes step results across re-invocations', async () => {
1412
+ // Setup
1413
+ let invocationCount = 0;
1414
+ let stepCallCount = 0;
1415
+ const jobId = await jobQueue.addJob({
1416
+ jobType: 'test',
1417
+ payload: { foo: 'memoize-test' },
1418
+ });
1419
+
1420
+ const handler = vi.fn(async (_payload: any, _signal: any, ctx: any) => {
1421
+ invocationCount++;
1422
+ const result = await ctx.run('step1', async () => {
1423
+ stepCallCount++;
1424
+ return 42;
1425
+ });
1426
+ expect(result).toBe(42);
1427
+
1428
+ if (invocationCount === 1) {
1429
+ await ctx.waitFor({ seconds: 1 });
1430
+ }
1431
+ });
1432
+
1433
+ const processor = jobQueue.createProcessor({
1434
+ email: vi.fn(async () => {}),
1435
+ sms: vi.fn(async () => {}),
1436
+ test: handler,
1437
+ });
1438
+
1439
+ // First invocation
1440
+ await processor.start();
1441
+ let job = await jobQueue.getJob(jobId);
1442
+ expect(job?.status).toBe('waiting');
1443
+ expect(stepCallCount).toBe(1);
1444
+
1445
+ // Advance time
1446
+ const pastMs = Date.now() - 5000;
1447
+ await redisClient.hset(
1448
+ `${prefix}job:${jobId}`,
1449
+ 'waitUntil',
1450
+ pastMs.toString(),
1451
+ );
1452
+ await redisClient.zadd(`${prefix}waiting`, pastMs, jobId.toString());
1453
+
1454
+ // Second invocation
1455
+ await processor.start();
1456
+
1457
+ // Assert — step1 should NOT have been called again (memoized)
1458
+ job = await jobQueue.getJob(jobId);
1459
+ expect(job?.status).toBe('completed');
1460
+ expect(stepCallCount).toBe(1);
1461
+ expect(invocationCount).toBe(2);
1462
+ });
1463
+
1464
+ it('waitForToken pauses and resumes on token completion', async () => {
1465
+ // Setup
1466
+ let invocationCount = 0;
1467
+ let tokenId: string;
1468
+ const jobId = await jobQueue.addJob({
1469
+ jobType: 'test',
1470
+ payload: { foo: 'token-wait-test' },
1471
+ });
1472
+
1473
+ const handler = vi.fn(async (_payload: any, _signal: any, ctx: any) => {
1474
+ invocationCount++;
1475
+ if (invocationCount === 1) {
1476
+ const token = await ctx.createToken({ timeout: '1h' });
1477
+ tokenId = token.id;
1478
+ const result = await ctx.waitForToken(token.id);
1479
+ // Should not reach here on first invocation (throws WaitSignal)
1480
+ expect(result.ok).toBe(true);
1481
+ } else {
1482
+ // Second invocation: token should be completed
1483
+ // The step data should have the result cached
1484
+ }
1485
+ });
1486
+
1487
+ const processor = jobQueue.createProcessor({
1488
+ email: vi.fn(async () => {}),
1489
+ sms: vi.fn(async () => {}),
1490
+ test: handler,
1491
+ });
1492
+
1493
+ // First invocation — should pause on waitForToken
1494
+ await processor.start();
1495
+
1496
+ let job = await jobQueue.getJob(jobId);
1497
+ expect(job?.status).toBe('waiting');
1498
+ expect(job?.waitTokenId).toBe(tokenId!);
1499
+
1500
+ // Complete the token externally
1501
+ await jobQueue.completeToken(tokenId!, { answer: 'yes' });
1502
+
1503
+ // Verify job is back to pending
1504
+ job = await jobQueue.getJob(jobId);
1505
+ expect(job?.status).toBe('pending');
1506
+
1507
+ // Second invocation — should complete
1508
+ await processor.start();
1509
+
1510
+ job = await jobQueue.getJob(jobId);
1511
+ expect(job?.status).toBe('completed');
1512
+ expect(invocationCount).toBe(2);
1513
+ });
1514
+ });