Files
plunk/apps/api/src/__tests__/integration/actions.test.ts
T

472 lines
13 KiB
TypeScript

import {describe, it, expect, beforeEach} from 'vitest';
import {ActionSchemas} from '@plunk/shared';
import {factories, getPrismaClient} from '../../../../../test/helpers';
import {
ErrorCode,
NotFound,
ValidationError,
NotAuthenticated,
NotAllowed,
RateLimitError,
ConflictError,
BadRequest,
HttpException,
} from '../../exceptions/index.js';
import {EmailService} from '../../services/EmailService.js';
/**
* Integration tests for Actions API endpoints (/v1/send, /v1/track)
* Tests error handling, validation, and business logic for public API
*/
describe('Actions API Integration Tests', () => {
let projectId: string;
const prisma = getPrismaClient();
beforeEach(async () => {
const {project} = await factories.createUserWithProject();
projectId = project.id;
});
// ========================================
// ERROR RESPONSE STRUCTURE
// ========================================
describe('Error Response Structure', () => {
it('should have standardized error response format', () => {
// Document the expected error response structure
const expectedErrorResponse = {
success: false,
error: {
code: 'VALIDATION_ERROR', // Machine-readable
message: 'Request validation failed', // Human-readable
statusCode: 422,
requestId: expect.any(String), // For tracking
errors: expect.any(Array), // Field-level errors (for validation)
suggestion: expect.any(String), // Helpful tip
},
timestamp: expect.any(String), // ISO timestamp
};
// Verify structure
expect(expectedErrorResponse.success).toBe(false);
expect(expectedErrorResponse.error.code).toBeDefined();
expect(expectedErrorResponse.error.message).toBeDefined();
expect(expectedErrorResponse.error.statusCode).toBeDefined();
});
});
// ========================================
// VALIDATION ERRORS (422)
// ========================================
describe('Validation Error Handling', () => {
it('should validate email format in requests', () => {
const result = ActionSchemas.send.safeParse({
to: 'not-an-email',
subject: 'Test',
body: 'Test',
});
expect(result.success).toBe(false);
});
it('should validate required fields for /v1/send', () => {
const result = ActionSchemas.send.safeParse({});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.errors.some(e => e.path.includes('to'))).toBe(true);
}
});
it('should validate subject and body required when no template', () => {
const result = ActionSchemas.send.safeParse({
to: 'test@example.com',
// Missing subject, body, and template
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.errors.some(e => e.message.includes('template'))).toBe(true);
}
});
it('should validate required fields for /v1/track', () => {
const result = ActionSchemas.track.safeParse({
email: 'test@example.com',
// Missing event name
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.errors.some(e => e.path.includes('event'))).toBe(true);
}
});
it('should accept from as string (backward compatible)', () => {
const result = ActionSchemas.send.safeParse({
to: 'test@example.com',
subject: 'Test',
body: 'Test',
from: 'sender@example.com',
});
expect(result.success).toBe(true);
});
it('should accept from as object with name and email', () => {
const result = ActionSchemas.send.safeParse({
to: 'test@example.com',
subject: 'Test',
body: 'Test',
from: {
name: 'John Doe',
email: 'sender@example.com',
},
});
expect(result.success).toBe(true);
});
it('should accept from as object with only email', () => {
const result = ActionSchemas.send.safeParse({
to: 'test@example.com',
subject: 'Test',
body: 'Test',
from: {
email: 'sender@example.com',
},
});
expect(result.success).toBe(true);
});
it('should reject from object with invalid email', () => {
const result = ActionSchemas.send.safeParse({
to: 'test@example.com',
subject: 'Test',
body: 'Test',
from: {
name: 'John Doe',
email: 'not-an-email',
},
});
expect(result.success).toBe(false);
});
it('should reject from object missing email field', () => {
const result = ActionSchemas.send.safeParse({
to: 'test@example.com',
subject: 'Test',
body: 'Test',
from: {
name: 'John Doe',
},
});
expect(result.success).toBe(false);
});
it('should accept to as string (backward compatible)', () => {
const result = ActionSchemas.send.safeParse({
to: 'test@example.com',
subject: 'Test',
body: 'Test',
});
expect(result.success).toBe(true);
});
it('should accept to as object with name and email', () => {
const result = ActionSchemas.send.safeParse({
to: {
name: 'Jane Doe',
email: 'test@example.com',
},
subject: 'Test',
body: 'Test',
});
expect(result.success).toBe(true);
});
it('should accept to as object with only email', () => {
const result = ActionSchemas.send.safeParse({
to: {
email: 'test@example.com',
},
subject: 'Test',
body: 'Test',
});
expect(result.success).toBe(true);
});
it('should accept to as array of strings', () => {
const result = ActionSchemas.send.safeParse({
to: ['test1@example.com', 'test2@example.com'],
subject: 'Test',
body: 'Test',
});
expect(result.success).toBe(true);
});
it('should accept to as array of objects with name and email', () => {
const result = ActionSchemas.send.safeParse({
to: [
{name: 'Jane Doe', email: 'test1@example.com'},
{name: 'John Smith', email: 'test2@example.com'},
],
subject: 'Test',
body: 'Test',
});
expect(result.success).toBe(true);
});
it('should accept to as mixed array of strings and objects', () => {
const result = ActionSchemas.send.safeParse({
to: ['test1@example.com', {name: 'John Smith', email: 'test2@example.com'}],
subject: 'Test',
body: 'Test',
});
expect(result.success).toBe(true);
});
it('should reject to object with invalid email', () => {
const result = ActionSchemas.send.safeParse({
to: {
name: 'Jane Doe',
email: 'not-an-email',
},
subject: 'Test',
body: 'Test',
});
expect(result.success).toBe(false);
});
it('should reject to object missing email field', () => {
const result = ActionSchemas.send.safeParse({
to: {
name: 'Jane Doe',
},
subject: 'Test',
body: 'Test',
});
expect(result.success).toBe(false);
});
});
// ========================================
// CUSTOM HTTP EXCEPTIONS
// ========================================
describe('Custom HTTP Exception Types', () => {
it('should structure NotFound errors correctly', () => {
const error = new NotFound('Template', 'abc-123');
expect(error.code).toBe(404);
expect(error.message).toContain('Template');
expect(error.message).toContain('abc-123');
expect(error.errorCode).toBe(ErrorCode.TEMPLATE_NOT_FOUND);
expect(error.details).toEqual({resource: 'Template', id: 'abc-123'});
});
it('should map resources to specific error codes', () => {
const testCases = [
{resource: 'contact', expectedCode: ErrorCode.CONTACT_NOT_FOUND},
{resource: 'template', expectedCode: ErrorCode.TEMPLATE_NOT_FOUND},
{resource: 'campaign', expectedCode: ErrorCode.CAMPAIGN_NOT_FOUND},
{resource: 'workflow', expectedCode: ErrorCode.WORKFLOW_NOT_FOUND},
{resource: 'unknown', expectedCode: ErrorCode.RESOURCE_NOT_FOUND},
];
for (const {resource, expectedCode} of testCases) {
const error = new NotFound(resource);
expect(error.errorCode).toBe(expectedCode);
}
});
it('should structure ValidationError with field details', () => {
const fieldErrors = [
{field: 'email', message: 'Invalid email format', code: 'invalid_email'},
{field: 'data.firstName', message: 'Required field', code: 'required'},
];
const error = new ValidationError(fieldErrors);
expect(error.code).toBe(422);
expect(error.errorCode).toBe(ErrorCode.VALIDATION_ERROR);
expect(error.errors).toEqual(fieldErrors);
});
it('should structure NotAuthenticated errors', () => {
const error = new NotAuthenticated();
expect(error.code).toBe(401);
expect(error.errorCode).toBe(ErrorCode.UNAUTHORIZED);
});
it('should structure NotAllowed errors', () => {
const error = new NotAllowed('Cannot perform action', 'Insufficient permissions');
expect(error.code).toBe(403);
expect(error.errorCode).toBe(ErrorCode.FORBIDDEN);
expect(error.details).toEqual({reason: 'Insufficient permissions'});
});
it('should structure RateLimitError', () => {
const error = new RateLimitError('Too many requests', 60);
expect(error.code).toBe(429);
expect(error.errorCode).toBe(ErrorCode.RATE_LIMIT_EXCEEDED);
expect(error.details).toEqual({retryAfter: 60});
});
it('should structure ConflictError', () => {
const error = new ConflictError('Contact exists', {email: 'test@example.com'});
expect(error.code).toBe(409);
expect(error.errorCode).toBe(ErrorCode.CONFLICT);
expect(error.details).toEqual({email: 'test@example.com'});
});
it('should structure BadRequest errors', () => {
const error = new BadRequest('Invalid format', ErrorCode.INVALID_REQUEST_BODY, {
expected: 'JSON',
});
expect(error.code).toBe(400);
expect(error.errorCode).toBe(ErrorCode.INVALID_REQUEST_BODY);
expect(error.details).toEqual({expected: 'JSON'});
});
});
// ========================================
// BUSINESS LOGIC ERRORS
// ========================================
describe('Business Logic Error Scenarios', () => {
it('should reject marketing template sent to unsubscribed contact', async () => {
const contact = await factories.createContact({
projectId,
subscribed: false,
});
const marketingTemplate = await factories.createTemplate({
projectId,
type: 'MARKETING',
});
await expect(
EmailService.sendTransactionalEmail({
projectId,
contactId: contact.id,
templateId: marketingTemplate.id,
subject: 'Marketing',
body: 'Buy now!',
from: 'test@example.com',
}),
).rejects.toThrow(/cannot send marketing template to unsubscribed contact/i);
});
it('should return NotFound when template does not exist', async () => {
const contact = await factories.createContact({projectId});
const nonExistentId = '00000000-0000-0000-0000-000000000000';
const template = await prisma.template.findUnique({
where: {id: nonExistentId, projectId},
});
expect(template).toBeNull();
// In actual API, this would trigger NotFound exception
const error = new NotFound('Template', nonExistentId);
expect(error.code).toBe(404);
expect(error.errorCode).toBe(ErrorCode.TEMPLATE_NOT_FOUND);
});
it('should handle billing limit exceeded', () => {
const error = new HttpException(429, 'Billing limit exceeded');
expect(error.code).toBe(429);
expect(error.message).toContain('Billing limit exceeded');
});
});
// ========================================
// ERROR CODE COVERAGE
// ========================================
describe('ErrorCode Enum Coverage', () => {
it('should have all expected error codes defined', () => {
const expectedCodes = [
// Auth (401, 403)
'UNAUTHORIZED',
'INVALID_CREDENTIALS',
'MISSING_AUTH',
'INVALID_API_KEY',
'FORBIDDEN',
'PROJECT_ACCESS_DENIED',
'PROJECT_DISABLED',
// Resources (404, 409)
'RESOURCE_NOT_FOUND',
'CONTACT_NOT_FOUND',
'TEMPLATE_NOT_FOUND',
'CAMPAIGN_NOT_FOUND',
'WORKFLOW_NOT_FOUND',
'CONFLICT',
// Validation (400, 422)
'BAD_REQUEST',
'VALIDATION_ERROR',
'INVALID_EMAIL',
'INVALID_REQUEST_BODY',
'MISSING_REQUIRED_FIELD',
// Limits (429, 402)
'RATE_LIMIT_EXCEEDED',
'BILLING_LIMIT_EXCEEDED',
'UPGRADE_REQUIRED',
// Server (500+)
'INTERNAL_SERVER_ERROR',
'DATABASE_ERROR',
'EXTERNAL_SERVICE_ERROR',
];
for (const code of expectedCodes) {
expect(ErrorCode[code as keyof typeof ErrorCode]).toBe(code);
}
});
});
});