f55b91cfa4
Закрывает архитектурное расхождение 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>
161 lines
6.6 KiB
TypeScript
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);
|
|
});
|
|
});
|