From 5ecfaa4837530e1e4da497063b675ceb20dd7e6e Mon Sep 17 00:00:00 2001 From: "chihao.ou" Date: Sun, 13 Sep 2026 17:22:56 +0800 Subject: [PATCH] fix(batch-image): scope access cache to the current user --- .../__tests__/useBatchImageAccess.spec.ts | 97 +++++++++++++++++++ .../src/composables/useBatchImageAccess.ts | 27 +++++- 2 files changed, 120 insertions(+), 4 deletions(-) create mode 100644 frontend/src/composables/__tests__/useBatchImageAccess.spec.ts diff --git a/frontend/src/composables/__tests__/useBatchImageAccess.spec.ts b/frontend/src/composables/__tests__/useBatchImageAccess.spec.ts new file mode 100644 index 0000000000..3840c888db --- /dev/null +++ b/frontend/src/composables/__tests__/useBatchImageAccess.spec.ts @@ -0,0 +1,97 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { reactive } from 'vue' + +const { list } = vi.hoisted(() => ({ list: vi.fn() })) +const auth = reactive({ user: { id: 1 } as { id: number } | null, isAuthenticated: true }) +vi.mock('@/api/keys', () => ({ keysAPI: { list } })) +vi.mock('@/stores/auth', () => ({ useAuthStore: () => auth })) + +const allowed = { items: [{ status: 'active', group: { platform: 'gemini', allow_batch_image_generation: true } }], pages: 1 } +const denied = { items: [], pages: 1 } +function deferred() { + let resolve!: (value: typeof denied | typeof allowed) => void + let reject!: (error: Error) => void + const promise = new Promise((res, rej) => { resolve = res; reject = rej }) + return { promise, resolve, reject } +} +beforeEach(() => { vi.resetModules(); list.mockReset(); auth.user = { id: 1 }; auth.isAuthenticated = true }) + +describe('batch image access session cache', () => { + it.each([true, false])('reloads access after switching users (previous access: %s)', async (previous) => { + list.mockResolvedValueOnce(previous ? allowed : denied).mockResolvedValueOnce(previous ? denied : allowed) + const { useBatchImageAccess } = await import('../useBatchImageAccess') + const state = useBatchImageAccess() + expect(await state.refreshBatchImageAccess()).toBe(previous) + auth.user = { id: 2 } + expect(state.canUseBatchImage.value).toBe(false) + expect(await state.refreshBatchImageAccess()).toBe(!previous) + expect(list).toHaveBeenCalledTimes(2) + expect(state.canUseBatchImage.value).toBe(!previous) + }) + + it('does not reuse an unauthenticated result after login', async () => { + auth.user = null; auth.isAuthenticated = false + const { useBatchImageAccess } = await import('../useBatchImageAccess') + const state = useBatchImageAccess() + expect(await state.refreshBatchImageAccess()).toBe(false) + auth.user = { id: 1 }; auth.isAuthenticated = true + list.mockResolvedValue(allowed) + expect(await state.refreshBatchImageAccess()).toBe(true) + }) + + it.each(['resolve', 'reject'] as const)('ignores an old user request that later %ss', async (settle) => { + const old = deferred() + list.mockReturnValueOnce(old.promise).mockResolvedValueOnce(allowed) + const { useBatchImageAccess } = await import('../useBatchImageAccess') + const state = useBatchImageAccess() + const oldLoad = state.refreshBatchImageAccess() + auth.user = { id: 2 } + expect(await state.refreshBatchImageAccess()).toBe(true) + if (settle === 'resolve') old.resolve(denied) + else old.reject(new Error('old request failed')) + await oldLoad + expect(state.canUseBatchImage.value).toBe(true) + expect(await state.refreshBatchImageAccess()).toBe(true) + expect(list).toHaveBeenCalledTimes(2) + }) + + it('keeps the new user request pending when an older request finishes', async () => { + const old = deferred(); const current = deferred() + list.mockReturnValueOnce(old.promise).mockReturnValueOnce(current.promise) + const { useBatchImageAccess } = await import('../useBatchImageAccess') + const state = useBatchImageAccess() + const oldLoad = state.refreshBatchImageAccess() + auth.user = { id: 2 } + const newLoad = state.refreshBatchImageAccess() + expect(list).toHaveBeenCalledTimes(2) + old.resolve(denied); await oldLoad + expect(state.batchImageAccessLoading.value).toBe(true) + const sharedLoad = state.refreshBatchImageAccess() + expect(list).toHaveBeenCalledTimes(2) + current.resolve(allowed) + expect(await newLoad).toBe(true); expect(await sharedLoad).toBe(true) + expect(state.batchImageAccessLoading.value).toBe(false) + }) + + it('hides cached access immediately on logout', async () => { + list.mockResolvedValue(allowed) + const { useBatchImageAccess } = await import('../useBatchImageAccess') + const state = useBatchImageAccess() + await state.refreshBatchImageAccess() + expect(state.canUseBatchImage.value).toBe(true) + auth.user = null; auth.isAuthenticated = false + expect(state.canUseBatchImage.value).toBe(false) + }) + + it('still shares in-flight and completed results between consumers for the same user', async () => { + const response = deferred(); list.mockReturnValue(response.promise) + const { useBatchImageAccess } = await import('../useBatchImageAccess') + const first = useBatchImageAccess(); const second = useBatchImageAccess() + const one = first.refreshBatchImageAccess(); const two = second.refreshBatchImageAccess() + expect(list).toHaveBeenCalledTimes(1) + response.resolve(allowed) + expect(await one).toBe(true); expect(await two).toBe(true) + expect(await second.refreshBatchImageAccess()).toBe(true) + expect(list).toHaveBeenCalledTimes(1) + }) +}) diff --git a/frontend/src/composables/useBatchImageAccess.ts b/frontend/src/composables/useBatchImageAccess.ts index caf1e8c0f9..42cf3988f5 100644 --- a/frontend/src/composables/useBatchImageAccess.ts +++ b/frontend/src/composables/useBatchImageAccess.ts @@ -6,6 +6,8 @@ import type { ApiKey } from '@/types' const loaded = ref(false) const loading = ref(false) const hasAllowedBatchImageKey = ref(false) +const cacheUserId = ref(null) +let requestId = 0 let pendingLoad: Promise | null = null const pageSize = 100 @@ -19,7 +21,16 @@ function keyAllowsBatchImage(key: ApiKey): boolean { async function loadBatchImageAccess(force = false): Promise { const authStore = useAuthStore() - if (!authStore.isAuthenticated) { + const userId = authStore.isAuthenticated ? authStore.user?.id ?? null : null + if (cacheUserId.value !== userId) { + cacheUserId.value = userId + loaded.value = false + loading.value = false + hasAllowedBatchImageKey.value = false + pendingLoad = null + requestId += 1 + } + if (userId === null) { loaded.value = true hasAllowedBatchImageKey.value = false return false @@ -33,6 +44,8 @@ async function loadBatchImageAccess(force = false): Promise { return pendingLoad } + const loadId = ++requestId + const isCurrentLoad = () => loadId === requestId && authStore.isAuthenticated && authStore.user?.id === userId loading.value = true pendingLoad = (async () => { let page = 1 @@ -43,6 +56,8 @@ async function loadBatchImageAccess(force = false): Promise { sort_order: 'desc' }) + if (!isCurrentLoad()) return false + if ((response.items || []).some(keyAllowsBatchImage)) { hasAllowedBatchImageKey.value = true loaded.value = true @@ -59,11 +74,13 @@ async function loadBatchImageAccess(force = false): Promise { } })() .catch(() => { + if (!isCurrentLoad()) return false hasAllowedBatchImageKey.value = false loaded.value = true return false }) .finally(() => { + if (loadId !== requestId) return loading.value = false pendingLoad = null }) @@ -72,12 +89,14 @@ async function loadBatchImageAccess(force = false): Promise { } export function useBatchImageAccess() { - const canUseBatchImage = computed(() => hasAllowedBatchImageKey.value) + const authStore = useAuthStore() + const isCurrentUser = computed(() => (authStore.isAuthenticated ? authStore.user?.id ?? null : null) === cacheUserId.value) + const canUseBatchImage = computed(() => authStore.isAuthenticated && isCurrentUser.value && hasAllowedBatchImageKey.value) return { canUseBatchImage, - batchImageAccessLoaded: computed(() => loaded.value), - batchImageAccessLoading: computed(() => loading.value), + batchImageAccessLoaded: computed(() => isCurrentUser.value && loaded.value), + batchImageAccessLoading: computed(() => isCurrentUser.value && loading.value), refreshBatchImageAccess: loadBatchImageAccess, } }