@objectstack/service-sms 16.1.0 → 17.0.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,114 +0,0 @@
1
- // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
-
3
- import { describe, it, expect, vi } from 'vitest';
4
- import { AliyunSmsTransport } from './aliyun.js';
5
- import { TwilioSmsTransport } from './twilio.js';
6
- import { makeSmsTransport } from './index.js';
7
- import { LogSmsTransport } from '../sms-service.js';
8
-
9
- const jsonResponse = (body: any, status = 200) =>
10
- ({
11
- ok: status >= 200 && status < 300,
12
- status,
13
- statusText: 'x',
14
- json: async () => body,
15
- }) as any;
16
-
17
- describe('AliyunSmsTransport', () => {
18
- const base = {
19
- accessKeyId: 'ak',
20
- accessKeySecret: 'secret',
21
- signName: '测试签名',
22
- defaultTemplateCode: 'SMS_123',
23
- };
24
-
25
- it('requires credentials + sign name', () => {
26
- expect(() => new AliyunSmsTransport({ ...base, accessKeyId: '' } as any)).toThrow(/accessKeyId/);
27
- expect(() => new AliyunSmsTransport({ ...base, signName: '' } as any)).toThrow(/signName/);
28
- });
29
-
30
- it('sends a signed SendSms request with template params', async () => {
31
- const fetchImpl = vi.fn(async () => jsonResponse({ Code: 'OK', BizId: 'biz_1', RequestId: 'req_1' }));
32
- const t = new AliyunSmsTransport({ ...base, fetchImpl: fetchImpl as any });
33
- const r = await t.send({ to: '+8613800000000', body: 'ignored', templateParams: { code: '123456' } });
34
- expect(r.messageId).toBe('biz_1');
35
-
36
- const [url, init] = fetchImpl.mock.calls[0] as any[];
37
- expect(String(url)).toContain('https://dysmsapi.aliyuncs.com/?');
38
- expect(String(url)).toContain('PhoneNumbers=%2B8613800000000');
39
- expect(String(url)).toContain('TemplateCode=SMS_123');
40
- expect(decodeURIComponent(String(url))).toContain('{"code":"123456"}');
41
- expect(init.method).toBe('POST');
42
- expect(init.headers.Authorization).toMatch(/^ACS3-HMAC-SHA256 Credential=ak,SignedHeaders=host;x-acs-action;/);
43
- expect(init.headers['x-acs-action']).toBe('SendSms');
44
- expect(init.headers['x-acs-version']).toBe('2017-05-25');
45
- });
46
-
47
- it('falls back to { content: body } for the default catch-all template', async () => {
48
- const fetchImpl = vi.fn(async () => jsonResponse({ Code: 'OK', BizId: 'b' }));
49
- const t = new AliyunSmsTransport({ ...base, fetchImpl: fetchImpl as any });
50
- await t.send({ to: '13800000000', body: 'hello world' });
51
- expect(decodeURIComponent(String(fetchImpl.mock.calls[0][0]))).toContain('{"content":"hello world"}');
52
- });
53
-
54
- it('throws when no template code is available (Aliyun is template-only)', async () => {
55
- const t = new AliyunSmsTransport({ ...base, defaultTemplateCode: undefined, fetchImpl: vi.fn() as any });
56
- await expect(t.send({ to: '13800000000', body: 'x' })).rejects.toThrow(/template/);
57
- });
58
-
59
- it('maps a non-OK Code to a thrown error', async () => {
60
- const fetchImpl = vi.fn(async () => jsonResponse({ Code: 'isv.BUSINESS_LIMIT_CONTROL', Message: 'limit' }));
61
- const t = new AliyunSmsTransport({ ...base, fetchImpl: fetchImpl as any });
62
- await expect(t.send({ to: '13800000000', body: 'x' })).rejects.toThrow(/BUSINESS_LIMIT_CONTROL/);
63
- });
64
- });
65
-
66
- describe('TwilioSmsTransport', () => {
67
- const base = { accountSid: 'AC123', authToken: 'tok', from: '+15005550006' };
68
-
69
- it('requires credentials and a sender', () => {
70
- expect(() => new TwilioSmsTransport({ ...base, authToken: '' } as any)).toThrow(/accountSid and authToken/);
71
- expect(() => new TwilioSmsTransport({ accountSid: 'AC123', authToken: 'tok' } as any)).toThrow(/from/);
72
- });
73
-
74
- it('POSTs the Messages resource with Basic auth + form body', async () => {
75
- const fetchImpl = vi.fn(async () => jsonResponse({ sid: 'SM1', status: 'queued' }, 201));
76
- const t = new TwilioSmsTransport({ ...base, fetchImpl: fetchImpl as any });
77
- const r = await t.send({ to: '+15005550009', body: 'hi there' });
78
- expect(r.messageId).toBe('SM1');
79
-
80
- const [url, init] = fetchImpl.mock.calls[0] as any[];
81
- expect(String(url)).toBe('https://api.twilio.com/2010-04-01/Accounts/AC123/Messages.json');
82
- expect(init.headers.Authorization).toBe(`Basic ${Buffer.from('AC123:tok').toString('base64')}`);
83
- const form = new URLSearchParams(init.body);
84
- expect(form.get('To')).toBe('+15005550009');
85
- expect(form.get('Body')).toBe('hi there');
86
- expect(form.get('From')).toBe('+15005550006');
87
- });
88
-
89
- it('prefers MessagingServiceSid over From when configured', async () => {
90
- const fetchImpl = vi.fn(async () => jsonResponse({ sid: 'SM2' }, 201));
91
- const t = new TwilioSmsTransport({ accountSid: 'AC123', authToken: 'tok', messagingServiceSid: 'MG9', fetchImpl: fetchImpl as any });
92
- await t.send({ to: '+15005550009', body: 'x' });
93
- const form = new URLSearchParams((fetchImpl.mock.calls[0] as any[])[1].body);
94
- expect(form.get('MessagingServiceSid')).toBe('MG9');
95
- expect(form.get('From')).toBeNull();
96
- });
97
-
98
- it('maps an error response to a thrown error', async () => {
99
- const fetchImpl = vi.fn(async () => jsonResponse({ code: 21211, message: 'invalid To' }, 400));
100
- const t = new TwilioSmsTransport({ ...base, fetchImpl: fetchImpl as any });
101
- await expect(t.send({ to: '+1', body: 'x' })).rejects.toThrow(/21211.*invalid To/);
102
- });
103
- });
104
-
105
- describe('makeSmsTransport', () => {
106
- it('builds by provider tag', () => {
107
- expect(makeSmsTransport({ provider: 'log' })).toBeInstanceOf(LogSmsTransport);
108
- expect(makeSmsTransport({ provider: 'aliyun', options: { accessKeyId: 'a', accessKeySecret: 'b', signName: 'c' } }))
109
- .toBeInstanceOf(AliyunSmsTransport);
110
- expect(makeSmsTransport({ provider: 'twilio', options: { accountSid: 'a', authToken: 'b', from: '+1' } }))
111
- .toBeInstanceOf(TwilioSmsTransport);
112
- expect(() => makeSmsTransport({ provider: 'nope' as any })).toThrow(/unknown provider/);
113
- });
114
- });
@@ -1,68 +0,0 @@
1
- // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
-
3
- import type { ISmsTransport, NormalizedSmsMessage, SmsTransportSendResult } from '@objectstack/spec/contracts';
4
-
5
- export interface TwilioSmsTransportOptions {
6
- accountSid: string;
7
- authToken: string;
8
- /** Sender number (E.164). One of `from` / `messagingServiceSid` is required. */
9
- from?: string;
10
- /** Twilio Messaging Service SID (alternative to a fixed `from` number). */
11
- messagingServiceSid?: string;
12
- /** API base URL override (tests). Default `https://api.twilio.com`. */
13
- baseUrl?: string;
14
- /** Injectable fetch for tests. */
15
- fetchImpl?: typeof fetch;
16
- }
17
-
18
- /**
19
- * Twilio Programmable Messaging transport — a single `POST
20
- * /2010-04-01/Accounts/{sid}/Messages.json` with HTTP Basic auth. Plain
21
- * `fetch`, no vendor SDK. Free-form: delivers `body` verbatim.
22
- */
23
- export class TwilioSmsTransport implements ISmsTransport {
24
- private readonly baseUrl: string;
25
- private readonly fetchImpl: typeof fetch;
26
-
27
- constructor(private readonly options: TwilioSmsTransportOptions) {
28
- if (!options.accountSid || !options.authToken) {
29
- throw new Error('TwilioSmsTransport: accountSid and authToken are required');
30
- }
31
- if (!options.from && !options.messagingServiceSid) {
32
- throw new Error('TwilioSmsTransport: one of from / messagingServiceSid is required');
33
- }
34
- this.baseUrl = (options.baseUrl ?? 'https://api.twilio.com').replace(/\/$/, '');
35
- this.fetchImpl = options.fetchImpl ?? fetch;
36
- }
37
-
38
- async send(message: NormalizedSmsMessage): Promise<SmsTransportSendResult> {
39
- const form = new URLSearchParams({
40
- To: message.to,
41
- Body: message.body,
42
- ...(this.options.messagingServiceSid
43
- ? { MessagingServiceSid: this.options.messagingServiceSid }
44
- : { From: this.options.from as string }),
45
- });
46
- const auth = Buffer.from(`${this.options.accountSid}:${this.options.authToken}`).toString('base64');
47
- const response = await this.fetchImpl(
48
- `${this.baseUrl}/2010-04-01/Accounts/${encodeURIComponent(this.options.accountSid)}/Messages.json`,
49
- {
50
- method: 'POST',
51
- headers: {
52
- Authorization: `Basic ${auth}`,
53
- 'Content-Type': 'application/x-www-form-urlencoded',
54
- },
55
- body: form.toString(),
56
- },
57
- );
58
-
59
- let payload: any = {};
60
- try { payload = await response.json(); } catch { /* non-JSON error body */ }
61
- if (!response.ok) {
62
- const code = payload?.code ?? `HTTP_${response.status}`;
63
- const detail = payload?.message ?? response.statusText ?? 'request failed';
64
- throw new Error(`Twilio send failed (${code}): ${detail}`);
65
- }
66
- return { messageId: String(payload.sid ?? ''), response: payload.status };
67
- }
68
- }
package/tsconfig.json DELETED
@@ -1,10 +0,0 @@
1
- {
2
- "extends": "../../../tsconfig.json",
3
- "compilerOptions": {
4
- "outDir": "./dist",
5
- "rootDir": "./src",
6
- "types": ["node"]
7
- },
8
- "include": ["src/**/*"],
9
- "exclude": ["dist", "node_modules", "**/*.test.ts"]
10
- }