fa11c7b223
Закрывает gap из v1.66 — mock-форма имеет mrrRub, но API возвращал null. Теперь AdminTenantsView показывает реальную колонку MRR. Backend (AdminTenantsController::index): - Добавлено tariff_plans.price_monthly as tariff_price_monthly в select. - mrr_rub в response: price_monthly (string) если не-trial; иначе null. - Aggregate-формат как у /admin/billing — string чтобы decimal не терял точность при передаче через JSON. Pest +3 (AdminTenantsIndexTest): - mrr_rub='990.00' для активного тарифа не-trial. - mrr_rub=null для trial (даже если тариф есть). - mrr_rub=null если current_tariff_id отсутствует. Frontend: - ApiAdminTenant.mrr_rub: string | null в типе. - mapApiAdminTenant: parseFloat(api.mrr_rub) или null (вместо hardcoded null из v1.66). - AdminTenantsView: formatRub(item.mrrRub) для консистентности с другими ₽-полями. Vitest +2: - mrr_rub строка → number. - mrr_rub=null → mrrRub null. PHPStan baseline регенерирован. cspell-glossary +консистентности. Регресс: - Lint+type-check+format passed. - Vitest 313/313 за 18.83 сек (+2 от 311). - Vite build 947 ms. - Pint + PHPStan passed. - Pest 266/266 за 28.39 сек (+3 от 263, 1001 assertion). Реестр v1.70→v1.71 / CLAUDE.md v1.61→v1.62. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
197 lines
7.3 KiB
TypeScript
197 lines
7.3 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { mount, flushPromises } from '@vue/test-utils';
|
|
import { createVuetify } from 'vuetify';
|
|
import { createRouter, createMemoryHistory } from 'vue-router';
|
|
import { createPinia, setActivePinia } from 'pinia';
|
|
import AdminTenantsView from '../../resources/js/views/admin/AdminTenantsView.vue';
|
|
import { mapApiAdminTenant } from '../../resources/js/composables/adminTenantsMapper';
|
|
import type { AdminTenant as ApiAdminTenant } 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,
|
|
listAdminTenants: vi.fn(),
|
|
};
|
|
});
|
|
|
|
const adminApi = await import('../../resources/js/api/admin');
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
setActivePinia(createPinia());
|
|
});
|
|
|
|
function makeApiTenant(overrides: Partial<ApiAdminTenant> = {}): ApiAdminTenant {
|
|
return {
|
|
id: 1,
|
|
subdomain: 'test',
|
|
organization_name: 'Test ООО',
|
|
contact_email: 'admin@test.io',
|
|
status: 'active',
|
|
balance_rub: '1000.00',
|
|
balance_leads: 10,
|
|
is_trial: false,
|
|
last_activity_at: new Date().toISOString(),
|
|
tariff_id: null,
|
|
tariff_name: 'Команда',
|
|
mrr_rub: '990.00',
|
|
desired_daily_numbers: 5,
|
|
chargeback_unrecovered_rub: '0.00',
|
|
created_at: new Date().toISOString(),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
const mountView = async () => {
|
|
const router = createRouter({
|
|
history: createMemoryHistory(),
|
|
routes: [
|
|
{ path: '/admin/tenants', component: AdminTenantsView },
|
|
{ path: '/admin/tenants/:code', name: 'admin-tenant-detail', component: { template: '<div />' } },
|
|
],
|
|
});
|
|
await router.push('/admin/tenants');
|
|
await router.isReady();
|
|
return mount(AdminTenantsView, {
|
|
global: {
|
|
plugins: [createVuetify(), router],
|
|
stubs: { ImpersonationDialog: true },
|
|
},
|
|
});
|
|
};
|
|
|
|
describe('AdminTenantsView ↔ GET /api/admin/tenants integration', () => {
|
|
it('listAdminTenants вызывается на mount', async () => {
|
|
vi.mocked(adminApi.listAdminTenants).mockResolvedValueOnce({
|
|
tenants: [],
|
|
total: 0,
|
|
limit: 100,
|
|
offset: 0,
|
|
stats: { total: 0, active: 0, trial: 0, overdue: 0 },
|
|
});
|
|
|
|
await mountView();
|
|
await flushPromises();
|
|
expect(adminApi.listAdminTenants).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('успех — replace tenantsState на API-данные + stats', async () => {
|
|
vi.mocked(adminApi.listAdminTenants).mockResolvedValueOnce({
|
|
tenants: [
|
|
makeApiTenant({ id: 100, organization_name: 'Окна Москва', tariff_name: 'Команда' }),
|
|
makeApiTenant({ id: 101, organization_name: 'Двери СПб', tariff_name: 'Pro', is_trial: true }),
|
|
],
|
|
total: 2,
|
|
limit: 100,
|
|
offset: 0,
|
|
stats: { total: 2, active: 1, trial: 1, overdue: 0 },
|
|
});
|
|
|
|
const wrapper = await mountView();
|
|
await flushPromises();
|
|
|
|
const vm = wrapper.vm as unknown as {
|
|
tenantsState: { id: number; name: string; status: string }[];
|
|
stats: { total: number; active: number; trial: number; overdue: number };
|
|
};
|
|
expect(vm.tenantsState).toHaveLength(2);
|
|
expect(vm.tenantsState[0].id).toBe(100);
|
|
expect(vm.tenantsState[0].name).toBe('Окна Москва');
|
|
expect(vm.tenantsState[1].status).toBe('trial'); // is_trial=true → 'trial'
|
|
expect(vm.stats.total).toBe(2);
|
|
expect(vm.stats.trial).toBe(1);
|
|
});
|
|
|
|
it('reject → fetchError=true + alert виден + MOCK_TENANTS остаётся', async () => {
|
|
vi.mocked(adminApi.listAdminTenants).mockRejectedValueOnce(new Error('500'));
|
|
|
|
const wrapper = await mountView();
|
|
await flushPromises();
|
|
|
|
const vm = wrapper.vm as unknown as { fetchError: boolean; tenantsState: unknown[] };
|
|
expect(vm.fetchError).toBe(true);
|
|
expect(vm.tenantsState.length).toBeGreaterThan(0); // mock-fallback
|
|
expect(wrapper.find('[data-testid="fetch-error-alert"]').exists()).toBe(true);
|
|
});
|
|
|
|
it('reload-btn вызывает listAdminTenants второй раз', async () => {
|
|
vi.mocked(adminApi.listAdminTenants).mockResolvedValue({
|
|
tenants: [],
|
|
total: 0,
|
|
limit: 100,
|
|
offset: 0,
|
|
stats: { total: 0, active: 0, trial: 0, overdue: 0 },
|
|
});
|
|
|
|
const wrapper = await mountView();
|
|
await flushPromises();
|
|
expect(adminApi.listAdminTenants).toHaveBeenCalledTimes(1);
|
|
|
|
await wrapper.find('[data-testid="reload-btn"]').trigger('click');
|
|
await flushPromises();
|
|
expect(adminApi.listAdminTenants).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|
|
|
|
describe('mapApiAdminTenant', () => {
|
|
it('маппит organization_name → name, subdomain → code', () => {
|
|
const m = mapApiAdminTenant(makeApiTenant({ subdomain: 'okna', organization_name: 'Окна' }));
|
|
expect(m.code).toBe('okna');
|
|
expect(m.name).toBe('Окна');
|
|
});
|
|
|
|
it('inn пустой (нет в API)', () => {
|
|
const m = mapApiAdminTenant(makeApiTenant());
|
|
expect(m.inn).toBe('');
|
|
});
|
|
|
|
it('is_trial=true → status=trial', () => {
|
|
const m = mapApiAdminTenant(makeApiTenant({ is_trial: true }));
|
|
expect(m.status).toBe('trial');
|
|
expect(m.statusText).toBe('Trial');
|
|
});
|
|
|
|
it('chargeback>0 → status=overdue', () => {
|
|
const m = mapApiAdminTenant(makeApiTenant({ chargeback_unrecovered_rub: '1000.00' }));
|
|
expect(m.status).toBe('overdue');
|
|
});
|
|
|
|
it('balance<0 → status=overdue', () => {
|
|
const m = mapApiAdminTenant(makeApiTenant({ balance_rub: '-200.00' }));
|
|
expect(m.status).toBe('overdue');
|
|
});
|
|
|
|
it('schema status=suspended → status=suspended', () => {
|
|
const m = mapApiAdminTenant(makeApiTenant({ status: 'suspended', is_trial: false }));
|
|
expect(m.status).toBe('suspended');
|
|
});
|
|
|
|
it('balance_rub строка → number', () => {
|
|
const m = mapApiAdminTenant(makeApiTenant({ balance_rub: '15000.50' }));
|
|
expect(m.balanceRub).toBe(15000.5);
|
|
});
|
|
|
|
it('последняя активность форматируется как «X мин назад»', () => {
|
|
const fixedNow = new Date('2026-05-09T10:00:00Z');
|
|
const tenMinAgo = new Date('2026-05-09T09:50:00Z');
|
|
const m = mapApiAdminTenant(makeApiTenant({ last_activity_at: tenMinAgo.toISOString() }), fixedNow);
|
|
expect(m.activitySince).toBe('10 мин назад');
|
|
});
|
|
|
|
it('last_activity_at=null → activitySince=«—»', () => {
|
|
const m = mapApiAdminTenant(makeApiTenant({ last_activity_at: null }));
|
|
expect(m.activitySince).toBe('—');
|
|
});
|
|
|
|
it('mrr_rub строка → number', () => {
|
|
const m = mapApiAdminTenant(makeApiTenant({ mrr_rub: '990.00' }));
|
|
expect(m.mrrRub).toBe(990);
|
|
});
|
|
|
|
it("mrr_rub=null → mrrRub null (для trial и tenant'ов без тарифа)", () => {
|
|
const m = mapApiAdminTenant(makeApiTenant({ mrr_rub: null }));
|
|
expect(m.mrrRub).toBeNull();
|
|
});
|
|
});
|