diff --git a/app/Models/Foundation/Summit/Registration/PromoCodes/AllowedEmailDomainsLookup.php b/app/Models/Foundation/Summit/Registration/PromoCodes/AllowedEmailDomainsLookup.php new file mode 100644 index 000000000..eb5b2a30c --- /dev/null +++ b/app/Models/Foundation/Summit/Registration/PromoCodes/AllowedEmailDomainsLookup.php @@ -0,0 +1,42 @@ +unrestricted) return true; + + $email = strtolower(trim($email)); + if ($email === '') return false; + + $atPos = strpos($email, '@'); + if ($atPos === false) return false; + + $emailDomain = substr($email, $atPos); + + if (isset($lookup->exactSet[$emailDomain])) return true; + if (isset($lookup->exactSet[$email])) return true; + + foreach ($lookup->suffixList as $suffix) { + if (str_ends_with($emailDomain, $suffix)) return true; + } + + return false; + } + /** * Validates email against allowed_email_domains. * Throws ValidationException if no match. diff --git a/app/Models/Foundation/Summit/Registration/PromoCodes/IDomainAuthorizedPromoCode.php b/app/Models/Foundation/Summit/Registration/PromoCodes/IDomainAuthorizedPromoCode.php index 5bcee133c..120c211ec 100644 --- a/app/Models/Foundation/Summit/Registration/PromoCodes/IDomainAuthorizedPromoCode.php +++ b/app/Models/Foundation/Summit/Registration/PromoCodes/IDomainAuthorizedPromoCode.php @@ -37,6 +37,20 @@ public function getQuantityPerAccount(): int; */ public function matchesEmailDomain(string $email): bool; + /** + * Lookup-driven sibling of matchesEmailDomain(). + * + * Consumes a precomputed AllowedEmailDomainsLookup (normalized + partitioned + * exactSet / suffixList) so callers iterating many candidate codes against + * the same pattern set avoid re-normalizing patterns per code. Must return + * the SAME boolean as matchesEmailDomain() for any (patterns, email) pair. + * + * @param string $email + * @param AllowedEmailDomainsLookup $lookup + * @return bool + */ + public function matchesEmailDomainViaLookup(string $email, AllowedEmailDomainsLookup $lookup): bool; + /** * @param int|null $remaining * @return void diff --git a/app/Models/Foundation/Summit/Repositories/ISummitRegistrationPromoCodeRepository.php b/app/Models/Foundation/Summit/Repositories/ISummitRegistrationPromoCodeRepository.php index de64859e3..82ed2cebd 100644 --- a/app/Models/Foundation/Summit/Repositories/ISummitRegistrationPromoCodeRepository.php +++ b/app/Models/Foundation/Summit/Repositories/ISummitRegistrationPromoCodeRepository.php @@ -81,11 +81,14 @@ public function getBySummitAndCode(Summit $summit, string $code):?SummitRegistra public function getDiscoverableByEmailForSummit(Summit $summit, string $email): array; /** + * Returns date-windowed DOMAIN_AUTHORIZED_* candidates for the summit. + * Filtering by email moved to SummitPromoCodeService::discoverPromoCodes + * under option (b) of the Track-1 repository-decouple (SDS Task T1-Service). + * * @param Summit $summit - * @param string $email * @return SummitRegistrationPromoCode[] */ - public function getDomainAuthorizedDiscoverableForSummit(Summit $summit, string $email): array; + public function getDomainAuthorizedDiscoverableForSummit(Summit $summit): array; /** * @param Summit $summit diff --git a/app/Repositories/Summit/DoctrineSummitRegistrationPromoCodeRepository.php b/app/Repositories/Summit/DoctrineSummitRegistrationPromoCodeRepository.php index d248aa9a1..76262e726 100644 --- a/app/Repositories/Summit/DoctrineSummitRegistrationPromoCodeRepository.php +++ b/app/Repositories/Summit/DoctrineSummitRegistrationPromoCodeRepository.php @@ -661,32 +661,43 @@ public function getBySummitAndCode(Summit $summit, string $code):?SummitRegistra * Returns domain-authorized types (matched by email domain) and * existing email-linked types (member/speaker, matched by associated email). * + * @deprecated Track 1 (SDS task T1-Service) moved the discover hot path to + * SummitPromoCodeService::discoverPromoCodes calling the two leaf methods + * directly. This aggregator is retained only to preserve its public + * by-email contract for any non-grepped caller. Remove once Track 2 ships + * and a `grep -rn getDiscoverableByEmailForSummit` confirms zero callers. + * * @param Summit $summit * @param string $email * @return SummitRegistrationPromoCode[] */ public function getDiscoverableByEmailForSummit(Summit $summit, string $email): array { - if (empty($email)) return []; + $normalized = strtolower(trim($email)); + if ($normalized === '') return []; - $email = strtolower(trim($email)); + $daCandidates = $this->getDomainAuthorizedDiscoverableForSummit($summit); - return array_merge( - $this->getDomainAuthorizedDiscoverableForSummit($summit, $email), - $this->getEmailLinkedDiscoverableForSummit($summit, $email) - ); + $daMatched = []; + foreach ($daCandidates as $code) { + if ($code instanceof IDomainAuthorizedPromoCode && $code->matchesEmailDomain($normalized)) { + $daMatched[] = $code; + } + } + + $emailLinked = $this->getEmailLinkedDiscoverableForSummit($summit, $normalized); + return array_merge($daMatched, $emailLinked); } /** * @param Summit $summit - * @param string $email * @return SummitRegistrationPromoCode[] */ - public function getDomainAuthorizedDiscoverableForSummit(Summit $summit, string $email): array + public function getDomainAuthorizedDiscoverableForSummit(Summit $summit): array { - $em = $this->getEntityManager(); + $em = $this->getEntityManager(); $now = new \DateTime('now', new \DateTimeZone('UTC')); - $daPromoClass = DomainAuthorizedSummitRegistrationPromoCode::class; + $daPromoClass = DomainAuthorizedSummitRegistrationPromoCode::class; $daDiscountClass = DomainAuthorizedSummitRegistrationDiscountCode::class; $qb = $em->createQueryBuilder(); @@ -702,16 +713,7 @@ public function getDomainAuthorizedDiscoverableForSummit(Summit $summit, string ->setParameter('summit_id', $summit->getId()) ->setParameter('now', $now); - $candidates = $qb->getQuery()->getResult(); - $results = []; - - foreach ($candidates as $code) { - if ($code instanceof IDomainAuthorizedPromoCode && $code->matchesEmailDomain($email)) { - $results[] = $code; - } - } - - return $results; + return $qb->getQuery()->getResult(); } /** diff --git a/app/Services/Model/Imp/AllowedEmailDomainsLookupBuilder.php b/app/Services/Model/Imp/AllowedEmailDomainsLookupBuilder.php new file mode 100644 index 000000000..bc0416cea --- /dev/null +++ b/app/Services/Model/Imp/AllowedEmailDomainsLookupBuilder.php @@ -0,0 +1,87 @@ + exactSet[$p] = true (e.g. "@acme.com") + * - leading '.' -> suffixList[] = $p (e.g. ".edu") + * - contains '@' (not at start) -> exactSet[$p] = true (e.g. "user@acme.com") + * - otherwise -> dropped silently + * + * patternsHash = sha1(implode('|', $sortedNormalizedPatterns)) — stable + * regardless of input order, so callers can use it for change detection / equality. + */ +final class AllowedEmailDomainsLookupBuilder +{ + public function build(array $patterns): AllowedEmailDomainsLookup + { + // Capture "no patterns configured" from the RAW input BEFORE any + // normalization or partitioning. Required for legacy parity: a + // non-empty input whose patterns all drop as malformed must NOT + // be treated as unrestricted. + $unrestricted = count($patterns) === 0; + + $exactSet = []; + $suffixList = []; + $seen = []; + $normalized = []; + + foreach ($patterns as $raw) { + $p = strtolower(trim((string) $raw)); + if ($p === '') { + continue; + } + if (isset($seen[$p])) { + continue; + } + $seen[$p] = true; + + $first = $p[0]; + if ($first === '@') { + $exactSet[$p] = true; + $normalized[] = $p; + continue; + } + if ($first === '.') { + $suffixList[] = $p; + $normalized[] = $p; + continue; + } + if (strpos($p, '@') !== false) { + $exactSet[$p] = true; + $normalized[] = $p; + continue; + } + // otherwise: dropped silently + } + + sort($normalized); + $patternsHash = sha1(implode('|', $normalized)); + + return new AllowedEmailDomainsLookup($exactSet, $suffixList, $patternsHash, $unrestricted); + } +} diff --git a/app/Services/Model/Imp/SummitPromoCodeService.php b/app/Services/Model/Imp/SummitPromoCodeService.php index 4c7543c11..d55117d88 100644 --- a/app/Services/Model/Imp/SummitPromoCodeService.php +++ b/app/Services/Model/Imp/SummitPromoCodeService.php @@ -18,6 +18,7 @@ use App\Jobs\ReApplyPromoCodeRetroActively; use App\Models\Foundation\Summit\Factories\SummitPromoCodeFactory; use App\Models\Foundation\Summit\Factories\SummitRegistrationDiscountCodeTicketTypeRuleFactory; +use App\Services\Model\AllowedEmailDomainsLookupBuilder; use App\Services\Model\Imp\Traits\ParametrizedSendEmails; use App\Services\Model\Strategies\PromoCodes\PromoCodeValidationStrategyFactory; use App\Services\Utils\CSVReader; @@ -102,6 +103,11 @@ final class SummitPromoCodeService */ private $lock_service; + /** + * @var AllowedEmailDomainsLookupBuilder + */ + private $lookup_builder; + /** * @param IMemberRepository $member_repository * @param ICompanyRepository $company_repository @@ -112,6 +118,7 @@ final class SummitPromoCodeService * @param ISummitRegistrationPromoCodeRepository $repository * @param ITransactionService $tx_service * @param ILockManagerService $lock_service + * @param AllowedEmailDomainsLookupBuilder $lookup_builder */ public function __construct ( @@ -123,18 +130,20 @@ public function __construct ITagRepository $tag_repository, ISummitRegistrationPromoCodeRepository $repository, ITransactionService $tx_service, - ILockManagerService $lock_service + ILockManagerService $lock_service, + AllowedEmailDomainsLookupBuilder $lookup_builder ) { parent::__construct($tx_service); - $this->member_repository = $member_repository; + $this->member_repository = $member_repository; $this->company_repository = $company_repository; $this->speaker_repository = $speaker_repository; - $this->summit_repository = $summit_repository; - $this->ticket_repository = $ticket_repository; - $this->tag_repository = $tag_repository; - $this->repository = $repository; - $this->lock_service = $lock_service; + $this->summit_repository = $summit_repository; + $this->ticket_repository = $ticket_repository; + $this->tag_repository = $tag_repository; + $this->repository = $repository; + $this->lock_service = $lock_service; + $this->lookup_builder = $lookup_builder; } /** @@ -1022,36 +1031,77 @@ function ($summit, $flow_event, $promocode_id, $test_email_recipient, $announcem public function discoverPromoCodes(Summit $summit, Member $member): array { $email = $member->getEmail(); - if (empty($email)) return []; + if (empty(trim($email))) return []; + + $started = microtime(true); + $normalizedEmail = strtolower(trim($email)); + + // 1. Date-windowed DA candidates (raw, unfiltered by email). + $candidates = $this->repository->getDomainAuthorizedDiscoverableForSummit($summit); + + // 2. Filter DA candidates via O(1) set membership + small suffix scan. + // Lookups are memoized by pattern-array hash so sibling codes that share + // an identical pattern set reuse a single normalized lookup structure. + $matched = []; + $lookupCache = []; + foreach ($candidates as $code) { + if (!$code instanceof IDomainAuthorizedPromoCode) continue; + $patterns = $code->getAllowedEmailDomains(); + $hash = sha1(implode("\0", $patterns)); // stable pre-hash; order- and case-sensitive + $lookup = $lookupCache[$hash] ?? ($lookupCache[$hash] = $this->lookup_builder->build($patterns)); + if ($code->matchesEmailDomainViaLookup($normalizedEmail, $lookup)) { + $matched[] = $code; + } + } - $codes = $this->repository->getDiscoverableByEmailForSummit($summit, $email); - $results = []; + // 3. Merge with email-linked codes (repo SQL binds against LOWER(e.email); + // interface docblock requires the param to be pre-lowercased). + $emailLinked = $this->repository->getEmailLinkedDiscoverableForSummit($summit, $normalizedEmail); + $matched = array_merge($matched, $emailLinked); - foreach ($codes as $code) { - // Global exhaustion: finite code with quantity_used >= quantity_available. - // The repository filter uses isLive() (dates only), so exhausted codes leak through. - // Skip them here so discovery matches checkout's validate() behavior. - if (!$code->hasQuantityAvailable()) { - continue; - } + // 4. Per-code quantity loop (unchanged from prior behavior; N+1 stays at Track 1). + $results = []; + foreach ($matched as $code) { + if (!$code->hasQuantityAvailable()) continue; - // QuantityPerAccount enforcement: exclude exhausted codes if ($code instanceof IDomainAuthorizedPromoCode) { $quantityPerAccount = $code->getQuantityPerAccount(); if ($quantityPerAccount > 0) { $usedCount = $this->repository->getTicketCountByMemberAndPromoCode($member, $code); - if ($usedCount >= $quantityPerAccount) { - continue; // exhausted - } + if ($usedCount >= $quantityPerAccount) continue; $code->setRemainingQuantityPerAccount($quantityPerAccount - $usedCount); } else { - $code->setRemainingQuantityPerAccount(null); // unlimited + $code->setRemainingQuantityPerAccount(null); } } $results[] = $code; } + // 5. Track-1 metric emit. $candidates is the pre-filter date-windowed set; + // valid_until_date_passed signals indexed-SQL self-termination per the + // SDS Verified Ops Precondition. + $duration_ms = (microtime(true) - $started) * 1000; + $now = new \DateTime('now', new \DateTimeZone('UTC')); + $finiteCandidates = array_filter( + $candidates, + fn($c) => method_exists($c, 'getValidUntilDate') && $c->getValidUntilDate() !== null + ); + $valid_until_date_passed = empty($finiteCandidates) + ? false + : array_reduce( + $finiteCandidates, + fn($acc, $c) => $acc && $c->getValidUntilDate() < $now, + true + ); + + Log::info('promo_code.discover.track1', [ + 'summit_id' => $summit->getId(), + 'code_count' => count($candidates), + 'duration_ms' => $duration_ms, + 'valid_until_date_passed' => $valid_until_date_passed, + ]); + return $results; } diff --git a/app/Services/ModelServicesProvider.php b/app/Services/ModelServicesProvider.php index 77f5af929..90eea8be5 100644 --- a/app/Services/ModelServicesProvider.php +++ b/app/Services/ModelServicesProvider.php @@ -23,6 +23,7 @@ use App\Services\FileSystem\Swift\SwiftStorageFileDownloadStrategy; use App\Services\FileSystem\Swift\SwiftStorageFileUploadStrategy; use App\Services\ISummitRSVPInvitationService; +use App\Services\Model\AllowedEmailDomainsLookupBuilder; use App\Services\Model\AttendeeService; use App\Services\Model\IAttendeeService; use App\Services\Model\IBadgeViewTypeService; @@ -203,6 +204,12 @@ public function register() MemberService::class ); + App::singleton + ( + AllowedEmailDomainsLookupBuilder::class, + AllowedEmailDomainsLookupBuilder::class + ); + App::singleton ( ISummitPromoCodeService::class, diff --git a/tests/Unit/Services/AllowedEmailDomainsLookupBuilderTest.php b/tests/Unit/Services/AllowedEmailDomainsLookupBuilderTest.php new file mode 100644 index 000000000..d52d6482c --- /dev/null +++ b/tests/Unit/Services/AllowedEmailDomainsLookupBuilderTest.php @@ -0,0 +1,116 @@ +builder = new AllowedEmailDomainsLookupBuilder(); + } + + public function testPartitionsAtDomainAndExactEmailIntoExactSet(): void + { + $lookup = $this->builder->build(['@acme.com', 'user@acme.com']); + $this->assertSame(['@acme.com' => true, 'user@acme.com' => true], $lookup->exactSet); + $this->assertSame([], $lookup->suffixList); + } + + public function testPartitionsLeadingDotIntoSuffixList(): void + { + $lookup = $this->builder->build(['.edu', '.gov']); + $this->assertSame([], $lookup->exactSet); + $this->assertSame(['.edu', '.gov'], $lookup->suffixList); + } + + public function testCaseInsensitiveDedup(): void + { + $lookup = $this->builder->build(['@acme.com', '@ACME.com', '@Acme.COM']); + $this->assertSame(['@acme.com' => true], $lookup->exactSet); + } + + public function testTrimsWhitespace(): void + { + $lookup = $this->builder->build([' @acme.com ', "\t.edu\n"]); + $this->assertArrayHasKey('@acme.com', $lookup->exactSet); + $this->assertContains('.edu', $lookup->suffixList); + } + + public function testDropsEmptyPatterns(): void + { + $lookup = $this->builder->build(['', ' ', '@acme.com']); + $this->assertSame(['@acme.com' => true], $lookup->exactSet); + $this->assertSame([], $lookup->suffixList); + } + + public function testPatternsHashStableUnderReordering(): void + { + $a = $this->builder->build(['@acme.com', '.edu', 'user@beta.io']); + $b = $this->builder->build(['user@beta.io', '@acme.com', '.edu']); + $c = $this->builder->build(['.edu', 'user@beta.io', '@acme.com']); + $this->assertSame($a->patternsHash, $b->patternsHash); + $this->assertSame($a->patternsHash, $c->patternsHash); + } + + public function testPatternsHashDiffersOnPatternSetChange(): void + { + $a = $this->builder->build(['@acme.com']); + $b = $this->builder->build(['@acme.com', '.edu']); + $this->assertNotSame($a->patternsHash, $b->patternsHash); + } + + public function testRealisticOcpMix(): void + { + $patterns = array_map(fn($i) => "@company{$i}.com", range(1, 50)); + $patterns[] = '.edu'; + $lookup = $this->builder->build($patterns); + $this->assertCount(50, $lookup->exactSet); + $this->assertSame(['.edu'], $lookup->suffixList); + } + + public function testEmptyInputProducesEmptyLookup(): void + { + $lookup = $this->builder->build([]); + $this->assertSame([], $lookup->exactSet); + $this->assertSame([], $lookup->suffixList); + // sha1('') is the well-defined hash for the empty pattern set. + $this->assertSame(sha1(''), $lookup->patternsHash); + } + + public function testMalformedPatternsDroppedSilently(): void + { + // No leading '@', no leading '.', no embedded '@' → silently dropped per spec. + $lookup = $this->builder->build(['acme.com', 'plainword', '@valid.com']); + + $this->assertSame(['@valid.com' => true], $lookup->exactSet); + $this->assertSame([], $lookup->suffixList); + } + + public function testUnrestrictedTrueOnEmptyInput(): void + { + $lookup = $this->builder->build([]); + $this->assertTrue($lookup->unrestricted); + } + + public function testUnrestrictedFalseOnNonEmptyInputEvenIfAllMalformed(): void + { + // Input is non-empty but every pattern is malformed (no @, no leading .). + // The DTO's exactSet/suffixList end up empty, but unrestricted must be false + // to preserve parity with legacy matchesEmailDomain (which returns false here). + $lookup = $this->builder->build(['acme.com', 'plainword']); + $this->assertSame([], $lookup->exactSet); + $this->assertSame([], $lookup->suffixList); + $this->assertFalse($lookup->unrestricted); + } + + public function testUnrestrictedFalseOnAnyValidInput(): void + { + $lookup = $this->builder->build(['@acme.com']); + $this->assertFalse($lookup->unrestricted); + } +} diff --git a/tests/Unit/Services/MatchesEmailDomainViaLookupTest.php b/tests/Unit/Services/MatchesEmailDomainViaLookupTest.php new file mode 100644 index 000000000..9beb1da29 --- /dev/null +++ b/tests/Unit/Services/MatchesEmailDomainViaLookupTest.php @@ -0,0 +1,123 @@ +builder = new AllowedEmailDomainsLookupBuilder(); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } + + private function makeCodeWithPatterns(array $patterns): DomainAuthorizedSummitRegistrationPromoCode + { + $code = new DomainAuthorizedSummitRegistrationPromoCode(); + $code->setAllowedEmailDomains($patterns); + return $code; + } + + private function assertParity(array $patterns, string $email): void + { + $code = $this->makeCodeWithPatterns($patterns); + $lookup = $this->builder->build($patterns); + + $legacy = $code->matchesEmailDomain($email); + $viaIndex = $code->matchesEmailDomainViaLookup($email, $lookup); + + $this->assertSame( + $legacy, + $viaIndex, + sprintf( + 'Parity break: patterns=%s email=%s legacy=%s viaLookup=%s', + json_encode($patterns), $email, + var_export($legacy, true), var_export($viaIndex, true) + ) + ); + } + + public function testExactAtDomainMatch(): void + { + $this->assertParity(['@acme.com'], 'user@acme.com'); + } + + public function testExactAtDomainMiss(): void + { + $this->assertParity(['@acme.com'], 'user@beta.io'); + } + + public function testCasingInsensitive(): void + { + $this->assertParity(['@ACME.com'], 'user@acme.com'); + $this->assertParity(['@acme.com'], 'User@ACME.COM'); + } + + public function testSuffixTld(): void + { + $this->assertParity(['.edu'], 'student@harvard.edu'); + $this->assertParity(['.edu'], 'user@acme.com'); + } + + public function testSuffixMultiLabel(): void + { + $this->assertParity(['.mit.edu'], 'user@cs.mit.edu'); + $this->assertParity(['.mit.edu'], 'user@harvard.edu'); + } + + public function testExactEmail(): void + { + $this->assertParity(['user@acme.com'], 'user@acme.com'); + $this->assertParity(['user@acme.com'], 'other@acme.com'); + } + + public function testMixedPatterns(): void + { + $patterns = ['@acme.com', '.edu', 'vip@beta.io']; + $this->assertParity($patterns, 'employee@acme.com'); + $this->assertParity($patterns, 'student@harvard.edu'); + $this->assertParity($patterns, 'vip@beta.io'); + $this->assertParity($patterns, 'random@elsewhere.com'); + } + + public function testEmptyPatternsMeansNoRestriction(): void + { + $this->assertParity([], 'anyone@anywhere.com'); + } + + public function testMalformedEmailReturnsFalse(): void + { + $code = $this->makeCodeWithPatterns(['@acme.com']); + $lookup = $this->builder->build(['@acme.com']); + + $this->assertFalse($code->matchesEmailDomainViaLookup('', $lookup)); + $this->assertFalse($code->matchesEmailDomainViaLookup('no-at-sign', $lookup)); + } + + public function testParityOnAllMalformedPatterns(): void + { + // Codex B1 regression: builder drops 'acme.com' as malformed, leaving an + // empty lookup. Legacy matchesEmailDomain returns false (non-empty array, + // no pattern matches). New matcher must agree via $unrestricted flag. + $this->assertParity(['acme.com'], 'user@acme.com'); + } + + public function testParityOnSuffixLookalikePattern(): void + { + // Bonus case: '@.edu' is a literal exact-domain pattern, NOT a TLD suffix. + // Legacy iterates and routes '@.edu' through the @-branch ($emailDomain + // must literally equal '@.edu', not '@foo.edu'). Builder routes '@.edu' + // to exactSet. Neither matches 'user@foo.edu'. Both return false. + $this->assertParity(['@.edu'], 'user@foo.edu'); + } +} diff --git a/tests/Unit/Services/SummitPromoCodeServiceDiscoveryTest.php b/tests/Unit/Services/SummitPromoCodeServiceDiscoveryTest.php index cd5840e50..7948b1e01 100644 --- a/tests/Unit/Services/SummitPromoCodeServiceDiscoveryTest.php +++ b/tests/Unit/Services/SummitPromoCodeServiceDiscoveryTest.php @@ -12,8 +12,11 @@ * limitations under the License. **/ +use App\Services\Model\AllowedEmailDomainsLookupBuilder; use App\Services\Model\SummitPromoCodeService; use App\Services\Utils\ILockManagerService; +use Illuminate\Container\Container; +use Illuminate\Support\Facades\Facade; use libs\utils\ITransactionService; use Mockery; use models\main\ICompanyRepository; @@ -27,6 +30,7 @@ use models\summit\ISummitRepository; use models\summit\Summit; use PHPUnit\Framework\TestCase; +use Psr\Log\NullLogger; /** * Class SummitPromoCodeServiceDiscoveryTest @@ -39,8 +43,24 @@ */ class SummitPromoCodeServiceDiscoveryTest extends TestCase { + protected function setUp(): void + { + parent::setUp(); + // Provide a minimal facade application so Log::info() calls in the SUT + // (Track-1 metric emit) resolve via the facade without requiring a full + // Laravel app or database. + Facade::clearResolvedInstances(); + $app = new Container(); + $app->singleton('log', fn() => new NullLogger()); + Container::setInstance($app); + Facade::setFacadeApplication($app); + } + protected function tearDown(): void { + Facade::setFacadeApplication(null); + Facade::clearResolvedInstances(); + Container::setInstance(null); Mockery::close(); parent::tearDown(); } @@ -56,7 +76,8 @@ private function buildService(ISummitRegistrationPromoCodeRepository $repository Mockery::mock(ITagRepository::class), $repository, Mockery::mock(ITransactionService::class), - Mockery::mock(ILockManagerService::class) + Mockery::mock(ILockManagerService::class), + new AllowedEmailDomainsLookupBuilder() ); } @@ -77,8 +98,12 @@ public function testDiscoverExcludesGloballyExhaustedDomainAuthorizedCode(): voi // surface as a quota check, not an uncaught Mockery error. $exhausted->shouldReceive('getQuantityPerAccount')->andReturn(0); $exhausted->shouldReceive('setRemainingQuantityPerAccount')->andReturn(null); + $exhausted->shouldReceive('getAllowedEmailDomains')->andReturn(['@acme.com']); + $exhausted->shouldReceive('matchesEmailDomainViaLookup')->andReturn(true); + $exhausted->shouldReceive('getValidUntilDate')->andReturn(null); $summit = Mockery::mock(Summit::class); + $summit->shouldReceive('getId')->andReturn(1); $member = Mockery::mock(Member::class); $member->shouldReceive('getEmail')->andReturn('new-buyer@acme.com'); $member->shouldReceive('getId')->andReturn(99); @@ -86,9 +111,12 @@ public function testDiscoverExcludesGloballyExhaustedDomainAuthorizedCode(): voi $repository = Mockery::mock(ISummitRegistrationPromoCodeRepository::class); // Repository filter is isLive()-only, so it would pass the exhausted // code through — simulate that by returning it. - $repository->shouldReceive('getDiscoverableByEmailForSummit') - ->with($summit, 'new-buyer@acme.com') + $repository->shouldReceive('getDomainAuthorizedDiscoverableForSummit') + ->with($summit) ->andReturn([$exhausted]); + $repository->shouldReceive('getEmailLinkedDiscoverableForSummit') + ->with($summit, 'new-buyer@acme.com') + ->andReturn([]); $service = $this->buildService($repository); $result = $service->discoverPromoCodes($summit, $member); @@ -109,16 +137,23 @@ public function testDiscoverReturnsHealthyDomainAuthorizedCode(): void $healthy->shouldReceive('hasQuantityAvailable')->andReturn(true); $healthy->shouldReceive('getQuantityPerAccount')->andReturn(0); $healthy->shouldReceive('setRemainingQuantityPerAccount')->with(null)->once(); + $healthy->shouldReceive('getAllowedEmailDomains')->andReturn(['@acme.com']); + $healthy->shouldReceive('matchesEmailDomainViaLookup')->andReturn(true); + $healthy->shouldReceive('getValidUntilDate')->andReturn(null); $summit = Mockery::mock(Summit::class); + $summit->shouldReceive('getId')->andReturn(1); $member = Mockery::mock(Member::class); $member->shouldReceive('getEmail')->andReturn('buyer@acme.com'); $member->shouldReceive('getId')->andReturn(42); $repository = Mockery::mock(ISummitRegistrationPromoCodeRepository::class); - $repository->shouldReceive('getDiscoverableByEmailForSummit') - ->with($summit, 'buyer@acme.com') + $repository->shouldReceive('getDomainAuthorizedDiscoverableForSummit') + ->with($summit) ->andReturn([$healthy]); + $repository->shouldReceive('getEmailLinkedDiscoverableForSummit') + ->with($summit, 'buyer@acme.com') + ->andReturn([]); $service = $this->buildService($repository); $result = $service->discoverPromoCodes($summit, $member); @@ -140,16 +175,23 @@ public function testDiscoverReturnsInfiniteDomainAuthorizedCode(): void $infinite->shouldReceive('hasQuantityAvailable')->andReturn(true); $infinite->shouldReceive('getQuantityPerAccount')->andReturn(0); $infinite->shouldReceive('setRemainingQuantityPerAccount')->with(null)->once(); + $infinite->shouldReceive('getAllowedEmailDomains')->andReturn(['@acme.com']); + $infinite->shouldReceive('matchesEmailDomainViaLookup')->andReturn(true); + $infinite->shouldReceive('getValidUntilDate')->andReturn(null); $summit = Mockery::mock(Summit::class); + $summit->shouldReceive('getId')->andReturn(1); $member = Mockery::mock(Member::class); $member->shouldReceive('getEmail')->andReturn('buyer@acme.com'); $member->shouldReceive('getId')->andReturn(11); $repository = Mockery::mock(ISummitRegistrationPromoCodeRepository::class); - $repository->shouldReceive('getDiscoverableByEmailForSummit') - ->with($summit, 'buyer@acme.com') + $repository->shouldReceive('getDomainAuthorizedDiscoverableForSummit') + ->with($summit) ->andReturn([$infinite]); + $repository->shouldReceive('getEmailLinkedDiscoverableForSummit') + ->with($summit, 'buyer@acme.com') + ->andReturn([]); $service = $this->buildService($repository); $result = $service->discoverPromoCodes($summit, $member); @@ -169,22 +211,32 @@ public function testDiscoverMixedHealthyAndExhaustedCodes(): void $exhausted->shouldReceive('hasQuantityAvailable')->andReturn(false); $exhausted->shouldReceive('getQuantityPerAccount')->andReturn(0); $exhausted->shouldReceive('setRemainingQuantityPerAccount')->andReturn(null); + $exhausted->shouldReceive('getAllowedEmailDomains')->andReturn(['@acme.com']); + $exhausted->shouldReceive('matchesEmailDomainViaLookup')->andReturn(true); + $exhausted->shouldReceive('getValidUntilDate')->andReturn(null); $healthy = Mockery::mock(DomainAuthorizedSummitRegistrationPromoCode::class); $healthy->shouldReceive('getCode')->andReturn('HEALTHY'); $healthy->shouldReceive('hasQuantityAvailable')->andReturn(true); $healthy->shouldReceive('getQuantityPerAccount')->andReturn(0); $healthy->shouldReceive('setRemainingQuantityPerAccount')->with(null)->once(); + $healthy->shouldReceive('getAllowedEmailDomains')->andReturn(['@acme.com']); + $healthy->shouldReceive('matchesEmailDomainViaLookup')->andReturn(true); + $healthy->shouldReceive('getValidUntilDate')->andReturn(null); $summit = Mockery::mock(Summit::class); + $summit->shouldReceive('getId')->andReturn(1); $member = Mockery::mock(Member::class); $member->shouldReceive('getEmail')->andReturn('buyer@acme.com'); $member->shouldReceive('getId')->andReturn(7); $repository = Mockery::mock(ISummitRegistrationPromoCodeRepository::class); - $repository->shouldReceive('getDiscoverableByEmailForSummit') - ->with($summit, 'buyer@acme.com') + $repository->shouldReceive('getDomainAuthorizedDiscoverableForSummit') + ->with($summit) ->andReturn([$exhausted, $healthy]); + $repository->shouldReceive('getEmailLinkedDiscoverableForSummit') + ->with($summit, 'buyer@acme.com') + ->andReturn([]); $service = $this->buildService($repository); $result = $service->discoverPromoCodes($summit, $member); @@ -192,4 +244,86 @@ public function testDiscoverMixedHealthyAndExhaustedCodes(): void $this->assertCount(1, $result); $this->assertSame('HEALTHY', $result[0]->getCode()); } + + /** + * Regression: a DA code returned by the repository but NOT matching the + * member's email domain (via the real AllowedEmailDomainsLookupBuilder + + * matchesEmailDomainViaLookup trait method) must be excluded from results. + * + * Existing tests all mock matchesEmailDomainViaLookup -> true; a regression + * making the matcher return true regardless of input would silently expose + * all in-date DA codes to every member. This pins the negative path by + * exercising the real trait method against a real lookup structure + * (allowed_email_domains=['@acme.com'] vs email user@other.com). + */ + public function testDomainNonMatchingDACodeIsExcluded(): void + { + // makePartial(): leave matchesEmailDomainViaLookup unmocked so the real + // trait method runs against the real AllowedEmailDomainsLookup built by + // buildService()'s injected AllowedEmailDomainsLookupBuilder. + $nonMatching = Mockery::mock(DomainAuthorizedSummitRegistrationPromoCode::class)->makePartial(); + $nonMatching->shouldReceive('getCode')->andReturn('OTHER_DOMAIN'); + $nonMatching->shouldReceive('hasQuantityAvailable')->andReturn(true); + $nonMatching->shouldReceive('getQuantityPerAccount')->andReturn(0); + // setRemainingQuantityPerAccount must NEVER be called — the code is + // filtered out at the email-match step before reaching the quantity loop. + $nonMatching->shouldNotReceive('setRemainingQuantityPerAccount'); + $nonMatching->shouldReceive('getAllowedEmailDomains')->andReturn(['@acme.com']); + // Intentionally NOT mocking matchesEmailDomainViaLookup — real trait + // method runs and returns false for user@other.com vs @acme.com. + $nonMatching->shouldReceive('getValidUntilDate')->andReturn(null); + + $summit = Mockery::mock(Summit::class); + $summit->shouldReceive('getId')->andReturn(1); + $member = Mockery::mock(Member::class); + $member->shouldReceive('getEmail')->andReturn('user@other.com'); + $member->shouldReceive('getId')->andReturn(123); + + $repository = Mockery::mock(ISummitRegistrationPromoCodeRepository::class); + $repository->shouldReceive('getDomainAuthorizedDiscoverableForSummit') + ->with($summit) + ->andReturn([$nonMatching]); + // Email-linked lookup must return [] so the only candidate flows through + // the DA email-domain match path and gets excluded there. + $repository->shouldReceive('getEmailLinkedDiscoverableForSummit') + ->with($summit, 'user@other.com') + ->andReturn([]); + + $service = $this->buildService($repository); + $result = $service->discoverPromoCodes($summit, $member); + + $this->assertSame([], $result, + 'DA code whose allowed_email_domains do not match the member email must be excluded'); + } + + /** + * Regression: a Member whose getEmail() returns whitespace-only must not + * trigger any repository call and must return an empty result. + * + * empty(" ") returns false in PHP, so the early-return guard must trim + * before testing emptiness. See @smarcet review finding 1 on PR #546. + * Guard lives at SummitPromoCodeService::discoverPromoCodes L1034: + * if (empty(trim($email))) return []; + */ + public function testDiscoverReturnsEmptyForWhitespaceOnlyEmail(): void + { + $summit = Mockery::mock(Summit::class); + // Guard returns before any Summit method is touched; do not stub getId(). + + $member = Mockery::mock(Member::class); + $member->shouldReceive('getEmail')->andReturn(" \t\n "); + + $repository = Mockery::mock(ISummitRegistrationPromoCodeRepository::class); + // Neither repo lookup may be called — the early-return guard at L1034 + // must short-circuit before any I/O. shouldNotReceive turns any call + // into an assertion failure. + $repository->shouldNotReceive('getDomainAuthorizedDiscoverableForSummit'); + $repository->shouldNotReceive('getEmailLinkedDiscoverableForSummit'); + + $service = $this->buildService($repository); + $result = $service->discoverPromoCodes($summit, $member); + + $this->assertSame([], $result, + 'Whitespace-only email must early-return [] without any repository call'); + } }