diff --git a/frontend/taskdeck-web/src/store/board/cardCommentStore.ts b/frontend/taskdeck-web/src/store/board/cardCommentStore.ts index e252bfb7d..a7327623a 100644 --- a/frontend/taskdeck-web/src/store/board/cardCommentStore.ts +++ b/frontend/taskdeck-web/src/store/board/cardCommentStore.ts @@ -1,53 +1,211 @@ /** * Card comment operations: fetch, create, update, delete comments. */ +import { watch } from 'vue' import { cardCommentsApi } from '../../api/cardCommentsApi' import type { CardComment, CreateCardCommentDto, UpdateCardCommentDto } from '../../types/comments' import type { BoardState } from './boardState' import type { BoardHelpers } from './boardStoreHelpers' +interface CommentCacheVisit { + boardId: string + cache: Record + generation: number +} + +class StaleBoardVisitError extends Error { + constructor() { + super('The board visit that queued this comment change has ended.') + this.name = 'StaleBoardVisitError' + } +} + export function createCardCommentActions(state: BoardState, helpers: BoardHelpers) { + // Reads and writes share one per-card cache. Cache-container identity protects + // ordinary reads, while a synchronous board-id generation distinguishes one + // visit/session from A→B→A or logout→login. Same-board detail refreshes keep + // that generation and may therefore receive a confirmed write into their new + // cache container. + const readVersionByCardId = new Map() + const mutationVersionByCardId = new Map() + const mutationTailByCommentKey = new Map>() + let boardVisitGeneration = 0 + + watch( + () => state.currentBoard?.value?.id ?? null, + (nextBoardId, previousBoardId) => { + if (nextBoardId !== previousBoardId) boardVisitGeneration++ + }, + { flush: 'sync' }, + ) + + function nextReadVersion(cardId: string) { + const version = (readVersionByCardId.get(cardId) ?? 0) + 1 + readVersionByCardId.set(cardId, version) + return version + } + + function currentMutationVersion(cardId: string) { + return mutationVersionByCardId.get(cardId) ?? 0 + } + + function markCommentMutation(cardId: string) { + mutationVersionByCardId.set(cardId, currentMutationVersion(cardId) + 1) + } + + function captureCommentCacheVisit(boardId: string): CommentCacheVisit { + return { + boardId, + cache: state.cardCommentsByCardId.value, + generation: boardVisitGeneration, + } + } + + function isCurrentBoardVisit(visit: CommentCacheVisit) { + const currentBoard = state.currentBoard?.value + return ( + (currentBoard == null || currentBoard.id === visit.boardId) && + boardVisitGeneration === visit.generation + ) + } + + function ownsExactCommentCache(visit: CommentCacheVisit) { + return isCurrentBoardVisit(visit) && state.cardCommentsByCardId.value === visit.cache + } + + async function runCommentMutation( + cardId: string, + commentId: string, + visit: CommentCacheVisit, + mutation: () => Promise, + ): Promise { + const key = `${cardId}:${commentId}` + const previous = mutationTailByCommentKey.get(key) + let operation: Promise + + if (previous) { + // A failed predecessor must not cancel a later user intent. It still + // settles through its own caller/error path; the next request starts + // afterward if the initiating board session still owns it. + operation = previous.catch(() => undefined).then(() => { + // The HTTP interceptor reads the token when transport starts. Reject a + // queued pre-logout intent before the API callback can run under another + // session's credentials. + 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, + ) + mutationTailByCommentKey.set(key, tail) + + try { + return await operation + } finally { + if (mutationTailByCommentKey.get(key) === tail) { + mutationTailByCommentKey.delete(key) + } + } + } + + async function reconcileCurrentCommentsAfterStaleVisit(boardId: string, cardId: string) { + if (state.currentBoard?.value?.id !== boardId) return + + const visit = captureCommentCacheVisit(boardId) + const readVersion = nextReadVersion(cardId) + const mutationVersion = currentMutationVersion(cardId) + try { + const comments = await cardCommentsApi.getComments(boardId, cardId) + if ( + isCurrentBoardVisit(visit) && + readVersionByCardId.get(cardId) === readVersion && + currentMutationVersion(cardId) === mutationVersion + ) { + // This read begins only after the write succeeded. It is authoritative + // for the currently reopened visit, while any older read is rejected by + // the version/mutation guards above. + state.cardCommentsByCardId.value[cardId] = comments + } + } catch { + if (isCurrentBoardVisit(visit)) { + helpers.toast.warning( + 'Comment saved, but comments could not be refreshed. Reopen the card before editing again.', + ) + } + } + } + function getCardComments(cardId: string): CardComment[] { return state.cardCommentsByCardId.value[cardId] ?? [] } async function fetchCardComments(boardId: string, cardId: string) { if (helpers.isDemoMode) return [] + const visit = captureCommentCacheVisit(boardId) + const readVersion = nextReadVersion(cardId) + const mutationVersion = currentMutationVersion(cardId) try { const comments = await cardCommentsApi.getComments(boardId, cardId) - state.cardCommentsByCardId.value = { - ...state.cardCommentsByCardId.value, - [cardId]: comments, + // A newer read owns the cache. A successful local mutation also + // invalidates every snapshot that began before it, even when that older + // request returns later. The payload is still returned to its caller. + if ( + ownsExactCommentCache(visit) && + readVersionByCardId.get(cardId) === readVersion && + currentMutationVersion(cardId) === mutationVersion + ) { + visit.cache[cardId] = comments } return comments } catch (e: unknown) { - helpers.handleApiError(e, 'Failed to fetch card comments') + if (ownsExactCommentCache(visit)) { + helpers.handleApiError(e, 'Failed to fetch card comments') + } throw e } } async function createCardComment(boardId: string, cardId: string, dto: CreateCardCommentDto) { helpers.guardDemoMutation() + const visit = captureCommentCacheVisit(boardId) try { state.loading.value = true state.error.value = null const createdComment = await cardCommentsApi.createComment(boardId, cardId, dto) - const existingComments = state.cardCommentsByCardId.value[cardId] ?? [] - state.cardCommentsByCardId.value = { - ...state.cardCommentsByCardId.value, - [cardId]: [...existingComments, createdComment].sort( - (left, right) => - new Date(left.createdAt).getTime() - new Date(right.createdAt).getTime(), - ), - } + markCommentMutation(cardId) - helpers.toast.success('Comment added') + if (isCurrentBoardVisit(visit)) { + const currentCache = state.cardCommentsByCardId.value + const existingComments = currentCache[cardId] ?? [] + // A same-board refresh can commit the stable id before this response + // arrives. Preserve that fresher object instead of appending a duplicate. + if (!existingComments.some(comment => comment.id === createdComment.id)) { + currentCache[cardId] = [...existingComments, createdComment].sort( + (left, right) => + new Date(left.createdAt).getTime() - new Date(right.createdAt).getTime(), + ) + } + helpers.toast.success('Comment added') + } else if (state.currentBoard?.value?.id === boardId) { + await reconcileCurrentCommentsAfterStaleVisit(boardId, cardId) + } return createdComment } catch (e: unknown) { - helpers.handleApiError(e, 'Failed to create card comment') + if (isCurrentBoardVisit(visit)) { + helpers.handleApiError(e, 'Failed to create card comment') + } throw e } finally { - state.loading.value = false + if (isCurrentBoardVisit(visit)) state.loading.value = false } } @@ -58,45 +216,73 @@ export function createCardCommentActions(state: BoardState, helpers: BoardHelper dto: UpdateCardCommentDto, ) { helpers.guardDemoMutation() + const visit = captureCommentCacheVisit(boardId) try { state.loading.value = true state.error.value = null - const updatedComment = await cardCommentsApi.updateComment(boardId, cardId, commentId, dto) - const existingComments = state.cardCommentsByCardId.value[cardId] ?? [] - state.cardCommentsByCardId.value = { - ...state.cardCommentsByCardId.value, - [cardId]: existingComments.map((comment) => + // The API has no revision/If-Match field. Serialize same-comment writes so + // server commit order follows user intent order; filtering a late success + // client-side would otherwise let the server silently keep the older edit. + const updatedComment = await runCommentMutation( + cardId, + commentId, + visit, + () => cardCommentsApi.updateComment(boardId, cardId, commentId, dto), + ) + markCommentMutation(cardId) + + if (isCurrentBoardVisit(visit)) { + const currentCache = state.cardCommentsByCardId.value + const existingComments = currentCache[cardId] ?? [] + currentCache[cardId] = existingComments.map((comment) => comment.id === commentId ? updatedComment : comment, - ), + ) + helpers.toast.success('Comment updated') + } else if (state.currentBoard?.value?.id === boardId) { + await reconcileCurrentCommentsAfterStaleVisit(boardId, cardId) } - - helpers.toast.success('Comment updated') return updatedComment } catch (e: unknown) { - helpers.handleApiError(e, 'Failed to update card comment') + if (!(e instanceof StaleBoardVisitError) && isCurrentBoardVisit(visit)) { + helpers.handleApiError(e, 'Failed to update card comment') + } throw e } finally { - state.loading.value = false + if (isCurrentBoardVisit(visit)) state.loading.value = false } } async function deleteCardComment(boardId: string, cardId: string, commentId: string) { helpers.guardDemoMutation() + const visit = captureCommentCacheVisit(boardId) try { state.loading.value = true state.error.value = null - await cardCommentsApi.deleteComment(boardId, cardId, commentId) - const existingComments = state.cardCommentsByCardId.value[cardId] ?? [] - state.cardCommentsByCardId.value = { - ...state.cardCommentsByCardId.value, - [cardId]: existingComments.filter((comment) => comment.id !== commentId), + await runCommentMutation( + cardId, + commentId, + visit, + () => cardCommentsApi.deleteComment(boardId, cardId, commentId), + ) + markCommentMutation(cardId) + + if (isCurrentBoardVisit(visit)) { + const currentCache = state.cardCommentsByCardId.value + const existingComments = currentCache[cardId] ?? [] + currentCache[cardId] = existingComments.filter( + (comment) => comment.id !== commentId, + ) + helpers.toast.success('Comment deleted') + } else if (state.currentBoard?.value?.id === boardId) { + await reconcileCurrentCommentsAfterStaleVisit(boardId, cardId) } - helpers.toast.success('Comment deleted') } catch (e: unknown) { - helpers.handleApiError(e, 'Failed to delete card comment') + if (!(e instanceof StaleBoardVisitError) && isCurrentBoardVisit(visit)) { + helpers.handleApiError(e, 'Failed to delete card comment') + } throw e } finally { - state.loading.value = false + if (isCurrentBoardVisit(visit)) state.loading.value = false } } diff --git a/frontend/taskdeck-web/src/tests/store/board/cardCommentStoreConcurrency.spec.ts b/frontend/taskdeck-web/src/tests/store/board/cardCommentStoreConcurrency.spec.ts new file mode 100644 index 000000000..92fbead2a --- /dev/null +++ b/frontend/taskdeck-web/src/tests/store/board/cardCommentStoreConcurrency.spec.ts @@ -0,0 +1,199 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ref } from 'vue' + +const { mockCardCommentsApi } = vi.hoisted(() => ({ + mockCardCommentsApi: { + getComments: vi.fn(), + createComment: vi.fn(), + updateComment: vi.fn(), + deleteComment: vi.fn(), + }, +})) + +vi.mock('../../../api/cardCommentsApi', () => ({ + cardCommentsApi: mockCardCommentsApi, +})) + +import { createCardCommentActions } from '../../../store/board/cardCommentStore' + +interface TestComment { + id: string + content: string + createdAt: string + updatedAt: string +} + +const originalComment: TestComment = { + id: 'cmt-1', + content: 'Original', + createdAt: '2026-09-20T10:00:00Z', + updatedAt: '2026-09-20T10:00:00Z', +} + +const secondComment: TestComment = { + id: 'cmt-2', + content: 'Second', + createdAt: '2026-09-20T10:01:00Z', + updatedAt: '2026-09-20T10:01:00Z', +} + +function createState() { + return { + currentBoard: ref<{ id: string } | null>({ id: 'board-1' }), + cardCommentsByCardId: ref>({ + 'card-1': [{ ...originalComment }], + }), + loading: ref(false), + error: ref(null), + } +} + +function createHelpers() { + return { + guardDemoMutation: vi.fn(), + handleApiError: vi.fn(), + isDemoMode: false, + toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() }, + } +} + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((settle) => { + resolve = settle + }) + return { promise, resolve } +} + +describe('cardCommentStore cache ordering', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCardCommentsApi.getComments.mockReset() + mockCardCommentsApi.createComment.mockReset() + mockCardCommentsApi.updateComment.mockReset() + mockCardCommentsApi.deleteComment.mockReset() + }) + + it('does not let a fetch that started first erase a created comment', async () => { + const state = createState() + const helpers = createHelpers() + const staleRead = deferred() + mockCardCommentsApi.getComments.mockReturnValueOnce(staleRead.promise) + mockCardCommentsApi.createComment.mockResolvedValueOnce({ ...secondComment }) + const actions = createCardCommentActions(state as never, helpers as never) + + const pendingRead = actions.fetchCardComments('board-1', 'card-1') + await actions.createCardComment('board-1', 'card-1', { content: 'Second' }) + staleRead.resolve([{ ...originalComment }]) + await pendingRead + + expect(state.cardCommentsByCardId.value['card-1'].map(comment => comment.id)).toEqual([ + 'cmt-1', + 'cmt-2', + ]) + }) + + it('does not let a fetch that started first erase an updated comment', async () => { + const state = createState() + const helpers = createHelpers() + const staleRead = deferred() + mockCardCommentsApi.getComments.mockReturnValueOnce(staleRead.promise) + mockCardCommentsApi.updateComment.mockResolvedValueOnce({ + ...originalComment, + content: 'Edited', + updatedAt: '2026-09-20T10:02:00Z', + }) + const actions = createCardCommentActions(state as never, helpers as never) + + const pendingRead = actions.fetchCardComments('board-1', 'card-1') + await actions.updateCardComment('board-1', 'card-1', 'cmt-1', { content: 'Edited' }) + staleRead.resolve([{ ...originalComment }]) + await pendingRead + + expect(state.cardCommentsByCardId.value['card-1'][0].content).toBe('Edited') + }) + + it('does not let a fetch that started first restore a deleted comment', async () => { + const state = createState() + state.cardCommentsByCardId.value['card-1'].push({ ...secondComment }) + const helpers = createHelpers() + const staleRead = deferred() + mockCardCommentsApi.getComments.mockReturnValueOnce(staleRead.promise) + mockCardCommentsApi.deleteComment.mockResolvedValueOnce(undefined) + const actions = createCardCommentActions(state as never, helpers as never) + + const pendingRead = actions.fetchCardComments('board-1', 'card-1') + await actions.deleteCardComment('board-1', 'card-1', 'cmt-1') + staleRead.resolve([{ ...originalComment }, { ...secondComment }]) + await pendingRead + + expect(state.cardCommentsByCardId.value['card-1'].map(comment => comment.id)).toEqual([ + 'cmt-2', + ]) + }) + + it('lets only the latest overlapping fetch commit for a card', async () => { + const state = createState() + const helpers = createHelpers() + const firstRead = deferred() + const secondRead = deferred() + mockCardCommentsApi.getComments + .mockReturnValueOnce(firstRead.promise) + .mockReturnValueOnce(secondRead.promise) + const actions = createCardCommentActions(state as never, helpers as never) + + const pendingFirstRead = actions.fetchCardComments('board-1', 'card-1') + const pendingSecondRead = actions.fetchCardComments('board-1', 'card-1') + secondRead.resolve([{ ...secondComment }]) + await pendingSecondRead + firstRead.resolve([{ ...originalComment }]) + const firstResult = await pendingFirstRead + + expect(firstResult).toEqual([{ ...originalComment }]) + expect(state.cardCommentsByCardId.value['card-1']).toEqual([{ ...secondComment }]) + }) + + it('does not repopulate the prior board cache after navigation', async () => { + const state = createState() + const helpers = createHelpers() + const staleRead = deferred() + mockCardCommentsApi.getComments.mockReturnValueOnce(staleRead.promise) + const actions = createCardCommentActions(state as never, helpers as never) + + const pendingRead = actions.fetchCardComments('board-1', 'card-1') + const nextBoardCache = { + 'card-next': [{ ...secondComment, id: 'cmt-next' }], + } + state.currentBoard.value = { id: 'board-2' } + state.cardCommentsByCardId.value = nextBoardCache + staleRead.resolve([{ ...originalComment }]) + await pendingRead + + expect(state.cardCommentsByCardId.value).toEqual(nextBoardCache) + expect(state.cardCommentsByCardId.value).not.toHaveProperty('card-1') + }) + + it('preserves a fresher stable-id comment that a refresh committed before create settles', async () => { + const state = createState() + const helpers = createHelpers() + const pendingCreateResponse = deferred() + mockCardCommentsApi.createComment.mockReturnValueOnce(pendingCreateResponse.promise) + const actions = createCardCommentActions(state as never, helpers as never) + + const pendingCreate = actions.createCardComment('board-1', 'card-1', { + content: 'Second', + }) + const refreshed = { + ...secondComment, + content: 'Second from authoritative refresh', + updatedAt: '2026-09-20T10:03:00Z', + } + state.cardCommentsByCardId.value['card-1'].push(refreshed) + pendingCreateResponse.resolve({ ...secondComment }) + await pendingCreate + + expect( + state.cardCommentsByCardId.value['card-1'].filter(comment => comment.id === 'cmt-2'), + ).toEqual([refreshed]) + }) +}) diff --git a/frontend/taskdeck-web/src/tests/store/board/cardCommentStoreNavigation.spec.ts b/frontend/taskdeck-web/src/tests/store/board/cardCommentStoreNavigation.spec.ts new file mode 100644 index 000000000..ec4843e24 --- /dev/null +++ b/frontend/taskdeck-web/src/tests/store/board/cardCommentStoreNavigation.spec.ts @@ -0,0 +1,138 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ref } from 'vue' + +const { mockCardCommentsApi } = vi.hoisted(() => ({ + mockCardCommentsApi: { + getComments: vi.fn(), + createComment: vi.fn(), + updateComment: vi.fn(), + deleteComment: vi.fn(), + }, +})) + +vi.mock('../../../api/cardCommentsApi', () => ({ + cardCommentsApi: mockCardCommentsApi, +})) + +import { createCardCommentActions } from '../../../store/board/cardCommentStore' + +interface TestComment { + id: string + content: string + createdAt: string + updatedAt: string +} + +const originalComment: TestComment = { + id: 'cmt-1', + content: 'Original', + createdAt: '2026-09-20T10:00:00Z', + updatedAt: '2026-09-20T10:00:00Z', +} + +function createState() { + return { + currentBoard: ref<{ id: string } | null>({ id: 'board-1' }), + cardCommentsByCardId: ref>({ + 'card-1': [{ ...originalComment }], + }), + loading: ref(false), + error: ref(null), + } +} + +function createHelpers() { + return { + guardDemoMutation: vi.fn(), + handleApiError: vi.fn(), + isDemoMode: false, + toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() }, + } +} + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((settle) => { + resolve = settle + }) + return { promise, resolve } +} + +function navigateToNextBoard(state: ReturnType) { + const nextBoardCache = { + 'card-next': [{ + ...originalComment, + id: 'cmt-next', + content: 'Next board', + }], + } + state.currentBoard.value = { id: 'board-2' } + state.cardCommentsByCardId.value = nextBoardCache + return nextBoardCache +} + +describe('cardCommentStore late write ownership', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCardCommentsApi.createComment.mockReset() + mockCardCommentsApi.updateComment.mockReset() + mockCardCommentsApi.deleteComment.mockReset() + }) + + it('does not append a create response after another board is selected', async () => { + const state = createState() + const helpers = createHelpers() + const response = deferred() + mockCardCommentsApi.createComment.mockReturnValueOnce(response.promise) + const { createCardComment } = createCardCommentActions(state as never, helpers as never) + + const pendingCreate = createCardComment('board-1', 'card-1', { content: 'Created' }) + const nextBoardCache = navigateToNextBoard(state) + const created = { + ...originalComment, + id: 'cmt-created', + content: 'Created', + } + response.resolve(created) + const result = await pendingCreate + + expect(result).toEqual(created) + expect(state.cardCommentsByCardId.value).toEqual(nextBoardCache) + }) + + it('does not apply an update response after another board is selected', async () => { + const state = createState() + const helpers = createHelpers() + const response = deferred() + mockCardCommentsApi.updateComment.mockReturnValueOnce(response.promise) + const { updateCardComment } = createCardCommentActions(state as never, helpers as never) + + const pendingUpdate = updateCardComment('board-1', 'card-1', 'cmt-1', { + content: 'Edited', + }) + const nextBoardCache = navigateToNextBoard(state) + response.resolve({ + ...originalComment, + content: 'Edited', + updatedAt: '2026-09-20T10:02:00Z', + }) + await pendingUpdate + + expect(state.cardCommentsByCardId.value).toEqual(nextBoardCache) + }) + + it('does not apply a delete response after another board is selected', async () => { + const state = createState() + const helpers = createHelpers() + const response = deferred() + mockCardCommentsApi.deleteComment.mockReturnValueOnce(response.promise) + const { deleteCardComment } = createCardCommentActions(state as never, helpers as never) + + const pendingDelete = deleteCardComment('board-1', 'card-1', 'cmt-1') + const nextBoardCache = navigateToNextBoard(state) + response.resolve(undefined) + await pendingDelete + + expect(state.cardCommentsByCardId.value).toEqual(nextBoardCache) + }) +}) diff --git a/frontend/taskdeck-web/src/tests/store/board/cardCommentStoreVisitOrdering.spec.ts b/frontend/taskdeck-web/src/tests/store/board/cardCommentStoreVisitOrdering.spec.ts new file mode 100644 index 000000000..93eaee536 --- /dev/null +++ b/frontend/taskdeck-web/src/tests/store/board/cardCommentStoreVisitOrdering.spec.ts @@ -0,0 +1,308 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ref } from 'vue' + +const { mockCardCommentsApi } = vi.hoisted(() => ({ + mockCardCommentsApi: { + getComments: vi.fn(), + createComment: vi.fn(), + updateComment: vi.fn(), + deleteComment: vi.fn(), + }, +})) + +vi.mock('../../../api/cardCommentsApi', () => ({ + cardCommentsApi: mockCardCommentsApi, +})) + +import { createCardCommentActions } from '../../../store/board/cardCommentStore' + +interface TestComment { + id: string + content: string + createdAt: string + updatedAt: string +} + +const originalComment: TestComment = { + id: 'cmt-1', + content: 'Original', + createdAt: '2026-09-20T10:00:00Z', + updatedAt: '2026-09-20T10:00:00Z', +} + +const secondComment: TestComment = { + id: 'cmt-2', + content: 'Second', + createdAt: '2026-09-20T10:01:00Z', + updatedAt: '2026-09-20T10:01:00Z', +} + +function createState() { + return { + currentBoard: ref<{ id: string } | null>({ id: 'board-1' }), + cardCommentsByCardId: ref>({ + 'card-1': [{ ...originalComment }], + }), + loading: ref(false), + error: ref(null), + } +} + +function createHelpers() { + return { + guardDemoMutation: vi.fn(), + handleApiError: vi.fn(), + isDemoMode: false, + toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: 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('cardCommentStore visit and mutation ownership', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCardCommentsApi.getComments.mockReset() + mockCardCommentsApi.createComment.mockReset() + mockCardCommentsApi.updateComment.mockReset() + mockCardCommentsApi.deleteComment.mockReset() + }) + + it('patches the current same-board cache when a detail refresh replaces it before update settlement', async () => { + const state = createState() + const helpers = createHelpers() + const update = deferred() + mockCardCommentsApi.updateComment.mockReturnValueOnce(update.promise) + const actions = createCardCommentActions(state as never, helpers as never) + + const pendingUpdate = actions.updateCardComment( + 'board-1', + 'card-1', + 'cmt-1', + { content: 'Edited' }, + ) + + const refreshedCache = { + 'card-1': [{ ...originalComment, content: 'Pre-write refresh' }], + } + state.cardCommentsByCardId.value = refreshedCache + const installedCache = state.cardCommentsByCardId.value + const updated = { + ...originalComment, + content: 'Edited', + updatedAt: '2026-09-20T10:02:00Z', + } + update.resolve(updated) + await pendingUpdate + + expect(state.cardCommentsByCardId.value).toBe(installedCache) + expect(state.cardCommentsByCardId.value['card-1']).toEqual([updated]) + expect(mockCardCommentsApi.getComments).not.toHaveBeenCalled() + }) + + it('patches the current same-board cache when a detail refresh replaces it before create settlement', async () => { + const state = createState() + const helpers = createHelpers() + const create = deferred() + mockCardCommentsApi.createComment.mockReturnValueOnce(create.promise) + const actions = createCardCommentActions(state as never, helpers as never) + + const pendingCreate = actions.createCardComment('board-1', 'card-1', { + content: 'Second', + }) + + const refreshedCache = { + 'card-1': [{ ...originalComment, content: 'Pre-write refresh' }], + } + state.cardCommentsByCardId.value = refreshedCache + const installedCache = state.cardCommentsByCardId.value + create.resolve({ ...secondComment }) + await pendingCreate + + expect(state.cardCommentsByCardId.value).toBe(installedCache) + expect(state.cardCommentsByCardId.value['card-1'].map(comment => comment.id)).toEqual([ + 'cmt-1', + 'cmt-2', + ]) + }) + + it('patches the current same-board cache when a detail refresh replaces it before delete settlement', async () => { + const state = createState() + state.cardCommentsByCardId.value['card-1'].push({ ...secondComment }) + const helpers = createHelpers() + const deletion = deferred() + mockCardCommentsApi.deleteComment.mockReturnValueOnce(deletion.promise) + const actions = createCardCommentActions(state as never, helpers as never) + + const pendingDelete = actions.deleteCardComment('board-1', 'card-1', 'cmt-1') + + const refreshedCache = { + 'card-1': [{ ...originalComment }, { ...secondComment }], + } + state.cardCommentsByCardId.value = refreshedCache + const installedCache = state.cardCommentsByCardId.value + deletion.resolve(undefined) + await pendingDelete + + expect(state.cardCommentsByCardId.value).toBe(installedCache) + expect(state.cardCommentsByCardId.value['card-1'].map(comment => comment.id)).toEqual([ + 'cmt-2', + ]) + }) + + it('reconciles a successful old-visit write into the currently reopened same board', async () => { + const state = createState() + const helpers = createHelpers() + const oldVisitUpdate = deferred() + const reconciliation = deferred() + mockCardCommentsApi.updateComment.mockReturnValueOnce(oldVisitUpdate.promise) + mockCardCommentsApi.getComments.mockReturnValueOnce(reconciliation.promise) + const actions = createCardCommentActions(state as never, helpers as never) + + const pendingUpdate = actions.updateCardComment( + 'board-1', + 'card-1', + 'cmt-1', + { content: 'Old visit edit' }, + ) + + state.currentBoard.value = { id: 'board-2' } + state.cardCommentsByCardId.value = {} + state.currentBoard.value = { id: 'board-1' } + state.cardCommentsByCardId.value = { + 'card-1': [{ ...originalComment, content: 'Reopened pre-write value' }], + } + const reopenedCache = state.cardCommentsByCardId.value + + const updated = { + ...originalComment, + content: 'Old visit edit', + updatedAt: '2026-09-20T10:01:00Z', + } + oldVisitUpdate.resolve(updated) + await flushPromises() + expect(mockCardCommentsApi.getComments).toHaveBeenCalledWith('board-1', 'card-1') + + reconciliation.resolve([updated]) + await pendingUpdate + + expect(state.cardCommentsByCardId.value).toBe(reopenedCache) + expect(state.cardCommentsByCardId.value['card-1']).toEqual([updated]) + expect(helpers.toast.success).not.toHaveBeenCalledWith('Comment updated') + }) + + it('does not start a queued comment write after the board session has ended', async () => { + const state = createState() + const helpers = createHelpers() + const firstEdit = deferred() + mockCardCommentsApi.updateComment + .mockReturnValueOnce(firstEdit.promise) + .mockResolvedValueOnce({ + ...originalComment, + content: 'Second edit', + updatedAt: '2026-09-20T10:02:00Z', + }) + const actions = createCardCommentActions(state as never, helpers as never) + + const pendingFirst = actions.updateCardComment( + 'board-1', + 'card-1', + 'cmt-1', + { content: 'First edit' }, + ) + const pendingSecond = actions + .updateCardComment( + 'board-1', + 'card-1', + 'cmt-1', + { content: 'Second edit' }, + ) + .catch(error => error as Error) + + await flushPromises() + expect(mockCardCommentsApi.updateComment).toHaveBeenCalledTimes(1) + + state.currentBoard.value = null + state.cardCommentsByCardId.value = {} + firstEdit.resolve({ + ...originalComment, + content: 'First edit', + updatedAt: '2026-09-20T10:01:00Z', + }) + await pendingFirst + const cancellation = await pendingSecond + + expect(cancellation).toBeInstanceOf(Error) + expect((cancellation as Error).name).toBe('StaleBoardVisitError') + expect(mockCardCommentsApi.updateComment).toHaveBeenCalledTimes(1) + expect(helpers.handleApiError).not.toHaveBeenCalled() + expect(state.cardCommentsByCardId.value).toEqual({}) + }) + + it('serializes overlapping edits so the later intent commits last and owns the cache', async () => { + const state = createState() + const helpers = createHelpers() + const firstEdit = deferred() + const secondEdit = deferred() + const authoritativeRead = deferred() + mockCardCommentsApi.updateComment + .mockReturnValueOnce(firstEdit.promise) + .mockReturnValueOnce(secondEdit.promise) + mockCardCommentsApi.getComments.mockReturnValueOnce(authoritativeRead.promise) + const actions = createCardCommentActions(state as never, helpers as never) + + const pendingFirst = actions.updateCardComment( + 'board-1', + 'card-1', + 'cmt-1', + { content: 'First edit' }, + ) + const pendingSecond = actions.updateCardComment( + 'board-1', + 'card-1', + 'cmt-1', + { content: 'Second edit' }, + ) + + await flushPromises() + expect(mockCardCommentsApi.updateComment).toHaveBeenCalledTimes(1) + + const firstResult = { + ...originalComment, + content: 'First edit', + updatedAt: '2026-09-20T10:01:00Z', + } + firstEdit.resolve(firstResult) + await pendingFirst + await flushPromises() + expect(mockCardCommentsApi.updateComment).toHaveBeenCalledTimes(2) + expect(state.cardCommentsByCardId.value['card-1']).toEqual([firstResult]) + + const pendingRead = actions.fetchCardComments('board-1', 'card-1') + const secondResult = { + ...originalComment, + content: 'Second edit', + updatedAt: '2026-09-20T10:02:00Z', + } + secondEdit.resolve(secondResult) + await pendingSecond + authoritativeRead.resolve([secondResult]) + await pendingRead + + expect(state.cardCommentsByCardId.value['card-1']).toEqual([secondResult]) + expect(helpers.toast.success).toHaveBeenCalledTimes(2) + expect(helpers.toast.success).toHaveBeenNthCalledWith(1, 'Comment updated') + expect(helpers.toast.success).toHaveBeenNthCalledWith(2, 'Comment updated') + }) +})