From f44d6a8d17720b5dd2f830165c421c35bfc1bb3c Mon Sep 17 00:00:00 2001 From: oleghasjanov Date: Thu, 9 Apr 2026 12:34:37 +0300 Subject: [PATCH 1/7] Add stale fallback for domain listing when business registry is unavailable Domain listing endpoint no longer returns empty results when the business registry SOAP service is down. A new ListingCompanyCodesResolver service provides cache-first lookup with bounded stale fallback for company codes, used exclusively in the read-only domains#index path. Direct domains are always preserved; company-linked domains degrade gracefully. Write paths (registry locks, contacts) remain unchanged. Resolves registrant_center#165 --- .../api/v1/registrant/domains_controller.rb | 21 +- app/models/contact.rb | 4 + app/models/domain.rb | 26 ++ .../listing_company_codes_resolver.rb | 89 +++++++ .../api/v1/registrant/domains_test.rb | 120 ++++++++- .../api/v1/registrant/registry_locks_test.rb | 56 ++++ test/models/contact_test.rb | 16 ++ test/models/domain_test.rb | 50 ++++ .../listing_company_codes_resolver_test.rb | 243 ++++++++++++++++++ 9 files changed, 612 insertions(+), 13 deletions(-) create mode 100644 app/services/listing_company_codes_resolver.rb create mode 100644 test/integration/api/v1/registrant/registry_locks_test.rb create mode 100644 test/services/listing_company_codes_resolver_test.rb diff --git a/app/controllers/api/v1/registrant/domains_controller.rb b/app/controllers/api/v1/registrant/domains_controller.rb index 95475a4989..4375db869e 100644 --- a/app/controllers/api/v1/registrant/domains_controller.rb +++ b/app/controllers/api/v1/registrant/domains_controller.rb @@ -23,13 +23,17 @@ def index status: :bad_request) && return end - domains = current_user_domains + company_codes = listing_company_codes + admin = listing_admin_flag(company_codes) + domains = Domain.listing_user_domains(current_registrant_user, company_codes, admin: admin) + 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, + total = Domain.listing_user_domains_count(current_registrant_user, company_codes) + render json: { total: total, count: domains.count, domains: serialized_domains } end @@ -52,6 +56,19 @@ def set_tech_flag params.merge!(tech: 'true') end + def listing_company_codes + ListingCompanyCodesResolver.new(current_registrant_user).call + end + + def listing_admin_flag(company_codes) + if params[:tech] == 'init' + total = Domain.listing_user_domains_count(current_registrant_user, company_codes) + return total >= LIMIT_DOMAIN_TOTAL + end + + params[:tech] != 'true' + 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..23be63f533 100644 --- a/app/models/domain.rb +++ b/app/models/domain.rb @@ -286,6 +286,32 @@ 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 + + if company_codes.blank? + return direct + end + + 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 + 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..caafc5515b --- /dev/null +++ b/app/services/listing_company_codes_resolver.rb @@ -0,0 +1,89 @@ +class ListingCompanyCodesResolver + CACHE_VERSION = 'v1' + STALE_GRACE_PERIOD = 24.hours + FALLBACK_TTL = 1.day + + 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?('-') + + cached_primary, cached_stale = read_cache + if cached_primary + log(:info, 'cache_hit') + return cached_primary + end + + fetch_live(cached_stale) + end + + private + + def fetch_live(cached_stale) + results = @company_register.representation_rights( + citizen_personal_code: @user.ident, + citizen_country_code: @user.country.alpha3 + ) + codes = results.map(&:registration_number).compact.uniq + + write_cache(codes) + log(:info, 'live_success') + codes + rescue CompanyRegister::NotAvailableError + stale_fallback(cached_stale) + rescue CompanyRegister::SOAPFaultError + log(:error, 'soap_fault_direct_only') + [] + end + + def stale_fallback(cached_stale) + if cached_stale + log(:warn, 'stale_fallback') + cached_stale + else + log(:error, 'empty_after_error') + [] + end + end + + def read_cache + primary = @cache.read(primary_key) + stale = @cache.read(stale_key) + [primary, stale] + end + + def write_cache(codes) + ttl = cache_ttl + @cache.write(primary_key, codes, expires_in: ttl) + @cache.write(stale_key, codes, expires_in: 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 primary_key + "registrant/listing_company_codes/#{CACHE_VERSION}/#{@user.id}" + end + + def stale_key + "registrant/listing_company_codes_stale/#{CACHE_VERSION}/#{@user.id}" + end + + def log(level, outcome, extra = {}) + @logger.send(level, { user_id: @user.id, outcome: outcome }.merge(extra).to_json) + end +end diff --git a/test/integration/api/v1/registrant/domains_test.rb b/test/integration/api/v1/registrant/domains_test.rb index 367df9e48a..f8aa5fce2c 100644 --- a/test/integration/api/v1/registrant/domains_test.rb +++ b/test/integration/api/v1/registrant/domains_test.rb @@ -1,14 +1,11 @@ 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 + CompanyStub = Struct.new(:registration_number, :company_name) + setup do + Rails.cache.clear @user = users(:registrant) @registrar = registrars(:bestnames) @contact = contacts(:john) @@ -21,19 +18,112 @@ def test_get_default_counts_of_domains assert_response :ok response_json = JSON.parse(response.body) - assert_equal response_json['total'], 4 - assert_equal response_json['count'], 4 + assert_equal response_json['count'], response_json['total'] + assert response_json['total'] > 0 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 response_json['total'] > 0 + assert response_json['count'] > 0 + end + + def test_outage_with_stale_cache_returns_company_linked_domains + # Pre-populate stale cache with acme_ltd's registration number + 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) - assert_equal response_json['total'], 4 - assert_equal response_json['count'], 4 + 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 response_json['total'] > 0 + 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 response_json['total'] > 0 + 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 response_json['total'] >= response_json['count'] + end + + def test_resolver_called_once_per_request + call_count = 0 + original_new = ListingCompanyCodesResolver.method(:new) + + ListingCompanyCodesResolver.define_singleton_method(:new) do |*args, **kwargs| + call_count += 1 + original_new.call(*args, **kwargs) + end + + get api_v1_registrant_domains_path + "?tech=init", as: :json, + headers: { 'HTTP_AUTHORIZATION' => auth_token } + + assert_equal 1, call_count + ensure + ListingCompanyCodesResolver.define_singleton_method(:new, original_new) + 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 +133,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..f7a2112ac1 --- /dev/null +++ b/test/services/listing_company_codes_resolver_test.rb @@ -0,0 +1,243 @@ +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_cache_hit_returns_cached_codes + codes = %w[1234567 7654321] + Rails.cache.write(primary_key, codes, expires_in: 1.day) + + stub = build_stub(expected_result: :should_not_be_called) + resolver = build_resolver(company_register: stub) + + assert_equal codes, resolver.call + end + + def test_live_success_returns_codes_and_writes_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(primary_key) + 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(primary_key) + 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_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_cache_hit + log_output = StringIO.new + logger = Logger.new(log_output) + Rails.cache.write(primary_key, %w[1234567], expires_in: 1.day) + + stub = build_stub(expected_result: :should_not_be_called) + resolver = build_resolver(company_register: stub, logger: logger) + resolver.call + + assert_includes log_output.string, 'cache_hit' + 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 primary_key + "registrant/listing_company_codes/v1/#{@user.id}" + end + + def stale_key + "registrant/listing_company_codes_stale/v1/#{@user.id}" + end +end From 965fa81a33a27fdafa365f6fd6f44c18263cddf8 Mon Sep 17 00:00:00 2001 From: oleghasjanov Date: Thu, 9 Apr 2026 12:47:33 +0300 Subject: [PATCH 2/7] Remove redundant primary cache layer from resolver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CompanyRegister::Client already caches representation_rights responses internally via cache_store.fetch with cache_period TTL. The resolver was duplicating this with its own primary key. Now the resolver only maintains a stale fallback key — the gem handles primary caching. --- .../listing_company_codes_resolver.rb | 50 ++++++++----------- .../listing_company_codes_resolver_test.rb | 30 +---------- 2 files changed, 22 insertions(+), 58 deletions(-) diff --git a/app/services/listing_company_codes_resolver.rb b/app/services/listing_company_codes_resolver.rb index caafc5515b..b673930c52 100644 --- a/app/services/listing_company_codes_resolver.rb +++ b/app/services/listing_company_codes_resolver.rb @@ -13,35 +13,38 @@ def initialize(user, cache: Rails.cache, company_register: CompanyRegister::Clie def call return [] if @user.ident.include?('-') - cached_primary, cached_stale = read_cache - if cached_primary - log(:info, 'cache_hit') - return cached_primary - end - - fetch_live(cached_stale) + fetch_with_stale_fallback end private - def fetch_live(cached_stale) - results = @company_register.representation_rights( - citizen_personal_code: @user.ident, - citizen_country_code: @user.country.alpha3 - ) - codes = results.map(&:registration_number).compact.uniq - - write_cache(codes) + # 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(cached_stale) + stale_fallback rescue CompanyRegister::SOAPFaultError log(:error, 'soap_fault_direct_only') [] end - def stale_fallback(cached_stale) + 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 @@ -51,15 +54,8 @@ def stale_fallback(cached_stale) end end - def read_cache - primary = @cache.read(primary_key) - stale = @cache.read(stale_key) - [primary, stale] - end - - def write_cache(codes) + def write_stale_cache(codes) ttl = cache_ttl - @cache.write(primary_key, codes, expires_in: ttl) @cache.write(stale_key, codes, expires_in: ttl + STALE_GRACE_PERIOD) rescue StandardError => e log(:warn, 'cache_write_failed', error: e.message) @@ -75,10 +71,6 @@ def cache_ttl end end - def primary_key - "registrant/listing_company_codes/#{CACHE_VERSION}/#{@user.id}" - end - def stale_key "registrant/listing_company_codes_stale/#{CACHE_VERSION}/#{@user.id}" end diff --git a/test/services/listing_company_codes_resolver_test.rb b/test/services/listing_company_codes_resolver_test.rb index f7a2112ac1..6ef8befa6f 100644 --- a/test/services/listing_company_codes_resolver_test.rb +++ b/test/services/listing_company_codes_resolver_test.rb @@ -21,17 +21,7 @@ def test_returns_empty_when_ident_contains_dash user&.destroy end - def test_cache_hit_returns_cached_codes - codes = %w[1234567 7654321] - Rails.cache.write(primary_key, codes, expires_in: 1.day) - - stub = build_stub(expected_result: :should_not_be_called) - resolver = build_resolver(company_register: stub) - - assert_equal codes, resolver.call - end - - def test_live_success_returns_codes_and_writes_cache + 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) @@ -39,7 +29,6 @@ def test_live_success_returns_codes_and_writes_cache result = resolver.call assert_equal %w[1234567 7654321], result - assert_equal %w[1234567 7654321], Rails.cache.read(primary_key) assert_equal %w[1234567 7654321], Rails.cache.read(stale_key) end @@ -50,7 +39,6 @@ def test_live_success_with_empty_result result = resolver.call assert_equal [], result - assert_equal [], Rails.cache.read(primary_key) assert_equal [], Rails.cache.read(stale_key) end @@ -132,18 +120,6 @@ def test_invalid_cache_period_uses_fallback_ttl CompanyRegister.configuration.cache_period = original_period end - def test_logs_cache_hit - log_output = StringIO.new - logger = Logger.new(log_output) - Rails.cache.write(primary_key, %w[1234567], expires_in: 1.day) - - stub = build_stub(expected_result: :should_not_be_called) - resolver = build_resolver(company_register: stub, logger: logger) - resolver.call - - assert_includes log_output.string, 'cache_hit' - end - def test_logs_live_success log_output = StringIO.new logger = Logger.new(log_output) @@ -233,10 +209,6 @@ def build_stub(expected_result: nil, raise_error: nil) stub end - def primary_key - "registrant/listing_company_codes/v1/#{@user.id}" - end - def stale_key "registrant/listing_company_codes_stale/v1/#{@user.id}" end From 6e6b2c8aa865289e7a985661ec02101900b97de5 Mon Sep 17 00:00:00 2001 From: oleghasjanov Date: Thu, 9 Apr 2026 13:32:26 +0300 Subject: [PATCH 3/7] Add mock data for stale fallback manual testing Adds registrant user, contacts, and domains for testing the company codes stale fallback feature via TARA test login (60001019906). Pre-populates both gem cache and stale cache so company-linked domains are visible immediately. Includes instructions for simulating outage. --- db/seeds_mock.rb | 161 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 161 insertions(+) diff --git a/db/seeds_mock.rb b/db/seeds_mock.rb index 8358b1d622..923c7370d5 100644 --- a/db/seeds_mock.rb +++ b/db/seeds_mock.rb @@ -197,6 +197,167 @@ 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 must match what we put in the cache + mock_companies = [ + { reg_number: '12345678', name: 'ACME Test OÜ', code: 'TARA:ORG:ACME' }, + { reg_number: '87654321', name: 'Globex Test AS', code: 'TARA:ORG:GLOBEX' }, + ] + + 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 + + # Pre-populate caches so company-linked domains appear immediately + company_codes = mock_companies.map { |c| c[:reg_number] } + + # 1. Populate CompanyRegister gem's internal cache (simulates successful SOAP response) + gem_cache_key = { fyysilise_isiku_kood: '60001019906', + fyysilise_isiku_koodi_riik: 'EST', + keel: 'eng' }.to_json + gem_cache_value = { + esindus_v1_response: { + paring: {}, + keha: { + ettevotjad: { + item: mock_companies.map { |c| { ariregistri_kood: c[:reg_number], arinimi: c[:name] } } + } + } + } + } + cache_period = CompanyRegister.configuration.cache_period + cache_period = 1.day if cache_period.nil? || cache_period <= 0 + Rails.cache.write(gem_cache_key, gem_cache_value, expires_in: cache_period) + puts " Gem cache populated (TTL=#{cache_period.inspect}, key=#{gem_cache_key[0..50]}...)" + + # 2. Populate stale fallback cache (used by ListingCompanyCodesResolver on outage) + stale_key = "registrant/listing_company_codes_stale/v1/#{registrant_user.id}" + Rails.cache.write(stale_key, company_codes, expires_in: cache_period + 24.hours) + puts " Stale cache populated (TTL=#{(cache_period + 24.hours).inspect}, key=#{stale_key})" + + 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: acme-1.ee, acme-2.ee (ACME Test OÜ, reg=12345678)" + puts " globex-1.ee, globex-2.ee (Globex Test AS, reg=87654321)" + puts " Gem cache key: #{gem_cache_key}" + puts "" + puts " To simulate outage:" + puts " 1. Clear gem cache: Rails.cache.delete('#{gem_cache_key}')" + puts " 2. Change company_register_password to 'invalid' and restart" + puts " 3. Domains listing should still show company domains via stale fallback" + puts "==========================================" + # Custom User requested by the user puts "Processing Custom Registrar: REG1..." custom_registrar = Registrar.find_or_create_by!(code: "REG1") do |r| From 62dc2c64e08456c4e9c12fda376dfe618e0416dd Mon Sep 17 00:00:00 2001 From: oleghasjanov Date: Thu, 9 Apr 2026 15:18:29 +0300 Subject: [PATCH 4/7] remove mocks --- .gitignore | 3 +++ db/seeds_mock.rb | 57 +++++++++++++++++------------------------------- e | 0 3 files changed, 23 insertions(+), 37 deletions(-) create mode 100644 e diff --git a/.gitignore b/.gitignore index 3be84dc6a6..b14a55b82c 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 \ No newline at end of file diff --git a/db/seeds_mock.rb b/db/seeds_mock.rb index 923c7370d5..27b9ee4b5d 100644 --- a/db/seeds_mock.rb +++ b/db/seeds_mock.rb @@ -246,10 +246,12 @@ def generate_random_string(length = 8) puts " PRIV contact: #{priv_contact.name} (ident=#{priv_contact.ident})" # ORG contacts — for company-linked domains - # These registration numbers must match what we put in the cache + # 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: 'ACME Test OÜ', code: 'TARA:ORG:ACME' }, - { reg_number: '87654321', name: 'Globex Test AS', code: 'TARA:ORG:GLOBEX' }, + { 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| @@ -317,45 +319,26 @@ def generate_random_string(length = 8) end end - # Pre-populate caches so company-linked domains appear immediately - company_codes = mock_companies.map { |c| c[:reg_number] } - - # 1. Populate CompanyRegister gem's internal cache (simulates successful SOAP response) - gem_cache_key = { fyysilise_isiku_kood: '60001019906', - fyysilise_isiku_koodi_riik: 'EST', - keel: 'eng' }.to_json - gem_cache_value = { - esindus_v1_response: { - paring: {}, - keha: { - ettevotjad: { - item: mock_companies.map { |c| { ariregistri_kood: c[:reg_number], arinimi: c[:name] } } - } - } - } - } - cache_period = CompanyRegister.configuration.cache_period - cache_period = 1.day if cache_period.nil? || cache_period <= 0 - Rails.cache.write(gem_cache_key, gem_cache_value, expires_in: cache_period) - puts " Gem cache populated (TTL=#{cache_period.inspect}, key=#{gem_cache_key[0..50]}...)" - - # 2. Populate stale fallback cache (used by ListingCompanyCodesResolver on outage) - stale_key = "registrant/listing_company_codes_stale/v1/#{registrant_user.id}" - Rails.cache.write(stale_key, company_codes, expires_in: cache_period + 24.hours) - puts " Stale cache populated (TTL=#{(cache_period + 24.hours).inspect}, key=#{stale_key})" - 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: acme-1.ee, acme-2.ee (ACME Test OÜ, reg=12345678)" - puts " globex-1.ee, globex-2.ee (Globex Test AS, reg=87654321)" - puts " Gem cache key: #{gem_cache_key}" + 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 " To simulate outage:" - puts " 1. Clear gem cache: Rails.cache.delete('#{gem_cache_key}')" - puts " 2. Change company_register_password to 'invalid' and restart" - puts " 3. Domains listing should still show company domains via stale fallback" + 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 diff --git a/e b/e new file mode 100644 index 0000000000..e69de29bb2 From 15d9dd89fd1bbef6e6c7c02086f5b0cf386d1517 Mon Sep 17 00:00:00 2001 From: oleghasjanov Date: Fri, 10 Apr 2026 11:25:33 +0300 Subject: [PATCH 5/7] Catch network timeouts and treat as business registry outage After switching the company_register gem from HTTPI to Net::HTTP adapter, network-level errors like Net::OpenTimeout bubble up from the SOAP client without being wrapped into CompanyRegister::NotAvailableError. This caused domains#index to return 500 instead of falling back to direct domains when the business registry was unreachable. Resolver now catches Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ENETUNREACH, Errno::ETIMEDOUT, SocketError and OpenSSL::SSL::SSLError and treats them the same as NotAvailableError: fall back to stale cache, log as 'network_error'. --- .../listing_company_codes_resolver.rb | 21 ++++++++ .../listing_company_codes_resolver_test.rb | 48 +++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/app/services/listing_company_codes_resolver.rb b/app/services/listing_company_codes_resolver.rb index b673930c52..2cdd927eae 100644 --- a/app/services/listing_company_codes_resolver.rb +++ b/app/services/listing_company_codes_resolver.rb @@ -1,8 +1,26 @@ +require 'net/http' +require 'openssl' +require 'socket' + class ListingCompanyCodesResolver CACHE_VERSION = 'v1' STALE_GRACE_PERIOD = 24.hours FALLBACK_TTL = 1.day + # Network-level errors that indicate the business registry is unreachable. + # These bubble up from Net::HTTP (used by the company_register gem's Savon adapter) + # and are NOT wrapped into CompanyRegister::NotAvailableError by the gem. + NETWORK_ERRORS = [ + Net::OpenTimeout, + Net::ReadTimeout, + Errno::ECONNREFUSED, + Errno::EHOSTUNREACH, + Errno::ENETUNREACH, + Errno::ETIMEDOUT, + SocketError, + OpenSSL::SSL::SSLError, + ].freeze + def initialize(user, cache: Rails.cache, company_register: CompanyRegister::Client.new, logger: Rails.logger) @user = user @cache = cache @@ -33,6 +51,9 @@ def fetch_with_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 diff --git a/test/services/listing_company_codes_resolver_test.rb b/test/services/listing_company_codes_resolver_test.rb index 6ef8befa6f..c048477adc 100644 --- a/test/services/listing_company_codes_resolver_test.rb +++ b/test/services/listing_company_codes_resolver_test.rb @@ -79,6 +79,54 @@ def test_expired_stale_returns_empty 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_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) From 31a3d2f343b2253a64368fe5afb5a671ec8dea66 Mon Sep 17 00:00:00 2001 From: oleghasjanov Date: Fri, 10 Apr 2026 11:49:18 +0300 Subject: [PATCH 6/7] Catch HTTPI errors as business registry outage On staging the company_register gem uses the HTTPI adapter, which raises HTTPI::SSLError / HTTPI::Error for network problems. These were bubbling up past the resolver's rescue and causing 500 responses. Resolver now catches HTTPI::Error (when HTTPI is loaded) alongside the Net::HTTP-era error classes. Added a regression test that raises HTTPI::SSLError and expects stale fallback. --- .../listing_company_codes_resolver.rb | 31 +++++++++++-------- .../listing_company_codes_resolver_test.rb | 26 ++++++++++++++++ 2 files changed, 44 insertions(+), 13 deletions(-) diff --git a/app/services/listing_company_codes_resolver.rb b/app/services/listing_company_codes_resolver.rb index 2cdd927eae..9cd90e6878 100644 --- a/app/services/listing_company_codes_resolver.rb +++ b/app/services/listing_company_codes_resolver.rb @@ -8,18 +8,23 @@ class ListingCompanyCodesResolver FALLBACK_TTL = 1.day # Network-level errors that indicate the business registry is unreachable. - # These bubble up from Net::HTTP (used by the company_register gem's Savon adapter) - # and are NOT wrapped into CompanyRegister::NotAvailableError by the gem. - NETWORK_ERRORS = [ - Net::OpenTimeout, - Net::ReadTimeout, - Errno::ECONNREFUSED, - Errno::EHOSTUNREACH, - Errno::ENETUNREACH, - Errno::ETIMEDOUT, - SocketError, - OpenSSL::SSL::SSLError, - ].freeze + # 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. + # We treat all of them as "business registry is unavailable" and fall back to stale cache. + def self.network_error_classes + classes = [ + Net::OpenTimeout, + Net::ReadTimeout, + Errno::ECONNREFUSED, + Errno::EHOSTUNREACH, + Errno::ENETUNREACH, + Errno::ETIMEDOUT, + SocketError, + OpenSSL::SSL::SSLError, + ] + classes << HTTPI::Error if defined?(HTTPI::Error) + classes + end def initialize(user, cache: Rails.cache, company_register: CompanyRegister::Client.new, logger: Rails.logger) @user = user @@ -51,7 +56,7 @@ def fetch_with_stale_fallback rescue CompanyRegister::SOAPFaultError log(:error, 'soap_fault_direct_only') [] - rescue *NETWORK_ERRORS => e + rescue *self.class.network_error_classes => e log(:warn, 'network_error', error_class: e.class.name, error_message: e.message) stale_fallback end diff --git a/test/services/listing_company_codes_resolver_test.rb b/test/services/listing_company_codes_resolver_test.rb index c048477adc..f6c519dd87 100644 --- a/test/services/listing_company_codes_resolver_test.rb +++ b/test/services/listing_company_codes_resolver_test.rb @@ -109,6 +109,32 @@ def test_connection_refused_uses_stale_fallback 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) From 4f47d0ab977b9718be26a261fe3a9b2e35ecc257 Mon Sep 17 00:00:00 2001 From: oleghasjanov Date: Fri, 10 Apr 2026 14:23:21 +0300 Subject: [PATCH 7/7] refactor --- .gitignore | 2 +- .../api/v1/registrant/domains_controller.rb | 18 ++---- app/models/domain.rb | 20 ++++++- .../listing_company_codes_resolver.rb | 58 +++++++++++-------- e | 0 .../api/v1/registrant/domains_test.rb | 35 +++++------ 6 files changed, 73 insertions(+), 60 deletions(-) delete mode 100644 e diff --git a/.gitignore b/.gitignore index b14a55b82c..dd73451a26 100644 --- a/.gitignore +++ b/.gitignore @@ -35,4 +35,4 @@ CLAUDE.md AGENT.md .claude/ .memory-bank -own \ No newline at end of file +own/ diff --git a/app/controllers/api/v1/registrant/domains_controller.rb b/app/controllers/api/v1/registrant/domains_controller.rb index 4375db869e..ad1c738a48 100644 --- a/app/controllers/api/v1/registrant/domains_controller.rb +++ b/app/controllers/api/v1/registrant/domains_controller.rb @@ -24,15 +24,18 @@ def index end company_codes = listing_company_codes - admin = listing_admin_flag(company_codes) - domains = Domain.listing_user_domains(current_registrant_user, company_codes, admin: admin) + 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 - total = Domain.listing_user_domains_count(current_registrant_user, company_codes) render json: { total: total, count: domains.count, domains: serialized_domains } end @@ -60,15 +63,6 @@ def listing_company_codes ListingCompanyCodesResolver.new(current_registrant_user).call end - def listing_admin_flag(company_codes) - if params[:tech] == 'init' - total = Domain.listing_user_domains_count(current_registrant_user, company_codes) - return total >= LIMIT_DOMAIN_TOTAL - end - - params[:tech] != 'true' - end - def current_user_domains_total_count current_registrant_user.domains.count rescue CompanyRegister::NotAvailableError diff --git a/app/models/domain.rb b/app/models/domain.rb index 23be63f533..42adb47808 100644 --- a/app/models/domain.rb +++ b/app/models/domain.rb @@ -293,9 +293,7 @@ def listing_user_domains(registrant_user, company_codes, admin: false) registrant_user_direct_domains(registrant_user) end - if company_codes.blank? - return direct - 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) @@ -312,6 +310,22 @@ 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 index 9cd90e6878..63b6c36c35 100644 --- a/app/services/listing_company_codes_resolver.rb +++ b/app/services/listing_company_codes_resolver.rb @@ -1,6 +1,7 @@ require 'net/http' require 'openssl' require 'socket' +require 'httpi' class ListingCompanyCodesResolver CACHE_VERSION = 'v1' @@ -10,21 +11,19 @@ class ListingCompanyCodesResolver # 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. - # We treat all of them as "business registry is unavailable" and fall back to stale cache. - def self.network_error_classes - classes = [ - Net::OpenTimeout, - Net::ReadTimeout, - Errno::ECONNREFUSED, - Errno::EHOSTUNREACH, - Errno::ENETUNREACH, - Errno::ETIMEDOUT, - SocketError, - OpenSSL::SSL::SSLError, - ] - classes << HTTPI::Error if defined?(HTTPI::Error) - classes - end + 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 @@ -34,7 +33,7 @@ def initialize(user, cache: Rails.cache, company_register: CompanyRegister::Clie end def call - return [] if @user.ident.include?('-') + return [] if user.ident.include?('-') fetch_with_stale_fallback end @@ -56,21 +55,21 @@ def fetch_with_stale_fallback rescue CompanyRegister::SOAPFaultError log(:error, 'soap_fault_direct_only') [] - rescue *self.class.network_error_classes => e + 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 = 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) + cached_stale = cache.read(stale_key) if cached_stale log(:warn, 'stale_fallback') cached_stale @@ -80,9 +79,12 @@ def stale_fallback 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) - ttl = cache_ttl - @cache.write(stale_key, codes, expires_in: ttl + STALE_GRACE_PERIOD) + cache.write(stale_key, codes, expires_in: cache_ttl + STALE_GRACE_PERIOD) rescue StandardError => e log(:warn, 'cache_write_failed', error: e.message) end @@ -98,10 +100,16 @@ def cache_ttl end def stale_key - "registrant/listing_company_codes_stale/#{CACHE_VERSION}/#{@user.id}" + "registrant/listing_company_codes_stale/#{CACHE_VERSION}/#{user.id}" end def log(level, outcome, extra = {}) - @logger.send(level, { user_id: @user.id, outcome: outcome }.merge(extra).to_json) + 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/e b/e deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/test/integration/api/v1/registrant/domains_test.rb b/test/integration/api/v1/registrant/domains_test.rb index f8aa5fce2c..16f27cef33 100644 --- a/test/integration/api/v1/registrant/domains_test.rb +++ b/test/integration/api/v1/registrant/domains_test.rb @@ -2,8 +2,6 @@ require 'auth_token/auth_token_creator' class RegistrantApiV1DomainsTest < ActionDispatch::IntegrationTest - CompanyStub = Struct.new(:registration_number, :company_name) - setup do Rails.cache.clear @user = users(:registrant) @@ -13,13 +11,13 @@ 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['count'], response_json['total'] - assert response_json['total'] > 0 + assert_equal 4, response_json['total'] + assert_equal 4, response_json['count'] end def test_get_default_counts_of_direct_domains @@ -32,12 +30,11 @@ def test_get_default_counts_of_direct_domains assert_response :ok response_json = JSON.parse(response.body) - assert response_json['total'] > 0 - assert response_json['count'] > 0 + assert_equal 4, response_json['total'] + assert_equal 4, response_json['count'] end def test_outage_with_stale_cache_returns_company_linked_domains - # Pre-populate stale cache with acme_ltd's registration number stale_key = "registrant/listing_company_codes_stale/v1/#{@user.id}" Rails.cache.write(stale_key, %w[1234567], expires_in: 1.hour) @@ -64,7 +61,7 @@ def test_outage_no_cache_returns_direct_domains_only assert_response :ok response_json = JSON.parse(response.body) - assert response_json['total'] > 0 + assert_equal 4, response_json['total'] domain_names = response_json['domains'].map { |d| d['name'] } assert_includes domain_names, 'shop.test' end @@ -79,7 +76,7 @@ def test_soap_fault_returns_direct_domains assert_response :ok response_json = JSON.parse(response.body) - assert response_json['total'] > 0 + assert_equal 4, response_json['total'] domain_names = response_json['domains'].map { |d| d['name'] } assert_includes domain_names, 'shop.test' end @@ -94,24 +91,24 @@ def test_totals_match_in_degrade_mode assert_response :ok response_json = JSON.parse(response.body) - assert response_json['total'] >= response_json['count'] + assert_equal response_json['count'], response_json['total'] end def test_resolver_called_once_per_request call_count = 0 - original_new = ListingCompanyCodesResolver.method(:new) - - ListingCompanyCodesResolver.define_singleton_method(:new) do |*args, **kwargs| + counting_factory = lambda do |user| call_count += 1 - original_new.call(*args, **kwargs) + resolver = Object.new + resolver.define_singleton_method(:call) { [] } + resolver end - get api_v1_registrant_domains_path + "?tech=init", as: :json, - headers: { 'HTTP_AUTHORIZATION' => auth_token } + 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 - ensure - ListingCompanyCodesResolver.define_singleton_method(:new, original_new) end def test_json_shape_preserved