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