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>
211 lines
8.6 KiB
PHP
211 lines
8.6 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Models\Reminder;
|
|
use App\Models\Tenant;
|
|
use App\Models\User;
|
|
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
|
use Illuminate\Support\Carbon;
|
|
|
|
uses(DatabaseTransactions::class);
|
|
|
|
beforeEach(function () {
|
|
$this->tenant = Tenant::factory()->create();
|
|
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
|
|
$this->actingAs($this->user);
|
|
});
|
|
|
|
function makeReminder(int $tenantId, int $userId, array $overrides = []): Reminder
|
|
{
|
|
return Reminder::create(array_merge([
|
|
'tenant_id' => $tenantId,
|
|
'deal_id' => 42,
|
|
'text' => 'Перезвонить клиенту',
|
|
'remind_at' => Carbon::now()->addHour(),
|
|
'created_by' => $userId,
|
|
'is_sent' => false,
|
|
], $overrides));
|
|
}
|
|
|
|
test('GET /api/reminders: 401 без auth', function () {
|
|
auth()->logout();
|
|
$this->getJson('/api/reminders')->assertStatus(401);
|
|
});
|
|
|
|
test('GET /api/reminders: пустой', function () {
|
|
$response = $this->getJson('/api/reminders');
|
|
$response->assertOk();
|
|
expect($response->json('items'))->toBe([]);
|
|
expect($response->json('counts.active'))->toBe(0);
|
|
});
|
|
|
|
test('GET /api/reminders: возвращает только свои', function () {
|
|
$otherTenant = Tenant::factory()->create();
|
|
$otherUser = User::factory()->create(['tenant_id' => $otherTenant->id]);
|
|
makeReminder($this->tenant->id, $this->user->id);
|
|
makeReminder($otherTenant->id, $otherUser->id);
|
|
|
|
$response = $this->getJson('/api/reminders');
|
|
$response->assertOk();
|
|
expect($response->json('items'))->toHaveCount(1);
|
|
});
|
|
|
|
test('GET /api/reminders?filter=overdue: возвращает только просроченные', function () {
|
|
makeReminder($this->tenant->id, $this->user->id, ['remind_at' => Carbon::now()->subDays(2)]); // overdue
|
|
makeReminder($this->tenant->id, $this->user->id, ['remind_at' => Carbon::now()->addHour()]); // today
|
|
makeReminder($this->tenant->id, $this->user->id, ['remind_at' => Carbon::now()->addDays(5)]); // upcoming
|
|
|
|
$response = $this->getJson('/api/reminders?filter=overdue');
|
|
expect($response->json('items'))->toHaveCount(1);
|
|
});
|
|
|
|
test('GET /api/reminders?filter=today: возвращает в пределах ±1д', function () {
|
|
makeReminder($this->tenant->id, $this->user->id, ['remind_at' => Carbon::now()->addHours(3)]);
|
|
makeReminder($this->tenant->id, $this->user->id, ['remind_at' => Carbon::now()->addDays(5)]); // upcoming
|
|
|
|
$response = $this->getJson('/api/reminders?filter=today');
|
|
expect($response->json('items'))->toHaveCount(1);
|
|
});
|
|
|
|
test('GET /api/reminders?filter=completed: возвращает выполненные', function () {
|
|
makeReminder($this->tenant->id, $this->user->id, ['completed_at' => Carbon::now()]);
|
|
makeReminder($this->tenant->id, $this->user->id, ['completed_at' => null]);
|
|
|
|
$response = $this->getJson('/api/reminders?filter=completed');
|
|
expect($response->json('items'))->toHaveCount(1);
|
|
});
|
|
|
|
test('GET /api/reminders: counts отдельно для каждого фильтра', function () {
|
|
makeReminder($this->tenant->id, $this->user->id, ['remind_at' => Carbon::now()->subDays(2)]);
|
|
makeReminder($this->tenant->id, $this->user->id, ['remind_at' => Carbon::now()->addHour()]);
|
|
makeReminder($this->tenant->id, $this->user->id, ['remind_at' => Carbon::now()->addDays(3)]);
|
|
|
|
$response = $this->getJson('/api/reminders');
|
|
expect($response->json('counts.active'))->toBe(3);
|
|
expect($response->json('counts.overdue'))->toBe(1);
|
|
expect($response->json('counts.today'))->toBe(1);
|
|
expect($response->json('counts.upcoming'))->toBe(1);
|
|
});
|
|
|
|
test('GET /api/reminders?deal_id=42: фильтр по сделке', function () {
|
|
makeReminder($this->tenant->id, $this->user->id, ['deal_id' => 42]);
|
|
makeReminder($this->tenant->id, $this->user->id, ['deal_id' => 43]);
|
|
|
|
$response = $this->getJson('/api/reminders?deal_id=42');
|
|
expect($response->json('items'))->toHaveCount(1);
|
|
expect($response->json('items.0.deal_id'))->toBe(42);
|
|
});
|
|
|
|
test('POST /api/reminders: создаёт', function () {
|
|
$response = $this->postJson('/api/reminders', [
|
|
'deal_id' => 100,
|
|
'text' => 'Перезвонить через час',
|
|
'remind_at' => Carbon::now()->addHour()->toIso8601String(),
|
|
]);
|
|
|
|
$response->assertStatus(201);
|
|
expect($response->json('reminder.deal_id'))->toBe(100);
|
|
expect($response->json('reminder.text'))->toBe('Перезвонить через час');
|
|
expect($response->json('reminder.is_sent'))->toBeFalse();
|
|
expect($response->json('reminder.created_by'))->toBe($this->user->id);
|
|
|
|
expect(Reminder::query()->count())->toBe(1);
|
|
});
|
|
|
|
test('POST /api/reminders: 422 без deal_id', function () {
|
|
$this->postJson('/api/reminders', [
|
|
'remind_at' => Carbon::now()->addHour()->toIso8601String(),
|
|
])->assertStatus(422);
|
|
});
|
|
|
|
test('POST /api/reminders: 422 без remind_at', function () {
|
|
$this->postJson('/api/reminders', [
|
|
'deal_id' => 100,
|
|
])->assertStatus(422);
|
|
});
|
|
|
|
test('POST /api/reminders: assignee_id чужого тенанта → 422', function () {
|
|
$other = User::factory()->create(); // другой тенант
|
|
$this->postJson('/api/reminders', [
|
|
'deal_id' => 100,
|
|
'remind_at' => Carbon::now()->addHour()->toIso8601String(),
|
|
'assignee_id' => $other->id,
|
|
])->assertStatus(422);
|
|
});
|
|
|
|
test('POST /api/reminders: assignee_id своего тенанта — ok', function () {
|
|
$colleague = User::factory()->create(['tenant_id' => $this->tenant->id]);
|
|
$response = $this->postJson('/api/reminders', [
|
|
'deal_id' => 100,
|
|
'remind_at' => Carbon::now()->addHour()->toIso8601String(),
|
|
'assignee_id' => $colleague->id,
|
|
]);
|
|
$response->assertStatus(201);
|
|
expect($response->json('reminder.assignee_id'))->toBe($colleague->id);
|
|
});
|
|
|
|
test('PATCH /api/reminders/{id}: обновляет text', function () {
|
|
$reminder = makeReminder($this->tenant->id, $this->user->id);
|
|
$response = $this->patchJson("/api/reminders/{$reminder->id}", ['text' => 'Новый текст']);
|
|
$response->assertOk();
|
|
expect($response->json('reminder.text'))->toBe('Новый текст');
|
|
});
|
|
|
|
test('PATCH /api/reminders/{id}: смена remind_at сбрасывает is_sent', function () {
|
|
$reminder = makeReminder($this->tenant->id, $this->user->id, [
|
|
'is_sent' => true,
|
|
'sent_at' => Carbon::now()->subMinute(),
|
|
]);
|
|
|
|
$response = $this->patchJson("/api/reminders/{$reminder->id}", [
|
|
'remind_at' => Carbon::now()->addHours(3)->toIso8601String(),
|
|
]);
|
|
|
|
$response->assertOk();
|
|
expect($response->json('reminder.is_sent'))->toBeFalse();
|
|
expect($response->json('reminder.sent_at'))->toBeNull();
|
|
});
|
|
|
|
test('PATCH /api/reminders/{id}: 404 для чужого', function () {
|
|
$other = User::factory()->create();
|
|
$reminder = makeReminder($other->tenant_id, $other->id);
|
|
|
|
$this->patchJson("/api/reminders/{$reminder->id}", ['text' => 'Hack'])->assertStatus(404);
|
|
});
|
|
|
|
test('PATCH /api/reminders/{id}: 422 без полей', function () {
|
|
$reminder = makeReminder($this->tenant->id, $this->user->id);
|
|
$this->patchJson("/api/reminders/{$reminder->id}", [])->assertStatus(422);
|
|
});
|
|
|
|
test('POST /api/reminders/{id}/complete: ставит completed_at', function () {
|
|
$reminder = makeReminder($this->tenant->id, $this->user->id);
|
|
$response = $this->postJson("/api/reminders/{$reminder->id}/complete");
|
|
$response->assertOk();
|
|
expect($response->json('reminder.completed_at'))->not->toBeNull();
|
|
});
|
|
|
|
test('POST /api/reminders/{id}/complete: idempotent (повторный NO-OP)', function () {
|
|
$reminder = makeReminder($this->tenant->id, $this->user->id);
|
|
$first = $this->postJson("/api/reminders/{$reminder->id}/complete");
|
|
$firstCompletedAt = $first->json('reminder.completed_at');
|
|
|
|
sleep(1);
|
|
$second = $this->postJson("/api/reminders/{$reminder->id}/complete");
|
|
expect($second->json('reminder.completed_at'))->toBe($firstCompletedAt); // не изменилось
|
|
});
|
|
|
|
test('DELETE /api/reminders/{id}: удаляет', function () {
|
|
$reminder = makeReminder($this->tenant->id, $this->user->id);
|
|
$this->deleteJson("/api/reminders/{$reminder->id}")->assertOk();
|
|
expect(Reminder::query()->find($reminder->id))->toBeNull();
|
|
});
|
|
|
|
test('DELETE /api/reminders/{id}: 404 для чужого', function () {
|
|
$other = User::factory()->create();
|
|
$reminder = makeReminder($other->tenant_id, $other->id);
|
|
$this->deleteJson("/api/reminders/{$reminder->id}")->assertStatus(404);
|
|
expect(Reminder::query()->find($reminder->id))->not->toBeNull();
|
|
});
|