create([ 'tenant_id' => $tenant->id, 'email' => $email, 'notification_preferences' => [ 'new_lead' => ['email' => $emailOn, 'inapp' => true], ], ]); } it('шлёт одно письмо-сводку с N сделками подписанному пользователю', function () { $tenant = Tenant::factory()->create(); digestUser($tenant, 'digest-on@example.test', true); DB::statement('SET app.current_tenant_id = '.$tenant->id); Deal::factory()->for($tenant)->create(['received_at' => now()->subMinutes(5), 'is_test' => false]); Deal::factory()->for($tenant)->create(['received_at' => now()->subMinutes(10), 'is_test' => false]); Deal::factory()->for($tenant)->create(['received_at' => now()->subMinutes(15), 'is_test' => false]); (new SendNewLeadsDigestJob)->handle(app(NotificationService::class)); Mail::assertSent( NewLeadsDigestMail::class, fn (NewLeadsDigestMail $m) => $m->hasTo('digest-on@example.test') && $m->deals->count() === 3, ); }); it('не шлёт сводку пользователю с выключенным email', function () { $tenant = Tenant::factory()->create(); digestUser($tenant, 'digest-off@example.test', false); DB::statement('SET app.current_tenant_id = '.$tenant->id); Deal::factory()->for($tenant)->create(['received_at' => now()->subMinutes(5), 'is_test' => false]); (new SendNewLeadsDigestJob)->handle(app(NotificationService::class)); Mail::assertNotSent( NewLeadsDigestMail::class, fn (NewLeadsDigestMail $m) => $m->hasTo('digest-off@example.test'), ); }); it('не шлёт сводку, если за окно нет новых сделок', function () { $tenant = Tenant::factory()->create(); digestUser($tenant, 'digest-old@example.test', true); DB::statement('SET app.current_tenant_id = '.$tenant->id); Deal::factory()->for($tenant)->create(['received_at' => now()->subMinutes(45), 'is_test' => false]); (new SendNewLeadsDigestJob)->handle(app(NotificationService::class)); Mail::assertNotSent( NewLeadsDigestMail::class, fn (NewLeadsDigestMail $m) => $m->hasTo('digest-old@example.test'), ); }); it('повторный прогон в том же окне НЕ дублирует дайджест (N-4)', function () { $tenant = Tenant::factory()->create(); digestUser($tenant, 'digest-dup@example.test', true); DB::statement('SET app.current_tenant_id = '.$tenant->id); Deal::factory()->for($tenant)->create(['received_at' => now()->subMinutes(5), 'is_test' => false]); Deal::factory()->for($tenant)->create(['received_at' => now()->subMinutes(10), 'is_test' => false]); // ранбук R3b сам велит дёргать джоб вручную → два прогона в одном окне (new SendNewLeadsDigestJob)->handle(app(NotificationService::class)); (new SendNewLeadsDigestJob)->handle(app(NotificationService::class)); // письмо-сводка ушло РОВНО один раз — уже отправленные сделки не дублируются Mail::assertSent(NewLeadsDigestMail::class, 1); }); it('при падении отправки НЕ помечает сделки — следующий прогон повторит (N-4)', function () { $tenant = Tenant::factory()->create(); digestUser($tenant, 'digest-fail@example.test', true); DB::statement('SET app.current_tenant_id = '.$tenant->id); $deal = Deal::factory()->for($tenant)->create(['received_at' => now()->subMinutes(5), 'is_test' => false]); // джоб прерван ДО отправки: notify бросает (анон-subclass — тип-чистый для larastan) $throwing = new class extends NotificationService { public function notifyNewLeadsDigest(Tenant $tenant, Collection $deals): void { throw new RuntimeException('mail down'); } }; try { (new SendNewLeadsDigestJob)->handle($throwing); } catch (Throwable) { // ожидаемо — джоб упал, очередь повторит } expect(Cache::has('digest_sent:'.$deal->id))->toBeFalse(); // следующий прогон с рабочим сервисом — дайджест НЕ потерян, уходит (new SendNewLeadsDigestJob)->handle(app(NotificationService::class)); Mail::assertSent(NewLeadsDigestMail::class, 1); }); it('рассылает дайджест НЕСКОЛЬКИМ тенантам за один прогон, не завися от текущего RLS-контекста', function () { // Регрессия на «тихий no-op»: планировщик бежит БЕЗ контекста конкретной фирмы // (системный tenant 0). Перечень фирм джоба обязана взять сама (через pgsql_supplier), // а не через RLS-ограниченное дефолтное соединение. Раньше список выходил пустым и // ни одно письмо о новых сделках не уходило. $tenantA = Tenant::factory()->create(); $tenantB = Tenant::factory()->create(); digestUser($tenantA, 'sweep-a@example.test', true); digestUser($tenantB, 'sweep-b@example.test', true); DB::statement('SET app.current_tenant_id = '.$tenantA->id); Deal::factory()->for($tenantA)->create(['received_at' => now()->subMinutes(5), 'is_test' => false]); DB::statement('SET app.current_tenant_id = '.$tenantB->id); Deal::factory()->for($tenantB)->create(['received_at' => now()->subMinutes(5), 'is_test' => false]); Deal::factory()->for($tenantB)->create(['received_at' => now()->subMinutes(6), 'is_test' => false]); // Планировщик реально запускает джобу без контекста своей фирмы. DB::statement("SELECT set_config('app.current_tenant_id', '0', true)"); (new SendNewLeadsDigestJob)->handle(app(NotificationService::class)); Mail::assertSent( NewLeadsDigestMail::class, fn (NewLeadsDigestMail $m) => $m->hasTo('sweep-a@example.test') && $m->deals->count() === 1, ); Mail::assertSent( NewLeadsDigestMail::class, fn (NewLeadsDigestMail $m) => $m->hasTo('sweep-b@example.test') && $m->deals->count() === 2, ); }); it('notifyNewLead больше не шлёт пер-лид письмо', function () { $tenant = Tenant::factory()->create(); digestUser($tenant, 'perlead@example.test', true); DB::statement('SET app.current_tenant_id = '.$tenant->id); $deal = Deal::factory()->for($tenant)->create(['received_at' => now(), 'is_test' => false]); app(NotificationService::class)->notifyNewLead($tenant, $deal); Mail::assertNotSent(NewLeadNotification::class); });