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

327 lines
12 KiB
TypeScript
Raw Normal View History

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<typeof import('../../resources/js/api/admin')>();
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> = {}): 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
});
});