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