feat: fold purchase and promo voucher fields into one code field

This commit is contained in:
fromme committed 2026-10-06 10:41:30 +02:00
1 parent 93512b8345
commit 21d756a2e3
21 files changed
+801 -432

No files matched your search

+37 -17
View File
@@ -16,13 +16,9 @@ use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
use App\Form\Model\ParticipantEditDto;
use App\Form\Service\ServiceAgeEvaluator;
use App\Service\BookingPriceCalculator;
use App\Service\ParticipantEligibilityChecker;
use App\Service\ServiceAvailabilityCalculator;
use App\Service\VoucherValidator;
use App\Validator\Constraints\MandatoryAdditionalServicesSelectedValidator;
use App\Validator\Constraints\PromoVoucherValidator;
use App\Validator\Constraints\PurchaseVoucherValidator;
use App\Validator\Constraints\SkiPassSelectionValidator;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Validator\Constraint;
@@ -48,8 +44,6 @@ class ParticipantEditDtoTest extends TestCase
protected function setUp(): void
{
// Create mock services for validator dependencies
$mockVoucherService = $this->createStub(VoucherValidator::class);
$mockPriceCalculatorService = $this->createStub(BookingPriceCalculator::class);
$mockParticipantEligibilityChecker = $this->createStub(ParticipantEligibilityChecker::class);
$mockServiceAgeEvaluator = $this->createStub(ServiceAgeEvaluator::class);
@@ -61,10 +55,8 @@ class ParticipantEditDtoTest extends TestCase
);
// Create custom validator factory that can inject dependencies
$validatorFactory = new class($mockVoucherService, $mockPriceCalculatorService, $mockParticipantEligibilityChecker, $mockServiceAgeEvaluator, $participantEligibilityChecker) implements ConstraintValidatorFactoryInterface {
$validatorFactory = new class($mockParticipantEligibilityChecker, $mockServiceAgeEvaluator, $participantEligibilityChecker) implements ConstraintValidatorFactoryInterface {
public function __construct(
private readonly VoucherValidator $voucherService,
private readonly BookingPriceCalculator $priceCalculatorService,
private readonly ParticipantEligibilityChecker $participantEligibilityService,
private readonly ServiceAgeEvaluator $serviceAgeEvaluator,
private readonly ParticipantEligibilityChecker $realEligibilityChecker,
@@ -75,14 +67,6 @@ class ParticipantEditDtoTest extends TestCase
{
$className = $constraint->validatedBy();
if (PurchaseVoucherValidator::class === $className) {
return new PurchaseVoucherValidator($this->voucherService);
}
if (PromoVoucherValidator::class === $className) {
return new PromoVoucherValidator($this->voucherService, $this->priceCalculatorService);
}
if (EmailValidator::class === $className) {
return new EmailValidator(Email::VALIDATION_MODE_HTML5);
}
@@ -125,6 +109,42 @@ class ParticipantEditDtoTest extends TestCase
$this->assertCount(0, $violations);
}
public function testRejectedVoucherCodeFailsValidation(): void
{
$bookingDto = $this->createBookingDtoWithParticipants([
$this->createValidAdultParticipant('[email protected]'),
]);
$bookingDto->participants[0]->voucherCodeError = 'Gutschein ungültig: Gutschein nicht gefunden';
$violations = $this->validator->validate(
new ParticipantEditDto(participant: $bookingDto->participants[0], bookingContext: $bookingDto),
null,
['booking_create']
);
$this->assertCount(1, $violations);
$this->assertSame('participant.voucherCode', $violations->get(0)->getPropertyPath());
$this->assertSame('Gutschein ungültig: Gutschein nicht gefunden', $violations->get(0)->getMessage());
}
public function testAppliedPromoCodeRejectedOnRecheckFailsValidation(): void
{
$bookingDto = $this->createBookingDtoWithParticipants([
$this->createValidAdultParticipant('[email protected]'),
]);
$bookingDto->participants[0]->promoVoucherCode = 'R-SOMMER26';
$bookingDto->participants[0]->promoVoucherError = 'Aktionscode ungültig: Mindestpreis nicht erreicht';
$violations = $this->validator->validate(
new ParticipantEditDto(participant: $bookingDto->participants[0], bookingContext: $bookingDto),
null,
['booking_create']
);
$this->assertCount(1, $violations);
$this->assertSame('participant.voucherCode', $violations->get(0)->getPropertyPath());
}
public function testAdultWithDuplicateEmailFailsValidation(): void
{
$bookingDto = $this->createBookingDtoWithParticipants([
@@ -0,0 +1,237 @@
<?php
declare(strict_types=1);
namespace App\Tests\Form\Service;
use App\BusProNet\Model\PromoVoucher;
use App\BusProNet\Model\PromoVoucherValidationResult;
use App\BusProNet\Model\PurchaseVoucher;
use App\BusProNet\Model\PurchaseVoucherValidationResult;
use App\BusProNet\Model\Travel;
use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
use App\Form\Service\ParticipantVoucherFieldHandler;
use App\Service\BookingPriceCalculator;
use App\Service\VoucherCodeClassifier;
use App\Service\VoucherValidator;
use PHPUnit\Framework\MockObject\Stub;
use PHPUnit\Framework\TestCase;
class ParticipantVoucherFieldHandlerTest extends TestCase
{
private VoucherValidator&Stub $voucherValidator;
private BookingDto $bookingDto;
private ParticipantDto $participant;
protected function setUp(): void
{
$this->voucherValidator = $this->createStub(VoucherValidator::class);
$travel = new Travel();
$travel->id = 12500;
$this->participant = new ParticipantDto();
$this->participant->index = 0;
$this->bookingDto = new BookingDto($travel, 1);
$this->bookingDto->participants = [$this->participant];
}
private function handler(?VoucherValidator $voucherValidator = null): ParticipantVoucherFieldHandler
{
$priceCalculator = $this->createStub(BookingPriceCalculator::class);
$priceCalculator->method('calculateIndividualParticipantPrice')->willReturn(500.0);
return new ParticipantVoucherFieldHandler(
$voucherValidator ?? $this->voucherValidator,
$priceCalculator,
new VoucherCodeClassifier(['LW0705' => '2099-12-31']),
);
}
private static function promoVoucher(string $code): PromoVoucher
{
return new PromoVoucher($code, 'Aktion', PromoVoucher::APPLICABILITY_PER_PERSON, 50.0, 0.0, 0.0);
}
private static function purchaseVoucher(string $code, string $type = PurchaseVoucher::TYPE_PURCHASE): PurchaseVoucher
{
return new PurchaseVoucher('80190', $code, 1, 100.0, $type);
}
public function testRunsOnlyInCreateMode(): void
{
self::assertTrue($this->handler()->shouldProcess([], BookingDto::MODE_CREATE, 0));
self::assertFalse($this->handler()->shouldProcess([], BookingDto::MODE_EDIT, 0));
}
public function testAppliesPrefixedPromoCodeAndClearsTheField(): void
{
$voucher = self::promoVoucher('R-SOMMER26');
$this->voucherValidator->method('validatePromoVoucher')
->willReturn(new PromoVoucherValidationResult(isValid: true, voucher: $voucher));
$this->handler()->processField(['voucherCode' => ' r-sommer26 '], $this->bookingDto, 0);
self::assertSame('R-SOMMER26', $this->participant->promoVoucherCode);
self::assertSame($voucher, $this->participant->validatedPromoVoucher);
self::assertNull($this->participant->voucherCode);
self::assertNull($this->participant->voucherCodeError);
self::assertNull($this->participant->purchaseVoucherCode);
}
public function testAppliesLegacyPromoCodeAsPromo(): void
{
$voucherValidator = $this->createMock(VoucherValidator::class);
$voucherValidator->expects(self::once())->method('validatePromoVoucher')
->with('LW0705', 12500, 500.0)
->willReturn(new PromoVoucherValidationResult(isValid: true, voucher: self::promoVoucher('LW0705')));
$voucherValidator->expects(self::never())->method('validatePurchaseVoucher');
$this->handler($voucherValidator)->processField(['voucherCode' => 'lw0705'], $this->bookingDto, 0);
self::assertSame('LW0705', $this->participant->promoVoucherCode);
}
public function testAppliesPurchaseCode(): void
{
$voucher = self::purchaseVoucher('1F7V6PADRZ');
$this->voucherValidator->method('validatePurchaseVoucher')
->willReturn(new PurchaseVoucherValidationResult(isValid: true, voucher: $voucher));
$this->handler()->processField(['voucherCode' => '1f7v6padrz'], $this->bookingDto, 0);
self::assertSame('1F7V6PADRZ', $this->participant->purchaseVoucherCode);
self::assertSame($voucher, $this->participant->validatedPurchaseVoucher);
self::assertNull($this->participant->voucherCode);
self::assertFalse($this->participant->hasGoodwillVoucher());
}
public function testAppliesGoodwillCodeAsPurchaseCode(): void
{
$this->voucherValidator->method('validatePurchaseVoucher')
->willReturn(new PurchaseVoucherValidationResult(isValid: true, voucher: self::purchaseVoucher('KULANZ', PurchaseVoucher::TYPE_GOODWILL)));
$this->handler()->processField(['voucherCode' => 'KULANZ'], $this->bookingDto, 0);
self::assertSame('KULANZ', $this->participant->purchaseVoucherCode);
self::assertTrue($this->participant->hasGoodwillVoucher());
}
public function testRejectedCodeStaysInTheFieldWithBusProsReason(): void
{
$this->voucherValidator->method('validatePurchaseVoucher')
->willReturn(new PurchaseVoucherValidationResult(isValid: false, errorMessage: 'Gutschein nicht gefunden'));
$this->handler()->processField(['voucherCode' => 'abcdef'], $this->bookingDto, 0);
self::assertSame('abcdef', $this->participant->voucherCode);
self::assertSame('Gutschein ungültig: Gutschein nicht gefunden', $this->participant->voucherCodeError);
self::assertNull($this->participant->purchaseVoucherCode);
}
public function testMalformedCodeIsRejectedWithoutAskingBusPro(): void
{
$voucherValidator = $this->createMock(VoucherValidator::class);
$voucherValidator->expects(self::never())->method('validatePurchaseVoucher');
$voucherValidator->expects(self::never())->method('validatePromoVoucher');
$this->handler($voucherValidator)->processField(['voucherCode' => 'X-123'], $this->bookingDto, 0);
self::assertSame('X-123', $this->participant->voucherCode);
self::assertNotNull($this->participant->voucherCodeError);
}
public function testCodeAlreadyAppliedIsRejected(): void
{
$this->participant->purchaseVoucherCode = '1F7V6PADRZ';
$voucherValidator = $this->createMock(VoucherValidator::class);
$voucherValidator->expects(self::never())->method('validatePurchaseVoucher');
$this->handler($voucherValidator)->processField(['voucherCode' => '1f7v6padrz'], $this->bookingDto, 0);
self::assertSame('Dieser Code ist bereits eingelöst.', $this->participant->voucherCodeError);
}
public function testSecondPurchaseCodeIsRejectedAndTheFirstKept(): void
{
$this->participant->purchaseVoucherCode = '1F7V6PADRZ';
$voucherValidator = $this->createMock(VoucherValidator::class);
$voucherValidator->expects(self::never())->method('validatePurchaseVoucher');
$this->handler($voucherValidator)->processField(['voucherCode' => '1VDHQWPZHK'], $this->bookingDto, 0);
self::assertSame('1F7V6PADRZ', $this->participant->purchaseVoucherCode);
self::assertSame('1VDHQWPZHK', $this->participant->voucherCode);
self::assertStringContainsString('1F7V6PADRZ', (string) $this->participant->voucherCodeError);
}
public function testSecondPromoCodeIsRejectedAndTheFirstKept(): void
{
$applied = self::promoVoucher('R-SOMMER26');
$this->participant->promoVoucherCode = 'R-SOMMER26';
$this->participant->validatedPromoVoucher = $applied;
$this->voucherValidator->method('validatePromoVoucher')
->willReturn(new PromoVoucherValidationResult(isValid: true, voucher: $applied));
$this->handler()->processField(['voucherCode' => 'R-WINTER26'], $this->bookingDto, 0);
self::assertSame('R-SOMMER26', $this->participant->promoVoucherCode);
self::assertSame($applied, $this->participant->validatedPromoVoucher);
self::assertStringContainsString('R-SOMMER26', (string) $this->participant->voucherCodeError);
}
public function testPromoAndPurchaseCodeCanBeCombined(): void
{
$this->participant->purchaseVoucherCode = '1F7V6PADRZ';
$this->voucherValidator->method('validatePromoVoucher')
->willReturn(new PromoVoucherValidationResult(isValid: true, voucher: self::promoVoucher('R-SOMMER26')));
$this->handler()->processField(['voucherCode' => 'R-SOMMER26'], $this->bookingDto, 0);
self::assertSame('1F7V6PADRZ', $this->participant->purchaseVoucherCode);
self::assertSame('R-SOMMER26', $this->participant->promoVoucherCode);
self::assertNull($this->participant->voucherCodeError);
}
public function testRemovesAnAppliedCode(): void
{
$this->participant->purchaseVoucherCode = '1F7V6PADRZ';
$this->participant->validatedPurchaseVoucher = self::purchaseVoucher('1F7V6PADRZ');
$this->participant->removeVoucherCode = '1F7V6PADRZ';
$this->handler()->processField(['voucherCode' => '', 'removeVoucherCode' => '1f7v6padrz'], $this->bookingDto, 0);
self::assertNull($this->participant->purchaseVoucherCode);
self::assertNull($this->participant->validatedPurchaseVoucher);
self::assertNull($this->participant->removeVoucherCode);
}
public function testAppliedPromoCodeRejectedOnRecheckKeepsTheCodeWithItsReason(): void
{
$this->participant->promoVoucherCode = 'R-SOMMER26';
$this->participant->validatedPromoVoucher = self::promoVoucher('R-SOMMER26');
$this->voucherValidator->method('validatePromoVoucher')
->willReturn(new PromoVoucherValidationResult(isValid: false, errorMessage: 'Mindestpreis nicht erreicht'));
$this->handler()->processField([], $this->bookingDto, 0);
self::assertSame('R-SOMMER26', $this->participant->promoVoucherCode);
self::assertNull($this->participant->validatedPromoVoucher);
self::assertSame('Aktionscode ungültig: Mindestpreis nicht erreicht', $this->participant->promoVoucherError);
}
public function testCodeLeftFromAnEarlierRequestIsDroppedWhenTheFieldIsNotSubmitted(): void
{
$this->participant->voucherCode = 'ABCDEF';
$this->participant->voucherCodeError = 'Gutschein ungültig: Gutschein nicht gefunden';
$this->handler()->processField([], $this->bookingDto, 0);
self::assertNull($this->participant->voucherCode);
self::assertNull($this->participant->voucherCodeError);
}
}
@@ -15,12 +15,8 @@ use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
use App\Form\Service\ServiceAgeEvaluator;
use App\Service\BookingEditPreFlightChecker;
use App\Service\BookingPriceCalculator;
use App\Service\ParticipantEligibilityChecker;
use App\Service\VoucherValidator;
use App\Validator\Constraints\MandatoryAdditionalServicesSelectedValidator;
use App\Validator\Constraints\PromoVoucherValidator;
use App\Validator\Constraints\PurchaseVoucherValidator;
use App\Validator\Constraints\SkiPassSelectionValidator;
use Carbon\CarbonImmutable;
use PHPUnit\Framework\TestCase;
@@ -248,17 +244,13 @@ class BookingEditPreFlightCheckerTest extends TestCase
private function createValidator(): ValidatorInterface
{
$voucherValidator = $this->createStub(VoucherValidator::class);
$bookingPriceCalculator = $this->createStub(BookingPriceCalculator::class);
$participantEligibilityChecker = $this->createStub(ParticipantEligibilityChecker::class);
$participantEligibilityChecker
->method('isParticipantEligible')
->willReturn(false);
$validatorFactory = new class($voucherValidator, $bookingPriceCalculator, $participantEligibilityChecker, new ServiceAgeEvaluator()) implements ConstraintValidatorFactoryInterface {
$validatorFactory = new class($participantEligibilityChecker, new ServiceAgeEvaluator()) implements ConstraintValidatorFactoryInterface {
public function __construct(
private readonly VoucherValidator $voucherValidator,
private readonly BookingPriceCalculator $bookingPriceCalculator,
private readonly ParticipantEligibilityChecker $participantEligibilityChecker,
private readonly ServiceAgeEvaluator $serviceAgeEvaluator,
) {
@@ -272,14 +264,6 @@ class BookingEditPreFlightCheckerTest extends TestCase
return new EmailValidator(Email::VALIDATION_MODE_HTML5);
}
if (PurchaseVoucherValidator::class === $className) {
return new PurchaseVoucherValidator($this->voucherValidator);
}
if (PromoVoucherValidator::class === $className) {
return new PromoVoucherValidator($this->voucherValidator, $this->bookingPriceCalculator);
}
if (MandatoryAdditionalServicesSelectedValidator::class === $className) {
return new MandatoryAdditionalServicesSelectedValidator(
$this->participantEligibilityChecker,
@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service;
use App\Enum\VoucherCodeKind;
use App\Service\VoucherCodeClassifier;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
class VoucherCodeClassifierTest extends TestCase
{
private VoucherCodeClassifier $classifier;
protected function setUp(): void
{
$this->classifier = new VoucherCodeClassifier(['lw0705' => '2027-04-30']);
}
/**
* @return iterable<string, array{string, VoucherCodeKind}>
*/
public static function codes(): iterable
{
yield 'prefixed promo' => ['R-SOMMER26', VoucherCodeKind::Promo];
yield 'prefixed promo in lowercase' => ['r-sommer26', VoucherCodeKind::Promo];
yield 'legacy promo, matched case-insensitively' => ['Lw0705', VoucherCodeKind::Promo];
yield 'purchase code' => ['1F7V6PADRZ', VoucherCodeKind::Purchase];
yield 'purchase code in lowercase' => ['1f7v6padrz', VoucherCodeKind::Purchase];
yield 'surrounding whitespace' => [' 1F7V6PADRZ ', VoucherCodeKind::Purchase];
yield 'hyphen without the promo prefix' => ['X-1F7V6PADRZ', VoucherCodeKind::Malformed];
yield 'characters outside the code alphabet' => ['1F7V 6PADRZ', VoucherCodeKind::Malformed];
yield 'empty' => [' ', VoucherCodeKind::Malformed];
yield 'too long' => [str_repeat('A', 51), VoucherCodeKind::Malformed];
}
#[DataProvider('codes')]
public function testClassify(string $code, VoucherCodeKind $expected): void
{
self::assertSame($expected, $this->classifier->classify($code, new \DateTimeImmutable('2026-10-06')));
}
public function testLegacyCodeIsRecognisedOnItsLastDay(): void
{
self::assertSame(VoucherCodeKind::Promo, $this->classifier->classify('LW0705', new \DateTimeImmutable('2027-04-30')));
}
public function testLegacyCodeIsAPurchaseCodeOnceExpired(): void
{
self::assertSame(VoucherCodeKind::Purchase, $this->classifier->classify('LW0705', new \DateTimeImmutable('2027-05-01')));
}
public function testNormalizeTrimsAndUppercases(): void
{
self::assertSame('R-SOMMER26', $this->classifier->normalize(' r-Sommer26 '));
}
}