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>
This commit is contained in:
Дмитрий
2026-05-09 11:41:35 +03:00
parent 508de4eaf3
commit f55b91cfa4
11 changed files with 614 additions and 42 deletions
+3 -1
View File
File diff suppressed because one or more lines are too long
@@ -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()),
]);
}
}
+13
View File
@@ -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],
],
];
}
+30
View File
@@ -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
+27
View File
@@ -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<NotificationEventKey, Partial<Record<NotificationChannel, boolean>>>
>;
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<AuthUser> {
await ensureCsrfCookie();
const { data } = await apiClient.patch<{ user: AuthUser }>('/api/auth/me/notification-preferences', payload);
return data.user;
}
@@ -1,88 +1,209 @@
<script setup lang="ts">
/**
* Settings Уведомления. Матрица 8×3 типов событий × каналов (Email/SMS/In-app).
* Settings Уведомления. Матрица 8 событий × 3 канала (inapp/push/email)
* + sound_enabled. Соответствует schema.sql:699 users.notification_preferences.
*
* Источник логики: ТЗ schema v8.7 §4 `users.notification_preferences` JSONB
* (8 событий × 3 канала) + `sound_enabled` BOOLEAN.
* Источник дизайна: liderra_v8_handoff/concepts/v8_settings.html секция #notifications
* (стаб; разворачиваем в матрицу по ТЗ).
* События (точно по schema-default):
* new_lead / reminder / low_balance / zero_balance /
* topup_success / invoice_paid / new_device_login / marketing.
*
* Каналы (точно по schema):
* inapp (bell-icon в UI) / push (web-push, Post-MVP) / email (Unisender Go).
*
* Push-канал на MVP пока не доставляется (требует ServiceWorker + VAPID), но
* preferences записываются для будущей активации без миграции данных.
*/
import { reactive, ref } from 'vue';
import { computed, ref, watch } from 'vue';
import { useAuthStore } from '../../stores/auth';
import {
type NotificationChannel,
type NotificationEventKey,
type NotificationPreferences,
updateNotificationPreferences,
} from '../../api/auth';
interface NotifEvent {
id: string;
id: NotificationEventKey;
label: string;
description?: string;
}
const events: NotifEvent[] = [
{ id: 'new_lead', label: 'Новый лид' },
{ id: 'duplicate_detected', label: 'Дубликат / антифрод' },
{ id: 'low_balance', label: 'Низкий баланс (<3 дней)' },
{ id: 'tariff_charge', label: 'Списание тарифа' },
{ id: 'reminder_due', label: 'Срок напоминания' },
{ id: 'manager_assigned', label: 'Назначен менеджер' },
{ id: 'webhook_failed', label: 'Webhook упал (>3 ретраев)' },
{ id: 'monthly_report', label: 'Месячный отчёт' },
const EVENTS: NotifEvent[] = [
{ id: 'new_lead', label: 'Новый лид', description: 'Поступил новый лид через webhook или manual-создание.' },
{ id: 'reminder', label: 'Напоминание', description: 'Срок касания клиента наступил.' },
{ id: 'low_balance', label: 'Низкий баланс', description: 'Осталось < 3 дней работы по текущим ценам.' },
{ id: 'zero_balance', label: 'Нулевой баланс', description: 'Лиды отклоняются — нет средств для оплаты.' },
{ id: 'topup_success', label: 'Пополнение успешно', description: 'Платёж зачислен на баланс.' },
{ id: 'invoice_paid', label: 'Счёт оплачен', description: 'Тарифный счёт списан.' },
{ id: 'new_device_login', label: 'Новое устройство', description: 'Вход с незнакомого устройства / IP.' },
{ id: 'marketing', label: 'Анонсы и промо', description: 'Новости платформы, скидки, новые функции.' },
];
interface ChannelKey {
id: 'email' | 'sms' | 'in_app';
id: NotificationChannel;
label: string;
description: string;
}
const channels: ChannelKey[] = [
{ id: 'email', label: 'Email' },
{ id: 'sms', label: 'SMS' },
{ id: 'in_app', label: 'В приложении' },
const CHANNELS: ChannelKey[] = [
{ id: 'inapp', label: 'В приложении', description: 'Колокольчик в интерфейсе.' },
{ id: 'push', label: 'Push', description: 'Web-push в браузер (включится в Post-MVP).' },
{ id: 'email', label: 'Email', description: 'На адрес в профиле.' },
];
const prefs = reactive<Record<string, Record<string, boolean>>>({});
events.forEach((e) => {
prefs[e.id] = {
email: ['new_lead', 'low_balance', 'monthly_report'].includes(e.id),
sms: e.id === 'low_balance',
in_app: !['monthly_report'].includes(e.id),
};
});
const auth = useAuthStore();
const soundEnabled = ref(true);
function buildPrefs(): NotificationPreferences {
const userPrefs = auth.user?.notification_preferences ?? {};
const next: NotificationPreferences = {};
for (const e of EVENTS) {
next[e.id] = {};
for (const c of CHANNELS) {
const stored = userPrefs[e.id]?.[c.id];
next[e.id]![c.id] = typeof stored === 'boolean' ? stored : false;
}
}
return next;
}
// Инициализация синхронно в setup() иначе v-if="prefs[e.id]" блокирует
// рендер чекбоксов до onMounted, и тесты mount-then-find падают.
const prefs = ref<NotificationPreferences>(buildPrefs());
const soundEnabled = ref<boolean>(auth.user?.sound_enabled ?? true);
const saving = ref(false);
const saveSuccess = ref(false);
const saveError = ref(false);
// Snapshot оригинальных значений из auth.user для определения dirty.
// Replacing с deep-clone чтобы JSON.stringify сравнение работало стабильно.
const originalPrefs = ref<NotificationPreferences>(buildPrefs());
const originalSound = ref<boolean>(auth.user?.sound_enabled ?? true);
const dirty = computed(
() =>
JSON.stringify(prefs.value) !== JSON.stringify(originalPrefs.value) ||
soundEnabled.value !== originalSound.value,
);
function readFromUser(): void {
prefs.value = buildPrefs();
soundEnabled.value = auth.user?.sound_enabled ?? true;
originalPrefs.value = buildPrefs();
originalSound.value = auth.user?.sound_enabled ?? true;
}
watch(() => auth.user?.id, readFromUser);
async function save(): Promise<void> {
if (saving.value) return;
saving.value = true;
saveSuccess.value = false;
saveError.value = false;
try {
const updatedUser = await updateNotificationPreferences({
prefs: prefs.value,
sound_enabled: soundEnabled.value,
});
// Синхронизируем auth.user без повторного /me-вызова.
auth.user = { ...auth.user!, ...updatedUser };
// Обновляем snapshot dirty снова false.
originalPrefs.value = buildPrefs();
originalSound.value = auth.user.sound_enabled ?? true;
saveSuccess.value = true;
} catch {
saveError.value = true;
} finally {
saving.value = false;
}
}
function reset(): void {
readFromUser();
}
const canSave = computed(() => dirty.value && !saving.value && auth.isAuthenticated);
</script>
<template>
<div class="tab-content">
<h2 class="tab-title text-h6 mb-4">Уведомления</h2>
<v-alert
v-if="saveSuccess"
type="success"
variant="tonal"
density="compact"
class="mb-3"
closable
data-testid="notifications-save-success"
@click:close="saveSuccess = false"
>
Настройки сохранены.
</v-alert>
<v-alert
v-if="saveError"
type="warning"
variant="tonal"
density="compact"
class="mb-3"
closable
data-testid="notifications-save-error"
@click:close="saveError = false"
>
Не удалось сохранить настройки. Попробуйте позже.
</v-alert>
<v-card variant="outlined" class="pa-4 mb-4">
<h3 class="text-subtitle-2 mb-3">События × каналы</h3>
<p class="text-body-2 text-medium-emphasis mb-3">
Матрица 8 типов событий × 3 каналов. Email рабочий, SMS критичные алерты (списания, низкий баланс),
В приложении для realtime push в браузер.
Матрица 8 событий × 3 каналов (соответствует
<code>users.notification_preferences</code> в БД). Push активируется в Post-MVP.
</p>
<div class="prefs-table">
<div class="prefs-head">
<div class="prefs-cell event-col">Событие</div>
<div v-for="ch in channels" :key="ch.id" class="prefs-cell ch-col">
<div v-for="ch in CHANNELS" :key="ch.id" class="prefs-cell ch-col" :title="ch.description">
{{ ch.label }}
</div>
</div>
<div v-for="e in events" :key="e.id" class="prefs-row">
<div class="prefs-cell event-col">{{ e.label }}</div>
<div v-for="ch in channels" :key="ch.id" class="prefs-cell ch-col">
<v-checkbox v-model="prefs[e.id][ch.id]" density="compact" hide-details color="primary" />
<div v-for="e in EVENTS" :key="e.id" class="prefs-row" :data-event="e.id">
<div class="prefs-cell event-col">
<div>{{ e.label }}</div>
<div v-if="e.description" class="event-desc">{{ e.description }}</div>
</div>
<div v-for="ch in CHANNELS" :key="ch.id" class="prefs-cell ch-col">
<v-checkbox
v-if="prefs[e.id]"
v-model="prefs[e.id]![ch.id]"
density="compact"
hide-details
color="primary"
:data-testid="`pref-${e.id}-${ch.id}`"
/>
</div>
</div>
</div>
</v-card>
<v-card variant="outlined" class="pa-4">
<v-card variant="outlined" class="pa-4 mb-4">
<h3 class="text-subtitle-2 mb-3">Звуковые алерты</h3>
<v-switch
v-model="soundEnabled"
color="primary"
hide-details
label="Воспроизводить звук при новом лиде в приложении"
data-testid="sound-enabled-switch"
label="Воспроизводить звук при новом in-app уведомлении"
/>
</v-card>
<div class="actions-row">
<v-btn color="primary" :loading="saving" :disabled="!canSave" data-testid="save-btn" @click="save">
Сохранить
</v-btn>
<v-btn variant="outlined" :disabled="!dirty || saving" data-testid="reset-btn" @click="reset">
Отменить
</v-btn>
</div>
</div>
</template>
@@ -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;
}
</style>
+1
View File
@@ -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']);
});
});
@@ -0,0 +1,150 @@
<?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');
});
+160
View File
@@ -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);
});
});
+2 -2
View File
@@ -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),
);
});
+1
View File
@@ -804,3 +804,4 @@ unshift
партиальный
консистентности
inapp
prefs