Files
lindyson-ai/apps/api/tests/emailService.test.ts
T
Juno ChowandClaude Fable 5.1 f665082dad chore: 建立产品仓库,完成开源前的敏感信息清理
前端(Vite + React)、API/worker(Fastify + BullMQ)、api-contracts 契约包、
迁移脚本、测试与自托管 docker-compose 示例。

开源准备:运维脚本与机器信息已拆到私有仓库;公司名、备案号、隐私邮箱改读
publicCompliance.local.json(仓库只保留占位模板);站点地址与客服二维码改为
VITE_ 环境变量;fixtures 中的真实用户标识已匿名化;三个 package.json 标注
Apache-2.0。

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-16 02:10:46 +08:00

320 lines
11 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
poolQuery: vi.fn(),
}));
vi.mock('../src/config/env.js', () => ({
env: {
RESEND_API_KEY: 're_test_1234567890',
RESEND_FROM: 'Lindyson AI <noreply@lindyson.com>',
RESEND_NOTIFY_TO: 'orylite@outlook.com',
RESEND_BLOCKED_TO: 'admin@lindyson.com',
APP_BASE_URL: 'https://lindyson.com',
},
}));
vi.mock('../src/db/pool.js', () => ({
pool: {
query: mocks.poolQuery,
},
}));
import { env } from '../src/config/env.js';
import {
sendInvoiceRequestedAdminEmail,
sendPasswordResetEmail,
sendPaymentSuccessEmail,
sendRegisterCodeEmail,
sendSupportTicketEmail,
sendTransactionalEmail,
sendWelcomeEmail,
} from '../src/notifications/emailService.js';
describe('emailService', () => {
let fetchMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
vi.spyOn(console, 'error').mockImplementation(() => undefined);
mocks.poolQuery.mockReset();
mocks.poolQuery.mockResolvedValue({ rowCount: 1, rows: [] });
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it('通过 Resend API 发送成功并记录 sent 事件', async () => {
fetchMock.mockResolvedValueOnce(jsonResponse({ id: 'em_123' }));
const result = await sendSampleEmail();
expect(result).toEqual({ status: 'sent', providerMessageId: 'em_123' });
expect(fetchMock).toHaveBeenCalledTimes(1);
const init = fetchMock.mock.calls[0][1] as RequestInit;
const headers = new Headers(init.headers);
expect(headers.get('authorization')).toBe('Bearer re_test_1234567890');
expect(headers.get('idempotency-key')).toBe('verification-1');
expect(JSON.parse(String(init.body))).toMatchObject({
from: 'Lindyson AI <noreply@lindyson.com>',
to: ['new.user@example.com'],
subject: '测试邮件',
html: '<p>ok</p>',
});
const params = lastEmailEventParams();
expect(params[0]).toBe('register-code-requested');
expect(params[3]).toBe('sent');
expect(params[4]).toBe('em_123');
expect(JSON.parse(String(params[10]))).toMatchObject({
expiresMinutes: 10,
resendAttempts: 1,
});
});
it('所有用户邮件使用 Lindyson AI 品牌标题和发件人名称', async () => {
fetchMock.mockImplementation(() => Promise.resolve(jsonResponse({ id: 'em_branding' })));
await sendWelcomeEmail({
userId: 'user-1',
email: 'user@example.com',
userName: '测试用户',
});
await sendRegisterCodeEmail({
email: 'user@example.com',
userName: '测试用户',
code: '123456',
expiresMinutes: 10,
idempotencyKey: 'register-code-1',
});
await sendPasswordResetEmail({
userId: 'user-1',
email: 'user@example.com',
userName: '测试用户',
resetUrl: 'https://lindyson.com/reset-password?token=test',
expiresMinutes: 30,
idempotencyKey: 'password-reset-1',
});
await sendPaymentSuccessEmail({
userId: 'user-1',
email: 'user@example.com',
orderId: 'order-1',
planName: '专业版',
amount: 2900,
credits: 100,
});
await sendSupportTicketEmail({
userId: 'user-1',
email: 'user@example.com',
userName: '测试用户',
ticketId: 'ticket-1',
title: '测试工单',
priority: 'normal',
});
const bodies = fetchMock.mock.calls.map(call => JSON.parse(String((call[1] as RequestInit).body)));
expect(bodies.map(body => body.subject)).toEqual([
'欢迎使用 Lindyson AI',
'Lindyson AI 注册邮箱验证码',
'Lindyson AI 密码重置链接',
'Lindyson AI 订单支付成功',
'Lindyson AI 工单已提交:测试工单',
]);
expect(bodies.every(body => body.from === 'Lindyson AI <noreply@lindyson.com>')).toBe(true);
});
it('Resend 4xx 失败不重试并记录 failed 事件', async () => {
fetchMock.mockResolvedValueOnce(jsonResponse({
name: 'validation_error',
message: 'API key is invalid',
}, 401));
const result = await sendSampleEmail();
expect(result).toEqual({
status: 'failed',
error: 'validation_error: API key is invalid',
});
expect(fetchMock).toHaveBeenCalledTimes(1);
const params = lastEmailEventParams();
expect(params[3]).toBe('failed');
expect(params[5]).toBe('validation_error: API key is invalid');
expect(JSON.parse(String(params[10]))).toMatchObject({ resendAttempts: 1 });
});
it('Resend 5xx 失败会用同一个 Idempotency-Key 重试一次', async () => {
fetchMock
.mockResolvedValueOnce(jsonResponse({
name: 'application_error',
message: 'temporary unavailable',
}, 503))
.mockResolvedValueOnce(jsonResponse({ id: 'em_after_retry' }));
const result = await sendSampleEmail();
expect(result).toEqual({ status: 'sent', providerMessageId: 'em_after_retry' });
expect(fetchMock).toHaveBeenCalledTimes(2);
const firstHeaders = new Headers((fetchMock.mock.calls[0][1] as RequestInit).headers);
const secondHeaders = new Headers((fetchMock.mock.calls[1][1] as RequestInit).headers);
expect(firstHeaders.get('idempotency-key')).toBe('verification-1');
expect(secondHeaders.get('idempotency-key')).toBe('verification-1');
expect(JSON.parse(String(lastEmailEventParams()[10]))).toMatchObject({ resendAttempts: 2 });
});
it('网络错误只重试一次并记录最终失败', async () => {
fetchMock
.mockRejectedValueOnce(new Error('network down'))
.mockRejectedValueOnce(new Error('still down'));
const result = await sendSampleEmail();
expect(result).toEqual({ status: 'failed', error: 'still down' });
expect(fetchMock).toHaveBeenCalledTimes(2);
const params = lastEmailEventParams();
expect(params[3]).toBe('failed');
expect(params[5]).toBe('still down');
expect(JSON.parse(String(params[10]))).toMatchObject({ resendAttempts: 2 });
});
it('仅有被屏蔽收件人时不调用 Resend,并按大小写无关规则记录 skipped', async () => {
const result = await sendTransactionalEmail({
eventType: 'password-reset-requested',
to: 'Admin@LINDYSON.com',
subject: '测试屏蔽邮件',
html: '<p>blocked</p>',
});
expect(result).toEqual({
status: 'skipped',
error: '所有收件人均已被全局屏蔽,邮件未发送。',
});
expect(fetchMock).not.toHaveBeenCalled();
expect(mocks.poolQuery).toHaveBeenCalledTimes(1);
const params = lastEmailEventParams();
expect(params[1]).toBe('Admin@LINDYSON.com');
expect(params[3]).toBe('skipped');
expect(params[5]).toContain('RESEND_BLOCKED_TO');
expect(JSON.parse(String(params[10]))).toMatchObject({ resendAttempts: 0 });
});
it('混合收件人只向未屏蔽邮箱投递,并分别记录 skipped 与 sent', async () => {
fetchMock.mockResolvedValueOnce(jsonResponse({ id: 'em_support_admin' }));
const result = await sendTransactionalEmail({
eventType: 'support-ticket-submitted',
to: ['admin@lindyson.com', 'orylite@outlook.com'],
subject: '测试工单通知',
html: '<p>ticket</p>',
relatedTicketId: 'ticket-1',
});
expect(result).toEqual({ status: 'sent', providerMessageId: 'em_support_admin' });
expect(fetchMock).toHaveBeenCalledTimes(1);
const body = JSON.parse(String((fetchMock.mock.calls[0][1] as RequestInit).body));
expect(body.to).toEqual(['orylite@outlook.com']);
expect(body.to).not.toContain('admin@lindyson.com');
expect(mocks.poolQuery).toHaveBeenCalledTimes(2);
const blockedParams = mocks.poolQuery.mock.calls[0][1] as unknown[];
expect(blockedParams[1]).toBe('admin@lindyson.com');
expect(blockedParams[3]).toBe('skipped');
expect(blockedParams[5]).toContain('RESEND_BLOCKED_TO');
const sentParams = mocks.poolQuery.mock.calls[1][1] as unknown[];
expect(sentParams[1]).toBe('orylite@outlook.com');
expect(sentParams[3]).toBe('sent');
expect(sentParams[4]).toBe('em_support_admin');
});
it('发票申请只向管理员邮箱发通知,账号邮箱和接收邮箱只出现在人工处理资料中', async () => {
fetchMock.mockResolvedValueOnce(jsonResponse({ id: 'em_invoice_admin' }));
await sendInvoiceRequestedAdminEmail({
idempotencyKey: 'invoice-requested-admin:invoice-1',
invoiceId: 'invoice-1',
orderId: 'order-1',
userId: 'user-1',
userName: '测试用户',
accountEmail: 'account@example.com',
planName: '专业版',
amount: 2900,
invoiceTitle: '测试公司',
taxId: '91310000TEST',
receiveEmail: 'invoice-delivery@example.com',
note: '电子发票',
});
const init = fetchMock.mock.calls[0][1] as RequestInit;
const body = JSON.parse(String(init.body));
expect(body.to).toEqual(['orylite@outlook.com']);
expect(body.to).not.toContain('account@example.com');
expect(body.to).not.toContain('invoice-delivery@example.com');
expect(body.subject).toBe('Lindyson AI 新发票申请:测试公司');
expect(body.html).toContain('account@example.com');
expect(body.html).toContain('invoice-delivery@example.com');
expect(body.html).toContain('https://lindyson.com/admin/invoices');
expect(new Headers(init.headers).get('idempotency-key')).toBe('invoice-requested-admin:invoice-1');
const params = lastEmailEventParams();
expect(params[0]).toBe('invoice-requested-admin');
expect(params[1]).toBe('orylite@outlook.com');
expect(params[6]).toBe('user-1');
expect(params[7]).toBe('order-1');
expect(params[9]).toBe('invoice-1');
});
it('未配置管理员通知邮箱时跳过投递且不会回退发送给用户', async () => {
const mutableEnv = env as typeof env & { RESEND_NOTIFY_TO?: string };
const previousNotifyTo = mutableEnv.RESEND_NOTIFY_TO;
mutableEnv.RESEND_NOTIFY_TO = undefined;
try {
await expect(sendInvoiceRequestedAdminEmail({
idempotencyKey: 'invoice-requested-admin:invoice-2',
invoiceId: 'invoice-2',
orderId: 'order-2',
userId: 'user-2',
userName: '测试用户',
accountEmail: 'account@example.com',
planName: '专业版',
amount: 2900,
invoiceTitle: '测试公司',
taxId: '',
receiveEmail: 'invoice-delivery@example.com',
note: '',
})).resolves.toEqual(expect.objectContaining({ status: 'skipped' }));
expect(fetchMock).not.toHaveBeenCalled();
expect(mocks.poolQuery).not.toHaveBeenCalled();
} finally {
mutableEnv.RESEND_NOTIFY_TO = previousNotifyTo;
}
});
});
function sendSampleEmail() {
return sendTransactionalEmail({
eventType: 'register-code-requested',
to: 'new.user@example.com',
subject: '测试邮件',
html: '<p>ok</p>',
idempotencyKey: 'verification-1',
metadata: { expiresMinutes: 10 },
});
}
function jsonResponse(body: unknown, status = 200) {
return new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json' },
});
}
function lastEmailEventParams() {
const call = mocks.poolQuery.mock.calls.at(-1);
expect(call).toBeTruthy();
return call![1] as unknown[];
}