Repository: MushroomObserver/mushroom-observer
Pull request #2982: InatImportJobTest improvements
Author: login: JoeCohen
Labels: name: tests
description: includes CI
name: iNat
description: interaction with iNat
Created: 2025-05-10T23:23:38Z | Merged: 2025-05-14T11:17:46Z

Refactors `InatImportobTest`, including:
- Moves stubs to separate file
- Gets rid of tracking and deletion of stubs
- Adds and uses ivars to reduce JSON parsing of (mock) iNat response



Diff:
diff --git a/app/helpers/link_helper.rb b/app/helpers/link_helper.rb
index 52451a03f6..d28b518a21 100644
--- a/app/helpers/link_helper.rb
+++ b/app/helpers/link_helper.rb
@@ -140,6 +140,20 @@ def link_icon(type, **args)
     tag.span(text, **args)
   end
 
+  def external_link(link)
+    case link.external_site.name
+    when "iNaturalist"
+      concat(
+        link_to(
+          "iNat ##{link.url.sub(link.external_site.base_url, "")}", link.url
+        )
+      )
+    else
+      concat(link_to(:on_site.t(site: link.external_site.name), link.url))
+      concat(tag.small(" #{link.created_at.web_date}"))
+    end
+  end
+
   # NOTE: Specific to glyphicons
   LINK_ICON_INDEX = {
     edit: "edit",
diff --git a/app/jobs/inat_import_job.rb b/app/jobs/inat_import_job.rb
index e2bad13d34..e75927348a 100644
--- a/app/jobs/inat_import_job.rb
+++ b/app/jobs/inat_import_job.rb
@@ -42,6 +42,7 @@ def create_ivars(inat_import)
     )
     @user = @inat_import.user
     @user_api_key = APIKey.find_by(user: @user, notes: MO_API_KEY_NOTES).key
+    @external_site = ExternalSite.find_by(name: "iNaturalist")
   end
 
   # https://www.inaturalist.org/pages/api+reference#authorization_code_flow
@@ -180,6 +181,7 @@ def import_one_result(result)
     return unless @inat_obs.importable?
 
     create_observation
+    add_external_link
     add_inat_images(@inat_obs[:observation_photos])
     update_names_and_proposals
     add_inat_sequences
@@ -219,8 +221,7 @@ def new_obs_params
       specimen: @inat_obs.specimen?,
       text_name: Name.find(name_id).text_name,
       notes: @inat_obs.notes,
-      source: @inat_obs.source,
-      inat_id: @inat_obs[:id] }
+      source: @inat_obs.source }
   end
 
   # NOTE: 1. iNat users seem to add a prov name only if there's a sequence.
@@ -244,6 +245,15 @@ def need_new_prov_name?(prov_name)
     prov_name.blank? || Name.where(text_name: prov_name).none?
   end
 
+  def add_external_link
+    ExternalLink.create(
+      user: @user,
+      observation: @observation,
+      external_site: @external_site,
+      url: "#{@external_site.base_url}#{@inat_obs[:id]}"
+    )
+  end
+
   def add_provisional_name(prov_name)
     params = { method: :post, action: :name,
                api_key: @user_api_key,
@@ -385,9 +395,9 @@ def update_inat_observation
   end
 
   def update_mushroom_observer_url_field
-    update_inat_observation_field(observation_id: @observation.inat_id,
+    update_inat_observation_field(observation_id: @inat_obs[:id],
                                   field_id: 5005,
-                                  value: "#{MO.http_domain}/#{@observation.id}")
+                                  value: "#{MO.http_domain}/#{@inat_obs[:id]}")
   end
 
   def update_inat_observation_field(observation_id:, field_id:, value:)
diff --git a/app/views/controllers/observations/show/_external_links.html.erb b/app/views/controllers/observations/show/_external_links.html.erb
index 7c9d73f0c4..3b69ffa2cd 100644
--- a/app/views/controllers/observations/show/_external_links.html.erb
+++ b/app/views/controllers/observations/show/_external_links.html.erb
@@ -21,9 +21,7 @@ tag.div(
       tag.ul(class: "tight-list") do
         obs.external_links.sort_by(&:site_name).map do |link|
           tag.li(id: "external_link_#{link.id}") do
-            concat(link_to(:on_site.t(site: link.external_site.name),
-                            link.url))
-            concat(tag.small(" #{link.created_at.web_date}"))
+            (external_link(link))
             concat(
               [
                 " [",
diff --git a/test/jobs/inat_import_job_test.rb b/test/jobs/inat_import_job_test.rb
index 8ce14bb0b5..cf2787df4a 100644
--- a/test/jobs/inat_import_job_test.rb
+++ b/test/jobs/inat_import_job_test.rb
@@ -1,37 +1,25 @@
 # frozen_string_literal: true
 
 require("test_helper")
+require_relative("inat_import_job_test_doubles")
+
 class InatImportJobTest < ActiveJob::TestCase
-  # Prevent stubs from persisting between test methods because
-  # the same request (/users/me) needs diffferent responses
+  include InatImportJobTestDoubles
+
   def setup
     @user = users(:inat_importer)
-    @stubs = []
     directory_path = Rails.public_path.join("test_images/orig")
     FileUtils.mkdir_p(directory_path) unless Dir.exist?(directory_path)
-  end
-
-  def add_stub(stub)
-    @stubs << stub
-    stub
-  end
-
-  def teardown
-    @stubs.each do |stub|
-      WebMock::StubRegistry.instance.remove_request_stub(stub)
-    end
+    @external_link_base_url = ExternalSite.find_by(name: "iNaturalist").base_url
   end
 
   # Had 1 identification, 0 photos, 0 observation_fields
   def test_import_job_basic_obs
-    file_name = "calostoma_lutescens"
-    mock_inat_response = File.read("test/inat/#{file_name}.txt")
-
-    inat_import = create_inat_import(inat_response: mock_inat_response)
+    create_ivars_from_filename("calostoma_lutescens")
     # You can import only your own observations.
     # So adjust importing user's inat_username to be the Inat login
     # of the iNat user who made the iNat observation
-    @user.update(inat_username: inat_import.inat_username)
+    @user.update(inat_username: @inat_import.inat_username)
 
     # Add objects which are not included in fixtures
     name = Name.create(
@@ -46,15 +34,14 @@ def test_import_job_basic_obs
                           north: 36.043571, south: 35.561849,
                           east: -83.253046, west: -83.794123)
 
-    stub_inat_interactions(inat_import: inat_import,
-                           mock_inat_response: mock_inat_response)
+    stub_inat_interactions
 
     QueuedEmail.queue = true
     before_emails_to_user = QueuedEmail.where(to_user: @user).count
 
     assert_difference("Observation.count", 1,
                       "Failed to create observation") do
-      InatImportJob.perform_now(inat_import)
+      InatImportJob.perform_now(@inat_import)
     end
 
     obs = Observation.last
@@ -69,18 +56,15 @@ def test_import_job_basic_obs
       "Proposed consensus Name reason should be #{:naming_reason_label_2.l}"
     )
     proposed_name_notes = proposed_name[:reasons][used_references]
-    suggesting_inat_user = JSON.parse(mock_inat_response)["results"].
-                           first["identifications"].
-                           first["user"]["login"]
+    suggesting_inat_user = @parsed_results.
+                           first[:identifications].first[:user][:login]
     assert_match(:naming_reason_suggested_on_inat.l(user: suggesting_inat_user),
                  proposed_name_notes)
-    suggestion_date = JSON.parse(mock_inat_response)["results"].
-                      first["identifications"].
-                      first["created_at"]
+    suggestion_date = @parsed_results.
+                      first[:identifications].first[:created_at]
     assert_match(suggestion_date, proposed_name_notes)
 
     assert_not(obs.specimen, "Obs should not have a specimen")
-    assert_equal(0, obs.images.length, "Obs should not have images")
     assert_match(/Observation Fields: none/, obs.comments.first.comment,
                  "Missing 'none' for Observation Fields")
 
@@ -94,18 +78,19 @@ def test_import_job_basic_obs
   # Prove (inter alia) that the MO Naming.user differs from the importing user
   # when the iNat user who made the 1st iNat id is another MO user
   def test_import_job_inat_id_suggested_by_another_by_mo_user
-    file_name = "calostoma_lutescens"
-    mock_inat_response = File.read("test/inat/#{file_name}.txt")
+    create_ivars_from_filename("calostoma_lutescens")
 
-    # Tweak mock response to make mary the person who made the 1st iNat id
     user = users(:mary)
     assert(user.inat_username.present?,
            "Test needs user fixture with an inat_username")
-    parsed_response = JSON.parse(mock_inat_response)
-    parsed_response["results"].first["identifications"].first["user"]["login"] =
+
+    # re-do the ivars to make mary the iNat user who made the 1st iNat id
+    parsed_response = JSON.parse(@mock_inat_response, symbolize_names: true)
+    parsed_response[:results].first[:identifications].first[:user][:login] =
       user.inat_username
-    mock_inat_response = JSON.generate(parsed_response)
-    inat_import = create_inat_import(inat_response: mock_inat_response)
+    @mock_inat_response = JSON.generate(parsed_response)
+    @parsed_results = parsed_response[:results]
+    @inat_import = create_inat_import
 
     # Add objects which are not included in fixtures
     Name.create(
@@ -120,12 +105,11 @@ def test_import_job_inat_id_suggested_by_another_by_mo_user
                     north: 36.043571, south: 35.561849,
                     east: -83.253046, west: -83.794123)
 
-    stub_inat_interactions(inat_import: inat_import,
-                           mock_inat_response: mock_inat_response)
+    stub_inat_interactions
 
     assert_difference("Observation.count", 1,
                       "Failed to create observation") do
-      InatImportJob.perform_now(inat_import)
+      InatImportJob.perform_now(@inat_import)
     end
 
     obs = Observation.last
@@ -138,22 +122,18 @@ def test_import_job_inat_id_suggested_by_another_by_mo_user
       "Proposed Name reason should be #{:naming_reason_label_2.l}"
     )
     proposed_name_notes = proposed_name[:reasons][used_references]
-    suggesting_inat_user = JSON.parse(mock_inat_response)["results"].
-                           first["identifications"].
-                           first["user"]["login"]
+    suggesting_inat_user = @parsed_results.
+                           first[:identifications].first[:user][:login]
     assert_match(:naming_reason_suggested_on_inat.l(user: suggesting_inat_user),
                  proposed_name_notes)
-    suggestion_date = JSON.parse(mock_inat_response)["results"].
-                      first["identifications"].
-                      first["created_at"]
+    suggestion_date = @parsed_results.
+                      first[:identifications].first[:created_at]
     assert_match(suggestion_date, proposed_name_notes)
   end
 
   # Had 1 photo, 1 identification, 0 observation_fields; 0 sequences
   def test_import_job_obs_with_one_photo
-    file_name = "evernia"
-    mock_inat_response = File.read("test/inat/#{file_name}.txt")
-    inat_import = create_inat_import(inat_response: mock_inat_response)
+    create_ivars_from_filename("evernia")
 
     # Add objects which are not included in fixtures
     name = Name.create(
@@ -168,26 +148,23 @@ def test_import_job_obs_with_one_photo
       east: -122.367, west: -122.431
     )
 
-    stub_inat_interactions(inat_import: inat_import,
-                           mock_inat_response: mock_inat_response)
+    stub_inat_interactions
 
     assert_difference("Observation.count", 1,
                       "Failed to create observation") do
-      InatImportJob.perform_now(inat_import)
+      InatImportJob.perform_now(@inat_import)
     end
 
     obs = Observation.last
     standard_assertions(obs: obs, name: name, loc: loc)
 
-    assert_equal(1, obs.images.length, "Obs should have 1 image")
-
-    inat_photo = JSON.parse(mock_inat_response)["results"].
-                 first["observation_photos"].first
+    inat_photo = @parsed_results.
+                 first[:observation_photos].first
     imported_img = obs.images.first
     assert_equal(@user, imported_img.user,
                  "Image should belong to importing user")
     assert_equal(
-      "iNat photo_id: #{inat_photo["photo_id"]}, uuid: #{inat_photo["uuid"]}",
+      "iNat photo_id: #{inat_photo[:photo_id]}, uuid: #{inat_photo[:uuid]}",
       imported_img.original_name,
       "Image original_name should be iNat photo_id and uuid"
     )
@@ -197,9 +174,7 @@ def test_import_job_obs_with_one_photo
 
   # Had 1 photo; 2 identifications, 1 not by user.
   def test_import_job_obs_with_many_namings
-    file_name = "tremella_mesenterica"
-    mock_inat_response = File.read("test/inat/#{file_name}.txt")
-    inat_import = create_inat_import(inat_response: mock_inat_response)
+    create_ivars_from_filename("tremella_mesenterica")
 
     name = Name.find_or_create_by(text_name: "Tremellales",
                                   author: "Fr.",
@@ -207,26 +182,23 @@ def test_import_job_obs_with_many_namings
                                   display_name: "Tremellales",
                                   rank: "Order",
                                   user: @user)
-    stub_inat_interactions(inat_import: inat_import,
-                           mock_inat_response: mock_inat_response)
+
+    stub_inat_interactions
 
     assert_difference("Observation.count", 1,
                       "Failed to create observation") do
-      InatImportJob.perform_now(inat_import)
+      InatImportJob.perform_now(@inat_import)
     end
 
     obs = Observation.last
     standard_assertions(obs: obs, name: name)
-    assert(obs.images.any?, "Obs should have images")
     assert(obs.sequences.none?)
   end
 
   # Had 2 photos, 6 identifications of 3 taxa, a different taxon,
   # 9 obs fields, including "DNA Barcode ITS", "Collection number", "Collector"
   def test_import_job_obs_with_sequence_and_multiple_ids
-    file_name = "lycoperdon"
-    mock_inat_response = File.read("test/inat/#{file_name}.txt")
-    inat_import = create_inat_import(inat_response: mock_inat_response)
+    create_ivars_from_filename("lycoperdon")
 
     name = Name.create(
       text_name: "Lycoperdon", author: "Pers.",
@@ -236,25 +208,23 @@ def test_import_job_obs_with_sequence_and_multiple_ids
       user: @user
     )
 
-    stub_inat_interactions(inat_import: inat_import,
-                           mock_inat_response: mock_inat_response)
+    stub_inat_interactions
 
     assert_difference("Observation.count", 1,
                       "Failed to create observation") do
-      InatImportJob.perform_now(inat_import)
+      InatImportJob.perform_now(@inat_import)
     end
 
     obs = Observation.last
     standard_assertions(obs: obs, name: name)
 
-    assert(obs.images.any?, "Obs should have images")
     assert(obs.sequences.one?, "Obs should have a sequence")
     assert_equal(@user, obs.sequences.first.user,
                  "Sequences should belong to the user who imported the obs")
 
-    ids = JSON.parse(mock_inat_response)["results"].first["identifications"]
+    ids = @parsed_results.first[:identifications]
     unique_suggested_taxon_names = ids.each_with_object([]) do |id, ary|
-      ary << id["taxon"]["name"]
+      ary << id[:taxon][:name]
     end
     unique_suggested_taxon_names.each do |taxon_name|
       assert_match(taxon_name, obs.comments.first.comment,
@@ -263,9 +233,7 @@ def test_import_job_obs_with_sequence_and_multiple_ids
   end
 
   def test_import_job_infra_specific_name
-    file_name = "i_obliquus_f_sterilis"
-    mock_inat_response = File.read("test/inat/#{file_name}.txt")
-    inat_import = create_inat_import(inat_response: mock_inat_response)
+    create_ivars_from_filename("i_obliquus_f_sterilis")
 
     # Add objects which are not included in fixtures
     name = Name.create(
@@ -278,24 +246,21 @@ def test_import_job_infra_specific_name
       user: @user
     )
 
-    stub_inat_interactions(inat_import: inat_import,
-                           mock_inat_response: mock_inat_response)
+    stub_inat_interactions
 
     assert_difference("Observation.count", 1,
                       "Failed to create observation") do
-      InatImportJob.perform_now(inat_import)
+      InatImportJob.perform_now(@inat_import)
     end
 
     obs = Observation.last
     standard_assertions(obs: obs, name: name)
-    assert_equal(1, obs.images.length, "Obs should have 1 image")
     assert(obs.sequences.none?)
   end
 
   def test_import_job_complex
-    file_name = "xeromphalina_campanella_complex"
-    mock_inat_response = File.read("test/inat/#{file_name}.txt")
-    inat_import = create_inat_import(inat_response: mock_inat_response)
+    create_ivars_from_filename("xeromphalina_campanella_complex")
+
     # Add objects which are not included in fixtures
     name = Name.create(
       text_name: "Xeromphalina campanella group", author: "",
@@ -305,17 +270,15 @@ def test_import_job_complex
       user: @user
     )
 
-    stub_inat_interactions(inat_import: inat_import,
-                           mock_inat_response: mock_inat_response)
+    stub_inat_interactions
 
     assert_difference("Observation.count", 1,
                       "Failed to create observation") do
-      InatImportJob.perform_now(inat_import)
+      InatImportJob.perform_now(@inat_import)
     end
 
     obs = Observation.last
     standard_assertions(obs: obs, name: name)
-    assert_equal(2, obs.images.length, "Obs should have 2 images")
     assert(obs.sequences.none?)
   end
 
@@ -325,9 +288,8 @@ def test_import_job_complex
   # 3 comments, everyone has MO account;
   # obs fields(include("Voucher Number(s)", "Voucher Specimen Taken"))
   def test_import_job_nemf_plischke
-    file_name = "arrhenia_sp_NY02"
-    mock_inat_response = File.read("test/inat/#{file_name}.txt")
-    inat_import = create_inat_import(inat_response: mock_inat_response)
+    create_ivars_from_filename("arrhenia_sp_NY02")
+
     name = Name.create(
       text_name: 'Arrhenia "sp-NY02"', author: "S.D. Russell crypt. temp.",
       search_name: 'Arrhenia "sp-NY02" S.D. Russell crypt. temp.',
@@ -336,18 +298,16 @@ def test_import_job_nemf_plischke
       user: @user
     )
 
-    stub_inat_interactions(inat_import: inat_import,
-                           mock_inat_response: mock_inat_response)
+    stub_inat_interactions
 
     assert_difference("Observation.count", 1,
                       "Failed to create observation") do
-      InatImportJob.perform_now(inat_import)
+      InatImportJob.perform_now(@inat_import)
     end
 
     obs = Observation.last
     standard_assertions(obs: obs, name: name)
 
-    assert(obs.images.any?, "Obs should have Images")
     assert(obs.sequences.one?, "Obs should have one Sequence")
     assert(obs.specimen, "Obs should show that a Specimen is available")
   end
@@ -358,17 +318,13 @@ def test_import_job_nemf_plischke
   def test_import_job_create_prov_name
     assert_nil(Name.find_by(text_name: 'Arrhenia "sp-NY02"'),
                "Test requires that MO not yet have provisional name")
+    create_ivars_from_filename("arrhenia_sp_NY02")
 
-    file_name = "arrhenia_sp_NY02"
-    mock_inat_response = File.read("test/inat/#{file_name}.txt")
-    inat_import = create_inat_import(inat_response: mock_inat_response)
-
-    stub_inat_interactions(inat_import: inat_import,
-                           mock_inat_response: mock_inat_response)
+    stub_inat_interactions
 
     assert_difference("Observation.count", 1,
                       "Failed to create observation") do
-      InatImportJob.perform_now(inat_import)
+      InatImportJob.perform_now(@inat_import)
     end
 
     obs = Observation.last
@@ -390,31 +346,26 @@ def test_import_job_create_prov_name
     )
     proposed_name_notes = proposed_name[:reasons][used_references]
     provisional_field =
-      JSON.parse(mock_inat_response)["results"].first["ofvs"].
-      find { |field| field["name"] == "Provisional Species Name" }
-    adding_inat_user = provisional_field["user"]["login"]
+      @parsed_results.first[:ofvs].
+      find { |field| field[:name] == "Provisional Species Name" }
+    adding_inat_user = provisional_field[:user][:login]
     assert_match(:naming_inat_provisional.l(user: adding_inat_user),
                  proposed_name_notes)
 
-    assert(obs.images.any?, "Obs should have images")
     assert(obs.sequences.one?, "Obs should have one sequence")
   end
 
   # Inat Provisional Species Name "Donadina PNW01" (no: quotes, sp. dash)
   def test_import_job_prov_name_pnw_style
-    file_name = "donadinia_PNW01"
-    mock_inat_response = File.read("test/inat/#{file_name}.txt")
-    inat_import = create_inat_import(inat_response: mock_inat_response)
-
     assert(Name.where(Name[:text_name] =~ /Donadinia/).none?,
            "Test requires that MO not yet have `Donadinia` Names")
 
-    stub_inat_interactions(inat_import: inat_import,
-                           mock_inat_response: mock_inat_response)
+    create_ivars_from_filename("donadinia_PNW01")
+    stub_inat_interactions
 
     assert_difference("Observation.count", 1,
                       "Failed to create observation") do
-      InatImportJob.perform_now(inat_import)
+      InatImportJob.perform_now(@inat_import)
     end
 
     obs = Observation.last
@@ -432,22 +383,17 @@ def test_import_job_prov_name_pnw_style
 
     standard_assertions(obs: obs, name: name)
 
-    assert(obs.images.any?, "Obs should have images")
     assert(obs.sequences.one?, "Obs should have one sequence")
   end
 
   # Inat Prov Species Name "Hygrocybe sp. 'conica-CA06'" (epithet single-quoted)
   def test_import_job_prov_name_ncbi_style
-    file_name = "hygrocybe_sp_conica-CA06_ncbi_style"
-    mock_inat_response = File.read("test/inat/#{file_name}.txt")
-    inat_import = create_inat_import(inat_response: mock_inat_response)
-
-    stub_inat_interactions(inat_import: inat_import,
-                           mock_inat_response: mock_inat_response)
+    create_ivars_from_filename("hygrocybe_sp_conica-CA06_ncbi_style")
+    stub_inat_interactions
 
     assert_difference("Observation.count", 1,
                       "Failed to create observation") do
-      InatImportJob.perform_now(inat_import)
+      InatImportJob.perform_now(@inat_import)
     end
 
     obs = Observation.last
@@ -457,74 +403,54 @@ def test_import_job_prov_name_ncbi_style
   end
 
   def test_import_plant
-    file_name = "ceanothus_cordulatus"
-    mock_inat_response = File.read("test/inat/#{file_name}.txt")
-    inat_import = create_inat_import(inat_response: mock_inat_response)
-    stub_inat_interactions(inat_import: inat_import,
-                           mock_inat_response: mock_inat_response)
+    create_ivars_from_filename("ceanothus_cordulatus")
+
+    stub_inat_interactions
 
     assert_no_difference("Observation.count", "Should not import Plantae") do
-      InatImportJob.perform_now(inat_import)
+      InatImportJob.perform_now(@inat_import)
     end
   end
 
   def test_import_zero_results
-    file_name = "zero_results"
-    mock_inat_response = File.read("test/inat/#{file_name}.txt")
-    inat_import = inat_imports(:rolf_inat_import)
-    inat_import.update(inat_ids: "123", token: "MockCode")
-    stub_inat_interactions(inat_import: inat_import,
-                           mock_inat_response: mock_inat_response)
+    create_ivars_from_filename("zero_results")
+    @inat_import.update(inat_ids: "123", token: "MockCode")
+    stub_inat_interactions
 
     assert_no_difference(
       "Observation.count",
       "Should import nothing if no iNat obss match the user's list of id's"
     ) do
-      InatImportJob.perform_now(inat_import)
+      InatImportJob.perform_now(@inat_import)
     end
   end
 
   def test_import_update_inat_username_if_job_succeeds
-    user = @user
-    assert_empty(user.inat_username,
-                 "Test needs user fixture without an iNat username")
-
-    file_name = "zero_results"
-    mock_inat_response = File.read("test/inat/#{file_name}.txt")
-    inat_import = InatImport.find_by(user: user)
-    inat_import.update(inat_ids: "123", token: "MockCode")
-    stub_inat_interactions(inat_import: inat_import,
-                           mock_inat_response: mock_inat_response)
-
-    InatImportJob.perform_now(inat_import)
-
-    assert_equal(inat_import.inat_username, @user.reload.inat_username,
-                 "Failed to update user's inat_username")
+    create_ivars_from_filename("zero_results")
+    # simulate user entering new inat_username in iNat import form
+    @inat_import.update(inat_username: "updatedInatUsername",
+                        inat_ids: "123", token: "MockCode")
+    stub_inat_interactions
+
+    assert_changes("@user.inat_username", to: "updatedInatUsername") do
+      InatImportJob.perform_now(@inat_import)
+    end
   end
 
   def test_import_multiple
-    file_name = "listed_ids"
-    mock_inat_response = File.read("test/inat/#{file_name}.txt")
-    inat_import = InatImport.create(user: @user,
-                                    inat_ids: "231104466,195434438",
-                                    token: "MockCode",
-                                    inat_username: "anything")
-
-    # prove that mock was constructed properly
-    json = JSON.parse(mock_inat_response)
-    assert_equal(2, json["total_results"])
-    assert_equal(1, json["page"])
-    assert_equal(30, json["per_page"])
-    # mock is sorted by id, asc
-    assert_equal(195_434_438, json["results"].first["id"])
-    assert_equal(231_104_466, json["results"].second["id"])
-
-    stub_inat_interactions(inat_import: inat_import,
-                           mock_inat_response: mock_inat_response)
+    create_ivars_from_filename("listed_ids")
+
+    # override ivar because this test wants to import multiple observations
+    @inat_import = InatImport.create(user: @user,
+                                     inat_ids: "231104466,195434438",
+                                     token: "MockCode",
+                                     inat_username: "anything")
+
+    stub_inat_interactions
 
     assert_difference("Observation.count", 2,
                       "Failed to create multiple observations") do
-      InatImportJob.perform_now(inat_import)
+      InatImportJob.perform_now(@inat_import)
     end
   end
 
@@ -533,109 +459,102 @@ def test_import_multiple
   def test_import_all
     file_name = "import_all"
     mock_inat_response = File.read("test/inat/#{file_name}.txt")
-    inat_import = InatImport.create(user: @user,
-                                    inat_ids: "",
-                                    import_all: true,
-                                    token: "MockCode",
-                                    inat_username: "anything")
+    @inat_import = InatImport.create(user: @user,
+                                     inat_ids: "",
+                                     import_all: true,
+                                     token: "MockCode",
+                                     inat_username: "anything")
     # limit it to one page to avoid complications of stubbing multiple
     # inat api requests with multiple files
-    mock_inat_response = limited_to_first_page(mock_inat_response)
+    @mock_inat_response = limited_to_first_page(mock_inat_response)
+    @parsed_results =
+      JSON.parse(@mock_inat_response, symbolize_names: true)[:results]
 
-    stub_inat_interactions(inat_import: inat_import,
-                           mock_inat_response: mock_inat_response)
+    stub_inat_interactions
 
     assert_difference("Observation.count", 2,
                       "Failed to create multiple observations") do
-      InatImportJob.perform_now(inat_import)
+      InatImportJob.perform_now(@inat_import)
     end
   end
 
-  def test_oauth_failure
-    file_name = "calostoma_lutescens"
-    mock_inat_response = File.read("test/inat/#{file_name}.txt")
-    inat_import = create_inat_import(inat_response: mock_inat_response)
-
-    oauth_return = { status: 401, body: "Unauthorized",
-                     headers: { "Content-Type" => "application/json" } }
-    stub_oauth_token_request(oauth_return: oauth_return)
-
-    InatImportJob.perform_now(inat_import)
-
-    assert_match(/401 Unauthorized/, inat_import.response_errors,
-                 "Failed to report OAuth failure")
-  end
-
-  def test_jwt_failure
-    file_name = "calostoma_lutescens"
-    mock_inat_response = File.read("test/inat/#{file_name}.txt")
-    inat_import = create_inat_import(inat_response: mock_inat_response)
-
-    stub_oauth_token_request
-    jwt_return = { status: 401, body: "Unauthorized",
-                   headers: { "Content-Type" => "application/json" } }
-    stub_jwt_request(jwt_return: jwt_return)
-
-    InatImportJob.perform_now(inat_import)
-
-    assert_match(/401 Unauthorized/, inat_import.response_errors,
-                 "Failed to report OAuth failure")
-  end
-
   def test_import_anothers_observation
-    file_name = "calostoma_lutescens"
-    mock_inat_response = File.read("test/inat/#{file_name}.txt")
-    inat_import = create_inat_import(inat_response: mock_inat_response)
+    create_ivars_from_filename("calostoma_lutescens")
 
-    stub_inat_interactions(inat_import: inat_import,
-                           mock_inat_response: mock_inat_response,
-                           login: "another user")
+    stub_inat_interactions(login: "another user")
 
     assert_difference("Observation.count", 0,
                       "It should not import another user's observation") do
-      InatImportJob.perform_now(inat_import)
+      InatImportJob.perform_now(@inat_import)
     end
 
     assert_match(
-      :inat_wrong_user.l, inat_import.response_errors,
+      :inat_wrong_user.l, @inat_import.response_errors,
       "It should warn if a user tries to import another's iNat obs"
     )
   end
 
   def test_super_importer_anothers_observation
-    file_name = "calostoma_lutescens"
-    mock_inat_response = File.read("test/inat/#{file_name}.txt")
-    user = users(:dick)
-    assert(InatImport.super_importers.include?(user),
+    @user = users(:dick)
+    assert(InatImport.super_importers.include?(@user),
            "Test needs User fixture that's SuperImporter")
 
-    inat_import = create_inat_import(user: user,
-                                     inat_response: mock_inat_response)
-    stub_inat_interactions(inat_import: inat_import,
-                           mock_inat_response: mock_inat_response,
-                           superimporter: true)
+    create_ivars_from_filename("calostoma_lutescens")
+    stub_inat_interactions(superimporter: true)
 
     assert_difference(
       "Observation.count", 1,
       "'super_importer' failed to import another user's observation"
     ) do
-      InatImportJob.perform_now(inat_import)
+      InatImportJob.perform_now(@inat_import)
     end
 
-    assert_empty(inat_import.response_errors, "There should be no errors")
+    assert_empty(@inat_import.response_errors, "There should be no errors")
+  end
+
+  def test_oauth_failure
+    create_ivars_from_filename("calostoma_lutescens")
+
+    oauth_return = { status: 401, body: "Unauthorized",
+                     headers: { "Content-Type" => "application/json" } }
+    stub_oauth_token_request(oauth_return: oauth_return)
+
+    InatImportJob.perform_now(@inat_import)
+
+    assert_match(/401 Unauthorized/, @inat_import.response_errors,
+                 "Failed to report OAuth failure")
+  end
+
+  def test_jwt_failure
+    create_ivars_from_filename("calostoma_lutescens")
+
+    stub_oauth_token_request
+    jwt_return = { status: 401, body: "Unauthorized",
+                   headers: { "Content-Type" => "application/json" } }
+    stub_jwt_request(jwt_return: jwt_return)
+
+    InatImportJob.perform_now(@inat_import)
+
+    assert_match(/401 Unauthorized/, @inat_import.response_errors,
+                 "Failed to report OAuth failure")
   end
 
   ########## Utilities
 
+  def create_ivars_from_filename(filename)
+    @mock_inat_response = File.read("test/inat/#{filename}.txt")
+    @parsed_results =
+      JSON.parse(@mock_inat_response, symbolize_names: true)[:results]
+    @inat_import = create_inat_import
+  end
+
   # The InatImport object which is created in InatImportController#create
   # and recovered in InatImportController#authorization_response
-  def create_inat_import(user: @user,
-                         inat_response: mock_inat_response)
+  def create_inat_import(user: @user)
     InatImport.create(
       user: user, token: "MockCode",
-      inat_ids: JSON.parse(inat_response)["results"].first["id"],
-      inat_username: JSON.
-        parse(inat_response)["results"].first["user"]["login"],
+      inat_ids: @parsed_results.first&.dig(:id),
+      inat_username: @parsed_results.first&.dig(:user, :login),
       response_errors: ""
     )
   end
@@ -647,6 +566,10 @@ def standard_assertions(obs:, user: @user, name: nil, loc: nil)
     assert_equal("mo_inat_import", obs.source)
     assert_equal(loc, obs.location) if loc
 
+    photo_count = @parsed_results.first[:observation_photos].length
+    assert_equal(photo_count, obs.images.length,
+                 "Observation should have #{photo_count} image(s)")
+
     assert_equal(1, obs.namings.length,
                  "iNatImport should create exactly one Naming")
     obs.namings.each do |naming|
@@ -676,6 +599,13 @@ def standard_assertions(obs:, user: @user, name: nil, loc: nil)
            find_by(observation_id: obs.id, user_id: user.id)
     assert(view.present?, "Failed to create ObservationView")
 
+    external_link = obs.external_links.first
+    assert_equal(
+      "#{@external_link_base_url}#{@parsed_results.first[:id]}",
+      external_link&.url,
+      "MO Observation should have ExternalLink to iNat observation"
+    )
+
     assert(obs.comments.any?, "Imported iNat should have >= 1 Comment")
     obs_comments =
       Comment.where(target_type: "Observation", target_id: obs.id)
@@ -717,187 +647,4 @@ def limited_to_first_page(mock_search_result)
     ms_hash["total_results"] = ms_hash["results"].length
     JSON.generate(ms_hash)
   end
-
-  # -------- Test doubles
-
-  # url for iNat authorization and authentication requests
-  SITE = InatImportsController::SITE
-  # MO url called by iNat after iNat user authorizes MO to access their data
-  REDIRECT_URI = InatImportsController::REDIRECT_URI
-  # iNat API url
-  API_BASE = InatImportsController::API_BASE
-  # Value of the iNat API "iconic_taxa" query param
-  # That param is included in iNat API requests in order to limit the results
-  # to Fungi and slime molds (with Protozoa as a proxy for slime molds)
-  ICONIC_TAXA = InatImportJob::ICONIC_TAXA
-  # base url for iNat CC-licensed and public domain photos
-  LICENSED_PHOTO_BASE = "https://inaturalist-open-data.s3.amazonaws.com/photos"
-  # base url for iNat unlicensed photos
-  UNLICENSED_PHOTO_BASE = "https://static.inaturalist.org/photos"
-
-  def stub_inat_interactions(
-    inat_import:, mock_inat_response:, id_above: 0,
-    login: inat_import.inat_username, superimporter: false
-  )
-    stub_token_requests
-    stub_check_username_match(login)
-    stub_inat_observation_request(inat_import: inat_import,
-                                  mock_inat_response: mock_inat_response,
-                                  id_above: id_above,
-                                  superimporter: superimporter)
-    stub_inat_photo_requests(mock_inat_response)
-    stub_modify_inat_observations(mock_inat_response)
-  end
-
-  def stub_token_requests
-    stub_oauth_token_request
-    # must trade oauth access token for a JWT in order to use iNat API v1
-    stub_jwt_request
-  end
-
-  # stub exchanging iNat code for oauth token
-  # https://www.inaturalist.org/pages/api+reference#authorization_code_flow
-  def stub_oauth_token_request(oauth_return: {
-    status: 200,
-    body: { access_token: "MockAccessToken" }.to_json,
-    headers: {}
-  })
-    add_stub(stub_request(:post, "#{SITE}/oauth/token").
-      with(
-        body: { "client_id" => Rails.application.credentials.inat.id,
-                "client_secret" => Rails.application.credentials.inat.secret,
-                "code" => "MockCode",
-                "grant_type" => "authorization_code",
-                "redirect_uri" => REDIRECT_URI }
-      ).
-      to_return(oauth_return))
-  end
-
-  def stub_jwt_request(jwt_return:
-    { status: 200,
-      body: { api_token: "MockJWT" }.to_json,
-      headers: {} })
-    add_stub(stub_request(:get, "#{SITE}/users/api_token").
-      with(
-        headers: {
-          "Accept" => "application/json",
-          "Accept-Encoding" => "gzip;q=1.0,deflate;q=0.6,identity;q=0.3",
-          "Authorization" => "Bearer MockAccessToken",
-          "Host" => "www.inaturalist.org"
-        }
-      ).
-      to_return(jwt_return))
-  end
-
-  def stub_check_username_match(login)
-    add_stub(stub_request(:get, "#{API_BASE}/users/me").
-      with(
-        headers: {
-          "Accept" => "application/json",
-          "Accept-Encoding" => "gzip;q=1.0,deflate;q=0.6,identity;q=0.3",
-          "Authorization" => "Bearer MockJWT",
-          "Content-Type" => "application/json",
-          "Host" => "api.inaturalist.org"
-        }
-      ).
-      to_return(status: 200,
-                body: "{\"results\":[{\"login\":\"#{login}\"}]}",
-                headers: {}))
-  end
-
-  def stub_inat_observation_request(inat_import:,
-                                    mock_inat_response:, id_above: 0,
-                                    superimporter: false)
-    query_args = {
-      iconic_taxa: ICONIC_TAXA,
-      id: inat_import.inat_ids,
-      id_above: id_above,
-      per_page: 200,
-      only_id: false,
-      order: "asc",
-      order_by: "id",
-      without_field: "Mushroom Observer URL",
-      # Limit results to observations by the user, unless superimporter
-      user_login: (inat_import.inat_username unless superimporter)
-    }
-
-    add_stub(stub_request(:get,
-                          "#{API_BASE}/observations?#{query_args.to_query}").
-      with(headers:
-    { "Accept" => "application/json",
-      "Accept-Encoding" => "gzip;q=1.0,deflate;q=0.6,identity;q=0.3",
-      "Authorization" => "Bearer MockJWT",
-      "Host" => "api.inaturalist.org" }).
-      to_return(body: mock_inat_response))
-  end
-
-  # Stub MO requests which GET iNat original photos
-  def stub_inat_photo_requests(mock_inat_response)
-    JSON.parse(mock_inat_response)["results"].each do |result|
-      result["observation_photos"].each do |photo|
-        url = photo["photo"]["url"].sub("square", "original")
-        stub_request(
-          :get,
-          url
-        ).
-          with(
-            headers: {
-              "Accept" => "image/*",
-              "Accept-Encoding" => "gzip;q=1.0,deflate;q=0.6,identity;q=0.3",
-              "Host" => URI(url).host,
-              "User-Agent" => "Ruby"
-            }
-          ).
-          # Return the same photo for all stubs, which is
-          # sufficient for testing purposes.
-          to_return(status: 200, body: image_for_stubs, headers: {})
-      end
-    end
-  end
-
-  def image_for_stubs
-    @image_for_stubs ||= Rails.root.join("test/images/test_image.jpg").read
-  end
-
-  def stub_modify_inat_observations(mock_inat_response)
-    stub_add_observation_fields
-    stub_update_descriptions(mock_inat_response)
-  end
-
-  def stub_add_observation_fields
-    add_stub(stub_request(:post, "#{API_BASE}/observation_field_values").
-      to_return(status: 200, body: "".to_json,
-                headers: { "Content-Type" => "application/json" }))
-  end
-
-  def stub_update_descriptions(mock_inat_response)
-    date = Time.zone.today.strftime(MO.web_date_format)
-    observations = JSON.parse(mock_inat_response)["results"]
-    observations.each do |obs|
-      updated_description =
-        "Imported by Mushroom Observer #{date}"
-      if obs["description"].present?
-        updated_description.prepend("#{obs["description"]}\n\n")
-      end
-
-      body = {
-        observation: {
-          description: updated_description,
-          # This param needed in order to retain the photos
-          # https://forum.inaturalist.org/t/api-modify-observation-description/55665
-          # https://www.inaturalist.org/pages/api+reference#put-observations-id
-          ignore_photos: 1
-        }
-      }
-      headers = { authorization: "Bearer MockJWT",
-                  content_type: "application/json", accept: "application/json" }
-      add_stub(
-        stub_request(
-          :put, "#{API_BASE}/observations/#{obs["id"]}?ignore_photos=1"
-        ).
-        with(body: body.to_json, headers: headers).
-        to_return(status: 200, body: "".to_json, headers: {})
-      )
-    end
-  end
 end
diff --git a/test/jobs/inat_import_job_test_doubles.rb b/test/jobs/inat_import_job_test_doubles.rb
new file mode 100644
index 0000000000..b8dcfb4a2f
--- /dev/null
+++ b/test/jobs/inat_import_job_test_doubles.rb
@@ -0,0 +1,165 @@
+# frozen_string_literal: true
+
+module InatImportJobTestDoubles
+  # url for iNat authorization and authentication requests
+  SITE = InatImportsController::SITE
+  # MO url called by iNat after iNat user authorizes MO to access their data
+  REDIRECT_URI = InatImportsController::REDIRECT_URI
+  # iNat API url
+  API_BASE = InatImportsController::API_BASE
+  # Value of the iNat API "iconic_taxa" query param
+  ICONIC_TAXA = InatImportJob::ICONIC_TAXA
+  # base url for iNat CC-licensed and public domain photos
+  LICENSED_PHOTO_BASE = "https://inaturalist-open-data.s3.amazonaws.com/photos"
+  # base url for iNat unlicensed photos
+  UNLICENSED_PHOTO_BASE = "https://static.inaturalist.org/photos"
+
+  def stub_inat_interactions(
+    id_above: 0,
+    login: @inat_import.inat_username,
+    superimporter: false
+  )
+    stub_token_requests
+    stub_check_username_match(login)
+    stub_inat_observation_request(id_above: id_above,
+                                  superimporter: superimporter)
+    stub_inat_photo_requests
+    stub_modify_inat_observations
+  end
+
+  def stub_token_requests
+    stub_oauth_token_request
+    stub_jwt_request
+  end
+
+  def stub_oauth_token_request(oauth_return: {
+    status: 200,
+    body: { access_token: "MockAccessToken" }.to_json,
+    headers: {}
+  })
+    stub_request(:post, "#{SITE}/oauth/token").
+      with(
+        body: { "client_id" => Rails.application.credentials.inat.id,
+                "client_secret" => Rails.application.credentials.inat.secret,
+                "code" => "MockCode",
+                "grant_type" => "authorization_code",
+                "redirect_uri" => REDIRECT_URI }
+      ).
+      to_return(oauth_return)
+  end
+
+  def stub_jwt_request(jwt_return:
+    { status: 200,
+      body: { api_token: "MockJWT" }.to_json,
+      headers: {} })
+    stub_request(:get, "#{SITE}/users/api_token").
+      with(
+        headers: {
+          "Accept" => "application/json",
+          "Accept-Encoding" => "gzip;q=1.0,deflate;q=0.6,identity;q=0.3",
+          "Authorization" => "Bearer MockAccessToken",
+          "Host" => "www.inaturalist.org"
+        }
+      ).
+      to_return(jwt_return)
+  end
+
+  def stub_check_username_match(login)
+    stub_request(:get, "#{API_BASE}/users/me").
+      with(
+        headers: {
+          "Accept" => "application/json",
+          "Accept-Encoding" => "gzip;q=1.0,deflate;q=0.6,identity;q=0.3",
+          "Authorization" => "Bearer MockJWT",
+          "Content-Type" => "application/json",
+          "Host" => "api.inaturalist.org"
+        }
+      ).
+      to_return(status: 200,
+                body: "{\"results\":[{\"login\":\"#{login}\"}]}",
+                headers: {})
+  end
+
+  def stub_inat_observation_request(id_above: 0, superimporter: false)
+    query_args = {
+      iconic_taxa: ICONIC_TAXA,
+      id: @inat_import.inat_ids,
+      id_above: id_above,
+      per_page: 200,
+      only_id: false,
+      order: "asc",
+      order_by: "id",
+      without_field: "Mushroom Observer URL",
+      user_login: (@inat_import.inat_username unless superimporter)
+    }
+
+    stub_request(:get, "#{API_BASE}/observations?#{query_args.to_query}").
+      with(headers:
+    { "Accept" => "application/json",
+      "Accept-Encoding" => "gzip;q=1.0,deflate;q=0.6,identity;q=0.3",
+      "Authorization" => "Bearer MockJWT",
+      "Host" => "api.inaturalist.org" }).
+      to_return(body: @mock_inat_response)
+  end
+
+  def stub_inat_photo_requests
+    JSON.parse(@mock_inat_response)["results"].each do |result|
+      result["observation_photos"].each do |photo|
+        url = photo["photo"]["url"].sub("square", "original")
+        stub_request(
+          :get,
+          url
+        ).
+          with(
+            headers: {
+              "Accept" => "image/*",
+              "Accept-Encoding" => "gzip;q=1.0,deflate;q=0.6,identity;q=0.3",
+              "Host" => URI(url).host,
+              "User-Agent" => "Ruby"
+            }
+          ).
+          to_return(status: 200, body: image_for_stubs, headers: {})
+      end
+    end
+  end
+
+  def image_for_stubs
+    @image_for_stubs ||= Rails.root.join("test/images/test_image.jpg").read
+  end
+
+  def stub_modify_inat_observations
+    stub_add_observation_fields
+    stub_update_descriptions
+  end
+
+  def stub_add_observation_fields
+    stub_request(:post, "#{API_BASE}/observation_field_values").
+      to_return(status: 200, body: "".to_json,
+                headers: { "Content-Type" => "application/json" })
+  end
+
+  def stub_update_descriptions
+    date = Time.zone.today.strftime(MO.web_date_format)
+    @parsed_results.each do |obs|
+      updated_description =
+        "Imported by Mushroom Observer #{date}"
+      if obs[:description].present?
+        updated_description.prepend("#{obs[:description]}\n\n")
+      end
+
+      body = {
+        observation: {
+          description: updated_description,
+          ignore_photos: 1
+        }
+      }
+      headers = { authorization: "Bearer MockJWT",
+                  content_type: "application/json", accept: "application/json" }
+      stub_request(
+        :put, "#{API_BASE}/observations/#{obs[:id]}?ignore_photos=1"
+      ).
+        with(body: body.to_json, headers: headers).
+        to_return(status: 200, body: "".to_json, headers: {})
+    end
+  end
+end


Reviews:
user: login: JoeCohen
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-11T23:25:23Z
user: login: JoeCohen
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-11T23:27:08Z
user: login: JoeCohen
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-13T00:58:30Z

Review comments:
diff_hunk: @@ -0,0 +1,165 @@
+# frozen_string_literal: true
path: test/jobs/inat_import_job_test_doubles.rb
user: login: JoeCohen
body: Is `test/jobs/inat_import_job_test_doubles.rb` for the doubles?
Is there a better way to split them off from the test methods?
created_at: 2025-05-11T23:25:23Z
author_association: MEMBER
diff_hunk: @@ -1,37 +1,25 @@
 # frozen_string_literal: true
 
 require("test_helper")
+require_relative "inat_import_job_test_doubles"
+
 class InatImportJobTest < ActiveJob::TestCase
+  include InatImportJobTestDoubles
   # Prevent stubs from persisting between test methods because
   # the same request (/users/me) needs diffferent responses
   def setup
     @user = users(:inat_importer)
-    @stubs = []
     directory_path = Rails.public_path.join("test_images/orig")
     FileUtils.mkdir_p(directory_path) unless Dir.exist?(directory_path)
   end
 
-  def add_stub(stub)
-    @stubs << stub
-    stub
-  end
-
-  def teardown
-    @stubs.each do |stub|
-      WebMock::StubRegistry.instance.remove_request_stub(stub)
-    end
-  end
-
path: test/jobs/inat_import_job_test.rb
user: login: JoeCohen
body: Is it important/useful to remove the stubs?
(I think they persist otherwise.)
created_at: 2025-05-11T23:27:08Z
author_association: MEMBER
diff_hunk: @@ -1,37 +1,25 @@
 # frozen_string_literal: true
 
 require("test_helper")
+require_relative "inat_import_job_test_doubles"
+
 class InatImportJobTest < ActiveJob::TestCase
+  include InatImportJobTestDoubles
   # Prevent stubs from persisting between test methods because
   # the same request (/users/me) needs diffferent responses
   def setup
     @user = users(:inat_importer)
-    @stubs = []
     directory_path = Rails.public_path.join("test_images/orig")
     FileUtils.mkdir_p(directory_path) unless Dir.exist?(directory_path)
   end
 
-  def add_stub(stub)
-    @stubs << stub
-    stub
-  end
-
-  def teardown
-    @stubs.each do |stub|
-      WebMock::StubRegistry.instance.remove_request_stub(stub)
-    end
-  end
-
path: test/jobs/inat_import_job_test.rb
user: login: JoeCohen
body: Not important, as the last-declared stub takes precedence if there are matching stubs.
https://github.com/bblimke/webmock?tab=readme-ov-file#precedence-of-stubs
created_at: 2025-05-13T00:58:30Z
author_association: MEMBER

PR comments:
user: login: coveralls
created_at: 2025-05-10T23:31:14Z
body: 
[![Coverage Status](https://coveralls.io/builds/73663848/badge)](https://coveralls.io/builds/73663848)

coverage: 94.717% (-0.001%) from 94.718%
when pulling **821d051ea8a5f91ee922308f1121cf5a9ea33681 on jdc-2912-improve-inatimportjobtest**
into **20e3d01a457d5b12c24cb61454bfe1c9a0aa0876 on main**.

author_association: COLLABORATOR
user: login: JoeCohen
created_at: 2025-05-11T23:23:33Z
body: The alleged tiny coverage decrease is unrelated to this PR.
author_association: MEMBER
user: login: JoeCohen
created_at: 2025-05-11T23:31:32Z
body: @mo-nathan @nimmolo I had two short questions about this PR. See Comments above. (No need to review all the changes.)
author_association: MEMBER