Files
portal/app/tests/Frontend/ReminderDialog.spec.ts
T

341 lines
15 KiB
TypeScript
Raw Normal View History

import { describe, it, expect, beforeEach, vi } from 'vitest';
import { mount, flushPromises } from '@vue/test-utils';
import { createPinia, setActivePinia } from 'pinia';
import { createVuetify } from 'vuetify';
// Мокаем API-слой reminders, чтобы реальный Pinia-store работал поверх spy'ев.
const createReminderMock = vi.fn();
const updateReminderMock = vi.fn();
vi.mock('../../resources/js/api/reminders', () => ({
listReminders: vi.fn(),
createReminder: (...args: unknown[]) => createReminderMock(...args),
updateReminder: (...args: unknown[]) => updateReminderMock(...args),
completeReminder: vi.fn(),
deleteReminder: vi.fn(),
}));
vi.mock('../../resources/js/api/client', () => ({
apiClient: {},
ensureCsrfCookie: vi.fn(),
extractValidationErrors: vi.fn(() => null),
extractErrorMessage: vi.fn(() => 'Произошла ошибка.'),
extractRateLimitRetry: vi.fn(() => null),
}));
import ReminderDialog from '../../resources/js/components/reminders/ReminderDialog.vue';
import type { ApiReminder } from '../../resources/js/api/reminders';
const mockReminder = (overrides: Partial<ApiReminder> = {}): ApiReminder => ({
id: 42,
deal_id: 7,
text: 'Перезвонить клиенту',
remind_at: '2026-06-01T10:30:00.000Z',
completed_at: null,
is_sent: false,
sent_at: null,
created_at: '2026-05-01T09:00:00.000Z',
created_by: 1,
assignee_id: null,
creator_name: 'Иван Петров',
...overrides,
});
// VDialog в JSDOM teleport'ится — стаб делает <slot/> рендеримым inline.
const factory = (props: {
modelValue: boolean;
dealId?: number | null;
reminder?: ApiReminder | null;
}) =>
mount(ReminderDialog, {
props,
global: {
plugins: [createVuetify()],
stubs: {
VDialog: {
template: '<div class="dialog-stub" v-if="modelValue"><slot /></div>',
props: ['modelValue'],
},
},
},
});
beforeEach(() => {
setActivePinia(createPinia());
vi.clearAllMocks();
});
describe('ReminderDialog.vue', () => {
describe('rendering', () => {
it('не рендерит content при modelValue=false', () => {
const wrapper = factory({ modelValue: false, dealId: 7 });
expect(wrapper.find('.dialog-stub').exists()).toBe(false);
});
it('рендерит title "Новое напоминание" в create режиме', async () => {
const wrapper = factory({ modelValue: true, dealId: 7, reminder: null });
await flushPromises();
expect(wrapper.text()).toContain('Новое напоминание');
expect(wrapper.text()).not.toContain('Редактировать напоминание');
});
it('рендерит title "Редактировать напоминание" в edit режиме', async () => {
const wrapper = factory({ modelValue: true, reminder: mockReminder() });
await flushPromises();
expect(wrapper.text()).toContain('Редактировать напоминание');
});
it('кнопка submit подписана "Создать" в create режиме', async () => {
const wrapper = factory({ modelValue: true, dealId: 7 });
await flushPromises();
const submitBtn = wrapper.find('[data-testid="reminder-submit"]');
expect(submitBtn.exists()).toBe(true);
expect(submitBtn.text()).toContain('Создать');
});
it('кнопка submit подписана "Сохранить" в edit режиме', async () => {
const wrapper = factory({ modelValue: true, reminder: mockReminder() });
await flushPromises();
const submitBtn = wrapper.find('[data-testid="reminder-submit"]');
expect(submitBtn.text()).toContain('Сохранить');
});
it('рендерит обязательные поля textarea + datetime-input', async () => {
const wrapper = factory({ modelValue: true, dealId: 7 });
await flushPromises();
expect(wrapper.find('[data-testid="reminder-text"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="reminder-at"]').exists()).toBe(true);
});
});
describe('watch(dialogOpen) — populate / reset', () => {
it('open + edit → text и remindAt берутся из props.reminder', async () => {
const reminder = mockReminder({
text: 'Тестовое описание',
remind_at: '2026-06-15T14:25:00.000Z',
});
const wrapper = factory({ modelValue: true, reminder });
await flushPromises();
const vm = wrapper.vm as unknown as { text: string; remindAt: string };
expect(vm.text).toBe('Тестовое описание');
// remind_at был ISO; превратился в YYYY-MM-DDThh:mm local-format.
expect(vm.remindAt).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/);
});
it('open + create → text сброшен, remindAt = default (1 час от now)', async () => {
const wrapper = factory({ modelValue: true, dealId: 7, reminder: null });
await flushPromises();
const vm = wrapper.vm as unknown as { text: string; remindAt: string };
expect(vm.text).toBe('');
expect(vm.remindAt).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/);
// Should be ~1 час впереди (allow tolerance ±5 минут).
const parsed = new Date(vm.remindAt);
const now = Date.now();
const diffMin = (parsed.getTime() - now) / 60_000;
expect(diffMin).toBeGreaterThan(55);
expect(diffMin).toBeLessThan(65);
});
it('перезакрытие сбрасывает error и populates заново', async () => {
const wrapper = factory({ modelValue: false, dealId: 7 });
// Open via v-model.
await wrapper.setProps({ modelValue: true });
await flushPromises();
const vm = wrapper.vm as unknown as { text: string; remindAt: string; error: string | null };
expect(vm.error).toBeNull();
expect(vm.text).toBe('');
expect(vm.remindAt).not.toBe('');
});
it('reminder с remind_at=null → default используется', async () => {
const wrapper = factory({
modelValue: true,
reminder: mockReminder({ remind_at: null, text: null }),
});
await flushPromises();
const vm = wrapper.vm as unknown as { text: string; remindAt: string };
expect(vm.text).toBe('');
expect(vm.remindAt).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/);
});
});
describe('submit — create mode', () => {
it('успешный create → store.create вызывается + emit saved + закрытие', async () => {
const created = mockReminder({ id: 100 });
createReminderMock.mockResolvedValue(created);
const wrapper = factory({ modelValue: true, dealId: 7, reminder: null });
const vm = wrapper.vm as unknown as { text: string; remindAt: string };
vm.text = ' Перезвонить ';
vm.remindAt = '2026-06-01T12:30';
await flushPromises();
await wrapper.find('[data-testid="reminder-submit"]').trigger('click');
await flushPromises();
expect(createReminderMock).toHaveBeenCalledTimes(1);
const payload = createReminderMock.mock.calls[0]![0] as {
deal_id: number;
text: string | null;
remind_at: string;
};
expect(payload.deal_id).toBe(7);
expect(payload.text).toBe('Перезвонить'); // trimmed
expect(payload.remind_at).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
const saved = wrapper.emitted('saved');
expect(saved).toBeDefined();
expect((saved![0] as unknown[])[0]).toEqual(created);
// dialogOpen → false через update:modelValue.
const closeEmits = wrapper.emitted('update:modelValue');
expect(closeEmits).toBeDefined();
expect(closeEmits!.some((e) => e[0] === false)).toBe(true);
});
it('text пустой после trim → передаёт null', async () => {
createReminderMock.mockResolvedValue(mockReminder({ id: 101 }));
const wrapper = factory({ modelValue: true, dealId: 7, reminder: null });
const vm = wrapper.vm as unknown as { text: string; remindAt: string };
vm.text = ' ';
vm.remindAt = '2026-06-01T12:30';
await flushPromises();
await wrapper.find('[data-testid="reminder-submit"]').trigger('click');
await flushPromises();
const payload = createReminderMock.mock.calls[0]![0] as { text: string | null };
expect(payload.text).toBeNull();
});
it('create без dealId → error "Не указан deal_id"', async () => {
const wrapper = factory({ modelValue: true, dealId: null, reminder: null });
const vm = wrapper.vm as unknown as { text: string; remindAt: string };
vm.remindAt = '2026-06-01T12:30';
await flushPromises();
await wrapper.find('[data-testid="reminder-submit"]').trigger('click');
await flushPromises();
expect(createReminderMock).not.toHaveBeenCalled();
expect(wrapper.text()).toContain('Не указан deal_id для создания.');
expect(wrapper.emitted('saved')).toBeUndefined();
});
it('create API rejected → store returns null → error message', async () => {
createReminderMock.mockRejectedValue(new Error('Network down'));
const wrapper = factory({ modelValue: true, dealId: 7, reminder: null });
const vm = wrapper.vm as unknown as { text: string; remindAt: string };
vm.remindAt = '2026-06-01T12:30';
await flushPromises();
await wrapper.find('[data-testid="reminder-submit"]').trigger('click');
await flushPromises();
expect(wrapper.text()).toContain('Не удалось сохранить. Попробуйте позже.');
expect(wrapper.emitted('saved')).toBeUndefined();
});
});
describe('submit — edit mode', () => {
it('успешный update → store.update вызывается с reminder.id + payload + emit saved', async () => {
const original = mockReminder({ id: 55, text: 'Старое описание' });
const updated = mockReminder({ id: 55, text: 'Новое описание' });
updateReminderMock.mockResolvedValue(updated);
const wrapper = factory({ modelValue: true, reminder: original });
await flushPromises();
const vm = wrapper.vm as unknown as { text: string; remindAt: string };
vm.text = 'Новое описание';
vm.remindAt = '2026-07-10T09:00';
await flushPromises();
await wrapper.find('[data-testid="reminder-submit"]').trigger('click');
await flushPromises();
expect(updateReminderMock).toHaveBeenCalledTimes(1);
const [id, payload] = updateReminderMock.mock.calls[0] as [
number,
{ text: string | null; remind_at: string },
];
expect(id).toBe(55);
expect(payload.text).toBe('Новое описание');
expect(payload.remind_at).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
expect(createReminderMock).not.toHaveBeenCalled();
const saved = wrapper.emitted('saved');
expect(saved).toBeDefined();
expect((saved![0] as unknown[])[0]).toEqual(updated);
});
it('update API rejected → error + не emit saved', async () => {
updateReminderMock.mockRejectedValue(new Error('500'));
const wrapper = factory({ modelValue: true, reminder: mockReminder() });
await flushPromises();
await wrapper.find('[data-testid="reminder-submit"]').trigger('click');
await flushPromises();
expect(wrapper.text()).toContain('Не удалось сохранить. Попробуйте позже.');
expect(wrapper.emitted('saved')).toBeUndefined();
});
});
describe('submit — общие пути', () => {
it('пустой remindAt → error "Укажите дату и время"', async () => {
const wrapper = factory({ modelValue: true, dealId: 7, reminder: null });
const vm = wrapper.vm as unknown as { remindAt: string };
vm.remindAt = '';
await flushPromises();
await wrapper.find('[data-testid="reminder-submit"]').trigger('click');
await flushPromises();
expect(createReminderMock).not.toHaveBeenCalled();
expect(updateReminderMock).not.toHaveBeenCalled();
expect(wrapper.text()).toContain('Укажите дату и время напоминания.');
});
it('submit вызывается также через клик по кнопке (v-form @submit интегрирован)', async () => {
createReminderMock.mockResolvedValue(mockReminder({ id: 200 }));
const wrapper = factory({ modelValue: true, dealId: 7, reminder: null });
const vm = wrapper.vm as unknown as { remindAt: string };
vm.remindAt = '2026-06-01T12:30';
await flushPromises();
await wrapper.find('[data-testid="reminder-submit"]').trigger('click');
await flushPromises();
expect(createReminderMock).toHaveBeenCalled();
});
});
describe('cancel', () => {
it('cancel закрывает dialog (emit update:modelValue=false)', async () => {
const wrapper = factory({ modelValue: true, dealId: 7 });
await flushPromises();
// Cancel btn — vanilla v-btn, ищем по тексту "Отмена".
const buttons = wrapper.findAll('button');
const cancelBtn = buttons.find((b) => b.text().includes('Отмена'));
expect(cancelBtn).toBeDefined();
await cancelBtn!.trigger('click');
await flushPromises();
const closeEmits = wrapper.emitted('update:modelValue');
expect(closeEmits).toBeDefined();
expect(closeEmits!.some((e) => e[0] === false)).toBe(true);
// Не должен звать API.
expect(createReminderMock).not.toHaveBeenCalled();
expect(updateReminderMock).not.toHaveBeenCalled();
});
});
});