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