Files
portal/app/tests/Frontend/admin-api.spec.ts
T

476 lines
19 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, 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');
});
});