Files
portal/app/tests/Frontend/AdminPricingTiersView.spec.ts
T

213 lines
10 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));
expect(wrapper.text()).toContain('500.00');
expect(wrapper.text()).toContain('250.00');
});
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 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);
});
});