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
249 changes: 246 additions & 3 deletions src/actions/__tests__/sponsor-forms-actions.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,19 @@ import thunk from "redux-thunk";
import flushPromises from "flush-promises";
import {
getRequest,
putRequest
postRequest,
putRequest,
deleteRequest
} from "openstack-uicore-foundation/lib/utils/actions";
import {
getSponsorForms,
normalizeFormTemplate,
normalizeSponsorCustomizedForm,
updateFormTemplateTiers
updateFormTemplateTiers,
removeItemFile,
removeSponsorCustomizedFormItemImages,
saveSponsorFormItem,
updateSponsorFormItem
} from "../sponsor-forms-actions";
import * as methods from "../../utils/methods";

Expand All @@ -21,7 +27,8 @@ jest.mock("openstack-uicore-foundation/lib/utils/actions", () => ({
...jest.requireActual("openstack-uicore-foundation/lib/utils/actions"),
postRequest: jest.fn(),
getRequest: jest.fn(),
putRequest: jest.fn()
putRequest: jest.fn(),
deleteRequest: jest.fn()
}));

describe("Sponsor Forms Actions", () => {
Expand Down Expand Up @@ -288,4 +295,240 @@ describe("Sponsor Forms Actions", () => {
);
});
});

describe("removeItemFile", () => {
const middlewares = [thunk];
const mockStore = configureStore(middlewares);

beforeEach(() => {
jest.spyOn(methods, "getAccessTokenSafely").mockReturnValue("TOKEN");

deleteRequest.mockImplementation(
(requestActionCreator, receiveAction) => () => (dispatch) => {
if (typeof receiveAction === "function") {
dispatch(receiveAction({ response: {} }));
} else {
dispatch(receiveAction);
}
return Promise.resolve({ response: {} });
}
);
});

afterEach(() => {
jest.restoreAllMocks();
});

it("dispatches SPONSOR_FORM_ITEM_FILE_DELETED with fileId and itemId", async () => {
const store = mockStore({
currentSummitState: { currentSummit: { id: 42 } }
});

store.dispatch(removeItemFile(7, 99, 555));
await flushPromises();

expect(deleteRequest).toHaveBeenCalledWith(
null,
{
type: "SPONSOR_FORM_ITEM_FILE_DELETED",
payload: { fileId: 555, itemId: 99 }
},
`${window.PURCHASES_API_URL}/api/v1/summits/42/show-forms/7/items/99/images/555`,
null,
expect.any(Function)
);

const dispatched = store
.getActions()
.find((a) => a.type === "SPONSOR_FORM_ITEM_FILE_DELETED");
expect(dispatched.payload).toEqual({ fileId: 555, itemId: 99 });
});
});

describe("removeSponsorCustomizedFormItemImages", () => {
const middlewares = [thunk];
const mockStore = configureStore(middlewares);

beforeEach(() => {
jest.spyOn(methods, "getAccessTokenSafely").mockReturnValue("TOKEN");

deleteRequest.mockImplementation(
(requestActionCreator, receiveAction) => () => (dispatch) => {
if (typeof receiveAction === "function") {
dispatch(receiveAction({ response: {} }));
} else {
dispatch(receiveAction);
}
return Promise.resolve({ response: {} });
}
);
});

afterEach(() => {
jest.restoreAllMocks();
});

it("dispatches SPONSOR_CUSTOMIZED_FORM_ITEM_IMAGE_DELETED with fileId and itemId", async () => {
const store = mockStore({
currentSummitState: { currentSummit: { id: 42 } },
currentSponsorState: { entity: { id: 5 } }
});

store.dispatch(removeSponsorCustomizedFormItemImages(7, 99, 555));
await flushPromises();

expect(deleteRequest).toHaveBeenCalledWith(
null,
{
type: "SPONSOR_CUSTOMIZED_FORM_ITEM_IMAGE_DELETED",
payload: { fileId: 555, itemId: 99 }
},
`${window.PURCHASES_API_URL}/api/v1/summits/42/sponsors/5/sponsor-forms/7/items/99/images/555`,
null,
expect.any(Function)
);

const dispatched = store
.getActions()
.find((a) => a.type === "SPONSOR_CUSTOMIZED_FORM_ITEM_IMAGE_DELETED");
expect(dispatched.payload).toEqual({ fileId: 555, itemId: 99 });
});
});

describe("saveSponsorFormItem", () => {
const middlewares = [thunk];
const mockStore = configureStore(middlewares);

beforeEach(() => {
jest.spyOn(methods, "getAccessTokenSafely").mockReturnValue("TOKEN");

postRequest.mockImplementation(
() => () => () => Promise.resolve({ response: { id: 100 } })
);
});

afterEach(() => {
jest.restoreAllMocks();
});

it("omits images from the create request body and POSTs new uploads to the images subresource", async () => {
const store = mockStore({
currentSummitState: { currentSummit: { id: 42 } }
});

const entity = {
name: "Item",
images: [{ file_path: "data:image/png;base64,AAA" }],
meta_fields: []
};

await store.dispatch(saveSponsorFormItem(7, entity));
await flushPromises();

expect(postRequest).toHaveBeenNthCalledWith(
1,
null,
expect.any(Function),
`${window.PURCHASES_API_URL}/api/v1/summits/42/show-forms/7/items`,
expect.not.objectContaining({ images: expect.anything() }),
expect.any(Function)
);

// The created item's id (100, from the mocked response) is used to
// POST the new upload to the images subresource - the only path that
// actually materializes the file server-side.
expect(postRequest).toHaveBeenNthCalledWith(
2,
null,
expect.any(Function),
`${window.PURCHASES_API_URL}/api/v1/summits/42/show-forms/7/items/100/images`,
{ file_path: "data:image/png;base64,AAA" },
expect.any(Function),
{ file_path: "data:image/png;base64,AAA" }
);
});
});

describe("updateSponsorFormItem", () => {
const middlewares = [thunk];
const mockStore = configureStore(middlewares);

beforeEach(() => {
jest.spyOn(methods, "getAccessTokenSafely").mockReturnValue("TOKEN");

putRequest.mockImplementation(
() => () => () => Promise.resolve({ response: { id: 100 } })
);
// Clear call history left by the sibling saveSponsorFormItem tests -
// this describe's assertions inspect postRequest's call log directly.
postRequest.mockClear();
postRequest.mockImplementation(
() => () => () => Promise.resolve({ response: {} })
);
});

afterEach(() => {
jest.restoreAllMocks();
});

it("omits persisted images from the update request body so they are never round-tripped", async () => {
const store = mockStore({
currentSummitState: { currentSummit: { id: 42 } }
});

const entity = {
id: 100,
name: "Item",
images: [{ id: 5, file_path: "https://cdn/a.png" }],
meta_fields: []
};

await store.dispatch(updateSponsorFormItem(7, entity));
await flushPromises();

expect(putRequest).toHaveBeenCalledWith(
null,
expect.any(Function),
`${window.PURCHASES_API_URL}/api/v1/summits/42/show-forms/7/items/100`,
expect.not.objectContaining({ images: expect.anything() }),
expect.any(Function)
);

// The image already has an id (persisted) - it must never be resent,
// since the backend replaces the whole collection on update and can't
// preserve a cloned-from-inventory image's external id.
const hitImagesEndpoint = postRequest.mock.calls.some(
([, , url]) => url && url.includes("/images")
);
expect(hitImagesEndpoint).toBe(false);
});

it("POSTs new (id-less) uploads to the images subresource after the update succeeds", async () => {
const store = mockStore({
currentSummitState: { currentSummit: { id: 42 } }
});

const entity = {
id: 100,
name: "Item",
images: [
{ id: 5, file_path: "https://cdn/a.png" },
{ file_path: "data:image/png;base64,BBB" }
],
meta_fields: []
};

await store.dispatch(updateSponsorFormItem(7, entity));
await flushPromises();

expect(postRequest).toHaveBeenCalledWith(
null,
expect.any(Function),
`${window.PURCHASES_API_URL}/api/v1/summits/42/show-forms/7/items/100/images`,
{ file_path: "data:image/png;base64,BBB" },
expect.any(Function),
{ file_path: "data:image/png;base64,BBB" }
);
});
});
});
12 changes: 7 additions & 5 deletions src/actions/inventory-shared-actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -273,13 +273,15 @@ export const deleteFile =

return deleteRequest(
null,
createAction(settings.deletedActionName)({ fileId }),
createAction(settings.deletedActionName)({ fileId, ...settings.payload }),
`${settings.url}/${fileId}`,
null,
snackbarErrorHandler
)(params)(dispatch).then(() => {
dispatch(stopLoading());
});
settings.errorHandler ?? snackbarErrorHandler
)(params)(dispatch)
.catch(() => {})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@tomrndom The .catch(() => {}).finally(() => dispatch(stopLoading())) added here fixes a real bug — previously (.then(() => dispatch(stopLoading())) only) a failed delete request left stopLoading() never dispatched, so the loading spinner stayed on indefinitely on error. That fix has no test, and deleteFile has no test file at all in src/actions/__tests__/.

Suggested fix: add a test that mocks deleteRequest to reject and asserts stopLoading is still dispatched.

.finally(() => {
dispatch(stopLoading());
});
};

/* ************************************ ARCHIVE ************************************ */
Expand Down
Loading
Loading