Files
portal/app/tests/Frontend/AdminPricingTiersView.spec.ts
T
Дмитрий b35d3888bb fix(admin): клиентский диапазон ступеней в тарифной сетке + живой предпросмотр в редакторе
Админка показывала сырую ширину ступени (leads_in_tier: 250/500/1000...),
а кабинет клиента — накопительный диапазон (1-250, 251-750...). Одни и те же
данные, но выглядят как разные тарифы; в форме редактирования подпись
'Лидов в ступени' читается как потолок, а система понимает её как ширину -
ловушка при смене цен.

Логика денег не менялась. Изменён только показ в админке:
- колонка 'Диапазон у клиента' в активной сетке;
- живой предпросмотр диапазона в форме редактирования;
- расчёт диапазона вынесен в общий utils/pricingTiers.ts, чтобы админка
  и кабинет больше не расходились.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 15:10:08 +03:00

270 lines
14 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, afterEach, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
import AdminPricingTiersView from '../../resources/js/views/admin/AdminPricingTiersView.vue';
/**
* Создаёт объект, который проходит `axios.isAxiosError()` (проверяет флаг `isAxiosError: true`),
* с нужным `response.data.message`.
*/
function makeAxiosError(message: string, status = 422): unknown {
return Object.assign(new Error(message), {
isAxiosError: true,
response: { status, data: { message } },
});
}
vi.mock('../../resources/js/api/admin', async (importOriginal) => {
const orig = await importOriginal<typeof import('../../resources/js/api/admin')>();
return {
...orig,
getPricingTiers: vi.fn(),
createPricingTiers: vi.fn(),
deleteScheduledPricingTier: vi.fn(),
};
});
const adminApi = await import('../../resources/js/api/admin');
// Auto-импорт компонентов/директив Vuetify подхватывает vite-plugin-vuetify
// из vitest.config.ts (см. AdminBillingView.spec.ts).
const vuetify = createVuetify();
const mockTiers = [
{ tier_no: 1, leads_in_tier: 100, price_per_lead_kopecks: 50000, effective_from: '1970-01-01' },
{ tier_no: 2, leads_in_tier: 200, price_per_lead_kopecks: 45000, effective_from: '1970-01-01' },
{ tier_no: 3, leads_in_tier: 400, price_per_lead_kopecks: 40000, effective_from: '1970-01-01' },
{ tier_no: 4, leads_in_tier: 800, price_per_lead_kopecks: 35000, effective_from: '1970-01-01' },
{ tier_no: 5, leads_in_tier: 1500, price_per_lead_kopecks: 30000, effective_from: '1970-01-01' },
{ tier_no: 6, leads_in_tier: 3000, price_per_lead_kopecks: 27000, effective_from: '1970-01-01' },
{ tier_no: 7, leads_in_tier: null, price_per_lead_kopecks: 25000, effective_from: '1970-01-01' },
];
describe('AdminPricingTiersView', () => {
beforeEach(() => {
vi.mocked(adminApi.getPricingTiers).mockResolvedValue({ active: mockTiers, scheduled: {} });
vi.mocked(adminApi.createPricingTiers).mockResolvedValue({ effective_from: '2026-06-01' });
vi.mocked(adminApi.deleteScheduledPricingTier).mockResolvedValue(undefined);
});
it('renders 7 tier rows from /api/admin/pricing-tiers', async () => {
const wrapper = mount(AdminPricingTiersView, { global: { plugins: [vuetify] } });
await new Promise((r) => setTimeout(r, 50));
// fmtRub форматирует как «500 ₽» (целое, без хвостовых нулей), не «500.00».
expect(wrapper.text()).toContain('500 ₽');
expect(wrapper.text()).toContain('250 ₽');
});
it('shows "все свыше" for tier 7 with leads_in_tier=null', async () => {
const wrapper = mount(AdminPricingTiersView, { global: { plugins: [vuetify] } });
await new Promise((r) => setTimeout(r, 50));
expect(wrapper.text()).toContain('все свыше');
});
it('opens editor dialog on button click', async () => {
const wrapper = mount(AdminPricingTiersView, { global: { plugins: [vuetify] } });
await new Promise((r) => setTimeout(r, 50));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect((wrapper.vm as any).editorOpen).toBe(false);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(wrapper.vm as any).editorOpen = true;
await wrapper.vm.$nextTick();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect((wrapper.vm as any).editorOpen).toBe(true);
});
it('submits POST with editor payload', async () => {
const wrapper = mount(AdminPricingTiersView, { global: { plugins: [vuetify] } });
await new Promise((r) => setTimeout(r, 50));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (wrapper.vm as any).submit();
expect(adminApi.createPricingTiers).toHaveBeenCalledWith(
expect.arrayContaining([expect.objectContaining({ tier_no: 7, leads_in_tier: null })]),
expect.any(String),
);
});
it('редактор содержит поле даты effective_from', async () => {
const wrapper = mount(AdminPricingTiersView, {
global: {
plugins: [vuetify],
stubs: { VDialog: { template: '<div><slot /></div>' } },
},
});
await new Promise((r) => setTimeout(r, 50));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(wrapper.vm as any).editorOpen = true;
await wrapper.vm.$nextTick();
expect(wrapper.find('[data-testid="effective-from-input"]').exists()).toBe(true);
});
it('submit передаёт выбранную effective_from в createPricingTiers', async () => {
const wrapper = mount(AdminPricingTiersView, { global: { plugins: [vuetify] } });
await new Promise((r) => setTimeout(r, 50));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(wrapper.vm as any).effectiveFrom = '2026-09-01';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (wrapper.vm as any).submit();
expect(adminApi.createPricingTiers).toHaveBeenCalledWith(expect.any(Array), '2026-09-01');
});
it('confirmDelete открывает диалог подтверждения, DELETE не вызывается сразу', async () => {
const wrapper = mount(AdminPricingTiersView, { global: { plugins: [vuetify] } });
await new Promise((r) => setTimeout(r, 50));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const vm = wrapper.vm as any;
vm.confirmDelete('2026-06-01');
expect(vm.deleteDialogOpen).toBe(true);
expect(vm.deleteTarget).toBe('2026-06-01');
expect(adminApi.deleteScheduledPricingTier).not.toHaveBeenCalled();
});
it('openEditor сбрасывает effectiveFrom к дефолту (nextMonthStart)', async () => {
const wrapper = mount(AdminPricingTiersView, { global: { plugins: [vuetify] } });
await new Promise((r) => setTimeout(r, 50));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const vm = wrapper.vm as any;
vm.effectiveFrom = '2099-01-01';
vm.openEditor();
expect(vm.editorOpen).toBe(true);
expect(vm.effectiveFrom).not.toBe('2099-01-01');
});
it('performDelete вызывает deleteScheduledPricingTier для выбранной даты', async () => {
const wrapper = mount(AdminPricingTiersView, { global: { plugins: [vuetify] } });
await new Promise((r) => setTimeout(r, 50));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const vm = wrapper.vm as any;
vm.confirmDelete('2026-06-01');
await vm.performDelete();
expect(adminApi.deleteScheduledPricingTier).toHaveBeenCalledWith('2026-06-01');
expect(vm.deleteDialogOpen).toBe(false);
});
});
describe('AdminPricingTiersView — клиентский диапазон (устранение путаницы admin↔кабинет)', () => {
beforeEach(() => {
vi.mocked(adminApi.getPricingTiers).mockResolvedValue({ active: mockTiers, scheduled: {} });
vi.mocked(adminApi.createPricingTiers).mockResolvedValue({ effective_from: '2026-06-01' });
});
it('в активной сетке рядом с шириной показывает накопительный диапазон, как у клиента', async () => {
const wrapper = mount(AdminPricingTiersView, { global: { plugins: [vuetify] } });
await new Promise((r) => setTimeout(r, 50));
// ширины 100,200 → ступень 2 у клиента = 101–300 (а не «200»)
const cell = wrapper.find('[data-testid="active-range-2"]');
expect(cell.exists()).toBe(true);
expect(cell.text()).toContain('101300');
});
it('ступень 7 в активной сетке — открытый диапазон со знаком +', async () => {
const wrapper = mount(AdminPricingTiersView, { global: { plugins: [vuetify] } });
await new Promise((r) => setTimeout(r, 50));
// 100+200+400+800+1500+3000 = 6000 → ступень 7 = 6001+
expect(wrapper.find('[data-testid="active-range-7"]').text()).toContain('6001+');
});
it('редактор показывает живой клиентский диапазон рядом с полем ширины', async () => {
const wrapper = mount(AdminPricingTiersView, {
global: {
plugins: [vuetify],
stubs: { VDialog: { template: '<div><slot /></div>' } },
},
});
await new Promise((r) => setTimeout(r, 50));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(wrapper.vm as any).editorOpen = true;
await wrapper.vm.$nextTick();
// defaultEditor: ширины 100,200 → ступень 2 = 101300
const cell = wrapper.find('[data-testid="editor-range-2"]');
expect(cell.exists()).toBe(true);
expect(cell.text()).toContain('101300');
});
it('предпросмотр в редакторе пересчитывается при изменении ширины', async () => {
const wrapper = mount(AdminPricingTiersView, {
global: {
plugins: [vuetify],
stubs: { VDialog: { template: '<div><slot /></div>' } },
},
});
await new Promise((r) => setTimeout(r, 50));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const vm = wrapper.vm as any;
vm.editorOpen = true;
vm.editor[0].leads_in_tier = 250; // была 100 → ступень 2 сдвигается на 251–450
await wrapper.vm.$nextTick();
expect(wrapper.find('[data-testid="editor-range-2"]').text()).toContain('251450');
});
});
describe('AdminPricingTiersView error handling (Sprint 1 G1)', () => {
afterEach(() => {
vi.clearAllMocks();
});
it('submit() shows errorMessage when createPricingTiers rejects with 422', async () => {
vi.mocked(adminApi.getPricingTiers).mockResolvedValue({ active: mockTiers, scheduled: {} });
vi.mocked(adminApi.createPricingTiers).mockRejectedValue(
makeAxiosError('Validation failed: tier 7 leads_in_tier must be null', 422),
);
const wrapper = mount(AdminPricingTiersView, { global: { plugins: [vuetify] } });
await new Promise((r) => setTimeout(r, 50));
// Open editor first (submit is called from dialog) so we can verify it stays open on error.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(wrapper.vm as any).editorOpen = true;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (wrapper.vm as any).submit();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const vm = wrapper.vm as any;
expect(vm.errorMessage).toContain('Validation failed');
expect(vm.saving).toBe(false);
// Dialog should remain OPEN so user can fix and retry
expect(vm.editorOpen).toBe(true);
});
it('submit() shows successMessage on 200', async () => {
vi.mocked(adminApi.getPricingTiers).mockResolvedValue({ active: mockTiers, scheduled: {} });
vi.mocked(adminApi.createPricingTiers).mockResolvedValue({ effective_from: '2026-06-01' });
const wrapper = mount(AdminPricingTiersView, { global: { plugins: [vuetify] } });
await new Promise((r) => setTimeout(r, 50));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (wrapper.vm as any).submit();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const vm = wrapper.vm as any;
expect(vm.successMessage).toContain('Сохранено');
expect(vm.errorMessage).toBe(null);
expect(vm.saving).toBe(false);
expect(vm.editorOpen).toBe(false);
expect(vm.successToastOpen).toBe(true);
});
it('performDelete() shows errorMessage when deleteScheduledPricingTier rejects', async () => {
vi.mocked(adminApi.getPricingTiers).mockResolvedValue({ active: mockTiers, scheduled: {} });
vi.mocked(adminApi.deleteScheduledPricingTier).mockRejectedValue(
makeAxiosError('Database connection failed', 500),
);
const wrapper = mount(AdminPricingTiersView, { global: { plugins: [vuetify] } });
await new Promise((r) => setTimeout(r, 50));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const vm = wrapper.vm as any;
vm.confirmDelete('2026-06-01');
await vm.performDelete();
expect(vm.errorMessage).toContain('Database connection failed');
});
it('performDelete() shows successMessage on OK', async () => {
vi.mocked(adminApi.getPricingTiers).mockResolvedValue({ active: mockTiers, scheduled: {} });
vi.mocked(adminApi.deleteScheduledPricingTier).mockResolvedValue(undefined);
const wrapper = mount(AdminPricingTiersView, { global: { plugins: [vuetify] } });
await new Promise((r) => setTimeout(r, 50));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const vm = wrapper.vm as any;
vm.confirmDelete('2026-06-01');
await vm.performDelete();
expect(vm.successMessage).toContain('Удалено');
expect(vm.successToastOpen).toBe(true);
});
});