import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; import { Result } from '../../../../packages/shared/result/Result'; import { ConfirmCheckoutUseCase } from '../../../../packages/automation/application/use-cases/ConfirmCheckoutUseCase'; import { ICheckoutService, CheckoutInfo } from '../../../../packages/automation/application/ports/ICheckoutService'; import { ICheckoutConfirmationPort } from '../../../../packages/automation/application/ports/ICheckoutConfirmationPort'; import { CheckoutPrice } from '@gridpilot/automation/domain/value-objects/CheckoutPrice'; import { CheckoutState, CheckoutStateEnum } from '@gridpilot/automation/domain/value-objects/CheckoutState'; import { CheckoutConfirmation } from '@gridpilot/automation/domain/value-objects/CheckoutConfirmation'; /** * ConfirmCheckoutUseCase - GREEN PHASE * * Tests for checkout confirmation flow including price extraction, * insufficient funds detection, and user confirmation. */ describe('ConfirmCheckoutUseCase', () => { let mockCheckoutService: { extractCheckoutInfo: Mock; proceedWithCheckout: Mock; }; let mockConfirmationPort: { requestCheckoutConfirmation: Mock; }; let mockPrice: CheckoutPrice; beforeEach(() => { mockCheckoutService = { extractCheckoutInfo: vi.fn(), proceedWithCheckout: vi.fn(), }; mockConfirmationPort = { requestCheckoutConfirmation: vi.fn(), }; mockPrice = { getAmount: vi.fn(() => 0.50), toDisplayString: vi.fn(() => '$0.50'), isZero: vi.fn(() => false), } as unknown as CheckoutPrice; }); describe('Success flow', () => { it('should extract price, get user confirmation, and proceed with checkout', async () => { const useCase = new ConfirmCheckoutUseCase( mockCheckoutService as unknown as ICheckoutService, mockConfirmationPort as unknown as ICheckoutConfirmationPort ); mockCheckoutService.extractCheckoutInfo.mockResolvedValue( Result.ok({ price: mockPrice, state: CheckoutState.ready(), buttonHtml: '$0.50', }) ); mockConfirmationPort.requestCheckoutConfirmation.mockResolvedValue( Result.ok(CheckoutConfirmation.create('confirmed')) ); mockCheckoutService.proceedWithCheckout.mockResolvedValue(Result.ok(undefined)); const result = await useCase.execute(); expect(result.isOk()).toBe(true); expect(mockCheckoutService.extractCheckoutInfo).toHaveBeenCalledTimes(1); expect(mockConfirmationPort.requestCheckoutConfirmation).toHaveBeenCalledTimes(1); expect(mockConfirmationPort.requestCheckoutConfirmation).toHaveBeenCalledWith( expect.objectContaining({ price: mockPrice }) ); expect(mockCheckoutService.proceedWithCheckout).toHaveBeenCalledTimes(1); }); it('should include price in confirmation message', async () => { const useCase = new ConfirmCheckoutUseCase( mockCheckoutService as unknown as ICheckoutService, mockConfirmationPort as unknown as ICheckoutConfirmationPort ); mockCheckoutService.extractCheckoutInfo.mockResolvedValue( Result.ok({ price: mockPrice, state: CheckoutState.ready(), buttonHtml: '$0.50', }) ); mockConfirmationPort.requestCheckoutConfirmation.mockResolvedValue( Result.ok(CheckoutConfirmation.create('confirmed')) ); mockCheckoutService.proceedWithCheckout.mockResolvedValue(Result.ok(undefined)); await useCase.execute(); expect(mockConfirmationPort.requestCheckoutConfirmation).toHaveBeenCalledWith( expect.objectContaining({ price: mockPrice }) ); }); }); describe('User cancellation', () => { it('should abort checkout when user cancels confirmation', async () => { const useCase = new ConfirmCheckoutUseCase( mockCheckoutService as unknown as ICheckoutService, mockConfirmationPort as unknown as ICheckoutConfirmationPort ); mockCheckoutService.extractCheckoutInfo.mockResolvedValue( Result.ok({ price: mockPrice, state: CheckoutState.ready(), buttonHtml: '$0.50', }) ); mockConfirmationPort.requestCheckoutConfirmation.mockResolvedValue( Result.ok(CheckoutConfirmation.create('cancelled')) ); const result = await useCase.execute(); expect(result.isErr()).toBe(true); expect(result.unwrapErr().message).toMatch(/cancel/i); expect(mockCheckoutService.proceedWithCheckout).not.toHaveBeenCalled(); }); it('should not proceed with checkout after cancellation', async () => { const useCase = new ConfirmCheckoutUseCase( mockCheckoutService as unknown as ICheckoutService, mockConfirmationPort as unknown as ICheckoutConfirmationPort ); mockCheckoutService.extractCheckoutInfo.mockResolvedValue( Result.ok({ price: mockPrice, state: CheckoutState.ready(), buttonHtml: '$0.50', }) ); mockConfirmationPort.requestCheckoutConfirmation.mockResolvedValue( Result.ok(CheckoutConfirmation.create('cancelled')) ); await useCase.execute(); expect(mockCheckoutService.proceedWithCheckout).toHaveBeenCalledTimes(0); }); }); describe('Insufficient funds detection', () => { it('should return error when checkout state is INSUFFICIENT_FUNDS', async () => { const useCase = new ConfirmCheckoutUseCase( mockCheckoutService as unknown as ICheckoutService, mockConfirmationPort as unknown as ICheckoutConfirmationPort ); mockCheckoutService.extractCheckoutInfo.mockResolvedValue( Result.ok({ price: mockPrice, state: CheckoutState.insufficientFunds(), buttonHtml: '$0.50', }) ); const result = await useCase.execute(); expect(result.isErr()).toBe(true); expect(result.unwrapErr().message).toMatch(/insufficient.*funds/i); expect(mockConfirmationPort.requestCheckoutConfirmation).not.toHaveBeenCalled(); expect(mockCheckoutService.proceedWithCheckout).not.toHaveBeenCalled(); }); it('should not ask for confirmation when funds are insufficient', async () => { const useCase = new ConfirmCheckoutUseCase( mockCheckoutService as unknown as ICheckoutService, mockConfirmationPort as unknown as ICheckoutConfirmationPort ); mockCheckoutService.extractCheckoutInfo.mockResolvedValue( Result.ok({ price: mockPrice, state: CheckoutState.insufficientFunds(), buttonHtml: '$0.50', }) ); await useCase.execute(); expect(mockConfirmationPort.requestCheckoutConfirmation).toHaveBeenCalledTimes(0); }); }); describe('Price extraction failure', () => { it('should return error when price cannot be extracted', async () => { const useCase = new ConfirmCheckoutUseCase( mockCheckoutService as unknown as ICheckoutService, mockConfirmationPort as unknown as ICheckoutConfirmationPort ); mockCheckoutService.extractCheckoutInfo.mockResolvedValue( Result.ok({ price: null, state: CheckoutState.unknown(), buttonHtml: '', }) ); const result = await useCase.execute(); expect(result.isErr()).toBe(true); expect(result.unwrapErr().message).toMatch(/extract|price|not found/i); expect(mockConfirmationPort.requestCheckoutConfirmation).not.toHaveBeenCalled(); expect(mockCheckoutService.proceedWithCheckout).not.toHaveBeenCalled(); }); it('should return error when extraction service fails', async () => { const useCase = new ConfirmCheckoutUseCase( mockCheckoutService as unknown as ICheckoutService, mockConfirmationPort as unknown as ICheckoutConfirmationPort ); mockCheckoutService.extractCheckoutInfo.mockResolvedValue( Result.err('Button not found') ); const result = await useCase.execute(); expect(result.isErr()).toBe(true); expect(mockConfirmationPort.requestCheckoutConfirmation).not.toHaveBeenCalled(); }); }); describe('Zero price warning', () => { it('should still require confirmation for $0.00 price', async () => { const zeroPriceMock = { getAmount: vi.fn(() => 0.00), toDisplayString: vi.fn(() => '$0.00'), isZero: vi.fn(() => true), } as unknown as CheckoutPrice; const useCase = new ConfirmCheckoutUseCase( mockCheckoutService as unknown as ICheckoutService, mockConfirmationPort as unknown as ICheckoutConfirmationPort ); mockCheckoutService.extractCheckoutInfo.mockResolvedValue( Result.ok({ price: zeroPriceMock, state: CheckoutState.ready(), buttonHtml: '$0.00', }) ); mockConfirmationPort.requestCheckoutConfirmation.mockResolvedValue( Result.ok(CheckoutConfirmation.create('confirmed')) ); mockCheckoutService.proceedWithCheckout.mockResolvedValue(Result.ok(undefined)); const result = await useCase.execute(); expect(result.isOk()).toBe(true); expect(mockConfirmationPort.requestCheckoutConfirmation).toHaveBeenCalledTimes(1); expect(mockConfirmationPort.requestCheckoutConfirmation).toHaveBeenCalledWith( expect.objectContaining({ price: zeroPriceMock }) ); }); it('should proceed with checkout for zero price after confirmation', async () => { const zeroPriceMock = { getAmount: vi.fn(() => 0.00), toDisplayString: vi.fn(() => '$0.00'), isZero: vi.fn(() => true), } as unknown as CheckoutPrice; const useCase = new ConfirmCheckoutUseCase( mockCheckoutService as unknown as ICheckoutService, mockConfirmationPort as unknown as ICheckoutConfirmationPort ); mockCheckoutService.extractCheckoutInfo.mockResolvedValue( Result.ok({ price: zeroPriceMock, state: CheckoutState.ready(), buttonHtml: '$0.00', }) ); mockConfirmationPort.requestCheckoutConfirmation.mockResolvedValue( Result.ok(CheckoutConfirmation.create('confirmed')) ); mockCheckoutService.proceedWithCheckout.mockResolvedValue(Result.ok(undefined)); await useCase.execute(); expect(mockCheckoutService.proceedWithCheckout).toHaveBeenCalledTimes(1); }); }); describe('Checkout execution failure', () => { it('should return error when proceedWithCheckout fails', async () => { const useCase = new ConfirmCheckoutUseCase( mockCheckoutService as unknown as ICheckoutService, mockConfirmationPort as unknown as ICheckoutConfirmationPort ); mockCheckoutService.extractCheckoutInfo.mockResolvedValue( Result.ok({ price: mockPrice, state: CheckoutState.ready(), buttonHtml: '$0.50', }) ); mockConfirmationPort.requestCheckoutConfirmation.mockResolvedValue( Result.ok(CheckoutConfirmation.create('confirmed')) ); mockCheckoutService.proceedWithCheckout.mockResolvedValue( Result.err('Network error') ); const result = await useCase.execute(); expect(result.isErr()).toBe(true); expect(result.unwrapErr()).toContain('Network error'); }); }); describe('BDD Scenarios', () => { it('Given checkout price $0.50 and READY state, When user confirms, Then checkout proceeds', async () => { const useCase = new ConfirmCheckoutUseCase( mockCheckoutService as unknown as ICheckoutService, mockConfirmationPort as unknown as ICheckoutConfirmationPort ); mockCheckoutService.extractCheckoutInfo.mockResolvedValue( Result.ok({ price: mockPrice, state: CheckoutState.ready(), buttonHtml: '$0.50', }) ); mockConfirmationPort.requestCheckoutConfirmation.mockResolvedValue( Result.ok(CheckoutConfirmation.create('confirmed')) ); mockCheckoutService.proceedWithCheckout.mockResolvedValue(Result.ok(undefined)); const result = await useCase.execute(); expect(result.isOk()).toBe(true); }); it('Given checkout price $0.50, When user cancels, Then checkout is aborted', async () => { const useCase = new ConfirmCheckoutUseCase( mockCheckoutService as unknown as ICheckoutService, mockConfirmationPort as unknown as ICheckoutConfirmationPort ); mockCheckoutService.extractCheckoutInfo.mockResolvedValue( Result.ok({ price: mockPrice, state: CheckoutState.ready(), buttonHtml: '$0.50', }) ); mockConfirmationPort.requestCheckoutConfirmation.mockResolvedValue( Result.ok(CheckoutConfirmation.create('cancelled')) ); const result = await useCase.execute(); expect(result.isErr()).toBe(true); expect(mockCheckoutService.proceedWithCheckout).not.toHaveBeenCalled(); }); it('Given INSUFFICIENT_FUNDS state, When executing, Then error is returned', async () => { const useCase = new ConfirmCheckoutUseCase( mockCheckoutService as unknown as ICheckoutService, mockConfirmationPort as unknown as ICheckoutConfirmationPort ); mockCheckoutService.extractCheckoutInfo.mockResolvedValue( Result.ok({ price: mockPrice, state: CheckoutState.insufficientFunds(), buttonHtml: '$0.50', }) ); const result = await useCase.execute(); expect(result.isErr()).toBe(true); }); it('Given price extraction failure, When executing, Then error is returned', async () => { const useCase = new ConfirmCheckoutUseCase( mockCheckoutService as unknown as ICheckoutService, mockConfirmationPort as unknown as ICheckoutConfirmationPort ); mockCheckoutService.extractCheckoutInfo.mockResolvedValue( Result.err('Button not found') ); const result = await useCase.execute(); expect(result.isErr()).toBe(true); }); }); });