Skip to content

Commit ed2064d

Browse files
caseylockerclaude
andcommitted
test(promo-codes): add mixed-payload and infinite-code regression tests
Follow-up to b87cefd addressing Codex review suggestions #2 and #4. PreProcessReservationTaskTest: add two mixed-payload tests exercising the per-ticket guard in heterogeneous reservations (promo-only + Audience_All), both orderings. The original tests only covered single-ticket payloads. - testRejectsMixedPayloadWithPromoCodeOnlyFirst — guard fires on first iter. - testRejectsMixedPayloadWithPromoCodeOnlySecond — guard fires after prior aggregation; proves the exception short-circuits cleanly. SummitPromoCodeServiceDiscoveryTest: add an infinite-code overreach test that pins the `quantity_available == 0` semantics — `hasQuantityAvailable()` short-circuits to true for infinite codes, so the exhaustion guard must not drop them. - testDiscoverReturnsInfiniteDomainAuthorizedCode. Mutation-verified: reverting the production fixes causes the 3 reject tests to fail while the infinite-code and healthy-code tests still pass, as expected for overreach guards. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
1 parent b87cefd commit ed2064d

2 files changed

Lines changed: 109 additions & 0 deletions

File tree

tests/Unit/Services/PreProcessReservationTaskTest.php

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,4 +95,82 @@ public function testAllowsNonPromoCodeOnlyTicketTypeWithoutPromoCode(): void
9595
$this->assertEquals([], $state['promo_codes_usage']);
9696
$this->assertEquals([7], $state['ticket_types_ids']);
9797
}
98+
99+
/**
100+
* Mixed payload: a promo-only ticket (no promo_code) alongside an Audience_All
101+
* ticket. The per-ticket guard must fire on the promo-only entry even when
102+
* it is the first item in the payload (no prior aggregation).
103+
*/
104+
public function testRejectsMixedPayloadWithPromoCodeOnlyFirst(): void
105+
{
106+
$promo_only = Mockery::mock(SummitTicketType::class);
107+
$promo_only->shouldReceive('getId')->andReturn(42);
108+
$promo_only->shouldReceive('getName')->andReturn('VIP_PROMO_ONLY');
109+
$promo_only->shouldReceive('isLive')->andReturn(true);
110+
$promo_only->shouldReceive('isPromoCodeOnly')->andReturn(true);
111+
112+
$general = Mockery::mock(SummitTicketType::class);
113+
$general->shouldReceive('getId')->andReturn(7);
114+
$general->shouldReceive('getName')->andReturn('GENERAL_ADMISSION');
115+
$general->shouldReceive('isLive')->andReturn(true);
116+
$general->shouldReceive('isPromoCodeOnly')->andReturn(false);
117+
118+
$summit = Mockery::mock(Summit::class);
119+
$summit->shouldReceive('getTicketTypeById')->with(42)->andReturn($promo_only);
120+
$summit->shouldReceive('getTicketTypeById')->with(7)->andReturn($general);
121+
122+
$payload = [
123+
'tickets' => [
124+
['type_id' => 42], // promo-only, no promo_code → must throw
125+
['type_id' => 7], // general admission (would be allowed on its own)
126+
],
127+
];
128+
129+
$task = new PreProcessReservationTask($summit, $payload);
130+
131+
$this->expectException(ValidationException::class);
132+
$this->expectExceptionMessage('Ticket type VIP_PROMO_ONLY requires a promo code.');
133+
134+
$task->run([]);
135+
}
136+
137+
/**
138+
* Mixed payload, reverse order: general-admission ticket aggregated first,
139+
* then a promo-only ticket without a promo_code. The guard must still fire
140+
* even though prior iterations have already populated `reservations` and
141+
* `ticket_types_ids` — the exception short-circuits without partial state
142+
* being returned.
143+
*/
144+
public function testRejectsMixedPayloadWithPromoCodeOnlySecond(): void
145+
{
146+
$general = Mockery::mock(SummitTicketType::class);
147+
$general->shouldReceive('getId')->andReturn(7);
148+
$general->shouldReceive('getName')->andReturn('GENERAL_ADMISSION');
149+
$general->shouldReceive('isLive')->andReturn(true);
150+
$general->shouldReceive('isPromoCodeOnly')->andReturn(false);
151+
152+
$promo_only = Mockery::mock(SummitTicketType::class);
153+
$promo_only->shouldReceive('getId')->andReturn(42);
154+
$promo_only->shouldReceive('getName')->andReturn('VIP_PROMO_ONLY');
155+
$promo_only->shouldReceive('isLive')->andReturn(true);
156+
$promo_only->shouldReceive('isPromoCodeOnly')->andReturn(true);
157+
158+
$summit = Mockery::mock(Summit::class);
159+
$summit->shouldReceive('getTicketTypeById')->with(7)->andReturn($general);
160+
$summit->shouldReceive('getTicketTypeById')->with(42)->andReturn($promo_only);
161+
162+
$payload = [
163+
'tickets' => [
164+
['type_id' => 7], // aggregated successfully
165+
['type_id' => 42], // promo-only, no promo_code → must throw
166+
],
167+
];
168+
169+
$task = new PreProcessReservationTask($summit, $payload);
170+
171+
$this->expectException(ValidationException::class);
172+
$this->expectExceptionMessage('Ticket type VIP_PROMO_ONLY requires a promo code.');
173+
174+
$task->run([]);
175+
}
98176
}

tests/Unit/Services/SummitPromoCodeServiceDiscoveryTest.php

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,37 @@ public function testDiscoverReturnsHealthyDomainAuthorizedCode(): void
127127
$this->assertSame('HEALTHY', $result[0]->getCode());
128128
}
129129

130+
/**
131+
* Infinite code (quantity_available == 0) must always pass through the
132+
* global-exhaustion guard. Pins the `hasQuantityAvailable()` semantics
133+
* that infinite codes short-circuit to true regardless of quantity_used.
134+
*/
135+
public function testDiscoverReturnsInfiniteDomainAuthorizedCode(): void
136+
{
137+
$infinite = Mockery::mock(DomainAuthorizedSummitRegistrationPromoCode::class);
138+
$infinite->shouldReceive('getCode')->andReturn('INFINITE');
139+
// quantity_available == 0 means "unlimited"; hasQuantityAvailable() must return true.
140+
$infinite->shouldReceive('hasQuantityAvailable')->andReturn(true);
141+
$infinite->shouldReceive('getQuantityPerAccount')->andReturn(0);
142+
$infinite->shouldReceive('setRemainingQuantityPerAccount')->with(null)->once();
143+
144+
$summit = Mockery::mock(Summit::class);
145+
$member = Mockery::mock(Member::class);
146+
$member->shouldReceive('getEmail')->andReturn('buyer@acme.com');
147+
$member->shouldReceive('getId')->andReturn(11);
148+
149+
$repository = Mockery::mock(ISummitRegistrationPromoCodeRepository::class);
150+
$repository->shouldReceive('getDiscoverableByEmailForSummit')
151+
->with($summit, 'buyer@acme.com')
152+
->andReturn([$infinite]);
153+
154+
$service = $this->buildService($repository);
155+
$result = $service->discoverPromoCodes($summit, $member);
156+
157+
$this->assertCount(1, $result);
158+
$this->assertSame('INFINITE', $result[0]->getCode());
159+
}
160+
130161
/**
131162
* Mixed case: exhausted code is dropped while a healthy sibling survives.
132163
* This proves the guard uses per-code `continue`, not a scalar short-circuit.

0 commit comments

Comments
 (0)