Files
portal/app/tests/Frontend/AdminTenantDetailView.spec.ts
T

297 lines
12 KiB
TypeScript
Raw Normal View History

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 AdminTenantDetailView from '../../resources/js/views/admin/AdminTenantDetailView.vue';
import type { AdminTenantDetailResponse } 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,
getAdminTenantDetail: vi.fn(),
};
});
const adminApi = await import('../../resources/js/api/admin');
beforeEach(() => {
vi.clearAllMocks();
});
function makeApiDetail(overrides: Partial<AdminTenantDetailResponse> = {}): AdminTenantDetailResponse {
return {
tenant: {
id: 42,
subdomain: 'okna-moscow',
organization_name: 'Окна Москва ООО',
contact_email: 'admin@okna-moscow.ru',
status: 'active',
balance_rub: '14250.00',
balance_leads: 5,
is_trial: false,
last_activity_at: new Date(Date.now() - 30 * 60_000).toISOString(),
tariff_id: 2,
tariff_name: 'Команда',
mrr_rub: '990.00',
desired_daily_numbers: 12,
chargeback_unrecovered_rub: '0.00',
created_at: '2025-09-12T00:00:00+00:00',
},
users: [
{
id: 1,
email: 'admin@okna-moscow.ru',
first_name: 'Алексей',
last_name: 'Петров',
is_active: true,
totp_enabled: true,
last_active_at: new Date().toISOString(),
last_login_at: new Date().toISOString(),
},
{
id: 2,
email: 'ivan@okna-moscow.ru',
first_name: 'Иван',
last_name: 'П.',
is_active: true,
totp_enabled: false,
last_active_at: new Date().toISOString(),
last_login_at: new Date().toISOString(),
},
],
projects: [
{
id: 1,
name: 'Натяжные потолки',
tag: 'natyazhnye-potolki',
is_active: true,
daily_limit_target: 8,
suppliers_count: 3,
leads_today: 6,
},
],
balance_history: [
{
id: 347,
type: 'topup',
amount_rub: '5000.00',
amount_leads: 0,
balance_rub_after: '14250.00',
description: 'Пополнение ЮKassa',
created_at: '2026-05-09T09:14:00+00:00',
},
{
id: 346,
type: 'lead_charge',
amount_rub: '-1850.00',
amount_leads: -1,
balance_rub_after: '9250.00',
description: 'Лид Анна Соколова',
created_at: '2026-05-09T08:42:00+00:00',
},
],
activity: [
{
id: 1,
event: 'webhook.received',
deal_id: 4471,
actor_email: null,
context: null,
created_at: '2026-05-09T08:42:00+00:00',
},
{
id: 2,
event: 'deal.status_changed',
deal_id: 4470,
actor_email: 'ivan@okna-moscow.ru',
context: { from: 'viewed', to: 'worked' },
created_at: '2026-05-09T07:18:00+00:00',
},
],
metrics: {
leads_today: 11,
leads_this_week: 42,
leads_this_month: 187,
avg_lead_cost_rub: 1980,
runway_days: 50,
},
...overrides,
};
}
const buildRouter = (subdomain: string) => {
const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/admin/tenants', name: 'admin-tenants', component: { template: '<div />' } },
{
path: '/admin/tenants/:code',
name: 'admin-tenant-detail',
component: AdminTenantDetailView,
},
],
});
return router.push({ name: 'admin-tenant-detail', params: { code: subdomain } }).then(() => router);
};
const mountDetail = async (subdomain: string) => {
const router = await buildRouter(subdomain);
await router.isReady();
const wrapper = mount(AdminTenantDetailView, {
global: {
plugins: [createVuetify(), router],
stubs: { ImpersonationDialog: true },
},
});
await flushPromises();
return wrapper;
};
describe('AdminTenantDetailView.vue (API integration)', () => {
it('вызывает getAdminTenantDetail с subdomain из route', async () => {
(adminApi.getAdminTenantDetail as ReturnType<typeof vi.fn>).mockResolvedValue(makeApiDetail());
await mountDetail('okna-moscow');
expect(adminApi.getAdminTenantDetail).toHaveBeenCalledWith('okna-moscow');
});
it('рендерит карточку с organization_name + tariff_name', async () => {
(adminApi.getAdminTenantDetail as ReturnType<typeof vi.fn>).mockResolvedValue(makeApiDetail());
const wrapper = await mountDetail('okna-moscow');
const text = wrapper.text();
expect(text).toContain('Окна Москва ООО');
expect(text).toContain('okna-moscow'); // code = subdomain
expect(text).toContain('Команда');
});
it('показывает 4 KPI cards', async () => {
(adminApi.getAdminTenantDetail as ReturnType<typeof vi.fn>).mockResolvedValue(makeApiDetail());
const wrapper = await mountDetail('okna-moscow');
['kpi-balance', 'kpi-mrr', 'kpi-leads', 'kpi-avg-cost'].forEach((id) => {
expect(wrapper.find(`[data-testid="${id}"]`).exists()).toBe(true);
});
});
it('KPI Лиды показывает leads_today / desired_daily_numbers', async () => {
(adminApi.getAdminTenantDetail as ReturnType<typeof vi.fn>).mockResolvedValue(makeApiDetail());
const wrapper = await mountDetail('okna-moscow');
const leadsCard = wrapper.find('[data-testid="kpi-leads"]');
expect(leadsCard.text()).toContain('11 / 12');
});
it('по умолчанию активен таб «Финансы» с balance_history', async () => {
(adminApi.getAdminTenantDetail as ReturnType<typeof vi.fn>).mockResolvedValue(makeApiDetail());
const wrapper = await mountDetail('okna-moscow');
expect(wrapper.find('[data-testid="pane-finance"]').exists()).toBe(true);
expect(wrapper.text()).toContain('Пополнение ЮKassa');
});
it('переключение таба Пользователи показывает users', async () => {
(adminApi.getAdminTenantDetail as ReturnType<typeof vi.fn>).mockResolvedValue(makeApiDetail());
const wrapper = await mountDetail('okna-moscow');
const vm = wrapper.vm as unknown as { activeTab: string };
vm.activeTab = 'users';
await wrapper.vm.$nextTick();
expect(wrapper.find('[data-testid="pane-users"]').exists()).toBe(true);
const text = wrapper.text();
expect(text).toContain('admin@okna-moscow.ru');
expect(text).toContain('Алексей Петров');
});
it('переключение таба Проекты показывает projects', async () => {
(adminApi.getAdminTenantDetail as ReturnType<typeof vi.fn>).mockResolvedValue(makeApiDetail());
const wrapper = await mountDetail('okna-moscow');
const vm = wrapper.vm as unknown as { activeTab: string };
vm.activeTab = 'projects';
await wrapper.vm.$nextTick();
expect(wrapper.find('[data-testid="pane-projects"]').exists()).toBe(true);
const text = wrapper.text();
expect(text).toContain('Натяжные потолки');
expect(text).toContain('natyazhnye-potolki');
});
it('переключение таба Активность показывает event-коды + actor + summary', async () => {
(adminApi.getAdminTenantDetail as ReturnType<typeof vi.fn>).mockResolvedValue(makeApiDetail());
const wrapper = await mountDetail('okna-moscow');
const vm = wrapper.vm as unknown as { activeTab: string };
vm.activeTab = 'activity';
await wrapper.vm.$nextTick();
expect(wrapper.find('[data-testid="pane-activity"]').exists()).toBe(true);
const text = wrapper.text();
expect(text).toContain('webhook.received');
expect(text).toContain('deal.status_changed');
expect(text).toContain('ivan@okna-moscow.ru'); // actor_email
expect(text).toContain('viewed → worked'); // summary из context
});
it('кнопка «Войти как клиент» open impersonationDialog', async () => {
(adminApi.getAdminTenantDetail as ReturnType<typeof vi.fn>).mockResolvedValue(makeApiDetail());
const wrapper = await mountDetail('okna-moscow');
const vm = wrapper.vm as unknown as { impersonationOpen: boolean };
const btn = wrapper.find('[data-testid="impersonate-btn"]');
expect(btn.exists()).toBe(true);
await btn.trigger('click');
await wrapper.vm.$nextTick();
expect(vm.impersonationOpen).toBe(true);
});
it('suspended-тенант имеет disabled impersonate-кнопку', async () => {
const data = makeApiDetail({
tenant: { ...makeApiDetail().tenant, status: 'suspended' },
});
(adminApi.getAdminTenantDetail as ReturnType<typeof vi.fn>).mockResolvedValue(data);
const wrapper = await mountDetail('okna-moscow');
const btn = wrapper.find('[data-testid="impersonate-btn"]');
expect(btn.exists()).toBe(true);
expect(btn.attributes('disabled')).toBeDefined();
});
it('404 от API → fallback с tenant-not-found', async () => {
(adminApi.getAdminTenantDetail as ReturnType<typeof vi.fn>).mockRejectedValue({
isAxiosError: true,
response: { status: 404, data: { message: 'Тенант не найден.' } },
});
const wrapper = await mountDetail('unknown-tenant');
expect(wrapper.find('[data-testid="tenant-not-found"]').exists()).toBe(true);
expect(wrapper.text()).toContain('unknown-tenant');
expect(wrapper.text()).toContain('не найден');
});
it('500 от API → fetch-error с кнопкой Повторить', async () => {
(adminApi.getAdminTenantDetail as ReturnType<typeof vi.fn>).mockRejectedValue({
isAxiosError: true,
response: { status: 500, data: { message: 'Backend сломан' } },
});
const wrapper = await mountDetail('okna-moscow');
expect(wrapper.find('[data-testid="tenant-fetch-error"]').exists()).toBe(true);
expect(wrapper.text()).toContain('Backend сломан');
});
it('overdue (negative balance) — статус Просрочка', async () => {
const data = makeApiDetail({
tenant: {
...makeApiDetail().tenant,
balance_rub: '-500.00',
chargeback_unrecovered_rub: '500.00',
},
});
(adminApi.getAdminTenantDetail as ReturnType<typeof vi.fn>).mockResolvedValue(data);
const wrapper = await mountDetail('okna-moscow');
expect(wrapper.text()).toContain('Просрочка');
});
it('trial-тенант → tariff=Trial + mrr_rub скрыт (mrrRub null)', async () => {
const data = makeApiDetail({
tenant: {
...makeApiDetail().tenant,
is_trial: true,
mrr_rub: null,
},
});
(adminApi.getAdminTenantDetail as ReturnType<typeof vi.fn>).mockResolvedValue(data);
const wrapper = await mountDetail('okna-moscow');
expect(wrapper.text()).toContain('Trial');
expect(wrapper.text()).toContain('без оплаты');
});
});