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:
@@ -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()),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -804,3 +804,4 @@ unshift
|
||||
партиальный
|
||||
консистентности
|
||||
inapp
|
||||
prefs
|
||||
|
||||
Reference in New Issue
Block a user