Repository: hathitrust/holdings-backend
Pull request #386: ETT-193: (Holdings API) determine if an item is held by an institution and is brittle/lost/missing
Author: login: mwarin
Created: 2025-05-15T14:00:28Z | Merged: 2025-05-20T18:47:22Z

Updated `/v1/item_access` to always provide a `brlm_count` alongside `copy_count`. Does not need `constraint=brlm`. This, being one of many ways to do it, seemed like a good compromise and was deemed acceptable by @moseshll .

An unrelated change (accounting for most of the lines in the diff) was implicitly setting the API version to 1 in all the test calls.


Diff:
diff --git a/lib/api/holdings_api.rb b/lib/api/holdings_api.rb
index 74b4f7ad..5e05adcc 100644
--- a/lib/api/holdings_api.rb
+++ b/lib/api/holdings_api.rb
@@ -22,16 +22,17 @@ class HoldingsAPI < Sinatra::Base
 
     organization = params["organization"]
     item_id = params["item_id"]
-
     ht_item = Clusterable::HtItem.find(item_id: item_id)
     overlap_record = Overlap::ClusterOverlap.overlap_record(organization, ht_item)
 
     return_doc = {
+      "brlm_count" => overlap_record.access_count,
       "copy_count" => overlap_record.copy_count,
       "format" => ht_item.cluster.format,
       "n_enum" => ht_item.n_enum,
       "ocns" => ht_item.ocns.sort
     }
+
     return_doc.to_json
   end
 
diff --git a/spec/api/holdings_api_spec.rb b/spec/api/holdings_api_spec.rb
index fb55b9a1..93cb555c 100644
--- a/spec/api/holdings_api_spec.rb
+++ b/spec/api/holdings_api_spec.rb
@@ -19,7 +19,9 @@
   # ht_item 2 is an mpm
   let(:item_id_2) { "test.123456" }
   let(:ocn2) { [123, 456] }
-  let(:htitem_2) { build(:ht_item, :mpm, item_id: item_id_2, ocns: ocn2, enum_chron: "v.1-5 (1901-1905)") }
+  let(:enum_chron) { "v.1-5 (1901-1905)" }
+  let(:n_enum) { "1-5" }
+  let(:htitem_2) { build(:ht_item, :mpm, item_id: item_id_2, ocns: ocn2, enum_chron: enum_chron) }
 
   # ht_item 3 is an spm with 3 ocns
   let(:item_id_3) { "test.123456789" }
@@ -31,11 +33,8 @@
 
   def url_template(page, **kwargs)
     params = []
-    version = 0
-
-    if kwargs.key?(:v)
-      version = kwargs.delete(:v)
-    end
+    # Assume version 1 for this spec, a new version should probably have its own spec file.
+    version = 1
 
     kwargs.each do |k, v|
       params << "#{k}=#{v}"
@@ -62,7 +61,7 @@ def parse_response(...)
 
   describe "/ping" do
     it "ping-pongs" do
-      make_call(url_template("ping", v: 1))
+      make_call(url_template("ping"))
       expect(last_response.body).to eq "pong"
       expect(last_response.status).to eq 200
     end
@@ -78,43 +77,43 @@ def parse_response(...)
   describe "missing arguments -> #404 & application_error message" do
     it "missing organization" do
       load_test_data(htitem_1)
-      response = parse_response("item_access", v: 1, item_id: item_id_1)
+      response = parse_response("item_access", item_id: item_id_1)
       expect(response["application_error"]).to eq "missing required param: organization"
       expect(last_response.status).to eq 404
     end
 
     it "missing item_id" do
       load_test_data(htitem_1)
-      response = parse_response("item_access", v: 1, organization: "umich")
+      response = parse_response("item_access", organization: "umich")
       expect(response["application_error"]).to eq "missing required param: item_id"
       expect(last_response.status).to eq 404
     end
 
     it "missing both" do
       load_test_data(htitem_1)
-      response = parse_response("item_access", v: 1)
+      response = parse_response("item_access")
       expect(response["application_error"]).to eq "missing required param: organization; missing required param: item_id"
       expect(last_response.status).to eq 404
     end
   end
 
-  describe "/item_access" do
+  describe "v1/item_access" do
     it "returns an error message if there is no matching htitem" do
-      response = parse_response("item_access", v: 1, item_id: item_id_1, organization: "umich")
+      response = parse_response("item_access", item_id: item_id_1, organization: "umich")
       expect(response["application_error"]).to eq "no matching data"
       expect(last_response.status).to eq 404
     end
 
     it "returns 0 if no holdings (and not depositor)" do
       load_test_data(htitem_1)
-      response = parse_response("item_access", v: 1, item_id: item_id_1, organization: "upenn")
+      response = parse_response("item_access", item_id: item_id_1, organization: "upenn")
       expect(response["ocns"]).to eq [ocn]
       expect(response["copy_count"]).to eq 0
     end
 
     it "handles ht_items with slashes" do
       load_test_data(slashy_ht_item)
-      response = parse_response("item_access", v: 1, item_id: slashy_ht_item_id, organization: "umich")
+      response = parse_response("item_access", item_id: slashy_ht_item_id, organization: "umich")
       expect(response["copy_count"]).to eq 0
     end
 
@@ -123,7 +122,7 @@ def parse_response(...)
         htitem_1,
         build(:holding, organization: "umich", ocn: ocn)
       )
-      response = parse_response("item_access", v: 1, item_id: item_id_1, organization: "umich")
+      response = parse_response("item_access", item_id: item_id_1, organization: "umich")
       expect(response["ocns"]).to eq [ocn]
       expect(response["copy_count"]).to eq 1
     end
@@ -135,7 +134,7 @@ def parse_response(...)
         build(:holding, organization: "umich", ocn: ocn),
         build(:holding, organization: "umich", ocn: ocn)
       )
-      response = parse_response("item_access", v: 1, item_id: item_id_1, organization: "umich")
+      response = parse_response("item_access", item_id: item_id_1, organization: "umich")
       expect(response["ocns"]).to eq [ocn]
       expect(response["copy_count"]).to eq 3
     end
@@ -148,11 +147,11 @@ def parse_response(...)
         build(:holding, organization: "smu", ocn: ocn)
       )
       # umich, 1 holding
-      response = parse_response("item_access", v: 1, item_id: item_id_1, organization: "umich")
+      response = parse_response("item_access", item_id: item_id_1, organization: "umich")
       expect(response["ocns"]).to eq [ocn]
       expect(response["copy_count"]).to eq 1
       # smu, 2 holdings
-      response = parse_response("item_access", v: 1, item_id: item_id_1, organization: "smu")
+      response = parse_response("item_access", item_id: item_id_1, organization: "smu")
       expect(response["ocns"]).to eq [ocn]
       expect(response["copy_count"]).to eq 2
     end
@@ -162,7 +161,7 @@ def parse_response(...)
         htitem_3,
         build(:holding, organization: "umich", ocn: ocn)
       )
-      response = parse_response("item_access", v: 1, item_id: item_id_3, organization: "smu")
+      response = parse_response("item_access", item_id: item_id_3, organization: "smu")
       expect(response["ocns"]).to eq [123, 456, 789]
     end
 
@@ -174,31 +173,31 @@ def parse_response(...)
         build(:holding, organization: "umich", ocn: 789),
         build(:holding, organization: "umich", ocn: 999999999, local_id: "not matching")
       )
-      response = parse_response("item_access", v: 1, item_id: item_id_3, organization: "umich")
+      response = parse_response("item_access", item_id: item_id_3, organization: "umich")
       expect(response["copy_count"]).to eq 3
     end
 
     it "returns an empty n_enum for spm" do
       load_test_data(htitem_1)
-      response = parse_response("item_access", v: 1, item_id: item_id_1, organization: "umich")
+      response = parse_response("item_access", item_id: item_id_1, organization: "umich")
       expect(response["n_enum"]).to eq ""
     end
 
     it "returns non-empty n_enum for mpm" do
       load_test_data(htitem_2)
-      response = parse_response("item_access", v: 1, item_id: item_id_2, organization: "umich")
-      expect(response["n_enum"]).to eq "1-5"
+      response = parse_response("item_access", item_id: item_id_2, organization: "umich")
+      expect(response["n_enum"]).to eq n_enum
     end
 
     it "returns format:spm for an item in a spm cluster" do
       load_test_data(htitem_1)
-      response = parse_response("item_access", v: 1, item_id: item_id_1, organization: "umich")
+      response = parse_response("item_access", item_id: item_id_1, organization: "umich")
       expect(response["format"]).to eq "spm"
     end
 
     it "returns format:mpm for an item in a mpm cluster" do
       load_test_data(htitem_2)
-      response = parse_response("item_access", v: 1, item_id: item_id_2, organization: "umich")
+      response = parse_response("item_access", item_id: item_id_2, organization: "umich")
       expect(response["format"]).to eq "mpm"
     end
 
@@ -210,9 +209,9 @@ def parse_response(...)
       load_test_data(ht1, ht2, holding)
 
       # as set up above, upenn's holdings matches on ht1 but not on ht2 (different enumchron)
-      response = parse_response("item_access", v: 1, organization: holding.organization, item_id: ht1.item_id)
+      response = parse_response("item_access", organization: holding.organization, item_id: ht1.item_id)
       expect(response["copy_count"]).to eq 1
-      response = parse_response("item_access", v: 1, organization: holding.organization, item_id: ht2.item_id)
+      response = parse_response("item_access", organization: holding.organization, item_id: ht2.item_id)
       expect(response["copy_count"]).to eq 0
     end
 
@@ -223,28 +222,89 @@ def parse_response(...)
       load_test_data(ht1, ht2, holding)
 
       # as set up above, upenn's holdings (empty enumchron) matches on ht1 both ht2
-      response = parse_response("item_access", v: 1, organization: holding.organization, item_id: ht1.item_id)
+      response = parse_response("item_access", organization: holding.organization, item_id: ht1.item_id)
       expect(response["copy_count"]).to eq 1
-      response = parse_response("item_access", v: 1, organization: holding.organization, item_id: ht2.item_id)
+      response = parse_response("item_access", organization: holding.organization, item_id: ht2.item_id)
       expect(response["copy_count"]).to eq 1
     end
+
+    it "brlm_count indicates number of BRT/LM holdings by organization, spm" do
+      load_test_data(
+        htitem_1,
+        build(:holding, organization: "umich", ocn: ocn, status: nil, condition: "")
+      )
+
+      # brlm_count defaults to 0
+      # holdings with empty status/condition do not count towards brlm_count
+      response = parse_response("item_access", item_id: item_id_1, organization: "umich")
+      expect(response["brlm_count"]).to eq 0
+
+      # holdings with condition: "BRT" count towards brlm_count
+      load_test_data(build(:holding, organization: "umich", ocn: ocn, condition: "BRT"))
+      response = parse_response("item_access", item_id: item_id_1, organization: "umich")
+      expect(response["brlm_count"]).to eq 1
+
+      # holdings with status: LM count towards brlm_count
+      load_test_data(build(:holding, organization: "umich", ocn: ocn, status: "LM"))
+      response = parse_response("item_access", item_id: item_id_1, organization: "umich")
+      expect(response["brlm_count"]).to eq 2
+    end
+
+    it "brlm_count indicates number of BRT/LM holdings by organization, mpm" do
+      # Setting all of these test holdings to be BRT, since we are already testing
+      # the BRT/LM functionality in the spm-tests.
+      # These here tests are more about the matchiness of the enum_chrons.
+      holding_args = {
+        organization: "umich",
+        ocn: ocn2.first,
+        status: nil,
+        condition: "BRT",
+        mono_multi_serial: "mpm",
+        enum_chron: ""
+      }
+
+      item_access_args = {
+        item_id: item_id_2,
+        organization: "umich"
+      }
+
+      holding_empty = build(:holding, **holding_args)
+      holding_matching = build(:holding, **holding_args, enum_chron: enum_chron)
+      holding_nonmatching = build(:holding, **holding_args, enum_chron: "999-1001")
+      load_test_data(htitem_2)
+
+      load_test_data(holding_empty)
+      # empty enum_chron matches
+      response = parse_response("item_access", **item_access_args)
+      expect(response["brlm_count"]).to eq 1
+
+      load_test_data(holding_matching)
+      # matching enum_chron matches
+      response = parse_response("item_access", **item_access_args)
+      expect(response["brlm_count"]).to eq 2
+
+      load_test_data(holding_nonmatching)
+      # non-matching enum_chron does not match
+      response = parse_response("item_access", **item_access_args)
+      expect(response["brlm_count"]).to eq 2
+    end
   end
 
   describe "v1/item_held_by" do
     it "returns an application error if the item_id does not match an ht_item" do
-      response = parse_response("item_held_by", v: 1, item_id: item_id_1)
+      response = parse_response("item_held_by", item_id: item_id_1)
       expect(response["application_error"]).to eq "no matching data"
     end
 
     it "returns an array with the submitter if no organization has reported holdings matching ht_item" do
       load_test_data(htitem_1)
-      response = parse_response("item_held_by", v: 1, item_id: item_id_1)
+      response = parse_response("item_held_by", item_id: item_id_1)
       expect(response["organizations"]).to eq ["umich"]
     end
 
     it "returns an array with the submitter if only submitter has reported holdings matching ht_item" do
       load_test_data(htitem_1, build(:holding, organization: "umich", ocn: 123))
-      response = parse_response("item_held_by", v: 1, item_id: item_id_1)
+      response = parse_response("item_held_by", item_id: item_id_1)
       expect(response["organizations"]).to eq ["umich"]
     end
 
@@ -254,7 +314,7 @@ def parse_response(...)
         build(:holding, organization: "umich", ocn: ocn),
         build(:holding, organization: "smu", ocn: ocn)
       )
-      response = parse_response("item_held_by", v: 1, item_id: item_id_1)
+      response = parse_response("item_held_by", item_id: item_id_1)
       expect(response["organizations"].sort).to eq ["smu", "umich"]
     end
   end
@@ -273,7 +333,7 @@ def parse_response(...)
       # solr record in the format traject should send it to us
       solr_record_no_holdings = solr_docs_for(ht1, ht2)[0].to_json
 
-      post base_url + "/" + url_template("record_held_by", v: 1), solr_record_no_holdings, "CONTENT_TYPE" => "application/json"
+      post base_url + "/" + url_template("record_held_by"), solr_record_no_holdings, "CONTENT_TYPE" => "application/json"
 
       response = JSON.parse(last_response.body)
 
@@ -288,27 +348,27 @@ def parse_response(...)
   describe "v1/item_held_by?constraint=brlm" do
     it "returns an application error if given an invalid constraint" do
       load_test_data(htitem_1)
-      response = parse_response("item_held_by", v: 1, item_id: item_id_1, constraint: "foo")
+      response = parse_response("item_held_by", item_id: item_id_1, constraint: "foo")
       expect(response["application_error"]).to eq "Invalid constraint."
     end
 
     it "reports nothing if there are no holdings" do
       load_test_data(htitem_1)
-      response = parse_response("item_held_by", v: 1, item_id: item_id_1, constraint: "brlm")
+      response = parse_response("item_held_by", item_id: item_id_1, constraint: "brlm")
       expect(response["organizations"]).to eq []
     end
 
     it "reports nothing if all holdings are status:CH & condition:''" do
       load_test_data(htitem_1)
       load_test_data(build(:holding, organization: "a", ocn: htitem_1.ocns.first, status: "CH", condition: ""))
-      response = parse_response("item_held_by", v: 1, item_id: item_id_1, constraint: "brlm")
+      response = parse_response("item_held_by", item_id: item_id_1, constraint: "brlm")
       expect(response["organizations"]).to eq []
     end
 
     it "reports nothing if all holdings are status:WD & condition:''" do
       load_test_data(htitem_1)
       load_test_data(build(:holding, organization: "a", ocn: htitem_1.ocns.first, status: "WD", condition: ""))
-      response = parse_response("item_held_by", v: 1, item_id: item_id_1, constraint: "brlm")
+      response = parse_response("item_held_by", item_id: item_id_1, constraint: "brlm")
       expect(response["organizations"]).to eq []
     end
 
@@ -316,7 +376,7 @@ def parse_response(...)
       load_test_data(htitem_1)
       load_test_data(build(:holding, organization: "a", ocn: htitem_1.ocns.first, status: "LM", condition: ""))
       load_test_data(build(:holding, organization: "b", ocn: htitem_1.ocns.first, status: nil, condition: ""))
-      response = parse_response("item_held_by", v: 1, item_id: item_id_1, constraint: "brlm")
+      response = parse_response("item_held_by", item_id: item_id_1, constraint: "brlm")
       expect(response["organizations"]).to eq ["a"]
     end
 
@@ -324,7 +384,7 @@ def parse_response(...)
       load_test_data(htitem_1)
       load_test_data(build(:holding, organization: "a", ocn: htitem_1.ocns.first, status: nil, condition: "BRT"))
       load_test_data(build(:holding, organization: "b", ocn: htitem_1.ocns.first, status: nil, condition: ""))
-      response = parse_response("item_held_by", v: 1, item_id: item_id_1, constraint: "brlm")
+      response = parse_response("item_held_by", item_id: item_id_1, constraint: "brlm")
       expect(response["organizations"]).to eq ["a"]
     end
 
@@ -332,7 +392,7 @@ def parse_response(...)
       load_test_data(htitem_1)
       load_test_data(build(:holding, organization: "a", ocn: htitem_1.ocns.first, status: "LM", condition: "BRT"))
       load_test_data(build(:holding, organization: "b", ocn: htitem_1.ocns.first, status: nil, condition: ""))
-      response = parse_response("item_held_by", v: 1, item_id: item_id_1, constraint: "brlm")
+      response = parse_response("item_held_by", item_id: item_id_1, constraint: "brlm")
       expect(response["organizations"]).to eq ["a"]
     end
 
@@ -342,7 +402,7 @@ def parse_response(...)
       load_test_data(build(:holding, organization: "b", ocn: htitem_1.ocns.first, status: "LM", condition: ""))
       load_test_data(build(:holding, organization: "c", ocn: htitem_1.ocns.first, status: "LM", condition: "BRT"))
       load_test_data(build(:holding, organization: "d", ocn: htitem_1.ocns.first, status: "CH", condition: "BRT"))
-      response = parse_response("item_held_by", v: 1, item_id: item_id_1, constraint: "brlm")
+      response = parse_response("item_held_by", item_id: item_id_1, constraint: "brlm")
       expect(response["organizations"]).to eq ["a", "b", "c", "d"]
     end
   end


Reviews:
user: login: mwarin
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T15:53:53Z
user: login: mwarin
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T15:54:17Z
user: login: moseshll
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T17:27:16Z
user: login: moseshll
body: One recommended change (unneeded `constraint` kwarg) noted in the inline comment. Otherwise I'm quite happy with this.
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T17:28:56Z
user: login: mwarin
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T17:59:47Z
user: login: aelkiss
body: I like the change for making the version fixed in the spec file.

We do need to make sure that the BRLM stuff works as expected for MPMs, which should be straightforward to change in holdings_api.rb but will need another test in holdings_api_spec.rb.
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-05-19T17:59:05Z
user: login: mwarin
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T13:22:55Z
user: login: mwarin
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T15:44:53Z
user: login: aelkiss
body: Looks good to me now, thanks.

I'll briefly investigate whether we can remove the count methods in Cluster.
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-20T16:51:54Z

Review comments:
diff_hunk: @@ -22,16 +22,17 @@ class HoldingsAPI < Sinatra::Base
 
     organization = params["organization"]
     item_id = params["item_id"]
-
     ht_item = Clusterable::HtItem.find(item_id: item_id)
     overlap_record = Overlap::ClusterOverlap.overlap_record(organization, ht_item)
 
     return_doc = {
+      "brlm_count" => ht_item.cluster.access_counts[organization],
path: lib/api/holdings_api.rb
user: login: mwarin
body: This is the main change, adding `brlm_count` to the return doc, regardless of any `constraint` parameter.
created_at: 2025-05-15T15:53:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -223,28 +220,48 @@ def parse_response(...)
       load_test_data(ht1, ht2, holding)
 
       # as set up above, upenn's holdings (empty enumchron) matches on ht1 both ht2
-      response = parse_response("item_access", v: 1, organization: holding.organization, item_id: ht1.item_id)
+      response = parse_response("item_access", organization: holding.organization, item_id: ht1.item_id)
       expect(response["copy_count"]).to eq 1
-      response = parse_response("item_access", v: 1, organization: holding.organization, item_id: ht2.item_id)
+      response = parse_response("item_access", organization: holding.organization, item_id: ht2.item_id)
       expect(response["copy_count"]).to eq 1
     end
+
path: spec/api/holdings_api_spec.rb
user: login: mwarin
body: These are the added tests for `brlm_count`.
created_at: 2025-05-15T15:54:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -223,28 +220,48 @@ def parse_response(...)
       load_test_data(ht1, ht2, holding)
 
       # as set up above, upenn's holdings (empty enumchron) matches on ht1 both ht2
-      response = parse_response("item_access", v: 1, organization: holding.organization, item_id: ht1.item_id)
+      response = parse_response("item_access", organization: holding.organization, item_id: ht1.item_id)
       expect(response["copy_count"]).to eq 1
-      response = parse_response("item_access", v: 1, organization: holding.organization, item_id: ht2.item_id)
+      response = parse_response("item_access", organization: holding.organization, item_id: ht2.item_id)
       expect(response["copy_count"]).to eq 1
     end
+
+    it "brlm_count indicates number of BRT/LM holdings by organization" do
+      load_test_data(
+        htitem_1,
+        build(:holding, organization: "umich", ocn: ocn)
+      )
+
+      # brlm_count if given &constraint=brlm, default 0
+      response = parse_response("item_access", item_id: item_id_1, organization: "umich", constraint: "brlm")
path: spec/api/holdings_api_spec.rb
user: login: moseshll
body: You can get rid of the three instances of `constraint: "brlm"` from the API call.
created_at: 2025-05-15T17:27:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -223,28 +220,48 @@ def parse_response(...)
       load_test_data(ht1, ht2, holding)
 
       # as set up above, upenn's holdings (empty enumchron) matches on ht1 both ht2
-      response = parse_response("item_access", v: 1, organization: holding.organization, item_id: ht1.item_id)
+      response = parse_response("item_access", organization: holding.organization, item_id: ht1.item_id)
       expect(response["copy_count"]).to eq 1
-      response = parse_response("item_access", v: 1, organization: holding.organization, item_id: ht2.item_id)
+      response = parse_response("item_access", organization: holding.organization, item_id: ht2.item_id)
       expect(response["copy_count"]).to eq 1
     end
+
+    it "brlm_count indicates number of BRT/LM holdings by organization" do
+      load_test_data(
+        htitem_1,
+        build(:holding, organization: "umich", ocn: ocn)
+      )
+
+      # brlm_count if given &constraint=brlm, default 0
+      response = parse_response("item_access", item_id: item_id_1, organization: "umich", constraint: "brlm")
path: spec/api/holdings_api_spec.rb
user: login: mwarin
body: Yep. Good catch. Removed & updated comments.
created_at: 2025-05-15T17:59:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -22,16 +22,17 @@ class HoldingsAPI < Sinatra::Base
 
     organization = params["organization"]
     item_id = params["item_id"]
-
     ht_item = Clusterable::HtItem.find(item_id: item_id)
     overlap_record = Overlap::ClusterOverlap.overlap_record(organization, ht_item)
 
     return_doc = {
+      "brlm_count" => ht_item.cluster.access_counts[organization],
path: lib/api/holdings_api.rb
user: login: aelkiss
body: Shouldn't this be `overlap_record.access_count` so that it works properly for MPMs? (I'm not certain if we actually even need `cluster.access_counts` etc or rather we just always use the `Overlap` subclass to do it)
created_at: 2025-05-19T13:46:04Z
author_association: MEMBER
diff_hunk: @@ -223,28 +220,48 @@ def parse_response(...)
       load_test_data(ht1, ht2, holding)
 
       # as set up above, upenn's holdings (empty enumchron) matches on ht1 both ht2
-      response = parse_response("item_access", v: 1, organization: holding.organization, item_id: ht1.item_id)
+      response = parse_response("item_access", organization: holding.organization, item_id: ht1.item_id)
       expect(response["copy_count"]).to eq 1
-      response = parse_response("item_access", v: 1, organization: holding.organization, item_id: ht2.item_id)
+      response = parse_response("item_access", organization: holding.organization, item_id: ht2.item_id)
       expect(response["copy_count"]).to eq 1
     end
+
path: spec/api/holdings_api_spec.rb
user: login: aelkiss
body: We should probably have a test for an MPM...
created_at: 2025-05-19T17:58:20Z
author_association: MEMBER
diff_hunk: @@ -22,16 +22,17 @@ class HoldingsAPI < Sinatra::Base
 
     organization = params["organization"]
     item_id = params["item_id"]
-
     ht_item = Clusterable::HtItem.find(item_id: item_id)
     overlap_record = Overlap::ClusterOverlap.overlap_record(organization, ht_item)
 
     return_doc = {
+      "brlm_count" => ht_item.cluster.access_counts[organization],
path: lib/api/holdings_api.rb
user: login: mwarin
body: Yep, old thinking on my behalf, I keep forgetting that the overlap classes exist. Fixed.
created_at: 2025-05-20T13:22:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -223,28 +220,48 @@ def parse_response(...)
       load_test_data(ht1, ht2, holding)
 
       # as set up above, upenn's holdings (empty enumchron) matches on ht1 both ht2
-      response = parse_response("item_access", v: 1, organization: holding.organization, item_id: ht1.item_id)
+      response = parse_response("item_access", organization: holding.organization, item_id: ht1.item_id)
       expect(response["copy_count"]).to eq 1
-      response = parse_response("item_access", v: 1, organization: holding.organization, item_id: ht2.item_id)
+      response = parse_response("item_access", organization: holding.organization, item_id: ht2.item_id)
       expect(response["copy_count"]).to eq 1
     end
+
path: spec/api/holdings_api_spec.rb
user: login: mwarin
body: Yep. Added.
created_at: 2025-05-20T15:44:53Z
author_association: CONTRIBUTOR

PR comments:
user: login: mwarin
created_at: 2025-05-20T17:37:29Z
body: > Looks good to me now, thanks.
> 
> I'll briefly investigate whether we can remove the count methods in Cluster.

Sorry, do you want me to wait with the merge until that investigation has concluded?
author_association: CONTRIBUTOR
user: login: aelkiss
created_at: 2025-05-21T12:47:38Z
body: > > Looks good to me now, thanks.
> > I'll briefly investigate whether we can remove the count methods in Cluster.
> 
> Sorry, do you want me to wait with the merge until that investigation has concluded?

Just to close the loop here: I didn't need to wait on the merge. `SerialOverlap` and `SinglePartOverlap` both use the `copy_counts` methods, etc, so we can't remove them from `Cluster`.
author_association: MEMBER