diff --git a/apps/api/src/modules/auth/presentation/__tests__/auth-guards-branch-coverage.spec.ts b/apps/api/src/modules/auth/presentation/__tests__/auth-guards-branch-coverage.spec.ts new file mode 100644 index 0000000..d2745f8 --- /dev/null +++ b/apps/api/src/modules/auth/presentation/__tests__/auth-guards-branch-coverage.spec.ts @@ -0,0 +1,92 @@ +/** + * Supplemental branch-coverage tests for auth guards. + * Covers: OptionalJwtAuthGuard.handleRequest pass-through, + * RolesGuard x-forwarded-for array/string ip extraction. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { OptionalJwtAuthGuard } from '../guards/optional-jwt-auth.guard'; +import { RolesGuard } from '../guards/roles.guard'; +import { ROLES_KEY } from '../decorators/roles.decorator'; + +describe('OptionalJwtAuthGuard — handleRequest branch coverage', () => { + it('handleRequest returns user when user is provided', () => { + const guard = new OptionalJwtAuthGuard(); + const fakeUser = { sub: 'user-1', role: 'BUYER' }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = (guard as any).handleRequest(null, fakeUser); + expect(result).toBe(fakeUser); + }); + + it('handleRequest returns undefined when user is falsy (anonymous)', () => { + const guard = new OptionalJwtAuthGuard(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = (guard as any).handleRequest(null, undefined); + expect(result).toBeUndefined(); + }); + + it('handleRequest returns false for unauthenticated passport result', () => { + const guard = new OptionalJwtAuthGuard(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = (guard as any).handleRequest(null, false); + expect(result).toBe(false); + }); + + it('handleRequest ignores error and returns user', () => { + const guard = new OptionalJwtAuthGuard(); + const fakeUser = { sub: 'user-2' }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = (guard as any).handleRequest(new Error('invalid token'), fakeUser); + expect(result).toBe(fakeUser); + }); +}); + +describe('RolesGuard — ip extraction branch coverage', () => { + let guard: RolesGuard; + let mockReflector: { getAllAndOverride: ReturnType }; + let mockLogger: { warn: ReturnType }; + + beforeEach(() => { + mockReflector = { getAllAndOverride: vi.fn() }; + mockLogger = { warn: vi.fn() }; + guard = new RolesGuard(mockReflector as any, mockLogger as any); + }); + + it('uses x-forwarded-for header for ip when req.ip is absent', () => { + mockReflector.getAllAndOverride.mockReturnValue(['ADMIN']); + const mockRequest = { + user: { sub: 'u1', role: 'BUYER' }, + ip: undefined, + headers: { 'x-forwarded-for': '203.0.113.1' }, + }; + const ctx = { + switchToHttp: () => ({ getRequest: () => mockRequest }), + getHandler: () => ({ name: 'h' }), + getClass: () => ({ name: 'C' }), + } as any; + + const result = guard.canActivate(ctx); + expect(result).toBe(false); + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('Access denied'), + 'RolesGuard', + ); + }); + + it('logs "unknown" ip when neither ip nor headers present', () => { + mockReflector.getAllAndOverride.mockReturnValue(['ADMIN']); + const mockRequest = { + user: { sub: 'u1', role: 'BUYER' }, + }; + const ctx = { + switchToHttp: () => ({ getRequest: () => mockRequest }), + getHandler: () => ({ name: 'h' }), + getClass: () => ({ name: 'C' }), + } as any; + + guard.canActivate(ctx); + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('unknown'), + 'RolesGuard', + ); + }); +}); diff --git a/apps/api/src/modules/payments/application/__tests__/payments-branch-coverage.spec.ts b/apps/api/src/modules/payments/application/__tests__/payments-branch-coverage.spec.ts new file mode 100644 index 0000000..771d9c3 --- /dev/null +++ b/apps/api/src/modules/payments/application/__tests__/payments-branch-coverage.spec.ts @@ -0,0 +1,126 @@ +/** + * Supplemental branch-coverage tests for payments handlers. + * Covers gateway error path, InternalServerErrorException wrap, + * and refund handler edge cases. + */ +import { InternalServerErrorException } from '@nestjs/common'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { type IPaymentRepository } from '../../domain/repositories/payment.repository'; +import { CreatePaymentCommand } from '../commands/create-payment/create-payment.command'; +import { CreatePaymentHandler } from '../commands/create-payment/create-payment.handler'; +import { RefundPaymentCommand } from '../commands/refund-payment/refund-payment.command'; +import { RefundPaymentHandler } from '../commands/refund-payment/refund-payment.handler'; +import { PaymentEntity } from '../../domain/entities/payment.entity'; +import { Money } from '../../domain/value-objects/money.vo'; + +function makeCompletedPayment(): PaymentEntity { + const money = Money.create(500_000n).unwrap(); + const p = PaymentEntity.createNew('pay-1', 'user-1', 'VNPAY', 'LISTING_FEE', money); + p.markProcessing('vnpay-tx-1'); + p.markCompleted({ resultCode: '00' }); + p.clearDomainEvents(); + return p; +} + +function makePendingPayment(): PaymentEntity { + const money = Money.create(500_000n).unwrap(); + const p = PaymentEntity.createNew('pay-2', 'user-1', 'VNPAY', 'LISTING_FEE', money); + p.clearDomainEvents(); + return p; +} + +function makeRepo(): { [K in keyof IPaymentRepository]: ReturnType } { + return { + findById: vi.fn(), + findByProviderTxId: vi.fn(), + findByIdempotencyKey: vi.fn(), + findByUserId: vi.fn(), + save: vi.fn().mockResolvedValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + updateIfStatus: vi.fn(), + }; +} + +describe('CreatePaymentHandler — branch coverage supplements', () => { + let handler: CreatePaymentHandler; + let mockRepo: ReturnType; + let mockGatewayFactory: { getGateway: ReturnType }; + let mockGateway: { createPaymentUrl: ReturnType; verifyCallback: ReturnType; refund: ReturnType }; + let mockEventBus: { publish: ReturnType }; + let mockLogger: any; + + beforeEach(() => { + mockRepo = makeRepo(); + mockGateway = { + createPaymentUrl: vi.fn().mockResolvedValue({ paymentUrl: 'https://pay.vn/1', providerTxId: 'tx-1' }), + verifyCallback: vi.fn(), + refund: vi.fn(), + }; + mockGatewayFactory = { getGateway: vi.fn().mockReturnValue(mockGateway) }; + mockEventBus = { publish: vi.fn() }; + mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), verbose: vi.fn() }; + handler = new CreatePaymentHandler(mockRepo as any, mockGatewayFactory as any, mockEventBus as any, mockLogger); + }); + + const makeCmd = () => new CreatePaymentCommand( + 'user-1', 'VNPAY', 'LISTING_FEE', 500_000n, 'Thanh toán phí đăng tin', + 'https://return.url', '127.0.0.1', + ); + + it('throws ValidationException when gateway createPaymentUrl throws', async () => { + mockRepo.findByIdempotencyKey.mockResolvedValue(null); + mockGateway.createPaymentUrl.mockRejectedValue(new Error('Gateway timeout')); + + const { ValidationException } = await import('@modules/shared'); + await expect(handler.execute(makeCmd())).rejects.toBeInstanceOf(ValidationException); + expect(mockLogger.error).toHaveBeenCalled(); + }); + + it('wraps unexpected repo.save error in InternalServerErrorException', async () => { + mockRepo.findByIdempotencyKey.mockResolvedValue(null); + mockRepo.save.mockRejectedValue(new Error('DB write failed')); + + await expect(handler.execute(makeCmd())).rejects.toBeInstanceOf(InternalServerErrorException); + expect(mockLogger.error).toHaveBeenCalled(); + }); +}); + +describe('RefundPaymentHandler — branch coverage supplements', () => { + let handler: RefundPaymentHandler; + let mockRepo: ReturnType; + let mockGatewayFactory: { getGateway: ReturnType }; + let mockGateway: { createPaymentUrl: ReturnType; verifyCallback: ReturnType; refund: ReturnType }; + let mockEventBus: { publish: ReturnType }; + let mockLogger: any; + + beforeEach(() => { + mockRepo = makeRepo(); + mockGateway = { + createPaymentUrl: vi.fn(), + verifyCallback: vi.fn(), + refund: vi.fn().mockResolvedValue({ success: true, refundTxId: 'ref-tx-1' }), + }; + mockGatewayFactory = { getGateway: vi.fn().mockReturnValue(mockGateway) }; + mockEventBus = { publish: vi.fn() }; + mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), verbose: vi.fn() }; + handler = new RefundPaymentHandler(mockRepo as any, mockGatewayFactory as any, mockLogger); + }); + + it('throws NotFoundException when payment not found', async () => { + mockRepo.findById.mockResolvedValue(null); + const { NotFoundException } = await import('@modules/shared'); + + await expect( + handler.execute(new RefundPaymentCommand('missing-id', 'duplicate', 'user-1')), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('throws ValidationException when trying to refund non-completed payment', async () => { + mockRepo.findById.mockResolvedValue(makePendingPayment()); + const { ValidationException } = await import('@modules/shared'); + + await expect( + handler.execute(new RefundPaymentCommand('pay-2', 'error', 'user-1')), + ).rejects.toBeInstanceOf(ValidationException); + }); +}); diff --git a/apps/api/src/modules/subscriptions/application/__tests__/subscription-handlers-branch-coverage.spec.ts b/apps/api/src/modules/subscriptions/application/__tests__/subscription-handlers-branch-coverage.spec.ts new file mode 100644 index 0000000..eae28ac --- /dev/null +++ b/apps/api/src/modules/subscriptions/application/__tests__/subscription-handlers-branch-coverage.spec.ts @@ -0,0 +1,193 @@ +/** + * Supplemental branch-coverage tests for subscription application handlers. + * Targets the uncovered `catch (non-DomainException)` → InternalServerErrorException + * paths and plan-field=null branches that were missed by the primary spec files. + */ +import { InternalServerErrorException } from '@nestjs/common'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { SubscriptionEntity } from '../../domain/entities/subscription.entity'; +import { type ISubscriptionRepository } from '../../domain/repositories/subscription.repository'; +import { CheckQuotaHandler } from '../queries/check-quota/check-quota.handler'; +import { CheckQuotaQuery } from '../queries/check-quota/check-quota.query'; +import { MeterUsageHandler } from '../commands/meter-usage/meter-usage.handler'; +import { MeterUsageCommand } from '../commands/meter-usage/meter-usage.command'; +import { UpgradeSubscriptionHandler } from '../commands/upgrade-subscription/upgrade-subscription.handler'; +import { UpgradeSubscriptionCommand } from '../commands/upgrade-subscription/upgrade-subscription.command'; +import { CancelSubscriptionHandler } from '../commands/cancel-subscription/cancel-subscription.handler'; +import { CancelSubscriptionCommand } from '../commands/cancel-subscription/cancel-subscription.command'; + +function makeActiveSub(): SubscriptionEntity { + const sub = SubscriptionEntity.createNew( + 'sub-1', 'user-1', 'plan-1', 'AGENT_PRO', + new Date('2026-01-01'), new Date('2026-02-01'), + ); + sub.clearDomainEvents(); + return sub; +} + +function makeRepo(): { [K in keyof ISubscriptionRepository]: ReturnType } { + return { + findById: vi.fn(), + findByUserId: vi.fn(), + save: vi.fn(), + update: vi.fn(), + }; +} + +function makeLogger() { + return { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), verbose: vi.fn() }; +} + +// ── CheckQuotaHandler ───────────────────────────────────────────────────────── + +describe('CheckQuotaHandler — branch coverage supplements', () => { + let handler: CheckQuotaHandler; + let mockRepo: ReturnType; + let mockPrisma: any; + let mockCache: { getOrSet: ReturnType; invalidate: ReturnType; invalidateByPrefix: ReturnType }; + let mockLogger: ReturnType; + + beforeEach(() => { + mockRepo = makeRepo(); + mockPrisma = { + plan: { findFirst: vi.fn(), findUnique: vi.fn() }, + usageRecord: { findFirst: vi.fn(), findUnique: vi.fn() }, + }; + mockCache = { + getOrSet: vi.fn().mockImplementation((_k: string, fn: () => Promise) => fn()), + invalidate: vi.fn().mockResolvedValue(undefined), + invalidateByPrefix: vi.fn().mockResolvedValue(undefined), + }; + mockLogger = makeLogger(); + handler = new CheckQuotaHandler(mockRepo as any, mockPrisma, mockCache as any, mockLogger as any); + }); + + it('returns unlimited when known plan field has null value (e.g. unlimited savedSearches)', async () => { + const sub = makeActiveSub(); + mockRepo.findByUserId.mockResolvedValue(sub); + // maxSavedSearches = null means unlimited + mockPrisma.plan.findUnique.mockResolvedValue({ id: 'plan-1', maxSavedSearches: null }); + + const result = await handler.execute(new CheckQuotaQuery('user-1', 'searches_saved')); + + expect(result.limit).toBeNull(); + expect(result.allowed).toBe(true); + expect(result.remaining).toBeNull(); + }); + + it('propagates error from inner loadQuota when DB throws', async () => { + mockRepo.findByUserId.mockRejectedValue(new Error('DB crash')); + mockCache.getOrSet.mockImplementation(async (_k: string, fn: () => Promise) => fn()); + + await expect(handler.execute(new CheckQuotaQuery('user-1', 'listings_created'))) + .rejects.toThrow('DB crash'); + }); + + it('re-throws DomainException directly without wrapping', async () => { + const { NotFoundException } = await import('@modules/shared'); + mockCache.getOrSet.mockImplementationOnce(async (_k: string, fn: () => Promise) => { + throw new NotFoundException('Plan', 'plan-missing'); + }); + + await expect(handler.execute(new CheckQuotaQuery('user-1', 'listings_created'))) + .rejects.toBeInstanceOf(NotFoundException); + }); +}); + +// ── MeterUsageHandler ───────────────────────────────────────────────────────── + +describe('MeterUsageHandler — branch coverage supplements', () => { + let handler: MeterUsageHandler; + let mockRepo: ReturnType; + let mockPrisma: any; + let mockCache: any; + let mockLogger: ReturnType; + + beforeEach(() => { + mockRepo = makeRepo(); + mockPrisma = { usageRecord: { upsert: vi.fn() } }; + mockCache = { getOrSet: vi.fn(), invalidate: vi.fn().mockResolvedValue(undefined), invalidateByPrefix: vi.fn() }; + mockLogger = makeLogger(); + handler = new MeterUsageHandler(mockRepo as any, mockPrisma, mockCache as any, mockLogger as any); + }); + + it('wraps unexpected repo error in InternalServerErrorException', async () => { + const sub = makeActiveSub(); + mockRepo.findByUserId.mockResolvedValue(sub); + mockPrisma.usageRecord.upsert.mockRejectedValue(new Error('DB unavailable')); + + await expect(handler.execute(new MeterUsageCommand('user-1', 'listings_created', 1))) + .rejects.toBeInstanceOf(InternalServerErrorException); + expect(mockLogger.error).toHaveBeenCalled(); + }); +}); + +// ── UpgradeSubscriptionHandler ──────────────────────────────────────────────── + +describe('UpgradeSubscriptionHandler — branch coverage supplements', () => { + let handler: UpgradeSubscriptionHandler; + let mockRepo: ReturnType; + let mockPrisma: any; + let mockEventBus: { publish: ReturnType }; + let mockCache: any; + let mockLogger: ReturnType; + + beforeEach(() => { + mockRepo = makeRepo(); + mockPrisma = { plan: { findFirst: vi.fn() } }; + mockEventBus = { publish: vi.fn() }; + mockCache = { invalidateByPrefix: vi.fn().mockResolvedValue(undefined) }; + mockLogger = makeLogger(); + handler = new UpgradeSubscriptionHandler( + mockRepo as any, mockPrisma, mockEventBus as any, mockCache as any, mockLogger as any, + ); + }); + + it('wraps unexpected error in InternalServerErrorException', async () => { + mockRepo.findByUserId.mockRejectedValue(new Error('Connection refused')); + + await expect( + handler.execute(new UpgradeSubscriptionCommand('user-1', 'ENTERPRISE' as any)), + ).rejects.toBeInstanceOf(InternalServerErrorException); + expect(mockLogger.error).toHaveBeenCalled(); + }); + + it('allows lateral switch AGENT_PRO → INVESTOR (same tier order level)', async () => { + const sub = makeActiveSub(); // planTier = AGENT_PRO + mockRepo.findByUserId.mockResolvedValue(sub); + mockPrisma.plan.findFirst.mockResolvedValue({ id: 'plan-investor', tier: 'INVESTOR' }); + mockRepo.update.mockResolvedValue(undefined); + + const result = await handler.execute( + new UpgradeSubscriptionCommand('user-1', 'INVESTOR' as any), + ); + + expect(result.newTier).toBe('INVESTOR'); + expect(result.previousTier).toBe('AGENT_PRO'); + }); +}); + +// ── CancelSubscriptionHandler ───────────────────────────────────────────────── + +describe('CancelSubscriptionHandler — branch coverage supplements', () => { + let handler: CancelSubscriptionHandler; + let mockRepo: ReturnType; + let mockEventBus: { publish: ReturnType }; + let mockLogger: ReturnType; + + beforeEach(() => { + mockRepo = makeRepo(); + mockEventBus = { publish: vi.fn() }; + mockLogger = makeLogger(); + handler = new CancelSubscriptionHandler(mockRepo as any, mockEventBus as any, mockLogger as any); + }); + + it('wraps unexpected error in InternalServerErrorException', async () => { + mockRepo.findByUserId.mockRejectedValue(new Error('Network error')); + + await expect( + handler.execute(new CancelSubscriptionCommand('user-1', 'test-reason')), + ).rejects.toBeInstanceOf(InternalServerErrorException); + expect(mockLogger.error).toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/modules/subscriptions/domain/__tests__/subscription-entity-branch-coverage.spec.ts b/apps/api/src/modules/subscriptions/domain/__tests__/subscription-entity-branch-coverage.spec.ts new file mode 100644 index 0000000..809f819 --- /dev/null +++ b/apps/api/src/modules/subscriptions/domain/__tests__/subscription-entity-branch-coverage.spec.ts @@ -0,0 +1,76 @@ +/** + * Supplemental branch-coverage tests for SubscriptionEntity. + * Covers error paths in markPastDue, markExpired, and isExpired. + */ +import { describe, it, expect } from 'vitest'; +import { SubscriptionEntity } from '../entities/subscription.entity'; + +function makeSub(): SubscriptionEntity { + return SubscriptionEntity.createNew( + 'sub-1', 'user-1', 'plan-1', 'AGENT_PRO', + new Date('2026-01-01'), new Date('2026-02-01'), + ); +} + +describe('SubscriptionEntity — branch coverage supplements', () => { + it('returns error from markPastDue when already cancelled', () => { + const sub = makeSub(); + sub.cancel(); + const result = sub.markPastDue(); + expect(result.isErr).toBe(true); + expect(result.unwrapErr().message).toContain('CANCELLED'); + }); + + it('returns error from markPastDue when already expired', () => { + const sub = makeSub(); + sub.markExpired(); + const result = sub.markPastDue(); + expect(result.isErr).toBe(true); + }); + + it('returns error from markExpired when already cancelled', () => { + const sub = makeSub(); + sub.cancel(); + const result = sub.markExpired(); + expect(result.isErr).toBe(true); + expect(result.unwrapErr().message).toContain('CANCELLED'); + }); + + it('marks expired from PAST_DUE state successfully', () => { + const sub = makeSub(); + sub.markPastDue(); + sub.clearDomainEvents(); + const result = sub.markExpired(); + expect(result.isOk).toBe(true); + expect(sub.status).toBe('EXPIRED'); + }); + + it('isExpired returns false for subscription with future end date', () => { + const futureSub = SubscriptionEntity.createNew( + 'sub-2', 'user-1', 'plan-1', 'FREE', + new Date(), new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), + ); + expect(futureSub.isExpired()).toBe(false); + }); + + it('isExpired returns true for subscription with past end date', () => { + const pastSub = SubscriptionEntity.createNew( + 'sub-3', 'user-1', 'plan-1', 'FREE', + new Date(Date.now() - 60 * 24 * 60 * 60 * 1000), + new Date(Date.now() - 1 * 24 * 60 * 60 * 1000), + ); + expect(pastSub.isExpired()).toBe(true); + }); + + it('isActive returns false for cancelled subscription', () => { + const sub = makeSub(); + sub.cancel(); + expect(sub.isActive()).toBe(false); + }); + + it('isActive returns false for past-due subscription', () => { + const sub = makeSub(); + sub.markPastDue(); + expect(sub.isActive()).toBe(false); + }); +}); diff --git a/apps/api/src/modules/subscriptions/infrastructure/__tests__/bank-transfer-subscription-activation.handler.spec.ts b/apps/api/src/modules/subscriptions/infrastructure/__tests__/bank-transfer-subscription-activation.handler.spec.ts new file mode 100644 index 0000000..4c281ef --- /dev/null +++ b/apps/api/src/modules/subscriptions/infrastructure/__tests__/bank-transfer-subscription-activation.handler.spec.ts @@ -0,0 +1,110 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { BankTransferConfirmedEvent } from '@modules/payments'; +import { SubscriptionEntity } from '../../domain/entities/subscription.entity'; +import { type ISubscriptionRepository } from '../../domain/repositories/subscription.repository'; +import { BankTransferSubscriptionActivationHandler } from '../event-handlers/bank-transfer-subscription-activation.handler'; + +function makeSub(periodStart: Date, periodEnd: Date): SubscriptionEntity { + const sub = SubscriptionEntity.createNew( + 'sub-1', 'user-1', 'plan-1', 'AGENT_PRO', periodStart, periodEnd, + ); + sub.clearDomainEvents(); + return sub; +} + +describe('BankTransferSubscriptionActivationHandler', () => { + let handler: BankTransferSubscriptionActivationHandler; + let mockRepo: { [K in keyof ISubscriptionRepository]: ReturnType }; + let mockLogger: { log: ReturnType; warn: ReturnType; error: ReturnType }; + + beforeEach(() => { + mockRepo = { + findById: vi.fn(), + findByUserId: vi.fn(), + save: vi.fn(), + update: vi.fn().mockResolvedValue(undefined), + }; + mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn() }; + handler = new BankTransferSubscriptionActivationHandler( + mockRepo as any, + mockLogger as any, + ); + }); + + const makeEvent = (type: 'SUBSCRIPTION' | 'LISTING_FEE'): BankTransferConfirmedEvent => + new BankTransferConfirmedEvent( + 'payment-1', 'user-1', type as any, 5_000_000n, 'admin-1', 'REF-001', + ); + + it('does nothing for non-SUBSCRIPTION payment types', async () => { + await handler.handle(makeEvent('LISTING_FEE')); + expect(mockRepo.findByUserId).not.toHaveBeenCalled(); + }); + + it('logs warning and returns when no subscription found for user', async () => { + mockRepo.findByUserId.mockResolvedValue(null); + await handler.handle(makeEvent('SUBSCRIPTION')); + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('manual CS review required'), + 'BankTransferSubscriptionActivationHandler', + ); + expect(mockRepo.update).not.toHaveBeenCalled(); + }); + + it('renews period from current periodEnd when subscription is still active (end > now)', async () => { + const now = new Date(); + const futureEnd = new Date(now.getTime() + 15 * 24 * 60 * 60 * 1000); // +15 days + const start = new Date(now.getTime() - 15 * 24 * 60 * 60 * 1000); // -15 days + const sub = makeSub(start, futureEnd); + mockRepo.findByUserId.mockResolvedValue(sub); + + await handler.handle(makeEvent('SUBSCRIPTION')); + + expect(mockRepo.update).toHaveBeenCalledWith(sub); + const events = sub.domainEvents; + expect(events.some((e) => e.eventName === 'subscription.renewed')).toBe(true); + expect(sub.currentPeriodEnd.getTime()).toBeGreaterThan(futureEnd.getTime()); + }); + + it('renews period from now when subscription is already expired (end <= now)', async () => { + const pastStart = new Date(Date.now() - 60 * 24 * 60 * 60 * 1000); + const pastEnd = new Date(Date.now() - 5 * 24 * 60 * 60 * 1000); + const sub = makeSub(pastStart, pastEnd); + mockRepo.findByUserId.mockResolvedValue(sub); + + const before = Date.now(); + await handler.handle(makeEvent('SUBSCRIPTION')); + const after = Date.now(); + + expect(mockRepo.update).toHaveBeenCalledWith(sub); + expect(sub.currentPeriodStart.getTime()).toBeGreaterThanOrEqual(before - 100); + expect(sub.currentPeriodStart.getTime()).toBeLessThanOrEqual(after + 100); + }); + + it('logs success after activation', async () => { + const now = new Date(); + const futureEnd = new Date(now.getTime() + 10 * 24 * 60 * 60 * 1000); + mockRepo.findByUserId.mockResolvedValue(makeSub(now, futureEnd)); + + await handler.handle(makeEvent('SUBSCRIPTION')); + + expect(mockLogger.log).toHaveBeenCalledWith( + expect.stringContaining('Subscription activated via bank transfer'), + 'BankTransferSubscriptionActivationHandler', + ); + }); + + it('logs error and does not rethrow when repo.update throws', async () => { + const now = new Date(); + const futureEnd = new Date(now.getTime() + 10 * 24 * 60 * 60 * 1000); + mockRepo.findByUserId.mockResolvedValue(makeSub(now, futureEnd)); + mockRepo.update.mockRejectedValue(new Error('DB connection lost')); + + await expect(handler.handle(makeEvent('SUBSCRIPTION'))).resolves.not.toThrow(); + expect(mockLogger.error).toHaveBeenCalledWith( + expect.stringContaining('Failed to activate subscription on bank transfer confirmation'), + expect.any(String), + 'BankTransferSubscriptionActivationHandler', + ); + }); +}); diff --git a/apps/api/vitest.config.ts b/apps/api/vitest.config.ts index 1a34d3b..7c57f01 100644 --- a/apps/api/vitest.config.ts +++ b/apps/api/vitest.config.ts @@ -31,7 +31,7 @@ export default defineConfig({ statements: 70, lines: 70, functions: 70, - branches: 58, + branches: 60, }, }, },