@nicnocquee/dataqueue 1.30.0 → 1.31.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.
@@ -541,3 +541,353 @@ describe('Redis backend integration', () => {
541
541
  expect(job?.status).toBe('pending');
542
542
  });
543
543
  });
544
+
545
+ describe('Redis cron schedules integration', () => {
546
+ let prefix: string;
547
+ let jobQueue: ReturnType<typeof initJobQueue<TestPayloadMap>>;
548
+ let redisClient: any;
549
+
550
+ beforeEach(async () => {
551
+ prefix = createRedisTestPrefix();
552
+ const config: RedisJobQueueConfig = {
553
+ backend: 'redis',
554
+ redisConfig: {
555
+ url: REDIS_URL,
556
+ keyPrefix: prefix,
557
+ },
558
+ };
559
+ jobQueue = initJobQueue<TestPayloadMap>(config);
560
+ redisClient = jobQueue.getRedisClient();
561
+ });
562
+
563
+ afterEach(async () => {
564
+ vi.restoreAllMocks();
565
+ await cleanupRedisPrefix(redisClient, prefix);
566
+ await redisClient.quit();
567
+ });
568
+
569
+ it('creates a cron schedule and retrieves it by ID', async () => {
570
+ // Act
571
+ const id = await jobQueue.addCronJob({
572
+ scheduleName: 'every-5-min-email',
573
+ cronExpression: '*/5 * * * *',
574
+ jobType: 'email',
575
+ payload: { to: 'cron@example.com' },
576
+ });
577
+
578
+ // Assert
579
+ const schedule = await jobQueue.getCronJob(id);
580
+ expect(schedule).not.toBeNull();
581
+ expect(schedule!.scheduleName).toBe('every-5-min-email');
582
+ expect(schedule!.cronExpression).toBe('*/5 * * * *');
583
+ expect(schedule!.jobType).toBe('email');
584
+ expect(schedule!.payload).toEqual({ to: 'cron@example.com' });
585
+ expect(schedule!.status).toBe('active');
586
+ expect(schedule!.allowOverlap).toBe(false);
587
+ expect(schedule!.timezone).toBe('UTC');
588
+ expect(schedule!.nextRunAt).toBeInstanceOf(Date);
589
+ });
590
+
591
+ it('retrieves a cron schedule by name', async () => {
592
+ // Setup
593
+ await jobQueue.addCronJob({
594
+ scheduleName: 'my-schedule',
595
+ cronExpression: '0 * * * *',
596
+ jobType: 'email',
597
+ payload: { to: 'test@example.com' },
598
+ });
599
+
600
+ // Act
601
+ const schedule = await jobQueue.getCronJobByName('my-schedule');
602
+
603
+ // Assert
604
+ expect(schedule).not.toBeNull();
605
+ expect(schedule!.scheduleName).toBe('my-schedule');
606
+ });
607
+
608
+ it('returns null for nonexistent schedule', async () => {
609
+ // Act
610
+ const byId = await jobQueue.getCronJob(99999);
611
+ const byName = await jobQueue.getCronJobByName('nonexistent');
612
+
613
+ // Assert
614
+ expect(byId).toBeNull();
615
+ expect(byName).toBeNull();
616
+ });
617
+
618
+ it('rejects duplicate schedule names', async () => {
619
+ // Setup
620
+ await jobQueue.addCronJob({
621
+ scheduleName: 'unique-name',
622
+ cronExpression: '* * * * *',
623
+ jobType: 'email',
624
+ payload: { to: 'a@example.com' },
625
+ });
626
+
627
+ // Act & Assert
628
+ await expect(
629
+ jobQueue.addCronJob({
630
+ scheduleName: 'unique-name',
631
+ cronExpression: '*/5 * * * *',
632
+ jobType: 'sms',
633
+ payload: { to: 'b@example.com' },
634
+ }),
635
+ ).rejects.toThrow();
636
+ });
637
+
638
+ it('rejects invalid cron expressions', async () => {
639
+ // Act & Assert
640
+ await expect(
641
+ jobQueue.addCronJob({
642
+ scheduleName: 'bad-cron',
643
+ cronExpression: 'not a cron',
644
+ jobType: 'email',
645
+ payload: { to: 'a@example.com' },
646
+ }),
647
+ ).rejects.toThrow('Invalid cron expression');
648
+ });
649
+
650
+ it('lists active and paused schedules', async () => {
651
+ // Setup
652
+ const id1 = await jobQueue.addCronJob({
653
+ scheduleName: 'schedule-1',
654
+ cronExpression: '* * * * *',
655
+ jobType: 'email',
656
+ payload: { to: 'a@example.com' },
657
+ });
658
+ await jobQueue.addCronJob({
659
+ scheduleName: 'schedule-2',
660
+ cronExpression: '*/5 * * * *',
661
+ jobType: 'sms',
662
+ payload: { to: 'b@example.com' },
663
+ });
664
+ await jobQueue.pauseCronJob(id1);
665
+
666
+ // Act
667
+ const all = await jobQueue.listCronJobs();
668
+ const active = await jobQueue.listCronJobs('active');
669
+ const paused = await jobQueue.listCronJobs('paused');
670
+
671
+ // Assert
672
+ expect(all).toHaveLength(2);
673
+ expect(active).toHaveLength(1);
674
+ expect(active[0].scheduleName).toBe('schedule-2');
675
+ expect(paused).toHaveLength(1);
676
+ expect(paused[0].scheduleName).toBe('schedule-1');
677
+ });
678
+
679
+ it('pauses and resumes a schedule', async () => {
680
+ // Setup
681
+ const id = await jobQueue.addCronJob({
682
+ scheduleName: 'pausable',
683
+ cronExpression: '* * * * *',
684
+ jobType: 'email',
685
+ payload: { to: 'a@example.com' },
686
+ });
687
+
688
+ // Act — pause
689
+ await jobQueue.pauseCronJob(id);
690
+ const paused = await jobQueue.getCronJob(id);
691
+
692
+ // Assert
693
+ expect(paused!.status).toBe('paused');
694
+
695
+ // Act — resume
696
+ await jobQueue.resumeCronJob(id);
697
+ const resumed = await jobQueue.getCronJob(id);
698
+
699
+ // Assert
700
+ expect(resumed!.status).toBe('active');
701
+ });
702
+
703
+ it('edits a schedule and recalculates nextRunAt when expression changes', async () => {
704
+ // Setup
705
+ const id = await jobQueue.addCronJob({
706
+ scheduleName: 'editable',
707
+ cronExpression: '* * * * *',
708
+ jobType: 'email',
709
+ payload: { to: 'old@example.com' },
710
+ });
711
+ const before = await jobQueue.getCronJob(id);
712
+
713
+ // Act
714
+ await jobQueue.editCronJob(id, {
715
+ cronExpression: '0 0 * * *',
716
+ payload: { to: 'new@example.com' },
717
+ });
718
+
719
+ // Assert
720
+ const after = await jobQueue.getCronJob(id);
721
+ expect(after!.cronExpression).toBe('0 0 * * *');
722
+ expect(after!.payload).toEqual({ to: 'new@example.com' });
723
+ expect(after!.nextRunAt!.getTime()).not.toBe(before!.nextRunAt!.getTime());
724
+ });
725
+
726
+ it('removes a schedule', async () => {
727
+ // Setup
728
+ const id = await jobQueue.addCronJob({
729
+ scheduleName: 'removable',
730
+ cronExpression: '* * * * *',
731
+ jobType: 'email',
732
+ payload: { to: 'a@example.com' },
733
+ });
734
+
735
+ // Act
736
+ await jobQueue.removeCronJob(id);
737
+
738
+ // Assert
739
+ const removed = await jobQueue.getCronJob(id);
740
+ expect(removed).toBeNull();
741
+ });
742
+
743
+ it('enqueueDueCronJobs enqueues a job when nextRunAt is due', async () => {
744
+ // Setup — create schedule then force nextRunAt into the past
745
+ const id = await jobQueue.addCronJob({
746
+ scheduleName: 'due-now',
747
+ cronExpression: '* * * * *',
748
+ jobType: 'email',
749
+ payload: { to: 'due@example.com' },
750
+ });
751
+ const pastMs = (Date.now() - 60_000).toString();
752
+ await redisClient.hset(`${prefix}cron:${id}`, 'nextRunAt', pastMs);
753
+ await redisClient.zadd(`${prefix}cron_due`, Number(pastMs), id.toString());
754
+
755
+ // Act
756
+ const count = await jobQueue.enqueueDueCronJobs();
757
+
758
+ // Assert
759
+ expect(count).toBe(1);
760
+ const jobs = await jobQueue.getJobsByStatus('pending');
761
+ const cronJob = jobs.find(
762
+ (j) =>
763
+ j.jobType === 'email' && (j.payload as any).to === 'due@example.com',
764
+ );
765
+ expect(cronJob).toBeDefined();
766
+ });
767
+
768
+ it('enqueueDueCronJobs advances nextRunAt and sets lastJobId', async () => {
769
+ // Setup
770
+ const id = await jobQueue.addCronJob({
771
+ scheduleName: 'advance-test',
772
+ cronExpression: '* * * * *',
773
+ jobType: 'email',
774
+ payload: { to: 'advance@example.com' },
775
+ });
776
+ const pastMs = (Date.now() - 60_000).toString();
777
+ await redisClient.hset(`${prefix}cron:${id}`, 'nextRunAt', pastMs);
778
+ await redisClient.zadd(`${prefix}cron_due`, Number(pastMs), id.toString());
779
+
780
+ // Act
781
+ await jobQueue.enqueueDueCronJobs();
782
+
783
+ // Assert
784
+ const schedule = await jobQueue.getCronJob(id);
785
+ expect(schedule!.lastJobId).not.toBeNull();
786
+ expect(schedule!.lastEnqueuedAt).toBeInstanceOf(Date);
787
+ expect(schedule!.nextRunAt).toBeInstanceOf(Date);
788
+ expect(schedule!.nextRunAt!.getTime()).toBeGreaterThan(Date.now() - 5000);
789
+ });
790
+
791
+ it('enqueueDueCronJobs skips paused schedules', async () => {
792
+ // Setup
793
+ const id = await jobQueue.addCronJob({
794
+ scheduleName: 'paused-skip',
795
+ cronExpression: '* * * * *',
796
+ jobType: 'email',
797
+ payload: { to: 'paused@example.com' },
798
+ });
799
+ const pastMs = (Date.now() - 60_000).toString();
800
+ await redisClient.hset(`${prefix}cron:${id}`, 'nextRunAt', pastMs);
801
+ await redisClient.zadd(`${prefix}cron_due`, Number(pastMs), id.toString());
802
+ await jobQueue.pauseCronJob(id);
803
+
804
+ // Act
805
+ const count = await jobQueue.enqueueDueCronJobs();
806
+
807
+ // Assert
808
+ expect(count).toBe(0);
809
+ });
810
+
811
+ it('enqueueDueCronJobs skips schedules not yet due', async () => {
812
+ // Setup — nextRunAt is in the future by default
813
+ await jobQueue.addCronJob({
814
+ scheduleName: 'future-schedule',
815
+ cronExpression: '0 0 1 1 *',
816
+ jobType: 'email',
817
+ payload: { to: 'future@example.com' },
818
+ });
819
+
820
+ // Act
821
+ const count = await jobQueue.enqueueDueCronJobs();
822
+
823
+ // Assert
824
+ expect(count).toBe(0);
825
+ });
826
+
827
+ it('enqueueDueCronJobs skips when allowOverlap=false and last job is still active', async () => {
828
+ // Setup
829
+ const id = await jobQueue.addCronJob({
830
+ scheduleName: 'no-overlap',
831
+ cronExpression: '* * * * *',
832
+ jobType: 'email',
833
+ payload: { to: 'overlap@example.com' },
834
+ allowOverlap: false,
835
+ });
836
+ const pastMs = (Date.now() - 60_000).toString();
837
+ await redisClient.hset(`${prefix}cron:${id}`, 'nextRunAt', pastMs);
838
+ await redisClient.zadd(`${prefix}cron_due`, Number(pastMs), id.toString());
839
+
840
+ // First enqueue should succeed
841
+ const count1 = await jobQueue.enqueueDueCronJobs();
842
+ expect(count1).toBe(1);
843
+
844
+ // Force nextRunAt into the past again
845
+ const pastMs2 = (Date.now() - 60_000).toString();
846
+ await redisClient.hset(`${prefix}cron:${id}`, 'nextRunAt', pastMs2);
847
+ await redisClient.zadd(`${prefix}cron_due`, Number(pastMs2), id.toString());
848
+
849
+ // Act — second enqueue should be skipped because previous job is pending
850
+ const count2 = await jobQueue.enqueueDueCronJobs();
851
+
852
+ // Assert
853
+ expect(count2).toBe(0);
854
+ });
855
+
856
+ it('enqueueDueCronJobs enqueues when allowOverlap=true even if last job is still active', async () => {
857
+ // Setup
858
+ const id = await jobQueue.addCronJob({
859
+ scheduleName: 'with-overlap',
860
+ cronExpression: '* * * * *',
861
+ jobType: 'email',
862
+ payload: { to: 'overlap@example.com' },
863
+ allowOverlap: true,
864
+ });
865
+ const pastMs = (Date.now() - 60_000).toString();
866
+ await redisClient.hset(`${prefix}cron:${id}`, 'nextRunAt', pastMs);
867
+ await redisClient.zadd(`${prefix}cron_due`, Number(pastMs), id.toString());
868
+
869
+ // First enqueue
870
+ const count1 = await jobQueue.enqueueDueCronJobs();
871
+ expect(count1).toBe(1);
872
+
873
+ // Force nextRunAt into the past again
874
+ const pastMs2 = (Date.now() - 60_000).toString();
875
+ await redisClient.hset(`${prefix}cron:${id}`, 'nextRunAt', pastMs2);
876
+ await redisClient.zadd(`${prefix}cron_due`, Number(pastMs2), id.toString());
877
+
878
+ // Act — second enqueue should succeed because allowOverlap=true
879
+ const count2 = await jobQueue.enqueueDueCronJobs();
880
+
881
+ // Assert
882
+ expect(count2).toBe(1);
883
+
884
+ // Verify two pending jobs
885
+ const jobs = await jobQueue.getJobsByStatus('pending');
886
+ const cronJobs = jobs.filter(
887
+ (j) =>
888
+ j.jobType === 'email' &&
889
+ (j.payload as any).to === 'overlap@example.com',
890
+ );
891
+ expect(cronJobs).toHaveLength(2);
892
+ });
893
+ });