From e2fea70d7bab90fec87cb47e7d0b7f49c6943809 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Tue, 22 Sep 2026 19:43:06 +0100 Subject: [PATCH 1/3] fix(board): retire old session mutations after logout --- docs/STATUS.md | 18 +- .../src/store/board/boardCrudStore.ts | 31 ++- .../src/store/board/boardStoreHelpers.ts | 6 + .../src/store/board/cardCommentStore.ts | 24 +- .../taskdeck-web/src/store/board/cardStore.ts | 39 ++- .../src/store/board/labelStore.ts | 24 +- .../store/board/boardMutationSession.spec.ts | 236 ++++++++++++++++++ .../store/board/cardCommentStore.spec.ts | 2 + .../src/tests/store/board/cardStore.spec.ts | 2 + .../board/cardStoreDeleteConcurrency.spec.ts | 2 + .../src/tests/store/board/labelStore.spec.ts | 2 + .../store/board/labelStoreOwnership.spec.ts | 2 + .../store/board/labelStoreReadErrors.spec.ts | 2 + .../board/labelStoreVisitOrdering.spec.ts | 2 + 14 files changed, 350 insertions(+), 42 deletions(-) create mode 100644 frontend/taskdeck-web/src/tests/store/board/boardMutationSession.spec.ts diff --git a/docs/STATUS.md b/docs/STATUS.md index 354510e886..59bd42b42f 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -2,6 +2,22 @@ Last Updated: 2026-09-22 +## Logout retires board mutation settlements (#3306) + +Board, card (including archive/restore), label and comment operations capture the shared +session generation before transport. Logout advances it before clearing state. Old responses +still settle for their original callers but cannot repopulate caches, change counts or board +selection, invalidate new-session detail reads, publish toasts/errors, or clear new loading. +Queued label writes stop before transport across logout, including when no board detail was +loaded, and stale writes cannot initiate recovery reads under the next session. Direct card, +label, comment and provenance reads honor the same client boundary. Same-session programmatic +writes before detail loads continue to work. Deferred real-store regressions cover these cases. + +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 remains #3305; card/comment ordering PRs #3312/#3304 require separate +reconciliation with these guards before integration. + ## Column writes follow their board visit (#3314) Create, update, delete and reorder share one mutation lane per board, preserving intent order @@ -16,7 +32,7 @@ for that recovery and keeps its later result. This reduces navigation-induced board maintenance while preserving existing review-first proposal behavior. It does not cancel a write already accepted by the server. Shared ownership -for card, comment, label and board mutations remains tracked in #3306; shared loading arbitration +for card, comment, label and board mutations is covered by #3306 above; shared loading arbitration remains tracked in #3305. Deferred-response store tests and BoardView lifecycle tests cover the route/session boundary, and the existing three ordering assertions now compare actual reactive array identities as well as full contents. diff --git a/frontend/taskdeck-web/src/store/board/boardCrudStore.ts b/frontend/taskdeck-web/src/store/board/boardCrudStore.ts index 1f1983a911..c1705f0932 100644 --- a/frontend/taskdeck-web/src/store/board/boardCrudStore.ts +++ b/frontend/taskdeck-web/src/store/board/boardCrudStore.ts @@ -10,7 +10,7 @@ import { buildDemoBoardList } from '../../utils/demoData' import { applyBoardCardCounts } from '../../utils/boardCardCounts' import type { CreateBoardDto, UpdateBoardDto } from '../../types/board' import { initialCardFilters, type BoardState } from './boardState' -import type { BoardHelpers } from './boardStoreHelpers' +import { captureBoardSession, type BoardHelpers } from './boardStoreHelpers' // Minimum gap between board-list fetches. Multiple views (BoardsListView, // ActivityView, ReviewView, etc.) can call fetchBoards on mount in quick @@ -594,27 +594,31 @@ export function createBoardCrudActions(state: BoardState, helpers: BoardHelpers) async function createBoard(board: CreateBoardDto) { helpers.guardDemoMutation() + const isCurrentSession = captureBoardSession(state) try { state.loading.value = true state.error.value = null const newBoard = await boardsApi.createBoard(board) + if (!isCurrentSession()) return newBoard state.boards.value.push(newBoard) helpers.toast.success(`Board "${newBoard.name}" created successfully`) return newBoard } catch (e: unknown) { - helpers.handleApiError(e, 'Failed to create board') + if (isCurrentSession()) helpers.handleApiError(e, 'Failed to create board') throw e } finally { - state.loading.value = false + if (isCurrentSession()) state.loading.value = false } } async function updateBoard(boardId: string, board: UpdateBoardDto) { helpers.guardDemoMutation() + const isCurrentSession = captureBoardSession(state) try { state.loading.value = true state.error.value = null const updatedBoard = await boardsApi.updateBoard(boardId, board) + if (!isCurrentSession()) return updatedBoard // Board settings are part of the board-detail fan-out, so a detail read // that captured the pre-save state must not replace this update (#2435). helpers.markBoardDetailMutation(boardId) @@ -633,19 +637,21 @@ export function createBoardCrudActions(state: BoardState, helpers: BoardHelpers) helpers.toast.success('Board updated successfully') return updatedBoard } catch (e: unknown) { - helpers.handleApiError(e, 'Failed to update board') + if (isCurrentSession()) helpers.handleApiError(e, 'Failed to update board') throw e } finally { - state.loading.value = false + if (isCurrentSession()) state.loading.value = false } } async function deleteBoard(boardId: string) { helpers.guardDemoMutation() + const isCurrentSession = captureBoardSession(state) try { state.loading.value = true state.error.value = null await boardsApi.deleteBoard(boardId) + if (!isCurrentSession()) return // Clear detailed state for the current board before removing it from the // main boards list. This prevents any watchers on the `boards` array @@ -673,10 +679,10 @@ export function createBoardCrudActions(state: BoardState, helpers: BoardHelpers) helpers.toast.success('Board archived successfully') } catch (e: unknown) { - helpers.handleApiError(e, 'Failed to archive board') + if (isCurrentSession()) helpers.handleApiError(e, 'Failed to archive board') throw e } finally { - state.loading.value = false + if (isCurrentSession()) state.loading.value = false } } @@ -691,8 +697,9 @@ export function createBoardCrudActions(state: BoardState, helpers: BoardHelpers) * without ending the session, and clearing on them would drop the board the * user is looking at. * - * Two generations are bumped rather than one because the list and the detail - * read are separate lifecycles. A bumped generation is what makes an + * The shared session generation retires mutations and direct cache reads, + * including requests begun before any board detail loaded. List and detail + * reads additionally retain their separate request generations. A bump makes an * already-issued request safe: the response still arrives, finds its * generation stale, and returns without writing board state, the loading * flag, a throttle stamp, or an error surface. The loading flag is in that @@ -702,9 +709,9 @@ export function createBoardCrudActions(state: BoardState, helpers: BoardHelpers) * in flight during the reset would land afterwards and repopulate the store * with the previous account's boards. * - * The generation bump makes a late response harmless; the abort keeps it from - * being sent at all, so no request outlives the session that started it. Both - * lifecycles are aborted: every open list read and the active detail read. + * The generation bump suppresses late client effects. Both read lifecycles + * also receive cancellation: every open list read and the active detail read. + * Submitted writes can still complete on the server; reset does not undo them. */ function resetForLogout() { state.boardMutationSessionGeneration.value++ diff --git a/frontend/taskdeck-web/src/store/board/boardStoreHelpers.ts b/frontend/taskdeck-web/src/store/board/boardStoreHelpers.ts index 40ddd94aa5..7a0bf651eb 100644 --- a/frontend/taskdeck-web/src/store/board/boardStoreHelpers.ts +++ b/frontend/taskdeck-web/src/store/board/boardStoreHelpers.ts @@ -13,6 +13,12 @@ import type { BoardState } from './boardState' // thing to a reader, so both map to the same copy. const TIMEOUT_CODES = new Set(['ECONNABORTED', 'ETIMEDOUT']) +/** A null board can mean pre-load or logout; only the reset generation distinguishes them. */ +export function captureBoardSession(state: BoardState): () => boolean { + const generation = state.boardMutationSessionGeneration.value + return () => state.boardMutationSessionGeneration.value === generation +} + /** * Whether this failure is a client-side timeout — a routine outcome on every * board read since #2685 bounded them (`timeout: BOARD_REQUEST_TIMEOUT_MS`, diff --git a/frontend/taskdeck-web/src/store/board/cardCommentStore.ts b/frontend/taskdeck-web/src/store/board/cardCommentStore.ts index e252bfb7d7..65618d8cc0 100644 --- a/frontend/taskdeck-web/src/store/board/cardCommentStore.ts +++ b/frontend/taskdeck-web/src/store/board/cardCommentStore.ts @@ -4,7 +4,7 @@ import { cardCommentsApi } from '../../api/cardCommentsApi' import type { CardComment, CreateCardCommentDto, UpdateCardCommentDto } from '../../types/comments' import type { BoardState } from './boardState' -import type { BoardHelpers } from './boardStoreHelpers' +import { captureBoardSession, type BoardHelpers } from './boardStoreHelpers' export function createCardCommentActions(state: BoardState, helpers: BoardHelpers) { function getCardComments(cardId: string): CardComment[] { @@ -13,25 +13,29 @@ export function createCardCommentActions(state: BoardState, helpers: BoardHelper async function fetchCardComments(boardId: string, cardId: string) { if (helpers.isDemoMode) return [] + const isCurrentSession = captureBoardSession(state) try { const comments = await cardCommentsApi.getComments(boardId, cardId) + if (!isCurrentSession()) return comments state.cardCommentsByCardId.value = { ...state.cardCommentsByCardId.value, [cardId]: comments, } return comments } catch (e: unknown) { - helpers.handleApiError(e, 'Failed to fetch card comments') + if (isCurrentSession()) 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) try { state.loading.value = true 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, @@ -44,10 +48,10 @@ export function createCardCommentActions(state: BoardState, helpers: BoardHelper helpers.toast.success('Comment added') return createdComment } catch (e: unknown) { - helpers.handleApiError(e, 'Failed to create card comment') + if (isCurrentSession()) helpers.handleApiError(e, 'Failed to create card comment') throw e } finally { - state.loading.value = false + if (isCurrentSession()) state.loading.value = false } } @@ -58,10 +62,12 @@ export function createCardCommentActions(state: BoardState, helpers: BoardHelper dto: UpdateCardCommentDto, ) { helpers.guardDemoMutation() + const isCurrentSession = captureBoardSession(state) try { state.loading.value = true 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, @@ -73,19 +79,21 @@ export function createCardCommentActions(state: BoardState, helpers: BoardHelper helpers.toast.success('Comment updated') return updatedComment } catch (e: unknown) { - helpers.handleApiError(e, 'Failed to update card comment') + if (isCurrentSession()) helpers.handleApiError(e, 'Failed to update card comment') throw e } finally { - state.loading.value = false + if (isCurrentSession()) state.loading.value = false } } async function deleteCardComment(boardId: string, cardId: string, commentId: string) { helpers.guardDemoMutation() + const isCurrentSession = captureBoardSession(state) try { state.loading.value = true 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, @@ -93,10 +101,10 @@ export function createCardCommentActions(state: BoardState, helpers: BoardHelper } helpers.toast.success('Comment deleted') } catch (e: unknown) { - helpers.handleApiError(e, 'Failed to delete card comment') + if (isCurrentSession()) helpers.handleApiError(e, 'Failed to delete card comment') throw e } finally { - state.loading.value = false + if (isCurrentSession()) state.loading.value = false } } diff --git a/frontend/taskdeck-web/src/store/board/cardStore.ts b/frontend/taskdeck-web/src/store/board/cardStore.ts index 4e43f6fb14..c0da2f4268 100644 --- a/frontend/taskdeck-web/src/store/board/cardStore.ts +++ b/frontend/taskdeck-web/src/store/board/cardStore.ts @@ -5,7 +5,7 @@ import { cardsApi } from '../../api/cardsApi' import { getErrorMessage } from '../../utils/errorMessage' import type { CardDetachPreview, CreateCardDto, UpdateCardDto, CardCaptureProvenance } from '../../types/board' import type { BoardState } from './boardState' -import type { BoardHelpers } from './boardStoreHelpers' +import { captureBoardSession, type BoardHelpers } from './boardStoreHelpers' import type { BoardFetchOptions } from './boardCrudStore' export function createCardActions( @@ -27,9 +27,11 @@ export function createCardActions( } async function setCardArchived(boardId: string, cardId: string, archive: boolean, expectedUpdatedAt: string, expectedChildrenFingerprint?: string) { helpers.guardDemoMutation() + const isCurrentSession = captureBoardSession(state) const updated = expectedChildrenFingerprint === undefined ? await cardsApi.setArchived(boardId, cardId, archive, expectedUpdatedAt) : await cardsApi.setArchived(boardId, cardId, archive, expectedUpdatedAt, expectedChildrenFingerprint) + if (!isCurrentSession()) return updated helpers.markBoardDetailMutation(boardId) if (state.currentBoard.value?.id === boardId) { const existed = state.currentBoardCards.value.some(card => card.id === cardId) @@ -46,8 +48,11 @@ export function createCardActions( filters?: { search?: string; labelId?: string; columnId?: string }, ) { if (helpers.isDemoMode) return + const isCurrentSession = captureBoardSession(state) try { - state.currentBoardCards.value = await cardsApi.getCards(boardId, filters) + const cards = await cardsApi.getCards(boardId, filters) + if (!isCurrentSession()) return + state.currentBoardCards.value = cards // Keep column card counts in sync with the latest cards collection if (state.currentBoard.value) { @@ -61,17 +66,19 @@ export function createCardActions( }) } } catch (e: unknown) { - helpers.handleApiError(e, 'Failed to fetch cards') + if (isCurrentSession()) helpers.handleApiError(e, 'Failed to fetch cards') throw e } } async function createCard(boardId: string, card: CreateCardDto) { helpers.guardDemoMutation() + const isCurrentSession = captureBoardSession(state) try { state.loading.value = true state.error.value = null const newCard = await cardsApi.createCard(boardId, card) + if (!isCurrentSession()) return newCard helpers.markBoardDetailMutation(boardId) // A board-detail refresh can commit the created card while this POST is // still resolving. Keep that newer snapshot intact instead of appending a @@ -88,15 +95,16 @@ export function createCardActions( helpers.toast.success(`Card "${newCard.title.trim()}" created successfully`) return newCard } catch (e: unknown) { - helpers.handleApiError(e, 'Failed to create card') + if (isCurrentSession()) helpers.handleApiError(e, 'Failed to create card') throw e } finally { - state.loading.value = false + if (isCurrentSession()) state.loading.value = false } } async function updateCard(boardId: string, cardId: string, card: UpdateCardDto) { helpers.guardDemoMutation() + const isCurrentSession = captureBoardSession(state) try { state.loading.value = true state.error.value = null @@ -106,6 +114,7 @@ export function createCardActions( expectedUpdatedAt: card.expectedUpdatedAt ?? existingCard?.updatedAt ?? null, } const updatedCard = await cardsApi.updateCard(boardId, cardId, request) + if (!isCurrentSession()) return updatedCard helpers.markBoardDetailMutation(boardId) // Update the card in the store @@ -117,6 +126,7 @@ export function createCardActions( helpers.toast.success('Card updated successfully') return updatedCard } catch (e: unknown) { + if (!isCurrentSession()) throw e if (helpers.isHttpConflict(e)) { helpers.toast.error(getErrorMessage(e, 'Failed to update card')) } else { @@ -124,17 +134,19 @@ export function createCardActions( } throw e } finally { - state.loading.value = false + if (isCurrentSession()) state.loading.value = false } } async function deleteCard(boardId: string, cardId: string, confirmation?: CardDetachPreview) { helpers.guardDemoMutation() + const isCurrentSession = captureBoardSession(state) let refreshChildren = false try { state.loading.value = true state.error.value = null await cardsApi.deleteCard(boardId, cardId, confirmation) + if (!isCurrentSession()) return helpers.markBoardDetailMutation(boardId) // A move, realtime refresh, or navigation can replace this state while the @@ -161,13 +173,13 @@ export function createCardActions( } helpers.toast.success('Card deleted successfully') } catch (e: unknown) { - helpers.handleApiError(e, 'Failed to delete card') + if (isCurrentSession()) helpers.handleApiError(e, 'Failed to delete card') throw e } finally { - state.loading.value = false + if (isCurrentSession()) state.loading.value = false } // Finish mutation-owned loading/error writes before a refresh can outlive navigation. - if (refreshChildren) await refreshDetachedChildren(boardId) + if (isCurrentSession() && refreshChildren) await refreshDetachedChildren(boardId) } async function moveCard( @@ -177,6 +189,7 @@ export function createCardActions( targetPosition: number, ) { helpers.guardDemoMutation() + const isCurrentSession = captureBoardSession(state) try { state.loading.value = true state.error.value = null @@ -188,6 +201,7 @@ export function createCardActions( targetColumnId, targetPosition, }) + if (!isCurrentSession()) return updatedCard helpers.markBoardDetailMutation(boardId) // The board can change while the move is in flight. Committing to another @@ -219,10 +233,10 @@ export function createCardActions( helpers.toast.success('Card moved successfully') return updatedCard } catch (e: unknown) { - helpers.handleApiError(e, 'Failed to move card') + if (isCurrentSession()) helpers.handleApiError(e, 'Failed to move card') throw e } finally { - state.loading.value = false + if (isCurrentSession()) state.loading.value = false } } @@ -231,12 +245,13 @@ export function createCardActions( cardId: string, ): Promise { if (helpers.isDemoMode) return null + const isCurrentSession = captureBoardSession(state) try { // cardsApi.getCardProvenance already returns null for 404 (manual cards have no // capture provenance — absence is expected, not exceptional). return await cardsApi.getCardProvenance(boardId, cardId) } catch (e: unknown) { - helpers.handleApiError(e, 'Failed to fetch card provenance') + if (isCurrentSession()) helpers.handleApiError(e, 'Failed to fetch card provenance') throw e } } diff --git a/frontend/taskdeck-web/src/store/board/labelStore.ts b/frontend/taskdeck-web/src/store/board/labelStore.ts index 7c3ddec989..4c70502714 100644 --- a/frontend/taskdeck-web/src/store/board/labelStore.ts +++ b/frontend/taskdeck-web/src/store/board/labelStore.ts @@ -10,12 +10,13 @@ import { watch } from 'vue' import { labelsApi } from '../../api/labelsApi' import type { CreateLabelDto, Label, UpdateLabelDto } from '../../types/board' import type { BoardState } from './boardState' -import type { BoardHelpers } from './boardStoreHelpers' +import { captureBoardSession, type BoardHelpers } from './boardStoreHelpers' interface LabelCacheVisit { boardId: string labels: Label[] generation: number + isCurrentSession: () => boolean } class StaleBoardVisitError extends Error { @@ -29,8 +30,8 @@ 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. + // array identity it survives a same-board detail refresh but retires A→B→A. + // The shared session generation also retires logout before any detail loaded. const readVersionByBoardId = new Map() const mutationVersionByBoardId = new Map() const mutationTailByLabelKey = new Map>() @@ -49,6 +50,7 @@ export function createLabelActions(state: BoardState, helpers: BoardHelpers) { boardId, labels: state.currentBoardLabels.value, generation: boardVisitGeneration, + isCurrentSession: captureBoardSession(state), } } @@ -56,7 +58,8 @@ export function createLabelActions(state: BoardState, helpers: BoardHelpers) { const currentBoard = state.currentBoard?.value return ( (currentBoard == null || currentBoard.id === visit.boardId) && - boardVisitGeneration === visit.generation + boardVisitGeneration === visit.generation && + visit.isCurrentSession() ) } @@ -119,8 +122,8 @@ export function createLabelActions(state: BoardState, helpers: BoardHelpers) { } } - async function reconcileCurrentLabelsAfterStaleVisit(boardId: string) { - if (state.currentBoard?.value?.id !== boardId) return + async function reconcileCurrentLabelsAfterStaleVisit(boardId: string, originalVisit: LabelCacheVisit) { + if (!originalVisit.isCurrentSession() || state.currentBoard?.value?.id !== boardId) return const visit = captureLabelVisit(boardId) const readVersion = nextReadVersion(boardId) @@ -183,6 +186,7 @@ export function createLabelActions(state: BoardState, helpers: BoardHelpers) { state.loading.value = true state.error.value = null const newLabel = await labelsApi.createLabel(boardId, label) + if (!visit.isCurrentSession()) return newLabel helpers.markBoardDetailMutation(boardId) markLabelMutation(boardId) @@ -195,7 +199,7 @@ export function createLabelActions(state: BoardState, helpers: BoardHelpers) { } helpers.toast.success(`Label "${newLabel.name}" created successfully`) } else if (state.currentBoard?.value?.id === boardId) { - await reconcileCurrentLabelsAfterStaleVisit(boardId) + await reconcileCurrentLabelsAfterStaleVisit(boardId, visit) } return newLabel } catch (e: unknown) { @@ -220,6 +224,7 @@ export function createLabelActions(state: BoardState, helpers: BoardHelpers) { visit, () => labelsApi.updateLabel(boardId, labelId, label), ) + if (!visit.isCurrentSession()) return updatedLabel helpers.markBoardDetailMutation(boardId) markLabelMutation(boardId) @@ -229,7 +234,7 @@ export function createLabelActions(state: BoardState, helpers: BoardHelpers) { if (index !== -1) currentLabels[index] = updatedLabel helpers.toast.success('Label updated successfully') } else if (state.currentBoard?.value?.id === boardId) { - await reconcileCurrentLabelsAfterStaleVisit(boardId) + await reconcileCurrentLabelsAfterStaleVisit(boardId, visit) } return updatedLabel @@ -255,6 +260,7 @@ export function createLabelActions(state: BoardState, helpers: BoardHelpers) { visit, () => labelsApi.deleteLabel(boardId, labelId), ) + if (!visit.isCurrentSession()) return helpers.markBoardDetailMutation(boardId) markLabelMutation(boardId) @@ -264,7 +270,7 @@ export function createLabelActions(state: BoardState, helpers: BoardHelpers) { if (index !== -1) currentLabels.splice(index, 1) helpers.toast.success('Label deleted successfully') } else if (state.currentBoard?.value?.id === boardId) { - await reconcileCurrentLabelsAfterStaleVisit(boardId) + await reconcileCurrentLabelsAfterStaleVisit(boardId, visit) } } catch (e: unknown) { if (!(e instanceof StaleBoardVisitError) && isCurrentBoardVisit(visit)) { diff --git a/frontend/taskdeck-web/src/tests/store/board/boardMutationSession.spec.ts b/frontend/taskdeck-web/src/tests/store/board/boardMutationSession.spec.ts new file mode 100644 index 0000000000..ada11e1f71 --- /dev/null +++ b/frontend/taskdeck-web/src/tests/store/board/boardMutationSession.spec.ts @@ -0,0 +1,236 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' +import { useBoardStore } from '../../../store/boardStore' +import { useToastStore } from '../../../store/toastStore' +import { boardsApi } from '../../../api/boardsApi' +import { cardsApi } from '../../../api/cardsApi' +import { labelsApi } from '../../../api/labelsApi' +import { cardCommentsApi } from '../../../api/cardCommentsApi' +import type { BoardDetail, Card, Label } from '../../../types/board' +import type { CardComment } from '../../../types/comments' + +vi.mock('../../../api/boardsApi') +vi.mock('../../../api/cardsApi') +vi.mock('../../../api/labelsApi') +vi.mock('../../../api/cardCommentsApi') + +const time = '2026-09-22T12:00:00Z' +const board: BoardDetail = { + id: 'board', name: 'Account A board', description: null, isArchived: false, + createdAt: time, updatedAt: time, + columns: [{ id: 'column', boardId: 'board', name: 'Todo', position: 0, + wipLimit: null, cardCount: 1, createdAt: time, updatedAt: time }], +} +const card: Card = { + id: 'card', boardId: 'board', columnId: 'column', title: 'Account A card', + description: '', dueDate: null, isBlocked: false, blockReason: null, + position: 0, labels: [], createdAt: time, updatedAt: time, +} +const label: Label = { + id: 'label', boardId: 'board', name: 'Account A label', colorHex: '#123456', + createdAt: time, updatedAt: time, +} +const comment: CardComment = { + id: 'comment', boardId: 'board', cardId: 'card', 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) => void + let reject!: (error: unknown) => void + const promise = new Promise((yes, no) => { resolve = yes; reject = no }) + return { promise, resolve, reject } +} + +type Store = ReturnType +const operations = [ + { name: 'create board', api: boardsApi.createBoard, result: board, + start: (s: Store) => s.createBoard({ name: 'Account A board' }) }, + { name: 'update board', api: boardsApi.updateBoard, result: board, + start: (s: Store) => s.updateBoard('board', { name: 'Account A board' }) }, + { name: 'delete board', api: boardsApi.deleteBoard, result: undefined, + start: (s: Store) => s.deleteBoard('board') }, + { name: 'create card', api: cardsApi.createCard, result: card, + start: (s: Store) => s.createCard('board', { columnId: 'column', title: card.title }) }, + { name: 'update card', api: cardsApi.updateCard, result: card, + start: (s: Store) => s.updateCard('board', 'card', { title: card.title }) }, + { name: 'delete card', api: cardsApi.deleteCard, result: undefined, + start: (s: Store) => s.deleteCard('board', 'card') }, + { name: 'move card', api: cardsApi.moveCard, result: card, + start: (s: Store) => s.moveCard('board', 'card', 'column', 0) }, + { name: 'archive card', api: cardsApi.setArchived, result: card, + start: (s: Store) => s.setCardArchived('board', 'card', true, time) }, + { name: 'restore card', api: cardsApi.setArchived, result: card, + start: (s: Store) => s.setCardArchived('board', 'card', false, time) }, + { name: 'create label', api: labelsApi.createLabel, result: label, + start: (s: Store) => s.createLabel('board', { name: label.name, colorHex: label.colorHex }) }, + { name: 'update label', api: labelsApi.updateLabel, result: label, + start: (s: Store) => s.updateLabel('board', 'label', { name: label.name }) }, + { name: 'delete label', api: labelsApi.deleteLabel, result: undefined, + start: (s: Store) => s.deleteLabel('board', 'label') }, + { name: 'create comment', api: cardCommentsApi.createComment, result: comment, + start: (s: Store) => s.createCardComment('board', 'card', { content: comment.content }) }, + { name: 'update comment', api: cardCommentsApi.updateComment, result: comment, + start: (s: Store) => s.updateCardComment('board', 'card', 'comment', { content: comment.content }) }, + { name: 'delete comment', api: cardCommentsApi.deleteComment, result: undefined, + start: (s: Store) => s.deleteCardComment('board', 'card', 'comment') }, +] + +function installNextSession(s: Store) { + s.boards = [{ ...board, name: 'Account B board' }] + s.currentBoard = structuredClone({ ...board, name: 'Account B board' }) + s.activeBoardId = 'board' + // Keep the same resource IDs, including a child that would trigger a recovery GET. + s.currentBoardCards = [{ ...card, title: 'Account B card' }, + { ...card, id: 'child', parentCardId: 'card', title: 'Account B child' }] + s.currentBoardLabels = [{ ...label, name: 'Account B label' }] + s.cardCommentsByCardId = { card: [{ ...comment, content: 'Account B comment' }] } + s.loading = true + s.error = 'Account B error' +} + +function snapshot(s: Store) { + return JSON.parse(JSON.stringify({ boards: s.boards, currentBoard: s.currentBoard, + activeBoardId: s.activeBoardId, cards: s.currentBoardCards, labels: s.currentBoardLabels, + comments: s.cardCommentsByCardId, loading: s.loading, error: s.error })) +} + +describe('board mutation session ownership', () => { + beforeEach(() => { vi.resetAllMocks(); setActivePinia(createPinia()) }) + + for (const op of operations) { + for (const replacement of ['logged out', 'next account'] as const) { + it(`${op.name}: late success cannot change ${replacement} state`, async () => { + const s = useBoardStore() + const toast = useToastStore() + const success = vi.spyOn(toast, 'success') + const warning = vi.spyOn(toast, 'warning') + const pending = deferred() + vi.mocked(op.api).mockReturnValueOnce(pending.promise as never) + // No committed detail: logout must invalidate even null → null transitions. + const call = op.start(s) + s.resetForLogout() + if (replacement === 'next account') installNextSession(s) + const before = snapshot(s) + const refs = [s.boards, s.currentBoard, s.currentBoardCards, s.currentBoardLabels, s.cardCommentsByCardId] + pending.resolve(op.result) + await expect(call).resolves.toEqual(op.result) + expect(snapshot(s)).toEqual(before) + expect([s.boards, s.currentBoard, s.currentBoardCards, s.currentBoardLabels, s.cardCommentsByCardId]) + .toEqual(refs) + refs.forEach((ref, index) => expect([s.boards, s.currentBoard, s.currentBoardCards, + s.currentBoardLabels, s.cardCommentsByCardId][index]).toBe(ref)) + expect(success).not.toHaveBeenCalled() + expect(warning).not.toHaveBeenCalled() + expect(boardsApi.getBoard).not.toHaveBeenCalled() + expect(labelsApi.getLabels).not.toHaveBeenCalled() + }) + } + + it(`${op.name}: late rejection reaches caller without changing next account`, async () => { + const s = useBoardStore() + const toast = useToastStore() + const errorToast = vi.spyOn(toast, 'error') + const pending = deferred() + vi.mocked(op.api).mockReturnValueOnce(pending.promise as never) + const failure = { response: { status: 409 }, message: 'Account A conflict' } + const call = op.start(s) + const rejected = expect(call).rejects.toBe(failure) + s.resetForLogout() + installNextSession(s) + const before = snapshot(s) + pending.reject(failure) + await rejected + expect(snapshot(s)).toEqual(before) + expect(errorToast).not.toHaveBeenCalled() + }) + } + + it('drops a queued label change across logout with no committed board before either session', async () => { + const s = useBoardStore() + const pending = deferred