diff --git a/frontend/taskdeck-web/src/store/board/labelStore.ts b/frontend/taskdeck-web/src/store/board/labelStore.ts index 5ddda9473..7c3ddec98 100644 --- a/frontend/taskdeck-web/src/store/board/labelStore.ts +++ b/frontend/taskdeck-web/src/store/board/labelStore.ts @@ -6,83 +6,273 @@ * pre-write state must not be allowed to replace the local update when it * resolves after the write (#2435). */ +import { watch } from 'vue' import { labelsApi } from '../../api/labelsApi' -import type { CreateLabelDto, UpdateLabelDto } from '../../types/board' +import type { CreateLabelDto, Label, UpdateLabelDto } from '../../types/board' import type { BoardState } from './boardState' import type { BoardHelpers } from './boardStoreHelpers' +interface LabelCacheVisit { + boardId: string + labels: Label[] + generation: number +} + +class StaleBoardVisitError extends Error { + constructor() { + super('The board visit that queued this label change has ended.') + this.name = 'StaleBoardVisitError' + } +} + export function createLabelActions(state: BoardState, helpers: BoardHelpers) { + // Label state is one selected-board collection. Board-detail commits replace + // the array, so its identity distinguishes overlapping reads within one visit. + // A separate generation observes board-id transitions synchronously: unlike + // array identity it survives a same-board detail refresh, but A→B→A and + // logout→login can never reuse the old authority. + const readVersionByBoardId = new Map() + const mutationVersionByBoardId = new Map() + const mutationTailByLabelKey = new Map>() + let boardVisitGeneration = 0 + + watch( + () => state.currentBoard?.value?.id ?? null, + (nextBoardId, previousBoardId) => { + if (nextBoardId !== previousBoardId) boardVisitGeneration++ + }, + { flush: 'sync' }, + ) + + function captureLabelVisit(boardId: string): LabelCacheVisit { + return { + boardId, + labels: state.currentBoardLabels.value, + generation: boardVisitGeneration, + } + } + + function isCurrentBoardVisit(visit: LabelCacheVisit) { + const currentBoard = state.currentBoard?.value + return ( + (currentBoard == null || currentBoard.id === visit.boardId) && + boardVisitGeneration === visit.generation + ) + } + + function ownsExactLabelCache(visit: LabelCacheVisit) { + return isCurrentBoardVisit(visit) && state.currentBoardLabels.value === visit.labels + } + + function nextReadVersion(boardId: string) { + const version = (readVersionByBoardId.get(boardId) ?? 0) + 1 + readVersionByBoardId.set(boardId, version) + return version + } + + function currentMutationVersion(boardId: string) { + return mutationVersionByBoardId.get(boardId) ?? 0 + } + + function markLabelMutation(boardId: string) { + mutationVersionByBoardId.set(boardId, currentMutationVersion(boardId) + 1) + } + + async function runLabelMutation( + boardId: string, + labelId: string, + visit: LabelCacheVisit, + mutation: () => Promise, + ): Promise { + const key = `${boardId}:${labelId}` + const previous = mutationTailByLabelKey.get(key) + let operation: Promise + + if (previous) { + operation = previous.catch(() => undefined).then(() => { + // The HTTP interceptor reads the token when transport starts. A queued + // pre-logout intent must therefore be rejected BEFORE invoking the API, + // not merely ignored when its response arrives under another session. + if (!isCurrentBoardVisit(visit)) throw new StaleBoardVisitError() + return mutation() + }) + } else { + // The first intent is not queued. Start its transport in the initiating + // call stack so immediate navigation cannot retroactively cancel a request + // that the UI already submitted. Only later intents wait behind a tail. + if (!isCurrentBoardVisit(visit)) throw new StaleBoardVisitError() + operation = mutation() + } + + const tail = operation.then( + () => undefined, + () => undefined, + ) + mutationTailByLabelKey.set(key, tail) + + try { + return await operation + } finally { + if (mutationTailByLabelKey.get(key) === tail) { + mutationTailByLabelKey.delete(key) + } + } + } + + async function reconcileCurrentLabelsAfterStaleVisit(boardId: string) { + if (state.currentBoard?.value?.id !== boardId) return + + const visit = captureLabelVisit(boardId) + const readVersion = nextReadVersion(boardId) + const mutationVersion = currentMutationVersion(boardId) + try { + const labels = await labelsApi.getLabels(boardId) + if ( + isCurrentBoardVisit(visit) && + readVersionByBoardId.get(boardId) === readVersion && + currentMutationVersion(boardId) === mutationVersion + ) { + // This read starts only after the write succeeded. A pre-write detail + // read is invalidated by the mutation epoch; replacing the latest + // same-visit array therefore installs the authoritative post-write set. + state.currentBoardLabels.value.splice( + 0, + state.currentBoardLabels.value.length, + ...labels, + ) + } + } catch { + if (isCurrentBoardVisit(visit)) { + helpers.toast.warning( + 'Label saved, but labels could not be refreshed. Refresh the board before editing again.', + ) + } + } + } + async function fetchLabels(boardId: string) { if (helpers.isDemoMode) return + const visit = captureLabelVisit(boardId) + const readVersion = nextReadVersion(boardId) + const mutationVersion = currentMutationVersion(boardId) try { - state.currentBoardLabels.value = await labelsApi.getLabels(boardId) + const labels = await labelsApi.getLabels(boardId) + if ( + ownsExactLabelCache(visit) && + readVersionByBoardId.get(boardId) === readVersion && + currentMutationVersion(boardId) === mutationVersion + ) { + visit.labels.splice(0, visit.labels.length, ...labels) + } } catch (e: unknown) { - helpers.handleApiError(e, 'Failed to fetch labels') + if ( + ownsExactLabelCache(visit) && + readVersionByBoardId.get(boardId) === readVersion && + currentMutationVersion(boardId) === mutationVersion + ) { + helpers.handleApiError(e, 'Failed to fetch labels') + } throw e } } async function createLabel(boardId: string, label: CreateLabelDto) { helpers.guardDemoMutation() + const visit = captureLabelVisit(boardId) try { state.loading.value = true state.error.value = null const newLabel = await labelsApi.createLabel(boardId, label) helpers.markBoardDetailMutation(boardId) - state.currentBoardLabels.value.push(newLabel) - helpers.toast.success(`Label "${newLabel.name}" created successfully`) + markLabelMutation(boardId) + + if (isCurrentBoardVisit(visit)) { + const currentLabels = state.currentBoardLabels.value + if (!currentLabels.some(existingLabel => existingLabel.id === newLabel.id)) { + // A same-board refresh can install the stable id before the POST + // resolves. Preserve that fresher object instead of appending a duplicate. + currentLabels.push(newLabel) + } + helpers.toast.success(`Label "${newLabel.name}" created successfully`) + } else if (state.currentBoard?.value?.id === boardId) { + await reconcileCurrentLabelsAfterStaleVisit(boardId) + } return newLabel } catch (e: unknown) { - helpers.handleApiError(e, 'Failed to create label') + if (isCurrentBoardVisit(visit)) { + helpers.handleApiError(e, 'Failed to create label') + } throw e } finally { - state.loading.value = false + if (isCurrentBoardVisit(visit)) state.loading.value = false } } async function updateLabel(boardId: string, labelId: string, label: UpdateLabelDto) { helpers.guardDemoMutation() + const visit = captureLabelVisit(boardId) try { state.loading.value = true state.error.value = null - const updatedLabel = await labelsApi.updateLabel(boardId, labelId, label) + const updatedLabel = await runLabelMutation( + boardId, + labelId, + visit, + () => labelsApi.updateLabel(boardId, labelId, label), + ) helpers.markBoardDetailMutation(boardId) + markLabelMutation(boardId) - // Update label in store - const index = state.currentBoardLabels.value.findIndex((l) => l.id === labelId) - if (index !== -1) { - state.currentBoardLabels.value[index] = updatedLabel + if (isCurrentBoardVisit(visit)) { + const currentLabels = state.currentBoardLabels.value + const index = currentLabels.findIndex((candidate) => candidate.id === labelId) + if (index !== -1) currentLabels[index] = updatedLabel + helpers.toast.success('Label updated successfully') + } else if (state.currentBoard?.value?.id === boardId) { + await reconcileCurrentLabelsAfterStaleVisit(boardId) } - helpers.toast.success('Label updated successfully') return updatedLabel } catch (e: unknown) { - helpers.handleApiError(e, 'Failed to update label') + if (!(e instanceof StaleBoardVisitError) && isCurrentBoardVisit(visit)) { + helpers.handleApiError(e, 'Failed to update label') + } throw e } finally { - state.loading.value = false + if (isCurrentBoardVisit(visit)) state.loading.value = false } } async function deleteLabel(boardId: string, labelId: string) { helpers.guardDemoMutation() + const visit = captureLabelVisit(boardId) try { state.loading.value = true state.error.value = null - await labelsApi.deleteLabel(boardId, labelId) - helpers.markBoardDetailMutation(boardId) - - // Remove label from store - state.currentBoardLabels.value = state.currentBoardLabels.value.filter( - (l) => l.id !== labelId, + await runLabelMutation( + boardId, + labelId, + visit, + () => labelsApi.deleteLabel(boardId, labelId), ) + helpers.markBoardDetailMutation(boardId) + markLabelMutation(boardId) - helpers.toast.success('Label deleted successfully') + if (isCurrentBoardVisit(visit)) { + const currentLabels = state.currentBoardLabels.value + const index = currentLabels.findIndex((candidate) => candidate.id === labelId) + if (index !== -1) currentLabels.splice(index, 1) + helpers.toast.success('Label deleted successfully') + } else if (state.currentBoard?.value?.id === boardId) { + await reconcileCurrentLabelsAfterStaleVisit(boardId) + } } catch (e: unknown) { - helpers.handleApiError(e, 'Failed to delete label') + if (!(e instanceof StaleBoardVisitError) && isCurrentBoardVisit(visit)) { + helpers.handleApiError(e, 'Failed to delete label') + } throw e } finally { - state.loading.value = false + if (isCurrentBoardVisit(visit)) state.loading.value = false } } diff --git a/frontend/taskdeck-web/src/tests/store/board/labelStoreOwnership.spec.ts b/frontend/taskdeck-web/src/tests/store/board/labelStoreOwnership.spec.ts new file mode 100644 index 000000000..4547e7c45 --- /dev/null +++ b/frontend/taskdeck-web/src/tests/store/board/labelStoreOwnership.spec.ts @@ -0,0 +1,151 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ref } from 'vue' + +const { mockLabelsApi } = vi.hoisted(() => ({ + mockLabelsApi: { + getLabels: vi.fn(), + createLabel: vi.fn(), + updateLabel: vi.fn(), + deleteLabel: vi.fn(), + }, +})) + +vi.mock('../../../api/labelsApi', () => ({ labelsApi: mockLabelsApi })) + +import { createLabelActions } from '../../../store/board/labelStore' + +function createState() { + return { + currentBoard: ref<{ id: string } | null>({ id: 'board-1' }), + currentBoardLabels: ref([ + { id: 'lbl-1', name: 'Bug', colorHex: '#f00' }, + { id: 'lbl-2', name: 'Feature', colorHex: '#0f0' }, + ]), + loading: ref(false), + error: ref(null), + } +} + +function createHelpers() { + return { + guardDemoMutation: vi.fn(), + handleApiError: vi.fn(), + isDemoMode: false, + toast: { success: vi.fn(), error: vi.fn() }, + markBoardDetailMutation: vi.fn(), + } +} + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((settle) => { + resolve = settle + }) + return { promise, resolve } +} + +describe('labelStore board ownership', () => { + beforeEach(() => { + vi.clearAllMocks() + mockLabelsApi.getLabels.mockReset() + mockLabelsApi.createLabel.mockReset() + mockLabelsApi.updateLabel.mockReset() + mockLabelsApi.deleteLabel.mockReset() + }) + + it('does not let a late label read overwrite the newly selected board', async () => { + const state = createState() + const helpers = createHelpers() + const response = deferred>() + mockLabelsApi.getLabels.mockReturnValueOnce(response.promise) + const { fetchLabels } = createLabelActions(state as never, helpers as never) + + const pendingFetch = fetchLabels('board-1') + + const nextBoardLabels = [{ id: 'lbl-next', name: 'Next', colorHex: '#00f' }] + state.currentBoard.value = { id: 'board-2' } + state.currentBoardLabels.value = nextBoardLabels + response.resolve([{ id: 'lbl-old', name: 'Old board', colorHex: '#aaa' }]) + await pendingFetch + + expect(state.currentBoardLabels.value).toEqual(nextBoardLabels) + }) + + it('does not append a late create response to the newly selected board', async () => { + const state = createState() + const helpers = createHelpers() + const response = deferred<{ id: string; name: string; colorHex: string }>() + mockLabelsApi.createLabel.mockReturnValueOnce(response.promise) + const { createLabel } = createLabelActions(state as never, helpers as never) + + const pendingCreate = createLabel('board-1', { name: 'Chore', colorHex: '#abc' }) + + const nextBoardLabels = [{ id: 'lbl-next', name: 'Next', colorHex: '#00f' }] + state.currentBoard.value = { id: 'board-2' } + state.currentBoardLabels.value = nextBoardLabels + const created = { id: 'lbl-created', name: 'Chore', colorHex: '#abc' } + response.resolve(created) + const result = await pendingCreate + + expect(result).toEqual(created) + expect(state.currentBoardLabels.value).toEqual(nextBoardLabels) + expect(helpers.markBoardDetailMutation).toHaveBeenCalledWith('board-1') + }) + + it('preserves a fresher detail label when it commits before create settles', async () => { + const state = createState() + const helpers = createHelpers() + const response = deferred<{ id: string; name: string; colorHex: string }>() + mockLabelsApi.createLabel.mockReturnValueOnce(response.promise) + const { createLabel } = createLabelActions(state as never, helpers as never) + + const pendingCreate = createLabel('board-1', { name: 'Chore', colorHex: '#abc' }) + + const refreshed = { id: 'lbl-created', name: 'Chore from detail', colorHex: '#def' } + state.currentBoardLabels.value.push(refreshed) + response.resolve({ id: 'lbl-created', name: 'Chore', colorHex: '#abc' }) + await pendingCreate + + expect(state.currentBoardLabels.value.filter(label => label.id === refreshed.id)).toEqual([ + refreshed, + ]) + }) + + it('does not update the prior label collection after another board is selected', async () => { + const state = createState() + const helpers = createHelpers() + const response = deferred<{ id: string; name: string; colorHex: string }>() + mockLabelsApi.updateLabel.mockReturnValueOnce(response.promise) + const { updateLabel } = createLabelActions(state as never, helpers as never) + + const pendingUpdate = updateLabel('board-1', 'lbl-1', { name: 'Critical' }) + + state.currentBoard.value = { id: 'board-2' } + response.resolve({ id: 'lbl-1', name: 'Critical', colorHex: '#f00' }) + await pendingUpdate + + expect(state.currentBoardLabels.value[0]).toEqual({ + id: 'lbl-1', + name: 'Bug', + colorHex: '#f00', + }) + expect(helpers.markBoardDetailMutation).toHaveBeenCalledWith('board-1') + }) + + it('does not delete from the prior label collection after another board is selected', async () => { + const state = createState() + const helpers = createHelpers() + const response = deferred() + mockLabelsApi.deleteLabel.mockReturnValueOnce(response.promise) + const { deleteLabel } = createLabelActions(state as never, helpers as never) + + const pendingDelete = deleteLabel('board-1', 'lbl-1') + + state.currentBoard.value = { id: 'board-2' } + response.resolve(undefined) + await pendingDelete + + expect(state.currentBoardLabels.value.map(label => label.id)).toEqual(['lbl-1', 'lbl-2']) + expect(helpers.markBoardDetailMutation).toHaveBeenCalledWith('board-1') + }) +}) diff --git a/frontend/taskdeck-web/src/tests/store/board/labelStoreReadErrors.spec.ts b/frontend/taskdeck-web/src/tests/store/board/labelStoreReadErrors.spec.ts new file mode 100644 index 000000000..72da586ca --- /dev/null +++ b/frontend/taskdeck-web/src/tests/store/board/labelStoreReadErrors.spec.ts @@ -0,0 +1,113 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ref } from 'vue' + +const { mockLabelsApi } = vi.hoisted(() => ({ + mockLabelsApi: { + getLabels: vi.fn(), + createLabel: vi.fn(), + updateLabel: vi.fn(), + deleteLabel: vi.fn(), + }, +})) + +vi.mock('../../../api/labelsApi', () => ({ labelsApi: mockLabelsApi })) + +import { createLabelActions } from '../../../store/board/labelStore' + +const savedLabel = { id: 'label-1', boardId: 'board-1', name: 'Saved', colorHex: '#123456' } + +function deferred() { + let resolve!: (value: T) => void + let reject!: (error: unknown) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} + +function createHarness() { + const state = { + currentBoard: ref<{ id: string } | null>({ id: 'board-1' }), + currentBoardLabels: ref>([]), + loading: ref(false), + error: ref(null), + } + const helpers = { + isDemoMode: false, + guardDemoMutation: vi.fn(), + markBoardDetailMutation: vi.fn(), + toast: { success: vi.fn(), warning: vi.fn(), error: vi.fn() }, + handleApiError: vi.fn((error: unknown) => { + state.error.value = error instanceof Error ? error.message : String(error) + throw error + }), + } + return { state, helpers, actions: createLabelActions(state as never, helpers as never) } +} + +describe('labelStore read error ownership', () => { + beforeEach(() => { + for (const mock of Object.values(mockLabelsApi)) mock.mockReset() + }) + + it('does not publish an older read error after a newer read succeeds', async () => { + const older = deferred>() + mockLabelsApi.getLabels.mockReturnValueOnce(older.promise).mockResolvedValueOnce([savedLabel]) + const { state, helpers, actions } = createHarness() + const pending = actions.fetchLabels('board-1').catch(error => error) + + await actions.fetchLabels('board-1') + const failure = new Error('obsolete read failed') + older.reject(failure) + + expect(await pending).toBe(failure) + expect(state.currentBoardLabels.value).toEqual([savedLabel]) + expect(state.error.value).toBeNull() + expect(helpers.handleApiError).not.toHaveBeenCalled() + }) + + it('does not replace a newer read error with an older read error', async () => { + const older = deferred>() + const latestFailure = new Error('current read failed') + mockLabelsApi.getLabels.mockReturnValueOnce(older.promise).mockRejectedValueOnce(latestFailure) + const { state, helpers, actions } = createHarness() + const pending = actions.fetchLabels('board-1').catch(error => error) + + await expect(actions.fetchLabels('board-1')).rejects.toBe(latestFailure) + const staleFailure = new Error('obsolete read failed') + older.reject(staleFailure) + + expect(await pending).toBe(staleFailure) + expect(state.error.value).toBe(latestFailure.message) + expect(helpers.handleApiError).toHaveBeenCalledExactlyOnceWith(latestFailure, 'Failed to fetch labels') + }) + + it('does not publish a pre-write read error after a confirmed label mutation', async () => { + const older = deferred>() + mockLabelsApi.getLabels.mockReturnValueOnce(older.promise) + mockLabelsApi.createLabel.mockResolvedValueOnce(savedLabel) + const { state, helpers, actions } = createHarness() + const pending = actions.fetchLabels('board-1').catch(error => error) + + await actions.createLabel('board-1', { name: savedLabel.name, colorHex: savedLabel.colorHex }) + const failure = new Error('pre-write read failed') + older.reject(failure) + + expect(await pending).toBe(failure) + expect(state.currentBoardLabels.value).toEqual([savedLabel]) + expect(state.error.value).toBeNull() + expect(helpers.handleApiError).not.toHaveBeenCalled() + }) + + it('still publishes and rejects a current read failure', async () => { + const failure = new Error('current read failed') + mockLabelsApi.getLabels.mockRejectedValueOnce(failure) + const { state, helpers, actions } = createHarness() + + await expect(actions.fetchLabels('board-1')).rejects.toBe(failure) + + expect(state.error.value).toBe(failure.message) + expect(helpers.handleApiError).toHaveBeenCalledExactlyOnceWith(failure, 'Failed to fetch labels') + }) +}) diff --git a/frontend/taskdeck-web/src/tests/store/board/labelStoreVisitOrdering.spec.ts b/frontend/taskdeck-web/src/tests/store/board/labelStoreVisitOrdering.spec.ts new file mode 100644 index 000000000..12566e6a7 --- /dev/null +++ b/frontend/taskdeck-web/src/tests/store/board/labelStoreVisitOrdering.spec.ts @@ -0,0 +1,274 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ref } from 'vue' + +const { mockLabelsApi } = vi.hoisted(() => ({ + mockLabelsApi: { + getLabels: vi.fn(), + createLabel: vi.fn(), + updateLabel: vi.fn(), + deleteLabel: vi.fn(), + }, +})) + +vi.mock('../../../api/labelsApi', () => ({ labelsApi: mockLabelsApi })) + +import { createLabelActions } from '../../../store/board/labelStore' + +interface TestLabel { + id: string + boardId: string + name: string + colorHex: string + createdAt: string + updatedAt: string +} + +const originalLabel: TestLabel = { + id: 'lbl-1', + boardId: 'board-1', + name: 'Bug', + colorHex: '#f00', + createdAt: '2026-09-20T10:00:00Z', + updatedAt: '2026-09-20T10:00:00Z', +} + +function createState() { + return { + currentBoard: ref<{ id: string } | null>({ id: 'board-1' }), + currentBoardLabels: ref([{ ...originalLabel }]), + loading: ref(false), + error: ref(null), + } +} + +function createHelpers() { + return { + guardDemoMutation: vi.fn(), + handleApiError: vi.fn(), + isDemoMode: false, + toast: { success: vi.fn(), error: vi.fn(), warning: vi.fn() }, + markBoardDetailMutation: vi.fn(), + } +} + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((settle) => { + resolve = settle + }) + return { promise, resolve } +} + +async function flushPromises() { + await Promise.resolve() + await Promise.resolve() +} + +describe('labelStore visit and settlement ordering', () => { + beforeEach(() => { + vi.clearAllMocks() + mockLabelsApi.getLabels.mockReset() + mockLabelsApi.createLabel.mockReset() + mockLabelsApi.updateLabel.mockReset() + mockLabelsApi.deleteLabel.mockReset() + }) + + it('does not let an earlier A visit overwrite the authoritative A read after A to B to A', async () => { + const state = createState() + const helpers = createHelpers() + const oldVisitRead = deferred() + const reopenedRead = deferred() + mockLabelsApi.getLabels + .mockReturnValueOnce(oldVisitRead.promise) + .mockReturnValueOnce(reopenedRead.promise) + const { fetchLabels } = createLabelActions(state as never, helpers as never) + + const pendingOldRead = fetchLabels('board-1') + + state.currentBoard.value = { id: 'board-2' } + state.currentBoardLabels.value = [] + state.currentBoard.value = { id: 'board-1' } + state.currentBoardLabels.value = [] + const reopenedCache = state.currentBoardLabels.value + const pendingReopenedRead = fetchLabels('board-1') + + oldVisitRead.resolve([{ ...originalLabel, name: 'Old visit' }]) + await pendingOldRead + expect(state.currentBoardLabels.value).toBe(reopenedCache) + expect(state.currentBoardLabels.value).toEqual([]) + + const authoritative = [{ + ...originalLabel, + name: 'Reopened authoritative', + updatedAt: '2026-09-20T10:02:00Z', + }] + reopenedRead.resolve(authoritative) + await pendingReopenedRead + + expect(state.currentBoardLabels.value).toBe(reopenedCache) + expect(state.currentBoardLabels.value).toEqual(authoritative) + }) + + it('does not let a fetch that started first erase a confirmed label update', async () => { + const state = createState() + const helpers = createHelpers() + const staleRead = deferred() + mockLabelsApi.getLabels.mockReturnValueOnce(staleRead.promise) + const updated = { + ...originalLabel, + name: 'Critical', + updatedAt: '2026-09-20T10:01:00Z', + } + mockLabelsApi.updateLabel.mockResolvedValueOnce(updated) + const actions = createLabelActions(state as never, helpers as never) + + const pendingRead = actions.fetchLabels('board-1') + await actions.updateLabel('board-1', 'lbl-1', { name: 'Critical' }) + staleRead.resolve([{ ...originalLabel }]) + await pendingRead + + expect(state.currentBoardLabels.value).toEqual([updated]) + }) + + it('patches the current same-board cache when a detail refresh replaces the array before write settlement', async () => { + const state = createState() + const helpers = createHelpers() + const update = deferred() + mockLabelsApi.updateLabel.mockReturnValueOnce(update.promise) + const actions = createLabelActions(state as never, helpers as never) + + const pendingUpdate = actions.updateLabel('board-1', 'lbl-1', { name: 'Critical' }) + + const refreshedCache = [{ ...originalLabel, name: 'Pre-write refresh' }] + state.currentBoardLabels.value = refreshedCache + const installedCache = state.currentBoardLabels.value + const updated = { + ...originalLabel, + name: 'Critical', + updatedAt: '2026-09-20T10:01:00Z', + } + update.resolve(updated) + await pendingUpdate + + expect(state.currentBoardLabels.value).toBe(installedCache) + expect(state.currentBoardLabels.value).toEqual([updated]) + expect(mockLabelsApi.getLabels).not.toHaveBeenCalled() + }) + + it('reconciles a successful old-visit write into the currently reopened same board', async () => { + const state = createState() + const helpers = createHelpers() + const update = deferred() + const reconciliation = deferred() + mockLabelsApi.updateLabel.mockReturnValueOnce(update.promise) + mockLabelsApi.getLabels.mockReturnValueOnce(reconciliation.promise) + const actions = createLabelActions(state as never, helpers as never) + + const pendingUpdate = actions.updateLabel('board-1', 'lbl-1', { name: 'Old visit edit' }) + + state.currentBoard.value = { id: 'board-2' } + state.currentBoardLabels.value = [] + state.currentBoard.value = { id: 'board-1' } + state.currentBoardLabels.value = [{ ...originalLabel, name: 'Reopened pre-write value' }] + const reopenedCache = state.currentBoardLabels.value + + const updated = { + ...originalLabel, + name: 'Old visit edit', + updatedAt: '2026-09-20T10:01:00Z', + } + update.resolve(updated) + await flushPromises() + expect(mockLabelsApi.getLabels).toHaveBeenCalledWith('board-1') + + reconciliation.resolve([updated]) + await pendingUpdate + + expect(state.currentBoardLabels.value).toBe(reopenedCache) + expect(state.currentBoardLabels.value).toEqual([updated]) + expect(helpers.toast.success).not.toHaveBeenCalledWith('Label updated successfully') + }) + + it('does not start a queued label write after the board session has ended', async () => { + const state = createState() + const helpers = createHelpers() + const firstUpdate = deferred() + mockLabelsApi.updateLabel + .mockReturnValueOnce(firstUpdate.promise) + .mockResolvedValueOnce({ + ...originalLabel, + name: 'Second', + updatedAt: '2026-09-20T10:02:00Z', + }) + const actions = createLabelActions(state as never, helpers as never) + + const pendingFirst = actions.updateLabel('board-1', 'lbl-1', { name: 'First' }) + const pendingSecond = actions + .updateLabel('board-1', 'lbl-1', { name: 'Second' }) + .catch(error => error as Error) + + await flushPromises() + expect(mockLabelsApi.updateLabel).toHaveBeenCalledTimes(1) + + state.currentBoard.value = null + state.currentBoardLabels.value = [] + firstUpdate.resolve({ + ...originalLabel, + name: 'First', + updatedAt: '2026-09-20T10:01:00Z', + }) + await pendingFirst + const cancellation = await pendingSecond + + expect(cancellation.name).toBe('StaleBoardVisitError') + expect(mockLabelsApi.updateLabel).toHaveBeenCalledTimes(1) + expect(helpers.handleApiError).not.toHaveBeenCalled() + expect(state.currentBoardLabels.value).toEqual([]) + }) + + it('serializes overlapping updates so the later intent commits last and owns the cache', async () => { + const state = createState() + const helpers = createHelpers() + const firstUpdate = deferred() + const secondUpdate = deferred() + const authoritativeRead = deferred() + mockLabelsApi.updateLabel + .mockReturnValueOnce(firstUpdate.promise) + .mockReturnValueOnce(secondUpdate.promise) + mockLabelsApi.getLabels.mockReturnValueOnce(authoritativeRead.promise) + const actions = createLabelActions(state as never, helpers as never) + + const pendingFirst = actions.updateLabel('board-1', 'lbl-1', { name: 'First' }) + const pendingSecond = actions.updateLabel('board-1', 'lbl-1', { name: 'Second' }) + + await flushPromises() + expect(mockLabelsApi.updateLabel).toHaveBeenCalledTimes(1) + + const firstResult = { + ...originalLabel, + name: 'First', + updatedAt: '2026-09-20T10:01:00Z', + } + firstUpdate.resolve(firstResult) + await pendingFirst + await flushPromises() + expect(mockLabelsApi.updateLabel).toHaveBeenCalledTimes(2) + expect(state.currentBoardLabels.value).toEqual([firstResult]) + + const pendingRead = actions.fetchLabels('board-1') + const secondResult = { + ...originalLabel, + name: 'Second', + updatedAt: '2026-09-20T10:02:00Z', + } + secondUpdate.resolve(secondResult) + await pendingSecond + authoritativeRead.resolve([secondResult]) + await pendingRead + + expect(state.currentBoardLabels.value).toEqual([secondResult]) + expect(helpers.toast.success).toHaveBeenCalledTimes(2) + expect(helpers.toast.success).toHaveBeenNthCalledWith(1, 'Label updated successfully') + expect(helpers.toast.success).toHaveBeenNthCalledWith(2, 'Label updated successfully') + }) +})