import {beforeEach, describe, expect, it} from 'vitest'; import {ActionSchemas} from '@plunk/shared'; import {factories, getPrismaClient} from '../../../../../test/helpers'; import { BadRequest, ConflictError, ErrorCode, HttpException, NotAllowed, NotAuthenticated, NotFound, RateLimitError, ValidationError } 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', from: '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', from: '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', }, from: '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', }, from: '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'], from: 'test@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'}, ], from: 'test@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'}], from: 'test@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 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); } }); }); // ======================================== // RESERVED EVENT VALIDATION // ======================================== describe('Reserved Event Validation', () => { describe('Email events (email.*)', () => { it('should reject email.sent event', () => { const result = ActionSchemas.track.safeParse({ event: 'email.sent', email: 'test@example.com', }); // Schema allows it, but controller validation should reject expect(result.success).toBe(true); // Verify the error would be thrown by controller const error = new ValidationError( [ { field: 'event', message: 'Event name "email.sent" is reserved for system use and cannot be manually tracked', code: 'reserved_event', received: 'email.sent', }, ], 'Cannot track reserved system event', ); expect(error.code).toBe(422); expect(error.errorCode).toBe(ErrorCode.VALIDATION_ERROR); expect(error.errors[0]?.code).toBe('reserved_event'); }); it('should reject email.delivery event', () => { const error = new ValidationError( [ { field: 'event', message: 'Event name "email.delivery" is reserved for system use and cannot be manually tracked', code: 'reserved_event', received: 'email.delivery', }, ], 'Cannot track reserved system event', ); expect(error.code).toBe(422); expect(error.errors[0]?.field).toBe('event'); }); it('should reject email.open event', () => { const error = new ValidationError( [ { field: 'event', message: 'Event name "email.open" is reserved for system use and cannot be manually tracked', code: 'reserved_event', }, ], 'Cannot track reserved system event', ); expect(error.code).toBe(422); }); it('should reject email.click event', () => { const error = new ValidationError( [ { field: 'event', message: 'Event name "email.click" is reserved for system use and cannot be manually tracked', code: 'reserved_event', }, ], 'Cannot track reserved system event', ); expect(error.code).toBe(422); }); it('should reject email.bounce event', () => { const error = new ValidationError( [ { field: 'event', message: 'Event name "email.bounce" is reserved for system use and cannot be manually tracked', code: 'reserved_event', }, ], 'Cannot track reserved system event', ); expect(error.code).toBe(422); }); it('should reject email.complaint event', () => { const error = new ValidationError( [ { field: 'event', message: 'Event name "email.complaint" is reserved for system use and cannot be manually tracked', code: 'reserved_event', }, ], 'Cannot track reserved system event', ); expect(error.code).toBe(422); }); it('should reject any email.* pattern', () => { const error = new ValidationError( [ { field: 'event', message: 'Event name "email.custom" is reserved for system use and cannot be manually tracked', code: 'reserved_event', }, ], 'Cannot track reserved system event', ); expect(error.code).toBe(422); }); }); describe('Contact events', () => { it('should reject contact.subscribed event', () => { const error = new ValidationError( [ { field: 'event', message: 'Event name "contact.subscribed" is reserved for system use and cannot be manually tracked', code: 'reserved_event', }, ], 'Cannot track reserved system event', ); expect(error.code).toBe(422); expect(error.errors[0]?.field).toBe('event'); }); it('should reject contact.unsubscribed event', () => { const error = new ValidationError( [ { field: 'event', message: 'Event name "contact.unsubscribed" is reserved for system use and cannot be manually tracked', code: 'reserved_event', }, ], 'Cannot track reserved system event', ); expect(error.code).toBe(422); }); it('should allow other contact.* events', () => { const result1 = ActionSchemas.track.safeParse({ event: 'contact.created', email: 'test@example.com', }); const result2 = ActionSchemas.track.safeParse({ event: 'contact.updated', email: 'test@example.com', }); expect(result1.success).toBe(true); expect(result2.success).toBe(true); }); }); describe('Segment events', () => { it('should reject segment.*.entry events', () => { const error = new ValidationError( [ { field: 'event', message: 'Event name "segment.vip-users.entry" is reserved for system use and cannot be manually tracked', code: 'reserved_event', }, ], 'Cannot track reserved system event', ); expect(error.code).toBe(422); expect(error.errors[0]?.code).toBe('reserved_event'); }); it('should reject segment.*.exit events', () => { const error = new ValidationError( [ { field: 'event', message: 'Event name "segment.premium.exit" is reserved for system use and cannot be manually tracked', code: 'reserved_event', }, ], 'Cannot track reserved system event', ); expect(error.code).toBe(422); }); it('should allow other segment.* events', () => { const result1 = ActionSchemas.track.safeParse({ event: 'segment.created', email: 'test@example.com', }); const result2 = ActionSchemas.track.safeParse({ event: 'segment.premium.updated', email: 'test@example.com', }); expect(result1.success).toBe(true); expect(result2.success).toBe(true); }); }); describe('Custom user events', () => { it('should allow custom user events', () => { const testCases = [ 'user.signup', 'purchase.completed', 'order.placed', 'custom.event', 'product.viewed', 'cart.abandoned', ]; for (const eventName of testCases) { const result = ActionSchemas.track.safeParse({ event: eventName, email: 'test@example.com', }); expect(result.success).toBe(true); } }); it('should allow events with similar but different prefixes', () => { const testCases = ['emails.sent', 'contacts.subscribed', 'segments.entry']; for (const eventName of testCases) { const result = ActionSchemas.track.safeParse({ event: eventName, email: 'test@example.com', }); expect(result.success).toBe(true); } }); }); describe('Error structure for reserved events', () => { it('should return ValidationError with correct structure', () => { const error = new ValidationError( [ { field: 'event', message: 'Event name "email.sent" is reserved for system use and cannot be manually tracked', code: 'reserved_event', received: 'email.sent', }, ], 'Cannot track reserved system event', ); expect(error).toBeInstanceOf(ValidationError); expect(error.code).toBe(422); expect(error.errorCode).toBe(ErrorCode.VALIDATION_ERROR); expect(error.message).toBe('Cannot track reserved system event'); expect(error.errors).toHaveLength(1); expect(error.errors[0]).toMatchObject({ field: 'event', code: 'reserved_event', received: 'email.sent', }); }); }); }); // ======================================== // SUBSCRIPTION STATUS PRESERVATION // ======================================== describe('Subscription Status Preservation', () => { describe('/v1/send endpoint', () => { it('should NOT change subscription status when sending to subscribed contact without subscribed field', async () => { // Create a subscribed contact const contact = await factories.createContact({ projectId, subscribed: true, email: 'subscribed@example.com', }); // Verify initial state expect(contact.subscribed).toBe(true); // Send transactional email without specifying subscribed field await EmailService.sendTransactionalEmail({ projectId, contactId: contact.id, subject: 'Test', body: 'Test', from: 'test@example.com', }); // Verify subscription status unchanged const updatedContact = await prisma.contact.findUnique({ where: {id: contact.id}, }); expect(updatedContact?.subscribed).toBe(true); }); it('should NOT change subscription status when sending to unsubscribed contact without subscribed field', async () => { // Create an unsubscribed contact const contact = await factories.createContact({ projectId, subscribed: false, email: 'unsubscribed@example.com', }); // Verify initial state expect(contact.subscribed).toBe(false); // Send transactional email without specifying subscribed field await EmailService.sendTransactionalEmail({ projectId, contactId: contact.id, subject: 'Test', body: 'Test', from: 'test@example.com', }); // Verify subscription status unchanged (should still be false) const updatedContact = await prisma.contact.findUnique({ where: {id: contact.id}, }); expect(updatedContact?.subscribed).toBe(false); }); it('should allow explicit subscription when subscribed=true is provided', async () => { // Create an unsubscribed contact const contact = await factories.createContact({ projectId, subscribed: false, email: 'resubscribe@example.com', }); // This test would need to be implemented at the controller level // since EmailService.sendTransactionalEmail doesn't accept subscribed parameter // For now, verify the schema allows it const result = ActionSchemas.send.safeParse({ to: contact.email, subject: 'Test', body: 'Test', from: 'test@example.com', subscribed: true, }); expect(result.success).toBe(true); if (result.success) { expect(result.data.subscribed).toBe(true); } }); it('should allow explicit unsubscription when subscribed=false is provided', async () => { // Verify the schema allows explicit false const result = ActionSchemas.send.safeParse({ to: 'test@example.com', subject: 'Test', body: 'Test', from: 'test@example.com', subscribed: false, }); expect(result.success).toBe(true); if (result.success) { expect(result.data.subscribed).toBe(false); } }); it('should default to undefined when subscribed field is omitted', () => { const result = ActionSchemas.send.safeParse({ to: 'test@example.com', subject: 'Test', body: 'Test', from: 'test@example.com', }); expect(result.success).toBe(true); if (result.success) { // Should be undefined, not false expect(result.data.subscribed).toBeUndefined(); } }); }); describe('/v1/track endpoint', () => { it('should NOT change subscription status when tracking event for subscribed contact', async () => { // Create a subscribed contact const contact = await factories.createContact({ projectId, subscribed: true, email: 'track-subscribed@example.com', }); // Verify initial state expect(contact.subscribed).toBe(true); // Track event without specifying subscribed field // This would be done via ContactService.upsert in the track endpoint const {ContactService} = await import('../../services/ContactService.js'); await ContactService.upsert(projectId, contact.email, {event: 'test'}, undefined); // Verify subscription status unchanged const updatedContact = await prisma.contact.findUnique({ where: {id: contact.id}, }); expect(updatedContact?.subscribed).toBe(true); }); it('should NOT re-subscribe unsubscribed contact when tracking event', async () => { // Create an unsubscribed contact const contact = await factories.createContact({ projectId, subscribed: false, email: 'track-unsubscribed@example.com', }); // Verify initial state expect(contact.subscribed).toBe(false); // Track event without specifying subscribed field const {ContactService} = await import('../../services/ContactService.js'); await ContactService.upsert(projectId, contact.email, {event: 'test'}, undefined); // Verify subscription status unchanged (should still be false, NOT re-subscribed) const updatedContact = await prisma.contact.findUnique({ where: {id: contact.id}, }); expect(updatedContact?.subscribed).toBe(false); }); it('should create new contacts as subscribed when subscribed is undefined', async () => { const newEmail = 'new-track-contact@example.com'; // Track event for new contact without specifying subscribed const {ContactService} = await import('../../services/ContactService.js'); const contact = await ContactService.upsert(projectId, newEmail, {event: 'test'}, undefined); // New contacts should default to subscribed=true expect(contact.subscribed).toBe(true); }); it('should allow explicit subscription when subscribed=true is provided', async () => { const result = ActionSchemas.track.safeParse({ event: 'test', email: 'test@example.com', subscribed: true, }); expect(result.success).toBe(true); if (result.success) { expect(result.data.subscribed).toBe(true); } }); it('should allow explicit unsubscription when subscribed=false is provided', async () => { const result = ActionSchemas.track.safeParse({ event: 'test', email: 'test@example.com', subscribed: false, }); expect(result.success).toBe(true); if (result.success) { expect(result.data.subscribed).toBe(false); } }); it('should default to undefined when subscribed field is omitted', () => { const result = ActionSchemas.track.safeParse({ event: 'test', email: 'test@example.com', }); expect(result.success).toBe(true); if (result.success) { // Should be undefined, not true expect(result.data.subscribed).toBeUndefined(); } }); }); describe('ContactService.upsert behavior', () => { it('should preserve subscription status when undefined is passed for existing contact', async () => { // Create a subscribed contact const contact = await factories.createContact({ projectId, subscribed: true, email: 'upsert-test@example.com', }); const {ContactService} = await import('../../services/ContactService.js'); // Update with undefined subscribed await ContactService.upsert(projectId, contact.email, {firstName: 'John'}, undefined); const updated = await prisma.contact.findUnique({ where: {id: contact.id}, }); expect(updated?.subscribed).toBe(true); }); it('should preserve unsubscribed status when undefined is passed', async () => { // Create an unsubscribed contact const contact = await factories.createContact({ projectId, subscribed: false, email: 'upsert-unsub@example.com', }); const {ContactService} = await import('../../services/ContactService.js'); // Update with undefined subscribed await ContactService.upsert(projectId, contact.email, {firstName: 'Jane'}, undefined); const updated = await prisma.contact.findUnique({ where: {id: contact.id}, }); expect(updated?.subscribed).toBe(false); }); it('should allow explicit subscription change to true', async () => { // Create an unsubscribed contact const contact = await factories.createContact({ projectId, subscribed: false, email: 'explicit-sub@example.com', }); const {ContactService} = await import('../../services/ContactService.js'); // Explicitly subscribe await ContactService.upsert(projectId, contact.email, {}, true); const updated = await prisma.contact.findUnique({ where: {id: contact.id}, }); expect(updated?.subscribed).toBe(true); }); it('should allow explicit subscription change to false', async () => { // Create a subscribed contact const contact = await factories.createContact({ projectId, subscribed: true, email: 'explicit-unsub@example.com', }); const {ContactService} = await import('../../services/ContactService.js'); // Explicitly unsubscribe await ContactService.upsert(projectId, contact.email, {}, false); const updated = await prisma.contact.findUnique({ where: {id: contact.id}, }); expect(updated?.subscribed).toBe(false); }); }); }); });