[TASK-009] test: Add CRM integration and unit tests

- Add crm-flow.integration.test.ts with complete CRM workflow tests:
  - Lead creation and qualification flow
  - Lead to opportunity conversion
  - Opportunity pipeline stage progression
  - Opportunity won/lost handling
  - Activities and follow-up tracking
  - Edge cases and error handling

- Add activities.service.test.ts with unit tests for:
  - CRUD operations for activities
  - Activity status transitions (scheduled/done/cancelled)
  - Resource linking (leads, opportunities, partners)
  - Activity summary and overdue tracking
  - Follow-up scheduling

- Update helpers.ts with createMockActivity factory

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-01-26 18:51:26 -06:00
parent 23631d3b9b
commit 5ee2023428
3 changed files with 1698 additions and 0 deletions

View File

@ -557,6 +557,35 @@ export function createMockTimesheet(overrides: Record<string, any> = {}) {
};
}
// Activity factory (CRM)
export function createMockActivity(overrides: Record<string, any> = {}) {
return {
id: 'activity-uuid-1',
tenant_id: global.testTenantId,
company_id: 'company-uuid-1',
company_name: 'Test Company',
activity_type: 'call' as const,
name: 'Test Activity',
description: null,
user_id: 'user-uuid-1',
user_name: 'John Doe',
res_model: 'opportunity',
res_id: 'opportunity-uuid-1',
res_name: 'Test Opportunity',
partner_id: 'partner-uuid-1',
partner_name: 'Test Partner',
scheduled_date: new Date(),
date_done: null,
duration_hours: 1,
status: 'scheduled' as const,
priority: 1,
notes: null,
created_at: new Date(),
created_by: 'user-uuid-1',
...overrides,
};
}
// =====================================================
// Core Catalog Factories
// =====================================================

View File

@ -0,0 +1,647 @@
/**
* Activities Service Unit Tests
*
* Tests for CRM activities (calls, meetings, emails, tasks, notes)
*/
import { jest, describe, it, expect, beforeEach } from '@jest/globals';
import { createMockActivity } from '../../../__tests__/helpers.js';
// Mock query functions
const mockQuery = jest.fn();
const mockQueryOne = jest.fn();
const mockGetClient = jest.fn();
jest.mock('../../../config/database.js', () => ({
query: (...args: any[]) => mockQuery(...args),
queryOne: (...args: any[]) => mockQueryOne(...args),
getClient: () => mockGetClient(),
}));
// Mock logger
jest.mock('../../../shared/utils/logger.js', () => ({
logger: {
info: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
debug: jest.fn(),
},
}));
// Import after mocking
import { activitiesService } from '../activities.service.js';
import { NotFoundError, ValidationError } from '../../../shared/errors/index.js';
describe('ActivitiesService', () => {
const tenantId = 'test-tenant-uuid';
const userId = 'test-user-uuid';
const companyId = 'company-uuid-1';
beforeEach(() => {
jest.clearAllMocks();
});
describe('findAll', () => {
it('should return activities with pagination', async () => {
const mockActivities = [
createMockActivity({ id: '1', name: 'Call 1' }),
createMockActivity({ id: '2', name: 'Meeting 1' }),
];
mockQueryOne.mockResolvedValue({ count: '2' });
mockQuery.mockResolvedValue(mockActivities);
const result = await activitiesService.findAll(tenantId, { page: 1, limit: 20 });
expect(result.data).toHaveLength(2);
expect(result.total).toBe(2);
});
it('should filter by activity_type', async () => {
mockQueryOne.mockResolvedValue({ count: '0' });
mockQuery.mockResolvedValue([]);
await activitiesService.findAll(tenantId, { activity_type: 'call' });
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('a.activity_type = $'),
expect.arrayContaining([tenantId, 'call'])
);
});
it('should filter by status', async () => {
mockQueryOne.mockResolvedValue({ count: '0' });
mockQuery.mockResolvedValue([]);
await activitiesService.findAll(tenantId, { status: 'scheduled' });
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('a.status = $'),
expect.arrayContaining([tenantId, 'scheduled'])
);
});
it('should filter by res_model and res_id', async () => {
mockQueryOne.mockResolvedValue({ count: '0' });
mockQuery.mockResolvedValue([]);
await activitiesService.findAll(tenantId, { res_model: 'opportunity', res_id: 'opp-uuid-1' });
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('a.res_model = $'),
expect.arrayContaining([tenantId, 'opportunity', 'opp-uuid-1'])
);
});
it('should filter by date range', async () => {
mockQueryOne.mockResolvedValue({ count: '0' });
mockQuery.mockResolvedValue([]);
await activitiesService.findAll(tenantId, {
date_from: '2026-01-01',
date_to: '2026-01-31',
});
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('a.scheduled_date >= $'),
expect.any(Array)
);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('a.scheduled_date <= $'),
expect.any(Array)
);
});
it('should filter by search term', async () => {
mockQueryOne.mockResolvedValue({ count: '0' });
mockQuery.mockResolvedValue([]);
await activitiesService.findAll(tenantId, { search: 'demo' });
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('a.name ILIKE'),
expect.arrayContaining([tenantId, '%demo%'])
);
});
});
describe('findById', () => {
it('should return activity when found', async () => {
const mockActivity = createMockActivity();
mockQueryOne.mockResolvedValue(mockActivity);
const result = await activitiesService.findById('activity-uuid-1', tenantId);
expect(result).toEqual(mockActivity);
});
it('should throw NotFoundError when activity not found', async () => {
mockQueryOne.mockResolvedValue(null);
await expect(
activitiesService.findById('nonexistent-id', tenantId)
).rejects.toThrow(NotFoundError);
});
it('should fetch resource name for linked activity', async () => {
const mockActivity = createMockActivity({
res_model: 'opportunity',
res_id: 'opp-uuid-1',
});
mockQueryOne
.mockResolvedValueOnce(mockActivity) // findById
.mockResolvedValueOnce({ name: 'Big Deal Opportunity' }); // getResourceName
const result = await activitiesService.findById('activity-uuid-1', tenantId);
expect(result.res_name).toBe('Big Deal Opportunity');
});
});
describe('create', () => {
const createDto = {
company_id: companyId,
activity_type: 'call' as const,
name: 'Initial call with prospect',
res_model: 'lead',
res_id: 'lead-uuid-1',
user_id: userId,
};
it('should create activity successfully', async () => {
const createdActivity = createMockActivity({ ...createDto });
mockQueryOne
.mockResolvedValueOnce(createdActivity) // INSERT
.mockResolvedValueOnce(createdActivity); // findById
mockQuery.mockResolvedValueOnce([]); // Update last activity date
const result = await activitiesService.create(createDto, tenantId, userId);
expect(result.name).toBe(createDto.name);
expect(result.activity_type).toBe('call');
});
it('should create activity with scheduled date', async () => {
const scheduledDate = new Date().toISOString();
const dto = { ...createDto, scheduled_date: scheduledDate };
const createdActivity = createMockActivity({ ...dto });
mockQueryOne
.mockResolvedValueOnce(createdActivity)
.mockResolvedValueOnce(createdActivity);
mockQuery.mockResolvedValueOnce([]);
await activitiesService.create(dto, tenantId, userId);
expect(mockQueryOne).toHaveBeenCalledWith(
expect.stringContaining('INSERT INTO crm.activities'),
expect.arrayContaining([scheduledDate])
);
});
it('should update last activity date on linked resource', async () => {
const dto = {
...createDto,
res_model: 'opportunity',
res_id: 'opp-uuid-1',
};
const createdActivity = createMockActivity(dto);
mockQueryOne
.mockResolvedValueOnce(createdActivity)
.mockResolvedValueOnce(createdActivity);
mockQuery.mockResolvedValueOnce([]);
await activitiesService.create(dto, tenantId, userId);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('UPDATE crm.opportunities SET date_last_activity'),
expect.arrayContaining(['opp-uuid-1', tenantId])
);
});
});
describe('update', () => {
it('should update activity successfully', async () => {
const existingActivity = createMockActivity({ status: 'scheduled' });
mockQueryOne.mockResolvedValue(existingActivity);
mockQuery.mockResolvedValue([]);
await activitiesService.update(
'activity-uuid-1',
{ name: 'Updated Activity Name' },
tenantId,
userId
);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('UPDATE crm.activities SET'),
expect.any(Array)
);
});
it('should throw ValidationError when activity is done', async () => {
const doneActivity = createMockActivity({ status: 'done' });
mockQueryOne.mockResolvedValue(doneActivity);
await expect(
activitiesService.update('activity-uuid-1', { name: 'New name' }, tenantId, userId)
).rejects.toThrow(ValidationError);
});
it('should return existing activity when no fields to update', async () => {
const existingActivity = createMockActivity({ status: 'scheduled' });
mockQueryOne.mockResolvedValue(existingActivity);
const result = await activitiesService.update('activity-uuid-1', {}, tenantId, userId);
expect(result).toEqual(existingActivity);
expect(mockQuery).not.toHaveBeenCalled();
});
});
describe('markDone', () => {
it('should mark activity as done', async () => {
const scheduledActivity = createMockActivity({ status: 'scheduled', res_model: null, res_id: null });
const doneActivity = createMockActivity({ ...scheduledActivity, status: 'done' });
mockQueryOne
.mockResolvedValueOnce(scheduledActivity) // findById before update
.mockResolvedValueOnce(doneActivity); // findById after update
mockQuery.mockResolvedValueOnce([]); // UPDATE activity
await activitiesService.markDone('activity-uuid-1', tenantId, userId);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining("status = 'done'"),
expect.any(Array)
);
});
it('should add notes when marking as done', async () => {
const scheduledActivity = createMockActivity({ status: 'scheduled' });
mockQueryOne.mockResolvedValue(scheduledActivity);
mockQuery.mockResolvedValueOnce([]);
mockQuery.mockResolvedValueOnce([]);
await activitiesService.markDone(
'activity-uuid-1',
tenantId,
userId,
'Great conversation about the product'
);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('notes = COALESCE($1, notes)'),
expect.arrayContaining(['Great conversation about the product'])
);
});
it('should throw ValidationError when activity is already done', async () => {
const doneActivity = createMockActivity({ status: 'done' });
mockQueryOne.mockResolvedValue(doneActivity);
await expect(
activitiesService.markDone('activity-uuid-1', tenantId, userId)
).rejects.toThrow(ValidationError);
});
it('should throw ValidationError when activity is cancelled', async () => {
const cancelledActivity = createMockActivity({ status: 'cancelled' });
mockQueryOne.mockResolvedValue(cancelledActivity);
await expect(
activitiesService.markDone('activity-uuid-1', tenantId, userId)
).rejects.toThrow(ValidationError);
});
});
describe('cancel', () => {
it('should cancel scheduled activity', async () => {
const scheduledActivity = createMockActivity({ status: 'scheduled', res_model: null, res_id: null });
const cancelledActivity = createMockActivity({ ...scheduledActivity, status: 'cancelled' });
mockQueryOne
.mockResolvedValueOnce(scheduledActivity) // findById before cancel
.mockResolvedValueOnce(cancelledActivity); // findById after cancel
mockQuery.mockResolvedValueOnce([]);
await activitiesService.cancel('activity-uuid-1', tenantId, userId);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining("status = 'cancelled'"),
expect.any(Array)
);
});
it('should throw ValidationError when activity is done', async () => {
const doneActivity = createMockActivity({ status: 'done' });
mockQueryOne.mockResolvedValue(doneActivity);
await expect(
activitiesService.cancel('activity-uuid-1', tenantId, userId)
).rejects.toThrow(ValidationError);
});
it('should throw ValidationError when activity is already cancelled', async () => {
const cancelledActivity = createMockActivity({ status: 'cancelled' });
mockQueryOne.mockResolvedValue(cancelledActivity);
await expect(
activitiesService.cancel('activity-uuid-1', tenantId, userId)
).rejects.toThrow(ValidationError);
});
});
describe('delete', () => {
it('should delete scheduled activity', async () => {
const scheduledActivity = createMockActivity({ status: 'scheduled' });
mockQueryOne.mockResolvedValue(scheduledActivity);
mockQuery.mockResolvedValue([]);
await activitiesService.delete('activity-uuid-1', tenantId);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('DELETE FROM crm.activities'),
expect.any(Array)
);
});
it('should throw ValidationError when trying to delete done activity', async () => {
const doneActivity = createMockActivity({ status: 'done' });
mockQueryOne.mockResolvedValue(doneActivity);
await expect(
activitiesService.delete('activity-uuid-1', tenantId)
).rejects.toThrow(ValidationError);
});
});
describe('getResourceActivities', () => {
it('should return activities for opportunity', async () => {
const activities = [
createMockActivity({ id: '1', name: 'Call 1', activity_type: 'call' }),
createMockActivity({ id: '2', name: 'Meeting 1', activity_type: 'meeting' }),
];
mockQuery.mockResolvedValueOnce(activities);
const result = await activitiesService.getResourceActivities(
'opportunity',
'opp-uuid-1',
tenantId
);
expect(result).toHaveLength(2);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('a.res_model = $1 AND a.res_id = $2'),
expect.arrayContaining(['opportunity', 'opp-uuid-1', tenantId])
);
});
it('should filter by status when provided', async () => {
mockQuery.mockResolvedValue([]);
await activitiesService.getResourceActivities(
'lead',
'lead-uuid-1',
tenantId,
'scheduled'
);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('a.status = $4'),
expect.arrayContaining(['lead', 'lead-uuid-1', tenantId, 'scheduled'])
);
});
});
describe('getActivitySummary', () => {
it('should return activity summary', async () => {
mockQueryOne.mockResolvedValue({
total: '15',
scheduled: '8',
done: '5',
cancelled: '2',
overdue: '3',
});
mockQuery.mockResolvedValue([
{ activity_type: 'call', count: '6' },
{ activity_type: 'meeting', count: '4' },
{ activity_type: 'email', count: '3' },
{ activity_type: 'task', count: '2' },
]);
const result = await activitiesService.getActivitySummary(tenantId);
expect(result.total_activities).toBe(15);
expect(result.scheduled).toBe(8);
expect(result.done).toBe(5);
expect(result.overdue).toBe(3);
expect(result.by_type.call).toBe(6);
expect(result.by_type.meeting).toBe(4);
});
it('should filter by user when provided', async () => {
mockQueryOne.mockResolvedValue({
total: '5',
scheduled: '3',
done: '2',
cancelled: '0',
overdue: '1',
});
mockQuery.mockResolvedValue([]);
await activitiesService.getActivitySummary(tenantId, userId);
expect(mockQueryOne).toHaveBeenCalledWith(
expect.stringContaining('user_id = $'),
expect.arrayContaining([tenantId, userId])
);
});
it('should filter by date range', async () => {
mockQueryOne.mockResolvedValue({
total: '10',
scheduled: '5',
done: '5',
cancelled: '0',
overdue: '0',
});
mockQuery.mockResolvedValue([]);
await activitiesService.getActivitySummary(
tenantId,
undefined,
'2026-01-01',
'2026-01-31'
);
expect(mockQueryOne).toHaveBeenCalledWith(
expect.stringContaining('scheduled_date >= $'),
expect.any(Array)
);
});
});
describe('scheduleFollowUp', () => {
it('should schedule follow-up inheriting resource info', async () => {
// No res_model on completed activity to avoid getResourceName call
const completedActivity = createMockActivity({
id: 'completed-uuid',
status: 'done',
res_model: null,
res_id: null,
partner_id: 'partner-uuid-1',
});
const followUpDto = {
company_id: companyId,
activity_type: 'meeting' as const,
name: 'Follow-up meeting',
scheduled_date: new Date().toISOString(),
res_model: 'opportunity',
res_id: 'opp-uuid-1',
};
const newActivity = createMockActivity({
activity_type: 'meeting',
name: 'Follow-up meeting',
res_model: 'opportunity',
res_id: 'opp-uuid-1',
partner_id: 'partner-uuid-1',
});
// Mock sequence:
// 1. findById(completedActivity) - no res_model, so no getResourceName
// 2. create:
// - INSERT returns newActivity
// - findById(newActivity) - has res_model, so calls getResourceName
mockQueryOne
.mockResolvedValueOnce(completedActivity) // findById for completed (no getResourceName since res_model is null)
.mockResolvedValueOnce(newActivity) // INSERT new activity
.mockResolvedValueOnce(newActivity) // findById for new activity
.mockResolvedValueOnce({ name: 'Test Opportunity' }); // getResourceName for new activity
mockQuery.mockResolvedValueOnce([]); // Update last activity date
const result = await activitiesService.scheduleFollowUp(
'completed-uuid',
followUpDto,
tenantId,
userId
);
expect(result.res_model).toBe('opportunity');
expect(result.res_id).toBe('opp-uuid-1');
expect(result.partner_id).toBe('partner-uuid-1');
});
it('should allow overriding resource info in follow-up', async () => {
// No res_model to simplify mock sequence
const completedActivity = createMockActivity({
id: 'completed-uuid',
res_model: null,
res_id: null,
});
const followUpDto = {
company_id: companyId,
activity_type: 'call' as const,
name: 'New opportunity call',
res_model: 'opportunity',
res_id: 'new-opp-uuid',
};
const newActivity = createMockActivity({
activity_type: 'call',
name: 'New opportunity call',
res_model: 'opportunity',
res_id: 'new-opp-uuid',
});
mockQueryOne
.mockResolvedValueOnce(completedActivity) // findById completed (no getResourceName)
.mockResolvedValueOnce(newActivity) // INSERT
.mockResolvedValueOnce(newActivity) // findById new
.mockResolvedValueOnce({ name: 'New Opportunity' }); // getResourceName
mockQuery.mockResolvedValueOnce([]); // Update last activity
const result = await activitiesService.scheduleFollowUp(
'completed-uuid',
followUpDto,
tenantId,
userId
);
expect(result.res_model).toBe('opportunity');
expect(result.res_id).toBe('new-opp-uuid');
});
});
describe('getOverdueCount', () => {
it('should return count of overdue activities', async () => {
mockQueryOne.mockResolvedValueOnce({ count: '7' });
const result = await activitiesService.getOverdueCount(tenantId);
expect(result).toBe(7);
expect(mockQueryOne).toHaveBeenCalledWith(
expect.stringContaining("status = 'scheduled'"),
expect.arrayContaining([tenantId])
);
expect(mockQueryOne).toHaveBeenCalledWith(
expect.stringContaining('scheduled_date < CURRENT_DATE'),
expect.any(Array)
);
});
it('should filter by user when provided', async () => {
mockQueryOne.mockResolvedValueOnce({ count: '3' });
await activitiesService.getOverdueCount(tenantId, userId);
expect(mockQueryOne).toHaveBeenCalledWith(
expect.stringContaining('user_id = $2'),
expect.arrayContaining([tenantId, userId])
);
});
});
describe('Activity Types', () => {
it.each([
['call', 'Initial sales call'],
['meeting', 'Product demo'],
['email', 'Follow-up email'],
['task', 'Prepare proposal'],
['note', 'Customer feedback'],
['other', 'Site visit'],
])('should create %s activity', async (activityType, name) => {
// Clear mocks before each iteration
jest.clearAllMocks();
const dto = {
company_id: companyId,
activity_type: activityType as any,
name,
res_model: null,
res_id: null,
};
const createdActivity = createMockActivity({
...dto,
activity_type: activityType as any,
name,
});
mockQueryOne
.mockResolvedValueOnce(createdActivity) // INSERT
.mockResolvedValueOnce(createdActivity); // findById (no getResourceName since res_model is null)
const result = await activitiesService.create(dto, tenantId, userId);
expect(result.activity_type).toBe(activityType);
expect(result.name).toBe(name);
});
});
});

File diff suppressed because it is too large Load Diff