Repository: BookOps-CAT/bookops-worldcat (language: python)

Issue:
Live tests can only be run by BookOps contributors
Live keys are loaded following a windows filepath and look for a BookOps-specific file name. Possible fixes:
 + Change how API credentials are loaded in order to allow non-BookOps contributors, and/or
 + Add info about running live tests to contributors section of documentation?


Discussion / hints:
There used to be another option in the `conftest.live_keys` for "posix" (Github's Ubuntu), but got deleted after temp WSkey expired. There is a way to securely add these credentials to the repository so they are available as environment variables when a virtual machine is spun.

Fix patch:
diff --git a/docs/contributing.md b/docs/contributing.md
index dbde9f6..378402c 100644
--- a/docs/contributing.md
+++ b/docs/contributing.md
@@ -76,9 +76,9 @@ poetry install
 ```
 
 #### Run tests
-Run tests before making changes on your fork.
+Run tests before making changes on your fork. Live tests will be run on a monthly basis using credentials saved in the repository's secrets.
 ??? info
-    Our live tests are designed to look for API credentials in a specific file/directory in a Windows environment. We will need to refactor the live tests to allow contributors to run live tests with their own API credentials and run live tests in a macOS environment.
+    Our live tests are designed to look for API credentials in a specific file/directory in a Windows environment. We will need to refactor the `live_keys` fixture to allow contributors to run live tests locally with their own API credentials and to run live tests in a macOS environment.
 
 ```py
 # basic usage without webtests


Test patch:
diff --git a/.github/workflows/monthly-api-tests.yaml b/.github/workflows/monthly-api-tests.yaml
new file mode 100644
index 0000000..9e228bf
--- /dev/null
+++ b/.github/workflows/monthly-api-tests.yaml
@@ -0,0 +1,44 @@
+name: monthly API response check
+on: 
+  pull_request:
+    branches:
+      - main
+      - "releases/**"
+  schedule: 
+    - cron: '0 5 15 * *'
+env:
+  GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+  GH_REPO: ${{ github.repository }}
+  WCKey: ${{ secrets.WC_KEY }}
+  WCSecret: ${{ secrets.WC_SECRET }}
+  WCScopes: "WorldCatMetadataAPI"
+jobs:
+  webtests:
+    name: Run live webtests
+    runs-on: ubuntu-latest
+    strategy:
+      matrix:
+        python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"]
+    steps:
+      - uses: actions/checkout@v4
+      - name: Check if PR is from fork
+        if: ${{ github.event_name == 'pull_request' }}
+        run: |
+          if [ "${{ github.event.pull_request.head.repo.full_name }}" != "${{ github.event.pull_request.base.repo.full_name }}" ]; then
+            echo "is_fork=true" >> $GITHUB_ENV
+          else
+            echo "is_fork=false" >> $GITHUB_ENV
+          fi
+      - name: Set up Python ${{ matrix.python-version}}
+        uses: actions/setup-python@v5
+        with:
+          python-version: ${{ matrix.python-version}}
+      - name: Install dependencies
+        if: ${{ env.is_fork == 'false' || github.event_name == 'schedule' }}
+        run: |
+          python -m pip install --upgrade pip
+          python -m pip install -r dev-requirements.txt
+      - name: Run monthly live tests
+        if: ${{ env.is_fork == 'false' || github.event_name == 'schedule' }}
+        run: |
+          pytest -m "webtest"     
\ No newline at end of file
diff --git a/tests/conftest.py b/tests/conftest.py
index 01cc096..ee3c89d 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,8 +1,6 @@
 # -*- coding: utf-8 -*-
 
 import datetime
-import json
-import os
 from typing import Dict, Generator, Union
 import pytest
 import requests
@@ -10,17 +8,6 @@
 from bookops_worldcat import WorldcatAccessToken, MetadataSession
 
 
-@pytest.fixture
-def live_keys():
-    if os.name == "nt" and not os.getenv("GITHUB_ACTIONS"):
-        fh = os.path.join(os.environ["USERPROFILE"], ".oclc/nyp_wc_test.json")
-        with open(fh, "r") as file:
-            data = json.load(file)
-            os.environ["WCKey"] = data["key"]
-            os.environ["WCSecret"] = data["secret"]
-            os.environ["WCScopes"] = data["scopes"]
-
-
 @pytest.fixture
 def stub_marc_xml() -> str:
     stub_marc_xml = "<record><leader>00000nam a2200000 a 4500</leader><controlfield tag='008'>120827s2012    nyua          000 0 eng d</controlfield><datafield tag='010' ind1=' ' ind2=' '><subfield code='a'>   63011276 </subfield></datafield><datafield tag='035' ind1=' ' ind2=' '><subfield code='a'>ocn850940548</subfield></datafield><datafield tag='040' ind1=' ' ind2=' '><subfield code='a'>OCWMS</subfield><subfield code='b'>eng</subfield><subfield code='c'>OCWMS</subfield></datafield><datafield tag='100' ind1='0' ind2=' '><subfield code='a'>OCLC Developer Network</subfield></datafield><datafield tag='245' ind1='1' ind2='0'><subfield code='a'>Test Record</subfield></datafield><datafield tag='500' ind1=' ' ind2=' '><subfield code='a'>FOR OCLC DEVELOPER NETWORK DOCUMENTATION</subfield></datafield></record>"
@@ -40,12 +27,12 @@ def stub_marc21() -> bytes:
 
 class FakeUtcNow(datetime.datetime):
     @classmethod
-    def now(cls, tzinfo=datetime.timezone.utc):
+    def now(cls, tzinfo=datetime.timezone.utc) -> "FakeUtcNow":
         return cls(2020, 1, 1, 17, 0, 0, 0, tzinfo=datetime.timezone.utc)
 
 
 @pytest.fixture
-def mock_now(monkeypatch):
+def mock_now(monkeypatch) -> None:
     monkeypatch.setattr(datetime, "datetime", FakeUtcNow)
 
 
@@ -130,7 +117,7 @@ def __init__(self, http_code) -> None:
 
 
 @pytest.fixture
-def mock_session_response(request, monkeypatch):
+def mock_session_response(request, monkeypatch) -> None:
     """
     Use together with `pytest.mark.http_code` marker to pass
     specific HTTP code to be returned to simulate various
@@ -165,7 +152,7 @@ def mock_oauth_server_response(
 
 
 @pytest.fixture
-def mock_successful_post_token_response(mock_now, monkeypatch):
+def mock_successful_post_token_response(mock_now, monkeypatch) -> None:
     def mock_oauth_server_response(*args, **kwargs):
         return MockAuthServerResponseSuccess()
 
@@ -173,7 +160,7 @@ def mock_oauth_server_response(*args, **kwargs):
 
 
 @pytest.fixture
-def mock_failed_post_token_response(monkeypatch):
+def mock_failed_post_token_response(monkeypatch) -> None:
     def mock_oauth_server_response(*args, **kwargs):
         return MockAuthServerResponseFailure()
 
@@ -181,28 +168,28 @@ def mock_oauth_server_response(*args, **kwargs):
 
 
 @pytest.fixture
-def mock_unexpected_error(monkeypatch):
+def mock_unexpected_error(monkeypatch) -> None:
     monkeypatch.setattr("requests.post", MockUnexpectedException)
     monkeypatch.setattr("requests.get", MockUnexpectedException)
     monkeypatch.setattr("requests.Session.send", MockUnexpectedException)
 
 
 @pytest.fixture
-def mock_timeout(monkeypatch):
+def mock_timeout(monkeypatch) -> None:
     monkeypatch.setattr("requests.post", MockTimeout)
     monkeypatch.setattr("requests.get", MockTimeout)
     monkeypatch.setattr("requests.Session.send", MockTimeout)
 
 
 @pytest.fixture
-def mock_connection_error(monkeypatch):
+def mock_connection_error(monkeypatch) -> None:
     monkeypatch.setattr("requests.post", MockConnectionError)
     monkeypatch.setattr("requests.get", MockConnectionError)
     monkeypatch.setattr("requests.Session.send", MockConnectionError)
 
 
 @pytest.fixture
-def mock_retry_error(monkeypatch):
+def mock_retry_error(monkeypatch) -> None:
     monkeypatch.setattr("requests.post", MockRetryError)
     monkeypatch.setattr("requests.get", MockRetryError)
     monkeypatch.setattr("requests.Session.send", MockRetryError)
diff --git a/tests/test_authorize.py b/tests/test_authorize.py
index 4a97fb7..88c42ba 100644
--- a/tests/test_authorize.py
+++ b/tests/test_authorize.py
@@ -1,7 +1,6 @@
 # -*- coding: utf-8 -*-
 
 import datetime
-import os
 
 import pytest
 
@@ -301,82 +300,3 @@ def test_token_repr(
             str(mock_token)
             == "access_token: 'tk_Yebz4BpEp9dAsghA7KpWx6dYD1OZKWBlHjqW', expires_at: '2020-01-01 17:19:58Z'"
         )
-
-    @pytest.mark.webtest
-    def test_cred_in_env_variables(self, live_keys):
-        assert os.getenv("WCKey") is not None
-        assert os.getenv("WCSecret") is not None
-        assert os.getenv("WCScopes") == "WorldCatMetadataAPI"
-
-    @pytest.mark.webtest
-    def test_post_token_request_with_live_service(self, live_keys):
-        token = WorldcatAccessToken(
-            key=os.getenv("WCKey"),
-            secret=os.getenv("WCSecret"),
-            scopes=os.getenv("WCScopes"),
-        )
-
-        assert token.server_response.status_code == 200
-
-        # test presence of all returned parameters
-        response = token.server_response.json()
-        params = [
-            "access_token",
-            "expires_at",
-            "authenticating_institution_id",
-            "principalID",
-            "context_institution_id",
-            "scope",
-            "scopes",
-            "token_type",
-            "expires_in",
-            "principalIDNS",
-        ]
-        for p in params:
-            assert p in response
-
-        # test if any new additions are present
-        assert sorted(params) == sorted(response.keys())
-
-        # test if token looks right
-        assert token.token_str.startswith("tk_")
-        assert response["scopes"] == response["scope"]
-        assert token.is_expired() is False
-        assert isinstance(token.token_expires_at, datetime.datetime)
-
-    @pytest.mark.webtest
-    def test_post_token_request_with_live_service_no_timeout(self, live_keys):
-        token = WorldcatAccessToken(
-            key=os.getenv("WCKey"),
-            secret=os.getenv("WCSecret"),
-            scopes=os.getenv("WCScopes"),
-            timeout=None,
-        )
-
-        assert token.server_response.status_code == 200
-
-        # test presence of all returned parameters
-        response = token.server_response.json()
-        params = [
-            "access_token",
-            "expires_at",
-            "authenticating_institution_id",
-            "principalID",
-            "context_institution_id",
-            "scope",
-            "scopes",
-            "token_type",
-            "expires_in",
-            "principalIDNS",
-        ]
-        for p in params:
-            assert p in response
-
-        # test if any new additions are present
-        assert sorted(params) == sorted(response.keys())
-
-        # test if token looks right
-        assert token.token_str.startswith("tk_")
-        assert response["scopes"] == response["scope"]
-        assert token.is_expired() is False
-        assert isinstance(token.token_expires_at, datetime.datetime)
diff --git a/tests/test_metadata_api.py b/tests/test_metadata_api.py
index be30b96..0e6ba5e 100644
--- a/tests/test_metadata_api.py
+++ b/tests/test_metadata_api.py
@@ -1,13 +1,12 @@
 # -*- coding: utf-8 -*-
 
-from contextlib import contextmanager
+from contextlib import nullcontext as does_not_raise
 import datetime
-import os
 
 import pytest
 
 
-from bookops_worldcat import MetadataSession, WorldcatAccessToken
+from bookops_worldcat import MetadataSession
 from bookops_worldcat.errors import (
     WorldcatRequestError,
     WorldcatAuthorizationError,
@@ -15,11 +14,6 @@
 )
 
 
-@contextmanager
-def does_not_raise():
-    yield
-
-
 class TestMockedMetadataSession:
     """Tests MetadataSession methods with mocking"""
 
@@ -700,492 +694,3 @@ def test_shared_print_holdings_search_with_invalid_oclc_number_passed(
         with pytest.raises(InvalidOclcNumber) as exc:
             stub_session.shared_print_holdings_search(oclcNumber="odn12345")
         assert msg in str(exc.value)
-
-
-@pytest.mark.webtest
-class TestLiveMetadataSession:
-    """Runs rudimentary tests against live Metadata API"""
-
-    def test_bib_get(self, live_keys):
-        token = WorldcatAccessToken(
-            key=os.getenv("WCKey"),
-            secret=os.getenv("WCSecret"),
-            scopes=os.getenv("WCScopes"),
-        )
-
-        with MetadataSession(authorization=token) as session:
-            response = session.bib_get(41266045)
-
-            assert (
-                response.url
-                == "https://metadata.api.oclc.org/worldcat/manage/bibs/41266045"
-            )
-            assert response.status_code == 200
-
-    def test_bib_get_classification(self, live_keys):
-        token = WorldcatAccessToken(
-            key=os.getenv("WCKey"),
-            secret=os.getenv("WCSecret"),
-            scopes=os.getenv("WCScopes"),
-        )
-
-        with MetadataSession(authorization=token) as session:
-            response = session.bib_get_classification(41266045)
-
-            assert (
-                response.url
-                == "https://metadata.api.oclc.org/worldcat/search/classification-bibs/41266045"
-            )
-            assert response.status_code == 200
-            assert sorted(response.json().keys()) == [
-                "dewey",
-                "lc",
-            ]
-
-    def test_bib_get_current_oclc_number(self, live_keys):
-        token = WorldcatAccessToken(
-            key=os.getenv("WCKey"),
-            secret=os.getenv("WCSecret"),
-            scopes=os.getenv("WCScopes"),
-        )
-
-        with MetadataSession(authorization=token) as session:
-            response = session.bib_get_current_oclc_number([41266045, 519740398])
-
-            assert response.status_code == 200
-            assert (
-                response.request.url
-                == "https://metadata.api.oclc.org/worldcat/manage/bibs/current?oclcNumbers=41266045%2C519740398"
-            )
-            jres = response.json()
-            assert sorted(jres.keys()) == ["controlNumbers"]
-            assert sorted(jres["controlNumbers"][0].keys()) == ["current", "requested"]
-
-    def test_bib_get_current_oclc_number_str(self, live_keys):
-        token = WorldcatAccessToken(
-            key=os.getenv("WCKey"),
-            secret=os.getenv("WCSecret"),
-            scopes=os.getenv("WCScopes"),
-        )
-
-        with MetadataSession(authorization=token) as session:
-            response = session.bib_get_current_oclc_number("41266045")
-
-            assert response.status_code == 200
-            assert (
-                response.request.url
-                == "https://metadata.api.oclc.org/worldcat/manage/bibs/current?oclcNumbers=41266045"
-            )
-            jres = response.json()
-            assert sorted(jres.keys()) == ["controlNumbers"]
-            assert sorted(jres["controlNumbers"][0].keys()) == ["current", "requested"]
-
-    def test_bib_match_marcxml(self, live_keys, stub_marc_xml):
-        token = WorldcatAccessToken(
-            key=os.getenv("WCKey"),
-            secret=os.getenv("WCSecret"),
-            scopes=os.getenv("WCScopes"),
-        )
-
-        with MetadataSession(authorization=token) as session:
-            response = session.bib_match(
-                stub_marc_xml, recordFormat="application/marcxml+xml"
-            )
-            assert response.status_code == 200
-            assert sorted(response.json().keys()) == sorted(
-                ["numberOfRecords", "briefRecords"]
-            )
-
-    def test_bib_search(self, live_keys):
-        token = WorldcatAccessToken(
-            key=os.getenv("WCKey"),
-            secret=os.getenv("WCSecret"),
-            scopes=os.getenv("WCScopes"),
-        )
-        with MetadataSession(authorization=token) as session:
-            response = session.bib_search(41266045)
-            assert (
-                response.url
-                == "https://metadata.api.oclc.org/worldcat/search/bibs/41266045"
-            )
-            assert response.status_code == 200
-            assert sorted(response.json().keys()) == sorted(
-                [
-                    "identifier",
-                    "title",
-                    "contributor",
-                    "subjects",
-                    "classification",
-                    "publishers",
-                    "date",
-                    "language",
-                    "edition",
-                    "note",
-                    "format",
-                    "description",
-                    "related",
-                    "work",
-                    "editionCluster",
-                    "database",
-                    "digitalAccessAndLocations",
-                ]
-            )
-
-    def test_bib_validate(self, live_keys, stub_marc21):
-        token = WorldcatAccessToken(
-            key=os.getenv("WCKey"),
-            secret=os.getenv("WCSecret"),
-            scopes=os.getenv("WCScopes"),
-        )
-
-        with MetadataSession(authorization=token) as session:
-            response = session.bib_validate(
-                stub_marc21, recordFormat="application/marc"
-            )
-            assert response.status_code == 200
-            assert (
-                response.url
-                == "https://metadata.api.oclc.org/worldcat/manage/bibs/validate/validateFull"
-            )
-            assert sorted(response.json().keys()) == sorted(["httpStatus", "status"])
-
-    def test_brief_bibs_get(self, live_keys):
-        fields = sorted(
-            [
-                "catalogingInfo",
-                "creator",
-                "date",
-                "edition",
-                "generalFormat",
-                "isbns",
-                "language",
-                "machineReadableDate",
-                "mergedOclcNumbers",
-                "oclcNumber",
-                "publicationPlace",
-                "publisher",
-                "specificFormat",
-                "title",
-            ]
-        )
-
-        token = WorldcatAccessToken(
-            key=os.getenv("WCKey"),
-            secret=os.getenv("WCSecret"),
-            scopes=os.getenv("WCScopes"),
-        )
-
-        with MetadataSession(authorization=token) as session:
-            response = session.brief_bibs_get(41266045)
-
-            assert response.status_code == 200
-            assert sorted(response.json().keys()) == fields
-
-    def test_brief_bibs_get_401_error(self, live_keys):
-        token = WorldcatAccessToken(
-            key=os.getenv("WCKey"),
-            secret=os.getenv("WCSecret"),
-            scopes=os.getenv("WCScopes"),
-        )
-        token.token_str = "invalid-token"
-        err_msg = "401 Client Error: Unauthorized for url: https://metadata.api.oclc.org/worldcat/search/brief-bibs/41266045"
-        with MetadataSession(authorization=token) as session:
-            session.headers.update({"Authorization": "Bearer invalid-token"})
-            with pytest.raises(WorldcatRequestError) as exc:
-                session.brief_bibs_get(41266045)
-
-            assert err_msg in str(exc.value)
-
-    def test_brief_bibs_search(self, live_keys):
-        fields = sorted(["briefRecords", "numberOfRecords"])
-        token = WorldcatAccessToken(
-            key=os.getenv("WCKey"),
-            secret=os.getenv("WCSecret"),
-            scopes=os.getenv("WCScopes"),
-        )
-
-        with MetadataSession(authorization=token) as session:
-            response = session.brief_bibs_search(
-                "ti:zendegi AND au:egan",
-                inLanguage="eng",
-                inCatalogLanguage="eng",
-                itemType="book",
-                itemSubType=["book-printbook", "book-digital"],
-                catalogSource="dlc",
-                orderBy="mostWidelyHeld",
-                limit=5,
-            )
-            assert response.status_code == 200
-            assert sorted(response.json().keys()) == fields
-            assert (
-                response.request.url
-                == "https://metadata.api.oclc.org/worldcat/search/brief-bibs?q=ti%3Azendegi+AND+au%3Aegan&inLanguage=eng&inCatalogLanguage=eng&catalogSource=dlc&itemType=book&itemSubType=book-printbook&itemSubType=book-digital&retentionCommitments=False&groupRelatedEditions=False&groupVariantRecords=False&preferredLanguage=eng&showHoldingsIndicators=False&unit=M&orderBy=mostWidelyHeld&offset=1&limit=5"
-            )
-
-    def test_brief_bibs_get_other_editions(self, live_keys):
-        fields = sorted(["briefRecords", "numberOfRecords"])
-        token = WorldcatAccessToken(
-            key=os.getenv("WCKey"),
-            secret=os.getenv("WCSecret"),
-            scopes=os.getenv("WCScopes"),
-        )
-
-        with MetadataSession(authorization=token) as session:
-            response = session.brief_bibs_get_other_editions(41266045)
-
-            assert response.status_code == 200
-            assert sorted(response.json().keys()) == fields
-
-    def test_holdings_get_current(self, live_keys):
-        token = WorldcatAccessToken(
-            key=os.getenv("WCKey"),
-            secret=os.getenv("WCSecret"),
-            scopes=os.getenv("WCScopes"),
-        )
-
-        with MetadataSession(authorization=token) as session:
-            response = session.holdings_get_current("982651100")
-
-            assert (
-                response.url
-                == "https://metadata.api.oclc.org/worldcat/manage/institution/holdings/current?oclcNumbers=982651100"
-            )
-            assert response.status_code == 200
-            assert sorted(response.json().keys()) == ["holdings"]
-            assert sorted(response.json()["holdings"][0].keys()) == sorted(
-                [
-                    "requestedControlNumber",
-                    "currentControlNumber",
-                    "institutionSymbol",
-                    "holdingSet",
-                ]
-            )
-
-    @pytest.mark.holdings
-    def test_holdings_set_unset(self, live_keys):
-        token = WorldcatAccessToken(
-            key=os.getenv("WCKey"),
-            secret=os.getenv("WCSecret"),
-            scopes=os.getenv("WCScopes"),
-        )
-
-        with MetadataSession(authorization=token) as session:
-            response = session.holdings_get_current("850940548")
-            holdings = response.json()["holdings"]
-
-            # make sure no holdings are set initially
-            if len(holdings) > 0:
-                response = session.holdings_unset(850940548)
-
-            response = session.holdings_set(850940548)
-            assert (
-                response.url
-                == "https://metadata.api.oclc.org/worldcat/manage/institution/holdings/850940548/set"
-            )
-            assert response.status_code == 200
-            assert response.json()["action"] == "Set Holdings"
-
-            # test deleting holdings
-            response = session.holdings_unset(850940548)
-            assert response.status_code == 200
-            assert (
-                response.request.url
-                == "https://metadata.api.oclc.org/worldcat/manage/institution/holdings/850940548/unset?cascadeDelete=True"
-            )
-            assert response.json()["action"] == "Unset Holdings"
-
-    @pytest.mark.holdings
-    def test_holdings_set_unset_cascadeDelete_False(self, live_keys, stub_marc_xml):
-        token = WorldcatAccessToken(
-            key=os.getenv("WCKey"),
-            secret=os.getenv("WCSecret"),
-            scopes=os.getenv("WCScopes"),
-        )
-
-        with MetadataSession(authorization=token) as session:
-            response = session.holdings_get_current("850940548")
-            holdings = response.json()["holdings"]
-
-            # make sure no holdings are set initially
-            if len(holdings) > 0:
-                response = session.holdings_unset(850940548)
-
-            response = session.holdings_set(850940548)
-            assert (
-                response.url
-                == "https://metadata.api.oclc.org/worldcat/manage/institution/holdings/850940548/set"
-            )
-            assert response.status_code == 200
-            assert response.json()["action"] == "Set Holdings"
-
-            # test deleting holdings
-            response = session.holdings_unset(850940548, cascadeDelete=False)
-            assert response.status_code == 200
-            assert (
-                response.request.url
-                == "https://metadata.api.oclc.org/worldcat/manage/institution/holdings/850940548/unset?cascadeDelete=False"
-            )
-            assert response.json()["action"] == "Unset Holdings"
-
-    @pytest.mark.holdings
-    def test_holdings_set_unset_marcxml(self, live_keys, stub_marc_xml):
-        token = WorldcatAccessToken(
-            key=os.getenv("WCKey"),
-            secret=os.getenv("WCSecret"),
-            scopes=os.getenv("WCScopes"),
-        )
-
-        with MetadataSession(authorization=token) as session:
-            response = session.holdings_get_current("850940548")
-            holdings = response.json()["holdings"]
-
-            # make sure no holdings are set initially
-            if len(holdings) > 0:
-                response = session.holdings_unset_with_bib(
-                    stub_marc_xml, recordFormat="application/marcxml+xml"
-                )
-
-            response = session.holdings_set_with_bib(
-                stub_marc_xml, recordFormat="application/marcxml+xml"
-            )
-            assert (
-                response.url
-                == "https://metadata.api.oclc.org/worldcat/manage/institution/holdings/set"
-            )
-            assert response.status_code == 200
-            assert response.json()["action"] == "Set Holdings"
-
-            response = session.holdings_unset_with_bib(
-                stub_marc_xml, recordFormat="application/marcxml+xml"
-            )
-            assert response.status_code == 200
-            assert (
-                response.request.url
-                == "https://metadata.api.oclc.org/worldcat/manage/institution/holdings/unset?cascadeDelete=True"
-            )
-            assert response.json()["action"] == "Unset Holdings"
-
-    @pytest.mark.holdings
-    def test_holdings_set_unset_marcxml_cascadeDelete_False(
-        self, live_keys, stub_marc_xml
-    ):
-        token = WorldcatAccessToken(
-            key=os.getenv("WCKey"),
-            secret=os.getenv("WCSecret"),
-            scopes=os.getenv("WCScopes"),
-        )
-
-        with MetadataSession(authorization=token) as session:
-            response = session.holdings_get_current("850940548")
-            holdings = response.json()["holdings"]
-
-            # make sure no holdings are set initially
-            if len(holdings) > 0:
-                response = session.holdings_unset_with_bib(
-                    stub_marc_xml, recordFormat="application/marcxml+xml"
-                )
-
-            response = session.holdings_set_with_bib(
-                stub_marc_xml, recordFormat="application/marcxml+xml"
-            )
-            assert (
-                response.url
-                == "https://metadata.api.oclc.org/worldcat/manage/institution/holdings/set"
-            )
-            assert response.status_code == 200
-            assert response.json()["action"] == "Set Holdings"
-
-            # test deleting holdings
-            response = session.holdings_unset_with_bib(
-                stub_marc_xml,
-                recordFormat="application/marcxml+xml",
-                cascadeDelete=False,
-            )
-            assert response.status_code == 200
-            assert (
-                response.request.url
-                == "https://metadata.api.oclc.org/worldcat/manage/institution/holdings/unset?cascadeDelete=False"
-            )
-            assert response.json()["action"] == "Unset Holdings"
-
-    def test_holdings_get_codes(self, live_keys):
-        token = WorldcatAccessToken(
-            key=os.getenv("WCKey"),
-            secret=os.getenv("WCSecret"),
-            scopes=os.getenv("WCScopes"),
-        )
-
-        with MetadataSession(authorization=token) as session:
-            response = session.holdings_get_codes()
-
-            assert (
-                response.url
-                == "https://metadata.api.oclc.org/worldcat/manage/institution/holding-codes"
-            )
-            assert response.status_code == 200
-            assert sorted(response.json().keys()) == ["holdingLibraryCodes"]
-            assert {"code": "Print Collection", "name": "NYPC"} in response.json()[
-                "holdingLibraryCodes"
-            ]
-
-    def test_summary_holdings_search_oclc(self, live_keys):
-        fields = sorted(["briefRecords", "numberOfRecords"])
-        token = WorldcatAccessToken(
-            key=os.getenv("WCKey"),
-            secret=os.getenv("WCSecret"),
-            scopes=os.getenv("WCScopes"),
-        )
-
-        with MetadataSession(authorization=token) as session:
-            response = session.summary_holdings_search(oclcNumber="41266045")
-
-            assert response.status_code == 200
-            assert sorted(response.json().keys()) == fields
-
-    def test_summary_holdings_search_isbn(self, live_keys):
-        fields = sorted(["briefRecords", "numberOfRecords"])
-        token = WorldcatAccessToken(
-            key=os.getenv("WCKey"),
-            secret=os.getenv("WCSecret"),
-            scopes=os.getenv("WCScopes"),
-        )
-
-        with MetadataSession(authorization=token) as session:
-            response = session.summary_holdings_search(isbn="9781597801744")
-
-            assert response.status_code == 200
-            assert sorted(response.json().keys()) == fields
-
-    def test_default_retries(self, live_keys, stub_marc21):
-        token = WorldcatAccessToken(
-            key=os.getenv("WCKey"),
-            secret=os.getenv("WCSecret"),
-            scopes=os.getenv("WCScopes"),
-        )
-
-        with MetadataSession(authorization=token) as session:
-            with pytest.raises(WorldcatRequestError) as exc:
-                session.bib_validate(stub_marc21, recordFormat="foo/bar")
-            assert "406 Client Error: Not Acceptable for url: " in (str(exc.value))
-            assert session.adapters["https://"].max_retries.total == 0
-
-    def test_custom_retries(self, live_keys, stub_marc21):
-        token = WorldcatAccessToken(
-            key=os.getenv("WCKey"),
-            secret=os.getenv("WCSecret"),
-            scopes=os.getenv("WCScopes"),
-        )
-
-        with MetadataSession(
-            authorization=token,
-            totalRetries=3,
-            backoffFactor=0.5,
-            statusForcelist=[406],
-            allowedMethods=["GET", "POST"],
-        ) as session:
-            with pytest.raises(WorldcatRequestError) as exc:
-                session.bib_validate(stub_marc21, recordFormat="foo/bar")
-            assert "Connection Error: <class 'requests.exceptions.RetryError'>" in (
-                str(exc.value)
-            )
-            assert session.adapters["https://"].max_retries.total == 3
diff --git a/tests/test_query.py b/tests/test_query.py
index 3186f0d..ebe4fc6 100644
--- a/tests/test_query.py
+++ b/tests/test_query.py
@@ -1,7 +1,6 @@
 # -*- coding: utf-8 -*-
 from contextlib import nullcontext as does_not_raise
 import datetime
-import os
 
 import pytest
 
@@ -9,30 +8,6 @@
 
 from bookops_worldcat.errors import WorldcatRequestError
 from bookops_worldcat.query import Query
-from bookops_worldcat.metadata_api import MetadataSession, WorldcatAccessToken
-
-
-@pytest.mark.webtest
-def test_query_live(live_keys):
-    token = WorldcatAccessToken(
-        key=os.getenv("WCKey"),
-        secret=os.getenv("WCSecret"),
-        scopes=os.getenv("WCScopes"),
-    )
-    with MetadataSession(authorization=token) as session:
-        header = {"Accept": "application/json"}
-        url = "https://metadata.api.oclc.org/worldcat/search/brief-bibs/41266045"
-        req = Request(
-            "GET",
-            url,
-            headers=header,
-        )
-        prepped = session.prepare_request(req)
-
-        with does_not_raise():
-            query = Query(session, prepped, timeout=5)
-
-        assert query.response.status_code == 200
 
 
 def test_query_not_prepared_request(stub_session):
diff --git a/tests/webtests/conftest.py b/tests/webtests/conftest.py
new file mode 100644
index 0000000..f2ace19
--- /dev/null
+++ b/tests/webtests/conftest.py
@@ -0,0 +1,118 @@
+# -*- coding: utf-8 -*-
+from collections.abc import Callable
+import inspect
+import json
+import os
+from typing import Generator
+import yaml
+import pytest
+import requests
+
+from bookops_worldcat import WorldcatAccessToken
+
+
+@pytest.fixture
+def live_keys() -> None:
+    if not os.getenv("GITHUB_ACTIONS"):
+        fh = os.path.expanduser("~/.oclc/nyp_wc_test.json")
+        with open(fh, "r") as file:
+            data = json.load(file)
+            os.environ["WCKey"] = data["key"]
+            os.environ["WCSecret"] = data["secret"]
+            os.environ["WCScopes"] = data["scopes"]
+
+
+@pytest.fixture(scope="class")
+@pytest.mark.usefixtures("live_keys")
+def live_token() -> Generator[WorldcatAccessToken, None, None]:
+    """
+    Gets live token from environment variables. For use with live tests so that
+    the service does not need to request a new token for each test.
+    """
+    yield WorldcatAccessToken(
+        key=os.environ["WCKey"],
+        secret=os.environ["WCSecret"],
+        scopes=os.environ["WCScopes"],
+    )
+
+
+@pytest.fixture
+def method_params() -> Callable:
+    """
+    Inspects signature of `MetadataSession` method and and returns list
+    of parameters. Filters "responseFormat", "hooks", and "Accept" parameters
+    as they are specific to bookops-worldcat or the `requests` library and
+    not part of the OCLC API. Filters "record" and "recordFormat" parameters as
+    they are passed to the API in the request body and not as query parameters.
+    """
+
+    def get_params(method):
+        all_params = list(inspect.signature(method).parameters.keys())
+        return [
+            i
+            for i in all_params
+            if i not in ["responseFormat", "hooks", "Accept", "record", "recordFormat"]
+        ]
+
+    return get_params
+
+
+@pytest.fixture
+def metadata_session_open_api_spec() -> dict:
+    """Retrieves OpenAPI spec from Metadata API documentation"""
+    yaml_response = requests.get(
+        "https://developer.api.oclc.org/docs/wc-metadata/openapi-external-prod.yaml"
+    )
+    return yaml.safe_load(yaml_response.text)
+
+
+@pytest.fixture
+def endpoint_params(metadata_session_open_api_spec) -> Callable:
+    """
+    Reads yaml file from OCLC API documentation (available here:
+    https://developer.api.oclc.org/wc-metadata-v2) and returns list of
+    parameters for a given endpoint. This assumes that OCLC updates this
+    .yaml file with up-to-date information about the API endpoints, parameters
+    and responses. This fixture does not account for endpoints that retrieve
+    data related to local holdings or bib records as these are not tested
+    during the monthly API tests. Function removes "Accept" param as it is not
+    part of the API and "heldBy" as it is deprecated.
+    """
+
+    def get_params_from_yaml(url: str, method: str) -> list:
+        data = metadata_session_open_api_spec
+        split_url = url.split("https://metadata.api.oclc.org")[1].split("?")[0]
+        if "bibs/validate/" in split_url:
+            endpoint = "/worldcat/manage/bibs/validate/{validationLevel}"
+        elif all(i.isdigit() is False for i in split_url):
+            endpoint = split_url
+        elif "institution" in split_url:
+            base = "/worldcat/manage/institution/holdings/{oclcNumber}"
+            endpoint = f"{base}/{split_url.split('/')[-1]}"
+        elif "other-editions" in split_url:
+            endpoint = "/worldcat/search/brief-bibs/{oclcNumber}/other-editions"
+        elif "manage/bibs" in split_url:
+            endpoint = "/worldcat/manage/bibs/{oclcNumber}"
+        elif "search" in split_url and "bibs" in split_url:
+            endpoint = f"{split_url.rsplit('/', 1)[0]}/{{oclcNumber}}"
+        else:
+            endpoint = split_url
+        method = method.lower()
+        if "parameters" in data["paths"][endpoint][method]:
+            param_list = [i for i in data["paths"][endpoint][method]["parameters"]]
+            if any("$ref" in i.keys() for i in param_list):
+                params = [
+                    data["components"]["parameters"][i["$ref"].split("/")[-1]]["name"]
+                    for i in param_list
+                ]
+            elif any("name" in i.keys() for i in param_list):
+                params = [
+                    i["name"] for i in data["paths"][endpoint][method]["parameters"]
+                ]
+            else:
+                params = param_list
+        else:
+            params = []
+        return [i for i in params if i != "Accept" and i != "heldBy"]
+
+    return get_params_from_yaml
diff --git a/tests/webtests/test_authorize_live.py b/tests/webtests/test_authorize_live.py
new file mode 100644
index 0000000..3879b3c
--- /dev/null
+++ b/tests/webtests/test_authorize_live.py
@@ -0,0 +1,104 @@
+# -*- coding: utf-8 -*-
+
+import datetime
+import os
+
+import pytest
+
+
+from bookops_worldcat.authorize import WorldcatAccessToken
+from bookops_worldcat.errors import WorldcatAuthorizationError
+
+
+class TestWorldcatAccessToken:
+    """Tests WorldcatAccessToken object"""
+
+    @pytest.mark.webtest
+    def test_cred_in_env_variables(self, live_keys):
+        assert os.getenv("WCKey") is not None
+        assert os.getenv("WCSecret") is not None
+        assert os.getenv("WCScopes") == "WorldCatMetadataAPI"
+
+    @pytest.mark.webtest
+    def test_post_token_request_with_live_service(self, live_keys):
+        token = WorldcatAccessToken(
+            key=os.getenv("WCKey"),
+            secret=os.getenv("WCSecret"),
+            scopes=os.getenv("WCScopes"),
+        )
+
+        assert token.server_response.status_code == 200
+
+        # test presence of all returned parameters
+        response = token.server_response.json()
+        params = [
+            "access_token",
+            "expires_at",
+            "authenticating_institution_id",
+            "principalID",
+            "context_institution_id",
+            "scope",
+            "scopes",
+            "token_type",
+            "expires_in",
+            "principalIDNS",
+        ]
+        for p in params:
+            assert p in response
+
+        # test if any new additions are present
+        assert sorted(params) == sorted(response.keys())
+
+        # test if token looks right
+        assert token.token_str.startswith("tk_")
+        assert response["scopes"] == response["scope"]
+        assert token.is_expired() is False
+        assert isinstance(token.token_expires_at, datetime.datetime)
+
+    @pytest.mark.webtest
+    def test_post_token_request_with_live_service_no_timeout(self, live_keys):
+        token = WorldcatAccessToken(
+            key=os.getenv("WCKey"),
+            secret=os.getenv("WCSecret"),
+            scopes=os.getenv("WCScopes"),
+            timeout=None,
+        )
+
+        assert token.server_response.status_code == 200
+
+        # test presence of all returned parameters
+        response = token.server_response.json()
+        params = [
+            "access_token",
+            "expires_at",
+            "authenticating_institution_id",
+            "principalID",
+            "context_institution_id",
+            "scope",
+            "scopes",
+            "token_type",
+            "expires_in",
+            "principalIDNS",
+        ]
+        for p in params:
+            assert p in response
+
+        # test if any new additions are present
+        assert sorted(params) == sorted(response.keys())
+
+        # test if token looks right
+        assert token.token_str.startswith("tk_")
+        assert response["scopes"] == response["scope"]
+        assert token.is_expired() is False
+        assert isinstance(token.token_expires_at, datetime.datetime)
+
+    @pytest.mark.webtest
+    def test_post_token_request_auth_error(self, live_keys):
+        with pytest.raises(WorldcatAuthorizationError) as exp:
+            token = WorldcatAccessToken(
+                key=os.environ["WCKey"],
+                secret="secret",
+                scopes=os.environ["WCScopes"],
+            )
+            assert token.server_response.status_code == 401
+        assert "No valid authentication credentials found in request" in str(exp.value)
diff --git a/tests/webtests/test_metadata_api_live.py b/tests/webtests/test_metadata_api_live.py
new file mode 100644
index 0000000..4fd1e58
--- /dev/null
+++ b/tests/webtests/test_metadata_api_live.py
@@ -0,0 +1,932 @@
+# -*- coding: utf-8 -*-
+
+import os
+import pytest
+
+
+from bookops_worldcat import MetadataSession, WorldcatAccessToken
+from bookops_worldcat.errors import WorldcatRequestError
+
+
+@pytest.mark.webtest
+class TestLiveMetadataSession:
+    """Runs tests against live Metadata API"""
+
+    BRIEF_BIB_RESPONSE_KEYS = ["briefRecords", "numberOfRecords"]
+    CAT_INFO_KEYS = [
+        "catalogingAgency",
+        "transcribingAgency",
+        "catalogingLanguage",
+        "levelOfCataloging",
+    ]
+
+    def test_bib_get(self, live_token):
+        with MetadataSession(authorization=live_token) as session:
+            response = session.bib_get(850940461)
+            endpoint = response.url.split("https://metadata.api.oclc.org/")[1]
+            headers = response.headers
+            assert endpoint == "worldcat/manage/bibs/850940461"
+            assert response.status_code == 200
+            assert headers["Content-Type"] == "application/marcxml+xml;charset=UTF-8"
+            assert isinstance(response.content, bytes)
+            assert response.content.decode().startswith("<?xml version=")
+
+    def test_bib_get_classification(self, live_token):
+        with MetadataSession(authorization=live_token) as session:
+            response = session.bib_get_classification(850940461)
+            endpoint = response.url.split("https://metadata.api.oclc.org/")[1]
+            assert endpoint == "worldcat/search/classification-bibs/850940461"
+            assert response.status_code == 200
+            assert response.headers["Content-Type"] == "application/json;charset=UTF-8"
+            assert sorted(response.json().keys()) == sorted(["dewey", "lc"])
+            assert sorted(response.json()["dewey"].keys()) == ["mostPopular"]
+            assert sorted(response.json()["lc"].keys()) == ["mostPopular"]
+
+    def test_bib_get_current_oclc_number(self, live_token):
+        with MetadataSession(authorization=live_token) as session:
+            response = session.bib_get_current_oclc_number([41266045, 519740398])
+            endpoint = response.url.split("https://metadata.api.oclc.org/")[1]
+            assert response.status_code == 200
+            assert (
+                endpoint
+                == "worldcat/manage/bibs/current?oclcNumbers=41266045%2C519740398"
+            )
+            assert sorted(response.json().keys()) == ["controlNumbers"]
+            assert sorted(response.json()["controlNumbers"][0].keys()) == sorted(
+                ["requested", "current"]
+            )
+
+    def test_bib_get_current_oclc_number_str(self, live_token):
+        with MetadataSession(authorization=live_token) as session:
+            response = session.bib_get_current_oclc_number("41266045")
+            endpoint = response.url.split("https://metadata.api.oclc.org/")[1]
+            assert response.status_code == 200
+            assert endpoint == "worldcat/manage/bibs/current?oclcNumbers=41266045"
+            assert sorted(response.json().keys()) == ["controlNumbers"]
+            assert sorted(response.json()["controlNumbers"][0].keys()) == sorted(
+                ["requested", "current"]
+            )
+
+    def test_bib_match(self, live_token, stub_marc_xml):
+        with MetadataSession(authorization=live_token) as session:
+            response = session.bib_match(
+                stub_marc_xml, recordFormat="application/marcxml+xml"
+            )
+            endpoint = response.url.split("https://metadata.api.oclc.org/")[1]
+            assert endpoint == "worldcat/manage/bibs/match"
+            assert response.status_code == 200
+            assert response.headers["Content-Type"] == "application/json;charset=UTF-8"
+            assert sorted(response.json().keys()) == sorted(
+                self.BRIEF_BIB_RESPONSE_KEYS
+            )
+            assert sorted(response.json()["briefRecords"][0].keys()) == sorted(
+                [
+                    "oclcNumber",
+                    "title",
+                    "creator",
+                    "date",
+                    "language",
+                    "edition",
+                    "publisher",
+                    "isbns",
+                    "publicationPlace",
+                    "catalogingInfo",
+                    "specificFormat",
+                    "generalFormat",
+                    "machineReadableDate",
+                    "mergedOclcNumbers",
+                    "issns",
+                ]
+            )
+            assert sorted(
+                response.json()["briefRecords"][0]["catalogingInfo"].keys()
+            ) == sorted(self.CAT_INFO_KEYS)
+
+    def test_bib_search(self, live_token):
+        with MetadataSession(authorization=live_token) as session:
+            response = session.bib_search(41266045)
+            endpoint = response.url.split("https://metadata.api.oclc.org/")[1]
+            assert endpoint == "worldcat/search/bibs/41266045"
+            assert response.status_code == 200
+            assert sorted(response.json().keys()) == sorted(
+                [
+                    "identifier",
+                    "title",
+                    "contributor",
+                    "subjects",
+                    "classification",
+                    "publishers",
+                    "date",
+                    "language",
+                    "edition",
+                    "note",
+                    "format",
+                    "description",
+                    "related",
+                    "work",
+                    "editionCluster",
+                    "database",
+                    "digitalAccessAndLocations",
+                ]
+            )
+
+    def test_bib_validate(self, live_token, stub_marc21):
+        with MetadataSession(authorization=live_token) as session:
+            response = session.bib_validate(
+                stub_marc21, recordFormat="application/marc"
+            )
+            endpoint = response.url.split("https://metadata.api.oclc.org/")[1]
+            assert response.status_code == 200
+            assert endpoint == "worldcat/manage/bibs/validate/validateFull"
+            assert response.headers["Content-Type"] == "application/json;charset=UTF-8"
+            assert sorted(response.json().keys()) == sorted(["status", "httpStatus"])
+            assert sorted(response.json()["status"].keys()) == sorted(
+                ["description", "summary"]
+            )
+
+    def test_brief_bibs_get(self, live_token):
+        with MetadataSession(authorization=live_token) as session:
+            response = session.brief_bibs_get(41266045)
+            endpoint = response.url.split("https://metadata.api.oclc.org/")[1]
+            assert endpoint == "worldcat/search/brief-bibs/41266045"
+            assert response.status_code == 200
+            assert "numberOfRecords" not in response.json().keys()
+            assert sorted(response.json().keys()) == sorted(
+                [
+                    "oclcNumber",
+                    "title",
+                    "creator",
+                    "date",
+                    "language",
+                    "edition",
+                    "publisher",
+                    "isbns",
+                    "publicationPlace",
+                    "catalogingInfo",
+                    "specificFormat",
+                    "generalFormat",
+                    "machineReadableDate",
+                    "mergedOclcNumbers",
+                ]
+            )
+            assert sorted(response.json()["catalogingInfo"].keys()) == sorted(
+                self.CAT_INFO_KEYS
+            )
+
+    def test_brief_bibs_search(self, live_token):
+        with MetadataSession(authorization=live_token) as session:
+            response = session.brief_bibs_search(
+                q="ti:Zendegi", inLanguage="eng", inCatalogLanguage="eng"
+            )
+            endpoint = response.url.split("https://metadata.api.oclc.org/")[1]
+            assert response.status_code == 200
+            assert endpoint.split("?")[0] == "worldcat/search/brief-bibs"
+            assert sorted(response.json().keys()) == sorted(
+                ["briefRecords", "numberOfRecords"]
+            )
+            assert sorted(response.json()["briefRecords"][0].keys()) == sorted(
+                [
+                    "oclcNumber",
+                    "title",
+                    "creator",
+                    "date",
+                    "language",
+                    "edition",
+                    "publisher",
+                    "isbns",
+                    "publicationPlace",
+                    "catalogingInfo",
+                    "specificFormat",
+                    "generalFormat",
+                    "machineReadableDate",
+                    "mergedOclcNumbers",
+                ]
+            )
+            assert sorted(
+                response.json()["briefRecords"][0]["catalogingInfo"].keys()
+            ) == sorted(self.CAT_INFO_KEYS)
+
+    def test_brief_bibs_get_other_editions(self, live_token):
+        with MetadataSession(authorization=live_token) as session:
+            response = session.brief_bibs_get_other_editions(41266045)
+            endpoint = response.url.split("https://metadata.api.oclc.org/")[1]
+            assert (
+                endpoint.split("?")[0]
+                == "worldcat/search/brief-bibs/41266045/other-editions"
+            )
+            assert response.status_code == 200
+            assert sorted(response.json().keys()) == sorted(
+                ["briefRecords", "numberOfRecords"]
+            )
+            assert sorted(response.json()["briefRecords"][0].keys()) == sorted(
+                [
+                    "oclcNumber",
+                    "title",
+                    "creator",
+                    "date",
+                    "language",
+                    "publisher",
+                    "isbns",
+                    "publicationPlace",
+                    "catalogingInfo",
+                    "specificFormat",
+                    "generalFormat",
+                    "machineReadableDate",
+                ]
+            )
+            assert sorted(
+                response.json()["briefRecords"][0]["catalogingInfo"].keys()
+            ) == sorted(self.CAT_INFO_KEYS)
+
+    def test_holdings_get_codes(self, live_token):
+        with MetadataSession(authorization=live_token) as session:
+            response = session.holdings_get_codes()
+            endpoint = response.url.split("https://metadata.api.oclc.org/")[1]
+            assert endpoint == "worldcat/manage/institution/holding-codes"
+            assert response.status_code == 200
+            assert sorted(response.json().keys()) == ["holdingLibraryCodes"]
+            assert all(
+                sorted(list(i.keys())) == sorted(["code", "name"])
+                for i in response.json()["holdingLibraryCodes"]
+            )
+            assert {"code": "Print Collection", "name": "NYPC"} in response.json()[
+                "holdingLibraryCodes"
+            ]
+
+    def test_holdings_get_current(self, live_token):
+        with MetadataSession(authorization=live_token) as session:
+            response = session.holdings_get_current("982651100")
+            endpoint = response.url.split("https://metadata.api.oclc.org/")[1]
+            assert (
+                endpoint
+                == "worldcat/manage/institution/holdings/current?oclcNumbers=982651100"
+            )
+            assert response.status_code == 200
+            assert sorted(response.json().keys()) == ["holdings"]
+            assert sorted(response.json()["holdings"][0].keys()) == sorted(
+                [
+                    "requestedControlNumber",
+                    "currentControlNumber",
+                    "institutionSymbol",
+                    "holdingSet",
+                ]
+            )
+
+    @pytest.mark.holdings
+    def test_holdings_set_unset(self, live_token):
+        with MetadataSession(
+            authorization=live_token,
+            totalRetries=3,
+            backoffFactor=0.5,
+            statusForcelist=[408, 500, 502, 503, 504],
+            allowedMethods=["GET", "POST"],
+        ) as session:
+            get_resp = session.holdings_get_current("850940548")
+            holdings = get_resp.json()["holdings"]
+
+            # make sure no holdings are set initially
+            if len(holdings) > 0:
+                session.holdings_unset(850940548)
+
+            set_resp = session.holdings_set(850940548)
+            assert (
+                set_resp.url
+                == "https://metadata.api.oclc.org/worldcat/manage/institution/holdings/850940548/set"
+            )
+            assert set_resp.status_code == 200
+            assert set_resp.json()["action"] == "Set Holdings"
+
+            # test deleting holdings
+            unset_resp = session.holdings_unset(850940548)
+            assert unset_resp.status_code == 200
+            assert (
+                unset_resp.url
+                == "https://metadata.api.oclc.org/worldcat/manage/institution/holdings/850940548/unset?cascadeDelete=True"
+            )
+            assert unset_resp.json()["action"] == "Unset Holdings"
+
+    @pytest.mark.holdings
+    def test_holdings_set_unset_cascadeDelete(self, live_token, stub_marc_xml):
+        with MetadataSession(
+            authorization=live_token,
+            totalRetries=3,
+            backoffFactor=0.5,
+            statusForcelist=[408, 500, 502, 503, 504],
+            allowedMethods=["GET", "POST"],
+        ) as session:
+            get_resp = session.holdings_get_current("850940548")
+            holdings = get_resp.json()["holdings"]
+
+            # make sure no holdings are set initially
+            if len(holdings) > 0:
+                session.holdings_unset(850940548)
+
+            set_resp = session.holdings_set(850940548)
+            assert (
+                set_resp.url
+                == "https://metadata.api.oclc.org/worldcat/manage/institution/holdings/850940548/set"
+            )
+            assert set_resp.status_code == 200
+            assert set_resp.json()["action"] == "Set Holdings"
+
+            # test deleting holdings
+            unset_resp = session.holdings_unset(850940548, cascadeDelete=False)
+            assert unset_resp.status_code == 200
+            assert (
+                unset_resp.url
+                == "https://metadata.api.oclc.org/worldcat/manage/institution/holdings/850940548/unset?cascadeDelete=False"
+            )
+            assert unset_resp.json()["action"] == "Unset Holdings"
+
+    @pytest.mark.holdings
+    def test_holdings_set_unset_xml(self, live_token, stub_marc_xml):
+        with MetadataSession(
+            authorization=live_token,
+            totalRetries=3,
+            backoffFactor=0.5,
+            statusForcelist=[408, 500, 502, 503, 504],
+            allowedMethods=["GET", "POST"],
+        ) as session:
+            response = session.holdings_get_current("850940548")
+            holdings = response.json()["holdings"]
+
+            # make sure no holdings are set initially
+            if len(holdings) > 0:
+                response = session.holdings_unset_with_bib(
+                    stub_marc_xml, recordFormat="application/marcxml+xml"
+                )
+
+            response = session.holdings_set_with_bib(
+                stub_marc_xml, recordFormat="application/marcxml+xml"
+            )
+            assert (
+                response.url
+                == "https://metadata.api.oclc.org/worldcat/manage/institution/holdings/set"
+            )
+            assert response.status_code == 200
+            assert response.json()["action"] == "Set Holdings"
+
+            response = session.holdings_unset_with_bib(
+                stub_marc_xml, recordFormat="application/marcxml+xml"
+            )
+            assert response.status_code == 200
+            assert (
+                response.url
+                == "https://metadata.api.oclc.org/worldcat/manage/institution/holdings/unset?cascadeDelete=True"
+            )
+            assert response.json()["action"] == "Unset Holdings"
+
+    @pytest.mark.holdings
+    def test_holdings_set_unset_xml_cascadeDelete(self, live_token, stub_marc_xml):
+        with MetadataSession(
+            authorization=live_token,
+            totalRetries=3,
+            backoffFactor=0.5,
+            statusForcelist=[408, 500, 502, 503, 504],
+            allowedMethods=["GET", "POST"],
+        ) as session:
+            response = session.holdings_get_current("850940548")
+            holdings = response.json()["holdings"]
+
+            # make sure no holdings are set initially
+            if len(holdings) > 0:
+                response = session.holdings_unset_with_bib(
+                    stub_marc_xml, recordFormat="application/marcxml+xml"
+                )
+
+            response = session.holdings_set_with_bib(
+                stub_marc_xml, recordFormat="application/marcxml+xml"
+            )
+            assert (
+                response.url
+                == "https://metadata.api.oclc.org/worldcat/manage/institution/holdings/set"
+            )
+            assert response.status_code == 200
+            assert response.json()["action"] == "Set Holdings"
+
+            # test deleting holdings
+            response = session.holdings_unset_with_bib(
+                stub_marc_xml,
+                recordFormat="application/marcxml+xml",
+                cascadeDelete=False,
+            )
+            assert response.status_code == 200
+            assert (
+                response.url
+                == "https://metadata.api.oclc.org/worldcat/manage/institution/holdings/unset?cascadeDelete=False"
+            )
+            assert response.json()["action"] == "Unset Holdings"
+
+    def test_shared_print_holdings_search(self, live_token):
+        with MetadataSession(authorization=live_token) as session:
+            response = session.shared_print_holdings_search(oclcNumber="41266045")
+            assert (
+                response.url
+                == "https://metadata.api.oclc.org/worldcat/search/bibs-retained-holdings?oclcNumber=41266045"
+            )
+            assert response.status_code == 200
+            assert sorted(response.json().keys()) == sorted(
+                ["briefRecords", "numberOfRecords"]
+            )
+            assert sorted(response.json()["briefRecords"][0].keys()) == sorted(
+                [
+                    "oclcNumber",
+                    "title",
+                    "creator",
+                    "date",
+                    "edition",
+                    "institutionHolding",
+                    "mergedOclcNumbers",
+                    "language",
+                    "publisher",
+                    "isbns",
+                    "publicationPlace",
+                    "catalogingInfo",
+                    "specificFormat",
+                    "generalFormat",
+                    "machineReadableDate",
+                ]
+            )
+            assert sorted(
+                response.json()["briefRecords"][0]["catalogingInfo"].keys()
+            ) == sorted(self.CAT_INFO_KEYS)
+
+    def test_summary_holdings_get(self, live_token):
+        with MetadataSession(authorization=live_token) as session:
+            response = session.summary_holdings_get("41266045")
+            assert (
+                response.url
+                == "https://metadata.api.oclc.org/worldcat/search/summary-holdings?oclcNumber=41266045&unit=M"
+            )
+            assert response.status_code == 200
+            assert sorted(response.json().keys()) == sorted(
+                ["totalEditions", "totalHoldingCount", "totalSharedPrintCount"]
+            )
+
+    def test_summary_holdings_search_isbn(self, live_token):
+        with MetadataSession(authorization=live_token) as session:
+            response = session.summary_holdings_search(isbn="9781597801744")
+            endpoint = response.url.split("https://metadata.api.oclc.org/")[1]
+            assert response.status_code == 200
+            assert (
+                endpoint
+                == "worldcat/search/bibs-summary-holdings?isbn=9781597801744&preferredLanguage=eng&unit=M"
+            )
+            assert sorted(response.json().keys()) == sorted(
+                self.BRIEF_BIB_RESPONSE_KEYS
+            )
+            assert sorted(response.json()["briefRecords"][0].keys()) == sorted(
+                [
+                    "oclcNumber",
+                    "title",
+                    "creator",
+                    "date",
+                    "language",
+                    "edition",
+                    "publisher",
+                    "isbns",
+                    "publicationPlace",
+                    "catalogingInfo",
+                    "specificFormat",
+                    "generalFormat",
+                    "machineReadableDate",
+                    "mergedOclcNumbers",
+                    "institutionHolding",
+                ]
+            )
+            assert sorted(
+                response.json()["briefRecords"][0]["catalogingInfo"].keys()
+            ) == sorted(self.CAT_INFO_KEYS)
+
+    def test_summary_holdings_search_oclc(self, live_token):
+        with MetadataSession(authorization=live_token) as session:
+            response = session.summary_holdings_search(oclcNumber="41266045")
+            endpoint = response.url.split("https://metadata.api.oclc.org/")[1]
+            assert response.status_code == 200
+            assert (
+                endpoint
+                == "worldcat/search/bibs-summary-holdings?oclcNumber=41266045&preferredLanguage=eng&unit=M"
+            )
+            assert sorted(response.json().keys()) == sorted(
+                self.BRIEF_BIB_RESPONSE_KEYS
+            )
+            assert sorted(response.json()["briefRecords"][0].keys()) == sorted(
+                [
+                    "oclcNumber",
+                    "title",
+                    "creator",
+                    "date",
+                    "language",
+                    "edition",
+                    "publisher",
+                    "isbns",
+                    "publicationPlace",
+                    "catalogingInfo",
+                    "specificFormat",
+                    "generalFormat",
+                    "machineReadableDate",
+                    "mergedOclcNumbers",
+                    "institutionHolding",
+                ]
+            )
+            assert sorted(
+                response.json()["briefRecords"][0]["catalogingInfo"].keys()
+            ) == sorted(self.CAT_INFO_KEYS)
+
+
+@pytest.mark.webtest
+@pytest.mark.usefixtures("live_keys")
+class TestLiveMetadataSessionErrors:
+    """Tests error responses from live Metadata API"""
+
+    def test_errors_invalid_query_param(self):
+        token = WorldcatAccessToken(
+            key=os.getenv("WCKey"),
+            secret=os.getenv("WCSecret"),
+            scopes=os.getenv("WCScopes"),
+        )
+        with MetadataSession(authorization=token) as session:
+            with pytest.raises(WorldcatRequestError) as exc:
+                session.brief_bibs_get(-41266045)
+            assert (
+                '400 Client Error:  for url: https://metadata.api.oclc.org/worldcat/search/brief-bibs/-41266045. Server response: {"type":"INVALID_QUERY_PARAMETER_VALUE","title":"Validation Failure","detail":"oclcNumber must be a positive whole number","invalid-params":[{"reason":"Invalid Value: -41266045"}]}'
+                == str(exc.value)
+            )
+
+    def test_errors_invalid_token(self):
+        token = WorldcatAccessToken(
+            key=os.getenv("WCKey"),
+            secret=os.getenv("WCSecret"),
+            scopes=os.getenv("WCScopes"),
+        )
+        token.token_str = "invalid-token"
+        with MetadataSession(authorization=token) as session:
+            session.headers.update({"Authorization": "Bearer invalid-token"})
+            with pytest.raises(WorldcatRequestError) as exc:
+                session.brief_bibs_get(41266045)
+
+            assert (
+                '401 Client Error: Unauthorized for url: https://metadata.api.oclc.org/worldcat/search/brief-bibs/41266045. Server response: {"message":"Unauthorized"}'
+                == str(exc.value)
+            )
+
+    def test_errors_resource_not_found(self):
+        token = WorldcatAccessToken(
+            key=os.getenv("WCKey"),
+            secret=os.getenv("WCSecret"),
+            scopes=os.getenv("WCScopes"),
+        )
+        with MetadataSession(authorization=token) as session:
+            with pytest.raises(WorldcatRequestError) as exc:
+                session.lbd_get(12345)
+            assert (
+                '404 Client Error:  for url: https://metadata.api.oclc.org/worldcat/manage/lbds/12345. Server response: {"type":"NOT_FOUND","title":"Unable to perform the lbd read operation.","detail":{"summary":"NOT_FOUND","description":"Not able to find the requested LBD"}}'
+                == str(exc.value)
+            )
+
+    def test_errors_unacceptable_header(self, stub_marc21):
+        token = WorldcatAccessToken(
+            key=os.getenv("WCKey"),
+            secret=os.getenv("WCSecret"),
+            scopes=os.getenv("WCScopes"),
+        )
+
+        with MetadataSession(authorization=token) as session:
+            with pytest.raises(WorldcatRequestError) as exc:
+                session.bib_validate(stub_marc21, recordFormat="foo/bar")
+            assert (
+                '406 Client Error:  for url: https://metadata.api.oclc.org/worldcat/manage/bibs/validate/validateFull. Server response: {"type":"NOT_ACCEPTABLE","title":"Invalid \'Content-Type\' header.","detail":"A request with an invalid \'Content-Type\' header was attempted: foo/bar"}'
+                == (str(exc.value))
+            )
+            assert session.adapters["https://"].max_retries.total == 0
+
+    def test_error_max_retries(self, stub_marc21):
+        token = WorldcatAccessToken(
+            key=os.getenv("WCKey"),
+            secret=os.getenv("WCSecret"),
+            scopes=os.getenv("WCScopes"),
+        )
+
+        with MetadataSession(
+            authorization=token,
+            totalRetries=3,
+            backoffFactor=0.5,
+            statusForcelist=[406],
+            allowedMethods=["GET", "POST"],
+        ) as session:
+            with pytest.raises(WorldcatRequestError) as exc:
+                session.bib_validate(stub_marc21, recordFormat="foo/bar")
+            assert "Connection Error: <class 'requests.exceptions.RetryError'>" in (
+                str(exc.value)
+            )
+            assert session.adapters["https://"].max_retries.total == 3
+
+
+@pytest.mark.webtest
+class TestAPISpec:
+    """Compares API spec with MetadataSession methods"""
+
+    def test_open_api_spec_check(self, metadata_session_open_api_spec):
+        """Confirm API spec contains the same endpoints as expected."""
+        endpoints = metadata_session_open_api_spec["paths"]
+        assert len(endpoints) == 29
+        assert sorted(endpoints) == sorted(
+            [
+                "/worldcat/manage/bibs/validate/{validationLevel}",
+                "/worldcat/manage/bibs/current",
+                "/worldcat/manage/bibs",
+                "/worldcat/manage/bibs/{oclcNumber}",
+                "/worldcat/manage/bibs/match",
+                "/worldcat/manage/institution/holdings/current",
+                "/worldcat/manage/institution/holdings/{oclcNumber}/set",
+                "/worldcat/manage/institution/holdings/{oclcNumber}/unset",
+                "/worldcat/manage/institution/holdings/set",
+                "/worldcat/manage/institution/holdings/unset",
+                "/worldcat/manage/institution/holding-codes",
+                "/worldcat/manage/lbds/{controlNumber}",
+                "/worldcat/manage/lbds",
+                "/worldcat/manage/lhrs/{controlNumber}",
+                "/worldcat/manage/lhrs",
+                "/worldcat/search/brief-bibs",
+                "/worldcat/search/brief-bibs/{oclcNumber}",
+                "/worldcat/search/classification-bibs/{oclcNumber}",
+                "/worldcat/search/brief-bibs/{oclcNumber}/other-editions",
+                "/worldcat/search/bibs-retained-holdings",
+                "/worldcat/search/bibs-summary-holdings",
+                "/worldcat/search/bibs/{oclcNumber}",
+                "/worldcat/search/summary-holdings",
+                "/worldcat/search/retained-holdings",
+                "/worldcat/search/my-local-bib-data/{controlNumber}",
+                "/worldcat/search/my-local-bib-data",
+                "/worldcat/search/my-holdings/{controlNumber}",
+                "/worldcat/search/my-holdings",
+                "/worldcat/browse/my-holdings",
+            ]
+        )
+        post_endpoints = [
+            "/worldcat/manage/bibs/validate/{validationLevel}",
+            "/worldcat/manage/bibs",
+            "/worldcat/manage/bibs/match",
+            "/worldcat/manage/institution/holdings/{oclcNumber}/set",
+            "/worldcat/manage/institution/holdings/{oclcNumber}/unset",
+            "/worldcat/manage/institution/holdings/set",
+            "/worldcat/manage/institution/holdings/unset",
+            "/worldcat/manage/lbds",
+            "/worldcat/manage/lhrs",
+        ]
+        get_endpoints = [
+            "/worldcat/manage/bibs/current",
+            "/worldcat/manage/institution/holdings/current",
+            "/worldcat/manage/institution/holding-codes",
+            "/worldcat/search/brief-bibs",
+            "/worldcat/search/brief-bibs/{oclcNumber}",
+            "/worldcat/search/classification-bibs/{oclcNumber}",
+            "/worldcat/search/brief-bibs/{oclcNumber}/other-editions",
+            "/worldcat/search/bibs-retained-holdings",
+            "/worldcat/search/bibs-summary-holdings",
+            "/worldcat/search/bibs/{oclcNumber}",
+            "/worldcat/search/summary-holdings",
+            "/worldcat/search/retained-holdings",
+            "/worldcat/search/my-local-bib-data/{controlNumber}",
+            "/worldcat/search/my-local-bib-data",
+            "/worldcat/search/my-holdings/{controlNumber}",
+            "/worldcat/search/my-holdings",
+            "/worldcat/browse/my-holdings",
+        ]
+        for endpoint in endpoints:
+            methods = list(endpoints[endpoint].keys())
+            if endpoint in post_endpoints:
+                assert methods == ["post"]
+            elif endpoint in get_endpoints:
+                assert methods == ["get"]
+            elif endpoint == "/worldcat/manage/bibs/{oclcNumber}":
+                assert sorted(methods) == ["get", "put"]
+            else:
+                assert sorted(methods) == ["delete", "get", "put"]
+
+    def test_params_bib_get(self, live_token, endpoint_params, method_params):
+        with MetadataSession(authorization=live_token) as session:
+            response = session.bib_get(41266045)
+            endpoint_args = endpoint_params(
+                response.request.url, response.request.method
+            )
+            method_args = method_params(session.bib_get)
+            assert endpoint_args == method_args
+
+    def test_params_bib_get_classification(
+        self, live_token, endpoint_params, method_params
+    ):
+        with MetadataSession(authorization=live_token) as session:
+            response = session.bib_get_classification(41266045)
+            endpoint_args = endpoint_params(
+                response.request.url, response.request.method
+            )
+            method_args = method_params(session.bib_get_classification)
+            assert endpoint_args == method_args
+
+    def test_params_bib_get_current_oclc_number(
+        self, live_token, endpoint_params, method_params
+    ):
+        with MetadataSession(authorization=live_token) as session:
+            response = session.bib_get_current_oclc_number([41266045, 519740398])
+            endpoint_args = endpoint_params(
+                response.request.url, response.request.method
+            )
+            method_args = method_params(session.bib_get_current_oclc_number)
+            assert endpoint_args == method_args
+
+    def test_params_bib_match_marcxml(
+        self, live_token, stub_marc_xml, endpoint_params, method_params
+    ):
+        with MetadataSession(authorization=live_token) as session:
+            response = session.bib_match(
+                stub_marc_xml, recordFormat="application/marcxml+xml"
+            )
+            endpoint_args = endpoint_params(
+                response.request.url, response.request.method
+            )
+            method_args = method_params(session.bib_match)
+            assert endpoint_args == method_args
+
+    def test_params_bib_validate(
+        self, live_token, stub_marc21, endpoint_params, method_params
+    ):
+        with MetadataSession(authorization=live_token) as session:
+            response = session.bib_validate(
+                stub_marc21, recordFormat="application/marc"
+            )
+            endpoint_args = endpoint_params(
+                response.request.url, response.request.method
+            )
+            method_args = method_params(session.bib_validate)
+            assert endpoint_args == method_args
+
+    def test_params_brief_bibs_get(self, live_token, endpoint_params, method_params):
+        with MetadataSession(authorization=live_token) as session:
+            response = session.brief_bibs_get(41266045)
+            endpoint_args = endpoint_params(
+                response.request.url, response.request.method
+            )
+            method_args = method_params(session.brief_bibs_get)
+            assert endpoint_args == method_args
+
+    def test_params_brief_bibs_search(self, live_token, endpoint_params, method_params):
+        with MetadataSession(authorization=live_token) as session:
+            response = session.brief_bibs_search(
+                q="ti:Zendegi", inLanguage="eng", inCatalogLanguage="eng"
+            )
+            endpoint_args = endpoint_params(
+                response.request.url, response.request.method
+            )
+            method_args = method_params(session.brief_bibs_search)
+            assert endpoint_args == method_args
+
+    def test_params_brief_bibs_get_other_editions(
+        self, live_token, endpoint_params, method_params
+    ):
+        with MetadataSession(authorization=live_token) as session:
+            response = session.brief_bibs_get_other_editions(41266045)
+            endpoint_args = endpoint_params(
+                response.request.url, response.request.method
+            )
+            method_args = method_params(session.brief_bibs_get_other_editions)
+            assert endpoint_args == method_args
+
+    def test_params_holdings_get_codes(
+        self, live_token, endpoint_params, method_params
+    ):
+        with MetadataSession(authorization=live_token) as session:
+            response = session.holdings_get_codes()
+            endpoint_args = endpoint_params(
+                response.request.url, response.request.method
+            )
+            method_args = method_params(session.holdings_get_codes)
+            assert endpoint_args == method_args
+
+    def test_params_holdings_get_current(
+        self, live_token, endpoint_params, method_params
+    ):
+        with MetadataSession(authorization=live_token) as session:
+            response = session.holdings_get_current("982651100")
+            endpoint_args = endpoint_params(
+                response.request.url, response.request.method
+            )
+            method_args = method_params(session.holdings_get_current)
+            assert endpoint_args == method_args
+
+    @pytest.mark.holdings
+    def test_params_holdings_set_unset(
+        self, live_token, endpoint_params, method_params
+    ):
+        with MetadataSession(
+            authorization=live_token,
+            totalRetries=3,
+            backoffFactor=0.5,
+            statusForcelist=[408, 500, 502, 503, 504],
+            allowedMethods=["GET", "POST"],
+        ) as session:
+            get_response = session.holdings_get_current("850940548")
+            holdings = get_response.json()["holdings"]
+            current_holding_endpoint_args = endpoint_params(
+                get_response.request.url, get_response.request.method
+            )
+            current_holding_method_args = method_params(session.holdings_get_current)
+            assert current_holding_endpoint_args == current_holding_method_args
+
+            # make sure no holdings are set initially
+            if len(holdings) > 0:
+                session.holdings_unset(850940548)
+
+            # test setting holdings
+            set_response = session.holdings_set(850940548)
+            set_holding_endpoint_args = endpoint_params(
+                set_response.request.url, set_response.request.method
+            )
+            set_holding_method_args = method_params(session.holdings_set)
+            assert set_holding_endpoint_args == set_holding_method_args
+
+            # test deleting holdings
+            unset_response = session.holdings_unset(oclcNumber=850940548)
+            unset_holding_endpoint_args = endpoint_params(
+                unset_response.request.url, unset_response.request.method
+            )
+            unset_holding_method_args = method_params(session.holdings_unset)
+            assert unset_holding_endpoint_args == unset_holding_method_args
+
+    @pytest.mark.holdings
+    def test_params_holdings_set_unset_with_bib(
+        self, live_token, endpoint_params, method_params, stub_marc_xml
+    ):
+        with MetadataSession(
+            authorization=live_token,
+            totalRetries=3,
+            backoffFactor=0.5,
+            statusForcelist=[408, 500, 502, 503, 504],
+            allowedMethods=["GET", "POST"],
+        ) as session:
+            get_response = session.holdings_get_current("850940548")
+            holdings = get_response.json()["holdings"]
+            current_holding_endpoint_args = endpoint_params(
+                get_response.request.url, get_response.request.method
+            )
+            current_holding_method_args = method_params(session.holdings_get_current)
+            assert current_holding_endpoint_args == current_holding_method_args
+
+            # make sure no holdings are set initially
+            if len(holdings) > 0:
+                session.holdings_unset_with_bib(
+                    stub_marc_xml, recordFormat="application/marcxml+xml"
+                )
+
+            # test setting holdings
+            set_response = session.holdings_set_with_bib(
+                stub_marc_xml, recordFormat="application/marcxml+xml"
+            )
+            set_holding_endpoint_args = endpoint_params(
+                set_response.request.url, set_response.request.method
+            )
+            set_holding_method_args = method_params(session.holdings_set_with_bib)
+            assert set_holding_endpoint_args == set_holding_method_args
+
+            # test deleting holdings
+            unset_response = session.holdings_unset_with_bib(
+                stub_marc_xml, recordFormat="application/marcxml+xml"
+            )
+            unset_holding_endpoint_args = endpoint_params(
+                unset_response.request.url, unset_response.request.method
+            )
+            unset_holding_method_args = method_params(session.holdings_unset_with_bib)
+            assert unset_holding_endpoint_args == unset_holding_method_args
+
+    def test_params_shared_print_holdings_search(
+        self, live_token, endpoint_params, method_params
+    ):
+        with MetadataSession(authorization=live_token) as session:
+            response = session.shared_print_holdings_search(oclcNumber="41266045")
+            endpoint_args = endpoint_params(
+                response.request.url, response.request.method
+            )
+            method_args = method_params(session.shared_print_holdings_search)
+            assert endpoint_args == method_args
+
+    def test_params_summary_holdings_get(
+        self, live_token, endpoint_params, method_params
+    ):
+        with MetadataSession(authorization=live_token) as session:
+            response = session.summary_holdings_get("41266045")
+            endpoint_args = endpoint_params(
+                response.request.url, response.request.method
+            )
+            method_args = method_params(session.summary_holdings_get)
+            assert endpoint_args == method_args
+
+    def test_params_summary_holdings_search_oclc(
+        self, live_token, endpoint_params, method_params
+    ):
+        with MetadataSession(authorization=live_token) as session:
+            response = session.summary_holdings_search(oclcNumber="41266045")
+            endpoint_args = endpoint_params(
+                response.request.url, response.request.method
+            )
+            method_args = method_params(session.summary_holdings_search)
+            assert endpoint_args == method_args
diff --git a/tests/webtests/test_query_live.py b/tests/webtests/test_query_live.py
new file mode 100644
index 0000000..588c219
--- /dev/null
+++ b/tests/webtests/test_query_live.py
@@ -0,0 +1,33 @@
+# -*- coding: utf-8 -*-
+from contextlib import nullcontext as does_not_raise
+import os
+
+import pytest
+
+from requests import Request
+
+from bookops_worldcat.query import Query
+from bookops_worldcat.metadata_api import MetadataSession, WorldcatAccessToken
+
+
+@pytest.mark.webtest
+def test_query_live(live_keys):
+    token = WorldcatAccessToken(
+        key=os.getenv("WCKey"),
+        secret=os.getenv("WCSecret"),
+        scopes=os.getenv("WCScopes"),
+    )
+    with MetadataSession(authorization=token) as session:
+        header = {"Accept": "application/json"}
+        url = "https://metadata.api.oclc.org/worldcat/search/brief-bibs/41266045"
+        req = Request(
+            "GET",
+            url,
+            headers=header,
+        )
+        prepped = session.prepare_request(req)
+
+        with does_not_raise():
+            query = Query(session, prepped, timeout=5)
+
+        assert query.response.status_code == 200