341 lines
15 KiB
TypeScript
341 lines
15 KiB
TypeScript
|
|
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();
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
});
|