import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ZodError } from 'zod'; const createTenantKnexMock = vi.fn(); const hasPermissionMock = vi.fn(); const assertCanActOnBehalfMock = vi.fn(); const resolveUserTimeZoneMock = vi.fn(); const computeWorkDateFieldsMock = vi.fn(); const createTimeEntryChangeRequestRecordMock = vi.fn(); const fetchTimeEntryChangeRequestsForEntryIdsFromDbMock = vi.fn(); const markTimeEntryChangeRequestsHandledMock = vi.fn(); const determineDefaultContractLineMock = vi.fn(async () => null); vi.mock('@alga-psa/auth', () => ({ withAuth: (fn: any) => fn, hasPermission: (...args: any[]) => hasPermissionMock(...args), })); vi.mock('@alga-psa/db', () => ({ createTenantKnex: createTenantKnexMock, resolveUserTimeZone: (...args: any[]) => resolveUserTimeZoneMock(...args), computeWorkDateFields: (...args: any[]) => computeWorkDateFieldsMock(...args), })); vi.mock('../src/actions/timeEntryDelegationAuth', () => ({ assertCanActOnBehalf: (...args: any[]) => assertCanActOnBehalfMock(...args), })); vi.mock('../src/lib/contractLineDisambiguation', () => ({ determineDefaultContractLine: (...args: any[]) => determineDefaultContractLineMock(...args), })); vi.mock('../src/services/bucketUsageService', () => ({ findOrCreateCurrentBucketUsageRecord: vi.fn(), updateBucketUsageMinutes: vi.fn(), })); vi.mock('../src/actions/timeEntryHelpers', () => ({ getClientIdForWorkItem: vi.fn(async () => null), })); vi.mock('../src/actions/timeEntryChangeRequestActions', () => ({ createTimeEntryChangeRequestRecord: (...args: any[]) => createTimeEntryChangeRequestRecordMock(...args), fetchTimeEntryChangeRequestsForEntryIdsFromDb: (...args: any[]) => fetchTimeEntryChangeRequestsForEntryIdsFromDbMock(...args), markTimeEntryChangeRequestsHandled: (...args: any[]) => markTimeEntryChangeRequestsHandledMock(...args), })); type DbStubConfig = { existingEntry: Record; updatedEntry?: Record; timeSheetStatus?: string; initialBillableDuration?: number; }; function createDbStub(config: DbStubConfig) { const calls = { entryUpdates: [] as Array<{ criteria: Record; payload: Record }>, sheetUpdates: [] as Array<{ criteria: Record; payload: Record }>, }; const db: any = (table: string) => { const state: { criteria?: Record; selectColumns?: string[] } = {}; const builder: any = { where(criteria: Record) { state.criteria = criteria; return builder; }, select(...columns: string[]) { state.selectColumns = columns; return builder; }, first(...columns: string[]) { if (columns.length > 0) { state.selectColumns = columns; } if (table === 'time_entries') { if (state.selectColumns?.includes('billable_duration')) { return Promise.resolve({ billable_duration: config.initialBillableDuration ?? 0 }); } return Promise.resolve(config.existingEntry); } if (table === 'time_sheets') { return Promise.resolve({ approval_status: config.timeSheetStatus ?? 'CHANGES_REQUESTED' }); } if (table === 'tickets') { return Promise.resolve({ client_id: 'client-1' }); } throw new Error(`Unexpected first() call for table ${table}`); }, update(payload: Record) { if (!state.criteria) { throw new Error(`Missing criteria for ${table} update`); } if (table === 'time_entries') { calls.entryUpdates.push({ criteria: state.criteria, payload }); const updateResult = config.updatedEntry ? [config.updatedEntry] : 1; return { returning: async () => updateResult, then: (resolve: (value: any) => any) => Promise.resolve(updateResult).then(resolve), }; } if (table === 'time_sheets') { calls.sheetUpdates.push({ criteria: state.criteria, payload }); return Promise.resolve(1); } throw new Error(`Unexpected update() call for table ${table}`); }, }; return builder; }; db.transaction = async (callback: (trx: any) => Promise) => callback(db); db.fn = { now: () => 'NOW' }; db.raw = (value: string) => value; return { db, calls }; } function createFetchEntriesDbStub() { const db: any = Object.assign((table: string) => { if (table === 'time_sheets') { return { where() { return { select() { return { first: async () => ({ user_id: 'user-1' }), }; }, }; }, }; } if (table === 'time_entries') { const builder: any = { leftJoin() { return builder; }, where() { return builder; }, orderBy() { return builder; }, select() { return Promise.resolve([ { entry_id: 'entry-1', work_item_id: null, work_item_type: 'non_billable_category', start_time: new Date('2026-03-10T09:00:00.000Z'), end_time: new Date('2026-03-10T10:00:00.000Z'), created_at: new Date('2026-03-10T09:00:00.000Z'), updated_at: new Date('2026-03-10T10:00:00.000Z'), billable_duration: 0, notes: 'Internal admin time', user_id: 'user-1', time_sheet_id: 'sheet-1', approval_status: 'DRAFT', tenant: 'tenant-1', work_date: '2026-03-10', service_id: undefined, }, ]); }, }; return builder; } if (table === 'service_catalog as sc') { const builder: any = { leftJoin() { return builder; }, where() { return builder; }, select() { return Promise.resolve([]); }, }; return builder; } throw new Error(`Unexpected table ${table}`); }, { raw: (value: string) => value, }); return db; } describe('time entry change-request action integration', () => { beforeEach(() => { vi.clearAllMocks(); hasPermissionMock.mockResolvedValue(true); assertCanActOnBehalfMock.mockResolvedValue(undefined); resolveUserTimeZoneMock.mockResolvedValue('America/New_York'); computeWorkDateFieldsMock.mockImplementation((value: string) => ({ work_date: value.slice(0, 10), work_timezone: 'America/New_York', })); fetchTimeEntryChangeRequestsForEntryIdsFromDbMock.mockResolvedValue(new Map()); }); it('T003/T025: requesting changes creates a change-request record and keeps creating new records across review cycles', async () => { const { db, calls } = createDbStub({ existingEntry: { entry_id: 'entry-1', user_id: 'user-1', invoiced: false, time_sheet_id: 'sheet-1', }, }); createTenantKnexMock.mockResolvedValue({ knex: db }); const { updateTimeEntryApprovalStatus } = await import('../src/actions/timeEntryCrudActions'); await (updateTimeEntryApprovalStatus as any)( { user_id: 'manager-1' }, { tenant: 'tenant-1' }, { entryId: 'entry-1', approvalStatus: 'CHANGES_REQUESTED', changeRequestComment: 'Please break out travel time.', }, ); await (updateTimeEntryApprovalStatus as any)( { user_id: 'manager-1' }, { tenant: 'tenant-1' }, { entryId: 'entry-1', approvalStatus: 'CHANGES_REQUESTED', changeRequestComment: 'Please attach the client note too.', }, ); expect(calls.entryUpdates).toHaveLength(2); expect(calls.sheetUpdates).toHaveLength(2); expect(createTimeEntryChangeRequestRecordMock).toHaveBeenCalledTimes(2); expect(createTimeEntryChangeRequestRecordMock).toHaveBeenNthCalledWith(1, db, { tenant: 'tenant-1', timeEntryId: 'entry-1', timeSheetId: 'sheet-1', comment: 'Please break out travel time.', createdBy: 'manager-1', }); expect(createTimeEntryChangeRequestRecordMock).toHaveBeenNthCalledWith(2, db, { tenant: 'tenant-1', timeEntryId: 'entry-1', timeSheetId: 'sheet-1', comment: 'Please attach the client note too.', createdBy: 'manager-1', }); }); it('T005: blocks change-request creation when the user lacks approval permission', async () => { hasPermissionMock.mockResolvedValue(false); const { db } = createDbStub({ existingEntry: { entry_id: 'entry-1', user_id: 'user-1', invoiced: false, time_sheet_id: 'sheet-1', }, }); createTenantKnexMock.mockResolvedValue({ knex: db }); const { updateTimeEntryApprovalStatus } = await import('../src/actions/timeEntryCrudActions'); await expect( (updateTimeEntryApprovalStatus as any)( { user_id: 'manager-1' }, { tenant: 'tenant-1' }, { entryId: 'entry-1', approvalStatus: 'CHANGES_REQUESTED', changeRequestComment: 'Please fix this.', }, ), ).rejects.toThrow('Permission denied: Cannot update time entry approval status'); expect(createTimeEntryChangeRequestRecordMock).not.toHaveBeenCalled(); }); it('T021/T023/T024/T029: saving an edited entry auto-handles only that entry during CHANGES_REQUESTED and never creates approver feedback', async () => { const { db } = createDbStub({ existingEntry: { entry_id: 'entry-1', user_id: 'user-1', invoiced: false, time_sheet_id: 'sheet-1', }, updatedEntry: { entry_id: 'entry-1', work_item_id: 'non-billable', work_item_type: 'non_billable_category', start_time: '2026-03-10T09:00:00.000Z', end_time: '2026-03-10T10:00:00.000Z', created_at: '2026-03-10T09:00:00.000Z', updated_at: '2026-03-10T10:00:00.000Z', billable_duration: 0, notes: 'Updated notes', user_id: 'user-1', time_sheet_id: 'sheet-1', approval_status: 'DRAFT', service_id: 'service-1', tenant: 'tenant-1', }, timeSheetStatus: 'CHANGES_REQUESTED', initialBillableDuration: 0, }); createTenantKnexMock.mockResolvedValue({ knex: db }); const { saveTimeEntry } = await import('../src/actions/timeEntryCrudActions'); await (saveTimeEntry as any)( { user_id: 'user-1' }, { tenant: 'tenant-1' }, { entry_id: 'entry-1', work_item_id: 'non-billable', work_item_type: 'non_billable_category', start_time: '2026-03-10T09:00:00.000Z', end_time: '2026-03-10T10:00:00.000Z', created_at: '2026-03-10T09:00:00.000Z', updated_at: '2026-03-10T09:00:00.000Z', billable_duration: 0, notes: 'Updated notes', user_id: 'user-1', approval_status: 'DRAFT', service_id: 'service-1', }, ); expect(markTimeEntryChangeRequestsHandledMock).toHaveBeenCalledWith(db, { tenant: 'tenant-1', timeEntryId: 'entry-1', handledBy: 'user-1', }); expect(markTimeEntryChangeRequestsHandledMock).not.toHaveBeenCalledWith(db, { tenant: 'tenant-1', timeEntryId: 'entry-2', handledBy: 'user-1', }); expect(createTimeEntryChangeRequestRecordMock).not.toHaveBeenCalled(); }); it('does not auto-handle feedback when saving outside the changes-requested flow', async () => { const { db } = createDbStub({ existingEntry: { entry_id: 'entry-1', user_id: 'user-1', invoiced: false, time_sheet_id: 'sheet-1', }, updatedEntry: { entry_id: 'entry-1', work_item_id: 'non-billable', work_item_type: 'non_billable_category', start_time: '2026-03-10T09:00:00.000Z', end_time: '2026-03-10T10:00:00.000Z', created_at: '2026-03-10T09:00:00.000Z', updated_at: '2026-03-10T10:00:00.000Z', billable_duration: 0, notes: 'Updated notes', user_id: 'user-1', time_sheet_id: 'sheet-1', approval_status: 'DRAFT', service_id: 'service-1', tenant: 'tenant-1', }, timeSheetStatus: 'SUBMITTED', initialBillableDuration: 0, }); createTenantKnexMock.mockResolvedValue({ knex: db }); const { saveTimeEntry } = await import('../src/actions/timeEntryCrudActions'); await (saveTimeEntry as any)( { user_id: 'user-1' }, { tenant: 'tenant-1' }, { entry_id: 'entry-1', work_item_id: 'non-billable', work_item_type: 'non_billable_category', start_time: '2026-03-10T09:00:00.000Z', end_time: '2026-03-10T10:00:00.000Z', created_at: '2026-03-10T09:00:00.000Z', updated_at: '2026-03-10T09:00:00.000Z', billable_duration: 0, notes: 'Updated notes', user_id: 'user-1', approval_status: 'DRAFT', service_id: 'service-1', }, ); expect(markTimeEntryChangeRequestsHandledMock).not.toHaveBeenCalled(); }); it('T003: saveTimeEntry resolves default contract line using effective work date for backdated/current/future entries', async () => { const { saveTimeEntry } = await import('../src/actions/timeEntryCrudActions'); const scenarios = [ { label: 'backdated', startTime: '2025-01-15T09:00:00.000Z', expectedEffectiveDate: '2025-01-15' }, { label: 'current', startTime: '2026-03-10T09:00:00.000Z', expectedEffectiveDate: '2026-03-10' }, { label: 'future', startTime: '2027-11-02T09:00:00.000Z', expectedEffectiveDate: '2027-11-02' }, ]; for (const scenario of scenarios) { determineDefaultContractLineMock.mockClear(); determineDefaultContractLineMock.mockResolvedValueOnce(null); const { db } = createDbStub({ existingEntry: { entry_id: `entry-${scenario.label}`, user_id: 'user-1', invoiced: false, time_sheet_id: 'sheet-1', }, updatedEntry: { entry_id: `entry-${scenario.label}`, work_item_id: 'non-billable', work_item_type: 'non_billable_category', start_time: scenario.startTime, end_time: scenario.startTime.replace('09:00:00.000Z', '10:00:00.000Z'), created_at: scenario.startTime, updated_at: scenario.startTime, billable_duration: 60, notes: `${scenario.label} update`, user_id: 'user-1', time_sheet_id: 'sheet-1', approval_status: 'DRAFT', service_id: 'service-1', tenant: 'tenant-1', }, timeSheetStatus: 'SUBMITTED', initialBillableDuration: 60, }); createTenantKnexMock.mockResolvedValue({ knex: db }); await (saveTimeEntry as any)( { user_id: 'user-1' }, { tenant: 'tenant-1' }, { entry_id: `entry-${scenario.label}`, work_item_id: 'non-billable', work_item_type: 'non_billable_category', start_time: scenario.startTime, end_time: scenario.startTime.replace('09:00:00.000Z', '10:00:00.000Z'), created_at: scenario.startTime, updated_at: scenario.startTime, billable_duration: 60, notes: `${scenario.label} update`, user_id: 'user-1', approval_status: 'DRAFT', service_id: 'service-1', }, ); expect(determineDefaultContractLineMock).toHaveBeenCalledWith( null, 'service-1', scenario.expectedEffectiveDate, ); } }); it('rejects save payloads that omit service_id', async () => { const { saveTimeEntryParamsSchema } = await import('../src/actions/timeEntrySchemas'); expect(() => saveTimeEntryParamsSchema.parse({ entry_id: 'entry-1', work_item_id: 'non-billable', work_item_type: 'non_billable_category', start_time: '2026-03-10T09:00:00.000Z', end_time: '2026-03-10T10:00:00.000Z', created_at: '2026-03-10T09:00:00.000Z', updated_at: '2026-03-10T09:00:00.000Z', billable_duration: 0, notes: 'Updated notes', user_id: 'user-1', approval_status: 'DRAFT', tenant: 'tenant-1', })).toThrowError(ZodError); }); it('T012: fetchTimeEntriesForTimeSheet returns entry-level feedback alongside entry data', async () => { const db = createFetchEntriesDbStub(); createTenantKnexMock.mockResolvedValue({ knex: db }); fetchTimeEntryChangeRequestsForEntryIdsFromDbMock.mockResolvedValue( new Map([ [ 'entry-1', [ { change_request_id: 'cr-1', time_entry_id: 'entry-1', time_sheet_id: 'sheet-1', comment: 'Please split out the admin block.', created_at: '2026-03-10T11:00:00.000Z', created_by: 'manager-1', tenant: 'tenant-1', }, ], ], ]), ); const { fetchTimeEntriesForTimeSheet } = await import('../src/actions/timeEntryCrudActions'); const result = await (fetchTimeEntriesForTimeSheet as any)( { user_id: 'viewer-1' }, { tenant: 'tenant-1' }, 'sheet-1', ); expect(result).toHaveLength(1); expect(result[0].latest_change_request?.change_request_id).toBe('cr-1'); expect(result[0].change_request_state).toBe('unresolved'); expect(result[0].change_requests?.[0].comment).toBe('Please split out the admin block.'); }); });