diff --git a/app/tests/Feature/Imitation/ScenarioD_DeliveryDaysTest.php b/app/tests/Feature/Imitation/ScenarioD_DeliveryDaysTest.php new file mode 100644 index 00000000..3ecb3a9d --- /dev/null +++ b/app/tests/Feature/Imitation/ScenarioD_DeliveryDaysTest.php @@ -0,0 +1,265 @@ +isoWeekday() - 1) + * isoWeekday(): Monday=1, Tuesday=2, ..., Sunday=7 + * → Monday = 1<<0 = 1 + * → Tuesday = 1<<1 = 2 + * → ... + * → Sunday = 1<<6 = 64 + * → Full week mask = 127 (bits 0-6 all set) + * + * SnapshotForge::activeDate() mirrors LeadRouter::activeSnapshotDate(): + * - Before 21:00 MSK → today's date + * - From 21:00 MSK → tomorrow's date + * snapshot:rebuild filters by that date's isoWeekday bit. + * + * Setup: + * - One shared SupplierProject (B1 site signal). + * - Two tenants / projects on that supplier. + * - ACTIVE client: delivery_days_mask = 127 (all days — includes any day). + * - INACTIVE client: delivery_days_mask = full-week MINUS today's bit (excludes active date). + * - Both tenants have ample balance and no freeze. + * - SnapshotForge::rebuild() called AFTER masks are set. + * - One lead injected. + * + * Expected (per plan §6.2 D): + * - ACTIVE client receives the deal. + * - INACTIVE client receives ZERO deals (not in snapshot → invisible to LeadRouter). + * + * Subject code: Москва = 82 (порядковый, НЕ ГИБДД). + * + * Task 8 — Phase 1 Portal Client Imitation, Scenario D. + * Spec: docs/superpowers/specs/2026-06-03-portal-client-imitation-phase1-design.md §6.2 D + * Plan: docs/superpowers/plans/2026-06-03-portal-client-imitation-phase1.md §Task 8 + */ + +// ── SUBJECT CODE ────────────────────────────────────────────────────────────── +/** App\Support\RussianRegions::CODE_TO_NAME[82] = 'Москва' */ +const D_MOSCOW_CODE = 82; + +// ── SUPPLIER SIGNAL ─────────────────────────────────────────────────────────── +const D_SUPPLIER_DOMAIN = 'scenario-d-delivery-days.ru'; +const D_SUPPLIER_PLATFORM = 'B1'; + +// ── DETERMINISTIC SEED ──────────────────────────────────────────────────────── +const D_SEED = 19; + +// ── PHONE NUMBERS ───────────────────────────────────────────────────────────── +/** Phone resolving to Москва via FakeDaDataPhoneClient. */ +const D_PHONE_1 = '79270000099'; + +beforeEach(function (): void { + // Pricing tiers — required by LedgerService::chargeForDelivery. + $this->seed(PricingTierSeeder::class); + + // Global RLS bypass for seeding phase (tenant context = 0). + DB::statement("SELECT set_config('app.current_tenant_id', '0', true)"); + + // DaData config — FakeDaDataPhoneClient bypasses HTTP entirely. + config([ + 'services.dadata.enabled' => true, + 'services.dadata.api_key' => 'fake-key', + 'services.dadata.secret' => 'fake-secret', + 'services.dadata.daily_cap_rub' => 1_000_000, + ]); + + // Bind deterministic LeadRouter (small cap — only 2 projects, so no real lottery needed, + // but we seed for reproducibility). + app()->instance(LeadRouter::class, new LeadRouter(new Randomizer(new Mt19937(D_SEED)))); +}); + +it('only delivers to the active-today client; the excluded-day client gets zero deals', function (): void { + // ── DETERMINE ACTIVE DATE AND ITS WEEKDAY BIT ──────────────────────────── + // + // SnapshotForge::activeDate() mirrors LeadRouter::activeSnapshotDate(). + // SnapshotRebuildCommand: weekdayBit = 1 << (date->isoWeekday() - 1). + // isoWeekday(): Monday=1 .. Sunday=7. + // + // We MUST compute the bit from the active-snapshot date (not wall-clock today), + // because after 21:00 MSK the active date flips to tomorrow. + $activeDate = SnapshotForge::activeDate(); // 'YYYY-MM-DD' + $activeDateObj = Carbon::parse($activeDate, 'Europe/Moscow'); + $todayBit = 1 << ($activeDateObj->isoWeekday() - 1); // e.g. Wednesday=4 + + // ACTIVE mask: full week (includes every day, including today). + $activeMask = 127; // 0b1111111 + + // INACTIVE mask: full week MINUS today's bit → excludes the active snapshot date. + // snapshot:rebuild WHERE (delivery_days_mask & weekdayBit) <> 0 will skip this project. + $inactiveMask = 127 & ~$todayBit; // clears the bit for the active date's weekday + + // Sanity: active mask passes the filter, inactive mask does not. + expect(($activeMask & $todayBit) !== 0)->toBeTrue(); + expect(($inactiveMask & $todayBit))->toBe(0); + + // ── ARRANGE: SHARED SUPPLIER PROJECT ───────────────────────────────────── + $supplier = SupplierProject::factory()->create([ + 'platform' => D_SUPPLIER_PLATFORM, + 'signal_type' => 'site', + 'unique_key' => D_SUPPLIER_DOMAIN, + ]); + + // ── ARRANGE: ACTIVE TENANT / PROJECT ───────────────────────────────────── + $tenantActive = Tenant::factory()->create([ + 'balance_rub' => '9999.00', + 'frozen_by_balance_at' => null, + ]); + + $projectActive = Project::factory()->create([ + 'tenant_id' => $tenantActive->id, + 'is_active' => true, + 'signal_type' => 'site', + 'signal_identifier' => D_SUPPLIER_DOMAIN, + 'daily_limit_target' => 10, + 'effective_daily_limit_today' => null, + 'delivered_today' => 0, + 'delivered_in_month' => 0, + 'delivery_days_mask' => $activeMask, // all days — included today + 'preflight_blocked_at' => null, + 'regions' => [D_MOSCOW_CODE], + ]); + + linkProjectToSupplier($projectActive, $supplier); + + // ── ARRANGE: INACTIVE TENANT / PROJECT ─────────────────────────────────── + $tenantInactive = Tenant::factory()->create([ + 'balance_rub' => '9999.00', + 'frozen_by_balance_at' => null, + ]); + + $projectInactive = Project::factory()->create([ + 'tenant_id' => $tenantInactive->id, + 'is_active' => true, + 'signal_type' => 'site', + 'signal_identifier' => D_SUPPLIER_DOMAIN, + 'daily_limit_target' => 10, + 'effective_daily_limit_today' => null, + 'delivered_today' => 0, + 'delivered_in_month' => 0, + 'delivery_days_mask' => $inactiveMask, // today's bit cleared — excluded from snapshot + 'preflight_blocked_at' => null, + 'regions' => [D_MOSCOW_CODE], + ]); + + linkProjectToSupplier($projectInactive, $supplier); + + // ── REBUILD SNAPSHOT AFTER MASKS ARE SET ───────────────────────────────── + // SnapshotRebuildCommand: WHERE (delivery_days_mask & weekdayBit) <> 0 + // → projectActive IS inserted (bit set) + // → projectInactive NOT inserted (bit cleared) + SnapshotForge::rebuild(); + + // Verify snapshot state directly: active project is in snapshot, inactive is not. + $snapshotActiveExists = DB::connection('pgsql_supplier') + ->table('project_routing_snapshots') + ->where('snapshot_date', $activeDate) + ->where('project_id', $projectActive->id) + ->exists(); + + $snapshotInactiveExists = DB::connection('pgsql_supplier') + ->table('project_routing_snapshots') + ->where('snapshot_date', $activeDate) + ->where('project_id', $projectInactive->id) + ->exists(); + + expect($snapshotActiveExists)->toBeTrue( + "FINDING: projectActive (mask={$activeMask}, bit={$todayBit}) " . + "was expected in the snapshot for {$activeDate} but is absent. " . + "SnapshotRebuildCommand may have a bug in delivery_days_mask filtering." + ); + + expect($snapshotInactiveExists)->toBeFalse( + "FINDING: projectInactive (mask={$inactiveMask}, bit={$todayBit}) " . + "was expected to be ABSENT from the snapshot for {$activeDate} but was INSERTED. " . + "This means the delivery_days_mask filter is not working — the inactive project " . + "will receive leads it should not receive." + ); + + // ── ARRANGE: FAKE DADATA CLIENT ────────────────────────────────────────── + $fake = (new FakeDaDataPhoneClient())->stub(D_PHONE_1, qc: 0, region: 'Москва', provider: 'МТС'); + app()->instance(DaDataPhoneClient::class, $fake); + + // ── ACT: INJECT ONE LEAD ───────────────────────────────────────────────── + (new LeadInjector())->site( + domain: D_SUPPLIER_DOMAIN, + phone: D_PHONE_1, + tag: 'Москва', + platform: D_SUPPLIER_PLATFORM, + vid: 88_000_000_001, + ); + + // ── ASSERT: DEALS DISTRIBUTION ─────────────────────────────────────────── + + // Active client MUST receive exactly 1 deal. + $activeDeals = DB::connection('pgsql_supplier') + ->table('deals') + ->where('tenant_id', $tenantActive->id) + ->count(); + + // Inactive client MUST receive 0 deals (not in snapshot). + $inactiveDeals = DB::connection('pgsql_supplier') + ->table('deals') + ->where('tenant_id', $tenantInactive->id) + ->count(); + + // Diagnostic output. + fwrite(STDOUT, PHP_EOL . '=== SCENARIO D: DELIVERY DAYS FILTER REPORT ===' . PHP_EOL); + fwrite(STDOUT, sprintf('Active date: %s (isoWeekday=%d)%s', + $activeDate, $activeDateObj->isoWeekday(), PHP_EOL)); + fwrite(STDOUT, sprintf('Today bit: %d (0b%s)%s', + $todayBit, str_pad(decbin($todayBit), 7, '0', STR_PAD_LEFT), PHP_EOL)); + fwrite(STDOUT, sprintf('Active mask: %d (0b%s) → snapshot: %s%s', + $activeMask, str_pad(decbin($activeMask), 7, '0', STR_PAD_LEFT), + $snapshotActiveExists ? 'INCLUDED' : 'MISSING', PHP_EOL)); + fwrite(STDOUT, sprintf('Inactive mask: %d (0b%s) → snapshot: %s%s', + $inactiveMask, str_pad(decbin($inactiveMask), 7, '0', STR_PAD_LEFT), + $snapshotInactiveExists ? 'PRESENT (BUG!)' : 'ABSENT (correct)', PHP_EOL)); + fwrite(STDOUT, sprintf('Active client deals: %d (expected: 1)%s', $activeDeals, PHP_EOL)); + fwrite(STDOUT, sprintf('Inactive client deals: %d (expected: 0)%s', $inactiveDeals, PHP_EOL)); + fwrite(STDOUT, '=== END SCENARIO D ===' . PHP_EOL . PHP_EOL); + + expect($activeDeals)->toBe(1, + "FINDING: Active client (mask={$activeMask}, project_id={$projectActive->id}) " . + "expected 1 deal but got {$activeDeals}. " . + "Check LeadRouter eligibility query or LedgerService::chargeForDelivery." + ); + + expect($inactiveDeals)->toBe(0, + "FINDING: Inactive client (mask={$inactiveMask}, today_bit={$todayBit}, " . + "project_id={$projectInactive->id}) expected 0 deals but got {$inactiveDeals}. " . + "The delivery_days_mask filter in SnapshotRebuildCommand is NOT excluding this project. " . + "This is a correctness bug: clients with today's bit cleared MUST be absent from the snapshot." + ); + +})->group('imitation');