import { describe, it, expect, vi, beforeEach } from 'vitest'; import { mount, flushPromises } from '@vue/test-utils'; import { createVuetify } from 'vuetify'; import AdminBillingView from '../../resources/js/views/admin/AdminBillingView.vue'; import type { ApiAdminBillingTenant } from '../../resources/js/api/admin'; /** * Создаёт объект, который проходит `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, listAdminBilling: vi.fn(), listAdminTariffPlans: vi.fn(), updateTenantStatus: vi.fn(), refundTenant: vi.fn(), changeTenantTariff: vi.fn(), }; }); const adminApi = await import('../../resources/js/api/admin'); beforeEach(() => { vi.clearAllMocks(); }); function makeApiBillingTenant(overrides: Partial = {}): ApiAdminBillingTenant { return { id: 42, subdomain: 'acme', organization_name: 'Acme ООО', contact_email: 'admin@acme.io', status: 'active', balance_rub: '5000.00', tariff_id: 1, tariff_name: 'Команда', mrr_rub: '990.00', monthly_topups_rub: '10000.00', monthly_charges_rub: '8000.00', last_payment_at: '2026-05-01T10:00:00Z', chargeback_unrecovered_rub: '0.00', ...overrides, }; } function makeBillingResponse(tenants: ApiAdminBillingTenant[]) { return { tenants, summary: { total_mrr_rub: '990.00', monthly_revenue_rub: '10000.00', overdue_count: 0, refunds_count_30d: 0, }, }; } const mountView = () => mount(AdminBillingView, { global: { plugins: [createVuetify()] }, }); describe('AdminBillingView — row-actions menu (G4)', () => { it('каждая строка содержит кнопку действий [data-testid="row-actions-{id}"]', async () => { vi.mocked(adminApi.listAdminBilling).mockResolvedValueOnce( makeBillingResponse([makeApiBillingTenant({ id: 42 })]), ); const wrapper = mountView(); await flushPromises(); expect(wrapper.find('[data-testid="row-actions-42"]').exists()).toBe(true); }); it('openAction("status", row) устанавливает actionDialog="status" и actionRow', async () => { vi.mocked(adminApi.listAdminBilling).mockResolvedValueOnce( makeBillingResponse([makeApiBillingTenant({ id: 42 })]), ); const wrapper = mountView(); await flushPromises(); // eslint-disable-next-line @typescript-eslint/no-explicit-any const vm = wrapper.vm as any; const row = vm.rowsState[0]; await vm.openAction('status', row); await wrapper.vm.$nextTick(); expect(vm.actionDialog).toBe('status'); expect(vm.actionRow).toBe(row); expect(vm.actionReason).toBe(''); expect(vm.actionError).toBe(''); }); it('confirmAction() вызывает updateTenantStatus и затем loadBilling', async () => { vi.mocked(adminApi.listAdminBilling).mockResolvedValue( makeBillingResponse([makeApiBillingTenant({ id: 42, status: 'active' })]), ); vi.mocked(adminApi.updateTenantStatus).mockResolvedValueOnce({ id: 42, status: 'suspended' }); const wrapper = mountView(); await flushPromises(); // eslint-disable-next-line @typescript-eslint/no-explicit-any const vm = wrapper.vm as any; const row = vm.rowsState[0]; await vm.openAction('status', row); vm.actionReason = 'Основание для блокировки тенанта'; await vm.confirmAction(); await flushPromises(); expect(adminApi.updateTenantStatus).toHaveBeenCalledWith(42, 'suspended', 'Основание для блокировки тенанта'); expect(adminApi.listAdminBilling).toHaveBeenCalledTimes(2); // mount + after action expect(vm.actionDialog).toBeNull(); }); it('confirmAction() вызывает refundTenant с суммой и причиной', async () => { vi.mocked(adminApi.listAdminBilling).mockResolvedValue( makeBillingResponse([makeApiBillingTenant({ id: 42 })]), ); vi.mocked(adminApi.refundTenant).mockResolvedValueOnce({ id: 42, balance_rub: '4500.00', transaction_id: 999, }); const wrapper = mountView(); await flushPromises(); // eslint-disable-next-line @typescript-eslint/no-explicit-any const vm = wrapper.vm as any; const row = vm.rowsState[0]; await vm.openAction('refund', row); vm.actionAmount = 500; vm.actionReason = 'Возврат по заявке клиента'; await vm.confirmAction(); await flushPromises(); expect(adminApi.refundTenant).toHaveBeenCalledWith(42, 500, 'Возврат по заявке клиента'); expect(vm.actionDialog).toBeNull(); }); it('openAction("tariff") вызывает listAdminTariffPlans и заполняет tariffPlans', async () => { vi.mocked(adminApi.listAdminBilling).mockResolvedValueOnce( makeBillingResponse([makeApiBillingTenant({ id: 42 })]), ); vi.mocked(adminApi.listAdminTariffPlans).mockResolvedValueOnce([ { id: 1, name: 'Старт', price_monthly: '490.00' }, { id: 2, name: 'Команда', price_monthly: '990.00' }, ]); const wrapper = mountView(); await flushPromises(); // eslint-disable-next-line @typescript-eslint/no-explicit-any const vm = wrapper.vm as any; const row = vm.rowsState[0]; await vm.openAction('tariff', row); await flushPromises(); expect(adminApi.listAdminTariffPlans).toHaveBeenCalledTimes(1); expect(vm.tariffPlans).toHaveLength(2); expect(vm.tariffPlans[0].name).toBe('Старт'); }); it('confirmAction("tariff") вызывает changeTenantTariff', async () => { vi.mocked(adminApi.listAdminBilling).mockResolvedValue( makeBillingResponse([makeApiBillingTenant({ id: 42 })]), ); vi.mocked(adminApi.listAdminTariffPlans).mockResolvedValueOnce([ { id: 2, name: 'Команда', price_monthly: '990.00' }, ]); vi.mocked(adminApi.changeTenantTariff).mockResolvedValueOnce({ id: 42, tariff_id: 2, tariff_name: 'Команда', }); const wrapper = mountView(); await flushPromises(); // eslint-disable-next-line @typescript-eslint/no-explicit-any const vm = wrapper.vm as any; const row = vm.rowsState[0]; await vm.openAction('tariff', row); await flushPromises(); vm.actionTariffId = 2; vm.actionReason = 'Смена тарифа по просьбе клиента'; await vm.confirmAction(); await flushPromises(); expect(adminApi.changeTenantTariff).toHaveBeenCalledWith(42, 2, 'Смена тарифа по просьбе клиента'); expect(vm.actionDialog).toBeNull(); }); it('API-ошибка в confirmAction — actionError содержит backend-сообщение, диалог остаётся открытым', async () => { vi.mocked(adminApi.listAdminBilling).mockResolvedValueOnce( makeBillingResponse([makeApiBillingTenant({ id: 42 })]), ); vi.mocked(adminApi.updateTenantStatus).mockRejectedValueOnce( makeAxiosError('Нельзя заблокировать — есть долги'), ); const wrapper = mountView(); await flushPromises(); // eslint-disable-next-line @typescript-eslint/no-explicit-any const vm = wrapper.vm as any; const row = vm.rowsState[0]; await vm.openAction('status', row); vm.actionReason = 'Причина блокировки тенанта'; await vm.confirmAction(); await flushPromises(); // Dialog stays open, exact backend message surfaces, loading cleared expect(vm.actionDialog).toBe('status'); expect(vm.actionError).toBe('Нельзя заблокировать — есть долги'); expect(vm.actionLoading).toBe(false); }); it('openAction("tariff") — ошибка загрузки тарифов устанавливает actionError, диалог остаётся открытым', async () => { vi.mocked(adminApi.listAdminBilling).mockResolvedValueOnce( makeBillingResponse([makeApiBillingTenant({ id: 42 })]), ); vi.mocked(adminApi.listAdminTariffPlans).mockRejectedValueOnce( makeAxiosError('Тарифы временно недоступны', 503), ); const wrapper = mountView(); await flushPromises(); // eslint-disable-next-line @typescript-eslint/no-explicit-any const vm = wrapper.vm as any; const row = vm.rowsState[0]; await vm.openAction('tariff', row); await flushPromises(); expect(vm.actionDialog).toBe('tariff'); expect(vm.actionError).toBe('Тарифы временно недоступны'); }); it('возврат суммы больше баланса → actionError, refundTenant не вызывается', async () => { vi.mocked(adminApi.listAdminBilling).mockResolvedValueOnce( makeBillingResponse([makeApiBillingTenant({ id: 42, balance_rub: '1000.00' })]), ); const wrapper = mountView(); await flushPromises(); // eslint-disable-next-line @typescript-eslint/no-explicit-any const vm = wrapper.vm as any; const row = vm.rowsState[0]; // balance_rub = 1000 await vm.openAction('refund', row); vm.actionAmount = 1500; // exceeds balance vm.actionReason = 'Возврат по заявке клиента'; await vm.confirmAction(); expect(adminApi.refundTenant).not.toHaveBeenCalled(); expect(vm.actionError).toBe('Сумма возврата превышает баланс тенанта.'); expect(vm.actionDialog).toBe('refund'); }); it('NaN в сумме возврата → actionError, refundTenant не вызывается', async () => { vi.mocked(adminApi.listAdminBilling).mockResolvedValueOnce( makeBillingResponse([makeApiBillingTenant({ id: 42 })]), ); const wrapper = mountView(); await flushPromises(); // eslint-disable-next-line @typescript-eslint/no-explicit-any const vm = wrapper.vm as any; const row = vm.rowsState[0]; await vm.openAction('refund', row); vm.actionAmount = NaN; // non-numeric input from v-model.number vm.actionReason = 'Возврат по заявке клиента'; await vm.confirmAction(); expect(adminApi.refundTenant).not.toHaveBeenCalled(); expect(vm.actionError).toBeTruthy(); expect(vm.actionDialog).toBe('refund'); }); it('короткая причина (<10 символов) → confirmAction ставит actionError, не вызывает API', async () => { vi.mocked(adminApi.listAdminBilling).mockResolvedValueOnce( makeBillingResponse([makeApiBillingTenant({ id: 42 })]), ); const wrapper = mountView(); await flushPromises(); // eslint-disable-next-line @typescript-eslint/no-explicit-any const vm = wrapper.vm as any; const row = vm.rowsState[0]; await vm.openAction('status', row); vm.actionReason = 'Коротко'; // < 10 chars await vm.confirmAction(); expect(adminApi.updateTenantStatus).not.toHaveBeenCalled(); expect(vm.actionError).toBeTruthy(); expect(vm.actionDialog).toBe('status'); // stays open }); });