diff --git a/modules/accredited_representative_portal/accredited_representative_portal.gemspec b/modules/accredited_representative_portal/accredited_representative_portal.gemspec index 59ff0751b94..0a163557ead 100644 --- a/modules/accredited_representative_portal/accredited_representative_portal.gemspec +++ b/modules/accredited_representative_portal/accredited_representative_portal.gemspec @@ -20,5 +20,6 @@ Gem::Specification.new do |spec| spec.test_files = Dir['spec/**/*'] spec.add_dependency 'blind_index' + spec.add_development_dependency 'activerecord' spec.add_development_dependency 'rspec-rails' end diff --git a/modules/accredited_representative_portal/app/models/accredited_representative_portal/power_of_attorney_form.rb b/modules/accredited_representative_portal/app/models/accredited_representative_portal/power_of_attorney_form.rb new file mode 100644 index 00000000000..b9d70b73e25 --- /dev/null +++ b/modules/accredited_representative_portal/app/models/accredited_representative_portal/power_of_attorney_form.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module AccreditedRepresentativePortal + class PowerOfAttorneyForm < ApplicationRecord + belongs_to :power_of_attorney_request, + class_name: 'AccreditedRepresentativePortal::PowerOfAttorneyRequest', + inverse_of: :power_of_attorney_form + + has_kms_key + + has_encrypted :data, key: :kms_key, **lockbox_options + + blind_index :city + blind_index :state + blind_index :zipcode + end +end diff --git a/modules/accredited_representative_portal/app/models/accredited_representative_portal/power_of_attorney_request.rb b/modules/accredited_representative_portal/app/models/accredited_representative_portal/power_of_attorney_request.rb new file mode 100644 index 00000000000..18af415b22c --- /dev/null +++ b/modules/accredited_representative_portal/app/models/accredited_representative_portal/power_of_attorney_request.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module AccreditedRepresentativePortal + class PowerOfAttorneyRequest < ApplicationRecord + belongs_to :claimant, class_name: 'UserAccount' + + has_one :power_of_attorney_form, + class_name: 'AccreditedRepresentativePortal::PowerOfAttorneyForm', + inverse_of: :power_of_attorney_request + + has_one :resolution, + class_name: 'AccreditedRepresentativePortal::PowerOfAttorneyRequestResolution', + inverse_of: :power_of_attorney_request + end +end diff --git a/modules/accredited_representative_portal/app/models/accredited_representative_portal/power_of_attorney_request_decision.rb b/modules/accredited_representative_portal/app/models/accredited_representative_portal/power_of_attorney_request_decision.rb new file mode 100644 index 00000000000..6dcc0a03e41 --- /dev/null +++ b/modules/accredited_representative_portal/app/models/accredited_representative_portal/power_of_attorney_request_decision.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module AccreditedRepresentativePortal + class PowerOfAttorneyRequestDecision < ApplicationRecord + include PowerOfAttorneyRequestResolution::Resolving + + self.inheritance_column = nil + + belongs_to :creator, + class_name: 'UserAccount' + end +end diff --git a/modules/accredited_representative_portal/app/models/accredited_representative_portal/power_of_attorney_request_expiration.rb b/modules/accredited_representative_portal/app/models/accredited_representative_portal/power_of_attorney_request_expiration.rb new file mode 100644 index 00000000000..a2a6fd4cd9e --- /dev/null +++ b/modules/accredited_representative_portal/app/models/accredited_representative_portal/power_of_attorney_request_expiration.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module AccreditedRepresentativePortal + class PowerOfAttorneyRequestExpiration < ApplicationRecord + include PowerOfAttorneyRequestResolution::Resolving + end +end diff --git a/modules/accredited_representative_portal/app/models/accredited_representative_portal/power_of_attorney_request_resolution.rb b/modules/accredited_representative_portal/app/models/accredited_representative_portal/power_of_attorney_request_resolution.rb new file mode 100644 index 00000000000..96754cbb86d --- /dev/null +++ b/modules/accredited_representative_portal/app/models/accredited_representative_portal/power_of_attorney_request_resolution.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module AccreditedRepresentativePortal + class PowerOfAttorneyRequestResolution < ApplicationRecord + belongs_to :power_of_attorney_request, + class_name: 'AccreditedRepresentativePortal::PowerOfAttorneyRequest', + inverse_of: :resolution + + RESOLVING_TYPES = [ + 'AccreditedRepresentativePortal::PowerOfAttorneyRequestExpiration', + 'AccreditedRepresentativePortal::PowerOfAttorneyRequestDecision' + ].freeze + + delegated_type :resolving, types: RESOLVING_TYPES + + has_kms_key + + has_encrypted :reason, key: :kms_key, **lockbox_options + + module Resolving + extend ActiveSupport::Concern + + included do + has_one :power_of_attorney_request_resolution, as: :resolving + end + end + end +end diff --git a/modules/accredited_representative_portal/lib/accredited_representative_portal/engine.rb b/modules/accredited_representative_portal/lib/accredited_representative_portal/engine.rb index 9d1284ad819..864b156a223 100644 --- a/modules/accredited_representative_portal/lib/accredited_representative_portal/engine.rb +++ b/modules/accredited_representative_portal/lib/accredited_representative_portal/engine.rb @@ -3,6 +3,16 @@ module AccreditedRepresentativePortal class Engine < ::Rails::Engine isolate_namespace AccreditedRepresentativePortal + + # `isolate_namespace` redefines `table_name_prefix` on load of + # `active_record`, so we append our own callback to redefine it again how we + # want. + ActiveSupport.on_load(:active_record) do + AccreditedRepresentativePortal.redefine_singleton_method(:table_name_prefix) do + 'ar_' + end + end + config.generators.api_only = true # So that the app-wide migration command notices our engine's migrations. diff --git a/modules/accredited_representative_portal/spec/factories/power_of_attorney_decision.rb b/modules/accredited_representative_portal/spec/factories/power_of_attorney_decision.rb new file mode 100644 index 00000000000..fd6f2e65d66 --- /dev/null +++ b/modules/accredited_representative_portal/spec/factories/power_of_attorney_decision.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :power_of_attorney_request_decision, + class: 'AccreditedRepresentativePortal::PowerOfAttorneyRequestDecision' do + id { Faker::Internet.uuid } + association :creator, factory: :user_account + type { 'Approval' } + end +end diff --git a/modules/accredited_representative_portal/spec/factories/power_of_attorney_expiration.rb b/modules/accredited_representative_portal/spec/factories/power_of_attorney_expiration.rb new file mode 100644 index 00000000000..2f294a2a9fb --- /dev/null +++ b/modules/accredited_representative_portal/spec/factories/power_of_attorney_expiration.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :power_of_attorney_request_expiration, + class: 'AccreditedRepresentativePortal::PowerOfAttorneyRequestExpiration' do + id { Faker::Internet.uuid } + end +end diff --git a/modules/accredited_representative_portal/spec/factories/power_of_attorney_form.rb b/modules/accredited_representative_portal/spec/factories/power_of_attorney_form.rb new file mode 100644 index 00000000000..779efce161d --- /dev/null +++ b/modules/accredited_representative_portal/spec/factories/power_of_attorney_form.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :power_of_attorney_form, class: 'AccreditedRepresentativePortal::PowerOfAttorneyForm' do + association :power_of_attorney_request, factory: :power_of_attorney_request + data_ciphertext { 'Test encrypted data' } + city_bidx { Faker::Alphanumeric.alphanumeric(number: 44) } + state_bidx { Faker::Alphanumeric.alphanumeric(number: 44) } + zipcode_bidx { Faker::Alphanumeric.alphanumeric(number: 44) } + encrypted_kms_key { SecureRandom.hex(16) } + end +end diff --git a/modules/accredited_representative_portal/spec/factories/power_of_attorney_request.rb b/modules/accredited_representative_portal/spec/factories/power_of_attorney_request.rb new file mode 100644 index 00000000000..0aa23eea4f6 --- /dev/null +++ b/modules/accredited_representative_portal/spec/factories/power_of_attorney_request.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :power_of_attorney_request, class: 'AccreditedRepresentativePortal::PowerOfAttorneyRequest' do + association :claimant, factory: :user_account + id { Faker::Internet.uuid } + created_at { Time.current } + end +end diff --git a/modules/accredited_representative_portal/spec/factories/power_of_attorney_request_resolution.rb b/modules/accredited_representative_portal/spec/factories/power_of_attorney_request_resolution.rb new file mode 100644 index 00000000000..20798bf1d54 --- /dev/null +++ b/modules/accredited_representative_portal/spec/factories/power_of_attorney_request_resolution.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :power_of_attorney_request_resolution, + class: 'AccreditedRepresentativePortal::PowerOfAttorneyRequestResolution' do + association :power_of_attorney_request, factory: :power_of_attorney_request + resolving_id { SecureRandom.uuid } + reason_ciphertext { 'Encrypted Reason' } + created_at { Time.current } + encrypted_kms_key { SecureRandom.hex(16) } + + trait :with_expiration do + resolving_type { 'AccreditedRepresentativePortal::PowerOfAttorneyRequestExpiration' } + resolving { create(:power_of_attorney_request_expiration) } + end + + trait :with_decision do + resolving_type { 'AccreditedRepresentativePortal::PowerOfAttorneyRequestDecision' } + resolving { create(:power_of_attorney_request_decision) } + end + + trait :with_invalid_type do + resolving_type { 'AccreditedRepresentativePortal::InvalidType' } + resolving { AccreditedRepresentativePortal::InvalidType.new } + end + end +end + +module AccreditedRepresentativePortal + class InvalidType + def method_missing(_method, *_args) = self + + def respond_to_missing?(_method, _include_private = false) = true + + def id = nil + + def self.method_missing(_method, *_args) = NullObject.new + + def self.respond_to_missing?(_method, _include_private = false) = true + end + + class NullObject + def method_missing(_method, *_args) = self + + def respond_to_missing?(*) = true + + def nil? = true + + def to_s = '' + end +end diff --git a/modules/accredited_representative_portal/spec/models/accredited_representative_portal/power_of_attorney_form_spec.rb b/modules/accredited_representative_portal/spec/models/accredited_representative_portal/power_of_attorney_form_spec.rb new file mode 100644 index 00000000000..0767cf0ceca --- /dev/null +++ b/modules/accredited_representative_portal/spec/models/accredited_representative_portal/power_of_attorney_form_spec.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require_relative '../../rails_helper' + +RSpec.describe AccreditedRepresentativePortal::PowerOfAttorneyForm, type: :model do + describe 'associations' do + it { is_expected.to belong_to(:power_of_attorney_request) } + end + + describe 'creation' do + it 'creates a valid form' do + form = build(:power_of_attorney_form, data_ciphertext: 'test_data') + expect(form).to be_valid + end + end +end diff --git a/modules/accredited_representative_portal/spec/models/accredited_representative_portal/power_of_attorney_request_decision_spec.rb b/modules/accredited_representative_portal/spec/models/accredited_representative_portal/power_of_attorney_request_decision_spec.rb new file mode 100644 index 00000000000..c2376dc7ecd --- /dev/null +++ b/modules/accredited_representative_portal/spec/models/accredited_representative_portal/power_of_attorney_request_decision_spec.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +require_relative '../../rails_helper' + +RSpec.describe AccreditedRepresentativePortal::PowerOfAttorneyRequestDecision, type: :model do + describe 'associations' do + it { is_expected.to belong_to(:creator).class_name('UserAccount') } + it { is_expected.to have_one(:power_of_attorney_request_resolution) } + end +end diff --git a/modules/accredited_representative_portal/spec/models/accredited_representative_portal/power_of_attorney_request_expiration_spec.rb b/modules/accredited_representative_portal/spec/models/accredited_representative_portal/power_of_attorney_request_expiration_spec.rb new file mode 100644 index 00000000000..2213312df20 --- /dev/null +++ b/modules/accredited_representative_portal/spec/models/accredited_representative_portal/power_of_attorney_request_expiration_spec.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require_relative '../../rails_helper' + +RSpec.describe AccreditedRepresentativePortal::PowerOfAttorneyRequestExpiration, type: :model do + describe 'associations' do + it { is_expected.to have_one(:power_of_attorney_request_resolution) } + end + + describe 'validations' do + it 'creates a valid record' do + expiration = create(:power_of_attorney_request_expiration) + expect(expiration).to be_valid + end + end +end diff --git a/modules/accredited_representative_portal/spec/models/accredited_representative_portal/power_of_attorney_request_resolution_spec.rb b/modules/accredited_representative_portal/spec/models/accredited_representative_portal/power_of_attorney_request_resolution_spec.rb new file mode 100644 index 00000000000..e7a006aac77 --- /dev/null +++ b/modules/accredited_representative_portal/spec/models/accredited_representative_portal/power_of_attorney_request_resolution_spec.rb @@ -0,0 +1,142 @@ +# frozen_string_literal: true + +require_relative '../../rails_helper' + +mod = AccreditedRepresentativePortal +RSpec.describe mod::PowerOfAttorneyRequestResolution, type: :model do + describe 'associations' do + let(:power_of_attorney_request) { create(:power_of_attorney_request) } + + it { is_expected.to belong_to(:power_of_attorney_request) } + + it 'can resolve to PowerOfAttorneyRequestExpiration' do + expiration = create(:power_of_attorney_request_expiration) + resolution = described_class.create!( + resolving: expiration, + power_of_attorney_request: power_of_attorney_request, + created_at: Time.zone.now, + encrypted_kms_key: SecureRandom.hex(16) + ) + + expect(resolution.resolving).to eq(expiration) + expect(resolution.resolving_type).to eq('AccreditedRepresentativePortal::PowerOfAttorneyRequestExpiration') + end + + it 'can resolve to PowerOfAttorneyRequestDecision' do + decision = create(:power_of_attorney_request_decision) + resolution = described_class.create!( + resolving: decision, + power_of_attorney_request: power_of_attorney_request, + created_at: Time.zone.now, + encrypted_kms_key: SecureRandom.hex(16) + ) + + expect(resolution.resolving).to eq(decision) + expect(resolution.resolving_type).to eq('AccreditedRepresentativePortal::PowerOfAttorneyRequestDecision') + end + end + + describe 'delegated_type resolving' do + it 'is valid with expiration resolving' do + resolution = create(:power_of_attorney_request_resolution, :with_expiration) + expect(resolution).to be_valid + expect(resolution.resolving).to be_a(mod::PowerOfAttorneyRequestExpiration) + end + + it 'is valid with decision resolving' do + resolution = create(:power_of_attorney_request_resolution, :with_decision) + expect(resolution).to be_valid + expect(resolution.resolving).to be_a(mod::PowerOfAttorneyRequestDecision) + end + + it 'is invalid with null resolving_type and resolving_id' do + resolution = build(:power_of_attorney_request_resolution, resolving_type: nil, resolving_id: nil) + expect(resolution).not_to be_valid + end + end + + describe 'heterogeneous list behavior' do + it 'conveniently returns heterogeneous lists' do + travel_to Time.zone.parse('2024-11-25T09:46:24Z') do + creator = create(:user_account) + + ids = [] + + # Persisted resolving records + decision_acceptance = mod::PowerOfAttorneyRequestDecision.create!( + type: 'acceptance', + creator: creator + ) + decision_declination = mod::PowerOfAttorneyRequestDecision.create!( + type: 'declination', + creator: creator + ) + expiration = mod::PowerOfAttorneyRequestExpiration.create! + + # Associate resolving records + ids << described_class.create!( + power_of_attorney_request: create(:power_of_attorney_request), + resolving: decision_acceptance, + encrypted_kms_key: SecureRandom.hex(16), + created_at: Time.current + ).id + + ids << described_class.create!( + power_of_attorney_request: create(:power_of_attorney_request), + resolving: decision_declination, + encrypted_kms_key: SecureRandom.hex(16), + created_at: Time.current + ).id + + ids << described_class.create!( + power_of_attorney_request: create(:power_of_attorney_request), + resolving: expiration, + encrypted_kms_key: SecureRandom.hex(16), + created_at: Time.current + ).id + + resolutions = described_class.includes(:resolving).find(ids) + + # Serialize for comparison + actual = + resolutions.map do |resolution| + serialized = + case resolution.resolving + when mod::PowerOfAttorneyRequestDecision + { + type: 'decision', + decision_type: resolution.resolving.type + } + when mod::PowerOfAttorneyRequestExpiration + { + type: 'expiration' + } + end + + serialized.merge!( + created_at: resolution.created_at.iso8601 + ) + end + + expect(actual).to eq( + [ + { + type: 'decision', + decision_type: 'acceptance', + created_at: '2024-11-25T09:46:24Z' + }, + { + type: 'decision', + decision_type: 'declination', + created_at: '2024-11-25T09:46:24Z' + }, + { + type: 'expiration', + created_at: '2024-11-25T09:46:24Z' + } + ] + ) + end + end + end +end diff --git a/modules/accredited_representative_portal/spec/models/accredited_representative_portal/power_of_attorney_request_spec.rb b/modules/accredited_representative_portal/spec/models/accredited_representative_portal/power_of_attorney_request_spec.rb new file mode 100644 index 00000000000..81b29d90317 --- /dev/null +++ b/modules/accredited_representative_portal/spec/models/accredited_representative_portal/power_of_attorney_request_spec.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require_relative '../../rails_helper' + +RSpec.describe AccreditedRepresentativePortal::PowerOfAttorneyRequest, type: :model do + describe 'associations' do + it { is_expected.to belong_to(:claimant).class_name('UserAccount') } + it { is_expected.to have_one(:power_of_attorney_form) } + it { is_expected.to have_one(:resolution) } + end +end diff --git a/modules/accredited_representative_portal/spec/models/accredited_representatiive_portal/representative_user_spec.rb b/modules/accredited_representative_portal/spec/models/accredited_representative_portal/representative_user_spec.rb similarity index 100% rename from modules/accredited_representative_portal/spec/models/accredited_representatiive_portal/representative_user_spec.rb rename to modules/accredited_representative_portal/spec/models/accredited_representative_portal/representative_user_spec.rb