diff --git a/frontend/__tests__/composables/useSocketEventHandler.spec.js b/frontend/__tests__/composables/useSocketEventHandler.spec.js new file mode 100644 index 0000000000..89abe8268e --- /dev/null +++ b/frontend/__tests__/composables/useSocketEventHandler.spec.js @@ -0,0 +1,276 @@ +// +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Gardener contributors +// +// SPDX-License-Identifier: Apache-2.0 +// + +import { flushPromises } from '@vue/test-utils' +import { + computed, + nextTick, + reactive, + ref, +} from 'vue' + +import { + createListOperator, + useSocketEventHandler, +} from '@/composables/useSocketEventHandler' + +function createLogger () { + return { + debug: vi.fn(), + error: vi.fn(), + info: vi.fn(), + } +} + +function createListStore (id, initialList = null) { + const state = reactive({ + list: initialList, + }) + const isInitial = computed(() => state.list === null) + const store = { + $id: id, + get isInitial () { + return isInitial.value + }, + $patch (fn) { + fn(state) + }, + } + + return { + state, + store, + } +} + +function createDeferred () { + let resolvePromise + let rejectPromise + const promise = new Promise((resolve, reject) => { + resolvePromise = resolve + rejectPromise = reject + }) + return { + promise, + resolve: resolvePromise, + reject: rejectPromise, + } +} + +async function flushEvents () { + await nextTick() + await flushPromises() +} + +describe('composables', () => { + describe('useSocketEventHandler', () => { + let logger + let socketStore + let visibility + + beforeEach(() => { + logger = createLogger() + socketStore = { + synchronize: vi.fn(), + } + visibility = ref('visible') + }) + + it('defers socket events until the store is initialized', async () => { + const { state, store } = createListStore('seed') + const item = { + kind: 'Seed', + metadata: { + uid: 'uid-1', + }, + } + socketStore.synchronize.mockResolvedValue([item]) + const socketEventHandler = useSocketEventHandler(() => store, { + logger, + socketStore, + visibility, + }) + + socketEventHandler.start(0) + socketEventHandler.listener({ + type: 'MODIFIED', + uid: 'uid-1', + }) + await flushEvents() + + expect(socketStore.synchronize).not.toBeCalled() + expect(state.list).toBeNull() + + state.list = [] + await flushEvents() + + expect(socketStore.synchronize).toBeCalledTimes(1) + expect(socketStore.synchronize).toBeCalledWith('seeds', ['uid-1']) + expect(state.list).toEqual([item]) + }) + + it('does not apply synchronized events when the store was reset before the response arrives', async () => { + const { state, store } = createListStore('seed', []) + const item = { + kind: 'Seed', + metadata: { + uid: 'uid-1', + }, + } + const synchronize = createDeferred() + socketStore.synchronize + .mockReturnValueOnce(synchronize.promise) + .mockResolvedValueOnce([item]) + const socketEventHandler = useSocketEventHandler(() => store, { + logger, + socketStore, + visibility, + }) + + socketEventHandler.start(0) + socketEventHandler.listener({ + type: 'MODIFIED', + uid: 'uid-1', + }) + await flushEvents() + + state.list = null + synchronize.resolve([item]) + await flushEvents() + + expect(state.list).toBeNull() + expect(logger.error).not.toBeCalled() + + state.list = [] + await flushEvents() + + expect(socketStore.synchronize).toBeCalledTimes(2) + expect(state.list).toEqual([item]) + }) + + it('requeues synchronized events when the store is reset while applying them', async () => { + const { state, store } = createListStore('seed', []) + const item = { + kind: 'Seed', + metadata: { + uid: 'uid-1', + }, + } + const patch = store.$patch + let resetBeforePatch = true + store.$patch = fn => { + if (resetBeforePatch) { + resetBeforePatch = false + state.list = null + } + patch(fn) + } + socketStore.synchronize.mockResolvedValue([item]) + const socketEventHandler = useSocketEventHandler(() => store, { + logger, + socketStore, + visibility, + }) + + socketEventHandler.start(0) + socketEventHandler.listener({ + type: 'MODIFIED', + uid: 'uid-1', + }) + await flushEvents() + + expect(socketStore.synchronize).toBeCalledTimes(1) + expect(state.list).toBeNull() + expect(logger.debug).toBeCalledWith( + 'Skipped synchronization of %s: store not yet initialized', + 'seeds', + ) + expect(logger.error).not.toBeCalled() + + state.list = [] + await flushEvents() + + expect(socketStore.synchronize).toBeCalledTimes(2) + expect(state.list).toEqual([item]) + }) + + it('synchronizes socket events immediately when the store is initialized', async () => { + const { state, store } = createListStore('project', []) + const item = { + kind: 'Project', + metadata: { + uid: 'uid-1', + }, + } + socketStore.synchronize.mockResolvedValue([item]) + const socketEventHandler = useSocketEventHandler(() => store, { + logger, + socketStore, + visibility, + }) + + socketEventHandler.start(0) + socketEventHandler.listener({ + type: 'ADDED', + uid: 'uid-1', + }) + await flushEvents() + + expect(socketStore.synchronize).toBeCalledTimes(1) + expect(socketStore.synchronize).toBeCalledWith('projects', ['uid-1']) + expect(state.list).toEqual([item]) + }) + + it('does not retry operator failures as transient synchronization failures', async () => { + const { store } = createListStore('seed', []) + const item = { + kind: 'Seed', + metadata: { + uid: 'uid-1', + }, + } + socketStore.synchronize.mockResolvedValue([item]) + const socketEventHandler = useSocketEventHandler(() => store, { + logger, + socketStore, + visibility, + createOperator () { + return { + delete: vi.fn(), + set () { + throw new Error('operator failed') + }, + } + }, + }) + + socketEventHandler.start(0) + socketEventHandler.listener({ + type: 'MODIFIED', + uid: 'uid-1', + }) + await flushEvents() + + expect(socketStore.synchronize).toBeCalledTimes(1) + expect(logger.error).toBeCalledWith( + 'Failed to apply synchronized %s: %s', + 'seeds', + 'operator failed', + ) + + visibility.value = 'hidden' + await flushEvents() + visibility.value = 'visible' + await flushEvents() + + expect(socketStore.synchronize).toBeCalledTimes(1) + }) + + it('requires an initialized array list for list operators', () => { + expect(() => createListOperator(null)).toThrow('Argument `list` must be an array') + }) + }) +}) diff --git a/frontend/src/composables/useSocketEventHandler.js b/frontend/src/composables/useSocketEventHandler.js index 467ef8d3e1..e66044c5fa 100644 --- a/frontend/src/composables/useSocketEventHandler.js +++ b/frontend/src/composables/useSocketEventHandler.js @@ -17,13 +17,28 @@ import partial from 'lodash/partial' import throttle from 'lodash/throttle' import findIndex from 'lodash/findIndex' +class StoreNotInitializedError extends Error { + constructor () { + super('store not yet initialized') + this.name = 'StoreNotInitializedError' + } +} + +function isStoreNotInitializedError (err) { + return err instanceof StoreNotInitializedError +} + export function createDefaultOperator (state) { - if (Array.isArray(state.list)) { - return createListOperator(state.list) + if (state.list === null) { + throw new StoreNotInitializedError() } + return createListOperator(state.list) } export function createListOperator (list) { + if (!Array.isArray(list)) { + throw new TypeError('Argument `list` must be an array') + } return { delete (uid) { const index = findIndex(list, ['metadata.uid', uid]) @@ -42,51 +57,100 @@ export function createListOperator (list) { } } +function isStoreInitialized (store) { + return store.isInitial !== true +} + export function useSocketEventHandler (useStore, options = {}) { const { logger = useLogger(), socketStore = useSocketStore(), visibility = useDocumentVisibility(), createOperator = createDefaultOperator, + isInitialized = isStoreInitialized, } = options const eventMap = new Map([]) + function restoreEvents (events) { + for (const event of events) { + const { uid } = event + if (!eventMap.has(uid)) { + eventMap.set(uid, event) + } + } + } + + function flushEvents (store) { + if (!eventMap.size) { + return + } + if (!isInitialized(store)) { + return + } + if (visibility.value !== 'visible') { + return + } + throttledHandleEvents() + } + async function handleEvents (store) { + if (!isInitialized(store)) { + return + } const pluralName = store.$id + 's' const events = Array.from(eventMap.values()) + if (!events.length) { + return + } eventMap.clear() + const uidMap = new Map() + const uids = [] + for (const { type, uid } of events) { + if (type === 'DELETED') { + uidMap.set(uid, false) + } else { + uidMap.set(uid, true) + uids.push(uid) + } + } + let items try { - const uidMap = new Map() - const uids = [] - for (const { type, uid } of events) { - if (type === 'DELETED') { - uidMap.set(uid, false) - } else { - uidMap.set(uid, true) - uids.push(uid) - } + items = await socketStore.synchronize(pluralName, uids) + } catch (err) { + if (isTooManyRequestsError(err)) { + logger.info('Skipped synchronization of modified %s: %s', pluralName, err.message) + } else { + logger.error('Failed to synchronize modified %s: %s', pluralName, err.message) + } + // Synchronization failed -> Rollback events + restoreEvents(events) + return + } + if (!isInitialized(store)) { + restoreEvents(events) + return + } + for (const item of items) { + if (item.kind !== 'Status') { + uidMap.set(item.metadata.uid, item) + continue } - const items = await socketStore.synchronize(pluralName, uids) - for (const item of items) { - if (item.kind !== 'Status') { - uidMap.set(item.metadata.uid, item) - continue - } - - logger.info('Failed to synchronize a single %s: %s', store.$id, item.message) - if (item.code !== 404) { - continue - } + logger.info('Failed to synchronize a single %s: %s', store.$id, item.message) - const uid = item.details?.uid - if (!uid) { - continue - } + if (item.code !== 404) { + continue + } - uidMap.set(uid, false) + const uid = item.details?.uid + if (!uid) { + continue } + + uidMap.set(uid, false) + } + try { store.$patch(state => { const operator = createOperator(state) // Delete items first @@ -103,22 +167,17 @@ export function useSocketEventHandler (useStore, options = {}) { } }) } catch (err) { - if (isTooManyRequestsError(err)) { - logger.info('Skipped synchronization of modified %s: %s', pluralName, err.message) - } else { - logger.error('Failed to synchronize modified %s: %s', pluralName, err.message) - } - // Synchronization failed -> Rollback events - for (const event of events) { - const { uid } = event - if (!eventMap.has(uid)) { - eventMap.set(uid, event) - } + if (isStoreNotInitializedError(err)) { + logger.debug('Skipped synchronization of %s: store not yet initialized', pluralName) + restoreEvents(events) + return } + logger.error('Failed to apply synchronized %s: %s', pluralName, err.message) } } let throttledHandleEvents + let unwatchInitialization function cancelTrailingInvocation () { if (typeof throttledHandleEvents?.cancel === 'function') { @@ -126,19 +185,33 @@ export function useSocketEventHandler (useStore, options = {}) { } } + function teardownInitializationWatch () { + if (typeof unwatchInitialization === 'function') { + unwatchInitialization() + unwatchInitialization = undefined + } + } + function start (wait = 500) { cancelTrailingInvocation() + teardownInitializationWatch() eventMap.clear() const store = useStore() const handleEventsWithParams = partial(handleEvents, store) throttledHandleEvents = wait > 0 ? throttle(handleEventsWithParams, wait) : handleEventsWithParams + unwatchInitialization = watch(() => isInitialized(store), value => { + if (value) { + flushEvents(store) + } + }) return throttledHandleEvents } function stop () { cancelTrailingInvocation() + teardownInitializationWatch() eventMap.clear() throttledHandleEvents = undefined } @@ -153,10 +226,7 @@ export function useSocketEventHandler (useStore, options = {}) { return } eventMap.set(uid, event) - if (visibility.value !== 'visible') { - return - } - throttledHandleEvents() + flushEvents(useStore()) } watch(visibility, (current, previous) => { @@ -164,7 +234,7 @@ export function useSocketEventHandler (useStore, options = {}) { return } if (current === 'visible' && previous === 'hidden') { - throttledHandleEvents() + flushEvents(useStore()) } })