From af45f52c5ac84cae10a5b28c4507bc86e7eab8f0 Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Fri, 16 Oct 2026 12:55:27 +0800 Subject: [PATCH] fix(sync): process marked boards before reporting batch failures --- src/components/Board/Board.actions.js | 178 +++++++-------- .../Board/__tests__/Board.markedSync.test.js | 215 ++++++++++++++++++ 2 files changed, 298 insertions(+), 95 deletions(-) create mode 100644 src/components/Board/__tests__/Board.markedSync.test.js diff --git a/src/components/Board/Board.actions.js b/src/components/Board/Board.actions.js index 88d789a22..d908ea502 100644 --- a/src/components/Board/Board.actions.js +++ b/src/components/Board/Board.actions.js @@ -1264,50 +1264,43 @@ export function updateApiObjectsNoChild( return async (dispatch, getState) => { //create - update parent board const action = createParentBoard ? createApiBoard : updateApiBoard; - return await dispatch(action(parentBoard, parentBoard.id)) - .then((res) => { - const updatedParentBoardId = res.id; - //add new boards to the active communicator - if (parentBoard.id !== updatedParentBoardId) { - dispatch( - replaceBoardCommunicator(parentBoard.id, updatedParentBoardId) - ); - } - + return await dispatch(action(parentBoard, parentBoard.id)).then((res) => { + const updatedParentBoardId = res.id; + //add new boards to the active communicator + if (parentBoard.id !== updatedParentBoardId) { dispatch( - replaceDefaultHomeBoardIfIsNescesary( - parentBoard.id, - updatedParentBoardId - ) + replaceBoardCommunicator(parentBoard.id, updatedParentBoardId) ); + } - //check if parent board is the root board of the communicator - const comm = getState().communicator.communicators.find( - (communicator) => - communicator.id === getState().communicator.activeCommunicatorId - ); - if (comm.rootBoard === parentBoard.id) { - comm.rootBoard = updatedParentBoardId; - comm.activeBoardId = updatedParentBoardId; - dispatch(upsertCommunicator(comm)); - } - return dispatch(upsertApiCommunicator(comm)) - .then(async () => { - await dispatch(updateApiMarkedBoards()); - return updatedParentBoardId; - }) - .catch((e) => { - throw new Error(e.message); - }); - }) - .catch((e) => { - throw new Error(e.message); + dispatch( + replaceDefaultHomeBoardIfIsNescesary( + parentBoard.id, + updatedParentBoardId + ) + ); + + //check if parent board is the root board of the communicator + const comm = getState().communicator.communicators.find( + (communicator) => + communicator.id === getState().communicator.activeCommunicatorId + ); + if (comm.rootBoard === parentBoard.id) { + comm.rootBoard = updatedParentBoardId; + comm.activeBoardId = updatedParentBoardId; + dispatch(upsertCommunicator(comm)); + } + return dispatch(upsertApiCommunicator(comm)).then(async () => { + await dispatch(updateApiMarkedBoards()); + return updatedParentBoardId; }); + }); }; } export function updateApiMarkedBoards() { return async (dispatch, getState) => { const allBoards = [...getState().board.boards]; + const failures = []; for await (const board of allBoards) { const boardsIds = getState().board.boards?.map((board) => board.id); if (!boardsIds.includes(board.id)) continue; @@ -1323,7 +1316,7 @@ export function updateApiMarkedBoards() { await dispatch(updateApiBoard(board)); dispatch(unmarkBoard(board.id)); } catch (e) { - throw new Error(e.message); + failures.push({ boardId: board.id, operation: 'update', error: e }); } } if (isLocalBoard(board) && board.shouldCreateBoard) { @@ -1350,10 +1343,17 @@ export function updateApiMarkedBoards() { replaceBoard({ ...boardData }, { ...boardData, id: boardId }) ); } catch (err) { - console.log(err.message); + failures.push({ boardId: board.id, operation: 'create', error: err }); } } } + if (failures.length > 0) { + const error = new Error( + `Failed to sync marked boards: ${failures.map(({ boardId }) => boardId).join(', ')}` + ); + error.failures = failures; + throw error; + } }; } @@ -1371,68 +1371,56 @@ export function updateApiObjects( ) { return (dispatch, getState) => { //create child board - return dispatch(createApiBoard(childBoard, childBoard.id)) - .then((res) => { - const updatedChildBoardId = res.id; - //create - update parent board - const updateTilesParentBoard = () => - parentBoard.tiles.map((tile) => { - if (tile.loadBoard === childBoard.id) - return { ...tile, loadBoard: updatedChildBoardId }; - return tile; - }); - const updatedParentBoard = { - ...parentBoard, - tiles: createParentBoard - ? updateTilesParentBoard() - : parentBoard.tiles - }; - const action = createParentBoard ? createApiBoard : updateApiBoard; - return dispatch(action(updatedParentBoard, parentBoard.id)) - .then((res) => { - const updatedParentBoardId = res.id; - //add new boards to the active communicator + return dispatch(createApiBoard(childBoard, childBoard.id)).then((res) => { + const updatedChildBoardId = res.id; + //create - update parent board + const updateTilesParentBoard = () => + parentBoard.tiles.map((tile) => { + if (tile.loadBoard === childBoard.id) + return { ...tile, loadBoard: updatedChildBoardId }; + return tile; + }); + const updatedParentBoard = { + ...parentBoard, + tiles: createParentBoard ? updateTilesParentBoard() : parentBoard.tiles + }; + const action = createParentBoard ? createApiBoard : updateApiBoard; + return dispatch(action(updatedParentBoard, parentBoard.id)).then( + (res) => { + const updatedParentBoardId = res.id; + //add new boards to the active communicator + dispatch( + replaceBoardCommunicator(childBoard.id, updatedChildBoardId) + ); + if (parentBoard.id !== updatedParentBoardId) { dispatch( - replaceBoardCommunicator(childBoard.id, updatedChildBoardId) + replaceBoardCommunicator(parentBoard.id, updatedParentBoardId) ); - if (parentBoard.id !== updatedParentBoardId) { - dispatch( - replaceBoardCommunicator(parentBoard.id, updatedParentBoardId) - ); - } + } - dispatch( - replaceDefaultHomeBoardIfIsNescesary( - parentBoard.id, - updatedParentBoardId - ) - ); + dispatch( + replaceDefaultHomeBoardIfIsNescesary( + parentBoard.id, + updatedParentBoardId + ) + ); - //check if parent board is the root board of the communicator - const comm = getState().communicator.communicators.find( - (communicator) => - communicator.id === getState().communicator.activeCommunicatorId - ); - if (comm.rootBoard === parentBoard.id) { - comm.rootBoard = updatedParentBoardId; - comm.activeBoardId = updatedParentBoardId; - dispatch(upsertCommunicator(comm)); - } - return dispatch(upsertApiCommunicator(comm)) - .then(() => { - dispatch(updateApiMarkedBoards()); - return updatedParentBoardId; - }) - .catch((e) => { - throw new Error(e.message); - }); - }) - .catch((e) => { - throw new Error(e.message); + //check if parent board is the root board of the communicator + const comm = getState().communicator.communicators.find( + (communicator) => + communicator.id === getState().communicator.activeCommunicatorId + ); + if (comm.rootBoard === parentBoard.id) { + comm.rootBoard = updatedParentBoardId; + comm.activeBoardId = updatedParentBoardId; + dispatch(upsertCommunicator(comm)); + } + return dispatch(upsertApiCommunicator(comm)).then(async () => { + await dispatch(updateApiMarkedBoards()); + return updatedParentBoardId; }); - }) - .catch((e) => { - throw new Error(e.message); - }); + } + ); + }); }; } diff --git a/src/components/Board/__tests__/Board.markedSync.test.js b/src/components/Board/__tests__/Board.markedSync.test.js new file mode 100644 index 000000000..61b57ff07 --- /dev/null +++ b/src/components/Board/__tests__/Board.markedSync.test.js @@ -0,0 +1,215 @@ +import { applyMiddleware, combineReducers, createStore } from 'redux'; +import thunk from 'redux-thunk'; +import API from '../../../api/api'; +import boardReducer from '../Board.reducer'; +import communicatorReducer from '../../Communicator/Communicator.reducer'; +import { + updateApiMarkedBoards, + updateApiObjectsNoChild, + updateApiObjects +} from '../Board.actions'; +import { SYNC_STATUS } from '../Board.constants'; + +jest.mock('../../../api/api', () => ({ + uploadBoardLocalMedia: jest.fn(), + updateBoard: jest.fn(), + createBoard: jest.fn(), + updateCommunicator: jest.fn() +})); + +const ids = [ + 'aaaaaaaaaaaaaaaaaaaaaaaa', + 'bbbbbbbbbbbbbbbbbbbbbbbb', + 'cccccccccccccccccccccccc' +]; +const userData = { email: 'user@example.com', name: 'User' }; +const markedBoard = (id) => ({ + id, + name: id, + email: userData.email, + tiles: [], + markToUpdate: true +}); + +function setup(boards) { + return createStore( + combineReducers({ + board: boardReducer, + communicator: communicatorReducer, + app: () => ({ userData }), + language: () => ({ lang: 'en-US' }) + }), + { + board: { + ...boardReducer(undefined, {}), + boards, + syncMeta: Object.fromEntries( + boards.map((board) => [board.id, { status: SYNC_STATUS.PENDING }]) + ) + }, + communicator: { + activeCommunicatorId: 'dddddddddddddddddddddddd', + communicators: [ + { + id: 'dddddddddddddddddddddddd', + email: userData.email, + rootBoard: boards[0].id, + boards: boards.map((board) => board.id) + } + ] + } + }, + applyMiddleware(thunk) + ); +} + +beforeEach(() => { + jest.resetAllMocks(); + API.uploadBoardLocalMedia.mockImplementation(async (board) => ({ + board, + hadFailure: false + })); + API.updateBoard.mockImplementation(async (board) => ({ + ...board, + lastEdited: '2026-10-16' + })); + API.createBoard.mockImplementation(async (board) => ({ + ...board, + id: 'eeeeeeeeeeeeeeeeeeeeeeee' + })); + API.updateCommunicator.mockImplementation( + async (communicator) => communicator + ); +}); + +it('processes later boards and reports every failed update after the batch', async () => { + const firstError = new Error('First update failed'); + const secondError = new Error('Second update failed'); + API.updateBoard + .mockRejectedValueOnce(firstError) + .mockRejectedValueOnce(secondError); + const store = setup(ids.map(markedBoard)); + + const failure = await store + .dispatch(updateApiMarkedBoards()) + .catch((error) => error); + + expect(API.updateBoard.mock.calls.map(([board]) => board.id)).toEqual(ids); + expect(failure).toMatchObject({ + failures: [ + { boardId: ids[0], operation: 'update', error: firstError }, + { boardId: ids[1], operation: 'update', error: secondError } + ] + }); + expect(failure.message).toContain(ids[0]); + expect(failure.message).toContain(ids[1]); + const state = store.getState().board; + expect(state.boards.map((board) => board.markToUpdate)).toEqual([ + true, + true, + false + ]); + expect(state.syncMeta[ids[0]].status).toBe(SYNC_STATUS.PENDING); + expect(state.syncMeta[ids[1]].status).toBe(SYNC_STATUS.PENDING); + expect(state.syncMeta[ids[2]].status).toBe(SYNC_STATUS.SYNCED); +}); + +it('reports a failed create while still updating later boards', async () => { + API.createBoard.mockRejectedValue(new Error('Create failed')); + const local = { + ...markedBoard('local123'), + markToUpdate: false, + shouldCreateBoard: true + }; + const store = setup([local, markedBoard(ids[0])]); + + const failure = await store + .dispatch(updateApiMarkedBoards()) + .catch((error) => error); + + expect(API.updateBoard).toHaveBeenCalledWith( + expect.objectContaining({ id: ids[0] }) + ); + expect(failure).toMatchObject({ + failures: [ + { boardId: local.id, operation: 'create', error: expect.any(Error) } + ] + }); + expect(failure.failures[0].error.message).toBe('Create failed'); + expect(store.getState().board.syncMeta[local.id].status).toBe( + SYNC_STATUS.PENDING + ); + expect(store.getState().board.syncMeta[ids[0]].status).toBe( + SYNC_STATUS.SYNCED + ); +}); + +it('resolves normally and clears update flags for a successful batch', async () => { + const store = setup(ids.map(markedBoard)); + + await expect( + store.dispatch(updateApiMarkedBoards()) + ).resolves.toBeUndefined(); + + expect(API.updateBoard).toHaveBeenCalledTimes(3); + expect( + store.getState().board.boards.every((board) => !board.markToUpdate) + ).toBe(true); + expect( + Object.values(store.getState().board.syncMeta).every( + (meta) => meta.status === SYNC_STATUS.SYNCED + ) + ).toBe(true); +}); + +it('preserves failure details through the single-board cascade caller', async () => { + const local = { ...markedBoard('local123'), markToUpdate: false }; + const store = setup([local, markedBoard(ids[0]), markedBoard(ids[1])]); + API.updateBoard.mockRejectedValueOnce(new Error('Marked update failed')); + + await expect( + store.dispatch(updateApiObjectsNoChild(local, true)) + ).rejects.toMatchObject({ + failures: [ + { boardId: ids[0], operation: 'update', error: expect.any(Error) } + ] + }); + + expect(API.updateBoard.mock.calls.map(([board]) => board.id)).toEqual([ + ids[0], + ids[1] + ]); + expect(store.getState().board.syncMeta[ids[1]].status).toBe( + SYNC_STATUS.SYNCED + ); +}); + +it('awaits and reports marked-board failures through the parent/child caller', async () => { + const child = { ...markedBoard('local123'), markToUpdate: false }; + const parent = { + ...markedBoard(ids[0]), + tiles: [{ id: 'folder', loadBoard: child.id }] + }; + const store = setup([child, parent]); + API.updateBoard.mockResolvedValueOnce({ + ...parent, + lastEdited: '2026-10-16' + }); + API.updateBoard.mockRejectedValueOnce( + new Error('Later marked update failed') + ); + + await expect( + store.dispatch(updateApiObjects(child, parent)) + ).rejects.toMatchObject({ + failures: [ + { boardId: parent.id, operation: 'update', error: expect.any(Error) } + ] + }); + + expect(API.updateBoard).toHaveBeenCalledTimes(2); + expect( + store.getState().board.boards.find((board) => board.id === parent.id) + .markToUpdate + ).toBe(true); +});