diff --git a/docs/STATUS.md b/docs/STATUS.md index 54624cd0e..ac13ebeb7 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -2,6 +2,23 @@ Last Updated: 2026-09-22 +## Comment ordering retains session and loading ownership (#3303) + +Comment reads publish only while their exact cache visit, latest-read version and local +mutation version still match. Successful writes invalidate older snapshots, and repeated +creates preserve an existing stable comment ID. Same-comment update/delete operations run +in intent order so the server and cache agree. Already submitted queued edits and deletes +continue across same-session navigation while the departed cache stays protected; successful +writes from a prior visit reconcile a currently reopened same-board cache after completion. + +The imported ordering work also uses the shared session and loading owners from #3306/#3305. +Logout before any board loads retires queued comment transport, and old settlements or +reconciliation reads cannot publish into a new account. Queued writes retain loading until +their own settlement. Deferred tests cover the combined boundaries and preserve the source +ordering regressions. This prevents confirmed comment edits disappearing during navigation +and reduces manual refreshes without changing review-first proposal behavior. Recovery from an +unanswered old-session write holding a same-comment queue remains tracked in #3362. + ## Board loading belongs to pending operations (#3305) Board list/detail reads and the mutations that show shared loading now retain individual @@ -34,8 +51,8 @@ notifications. Store results and errors still settle for the original caller. This prevents old-account data from reappearing and reduces cleanup after account switching. It preserves review-first proposal behavior and does not undo server writes. Same-session -loading arbitration is covered by #3305 above; card/comment ordering PRs #3312/#3304 require separate -reconciliation with these guards before integration. +loading arbitration is covered by #3305 above, and comment ordering is integrated through +#3303 above. Card ordering PR #3312 still requires separate reconciliation before integration. ## Column writes follow their board visit (#3314) diff --git a/frontend/taskdeck-web/src/store/board/cardCommentStore.ts b/frontend/taskdeck-web/src/store/board/cardCommentStore.ts index 17df0032e..92ea0f6b9 100644 --- a/frontend/taskdeck-web/src/store/board/cardCommentStore.ts +++ b/frontend/taskdeck-web/src/store/board/cardCommentStore.ts @@ -1,54 +1,216 @@ /** * 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 { beginBoardLoading, captureBoardSession, type BoardHelpers } from './boardStoreHelpers' +interface CommentCacheVisit { + boardId: string + cache: Record + generation: number + isCurrentSession: () => boolean +} + +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 from A→B→A. The shared session epoch also covers logout with no + // committed board. 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, + isCurrentSession: captureBoardSession(state), + } + } + + function isCurrentBoardVisit(visit: CommentCacheVisit) { + const currentBoard = state.currentBoard?.value + return ( + visit.isCurrentSession() && + (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. A same-session board change only retires cache + // publication; it must not discard an already accepted edit or delete. + if (!visit.isCurrentSession()) 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, previousVisit: CommentCacheVisit, + ) { + if (!previousVisit.isCurrentSession() || 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 isCurrentSession = captureBoardSession(state) + const visit = captureCommentCacheVisit(boardId) + const readVersion = nextReadVersion(cardId) + const mutationVersion = currentMutationVersion(cardId) try { const comments = await cardCommentsApi.getComments(boardId, cardId) - if (!isCurrentSession()) return comments - 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) { - if (isCurrentSession()) 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 isCurrentSession = captureBoardSession(state) + const visit = captureCommentCacheVisit(boardId) const finishLoading = beginBoardLoading(state) try { state.error.value = null const createdComment = await cardCommentsApi.createComment(boardId, cardId, dto) - if (!isCurrentSession()) return createdComment - 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(), - ), - } + if (!visit.isCurrentSession()) return createdComment + 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, visit) + } return createdComment } catch (e: unknown) { - if (isCurrentSession()) helpers.handleApiError(e, 'Failed to create card comment') + if (isCurrentBoardVisit(visit)) { + helpers.handleApiError(e, 'Failed to create card comment') + } throw e } finally { finishLoading() @@ -62,24 +224,37 @@ export function createCardCommentActions(state: BoardState, helpers: BoardHelper dto: UpdateCardCommentDto, ) { helpers.guardDemoMutation() - const isCurrentSession = captureBoardSession(state) + const visit = captureCommentCacheVisit(boardId) const finishLoading = beginBoardLoading(state) try { state.error.value = null - const updatedComment = await cardCommentsApi.updateComment(boardId, cardId, commentId, dto) - if (!isCurrentSession()) return updatedComment - 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), + ) + if (!visit.isCurrentSession()) return updatedComment + 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, visit) } - - helpers.toast.success('Comment updated') return updatedComment } catch (e: unknown) { - if (isCurrentSession()) 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 { finishLoading() @@ -88,20 +263,33 @@ export function createCardCommentActions(state: BoardState, helpers: BoardHelper async function deleteCardComment(boardId: string, cardId: string, commentId: string) { helpers.guardDemoMutation() - const isCurrentSession = captureBoardSession(state) + const visit = captureCommentCacheVisit(boardId) const finishLoading = beginBoardLoading(state) try { state.error.value = null - await cardCommentsApi.deleteComment(boardId, cardId, commentId) - if (!isCurrentSession()) return - 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), + ) + if (!visit.isCurrentSession()) return + 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, visit) } - helpers.toast.success('Comment deleted') } catch (e: unknown) { - if (isCurrentSession()) 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 { finishLoading() 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..b4acecb03 --- /dev/null +++ b/frontend/taskdeck-web/src/tests/store/board/cardCommentStoreConcurrency.spec.ts @@ -0,0 +1,201 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ref } from 'vue' +import { createBoardState } from '../../../store/board/boardState' + +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 { + ...createBoardState(), + 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..e520fc568 --- /dev/null +++ b/frontend/taskdeck-web/src/tests/store/board/cardCommentStoreNavigation.spec.ts @@ -0,0 +1,140 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ref } from 'vue' +import { createBoardState } from '../../../store/board/boardState' + +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 { + ...createBoardState(), + 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/cardCommentStoreSessionIntegration.spec.ts b/frontend/taskdeck-web/src/tests/store/board/cardCommentStoreSessionIntegration.spec.ts new file mode 100644 index 000000000..aaed1154a --- /dev/null +++ b/frontend/taskdeck-web/src/tests/store/board/cardCommentStoreSessionIntegration.spec.ts @@ -0,0 +1,160 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' +import { cardCommentsApi } from '../../../api/cardCommentsApi' +import { useBoardStore } from '../../../store/boardStore' +import type { BoardDetail } from '../../../types/board' +import type { CardComment } from '../../../types/comments' + +vi.mock('../../../api/cardCommentsApi') + +const time = '2026-09-22T12:00:00Z' +const column = { + id: 'column-a', boardId: 'board-a', name: 'Todo', position: 0, + wipLimit: null, cardCount: 1, createdAt: time, updatedAt: time, +} +const boardA: BoardDetail = { + id: 'board-a', name: 'Account A board', description: null, isArchived: false, + createdAt: time, updatedAt: time, columns: [column], +} +const boardB: BoardDetail = { ...boardA, id: 'board-b', name: 'Other board' } +const commentA: CardComment = { + id: 'comment-a', boardId: 'board-a', cardId: 'card-a', parentCommentId: null, + authorUserId: 'account-a', authorUsername: 'account-a', content: 'Account A comment', + isDeleted: false, editedAt: null, mentions: [], createdAt: time, updatedAt: time, +} + +function deferred() { + let resolve!: (value: T | PromiseLike) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((yes, no) => { resolve = yes; reject = no }) + return { promise, resolve, reject } +} + +async function flushPromises() { + await Promise.resolve() + await Promise.resolve() +} + +function installCommentCache(store: ReturnType, comment: CardComment) { + store.cardCommentsByCardId = { [comment.cardId]: [structuredClone(comment)] } +} + +describe('card comment session integration', () => { + beforeEach(() => { + vi.resetAllMocks() + setActivePinia(createPinia()) + }) + + it('rejects a queued same-comment write before transport across null logout and preserves the new error', async () => { + const store = useBoardStore() + const first = deferred() + const secondTransportFailure = new Error('queued transport must not start') + vi.mocked(cardCommentsApi.updateComment) + .mockReturnValueOnce(first.promise) + .mockRejectedValueOnce(secondTransportFailure) + + const firstCall = store.updateCardComment('board-a', 'card-a', 'comment-a', { content: 'First edit' }) + const secondCall = store.updateCardComment('board-a', 'card-a', 'comment-a', { content: 'Second edit' }) + const secondRejected = expect(secondCall).rejects.toMatchObject({ name: 'StaleBoardVisitError' }) + + store.resetForLogout() + expect(store.currentBoard).toBeNull() + store.error = 'new-session-error' + first.resolve(structuredClone(commentA)) + + await expect(firstCall).resolves.toEqual(commentA) + await secondRejected + expect(cardCommentsApi.updateComment).toHaveBeenCalledTimes(1) + expect(store.error).toBe('new-session-error') + }) + + it('keeps shared loading through queued same-comment writes until the final request settles', async () => { + const store = useBoardStore() + const first = deferred() + const second = deferred() + vi.mocked(cardCommentsApi.updateComment) + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise) + + const firstCall = store.updateCardComment('board-a', 'card-a', 'comment-a', { content: 'First edit' }) + const secondCall = store.updateCardComment('board-a', 'card-a', 'comment-a', { content: 'Second edit' }) + expect(store.loading).toBe(true) + expect(cardCommentsApi.updateComment).toHaveBeenCalledTimes(1) + + first.resolve(structuredClone(commentA)) + await expect(firstCall).resolves.toEqual(commentA) + await flushPromises() + expect(cardCommentsApi.updateComment).toHaveBeenCalledTimes(2) + expect(store.loading).toBe(true) + + const secondResult = { ...commentA, content: 'Second edit', updatedAt: '2026-09-22T12:01:00Z' } + second.resolve(secondResult) + await expect(secondCall).resolves.toEqual(secondResult) + expect(store.loading).toBe(false) + }) + + it.each(['update', 'delete'] as const)('preserves an accepted queued %s after same-session navigation', async (operation) => { + const store = useBoardStore() + store.currentBoard = structuredClone(boardA) + installCommentCache(store, commentA) + const first = deferred() + const finalComment = { ...commentA, content: 'Last accepted edit' } + vi.mocked(cardCommentsApi.updateComment) + .mockReturnValueOnce(first.promise) + .mockResolvedValueOnce(finalComment) + vi.mocked(cardCommentsApi.deleteComment).mockResolvedValueOnce(undefined) + + const firstCall = store.updateCardComment('board-a', 'card-a', 'comment-a', { content: 'First edit' }) + const queuedCall = operation === 'update' + ? store.updateCardComment('board-a', 'card-a', 'comment-a', { content: finalComment.content }) + : store.deleteCardComment('board-a', 'card-a', 'comment-a') + const outcome = queuedCall.then(value => ({ value }), error => ({ error })) + + store.currentBoard = structuredClone(boardB) + const nextBoardComment = { ...commentA, boardId: 'board-b', cardId: 'card-b', content: 'Other board' } + installCommentCache(store, nextBoardComment) + first.resolve({ ...commentA, content: 'First edit' }) + + await firstCall + await expect(outcome).resolves.toEqual({ value: operation === 'update' ? finalComment : undefined }) + if (operation === 'update') { + expect(cardCommentsApi.updateComment).toHaveBeenLastCalledWith('board-a', 'card-a', 'comment-a', { content: finalComment.content }) + } else { + expect(cardCommentsApi.deleteComment).toHaveBeenCalledWith('board-a', 'card-a', 'comment-a') + } + expect(store.cardCommentsByCardId).toEqual({ 'card-b': [nextBoardComment] }) + expect(cardCommentsApi.getComments).not.toHaveBeenCalled() + expect(store.loading).toBe(false) + }) + + it('does not publish an old A-to-B-to-A reconciliation after logout and a new same-id account', async () => { + const store = useBoardStore() + store.currentBoard = structuredClone(boardA) + installCommentCache(store, commentA) + const oldWrite = deferred() + const oldReconciliation = deferred() + vi.mocked(cardCommentsApi.updateComment).mockReturnValueOnce(oldWrite.promise) + vi.mocked(cardCommentsApi.getComments).mockReturnValueOnce(oldReconciliation.promise) + + const write = store.updateCardComment('board-a', 'card-a', 'comment-a', { content: 'Old visit edit' }) + store.currentBoard = structuredClone(boardB) + store.cardCommentsByCardId = {} + store.currentBoard = structuredClone(boardA) + const reopened = { ...commentA, content: 'Reopened account A value' } + installCommentCache(store, reopened) + + const updated = { ...commentA, content: 'Old visit edit', updatedAt: '2026-09-22T12:01:00Z' } + oldWrite.resolve(updated) + await flushPromises() + expect(cardCommentsApi.getComments).toHaveBeenCalledWith('board-a', 'card-a') + + store.resetForLogout() + store.currentBoard = structuredClone(boardA) + const newAccountComment = { ...commentA, content: 'New account value' } + installCommentCache(store, newAccountComment) + + oldReconciliation.resolve([updated]) + await expect(write).resolves.toEqual(updated) + expect(store.cardCommentsByCardId).toEqual({ 'card-a': [newAccountComment] }) + }) +}) 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..206c70602 --- /dev/null +++ b/frontend/taskdeck-web/src/tests/store/board/cardCommentStoreVisitOrdering.spec.ts @@ -0,0 +1,311 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ref } from 'vue' +import { createBoardState } from '../../../store/board/boardState' + +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 { + ...createBoardState(), + 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 logout has ended the session', 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.boardMutationSessionGeneration.value++ + 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') + }) +})