Skip to content
Merged
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
21 changes: 9 additions & 12 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -65,22 +65,19 @@ SENDGRID_FROM_EMAIL=noreply@scholarmancy.com
SENDGRID_FROM_NAME=Scholaracle
SENDGRID_REPLY_TO=rvegajr@yolovibecodebootcamp.com

# --- Twilio SMS (optional) ---
# Obtain from https://www.twilio.com/console
TWILIO_ACCOUNT_SID=
TWILIO_API_KEY_SID=
TWILIO_API_KEY_SECRET=
# Auth token — required in production for webhook signature validation
TWILIO_AUTH_TOKEN=
TWILIO_FROM_NUMBER=
# Messaging Service SID — enables status callbacks + opt-out handling at the service level
TWILIO_MESSAGING_SERVICE_SID=
# --- Scholarmancy SMS (Noctusoft relay) ---
# Outbound: POST https://api.twilio.noctusoft.com/sms/send (Bearer token below).
NOCTUSOFT_API_KEY=
# Inbound webhook HMAC: x-relay-signature = base64(HMAC-SHA256(secret, publicUrl + rawBody))
RELAY_INBOUND_SECRET=
# Optional overrides (defaults to production api.scholarmancy.com webhook URLs)
# RELAY_TWILIO_SMS_WEBHOOK_URL=https://api.scholarmancy.com/api/webhooks/twilio/sms
# RELAY_TWILIO_STATUS_WEBHOOK_URL=https://api.scholarmancy.com/api/webhooks/twilio/status

# --- Noctusoft API Relay (optional) ---
# Route vendor API calls through the Noctusoft unified relay gateway instead of
# hitting SendGrid/Twilio directly. Leave unset for direct vendor access.
# hitting SendGrid directly. Leave unset for direct vendor access.
# SENDGRID_BASE_URL=https://api.sendgrid.noctusoft.com
# TWILIO_API_BASE_URL=https://api.twilio.noctusoft.com

# --- AI Personalization (optional) ---
# Anthropic API key for LLM-powered notification personalization
Expand Down
2 changes: 1 addition & 1 deletion packages/agents/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
"@scholaracle/studio-core": "workspace:*",
"@scholaracle/logger": "workspace:*",
"@sendgrid/mail": "^8.1.0",
"twilio": "^5.0.0",
"libphonenumber-js": "^1.12.9",
"mongodb": "^6.3.0",
"nodemailer": "^6.9.0"
},
Expand Down
259 changes: 28 additions & 231 deletions packages/agents/src/delivery/SMSDelivery/SMSDelivery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,246 +5,43 @@ import {
NotificationPriority,
AgentType,
} from '@scholaracle/contracts';
import { DeliveryError } from '@scholaracle/contracts';
import type { Twilio } from 'twilio';
import type { GuardedSmsSender } from '../../sms/GuardedSmsSender';

describe('SMSDelivery', () => {
let smsDelivery: SMSDelivery;
let mockTwilio: {
messages: {
create: jest.Mock;
};
};

const testConfig = {
accountSid: 'test-account-sid',
authToken: 'test-auth-token',
fromNumber: '+15551234567',
};
let mockGuarded: jest.Mocked<Pick<GuardedSmsSender, 'sendTransactional'>>;

beforeEach(() => {
mockTwilio = {
messages: {
create: jest.fn(),
},
mockGuarded = {
sendTransactional: jest.fn().mockResolvedValue({ messageId: 'SM123' }),
};

smsDelivery = new SMSDelivery(testConfig, mockTwilio as unknown as Twilio);
});

afterEach(() => {
jest.clearAllMocks();
smsDelivery = new SMSDelivery(mockGuarded as unknown as GuardedSmsSender);
});

describe('supports', () => {
it('should return true for SMS channel', () => {
// Act
const result = smsDelivery.supports(NotificationChannel.SMS);

// Assert
expect(result).toBe(true);
});

it('should return false for non-SMS channels', () => {
// Act & Assert
expect(smsDelivery.supports(NotificationChannel.EMAIL)).toBe(false);
expect(smsDelivery.supports(NotificationChannel.PUSH)).toBe(false);
expect(smsDelivery.supports(NotificationChannel.IN_APP)).toBe(false);
});
it('supports SMS channel only', () => {
expect(smsDelivery.supports(NotificationChannel.SMS)).toBe(true);
expect(smsDelivery.supports(NotificationChannel.EMAIL)).toBe(false);
});

describe('deliver', () => {
it('should deliver SMS notification successfully', async () => {
// Arrange
const notification = new Notification({
agentType: AgentType.STUDENT,
studentId: 'student-123',
userId: '+15559876543',
subject: 'MISSING ASSIGNMENT',
body: 'Math: Homework 5\nDue: 2 days ago\nValue: 25 points\n\nSubmit immediately.',
priority: NotificationPriority.HIGH,
triggerType: 'missing_assignment',
});

const messageSid = 'SM1234567890abcdef';
mockTwilio.messages.create.mockResolvedValue({
sid: messageSid,
status: 'queued',
to: notification.userId,
from: testConfig.fromNumber,
body: notification.body,
} as unknown as Awaited<ReturnType<typeof mockTwilio.messages.create>>);

// Act
const result = await smsDelivery.deliver(notification);

// Assert
expect(result.success).toBe(true);
expect(result.channel).toBe(NotificationChannel.SMS);
expect(result.messageId).toBe(messageSid);
expect(mockTwilio.messages.create).toHaveBeenCalledTimes(1);
const callArgs = mockTwilio.messages.create.mock.calls[0]?.[0] as {
to?: string;
from?: string;
body?: string;
};
expect(callArgs?.to).toBe(notification.userId);
expect(callArgs?.from).toBe(testConfig.fromNumber);
expect(callArgs?.body).toBeDefined();
});

it('should format SMS body with subject prefix', async () => {
// Arrange
const notification = new Notification({
agentType: AgentType.PARENT,
studentId: 'student-123',
userId: '+15559876543',
subject: 'John Doe - Grade Drop Alert',
body: 'Math grade dropped from 92% to 85%',
priority: NotificationPriority.HIGH,
triggerType: 'grade_drop',
});

mockTwilio.messages.create.mockResolvedValue({
sid: 'SM123',
status: 'queued',
} as unknown as Awaited<ReturnType<typeof mockTwilio.messages.create>>);

// Act
await smsDelivery.deliver(notification);

// Assert
const callArgs = mockTwilio.messages.create.mock.calls[0]?.[0] as {
body?: string;
};
if (callArgs?.body) {
expect(callArgs.body).toContain(notification.subject);
expect(callArgs.body).toContain(notification.body);
}
});

it('should truncate body if exceeds SMS length limit', async () => {
// Arrange
const longBody = 'A'.repeat(2000);
const notification = new Notification({
agentType: AgentType.STUDENT,
studentId: 'student-123',
userId: '+15559876543',
subject: 'Test',
body: longBody,
priority: NotificationPriority.MEDIUM,
triggerType: 'test',
});

mockTwilio.messages.create.mockResolvedValue({
sid: 'SM123',
status: 'queued',
} as unknown as Awaited<ReturnType<typeof mockTwilio.messages.create>>);

// Act
await smsDelivery.deliver(notification);

// Assert
const callArgs = mockTwilio.messages.create.mock.calls[0]?.[0] as {
body?: string;
};
expect(callArgs?.body?.length).toBeLessThanOrEqual(1600);
});

it('should use userId as recipient phone number', async () => {
// Arrange
const notification = new Notification({
agentType: AgentType.STUDENT,
studentId: 'student-123',
userId: '+15551234567',
subject: 'Test',
body: 'Test body',
priority: NotificationPriority.MEDIUM,
triggerType: 'test',
});

mockTwilio.messages.create.mockResolvedValue({
sid: 'SM123',
status: 'queued',
} as unknown as Awaited<ReturnType<typeof mockTwilio.messages.create>>);

// Act
await smsDelivery.deliver(notification);

// Assert
const callArgs = mockTwilio.messages.create.mock.calls[0]?.[0] as {
to?: string;
};
expect(callArgs?.to).toBe('+15551234567');
});

it('should throw DeliveryError when Twilio API fails', async () => {
// Arrange
const notification = new Notification({
agentType: AgentType.STUDENT,
studentId: 'student-123',
userId: '+15551234567',
subject: 'Test',
body: 'Test body',
priority: NotificationPriority.HIGH,
triggerType: 'test',
});

const twilioError = new Error('Invalid phone number');
mockTwilio.messages.create.mockRejectedValue(twilioError);

// Act & Assert
await expect(smsDelivery.deliver(notification)).rejects.toThrow(DeliveryError);
await expect(smsDelivery.deliver(notification)).rejects.toThrow(
expect.objectContaining({
channel: NotificationChannel.SMS,
})
);
});

it('should handle Twilio error response format', async () => {
// Arrange
const notification = new Notification({
agentType: AgentType.STUDENT,
studentId: 'student-123',
userId: '+15551234567',
subject: 'Test',
body: 'Test body',
priority: NotificationPriority.HIGH,
triggerType: 'test',
});

const twilioError = {
code: 21211,
message: "Invalid 'To' Phone Number",
status: 400,
};
mockTwilio.messages.create.mockRejectedValue(twilioError);

// Act & Assert
await expect(smsDelivery.deliver(notification)).rejects.toThrow(DeliveryError);
});

it('should handle error without message property', async () => {
// Arrange
const notification = new Notification({
agentType: AgentType.STUDENT,
studentId: 'student-123',
userId: '+15551234567',
subject: 'Test',
body: 'Test body',
priority: NotificationPriority.HIGH,
triggerType: 'test',
});

const errorWithoutMessage = { code: 500 };
mockTwilio.messages.create.mockRejectedValue(errorWithoutMessage);

// Act & Assert
await expect(smsDelivery.deliver(notification)).rejects.toThrow(DeliveryError);
await expect(smsDelivery.deliver(notification)).rejects.toThrow(
'Unknown error occurred during SMS delivery'
);
});
it('delivers via guarded sender with formatted body', async () => {
const notification = new Notification({
id: 'n1',
agentType: AgentType.PARENT,
studentId: 'stu-1',
userId: '+15125550100',
subject: 'Due tomorrow',
body: 'Math homework',
priority: NotificationPriority.MEDIUM,
triggerType: 'deadline',
channels: [NotificationChannel.SMS],
});
const result = await smsDelivery.deliver(notification);
expect(result.success).toBe(true);
expect(result.messageId).toBe('SM123');
expect(mockGuarded.sendTransactional).toHaveBeenCalledWith(
'+15125550100',
'Due tomorrow\n\nMath homework',
expect.objectContaining({ subject: 'Due tomorrow' })
);
});
});
Loading
Loading