feat: 补全P0基础功能 - 订单模块、企微通知、邮件/短信真实发送
- 新增订单系统模块(OrderModule):创建/查询/状态流转/取消/统计,54个测试用例通过 - 新增企业微信通知适配器:Webhook发送文本/Markdown消息,支持@指定人 - 邮件适配器升级:Nodemailer SMTP真实发送,模板变量替换,附件支持 - 短信适配器升级:阿里云短信SDK真实发送,模板短信/验证码,频率限制 - 通知模块基类重构:提取isMockMode/createMockResult/createErrorResult公共方法 - Prisma Schema新增Order/OrderItem表及OrderStatus枚举 - 测试覆盖率85.61%,105个测试用例全部通过
This commit is contained in:
parent
e1cc979d50
commit
2efab8e712
|
|
@ -53,3 +53,23 @@ CDN_BASE_URL="https://cdn.yourdomain.com"
|
|||
UPLOAD_MAX_FILE_SIZE=104857600
|
||||
UPLOAD_ALLOWED_MIME_TYPES="*/*"
|
||||
UPLOAD_CHUNK_SIZE=5242880
|
||||
|
||||
# Notification - Mock Mode (set to "true" in development to skip actual sending)
|
||||
NOTIFICATION_MOCK_MODE="true"
|
||||
|
||||
# Notification - SMTP Email
|
||||
SMTP_HOST="smtpdm.aliyun.com"
|
||||
SMTP_PORT="465"
|
||||
SMTP_USER="noreply@yourdomain.com"
|
||||
SMTP_PASS="your-smtp-password"
|
||||
SMTP_FROM='"FischerX" <noreply@yourdomain.com>'
|
||||
|
||||
# Notification - Aliyun SMS
|
||||
ALIYUN_ACCESS_KEY_ID="your-aliyun-access-key-id"
|
||||
ALIYUN_ACCESS_KEY_SECRET="your-aliyun-access-key-secret"
|
||||
ALIYUN_SMS_SIGN_NAME="FischerX"
|
||||
ALIYUN_SMS_TEMPLATE_CODE_VERIFY="SMS_123456"
|
||||
|
||||
# Notification - WeCom Robot Webhook
|
||||
WECOM_WEBHOOK_URL="https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=your-default-key"
|
||||
WECOM_WEBHOOK_URL_ALERT="https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=your-alert-key"
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -20,6 +20,8 @@
|
|||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@alicloud/dysmsapi20170525": "^4.5.1",
|
||||
"@alicloud/openapi-client": "^0.4.15",
|
||||
"@nestjs/cache-manager": "^3.1.2",
|
||||
"@nestjs/common": "^11.0.1",
|
||||
"@nestjs/config": "^4.0.4",
|
||||
|
|
@ -28,19 +30,18 @@
|
|||
"@nestjs/passport": "^11.0.5",
|
||||
"@nestjs/platform-express": "^11.0.1",
|
||||
"@nestjs/swagger": "^8.0.0",
|
||||
"@willsoto/nestjs-prometheus": "^6.0.0",
|
||||
"@opentelemetry/sdk-node": "^0.48.0",
|
||||
"@opentelemetry/instrumentation-nestjs-core": "^0.34.0",
|
||||
"@opentelemetry/auto-instrumentations-node": "^0.41.0",
|
||||
"@opentelemetry/exporter-trace-otlp-proto": "^0.48.0",
|
||||
"@opentelemetry/instrumentation-express": "^0.34.0",
|
||||
"@opentelemetry/instrumentation-http": "^0.48.0",
|
||||
"@opentelemetry/exporter-trace-otlp-proto": "^0.48.0",
|
||||
"@opentelemetry/instrumentation-nestjs-core": "^0.34.0",
|
||||
"@opentelemetry/sdk-node": "^0.48.0",
|
||||
"@opentelemetry/sdk-trace-base": "^1.21.0",
|
||||
"@opentelemetry/auto-instrumentations-node": "^0.41.0",
|
||||
"prom-client": "^15.0.0",
|
||||
"@prisma/client": "6",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/redis": "^4.0.11",
|
||||
"@willsoto/nestjs-prometheus": "^6.0.0",
|
||||
"ali-oss": "^6.21.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"cache-manager": "^7.2.8",
|
||||
|
|
@ -51,8 +52,10 @@
|
|||
"jsonwebtoken": "^9.0.3",
|
||||
"minio": "^8.0.1",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"nodemailer": "^8.0.8",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"prom-client": "^15.0.0",
|
||||
"redis": "^5.12.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
|
|
@ -70,6 +73,7 @@
|
|||
"@types/jest": "^30.0.0",
|
||||
"@types/multer": "^1.4.12",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/nodemailer": "^8.0.0",
|
||||
"@types/supertest": "^7.0.0",
|
||||
"@types/uuid": "^11.0.0",
|
||||
"eslint": "^9.18.0",
|
||||
|
|
@ -77,6 +81,7 @@
|
|||
"eslint-plugin-prettier": "^5.2.2",
|
||||
"globals": "^17.0.0",
|
||||
"jest": "^29.7.0",
|
||||
"jest-util": "^30.4.1",
|
||||
"prettier": "^3.4.2",
|
||||
"prisma": "6",
|
||||
"source-map-support": "^0.5.21",
|
||||
|
|
|
|||
|
|
@ -27,12 +27,21 @@ model User {
|
|||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
roles UserRole[]
|
||||
sessions Session[]
|
||||
files File[]
|
||||
oauthAccounts OAuthAccount[]
|
||||
realnameAuth RealnameAuth?
|
||||
mfaSecret String?
|
||||
roles UserRole[]
|
||||
sessions Session[]
|
||||
files File[]
|
||||
oauthAccounts OAuthAccount[]
|
||||
realnameAuth RealnameAuth?
|
||||
orders Order[]
|
||||
paymentOrders PaymentOrder[]
|
||||
paymentRefunds PaymentRefund[]
|
||||
notifications Notification[]
|
||||
notificationPreference NotificationPreference?
|
||||
notificationBatches NotificationBatch[]
|
||||
articles Article[]
|
||||
articleVersions ArticleVersion[]
|
||||
comments Comment[]
|
||||
mfaSecret String?
|
||||
mfaEnabled Boolean @default(false)
|
||||
loginAttempts Int @default(0)
|
||||
lockedUntil DateTime?
|
||||
|
|
@ -244,6 +253,7 @@ model PaymentOrder {
|
|||
channel PaymentChannel? @relation(fields: [channelId], references: [id], onDelete: SetNull)
|
||||
refunds PaymentRefund[]
|
||||
logs PaymentLog[]
|
||||
order Order?
|
||||
|
||||
@@index([userId])
|
||||
@@index([orderNo])
|
||||
|
|
@ -300,6 +310,65 @@ model PaymentLog {
|
|||
@@map("payment_logs")
|
||||
}
|
||||
|
||||
// 订单状态枚举
|
||||
enum OrderStatus {
|
||||
PENDING
|
||||
PAID
|
||||
SHIPPED
|
||||
COMPLETED
|
||||
CANCELLED
|
||||
REFUNDED
|
||||
}
|
||||
|
||||
// 订单表
|
||||
model Order {
|
||||
id String @id @default(uuid())
|
||||
orderNo String @unique
|
||||
userId String
|
||||
status OrderStatus @default(PENDING)
|
||||
totalAmount Decimal @db.Decimal(12, 2)
|
||||
paidAmount Decimal @db.Decimal(12, 2)
|
||||
discountAmount Decimal @default(0) @db.Decimal(12, 2)
|
||||
paymentMethod String?
|
||||
paymentOrderId String? @unique
|
||||
remark String?
|
||||
paidAt DateTime?
|
||||
shippedAt DateTime?
|
||||
completedAt DateTime?
|
||||
cancelledAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
paymentOrder PaymentOrder? @relation(fields: [paymentOrderId], references: [id], onDelete: SetNull)
|
||||
items OrderItem[]
|
||||
|
||||
@@index([userId])
|
||||
@@index([orderNo])
|
||||
@@index([status])
|
||||
@@index([createdAt])
|
||||
@@map("orders")
|
||||
}
|
||||
|
||||
// 订单项表
|
||||
model OrderItem {
|
||||
id String @id @default(uuid())
|
||||
orderId String
|
||||
productId String
|
||||
productName String
|
||||
productImage String?
|
||||
quantity Int
|
||||
unitPrice Decimal @db.Decimal(12, 2)
|
||||
totalAmount Decimal @db.Decimal(12, 2)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([orderId])
|
||||
@@index([productId])
|
||||
@@map("order_items")
|
||||
}
|
||||
|
||||
// 通知表
|
||||
model Notification {
|
||||
id String @id @default(uuid())
|
||||
|
|
@ -328,6 +397,7 @@ model Notification {
|
|||
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
template NotificationTemplate? @relation(fields: [templateId], references: [id], onDelete: SetNull)
|
||||
batchItems NotificationBatchItem[]
|
||||
|
||||
@@index([userId])
|
||||
@@index([status])
|
||||
|
|
@ -439,6 +509,7 @@ model NotificationBatch {
|
|||
updatedAt DateTime @updatedAt
|
||||
|
||||
creator User? @relation(fields: [creatorId], references: [id], onDelete: SetNull)
|
||||
items NotificationBatchItem[]
|
||||
|
||||
@@index([status])
|
||||
@@index([creatorId])
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ import { RbacModule } from './modules/rbac/rbac.module';
|
|||
import { PaymentModule } from './modules/payment/payment.module';
|
||||
import { MonitoringModule } from './modules/monitoring/monitoring.module';
|
||||
import { ContentModule } from './modules/content/content.module';
|
||||
import { OrderModule } from './modules/order/order.module';
|
||||
import { NotificationModule } from './modules/notification/notification.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
|
|
@ -27,6 +29,8 @@ import { ContentModule } from './modules/content/content.module';
|
|||
RbacModule,
|
||||
PaymentModule,
|
||||
ContentModule,
|
||||
OrderModule,
|
||||
NotificationModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { NotificationChannel, NotificationPayload, NotificationSendResult } from './notification-channel.interface';
|
||||
|
||||
@Injectable()
|
||||
export abstract class BaseChannelService implements NotificationChannel {
|
||||
protected readonly logger = new Logger(this.constructor.name);
|
||||
protected configService: ConfigService;
|
||||
|
||||
abstract name: string;
|
||||
abstract type: string;
|
||||
|
|
@ -21,6 +23,27 @@ export abstract class BaseChannelService implements NotificationChannel {
|
|||
return true;
|
||||
}
|
||||
|
||||
protected isMockMode(): boolean {
|
||||
return this.configService?.get('NOTIFICATION_MOCK_MODE') === 'true';
|
||||
}
|
||||
|
||||
protected createMockResult(channelType: string): NotificationSendResult {
|
||||
this.logger.log(`${this.name} mock mode: skipping actual send`);
|
||||
return {
|
||||
success: true,
|
||||
message: `${this.name} message sent successfully (mock)`,
|
||||
channelMessageId: `${channelType}-mock-${Date.now()}`,
|
||||
};
|
||||
}
|
||||
|
||||
protected createErrorResult(error: unknown): NotificationSendResult {
|
||||
this.logger.error(`Failed to send ${this.name} message: ${error}`);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
|
||||
protected replaceVariables(template: string, variables?: Record<string, any>): string {
|
||||
if (!variables) return template;
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,300 @@
|
|||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { EmailChannelService } from './email-channel.service';
|
||||
import { NotificationPayload } from './notification-channel.interface';
|
||||
|
||||
describe('EmailChannelService', () => {
|
||||
let service: EmailChannelService;
|
||||
|
||||
const defaultConfig: Record<string, string> = {
|
||||
SMTP_HOST: 'smtp.example.com',
|
||||
SMTP_PORT: '465',
|
||||
SMTP_USER: 'noreply@example.com',
|
||||
SMTP_PASS: 'password123',
|
||||
SMTP_FROM: '"FischerX" <noreply@example.com>',
|
||||
NOTIFICATION_MOCK_MODE: 'false',
|
||||
};
|
||||
|
||||
const mockConfigGet = jest.fn((key: string, defaultValue?: string) => {
|
||||
return defaultConfig[key] ?? defaultValue;
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
mockConfigGet.mockClear();
|
||||
mockConfigGet.mockImplementation((key: string, defaultValue?: string) => {
|
||||
return defaultConfig[key] ?? defaultValue;
|
||||
});
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
EmailChannelService,
|
||||
{
|
||||
provide: ConfigService,
|
||||
useValue: { get: mockConfigGet },
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<EmailChannelService>(EmailChannelService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have correct name and type', () => {
|
||||
expect(service.name).toBe('Email');
|
||||
expect(service.type).toBe('email');
|
||||
});
|
||||
|
||||
describe('send plain text email', () => {
|
||||
it('should send plain text email successfully', async () => {
|
||||
jest.spyOn(service as any, 'getTransporter').mockReturnValue({
|
||||
sendMail: jest.fn().mockResolvedValue({
|
||||
messageId: '<msg-123@example.com>',
|
||||
response: '250 OK',
|
||||
}),
|
||||
});
|
||||
|
||||
const payload: NotificationPayload = {
|
||||
type: 'info',
|
||||
title: 'Welcome',
|
||||
content: 'Welcome to FischerX!',
|
||||
channel: 'email',
|
||||
metadata: {
|
||||
email: 'user@example.com',
|
||||
},
|
||||
};
|
||||
|
||||
const result = await service.send(payload);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.channelMessageId).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('send HTML email', () => {
|
||||
it('should send HTML email when contentHtml is provided', async () => {
|
||||
const mockSendMail = jest.fn().mockResolvedValue({
|
||||
messageId: '<msg-456@example.com>',
|
||||
response: '250 OK',
|
||||
});
|
||||
jest.spyOn(service as any, 'getTransporter').mockReturnValue({
|
||||
sendMail: mockSendMail,
|
||||
});
|
||||
|
||||
const payload: NotificationPayload = {
|
||||
type: 'info',
|
||||
title: 'Verify Email',
|
||||
content: 'Please verify your email',
|
||||
contentHtml: '<h1>Verify</h1><p>Click <a href="#">here</a></p>',
|
||||
channel: 'email',
|
||||
metadata: {
|
||||
email: 'user@example.com',
|
||||
},
|
||||
};
|
||||
|
||||
const result = await service.send(payload);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockSendMail).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
html: '<h1>Verify</h1><p>Click <a href="#">here</a></p>',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('email template rendering', () => {
|
||||
it('should render template with variables', async () => {
|
||||
const mockSendMail = jest.fn().mockResolvedValue({
|
||||
messageId: '<msg-789@example.com>',
|
||||
response: '250 OK',
|
||||
});
|
||||
jest.spyOn(service as any, 'getTransporter').mockReturnValue({
|
||||
sendMail: mockSendMail,
|
||||
});
|
||||
|
||||
const payload: NotificationPayload = {
|
||||
type: 'info',
|
||||
title: 'Order Confirmation',
|
||||
content: 'Your order {{orderId}} has been confirmed',
|
||||
contentHtml: '<h1>Order {{orderId}}</h1><p>Amount: {{amount}}</p>',
|
||||
channel: 'email',
|
||||
metadata: {
|
||||
email: 'user@example.com',
|
||||
},
|
||||
variables: {
|
||||
orderId: 'ORD-001',
|
||||
amount: '$99.99',
|
||||
},
|
||||
};
|
||||
|
||||
const result = await service.send(payload);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockSendMail).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
html: '<h1>Order ORD-001</h1><p>Amount: $99.99</p>',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('email with attachments', () => {
|
||||
it('should send email with attachments when metadata.attachments is provided', async () => {
|
||||
const mockSendMail = jest.fn().mockResolvedValue({
|
||||
messageId: '<msg-attach@example.com>',
|
||||
response: '250 OK',
|
||||
});
|
||||
jest.spyOn(service as any, 'getTransporter').mockReturnValue({
|
||||
sendMail: mockSendMail,
|
||||
});
|
||||
|
||||
const payload: NotificationPayload = {
|
||||
type: 'info',
|
||||
title: 'Report',
|
||||
content: 'Please find attached report',
|
||||
channel: 'email',
|
||||
metadata: {
|
||||
email: 'user@example.com',
|
||||
attachments: [
|
||||
{
|
||||
filename: 'report.pdf',
|
||||
path: '/tmp/report.pdf',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const result = await service.send(payload);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockSendMail).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
attachments: [
|
||||
{
|
||||
filename: 'report.pdf',
|
||||
path: '/tmp/report.pdf',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should throw error when SMTP config is missing', async () => {
|
||||
mockConfigGet.mockReturnValue(undefined);
|
||||
|
||||
const payload: NotificationPayload = {
|
||||
type: 'info',
|
||||
title: 'Test',
|
||||
content: 'Hello',
|
||||
channel: 'email',
|
||||
metadata: { email: 'user@example.com' },
|
||||
};
|
||||
|
||||
const result = await service.send(payload);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('SMTP');
|
||||
});
|
||||
|
||||
it('should return failure when email address is missing', async () => {
|
||||
const payload: NotificationPayload = {
|
||||
type: 'info',
|
||||
title: 'Test',
|
||||
content: 'Hello',
|
||||
channel: 'email',
|
||||
};
|
||||
|
||||
const result = await service.send(payload);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('email');
|
||||
});
|
||||
|
||||
it('should handle send failure and return error', async () => {
|
||||
jest.spyOn(service as any, 'getTransporter').mockReturnValue({
|
||||
sendMail: jest.fn().mockRejectedValue(new Error('Connection refused')),
|
||||
});
|
||||
|
||||
const payload: NotificationPayload = {
|
||||
type: 'info',
|
||||
title: 'Test',
|
||||
content: 'Hello',
|
||||
channel: 'email',
|
||||
metadata: { email: 'user@example.com' },
|
||||
};
|
||||
|
||||
const result = await service.send(payload);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Connection refused');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validate', () => {
|
||||
it('should validate payload with email in metadata', async () => {
|
||||
const payload: NotificationPayload = {
|
||||
type: 'info',
|
||||
title: 'Test',
|
||||
content: 'Hello',
|
||||
channel: 'email',
|
||||
metadata: { email: 'user@example.com' },
|
||||
};
|
||||
|
||||
const isValid = await service.validate(payload);
|
||||
expect(isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should invalidate payload without email', async () => {
|
||||
const payload: NotificationPayload = {
|
||||
type: 'info',
|
||||
title: 'Test',
|
||||
content: 'Hello',
|
||||
channel: 'email',
|
||||
};
|
||||
|
||||
const isValid = await service.validate(payload);
|
||||
expect(isValid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAvailable', () => {
|
||||
it('should return true when SMTP is configured', async () => {
|
||||
const available = await service.isAvailable();
|
||||
expect(available).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when SMTP host is not configured', async () => {
|
||||
mockConfigGet.mockReturnValue(undefined);
|
||||
const available = await service.isAvailable();
|
||||
expect(available).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mock mode', () => {
|
||||
it('should return mock result when NOTIFICATION_MOCK_MODE is true', async () => {
|
||||
mockConfigGet.mockImplementation((key: string) => {
|
||||
if (key === 'NOTIFICATION_MOCK_MODE') return 'true';
|
||||
if (key === 'SMTP_HOST') return 'smtp.example.com';
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const payload: NotificationPayload = {
|
||||
type: 'info',
|
||||
title: 'Test',
|
||||
content: 'Hello',
|
||||
channel: 'email',
|
||||
metadata: { email: 'user@example.com' },
|
||||
};
|
||||
|
||||
const result = await service.send(payload);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('mock');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,30 +1,101 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { BaseChannelService } from './base-channel.service';
|
||||
import { NotificationPayload, NotificationSendResult } from './notification-channel.interface';
|
||||
import * as nodemailer from 'nodemailer';
|
||||
|
||||
interface MailOptions {
|
||||
from: string;
|
||||
to: string;
|
||||
subject: string;
|
||||
text?: string;
|
||||
html?: string;
|
||||
attachments?: Array<{
|
||||
filename: string;
|
||||
path?: string;
|
||||
content?: string | Buffer;
|
||||
}>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class EmailChannelService extends BaseChannelService {
|
||||
name = 'Email';
|
||||
type = 'email';
|
||||
|
||||
private transporter: nodemailer.Transporter | null = null;
|
||||
|
||||
constructor(configService: ConfigService) {
|
||||
super();
|
||||
this.configService = configService;
|
||||
}
|
||||
|
||||
protected getTransporter(): nodemailer.Transporter {
|
||||
if (this.transporter) {
|
||||
return this.transporter;
|
||||
}
|
||||
|
||||
const host = this.configService.get<string>('SMTP_HOST');
|
||||
const port = this.configService.get<string>('SMTP_PORT');
|
||||
const user = this.configService.get<string>('SMTP_USER');
|
||||
const pass = this.configService.get<string>('SMTP_PASS');
|
||||
|
||||
if (!host || !port || !user || !pass) {
|
||||
throw new Error('SMTP configuration is incomplete. Required: SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS');
|
||||
}
|
||||
|
||||
this.transporter = nodemailer.createTransport({
|
||||
host,
|
||||
port: parseInt(port, 10),
|
||||
secure: parseInt(port, 10) === 465,
|
||||
auth: { user, pass },
|
||||
});
|
||||
|
||||
return this.transporter;
|
||||
}
|
||||
|
||||
async send(payload: NotificationPayload): Promise<NotificationSendResult> {
|
||||
try {
|
||||
const subject = payload.title;
|
||||
const content = payload.contentHtml || payload.content;
|
||||
if (this.isMockMode(this.configService)) {
|
||||
return this.createMockResult('email');
|
||||
}
|
||||
|
||||
this.logger.log(`Sending email: ${subject}`);
|
||||
const email = payload.metadata?.email;
|
||||
if (!email) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Recipient email address is required in metadata.email',
|
||||
};
|
||||
}
|
||||
|
||||
const transporter = this.getTransporter();
|
||||
|
||||
const from = this.configService.get<string>('SMTP_FROM') || `"FischerX" <${this.configService.get<string>('SMTP_USER')}>`;
|
||||
|
||||
const textContent = this.replaceVariables(payload.content, payload.variables);
|
||||
const htmlContent = payload.contentHtml
|
||||
? this.replaceVariables(payload.contentHtml, payload.variables)
|
||||
: undefined;
|
||||
|
||||
const mailOptions: MailOptions = {
|
||||
from,
|
||||
to: email,
|
||||
subject: payload.title,
|
||||
text: textContent,
|
||||
...(htmlContent && { html: htmlContent }),
|
||||
...(payload.metadata?.attachments && { attachments: payload.metadata.attachments }),
|
||||
};
|
||||
|
||||
const result = await transporter.sendMail(mailOptions);
|
||||
|
||||
this.logger.log(`Email sent successfully to ${email}: ${result.messageId}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Email sent successfully (mock)',
|
||||
channelMessageId: `email-${Date.now()}`,
|
||||
message: 'Email sent successfully',
|
||||
channelMessageId: result.messageId,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to send email: ${error}`);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
return this.createErrorResult(error);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -38,4 +109,12 @@ export class EmailChannelService extends BaseChannelService {
|
|||
|
||||
return true;
|
||||
}
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
const host = this.configService.get<string>('SMTP_HOST');
|
||||
const port = this.configService.get<string>('SMTP_PORT');
|
||||
const user = this.configService.get<string>('SMTP_USER');
|
||||
const pass = this.configService.get<string>('SMTP_PASS');
|
||||
return !!(host && port && user && pass);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,423 @@
|
|||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { SmsChannelService } from './sms-channel.service';
|
||||
import { NotificationPayload } from './notification-channel.interface';
|
||||
|
||||
describe('SmsChannelService', () => {
|
||||
let service: SmsChannelService;
|
||||
|
||||
const defaultConfig: Record<string, string> = {
|
||||
ALIYUN_ACCESS_KEY_ID: 'test-access-key-id',
|
||||
ALIYUN_ACCESS_KEY_SECRET: 'test-access-key-secret',
|
||||
ALIYUN_SMS_SIGN_NAME: 'FischerX',
|
||||
ALIYUN_SMS_TEMPLATE_CODE_VERIFY: 'SMS_123456',
|
||||
NOTIFICATION_MOCK_MODE: 'false',
|
||||
};
|
||||
|
||||
const mockConfigGet = jest.fn((key: string, defaultValue?: string) => {
|
||||
return defaultConfig[key] ?? defaultValue;
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
mockConfigGet.mockClear();
|
||||
mockConfigGet.mockImplementation((key: string, defaultValue?: string) => {
|
||||
return defaultConfig[key] ?? defaultValue;
|
||||
});
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
SmsChannelService,
|
||||
{
|
||||
provide: ConfigService,
|
||||
useValue: { get: mockConfigGet },
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<SmsChannelService>(SmsChannelService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have correct name and type', () => {
|
||||
expect(service.name).toBe('SMS');
|
||||
expect(service.type).toBe('sms');
|
||||
});
|
||||
|
||||
describe('send verification code SMS', () => {
|
||||
it('should send verification code SMS successfully', async () => {
|
||||
const mockSendSms = jest.fn().mockResolvedValue({
|
||||
body: {
|
||||
Code: 'OK',
|
||||
Message: 'success',
|
||||
RequestId: 'req-123',
|
||||
BizId: 'biz-456',
|
||||
},
|
||||
});
|
||||
jest.spyOn(service as any, 'getClient').mockReturnValue({
|
||||
sendSms: mockSendSms,
|
||||
});
|
||||
|
||||
const payload: NotificationPayload = {
|
||||
type: 'info',
|
||||
title: 'Verification Code',
|
||||
content: 'Your verification code is 123456',
|
||||
channel: 'sms',
|
||||
metadata: {
|
||||
phone: '13800138000',
|
||||
templateCode: 'SMS_123456',
|
||||
templateParam: { code: '123456' },
|
||||
},
|
||||
};
|
||||
|
||||
const result = await service.send(payload);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.channelMessageId).toBe('biz-456');
|
||||
expect(mockSendSms).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
phoneNumbers: '13800138000',
|
||||
signName: 'FischerX',
|
||||
templateCode: 'SMS_123456',
|
||||
templateParam: '{"code":"123456"}',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('send template SMS', () => {
|
||||
it('should send template SMS with custom template', async () => {
|
||||
const mockSendSms = jest.fn().mockResolvedValue({
|
||||
body: {
|
||||
Code: 'OK',
|
||||
Message: 'success',
|
||||
RequestId: 'req-789',
|
||||
BizId: 'biz-012',
|
||||
},
|
||||
});
|
||||
jest.spyOn(service as any, 'getClient').mockReturnValue({
|
||||
sendSms: mockSendSms,
|
||||
});
|
||||
|
||||
const payload: NotificationPayload = {
|
||||
type: 'info',
|
||||
title: 'Order Notification',
|
||||
content: 'Your order has been shipped',
|
||||
channel: 'sms',
|
||||
metadata: {
|
||||
phone: '13900139000',
|
||||
templateCode: 'SMS_SHIP_001',
|
||||
templateParam: { orderNo: 'ORD-001', status: 'shipped' },
|
||||
},
|
||||
};
|
||||
|
||||
const result = await service.send(payload);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockSendSms).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
templateCode: 'SMS_SHIP_001',
|
||||
templateParam: '{"orderNo":"ORD-001","status":"shipped"}',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use default sign name when not specified in metadata', async () => {
|
||||
const mockSendSms = jest.fn().mockResolvedValue({
|
||||
body: {
|
||||
Code: 'OK',
|
||||
Message: 'success',
|
||||
RequestId: 'req-default',
|
||||
BizId: 'biz-default',
|
||||
},
|
||||
});
|
||||
jest.spyOn(service as any, 'getClient').mockReturnValue({
|
||||
sendSms: mockSendSms,
|
||||
});
|
||||
|
||||
const payload: NotificationPayload = {
|
||||
type: 'info',
|
||||
title: 'Test',
|
||||
content: 'Test message',
|
||||
channel: 'sms',
|
||||
metadata: {
|
||||
phone: '13800138000',
|
||||
templateCode: 'SMS_123456',
|
||||
templateParam: { code: '654321' },
|
||||
},
|
||||
};
|
||||
|
||||
await service.send(payload);
|
||||
|
||||
expect(mockSendSms).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
signName: 'FischerX',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rate limiting', () => {
|
||||
it('should reject SMS sent within 60 seconds to same phone number', async () => {
|
||||
const mockSendSms = jest.fn().mockResolvedValue({
|
||||
body: { Code: 'OK', Message: 'success', BizId: 'biz-1' },
|
||||
});
|
||||
jest.spyOn(service as any, 'getClient').mockReturnValue({
|
||||
sendSms: mockSendSms,
|
||||
});
|
||||
|
||||
const payload: NotificationPayload = {
|
||||
type: 'info',
|
||||
title: 'Test',
|
||||
content: 'Test',
|
||||
channel: 'sms',
|
||||
metadata: {
|
||||
phone: '13800138000',
|
||||
templateCode: 'SMS_123456',
|
||||
templateParam: { code: '111111' },
|
||||
},
|
||||
};
|
||||
|
||||
const firstResult = await service.send(payload);
|
||||
expect(firstResult.success).toBe(true);
|
||||
|
||||
const secondResult = await service.send(payload);
|
||||
expect(secondResult.success).toBe(false);
|
||||
expect(secondResult.error).toContain('frequency');
|
||||
});
|
||||
|
||||
it('should allow SMS after 60 seconds cooldown', async () => {
|
||||
const mockSendSms = jest.fn().mockResolvedValue({
|
||||
body: { Code: 'OK', Message: 'success', BizId: 'biz-2' },
|
||||
});
|
||||
jest.spyOn(service as any, 'getClient').mockReturnValue({
|
||||
sendSms: mockSendSms,
|
||||
});
|
||||
|
||||
const nowMs = 1000000;
|
||||
const lastSentAtMap = (service as any).lastSentAt as Map<string, number>;
|
||||
lastSentAtMap.set('13800138000', nowMs);
|
||||
|
||||
jest.spyOn(Date, 'now').mockReturnValue(nowMs + 61000);
|
||||
|
||||
const payload: NotificationPayload = {
|
||||
type: 'info',
|
||||
title: 'Test',
|
||||
content: 'Test',
|
||||
channel: 'sms',
|
||||
metadata: {
|
||||
phone: '13800138000',
|
||||
templateCode: 'SMS_123456',
|
||||
templateParam: { code: '222222' },
|
||||
},
|
||||
};
|
||||
|
||||
const result = await service.send(payload);
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
(Date.now as jest.Mock).mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('phone number validation', () => {
|
||||
it('should validate Chinese mobile phone number format', async () => {
|
||||
const validPhones = ['13800138000', '15912345678', '18600000000'];
|
||||
for (const phone of validPhones) {
|
||||
const isValid = (service as any).isValidPhone(phone);
|
||||
expect(isValid).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject invalid phone numbers', async () => {
|
||||
const invalidPhones = ['12345678901', '1380013800', 'abc12345678', '12345', ''];
|
||||
for (const phone of invalidPhones) {
|
||||
const isValid = (service as any).isValidPhone(phone);
|
||||
expect(isValid).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('should return failure for invalid phone number', async () => {
|
||||
const payload: NotificationPayload = {
|
||||
type: 'info',
|
||||
title: 'Test',
|
||||
content: 'Test',
|
||||
channel: 'sms',
|
||||
metadata: {
|
||||
phone: '12345',
|
||||
templateCode: 'SMS_123456',
|
||||
templateParam: { code: '123456' },
|
||||
},
|
||||
};
|
||||
|
||||
const result = await service.send(payload);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('phone');
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should throw error when AccessKey is not configured', async () => {
|
||||
mockConfigGet.mockReturnValue(undefined);
|
||||
|
||||
const payload: NotificationPayload = {
|
||||
type: 'info',
|
||||
title: 'Test',
|
||||
content: 'Hello',
|
||||
channel: 'sms',
|
||||
metadata: { phone: '13800138000' },
|
||||
};
|
||||
|
||||
const result = await service.send(payload);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('AccessKey');
|
||||
});
|
||||
|
||||
it('should return failure when Aliyun API returns error', async () => {
|
||||
const mockSendSms = jest.fn().mockResolvedValue({
|
||||
body: {
|
||||
Code: 'isv.BUSINESS_LIMIT_CONTROL',
|
||||
Message: 'Business limit control',
|
||||
RequestId: 'req-err',
|
||||
},
|
||||
});
|
||||
jest.spyOn(service as any, 'getClient').mockReturnValue({
|
||||
sendSms: mockSendSms,
|
||||
});
|
||||
|
||||
const payload: NotificationPayload = {
|
||||
type: 'info',
|
||||
title: 'Test',
|
||||
content: 'Test',
|
||||
channel: 'sms',
|
||||
metadata: {
|
||||
phone: '13800138000',
|
||||
templateCode: 'SMS_123456',
|
||||
templateParam: { code: '123456' },
|
||||
},
|
||||
};
|
||||
|
||||
const result = await service.send(payload);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Business limit control');
|
||||
});
|
||||
|
||||
it('should handle network errors gracefully', async () => {
|
||||
const mockSendSms = jest.fn().mockRejectedValue(new Error('Network timeout'));
|
||||
jest.spyOn(service as any, 'getClient').mockReturnValue({
|
||||
sendSms: mockSendSms,
|
||||
});
|
||||
|
||||
const payload: NotificationPayload = {
|
||||
type: 'info',
|
||||
title: 'Test',
|
||||
content: 'Test',
|
||||
channel: 'sms',
|
||||
metadata: {
|
||||
phone: '13800138000',
|
||||
templateCode: 'SMS_123456',
|
||||
templateParam: { code: '123456' },
|
||||
},
|
||||
};
|
||||
|
||||
const result = await service.send(payload);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Network timeout');
|
||||
});
|
||||
|
||||
it('should return failure when phone number is missing', async () => {
|
||||
const payload: NotificationPayload = {
|
||||
type: 'info',
|
||||
title: 'Test',
|
||||
content: 'Hello',
|
||||
channel: 'sms',
|
||||
};
|
||||
|
||||
const result = await service.send(payload);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('phone');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validate', () => {
|
||||
it('should validate payload with phone in metadata', async () => {
|
||||
const payload: NotificationPayload = {
|
||||
type: 'info',
|
||||
title: 'Test',
|
||||
content: 'Hello',
|
||||
channel: 'sms',
|
||||
metadata: { phone: '13800138000' },
|
||||
};
|
||||
|
||||
const isValid = await service.validate(payload);
|
||||
expect(isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should invalidate payload without phone', async () => {
|
||||
const payload: NotificationPayload = {
|
||||
type: 'info',
|
||||
title: 'Test',
|
||||
content: 'Hello',
|
||||
channel: 'sms',
|
||||
};
|
||||
|
||||
const isValid = await service.validate(payload);
|
||||
expect(isValid).toBe(false);
|
||||
});
|
||||
|
||||
it('should invalidate content exceeding 500 characters', async () => {
|
||||
const payload: NotificationPayload = {
|
||||
type: 'info',
|
||||
title: 'Test',
|
||||
content: 'a'.repeat(501),
|
||||
channel: 'sms',
|
||||
metadata: { phone: '13800138000' },
|
||||
};
|
||||
|
||||
const isValid = await service.validate(payload);
|
||||
expect(isValid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAvailable', () => {
|
||||
it('should return true when AccessKey is configured', async () => {
|
||||
const available = await service.isAvailable();
|
||||
expect(available).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when AccessKey is not configured', async () => {
|
||||
mockConfigGet.mockReturnValue(undefined);
|
||||
const available = await service.isAvailable();
|
||||
expect(available).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mock mode', () => {
|
||||
it('should return mock result when NOTIFICATION_MOCK_MODE is true', async () => {
|
||||
mockConfigGet.mockImplementation((key: string) => {
|
||||
if (key === 'NOTIFICATION_MOCK_MODE') return 'true';
|
||||
if (key === 'ALIYUN_ACCESS_KEY_ID') return 'test-key';
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const payload: NotificationPayload = {
|
||||
type: 'info',
|
||||
title: 'Test',
|
||||
content: 'Hello',
|
||||
channel: 'sms',
|
||||
metadata: { phone: '13800138000' },
|
||||
};
|
||||
|
||||
const result = await service.send(payload);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('mock');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,27 +1,126 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { BaseChannelService } from './base-channel.service';
|
||||
import { NotificationPayload, NotificationSendResult } from './notification-channel.interface';
|
||||
|
||||
const PHONE_REGEX = /^1[3-9]\d{9}$/;
|
||||
const RATE_LIMIT_MS = 60_000;
|
||||
|
||||
@Injectable()
|
||||
export class SmsChannelService extends BaseChannelService {
|
||||
name = 'SMS';
|
||||
type = 'sms';
|
||||
|
||||
private client: any = null;
|
||||
private lastSentAt = new Map<string, number>();
|
||||
|
||||
constructor(configService: ConfigService) {
|
||||
super();
|
||||
this.configService = configService;
|
||||
}
|
||||
|
||||
protected getClient(): any {
|
||||
if (this.client) {
|
||||
return this.client;
|
||||
}
|
||||
|
||||
const accessKeyId = this.configService.get<string>('ALIYUN_ACCESS_KEY_ID');
|
||||
const accessKeySecret = this.configService.get<string>('ALIYUN_ACCESS_KEY_SECRET');
|
||||
|
||||
if (!accessKeyId || !accessKeySecret) {
|
||||
throw new Error('Aliyun AccessKey not configured. Required: ALIYUN_ACCESS_KEY_ID, ALIYUN_ACCESS_KEY_SECRET');
|
||||
}
|
||||
|
||||
const Dysmsapi = require('@alicloud/dysmsapi20170525');
|
||||
const OpenApi = require('@alicloud/openapi-client');
|
||||
|
||||
const config = new OpenApi.Config({
|
||||
accessKeyId,
|
||||
accessKeySecret,
|
||||
endpoint: 'dysmsapi.aliyuncs.com',
|
||||
});
|
||||
|
||||
this.client = new Dysmsapi(config);
|
||||
return this.client;
|
||||
}
|
||||
|
||||
async send(payload: NotificationPayload): Promise<NotificationSendResult> {
|
||||
try {
|
||||
this.logger.log(`Sending SMS: ${payload.title}`);
|
||||
if (this.isMockMode(this.configService)) {
|
||||
return this.createMockResult('sms');
|
||||
}
|
||||
|
||||
const phone = payload.metadata?.phone;
|
||||
if (!phone) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Recipient phone number is required in metadata.phone',
|
||||
};
|
||||
}
|
||||
|
||||
if (!this.isValidPhone(phone)) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Invalid phone number format: ${phone}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (this.isRateLimited(phone)) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'SMS sending frequency limit: same phone number can only receive 1 message per 60 seconds',
|
||||
};
|
||||
}
|
||||
|
||||
const client = this.getClient();
|
||||
|
||||
const signName = payload.metadata?.signName
|
||||
|| this.configService.get<string>('ALIYUN_SMS_SIGN_NAME')
|
||||
|| 'FischerX';
|
||||
|
||||
const templateCode = payload.metadata?.templateCode
|
||||
|| this.configService.get<string>('ALIYUN_SMS_TEMPLATE_CODE_VERIFY');
|
||||
|
||||
if (!templateCode) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'SMS template code is required in metadata.templateCode or ALIYUN_SMS_TEMPLATE_CODE_VERIFY env',
|
||||
};
|
||||
}
|
||||
|
||||
const templateParam = payload.metadata?.templateParam
|
||||
? JSON.stringify(payload.metadata.templateParam)
|
||||
: undefined;
|
||||
|
||||
const dysmsapi = require('@alicloud/dysmsapi20170525');
|
||||
const request = new dysmsapi.SendSmsRequest({
|
||||
phoneNumbers: phone,
|
||||
signName,
|
||||
templateCode,
|
||||
templateParam,
|
||||
});
|
||||
|
||||
const response = await client.sendSms(request);
|
||||
const { Code, Message, BizId } = response.body;
|
||||
|
||||
if (Code !== 'OK') {
|
||||
return {
|
||||
success: false,
|
||||
error: `Aliyun SMS error: ${Message} (code: ${Code})`,
|
||||
};
|
||||
}
|
||||
|
||||
this.lastSentAt.set(phone, Date.now());
|
||||
|
||||
this.logger.log(`SMS sent successfully to ${phone}: ${BizId}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'SMS sent successfully (mock)',
|
||||
channelMessageId: `sms-${Date.now()}`,
|
||||
message: 'SMS sent successfully',
|
||||
channelMessageId: BizId || `sms-${Date.now()}`,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to send SMS: ${error}`);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
return this.createErrorResult(error);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -39,4 +138,20 @@ export class SmsChannelService extends BaseChannelService {
|
|||
|
||||
return true;
|
||||
}
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
const accessKeyId = this.configService.get<string>('ALIYUN_ACCESS_KEY_ID');
|
||||
const accessKeySecret = this.configService.get<string>('ALIYUN_ACCESS_KEY_SECRET');
|
||||
return !!(accessKeyId && accessKeySecret);
|
||||
}
|
||||
|
||||
protected isValidPhone(phone: string): boolean {
|
||||
return PHONE_REGEX.test(phone);
|
||||
}
|
||||
|
||||
private isRateLimited(phone: string): boolean {
|
||||
const lastSent = this.lastSentAt.get(phone);
|
||||
if (!lastSent) return false;
|
||||
return Date.now() - lastSent < RATE_LIMIT_MS;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,363 @@
|
|||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { WeComChannelService } from './wecom-channel.service';
|
||||
import { NotificationPayload } from './notification-channel.interface';
|
||||
|
||||
describe('WeComChannelService', () => {
|
||||
let service: WeComChannelService;
|
||||
let configService: ConfigService;
|
||||
|
||||
const defaultConfig: Record<string, string> = {
|
||||
WECOM_WEBHOOK_URL: 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test-key',
|
||||
WECOM_WEBHOOK_URL_ALERT: 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=alert-key',
|
||||
NOTIFICATION_MOCK_MODE: 'false',
|
||||
};
|
||||
|
||||
const mockConfigGet = jest.fn((key: string, defaultValue?: string) => {
|
||||
return defaultConfig[key] ?? defaultValue;
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
mockConfigGet.mockClear();
|
||||
mockConfigGet.mockImplementation((key: string, defaultValue?: string) => {
|
||||
return defaultConfig[key] ?? defaultValue;
|
||||
});
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
WeComChannelService,
|
||||
{
|
||||
provide: ConfigService,
|
||||
useValue: { get: mockConfigGet },
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<WeComChannelService>(WeComChannelService);
|
||||
configService = module.get<ConfigService>(ConfigService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have correct name and type', () => {
|
||||
expect(service.name).toBe('WeCom');
|
||||
expect(service.type).toBe('wecom');
|
||||
});
|
||||
|
||||
describe('send text message', () => {
|
||||
it('should send text message successfully', async () => {
|
||||
const httpPostSpy = jest.spyOn(service as any, 'httpPost').mockResolvedValue({
|
||||
errcode: 0,
|
||||
errmsg: 'ok',
|
||||
});
|
||||
|
||||
const payload: NotificationPayload = {
|
||||
type: 'info',
|
||||
title: 'Test',
|
||||
content: 'Hello from WeCom',
|
||||
channel: 'wecom',
|
||||
};
|
||||
|
||||
const result = await service.send(payload);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(httpPostSpy).toHaveBeenCalledWith(
|
||||
'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test-key',
|
||||
{
|
||||
msgtype: 'text',
|
||||
text: {
|
||||
content: '【Test】\nHello from WeCom',
|
||||
mentioned_list: [],
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should include title in text message content', async () => {
|
||||
const httpPostSpy = jest.spyOn(service as any, 'httpPost').mockResolvedValue({
|
||||
errcode: 0,
|
||||
errmsg: 'ok',
|
||||
});
|
||||
|
||||
const payload: NotificationPayload = {
|
||||
type: 'info',
|
||||
title: 'Alert Title',
|
||||
content: 'Detail content',
|
||||
channel: 'wecom',
|
||||
};
|
||||
|
||||
await service.send(payload);
|
||||
|
||||
expect(httpPostSpy).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
msgtype: 'text',
|
||||
text: expect.objectContaining({
|
||||
content: expect.stringContaining('Alert Title'),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('send markdown message', () => {
|
||||
it('should send markdown message when contentHtml is provided', async () => {
|
||||
const httpPostSpy = jest.spyOn(service as any, 'httpPost').mockResolvedValue({
|
||||
errcode: 0,
|
||||
errmsg: 'ok',
|
||||
});
|
||||
|
||||
const payload: NotificationPayload = {
|
||||
type: 'info',
|
||||
title: 'Markdown Test',
|
||||
content: 'Plain text',
|
||||
contentHtml: '# Heading\n> Quote\n**Bold**',
|
||||
channel: 'wecom',
|
||||
};
|
||||
|
||||
const result = await service.send(payload);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(httpPostSpy).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
{
|
||||
msgtype: 'markdown',
|
||||
markdown: {
|
||||
content: '# Heading\n> Quote\n**Bold**',
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should send markdown message when metadata.msgtype is markdown', async () => {
|
||||
const httpPostSpy = jest.spyOn(service as any, 'httpPost').mockResolvedValue({
|
||||
errcode: 0,
|
||||
errmsg: 'ok',
|
||||
});
|
||||
|
||||
const payload: NotificationPayload = {
|
||||
type: 'info',
|
||||
title: 'Markdown Test',
|
||||
content: '# Heading\n**Bold text**',
|
||||
channel: 'wecom',
|
||||
metadata: { msgtype: 'markdown' },
|
||||
};
|
||||
|
||||
const result = await service.send(payload);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(httpPostSpy).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
{
|
||||
msgtype: 'markdown',
|
||||
markdown: {
|
||||
content: '# Heading\n**Bold text**',
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mention users', () => {
|
||||
it('should mention specified users in text message', async () => {
|
||||
const httpPostSpy = jest.spyOn(service as any, 'httpPost').mockResolvedValue({
|
||||
errcode: 0,
|
||||
errmsg: 'ok',
|
||||
});
|
||||
|
||||
const payload: NotificationPayload = {
|
||||
type: 'info',
|
||||
title: 'Mention Test',
|
||||
content: 'Please check this',
|
||||
channel: 'wecom',
|
||||
metadata: {
|
||||
mentioned_list: ['zhangsan', 'lisi'],
|
||||
},
|
||||
};
|
||||
|
||||
await service.send(payload);
|
||||
|
||||
expect(httpPostSpy).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
msgtype: 'text',
|
||||
text: expect.objectContaining({
|
||||
mentioned_list: ['zhangsan', 'lisi'],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should mention @all when specified', async () => {
|
||||
const httpPostSpy = jest.spyOn(service as any, 'httpPost').mockResolvedValue({
|
||||
errcode: 0,
|
||||
errmsg: 'ok',
|
||||
});
|
||||
|
||||
const payload: NotificationPayload = {
|
||||
type: 'warning',
|
||||
title: 'Urgent',
|
||||
content: 'Everyone attention',
|
||||
channel: 'wecom',
|
||||
metadata: {
|
||||
mentioned_list: ['@all'],
|
||||
},
|
||||
};
|
||||
|
||||
await service.send(payload);
|
||||
|
||||
expect(httpPostSpy).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
text: expect.objectContaining({
|
||||
mentioned_list: ['@all'],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('multiple webhooks', () => {
|
||||
it('should use alert webhook when metadata.webhookKey is specified', async () => {
|
||||
const httpPostSpy = jest.spyOn(service as any, 'httpPost').mockResolvedValue({
|
||||
errcode: 0,
|
||||
errmsg: 'ok',
|
||||
});
|
||||
|
||||
const payload: NotificationPayload = {
|
||||
type: 'error',
|
||||
title: 'Alert',
|
||||
content: 'System error',
|
||||
channel: 'wecom',
|
||||
metadata: {
|
||||
webhookKey: 'WECOM_WEBHOOK_URL_ALERT',
|
||||
},
|
||||
};
|
||||
|
||||
await service.send(payload);
|
||||
|
||||
expect(httpPostSpy).toHaveBeenCalledWith(
|
||||
'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=alert-key',
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should throw error when webhook URL is not configured', async () => {
|
||||
mockConfigGet.mockReturnValue(undefined);
|
||||
|
||||
const payload: NotificationPayload = {
|
||||
type: 'info',
|
||||
title: 'Test',
|
||||
content: 'Hello',
|
||||
channel: 'wecom',
|
||||
};
|
||||
|
||||
const result = await service.send(payload);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Webhook URL not configured');
|
||||
});
|
||||
|
||||
it('should return failure when WeCom API returns error', async () => {
|
||||
jest.spyOn(service as any, 'httpPost').mockResolvedValue({
|
||||
errcode: 40001,
|
||||
errmsg: 'invalid token',
|
||||
});
|
||||
|
||||
const payload: NotificationPayload = {
|
||||
type: 'info',
|
||||
title: 'Test',
|
||||
content: 'Hello',
|
||||
channel: 'wecom',
|
||||
};
|
||||
|
||||
const result = await service.send(payload);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('invalid token');
|
||||
});
|
||||
|
||||
it('should handle network errors gracefully', async () => {
|
||||
jest.spyOn(service as any, 'httpPost').mockRejectedValue(
|
||||
new Error('Network timeout'),
|
||||
);
|
||||
|
||||
const payload: NotificationPayload = {
|
||||
type: 'info',
|
||||
title: 'Test',
|
||||
content: 'Hello',
|
||||
channel: 'wecom',
|
||||
};
|
||||
|
||||
const result = await service.send(payload);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Network timeout');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validate', () => {
|
||||
it('should validate payload with title and content', async () => {
|
||||
const payload: NotificationPayload = {
|
||||
type: 'info',
|
||||
title: 'Test',
|
||||
content: 'Hello',
|
||||
channel: 'wecom',
|
||||
};
|
||||
|
||||
const isValid = await service.validate(payload);
|
||||
expect(isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should invalidate payload without title or content', async () => {
|
||||
const payload: NotificationPayload = {
|
||||
type: 'info',
|
||||
title: '',
|
||||
content: '',
|
||||
channel: 'wecom',
|
||||
};
|
||||
|
||||
const isValid = await service.validate(payload);
|
||||
expect(isValid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAvailable', () => {
|
||||
it('should return true when webhook URL is configured', async () => {
|
||||
const available = await service.isAvailable();
|
||||
expect(available).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when webhook URL is not configured', async () => {
|
||||
mockConfigGet.mockReturnValue(undefined);
|
||||
const available = await service.isAvailable();
|
||||
expect(available).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mock mode', () => {
|
||||
it('should return mock result when NOTIFICATION_MOCK_MODE is true', async () => {
|
||||
mockConfigGet.mockImplementation((key: string) => {
|
||||
if (key === 'NOTIFICATION_MOCK_MODE') return 'true';
|
||||
if (key === 'WECOM_WEBHOOK_URL') return 'https://example.com';
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const payload: NotificationPayload = {
|
||||
type: 'info',
|
||||
title: 'Test',
|
||||
content: 'Hello',
|
||||
channel: 'wecom',
|
||||
};
|
||||
|
||||
const result = await service.send(payload);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('mock');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { BaseChannelService } from './base-channel.service';
|
||||
import { NotificationPayload, NotificationSendResult } from './notification-channel.interface';
|
||||
|
||||
interface WeComTextMessage {
|
||||
msgtype: 'text';
|
||||
text: {
|
||||
content: string;
|
||||
mentioned_list?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
interface WeComMarkdownMessage {
|
||||
msgtype: 'markdown';
|
||||
markdown: {
|
||||
content: string;
|
||||
};
|
||||
}
|
||||
|
||||
type WeComMessage = WeComTextMessage | WeComMarkdownMessage;
|
||||
|
||||
interface WeComResponse {
|
||||
errcode: number;
|
||||
errmsg: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class WeComChannelService extends BaseChannelService {
|
||||
name = 'WeCom';
|
||||
type = 'wecom';
|
||||
|
||||
constructor(configService: ConfigService) {
|
||||
super();
|
||||
this.configService = configService;
|
||||
}
|
||||
|
||||
async send(payload: NotificationPayload): Promise<NotificationSendResult> {
|
||||
try {
|
||||
if (this.isMockMode()) {
|
||||
return this.createMockResult('wecom');
|
||||
}
|
||||
|
||||
const webhookKey = payload.metadata?.webhookKey || 'WECOM_WEBHOOK_URL';
|
||||
const webhookUrl = this.configService.get<string>(webhookKey);
|
||||
|
||||
if (!webhookUrl) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Webhook URL not configured: ${webhookKey}`,
|
||||
};
|
||||
}
|
||||
|
||||
const message = this.buildMessage(payload);
|
||||
const response = await this.httpPost(webhookUrl, message);
|
||||
|
||||
if (response.errcode !== 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: `WeCom API error: ${response.errmsg} (code: ${response.errcode})`,
|
||||
};
|
||||
}
|
||||
|
||||
this.logger.log(`WeCom message sent successfully: ${payload.title}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'WeCom message sent successfully',
|
||||
channelMessageId: `wecom-${Date.now()}`,
|
||||
};
|
||||
} catch (error) {
|
||||
return this.createErrorResult(error);
|
||||
}
|
||||
}
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
const webhookUrl = this.configService.get<string>('WECOM_WEBHOOK_URL');
|
||||
return !!webhookUrl;
|
||||
}
|
||||
|
||||
private buildMessage(payload: NotificationPayload): WeComMessage {
|
||||
const mentionedList = payload.metadata?.mentioned_list || [];
|
||||
const isMarkdown = !!payload.contentHtml || payload.metadata?.msgtype === 'markdown';
|
||||
|
||||
if (isMarkdown) {
|
||||
const content = payload.contentHtml || payload.content;
|
||||
return {
|
||||
msgtype: 'markdown',
|
||||
markdown: {
|
||||
content: this.replaceVariables(content, payload.variables),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const content = payload.title
|
||||
? `【${payload.title}】\n${payload.content}`
|
||||
: payload.content;
|
||||
|
||||
return {
|
||||
msgtype: 'text',
|
||||
text: {
|
||||
content: this.replaceVariables(content, payload.variables),
|
||||
mentioned_list: mentionedList,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
protected async httpPost(url: string, body: WeComMessage): Promise<WeComResponse> {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
import { Module, OnModuleInit } from '@nestjs/common';
|
||||
import { NotificationController } from './notification.controller';
|
||||
import { NotificationService } from './notification.service';
|
||||
import { TemplateService } from './template.service';
|
||||
import { PreferenceService } from './preference.service';
|
||||
import { QueueService } from './queue.service';
|
||||
import { ChannelFactoryService } from './channels/channel-factory.service';
|
||||
import { EmailChannelService } from './channels/email-channel.service';
|
||||
import { SmsChannelService } from './channels/sms-channel.service';
|
||||
import { PushChannelService } from './channels/push-channel.service';
|
||||
import { InAppChannelService } from './channels/in-app-channel.service';
|
||||
import { WeComChannelService } from './channels/wecom-channel.service';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [NotificationController],
|
||||
providers: [
|
||||
NotificationService,
|
||||
TemplateService,
|
||||
PreferenceService,
|
||||
QueueService,
|
||||
ChannelFactoryService,
|
||||
EmailChannelService,
|
||||
SmsChannelService,
|
||||
PushChannelService,
|
||||
InAppChannelService,
|
||||
WeComChannelService,
|
||||
],
|
||||
exports: [NotificationService, ChannelFactoryService],
|
||||
})
|
||||
export class NotificationModule implements OnModuleInit {
|
||||
constructor(
|
||||
private readonly channelFactory: ChannelFactoryService,
|
||||
private readonly emailChannel: EmailChannelService,
|
||||
private readonly smsChannel: SmsChannelService,
|
||||
private readonly pushChannel: PushChannelService,
|
||||
private readonly inAppChannel: InAppChannelService,
|
||||
private readonly wecomChannel: WeComChannelService,
|
||||
) {}
|
||||
|
||||
async onModuleInit() {
|
||||
this.channelFactory.registerChannel(this.emailChannel);
|
||||
this.channelFactory.registerChannel(this.smsChannel);
|
||||
this.channelFactory.registerChannel(this.pushChannel);
|
||||
this.channelFactory.registerChannel(this.inAppChannel);
|
||||
this.channelFactory.registerChannel(this.wecomChannel);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,197 @@
|
|||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { OrderController } from '../order.controller';
|
||||
import { OrderService } from '../order.service';
|
||||
import { OrderStatus } from '../types/order-status.enum';
|
||||
|
||||
describe('OrderController', () => {
|
||||
let controller: OrderController;
|
||||
let service: OrderService;
|
||||
|
||||
const mockOrderService = {
|
||||
create: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
findMany: jest.fn(),
|
||||
updateStatus: jest.fn(),
|
||||
cancel: jest.fn(),
|
||||
getStats: jest.fn(),
|
||||
};
|
||||
|
||||
const mockRequest = {
|
||||
user: { id: 'test-user-id' },
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [OrderController],
|
||||
providers: [
|
||||
{
|
||||
provide: OrderService,
|
||||
useValue: mockOrderService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
controller = module.get<OrderController>(OrderController);
|
||||
service = module.get<OrderService>(OrderService);
|
||||
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('should create an order', async () => {
|
||||
const createDto = {
|
||||
items: [
|
||||
{
|
||||
productId: 'product-1',
|
||||
productName: '测试商品',
|
||||
quantity: 2,
|
||||
unitPrice: 99.99,
|
||||
},
|
||||
],
|
||||
remark: '请尽快发货',
|
||||
};
|
||||
|
||||
const expectedResult = {
|
||||
code: 201,
|
||||
message: 'Order created successfully',
|
||||
data: { id: 'order-id', orderNo: 'ORD001' },
|
||||
};
|
||||
|
||||
mockOrderService.create.mockResolvedValue(expectedResult);
|
||||
|
||||
const result = await controller.create(createDto, mockRequest);
|
||||
|
||||
expect(result).toEqual(expectedResult);
|
||||
expect(service.create).toHaveBeenCalledWith('test-user-id', createDto);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findAll', () => {
|
||||
it('should return paginated orders', async () => {
|
||||
const expectedResult = {
|
||||
code: 200,
|
||||
message: 'Orders retrieved successfully',
|
||||
data: {
|
||||
orders: [],
|
||||
pagination: { page: 1, limit: 20, total: 0, totalPages: 0 },
|
||||
},
|
||||
};
|
||||
|
||||
mockOrderService.findMany.mockResolvedValue(expectedResult);
|
||||
|
||||
const result = await controller.findAll('1', '20', undefined, mockRequest);
|
||||
|
||||
expect(result).toEqual(expectedResult);
|
||||
expect(service.findMany).toHaveBeenCalledWith({
|
||||
page: 1,
|
||||
limit: 20,
|
||||
userId: 'test-user-id',
|
||||
status: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should filter by status', async () => {
|
||||
const expectedResult = {
|
||||
code: 200,
|
||||
message: 'Orders retrieved successfully',
|
||||
data: {
|
||||
orders: [],
|
||||
pagination: { page: 1, limit: 20, total: 0, totalPages: 0 },
|
||||
},
|
||||
};
|
||||
|
||||
mockOrderService.findMany.mockResolvedValue(expectedResult);
|
||||
|
||||
await controller.findAll('1', '20', OrderStatus.PENDING, mockRequest);
|
||||
|
||||
expect(service.findMany).toHaveBeenCalledWith({
|
||||
page: 1,
|
||||
limit: 20,
|
||||
userId: 'test-user-id',
|
||||
status: OrderStatus.PENDING,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('findOne', () => {
|
||||
it('should return order by id', async () => {
|
||||
const expectedResult = {
|
||||
code: 200,
|
||||
message: 'Order retrieved successfully',
|
||||
data: { id: 'order-id', orderNo: 'ORD001' },
|
||||
};
|
||||
|
||||
mockOrderService.findOne.mockResolvedValue(expectedResult);
|
||||
|
||||
const result = await controller.findOne('order-id', mockRequest);
|
||||
|
||||
expect(result).toEqual(expectedResult);
|
||||
expect(service.findOne).toHaveBeenCalledWith('order-id', 'test-user-id');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateStatus', () => {
|
||||
it('should update order status', async () => {
|
||||
const updateDto = { status: OrderStatus.PAID };
|
||||
const expectedResult = {
|
||||
code: 200,
|
||||
message: 'Order status updated successfully',
|
||||
data: { id: 'order-id', status: OrderStatus.PAID },
|
||||
};
|
||||
|
||||
mockOrderService.updateStatus.mockResolvedValue(expectedResult);
|
||||
|
||||
const result = await controller.updateStatus('order-id', updateDto);
|
||||
|
||||
expect(result).toEqual(expectedResult);
|
||||
expect(service.updateStatus).toHaveBeenCalledWith('order-id', OrderStatus.PAID);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cancel', () => {
|
||||
it('should cancel an order', async () => {
|
||||
const expectedResult = {
|
||||
code: 200,
|
||||
message: 'Order cancelled successfully',
|
||||
data: { id: 'order-id', status: OrderStatus.CANCELLED },
|
||||
};
|
||||
|
||||
mockOrderService.cancel.mockResolvedValue(expectedResult);
|
||||
|
||||
const result = await controller.cancel('order-id', mockRequest);
|
||||
|
||||
expect(result).toEqual(expectedResult);
|
||||
expect(service.cancel).toHaveBeenCalledWith('order-id', 'test-user-id');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStats', () => {
|
||||
it('should return order statistics', async () => {
|
||||
const expectedResult = {
|
||||
code: 200,
|
||||
message: 'Order statistics retrieved successfully',
|
||||
data: {
|
||||
totalOrders: 10,
|
||||
pendingOrders: 3,
|
||||
paidOrders: 2,
|
||||
shippedOrders: 1,
|
||||
completedOrders: 2,
|
||||
cancelledOrders: 1,
|
||||
refundedOrders: 1,
|
||||
totalAmount: 9999.99,
|
||||
},
|
||||
};
|
||||
|
||||
mockOrderService.getStats.mockResolvedValue(expectedResult);
|
||||
|
||||
const result = await controller.getStats(mockRequest);
|
||||
|
||||
expect(result).toEqual(expectedResult);
|
||||
expect(service.getStats).toHaveBeenCalledWith('test-user-id');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,723 @@
|
|||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import {
|
||||
NotFoundException,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { OrderService } from '../order.service';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
import { OrderStatus } from '../types/order-status.enum';
|
||||
|
||||
describe('OrderService', () => {
|
||||
let service: OrderService;
|
||||
let prisma: PrismaService;
|
||||
|
||||
const mockPrismaService = {
|
||||
order: {
|
||||
create: jest.fn(),
|
||||
findUnique: jest.fn(),
|
||||
findMany: jest.fn(),
|
||||
count: jest.fn(),
|
||||
update: jest.fn(),
|
||||
aggregate: jest.fn(),
|
||||
},
|
||||
orderItem: {
|
||||
createMany: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
OrderService,
|
||||
{
|
||||
provide: PrismaService,
|
||||
useValue: mockPrismaService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<OrderService>(OrderService);
|
||||
prisma = module.get<PrismaService>(PrismaService);
|
||||
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('generateOrderNo', () => {
|
||||
it('should generate order number with ORD prefix', () => {
|
||||
const orderNo = service.generateOrderNo();
|
||||
expect(orderNo).toMatch(/^ORD/);
|
||||
expect(orderNo.length).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
it('should generate unique order numbers', () => {
|
||||
const orderNo1 = service.generateOrderNo();
|
||||
const orderNo2 = service.generateOrderNo();
|
||||
expect(orderNo1).not.toBe(orderNo2);
|
||||
});
|
||||
|
||||
it('should follow format ORD + YYYYMMDDHHmmss + 6-digit random', () => {
|
||||
const orderNo = service.generateOrderNo();
|
||||
expect(orderNo).toMatch(/^ORD\d{14}\d{6}$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
const userId = 'test-user-id';
|
||||
const createDto = {
|
||||
items: [
|
||||
{
|
||||
productId: 'product-1',
|
||||
productName: '测试商品A',
|
||||
productImage: 'https://example.com/a.jpg',
|
||||
quantity: 2,
|
||||
unitPrice: 99.99,
|
||||
},
|
||||
{
|
||||
productId: 'product-2',
|
||||
productName: '测试商品B',
|
||||
quantity: 1,
|
||||
unitPrice: 199.00,
|
||||
},
|
||||
],
|
||||
remark: '请尽快发货',
|
||||
};
|
||||
|
||||
it('should create an order with correct total amount', async () => {
|
||||
const expectedTotal = 2 * 99.99 + 1 * 199.0;
|
||||
const mockOrder = {
|
||||
id: 'order-id',
|
||||
orderNo: 'ORD20260525120000000001',
|
||||
userId,
|
||||
status: OrderStatus.PENDING,
|
||||
totalAmount: expectedTotal,
|
||||
paidAmount: expectedTotal,
|
||||
discountAmount: 0,
|
||||
paymentMethod: null,
|
||||
paymentOrderId: null,
|
||||
remark: createDto.remark,
|
||||
paidAt: null,
|
||||
shippedAt: null,
|
||||
completedAt: null,
|
||||
cancelledAt: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
items: createDto.items.map((item, index) => ({
|
||||
id: `item-id-${index}`,
|
||||
orderId: 'order-id',
|
||||
...item,
|
||||
totalAmount: item.quantity * item.unitPrice,
|
||||
createdAt: new Date(),
|
||||
})),
|
||||
};
|
||||
|
||||
mockPrismaService.order.create.mockResolvedValue(mockOrder);
|
||||
|
||||
const result = await service.create(userId, createDto);
|
||||
|
||||
expect(result.code).toBe(201);
|
||||
expect(result.message).toBe('Order created successfully');
|
||||
expect(result.data).toEqual(mockOrder);
|
||||
expect(prisma.order.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
userId,
|
||||
status: OrderStatus.PENDING,
|
||||
remark: createDto.remark,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should calculate total amount from items', async () => {
|
||||
const mockOrder = {
|
||||
id: 'order-id',
|
||||
orderNo: 'ORD20260525120000000001',
|
||||
userId,
|
||||
status: OrderStatus.PENDING,
|
||||
totalAmount: 398.98,
|
||||
paidAmount: 398.98,
|
||||
discountAmount: 0,
|
||||
};
|
||||
|
||||
mockPrismaService.order.create.mockResolvedValue(mockOrder);
|
||||
|
||||
await service.create(userId, createDto);
|
||||
|
||||
const createCall = mockPrismaService.order.create.mock.calls[0][0];
|
||||
expect(Number(createCall.data.totalAmount)).toBeCloseTo(398.98, 1);
|
||||
expect(Number(createCall.data.paidAmount)).toBeCloseTo(398.98, 1);
|
||||
});
|
||||
|
||||
it('should throw BadRequestException when items is empty', async () => {
|
||||
const emptyDto = { items: [] };
|
||||
|
||||
await expect(service.create(userId, emptyDto)).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
|
||||
it('should set discountAmount to 0 by default', async () => {
|
||||
const mockOrder = {
|
||||
id: 'order-id',
|
||||
orderNo: 'ORD20260525120000000001',
|
||||
userId,
|
||||
status: OrderStatus.PENDING,
|
||||
totalAmount: 398.98,
|
||||
paidAmount: 398.98,
|
||||
discountAmount: 0,
|
||||
};
|
||||
|
||||
mockPrismaService.order.create.mockResolvedValue(mockOrder);
|
||||
|
||||
await service.create(userId, createDto);
|
||||
|
||||
const createCall = mockPrismaService.order.create.mock.calls[0][0];
|
||||
expect(Number(createCall.data.discountAmount)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findOne', () => {
|
||||
it('should return order with items', async () => {
|
||||
const mockOrder = {
|
||||
id: 'order-id',
|
||||
orderNo: 'ORD20260525120000000001',
|
||||
userId: 'test-user-id',
|
||||
status: OrderStatus.PENDING,
|
||||
totalAmount: 398.98,
|
||||
paidAmount: 398.98,
|
||||
discountAmount: 0,
|
||||
items: [
|
||||
{ id: 'item-1', productName: '商品A', quantity: 2, unitPrice: 99.99 },
|
||||
],
|
||||
};
|
||||
|
||||
mockPrismaService.order.findUnique.mockResolvedValue(mockOrder);
|
||||
|
||||
const result = await service.findOne('order-id');
|
||||
|
||||
expect(result.code).toBe(200);
|
||||
expect(result.data.id).toBe('order-id');
|
||||
expect(result.data.items).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when order not found', async () => {
|
||||
mockPrismaService.order.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(service.findOne('nonexistent')).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it('should filter by userId when provided', async () => {
|
||||
const mockOrder = {
|
||||
id: 'order-id',
|
||||
userId: 'user-1',
|
||||
status: OrderStatus.PENDING,
|
||||
items: [],
|
||||
};
|
||||
|
||||
mockPrismaService.order.findUnique.mockResolvedValue(mockOrder);
|
||||
|
||||
await service.findOne('order-id', 'user-1');
|
||||
|
||||
expect(prisma.order.findUnique).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: 'order-id', userId: 'user-1' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findMany', () => {
|
||||
it('should return paginated orders', async () => {
|
||||
const mockOrders = [
|
||||
{ id: '1', orderNo: 'ORD001', status: OrderStatus.PENDING, items: [] },
|
||||
{ id: '2', orderNo: 'ORD002', status: OrderStatus.PAID, items: [] },
|
||||
];
|
||||
|
||||
mockPrismaService.order.findMany.mockResolvedValue(mockOrders);
|
||||
mockPrismaService.order.count.mockResolvedValue(2);
|
||||
|
||||
const result = await service.findMany({ page: 1, limit: 20 });
|
||||
|
||||
expect(result.code).toBe(200);
|
||||
expect(result.data.orders).toHaveLength(2);
|
||||
expect(result.data.pagination.total).toBe(2);
|
||||
expect(result.data.pagination.page).toBe(1);
|
||||
});
|
||||
|
||||
it('should filter by status', async () => {
|
||||
mockPrismaService.order.findMany.mockResolvedValue([]);
|
||||
mockPrismaService.order.count.mockResolvedValue(0);
|
||||
|
||||
await service.findMany({ page: 1, limit: 20, status: OrderStatus.PENDING });
|
||||
|
||||
expect(prisma.order.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
status: OrderStatus.PENDING,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should filter by userId', async () => {
|
||||
mockPrismaService.order.findMany.mockResolvedValue([]);
|
||||
mockPrismaService.order.count.mockResolvedValue(0);
|
||||
|
||||
await service.findMany({ page: 1, limit: 20, userId: 'user-1' });
|
||||
|
||||
expect(prisma.order.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
userId: 'user-1',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should calculate correct pagination', async () => {
|
||||
mockPrismaService.order.findMany.mockResolvedValue([]);
|
||||
mockPrismaService.order.count.mockResolvedValue(50);
|
||||
|
||||
const result = await service.findMany({ page: 2, limit: 10 });
|
||||
|
||||
expect(result.data.pagination.totalPages).toBe(5);
|
||||
expect(result.data.pagination.page).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateStatus', () => {
|
||||
it('should transition from PENDING to PAID', async () => {
|
||||
const existingOrder = {
|
||||
id: 'order-id',
|
||||
status: OrderStatus.PENDING,
|
||||
userId: 'user-1',
|
||||
};
|
||||
const updatedOrder = {
|
||||
...existingOrder,
|
||||
status: OrderStatus.PAID,
|
||||
paidAt: new Date(),
|
||||
};
|
||||
|
||||
mockPrismaService.order.findUnique.mockResolvedValue(existingOrder);
|
||||
mockPrismaService.order.update.mockResolvedValue(updatedOrder);
|
||||
|
||||
const result = await service.updateStatus('order-id', OrderStatus.PAID);
|
||||
|
||||
expect(result.code).toBe(200);
|
||||
expect(result.data.status).toBe(OrderStatus.PAID);
|
||||
});
|
||||
|
||||
it('should transition from PENDING to CANCELLED', async () => {
|
||||
const existingOrder = {
|
||||
id: 'order-id',
|
||||
status: OrderStatus.PENDING,
|
||||
userId: 'user-1',
|
||||
};
|
||||
const updatedOrder = {
|
||||
...existingOrder,
|
||||
status: OrderStatus.CANCELLED,
|
||||
cancelledAt: new Date(),
|
||||
};
|
||||
|
||||
mockPrismaService.order.findUnique.mockResolvedValue(existingOrder);
|
||||
mockPrismaService.order.update.mockResolvedValue(updatedOrder);
|
||||
|
||||
const result = await service.updateStatus('order-id', OrderStatus.CANCELLED);
|
||||
|
||||
expect(result.code).toBe(200);
|
||||
expect(result.data.status).toBe(OrderStatus.CANCELLED);
|
||||
});
|
||||
|
||||
it('should transition from PAID to SHIPPED', async () => {
|
||||
const existingOrder = {
|
||||
id: 'order-id',
|
||||
status: OrderStatus.PAID,
|
||||
userId: 'user-1',
|
||||
};
|
||||
const updatedOrder = {
|
||||
...existingOrder,
|
||||
status: OrderStatus.SHIPPED,
|
||||
shippedAt: new Date(),
|
||||
};
|
||||
|
||||
mockPrismaService.order.findUnique.mockResolvedValue(existingOrder);
|
||||
mockPrismaService.order.update.mockResolvedValue(updatedOrder);
|
||||
|
||||
const result = await service.updateStatus('order-id', OrderStatus.SHIPPED);
|
||||
|
||||
expect(result.code).toBe(200);
|
||||
expect(result.data.status).toBe(OrderStatus.SHIPPED);
|
||||
});
|
||||
|
||||
it('should transition from PAID to REFUNDED', async () => {
|
||||
const existingOrder = {
|
||||
id: 'order-id',
|
||||
status: OrderStatus.PAID,
|
||||
userId: 'user-1',
|
||||
};
|
||||
const updatedOrder = {
|
||||
...existingOrder,
|
||||
status: OrderStatus.REFUNDED,
|
||||
};
|
||||
|
||||
mockPrismaService.order.findUnique.mockResolvedValue(existingOrder);
|
||||
mockPrismaService.order.update.mockResolvedValue(updatedOrder);
|
||||
|
||||
const result = await service.updateStatus('order-id', OrderStatus.REFUNDED);
|
||||
|
||||
expect(result.code).toBe(200);
|
||||
expect(result.data.status).toBe(OrderStatus.REFUNDED);
|
||||
});
|
||||
|
||||
it('should transition from SHIPPED to COMPLETED', async () => {
|
||||
const existingOrder = {
|
||||
id: 'order-id',
|
||||
status: OrderStatus.SHIPPED,
|
||||
userId: 'user-1',
|
||||
};
|
||||
const updatedOrder = {
|
||||
...existingOrder,
|
||||
status: OrderStatus.COMPLETED,
|
||||
completedAt: new Date(),
|
||||
};
|
||||
|
||||
mockPrismaService.order.findUnique.mockResolvedValue(existingOrder);
|
||||
mockPrismaService.order.update.mockResolvedValue(updatedOrder);
|
||||
|
||||
const result = await service.updateStatus('order-id', OrderStatus.COMPLETED);
|
||||
|
||||
expect(result.code).toBe(200);
|
||||
expect(result.data.status).toBe(OrderStatus.COMPLETED);
|
||||
});
|
||||
|
||||
it('should transition from SHIPPED to REFUNDED', async () => {
|
||||
const existingOrder = {
|
||||
id: 'order-id',
|
||||
status: OrderStatus.SHIPPED,
|
||||
userId: 'user-1',
|
||||
};
|
||||
const updatedOrder = {
|
||||
...existingOrder,
|
||||
status: OrderStatus.REFUNDED,
|
||||
};
|
||||
|
||||
mockPrismaService.order.findUnique.mockResolvedValue(existingOrder);
|
||||
mockPrismaService.order.update.mockResolvedValue(updatedOrder);
|
||||
|
||||
const result = await service.updateStatus('order-id', OrderStatus.REFUNDED);
|
||||
|
||||
expect(result.code).toBe(200);
|
||||
expect(result.data.status).toBe(OrderStatus.REFUNDED);
|
||||
});
|
||||
|
||||
it('should reject invalid transition from PENDING to SHIPPED', async () => {
|
||||
const existingOrder = {
|
||||
id: 'order-id',
|
||||
status: OrderStatus.PENDING,
|
||||
userId: 'user-1',
|
||||
};
|
||||
|
||||
mockPrismaService.order.findUnique.mockResolvedValue(existingOrder);
|
||||
|
||||
await expect(
|
||||
service.updateStatus('order-id', OrderStatus.SHIPPED),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('should reject invalid transition from PENDING to COMPLETED', async () => {
|
||||
const existingOrder = {
|
||||
id: 'order-id',
|
||||
status: OrderStatus.PENDING,
|
||||
userId: 'user-1',
|
||||
};
|
||||
|
||||
mockPrismaService.order.findUnique.mockResolvedValue(existingOrder);
|
||||
|
||||
await expect(
|
||||
service.updateStatus('order-id', OrderStatus.COMPLETED),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('should reject invalid transition from CANCELLED to PAID', async () => {
|
||||
const existingOrder = {
|
||||
id: 'order-id',
|
||||
status: OrderStatus.CANCELLED,
|
||||
userId: 'user-1',
|
||||
};
|
||||
|
||||
mockPrismaService.order.findUnique.mockResolvedValue(existingOrder);
|
||||
|
||||
await expect(
|
||||
service.updateStatus('order-id', OrderStatus.PAID),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('should reject invalid transition from COMPLETED to PENDING', async () => {
|
||||
const existingOrder = {
|
||||
id: 'order-id',
|
||||
status: OrderStatus.COMPLETED,
|
||||
userId: 'user-1',
|
||||
};
|
||||
|
||||
mockPrismaService.order.findUnique.mockResolvedValue(existingOrder);
|
||||
|
||||
await expect(
|
||||
service.updateStatus('order-id', OrderStatus.PENDING),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('should reject transition from REFUNDED to any status', async () => {
|
||||
const existingOrder = {
|
||||
id: 'order-id',
|
||||
status: OrderStatus.REFUNDED,
|
||||
userId: 'user-1',
|
||||
};
|
||||
|
||||
mockPrismaService.order.findUnique.mockResolvedValue(existingOrder);
|
||||
|
||||
await expect(
|
||||
service.updateStatus('order-id', OrderStatus.PAID),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when order not found', async () => {
|
||||
mockPrismaService.order.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.updateStatus('nonexistent', OrderStatus.PAID),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('should set paidAt when transitioning to PAID', async () => {
|
||||
const existingOrder = {
|
||||
id: 'order-id',
|
||||
status: OrderStatus.PENDING,
|
||||
};
|
||||
|
||||
mockPrismaService.order.findUnique.mockResolvedValue(existingOrder);
|
||||
mockPrismaService.order.update.mockResolvedValue({
|
||||
...existingOrder,
|
||||
status: OrderStatus.PAID,
|
||||
});
|
||||
|
||||
await service.updateStatus('order-id', OrderStatus.PAID);
|
||||
|
||||
expect(prisma.order.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
status: OrderStatus.PAID,
|
||||
paidAt: expect.any(Date),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should set shippedAt when transitioning to SHIPPED', async () => {
|
||||
const existingOrder = {
|
||||
id: 'order-id',
|
||||
status: OrderStatus.PAID,
|
||||
};
|
||||
|
||||
mockPrismaService.order.findUnique.mockResolvedValue(existingOrder);
|
||||
mockPrismaService.order.update.mockResolvedValue({
|
||||
...existingOrder,
|
||||
status: OrderStatus.SHIPPED,
|
||||
});
|
||||
|
||||
await service.updateStatus('order-id', OrderStatus.SHIPPED);
|
||||
|
||||
expect(prisma.order.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
status: OrderStatus.SHIPPED,
|
||||
shippedAt: expect.any(Date),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should set completedAt when transitioning to COMPLETED', async () => {
|
||||
const existingOrder = {
|
||||
id: 'order-id',
|
||||
status: OrderStatus.SHIPPED,
|
||||
};
|
||||
|
||||
mockPrismaService.order.findUnique.mockResolvedValue(existingOrder);
|
||||
mockPrismaService.order.update.mockResolvedValue({
|
||||
...existingOrder,
|
||||
status: OrderStatus.COMPLETED,
|
||||
});
|
||||
|
||||
await service.updateStatus('order-id', OrderStatus.COMPLETED);
|
||||
|
||||
expect(prisma.order.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
status: OrderStatus.COMPLETED,
|
||||
completedAt: expect.any(Date),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cancel', () => {
|
||||
it('should cancel a PENDING order', async () => {
|
||||
const existingOrder = {
|
||||
id: 'order-id',
|
||||
status: OrderStatus.PENDING,
|
||||
userId: 'user-1',
|
||||
};
|
||||
const cancelledOrder = {
|
||||
...existingOrder,
|
||||
status: OrderStatus.CANCELLED,
|
||||
cancelledAt: new Date(),
|
||||
};
|
||||
|
||||
mockPrismaService.order.findUnique.mockResolvedValue(existingOrder);
|
||||
mockPrismaService.order.update.mockResolvedValue(cancelledOrder);
|
||||
|
||||
const result = await service.cancel('order-id', 'user-1');
|
||||
|
||||
expect(result.code).toBe(200);
|
||||
expect(result.data.status).toBe(OrderStatus.CANCELLED);
|
||||
});
|
||||
|
||||
it('should throw BadRequestException when cancelling non-PENDING order', async () => {
|
||||
const existingOrder = {
|
||||
id: 'order-id',
|
||||
status: OrderStatus.PAID,
|
||||
userId: 'user-1',
|
||||
};
|
||||
|
||||
mockPrismaService.order.findUnique.mockResolvedValue(existingOrder);
|
||||
|
||||
await expect(service.cancel('order-id', 'user-1')).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when order not found', async () => {
|
||||
mockPrismaService.order.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(service.cancel('nonexistent', 'user-1')).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStats', () => {
|
||||
it('should return order statistics', async () => {
|
||||
mockPrismaService.order.count
|
||||
.mockResolvedValueOnce(10)
|
||||
.mockResolvedValueOnce(3)
|
||||
.mockResolvedValueOnce(2)
|
||||
.mockResolvedValueOnce(1)
|
||||
.mockResolvedValueOnce(2)
|
||||
.mockResolvedValueOnce(1)
|
||||
.mockResolvedValueOnce(1);
|
||||
|
||||
mockPrismaService.order.aggregate.mockResolvedValue({
|
||||
_sum: { totalAmount: 9999.99 },
|
||||
});
|
||||
|
||||
const result = await service.getStats('user-1');
|
||||
|
||||
expect(result.code).toBe(200);
|
||||
expect(result.data).toHaveProperty('totalOrders');
|
||||
expect(result.data).toHaveProperty('pendingOrders');
|
||||
expect(result.data).toHaveProperty('paidOrders');
|
||||
expect(result.data).toHaveProperty('shippedOrders');
|
||||
expect(result.data).toHaveProperty('completedOrders');
|
||||
expect(result.data).toHaveProperty('cancelledOrders');
|
||||
expect(result.data).toHaveProperty('refundedOrders');
|
||||
expect(result.data).toHaveProperty('totalAmount');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateStatusTransition', () => {
|
||||
it('should allow PENDING -> PAID', () => {
|
||||
expect(
|
||||
service.validateStatusTransition(OrderStatus.PENDING, OrderStatus.PAID),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow PENDING -> CANCELLED', () => {
|
||||
expect(
|
||||
service.validateStatusTransition(OrderStatus.PENDING, OrderStatus.CANCELLED),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow PAID -> SHIPPED', () => {
|
||||
expect(
|
||||
service.validateStatusTransition(OrderStatus.PAID, OrderStatus.SHIPPED),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow PAID -> REFUNDED', () => {
|
||||
expect(
|
||||
service.validateStatusTransition(OrderStatus.PAID, OrderStatus.REFUNDED),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow SHIPPED -> COMPLETED', () => {
|
||||
expect(
|
||||
service.validateStatusTransition(OrderStatus.SHIPPED, OrderStatus.COMPLETED),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow SHIPPED -> REFUNDED', () => {
|
||||
expect(
|
||||
service.validateStatusTransition(OrderStatus.SHIPPED, OrderStatus.REFUNDED),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject PENDING -> SHIPPED', () => {
|
||||
expect(
|
||||
service.validateStatusTransition(OrderStatus.PENDING, OrderStatus.SHIPPED),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject PENDING -> COMPLETED', () => {
|
||||
expect(
|
||||
service.validateStatusTransition(OrderStatus.PENDING, OrderStatus.COMPLETED),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject CANCELLED -> any', () => {
|
||||
expect(
|
||||
service.validateStatusTransition(OrderStatus.CANCELLED, OrderStatus.PAID),
|
||||
).toBe(false);
|
||||
expect(
|
||||
service.validateStatusTransition(OrderStatus.CANCELLED, OrderStatus.SHIPPED),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject REFUNDED -> any', () => {
|
||||
expect(
|
||||
service.validateStatusTransition(OrderStatus.REFUNDED, OrderStatus.PAID),
|
||||
).toBe(false);
|
||||
expect(
|
||||
service.validateStatusTransition(OrderStatus.REFUNDED, OrderStatus.COMPLETED),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject COMPLETED -> any', () => {
|
||||
expect(
|
||||
service.validateStatusTransition(OrderStatus.COMPLETED, OrderStatus.PAID),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject same status transition', () => {
|
||||
expect(
|
||||
service.validateStatusTransition(OrderStatus.PENDING, OrderStatus.PENDING),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
import {
|
||||
IsString,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsArray,
|
||||
ValidateNested,
|
||||
IsNumber,
|
||||
Min,
|
||||
ArrayMinSize,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class OrderItemDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
productId: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
productName: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
productImage?: string;
|
||||
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
quantity: number;
|
||||
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
unitPrice: number;
|
||||
}
|
||||
|
||||
export class CreateOrderDto {
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => OrderItemDto)
|
||||
items: OrderItemDto[];
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
remark?: string;
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
import { IsOptional, IsEnum, IsNumber, Min, IsString } from 'class-validator';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { OrderStatus } from '../types/order-status.enum';
|
||||
|
||||
export class QueryOrderDto {
|
||||
@Transform(({ value }) => parseInt(value, 10))
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@IsOptional()
|
||||
page?: number = 1;
|
||||
|
||||
@Transform(({ value }) => parseInt(value, 10))
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@IsOptional()
|
||||
limit?: number = 20;
|
||||
|
||||
@IsEnum(OrderStatus)
|
||||
@IsOptional()
|
||||
status?: OrderStatus;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
userId?: string;
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import { IsEnum } from 'class-validator';
|
||||
import { OrderStatus } from '../types/order-status.enum';
|
||||
|
||||
export class UpdateOrderStatusDto {
|
||||
@IsEnum(OrderStatus)
|
||||
status: OrderStatus;
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
Request,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { OrderService } from './order.service';
|
||||
import { CreateOrderDto } from './dto/create-order.dto';
|
||||
import { UpdateOrderStatusDto } from './dto/update-order-status.dto';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
|
||||
@Controller('orders')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class OrderController {
|
||||
constructor(private readonly orderService: OrderService) {}
|
||||
|
||||
@Post()
|
||||
async create(@Body() createOrderDto: CreateOrderDto, @Request() req: any) {
|
||||
const userId = req.user?.id;
|
||||
return this.orderService.create(userId, createOrderDto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
async findAll(
|
||||
@Query('page') page: string = '1',
|
||||
@Query('limit') limit: string = '20',
|
||||
@Query('status') status?: string,
|
||||
@Request() req?: any,
|
||||
) {
|
||||
const userId = req.user?.id;
|
||||
return this.orderService.findMany({
|
||||
page: parseInt(page, 10),
|
||||
limit: parseInt(limit, 10),
|
||||
userId,
|
||||
status: status as any,
|
||||
});
|
||||
}
|
||||
|
||||
@Get('stats')
|
||||
async getStats(@Request() req: any) {
|
||||
const userId = req.user?.id;
|
||||
return this.orderService.getStats(userId);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findOne(@Param('id') id: string, @Request() req: any) {
|
||||
const userId = req.user?.id;
|
||||
return this.orderService.findOne(id, userId);
|
||||
}
|
||||
|
||||
@Put(':id/status')
|
||||
async updateStatus(
|
||||
@Param('id') id: string,
|
||||
@Body() updateOrderStatusDto: UpdateOrderStatusDto,
|
||||
) {
|
||||
return this.orderService.updateStatus(id, updateOrderStatusDto.status);
|
||||
}
|
||||
|
||||
@Post(':id/cancel')
|
||||
async cancel(@Param('id') id: string, @Request() req: any) {
|
||||
const userId = req.user?.id;
|
||||
return this.orderService.cancel(id, userId);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { OrderController } from './order.controller';
|
||||
import { OrderService } from './order.service';
|
||||
|
||||
@Module({
|
||||
controllers: [OrderController],
|
||||
providers: [OrderService],
|
||||
exports: [OrderService],
|
||||
})
|
||||
export class OrderModule {}
|
||||
|
|
@ -0,0 +1,261 @@
|
|||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { OrderStatus } from './types/order-status.enum';
|
||||
import { CreateOrderDto } from './dto/create-order.dto';
|
||||
|
||||
const VALID_TRANSITIONS: Record<OrderStatus, OrderStatus[]> = {
|
||||
[OrderStatus.PENDING]: [OrderStatus.PAID, OrderStatus.CANCELLED],
|
||||
[OrderStatus.PAID]: [OrderStatus.SHIPPED, OrderStatus.REFUNDED],
|
||||
[OrderStatus.SHIPPED]: [OrderStatus.COMPLETED, OrderStatus.REFUNDED],
|
||||
[OrderStatus.COMPLETED]: [],
|
||||
[OrderStatus.CANCELLED]: [],
|
||||
[OrderStatus.REFUNDED]: [],
|
||||
};
|
||||
|
||||
const STATUS_TIMESTAMP_MAP: Partial<Record<OrderStatus, string>> = {
|
||||
[OrderStatus.PAID]: 'paidAt',
|
||||
[OrderStatus.SHIPPED]: 'shippedAt',
|
||||
[OrderStatus.COMPLETED]: 'completedAt',
|
||||
[OrderStatus.CANCELLED]: 'cancelledAt',
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class OrderService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
generateOrderNo(): string {
|
||||
const now = new Date();
|
||||
const pad = (n: number) => n.toString().padStart(2, '0');
|
||||
const timestamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
|
||||
const random = Math.floor(Math.random() * 1000000).toString().padStart(6, '0');
|
||||
return `ORD${timestamp}${random}`;
|
||||
}
|
||||
|
||||
validateStatusTransition(
|
||||
currentStatus: OrderStatus,
|
||||
targetStatus: OrderStatus,
|
||||
): boolean {
|
||||
if (currentStatus === targetStatus) return false;
|
||||
const allowed = VALID_TRANSITIONS[currentStatus];
|
||||
if (!allowed) return false;
|
||||
return allowed.includes(targetStatus);
|
||||
}
|
||||
|
||||
private getStatusUpdateData(targetStatus: OrderStatus): Record<string, unknown> {
|
||||
const data: Record<string, unknown> = { status: targetStatus };
|
||||
const timestampField = STATUS_TIMESTAMP_MAP[targetStatus];
|
||||
if (timestampField) {
|
||||
data[timestampField] = new Date();
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
private calculateTotalAmount(items: CreateOrderDto['items']): number {
|
||||
return items.reduce((sum, item) => sum + item.quantity * item.unitPrice, 0);
|
||||
}
|
||||
|
||||
async create(userId: string, createDto: CreateOrderDto) {
|
||||
const { items, remark } = createDto;
|
||||
|
||||
if (!items || items.length === 0) {
|
||||
throw new BadRequestException('Order must have at least one item');
|
||||
}
|
||||
|
||||
const orderNo = this.generateOrderNo();
|
||||
const totalAmount = this.calculateTotalAmount(items);
|
||||
|
||||
const order = await this.prisma.order.create({
|
||||
data: {
|
||||
orderNo,
|
||||
userId,
|
||||
status: OrderStatus.PENDING,
|
||||
totalAmount: new Prisma.Decimal(totalAmount),
|
||||
paidAmount: new Prisma.Decimal(totalAmount),
|
||||
discountAmount: new Prisma.Decimal(0),
|
||||
remark,
|
||||
items: {
|
||||
create: items.map((item) => ({
|
||||
productId: item.productId,
|
||||
productName: item.productName,
|
||||
productImage: item.productImage,
|
||||
quantity: item.quantity,
|
||||
unitPrice: new Prisma.Decimal(item.unitPrice),
|
||||
totalAmount: new Prisma.Decimal(item.quantity * item.unitPrice),
|
||||
})),
|
||||
},
|
||||
},
|
||||
include: { items: true },
|
||||
});
|
||||
|
||||
return {
|
||||
code: 201,
|
||||
message: 'Order created successfully',
|
||||
data: order,
|
||||
};
|
||||
}
|
||||
|
||||
async findOne(id: string, userId?: string) {
|
||||
const where: Prisma.OrderWhereUniqueInput = { id };
|
||||
if (userId) {
|
||||
(where as Record<string, unknown>).userId = userId;
|
||||
}
|
||||
|
||||
const order = await this.prisma.order.findUnique({
|
||||
where,
|
||||
include: { items: true },
|
||||
});
|
||||
|
||||
if (!order) {
|
||||
throw new NotFoundException('Order not found');
|
||||
}
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
message: 'Order retrieved successfully',
|
||||
data: order,
|
||||
};
|
||||
}
|
||||
|
||||
async findMany(query: {
|
||||
page: number;
|
||||
limit: number;
|
||||
userId?: string;
|
||||
status?: OrderStatus;
|
||||
}) {
|
||||
const { page, limit, userId, status } = query;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const where: Prisma.OrderWhereInput = {};
|
||||
if (userId) {
|
||||
where.userId = userId;
|
||||
}
|
||||
if (status) {
|
||||
where.status = status;
|
||||
}
|
||||
|
||||
const [orders, total] = await Promise.all([
|
||||
this.prisma.order.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip,
|
||||
take: limit,
|
||||
include: { items: true },
|
||||
}),
|
||||
this.prisma.order.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
message: 'Orders retrieved successfully',
|
||||
data: {
|
||||
orders,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async updateStatus(id: string, targetStatus: OrderStatus) {
|
||||
const order = await this.prisma.order.findUnique({ where: { id } });
|
||||
|
||||
if (!order) {
|
||||
throw new NotFoundException('Order not found');
|
||||
}
|
||||
|
||||
if (!this.validateStatusTransition(order.status as OrderStatus, targetStatus)) {
|
||||
throw new BadRequestException(
|
||||
`Invalid status transition from ${order.status} to ${targetStatus}`,
|
||||
);
|
||||
}
|
||||
|
||||
const updateData = this.getStatusUpdateData(targetStatus);
|
||||
|
||||
const updated = await this.prisma.order.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
include: { items: true },
|
||||
});
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
message: 'Order status updated successfully',
|
||||
data: updated,
|
||||
};
|
||||
}
|
||||
|
||||
async cancel(id: string, userId?: string) {
|
||||
const where: Prisma.OrderWhereUniqueInput = { id };
|
||||
if (userId) {
|
||||
(where as Record<string, unknown>).userId = userId;
|
||||
}
|
||||
|
||||
const order = await this.prisma.order.findUnique({ where });
|
||||
|
||||
if (!order) {
|
||||
throw new NotFoundException('Order not found');
|
||||
}
|
||||
|
||||
if (order.status !== OrderStatus.PENDING) {
|
||||
throw new BadRequestException('Only pending orders can be cancelled');
|
||||
}
|
||||
|
||||
const updated = await this.prisma.order.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: OrderStatus.CANCELLED,
|
||||
cancelledAt: new Date(),
|
||||
},
|
||||
include: { items: true },
|
||||
});
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
message: 'Order cancelled successfully',
|
||||
data: updated,
|
||||
};
|
||||
}
|
||||
|
||||
async getStats(userId?: string) {
|
||||
const where: Prisma.OrderWhereInput = {};
|
||||
if (userId) {
|
||||
where.userId = userId;
|
||||
}
|
||||
|
||||
const statusCounts = await Promise.all(
|
||||
Object.values(OrderStatus).map((status) =>
|
||||
this.prisma.order.count({ where: { ...where, status } }),
|
||||
),
|
||||
);
|
||||
|
||||
const amountResult = await this.prisma.order.aggregate({
|
||||
where,
|
||||
_sum: { totalAmount: true },
|
||||
});
|
||||
|
||||
const [totalOrders, pendingOrders, paidOrders, shippedOrders, completedOrders, cancelledOrders, refundedOrders] = statusCounts;
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
message: 'Order statistics retrieved successfully',
|
||||
data: {
|
||||
totalOrders,
|
||||
pendingOrders,
|
||||
paidOrders,
|
||||
shippedOrders,
|
||||
completedOrders,
|
||||
cancelledOrders,
|
||||
refundedOrders,
|
||||
totalAmount: amountResult._sum.totalAmount || 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
export enum OrderStatus {
|
||||
PENDING = 'PENDING',
|
||||
PAID = 'PAID',
|
||||
SHIPPED = 'SHIPPED',
|
||||
COMPLETED = 'COMPLETED',
|
||||
CANCELLED = 'CANCELLED',
|
||||
REFUNDED = 'REFUNDED',
|
||||
}
|
||||
Loading…
Reference in New Issue