diff --git a/src/core/plugins/oas3/components/callbacks.jsx b/src/core/plugins/oas3/components/callbacks.jsx index 2280b40b5ee..c7f15b08391 100644 --- a/src/core/plugins/oas3/components/callbacks.jsx +++ b/src/core/plugins/oas3/components/callbacks.jsx @@ -26,7 +26,7 @@ const Callbacks = ({ callbacks, specPath, specSelectors, getComponent }) => { { + beforeEach(() => { + cy.visit("/?url=/documents/bugs/5536.yaml") + .get("#operations-default-subscribe") + .click() + .get("#operations-default-subscribe .tab-item") + .contains("Callbacks") + .click() + }) + + it("should render a separate operation block per callback name", () => { + cy.get("#operations-callbacks-myEvent-post__request_body__callbackUrl_") + .should("exist") + cy.get("#operations-callbacks-otherEvent-post__request_body__callbackUrl_") + .should("exist") + }) + + it("should expand only the clicked callback's operation block", () => { + cy.get("#operations-callbacks-myEvent-post__request_body__callbackUrl_") + .as("myEventOp") + .should("not.have.class", "is-open") + cy.get("#operations-callbacks-otherEvent-post__request_body__callbackUrl_") + .as("otherEventOp") + .should("not.have.class", "is-open") + + cy.get("@myEventOp") + .find(".opblock-summary-control") + .click() + + cy.get("@myEventOp").should("have.class", "is-open") + cy.get("@otherEventOp").should("not.have.class", "is-open") + }) +}) diff --git a/test/e2e-cypress/static/documents/bugs/5536.yaml b/test/e2e-cypress/static/documents/bugs/5536.yaml new file mode 100644 index 00000000000..8d6a14cd539 --- /dev/null +++ b/test/e2e-cypress/static/documents/bugs/5536.yaml @@ -0,0 +1,64 @@ +openapi: 3.0.0 +info: + version: 1.0.0 + title: test + description: example with 2 callbacks using the same URL template +paths: + /subscribe: + post: + summary: Subscribe to a webhook + operationId: subscribe + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + callbackUrl: + type: string + format: uri + example: https://myserver.com/send/callback/here + required: + - callbackUrl + callbacks: + myEvent: + '{$request.body#/callbackUrl}': + post: + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + message: + type: string + example: Some event happened + required: + - message + responses: + '200': + description: Your server returns this code if it accepts the callback + otherEvent: + '{$request.body#/callbackUrl}': + post: + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + id: + type: integer + format: int32 + example: 42 + required: + - id + responses: + '200': + description: Your server returns this code if it accepts the callback + responses: + '201': + description: Webhook created diff --git a/test/unit/core/plugins/oas3/callbacks.jsx b/test/unit/core/plugins/oas3/callbacks.jsx new file mode 100644 index 00000000000..c48f84994ec --- /dev/null +++ b/test/unit/core/plugins/oas3/callbacks.jsx @@ -0,0 +1,89 @@ +/** + * @prettier + */ + +import React from "react" +import { shallow } from "enzyme" +import { fromJS, List } from "immutable" +import Callbacks from "core/plugins/oas3/components/callbacks" + +describe("", () => { + const OperationContainerStub = () => null + OperationContainerStub.displayName = "OperationContainerStub" + + const buildOperationDTOs = () => ({ + myEvent: [ + { + operation: fromJS({ operation: { summary: "myEvent op" } }), + method: "post", + path: "{$request.body#/callbackUrl}", + callbackName: "myEvent", + specPath: List([ + "paths", + "/subscribe", + "post", + "callbacks", + "myEvent", + "{$request.body#/callbackUrl}", + "post", + ]), + }, + ], + otherEvent: [ + { + operation: fromJS({ operation: { summary: "otherEvent op" } }), + method: "post", + path: "{$request.body#/callbackUrl}", + callbackName: "otherEvent", + specPath: List([ + "paths", + "/subscribe", + "post", + "callbacks", + "otherEvent", + "{$request.body#/callbackUrl}", + "post", + ]), + }, + ], + }) + + const buildProps = (operationDTOs) => ({ + callbacks: fromJS({}), + specPath: List(["paths", "/subscribe", "post", "callbacks"]), + specSelectors: { + callbacksOperations: () => operationDTOs, + }, + getComponent: (name) => { + if (name === "OperationContainer") return OperationContainerStub + return null + }, + }) + + it("renders one OperationContainer per callback path-item operation", () => { + const wrapper = shallow() + + const operationContainers = wrapper.find(OperationContainerStub) + expect(operationContainers.length).toEqual(2) + }) + + it("scopes the tag prop with the callback name so colliding URLs do not share expansion state", () => { + // Regression test for https://github.com/swagger-api/swagger-ui/issues/5536 + // When two callbacks declare the same URL template, each OperationContainer + // must receive a unique `tag` so that the layout isShownKey + // (["operations", tag, operationId]) differs between callbacks. + const wrapper = shallow() + const operationContainers = wrapper.find(OperationContainerStub) + + const tags = operationContainers.map((node) => node.prop("tag")) + expect(tags).toEqual(["callbacks-myEvent", "callbacks-otherEvent"]) + + // tags must be unique across colliding URL templates + expect(new Set(tags).size).toEqual(tags.length) + }) + + it("renders 'No callbacks' when the selector returns no operations", () => { + const wrapper = shallow() + expect(wrapper.text()).toEqual("No callbacks") + }) +})