PSA/packages/scheduling/tests/scheduleEntryRecurrence.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

887 lines
30 KiB
TypeScript

/**
* @alga-psa/scheduling - Schedule Entry Recurrence Tests
*
* Tests for recurrence-aware update/delete operations in the ScheduleEntry model.
* Uses a mock Knex builder to verify the correct DB operations are performed
* for SINGLE/FUTURE/ALL edit scopes.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import ScheduleEntry from '@alga-psa/shared/models/scheduleEntry';
import type { IScheduleEntry, IRecurrencePattern } from '@alga-psa/types';
// ---------- Mock Knex Builder ----------
/**
* Creates a deeply chainable mock Knex object that records calls.
* Every method returns the chainable object so query builder chaining works.
* Terminal methods (first, del, returning) can be configured to resolve with values.
*/
function createMockKnex() {
const calls: { table: string; method: string; args: any[] }[] = [];
let currentTable = '';
// Storage for configuring mock return values
const firstResults: Map<string, any> = new Map();
const selectResults: any[] = [];
let insertCalled = false;
let insertArgs: any = null;
let updateCalled = false;
let updateArgs: any = null;
let deleteCalled = false;
let returningResult: any[] = [];
const chainable: Record<string, any> = {};
const chainMethods = [
'where',
'andWhere',
'whereIn',
'whereNull',
'whereNotNull',
'whereBetween',
'orWhereBetween',
'orWhere',
'orWhereRaw',
'andWhereRaw',
'whereRaw',
'select',
'orderBy',
'join',
];
for (const method of chainMethods) {
chainable[method] = vi.fn((...args: any[]) => {
calls.push({ table: currentTable, method, args });
return chainable;
});
}
chainable.first = vi.fn(async () => {
calls.push({ table: currentTable, method: 'first', args: [] });
return firstResults.get(currentTable) ?? undefined;
});
chainable.del = vi.fn(async () => {
calls.push({ table: currentTable, method: 'del', args: [] });
deleteCalled = true;
return 1;
});
chainable.insert = vi.fn(async (data: any) => {
calls.push({ table: currentTable, method: 'insert', args: [data] });
insertCalled = true;
insertArgs = data;
return chainable;
});
chainable.update = vi.fn((data: any) => {
calls.push({ table: currentTable, method: 'update', args: [data] });
updateCalled = true;
updateArgs = data;
return chainable;
});
chainable.returning = vi.fn(async () => {
calls.push({ table: currentTable, method: 'returning', args: [] });
return returningResult;
});
const mockKnex = vi.fn((tableName: string) => {
currentTable = tableName;
return chainable;
}) as any;
return {
knex: mockKnex,
chainable,
calls,
// Helpers to configure mock behavior
setFirstResult: (table: string, result: any) => firstResults.set(table, result),
setReturningResult: (result: any[]) => {
returningResult = result;
},
getInsertArgs: () => insertArgs,
getUpdateArgs: () => updateArgs,
wasInsertCalled: () => insertCalled,
wasUpdateCalled: () => updateCalled,
wasDeleteCalled: () => deleteCalled,
reset: () => {
calls.length = 0;
firstResults.clear();
selectResults.length = 0;
insertCalled = false;
insertArgs = null;
updateCalled = false;
updateArgs = null;
deleteCalled = false;
returningResult = [];
},
};
}
// ---------- Test Fixtures ----------
const TENANT = 'test-tenant';
function makeRecurrencePattern(overrides: Partial<IRecurrencePattern> = {}): IRecurrencePattern {
return {
frequency: 'daily',
interval: 1,
startDate: new Date('2024-01-15'),
...overrides,
};
}
function makeMasterEntry(overrides: Partial<any> = {}) {
return {
entry_id: 'master-1',
title: 'Daily Standup',
scheduled_start: new Date('2024-01-15T09:00:00Z'),
scheduled_end: new Date('2024-01-15T09:30:00Z'),
notes: 'Team standup',
status: 'scheduled',
work_item_id: null,
work_item_type: 'ad_hoc',
tenant: TENANT,
is_recurring: true,
recurrence_pattern: JSON.stringify(makeRecurrencePattern()),
is_private: false,
original_entry_id: null,
...overrides,
};
}
// ---------- Tests ----------
describe('ScheduleEntry - Recurrence Operations', () => {
describe('update() with IEditScope', () => {
describe('SINGLE scope', () => {
it('should create a standalone entry and add exception to master', async () => {
const mock = createMockKnex();
const masterEntry = makeMasterEntry();
mock.setFirstResult('schedule_entries', masterEntry);
// Stub getAssignedUserIds to avoid DB calls
const origGetAssigned = ScheduleEntry.getAssignedUserIds;
ScheduleEntry.getAssignedUserIds = vi.fn(async () => ({
'master-1': ['user-1'],
}));
// Stub updateAssignees to avoid DB calls
const origUpdateAssignees = ScheduleEntry.updateAssignees;
ScheduleEntry.updateAssignees = vi.fn(async () => {});
try {
const result = await ScheduleEntry.update(
mock.knex,
TENANT,
'master-1_1705485600000', // virtual ID: master-1 at Jan 17 2024 09:00 UTC
{
title: 'Updated Standup',
scheduled_start: new Date('2024-01-17T10:00:00Z'),
scheduled_end: new Date('2024-01-17T10:30:00Z'),
},
'single' as any
);
expect(result).toBeDefined();
// The returned entry should be non-recurring (standalone)
expect(result!.is_recurring).toBe(false);
expect(result!.original_entry_id).toBeNull();
expect(result!.title).toBe('Updated Standup');
expect(result!.assigned_user_ids).toEqual(['user-1']);
// Should have inserted a new standalone entry
expect(mock.wasInsertCalled()).toBe(true);
const insertArgs = mock.getInsertArgs();
expect(insertArgs.is_recurring).toBe(false);
expect(insertArgs.recurrence_pattern).toBeNull();
expect(insertArgs.title).toBe('Updated Standup');
// Should have updated master pattern with exception
expect(mock.wasUpdateCalled()).toBe(true);
const updateArgs = mock.getUpdateArgs();
expect(updateArgs.recurrence_pattern).toBeDefined();
const updatedPattern = JSON.parse(updateArgs.recurrence_pattern);
expect(updatedPattern.exceptions).toBeDefined();
expect(updatedPattern.exceptions.length).toBeGreaterThan(0);
} finally {
ScheduleEntry.getAssignedUserIds = origGetAssigned;
ScheduleEntry.updateAssignees = origUpdateAssignees;
}
});
});
describe('FUTURE scope', () => {
it('should truncate original master and create new master for future', async () => {
const mock = createMockKnex();
const masterEntry = makeMasterEntry();
mock.setFirstResult('schedule_entries', masterEntry);
const origGetAssigned = ScheduleEntry.getAssignedUserIds;
ScheduleEntry.getAssignedUserIds = vi.fn(async () => ({
'master-1': ['user-1'],
}));
const origUpdateAssignees = ScheduleEntry.updateAssignees;
ScheduleEntry.updateAssignees = vi.fn(async () => {});
try {
// Virtual ID for Jan 20, 2024 09:00 UTC
const virtualTimestamp = new Date('2024-01-20T09:00:00Z').getTime();
const result = await ScheduleEntry.update(
mock.knex,
TENANT,
`master-1_${virtualTimestamp}`,
{
title: 'Renamed Standup',
},
'future' as any
);
expect(result).toBeDefined();
// The new master should be recurring
expect(result!.is_recurring).toBe(true);
expect(result!.title).toBe('Renamed Standup');
// Should have updated original master (truncating its endDate)
expect(mock.wasUpdateCalled()).toBe(true);
const updateArgs = mock.getUpdateArgs();
const truncatedPattern = JSON.parse(updateArgs.recurrence_pattern);
// endDate should be the day before the virtual timestamp
const expectedEnd = new Date('2024-01-19T23:59:59.999');
expect(new Date(truncatedPattern.endDate).getDate()).toBe(expectedEnd.getDate());
// Should have inserted a new master entry
expect(mock.wasInsertCalled()).toBe(true);
const insertArgs = mock.getInsertArgs();
expect(insertArgs.is_recurring).toBe(true);
expect(insertArgs.recurrence_pattern).toBeDefined();
const newPattern = JSON.parse(insertArgs.recurrence_pattern);
expect(new Date(newPattern.startDate).getTime()).toBe(
new Date('2024-01-20T09:00:00Z').getTime()
);
} finally {
ScheduleEntry.getAssignedUserIds = origGetAssigned;
ScheduleEntry.updateAssignees = origUpdateAssignees;
}
});
it('should throw when virtualTimestamp is missing for FUTURE scope', async () => {
const mock = createMockKnex();
const masterEntry = makeMasterEntry();
mock.setFirstResult('schedule_entries', masterEntry);
await expect(
ScheduleEntry.update(
mock.knex,
TENANT,
'master-1', // NOT a virtual ID — no timestamp
{ title: 'Updated' },
'future' as any
)
).rejects.toThrow('Virtual timestamp is required for future updates');
});
});
describe('ALL scope', () => {
it('should update the master entry directly, preserving exceptions', async () => {
const patternWithExceptions = makeRecurrencePattern({
exceptions: [new Date('2024-01-17')],
});
const masterEntry = makeMasterEntry({
recurrence_pattern: JSON.stringify(patternWithExceptions),
});
const mock = createMockKnex();
mock.setFirstResult('schedule_entries', masterEntry);
mock.setReturningResult([
{
...masterEntry,
title: 'All Updated',
recurrence_pattern: JSON.stringify(patternWithExceptions),
},
]);
const origGetAssigned = ScheduleEntry.getAssignedUserIds;
ScheduleEntry.getAssignedUserIds = vi.fn(async () => ({
'master-1': ['user-1'],
}));
try {
const result = await ScheduleEntry.update(
mock.knex,
TENANT,
'master-1',
{ title: 'All Updated' },
'all' as any
);
expect(result).toBeDefined();
expect(result!.is_recurring).toBe(true);
expect(result!.assigned_user_ids).toEqual(['user-1']);
// Should have updated the master entry
expect(mock.wasUpdateCalled()).toBe(true);
const updateArgs = mock.getUpdateArgs();
expect(updateArgs.title).toBe('All Updated');
expect(updateArgs.is_recurring).toBe(true);
// Exceptions should be preserved
const updatedPattern = JSON.parse(updateArgs.recurrence_pattern);
expect(updatedPattern.exceptions).toBeDefined();
expect(updatedPattern.exceptions.length).toBe(1);
} finally {
ScheduleEntry.getAssignedUserIds = origGetAssigned;
}
});
});
describe('non-recurring update (no scope)', () => {
it('should perform a simple field update when entry has no recurrence_pattern', async () => {
const nonRecurringEntry = makeMasterEntry({
is_recurring: false,
recurrence_pattern: null,
});
const mock = createMockKnex();
mock.setFirstResult('schedule_entries', nonRecurringEntry);
mock.setReturningResult([{ ...nonRecurringEntry, title: 'Updated Title' }]);
const origGetAssigned = ScheduleEntry.getAssignedUserIds;
ScheduleEntry.getAssignedUserIds = vi.fn(async () => ({
'master-1': ['user-1'],
}));
try {
const result = await ScheduleEntry.update(mock.knex, TENANT, 'master-1', {
title: 'Updated Title',
});
expect(result).toBeDefined();
expect(result!.title).toBe('Updated Title');
// Should have done a regular update (not insert)
expect(mock.wasUpdateCalled()).toBe(true);
expect(mock.wasInsertCalled()).toBe(false);
} finally {
ScheduleEntry.getAssignedUserIds = origGetAssigned;
}
});
it('should return undefined when entry does not exist', async () => {
const mock = createMockKnex();
mock.setFirstResult('schedule_entries', undefined);
const result = await ScheduleEntry.update(mock.knex, TENANT, 'nonexistent', {
title: 'test',
});
expect(result).toBeUndefined();
});
});
describe('removing recurrence', () => {
it('should clear recurrence when pattern is set to empty object', async () => {
const masterEntry = makeMasterEntry();
const mock = createMockKnex();
mock.setFirstResult('schedule_entries', masterEntry);
mock.setReturningResult([
{
...masterEntry,
is_recurring: false,
recurrence_pattern: null,
},
]);
const origGetAssigned = ScheduleEntry.getAssignedUserIds;
ScheduleEntry.getAssignedUserIds = vi.fn(async () => ({
'master-1': ['user-1'],
}));
try {
// Pass empty recurrence_pattern and no updateType — triggers removal path
const result = await ScheduleEntry.update(mock.knex, TENANT, 'master-1', {
recurrence_pattern: {} as any,
});
expect(result).toBeDefined();
expect(mock.wasUpdateCalled()).toBe(true);
const updateArgs = mock.getUpdateArgs();
expect(updateArgs.recurrence_pattern).toBeNull();
expect(updateArgs.is_recurring).toBe(false);
} finally {
ScheduleEntry.getAssignedUserIds = origGetAssigned;
}
});
});
});
describe('delete() with IEditScope', () => {
describe('SINGLE scope — virtual instance', () => {
it('should add exception date to master pattern', async () => {
const mock = createMockKnex();
const masterEntry = makeMasterEntry();
mock.setFirstResult('schedule_entries', masterEntry);
// Virtual ID for Jan 17, 2024 09:00 UTC
const virtualTimestamp = new Date('2024-01-17T09:00:00Z').getTime();
const result = await ScheduleEntry.delete(
mock.knex,
TENANT,
`master-1_${virtualTimestamp}`,
'single' as any
);
expect(result).toBe(true);
// Should have updated (not deleted) the master with an exception
expect(mock.wasUpdateCalled()).toBe(true);
const updateArgs = mock.getUpdateArgs();
const updatedPattern = JSON.parse(updateArgs.recurrence_pattern);
expect(updatedPattern.exceptions).toBeDefined();
expect(updatedPattern.exceptions.length).toBe(1);
// The exception should be midnight UTC on Jan 17
const exceptionDate = new Date(updatedPattern.exceptions[0]);
expect(exceptionDate.getUTCFullYear()).toBe(2024);
expect(exceptionDate.getUTCMonth()).toBe(0); // January
expect(exceptionDate.getUTCDate()).toBe(17);
expect(exceptionDate.getUTCHours()).toBe(0);
});
});
describe('SINGLE scope — master entry', () => {
it('should delete the original master', async () => {
const mock = createMockKnex();
const masterEntry = makeMasterEntry();
mock.setFirstResult('schedule_entries', masterEntry);
// Stub getAssignedUserIds + updateAssignees for the new master creation
const origGetAssigned = ScheduleEntry.getAssignedUserIds;
ScheduleEntry.getAssignedUserIds = vi.fn(async () => ({
'master-1': ['user-1'],
}));
const origUpdateAssignees = ScheduleEntry.updateAssignees;
ScheduleEntry.updateAssignees = vi.fn(async () => {});
try {
const result = await ScheduleEntry.delete(
mock.knex,
TENANT,
'master-1', // NOT a virtual ID
'single' as any
);
expect(result).toBe(true);
// Should have deleted the master entry
expect(mock.wasDeleteCalled()).toBe(true);
} finally {
ScheduleEntry.getAssignedUserIds = origGetAssigned;
ScheduleEntry.updateAssignees = origUpdateAssignees;
}
});
});
describe('FUTURE scope — virtual instance', () => {
it('should truncate master series endDate to before this instance', async () => {
const mock = createMockKnex();
const masterEntry = makeMasterEntry();
mock.setFirstResult('schedule_entries', masterEntry);
// Virtual ID for Jan 20, 2024 09:00 UTC
const virtualTimestamp = new Date('2024-01-20T09:00:00Z').getTime();
const result = await ScheduleEntry.delete(
mock.knex,
TENANT,
`master-1_${virtualTimestamp}`,
'future' as any
);
expect(result).toBe(true);
// Should have updated master with truncated endDate
expect(mock.wasUpdateCalled()).toBe(true);
const updateArgs = mock.getUpdateArgs();
const updatedPattern = JSON.parse(updateArgs.recurrence_pattern);
// endDate should be Jan 19 end of day
const endDate = new Date(updatedPattern.endDate);
expect(endDate.getDate()).toBe(19);
expect(endDate.getHours()).toBe(23);
expect(endDate.getMinutes()).toBe(59);
});
it('should filter out exceptions after the truncation point', () => {
// This is an indirect test — the update method filters exceptions.
// We verify by checking the pattern stored.
const mock = createMockKnex();
const patternWithExceptions = makeRecurrencePattern({
exceptions: [
new Date('2024-01-17T00:00:00Z'), // before truncation
new Date('2024-01-22T00:00:00Z'), // after truncation
],
});
const masterEntry = makeMasterEntry({
recurrence_pattern: JSON.stringify(patternWithExceptions),
});
mock.setFirstResult('schedule_entries', masterEntry);
const virtualTimestamp = new Date('2024-01-20T09:00:00Z').getTime();
return ScheduleEntry.delete(
mock.knex,
TENANT,
`master-1_${virtualTimestamp}`,
'future' as any
).then((result) => {
expect(result).toBe(true);
const updateArgs = mock.getUpdateArgs();
const updatedPattern = JSON.parse(updateArgs.recurrence_pattern);
// Only the exception before the truncation point should remain
expect(updatedPattern.exceptions.length).toBe(1);
expect(new Date(updatedPattern.exceptions[0]).getUTCDate()).toBe(17);
});
});
});
describe('FUTURE scope — master entry', () => {
it('should delete the entire series', async () => {
const mock = createMockKnex();
const masterEntry = makeMasterEntry();
mock.setFirstResult('schedule_entries', masterEntry);
const result = await ScheduleEntry.delete(
mock.knex,
TENANT,
'master-1',
'future' as any
);
expect(result).toBe(true);
expect(mock.wasDeleteCalled()).toBe(true);
});
});
describe('ALL scope', () => {
it('should delete the master entry entirely', async () => {
const mock = createMockKnex();
const masterEntry = makeMasterEntry();
mock.setFirstResult('schedule_entries', masterEntry);
const result = await ScheduleEntry.delete(
mock.knex,
TENANT,
'master-1',
'all' as any
);
expect(result).toBe(true);
expect(mock.wasDeleteCalled()).toBe(true);
});
});
describe('non-recurring delete', () => {
it('should perform a simple delete for non-recurring entries', async () => {
const nonRecurringEntry = makeMasterEntry({
is_recurring: false,
recurrence_pattern: null,
});
const mock = createMockKnex();
mock.setFirstResult('schedule_entries', nonRecurringEntry);
const result = await ScheduleEntry.delete(mock.knex, TENANT, 'master-1');
expect(result).toBe(true);
expect(mock.wasDeleteCalled()).toBe(true);
});
it('should return false when entry does not exist', async () => {
const mock = createMockKnex();
mock.setFirstResult('schedule_entries', undefined);
const result = await ScheduleEntry.delete(mock.knex, TENANT, 'nonexistent');
expect(result).toBe(false);
});
});
});
describe('Virtual ID parsing', () => {
it('should correctly parse virtual entry IDs in update()', async () => {
const mock = createMockKnex();
const masterEntry = makeMasterEntry();
mock.setFirstResult('schedule_entries', masterEntry);
const origGetAssigned = ScheduleEntry.getAssignedUserIds;
ScheduleEntry.getAssignedUserIds = vi.fn(async () => ({
'master-1': ['user-1'],
}));
const origUpdateAssignees = ScheduleEntry.updateAssignees;
ScheduleEntry.updateAssignees = vi.fn(async () => {});
try {
// The virtual ID should be parsed into master-1 + timestamp
await ScheduleEntry.update(
mock.knex,
TENANT,
'master-1_1705485600000',
{ title: 'Updated' },
'single' as any
);
// Verify the master entry was fetched using master-1 (not the full virtual ID)
const firstCall = mock.calls.find(
(c) => c.table === 'schedule_entries' && c.method === 'first'
);
expect(firstCall).toBeDefined();
} finally {
ScheduleEntry.getAssignedUserIds = origGetAssigned;
ScheduleEntry.updateAssignees = origUpdateAssignees;
}
});
it('should correctly parse virtual entry IDs in delete()', async () => {
const mock = createMockKnex();
const masterEntry = makeMasterEntry();
mock.setFirstResult('schedule_entries', masterEntry);
await ScheduleEntry.delete(
mock.knex,
TENANT,
'master-1_1705485600000',
'single' as any
);
// The exception should have been added based on the parsed timestamp
expect(mock.wasUpdateCalled()).toBe(true);
const updateArgs = mock.getUpdateArgs();
const pattern = JSON.parse(updateArgs.recurrence_pattern);
expect(pattern.exceptions).toHaveLength(1);
});
});
describe('getRecurringEntriesInRange()', () => {
it('should throw when tenant is not provided', async () => {
const mock = createMockKnex();
await expect(
ScheduleEntry.getRecurringEntriesInRange(
mock.knex,
'',
new Date('2024-01-01'),
new Date('2024-01-31')
)
).rejects.toThrow('Tenant context is required');
});
});
describe('getRecurringEntriesWithAssignments()', () => {
it('should generate virtual entries with composite IDs and assignments', async () => {
const pattern = makeRecurrencePattern({
frequency: 'daily',
interval: 1,
startDate: new Date('2024-01-15'),
});
const masterEntry: IScheduleEntry = {
entry_id: 'master-1',
title: 'Daily Standup',
scheduled_start: new Date('2024-01-15T09:00:00Z'),
scheduled_end: new Date('2024-01-15T09:30:00Z'),
notes: 'Team standup',
status: 'scheduled',
work_item_id: null,
work_item_type: 'ad_hoc',
tenant: TENANT,
is_recurring: true,
recurrence_pattern: pattern,
is_private: false,
original_entry_id: null,
assigned_user_ids: [],
} as IScheduleEntry;
// Stub getAssignedUserIds
const origGetAssigned = ScheduleEntry.getAssignedUserIds;
ScheduleEntry.getAssignedUserIds = vi.fn(async () => ({
'master-1': ['user-1', 'user-2'],
}));
try {
const mock = createMockKnex();
const start = new Date('2024-01-16');
const end = new Date('2024-01-18');
const result = await ScheduleEntry.getRecurringEntriesWithAssignments(
mock.knex,
TENANT,
[masterEntry],
start,
end
);
// Should have generated virtual entries for Jan 16, 17, 18
expect(result.length).toBeGreaterThanOrEqual(2);
for (const entry of result) {
// Each virtual entry should have a composite ID
expect(entry.entry_id).toContain('master-1_');
// Should reference the master
expect(entry.original_entry_id).toBe('master-1');
// Should be marked as recurring
expect(entry.is_recurring).toBe(true);
// Should inherit assignments
expect(entry.assigned_user_ids).toEqual(['user-1', 'user-2']);
// Duration should be preserved (30 min)
const duration =
new Date(entry.scheduled_end).getTime() -
new Date(entry.scheduled_start).getTime();
expect(duration).toBe(30 * 60 * 1000); // 30 minutes in ms
}
} finally {
ScheduleEntry.getAssignedUserIds = origGetAssigned;
}
});
it('should skip entries with empty recurrence patterns', async () => {
const entryWithoutPattern: IScheduleEntry = {
entry_id: 'entry-no-pattern',
title: 'No Pattern',
scheduled_start: new Date('2024-01-15T09:00:00Z'),
scheduled_end: new Date('2024-01-15T09:30:00Z'),
notes: '',
status: 'scheduled',
work_item_id: null,
work_item_type: 'ad_hoc',
tenant: TENANT,
is_recurring: true,
recurrence_pattern: null as any,
is_private: false,
original_entry_id: null,
assigned_user_ids: [],
} as IScheduleEntry;
const origGetAssigned = ScheduleEntry.getAssignedUserIds;
ScheduleEntry.getAssignedUserIds = vi.fn(async () => ({}));
try {
const mock = createMockKnex();
const result = await ScheduleEntry.getRecurringEntriesWithAssignments(
mock.knex,
TENANT,
[entryWithoutPattern],
new Date('2024-01-16'),
new Date('2024-01-18')
);
expect(result).toEqual([]);
} finally {
ScheduleEntry.getAssignedUserIds = origGetAssigned;
}
});
it('should parse JSON string recurrence patterns from DB', async () => {
const patternObj = makeRecurrencePattern({
frequency: 'daily',
interval: 1,
startDate: new Date('2024-01-15'),
});
// Simulate what the DB returns — pattern as a JSON string
const masterEntry: IScheduleEntry = {
entry_id: 'master-1',
title: 'Daily Standup',
scheduled_start: new Date('2024-01-15T09:00:00Z'),
scheduled_end: new Date('2024-01-15T09:30:00Z'),
notes: '',
status: 'scheduled',
work_item_id: null,
work_item_type: 'ad_hoc',
tenant: TENANT,
is_recurring: true,
recurrence_pattern: JSON.stringify(patternObj) as any,
is_private: false,
original_entry_id: null,
assigned_user_ids: [],
} as IScheduleEntry;
const origGetAssigned = ScheduleEntry.getAssignedUserIds;
ScheduleEntry.getAssignedUserIds = vi.fn(async () => ({
'master-1': ['user-1'],
}));
try {
const mock = createMockKnex();
const result = await ScheduleEntry.getRecurringEntriesWithAssignments(
mock.knex,
TENANT,
[masterEntry],
new Date('2024-01-16'),
new Date('2024-01-17')
);
expect(result.length).toBeGreaterThan(0);
for (const entry of result) {
expect(entry.entry_id).toContain('master-1_');
}
} finally {
ScheduleEntry.getAssignedUserIds = origGetAssigned;
}
});
it('should filter out exception dates from virtual entries', async () => {
const pattern = makeRecurrencePattern({
frequency: 'daily',
interval: 1,
startDate: new Date('2024-01-15'),
exceptions: [new Date('2024-01-17')],
});
const masterEntry: IScheduleEntry = {
entry_id: 'master-1',
title: 'Daily Standup',
scheduled_start: new Date('2024-01-15T09:00:00Z'),
scheduled_end: new Date('2024-01-15T09:30:00Z'),
notes: '',
status: 'scheduled',
work_item_id: null,
work_item_type: 'ad_hoc',
tenant: TENANT,
is_recurring: true,
recurrence_pattern: pattern,
is_private: false,
original_entry_id: null,
assigned_user_ids: [],
} as IScheduleEntry;
const origGetAssigned = ScheduleEntry.getAssignedUserIds;
ScheduleEntry.getAssignedUserIds = vi.fn(async () => ({
'master-1': ['user-1'],
}));
try {
const mock = createMockKnex();
// Use a wide range to avoid boundary issues
const result = await ScheduleEntry.getRecurringEntriesWithAssignments(
mock.knex,
TENANT,
[masterEntry],
new Date('2024-01-14'),
new Date('2024-01-20')
);
const dates = result.map(
(e) => new Date(e.scheduled_start).toISOString().split('T')[0]
);
expect(dates).toContain('2024-01-16');
expect(dates).not.toContain('2024-01-17'); // exception
expect(dates).toContain('2024-01-18');
} finally {
ScheduleEntry.getAssignedUserIds = origGetAssigned;
}
});
});
});