2026-05-16 13:29:53 +03:00
|
|
|
|
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';
|
|
|
|
|
|
|
2026-05-16 13:35:33 +03:00
|
|
|
|
/**
|
|
|
|
|
|
* Создаёт объект, который проходит `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 } },
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-16 13:29:53 +03:00
|
|
|
|
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 () => {
|
2026-06-17 05:17:12 +03:00
|
|
|
|
vi.mocked(adminApi.listAdminBilling).mockResolvedValue(makeBillingResponse([makeApiBillingTenant({ id: 42 })]));
|
2026-05-16 13:29:53 +03:00
|
|
|
|
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 () => {
|
2026-06-17 05:17:12 +03:00
|
|
|
|
vi.mocked(adminApi.listAdminBilling).mockResolvedValue(makeBillingResponse([makeApiBillingTenant({ id: 42 })]));
|
2026-05-16 13:29:53 +03:00
|
|
|
|
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();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-16 13:35:33 +03:00
|
|
|
|
it('API-ошибка в confirmAction — actionError содержит backend-сообщение, диалог остаётся открытым', async () => {
|
2026-05-16 13:29:53 +03:00
|
|
|
|
vi.mocked(adminApi.listAdminBilling).mockResolvedValueOnce(
|
|
|
|
|
|
makeBillingResponse([makeApiBillingTenant({ id: 42 })]),
|
|
|
|
|
|
);
|
2026-05-16 13:35:33 +03:00
|
|
|
|
vi.mocked(adminApi.updateTenantStatus).mockRejectedValueOnce(
|
|
|
|
|
|
makeAxiosError('Нельзя заблокировать — есть долги'),
|
|
|
|
|
|
);
|
2026-05-16 13:29:53 +03: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('status', row);
|
|
|
|
|
|
vm.actionReason = 'Причина блокировки тенанта';
|
|
|
|
|
|
|
|
|
|
|
|
await vm.confirmAction();
|
|
|
|
|
|
await flushPromises();
|
|
|
|
|
|
|
2026-05-16 13:35:33 +03:00
|
|
|
|
// Dialog stays open, exact backend message surfaces, loading cleared
|
2026-05-16 13:29:53 +03:00
|
|
|
|
expect(vm.actionDialog).toBe('status');
|
2026-05-16 13:35:33 +03:00
|
|
|
|
expect(vm.actionError).toBe('Нельзя заблокировать — есть долги');
|
2026-05-16 13:29:53 +03:00
|
|
|
|
expect(vm.actionLoading).toBe(false);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-16 13:35:33 +03:00
|
|
|
|
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');
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-16 13:29:53 +03:00
|
|
|
|
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
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|