405 lines
14 KiB
TypeScript
405 lines
14 KiB
TypeScript
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
|
|
import { Result } from '@core/shared/result/Result';
|
|
import { ConfirmCheckoutUseCase } from '@core/automation/application/use-cases/ConfirmCheckoutUseCase';
|
|
import type { CheckoutServicePort } from '@core/automation/application/ports/CheckoutServicePort';
|
|
import type { CheckoutConfirmationPort } from '@core/automation/application/ports/CheckoutConfirmationPort';
|
|
import type { CheckoutInfoDTO } from '@core/automation/application/dto/CheckoutInfoDTO';
|
|
import { CheckoutPrice } from '@core/automation/domain/value-objects/CheckoutPrice';
|
|
|
|
import { CheckoutConfirmation } from '@core/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 CheckoutServicePort,
|
|
mockConfirmationPort as unknown as CheckoutConfirmationPort
|
|
);
|
|
|
|
mockCheckoutService.extractCheckoutInfo.mockResolvedValue(
|
|
Result.ok({
|
|
price: mockPrice,
|
|
state: CheckoutState.ready(),
|
|
buttonHtml: '<a class="btn btn-success"><span>$0.50</span></a>',
|
|
})
|
|
);
|
|
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 CheckoutServicePort,
|
|
mockConfirmationPort as unknown as CheckoutConfirmationPort
|
|
);
|
|
|
|
mockCheckoutService.extractCheckoutInfo.mockResolvedValue(
|
|
Result.ok({
|
|
price: mockPrice,
|
|
state: CheckoutState.ready(),
|
|
buttonHtml: '<a class="btn btn-success"><span>$0.50</span></a>',
|
|
})
|
|
);
|
|
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 CheckoutServicePort,
|
|
mockConfirmationPort as unknown as CheckoutConfirmationPort
|
|
);
|
|
|
|
mockCheckoutService.extractCheckoutInfo.mockResolvedValue(
|
|
Result.ok({
|
|
price: mockPrice,
|
|
state: CheckoutState.ready(),
|
|
buttonHtml: '<a class="btn btn-success"><span>$0.50</span></a>',
|
|
})
|
|
);
|
|
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 CheckoutServicePort,
|
|
mockConfirmationPort as unknown as CheckoutConfirmationPort
|
|
);
|
|
|
|
mockCheckoutService.extractCheckoutInfo.mockResolvedValue(
|
|
Result.ok({
|
|
price: mockPrice,
|
|
state: CheckoutState.ready(),
|
|
buttonHtml: '<a class="btn btn-success"><span>$0.50</span></a>',
|
|
})
|
|
);
|
|
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 CheckoutServicePort,
|
|
mockConfirmationPort as unknown as CheckoutConfirmationPort
|
|
);
|
|
|
|
mockCheckoutService.extractCheckoutInfo.mockResolvedValue(
|
|
Result.ok({
|
|
price: mockPrice,
|
|
state: CheckoutState.insufficientFunds(),
|
|
buttonHtml: '<a class="btn btn-default"><span>$0.50</span></a>',
|
|
})
|
|
);
|
|
|
|
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 CheckoutServicePort,
|
|
mockConfirmationPort as unknown as CheckoutConfirmationPort
|
|
);
|
|
|
|
mockCheckoutService.extractCheckoutInfo.mockResolvedValue(
|
|
Result.ok({
|
|
price: mockPrice,
|
|
state: CheckoutState.insufficientFunds(),
|
|
buttonHtml: '<a class="btn btn-default"><span>$0.50</span></a>',
|
|
})
|
|
);
|
|
|
|
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 CheckoutServicePort,
|
|
mockConfirmationPort as unknown as CheckoutConfirmationPort
|
|
);
|
|
|
|
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 CheckoutServicePort,
|
|
mockConfirmationPort as unknown as CheckoutConfirmationPort
|
|
);
|
|
|
|
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 CheckoutServicePort,
|
|
mockConfirmationPort as unknown as CheckoutConfirmationPort
|
|
);
|
|
|
|
mockCheckoutService.extractCheckoutInfo.mockResolvedValue(
|
|
Result.ok({
|
|
price: zeroPriceMock,
|
|
state: CheckoutState.ready(),
|
|
buttonHtml: '<a class="btn btn-success"><span>$0.00</span></a>',
|
|
})
|
|
);
|
|
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 CheckoutServicePort,
|
|
mockConfirmationPort as unknown as CheckoutConfirmationPort
|
|
);
|
|
|
|
mockCheckoutService.extractCheckoutInfo.mockResolvedValue(
|
|
Result.ok({
|
|
price: zeroPriceMock,
|
|
state: CheckoutState.ready(),
|
|
buttonHtml: '<a class="btn btn-success"><span>$0.00</span></a>',
|
|
})
|
|
);
|
|
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 CheckoutServicePort,
|
|
mockConfirmationPort as unknown as CheckoutConfirmationPort
|
|
);
|
|
|
|
mockCheckoutService.extractCheckoutInfo.mockResolvedValue(
|
|
Result.ok({
|
|
price: mockPrice,
|
|
state: CheckoutState.ready(),
|
|
buttonHtml: '<a class="btn btn-success"><span>$0.50</span></a>',
|
|
})
|
|
);
|
|
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 CheckoutServicePort,
|
|
mockConfirmationPort as unknown as CheckoutConfirmationPort
|
|
);
|
|
|
|
mockCheckoutService.extractCheckoutInfo.mockResolvedValue(
|
|
Result.ok({
|
|
price: mockPrice,
|
|
state: CheckoutState.ready(),
|
|
buttonHtml: '<a class="btn btn-success"><span>$0.50</span></a>',
|
|
})
|
|
);
|
|
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 CheckoutServicePort,
|
|
mockConfirmationPort as unknown as CheckoutConfirmationPort
|
|
);
|
|
|
|
mockCheckoutService.extractCheckoutInfo.mockResolvedValue(
|
|
Result.ok({
|
|
price: mockPrice,
|
|
state: CheckoutState.ready(),
|
|
buttonHtml: '<a class="btn btn-success"><span>$0.50</span></a>',
|
|
})
|
|
);
|
|
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 CheckoutServicePort,
|
|
mockConfirmationPort as unknown as CheckoutConfirmationPort
|
|
);
|
|
|
|
mockCheckoutService.extractCheckoutInfo.mockResolvedValue(
|
|
Result.ok({
|
|
price: mockPrice,
|
|
state: CheckoutState.insufficientFunds(),
|
|
buttonHtml: '<a class="btn btn-default"><span>$0.50</span></a>',
|
|
})
|
|
);
|
|
|
|
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 CheckoutServicePort,
|
|
mockConfirmationPort as unknown as CheckoutConfirmationPort
|
|
);
|
|
|
|
mockCheckoutService.extractCheckoutInfo.mockResolvedValue(
|
|
Result.err('Button not found')
|
|
);
|
|
|
|
const result = await useCase.execute();
|
|
|
|
expect(result.isErr()).toBe(true);
|
|
});
|
|
});
|
|
}); |