179 lines
6.9 KiB
PHP
179 lines
6.9 KiB
PHP
|
|
<?php
|
||
|
|
|
||
|
|
declare(strict_types=1);
|
||
|
|
|
||
|
|
use App\Models\InAppNotification;
|
||
|
|
use App\Models\Tenant;
|
||
|
|
use App\Models\User;
|
||
|
|
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Тесты API для in-app уведомлений: GET /api/notifications + PATCH /{id}/read +
|
||
|
|
* POST /mark-all-read + DELETE /{id}. Все endpoint'ы под Sanctum SPA auth.
|
||
|
|
*
|
||
|
|
* RLS-проверка через защиту от кражи чужого id (where user_id) — постgres
|
||
|
|
* superuser BYPASSRLS в тестах, но controller-level фильтр работает.
|
||
|
|
*/
|
||
|
|
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 makeNotif(int $userId, int $tenantId, ?string $readAt = null, string $event = 'new_lead'): InAppNotification
|
||
|
|
{
|
||
|
|
return InAppNotification::create([
|
||
|
|
'tenant_id' => $tenantId,
|
||
|
|
'user_id' => $userId,
|
||
|
|
'event' => $event,
|
||
|
|
'title' => 'Новый лид — Caranga',
|
||
|
|
'body' => '79001234567',
|
||
|
|
'deal_id' => 42,
|
||
|
|
'payload' => ['deal_id' => 42, 'project_name' => 'Caranga'],
|
||
|
|
'read_at' => $readAt,
|
||
|
|
]);
|
||
|
|
}
|
||
|
|
|
||
|
|
test('GET /api/notifications: 401 без auth', function () {
|
||
|
|
auth()->logout();
|
||
|
|
$this->getJson('/api/notifications')->assertStatus(401);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('GET /api/notifications: пустой', function () {
|
||
|
|
$response = $this->getJson('/api/notifications');
|
||
|
|
$response->assertOk();
|
||
|
|
expect($response->json('items'))->toBe([]);
|
||
|
|
expect($response->json('unread_count'))->toBe(0);
|
||
|
|
expect($response->json('total'))->toBe(0);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('GET /api/notifications: возвращает только свои + сортировка по created_at DESC', function () {
|
||
|
|
$other = User::factory()->create(['tenant_id' => $this->tenant->id]);
|
||
|
|
makeNotif($this->user->id, $this->tenant->id);
|
||
|
|
sleep(1);
|
||
|
|
$newer = makeNotif($this->user->id, $this->tenant->id);
|
||
|
|
makeNotif($other->id, $this->tenant->id); // чужое
|
||
|
|
|
||
|
|
$response = $this->getJson('/api/notifications');
|
||
|
|
$response->assertOk();
|
||
|
|
expect($response->json('total'))->toBe(2);
|
||
|
|
expect($response->json('items.0.id'))->toBe($newer->id); // newer first
|
||
|
|
expect($response->json('unread_count'))->toBe(2);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('GET /api/notifications?unread_only=1: только непрочитанные', function () {
|
||
|
|
makeNotif($this->user->id, $this->tenant->id, readAt: now()->toIso8601String());
|
||
|
|
makeNotif($this->user->id, $this->tenant->id);
|
||
|
|
|
||
|
|
$response = $this->getJson('/api/notifications?unread_only=1');
|
||
|
|
expect($response->json('items'))->toHaveCount(1);
|
||
|
|
expect($response->json('items.0.read_at'))->toBeNull();
|
||
|
|
expect($response->json('unread_count'))->toBe(1);
|
||
|
|
expect($response->json('total'))->toBe(2);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('GET /api/notifications?limit=2: лимитирует выдачу', function () {
|
||
|
|
for ($i = 0; $i < 5; $i++) {
|
||
|
|
makeNotif($this->user->id, $this->tenant->id);
|
||
|
|
}
|
||
|
|
|
||
|
|
$response = $this->getJson('/api/notifications?limit=2');
|
||
|
|
expect($response->json('items'))->toHaveCount(2);
|
||
|
|
expect($response->json('total'))->toBe(5);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('GET /api/notifications: 422 на limit > 100', function () {
|
||
|
|
$this->getJson('/api/notifications?limit=101')->assertStatus(422);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('GET /api/notifications: возвращает поля title/body/event/payload/deal_id', function () {
|
||
|
|
$notif = makeNotif($this->user->id, $this->tenant->id);
|
||
|
|
|
||
|
|
$response = $this->getJson('/api/notifications');
|
||
|
|
$item = $response->json('items.0');
|
||
|
|
expect($item['id'])->toBe($notif->id);
|
||
|
|
expect($item['event'])->toBe('new_lead');
|
||
|
|
expect($item['title'])->toBe('Новый лид — Caranga');
|
||
|
|
expect($item['body'])->toBe('79001234567');
|
||
|
|
expect($item['deal_id'])->toBe(42);
|
||
|
|
expect($item['payload']['project_name'])->toBe('Caranga');
|
||
|
|
expect($item['read_at'])->toBeNull();
|
||
|
|
});
|
||
|
|
|
||
|
|
test('PATCH /api/notifications/{id}/read: ставит read_at + idempotent', function () {
|
||
|
|
$notif = makeNotif($this->user->id, $this->tenant->id);
|
||
|
|
|
||
|
|
$response = $this->patchJson("/api/notifications/{$notif->id}/read");
|
||
|
|
$response->assertOk();
|
||
|
|
expect($response->json('read_at'))->not->toBeNull();
|
||
|
|
|
||
|
|
$notif->refresh();
|
||
|
|
$firstReadAt = $notif->read_at?->toIso8601String();
|
||
|
|
expect($firstReadAt)->not->toBeNull();
|
||
|
|
|
||
|
|
// Повторный — не меняет read_at.
|
||
|
|
$this->patchJson("/api/notifications/{$notif->id}/read")->assertOk();
|
||
|
|
$notif->refresh();
|
||
|
|
expect($notif->read_at?->toIso8601String())->toBe($firstReadAt);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('PATCH /api/notifications/{id}/read: 404 для чужого', function () {
|
||
|
|
$other = User::factory()->create(['tenant_id' => $this->tenant->id]);
|
||
|
|
$notif = makeNotif($other->id, $this->tenant->id);
|
||
|
|
|
||
|
|
$this->patchJson("/api/notifications/{$notif->id}/read")->assertStatus(404);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('PATCH /api/notifications/{id}/read: 404 на несуществующий id', function () {
|
||
|
|
$this->patchJson('/api/notifications/999999/read')->assertStatus(404);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('POST /api/notifications/mark-all-read: bulk-update + count', function () {
|
||
|
|
makeNotif($this->user->id, $this->tenant->id);
|
||
|
|
makeNotif($this->user->id, $this->tenant->id);
|
||
|
|
makeNotif($this->user->id, $this->tenant->id, readAt: now()->toIso8601String()); // уже прочитано
|
||
|
|
|
||
|
|
$response = $this->postJson('/api/notifications/mark-all-read');
|
||
|
|
$response->assertOk();
|
||
|
|
expect($response->json('updated'))->toBe(2);
|
||
|
|
|
||
|
|
$unreadCount = InAppNotification::query()
|
||
|
|
->where('user_id', $this->user->id)
|
||
|
|
->whereNull('read_at')
|
||
|
|
->count();
|
||
|
|
expect($unreadCount)->toBe(0);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('POST /api/notifications/mark-all-read: только свои', function () {
|
||
|
|
$other = User::factory()->create(['tenant_id' => $this->tenant->id]);
|
||
|
|
makeNotif($other->id, $this->tenant->id); // чужое
|
||
|
|
makeNotif($this->user->id, $this->tenant->id);
|
||
|
|
|
||
|
|
$response = $this->postJson('/api/notifications/mark-all-read');
|
||
|
|
expect($response->json('updated'))->toBe(1); // только своё
|
||
|
|
|
||
|
|
$otherUnread = InAppNotification::query()
|
||
|
|
->where('user_id', $other->id)
|
||
|
|
->whereNull('read_at')
|
||
|
|
->count();
|
||
|
|
expect($otherUnread)->toBe(1); // чужое осталось непрочитанным
|
||
|
|
});
|
||
|
|
|
||
|
|
test('DELETE /api/notifications/{id}: удаляет своё', function () {
|
||
|
|
$notif = makeNotif($this->user->id, $this->tenant->id);
|
||
|
|
|
||
|
|
$this->deleteJson("/api/notifications/{$notif->id}")->assertOk();
|
||
|
|
expect(InAppNotification::query()->find($notif->id))->toBeNull();
|
||
|
|
});
|
||
|
|
|
||
|
|
test('DELETE /api/notifications/{id}: 404 для чужого', function () {
|
||
|
|
$other = User::factory()->create(['tenant_id' => $this->tenant->id]);
|
||
|
|
$notif = makeNotif($other->id, $this->tenant->id);
|
||
|
|
|
||
|
|
$this->deleteJson("/api/notifications/{$notif->id}")->assertStatus(404);
|
||
|
|
|
||
|
|
expect(InAppNotification::query()->find($notif->id))->not->toBeNull(); // не удалено
|
||
|
|
});
|