From 73ba0c074f8cef01cd6b0c4041fd507ed4658d7d Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Fri, 13 Jun 2025 11:38:11 -0400 Subject: [PATCH] Introduce a Signup model and integrate with QB/37id - Creates a Queenbee::Account - SignalId peer classes work properly - Set up a basic template for the account (closure reasons, workflow, collection) - Load signal_id fixtures during `bin/setup --reset` - Update signal_id to pull in the Fizzy product --- Gemfile | 3 + Gemfile.lock | 22 +++- app/models/account.rb | 9 ++ app/models/account/signal_account.rb | 19 ++++ app/models/signup.rb | 124 +++++++++++++++++++++ config/initializers/signal_id.rb | 7 ++ test/models/account/signal_account_test.rb | 23 ++++ test/models/signup_test.rb | 93 ++++++++++++++++ test/test_helper.rb | 10 ++ 9 files changed, 309 insertions(+), 1 deletion(-) create mode 100644 app/models/signup.rb create mode 100644 test/models/signup_test.rb diff --git a/Gemfile b/Gemfile index cc84e2398..e5e3637e9 100644 --- a/Gemfile +++ b/Gemfile @@ -39,6 +39,8 @@ gem "queuety", bc: "queuety", branch: "rails4" # needed by signal_id gem "service_concurrency_prevention", bc: "service_concurrency_prevention" # needed by queuety gem "portfolio", ">= 4.6", bc: "portfolio" # needed by signal_id gem "file_repository", "~> 1.4.5", bc: "file_repository" # needed by portfolio +gem "queenbee", bc: "queenbee-plugin" +gem "activeresource", require: "active_resource" # needed by queenbee # Telemetry, logging, and operations gem "mission_control-jobs" @@ -63,4 +65,5 @@ group :test do gem "selenium-webdriver" gem "webmock" gem "vcr" + gem "mocha" end diff --git a/Gemfile.lock b/Gemfile.lock index 780151b61..a1e1ced06 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -46,6 +46,15 @@ GIT activesupport file_repository (>= 1.4.4) +GIT + remote: https://github.com/basecamp/queenbee-plugin + revision: bf887b5c128570d1b19a0140146ae9aa4ae2915f + specs: + queenbee (3.2.0) + activeresource + builder + rexml + GIT remote: https://github.com/basecamp/queuety revision: bf7e2a552fb674533d3e092f06dbf9b5d1ebc753 @@ -72,7 +81,7 @@ GIT GIT remote: https://github.com/basecamp/signal_id - revision: 7090b0d518b6656d8c8792da8301146da40f3eae + revision: 129483ccc1d9452fa7713080fcbf0c012b2f4107 branch: rails4 specs: signal_id (4.3.2) @@ -205,6 +214,14 @@ GEM specs: action_text-trix (2.1.15) railties + activemodel-serializers-xml (1.0.3) + activemodel (>= 5.0.0.a) + activesupport (>= 5.0.0.a) + builder (~> 3.1) + activeresource (6.1.4) + activemodel (>= 6.0) + activemodel-serializers-xml (~> 1.0) + activesupport (>= 6.0) addressable (2.8.7) public_suffix (>= 2.0.2, < 7.0) ast (2.4.3) @@ -568,6 +585,7 @@ PLATFORMS DEPENDENCIES actiontext-lexical! active_record-tenanted! + activeresource aws-sdk-s3 bcrypt (~> 3.1.7) bootsnap @@ -582,11 +600,13 @@ DEPENDENCIES jbuilder kamal! mission_control-jobs + mocha mysql2 platform_agent portfolio (>= 4.6)! propshaft puma (>= 5.0) + queenbee! queuety! rails! rails_structured_logging! diff --git a/app/models/account.rb b/app/models/account.rb index c562043b2..25b74efde 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -2,4 +2,13 @@ class Account < ApplicationRecord include Entropic, Joinable, SignalAccount has_many_attached :uploads + + def setup_basic_template + user = User.first + + Closure::Reason.create_defaults + Collection.create!(name: "Cards", creator: user, all_access: true) + workflow = Workflow.create!(name: "Basic") + Collection.first.update!(workflow: workflow) + end end diff --git a/app/models/account/signal_account.rb b/app/models/account/signal_account.rb index 99af103bb..861626909 100644 --- a/app/models/account/signal_account.rb +++ b/app/models/account/signal_account.rb @@ -5,4 +5,23 @@ module Account::SignalAccount # TODO: remove the "optional: true" once we've populated the accounts properly belongs_to :signal_account, class_name: "SignalId::Account", primary_key: :queenbee_id, foreign_key: :queenbee_id, optional: true end + + class_methods do + def create_with_admin_user(queenbee_id:) + new(queenbee_id: queenbee_id).tap do |account| + SignalId::Database.on_master do + account.name = account.signal_account.name + account.save! + + User.create!( + name: account.signal_account.owner.name, + email_address: account.signal_account.owner.email_address, + signal_user_id: account.signal_account.owner.id, + role: "admin", + password: SecureRandom.hex(36) # TODO: remove password column? + ) + end + end + end + end end diff --git a/app/models/signup.rb b/app/models/signup.rb new file mode 100644 index 000000000..a56a5fcd8 --- /dev/null +++ b/app/models/signup.rb @@ -0,0 +1,124 @@ +class Signup + include ActiveModel::Model + include ActiveModel::Attributes + include ActiveModel::Validations + + attribute :full_name, :string + attribute :email_address, :string + attribute :password, :string + attribute :company_name, :string + + attr_reader :signal_identity, :queenbee_account, :signal_account, :account, :user + + validate :validate_new_identity + + def initialize(...) + super + + @signal_identity = nil + @queenbee_account = nil + @account = nil + @user = nil + end + + def process + return false unless valid? + + create_signal_identity + create_queenbee_account + create_tenant + + true + rescue => error + destroy_tenant + destroy_queenbee_account + destroy_signal_identity + + errors.add(:base, "An error occurred during signup: #{error.message}") + + false + end + + private + def create_signal_identity + SignalId::Database.on_master do + @signal_identity = build_new_identity.tap(&:save!) + end + end + + def create_queenbee_account + @queenbee_account = Queenbee::Remote::Account.create!(queenbee_account_attributes) + SignalId::Database.on_master do + @signal_account = SignalId::Account.find_by(queenbee_id: @queenbee_account.id) + end + end + + def create_tenant(&block) + ApplicationRecord.create_tenant(tenant_name) do + @account = Account.create_with_admin_user(queenbee_id: queenbee_account.id) + @account.setup_basic_template + @user = User.first + end + end + + def destroy_tenant + if queenbee_account && ApplicationRecord.tenant_exist?(tenant_name) + ApplicationRecord.destroy_tenant(tenant_name) + @account = nil + @user = nil + end + end + + def destroy_queenbee_account + queenbee_account&.cancel + @queenbee_account = nil + end + + def destroy_signal_identity + SignalId::Database.on_master do + signal_identity&.destroy + end + @signal_identity = nil + end + + def validate_new_identity + build_new_identity.tap do |identity| + unless identity.valid? + identity.errors.each { |error| errors.add(error.attribute, error.message) } + end + end + end + + def build_new_identity + SignalId::Identity.new do |identity| + identity.name = full_name || email_address + identity.email_address = email_address + identity.username = email_address + identity.password = password + end + end + + def queenbee_account_attributes + { + skip_remote: true, # Fizzy creates its own local account + product_name: "fizzy", + name: company_name.presence || signal_identity.name, + owner_identity_id: signal_identity.id, + trial: false, + subscription: subscription_attributes, + remote_request: request_attributes + } + end + + def subscription_attributes + { name: "FreeV1", price: 0 } + end + + def request_attributes + { remote_address: Current.ip_address, user_agent: Current.user_agent, referrer: Current.referrer } + end + + def tenant_name + @tenant_name ||= signal_account.subdomain + end +end diff --git a/config/initializers/signal_id.rb b/config/initializers/signal_id.rb index cf88348c0..dc2c41417 100644 --- a/config/initializers/signal_id.rb +++ b/config/initializers/signal_id.rb @@ -1,8 +1,15 @@ require "signal_id" Rails.application.config.to_prepare do + SignalId.product = "fizzy" + SignalId::Database.load_configuration SignalId::Database.default_configuration SignalId::Database.enable_rw_splitting! + + silence_warnings do + SignalId::Account::Peer = Account + SignalId::User::Peer = User + end end Rails.application.config.after_initialize do diff --git a/test/models/account/signal_account_test.rb b/test/models/account/signal_account_test.rb index ee9b5b6d8..62f5e4b87 100644 --- a/test/models/account/signal_account_test.rb +++ b/test/models/account/signal_account_test.rb @@ -10,4 +10,27 @@ class Account::SignalAccountTest < ActiveSupport::TestCase assert_equal @account.queenbee_id, Account.new(signal_account: @account.signal_account).queenbee_id assert_equal @account.signal_account, Account.new(queenbee_id: @account.queenbee_id).signal_account end + + test ".create_with_admin_user creates a new local account and user peers" do + ApplicationRecord.create_tenant("account-create-with-dependents") do + signal_account = signal_accounts(:honcho_fizzy) + account = Account.create_with_admin_user(queenbee_id: signal_account.queenbee_id) + + assert_not_nil account + assert account.persisted? + assert_equal 1, Account.count + assert_equal signal_account.queenbee_id, account.queenbee_id + assert_equal signal_account.name, account.name + assert_equal account, signal_account.peer + + assert_equal 1, User.count + User.first.tap do |user| + assert signal_account.owner.name, user.name + assert signal_account.owner.email_address, user.email_address + assert signal_account.owner.id, user.signal_user_id + assert_equal "admin", user.role + assert_equal user, signal_account.owner.peer + end + end + end end diff --git a/test/models/signup_test.rb b/test/models/signup_test.rb new file mode 100644 index 000000000..1c77c1748 --- /dev/null +++ b/test/models/signup_test.rb @@ -0,0 +1,93 @@ +require "test_helper" + +class SignupTest < ActiveSupport::TestCase + setup do + @signup = Signup.new( + email_address: "brian@example.com", + full_name: "Brian Wilson", + company_name: "Beach Boys", + password: SecureRandom.hex(16) + ) + end + + test "#process creates all the necessary objects" do + Account.any_instance.expects(:setup_basic_template).once + + assert @signup.process, @signup.errors.full_messages.to_sentence(words_connector: ". ") + assert_empty @signup.errors + + assert @signup.signal_identity + assert @signup.signal_identity.persisted? + + assert @signup.queenbee_account + assert @signup.queenbee_account.id + + assert @signup.signal_account + assert @signup.signal_account.persisted? + + assert @signup.account + assert @signup.account.persisted? + + assert @signup.user + assert @signup.user.persisted? + + assert_includes ApplicationRecord.tenants, @signup.signal_account.subdomain + end + + test "#process does nothing if a validation error occurs creating identity" do + @signup.password = "" + + assert_not @signup.process + assert_not_empty @signup.errors[:password] + + assert_nil @signup.signal_identity + assert_nil @signup.queenbee_account + assert_nil @signup.account + assert_nil @signup.user + end + + test "#process does nothing if a validation error occurs creating the queenbee account" do + Queenbee::Remote::Account.stubs(:create!).raises(RuntimeError, "Invalid account data") + + SignalId::Identity.any_instance.expects(:destroy).once + + assert_not @signup.process + assert_not_empty @signup.errors[:base] + + assert_nil @signup.signal_identity + assert_nil @signup.queenbee_account + assert_nil @signup.account + assert_nil @signup.user + end + + test "#process does nothing if a validation error occurs creating the tenant" do + ApplicationRecord.stubs(:create_tenant).raises(RuntimeError, "Tenant already exists") + + Queenbee::Remote::Account.any_instance.expects(:cancel).once + SignalId::Identity.any_instance.expects(:destroy).once + + assert_not @signup.process + assert_not_empty @signup.errors[:base] + + assert_nil @signup.signal_identity + assert_nil @signup.queenbee_account + assert_nil @signup.account + assert_nil @signup.user + end + + test "#process does nothing if a validation error occurs creating the account" do + Account.stubs(:create_with_admin_user).raises(RuntimeError, "Account creation failed") + + ApplicationRecord.expects(:destroy_tenant).once + Queenbee::Remote::Account.any_instance.expects(:cancel).once + SignalId::Identity.any_instance.expects(:destroy).once + + assert_not @signup.process + assert_not_empty @signup.errors[:base] + + assert_nil @signup.signal_identity + assert_nil @signup.queenbee_account + assert_nil @signup.account + assert_nil @signup.user + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index b999101f6..622c41a0e 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -4,6 +4,8 @@ require "rails/test_help" require "webmock/minitest" require "vcr" require "signal_id/testing" +require "queenbee/testing/mocks" +require "mocha/minitest" WebMock.allow_net_connect! @@ -34,3 +36,11 @@ end RubyLLM.configure do |config| config.openai_api_key ||= "DUMMY-TEST-KEY" # Run tests with VCR without having to configure OpenAI API key locally. end + +Queenbee::Remote::Account.class_eval do + # because we use the account ID as the tenant name, we need it to be unique in each test to avoid + # parallelized tests clobbering each other. + def next_id + super + Random.rand(1000000) + end +end