297 lines
12 KiB
TypeScript
297 lines
12 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 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('без оплаты');
|
||
});
|
||
});
|