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_MAX_FILE_SIZE=104857600
|
||||||
UPLOAD_ALLOWED_MIME_TYPES="*/*"
|
UPLOAD_ALLOWED_MIME_TYPES="*/*"
|
||||||
UPLOAD_CHUNK_SIZE=5242880
|
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"
|
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@alicloud/dysmsapi20170525": "^4.5.1",
|
||||||
|
"@alicloud/openapi-client": "^0.4.15",
|
||||||
"@nestjs/cache-manager": "^3.1.2",
|
"@nestjs/cache-manager": "^3.1.2",
|
||||||
"@nestjs/common": "^11.0.1",
|
"@nestjs/common": "^11.0.1",
|
||||||
"@nestjs/config": "^4.0.4",
|
"@nestjs/config": "^4.0.4",
|
||||||
|
|
@ -28,19 +30,18 @@
|
||||||
"@nestjs/passport": "^11.0.5",
|
"@nestjs/passport": "^11.0.5",
|
||||||
"@nestjs/platform-express": "^11.0.1",
|
"@nestjs/platform-express": "^11.0.1",
|
||||||
"@nestjs/swagger": "^8.0.0",
|
"@nestjs/swagger": "^8.0.0",
|
||||||
"@willsoto/nestjs-prometheus": "^6.0.0",
|
"@opentelemetry/auto-instrumentations-node": "^0.41.0",
|
||||||
"@opentelemetry/sdk-node": "^0.48.0",
|
"@opentelemetry/exporter-trace-otlp-proto": "^0.48.0",
|
||||||
"@opentelemetry/instrumentation-nestjs-core": "^0.34.0",
|
|
||||||
"@opentelemetry/instrumentation-express": "^0.34.0",
|
"@opentelemetry/instrumentation-express": "^0.34.0",
|
||||||
"@opentelemetry/instrumentation-http": "^0.48.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/sdk-trace-base": "^1.21.0",
|
||||||
"@opentelemetry/auto-instrumentations-node": "^0.41.0",
|
|
||||||
"prom-client": "^15.0.0",
|
|
||||||
"@prisma/client": "6",
|
"@prisma/client": "6",
|
||||||
"@types/jsonwebtoken": "^9.0.10",
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
"@types/passport-jwt": "^4.0.1",
|
"@types/passport-jwt": "^4.0.1",
|
||||||
"@types/redis": "^4.0.11",
|
"@types/redis": "^4.0.11",
|
||||||
|
"@willsoto/nestjs-prometheus": "^6.0.0",
|
||||||
"ali-oss": "^6.21.0",
|
"ali-oss": "^6.21.0",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"cache-manager": "^7.2.8",
|
"cache-manager": "^7.2.8",
|
||||||
|
|
@ -51,8 +52,10 @@
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"minio": "^8.0.1",
|
"minio": "^8.0.1",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
|
"nodemailer": "^8.0.8",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
|
"prom-client": "^15.0.0",
|
||||||
"redis": "^5.12.1",
|
"redis": "^5.12.1",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
|
|
@ -70,6 +73,7 @@
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/multer": "^1.4.12",
|
"@types/multer": "^1.4.12",
|
||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
|
"@types/nodemailer": "^8.0.0",
|
||||||
"@types/supertest": "^7.0.0",
|
"@types/supertest": "^7.0.0",
|
||||||
"@types/uuid": "^11.0.0",
|
"@types/uuid": "^11.0.0",
|
||||||
"eslint": "^9.18.0",
|
"eslint": "^9.18.0",
|
||||||
|
|
@ -77,6 +81,7 @@
|
||||||
"eslint-plugin-prettier": "^5.2.2",
|
"eslint-plugin-prettier": "^5.2.2",
|
||||||
"globals": "^17.0.0",
|
"globals": "^17.0.0",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
|
"jest-util": "^30.4.1",
|
||||||
"prettier": "^3.4.2",
|
"prettier": "^3.4.2",
|
||||||
"prisma": "6",
|
"prisma": "6",
|
||||||
"source-map-support": "^0.5.21",
|
"source-map-support": "^0.5.21",
|
||||||
|
|
|
||||||
|
|
@ -27,12 +27,21 @@ model User {
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
roles UserRole[]
|
roles UserRole[]
|
||||||
sessions Session[]
|
sessions Session[]
|
||||||
files File[]
|
files File[]
|
||||||
oauthAccounts OAuthAccount[]
|
oauthAccounts OAuthAccount[]
|
||||||
realnameAuth RealnameAuth?
|
realnameAuth RealnameAuth?
|
||||||
mfaSecret String?
|
orders Order[]
|
||||||
|
paymentOrders PaymentOrder[]
|
||||||
|
paymentRefunds PaymentRefund[]
|
||||||
|
notifications Notification[]
|
||||||
|
notificationPreference NotificationPreference?
|
||||||
|
notificationBatches NotificationBatch[]
|
||||||
|
articles Article[]
|
||||||
|
articleVersions ArticleVersion[]
|
||||||
|
comments Comment[]
|
||||||
|
mfaSecret String?
|
||||||
mfaEnabled Boolean @default(false)
|
mfaEnabled Boolean @default(false)
|
||||||
loginAttempts Int @default(0)
|
loginAttempts Int @default(0)
|
||||||
lockedUntil DateTime?
|
lockedUntil DateTime?
|
||||||
|
|
@ -244,6 +253,7 @@ model PaymentOrder {
|
||||||
channel PaymentChannel? @relation(fields: [channelId], references: [id], onDelete: SetNull)
|
channel PaymentChannel? @relation(fields: [channelId], references: [id], onDelete: SetNull)
|
||||||
refunds PaymentRefund[]
|
refunds PaymentRefund[]
|
||||||
logs PaymentLog[]
|
logs PaymentLog[]
|
||||||
|
order Order?
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([orderNo])
|
@@index([orderNo])
|
||||||
|
|
@ -300,6 +310,65 @@ model PaymentLog {
|
||||||
@@map("payment_logs")
|
@@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 {
|
model Notification {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
|
|
@ -328,6 +397,7 @@ model Notification {
|
||||||
|
|
||||||
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
template NotificationTemplate? @relation(fields: [templateId], references: [id], onDelete: SetNull)
|
template NotificationTemplate? @relation(fields: [templateId], references: [id], onDelete: SetNull)
|
||||||
|
batchItems NotificationBatchItem[]
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([status])
|
@@index([status])
|
||||||
|
|
@ -439,6 +509,7 @@ model NotificationBatch {
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
creator User? @relation(fields: [creatorId], references: [id], onDelete: SetNull)
|
creator User? @relation(fields: [creatorId], references: [id], onDelete: SetNull)
|
||||||
|
items NotificationBatchItem[]
|
||||||
|
|
||||||
@@index([status])
|
@@index([status])
|
||||||
@@index([creatorId])
|
@@index([creatorId])
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ import { RbacModule } from './modules/rbac/rbac.module';
|
||||||
import { PaymentModule } from './modules/payment/payment.module';
|
import { PaymentModule } from './modules/payment/payment.module';
|
||||||
import { MonitoringModule } from './modules/monitoring/monitoring.module';
|
import { MonitoringModule } from './modules/monitoring/monitoring.module';
|
||||||
import { ContentModule } from './modules/content/content.module';
|
import { ContentModule } from './modules/content/content.module';
|
||||||
|
import { OrderModule } from './modules/order/order.module';
|
||||||
|
import { NotificationModule } from './modules/notification/notification.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
|
@ -27,6 +29,8 @@ import { ContentModule } from './modules/content/content.module';
|
||||||
RbacModule,
|
RbacModule,
|
||||||
PaymentModule,
|
PaymentModule,
|
||||||
ContentModule,
|
ContentModule,
|
||||||
|
OrderModule,
|
||||||
|
NotificationModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { NotificationChannel, NotificationPayload, NotificationSendResult } from './notification-channel.interface';
|
import { NotificationChannel, NotificationPayload, NotificationSendResult } from './notification-channel.interface';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export abstract class BaseChannelService implements NotificationChannel {
|
export abstract class BaseChannelService implements NotificationChannel {
|
||||||
protected readonly logger = new Logger(this.constructor.name);
|
protected readonly logger = new Logger(this.constructor.name);
|
||||||
|
protected configService: ConfigService;
|
||||||
|
|
||||||
abstract name: string;
|
abstract name: string;
|
||||||
abstract type: string;
|
abstract type: string;
|
||||||
|
|
@ -21,11 +23,32 @@ export abstract class BaseChannelService implements NotificationChannel {
|
||||||
return true;
|
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 {
|
protected replaceVariables(template: string, variables?: Record<string, any>): string {
|
||||||
if (!variables) return template;
|
if (!variables) return template;
|
||||||
|
|
||||||
return template.replace(/\{\{(\w+)\}\}/g, (match, key) => {
|
return template.replace(/\{\{(\w+)\}\}/g, (match, key) => {
|
||||||
return variables[key] !== undefined ? String(variables[key]) : match;
|
return variables[key] !== undefined ? String(variables[key]) : match;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,41 +1,120 @@
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { BaseChannelService } from './base-channel.service';
|
import { BaseChannelService } from './base-channel.service';
|
||||||
import { NotificationPayload, NotificationSendResult } from './notification-channel.interface';
|
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()
|
@Injectable()
|
||||||
export class EmailChannelService extends BaseChannelService {
|
export class EmailChannelService extends BaseChannelService {
|
||||||
name = 'Email';
|
name = 'Email';
|
||||||
type = '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> {
|
async send(payload: NotificationPayload): Promise<NotificationSendResult> {
|
||||||
try {
|
try {
|
||||||
const subject = payload.title;
|
if (this.isMockMode(this.configService)) {
|
||||||
const content = payload.contentHtml || payload.content;
|
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 {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Email sent successfully (mock)',
|
message: 'Email sent successfully',
|
||||||
channelMessageId: `email-${Date.now()}`,
|
channelMessageId: result.messageId,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`Failed to send email: ${error}`);
|
return this.createErrorResult(error);
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Unknown error',
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async validate(payload: NotificationPayload): Promise<boolean> {
|
async validate(payload: NotificationPayload): Promise<boolean> {
|
||||||
const isValid = await super.validate(payload);
|
const isValid = await super.validate(payload);
|
||||||
if (!isValid) return false;
|
if (!isValid) return false;
|
||||||
|
|
||||||
if (!payload.userId && !payload.metadata?.email) {
|
if (!payload.userId && !payload.metadata?.email) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
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,42 +1,157 @@
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { BaseChannelService } from './base-channel.service';
|
import { BaseChannelService } from './base-channel.service';
|
||||||
import { NotificationPayload, NotificationSendResult } from './notification-channel.interface';
|
import { NotificationPayload, NotificationSendResult } from './notification-channel.interface';
|
||||||
|
|
||||||
|
const PHONE_REGEX = /^1[3-9]\d{9}$/;
|
||||||
|
const RATE_LIMIT_MS = 60_000;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SmsChannelService extends BaseChannelService {
|
export class SmsChannelService extends BaseChannelService {
|
||||||
name = 'SMS';
|
name = 'SMS';
|
||||||
type = '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> {
|
async send(payload: NotificationPayload): Promise<NotificationSendResult> {
|
||||||
try {
|
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 {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: 'SMS sent successfully (mock)',
|
message: 'SMS sent successfully',
|
||||||
channelMessageId: `sms-${Date.now()}`,
|
channelMessageId: BizId || `sms-${Date.now()}`,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`Failed to send SMS: ${error}`);
|
return this.createErrorResult(error);
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Unknown error',
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async validate(payload: NotificationPayload): Promise<boolean> {
|
async validate(payload: NotificationPayload): Promise<boolean> {
|
||||||
const isValid = await super.validate(payload);
|
const isValid = await super.validate(payload);
|
||||||
if (!isValid) return false;
|
if (!isValid) return false;
|
||||||
|
|
||||||
if (!payload.userId && !payload.metadata?.phone) {
|
if (!payload.userId && !payload.metadata?.phone) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (payload.content.length > 500) {
|
if (payload.content.length > 500) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
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