357 lines
14 KiB
TypeScript
357 lines
14 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,
|
|||
|
|
} 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');
|
|||
|
|
});
|
|||
|
|
});
|