Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
262 changes: 262 additions & 0 deletions functions/send-sms/__tests__/handler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,262 @@
import type { FunctionHandler } from '@constructive-io/fn-runtime';

import { createMockContext } from '../../../tests/helpers/mock-context';
import handler, { SendSmsParams, SendSmsResult } from '../handler';

const configuredEnv = {
SMS_SEND_PROVIDER: 'sms-dev',
SMS_DEV_API_URL: 'http://localhost:4001',
};

describe('send-sms handler', () => {
const sendSmsHandler: FunctionHandler<SendSmsParams, SendSmsResult> = handler;
const originalFetch = global.fetch;
let fetchMock: jest.MockedFunction<typeof fetch>;

beforeEach(() => {
fetchMock = jest.fn().mockResolvedValue(
new Response(null, { status: 201 }),
);
global.fetch = fetchMock;
});

afterEach(() => {
global.fetch = originalFetch;
jest.restoreAllMocks();
});

it('sends sms_otp_code messages through the configured sms-dev transport', async () => {
const result = await sendSmsHandler(
{
sms_type: 'sms_otp_code',
phone: '+12025550101',
code: '123456',
},
createMockContext({
env: {
...configuredEnv,
SMS_FROM: 'Constructive Test',
},
}),
);

expect(result).toEqual({ complete: true });
expect(fetchMock).toHaveBeenCalledWith(
'http://localhost:4001/v1/messages',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
to: '+12025550101',
from: 'Constructive Test',
body: 'Your verification code is 123456',
}),
signal: expect.any(AbortSignal),
}),
);
});

it('uses a complete E.164 phone_number from the real DB MFA payload', async () => {
await sendSmsHandler(
{
sms_type: 'mfa_verification_code',
phone_cc: '+',
phone_number: '+12025550102',
code: '234567',
user_id: '00000000-0000-0000-0000-000000000001',
},
createMockContext({ env: configuredEnv }),
);

expect(fetchMock).toHaveBeenCalledWith(
'http://localhost:4001/v1/messages',
expect.objectContaining({
body: JSON.stringify({
to: '+12025550102',
from: 'Constructive',
body: 'Your verification code is 234567',
}),
}),
);
});

it('combines a country code with a local formatted number', async () => {
await sendSmsHandler(
{
sms_type: 'mfa_verification_code',
phone_cc: ' +1 ',
phone_number: ' (202) 555-0103 ',
code: '345678',
},
createMockContext({ env: configuredEnv }),
);

expect(fetchMock).toHaveBeenCalledWith(
'http://localhost:4001/v1/messages',
expect.objectContaining({
body: expect.stringContaining('"to":"+12025550103"'),
}),
);
});

it('normalizes whitespace, parentheses, and hyphens in a direct phone', async () => {
await sendSmsHandler(
{
sms_type: 'sms_otp_code',
phone: ' +1 (202) 555-0104 ',
code: '456789',
},
createMockContext({ env: configuredEnv }),
);

expect(fetchMock).toHaveBeenCalledWith(
'http://localhost:4001/v1/messages',
expect.objectContaining({
body: expect.stringContaining('"to":"+12025550104"'),
}),
);
});

it('allows dry-run without configuring a transport', async () => {
const result = await sendSmsHandler(
{
sms_type: 'sms_otp_code',
phone: '+12025550105',
code: '567890',
},
createMockContext({ env: { SMS_SEND_DRY_RUN: 'true' } }),
);

expect(result).toEqual({ complete: true, dryRun: true });
expect(fetchMock).not.toHaveBeenCalled();
});

it('fails when the provider is not configured', async () => {
await expect(
sendSmsHandler(
{
sms_type: 'sms_otp_code',
phone: '+12025550106',
code: '678901',
},
createMockContext({ env: { SMS_DEV_API_URL: 'http://localhost:4001' } }),
),
).rejects.toThrow('Missing required field: SMS_SEND_PROVIDER');
});

it('fails when the sms-dev URL is not configured', async () => {
await expect(
sendSmsHandler(
{
sms_type: 'sms_otp_code',
phone: '+12025550107',
code: '789012',
},
createMockContext({ env: { SMS_SEND_PROVIDER: 'sms-dev' } }),
),
).rejects.toThrow('Missing required field: SMS_DEV_API_URL');
});

it('reports non-2xx status without exposing the response body', async () => {
fetchMock.mockResolvedValueOnce(
new Response('OTP 890123 for +12025550108', {
status: 503,
statusText: 'Service Unavailable',
}),
);

const request = sendSmsHandler(
{
sms_type: 'sms_otp_code',
phone: '+12025550108',
code: '890123',
},
createMockContext({ env: configuredEnv }),
);

await expect(request).rejects.toThrow(
'sms-dev API returned 503 Service Unavailable',
);
await expect(request).rejects.not.toThrow('890123');
await expect(request).rejects.not.toThrow('+12025550108');
});

it('aborts a request that exceeds the configured timeout', async () => {
fetchMock.mockImplementationOnce((_input, init) =>
new Promise((_resolve, reject) => {
init?.signal?.addEventListener('abort', () => {
reject(new DOMException('Aborted', 'AbortError'));
});
}),
);

await expect(
sendSmsHandler(
{
sms_type: 'sms_otp_code',
phone: '+12025550109',
code: '901234',
},
createMockContext({
env: { ...configuredEnv, SMS_SEND_TIMEOUT_MS: '5' },
}),
),
).rejects.toThrow('sms-dev request timed out after 5ms');
});

it.each([
[{ sms_type: ' ', phone: '+12025550110', code: '012345' }, 'sms_type'],
[{ sms_type: 'sms_otp_code', phone: '+12025550110', code: ' ' }, 'code'],
[{ sms_type: 'sms_otp_code', phone: ' ', code: '012345' }, 'phone'],
])('rejects whitespace-only required fields', async (params, field) => {
await expect(
sendSmsHandler(params, createMockContext({ env: configuredEnv })),
).rejects.toThrow(`Missing required field: ${field}`);
});

it.each(['12345', '1234567', '12a456'])('rejects malformed OTP %s', async (code) => {
await expect(
sendSmsHandler(
{ sms_type: 'sms_otp_code', phone: '+12025550111', code },
createMockContext({ env: configuredEnv }),
),
).rejects.toThrow('Invalid code: expected a 6-digit OTP');
});

it.each([
{ phone: '++12025550112' },
{ phone: '+02025550112' },
{ phone_cc: '+1', phone_number: '+1+2025550112' },
{ phone_cc: '+1', phone_number: 'not-a-phone' },
])('rejects malformed or duplicate-plus phone input', async (phoneInput) => {
await expect(
sendSmsHandler(
{
sms_type: 'sms_otp_code',
code: '123456',
...phoneInput,
},
createMockContext({ env: configuredEnv }),
),
).rejects.toThrow('Invalid phone: expected E.164 format');
});

it('does not log the OTP or complete phone number', async () => {
const context = createMockContext({ env: configuredEnv });

await sendSmsHandler(
{
sms_type: 'sms_otp_code',
phone: '+12025550113',
code: '234567',
},
context,
);

const logOutput = JSON.stringify(
(context.log.info as jest.Mock).mock.calls,
);
expect(logOutput).not.toContain('234567');
expect(logOutput).not.toContain('+12025550113');
expect(logOutput).toContain('***0113');
});
});
11 changes: 11 additions & 0 deletions functions/send-sms/handler.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"name": "send-sms",
"version": "0.1.0",
"type": "node-graphql",
"port": 8092,
"taskIdentifier": "sms:send_verification_code",
"description": "Sends SMS verification codes for auth flows",
"dependencies": {
"@pgpmjs/env": "^2.15.3"
}
}
Loading
Loading