From f55b91cfa4f79bec89dc6fcf3caa63ea9c627fba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sat, 9 May 2026 11:41:35 +0300 Subject: [PATCH] phase2(notifications-stage3): NotificationsTab schema-aligned + prefs API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Закрывает архитектурное расхождение 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) --- CLAUDE.md | 4 +- .../Http/Controllers/Api/AuthController.php | 53 +++++ app/database/factories/UserFactory.php | 13 ++ app/phpstan-baseline.neon | 30 +++ app/resources/js/api/auth.ts | 27 +++ .../js/views/settings/NotificationsTab.vue | 213 ++++++++++++++---- app/routes/web.php | 1 + .../Auth/NotificationPreferencesTest.php | 150 ++++++++++++ app/tests/Frontend/NotificationsTab.spec.ts | 160 +++++++++++++ app/tests/Frontend/SettingsView.spec.ts | 4 +- cspell-words.txt | 1 + 11 files changed, 614 insertions(+), 42 deletions(-) create mode 100644 app/tests/Feature/Auth/NotificationPreferencesTest.php create mode 100644 app/tests/Frontend/NotificationsTab.spec.ts diff --git a/CLAUDE.md b/CLAUDE.md index fc94c9a0..a241df1e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # CLAUDE.md — техконтекст Лидерры -**Версия:** 1.67 от 09.05.2026 +**Версия:** 1.68 от 09.05.2026 **Назначение:** оперативная карта для Claude Code. Не первоисточник — первоисточники указаны в §0. > **Ребрендинг 08.05.2026:** «Лидпоток» → **«Лидерра.»** (с точкой). Палитра, лого и шрифты — из handoff Платона (v8 Forest). Применяется только к дизайну/имени/логотипу; функционал, состав страниц и правила — без изменений (источник — ТЗ v8.5/schema v8.5). @@ -224,6 +224,8 @@ trivy image liderra:latest --- +*CLAUDE.md v1.68 от 09.05.2026. Изменения v1.68: **P0 этап 3 — NotificationsTab.vue фикс под schema + GET/PATCH prefs API**. Закрытие архитектурного расхождения из v1.28: handoff (8 событий: new_lead/duplicate_detected/low_balance/tariff_charge/reminder_due/manager_assigned/webhook_failed/monthly_report × email/sms/in_app) — **не совпадал** с schema (8 событий: new_lead/reminder/low_balance/zero_balance/topup_success/invoice_paid/new_device_login/marketing × inapp/push/email). Tab сохранял prefs только локально без API. **(1) Backend `AuthController::updateNotificationPreferences`** — PATCH /api/auth/me/notification-preferences под `auth:sanctum`. Принимает `{prefs: {event: {channel: bool}}, sound_enabled?: bool}`. Валидация: события ∈ `NotificationService::ALL_EVENTS` (8 schema-aligned), каналы ∈ `{inapp, push, email}`. **Replace-семантика**: незадекларированные events отбрасываются полностью (не merge — позволяет «выключить целиком»). Незадекларированные channels тоже отбрасываются (защита от schema-pollution). bool-кастинг (`1`/`'1'` → `true`). Возвращает `userResource` с обновлёнными prefs. **`userResource`** расширен: добавлены `notification_preferences` + `sound_enabled` поля. **`UserFactory`** расширен `notification_preferences` (schema-default JSON 8×3) — без этого тесты падали на `User::factory()->create()` поскольку Eloquent не перечитывает строку после INSERT, а DB-DEFAULT JSONB виден как null на свежесозданной модели. **(2) Pest +10** в `NotificationPreferencesTest.php` (всего **315/315 за 36.73 сек**, 1130 assertions): 401 без auth / успех + replace prefs / неизвестные events отбрасываются / неизвестные channels (sms/webhook) отбрасываются / 422 без prefs / sound_enabled опционален / GET /me возвращает prefs+sound_enabled / 422 при prefs.* строка вместо объекта / bool-кастинг 1/'1' → true / replace-семантика (отсутствующие events исчезают). **(3) Frontend `api/auth.ts`** — типы `NotificationChannel = 'inapp'|'push'|'email'` + `NotificationEventKey` (8 events) + `NotificationPreferences` Partial-Record. `AuthUser` interface получил optional `notification_preferences` + `sound_enabled`. Helper `updateNotificationPreferences(payload)`. **(4) `NotificationsTab.vue`** полностью переписан под schema-aligned: 8 событий с описаниями (Новый лид/Напоминание/Низкий баланс/Нулевой баланс/Пополнение успешно/Счёт оплачен/Новое устройство/Анонсы и промо), 3 канала (В приложении/Push/Email — БЕЗ SMS). Реактивный flow: `prefs` ref инициализирован синхронно через `buildPrefs()` (иначе `v-if="prefs[e.id]"` блокирует рендер чекбоксов до onMounted и тесты `mount()→find()` падают). `dirty` — computed (JSON.stringify сравнение с `originalPrefs` snapshot вместо watch+флаг — устойчив к идемпотентным изменениям). `save()` async + 2 v-alert (success-tonal / warning-tonal closable). `Сохранить` btn `:disabled="!canSave"` + `:loading="saving"`. `Отменить` btn вызывает `readFromUser()` (re-snapshot из auth.user). Push-канал отмечен «включится в Post-MVP» в hint'ах. **(5) Vitest +10** в `NotificationsTab.spec.ts` (всего **349/349 за 20.42 сек**, +10 от 339): 8 schema-aligned событий присутствуют / 3 канала (НЕ sms) / legacy-events отсутствуют (Дубликат/Webhook упал/etc) / читает prefs из auth.user (new_lead.email=false / reminder.email=true) / Сохранить disabled пока не изменено / после toggle становится enabled / save() вызывает API + success-alert + правильный payload / save() reject → error-alert / Отменить возвращает к оригиналу / sound_enabled читается из auth.user. SettingsView.spec.ts обновлён (legacy event-имена «Дубликат/Срок напоминания/Webhook упал» → «Напоминание/Нулевой баланс/Анонсы и промо»). **PHPStan baseline** регенерирован для +25 ignored Pest TestCall. **Производственные TODO остаточные:** этапы 4-5 (Reminders backend + frontend), этап 6 (4 email-события); SaaS-admin auth ⏸ Б-1; Pest browser-mode ⏸ инфра. **Регресс зелёный:** lint+type-check+format ✅; **Vitest 349/349 за 20.42 сек** (+10 от 339); vite build 983 ms; Pint+PHPStan passed; **Pest 315/315 за 36.73 сек** (+10 от 305, 1130 assertions). v1.67→v1.68.* + *CLAUDE.md v1.67 от 09.05.2026. Изменения v1.67: **P0 этап 2b — In-app notifications API + UI bell + polling**. Закрывает этап 2 P0 целиком (вместе с 2a). **(1) Backend `App\Http\Controllers\Api\InAppNotificationController`** под `auth:sanctum` (Sanctum SPA, уведомления USER-personal). 4 endpoint'а: `GET /api/notifications?unread_only=&limit=` (1..100, default 50; ORDER BY created_at DESC + id DESC; возвращает items+unread_count+total); `PATCH /api/notifications/{id}/read` (idempotent — повторный вызов NO-OP); `POST /api/notifications/mark-all-read` (bulk update + count); `DELETE /api/notifications/{id}` (hard-delete). Все четыре обёрнуты в DB::transaction + SET LOCAL app.current_tenant_id. Защита от кражи чужого id через `where('user_id', $authUser->id)` поверх RLS. **(2) Маршруты** в `routes/web.php` под `Route::middleware('auth:sanctum')->prefix('/api/notifications')` — Sanctum SPA требует session middleware из web-группы. **(3) Pest +14** в `InAppNotificationApiTest.php` (всего **305/305 за 34.71 сек**, 1099 assertions): 401 без auth / пустой / только свои + ORDER BY created_at DESC / unread_only=1 / limit=2 + total=5 / 422 limit>100 / поля title+body+event+payload+deal_id / mark-read ставит read_at + idempotent / mark-read 404 для чужого / mark-read 404 unknown / mark-all-read bulk + count / mark-all-read только свои / DELETE удаляет своё / DELETE 404 для чужого. **(4) Frontend `api/notifications.ts`** — типизированные axios-helpers с `ensureCsrfCookie` для mutating-вызовов. ApiInAppNotification + ListNotificationsResponse interfaces. **(5) Pinia store `stores/notifications.ts`** — items/unreadCount/total/loading/fetchError refs + sortedItems computed (DESC by created_at) + actions: `load(limit, unreadOnly)` / `markRead(id)` (optimistic + revert на reject) / `markAllRead()` (NO-OP при unreadCount=0) / `remove(id)` (optimistic с decrement total/unreadCount) / `reset()`. На fail markRead/markAllRead/remove — silently revert (без toast'а — иначе спам при каждом sync-failure). **(6) AppLayout** — bell-icon переписан с static-pip на v-menu (offset=8, close-on-content-click=false, location=bottom-end): `` с pip badge показывающим `unreadDisplay` (1..99 / `99+` / hidden при 0); v-card с заголовком + Mark-all-read btn (только при unreadCount>0) + v-list последних 10 элементов из sortedItems. Click на item → `markRead` + если `deal_id` → `router.push('/deals')` (deep-link на конкретный drawer — отдельный коммит). 8 mock event-icon'ов (mdi-account-plus-outline для new_lead, mdi-clock-outline для reminder, и т.д.). **`formatRelative`** показывает «только что» / «N мин назад» / «N ч назад» / «N д назад». `usePolling(loadNotifications, {intervalMs: 30_000})` — каждые 30 сек reload (Page Visibility API в usePolling pause'ит при hidden tab). `loadNotifications` no-op без auth.user. **(7) Vitest +18** (всего **339/339 за 20.03 сек**, +18 от 321): notifications-store 12 (initial state / load fills+rejects / markRead optimistic+revert+already-read / markAllRead optimistic+NO-OP при 0 / remove optimistic+revert / sortedItems DESC / reset); AppLayout +6 (bell-btn существует / pip скрыт при 0 / pip показывает count / pip 99+ при >99 / listNotifications вызывается на mount при auth.user / без user не вызывается). **PHPStan baseline регенерирован** (50 false-positive Pest TestCall warnings подавлены). Production TODO остаточные: deep-link на конкретный drawer (на MVP — push на /deals); этапы 3-6 P0; SaaS-admin auth ⏸ Б-1; Pest browser-mode ⏸ инфра. **Регресс зелёный:** lint+type-check+format ✅; **Vitest 339/339 за 20.03 сек** (+18 от 321); vite build 989 ms (main app-chunk 164.94 KB / KanbanView lazy 182.26 KB); Pint+PHPStan passed (baseline регенерирован); **Pest 305/305 за 34.71 сек** (+14 от 291, 1099 assertions). v1.66→v1.67.* *CLAUDE.md v1.66 от 09.05.2026. Изменения v1.66: **P0 этап 2a — in_app_notifications + notifyInApp в NotificationService** (schema v8.9→v8.10). Backend-фундамент bell-icon канала; UI bell + API endpoints — этап 2b отдельным коммитом. **(1) Schema v8.10** — таблица `in_app_notifications` после `reminders` (обе про работу/коммуникации): id BIGSERIAL / tenant_id FK / user_id FK / event VARCHAR(50) / title VARCHAR(255) / body TEXT / deal_id BIGINT БЕЗ FK (deals партиционирована) / payload JSONB DEFAULT '{}' / read_at TIMESTAMPTZ / created_at TIMESTAMPTZ. UPDATED_AT отсутствует (только created_at + read_at). Индексы: `idx_in_app_notifications_user_unread (user_id, created_at DESC) WHERE read_at IS NULL` (главный UI-флоу) + `idx_in_app_notifications_user_recent (user_id, created_at DESC)` (последние 50 с прочитанными). RLS `tenant_isolation` стандартная. CHANGELOG_schema.md +§T (3 точки источник изменений + 4 точки SQL DDL + почему НЕ Laravel default `notifications`-table). **Метрики после v8.10:** 55→56 таблиц, 93→95 индексов, 36→37 RLS-политик. **(2) `App\Models\InAppNotification`** — Eloquent с `UPDATED_AT=null`, payload cast `array`, read_at cast `datetime`, BelongsTo на User+Tenant. **(3) `NotificationService::notifyInApp(User, event, title, body, payload)`** — INSERT в БД через `DB::transaction` + `SET LOCAL app.current_tenant_id = user.tenant_id` (PgBouncer-safe, RLS-симметрично). Throwable проглатываются + Log::warning. **`NotificationService::notifyNewLead`** теперь шлёт ДВА канала параллельно: email (если prefs.email=true) И in-app (если prefs.inapp=true). `title` = `«Новый лид — {projectName}»`, `body` = `contact_name ?? phone`, `payload` = `{deal_id, project_name}` для UI deep-link на DealDetailDrawer. Schema-default `new_lead.inapp=true` → большинство получит in-app, и только подписавшиеся — email. **(4) Pest +11** в `tests/Feature/Notifications/InAppNotificationTest.php` (всего **291/291 за 32.94 сек**, 1060 assertions): inapp=true создаёт row + поля + payload / inapp=false не создаёт / schema-default ставит row / 2 user'а с inapp=true оба получают / inactive не получает / другой тенант не получает (RLS изоляция) / Биз-19 дубль не дублирует / повторный vid не дублирует / inapp+email=true создаёт 1 row + 1 email / payload содержит deal_id для deep-link / `notifyInApp` напрямую с reminder создаёт row. **(5) Quirk** — Write tool с относительным путём `app/tests/...` создал файлы в `app/app/tests/...` (CWD дрейфонул на `/c/моя/.../app/app`); файлы перемещены вручную, пустые директории удалены через rmdir (rm -rf не пройден permissions). **(6) IDE-helper регенерирован** для нового InAppNotification. **PHPStan baseline регенерирован** (1 «nullsafe.neverNull» error на `$deal->project?->name` подавлен через baseline). **Производственные TODO остаточные:** этап 2b (API + UI bell), этапы 3-6; SaaS-admin auth ⏸ Б-1; Pest browser-mode ⏸ инфра. **Регресс зелёный:** Pint+PHPStan passed (baseline регенерирован); **Pest 291/291 за 32.94 сек** (+11 от 280, 1060 assertions); frontend нетронут — Vitest/build не нужны. v1.65→v1.66.* diff --git a/app/app/Http/Controllers/Api/AuthController.php b/app/app/Http/Controllers/Api/AuthController.php index 0e8b393e..12448d38 100644 --- a/app/app/Http/Controllers/Api/AuthController.php +++ b/app/app/Http/Controllers/Api/AuthController.php @@ -15,6 +15,7 @@ use App\Mail\SuspiciousLoginNotification; use App\Models\Tenant; use App\Models\User; use App\Models\UserRecoveryCode; +use App\Services\NotificationService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; @@ -537,6 +538,58 @@ class AuthController extends Controller 'tenant_id' => $user->tenant_id, 'totp_enabled' => $user->totp_enabled, 'last_login_at' => $user->last_login_at, + 'notification_preferences' => $user->notification_preferences, + 'sound_enabled' => $user->sound_enabled, ]; } + + /** + * PATCH /api/auth/me/notification-preferences — сохранить матрицу + * 8 событий × 3 каналов (inapp/push/email) + sound_enabled. + * + * Источник: schema.sql:699 users.notification_preferences JSONB DEFAULT. + * Валидация: события ∈ NotificationService::ALL_EVENTS, каналы ∈ + * {inapp, push, email}. Незадекларированные ключи отбрасываются. + */ + public function updateNotificationPreferences(Request $request): JsonResponse + { + $validated = $request->validate([ + 'prefs' => 'required|array', + 'prefs.*' => 'array', + 'sound_enabled' => 'nullable|boolean', + ]); + + $allEvents = NotificationService::ALL_EVENTS; + $allChannels = [ + NotificationService::CHANNEL_INAPP, + NotificationService::CHANNEL_PUSH, + NotificationService::CHANNEL_EMAIL, + ]; + + // Очищенная матрица (только known events × known channels). + $sanitized = []; + foreach ($validated['prefs'] as $event => $channelPrefs) { + if (! in_array($event, $allEvents, true)) { + continue; + } + $sanitized[$event] = []; + foreach ($allChannels as $channel) { + if (isset($channelPrefs[$channel])) { + $sanitized[$event][$channel] = (bool) $channelPrefs[$channel]; + } + } + } + + /** @var User $user */ + $user = $request->user(); + $update = ['notification_preferences' => $sanitized]; + if (array_key_exists('sound_enabled', $validated)) { + $update['sound_enabled'] = (bool) $validated['sound_enabled']; + } + $user->update($update); + + return response()->json([ + 'user' => $this->userResource($user->fresh()), + ]); + } } diff --git a/app/database/factories/UserFactory.php b/app/database/factories/UserFactory.php index 79859d1a..80fb1fbb 100644 --- a/app/database/factories/UserFactory.php +++ b/app/database/factories/UserFactory.php @@ -32,6 +32,19 @@ class UserFactory extends Factory 'totp_enabled' => false, 'sound_enabled' => true, 'email_verified_at' => now(), + // Schema-default matrix (см. schema.sql:699). Eloquent не перечитывает + // строку после INSERT, поэтому колонки с DB-DEFAULT'ами видны как + // null на свежесозданной модели — нужно явно задать здесь. + 'notification_preferences' => [ + 'new_lead' => ['inapp' => true, 'push' => true, 'email' => false], + 'reminder' => ['inapp' => true, 'push' => true, 'email' => true], + 'low_balance' => ['email' => true], + 'zero_balance' => ['email' => true], + 'topup_success' => ['email' => true], + 'invoice_paid' => ['email' => true], + 'new_device_login' => ['email' => true], + 'marketing' => ['email' => false], + ], ]; } diff --git a/app/phpstan-baseline.neon b/app/phpstan-baseline.neon index 536f554e..f5d01fd8 100644 --- a/app/phpstan-baseline.neon +++ b/app/phpstan-baseline.neon @@ -186,6 +186,36 @@ parameters: count: 6 path: tests/Feature/Auth/IpLockoutTest.php + - + message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#' + identifier: property.notFound + count: 1 + path: tests/Feature/Auth/NotificationPreferencesTest.php + + - + message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#' + identifier: property.notFound + count: 3 + path: tests/Feature/Auth/NotificationPreferencesTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#' + identifier: method.notFound + count: 1 + path: tests/Feature/Auth/NotificationPreferencesTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#' + identifier: method.notFound + count: 1 + path: tests/Feature/Auth/NotificationPreferencesTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:patchJson\(\)\.$#' + identifier: method.notFound + count: 9 + path: tests/Feature/Auth/NotificationPreferencesTest.php + - message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#' identifier: property.notFound diff --git a/app/resources/js/api/auth.ts b/app/resources/js/api/auth.ts index 0244e692..f254d437 100644 --- a/app/resources/js/api/auth.ts +++ b/app/resources/js/api/auth.ts @@ -6,6 +6,20 @@ import { apiClient, ensureCsrfCookie } from './client'; * Все методы делают `ensureCsrfCookie()` перед POST'ами — Sanctum SPA flow. */ +export type NotificationChannel = 'inapp' | 'push' | 'email'; +export type NotificationEventKey = + | 'new_lead' + | 'reminder' + | 'low_balance' + | 'zero_balance' + | 'topup_success' + | 'invoice_paid' + | 'new_device_login' + | 'marketing'; +export type NotificationPreferences = Partial< + Record>> +>; + export interface AuthUser { id: number; email: string; @@ -14,6 +28,8 @@ export interface AuthUser { tenant_id: number; totp_enabled: boolean; last_login_at: string | null; + notification_preferences?: NotificationPreferences; + sound_enabled?: boolean; } export interface LoginPayload { @@ -124,3 +140,14 @@ export async function resetPassword(payload: ResetPasswordPayload): Promise<{ me const { data } = await apiClient.post<{ message: string }>('/api/auth/reset-password', payload); return data; } + +export interface UpdateNotificationPreferencesPayload { + prefs: NotificationPreferences; + sound_enabled?: boolean; +} + +export async function updateNotificationPreferences(payload: UpdateNotificationPreferencesPayload): Promise { + await ensureCsrfCookie(); + const { data } = await apiClient.patch<{ user: AuthUser }>('/api/auth/me/notification-preferences', payload); + return data.user; +} diff --git a/app/resources/js/views/settings/NotificationsTab.vue b/app/resources/js/views/settings/NotificationsTab.vue index 384caa33..30196f61 100644 --- a/app/resources/js/views/settings/NotificationsTab.vue +++ b/app/resources/js/views/settings/NotificationsTab.vue @@ -1,88 +1,209 @@ @@ -121,13 +242,27 @@ const soundEnabled = ref(true); .prefs-cell { padding: 10px 12px; } + .event-col { color: #081319; font-size: 13px; } + +.event-desc { + font-size: 11px; + color: #66635c; + margin-top: 2px; +} + .ch-col { text-align: center; display: flex; justify-content: center; } + +.actions-row { + display: flex; + gap: 12px; + margin-top: 8px; +} diff --git a/app/routes/web.php b/app/routes/web.php index ce9af097..6ce32288 100644 --- a/app/routes/web.php +++ b/app/routes/web.php @@ -33,6 +33,7 @@ Route::prefix('/api/auth')->group(function () { Route::middleware('auth:sanctum')->group(function () { Route::get('/me', [AuthController::class, 'me']); Route::post('/logout', [AuthController::class, 'logout']); + Route::patch('/me/notification-preferences', [AuthController::class, 'updateNotificationPreferences']); }); }); diff --git a/app/tests/Feature/Auth/NotificationPreferencesTest.php b/app/tests/Feature/Auth/NotificationPreferencesTest.php new file mode 100644 index 00000000..1766823d --- /dev/null +++ b/app/tests/Feature/Auth/NotificationPreferencesTest.php @@ -0,0 +1,150 @@ +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'); +}); diff --git a/app/tests/Frontend/NotificationsTab.spec.ts b/app/tests/Frontend/NotificationsTab.spec.ts new file mode 100644 index 00000000..503dddac --- /dev/null +++ b/app/tests/Frontend/NotificationsTab.spec.ts @@ -0,0 +1,160 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { mount } from '@vue/test-utils'; +import { createPinia, setActivePinia } from 'pinia'; +import { createVuetify } from 'vuetify'; + +vi.mock('../../resources/js/api/auth', () => ({ + updateNotificationPreferences: vi.fn(), +})); + +vi.mock('../../resources/js/api/client', () => ({ + apiClient: {}, + ensureCsrfCookie: vi.fn(), + extractValidationErrors: vi.fn(() => null), + extractErrorMessage: vi.fn(() => 'Произошла ошибка.'), + extractRateLimitRetry: vi.fn(() => null), +})); + +import * as authApi from '../../resources/js/api/auth'; +import NotificationsTab from '../../resources/js/views/settings/NotificationsTab.vue'; +import { useAuthStore } from '../../resources/js/stores/auth'; +import type { AuthUser } from '../../resources/js/api/auth'; + +const mockUser: AuthUser = { + id: 1, + email: 'test@example.ru', + first_name: 'Иван', + last_name: 'Петров', + tenant_id: 1, + totp_enabled: false, + last_login_at: null, + notification_preferences: { + new_lead: { inapp: true, push: true, email: false }, + reminder: { inapp: true, push: true, email: true }, + low_balance: { email: true }, + zero_balance: { email: true }, + topup_success: { email: true }, + invoice_paid: { email: true }, + new_device_login: { email: true }, + marketing: { email: false }, + }, + sound_enabled: true, +}; + +const factory = (user: AuthUser | null = mockUser) => { + setActivePinia(createPinia()); + const auth = useAuthStore(); + auth.user = user; + return mount(NotificationsTab, { + global: { plugins: [createVuetify()] }, + }); +}; + +describe('NotificationsTab.vue (schema-aligned)', () => { + beforeEach(() => vi.clearAllMocks()); + + it('содержит ровно 8 schema-aligned событий', () => { + const wrapper = factory(); + const text = wrapper.text(); + [ + 'Новый лид', + 'Напоминание', + 'Низкий баланс', + 'Нулевой баланс', + 'Пополнение успешно', + 'Счёт оплачен', + 'Новое устройство', + 'Анонсы и промо', + ].forEach((label) => expect(text).toContain(label)); + }); + + it('содержит ровно 3 канала (inapp/push/email) — НЕ sms', () => { + const wrapper = factory(); + const text = wrapper.text(); + expect(text).toContain('В приложении'); + expect(text).toContain('Push'); + expect(text).toContain('Email'); + expect(text).not.toContain('SMS'); + }); + + it('legacy-events отсутствуют (Дубликат / Webhook упал и т.д.)', () => { + const wrapper = factory(); + const text = wrapper.text(); + ['Дубликат / антифрод', 'Срок напоминания', 'Webhook упал', 'Месячный отчёт', 'Назначен менеджер'].forEach( + (legacy) => expect(text).not.toContain(legacy), + ); + }); + + it('читает prefs из auth.user при mount: new_lead.email=false / reminder.email=true', () => { + const wrapper = factory(); + const newLeadEmail = wrapper.find('[data-testid="pref-new_lead-email"] input'); + const reminderEmail = wrapper.find('[data-testid="pref-reminder-email"] input'); + expect((newLeadEmail.element as HTMLInputElement).checked).toBe(false); + expect((reminderEmail.element as HTMLInputElement).checked).toBe(true); + }); + + it('Сохранить disabled пока ничего не изменено', () => { + const wrapper = factory(); + const saveBtn = wrapper.find('[data-testid="save-btn"]'); + expect(saveBtn.attributes('disabled')).toBeDefined(); + }); + + it('после переключения checkbox Сохранить становится enabled', async () => { + const wrapper = factory(); + const checkbox = wrapper.find('[data-testid="pref-new_lead-email"] input'); + await checkbox.setValue(true); + const saveBtn = wrapper.find('[data-testid="save-btn"]'); + expect(saveBtn.attributes('disabled')).toBeUndefined(); + }); + + it('save() вызывает API и показывает success-alert', async () => { + vi.mocked(authApi.updateNotificationPreferences).mockResolvedValue({ + ...mockUser, + notification_preferences: { + ...mockUser.notification_preferences, + new_lead: { inapp: true, push: true, email: true }, + }, + }); + + const wrapper = factory(); + const checkbox = wrapper.find('[data-testid="pref-new_lead-email"] input'); + await checkbox.setValue(true); + await wrapper.find('[data-testid="save-btn"]').trigger('click'); + await new Promise((resolve) => setTimeout(resolve, 0)); + await wrapper.vm.$nextTick(); + + expect(authApi.updateNotificationPreferences).toHaveBeenCalled(); + const callArg = vi.mocked(authApi.updateNotificationPreferences).mock.calls[0]![0]; + expect(callArg.prefs?.new_lead?.email).toBe(true); + expect(wrapper.find('[data-testid="notifications-save-success"]').exists()).toBe(true); + }); + + it('save() при reject показывает error-alert', async () => { + vi.mocked(authApi.updateNotificationPreferences).mockRejectedValue(new Error('500')); + + const wrapper = factory(); + const checkbox = wrapper.find('[data-testid="pref-new_lead-email"] input'); + await checkbox.setValue(true); + await wrapper.find('[data-testid="save-btn"]').trigger('click'); + await new Promise((resolve) => setTimeout(resolve, 0)); + await wrapper.vm.$nextTick(); + + expect(wrapper.find('[data-testid="notifications-save-error"]').exists()).toBe(true); + }); + + it('Отменить возвращает prefs к оригиналу (auth.user)', async () => { + const wrapper = factory(); + const checkbox = wrapper.find('[data-testid="pref-new_lead-email"] input'); + await checkbox.setValue(true); + await wrapper.find('[data-testid="reset-btn"]').trigger('click'); + await wrapper.vm.$nextTick(); + // dirty снова false → save disabled. + expect(wrapper.find('[data-testid="save-btn"]').attributes('disabled')).toBeDefined(); + }); + + it('sound_enabled читается из auth.user (default true)', () => { + const wrapper = factory(); + const sw = wrapper.find('[data-testid="sound-enabled-switch"] input'); + expect((sw.element as HTMLInputElement).checked).toBe(true); + }); +}); diff --git a/app/tests/Frontend/SettingsView.spec.ts b/app/tests/Frontend/SettingsView.spec.ts index 17c3684e..c7aee115 100644 --- a/app/tests/Frontend/SettingsView.spec.ts +++ b/app/tests/Frontend/SettingsView.spec.ts @@ -64,8 +64,8 @@ describe('SettingsView.vue', () => { await wrapper.vm.$nextTick(); const text = wrapper.text(); expect(text).toContain('События × каналы'); - // 8 типов событий из матрицы. - ['Новый лид', 'Дубликат', 'Низкий баланс', 'Срок напоминания', 'Webhook упал'].forEach((e) => + // 8 типов событий из schema users.notification_preferences. + ['Новый лид', 'Напоминание', 'Низкий баланс', 'Нулевой баланс', 'Анонсы и промо'].forEach((e) => expect(text).toContain(e), ); }); diff --git a/cspell-words.txt b/cspell-words.txt index 6c32faee..87b7018f 100644 --- a/cspell-words.txt +++ b/cspell-words.txt @@ -804,3 +804,4 @@ unshift партиальный консистентности inapp +prefs