Files
portal/app/tests/Frontend/RemindersView.spec.ts
T
Дмитрий dc1457a008 phase2(reminders-frontend): RemindersView + DealDetailDrawer + nav-badge
P0 этап 5 — frontend для reminders (после backend-этапа 4).
Пользователь может создавать/просматривать/завершать/удалять напоминания
из UI с inline-create в DealDetailDrawer.

Frontend:
- api/reminders.ts: типизированные helpers для 5 endpoints + ensureCsrfCookie
  для mutating. Types ReminderFilter/ApiReminder/ReminderCounts.
- stores/reminders.ts: Pinia с items/counts/currentFilter +
  load/refreshCounts/create/update/complete/remove. Optimistic для
  complete/remove с revert на reject.
- components/reminders/ReminderDialog.vue: dual-mode (create/edit) modal
  с native datetime-local input. Props dealId?/reminder? (edit),
  ISO-конверсия при submit.
- views/RemindersView.vue: page-head с stats (active/overdue) + reload-btn;
  4 tabs (today/upcoming/overdue/completed) с counts на бейджах
  (overdue=error color); v-list с complete-btn / dropdown
  Изменить/Удалить с confirm-dialog; empty-state.
- router: /reminders маршрут (lazy).
- AppLayout: nav-badge «Напоминания» биндит count из store
  (replace static «12»); скрыт при count=0; polling 60 сек для
  refreshCounts.
- DealDetailDrawer: секция «Напоминания» (только при tenantId+deal):
  inline + create-btn / список / complete / встроенный ReminderDialog.

Vitest +20 (369/369 за 21.20 сек):
- reminders-store 11: initial / load+reject / refreshCounts /
  create+reject / complete optimistic+revert / remove+reject / reset.
- RemindersView 7: mount / 4 tabs / counts / empty-state /
  список / reload-btn / filter=today default.
- AppLayout +2: бейдж скрыт при count=0 / показывает count при >0.

Pest 347/347 (без изменений — backend нетронут).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 12:41:41 +03:00

130 lines
4.7 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 } from '@vue/test-utils';
import { createPinia, setActivePinia } from 'pinia';
import { createVuetify } from 'vuetify';
import { createMemoryHistory, createRouter } from 'vue-router';
vi.mock('../../resources/js/api/reminders', () => ({
listReminders: vi.fn(),
createReminder: vi.fn(),
updateReminder: vi.fn(),
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 * as remindersApi from '../../resources/js/api/reminders';
import RemindersView from '../../resources/js/views/RemindersView.vue';
import type { ApiReminder } from '../../resources/js/api/reminders';
const mockReminder = (id: number, overrides: Partial<ApiReminder> = {}): ApiReminder => ({
id,
deal_id: 100 + id,
text: `Перезвонить #${id}`,
remind_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
completed_at: null,
is_sent: false,
sent_at: null,
created_at: new Date().toISOString(),
created_by: 1,
assignee_id: null,
creator_name: 'Иван Петров',
...overrides,
});
const factory = async (
apiResp: { items: ApiReminder[]; counts: { active: number; today: number; upcoming: number; overdue: number } } = {
items: [],
counts: { active: 0, today: 0, upcoming: 0, overdue: 0 },
},
) => {
setActivePinia(createPinia());
vi.mocked(remindersApi.listReminders).mockResolvedValue(apiResp);
const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/reminders', component: RemindersView },
{ path: '/deals', component: { template: '<div>deals</div>' } },
],
});
await router.push('/reminders');
await router.isReady();
const wrapper = mount(RemindersView, {
global: {
plugins: [createVuetify(), router],
stubs: { ReminderDialog: true },
},
});
await new Promise((r) => setTimeout(r, 0));
await wrapper.vm.$nextTick();
return wrapper;
};
describe('RemindersView.vue', () => {
beforeEach(() => vi.clearAllMocks());
it('монтируется и содержит заголовок «Напоминания»', async () => {
const wrapper = await factory();
expect(wrapper.find('.page-title').text()).toBe('Напоминания');
});
it('содержит 4 tabs (Сегодня/Предстоит/Просрочено/Выполнено)', async () => {
const wrapper = await factory();
const text = wrapper.text();
['Сегодня', 'Предстоит', 'Просрочено', 'Выполнено'].forEach((label) => {
expect(text).toContain(label);
});
});
it('показывает counts на табах', async () => {
const wrapper = await factory({
items: [],
counts: { active: 5, today: 2, upcoming: 2, overdue: 1 },
});
const todayTab = wrapper.find('[data-testid="tab-today"]');
expect(todayTab.text()).toContain('2');
const overdueTab = wrapper.find('[data-testid="tab-overdue"]');
expect(overdueTab.text()).toContain('1');
});
it('при пустом списке показывает empty-state', async () => {
const wrapper = await factory();
expect(wrapper.find('[data-testid="reminders-empty"]').exists()).toBe(true);
});
it('рендерит список напоминаний', async () => {
const wrapper = await factory({
items: [mockReminder(1), mockReminder(2)],
counts: { active: 2, today: 2, upcoming: 0, overdue: 0 },
});
const items = wrapper.findAll('[data-testid="reminder-item"]');
expect(items).toHaveLength(2);
});
it('reload-btn вызывает listReminders повторно', async () => {
const wrapper = await factory();
const initialCalls = vi.mocked(remindersApi.listReminders).mock.calls.length;
await wrapper.find('[data-testid="reload-btn"]').trigger('click');
await wrapper.vm.$nextTick();
expect(vi.mocked(remindersApi.listReminders).mock.calls.length).toBeGreaterThan(initialCalls);
});
it('listReminders вызывается с filter=today по умолчанию', async () => {
await factory();
expect(remindersApi.listReminders).toHaveBeenCalledWith(expect.objectContaining({ filter: 'today' }));
});
});