Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow dynamic instance method calls on FactoryBot objects in Cypress #696

Merged
merged 16 commits into from
Sep 25, 2024
Merged
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
86 changes: 72 additions & 14 deletions app/controllers/cypress/factories_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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)
Splines marked this conversation as resolved.
Show resolved Hide resolved
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
10 changes: 7 additions & 3 deletions app/controllers/cypress/user_creator_controller.rb
Original file line number Diff line number Diff line change
@@ -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."
Expand All @@ -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
1 change: 1 addition & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
47 changes: 47 additions & 0 deletions spec/cypress/e2e/meta/factory_bot_spec.cy.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
});
4 changes: 2 additions & 2 deletions spec/cypress/support/commands.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
Expand Down
87 changes: 83 additions & 4 deletions spec/cypress/support/factorybot.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)})
Splines marked this conversation as resolved.
Show resolved Hide resolved
* 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();