Files
portal/app/tests/Frontend/AdminTenantsView.spec.ts
T
Дмитрий 768628d914 phase2(7-features): bulk-actions / new-deal / tenant-card / system-edit / webhook / smart-filters / impersonation-list
7-фичный auto-mode пакет согласно «карте что осталось» (после v1.54).

(1) Bulk-actions DealsView:
- dealsState reactive-копия MOCK_DEALS (deep-clone) для безопасного bulk-edit.
- Bulk-bar (sticky, теало-нуар, theme=dark) при selected.length > 0:
  count + Сменить статус (v-menu × 14 lead_statuses) + Экспорт (snackbar) +
  Удалить (v-dialog confirm) + ✕ clear.
- На production: smart status-transition с проверкой allowed-переходов;
  soft-delete (архив 30 дней); реальный CSV/XLSX export через xlsx-lib.

(2) NewDealDialog (used in DealsView+KanbanView):
- 6 полей: name/phone/project (MOCK_PROJECTS) / manager (MOCK_MANAGERS) /
  cost / status (default 'new' или presetStatus). Phone-валидация ≥10 цифр.
- emit('created', deal) → DealsView push в начало dealsState; KanbanView push
  в правильную колонку по statusSlug + totalDeals++.

(3) AdminTenantDetailView (/admin/tenants/:code):
- 4 KPI cards (Баланс/runway / Тариф+MRR/мес / Лиды сегодня+неделя+месяц /
  Средняя цена). 4 v-tabs: Финансы (balance-history) / Пользователи /
  Проекты / Активность с event-кодами.
- Кнопка «Войти как клиент» (использует ImpersonationDialog из v1.54).
  404-fallback. composables/mockTenantDetail.ts с expandTenantDetail.
- AdminTenantsView получил @click:row → router.push.

(4) Edit-flow AdminSystemView (audit-log + 2-step):
- Backend: SystemSetting + SaasAdminAuditLog Eloquent (append-only,
  payload_before/after JSONB casts).
- AdminSystemSettingsController с GET (list) + PUT (update в DB::transaction
  + INSERT в saas_admin_audit_log; hash-chain trigger BEFORE INSERT
  заполняет log_hash).
- Type-validation: int/decimal/bool/json. Reason ≥30 chars. No-op → 422.
- Frontend SystemSettingEditDialog — 3-step (edit → confirm с diff
  before/after → done).

(5) Webhook receive endpoint (POST /api/webhook/{token}):
- WebhookReceiveController::receive. Token = tenants.webhook_token.
- 404 unknown / 422 bad payload / 202 success + dispatch ProcessWebhookJob.
- Stub-INSERT в webhook_log через DB::table обёрнут в DB::transaction +
  SET LOCAL app.current_tenant_id для RLS.
- CSRF-исключение для api/webhook/* в bootstrap/app.php.
- На prod: + HMAC X-Webhook-Signature + per-token rate-limit.

(6) Smart-filters:
- DealsView: multi-select v-select Проект+Менеджер с auto availableProjects/
  availableManagers computed.
- AdminTenantsView: filterStatuses (4 STATUS_OPTIONS) + filterTariffs
  (computed availableTariffs).
- Кнопка «Сбросить» появляется только когда фильтры активны.

(7) AdminImpersonationView (/admin/impersonation):
- Backend +2 GET endpoints: /active (used_at != null AND session_ended_at
  == null) + /recent (last 20 завершённых с duration_seconds через
  abs(diffInSeconds) — Carbon signed по умолчанию).
- ImpersonationToken получил belongsTo(Tenant).
- Frontend view: 2 секции (Активные с end-кнопкой / Недавно завершённые
  read-only) + refresh + onMounted load.
- Маршрут /admin/impersonation + 5-й nav-пункт «Impersonation» в AdminLayout.

Vitest +48 (всего 238/238 за 15.31 сек).
Pest +16 (всего 136/136 за 15.8 сек, 495 assertions).
PHPStan baseline регенерирован (0 errors после фикса nullsafe.neverNull).

Регресс: lint+type-check+format ; vite build 937 ms; Pint+PHPStan passed;
Pest 136/136. Реестр v1.54→v1.55, CLAUDE.md v1.45→v1.46.

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

190 lines
9.0 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 } from 'vitest';
import { mount } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
import { createRouter, createMemoryHistory } from 'vue-router';
import AdminTenantsView from '../../resources/js/views/admin/AdminTenantsView.vue';
import { MOCK_STATS, MOCK_TENANTS } from '../../resources/js/composables/mockTenants';
describe('AdminTenantsView.vue', () => {
const factory = () => {
// useRouter() в AdminTenantsView требует router-context в тестах.
const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/admin/tenants', name: 'admin-tenants', component: AdminTenantsView },
{ path: '/admin/tenants/:code', name: 'admin-tenant-detail', component: { template: '<div />' } },
],
});
return mount(AdminTenantsView, {
global: {
plugins: [createVuetify(), router],
// ImpersonationDialog stubим — внутри использует api/admin axios.
stubs: { ImpersonationDialog: true },
},
});
};
it('монтируется и содержит заголовок «Тенанты»', () => {
const wrapper = factory();
expect(wrapper.find('h1').text()).toBe('Тенанты');
});
it('показывает 5 stats: всего/активны/trial/просрочка/выручка', () => {
const wrapper = factory();
const text = wrapper.text();
expect(text).toContain(`${MOCK_STATS.total}`); // 142
expect(text).toContain('всего');
expect(text).toContain(`${MOCK_STATS.active}`); // 128
expect(text).toContain('активны');
expect(text).toContain(`${MOCK_STATS.trial}`); // 9
expect(text).toContain('trial');
expect(text).toContain(`${MOCK_STATS.overdue}`); // 5
expect(text).toContain('просрочка');
expect(text).toContain('выручка месяц');
// 1 248 600 ₽
expect(text).toMatch(/1\s+248\s+600\s*₽/);
});
it('таблица содержит 7 колонок (Тенант/Статус/Тариф/Баланс/Желаем×факт/MRR/Активность)', () => {
const wrapper = factory();
const headers = wrapper.findAll('thead th').map((h) => h.text());
['Тенант', 'Статус', 'Тариф', 'Баланс', 'Желаем×факт', 'MRR', 'Активность'].forEach((label) => {
expect(headers.some((h) => h.includes(label))).toBe(true);
});
});
it('рендерит все 7 mock-tenants', () => {
const wrapper = factory();
const rows = wrapper.findAll('tbody tr');
expect(rows.length).toBe(MOCK_TENANTS.length);
});
it('первая строка — Окна Москва ООО + ИНН + Активен + Команда', () => {
const wrapper = factory();
const text = wrapper.text();
expect(text).toContain('Окна Москва ООО');
expect(text).toContain('ИНН 7724444444');
expect(text).toContain('Активен');
expect(text).toContain('Команда');
});
it('overdue-тенант (Двери Премиум) показывает «Просрочка 3 дня» + отрицательный баланс', () => {
const wrapper = factory();
const text = wrapper.text();
expect(text).toContain('Двери Премиум');
expect(text).toContain('Просрочка 3 дня');
expect(text).toMatch(/1\s+200/); // -1200 без 0 ₽
});
it('trial-тенант (Ремонт под ключ) показывает «Trial · 4 дня» + MRR=—', () => {
const wrapper = factory();
const text = wrapper.text();
expect(text).toContain('Ремонт под ключ');
expect(text).toContain('Trial · 4 дня');
});
it('suspended-тенант (Оконные системы РФ) показывает «Приостановлен»', () => {
const wrapper = factory();
const text = wrapper.text();
expect(text).toContain('Оконные системы РФ');
expect(text).toContain('Приостановлен');
});
it('содержит search-input с placeholder «ИНН, юр. лицо, email админа…»', () => {
const wrapper = factory();
const input = wrapper.find('input[type="text"]');
expect(input.exists()).toBe(true);
expect(input.attributes('placeholder')).toContain('ИНН');
});
it('фильтр по search оставляет только matching-tenants', async () => {
const wrapper = factory();
const input = wrapper.find('input[type="text"]');
await input.setValue('Натяжные');
await wrapper.vm.$nextTick();
const rows = wrapper.findAll('tbody tr');
expect(rows.length).toBe(1);
expect(rows[0].text()).toContain('Натяжные потолки СПб');
});
it('содержит Экспорт-кнопку и фильтры Статус/Тариф', () => {
const wrapper = factory();
expect(wrapper.text()).toContain('Экспорт');
expect(wrapper.find('[data-testid="filter-statuses"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="filter-tariffs"]').exists()).toBe(true);
});
it('фильтр по статусу «overdue» оставляет только просроченных', async () => {
const wrapper = factory();
const vm = wrapper.vm as unknown as { filterStatuses: string[] };
vm.filterStatuses = ['overdue'];
await wrapper.vm.$nextTick();
const rows = wrapper.findAll('tbody tr');
expect(rows.length).toBe(1);
expect(rows[0].text()).toContain('Двери Премиум');
});
it('фильтр по тарифу «Pro» оставляет 1 row', async () => {
const wrapper = factory();
const vm = wrapper.vm as unknown as { filterTariffs: string[] };
vm.filterTariffs = ['Pro'];
await wrapper.vm.$nextTick();
const rows = wrapper.findAll('tbody tr');
expect(rows.length).toBe(1);
expect(rows[0].text()).toContain('Кухни на заказ Екб');
});
it('clearFilters сбрасывает оба фильтра + кнопка «Сбросить» появляется только когда фильтры активны', async () => {
const wrapper = factory();
const vm = wrapper.vm as unknown as {
filterStatuses: string[];
filterTariffs: string[];
clearFilters: () => void;
};
// Default — кнопки нет
expect(wrapper.find('[data-testid="clear-filters-btn"]').exists()).toBe(false);
vm.filterStatuses = ['active'];
await wrapper.vm.$nextTick();
expect(wrapper.find('[data-testid="clear-filters-btn"]').exists()).toBe(true);
vm.clearFilters();
await wrapper.vm.$nextTick();
expect(vm.filterStatuses).toEqual([]);
expect(vm.filterTariffs).toEqual([]);
});
it('каждая строка имеет impersonate-кнопку (mdi-account-switch) с уникальным data-testid', () => {
const wrapper = factory();
// Все 7 mock-tenants должны иметь кнопку
MOCK_TENANTS.forEach((t) => {
const btn = wrapper.find(`[data-testid="impersonate-btn-${t.id}"]`);
expect(btn.exists()).toBe(true);
});
});
it('impersonate-кнопка disabled для suspended-тенанта (Оконные системы РФ id=105)', () => {
const wrapper = factory();
const suspendedBtn = wrapper.find('[data-testid="impersonate-btn-105"]');
expect(suspendedBtn.exists()).toBe(true);
// v-btn disabled-state — атрибут disabled на DOM-элементе
expect(suspendedBtn.attributes('disabled')).toBeDefined();
});
it('click на impersonate-кнопке открывает ImpersonationDialog с правильным tenant', async () => {
const wrapper = factory();
// До click — диалог закрыт (modelValue=false)
const dialogStub = wrapper.findComponent({ name: 'ImpersonationDialog' });
expect(dialogStub.exists()).toBe(true);
expect(dialogStub.props('modelValue')).toBe(false);
expect(dialogStub.props('tenant')).toBeNull();
// Click по кнопке для Окна Москва (id=42)
await wrapper.find('[data-testid="impersonate-btn-42"]').trigger('click');
await wrapper.vm.$nextTick();
// Диалог открывается с этим tenant
expect(dialogStub.props('modelValue')).toBe(true);
expect(dialogStub.props('tenant')).toMatchObject({ id: 42, name: 'Окна Москва ООО' });
expect(dialogStub.props('requestedBy')).toBe(1);
});
});