b35d3888bb
Админка показывала сырую ширину ступени (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>
270 lines
14 KiB
TypeScript
270 lines
14 KiB
TypeScript
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('101–300');
|
||
});
|
||
|
||
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 = 101–300
|
||
const cell = wrapper.find('[data-testid="editor-range-2"]');
|
||
expect(cell.exists()).toBe(true);
|
||
expect(cell.text()).toContain('101–300');
|
||
});
|
||
|
||
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('251–450');
|
||
});
|
||
});
|
||
|
||
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);
|
||
});
|
||
});
|