import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { mount, flushPromises } from '@vue/test-utils' import { nextTick } from 'vue' import { createPinia, setActivePinia } from 'pinia' import { message } from 'ant-design-vue' import DataScopeModal from '@/components/DataScopeModal.vue' Object.defineProperty(window, 'matchMedia', { writable: true, value: (query: string) => ({ matches: false, media: query, onchange: null, addListener: () => {}, removeListener: () => {}, addEventListener: () => {}, removeEventListener: () => {}, dispatchEvent: () => false, }), }) vi.mock('@/utils/request', () => ({ api: { get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), }, })) import { api } from '@/utils/request' const mockScopes = { user_id: 1, role: 'accessor', scopes: [ { scope_type: 'company', scope_id: 1, name: '阿克米公司' }, { scope_type: 'store', scope_id: 5, name: '我的店铺' }, ], resolved_store_ids: [5], } const mockCompanies = [ { id: 1, name: 'acme', label: '阿克米公司', enabled: true }, { id: 2, name: 'beta', label: '贝塔公司', enabled: true }, ] const mockPlatforms = [ { id: 1, developer_id: 1 }, { id: 2, developer_id: 1 }, ] const mockStores = [ { id: 5, company_id: 1, platform_id: 1, name: 'my-store', label: '我的店铺' }, { id: 6, company_id: 2, platform_id: 2, name: 'other-store', label: '其他店铺' }, ] function setupMocks(overrides: { scopes?: typeof mockScopes } = {}) { vi.mocked(api.get).mockImplementation((url: string) => { if (url.match(/\/users\/\d+\/data-scope/)) return Promise.resolve(overrides.scopes ?? mockScopes) as never if (url === '/api/v1/companies') return Promise.resolve(mockCompanies) as never if (url === '/api/v1/platforms') return Promise.resolve(mockPlatforms) as never if (url === '/api/v1/stores') return Promise.resolve(mockStores) as never return Promise.resolve([]) as never }) vi.mocked(api.put).mockResolvedValue(undefined as never) } describe('DataScopeModal', () => { let wrapper: ReturnType beforeEach(() => { vi.restoreAllMocks() document.body.innerHTML = '' setActivePinia(createPinia()) }) afterEach(() => { wrapper?.unmount() document.body.innerHTML = '' }) async function mountModal(props = {}) { wrapper = mount(DataScopeModal, { props: { open: true, userId: 1, username: 'testuser', ...props, }, attachTo: document.body, }) await flushPromises() await nextTick() return wrapper } function queryBody(selector: string) { return document.body.querySelectorAll(selector) } describe('数据加载', () => { it('打开时加载用户 scope 数据', async () => { setupMocks() await mountModal() expect(vi.mocked(api.get)).toHaveBeenCalled() const calls = vi.mocked(api.get).mock.calls.map((c) => c[0]) expect(calls.some((url) => url.includes('/data-scope'))).toBe(true) expect(calls.some((url) => url.includes('/companies'))).toBe(true) }) it('标题包含用户名', async () => { setupMocks() await mountModal() const title = document.body.querySelector('.ant-modal-title') expect(title?.textContent).toContain('testuser') }) it('open=false 不渲染内容', async () => { setupMocks() await mountModal({ open: false }) const modal = document.body.querySelector('.ant-modal') expect(modal).toBeNull() }) }) describe('Scope 行操作', () => { it('加载后显示 scope 行', async () => { setupMocks() await mountModal() // 2 scope rows rendered as flex rows const selects = queryBody('.ant-select') // Each row has 2 selects (type + entity), so expect >= 4 expect(selects.length).toBeGreaterThanOrEqual(4) }) it('点击添加按钮增加一行', async () => { setupMocks({ scopes: { ...mockScopes, scopes: [] } }) await mountModal() const addBtn = Array.from(queryBody('.ant-btn-dashed')).find( (btn) => btn.textContent?.includes('添加数据范围'), ) as HTMLElement expect(addBtn).toBeTruthy() addBtn.click() await flushPromises() await nextTick() // Should have at least one row with selects now const selects = queryBody('.ant-select') expect(selects.length).toBeGreaterThanOrEqual(2) }) it('点击删除按钮移除行', async () => { setupMocks() await mountModal() const deleteBtns = queryBody('.anticon-delete') const initialCount = deleteBtns.length expect(initialCount).toBe(2) // Click first delete const firstDeleteBtn = deleteBtns[0]?.closest('button') as HTMLElement if (firstDeleteBtn) { firstDeleteBtn.click() await flushPromises() await nextTick() } const afterDeleteBtns = queryBody('.anticon-delete') expect(afterDeleteBtns.length).toBe(initialCount - 1) }) }) describe('保存', () => { it('保存调用正确 API', async () => { setupMocks() await mountModal() // Click OK button const okBtn = Array.from(queryBody('.ant-btn-primary')).find( (btn) => btn.textContent?.includes('确') || btn.textContent?.includes('OK'), ) as HTMLElement if (okBtn) { okBtn.click() await flushPromises() } expect(vi.mocked(api.put)).toHaveBeenCalledWith( '/api/v1/users/1/data-scope', { scopes: [ { scope_type: 'company', scope_id: 1 }, { scope_type: 'store', scope_id: 5 }, ], }, ) }) it('保存成功显示 success message', async () => { setupMocks() const spy = vi.spyOn(message, 'success') await mountModal() const okBtn = Array.from(queryBody('.ant-btn-primary')).find( (btn) => btn.textContent?.includes('确') || btn.textContent?.includes('OK'), ) as HTMLElement if (okBtn) { okBtn.click() await flushPromises() } expect(spy).toHaveBeenCalledWith('数据范围保存成功') }) it('API 失败显示 error message', async () => { setupMocks() vi.mocked(api.put).mockRejectedValueOnce(new Error('保存出错')) const spy = vi.spyOn(message, 'error') await mountModal() const okBtn = Array.from(queryBody('.ant-btn-primary')).find( (btn) => btn.textContent?.includes('确') || btn.textContent?.includes('OK'), ) as HTMLElement if (okBtn) { okBtn.click() await flushPromises() } expect(spy).toHaveBeenCalledWith('保存出错') }) }) })