39b6127bce
Закрыт пункт «Reminders ⏸ no-view» из AppLayout nav-tree. Schema-таблица
reminders уже была в v8.10 §17.5 — теперь работает целиком backend-side.
Backend:
- App\Models\Reminder — Eloquent с casts/relations + isCompleted/isOverdue.
- ReminderFactory с states overdue/completed/sent.
- App\Http\Controllers\Api\ReminderController под auth:sanctum:
GET ?filter=&deal_id=&limit= (active/today/upcoming/overdue/completed,
окно ±1 день, counts для UI badges);
POST {deal_id, text?, remind_at, assignee_id?} (FK guard на assignee);
PATCH {id} (при смене remind_at сбрасывает is_sent+sent_at для retrigger);
POST {id}/complete (idempotent);
DELETE {id}.
RLS-обёртка + defense-in-depth where('tenant_id').
- App\Mail\ReminderDueNotification + emails/reminder.blade.php (Forest,
TZ из recipient.timezone).
- NotificationService::notifyReminder(Reminder) — recipient = assignee_id
?? created_by (если active+!deleted). Каналы email+inapp по prefs.
payload {reminder_id, deal_id} для UI deep-link.
- App\Console\Commands\RemindersDispatchDue — cron reminders:dispatch-due
{--dry-run} {--limit=500}. По одному reminder в DB::transaction (SET
LOCAL app.current_tenant_id нельзя переключать). После notifyReminder
ставит is_sent=true даже если recipient deactivated (защита от retry-spam).
Pest +32 (347/347 за 41.21 сек, 1203 assertions):
- ReminderControllerTest 21: 401 / RLS / 5 filter'ов / counts / deal_id /
store + FK guard / update text+remind_at сбрасывает is_sent / complete
idempotent / delete + 404 чужой.
- RemindersDispatchDueTest 11: due → email+inapp / future skip / completed
skip / уже sent / assignee вместо created_by / deactivated user (is_sent
всё равно) / только inapp при email=false / --dry-run / --limit / RLS.
PHPStan baseline регенерирован. IDE-helper для всех моделей.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
209 lines
7.5 KiB
PHP
209 lines
7.5 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Mail\ReminderDueNotification;
|
|
use App\Models\InAppNotification;
|
|
use App\Models\Reminder;
|
|
use App\Models\Tenant;
|
|
use App\Models\User;
|
|
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
|
use Illuminate\Support\Carbon;
|
|
use Illuminate\Support\Facades\Mail;
|
|
|
|
uses(DatabaseTransactions::class);
|
|
|
|
beforeEach(function () {
|
|
Mail::fake();
|
|
});
|
|
|
|
function dueReminder(int $tenantId, int $userId, array $overrides = []): Reminder
|
|
{
|
|
return Reminder::create(array_merge([
|
|
'tenant_id' => $tenantId,
|
|
'deal_id' => 42,
|
|
'text' => 'Перезвонить',
|
|
'remind_at' => Carbon::now()->subMinute(), // due
|
|
'created_by' => $userId,
|
|
'is_sent' => false,
|
|
], $overrides));
|
|
}
|
|
|
|
test('dispatch-due: due-reminder → email + inapp + is_sent=true', function () {
|
|
$tenant = Tenant::factory()->create();
|
|
$user = User::factory()->create([
|
|
'tenant_id' => $tenant->id,
|
|
'notification_preferences' => [
|
|
'reminder' => ['inapp' => true, 'push' => true, 'email' => true],
|
|
],
|
|
]);
|
|
$reminder = dueReminder($tenant->id, $user->id);
|
|
|
|
$this->artisan('reminders:dispatch-due')->assertSuccessful();
|
|
|
|
Mail::assertSent(ReminderDueNotification::class, 1);
|
|
Mail::assertSent(fn (ReminderDueNotification $m) => $m->reminder->id === $reminder->id && $m->hasTo($user->email));
|
|
|
|
expect(InAppNotification::query()->where('event', 'reminder')->count())->toBe(1);
|
|
|
|
$reminder->refresh();
|
|
expect($reminder->is_sent)->toBeTrue();
|
|
expect($reminder->sent_at)->not->toBeNull();
|
|
});
|
|
|
|
test('dispatch-due: future-reminder skip', function () {
|
|
$tenant = Tenant::factory()->create();
|
|
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
|
Reminder::create([
|
|
'tenant_id' => $tenant->id,
|
|
'deal_id' => 42,
|
|
'text' => 'Future',
|
|
'remind_at' => Carbon::now()->addHour(), // future
|
|
'created_by' => $user->id,
|
|
'is_sent' => false,
|
|
]);
|
|
|
|
$this->artisan('reminders:dispatch-due')->assertSuccessful();
|
|
Mail::assertNothingSent();
|
|
});
|
|
|
|
test('dispatch-due: completed reminder skip', function () {
|
|
$tenant = Tenant::factory()->create();
|
|
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
|
dueReminder($tenant->id, $user->id, ['completed_at' => Carbon::now()->subMinute()]);
|
|
|
|
$this->artisan('reminders:dispatch-due')->assertSuccessful();
|
|
Mail::assertNothingSent();
|
|
});
|
|
|
|
test('dispatch-due: уже sent skip (is_sent=true)', function () {
|
|
$tenant = Tenant::factory()->create();
|
|
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
|
dueReminder($tenant->id, $user->id, ['is_sent' => true, 'sent_at' => Carbon::now()->subMinute()]);
|
|
|
|
$this->artisan('reminders:dispatch-due')->assertSuccessful();
|
|
Mail::assertNothingSent();
|
|
});
|
|
|
|
test('dispatch-due: assignee получает (вместо created_by)', function () {
|
|
$tenant = Tenant::factory()->create();
|
|
$creator = User::factory()->create(['tenant_id' => $tenant->id, 'email' => 'creator@example.ru']);
|
|
$assignee = User::factory()->create([
|
|
'tenant_id' => $tenant->id,
|
|
'email' => 'assignee@example.ru',
|
|
'notification_preferences' => [
|
|
'reminder' => ['inapp' => false, 'push' => false, 'email' => true],
|
|
],
|
|
]);
|
|
|
|
Reminder::create([
|
|
'tenant_id' => $tenant->id,
|
|
'deal_id' => 42,
|
|
'text' => 'Перезвонить',
|
|
'remind_at' => Carbon::now()->subMinute(),
|
|
'created_by' => $creator->id,
|
|
'assignee_id' => $assignee->id,
|
|
'is_sent' => false,
|
|
]);
|
|
|
|
$this->artisan('reminders:dispatch-due')->assertSuccessful();
|
|
|
|
Mail::assertSent(fn (ReminderDueNotification $m) => $m->hasTo('assignee@example.ru'));
|
|
Mail::assertNotSent(fn (ReminderDueNotification $m) => $m->hasTo('creator@example.ru'));
|
|
});
|
|
|
|
test('dispatch-due: deactivated user — НЕ получает', function () {
|
|
$tenant = Tenant::factory()->create();
|
|
$user = User::factory()->inactive()->create(['tenant_id' => $tenant->id]);
|
|
$reminder = dueReminder($tenant->id, $user->id);
|
|
|
|
$this->artisan('reminders:dispatch-due')->assertSuccessful();
|
|
|
|
Mail::assertNothingSent();
|
|
expect(InAppNotification::query()->count())->toBe(0);
|
|
|
|
// Reminder всё равно помечается is_sent=true (чтобы не пытаться слать снова).
|
|
$reminder->refresh();
|
|
expect($reminder->is_sent)->toBeTrue();
|
|
});
|
|
|
|
test('dispatch-due: prefs.reminder.email=false — только inapp', function () {
|
|
$tenant = Tenant::factory()->create();
|
|
$user = User::factory()->create([
|
|
'tenant_id' => $tenant->id,
|
|
'notification_preferences' => [
|
|
'reminder' => ['inapp' => true, 'push' => false, 'email' => false],
|
|
],
|
|
]);
|
|
dueReminder($tenant->id, $user->id);
|
|
|
|
$this->artisan('reminders:dispatch-due')->assertSuccessful();
|
|
|
|
Mail::assertNothingSent();
|
|
expect(InAppNotification::query()->where('event', 'reminder')->count())->toBe(1);
|
|
});
|
|
|
|
test('dispatch-due --dry-run: не шлёт + не помечает is_sent', function () {
|
|
$tenant = Tenant::factory()->create();
|
|
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
|
$reminder = dueReminder($tenant->id, $user->id);
|
|
|
|
$this->artisan('reminders:dispatch-due', ['--dry-run' => true])->assertSuccessful();
|
|
|
|
Mail::assertNothingSent();
|
|
$reminder->refresh();
|
|
expect($reminder->is_sent)->toBeFalse();
|
|
});
|
|
|
|
test('dispatch-due: 3 due-reminder → 3 sent', function () {
|
|
$tenant = Tenant::factory()->create();
|
|
$user = User::factory()->create([
|
|
'tenant_id' => $tenant->id,
|
|
'notification_preferences' => [
|
|
'reminder' => ['inapp' => false, 'push' => false, 'email' => true],
|
|
],
|
|
]);
|
|
dueReminder($tenant->id, $user->id, ['deal_id' => 1]);
|
|
dueReminder($tenant->id, $user->id, ['deal_id' => 2]);
|
|
dueReminder($tenant->id, $user->id, ['deal_id' => 3]);
|
|
|
|
$this->artisan('reminders:dispatch-due')->assertSuccessful();
|
|
|
|
Mail::assertSent(ReminderDueNotification::class, 3);
|
|
});
|
|
|
|
test('dispatch-due: --limit=1 ограничивает выдачу', function () {
|
|
$tenant = Tenant::factory()->create();
|
|
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
|
dueReminder($tenant->id, $user->id, ['deal_id' => 1]);
|
|
dueReminder($tenant->id, $user->id, ['deal_id' => 2]);
|
|
|
|
$this->artisan('reminders:dispatch-due', ['--limit' => 1])->assertSuccessful();
|
|
|
|
Mail::assertSent(ReminderDueNotification::class, 1);
|
|
});
|
|
|
|
test('dispatch-due: разные tenant\'ы изолируются (RLS)', function () {
|
|
$tenantA = Tenant::factory()->create();
|
|
$userA = User::factory()->create([
|
|
'tenant_id' => $tenantA->id,
|
|
'email' => 'a@example.ru',
|
|
'notification_preferences' => ['reminder' => ['inapp' => true, 'push' => false, 'email' => true]],
|
|
]);
|
|
$tenantB = Tenant::factory()->create();
|
|
$userB = User::factory()->create([
|
|
'tenant_id' => $tenantB->id,
|
|
'email' => 'b@example.ru',
|
|
'notification_preferences' => ['reminder' => ['inapp' => true, 'push' => false, 'email' => true]],
|
|
]);
|
|
|
|
dueReminder($tenantA->id, $userA->id);
|
|
dueReminder($tenantB->id, $userB->id);
|
|
|
|
$this->artisan('reminders:dispatch-due')->assertSuccessful();
|
|
|
|
Mail::assertSent(ReminderDueNotification::class, 2);
|
|
expect(InAppNotification::query()->where('tenant_id', $tenantA->id)->count())->toBe(1);
|
|
expect(InAppNotification::query()->where('tenant_id', $tenantB->id)->count())->toBe(1);
|
|
});
|