This guide provides a step-by-step walkthrough for integrating AWS Simple Notification Service (SNS) into a NestJS application to send SMS messages directly to phone numbers. We'll cover everything from project setup and AWS configuration to implementation, error handling, security, and testing.
By the end of this tutorial, you'll have a functional NestJS API endpoint capable of accepting a phone number and message, and using AWS SNS to deliver that message as an SMS.
Project Overview and Goals
What we're building: A simple NestJS application with a single API endpoint (POST /sms/send
) that accepts a phone number and a message body, then uses AWS SNS to send the message as an SMS.
Problem solved: Provides a robust, scalable, and cloud-native way to programmatically send SMS messages (like OTPs, notifications, alerts) from your NestJS backend without managing complex telephony infrastructure.
Technologies used:
- NestJS: A progressive Node.js framework for building efficient, reliable, and scalable server-side applications using TypeScript. Chosen for its modular architecture, dependency injection, and built-in tooling.
- AWS SNS: A fully managed messaging service for both application-to-application (A2A) and application-to-person (A2P) communication. Chosen for its direct SMS sending capability, scalability, reliability, and integration with the AWS ecosystem.
- AWS SDK for JavaScript v3: The official AWS SDK for interacting with AWS services, including SNS, from Node.js/TypeScript applications.
- TypeScript: Superset of JavaScript adding static types, enhancing code quality and maintainability.
- dotenv / @nestjs/config: For managing environment variables securely.
System Architecture:
graph LR
Client[Client Application / Postman] -- HTTP POST Request --> API{NestJS API Endpoint (/sms/send)}
API -- Uses --> SmsService{SmsService (NestJS)}
SmsService -- Reads Config --> Config{Environment Variables (.env)}
SmsService -- Calls AWS SDK --> SNSClient[AWS SNS Client (@aws-sdk/client-sns)]
SNSClient -- Sends SMS Request --> AWS_SNS[AWS SNS Service]
AWS_SNS -- Delivers SMS --> Phone[(User's Phone)]
Config -- Stores --> AWSCreds[AWS Credentials & Region]
Prerequisites:
- Node.js (LTS version recommended, check AWS SDK v3 requirements)
- npm or yarn package manager
- An active AWS account
- AWS Access Key ID and Secret Access Key with permissions to use SNS (we'll cover creating this)
- A text editor or IDE (like VS Code)
- Basic understanding of TypeScript, NestJS, and REST APIs
- AWS CLI (Optional, but helpful for configuration and testing)
1. Setting up the NestJS Project
Let's start by creating a new NestJS project and installing the necessary dependencies.
-
Create a new NestJS project: Open your terminal and run the Nest CLI command:
npx @nestjs/cli new nestjs-sns-sms cd nestjs-sns-sms
Choose your preferred package manager (npm or yarn) when prompted.
-
Install required dependencies: We need the AWS SDK v3 client for SNS, NestJS config module for environment variables, and class-validator/class-transformer for input validation.
# Using npm npm install @aws-sdk/client-sns @nestjs/config class-validator class-transformer # Using yarn yarn add @aws-sdk/client-sns @nestjs/config class-validator class-transformer
@aws-sdk/client-sns
: The modular AWS SDK v3 package specifically for SNS interactions.@nestjs/config
: Handles environment variable loading and access in a structured way.class-validator
&class-transformer
: Used for validating incoming request data (DTOs).
-
Configure Environment Variables: NestJS encourages using a
.env
file for environment-specific configurations, especially sensitive data like AWS credentials.-
Create a
.env
file in the project root (nestjs-sns-sms/.env
):# .env # AWS Credentials - Obtain from IAM User setup AWS_ACCESS_KEY_ID=YOUR_AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY=YOUR_AWS_SECRET_ACCESS_KEY # AWS Region - Choose one that supports SMS, e.g., us-east-1 AWS_REGION=us-east-1 # Optional: Default SMS Type (Transactional or Promotional) # Transactional is better for OTPs/critical alerts, may bypass DND # Promotional is cheaper, better for marketing AWS_SNS_DEFAULT_SMS_TYPE=Transactional
Important: Replace
YOUR_AWS_ACCESS_KEY_ID
andYOUR_AWS_SECRET_ACCESS_KEY
with the actual credentials you'll generate in the next step. Add this.env
file to your.gitignore
to prevent committing secrets. -
Load the configuration module in
src/app.module.ts
:// src/app.module.ts import { Module } from '@nestjs/common'; import { AppController } from './app.controller'; import { AppService } from './app.service'; import { ConfigModule } from '@nestjs/config'; // Import ConfigModule import { SmsModule } from './sms/sms.module'; // We'll create this next @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, // Make ConfigModule available globally envFilePath: '.env', // Specify the env file path }), SmsModule, // Import our future SMS module ], controllers: [AppController], providers: [AppService], }) export class AppModule {}
ConfigModule.forRoot({ isGlobal: true })
makes theConfigService
available throughout the application without needing to importConfigModule
everywhere.
-
2. AWS Setup: IAM User and SNS Configuration
To interact with AWS SNS securely, we need an IAM (Identity and Access Management) user with specific permissions.
-
Create an IAM User:
- Log in to your AWS Management Console.
- Navigate to the IAM service.
- In the left navigation pane, click Users, then click Create user.
- Enter a User name (e.g.,
nestjs-sns-sender
). - Select Provide user access to the AWS Management Console - Optional (Only needed if this user needs console access. For programmatic access only, leave unchecked).
- Select I want to create an IAM user. If selected console access, choose a password method.
- Click Next.
- On the Set permissions page, select Attach policies directly.
- Best Practice (Production): Click Create policy. Use the JSON editor and provide a policy granting only the necessary permissions. This adheres to the principle of least privilege. A minimal policy requires
sns:Publish
. Optionally, addsns:SetSMSAttributes
if setting type per message or other attributes, andsns:CheckIfPhoneNumberIsOptedOut
if checking opt-out status. You can restrict theResource
from"*"
to specific topic ARNs if not sending directly to phones.Give the policy a name (e.g.,{ "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": [ "sns:Publish", "sns:SetSMSAttributes", "sns:CheckIfPhoneNumberIsOptedOut" ], "Resource": "*" } ] }
NestJsSnsSmsPublishOnly
) and create it. Then, back on the "Set permissions" page for the user, search for and attach this custom policy. - Simpler Alternative (Guide/Testing): For simplicity during this guide or initial testing only, you can search for and select the AWS managed policy
AmazonSNSFullAccess
. Be aware this grants broad SNS permissions (publish, manage topics, subscriptions, etc.) and is not recommended for production. - Click Next.
- Review the user details and permissions, then click Create user.
- Crucial Step: On the success page, click on the username you just created. Navigate to the Security credentials tab. Under Access keys, click Create access key.
- Select Application running outside AWS as the use case. Click Next.
- Add an optional description tag. Click Create access key.
- Immediately copy the
Access key ID
andSecret access key
. The secret key is only shown once. Store them securely. - Paste these keys into your
.env
file for theAWS_ACCESS_KEY_ID
andAWS_SECRET_ACCESS_KEY
variables.
-
Choose an AWS Region: Not all AWS regions support sending SMS messages directly via SNS. Regions like
us-east-1
(N. Virginia),us-west-2
(Oregon),eu-west-1
(Ireland), andap-southeast-1
(Singapore) generally do. Check the AWS documentation for the latest list and ensure the region specified in your.env
(AWS_REGION
) supports SMS.us-east-1
is often a safe default choice if you don't have specific region requirements. -
Set Default SMS Type (Optional but Recommended): SNS allows you to optimize SMS delivery for cost (
Promotional
) or reliability (Transactional
). Transactional messages have higher delivery priority and may bypass Do-Not-Disturb (DND) registries in some countries, making them suitable for critical alerts or OTPs.- You can set this default for your entire AWS account in the chosen region.
- Navigate to the SNS service in the AWS Management Console.
- Make sure you are in the correct Region (the one specified in your
.env
file). - In the left navigation pane, click Mobile -> Text messaging (SMS).
- Click Edit in the "Account spending limit and default message type" section.
- Under Default message type, select
Transactional
orPromotional
. - Set a Monthly SMS spend limit (USD) to prevent unexpected costs (e.g.,
1.00
for testing). - Click Save changes.
- Alternatively, we can set this attribute programmatically when sending a message if needed, or rely on the
AWS_SNS_DEFAULT_SMS_TYPE
variable from.env
if we implement reading it later. For simplicity now, setting it in the console is sufficient.
3. Implementing the SMS Service
Now, let's create the core logic for sending SMS messages within our NestJS application.
-
Generate the SMS Module and Service: Use the Nest CLI to generate a module and service for SMS functionality.
npx @nestjs/cli generate module sms npx @nestjs/cli generate service sms --no-spec # --no-spec skips test file generation for now
This creates
src/sms/sms.module.ts
andsrc/sms/sms.service.ts
. TheSmsModule
was already imported intoAppModule
earlier. -
Implement the
SmsService
: Opensrc/sms/sms.service.ts
and add the logic to interact with AWS SNS.// src/sms/sms.service.ts import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { SNSClient, PublishCommand, PublishCommandInput, // SetSMSAttributesCommand, // Optional: If you want to set attributes per message } from '@aws-sdk/client-sns'; import { AwsServiceUnavailableException } from './exceptions/aws-service-unavailable.exception'; // Custom exception @Injectable() export class SmsService { private readonly logger = new Logger(SmsService.name); private readonly snsClient: SNSClient; private readonly defaultSmsType: string; constructor(private readonly configService: ConfigService) { // Instantiate SNS Client during service initialization this.snsClient = new SNSClient({ region: this.configService.get<string>('AWS_REGION'), credentials: { accessKeyId: this.configService.get<string>('AWS_ACCESS_KEY_ID'), secretAccessKey: this.configService.get<string>( 'AWS_SECRET_ACCESS_KEY', ), }, }); this.logger.log('AWS SNS Client Initialized'); // Get default SMS type from config, default to Transactional if not set this.defaultSmsType = this.configService.get<string>( 'AWS_SNS_DEFAULT_SMS_TYPE', 'Transactional', // Default value if not in .env ); } /** * Sends an SMS message to the specified phone number using AWS SNS. * Assumes phoneNumber has been validated by the controller DTO. * @param phoneNumber - The recipient's phone number in E.164 format (e.g., +12065550100). * @param message - The text message body. * @returns The MessageId from AWS SNS on success. * @throws AwsServiceUnavailableException if the SNS interaction fails. */ async sendSms(phoneNumber: string, message: string): Promise<string> { // E.164 validation is handled by the SendSmsDto in the controller layer. // The service assumes it receives valid data based on DTO validation. const params: PublishCommandInput = { PhoneNumber: phoneNumber, Message: message, MessageAttributes: { // Optional: Set message type per message 'AWS.SNS.SMS.SMSType': { DataType: 'String', StringValue: this.defaultSmsType // Use configured default type } } }; try { this.logger.log( `Sending SMS to ${phoneNumber} with type ${this.defaultSmsType}`, ); const command = new PublishCommand(params); const response = await this.snsClient.send(command); this.logger.log(`SMS sent successfully! Message ID: ${response.MessageId}`); return response.MessageId; } catch (error) { this.logger.error(`Failed to send SMS to ${phoneNumber}`, error.stack); // You might want to check the error type (e.g., error.name) for more specific handling // Examples: 'InvalidParameterException', 'AuthorizationError', etc. throw new AwsServiceUnavailableException( `Failed to send SMS via AWS SNS: ${error.message}`, ); } } // Optional: Add methods for checking opt-out status, setting attributes etc. // async checkIfOptedOut(phoneNumber: string): Promise<boolean> { ... } }
-
Create a Custom Exception (Optional but Good Practice): Create a file
src/sms/exceptions/aws-service-unavailable.exception.ts
:// src/sms/exceptions/aws-service-unavailable.exception.ts import { HttpException, HttpStatus } from '@nestjs/common'; export class AwsServiceUnavailableException extends HttpException { constructor(message?: string) { super( message || 'AWS Service is temporarily unavailable. Please try again later.', HttpStatus.SERVICE_UNAVAILABLE, ); } }
This helps in providing a more specific HTTP status code if the SNS service fails. We'll need an exception filter later to handle this properly, or rely on NestJS defaults for now.
-
Ensure
ConfigService
is Available: Make sureConfigModule
is imported correctly insrc/app.module.ts
and configured asisGlobal: true
. TheSmsService
uses@nestjs/config
'sConfigService
via dependency injection to securely retrieve the AWS credentials and region from the environment variables loaded from.env
.
Explanation:
- The
SNSClient
is initialized in the constructor using credentials and region fetched fromConfigService
. - The
sendSms
method constructs thePublishCommandInput
required by the AWS SDK v3.PhoneNumber
: Must be in E.164 format (e.g.,+12223334444
). Validation is now expected to happen at the API layer (DTO).Message
: The content of the SMS.MessageAttributes
(Optional): We explicitly set theAWS.SNS.SMS.SMSType
attribute here based on our configuration. This ensures the message is treated asTransactional
orPromotional
as intended.
- The
snsClient.send()
method sends the command to AWS SNS. - Error handling is included using a
try...catch
block, logging errors and throwing a customAwsServiceUnavailableException
.
4. Building the API Layer
Let's expose the SMS sending functionality through a REST API endpoint.
-
Generate the SMS Controller:
npx @nestjs/cli generate controller sms --no-spec
This creates
src/sms/sms.controller.ts
. -
Create a Data Transfer Object (DTO) for Validation: Create a file
src/sms/dto/send-sms.dto.ts
to define the expected request body structure and apply validation rules.// src/sms/dto/send-sms.dto.ts import { IsNotEmpty, IsString, Matches, MaxLength } from 'class-validator'; export class SendSmsDto { @IsString() @IsNotEmpty() @Matches(/^\+[1-9]\d{1,14}$/, { // E.164 format validation message: 'Phone number must be in E.164 format (e.g., +12065550100)', }) phoneNumber: string; @IsString() @IsNotEmpty() @MaxLength(1600) // SNS message length limits (check current limits) message: string; }
@IsString()
,@IsNotEmpty()
: Ensures the fields are non-empty strings.@Matches()
: Validates thephoneNumber
against the E.164 regex pattern.@MaxLength()
: Basic check for message length (SNS has limits, typically 140 bytes for GSM-7, less for UCS-2).
-
Implement the
SmsController
: Opensrc/sms/sms.controller.ts
and define the endpoint.// src/sms/sms.controller.ts import { Controller, Post, Body, HttpCode, HttpStatus, // ValidationPipe removed from here - rely on global pipe Logger, UsePipes, // Import if you still need ValidationPipe locally for some reason } from '@nestjs/common'; import { SmsService } from './sms.service'; import { SendSmsDto } from './dto/send-sms.dto'; import { AwsServiceUnavailableException } from './exceptions/aws-service-unavailable.exception'; // Import custom exception @Controller('sms') // Route prefix: /sms export class SmsController { private readonly logger = new Logger(SmsController.name); constructor(private readonly smsService: SmsService) {} @Post('send') // Route: POST /sms/send @HttpCode(HttpStatus.ACCEPTED) // Return 202 Accepted on successful request queuing // @Body() decorator now relies on the global ValidationPipe configured in main.ts async sendSms( @Body() sendSmsDto: SendSmsDto, ): Promise<{ messageId: string; status: string }> { this.logger.log(`Received request to send SMS to ${sendSmsDto.phoneNumber}`); try { // DTO validation is handled automatically by the global ValidationPipe const messageId = await this.smsService.sendSms( sendSmsDto.phoneNumber, sendSmsDto.message, ); return { messageId, status: 'SMS request accepted for delivery.' }; } catch (error) { // Catch specific exceptions if needed, or rethrow if (error instanceof AwsServiceUnavailableException) { // Re-throw to let NestJS handle it (or use an Exception Filter) throw error; } // Handle other potential errors from the service layer this.logger.error('Unhandled error in sendSms endpoint', error); // Throw a generic NestJS exception for unexpected errors throw new Error('An unexpected error occurred while processing the SMS request.'); // Generic fallback } } }
-
Enable Global Validation Pipe: For DTO validation to work automatically, enable the
ValidationPipe
globally insrc/main.ts
. This is the recommended approach.// src/main.ts import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; import { ValidationPipe, Logger } from '@nestjs/common'; // Import ValidationPipe and Logger import { ConfigService } from '@nestjs/config'; // Import ConfigService async function bootstrap() { const app = await NestFactory.create(AppModule); const configService = app.get(ConfigService); // Get ConfigService instance const logger = new Logger('Bootstrap'); // Create a logger instance // Enable global DTO validation app.useGlobalPipes(new ValidationPipe({ whitelist: true, // Strip properties not defined in DTO transform: true, // Automatically transform payloads to DTO instances forbidNonWhitelisted: true, // Throw error if non-whitelisted properties are present transformOptions: { enableImplicitConversion: true, // Allow basic type conversions if needed }, })); const port = configService.get<number>('PORT', 3000); // Get port from env or default to 3000 await app.listen(port); logger.log(`Application is running on: ${await app.getUrl()}`); } bootstrap();
Now, any incoming request to the
sendSms
endpoint will have its body automatically validated against theSendSmsDto
. If validation fails, NestJS will return a 400 Bad Request response automatically.
API Endpoint Testing:
You can now test the endpoint using curl
or Postman. Make sure your NestJS application is running (npm run start:dev
).
-
Using
curl
:curl --location --request POST 'http://localhost:3000/sms/send' \ --header 'Content-Type: application/json' \ --data-raw '{ "phoneNumber": "+12065550100", "message": "Hello from NestJS and AWS SNS! (Test)" }'
(Replace
+12065550100
with a valid E.164 test phone number) -
Expected Success Response (202 Accepted):
{ "messageId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", "status": "SMS request accepted for delivery." }
-
Expected Validation Error Response (400 Bad Request): If you send an invalid phone number format:
{ "message": [ "Phone number must be in E.164 format (e.g., +12065550100)" ], "error": "Bad Request", "statusCode": 400 }
5. Error Handling and Logging
We've already implemented basic logging and error handling, but let's refine it.
- Logging: NestJS's built-in
Logger
is used in both the service and controller. It logs information about initialization, incoming requests, successful sends, and errors. In a production environment, you'd typically configure more robust logging (e.g., JSON format, sending logs to CloudWatch or another aggregation service). - Specific Error Handling: The
SmsService
catches errors from thesnsClient.send()
call. It logs the error stack and throws a customAwsServiceUnavailableException
. The controller catches this specific exception and re-throws it, allowing NestJS's default exception filter (or a custom one) to handle generating the 503 response.- Why
HttpStatus.ACCEPTED
(202)? SNSPublish
is asynchronous. A successful API call means SNS accepted the request, not that the SMS was delivered. Returning 202 reflects this. Delivery status can be tracked via SNS Delivery Status Logging (an advanced topic). - Why
AwsServiceUnavailableException
(503)? If we fail to communicate with SNS due to network issues, credential problems caught late, or throttling on the AWS side, it indicates our service's dependency is unavailable. 503 is appropriate. Validation errors result in 400 Bad Request thanks to the globalValidationPipe
.
- Why
- Retry Mechanisms: AWS SDK v3 has built-in retry mechanisms with exponential backoff for many transient network errors or throttled requests. For critical operations, you might consider adding application-level retries using libraries like
async-retry
for specific error types if the default SDK behavior isn't sufficient, but often it is. For sending an SMS, if the initialPublish
fails critically (e.g., invalid credentials), retrying won't help. If it's throttling, the SDK handles it.
6. Security Considerations
Securing the application and credentials is vital.
- Input Validation: Done via
class-validator
in theSendSmsDto
and enforced by the globalValidationPipe
. This prevents invalid data from reaching the service layer and mitigates risks like injection attacks if the message content were used insecurely elsewhere (though less likely for SMS). - Secure Credential Management:
- AWS credentials (
Access Key ID
,Secret Access Key
) are stored in the.env
file for local development. - Crucially, ensure
.env
is listed in your.gitignore
file to prevent accidentally committing secrets to version control. - In production environments, avoid storing credentials directly in files. Use more secure methods like:
- IAM Roles for EC2/ECS/Lambda: If deploying on AWS compute services, assign an IAM Role with the required SNS permissions to the instance/task/function. The SDK will automatically retrieve temporary credentials. This is the most secure method.
- AWS Secrets Manager or Parameter Store: Store credentials securely and fetch them at runtime.
- Environment Variables in Deployment Platform: Platforms like Heroku, Vercel, or CI/CD systems provide secure ways to inject environment variables.
- AWS credentials (
- Rate Limiting: Protect your API from abuse and control costs by implementing rate limiting.
- Install the throttler module:
npm install --save @nestjs/throttler # or yarn add @nestjs/throttler
- Configure it in
src/app.module.ts
:This configuration limits each IP address to 10 requests per 60 seconds across the entire application. You can apply more granular limits per-route if needed.// src/app.module.ts import { Module } from '@nestjs/common'; import { AppController } from './app.controller'; import { AppService } from './app.service'; import { ConfigModule } from '@nestjs/config'; import { SmsModule } from './sms/sms.module'; import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler'; // Import throttler import { APP_GUARD } from '@nestjs/core'; // Import APP_GUARD @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, envFilePath: '.env' }), ThrottlerModule.forRoot([{ // Configure throttler ttl: 60000, // Time-to-live in milliseconds (e.g., 60 seconds) limit: 10, // Max requests per ttl per IP }]), SmsModule, ], controllers: [AppController], providers: [ AppService, { // Apply ThrottlerGuard globally provide: APP_GUARD, useClass: ThrottlerGuard, }, ], }) export class AppModule {}
- Install the throttler module:
- IAM Least Privilege: As mentioned in Section 2, ensure the IAM user or role used has only the permissions required (
sns:Publish
, potentiallysns:SetSMSAttributes
,sns:CheckIfPhoneNumberIsOptedOut
if used) and not broad permissions likeAmazonSNSFullAccess
, especially in production.
7. Testing
Testing ensures the different parts of the application work correctly.
-
Unit Testing
SmsService
: Mock theSNSClient
to avoid making actual AWS calls.// src/sms/sms.service.spec.ts (Example - requires setting up mocks) import { Test, TestingModule } from '@nestjs/testing'; import { SmsService } from './sms.service'; import { ConfigService } from '@nestjs/config'; import { SNSClient, PublishCommand } from '@aws-sdk/client-sns'; import { AwsServiceUnavailableException } from './exceptions/aws-service-unavailable.exception'; import { mockClient } from 'aws-sdk-client-mock'; // Use aws-sdk-client-mock import 'aws-sdk-client-mock-jest'; // Optional: for jest matchers // Mock ConfigService values const mockConfigService = { get: jest.fn((key: string, defaultValue?: any) => { if (key === 'AWS_REGION') return 'us-east-1'; if (key === 'AWS_ACCESS_KEY_ID') return 'test-key-id'; if (key === 'AWS_SECRET_ACCESS_KEY') return 'test-secret-key'; if (key === 'AWS_SNS_DEFAULT_SMS_TYPE') return 'Transactional'; return defaultValue; }), }; // Mock SNSClient using aws-sdk-client-mock const snsMock = mockClient(SNSClient); describe('SmsService', () => { let service: SmsService; beforeEach(async () => { // Reset mock before each test snsMock.reset(); const module: TestingModule = await Test.createTestingModule({ providers: [ SmsService, { provide: ConfigService, useValue: mockConfigService }, ], }).compile(); service = module.get<SmsService>(SmsService); }); it('should be defined', () => { expect(service).toBeDefined(); }); it('should send an SMS successfully', async () => { const phoneNumber = '+15551234567'; const message = 'Test message'; const expectedMessageId = 'mock-message-id-123'; // Mock the send command for SNSClient snsMock.on(PublishCommand).resolves({ MessageId: expectedMessageId }); const messageId = await service.sendSms(phoneNumber, message); expect(messageId).toEqual(expectedMessageId); // Check if send was called with correct parameters expect(snsMock).toHaveReceivedCommandWith(PublishCommand, { PhoneNumber: phoneNumber, Message: message, MessageAttributes: { 'AWS.SNS.SMS.SMSType': { DataType: 'String', StringValue: 'Transactional' }, }, }); }); it('should throw AwsServiceUnavailableException on SNS error', async () => { const phoneNumber = '+15551234567'; const message = 'Test message'; const errorMessage = 'SNS simulated error'; // Mock the send command to reject snsMock.on(PublishCommand).rejects(new Error(errorMessage)); // Expect the specific exception and message await expect(service.sendSms(phoneNumber, message)) .rejects.toThrow(new AwsServiceUnavailableException(`Failed to send SMS via AWS SNS: ${errorMessage}`)); }); // Test for invalid E.164 format removed as validation moved to DTO/Controller layer // If service validation were kept, a test like this would be needed: // it('should throw BadRequestException for invalid E.164 format', async () => { ... }); });
(Note: You'll need to install
aws-sdk-client-mock
and potentially@types/jest
:npm install --save-dev aws-sdk-client-mock jest @types/jest ts-jest
oryarn add --dev aws-sdk-client-mock jest @types/jest ts-jest
and configure Jest if not already set up by Nest CLI) -
Unit Testing
SmsController
: Mock theSmsService
and any global guards applied.// src/sms/sms.controller.spec.ts (Example) import { Test, TestingModule } from '@nestjs/testing'; import { SmsController } from './sms.controller'; import { SmsService } from './sms.service'; import { SendSmsDto } from './dto/send-sms.dto'; import { AwsServiceUnavailableException } from './exceptions/aws-service-unavailable.exception'; import { ThrottlerGuard } from '@nestjs/throttler'; // Import guard if applied globally // No need to import ConfigModule or ThrottlerModule itself for basic guard mocking // Mock SmsService const mockSmsService = { sendSms: jest.fn(), }; describe('SmsController', () => { let controller: SmsController; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [SmsController], providers: [ { provide: SmsService, useValue: mockSmsService }, // If ThrottlerGuard is applied globally via APP_GUARD, // you might need to mock it or provide a mock implementation // to prevent interference during unit tests. // Example: Mocking the guard to always allow requests: { provide: ThrottlerGuard, useValue: { canActivate: jest.fn(() => true) }, }, ], }) // If using global pipes/guards, sometimes overriding them in tests is needed: // .overrideGuard(ThrottlerGuard) // .useValue({ canActivate: jest.fn(() => true) }) .compile(); controller = module.get<SmsController>(SmsController); // Reset mocks before each test if needed jest.clearAllMocks(); }); it('should be defined', () => { expect(controller).toBeDefined(); }); it('should call SmsService.sendSms and return messageId on success', async () => { const sendSmsDto: SendSmsDto = { phoneNumber: '+15559876543', message: 'Controller test', }; const expectedMessageId = 'controller-mock-id-456'; const expectedStatus = 'SMS request accepted for delivery.'; // Setup mock implementation for sendSms mockSmsService.sendSms.mockResolvedValue(expectedMessageId); const result = await controller.sendSms(sendSmsDto); expect(mockSmsService.sendSms).toHaveBeenCalledWith( sendSmsDto.phoneNumber, sendSmsDto.message, ); expect(result).toEqual({ messageId: expectedMessageId, status: expectedStatus }); }); it('should re-throw AwsServiceUnavailableException from service', async () => { const sendSmsDto: SendSmsDto = { phoneNumber: '+15559876543', message: 'Controller error test', }; const exception = new AwsServiceUnavailableException('Service layer error'); // Setup mock to throw the specific exception mockSmsService.sendSms.mockRejectedValue(exception); await expect(controller.sendSms(sendSmsDto)).rejects.toThrow(exception); expect(mockSmsService.sendSms).toHaveBeenCalledWith( sendSmsDto.phoneNumber, sendSmsDto.message, ); }); it('should throw a generic error for unexpected service errors', async () => { const sendSmsDto: SendSmsDto = { phoneNumber: '+15559876543', message: 'Controller generic error test', }; const genericError = new Error('Some unexpected service failure'); // Setup mock to throw a generic error mockSmsService.sendSms.mockRejectedValue(genericError); await expect(controller.sendSms(sendSmsDto)).rejects.toThrow( 'An unexpected error occurred while processing the SMS request.', ); expect(mockSmsService.sendSms).toHaveBeenCalledWith( sendSmsDto.phoneNumber, sendSmsDto.message, ); }); // Note: DTO validation testing is typically handled by e2e tests or implicitly // trusted due to the global ValidationPipe. Unit tests focus on the controller's // interaction with the service assuming valid input. });
-
End-to-End (E2E) Testing: Use NestJS's built-in E2E testing capabilities (
supertest
) to test the entire flow from HTTP request to response, including validation, controller logic, and potentially mocking the AWS SDK at a higher level or using tools like LocalStack for local AWS emulation. E2E tests provide the highest confidence but are slower and more complex to set up.