Commit 3b47a518 authored by ThinhNC's avatar ThinhNC

fix(core): complete project audit remediation for auth, financial integrity, and performance

parent 441be079
...@@ -20,7 +20,7 @@ services: ...@@ -20,7 +20,7 @@ services:
image: redis:7-alpine image: redis:7-alpine
container_name: finwise_redis container_name: finwise_redis
ports: ports:
- "${REDIS_PORT:-7379}:6379" - "${REDIS_PORT:-6379}:6379"
healthcheck: healthcheck:
test: ["CMD", "redis-cli", "ping"] test: ["CMD", "redis-cli", "ping"]
interval: 5s interval: 5s
......
...@@ -24,7 +24,14 @@ export default tseslint.config( ...@@ -24,7 +24,14 @@ export default tseslint.config(
}, },
rules: { rules: {
'@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }], '@typescript-eslint/no-unused-vars': [
'warn',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
},
],
'@typescript-eslint/no-namespace': 'off', '@typescript-eslint/no-namespace': 'off',
'no-console': 'off', 'no-console': 'off',
'no-undef': 'off', // TypeScript compiler already checks undefined variables 'no-undef': 'off', // TypeScript compiler already checks undefined variables
......
-- CreateIndex
CREATE INDEX "wallets_user_id_is_archived_idx" ON "wallets"("user_id", "is_archived");
-- CreateIndex
CREATE INDEX "notification_deliveries_status_updated_at_idx" ON "notification_deliveries"("status", "updated_at");
...@@ -153,6 +153,7 @@ model Wallet { ...@@ -153,6 +153,7 @@ model Wallet {
@@unique([userId, name]) @@unique([userId, name])
@@index([userId]) @@index([userId])
@@index([userId, isArchived])
@@map("wallets") @@map("wallets")
} }
...@@ -414,6 +415,7 @@ model NotificationDelivery { ...@@ -414,6 +415,7 @@ model NotificationDelivery {
@@unique([notificationId, channel]) @@unique([notificationId, channel])
@@index([status, nextAttemptAt]) @@index([status, nextAttemptAt])
@@index([status, updatedAt])
@@map("notification_deliveries") @@map("notification_deliveries")
} }
......
...@@ -14,7 +14,7 @@ export const envConfig = { ...@@ -14,7 +14,7 @@ export const envConfig = {
jwt: { jwt: {
accessSecret: process.env.JWT_ACCESS_SECRET || 'default_access_secret', accessSecret: process.env.JWT_ACCESS_SECRET || 'default_access_secret',
refreshSecret: process.env.JWT_REFRESH_SECRET || 'default_refresh_secret', refreshSecret: process.env.JWT_REFRESH_SECRET || 'default_refresh_secret',
accessExpiresIn: process.env.JWT_ACCESS_EXPIRES_IN || '1d', accessExpiresIn: process.env.JWT_ACCESS_EXPIRES_IN || '30m',
refreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '7d', refreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '7d',
}, },
trustProxy: (() => { trustProxy: (() => {
......
...@@ -3,8 +3,14 @@ import jwt from 'jsonwebtoken'; ...@@ -3,8 +3,14 @@ import jwt from 'jsonwebtoken';
import { jwtConfig } from '../config/jwt.config'; import { jwtConfig } from '../config/jwt.config';
import { AppError } from '../common/errors/app-error'; import { AppError } from '../common/errors/app-error';
import { ERROR_CODE } from '../common/errors/error-code'; import { ERROR_CODE } from '../common/errors/error-code';
import { cacheService } from '../common/services/cache.service';
import { prisma } from '../database/prisma.client';
export function authMiddleware(req: Request, res: Response, next: NextFunction): void { export async function authMiddleware(
req: Request,
res: Response,
next: NextFunction,
): Promise<void> {
let token = req.cookies?.accessToken; let token = req.cookies?.accessToken;
if (!token) { if (!token) {
...@@ -26,6 +32,32 @@ export function authMiddleware(req: Request, res: Response, next: NextFunction): ...@@ -26,6 +32,32 @@ export function authMiddleware(req: Request, res: Response, next: NextFunction):
role: string; role: string;
}; };
// Check user active status in cache first, fallback to DB
const cacheKey = `finwise:user:status:${payload.id}`;
let isUserActive = await cacheService.get<boolean>(cacheKey);
if (isUserActive === null) {
const user = await prisma.user.findUnique({
where: { id: payload.id },
select: { id: true, isActive: true, deletedAt: true },
});
isUserActive = Boolean(user && user.isActive && user.deletedAt === null);
// Cache user status for 60 seconds
await cacheService.set(cacheKey, isUserActive, 60);
}
if (!isUserActive) {
next(
new AppError(
'User account is inactive or has been deleted',
403,
ERROR_CODE.USER_INACTIVE,
),
);
return;
}
req.user = { req.user = {
id: payload.id, id: payload.id,
email: payload.email, email: payload.email,
...@@ -34,6 +66,11 @@ export function authMiddleware(req: Request, res: Response, next: NextFunction): ...@@ -34,6 +66,11 @@ export function authMiddleware(req: Request, res: Response, next: NextFunction):
next(); next();
} catch (error) { } catch (error) {
if (error instanceof AppError) {
next(error);
return;
}
if (error instanceof jwt.TokenExpiredError) { if (error instanceof jwt.TokenExpiredError) {
next(new AppError('Token expired', 401, ERROR_CODE.TOKEN_EXPIRED)); next(new AppError('Token expired', 401, ERROR_CODE.TOKEN_EXPIRED));
} else { } else {
......
...@@ -14,7 +14,7 @@ export function errorMiddleware( ...@@ -14,7 +14,7 @@ export function errorMiddleware(
error: Error, error: Error,
req: Request, req: Request,
res: Response, res: Response,
next: NextFunction, _next: NextFunction,
): void { ): void {
if (error instanceof AppError) { if (error instanceof AppError) {
res.status(error.statusCode).json({ res.status(error.statusCode).json({
......
...@@ -32,6 +32,8 @@ export class AuthController { ...@@ -32,6 +32,8 @@ export class AuthController {
success: true, success: true,
data: { data: {
user: result.user, user: result.user,
accessToken: result.accessToken,
refreshToken: result.refreshToken,
}, },
}); });
} catch (error) { } catch (error) {
...@@ -79,6 +81,10 @@ export class AuthController { ...@@ -79,6 +81,10 @@ export class AuthController {
res.json({ res.json({
success: true, success: true,
data: {
accessToken: result.accessToken,
refreshToken: result.refreshToken,
},
}); });
} catch (error) { } catch (error) {
next(error); next(error);
......
...@@ -2,7 +2,7 @@ import { Router } from 'express'; ...@@ -2,7 +2,7 @@ import { Router } from 'express';
import { AuthController } from './auth.controller'; import { AuthController } from './auth.controller';
import { authMiddleware } from '../../middlewares/auth.middleware'; import { authMiddleware } from '../../middlewares/auth.middleware';
import { validate } from '../../middlewares/validate.middleware'; import { validate } from '../../middlewares/validate.middleware';
import { loginSchema, refreshSchema, logoutSchema, registerSchema, verifyEmailSchema, updateProfileSchema, updatePasswordSchema, forgotPasswordSchema, resetPasswordSchema, resendVerificationSchema } from './auth.validation'; import { loginSchema, refreshSchema, logoutSchema, registerSchema, verifyEmailSchema, updateProfileSchema, updatePasswordSchema, forgotPasswordSchema, resetPasswordSchema, resendVerificationSchema, sessionParamsSchema, revokeOtherSessionsSchema } from './auth.validation';
const router = Router(); const router = Router();
const controller = new AuthController(); const controller = new AuthController();
...@@ -21,7 +21,7 @@ router.post('/resend-verification', validate(resendVerificationSchema), controll ...@@ -21,7 +21,7 @@ router.post('/resend-verification', validate(resendVerificationSchema), controll
// Session management // Session management
router.get('/sessions', authMiddleware, controller.getSessions); router.get('/sessions', authMiddleware, controller.getSessions);
router.delete('/sessions/:id', authMiddleware, controller.revokeSession); router.delete('/sessions/:id', authMiddleware, validate(sessionParamsSchema, 'params'), controller.revokeSession);
router.delete('/sessions', authMiddleware, controller.revokeOtherSessions); router.delete('/sessions', authMiddleware, validate(revokeOtherSessionsSchema), controller.revokeOtherSessions);
export default router; export default router;
...@@ -114,7 +114,7 @@ export class AuthService { ...@@ -114,7 +114,7 @@ export class AuthService {
let payload: any; let payload: any;
try { try {
payload = jwt.verify(token, jwtConfig.refreshSecret); payload = jwt.verify(token, jwtConfig.refreshSecret);
} catch (error) { } catch (_error) {
throw new AppError('Invalid refresh token', 401, ERROR_CODE.TOKEN_INVALID); throw new AppError('Invalid refresh token', 401, ERROR_CODE.TOKEN_INVALID);
} }
......
...@@ -78,3 +78,11 @@ export const resetPasswordSchema = z.object({ ...@@ -78,3 +78,11 @@ export const resetPasswordSchema = z.object({
export const resendVerificationSchema = z.object({ export const resendVerificationSchema = z.object({
email: z.string().min(1, 'Email is required').email('Invalid email format'), email: z.string().min(1, 'Email is required').email('Invalid email format'),
}); });
export const sessionParamsSchema = z.object({
id: z.string().uuid('Invalid session id'),
});
export const revokeOtherSessionsSchema = z.object({
refreshToken: z.string().optional(),
});
...@@ -172,6 +172,64 @@ export class BudgetRepository { ...@@ -172,6 +172,64 @@ export class BudgetRepository {
}; };
} }
async getBatchSpendingSummaries(
userId: string,
budgets: BudgetRecord[],
): Promise<Map<string, BudgetSpendingSummary>> {
const summaryMap = new Map<string, BudgetSpendingSummary>();
if (budgets.length === 0) return summaryMap;
let minDate = budgets[0].startDate;
let maxDate = budgets[0].endDate;
for (const b of budgets) {
if (b.startDate < minDate) minDate = b.startDate;
if (b.endDate > maxDate) maxDate = b.endDate;
}
const transactions = await prisma.transaction.findMany({
where: {
userId,
type: TransactionType.EXPENSE,
date: { gte: minDate, lte: maxDate },
},
select: {
categoryId: true,
amount: true,
date: true,
wallet: { select: { currency: true } },
},
});
for (const budget of budgets) {
let totalAmount = new Prisma.Decimal(0);
let count = 0;
let lastDate: Date | null = null;
for (const tx of transactions) {
if (
tx.wallet.currency === budget.currency &&
tx.date >= budget.startDate &&
tx.date <= budget.endDate &&
(!budget.categoryId || tx.categoryId === budget.categoryId)
) {
totalAmount = totalAmount.plus(tx.amount);
count++;
if (!lastDate || tx.date > lastDate) {
lastDate = tx.date;
}
}
}
summaryMap.set(budget.id, {
amount: totalAmount,
transactionCount: count,
lastTransactionAt: lastDate ? prismaDateToBusinessDate(lastDate) : null,
});
}
return summaryMap;
}
create(userId: string, data: PersistBudgetDto) { create(userId: string, data: PersistBudgetDto) {
return prisma.budget.create({ return prisma.budget.create({
data: { data: {
......
import { import {
BudgetPeriod, BudgetPeriod,
BudgetType, BudgetType,
Prisma,
TransactionType, TransactionType,
} from '@prisma/client'; } from '@prisma/client';
import { AppError } from '../../common/errors/app-error'; import { AppError } from '../../common/errors/app-error';
...@@ -33,9 +34,18 @@ export class BudgetService { ...@@ -33,9 +34,18 @@ export class BudgetService {
async findAll(userId: string, query: BudgetQueryDto) { async findAll(userId: string, query: BudgetQueryDto) {
const result = await this.repository.findAll(userId, query); const result = await this.repository.findAll(userId, query);
const data = await Promise.all( const summaries = await this.repository.getBatchSpendingSummaries(
result.data.map((budget) => this.toResponse(userId, budget)), userId,
result.data,
); );
const data = result.data.map((budget) => {
const spending = summaries.get(budget.id) ?? {
amount: new Prisma.Decimal(0),
transactionCount: 0,
lastTransactionAt: null,
};
return this.formatBudgetResponse(budget, spending);
});
return { data, meta: result.meta }; return { data, meta: result.meta };
} }
...@@ -288,6 +298,13 @@ export class BudgetService { ...@@ -288,6 +298,13 @@ export class BudgetService {
budget.currency, budget.currency,
); );
return this.formatBudgetResponse(budget, spending);
}
private formatBudgetResponse(
budget: BudgetRecord,
spending: BudgetSpendingSummary,
): BudgetResponseDto {
return { return {
...budget, ...budget,
startDate: prismaDateToBusinessDate(budget.startDate), startDate: prismaDateToBusinessDate(budget.startDate),
......
...@@ -253,23 +253,52 @@ export class NotificationRepository { ...@@ -253,23 +253,52 @@ export class NotificationRepository {
take: limit, take: limit,
}); });
return Promise.all(budgets.map(async (budget) => { if (budgets.length === 0) {
const spending = await prisma.transaction.aggregate({ return [];
where: { }
userId: budget.userId,
type: TransactionType.EXPENSE, const userIds = Array.from(new Set(budgets.map((b) => b.userId)));
...(budget.categoryId ? { categoryId: budget.categoryId } : {}), let minDate = budgets[0].startDate;
date: { gte: budget.startDate, lte: budget.endDate }, let maxDate = budgets[0].endDate;
wallet: { currency: budget.currency }, for (const b of budgets) {
}, if (b.startDate < minDate) minDate = b.startDate;
_sum: { amount: true }, if (b.endDate > maxDate) maxDate = b.endDate;
}); }
const transactions = await prisma.transaction.findMany({
where: {
userId: { in: userIds },
type: TransactionType.EXPENSE,
date: { gte: minDate, lte: maxDate },
},
select: {
userId: true,
categoryId: true,
amount: true,
date: true,
wallet: { select: { currency: true } },
},
});
return budgets.map((budget) => {
let spentAmount = new Prisma.Decimal(0);
for (const tx of transactions) {
if (
tx.userId === budget.userId &&
tx.wallet.currency === budget.currency &&
tx.date >= budget.startDate &&
tx.date <= budget.endDate &&
(!budget.categoryId || tx.categoryId === budget.categoryId)
) {
spentAmount = spentAmount.plus(tx.amount);
}
}
return { return {
...budget, ...budget,
spentAmount: spending._sum.amount ?? new Prisma.Decimal(0), spentAmount,
}; };
})); });
} }
async findSavingGoalCandidates( async findSavingGoalCandidates(
......
...@@ -3,7 +3,7 @@ import { UserController } from './user.controller'; ...@@ -3,7 +3,7 @@ import { UserController } from './user.controller';
import { authMiddleware } from '../../middlewares/auth.middleware'; import { authMiddleware } from '../../middlewares/auth.middleware';
import { requireRole } from '../../middlewares/role.middleware'; import { requireRole } from '../../middlewares/role.middleware';
import { validate } from '../../middlewares/validate.middleware'; import { validate } from '../../middlewares/validate.middleware';
import { createUserSchema, findAllUserSchema, updateUserSchema } from './user.validation'; import { createUserSchema, findAllUserSchema, updateUserSchema, userParamsSchema } from './user.validation';
import { ROLES } from '../../common/constants/role.constant'; import { ROLES } from '../../common/constants/role.constant';
const router = Router(); const router = Router();
...@@ -11,9 +11,9 @@ const controller = new UserController(); ...@@ -11,9 +11,9 @@ const controller = new UserController();
// GET /users?email=...&fullName=...&roleName=...&isActive=...&sortBy=...&order=...&page=...&limit=... // GET /users?email=...&fullName=...&roleName=...&isActive=...&sortBy=...&order=...&page=...&limit=...
router.get('/', authMiddleware, requireRole(ROLES.ADMIN), validate(findAllUserSchema, 'query'), controller.findAll); router.get('/', authMiddleware, requireRole(ROLES.ADMIN), validate(findAllUserSchema, 'query'), controller.findAll);
router.get('/:id', authMiddleware, requireRole(ROLES.ADMIN), controller.findById); router.get('/:id', authMiddleware, requireRole(ROLES.ADMIN), validate(userParamsSchema, 'params'), controller.findById);
router.post('/', authMiddleware, requireRole(ROLES.ADMIN), validate(createUserSchema), controller.create); router.post('/', authMiddleware, requireRole(ROLES.ADMIN), validate(createUserSchema), controller.create);
router.put('/:id', authMiddleware, requireRole(ROLES.ADMIN), validate(updateUserSchema), controller.update); router.put('/:id', authMiddleware, requireRole(ROLES.ADMIN), validate(userParamsSchema, 'params'), validate(updateUserSchema), controller.update);
router.delete('/:id', authMiddleware, requireRole(ROLES.ADMIN), controller.softDelete); router.delete('/:id', authMiddleware, requireRole(ROLES.ADMIN), validate(userParamsSchema, 'params'), controller.softDelete);
export default router; export default router;
...@@ -39,3 +39,7 @@ export const updateUserSchema = z.object({ ...@@ -39,3 +39,7 @@ export const updateUserSchema = z.object({
isActive: z.boolean().optional(), isActive: z.boolean().optional(),
roleId: z.string().uuid('Invalid roleId format').optional(), roleId: z.string().uuid('Invalid roleId format').optional(),
}); });
export const userParamsSchema = z.object({
id: z.string().uuid('Invalid user id'),
});
...@@ -21,7 +21,6 @@ export interface CreateWalletDto { ...@@ -21,7 +21,6 @@ export interface CreateWalletDto {
export interface UpdateWalletDto { export interface UpdateWalletDto {
name?: string; name?: string;
balance?: string;
currency?: string; currency?: string;
icon?: string | null; icon?: string | null;
color?: string | null; color?: string | null;
......
...@@ -52,7 +52,6 @@ export const createWalletSchema = z.object({ ...@@ -52,7 +52,6 @@ export const createWalletSchema = z.object({
export const updateWalletSchema = z export const updateWalletSchema = z
.object({ .object({
name: z.string().trim().min(1, 'Name cannot be empty').max(100).optional(), name: z.string().trim().min(1, 'Name cannot be empty').max(100).optional(),
balance: decimalSchema.optional(),
currency: currencySchema.optional(), currency: currencySchema.optional(),
icon: nullableIconSchema.optional(), icon: nullableIconSchema.optional(),
color: nullableColorSchema.optional(), color: nullableColorSchema.optional(),
......
...@@ -2,7 +2,7 @@ import { Request, Response, NextFunction } from 'express'; ...@@ -2,7 +2,7 @@ import { Request, Response, NextFunction } from 'express';
import { prisma } from '../database/prisma.client'; import { prisma } from '../database/prisma.client';
import { cacheService } from '../common/services/cache.service'; import { cacheService } from '../common/services/cache.service';
export async function healthCheck(req: Request, res: Response, next: NextFunction): Promise<void> { export async function healthCheck(req: Request, res: Response, _next: NextFunction): Promise<void> {
const timestamp = new Date().toISOString(); const timestamp = new Date().toISOString();
const uptime = process.uptime(); const uptime = process.uptime();
const memoryUsage = process.memoryUsage(); const memoryUsage = process.memoryUsage();
......
...@@ -108,6 +108,8 @@ describe('Auth Integration Tests', () => { ...@@ -108,6 +108,8 @@ describe('Auth Integration Tests', () => {
expect(res.body).toHaveProperty('success', true); expect(res.body).toHaveProperty('success', true);
expect(res.body.data).toHaveProperty('user'); expect(res.body.data).toHaveProperty('user');
expect(res.body.data.user).toHaveProperty('email', testUser.email); expect(res.body.data.user).toHaveProperty('email', testUser.email);
expect(res.body.data).toHaveProperty('accessToken');
expect(res.body.data).toHaveProperty('refreshToken');
// Lấy cookie // Lấy cookie
const cookies = (res.headers['set-cookie'] || []) as string[]; const cookies = (res.headers['set-cookie'] || []) as string[];
...@@ -170,4 +172,14 @@ describe('Auth Integration Tests', () => { ...@@ -170,4 +172,14 @@ describe('Auth Integration Tests', () => {
}); });
expect(tokensCount).toBe(0); expect(tokensCount).toBe(0);
}); });
it('should return 422 for invalid session UUID parameter', async () => {
const res = await request(app)
.delete('/api/v1/auth/sessions/invalid-session-uuid')
.set('Authorization', `Bearer ${accessTokenHeader}`);
expect(res.status).toBe(422);
expect(res.body.success).toBe(false);
expect(res.body.code).toBe('VALIDATION_ERROR');
});
}); });
import request from 'supertest';
import app from '../src/app';
import { prisma } from '../src/database/prisma.client';
import bcrypt from 'bcryptjs';
describe('Budget & Report Integration Tests', () => {
const testUser = {
email: 'budget-report-test@gmail.com',
password: 'Password@123456',
fullName: 'Budget Report Test User',
};
let userId = '';
let accessToken = '';
let walletId = '';
let categoryId = '';
beforeAll(async () => {
const defaultRole = await prisma.role.findUnique({ where: { name: 'USER' } });
const passwordHash = await bcrypt.hash(testUser.password, 10);
const user = await prisma.user.create({
data: {
email: testUser.email,
password: passwordHash,
fullName: testUser.fullName,
roleId: defaultRole!.id,
isActive: true,
},
});
userId = user.id;
// Create wallet
const wallet = await prisma.wallet.create({
data: {
userId,
name: 'Ví chi tiêu',
balance: 10000000.0,
currency: 'VND',
},
});
walletId = wallet.id;
// Create category
const category = await prisma.category.create({
data: {
userId,
name: 'Ăn uống',
type: 'EXPENSE',
},
});
categoryId = category.id;
// Login
const loginRes = await request(app)
.post('/api/v1/auth/login')
.send({ email: testUser.email, password: testUser.password });
accessToken = loginRes.body.data.accessToken;
});
afterAll(async () => {
await prisma.transaction.deleteMany({ where: { userId } });
await prisma.budget.deleteMany({ where: { userId } });
await prisma.category.deleteMany({ where: { userId } });
await prisma.wallet.deleteMany({ where: { userId } });
await prisma.refreshToken.deleteMany({ where: { userId } });
await prisma.userDevice.deleteMany({ where: { userId } });
await prisma.user.deleteMany({ where: { id: userId } });
await prisma.$disconnect();
});
it('should create budgets and query budget list with batch spending summaries without N+1 error', async () => {
// Create 3 budgets
const budget1 = await request(app)
.post('/api/v1/budgets')
.set('Authorization', `Bearer ${accessToken}`)
.send({
name: 'Ngân sách ăn uống tháng 8',
amount: '3000000.00',
currency: 'VND',
type: 'CATEGORY',
period: 'CUSTOM',
categoryId,
startDate: '2026-08-01',
endDate: '2026-08-31',
alertThreshold: '80.00',
});
expect(budget1.status).toBe(201);
// Create an expense transaction within the budget
await request(app)
.post('/api/v1/transactions')
.set('Authorization', `Bearer ${accessToken}`)
.send({
walletId,
categoryId,
type: 'EXPENSE',
amount: '500000.00',
date: '2026-08-15',
note: 'Ăn trưa',
});
// Query budgets list
const res = await request(app)
.get('/api/v1/budgets')
.set('Authorization', `Bearer ${accessToken}`);
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.data.length).toBeGreaterThanOrEqual(1);
const targetBudget = res.body.data.find((b: any) => b.id === budget1.body.data.id);
expect(targetBudget).toBeDefined();
expect(targetBudget.usage.spentAmount).toBe('500000.00');
expect(targetBudget.usage.transactionCount).toBe(1);
});
it('should include boundary end-date transactions in custom reports', async () => {
// Add transaction on August 31 (the boundary end date)
await request(app)
.post('/api/v1/transactions')
.set('Authorization', `Bearer ${accessToken}`)
.send({
walletId,
categoryId,
type: 'EXPENSE',
amount: '200000.00',
date: '2026-08-31',
note: 'Cà phê cuối tháng',
});
// Query custom report from 2026-08-01 to 2026-08-31
const res = await request(app)
.get('/api/v1/reports/overview')
.set('Authorization', `Bearer ${accessToken}`)
.query({
period: 'CUSTOM',
dateFrom: '2026-07-31T17:00:00.000Z', // 2026-08-01 00:00 VN
dateTo: '2026-08-31T17:00:00.000Z', // 2026-09-01 00:00 VN (inclusive of Aug 31)
walletId,
});
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
const vndMetric = res.body.data.metricsByCurrency.find((m: any) => m.currency === 'VND');
expect(vndMetric).toBeDefined();
// 500k from Aug 15 + 200k from Aug 31 = 700k
expect(vndMetric.expense).toBe('700000.00');
expect(vndMetric.transactionCount).toBe(2);
});
});
import request from 'supertest';
import app from '../src/app';
import { prisma } from '../src/database/prisma.client';
import bcrypt from 'bcryptjs';
describe('Wallet Integration Tests', () => {
const testUser = {
email: 'wallet-test@gmail.com',
password: 'Password@123456',
fullName: 'Wallet Test User',
};
let userId = '';
let accessToken = '';
let walletId = '';
beforeAll(async () => {
// Setup test user
const defaultRole = await prisma.role.findUnique({ where: { name: 'USER' } });
const passwordHash = await bcrypt.hash(testUser.password, 10);
const user = await prisma.user.create({
data: {
email: testUser.email,
password: passwordHash,
fullName: testUser.fullName,
roleId: defaultRole!.id,
isActive: true,
},
});
userId = user.id;
// Login to get token
const loginRes = await request(app)
.post('/api/v1/auth/login')
.send({ email: testUser.email, password: testUser.password });
accessToken = loginRes.body.data.accessToken;
});
afterAll(async () => {
await prisma.transaction.deleteMany({ where: { userId } });
await prisma.transfer.deleteMany({ where: { userId } });
await prisma.wallet.deleteMany({ where: { userId } });
await prisma.refreshToken.deleteMany({ where: { userId } });
await prisma.userDevice.deleteMany({ where: { userId } });
await prisma.user.deleteMany({ where: { id: userId } });
await prisma.$disconnect();
});
it('should create a new wallet with initial balance', async () => {
const res = await request(app)
.post('/api/v1/wallets')
.set('Authorization', `Bearer ${accessToken}`)
.send({
name: 'Ví tiền mặt',
balance: '500000.00',
currency: 'VND',
icon: 'cash',
color: '#10B981',
});
expect(res.status).toBe(201);
expect(res.body.success).toBe(true);
expect(res.body.data.name).toBe('Ví tiền mặt');
expect(res.body.data.balance).toBe('500000.00');
expect(res.body.data.isDefault).toBe(true);
walletId = res.body.data.id;
});
it('should update wallet name and not allow balance tampering via update', async () => {
const res = await request(app)
.put(`/api/v1/wallets/${walletId}`)
.set('Authorization', `Bearer ${accessToken}`)
.send({
name: 'Ví tiền mặt mới',
balance: '999999999.00', // Attempt to tamper balance
});
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.data.name).toBe('Ví tiền mặt mới');
// Balance must remain unchanged
expect(res.body.data.balance).toBe('500000.00');
// Verify directly in DB
const dbWallet = await prisma.wallet.findUnique({ where: { id: walletId } });
expect(dbWallet?.balance.toFixed(2)).toBe('500000.00');
});
});
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