Files
portal/app/tests/Frontend/AdminBillingViewApi.spec.ts
T
Дмитрий 4532b95d64 phase2(admin-billing): GET /api/admin/billing + AdminBillingView API (этап 3/5)
Aggregates пополнений/списаний за текущий месяц по balance_transactions
+ summary с MRR/revenue/overdue/refunds_30d.

Backend (AdminBillingController::index):
- GET /api/admin/billing?search=. Per-tenant SUM с CASE WHEN type IN
  ('topup','lead_charge') GROUP BY tenant_id; ABS для charges.
- Row: id/subdomain/organization_name/contact_email/status/balance_rub/
  tariff_id/tariff_name/mrr_rub (=tariff.price_monthly если не-trial)/
  monthly_topups_rub/monthly_charges_rub/last_payment_at/
  chargeback_unrecovered_rub.
- summary: total_mrr_rub (SUM не-trial), monthly_revenue_rub (SUM topup),
  overdue_count (balance<0 || chargeback>0), refunds_count_30d.
- Quirk: schema-колонка tariff_plans.price_monthly (НЕ price_rub_monthly)
  — обнаружено первым прогоном Pest, исправлено сразу.

Pest +9 (AdminBillingIndexTest):
- пустой / поля+tariff JOIN / aggregates за месяц / прошлый месяц не
  попадает / overdue / refunds_30d (старые исключены) / total_mrr_rub
  (trial исключаются) / search ILIKE / soft-deleted скрыт.

Frontend:
- api/admin.ts::listAdminBilling — типизированный helper.
- AdminBillingView: reactive rowsState+summary default = MOCK,
  loadBilling() async на onMounted парсит API-строки → numbers + derive
  status (suspended/balance<0||chargeback>0→overdue/active). На fail —
  fetchError + warning alert + MOCK fallback. Reload-btn.
- tariffLabel/statusInfo обобщены с fallback'ами на новые slug'и.

Vitest +4:
- listAdminBilling на mount / replace rowsState+summary + string→number
  + status derive / reject → fetchError+alert+fallback / reload-btn x2.

PHPStan baseline регенерирован.

Регресс:
- Lint+type-check+format passed.
- Vitest 300/300 за 18.41 сек (+4 от 296).
- Vite build 925 ms.
- Pint + PHPStan passed.
- Pest 237/237 за 27.69 сек (+9 от 228, 926 assertions).

Реестр v1.66→v1.67 / CLAUDE.md v1.57→v1.58.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 09:28:49 +03:00

128 lines
4.6 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';
vi.mock('../../resources/js/api/admin', async (importOriginal) => {
const orig = await importOriginal<typeof import('../../resources/js/api/admin')>();
return {
...orig,
listAdminBilling: vi.fn(),
};
});
const adminApi = await import('../../resources/js/api/admin');
beforeEach(() => {
vi.clearAllMocks();
});
function makeApiBillingTenant(overrides: Partial<ApiAdminBillingTenant> = {}): ApiAdminBillingTenant {
return {
id: 1,
subdomain: 'test',
organization_name: 'Test ООО',
contact_email: 'admin@test.io',
status: 'active',
balance_rub: '14250.00',
tariff_id: 1,
tariff_name: 'Команда',
mrr_rub: '990.00',
monthly_topups_rub: '30000.00',
monthly_charges_rub: '25400.00',
last_payment_at: '2026-05-04T10:23:00Z',
chargeback_unrecovered_rub: '0.00',
...overrides,
};
}
const mountView = () =>
mount(AdminBillingView, {
global: { plugins: [createVuetify()] },
});
describe('AdminBillingView ↔ GET /api/admin/billing integration', () => {
it('listAdminBilling вызывается на mount', async () => {
vi.mocked(adminApi.listAdminBilling).mockResolvedValueOnce({
tenants: [],
summary: {
total_mrr_rub: '0',
monthly_revenue_rub: '0',
overdue_count: 0,
refunds_count_30d: 0,
},
});
mountView();
await flushPromises();
expect(adminApi.listAdminBilling).toHaveBeenCalledTimes(1);
});
it('успех — replace rowsState + summary; numbers конвертируются из string', async () => {
vi.mocked(adminApi.listAdminBilling).mockResolvedValueOnce({
tenants: [
makeApiBillingTenant({ id: 100, organization_name: 'Окна Москва', balance_rub: '14250.50' }),
makeApiBillingTenant({
id: 101,
organization_name: 'Просрочник',
balance_rub: '-200.00',
status: 'active',
}),
],
summary: {
total_mrr_rub: '1248600.00',
monthly_revenue_rub: '1318400.00',
overdue_count: 5,
refunds_count_30d: 3,
},
});
const wrapper = mountView();
await flushPromises();
const vm = wrapper.vm as unknown as {
rowsState: Array<{ id: number; name: string; balance_rub: number; status: string }>;
summary: { total_mrr_rub: number; overdue_count: number; refunds_count_30d: number };
};
expect(vm.rowsState).toHaveLength(2);
expect(vm.rowsState[0].balance_rub).toBe(14250.5);
expect(vm.rowsState[1].status).toBe('overdue'); // balance<0 → derive
expect(vm.summary.total_mrr_rub).toBe(1248600);
expect(vm.summary.overdue_count).toBe(5);
expect(vm.summary.refunds_count_30d).toBe(3);
});
it('reject → fetchError=true + alert виден + MOCK fallback остаётся', async () => {
vi.mocked(adminApi.listAdminBilling).mockRejectedValueOnce(new Error('500'));
const wrapper = mountView();
await flushPromises();
const vm = wrapper.vm as unknown as { fetchError: boolean; rowsState: unknown[] };
expect(vm.fetchError).toBe(true);
expect(vm.rowsState.length).toBeGreaterThan(0);
expect(wrapper.find('[data-testid="fetch-error-alert"]').exists()).toBe(true);
});
it('reload-btn вызывает listAdminBilling второй раз', async () => {
vi.mocked(adminApi.listAdminBilling).mockResolvedValue({
tenants: [],
summary: {
total_mrr_rub: '0',
monthly_revenue_rub: '0',
overdue_count: 0,
refunds_count_30d: 0,
},
});
const wrapper = mountView();
await flushPromises();
expect(adminApi.listAdminBilling).toHaveBeenCalledTimes(1);
await wrapper.find('[data-testid="reload-btn"]').trigger('click');
await flushPromises();
expect(adminApi.listAdminBilling).toHaveBeenCalledTimes(2);
});
});