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