Files
portal/app/tests/Frontend/AdminSystemView.spec.ts
T
Дмитрий 4a385b1df7 phase2(prod-tightening): HMAC+rate-limit webhook / fetch system_settings / CSV export
3 production-tightening после 7-фичного пакета v1.55.

(1) HMAC + per-token rate-limit для webhook receive endpoint:
- WebhookReceiveController::receive: tenant lookup → rate-limit → HMAC
  → payload validation.
- HMAC: опциональный X-Webhook-Signature: sha256=<hex> через hash_hmac +
  hash_equals (constant-time). Backward-compat: header missing → 202.
- Per-token rate-limit: RateLimiter с decay 60 сек. Лимит из
  system_settings.webhook_rate_limit_rps × 60. На превышении 429 +
  Retry-After. Hit ставится ДО валидации payload — иначе обходимо 422.
- Pest +5: HMAC valid/invalid 401/missing 202; rate-limit 60+1=429;
  ключ изолирован per-token.

(2) Реальный fetch system_settings в AdminSystemView:
- onMounted → adminApi.listSystemSettings() → splice replace.
- На fetch-error → fallback на mock + warning v-alert.
- Кнопка «Обновить» — ручной reload.
- Vitest +3: mount fetch / reload / error fallback.

(3) Реальный CSV-export для bulk-actions DealsView:
- applyBulkExport → CSV через Blob+a[download].
- 8 колонок, ; разделитель, \r\n, BOM через String.fromCharCode(0xFEFF)
  (литеральный U+FEFF блокируется ESLint no-irregular-whitespace).
- Filename deals_export_YYYY-MM-DD.csv.
- Empty selection → toast без download.
- Vitest +2: spy createObjectURL+anchor.click; empty без blob.

PHPStan baseline регенерирован.

Регресс: lint+type-check+format ; vitest 242/242 за 15.82 сек (+4);
vite build 903 ms; Pint+PHPStan passed; Pest 141/141 за 17.8 сек (+5,
627 assertions). Реестр v1.55→v1.56, CLAUDE.md v1.46→v1.47.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 05:49:34 +03:00

151 lines
6.4 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';
import { mount, flushPromises } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
import { createRouter, createMemoryHistory } from 'vue-router';
// Мокаем api/admin до import'а view (loadSettings вызывается на mount).
vi.mock('../../resources/js/api/admin', () => ({
listSystemSettings: vi.fn(() => Promise.resolve([])), // default — пустой массив
updateSystemSetting: vi.fn(),
}));
vi.mock('../../resources/js/api/client', () => ({
extractErrorMessage: vi.fn((_e, fb?: string) => fb ?? 'err'),
extractValidationErrors: vi.fn(() => null),
apiClient: {},
ensureCsrfCookie: vi.fn(),
}));
import * as adminApi from '../../resources/js/api/admin';
import AdminSystemView from '../../resources/js/views/admin/AdminSystemView.vue';
import { ADMIN_SYSTEM_SETTINGS } from '../../resources/js/composables/mockAdmin';
const mountView = async () => {
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/admin/system', component: AdminSystemView }],
});
await router.push('/admin/system');
await router.isReady();
return mount(AdminSystemView, {
global: {
plugins: [createVuetify(), router],
stubs: { SystemSettingEditDialog: true },
},
});
};
describe('AdminSystemView.vue', () => {
beforeEach(() => {
vi.clearAllMocks();
// По умолчанию backend возвращает те же 7 mock-настроек — view replace
// settingsState на ту же форму, тесты структуры остаются валидными.
vi.mocked(adminApi.listSystemSettings).mockResolvedValue(
ADMIN_SYSTEM_SETTINGS.map((s) => ({
key: s.key,
value: s.value,
type: s.type,
description: s.description,
updated_at: s.updated_at,
updated_by: null,
})),
);
});
it('монтируется и содержит заголовок «Система»', async () => {
const wrapper = await mountView();
expect(wrapper.text()).toContain('Система');
});
it('показывает информационный alert про edit-flow + audit-log', async () => {
const wrapper = await mountView();
const text = wrapper.text();
expect(text).toContain('Edit-flow');
expect(text).toContain('saas_admin_audit_log');
});
it('перечисляет ключевые system_settings (rate-limit, retention, login_max_attempts)', async () => {
const wrapper = await mountView();
const text = wrapper.text();
expect(text).toContain('webhook_rate_limit_rps');
expect(text).toContain('login_max_attempts');
expect(text).toContain('password_min_length');
expect(text).toContain('webhook_log_retention_days');
expect(text).toContain('maintenance_mode');
});
it('содержит type-chip для каждой строки (int/string/bool/json)', async () => {
const wrapper = await mountView();
const text = wrapper.text();
expect(text).toContain('int');
expect(text).toContain('bool');
});
it('число строк settings = 7 (mock count)', async () => {
const wrapper = await mountView();
const rows = wrapper.findAll('[data-testid="setting-row"]');
expect(rows.length).toBe(7);
});
it('каждая строка имеет «Изменить» кнопку с уникальным data-testid', async () => {
const wrapper = await mountView();
const editBtns = wrapper.findAll('[data-testid$="-btn"]').filter((b) => b.text().includes('Изменить'));
expect(editBtns.length).toBe(7);
});
it('click на Изменить открывает edit-dialog с правильным setting', async () => {
const wrapper = await mountView();
const vm = wrapper.vm as unknown as {
editOpen: boolean;
editSetting: { key: string } | null;
};
expect(vm.editOpen).toBe(false);
await wrapper.find('[data-testid="edit-login_max_attempts-btn"]').trigger('click');
await wrapper.vm.$nextTick();
expect(vm.editOpen).toBe(true);
expect(vm.editSetting?.key).toBe('login_max_attempts');
});
it('на mount вызывает listSystemSettings (реальный fetch с backend)', async () => {
await mountView();
await flushPromises();
expect(adminApi.listSystemSettings).toHaveBeenCalledTimes(1);
});
it('кнопка Обновить триггерит loadSettings', async () => {
const wrapper = await mountView();
await flushPromises();
vi.mocked(adminApi.listSystemSettings).mockClear();
await wrapper.find('[data-testid="reload-btn"]').trigger('click');
await flushPromises();
expect(adminApi.listSystemSettings).toHaveBeenCalledTimes(1);
});
it('при сетевой ошибке показывает warning-banner + сохраняет mock-данные', async () => {
vi.mocked(adminApi.listSystemSettings).mockRejectedValueOnce(new Error('Network down'));
const wrapper = await mountView();
await flushPromises();
const banner = wrapper.find('[data-testid="fetch-error-alert"]');
expect(banner.exists()).toBe(true);
// Mock-настройки остались (fallback)
const rows = wrapper.findAll('[data-testid="setting-row"]');
expect(rows.length).toBe(7);
});
it('onSettingUpdated обновляет value и updated_at в settingsState', async () => {
const wrapper = await mountView();
const vm = wrapper.vm as unknown as {
settingsState: Array<{ key: string; value: string; updated_at: string }>;
onSettingUpdated: (p: { key: string; value: string; updated_at: string }) => void;
};
vm.onSettingUpdated({
key: 'login_max_attempts',
value: '7',
updated_at: '2026-05-09T11:30:00',
});
await wrapper.vm.$nextTick();
const row = vm.settingsState.find((s) => s.key === 'login_max_attempts');
expect(row?.value).toBe('7');
expect(row?.updated_at).toBe('2026-05-09T11:30:00');
});
});