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
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
});
});