Files
portal/app/tests/Frontend/ReminderDialog.spec.ts
T
Дмитрий c5c0e76950 test(coverage): close F-COV-01/02/03 — ReminderDialog + AdminLayout + api/admin
Closes Audit #2+#3 P2 carryforward triplet (low-coverage files at risk
of silent regression).

Coverage results (Vitest --coverage --coverage.include per-file):

| File | Stmts before | Stmts now | Δ |
|---|---|---|---|
| ReminderDialog.vue | 0% | 95.38% | +95 pp |
| AdminLayout.vue | 9.09% | 95.45% | +86 pp |
| api/admin.ts | 11.53% | 100% | +88 pp |

Branches/Funcs deltas (subagent reports):
- ReminderDialog: Branch 0→97.56%, Funcs 0→85.71%, Lines 0→96.61%
- AdminLayout: Branch 0→90%, Funcs 0→90%, Lines 9.09→94.73%
- api/admin: Branch 0→100%, Funcs 27.27→100%, Lines 11.53→100%

Approach: TDD via @vue/test-utils + Vuetify global plugin + vi.mock for
store/api. Three parallel subagents (general-purpose), each focused on
single target — no production code changes, only test infrastructure.

Coverage areas:
- ReminderDialog (19 specs): rendering, watch(dialogOpen) populate/reset,
  submit create-mode happy + 3 errors, submit edit-mode happy + 1 error,
  cancel, common validation paths
- AdminLayout (16 specs): brand block, 5 nav items, count badges (142/3),
  breadcrumb per route (5 cases + fallback), userInitials computed (4
  cases incl. fallback), userShortName (4 cases), handleLogout call-order,
  active state, aria-label
- api/admin (18 specs): 11 exported functions × happy-path; 2 encodeURI
  edge cases; 4 ensureCsrfCookie call-order verifications via
  invocationCallOrder; 2 error-propagation tests

Verification (full sweep after merge):
- Vitest: 91 files / 736 passed / 3 skipped / 0 failed (+3 files, +53 specs
  from Audit #3 baseline 88/683/3sk)
- Pest --parallel: 742/739/3sk/0 (identical to baseline, 0 regressions)
- Vite build: 2.03s
- vue-tsc: 0 errors
- ESLint: 0 errors

Plan: docs/superpowers/plans/2026-05-14-audit3-deferred-fixes.md Task 3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 08:37:26 +03:00

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