PSA/packages/scheduling/tests/recurrenceUtils.test.ts
Hermes 284313f908
Some checks are pending
Bidi Control Character Guard / bidi-control-guard (push) Waiting to run
Circular Dependency Check / Check for new circular dependencies (push) Waiting to run
Citus Migration Smoke / Combined migrations on single-node Citus (push) Waiting to run
E2E Fresh Install Tests / fresh-install-e2e (push) Waiting to run
ext-v2 guardrails / Run ext-v2 guard and ESLint (push) Waiting to run
Integration Tests / Check for relevant changes (push) Waiting to run
Integration Tests / ${{ (github.event_name == 'schedule' || github.event.inputs.suite == 'full') && 'Full integration suite' || 'Tier-1 integration subset' }} (push) Blocked by required conditions
Mobile checks / Mobile lint + typecheck (push) Waiting to run
Mobile checks / Mobile unit tests (push) Waiting to run
Mobile checks / Mobile dependency audit (report) (push) Waiting to run
Mobile checks / Mobile reproducibility checks (push) Waiting to run
Secrets guard (env backups) / Ensure no tracked env backup files (push) Waiting to run
Temporal Readiness / fast-readiness (push) Waiting to run
Temporal Readiness / docker-parity (push) Waiting to run
TypeScript Type Check / Nx affected typecheck (push) Waiting to run
Unit Tests / Skipped-test budget (push) Waiting to run
Unit Tests / Nx affected unit tests (push) Waiting to run
Unit Tests / Server unit coverage (informational) (push) Waiting to run
Validate Tenant Management Schema / Check for relevant changes (push) Waiting to run
Validate Tenant Management Schema / Validate Tenant Management Schema (push) Blocked by required conditions
EE Workflows Build Guard / ee-workflows-build-guard (push) Waiting to run
Initial import of AlgaPSA codebase from PSA server
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz

Source: /opt/alga-psa on psa.joliet.tech
2026-06-22 16:12:17 -05:00

456 lines
15 KiB
TypeScript

/**
* @alga-psa/scheduling - Recurrence Utils Tests
*
* Tests for generateOccurrences() and applyTimeToDate() utility functions.
* These are pure functions that can be tested without database mocking.
*
* Note: generateOccurrences() normalizes range boundaries using setHours() in local
* time, which means exact boundary behavior varies by timezone. Tests use wide ranges
* and check for known interior dates rather than exact boundary inclusion.
*/
import { describe, it, expect } from 'vitest';
import { generateOccurrences, applyTimeToDate } from '@alga-psa/shared/utils/recurrenceUtils';
import type { IScheduleEntry, IRecurrencePattern } from '@alga-psa/types';
/**
* Helper to create a minimal IScheduleEntry for testing.
*/
function makeEntry(overrides: Partial<IScheduleEntry> = {}): IScheduleEntry {
return {
entry_id: 'entry-1',
title: 'Test Entry',
scheduled_start: new Date('2024-01-15T09:00:00Z'),
scheduled_end: new Date('2024-01-15T10:00:00Z'),
status: 'scheduled',
work_item_id: null,
work_item_type: 'ad_hoc',
assigned_user_ids: [],
is_recurring: false,
tenant: 'test-tenant',
...overrides,
} as IScheduleEntry;
}
function makePattern(overrides: Partial<IRecurrencePattern> = {}): IRecurrencePattern {
return {
frequency: 'daily',
interval: 1,
startDate: new Date('2024-01-15'),
...overrides,
};
}
describe('applyTimeToDate', () => {
it('should apply hours/minutes/seconds from time to date', () => {
const date = new Date('2024-03-20T00:00:00');
const time = new Date('2024-01-01T14:30:45.123');
const result = applyTimeToDate(date, time);
expect(result.getHours()).toBe(14);
expect(result.getMinutes()).toBe(30);
expect(result.getSeconds()).toBe(45);
expect(result.getMilliseconds()).toBe(123);
// Date portion should be preserved
expect(result.getFullYear()).toBe(2024);
expect(result.getMonth()).toBe(2); // March = 2
expect(result.getDate()).toBe(20);
});
it('should not mutate the original date', () => {
const date = new Date('2024-03-20T00:00:00');
const originalTime = date.getTime();
const time = new Date('2024-01-01T14:30:00');
applyTimeToDate(date, time);
expect(date.getTime()).toBe(originalTime);
});
});
describe('generateOccurrences', () => {
describe('without recurrence pattern', () => {
it('should return the entry scheduled_start when no recurrence_pattern', () => {
const entry = makeEntry({ recurrence_pattern: undefined });
const start = new Date('2024-01-01');
const end = new Date('2024-01-31');
const result = generateOccurrences(entry, start, end);
expect(result).toHaveLength(1);
expect(result[0].toISOString()).toBe(new Date('2024-01-15T09:00:00Z').toISOString());
});
});
describe('daily recurrence', () => {
it('should generate daily occurrences excluding master date', () => {
const pattern = makePattern({
frequency: 'daily',
interval: 1,
startDate: new Date('2024-01-15'),
});
const entry = makeEntry({
scheduled_start: new Date('2024-01-15T09:00:00Z'),
scheduled_end: new Date('2024-01-15T10:00:00Z'),
is_recurring: true,
recurrence_pattern: pattern,
});
// Use a wide range to avoid boundary issues
const start = new Date('2024-01-14');
const end = new Date('2024-01-20');
const result = generateOccurrences(entry, start, end);
const dates = result.map((d) => d.toISOString().split('T')[0]);
// Should include several days after master
expect(dates).toContain('2024-01-16');
expect(dates).toContain('2024-01-17');
expect(dates).toContain('2024-01-18');
expect(dates).toContain('2024-01-19');
// Master date should be excluded
expect(dates).not.toContain('2024-01-15');
});
it('should apply the original entry time to each occurrence', () => {
const pattern = makePattern({
frequency: 'daily',
interval: 1,
startDate: new Date('2024-01-15'),
});
const entry = makeEntry({
scheduled_start: new Date('2024-01-15T14:30:00'),
scheduled_end: new Date('2024-01-15T15:30:00'),
is_recurring: true,
recurrence_pattern: pattern,
});
const start = new Date('2024-01-15');
const end = new Date('2024-01-20');
const result = generateOccurrences(entry, start, end);
expect(result.length).toBeGreaterThan(0);
for (const occ of result) {
// Time should match the master entry's scheduled_start time (local)
expect(occ.getHours()).toBe(14);
expect(occ.getMinutes()).toBe(30);
}
});
it('should respect interval > 1', () => {
const pattern = makePattern({
frequency: 'daily',
interval: 2, // every other day
startDate: new Date('2024-01-15'),
});
const entry = makeEntry({
scheduled_start: new Date('2024-01-15T09:00:00Z'),
scheduled_end: new Date('2024-01-15T10:00:00Z'),
is_recurring: true,
recurrence_pattern: pattern,
});
const start = new Date('2024-01-14');
const end = new Date('2024-01-30');
const result = generateOccurrences(entry, start, end);
// With interval=2, each consecutive occurrence should be exactly 2 days apart
expect(result.length).toBeGreaterThanOrEqual(3);
for (let i = 1; i < result.length; i++) {
const diffMs = result[i].getTime() - result[i - 1].getTime();
const diffDays = Math.round(diffMs / (24 * 60 * 60 * 1000));
expect(diffDays).toBe(2);
}
});
});
describe('weekly recurrence', () => {
it('should generate weekly occurrences', () => {
const pattern = makePattern({
frequency: 'weekly',
interval: 1,
startDate: new Date('2024-01-15'), // Monday
});
const entry = makeEntry({
scheduled_start: new Date('2024-01-15T09:00:00Z'),
scheduled_end: new Date('2024-01-15T10:00:00Z'),
is_recurring: true,
recurrence_pattern: pattern,
});
const start = new Date('2024-01-14');
const end = new Date('2024-02-28');
const result = generateOccurrences(entry, start, end);
// Should generate multiple weekly occurrences (master excluded)
expect(result.length).toBeGreaterThanOrEqual(3);
// Each consecutive occurrence should be exactly 7 days apart
for (let i = 1; i < result.length; i++) {
const diffMs = result[i].getTime() - result[i - 1].getTime();
const diffDays = Math.round(diffMs / (24 * 60 * 60 * 1000));
expect(diffDays).toBe(7);
}
// Master date should not be in the result
const dates = result.map((d) => d.toISOString().split('T')[0]);
expect(dates).not.toContain('2024-01-15');
});
it('should support daysOfWeek for weekly recurrence', () => {
const pattern = makePattern({
frequency: 'weekly',
interval: 1,
startDate: new Date('2024-01-15'), // Monday
daysOfWeek: [0, 2, 4], // Mon, Wed, Fri
});
const entry = makeEntry({
scheduled_start: new Date('2024-01-15T09:00:00Z'),
scheduled_end: new Date('2024-01-15T10:00:00Z'),
is_recurring: true,
recurrence_pattern: pattern,
});
const start = new Date('2024-01-14');
const end = new Date('2024-01-28');
const result = generateOccurrences(entry, start, end);
// All occurrences should fall on Mon(1), Wed(3), or Fri(5) UTC day-of-week
for (const occ of result) {
expect([1, 3, 5]).toContain(occ.getUTCDay());
}
expect(result.length).toBeGreaterThan(0);
});
});
describe('monthly recurrence', () => {
it('should generate monthly occurrences', () => {
const pattern = makePattern({
frequency: 'monthly',
interval: 1,
startDate: new Date('2024-01-15'),
});
const entry = makeEntry({
scheduled_start: new Date('2024-01-15T09:00:00Z'),
scheduled_end: new Date('2024-01-15T10:00:00Z'),
is_recurring: true,
recurrence_pattern: pattern,
});
const start = new Date('2024-01-01');
const end = new Date('2024-06-01');
const result = generateOccurrences(entry, start, end);
// Should generate multiple monthly occurrences (master excluded)
expect(result.length).toBeGreaterThanOrEqual(3);
// Each consecutive occurrence should be roughly 28-31 days apart (monthly)
for (let i = 1; i < result.length; i++) {
const diffMs = result[i].getTime() - result[i - 1].getTime();
const diffDays = Math.round(diffMs / (24 * 60 * 60 * 1000));
expect(diffDays).toBeGreaterThanOrEqual(28);
expect(diffDays).toBeLessThanOrEqual(31);
}
// Master date should not be in the result
const dates = result.map((d) => d.toISOString().split('T')[0]);
expect(dates).not.toContain('2024-01-15');
});
});
describe('endDate handling', () => {
it('should not generate occurrences after endDate', () => {
const pattern = makePattern({
frequency: 'daily',
interval: 1,
startDate: new Date('2024-01-15'),
endDate: new Date('2024-01-18'),
});
const entry = makeEntry({
scheduled_start: new Date('2024-01-15T09:00:00Z'),
scheduled_end: new Date('2024-01-15T10:00:00Z'),
is_recurring: true,
recurrence_pattern: pattern,
});
const start = new Date('2024-01-14');
const end = new Date('2024-01-31');
const result = generateOccurrences(entry, start, end);
const dates = result.map((d) => d.toISOString().split('T')[0]);
// Should have Jan 16, 17 at minimum (limited by endDate of Jan 18)
expect(dates).toContain('2024-01-16');
expect(dates).toContain('2024-01-17');
// Should NOT have dates clearly after the endDate
expect(dates).not.toContain('2024-01-20');
expect(dates).not.toContain('2024-01-25');
});
});
describe('count handling', () => {
it('should limit occurrences by count', () => {
const pattern = makePattern({
frequency: 'daily',
interval: 1,
startDate: new Date('2024-01-15'),
count: 5, // Only 5 total occurrences (including master)
});
const entry = makeEntry({
scheduled_start: new Date('2024-01-15T09:00:00Z'),
scheduled_end: new Date('2024-01-15T10:00:00Z'),
is_recurring: true,
recurrence_pattern: pattern,
});
const start = new Date('2024-01-14');
const end = new Date('2024-01-31');
const result = generateOccurrences(entry, start, end);
// Count=5 means 5 total. Master (Jan 15) is excluded from result.
// So we should get at most 4 virtual instances: Jan 16, 17, 18, 19
expect(result.length).toBeLessThanOrEqual(4);
expect(result.length).toBeGreaterThan(0);
});
});
describe('exception dates', () => {
it('should exclude exception dates from occurrences', () => {
const pattern = makePattern({
frequency: 'daily',
interval: 1,
startDate: new Date('2024-01-15'),
exceptions: [new Date('2024-01-17'), new Date('2024-01-19')],
});
const entry = makeEntry({
scheduled_start: new Date('2024-01-15T09:00:00Z'),
scheduled_end: new Date('2024-01-15T10:00:00Z'),
is_recurring: true,
recurrence_pattern: pattern,
});
const start = new Date('2024-01-14');
const end = new Date('2024-01-22');
const result = generateOccurrences(entry, start, end);
const dates = result.map((d) => d.toISOString().split('T')[0]);
// Jan 16, 18, 20, 21 should be present
expect(dates).toContain('2024-01-16');
expect(dates).toContain('2024-01-18');
expect(dates).toContain('2024-01-20');
// Exception dates should be excluded
expect(dates).not.toContain('2024-01-17');
expect(dates).not.toContain('2024-01-19');
});
it('should handle exception dates as strings', () => {
const pattern = makePattern({
frequency: 'daily',
interval: 1,
startDate: new Date('2024-01-15'),
exceptions: ['2024-01-17T00:00:00.000Z' as any],
});
const entry = makeEntry({
scheduled_start: new Date('2024-01-15T09:00:00Z'),
scheduled_end: new Date('2024-01-15T10:00:00Z'),
is_recurring: true,
recurrence_pattern: pattern,
});
const start = new Date('2024-01-14');
const end = new Date('2024-01-20');
const result = generateOccurrences(entry, start, end);
const dates = result.map((d) => d.toISOString().split('T')[0]);
expect(dates).not.toContain('2024-01-17'); // exception
expect(dates).toContain('2024-01-16');
expect(dates).toContain('2024-01-18');
});
it('should silently skip invalid exception dates', () => {
const pattern = makePattern({
frequency: 'daily',
interval: 1,
startDate: new Date('2024-01-15'),
exceptions: ['not-a-date' as any, new Date('2024-01-17')],
});
const entry = makeEntry({
scheduled_start: new Date('2024-01-15T09:00:00Z'),
scheduled_end: new Date('2024-01-15T10:00:00Z'),
is_recurring: true,
recurrence_pattern: pattern,
});
const start = new Date('2024-01-14');
const end = new Date('2024-01-20');
const result = generateOccurrences(entry, start, end);
const dates = result.map((d) => d.toISOString().split('T')[0]);
// Jan 17 should still be excluded (valid exception)
expect(dates).not.toContain('2024-01-17');
// Other dates should be present
expect(dates).toContain('2024-01-16');
expect(dates).toContain('2024-01-18');
});
});
describe('range filtering', () => {
it('should only return occurrences within the range', () => {
const pattern = makePattern({
frequency: 'daily',
interval: 1,
startDate: new Date('2024-01-01'),
});
const entry = makeEntry({
scheduled_start: new Date('2024-01-01T09:00:00Z'),
scheduled_end: new Date('2024-01-01T10:00:00Z'),
is_recurring: true,
recurrence_pattern: pattern,
});
// Small window well inside the series
const start = new Date('2024-01-09');
const end = new Date('2024-01-13');
const result = generateOccurrences(entry, start, end);
const dates = result.map((d) => d.toISOString().split('T')[0]);
// Should include interior dates
expect(dates).toContain('2024-01-10');
expect(dates).toContain('2024-01-11');
expect(dates).toContain('2024-01-12');
// Should not include dates well outside the window
expect(dates).not.toContain('2024-01-07');
expect(dates).not.toContain('2024-01-15');
});
});
describe('error handling', () => {
it('should return scheduled_start as fallback for invalid pattern startDate', () => {
const pattern = makePattern({
startDate: new Date('invalid') as any,
});
const entry = makeEntry({
scheduled_start: new Date('2024-01-15T09:00:00Z'),
is_recurring: true,
recurrence_pattern: pattern,
});
const result = generateOccurrences(entry, new Date('2024-01-01'), new Date('2024-01-31'));
expect(result).toHaveLength(1);
expect(result[0].toISOString()).toBe(new Date('2024-01-15T09:00:00Z').toISOString());
});
});
});