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

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

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

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

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

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

161 lines
6.6 KiB
TypeScript

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);
});
});