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('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); });