From 7b257a71b3f800581b18e4871fe31193e3375df5 Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Mon, 18 May 2026 13:51:12 -0500 Subject: [PATCH 1/9] feat(promo-codes): add AllowedEmailDomainsLookup DTO + builder for Track 1 --- .../PromoCodes/AllowedEmailDomainsLookup.php | 36 ++++++++ .../Imp/AllowedEmailDomainsLookupBuilder.php | 81 ++++++++++++++++++ .../AllowedEmailDomainsLookupBuilderTest.php | 83 +++++++++++++++++++ 3 files changed, 200 insertions(+) create mode 100644 app/Models/Foundation/Summit/Registration/PromoCodes/AllowedEmailDomainsLookup.php create mode 100644 app/Services/Model/Imp/AllowedEmailDomainsLookupBuilder.php create mode 100644 tests/Unit/Models/Foundation/Summit/Registration/PromoCodes/AllowedEmailDomainsLookupBuilderTest.php 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..c71d7c737 --- /dev/null +++ b/app/Models/Foundation/Summit/Registration/PromoCodes/AllowedEmailDomainsLookup.php @@ -0,0 +1,36 @@ + 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 cache keys / equality. + */ +final class AllowedEmailDomainsLookupBuilder +{ + public function build(array $patterns): AllowedEmailDomainsLookup + { + $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); + } +} diff --git a/tests/Unit/Models/Foundation/Summit/Registration/PromoCodes/AllowedEmailDomainsLookupBuilderTest.php b/tests/Unit/Models/Foundation/Summit/Registration/PromoCodes/AllowedEmailDomainsLookupBuilderTest.php new file mode 100644 index 000000000..94dec3f68 --- /dev/null +++ b/tests/Unit/Models/Foundation/Summit/Registration/PromoCodes/AllowedEmailDomainsLookupBuilderTest.php @@ -0,0 +1,83 @@ +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); + $this->assertNotEmpty($lookup->patternsHash); + } +} From c0789ee14362a9861f3527d99e1c610a14f9be85 Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Mon, 18 May 2026 13:59:44 -0500 Subject: [PATCH 2/9] test(promo-codes): move AllowedEmailDomainsLookupBuilderTest to Unit/Services + cover malformed-drop --- .../AllowedEmailDomainsLookupBuilderTest.php | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/Unit/{Models/Foundation/Summit/Registration/PromoCodes => Services}/AllowedEmailDomainsLookupBuilderTest.php (100%) diff --git a/tests/Unit/Models/Foundation/Summit/Registration/PromoCodes/AllowedEmailDomainsLookupBuilderTest.php b/tests/Unit/Services/AllowedEmailDomainsLookupBuilderTest.php similarity index 100% rename from tests/Unit/Models/Foundation/Summit/Registration/PromoCodes/AllowedEmailDomainsLookupBuilderTest.php rename to tests/Unit/Services/AllowedEmailDomainsLookupBuilderTest.php From 6063474ca80afb3e0f16173de2af0595efa06b87 Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Mon, 18 May 2026 13:59:53 -0500 Subject: [PATCH 3/9] test(promo-codes): align test namespace + drop tautological hash assert + cover malformed silent-drop --- .../AllowedEmailDomainsLookupBuilderTest.php | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/tests/Unit/Services/AllowedEmailDomainsLookupBuilderTest.php b/tests/Unit/Services/AllowedEmailDomainsLookupBuilderTest.php index 94dec3f68..8aec5a0a0 100644 --- a/tests/Unit/Services/AllowedEmailDomainsLookupBuilderTest.php +++ b/tests/Unit/Services/AllowedEmailDomainsLookupBuilderTest.php @@ -1,4 +1,4 @@ -builder->build([]); $this->assertSame([], $lookup->exactSet); $this->assertSame([], $lookup->suffixList); - $this->assertNotEmpty($lookup->patternsHash); + // 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); } } From 118f016468927a7af58e482bbe6e36e97bab5bb0 Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Mon, 18 May 2026 14:03:19 -0500 Subject: [PATCH 4/9] feat(promo-codes): add matchesEmailDomainViaLookup parity matcher --- .../DomainAuthorizedPromoCodeTrait.php | 33 ++++++ .../PromoCodes/IDomainAuthorizedPromoCode.php | 14 +++ .../MatchesEmailDomainViaLookupTest.php | 106 ++++++++++++++++++ 3 files changed, 153 insertions(+) create mode 100644 tests/Unit/Services/MatchesEmailDomainViaLookupTest.php diff --git a/app/Models/Foundation/Summit/Registration/PromoCodes/DomainAuthorizedPromoCodeTrait.php b/app/Models/Foundation/Summit/Registration/PromoCodes/DomainAuthorizedPromoCodeTrait.php index 2fd0b748d..fba47a8d4 100644 --- a/app/Models/Foundation/Summit/Registration/PromoCodes/DomainAuthorizedPromoCodeTrait.php +++ b/app/Models/Foundation/Summit/Registration/PromoCodes/DomainAuthorizedPromoCodeTrait.php @@ -110,6 +110,39 @@ public function matchesEmailDomain(string $email): bool return false; } + /** + * Lookup-driven sibling of matchesEmailDomain(). + * Consumes a precomputed AllowedEmailDomainsLookup so the caller does not + * pay strtolower/trim per pattern when matching many codes against the + * same pattern set. Must return parity with matchesEmailDomain(). + * + * @param string $email + * @param AllowedEmailDomainsLookup $lookup + * @return bool + */ + public function matchesEmailDomainViaLookup(string $email, AllowedEmailDomainsLookup $lookup): bool + { + // No restriction when neither exact nor suffix patterns exist. + if (empty($lookup->exactSet) && empty($lookup->suffixList)) 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/tests/Unit/Services/MatchesEmailDomainViaLookupTest.php b/tests/Unit/Services/MatchesEmailDomainViaLookupTest.php new file mode 100644 index 000000000..c429e397a --- /dev/null +++ b/tests/Unit/Services/MatchesEmailDomainViaLookupTest.php @@ -0,0 +1,106 @@ +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)); + } +} From 5a65009138950c8d3c8a52717e96971a74a11ef4 Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Mon, 18 May 2026 14:19:29 -0500 Subject: [PATCH 5/9] feat(promo-codes): Track 1 repo-decouple + lookup-driven discover + metric --- ...ISummitRegistrationPromoCodeRepository.php | 7 +- ...eSummitRegistrationPromoCodeRepository.php | 34 ++++---- .../Model/Imp/SummitPromoCodeService.php | 84 ++++++++++++++----- app/Services/ModelServicesProvider.php | 7 ++ .../SummitPromoCodeServiceDiscoveryTest.php | 70 ++++++++++++++-- 5 files changed, 152 insertions(+), 50 deletions(-) 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..431f991b5 100644 --- a/app/Repositories/Summit/DoctrineSummitRegistrationPromoCodeRepository.php +++ b/app/Repositories/Summit/DoctrineSummitRegistrationPromoCodeRepository.php @@ -669,24 +669,29 @@ public function getDiscoverableByEmailForSummit(Summit $summit, string $email): { if (empty($email)) return []; - $email = strtolower(trim($email)); + $normalized = 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($email)) { + $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 +707,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/SummitPromoCodeService.php b/app/Services/Model/Imp/SummitPromoCodeService.php index 4c7543c11..a5c951108 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; } /** @@ -1024,34 +1033,69 @@ public function discoverPromoCodes(Summit $summit, Member $member): array $email = $member->getEmail(); if (empty($email)) return []; - $codes = $this->repository->getDiscoverableByEmailForSummit($summit, $email); - $results = []; + $started = microtime(true); + $normalizedEmail = strtolower(trim($email)); + + // 1. Date-windowed DA candidates (raw, unfiltered by email). + $candidates = $this->repository->getDomainAuthorizedDiscoverableForSummit($summit); - 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; + // 2. Build per-code lookup once; filter via O(1) set membership + small suffix scan. + $matched = []; + foreach ($candidates as $code) { + if (!$code instanceof IDomainAuthorizedPromoCode) continue; + $lookup = $this->lookup_builder->build($code->getAllowedEmailDomains()); + if ($code->matchesEmailDomainViaLookup($normalizedEmail, $lookup)) { + $matched[] = $code; } + } + + // 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); + + // 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(); + $valid_until_date_passed = empty($candidates) + ? true + : array_reduce( + $candidates, + fn($acc, $c) => $acc + && method_exists($c, 'getValidUntilDate') + && $c->getValidUntilDate() !== null + && $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/SummitPromoCodeServiceDiscoveryTest.php b/tests/Unit/Services/SummitPromoCodeServiceDiscoveryTest.php index cd5840e50..dc3f88eae 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); From f53bb287dfdecf96499b8b8c0515ed926e577d18 Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Mon, 18 May 2026 14:29:35 -0500 Subject: [PATCH 6/9] docs(promo-codes): mark getDiscoverableByEmailForSummit @deprecated for Track 2 cleanup --- .../DoctrineSummitRegistrationPromoCodeRepository.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/Repositories/Summit/DoctrineSummitRegistrationPromoCodeRepository.php b/app/Repositories/Summit/DoctrineSummitRegistrationPromoCodeRepository.php index 431f991b5..28eea7a71 100644 --- a/app/Repositories/Summit/DoctrineSummitRegistrationPromoCodeRepository.php +++ b/app/Repositories/Summit/DoctrineSummitRegistrationPromoCodeRepository.php @@ -661,6 +661,12 @@ 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[] From 01f4113e4cf1935aa455ed322f34fa88a70992f4 Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Mon, 18 May 2026 15:41:10 -0500 Subject: [PATCH 7/9] fix(promo-codes): unrestricted flag preserves legacy parity for malformed-only arrays --- .../PromoCodes/AllowedEmailDomainsLookup.php | 10 ++++++-- .../DomainAuthorizedPromoCodeTrait.php | 6 +++-- .../Imp/AllowedEmailDomainsLookupBuilder.php | 10 ++++++-- .../AllowedEmailDomainsLookupBuilderTest.php | 23 +++++++++++++++++++ .../MatchesEmailDomainViaLookupTest.php | 17 ++++++++++++++ 5 files changed, 60 insertions(+), 6 deletions(-) diff --git a/app/Models/Foundation/Summit/Registration/PromoCodes/AllowedEmailDomainsLookup.php b/app/Models/Foundation/Summit/Registration/PromoCodes/AllowedEmailDomainsLookup.php index c71d7c737..eb5b2a30c 100644 --- a/app/Models/Foundation/Summit/Registration/PromoCodes/AllowedEmailDomainsLookup.php +++ b/app/Models/Foundation/Summit/Registration/PromoCodes/AllowedEmailDomainsLookup.php @@ -23,14 +23,20 @@ * - $exactSet: O(1) lookup map for "@domain.tld" and "user@domain.tld" patterns * - $suffixList: array of ".tld"-style suffixes for endsWith / str_ends_with checks * - $patternsHash: stable sha1 over the sorted normalized pattern set; identifies - * the pattern set for cache keys / change detection. + * the pattern set for change detection / equality comparisons. + * - $unrestricted: true iff the input pattern array was empty. Distinguishes + * "no patterns configured" (legacy "no restriction") from + * "patterns configured but all malformed" (which the legacy + * matcher treats as no match — see + * DomainAuthorizedPromoCodeTrait::matchesEmailDomain parity contract). */ final class AllowedEmailDomainsLookup { public function __construct( public readonly array $exactSet, public readonly array $suffixList, - public readonly string $patternsHash + public readonly string $patternsHash, + public readonly bool $unrestricted ) { } } diff --git a/app/Models/Foundation/Summit/Registration/PromoCodes/DomainAuthorizedPromoCodeTrait.php b/app/Models/Foundation/Summit/Registration/PromoCodes/DomainAuthorizedPromoCodeTrait.php index fba47a8d4..974e75067 100644 --- a/app/Models/Foundation/Summit/Registration/PromoCodes/DomainAuthorizedPromoCodeTrait.php +++ b/app/Models/Foundation/Summit/Registration/PromoCodes/DomainAuthorizedPromoCodeTrait.php @@ -122,8 +122,10 @@ public function matchesEmailDomain(string $email): bool */ public function matchesEmailDomainViaLookup(string $email, AllowedEmailDomainsLookup $lookup): bool { - // No restriction when neither exact nor suffix patterns exist. - if (empty($lookup->exactSet) && empty($lookup->suffixList)) return true; + // Parity with legacy matchesEmailDomain(): only return "match anything" + // when the stored pattern array was actually empty. A non-empty array + // whose patterns all dropped as malformed must still return false here. + if ($lookup->unrestricted) return true; $email = strtolower(trim($email)); if ($email === '') return false; diff --git a/app/Services/Model/Imp/AllowedEmailDomainsLookupBuilder.php b/app/Services/Model/Imp/AllowedEmailDomainsLookupBuilder.php index 2aa028581..bc0416cea 100644 --- a/app/Services/Model/Imp/AllowedEmailDomainsLookupBuilder.php +++ b/app/Services/Model/Imp/AllowedEmailDomainsLookupBuilder.php @@ -33,12 +33,18 @@ * - otherwise -> dropped silently * * patternsHash = sha1(implode('|', $sortedNormalizedPatterns)) — stable - * regardless of input order, so callers can use it for cache keys / equality. + * 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 = []; @@ -76,6 +82,6 @@ public function build(array $patterns): AllowedEmailDomainsLookup sort($normalized); $patternsHash = sha1(implode('|', $normalized)); - return new AllowedEmailDomainsLookup($exactSet, $suffixList, $patternsHash); + return new AllowedEmailDomainsLookup($exactSet, $suffixList, $patternsHash, $unrestricted); } } diff --git a/tests/Unit/Services/AllowedEmailDomainsLookupBuilderTest.php b/tests/Unit/Services/AllowedEmailDomainsLookupBuilderTest.php index 8aec5a0a0..d52d6482c 100644 --- a/tests/Unit/Services/AllowedEmailDomainsLookupBuilderTest.php +++ b/tests/Unit/Services/AllowedEmailDomainsLookupBuilderTest.php @@ -90,4 +90,27 @@ public function testMalformedPatternsDroppedSilently(): void $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 index c429e397a..9beb1da29 100644 --- a/tests/Unit/Services/MatchesEmailDomainViaLookupTest.php +++ b/tests/Unit/Services/MatchesEmailDomainViaLookupTest.php @@ -103,4 +103,21 @@ public function testMalformedEmailReturnsFalse(): void $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'); + } } From 5f7c26a0eac6ee268205cdce8d919eb1e8eee8b0 Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Mon, 18 May 2026 17:03:03 -0500 Subject: [PATCH 8/9] fix(promo-codes): trim email before empty guard in aggregator (CodeRabbit) --- .../DoctrineSummitRegistrationPromoCodeRepository.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/Repositories/Summit/DoctrineSummitRegistrationPromoCodeRepository.php b/app/Repositories/Summit/DoctrineSummitRegistrationPromoCodeRepository.php index 28eea7a71..76262e726 100644 --- a/app/Repositories/Summit/DoctrineSummitRegistrationPromoCodeRepository.php +++ b/app/Repositories/Summit/DoctrineSummitRegistrationPromoCodeRepository.php @@ -673,14 +673,14 @@ public function getBySummitAndCode(Summit $summit, string $code):?SummitRegistra */ public function getDiscoverableByEmailForSummit(Summit $summit, string $email): array { - if (empty($email)) return []; - $normalized = strtolower(trim($email)); + if ($normalized === '') return []; + $daCandidates = $this->getDomainAuthorizedDiscoverableForSummit($summit); $daMatched = []; foreach ($daCandidates as $code) { - if ($code instanceof IDomainAuthorizedPromoCode && $code->matchesEmailDomain($email)) { + if ($code instanceof IDomainAuthorizedPromoCode && $code->matchesEmailDomain($normalized)) { $daMatched[] = $code; } } From d7cdc3bb50239fc4a5c5a6f82c39ef9541676fb6 Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Tue, 19 May 2026 13:51:06 -0500 Subject: [PATCH 9/9] fix(promo-codes): apply smarcet review on discoverPromoCodes (PR #546) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses all 5 review findings from @smarcet on the Track-1 discover hot path in SummitPromoCodeService::discoverPromoCodes. 1. Whitespace-only email bypassed the empty() guard (empty(" ") === false), causing strtolower(trim(...)) -> "" to flow into the email-linked repo query and potentially match codes with NULL/empty email columns. Guard now normalizes first: if (empty(trim($email))) return []. 2. Per-code lookup rebuild defeated the SDS-promised "per-request micro-opt" — N codes meant N full normalize+sort+sha1 passes. Added $lookupCache keyed by sha1(implode("\0", $patterns)) so sibling codes sharing byte-identical pattern arrays reuse a single built lookup. Cache pre-hash is order- and case-sensitive (acceptable for upstream-generated arrays; future T1-Sanity can surface lower-than-expected hit rate if needed). 3. new \DateTime() replaced with new \DateTime('now', new DateTimeZone('UTC')) to match the repository convention at DoctrineSummitRegistrationPromoCode Repository.php:699. Note: config/app.php sets timezone => 'UTC' and PHP DateTime comparison normalizes via Unix timestamp, so runtime behavior is unchanged — change is defensive consistency. 4. valid_until_date_passed had two semantic bugs at the metric emit: - empty($candidates) short-circuited to true (zero DA codes != all expired) - open-ended codes (null valid_until_date) collapsed the reduce accumulator to false, masking the all-finite-expired case Filter to finite candidates first via array_filter; default empty-finite case to false; simplify reduce predicate since filter pre-enforces the invariants. 5. Test coverage gaps: all 4 existing discover tests mocked matchesEmailDomainViaLookup -> true, so a regression there would silently leak all in-date DA codes to every member. Added two tests: - testDomainNonMatchingDACodeIsExcluded — uses real AllowedEmailDomainsLookupBuilder + makePartial() so the real matchesEmailDomainViaLookup trait method fires; member email user@other.com against a code allowing @acme.com asserts exclusion. - testDiscoverReturnsEmptyForWhitespaceOnlyEmail — pins fix 1 with shouldNotReceive on both repo methods so any guard regression fails. vendor/bin/phpunit tests/Unit/Services -> 30/30 passing (was 28; +2 from the new discovery tests). Builder 12 + parity 11 + Discovery 7 = 30. --- .../Model/Imp/SummitPromoCodeService.php | 30 ++++--- .../SummitPromoCodeServiceDiscoveryTest.php | 82 +++++++++++++++++++ 2 files changed, 100 insertions(+), 12 deletions(-) diff --git a/app/Services/Model/Imp/SummitPromoCodeService.php b/app/Services/Model/Imp/SummitPromoCodeService.php index a5c951108..d55117d88 100644 --- a/app/Services/Model/Imp/SummitPromoCodeService.php +++ b/app/Services/Model/Imp/SummitPromoCodeService.php @@ -1031,7 +1031,7 @@ 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)); @@ -1039,11 +1039,16 @@ public function discoverPromoCodes(Summit $summit, Member $member): array // 1. Date-windowed DA candidates (raw, unfiltered by email). $candidates = $this->repository->getDomainAuthorizedDiscoverableForSummit($summit); - // 2. Build per-code lookup once; filter via O(1) set membership + small suffix scan. - $matched = []; + // 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; - $lookup = $this->lookup_builder->build($code->getAllowedEmailDomains()); + $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; } @@ -1077,15 +1082,16 @@ public function discoverPromoCodes(Summit $summit, Member $member): array // valid_until_date_passed signals indexed-SQL self-termination per the // SDS Verified Ops Precondition. $duration_ms = (microtime(true) - $started) * 1000; - $now = new \DateTime(); - $valid_until_date_passed = empty($candidates) - ? true + $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( - $candidates, - fn($acc, $c) => $acc - && method_exists($c, 'getValidUntilDate') - && $c->getValidUntilDate() !== null - && $c->getValidUntilDate() < $now, + $finiteCandidates, + fn($acc, $c) => $acc && $c->getValidUntilDate() < $now, true ); diff --git a/tests/Unit/Services/SummitPromoCodeServiceDiscoveryTest.php b/tests/Unit/Services/SummitPromoCodeServiceDiscoveryTest.php index dc3f88eae..7948b1e01 100644 --- a/tests/Unit/Services/SummitPromoCodeServiceDiscoveryTest.php +++ b/tests/Unit/Services/SummitPromoCodeServiceDiscoveryTest.php @@ -244,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'); + } }