7ac9af7c79
Правило продукта: ограничений по количеству проектов нет, лимит только по балансу и заказанным лидам. Убран гейт tenants.limits.max_projects в ProjectService::create и показ лимита проектов на дашборде. Поле limits оставлено как резерв; max_users и api_rps в коде не используются. Заодно фикс типа в EditProjectDialog.spec: sampleProject типизирован настоящим Project, source_locked больше не краснит vue-tsc. Тесты: ProjectsStore 13/13, DashboardSummary 11/11, DashboardView 8/8, EditProjectDialog 7/7; vue-tsc чисто; pint чисто; vite build ок. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
142 lines
6.3 KiB
TypeScript
142 lines
6.3 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { mount, flushPromises } from '@vue/test-utils';
|
|
import { createVuetify } from 'vuetify';
|
|
import { createPinia, setActivePinia } from 'pinia';
|
|
import DashboardView from '../../resources/js/views/DashboardView.vue';
|
|
import type { DashboardSummary } from '../../resources/js/api/dashboard';
|
|
import { useAuthStore } from '../../resources/js/stores/auth';
|
|
import type { AuthUser } from '../../resources/js/api/auth';
|
|
|
|
vi.mock('../../resources/js/api/dashboard', () => ({
|
|
getDashboardSummary: vi.fn(),
|
|
}));
|
|
|
|
const mockUser: AuthUser = {
|
|
id: 1,
|
|
email: 'user@liderra.ru',
|
|
first_name: 'Иван',
|
|
last_name: 'Петров',
|
|
tenant_id: 1,
|
|
totp_enabled: false,
|
|
last_login_at: null,
|
|
};
|
|
|
|
const dashboardApi = await import('../../resources/js/api/dashboard');
|
|
|
|
function makeSummary(overrides: Partial<DashboardSummary> = {}): DashboardSummary {
|
|
return {
|
|
range: '7d',
|
|
leads_received: { value: 247, delta_pct: 12.3, delta_dir: 'up' },
|
|
conversion: { value: 18.4, delta_pp: 2.1, delta_dir: 'up' },
|
|
active_projects: { active: 8 },
|
|
balance: { amount_rub: '14250.00', runway_days: 4, runway_leads: 285 },
|
|
activity: { points: [3, 5, 2, 8, 6, 9, 4], labels: ['сб', 'вс', 'пн', 'вт', 'ср', 'чт', 'сегодня'], max: 10 },
|
|
funnel: { new: 18, won: 45 },
|
|
avg_lead_cost_rub: null,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
const mountView = () => {
|
|
setActivePinia(createPinia());
|
|
useAuthStore().user = mockUser;
|
|
return mount(DashboardView, { global: { plugins: [createVuetify()] } });
|
|
};
|
|
|
|
beforeEach(() => vi.clearAllMocks());
|
|
|
|
describe('DashboardView.vue ↔ /api/dashboard/summary', () => {
|
|
it('getDashboardSummary вызывается на mount', async () => {
|
|
vi.mocked(dashboardApi.getDashboardSummary).mockResolvedValueOnce(makeSummary());
|
|
mountView();
|
|
await flushPromises();
|
|
expect(dashboardApi.getDashboardSummary).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('успех — KPI и баланс из API видны', async () => {
|
|
vi.mocked(dashboardApi.getDashboardSummary).mockResolvedValueOnce(
|
|
makeSummary({ balance: { amount_rub: '99000.00', runway_days: 9, runway_leads: 500 } }),
|
|
);
|
|
const wrapper = mountView();
|
|
await flushPromises();
|
|
const text = wrapper.text();
|
|
expect(text).toContain('Получено лидов');
|
|
expect(text).toContain('Конверсия в оплату');
|
|
expect(text).toContain('Активные проекты');
|
|
expect(text).toContain('Баланс');
|
|
expect(text).toContain('99 000');
|
|
expect(wrapper.text()).toContain('12.3%');
|
|
});
|
|
|
|
it('ошибка API — fallback на mock, view не падает', async () => {
|
|
vi.mocked(dashboardApi.getDashboardSummary).mockRejectedValueOnce(new Error('500'));
|
|
const wrapper = mountView();
|
|
await flushPromises();
|
|
expect(wrapper.text()).toContain('Получено лидов');
|
|
expect(wrapper.find('.runway-fill').exists()).toBe(true);
|
|
expect(wrapper.find('[data-testid="dashboard-fetch-error"]').exists()).toBe(true);
|
|
});
|
|
|
|
it('смена range перезапрашивает summary', async () => {
|
|
vi.mocked(dashboardApi.getDashboardSummary).mockResolvedValue(makeSummary());
|
|
const wrapper = mountView();
|
|
await flushPromises();
|
|
expect(dashboardApi.getDashboardSummary).toHaveBeenCalledTimes(1);
|
|
(wrapper.vm as unknown as { range: string }).range = '30d';
|
|
await flushPromises();
|
|
expect(dashboardApi.getDashboardSummary).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
// F3 (17.06.2026): runway_days в тексте — реальное число, не срезанное до
|
|
// RUNWAY_MAX (7 сегментов полосы). Иначе дашборд расходится с биллингом.
|
|
it('показывает реальный runway_days (не срезанный до 7 сегментов полосы)', async () => {
|
|
vi.mocked(dashboardApi.getDashboardSummary).mockResolvedValueOnce(
|
|
makeSummary({ balance: { amount_rub: '14250.00', runway_days: 28, runway_leads: 28 } }),
|
|
);
|
|
const wrapper = mountView();
|
|
await flushPromises();
|
|
expect(wrapper.text()).toContain('28 дней');
|
|
});
|
|
});
|
|
|
|
describe('DashboardView — косяк 07: онбординг новичка', () => {
|
|
beforeEach(() => localStorage.clear());
|
|
|
|
it('не показывает онбординг, когда есть активные проекты', async () => {
|
|
vi.mocked(dashboardApi.getDashboardSummary).mockResolvedValueOnce(makeSummary());
|
|
const w = mountView();
|
|
await flushPromises();
|
|
expect(w.find('[data-testid="dashboard-onboarding"]').exists()).toBe(false);
|
|
});
|
|
|
|
it('показывает онбординг новичку без проектов и лидов', async () => {
|
|
vi.mocked(dashboardApi.getDashboardSummary).mockResolvedValueOnce(
|
|
makeSummary({
|
|
active_projects: { active: 0 },
|
|
leads_received: { value: 0, delta_pct: 0, delta_dir: 'neutral' },
|
|
}),
|
|
);
|
|
const w = mountView();
|
|
await flushPromises();
|
|
const card = w.find('[data-testid="dashboard-onboarding"]');
|
|
expect(card.exists()).toBe(true);
|
|
expect(card.text()).toContain('Создать первый проект');
|
|
});
|
|
|
|
it('скрывает онбординг после «скрыть» и помнит это в localStorage', async () => {
|
|
vi.mocked(dashboardApi.getDashboardSummary).mockResolvedValueOnce(
|
|
makeSummary({
|
|
active_projects: { active: 0 },
|
|
leads_received: { value: 0, delta_pct: 0, delta_dir: 'neutral' },
|
|
}),
|
|
);
|
|
const w = mountView();
|
|
await flushPromises();
|
|
expect(w.find('[data-testid="dashboard-onboarding"]').exists()).toBe(true);
|
|
await w.find('[data-testid="onboarding-dismiss"]').trigger('click');
|
|
await w.vm.$nextTick();
|
|
expect(w.find('[data-testid="dashboard-onboarding"]').exists()).toBe(false);
|
|
expect(localStorage.getItem('dashboard.onboardingDismissed')).toBe('1');
|
|
});
|
|
});
|