diff --git a/app/controllers/cypress/factories_controller.rb b/app/controllers/cypress/factories_controller.rb index 157e9e331..f2ced3932 100644 --- a/app/controllers/cypress/factories_controller.rb +++ b/app/controllers/cypress/factories_controller.rb @@ -5,28 +5,57 @@ module Cypress # It is inspired by this blog post by Tom Conroy: # https://tbconroy.com/2018/04/07/creating-data-with-factorybot-for-rails-cypress-tests/ class FactoriesController < CypressController - # Wrapper around FactoryBot.create to create a factory via a POST request. + # Creates an instance of the factory (via FactoryBot) and returns it as JSON. def create - unless params["0"].is_a?(String) - msg = "First argument must be a string indicating the factory name." - msg += " But we got: '#{params["0"]}'" - raise(ArgumentError, msg) - end + factory_name = validate_factory_name(params["0"]) + attributes, should_validate = params_to_attributes( + params.except(:controller, :action, :number) + ) + res = create_class_instance_via_factorybot(attributes, should_validate) + + # The factory name is included in the response such that it can be passed + # to call_instance_method later on in order to determine the class of the instance. + render json: res.as_json.merge({ factory_name: factory_name }), status: :created + end - attributes, should_validate = params_to_attributes(params.except(:controller, :action, - :number)) + # Calls the instance method on the instance created by FactoryBot.create(). + # Expects as arguments the factory name, the id of the instance, + # the method name and the method arguments to be passed to the instance method. + def call_instance_method + factory_name = validate_factory_name(params["factory_name"]).capitalize + id = params["instance_id"].to_i + method_name = params["method_name"] + method_args = params["method_args"] + method_args, _validate = params_to_attributes(method_args) if method_args.present? - res = if should_validate - FactoryBot.create(*attributes) # default case - else - FactoryBot.build(*attributes).tap { |instance| instance.save(validate: false) } + # Find the instance + begin + instance = factory_name.constantize.find(id) + rescue ActiveRecord::RecordNotFound + result = { error: "Instance where you'd like to call '#{method_name}' on was not found" } + return render json: result.to_json, status: :bad_request end - render json: res.to_json, status: :created + # Call the instance method & return the result + begin + result = instance.send(method_name, *method_args) + render json: result.to_json, status: :created + rescue NoMethodError => _e + result = { error: "Method '#{method_name}' not found on instance" } + render json: result.to_json, status: :bad_request + end end private + def validate_factory_name(factory_name) + return factory_name if factory_name.is_a?(String) + + msg = "First argument must be a string indicating the factory name." + msg += " But we got: '#{factory_name}'" + raise(ArgumentError, msg) + end + def params_to_attributes(params) should_validate = true @@ -35,7 +64,7 @@ def params_to_attributes(params) if value.key?("validate") should_validate = (value["validate"] != "false") else - value.transform_keys(&:to_sym) + transform_hash(value) end elsif value.is_a?(String) value.to_sym @@ -46,5 +75,34 @@ def params_to_attributes(params) return attributes, should_validate end + + # Converts the keys of the hash to symbols. Furthermore, if the hash + # contains nested hashes with keys that are all integers, it converts + # the nested hashes to arrays of strings. + # + # The latter is important for calls like the following in Cypress: + # FactoryBot.create("tutorial", + # { lecture_id: this.lecture.id, tutor_ids: [this.tutor1.id, this.tutor2.id] } + # ) + # Without this transformation, the create() method in this controller + # would receive [:tutorial, {"lecture_id"=>"1", "tutor_ids"=>{"0"=>"42", "1"=>"43"}}], + # whereas what we need is: [:tutorial, {"lecture_id"=>"1", "tutor_ids"=>["42", "43"]}]. + def transform_hash(value) + value.transform_keys(&:to_sym).transform_values do |v| + if v.is_a?(Hash) && v.keys.all? { |key| key.to_i.to_s } + v.values.map(&:to_s) + else + v + end + end + end + + def create_class_instance_via_factorybot(attributes, should_validate) + if should_validate + FactoryBot.create(*attributes) # default case + else + FactoryBot.build(*attributes).tap { |instance| instance.save(validate: false) } + end + end end end diff --git a/app/controllers/cypress/user_creator_controller.rb b/app/controllers/cypress/user_creator_controller.rb index f82b4dd18..41366accc 100644 --- a/app/controllers/cypress/user_creator_controller.rb +++ b/app/controllers/cypress/user_creator_controller.rb @@ -1,6 +1,8 @@ module Cypress # Creates a user for use in Cypress tests. class UserCreatorController < CypressController + CYPRESS_PASSWORD = "cypress123".freeze + def create unless params[:role].is_a?(String) msg = "First argument must be a string indicating the user role." @@ -10,13 +12,15 @@ def create role = params[:role] is_admin = (role == "admin") + random_hash = SecureRandom.hex(6) - user = User.create(name: "#{role} Cypress", email: "#{role}@mampf.cypress", - password: "cypress123", consents: true, admin: is_admin, + user = User.create(name: "#{role} Cypress #{random_hash}", + email: "#{role}-#{random_hash}@mampf.cypress", + password: CYPRESS_PASSWORD, consents: true, admin: is_admin, locale: I18n.default_locale) user.confirm - render json: user.to_json, status: :created + render json: user.as_json.merge({ password: CYPRESS_PASSWORD }), status: :created end end end diff --git a/config/routes.rb b/config/routes.rb index 9e67f40d1..9903eb9f3 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -11,6 +11,7 @@ if Rails.env.test? namespace :cypress do resources :factories, only: :create + post "factories/call_instance_method", to: "factories#call_instance_method" resources :database_cleaner, only: :create resources :user_creator, only: :create resources :i18n, only: :create diff --git a/spec/cypress/e2e/meta/factory_bot_spec.cy.js b/spec/cypress/e2e/meta/factory_bot_spec.cy.js new file mode 100644 index 000000000..5cd1097e9 --- /dev/null +++ b/spec/cypress/e2e/meta/factory_bot_spec.cy.js @@ -0,0 +1,47 @@ +import FactoryBot from "../../support/factorybot"; + +describe("FactoryBot.create()", () => { + beforeEach(function () { + cy.createUserAndLogin("teacher").as("teacher"); + }); + + it("allows to call create() with array as argument", function () { + cy.createUser("generic").as("tutor1"); + cy.createUser("generic").as("tutor2"); + FactoryBot.create("lecture", { teacher_id: this.teacher.id }).as("lecture"); + + cy.then(() => { + // here we pass in an array as argument to tutor_ids + FactoryBot.create("tutorial", + { lecture_id: this.lecture.id, tutor_ids: [this.tutor1.id, this.tutor2.id] }, + ); + }); + }); +}); + +describe("FactoryBot.create().call", () => { + beforeEach(function () { + cy.createUser("teacher").as("teacher"); + cy.createUserAndLogin("generic"); + }); + + it("allows to call instance methods after assigning them to an alias", function () { + FactoryBot.create("lecture", { teacher_id: this.teacher.id }).as("lecture"); + + cy.then(() => { + // via alias in global this namespace + this.lecture.call.long_title().then((res) => { + cy.log(res); + }); + }); + }); + + it("allows to call instance methods directly (without an alias)", function () { + FactoryBot.create("lecture", { teacher_id: this.teacher.id }).then((lecture) => { + // via return value of FactoryBot.create() directly (no alias intermediate) + lecture.call.long_title().then((res) => { + cy.log(res); + }); + }); + }); +}); diff --git a/spec/cypress/support/commands.js b/spec/cypress/support/commands.js index 54776cfcb..fba93f8a5 100644 --- a/spec/cypress/support/commands.js +++ b/spec/cypress/support/commands.js @@ -105,8 +105,8 @@ Cypress.Commands.add("login", (user) => { }); Cypress.Commands.add("createUserAndLogin", (role) => { - cy.createUser(role).then((user) => { - cy.login({ email: `${role}@mampf.cypress`, password: "cypress123" }).then((_) => { + return cy.createUser(role).then((user) => { + cy.login({ email: user.email, password: user.password }).then((_) => { cy.wrap(user); }); }); diff --git a/spec/cypress/support/factorybot.js b/spec/cypress/support/factorybot.js index 973ffacd0..d5b136a14 100644 --- a/spec/cypress/support/factorybot.js +++ b/spec/cypress/support/factorybot.js @@ -9,19 +9,98 @@ class FactoryBot { * @param args The arguments to pass to FactoryBot.create(), e.g. * factory name, traits, and attributes. Pass them in as separated * string arguments. Attributes should be passed as an object. - * @returns The FactoryBot.create() response - * - * @example + * You are also able to call instance methods on the created record later. + * @returns The FactoryBot.create() response. + * @examples * FactoryBot.create("factory_name", "with_trait", { another_attribute: ".pdf"}) + * FactoryBot.create("factory_name").then(res => {res.call.any_rails_method(42)}) + * FactoryBot.create("tutorial", + * { lecture_id: this.lecture.id, tutor_ids: [this.tutor1.id, this.tutor2.id] }) */ create(...args) { - return BackendCaller.callCypressRoute("factories", "FactoryBot.create()", args); + const response = BackendCaller.callCypressRoute("factories", "FactoryBot.create()", args); + return this.#createProxy(response); } createNoValidate(...args) { args.push({ validate: false }); return this.create(...args); } + + /** + * Wraps the given Cypress response such that arbitrary methods (dynamic methods) + * can be called on the resulting object. + */ + #createProxy(obj) { + const outerContext = this; + + return new Proxy(obj, { + get: function (target, property, receiver) { + if (property !== "as" && property !== "then") { + return Reflect.get(target, property, receiver); + } + + // Trap the Cypress "as" and "then" methods to allow dynamic method calls + return function (...asOrThenArgs) { + if (property === "then") { + const callback = asOrThenArgs[0]; + asOrThenArgs[0] = function (callbackObj) { + outerContext.#defineCallProperty(callbackObj); + return callback(callbackObj); + }; + return target[property](...asOrThenArgs); + } + + if (property === "as") { + return target.as(...asOrThenArgs).then((asResponse) => { + outerContext.#defineCallProperty(asResponse); + }); + } + + throw new Error(`Unknown property that should not be wrapped: ${property}`); + }; + }, + }); + } + + #defineCallProperty(response) { + const factoryName = response["factory_name"]; + if (!factoryName) { + let msg = "FactoryBot call response does not contain factory_name key."; + msg += " Did you really use FactoryBot.create() (or similar) to create the record?"; + throw new Error(msg); + } + + if (typeof response.id !== "number") { + let msg = "FactoryBot call response does not contain a valid id key (number)."; + msg += " Did you really use FactoryBot.create() (or similar) to create the record?"; + throw new Error(msg); + } + + const call = this.#allowDynamicMethods({}, factoryName, response.id); + response.call = call; + } + + #allowDynamicMethods(obj, factoryName, instanceId) { + return new Proxy(obj, { + get: function (target, property, receiver) { + // If the property does not exist, define it as a new function + if (!(property in target)) { + target[property] = function () { + const payload = { + factory_name: factoryName, + instance_id: instanceId, + method_name: property, + method_args: Array.from(arguments), + }; + return BackendCaller.callCypressRoute("factories/call_instance_method", + `FactoryBot.create().call.${property}()`, payload); + }; + } + return Reflect.get(target, property, receiver); + }, + }); + } } export default new FactoryBot();