737 lines
22 KiB
TypeScript
737 lines
22 KiB
TypeScript
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: '[email protected]',
|
|
from: '[email protected]',
|
|
// 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: '[email protected]',
|
|
// 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: '[email protected]',
|
|
subject: 'Test',
|
|
body: 'Test',
|
|
from: '[email protected]',
|
|
});
|
|
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
it('should accept from as object with name and email', () => {
|
|
const result = ActionSchemas.send.safeParse({
|
|
to: '[email protected]',
|
|
subject: 'Test',
|
|
body: 'Test',
|
|
from: {
|
|
name: 'John Doe',
|
|
email: '[email protected]',
|
|
},
|
|
});
|
|
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
it('should accept from as object with only email', () => {
|
|
const result = ActionSchemas.send.safeParse({
|
|
to: '[email protected]',
|
|
subject: 'Test',
|
|
body: 'Test',
|
|
from: {
|
|
email: '[email protected]',
|
|
},
|
|
});
|
|
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
it('should reject from object with invalid email', () => {
|
|
const result = ActionSchemas.send.safeParse({
|
|
to: '[email protected]',
|
|
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: '[email protected]',
|
|
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: '[email protected]',
|
|
from: '[email protected]',
|
|
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: '[email protected]',
|
|
},
|
|
from: '[email protected]',
|
|
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: '[email protected]',
|
|
},
|
|
from: '[email protected]',
|
|
subject: 'Test',
|
|
body: 'Test',
|
|
});
|
|
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
it('should accept to as array of strings', () => {
|
|
const result = ActionSchemas.send.safeParse({
|
|
to: ['[email protected]', '[email protected]'],
|
|
from: '[email protected]',
|
|
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: '[email protected]'},
|
|
{name: 'John Smith', email: '[email protected]'},
|
|
],
|
|
from: '[email protected]',
|
|
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: ['[email protected]', {name: 'John Smith', email: '[email protected]'}],
|
|
from: '[email protected]',
|
|
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: '[email protected]'});
|
|
|
|
expect(error.code).toBe(409);
|
|
expect(error.errorCode).toBe(ErrorCode.CONFLICT);
|
|
expect(error.details).toEqual({email: '[email protected]'});
|
|
});
|
|
|
|
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: '[email protected]',
|
|
}),
|
|
).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: '[email protected]',
|
|
});
|
|
|
|
// 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: '[email protected]',
|
|
});
|
|
|
|
const result2 = ActionSchemas.track.safeParse({
|
|
event: 'contact.updated',
|
|
email: '[email protected]',
|
|
});
|
|
|
|
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: '[email protected]',
|
|
});
|
|
|
|
const result2 = ActionSchemas.track.safeParse({
|
|
event: 'segment.premium.updated',
|
|
email: '[email protected]',
|
|
});
|
|
|
|
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: '[email protected]',
|
|
});
|
|
|
|
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: '[email protected]',
|
|
});
|
|
|
|
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',
|
|
});
|
|
});
|
|
});
|
|
});
|
|
});
|