diff --git a/src/actions/sponsor-actions.js b/src/actions/sponsor-actions.js index b32b68b40..d67fa0a7c 100644 --- a/src/actions/sponsor-actions.js +++ b/src/actions/sponsor-actions.js @@ -1431,7 +1431,8 @@ export const saveBadgeScan = (entity) => async (dispatch, getState) => { dispatch(startLoading()); const params = { - access_token: accessToken + access_token: accessToken, + expand: "extra_questions" }; const normalizedEntity = normalizeBadgeScan(entity); diff --git a/src/pages/sponsors/__tests__/edit-badge-scan-page.test.js b/src/pages/sponsors/__tests__/edit-badge-scan-page.test.js new file mode 100644 index 000000000..a24e65766 --- /dev/null +++ b/src/pages/sponsors/__tests__/edit-badge-scan-page.test.js @@ -0,0 +1,154 @@ +import React from "react"; +import { createStore, combineReducers, applyMiddleware } from "redux"; +import thunk from "redux-thunk"; +import { act, screen } from "@testing-library/react"; +import { putRequest } from "openstack-uicore-foundation/lib/utils/actions"; +import EditBadgeScanPage from "../edit-badge-scan-page"; +import { saveBadgeScan } from "../../../actions/sponsor-actions"; +import { renderWithRedux } from "../../../utils/test-utils"; +import badgeScanReducer, { + DEFAULT_ENTITY as defaultBadgeScanEntity +} from "../../../reducers/sponsors/badge-scan-reducer"; +import * as methods from "../../../utils/methods"; + +jest.mock("i18n-react/dist/i18n-react", () => ({ + __esModule: true, + default: { translate: (key) => key } +})); + +jest.mock("openstack-uicore-foundation/lib/utils/actions", () => ({ + __esModule: true, + ...jest.requireActual("openstack-uicore-foundation/lib/utils/actions"), + putRequest: jest.fn() +})); + +jest.mock("../../../actions/sponsor-actions", () => ({ + ...jest.requireActual("../../../actions/sponsor-actions"), + getBadgeScan: jest.fn(() => ({ type: "MOCK_ACTION" })), + resetBadgeScanForm: jest.fn(() => ({ type: "MOCK_ACTION" })) +})); + +// ExtraQuestionsForm is a third-party bootstrap form with its own per-type +// rendering; stub it so the test only asserts on the data our page passes +// down, not on how the library renders it. +jest.mock("openstack-uicore-foundation/lib/components/extra-questions", () => { + const react = require("react"); + return { + __esModule: true, + // eslint-disable-next-line no-unused-vars + default: react.forwardRef(({ userAnswers }, ref) => + react.createElement( + "div", + { "data-testid": "extra-questions-answers" }, + JSON.stringify(userAnswers) + ) + ) + }; +}); + +const currentSummitState = (state = { currentSummit: { id: 12 } }) => state; + +describe("EditBadgeScanPage", () => { + const match = { + params: { badge_scan_id: "21" }, + url: "/app/summits/12/badge-scans/21" + }; + + // Shape matches a real badge-scan API response: extra_questions is an + // array of extra-question-answer records, each with its own `id` + // (distinct from `question_id`) and the answer under `value`. + const savedApiResponse = { + id: 21, + notes: "updated notes", + extra_questions: [{ id: 1449348, question_id: 885, value: "Vegetarian" }] + }; + + const buildStore = () => + createStore( + combineReducers({ + currentSummitState, + currentBadgeScanState: badgeScanReducer + }), + { + currentBadgeScanState: { + entity: { + ...defaultBadgeScanEntity, + id: 21, + notes: "original notes", + extra_questions: [ + { id: 1400001, question_id: 885, value: "old answer" } + ], + sponsor_extra_questions: [ + { id: 885, name: "Test", type: "CheckBox", order: 1 } + ] + }, + errors: {} + } + }, + applyMiddleware(thunk) + ); + + beforeEach(() => { + jest.spyOn(methods, "getAccessTokenSafely").mockResolvedValue("TOKEN"); + + // Mirrors the real API: extra_questions only comes back as answer + // objects when it's explicitly asked to be expanded. Without the + // expand, the API returns its unexpanded form — a raw array of the + // answer records' own ids (e.g. [1449348]), not the + // { id, question_id, value } objects. + putRequest.mockImplementation( + (requestActionCreator, receiveActionCreator) => + (params = {}) => + (dispatch) => { + const response = params.expand?.includes("extra_questions") + ? savedApiResponse + : { + id: savedApiResponse.id, + notes: savedApiResponse.notes, + extra_questions: savedApiResponse.extra_questions.map( + (q) => q.id + ) + }; + dispatch(receiveActionCreator({ response })); + return Promise.resolve({ response }); + } + ); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("still shows the saved notes and extra question answers when the edit page is re-entered after a save", async () => { + // Regression guard for the missing-expand bug: saving used to return + // extra_questions in its unexpanded form (raw answer-record ids, not + // answer objects), overwriting the answers in the store, so reopening + // this page showed the badge scan as if it had never been answered + // until a hard refresh re-fetched it. getBadgeScan is mocked out below + // so the second render can't mask that by silently re-fetching correct + // data on its own. + const store = buildStore(); + const { unmount } = renderWithRedux(, { + store + }); + + await act(async () => { + await store.dispatch( + saveBadgeScan({ + id: 21, + notes: "updated notes", + extra_questions: [{ question_id: 885, answer: "Vegetarian" }] + }) + ); + }); + + // simulate navigating away and back to the same edit page + unmount(); + renderWithRedux(, { store }); + + expect(screen.getByDisplayValue("updated notes")).toBeInTheDocument(); + expect(screen.getByTestId("extra-questions-answers")).toHaveTextContent( + JSON.stringify(savedApiResponse.extra_questions) + ); + }); +}); diff --git a/src/reducers/sponsors/__tests__/badge-scan-reducer.test.js b/src/reducers/sponsors/__tests__/badge-scan-reducer.test.js new file mode 100644 index 000000000..39ca79c7b --- /dev/null +++ b/src/reducers/sponsors/__tests__/badge-scan-reducer.test.js @@ -0,0 +1,82 @@ +import badgeScanReducer, { DEFAULT_ENTITY } from "../badge-scan-reducer"; +import { BADGE_SCAN_UPDATED } from "../../../actions/sponsor-actions"; + +describe("badgeScanReducer", () => { + describe("BADGE_SCAN_UPDATED", () => { + it("replaces notes and extra_questions with the values from the save response", () => { + // Regression guard: the edit page showed the badge scan as if the + // answers were never entered right after saving, because without + // `expand=extra_questions` the save response returned extra_questions + // in its unexpanded form — a raw array of the answer records' own ids + // (e.g. [1449348]) instead of the answer objects — and this case + // wrote that over the real answers. + const initialState = { + entity: { + ...DEFAULT_ENTITY, + id: 21, + notes: "old notes", + extra_questions: [ + { id: 1449348, question_id: 885, value: "old answer" } + ], + attendee_full_name: "Jane Doe" + }, + errors: {} + }; + + const newState = badgeScanReducer(initialState, { + type: BADGE_SCAN_UPDATED, + payload: { + response: { + id: 21, + notes: "updated notes", + extra_questions: [ + { id: 1449348, question_id: 885, value: "new answer" } + ] + } + } + }); + + expect(newState.entity.notes).toBe("updated notes"); + expect(newState.entity.extra_questions).toEqual([ + { id: 1449348, question_id: 885, value: "new answer" } + ]); + // fields the update response doesn't return must survive the merge + expect(newState.entity.attendee_full_name).toBe("Jane Doe"); + }); + + it("clobbers extra_questions with the unexpanded answer-id array when the response isn't expanded", () => { + // Documents the failure mode this PR fixes at the source (the action + // not asking the API to expand extra_questions on save): without the + // expand, the API returns extra_questions as a raw array of the + // extra-question-answer records' own ids (not question ids, and not + // answer objects) — this reducer has no way to tell that apart from + // real data, so it commits it as-is. If a future change drops that + // expand again, this reducer will still silently clobber the answers + // exactly like it did before. + const initialState = { + entity: { + ...DEFAULT_ENTITY, + id: 21, + notes: "old notes", + extra_questions: [ + { id: 1449348, question_id: 885, value: "old answer" } + ] + }, + errors: {} + }; + + const newState = badgeScanReducer(initialState, { + type: BADGE_SCAN_UPDATED, + payload: { + response: { + id: 21, + notes: "updated notes", + extra_questions: [1449348] + } + } + }); + + expect(newState.entity.extra_questions).toEqual([1449348]); + }); + }); +});