CP-10294 add account groups (#19)

- Add account_groups table and model
- Add account_group references to accounts, users, templates, template_folders
- Make account_id nullable on users, templates, template_folders
- Add controllers and specs

* Consolidate account groups migrations

- Replace 8 separate migrations with 2 consolidated ones
- Create account groups and relationships in one migration
- Make account_id columns nullable in second migration

* this logic is being handled in external_auth_controller

* remove unnecessary controllers
* remove unnecessary routes

* refactor account_group.default_template_folder

* align method with Account version of this method

* refactor controllers to move complex logic to service

* move account/account group validation to concern

* this method is not yet needed

* we may implement this differently in next ticket to handle account and account group syncing for templates.

* rubocop violation fixes

* a few more refactors and add tests

* Change external_account_group_id to integer type

* Refactored external_account_group_id from string to integer in models, migrations, factories, and specs for consistency.
* Merged account_id nullability changes into a single migration and removed the obsolete migrations.
* Updated authentication logic to require either account or account_group presence for user activation.
pull/544/head
Ryan Arakawa 3 months ago committed by GitHub
parent c14f217812
commit a1ed992ee4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -6,23 +6,10 @@ module Api
skip_authorization_check
def user_token
account = Account.find_or_create_by_external_id(
params[:account][:external_id]&.to_i,
name: params[:account][:name],
locale: params[:account][:locale] || 'en-US',
timezone: params[:account][:timezone] || 'UTC'
)
service = ExternalAuthService.new(params)
access_token = service.authenticate_user
user = User.find_or_create_by_external_id(
account,
params[:user][:external_id]&.to_i,
email: params[:user][:email],
first_name: params[:user][:first_name],
last_name: params[:user][:last_name],
role: 'admin'
)
render json: { access_token: user.access_token.token }
render json: { access_token: access_token }
rescue StandardError => e
Rails.logger.error("External auth error: #{e.message}")
Rollbar.error(e) if defined?(Rollbar)

@ -70,25 +70,17 @@ class TemplatesController < ApplicationController
else
@template = Template.new(template_params) if @template.nil?
@template.author = current_user
@template.folder = TemplateFolders.find_or_create_by_name(current_user, params[:folder_name])
end
if params[:account_id].present? && authorized_clone_account_id?(params[:account_id])
@template.account_id = params[:account_id]
@template.folder = @template.account.default_template_folder if @template.account_id != current_account.id
else
@template.account = current_account
TemplateService.new(@template, current_user, params).assign_ownership
end
if @template.save
Templates::CloneAttachments.call(template: @template, original_template: @base_template) if @base_template
SearchEntries.enqueue_reindex(@template)
enqueue_template_created_webhooks(@template)
handle_account_override if params[:account_id].present?
if @template.save
handle_successful_template_creation
maybe_redirect_to_template(@template)
else
Rails.logger.error "Template save failed: #{@template.errors.full_messages.join(', ')}"
render turbo_stream: turbo_stream.replace(:modal, template: 'templates/new'), status: :unprocessable_entity
end
end
@ -126,7 +118,7 @@ class TemplatesController < ApplicationController
def template_params
params.require(:template).permit(
:name,
:name, :external_id,
{ schema: [[:attachment_uuid, :name, { conditions: [%i[field_uuid value action operation]] }]],
submitters: [%i[name uuid is_requester linked_to_uuid invite_by_uuid optional_invite_by_uuid email]],
external_data_fields: {},
@ -168,6 +160,20 @@ class TemplatesController < ApplicationController
end
end
def handle_account_override
return unless authorized_clone_account_id?(params[:account_id])
@template.account_id = params[:account_id]
@template.account_group = nil
@template.folder = @template.account.default_template_folder if @template.account_id != current_account&.id
end
def handle_successful_template_creation
Templates::CloneAttachments.call(template: @template, original_template: @base_template) if @base_template
SearchEntries.enqueue_reindex(@template)
enqueue_template_created_webhooks(@template)
end
def load_base_template
return if params[:base_template_id].blank?

@ -12,16 +12,23 @@
# uuid :string not null
# created_at :datetime not null
# updated_at :datetime not null
# account_group_id :bigint
# external_account_id :integer
#
# Indexes
#
# index_accounts_on_account_group_id (account_group_id)
# index_accounts_on_external_account_id (external_account_id) UNIQUE
# index_accounts_on_uuid (uuid) UNIQUE
#
# Foreign Keys
#
# fk_rails_... (account_group_id => account_groups.id)
#
class Account < ApplicationRecord
attribute :uuid, :string, default: -> { SecureRandom.uuid }
belongs_to :account_group, optional: true
has_many :users, dependent: :destroy
has_many :encrypted_configs, dependent: :destroy
has_many :account_configs, dependent: :destroy

@ -0,0 +1,36 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: account_groups
#
# id :bigint not null, primary key
# name :string not null
# created_at :datetime not null
# updated_at :datetime not null
# external_account_group_id :integer not null
#
# Indexes
#
# index_account_groups_on_external_account_group_id (external_account_group_id) UNIQUE
#
class AccountGroup < ApplicationRecord
has_many :accounts, dependent: :nullify
has_many :users, dependent: :nullify
has_many :templates, dependent: :destroy
has_many :template_folders, dependent: :destroy
validates :external_account_group_id, presence: true, uniqueness: true
validates :name, presence: true
def self.find_or_create_by_external_id(external_id, attributes = {})
find_by(external_account_group_id: external_id) ||
create!(attributes.merge(external_account_group_id: external_id))
end
def default_template_folder
template_folders.find_by(name: TemplateFolder::DEFAULT_NAME) ||
template_folders.create!(name: TemplateFolder::DEFAULT_NAME,
author_id: users.minimum(:id))
end
end

@ -0,0 +1,19 @@
# frozen_string_literal: true
module AccountGroupValidation
extend ActiveSupport::Concern
included do
validate :must_belong_to_account_or_account_group
end
private
def must_belong_to_account_or_account_group
if account.blank? && account_group.blank?
errors.add(:base, 'Must belong to either an account or account group')
elsif account.present? && account_group.present?
errors.add(:base, 'Cannot belong to both account and account group')
end
end
end

@ -17,13 +17,15 @@
# submitters :text not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :integer not null
# account_group_id :bigint
# account_id :integer
# author_id :integer not null
# external_id :string
# folder_id :integer not null
#
# Indexes
#
# index_templates_on_account_group_id (account_group_id)
# index_templates_on_account_id (account_id)
# index_templates_on_account_id_and_folder_id_and_id (account_id,folder_id,id) WHERE (archived_at IS NULL)
# index_templates_on_account_id_and_id_archived (account_id,id) WHERE (archived_at IS NOT NULL)
@ -34,15 +36,19 @@
#
# Foreign Keys
#
# fk_rails_... (account_group_id => account_groups.id)
# fk_rails_... (account_id => accounts.id)
# fk_rails_... (author_id => users.id)
# fk_rails_... (folder_id => template_folders.id)
#
class Template < ApplicationRecord
include AccountGroupValidation
DEFAULT_SUBMITTER_NAME = 'Employee'
belongs_to :author, class_name: 'User'
belongs_to :account
belongs_to :account, optional: true
belongs_to :account_group, optional: true
belongs_to :folder, class_name: 'TemplateFolder'
has_one :search_entry, as: :record, inverse_of: :record, dependent: :destroy
@ -84,6 +90,10 @@ class Template < ApplicationRecord
private
def maybe_set_default_folder
self.folder ||= account.default_template_folder
if account.present?
self.folder ||= account.default_template_folder
elsif account_group.present?
self.folder ||= account_group.default_template_folder
end
end
end

@ -4,29 +4,35 @@
#
# Table name: template_folders
#
# id :bigint not null, primary key
# archived_at :datetime
# name :string not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :integer not null
# author_id :integer not null
# id :bigint not null, primary key
# archived_at :datetime
# name :string not null
# created_at :datetime not null
# updated_at :datetime not null
# account_group_id :bigint
# account_id :integer
# author_id :integer not null
#
# Indexes
#
# index_template_folders_on_account_id (account_id)
# index_template_folders_on_author_id (author_id)
# index_template_folders_on_account_group_id (account_group_id)
# index_template_folders_on_account_id (account_id)
# index_template_folders_on_author_id (author_id)
#
# Foreign Keys
#
# fk_rails_... (account_group_id => account_groups.id)
# fk_rails_... (account_id => accounts.id)
# fk_rails_... (author_id => users.id)
#
class TemplateFolder < ApplicationRecord
include AccountGroupValidation
DEFAULT_NAME = 'Default'
belongs_to :author, class_name: 'User'
belongs_to :account
belongs_to :account, optional: true
belongs_to :account_group, optional: true
has_many :templates, dependent: :destroy, foreign_key: :folder_id, inverse_of: :folder
has_many :active_templates, -> { where(archived_at: nil) },

@ -28,11 +28,13 @@
# uuid :string not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :integer not null
# account_group_id :bigint
# account_id :integer
# external_user_id :integer
#
# Indexes
#
# index_users_on_account_group_id (account_group_id)
# index_users_on_account_id (account_id)
# index_users_on_email (email) UNIQUE
# index_users_on_external_user_id (external_user_id) UNIQUE
@ -42,9 +44,12 @@
#
# Foreign Keys
#
# fk_rails_... (account_group_id => account_groups.id)
# fk_rails_... (account_id => accounts.id)
#
class User < ApplicationRecord
include AccountGroupValidation
ROLES = [
ADMIN_ROLE = 'admin'
].freeze
@ -57,7 +62,9 @@ class User < ApplicationRecord
has_one_attached :signature
has_one_attached :initials
belongs_to :account
belongs_to :account, optional: true
belongs_to :account_group, optional: true
has_one :access_token, dependent: :destroy
has_many :access_tokens, dependent: :destroy
has_many :templates, dependent: :destroy, foreign_key: :author_id, inverse_of: :author
@ -88,12 +95,24 @@ class User < ApplicationRecord
)
end
def self.find_or_create_by_external_group_id(account_group, external_id, attributes = {})
account_group.users.find_by(external_user_id: external_id) ||
account_group.users.create!(
attributes.merge(
external_user_id: external_id,
password: SecureRandom.hex(16)
)
)
end
def access_token
super || build_access_token.tap(&:save!)
end
def active_for_authentication?
super && !archived_at? && !account.archived_at?
return false unless account.present? || account_group.present?
super && !archived_at? && !account&.archived_at?
end
def remember_me

@ -0,0 +1,58 @@
# frozen_string_literal: true
class ExternalAuthService
def initialize(params)
@params = params
end
def authenticate_user
user = if @params[:account].present?
find_or_create_user_with_account
elsif @params[:account_group].present?
find_or_create_user_with_account_group
else
raise ArgumentError, 'Either account or account_group params must be provided'
end
user.access_token.token
end
private
def find_or_create_user_with_account
account = Account.find_or_create_by_external_id(
@params[:account][:external_id]&.to_i,
name: @params[:account][:name],
locale: @params[:account][:locale] || 'en-US',
timezone: @params[:account][:timezone] || 'UTC'
)
User.find_or_create_by_external_id(
account,
@params[:user][:external_id]&.to_i,
user_attributes
)
end
def find_or_create_user_with_account_group
account_group = AccountGroup.find_or_create_by_external_id(
@params[:account_group][:external_id],
name: @params[:account_group][:name]
)
User.find_or_create_by_external_group_id(
account_group,
@params[:user][:external_id]&.to_i,
user_attributes
)
end
def user_attributes
{
email: @params[:user][:email],
first_name: @params[:user][:first_name],
last_name: @params[:user][:last_name],
role: 'admin'
}
end
end

@ -0,0 +1,19 @@
# frozen_string_literal: true
class TemplateService
def initialize(template, user, params = {})
@template = template
@user = user
@params = params
end
def assign_ownership
if @user.account_group.present?
@template.account_group = @user.account_group
@template.folder = @user.account_group.default_template_folder
elsif @user.account.present?
@template.account = @user.account
@template.folder = TemplateFolders.find_or_create_by_name(@user, @params[:folder_name])
end
end
end

@ -9,6 +9,7 @@ default: &default
database: <%= ENV['DB_NAME'] %>
development:
<<: *default
database: docuseal_development
pool: 5
username: postgres
@ -16,6 +17,7 @@ development:
host: localhost
test:
<<: *default
database: docuseal_test
pool: 5
username: postgres

@ -0,0 +1,21 @@
class CreateAccountGroupsAndAddRelationships < ActiveRecord::Migration[8.0]
def change
create_table :account_groups do |t|
t.integer :external_account_group_id, null: false
t.string :name, null: false
t.timestamps
end
add_index :account_groups, :external_account_group_id, unique: true
add_reference :accounts, :account_group, foreign_key: true
add_reference :templates, :account_group, foreign_key: true
add_reference :users, :account_group, foreign_key: true
add_reference :template_folders, :account_group, foreign_key: true
# Make account_ids nullable since records can belong to either account or account_group
change_column_null :templates, :account_id, true
change_column_null :users, :account_id, true
change_column_null :template_folders, :account_id, true
end
end

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2025_08_14_214357) do
ActiveRecord::Schema[8.0].define(version: 2025_09_10_191227) do
# These are extensions that must be enabled in order to support this database
enable_extension "btree_gin"
enable_extension "pg_catalog.plpgsql"
@ -43,6 +43,14 @@ ActiveRecord::Schema[8.0].define(version: 2025_08_14_214357) do
t.index ["account_id"], name: "index_account_configs_on_account_id"
end
create_table "account_groups", force: :cascade do |t|
t.integer "external_account_group_id", null: false
t.string "name", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["external_account_group_id"], name: "index_account_groups_on_external_account_group_id", unique: true
end
create_table "account_linked_accounts", force: :cascade do |t|
t.integer "account_id", null: false
t.integer "linked_account_id", null: false
@ -63,6 +71,8 @@ ActiveRecord::Schema[8.0].define(version: 2025_08_14_214357) do
t.string "uuid", null: false
t.datetime "archived_at"
t.integer "external_account_id"
t.bigint "account_group_id"
t.index ["account_group_id"], name: "index_accounts_on_account_group_id"
t.index ["external_account_id"], name: "index_accounts_on_external_account_id", unique: true
t.index ["uuid"], name: "index_accounts_on_uuid", unique: true
end
@ -363,10 +373,12 @@ ActiveRecord::Schema[8.0].define(version: 2025_08_14_214357) do
create_table "template_folders", force: :cascade do |t|
t.string "name", null: false
t.integer "author_id", null: false
t.integer "account_id", null: false
t.integer "account_id"
t.datetime "archived_at"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.bigint "account_group_id"
t.index ["account_group_id"], name: "index_template_folders_on_account_group_id"
t.index ["account_id"], name: "index_template_folders_on_account_id"
t.index ["author_id"], name: "index_template_folders_on_author_id"
end
@ -388,7 +400,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_08_14_214357) do
t.text "fields", null: false
t.text "submitters", null: false
t.integer "author_id", null: false
t.integer "account_id", null: false
t.integer "account_id"
t.datetime "archived_at"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
@ -398,6 +410,8 @@ ActiveRecord::Schema[8.0].define(version: 2025_08_14_214357) do
t.text "preferences", null: false
t.boolean "shared_link", default: false, null: false
t.text "external_data_fields"
t.bigint "account_group_id"
t.index ["account_group_id"], name: "index_templates_on_account_group_id"
t.index ["account_id", "folder_id", "id"], name: "index_templates_on_account_id_and_folder_id_and_id", where: "(archived_at IS NULL)"
t.index ["account_id", "id"], name: "index_templates_on_account_id_and_id_archived", where: "(archived_at IS NOT NULL)"
t.index ["account_id"], name: "index_templates_on_account_id"
@ -423,7 +437,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_08_14_214357) do
t.string "email", null: false
t.string "role", null: false
t.string "encrypted_password", null: false
t.integer "account_id", null: false
t.integer "account_id"
t.string "reset_password_token"
t.datetime "reset_password_sent_at"
t.datetime "remember_created_at"
@ -443,6 +457,8 @@ ActiveRecord::Schema[8.0].define(version: 2025_08_14_214357) do
t.integer "consumed_timestep"
t.boolean "otp_required_for_login", default: false, null: false
t.integer "external_user_id"
t.bigint "account_group_id"
t.index ["account_group_id"], name: "index_users_on_account_group_id"
t.index ["account_id"], name: "index_users_on_account_id"
t.index ["email"], name: "index_users_on_email", unique: true
t.index ["external_user_id"], name: "index_users_on_external_user_id", unique: true
@ -468,6 +484,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_08_14_214357) do
add_foreign_key "account_configs", "accounts"
add_foreign_key "account_linked_accounts", "accounts"
add_foreign_key "account_linked_accounts", "accounts", column: "linked_account_id"
add_foreign_key "accounts", "account_groups"
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
add_foreign_key "document_generation_events", "submitters"
@ -486,13 +503,16 @@ ActiveRecord::Schema[8.0].define(version: 2025_08_14_214357) do
add_foreign_key "submissions", "users", column: "created_by_user_id"
add_foreign_key "submitters", "submissions"
add_foreign_key "template_accesses", "templates"
add_foreign_key "template_folders", "account_groups"
add_foreign_key "template_folders", "accounts"
add_foreign_key "template_folders", "users", column: "author_id"
add_foreign_key "template_sharings", "templates"
add_foreign_key "templates", "account_groups"
add_foreign_key "templates", "accounts"
add_foreign_key "templates", "template_folders", column: "folder_id"
add_foreign_key "templates", "users", column: "author_id"
add_foreign_key "user_configs", "users"
add_foreign_key "users", "account_groups"
add_foreign_key "users", "accounts"
add_foreign_key "webhook_urls", "accounts"
end

@ -5,21 +5,32 @@ module Abilities
module_function
def collection(user, ability: nil)
templates = Template.where(account_id: user.account_id)
if user.account_id.present?
templates = Template.where(account_id: user.account_id)
return templates unless user.account.testing?
return templates unless user.account.testing?
shared_ids =
TemplateSharing.where({ ability:, account_id: [user.account_id, TemplateSharing::ALL_ID] }.compact)
.select(:template_id)
shared_ids =
TemplateSharing.where({ ability:, account_id: [user.account_id, TemplateSharing::ALL_ID] }.compact)
.select(:template_id)
Template.where(Template.arel_table[:id].in(Arel::Nodes::Union.new(templates.select(:id).arel, shared_ids.arel)))
Template.where(Template.arel_table[:id].in(Arel::Nodes::Union.new(templates.select(:id).arel, shared_ids.arel)))
elsif user.account_group_id.present?
Template.where(account_group_id: user.account_group_id)
else
Template.none
end
end
def entity(template, user:, ability: nil)
return true if template.account_id.blank?
return true if template.account_id.blank? && template.account_group_id.blank?
# Handle account group templates
return template.account_group_id == user.account_group_id if template.account_group_id.present?
# Handle regular account templates
return true if template.account_id == user.account_id
return false unless user.account.linked_account_account
return false unless user.account&.linked_account_account
return false if template.template_sharings.to_a.blank?
account_ids = [user.account_id, TemplateSharing::ALL_ID]

@ -4,9 +4,6 @@ class Ability
include CanCan::Ability
def initialize(user)
# Allow template creation for any authenticated user (for testing)
can :create, Template
can %i[read create update], Template, Abilities::TemplateConditions.collection(user) do |template|
Abilities::TemplateConditions.entity(template, user:, ability: 'manage')
end

@ -0,0 +1,8 @@
# frozen_string_literal: true
FactoryBot.define do
factory :account_group do
external_account_group_id { Faker::Number.unique.number(digits: 8) }
name { Faker::Company.name }
end
end

@ -0,0 +1,56 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: account_groups
#
# id :bigint not null, primary key
# name :string not null
# created_at :datetime not null
# updated_at :datetime not null
# external_account_group_id :integer not null
#
# Indexes
#
# index_account_groups_on_external_account_group_id (external_account_group_id) UNIQUE
#
describe AccountGroup do
let(:account_group) { create(:account_group) }
describe 'associations' do
it 'has many accounts' do
expect(account_group).to respond_to(:accounts)
end
end
describe 'validations' do
it 'validates presence of external_account_group_id' do
account_group = build(:account_group, external_account_group_id: nil)
expect(account_group).not_to be_valid
expect(account_group.errors[:external_account_group_id]).to include("can't be blank")
end
it 'validates uniqueness of external_account_group_id' do
create(:account_group, external_account_group_id: 123)
duplicate = build(:account_group, external_account_group_id: 123)
expect(duplicate).not_to be_valid
expect(duplicate.errors[:external_account_group_id]).to include('has already been taken')
end
it 'validates presence of name' do
account_group = build(:account_group, name: nil)
expect(account_group).not_to be_valid
expect(account_group.errors[:name]).to include("can't be blank")
end
end
describe 'when account group is destroyed' do
it 'nullifies accounts account_group_id' do
account = create(:account, account_group: account_group)
account_group.destroy
expect(account.reload.account_group).to be_nil
end
end
end

@ -1,5 +1,30 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: accounts
#
# id :bigint not null, primary key
# archived_at :datetime
# locale :string not null
# name :string not null
# timezone :string not null
# uuid :string not null
# created_at :datetime not null
# updated_at :datetime not null
# account_group_id :bigint
# external_account_id :integer
#
# Indexes
#
# index_accounts_on_account_group_id (account_group_id)
# index_accounts_on_external_account_id (external_account_id) UNIQUE
# index_accounts_on_uuid (uuid) UNIQUE
#
# Foreign Keys
#
# fk_rails_... (account_group_id => account_groups.id)
#
require 'rails_helper'
RSpec.describe Account do
@ -44,6 +69,17 @@ RSpec.describe Account do
end
end
describe 'account_group association' do
it 'belongs to account_group optionally' do
account = create(:account)
expect(account.account_group).to be_nil
account_group = create(:account_group)
account.update!(account_group: account_group)
expect(account.reload.account_group).to eq(account_group)
end
end
describe '#default_template_folder' do
it 'creates default folder when none exists' do
account = create(:account)

@ -0,0 +1,38 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe AccountGroupValidation do
# Test with User model since it includes the concern
describe 'validation' do
context 'with account only' do
it 'is valid' do
user = build(:user, account: create(:account), account_group: nil)
expect(user).to be_valid
end
end
context 'with account_group only' do
it 'is valid' do
user = build(:user, account: nil, account_group: create(:account_group))
expect(user).to be_valid
end
end
context 'with neither account nor account_group' do
it 'is invalid' do
user = build(:user, account: nil, account_group: nil)
expect(user).not_to be_valid
expect(user.errors[:base]).to include('Must belong to either an account or account group')
end
end
context 'with both account and account_group' do
it 'is invalid' do
user = build(:user, account: create(:account), account_group: create(:account_group))
expect(user).not_to be_valid
expect(user.errors[:base]).to include('Cannot belong to both account and account group')
end
end
end
end

@ -1,5 +1,52 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: users
#
# id :bigint not null, primary key
# archived_at :datetime
# consumed_timestep :integer
# current_sign_in_at :datetime
# current_sign_in_ip :string
# email :string not null
# encrypted_password :string not null
# failed_attempts :integer default(0), not null
# first_name :string
# last_name :string
# last_sign_in_at :datetime
# last_sign_in_ip :string
# locked_at :datetime
# otp_required_for_login :boolean default(FALSE), not null
# otp_secret :string
# remember_created_at :datetime
# reset_password_sent_at :datetime
# reset_password_token :string
# role :string not null
# sign_in_count :integer default(0), not null
# unlock_token :string
# uuid :string not null
# created_at :datetime not null
# updated_at :datetime not null
# account_group_id :bigint
# account_id :integer
# external_user_id :integer
#
# Indexes
#
# index_users_on_account_group_id (account_group_id)
# index_users_on_account_id (account_id)
# index_users_on_email (email) UNIQUE
# index_users_on_external_user_id (external_user_id) UNIQUE
# index_users_on_reset_password_token (reset_password_token) UNIQUE
# index_users_on_unlock_token (unlock_token) UNIQUE
# index_users_on_uuid (uuid) UNIQUE
#
# Foreign Keys
#
# fk_rails_... (account_group_id => account_groups.id)
# fk_rails_... (account_id => accounts.id)
#
require 'rails_helper'
RSpec.describe User do
@ -96,4 +143,26 @@ RSpec.describe User do
expect(user.friendly_name).to eq('john@example.com')
end
end
describe '.find_or_create_by_external_group_id' do
let(:account_group) { create(:account_group) }
let(:attributes) { { email: 'test@example.com', first_name: 'John' } }
it 'finds existing user by external_user_id and account_group' do
existing_user = create(:user, account: nil, account_group: account_group, external_user_id: 123)
result = described_class.find_or_create_by_external_group_id(account_group, 123, attributes)
expect(result).to eq(existing_user)
end
it 'creates new user when not found' do
result = described_class.find_or_create_by_external_group_id(account_group, 456, attributes)
expect(result.account_group).to eq(account_group)
expect(result.external_user_id).to eq(456)
expect(result.email).to eq('test@example.com')
expect(result.password).to be_present
end
end
end

@ -0,0 +1,68 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe ExternalAuthService do
describe '#authenticate_user' do
let(:user_params) do
{
external_id: 123,
email: 'test@example.com',
first_name: 'John',
last_name: 'Doe'
}
end
context 'with account params' do
let(:params) do
{
account: { external_id: 456, name: 'Test Account' },
user: user_params
}
end
it 'returns access token for new account and user' do
token = described_class.new(params).authenticate_user
expect(token).to be_present
expect(Account.last.external_account_id).to eq(456)
expect(User.last.external_user_id).to eq(123)
end
it 'returns access token for existing user' do
account = create(:account, external_account_id: 456)
user = create(:user, account: account, external_user_id: 123)
token = described_class.new(params).authenticate_user
expect(token).to eq(user.access_token.token)
end
end
context 'with account_group params' do
let(:params) do
{
account_group: { external_id: 789, name: 'Test Group' },
user: user_params
}
end
it 'returns access token for new account_group and user' do
token = described_class.new(params).authenticate_user
expect(token).to be_present
expect(AccountGroup.last.external_account_group_id).to eq(789)
expect(User.last.external_user_id).to eq(123)
end
end
context 'with invalid params' do
it 'raises error when neither account nor account_group provided' do
params = { user: user_params }
expect { described_class.new(params).authenticate_user }
.to raise_error(ArgumentError, 'Either account or account_group params must be provided')
end
end
end
end

@ -0,0 +1,48 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe TemplateService do
describe '#assign_ownership' do
let(:template) { build(:template, account: nil, account_group: nil) }
let(:params) { { folder_name: 'Custom Folder' } }
context 'with account_group user' do
let(:account_group) { create(:account_group) }
let(:user) { create(:user, account: nil, account_group: account_group) }
it 'assigns account_group and default folder' do
service = described_class.new(template, user, params)
service.assign_ownership
expect(template.account_group).to eq(account_group)
expect(template.folder).to eq(account_group.default_template_folder)
end
end
context 'with account user' do
let(:account) { create(:account) }
let(:user) { create(:user, account: account, account_group: nil) }
it 'assigns account and finds/creates folder' do
service = described_class.new(template, user, params)
service.assign_ownership
expect(template.account).to eq(account)
expect(template.folder).to be_present
end
end
context 'with user having neither account nor account_group' do
let(:user) { build(:user, account: nil, account_group: nil) }
it 'does not assign ownership' do
service = described_class.new(template, user, params)
service.assign_ownership
expect(template.account).to be_nil
expect(template.account_group).to be_nil
end
end
end
end
Loading…
Cancel
Save