Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/actions/sponsor-actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
154 changes: 154 additions & 0 deletions src/pages/sponsors/__tests__/edit-badge-scan-page.test.js
Original file line number Diff line number Diff line change
@@ -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(<EditBadgeScanPage match={match} />, {
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(<EditBadgeScanPage match={match} />, { store });

expect(screen.getByDisplayValue("updated notes")).toBeInTheDocument();
expect(screen.getByTestId("extra-questions-answers")).toHaveTextContent(
JSON.stringify(savedApiResponse.extra_questions)
);
});
});
82 changes: 82 additions & 0 deletions src/reducers/sponsors/__tests__/badge-scan-reducer.test.js
Original file line number Diff line number Diff line change
@@ -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]);
});
});
});
Loading