Files
portal/app/tests/Frontend/admin-api.spec.ts
T
Дмитрий c5c0e76950 test(coverage): close F-COV-01/02/03 — ReminderDialog + AdminLayout + api/admin
Closes Audit #2+#3 P2 carryforward triplet (low-coverage files at risk
of silent regression).

Coverage results (Vitest --coverage --coverage.include per-file):

| File | Stmts before | Stmts now | Δ |
|---|---|---|---|
| ReminderDialog.vue | 0% | 95.38% | +95 pp |
| AdminLayout.vue | 9.09% | 95.45% | +86 pp |
| api/admin.ts | 11.53% | 100% | +88 pp |

Branches/Funcs deltas (subagent reports):
- ReminderDialog: Branch 0→97.56%, Funcs 0→85.71%, Lines 0→96.61%
- AdminLayout: Branch 0→90%, Funcs 0→90%, Lines 9.09→94.73%
- api/admin: Branch 0→100%, Funcs 27.27→100%, Lines 11.53→100%

Approach: TDD via @vue/test-utils + Vuetify global plugin + vi.mock for
store/api. Three parallel subagents (general-purpose), each focused on
single target — no production code changes, only test infrastructure.

Coverage areas:
- ReminderDialog (19 specs): rendering, watch(dialogOpen) populate/reset,
  submit create-mode happy + 3 errors, submit edit-mode happy + 1 error,
  cancel, common validation paths
- AdminLayout (16 specs): brand block, 5 nav items, count badges (142/3),
  breadcrumb per route (5 cases + fallback), userInitials computed (4
  cases incl. fallback), userShortName (4 cases), handleLogout call-order,
  active state, aria-label
- api/admin (18 specs): 11 exported functions × happy-path; 2 encodeURI
  edge cases; 4 ensureCsrfCookie call-order verifications via
  invocationCallOrder; 2 error-propagation tests

Verification (full sweep after merge):
- Vitest: 91 files / 736 passed / 3 skipped / 0 failed (+3 files, +53 specs
  from Audit #3 baseline 88/683/3sk)
- Pest --parallel: 742/739/3sk/0 (identical to baseline, 0 regressions)
- Vite build: 2.03s
- vue-tsc: 0 errors
- ESLint: 0 errors

Plan: docs/superpowers/plans/2026-05-14-audit3-deferred-fixes.md Task 3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 08:37:26 +03:00

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