import { describe, it, expect, beforeEach, vi } from 'vitest'; vi.mock('../../resources/js/api/client', () => ({ apiClient: { get: vi.fn(), post: vi.fn(), put: vi.fn(), patch: vi.fn(), delete: vi.fn(), }, ensureCsrfCookie: vi.fn(), })); import { impersonationInit, impersonationVerify, impersonationEnd, impersonationActive, impersonationRecent, listAdminTenants, getAdminTenantDetail, listAdminBilling, listAdminIncidents, listSystemSettings, updateSystemSetting, listAdminTariffPlans, updateTenantStatus, refundTenant, changeTenantTariff, getAdminIncidentDetail, notifyIncidentRkn, } from '../../resources/js/api/admin'; import { apiClient, ensureCsrfCookie } from '../../resources/js/api/client'; const FAKE_TENANT = { id: 1, subdomain: 'demo', organization_name: 'Demo Org', contact_email: 'demo@x.ru', status: 'active', balance_rub: '1000.00', balance_leads: 0, is_trial: false, last_activity_at: null, tariff_id: 1, tariff_name: 'basic', mrr_rub: '5000.00', desired_daily_numbers: 10, chargeback_unrecovered_rub: '0.00', created_at: '2026-05-01T00:00:00Z', }; describe('api/admin', () => { beforeEach(() => vi.clearAllMocks()); it('impersonationInit() POSTs /api/admin/impersonation/init + ensureCsrfCookie ПЕРЕД post', async () => { vi.mocked(apiClient.post).mockResolvedValue({ data: { token_id: 42, expires_at: '2026-05-14T13:00:00Z', sent_to_email: 'admin@x.ru', _dev_plain_code: '123456', }, }); const payload = { tenant_id: 1, requested_by: 9, reason: 'Investigating webhook delivery failure for tenant', }; const r = await impersonationInit(payload); expect(ensureCsrfCookie).toHaveBeenCalledOnce(); expect(apiClient.post).toHaveBeenCalledWith('/api/admin/impersonation/init', payload); // verify ensureCsrfCookie вызван ДО apiClient.post const csrfOrder = vi.mocked(ensureCsrfCookie).mock.invocationCallOrder[0]; const postOrder = vi.mocked(apiClient.post).mock.invocationCallOrder[0]; expect(csrfOrder).toBeLessThan(postOrder); expect(r.token_id).toBe(42); expect(r._dev_plain_code).toBe('123456'); }); it('impersonationVerify() POSTs /api/admin/impersonation/verify + ensureCsrfCookie ПЕРЕД post', async () => { vi.mocked(apiClient.post).mockResolvedValue({ data: { token_id: 42, tenant_id: 1, used_at: '2026-05-14T12:30:00Z', message: 'verified', }, }); const payload = { token_id: 42, code: '123456' }; const r = await impersonationVerify(payload); expect(ensureCsrfCookie).toHaveBeenCalledOnce(); expect(apiClient.post).toHaveBeenCalledWith('/api/admin/impersonation/verify', payload); const csrfOrder = vi.mocked(ensureCsrfCookie).mock.invocationCallOrder[0]; const postOrder = vi.mocked(apiClient.post).mock.invocationCallOrder[0]; expect(csrfOrder).toBeLessThan(postOrder); expect(r.message).toBe('verified'); }); it('impersonationEnd() POSTs /api/admin/impersonation/end с {token_id} + ensureCsrfCookie ПЕРЕД post', async () => { vi.mocked(apiClient.post).mockResolvedValue({ data: { token_id: 42, session_ended_at: '2026-05-14T13:00:00Z', message: 'ended', }, }); const r = await impersonationEnd(42); expect(ensureCsrfCookie).toHaveBeenCalledOnce(); expect(apiClient.post).toHaveBeenCalledWith('/api/admin/impersonation/end', { token_id: 42 }); const csrfOrder = vi.mocked(ensureCsrfCookie).mock.invocationCallOrder[0]; const postOrder = vi.mocked(apiClient.post).mock.invocationCallOrder[0]; expect(csrfOrder).toBeLessThan(postOrder); expect(r.token_id).toBe(42); }); it('impersonationActive() GET /api/admin/impersonation/active + unwraps data.sessions', async () => { const session = { token_id: 1, tenant_id: 1, tenant_name: 'Demo', requested_by: 9, reason: 'r', sent_to_email: 'a@x.ru', used_at: '2026-05-14T12:00:00Z', expires_at: '2026-05-14T13:00:00Z', }; vi.mocked(apiClient.get).mockResolvedValue({ data: { sessions: [session] } }); const r = await impersonationActive(); expect(apiClient.get).toHaveBeenCalledWith('/api/admin/impersonation/active'); expect(r).toHaveLength(1); expect(r[0].token_id).toBe(1); }); it('impersonationRecent() GET /api/admin/impersonation/recent + unwraps data.sessions', async () => { const session = { token_id: 2, tenant_id: 1, tenant_name: 'Demo', requested_by: 9, reason: 'r', used_at: '2026-05-14T10:00:00Z', session_ended_at: '2026-05-14T11:00:00Z', duration_seconds: 3600, }; vi.mocked(apiClient.get).mockResolvedValue({ data: { sessions: [session] } }); const r = await impersonationRecent(); expect(apiClient.get).toHaveBeenCalledWith('/api/admin/impersonation/recent'); expect(r[0].duration_seconds).toBe(3600); }); it('listAdminTenants() без params → default {}', async () => { vi.mocked(apiClient.get).mockResolvedValue({ data: { tenants: [], total: 0, limit: 50, offset: 0, stats: { total: 0, active: 0, trial: 0, overdue: 0 }, }, }); await listAdminTenants(); expect(apiClient.get).toHaveBeenCalledWith('/api/admin/tenants', { params: {} }); }); it('listAdminTenants({status,search,limit,offset}) → передаёт params', async () => { vi.mocked(apiClient.get).mockResolvedValue({ data: { tenants: [FAKE_TENANT], total: 1, limit: 25, offset: 0, stats: { total: 1, active: 1, trial: 0, overdue: 0 }, }, }); const params = { status: 'active', search: 'demo', limit: 25, offset: 0 }; const r = await listAdminTenants(params); expect(apiClient.get).toHaveBeenCalledWith('/api/admin/tenants', { params }); expect(r.tenants).toHaveLength(1); expect(r.tenants[0].subdomain).toBe('demo'); }); it('getAdminTenantDetail() GET /api/admin/tenants/{subdomain} с обычным slug', async () => { vi.mocked(apiClient.get).mockResolvedValue({ data: { tenant: FAKE_TENANT, users: [], projects: [], balance_history: [], activity: [], metrics: { leads_today: 0, leads_this_week: 0, leads_this_month: 0, avg_lead_cost_rub: null, runway_days: null, }, }, }); const r = await getAdminTenantDetail('demo'); expect(apiClient.get).toHaveBeenCalledWith('/api/admin/tenants/demo'); expect(r.tenant.id).toBe(1); }); it('getAdminTenantDetail() применяет encodeURIComponent к slug со спецсимволами', async () => { vi.mocked(apiClient.get).mockResolvedValue({ data: { tenant: FAKE_TENANT, users: [], projects: [], balance_history: [], activity: [], metrics: { leads_today: 0, leads_this_week: 0, leads_this_month: 0, avg_lead_cost_rub: null, runway_days: null, }, }, }); await getAdminTenantDetail('tenant a&b'); expect(apiClient.get).toHaveBeenCalledWith('/api/admin/tenants/tenant%20a%26b'); }); it('listAdminBilling() без аргумента → search=""', async () => { vi.mocked(apiClient.get).mockResolvedValue({ data: { tenants: [], summary: { total_mrr_rub: '0.00', monthly_revenue_rub: '0.00', overdue_count: 0, refunds_count_30d: 0, }, }, }); await listAdminBilling(); expect(apiClient.get).toHaveBeenCalledWith('/api/admin/billing', { params: { search: '' } }); }); it('listAdminBilling("query") → передаёт search в params', async () => { vi.mocked(apiClient.get).mockResolvedValue({ data: { tenants: [], summary: { total_mrr_rub: '100.00', monthly_revenue_rub: '500.00', overdue_count: 1, refunds_count_30d: 0, }, }, }); const r = await listAdminBilling('demo'); expect(apiClient.get).toHaveBeenCalledWith('/api/admin/billing', { params: { search: 'demo' } }); expect(r.summary.overdue_count).toBe(1); }); it('listAdminIncidents() без params → default {}', async () => { vi.mocked(apiClient.get).mockResolvedValue({ data: { incidents: [], total: 0, limit: 50, offset: 0, summary: { open: 0, investigating: 0, rkn_pending: 0, total_unresolved: 0 }, }, }); await listAdminIncidents(); expect(apiClient.get).toHaveBeenCalledWith('/api/admin/incidents', { params: {} }); }); it('listAdminIncidents({type,severity,unresolved_only,limit,offset}) → передаёт params', async () => { vi.mocked(apiClient.get).mockResolvedValue({ data: { incidents: [], total: 0, limit: 10, offset: 0, summary: { open: 0, investigating: 0, rkn_pending: 0, total_unresolved: 0 }, }, }); const params = { type: 'pdn_leak', severity: 'high', unresolved_only: true, limit: 10, offset: 0 }; await listAdminIncidents(params); expect(apiClient.get).toHaveBeenCalledWith('/api/admin/incidents', { params }); }); it('listSystemSettings() GET /api/admin/system-settings + unwraps data.settings', async () => { const settings = [ { key: 'lead.price_default', value: '500', type: 'decimal' as const, description: 'default', updated_at: '2026-05-14T00:00:00Z', updated_by: 1, }, ]; vi.mocked(apiClient.get).mockResolvedValue({ data: { settings } }); const r = await listSystemSettings(); expect(apiClient.get).toHaveBeenCalledWith('/api/admin/system-settings'); expect(r).toHaveLength(1); expect(r[0].key).toBe('lead.price_default'); }); it('updateSystemSetting() PUTs /api/admin/system-settings/{key} + encodeURIComponent + ensureCsrfCookie ПЕРЕД put', async () => { vi.mocked(apiClient.put).mockResolvedValue({ data: { key: 'lead.price_default', value: '600', previous_value: '500', updated_at: '2026-05-14T12:00:00Z', message: 'updated', }, }); const payload = { value: '600', reason: 'Quarterly pricing adjustment for Q2 according to bizdev', admin_user_id: 1, }; const r = await updateSystemSetting('lead.price_default', payload); expect(ensureCsrfCookie).toHaveBeenCalledOnce(); expect(apiClient.put).toHaveBeenCalledWith( '/api/admin/system-settings/lead.price_default', payload, ); const csrfOrder = vi.mocked(ensureCsrfCookie).mock.invocationCallOrder[0]; const putOrder = vi.mocked(apiClient.put).mock.invocationCallOrder[0]; expect(csrfOrder).toBeLessThan(putOrder); expect(r.previous_value).toBe('500'); }); it('updateSystemSetting() encodeURIComponent применяется к ключу со спецсимволами', async () => { vi.mocked(apiClient.put).mockResolvedValue({ data: { key: 'a/b', value: 'v', previous_value: 'p', updated_at: '2026-05-14T12:00:00Z', message: 'ok', }, }); const payload = { value: 'v', reason: 'r'.repeat(40), admin_user_id: 1 }; await updateSystemSetting('a/b', payload); expect(apiClient.put).toHaveBeenCalledWith('/api/admin/system-settings/a%2Fb', payload); }); // === Error propagation === it('impersonationInit() пробрасывает ошибку из apiClient.post (не глотает)', async () => { vi.mocked(apiClient.post).mockRejectedValueOnce(new Error('Network')); await expect( impersonationInit({ tenant_id: 1, requested_by: 9, reason: 'r'.repeat(30) }), ).rejects.toThrow('Network'); expect(ensureCsrfCookie).toHaveBeenCalledOnce(); }); it('listAdminTenants() пробрасывает ошибку из apiClient.get (не глотает)', async () => { vi.mocked(apiClient.get).mockRejectedValueOnce(new Error('500 Server Error')); await expect(listAdminTenants({ status: 'active' })).rejects.toThrow('500 Server Error'); }); // === Sprint 3D G4: billing row-actions === it('listAdminTariffPlans() GET /api/admin/billing/tariff-plans + unwraps data.plans', async () => { const plans = [{ id: 1, name: 'Базовый', price_monthly: '990.00' }]; vi.mocked(apiClient.get).mockResolvedValue({ data: { plans } }); const r = await listAdminTariffPlans(); expect(apiClient.get).toHaveBeenCalledWith('/api/admin/billing/tariff-plans'); expect(r).toHaveLength(1); expect(r[0].name).toBe('Базовый'); }); it('updateTenantStatus() PATCH /api/admin/billing/tenants/{id}/status + ensureCsrfCookie', async () => { vi.mocked(apiClient.patch).mockResolvedValue({ data: { id: 5, status: 'suspended' } }); const r = await updateTenantStatus(5, 'suspended', 'Нарушение условий'); expect(ensureCsrfCookie).toHaveBeenCalledOnce(); expect(apiClient.patch).toHaveBeenCalledWith('/api/admin/billing/tenants/5/status', { status: 'suspended', reason: 'Нарушение условий', }); expect(r.status).toBe('suspended'); }); it('refundTenant() POST /api/admin/billing/tenants/{id}/refund + ensureCsrfCookie', async () => { vi.mocked(apiClient.post).mockResolvedValue({ data: { id: 3, balance_rub: '5000.00', transaction_id: 101 }, }); const r = await refundTenant(3, 1000, 'Возврат по заявке'); expect(ensureCsrfCookie).toHaveBeenCalledOnce(); expect(apiClient.post).toHaveBeenCalledWith('/api/admin/billing/tenants/3/refund', { amount_rub: 1000, reason: 'Возврат по заявке', }); expect(r.transaction_id).toBe(101); }); it('changeTenantTariff() PATCH /api/admin/billing/tenants/{id}/tariff + ensureCsrfCookie', async () => { vi.mocked(apiClient.patch).mockResolvedValue({ data: { id: 2, tariff_id: 3, tariff_name: 'Команда' }, }); const r = await changeTenantTariff(2, 3, 'Апгрейд по договорённости'); expect(ensureCsrfCookie).toHaveBeenCalledOnce(); expect(apiClient.patch).toHaveBeenCalledWith('/api/admin/billing/tenants/2/tariff', { tariff_id: 3, reason: 'Апгрейд по договорённости', }); expect(r.tariff_name).toBe('Команда'); }); // === Sprint 3D G5/G6: incident detail + РКН-notify === it('getAdminIncidentDetail() GET /api/admin/incidents/{id} + unwraps data.incident', async () => { const incident = { id: 7, incident_id: 'INC-2026-0516-0007', type: 'data_breach', severity: 'high', summary: 'Тест', root_cause: null, postmortem_url: null, started_at: '2026-05-16T10:00:00Z', detected_at: '2026-05-16T10:30:00Z', resolved_at: null, status: 'investigating', affected_tenants: [], affected_users_count: null, notification_sent_at: null, rkn_notified: false, rkn_notified_at: null, rkn_deadline_at: null, created_by_admin: null, closed_by_admin: null, created_at: null, updated_at: null, }; vi.mocked(apiClient.get).mockResolvedValue({ data: { incident } }); const r = await getAdminIncidentDetail(7); expect(apiClient.get).toHaveBeenCalledWith('/api/admin/incidents/7'); expect(r.incident_id).toBe('INC-2026-0516-0007'); expect(r.id).toBe(7); }); it('notifyIncidentRkn() POST /api/admin/incidents/{id}/rkn-notify + ensureCsrfCookie + unwraps data.incident', async () => { const incident = { id: 7, incident_id: 'INC-2026-0516-0007', type: 'data_breach', severity: 'high', summary: 'Тест', root_cause: null, postmortem_url: null, started_at: '2026-05-16T10:00:00Z', detected_at: '2026-05-16T10:30:00Z', resolved_at: null, status: 'investigating', affected_tenants: [], affected_users_count: null, notification_sent_at: '2026-05-16T11:00:00Z', rkn_notified: true, rkn_notified_at: '2026-05-16T11:00:00Z', rkn_deadline_at: '2026-05-17T10:30:00Z', created_by_admin: null, closed_by_admin: null, created_at: null, updated_at: null, }; vi.mocked(apiClient.post).mockResolvedValue({ data: { incident } }); const r = await notifyIncidentRkn(7); expect(ensureCsrfCookie).toHaveBeenCalledOnce(); expect(apiClient.post).toHaveBeenCalledWith('/api/admin/incidents/7/rkn-notify', {}); expect(r.rkn_notified).toBe(true); expect(r.rkn_notified_at).toBe('2026-05-16T11:00:00Z'); }); });