From c76db386c066e35ec147645ec05dc4aa9dabaef2 Mon Sep 17 00:00:00 2001 From: "chihao.ou" Date: Wed, 9 Sep 2026 20:01:34 +0800 Subject: [PATCH] fix(subscriptions): exclude deleted users from assignment search --- .../src/views/admin/SubscriptionsView.vue | 13 +-- .../SubscriptionsView.userUsageLink.spec.ts | 81 +++++++++++++++++-- 2 files changed, 83 insertions(+), 11 deletions(-) diff --git a/frontend/src/views/admin/SubscriptionsView.vue b/frontend/src/views/admin/SubscriptionsView.vue index eba7992b7e..d31578c9be 100644 --- a/frontend/src/views/admin/SubscriptionsView.vue +++ b/frontend/src/views/admin/SubscriptionsView.vue @@ -769,7 +769,7 @@ import { ref, reactive, computed, onMounted, onUnmounted } from 'vue' import { useI18n } from 'vue-i18n' import { useAppStore } from '@/stores/app' import { adminAPI } from '@/api/admin' -import type { UserSubscription, Group, GroupPlatform, SubscriptionType } from '@/types' +import type { AdminUser, UserSubscription, Group, GroupPlatform, SubscriptionType } from '@/types' import type { SimpleUser } from '@/api/admin/usage' import type { Column } from '@/components/common/types' import { formatDateTimeToMinute } from '@/utils/format' @@ -944,10 +944,10 @@ let filterUserSearchTimeout: ReturnType | null = null // User search state const userSearchKeyword = ref('') -const userSearchResults = ref([]) +const userSearchResults = ref([]) const userSearchLoading = ref(false) const showUserDropdown = ref(false) -const selectedUser = ref(null) +const selectedUser = ref(null) let userSearchTimeout: ReturnType | null = null const filters = reactive({ @@ -1148,7 +1148,10 @@ const searchUsers = async () => { userSearchLoading.value = true try { - userSearchResults.value = await adminAPI.usage.searchUsers(keyword) + const result = await adminAPI.users.list(1, 30, { + search: keyword, sort_by: 'email', sort_order: 'asc' + }) + userSearchResults.value = result.items } catch (error) { console.error('Failed to search users:', error) userSearchResults.value = [] @@ -1157,7 +1160,7 @@ const searchUsers = async () => { } } -const selectUser = (user: SimpleUser) => { +const selectUser = (user: AdminUser) => { selectedUser.value = user userSearchKeyword.value = user.email showUserDropdown.value = false diff --git a/frontend/src/views/admin/__tests__/SubscriptionsView.userUsageLink.spec.ts b/frontend/src/views/admin/__tests__/SubscriptionsView.userUsageLink.spec.ts index 0d89d0d4ca..d9b4ae68fe 100644 --- a/frontend/src/views/admin/__tests__/SubscriptionsView.userUsageLink.spec.ts +++ b/frontend/src/views/admin/__tests__/SubscriptionsView.userUsageLink.spec.ts @@ -4,15 +4,19 @@ import { defineComponent } from 'vue' import SubscriptionsView from '../SubscriptionsView.vue' -const { listSubscriptions, getAllGroups } = vi.hoisted(() => ({ +const { listSubscriptions, getAllGroups, listUsers, searchUsageUsers } = vi.hoisted(() => ({ listSubscriptions: vi.fn(), - getAllGroups: vi.fn() + getAllGroups: vi.fn(), + listUsers: vi.fn(), + searchUsageUsers: vi.fn() })) vi.mock('@/api/admin', () => ({ adminAPI: { subscriptions: { list: listSubscriptions }, - groups: { getAll: getAllGroups } + groups: { getAll: getAllGroups }, + users: { list: listUsers }, + usage: { searchUsers: searchUsageUsers } } })) @@ -51,7 +55,7 @@ const RouterLinkStub = defineComponent({ template: '' }) -describe('admin subscription user usage link', () => { +describe('admin subscription users', () => { beforeEach(() => { vi.clearAllMocks() localStorage.clear() @@ -77,17 +81,28 @@ describe('admin subscription user usage link', () => { pages: 1 }) getAllGroups.mockResolvedValue([]) + listUsers.mockResolvedValue({ + items: [{ id: 42, email: 'reader@example.com' }], + total: 1, + pages: 1 + }) + searchUsageUsers.mockResolvedValue([ + { id: 14, email: 'deleted@example.com', deleted: true } + ]) }) const mountView = () => mount(SubscriptionsView, { global: { stubs: { AppLayout: { template: '
' }, - TablePageLayout: { template: '
' }, + TablePageLayout: { template: '
' }, DataTable: DataTableStub, RouterLink: RouterLinkStub, Pagination: true, - BaseDialog: true, + BaseDialog: { + props: ['show'], + template: '
' + }, ConfirmDialog: true, EmptyState: true, Select: true, @@ -99,6 +114,60 @@ describe('admin subscription user usage link', () => { } }) + it('searches current users when assigning a subscription', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const wrapper = mountView() + try { + await flushPromises() + await wrapper.findAll('button') + .find((button) => button.text() === 'admin.subscriptions.assignSubscription')! + .trigger('click') + const search = wrapper.get('[data-assign-user-search] input') + await search.trigger('focus') + await search.setValue(' example.com ') + await vi.advanceTimersByTimeAsync(300) + await flushPromises() + + expect(listUsers).toHaveBeenCalledWith(1, 30, { + search: 'example.com', sort_by: 'email', sort_order: 'asc' + }) + expect(searchUsageUsers).not.toHaveBeenCalled() + const picker = wrapper.get('[data-assign-user-search]') + expect(picker.text()).toContain('reader@example.com') + expect(picker.text()).not.toContain('deleted@example.com') + await picker.get('button').trigger('click') + expect((search.element as HTMLInputElement).value).toBe('reader@example.com') + } finally { + wrapper.unmount() + vi.useRealTimers() + } + }) + + it('keeps deleted users available when filtering subscription history', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const wrapper = mountView() + try { + await flushPromises() + const search = wrapper.get('[data-filter-user-search] input') + await search.trigger('focus') + await search.setValue('deleted') + await vi.advanceTimersByTimeAsync(300) + await flushPromises() + + expect(searchUsageUsers).toHaveBeenCalledWith('deleted') + expect(listUsers).not.toHaveBeenCalled() + const picker = wrapper.get('[data-filter-user-search]') + expect(picker.text()).toContain('deleted@example.com') + await picker.get('button').trigger('click') + expect(listSubscriptions).toHaveBeenLastCalledWith( + 1, expect.any(Number), expect.objectContaining({ user_id: 14 }), expect.any(Object) + ) + } finally { + wrapper.unmount() + vi.useRealTimers() + } + }) + it('renders the user email as a link to that user filtered usage records', async () => { const wrapper = mountView()