Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
F
finwise-miniapp-be
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
ThinhNC
finwise-miniapp-be
Commits
3b47a518
Commit
3b47a518
authored
Aug 19, 2026
by
ThinhNC
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
fix(core): complete project audit remediation for auth, financial integrity, and performance
parent
441be079
Changes
22
Hide whitespace changes
Inline
Side-by-side
Showing
22 changed files
with
455 additions
and
31 deletions
+455
-31
docker-compose.yml
docker-compose.yml
+1
-1
eslint.config.mjs
eslint.config.mjs
+8
-1
migration.sql
...ations/20260819223000_add_composite_indexes/migration.sql
+5
-0
schema.prisma
prisma/schema.prisma
+2
-0
env.config.ts
src/config/env.config.ts
+1
-1
auth.middleware.ts
src/middlewares/auth.middleware.ts
+38
-1
error.middleware.ts
src/middlewares/error.middleware.ts
+1
-1
auth.controller.ts
src/modules/auth/auth.controller.ts
+6
-0
auth.route.ts
src/modules/auth/auth.route.ts
+3
-3
auth.service.ts
src/modules/auth/auth.service.ts
+1
-1
auth.validation.ts
src/modules/auth/auth.validation.ts
+8
-0
budget.repository.ts
src/modules/budgets/budget.repository.ts
+58
-0
budget.service.ts
src/modules/budgets/budget.service.ts
+19
-2
notification.repository.ts
src/modules/notifications/notification.repository.ts
+42
-13
user.route.ts
src/modules/users/user.route.ts
+4
-4
user.validation.ts
src/modules/users/user.validation.ts
+4
-0
wallet.dto.ts
src/modules/wallets/wallet.dto.ts
+0
-1
wallet.validation.ts
src/modules/wallets/wallet.validation.ts
+0
-1
health.controller.ts
src/routes/health.controller.ts
+1
-1
auth.test.ts
tests/auth.test.ts
+12
-0
budget-report.test.ts
tests/budget-report.test.ts
+152
-0
wallet.test.ts
tests/wallet.test.ts
+89
-0
No files found.
docker-compose.yml
View file @
3b47a518
...
...
@@ -20,7 +20,7 @@ services:
image
:
redis:7-alpine
container_name
:
finwise_redis
ports
:
-
"
${REDIS_PORT:-
7
379}:6379"
-
"
${REDIS_PORT:-
6
379}:6379"
healthcheck
:
test
:
[
"
CMD"
,
"
redis-cli"
,
"
ping"
]
interval
:
5s
...
...
eslint.config.mjs
View file @
3b47a518
...
...
@@ -24,7 +24,14 @@ export default tseslint.config(
},
rules
:
{
'@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'
,
'no-console'
:
'off'
,
'no-undef'
:
'off'
,
// TypeScript compiler already checks undefined variables
...
...
prisma/migrations/20260819223000_add_composite_indexes/migration.sql
0 → 100644
View file @
3b47a518
-- 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"
);
prisma/schema.prisma
View file @
3b47a518
...
...
@@ -153,6 +153,7 @@ model Wallet {
@@
unique
([
userId
,
name
])
@@
index
([
userId
])
@@
index
([
userId
,
isArchived
])
@@
map
(
"wallets"
)
}
...
...
@@ -414,6 +415,7 @@ model NotificationDelivery {
@@
unique
([
notificationId
,
channel
])
@@
index
([
status
,
nextAttemptAt
])
@@
index
([
status
,
updatedAt
])
@@
map
(
"notification_deliveries"
)
}
...
...
src/config/env.config.ts
View file @
3b47a518
...
...
@@ -14,7 +14,7 @@ export const envConfig = {
jwt
:
{
accessSecret
:
process
.
env
.
JWT_ACCESS_SECRET
||
'default_access_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'
,
},
trustProxy
:
(()
=>
{
...
...
src/middlewares/auth.middleware.ts
View file @
3b47a518
...
...
@@ -3,8 +3,14 @@ import jwt from 'jsonwebtoken';
import
{
jwtConfig
}
from
'../config/jwt.config'
;
import
{
AppError
}
from
'../common/errors/app-error'
;
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
;
if
(
!
token
)
{
...
...
@@ -26,6 +32,32 @@ export function authMiddleware(req: Request, res: Response, next: NextFunction):
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
=
{
id
:
payload
.
id
,
email
:
payload
.
email
,
...
...
@@ -34,6 +66,11 @@ export function authMiddleware(req: Request, res: Response, next: NextFunction):
next
();
}
catch
(
error
)
{
if
(
error
instanceof
AppError
)
{
next
(
error
);
return
;
}
if
(
error
instanceof
jwt
.
TokenExpiredError
)
{
next
(
new
AppError
(
'Token expired'
,
401
,
ERROR_CODE
.
TOKEN_EXPIRED
));
}
else
{
...
...
src/middlewares/error.middleware.ts
View file @
3b47a518
...
...
@@ -14,7 +14,7 @@ export function errorMiddleware(
error
:
Error
,
req
:
Request
,
res
:
Response
,
next
:
NextFunction
,
_
next
:
NextFunction
,
):
void
{
if
(
error
instanceof
AppError
)
{
res
.
status
(
error
.
statusCode
).
json
({
...
...
src/modules/auth/auth.controller.ts
View file @
3b47a518
...
...
@@ -32,6 +32,8 @@ export class AuthController {
success
:
true
,
data
:
{
user
:
result
.
user
,
accessToken
:
result
.
accessToken
,
refreshToken
:
result
.
refreshToken
,
},
});
}
catch
(
error
)
{
...
...
@@ -79,6 +81,10 @@ export class AuthController {
res
.
json
({
success
:
true
,
data
:
{
accessToken
:
result
.
accessToken
,
refreshToken
:
result
.
refreshToken
,
},
});
}
catch
(
error
)
{
next
(
error
);
...
...
src/modules/auth/auth.route.ts
View file @
3b47a518
...
...
@@ -2,7 +2,7 @@ import { Router } from 'express';
import
{
AuthController
}
from
'./auth.controller'
;
import
{
authMiddleware
}
from
'../../middlewares/auth.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
controller
=
new
AuthController
();
...
...
@@ -21,7 +21,7 @@ router.post('/resend-verification', validate(resendVerificationSchema), controll
// Session management
router
.
get
(
'/sessions'
,
authMiddleware
,
controller
.
getSessions
);
router
.
delete
(
'/sessions/:id'
,
authMiddleware
,
controller
.
revokeSession
);
router
.
delete
(
'/sessions'
,
authMiddleware
,
controller
.
revokeOtherSessions
);
router
.
delete
(
'/sessions/:id'
,
authMiddleware
,
validate
(
sessionParamsSchema
,
'params'
),
controller
.
revokeSession
);
router
.
delete
(
'/sessions'
,
authMiddleware
,
validate
(
revokeOtherSessionsSchema
),
controller
.
revokeOtherSessions
);
export
default
router
;
src/modules/auth/auth.service.ts
View file @
3b47a518
...
...
@@ -114,7 +114,7 @@ export class AuthService {
let
payload
:
any
;
try
{
payload
=
jwt
.
verify
(
token
,
jwtConfig
.
refreshSecret
);
}
catch
(
error
)
{
}
catch
(
_
error
)
{
throw
new
AppError
(
'Invalid refresh token'
,
401
,
ERROR_CODE
.
TOKEN_INVALID
);
}
...
...
src/modules/auth/auth.validation.ts
View file @
3b47a518
...
...
@@ -78,3 +78,11 @@ export const resetPasswordSchema = z.object({
export
const
resendVerificationSchema
=
z
.
object
({
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
(),
});
src/modules/budgets/budget.repository.ts
View file @
3b47a518
...
...
@@ -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
)
{
return
prisma
.
budget
.
create
({
data
:
{
...
...
src/modules/budgets/budget.service.ts
View file @
3b47a518
import
{
BudgetPeriod
,
BudgetType
,
Prisma
,
TransactionType
,
}
from
'@prisma/client'
;
import
{
AppError
}
from
'../../common/errors/app-error'
;
...
...
@@ -33,9 +34,18 @@ export class BudgetService {
async
findAll
(
userId
:
string
,
query
:
BudgetQueryDto
)
{
const
result
=
await
this
.
repository
.
findAll
(
userId
,
query
);
const
data
=
await
Promise
.
all
(
result
.
data
.
map
((
budget
)
=>
this
.
toResponse
(
userId
,
budget
)),
const
summaries
=
await
this
.
repository
.
getBatchSpendingSummaries
(
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
};
}
...
...
@@ -288,6 +298,13 @@ export class BudgetService {
budget
.
currency
,
);
return
this
.
formatBudgetResponse
(
budget
,
spending
);
}
private
formatBudgetResponse
(
budget
:
BudgetRecord
,
spending
:
BudgetSpendingSummary
,
):
BudgetResponseDto
{
return
{
...
budget
,
startDate
:
prismaDateToBusinessDate
(
budget
.
startDate
),
...
...
src/modules/notifications/notification.repository.ts
View file @
3b47a518
...
...
@@ -253,23 +253,52 @@ export class NotificationRepository {
take
:
limit
,
});
return
Promise
.
all
(
budgets
.
map
(
async
(
budget
)
=>
{
const
spending
=
await
prisma
.
transaction
.
aggregate
({
where
:
{
userId
:
budget
.
userId
,
type
:
TransactionType
.
EXPENSE
,
...(
budget
.
categoryId
?
{
categoryId
:
budget
.
categoryId
}
:
{}),
date
:
{
gte
:
budget
.
startDate
,
lte
:
budget
.
endDate
},
wallet
:
{
currency
:
budget
.
currency
},
},
_sum
:
{
amount
:
true
},
});
if
(
budgets
.
length
===
0
)
{
return
[];
}
const
userIds
=
Array
.
from
(
new
Set
(
budgets
.
map
((
b
)
=>
b
.
userId
)));
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
:
{
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
{
...
budget
,
spentAmount
:
spending
.
_sum
.
amount
??
new
Prisma
.
Decimal
(
0
)
,
spentAmount
,
};
})
)
;
});
}
async
findSavingGoalCandidates
(
...
...
src/modules/users/user.route.ts
View file @
3b47a518
...
...
@@ -3,7 +3,7 @@ import { UserController } from './user.controller';
import
{
authMiddleware
}
from
'../../middlewares/auth.middleware'
;
import
{
requireRole
}
from
'../../middlewares/role.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'
;
const
router
=
Router
();
...
...
@@ -11,9 +11,9 @@ const controller = new UserController();
// GET /users?email=...&fullName=...&roleName=...&isActive=...&sortBy=...&order=...&page=...&limit=...
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
.
put
(
'/:id'
,
authMiddleware
,
requireRole
(
ROLES
.
ADMIN
),
validate
(
updateUserSchema
),
controller
.
update
);
router
.
delete
(
'/:id'
,
authMiddleware
,
requireRole
(
ROLES
.
ADMIN
),
controller
.
softDelete
);
router
.
put
(
'/:id'
,
authMiddleware
,
requireRole
(
ROLES
.
ADMIN
),
validate
(
u
serParamsSchema
,
'params'
),
validate
(
u
pdateUserSchema
),
controller
.
update
);
router
.
delete
(
'/:id'
,
authMiddleware
,
requireRole
(
ROLES
.
ADMIN
),
validate
(
userParamsSchema
,
'params'
),
controller
.
softDelete
);
export
default
router
;
src/modules/users/user.validation.ts
View file @
3b47a518
...
...
@@ -39,3 +39,7 @@ export const updateUserSchema = z.object({
isActive
:
z
.
boolean
().
optional
(),
roleId
:
z
.
string
().
uuid
(
'Invalid roleId format'
).
optional
(),
});
export
const
userParamsSchema
=
z
.
object
({
id
:
z
.
string
().
uuid
(
'Invalid user id'
),
});
src/modules/wallets/wallet.dto.ts
View file @
3b47a518
...
...
@@ -21,7 +21,6 @@ export interface CreateWalletDto {
export
interface
UpdateWalletDto
{
name
?:
string
;
balance
?:
string
;
currency
?:
string
;
icon
?:
string
|
null
;
color
?:
string
|
null
;
...
...
src/modules/wallets/wallet.validation.ts
View file @
3b47a518
...
...
@@ -52,7 +52,6 @@ export const createWalletSchema = z.object({
export
const
updateWalletSchema
=
z
.
object
({
name
:
z
.
string
().
trim
().
min
(
1
,
'Name cannot be empty'
).
max
(
100
).
optional
(),
balance
:
decimalSchema
.
optional
(),
currency
:
currencySchema
.
optional
(),
icon
:
nullableIconSchema
.
optional
(),
color
:
nullableColorSchema
.
optional
(),
...
...
src/routes/health.controller.ts
View file @
3b47a518
...
...
@@ -2,7 +2,7 @@ import { Request, Response, NextFunction } from 'express';
import
{
prisma
}
from
'../database/prisma.client'
;
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
uptime
=
process
.
uptime
();
const
memoryUsage
=
process
.
memoryUsage
();
...
...
tests/auth.test.ts
View file @
3b47a518
...
...
@@ -108,6 +108,8 @@ describe('Auth Integration Tests', () => {
expect
(
res
.
body
).
toHaveProperty
(
'success'
,
true
);
expect
(
res
.
body
.
data
).
toHaveProperty
(
'user'
);
expect
(
res
.
body
.
data
.
user
).
toHaveProperty
(
'email'
,
testUser
.
email
);
expect
(
res
.
body
.
data
).
toHaveProperty
(
'accessToken'
);
expect
(
res
.
body
.
data
).
toHaveProperty
(
'refreshToken'
);
// Lấy cookie
const
cookies
=
(
res
.
headers
[
'set-cookie'
]
||
[])
as
string
[];
...
...
@@ -170,4 +172,14 @@ describe('Auth Integration Tests', () => {
});
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'
);
});
});
tests/budget-report.test.ts
0 → 100644
View file @
3b47a518
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
);
});
});
tests/wallet.test.ts
0 → 100644
View file @
3b47a518
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'
);
});
});
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment