Files
portal/app/tests/Frontend/AdminTenantsView.spec.ts
T
Дмитрий c92d498b57
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
feat(админка): экран Тенанты на серверную пагинацию/поиск/фильтры (масштаб 1000+)
AdminTenantsView грузил всех тенантов разом и фильтровал в браузере — на 1000
клиентов поиск/чипы видели только первую страницу. Теперь страница из limit/offset
+ v-pagination; поиск (ILIKE), статус (производный trial/overdue/active/suspended)
и тариф — серверные multi-фильтры. AdminTenantsController::index: statuses/tariffs
через CASE/whereIn (статус зеркалит adminTenantsMapper.deriveStatus). Опции тарифов —
отдельным запросом listAdminTariffPlans. Демо локально подтверждено.

Тесты: фронт 34/34 (tenants), бэкенд 13/13 (+2 на statuses/tariffs); baseline getJson 13→15.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 12:06:56 +03:00

257 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 { createRouter, createMemoryHistory } from 'vue-router';
import AdminTenantsView from '../../resources/js/views/admin/AdminTenantsView.vue';
import { MOCK_STATS, MOCK_TENANTS, type AdminTenant } from '../../resources/js/composables/mockTenants';
// listAdminTenants мокаем пустым ответом — smoke/render-тесты затем seed'ят
// tenantsState/stats напрямую через vm (defineExpose). Серверные фильтры/пагинация:
// фильтр-тесты проверяют, что view зовёт listAdminTenants с правильными параметрами.
vi.mock('../../resources/js/api/admin', () => ({
listAdminTenants: vi.fn().mockResolvedValue({
tenants: [],
total: 0,
limit: 25,
offset: 0,
stats: { total: 0, active: 0, trial: 0, overdue: 0 },
}),
listAdminTariffPlans: vi.fn().mockResolvedValue([
{ id: 1, name: 'Команда', price_monthly: '990.00' },
{ id: 2, name: 'Pro', price_monthly: '2990.00' },
]),
}));
beforeEach(() => {
vi.clearAllMocks();
});
describe('AdminTenantsView.vue', () => {
/** Монтирует view, ждёт mount-цикл, затем seed'ит state фикстурами. */
const factory = async () => {
const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/admin/tenants', name: 'admin-tenants', component: AdminTenantsView },
{ path: '/admin/tenants/:code', name: 'admin-tenant-detail', component: { template: '<div />' } },
],
});
await router.push('/admin/tenants');
await router.isReady();
const wrapper = mount(AdminTenantsView, {
global: {
plugins: [createVuetify(), router],
stubs: { ImpersonationDialog: true, TenantBalanceDialog: true },
},
});
await flushPromises();
// Seed state напрямую через defineExpose — имитирует успешную загрузку страницы.
const vm = wrapper.vm as unknown as {
tenantsState: AdminTenant[];
stats: typeof MOCK_STATS;
total: number;
};
vm.tenantsState.splice(0, vm.tenantsState.length, ...MOCK_TENANTS.map((t) => ({ ...t })));
Object.assign(vm.stats, MOCK_STATS);
vm.total = MOCK_TENANTS.length;
await wrapper.vm.$nextTick();
return wrapper;
};
it('монтируется и содержит заголовок «Тенанты»', async () => {
const wrapper = await factory();
expect(wrapper.find('h1').text()).toBe('Тенанты');
});
it('показывает 5 stats: всего/активны/trial/просрочка/выручка', async () => {
const wrapper = await factory();
const text = wrapper.text();
expect(text).toContain(`${MOCK_STATS.total}`); // 142
expect(text).toContain('всего');
expect(text).toContain(`${MOCK_STATS.active}`); // 128
expect(text).toContain('активны');
expect(text).toContain(`${MOCK_STATS.trial}`); // 9
expect(text).toContain('trial');
expect(text).toContain(`${MOCK_STATS.overdue}`); // 5
expect(text).toContain('просрочка');
expect(text).toContain('выручка месяц');
// 1 248 600 ₽
expect(text).toMatch(/1\s+248\s+600\s*₽/);
});
it('таблица содержит 7 колонок (Тенант/Статус/Тариф/Баланс/Желаем×факт/MRR/Активность)', async () => {
const wrapper = await factory();
const headers = wrapper.findAll('thead th').map((h) => h.text());
['Тенант', 'Статус', 'Тариф', 'Баланс', 'Желаем×факт', 'MRR', 'Активность'].forEach((label) => {
expect(headers.some((h) => h.includes(label))).toBe(true);
});
});
it('рендерит все 7 mock-tenants текущей страницы', async () => {
const wrapper = await factory();
const rows = wrapper.findAll('tbody tr');
expect(rows.length).toBe(MOCK_TENANTS.length);
});
it('первая строка — Окна Москва ООО + ИНН + Активен + Команда', async () => {
const wrapper = await factory();
const text = wrapper.text();
expect(text).toContain('Окна Москва ООО');
expect(text).toContain('ИНН 7724444444');
expect(text).toContain('Активен');
expect(text).toContain('Команда');
});
it('overdue-тенант (Двери Премиум) показывает «Просрочка 3 дня» + отрицательный баланс', async () => {
const wrapper = await factory();
const text = wrapper.text();
expect(text).toContain('Двери Премиум');
expect(text).toContain('Просрочка 3 дня');
expect(text).toMatch(/1\s+200/); // -1200 без 0 ₽
});
it('trial-тенант (Ремонт под ключ) показывает «Trial · 4 дня» + MRR=—', async () => {
const wrapper = await factory();
const text = wrapper.text();
expect(text).toContain('Ремонт под ключ');
expect(text).toContain('Trial · 4 дня');
});
it('suspended-тенант (Оконные системы РФ) показывает «Приостановлен»', async () => {
const wrapper = await factory();
const text = wrapper.text();
expect(text).toContain('Оконные системы РФ');
expect(text).toContain('Приостановлен');
});
it('содержит search-input с placeholder «ИНН, юр. лицо, email админа…»', async () => {
const wrapper = await factory();
const input = wrapper.find('input[type="text"]');
expect(input.exists()).toBe(true);
expect(input.attributes('placeholder')).toContain('ИНН');
});
it('поиск передаётся на сервер параметром search', async () => {
const wrapper = await factory();
const api = await import('../../resources/js/api/admin');
const vm = wrapper.vm as unknown as { search: string; loadTenants: () => Promise<void> };
vm.search = 'Натяжные';
await vm.loadTenants();
// .some, а не .at(-1): usePolling может вставить фоновый вызов между действием и проверкой.
expect(vi.mocked(api.listAdminTenants).mock.calls.some((c) => c[0]?.search === 'Натяжные')).toBe(true);
});
it('содержит Экспорт-кнопку и фильтры Статус/Тариф', async () => {
const wrapper = await factory();
expect(wrapper.text()).toContain('Экспорт');
expect(wrapper.find('[data-testid="filter-statuses"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="filter-tariffs"]').exists()).toBe(true);
});
it('фильтр по статусу «overdue» уходит на сервер параметром statuses + сброс на 1 страницу', async () => {
const wrapper = await factory();
const api = await import('../../resources/js/api/admin');
const vm = wrapper.vm as unknown as { filterStatuses: string[]; page: number };
vm.page = 3;
vm.filterStatuses = ['overdue'];
await wrapper.vm.$nextTick();
await flushPromises();
expect(
vi.mocked(api.listAdminTenants).mock.calls.some((c) => c[0]?.statuses === 'overdue' && c[0]?.offset === 0),
).toBe(true);
expect(vm.page).toBe(1);
});
it('фильтр по тарифу «Pro» уходит на сервер параметром tariffs', async () => {
const wrapper = await factory();
const api = await import('../../resources/js/api/admin');
const vm = wrapper.vm as unknown as { filterTariffs: string[] };
vm.filterTariffs = ['Pro'];
await wrapper.vm.$nextTick();
await flushPromises();
expect(vi.mocked(api.listAdminTenants).mock.calls.some((c) => c[0]?.tariffs === 'Pro')).toBe(true);
});
it('пагинация: goPage шлёт offset и грузит страницу', async () => {
const wrapper = await factory();
const api = await import('../../resources/js/api/admin');
const vm = wrapper.vm as unknown as { goPage: (p: number) => void; perPage: number };
vm.goPage(3);
await flushPromises();
// offset = (3-1) * perPage
expect(vi.mocked(api.listAdminTenants).mock.calls.some((c) => c[0]?.offset === 2 * vm.perPage)).toBe(true);
});
it('clearFilters сбрасывает оба фильтра + кнопка «Сбросить» появляется только когда фильтры активны', async () => {
const wrapper = await factory();
const vm = wrapper.vm as unknown as {
filterStatuses: string[];
filterTariffs: string[];
clearFilters: () => void;
};
// Default — кнопки нет
expect(wrapper.find('[data-testid="clear-filters-btn"]').exists()).toBe(false);
vm.filterStatuses = ['active'];
await wrapper.vm.$nextTick();
expect(wrapper.find('[data-testid="clear-filters-btn"]').exists()).toBe(true);
vm.clearFilters();
await wrapper.vm.$nextTick();
expect(vm.filterStatuses).toEqual([]);
expect(vm.filterTariffs).toEqual([]);
});
it('каждая строка имеет impersonate-кнопку (mdi-account-switch) с уникальным data-testid', async () => {
const wrapper = await factory();
MOCK_TENANTS.forEach((t) => {
const btn = wrapper.find(`[data-testid="impersonate-btn-${t.id}"]`);
expect(btn.exists()).toBe(true);
});
});
it('impersonate-кнопка disabled для suspended-тенанта (Оконные системы РФ id=105)', async () => {
const wrapper = await factory();
const suspendedBtn = wrapper.find('[data-testid="impersonate-btn-105"]');
expect(suspendedBtn.exists()).toBe(true);
expect(suspendedBtn.attributes('disabled')).toBeDefined();
});
it('click на impersonate-кнопке открывает ImpersonationDialog с правильным tenant', async () => {
const wrapper = await factory();
const dialogStub = wrapper.findComponent({ name: 'ImpersonationDialog' });
expect(dialogStub.exists()).toBe(true);
expect(dialogStub.props('modelValue')).toBe(false);
expect(dialogStub.props('tenant')).toBeNull();
await wrapper.find('[data-testid="impersonate-btn-42"]').trigger('click');
await wrapper.vm.$nextTick();
expect(dialogStub.props('modelValue')).toBe(true);
expect(dialogStub.props('tenant')).toMatchObject({ id: 42, name: 'Окна Москва ООО' });
expect(dialogStub.props('requestedBy')).toBe(1);
});
it('API reject → tenantsState пустой + fetch-error-alert виден', async () => {
const adminApi = await import('../../resources/js/api/admin');
vi.mocked(adminApi.listAdminTenants).mockRejectedValueOnce(new Error('Network error'));
const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/admin/tenants', name: 'admin-tenants', component: AdminTenantsView },
{ path: '/admin/tenants/:code', name: 'admin-tenant-detail', component: { template: '<div />' } },
],
});
await router.push('/admin/tenants');
await router.isReady();
const wrapper = mount(AdminTenantsView, {
global: { plugins: [createVuetify(), router], stubs: { ImpersonationDialog: true } },
});
await flushPromises();
const vm = wrapper.vm as unknown as { fetchError: boolean; tenantsState: unknown[] };
expect(vm.fetchError).toBe(true);
expect(vm.tenantsState.length).toBe(0);
expect(wrapper.find('[data-testid="fetch-error-alert"]').exists()).toBe(true);
});
});