Initial commit: Telegram Management System
Some checks failed
Deploy / deploy (push) Has been cancelled
Some checks failed
Deploy / deploy (push) Has been cancelled
Full-stack web application for Telegram management - Frontend: Vue 3 + Vben Admin - Backend: NestJS - Features: User management, group broadcast, statistics 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
126
marketing-agent/tests/README.md
Normal file
126
marketing-agent/tests/README.md
Normal file
@@ -0,0 +1,126 @@
|
||||
# Testing Guide
|
||||
|
||||
This directory contains all tests for the Telegram Marketing Agent System.
|
||||
|
||||
## Test Structure
|
||||
|
||||
```
|
||||
tests/
|
||||
├── unit/ # Unit tests for individual components
|
||||
│ ├── services/ # Service-specific unit tests
|
||||
│ ├── utils/ # Utility function tests
|
||||
│ └── middleware/ # Middleware tests
|
||||
├── integration/ # Integration tests
|
||||
│ ├── api/ # API endpoint tests
|
||||
│ ├── services/ # Service integration tests
|
||||
│ └── workflows/ # End-to-end workflow tests
|
||||
├── e2e/ # End-to-end tests
|
||||
│ ├── campaigns/ # Campaign workflow tests
|
||||
│ └── users/ # User management tests
|
||||
├── fixtures/ # Test data and mocks
|
||||
├── helpers/ # Test utilities
|
||||
└── config/ # Test configuration
|
||||
```
|
||||
|
||||
## Running Tests
|
||||
|
||||
### All Tests
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
|
||||
### Unit Tests Only
|
||||
```bash
|
||||
npm run test:unit
|
||||
```
|
||||
|
||||
### Integration Tests Only
|
||||
```bash
|
||||
npm run test:integration
|
||||
```
|
||||
|
||||
### End-to-End Tests
|
||||
```bash
|
||||
npm run test:e2e
|
||||
```
|
||||
|
||||
### Watch Mode
|
||||
```bash
|
||||
npm run test:watch
|
||||
```
|
||||
|
||||
### Coverage Report
|
||||
```bash
|
||||
npm run test:coverage
|
||||
```
|
||||
|
||||
## Test Framework
|
||||
|
||||
We use the following testing stack:
|
||||
- **Jest**: Main testing framework
|
||||
- **Supertest**: API endpoint testing
|
||||
- **MongoDB Memory Server**: In-memory MongoDB for tests
|
||||
- **Redis Mock**: Redis mocking for tests
|
||||
- **Sinon**: Mocking and stubbing
|
||||
- **Faker**: Test data generation
|
||||
|
||||
## Writing Tests
|
||||
|
||||
### Unit Test Example
|
||||
```javascript
|
||||
describe('CampaignService', () => {
|
||||
describe('createCampaign', () => {
|
||||
it('should create a new campaign', async () => {
|
||||
const campaignData = {
|
||||
name: 'Test Campaign',
|
||||
type: 'message'
|
||||
};
|
||||
|
||||
const result = await campaignService.createCampaign(campaignData);
|
||||
|
||||
expect(result).toHaveProperty('id');
|
||||
expect(result.name).toBe(campaignData.name);
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Integration Test Example
|
||||
```javascript
|
||||
describe('POST /api/v1/campaigns', () => {
|
||||
it('should create a campaign', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/v1/campaigns')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({
|
||||
name: 'Test Campaign',
|
||||
type: 'message'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body.success).toBe(true);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Test Coverage Goals
|
||||
|
||||
- **Unit Tests**: >80% coverage
|
||||
- **Integration Tests**: All critical paths
|
||||
- **E2E Tests**: Main user workflows
|
||||
|
||||
## CI/CD Integration
|
||||
|
||||
Tests are automatically run on:
|
||||
- Pull requests
|
||||
- Commits to main branch
|
||||
- Before deployment
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Isolation**: Each test should be independent
|
||||
2. **Cleanup**: Always clean up test data
|
||||
3. **Mocking**: Mock external dependencies
|
||||
4. **Descriptive**: Use clear test descriptions
|
||||
5. **Fast**: Keep tests fast and focused
|
||||
6. **Deterministic**: Tests should always produce same results
|
||||
372
marketing-agent/tests/e2e/campaigns/campaignWorkflow.test.js
Normal file
372
marketing-agent/tests/e2e/campaigns/campaignWorkflow.test.js
Normal file
@@ -0,0 +1,372 @@
|
||||
import request from 'supertest';
|
||||
import app from '../../../services/api-gateway/src/app.js';
|
||||
import { connectDatabase, closeDatabase, clearDatabase } from '../../helpers/database.js';
|
||||
import { createCampaign, createTelegramUser, createTemplate } from '../../helpers/factories.js';
|
||||
|
||||
describe('Campaign Workflow E2E Tests', () => {
|
||||
let authToken;
|
||||
let userId;
|
||||
|
||||
beforeAll(async () => {
|
||||
await connectDatabase();
|
||||
|
||||
// Register and login
|
||||
const registerResponse = await request(app)
|
||||
.post('/api/v1/auth/register')
|
||||
.send({
|
||||
username: 'testuser',
|
||||
email: 'test@example.com',
|
||||
password: 'SecurePass123!',
|
||||
fullName: 'Test User'
|
||||
});
|
||||
|
||||
const loginResponse = await request(app)
|
||||
.post('/api/v1/auth/login')
|
||||
.send({
|
||||
username: 'testuser',
|
||||
password: 'SecurePass123!'
|
||||
});
|
||||
|
||||
authToken = loginResponse.body.data.accessToken;
|
||||
userId = loginResponse.body.data.user.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await clearDatabase();
|
||||
await closeDatabase();
|
||||
});
|
||||
|
||||
describe('Complete Campaign Lifecycle', () => {
|
||||
let campaignId;
|
||||
let templateId;
|
||||
let segmentId;
|
||||
let telegramUsers = [];
|
||||
|
||||
it('Step 1: Create message template', async () => {
|
||||
const templateData = createTemplate({
|
||||
name: 'Welcome Message',
|
||||
category: 'onboarding',
|
||||
content: {
|
||||
en: 'Welcome {{firstName}}! Thanks for joining our community.',
|
||||
es: '¡Bienvenido {{firstName}}! Gracias por unirte a nuestra comunidad.'
|
||||
}
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/v1/templates')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send(templateData);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body.success).toBe(true);
|
||||
templateId = response.body.data.template.id;
|
||||
});
|
||||
|
||||
it('Step 2: Import users', async () => {
|
||||
// Create users via API
|
||||
const usersData = [
|
||||
createTelegramUser({ tags: ['new', 'premium'] }),
|
||||
createTelegramUser({ tags: ['new', 'free'] }),
|
||||
createTelegramUser({ tags: ['existing', 'premium'] }),
|
||||
createTelegramUser({ tags: ['new', 'premium'] }),
|
||||
createTelegramUser({ tags: ['new', 'free'] })
|
||||
];
|
||||
|
||||
for (const userData of usersData) {
|
||||
const response = await request(app)
|
||||
.post('/api/v1/users')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send(userData);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
telegramUsers.push(response.body.data.user);
|
||||
}
|
||||
|
||||
expect(telegramUsers.length).toBe(5);
|
||||
});
|
||||
|
||||
it('Step 3: Create user segment', async () => {
|
||||
const segmentData = {
|
||||
name: 'New Premium Users',
|
||||
description: 'Users who are new and have premium subscription',
|
||||
criteria: [
|
||||
{
|
||||
field: 'tags',
|
||||
operator: 'contains',
|
||||
value: 'new',
|
||||
logic: 'AND'
|
||||
},
|
||||
{
|
||||
field: 'tags',
|
||||
operator: 'contains',
|
||||
value: 'premium',
|
||||
logic: 'AND'
|
||||
}
|
||||
],
|
||||
isDynamic: true
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/v1/segments')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send(segmentData);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body.success).toBe(true);
|
||||
segmentId = response.body.data.segment.id;
|
||||
|
||||
// Verify segment users
|
||||
const segmentUsersResponse = await request(app)
|
||||
.get(`/api/v1/segments/${segmentId}/users`)
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(segmentUsersResponse.status).toBe(200);
|
||||
expect(segmentUsersResponse.body.data.users.length).toBe(2); // Should match 2 users
|
||||
});
|
||||
|
||||
it('Step 4: Create campaign targeting segment', async () => {
|
||||
const campaignData = {
|
||||
name: 'Welcome Campaign for Premium Users',
|
||||
description: 'Onboarding campaign for new premium subscribers',
|
||||
type: 'message',
|
||||
content: {
|
||||
messageTemplateId: templateId
|
||||
},
|
||||
targeting: {
|
||||
segments: [segmentId]
|
||||
},
|
||||
settings: {
|
||||
rateLimit: {
|
||||
messagesPerSecond: 5,
|
||||
messagesPerUser: 1
|
||||
}
|
||||
},
|
||||
goals: {
|
||||
targetAudience: 2,
|
||||
conversionRate: 20,
|
||||
revenue: 1000
|
||||
}
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/v1/orchestrator/campaigns')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send(campaignData);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body.success).toBe(true);
|
||||
campaignId = response.body.data.campaign.id;
|
||||
});
|
||||
|
||||
it('Step 5: Test campaign execution', async () => {
|
||||
// First, test with a single user
|
||||
const testResponse = await request(app)
|
||||
.post(`/api/v1/orchestrator/campaigns/${campaignId}/execute`)
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({
|
||||
test: true,
|
||||
testUsers: [telegramUsers[0].telegramId]
|
||||
});
|
||||
|
||||
expect(testResponse.status).toBe(200);
|
||||
expect(testResponse.body.success).toBe(true);
|
||||
expect(testResponse.body.data.status).toBe('running');
|
||||
});
|
||||
|
||||
it('Step 6: Activate and execute campaign', async () => {
|
||||
// Update campaign status to active
|
||||
const activateResponse = await request(app)
|
||||
.put(`/api/v1/orchestrator/campaigns/${campaignId}`)
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({ status: 'active' });
|
||||
|
||||
expect(activateResponse.status).toBe(200);
|
||||
expect(activateResponse.body.data.campaign.status).toBe('active');
|
||||
|
||||
// Execute campaign
|
||||
const executeResponse = await request(app)
|
||||
.post(`/api/v1/orchestrator/campaigns/${campaignId}/execute`)
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({ test: false });
|
||||
|
||||
expect(executeResponse.status).toBe(200);
|
||||
expect(executeResponse.body.success).toBe(true);
|
||||
expect(executeResponse.body.data).toHaveProperty('executionId');
|
||||
});
|
||||
|
||||
it('Step 7: Monitor campaign execution', async () => {
|
||||
// Get execution history
|
||||
const executionsResponse = await request(app)
|
||||
.get(`/api/v1/orchestrator/campaigns/${campaignId}/executions`)
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(executionsResponse.status).toBe(200);
|
||||
expect(executionsResponse.body.data.executions.length).toBeGreaterThan(0);
|
||||
|
||||
const latestExecution = executionsResponse.body.data.executions[0];
|
||||
expect(latestExecution).toHaveProperty('progress');
|
||||
expect(latestExecution.progress.total).toBe(2); // Should target 2 users from segment
|
||||
});
|
||||
|
||||
it('Step 8: View campaign statistics', async () => {
|
||||
// Wait a bit for execution to complete
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
|
||||
const statsResponse = await request(app)
|
||||
.get(`/api/v1/orchestrator/campaigns/${campaignId}/statistics`)
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(statsResponse.status).toBe(200);
|
||||
expect(statsResponse.body.success).toBe(true);
|
||||
expect(statsResponse.body.data.statistics.overview).toHaveProperty('messagesSent');
|
||||
expect(statsResponse.body.data.statistics.overview.messagesSent).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('Step 9: Create scheduled follow-up campaign', async () => {
|
||||
// Create follow-up campaign
|
||||
const followUpCampaign = {
|
||||
name: 'Premium User Engagement',
|
||||
description: 'Weekly engagement for premium users',
|
||||
type: 'message',
|
||||
content: {
|
||||
customMessage: 'Hi {{firstName}}, here are this week\'s premium features!'
|
||||
},
|
||||
targeting: {
|
||||
segments: [segmentId]
|
||||
},
|
||||
status: 'active'
|
||||
};
|
||||
|
||||
const campaignResponse = await request(app)
|
||||
.post('/api/v1/orchestrator/campaigns')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send(followUpCampaign);
|
||||
|
||||
const followUpId = campaignResponse.body.data.campaign.id;
|
||||
|
||||
// Schedule it
|
||||
const scheduleData = {
|
||||
campaignId: followUpId,
|
||||
name: 'Weekly Premium Engagement',
|
||||
type: 'recurring',
|
||||
schedule: {
|
||||
startDateTime: new Date(Date.now() + 86400000).toISOString(), // Start tomorrow
|
||||
recurring: {
|
||||
pattern: 'weekly',
|
||||
frequency: {
|
||||
interval: 1,
|
||||
unit: 'week'
|
||||
},
|
||||
daysOfWeek: [1], // Monday
|
||||
time: '10:00',
|
||||
timezone: 'America/New_York'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleResponse = await request(app)
|
||||
.post('/api/v1/scheduler/scheduled-campaigns')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send(scheduleData);
|
||||
|
||||
expect(scheduleResponse.status).toBe(201);
|
||||
expect(scheduleResponse.body.success).toBe(true);
|
||||
expect(scheduleResponse.body.data.schedule.type).toBe('recurring');
|
||||
});
|
||||
|
||||
it('Step 10: Setup webhook for campaign events', async () => {
|
||||
const webhookData = {
|
||||
name: 'Campaign Event Notifier',
|
||||
url: 'https://example.com/webhooks/campaigns',
|
||||
events: ['campaign.completed', 'campaign.failed', 'user.converted'],
|
||||
headers: {
|
||||
'X-Webhook-Secret': 'test-secret-key'
|
||||
},
|
||||
isActive: true
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/v1/webhooks')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send(webhookData);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.data.webhook.events).toContain('campaign.completed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('A/B Testing Workflow', () => {
|
||||
let campaignId;
|
||||
let abTestId;
|
||||
|
||||
it('Should create campaign with A/B test', async () => {
|
||||
// Create base campaign
|
||||
const campaignData = createCampaign({
|
||||
name: 'A/B Test Campaign',
|
||||
status: 'active'
|
||||
});
|
||||
|
||||
const campaignResponse = await request(app)
|
||||
.post('/api/v1/orchestrator/campaigns')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send(campaignData);
|
||||
|
||||
campaignId = campaignResponse.body.data.campaign.id;
|
||||
|
||||
// Create A/B test
|
||||
const abTestData = {
|
||||
name: 'Subject Line Test',
|
||||
campaignId: campaignId,
|
||||
variants: [
|
||||
{
|
||||
name: 'Control',
|
||||
weight: 50,
|
||||
content: {
|
||||
customMessage: '🎉 Special offer just for you!'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Variant A',
|
||||
weight: 50,
|
||||
content: {
|
||||
customMessage: '💰 Save 50% today only!'
|
||||
}
|
||||
}
|
||||
],
|
||||
metrics: ['open_rate', 'click_rate', 'conversion_rate'],
|
||||
sampleSize: 20 // 20% of audience
|
||||
};
|
||||
|
||||
const abTestResponse = await request(app)
|
||||
.post('/api/v1/ab-testing/experiments')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send(abTestData);
|
||||
|
||||
expect(abTestResponse.status).toBe(201);
|
||||
abTestId = abTestResponse.body.data.experiment.id;
|
||||
|
||||
// Start A/B test
|
||||
const startResponse = await request(app)
|
||||
.post(`/api/v1/ab-testing/experiments/${abTestId}/start`)
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(startResponse.status).toBe(200);
|
||||
expect(startResponse.body.success).toBe(true);
|
||||
});
|
||||
|
||||
it('Should analyze A/B test results', async () => {
|
||||
// Wait for some data
|
||||
await new Promise(resolve => setTimeout(resolve, 3000));
|
||||
|
||||
const resultsResponse = await request(app)
|
||||
.get(`/api/v1/ab-testing/experiments/${abTestId}/results`)
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(resultsResponse.status).toBe(200);
|
||||
expect(resultsResponse.body.data.results).toHaveProperty('variants');
|
||||
expect(resultsResponse.body.data.results).toHaveProperty('winner');
|
||||
expect(resultsResponse.body.data.results).toHaveProperty('confidence');
|
||||
});
|
||||
});
|
||||
});
|
||||
35
marketing-agent/tests/helpers/database.js
Normal file
35
marketing-agent/tests/helpers/database.js
Normal file
@@ -0,0 +1,35 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { MongoMemoryServer } from 'mongodb-memory-server';
|
||||
|
||||
let mongoServer;
|
||||
|
||||
export const connectDatabase = async () => {
|
||||
mongoServer = await MongoMemoryServer.create();
|
||||
const uri = mongoServer.getUri();
|
||||
|
||||
await mongoose.connect(uri, {
|
||||
useNewUrlParser: true,
|
||||
useUnifiedTopology: true,
|
||||
});
|
||||
};
|
||||
|
||||
export const closeDatabase = async () => {
|
||||
await mongoose.connection.dropDatabase();
|
||||
await mongoose.connection.close();
|
||||
if (mongoServer) {
|
||||
await mongoServer.stop();
|
||||
}
|
||||
};
|
||||
|
||||
export const clearDatabase = async () => {
|
||||
const collections = mongoose.connection.collections;
|
||||
|
||||
for (const key in collections) {
|
||||
const collection = collections[key];
|
||||
await collection.deleteMany();
|
||||
}
|
||||
};
|
||||
|
||||
export const seedDatabase = async (model, data) => {
|
||||
return await model.create(data);
|
||||
};
|
||||
202
marketing-agent/tests/helpers/factories.js
Normal file
202
marketing-agent/tests/helpers/factories.js
Normal file
@@ -0,0 +1,202 @@
|
||||
import { faker } from '@faker-js/faker';
|
||||
|
||||
// User factory
|
||||
export const createUser = (overrides = {}) => ({
|
||||
username: faker.internet.userName(),
|
||||
email: faker.internet.email(),
|
||||
password: 'Test123!@#',
|
||||
role: 'user',
|
||||
isActive: true,
|
||||
...overrides
|
||||
});
|
||||
|
||||
// Telegram user factory
|
||||
export const createTelegramUser = (overrides = {}) => ({
|
||||
telegramId: faker.string.numeric(10),
|
||||
username: faker.internet.userName(),
|
||||
firstName: faker.person.firstName(),
|
||||
lastName: faker.person.lastName(),
|
||||
phoneNumber: faker.phone.number('+1##########'),
|
||||
languageCode: faker.helpers.arrayElement(['en', 'es', 'fr', 'de']),
|
||||
status: 'active',
|
||||
tags: [],
|
||||
groups: [],
|
||||
customFields: {},
|
||||
engagement: {
|
||||
lastActivity: faker.date.recent(),
|
||||
messagesSent: faker.number.int({ min: 0, max: 100 }),
|
||||
messagesReceived: faker.number.int({ min: 0, max: 100 }),
|
||||
conversions: faker.number.int({ min: 0, max: 10 })
|
||||
},
|
||||
...overrides
|
||||
});
|
||||
|
||||
// Campaign factory
|
||||
export const createCampaign = (overrides = {}) => ({
|
||||
name: faker.lorem.words(3),
|
||||
description: faker.lorem.sentence(),
|
||||
type: faker.helpers.arrayElement(['message', 'invitation', 'data_collection', 'engagement', 'custom']),
|
||||
status: 'draft',
|
||||
content: {
|
||||
customMessage: faker.lorem.paragraph(),
|
||||
media: []
|
||||
},
|
||||
targeting: {
|
||||
includedUsers: [],
|
||||
excludedUsers: [],
|
||||
segments: [],
|
||||
groups: [],
|
||||
tags: [],
|
||||
filters: {}
|
||||
},
|
||||
settings: {
|
||||
rateLimit: {
|
||||
messagesPerSecond: 10,
|
||||
messagesPerUser: 1
|
||||
}
|
||||
},
|
||||
goals: {
|
||||
targetAudience: faker.number.int({ min: 100, max: 10000 }),
|
||||
conversionRate: faker.number.float({ min: 1, max: 30, precision: 0.1 }),
|
||||
revenue: faker.number.int({ min: 1000, max: 100000 })
|
||||
},
|
||||
statistics: {
|
||||
messagesSent: 0,
|
||||
delivered: 0,
|
||||
read: 0,
|
||||
clicked: 0,
|
||||
conversions: 0,
|
||||
revenue: 0
|
||||
},
|
||||
...overrides
|
||||
});
|
||||
|
||||
// Scheduled campaign factory
|
||||
export const createScheduledCampaign = (campaignId, overrides = {}) => ({
|
||||
campaignId,
|
||||
name: faker.lorem.words(3),
|
||||
description: faker.lorem.sentence(),
|
||||
type: faker.helpers.arrayElement(['one-time', 'recurring', 'trigger-based']),
|
||||
status: 'active',
|
||||
schedule: {
|
||||
startDateTime: faker.date.future(),
|
||||
recurring: {
|
||||
pattern: 'daily',
|
||||
frequency: {
|
||||
interval: 1,
|
||||
unit: 'day'
|
||||
},
|
||||
time: '09:00',
|
||||
timezone: 'America/New_York'
|
||||
}
|
||||
},
|
||||
...overrides
|
||||
});
|
||||
|
||||
// Message template factory
|
||||
export const createTemplate = (overrides = {}) => ({
|
||||
name: faker.lorem.words(2),
|
||||
category: faker.helpers.arrayElement(['promotional', 'transactional', 'informational', 'onboarding']),
|
||||
content: {
|
||||
en: faker.lorem.paragraph(),
|
||||
es: faker.lorem.paragraph(),
|
||||
fr: faker.lorem.paragraph()
|
||||
},
|
||||
variables: ['firstName', 'lastName'],
|
||||
isActive: true,
|
||||
...overrides
|
||||
});
|
||||
|
||||
// Webhook factory
|
||||
export const createWebhook = (overrides = {}) => ({
|
||||
name: faker.lorem.words(2),
|
||||
url: faker.internet.url(),
|
||||
events: ['campaign.completed', 'campaign.failed'],
|
||||
headers: {
|
||||
'X-Webhook-Secret': faker.string.alphanumeric(32)
|
||||
},
|
||||
isActive: true,
|
||||
retryPolicy: {
|
||||
maxRetries: 3,
|
||||
retryDelay: 1000
|
||||
},
|
||||
...overrides
|
||||
});
|
||||
|
||||
// User segment factory
|
||||
export const createSegment = (overrides = {}) => ({
|
||||
name: faker.lorem.words(2),
|
||||
description: faker.lorem.sentence(),
|
||||
criteria: [
|
||||
{
|
||||
field: 'tags',
|
||||
operator: 'contains',
|
||||
value: 'active',
|
||||
logic: 'AND'
|
||||
}
|
||||
],
|
||||
isDynamic: true,
|
||||
...overrides
|
||||
});
|
||||
|
||||
// A/B test factory
|
||||
export const createABTest = (campaignId, overrides = {}) => ({
|
||||
name: faker.lorem.words(3),
|
||||
description: faker.lorem.sentence(),
|
||||
campaignId,
|
||||
status: 'draft',
|
||||
variants: [
|
||||
{
|
||||
name: 'Control',
|
||||
weight: 50,
|
||||
content: {
|
||||
customMessage: faker.lorem.paragraph()
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Variant A',
|
||||
weight: 50,
|
||||
content: {
|
||||
customMessage: faker.lorem.paragraph()
|
||||
}
|
||||
}
|
||||
],
|
||||
metrics: ['open_rate', 'click_rate', 'conversion_rate'],
|
||||
startDate: faker.date.future(),
|
||||
endDate: faker.date.future(),
|
||||
...overrides
|
||||
});
|
||||
|
||||
// Workflow factory
|
||||
export const createWorkflow = (overrides = {}) => ({
|
||||
name: faker.lorem.words(3),
|
||||
description: faker.lorem.sentence(),
|
||||
triggerType: faker.helpers.arrayElement(['event', 'schedule', 'manual']),
|
||||
status: 'active',
|
||||
steps: [
|
||||
{
|
||||
id: faker.string.uuid(),
|
||||
type: 'send_message',
|
||||
config: {
|
||||
templateId: faker.string.uuid(),
|
||||
delay: 0
|
||||
}
|
||||
}
|
||||
],
|
||||
...overrides
|
||||
});
|
||||
|
||||
// Analytics data factory
|
||||
export const createAnalyticsData = (overrides = {}) => ({
|
||||
campaignId: faker.string.uuid(),
|
||||
date: faker.date.recent(),
|
||||
metrics: {
|
||||
sent: faker.number.int({ min: 100, max: 1000 }),
|
||||
delivered: faker.number.int({ min: 90, max: 950 }),
|
||||
read: faker.number.int({ min: 50, max: 800 }),
|
||||
clicked: faker.number.int({ min: 10, max: 200 }),
|
||||
conversions: faker.number.int({ min: 5, max: 50 }),
|
||||
revenue: faker.number.float({ min: 1000, max: 10000, precision: 0.01 })
|
||||
},
|
||||
...overrides
|
||||
});
|
||||
49
marketing-agent/tests/helpers/redis.js
Normal file
49
marketing-agent/tests/helpers/redis.js
Normal file
@@ -0,0 +1,49 @@
|
||||
import redis from 'redis-mock';
|
||||
|
||||
let redisClient;
|
||||
|
||||
export const connectRedis = async () => {
|
||||
redisClient = redis.createClient();
|
||||
return redisClient;
|
||||
};
|
||||
|
||||
export const closeRedis = async () => {
|
||||
if (redisClient) {
|
||||
await redisClient.quit();
|
||||
}
|
||||
};
|
||||
|
||||
export const clearRedis = async () => {
|
||||
if (redisClient) {
|
||||
await redisClient.flushall();
|
||||
}
|
||||
};
|
||||
|
||||
export const getRedisClient = () => redisClient;
|
||||
|
||||
// Mock Redis methods for testing
|
||||
export const mockRedisClient = {
|
||||
get: jest.fn(),
|
||||
set: jest.fn(),
|
||||
del: jest.fn(),
|
||||
exists: jest.fn(),
|
||||
expire: jest.fn(),
|
||||
ttl: jest.fn(),
|
||||
hget: jest.fn(),
|
||||
hset: jest.fn(),
|
||||
hdel: jest.fn(),
|
||||
hgetall: jest.fn(),
|
||||
sadd: jest.fn(),
|
||||
srem: jest.fn(),
|
||||
smembers: jest.fn(),
|
||||
sismember: jest.fn(),
|
||||
zadd: jest.fn(),
|
||||
zrem: jest.fn(),
|
||||
zrange: jest.fn(),
|
||||
zrevrange: jest.fn(),
|
||||
publish: jest.fn(),
|
||||
subscribe: jest.fn(),
|
||||
unsubscribe: jest.fn(),
|
||||
on: jest.fn(),
|
||||
quit: jest.fn(),
|
||||
};
|
||||
254
marketing-agent/tests/integration/api/auth.test.js
Normal file
254
marketing-agent/tests/integration/api/auth.test.js
Normal file
@@ -0,0 +1,254 @@
|
||||
import request from 'supertest';
|
||||
import app from '../../../services/api-gateway/src/app.js';
|
||||
import { connectDatabase, closeDatabase, clearDatabase } from '../../helpers/database.js';
|
||||
import { createUser } from '../../helpers/factories.js';
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
describe('Auth API Integration Tests', () => {
|
||||
beforeAll(async () => {
|
||||
await connectDatabase();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await clearDatabase();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await closeDatabase();
|
||||
});
|
||||
|
||||
describe('POST /api/v1/auth/login', () => {
|
||||
it('should login with valid credentials', async () => {
|
||||
// Create a user in the database
|
||||
const password = 'Test123!@#';
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
const userData = createUser({ password: hashedPassword });
|
||||
|
||||
// Save user to database (you'll need to import and use your User model)
|
||||
// const user = await User.create(userData);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/v1/auth/login')
|
||||
.send({
|
||||
username: userData.username,
|
||||
password: password
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.data).toHaveProperty('accessToken');
|
||||
expect(response.body.data).toHaveProperty('refreshToken');
|
||||
expect(response.body.data.user).toHaveProperty('id');
|
||||
expect(response.body.data.user.username).toBe(userData.username);
|
||||
});
|
||||
|
||||
it('should fail with invalid credentials', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/v1/auth/login')
|
||||
.send({
|
||||
username: 'nonexistent',
|
||||
password: 'wrongpassword'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.success).toBe(false);
|
||||
expect(response.body.error).toBe('Invalid credentials');
|
||||
});
|
||||
|
||||
it('should fail with missing credentials', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/v1/auth/login')
|
||||
.send({
|
||||
username: 'testuser'
|
||||
// missing password
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.success).toBe(false);
|
||||
expect(response.body.error).toContain('required');
|
||||
});
|
||||
|
||||
it('should handle rate limiting', async () => {
|
||||
// Make multiple requests to trigger rate limit
|
||||
const requests = Array(11).fill(null).map(() =>
|
||||
request(app)
|
||||
.post('/api/v1/auth/login')
|
||||
.send({
|
||||
username: 'testuser',
|
||||
password: 'password'
|
||||
})
|
||||
);
|
||||
|
||||
const responses = await Promise.all(requests);
|
||||
const rateLimited = responses.some(res => res.status === 429);
|
||||
|
||||
expect(rateLimited).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/auth/register', () => {
|
||||
it('should register new user', async () => {
|
||||
const userData = {
|
||||
username: 'newuser',
|
||||
email: 'newuser@example.com',
|
||||
password: 'SecurePass123!',
|
||||
fullName: 'New User'
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/v1/auth/register')
|
||||
.send(userData);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.message).toBe('Registration successful');
|
||||
});
|
||||
|
||||
it('should fail with duplicate username', async () => {
|
||||
const userData = {
|
||||
username: 'existinguser',
|
||||
email: 'test@example.com',
|
||||
password: 'SecurePass123!'
|
||||
};
|
||||
|
||||
// First registration
|
||||
await request(app)
|
||||
.post('/api/v1/auth/register')
|
||||
.send(userData);
|
||||
|
||||
// Duplicate registration
|
||||
const response = await request(app)
|
||||
.post('/api/v1/auth/register')
|
||||
.send(userData);
|
||||
|
||||
expect(response.status).toBe(409);
|
||||
expect(response.body.success).toBe(false);
|
||||
expect(response.body.error).toContain('already exists');
|
||||
});
|
||||
|
||||
it('should validate email format', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/v1/auth/register')
|
||||
.send({
|
||||
username: 'testuser',
|
||||
email: 'invalid-email',
|
||||
password: 'SecurePass123!'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.success).toBe(false);
|
||||
expect(response.body.error).toContain('email');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/auth/refresh', () => {
|
||||
let accessToken, refreshToken;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Login to get tokens
|
||||
const loginResponse = await request(app)
|
||||
.post('/api/v1/auth/login')
|
||||
.send({
|
||||
username: 'admin',
|
||||
password: 'password123'
|
||||
});
|
||||
|
||||
accessToken = loginResponse.body.data.accessToken;
|
||||
refreshToken = loginResponse.body.data.refreshToken;
|
||||
});
|
||||
|
||||
it('should refresh access token', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/v1/auth/refresh')
|
||||
.send({
|
||||
refreshToken: refreshToken
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.data).toHaveProperty('accessToken');
|
||||
expect(response.body.data.accessToken).not.toBe(accessToken);
|
||||
});
|
||||
|
||||
it('should fail with invalid refresh token', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/v1/auth/refresh')
|
||||
.send({
|
||||
refreshToken: 'invalid-refresh-token'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.success).toBe(false);
|
||||
expect(response.body.error).toBe('Invalid refresh token');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/auth/me', () => {
|
||||
let accessToken;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Login to get token
|
||||
const loginResponse = await request(app)
|
||||
.post('/api/v1/auth/login')
|
||||
.send({
|
||||
username: 'admin',
|
||||
password: 'password123'
|
||||
});
|
||||
|
||||
accessToken = loginResponse.body.data.accessToken;
|
||||
});
|
||||
|
||||
it('should get current user info', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/v1/auth/me')
|
||||
.set('Authorization', `Bearer ${accessToken}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.data).toHaveProperty('id');
|
||||
expect(response.body.data).toHaveProperty('role');
|
||||
});
|
||||
|
||||
it('should fail without token', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/v1/auth/me');
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.success).toBe(false);
|
||||
expect(response.body.error).toBe('No token provided');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/auth/logout', () => {
|
||||
let accessToken;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Login to get token
|
||||
const loginResponse = await request(app)
|
||||
.post('/api/v1/auth/login')
|
||||
.send({
|
||||
username: 'admin',
|
||||
password: 'password123'
|
||||
});
|
||||
|
||||
accessToken = loginResponse.body.data.accessToken;
|
||||
});
|
||||
|
||||
it('should logout successfully', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/v1/auth/logout')
|
||||
.set('Authorization', `Bearer ${accessToken}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.message).toBe('Logged out successfully');
|
||||
|
||||
// Verify token is blacklisted
|
||||
const meResponse = await request(app)
|
||||
.get('/api/v1/auth/me')
|
||||
.set('Authorization', `Bearer ${accessToken}`);
|
||||
|
||||
expect(meResponse.status).toBe(401);
|
||||
});
|
||||
});
|
||||
});
|
||||
370
marketing-agent/tests/integration/api/campaigns.test.js
Normal file
370
marketing-agent/tests/integration/api/campaigns.test.js
Normal file
@@ -0,0 +1,370 @@
|
||||
import request from 'supertest';
|
||||
import app from '../../../services/api-gateway/src/app.js';
|
||||
import { connectDatabase, closeDatabase, clearDatabase } from '../../helpers/database.js';
|
||||
import { createCampaign, createTelegramUser } from '../../helpers/factories.js';
|
||||
|
||||
describe('Campaigns API Integration Tests', () => {
|
||||
let authToken;
|
||||
|
||||
beforeAll(async () => {
|
||||
await connectDatabase();
|
||||
|
||||
// Login to get auth token
|
||||
const loginResponse = await request(app)
|
||||
.post('/api/v1/auth/login')
|
||||
.send({
|
||||
username: 'admin',
|
||||
password: 'password123'
|
||||
});
|
||||
|
||||
authToken = loginResponse.body.data.accessToken;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await clearDatabase();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await closeDatabase();
|
||||
});
|
||||
|
||||
describe('GET /api/v1/orchestrator/campaigns', () => {
|
||||
it('should list campaigns', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/v1/orchestrator/campaigns')
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.data).toHaveProperty('campaigns');
|
||||
expect(Array.isArray(response.body.data.campaigns)).toBe(true);
|
||||
expect(response.body.data).toHaveProperty('pagination');
|
||||
});
|
||||
|
||||
it('should filter campaigns by status', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/v1/orchestrator/campaigns?status=active')
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
response.body.data.campaigns.forEach(campaign => {
|
||||
expect(campaign.status).toBe('active');
|
||||
});
|
||||
});
|
||||
|
||||
it('should paginate campaigns', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/v1/orchestrator/campaigns?page=1&limit=5')
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data.campaigns.length).toBeLessThanOrEqual(5);
|
||||
expect(response.body.data.pagination.page).toBe(1);
|
||||
expect(response.body.data.pagination.limit).toBe(5);
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/v1/orchestrator/campaigns');
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/orchestrator/campaigns', () => {
|
||||
it('should create a new campaign', async () => {
|
||||
const campaignData = createCampaign();
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/v1/orchestrator/campaigns')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send(campaignData);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.data.campaign).toHaveProperty('id');
|
||||
expect(response.body.data.campaign.name).toBe(campaignData.name);
|
||||
expect(response.body.data.campaign.type).toBe(campaignData.type);
|
||||
});
|
||||
|
||||
it('should validate required fields', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/v1/orchestrator/campaigns')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({
|
||||
// Missing required fields
|
||||
description: 'Test campaign'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.success).toBe(false);
|
||||
expect(response.body.error).toContain('required');
|
||||
});
|
||||
|
||||
it('should validate campaign type', async () => {
|
||||
const campaignData = createCampaign({ type: 'invalid_type' });
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/v1/orchestrator/campaigns')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send(campaignData);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.success).toBe(false);
|
||||
expect(response.body.error).toContain('type');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/orchestrator/campaigns/:id', () => {
|
||||
let campaignId;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create a campaign
|
||||
const campaignData = createCampaign();
|
||||
const createResponse = await request(app)
|
||||
.post('/api/v1/orchestrator/campaigns')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send(campaignData);
|
||||
|
||||
campaignId = createResponse.body.data.campaign.id;
|
||||
});
|
||||
|
||||
it('should get campaign by ID', async () => {
|
||||
const response = await request(app)
|
||||
.get(`/api/v1/orchestrator/campaigns/${campaignId}`)
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.data.campaign.id).toBe(campaignId);
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent campaign', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/v1/orchestrator/campaigns/nonexistent123')
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.success).toBe(false);
|
||||
expect(response.body.error).toContain('not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /api/v1/orchestrator/campaigns/:id', () => {
|
||||
let campaignId;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create a campaign
|
||||
const campaignData = createCampaign();
|
||||
const createResponse = await request(app)
|
||||
.post('/api/v1/orchestrator/campaigns')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send(campaignData);
|
||||
|
||||
campaignId = createResponse.body.data.campaign.id;
|
||||
});
|
||||
|
||||
it('should update campaign', async () => {
|
||||
const updates = {
|
||||
name: 'Updated Campaign Name',
|
||||
description: 'Updated description'
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.put(`/api/v1/orchestrator/campaigns/${campaignId}`)
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send(updates);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.data.campaign.name).toBe(updates.name);
|
||||
expect(response.body.data.campaign.description).toBe(updates.description);
|
||||
});
|
||||
|
||||
it('should not update campaign type', async () => {
|
||||
const response = await request(app)
|
||||
.put(`/api/v1/orchestrator/campaigns/${campaignId}`)
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({ type: 'different_type' });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.success).toBe(false);
|
||||
expect(response.body.error).toContain('type');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/orchestrator/campaigns/:id/execute', () => {
|
||||
let campaignId;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create users for targeting
|
||||
const users = await Promise.all([
|
||||
createTelegramUser(),
|
||||
createTelegramUser(),
|
||||
createTelegramUser()
|
||||
]);
|
||||
|
||||
// Create a campaign with users
|
||||
const campaignData = createCampaign({
|
||||
status: 'active',
|
||||
targeting: {
|
||||
includedUsers: users.map(u => u.telegramId)
|
||||
}
|
||||
});
|
||||
|
||||
const createResponse = await request(app)
|
||||
.post('/api/v1/orchestrator/campaigns')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send(campaignData);
|
||||
|
||||
campaignId = createResponse.body.data.campaign.id;
|
||||
});
|
||||
|
||||
it('should execute campaign', async () => {
|
||||
const response = await request(app)
|
||||
.post(`/api/v1/orchestrator/campaigns/${campaignId}/execute`)
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({ test: false });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.data).toHaveProperty('executionId');
|
||||
expect(response.body.data.status).toBe('running');
|
||||
});
|
||||
|
||||
it('should execute campaign in test mode', async () => {
|
||||
const response = await request(app)
|
||||
.post(`/api/v1/orchestrator/campaigns/${campaignId}/execute`)
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({
|
||||
test: true,
|
||||
testUsers: ['testUser123']
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.data).toHaveProperty('executionId');
|
||||
});
|
||||
|
||||
it('should not execute draft campaign', async () => {
|
||||
// Create draft campaign
|
||||
const draftCampaign = createCampaign({ status: 'draft' });
|
||||
const createResponse = await request(app)
|
||||
.post('/api/v1/orchestrator/campaigns')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send(draftCampaign);
|
||||
|
||||
const draftId = createResponse.body.data.campaign.id;
|
||||
|
||||
const response = await request(app)
|
||||
.post(`/api/v1/orchestrator/campaigns/${draftId}/execute`)
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({ test: false });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.success).toBe(false);
|
||||
expect(response.body.error).toContain('not active');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/orchestrator/campaigns/:id/statistics', () => {
|
||||
let campaignId;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create and execute a campaign
|
||||
const campaignData = createCampaign({
|
||||
status: 'active',
|
||||
statistics: {
|
||||
messagesSent: 100,
|
||||
delivered: 95,
|
||||
read: 80,
|
||||
clicked: 20,
|
||||
conversions: 10
|
||||
}
|
||||
});
|
||||
|
||||
const createResponse = await request(app)
|
||||
.post('/api/v1/orchestrator/campaigns')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send(campaignData);
|
||||
|
||||
campaignId = createResponse.body.data.campaign.id;
|
||||
});
|
||||
|
||||
it('should get campaign statistics', async () => {
|
||||
const response = await request(app)
|
||||
.get(`/api/v1/orchestrator/campaigns/${campaignId}/statistics`)
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.data.statistics).toHaveProperty('overview');
|
||||
expect(response.body.data.statistics.overview).toHaveProperty('deliveryRate');
|
||||
expect(response.body.data.statistics.overview).toHaveProperty('readRate');
|
||||
expect(response.body.data.statistics.overview).toHaveProperty('conversionRate');
|
||||
});
|
||||
|
||||
it('should filter statistics by date range', async () => {
|
||||
const response = await request(app)
|
||||
.get(`/api/v1/orchestrator/campaigns/${campaignId}/statistics?dateRange=last7days`)
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.data.statistics).toHaveProperty('timeline');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/v1/orchestrator/campaigns/:id', () => {
|
||||
let campaignId;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create a campaign
|
||||
const campaignData = createCampaign({ status: 'draft' });
|
||||
const createResponse = await request(app)
|
||||
.post('/api/v1/orchestrator/campaigns')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send(campaignData);
|
||||
|
||||
campaignId = createResponse.body.data.campaign.id;
|
||||
});
|
||||
|
||||
it('should delete campaign', async () => {
|
||||
const response = await request(app)
|
||||
.delete(`/api/v1/orchestrator/campaigns/${campaignId}`)
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.message).toContain('deleted');
|
||||
|
||||
// Verify deletion
|
||||
const getResponse = await request(app)
|
||||
.get(`/api/v1/orchestrator/campaigns/${campaignId}`)
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(getResponse.status).toBe(404);
|
||||
});
|
||||
|
||||
it('should not delete active campaign', async () => {
|
||||
// Create active campaign
|
||||
const activeCampaign = createCampaign({ status: 'active' });
|
||||
const createResponse = await request(app)
|
||||
.post('/api/v1/orchestrator/campaigns')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send(activeCampaign);
|
||||
|
||||
const activeId = createResponse.body.data.campaign.id;
|
||||
|
||||
const response = await request(app)
|
||||
.delete(`/api/v1/orchestrator/campaigns/${activeId}`)
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.success).toBe(false);
|
||||
expect(response.body.error).toContain('Cannot delete active campaign');
|
||||
});
|
||||
});
|
||||
});
|
||||
56
marketing-agent/tests/setup.js
Normal file
56
marketing-agent/tests/setup.js
Normal file
@@ -0,0 +1,56 @@
|
||||
// Global test setup
|
||||
import { jest } from '@jest/globals';
|
||||
|
||||
// Set test environment
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.JWT_SECRET = 'test-jwt-secret';
|
||||
process.env.MONGODB_URI = 'mongodb://localhost:27017/test';
|
||||
process.env.REDIS_URL = 'redis://localhost:6379';
|
||||
|
||||
// Mock console methods to reduce noise
|
||||
global.console = {
|
||||
...console,
|
||||
log: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
};
|
||||
|
||||
// Set test timeout
|
||||
jest.setTimeout(30000);
|
||||
|
||||
// Clean up after tests
|
||||
afterAll(async () => {
|
||||
// Close database connections
|
||||
const mongoose = await import('mongoose');
|
||||
await mongoose.connection.close();
|
||||
|
||||
// Close Redis connections
|
||||
// Add any other cleanup needed
|
||||
});
|
||||
|
||||
// Global test utilities
|
||||
global.testUtils = {
|
||||
generateToken: (userId) => {
|
||||
const jwt = require('jsonwebtoken');
|
||||
return jwt.sign({ userId }, process.env.JWT_SECRET, { expiresIn: '1h' });
|
||||
},
|
||||
|
||||
createMockRequest: (options = {}) => ({
|
||||
headers: {},
|
||||
params: {},
|
||||
query: {},
|
||||
body: {},
|
||||
...options
|
||||
}),
|
||||
|
||||
createMockResponse: () => {
|
||||
const res = {};
|
||||
res.status = jest.fn(() => res);
|
||||
res.json = jest.fn(() => res);
|
||||
res.send = jest.fn(() => res);
|
||||
res.set = jest.fn(() => res);
|
||||
return res;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,149 @@
|
||||
import { jest } from '@jest/globals';
|
||||
import { authenticate, generateToken, generateRefreshToken } from '../../../../../services/api-gateway/src/middleware/auth.js';
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('jsonwebtoken');
|
||||
jest.mock('../../../../../services/api-gateway/src/utils/cache.js', () => ({
|
||||
cache: {
|
||||
get: jest.fn(),
|
||||
set: jest.fn(),
|
||||
del: jest.fn()
|
||||
}
|
||||
}));
|
||||
|
||||
describe('Auth Middleware', () => {
|
||||
let req, res, next;
|
||||
|
||||
beforeEach(() => {
|
||||
req = {
|
||||
headers: {},
|
||||
user: null
|
||||
};
|
||||
res = {
|
||||
status: jest.fn(() => res),
|
||||
json: jest.fn(() => res)
|
||||
};
|
||||
next = jest.fn();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('authenticate', () => {
|
||||
it('should authenticate valid token', async () => {
|
||||
const token = 'valid-token';
|
||||
const decodedToken = {
|
||||
userId: 'user123',
|
||||
role: 'user',
|
||||
permissions: ['read', 'write']
|
||||
};
|
||||
|
||||
req.headers.authorization = `Bearer ${token}`;
|
||||
jwt.verify.mockReturnValue(decodedToken);
|
||||
|
||||
await authenticate(req, res, next);
|
||||
|
||||
expect(jwt.verify).toHaveBeenCalledWith(token, process.env.JWT_SECRET);
|
||||
expect(req.user).toEqual(decodedToken);
|
||||
expect(req.token).toBe(token);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject missing authorization header', async () => {
|
||||
await authenticate(req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'No token provided'
|
||||
});
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject invalid token format', async () => {
|
||||
req.headers.authorization = 'InvalidFormat token';
|
||||
|
||||
await authenticate(req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'Invalid token format'
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject expired token', async () => {
|
||||
req.headers.authorization = 'Bearer expired-token';
|
||||
jwt.verify.mockImplementation(() => {
|
||||
throw new Error('jwt expired');
|
||||
});
|
||||
|
||||
await authenticate(req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'Token expired'
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject blacklisted token', async () => {
|
||||
const token = 'blacklisted-token';
|
||||
req.headers.authorization = `Bearer ${token}`;
|
||||
|
||||
const { cache } = require('../../../../../services/api-gateway/src/utils/cache.js');
|
||||
cache.get.mockResolvedValue('1');
|
||||
|
||||
await authenticate(req, res, next);
|
||||
|
||||
expect(cache.get).toHaveBeenCalledWith(`blacklist:${token}`);
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'Token has been revoked'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateToken', () => {
|
||||
it('should generate access token with correct payload', () => {
|
||||
const payload = {
|
||||
userId: 'user123',
|
||||
role: 'admin',
|
||||
permissions: ['all']
|
||||
};
|
||||
const expectedToken = 'generated-token';
|
||||
|
||||
jwt.sign.mockReturnValue(expectedToken);
|
||||
|
||||
const token = generateToken(payload);
|
||||
|
||||
expect(jwt.sign).toHaveBeenCalledWith(
|
||||
payload,
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '24h' }
|
||||
);
|
||||
expect(token).toBe(expectedToken);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateRefreshToken', () => {
|
||||
it('should generate refresh token with correct payload', () => {
|
||||
const payload = {
|
||||
userId: 'user123',
|
||||
role: 'user'
|
||||
};
|
||||
const expectedToken = 'refresh-token';
|
||||
|
||||
jwt.sign.mockReturnValue(expectedToken);
|
||||
|
||||
const token = generateRefreshToken(payload);
|
||||
|
||||
expect(jwt.sign).toHaveBeenCalledWith(
|
||||
payload,
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '7d' }
|
||||
);
|
||||
expect(token).toBe(expectedToken);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { jest } from '@jest/globals';
|
||||
import { globalRateLimiter, strictRateLimiter, dynamicRateLimiter } from '../../../../../services/api-gateway/src/middleware/rateLimiter.js';
|
||||
|
||||
// Mock Redis store
|
||||
jest.mock('rate-limit-redis', () => ({
|
||||
default: jest.fn(() => ({
|
||||
increment: jest.fn(),
|
||||
decrement: jest.fn(),
|
||||
resetKey: jest.fn()
|
||||
}))
|
||||
}));
|
||||
|
||||
describe('Rate Limiter Middleware', () => {
|
||||
let req, res, next;
|
||||
|
||||
beforeEach(() => {
|
||||
req = {
|
||||
ip: '127.0.0.1',
|
||||
user: { id: 'user123', role: 'user' },
|
||||
path: '/api/v1/test'
|
||||
};
|
||||
res = {
|
||||
status: jest.fn(() => res),
|
||||
json: jest.fn(() => res),
|
||||
set: jest.fn(() => res)
|
||||
};
|
||||
next = jest.fn();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('globalRateLimiter', () => {
|
||||
it('should allow request within rate limit', async () => {
|
||||
// Mock the rate limiter to allow the request
|
||||
const middleware = globalRateLimiter;
|
||||
|
||||
// Since globalRateLimiter is created by express-rate-limit,
|
||||
// we need to test its configuration
|
||||
expect(middleware).toBeDefined();
|
||||
expect(typeof middleware).toBe('function');
|
||||
});
|
||||
|
||||
it('should set rate limit headers', async () => {
|
||||
// Test that rate limit headers are set correctly
|
||||
res.setHeader = jest.fn();
|
||||
|
||||
// Mock a successful request
|
||||
const mockRateLimitInfo = {
|
||||
limit: 100,
|
||||
current: 25,
|
||||
remaining: 75,
|
||||
resetTime: new Date(Date.now() + 60000)
|
||||
};
|
||||
|
||||
// Test headers would be set by the middleware
|
||||
expect(res.setHeader).not.toHaveBeenCalled(); // Initially not called
|
||||
});
|
||||
});
|
||||
|
||||
describe('strictRateLimiter', () => {
|
||||
it('should have stricter limits than global', () => {
|
||||
expect(strictRateLimiter).toBeDefined();
|
||||
expect(typeof strictRateLimiter).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('dynamicRateLimiter', () => {
|
||||
it('should apply different limits based on user role', async () => {
|
||||
// Test for admin user
|
||||
req.user.role = 'admin';
|
||||
expect(dynamicRateLimiter).toBeDefined();
|
||||
|
||||
// Test for regular user
|
||||
req.user.role = 'user';
|
||||
expect(dynamicRateLimiter).toBeDefined();
|
||||
|
||||
// Test for viewer
|
||||
req.user.role = 'viewer';
|
||||
expect(dynamicRateLimiter).toBeDefined();
|
||||
});
|
||||
|
||||
it('should apply default limits for unauthenticated users', async () => {
|
||||
req.user = null;
|
||||
expect(dynamicRateLimiter).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,282 @@
|
||||
import { jest } from '@jest/globals';
|
||||
import CampaignService from '../../../../services/orchestrator/src/services/campaignService.js';
|
||||
import Campaign from '../../../../services/orchestrator/src/models/Campaign.js';
|
||||
import { createCampaign } from '../../../helpers/factories.js';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('../../../../services/orchestrator/src/models/Campaign.js');
|
||||
jest.mock('../../../../services/orchestrator/src/services/messagingService.js');
|
||||
jest.mock('../../../../services/orchestrator/src/services/analyticsService.js');
|
||||
|
||||
describe('CampaignService', () => {
|
||||
let campaignService;
|
||||
let mockMessagingService;
|
||||
let mockAnalyticsService;
|
||||
|
||||
beforeEach(() => {
|
||||
mockMessagingService = {
|
||||
sendMessage: jest.fn(),
|
||||
validateMessage: jest.fn()
|
||||
};
|
||||
mockAnalyticsService = {
|
||||
trackEvent: jest.fn(),
|
||||
updateCampaignStats: jest.fn()
|
||||
};
|
||||
|
||||
campaignService = new CampaignService(mockMessagingService, mockAnalyticsService);
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('createCampaign', () => {
|
||||
it('should create a new campaign', async () => {
|
||||
const campaignData = createCampaign();
|
||||
const savedCampaign = { ...campaignData, _id: 'camp123', save: jest.fn() };
|
||||
|
||||
Campaign.mockImplementation(() => savedCampaign);
|
||||
savedCampaign.save.mockResolvedValue(savedCampaign);
|
||||
|
||||
const result = await campaignService.createCampaign(campaignData, 'user123');
|
||||
|
||||
expect(Campaign).toHaveBeenCalledWith({
|
||||
...campaignData,
|
||||
createdBy: 'user123'
|
||||
});
|
||||
expect(savedCampaign.save).toHaveBeenCalled();
|
||||
expect(result).toEqual(savedCampaign);
|
||||
});
|
||||
|
||||
it('should validate required fields', async () => {
|
||||
const invalidData = { name: '' };
|
||||
|
||||
await expect(campaignService.createCampaign(invalidData, 'user123'))
|
||||
.rejects.toThrow('Campaign name is required');
|
||||
});
|
||||
|
||||
it('should track campaign creation', async () => {
|
||||
const campaignData = createCampaign();
|
||||
const savedCampaign = { ...campaignData, _id: 'camp123', save: jest.fn() };
|
||||
|
||||
Campaign.mockImplementation(() => savedCampaign);
|
||||
savedCampaign.save.mockResolvedValue(savedCampaign);
|
||||
|
||||
await campaignService.createCampaign(campaignData, 'user123');
|
||||
|
||||
expect(mockAnalyticsService.trackEvent).toHaveBeenCalledWith({
|
||||
event: 'campaign.created',
|
||||
campaignId: 'camp123',
|
||||
userId: 'user123',
|
||||
campaignType: campaignData.type
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('executeCampaign', () => {
|
||||
it('should execute campaign successfully', async () => {
|
||||
const campaign = {
|
||||
_id: 'camp123',
|
||||
status: 'active',
|
||||
targeting: {
|
||||
includedUsers: ['user1', 'user2', 'user3']
|
||||
},
|
||||
content: {
|
||||
customMessage: 'Test message'
|
||||
},
|
||||
settings: {
|
||||
rateLimit: {
|
||||
messagesPerSecond: 10
|
||||
}
|
||||
},
|
||||
save: jest.fn()
|
||||
};
|
||||
|
||||
Campaign.findById.mockResolvedValue(campaign);
|
||||
mockMessagingService.sendMessage.mockResolvedValue({ success: true });
|
||||
|
||||
const result = await campaignService.executeCampaign('camp123', {
|
||||
test: false
|
||||
});
|
||||
|
||||
expect(result.campaignId).toBe('camp123');
|
||||
expect(result.status).toBe('completed');
|
||||
expect(result.progress.total).toBe(3);
|
||||
expect(mockMessagingService.sendMessage).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('should handle test mode execution', async () => {
|
||||
const campaign = {
|
||||
_id: 'camp123',
|
||||
status: 'active',
|
||||
targeting: {
|
||||
includedUsers: ['user1', 'user2', 'user3']
|
||||
},
|
||||
content: {
|
||||
customMessage: 'Test message'
|
||||
},
|
||||
save: jest.fn()
|
||||
};
|
||||
|
||||
Campaign.findById.mockResolvedValue(campaign);
|
||||
mockMessagingService.sendMessage.mockResolvedValue({ success: true });
|
||||
|
||||
const result = await campaignService.executeCampaign('camp123', {
|
||||
test: true,
|
||||
testUsers: ['testUser1']
|
||||
});
|
||||
|
||||
expect(result.isTest).toBe(true);
|
||||
expect(mockMessagingService.sendMessage).toHaveBeenCalledTimes(1);
|
||||
expect(mockMessagingService.sendMessage).toHaveBeenCalledWith(
|
||||
'testUser1',
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('should respect rate limits', async () => {
|
||||
const campaign = {
|
||||
_id: 'camp123',
|
||||
status: 'active',
|
||||
targeting: {
|
||||
includedUsers: Array(100).fill(null).map((_, i) => `user${i}`)
|
||||
},
|
||||
content: {
|
||||
customMessage: 'Test message'
|
||||
},
|
||||
settings: {
|
||||
rateLimit: {
|
||||
messagesPerSecond: 10
|
||||
}
|
||||
},
|
||||
save: jest.fn()
|
||||
};
|
||||
|
||||
Campaign.findById.mockResolvedValue(campaign);
|
||||
mockMessagingService.sendMessage.mockResolvedValue({ success: true });
|
||||
|
||||
const startTime = Date.now();
|
||||
await campaignService.executeCampaign('camp123', { test: false });
|
||||
const endTime = Date.now();
|
||||
|
||||
// With 100 users and 10 messages per second, it should take at least 9 seconds
|
||||
// (allowing for some margin)
|
||||
expect(endTime - startTime).toBeGreaterThanOrEqual(9000);
|
||||
});
|
||||
|
||||
it('should handle campaign not found', async () => {
|
||||
Campaign.findById.mockResolvedValue(null);
|
||||
|
||||
await expect(campaignService.executeCampaign('invalid123'))
|
||||
.rejects.toThrow('Campaign not found');
|
||||
});
|
||||
|
||||
it('should handle inactive campaign', async () => {
|
||||
const campaign = {
|
||||
_id: 'camp123',
|
||||
status: 'draft'
|
||||
};
|
||||
|
||||
Campaign.findById.mockResolvedValue(campaign);
|
||||
|
||||
await expect(campaignService.executeCampaign('camp123'))
|
||||
.rejects.toThrow('Campaign is not active');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateCampaign', () => {
|
||||
it('should update campaign successfully', async () => {
|
||||
const campaign = {
|
||||
_id: 'camp123',
|
||||
name: 'Old Name',
|
||||
description: 'Old Description',
|
||||
save: jest.fn(),
|
||||
toObject: jest.fn(() => ({ _id: 'camp123', name: 'New Name' }))
|
||||
};
|
||||
|
||||
Campaign.findById.mockResolvedValue(campaign);
|
||||
campaign.save.mockResolvedValue(campaign);
|
||||
|
||||
const updates = {
|
||||
name: 'New Name',
|
||||
description: 'New Description'
|
||||
};
|
||||
|
||||
const result = await campaignService.updateCampaign('camp123', updates);
|
||||
|
||||
expect(campaign.name).toBe('New Name');
|
||||
expect(campaign.description).toBe('New Description');
|
||||
expect(campaign.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not allow updating campaign in progress', async () => {
|
||||
const campaign = {
|
||||
_id: 'camp123',
|
||||
status: 'executing'
|
||||
};
|
||||
|
||||
Campaign.findById.mockResolvedValue(campaign);
|
||||
|
||||
await expect(campaignService.updateCampaign('camp123', { name: 'New' }))
|
||||
.rejects.toThrow('Cannot update campaign while it is executing');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteCampaign', () => {
|
||||
it('should delete campaign successfully', async () => {
|
||||
const campaign = {
|
||||
_id: 'camp123',
|
||||
status: 'draft',
|
||||
deleteOne: jest.fn()
|
||||
};
|
||||
|
||||
Campaign.findById.mockResolvedValue(campaign);
|
||||
|
||||
await campaignService.deleteCampaign('camp123');
|
||||
|
||||
expect(campaign.deleteOne).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not allow deleting active campaign', async () => {
|
||||
const campaign = {
|
||||
_id: 'camp123',
|
||||
status: 'active'
|
||||
};
|
||||
|
||||
Campaign.findById.mockResolvedValue(campaign);
|
||||
|
||||
await expect(campaignService.deleteCampaign('camp123'))
|
||||
.rejects.toThrow('Cannot delete active campaign');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCampaignStatistics', () => {
|
||||
it('should return campaign statistics', async () => {
|
||||
const campaign = {
|
||||
_id: 'camp123',
|
||||
statistics: {
|
||||
messagesSent: 100,
|
||||
delivered: 95,
|
||||
read: 80,
|
||||
clicked: 20,
|
||||
conversions: 10
|
||||
}
|
||||
};
|
||||
|
||||
Campaign.findById.mockResolvedValue(campaign);
|
||||
|
||||
const stats = await campaignService.getCampaignStatistics('camp123');
|
||||
|
||||
expect(stats).toEqual({
|
||||
overview: {
|
||||
messagesSent: 100,
|
||||
delivered: 95,
|
||||
deliveryRate: 95,
|
||||
read: 80,
|
||||
readRate: 84.21,
|
||||
clicked: 20,
|
||||
clickRate: 25,
|
||||
conversions: 10,
|
||||
conversionRate: 12.5
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,377 @@
|
||||
import { jest } from '@jest/globals';
|
||||
import CampaignSchedulerService from '../../../../services/scheduler/src/services/campaignSchedulerService.js';
|
||||
import ScheduledCampaign from '../../../../services/scheduler/src/models/ScheduledCampaign.js';
|
||||
import ScheduleJob from '../../../../services/scheduler/src/models/ScheduleJob.js';
|
||||
import { createScheduledCampaign } from '../../../helpers/factories.js';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('../../../../services/scheduler/src/models/ScheduledCampaign.js');
|
||||
jest.mock('../../../../services/scheduler/src/models/ScheduleJob.js');
|
||||
jest.mock('node-cron');
|
||||
jest.mock('bull');
|
||||
|
||||
describe('CampaignSchedulerService', () => {
|
||||
let schedulerService;
|
||||
let mockQueue;
|
||||
|
||||
beforeEach(() => {
|
||||
mockQueue = {
|
||||
add: jest.fn(),
|
||||
process: jest.fn(),
|
||||
on: jest.fn()
|
||||
};
|
||||
|
||||
schedulerService = new CampaignSchedulerService(mockQueue);
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('createSchedule', () => {
|
||||
it('should create one-time schedule', async () => {
|
||||
const scheduleData = createScheduledCampaign('camp123', {
|
||||
type: 'one-time',
|
||||
schedule: {
|
||||
startDateTime: new Date('2024-06-01T10:00:00Z')
|
||||
}
|
||||
});
|
||||
|
||||
const savedSchedule = {
|
||||
...scheduleData,
|
||||
_id: 'sched123',
|
||||
save: jest.fn().mockResolvedValue(scheduleData)
|
||||
};
|
||||
|
||||
ScheduledCampaign.mockImplementation(() => savedSchedule);
|
||||
|
||||
const result = await schedulerService.createSchedule(scheduleData);
|
||||
|
||||
expect(ScheduledCampaign).toHaveBeenCalledWith(scheduleData);
|
||||
expect(savedSchedule.save).toHaveBeenCalled();
|
||||
expect(mockQueue.add).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
scheduleId: 'sched123',
|
||||
campaignId: 'camp123'
|
||||
}),
|
||||
expect.objectContaining({
|
||||
delay: expect.any(Number)
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should create recurring schedule', async () => {
|
||||
const scheduleData = createScheduledCampaign('camp123', {
|
||||
type: 'recurring',
|
||||
schedule: {
|
||||
startDateTime: new Date(),
|
||||
recurring: {
|
||||
pattern: 'daily',
|
||||
frequency: { interval: 1, unit: 'day' },
|
||||
time: '09:00',
|
||||
timezone: 'America/New_York'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const savedSchedule = {
|
||||
...scheduleData,
|
||||
_id: 'sched123',
|
||||
save: jest.fn().mockResolvedValue(scheduleData),
|
||||
calculateNextRunTime: jest.fn().mockReturnValue(new Date())
|
||||
};
|
||||
|
||||
ScheduledCampaign.mockImplementation(() => savedSchedule);
|
||||
|
||||
const result = await schedulerService.createSchedule(scheduleData);
|
||||
|
||||
expect(result.type).toBe('recurring');
|
||||
expect(savedSchedule.calculateNextRunTime).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should validate schedule data', async () => {
|
||||
const invalidData = {
|
||||
campaignId: 'camp123',
|
||||
name: 'Test Schedule'
|
||||
// Missing required fields
|
||||
};
|
||||
|
||||
await expect(schedulerService.createSchedule(invalidData))
|
||||
.rejects.toThrow('Schedule type is required');
|
||||
});
|
||||
|
||||
it('should handle timezone conversion', async () => {
|
||||
const scheduleData = createScheduledCampaign('camp123', {
|
||||
type: 'recurring',
|
||||
schedule: {
|
||||
recurring: {
|
||||
time: '09:00',
|
||||
timezone: 'Europe/London'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const savedSchedule = {
|
||||
...scheduleData,
|
||||
_id: 'sched123',
|
||||
save: jest.fn().mockResolvedValue(scheduleData)
|
||||
};
|
||||
|
||||
ScheduledCampaign.mockImplementation(() => savedSchedule);
|
||||
|
||||
await schedulerService.createSchedule(scheduleData);
|
||||
|
||||
// Verify timezone handling
|
||||
expect(savedSchedule.schedule.recurring.timezone).toBe('Europe/London');
|
||||
});
|
||||
});
|
||||
|
||||
describe('pauseSchedule', () => {
|
||||
it('should pause active schedule', async () => {
|
||||
const schedule = {
|
||||
_id: 'sched123',
|
||||
status: 'active',
|
||||
save: jest.fn()
|
||||
};
|
||||
|
||||
ScheduledCampaign.findById.mockResolvedValue(schedule);
|
||||
|
||||
await schedulerService.pauseSchedule('sched123');
|
||||
|
||||
expect(schedule.status).toBe('paused');
|
||||
expect(schedule.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should cancel pending jobs when pausing', async () => {
|
||||
const schedule = {
|
||||
_id: 'sched123',
|
||||
status: 'active',
|
||||
save: jest.fn()
|
||||
};
|
||||
|
||||
const pendingJobs = [
|
||||
{ _id: 'job1', status: 'pending', cancel: jest.fn() },
|
||||
{ _id: 'job2', status: 'pending', cancel: jest.fn() }
|
||||
];
|
||||
|
||||
ScheduledCampaign.findById.mockResolvedValue(schedule);
|
||||
ScheduleJob.find.mockResolvedValue(pendingJobs);
|
||||
|
||||
await schedulerService.pauseSchedule('sched123');
|
||||
|
||||
expect(ScheduleJob.find).toHaveBeenCalledWith({
|
||||
scheduleId: 'sched123',
|
||||
status: 'pending'
|
||||
});
|
||||
expect(pendingJobs[0].cancel).toHaveBeenCalled();
|
||||
expect(pendingJobs[1].cancel).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle already paused schedule', async () => {
|
||||
const schedule = {
|
||||
_id: 'sched123',
|
||||
status: 'paused'
|
||||
};
|
||||
|
||||
ScheduledCampaign.findById.mockResolvedValue(schedule);
|
||||
|
||||
await expect(schedulerService.pauseSchedule('sched123'))
|
||||
.rejects.toThrow('Schedule is already paused');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resumeSchedule', () => {
|
||||
it('should resume paused schedule', async () => {
|
||||
const schedule = {
|
||||
_id: 'sched123',
|
||||
status: 'paused',
|
||||
type: 'recurring',
|
||||
campaignId: 'camp123',
|
||||
save: jest.fn(),
|
||||
calculateNextRunTime: jest.fn().mockReturnValue(new Date())
|
||||
};
|
||||
|
||||
ScheduledCampaign.findById.mockResolvedValue(schedule);
|
||||
|
||||
await schedulerService.resumeSchedule('sched123');
|
||||
|
||||
expect(schedule.status).toBe('active');
|
||||
expect(schedule.save).toHaveBeenCalled();
|
||||
expect(schedule.calculateNextRunTime).toHaveBeenCalled();
|
||||
expect(mockQueue.add).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('processJob', () => {
|
||||
it('should execute campaign for job', async () => {
|
||||
const job = {
|
||||
_id: 'job123',
|
||||
scheduleId: 'sched123',
|
||||
campaignId: 'camp123',
|
||||
status: 'pending',
|
||||
acquireLock: jest.fn().mockResolvedValue(true),
|
||||
save: jest.fn()
|
||||
};
|
||||
|
||||
const schedule = {
|
||||
_id: 'sched123',
|
||||
type: 'recurring',
|
||||
recordExecution: jest.fn(),
|
||||
calculateNextRunTime: jest.fn().mockReturnValue(new Date())
|
||||
};
|
||||
|
||||
ScheduleJob.findById.mockResolvedValue(job);
|
||||
ScheduledCampaign.findById.mockResolvedValue(schedule);
|
||||
|
||||
// Mock campaign execution
|
||||
const mockCampaignExecution = jest.fn().mockResolvedValue({
|
||||
success: true,
|
||||
executionId: 'exec123'
|
||||
});
|
||||
schedulerService.executeCampaign = mockCampaignExecution;
|
||||
|
||||
await schedulerService.processJob('job123');
|
||||
|
||||
expect(job.acquireLock).toHaveBeenCalled();
|
||||
expect(mockCampaignExecution).toHaveBeenCalledWith('camp123');
|
||||
expect(job.status).toBe('completed');
|
||||
expect(schedule.recordExecution).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle job lock failure', async () => {
|
||||
const job = {
|
||||
_id: 'job123',
|
||||
acquireLock: jest.fn().mockResolvedValue(false)
|
||||
};
|
||||
|
||||
ScheduleJob.findById.mockResolvedValue(job);
|
||||
|
||||
await schedulerService.processJob('job123');
|
||||
|
||||
expect(job.acquireLock).toHaveBeenCalled();
|
||||
expect(job.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle campaign execution failure', async () => {
|
||||
const job = {
|
||||
_id: 'job123',
|
||||
scheduleId: 'sched123',
|
||||
campaignId: 'camp123',
|
||||
status: 'pending',
|
||||
attempts: 0,
|
||||
acquireLock: jest.fn().mockResolvedValue(true),
|
||||
save: jest.fn()
|
||||
};
|
||||
|
||||
ScheduleJob.findById.mockResolvedValue(job);
|
||||
|
||||
// Mock campaign execution failure
|
||||
const mockCampaignExecution = jest.fn().mockRejectedValue(new Error('Campaign failed'));
|
||||
schedulerService.executeCampaign = mockCampaignExecution;
|
||||
|
||||
await schedulerService.processJob('job123');
|
||||
|
||||
expect(job.status).toBe('failed');
|
||||
expect(job.lastError).toContain('Campaign failed');
|
||||
expect(job.attempts).toBe(1);
|
||||
});
|
||||
|
||||
it('should retry failed jobs', async () => {
|
||||
const job = {
|
||||
_id: 'job123',
|
||||
scheduleId: 'sched123',
|
||||
campaignId: 'camp123',
|
||||
status: 'failed',
|
||||
attempts: 1,
|
||||
maxRetries: 3,
|
||||
acquireLock: jest.fn().mockResolvedValue(true),
|
||||
save: jest.fn()
|
||||
};
|
||||
|
||||
ScheduleJob.findById.mockResolvedValue(job);
|
||||
|
||||
await schedulerService.processJob('job123');
|
||||
|
||||
expect(mockQueue.add).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ jobId: 'job123' }),
|
||||
expect.objectContaining({ delay: expect.any(Number) })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSchedulePreview', () => {
|
||||
it('should generate preview for recurring schedule', async () => {
|
||||
const schedule = {
|
||||
_id: 'sched123',
|
||||
type: 'recurring',
|
||||
schedule: {
|
||||
recurring: {
|
||||
pattern: 'weekly',
|
||||
daysOfWeek: [1, 3, 5], // Mon, Wed, Fri
|
||||
time: '10:00',
|
||||
timezone: 'America/New_York'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ScheduledCampaign.findById.mockResolvedValue(schedule);
|
||||
|
||||
const preview = await schedulerService.getSchedulePreview('sched123', 5);
|
||||
|
||||
expect(preview).toHaveLength(5);
|
||||
preview.forEach(item => {
|
||||
expect(item).toHaveProperty('scheduledFor');
|
||||
expect(item).toHaveProperty('dayOfWeek');
|
||||
expect(item).toHaveProperty('localTime');
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle one-time schedule preview', async () => {
|
||||
const startDate = new Date('2024-06-01T14:00:00Z');
|
||||
const schedule = {
|
||||
_id: 'sched123',
|
||||
type: 'one-time',
|
||||
schedule: {
|
||||
startDateTime: startDate
|
||||
}
|
||||
};
|
||||
|
||||
ScheduledCampaign.findById.mockResolvedValue(schedule);
|
||||
|
||||
const preview = await schedulerService.getSchedulePreview('sched123', 5);
|
||||
|
||||
expect(preview).toHaveLength(1);
|
||||
expect(preview[0].scheduledFor).toEqual(startDate);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteSchedule', () => {
|
||||
it('should delete schedule and associated jobs', async () => {
|
||||
const schedule = {
|
||||
_id: 'sched123',
|
||||
status: 'paused',
|
||||
deleteOne: jest.fn()
|
||||
};
|
||||
|
||||
ScheduledCampaign.findById.mockResolvedValue(schedule);
|
||||
ScheduleJob.deleteMany.mockResolvedValue({ deletedCount: 3 });
|
||||
|
||||
await schedulerService.deleteSchedule('sched123');
|
||||
|
||||
expect(schedule.deleteOne).toHaveBeenCalled();
|
||||
expect(ScheduleJob.deleteMany).toHaveBeenCalledWith({
|
||||
scheduleId: 'sched123',
|
||||
status: { $in: ['pending', 'failed'] }
|
||||
});
|
||||
});
|
||||
|
||||
it('should not delete active schedule', async () => {
|
||||
const schedule = {
|
||||
_id: 'sched123',
|
||||
status: 'active'
|
||||
};
|
||||
|
||||
ScheduledCampaign.findById.mockResolvedValue(schedule);
|
||||
|
||||
await expect(schedulerService.deleteSchedule('sched123'))
|
||||
.rejects.toThrow('Cannot delete active schedule');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user