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 10 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
70 changes: 58 additions & 12 deletions app/controllers/cypress/factories_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,26 +7,53 @@ module Cypress
class FactoriesController < CypressController
# Wrapper around FactoryBot.create to create a factory via a POST request.
Splines marked this conversation as resolved.
Show resolved Hide resolved
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_or_build_factory(attributes, should_validate)

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 @@ -46,5 +73,24 @@ def params_to_attributes(params)

return attributes, should_validate
end

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? { |k| k.match?(/^\d+$/) }
# Convert nested arrays to arrays of strings
v.values.map(&:to_s)
else
v
end
end
end

def create_or_build_factory(attributes, should_validate)
Splines marked this conversation as resolved.
Show resolved Hide resolved
if should_validate
FactoryBot.create(*attributes) # default case
else
FactoryBot.build(*attributes).tap { |instance| instance.save(validate: false) }
end
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
28 changes: 28 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,28 @@
import FactoryBot from "../../support/factorybot";

describe("FactoryBot.create()", () => {
beforeEach(function () {
cy.createUser("teacher").as("teacher");
cy.createUserAndLogin("generic");
});

it("allows to call instance method 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 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);
});
});
});
});
85 changes: 81 additions & 4 deletions spec/cypress/support/factorybot.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,96 @@ 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
*/
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();