Repository: heroku/heroku-applink-python
Pull request #6: Adding test suite for SDK.
Author: login: gopi-heroku
Created: 2025-04-28T14:07:23Z | Merged: 2025-05-08T21:37:12Z

# Pull Request

## Summary

- [x] Added test suite for all the classes with code coverage over 80%. 
- [x] Added Github Workflow to fail if coverage falls below 80% in python 3.10-3.13 envs. 
- [x] Added ruff for lint checking.

https://gus.lightning.force.com/lightning/r/ADM_Work__c/a07EE00002DOuYWYA1/view
https://gus.lightning.force.com/lightning/r/ADM_Work__c/a07EE00002DOi3fYAD/view
https://gus.lightning.force.com/lightning/r/ADM_Work__c/a07EE00002DOvB7YAL/view
<!--
Briefly explain the purpose of this PR.
What functionality or bug does it address?
Reference any related issues with "Fixes #123" or "Closes #456".
-->

Fixes #


## What Changed

<!--
Describe the key changes in this PR.
If it's a bug fix, describe what was broken and how it's fixed.
If it's a feature, explain how it works and any limitations.
-->

New test suite and GH actions workflow

## Checklist


- [x] I’ve added or updated unit tests where necessary
- [ ] I’ve added or updated documentation
- [x] I’ve manually tested the functionality in this PR
- [x] This pull request is ready for review

---

## Testing

<!--
Explain how you tested your changes. Include commands, env details, or Heroku resources if needed.
-->

```bash
# Example
pytest
python examples/your_example.py



Diff:
diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml
index 07ffbc1..b34047f 100644
--- a/.github/workflows/package.yml
+++ b/.github/workflows/package.yml
@@ -1,39 +1,90 @@
 name: Package Library
+
 permissions:
   contents: write
   id-token: write
+
 on:
   push:
     branches: [ "main" ]
-    tags: 
+    tags:
       - "*"
   pull_request:
     branches: [ "main" ]
-  
+
 jobs:
   build:
-    runs-on: pub-hk-ubuntu-24.04-ip
+    runs-on: ubuntu-latest  # Changed to ubuntu-latest to match the most common GitHub Actions runner environment
+
     steps:
-    - uses: actions/checkout@v4
-    - name: Set up Python 
-      uses: actions/setup-python@v3
-    - name: Install uv
-      uses: astral-sh/setup-uv@v5
-      with:
-        enable-cache: true
-        cache-dependency-glob: "uv.lock"
-    - name: Install the project
-      run: uv sync --all-extras --dev
-    - name: Build the artifact in dist/
-      run: uv build
-    - name: Release to Github
-      uses: softprops/action-gh-release@v2
-      if: startsWith(github.ref, 'refs/tags/')
-      with:
-          files: | 
+      - uses: actions/checkout@v4
+
+      - name: Set up Python 
+        uses: actions/setup-python@v3
+
+      - name: Install uv
+        uses: astral-sh/setup-uv@v5
+        with:
+          enable-cache: true
+          cache-dependency-glob: "uv.lock"
+
+      - name: Install the project
+        run: uv sync --all-extras --dev
+
+      - name: Build the artifact in dist/
+        run: uv build
+
+      - name: Release to GitHub
+        uses: softprops/action-gh-release@v2
+        if: startsWith(github.ref, 'refs/tags/')
+        with:
+          files: |
             dist/*.whl
             dist/*.tar.gz
-    - name: Release to pypi
-      run: uv publish
-      if: startsWith(github.ref, 'refs/tags/')
-      
\ No newline at end of file
+
+      - name: Release to PyPI
+        run: uv publish
+        if: startsWith(github.ref, 'refs/tags/')
+
+  test:
+    name: Test on Python ${{ matrix.python-version }}
+    runs-on: ubuntu-latest
+
+    strategy:
+      matrix:
+        python-version: ["3.10", "3.11", "3.12", "3.13"]
+      fail-fast: false
+
+    steps:
+      - uses: actions/checkout@v4
+
+      - name: Set up Python ${{ matrix.python-version }}
+        uses: actions/setup-python@v4
+        with:
+          python-version: ${{ matrix.python-version }}
+          cache: pip
+
+      - name: Install tox and tox-uv
+        run: pip install tox tox-uv
+
+      - name: Run tox
+        run: tox
+
+  lint:
+    name: Lint with Ruff
+    runs-on: ubuntu-latest
+
+    steps:
+      - uses: actions/checkout@v4
+
+      - name: Set up Python 3.11
+        uses: actions/setup-python@v4
+        with:
+          python-version: 3.11
+          cache: pip
+
+      - name: Install ruff
+        run: pip install ruff
+
+      - name: Run Ruff
+        run: ruff check .
diff --git a/.gitignore b/.gitignore
index 5b35b75..27b1067 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,6 +9,10 @@ venv/
 dist/
 **.egg-info/
 _build/
+.tox
+.ruff_cache
+.pytest_cache
+.mypy_cache
 
 # Salesforce
-.codegenie/
+.codegenie/
\ No newline at end of file
diff --git a/README.md b/README.md
index 8c274f3..1fa7737 100644
--- a/README.md
+++ b/README.md
@@ -25,3 +25,92 @@ Generate the documentation.
 ```shell
 $ uv run pdoc3 --template-dir templates/python heroku_applink -o docs --force
 ```
+
+## Development
+
+### Setting Up the Development Environment
+
+1. Clone the repository:
+
+    ```bash
+    git clone https://github.com/heroku/heroku-applink-python.git
+    cd heroku-applink-python
+    ```
+
+2. Install Dependencies:
+
+    Install the `uv` package manager:
+
+    ```bash
+    curl -LsSf https://astral.sh/uv/install.sh | sh
+    ```
+
+    Sync all dependencies:
+
+    ```bash
+    uv sync --all-extras --dev
+    ```
+
+3. Install Development Dependencies:
+
+    ```bash
+    pip install -e ".[dev]"
+    ```
+
+### Running Tests
+
+1. Run the full test suite:
+
+    ```bash
+    # Run all tests
+    pytest
+
+    # Run all tests with coverage
+    pytest --cov=heroku_applink.data_api --cov-report=term-missing -v
+    ```
+
+2. Run a single test:
+
+    ```bash
+    # Run a specific test file
+    pytest <path_to_test_file>/test_specific_file.py
+
+    # Run a specific test file with coverage
+    pytest tests/data_api/test_data_api_record.py::test_some_specific_case \
+        --cov=heroku_applink.data_api
+    ```
+
+3. Run tests with a specific Python version:
+
+    ```bash
+    pyenv shell 3.12.2  # Or any installed version
+    uv venv
+    source .venv/bin/activate
+    uv sync --all-extras --dev
+    pytest
+    ```
+
+4. Run tests across multiple Python versions with Tox:
+
+    ```bash
+    pip install tox tox-uv
+    tox
+    ```
+
+### Linting and Code Quality
+
+1. Run the Ruff linter:
+
+    ```bash
+    # Check the code for issues
+    ruff check .
+
+    # Automatically fix issues
+    ruff check . --fix
+
+    # Check a specific directory (e.g., heroku_applink)
+    ruff check heroku_applink/
+
+    # Format the codebase
+    ruff format .
+    ```
diff --git a/examples/fastapi_example.py b/examples/fastapi_example.py
index 1543d65..17ba305 100644
--- a/examples/fastapi_example.py
+++ b/examples/fastapi_example.py
@@ -1,6 +1,6 @@
 import asyncio
 import heroku_applink as sdk
-from fastapi import FastAPI, Request
+from fastapi import FastAPI
 
 app = FastAPI()
 app.add_middleware(sdk.IntegrationAsgiMiddleware)
diff --git a/heroku_applink/__init__.py b/heroku_applink/__init__.py
index e05e9ec..f88ddbd 100644
--- a/heroku_applink/__init__.py
+++ b/heroku_applink/__init__.py
@@ -2,7 +2,8 @@
 from .data_api.record import QueriedRecord, Record, RecordQueryResult
 from .data_api.reference_id import ReferenceId
 from .data_api.unit_of_work import UnitOfWork
-from .middleware import IntegrationWsgiMiddleware, IntegrationAsgiMiddleware, client_context
+from .middleware import IntegrationWsgiMiddleware, IntegrationAsgiMiddleware
+from .exceptions import ClientError, UnexpectedRestApiResponsePayload
 
 __all__ = [
     "client_context",
@@ -16,5 +17,7 @@
     "User",
     "IntegrationWsgiMiddleware",
     "IntegrationAsgiMiddleware",
+    "ClientError",
+    "UnexpectedRestApiResponsePayload",
 ]
 
diff --git a/heroku_applink/data_api/_requests.py b/heroku_applink/data_api/_requests.py
index 324e745..52b2257 100644
--- a/heroku_applink/data_api/_requests.py
+++ b/heroku_applink/data_api/_requests.py
@@ -2,8 +2,6 @@
 from typing import Any, Awaitable, Callable, Generic, Literal, TypeVar, cast
 from urllib.parse import urlencode
 
-"Context > Org > DataAPI"
-
 from .exceptions import (
     InnerSalesforceRestApiError,
     MissingFieldError,
diff --git a/heroku_applink/exceptions.py b/heroku_applink/exceptions.py
new file mode 100644
index 0000000..2af611a
--- /dev/null
+++ b/heroku_applink/exceptions.py
@@ -0,0 +1,9 @@
+# heroku_applink/exceptions.py
+
+class ClientError(Exception):
+    """Raised when there is an error with the HTTP client."""
+    pass
+
+class UnexpectedRestApiResponsePayload(Exception):
+    """Raised when the API response is not in the expected format."""
+    pass
diff --git a/pyproject.toml b/pyproject.toml
index 886f522..3c0ce06 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,7 +6,7 @@ readme = "README.md"
 requires-python = ">=3.10"
 dependencies = [
     "aiohttp>=3.11.12",
-    "orjson>=3.10.15",
+    "orjson>=3.10.15"
 ]
 
 [dependency-groups]
@@ -21,3 +21,48 @@ Documentation = "https://github.com/heroku/heroku-applink-python"
 Repository = "https://github.com/heroku/heroku-applink-python.git"
 "Bug Tracker" = "https://github.com/heroku/heroku-applink-python/issues"
 Changelog = "https://github.com/heroku/heroku-applink-python/CHANGELOG.md"
+
+
+[project.optional-dependencies]
+test = [
+    "pytest>=7.0.0",
+    "pytest-asyncio>=0.21.0",
+    "pytest-cov>=4.0.0",
+    "pytest-mock>=3.10.0",
+    "ruff>=0.1.0",
+    "coverage>=7.0.0"
+]
+
+[tool.pytest.ini_options]
+testpaths = ["tests"]
+python_files = ["test_*.py"]
+addopts = "--cov=heroku_applink.data_api --cov-report=term-missing -v"
+asyncio_mode = "auto"
+asyncio_default_fixture_loop_scope = "function"
+
+[tool.ruff]
+line-length = 88
+fix = true
+
+[tool.ruff.lint]
+select = ["E", "W", "F"]
+ignore = ["E501"]
+
+[tool.tox]
+legacy_tox_ini = """
+[tox]
+envlist = py310, py311, py312, py313
+skip_missing_interpreters = true
+
+[testenv]
+extras = test
+setenv =
+    COVERAGE_FILE = .coverage.{envname}
+commands_pre =
+    coverage erase
+commands =
+    ruff check .
+    pytest
+    coverage report --fail-under=80 --show-missing
+    coverage erase
+"""
\ No newline at end of file
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..0210cd1
--- /dev/null
+++ b/tests/__init__.py
@@ -0,0 +1,3 @@
+"""
+Test package for heroku_applink.
+"""
diff --git a/tests/data_api/__init__.py b/tests/data_api/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/data_api/test_data_api_exceptions.py b/tests/data_api/test_data_api_exceptions.py
new file mode 100644
index 0000000..f175222
--- /dev/null
+++ b/tests/data_api/test_data_api_exceptions.py
@@ -0,0 +1,58 @@
+import pytest
+from heroku_applink.data_api.exceptions import (
+    DataApiError,
+    SalesforceRestApiError,
+    InnerSalesforceRestApiError,
+    MissingFieldError,
+    ClientError,
+    UnexpectedRestApiResponsePayload,
+)
+
+def test_inner_salesforce_rest_api_error_str():
+    error = InnerSalesforceRestApiError(
+        message="Invalid field",
+        error_code="INVALID_FIELD",
+        fields=["Name"]
+    )
+    assert "INVALID_FIELD error" in str(error)
+    assert "Invalid field" in str(error)
+
+def test_salesforce_rest_api_error_str():
+    error = InnerSalesforceRestApiError(
+        message="Bad Request",
+        error_code="INVALID_FIELD",
+        fields=["Name"]
+    )
+    exc = SalesforceRestApiError(api_errors=[error])
+    output = str(exc)
+    assert "Salesforce REST API reported the following error(s)" in output
+    assert "INVALID_FIELD error" in output
+
+def test_salesforce_rest_api_error_multiple():
+    errors = [
+        InnerSalesforceRestApiError(message="Error1", error_code="ERR1", fields=[]),
+        InnerSalesforceRestApiError(message="Error2", error_code="ERR2", fields=[])
+    ]
+    exc = SalesforceRestApiError(api_errors=errors)
+    out = str(exc)
+    assert "ERR1 error" in out
+    assert "ERR2 error" in out
+
+def test_missing_field_error_raise():
+    with pytest.raises(MissingFieldError) as exc:
+        raise MissingFieldError("Missing field")
+    assert "Missing field" in str(exc.value)
+
+def test_client_error_raise():
+    with pytest.raises(ClientError) as exc:
+        raise ClientError("Connection failed")
+    assert "Connection failed" in str(exc.value)
+
+def test_unexpected_response_error_raise():
+    with pytest.raises(UnexpectedRestApiResponsePayload) as exc:
+        raise UnexpectedRestApiResponsePayload("Bad JSON format")
+    assert "Bad JSON format" in str(exc.value)
+
+def test_base_data_api_error():
+    with pytest.raises(DataApiError):
+        raise DataApiError("Something failed")
diff --git a/tests/data_api/test_data_api_init.py b/tests/data_api/test_data_api_init.py
new file mode 100644
index 0000000..fe021d0
--- /dev/null
+++ b/tests/data_api/test_data_api_init.py
@@ -0,0 +1,148 @@
+# tests/data_api/test_data_api.py
+import pytest
+import orjson
+import aiohttp
+from unittest.mock import AsyncMock, patch, MagicMock
+from heroku_applink.data_api import DataAPI, Record, RecordQueryResult, UnitOfWork
+from heroku_applink.data_api.exceptions import ClientError, UnexpectedRestApiResponsePayload
+
+@pytest.fixture
+def data_api():
+    return DataAPI(
+        org_domain_url="https://example.salesforce.com",
+        api_version="v60.0",
+        access_token="token"
+    )
+
+@pytest.mark.asyncio
+async def test_query(data_api):
+    result = RecordQueryResult(done=True, total_size=1, records=[], next_records_url=None)
+    data_api._execute = AsyncMock(return_value=result)
+    response = await data_api.query("SELECT Id FROM Account")
+    assert response.done
+
+@pytest.mark.asyncio
+async def test_query_more_no_url(data_api):
+    result = RecordQueryResult(done=True, total_size=2, records=[], next_records_url=None)
+    response = await data_api.query_more(result)
+    assert response.total_size == 2
+
+@pytest.mark.asyncio
+async def test_create(data_api):
+    record = Record(type="Account", fields={"Name": "Test"})
+    data_api._execute = AsyncMock(return_value="001123")
+    result = await data_api.create(record)
+    assert result == "001123"
+
+@pytest.mark.asyncio
+async def test_update(data_api):
+    record = Record(type="Account", fields={"Id": "001123", "Name": "Updated"})
+    data_api._execute = AsyncMock(return_value="001123")
+    result = await data_api.update(record)
+    assert result == "001123"
+
+@pytest.mark.asyncio
+async def test_update_missing_id():
+    record = Record(type="Account", fields={"Name": "NoId"})
+    from heroku_applink.data_api._requests import UpdateRecordRestApiRequest
+    with pytest.raises(Exception):
+        UpdateRecordRestApiRequest(record)
+
+@pytest.mark.asyncio
+async def test_delete(data_api):
+    data_api._execute = AsyncMock(return_value="001123")
+    result = await data_api.delete("Account", "001123")
+    assert result == "001123"
+
+@pytest.mark.asyncio
+async def test_commit_unit_of_work(data_api):
+    uow = UnitOfWork()
+    ref = uow.register_create(Record(type="Account", fields={"Name": "A"}))
+    data_api._execute = AsyncMock(return_value={ref: "001ABC"})
+    result = await data_api.commit_unit_of_work(uow)
+    assert ref in result
+
+@pytest.mark.asyncio
+async def test_unit_of_work_multiple_ops():
+    uow = UnitOfWork()
+    create_ref = uow.register_create(Record(type="Account", fields={"Name": "A"}))
+    update_ref = uow.register_update(Record(type="Account", fields={"Id": "001", "Name": "B"}))
+    delete_ref = uow.register_delete("Account", "001")
+    assert create_ref != update_ref != delete_ref
+
+@pytest.mark.asyncio
+async def test_download_file(data_api):
+    with patch("aiohttp.ClientSession.request", new_callable=AsyncMock) as mock_request:
+        mock_response = MagicMock()
+        mock_response.read = AsyncMock(return_value=b"file-data")
+        mock_request.return_value = mock_response
+        result = await data_api._download_file("/path")
+        assert result == b"file-data"
+
+@pytest.mark.asyncio
+async def test_default_headers(data_api):
+    headers = data_api._default_headers()
+    assert headers["Authorization"] == "Bearer token"
+
+@pytest.mark.asyncio
+async def test_create_session():
+    from heroku_applink.data_api import _create_session
+    session = _create_session()
+    assert isinstance(session, aiohttp.ClientSession)
+    await session.close()
+
+@pytest.mark.asyncio
+async def test_json_serialize():
+    from heroku_applink.data_api import _json_serialize
+    payload = _json_serialize({"key": "value"})
+    assert payload.content_type == "application/json"
+
+@pytest.mark.asyncio
+async def test_execute_client_error(data_api):
+    mock_req = MagicMock()
+    mock_req.url.return_value = "https://fake"
+    mock_req.http_method.return_value = "GET"
+    mock_req.request_body.return_value = None
+    mock_req.process_response = AsyncMock()
+
+    with patch("aiohttp.ClientSession.request", side_effect=aiohttp.ClientError("fail")):
+        with pytest.raises(ClientError):
+            await data_api._execute(mock_req)
+
+@pytest.mark.asyncio
+async def test_execute_with_invalid_json(data_api):
+    mock_req = MagicMock()
+    mock_req.url.return_value = "https://fake"
+    mock_req.http_method.return_value = "GET"
+    mock_req.request_body.return_value = None
+    mock_req.process_response = AsyncMock()
+
+    with patch("aiohttp.ClientSession.request", new_callable=AsyncMock) as mock_request:
+        mock_response = MagicMock()
+        mock_response.read = AsyncMock(return_value=b"not-valid-json")
+        mock_request.return_value = mock_response
+
+        with patch("orjson.loads", side_effect=orjson.JSONDecodeError("fail", "bad", 0)):
+            with pytest.raises(UnexpectedRestApiResponsePayload):
+                await data_api._execute(mock_req)
+
+@pytest.mark.asyncio
+async def test_commit_unit_of_work_with_update_and_delete(data_api):
+    uow = UnitOfWork()
+    update_ref = uow.register_update(Record(type="Account", fields={"Id": "001X", "Name": "Update"}))
+    delete_ref = uow.register_delete("Contact", "003Y")
+    mock_result = {update_ref: "001X", delete_ref: "003Y"}
+
+    data_api._execute = AsyncMock(return_value=mock_result)
+    result = await data_api.commit_unit_of_work(uow)
+    assert result[update_ref] == "001X"
+    assert result[delete_ref] == "003Y"
+
+
+@pytest.mark.asyncio
+async def test_create_session_cleanup():
+    from heroku_applink.data_api import _create_session
+    session = _create_session()
+    assert isinstance(session, aiohttp.ClientSession)
+    await session.close()
+    assert session.closed
diff --git a/tests/data_api/test_data_api_record.py b/tests/data_api/test_data_api_record.py
new file mode 100644
index 0000000..d3dd3f9
--- /dev/null
+++ b/tests/data_api/test_data_api_record.py
@@ -0,0 +1,15 @@
+from heroku_applink.data_api.record import Record, QueriedRecord, RecordQueryResult
+
+def test_record_structure():
+    rec = Record(type="Account", fields={"Name": "Acme"})
+    assert rec.type == "Account"
+    assert rec.fields["Name"] == "Acme"
+
+def test_queried_record_structure():
+    qrec = QueriedRecord(type="Contact", fields={"Name": "Joe"}, sub_query_results={})
+    assert qrec.type == "Contact"
+    assert qrec.fields["Name"] == "Joe"
+
+def test_record_query_result():
+    result = RecordQueryResult(done=False, total_size=10, records=[], next_records_url="/next")
+    assert result.total_size == 10
diff --git a/tests/data_api/test_data_api_reference_id.py b/tests/data_api/test_data_api_reference_id.py
new file mode 100644
index 0000000..4e69247
--- /dev/null
+++ b/tests/data_api/test_data_api_reference_id.py
@@ -0,0 +1,5 @@
+from heroku_applink.data_api import ReferenceId
+
+def test_reference_id_fields():
+    ref = ReferenceId(id="abc123")
+    assert ref.id == "abc123"
diff --git a/tests/data_api/test_data_api_requests.py b/tests/data_api/test_data_api_requests.py
new file mode 100644
index 0000000..fd34ce8
--- /dev/null
+++ b/tests/data_api/test_data_api_requests.py
@@ -0,0 +1,348 @@
+import pytest
+from unittest.mock import AsyncMock
+
+from heroku_applink.data_api._requests import (
+    CreateRecordRestApiRequest,
+    DeleteRecordRestApiRequest,
+    UpdateRecordRestApiRequest,
+    QueryRecordsRestApiRequest,
+    QueryNextRecordsRestApiRequest,
+    CompositeGraphRestApiRequest,
+    _normalize_field_value, _normalize_record_fields,
+    _is_binary_field, _parse_errors, _process_records_response,
+    _parse_record_query_result, _parse_queried_record
+)
+from heroku_applink.data_api.record import Record
+from heroku_applink.data_api.reference_id import ReferenceId
+from heroku_applink.data_api.exceptions import (
+    UnexpectedRestApiResponsePayload, InnerSalesforceRestApiError, SalesforceRestApiError
+)
+
+
+# Basic request tests
+@pytest.mark.asyncio
+async def test_create_record_request():
+    rec = Record(type="Account", fields={"Name": "Test"})
+    req = CreateRecordRestApiRequest(rec)
+    assert req.http_method() == "POST"
+    assert req.request_body() == {"Name": "Test"}
+
+@pytest.mark.asyncio
+async def test_update_record_request():
+    rec = Record(type="Account", fields={"Id": "abc", "Name": "Updated"})
+    req = UpdateRecordRestApiRequest(rec)
+    assert req.http_method() == "PATCH"
+    assert req.request_body() == {"Name": "Updated"}
+
+@pytest.mark.asyncio
+async def test_delete_record_request():
+    req = DeleteRecordRestApiRequest("Account", "123")
+    assert req.http_method() == "DELETE"
+    assert req.request_body() is None
+
+@pytest.mark.asyncio
+async def test_query_record_request():
+    req = QueryRecordsRestApiRequest("SELECT Id FROM Account", lambda url: b"")
+    assert req.http_method() == "GET"
+    assert req.request_body() is None
+
+@pytest.mark.asyncio
+async def test_query_next_record_request():
+    req = QueryNextRecordsRestApiRequest("/next", lambda url: b"")
+    assert req.http_method() == "GET"
+    assert req.request_body() is None
+
+
+# Field utils
+def test_normalize_field_value_reference_id():
+    ref = ReferenceId(id="ref1")
+    assert _normalize_field_value(ref) == "@{ref1.id}"
+
+def test_normalize_field_value_bytes():
+    assert _normalize_field_value(b"abc") == "YWJj"
+
+def test_normalize_field_value_default():
+    assert _normalize_field_value(123) == 123
+
+def test_normalize_record_fields():
+    ref = ReferenceId(id="ref2")
+    result = _normalize_record_fields({"key1": b"data", "key2": ref})
+    assert result["key1"] == "ZGF0YQ=="
+    assert result["key2"] == "@{ref2.id}"
+
+def test_is_binary_field_true():
+    assert _is_binary_field("ContentVersion", "VersionData")
+
+def test_is_binary_field_false():
+    assert not _is_binary_field("Account", "Name")
+
+
+# Error parsing
+def test_parse_errors_valid_list():
+    errors = [
+        {"message": "msg", "errorCode": "400", "fields": ["Name"]},
+        {"message": "another", "errorCode": "401"}
+    ]
+    parsed = _parse_errors(errors)
+    assert isinstance(parsed[0], InnerSalesforceRestApiError)
+    assert parsed[1].fields == []
+
+def test_parse_errors_invalid_type():
+    with pytest.raises(UnexpectedRestApiResponsePayload):
+        _parse_errors({"not": "a list"})
+
+
+# Record parsing
+@pytest.mark.asyncio
+async def test_process_records_response_invalid_type():
+    with pytest.raises(UnexpectedRestApiResponsePayload):
+        await _process_records_response(200, "not-a-dict", lambda x: b"")
+
+@pytest.mark.asyncio
+async def test_parse_record_query_result_basic():
+    json_body = {
+        "done": True,
+        "totalSize": 1,
+        "records": [
+            {"attributes": {"type": "Account"}, "Name": "Test"}
+        ]
+    }
+    result = await _parse_record_query_result(json_body, lambda x: b"")
+    assert result.total_size == 1
+    assert result.records[0].fields["Name"] == "Test"
+
+@pytest.mark.asyncio
+async def test_parse_queried_record_binary_field():
+    json_body = {
+        "attributes": {"type": "ContentVersion"},
+        "VersionData": "/binary/url"
+    }
+
+    async def mock_download(url):
+        return b"binary-content"
+
+    result = await _parse_queried_record(json_body, mock_download)
+    assert result.fields["VersionData"] == b"binary-content"
+
+
+# CompositeGraphRestApiRequest edge cases
+@pytest.mark.asyncio
+async def test_composite_graph_response_error_propagation():
+    ref_id = ReferenceId(id="ref-1")
+    sub_request = AsyncMock()
+    sub_request.url.return_value = "/fake"
+    sub_request.http_method.return_value = "POST"
+    sub_request.request_body.return_value = {"key": "value"}
+    sub_request.process_response.side_effect = SalesforceRestApiError(api_errors=[])
+
+    req = CompositeGraphRestApiRequest("60.0", {ref_id: sub_request})
+
+    error_payload = [
+        {
+            "message": "Invalid input",
+            "errorCode": "INVALID_INPUT",
+            "fields": ["Name"]
+        }
+    ]
+
+    with pytest.raises(SalesforceRestApiError) as exc_info:
+        await req.process_response(400, error_payload)
+
+    assert "INVALID_INPUT" in str(exc_info.value)
+    assert "Invalid input" in str(exc_info.value)
+
+@pytest.mark.asyncio
+async def test_composite_graph_invalid_structure():
+    ref_id = ReferenceId(id="r1")
+    sub_request = AsyncMock()
+    sub_request.url.return_value = "/fake"
+    sub_request.http_method.return_value = "POST"
+    sub_request.request_body.return_value = {"foo": "bar"}
+    sub_request.process_response = AsyncMock()
+
+    req = CompositeGraphRestApiRequest("60.0", {ref_id: sub_request})
+
+    with pytest.raises(UnexpectedRestApiResponsePayload):
+        await req.process_response(200, [])
+
+@pytest.mark.asyncio
+async def test_query_records_url():
+    soql = "SELECT Id, Name FROM Account"
+    req = QueryRecordsRestApiRequest(soql, download_file_fn=lambda x: b"")  # dummy download fn
+
+    org_domain_url = "https://example.salesforce.com"
+    api_version = "60.0"
+
+    expected_url = "https://example.salesforce.com/services/data/v60.0/query?q=SELECT+Id%2C+Name+FROM+Account"
+    assert req.url(org_domain_url, api_version) == expected_url
+
+@pytest.mark.asyncio
+async def test_create_record_process_response_success():
+    record = Record(type="Account", fields={"Name": "Test"})
+    req = CreateRecordRestApiRequest(record)
+
+    status_code = 201
+    json_body = {"id": "001XYZ"}
+
+    result = await req.process_response(status_code, json_body)
+    assert result == "001XYZ"
+
+
+@pytest.mark.asyncio
+async def test_create_record_process_response_non_201():
+    record = Record(type="Account", fields={"Name": "Test"})
+    req = CreateRecordRestApiRequest(record)
+
+    status_code = 400
+    json_body = [
+        {
+            "message": "Invalid field",
+            "errorCode": "INVALID_FIELD",
+            "fields": ["Name"]
+        }
+    ]
+
+    with pytest.raises(SalesforceRestApiError) as exc:
+        await req.process_response(status_code, json_body)
+
+    assert "INVALID_FIELD" in str(exc.value)
+
+
+@pytest.mark.asyncio
+async def test_create_record_process_response_invalid_structure():
+    record = Record(type="Account", fields={"Name": "Test"})
+    req = CreateRecordRestApiRequest(record)
+
+    status_code = 201
+    json_body = "not-a-dict"  # Should trigger UnexpectedRestApiResponsePayload
+
+    with pytest.raises(UnexpectedRestApiResponsePayload):
+        await req.process_response(status_code, json_body)
+
+@pytest.mark.asyncio
+async def test_update_record_process_response_success():
+    record = Record(type="Account", fields={"Id": "001ABC", "Name": "Updated"})
+    req = UpdateRecordRestApiRequest(record)
+
+    status_code = 204
+    json_body = None  # Body isn't used when status code is 204
+
+    result = await req.process_response(status_code, json_body)
+    assert result == "001ABC"
+
+
+@pytest.mark.asyncio
+async def test_update_record_process_response_failure():
+    record = Record(type="Account", fields={"Id": "001XYZ", "Name": "BadData"})
+    req = UpdateRecordRestApiRequest(record)
+
+    status_code = 400
+    json_body = [
+        {
+            "message": "Field error",
+            "errorCode": "INVALID_FIELD",
+            "fields": ["Name"]
+        }
+    ]
+
+    with pytest.raises(SalesforceRestApiError) as exc_info:
+        await req.process_response(status_code, json_body)
+
+    assert "INVALID_FIELD" in str(exc_info.value)
+
+@pytest.mark.asyncio
+async def test_process_response_dict_json_body_success():
+    # Setup a reference ID and a mock sub-request
+    ref_id = ReferenceId(id="r1")
+    mock_sub_request = AsyncMock()
+    mock_sub_request.process_response = AsyncMock(return_value="success-id")
+
+    # Create CompositeGraphRestApiRequest
+    request = CompositeGraphRestApiRequest("60.0", {ref_id: mock_sub_request})
+
+    # json_body is a dict → should trigger the isinstance condition
+    json_body = {
+        "graphs": [{
+            "graphResponse": {
+                "compositeResponse": [
+                    {
+                        "referenceId": "r1",
+                        "httpStatusCode": 200,
+                        "body": {"id": "success-id"}
+                    }
+                ]
+            }
+        }]
+    }
+
+    result = await request.process_response(200, json_body)
+    assert result[ref_id] == "success-id"
+    mock_sub_request.process_response.assert_awaited_once_with(200, {"id": "success-id"})
+
+@pytest.mark.asyncio
+async def test_process_response_non_dict_json_body():
+    ref_id = ReferenceId(id="r1")
+    mock_sub_request = AsyncMock()
+    request = CompositeGraphRestApiRequest("60.0", {ref_id: mock_sub_request})
+
+    # json_body is a list, so isinstance(json_body, dict) will be False
+    json_body = [{"invalid": "structure"}]
+
+    with pytest.raises(UnexpectedRestApiResponsePayload):
+        await request.process_response(200, json_body)
+
+@pytest.mark.asyncio
+async def test_process_records_response_error_path():
+    with pytest.raises(SalesforceRestApiError):
+        await _process_records_response(
+            400,
+            [{"message": "Invalid", "errorCode": "400"}],
+            lambda x: b""
+        )
+
+
+@pytest.mark.asyncio
+async def test_parse_record_query_result_with_subquery():
+    json_body = {
+        "done": True,
+        "totalSize": 1,
+        "records": [
+            {
+                "attributes": {"type": "Account"},
+                "Name": "Main",
+                "SubQuery": {
+                    "done": True,
+                    "totalSize": 1,
+                    "records": [
+                        {"attributes": {"type": "Contact"}, "FirstName": "John"}
+                    ]
+                }
+            }
+        ]
+    }
+
+    result = await _parse_record_query_result(json_body, lambda x: b"")
+    assert result.total_size == 1
+    assert "SubQuery" in result.records[0].sub_query_results
+    sub = result.records[0].sub_query_results["SubQuery"]
+    assert sub.records[0].fields["FirstName"] == "John"
+
+
+def test_parse_errors_key_fields_present_and_missing():
+    errors = [
+        {"message": "One", "errorCode": "123", "fields": ["Name"]},
+        {"message": "Two", "errorCode": "456"}
+    ]
+    parsed = _parse_errors(errors)
+    assert parsed[0].fields == ["Name"]
+    assert parsed[1].fields == []
+
+
+@pytest.mark.asyncio
+async def test_parse_queried_record_with_unexpected_field_type():
+    json_body = {
+        "attributes": {"type": "Account"},
+        "SomeField": 42
+    }
+    result = await _parse_queried_record(json_body, lambda x: b"")
+    assert result.fields["SomeField"] == 42
diff --git a/tests/data_api/test_data_api_unit_of_work.py b/tests/data_api/test_data_api_unit_of_work.py
new file mode 100644
index 0000000..3555d2b
--- /dev/null
+++ b/tests/data_api/test_data_api_unit_of_work.py
@@ -0,0 +1,18 @@
+from heroku_applink.data_api import UnitOfWork, Record
+
+def test_register_create():
+    uow = UnitOfWork()
+    record = Record(type="Account", fields={"Name": "Test"})
+    ref = uow.register_create(record)
+    assert ref.id.startswith("referenceId")
+
+def test_register_update():
+    uow = UnitOfWork()
+    record = Record(type="Account", fields={"Id": "123", "Name": "Test"})
+    ref = uow.register_update(record)
+    assert ref.id.startswith("referenceId")
+
+def test_register_delete():
+    uow = UnitOfWork()
+    ref = uow.register_delete("Account", "123")
+    assert ref.id.startswith("referenceId")
diff --git a/tests/test_context.py b/tests/test_context.py
new file mode 100644
index 0000000..01a795a
--- /dev/null
+++ b/tests/test_context.py
@@ -0,0 +1,76 @@
+import base64
+import json
+import pytest
+from heroku_applink.context import User, Org, ClientContext
+
+class FakeDataAPI:
+    """Fake DataAPI class for testing without real dependencies."""
+    def __init__(self, org_domain_url, api_version, access_token):
+        self.org_domain_url = org_domain_url
+        self.api_version = api_version
+        self.access_token = access_token
+
+@pytest.fixture(autouse=True)
+def patch_data_api(monkeypatch):
+    # Patch heroku_applink.context.DataAPI with our fake
+    monkeypatch.setattr("heroku_applink.context.DataAPI", FakeDataAPI)
+
+def test_user_creation():
+    user = User(id="005JS000000H123", username="user@example.tld")
+    assert user.id == "005JS000000H123"
+    assert user.username == "user@example.tld"
+
+def test_org_creation():
+    user = User(id="005JS000000H123", username="user@example.tld")
+    org = Org(id="00DJS0000000123ABC", domain_url="https://example-domain.my.salesforce.com", user=user)
+    assert org.id == "00DJS0000000123ABC"
+    assert org.domain_url.startswith("https://")
+    assert isinstance(org.user, User)
+
+def test_client_context_creation():
+    user = User(id="005JS000000H123", username="user@example.tld")
+    org = Org(id="00DJS0000000123ABC", domain_url="https://example-domain.my.salesforce.com", user=user)
+    ctx = ClientContext(
+        org=org,
+        data_api=FakeDataAPI(
+            org_domain_url=org.domain_url,
+            api_version="v57.0",
+            access_token="fake_token",
+        ),
+        request_id="req-123",
+        access_token="fake_token",
+        api_version="v57.0",
+        namespace="mynamespace",
+    )
+    assert ctx.org.id == "00DJS0000000123ABC"
+    assert ctx.data_api.api_version == "v57.0"
+    assert ctx.request_id == "req-123"
+
+def test_client_context_from_header():
+    payload = {
+        "orgId": "00DJS0000000123ABC",
+        "orgDomainUrl": "https://example-domain.my.salesforce.com",
+        "userContext": {
+            "userId": "005JS000000H123",
+            "username": "user@example.tld",
+        },
+        "requestId": "req-456",
+        "accessToken": "access-token-xyz",
+        "apiVersion": "v57.0",
+        "namespace": "ns",
+    }
+    encoded = base64.b64encode(json.dumps(payload).encode()).decode()
+
+    ctx = ClientContext.from_header(encoded)
+
+    assert ctx.org.id == "00DJS0000000123ABC"
+    assert ctx.org.user.username == "user@example.tld"
+    assert ctx.request_id == "req-456"
+    assert ctx.access_token == "access-token-xyz"
+    assert ctx.api_version == "v57.0"
+    assert ctx.namespace == "ns"
+
+def test_client_context_from_header_invalid():
+    # Provide bad Base64 encoded string
+    with pytest.raises(Exception):
+        ClientContext.from_header("not-valid-base64")
diff --git a/tests/test_middleware.py b/tests/test_middleware.py
new file mode 100644
index 0000000..45a20ef
--- /dev/null
+++ b/tests/test_middleware.py
@@ -0,0 +1,76 @@
+import pytest
+from heroku_applink.middleware import (
+    IntegrationWsgiMiddleware,
+    IntegrationAsgiMiddleware,
+    from_request,
+    client_context,
+)
+from heroku_applink.context import ClientContext, Org, User
+from unittest.mock import Mock, patch, AsyncMock
+
+@pytest.fixture
+def mock_client_context():
+    return ClientContext(
+        org=Org(
+            id="00DJS0000000123ABC",
+            domain_url="https://example-domain-url.my.salesforce.com",
+            user=User(id="005JS000000H123", username="user@example.tld"),
+        ),
+        request_id="test-request-id",
+        access_token="test-access-token",
+        api_version="v1",
+        namespace="test-namespace",
+        data_api=Mock(),
+    )
+
+def test_wsgi_middleware_initialization():
+    """Test that the WSGI middleware can be initialized."""
+    mock_get_response = Mock()
+    middleware = IntegrationWsgiMiddleware(mock_get_response)
+    assert middleware.get_response == mock_get_response
+
+def test_wsgi_middleware_call(mock_client_context):
+    """Test the WSGI middleware call method."""
+    mock_get_response = Mock(return_value="response")
+    middleware = IntegrationWsgiMiddleware(mock_get_response)
+
+    mock_request = Mock()
+    mock_request.headers = {"x-client-context": "dummy-header"}
+
+    with patch("heroku_applink.middleware.from_request", return_value=mock_client_context):
+        response = middleware(mock_request)
+        assert response == "response"
+        assert client_context.get() == mock_client_context
+
+def test_asgi_middleware_initialization():
+    """Test that the ASGI middleware can be initialized."""
+    mock_app = AsyncMock()
+    middleware = IntegrationAsgiMiddleware(mock_app)
+    assert middleware.app == mock_app
+
+@pytest.mark.asyncio
+async def test_asgi_middleware_call(mock_client_context):
+    """Test the ASGI middleware call method."""
+    mock_app = AsyncMock()
+    middleware = IntegrationAsgiMiddleware(mock_app)
+
+    scope = {
+        "type": "http",
+        "headers": [(b"x-client-context", b"dummy-header")],
+    }
+    receive = Mock()
+    send = Mock()
+
+    with patch("heroku_applink.middleware.ClientContext.from_header", return_value=mock_client_context):
+        await middleware(scope, receive, send)
+        assert client_context.get() == mock_client_context
+        assert scope["client-context"] == mock_client_context
+        mock_app.assert_called_once_with(scope, receive, send)
+
+def test_from_request_missing_header():
+    """Test that from_request raises ValueError when header is missing."""
+    mock_request = Mock()
+    mock_request.headers = {}
+
+    with pytest.raises(ValueError, match="x-client-context not set"):
+        from_request(mock_request)


Reviews:
user: login: binarycleric
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-30T15:38:35Z
user: login: gopi-heroku
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-30T15:40:40Z
user: login: binarycleric
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-30T15:40:51Z
user: login: gopi-heroku
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-30T15:46:07Z
user: login: binarycleric
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-30T20:30:48Z
user: login: sfdcarter
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-06T16:54:37Z
user: login: sfdcarter
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-06T16:55:01Z
user: login: sfdcarter
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-06T16:56:46Z
user: login: sfdcarter
body: Looks solid. Please update the build to use pyproject.yaml for all config, and at least try to use `tox-uv` to run the tests.

would appreciate a second reviewer on the actual test cases. context & middleware look good to me, but only have the data api a cursory glance. 
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-05-06T17:01:30Z
user: login: edmorley
body: (cancelling review; I don't have any context for this project and am backlogged until PyCon)
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-07T08:09:35Z
user: login: binarycleric
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-08T15:17:57Z
user: login: binarycleric
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-08T18:53:34Z
user: login: gopi-heroku
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-08T19:15:02Z
user: login: binarycleric
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-08T20:47:00Z
user: login: binarycleric
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-08T20:47:23Z

Review comments:
diff_hunk: @@ -0,0 +1,53 @@
+# .github/workflows/ci.yml
+name: CI
+
+on:
+  push:
+    branches: [main]
+  pull_request:
+    branches: [main]
+
+jobs:
+  test:
+    name: Test on Python ${{ matrix.python-version }}
+    runs-on: ubuntu-latest
path: .github/workflows/ci.yml
user: login: binarycleric
body: @gopi-heroku FYI, we have to use Salesforce GitHub action runners, even for public repos. See: https://salesforce.quip.com/bu6UA0KImOxJ 
created_at: 2025-04-30T15:38:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,53 @@
+# .github/workflows/ci.yml
+name: CI
+
+on:
+  push:
+    branches: [main]
+  pull_request:
+    branches: [main]
+
+jobs:
+  test:
+    name: Test on Python ${{ matrix.python-version }}
+    runs-on: ubuntu-latest
path: .github/workflows/ci.yml
user: login: gopi-heroku
body: Gotcha, I will update the runner. Not an important question but do you need me to break this into 2 PRs one for GH actions and other for test suite?
created_at: 2025-04-30T15:40:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,53 @@
+# .github/workflows/ci.yml
+name: CI
+
+on:
+  push:
+    branches: [main]
+  pull_request:
+    branches: [main]
+
+jobs:
+  test:
+    name: Test on Python ${{ matrix.python-version }}
+    runs-on: ubuntu-latest
path: .github/workflows/ci.yml
user: login: binarycleric
body: Oh wait, I'm incorrect. The doc says for public repos we can continue to use public runners where possible. Pretty sure running unit tests and pushing package updates are well within the capabilities of the public runners.

Sorry for any confusion.
created_at: 2025-04-30T15:40:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,53 @@
+# .github/workflows/ci.yml
+name: CI
+
+on:
+  push:
+    branches: [main]
+  pull_request:
+    branches: [main]
+
+jobs:
+  test:
+    name: Test on Python ${{ matrix.python-version }}
+    runs-on: ubuntu-latest
path: .github/workflows/ci.yml
user: login: gopi-heroku
body: No worries Jon, Thanks for the doc 
created_at: 2025-04-30T15:46:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,53 @@
+# .github/workflows/ci.yml
+name: CI
+
+on:
+  push:
+    branches: [main]
+  pull_request:
+    branches: [main]
+
+jobs:
+  test:
+    name: Test on Python ${{ matrix.python-version }}
+    runs-on: ubuntu-latest
path: .github/workflows/ci.yml
user: login: binarycleric
body: Not a problem. I think it is fine to keep these as a single PR since we're writing tests and introducing the GitHub action for them.
created_at: 2025-04-30T20:30:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,52 @@
+name: CI
+
+on:
+  push:
+    branches: [main]
+  pull_request:
+    branches: [main]
+
+jobs:
+  test:
+    name: Test on Python ${{ matrix.python-version }}
+    runs-on: ubuntu-latest
+
+    strategy:
+      matrix:
+        python-version: ["3.10", "3.11", "3.12", "3.13"]
+      fail-fast: false
+
+    steps:
+      - uses: actions/checkout@v4
+
+      - name: Set up Python ${{ matrix.python-version }}
+        uses: actions/setup-python@v4
+        with:
+          python-version: ${{ matrix.python-version }}
+          cache: pip
+
+      - name: Install tox
+        run: pip install tox
path: .github/workflows/ci.yml
user: login: sfdcarter
body: would much prefer we use `uv` for everything in this project. there is a uv/tox plugin. 
created_at: 2025-05-06T16:54:37Z
author_association: COLLABORATOR
diff_hunk: @@ -13,6 +11,8 @@
 from .record import QueriedRecord, Record, RecordQueryResult
 from .reference_id import ReferenceId
 
+"Context > Org > DataAPI"
path: heroku_applink/data_api/_requests.py
user: login: sfdcarter
body: ah, just remove this i think. or turn it into a real docstring. 
created_at: 2025-05-06T16:55:01Z
author_association: COLLABORATOR
path: tox.ini
user: login: sfdcarter
body: this can all go in pyproject.toml... see 
```pyproject.toml with the tool.tox table, having legacy_tox_ini key (containing INI),```

see https://tox.wiki/en/latest/config.html#discovery-and-file-types
created_at: 2025-05-06T16:56:46Z
author_association: COLLABORATOR
diff_hunk: @@ -1,39 +1,90 @@
 name: Package Library
+
 permissions:
   contents: write
   id-token: write
+
 on:
   push:
     branches: [ "main" ]
-    tags: 
+    tags:
       - "*"
   pull_request:
     branches: [ "main" ]
-  
+
 jobs:
   build:
-    runs-on: pub-hk-ubuntu-24.04-ip
+    runs-on: ubuntu-latest  # Changed to ubuntu-latest to match the most common GitHub Actions runner environment
+
     steps:
-    - uses: actions/checkout@v4
-    - name: Set up Python 
-      uses: actions/setup-python@v3
-    - name: Install uv
-      uses: astral-sh/setup-uv@v5
-      with:
-        enable-cache: true
-        cache-dependency-glob: "uv.lock"
-    - name: Install the project
-      run: uv sync --all-extras --dev
-    - name: Build the artifact in dist/
-      run: uv build
-    - name: Release to Github
-      uses: softprops/action-gh-release@v2
-      if: startsWith(github.ref, 'refs/tags/')
-      with:
-          files: | 
+      - uses: actions/checkout@v4
+
+      - name: Set up Python 
+        uses: actions/setup-python@v3
+
+      - name: Install uv
+        uses: astral-sh/setup-uv@v5
+        with:
+          enable-cache: true
+          cache-dependency-glob: "uv.lock"
+
+      - name: Install the project
+        run: uv sync --all-extras --dev
+
+      - name: Build the artifact in dist/
+        run: uv build
+
+      - name: Release to GitHub
+        uses: softprops/action-gh-release@v2
+        if: startsWith(github.ref, 'refs/tags/')
+        with:
+          files: |
             dist/*.whl
             dist/*.tar.gz
-    - name: Release to pypi
-      run: uv publish
-      if: startsWith(github.ref, 'refs/tags/')
-      
\ No newline at end of file
+
+      - name: Release to PyPI
+        run: uv publish
+        if: startsWith(github.ref, 'refs/tags/')
+
+  test:
path: .github/workflows/package.yml
user: login: binarycleric
body: Can we set this up in a way that we don't release a new package to PyPI unless all the tests pass?
created_at: 2025-05-08T15:17:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -25,3 +25,92 @@ Generate the documentation.
 ```shell
 $ uv run pdoc3 --template-dir templates/python heroku_applink -o docs --force
 ```
+
+## Development
path: README.md
user: login: binarycleric
body: 👍👍👍 Thank you!
created_at: 2025-05-08T18:53:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,39 +1,90 @@
 name: Package Library
+
 permissions:
   contents: write
   id-token: write
+
 on:
   push:
     branches: [ "main" ]
-    tags: 
+    tags:
       - "*"
   pull_request:
     branches: [ "main" ]
-  
+
 jobs:
   build:
-    runs-on: pub-hk-ubuntu-24.04-ip
+    runs-on: ubuntu-latest  # Changed to ubuntu-latest to match the most common GitHub Actions runner environment
+
     steps:
-    - uses: actions/checkout@v4
-    - name: Set up Python 
-      uses: actions/setup-python@v3
-    - name: Install uv
-      uses: astral-sh/setup-uv@v5
-      with:
-        enable-cache: true
-        cache-dependency-glob: "uv.lock"
-    - name: Install the project
-      run: uv sync --all-extras --dev
-    - name: Build the artifact in dist/
-      run: uv build
-    - name: Release to Github
-      uses: softprops/action-gh-release@v2
-      if: startsWith(github.ref, 'refs/tags/')
-      with:
-          files: | 
+      - uses: actions/checkout@v4
+
+      - name: Set up Python 
+        uses: actions/setup-python@v3
+
+      - name: Install uv
+        uses: astral-sh/setup-uv@v5
+        with:
+          enable-cache: true
+          cache-dependency-glob: "uv.lock"
+
+      - name: Install the project
+        run: uv sync --all-extras --dev
+
+      - name: Build the artifact in dist/
+        run: uv build
+
+      - name: Release to GitHub
+        uses: softprops/action-gh-release@v2
+        if: startsWith(github.ref, 'refs/tags/')
+        with:
+          files: |
             dist/*.whl
             dist/*.tar.gz
-    - name: Release to pypi
-      run: uv publish
-      if: startsWith(github.ref, 'refs/tags/')
-      
\ No newline at end of file
+
+      - name: Release to PyPI
+        run: uv publish
+        if: startsWith(github.ref, 'refs/tags/')
+
+  test:
path: .github/workflows/package.yml
user: login: gopi-heroku
body: We can do that Jon, I have no idea about releasing to PyPI, I will add it in a separate PR if thats okay?
created_at: 2025-05-08T19:15:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,39 +1,90 @@
 name: Package Library
+
 permissions:
   contents: write
   id-token: write
+
 on:
   push:
     branches: [ "main" ]
-    tags: 
+    tags:
       - "*"
   pull_request:
     branches: [ "main" ]
-  
+
 jobs:
   build:
-    runs-on: pub-hk-ubuntu-24.04-ip
+    runs-on: ubuntu-latest  # Changed to ubuntu-latest to match the most common GitHub Actions runner environment
+
     steps:
-    - uses: actions/checkout@v4
-    - name: Set up Python 
-      uses: actions/setup-python@v3
-    - name: Install uv
-      uses: astral-sh/setup-uv@v5
-      with:
-        enable-cache: true
-        cache-dependency-glob: "uv.lock"
-    - name: Install the project
-      run: uv sync --all-extras --dev
-    - name: Build the artifact in dist/
-      run: uv build
-    - name: Release to Github
-      uses: softprops/action-gh-release@v2
-      if: startsWith(github.ref, 'refs/tags/')
-      with:
-          files: | 
+      - uses: actions/checkout@v4
+
+      - name: Set up Python 
+        uses: actions/setup-python@v3
+
+      - name: Install uv
+        uses: astral-sh/setup-uv@v5
+        with:
+          enable-cache: true
+          cache-dependency-glob: "uv.lock"
+
+      - name: Install the project
+        run: uv sync --all-extras --dev
+
+      - name: Build the artifact in dist/
+        run: uv build
+
+      - name: Release to GitHub
+        uses: softprops/action-gh-release@v2
+        if: startsWith(github.ref, 'refs/tags/')
+        with:
+          files: |
             dist/*.whl
             dist/*.tar.gz
-    - name: Release to pypi
-      run: uv publish
-      if: startsWith(github.ref, 'refs/tags/')
-      
\ No newline at end of file
+
+      - name: Release to PyPI
+        run: uv publish
+        if: startsWith(github.ref, 'refs/tags/')
+
+  test:
path: .github/workflows/package.yml
user: login: binarycleric
body: Yeah, that's fine with me.
created_at: 2025-05-08T20:47:00Z
author_association: CONTRIBUTOR

PR comments:
user: login: gopi-heroku
created_at: 2025-05-06T20:22:12Z
body: > Looks solid. Please update the build to use pyproject.yaml for all config, and at least try to use `tox-uv` to run the tests.
> 
> would appreciate a second reviewer on the actual test cases. context & middleware look good to me, but only have the data api a cursory glance.

Hey @sfdcarter thanks for the feedback. I moved tox.ini to pyproject.toml and used tox-uv for the tests. Can you please let me know if I need to add the uv.lock file to the version control. I am little confused about it.
author_association: CONTRIBUTOR
user: login: binarycleric
created_at: 2025-05-07T20:00:54Z
body: @gopi-heroku Can you add a section to the `README.md` about how to run the test suite, lint, etc?
author_association: CONTRIBUTOR
user: login: binarycleric
created_at: 2025-05-08T15:12:08Z
body: @gopi-heroku Additionally, can you include docs on how to run a single test on a single Python version? I know that may feel overkill but I just want to make sure it is clear for developers who aren't 100% familiar with Python's tooling.
author_association: CONTRIBUTOR
user: login: gopi-heroku
created_at: 2025-05-08T21:37:36Z
body: Merged Thank you @binarycleric 
author_association: CONTRIBUTOR