From e708825e1a7f2ccbdacbcd01602ace89e1e56e5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sat, 16 May 2026 09:59:43 +0300 Subject: [PATCH] =?UTF-8?q?feat(admin):=20ImpersonationBanner=20=E2=80=94?= =?UTF-8?q?=20=D0=B3=D0=BB=D0=BE=D0=B1=D0=B0=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9?= =?UTF-8?q?=20=D0=B8=D0=BD=D0=B4=D0=B8=D0=BA=D0=B0=D1=82=D0=BE=D1=80=20?= =?UTF-8?q?=D0=B0=D0=BA=D1=82=D0=B8=D0=B2=D0=BD=D1=8B=D1=85=20=D1=81=D0=B5?= =?UTF-8?q?=D1=81=D1=81=D0=B8=D0=B9=20(audit=20B5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/admin/ImpersonationBanner.vue | 84 +++++++++++++ app/resources/js/layouts/AdminLayout.vue | 2 + app/tests/Frontend/AdminLayout.spec.ts | 2 +- .../Frontend/ImpersonationBanner.spec.ts | 118 ++++++++++++++++++ 4 files changed, 205 insertions(+), 1 deletion(-) create mode 100644 app/resources/js/components/admin/ImpersonationBanner.vue create mode 100644 app/tests/Frontend/ImpersonationBanner.spec.ts diff --git a/app/resources/js/components/admin/ImpersonationBanner.vue b/app/resources/js/components/admin/ImpersonationBanner.vue new file mode 100644 index 00000000..b4280baf --- /dev/null +++ b/app/resources/js/components/admin/ImpersonationBanner.vue @@ -0,0 +1,84 @@ + + + + + diff --git a/app/resources/js/layouts/AdminLayout.vue b/app/resources/js/layouts/AdminLayout.vue index 720b15a3..373edc57 100644 --- a/app/resources/js/layouts/AdminLayout.vue +++ b/app/resources/js/layouts/AdminLayout.vue @@ -16,6 +16,7 @@ import { useAuthStore } from '../stores/auth'; import { computed } from 'vue'; import { RouterView, useRoute, useRouter } from 'vue-router'; import DevIndexBadge from '../components/DevIndexBadge.vue'; +import ImpersonationBanner from '../components/admin/ImpersonationBanner.vue'; interface NavItem { title: string; @@ -131,6 +132,7 @@ const currentPageTitle = computed(() => { + diff --git a/app/tests/Frontend/AdminLayout.spec.ts b/app/tests/Frontend/AdminLayout.spec.ts index 361c41da..ba2c5402 100644 --- a/app/tests/Frontend/AdminLayout.spec.ts +++ b/app/tests/Frontend/AdminLayout.spec.ts @@ -53,7 +53,7 @@ const mountAdminLayout = async (path = '/admin/tenants', user: AuthUser | null = const wrapper = mount(AdminLayout, { global: { plugins: [createVuetify(), router], - stubs: { DevIndexBadge: true }, + stubs: { DevIndexBadge: true, ImpersonationBanner: true }, }, }); return { wrapper, auth, router }; diff --git a/app/tests/Frontend/ImpersonationBanner.spec.ts b/app/tests/Frontend/ImpersonationBanner.spec.ts new file mode 100644 index 00000000..c9fa67ac --- /dev/null +++ b/app/tests/Frontend/ImpersonationBanner.spec.ts @@ -0,0 +1,118 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { mount, flushPromises } from '@vue/test-utils'; +import { createVuetify } from 'vuetify'; +import ImpersonationBanner from '../../resources/js/components/admin/ImpersonationBanner.vue'; +import type { ImpersonationActiveSession } from '../../resources/js/api/admin'; + +vi.mock('../../resources/js/api/admin', async (importOriginal) => { + const orig = await importOriginal(); + return { ...orig, impersonationActive: vi.fn() }; +}); + +const adminApi = await import('../../resources/js/api/admin'); + +function makeSession(overrides: Partial = {}): ImpersonationActiveSession { + return { + token_id: 1, + tenant_id: 10, + tenant_name: 'ООО Ромашка', + requested_by: 7, + reason: 'Диагностика проблемы с балансом по обращению клиента', + sent_to_email: 'client@romashka.ru', + used_at: '2026-05-16T08:00:00Z', + expires_at: '2026-05-16T08:15:00Z', + ...overrides, + }; +} + +const mountBanner = () => + mount(ImpersonationBanner, { + global: { + plugins: [createVuetify()], + stubs: { + RouterLink: { + props: ['to'], + inheritAttrs: false, + template: '', + }, + }, + }, + }); + +beforeEach(() => vi.clearAllMocks()); +afterEach(() => vi.useRealTimers()); + +describe('ImpersonationBanner', () => { + it('вызывает impersonationActive на mount', async () => { + vi.mocked(adminApi.impersonationActive).mockResolvedValueOnce([]); + const wrapper = mountBanner(); + await flushPromises(); + expect(adminApi.impersonationActive).toHaveBeenCalledTimes(1); + wrapper.unmount(); + }); + + it('0 активных сессий — баннер не рендерится', async () => { + vi.mocked(adminApi.impersonationActive).mockResolvedValueOnce([]); + const wrapper = mountBanner(); + await flushPromises(); + expect(wrapper.find('[data-testid="impersonation-banner"]').exists()).toBe(false); + wrapper.unmount(); + }); + + it('1 активная сессия — баннер с именем тенанта + ссылка на /admin/impersonation', async () => { + vi.mocked(adminApi.impersonationActive).mockResolvedValueOnce([makeSession({ tenant_name: 'ООО Ромашка' })]); + const wrapper = mountBanner(); + await flushPromises(); + const banner = wrapper.find('[data-testid="impersonation-banner"]'); + expect(banner.exists()).toBe(true); + expect(banner.text()).toContain('Активна impersonation-сессия'); + expect(banner.text()).toContain('ООО Ромашка'); + expect(wrapper.find('[data-testid="impersonation-banner-link"]').attributes('href')).toBe( + '/admin/impersonation', + ); + wrapper.unmount(); + }); + + it('несколько активных сессий — баннер показывает счётчик', async () => { + vi.mocked(adminApi.impersonationActive).mockResolvedValueOnce([ + makeSession({ token_id: 1 }), + makeSession({ token_id: 2 }), + makeSession({ token_id: 3 }), + ]); + const wrapper = mountBanner(); + await flushPromises(); + expect(wrapper.find('[data-testid="impersonation-banner"]').text()).toContain( + 'Активны impersonation-сессии: 3', + ); + wrapper.unmount(); + }); + + it('tenant_name=null — fallback на «тенант #id»', async () => { + vi.mocked(adminApi.impersonationActive).mockResolvedValueOnce([ + makeSession({ tenant_name: null, tenant_id: 42 }), + ]); + const wrapper = mountBanner(); + await flushPromises(); + expect(wrapper.find('[data-testid="impersonation-banner"]').text()).toContain('тенант #42'); + wrapper.unmount(); + }); + + it('ошибка impersonationActive — баннер не падает и остаётся скрыт', async () => { + vi.mocked(adminApi.impersonationActive).mockRejectedValueOnce(new Error('500')); + const wrapper = mountBanner(); + await flushPromises(); + expect(wrapper.find('[data-testid="impersonation-banner"]').exists()).toBe(false); + wrapper.unmount(); + }); + + it('polling — impersonationActive вызывается повторно через 30 с', async () => { + vi.useFakeTimers(); + vi.mocked(adminApi.impersonationActive).mockResolvedValue([]); + const wrapper = mountBanner(); + await vi.advanceTimersByTimeAsync(0); + expect(adminApi.impersonationActive).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(30_000); + expect(adminApi.impersonationActive).toHaveBeenCalledTimes(2); + wrapper.unmount(); + }); +});