From 75a608031b55c9788b88ce68c17065c8db199bee Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Fri, 10 Apr 2026 21:39:00 +0700 Subject: [PATCH] =?UTF-8?q?fix:=20resolve=20lint=20errors=20in=20test=20fi?= =?UTF-8?q?les=20=E2=80=94=20group=20imports=20before=20vi.mock=20blocks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - local.strategy.spec.ts: move LocalStrategy import above vi.mock calls - media-storage.service.spec.ts: move MinioMediaStorageService import above vi.mock calls - Vitest hoists vi.mock regardless of source order, so grouping imports is safe Co-Authored-By: Paperclip --- .../__tests__/local.strategy.spec.ts | 124 ++++++++++++++ .../__tests__/media-storage.service.spec.ts | 158 ++++++++++++++++++ 2 files changed, 282 insertions(+) create mode 100644 apps/api/src/modules/auth/infrastructure/__tests__/local.strategy.spec.ts create mode 100644 apps/api/src/modules/listings/infrastructure/__tests__/media-storage.service.spec.ts diff --git a/apps/api/src/modules/auth/infrastructure/__tests__/local.strategy.spec.ts b/apps/api/src/modules/auth/infrastructure/__tests__/local.strategy.spec.ts new file mode 100644 index 0000000..5f44c32 --- /dev/null +++ b/apps/api/src/modules/auth/infrastructure/__tests__/local.strategy.spec.ts @@ -0,0 +1,124 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { LocalStrategy } from '../strategies/local.strategy'; + +// Mock passport-local before importing LocalStrategy +vi.mock('passport-local', () => { + return { + Strategy: class MockStrategy { + constructor(_options: any) {} + }, + }; +}); + +vi.mock('@nestjs/passport', () => { + return { + PassportStrategy: (StrategyClass: any) => { + return class extends StrategyClass {}; + }, + }; +}); + +vi.mock('@modules/shared', () => { + class UnauthorizedException extends Error { + constructor(message: string) { + super(message); + this.name = 'UnauthorizedException'; + } + } + return { + UnauthorizedException, + normalizeVietnamPhone: (phone: string) => { + if (phone.startsWith('+84') && phone.length === 12) return phone; + if (phone.startsWith('0') && phone.length === 10) return '+84' + phone.slice(1); + return null; + }, + }; +}); + +describe('LocalStrategy', () => { + let strategy: LocalStrategy; + let mockUserRepo: { + findByPhone: ReturnType; + }; + + beforeEach(() => { + mockUserRepo = { + findByPhone: vi.fn(), + }; + strategy = new LocalStrategy(mockUserRepo as any); + }); + + it('throws when phone number is invalid', async () => { + await expect(strategy.validate('12345', 'password')).rejects.toThrow( + 'Số điện thoại không hợp lệ', + ); + }); + + it('throws when user is not found', async () => { + mockUserRepo.findByPhone.mockResolvedValue(null); + + await expect(strategy.validate('0912345678', 'password')).rejects.toThrow( + 'Số điện thoại hoặc mật khẩu không đúng', + ); + }); + + it('throws when user has no password hash', async () => { + mockUserRepo.findByPhone.mockResolvedValue({ + id: 'user-1', + passwordHash: null, + isActive: true, + phone: { value: '+84912345678' }, + role: 'BUYER', + }); + + await expect(strategy.validate('0912345678', 'password')).rejects.toThrow( + 'Số điện thoại hoặc mật khẩu không đúng', + ); + }); + + it('throws when user is inactive', async () => { + mockUserRepo.findByPhone.mockResolvedValue({ + id: 'user-1', + passwordHash: { compare: vi.fn().mockResolvedValue(true) }, + isActive: false, + phone: { value: '+84912345678' }, + role: 'BUYER', + }); + + await expect(strategy.validate('0912345678', 'password')).rejects.toThrow( + 'Tài khoản đã bị vô hiệu hóa', + ); + }); + + it('throws when password is wrong', async () => { + mockUserRepo.findByPhone.mockResolvedValue({ + id: 'user-1', + passwordHash: { compare: vi.fn().mockResolvedValue(false) }, + isActive: true, + phone: { value: '+84912345678' }, + role: 'BUYER', + }); + + await expect(strategy.validate('0912345678', 'password')).rejects.toThrow( + 'Số điện thoại hoặc mật khẩu không đúng', + ); + }); + + it('returns user info with valid credentials', async () => { + mockUserRepo.findByPhone.mockResolvedValue({ + id: 'user-1', + passwordHash: { compare: vi.fn().mockResolvedValue(true) }, + isActive: true, + phone: { value: '+84912345678' }, + role: 'BUYER', + }); + + const result = await strategy.validate('0912345678', 'P@ssw0rd!'); + + expect(result).toEqual({ + id: 'user-1', + phone: '+84912345678', + role: 'BUYER', + }); + }); +}); diff --git a/apps/api/src/modules/listings/infrastructure/__tests__/media-storage.service.spec.ts b/apps/api/src/modules/listings/infrastructure/__tests__/media-storage.service.spec.ts new file mode 100644 index 0000000..9c17c82 --- /dev/null +++ b/apps/api/src/modules/listings/infrastructure/__tests__/media-storage.service.spec.ts @@ -0,0 +1,158 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { MinioMediaStorageService } from '../services/media-storage.service'; + +// Use vi.hoisted so variables are available when vi.mock factory runs (hoisted) +const { mockSend, mockGetSignedUrl } = vi.hoisted(() => ({ + mockSend: vi.fn(), + mockGetSignedUrl: vi.fn(), +})); + +vi.mock('@aws-sdk/client-s3', () => { + return { + S3Client: class MockS3Client { + send = mockSend; + constructor() {} + }, + PutObjectCommand: class MockPutObjectCommand { + constructor(public input: unknown) {} + }, + DeleteObjectCommand: class MockDeleteObjectCommand { + constructor(public input: unknown) {} + }, + HeadBucketCommand: class MockHeadBucketCommand { + constructor(public input: unknown) {} + }, + CreateBucketCommand: class MockCreateBucketCommand { + constructor(public input: unknown) {} + }, + }; +}); + +vi.mock('@aws-sdk/s3-request-presigner', () => ({ + getSignedUrl: mockGetSignedUrl, +})); + +describe('MinioMediaStorageService', () => { + let service: MinioMediaStorageService; + let mockLogger: { + log: ReturnType; + warn: ReturnType; + error: ReturnType; + }; + + beforeEach(() => { + vi.clearAllMocks(); + + // Set required env variables + process.env['MINIO_ACCESS_KEY'] = 'test-access-key'; + process.env['MINIO_SECRET_KEY'] = 'test-secret-key'; + process.env['MINIO_ENDPOINT'] = 'localhost'; + process.env['MINIO_PORT'] = '9000'; + process.env['MINIO_BUCKET'] = 'test-bucket'; + process.env['MINIO_USE_SSL'] = 'false'; + + mockLogger = { + log: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + + service = new MinioMediaStorageService(mockLogger as any); + }); + + describe('onModuleInit', () => { + it('should log when bucket already exists', async () => { + mockSend.mockResolvedValueOnce({}); + + await service.onModuleInit(); + + expect(mockLogger.log).toHaveBeenCalledWith( + expect.stringContaining('test-bucket'), + 'MinioMediaStorageService', + ); + }); + + it('should create bucket when it does not exist (404)', async () => { + const notFoundError = { $metadata: { httpStatusCode: 404 } }; + mockSend.mockRejectedValueOnce(notFoundError).mockResolvedValueOnce({}); + + await service.onModuleInit(); + + expect(mockSend).toHaveBeenCalledTimes(2); + expect(mockLogger.log).toHaveBeenCalledWith( + expect.stringContaining('Creating bucket'), + 'MinioMediaStorageService', + ); + }); + + it('should warn when bucket cannot be verified', async () => { + mockSend.mockRejectedValueOnce({ $metadata: { httpStatusCode: 500 } }); + + await service.onModuleInit(); + + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('Could not verify bucket'), + 'MinioMediaStorageService', + ); + }); + }); + + describe('upload', () => { + it('should upload a file and return URL', async () => { + mockSend.mockResolvedValueOnce({}); + const buffer = Buffer.from('test-image-data'); + + const url = await service.upload(buffer, 'photo.jpg', 'image/jpeg', 'listings'); + + expect(url).toContain('http://localhost:9000/test-bucket/listings/'); + expect(url).toContain('.jpg'); + expect(mockSend).toHaveBeenCalledTimes(1); + expect(mockLogger.log).toHaveBeenCalledWith( + expect.stringContaining('Media uploaded'), + 'MinioMediaStorageService', + ); + }); + + it('should throw and log error when upload fails', async () => { + mockSend.mockRejectedValueOnce(new Error('Network error')); + const buffer = Buffer.from('test-data'); + + await expect(service.upload(buffer, 'file.png', 'image/png', 'media')).rejects.toThrow('Network error'); + expect(mockLogger.error).toHaveBeenCalled(); + }); + }); + + describe('delete', () => { + it('should delete a file by URL', async () => { + mockSend.mockResolvedValueOnce({}); + + await service.delete('http://localhost:9000/test-bucket/listings/123-abc.jpg'); + + expect(mockSend).toHaveBeenCalledTimes(1); + expect(mockLogger.log).toHaveBeenCalledWith( + expect.stringContaining('Media deleted'), + 'MinioMediaStorageService', + ); + }); + + it('should throw and log error when delete fails', async () => { + mockSend.mockRejectedValueOnce(new Error('Delete error')); + + await expect( + service.delete('http://localhost:9000/test-bucket/listings/abc.jpg'), + ).rejects.toThrow('Delete error'); + expect(mockLogger.error).toHaveBeenCalled(); + }); + }); + + describe('getPresignedUploadUrl', () => { + it('should return a presigned URL', async () => { + mockGetSignedUrl.mockResolvedValueOnce('https://presigned-url.example.com'); + + const url = await service.getPresignedUploadUrl('listings/test.jpg', 'image/jpeg'); + + expect(url).toBe('https://presigned-url.example.com'); + expect(mockGetSignedUrl).toHaveBeenCalledTimes(1); + }); + }); +});