diff --git a/.gitignore b/.gitignore index 3be84dc6a6..dd73451a26 100644 --- a/.gitignore +++ b/.gitignore @@ -32,4 +32,7 @@ certs/ca/private/ca_*.pem certs/ Dockerfile.dev.v2 CLAUDE.md +AGENT.md .claude/ +.memory-bank +own/ diff --git a/app/controllers/api/v1/registrant/domains_controller.rb b/app/controllers/api/v1/registrant/domains_controller.rb index 95475a4989..ad1c738a48 100644 --- a/app/controllers/api/v1/registrant/domains_controller.rb +++ b/app/controllers/api/v1/registrant/domains_controller.rb @@ -23,13 +23,20 @@ def index status: :bad_request) && return end - domains = current_user_domains + company_codes = listing_company_codes + domains, total = Domain.listing_for_registrant( + current_registrant_user, + company_codes, + tech_param: params[:tech], + limit_total: LIMIT_DOMAIN_TOTAL + ) + serialized_domains = domains.limit(limit).offset(offset).map do |item| serializer = Serializers::RegistrantApi::Domain.new(item, simplify: simple) serializer.to_json end - render json: { total: current_user_domains_total_count, count: domains.count, + render json: { total: total, count: domains.count, domains: serialized_domains } end @@ -52,6 +59,10 @@ def set_tech_flag params.merge!(tech: 'true') end + def listing_company_codes + ListingCompanyCodesResolver.new(current_registrant_user).call + end + def current_user_domains_total_count current_registrant_user.domains.count rescue CompanyRegister::NotAvailableError diff --git a/app/models/contact.rb b/app/models/contact.rb index 1e78d724b5..3c78107807 100644 --- a/app/models/contact.rb +++ b/app/models/contact.rb @@ -285,6 +285,10 @@ def unlinked contacts.id)') end + def org_contacts_by_codes(company_codes, country_alpha2) + where(ident_type: ORG, ident: company_codes, ident_country_code: country_alpha2) + end + def registrant_user_company_contacts(registrant_user) ident = registrant_user.companies.collect(&:registration_number) diff --git a/app/models/domain.rb b/app/models/domain.rb index 809d18615a..42adb47808 100644 --- a/app/models/domain.rb +++ b/app/models/domain.rb @@ -286,6 +286,46 @@ def nameserver_required? Setting.nameserver_required end + def listing_user_domains(registrant_user, company_codes, admin: false) + direct = if admin + registrant_user_direct_admin_registrant_domains(registrant_user) + else + registrant_user_direct_domains(registrant_user) + end + + return direct if company_codes.blank? + + org_contacts = Contact.org_contacts_by_codes(company_codes, registrant_user.country.alpha2) + company_registrant = registrant_user_company_registrant(org_contacts) + company_by_contact = registrant_user_domains_company(org_contacts, except_tech: admin) + + from( + "(#{direct.to_sql} UNION " \ + "#{company_registrant.to_sql} UNION " \ + "#{company_by_contact.to_sql}) AS domains" + ) + end + + def listing_user_domains_count(registrant_user, company_codes) + listing_user_domains(registrant_user, company_codes, admin: false).count + end + + # Resolves the `admin` flag for the listing query based on the `tech` param. + # For `tech=init` we need the total count to decide if we should hide tech + # contacts (admin-only view) — that same total is returned to the caller + # to avoid a second count query. + def listing_for_registrant(registrant_user, company_codes, tech_param:, limit_total:) + if tech_param == 'init' + total = listing_user_domains_count(registrant_user, company_codes) + admin = total >= limit_total + [listing_user_domains(registrant_user, company_codes, admin: admin), total] + else + admin = tech_param != 'true' + relation = listing_user_domains(registrant_user, company_codes, admin: admin) + [relation, listing_user_domains_count(registrant_user, company_codes)] + end + end + def registrant_user_admin_registrant_domains(registrant_user) companies = Contact.registrant_user_company_contacts(registrant_user) from( diff --git a/app/services/listing_company_codes_resolver.rb b/app/services/listing_company_codes_resolver.rb new file mode 100644 index 0000000000..63b6c36c35 --- /dev/null +++ b/app/services/listing_company_codes_resolver.rb @@ -0,0 +1,115 @@ +require 'net/http' +require 'openssl' +require 'socket' +require 'httpi' + +class ListingCompanyCodesResolver + CACHE_VERSION = 'v1' + STALE_GRACE_PERIOD = 24.hours + FALLBACK_TTL = 1.day + + # Network-level errors that indicate the business registry is unreachable. + # The company_register gem does NOT wrap these into CompanyRegister::NotAvailableError — + # depending on the HTTP adapter in use (HTTPI or Net::HTTP), raw errors bubble up. + NETWORK_ERRORS = [ + Net::OpenTimeout, + Net::ReadTimeout, + Errno::ECONNREFUSED, + Errno::EHOSTUNREACH, + Errno::ENETUNREACH, + Errno::ETIMEDOUT, + SocketError, + OpenSSL::SSL::SSLError, + HTTPI::Error, + ].freeze + + attr_reader :user, :cache, :company_register, :logger + + def initialize(user, cache: Rails.cache, company_register: CompanyRegister::Client.new, logger: Rails.logger) + @user = user + @cache = cache + @company_register = company_register + @logger = logger + end + + def call + return [] if user.ident.include?('-') + + fetch_with_stale_fallback + end + + private + + # Primary caching is handled by CompanyRegister::Client internally + # (cache_store.fetch with cache_period TTL). This resolver only adds + # a stale fallback layer: on every successful lookup (cached or live), + # we persist codes to a stale key with an extended TTL. On error, + # we fall back to that stale key. + def fetch_with_stale_fallback + codes = resolve_company_codes + write_stale_cache(codes) + log(:info, 'live_success') + codes + rescue CompanyRegister::NotAvailableError + stale_fallback + rescue CompanyRegister::SOAPFaultError + log(:error, 'soap_fault_direct_only') + [] + rescue *NETWORK_ERRORS => e + log(:warn, 'network_error', error_class: e.class.name, error_message: e.message) + stale_fallback + end + + def resolve_company_codes + results = company_register.representation_rights( + citizen_personal_code: user.ident, + citizen_country_code: user.country.alpha3 + ) + results.map(&:registration_number).compact.uniq + end + + def stale_fallback + cached_stale = cache.read(stale_key) + if cached_stale + log(:warn, 'stale_fallback') + cached_stale + else + log(:error, 'empty_after_error') + [] + end + end + + # Stale-cache write is an observability concern, not the read path. + # Any cache backend failure (Redis down, memcache timeout, etc.) must not + # break the live lookup that already succeeded — we just lose the fallback + # for the next outage window and log it. + def write_stale_cache(codes) + cache.write(stale_key, codes, expires_in: cache_ttl + STALE_GRACE_PERIOD) + rescue StandardError => e + log(:warn, 'cache_write_failed', error: e.message) + end + + def cache_ttl + period = CompanyRegister.configuration.cache_period + if period.nil? || period <= 0 + log(:warn, 'invalid_cache_period') + FALLBACK_TTL + else + period + end + end + + def stale_key + "registrant/listing_company_codes_stale/#{CACHE_VERSION}/#{user.id}" + end + + def log(level, outcome, extra = {}) + payload = { user_id: user.id, outcome: outcome }.merge(extra).to_json + + case level + when :info then logger.info(payload) + when :warn then logger.warn(payload) + when :error then logger.error(payload) + end + end +end diff --git a/db/seeds_mock.rb b/db/seeds_mock.rb index 8358b1d622..27b9ee4b5d 100644 --- a/db/seeds_mock.rb +++ b/db/seeds_mock.rb @@ -197,6 +197,150 @@ def generate_random_string(length = 8) end end + # ============================================================ + # Stale Fallback Testing Data (registrant_center#165) + # ============================================================ + # + # TARA test user: MARY ANN O'CONNEZ-SUSLIK + # Personal code: 60001019906 + # Phone (Mobile-ID): +37200000766 + # + # Testing procedure: + # 1. Run this seed: rails runner db/seeds_mock.rb + # 2. Log into registrant center via TARA with 60001019906 + # 3. Open /api/v1/registrant/domains?tech=init + # 4. You should see BOTH direct domains (maryann-*.ee) AND company-linked domains (acme-*.ee, globex-*.ee) + # 5. To simulate business registry outage: change company_register_password to invalid value and restart + # 6. Clear primary gem cache: Rails.cache.delete(gem_cache_key) — see below + # 7. Reload domains listing — company-linked domains should still appear (stale fallback) + # 8. After stale TTL expires (cache_period + 24h), company-linked domains will disappear + # + puts "==========================================" + puts "Setting up Stale Fallback Test Data..." + puts "==========================================" + + # Use existing registrar (MOCKREG1) for test domains + test_registrar = Registrar.find_by!(code: "MOCKREG1") + + # RegistrantUser matching TARA test ID + registrant_user = RegistrantUser.find_or_create_by!(registrant_ident: 'EE-60001019906') do |u| + u.username = "MARY ANN O'CONNEZ-SUSLIK" + end + registrant_user.update!(username: "MARY ANN O'CONNEZ-SUSLIK") + puts " RegistrantUser: #{registrant_user.username} (#{registrant_user.registrant_ident})" + + # PRIV contact matching user's ident — for direct domains + priv_contact = Registrant.find_or_create_by!(code: "TARA:PRIV:60001019906") do |c| + c.name = "MARY ANN O'CONNEZ-SUSLIK" + c.email = "maryann+mock@example.com" + c.phone = '+372.50000766' + c.registrar = test_registrar + c.country_code = 'EE' + c.city = 'Tallinn' + c.street = 'Test St 1' + c.zip = '10111' + c.ident_country_code = 'EE' + c.ident_type = 'priv' + c.ident = '60001019906' + end + puts " PRIV contact: #{priv_contact.name} (ident=#{priv_contact.ident})" + + # ORG contacts — for company-linked domains + # These registration numbers come from CompanyRegister demo endpoint + # for test user 60001019906 (test_mode: true in config/application.yml) + mock_companies = [ + { reg_number: '12345678', name: 'Andmesilla DEMO OÜ', code: 'TARA:ORG:ANDMESILLA' }, + { reg_number: '10112390', name: 'SAUTEC AS', code: 'TARA:ORG:SAUTEC' }, + { reg_number: '10001880', name: 'OÜ Spider Autogrupp', code: 'TARA:ORG:SPIDER' }, + ] + + org_contacts = mock_companies.map do |company| + contact = Registrant.find_or_create_by!(code: company[:code]) do |c| + c.name = company[:name] + c.email = "#{company[:name].parameterize}+mock@example.com" + c.phone = generate_phone + c.registrar = test_registrar + c.country_code = 'EE' + c.city = 'Tallinn' + c.street = 'Business St 1' + c.zip = '10111' + c.ident_country_code = 'EE' + c.ident_type = 'org' + c.ident = company[:reg_number] + end + puts " ORG contact: #{contact.name} (ident=#{contact.ident})" + contact + end + + # Direct domains — owned by PRIV contact (always visible) + 2.times do |i| + domain_name = "maryann-#{i+1}.#{zone_origin}" + domain = Domain.find_or_create_by!(name: domain_name) do |d| + d.registrar = test_registrar + d.registrant = priv_contact + d.period = 1 + d.period_unit = 'y' + d.valid_to = 1.year.from_now + d.admin_contacts << priv_contact + d.tech_contacts << priv_contact + 2.times do |j| + d.nameservers.build( + hostname: "ns#{j+1}.#{domain_name}", + ipv4: ["192.0.2.#{100+i*10+j}"], + ipv6: ["2001:db8::#{100+i*10+j}"] + ) + end + end + puts " Direct domain: #{domain.name} (registrant=#{priv_contact.name})" if domain.persisted? + end + + # Company-linked domains — owned by ORG contacts (visible via company representation) + org_contacts.each_with_index do |org_contact, ci| + 2.times do |i| + prefix = org_contact.name.split.first.downcase + domain_name = "#{prefix}-#{i+1}.#{zone_origin}" + domain = Domain.find_or_create_by!(name: domain_name) do |d| + d.registrar = test_registrar + d.registrant = org_contact + d.period = 1 + d.period_unit = 'y' + d.valid_to = 1.year.from_now + d.admin_contacts << org_contact + d.tech_contacts << org_contact + 2.times do |j| + d.nameservers.build( + hostname: "ns#{j+1}.#{domain_name}", + ipv4: ["192.0.2.#{200+ci*20+i*10+j}"], + ipv6: ["2001:db8::#{200+ci*20+i*10+j}"] + ) + end + end + puts " Company domain: #{domain.name} (registrant=#{org_contact.name})" if domain.persisted? + end + end + + puts "" + puts " Test data summary:" + puts " User: EE-60001019906 (MARY ANN O'CONNEZ-SUSLIK)" + puts " Direct domains: maryann-1.ee, maryann-2.ee" + puts " Company domains (from demo business registry):" + puts " andmesilla-1.ee, andmesilla-2.ee (Andmesilla DEMO OÜ, reg=12345678)" + puts " sautec-1.ee, sautec-2.ee (SAUTEC AS, reg=10112390)" + puts " spider-1.ee, spider-2.ee (OÜ Spider Autogrupp, reg=10001880)" + puts "" + puts " Prerequisites:" + puts " company_register_test_mode: 'true' in config/application.yml" + puts "" + puts " Testing procedure:" + puts " 1. Run this seed: rails runner db/seeds_mock.rb" + puts " 2. Restart the app" + puts " 3. Log in via TARA with 60001019906 (+37200000766 for Mobile-ID)" + puts " 4. You should see 8 domains (2 direct + 6 company-linked)" + puts " 5. To simulate outage: set company_register_password to 'invalid' and restart" + puts " 6. Company-linked domains should still appear via stale cache" + puts " 7. After stale TTL expires (cache_period + 24h) they will disappear" + puts "==========================================" + # Custom User requested by the user puts "Processing Custom Registrar: REG1..." custom_registrar = Registrar.find_or_create_by!(code: "REG1") do |r| diff --git a/test/integration/api/v1/registrant/domains_test.rb b/test/integration/api/v1/registrant/domains_test.rb index 367df9e48a..16f27cef33 100644 --- a/test/integration/api/v1/registrant/domains_test.rb +++ b/test/integration/api/v1/registrant/domains_test.rb @@ -1,14 +1,9 @@ require 'test_helper' require 'auth_token/auth_token_creator' -CompanyRegisterClientStub = Struct.new(:any_method) do - def representation_rights(citizen_personal_code:, citizen_country_code:) - raise CompanyRegister::NotAvailableError - end -end - class RegistrantApiV1DomainsTest < ActionDispatch::IntegrationTest setup do + Rails.cache.clear @user = users(:registrant) @registrar = registrars(:bestnames) @contact = contacts(:john) @@ -16,24 +11,116 @@ class RegistrantApiV1DomainsTest < ActionDispatch::IntegrationTest def test_get_default_counts_of_domains get api_v1_registrant_domains_path + "?tech=init", as: :json, - headers: { 'HTTP_AUTHORIZATION' => auth_token } + headers: { 'HTTP_AUTHORIZATION' => auth_token } assert_response :ok response_json = JSON.parse(response.body) - assert_equal response_json['total'], 4 - assert_equal response_json['count'], 4 + assert_equal 4, response_json['total'] + assert_equal 4, response_json['count'] end def test_get_default_counts_of_direct_domains - CompanyRegister::Client.stub(:new, CompanyRegisterClientStub.new) do + stub = build_company_register_stub(raise_error: CompanyRegister::NotAvailableError) + + CompanyRegister::Client.stub(:new, stub) do + get api_v1_registrant_domains_path + "?tech=init", as: :json, + headers: { 'HTTP_AUTHORIZATION' => auth_token } + end + + assert_response :ok + response_json = JSON.parse(response.body) + assert_equal 4, response_json['total'] + assert_equal 4, response_json['count'] + end + + def test_outage_with_stale_cache_returns_company_linked_domains + stale_key = "registrant/listing_company_codes_stale/v1/#{@user.id}" + Rails.cache.write(stale_key, %w[1234567], expires_in: 1.hour) + + stub = build_company_register_stub(raise_error: CompanyRegister::NotAvailableError) + + CompanyRegister::Client.stub(:new, stub) do + get api_v1_registrant_domains_path + "?tech=init", as: :json, + headers: { 'HTTP_AUTHORIZATION' => auth_token } + end + + assert_response :ok + response_json = JSON.parse(response.body) + domain_names = response_json['domains'].map { |d| d['name'] } + assert_includes domain_names, 'shop.test' + end + + def test_outage_no_cache_returns_direct_domains_only + stub = build_company_register_stub(raise_error: CompanyRegister::NotAvailableError) + + CompanyRegister::Client.stub(:new, stub) do get api_v1_registrant_domains_path + "?tech=init", as: :json, headers: { 'HTTP_AUTHORIZATION' => auth_token } end + assert_response :ok response_json = JSON.parse(response.body) - assert_equal response_json['total'], 4 - assert_equal response_json['count'], 4 + assert_equal 4, response_json['total'] + domain_names = response_json['domains'].map { |d| d['name'] } + assert_includes domain_names, 'shop.test' + end + + def test_soap_fault_returns_direct_domains + stub = build_company_register_stub(raise_error: CompanyRegister::SOAPFaultError) + + CompanyRegister::Client.stub(:new, stub) do + get api_v1_registrant_domains_path + "?tech=init", as: :json, + headers: { 'HTTP_AUTHORIZATION' => auth_token } + end + + assert_response :ok + response_json = JSON.parse(response.body) + assert_equal 4, response_json['total'] + domain_names = response_json['domains'].map { |d| d['name'] } + assert_includes domain_names, 'shop.test' + end + + def test_totals_match_in_degrade_mode + stub = build_company_register_stub(raise_error: CompanyRegister::NotAvailableError) + + CompanyRegister::Client.stub(:new, stub) do + get api_v1_registrant_domains_path + "?tech=init", as: :json, + headers: { 'HTTP_AUTHORIZATION' => auth_token } + end + + assert_response :ok + response_json = JSON.parse(response.body) + assert_equal response_json['count'], response_json['total'] + end + + def test_resolver_called_once_per_request + call_count = 0 + counting_factory = lambda do |user| + call_count += 1 + resolver = Object.new + resolver.define_singleton_method(:call) { [] } + resolver + end + + ListingCompanyCodesResolver.stub(:new, counting_factory) do + get api_v1_registrant_domains_path + "?tech=init", as: :json, + headers: { 'HTTP_AUTHORIZATION' => auth_token } + end + + assert_equal 1, call_count + end + + def test_json_shape_preserved + get api_v1_registrant_domains_path + "?tech=init", as: :json, + headers: { 'HTTP_AUTHORIZATION' => auth_token } + + assert_response :ok + response_json = JSON.parse(response.body) + assert response_json.key?('total') + assert response_json.key?('count') + assert response_json.key?('domains') + assert_kind_of Array, response_json['domains'] end private @@ -43,4 +130,12 @@ def auth_token hash = token_creator.token_in_hash "Bearer #{hash[:access_token]}" end + + def build_company_register_stub(raise_error:) + stub = Object.new + stub.define_singleton_method(:representation_rights) do |citizen_personal_code:, citizen_country_code:| + raise raise_error + end + stub + end end diff --git a/test/integration/api/v1/registrant/registry_locks_test.rb b/test/integration/api/v1/registrant/registry_locks_test.rb new file mode 100644 index 0000000000..31face97e0 --- /dev/null +++ b/test/integration/api/v1/registrant/registry_locks_test.rb @@ -0,0 +1,56 @@ +require 'test_helper' +require 'auth_token/auth_token_creator' + +class RegistrantApiV1RegistryLocksTest < ActionDispatch::IntegrationTest + setup do + Rails.cache.clear + @user = users(:registrant) + end + + def test_outage_does_not_use_listing_resolver_for_lock_path + resolver_instantiated = false + original_new = ListingCompanyCodesResolver.method(:new) + + ListingCompanyCodesResolver.define_singleton_method(:new) do |*args, **kwargs| + resolver_instantiated = true + original_new.call(*args, **kwargs) + end + + domain = domains(:shop) + post api_v1_registrant_domain_registry_lock_path(domain_uuid: domain.uuid), + as: :json, + headers: { 'HTTP_AUTHORIZATION' => auth_token } + + refute resolver_instantiated, 'ListingCompanyCodesResolver should not be used in registry_locks path' + ensure + ListingCompanyCodesResolver.define_singleton_method(:new, original_new) + end + + def test_registry_locks_uses_live_flow_not_resolver_during_outage + stub = Object.new + stub.define_singleton_method(:representation_rights) do |citizen_personal_code:, citizen_country_code:| + raise CompanyRegister::NotAvailableError + end + + # metro domain is owned by jack (org ident 12345678), not accessible to registrant user + domain = domains(:metro) + + CompanyRegister::Client.stub(:new, stub) do + post api_v1_registrant_domain_registry_lock_path(domain_uuid: domain.uuid), + as: :json, + headers: { 'HTTP_AUTHORIZATION' => auth_token } + end + + # metro is not found because companies returns [] during outage via RegistrantUser#companies + # and jack is not a direct contact of the registrant user + assert_response :not_found + end + + private + + def auth_token + token_creator = AuthTokenCreator.create_with_defaults(@user) + hash = token_creator.token_in_hash + "Bearer #{hash[:access_token]}" + end +end diff --git a/test/models/contact_test.rb b/test/models/contact_test.rb index fb34ff9e3d..6b5c6ffa59 100644 --- a/test/models/contact_test.rb +++ b/test/models/contact_test.rb @@ -445,6 +445,22 @@ def unlinked_contact @contact end + def test_org_contacts_by_codes_returns_matching_org_contacts + result = Contact.org_contacts_by_codes(%w[1234567], 'US') + assert_includes result, contacts(:acme_ltd) + assert_not_includes result, contacts(:john) + end + + def test_org_contacts_by_codes_returns_empty_for_empty_codes + result = Contact.org_contacts_by_codes([], 'US') + assert_empty result + end + + def test_org_contacts_by_codes_filters_by_country + result = Contact.org_contacts_by_codes(%w[1234567], 'EE') + assert_empty result + end + def valid_contact contacts(:john) end diff --git a/test/models/domain_test.rb b/test/models/domain_test.rb index e94225343f..61f90f0106 100644 --- a/test/models/domain_test.rb +++ b/test/models/domain_test.rb @@ -670,6 +670,56 @@ def valid_domain domains(:shop) end + def test_listing_user_domains_returns_direct_domains_without_company_codes + user = users(:registrant) + result = Domain.listing_user_domains(user, []) + domain_names = result.pluck(:name) + + assert_includes domain_names, 'shop.test' + assert_includes domain_names, 'airport.test' + assert_includes domain_names, 'hospital.test' + end + + def test_listing_user_domains_includes_company_linked_domains + user = users(:registrant) + # jack's ident 12345678 is ORG, metro domain is only reachable via company codes + result = Domain.listing_user_domains(user, %w[12345678]) + domain_names = result.pluck(:name) + + assert_includes domain_names, 'metro.test' + assert_includes domain_names, 'shop.test' + end + + def test_listing_user_domains_deduplicates + user = users(:registrant) + # acme_ltd ident 1234567, library is accessible both directly (john is tech contact) and via company + result = Domain.listing_user_domains(user, %w[1234567]) + domain_ids = result.pluck(:id) + + assert_equal domain_ids.uniq.size, domain_ids.size + end + + def test_listing_user_domains_admin_flag_excludes_tech_contacts + user = users(:registrant) + result_admin = Domain.listing_user_domains(user, [], admin: true) + result_normal = Domain.listing_user_domains(user, [], admin: false) + + admin_names = result_admin.pluck(:name) + normal_names = result_normal.pluck(:name) + + # admin: true uses except_tech which excludes tech-only contacts + # library is linked to john only as tech contact, so should not appear in admin mode via direct path + assert_includes normal_names, 'library.test' + assert_not_includes admin_names, 'library.test' + end + + def test_listing_user_domains_count_returns_correct_count + user = users(:registrant) + count = Domain.listing_user_domains_count(user, []) + + assert_equal Domain.listing_user_domains(user, [], admin: false).count, count + end + def inactive_domain Setting.nameserver_required = true domain = @domain diff --git a/test/services/listing_company_codes_resolver_test.rb b/test/services/listing_company_codes_resolver_test.rb new file mode 100644 index 0000000000..f6c519dd87 --- /dev/null +++ b/test/services/listing_company_codes_resolver_test.rb @@ -0,0 +1,289 @@ +require 'test_helper' + +class ListingCompanyCodesResolverTest < ActiveSupport::TestCase + Company = Struct.new(:registration_number, :company_name) + + setup do + Rails.cache.clear + @user = users(:registrant) + @logger = Logger.new(StringIO.new) + end + + def test_returns_empty_when_ident_contains_dash + user = RegistrantUser.new(registrant_ident: 'EE-12-34', username: 'Test') + user.save!(validate: false) + + stub = build_stub(expected_result: :should_not_be_called) + resolver = build_resolver(user: user, company_register: stub) + + assert_equal [], resolver.call + ensure + user&.destroy + end + + def test_live_success_returns_codes_and_writes_stale_cache + companies = [Company.new('1234567', 'ACME'), Company.new('7654321', 'Globex')] + stub = build_stub(expected_result: companies) + + resolver = build_resolver(company_register: stub) + result = resolver.call + + assert_equal %w[1234567 7654321], result + assert_equal %w[1234567 7654321], Rails.cache.read(stale_key) + end + + def test_live_success_with_empty_result + stub = build_stub(expected_result: []) + resolver = build_resolver(company_register: stub) + + result = resolver.call + + assert_equal [], result + assert_equal [], Rails.cache.read(stale_key) + end + + def test_live_success_deduplicates_and_compacts + companies = [Company.new('1234567', 'ACME'), Company.new(nil, 'NoCode'), Company.new('1234567', 'ACME Dup')] + stub = build_stub(expected_result: companies) + + resolver = build_resolver(company_register: stub) + result = resolver.call + + assert_equal %w[1234567], result + end + + def test_stale_fallback_on_not_available_error + stale_codes = %w[1234567] + Rails.cache.write(stale_key, stale_codes, expires_in: 1.hour) + + stub = build_stub(raise_error: CompanyRegister::NotAvailableError) + resolver = build_resolver(company_register: stub) + + assert_equal stale_codes, resolver.call + end + + def test_empty_after_error_when_no_stale + stub = build_stub(raise_error: CompanyRegister::NotAvailableError) + resolver = build_resolver(company_register: stub) + + assert_equal [], resolver.call + end + + def test_expired_stale_returns_empty + Rails.cache.write(stale_key, %w[1234567], expires_in: 1.second) + sleep 1.1 + + stub = build_stub(raise_error: CompanyRegister::NotAvailableError) + resolver = build_resolver(company_register: stub) + + assert_equal [], resolver.call + end + + def test_net_open_timeout_uses_stale_fallback + stale_codes = %w[1234567] + Rails.cache.write(stale_key, stale_codes, expires_in: 1.hour) + + stub = build_stub(raise_error: Net::OpenTimeout) + resolver = build_resolver(company_register: stub) + + assert_equal stale_codes, resolver.call + end + + def test_net_read_timeout_uses_stale_fallback + stale_codes = %w[1234567] + Rails.cache.write(stale_key, stale_codes, expires_in: 1.hour) + + stub = build_stub(raise_error: Net::ReadTimeout) + resolver = build_resolver(company_register: stub) + + assert_equal stale_codes, resolver.call + end + + def test_connection_refused_uses_stale_fallback + stale_codes = %w[1234567] + Rails.cache.write(stale_key, stale_codes, expires_in: 1.hour) + + stub = build_stub(raise_error: Errno::ECONNREFUSED) + resolver = build_resolver(company_register: stub) + + assert_equal stale_codes, resolver.call + end + + def test_httpi_ssl_error_uses_stale_fallback + skip 'HTTPI not loaded' unless defined?(HTTPI::SSLError) + + stale_codes = %w[1234567] + Rails.cache.write(stale_key, stale_codes, expires_in: 1.hour) + + underlying = OpenSSL::SSL::SSLError.new('certificate verify failed') + stub = Object.new + stub.define_singleton_method(:representation_rights) do |**_kwargs| + raise HTTPI::SSLError.new(underlying.message, underlying) + end + resolver = build_resolver(company_register: stub) + + assert_equal stale_codes, resolver.call + end + + def test_openssl_error_uses_stale_fallback + stale_codes = %w[1234567] + Rails.cache.write(stale_key, stale_codes, expires_in: 1.hour) + + stub = build_stub(raise_error: OpenSSL::SSL::SSLError) + resolver = build_resolver(company_register: stub) + + assert_equal stale_codes, resolver.call + end + + def test_network_error_without_stale_returns_empty + stub = build_stub(raise_error: Net::OpenTimeout) + resolver = build_resolver(company_register: stub) + + assert_equal [], resolver.call + end + + def test_logs_network_error + log_output = StringIO.new + logger = Logger.new(log_output) + + stub = build_stub(raise_error: Net::OpenTimeout) + resolver = build_resolver(company_register: stub, logger: logger) + resolver.call + + assert_includes log_output.string, 'network_error' + end + + def test_soap_fault_returns_empty_without_stale_fallback + Rails.cache.write(stale_key, %w[1234567], expires_in: 1.hour) + + stub = build_stub(raise_error: CompanyRegister::SOAPFaultError) + resolver = build_resolver(company_register: stub) + + assert_equal [], resolver.call + end + + def test_cache_write_failure_returns_live_result + companies = [Company.new('1234567', 'ACME')] + + failing_cache = Object.new + failing_cache.define_singleton_method(:read) { |_key| nil } + failing_cache.define_singleton_method(:write) { |*_args| raise StandardError, 'cache down' } + + stub = build_stub(expected_result: companies) + resolver = build_resolver(company_register: stub, cache: failing_cache) + + result = resolver.call + assert_equal %w[1234567], result + end + + def test_invalid_cache_period_uses_fallback_ttl + log_output = StringIO.new + logger = Logger.new(log_output) + + original_period = CompanyRegister.configuration.cache_period + CompanyRegister.configuration.cache_period = 0.days + + companies = [Company.new('1234567', 'ACME')] + stub = build_stub(expected_result: companies) + resolver = build_resolver(company_register: stub, logger: logger) + + result = resolver.call + assert_equal %w[1234567], result + assert_includes log_output.string, 'invalid_cache_period' + ensure + CompanyRegister.configuration.cache_period = original_period + end + + def test_logs_live_success + log_output = StringIO.new + logger = Logger.new(log_output) + + companies = [Company.new('1234567', 'ACME')] + stub = build_stub(expected_result: companies) + resolver = build_resolver(company_register: stub, logger: logger) + resolver.call + + assert_includes log_output.string, 'live_success' + end + + def test_logs_stale_fallback + log_output = StringIO.new + logger = Logger.new(log_output) + Rails.cache.write(stale_key, %w[1234567], expires_in: 1.hour) + + stub = build_stub(raise_error: CompanyRegister::NotAvailableError) + resolver = build_resolver(company_register: stub, logger: logger) + resolver.call + + assert_includes log_output.string, 'stale_fallback' + end + + def test_logs_empty_after_error + log_output = StringIO.new + logger = Logger.new(log_output) + + stub = build_stub(raise_error: CompanyRegister::NotAvailableError) + resolver = build_resolver(company_register: stub, logger: logger) + resolver.call + + assert_includes log_output.string, 'empty_after_error' + end + + def test_logs_soap_fault + log_output = StringIO.new + logger = Logger.new(log_output) + + stub = build_stub(raise_error: CompanyRegister::SOAPFaultError) + resolver = build_resolver(company_register: stub, logger: logger) + resolver.call + + assert_includes log_output.string, 'soap_fault_direct_only' + end + + def test_logs_cache_write_failed + log_output = StringIO.new + logger = Logger.new(log_output) + + failing_cache = Object.new + failing_cache.define_singleton_method(:read) { |_key| nil } + failing_cache.define_singleton_method(:write) { |*_args| raise StandardError, 'cache down' } + + companies = [Company.new('1234567', 'ACME')] + stub = build_stub(expected_result: companies) + resolver = build_resolver(company_register: stub, cache: failing_cache, logger: logger) + resolver.call + + assert_includes log_output.string, 'cache_write_failed' + end + + private + + def build_resolver(user: @user, company_register: nil, cache: Rails.cache, logger: @logger) + company_register ||= build_stub(expected_result: []) + ListingCompanyCodesResolver.new(user, cache: cache, company_register: company_register, logger: logger) + end + + def build_stub(expected_result: nil, raise_error: nil) + stub = Object.new + + if raise_error + stub.define_singleton_method(:representation_rights) do |citizen_personal_code:, citizen_country_code:| + raise raise_error + end + elsif expected_result == :should_not_be_called + stub.define_singleton_method(:representation_rights) do |citizen_personal_code:, citizen_country_code:| + raise 'representation_rights should not have been called' + end + else + stub.define_singleton_method(:representation_rights) do |citizen_personal_code:, citizen_country_code:| + expected_result + end + end + + stub + end + + def stale_key + "registrant/listing_company_codes_stale/v1/#{@user.id}" + end +end