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
97360cba
Commit
97360cba
authored
Aug 29, 2026
by
ThinhNC
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat(auth): integrate Zalo login via phone number and HMAC-SHA256 verification
parent
c9394e72
Changes
8
Show whitespace changes
Inline
Side-by-side
Showing
8 changed files
with
369 additions
and
3 deletions
+369
-3
.env.example
.env.example
+4
-0
auth.controller.ts
src/modules/auth/auth.controller.ts
+35
-1
auth.dto.ts
src/modules/auth/auth.dto.ts
+18
-0
auth.repository.ts
src/modules/auth/auth.repository.ts
+8
-0
auth.route.ts
src/modules/auth/auth.route.ts
+2
-1
auth.service.ts
src/modules/auth/auth.service.ts
+143
-1
auth.validation.ts
src/modules/auth/auth.validation.ts
+8
-0
zalo-auth.test.ts
tests/zalo-auth.test.ts
+151
-0
No files found.
.env.example
View file @
97360cba
...
@@ -61,3 +61,7 @@ AI_MAX_OUTPUT_TOKENS=2048
...
@@ -61,3 +61,7 @@ AI_MAX_OUTPUT_TOKENS=2048
AI_MAX_CONTEXT_TRANSACTIONS=200
AI_MAX_CONTEXT_TRANSACTIONS=200
AI_RATE_LIMIT_MAX_REQUESTS=20
AI_RATE_LIMIT_MAX_REQUESTS=20
AI_RATE_LIMIT_WINDOW_MS=900000
AI_RATE_LIMIT_WINDOW_MS=900000
# Zalo Mini App credentials (from developers.zalo.me)
ZALO_APP_ID=your_zalo_app_id
ZALO_APP_SECRET=your_zalo_app_secret_key
src/modules/auth/auth.controller.ts
View file @
97360cba
import
{
Request
,
Response
,
NextFunction
}
from
'express'
;
import
{
Request
,
Response
,
NextFunction
}
from
'express'
;
import
{
AuthService
}
from
'./auth.service'
;
import
{
AuthService
}
from
'./auth.service'
;
import
{
LoginDto
,
RegisterDto
,
UpdateProfileDto
,
UpdateAvatarDto
,
UpdatePasswordDto
,
ForgotPasswordDto
,
ResetPasswordDto
,
ResendVerificationDto
,
SessionQueryDto
}
from
'./auth.dto'
;
import
{
LoginDto
,
ZaloLoginDto
,
RegisterDto
,
UpdateProfileDto
,
UpdateAvatarDto
,
UpdatePasswordDto
,
ForgotPasswordDto
,
ResetPasswordDto
,
ResendVerificationDto
,
SessionQueryDto
}
from
'./auth.dto'
;
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'
;
...
@@ -289,4 +289,38 @@ export class AuthController {
...
@@ -289,4 +289,38 @@ export class AuthController {
next
(
error
);
next
(
error
);
}
}
};
};
loginWithZalo
=
async
(
req
:
Request
,
res
:
Response
,
next
:
NextFunction
)
=>
{
try
{
const
body
=
req
.
body
as
ZaloLoginDto
;
const
userAgent
=
req
.
headers
[
'user-agent'
];
const
ipAddress
=
req
.
ip
;
const
result
=
await
this
.
service
.
loginWithZalo
(
body
,
{
userAgent
,
ipAddress
});
res
.
cookie
(
'accessToken'
,
result
.
accessToken
,
{
httpOnly
:
true
,
secure
:
process
.
env
.
NODE_ENV
===
'production'
,
sameSite
:
'strict'
,
maxAge
:
24
*
60
*
60
*
1000
,
});
res
.
cookie
(
'refreshToken'
,
result
.
refreshToken
,
{
httpOnly
:
true
,
secure
:
process
.
env
.
NODE_ENV
===
'production'
,
sameSite
:
'strict'
,
maxAge
:
7
*
24
*
60
*
60
*
1000
,
});
res
.
json
({
success
:
true
,
data
:
{
user
:
result
.
user
,
accessToken
:
result
.
accessToken
,
refreshToken
:
result
.
refreshToken
,
},
});
}
catch
(
error
)
{
next
(
error
);
}
};
}
}
src/modules/auth/auth.dto.ts
View file @
97360cba
...
@@ -3,6 +3,24 @@ export interface LoginDto {
...
@@ -3,6 +3,24 @@ export interface LoginDto {
password
:
string
;
password
:
string
;
}
}
export
interface
ZaloLoginDto
{
accessToken
:
string
;
// Zalo access_token từ getAccessToken() SDK
phoneNumber
:
string
;
// SĐT thực từ getPhoneNumber() SDK
}
export
interface
ZaloProfileResponse
{
id
:
string
;
name
?:
string
;
picture
?:
{
data
?:
{
url
?:
string
;
};
};
error
?:
number
;
message
?:
string
;
is_sensitive
?:
boolean
;
}
export
interface
RegisterDto
{
export
interface
RegisterDto
{
email
:
string
;
email
:
string
;
password
:
string
;
password
:
string
;
...
...
src/modules/auth/auth.repository.ts
View file @
97360cba
...
@@ -75,6 +75,7 @@ export class AuthRepository {
...
@@ -75,6 +75,7 @@ export class AuthRepository {
async
createSocialUser
(
data
:
{
async
createSocialUser
(
data
:
{
fullName
?:
string
;
fullName
?:
string
;
avatarUrl
?:
string
;
avatarUrl
?:
string
;
phoneNumber
?:
string
;
roleId
:
string
;
roleId
:
string
;
provider
:
string
;
provider
:
string
;
providerUserId
:
string
;
providerUserId
:
string
;
...
@@ -83,6 +84,7 @@ export class AuthRepository {
...
@@ -83,6 +84,7 @@ export class AuthRepository {
data
:
{
data
:
{
fullName
:
data
.
fullName
,
fullName
:
data
.
fullName
,
avatarUrl
:
data
.
avatarUrl
,
avatarUrl
:
data
.
avatarUrl
,
phoneNumber
:
data
.
phoneNumber
,
roleId
:
data
.
roleId
,
roleId
:
data
.
roleId
,
isActive
:
true
,
// Social users are active immediately
isActive
:
true
,
// Social users are active immediately
socialAccounts
:
{
socialAccounts
:
{
...
@@ -96,6 +98,12 @@ export class AuthRepository {
...
@@ -96,6 +98,12 @@ export class AuthRepository {
});
});
}
}
async
linkSocialAccount
(
userId
:
string
,
provider
:
string
,
providerUserId
:
string
)
{
return
prisma
.
userSocial
.
create
({
data
:
{
userId
,
provider
,
providerUserId
},
});
}
async
createVerificationToken
(
userId
:
string
,
token
:
string
,
expiresAt
:
Date
)
{
async
createVerificationToken
(
userId
:
string
,
token
:
string
,
expiresAt
:
Date
)
{
return
prisma
.
verificationToken
.
create
({
return
prisma
.
verificationToken
.
create
({
data
:
{
data
:
{
...
...
src/modules/auth/auth.route.ts
View file @
97360cba
...
@@ -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
,
updateAvatarSchema
,
updatePasswordSchema
,
forgotPasswordSchema
,
resetPasswordSchema
,
resendVerificationSchema
,
sessionParamsSchema
,
sessionQuerySchema
,
revokeOtherSessionsSchema
}
from
'./auth.validation'
;
import
{
loginSchema
,
zaloLoginSchema
,
refreshSchema
,
logoutSchema
,
registerSchema
,
verifyEmailSchema
,
updateProfileSchema
,
updateAvatarSchema
,
updatePasswordSchema
,
forgotPasswordSchema
,
resetPasswordSchema
,
resendVerificationSchema
,
sessionParamsSchema
,
sessionQuerySchema
,
revokeOtherSessionsSchema
}
from
'./auth.validation'
;
const
router
=
Router
();
const
router
=
Router
();
const
controller
=
new
AuthController
();
const
controller
=
new
AuthController
();
...
@@ -10,6 +10,7 @@ const controller = new AuthController();
...
@@ -10,6 +10,7 @@ const controller = new AuthController();
router
.
post
(
'/register'
,
validate
(
registerSchema
),
controller
.
register
);
router
.
post
(
'/register'
,
validate
(
registerSchema
),
controller
.
register
);
router
.
get
(
'/verify-email'
,
validate
(
verifyEmailSchema
,
'query'
),
controller
.
verifyEmail
);
router
.
get
(
'/verify-email'
,
validate
(
verifyEmailSchema
,
'query'
),
controller
.
verifyEmail
);
router
.
post
(
'/login'
,
validate
(
loginSchema
),
controller
.
login
);
router
.
post
(
'/login'
,
validate
(
loginSchema
),
controller
.
login
);
router
.
post
(
'/zalo-login'
,
validate
(
zaloLoginSchema
),
controller
.
loginWithZalo
);
router
.
get
(
'/me'
,
authMiddleware
,
controller
.
me
);
router
.
get
(
'/me'
,
authMiddleware
,
controller
.
me
);
router
.
post
(
'/refresh'
,
validate
(
refreshSchema
),
controller
.
refresh
);
router
.
post
(
'/refresh'
,
validate
(
refreshSchema
),
controller
.
refresh
);
router
.
post
(
'/logout'
,
validate
(
logoutSchema
),
controller
.
logout
);
router
.
post
(
'/logout'
,
validate
(
logoutSchema
),
controller
.
logout
);
...
...
src/modules/auth/auth.service.ts
View file @
97360cba
...
@@ -5,7 +5,8 @@ import { AuthRepository } from './auth.repository';
...
@@ -5,7 +5,8 @@ import { AuthRepository } from './auth.repository';
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
{
jwtConfig
}
from
'../../config/jwt.config'
;
import
{
jwtConfig
}
from
'../../config/jwt.config'
;
import
{
LoginDto
,
LoginResponseDto
,
AuthTokensDto
,
MeDto
,
RegisterDto
,
UpdateProfileDto
,
UpdateAvatarDto
,
UpdatePasswordDto
,
ForgotPasswordDto
,
ResetPasswordDto
,
ResendVerificationDto
,
SessionQueryDto
,
SessionsResponseDto
}
from
'./auth.dto'
;
import
{
LoginDto
,
ZaloLoginDto
,
ZaloProfileResponse
,
LoginResponseDto
,
AuthTokensDto
,
MeDto
,
RegisterDto
,
UpdateProfileDto
,
UpdateAvatarDto
,
UpdatePasswordDto
,
ForgotPasswordDto
,
ResetPasswordDto
,
ResendVerificationDto
,
SessionQueryDto
,
SessionsResponseDto
}
from
'./auth.dto'
;
import
https
from
'https'
;
import
{
MailService
}
from
'../../common/services/mail.service'
;
import
{
MailService
}
from
'../../common/services/mail.service'
;
import
{
generateDeviceHash
,
parseUserAgent
}
from
'../../common/helpers/user-agent.helper'
;
import
{
generateDeviceHash
,
parseUserAgent
}
from
'../../common/helpers/user-agent.helper'
;
import
{
rbacService
}
from
'../rbac/rbac.service'
;
import
{
rbacService
}
from
'../rbac/rbac.service'
;
...
@@ -440,4 +441,145 @@ export class AuthService {
...
@@ -440,4 +441,145 @@ export class AuthService {
async
revokeAllOtherSessions
(
userId
:
string
,
currentToken
:
string
)
{
async
revokeAllOtherSessions
(
userId
:
string
,
currentToken
:
string
)
{
await
this
.
repository
.
deleteOtherSessions
(
userId
,
currentToken
);
await
this
.
repository
.
deleteOtherSessions
(
userId
,
currentToken
);
}
}
async
loginWithZalo
(
dto
:
ZaloLoginDto
,
metadata
?:
{
userAgent
?:
string
;
ipAddress
?:
string
},
):
Promise
<
LoginResponseDto
>
{
const
{
accessToken
,
phoneNumber
}
=
dto
;
// 1. Xác thực access_token với Zalo Graph API
const
appSecret
=
process
.
env
.
ZALO_APP_SECRET
||
''
;
const
appsecretProof
=
crypto
.
createHmac
(
'sha256'
,
appSecret
)
.
update
(
accessToken
)
.
digest
(
'hex'
);
const
zaloProfile
=
await
this
.
fetchZaloProfile
(
accessToken
,
appsecretProof
);
if
(
!
zaloProfile
||
zaloProfile
.
error
!==
0
)
{
throw
new
AppError
(
'Invalid Zalo access token or Zalo API error'
,
401
,
ERROR_CODE
.
INVALID_CREDENTIALS
,
);
}
const
{
id
:
zaloId
,
name
:
zaloName
,
picture
}
=
zaloProfile
;
const
zaloAvatarUrl
:
string
|
null
=
picture
?.
data
?.
url
||
null
;
// 2. Tìm hoặc tạo user theo SĐT
let
user
=
await
this
.
repository
.
findByPhone
(
phoneNumber
);
if
(
user
)
{
// User đã tồn tại — liên kết Zalo ID nếu chưa có
const
existing
=
await
this
.
repository
.
findBySocial
(
'zalo'
,
zaloId
);
if
(
!
existing
)
{
await
this
.
repository
.
linkSocialAccount
(
user
.
id
,
'zalo'
,
zaloId
);
}
}
else
{
// User chưa tồn tại — tạo mới từ Zalo profile
const
role
=
await
this
.
repository
.
findRoleByName
(
SYSTEM_ROLES
.
USER
);
if
(
!
role
)
{
throw
new
AppError
(
'Default role not found'
,
500
,
ERROR_CODE
.
INTERNAL_SERVER_ERROR
);
}
user
=
await
this
.
repository
.
createSocialUser
({
fullName
:
zaloName
||
undefined
,
avatarUrl
:
zaloAvatarUrl
||
undefined
,
phoneNumber
,
roleId
:
role
.
id
,
provider
:
'zalo'
,
providerUserId
:
zaloId
,
});
}
// Load role relation nếu chưa có (createSocialUser đã include)
const
userWithRole
=
await
this
.
repository
.
findById
(
user
.
id
);
if
(
!
userWithRole
||
!
userWithRole
.
isActive
)
{
throw
new
AppError
(
'User not found or inactive'
,
401
,
ERROR_CODE
.
USER_INACTIVE
);
}
// 3. Tạo JWT tokens
const
payload
=
{
id
:
userWithRole
.
id
,
email
:
userWithRole
.
email
,
role
:
userWithRole
.
role
.
name
};
const
newAccessToken
=
jwt
.
sign
(
payload
,
jwtConfig
.
accessSecret
,
{
expiresIn
:
jwtConfig
.
accessExpiresIn
as
any
,
});
const
refreshToken
=
jwt
.
sign
(
{
...
payload
,
jti
:
crypto
.
randomUUID
()
},
jwtConfig
.
refreshSecret
,
{
expiresIn
:
jwtConfig
.
refreshExpiresIn
as
any
},
);
const
decoded
=
jwt
.
decode
(
refreshToken
)
as
{
exp
:
number
};
const
expiresAt
=
new
Date
(
decoded
.
exp
*
1000
);
await
this
.
repository
.
saveRefreshToken
(
userWithRole
.
id
,
refreshToken
,
expiresAt
,
metadata
?.
userAgent
,
metadata
?.
ipAddress
,
);
await
this
.
repository
.
cleanupExpiredSessions
(
userWithRole
.
id
);
await
this
.
repository
.
enforceSessionLimit
(
userWithRole
.
id
,
10
);
const
permissions
=
await
rbacService
.
getUserPermissions
(
userWithRole
.
id
);
return
{
user
:
{
id
:
userWithRole
.
id
,
email
:
userWithRole
.
email
,
fullName
:
userWithRole
.
fullName
,
avatarUrl
:
userWithRole
.
avatarUrl
,
avatarPositionX
:
userWithRole
.
avatarPositionX
,
avatarPositionY
:
userWithRole
.
avatarPositionY
,
phoneNumber
:
userWithRole
.
phoneNumber
,
roleId
:
userWithRole
.
roleId
,
role
:
{
id
:
userWithRole
.
role
.
id
,
name
:
userWithRole
.
role
.
name
,
description
:
userWithRole
.
role
.
description
,
isSystem
:
userWithRole
.
role
.
isSystem
,
},
permissions
,
isActive
:
userWithRole
.
isActive
,
createdAt
:
userWithRole
.
createdAt
,
updatedAt
:
userWithRole
.
updatedAt
,
},
accessToken
:
newAccessToken
,
refreshToken
,
};
}
private
fetchZaloProfile
(
accessToken
:
string
,
appsecretProof
:
string
,
):
Promise
<
ZaloProfileResponse
>
{
return
new
Promise
((
resolve
,
reject
)
=>
{
const
options
=
{
hostname
:
'graph.zalo.me'
,
path
:
'/v2.0/me?fields=id,name,picture'
,
method
:
'GET'
,
headers
:
{
access_token
:
accessToken
,
appsecret_proof
:
appsecretProof
,
},
};
const
req
=
https
.
request
(
options
,
(
res
)
=>
{
let
data
=
''
;
res
.
on
(
'data'
,
(
chunk
)
=>
(
data
+=
chunk
));
res
.
on
(
'end'
,
()
=>
{
try
{
resolve
(
JSON
.
parse
(
data
)
as
ZaloProfileResponse
);
}
catch
{
reject
(
new
Error
(
'Failed to parse Zalo API response'
));
}
});
});
req
.
on
(
'error'
,
reject
);
req
.
end
();
});
}
}
}
src/modules/auth/auth.validation.ts
View file @
97360cba
...
@@ -10,6 +10,14 @@ export const loginSchema = z.object({
...
@@ -10,6 +10,14 @@ export const loginSchema = z.object({
password
:
z
.
string
().
min
(
1
,
'Password is required'
),
password
:
z
.
string
().
min
(
1
,
'Password is required'
),
});
});
export
const
zaloLoginSchema
=
z
.
object
({
accessToken
:
z
.
string
().
min
(
1
,
'Zalo access token is required'
),
phoneNumber
:
z
.
string
()
.
min
(
1
,
'Phone number is required'
)
.
regex
(
/^
(
0
[
3|5|7|8|9
])
+
([
0-9
]{8})
$/
,
'Invalid Vietnamese phone number format'
),
});
export
const
refreshSchema
=
z
.
object
({
export
const
refreshSchema
=
z
.
object
({
refreshToken
:
z
.
string
().
optional
(),
refreshToken
:
z
.
string
().
optional
(),
});
});
...
...
tests/zalo-auth.test.ts
0 → 100644
View file @
97360cba
import
request
from
'supertest'
;
import
app
from
'../src/app'
;
import
{
prisma
}
from
'../src/database/prisma.client'
;
import
{
AuthService
}
from
'../src/modules/auth/auth.service'
;
describe
(
'Zalo Auth Integration Tests'
,
()
=>
{
const
testPhone
=
'0987654321'
;
const
testZaloId
=
'zalo_test_user_id_99999'
;
const
testZaloName
=
'Nguyễn Văn Zalo'
;
const
testAvatarUrl
=
'https://s120.zadn.vn/avatar_test.jpg'
;
afterAll
(
async
()
=>
{
// Cleanup test user
const
user
=
await
prisma
.
user
.
findFirst
({
where
:
{
phoneNumber
:
testPhone
},
});
if
(
user
)
{
await
prisma
.
userSocial
.
deleteMany
({
where
:
{
userId
:
user
.
id
}
});
await
prisma
.
refreshToken
.
deleteMany
({
where
:
{
userId
:
user
.
id
}
});
await
prisma
.
userDevice
.
deleteMany
({
where
:
{
userId
:
user
.
id
}
});
await
prisma
.
user
.
delete
({
where
:
{
id
:
user
.
id
}
});
}
});
describe
(
'Validation'
,
()
=>
{
it
(
'should reject request when accessToken is missing'
,
async
()
=>
{
const
res
=
await
request
(
app
)
.
post
(
'/api/v1/auth/zalo-login'
)
.
send
({
phoneNumber
:
testPhone
});
expect
(
res
.
status
).
toBe
(
422
);
expect
(
res
.
body
.
success
).
toBe
(
false
);
expect
(
res
.
body
.
code
).
toBe
(
'VALIDATION_ERROR'
);
});
it
(
'should reject request when phoneNumber is missing'
,
async
()
=>
{
const
res
=
await
request
(
app
)
.
post
(
'/api/v1/auth/zalo-login'
)
.
send
({
accessToken
:
'some_access_token'
});
expect
(
res
.
status
).
toBe
(
422
);
expect
(
res
.
body
.
success
).
toBe
(
false
);
expect
(
res
.
body
.
code
).
toBe
(
'VALIDATION_ERROR'
);
});
it
(
'should reject request when phoneNumber is invalid Vietnamese format'
,
async
()
=>
{
const
res
=
await
request
(
app
)
.
post
(
'/api/v1/auth/zalo-login'
)
.
send
({
accessToken
:
'some_access_token'
,
phoneNumber
:
'1234567890'
,
// Invalid prefix
});
expect
(
res
.
status
).
toBe
(
422
);
expect
(
res
.
body
.
success
).
toBe
(
false
);
expect
(
res
.
body
.
code
).
toBe
(
'VALIDATION_ERROR'
);
});
});
describe
(
'Invalid Zalo Token'
,
()
=>
{
it
(
'should return 401 when Zalo access token is invalid'
,
async
()
=>
{
const
res
=
await
request
(
app
)
.
post
(
'/api/v1/auth/zalo-login'
)
.
send
({
accessToken
:
'invalid_dummy_token'
,
phoneNumber
:
testPhone
,
});
expect
(
res
.
status
).
toBe
(
401
);
expect
(
res
.
body
.
success
).
toBe
(
false
);
expect
(
res
.
body
.
code
).
toBe
(
'INVALID_CREDENTIALS'
);
});
});
describe
(
'Successful Zalo Login Flow'
,
()
=>
{
let
mockFetchZalo
:
jest
.
SpyInstance
;
beforeEach
(()
=>
{
// Mock fetchZaloProfile on AuthService prototype
mockFetchZalo
=
jest
.
spyOn
(
AuthService
.
prototype
as
any
,
'fetchZaloProfile'
).
mockResolvedValue
({
id
:
testZaloId
,
name
:
testZaloName
,
error
:
0
,
message
:
'Success'
,
picture
:
{
data
:
{
url
:
testAvatarUrl
,
},
},
});
});
afterEach
(()
=>
{
mockFetchZalo
.
mockRestore
();
});
it
(
'should create new user and return tokens when phone does not exist yet'
,
async
()
=>
{
const
res
=
await
request
(
app
)
.
post
(
'/api/v1/auth/zalo-login'
)
.
send
({
accessToken
:
'valid_mock_token_123'
,
phoneNumber
:
testPhone
,
});
expect
(
res
.
status
).
toBe
(
200
);
expect
(
res
.
body
.
success
).
toBe
(
true
);
expect
(
res
.
body
.
data
).
toHaveProperty
(
'accessToken'
);
expect
(
res
.
body
.
data
).
toHaveProperty
(
'refreshToken'
);
expect
(
res
.
body
.
data
.
user
).
toHaveProperty
(
'phoneNumber'
,
testPhone
);
expect
(
res
.
body
.
data
.
user
).
toHaveProperty
(
'fullName'
,
testZaloName
);
expect
(
res
.
body
.
data
.
user
).
toHaveProperty
(
'avatarUrl'
,
testAvatarUrl
);
expect
(
res
.
body
.
data
.
user
).
toHaveProperty
(
'isActive'
,
true
);
// Verify cookies are set
const
cookies
=
res
.
headers
[
'set-cookie'
]
as
unknown
as
string
[];
expect
(
cookies
).
toBeDefined
();
expect
(
cookies
.
some
((
c
:
string
)
=>
c
.
includes
(
'accessToken'
))).
toBe
(
true
);
expect
(
cookies
.
some
((
c
:
string
)
=>
c
.
includes
(
'refreshToken'
))).
toBe
(
true
);
// Verify DB record
const
dbUser
=
await
prisma
.
user
.
findFirst
({
where
:
{
phoneNumber
:
testPhone
},
include
:
{
socialAccounts
:
true
},
});
expect
(
dbUser
).
not
.
toBeNull
();
expect
(
dbUser
?.
socialAccounts
.
length
).
toBe
(
1
);
expect
(
dbUser
?.
socialAccounts
[
0
].
provider
).
toBe
(
'zalo'
);
expect
(
dbUser
?.
socialAccounts
[
0
].
providerUserId
).
toBe
(
testZaloId
);
});
it
(
'should login existing user and return tokens without duplicate creation'
,
async
()
=>
{
const
res
=
await
request
(
app
)
.
post
(
'/api/v1/auth/zalo-login'
)
.
send
({
accessToken
:
'valid_mock_token_123'
,
phoneNumber
:
testPhone
,
});
expect
(
res
.
status
).
toBe
(
200
);
expect
(
res
.
body
.
success
).
toBe
(
true
);
expect
(
res
.
body
.
data
.
user
.
phoneNumber
).
toBe
(
testPhone
);
// Ensure no duplicate users were created
const
count
=
await
prisma
.
user
.
count
({
where
:
{
phoneNumber
:
testPhone
},
});
expect
(
count
).
toBe
(
1
);
});
});
});
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