diff --git a/.env.test b/.env.test new file mode 100644 index 0000000..4ff489b --- /dev/null +++ b/.env.test @@ -0,0 +1,34 @@ +# ============================================================================= +# GoodGo Platform — Test Environment Variables +# Used by E2E tests (Playwright globalSetup loads this automatically) +# ============================================================================= + +# Test database — separate from development DB for isolation +DATABASE_URL=postgresql://goodgo:goodgo_secret@localhost:5432/goodgo_test?schema=public + +# Services (same as dev, adjust if your test infra differs) +REDIS_URL=redis://localhost:6379 +TYPESENSE_HOST=localhost +TYPESENSE_PORT=8108 +TYPESENSE_PROTOCOL=http +TYPESENSE_API_KEY=ts_dev_key_change_me + +# MinIO +MINIO_ENDPOINT=localhost +MINIO_PORT=9000 +MINIO_ACCESS_KEY=minioadmin +MINIO_SECRET_KEY=minioadmin_secret +MINIO_BUCKET=goodgo-uploads + +# Auth (deterministic secrets for test reproducibility) +JWT_SECRET=e2e-test-jwt-secret-key +JWT_REFRESH_SECRET=e2e-test-refresh-secret-key +JWT_EXPIRES_IN=15m +JWT_REFRESH_EXPIRES_IN=7d +NODE_ENV=test + +# Payment (sandbox) +VNPAY_TMN_CODE=TESTCODE +VNPAY_HASH_SECRET=TESTHASHSECRET +VNPAY_URL=https://sandbox.vnpayment.vn/paymentv2/vpcpay.html +VNPAY_RETURN_URL=http://localhost:3000/payment/return diff --git a/apps/api/src/modules/auth/application/__tests__/login-user.handler.spec.ts b/apps/api/src/modules/auth/application/__tests__/login-user.handler.spec.ts new file mode 100644 index 0000000..1c50ed2 --- /dev/null +++ b/apps/api/src/modules/auth/application/__tests__/login-user.handler.spec.ts @@ -0,0 +1,52 @@ +import { LoginUserCommand } from '../commands/login-user/login-user.command'; +import { LoginUserHandler } from '../commands/login-user/login-user.handler'; + +describe('LoginUserHandler', () => { + let handler: LoginUserHandler; + let mockTokenService: { generateTokenPair: ReturnType }; + + const tokenPair = { + accessToken: 'access-jwt', + refreshToken: 'family.refresh-hex', + expiresIn: 900, + }; + + beforeEach(() => { + mockTokenService = { generateTokenPair: vi.fn().mockResolvedValue(tokenPair) }; + handler = new LoginUserHandler(mockTokenService as any); + }); + + it('generates token pair with correct payload', async () => { + const command = new LoginUserCommand('user-1', '0912345678', 'BUYER'); + const result = await handler.execute(command); + + expect(result).toEqual(tokenPair); + expect(mockTokenService.generateTokenPair).toHaveBeenCalledWith({ + sub: 'user-1', + phone: '0912345678', + role: 'BUYER', + }); + }); + + it('passes AGENT role correctly', async () => { + const command = new LoginUserCommand('user-2', '0987654321', 'AGENT'); + await handler.execute(command); + + expect(mockTokenService.generateTokenPair).toHaveBeenCalledWith({ + sub: 'user-2', + phone: '0987654321', + role: 'AGENT', + }); + }); + + it('passes ADMIN role correctly', async () => { + const command = new LoginUserCommand('admin-1', '0901234567', 'ADMIN'); + await handler.execute(command); + + expect(mockTokenService.generateTokenPair).toHaveBeenCalledWith({ + sub: 'admin-1', + phone: '0901234567', + role: 'ADMIN', + }); + }); +}); diff --git a/apps/api/src/modules/auth/application/__tests__/refresh-token.handler.spec.ts b/apps/api/src/modules/auth/application/__tests__/refresh-token.handler.spec.ts new file mode 100644 index 0000000..d7d46ce --- /dev/null +++ b/apps/api/src/modules/auth/application/__tests__/refresh-token.handler.spec.ts @@ -0,0 +1,122 @@ +import { Phone } from '../../domain/value-objects/phone.vo'; +import { UserEntity } from '../../domain/entities/user.entity'; +import { HashedPassword } from '../../domain/value-objects/hashed-password.vo'; +import { type IUserRepository } from '../../domain/repositories/user.repository'; +import { RefreshTokenCommand } from '../commands/refresh-token/refresh-token.command'; +import { RefreshTokenHandler } from '../commands/refresh-token/refresh-token.handler'; + +function createActiveUser(): UserEntity { + const phone = Phone.create('0912345678').unwrap(); + const pw = { value: 'hashed' } as HashedPassword; + return new UserEntity('user-1', { + email: null, + phone, + passwordHash: pw, + fullName: 'Test User', + avatarUrl: null, + role: 'BUYER', + kycStatus: 'NONE', + kycData: null, + isActive: true, + }); +} + +function createInactiveUser(): UserEntity { + const phone = Phone.create('0912345678').unwrap(); + const pw = { value: 'hashed' } as HashedPassword; + return new UserEntity('user-1', { + email: null, + phone, + passwordHash: pw, + fullName: 'Deactivated User', + avatarUrl: null, + role: 'BUYER', + kycStatus: 'NONE', + kycData: null, + isActive: false, + }); +} + +describe('RefreshTokenHandler', () => { + let handler: RefreshTokenHandler; + let mockTokenService: { + rotateRefreshToken: ReturnType; + generateAccessToken: ReturnType; + }; + let mockUserRepo: { [K in keyof IUserRepository]: ReturnType }; + + beforeEach(() => { + mockTokenService = { + rotateRefreshToken: vi.fn(), + generateAccessToken: vi.fn().mockReturnValue('new-access-jwt'), + }; + mockUserRepo = { + findById: vi.fn(), + findByPhone: vi.fn(), + findByEmail: vi.fn(), + save: vi.fn(), + update: vi.fn(), + }; + + handler = new RefreshTokenHandler( + mockTokenService as any, + mockUserRepo as any, + ); + }); + + it('rotates refresh token and returns new token pair', async () => { + mockTokenService.rotateRefreshToken.mockResolvedValue({ + userId: 'user-1', + refreshToken: 'new-family.new-refresh-hex', + }); + mockUserRepo.findById.mockResolvedValue(createActiveUser()); + + const command = new RefreshTokenCommand('old-family.old-refresh-hex'); + const result = await handler.execute(command); + + expect(result.accessToken).toBe('new-access-jwt'); + expect(result.refreshToken).toBe('new-family.new-refresh-hex'); + expect(result.expiresIn).toBe(900); + expect(mockTokenService.generateAccessToken).toHaveBeenCalledWith( + expect.objectContaining({ sub: 'user-1', phone: '+84912345678', role: 'BUYER' }), + ); + }); + + it('throws UnauthorizedException when refresh token is invalid', async () => { + mockTokenService.rotateRefreshToken.mockResolvedValue(null); + + const command = new RefreshTokenCommand('invalid-token'); + + await expect(handler.execute(command)).rejects.toThrow( + 'Refresh token không hợp lệ hoặc đã hết hạn', + ); + }); + + it('throws UnauthorizedException when user not found', async () => { + mockTokenService.rotateRefreshToken.mockResolvedValue({ + userId: 'deleted-user', + refreshToken: 'new-family.new-hex', + }); + mockUserRepo.findById.mockResolvedValue(null); + + const command = new RefreshTokenCommand('family.valid-hex'); + + await expect(handler.execute(command)).rejects.toThrow( + 'Tài khoản không tồn tại hoặc đã bị vô hiệu hóa', + ); + }); + + it('throws UnauthorizedException when user is deactivated', async () => { + mockTokenService.rotateRefreshToken.mockResolvedValue({ + userId: 'user-1', + refreshToken: 'new-family.new-hex', + }); + mockUserRepo.findById.mockResolvedValue(createInactiveUser()); + + const command = new RefreshTokenCommand('family.valid-hex'); + + await expect(handler.execute(command)).rejects.toThrow( + 'Tài khoản không tồn tại hoặc đã bị vô hiệu hóa', + ); + }); +}); diff --git a/apps/api/src/modules/auth/application/__tests__/register-user.handler.spec.ts b/apps/api/src/modules/auth/application/__tests__/register-user.handler.spec.ts new file mode 100644 index 0000000..c06faae --- /dev/null +++ b/apps/api/src/modules/auth/application/__tests__/register-user.handler.spec.ts @@ -0,0 +1,106 @@ +import { type IUserRepository } from '../../domain/repositories/user.repository'; +import { Phone } from '../../domain/value-objects/phone.vo'; +import { RegisterUserCommand } from '../commands/register-user/register-user.command'; +import { RegisterUserHandler } from '../commands/register-user/register-user.handler'; + +describe('RegisterUserHandler', () => { + let handler: RegisterUserHandler; + let mockUserRepo: { [K in keyof IUserRepository]: ReturnType }; + let mockTokenService: { generateTokenPair: ReturnType }; + let mockEventBus: { publish: ReturnType }; + + const tokenPair = { + accessToken: 'access-jwt', + refreshToken: 'family.refresh-hex', + expiresIn: 900, + }; + + beforeEach(() => { + mockUserRepo = { + findById: vi.fn(), + findByPhone: vi.fn().mockResolvedValue(null), + findByEmail: vi.fn().mockResolvedValue(null), + save: vi.fn().mockResolvedValue(undefined), + update: vi.fn(), + }; + mockTokenService = { generateTokenPair: vi.fn().mockResolvedValue(tokenPair) }; + mockEventBus = { publish: vi.fn() }; + + handler = new RegisterUserHandler( + mockUserRepo as any, + mockTokenService as any, + mockEventBus as any, + ); + }); + + it('registers a new user and returns token pair', async () => { + const command = new RegisterUserCommand('0912345678', 'StrongP@ss1', 'Nguyen Van A'); + const result = await handler.execute(command); + + expect(result).toEqual(tokenPair); + expect(mockUserRepo.save).toHaveBeenCalledTimes(1); + expect(mockTokenService.generateTokenPair).toHaveBeenCalledWith( + expect.objectContaining({ phone: '+84912345678', role: 'BUYER' }), + ); + expect(mockEventBus.publish).toHaveBeenCalled(); + }); + + it('registers user with optional email', async () => { + const command = new RegisterUserCommand( + '0912345678', 'StrongP@ss1', 'Nguyen Van A', 'test@example.com', + ); + const result = await handler.execute(command); + + expect(result).toEqual(tokenPair); + expect(mockUserRepo.findByEmail).toHaveBeenCalledWith('test@example.com'); + expect(mockUserRepo.save).toHaveBeenCalledTimes(1); + }); + + it('throws ConflictException when phone already exists', async () => { + mockUserRepo.findByPhone.mockResolvedValue({ id: 'existing-user' }); + + const command = new RegisterUserCommand('0912345678', 'StrongP@ss1', 'Nguyen Van A'); + + await expect(handler.execute(command)).rejects.toThrow('Số điện thoại đã được đăng ký'); + }); + + it('throws ConflictException when email already exists', async () => { + mockUserRepo.findByEmail.mockResolvedValue({ id: 'existing-user' }); + + const command = new RegisterUserCommand( + '0912345678', 'StrongP@ss1', 'Nguyen Van A', 'taken@example.com', + ); + + await expect(handler.execute(command)).rejects.toThrow('Email đã được đăng ký'); + }); + + it('throws ValidationException for invalid phone number', async () => { + const command = new RegisterUserCommand('12345', 'StrongP@ss1', 'Nguyen Van A'); + + await expect(handler.execute(command)).rejects.toThrow(); + }); + + it('throws ValidationException for invalid email format', async () => { + const command = new RegisterUserCommand( + '0912345678', 'StrongP@ss1', 'Nguyen Van A', 'not-an-email', + ); + + await expect(handler.execute(command)).rejects.toThrow(); + }); + + it('publishes UserRegisteredEvent after saving', async () => { + const command = new RegisterUserCommand('0912345678', 'StrongP@ss1', 'Nguyen Van A'); + await handler.execute(command); + + expect(mockEventBus.publish).toHaveBeenCalledWith( + expect.objectContaining({ eventName: 'user.registered' }), + ); + }); + + it('does not save user if phone validation fails', async () => { + const command = new RegisterUserCommand('invalid', 'StrongP@ss1', 'Nguyen Van A'); + + await expect(handler.execute(command)).rejects.toThrow(); + expect(mockUserRepo.save).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/modules/auth/infrastructure/__tests__/token.service.spec.ts b/apps/api/src/modules/auth/infrastructure/__tests__/token.service.spec.ts new file mode 100644 index 0000000..a84c7b9 --- /dev/null +++ b/apps/api/src/modules/auth/infrastructure/__tests__/token.service.spec.ts @@ -0,0 +1,158 @@ +import { type IRefreshTokenRepository, type RefreshTokenRecord } from '../../domain/repositories/refresh-token.repository'; +import { TokenService } from '../services/token.service'; + +describe('TokenService', () => { + let service: TokenService; + let mockJwtService: { sign: ReturnType; verify: ReturnType }; + let mockRefreshTokenRepo: { [K in keyof IRefreshTokenRepository]: ReturnType }; + + const payload = { sub: 'user-1', phone: '0912345678', role: 'BUYER' }; + + beforeEach(() => { + mockJwtService = { + sign: vi.fn().mockReturnValue('signed-jwt'), + verify: vi.fn(), + }; + mockRefreshTokenRepo = { + create: vi.fn().mockResolvedValue({} as RefreshTokenRecord), + findByToken: vi.fn(), + revokeByFamily: vi.fn().mockResolvedValue(undefined), + revokeAllForUser: vi.fn().mockResolvedValue(undefined), + deleteExpired: vi.fn(), + }; + + service = new TokenService( + mockJwtService as any, + mockRefreshTokenRepo as any, + ); + }); + + describe('generateTokenPair', () => { + it('returns access token, refresh token with family prefix, and expiresIn', async () => { + const result = await service.generateTokenPair(payload); + + expect(result.accessToken).toBe('signed-jwt'); + expect(result.refreshToken).toContain('.'); + expect(result.expiresIn).toBe(900); + expect(mockJwtService.sign).toHaveBeenCalledWith(payload); + expect(mockRefreshTokenRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'user-1', + revokedAt: null, + }), + ); + }); + + it('creates refresh token record with 30-day expiry', async () => { + await service.generateTokenPair(payload); + + const createCall = mockRefreshTokenRepo.create.mock.calls[0][0]; + const expiresAt = createCall.expiresAt as Date; + const now = new Date(); + const daysDiff = Math.round((expiresAt.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)); + expect(daysDiff).toBeGreaterThanOrEqual(29); + expect(daysDiff).toBeLessThanOrEqual(31); + }); + }); + + describe('rotateRefreshToken', () => { + const makeExistingToken = (overrides?: Partial): RefreshTokenRecord => ({ + id: 'rt-1', + userId: 'user-1', + token: 'hashed-token', + family: 'old-family', + expiresAt: new Date(Date.now() + 86400000), + revokedAt: null, + createdAt: new Date(), + ...overrides, + }); + + it('rotates valid token: revokes old family, creates new token', async () => { + mockRefreshTokenRepo.findByToken.mockResolvedValue(makeExistingToken()); + mockRefreshTokenRepo.create.mockResolvedValue({} as RefreshTokenRecord); + + const result = await service.rotateRefreshToken('old-family.raw-token-hex'); + + expect(result).not.toBeNull(); + expect(result!.userId).toBe('user-1'); + expect(result!.refreshToken).toContain('.'); + expect(mockRefreshTokenRepo.revokeByFamily).toHaveBeenCalledWith('old-family'); + expect(mockRefreshTokenRepo.create).toHaveBeenCalled(); + }); + + it('returns null for malformed token (no dot separator)', async () => { + const result = await service.rotateRefreshToken('no-dot-separator'); + expect(result).toBeNull(); + }); + + it('returns null and revokes family when token not found (reuse attack)', async () => { + mockRefreshTokenRepo.findByToken.mockResolvedValue(null); + + const result = await service.rotateRefreshToken('suspect-family.unknown-token'); + + expect(result).toBeNull(); + expect(mockRefreshTokenRepo.revokeByFamily).toHaveBeenCalledWith('suspect-family'); + }); + + it('returns null and revokes family when token is already revoked', async () => { + mockRefreshTokenRepo.findByToken.mockResolvedValue( + makeExistingToken({ revokedAt: new Date() }), + ); + + const result = await service.rotateRefreshToken('old-family.revoked-token'); + + expect(result).toBeNull(); + expect(mockRefreshTokenRepo.revokeByFamily).toHaveBeenCalled(); + }); + + it('returns null and revokes family when token is expired', async () => { + mockRefreshTokenRepo.findByToken.mockResolvedValue( + makeExistingToken({ expiresAt: new Date(Date.now() - 86400000) }), + ); + + const result = await service.rotateRefreshToken('old-family.expired-token'); + + expect(result).toBeNull(); + expect(mockRefreshTokenRepo.revokeByFamily).toHaveBeenCalled(); + }); + + it('returns null for empty family segment', async () => { + const result = await service.rotateRefreshToken('.some-raw-token'); + expect(result).toBeNull(); + }); + + it('returns null for empty raw token segment', async () => { + const result = await service.rotateRefreshToken('some-family.'); + expect(result).toBeNull(); + }); + }); + + describe('generateAccessToken', () => { + it('delegates to jwtService.sign', () => { + const token = service.generateAccessToken(payload); + expect(token).toBe('signed-jwt'); + expect(mockJwtService.sign).toHaveBeenCalledWith(payload); + }); + }); + + describe('revokeAllUserTokens', () => { + it('revokes all tokens for a user', async () => { + await service.revokeAllUserTokens('user-1'); + expect(mockRefreshTokenRepo.revokeAllForUser).toHaveBeenCalledWith('user-1'); + }); + }); + + describe('verifyAccessToken', () => { + it('returns decoded payload for valid token', () => { + mockJwtService.verify.mockReturnValue(payload); + const result = service.verifyAccessToken('valid-jwt'); + expect(result).toEqual(payload); + }); + + it('returns null for invalid token', () => { + mockJwtService.verify.mockImplementation(() => { throw new Error('invalid'); }); + const result = service.verifyAccessToken('bad-jwt'); + expect(result).toBeNull(); + }); + }); +}); diff --git a/apps/api/src/modules/payments/application/__tests__/handle-callback-edge-cases.handler.spec.ts b/apps/api/src/modules/payments/application/__tests__/handle-callback-edge-cases.handler.spec.ts new file mode 100644 index 0000000..5da3644 --- /dev/null +++ b/apps/api/src/modules/payments/application/__tests__/handle-callback-edge-cases.handler.spec.ts @@ -0,0 +1,195 @@ +import { PaymentEntity } from '../../domain/entities/payment.entity'; +import { type IPaymentRepository } from '../../domain/repositories/payment.repository'; +import { Money } from '../../domain/value-objects/money.vo'; +import { HandleCallbackCommand } from '../commands/handle-callback/handle-callback.command'; +import { HandleCallbackHandler } from '../commands/handle-callback/handle-callback.handler'; + +function createPaymentEntity( + status: 'PENDING' | 'PROCESSING' | 'COMPLETED' | 'FAILED' = 'PROCESSING', + provider: 'VNPAY' | 'MOMO' | 'ZALOPAY' = 'VNPAY', +): PaymentEntity { + const money = Money.create(500_000n).unwrap(); + const payment = PaymentEntity.createNew('pay-1', 'user-1', provider, 'SUBSCRIPTION', money); + if (status === 'PROCESSING') payment.markProcessing('provider-tx-1'); + if (status === 'COMPLETED') { + payment.markProcessing('provider-tx-1'); + payment.markCompleted({ verified: true }); + } + if (status === 'FAILED') { + payment.markProcessing('provider-tx-1'); + payment.markFailed({ reason: 'declined' }); + } + payment.clearDomainEvents(); + return payment; +} + +describe('HandleCallbackHandler — edge cases', () => { + let handler: HandleCallbackHandler; + let mockPaymentRepo: { [K in keyof IPaymentRepository]: ReturnType }; + let mockGatewayFactory: { getGateway: ReturnType }; + let mockGateway: { verifyCallback: ReturnType; createPaymentUrl: ReturnType; refund: ReturnType }; + let mockEventBus: { publish: ReturnType }; + + beforeEach(() => { + mockPaymentRepo = { + findById: vi.fn(), + findByProviderTxId: vi.fn(), + findByIdempotencyKey: vi.fn(), + findByUserId: vi.fn(), + save: vi.fn(), + update: vi.fn(), + updateIfStatus: vi.fn(), + }; + mockGateway = { + verifyCallback: vi.fn(), + createPaymentUrl: vi.fn(), + refund: vi.fn(), + }; + mockGatewayFactory = { getGateway: vi.fn().mockReturnValue(mockGateway) }; + mockEventBus = { publish: vi.fn() }; + + handler = new HandleCallbackHandler( + mockPaymentRepo as any, + mockGatewayFactory as any, + mockEventBus as any, + ); + }); + + it('returns idempotent response for duplicate success callback on COMPLETED payment', async () => { + const completedPayment = createPaymentEntity('COMPLETED'); + mockGateway.verifyCallback.mockReturnValue({ + isValid: true, + orderId: 'pay-1', + providerTxId: 'provider-tx-1', + isSuccess: true, + rawData: { responseCode: '00' }, + }); + // updateIfStatus returns null because payment is already COMPLETED + mockPaymentRepo.updateIfStatus.mockResolvedValue(null); + mockPaymentRepo.findById.mockResolvedValue(completedPayment); + + const command = new HandleCallbackCommand('VNPAY', { vnp_ResponseCode: '00' }); + const result = await handler.execute(command); + + expect(result.paymentId).toBe('pay-1'); + expect(result.status).toBe('COMPLETED'); + expect(result.isSuccess).toBe(true); + // No new events should be published for idempotent response + expect(mockEventBus.publish).not.toHaveBeenCalled(); + }); + + it('returns idempotent response for duplicate callback on FAILED payment', async () => { + const failedPayment = createPaymentEntity('FAILED'); + mockGateway.verifyCallback.mockReturnValue({ + isValid: true, + orderId: 'pay-1', + providerTxId: 'provider-tx-1', + isSuccess: false, + rawData: { responseCode: '24' }, + }); + mockPaymentRepo.updateIfStatus.mockResolvedValue(null); + mockPaymentRepo.findById.mockResolvedValue(failedPayment); + + const command = new HandleCallbackCommand('VNPAY', { vnp_ResponseCode: '24' }); + const result = await handler.execute(command); + + expect(result.paymentId).toBe('pay-1'); + expect(result.status).toBe('FAILED'); + expect(result.isSuccess).toBe(false); + expect(mockEventBus.publish).not.toHaveBeenCalled(); + }); + + it('uses correct gateway for MoMo provider', async () => { + const payment = createPaymentEntity('PROCESSING', 'MOMO'); + mockGateway.verifyCallback.mockReturnValue({ + isValid: true, + orderId: 'pay-1', + providerTxId: 'momo-tx-1', + isSuccess: true, + rawData: { resultCode: 0 }, + }); + mockPaymentRepo.updateIfStatus.mockResolvedValue(payment); + + const command = new HandleCallbackCommand('MOMO', { resultCode: 0 }); + await handler.execute(command); + + expect(mockGatewayFactory.getGateway).toHaveBeenCalledWith('MOMO'); + }); + + it('uses correct gateway for ZaloPay provider', async () => { + const payment = createPaymentEntity('PROCESSING', 'ZALOPAY'); + mockGateway.verifyCallback.mockReturnValue({ + isValid: true, + orderId: 'pay-1', + providerTxId: 'zalo-tx-1', + isSuccess: true, + rawData: { return_code: 1 }, + }); + mockPaymentRepo.updateIfStatus.mockResolvedValue(payment); + + const command = new HandleCallbackCommand('ZALOPAY', { return_code: 1 }); + await handler.execute(command); + + expect(mockGatewayFactory.getGateway).toHaveBeenCalledWith('ZALOPAY'); + }); + + it('publishes PaymentFailedEvent on failed callback', async () => { + const payment = createPaymentEntity('PROCESSING'); + mockGateway.verifyCallback.mockReturnValue({ + isValid: true, + orderId: 'pay-1', + providerTxId: 'provider-tx-1', + isSuccess: false, + rawData: { responseCode: '99' }, + }); + mockPaymentRepo.updateIfStatus.mockResolvedValue(payment); + + const command = new HandleCallbackCommand('VNPAY', { vnp_ResponseCode: '99' }); + const result = await handler.execute(command); + + expect(result.isSuccess).toBe(false); + expect(mockEventBus.publish).toHaveBeenCalledWith( + expect.objectContaining({ eventName: 'payment.failed' }), + ); + }); + + it('publishes PaymentCompletedEvent on successful callback', async () => { + const payment = createPaymentEntity('PROCESSING'); + mockGateway.verifyCallback.mockReturnValue({ + isValid: true, + orderId: 'pay-1', + providerTxId: 'provider-tx-1', + isSuccess: true, + rawData: { responseCode: '00' }, + }); + mockPaymentRepo.updateIfStatus.mockResolvedValue(payment); + + const command = new HandleCallbackCommand('VNPAY', { vnp_ResponseCode: '00' }); + const result = await handler.execute(command); + + expect(result.isSuccess).toBe(true); + expect(mockEventBus.publish).toHaveBeenCalledWith( + expect.objectContaining({ eventName: 'payment.completed' }), + ); + }); + + it('handles concurrent callbacks — second callback gets idempotent response', async () => { + // Simulate: first callback succeeds, second callback arrives and updateIfStatus returns null + const completedPayment = createPaymentEntity('COMPLETED'); + mockGateway.verifyCallback.mockReturnValue({ + isValid: true, + orderId: 'pay-1', + providerTxId: 'provider-tx-1', + isSuccess: true, + rawData: { responseCode: '00' }, + }); + mockPaymentRepo.updateIfStatus.mockResolvedValue(null); + mockPaymentRepo.findById.mockResolvedValue(completedPayment); + + const command = new HandleCallbackCommand('VNPAY', { vnp_ResponseCode: '00' }); + const result = await handler.execute(command); + + expect(result.status).toBe('COMPLETED'); + expect(mockEventBus.publish).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/modules/shared/infrastructure/__tests__/throttler-behind-proxy.guard.spec.ts b/apps/api/src/modules/shared/infrastructure/__tests__/throttler-behind-proxy.guard.spec.ts new file mode 100644 index 0000000..3b308e1 --- /dev/null +++ b/apps/api/src/modules/shared/infrastructure/__tests__/throttler-behind-proxy.guard.spec.ts @@ -0,0 +1,41 @@ +import { ThrottlerBehindProxyGuard } from '../guards/throttler-behind-proxy.guard'; + +describe('ThrottlerBehindProxyGuard', () => { + let guard: ThrottlerBehindProxyGuard; + + beforeEach(() => { + // Create instance without DI — we only test the overridden getTracker method + guard = Object.create(ThrottlerBehindProxyGuard.prototype); + }); + + it('extracts first IP from X-Forwarded-For header', async () => { + const req = { headers: { 'x-forwarded-for': '203.0.113.1, 70.41.3.18, 150.172.238.178' }, ip: '127.0.0.1' }; + // Access the protected method via bracket notation + const ip = await (guard as any).getTracker(req); + expect(ip).toBe('203.0.113.1'); + }); + + it('trims whitespace from forwarded IP', async () => { + const req = { headers: { 'x-forwarded-for': ' 10.0.0.1 , 192.168.1.1' }, ip: '127.0.0.1' }; + const ip = await (guard as any).getTracker(req); + expect(ip).toBe('10.0.0.1'); + }); + + it('falls back to req.ip when no X-Forwarded-For header', async () => { + const req = { headers: {}, ip: '192.168.1.100' }; + const ip = await (guard as any).getTracker(req); + expect(ip).toBe('192.168.1.100'); + }); + + it('returns 127.0.0.1 when no IP available', async () => { + const req = { headers: {}, ip: undefined }; + const ip = await (guard as any).getTracker(req); + expect(ip).toBe('127.0.0.1'); + }); + + it('handles array-typed x-forwarded-for by falling back to req.ip', async () => { + const req = { headers: { 'x-forwarded-for': ['203.0.113.1', '10.0.0.1'] }, ip: '172.16.0.1' }; + const ip = await (guard as any).getTracker(req); + expect(ip).toBe('172.16.0.1'); + }); +}); diff --git a/apps/api/src/modules/subscriptions/domain/__tests__/subscription-lifecycle.spec.ts b/apps/api/src/modules/subscriptions/domain/__tests__/subscription-lifecycle.spec.ts new file mode 100644 index 0000000..afade69 --- /dev/null +++ b/apps/api/src/modules/subscriptions/domain/__tests__/subscription-lifecycle.spec.ts @@ -0,0 +1,167 @@ +import { SubscriptionEntity } from '../entities/subscription.entity'; + +describe('SubscriptionEntity — lifecycle edge cases', () => { + const makeSub = (status?: 'ACTIVE' | 'PAST_DUE' | 'CANCELLED' | 'EXPIRED') => { + const sub = SubscriptionEntity.createNew( + 'sub-1', + 'user-1', + 'plan-1', + 'AGENT_PRO', + new Date('2026-01-01'), + new Date('2026-02-01'), + ); + sub.clearDomainEvents(); + + if (status === 'PAST_DUE') sub.markPastDue(); + if (status === 'CANCELLED') sub.cancel(); + if (status === 'EXPIRED') sub.markExpired(); + + return sub; + }; + + describe('markExpired', () => { + it('transitions ACTIVE to EXPIRED', () => { + const sub = makeSub('ACTIVE'); + sub.markExpired(); + expect(sub.status).toBe('EXPIRED'); + }); + + it('transitions PAST_DUE to EXPIRED', () => { + const sub = makeSub('PAST_DUE'); + sub.markExpired(); + expect(sub.status).toBe('EXPIRED'); + }); + + it('throws when marking CANCELLED as expired', () => { + const sub = makeSub('CANCELLED'); + expect(() => sub.markExpired()).toThrow('Không thể đánh dấu hết hạn'); + }); + + it('throws when already expired', () => { + const sub = makeSub('EXPIRED'); + expect(() => sub.markExpired()).toThrow('Không thể đánh dấu hết hạn'); + }); + }); + + describe('markPastDue', () => { + it('transitions ACTIVE to PAST_DUE', () => { + const sub = makeSub('ACTIVE'); + sub.markPastDue(); + expect(sub.status).toBe('PAST_DUE'); + }); + + it('throws when marking PAST_DUE as past due again', () => { + const sub = makeSub('PAST_DUE'); + expect(() => sub.markPastDue()).toThrow('Không thể đánh dấu quá hạn'); + }); + + it('throws when marking CANCELLED as past due', () => { + const sub = makeSub('CANCELLED'); + expect(() => sub.markPastDue()).toThrow('Không thể đánh dấu quá hạn'); + }); + + it('throws when marking EXPIRED as past due', () => { + const sub = makeSub('EXPIRED'); + expect(() => sub.markPastDue()).toThrow('Không thể đánh dấu quá hạn'); + }); + }); + + describe('renewPeriod', () => { + it('renews from PAST_DUE back to ACTIVE with new dates', () => { + const sub = makeSub('PAST_DUE'); + const newStart = new Date('2026-02-01'); + const newEnd = new Date('2026-03-01'); + + sub.renewPeriod(newStart, newEnd); + + expect(sub.status).toBe('ACTIVE'); + expect(sub.currentPeriodStart).toEqual(newStart); + expect(sub.currentPeriodEnd).toEqual(newEnd); + }); + + it('renews from ACTIVE (extending period)', () => { + const sub = makeSub('ACTIVE'); + const newStart = new Date('2026-03-01'); + const newEnd = new Date('2026-04-01'); + + sub.renewPeriod(newStart, newEnd); + + expect(sub.status).toBe('ACTIVE'); + expect(sub.currentPeriodStart).toEqual(newStart); + expect(sub.currentPeriodEnd).toEqual(newEnd); + }); + + it('renews from EXPIRED back to ACTIVE', () => { + const sub = makeSub('EXPIRED'); + const newStart = new Date('2026-04-01'); + const newEnd = new Date('2026-05-01'); + + sub.renewPeriod(newStart, newEnd); + + expect(sub.status).toBe('ACTIVE'); + }); + + it('renews from CANCELLED back to ACTIVE', () => { + const sub = makeSub('CANCELLED'); + const newStart = new Date('2026-04-01'); + const newEnd = new Date('2026-05-01'); + + sub.renewPeriod(newStart, newEnd); + + expect(sub.status).toBe('ACTIVE'); + }); + }); + + describe('isExpired', () => { + it('returns true when period end is in the past', () => { + const sub = SubscriptionEntity.createNew( + 'sub-2', + 'user-1', + 'plan-1', + 'FREE', + new Date('2020-01-01'), + new Date('2020-02-01'), + ); + expect(sub.isExpired()).toBe(true); + }); + + it('returns false when period end is in the future', () => { + const sub = SubscriptionEntity.createNew( + 'sub-3', + 'user-1', + 'plan-1', + 'FREE', + new Date('2030-01-01'), + new Date('2030-02-01'), + ); + expect(sub.isExpired()).toBe(false); + }); + }); + + describe('upgrade constraints', () => { + it('throws when upgrading from PAST_DUE', () => { + const sub = makeSub('PAST_DUE'); + expect(() => sub.upgrade('plan-2', 'ENTERPRISE')).toThrow(); + }); + + it('throws when upgrading from EXPIRED', () => { + const sub = makeSub('EXPIRED'); + expect(() => sub.upgrade('plan-2', 'ENTERPRISE')).toThrow(); + }); + }); + + describe('cancel constraints', () => { + it('cancels from PAST_DUE', () => { + const sub = makeSub('PAST_DUE'); + sub.cancel(); + expect(sub.status).toBe('CANCELLED'); + expect(sub.cancelledAt).not.toBeNull(); + }); + + it('cancels from ACTIVE', () => { + const sub = makeSub('ACTIVE'); + sub.cancel(); + expect(sub.status).toBe('CANCELLED'); + }); + }); +}); diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts new file mode 100644 index 0000000..fe890a8 --- /dev/null +++ b/e2e/global-setup.ts @@ -0,0 +1,55 @@ +/* eslint-disable no-console */ +import { execSync } from 'node:child_process'; +import path from 'node:path'; + +/** + * Playwright globalSetup — runs once before all E2E tests. + * + * 1. Loads .env.test (if present) so DATABASE_URL points to the test DB. + * 2. Runs Prisma migrations against the test database. + * 3. Seeds the test database with sample data. + */ +export default async function globalSetup() { + const root = path.resolve(__dirname, '..'); + const envTestPath = path.join(root, '.env.test'); + const isCI = !!process.env.CI; + + // In CI, env vars are already set by the workflow; locally, load .env.test + if (!isCI) { + const { config } = await import('dotenv'); + config({ path: envTestPath, override: true }); + } + + const databaseUrl = process.env.DATABASE_URL; + if (!databaseUrl) { + throw new Error( + 'DATABASE_URL is not set. Create .env.test or set it in your environment.', + ); + } + + // In CI, the workflow already runs migrations + seed before Playwright. + // Skip to avoid duplicate work; only validate DATABASE_URL is set. + if (isCI) { + console.log('[E2E globalSetup] CI detected — skipping migrations/seed (handled by workflow).'); + return; + } + + console.log('\n[E2E globalSetup] Preparing test database...'); + console.log(`[E2E globalSetup] DATABASE_URL = ${databaseUrl.replace(/\/\/.*@/, '//***@')}`); + + const execOpts = { + cwd: root, + stdio: 'inherit' as const, + env: { ...process.env, DATABASE_URL: databaseUrl }, + }; + + // Run migrations (deploy = no interactive prompts, safe for test) + console.log('[E2E globalSetup] Running prisma migrate deploy...'); + execSync('npx prisma migrate deploy', execOpts); + + // Seed database (upserts are idempotent) + console.log('[E2E globalSetup] Seeding test database...'); + execSync('npx prisma db seed', execOpts); + + console.log('[E2E globalSetup] Test database ready.\n'); +} diff --git a/e2e/global-teardown.ts b/e2e/global-teardown.ts new file mode 100644 index 0000000..6a0db38 --- /dev/null +++ b/e2e/global-teardown.ts @@ -0,0 +1,78 @@ +/* eslint-disable no-console */ +import path from 'node:path'; + +/** + * Playwright globalTeardown — runs once after all E2E tests. + * + * Cleans up test-generated data (users, listings, etc.) while preserving + * seed data for the next run. This ensures isolation between test runs. + */ +export default async function globalTeardown() { + const root = path.resolve(__dirname, '..'); + const isCI = !!process.env.CI; + + if (!isCI) { + const { config } = await import('dotenv'); + config({ path: path.join(root, '.env.test'), override: true }); + } + + const databaseUrl = process.env.DATABASE_URL; + if (!databaseUrl) { + console.warn('[E2E globalTeardown] DATABASE_URL not set, skipping cleanup.'); + return; + } + + console.log('\n[E2E globalTeardown] Cleaning up test-generated data...'); + + // Dynamic import to avoid top-level side effects + const pg = await import('pg'); + const pool = new pg.default.Pool({ connectionString: databaseUrl }); + + try { + // Delete test-generated records (those NOT created by seed). + // Seed data uses known IDs (prop-1..prop-5, listing-1..listing-5) + // and known phones (0900000001..0900000005). + // Test fixtures generate users with phone starting with '09' + timestamp digits. + // + // Order matters due to foreign key constraints. + // Seed phones and IDs to preserve between runs + const SEED_PHONES = `('0900000001','0900000002','0900000003','0900000004','0900000005')`; + const SEED_LISTING_IDS = `('listing-1','listing-2','listing-3','listing-4','listing-5')`; + const SEED_PROP_IDS = `('prop-1','prop-2','prop-3','prop-4','prop-5')`; + const NON_SEED_USERS = `SELECT id FROM "User" WHERE phone NOT IN ${SEED_PHONES}`; + + await pool.query(` + -- Delete test-generated data in dependency order (FK-safe) + DELETE FROM "NotificationLog" WHERE "userId" IN (${NON_SEED_USERS}); + DELETE FROM "NotificationPreference" WHERE "userId" IN (${NON_SEED_USERS}); + DELETE FROM "Review" WHERE "reviewerId" IN (${NON_SEED_USERS}); + DELETE FROM "Lead" WHERE "userId" IN (${NON_SEED_USERS}); + DELETE FROM "Inquiry" WHERE "userId" IN (${NON_SEED_USERS}); + DELETE FROM "Transaction" WHERE "sellerId" IN (${NON_SEED_USERS}); + DELETE FROM "Payment" WHERE "userId" IN (${NON_SEED_USERS}); + DELETE FROM "UsageRecord" WHERE "subscriptionId" IN ( + SELECT s.id FROM "Subscription" s + JOIN "User" u ON s."userId" = u.id + WHERE u.phone NOT IN ${SEED_PHONES} + ); + DELETE FROM "Subscription" WHERE "userId" IN (${NON_SEED_USERS}); + DELETE FROM "Valuation" WHERE "propertyId" NOT IN ${SEED_PROP_IDS}; + DELETE FROM "ListingMedia" WHERE "listingId" NOT IN ${SEED_LISTING_IDS}; + DELETE FROM "Listing" WHERE id NOT IN ${SEED_LISTING_IDS}; + DELETE FROM "PropertyMedia" WHERE "propertyId" NOT IN ${SEED_PROP_IDS}; + DELETE FROM "Property" WHERE id NOT IN ${SEED_PROP_IDS}; + DELETE FROM "Agent" WHERE "userId" IN (${NON_SEED_USERS}); + -- RefreshToken and OAuthAccount cascade from User, but delete explicitly for safety + DELETE FROM "RefreshToken" WHERE "userId" IN (${NON_SEED_USERS}); + DELETE FROM "OAuthAccount" WHERE "userId" IN (${NON_SEED_USERS}); + DELETE FROM "SavedSearch" WHERE "userId" IN (${NON_SEED_USERS}); + DELETE FROM "User" WHERE phone NOT IN ${SEED_PHONES}; + `); + + console.log('[E2E globalTeardown] Test data cleaned up successfully.\n'); + } catch (err) { + console.error('[E2E globalTeardown] Cleanup error (non-fatal):', err); + } finally { + await pool.end(); + } +} diff --git a/package.json b/package.json index 6e99fd7..39ec3f6 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,9 @@ "devDependencies": { "@eslint/js": "^9.39.4", "@playwright/test": "^1.59.1", + "@types/pg": "^8.20.0", "dependency-cruiser": "^17.3.10", + "dotenv": "^17.4.1", "eslint": "^9.39.4", "eslint-config-prettier": "^10.1.8", "eslint-import-resolver-typescript": "^4.4.4", @@ -58,6 +60,7 @@ "globals": "^17.4.0", "husky": "^9.1.7", "lint-staged": "^16.4.0", + "pg": "^8.20.0", "prettier": "^3.8.1", "prisma": "^7.7.0", "tsx": "^4.21.0", diff --git a/playwright.config.ts b/playwright.config.ts index 76ded80..524c99c 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,4 +1,11 @@ +import path from 'node:path'; import { defineConfig, devices } from '@playwright/test'; +import { config } from 'dotenv'; + +// Load .env.test so webServer processes and tests use the test database +if (!process.env.CI) { + config({ path: path.resolve(__dirname, '.env.test'), override: true }); +} /** * Playwright E2E configuration for Goodgo Platform. @@ -6,9 +13,15 @@ import { defineConfig, devices } from '@playwright/test'; * Projects: * - "api" — tests against the NestJS API (port 3001) * - "web" — tests against the Next.js frontend (port 3000) + * + * Database isolation: + * - globalSetup runs migrations + seed on the test DB + * - globalTeardown cleans up test-generated data after all tests */ export default defineConfig({ testDir: './e2e', + globalSetup: './e2e/global-setup.ts', + globalTeardown: './e2e/global-teardown.ts', fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, @@ -50,6 +63,7 @@ export default defineConfig({ timeout: 60_000, env: { NODE_ENV: 'test', + DATABASE_URL: process.env.DATABASE_URL ?? '', }, }, { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d040834..d22c307 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,6 +18,9 @@ importers: '@playwright/test': specifier: ^1.59.1 version: 1.59.1 + '@types/pg': + specifier: ^8.20.0 + version: 8.20.0 dependency-cruiser: specifier: ^17.3.10 version: 17.3.10 @@ -45,6 +48,9 @@ importers: lint-staged: specifier: ^16.4.0 version: 16.4.0 + pg: + specifier: ^8.20.0 + version: 8.20.0 prettier: specifier: ^3.8.1 version: 3.8.1