Commit fa276d02 authored by ThinhNC's avatar ThinhNC

docs: implement system architecture, API specs, deployment guide, enhance demo...

docs: implement system architecture, API specs, deployment guide, enhance demo seed, and setup integration tests
parent 4af76950
......@@ -51,12 +51,13 @@ File này chỉ lưu sự thật và quyết định dài hạn giúp các phiê
- LockService cung cấp phân phối khoá (Redis hoặc memory fallback) nhằm ngăn chặn tranh chấp chạy song song của worker nền trong môi trường production.
- Rate Limiting tổng thể được xây dựng để sử dụng Redis (kết hợp memory fallback an toàn, có cơ chế tự giải phóng dữ liệu tránh rò rỉ bộ nhớ).
- Hệ thống log sử dụng LoggerService, đầu ra JSON ở production và text màu ở development, hỗ trợ ẩn thông tin nhạy cảm.
- Môi trường production được container hóa bằng Dockerfile (multi-stage) chạy với user phi quản trị và docker-compose.yml có thiết lập kiểm tra sức khoẻ (healthcheck) cho Postgres và Redis.
- Môi trường production được container hóa bằng Dockerfile (multi-stage) chạy với user phi quản trị và docker-compose.yml có thiết hành kiểm tra sức khoẻ (healthcheck) cho Postgres và Redis.
- Script seed (prisma/seed.ts) được mở rộng để tự động tạo dữ liệu mẫu demo phong phú (Wallets, Transactions, Budgets, SavingGoals, Contributions, Notifications, Reminders) cho tài khoản user@finwise.local.
## Trạng thái đã biết
- Chưa có test script hoặc test suite trong `package.json`.
- Hệ thống linting đã được cấu hình qua `eslint.config.mjs` (flat config) và chạy sạch sẽ khi gọi `pnpm run lint`.
- Đã thiết lập khung kiểm thử tích hợp (integration tests) bằng Jest và Supertest, chạy kiểm thử qua lệnh pnpm test sử dụng cấu hình môi trường test cô lập (REDIS_ENABLED=false để chạy in-memory cache).
- Hệ thống linting đã được cấu hình qua eslint.config.mjs (flat config, bỏ qua các tệp test & configs liên quan) và chạy sạch sẽ khi gọi pnpm run lint.
- `env.config.ts` dùng port fallback `8888`, còn `.env.example` dùng `7777`; README
ghi rõ cả hai và dùng `7777` cho hướng dẫn chạy theo file env mẫu.
- Wallet, Category, Transaction và Budget đã có API theo ownership; Category đồng
......
......@@ -2,7 +2,16 @@
Backend API cho **FinWise - Sổ tay Chi tiêu & Báo cáo Tài chính**, phục vụ Zalo Mini App.
API hiện hỗ trợ:
## 📚 Tài liệu Kỹ thuật Chuyên sâu
Để tìm hiểu chi tiết về hệ thống, vui lòng xem các tài liệu chuyên sâu dưới đây:
* [Kiến trúc Hệ thống & Sơ đồ ERD](./docs/architecture.md): Mô tả mô hình phân tầng, Mermaid ERD chi tiết của 14 bảng, và các cơ chế Cache, Lock, Rate Limiting, Logging.
* [Tài liệu API chi tiết](./docs/api.md): Mô tả định dạng request/response, xác thực qua token JWT, cookie HTTP-only và danh sách toàn bộ endpoints.
* [Hướng dẫn Cài đặt & Triển khai](./docs/deployment.md): Hướng dẫn thiết lập môi trường development cục bộ, nạp seed data và vận hành container hoá bằng Docker Compose (healthcheck & non-root user) hoặc PM2 Cluster.
---
## Tính năng chính
- Đăng ký, xác thực email, đăng nhập JWT và quản lý phiên đăng nhập.
- Quản lý hồ sơ, mật khẩu và khôi phục mật khẩu qua email.
......@@ -289,7 +298,15 @@ route -> validation -> controller -> service -> repository -> Prisma/PostgreSQL
| `pnpm run db:migrate` | Tạo/apply migration trong development |
| `pnpm run db:migrate:deploy` | Apply migration hiện có |
| `pnpm run db:migrate:status` | Xem trạng thái migration |
| `pnpm run db:seed` | Seed dữ liệu development |
| `pnpm run db:seed` | Seed dữ liệu development & demo data |
| `pnpm test` | Chạy bộ kiểm thử tự động với Jest |
| `pnpm run test:cov` | Chạy test suite và báo cáo độ phủ (cov)|
| `pnpm format` | Format repository bằng Prettier |
Repository hiện chưa có test suite. Script `lint` đã được khai báo nhưng chưa có ESLint config, vì vậy verification khả dụng hiện tại là Prisma validation và TypeScript build.
## Kiểm thử tự động (Testing)
Hệ thống đã được thiết lập bộ kiểm thử tích hợp (integration tests) bằng **Jest****Supertest**:
- Thư mục kiểm thử: `tests/`
- Tệp cấu hình: `jest.config.ts``tests/setup.ts`
- Các test suites khả dụng: kiểm tra tính sẵn sàng biên HTTP (`health.test.ts`), luồng đăng ký, đăng nhập và xác thực token kép (`auth.test.ts`).
- Chạy kiểm tra: `pnpm test`
# Hướng dẫn và Mô tả Chi tiết API FinWise
FinWise API được xây dựng theo chuẩn RESTful, trả về dữ liệu định dạng JSON nhất quán và hỗ trợ tích hợp trực quan qua Swagger UI.
---
## 1. Cổng Thông tin Tài liệu API (Swagger UI)
* **Đường dẫn truy cập cục bộ**: `http://localhost:7777/api/docs`
* **Giao diện**: Swagger UI được cấu hình với giao diện tối tối ưu (dark mode), cho phép thử nghiệm trực tiếp các tham số request và xem chi tiết cấu trúc JSON Schema cho từng API.
---
## 2. Chuẩn Giao tiếp & Xác thực
### 2.1 Cấu trúc Phản hồi Chuẩn (Response Schema)
#### Phản hồi Thành công (Success Response):
Mọi response thành công đều trả về HTTP Status `2xx` và có thuộc tính `success: true`:
```json
{
"success": true,
"data": {
"id": "c30172bf-d6ff-4340-9a84-0a3ffb1d9bf8",
"name": "Ví tiền mặt",
"balance": "4500000.00",
"currency": "VND"
}
}
```
#### Phản hồi Thất bại (Error Response):
Mọi response lỗi đều trả về HTTP Status tương ứng (`4xx`, `5xx`) và có cấu trúc:
```json
{
"success": false,
"message": "Thông điệp mô tả lỗi chi tiết cho client",
"code": "ERROR_CODE_NGHIEP_VU"
}
```
*Các mã lỗi nghiệp vụ (`code`) thông dụng*: `INVALID_CREDENTIALS`, `USER_INACTIVE`, `UNAUTHORIZED`, `TOKEN_EXPIRED`, `TOKEN_INVALID`, `NOT_FOUND`, `DUPLICATE_ENTRY`, `RATE_LIMIT_EXCEEDED`, `SERVICE_UNAVAILABLE`.
### 2.2 Xác thực qua Token JWT
Hệ thống sử dụng cơ chế token kép (Access Token và Refresh Token):
1. **Access Token**: Truyền qua Header HTTP:
```http
Authorization: Bearer <Your_Access_Token>
```
*Hoặc* hệ thống sẽ tự động đọc từ Cookie HTTP-only `accessToken` nếu có.
2. **Refresh Token**: Được lưu tự động trong Cookie HTTP-only `refreshToken` khi đăng nhập thành công. Để làm mới cặp token, gọi API `/auth/refresh`.
---
## 3. Danh sách Endpoints Chính
Tất cả các endpoint bên dưới có tiền tố (prefix) mặc định: `/api/v1`
### 3.1 Hệ thống & Xác thực (Auth)
* `GET /health` [Public] - Kiểm tra sức khỏe của API, database, cache và đo độ trễ.
* `POST /auth/register` [Public] - Đăng ký tài khoản (kích hoạt qua token email).
* `GET /auth/verify-email?token=<token>` [Public] - Xác thực email để active tài khoản.
* `POST /auth/login` [Public] - Đăng nhập tài khoản, nhận token và lưu cookie.
* `GET /auth/me` [Bearer] - Lấy thông tin tài khoản hiện tại.
* `POST /auth/refresh` [Public] - Dùng Refresh Token để làm mới cặp Access/Refresh Token.
* `POST /auth/logout` [Public] - Đăng xuất và thu hồi Refresh Token trong DB.
* `PUT /auth/profile` [Bearer] - Cập nhật thông tin cá nhân (họ tên, số điện thoại, avatar).
* `PUT /auth/password` [Bearer] - Đổi mật khẩu tài khoản.
* `POST /auth/forgot-password` [Public] - Gửi email khôi phục mật khẩu.
* `POST /auth/reset-password` [Public] - Đặt lại mật khẩu mới sử dụng token.
* `GET /auth/sessions` [Bearer] - Danh sách các thiết bị/phiên đăng nhập hoạt động.
* `DELETE /auth/sessions/:id` [Bearer] - Đăng xuất từ xa một phiên thiết bị cụ thể.
### 3.2 Quản trị Người dùng (Users) - Chỉ dành cho vai trò ADMIN
* `GET /users` - Lấy danh sách người dùng kèm phân trang, tìm kiếm và lọc trạng thái.
* `POST /users` - Admin tạo tài khoản người dùng trực tiếp.
* `PUT /users/:id` - Admin cập nhật trạng thái hoạt động (`isActive`) hoặc vai trò (`roleId`).
* `DELETE /users/:id` - Admin thực hiện xóa mềm (Soft-delete) tài khoản người dùng.
### 3.3 Quản lý Ví (Wallets) - Theo quyền sở hữu (Ownership)
* `GET /wallets` [Bearer] - Lấy danh sách ví hoạt động (kèm số dư, icon, màu sắc).
* `POST /wallets` [Bearer] - Tạo ví mới (hỗ trợ các loại tiền tệ VND, USD,...).
* `GET /wallets/:id` [Bearer] - Xem chi tiết một ví.
* `PUT /wallets/:id` [Bearer] - Cập nhật thông tin ví.
* `PATCH /wallets/:id/default` [Bearer] - Thiết lập một ví làm ví mặc định của hệ thống.
* `DELETE /wallets/:id` [Bearer] - Lưu trữ (archive) ví thay vì xóa vật lý để bảo toàn lịch sử giao dịch.
### 3.4 Danh mục Thu Chi (Categories)
* `GET /categories` [Bearer] - Lấy danh sách category bao gồm danh mục hệ thống (isSystem=true) và danh mục riêng của người dùng.
* `GET /categories/tree` [Bearer] - Lấy danh sách danh mục theo cấu trúc hình cây (cha - con).
* `POST /categories` [Bearer] - Tạo danh mục con hoặc danh mục riêng mới.
* `DELETE /categories/:id` [Bearer] - Lưu trữ (archive) danh mục riêng.
### 3.5 Giao dịch (Transactions) - Tự động đồng bộ số dư ví
* `GET /transactions` [Bearer] - Danh sách giao dịch có bộ lọc mạnh mẽ theo ví, danh mục, khoảng thời gian, loại thu/chi và phân trang.
* `POST /transactions` [Bearer] - Tạo giao dịch thu/chi (tự động cộng/trừ số dư ví liên quan dưới database transaction Serializable).
* `PUT /transactions/:id` [Bearer] - Cập nhật giao dịch (tự động tính toán lại và hoàn tác/cập nhật số dư ví cũ và mới tương ứng).
* `DELETE /transactions/:id` [Bearer] - Xóa giao dịch (tự động hoàn trả số dư ví về trạng thái trước giao dịch).
* `PUT /transactions/:id/receipt` [Bearer] - Tải lên ảnh hóa đơn (`receipt`) dạng multipart-form (hỗ trợ JPEG, PNG, WebP, PDF tối đa 5MB).
* `GET /transactions/:id/receipt` [Bearer] - Xem/Tải xuống tệp hóa đơn đã upload bảo mật.
### 3.6 Ngân sách chi tiêu (Budgets)
* `GET /budgets` [Bearer] - Danh sách ngân sách kèm tiến độ sử dụng tính theo thời gian thực từ giao dịch chi tiêu tương ứng.
* `POST /budgets` [Bearer] - Tạo ngân sách tổng quát (`OVERALL`) hoặc theo danh mục chi tiêu (`CATEGORY`) với ngưỡng cảnh báo tùy chọn.
### 3.7 Mục tiêu tiết kiệm (Saving Goals)
* `GET /saving-goals` [Bearer] - Danh sách mục tiêu tiết kiệm và tiến trình đạt được (%).
* `POST /saving-goals` [Bearer] - Tạo mục tiêu mới.
* `POST /saving-goals/:id/contributions` [Bearer] - Gửi tiền tích lũy vào mục tiêu tiết kiệm (hệ thống tự động cập nhật tiến trình và trạng thái hoàn thành).
### 3.8 Báo cáo tài chính (Reports) - Đọc dữ liệu nhanh có Cache
* `GET /reports/overview` [Bearer] - Báo cáo tổng quan số dư, tổng thu, tổng chi và dòng tiền ròng.
* `GET /reports/cash-flow` [Bearer] - Chuỗi dữ liệu dòng tiền theo ngày/tuần/tháng để vẽ biểu đồ đường.
* `GET /reports/category-distribution` [Bearer] - Cơ cấu chi tiêu phân chia theo danh mục để vẽ biểu đồ tròn.
* `GET /reports/budget-performance` [Bearer] - So sánh ngân sách và thực tế chi tiêu.
### 3.9 Trợ lý Tài chính AI (AI Assistant)
* `POST /ai-assistant/chat` [Bearer] - Hỏi đáp tài chính cá nhân với trợ lý AI dựa trên dữ liệu thu chi thực tế của người dùng.
* `POST /ai-assistant/classify` [Bearer] - Phân tích văn bản giao dịch tự do để đề xuất danh mục thích hợp.
* `POST /ai-assistant/ocr` [Bearer] - Phân tích ảnh hóa đơn tải lên để tự động bóc tách số tiền, danh mục, ngày tháng.
# Tài liệu Kiến trúc Hệ thống FinWise Backend
Tài liệu này mô tả chi tiết kiến trúc phần mềm, cấu trúc dữ liệu (ERD) và các cơ chế bổ trợ của dự án FinWise Backend.
---
## 1. Kiến trúc Phân tầng (Layered Architecture)
FinWise Backend được xây dựng trên kiến trúc phân tầng rõ ràng nhằm đảm bảo tính bảo mật, dễ bảo trì và mở rộng rộng rãi. Luồng xử lý một request chuẩn:
```text
HTTP Request
├── 1. Route Layer (Route, Auth, Role middlewares)
├── 2. Validation Layer (Zod DTO validation)
├── 3. Controller Layer (HTTP handling, response format)
├── 4. Service Layer (Business rules, transactions)
├── 5. Repository Layer (Prisma Database queries)
└── Database (PostgreSQL)
```
### Chi tiết các tầng:
* **Tầng Route (`*.route.ts`)**:
* Khai báo các đường dẫn HTTP (methods, paths).
* Áp dụng các middleware kiểm soát: `authMiddleware`, `requireRole`, `rateLimitMiddleware` để bảo vệ tài nguyên biên HTTP.
* *Không* chứa business logic hoặc Prisma query trực tiếp.
* **Tầng Validation (`*.validation.ts` / DTO)**:
* Sử dụng thư viện **Zod** để kiểm tra và định dạng dữ liệu đầu vào (`req.body`, `req.query`, `req.params`).
* Tự động normalize hoặc ép kiểu dữ liệu (coerce) khi cần (ví dụ: chuyển chuỗi ngày tháng sang đối tượng Date, chuyển string số tiền sang chuỗi số chính xác).
* Xuất ra kiểu dữ liệu DTO rõ ràng để các tầng tiếp theo sử dụng an toàn.
* **Tầng Controller (`*.controller.ts`)**:
* Tiếp nhận request đã được kiểm tra tính hợp lệ từ validator.
* Chuyển tiếp tham số vào tầng Service tương ứng.
* Định cấu hình HTTP response (status code, cookies, format JSON).
* Bắt lỗi và chuyển lỗi trực tiếp xuống `errorMiddleware` qua hàm `next(error)`.
* **Tầng Service (`*.service.ts`)**:
* Trọng tâm xử lý logic nghiệp vụ (business rules), phân quyền dữ liệu mức dòng (ownership authorization).
* Điều phối nhiều repositories hoặc các dịch vụ dùng chung (như MailService, CacheService, LockService).
* Quản lý transaction của cơ sở dữ liệu khi cần thực thi nhiều câu lệnh ghi dữ liệu đồng thời bằng `$transaction` của Prisma mức Serializable.
* Ném ra `AppError` kèm `ERROR_CODE` thích hợp đối với lỗi nghiệp vụ định trước.
* **Tầng Repository (`*.repository.ts`)**:
* Nơi duy nhất tương tác trực tiếp với cơ sở dữ liệu qua **Prisma Client**.
* Thực hiện lọc các trường nhạy cảm trước khi trả về (không làm rò rỉ `password`, `token` nhạy cảm).
* Mặc định loại trừ các bản ghi đã bị xóa mềm (`deletedAt IS NOT NULL`) đối với các thực thể liên quan đến người dùng.
---
## 2. Sơ đồ Quan hệ Thực thể (ERD)
Dưới đây là sơ đồ ERD của FinWise Backend mô tả 14 bảng thực thể và mối liên kết, được viết bằng cú pháp Mermaid:
```mermaid
erDiagram
Role ||--o{ User : "has_role"
User ||--o{ Wallet : "owns"
User ||--o{ Category : "owns"
User ||--o{ Transaction : "performs"
User ||--o{ Budget : "defines"
User ||--o{ SavingGoal : "targets"
User ||--o{ RefreshToken : "has"
User ||--o{ UserSocial : "links"
User ||--o{ VerificationToken : "owns"
User ||--o{ PasswordResetToken : "owns"
User ||--o{ UserDevice : "registers"
User ||--o{ Notification : "receives"
User ||--o{ Reminder : "creates"
User ||--|| NotificationSetting : "configures"
Wallet ||--o{ Transaction : "contains"
Category ||--o{ Transaction : "classifies"
Category ||--o{ Budget : "restricts"
Category ||--o{ Category : "hierarchy_parent"
SavingGoal ||--o{ SavingContribution : "has_progress"
Notification ||--o{ NotificationDelivery : "sends_via_channels"
User {
uuid id PK
string email UK
string password
string fullName
string avatarUrl
string phoneNumber UK
boolean isActive
datetime deletedAt
uuid deletedBy
uuid roleId FK
datetime createdAt
datetime updatedAt
}
Role {
uuid id PK
string name UK
datetime createdAt
datetime updatedAt
}
Wallet {
uuid id PK
uuid userId FK
string name
decimal balance
string currency
string icon
string color
string description
boolean isDefault
boolean isArchived
datetime createdAt
datetime updatedAt
}
Category {
uuid id PK
uuid userId FK
uuid parentId FK
string name
string type
string icon
string color
boolean isSystem
boolean isArchived
datetime createdAt
datetime updatedAt
}
Transaction {
uuid id PK
uuid userId FK
uuid walletId FK
uuid categoryId FK
decimal amount
string type
string description
string receiptUrl
string location
datetime date
datetime createdAt
datetime updatedAt
}
Budget {
uuid id PK
uuid userId FK
uuid categoryId FK
string name
decimal amount
string currency
string type
string period
datetime startDate
datetime endDate
decimal alertThreshold
boolean isArchived
datetime createdAt
datetime updatedAt
}
SavingGoal {
uuid id PK
uuid userId FK
string name
decimal targetAmount
string currency
datetime targetDate
string description
string icon
string color
string status
datetime completedAt
boolean isArchived
datetime createdAt
datetime updatedAt
}
SavingContribution {
uuid id PK
uuid savingGoalId FK
decimal amount
datetime contributedAt
string note
datetime createdAt
datetime updatedAt
}
Notification {
uuid id PK
uuid userId FK
string type
string priority
string title
string message
string_array channels
json data
string actionUrl
string sourceType
uuid sourceId
string dedupKey UK
datetime readAt
datetime expiresAt
datetime createdAt
datetime updatedAt
}
NotificationDelivery {
uuid id PK
uuid notificationId FK
string channel
string status
int attemptCount
datetime nextAttemptAt
datetime sentAt
string failureReason
string providerMessageId
datetime createdAt
datetime updatedAt
}
NotificationSetting {
uuid id PK
uuid userId FK "one-to-one"
string_array channels
boolean budgetAlertsEnabled
boolean savingGoalAlertsEnabled
boolean reminderAlertsEnabled
boolean unusualTxnAlertsEnabled
datetime createdAt
datetime updatedAt
}
Reminder {
uuid id PK
uuid userId FK
string type
string title
string message
datetime remindAt
string frequency
int repeatInterval
datetime endAt
datetime nextTriggerAt
datetime lastTriggeredAt
string actionUrl
boolean isActive
datetime createdAt
datetime updatedAt
}
RefreshToken {
uuid id PK
string token UK
uuid userId FK
string userAgent
string ipAddress
datetime expiresAt
datetime createdAt
}
UserSocial {
uuid id PK
uuid userId FK
string provider
string providerUserId UK
datetime createdAt
}
VerificationToken {
uuid id PK
string token UK
uuid userId FK
datetime expiresAt
datetime createdAt
}
PasswordResetToken {
uuid id PK
string token UK
uuid userId FK
datetime expiresAt
datetime createdAt
}
UserDevice {
uuid id PK
uuid userId FK
string deviceHash UK
string deviceName
string ipAddress
datetime createdAt
datetime lastLoginAt
}
```
---
## 3. Các Cơ chế Bổ trợ Hệ thống
### 3.1 Cơ chế Caching (`CacheService`)
* Sử dụng chiến lược kết hợp **Redis Cache** (môi trường sản xuất) và **In-memory cache** làm phương án dự phòng (fallback) tự dọn dẹp khi mất kết nối Redis hoặc môi trường dev không cài đặt Redis.
* **Áp dụng cho**: Danh mục hệ thống (`categories`), báo cáo tài chính (`reports`).
* **Chiến lược thu hồi cache (Eviction)**: Khi có bất kỳ hành động thêm/sửa/xóa ví, giao dịch, ngân sách hoặc mục tiêu tiết kiệm, hệ thống tự động quét và thu hồi (delete) cache báo cáo tài chính của người dùng tương ứng bằng cơ chế `clearPattern` để đảm bảo tính thời gian thực của số liệu.
### 3.2 Khóa Phân Phối (`LockService`)
* Ngăn ngừa việc chạy song song (race conditions) của worker nền khi kiểm tra và gửi thông báo/nhắc nhở định kỳ.
* Được cài đặt bằng khóa phân phối dựa trên **Redis Lock** (hoặc in-memory lock dự phòng) để đảm bảo chỉ có duy nhất một instance worker thực thi xử lý sự kiện tại một thời điểm trong môi trường multi-instance (Scale out).
### 3.3 Giới hạn tần suất yêu cầu (`Rate Limiting`)
* Sử dụng middleware `rateLimitMiddleware` dựa trên Redis kết hợp in-memory fallback giúp bảo vệ các endpoint chống tấn công Brute-force hoặc Spam yêu cầu.
* Tự giải phóng các token lưu trữ in-memory định kỳ để tránh rò rỉ bộ nhớ (memory leak).
### 3.4 Nhật ký hệ thống (`LoggerService`)
* Tự động ghi nhận log có cấu trúc.
* Production format: Định dạng **JSON** để dễ dàng thu thập và phân tích bởi ELK, Grafana Loki.
* Development format: Dạng text có màu trực quan.
* Tự động phát hiện và ẩn (mask) các thông tin nhạy cảm như `password`, `accessToken`, `refreshToken`, các API keys của AI Assistant trước khi ghi file log hoặc in ra console.
# Hướng dẫn Cài đặt và Triển khai Hệ thống FinWise Backend
Tài liệu này cung cấp hướng dẫn cài đặt từ môi trường phát triển (Development) cục bộ cho đến môi trường vận hành thực tế (Production) có container hoá.
---
## 1. Yêu cầu Hệ thống tối thiểu
* **Node.js**: Phiên bản 18.x trở lên.
* **Package Manager**: `pnpm` phiên bản 9.x trở lên.
* **Database**: PostgreSQL 15.x trở lên.
* **Cache & Session**: Redis 7.x trở lên.
* **Docker & Docker Compose**: Nếu triển khai bằng Container.
---
## 2. Hướng dẫn Triển khai cục bộ (Local Development)
### Bước 2.1: Tải mã nguồn và Cài đặt thư viện phụ thuộc
Sử dụng `pnpm` để cài đặt dependencies theo chuẩn cấu hình `package.json`:
```bash
pnpm install
```
### Bước 2.2: Cấu hình biến môi trường
1. Sao chép file cấu hình mẫu:
```bash
cp .env.example .env
```
2. Mở file `.env` và cập nhật thông số kết nối Database, Redis và JWT:
```env
NODE_ENV=development
PORT=7777
# Kết nối PostgreSQL
DATABASE_URL="postgresql://postgres:password@localhost:5432/datafinwise?schema=public"
# Cấu hình Token bảo mật
JWT_ACCESS_SECRET="your_strong_access_secret_key"
JWT_REFRESH_SECRET="your_strong_refresh_secret_key"
JWT_ACCESS_EXPIRES_IN=1d
JWT_REFRESH_EXPIRES_IN=7d
# Kết nối Cache Redis
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_ENABLED=true
# Dịch vụ AI (Trợ lý Tài chính)
AI_PROVIDER=gemini
GEMINI_API_KEYS="key1,key2" # Danh sách khóa xoay vòng ngăn lỗi quota limit
```
### Bước 2.3: Chuẩn bị Cơ sở dữ liệu (Prisma setup)
Chạy tuần tự các lệnh sau để kiểm tra cấu trúc schema, sinh kiểu (client types) và cập nhật cơ sở dữ liệu:
```bash
# Validate cấu trúc Prisma schema
pnpm exec prisma validate
# Sinh mã Prisma Client tương thích
pnpm run prisma:generate
# Triển khai các file migration và cập nhật cấu trúc database
pnpm run db:migrate:deploy
# Nạp dữ liệu mẫu demo phong phú (Ví, giao dịch, budget, saving goals mẫu)
pnpm run db:seed
```
### Bước 2.4: Khởi động Server phát triển
```bash
pnpm dev
```
Hệ thống sẽ chạy tại `http://localhost:7777`.
---
## 3. Triển khai Production sử dụng Docker (Khuyến nghị)
FinWise cung cấp file cấu hình Docker tối ưu bảo mật chạy dưới quyền **non-root user** để ngăn chặn leo thang đặc quyền bảo mật.
### Bước 3.1: Build Container Image
Dockerfile multi-stage giúp giảm tối đa dung lượng image và loại bỏ source code TypeScript thừa ở runtime:
```bash
docker build -t finwise-backend:latest .
```
### Bước 3.2: Chạy toàn bộ Stack dịch vụ bằng Docker Compose
File `docker-compose.yml` định nghĩa đầy đủ 3 services chính: `app` (Node.js API), `postgres` (Database), `redis` (Cache).
Đặc biệt, hệ thống sử dụng **Healthchecks** tích hợp để đảm bảo các dịch vụ hạ tầng sẵn sàng trước khi nạp ứng dụng.
Để khởi động toàn bộ hệ thống ở chế độ nền (detached mode):
```bash
docker compose up -d
```
Để theo dõi log hoạt động:
```bash
docker compose logs -f
```
Để dừng hệ thống và bảo lưu dữ liệu (Named volumes):
```bash
docker compose down
```
---
## 4. Triển khai bằng PM2 (Môi trường Linux VPS thông thường)
Nếu không sử dụng Docker trên máy chủ, sử dụng công cụ quản lý tiến trình **PM2** để chạy ngầm và tự động khởi động lại ứng dụng khi gặp sự cố crash.
### Bước 4.1: Cài đặt PM2 toàn cục
```bash
npm install -g pm2
```
### Bước 4.2: Build mã nguồn TypeScript thành Javascript
```bash
pnpm build
```
### Bước 4.3: Khởi động ứng dụng bằng PM2
Tạo file cấu hình `ecosystem.config.js` ở thư mục gốc:
```javascript
module.exports = {
apps: [
{
name: 'finwise-backend',
script: 'dist/server.js',
instances: 'max', // Chạy chế độ Cluster tận dụng tối đa số nhân CPU
exec_mode: 'cluster',
env: {
NODE_ENV: 'production',
},
},
],
};
```
Khởi động ứng dụng:
```bash
pm2 start ecosystem.config.js
```
Kiểm tra trạng thái các instances:
```bash
pm2 status
```
......@@ -9,6 +9,8 @@ export default tseslint.config(
'scripts/**/*',
'prisma/**/*',
'eslint.config.mjs',
'jest.config.ts',
'tests/**/*',
],
},
js.configs.recommended,
......
import type { Config } from 'jest';
const config: Config = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/src', '<rootDir>/tests'],
testMatch: ['**/*.spec.ts', '**/*.test.ts'],
transform: {
'^.+\\.tsx?$': 'ts-jest',
},
setupFilesAfterEnv: ['<rootDir>/tests/setup.ts'],
verbose: true,
forceExit: true,
clearMocks: true,
resetMocks: true,
restoreMocks: true,
};
export default config;
......@@ -17,7 +17,10 @@
"db:migrate:status": "node scripts/prisma-run.js migrate status",
"db:seed": "node scripts/prisma-run.js db seed -- --tsx prisma/seed.ts",
"lint": "eslint .",
"format": "prettier --write ."
"format": "prettier --write .",
"test": "jest --runInBand",
"test:watch": "jest --watch --runInBand",
"test:cov": "jest --coverage --runInBand"
},
"dependencies": {
"@prisma/client": "^5.22.0",
......@@ -41,15 +44,20 @@
"@types/cookie-parser": "^1.4.10",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/jest": "^30.0.0",
"@types/jsonwebtoken": "^9.0.7",
"@types/morgan": "^1.9.9",
"@types/multer": "^2.2.0",
"@types/node": "^22.10.2",
"@types/nodemailer": "^8.0.1",
"@types/supertest": "^7.2.1",
"@types/swagger-ui-express": "^4.1.8",
"eslint": "^9.17.0",
"jest": "^30.4.2",
"prettier": "^3.4.2",
"prisma": "^5.22.0",
"supertest": "^7.2.2",
"ts-jest": "^29.4.12",
"ts-node": "^10.9.2",
"ts-node-dev": "^2.0.0",
"tsx": "^4.19.2",
......
This source diff could not be displayed because it is too large. You can view the blob instead.
import { PrismaClient, TransactionType } from '@prisma/client';
import { PrismaClient, TransactionType, BudgetType, BudgetPeriod, SavingGoalStatus, NotificationType, NotificationPriority, NotificationChannel, NotificationSourceType, ReminderType, ReminderFrequency } from '@prisma/client';
import bcrypt from 'bcryptjs';
const prisma = new PrismaClient();
......@@ -60,18 +60,18 @@ async function main() {
},
});
await prisma.user.upsert({
const demoUser = await prisma.user.upsert({
where: { email: userEmail },
update: {
password: userPassword,
fullName: 'User',
fullName: 'Demo User',
roleId: roleMap['USER'],
isActive: true,
},
create: {
email: userEmail,
password: userPassword,
fullName: 'User',
fullName: 'Demo User',
roleId: roleMap['USER'],
isActive: true,
},
......@@ -206,9 +206,384 @@ async function main() {
});
}
await seedDemoData(demoUser.id);
console.log('Seed completed successfully');
}
async function seedDemoData(userId: string) {
console.log('Seeding demo data for user...');
// 1. Wallets
const walletCash = await prisma.wallet.upsert({
where: { userId_name: { userId, name: 'Ví tiền mặt' } },
update: {},
create: {
userId,
name: 'Ví tiền mặt',
balance: 4500000.00,
currency: 'VND',
icon: 'wallet',
color: '#10B981',
description: 'Tiền mặt chi tiêu hàng ngày',
isDefault: false,
}
});
const walletBank = await prisma.wallet.upsert({
where: { userId_name: { userId, name: 'Tài khoản Techcombank' } },
update: {},
create: {
userId,
name: 'Tài khoản Techcombank',
balance: 85300000.00,
currency: 'VND',
icon: 'credit-card',
color: '#EF4444',
description: 'Tài khoản nhận lương và chi tiêu chính',
isDefault: true,
}
});
const walletSavings = await prisma.wallet.upsert({
where: { userId_name: { userId, name: 'Sổ tiết kiệm Techcombank' } },
update: {},
create: {
userId,
name: 'Sổ tiết kiệm Techcombank',
balance: 100000000.00,
currency: 'VND',
icon: 'piggy-bank',
color: '#3B82F6',
description: 'Tài khoản tích luỹ dài hạn',
isDefault: false,
}
});
// Xoá giao dịch demo cũ của user này nếu có để seed lại sạch sẽ
await prisma.transaction.deleteMany({ where: { userId } });
// 2. Transactions
const transactionsData = [
// Tháng 6
{
userId,
walletId: walletBank.id,
categoryId: '10000000-0000-4000-8000-000000000001', // Salary
amount: 25000000,
type: TransactionType.INCOME,
description: 'Nhận lương tháng 6/2026',
date: new Date('2026-06-05T08:00:00Z'),
},
{
userId,
walletId: walletCash.id,
categoryId: '20000000-0000-4000-8000-000000000002', // Groceries
amount: 450000,
type: TransactionType.EXPENSE,
description: 'Mua thực phẩm tuần 1',
date: new Date('2026-06-06T17:30:00Z'),
location: 'WinMart Quận 1',
},
{
userId,
walletId: walletBank.id,
categoryId: '20000000-0000-4000-8000-000000000003', // Restaurants
amount: 1200000,
type: TransactionType.EXPENSE,
description: 'Liên hoan cùng gia đình',
date: new Date('2026-06-08T19:00:00Z'),
location: 'Hailidao Landmark 81',
},
{
userId,
walletId: walletBank.id,
categoryId: '20000000-0000-4000-8000-000000000006', // Bills & Utilities
amount: 1850000,
type: TransactionType.EXPENSE,
description: 'Thanh toán tiền điện nước tháng 5',
date: new Date('2026-06-10T10:00:00Z'),
},
{
userId,
walletId: walletBank.id,
categoryId: '20000000-0000-4000-8000-000000000005', // Shopping
amount: 3200000,
type: TransactionType.EXPENSE,
description: 'Mua giày chạy bộ Nike',
date: new Date('2026-06-15T16:00:00Z'),
location: 'Nike Store Quận 1',
},
{
userId,
walletId: walletCash.id,
categoryId: '20000000-0000-4000-8000-000000000008', // Entertainment
amount: 350000,
type: TransactionType.EXPENSE,
description: 'Xem phim và bắp nước',
date: new Date('2026-06-20T20:30:00Z'),
location: 'CGV Vincom',
},
// Tháng 7
{
userId,
walletId: walletBank.id,
categoryId: '10000000-0000-4000-8000-000000000001', // Salary
amount: 25000000,
type: TransactionType.INCOME,
description: 'Nhận lương tháng 7/2026',
date: new Date('2026-07-05T08:00:00Z'),
},
{
userId,
walletId: walletBank.id,
categoryId: '10000000-0000-4000-8000-000000000002', // Investment
amount: 1500000,
type: TransactionType.INCOME,
description: 'Nhận cổ tức chứng khoán',
date: new Date('2026-07-07T09:30:00Z'),
},
{
userId,
walletId: walletBank.id,
categoryId: '20000000-0000-4000-8000-000000000002', // Groceries
amount: 680000,
type: TransactionType.EXPENSE,
description: 'Mua đồ ăn WinMart',
date: new Date('2026-07-06T18:00:00Z'),
location: 'WinMart Quận 1',
},
{
userId,
walletId: walletBank.id,
categoryId: '20000000-0000-4000-8000-000000000006', // Bills & Utilities
amount: 1920000,
type: TransactionType.EXPENSE,
description: 'Thanh toán tiền điện nước tháng 6',
date: new Date('2026-07-10T10:00:00Z'),
},
{
userId,
walletId: walletBank.id,
categoryId: '20000000-0000-4000-8000-000000000007', // Health
amount: 1500000,
type: TransactionType.EXPENSE,
description: 'Khám răng định kỳ',
date: new Date('2026-07-12T09:00:00Z'),
location: 'Nha khoa Kim',
},
{
userId,
walletId: walletCash.id,
categoryId: '20000000-0000-4000-8000-000000000003', // Restaurants
amount: 450000,
type: TransactionType.EXPENSE,
description: 'Ăn tối cùng bạn bè',
date: new Date('2026-07-15T19:30:00Z'),
location: 'Phở Hùng',
},
{
userId,
walletId: walletBank.id,
categoryId: '20000000-0000-4000-8000-000000000005', // Shopping
amount: 5500000,
type: TransactionType.EXPENSE,
description: 'Mua bàn phím cơ và chuột không dây',
date: new Date('2026-07-18T14:00:00Z'),
location: 'Phong Vũ',
},
{
userId,
walletId: walletCash.id,
categoryId: '20000000-0000-4000-8000-000000000004', // Transport
amount: 120000,
type: TransactionType.EXPENSE,
description: 'Nạp tiền thẻ xe bus / Grab',
date: new Date('2026-07-22T08:00:00Z'),
},
// Tháng 8 (Tháng hiện tại)
{
userId,
walletId: walletBank.id,
categoryId: '10000000-0000-4000-8000-000000000001', // Salary
amount: 25000000,
type: TransactionType.INCOME,
description: 'Nhận lương tháng 8/2026',
date: new Date('2026-08-05T08:00:00Z'),
},
{
userId,
walletId: walletBank.id,
categoryId: '20000000-0000-4000-8000-000000000002', // Groceries
amount: 720000,
type: TransactionType.EXPENSE,
description: 'Mua thực phẩm trữ tủ lạnh WinMart',
date: new Date('2026-08-01T17:00:00Z'),
location: 'WinMart Landmark 81',
},
{
userId,
walletId: walletBank.id,
categoryId: '20000000-0000-4000-8000-000000000003', // Restaurants
amount: 2850000,
type: TransactionType.EXPENSE,
description: 'Tiệc liên hoan sinh nhật đồng nghiệp',
date: new Date('2026-08-03T19:30:00Z'),
location: 'Nhà hàng San Fu Lou',
},
{
userId,
walletId: walletCash.id,
categoryId: '20000000-0000-4000-8000-000000000004', // Transport
amount: 80000,
type: TransactionType.EXPENSE,
description: 'Xăng xe máy',
date: new Date('2026-08-04T08:30:00Z'),
},
{
userId,
walletId: walletBank.id,
categoryId: '20000000-0000-4000-8000-000000000008', // Entertainment
amount: 220000,
type: TransactionType.EXPENSE,
description: 'Uống cà phê và bánh ngọt Starbuck',
date: new Date('2026-08-05T15:00:00Z'),
location: 'Starbucks Quận 1',
},
];
for (const tx of transactionsData) {
await prisma.transaction.create({ data: tx });
}
// 3. Budgets
await prisma.budget.deleteMany({ where: { userId } });
// Ngân sách tổng tháng này
const budgetOverall = await prisma.budget.create({
data: {
userId,
name: 'Ngân sách Chi tiêu Tháng 8',
amount: 15000000,
currency: 'VND',
type: BudgetType.OVERALL,
period: BudgetPeriod.MONTHLY,
startDate: new Date('2026-08-01T00:00:00Z'),
endDate: new Date('2026-09-01T00:00:00Z'),
alertThreshold: 80.00,
}
});
// Ngân sách danh mục Food & Dining tháng này
const budgetFood = await prisma.budget.create({
data: {
userId,
categoryId: '20000000-0000-4000-8000-000000000001', // Food & Dining
name: 'Ngân sách Ăn uống Tháng 8',
amount: 4500000,
currency: 'VND',
type: BudgetType.CATEGORY,
period: BudgetPeriod.MONTHLY,
startDate: new Date('2026-08-01T00:00:00Z'),
endDate: new Date('2026-09-01T00:00:00Z'),
alertThreshold: 80.00,
}
});
// 4. Saving Goals & Contributions
await prisma.savingGoal.deleteMany({ where: { userId } });
// Mục tiêu: Mua Macbook Pro M4
const goalMacbook = await prisma.savingGoal.create({
data: {
userId,
name: 'Mua Macbook Pro M4',
targetAmount: 45000000,
currency: 'VND',
targetDate: new Date('2026-12-31T23:59:59Z'),
description: 'Phục vụ học tập và làm việc Freelance',
icon: 'laptop',
color: '#3B82F6',
status: SavingGoalStatus.ACTIVE,
}
});
// Đóng góp tiết kiệm
await prisma.savingContribution.createMany({
data: [
{
savingGoalId: goalMacbook.id,
amount: 10000000,
contributedAt: new Date('2026-06-10T10:00:00Z'),
note: 'Tiền thưởng dự án tháng 5',
},
{
savingGoalId: goalMacbook.id,
amount: 5000000,
contributedAt: new Date('2026-07-10T10:00:00Z'),
note: 'Tích luỹ lương tháng 6',
},
{
savingGoalId: goalMacbook.id,
amount: 5000000,
contributedAt: new Date('2026-08-05T10:00:00Z'),
note: 'Tích luỹ lương tháng 7',
},
]
});
// 5. Notifications
await prisma.notification.deleteMany({ where: { userId } });
await prisma.notification.createMany({
data: [
{
userId,
type: NotificationType.BUDGET_NEAR_LIMIT,
priority: NotificationPriority.NORMAL,
title: 'Cảnh báo ngân sách ăn uống',
message: 'Ngân sách Ăn uống Tháng 8 đã sử dụng 3,570,000 VND (79.33%), sắp chạm ngưỡng cảnh báo 80.00%.',
channels: [NotificationChannel.IN_APP],
dedupKey: `budget_near_limit_${budgetFood.id}_2026-08`,
sourceType: NotificationSourceType.BUDGET,
sourceId: budgetFood.id,
},
{
userId,
type: NotificationType.SAVING_GOAL_ACHIEVED,
priority: NotificationPriority.HIGH,
title: 'Cột mốc tiết kiệm mới!',
message: 'Chúc mừng bạn đã tích lũy được 20,000,000 VND (44.44%) cho mục tiêu "Mua Macbook Pro M4". Cố lên nhé!',
channels: [NotificationChannel.IN_APP],
dedupKey: `saving_goal_milestone_${goalMacbook.id}_20M`,
sourceType: NotificationSourceType.SAVING_GOAL,
sourceId: goalMacbook.id,
}
]
});
// 6. Reminders
await prisma.reminder.deleteMany({ where: { userId } });
await prisma.reminder.create({
data: {
userId,
type: ReminderType.RECURRING_PAYMENT,
title: 'Đóng tiền điện & nước',
message: 'Thanh toán tiền điện sinh hoạt và tiền nước qua Techcombank Mobile',
remindAt: new Date('2026-08-10T09:00:00Z'),
frequency: ReminderFrequency.MONTHLY,
repeatInterval: 1,
nextTriggerAt: new Date('2026-08-10T09:00:00Z'),
isActive: true,
}
});
console.log('Seeding demo data for user completed.');
}
main()
.catch((error) => {
console.error(error);
......
import request from 'supertest';
import app from '../src/app';
import { prisma } from '../src/database/prisma.client';
describe('Auth Integration Tests', () => {
const testUser = {
email: 'register-test@gmail.com',
password: 'Password@123456',
fullName: 'Test Register User',
};
afterAll(async () => {
// Dọn dẹp dữ liệu kiểm thử
await prisma.verificationToken.deleteMany({
where: {
user: {
email: testUser.email,
},
},
});
await prisma.refreshToken.deleteMany({
where: {
user: {
email: testUser.email,
},
},
});
await prisma.userDevice.deleteMany({
where: {
user: {
email: testUser.email,
},
},
});
await prisma.user.deleteMany({
where: {
email: testUser.email,
},
});
await prisma.$disconnect();
});
let verificationToken = '';
let accessTokenCookie = '';
let accessTokenHeader = '';
it('should register a new user successfully', async () => {
const res = await request(app)
.post('/api/v1/auth/register')
.send(testUser);
expect(res.status).toBe(201);
expect(res.body).toHaveProperty('success', true);
expect(res.body).toHaveProperty('message');
// Lấy token từ database để verify
const dbUser = await prisma.user.findUnique({
where: { email: testUser.email },
include: { verificationTokens: true },
});
expect(dbUser).toBeDefined();
expect(dbUser?.isActive).toBe(false);
expect(dbUser?.verificationTokens.length).toBe(1);
verificationToken = dbUser?.verificationTokens[0].token || '';
});
it('should not allow login with inactive account', async () => {
const res = await request(app)
.post('/api/v1/auth/login')
.send({
email: testUser.email,
password: testUser.password,
});
expect(res.status).toBe(403);
expect(res.body).toHaveProperty('success', false);
expect(res.body.code).toBe('USER_INACTIVE');
});
it('should verify email successfully', async () => {
const res = await request(app)
.get(`/api/v1/auth/verify-email?token=${verificationToken}`);
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('success', true);
expect(res.body).toHaveProperty('message');
const dbUser = await prisma.user.findUnique({
where: { email: testUser.email },
});
expect(dbUser?.isActive).toBe(true);
});
it('should login successfully and set cookies', async () => {
const res = await request(app)
.post('/api/v1/auth/login')
.send({
email: testUser.email,
password: testUser.password,
});
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('success', true);
expect(res.body.data).toHaveProperty('user');
expect(res.body.data.user).toHaveProperty('email', testUser.email);
// Lấy cookie
const cookies = (res.headers['set-cookie'] || []) as string[];
expect(cookies).toBeDefined();
const accessTokenMatch = cookies.find(c => c.startsWith('accessToken='));
expect(accessTokenMatch).toBeDefined();
// Trích xuất JWT token phục vụ test gọi API bằng header
const token = accessTokenMatch?.split(';')[0].split('=')[1];
expect(token).toBeDefined();
accessTokenHeader = token || '';
// Giữ nguyên mảng cookie để gọi API qua cookie
accessTokenCookie = cookies.join('; ');
});
it('should get current user profile using Bearer token header', async () => {
const res = await request(app)
.get('/api/v1/auth/me')
.set('Authorization', `Bearer ${accessTokenHeader}`);
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('success', true);
expect(res.body.data).toHaveProperty('email', testUser.email);
});
it('should get current user profile using Cookie', async () => {
const res = await request(app)
.get('/api/v1/auth/me')
.set('Cookie', accessTokenCookie);
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('success', true);
expect(res.body.data).toHaveProperty('email', testUser.email);
});
it('should logout successfully and clear cookies', async () => {
// Trích xuất refresh token từ DB để gửi kèm body nếu logout yêu cầu (hoặc qua cookie)
const dbUser = await prisma.user.findUnique({
where: { email: testUser.email },
include: { refreshTokens: true },
});
const refreshToken = dbUser?.refreshTokens[0]?.token || '';
const res = await request(app)
.post('/api/v1/auth/logout')
.set('Cookie', accessTokenCookie)
.send({ refreshToken });
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('success', true);
expect(res.body).toHaveProperty('message');
// Kiểm tra xem refresh token trong DB đã bị xoá chưa
const tokensCount = await prisma.refreshToken.count({
where: {
userId: dbUser?.id,
},
});
expect(tokensCount).toBe(0);
});
});
import request from 'supertest';
import app from '../src/app';
import { prisma } from '../src/database/prisma.client';
describe('GET /api/v1/health', () => {
it('should return 200 and status ok if database is up', async () => {
// Đảm bảo prisma hoạt động
jest.spyOn(prisma, '$queryRaw').mockResolvedValueOnce([1]);
const res = await request(app).get('/api/v1/health');
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('success', true);
expect(res.body).toHaveProperty('status', 'ok');
expect(res.body).toHaveProperty('database');
expect(res.body.database).toHaveProperty('status', 'up');
});
it('should return 503 and status error if database is down', async () => {
// Giả lập lỗi truy vấn database
jest.spyOn(prisma, '$queryRaw').mockRejectedValueOnce(new Error('Connection failed'));
const res = await request(app).get('/api/v1/health');
expect(res.status).toBe(503);
expect(res.body).toHaveProperty('success', false);
expect(res.body).toHaveProperty('status', 'error');
expect(res.body.database).toHaveProperty('status', 'down');
});
});
// Cấu hình môi trường chạy test
process.env.NODE_ENV = 'test';
process.env.PORT = '8889';
process.env.JWT_ACCESS_SECRET = 'test_access_secret_key_123456789_xyz';
process.env.JWT_REFRESH_SECRET = 'test_refresh_secret_key_123456789_xyz';
process.env.REDIS_ENABLED = 'false';
process.env.NOTIFICATION_WORKER_ENABLED = 'false';
// Mock MailService để tránh gửi mail thật và in log cảnh báo ra console
jest.mock('../src/common/services/mail.service', () => {
return {
MailService: jest.fn().mockImplementation(() => {
return {
sendVerificationEmail: jest.fn().mockResolvedValue(undefined),
sendPasswordResetEmail: jest.fn().mockResolvedValue(undefined),
sendNewDeviceAlertEmail: jest.fn().mockResolvedValue(undefined),
sendNotificationEmail: jest.fn().mockResolvedValue(undefined),
};
}),
};
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment