Files
portal/app/tests/Frontend/reminders-store.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

179 lines
6.3 KiB
TypeScript

import { describe, it, expect, beforeEach, vi } from 'vitest';
import { createPinia, setActivePinia } from 'pinia';
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 { useRemindersStore } from '../../resources/js/stores/reminders';
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(2026, 4, 9, 14 + id, 0).toISOString(),
completed_at: null,
is_sent: false,
sent_at: null,
created_at: new Date(2026, 4, 9, 12, 0).toISOString(),
created_by: 1,
assignee_id: null,
creator_name: 'Иван Петров',
...overrides,
});
describe('useRemindersStore', () => {
beforeEach(() => {
setActivePinia(createPinia());
vi.clearAllMocks();
});
it('initial state', () => {
const store = useRemindersStore();
expect(store.items).toEqual([]);
expect(store.counts).toEqual({ active: 0, today: 0, upcoming: 0, overdue: 0 });
});
it('load() заполняет items + counts', async () => {
vi.mocked(remindersApi.listReminders).mockResolvedValue({
items: [mockReminder(1), mockReminder(2)],
counts: { active: 2, today: 1, upcoming: 1, overdue: 0 },
});
const store = useRemindersStore();
await store.load();
expect(store.items).toHaveLength(2);
expect(store.counts.active).toBe(2);
});
it('load() при reject ставит fetchError', async () => {
vi.mocked(remindersApi.listReminders).mockRejectedValue(new Error('500'));
const store = useRemindersStore();
await store.load();
expect(store.fetchError).toBe(true);
});
it('refreshCounts() обновляет только counts', async () => {
vi.mocked(remindersApi.listReminders).mockResolvedValue({
items: [mockReminder(1)],
counts: { active: 5, today: 2, upcoming: 1, overdue: 2 },
});
const store = useRemindersStore();
store.items = [mockReminder(99)]; // pre-populate
await store.refreshCounts();
expect(store.counts.active).toBe(5);
expect(store.items).toHaveLength(1); // не тронуто
});
it('create() добавляет в начало + increment active', async () => {
const newR = mockReminder(99);
vi.mocked(remindersApi.createReminder).mockResolvedValue(newR);
const store = useRemindersStore();
store.counts.active = 3;
await store.create({ deal_id: 100, remind_at: new Date().toISOString() });
expect(store.items[0]!.id).toBe(99);
expect(store.counts.active).toBe(4);
});
it('create() при reject возвращает null', async () => {
vi.mocked(remindersApi.createReminder).mockRejectedValue(new Error('500'));
const store = useRemindersStore();
const result = await store.create({ deal_id: 100, remind_at: new Date().toISOString() });
expect(result).toBeNull();
});
it('complete() optimistic + decrement active', async () => {
vi.mocked(remindersApi.listReminders).mockResolvedValue({
items: [mockReminder(1)],
counts: { active: 1, today: 1, upcoming: 0, overdue: 0 },
});
vi.mocked(remindersApi.completeReminder).mockResolvedValue(
mockReminder(1, { completed_at: '2026-05-09T15:00:00Z' }),
);
const store = useRemindersStore();
await store.load();
await store.complete(1);
expect(store.counts.active).toBe(0);
// currentFilter='active' → удалили из items.
expect(store.items).toHaveLength(0);
});
it('complete() при reject — revert', async () => {
vi.mocked(remindersApi.listReminders).mockResolvedValue({
items: [mockReminder(1)],
counts: { active: 1, today: 1, upcoming: 0, overdue: 0 },
});
vi.mocked(remindersApi.completeReminder).mockRejectedValue(new Error('500'));
const store = useRemindersStore();
await store.load();
await store.complete(1);
expect(store.counts.active).toBe(1);
expect(store.items[0]!.completed_at).toBeNull();
});
it('remove() optimistic убирает + decrement', async () => {
vi.mocked(remindersApi.listReminders).mockResolvedValue({
items: [mockReminder(1), mockReminder(2)],
counts: { active: 2, today: 1, upcoming: 1, overdue: 0 },
});
vi.mocked(remindersApi.deleteReminder).mockResolvedValue();
const store = useRemindersStore();
await store.load();
await store.remove(1);
expect(store.items).toHaveLength(1);
expect(store.counts.active).toBe(1);
});
it('remove() при reject — revert', async () => {
vi.mocked(remindersApi.listReminders).mockResolvedValue({
items: [mockReminder(1), mockReminder(2)],
counts: { active: 2, today: 1, upcoming: 1, overdue: 0 },
});
vi.mocked(remindersApi.deleteReminder).mockRejectedValue(new Error('500'));
const store = useRemindersStore();
await store.load();
await store.remove(1);
expect(store.items).toHaveLength(2);
expect(store.counts.active).toBe(2);
});
it('reset() очищает', async () => {
vi.mocked(remindersApi.listReminders).mockResolvedValue({
items: [mockReminder(1)],
counts: { active: 1, today: 1, upcoming: 0, overdue: 0 },
});
const store = useRemindersStore();
await store.load();
store.reset();
expect(store.items).toEqual([]);
expect(store.counts).toEqual({ active: 0, today: 0, upcoming: 0, overdue: 0 });
});
});