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(); return { ...orig, getAdminTenantDetail: vi.fn(), }; }); const adminApi = await import('../../resources/js/api/admin'); beforeEach(() => { vi.clearAllMocks(); }); function makeApiDetail(overrides: Partial = {}): 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: '
' } }, { 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).mockResolvedValue(makeApiDetail()); await mountDetail('okna-moscow'); expect(adminApi.getAdminTenantDetail).toHaveBeenCalledWith('okna-moscow'); }); it('рендерит карточку с organization_name + tariff_name', async () => { (adminApi.getAdminTenantDetail as ReturnType).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).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).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).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).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).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).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).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).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).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).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).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).mockResolvedValue(data); const wrapper = await mountDetail('okna-moscow'); expect(wrapper.text()).toContain('Trial'); expect(wrapper.text()).toContain('без оплаты'); }); });