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

327 lines
14 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',
aliveness_state: 'cooling',
},
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',
},
],
aliveness: {
state: 'cooling',
last_login_at: '2026-08-06T03:23:12+00:00',
days_since_login: 6,
last_login_ip: '5.166.40.65',
last_login_user_agent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/126.0 Safari/537.36',
days: [{ date: '2026-08-06', visits: 1 }],
counters: { visits_30d: 2, active_days_30d: 2, actions_30d: 1, failed_logins_30d: 0 },
},
activity: {
items: [
{
id: 'ops-1',
at: '2026-08-05T07:34:48+00:00',
kind: 'project',
event: 'project.created',
actor: 'admin@okna-moscow.ru',
subject: 'Окна ЕКБ',
amount_rub: null,
bulk_count: 0,
},
],
offset: 0,
limit: 50,
has_more: false,
},
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');
});
// 07.08.2026: вкладка больше не показывает коды событий. Она отвечает
// «жив ли клиент»: вердикт наверху, а ниже дела человеческой фразой.
it('переключение таба Активность показывает вердикт и дела по-русски', 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('Не заходил 6 дней');
expect(text).toContain('создал проект «Окна ЕКБ»');
expect(text).toContain('admin@okna-moscow.ru');
expect(text).not.toContain('project.created'); // кода события на экране нет
});
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('без оплаты');
});
it('на вкладке «Пользователи» колонка называется «Последний вход»', async () => {
(adminApi.getAdminTenantDetail as ReturnType<typeof vi.fn>).mockResolvedValue(makeApiDetail());
const wrapper = await mountDetail('okna-moscow');
await wrapper.find('[data-testid="tab-users"]').trigger('click');
await flushPromises();
expect(wrapper.text()).toContain('Последний вход');
expect(wrapper.text()).not.toContain('Последняя активность');
});
it('на вкладке «Активность» показывает вердикт, а не журнал сделок', async () => {
(adminApi.getAdminTenantDetail as ReturnType<typeof vi.fn>).mockResolvedValue(makeApiDetail());
const wrapper = await mountDetail('okna-moscow');
await wrapper.find('[data-testid="tab-activity"]').trigger('click');
await flushPromises();
expect(wrapper.find('[data-testid="pane-activity"]').exists()).toBe(true);
expect(wrapper.text()).toContain('Не заходил 6 дней');
expect(wrapper.text()).toContain('создал проект «Окна ЕКБ»');
expect(wrapper.text()).not.toContain('deal.created');
});
});