diff --git a/apps/api/src/modules/auth/application/__tests__/get-agent-by-user-id.handler.spec.ts b/apps/api/src/modules/auth/application/__tests__/get-agent-by-user-id.handler.spec.ts new file mode 100644 index 0000000..c2fd776 --- /dev/null +++ b/apps/api/src/modules/auth/application/__tests__/get-agent-by-user-id.handler.spec.ts @@ -0,0 +1,62 @@ +import { GetAgentByUserIdHandler } from '../queries/get-agent-by-user-id/get-agent-by-user-id.handler'; +import { GetAgentByUserIdQuery } from '../queries/get-agent-by-user-id/get-agent-by-user-id.query'; + +const mockAgent = { + id: 'agent-1', + userId: 'user-1', + licenseNumber: 'LIC-001', + agency: 'ABC Realty', + qualityScore: 4.5, + totalDeals: 12, + responseTimeAvg: 3600, + bio: 'Experienced agent in HCMC', + serviceAreas: ['District 1', 'District 7'], + isVerified: true, + createdAt: new Date('2025-01-15'), +}; + +describe('GetAgentByUserIdHandler', () => { + let handler: GetAgentByUserIdHandler; + let mockPrisma: { agent: { findUnique: ReturnType } }; + + beforeEach(() => { + mockPrisma = { + agent: { findUnique: vi.fn() }, + }; + + handler = new GetAgentByUserIdHandler(mockPrisma as any); + }); + + it('returns agent DTO when agent exists', async () => { + mockPrisma.agent.findUnique.mockResolvedValue(mockAgent); + + const query = new GetAgentByUserIdQuery('user-1'); + const result = await handler.execute(query); + + expect(result).toEqual({ + id: 'agent-1', + userId: 'user-1', + licenseNumber: 'LIC-001', + agency: 'ABC Realty', + qualityScore: 4.5, + totalDeals: 12, + responseTimeAvg: 3600, + bio: 'Experienced agent in HCMC', + serviceAreas: ['District 1', 'District 7'], + isVerified: true, + createdAt: new Date('2025-01-15'), + }); + expect(mockPrisma.agent.findUnique).toHaveBeenCalledWith({ + where: { userId: 'user-1' }, + }); + }); + + it('returns null when agent does not exist', async () => { + mockPrisma.agent.findUnique.mockResolvedValue(null); + + const query = new GetAgentByUserIdQuery('user-without-agent'); + const result = await handler.execute(query); + + expect(result).toBeNull(); + }); +}); diff --git a/apps/api/src/modules/auth/application/__tests__/get-profile.handler.spec.ts b/apps/api/src/modules/auth/application/__tests__/get-profile.handler.spec.ts new file mode 100644 index 0000000..130e791 --- /dev/null +++ b/apps/api/src/modules/auth/application/__tests__/get-profile.handler.spec.ts @@ -0,0 +1,113 @@ +import { UserEntity } from '../../domain/entities/user.entity'; +import { type IUserRepository } from '../../domain/repositories/user.repository'; +import { Email } from '../../domain/value-objects/email.vo'; +import { type HashedPassword } from '../../domain/value-objects/hashed-password.vo'; +import { Phone } from '../../domain/value-objects/phone.vo'; +import { GetProfileHandler } from '../queries/get-profile/get-profile.handler'; +import { GetProfileQuery } from '../queries/get-profile/get-profile.query'; + +function createUserWithEmail(): UserEntity { + const phone = Phone.create('0912345678').unwrap(); + const email = Email.create('test@example.com').unwrap(); + const pw = { value: 'hashed' } as HashedPassword; + return new UserEntity('user-1', { + email, + phone, + passwordHash: pw, + fullName: 'Nguyen Van A', + avatarUrl: 'https://example.com/avatar.jpg', + role: 'BUYER', + kycStatus: 'VERIFIED', + kycData: null, + isActive: true, + }); +} + +function createUserWithoutEmail(): UserEntity { + const phone = Phone.create('0912345678').unwrap(); + const pw = { value: 'hashed' } as HashedPassword; + return new UserEntity('user-2', { + email: null, + phone, + passwordHash: pw, + fullName: 'Tran Thi B', + avatarUrl: null, + role: 'SELLER', + kycStatus: 'NONE', + kycData: null, + isActive: true, + }); +} + +describe('GetProfileHandler', () => { + let handler: GetProfileHandler; + let mockUserRepo: { [K in keyof IUserRepository]: ReturnType }; + let mockCache: { getOrSet: ReturnType }; + + beforeEach(() => { + mockUserRepo = { + findById: vi.fn(), + findByPhone: vi.fn(), + findByEmail: vi.fn(), + save: vi.fn(), + update: vi.fn(), + }; + // Pass-through cache: always invokes the loader + mockCache = { + getOrSet: vi.fn(async (_key: string, loader: () => Promise) => loader()), + }; + + handler = new GetProfileHandler(mockUserRepo as any, mockCache as any); + }); + + it('returns user profile with email', async () => { + mockUserRepo.findById.mockResolvedValue(createUserWithEmail()); + + const query = new GetProfileQuery('user-1'); + const result = await handler.execute(query); + + expect(result).toEqual({ + id: 'user-1', + email: 'test@example.com', + phone: '+84912345678', + fullName: 'Nguyen Van A', + avatarUrl: 'https://example.com/avatar.jpg', + role: 'BUYER', + kycStatus: 'VERIFIED', + isActive: true, + createdAt: expect.any(Date), + }); + }); + + it('returns null email when user has no email', async () => { + mockUserRepo.findById.mockResolvedValue(createUserWithoutEmail()); + + const query = new GetProfileQuery('user-2'); + const result = await handler.execute(query); + + expect(result.email).toBeNull(); + expect(result.fullName).toBe('Tran Thi B'); + }); + + it('throws NotFoundException when user does not exist', async () => { + mockUserRepo.findById.mockResolvedValue(null); + + const query = new GetProfileQuery('non-existent'); + + await expect(handler.execute(query)).rejects.toThrow('Người dùng'); + }); + + it('uses cache with correct key', async () => { + mockUserRepo.findById.mockResolvedValue(createUserWithEmail()); + + const query = new GetProfileQuery('user-1'); + await handler.execute(query); + + expect(mockCache.getOrSet).toHaveBeenCalledWith( + expect.stringContaining('user-1'), + expect.any(Function), + expect.any(Number), + 'user_profile', + ); + }); +}); diff --git a/apps/api/src/modules/auth/application/__tests__/verify-kyc.handler.spec.ts b/apps/api/src/modules/auth/application/__tests__/verify-kyc.handler.spec.ts new file mode 100644 index 0000000..c33bbed --- /dev/null +++ b/apps/api/src/modules/auth/application/__tests__/verify-kyc.handler.spec.ts @@ -0,0 +1,86 @@ +import { UserEntity } from '../../domain/entities/user.entity'; +import { type IUserRepository } from '../../domain/repositories/user.repository'; +import { type HashedPassword } from '../../domain/value-objects/hashed-password.vo'; +import { Phone } from '../../domain/value-objects/phone.vo'; +import { VerifyKycCommand } from '../commands/verify-kyc/verify-kyc.command'; +import { VerifyKycHandler } from '../commands/verify-kyc/verify-kyc.handler'; + +function createTestUser(): UserEntity { + const phone = Phone.create('0912345678').unwrap(); + const pw = { value: 'hashed' } as HashedPassword; + return new UserEntity('user-1', { + email: null, + phone, + passwordHash: pw, + fullName: 'Nguyen Van A', + avatarUrl: null, + role: 'BUYER', + kycStatus: 'NONE', + kycData: null, + isActive: true, + }); +} + +describe('VerifyKycHandler', () => { + let handler: VerifyKycHandler; + let mockUserRepo: { [K in keyof IUserRepository]: ReturnType }; + let mockCache: { invalidate: ReturnType }; + + beforeEach(() => { + mockUserRepo = { + findById: vi.fn(), + findByPhone: vi.fn(), + findByEmail: vi.fn(), + save: vi.fn(), + update: vi.fn(), + }; + mockCache = { invalidate: vi.fn().mockResolvedValue(undefined) }; + + handler = new VerifyKycHandler(mockUserRepo as any, mockCache as any); + }); + + it('updates KYC status and invalidates cache', async () => { + const user = createTestUser(); + mockUserRepo.findById.mockResolvedValue(user); + mockUserRepo.update.mockResolvedValue(undefined); + + const command = new VerifyKycCommand('user-1', 'VERIFIED', { documentId: 'doc-123' }); + await handler.execute(command); + + expect(mockUserRepo.findById).toHaveBeenCalledWith('user-1'); + expect(mockUserRepo.update).toHaveBeenCalledWith(user); + expect(mockCache.invalidate).toHaveBeenCalledWith( + expect.stringContaining('user-1'), + ); + }); + + it('updates KYC status without optional kycData', async () => { + const user = createTestUser(); + mockUserRepo.findById.mockResolvedValue(user); + mockUserRepo.update.mockResolvedValue(undefined); + + const command = new VerifyKycCommand('user-1', 'REJECTED'); + await handler.execute(command); + + expect(mockUserRepo.update).toHaveBeenCalledWith(user); + expect(mockCache.invalidate).toHaveBeenCalled(); + }); + + it('throws NotFoundException when user does not exist', async () => { + mockUserRepo.findById.mockResolvedValue(null); + + const command = new VerifyKycCommand('non-existent', 'VERIFIED'); + + await expect(handler.execute(command)).rejects.toThrow('Người dùng'); + }); + + it('does not update repository when user is not found', async () => { + mockUserRepo.findById.mockResolvedValue(null); + + const command = new VerifyKycCommand('non-existent', 'VERIFIED'); + + await expect(handler.execute(command)).rejects.toThrow(); + expect(mockUserRepo.update).not.toHaveBeenCalled(); + expect(mockCache.invalidate).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/modules/auth/application/commands/refresh-token/refresh-token.handler.ts b/apps/api/src/modules/auth/application/commands/refresh-token/refresh-token.handler.ts index 3e39908..02f17b8 100644 --- a/apps/api/src/modules/auth/application/commands/refresh-token/refresh-token.handler.ts +++ b/apps/api/src/modules/auth/application/commands/refresh-token/refresh-token.handler.ts @@ -1,6 +1,6 @@ import { Inject } from '@nestjs/common'; import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs'; -import { UnauthorizedException } from '@modules/shared/domain/domain-exception'; +import { UnauthorizedException } from '@modules/shared'; import { USER_REPOSITORY, type IUserRepository } from '../../../domain/repositories/user.repository'; import { type TokenService, type TokenPair } from '../../../infrastructure/services/token.service'; import { RefreshTokenCommand } from './refresh-token.command'; diff --git a/apps/api/src/modules/auth/application/commands/register-user/register-user.handler.ts b/apps/api/src/modules/auth/application/commands/register-user/register-user.handler.ts index 34c482b..1a81f1f 100644 --- a/apps/api/src/modules/auth/application/commands/register-user/register-user.handler.ts +++ b/apps/api/src/modules/auth/application/commands/register-user/register-user.handler.ts @@ -1,7 +1,7 @@ import { Inject } from '@nestjs/common'; import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs'; import { createId } from '@paralleldrive/cuid2'; -import { ConflictException, ValidationException } from '@modules/shared/domain/domain-exception'; +import { ConflictException, ValidationException } from '@modules/shared'; import { UserEntity } from '../../../domain/entities/user.entity'; import { USER_REPOSITORY, type IUserRepository } from '../../../domain/repositories/user.repository'; import { Email } from '../../../domain/value-objects/email.vo'; diff --git a/apps/api/src/modules/auth/application/commands/verify-kyc/verify-kyc.handler.ts b/apps/api/src/modules/auth/application/commands/verify-kyc/verify-kyc.handler.ts index 72ff0c0..a1a10b4 100644 --- a/apps/api/src/modules/auth/application/commands/verify-kyc/verify-kyc.handler.ts +++ b/apps/api/src/modules/auth/application/commands/verify-kyc/verify-kyc.handler.ts @@ -1,7 +1,6 @@ import { Inject } from '@nestjs/common'; import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs'; -import { NotFoundException } from '@modules/shared/domain/domain-exception'; -import { CacheService, CachePrefix } from '@modules/shared/infrastructure/cache.service'; +import { NotFoundException, CacheService, CachePrefix } from '@modules/shared'; import { USER_REPOSITORY, type IUserRepository } from '../../../domain/repositories/user.repository'; import { VerifyKycCommand } from './verify-kyc.command'; diff --git a/apps/api/src/modules/auth/application/queries/get-agent-by-user-id/get-agent-by-user-id.handler.ts b/apps/api/src/modules/auth/application/queries/get-agent-by-user-id/get-agent-by-user-id.handler.ts index f105889..d2aa37f 100644 --- a/apps/api/src/modules/auth/application/queries/get-agent-by-user-id/get-agent-by-user-id.handler.ts +++ b/apps/api/src/modules/auth/application/queries/get-agent-by-user-id/get-agent-by-user-id.handler.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs'; -import { type PrismaService } from '@modules/shared/infrastructure/prisma.service'; +import { type PrismaService } from '@modules/shared'; import { GetAgentByUserIdQuery } from './get-agent-by-user-id.query'; export interface AgentDto { diff --git a/apps/api/src/modules/auth/application/queries/get-profile/get-profile.handler.ts b/apps/api/src/modules/auth/application/queries/get-profile/get-profile.handler.ts index 9e58b90..ef06a56 100644 --- a/apps/api/src/modules/auth/application/queries/get-profile/get-profile.handler.ts +++ b/apps/api/src/modules/auth/application/queries/get-profile/get-profile.handler.ts @@ -1,7 +1,6 @@ import { Inject } from '@nestjs/common'; import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs'; -import { NotFoundException } from '@modules/shared/domain/domain-exception'; -import { CacheService, CachePrefix, CacheTTL } from '@modules/shared/infrastructure/cache.service'; +import { NotFoundException, CacheService, CachePrefix, CacheTTL } from '@modules/shared'; import { USER_REPOSITORY, type IUserRepository } from '../../../domain/repositories/user.repository'; import { GetProfileQuery } from './get-profile.query'; diff --git a/apps/api/src/modules/auth/auth.module.ts b/apps/api/src/modules/auth/auth.module.ts index 9c689ae..55ffc32 100644 --- a/apps/api/src/modules/auth/auth.module.ts +++ b/apps/api/src/modules/auth/auth.module.ts @@ -34,15 +34,17 @@ const QueryHandlers = [GetProfileHandler, GetAgentByUserIdHandler]; imports: [ CqrsModule, PassportModule, - JwtModule.register({ - secret: (() => { + JwtModule.registerAsync({ + useFactory: () => { const secret = process.env['JWT_SECRET']; if (!secret) { throw new Error('JWT_SECRET environment variable is required'); } - return secret; - })(), - signOptions: { expiresIn: '15m', audience: 'goodgo-api', issuer: 'goodgo-platform' }, + return { + secret, + signOptions: { expiresIn: '15m', audience: 'goodgo-api', issuer: 'goodgo-platform' }, + }; + }, }), ], controllers: [AuthController, OAuthController], diff --git a/apps/api/src/modules/auth/domain/entities/user.entity.ts b/apps/api/src/modules/auth/domain/entities/user.entity.ts index ad62da1..ed58f73 100644 --- a/apps/api/src/modules/auth/domain/entities/user.entity.ts +++ b/apps/api/src/modules/auth/domain/entities/user.entity.ts @@ -1,5 +1,5 @@ import { type UserRole, type KYCStatus } from '@prisma/client'; -import { AggregateRoot } from '@modules/shared/domain/aggregate-root'; +import { AggregateRoot } from '@modules/shared'; import { UserRegisteredEvent } from '../events/user-registered.event'; import { type Email } from '../value-objects/email.vo'; import { type HashedPassword } from '../value-objects/hashed-password.vo'; diff --git a/apps/api/src/modules/auth/domain/events/agent-verified.event.ts b/apps/api/src/modules/auth/domain/events/agent-verified.event.ts index f27e00d..b7c41e4 100644 --- a/apps/api/src/modules/auth/domain/events/agent-verified.event.ts +++ b/apps/api/src/modules/auth/domain/events/agent-verified.event.ts @@ -1,4 +1,4 @@ -import { type DomainEvent } from '@modules/shared/domain/domain-event'; +import { type DomainEvent } from '@modules/shared'; export class AgentVerifiedEvent implements DomainEvent { readonly eventName = 'agent.verified'; diff --git a/apps/api/src/modules/auth/domain/events/user-registered.event.ts b/apps/api/src/modules/auth/domain/events/user-registered.event.ts index 96a395e..0c28f74 100644 --- a/apps/api/src/modules/auth/domain/events/user-registered.event.ts +++ b/apps/api/src/modules/auth/domain/events/user-registered.event.ts @@ -1,5 +1,5 @@ import { type UserRole } from '@prisma/client'; -import { type DomainEvent } from '@modules/shared/domain/domain-event'; +import { type DomainEvent } from '@modules/shared'; export class UserRegisteredEvent implements DomainEvent { readonly eventName = 'user.registered'; diff --git a/apps/api/src/modules/auth/domain/value-objects/email.vo.ts b/apps/api/src/modules/auth/domain/value-objects/email.vo.ts index 33c41ab..81b36d8 100644 --- a/apps/api/src/modules/auth/domain/value-objects/email.vo.ts +++ b/apps/api/src/modules/auth/domain/value-objects/email.vo.ts @@ -1,5 +1,4 @@ -import { Result } from '@modules/shared/domain/result'; -import { ValueObject } from '@modules/shared/domain/value-object'; +import { Result, ValueObject } from '@modules/shared'; interface EmailProps { value: string; diff --git a/apps/api/src/modules/auth/domain/value-objects/hashed-password.vo.ts b/apps/api/src/modules/auth/domain/value-objects/hashed-password.vo.ts index dc3788d..3a0e678 100644 --- a/apps/api/src/modules/auth/domain/value-objects/hashed-password.vo.ts +++ b/apps/api/src/modules/auth/domain/value-objects/hashed-password.vo.ts @@ -1,6 +1,5 @@ import * as bcrypt from 'bcrypt'; -import { Result } from '@modules/shared/domain/result'; -import { ValueObject } from '@modules/shared/domain/value-object'; +import { Result, ValueObject } from '@modules/shared'; interface HashedPasswordProps { value: string; diff --git a/apps/api/src/modules/auth/domain/value-objects/phone.vo.ts b/apps/api/src/modules/auth/domain/value-objects/phone.vo.ts index dedc6f8..78978f9 100644 --- a/apps/api/src/modules/auth/domain/value-objects/phone.vo.ts +++ b/apps/api/src/modules/auth/domain/value-objects/phone.vo.ts @@ -1,6 +1,4 @@ -import { Result } from '@modules/shared/domain/result'; -import { ValueObject } from '@modules/shared/domain/value-object'; -import { isValidVietnamPhone, normalizeVietnamPhone } from '@modules/shared/utils/vietnam-phone.validator'; +import { Result, ValueObject, isValidVietnamPhone, normalizeVietnamPhone } from '@modules/shared'; interface PhoneProps { value: string; diff --git a/apps/api/src/modules/auth/index.ts b/apps/api/src/modules/auth/index.ts index d9b65ec..5c0d01d 100644 --- a/apps/api/src/modules/auth/index.ts +++ b/apps/api/src/modules/auth/index.ts @@ -3,4 +3,10 @@ export { JwtAuthGuard } from './presentation/guards/jwt-auth.guard'; export { RolesGuard } from './presentation/guards/roles.guard'; export { Roles } from './presentation/decorators/roles.decorator'; export { CurrentUser } from './presentation/decorators/current-user.decorator'; -export { type JwtPayload } from './infrastructure/services/token.service'; +export { TokenService, type JwtPayload, type TokenPair, type RotateResult } from './infrastructure/services/token.service'; +export { UserEntity, type UserProps } from './domain/entities/user.entity'; +export { HashedPassword } from './domain/value-objects/hashed-password.vo'; +export { Phone } from './domain/value-objects/phone.vo'; +export { AgentVerifiedEvent } from './domain/events/agent-verified.event'; +export { UserRegisteredEvent } from './domain/events/user-registered.event'; +export { USER_REPOSITORY, type IUserRepository } from './domain/repositories/user.repository'; diff --git a/apps/api/src/modules/auth/infrastructure/repositories/prisma-refresh-token.repository.ts b/apps/api/src/modules/auth/infrastructure/repositories/prisma-refresh-token.repository.ts index 7e39693..7295172 100644 --- a/apps/api/src/modules/auth/infrastructure/repositories/prisma-refresh-token.repository.ts +++ b/apps/api/src/modules/auth/infrastructure/repositories/prisma-refresh-token.repository.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { type PrismaService } from '@modules/shared/infrastructure/prisma.service'; +import { type PrismaService } from '@modules/shared'; import { type IRefreshTokenRepository, type RefreshTokenRecord, diff --git a/apps/api/src/modules/auth/infrastructure/repositories/prisma-user.repository.ts b/apps/api/src/modules/auth/infrastructure/repositories/prisma-user.repository.ts index 58129a3..7cb0d13 100644 --- a/apps/api/src/modules/auth/infrastructure/repositories/prisma-user.repository.ts +++ b/apps/api/src/modules/auth/infrastructure/repositories/prisma-user.repository.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { type Prisma, type User as PrismaUser } from '@prisma/client'; -import { type PrismaService } from '@modules/shared/infrastructure/prisma.service'; +import { type PrismaService } from '@modules/shared'; import { UserEntity, type UserProps } from '../../domain/entities/user.entity'; import { type IUserRepository } from '../../domain/repositories/user.repository'; import { Email } from '../../domain/value-objects/email.vo'; diff --git a/apps/api/src/modules/auth/infrastructure/services/oauth.service.ts b/apps/api/src/modules/auth/infrastructure/services/oauth.service.ts index 305fd1a..81eba3c 100644 --- a/apps/api/src/modules/auth/infrastructure/services/oauth.service.ts +++ b/apps/api/src/modules/auth/infrastructure/services/oauth.service.ts @@ -2,7 +2,7 @@ import { Inject, Injectable, Logger } from '@nestjs/common'; import { type EventBus } from '@nestjs/cqrs'; import { createId } from '@paralleldrive/cuid2'; import { type OAuthProvider, type Prisma } from '@prisma/client'; -import { type PrismaService } from '@modules/shared/infrastructure/prisma.service'; +import { type PrismaService } from '@modules/shared'; import { UserEntity } from '../../domain/entities/user.entity'; import { UserRegisteredEvent } from '../../domain/events/user-registered.event'; import { USER_REPOSITORY, type IUserRepository } from '../../domain/repositories/user.repository'; diff --git a/apps/api/src/modules/auth/infrastructure/strategies/local.strategy.ts b/apps/api/src/modules/auth/infrastructure/strategies/local.strategy.ts index d4fd085..fff93e8 100644 --- a/apps/api/src/modules/auth/infrastructure/strategies/local.strategy.ts +++ b/apps/api/src/modules/auth/infrastructure/strategies/local.strategy.ts @@ -1,7 +1,7 @@ import { Inject, Injectable, UnauthorizedException } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import { Strategy } from 'passport-local'; -import { normalizeVietnamPhone } from '@modules/shared/utils/vietnam-phone.validator'; +import { normalizeVietnamPhone } from '@modules/shared'; import { USER_REPOSITORY, type IUserRepository } from '../../domain/repositories/user.repository'; @Injectable()