Skip to content

feat(promo-codes): Track 1 telemetry + lookup-build for DA discover#546

Merged
smarcet merged 9 commits into
mainfrom
feature/domain-authorized-promo-codes-track1
May 19, 2026
Merged

feat(promo-codes): Track 1 telemetry + lookup-build for DA discover#546
smarcet merged 9 commits into
mainfrom
feature/domain-authorized-promo-codes-track1

Conversation

@caseylocker
Copy link
Copy Markdown
Contributor

@caseylocker caseylocker commented May 18, 2026

ref: https://app.clickup.com/t/86ba0b8tz

Summary

Ships Track 1 of the Domain-Authorized Promo Codes storage-and-lookup SDS per ftn-docsnsklz PR #15. No schema change, no Redis. Two new classes, one new trait method, a repository decouple that drops $email from the leaf signature and moves email filtering into the service, plus a single observability emit that becomes the Track-2 trigger instrumentation.

  • New models\summit\AllowedEmailDomainsLookup (final readonly DTO: exactSet / suffixList / patternsHash / unrestricted).
  • New App\Services\Model\AllowedEmailDomainsLookupBuilder (pure builder, no DB / cache / Doctrine).
  • New trait method IDomainAuthorizedPromoCode::matchesEmailDomainViaLookup(string $email, AllowedEmailDomainsLookup $lookup): bool — O(1) exact-set + linear suffix scan. Parity-tested against legacy matchesEmailDomain for every pattern shape and casing.
  • Repository decouple (option-(b)): getDomainAuthorizedDiscoverableForSummit drops $email and the PHP filter loop; returns raw date-windowed candidates. Aggregator getDiscoverableByEmailForSummit rewritten with an inline legacy filter so its public by-email contract is preserved (marked @deprecated for Track 2 cleanup).
  • SummitPromoCodeService::discoverPromoCodes rewritten — calls the two leaf methods directly, builds per-code AllowedEmailDomainsLookup with a pattern-hash-keyed cache so sibling codes sharing pattern arrays reuse a single normalized lookup, filters via matchesEmailDomainViaLookup. Eliminates the ~7,000 strtolower(trim()) calls per OCP-volume discover request (~35 ms → sub-ms).
  • Track-1 observability emit: Log::info('promo_code.discover.track1', [...]) with exactly the 4 attrs the SDS specifies: summit_id, code_count, duration_ms, valid_until_date_passed. The valid_until_date_passed flag is the indexed-SQL self-termination signal JP's verified ops precondition relies on.

Companion PR: fntechgit/summit-admin#947 (Tier 1 bulk-input frontend) — same prod push, week of 2026-05-19.

Out of scope (Track 2, decoupled — target branch cut 2026-06-18): PromoCodeEmailDomainPattern child table + migration, candidate-decomposition matcher (EmailDomainCandidateBuilder), batched getTicketCountsByMemberAndPromoCodes, trait getter/setter delegation. No Redis at any track under pass-4.

ClickUp: https://app.clickup.com/t/86ba0b8tz

smarcet review pass — 2026-05-19 (commit d7cdc3bb5)

All 5 review findings applied in one commit on discoverPromoCodes. Inline replies tying each fix to file:line landed on the original review threads.

  1. Whitespace-only email bypassed the empty() guard (empty(" ") === false, so the empty-string flowed into the email-linked repo query). Guard now reads if (empty(trim($email))) return []; at L1034, mirroring the legacy aggregator's normalize-first order at DoctrineSummitRegistrationPromoCodeRepository.php:677.
  2. Per-code lookup rebuild defeated the SDS-promised micro-opt. Added $lookupCache keyed by sha1(implode("\0", $patterns)) at L1042-1054 so sibling codes with byte-identical pattern arrays reuse a single built lookup. Pre-hash is order- and case-sensitive — acceptable for upstream-generated arrays; future T1-Sanity can surface lower-than-expected hit rate if a refinement to normalize patterns before hashing is needed.
  3. new \DateTime() not explicitly UTC. L1083 now uses new \DateTime('now', new \DateTimeZone('UTC')) matching the repository at L699. Note: config/app.php:70 sets 'timezone' => 'UTC' and PHP \DateTime comparison normalizes via Unix timestamp regardless of TZ, so the runtime behavior was already correct — change is defensive consistency.
  4. valid_until_date_passed two semantic bugs. L1084-1094 now filters $candidates to a $finiteCandidates set (non-null getValidUntilDate()); empty-finite case defaults to false instead of true (resolves Bug A — zero DA codes ≠ all expired); open-ended codes no longer collapse the accumulator (resolves Bug B). The reduce predicate simplifies to $acc && $c->getValidUntilDate() < $now since the filter pre-enforces the invariants.
  5. Test coverage gap on negative match path. All 4 prior discovery tests mocked matchesEmailDomainViaLookuptrue. Added testDomainNonMatchingDACodeIsExcluded using the real AllowedEmailDomainsLookupBuilder (already wired into buildService() at L80) plus Mockery makePartial() so the real matchesEmailDomainViaLookup trait method fires. Also added testDiscoverReturnsEmptyForWhitespaceOnlyEmail with shouldNotReceive on both repo methods to pin the fix-1 guard.

Test plan

  • CI: vendor/bin/phpunit green for the 3 hot-path suites — 30/30 as of d7cdc3bb5:
    • tests/Unit/Services/AllowedEmailDomainsLookupBuilderTest.php (12/12)
    • tests/Unit/Services/MatchesEmailDomainViaLookupTest.php (11/11)
    • tests/Unit/Services/SummitPromoCodeServiceDiscoveryTest.php (7/7 — was 5; +2 from smarcet review pass)
  • DoD verification — leaf method body contains zero matchesEmailDomain( calls (aggregator exempt):
    awk '/^    public function getDomainAuthorizedDiscoverableForSummit\(/,/^    }/' \
      app/Repositories/Summit/DoctrineSummitRegistrationPromoCodeRepository.php \
      | grep -c 'matchesEmailDomain('
    # must print: 0
  • Behavior parity — existing discover-endpoint integration tests pass unmodified
  • Local smoke against seeded summit:
    php artisan tinker
    $svc = app(\App\Services\Model\ISummitPromoCodeService::class);
    $result = $svc->discoverPromoCodes($summit, $member);
    # tail laravel.log for the promo_code.discover.track1 emit

Track 1 staging sanity measurement (REQUIRED before prod merge)

Per SDS Task T1-Sanity — paste results here before approving.

  1. Seed staging with one OCP-realistic DA code (valid_until_date in future, ~1,400 patterns, ~100% @domain per JP's OCP data).
  2. Hit GET /api/v1/summits/{id}/promo-codes/all/discover as a member whose email matches a pattern; tail laravel.log for promo_code.discover.track1.
  3. Confirm duration_ms ≈ 35 ms, valid_until_date_passed = false, code_count = 1.
  4. SQL-flip the code's valid_until_date to the past, re-hit the endpoint; confirm valid_until_date_passed flips to true and code_count → 0.
  5. Paste both measurements into this PR description.

If anything is unexpected (duration wildly higher than ~35 ms, or valid_until_date_passed doesn't flip), pause and escalate to the Track-2 owner — the Track-1 posture assumption is broken.

Summary by CodeRabbit

  • New Features

    • Domain-based promo code discovery using precomputed allowed-domain lookups for faster, consistent matching.
  • Improvements

    • More accurate promo-code filtering (merges domain-authorized and email-linked candidates, normalizes emails, skips invalid/whitespace inputs).
    • Deterministic handling of allowed-domain patterns and stable identity detection.
  • Tests

    • Extensive unit tests covering pattern partitioning, matching parity, discovery flows, and edge cases.

Review Change Stack

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 18, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 40354216-2600-45f3-afbc-96303a2b865b

📥 Commits

Reviewing files that changed from the base of the PR and between 01f4113 and d7cdc3b.

📒 Files selected for processing (3)
  • app/Repositories/Summit/DoctrineSummitRegistrationPromoCodeRepository.php
  • app/Services/Model/Imp/SummitPromoCodeService.php
  • tests/Unit/Services/SummitPromoCodeServiceDiscoveryTest.php
🚧 Files skipped from review as they are similar to previous changes (2)
  • app/Services/Model/Imp/SummitPromoCodeService.php
  • app/Repositories/Summit/DoctrineSummitRegistrationPromoCodeRepository.php

📝 Walkthrough

Walkthrough

This PR adds a precomputed AllowedEmailDomainsLookup DTO and builder, implements a lookup-based matcher, removes email filtering from the repository contract, and updates SummitPromoCodeService to filter domain-authorized candidates in-memory using the lookup (merged with email-linked codes) while emitting basic telemetry.

Changes

Domain Lookup & Promo Code Discovery Refactoring

Layer / File(s) Summary
Domain Lookup DTO and Builder
app/Models/Foundation/Summit/Registration/PromoCodes/AllowedEmailDomainsLookup.php, app/Services/Model/Imp/AllowedEmailDomainsLookupBuilder.php, tests/Unit/Services/AllowedEmailDomainsLookupBuilderTest.php
AllowedEmailDomainsLookup DTO defines readonly exactSet, suffixList, patternsHash, and unrestricted. AllowedEmailDomainsLookupBuilder->build() normalizes (lower/trim), deduplicates (first-win, case-insensitive), partitions patterns into exact vs suffix lists, drops malformed entries, sorts normalized patterns, computes stable patternsHash, and returns the DTO. Tests cover partitioning, dedup, trimming, hashing stability, and unrestricted semantics.
Lookup-Based Email Domain Matching
app/Models/Foundation/Summit/Registration/PromoCodes/IDomainAuthorizedPromoCode.php, app/Models/Foundation/Summit/Registration/PromoCodes/DomainAuthorizedPromoCodeTrait.php, tests/Unit/Services/MatchesEmailDomainViaLookupTest.php
Adds interface method matchesEmailDomainViaLookup(string $email, AllowedEmailDomainsLookup $lookup): bool. Trait implements lookup-based matching: short-circuits on unrestricted, normalizes email, extracts domain, checks exactSet (email or domain) and suffixList. Unit tests assert parity with legacy matcher across exact, suffix, exact-email, mixed, empty, and malformed cases.
Repository Contract Refactoring
app/Models/Foundation/Summit/Repositories/ISummitRegistrationPromoCodeRepository.php, app/Repositories/Summit/DoctrineSummitRegistrationPromoCodeRepository.php
getDomainAuthorizedDiscoverableForSummit() no longer takes an email and now returns date-windowed domain-authorized candidates without email filtering. getDiscoverableByEmailForSummit() kept with a deprecation note and still provides backward behavior by normalizing and filtering when used.
Service Integration and Discovery Flow
app/Services/ModelServicesProvider.php, app/Services/Model/Imp/SummitPromoCodeService.php, tests/Unit/Services/SummitPromoCodeServiceDiscoveryTest.php
Registers AllowedEmailDomainsLookupBuilder singleton in provider. Injects builder into SummitPromoCodeService; discoverPromoCodes() early-returns on empty emails, normalizes email, fetches domain-authorized candidates, builds per-code lookups (with caching), filters via matchesEmailDomainViaLookup, merges with email-linked discoverables, applies per-account quantity checks, and logs telemetry (elapsed time, candidate count, validity flag). Service tests updated to use split repository methods and new lookup expectations, plus regression tests for domain non-match exclusion and whitespace-only early-return.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • OpenStackweb/summit-api#525: Earlier refactor introducing domain-authorized allowed-email-domain matching that this PR extends and optimizes.

Suggested reviewers

  • romanetar
  • smarcet
  • martinquiroga-exo

"I nibble at patterns, trim each stray space,
I hash what I keep and quietly race,
Exact or suffix, I sort with delight,
Service filters swiftly — promo codes take flight! 🐇"

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 31.37% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title precisely captures the main changes: Track 1 telemetry observability and lookup-based builder for domain-authorized promo code discovery optimization.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/domain-authorized-promo-codes-track1

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions
Copy link
Copy Markdown

📘 OpenAPI / Swagger preview

➡️ https://OpenStackweb.github.io/summit-api/openapi/pr-546/

This page is automatically updated on each push to this PR.

@github-actions
Copy link
Copy Markdown

📘 OpenAPI / Swagger preview

➡️ https://OpenStackweb.github.io/summit-api/openapi/pr-546/

This page is automatically updated on each push to this PR.

@caseylocker caseylocker self-assigned this May 18, 2026
@caseylocker caseylocker marked this pull request as ready for review May 18, 2026 21:37
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/Repositories/Summit/DoctrineSummitRegistrationPromoCodeRepository.php`:
- Around line 676-689: Normalize the incoming $email first (e.g., $normalized =
strtolower(trim($email))) and use that for the empty guard (if
empty($normalized) return []), then use $normalized throughout: pass $normalized
to IDomainAuthorizedPromoCode::matchesEmailDomain(...) instead of $email and to
getEmailLinkedDiscoverableForSummit($summit, $normalized); keep the same calls
to getDomainAuthorizedDiscoverableForSummit and IDomainAuthorizedPromoCode
checks but operate on the normalized value.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: db438cef-5002-4763-bead-9c32100f6c8a

📥 Commits

Reviewing files that changed from the base of the PR and between 12a29e2 and 01f4113.

📒 Files selected for processing (11)
  • app/Models/Foundation/Summit/Registration/PromoCodes/AllowedEmailDomainsLookup.php
  • app/Models/Foundation/Summit/Registration/PromoCodes/DomainAuthorizedPromoCodeTrait.php
  • app/Models/Foundation/Summit/Registration/PromoCodes/IDomainAuthorizedPromoCode.php
  • app/Models/Foundation/Summit/Repositories/ISummitRegistrationPromoCodeRepository.php
  • app/Repositories/Summit/DoctrineSummitRegistrationPromoCodeRepository.php
  • app/Services/Model/Imp/AllowedEmailDomainsLookupBuilder.php
  • app/Services/Model/Imp/SummitPromoCodeService.php
  • app/Services/ModelServicesProvider.php
  • tests/Unit/Services/AllowedEmailDomainsLookupBuilderTest.php
  • tests/Unit/Services/MatchesEmailDomainViaLookupTest.php
  • tests/Unit/Services/SummitPromoCodeServiceDiscoveryTest.php

Comment thread app/Repositories/Summit/DoctrineSummitRegistrationPromoCodeRepository.php Outdated
@github-actions
Copy link
Copy Markdown

📘 OpenAPI / Swagger preview

➡️ https://OpenStackweb.github.io/summit-api/openapi/pr-546/

This page is automatically updated on each push to this PR.

@caseylocker caseylocker requested a review from smarcet May 18, 2026 23:03
@smarcet smarcet requested a review from romanetar May 19, 2026 12:48
@@ -1024,34 +1033,69 @@ public function discoverPromoCodes(Summit $summit, Member $member): array
$email = $member->getEmail();
if (empty($email)) return [];
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[BLOCKER] Whitespace-only email bypasses guard → live DB query with email=\'

empty(" ") returns false in PHP, so a member whose getEmail() returns whitespace passes this check. $normalizedEmail becomes "" and getEmailLinkedDiscoverableForSummit($summit, "") executes a real DB query binding email=''.

The prior aggregator had an explicit guard at DoctrineSummitRegistrationPromoCodeRepository.php:677 (if ($normalized === '' ) return []) that was removed without a replacement here.

Fix:

if (empty(trim($email))) return []; ```

Also add a unit test in `SummitPromoCodeServiceDiscoveryTest` where `$member->getEmail()` returns `"   "`, asserting the result is `[]` and no repository method is called.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d7cdc3b. SummitPromoCodeService.php:1034 now reads if (empty(trim($email))) return []; per your suggested form — normalize-then-test mirrors the legacy aggregator at DoctrineSummitRegistrationPromoCodeRepository.php:677. Coverage added in SummitPromoCodeServiceDiscoveryTest::testDiscoverReturnsEmptyForWhitespaceOnlyEmail — mocks getEmail() to return " \t\n " and uses shouldNotReceive on both repo methods so any guard regression fails immediately.

$matched = [];
foreach ($candidates as $code) {
if (!$code instanceof IDomainAuthorizedPromoCode) continue;
$lookup = $this->lookup_builder->build($code->getAllowedEmailDomains());
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[BLOCKER] Lookup rebuilt on every loop iteration — O(N) optimisation does not materialise

$this->lookup_builder->build($code->getAllowedEmailDomains()) is called once per candidate inside the foreach. For a summit with 50 DA codes × 1,400 patterns each, this triggers 50 full rebuilds — each running 1,400 normalizations, a dedup pass, sort(), and sha1(). The performance gain described in the PR only applies when the lookup is built once per unique pattern-set and reused.

Fix: add a local patternsHash-keyed cache before calling build():

$lookupCache = [];
foreach ($candidates as $code) {
    if (!$code instanceof IDomainAuthorizedPromoCode) continue;
    $patterns = $code->getAllowedEmailDomains();
    $hash     = sha1(implode("\0", $patterns)); // stable, cheap pre-hash
    $lookup   = $lookupCache[$hash] ?? ($lookupCache[$hash] = $this->lookup_builder->build($patterns));
    if ($code->matchesEmailDomainViaLookup($normalizedEmail, $lookup)) {
        $matched[] = $code;
    }
}

This reduces N lookups to at most K unique pattern-sets.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d7cdc3b. SummitPromoCodeService.php:1042-1054 adds a $lookupCache keyed by sha1(implode("\0", $patterns)) per your suggested form. Caveat worth flagging: the pre-hash is order- and case-sensitive (no normalization before hashing), so two codes whose pattern arrays differ only in case or order won't share a cache entry. Acceptable for the OCP case where sibling codes are populated from the same upstream pattern array — but if the T1-Sanity measurement shows lower-than-expected hit rate, the fix is to normalize patterns before hashing.

// valid_until_date_passed signals indexed-SQL self-termination per the
// SDS Verified Ops Precondition.
$duration_ms = (microtime(true) - $started) * 1000;
$now = new \DateTime();
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[BLOCKER] new \DateTime() without UTC → valid_until_date_passed wrong on non-UTC servers

The repository query (line 699) creates $now as new \DateTime('now', new \DateTimeZone('UTC')). This line uses new \DateTime() which inherits the PHP process system timezone. On any server where the default timezone is not UTC, $c->getValidUntilDate() < $now compares a UTC-stored DateTime against wall-clock local time — producing incorrect flag values and potentially triggering wrong ops self-termination signals.

Fix:

$now = new \DateTime('now', new \DateTimeZone('UTC'));

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d7cdc3b. SummitPromoCodeService.php:1083 now uses new \DateTime('now', new \DateTimeZone('UTC')) matching the repository at DoctrineSummitRegistrationPromoCodeRepository.php:699.

Note for the record: config/app.php:70 sets 'timezone' => 'UTC' and PHP \DateTime comparison normalizes via Unix timestamp regardless of TZ, so the runtime comparison result was already correct on this app. Applied as defensive consistency — matches the repo convention and future-proofs against any config drift.

&& $c->getValidUntilDate() !== null
&& $c->getValidUntilDate() < $now,
true
);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[BLOCKER] valid_until_date_passed has two semantic bugs

Bug A — empty candidates emits true
When $candidates is empty (no DA codes configured for the summit), the flag short-circuits to true. The SDS defines this flag as "all DA codes have passed their expiry window." Zero candidates means no DA codes exist at all — not that they expired. Ops tooling acting on true will incorrectly self-terminate DA-code processing for summits that never had any DA codes.

Fix: change the empty branch to false (or null if a third state is needed):

$valid_until_date_passed = empty($candidates)
    ? false   // no DA codes configured — not the same as all expired
    : array_reduce(...);

Bug B — open-ended codes (null valid_until_date) force flag to false
The reduce requires $c->getValidUntilDate() !== null. Any open-ended code (perpetual, no expiry set) short-circuits the accumulator to false. A summit with one open-ended code and several fully-expired finite codes will emit valid_until_date_passed = false, hiding the fact that all finite codes are past their window.

Fix: compute the flag only over codes that have a non-null valid_until_date:

$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
      );

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d7cdc3b. SummitPromoCodeService.php:1084-1094 applies your combined fix:

$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
      );

Filter to finite codes pre-enforces the non-null invariant so the reduce predicate simplifies. Empty-finite case defaults to false (resolves Bug A — no DA codes configured ≠ all expired). Open-ended codes no longer drag the accumulator to false (resolves Bug B).

foreach ($candidates as $code) {
if (!$code instanceof IDomainAuthorizedPromoCode) continue;
$lookup = $this->lookup_builder->build($code->getAllowedEmailDomains());
if ($code->matchesEmailDomainViaLookup($normalizedEmail, $lookup)) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[FOLLOW-UP] Domain-non-matching DA code exclusion not tested at service level

All four unit tests in SummitPromoCodeServiceDiscoveryTest mock matchesEmailDomainViaLookup to return true. No test verifies that a DA code returned by the repository but not matching the member's email domain is excluded from results.

A regression in this build-and-filter path would silently expose all in-date DA codes to every member.

Suggested test:

public function testDomainNonMatchingDACodeIsExcluded(): void
{
    // DA code with @acme.com pattern, member has @other.com email
    $daCode = $this->buildDAPromoCode(allowedDomains: [@acme.com]); $this->repository->shouldReceive('getDomainAuthorizedDiscoverableForSummit')->andReturn([$daCode]); $this->repository->shouldReceive('getEmailLinkedDiscoverableForSummit')->andReturn([]); $member = $this->buildMember(email: 'user@other.com'); $result = $this->service->discoverPromoCodes($this->summit, $member); $this->assertEmpty($result); }

Use real AllowedEmailDomainsLookupBuilder (already wired in buildService()) so the test exercises the actual lookup path, not a mock.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d7cdc3b. Added SummitPromoCodeServiceDiscoveryTest::testDomainNonMatchingDACodeIsExcluded — uses the real AllowedEmailDomainsLookupBuilder already wired into buildService() at L80 and lets the real matchesEmailDomainViaLookup trait method fire (via Mockery's makePartial()). Member email user@other.com against a code allowing @acme.com asserts the code is excluded from results. shouldNotReceive('setRemainingQuantityPerAccount') additionally proves the code is dropped at the email-match step, not later at the quantity loop.

Hot-path suite now 30/30 (Builder 12 + parity 11 + Discovery 7).

Copy link
Copy Markdown
Collaborator

@smarcet smarcet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@caseylocker please review

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.
@github-actions
Copy link
Copy Markdown

📘 OpenAPI / Swagger preview

➡️ https://OpenStackweb.github.io/summit-api/openapi/pr-546/

This page is automatically updated on each push to this PR.

Copy link
Copy Markdown
Collaborator

@smarcet smarcet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@smarcet smarcet merged commit 383eb12 into main May 19, 2026
19 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants