From 2f4bdcde17edf6e7a1ebbdde2e3f4f88e649e520 Mon Sep 17 00:00:00 2001 From: Shubham Bisht Date: Mon, 10 Aug 2026 10:00:00 +0530 Subject: [PATCH 1/5] feat: implement PhotonClient.submit --- pyproject.toml | 1 + src/photon/__init__.py | 4 + src/photon/client.py | 232 ++++++++++++++++++++++ src/photon/models/__init__.py | 7 + src/photon/models/submission.py | 57 ++++++ tests/test_submit.py | 335 ++++++++++++++++++++++++++++++++ 6 files changed, 636 insertions(+) create mode 100644 src/photon/client.py create mode 100644 src/photon/models/__init__.py create mode 100644 src/photon/models/submission.py create mode 100644 tests/test_submit.py diff --git a/pyproject.toml b/pyproject.toml index 8adb549..2a85b56 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,7 @@ classifiers = [ ] dependencies = [ "httpx>=0.27", + "pydantic>=2", ] [project.optional-dependencies] diff --git a/src/photon/__init__.py b/src/photon/__init__.py index 29356fa..98f4d5e 100644 --- a/src/photon/__init__.py +++ b/src/photon/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations from ._version import __version__ +from .client import PhotonClient from .config import Config from .constants import DocType, Environment from .exceptions import ( @@ -16,6 +17,7 @@ PhotonError, QuotaExceededError, ) +from .models import Submission __all__ = [ "APIError", @@ -27,8 +29,10 @@ "ExtractionTimeoutError", "InvalidRequestError", "NotReadyError", + "PhotonClient", "PhotonConnectionError", "PhotonError", "QuotaExceededError", + "Submission", "__version__", ] diff --git a/src/photon/client.py b/src/photon/client.py new file mode 100644 index 0000000..4958065 --- /dev/null +++ b/src/photon/client.py @@ -0,0 +1,232 @@ +"""The public synchronous client. + +:class:`PhotonClient` is the SDK's front door: configure it once with the five +Photon credentials, then call its methods. HTTP mechanics and error +classification live in :mod:`photon._transport`; this module owns argument +validation, request shaping, and response models. +""" + +from __future__ import annotations + +import contextlib +import os +import re +from types import TracebackType +from typing import IO, TYPE_CHECKING, Any, cast + +from ._transport import Transport +from .config import Config +from .constants import ( + DEFAULT_BACKOFF_FACTOR, + DEFAULT_MAX_RETRIES, + DEFAULT_TIMEOUT, + SUBMIT_PATH, + DocType, + Environment, +) +from .models import Submission + +if TYPE_CHECKING: + # typing.Self only exists from 3.11; type checkers bundle typing_extensions, + # so this needs no runtime dependency. + from typing_extensions import Self + +__all__ = ["PhotonClient"] + +# What ``submit`` accepts as a document: a filesystem path, an open binary file +# object, or the file's bytes. +DocumentInput = str | os.PathLike[str] | IO[bytes] | bytes + +# The multipart form field the API expects the uploaded file in, whatever the +# actual file type. +_FILE_FIELD = "pdf" + +_SUBACCOUNT_MAX_LEN = 50 +_SUBACCOUNT_RE = re.compile(r"^[A-Za-z0-9-]+$") + + +class PhotonClient: + """Synchronous client for the Photon Commerce API. + + Use it as a context manager, or call :meth:`close` when finished, so the + connection pool is released. + + Args: + client_id: The ``CLIENT-ID`` credential. + username: The account username (email). + api_key: The API key. + password: The ``PASSWORD`` credential. + secret_key: The ``SECRET-KEY`` credential. + environment: Which API environment to target, ``"sandbox"`` (default) + or ``"production"``. + base_url: Overrides the environment's base URL when set. + timeout: Per-request timeout, in seconds. + max_retries: How many times to retry transient failures. + backoff_factor: Base delay (seconds) for exponential retry backoff. + + Raises: + ConfigurationError: If a credential is missing or the environment is + unknown. + """ + + def __init__( + self, + client_id: str = "", + username: str = "", + api_key: str = "", + password: str = "", + secret_key: str = "", + *, + environment: Environment | str = Environment.SANDBOX, + base_url: str | None = None, + timeout: float = DEFAULT_TIMEOUT, + max_retries: int = DEFAULT_MAX_RETRIES, + backoff_factor: float = DEFAULT_BACKOFF_FACTOR, + ) -> None: + self.config = Config( + client_id=client_id, + username=username, + api_key=api_key, + password=password, + secret_key=secret_key, + # Config coerces a string environment itself, with a better error. + environment=cast(Environment, environment), + base_url=base_url or "", + timeout=timeout, + max_retries=max_retries, + backoff_factor=backoff_factor, + ) + self._transport = Transport(self.config) + + @classmethod + def from_env(cls, **overrides: Any) -> PhotonClient: + """Build a client from ``PHOTON_*`` environment variables. + + See :meth:`Config.from_env` for the variables read. Explicit keyword + ``overrides`` take precedence over the environment, e.g. + ``PhotonClient.from_env(environment="production")``. + """ + config = Config.from_env(**overrides) + return cls( + client_id=config.client_id, + username=config.username, + api_key=config.api_key, + password=config.password, + secret_key=config.secret_key, + environment=config.environment, + base_url=config.base_url, + timeout=config.timeout, + max_retries=config.max_retries, + backoff_factor=config.backoff_factor, + ) + + def submit( + self, + document: DocumentInput | None = None, + *, + doctype: DocType | str = DocType.INVOICE, + url: str | None = None, + webhook_url: str | None = None, + auth_token: str | None = None, + reference_id: str | None = None, + subaccount: str | None = None, + page_start: int | None = None, + page_end: int | None = None, + ) -> Submission: + """Submit a document for extraction. + + Exactly one of ``document`` and ``url`` must be given. + + Args: + document: The document to upload — a path, an open binary file + object, or raw bytes. A path is opened and closed by the + client; a file object is read as-is and left open. + doctype: What kind of document this is; the API defaults to + invoice. Any string is passed through, so doctypes newer than + this SDK still work. + url: Publicly fetchable URL of the document, as an alternative to + uploading it. + webhook_url: Endpoint Photon calls (POST) when processing finishes. + auth_token: Value Photon echoes back in the webhook's + ``Authorization`` header, so the receiver can verify the sender. + reference_id: Your own correlation ID, echoed in the webhook body. + Sent to the API as ``ID``. + subaccount: Sub-account to attribute this call to. Letters, digits, + and hyphens; at most 50 characters. + page_start: First page to process (1-based, inclusive). + page_end: Last page to process (1-based, inclusive). + + Returns: + A :class:`Submission` carrying the ``photon_key`` (to retrieve the + result) and ``doc_path`` (to fetch or delete the original later). + + Raises: + ValueError: Neither or both of ``document``/``url`` given, or an + invalid ``subaccount`` — checked before any I/O. + PhotonError: See :meth:`Transport.request_json` for the mapping. + """ + if (document is None) == (url is None): + raise ValueError("Pass exactly one of 'document' or 'url'.") + if subaccount is not None and not _is_valid_subaccount(subaccount): + raise ValueError( + "subaccount must be 1-50 characters of letters, digits, or hyphens." + ) + + params: dict[str, Any] = { + "doctype": doctype.value if isinstance(doctype, DocType) else str(doctype), + "url": url, + "webhook_url": webhook_url, + "auth_token": auth_token, + "ID": reference_id, + "subaccount": subaccount, + "page_start": page_start, + "page_end": page_end, + } + + # A path is opened (and closed) here; a caller's file object is not ours + # to close, so it never enters the stack. + with contextlib.ExitStack() as cleanup: + files: Any = None + if isinstance(document, bytes): + files = {_FILE_FIELD: document} + elif isinstance(document, (str, os.PathLike)): + handle = cleanup.enter_context(open(document, "rb")) + files = {_FILE_FIELD: (os.path.basename(os.fspath(document)), handle)} + elif document is not None: + files = {_FILE_FIELD: document} + + body = self._transport.request_json( + "POST", SUBMIT_PATH, params=params, files=files + ) + + return Submission.from_response(body) + + def close(self) -> None: + """Close the underlying connection pool. Safe to call more than once.""" + self._transport.close() + + @property + def is_closed(self) -> bool: + """Whether :meth:`close` has been called.""" + return self._transport.is_closed + + def __enter__(self) -> Self: + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + traceback: TracebackType | None, + ) -> None: + self.close() + + def __repr__(self) -> str: + return ( + f"PhotonClient(environment={self.config.environment.value!r}, " + f"base_url={self.config.base_url!r})" + ) + + +def _is_valid_subaccount(subaccount: str) -> bool: + return len(subaccount) <= _SUBACCOUNT_MAX_LEN and bool(_SUBACCOUNT_RE.match(subaccount)) diff --git a/src/photon/models/__init__.py b/src/photon/models/__init__.py new file mode 100644 index 0000000..045d425 --- /dev/null +++ b/src/photon/models/__init__.py @@ -0,0 +1,7 @@ +"""Response models for the Photon SDK.""" + +from __future__ import annotations + +from .submission import Submission + +__all__ = ["Submission"] diff --git a/src/photon/models/submission.py b/src/photon/models/submission.py new file mode 100644 index 0000000..0d0402e --- /dev/null +++ b/src/photon/models/submission.py @@ -0,0 +1,57 @@ +"""The API's receipt for a submitted document. + +Submitting a document returns two handles: the ``photon_key`` used to retrieve +the extraction result, and the ``doc_path`` used later to fetch or delete the +original file. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + +__all__ = ["Submission"] + + +class Submission(BaseModel): + """Receipt for a submitted document. + + Attributes: + photon_key: Key for retrieving the extraction result. + doc_path: Server-side path of the uploaded file, needed to fetch the + original document or delete it later. + message: The API's status message, normally ``"success"``. + raw: The complete response body, untouched. Anything the SDK does not + model stays readable here, so an API shape change never loses data. + """ + + model_config = ConfigDict(frozen=True) + + photon_key: str = "" + doc_path: str = "" + message: str = "" + raw: dict[str, Any] = Field(default_factory=dict, repr=False) + + @classmethod + def from_response(cls, body: Mapping[str, Any]) -> Submission: + """Build a Submission from a decoded response body. + + Tolerant by construction: missing or oddly-typed keys become empty or + stringified values rather than raising, and the whole body is kept on + :attr:`raw`. + """ + return cls( + photon_key=_text(body, "photon_key"), + doc_path=_text(body, "doc_path"), + message=_text(body, "message"), + raw=dict(body), + ) + + +def _text(body: Mapping[str, Any], key: str) -> str: + value = body.get(key) + if isinstance(value, str): + return value + return "" if value is None else str(value) diff --git a/tests/test_submit.py b/tests/test_submit.py new file mode 100644 index 0000000..a46c69a --- /dev/null +++ b/tests/test_submit.py @@ -0,0 +1,335 @@ +"""Tests for PhotonClient.submit: input modes, validation, and the Submission model. + +The API is stubbed with respx; the submit response shape comes from +``plans/API-REFERENCE.md``. +""" + +from __future__ import annotations + +import builtins +import io +from collections.abc import Iterator +from pathlib import Path +from typing import IO, Any + +import httpx +import pytest +import respx +from pydantic import ValidationError + +from photon import APIError, ConfigurationError, DocType, PhotonClient, Submission +from photon.constants import SUBMIT_PATH + +BASE_URL = "https://sandbox-api.photoncommerce.com" + +PDF_BYTES = b"%PDF-1.4 not a real document" + +SUBMIT_RESPONSE = { + "photon_key": "pk_test_123", + "doc_path": "uploads/2026/invoice-abc.pdf", + "message": "success", +} + + +def make_client(**overrides: Any) -> PhotonClient: + values: dict[str, Any] = { + "client_id": "AAA111", + "username": "user@example.com", + "api_key": "BBB222", + "password": "CCC333", + "secret_key": "DDD444", + } + values.update(overrides) + return PhotonClient(**values) + + +@pytest.fixture +def client() -> Iterator[PhotonClient]: + with make_client() as open_client: + yield open_client + + +def mock_submit(mock: respx.MockRouter, response: httpx.Response | None = None) -> respx.Route: + if response is None: + response = httpx.Response(200, json=SUBMIT_RESPONSE) + return mock.post(SUBMIT_PATH).mock(return_value=response) + + +# --- document input modes ------------------------------------------------- + + +@pytest.mark.parametrize("as_type", [str, Path]) +def test_path_input_uploads_multipart_pdf_field( + tmp_path: Path, client: PhotonClient, as_type: type +) -> None: + document = tmp_path / "invoice.pdf" + document.write_bytes(PDF_BYTES) + + with respx.mock(base_url=BASE_URL) as mock: + route = mock_submit(mock) + submission = client.submit(as_type(document)) + + request = route.calls.last.request + assert request.headers["content-type"].startswith("multipart/form-data") + assert b'name="pdf"' in request.content + assert b'filename="invoice.pdf"' in request.content + assert PDF_BYTES in request.content + assert submission.photon_key == "pk_test_123" + assert submission.doc_path == "uploads/2026/invoice-abc.pdf" + + +def test_file_object_input_is_uploaded_and_left_open(client: PhotonClient) -> None: + handle = io.BytesIO(PDF_BYTES) + + with respx.mock(base_url=BASE_URL) as mock: + route = mock_submit(mock) + client.submit(handle) + + request = route.calls.last.request + assert b'name="pdf"' in request.content + assert PDF_BYTES in request.content + assert not handle.closed + + +def test_bytes_input_is_uploaded(client: PhotonClient) -> None: + with respx.mock(base_url=BASE_URL) as mock: + route = mock_submit(mock) + client.submit(PDF_BYTES) + + request = route.calls.last.request + assert request.headers["content-type"].startswith("multipart/form-data") + assert b'name="pdf"' in request.content + assert PDF_BYTES in request.content + + +def test_path_input_closes_the_file_it_opened( + tmp_path: Path, client: PhotonClient, monkeypatch: pytest.MonkeyPatch +) -> None: + document = tmp_path / "invoice.pdf" + document.write_bytes(PDF_BYTES) + + opened: list[IO[bytes]] = [] + real_open = builtins.open + + def recording_open(*args: Any, **kwargs: Any) -> Any: + handle = real_open(*args, **kwargs) + opened.append(handle) + return handle + + monkeypatch.setattr(builtins, "open", recording_open) + with respx.mock(base_url=BASE_URL) as mock: + mock_submit(mock) + client.submit(str(document)) + + assert opened + assert all(handle.closed for handle in opened) + + +def test_path_input_closes_the_file_even_when_the_request_fails( + tmp_path: Path, client: PhotonClient, monkeypatch: pytest.MonkeyPatch +) -> None: + document = tmp_path / "invoice.pdf" + document.write_bytes(PDF_BYTES) + + opened: list[IO[bytes]] = [] + real_open = builtins.open + + def recording_open(*args: Any, **kwargs: Any) -> Any: + handle = real_open(*args, **kwargs) + opened.append(handle) + return handle + + monkeypatch.setattr(builtins, "open", recording_open) + with respx.mock(base_url=BASE_URL) as mock: + mock_submit(mock, httpx.Response(500, json={"message": "Internal server error"})) + with pytest.raises(APIError): + client.submit(str(document)) + + assert opened + assert all(handle.closed for handle in opened) + + +def test_url_mode_sends_the_query_param_and_no_file(client: PhotonClient) -> None: + with respx.mock(base_url=BASE_URL) as mock: + route = mock_submit(mock) + submission = client.submit(url="https://example.com/invoice.pdf") + + request = route.calls.last.request + assert request.url.params["url"] == "https://example.com/invoice.pdf" + assert "multipart" not in request.headers.get("content-type", "") + assert request.content == b"" + assert submission.photon_key == "pk_test_123" + + +# --- validation, before any I/O ------------------------------------------- + + +def test_neither_document_nor_url_is_a_value_error(client: PhotonClient) -> None: + with respx.mock(base_url=BASE_URL) as mock, pytest.raises(ValueError, match="exactly one"): + client.submit() + assert not mock.calls + + +def test_both_document_and_url_is_a_value_error(client: PhotonClient) -> None: + with respx.mock(base_url=BASE_URL) as mock, pytest.raises(ValueError, match="exactly one"): + client.submit(PDF_BYTES, url="https://example.com/invoice.pdf") + assert not mock.calls + + +@pytest.mark.parametrize( + "subaccount", + ["a" * 51, "under_score", "has space", "", "email@nope", "slash/nope"], +) +def test_invalid_subaccount_is_a_value_error(client: PhotonClient, subaccount: str) -> None: + with respx.mock(base_url=BASE_URL) as mock, pytest.raises(ValueError, match="subaccount"): + client.submit(PDF_BYTES, subaccount=subaccount) + assert not mock.calls + + +@pytest.mark.parametrize("subaccount", ["team-1", "ACME", "a" * 50, "0-0"]) +def test_valid_subaccount_is_sent(client: PhotonClient, subaccount: str) -> None: + with respx.mock(base_url=BASE_URL) as mock: + route = mock_submit(mock) + client.submit(PDF_BYTES, subaccount=subaccount) + + assert route.calls.last.request.url.params["subaccount"] == subaccount + + +# --- request shaping ------------------------------------------------------- + + +def test_reference_id_is_sent_as_the_ID_param(client: PhotonClient) -> None: + with respx.mock(base_url=BASE_URL) as mock: + route = mock_submit(mock) + client.submit(PDF_BYTES, reference_id="order-42") + + params = route.calls.last.request.url.params + assert params["ID"] == "order-42" + assert "reference_id" not in params + + +def test_optional_params_are_omitted_when_none(client: PhotonClient) -> None: + with respx.mock(base_url=BASE_URL) as mock: + route = mock_submit(mock) + client.submit(PDF_BYTES) + + params = route.calls.last.request.url.params + assert dict(params) == {"doctype": "invoice"} + + +def test_all_optional_params_are_sent_when_given(client: PhotonClient) -> None: + with respx.mock(base_url=BASE_URL) as mock: + route = mock_submit(mock) + client.submit( + PDF_BYTES, + doctype=DocType.RECEIPT_EXPENSE, + webhook_url="https://example.com/hook", + auth_token="hook-token", + reference_id="ref-1", + subaccount="team-1", + page_start=1, + page_end=3, + ) + + params = dict(route.calls.last.request.url.params) + assert params == { + "doctype": "receipt-expense", + "webhook_url": "https://example.com/hook", + "auth_token": "hook-token", + "ID": "ref-1", + "subaccount": "team-1", + "page_start": "1", + "page_end": "3", + } + + +@pytest.mark.parametrize( + ("doctype", "expected"), + [ + (DocType.INVOICE, "invoice"), + (DocType.BILL_UTILITY, "bill-utility"), + ("statement", "statement"), + ("some-future-doctype", "some-future-doctype"), + ], +) +def test_doctype_accepts_enum_or_string( + client: PhotonClient, doctype: DocType | str, expected: str +) -> None: + with respx.mock(base_url=BASE_URL) as mock: + route = mock_submit(mock) + client.submit(PDF_BYTES, doctype=doctype) + + assert route.calls.last.request.url.params["doctype"] == expected + + +# --- the Submission model --------------------------------------------------- + + +def test_submission_tolerates_a_missing_or_reshaped_body() -> None: + submission = Submission.from_response({}) + assert submission.photon_key == "" + assert submission.doc_path == "" + assert submission.message == "" + assert submission.raw == {} + + +def test_submission_keeps_extra_keys_on_raw() -> None: + body = {**SUBMIT_RESPONSE, "brand_new_field": {"nested": True}} + submission = Submission.from_response(body) + assert submission.photon_key == "pk_test_123" + assert submission.raw == body + assert submission.raw["brand_new_field"] == {"nested": True} + + +def test_submission_stringifies_oddly_typed_values_instead_of_raising() -> None: + submission = Submission.from_response({"photon_key": 123, "doc_path": None}) + assert submission.photon_key == "123" + assert submission.doc_path == "" + + +def test_submission_is_frozen() -> None: + submission = Submission.from_response(SUBMIT_RESPONSE) + with pytest.raises(ValidationError): + submission.photon_key = "other" + + +# --- client construction and lifecycle -------------------------------------- + + +def test_missing_credentials_raise_configuration_error() -> None: + with pytest.raises(ConfigurationError, match="password"): + make_client(password="") + + +def test_from_env_reads_photon_variables(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("PHOTON_CLIENT_ID", "env-cid") + monkeypatch.setenv("PHOTON_USERNAME", "env-user@example.com") + monkeypatch.setenv("PHOTON_API_KEY", "env-key") + monkeypatch.setenv("PHOTON_PASSWORD", "env-pass") + monkeypatch.setenv("PHOTON_SECRET_KEY", "env-secret") + monkeypatch.setenv("PHOTON_ENVIRONMENT", "production") + + with PhotonClient.from_env(timeout=5.0) as client: + assert client.config.client_id == "env-cid" + assert client.config.environment.value == "production" + assert client.config.base_url == "https://api.photoncommerce.com" + assert client.config.timeout == 5.0 + + +def test_context_manager_closes_the_transport() -> None: + client = make_client() + with client: + assert not client.is_closed + assert client.is_closed + client.close() # safe to call again + + +def test_repr_shows_no_secrets() -> None: + client = make_client() + try: + text = repr(client) + for secret in ("AAA111", "BBB222", "CCC333", "DDD444"): + assert secret not in text + assert "sandbox" in text + finally: + client.close() From f407d0d4be4f7542a750dab82c26d22791f1451d Mon Sep 17 00:00:00 2001 From: Shubham Bisht Date: Tue, 11 Aug 2026 10:00:00 +0530 Subject: [PATCH 2/5] feat: implement PhotonClient.retrieve --- src/photon/client.py | 37 ++++++++ tests/fixtures/README.md | 7 ++ tests/fixtures/invoice_processing.json | 3 + tests/fixtures/invoice_ready.json | 45 ++++++++++ tests/test_retrieve.py | 119 +++++++++++++++++++++++++ 5 files changed, 211 insertions(+) create mode 100644 tests/fixtures/README.md create mode 100644 tests/fixtures/invoice_processing.json create mode 100644 tests/fixtures/invoice_ready.json create mode 100644 tests/test_retrieve.py diff --git a/src/photon/client.py b/src/photon/client.py index 4958065..efd5b6e 100644 --- a/src/photon/client.py +++ b/src/photon/client.py @@ -20,10 +20,12 @@ DEFAULT_BACKOFF_FACTOR, DEFAULT_MAX_RETRIES, DEFAULT_TIMEOUT, + RETRIEVE_PATH, SUBMIT_PATH, DocType, Environment, ) +from .exceptions import APIError from .models import Submission if TYPE_CHECKING: @@ -201,6 +203,41 @@ def submit( return Submission.from_response(body) + def retrieve(self, photon_key: str) -> dict[str, Any]: + """Fetch the extraction result for a submitted document. + + Args: + photon_key: The key from :attr:`Submission.photon_key`. + + Returns: + The extracted fields, exactly as the API returned them (the body's + ``data`` object). Field names vary by doctype — see the doctype + family schemas in the API reference. + + Raises: + ValueError: ``photon_key`` is empty — checked before any I/O. + NotReadyError: The document is still being processed; retry later, + or let ``extract()`` (Week 3) poll for you. + APIError: The response reported success but carried no ``data`` + object. + PhotonError: See :meth:`Transport.request_json` for the rest of + the mapping. + """ + if not photon_key or not photon_key.strip(): + raise ValueError("photon_key must be a non-empty string.") + + body = self._transport.request_json( + "GET", RETRIEVE_PATH, params={"photon_key": photon_key} + ) + + data = body.get("data") + if not isinstance(data, dict): + raise APIError( + "The response reported success but did not include a 'data' object.", + body=body, + ) + return data + def close(self) -> None: """Close the underlying connection pool. Safe to call more than once.""" self._transport.close() diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md new file mode 100644 index 0000000..25cc9ac --- /dev/null +++ b/tests/fixtures/README.md @@ -0,0 +1,7 @@ +# Test fixtures + +⚠️ **Synthetic, pending the sandbox smoke test.** These bodies are hand-built +from the response shapes in the official API docs (apidocs.photoncommerce.com); +they have **not** been captured from the live API yet. All values are +placeholders. When the sandbox smoke test runs, replace them with real, +redacted captures and delete this warning. diff --git a/tests/fixtures/invoice_processing.json b/tests/fixtures/invoice_processing.json new file mode 100644 index 0000000..c038b54 --- /dev/null +++ b/tests/fixtures/invoice_processing.json @@ -0,0 +1,3 @@ +{ + "message": "The document you submitted is being processed." +} diff --git a/tests/fixtures/invoice_ready.json b/tests/fixtures/invoice_ready.json new file mode 100644 index 0000000..826aa06 --- /dev/null +++ b/tests/fixtures/invoice_ready.json @@ -0,0 +1,45 @@ +{ + "data": { + "Vendor_Name": "Acme Supplies Ltd", + "Vendor_Email": "billing@acme-supplies.example", + "Vendor_Address": "1 Example Way, Springfield", + "Document_Type": "Invoice", + "Invoice_Number": "INV-2026-00042", + "PO_Number": "PO-7788", + "Date": "07/15/2026", + "Due_Date": "08/14/2026", + "Payment_Terms": "Net 30", + "Bill_To_Name": "Photon Test Buyer Inc", + "Subtotal": "1150.00", + "Tax": "84.50", + "Total": "1234.50", + "Balance_Due": "1234.50", + "Currency_Code": "USD", + "Pages": 1, + "Is_Duplicate": false, + "Notes": "", + "Tax_Lines": [ + { + "Base": "1150.00", + "Name": "VAT", + "Order": 1, + "Rate": "7.35", + "Total": "84.50" + } + ], + "Line_Items": [ + { + "Line": 1, + "SKU": "WID-100", + "Description": "Widget, standard", + "QTY": "10", + "Unit": "ea", + "Price": "115.00", + "Amount": "1150.00" + } + ], + "photon_key": "pk_fixture_0001" + }, + "message": "success", + "status": "success" +} diff --git a/tests/test_retrieve.py b/tests/test_retrieve.py new file mode 100644 index 0000000..4671d01 --- /dev/null +++ b/tests/test_retrieve.py @@ -0,0 +1,119 @@ +"""Tests for PhotonClient.retrieve: the data payload, the processing signal, errors. + +Response bodies come from ``tests/fixtures/`` (see the README there for their +provenance) and are served via respx. +""" + +from __future__ import annotations + +import json +from collections.abc import Iterator +from pathlib import Path +from typing import Any + +import httpx +import pytest +import respx + +from photon import ( + APIError, + AuthenticationError, + InvalidRequestError, + NotReadyError, + PhotonClient, +) +from photon.constants import PROCESSING_MESSAGE, RETRIEVE_PATH + +BASE_URL = "https://sandbox-api.photoncommerce.com" +FIXTURES = Path(__file__).parent / "fixtures" + +INVOICE_READY: dict[str, Any] = json.loads((FIXTURES / "invoice_ready.json").read_text()) +INVOICE_PROCESSING: dict[str, Any] = json.loads( + (FIXTURES / "invoice_processing.json").read_text() +) + + +def make_client(**overrides: Any) -> PhotonClient: + values: dict[str, Any] = { + "client_id": "AAA111", + "username": "user@example.com", + "api_key": "BBB222", + "password": "CCC333", + "secret_key": "DDD444", + } + values.update(overrides) + return PhotonClient(**values) + + +@pytest.fixture +def client() -> Iterator[PhotonClient]: + with make_client() as open_client: + yield open_client + + +def mock_retrieve(mock: respx.MockRouter, response: httpx.Response) -> respx.Route: + return mock.get(RETRIEVE_PATH).mock(return_value=response) + + +def test_ready_document_returns_the_data_dict(client: PhotonClient) -> None: + with respx.mock(base_url=BASE_URL) as mock: + route = mock_retrieve(mock, httpx.Response(200, json=INVOICE_READY)) + data = client.retrieve("pk_fixture_0001") + + assert route.calls.last.request.url.params["photon_key"] == "pk_fixture_0001" + assert data == INVOICE_READY["data"] + assert data["Vendor_Name"] == "Acme Supplies Ltd" + assert data["Line_Items"][0]["Amount"] == "1150.00" + + +def test_processing_document_raises_not_ready(client: PhotonClient) -> None: + with respx.mock(base_url=BASE_URL) as mock: + mock_retrieve(mock, httpx.Response(200, json=INVOICE_PROCESSING)) + with pytest.raises(NotReadyError) as caught: + client.retrieve("pk_fixture_0001") + + assert caught.value.message == PROCESSING_MESSAGE + assert caught.value.status_code == 200 + + +@pytest.mark.parametrize("photon_key", ["", " "]) +def test_empty_photon_key_is_a_value_error_before_any_io( + client: PhotonClient, photon_key: str +) -> None: + with respx.mock(base_url=BASE_URL) as mock, pytest.raises(ValueError, match="photon_key"): + client.retrieve(photon_key) + assert not mock.calls + + +def test_success_body_without_data_is_an_api_error_not_a_key_error( + client: PhotonClient, +) -> None: + body = {"message": "success", "status": "success"} + with respx.mock(base_url=BASE_URL) as mock: + mock_retrieve(mock, httpx.Response(200, json=body)) + with pytest.raises(APIError) as caught: + client.retrieve("pk_fixture_0001") + + assert caught.value.body == body + + +def test_success_body_with_non_dict_data_is_an_api_error(client: PhotonClient) -> None: + with respx.mock(base_url=BASE_URL) as mock: + mock_retrieve(mock, httpx.Response(200, json={"data": "oops", "message": "success"})) + with pytest.raises(APIError): + client.retrieve("pk_fixture_0001") + + +def test_error_payloads_map_to_typed_exceptions(client: PhotonClient) -> None: + with respx.mock(base_url=BASE_URL) as mock: + mock_retrieve(mock, httpx.Response(403, json={"message": "Photon Key missing"})) + with pytest.raises(InvalidRequestError, match="Photon Key missing"): + client.retrieve("pk_wrong") + + +def test_authentication_failure_propagates(client: PhotonClient) -> None: + message = "Authentication failed. Please check your credentials" + with respx.mock(base_url=BASE_URL) as mock: + mock_retrieve(mock, httpx.Response(401, json={"message": message})) + with pytest.raises(AuthenticationError): + client.retrieve("pk_fixture_0001") From aa3df358f7a63095777e4c3974248dfb19b5d61a Mon Sep 17 00:00:00 2001 From: Shubham Bisht Date: Wed, 12 Aug 2026 10:00:00 +0530 Subject: [PATCH 3/5] fix: harden transport and config against confirmed audit findings --- src/photon/_transport.py | 28 ++++++++++++-- src/photon/config.py | 18 ++++++++- tests/test_config.py | 80 ++++++++++++++++++++++++++++++++++++++++ tests/test_transport.py | 53 ++++++++++++++++++++++++++ 4 files changed, 174 insertions(+), 5 deletions(-) diff --git a/src/photon/_transport.py b/src/photon/_transport.py index 69cf5cf..56a5c7f 100644 --- a/src/photon/_transport.py +++ b/src/photon/_transport.py @@ -66,7 +66,13 @@ def __init__(self, config: Config) -> None: base_url=config.base_url, headers={**build_auth_headers(config), "User-Agent": USER_AGENT}, timeout=config.timeout, - follow_redirects=True, + # Never follow redirects: httpx strips only the Authorization header + # on a cross-origin redirect, so following one would re-send the + # CLIENT-ID, PASSWORD, and SECRET-KEY credentials to the redirect + # target (and a redirected POST is re-issued as a bodyless GET). The + # documented API never redirects; if an endpoint ever does, handle + # it explicitly without credentials rather than re-enabling this. + follow_redirects=False, ) def request( @@ -221,11 +227,23 @@ def _raise_for_response(response: httpx.Response, body: Any) -> None: message = _extract_message(response, body) if response.is_success: - # A still-processing document is reported as a successful response. - if PROCESSING_MARKER in message.lower(): + # A still-processing document is reported as a successful JSON response. + # Only a JSON body can carry that signal — matching it against other + # content would misread a downloaded document that happens to contain + # the words. + if isinstance(body, dict) and PROCESSING_MARKER in message.lower(): raise NotReadyError(message, status_code=status, body=body) return + if response.is_redirect: + location = response.headers.get("location", "unknown") + raise APIError( + f"Unexpected redirect to {location!r}. The SDK does not follow " + "redirects, to avoid re-sending credentials to another host.", + status_code=status, + body=body, + ) + if status == httpx.codes.UNAUTHORIZED: raise AuthenticationError(message, status_code=status, body=body) @@ -281,7 +299,9 @@ def _text_snippet(response: httpx.Response) -> str: def _describe_content(response: httpx.Response) -> str: """Describe a response body that could not be used as JSON.""" content_type = response.headers.get("content-type", "") - return f"content-type {content_type!r}" if content_type else "an empty body" + if content_type: + return f"content-type {content_type!r}" + return "a body with no content-type" if response.content else "an empty body" def _drop_none(params: dict[str, Any] | None) -> dict[str, Any] | None: diff --git a/src/photon/config.py b/src/photon/config.py index 849a2d7..deb2433 100644 --- a/src/photon/config.py +++ b/src/photon/config.py @@ -78,6 +78,20 @@ def __post_init__(self) -> None: # Derive the base URL from the environment unless one was given explicitly. if not self.base_url: object.__setattr__(self, "base_url", self.environment.base_url) + return + + # An explicit base_url that is really the *other* environment's stock URL + # would silently send this environment's traffic to the wrong host — the + # easy way to hit it is dataclasses.replace(config, environment=...), + # which copies the already-derived base_url along. Refuse loudly. + for env in Environment: + if self.base_url == env.base_url and env is not self.environment: + raise ConfigurationError( + f"base_url {self.base_url!r} is the {env.value} environment's URL, " + f"but environment is {self.environment.value!r}. Set " + f"environment={env.value!r} instead, or pass base_url='' to derive " + "the URL from the environment." + ) @classmethod def from_env(cls, **overrides: Any) -> Config: @@ -92,8 +106,10 @@ def from_env(cls, **overrides: Any) -> Config: name: os.environ.get(_ENV_PREFIX + name.upper(), "") for name in _REQUIRED_CREDENTIALS } + # An empty value means unset, same as PHOTON_BASE_URL below — a blank + # line in a .env file should fall back to the default, not error. environment = os.environ.get(_ENV_PREFIX + "ENVIRONMENT") - if environment is not None: + if environment: values["environment"] = environment base_url = os.environ.get(_ENV_PREFIX + "BASE_URL") if base_url: diff --git a/tests/test_config.py b/tests/test_config.py index 8d096c1..a74c46b 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -2,6 +2,8 @@ from __future__ import annotations +import dataclasses + import pytest from photon.config import Config @@ -45,6 +47,35 @@ def test_explicit_base_url_overrides_environment() -> None: assert config.base_url == "https://example.test" +def test_replace_with_a_new_environment_rejects_the_stale_base_url() -> None: + config = make_config(environment=Environment.PRODUCTION) + with pytest.raises(ConfigurationError, match="base_url"): + dataclasses.replace(config, environment=Environment.SANDBOX) + + +def test_replace_with_a_new_environment_and_cleared_base_url_rederives() -> None: + config = make_config(environment=Environment.PRODUCTION) + replaced = dataclasses.replace(config, environment=Environment.SANDBOX, base_url="") + assert replaced.base_url == "https://sandbox-api.photoncommerce.com" + + +def test_custom_base_url_survives_replace() -> None: + config = make_config(base_url="https://proxy.example.test") + replaced = dataclasses.replace(config, environment=Environment.PRODUCTION) + assert replaced.base_url == "https://proxy.example.test" + + +def test_the_other_environments_stock_url_is_rejected() -> None: + # environment defaults to sandbox; the production URL contradicts it. + with pytest.raises(ConfigurationError, match="production"): + make_config(base_url="https://api.photoncommerce.com") + + +def test_the_matching_environments_stock_url_is_accepted() -> None: + config = make_config(base_url="https://sandbox-api.photoncommerce.com") + assert config.environment is Environment.SANDBOX + + def test_missing_client_id_raises() -> None: with pytest.raises(ConfigurationError, match="client_id"): make_config(client_id="") @@ -125,6 +156,55 @@ def test_from_env_unknown_environment_raises(monkeypatch: pytest.MonkeyPatch) -> Config.from_env() +def test_from_env_empty_environment_falls_back_to_sandbox( + monkeypatch: pytest.MonkeyPatch, +) -> None: + for name, value in { + "CLIENT_ID": "cid", + "USERNAME": "user", + "API_KEY": "key", + "PASSWORD": "pw", + "SECRET_KEY": "sk", + "ENVIRONMENT": "", + }.items(): + monkeypatch.setenv("PHOTON_" + name, value) + + config = Config.from_env() + + assert config.environment is Environment.SANDBOX + assert config.base_url == "https://sandbox-api.photoncommerce.com" + + +def test_from_env_reads_base_url(monkeypatch: pytest.MonkeyPatch) -> None: + for name, value in { + "CLIENT_ID": "cid", + "USERNAME": "user", + "API_KEY": "key", + "PASSWORD": "pw", + "SECRET_KEY": "sk", + "BASE_URL": "https://proxy.example.test", + }.items(): + monkeypatch.setenv("PHOTON_" + name, value) + monkeypatch.delenv("PHOTON_ENVIRONMENT", raising=False) + + assert Config.from_env().base_url == "https://proxy.example.test" + + +def test_from_env_empty_base_url_is_ignored(monkeypatch: pytest.MonkeyPatch) -> None: + for name, value in { + "CLIENT_ID": "cid", + "USERNAME": "user", + "API_KEY": "key", + "PASSWORD": "pw", + "SECRET_KEY": "sk", + "BASE_URL": "", + }.items(): + monkeypatch.setenv("PHOTON_" + name, value) + monkeypatch.delenv("PHOTON_ENVIRONMENT", raising=False) + + assert Config.from_env().base_url == "https://sandbox-api.photoncommerce.com" + + def test_from_env_missing_credentials_raises(monkeypatch: pytest.MonkeyPatch) -> None: for name in ("CLIENT_ID", "USERNAME", "API_KEY", "PASSWORD", "SECRET_KEY", "ENVIRONMENT"): monkeypatch.delenv("PHOTON_" + name, raising=False) diff --git a/tests/test_transport.py b/tests/test_transport.py index b391376..ff89962 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -261,6 +261,59 @@ def test_close_is_idempotent() -> None: assert transport.is_closed +def test_redirects_are_not_followed_so_credentials_stay_home(transport: Transport) -> None: + with respx.mock(base_url=BASE_URL) as mock: + route = mock.get(DOWNLOAD_PATH).mock( + return_value=httpx.Response( + 302, headers={"location": "https://elsewhere.example/doc.pdf"} + ) + ) + with pytest.raises(APIError) as caught: + transport.request("GET", DOWNLOAD_PATH, params={"doc_path": "docs/1.pdf"}) + + assert caught.value.status_code == 302 + assert "redirect" in caught.value.message.lower() + # Exactly one request, to the API host: the redirect target was never + # contacted, so no credential header left the API host. (Any request to + # another host would also have failed respx's all-mocked assertion.) + assert len(route.calls) == 1 + + +def test_downloaded_text_mentioning_processing_is_not_misread_as_not_ready( + transport: Transport, +) -> None: + body = "INVOICE #42\nYour order is being processed by our warehouse team.\nTotal: $10" + with respx.mock(base_url=BASE_URL) as mock: + mock.get(DOWNLOAD_PATH).mock( + return_value=httpx.Response(200, text=body, headers={"content-type": "text/plain"}) + ) + response = transport.request("GET", DOWNLOAD_PATH, params={"doc_path": "docs/1.txt"}) + + assert response.text == body + + +def test_json_body_without_content_type_error_names_the_real_problem( + transport: Transport, +) -> None: + with respx.mock(base_url=BASE_URL) as mock: + mock.get(RETRIEVE_PATH).mock( + return_value=httpx.Response(200, content=b'{"data": {"Total": 1}}') + ) + with pytest.raises(APIError) as caught: + transport.request_json("GET", RETRIEVE_PATH) + + assert "no content-type" in caught.value.message + + +def test_empty_success_body_is_reported_as_empty(transport: Transport) -> None: + with respx.mock(base_url=BASE_URL) as mock: + mock.get(RETRIEVE_PATH).mock(return_value=httpx.Response(200)) + with pytest.raises(APIError) as caught: + transport.request_json("GET", RETRIEVE_PATH) + + assert "empty body" in caught.value.message + + def test_repr_names_the_base_url_and_hides_credentials() -> None: with Transport(make_config()) as transport: text = repr(transport) From a59a0132fcb3f63413ba6d616b372c1c6944d351 Mon Sep 17 00:00:00 2001 From: Shubham Bisht Date: Thu, 13 Aug 2026 10:00:00 +0530 Subject: [PATCH 4/5] fix: address confirmed review findings on submit --- src/photon/_transport.py | 2 +- src/photon/client.py | 37 ++++++++++++++++++++++++++++++------- tests/test_submit.py | 30 +++++++++++++++++++++++++----- tests/test_transport.py | 4 ++-- 4 files changed, 58 insertions(+), 15 deletions(-) diff --git a/src/photon/_transport.py b/src/photon/_transport.py index 56a5c7f..1a753ca 100644 --- a/src/photon/_transport.py +++ b/src/photon/_transport.py @@ -7,7 +7,7 @@ Classification reads the response **body** before the status code, because the API puts the real outcome in the body's ``message``: a document that is still processing comes back as an HTTP 200, and several genuine failures come back as -403. See ``plans/API-REFERENCE.md`` for the observed responses. +403. See the official API docs (apidocs.photoncommerce.com) for the responses. """ from __future__ import annotations diff --git a/src/photon/client.py b/src/photon/client.py index efd5b6e..d96307e 100644 --- a/src/photon/client.py +++ b/src/photon/client.py @@ -43,8 +43,13 @@ # actual file type. _FILE_FIELD = "pdf" +# Filename sent for raw bytes and unnamed file objects. The API recognises file +# types by extension, so the upload must carry one; callers whose content is not +# a PDF should pass a path or a named file object instead. +_DEFAULT_FILENAME = "upload.pdf" + _SUBACCOUNT_MAX_LEN = 50 -_SUBACCOUNT_RE = re.compile(r"^[A-Za-z0-9-]+$") +_SUBACCOUNT_RE = re.compile(r"[A-Za-z0-9-]+") class PhotonClient: @@ -142,7 +147,10 @@ def submit( Args: document: The document to upload — a path, an open binary file object, or raw bytes. A path is opened and closed by the - client; a file object is read as-is and left open. + client; a file object is read as-is and left open. The API + recognises file types by filename extension, so raw bytes and + unnamed file objects are uploaded as ``upload.pdf``; for other + file types, pass a path or a file object with a ``.name``. doctype: What kind of document this is; the API defaults to invoice. Any string is passed through, so doctypes newer than this SDK still work. @@ -190,12 +198,12 @@ def submit( with contextlib.ExitStack() as cleanup: files: Any = None if isinstance(document, bytes): - files = {_FILE_FIELD: document} + files = {_FILE_FIELD: (_DEFAULT_FILENAME, document)} elif isinstance(document, (str, os.PathLike)): handle = cleanup.enter_context(open(document, "rb")) files = {_FILE_FIELD: (os.path.basename(os.fspath(document)), handle)} elif document is not None: - files = {_FILE_FIELD: document} + files = {_FILE_FIELD: (_filename_for(document), document)} body = self._transport.request_json( "POST", SUBMIT_PATH, params=params, files=files @@ -216,8 +224,7 @@ def retrieve(self, photon_key: str) -> dict[str, Any]: Raises: ValueError: ``photon_key`` is empty — checked before any I/O. - NotReadyError: The document is still being processed; retry later, - or let ``extract()`` (Week 3) poll for you. + NotReadyError: The document is still being processed; retry later. APIError: The response reported success but carried no ``data`` object. PhotonError: See :meth:`Transport.request_json` for the rest of @@ -265,5 +272,21 @@ def __repr__(self) -> str: ) +def _filename_for(document: IO[bytes]) -> str: + """The filename to upload a file object under. + + The API recognises file types by the uploaded filename's extension, so an + unnamed stream (``BytesIO``, a pipe, a fd-opened file) gets the default + rather than httpx's extensionless fallback. + """ + name = getattr(document, "name", None) + if isinstance(name, str) and os.path.basename(name): + return os.path.basename(name) + return _DEFAULT_FILENAME + + def _is_valid_subaccount(subaccount: str) -> bool: - return len(subaccount) <= _SUBACCOUNT_MAX_LEN and bool(_SUBACCOUNT_RE.match(subaccount)) + # fullmatch, not match: with match, "$" would accept a trailing newline. + return len(subaccount) <= _SUBACCOUNT_MAX_LEN and bool( + _SUBACCOUNT_RE.fullmatch(subaccount) + ) diff --git a/tests/test_submit.py b/tests/test_submit.py index a46c69a..5ce9689 100644 --- a/tests/test_submit.py +++ b/tests/test_submit.py @@ -1,7 +1,7 @@ """Tests for PhotonClient.submit: input modes, validation, and the Submission model. -The API is stubbed with respx; the submit response shape comes from -``plans/API-REFERENCE.md``. +The API is stubbed with respx; the submit response shape comes from the +official API docs (apidocs.photoncommerce.com). """ from __future__ import annotations @@ -78,7 +78,9 @@ def test_path_input_uploads_multipart_pdf_field( assert submission.doc_path == "uploads/2026/invoice-abc.pdf" -def test_file_object_input_is_uploaded_and_left_open(client: PhotonClient) -> None: +def test_unnamed_file_object_is_uploaded_with_an_extension_and_left_open( + client: PhotonClient, +) -> None: handle = io.BytesIO(PDF_BYTES) with respx.mock(base_url=BASE_URL) as mock: @@ -87,11 +89,28 @@ def test_file_object_input_is_uploaded_and_left_open(client: PhotonClient) -> No request = route.calls.last.request assert b'name="pdf"' in request.content + # The API recognises file types by extension, so an unnamed stream must not + # go out under httpx's extensionless fallback name. + assert b'filename="upload.pdf"' in request.content assert PDF_BYTES in request.content assert not handle.closed -def test_bytes_input_is_uploaded(client: PhotonClient) -> None: +def test_named_file_object_keeps_its_own_filename( + tmp_path: Path, client: PhotonClient +) -> None: + document = tmp_path / "receipt.png" + document.write_bytes(PDF_BYTES) + + with respx.mock(base_url=BASE_URL) as mock: + route = mock_submit(mock) + with document.open("rb") as handle: + client.submit(handle) + + assert b'filename="receipt.png"' in route.calls.last.request.content + + +def test_bytes_input_is_uploaded_with_an_extension(client: PhotonClient) -> None: with respx.mock(base_url=BASE_URL) as mock: route = mock_submit(mock) client.submit(PDF_BYTES) @@ -99,6 +118,7 @@ def test_bytes_input_is_uploaded(client: PhotonClient) -> None: request = route.calls.last.request assert request.headers["content-type"].startswith("multipart/form-data") assert b'name="pdf"' in request.content + assert b'filename="upload.pdf"' in request.content assert PDF_BYTES in request.content @@ -178,7 +198,7 @@ def test_both_document_and_url_is_a_value_error(client: PhotonClient) -> None: @pytest.mark.parametrize( "subaccount", - ["a" * 51, "under_score", "has space", "", "email@nope", "slash/nope"], + ["a" * 51, "under_score", "has space", "", "email@nope", "slash/nope", "team-1\n"], ) def test_invalid_subaccount_is_a_value_error(client: PhotonClient, subaccount: str) -> None: with respx.mock(base_url=BASE_URL) as mock, pytest.raises(ValueError, match="subaccount"): diff --git a/tests/test_transport.py b/tests/test_transport.py index ff89962..da5c02c 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -1,8 +1,8 @@ """Tests for the HTTP transport: headers, response classification, lifecycle. The API is stubbed with respx, so these assert the SDK's behaviour against the -responses recorded in ``plans/API-REFERENCE.md`` — including the ones that report -failure with an HTTP 200. +responses shown in the official API docs (apidocs.photoncommerce.com) — including +the ones that report failure with an HTTP 200. """ from __future__ import annotations From 4de43b79eba9add520f9937ec2989aed993be6c1 Mon Sep 17 00:00:00 2001 From: Shubham Bisht Date: Thu, 13 Aug 2026 13:10:00 +0530 Subject: [PATCH 5/5] test: capture real sandbox processing fixture --- tests/fixtures/README.md | 20 +++++++++++++++----- tests/fixtures/invoice_processing.json | 3 ++- tests/test_submit.py | 15 +++++++++------ 3 files changed, 26 insertions(+), 12 deletions(-) diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md index 25cc9ac..5a4247a 100644 --- a/tests/fixtures/README.md +++ b/tests/fixtures/README.md @@ -1,7 +1,17 @@ # Test fixtures -⚠️ **Synthetic, pending the sandbox smoke test.** These bodies are hand-built -from the response shapes in the official API docs (apidocs.photoncommerce.com); -they have **not** been captured from the live API yet. All values are -placeholders. When the sandbox smoke test runs, replace them with real, -redacted captures and delete this warning. +Provenance (sandbox smoke test, 2026-08-13): + +- `invoice_processing.json` — **real** sandbox response, captured live. The + document-still-processing signal: HTTP 200 with this exact body (note + `status` is `"success"` even while processing; only the `message` + distinguishes the states). +- `invoice_ready.json` — ⚠️ **still synthetic**, hand-built from the response + shapes in the official API docs (apidocs.photoncommerce.com). The live + extraction had not finished at capture time (invoice processing can take up + to 24h due to human verification). Replace with the real, redacted response + when it is ready. + +All values are placeholders; anything identifying in real captures (the +account email embedded in `photon_key`/`doc_path` paths) must be redacted to +`user@example.com` before committing. diff --git a/tests/fixtures/invoice_processing.json b/tests/fixtures/invoice_processing.json index c038b54..6e60e4d 100644 --- a/tests/fixtures/invoice_processing.json +++ b/tests/fixtures/invoice_processing.json @@ -1,3 +1,4 @@ { - "message": "The document you submitted is being processed." + "message": "The document you submitted is being processed.", + "status": "success" } diff --git a/tests/test_submit.py b/tests/test_submit.py index 5ce9689..c840ed4 100644 --- a/tests/test_submit.py +++ b/tests/test_submit.py @@ -24,10 +24,13 @@ PDF_BYTES = b"%PDF-1.4 not a real document" +# Mirrors the real sandbox response shape (captured 2026-08-13, email redacted): +# photon_key/doc_path are storage paths embedding the account email. SUBMIT_RESPONSE = { - "photon_key": "pk_test_123", - "doc_path": "uploads/2026/invoice-abc.pdf", + "photon_key": "data/user@example.com/2026-08-12/13-26-39-943699_invoice.json", + "doc_path": "data/user@example.com/2026-08-12/13-26-39-943699_invoice.pdf", "message": "success", + "status": "success", } @@ -74,8 +77,8 @@ def test_path_input_uploads_multipart_pdf_field( assert b'name="pdf"' in request.content assert b'filename="invoice.pdf"' in request.content assert PDF_BYTES in request.content - assert submission.photon_key == "pk_test_123" - assert submission.doc_path == "uploads/2026/invoice-abc.pdf" + assert submission.photon_key == SUBMIT_RESPONSE["photon_key"] + assert submission.doc_path == SUBMIT_RESPONSE["doc_path"] def test_unnamed_file_object_is_uploaded_with_an_extension_and_left_open( @@ -178,7 +181,7 @@ def test_url_mode_sends_the_query_param_and_no_file(client: PhotonClient) -> Non assert request.url.params["url"] == "https://example.com/invoice.pdf" assert "multipart" not in request.headers.get("content-type", "") assert request.content == b"" - assert submission.photon_key == "pk_test_123" + assert submission.photon_key == SUBMIT_RESPONSE["photon_key"] # --- validation, before any I/O ------------------------------------------- @@ -296,7 +299,7 @@ def test_submission_tolerates_a_missing_or_reshaped_body() -> None: def test_submission_keeps_extra_keys_on_raw() -> None: body = {**SUBMIT_RESPONSE, "brand_new_field": {"nested": True}} submission = Submission.from_response(body) - assert submission.photon_key == "pk_test_123" + assert submission.photon_key == SUBMIT_RESPONSE["photon_key"] assert submission.raw == body assert submission.raw["brand_new_field"] == {"nested": True}