import { describe, it, expect, vi, beforeEach } from 'vitest'; import { mount, flushPromises } from '@vue/test-utils'; import { createPinia, setActivePinia } from 'pinia'; import { createVuetify } from 'vuetify'; import BillingView from '../../resources/js/views/BillingView.vue'; import * as billingApi from '../../resources/js/api/billing'; import type { Wallet } from '../../resources/js/api/billing'; vi.mock('../../resources/js/api/billing'); const vuetify = createVuetify(); function makeWallet(): Wallet { return { balance_rub: '14250.00', affordable_leads: 28, current_tier: { no: 1, price_rub: '500.00', leads_left_in_tier: 100 }, next_tier: { no: 2, price_rub: '450.00', leads_in_tier: 200 }, delivered_in_month: 0, runway_days: 142, tiers_preview: [ { tier_no: 1, price_rub: '500.00', leads_in_tier: 100 }, { tier_no: 2, price_rub: '450.00', leads_in_tier: 200 }, ], tariff: { code: 'pro', name: 'Про', features: ['webhook', 'kanban', 'api'], }, }; } describe('BillingView.vue', () => { beforeEach(() => { setActivePinia(createPinia()); vi.mocked(billingApi.getWallet).mockResolvedValue(makeWallet()); vi.mocked(billingApi.getLastPayment).mockResolvedValue(null); // по умолчанию незавершённых оплат нет }); const factory = () => mount(BillingView, { global: { plugins: [vuetify, createPinia()], stubs: { TransactionsTable: true, InvoicesTable: true, ChargesTab: true, TopupDialog: true }, }, }); it('монтируется с заголовком «Биллинг и тарифы»', async () => { const wrapper = factory(); await flushPromises(); expect(wrapper.find('h1').text()).toBe('Биллинг и тарифы'); }); it('загружает кошелёк и показывает баланс в шапке', async () => { const wrapper = factory(); await flushPromises(); expect(billingApi.getWallet).toHaveBeenCalled(); const text = wrapper.text(); expect(text).toMatch(/14\s+250\s*₽/); }); it('не показывает чип «лидов запас» в шапке', async () => { const wrapper = factory(); await flushPromises(); expect(wrapper.text()).not.toContain('лидов запас'); }); it('показывает «Запас» в BalanceCard вместо карточки «Тариф»', async () => { // tenant-store в тесте не замокан → requiredLeadsPerDay = 0 → нет активных проектов. // B1-2 (25.06): было «∞», теперь «—» + «нет активных проектов» (как на дашборде). const wrapper = factory(); await flushPromises(); const text = wrapper.text(); expect(text).toContain('Запас'); expect(text).toContain('нет активных проектов'); expect(text).not.toContain('∞'); expect(text).not.toContain('Тариф не выбран'); expect(text).not.toContain('по тарифу'); }); it('показывает error-alert при сбое загрузки кошелька', async () => { vi.mocked(billingApi.getWallet).mockRejectedValue(new Error('network')); const wrapper = factory(); await flushPromises(); expect(wrapper.text()).toContain('Не удалось загрузить'); }); it('кнопка «Повторить» перезагружает кошелёк после ошибки', async () => { vi.mocked(billingApi.getWallet).mockReset(); vi.mocked(billingApi.getWallet).mockRejectedValueOnce(new Error('network')).mockResolvedValueOnce(makeWallet()); const wrapper = factory(); await flushPromises(); expect(wrapper.text()).toContain('Не удалось загрузить'); const retry = wrapper.findAll('button').find((b) => b.text().includes('Повторить')); expect(retry).toBeDefined(); await retry!.trigger('click'); await flushPromises(); expect(billingApi.getWallet).toHaveBeenCalledTimes(2); expect(wrapper.text()).toMatch(/14\s+250\s*₽/); }); it('содержит табы Обзор / Списания', async () => { const wrapper = factory(); await flushPromises(); const text = wrapper.text(); expect(text).toContain('Обзор'); expect(text).toContain('Списания'); }); it('кнопка «Пополнить баланс» открывает TopupDialog', async () => { const wrapper = factory(); await flushPromises(); const btn = wrapper.findAll('button').find((b) => b.text().includes('Пополнить баланс')); expect(btn).toBeDefined(); await btn!.trigger('click'); expect((wrapper.vm as unknown as { topupOpen: boolean }).topupOpen).toBe(true); }); it('не показывает pending-баннер (E4 — mock убран)', async () => { const wrapper = factory(); await flushPromises(); expect(wrapper.text()).not.toContain('в обработке'); }); it('рендерит TierPricesPanel между BalanceCard и TransactionsTable', async () => { const wrapper = factory(); await flushPromises(); expect(wrapper.text()).toContain('Цены за лид'); }); // Инцидент 14.07.2026: клиент создал платёж на 10 000 ₽, не довёл оплату до конца, // ЮKassa через час его отменила — а кабинет обещал «баланс обновится автоматически». describe('незавершённая оплата', () => { it('говорит правду про отменённый платёж и предлагает оплатить снова', async () => { vi.mocked(billingApi.getLastPayment).mockResolvedValue({ id: 3, status: 'failed', amount_rub: '10000.00', failure_reason: 'expired_on_confirmation', created_at: '2026-07-14T05:56:19+00:00', }); const wrapper = factory(); await flushPromises(); const text = wrapper.text(); expect(text).toContain('Оплата не завершена'); expect(text).not.toContain('Платёж обрабатывается'); const btn = wrapper.findAll('button').find((b) => b.text().includes('Оплатить снова')); expect(btn).toBeDefined(); await btn!.trigger('click'); expect((wrapper.vm as unknown as { topupOpen: boolean }).topupOpen).toBe(true); }); it('про платёж, который ещё ждёт оплаты, не обещает зачисление', async () => { vi.mocked(billingApi.getLastPayment).mockResolvedValue({ id: 4, status: 'pending', amount_rub: '5000.00', failure_reason: null, created_at: '2026-07-14T05:56:19+00:00', }); const wrapper = factory(); await flushPromises(); expect(wrapper.text()).toContain('Оплата не завершена'); expect(wrapper.text()).not.toContain('Баланс обновится автоматически'); }); it('когда незавершённых оплат нет — баннера нет', async () => { vi.mocked(billingApi.getLastPayment).mockResolvedValue(null); const wrapper = factory(); await flushPromises(); expect(wrapper.text()).not.toContain('Оплата не завершена'); }); }); });