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
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz Source: /opt/alga-psa on psa.joliet.tech
887 lines
30 KiB
TypeScript
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;
|
|
}
|
|
});
|
|
});
|
|
});
|