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

297 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 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: 'in_progress' },
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 → in_progress'); // 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('без оплаты');
});
});