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

331 lines
12 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, 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
});
});