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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user