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(); 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: '
' } }, }, }); 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); }); });