c5c0e76950
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>
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');
|
||
});
|
||
});
|