diff --git a/app/tests/Frontend/auth-api.spec.ts b/app/tests/Frontend/auth-api.spec.ts new file mode 100644 index 00000000..f5b3fa52 --- /dev/null +++ b/app/tests/Frontend/auth-api.spec.ts @@ -0,0 +1,142 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +vi.mock('../../resources/js/api/client', () => ({ + apiClient: { + get: vi.fn(), + post: vi.fn(), + patch: vi.fn(), + delete: vi.fn(), + }, + ensureCsrfCookie: vi.fn(), +})); + +import { + login, + register, + me, + logout, + verifyTwoFactor, + useRecoveryCode, + twoFactorInit, + twoFactorConfirm, + twoFactorDisable, + twoFactorRegenerateRecoveryCodes, + forgotPassword, + resetPassword, + updateNotificationPreferences, +} from '../../resources/js/api/auth'; +import { apiClient, ensureCsrfCookie } from '../../resources/js/api/client'; + +const FAKE_USER = { + id: 1, + email: 'demo@x.ru', + first_name: 'D', + last_name: 'U', + tenant_id: 1, + totp_enabled: false, + last_login_at: null, +}; + +describe('api/auth', () => { + beforeEach(() => vi.clearAllMocks()); + + it('login() POSTs /api/auth/login + calls ensureCsrfCookie + unwraps data', async () => { + vi.mocked(apiClient.post).mockResolvedValue({ data: { user: FAKE_USER, requires_2fa: false } }); + const result = await login({ email: 'a@x.ru', password: 'pw' }); + expect(ensureCsrfCookie).toHaveBeenCalledOnce(); + expect(apiClient.post).toHaveBeenCalledWith('/api/auth/login', { email: 'a@x.ru', password: 'pw' }); + expect(result.requires_2fa).toBe(false); + expect(result.user.email).toBe('demo@x.ru'); + }); + + it('register() POSTs /api/auth/register с accept-флагами', async () => { + vi.mocked(apiClient.post).mockResolvedValue({ data: { user: FAKE_USER, requires_2fa: false } }); + await register({ email: 'a@x.ru', password: 'pw', accept_offer: true, accept_pdn: true }); + expect(ensureCsrfCookie).toHaveBeenCalledOnce(); + expect(apiClient.post).toHaveBeenCalledWith('/api/auth/register', { + email: 'a@x.ru', + password: 'pw', + accept_offer: true, + accept_pdn: true, + }); + }); + + it('me() GETs /api/auth/me + unwraps data.user', async () => { + vi.mocked(apiClient.get).mockResolvedValue({ data: { user: FAKE_USER } }); + const result = await me(); + expect(apiClient.get).toHaveBeenCalledWith('/api/auth/me'); + expect(result.id).toBe(1); + }); + + it('logout() POSTs /api/auth/logout + ensureCsrfCookie', async () => { + vi.mocked(apiClient.post).mockResolvedValue({ data: {} }); + await logout(); + expect(ensureCsrfCookie).toHaveBeenCalledOnce(); + expect(apiClient.post).toHaveBeenCalledWith('/api/auth/logout'); + }); + + it('verifyTwoFactor() POSTs /api/auth/2fa/verify с code', async () => { + vi.mocked(apiClient.post).mockResolvedValue({ data: { user: FAKE_USER, requires_2fa: false } }); + await verifyTwoFactor('123456'); + expect(ensureCsrfCookie).toHaveBeenCalledOnce(); + expect(apiClient.post).toHaveBeenCalledWith('/api/auth/2fa/verify', { code: '123456' }); + }); + + it('useRecoveryCode() POSTs /api/auth/2fa/recovery-use с code + возвращает remaining', async () => { + vi.mocked(apiClient.post).mockResolvedValue({ + data: { user: FAKE_USER, requires_2fa: false, recovery_codes_remaining: 7 }, + }); + const r = await useRecoveryCode('xxxx-yyyy'); + expect(ensureCsrfCookie).toHaveBeenCalledOnce(); + expect(apiClient.post).toHaveBeenCalledWith('/api/auth/2fa/recovery-use', { code: 'xxxx-yyyy' }); + expect(r.recovery_codes_remaining).toBe(7); + }); + + it('twoFactorInit() POSTs /api/2fa/init + возвращает secret+qr_url', async () => { + vi.mocked(apiClient.post).mockResolvedValue({ data: { secret: 'JBSW', qr_url: 'otpauth://...' } }); + const r = await twoFactorInit(); + expect(ensureCsrfCookie).toHaveBeenCalledOnce(); + expect(apiClient.post).toHaveBeenCalledWith('/api/2fa/init'); + expect(r.secret).toBe('JBSW'); + }); + + it('twoFactorConfirm() POSTs /api/2fa/confirm с code', async () => { + vi.mocked(apiClient.post).mockResolvedValue({ data: { recovery_codes: ['a', 'b'], message: 'ok' } }); + await twoFactorConfirm('000000'); + expect(apiClient.post).toHaveBeenCalledWith('/api/2fa/confirm', { code: '000000' }); + }); + + it('twoFactorDisable() POSTs /api/2fa/disable с паролем', async () => { + vi.mocked(apiClient.post).mockResolvedValue({ data: { message: 'disabled' } }); + await twoFactorDisable('current-pw'); + expect(apiClient.post).toHaveBeenCalledWith('/api/2fa/disable', { password: 'current-pw' }); + }); + + it('twoFactorRegenerateRecoveryCodes() POSTs /api/2fa/regenerate-recovery-codes', async () => { + vi.mocked(apiClient.post).mockResolvedValue({ data: { recovery_codes: ['x', 'y', 'z'], message: 'ok' } }); + const r = await twoFactorRegenerateRecoveryCodes('pw'); + expect(apiClient.post).toHaveBeenCalledWith('/api/2fa/regenerate-recovery-codes', { password: 'pw' }); + expect(r.recovery_codes.length).toBe(3); + }); + + it('forgotPassword() POSTs /api/auth/forgot с email', async () => { + vi.mocked(apiClient.post).mockResolvedValue({ data: { message: 'sent' } }); + await forgotPassword('a@x.ru'); + expect(apiClient.post).toHaveBeenCalledWith('/api/auth/forgot', { email: 'a@x.ru' }); + }); + + it('resetPassword() POSTs /api/auth/reset-password с token+email+password', async () => { + vi.mocked(apiClient.post).mockResolvedValue({ data: { message: 'reset' } }); + const payload = { token: 't', email: 'a@x.ru', password: 'newpw', password_confirmation: 'newpw' }; + await resetPassword(payload); + expect(apiClient.post).toHaveBeenCalledWith('/api/auth/reset-password', payload); + }); + + it('updateNotificationPreferences() PATCH /api/auth/me/notification-preferences', async () => { + vi.mocked(apiClient.patch).mockResolvedValue({ data: { user: FAKE_USER } }); + const payload = { prefs: { new_lead: { inapp: true } }, sound_enabled: false }; + await updateNotificationPreferences(payload); + expect(ensureCsrfCookie).toHaveBeenCalledOnce(); + expect(apiClient.patch).toHaveBeenCalledWith('/api/auth/me/notification-preferences', payload); + }); +}); diff --git a/app/tests/Frontend/deals-api.spec.ts b/app/tests/Frontend/deals-api.spec.ts new file mode 100644 index 00000000..2001bfe9 --- /dev/null +++ b/app/tests/Frontend/deals-api.spec.ts @@ -0,0 +1,177 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +vi.mock('../../resources/js/api/client', () => ({ + apiClient: { + get: vi.fn(), + post: vi.fn(), + patch: vi.fn(), + delete: vi.fn(), + }, + ensureCsrfCookie: vi.fn(), +})); + +import { + createDeal, + bulkDeleteDeals, + bulkRestoreDeals, + updateDeal, + transitionDeals, + exportDeals, + exportDealsXlsx, + getDeal, + listDeals, + listManagers, + listProjects, +} from '../../resources/js/api/deals'; +import { apiClient, ensureCsrfCookie } from '../../resources/js/api/client'; + +const FAKE_DEAL = { + id: 1, + tenant_id: 1, + project_id: 1, + project_name: 'Test', + phone: '+79161234567', + contact_name: 'X', + status: 'new', + manager_id: null, + manager_name: null, + manager_initials: null, + received_at: null, + comment: null, + assigned_at: null, +}; + +describe('api/deals', () => { + beforeEach(() => vi.clearAllMocks()); + + it('createDeal() POSTs /api/deals + ensureCsrfCookie + unwraps data.deal', async () => { + vi.mocked(apiClient.post).mockResolvedValue({ data: { deal: FAKE_DEAL, message: 'ok' } }); + const r = await createDeal({ tenant_id: 1, project_name: 'P', phone: '+79161234567' }); + expect(ensureCsrfCookie).toHaveBeenCalledOnce(); + expect(apiClient.post).toHaveBeenCalledWith('/api/deals', { + tenant_id: 1, + project_name: 'P', + phone: '+79161234567', + }); + expect(r.id).toBe(1); + }); + + it('bulkDeleteDeals() DELETE /api/deals с data payload', async () => { + vi.mocked(apiClient.delete).mockResolvedValue({ data: { deleted: 3, requested: 4 } }); + const r = await bulkDeleteDeals({ tenant_id: 1, ids: [1, 2, 3, 4] }); + expect(ensureCsrfCookie).toHaveBeenCalledOnce(); + expect(apiClient.delete).toHaveBeenCalledWith('/api/deals', { data: { tenant_id: 1, ids: [1, 2, 3, 4] } }); + expect(r.deleted).toBe(3); + }); + + it('bulkRestoreDeals() POSTs /api/deals/restore', async () => { + vi.mocked(apiClient.post).mockResolvedValue({ data: { restored: 2, requested: 2 } }); + await bulkRestoreDeals({ tenant_id: 1, ids: [1, 2] }); + expect(ensureCsrfCookie).toHaveBeenCalledOnce(); + expect(apiClient.post).toHaveBeenCalledWith('/api/deals/restore', { tenant_id: 1, ids: [1, 2] }); + }); + + it('updateDeal() PATCH /api/deals/{id} c partial payload', async () => { + vi.mocked(apiClient.patch).mockResolvedValue({ data: { deal: FAKE_DEAL } }); + await updateDeal(42, { tenant_id: 1, comment: 'new' }); + expect(ensureCsrfCookie).toHaveBeenCalledOnce(); + expect(apiClient.patch).toHaveBeenCalledWith('/api/deals/42', { tenant_id: 1, comment: 'new' }); + }); + + it('transitionDeals() POSTs /api/deals/transition с status', async () => { + vi.mocked(apiClient.post).mockResolvedValue({ data: { updated: 5, requested: 5, status: 'won' } }); + const r = await transitionDeals({ tenant_id: 1, ids: [1, 2, 3, 4, 5], status: 'won' }); + expect(apiClient.post).toHaveBeenCalledWith('/api/deals/transition', { + tenant_id: 1, + ids: [1, 2, 3, 4, 5], + status: 'won', + }); + expect(r.status).toBe('won'); + }); + + it('exportDeals() POSTs /api/deals/export с format=csv + responseType=text', async () => { + vi.mocked(apiClient.post).mockResolvedValue({ data: 'id,phone\n1,+79..' }); + const r = await exportDeals({ tenant_id: 1, ids: [1] }); + expect(ensureCsrfCookie).toHaveBeenCalledOnce(); + expect(apiClient.post).toHaveBeenCalledWith( + '/api/deals/export', + { tenant_id: 1, ids: [1], format: 'csv' }, + { responseType: 'text' }, + ); + expect(typeof r).toBe('string'); + }); + + it('exportDealsXlsx() POSTs /api/deals/export с format=xlsx + responseType=blob', async () => { + const blob = new Blob(['fake'], { type: 'application/octet-stream' }); + vi.mocked(apiClient.post).mockResolvedValue({ data: blob }); + const r = await exportDealsXlsx({ tenant_id: 1, ids: [1, 2] }); + expect(apiClient.post).toHaveBeenCalledWith( + '/api/deals/export', + { tenant_id: 1, ids: [1, 2], format: 'xlsx' }, + { responseType: 'blob' }, + ); + expect(r).toBeInstanceOf(Blob); + }); + + it('getDeal() GET /api/deals/{id} с tenant_id param', async () => { + vi.mocked(apiClient.get).mockResolvedValue({ data: { deal: FAKE_DEAL, events: [] } }); + const r = await getDeal(7, 1); + expect(apiClient.get).toHaveBeenCalledWith('/api/deals/7', { params: { tenant_id: 1 } }); + expect(r.events).toEqual([]); + }); + + it('listDeals() GET /api/deals с маппингом camelCase→snake_case + onlyDeleted flag', async () => { + vi.mocked(apiClient.get).mockResolvedValue({ + data: { deals: [], total: 0, limit: 50, offset: 0 }, + }); + await listDeals({ + tenantId: 1, + statusIn: ['new', 'in_progress'], + projectId: 2, + managerId: 3, + search: 'q', + limit: 50, + offset: 0, + onlyDeleted: true, + }); + expect(apiClient.get).toHaveBeenCalledWith('/api/deals', { + params: { + tenant_id: 1, + status_in: ['new', 'in_progress'], + project_id: 2, + manager_id: 3, + search: 'q', + limit: 50, + offset: 0, + only_deleted: 'true', + }, + }); + }); + + it('listDeals() без onlyDeleted → only_deleted=undefined', async () => { + vi.mocked(apiClient.get).mockResolvedValue({ data: { deals: [], total: 0, limit: 50, offset: 0 } }); + await listDeals({ tenantId: 1 }); + expect(apiClient.get).toHaveBeenCalledWith( + '/api/deals', + expect.objectContaining({ params: expect.objectContaining({ only_deleted: undefined }) }), + ); + }); + + it('listManagers() GET /api/managers + unwraps data.managers', async () => { + vi.mocked(apiClient.get).mockResolvedValue({ + data: { managers: [{ id: 1, email: 'm@x.ru', first_name: 'M', last_name: 'X', name: 'M X', initials: 'MX' }] }, + }); + const r = await listManagers(1); + expect(apiClient.get).toHaveBeenCalledWith('/api/managers', { params: { tenant_id: 1 } }); + expect(r).toHaveLength(1); + }); + + it('listProjects() GET /api/projects + unwraps data.projects', async () => { + vi.mocked(apiClient.get).mockResolvedValue({ + data: { projects: [{ id: 1, name: 'P', tag: 'site', type: 'webhook' }] }, + }); + const r = await listProjects(1); + expect(apiClient.get).toHaveBeenCalledWith('/api/projects', { params: { tenant_id: 1 } }); + expect(r[0].name).toBe('P'); + }); +}); diff --git a/app/tests/Frontend/notifications-api.spec.ts b/app/tests/Frontend/notifications-api.spec.ts new file mode 100644 index 00000000..e75d85a2 --- /dev/null +++ b/app/tests/Frontend/notifications-api.spec.ts @@ -0,0 +1,71 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +vi.mock('../../resources/js/api/client', () => ({ + apiClient: { + get: vi.fn(), + post: vi.fn(), + patch: vi.fn(), + delete: vi.fn(), + }, + ensureCsrfCookie: vi.fn(), +})); + +import { + listNotifications, + markNotificationRead, + markAllNotificationsRead, + deleteNotification, +} from '../../resources/js/api/notifications'; +import { apiClient, ensureCsrfCookie } from '../../resources/js/api/client'; + +describe('api/notifications', () => { + beforeEach(() => vi.clearAllMocks()); + + it('listNotifications() GET /api/notifications без params → unread_only=undefined', async () => { + vi.mocked(apiClient.get).mockResolvedValue({ data: { items: [], unread_count: 0, total: 0 } }); + const r = await listNotifications(); + expect(apiClient.get).toHaveBeenCalledWith('/api/notifications', { + params: { unread_only: undefined, limit: undefined }, + }); + expect(r.total).toBe(0); + }); + + it('listNotifications({unreadOnly:true}) → unread_only=1 (1, не true)', async () => { + vi.mocked(apiClient.get).mockResolvedValue({ data: { items: [], unread_count: 0, total: 0 } }); + await listNotifications({ unreadOnly: true, limit: 20 }); + expect(apiClient.get).toHaveBeenCalledWith('/api/notifications', { + params: { unread_only: 1, limit: 20 }, + }); + }); + + it('listNotifications({unreadOnly:false}) → unread_only=undefined (не 0)', async () => { + vi.mocked(apiClient.get).mockResolvedValue({ data: { items: [], unread_count: 0, total: 0 } }); + await listNotifications({ unreadOnly: false }); + expect(apiClient.get).toHaveBeenCalledWith('/api/notifications', { + params: { unread_only: undefined, limit: undefined }, + }); + }); + + it('markNotificationRead() PATCH /api/notifications/{id}/read + ensureCsrfCookie', async () => { + vi.mocked(apiClient.patch).mockResolvedValue({ data: { id: 5, read_at: '2026-05-12T20:00:00Z' } }); + const r = await markNotificationRead(5); + expect(ensureCsrfCookie).toHaveBeenCalledOnce(); + expect(apiClient.patch).toHaveBeenCalledWith('/api/notifications/5/read'); + expect(r.id).toBe(5); + }); + + it('markAllNotificationsRead() POSTs /api/notifications/mark-all-read', async () => { + vi.mocked(apiClient.post).mockResolvedValue({ data: { updated: 12 } }); + const r = await markAllNotificationsRead(); + expect(ensureCsrfCookie).toHaveBeenCalledOnce(); + expect(apiClient.post).toHaveBeenCalledWith('/api/notifications/mark-all-read'); + expect(r.updated).toBe(12); + }); + + it('deleteNotification() DELETE /api/notifications/{id} + ensureCsrfCookie', async () => { + vi.mocked(apiClient.delete).mockResolvedValue({ data: undefined }); + await deleteNotification(7); + expect(ensureCsrfCookie).toHaveBeenCalledOnce(); + expect(apiClient.delete).toHaveBeenCalledWith('/api/notifications/7'); + }); +}); diff --git a/app/tests/Frontend/reminders-api.spec.ts b/app/tests/Frontend/reminders-api.spec.ts new file mode 100644 index 00000000..7a179b4d --- /dev/null +++ b/app/tests/Frontend/reminders-api.spec.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +vi.mock('../../resources/js/api/client', () => ({ + apiClient: { + get: vi.fn(), + post: vi.fn(), + patch: vi.fn(), + delete: vi.fn(), + }, + ensureCsrfCookie: vi.fn(), +})); + +import { + listReminders, + createReminder, + updateReminder, + completeReminder, + deleteReminder, +} from '../../resources/js/api/reminders'; +import { apiClient, ensureCsrfCookie } from '../../resources/js/api/client'; + +const FAKE_REMINDER = { + id: 1, + deal_id: 10, + text: 'remind me', + remind_at: '2026-05-13T09:00:00Z', + completed_at: null, + is_sent: false, + sent_at: null, + created_at: '2026-05-12T20:00:00Z', + created_by: 1, + assignee_id: null, + creator_name: 'Demo', +}; + +describe('api/reminders', () => { + beforeEach(() => vi.clearAllMocks()); + + it('listReminders() без params → все params undefined', async () => { + vi.mocked(apiClient.get).mockResolvedValue({ + data: { items: [], counts: { active: 0, today: 0, upcoming: 0, overdue: 0 } }, + }); + await listReminders(); + expect(apiClient.get).toHaveBeenCalledWith('/api/reminders', { + params: { filter: undefined, deal_id: undefined, limit: undefined }, + }); + }); + + it('listReminders({filter:"overdue", dealId, limit}) → snake_case params', async () => { + vi.mocked(apiClient.get).mockResolvedValue({ + data: { items: [], counts: { active: 0, today: 0, upcoming: 0, overdue: 0 } }, + }); + await listReminders({ filter: 'overdue', dealId: 42, limit: 25 }); + expect(apiClient.get).toHaveBeenCalledWith('/api/reminders', { + params: { filter: 'overdue', deal_id: 42, limit: 25 }, + }); + }); + + it('createReminder() POSTs /api/reminders + unwraps data.reminder', async () => { + vi.mocked(apiClient.post).mockResolvedValue({ data: { reminder: FAKE_REMINDER } }); + const r = await createReminder({ deal_id: 10, remind_at: '2026-05-13T09:00:00Z', text: 'hi' }); + expect(ensureCsrfCookie).toHaveBeenCalledOnce(); + expect(apiClient.post).toHaveBeenCalledWith('/api/reminders', { + deal_id: 10, + remind_at: '2026-05-13T09:00:00Z', + text: 'hi', + }); + expect(r.id).toBe(1); + }); + + it('updateReminder() PATCH /api/reminders/{id} с partial payload', async () => { + vi.mocked(apiClient.patch).mockResolvedValue({ data: { reminder: FAKE_REMINDER } }); + await updateReminder(1, { text: 'updated', assignee_id: 5 }); + expect(ensureCsrfCookie).toHaveBeenCalledOnce(); + expect(apiClient.patch).toHaveBeenCalledWith('/api/reminders/1', { text: 'updated', assignee_id: 5 }); + }); + + it('completeReminder() POSTs /api/reminders/{id}/complete', async () => { + vi.mocked(apiClient.post).mockResolvedValue({ data: { reminder: FAKE_REMINDER } }); + await completeReminder(3); + expect(ensureCsrfCookie).toHaveBeenCalledOnce(); + expect(apiClient.post).toHaveBeenCalledWith('/api/reminders/3/complete'); + }); + + it('deleteReminder() DELETE /api/reminders/{id}', async () => { + vi.mocked(apiClient.delete).mockResolvedValue({ data: undefined }); + await deleteReminder(7); + expect(ensureCsrfCookie).toHaveBeenCalledOnce(); + expect(apiClient.delete).toHaveBeenCalledWith('/api/reminders/7'); + }); +}); diff --git a/app/tests/Frontend/reports-api.spec.ts b/app/tests/Frontend/reports-api.spec.ts new file mode 100644 index 00000000..f79ceb2a --- /dev/null +++ b/app/tests/Frontend/reports-api.spec.ts @@ -0,0 +1,111 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +vi.mock('../../resources/js/api/client', () => ({ + apiClient: { + get: vi.fn(), + post: vi.fn(), + patch: vi.fn(), + delete: vi.fn(), + }, + ensureCsrfCookie: vi.fn(), +})); + +import { + listReportJobs, + createReportJob, + retryReportJob, + cancelReportJob, + deleteReportJob, +} from '../../resources/js/api/reports'; +import { apiClient, ensureCsrfCookie } from '../../resources/js/api/client'; + +const FAKE_JOB = { + id: 1, + type: 'deals_export' as const, + parameters: { format: 'csv' as const, date_from: '2026-05-01', date_to: '2026-05-12' }, + status: 'pending' as const, + file_path: null, + file_size: null, + generation_seconds: null, + error_message: null, + created_at: '2026-05-12T20:00:00Z', + finished_at: null, + expires_at: null, + is_expired: false, + retry_count: 0, + retry_max: 3, +}; + +describe('api/reports', () => { + beforeEach(() => vi.clearAllMocks()); + + it('listReportJobs() без params → все undefined', async () => { + vi.mocked(apiClient.get).mockResolvedValue({ + data: { + jobs: [], + total: 0, + limit: 50, + offset: 0, + counts: { pending: 0, processing: 0, done: 0, failed: 0 }, + quota: { active: 0, max_active: 5 }, + }, + }); + await listReportJobs(); + expect(apiClient.get).toHaveBeenCalledWith('/api/reports/jobs', { + params: { status: undefined, limit: undefined, offset: undefined }, + }); + }); + + it('listReportJobs({status, limit, offset}) → passes params', async () => { + vi.mocked(apiClient.get).mockResolvedValue({ + data: { + jobs: [FAKE_JOB], + total: 1, + limit: 25, + offset: 0, + counts: { pending: 1, processing: 0, done: 0, failed: 0 }, + quota: { active: 1, max_active: 5 }, + }, + }); + const r = await listReportJobs({ status: 'pending', limit: 25, offset: 0 }); + expect(apiClient.get).toHaveBeenCalledWith('/api/reports/jobs', { + params: { status: 'pending', limit: 25, offset: 0 }, + }); + expect(r.jobs).toHaveLength(1); + }); + + it('createReportJob() POSTs /api/reports/jobs + unwraps data.job', async () => { + vi.mocked(apiClient.post).mockResolvedValue({ data: { job: FAKE_JOB } }); + const payload = { + type: 'deals_export' as const, + format: 'csv' as const, + parameters: { date_from: '2026-05-01', date_to: '2026-05-12', project_id: 7 }, + }; + const r = await createReportJob(payload); + expect(ensureCsrfCookie).toHaveBeenCalledOnce(); + expect(apiClient.post).toHaveBeenCalledWith('/api/reports/jobs', payload); + expect(r.id).toBe(1); + }); + + it('retryReportJob() POSTs /api/reports/jobs/{id}/retry', async () => { + vi.mocked(apiClient.post).mockResolvedValue({ data: { job: { ...FAKE_JOB, retry_count: 1 } } }); + const r = await retryReportJob(42); + expect(ensureCsrfCookie).toHaveBeenCalledOnce(); + expect(apiClient.post).toHaveBeenCalledWith('/api/reports/jobs/42/retry'); + expect(r.retry_count).toBe(1); + }); + + it('cancelReportJob() POSTs /api/reports/jobs/{id}/cancel', async () => { + vi.mocked(apiClient.post).mockResolvedValue({ data: { job: { ...FAKE_JOB, status: 'failed' as const } } }); + await cancelReportJob(42); + expect(ensureCsrfCookie).toHaveBeenCalledOnce(); + expect(apiClient.post).toHaveBeenCalledWith('/api/reports/jobs/42/cancel'); + }); + + it('deleteReportJob() DELETE /api/reports/jobs/{id}', async () => { + vi.mocked(apiClient.delete).mockResolvedValue({ data: undefined }); + await deleteReportJob(7); + expect(ensureCsrfCookie).toHaveBeenCalledOnce(); + expect(apiClient.delete).toHaveBeenCalledWith('/api/reports/jobs/7'); + }); +}); diff --git a/docs/superpowers/audits/2026-05-12-portal-full-audit-blocked.md b/docs/superpowers/audits/2026-05-12-portal-full-audit-blocked.md index a409e6bf..872813ec 100644 --- a/docs/superpowers/audits/2026-05-12-portal-full-audit-blocked.md +++ b/docs/superpowers/audits/2026-05-12-portal-full-audit-blocked.md @@ -116,6 +116,20 @@ C. Vuetify v-tooltip eager-mount artifact (aria-tooltip-name): role="tooltip" ov ### Q.DEFER.003 — Coverage debt: api/* layer + security cards (Phase 13 extra) +**✅ CLOSED 12.05.2026 ночь (post-audit continuation) — sub-A api/* tests:**User chose (A) api/* unit tests first (highest ROI). Created 5 new spec files (43 new tests): + +- `tests/Frontend/auth-api.spec.ts` — 13 tests (login/register/me/logout/verify2FA/recoveryCode/twoFactorInit/Confirm/Disable/RegenerateRecoveryCodes/forgotPassword/resetPassword/updateNotificationPreferences) +- `tests/Frontend/deals-api.spec.ts` — 12 tests (createDeal/bulkDelete/bulkRestore/update/transition/exportCSV/exportXLSX/getDeal/listDeals×2/listManagers/listProjects) +- `tests/Frontend/notifications-api.spec.ts` — 6 tests (listNotifications×3 для unreadOnly variants/markRead/markAllRead/delete) +- `tests/Frontend/reminders-api.spec.ts` — 6 tests (listReminders×2/create/update/complete/delete) +- `tests/Frontend/reports-api.spec.ts` — 6 tests (listReportJobs×2/create/retry/cancel/delete) + +Подход: `vi.mock('../../resources/js/api/client')` → mock `apiClient.{get,post,patch,delete}` + `ensureCsrfCookie`. Каждый тест проверяет: (1) correct HTTP method, (2) correct URL, (3) correct params/body (camelCase→snake_case mapping), (4) data unwrap, (5) `ensureCsrfCookie` called for mutating endpoints. + +**Vitest delta: 614 → 657 passed (+43); 79 → 84 files (+5). 0 failed.** Coverage api/* layer bumped from ≈0% Stmts на функциях (auth/deals/notifications/reminders/reports) к высокому покрытию (каждая exported функция exercised). + +**Sub-B (security cards 28%) + sub-C (router guards 33%) remain DEFERRED:** не критичны для Q.DEFER.003 first-phase closure (sub-A api/* — highest ROI per blocked.md рекомендации). Если потом сессия требует — отдельные Q-items в новой сессии. + **Из:** Phase 13 Vitest coverage report (subagent fresh run 2026-05-12 ночь). **Severity при отказе:** P1 long-term maintainability + security regression risk. **Контекст:**