feat(auth): add handler specs and improve auth infrastructure

Add unit tests for get-profile, get-agent-by-user-id, and verify-kyc handlers.
Improve OAuth service, local strategy, and repository implementations with
proper ConfigService injection and error handling.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-09 09:42:16 +07:00
parent cd25d4df2e
commit 36e0f49e9e
20 changed files with 290 additions and 27 deletions

View File

@@ -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<typeof vi.fn> } };
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();
});
});

View File

@@ -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<typeof vi.fn> };
let mockCache: { getOrSet: ReturnType<typeof vi.fn> };
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<unknown>) => 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',
);
});
});

View File

@@ -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<typeof vi.fn> };
let mockCache: { invalidate: ReturnType<typeof vi.fn> };
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();
});
});

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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 {

View File

@@ -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';

View File

@@ -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],

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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';

View File

@@ -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,

View File

@@ -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';

View File

@@ -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';

View File

@@ -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()