Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 112 additions & 0 deletions __tests__/batch-caller.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
const BatchCaller = require('../lib/batch-caller');
const axios = require('axios');

// Mock dependencies
jest.mock('axios');
jest.mock('../lib/utils', () => ({
...jest.requireActual('../lib/utils'), // Use actual implementation for all except mocked ones
loadPreviousCampaignEndpoints: jest.fn().mockReturnValue(new Set()),
writeProcessedCSV: jest.fn(),
}));
jest.mock('../lib/concurrency-service', () => ({
fetchConcurrency: jest.fn().mockResolvedValue({ active: 0, concurrency: 10 }),
}));


describe('BatchCaller', () => {
let consoleLogSpy;
let consoleErrorSpy;

beforeEach(() => {
// Spy on console
consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});

// Reset mocks
axios.post.mockClear();
require('../lib/utils').loadPreviousCampaignEndpoints.mockClear();
require('../lib/utils').writeProcessedCSV.mockClear();
});

afterEach(() => {
// Restore original implementations
consoleLogSpy.mockRestore();
consoleErrorSpy.mockRestore();
jest.clearAllMocks();
});

it('should perform a dry run and return exit code 0', async () => {
// Arrange
const options = { dryRun: true, apiUrl: 'http://test.com', apiKey: 'test-key', batchSize: 10, delay: 100 };
const batchCaller = new BatchCaller('calls.csv', 'agent-123', options);

// Mock the internal method to simplify test
const mockCalls = [{ endpoint: '+1' }, { endpoint: '+2' }];
const readCallsSpy = jest.spyOn(BatchCaller.prototype, '_readCallsFromCSV').mockResolvedValue({
calls: mockCalls,
allRows: [{ data: { endpoint: '+1' } }, { data: { endpoint: '+2' } }],
});

// Act
const exitCode = await batchCaller.run();

// Assert
expect(exitCode).toBe(0);
expect(readCallsSpy).toHaveBeenCalled();
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Dry run complete. 2 calls validated.'));
expect(axios.post).not.toHaveBeenCalled();

readCallsSpy.mockRestore();
});

it('should process batches and return exit code 0 on a successful run', async () => {
// Arrange
const options = { dryRun: false, apiUrl: 'http://test.com', apiKey: 'test-key', batchSize: 1, delay: 0 };
const batchCaller = new BatchCaller('calls.csv', 'agent-123', options);

const mockCalls = [{ endpoint: '+1' }, { endpoint: '+2' }];
const readCallsSpy = jest.spyOn(BatchCaller.prototype, '_readCallsFromCSV').mockResolvedValue({
calls: mockCalls,
allRows: [{data: {endpoint: '+1'}}, {data: {endpoint: '+2'}}],
});

axios.post.mockResolvedValue({ data: [{ callId: 'call-1' }, { callId: 'call-2' }] });

const saveMetaSpy = jest.spyOn(BatchCaller.prototype, '_saveCampaignMetadata').mockImplementation(() => {});

// Act
const exitCode = await batchCaller.run();

// Assert
expect(exitCode).toBe(0);
expect(readCallsSpy).toHaveBeenCalled();
expect(axios.post).toHaveBeenCalledTimes(2); // 2 calls in batches of 1
expect(saveMetaSpy).toHaveBeenCalled();

readCallsSpy.mockRestore();
saveMetaSpy.mockRestore();
});

it('should return exit code 1 if API calls fail', async () => {
// Arrange
const options = { dryRun: false, apiUrl: 'http://test.com', apiKey: 'test-key', batchSize: 1, delay: 0 };
const batchCaller = new BatchCaller('calls.csv', 'agent-123', options);

const mockCalls = [{ endpoint: '+1' }];
const readCallsSpy = jest.spyOn(BatchCaller.prototype, '_readCallsFromCSV').mockResolvedValue({
calls: mockCalls,
allRows: [{data: {endpoint: '+1'}}],
});

axios.post.mockRejectedValue({ response: { status: 500 } });

// Act
const exitCode = await batchCaller.run();

// Assert
expect(exitCode).toBe(1);
expect(axios.post).toHaveBeenCalledTimes(1);

readCallsSpy.mockRestore();
});
});
2 changes: 1 addition & 1 deletion __tests__/error-handling.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ jest.mock('axios');
const {
getFatalStatusMessage,
computePrimaryApiFailure
} = require('..//blackbox-cli.js');
} = require('../lib/utils.js');

describe('Fatal status messaging', () => {
test('maps 404 to agent not found with agent id', () => {
Expand Down
120 changes: 120 additions & 0 deletions __tests__/utils.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
const fs = require('fs');
const { validatePhoneNumber, parseDeadline, getSystemTimezone, writeProcessedCSV, loadPreviousCampaignEndpoints } = require('../lib/utils.js');

jest.mock('fs');

describe('utils', () => {
beforeEach(() => {
// Clear all instances and calls to constructor and all methods:
fs.writeFileSync.mockClear();
fs.readFileSync.mockClear();
fs.existsSync.mockClear();
fs.readdirSync.mockClear();
});

describe('writeProcessedCSV', () => {
it('should write error messages to the CSV file', () => {
const allRows = [
{ data: { endpoint: '+123', customer: 'A' }, error: 'Invalid number' },
{ data: { endpoint: '+456', customer: 'B' }, error: '' },
];
writeProcessedCSV('test.csv', allRows);
expect(fs.writeFileSync).toHaveBeenCalledTimes(1);
const writtenContent = fs.writeFileSync.mock.calls[0][1];
expect(writtenContent).toContain('"endpoint","customer","error_message"');
expect(writtenContent).toContain('"+123","A","Invalid number"');
expect(writtenContent).toContain('"+456","B",""');
});
});

describe('loadPreviousCampaignEndpoints', () => {
it('should load endpoints from previous campaign files', () => {
const campaign1 = { csvFile: 'test.csv', callMapping: { 'c1': { endpoint: '+111' } } };
const campaign2 = { csvFile: 'another.csv', callMapping: { 'c2': { endpoint: '+222' } } };
const campaign3 = { csvFile: 'test.csv', callMapping: { 'c3': { endpoint: '+333' } } };

fs.existsSync.mockReturnValue(true);
fs.readdirSync.mockReturnValue(['campaign1.json', 'campaign2.json', 'campaign3.json']);
fs.readFileSync
.mockReturnValueOnce(JSON.stringify(campaign1))
.mockReturnValueOnce(JSON.stringify(campaign2))
.mockReturnValueOnce(JSON.stringify(campaign3));

const endpoints = loadPreviousCampaignEndpoints('test.csv');
expect(endpoints.size).toBe(2);
expect(endpoints.has('+111')).toBe(true);
expect(endpoints.has('+333')).toBe(true);
expect(endpoints.has('+222')).toBe(false);
});

it('should return an empty set if the campaigns directory does not exist', () => {
fs.existsSync.mockReturnValue(false);
const endpoints = loadPreviousCampaignEndpoints('test.csv');
expect(endpoints.size).toBe(0);
});
});

describe('validatePhoneNumber', () => {
it('should return the cleaned phone number for valid inputs', () => {
expect(validatePhoneNumber('+1 (555) 123-4567')).toBe('+15551234567');
expect(validatePhoneNumber('+44-20-7123-4567')).toBe('+442071234567');
});

it('should throw an error for numbers not starting with +', () => {
expect(() => validatePhoneNumber('15551234567')).toThrow('Phone number must start with +');
});

it('should throw an error for numbers with invalid characters', () => {
expect(() => validatePhoneNumber('+15551234567a')).toThrow('Phone number must contain only digits after the + sign');
});

it('should throw an error for numbers that are too short or too long', () => {
expect(() => validatePhoneNumber('+123456')).toThrow('Phone number must be between 7 and 15 digits');
expect(() => validatePhoneNumber('+1234567890123456')).toThrow('Phone number must be between 7 and 15 digits');
});
});

describe('parseDeadline', () => {
it('should return an ISO string for a valid date string', () => {
const date = new Date();
date.setHours(date.getHours() + 1);
const isoString = date.toISOString();
// Compare dates without milliseconds for stability
expect(parseDeadline(isoString).substring(0, 19)).toBe(isoString.substring(0, 19));
});

it('should return an ISO string 24 hours in the future if no deadline is provided', () => {
const now = new Date();
const expectedDeadline = new Date(now.getTime() + 24 * 60 * 60 * 1000);
const parsed = new Date(parseDeadline(null));
// Allow a small difference for execution time
expect(parsed.getTime()).toBeCloseTo(expectedDeadline.getTime(), -2);
});

it('should throw an error for an invalid deadline format', () => {
expect(() => parseDeadline('not a date')).toThrow('Invalid deadline format');
});

it('should throw an error for a deadline in the past', () => {
const pastDate = new Date(Date.now() - 1000).toISOString();
expect(() => parseDeadline(pastDate)).toThrow('Deadline is in the past');
});

it('should throw an error for a deadline more than 7 days in the future', () => {
const futureDate = new Date();
futureDate.setDate(futureDate.getDate() + 8);
expect(() => parseDeadline(futureDate.toISOString())).toThrow('Deadline is more than 7 days in future');
});
});

describe('getSystemTimezone', () => {
it('should return a valid timezone string', () => {
const timezone = getSystemTimezone();
expect(typeof timezone).toBe('string');
// A simple check to see if it looks like a timezone
expect(timezone.length).toBeGreaterThan(2);
// Check if it's a valid timezone identifier
expect(() => Intl.DateTimeFormat(undefined, { timeZone: timezone })).not.toThrow();
});
});
});
Loading