Files
portal/app/tests/Feature/Auth/NotificationPreferencesTest.php
T
Дмитрий f55b91cfa4 phase2(notifications-stage3): NotificationsTab schema-aligned + prefs API
Закрывает архитектурное расхождение v1.28 — Tab сохранял prefs только
локально без API. Backend events не совпадали с handoff'ом.

Backend:
- PATCH /api/auth/me/notification-preferences под auth:sanctum.
- Replace-семантика: незадекларированные events/channels отбрасываются.
- userResource расширен: notification_preferences + sound_enabled.
- UserFactory с schema-default JSON (Eloquent не перечитывает после INSERT,
  DB-DEFAULT JSONB виден как null без явного override).
- Pest +10: 401 / replace / неизвестные events/channels отбрасываются /
  422 без prefs / sound_enabled опционален / bool-cast 1/'1' / replace-
  семантика (отсутствующие events исчезают).

Frontend:
- api/auth.ts: типы NotificationChannel/EventKey/Preferences +
  updateNotificationPreferences helper. AuthUser получил optional поля.
- NotificationsTab.vue переписан под schema:
  8 событий (new_lead/reminder/low_balance/zero_balance/topup_success/
  invoice_paid/new_device_login/marketing) × 3 канала (inapp/push/email,
  НЕ sms). Sync-init prefs (без onMounted — иначе v-if блокирует рендер
  и тесты mount-then-find падают). dirty через computed-сравнение с
  originalPrefs snapshot. save async + success/error alerts.
- SettingsView.spec.ts: legacy event-имена → schema-aligned.
- Vitest +10: 8 schema events / 3 channels (НЕ sms) / legacy отсутствуют /
  читает prefs из user / save calls API + alerts / Отменить возвращает.

cspell-words: +prefs.
PHPStan baseline регенерирован.

Pest 315/315 (+10) за 36.73 сек, 1130 assertions.
Vitest 349/349 (+10) за 20.42 сек.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 11:41:35 +03:00

151 lines
5.7 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
/**
* Тесты PATCH /api/auth/me/notification-preferences (Settings → Уведомления).
*
* Принимает {prefs: {event: {channel: bool}}, sound_enabled?: bool}.
* Валидация: события ∈ ALL_EVENTS (8), каналы ∈ {inapp, push, email}.
* Незадекларированные ключи отбрасываются (защита от schema-pollution).
*/
uses(DatabaseTransactions::class);
beforeEach(function () {
$this->tenant = Tenant::factory()->create();
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
$this->actingAs($this->user);
});
test('PATCH без auth: 401', function () {
auth()->logout();
$this->patchJson('/api/auth/me/notification-preferences', [
'prefs' => ['new_lead' => ['email' => true]],
])->assertStatus(401);
});
test('PATCH успех: сохраняет prefs + возвращает user', function () {
$response = $this->patchJson('/api/auth/me/notification-preferences', [
'prefs' => [
'new_lead' => ['inapp' => true, 'push' => false, 'email' => true],
'reminder' => ['inapp' => true, 'email' => true],
],
'sound_enabled' => false,
]);
$response->assertOk();
$userResp = $response->json('user');
expect($userResp['notification_preferences']['new_lead']['email'])->toBeTrue();
expect($userResp['notification_preferences']['new_lead']['inapp'])->toBeTrue();
expect($userResp['notification_preferences']['new_lead']['push'])->toBeFalse();
expect($userResp['sound_enabled'])->toBeFalse();
});
test('PATCH: неизвестные events отбрасываются', function () {
$response = $this->patchJson('/api/auth/me/notification-preferences', [
'prefs' => [
'new_lead' => ['email' => true],
'fake_event' => ['email' => true], // не в ALL_EVENTS — должно отброситься
],
]);
$response->assertOk();
$prefs = $response->json('user.notification_preferences');
expect($prefs)->toHaveKey('new_lead');
expect($prefs)->not->toHaveKey('fake_event');
});
test('PATCH: неизвестные каналы отбрасываются', function () {
$response = $this->patchJson('/api/auth/me/notification-preferences', [
'prefs' => [
'new_lead' => [
'email' => true,
'sms' => true, // SMS — не в нашей schema
'webhook' => true, // тоже не из schema
],
],
]);
$response->assertOk();
$prefs = $response->json('user.notification_preferences.new_lead');
expect($prefs)->toHaveKey('email');
expect($prefs)->not->toHaveKey('sms');
expect($prefs)->not->toHaveKey('webhook');
});
test('PATCH: 422 без prefs', function () {
$this->patchJson('/api/auth/me/notification-preferences', [
'sound_enabled' => true,
])->assertStatus(422);
});
test('PATCH: sound_enabled опционален (без него не меняется)', function () {
$this->user->update(['sound_enabled' => true]);
$response = $this->patchJson('/api/auth/me/notification-preferences', [
'prefs' => ['new_lead' => ['email' => true]],
]);
$response->assertOk();
expect($response->json('user.sound_enabled'))->toBeTrue();
});
test('GET /api/auth/me возвращает notification_preferences + sound_enabled', function () {
$response = $this->getJson('/api/auth/me');
$response->assertOk();
$user = $response->json('user');
expect($user)->toHaveKey('notification_preferences');
expect($user)->toHaveKey('sound_enabled');
// schema-default: 8 events.
expect($user['notification_preferences'])->toHaveKey('new_lead');
expect($user['notification_preferences'])->toHaveKey('reminder');
expect($user['notification_preferences'])->toHaveKey('low_balance');
});
test('PATCH: prefs.* должен быть объект (не строка)', function () {
$this->patchJson('/api/auth/me/notification-preferences', [
'prefs' => [
'new_lead' => 'true', // строка вместо объекта
],
])->assertStatus(422);
});
test('PATCH: bool-значения каналов кастятся', function () {
$response = $this->patchJson('/api/auth/me/notification-preferences', [
'prefs' => [
'new_lead' => ['email' => 1, 'inapp' => 0, 'push' => '1'],
],
]);
$response->assertOk();
$prefs = $response->json('user.notification_preferences.new_lead');
expect($prefs['email'])->toBeTrue();
expect($prefs['inapp'])->toBeFalse();
expect($prefs['push'])->toBeTrue();
});
test('PATCH полностью замещает prefs (не merge): ранее сохранённые отсутствующие events исчезают', function () {
// Дано: user имеет полный default-набор (schema-default 8 events).
$this->user->update(['notification_preferences' => [
'new_lead' => ['email' => true],
'reminder' => ['email' => true],
'low_balance' => ['email' => true],
]]);
// Update только new_lead — остальные пропадают (replace-семантика).
$response = $this->patchJson('/api/auth/me/notification-preferences', [
'prefs' => ['new_lead' => ['email' => false]],
]);
$response->assertOk();
$prefs = $response->json('user.notification_preferences');
expect($prefs)->toHaveKey('new_lead');
expect($prefs)->not->toHaveKey('reminder');
expect($prefs)->not->toHaveKey('low_balance');
});