diff --git a/README.md b/README.md index 3d525e1..012b6af 100644 --- a/README.md +++ b/README.md @@ -2,17 +2,21 @@ Node.js Client SDK for Infobip APIs. # Supported Channels +- SMS -> [Docs](https://www.infobip.com/docs/api#channels/sms) ⭐ **Now with v3 API Support** - Whatsapp -> [Docs](https://www.infobip.com/docs/api#channels/whatsapp) - Email -> [Docs](https://www.infobip.com/docs/api#channels/email) -- SMS -> [Docs](https://www.infobip.com/docs/api#channels/sms) #### Table of Contents: - [General Info](#general-info) - [License](#license) - [Installation](#installation) -- [Code example](#code-example) +- [Code Examples](#code-examples) + - [SMS Examples](#sms-examples) + - [WhatsApp Examples](#whatsapp-examples) + - [Email Examples](#email-examples) - [Testing](#testing) +- [Migration Guide](#migration-guide) ## General Info @@ -31,10 +35,187 @@ Install the library by using the following command: npm install @infobip-api/sdk ``` -## Code Example +## Code Examples The package is intended to be used with an Infobip account. If you don't already have one, you can create a free trial account [here](https://www.infobip.com/signup). +### SMS Examples + +#### Basic SMS Sending (v3 API - Recommended) + +The v3 API is the current and recommended way to send SMS messages. It provides a unified interface for both text and binary messages. + +```javascript +import { Infobip, AuthType } from "@infobip-api/sdk"; + +let infobip = new Infobip({ + baseUrl: "YOUR_BASE_URL", + apiKey: "YOUR_API_KEY", + authType: AuthType.ApiKey, +}); + +// Send a simple text message +let response = await infobip.channels.sms.v3.send({ + messages: [{ + from: "InfoSMS", + destinations: [{ to: "+1234567890" }], + text: "Hello World from SMS v3 API!" + }] +}); + +console.log(response); +``` + +#### Advanced SMS Features + +```javascript +// Send SMS with advanced features +let advancedResponse = await infobip.channels.sms.v3.send({ + messages: [{ + from: "InfoSMS", + destinations: [ + { to: "+1234567890", messageId: "msg-1" }, + { to: "+0987654321", messageId: "msg-2" } + ], + text: "Check out our website: https://example.com", + // URL shortening and tracking + urlOptions: { + shortenUrl: true, + trackClicks: true, + customDomain: "short.example.com" + }, + // Delivery time window + deliveryTimeWindow: { + days: ["MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY"], + from: "09:00", + to: "17:00" + }, + // Scheduled sending + sendAt: "2025-12-25T10:00:00.000Z", + // Delivery notifications + notifyUrl: "https://your-webhook.com/sms-delivery", + callbackData: "campaign-123", + // Message validity + validityPeriod: 720, // 12 hours in minutes + // Tracking parameters + applicationId: "app-123", + entityId: "entity-456", + campaignReferenceId: "campaign-789" + }], + includeSmsCountInResponse: true +}); + +console.log(advancedResponse); +``` + +#### Regional Compliance (India DLT) + +```javascript +// Send SMS with India DLT compliance +let indiaSmsResponse = await infobip.channels.sms.v3.send({ + messages: [{ + from: "INFOSMS", + destinations: [{ to: "+911234567890" }], + text: "Your OTP is 123456. Valid for 10 minutes.", + regional: { + indiaDlt: { + principalEntityId: "1234567890123456789", + contentTemplateId: "1234567890123456789" + } + } + }] +}); + +console.log(indiaSmsResponse); +``` + +#### Binary SMS + +```javascript +// Send binary SMS message +let binaryResponse = await infobip.channels.sms.v3.send({ + messages: [{ + from: "+1234567890", + destinations: [{ to: "+0987654321" }], + binary: { + hex: "48656C6C6F20576F726C6421", // "Hello World!" in hex + dataCoding: 0, + esmClass: 0 + } + }] +}); + +console.log(binaryResponse); +``` + +#### Get Delivery Reports (v3 API) + +```javascript +// Get delivery reports with filtering +let reports = await infobip.channels.sms.v3.getReports({ + bulkId: "bulk-123", + limit: 100, + deliveryStatus: "DELIVERED", + sentSince: "2025-10-01T00:00:00.000Z", + sentUntil: "2025-10-10T23:59:59.999Z" +}); + +console.log(reports); +``` + +#### Get Message Logs (v3 API) + +```javascript +// Get message logs with filtering +let logs = await infobip.channels.sms.v3.getLogs({ + from: "InfoSMS", + generalStatus: "DELIVERED", + limit: 500, + sentSince: "2025-10-01T00:00:00.000Z" +}); + +console.log(logs); +``` + +#### Error Handling + +```javascript +import { + SmsValidationError, + SmsApiError, + SmsRateLimitError, + SmsNetworkError +} from "@infobip-api/sdk"; + +try { + let response = await infobip.channels.sms.v3.send({ + messages: [{ + from: "InfoSMS", + destinations: [{ to: "+1234567890" }], + text: "Hello World!" + }] + }); + console.log(response); +} catch (error) { + if (error instanceof SmsValidationError) { + console.error('Validation Error:', error.message); + console.error('Field:', error.details?.field); + } else if (error instanceof SmsRateLimitError) { + console.error('Rate Limit Exceeded. Retry after:', error.retryAfter, 'seconds'); + } else if (error instanceof SmsApiError) { + console.error('API Error:', error.message); + console.error('Status Code:', error.statusCode); + console.error('API Error Code:', error.apiErrorCode); + } else if (error instanceof SmsNetworkError) { + console.error('Network Error:', error.message); + } else { + console.error('Unexpected Error:', error.message); + } +} +``` + +### WhatsApp Examples + This example shows you how to send a WhatsApp text message. The first step is to import the `Infobip` and `AuthType` dependencies. ```javascript @@ -67,7 +248,7 @@ let response = await infobip.channels.whatsapp.send({ console.log(response); ``` -### E-mail Attachment Example +### Email Examples When sending an E-mail with an attachment or inline image, you'll need to follow the below process @@ -110,6 +291,51 @@ To run tests position yourself in the project's root after you've installed depe npm run test ``` +## Migration Guide + +### Migrating from SMS v2 to v3 API + +The SMS v2 API endpoints (`/sms/2/text/advanced` and `/sms/2/binary/advanced`) were deprecated on October 9, 2024. Please migrate to the v3 unified API for continued support and access to new features. + +#### Before (v2 - Deprecated) +```javascript +// Old way - deprecated +let response = await infobip.channels.sms.send({ + messages: [{ + from: "InfoSMS", + destinations: [{ to: "+1234567890" }], + text: "Hello World!" + }] +}); +``` + +#### After (v3 - Recommended) +```javascript +// New way - recommended +let response = await infobip.channels.sms.v3.send({ + messages: [{ + from: "InfoSMS", + destinations: [{ to: "+1234567890" }], + text: "Hello World!" + }] +}); +``` + +#### Key Benefits of v3 API: +- **Unified Interface**: Single endpoint for both text and binary messages +- **Enhanced Features**: URL tracking, advanced scheduling, regional compliance +- **Better Error Handling**: Specific error types with detailed information +- **Type Safety**: Full TypeScript support with comprehensive interfaces +- **Future-Proof**: Active development and new feature additions + +#### Breaking Changes: +- Response format may differ slightly +- Some legacy parameters may not be supported +- Error responses follow new format + +#### Backward Compatibility: +The legacy `send()` method is still available but will show deprecation warnings. It will be removed in a future major version. + ## Building & Installing a Local Version To build the project for the first time, position yourself in the project's root and run: diff --git a/package.json b/package.json index 2a8d7c8..ae5a6d0 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "version": "0.3.2", + "version": "0.4.0", "license": "MIT", "main": "dist/index.js", "typings": "dist/index.d.ts", diff --git a/src/apis/sms.ts b/src/apis/sms.ts index 4dc1793..b102010 100644 --- a/src/apis/sms.ts +++ b/src/apis/sms.ts @@ -1,20 +1,31 @@ import { Http } from '../utils/http'; import { InfobipAuth } from '../utils/auth'; import { validateSMSMessage } from '../utils/validators/sms'; +import { validateSmsV3SendRequest, validateSmsReportsQuery, validateSmsLogsQuery } from '../utils/validators/sms-v3'; +import { SendSmsV3Request, SendSmsV3Response, SmsReportsQuery, SmsLogsQuery } from '../types/sms'; +import { createSmsErrorFromResponse, SmsError, SmsApiError } from '../errors/sms-errors'; import { Validator } from '../utils/validator'; const sendEndpoints: any = { - text: '/sms/2/text/advanced', - binary: '/sms/2/binary/advanced', - query: '/sms/1/text/query', + text: '/sms/2/text/advanced', // DEPRECATED - Use v3 unified endpoint + binary: '/sms/2/binary/advanced', // DEPRECATED - Use v3 unified endpoint + query: '/sms/1/text/query', // DEPRECATED - Use v3 query endpoint +}; + +// v3 API endpoints (current/recommended) +const v3Endpoints = { + messages: '/sms/3/messages', // Unified endpoint for text and binary messages + query: '/sms/3/text/query', + reports: '/sms/3/reports', + logs: '/sms/3/logs', }; const endpoints: any = { preview: '/sms/1/preview', get: '/sms/1/inbox/reports', - reports: '/sms/1/reports', - logs: '/sms/1/logs', + reports: '/sms/1/reports', // DEPRECATED - Use v3 reports endpoint + logs: '/sms/1/logs', // DEPRECATED - Use v3 logs endpoint schedule: '/sms/1/bulks', status: '/sms/1/bulks/status', }; @@ -27,12 +38,25 @@ class SMS { logs: any; scheduled: any; status: any; + // v3 API methods (recommended) + v3: { + send: (request: SendSmsV3Request) => Promise; + sendQuery: (query: any) => Promise; + getReports: (query?: SmsReportsQuery) => Promise; + getLogs: (query?: SmsLogsQuery) => Promise; + }; + // Bulk operations + bulk: { + cancel: (bulkId: string) => Promise; + getStatus: (bulkIds: string[]) => Promise; + }; constructor(credentials: InfobipAuth) { this.http = new Http(credentials.baseUrl, credentials.authorization); this.username = credentials.username; this.password = credentials.password; + // Legacy API methods (deprecated but maintained for backward compatibility) this.reports = { get: this.getDeliveryReports.bind(this), }; @@ -47,9 +71,95 @@ class SMS { get: this.getMessageStatus.bind(this), update: this.updateMessageStatus.bind(this), }; + + // v3 API methods (current/recommended) + this.v3 = { + send: this.sendV3.bind(this), + sendQuery: this.sendQueryV3.bind(this), + getReports: this.getDeliveryReportsV3.bind(this), + getLogs: this.getMessageLogsV3.bind(this), + }; + + // Bulk operations + this.bulk = { + cancel: this.cancelScheduledMessages.bind(this), + getStatus: this.getBulkStatus.bind(this), + }; + } + + /** + * Send SMS messages using the v3 unified API (RECOMMENDED) + * + * This method uses the current Infobip SMS v3 API which supports both text and binary messages + * through a single unified endpoint. This is the recommended method for sending SMS messages. + * + * @param request - SMS v3 send request containing messages and options + * @returns Promise - Response with bulk ID and message statuses + * + * @example + * ```typescript + * const response = await sms.sendV3({ + * messages: [{ + * from: "InfoSMS", + * destinations: [{ to: "+1234567890" }], + * text: "Hello World!" + * }] + * }); + * ``` + */ + async sendV3(request: SendSmsV3Request): Promise { + try { + // Validate request using comprehensive v3 validators + validateSmsV3SendRequest(request); + + // Send request to v3 unified endpoint + const response = await this.http.post(v3Endpoints.messages, request); + + return response.data as SendSmsV3Response; + } catch (error) { + // Handle different types of errors appropriately + if (error instanceof SmsError) { + throw error; + } + + // Handle HTTP errors from the API + if ((error as any).response) { + const smsError = createSmsErrorFromResponse( + (error as any).response.status, + (error as any).response.data, + error + ); + throw smsError; + } + + // Handle network errors + if ((error as any).request) { + const { SmsNetworkError } = await import('../errors/sms-errors'); + throw new SmsNetworkError('Network error occurred while sending SMS', error as Error); + } + + // Handle other errors + throw new SmsApiError('Unexpected error occurred', 500, 'SMS_UNKNOWN_ERROR', (error as Error).message); + } } - async send(message: any) { + /** + * Send SMS messages (DEPRECATED - Use sendV3 instead) + * + * @deprecated This method uses deprecated v2 API endpoints. Use sendV3() instead. + * The v2 endpoints (/sms/2/text/advanced and /sms/2/binary/advanced) were deprecated on 2024-10-09. + * + * @param message - SMS message object + * @returns Promise - API response + */ + async send(message: any): Promise { + // Add deprecation warning + console.warn( + '⚠️ DEPRECATION WARNING: The send() method uses deprecated v2 API endpoints. ' + + 'Please migrate to sendV3() which uses the current v3 unified API. ' + + 'The v2 endpoints were deprecated on 2024-10-09 and may be removed in future versions.' + ); + try { if (!message.type) message.type = 'text'; if (!sendEndpoints[message.type]) @@ -181,7 +291,213 @@ class SMS { } } + /** + * Get SMS delivery reports using v3 API (RECOMMENDED) + * + * Retrieves delivery reports for sent SMS messages using the enhanced v3 API + * with comprehensive filtering options. + * + * @param query - Query parameters for filtering reports + * @returns Promise - Delivery reports response + */ + async getDeliveryReportsV3(query: SmsReportsQuery = {}): Promise { + try { + // Validate query parameters + validateSmsReportsQuery(query); + + const response = await this.http.get(v3Endpoints.reports, query); + return response.data; + } catch (error) { + if (error instanceof SmsError) { + throw error; + } + + if ((error as any).response) { + const smsError = createSmsErrorFromResponse( + (error as any).response.status, + (error as any).response.data, + error + ); + throw smsError; + } + + const { SmsNetworkError } = await import('../errors/sms-errors'); + throw new SmsNetworkError('Network error occurred while fetching delivery reports', error as Error); + } + } + + /** + * Get SMS message logs using v3 API (RECOMMENDED) + * + * Retrieves message logs for sent SMS messages using the enhanced v3 API + * with comprehensive filtering options. Note: Only retrieves messages from the last 48 hours, + * with a maximum of 1000 records per call. + * + * @param query - Query parameters for filtering logs + * @returns Promise - Message logs response + */ + async getMessageLogsV3(query: SmsLogsQuery = {}): Promise { + try { + // Validate query parameters + validateSmsLogsQuery(query); + + const response = await this.http.get(v3Endpoints.logs, query); + return response.data; + } catch (error) { + if (error instanceof SmsError) { + throw error; + } + + if ((error as any).response) { + const smsError = createSmsErrorFromResponse( + (error as any).response.status, + (error as any).response.data, + error + ); + throw smsError; + } + + const { SmsNetworkError } = await import('../errors/sms-errors'); + throw new SmsNetworkError('Network error occurred while fetching message logs', error); + } + } + + /** + * Cancel scheduled SMS messages (DELETE bulk operation) + * + * Cancels all scheduled messages associated with the given bulk ID. + * Only messages that are still pending can be cancelled. + * + * @param bulkId - ID of the bulk to cancel + * @returns Promise - Cancellation response + */ + async cancelScheduledMessages(bulkId: string): Promise { + try { + if (!bulkId || typeof bulkId !== 'string') { + const { SmsValidationError } = await import('../errors/sms-errors'); + throw new SmsValidationError('bulkId is required and must be a string'); + } + + const response = await this.http.delete(`${endpoints.schedule}/${bulkId}`); + return response.data; + } catch (error) { + if (error instanceof SmsError) { + throw error; + } + + if ((error as any).response) { + const smsError = createSmsErrorFromResponse( + (error as any).response.status, + (error as any).response.data, + error + ); + throw smsError; + } + + const { SmsNetworkError } = await import('../errors/sms-errors'); + throw new SmsNetworkError('Network error occurred while cancelling scheduled messages', error); + } + } + + /** + * Get status of multiple bulk IDs + * + * @param bulkIds - Array of bulk IDs to check + * @returns Promise - Status information for all bulk IDs + */ + async getBulkStatus(bulkIds: string[]): Promise { + try { + if (!Array.isArray(bulkIds) || bulkIds.length === 0) { + const { SmsValidationError } = await import('../errors/sms-errors'); + throw new SmsValidationError('bulkIds must be a non-empty array'); + } + + const bulkIdParam = bulkIds.join(','); + const response = await this.http.get(endpoints.status, { bulkId: bulkIdParam }); + return response.data; + } catch (error) { + if (error instanceof SmsError) { + throw error; + } + + if ((error as any).response) { + const smsError = createSmsErrorFromResponse( + (error as any).response.status, + (error as any).response.data, + error + ); + throw smsError; + } + + const { SmsNetworkError } = await import('../errors/sms-errors'); + throw new SmsNetworkError('Network error occurred while fetching bulk status', error); + } + } + + /** + * Send SMS via query parameters using v3 API (RECOMMENDED) + * + * @deprecated The query-based sending method is generally not recommended. + * Use sendV3() with POST method instead for better reliability and features. + * + * @param query - Query parameters for sending SMS + * @returns Promise - Send response + */ + async sendQueryV3(query: any): Promise { + console.warn( + '⚠️ WARNING: Query-based SMS sending is not recommended. ' + + 'Use sendV3() with POST method instead for better reliability and access to all features.' + ); + + try { + // Basic validation for query parameters + if (!query.from) { + const { SmsValidationError } = await import('../errors/sms-errors'); + throw new SmsValidationError('from parameter is required'); + } + + if (!query.to) { + const { SmsValidationError } = await import('../errors/sms-errors'); + throw new SmsValidationError('to parameter is required'); + } + + if (!query.text) { + const { SmsValidationError } = await import('../errors/sms-errors'); + throw new SmsValidationError('text parameter is required'); + } + + const response = await this.http.get(v3Endpoints.query, query); + return response.data; + } catch (error) { + if (error instanceof SmsError) { + throw error; + } + + if ((error as any).response) { + const smsError = createSmsErrorFromResponse( + (error as any).response.status, + (error as any).response.data, + error + ); + throw smsError; + } + + const { SmsNetworkError } = await import('../errors/sms-errors'); + throw new SmsNetworkError('Network error occurred while sending SMS via query', error); + } + } + + /** + * Get delivery reports (DEPRECATED - Use getDeliveryReportsV3 instead) + * + * @deprecated This method uses deprecated v1 API endpoint. Use getDeliveryReportsV3() instead. + */ private async getDeliveryReports(filter: any) { + console.warn( + '⚠️ DEPRECATION WARNING: This method uses deprecated v1 API endpoint. ' + + 'Use getDeliveryReportsV3() instead for enhanced filtering and better performance.' + ); + try { const response = await this.http.get(endpoints.reports, filter); return response; @@ -190,7 +506,17 @@ class SMS { } } + /** + * Get message logs (DEPRECATED - Use getMessageLogsV3 instead) + * + * @deprecated This method uses deprecated v1 API endpoint. Use getMessageLogsV3() instead. + */ private async getMessageLogs(filter: any) { + console.warn( + '⚠️ DEPRECATION WARNING: This method uses deprecated v1 API endpoint. ' + + 'Use getMessageLogsV3() instead for enhanced filtering and better performance.' + ); + try { const response = await this.http.get(endpoints.logs, filter); return response; diff --git a/src/errors/sms-errors.ts b/src/errors/sms-errors.ts new file mode 100644 index 0000000..1ce8820 --- /dev/null +++ b/src/errors/sms-errors.ts @@ -0,0 +1,227 @@ +/** + * SMS-specific Error Classes + * + * This file contains all SMS-specific error classes for better error handling + * and debugging in the Infobip SMS SDK. + * + * @author SMS Modernization Team + * @version 1.0.0 + */ + +/** + * Base SMS Error class + */ +export abstract class SmsError extends Error { + public readonly code: string; + public readonly statusCode?: number; + public readonly details?: any; + + constructor(message: string, code: string, statusCode?: number, details?: any) { + super(message); + this.name = this.constructor.name; + this.code = code; + this.statusCode = statusCode; + this.details = details; + + // Maintains proper stack trace for where our error was thrown + if (Error.captureStackTrace) { + Error.captureStackTrace(this, this.constructor); + } + } +} + +/** + * SMS Validation Error - thrown when request parameters are invalid + */ +export class SmsValidationError extends SmsError { + constructor(message: string, field?: string, value?: any) { + super( + `SMS Validation Error: ${message}`, + 'SMS_VALIDATION_ERROR', + 400, + { field, value } + ); + } +} + +/** + * SMS API Error - thrown when API returns an error response + */ +export class SmsApiError extends SmsError { + public readonly apiErrorCode?: string; + public readonly apiErrorDescription?: string; + + constructor( + message: string, + statusCode: number, + apiErrorCode?: string, + apiErrorDescription?: string, + details?: any + ) { + super( + `SMS API Error: ${message}`, + 'SMS_API_ERROR', + statusCode, + details + ); + this.apiErrorCode = apiErrorCode; + this.apiErrorDescription = apiErrorDescription; + } +} + +/** + * SMS Network Error - thrown when network-related issues occur + */ +export class SmsNetworkError extends SmsError { + constructor(message: string, originalError?: Error) { + super( + `SMS Network Error: ${message}`, + 'SMS_NETWORK_ERROR', + undefined, + { originalError: originalError?.message } + ); + } +} + +/** + * SMS Rate Limit Error - thrown when API rate limits are exceeded + */ +export class SmsRateLimitError extends SmsError { + public readonly retryAfter?: number; + + constructor(message: string, retryAfter?: number) { + super( + `SMS Rate Limit Error: ${message}`, + 'SMS_RATE_LIMIT_ERROR', + 429, + { retryAfter } + ); + this.retryAfter = retryAfter; + } +} + +/** + * SMS Timeout Error - thrown when requests timeout + */ +export class SmsTimeoutError extends SmsError { + constructor(message: string, timeout: number) { + super( + `SMS Timeout Error: ${message}`, + 'SMS_TIMEOUT_ERROR', + 408, + { timeout } + ); + } +} + +/** + * SMS Authentication Error - thrown when authentication fails + */ +export class SmsAuthenticationError extends SmsError { + constructor(message: string) { + super( + `SMS Authentication Error: ${message}`, + 'SMS_AUTHENTICATION_ERROR', + 401 + ); + } +} + +/** + * SMS Configuration Error - thrown when SDK configuration is invalid + */ +export class SmsConfigurationError extends SmsError { + constructor(message: string, configField?: string) { + super( + `SMS Configuration Error: ${message}`, + 'SMS_CONFIGURATION_ERROR', + undefined, + { configField } + ); + } +} + +/** + * Error code mapping for Infobip API error codes + */ +export const SMS_ERROR_CODE_MAP: Record = { + // Authentication errors + 'UNAUTHORIZED': 'Invalid API key or authentication credentials', + 'FORBIDDEN': 'Access denied - insufficient permissions', + + // Validation errors + 'BAD_REQUEST': 'Invalid request parameters', + 'INVALID_DESTINATION': 'Invalid destination phone number format', + 'INVALID_SENDER': 'Invalid sender ID or phone number', + 'MESSAGE_TOO_LONG': 'Message text exceeds maximum length', + 'INVALID_VALIDITY_PERIOD': 'Invalid validity period value', + 'INVALID_SEND_TIME': 'Invalid scheduled send time', + + // Rate limiting + 'TOO_MANY_REQUESTS': 'API rate limit exceeded - please retry later', + + // Service errors + 'INTERNAL_SERVER_ERROR': 'Internal server error - please retry', + 'SERVICE_UNAVAILABLE': 'SMS service temporarily unavailable', + 'GATEWAY_TIMEOUT': 'Request timeout - please retry', + + // Business logic errors + 'INSUFFICIENT_CREDITS': 'Insufficient account credits to send message', + 'INVALID_TEMPLATE': 'Invalid or non-existent message template', + 'REGIONAL_RESTRICTION': 'Message blocked due to regional restrictions', + 'SPAM_DETECTED': 'Message blocked due to spam detection', + + // Network errors + 'NETWORK_ERROR': 'Network connectivity issue', + 'DNS_ERROR': 'DNS resolution failed', + 'CONNECTION_TIMEOUT': 'Connection timeout', + 'SSL_ERROR': 'SSL/TLS connection error' +}; + +/** + * Utility function to create appropriate error from API response + */ +export function createSmsErrorFromResponse( + statusCode: number, + responseBody: any, + originalError?: Error +): SmsError { + const errorCode = responseBody?.requestError?.serviceException?.messageId; + const errorText = responseBody?.requestError?.serviceException?.text; + const errorMessage = SMS_ERROR_CODE_MAP[errorCode] || errorText || 'Unknown API error'; + + switch (statusCode) { + case 400: + return new SmsValidationError(errorMessage); + case 401: + return new SmsAuthenticationError(errorMessage); + case 429: + const retryAfter = responseBody?.requestError?.serviceException?.retryAfter; + return new SmsRateLimitError(errorMessage, retryAfter); + case 408: + case 504: + return new SmsTimeoutError(errorMessage, 30000); + case 500: + case 502: + case 503: + return new SmsApiError(errorMessage, statusCode, errorCode, errorText, responseBody); + default: + if (originalError && originalError.message.includes('network')) { + return new SmsNetworkError(errorMessage, originalError); + } + return new SmsApiError(errorMessage, statusCode, errorCode, errorText, responseBody); + } +} + +/** + * Utility function to determine if an error is retryable + */ +export function isSmsErrorRetryable(error: SmsError): boolean { + if (error instanceof SmsRateLimitError) return true; + if (error instanceof SmsTimeoutError) return true; + if (error instanceof SmsNetworkError) return true; + if (error instanceof SmsApiError) { + return error.statusCode === 500 || error.statusCode === 502 || error.statusCode === 503; + } + return false; +} diff --git a/src/index.ts b/src/index.ts index 4bd27e0..35880c0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,6 +15,86 @@ import { TwoFAVerificationStatus, } from './models/2fa-models'; +// Export SMS v3 types and enums +export { + SendSmsV3Request, + SendSmsV3Response, + BaseSmsMessage, + SmsDestination, + BinaryContent, + SmsReportsQuery, + SmsLogsQuery, + SmsDeliveryReport, + SmsMessageLog, + MessageType, + DeliveryStatus, + GeneralStatus, + LanguageCode, + Transliteration, + UrlOptions, + RegionalOptions, + IndiaDltOptions, + DeliveryTimeWindow +} from './types/sms'; + +// Export SMS error classes +export { + SmsError, + SmsValidationError, + SmsApiError, + SmsNetworkError, + SmsRateLimitError, + SmsTimeoutError, + SmsAuthenticationError, + SmsConfigurationError, + createSmsErrorFromResponse, + isSmsErrorRetryable +} from './errors/sms-errors'; + +// Export SMS webhook utilities +export { + SmsWebhookPayload, + WebhookVerificationOptions, + verifyWebhookSignature, + parseWebhookPayload, + extractDeliveryStatuses, + filterReportsByStatus, + getFailedDeliveries, + getSuccessfulDeliveries, + calculateDeliveryStats, + createWebhookMiddleware +} from './utils/sms-webhook'; + +// Export SMS utility functions +export { + MessageEncoding, + MessagePartInfo, + PhoneNumberInfo, + detectMessageEncoding, + calculateMessageParts, + formatPhoneNumber, + isValidPhoneNumber, + isValidSenderId, + estimateSmsCost, + formatSendAtDateTime, + isValidNotifyUrl, + generateMessageId, + splitTextIntoParts, + isValidDeliveryTimeWindow +} from './utils/sms-utils'; + +// Export SMS pagination utilities +export { + PaginationOptions, + PaginatedResponse, + SmsReportsPaginator, + SmsLogsPaginator, + createReportsPaginator, + createLogsPaginator, + getAllReports, + getAllLogs +} from './utils/sms-pagination'; + class Infobip { /** * diff --git a/src/types/sms.ts b/src/types/sms.ts new file mode 100644 index 0000000..2c3c458 --- /dev/null +++ b/src/types/sms.ts @@ -0,0 +1,373 @@ +/** + * SMS API v3 TypeScript Interfaces + * + * This file contains all TypeScript interfaces for the SMS v3 unified API + * following the Infobip SMS REST API specification. + * + * @author SMS Modernization Team + * @version 1.0.0 + */ + +/** + * SMS Message Types + */ +export enum MessageType { + TEXT = 'text', + BINARY = 'binary' +} + +/** + * Delivery Status for SMS Reports + */ +export enum DeliveryStatus { + PENDING = 'PENDING', + UNDELIVERABLE = 'UNDELIVERABLE', + DELIVERED = 'DELIVERED', + EXPIRED = 'EXPIRED', + UNKNOWN = 'UNKNOWN', + REJECTED = 'REJECTED' +} + +/** + * General Status for SMS Logs + */ +export enum GeneralStatus { + ACCEPTED = 'ACCEPTED', + PENDING = 'PENDING', + UNDELIVERABLE = 'UNDELIVERABLE', + DELIVERED = 'DELIVERED', + EXPIRED = 'EXPIRED', + UNKNOWN = 'UNKNOWN', + REJECTED = 'REJECTED' +} + +/** + * Language Codes for SMS + */ +export enum LanguageCode { + AUTODETECT = 'AUTODETECT', + TR = 'TR', + ES = 'ES', + PT = 'PT', + RU = 'RU' +} + +/** + * Transliteration Options + */ +export enum Transliteration { + TURKISH = 'TURKISH', + GREEK = 'GREEK', + CYRILLIC = 'CYRILLIC', + SERBIAN_CYRILLIC = 'SERBIAN_CYRILLIC', + BULGARIAN_CYRILLIC = 'BULGARIAN_CYRILLIC', + CENTRAL_EUROPEAN = 'CENTRAL_EUROPEAN', + BALTIC = 'BALTIC', + NON_UNICODE = 'NON_UNICODE' +} + +/** + * Destination for SMS message + */ +export interface SmsDestination { + /** Recipient phone number in E.164 format */ + to: string; + /** Message ID for tracking */ + messageId?: string; +} + +/** + * Language configuration for SMS + */ +export interface SmsLanguage { + /** Language code for the message */ + languageCode: LanguageCode; +} + +/** + * Regional compliance configuration for India DLT + */ +export interface IndiaDltOptions { + /** Principal Entity ID for India DLT compliance */ + principalEntityId: string; + /** Content Template ID for India DLT compliance */ + contentTemplateId: string; +} + +/** + * Regional compliance configuration + */ +export interface RegionalOptions { + /** India DLT compliance options */ + indiaDlt?: IndiaDltOptions; +} + +/** + * URL options for link tracking and shortening + */ +export interface UrlOptions { + /** Enable URL shortening */ + shortenUrl?: boolean; + /** Track clicks on URLs */ + trackClicks?: boolean; + /** Custom domain for shortened URLs */ + customDomain?: string; +} + +/** + * Delivery time window configuration + */ +export interface DeliveryTimeWindow { + /** Days of the week when delivery is allowed */ + days: string[]; + /** Start time for delivery window */ + from?: string; + /** End time for delivery window */ + to?: string; +} + +/** + * Binary message content + */ +export interface BinaryContent { + /** Hexadecimal representation of binary data */ + hex: string; + /** Data coding scheme */ + dataCoding?: number; + /** ESM class */ + esmClass?: number; +} + +/** + * Base SMS message interface + */ +export interface BaseSmsMessage { + /** Sender ID or phone number */ + from: string; + /** List of message destinations */ + destinations: SmsDestination[]; + /** Message text content (for text messages) */ + text?: string; + /** Binary message content (for binary messages) */ + binary?: BinaryContent; + /** Flash SMS indicator */ + flash?: boolean; + /** Language configuration */ + language?: SmsLanguage; + /** Transliteration option */ + transliteration?: Transliteration; + /** Delivery time window */ + deliveryTimeWindow?: DeliveryTimeWindow; + /** Scheduled send time */ + sendAt?: string; + /** Message validity period in minutes */ + validityPeriod?: number; + /** Callback data */ + callbackData?: string; + /** Notification URL for delivery reports */ + notifyUrl?: string; + /** Content template ID for pre-approved templates */ + contentTemplateId?: string; + /** Regional compliance options */ + regional?: RegionalOptions; + /** URL options for tracking */ + urlOptions?: UrlOptions; + /** Application ID for tracking */ + applicationId?: string; + /** Entity ID for tracking */ + entityId?: string; + /** Platform ID */ + platformId?: string; + /** Campaign reference ID */ + campaignReferenceId?: string; + /** Tracking type */ + trackingType?: string; + /** Track parameter */ + track?: string; +} + +/** + * SMS v3 unified send request + */ +export interface SendSmsV3Request { + /** Array of SMS messages */ + messages: BaseSmsMessage[]; + /** Include SMS count in response */ + includeSmsCountInResponse?: boolean; +} + +/** + * SMS message status information + */ +export interface SmsMessageStatus { + /** Message ID */ + messageId: string; + /** Message status */ + status: { + /** Group ID */ + groupId: number; + /** Group name */ + groupName: string; + /** Status ID */ + id: number; + /** Status name */ + name: string; + /** Status description */ + description: string; + }; + /** Destination phone number */ + to: string; + /** SMS count */ + smsCount?: number; +} + +/** + * SMS v3 send response + */ +export interface SendSmsV3Response { + /** Bulk ID for the sent messages */ + bulkId: string; + /** Array of message statuses */ + messages: SmsMessageStatus[]; +} + +/** + * Query parameters for SMS reports + */ +export interface SmsReportsQuery { + /** Bulk ID filter */ + bulkId?: string; + /** Message ID filter */ + messageId?: string; + /** Maximum number of results */ + limit?: number; + /** Start date for filtering */ + sentSince?: string; + /** End date for filtering */ + sentUntil?: string; + /** Delivery status filter */ + deliveryStatus?: DeliveryStatus; + /** Sender address filter */ + from?: string; + /** Recipient number filter */ + to?: string; + /** Mobile Country Code filter */ + mcc?: string; + /** Mobile Network Code filter */ + mnc?: string; + /** Entity ID filter */ + entityId?: string; +} + +/** + * Query parameters for SMS logs + */ +export interface SmsLogsQuery { + /** Sender address filter */ + from?: string; + /** Recipient number filter */ + to?: string; + /** Bulk ID filter */ + bulkId?: string; + /** Message ID filter */ + messageId?: string; + /** General status filter */ + generalStatus?: GeneralStatus; + /** Start date for filtering */ + sentSince?: string; + /** End date for filtering */ + sentUntil?: string; + /** Maximum number of results (max 1000) */ + limit?: number; + /** Mobile Country Code filter */ + mcc?: string; + /** Mobile Network Code filter */ + mnc?: string; + /** Application ID filter */ + applicationId?: string; + /** Entity ID filter */ + entityId?: string; +} + +/** + * SMS delivery report + */ +export interface SmsDeliveryReport { + /** Bulk ID */ + bulkId: string; + /** Message ID */ + messageId: string; + /** Recipient number */ + to: string; + /** Sender address */ + from: string; + /** Message text */ + text: string; + /** Sent timestamp */ + sentAt: string; + /** Done timestamp */ + doneAt: string; + /** SMS count */ + smsCount: number; + /** Message status */ + status: { + groupId: number; + groupName: string; + id: number; + name: string; + description: string; + }; + /** Error information */ + error?: { + groupId: number; + groupName: string; + id: number; + name: string; + description: string; + permanent: boolean; + }; + /** Price information */ + price?: { + pricePerMessage: number; + currency: string; + }; + /** Callback data */ + callbackData?: string; +} + +/** + * SMS message log entry + */ +export interface SmsMessageLog { + /** Message ID */ + messageId: string; + /** Recipient number */ + to: string; + /** Sender address */ + from: string; + /** Message text */ + text: string; + /** Sent timestamp */ + sentAt: string; + /** SMS count */ + smsCount: number; + /** Message status */ + status: { + groupId: number; + groupName: string; + id: number; + name: string; + description: string; + }; + /** Price information */ + price?: { + pricePerMessage: number; + currency: string; + }; + /** Bulk ID */ + bulkId?: string; + /** Application ID */ + applicationId?: string; + /** Entity ID */ + entityId?: string; +} diff --git a/src/utils/sms-pagination.ts b/src/utils/sms-pagination.ts new file mode 100644 index 0000000..6d2e401 --- /dev/null +++ b/src/utils/sms-pagination.ts @@ -0,0 +1,315 @@ +/** + * SMS Pagination Utilities + * + * Helper utilities for paginating through SMS reports and logs + * + * @author SMS Modernization Team + * @version 1.0.0 + */ + +import { SmsReportsQuery, SmsLogsQuery } from '../types/sms'; +import { SmsValidationError } from '../errors/sms-errors'; + +/** + * Pagination options + */ +export interface PaginationOptions { + /** Maximum number of results per page (max 1000) */ + limit?: number; + /** Starting offset for pagination */ + offset?: number; + /** Auto-paginate through all results */ + autoPage?: boolean; + /** Maximum total results to fetch (prevents infinite loops) */ + maxResults?: number; +} + +/** + * Paginated response wrapper + */ +export interface PaginatedResponse { + /** Current page results */ + results: T[]; + /** Current page number (1-based) */ + currentPage: number; + /** Total number of pages */ + totalPages: number; + /** Total number of results */ + totalResults: number; + /** Number of results per page */ + limit: number; + /** Whether there are more pages */ + hasNextPage: boolean; + /** Whether there are previous pages */ + hasPreviousPage: boolean; +} + +/** + * Pagination iterator for SMS reports + */ +export class SmsReportsPaginator { + private smsInstance: any; + private baseQuery: SmsReportsQuery; + private options: PaginationOptions; + private currentOffset: number = 0; + private hasMore: boolean = true; + + constructor(smsInstance: any, query: SmsReportsQuery = {}, options: PaginationOptions = {}) { + this.smsInstance = smsInstance; + this.baseQuery = { ...query }; + this.options = { + limit: 100, + autoPage: false, + maxResults: 10000, + ...options + }; + + if (this.options.limit! > 1000) { + throw new SmsValidationError('Pagination limit cannot exceed 1000'); + } + + this.currentOffset = this.options.offset || 0; + } + + /** + * Get next page of results + */ + async next(): Promise | null> { + if (!this.hasMore) { + return null; + } + + const query = { + ...this.baseQuery, + limit: this.options.limit, + offset: this.currentOffset + }; + + try { + const response = await this.smsInstance.v3.getReports(query); + + const results = response.results || []; + const totalResults = response.totalCount || results.length; + const limit = this.options.limit!; + const currentPage = Math.floor(this.currentOffset / limit) + 1; + const totalPages = Math.ceil(totalResults / limit); + + // Update pagination state + this.currentOffset += limit; + this.hasMore = results.length === limit && this.currentOffset < (this.options.maxResults || Infinity); + + return { + results, + currentPage, + totalPages, + totalResults, + limit, + hasNextPage: this.hasMore, + hasPreviousPage: currentPage > 1 + }; + } catch (error) { + this.hasMore = false; + throw error; + } + } + + /** + * Get all results by auto-paginating + */ + async *getAllResults(): AsyncGenerator { + while (this.hasMore) { + const page = await this.next(); + if (!page || page.results.length === 0) { + break; + } + + for (const result of page.results) { + yield result; + } + } + } + + /** + * Collect all results into an array + */ + async collectAll(): Promise { + const allResults: any[] = []; + + for await (const result of this.getAllResults()) { + allResults.push(result); + + // Safety check to prevent memory issues + if (allResults.length >= (this.options.maxResults || 10000)) { + break; + } + } + + return allResults; + } + + /** + * Reset pagination to start + */ + reset(): void { + this.currentOffset = this.options.offset || 0; + this.hasMore = true; + } +} + +/** + * Pagination iterator for SMS logs + */ +export class SmsLogsPaginator { + private smsInstance: any; + private baseQuery: SmsLogsQuery; + private options: PaginationOptions; + private currentOffset: number = 0; + private hasMore: boolean = true; + + constructor(smsInstance: any, query: SmsLogsQuery = {}, options: PaginationOptions = {}) { + this.smsInstance = smsInstance; + this.baseQuery = { ...query }; + this.options = { + limit: 100, + autoPage: false, + maxResults: 10000, + ...options + }; + + if (this.options.limit! > 1000) { + throw new SmsValidationError('Pagination limit cannot exceed 1000'); + } + + this.currentOffset = this.options.offset || 0; + } + + /** + * Get next page of results + */ + async next(): Promise | null> { + if (!this.hasMore) { + return null; + } + + const query = { + ...this.baseQuery, + limit: this.options.limit, + offset: this.currentOffset + }; + + try { + const response = await this.smsInstance.v3.getLogs(query); + + const results = response.results || []; + const totalResults = response.totalCount || results.length; + const limit = this.options.limit!; + const currentPage = Math.floor(this.currentOffset / limit) + 1; + const totalPages = Math.ceil(totalResults / limit); + + // Update pagination state + this.currentOffset += limit; + this.hasMore = results.length === limit && this.currentOffset < (this.options.maxResults || Infinity); + + return { + results, + currentPage, + totalPages, + totalResults, + limit, + hasNextPage: this.hasMore, + hasPreviousPage: currentPage > 1 + }; + } catch (error) { + this.hasMore = false; + throw error; + } + } + + /** + * Get all results by auto-paginating + */ + async *getAllResults(): AsyncGenerator { + while (this.hasMore) { + const page = await this.next(); + if (!page || page.results.length === 0) { + break; + } + + for (const result of page.results) { + yield result; + } + } + } + + /** + * Collect all results into an array + */ + async collectAll(): Promise { + const allResults: any[] = []; + + for await (const result of this.getAllResults()) { + allResults.push(result); + + // Safety check to prevent memory issues + if (allResults.length >= (this.options.maxResults || 10000)) { + break; + } + } + + return allResults; + } + + /** + * Reset pagination to start + */ + reset(): void { + this.currentOffset = this.options.offset || 0; + this.hasMore = true; + } +} + +/** + * Helper function to create reports paginator + */ +export function createReportsPaginator( + smsInstance: any, + query: SmsReportsQuery = {}, + options: PaginationOptions = {} +): SmsReportsPaginator { + return new SmsReportsPaginator(smsInstance, query, options); +} + +/** + * Helper function to create logs paginator + */ +export function createLogsPaginator( + smsInstance: any, + query: SmsLogsQuery = {}, + options: PaginationOptions = {} +): SmsLogsPaginator { + return new SmsLogsPaginator(smsInstance, query, options); +} + +/** + * Helper function to get all reports with auto-pagination + */ +export async function getAllReports( + smsInstance: any, + query: SmsReportsQuery = {}, + maxResults: number = 10000 +): Promise { + const paginator = createReportsPaginator(smsInstance, query, { maxResults }); + return await paginator.collectAll(); +} + +/** + * Helper function to get all logs with auto-pagination + */ +export async function getAllLogs( + smsInstance: any, + query: SmsLogsQuery = {}, + maxResults: number = 10000 +): Promise { + const paginator = createLogsPaginator(smsInstance, query, { maxResults }); + return await paginator.collectAll(); +} diff --git a/src/utils/sms-utils.ts b/src/utils/sms-utils.ts new file mode 100644 index 0000000..d283bb5 --- /dev/null +++ b/src/utils/sms-utils.ts @@ -0,0 +1,386 @@ +/** + * SMS Utility Functions + * + * Helper utilities for SMS message processing, formatting, and calculations + * + * @author SMS Modernization Team + * @version 1.0.0 + */ + +import { SmsValidationError } from '../errors/sms-errors'; + +/** + * GSM 7-bit character set + */ +const GSM_7BIT_CHARS = /^[A-Za-z0-9 \r\n@£$¥èéùìòÇØøÅåΔ_ΦΓΛΩΠΨΣΘΞÆæßÉ!"#¤%&'()*+,\-./:;<=>?¡ÄÖÑܧ¿äöñüà^{}\\[\]~|€]*$/; + +/** + * Extended GSM characters that count as 2 characters + */ +const GSM_EXTENDED_CHARS = /[\^{}\\\[~\]|€]/g; + +/** + * Message encoding types + */ +export enum MessageEncoding { + GSM_7BIT = 'GSM_7BIT', + UNICODE = 'UNICODE' +} + +/** + * Message part calculation result + */ +export interface MessagePartInfo { + encoding: MessageEncoding; + length: number; + parts: number; + charactersPerPart: number; + remainingCharacters: number; +} + +/** + * Phone number formatting result + */ +export interface PhoneNumberInfo { + original: string; + formatted: string; + isValid: boolean; + country?: string; + type: 'mobile' | 'landline' | 'unknown'; +} + +/** + * Detects the encoding type for SMS message + * + * @param text - Message text + * @returns MessageEncoding - GSM_7BIT or UNICODE + */ +export function detectMessageEncoding(text: string): MessageEncoding { + if (!text) { + return MessageEncoding.GSM_7BIT; + } + + return GSM_7BIT_CHARS.test(text) ? MessageEncoding.GSM_7BIT : MessageEncoding.UNICODE; +} + +/** + * Calculates SMS message parts and character count + * + * @param text - Message text + * @returns MessagePartInfo - Detailed information about message parts + */ +export function calculateMessageParts(text: string): MessagePartInfo { + if (!text) { + return { + encoding: MessageEncoding.GSM_7BIT, + length: 0, + parts: 0, + charactersPerPart: 160, + remainingCharacters: 160 + }; + } + + const encoding = detectMessageEncoding(text); + let effectiveLength = text.length; + + if (encoding === MessageEncoding.GSM_7BIT) { + // Count extended characters as 2 characters + const extendedMatches = text.match(GSM_EXTENDED_CHARS); + if (extendedMatches) { + effectiveLength += extendedMatches.length; + } + + // GSM 7-bit limits + const singlePartLimit = 160; + const multiPartLimit = 153; // 7 characters reserved for UDH + + if (effectiveLength <= singlePartLimit) { + return { + encoding, + length: effectiveLength, + parts: 1, + charactersPerPart: singlePartLimit, + remainingCharacters: singlePartLimit - effectiveLength + }; + } else { + const parts = Math.ceil(effectiveLength / multiPartLimit); + const remainingCharacters = (parts * multiPartLimit) - effectiveLength; + + return { + encoding, + length: effectiveLength, + parts, + charactersPerPart: multiPartLimit, + remainingCharacters + }; + } + } else { + // Unicode limits + const singlePartLimit = 70; + const multiPartLimit = 67; // 3 characters reserved for UDH + + if (effectiveLength <= singlePartLimit) { + return { + encoding, + length: effectiveLength, + parts: 1, + charactersPerPart: singlePartLimit, + remainingCharacters: singlePartLimit - effectiveLength + }; + } else { + const parts = Math.ceil(effectiveLength / multiPartLimit); + const remainingCharacters = (parts * multiPartLimit) - effectiveLength; + + return { + encoding, + length: effectiveLength, + parts, + charactersPerPart: multiPartLimit, + remainingCharacters + }; + } + } +} + +/** + * Formats phone number to E.164 format + * + * @param phoneNumber - Phone number to format + * @param defaultCountryCode - Default country code if not provided + * @returns PhoneNumberInfo - Formatted phone number information + */ +export function formatPhoneNumber(phoneNumber: string, defaultCountryCode?: string): PhoneNumberInfo { + if (!phoneNumber || typeof phoneNumber !== 'string') { + return { + original: phoneNumber || '', + formatted: '', + isValid: false, + type: 'unknown' + }; + } + + // Remove all non-digit characters except + + let cleaned = phoneNumber.replace(/[^\d+]/g, ''); + + // If already in E.164 format + if (cleaned.startsWith('+') && cleaned.length >= 9 && cleaned.length <= 16) { + return { + original: phoneNumber, + formatted: cleaned, + isValid: /^\+[1-9]\d{1,14}$/.test(cleaned), + type: 'mobile' // Assume mobile for international format + }; + } + + // Remove leading + if present + if (cleaned.startsWith('+')) { + cleaned = cleaned.substring(1); + } + + // Add default country code if provided and number doesn't start with country code + if (defaultCountryCode && !cleaned.startsWith(defaultCountryCode.replace('+', ''))) { + cleaned = defaultCountryCode.replace('+', '') + cleaned; + } + + const formatted = '+' + cleaned; + const isValid = /^\+[1-9]\d{1,14}$/.test(formatted); + + return { + original: phoneNumber, + formatted: isValid ? formatted : '', + isValid, + type: 'mobile' // Default to mobile + }; +} + +/** + * Validates phone number format + * + * @param phoneNumber - Phone number to validate + * @returns boolean - True if valid E.164 format + */ +export function isValidPhoneNumber(phoneNumber: string): boolean { + if (!phoneNumber || typeof phoneNumber !== 'string') { + return false; + } + + return /^\+[1-9]\d{7,14}$/.test(phoneNumber); +} + +/** + * Validates sender ID format + * + * @param senderId - Sender ID to validate + * @returns boolean - True if valid sender ID + */ +export function isValidSenderId(senderId: string): boolean { + if (!senderId || typeof senderId !== 'string') { + return false; + } + + // Check if it's a valid phone number (E.164) + if (isValidPhoneNumber(senderId)) { + return true; + } + + // Check if it's a valid alphanumeric sender ID (max 11 characters) + return /^[a-zA-Z0-9\s]{1,11}$/.test(senderId); +} + +/** + * Estimates SMS cost based on message parts and destination + * + * @param text - Message text + * @param destinations - Array of destination phone numbers + * @param costPerPart - Cost per SMS part (default: 0.05) + * @returns Object with cost estimation + */ +export function estimateSmsCost( + text: string, + destinations: string[], + costPerPart: number = 0.05 +): { + totalParts: number; + totalMessages: number; + costPerMessage: number; + totalCost: number; + encoding: MessageEncoding; +} { + const messageInfo = calculateMessageParts(text); + const totalMessages = destinations.length; + const totalParts = messageInfo.parts * totalMessages; + const costPerMessage = messageInfo.parts * costPerPart; + const totalCost = totalParts * costPerPart; + + return { + totalParts, + totalMessages, + costPerMessage: Math.round(costPerMessage * 100) / 100, + totalCost: Math.round(totalCost * 100) / 100, + encoding: messageInfo.encoding + }; +} + +/** + * Validates and formats datetime string for sendAt parameter + * + * @param dateTime - DateTime string or Date object + * @returns string - ISO 8601 formatted datetime string + */ +export function formatSendAtDateTime(dateTime: string | Date): string { + let date: Date; + + if (typeof dateTime === 'string') { + date = new Date(dateTime); + } else if (dateTime instanceof Date) { + date = dateTime; + } else { + throw new SmsValidationError('dateTime must be a string or Date object'); + } + + if (isNaN(date.getTime())) { + throw new SmsValidationError('Invalid datetime format'); + } + + if (date <= new Date()) { + throw new SmsValidationError('sendAt datetime must be in the future'); + } + + return date.toISOString(); +} + +/** + * Validates URL format for notifyUrl parameter + * + * @param url - URL to validate + * @returns boolean - True if valid URL + */ +export function isValidNotifyUrl(url: string): boolean { + if (!url || typeof url !== 'string') { + return false; + } + + try { + const urlObj = new URL(url); + return ['http:', 'https:'].includes(urlObj.protocol); + } catch { + return false; + } +} + +/** + * Generates message ID for tracking + * + * @param prefix - Optional prefix for message ID + * @returns string - Generated message ID + */ +export function generateMessageId(prefix: string = 'msg'): string { + const timestamp = Date.now(); + const random = Math.random().toString(36).substring(2, 8); + return `${prefix}-${timestamp}-${random}`; +} + +/** + * Splits long text into multiple SMS parts + * + * @param text - Text to split + * @param encoding - Message encoding (optional, auto-detected if not provided) + * @returns string[] - Array of text parts + */ +export function splitTextIntoParts(text: string): string[] { + if (!text) { + return []; + } + + const messageInfo = calculateMessageParts(text); + + if (messageInfo.parts === 1) { + return [text]; + } + + const parts: string[] = []; + const charsPerPart = messageInfo.charactersPerPart; + + for (let i = 0; i < text.length; i += charsPerPart) { + parts.push(text.substring(i, i + charsPerPart)); + } + + return parts; +} + +/** + * Validates delivery time window format + * + * @param timeWindow - Delivery time window object + * @returns boolean - True if valid format + */ +export function isValidDeliveryTimeWindow(timeWindow: any): boolean { + if (!timeWindow || typeof timeWindow !== 'object') { + return false; + } + + const validDays = ['MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY', 'SUNDAY']; + + if (!Array.isArray(timeWindow.days) || timeWindow.days.length === 0) { + return false; + } + + // Check if all days are valid + const allDaysValid = timeWindow.days.every((day: string) => validDays.includes(day)); + if (!allDaysValid) { + return false; + } + + // Check time format if provided + const timeRegex = /^([01]?[0-9]|2[0-3]):[0-5][0-9]$/; + + if (timeWindow.from && !timeRegex.test(timeWindow.from)) { + return false; + } + + if (timeWindow.to && !timeRegex.test(timeWindow.to)) { + return false; + } + + return true; +} diff --git a/src/utils/sms-webhook.ts b/src/utils/sms-webhook.ts new file mode 100644 index 0000000..17d906d --- /dev/null +++ b/src/utils/sms-webhook.ts @@ -0,0 +1,246 @@ +/** + * SMS Webhook Utilities + * + * Utilities for handling SMS webhook callbacks and delivery reports + * + * @author SMS Modernization Team + * @version 1.0.0 + */ + +import * as crypto from 'crypto'; +import { SmsDeliveryReport } from '../types/sms'; +import { SmsValidationError } from '../errors/sms-errors'; + +/** + * Webhook delivery report payload structure + */ +export interface SmsWebhookPayload { + results: SmsDeliveryReport[]; +} + +/** + * Webhook signature verification options + */ +export interface WebhookVerificationOptions { + /** Webhook secret key */ + secret: string; + /** Request body as string */ + body: string; + /** Signature from webhook headers */ + signature: string; + /** Signature algorithm (default: sha256) */ + algorithm?: string; +} + +/** + * Verifies webhook signature to ensure authenticity + * + * @param options - Verification options + * @returns boolean - True if signature is valid + */ +export function verifyWebhookSignature(options: WebhookVerificationOptions): boolean { + try { + const { secret, body, signature, algorithm = 'sha256' } = options; + + if (!secret || !body || !signature) { + throw new SmsValidationError('secret, body, and signature are required for webhook verification'); + } + + // Generate expected signature + const expectedSignature = crypto + .createHmac(algorithm, secret) + .update(body, 'utf8') + .digest('hex'); + + // Compare signatures using timing-safe comparison + const expectedBuffer = Buffer.from(`sha256=${expectedSignature}`, 'utf8'); + const actualBuffer = Buffer.from(signature, 'utf8'); + + if (expectedBuffer.length !== actualBuffer.length) { + return false; + } + + return crypto.timingSafeEqual(expectedBuffer, actualBuffer); + } catch (error) { + console.error('Webhook signature verification failed:', error); + return false; + } +} + +/** + * Parses webhook payload and validates structure + * + * @param payload - Raw webhook payload + * @returns SmsWebhookPayload - Parsed and validated payload + */ +export function parseWebhookPayload(payload: any): SmsWebhookPayload { + if (!payload) { + throw new SmsValidationError('Webhook payload is required'); + } + + if (typeof payload === 'string') { + try { + payload = JSON.parse(payload); + } catch (error) { + throw new SmsValidationError('Invalid JSON in webhook payload'); + } + } + + if (!payload.results || !Array.isArray(payload.results)) { + throw new SmsValidationError('Webhook payload must contain results array'); + } + + // Validate each delivery report + payload.results.forEach((result: any, index: number) => { + validateDeliveryReport(result, `results[${index}]`); + }); + + return payload as SmsWebhookPayload; +} + +/** + * Validates delivery report structure + */ +function validateDeliveryReport(report: any, fieldPrefix: string): void { + const requiredFields = ['messageId', 'to', 'from', 'sentAt', 'status']; + + requiredFields.forEach(field => { + if (!report[field]) { + throw new SmsValidationError(`${fieldPrefix}.${field} is required in delivery report`); + } + }); + + if (report.status && typeof report.status === 'object') { + const statusFields = ['groupId', 'groupName', 'id', 'name']; + statusFields.forEach(field => { + if (report.status[field] === undefined) { + throw new SmsValidationError(`${fieldPrefix}.status.${field} is required`); + } + }); + } +} + +/** + * Extracts delivery status from webhook payload + * + * @param payload - Webhook payload + * @returns Map - Map of messageId to delivery status + */ +export function extractDeliveryStatuses(payload: SmsWebhookPayload): Map { + const statusMap = new Map(); + + payload.results.forEach(result => { + if (result.messageId && result.status?.name) { + statusMap.set(result.messageId, result.status.name); + } + }); + + return statusMap; +} + +/** + * Filters delivery reports by status + * + * @param payload - Webhook payload + * @param statusNames - Array of status names to filter by + * @returns SmsDeliveryReport[] - Filtered delivery reports + */ +export function filterReportsByStatus( + payload: SmsWebhookPayload, + statusNames: string[] +): SmsDeliveryReport[] { + return payload.results.filter(result => + result.status?.name && statusNames.includes(result.status.name) + ); +} + +/** + * Gets failed delivery reports + * + * @param payload - Webhook payload + * @returns SmsDeliveryReport[] - Failed delivery reports + */ +export function getFailedDeliveries(payload: SmsWebhookPayload): SmsDeliveryReport[] { + const failedStatuses = ['REJECTED', 'UNDELIVERABLE', 'EXPIRED']; + return filterReportsByStatus(payload, failedStatuses); +} + +/** + * Gets successful delivery reports + * + * @param payload - Webhook payload + * @returns SmsDeliveryReport[] - Successful delivery reports + */ +export function getSuccessfulDeliveries(payload: SmsWebhookPayload): SmsDeliveryReport[] { + const successStatuses = ['DELIVERED']; + return filterReportsByStatus(payload, successStatuses); +} + +/** + * Calculates delivery statistics from webhook payload + * + * @param payload - Webhook payload + * @returns Object with delivery statistics + */ +export function calculateDeliveryStats(payload: SmsWebhookPayload): { + total: number; + delivered: number; + failed: number; + pending: number; + deliveryRate: number; +} { + const total = payload.results.length; + const delivered = getSuccessfulDeliveries(payload).length; + const failed = getFailedDeliveries(payload).length; + const pending = total - delivered - failed; + const deliveryRate = total > 0 ? (delivered / total) * 100 : 0; + + return { + total, + delivered, + failed, + pending, + deliveryRate: Math.round(deliveryRate * 100) / 100 // Round to 2 decimal places + }; +} + +/** + * Express.js middleware for handling SMS webhooks + * + * @param secret - Webhook secret for signature verification + * @returns Express middleware function + */ +export function createWebhookMiddleware(secret: string) { + return (req: any, res: any, next: any) => { + try { + // Get signature from headers + const signature = req.headers['x-infobip-signature'] || req.headers['x-hub-signature-256']; + + if (!signature) { + return res.status(400).json({ error: 'Missing webhook signature' }); + } + + // Verify signature + const isValid = verifyWebhookSignature({ + secret, + body: JSON.stringify(req.body), + signature + }); + + if (!isValid) { + return res.status(401).json({ error: 'Invalid webhook signature' }); + } + + // Parse and validate payload + const payload = parseWebhookPayload(req.body); + + // Add parsed payload to request + req.smsWebhook = payload; + + next(); + } catch (error) { + console.error('Webhook middleware error:', error); + return res.status(400).json({ error: 'Invalid webhook payload' }); + } + }; +} diff --git a/src/utils/validators/sms-v3.ts b/src/utils/validators/sms-v3.ts new file mode 100644 index 0000000..1b351d5 --- /dev/null +++ b/src/utils/validators/sms-v3.ts @@ -0,0 +1,634 @@ +/** + * SMS v3 API Validators + * + * This file contains comprehensive validation functions for the SMS v3 unified API + * following all parameter requirements from the Infobip SMS REST API specification. + * + * @author SMS Modernization Team + * @version 1.0.0 + */ + +import { SmsValidationError } from '../../errors/sms-errors'; +import { + SendSmsV3Request, + BaseSmsMessage, + LanguageCode, + Transliteration, + DeliveryStatus, + GeneralStatus, + SmsReportsQuery, + SmsLogsQuery +} from '../../types/sms'; + +/** + * Validates SMS v3 unified send request + */ +export function validateSmsV3SendRequest(request: SendSmsV3Request): void { + if (!request) { + throw new SmsValidationError('Request object is required'); + } + + // Validate messages array + if (!request.messages || !Array.isArray(request.messages)) { + throw new SmsValidationError('messages array is required'); + } + + if (request.messages.length === 0) { + throw new SmsValidationError('messages array cannot be empty'); + } + + if (request.messages.length > 1000) { + throw new SmsValidationError('messages array cannot contain more than 1000 messages'); + } + + // Validate each message + request.messages.forEach((message, index) => { + validateSmsV3Message(message, `messages[${index}]`); + }); + + // Validate includeSmsCountInResponse if provided + if (request.includeSmsCountInResponse !== undefined) { + if (typeof request.includeSmsCountInResponse !== 'boolean') { + throw new SmsValidationError('includeSmsCountInResponse must be a boolean'); + } + } +} + +/** + * Validates individual SMS v3 message + */ +export function validateSmsV3Message(message: BaseSmsMessage, fieldPrefix: string = 'message'): void { + if (!message) { + throw new SmsValidationError(`${fieldPrefix} is required`); + } + + // Validate from field (sender ID) + validateSenderField(message.from, `${fieldPrefix}.from`); + + // Validate destinations + validateDestinations(message.destinations, `${fieldPrefix}.destinations`); + + // Validate message content (text or binary) + validateMessageContent(message, fieldPrefix); + + // Validate optional fields + if (message.flash !== undefined) { + validateFlashSms(message.flash, `${fieldPrefix}.flash`); + } + + if (message.language) { + validateLanguage(message.language, `${fieldPrefix}.language`); + } + + if (message.transliteration !== undefined) { + validateTransliteration(message.transliteration, `${fieldPrefix}.transliteration`); + } + + if (message.deliveryTimeWindow) { + validateDeliveryTimeWindow(message.deliveryTimeWindow, `${fieldPrefix}.deliveryTimeWindow`); + } + + if (message.sendAt) { + validateSendAt(message.sendAt, `${fieldPrefix}.sendAt`); + } + + if (message.validityPeriod !== undefined) { + validateValidityPeriod(message.validityPeriod, `${fieldPrefix}.validityPeriod`); + } + + if (message.callbackData) { + validateCallbackData(message.callbackData, `${fieldPrefix}.callbackData`); + } + + if (message.notifyUrl) { + validateNotifyUrl(message.notifyUrl, `${fieldPrefix}.notifyUrl`); + } + + if (message.contentTemplateId) { + validateContentTemplateId(message.contentTemplateId, `${fieldPrefix}.contentTemplateId`); + } + + if (message.regional) { + validateRegionalOptions(message.regional, `${fieldPrefix}.regional`); + } + + if (message.urlOptions) { + validateUrlOptions(message.urlOptions, `${fieldPrefix}.urlOptions`); + } + + if (message.applicationId) { + validateApplicationId(message.applicationId, `${fieldPrefix}.applicationId`); + } + + if (message.entityId) { + validateEntityId(message.entityId, `${fieldPrefix}.entityId`); + } + + if (message.platformId) { + validatePlatformId(message.platformId, `${fieldPrefix}.platformId`); + } + + if (message.campaignReferenceId) { + validateCampaignReferenceId(message.campaignReferenceId, `${fieldPrefix}.campaignReferenceId`); + } + + if (message.trackingType) { + validateTrackingType(message.trackingType, `${fieldPrefix}.trackingType`); + } + + if (message.track) { + validateTrack(message.track, `${fieldPrefix}.track`); + } +} + +/** + * Validates sender field (from) + */ +function validateSenderField(from: string, fieldName: string): void { + if (!from) { + throw new SmsValidationError(`${fieldName} is required`); + } + + if (typeof from !== 'string') { + throw new SmsValidationError(`${fieldName} must be a string`); + } + + if (from.length === 0) { + throw new SmsValidationError(`${fieldName} cannot be empty`); + } + + if (from.length > 50) { + throw new SmsValidationError(`${fieldName} cannot exceed 50 characters`); + } + + // Validate phone number format (E.164) or alphanumeric sender ID + const phoneRegex = /^\+[1-9]\d{1,14}$/; + const alphanumericRegex = /^[a-zA-Z0-9\s]{1,11}$/; + + if (!phoneRegex.test(from) && !alphanumericRegex.test(from)) { + throw new SmsValidationError(`${fieldName} must be a valid phone number (E.164 format) or alphanumeric sender ID (max 11 characters)`); + } +} + +/** + * Validates destinations array + */ +function validateDestinations(destinations: any, fieldName: string): void { + if (!destinations || !Array.isArray(destinations)) { + throw new SmsValidationError(`${fieldName} must be an array`); + } + + if (destinations.length === 0) { + throw new SmsValidationError(`${fieldName} cannot be empty`); + } + + if (destinations.length > 1000) { + throw new SmsValidationError(`${fieldName} cannot contain more than 1000 destinations`); + } + + destinations.forEach((destination, index) => { + const destFieldName = `${fieldName}[${index}]`; + + if (!destination || typeof destination !== 'object') { + throw new SmsValidationError(`${destFieldName} must be an object`); + } + + if (!destination.to) { + throw new SmsValidationError(`${destFieldName}.to is required`); + } + + if (typeof destination.to !== 'string') { + throw new SmsValidationError(`${destFieldName}.to must be a string`); + } + + // Validate E.164 phone number format + const phoneRegex = /^\+[1-9]\d{1,14}$/; + if (!phoneRegex.test(destination.to)) { + throw new SmsValidationError(`${destFieldName}.to must be a valid phone number in E.164 format`); + } + + if (destination.messageId && typeof destination.messageId !== 'string') { + throw new SmsValidationError(`${destFieldName}.messageId must be a string`); + } + }); +} + +/** + * Validates message content (text or binary) + */ +function validateMessageContent(message: BaseSmsMessage, fieldPrefix: string): void { + const hasText = message.text && message.text.length > 0; + const hasBinary = message.binary && message.binary.hex; + + if (!hasText && !hasBinary) { + throw new SmsValidationError(`${fieldPrefix} must contain either text or binary content`); + } + + if (hasText && hasBinary) { + throw new SmsValidationError(`${fieldPrefix} cannot contain both text and binary content`); + } + + if (hasText) { + validateTextContent(message.text!, `${fieldPrefix}.text`); + } + + if (hasBinary) { + validateBinaryContent(message.binary!, `${fieldPrefix}.binary`); + } +} + +/** + * Validates text content + */ +function validateTextContent(text: string, fieldName: string): void { + if (typeof text !== 'string') { + throw new SmsValidationError(`${fieldName} must be a string`); + } + + if (text.length === 0) { + throw new SmsValidationError(`${fieldName} cannot be empty`); + } + + // SMS text length validation (considering GSM 7-bit vs Unicode) + const gsmChars = /^[A-Za-z0-9 \r\n@£$¥èéùìòÇØøÅåΔ_ΦΓΛΩΠΨΣΘΞÆæßÉ!"#¤%&'()*+,\-./:;<=>?¡ÄÖÑܧ¿äöñüà^{}\\[~]|€]*$/; + const isGsm7Bit = gsmChars.test(text); + + if (isGsm7Bit && text.length > 1600) { + throw new SmsValidationError(`${fieldName} exceeds maximum length of 1600 characters for GSM 7-bit encoding`); + } else if (!isGsm7Bit && text.length > 700) { + throw new SmsValidationError(`${fieldName} exceeds maximum length of 700 characters for Unicode encoding`); + } +} + +/** + * Validates binary content + */ +function validateBinaryContent(binary: any, fieldName: string): void { + if (!binary || typeof binary !== 'object') { + throw new SmsValidationError(`${fieldName} must be an object`); + } + + if (!binary.hex) { + throw new SmsValidationError(`${fieldName}.hex is required`); + } + + if (typeof binary.hex !== 'string') { + throw new SmsValidationError(`${fieldName}.hex must be a string`); + } + + // Validate hexadecimal format + const hexRegex = /^[0-9A-Fa-f]+$/; + if (!hexRegex.test(binary.hex)) { + throw new SmsValidationError(`${fieldName}.hex must contain only hexadecimal characters`); + } + + if (binary.hex.length % 2 !== 0) { + throw new SmsValidationError(`${fieldName}.hex must have an even number of characters`); + } + + if (binary.hex.length > 280) { + throw new SmsValidationError(`${fieldName}.hex cannot exceed 140 bytes (280 hex characters)`); + } + + if (binary.dataCoding !== undefined) { + if (typeof binary.dataCoding !== 'number' || binary.dataCoding < 0 || binary.dataCoding > 255) { + throw new SmsValidationError(`${fieldName}.dataCoding must be a number between 0 and 255`); + } + } + + if (binary.esmClass !== undefined) { + if (typeof binary.esmClass !== 'number' || binary.esmClass < 0 || binary.esmClass > 255) { + throw new SmsValidationError(`${fieldName}.esmClass must be a number between 0 and 255`); + } + } +} + +/** + * Validates flash SMS setting + */ +function validateFlashSms(flash: boolean, fieldName: string): void { + if (typeof flash !== 'boolean') { + throw new SmsValidationError(`${fieldName} must be a boolean`); + } + + // Add warning about flash SMS limitations + if (flash) { + console.warn('Warning: Flash SMS may not be supported by all mobile networks and devices'); + } +} + +/** + * Validates language configuration + */ +function validateLanguage(language: any, fieldName: string): void { + if (!language || typeof language !== 'object') { + throw new SmsValidationError(`${fieldName} must be an object`); + } + + if (!language.languageCode) { + throw new SmsValidationError(`${fieldName}.languageCode is required`); + } + + if (!Object.values(LanguageCode).includes(language.languageCode)) { + throw new SmsValidationError(`${fieldName}.languageCode must be one of: ${Object.values(LanguageCode).join(', ')}`); + } +} + +/** + * Validates transliteration option + */ +function validateTransliteration(transliteration: string, fieldName: string): void { + if (!Object.values(Transliteration).includes(transliteration as Transliteration)) { + throw new SmsValidationError(`${fieldName} must be one of: ${Object.values(Transliteration).join(', ')}`); + } +} + +/** + * Validates delivery time window + */ +function validateDeliveryTimeWindow(timeWindow: any, fieldName: string): void { + if (!timeWindow || typeof timeWindow !== 'object') { + throw new SmsValidationError(`${fieldName} must be an object`); + } + + if (!timeWindow.days || !Array.isArray(timeWindow.days)) { + throw new SmsValidationError(`${fieldName}.days must be an array`); + } + + const validDays = ['MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY', 'SUNDAY']; + timeWindow.days.forEach((day: string, index: number) => { + if (!validDays.includes(day)) { + throw new SmsValidationError(`${fieldName}.days[${index}] must be one of: ${validDays.join(', ')}`); + } + }); + + if (timeWindow.from) { + validateTimeFormat(timeWindow.from, `${fieldName}.from`); + } + + if (timeWindow.to) { + validateTimeFormat(timeWindow.to, `${fieldName}.to`); + } +} + +/** + * Validates time format (HH:mm) + */ +function validateTimeFormat(time: string, fieldName: string): void { + const timeRegex = /^([01]?[0-9]|2[0-3]):[0-5][0-9]$/; + if (!timeRegex.test(time)) { + throw new SmsValidationError(`${fieldName} must be in HH:mm format`); + } +} + +/** + * Validates sendAt datetime + */ +function validateSendAt(sendAt: string, fieldName: string): void { + if (typeof sendAt !== 'string') { + throw new SmsValidationError(`${fieldName} must be a string`); + } + + const date = new Date(sendAt); + if (isNaN(date.getTime())) { + throw new SmsValidationError(`${fieldName} must be a valid ISO 8601 datetime string`); + } + + if (date <= new Date()) { + throw new SmsValidationError(`${fieldName} must be a future datetime`); + } +} + +/** + * Validates validity period + */ +function validateValidityPeriod(validityPeriod: number, fieldName: string): void { + if (typeof validityPeriod !== 'number') { + throw new SmsValidationError(`${fieldName} must be a number`); + } + + if (validityPeriod < 1 || validityPeriod > 2880) { + throw new SmsValidationError(`${fieldName} must be between 1 and 2880 minutes (48 hours)`); + } +} + +/** + * Validates callback data + */ +function validateCallbackData(callbackData: string, fieldName: string): void { + if (typeof callbackData !== 'string') { + throw new SmsValidationError(`${fieldName} must be a string`); + } + + if (callbackData.length > 200) { + throw new SmsValidationError(`${fieldName} cannot exceed 200 characters`); + } +} + +/** + * Validates notify URL + */ +function validateNotifyUrl(notifyUrl: string, fieldName: string): void { + if (typeof notifyUrl !== 'string') { + throw new SmsValidationError(`${fieldName} must be a string`); + } + + try { + const url = new URL(notifyUrl); + if (!['http:', 'https:'].includes(url.protocol)) { + throw new SmsValidationError(`${fieldName} must use HTTP or HTTPS protocol`); + } + } catch (error) { + throw new SmsValidationError(`${fieldName} must be a valid URL`); + } +} + +/** + * Validates content template ID + */ +function validateContentTemplateId(contentTemplateId: string, fieldName: string): void { + if (typeof contentTemplateId !== 'string') { + throw new SmsValidationError(`${fieldName} must be a string`); + } + + if (contentTemplateId.length === 0) { + throw new SmsValidationError(`${fieldName} cannot be empty`); + } +} + +/** + * Validates regional options + */ +function validateRegionalOptions(regional: any, fieldName: string): void { + if (!regional || typeof regional !== 'object') { + throw new SmsValidationError(`${fieldName} must be an object`); + } + + if (regional.indiaDlt) { + validateIndiaDltOptions(regional.indiaDlt, `${fieldName}.indiaDlt`); + } +} + +/** + * Validates India DLT options + */ +function validateIndiaDltOptions(indiaDlt: any, fieldName: string): void { + if (!indiaDlt || typeof indiaDlt !== 'object') { + throw new SmsValidationError(`${fieldName} must be an object`); + } + + if (!indiaDlt.principalEntityId) { + throw new SmsValidationError(`${fieldName}.principalEntityId is required`); + } + + if (typeof indiaDlt.principalEntityId !== 'string') { + throw new SmsValidationError(`${fieldName}.principalEntityId must be a string`); + } + + if (!indiaDlt.contentTemplateId) { + throw new SmsValidationError(`${fieldName}.contentTemplateId is required`); + } + + if (typeof indiaDlt.contentTemplateId !== 'string') { + throw new SmsValidationError(`${fieldName}.contentTemplateId must be a string`); + } +} + +/** + * Validates URL options + */ +function validateUrlOptions(urlOptions: any, fieldName: string): void { + if (!urlOptions || typeof urlOptions !== 'object') { + throw new SmsValidationError(`${fieldName} must be an object`); + } + + if (urlOptions.shortenUrl !== undefined && typeof urlOptions.shortenUrl !== 'boolean') { + throw new SmsValidationError(`${fieldName}.shortenUrl must be a boolean`); + } + + if (urlOptions.trackClicks !== undefined && typeof urlOptions.trackClicks !== 'boolean') { + throw new SmsValidationError(`${fieldName}.trackClicks must be a boolean`); + } + + if (urlOptions.customDomain && typeof urlOptions.customDomain !== 'string') { + throw new SmsValidationError(`${fieldName}.customDomain must be a string`); + } +} + +/** + * Validates application ID + */ +function validateApplicationId(applicationId: string, fieldName: string): void { + if (typeof applicationId !== 'string') { + throw new SmsValidationError(`${fieldName} must be a string`); + } +} + +/** + * Validates entity ID + */ +function validateEntityId(entityId: string, fieldName: string): void { + if (typeof entityId !== 'string') { + throw new SmsValidationError(`${fieldName} must be a string`); + } +} + +/** + * Validates platform ID + */ +function validatePlatformId(platformId: string, fieldName: string): void { + if (typeof platformId !== 'string') { + throw new SmsValidationError(`${fieldName} must be a string`); + } +} + +/** + * Validates campaign reference ID + */ +function validateCampaignReferenceId(campaignReferenceId: string, fieldName: string): void { + if (typeof campaignReferenceId !== 'string') { + throw new SmsValidationError(`${fieldName} must be a string`); + } +} + +/** + * Validates tracking type + */ +function validateTrackingType(trackingType: string, fieldName: string): void { + if (typeof trackingType !== 'string') { + throw new SmsValidationError(`${fieldName} must be a string`); + } +} + +/** + * Validates track parameter + */ +function validateTrack(track: string, fieldName: string): void { + if (typeof track !== 'string') { + throw new SmsValidationError(`${fieldName} must be a string`); + } +} + +/** + * Validates SMS reports query parameters + */ +export function validateSmsReportsQuery(query: SmsReportsQuery): void { + if (query.limit !== undefined) { + if (typeof query.limit !== 'number' || query.limit < 1 || query.limit > 1000) { + throw new SmsValidationError('limit must be a number between 1 and 1000'); + } + } + + if (query.deliveryStatus !== undefined) { + if (!Object.values(DeliveryStatus).includes(query.deliveryStatus)) { + throw new SmsValidationError(`deliveryStatus must be one of: ${Object.values(DeliveryStatus).join(', ')}`); + } + } + + if (query.sentSince) { + validateDateTimeString(query.sentSince, 'sentSince'); + } + + if (query.sentUntil) { + validateDateTimeString(query.sentUntil, 'sentUntil'); + } +} + +/** + * Validates SMS logs query parameters + */ +export function validateSmsLogsQuery(query: SmsLogsQuery): void { + if (query.limit !== undefined) { + if (typeof query.limit !== 'number' || query.limit < 1 || query.limit > 1000) { + throw new SmsValidationError('limit must be a number between 1 and 1000'); + } + } + + if (query.generalStatus !== undefined) { + if (!Object.values(GeneralStatus).includes(query.generalStatus)) { + throw new SmsValidationError(`generalStatus must be one of: ${Object.values(GeneralStatus).join(', ')}`); + } + } + + if (query.sentSince) { + validateDateTimeString(query.sentSince, 'sentSince'); + } + + if (query.sentUntil) { + validateDateTimeString(query.sentUntil, 'sentUntil'); + } +} + +/** + * Validates datetime string format + */ +function validateDateTimeString(dateTime: string, fieldName: string): void { + if (typeof dateTime !== 'string') { + throw new SmsValidationError(`${fieldName} must be a string`); + } + + const date = new Date(dateTime); + if (isNaN(date.getTime())) { + throw new SmsValidationError(`${fieldName} must be a valid ISO 8601 datetime string`); + } +} diff --git a/test/apis/sms-v3.test.ts b/test/apis/sms-v3.test.ts new file mode 100644 index 0000000..a735249 --- /dev/null +++ b/test/apis/sms-v3.test.ts @@ -0,0 +1,653 @@ +/** + * SMS v3 API Tests + * + * Comprehensive test suite for the SMS v3 unified API implementation + * following the tracking document requirements for 90%+ test coverage. + * + * @author SMS Modernization Team + * @version 1.0.0 + */ + +import { SMS } from '../../src/apis/sms'; +import { InfobipAuth } from '../../src/utils/auth'; +import { SendSmsV3Request, DeliveryStatus, GeneralStatus } from '../../src/types/sms'; +import { + SmsValidationError, + SmsRateLimitError, + SmsNetworkError +} from '../../src/errors/sms-errors'; + +// Mock the HTTP client +jest.mock('../../src/utils/http'); + +describe('SMS v3 API', () => { + let sms: SMS; + let mockHttp: any; + + const mockCredentials: InfobipAuth = { + baseUrl: 'https://api.infobip.com', + authorization: 'Bearer test-token' + }; + + beforeEach(() => { + // Reset mocks + jest.clearAllMocks(); + + sms = new SMS(mockCredentials); + mockHttp = sms.http; + }); + + describe('sendV3()', () => { + const validTextRequest: SendSmsV3Request = { + messages: [{ + from: 'InfoSMS', + destinations: [{ to: '+1234567890' }], + text: 'Hello World!' + }] + }; + + const validBinaryRequest: SendSmsV3Request = { + messages: [{ + from: '+1234567890', + destinations: [{ to: '+0987654321' }], + binary: { + hex: '48656C6C6F20576F726C6421', + dataCoding: 0, + esmClass: 0 + } + }] + }; + + it('should_send_text_message_successfully_when_valid_request_provided', async () => { + const mockResponse = { + data: { + bulkId: 'bulk-123', + messages: [{ + messageId: 'msg-123', + status: { + groupId: 1, + groupName: 'PENDING', + id: 26, + name: 'MESSAGE_ACCEPTED', + description: 'Message sent to next instance' + }, + to: '+1234567890', + smsCount: 1 + }] + } + }; + + mockHttp.post.mockResolvedValue(mockResponse); + + const result = await sms.sendV3(validTextRequest); + + expect(mockHttp.post).toHaveBeenCalledWith('/sms/3/messages', validTextRequest); + expect(result).toEqual(mockResponse.data); + expect(result.bulkId).toBe('bulk-123'); + expect(result.messages).toHaveLength(1); + }); + + it('should_send_binary_message_successfully_when_valid_request_provided', async () => { + const mockResponse = { + data: { + bulkId: 'bulk-456', + messages: [{ + messageId: 'msg-456', + status: { + groupId: 1, + groupName: 'PENDING', + id: 26, + name: 'MESSAGE_ACCEPTED', + description: 'Message sent to next instance' + }, + to: '+0987654321', + smsCount: 1 + }] + } + }; + + mockHttp.post.mockResolvedValue(mockResponse); + + const result = await sms.sendV3(validBinaryRequest); + + expect(mockHttp.post).toHaveBeenCalledWith('/sms/3/messages', validBinaryRequest); + expect(result).toEqual(mockResponse.data); + }); + + it('should_include_sms_count_in_response_when_flag_is_true', async () => { + const requestWithCount = { + ...validTextRequest, + includeSmsCountInResponse: true + }; + + const mockResponse = { + data: { + bulkId: 'bulk-789', + messages: [{ + messageId: 'msg-789', + status: { + groupId: 1, + groupName: 'PENDING', + id: 26, + name: 'MESSAGE_ACCEPTED', + description: 'Message sent to next instance' + }, + to: '+1234567890', + smsCount: 2 + }] + } + }; + + mockHttp.post.mockResolvedValue(mockResponse); + + const result = await sms.sendV3(requestWithCount); + + expect(mockHttp.post).toHaveBeenCalledWith('/sms/3/messages', requestWithCount); + expect(result.messages[0].smsCount).toBe(2); + }); + + it('should_throw_validation_error_when_messages_array_is_empty', async () => { + const invalidRequest = { + messages: [] + }; + + await expect(sms.sendV3(invalidRequest)).rejects.toThrow(SmsValidationError); + await expect(sms.sendV3(invalidRequest)).rejects.toThrow('messages array cannot be empty'); + }); + + it('should_throw_validation_error_when_messages_array_exceeds_limit', async () => { + const invalidRequest = { + messages: new Array(1001).fill(validTextRequest.messages[0]) + }; + + await expect(sms.sendV3(invalidRequest)).rejects.toThrow(SmsValidationError); + await expect(sms.sendV3(invalidRequest)).rejects.toThrow('messages array cannot contain more than 1000 messages'); + }); + + it('should_throw_validation_error_when_from_field_is_missing', async () => { + const invalidRequest = { + messages: [{ + destinations: [{ to: '+1234567890' }], + text: 'Hello World!' + }] + }; + + await expect(sms.sendV3(invalidRequest as any)).rejects.toThrow(SmsValidationError); + await expect(sms.sendV3(invalidRequest as any)).rejects.toThrow('messages[0].from is required'); + }); + + it('should_throw_validation_error_when_destinations_array_is_empty', async () => { + const invalidRequest = { + messages: [{ + from: 'InfoSMS', + destinations: [], + text: 'Hello World!' + }] + }; + + await expect(sms.sendV3(invalidRequest)).rejects.toThrow(SmsValidationError); + await expect(sms.sendV3(invalidRequest)).rejects.toThrow('messages[0].destinations cannot be empty'); + }); + + it('should_throw_validation_error_when_phone_number_format_is_invalid', async () => { + const invalidRequest = { + messages: [{ + from: 'InfoSMS', + destinations: [{ to: 'invalid-phone' }], + text: 'Hello World!' + }] + }; + + await expect(sms.sendV3(invalidRequest)).rejects.toThrow(SmsValidationError); + await expect(sms.sendV3(invalidRequest)).rejects.toThrow('must be a valid phone number in E.164 format'); + }); + + it('should_throw_validation_error_when_text_exceeds_gsm_limit', async () => { + const longText = 'A'.repeat(1601); // Exceeds GSM 7-bit limit + const invalidRequest = { + messages: [{ + from: 'InfoSMS', + destinations: [{ to: '+1234567890' }], + text: longText + }] + }; + + await expect(sms.sendV3(invalidRequest)).rejects.toThrow(SmsValidationError); + await expect(sms.sendV3(invalidRequest)).rejects.toThrow('exceeds maximum length of 1600 characters for GSM 7-bit encoding'); + }); + + it('should_throw_validation_error_when_unicode_text_exceeds_limit', async () => { + const longUnicodeText = '🚀'.repeat(701); // Exceeds Unicode limit + const invalidRequest = { + messages: [{ + from: 'InfoSMS', + destinations: [{ to: '+1234567890' }], + text: longUnicodeText + }] + }; + + await expect(sms.sendV3(invalidRequest)).rejects.toThrow(SmsValidationError); + await expect(sms.sendV3(invalidRequest)).rejects.toThrow('exceeds maximum length of 700 characters for Unicode encoding'); + }); + + it('should_throw_validation_error_when_binary_hex_is_invalid', async () => { + const invalidRequest = { + messages: [{ + from: '+1234567890', + destinations: [{ to: '+0987654321' }], + binary: { + hex: 'invalid-hex-string' + } + }] + }; + + await expect(sms.sendV3(invalidRequest)).rejects.toThrow(SmsValidationError); + await expect(sms.sendV3(invalidRequest)).rejects.toThrow('must contain only hexadecimal characters'); + }); + + it('should_throw_api_error_when_server_returns_400', async () => { + const errorResponse = { + response: { + status: 400, + data: { + requestError: { + serviceException: { + messageId: 'BAD_REQUEST', + text: 'Invalid request parameters' + } + } + } + } + }; + + mockHttp.post.mockRejectedValue(errorResponse); + + await expect(sms.sendV3(validTextRequest)).rejects.toThrow(SmsValidationError); + }); + + it('should_throw_rate_limit_error_when_server_returns_429', async () => { + const errorResponse = { + response: { + status: 429, + data: { + requestError: { + serviceException: { + messageId: 'TOO_MANY_REQUESTS', + text: 'Rate limit exceeded', + retryAfter: 60 + } + } + } + } + }; + + mockHttp.post.mockRejectedValue(errorResponse); + + await expect(sms.sendV3(validTextRequest)).rejects.toThrow(SmsRateLimitError); + }); + + it('should_throw_network_error_when_network_fails', async () => { + const networkError = { + request: {}, + message: 'Network Error' + }; + + mockHttp.post.mockRejectedValue(networkError); + + await expect(sms.sendV3(validTextRequest)).rejects.toThrow(SmsNetworkError); + }); + }); + + describe('getDeliveryReportsV3()', () => { + it('should_get_delivery_reports_successfully_with_no_filters', async () => { + const mockResponse = { + data: { + results: [{ + bulkId: 'bulk-123', + messageId: 'msg-123', + to: '+1234567890', + from: 'InfoSMS', + text: 'Hello World!', + sentAt: '2025-10-10T10:00:00.000Z', + doneAt: '2025-10-10T10:01:00.000Z', + smsCount: 1, + status: { + groupId: 3, + groupName: 'DELIVERED', + id: 5, + name: 'DELIVERED_TO_HANDSET', + description: 'Message delivered to handset' + } + }] + } + }; + + mockHttp.get.mockResolvedValue(mockResponse); + + const result = await sms.v3.getReports(); + + expect(mockHttp.get).toHaveBeenCalledWith('/sms/3/reports', {}); + expect(result).toEqual(mockResponse.data); + }); + + it('should_get_delivery_reports_with_filters_successfully', async () => { + const query = { + bulkId: 'bulk-123', + limit: 100, + deliveryStatus: DeliveryStatus.DELIVERED + }; + + const mockResponse = { + data: { + results: [] + } + }; + + mockHttp.get.mockResolvedValue(mockResponse); + + const result = await sms.v3.getReports(query); + + expect(mockHttp.get).toHaveBeenCalledWith('/sms/3/reports', query); + expect(result).toEqual(mockResponse.data); + }); + + it('should_throw_validation_error_when_limit_exceeds_maximum', async () => { + const invalidQuery = { + limit: 1001 + }; + + await expect(sms.v3.getReports(invalidQuery)).rejects.toThrow(SmsValidationError); + await expect(sms.v3.getReports(invalidQuery)).rejects.toThrow('limit must be a number between 1 and 1000'); + }); + + it('should_throw_validation_error_when_delivery_status_is_invalid', async () => { + const invalidQuery = { + deliveryStatus: 'INVALID_STATUS' as any + }; + + await expect(sms.v3.getReports(invalidQuery)).rejects.toThrow(SmsValidationError); + }); + }); + + describe('getMessageLogsV3()', () => { + it('should_get_message_logs_successfully_with_no_filters', async () => { + const mockResponse = { + data: { + results: [{ + messageId: 'msg-123', + to: '+1234567890', + from: 'InfoSMS', + text: 'Hello World!', + sentAt: '2025-10-10T10:00:00.000Z', + smsCount: 1, + status: { + groupId: 1, + groupName: 'PENDING', + id: 26, + name: 'MESSAGE_ACCEPTED', + description: 'Message sent to next instance' + } + }] + } + }; + + mockHttp.get.mockResolvedValue(mockResponse); + + const result = await sms.v3.getLogs(); + + expect(mockHttp.get).toHaveBeenCalledWith('/sms/3/logs', {}); + expect(result).toEqual(mockResponse.data); + }); + + it('should_get_message_logs_with_filters_successfully', async () => { + const query = { + from: 'InfoSMS', + generalStatus: GeneralStatus.DELIVERED, + limit: 500 + }; + + const mockResponse = { + data: { + results: [] + } + }; + + mockHttp.get.mockResolvedValue(mockResponse); + + const result = await sms.v3.getLogs(query); + + expect(mockHttp.get).toHaveBeenCalledWith('/sms/3/logs', query); + expect(result).toEqual(mockResponse.data); + }); + }); + + describe('sendQueryV3()', () => { + it('should_send_query_sms_successfully_with_warning', async () => { + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + + const query = { + from: 'InfoSMS', + to: '+1234567890', + text: 'Hello World!' + }; + + const mockResponse = { + data: { + bulkId: 'bulk-query-123', + messages: [{ + messageId: 'msg-query-123', + status: { + groupId: 1, + groupName: 'PENDING', + id: 26, + name: 'MESSAGE_ACCEPTED', + description: 'Message sent to next instance' + }, + to: '+1234567890' + }] + } + }; + + mockHttp.get.mockResolvedValue(mockResponse); + + const result = await sms.sendQueryV3(query); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('Query-based SMS sending is not recommended') + ); + expect(mockHttp.get).toHaveBeenCalledWith('/sms/3/text/query', query); + expect(result).toEqual(mockResponse.data); + + consoleSpy.mockRestore(); + }); + + it('should_throw_validation_error_when_required_query_params_missing', async () => { + const invalidQuery = { + from: 'InfoSMS' + // Missing 'to' and 'text' + }; + + await expect(sms.sendQueryV3(invalidQuery)).rejects.toThrow(SmsValidationError); + await expect(sms.sendQueryV3(invalidQuery)).rejects.toThrow('to parameter is required'); + }); + }); + + describe('Backward Compatibility', () => { + it('should_show_deprecation_warning_when_using_legacy_send_method', async () => { + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + + const legacyMessage = { + messages: [{ + from: 'InfoSMS', + destinations: [{ to: '+1234567890' }], + text: 'Hello World!' + }] + }; + + mockHttp.post.mockResolvedValue({ data: {} }); + + await sms.send(legacyMessage); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('DEPRECATION WARNING: The send() method uses deprecated v2 API endpoints') + ); + + consoleSpy.mockRestore(); + }); + + it('should_show_deprecation_warning_when_using_legacy_reports_method', async () => { + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + + mockHttp.get.mockResolvedValue({ data: {} }); + + await sms.reports.get({}); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('DEPRECATION WARNING: This method uses deprecated v1 API endpoint') + ); + + consoleSpy.mockRestore(); + }); + }); + + describe('Regional Compliance', () => { + it('should_validate_india_dlt_parameters_successfully', async () => { + const requestWithIndiaDlt: SendSmsV3Request = { + messages: [{ + from: 'InfoSMS', + destinations: [{ to: '+911234567890' }], + text: 'Hello India!', + regional: { + indiaDlt: { + principalEntityId: 'entity-123', + contentTemplateId: 'template-456' + } + } + }] + }; + + const mockResponse = { + data: { + bulkId: 'bulk-india-123', + messages: [{ + messageId: 'msg-india-123', + status: { + groupId: 1, + groupName: 'PENDING', + id: 26, + name: 'MESSAGE_ACCEPTED', + description: 'Message sent to next instance' + }, + to: '+911234567890', + smsCount: 1 + }] + } + }; + + mockHttp.post.mockResolvedValue(mockResponse); + + const result = await sms.sendV3(requestWithIndiaDlt); + + expect(result).toEqual(mockResponse.data); + }); + + it('should_throw_validation_error_when_india_dlt_content_template_id_missing', async () => { + const invalidRequest: SendSmsV3Request = { + messages: [{ + from: 'InfoSMS', + destinations: [{ to: '+911234567890' }], + text: 'Hello India!', + regional: { + indiaDlt: { + principalEntityId: 'entity-123' + // Missing contentTemplateId + } as any + } + }] + }; + + await expect(sms.sendV3(invalidRequest as any)).rejects.toThrow(SmsValidationError); + await expect(sms.sendV3(invalidRequest as any)).rejects.toThrow('contentTemplateId is required'); + }); + }); + + describe('Advanced Features', () => { + it('should_validate_url_options_successfully', async () => { + const requestWithUrlOptions: SendSmsV3Request = { + messages: [{ + from: 'InfoSMS', + destinations: [{ to: '+1234567890' }], + text: 'Check out https://example.com', + urlOptions: { + shortenUrl: true, + trackClicks: true, + customDomain: 'short.example.com' + } + }] + }; + + const mockResponse = { + data: { + bulkId: 'bulk-url-123', + messages: [{ + messageId: 'msg-url-123', + status: { + groupId: 1, + groupName: 'PENDING', + id: 26, + name: 'MESSAGE_ACCEPTED', + description: 'Message sent to next instance' + }, + to: '+1234567890', + smsCount: 1 + }] + } + }; + + mockHttp.post.mockResolvedValue(mockResponse); + + const result = await sms.sendV3(requestWithUrlOptions); + + expect(result).toEqual(mockResponse.data); + }); + + it('should_validate_delivery_time_window_successfully', async () => { + const requestWithTimeWindow: SendSmsV3Request = { + messages: [{ + from: 'InfoSMS', + destinations: [{ to: '+1234567890' }], + text: 'Scheduled message', + deliveryTimeWindow: { + days: ['MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY'], + from: '09:00', + to: '17:00' + } + }] + }; + + const mockResponse = { + data: { + bulkId: 'bulk-scheduled-123', + messages: [{ + messageId: 'msg-scheduled-123', + status: { + groupId: 1, + groupName: 'PENDING', + id: 26, + name: 'MESSAGE_ACCEPTED', + description: 'Message sent to next instance' + }, + to: '+1234567890', + smsCount: 1 + }] + } + }; + + mockHttp.post.mockResolvedValue(mockResponse); + + const result = await sms.sendV3(requestWithTimeWindow); + + expect(result).toEqual(mockResponse.data); + }); + }); +}); diff --git a/test/utils/sms-utils.test.ts b/test/utils/sms-utils.test.ts new file mode 100644 index 0000000..8791c7c --- /dev/null +++ b/test/utils/sms-utils.test.ts @@ -0,0 +1,337 @@ +/** + * SMS Utilities Tests + * + * Comprehensive test suite for SMS utility functions + * + * @author SMS Modernization Team + * @version 1.0.0 + */ + +import { + MessageEncoding, + detectMessageEncoding, + calculateMessageParts, + formatPhoneNumber, + isValidPhoneNumber, + isValidSenderId, + estimateSmsCost, + formatSendAtDateTime, + isValidNotifyUrl, + generateMessageId, + splitTextIntoParts, + isValidDeliveryTimeWindow +} from '../../src/utils/sms-utils'; + +describe('SMS Utilities', () => { + describe('detectMessageEncoding()', () => { + it('should_detect_gsm_7bit_encoding_for_basic_text', () => { + const text = 'Hello World! This is a test message.'; + const encoding = detectMessageEncoding(text); + expect(encoding).toBe(MessageEncoding.GSM_7BIT); + }); + + it('should_detect_unicode_encoding_for_emoji', () => { + const text = 'Hello World! 🚀'; + const encoding = detectMessageEncoding(text); + expect(encoding).toBe(MessageEncoding.UNICODE); + }); + + it('should_detect_gsm_7bit_for_extended_characters', () => { + const text = 'Hello {World}'; + const encoding = detectMessageEncoding(text); + expect(encoding).toBe(MessageEncoding.GSM_7BIT); + }); + + it('should_return_gsm_7bit_for_empty_string', () => { + const encoding = detectMessageEncoding(''); + expect(encoding).toBe(MessageEncoding.GSM_7BIT); + }); + }); + + describe('calculateMessageParts()', () => { + it('should_calculate_single_part_for_short_gsm_message', () => { + const text = 'Hello World!'; + const result = calculateMessageParts(text); + + expect(result.encoding).toBe(MessageEncoding.GSM_7BIT); + expect(result.parts).toBe(1); + expect(result.length).toBe(12); + expect(result.charactersPerPart).toBe(160); + expect(result.remainingCharacters).toBe(148); + }); + + it('should_calculate_multiple_parts_for_long_gsm_message', () => { + const text = 'A'.repeat(200); + const result = calculateMessageParts(text); + + expect(result.encoding).toBe(MessageEncoding.GSM_7BIT); + expect(result.parts).toBe(2); + expect(result.charactersPerPart).toBe(153); + }); + + it('should_calculate_single_part_for_short_unicode_message', () => { + const text = 'Hello 🚀'; + const result = calculateMessageParts(text); + + expect(result.encoding).toBe(MessageEncoding.UNICODE); + expect(result.parts).toBe(1); + expect(result.charactersPerPart).toBe(70); + }); + + it('should_calculate_multiple_parts_for_long_unicode_message', () => { + const text = '🚀'.repeat(80); + const result = calculateMessageParts(text); + + expect(result.encoding).toBe(MessageEncoding.UNICODE); + expect(result.parts).toBe(2); + expect(result.charactersPerPart).toBe(67); + }); + + it('should_count_extended_gsm_characters_as_double', () => { + const text = '{}'.repeat(80); // 160 effective characters + const result = calculateMessageParts(text); + + expect(result.encoding).toBe(MessageEncoding.GSM_7BIT); + expect(result.parts).toBe(2); + expect(result.length).toBe(160); + }); + + it('should_return_zero_parts_for_empty_string', () => { + const result = calculateMessageParts(''); + + expect(result.parts).toBe(0); + expect(result.length).toBe(0); + expect(result.remainingCharacters).toBe(160); + }); + }); + + describe('formatPhoneNumber()', () => { + it('should_format_valid_e164_number', () => { + const result = formatPhoneNumber('+1234567890'); + + expect(result.isValid).toBe(true); + expect(result.formatted).toBe('+1234567890'); + expect(result.original).toBe('+1234567890'); + }); + + it('should_format_number_with_spaces_and_dashes', () => { + const result = formatPhoneNumber('+1 (234) 567-890'); + + expect(result.isValid).toBe(true); + expect(result.formatted).toBe('+1234567890'); + }); + + it('should_add_default_country_code', () => { + const result = formatPhoneNumber('234567890', '+1'); + + expect(result.isValid).toBe(true); + expect(result.formatted).toBe('+1234567890'); + }); + + it('should_return_invalid_for_empty_string', () => { + const result = formatPhoneNumber(''); + + expect(result.isValid).toBe(false); + expect(result.formatted).toBe(''); + }); + + it('should_return_invalid_for_too_short_number', () => { + const result = formatPhoneNumber('+123'); + + expect(result.isValid).toBe(false); + }); + + it('should_return_invalid_for_too_long_number', () => { + const result = formatPhoneNumber('+' + '1'.repeat(16)); + + expect(result.isValid).toBe(false); + }); + }); + + describe('isValidPhoneNumber()', () => { + it('should_validate_correct_e164_format', () => { + expect(isValidPhoneNumber('+1234567890')).toBe(true); + expect(isValidPhoneNumber('+44123456789')).toBe(true); + }); + + it('should_reject_invalid_formats', () => { + expect(isValidPhoneNumber('1234567890')).toBe(false); + expect(isValidPhoneNumber('+0123456789')).toBe(false); + expect(isValidPhoneNumber('')).toBe(false); + expect(isValidPhoneNumber('+123')).toBe(false); + }); + }); + + describe('isValidSenderId()', () => { + it('should_validate_phone_number_sender', () => { + expect(isValidSenderId('+1234567890')).toBe(true); + }); + + it('should_validate_alphanumeric_sender', () => { + expect(isValidSenderId('InfoSMS')).toBe(true); + expect(isValidSenderId('Test123')).toBe(true); + }); + + it('should_reject_invalid_senders', () => { + expect(isValidSenderId('')).toBe(false); + expect(isValidSenderId('TooLongSenderID')).toBe(false); + expect(isValidSenderId('Invalid@Sender')).toBe(false); + }); + }); + + describe('estimateSmsCost()', () => { + it('should_calculate_cost_for_single_part_message', () => { + const text = 'Hello World!'; + const destinations = ['+1234567890', '+0987654321']; + const result = estimateSmsCost(text, destinations, 0.05); + + expect(result.totalMessages).toBe(2); + expect(result.totalParts).toBe(2); + expect(result.costPerMessage).toBe(0.05); + expect(result.totalCost).toBe(0.10); + expect(result.encoding).toBe(MessageEncoding.GSM_7BIT); + }); + + it('should_calculate_cost_for_multi_part_message', () => { + const text = 'A'.repeat(200); + const destinations = ['+1234567890']; + const result = estimateSmsCost(text, destinations, 0.05); + + expect(result.totalMessages).toBe(1); + expect(result.totalParts).toBe(2); + expect(result.costPerMessage).toBe(0.10); + expect(result.totalCost).toBe(0.10); + }); + }); + + describe('formatSendAtDateTime()', () => { + it('should_format_valid_future_date_string', () => { + const futureDate = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours from now + const result = formatSendAtDateTime(futureDate.toISOString()); + + expect(result).toBe(futureDate.toISOString()); + }); + + it('should_format_valid_future_date_object', () => { + const futureDate = new Date(Date.now() + 24 * 60 * 60 * 1000); + const result = formatSendAtDateTime(futureDate); + + expect(result).toBe(futureDate.toISOString()); + }); + + it('should_throw_error_for_past_date', () => { + const pastDate = new Date(Date.now() - 24 * 60 * 60 * 1000); + + expect(() => formatSendAtDateTime(pastDate)).toThrow('sendAt datetime must be in the future'); + }); + + it('should_throw_error_for_invalid_date', () => { + expect(() => formatSendAtDateTime('invalid-date')).toThrow('Invalid datetime format'); + }); + }); + + describe('isValidNotifyUrl()', () => { + it('should_validate_https_urls', () => { + expect(isValidNotifyUrl('https://example.com/webhook')).toBe(true); + }); + + it('should_validate_http_urls', () => { + expect(isValidNotifyUrl('http://example.com/webhook')).toBe(true); + }); + + it('should_reject_invalid_protocols', () => { + expect(isValidNotifyUrl('ftp://example.com')).toBe(false); + expect(isValidNotifyUrl('file:///path/to/file')).toBe(false); + }); + + it('should_reject_invalid_urls', () => { + expect(isValidNotifyUrl('not-a-url')).toBe(false); + expect(isValidNotifyUrl('')).toBe(false); + }); + }); + + describe('generateMessageId()', () => { + it('should_generate_unique_message_ids', () => { + const id1 = generateMessageId(); + const id2 = generateMessageId(); + + expect(id1).not.toBe(id2); + expect(id1).toMatch(/^msg-\d+-[a-z0-9]+$/); + }); + + it('should_use_custom_prefix', () => { + const id = generateMessageId('test'); + + expect(id).toMatch(/^test-\d+-[a-z0-9]+$/); + }); + }); + + describe('splitTextIntoParts()', () => { + it('should_return_single_part_for_short_text', () => { + const text = 'Hello World!'; + const parts = splitTextIntoParts(text); + + expect(parts).toHaveLength(1); + expect(parts[0]).toBe(text); + }); + + it('should_split_long_text_into_multiple_parts', () => { + const text = 'A'.repeat(200); + const parts = splitTextIntoParts(text); + + expect(parts.length).toBeGreaterThan(1); + expect(parts.join('')).toBe(text); + }); + + it('should_return_empty_array_for_empty_text', () => { + const parts = splitTextIntoParts(''); + + expect(parts).toEqual([]); + }); + }); + + describe('isValidDeliveryTimeWindow()', () => { + it('should_validate_correct_time_window', () => { + const timeWindow = { + days: ['MONDAY', 'TUESDAY', 'WEDNESDAY'], + from: '09:00', + to: '17:00' + }; + + expect(isValidDeliveryTimeWindow(timeWindow)).toBe(true); + }); + + it('should_validate_time_window_without_times', () => { + const timeWindow = { + days: ['MONDAY', 'FRIDAY'] + }; + + expect(isValidDeliveryTimeWindow(timeWindow)).toBe(true); + }); + + it('should_reject_invalid_days', () => { + const timeWindow = { + days: ['INVALID_DAY'] + }; + + expect(isValidDeliveryTimeWindow(timeWindow)).toBe(false); + }); + + it('should_reject_invalid_time_format', () => { + const timeWindow = { + days: ['MONDAY'], + from: '25:00' + }; + + expect(isValidDeliveryTimeWindow(timeWindow)).toBe(false); + }); + + it('should_reject_empty_days_array', () => { + const timeWindow = { + days: [] + }; + + expect(isValidDeliveryTimeWindow(timeWindow)).toBe(false); + }); + }); +});