From 4d11ee6036ca2a37eecebc0d65ff24ca7ff06564 Mon Sep 17 00:00:00 2001 From: Vicente Louvet III Date: Fri, 22 Dec 2023 12:45:24 -0700 Subject: [PATCH 01/29] Update index.md add REST API example in Programmatic use section --- docs/index.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/docs/index.md b/docs/index.md index 0aeed92..4f68816 100644 --- a/docs/index.md +++ b/docs/index.md @@ -99,6 +99,29 @@ if __name__ == "__main__": asyncio.run(async_main()) ``` +## Programmatic use - Search Object by Custom Field Value - REST API + +```python +import asyncio + +from netsuite import NetSuite, Config, TokenAuth + +config = Config( + account="12345", + auth=TokenAuth(consumer_key="abc", consumer_secret="123", token_id="xyz", token_secret="456"), +) + +ns = NetSuite(config) + +async def async_main() -> dict: + customer_keyword = 'Test Customer' + query_params = {'q':f'Name CONTAIN "{customer_keyword}"'} + rest_api_results = await ns.rest_api.get("/record/v1/customer", params=query_params) + + if __name__ == "__main__": + asyncio.run(async_main()) +``` + ## CLI ### Configuration From 5b95dcc48a4cff50b1742bcd3c0d4a168cdeb777 Mon Sep 17 00:00:00 2001 From: Vicente Louvet III Date: Wed, 17 Sep 2025 21:25:39 -0600 Subject: [PATCH 02/29] update httpx requirement from >=0.25,<0.28 to >=0.25,<0.29 (#2) * updated settings to add test specific parameters * updated httpx package pinning to allow newer versions * fixed pyodbc package name to pass poetry check --- .vscode/settings.json | 7 ++++++- pyproject.toml | 4 ++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 83d19f1..e28d91e 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,5 +2,10 @@ "python.formatting.provider": "black", "files.exclude": { "poetry.lock": true - } + }, + "python.testing.pytestArgs": [ + "tests" + ], + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true } diff --git a/pyproject.toml b/pyproject.toml index a73f9f4..af1e32b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,7 @@ classifiers = [ python = ">=3.9" authlib = ">=1,<3" # As per httpx recommendation we will lock to a fixed minor version until 1.0 is released -httpx = ">=0.25,<0.28" +httpx = ">=0.25,<0.29" pydantic = "^2.4.2" orjson = { version = "~3", optional = true } ipython = { version = "~8", optional = true, python = "^3.9" } @@ -39,7 +39,7 @@ soap_api = ["zeep"] cli = ["ipython"] orjson = ["orjson"] # TODO doesn't --all-extras solve this for us? -all = ["zeep", "ipython", "orjson", "odbc"] +all = ["zeep", "ipython", "orjson", "pyodbc"] [tool.poetry.dev-dependencies] black = "~24" From 0f63e9fee663271d68fa33379c5cb2999f169a0d Mon Sep 17 00:00:00 2001 From: Vicente Louvet III Date: Wed, 17 Sep 2025 21:47:44 -0600 Subject: [PATCH 03/29] Move WSDL version to a config parameter (#3) * move wsdl version to a config property --- netsuite/config.py | 4 ++++ netsuite/soap_api/client.py | 2 +- tests/conftest.py | 1 + tests/test_soap_api.py | 2 +- 4 files changed, 7 insertions(+), 2 deletions(-) diff --git a/netsuite/config.py b/netsuite/config.py index 437f1ed..e45834e 100644 --- a/netsuite/config.py +++ b/netsuite/config.py @@ -51,6 +51,10 @@ def is_sandbox(self) -> bool: def account_number(self) -> str: return self.account.split("_")[0] + @property + def wsdl_version(self) -> str: + return "2024.2.0" + @property def account_slugified(self) -> str: # https://followingnetsuite.wordpress.com/2018/10/18/suitetalk-sandbox-urls-addendum/ diff --git a/netsuite/soap_api/client.py b/netsuite/soap_api/client.py index 0525b41..4bd5d21 100644 --- a/netsuite/soap_api/client.py +++ b/netsuite/soap_api/client.py @@ -16,7 +16,7 @@ class NetSuiteSoapApi: - version = "2021.1.0" + version = getattr(Config, "wsdl_version", "2024.2.0") wsdl_url_tmpl = "https://{account_slug}.suitetalk.api.netsuite.com/wsdl/v{underscored_version}/netsuite.wsdl" def __init__( diff --git a/tests/conftest.py b/tests/conftest.py index 9f5c10e..54cf13c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -13,6 +13,7 @@ def dummy_config(): "token_id": "abcdefghijklmnopqrstuvwxyz0123456789", "token_secret": "abcdefghijklmnopqrstuvwxyz0123456789", }, + wsdl_version="2024.2", ) diff --git a/tests/test_soap_api.py b/tests/test_soap_api.py index 2954422..ec4283c 100644 --- a/tests/test_soap_api.py +++ b/tests/test_soap_api.py @@ -15,7 +15,7 @@ def test_netsuite_wsdl_url(dummy_config): soap_api = NetSuiteSoapApi(dummy_config) assert ( soap_api.wsdl_url - == "https://123456-sb1.suitetalk.api.netsuite.com/wsdl/v2021_1_0/netsuite.wsdl" + == "https://123456-sb1.suitetalk.api.netsuite.com/wsdl/v2024_2_0/netsuite.wsdl" ) From 2712dda7ffe06d87d9740ccd0c018d878d8f7d23 Mon Sep 17 00:00:00 2001 From: vlouvet Date: Thu, 22 Jan 2026 21:16:57 -0700 Subject: [PATCH 04/29] remove change unrelated to httpx upgrade --- .vscode/settings.json | 3 --- 1 file changed, 3 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index e28d91e..03555a8 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,9 +3,6 @@ "files.exclude": { "poetry.lock": true }, - "python.testing.pytestArgs": [ - "tests" - ], "python.testing.unittestEnabled": false, "python.testing.pytestEnabled": true } From 19fde3d928672f8e110eb5e1d2332bc3665bc12d Mon Sep 17 00:00:00 2001 From: vlouvet Date: Thu, 22 Jan 2026 21:18:03 -0700 Subject: [PATCH 05/29] keep original wsdl version for httpx change --- netsuite/soap_api/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netsuite/soap_api/client.py b/netsuite/soap_api/client.py index 4bd5d21..9b490aa 100644 --- a/netsuite/soap_api/client.py +++ b/netsuite/soap_api/client.py @@ -16,7 +16,7 @@ class NetSuiteSoapApi: - version = getattr(Config, "wsdl_version", "2024.2.0") + version = getattr(Config, "wsdl_version", "2021.1.0") wsdl_url_tmpl = "https://{account_slug}.suitetalk.api.netsuite.com/wsdl/v{underscored_version}/netsuite.wsdl" def __init__( From f00e81a7da84bc74fb8c62d309e9cb7415ddaa21 Mon Sep 17 00:00:00 2001 From: vlouvet Date: Thu, 22 Jan 2026 21:19:42 -0700 Subject: [PATCH 06/29] remove change unrelated to httpx upgrade --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index af1e32b..272761e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,7 @@ soap_api = ["zeep"] cli = ["ipython"] orjson = ["orjson"] # TODO doesn't --all-extras solve this for us? -all = ["zeep", "ipython", "orjson", "pyodbc"] +all = ["zeep", "ipython", "orjson", "odbc"] [tool.poetry.dev-dependencies] black = "~24" From 73bdb7b445129a6cda82ccd719736af506d56029 Mon Sep 17 00:00:00 2001 From: vlouvet Date: Thu, 22 Jan 2026 21:23:25 -0700 Subject: [PATCH 07/29] remove unused property definition for wsdl, which is defined in the soap api client instead. --- netsuite/config.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/netsuite/config.py b/netsuite/config.py index e45834e..437f1ed 100644 --- a/netsuite/config.py +++ b/netsuite/config.py @@ -51,10 +51,6 @@ def is_sandbox(self) -> bool: def account_number(self) -> str: return self.account.split("_")[0] - @property - def wsdl_version(self) -> str: - return "2024.2.0" - @property def account_slugified(self) -> str: # https://followingnetsuite.wordpress.com/2018/10/18/suitetalk-sandbox-urls-addendum/ From b46d2d1ad836c4bce3a27e101a41fc69a82caa9f Mon Sep 17 00:00:00 2001 From: vlouvet Date: Thu, 22 Jan 2026 21:55:07 -0700 Subject: [PATCH 08/29] removed the assertion in soap API testing that checked for a newer WSDL version. change unscoped for upgrade to httpx --- tests/test_soap_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_soap_api.py b/tests/test_soap_api.py index ec4283c..2954422 100644 --- a/tests/test_soap_api.py +++ b/tests/test_soap_api.py @@ -15,7 +15,7 @@ def test_netsuite_wsdl_url(dummy_config): soap_api = NetSuiteSoapApi(dummy_config) assert ( soap_api.wsdl_url - == "https://123456-sb1.suitetalk.api.netsuite.com/wsdl/v2024_2_0/netsuite.wsdl" + == "https://123456-sb1.suitetalk.api.netsuite.com/wsdl/v2021_1_0/netsuite.wsdl" ) From 85c800c385fc5823171dcab902ef39a5de5b3799 Mon Sep 17 00:00:00 2001 From: vlouvet Date: Thu, 22 Jan 2026 21:56:52 -0700 Subject: [PATCH 09/29] removed WSDL definition in conftest file, unscoped for HTTPX upgrade --- tests/conftest.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index 54cf13c..9f5c10e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -13,7 +13,6 @@ def dummy_config(): "token_id": "abcdefghijklmnopqrstuvwxyz0123456789", "token_secret": "abcdefghijklmnopqrstuvwxyz0123456789", }, - wsdl_version="2024.2", ) From bb0b92f640b3ac1e11cf97437339c5ffbf883750 Mon Sep 17 00:00:00 2001 From: Vicente Louvet III Date: Fri, 8 May 2026 08:36:13 -0600 Subject: [PATCH 10/29] fix(soap_api): WebServiceCall decorator was silently bypassed (#45) (#4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `WebServiceCall` checked `isinstance(response, zeep.xsd.ComplexType)`, but `ComplexType` is the schema-definition class — runtime SOAP responses are `zeep.xsd.CompoundValue` instances. The check was always False, so status validation and `extract` were silently skipped on every call. The decorator was also a synchronous wrapper around async SOAP methods, so even with the right isinstance check it would have processed the coroutine object rather than the awaited response. Switch the isinstance check to `CompoundValue`, and detect coroutine functions at decoration time so async methods are awaited inside the wrapper. Add regression tests covering the success/error/extract/default paths plus the sync code path. Closes #45 Co-authored-by: Claude Opus 4.7 (1M context) --- netsuite/soap_api/decorators.py | 28 ++++++- tests/test_soap_api_decorators.py | 124 ++++++++++++++++++++++++++++++ 2 files changed, 149 insertions(+), 3 deletions(-) create mode 100644 tests/test_soap_api_decorators.py diff --git a/netsuite/soap_api/decorators.py b/netsuite/soap_api/decorators.py index ee9e9f1..41fa440 100644 --- a/netsuite/soap_api/decorators.py +++ b/netsuite/soap_api/decorators.py @@ -1,3 +1,4 @@ +import inspect from functools import wraps from typing import Any, Callable, Optional @@ -8,6 +9,17 @@ __all__ = ("WebServiceCall",) +def _is_soap_response(response: Any) -> bool: + # SOAP responses returned by zeep are instances of `CompoundValue` + # (dynamically generated subclasses produced from the WSDL schema). + # The previous implementation checked `isinstance(response, + # zeep.xsd.ComplexType)` — but `ComplexType` is the *schema* definition + # class, not the runtime value class, so the check was always False and + # the decorator silently returned the raw response without status + # validation or extraction. See jacobsvante/netsuite#45. + return isinstance(response, zeep.xsd.CompoundValue) + + def WebServiceCall( path: Optional[str] = None, extract: Optional[Callable] = None, @@ -32,9 +44,17 @@ def WebServiceCall( def decorator(fn): @wraps(fn) - def wrapper(self, *args, **kw): + async def async_wrapper(self, *args, **kw): + response = await fn(self, *args, **kw) + return _process_response(response) + + @wraps(fn) + def sync_wrapper(self, *args, **kw): response = fn(self, *args, **kw) - if not isinstance(response, zeep.xsd.ComplexType): + return _process_response(response) + + def _process_response(response): + if not _is_soap_response(response): return response if path is not None: @@ -68,6 +88,8 @@ def wrapper(self, *args, **kw): return response - return wrapper + if inspect.iscoroutinefunction(fn): + return async_wrapper + return sync_wrapper return decorator diff --git a/tests/test_soap_api_decorators.py b/tests/test_soap_api_decorators.py new file mode 100644 index 0000000..bb286f7 --- /dev/null +++ b/tests/test_soap_api_decorators.py @@ -0,0 +1,124 @@ +"""Tests for the `WebServiceCall` decorator. + +Regression tests for jacobsvante/netsuite#45 — the decorator previously +checked `isinstance(response, zeep.xsd.ComplexType)`, which is the schema +definition class. Real SOAP responses are `zeep.xsd.CompoundValue` instances, +so the check was always False and status validation / extraction were +silently skipped. The decorator was also a synchronous wrapper around async +SOAP methods, so even with the right isinstance check it would have received +a coroutine instead of a response. +""" + +import pytest + +from netsuite.soap_api.exceptions import NetsuiteResponseError +from netsuite.soap_api.zeep import ZEEP_INSTALLED + +pytestmark = pytest.mark.skipif(not ZEEP_INSTALLED, reason="Requires zeep") + +if ZEEP_INSTALLED: + from zeep.xsd.valueobjects import CompoundValue + + from netsuite.soap_api.decorators import WebServiceCall + + +class _FakeResponse(CompoundValue): + """A minimal CompoundValue-shaped object usable as both attribute container and mapping.""" + + def __init__(self, **attrs): + # Bypass CompoundValue.__init__ which expects an XSD type + object.__setattr__(self, "_attrs", attrs) + + def __getattr__(self, name): + attrs = object.__getattribute__(self, "_attrs") + if name in attrs: + return attrs[name] + raise AttributeError(name) + + def __getitem__(self, key): + return object.__getattribute__(self, "_attrs")[key] + + def __iter__(self): + return iter(object.__getattribute__(self, "_attrs").values()) + + +def _ok_status(): + return {"isSuccess": True, "statusDetail": []} + + +def _err_status(detail="boom"): + return {"isSuccess": False, "statusDetail": detail} + + +@pytest.mark.asyncio +async def test_async_decorator_unwraps_path_and_extracts(): + """Decorator must descend `path` and apply `extract` for async SOAP methods.""" + + inner = _FakeResponse(record={"id": 42}, status=_ok_status()) + outer = _FakeResponse(body=_FakeResponse(readResponse=inner)) + + @WebServiceCall( + "body.readResponse", + extract=lambda resp: resp["record"], + ) + async def fake_get(self): + return outer + + result = await fake_get(object()) + assert result == {"id": 42} + + +@pytest.mark.asyncio +async def test_async_decorator_raises_on_failed_status(): + inner = _FakeResponse(record=None, status=_err_status("nope")) + outer = _FakeResponse(body=_FakeResponse(readResponse=inner)) + + @WebServiceCall("body.readResponse") + async def fake_get(self): + return outer + + with pytest.raises(NetsuiteResponseError): + await fake_get(object()) + + +@pytest.mark.asyncio +async def test_async_decorator_passes_through_non_soap_values(): + """If the wrapped function returns a non-CompoundValue (e.g. early-out []), + the decorator should return it untouched without trying to walk `path`.""" + + @WebServiceCall("body.readResponseList.readResponse") + async def fake_getList(self): + return [] + + assert await fake_getList(object()) == [] + + +@pytest.mark.asyncio +async def test_async_decorator_returns_default_when_path_missing(): + outer = _FakeResponse(body=_FakeResponse()) # no `getItemAvailabilityResult` + + @WebServiceCall( + "body.getItemAvailabilityResult", + extract=lambda resp: resp["itemAvailabilityList"]["itemAvailability"], + default=[], + ) + async def fake_getItemAvailability(self): + return outer + + assert await fake_getItemAvailability(object()) == [] + + +def test_sync_decorator_still_works(): + """Sync functions decorated with WebServiceCall should also process responses.""" + + inner = _FakeResponse(record={"id": 7}, status=_ok_status()) + outer = _FakeResponse(body=_FakeResponse(readResponse=inner)) + + @WebServiceCall( + "body.readResponse", + extract=lambda resp: resp["record"], + ) + def fake_get_sync(self): + return outer + + assert fake_get_sync(object()) == {"id": 7} From c6ab7a98145fa6ba79edc864fb2f5fc422bd6de5 Mon Sep 17 00:00:00 2001 From: Vicente Louvet III Date: Fri, 8 May 2026 08:36:33 -0600 Subject: [PATCH 11/29] test(soap_api): expand SOAP client coverage from 3 to 29 tests (#72) (#5) Adds substantive unit tests for `NetSuiteSoapApi`: - WSDL URL construction for sandbox vs production accounts, custom versions, and explicit `wsdl_url` overrides - `AsyncNetSuiteTransport._fix_address` munges the default WSDL host to the account-specific subdomain - Cache injection (default `SqliteCache` vs custom `InMemoryCache`) - `TokenPassport` signature determinism and message format, and that `passport.make` raises for username/password auth - Argument shaping for every public SOAP method (`get`, `getList`, `getAll`, `add`, `update`, `upsert`, `search`, `searchMoreWithId`, `getItemAvailability`) by mocking `request` and the type factories - `request` merges `generate_passport()` output with `additionalHeaders` - `to_builtin` delegates to `zeep.helpers.serialize_object` - `with_timeout` proxies to `transport.settings(timeout=...)` - `_ensure_required_dependencies` raises a `RuntimeError` mentioning the `soap_api` extra when zeep is missing Closes #72 Co-authored-by: Claude Opus 4.7 (1M context) --- tests/test_soap_api.py | 356 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 356 insertions(+) diff --git a/tests/test_soap_api.py b/tests/test_soap_api.py index 2954422..07f716d 100644 --- a/tests/test_soap_api.py +++ b/tests/test_soap_api.py @@ -1,11 +1,22 @@ +from datetime import datetime +from unittest.mock import AsyncMock, MagicMock, patch + import pytest from netsuite import NetSuiteSoapApi +from netsuite.config import Config, TokenAuth +from netsuite.soap_api.passport import TokenPassport, make as make_passport +from netsuite.soap_api.transports import AsyncNetSuiteTransport from netsuite.soap_api.zeep import ZEEP_INSTALLED pytestmark = pytest.mark.skipif(not ZEEP_INSTALLED, reason="Requires zeep") +# --------------------------------------------------------------------------- +# URL / hostname construction +# --------------------------------------------------------------------------- + + def test_netsuite_hostname(dummy_config): soap_api = NetSuiteSoapApi(dummy_config) assert soap_api.hostname == "123456-sb1.suitetalk.api.netsuite.com" @@ -19,6 +30,351 @@ def test_netsuite_wsdl_url(dummy_config): ) +def test_netsuite_wsdl_url_production_account(dummy_config_with_production_account): + soap_api = NetSuiteSoapApi(dummy_config_with_production_account) + assert ( + soap_api.wsdl_url + == "https://123456.suitetalk.api.netsuite.com/wsdl/v2021_1_0/netsuite.wsdl" + ) + + +def test_netsuite_explicit_version(dummy_config): + soap_api = NetSuiteSoapApi(dummy_config, version="2024.1.0") + assert soap_api.version == "2024.1.0" + assert "v2024_1_0" in soap_api.wsdl_url + + +def test_netsuite_invalid_version_rejected(dummy_config): + with pytest.raises(AssertionError): + NetSuiteSoapApi(dummy_config, version="not-a-version") + + +def test_netsuite_explicit_wsdl_url_overrides_default(dummy_config): + custom = "https://example.com/custom.wsdl" + soap_api = NetSuiteSoapApi(dummy_config, wsdl_url=custom) + assert soap_api.wsdl_url == custom + assert soap_api.hostname == "example.com" + + +def test_underscored_version_helpers(dummy_config): + soap_api = NetSuiteSoapApi(dummy_config, version="2024.1.0") + assert soap_api.underscored_version == "2024_1_0" + assert soap_api.underscored_version_no_micro == "2024_1" + + +# --------------------------------------------------------------------------- +# Transport +# --------------------------------------------------------------------------- + + def test_netsuite_transport_initialization(dummy_config): soap_api = NetSuiteSoapApi(dummy_config) soap_api._generate_transport() + + +def test_transport_fixes_address_to_account_subdomain(): + transport = AsyncNetSuiteTransport( + "https://123456-sb1.suitetalk.api.netsuite.com/wsdl/v2021_1_0/netsuite.wsdl", + ) + assert ( + transport._fix_address("https://webservices.netsuite.com/services/NetSuitePort_2021_1") + == "https://123456-sb1.suitetalk.api.netsuite.com/services/NetSuitePort_2021_1" + ) + + +# --------------------------------------------------------------------------- +# Cache injection (regression for the docs added in #87 / issue #86) +# --------------------------------------------------------------------------- + + +def test_default_cache_is_sqlite(dummy_config): + from zeep.cache import SqliteCache + + soap_api = NetSuiteSoapApi(dummy_config) + assert isinstance(soap_api.cache, SqliteCache) + + +def test_custom_cache_is_respected(dummy_config): + from zeep.cache import InMemoryCache + + cache = InMemoryCache() + soap_api = NetSuiteSoapApi(dummy_config, cache=cache) + assert soap_api.cache is cache + + +# --------------------------------------------------------------------------- +# Passport generation +# --------------------------------------------------------------------------- + + +def test_token_passport_signature_is_deterministic(dummy_config): + """Same nonce/timestamp inputs must produce the same signature.""" + soap_api = NetSuiteSoapApi(dummy_config) + auth = dummy_config.auth + assert isinstance(auth, TokenAuth) + passport = TokenPassport( + soap_api, + account=dummy_config.account, + consumer_key=auth.consumer_key, + consumer_secret=auth.consumer_secret, + token_id=auth.token_id, + token_secret=auth.token_secret, + ) + sig1 = passport._get_signature_value(nonce="123", timestamp="456") + sig2 = passport._get_signature_value(nonce="123", timestamp="456") + assert sig1 == sig2 + # And different inputs must produce different signatures. + assert sig1 != passport._get_signature_value(nonce="123", timestamp="457") + + +def test_passport_signature_message_format(dummy_config): + soap_api = NetSuiteSoapApi(dummy_config) + auth = dummy_config.auth + passport = TokenPassport( + soap_api, + account=dummy_config.account, + consumer_key=auth.consumer_key, + consumer_secret=auth.consumer_secret, + token_id=auth.token_id, + token_secret=auth.token_secret, + ) + msg = passport._get_signature_message(nonce="N", timestamp="T") + assert msg.split("&") == [ + dummy_config.account, + auth.consumer_key, + auth.token_id, + "N", + "T", + ] + + +def test_passport_make_rejects_username_password_auth(): + config = Config( + account="123456_SB1", + auth={"username": "user", "password": "pass"}, + ) + soap_api = MagicMock() + with pytest.raises(NotImplementedError): + make_passport(soap_api, config) + + +# --------------------------------------------------------------------------- +# Public API: argument shaping (mocking the underlying `request` method) +# --------------------------------------------------------------------------- + + +def _build_soap_api_with_mocks(config): + """Return a NetSuiteSoapApi whose `request` and type factories are mocked. + + Bypasses zeep client initialization entirely so we can assert argument + shaping without a live WSDL connection. + """ + + soap_api = NetSuiteSoapApi(config) + soap_api.request = AsyncMock(return_value="ok") # type: ignore[method-assign] + + # Replace each cached_property factory with a MagicMock that records + # constructor calls. We patch __dict__ to avoid the cached_property + # descriptor running. + for name in ( + "Core", + "Messages", + "Common", + "Sales", + "Relationships", + "Accounting", + ): + soap_api.__dict__[name] = MagicMock(name=name) + return soap_api + + +@pytest.mark.asyncio +async def test_get_requires_exactly_one_id(dummy_config): + soap_api = _build_soap_api_with_mocks(dummy_config) + # The decorator awaits the inner coroutine, so ValueError is raised when awaited. + with pytest.raises(ValueError): + await soap_api.get("customer") + with pytest.raises(ValueError): + await soap_api.get("customer", internalId=1, externalId="x") + + +@pytest.mark.asyncio +async def test_get_with_internal_id_builds_record_ref(dummy_config): + soap_api = _build_soap_api_with_mocks(dummy_config) + # The decorator will try to walk `body.readResponse` on the AsyncMock's + # return value. Bypass by short-circuiting `request` to a non-CompoundValue. + soap_api.request = AsyncMock(return_value=None) # type: ignore[method-assign] + await soap_api.get("customer", internalId=42) + soap_api.Core.RecordRef.assert_called_once_with(type="customer", internalId=42) + soap_api.request.assert_awaited_once() + assert soap_api.request.await_args.args[0] == "get" + + +@pytest.mark.asyncio +async def test_get_with_external_id_builds_record_ref(dummy_config): + soap_api = _build_soap_api_with_mocks(dummy_config) + soap_api.request = AsyncMock(return_value=None) # type: ignore[method-assign] + await soap_api.get("customer", externalId="abc") + soap_api.Core.RecordRef.assert_called_once_with(type="customer", externalId="abc") + + +@pytest.mark.asyncio +async def test_getList_short_circuits_on_no_ids(dummy_config): + soap_api = _build_soap_api_with_mocks(dummy_config) + soap_api.request = AsyncMock() # type: ignore[method-assign] + assert await soap_api.getList("customer") == [] + soap_api.request.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_getList_builds_record_refs_for_each_id(dummy_config): + soap_api = _build_soap_api_with_mocks(dummy_config) + soap_api.request = AsyncMock(return_value=None) # type: ignore[method-assign] + await soap_api.getList( + "customer", internalIds=[1, 2], externalIds=["e1"] + ) + # 2 internal + 1 external = 3 RecordRef constructions + assert soap_api.Core.RecordRef.call_count == 3 + soap_api.Messages.GetListRequest.assert_called_once() + + +@pytest.mark.asyncio +async def test_getItemAvailability_short_circuits_on_no_ids(dummy_config): + soap_api = _build_soap_api_with_mocks(dummy_config) + soap_api.request = AsyncMock() # type: ignore[method-assign] + assert await soap_api.getItemAvailability() == [] + soap_api.request.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_getItemAvailability_builds_filter_for_ids(dummy_config): + soap_api = _build_soap_api_with_mocks(dummy_config) + soap_api.request = AsyncMock(return_value=None) # type: ignore[method-assign] + when = datetime(2024, 1, 1, 12, 0, 0) + await soap_api.getItemAvailability( + internalIds=[1, 2], + externalIds=["x"], + lastQtyAvailableChange=when, + ) + soap_api.request.assert_awaited_once() + method, *_ = soap_api.request.await_args.args + assert method == "getItemAvailability" + kw = soap_api.request.await_args.kwargs + item_filters = kw["itemAvailabilityFilter"][0]["item"]["recordRef"] + assert len(item_filters) == 3 + assert kw["itemAvailabilityFilter"][0]["lastQtyAvailableChange"] == when + + +@pytest.mark.asyncio +async def test_getAll_passes_record_type(dummy_config): + soap_api = _build_soap_api_with_mocks(dummy_config) + soap_api.request = AsyncMock(return_value=None) # type: ignore[method-assign] + await soap_api.getAll("subsidiary") + soap_api.Core.GetAllRecord.assert_called_once_with(recordType="subsidiary") + + +@pytest.mark.asyncio +async def test_add_update_upsert_pass_record(dummy_config): + soap_api = _build_soap_api_with_mocks(dummy_config) + soap_api.request = AsyncMock(return_value=None) # type: ignore[method-assign] + record = MagicMock(name="record") + await soap_api.add(record) + await soap_api.update(record) + await soap_api.upsert(record) + methods = [c.args[0] for c in soap_api.request.await_args_list] + assert methods == ["add", "update", "upsert"] + for call in soap_api.request.await_args_list: + assert call.kwargs["record"] is record + + +@pytest.mark.asyncio +async def test_search_passes_search_record_and_headers(dummy_config): + soap_api = _build_soap_api_with_mocks(dummy_config) + soap_api.request = AsyncMock(return_value=None) # type: ignore[method-assign] + record = MagicMock(name="searchRecord") + headers = {"searchPreferences": MagicMock()} + await soap_api.search(record=record, additionalHeaders=headers) + soap_api.request.assert_awaited_once() + assert soap_api.request.await_args.args[0] == "search" + assert soap_api.request.await_args.kwargs["searchRecord"] is record + assert soap_api.request.await_args.kwargs["additionalHeaders"] is headers + + +@pytest.mark.asyncio +async def test_searchMoreWithId_passes_pagination_args(dummy_config): + soap_api = _build_soap_api_with_mocks(dummy_config) + soap_api.request = AsyncMock(return_value=None) # type: ignore[method-assign] + await soap_api.searchMoreWithId(searchId="SID", pageIndex=3) + soap_api.request.assert_awaited_once_with( + "searchMoreWithId", searchId="SID", pageIndex=3 + ) + + +@pytest.mark.asyncio +async def test_request_attaches_passport_and_extra_headers(dummy_config): + """`request` must merge the generated passport with `additionalHeaders`.""" + soap_api = NetSuiteSoapApi(dummy_config) + fake_service = MagicMock() + fake_method = AsyncMock(return_value="result") + fake_service.someOp = fake_method + with patch.object( + type(soap_api), "service", new_callable=lambda: property(lambda self: fake_service) + ), patch.object( + soap_api, "generate_passport", return_value={"tokenPassport": "P"} + ): + result = await soap_api.request( + "someOp", + "arg", + additionalHeaders={"extra": "X"}, + kw="v", + ) + assert result == "result" + fake_method.assert_awaited_once() + call = fake_method.await_args + assert call.args == ("arg",) + assert call.kwargs["kw"] == "v" + assert call.kwargs["_soapheaders"] == {"tokenPassport": "P", "extra": "X"} + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def test_to_builtin_serializes_via_zeep_helpers(dummy_config): + soap_api = NetSuiteSoapApi(dummy_config) + sentinel = object() + with patch( + "netsuite.soap_api.helpers.zeep.helpers.serialize_object", + return_value=sentinel, + ) as mock_ser: + out = soap_api.to_builtin("input") + assert out is sentinel + mock_ser.assert_called_once_with("input", target_cls=dict) + + +def test_with_timeout_uses_transport_settings(dummy_config): + soap_api = NetSuiteSoapApi(dummy_config) + fake_settings = MagicMock() + fake_settings.return_value.__enter__ = MagicMock() + fake_settings.return_value.__exit__ = MagicMock(return_value=None) + fake_transport = MagicMock(settings=fake_settings) + with patch.object( + type(soap_api), + "transport", + new_callable=lambda: property(lambda self: fake_transport), + ): + with soap_api.with_timeout(42): + pass + fake_settings.assert_called_once_with(timeout=42) + + +# --------------------------------------------------------------------------- +# Dependency check +# --------------------------------------------------------------------------- + + +def test_missing_zeep_raises_runtime_error(dummy_config): + with patch.object(NetSuiteSoapApi, "_has_required_dependencies", return_value=False): + with pytest.raises(RuntimeError, match="soap_api"): + NetSuiteSoapApi(dummy_config) From a207edf266fd31df2d190300a89145b0422108fd Mon Sep 17 00:00:00 2001 From: Vicente Louvet III Date: Fri, 8 May 2026 08:37:13 -0600 Subject: [PATCH 12/29] feat(rest_api): add suiteql_paginated helper that follows next link (#42) (#6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NetSuite's SuiteQL REST API caps a single response at limit=1000 and a single query at 100,000 rows. Users had to wire up the `next` link themselves — the issue was that `suiteql()` always re-encoded `limit` and `offset` in `params`, which would clash with the offset embedded in the absolute `next` URL. Add `NetSuiteRestApi.suiteql_paginated`: an async generator that runs the initial query, then follows `links[rel=next]` (POSTing the same body to the absolute URL) until `hasMore` is False or no next link is present. The original `suiteql` docstring now explains both the ORDER-BY zero-row quirk (#29) and the >100k partitioning workaround. README/docs also gain a pagination section. Closes #42 Co-authored-by: Claude Opus 4.7 (1M context) --- docs/index.md | 21 +++++++++++ netsuite/rest_api.py | 79 +++++++++++++++++++++++++++++++++++++++++- tests/test_rest_api.py | 75 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 174 insertions(+), 1 deletion(-) diff --git a/docs/index.md b/docs/index.md index b29fb89..14f8389 100644 --- a/docs/index.md +++ b/docs/index.md @@ -122,6 +122,27 @@ async def async_main() -> dict: asyncio.run(async_main()) ``` +## Programmatic use - SuiteQL pagination + +NetSuite caps a single SuiteQL response at `limit=1000` rows, and a single query overall at 100,000 rows. To stream every page of a result set without manually wiring up the `next` link, use `suiteql_paginated`: + +```python +async def fetch_all_transactions(): + rows = [] + async for page in ns.rest_api.suiteql_paginated( + q="SELECT id, tranid FROM transaction", + limit=1000, + ): + rows.extend(page["items"]) + return rows +``` + +Each yielded `page` is the raw NetSuite response dict (with `items`, `count`, `hasMore`, `links`, …). Iteration stops automatically when `hasMore` is False. + +To retrieve more than 100,000 rows from a query, partition by a WHERE clause and run multiple paginated queries — for example by `id` ranges or date windows. + +> **`ORDER BY` caveat.** NetSuite has a known quirk where a SuiteQL query with `ORDER BY` and a small `limit` (the default 10) can return zero items. Either request a larger page (`limit=1000`) or sort client-side after fetching. See [#29](https://github.com/jacobsvante/netsuite/issues/29). + ## Programmatic use - Download Large Files Using SOAP API When working with large files, you might find that responses are truncated if they exceed 10MB. This limitation stems from the default settings in Zeep. To overcome this, enable the `xml_huge_tree` option in the Zeep client settings. diff --git a/netsuite/rest_api.py b/netsuite/rest_api.py index 10cde84..6e55553 100644 --- a/netsuite/rest_api.py +++ b/netsuite/rest_api.py @@ -1,6 +1,6 @@ import logging from functools import cached_property -from typing import Sequence +from typing import Any, AsyncIterator, Dict, Optional, Sequence from . import rest_api_base from .config import Config @@ -10,6 +10,16 @@ __all__ = ("NetSuiteRestApi",) +def _next_link(suiteql_response: Dict[str, Any]) -> Optional[str]: + """Return the absolute URL of the `next` link from a SuiteQL response, or None.""" + if not suiteql_response.get("hasMore"): + return None + for link in suiteql_response.get("links") or (): + if link.get("rel") == "next": + return link.get("href") + return None + + class NetSuiteRestApi(rest_api_base.RestApiBase): def __init__( self, @@ -53,9 +63,19 @@ async def delete(self, subpath: str, **request_kw): # TODO maybe break out params vs poping? async def suiteql(self, q: str, limit: int = 10, offset: int = 0, **request_kw): """ + Run a single SuiteQL query. + Example: >>> suiteql(q="SELECT * FROM Transaction", limit=10, offset=0) + Note on `ORDER BY`: NetSuite has a known quirk where a SuiteQL query + with `ORDER BY` and a small `limit` (the default 10) can return zero + items. If you must order, ask for a larger page (`limit=1000`) or + order client-side after fetching. See jacobsvante/netsuite#29. + + Note on pagination: NetSuite caps `limit` at 1000. To stream every + page until exhaustion, use `suiteql_paginated` instead. + Documentation: - https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/section_156257799794.html#Using-SuiteQL @@ -70,6 +90,63 @@ async def suiteql(self, q: str, limit: int = 10, offset: int = 0, **request_kw): **request_kw, ) + async def suiteql_paginated( + self, + q: str, + *, + limit: int = 1000, + offset: int = 0, + **request_kw, + ) -> AsyncIterator[Dict[str, Any]]: + """ + Async generator yielding each page of a SuiteQL query, following the + `next` link in NetSuite's response until `hasMore` is False. + + Yields the raw page dict (with `items`, `count`, `hasMore`, `links`, + etc.). Use `limit=1000` (the NetSuite max) to minimize round trips. + + Example: + >>> async for page in rest_api.suiteql_paginated(q="SELECT id FROM transaction"): + ... for row in page["items"]: + ... ... + + Caveat: a single SuiteQL query can return at most 100,000 rows in + total — that is a NetSuite-side cap, not a library limitation. To + retrieve more, partition the query with a WHERE clause (e.g. on + `id` ranges or date windows) and run several paginated queries. + See jacobsvante/netsuite#42. + """ + # First page goes through the normal `suiteql` path so users get + # consistent header/param handling. Subsequent pages follow the + # absolute `next` link from each response. + page = await self.suiteql(q, limit=limit, offset=offset, **request_kw) + yield page + + next_url = _next_link(page) + # Subsequent pages reuse the same body; only the URL (with offset) + # changes. We forward `**request_kw` so callers' headers/params + # still apply. + body_kw = { + "headers": {"Prefer": "transient", **request_kw.pop("headers", {})}, + "json": {"q": q, **request_kw.pop("json", {})}, + } + # `params` are encoded in the next URL, so we must not also pass + # them here — that would double-up offset/limit. + request_kw.pop("params", None) + + while next_url is not None: + page = await self._request( + "POST", + # `subpath` is ignored when `url` is provided, but + # `_request_impl` still requires the parameter. + "/query/v1/suiteql", + url=next_url, + **body_kw, + **request_kw, + ) + yield page + next_url = _next_link(page) + async def jsonschema(self, record_type: str, **request_kw): headers = { "Accept": "application/schema+json", diff --git a/tests/test_rest_api.py b/tests/test_rest_api.py index 39f1bd6..16837d6 100644 --- a/tests/test_rest_api.py +++ b/tests/test_rest_api.py @@ -1,6 +1,81 @@ +from unittest.mock import AsyncMock + +import pytest + from netsuite import NetSuiteRestApi def test_expected_hostname(dummy_config): rest_api = NetSuiteRestApi(dummy_config) assert rest_api.hostname == "123456-sb1.suitetalk.api.netsuite.com" + + +@pytest.mark.asyncio +async def test_suiteql_posts_to_query_endpoint(dummy_config): + rest_api = NetSuiteRestApi(dummy_config) + rest_api._request = AsyncMock(return_value={"items": []}) # type: ignore[method-assign] + await rest_api.suiteql(q="SELECT id FROM customer", limit=50, offset=10) + rest_api._request.assert_awaited_once() + args, kwargs = rest_api._request.await_args + assert args == ("POST", "/query/v1/suiteql") + assert kwargs["json"] == {"q": "SELECT id FROM customer"} + assert kwargs["params"] == {"limit": 50, "offset": 10} + assert kwargs["headers"]["Prefer"] == "transient" + + +def _page(items, *, has_more, next_url=None): + page = {"items": items, "hasMore": has_more, "links": []} + if next_url is not None: + page["links"].append({"rel": "next", "href": next_url}) + return page + + +@pytest.mark.asyncio +async def test_suiteql_paginated_follows_next_link_until_exhausted(dummy_config): + """Regression test for jacobsvante/netsuite#42 — pagination must walk the + `next` link until `hasMore` is False, without re-sending the original + `params` (which would double-encode offset/limit).""" + rest_api = NetSuiteRestApi(dummy_config) + pages = [ + _page([1, 2], has_more=True, next_url="https://example.com/page2"), + _page([3, 4], has_more=True, next_url="https://example.com/page3"), + _page([5], has_more=False), + ] + rest_api._request = AsyncMock(side_effect=pages) # type: ignore[method-assign] + + collected = [] + async for page in rest_api.suiteql_paginated( + q="SELECT id FROM transaction", limit=2 + ): + collected.extend(page["items"]) + + assert collected == [1, 2, 3, 4, 5] + assert rest_api._request.await_count == 3 + + # Subsequent calls must use the absolute `url` from the next link, and + # must NOT re-pass `params` (NetSuite's next URL already encodes them). + second_call_kwargs = rest_api._request.await_args_list[1].kwargs + assert second_call_kwargs.get("url") == "https://example.com/page2" + assert "params" not in second_call_kwargs + + +@pytest.mark.asyncio +async def test_suiteql_paginated_stops_when_hasmore_false_on_first_page(dummy_config): + rest_api = NetSuiteRestApi(dummy_config) + rest_api._request = AsyncMock( # type: ignore[method-assign] + return_value=_page([1], has_more=False) + ) + pages = [page async for page in rest_api.suiteql_paginated(q="SELECT 1")] + assert len(pages) == 1 + rest_api._request.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_suiteql_paginated_stops_when_no_next_link(dummy_config): + """`hasMore=true` but no `rel=next` link should still terminate cleanly.""" + rest_api = NetSuiteRestApi(dummy_config) + rest_api._request = AsyncMock( # type: ignore[method-assign] + return_value=_page([1], has_more=True) # no next_url + ) + pages = [page async for page in rest_api.suiteql_paginated(q="SELECT 1")] + assert len(pages) == 1 From 4b58e7d18979627349feb6e2da18633ed35f5192 Mon Sep 17 00:00:00 2001 From: Vicente Louvet III Date: Fri, 8 May 2026 08:40:18 -0600 Subject: [PATCH 13/29] feat(rest_api): warn on SuiteQL ORDER BY with small limit (#29) (#7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NetSuite's SuiteQL REST endpoint has a known quirk where a query with `ORDER BY` and a small `limit` (the default 10) silently returns zero rows. Users hit this and assume the library is broken — see jacobsvante/netsuite#29 where `SELECT id, name FROM subsidiary ORDER BY id` returns no items. We can't fix the NetSuite-side bug, but we can stop users from losing hours debugging it: log a warning whenever `ORDER BY` is detected in the query string and `limit < 1000`, pointing them at the workaround (raise the limit, or sort client-side). Detection uses a word-boundary regex so substrings like `order_by_id` don't trigger false positives. Documented in the `suiteql` docstring. Closes #29 Co-authored-by: Claude Opus 4.7 (1M context) --- netsuite/rest_api.py | 26 +++++++++++++++++++-- tests/test_rest_api.py | 52 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/netsuite/rest_api.py b/netsuite/rest_api.py index 6e55553..e9e5e18 100644 --- a/netsuite/rest_api.py +++ b/netsuite/rest_api.py @@ -1,4 +1,5 @@ import logging +import re from functools import cached_property from typing import Any, AsyncIterator, Dict, Optional, Sequence @@ -20,6 +21,17 @@ def _next_link(suiteql_response: Dict[str, Any]) -> Optional[str]: return None +# Matches an `ORDER BY` clause (case-insensitive). Word boundaries prevent +# false positives on column names like `order_by_id`. The heuristic +# deliberately fires on subquery ORDER BY's too, since those can also +# surface the NetSuite empty-result quirk. +_ORDER_BY_RE = re.compile(r"\border\s+by\b", re.IGNORECASE) + +# Below this threshold, `ORDER BY` is at risk of NetSuite's +# zero-row quirk (jacobsvante/netsuite#29). +_ORDER_BY_SAFE_LIMIT = 1000 + + class NetSuiteRestApi(rest_api_base.RestApiBase): def __init__( self, @@ -70,8 +82,9 @@ async def suiteql(self, q: str, limit: int = 10, offset: int = 0, **request_kw): Note on `ORDER BY`: NetSuite has a known quirk where a SuiteQL query with `ORDER BY` and a small `limit` (the default 10) can return zero - items. If you must order, ask for a larger page (`limit=1000`) or - order client-side after fetching. See jacobsvante/netsuite#29. + items. If you hit this, request a larger page (`limit=1000`) or sort + client-side after fetching. This method also logs a warning when it + detects the pattern. See jacobsvante/netsuite#29. Note on pagination: NetSuite caps `limit` at 1000. To stream every page until exhaustion, use `suiteql_paginated` instead. @@ -80,6 +93,15 @@ async def suiteql(self, q: str, limit: int = 10, offset: int = 0, **request_kw): - https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/section_156257799794.html#Using-SuiteQL """ + if _ORDER_BY_RE.search(q) and limit < _ORDER_BY_SAFE_LIMIT: + logger.warning( + "SuiteQL query contains `ORDER BY` with limit=%d. NetSuite " + "has a known quirk where this combination can return zero " + "rows. Consider raising limit to %d or sorting client-side. " + "See jacobsvante/netsuite#29.", + limit, + _ORDER_BY_SAFE_LIMIT, + ) return await self._request( "POST", "/query/v1/suiteql", diff --git a/tests/test_rest_api.py b/tests/test_rest_api.py index 16837d6..a1675c4 100644 --- a/tests/test_rest_api.py +++ b/tests/test_rest_api.py @@ -1,3 +1,4 @@ +import logging from unittest.mock import AsyncMock import pytest @@ -79,3 +80,54 @@ async def test_suiteql_paginated_stops_when_no_next_link(dummy_config): ) pages = [page async for page in rest_api.suiteql_paginated(q="SELECT 1")] assert len(pages) == 1 + + +@pytest.mark.asyncio +async def test_suiteql_warns_on_order_by_with_small_limit(dummy_config, caplog): + """Regression test for jacobsvante/netsuite#29: warn when an `ORDER BY` + SuiteQL query is combined with a limit that may trigger NetSuite's + zero-row quirk.""" + rest_api = NetSuiteRestApi(dummy_config) + rest_api._request = AsyncMock(return_value={"items": []}) # type: ignore[method-assign] + with caplog.at_level(logging.WARNING, logger="netsuite.rest_api"): + await rest_api.suiteql(q="SELECT id FROM subsidiary ORDER BY id") + assert any("ORDER BY" in r.message for r in caplog.records) + + +@pytest.mark.asyncio +async def test_suiteql_quiet_when_order_by_with_safe_limit(dummy_config, caplog): + rest_api = NetSuiteRestApi(dummy_config) + rest_api._request = AsyncMock(return_value={"items": []}) # type: ignore[method-assign] + with caplog.at_level(logging.WARNING, logger="netsuite.rest_api"): + await rest_api.suiteql( + q="SELECT id FROM subsidiary ORDER BY id", limit=1000 + ) + assert not any("ORDER BY" in r.message for r in caplog.records) + + +@pytest.mark.asyncio +async def test_suiteql_quiet_when_no_order_by(dummy_config, caplog): + rest_api = NetSuiteRestApi(dummy_config) + rest_api._request = AsyncMock(return_value={"items": []}) # type: ignore[method-assign] + with caplog.at_level(logging.WARNING, logger="netsuite.rest_api"): + await rest_api.suiteql(q="SELECT id FROM subsidiary") + assert not any("ORDER BY" in r.message for r in caplog.records) + + +@pytest.mark.asyncio +async def test_suiteql_order_by_detection_is_case_insensitive(dummy_config, caplog): + rest_api = NetSuiteRestApi(dummy_config) + rest_api._request = AsyncMock(return_value={"items": []}) # type: ignore[method-assign] + with caplog.at_level(logging.WARNING, logger="netsuite.rest_api"): + await rest_api.suiteql(q="select id from subsidiary order by id") + assert any("ORDER BY" in r.message for r in caplog.records) + + +@pytest.mark.asyncio +async def test_suiteql_order_by_detection_ignores_substrings(dummy_config, caplog): + """`order_by_id` as a column name shouldn't trigger the warning.""" + rest_api = NetSuiteRestApi(dummy_config) + rest_api._request = AsyncMock(return_value={"items": []}) # type: ignore[method-assign] + with caplog.at_level(logging.WARNING, logger="netsuite.rest_api"): + await rest_api.suiteql(q="SELECT order_by_id FROM custom_table") + assert not any("ORDER BY" in r.message for r in caplog.records) From 4d7ea665d5e4fb004ce12c5f54a251097d3bb736 Mon Sep 17 00:00:00 2001 From: Vicente Louvet III Date: Fri, 8 May 2026 08:53:34 -0600 Subject: [PATCH 14/29] test: raise coverage from 46% to 75%; fix double-call bug in json encoder (#8) Adds 100 new tests across 8 files. Coverage gains by module: config.py 59% -> 100% exceptions.py 57% -> 100% json.py 46% -> 94% rest_api_base.py 46% -> 100% restlet.py 70% -> 100% soap_api/decorators 88% -> 100% soap_api/passport 77% -> 100% soap_api/transports 88% -> 100% cli/helpers 0% -> 92% cli/main 0% -> 55% cli/misc 0% -> 100% cli/rest_api 0% -> 51% cli/restlet 0% -> 63% cli/soap_api 0% -> 69% TOTAL 46% -> 75% Also fixes a 5-year-old bug in `netsuite.json._orjson_default`: it called `_get_encoder(obj)` then invoked the result, but `_get_encoder` already returns the *encoded value*, not the encoder. Any payload containing a Decimal, bytes, set, frozenset, Path, or timedelta would crash with `'str' object is not callable` when orjson is installed. The fix removes the redundant call. Surfaced while writing tests for `nsjson.dumps`. CLI tests gate on `pytest.importorskip("pkg_resources")` so they skip cleanly on Python 3.14 / setuptools 81+ (which dropped `pkg_resources`). Co-authored-by: Claude Opus 4.7 (1M context) --- netsuite/json.py | 7 +- tests/test_cli.py | 149 +++++++++ tests/test_config.py | 201 ++++++++++++ tests/test_exceptions.py | 40 +++ tests/test_json.py | 116 +++++++ tests/test_rest_api_base.py | 316 +++++++++++++++++++ tests/test_restlet.py | 57 ++++ tests/test_soap_api_decorators_edge_cases.py | 82 +++++ tests/test_soap_api_passport.py | 131 ++++++++ tests/test_soap_api_transports.py | 94 ++++++ 10 files changed, 1191 insertions(+), 2 deletions(-) create mode 100644 tests/test_cli.py create mode 100644 tests/test_config.py create mode 100644 tests/test_exceptions.py create mode 100644 tests/test_json.py create mode 100644 tests/test_rest_api_base.py create mode 100644 tests/test_soap_api_decorators_edge_cases.py create mode 100644 tests/test_soap_api_passport.py create mode 100644 tests/test_soap_api_transports.py diff --git a/netsuite/json.py b/netsuite/json.py index 06ef74e..c5e265c 100644 --- a/netsuite/json.py +++ b/netsuite/json.py @@ -35,8 +35,7 @@ def _orjson_default(obj: Any) -> Any: if isinstance(obj, str): return str(obj) else: - encoder = _get_encoder(obj) - return encoder(obj) + return _get_encoder(obj) def _isoformat(o: Union[datetime.date, datetime.time]) -> str: @@ -44,6 +43,10 @@ def _isoformat(o: Union[datetime.date, datetime.time]) -> str: def _get_encoder(obj: Any) -> Any: + """Return the encoded form of `obj` by walking its MRO for a registered + encoder. The function's name is a misnomer kept for backwards + compatibility — it returns the *encoded value*, not the encoder callable. + """ for base in obj.__class__.__mro__[:-1]: try: encoder = _ENCODERS_BY_TYPE[base] diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..4a6804b --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,149 @@ +"""Tests for the CLI surface — argparse wiring, version/help paths, and +config loading helpers. Does not invoke any real NetSuite calls.""" + +import argparse +from unittest.mock import patch + +import pytest + +# `netsuite.cli.misc` imports the deprecated `pkg_resources`, which was +# removed from setuptools 81+. Skip the whole CLI test module if it isn't +# importable so the rest of the suite still runs on newer Pythons. +pytest.importorskip("pkg_resources") + +import importlib # noqa: E402 + +cli_main_module = importlib.import_module("netsuite.cli.main") +from netsuite.cli import helpers, misc, restlet, soap_api # noqa: E402 +from netsuite.cli import rest_api as cli_rest_api # noqa: E402 + + +# --------------------------------------------------------------------------- +# helpers.load_config_or_error +# --------------------------------------------------------------------------- + + +def test_load_config_or_error_returns_config(tmp_path): + ini = tmp_path / "ns.ini" + ini.write_text( + "[netsuite]\n" + "account = 999\n" + "consumer_key = ck\n" + "consumer_secret = cs\n" + "token_id = ti\n" + "token_secret = ts\n" + ) + parser = argparse.ArgumentParser() + config = helpers.load_config_or_error(parser, str(ini), "netsuite") + assert config.account == "999" + + +def test_load_config_or_error_missing_file_calls_parser_error(tmp_path): + parser = argparse.ArgumentParser() + with pytest.raises(SystemExit): + helpers.load_config_or_error(parser, str(tmp_path / "missing.ini"), "x") + + +def test_load_config_or_error_missing_section_calls_parser_error(tmp_path): + ini = tmp_path / "ns.ini" + ini.write_text( + "[netsuite]\n" + "account = 999\n" + "consumer_key = ck\n" + "consumer_secret = cs\n" + "token_id = ti\n" + "token_secret = ts\n" + ) + parser = argparse.ArgumentParser() + with pytest.raises(SystemExit): + helpers.load_config_or_error(parser, str(ini), "missing-section") + + +# --------------------------------------------------------------------------- +# misc — version +# --------------------------------------------------------------------------- + + +def test_misc_version_returns_a_string(): + out = misc.version() + # Doesn't matter what version exactly — just that it's a non-empty string. + assert isinstance(out, str) and out + + +def test_misc_add_parser_registers_version_subcommand(): + parser = argparse.ArgumentParser() + sub = parser.add_subparsers() + version_parser, _ = misc.add_parser(parser, sub) + args = parser.parse_args(["version"]) + assert args.func is misc.version + + +# --------------------------------------------------------------------------- +# Subparser registration — build the full CLI tree without executing it. +# This covers argparse wiring in restlet/rest_api/soap_api. +# --------------------------------------------------------------------------- + + +def _build_full_parser(): + parser = argparse.ArgumentParser() + sub = parser.add_subparsers() + misc.add_parser(parser, sub) + restlet.add_parser(parser, sub) + cli_rest_api.add_parser(parser, sub) + soap_api.add_parser(parser, sub) + return parser + + +def test_full_cli_parser_builds_without_error(): + _build_full_parser() + + +def test_restlet_get_subcommand_parses_required_args(): + parser = _build_full_parser() + args = parser.parse_args(["restlet", "get", "42"]) + assert args.script_id == 42 + assert args.deploy == 1 # default + + +def test_restlet_get_accepts_custom_deploy(): + parser = _build_full_parser() + args = parser.parse_args(["restlet", "get", "42", "-d", "7"]) + assert args.deploy == 7 + + +def test_restlet_post_requires_payload_file(tmp_path): + parser = _build_full_parser() + payload = tmp_path / "payload.json" + payload.write_text('{"k": "v"}') + args = parser.parse_args(["restlet", "post", "42", str(payload)]) + assert args.script_id == 42 + # FileType opens the file for reading. + with args.payload_file as fh: + assert fh.read() == '{"k": "v"}' + + +# --------------------------------------------------------------------------- +# main() — the no-argv help path. We avoid actually invoking commands. +# --------------------------------------------------------------------------- + + +def test_main_with_no_args_prints_help_and_returns(capsys): + """argparse exits with code 2 when a required subparser is missing. + `main()` catches the resulting SystemExit-via-Exception in some Python + versions; in others it bubbles through as SystemExit. Either way the + process must not raise an unrelated exception.""" + with patch("sys.argv", ["netsuite"]): + try: + cli_main_module.main() + except SystemExit: + # argparse can call sys.exit on missing required subcommand; + # that's expected behavior. + pass + + +def test_main_handles_version_subcommand(capsys): + with patch("sys.argv", ["netsuite", "version"]): + cli_main_module.main() + captured = capsys.readouterr() + # `version` was invoked and its return value printed. + assert captured.out.strip() diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..1039e46 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,201 @@ +"""Tests for `netsuite.config` — `Config` properties and `from_env` / +`from_ini` constructors.""" + +from textwrap import dedent + +import pytest + +from netsuite.config import Config, TokenAuth, UsernamePasswordAuth + + +# --------------------------------------------------------------------------- +# Properties +# --------------------------------------------------------------------------- + + +def test_is_token_auth_true(dummy_config): + assert dummy_config.is_token_auth is True + + +def test_is_token_auth_false_for_username_password(dummy_username_password_config): + assert dummy_username_password_config.is_token_auth is False + + +@pytest.mark.parametrize( + "account,expected", + [ + ("123456", False), + ("123456_SB1", True), + ("123456_SB2", True), + ("ABCDE", False), + ("123456_SB10", True), + ], +) +def test_is_sandbox(account, expected, dummy_config): + config = Config(account=account, auth=dummy_config.auth) + assert config.is_sandbox is expected + + +def test_account_number_strips_sandbox_suffix(dummy_config): + assert Config(account="123456_SB1", auth=dummy_config.auth).account_number == "123456" + assert Config(account="123456", auth=dummy_config.auth).account_number == "123456" + + +def test_account_slugified_lowercases_and_replaces_underscore(dummy_config): + assert ( + Config(account="123456_SB1", auth=dummy_config.auth).account_slugified + == "123456-sb1" + ) + + +# --------------------------------------------------------------------------- +# `_reorganize_auth_keys` — splits flat dicts into top-level + nested `auth` +# --------------------------------------------------------------------------- + + +def test_reorganize_auth_keys_splits_token_fields(): + raw = { + "account": "123456", + "consumer_key": "ck", + "consumer_secret": "cs", + "token_id": "ti", + "token_secret": "ts", + "log_level": "DEBUG", + } + out = Config._reorganize_auth_keys(raw) + assert out["account"] == "123456" + assert out["log_level"] == "DEBUG" + assert out["auth"] == { + "consumer_key": "ck", + "consumer_secret": "cs", + "token_id": "ti", + "token_secret": "ts", + } + + +def test_reorganize_auth_keys_splits_username_password_fields(): + raw = {"account": "123456", "username": "u", "password": "p"} + out = Config._reorganize_auth_keys(raw) + assert out["account"] == "123456" + assert out["auth"] == {"username": "u", "password": "p"} + + +def test_reorganize_auth_keys_handles_no_auth_fields(): + out = Config._reorganize_auth_keys({"account": "123456"}) + assert out == {"account": "123456", "auth": {}} + + +# --------------------------------------------------------------------------- +# `Config.from_env` +# --------------------------------------------------------------------------- + + +def test_from_env_builds_token_config(monkeypatch): + monkeypatch.setenv("NETSUITE_ACCOUNT", "999_SB1") + monkeypatch.setenv("NETSUITE_CONSUMER_KEY", "ck" * 18) + monkeypatch.setenv("NETSUITE_CONSUMER_SECRET", "cs" * 18) + monkeypatch.setenv("NETSUITE_TOKEN_ID", "ti" * 18) + monkeypatch.setenv("NETSUITE_TOKEN_SECRET", "ts" * 18) + monkeypatch.setenv("NETSUITE_LOG_LEVEL", "INFO") + config = Config.from_env() + assert config.account == "999_SB1" + assert config.log_level == "INFO" + assert isinstance(config.auth, TokenAuth) + assert config.auth.consumer_key == "ck" * 18 + + +def test_from_env_skips_missing_keys(monkeypatch): + """Only env vars actually set should be forwarded — missing ones + must not show up as empty strings or `None`.""" + # Strip any pre-existing NETSUITE_ vars. + for key in list(__import__("os").environ): + if key.startswith("NETSUITE_"): + monkeypatch.delenv(key, raising=False) + monkeypatch.setenv("NETSUITE_ACCOUNT", "123_SB1") + monkeypatch.setenv("NETSUITE_USERNAME", "u") + monkeypatch.setenv("NETSUITE_PASSWORD", "p") + config = Config.from_env() + assert config.account == "123_SB1" + assert isinstance(config.auth, UsernamePasswordAuth) + + +# --------------------------------------------------------------------------- +# `Config.from_ini` +# --------------------------------------------------------------------------- + + +def _write_ini(tmp_path, body, name="netsuite.ini"): + path = tmp_path / name + path.write_text(dedent(body).lstrip()) + return str(path) + + +def test_from_ini_default_section(tmp_path): + path = _write_ini( + tmp_path, + """ + [netsuite] + auth_type = token + account = 123456 + consumer_key = ck + consumer_secret = cs + token_id = ti + token_secret = ts + """, + ) + config = Config.from_ini(path=path) + assert config.account == "123456" + assert isinstance(config.auth, TokenAuth) + assert config.auth.consumer_key == "ck" + + +def test_from_ini_custom_section(tmp_path): + path = _write_ini( + tmp_path, + """ + [netsuite] + account = wrong + consumer_key = w + consumer_secret = w + token_id = w + token_secret = w + + [prod] + account = 999 + consumer_key = pck + consumer_secret = pcs + token_id = pti + token_secret = pts + """, + ) + config = Config.from_ini(path=path, section="prod") + assert config.account == "999" + assert config.auth.consumer_key == "pck" + + +def test_from_ini_rejects_non_token_auth(tmp_path): + path = _write_ini( + tmp_path, + """ + [netsuite] + auth_type = oauth2 + """, + ) + with pytest.raises(RuntimeError, match="Only token auth"): + Config.from_ini(path=path) + + +def test_from_ini_defaults_auth_type_to_token_when_unspecified(tmp_path): + path = _write_ini( + tmp_path, + """ + [netsuite] + account = 123 + consumer_key = ck + consumer_secret = cs + token_id = ti + token_secret = ts + """, + ) + config = Config.from_ini(path=path) + assert config.is_token_auth diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py new file mode 100644 index 0000000..825f971 --- /dev/null +++ b/tests/test_exceptions.py @@ -0,0 +1,40 @@ +"""Tests for `netsuite.exceptions` and `netsuite.soap_api.exceptions`.""" + +import pytest + +from netsuite.exceptions import ( + NetsuiteAPIRequestError, + NetsuiteAPIResponseParsingError, +) +from netsuite.soap_api.exceptions import NetsuiteResponseError + + +def test_request_error_carries_status_and_text(): + err = NetsuiteAPIRequestError(404, "not found") + assert err.status_code == 404 + assert err.response_text == "not found" + + +def test_request_error_str_format(): + err = NetsuiteAPIRequestError(500, "boom") + assert str(err) == "HTTP500 - boom" + + +def test_response_parsing_error_subclasses_request_error(): + """Callers catching `NetsuiteAPIRequestError` must also catch parsing + errors — the latter signals "we got HTTP 2xx but couldn't decode it", + which is still a request-level failure.""" + err = NetsuiteAPIResponseParsingError(200, "") + assert isinstance(err, NetsuiteAPIRequestError) + assert err.status_code == 200 + assert str(err) == "HTTP200 - " + + +def test_request_error_can_be_raised_and_caught(): + with pytest.raises(NetsuiteAPIRequestError): + raise NetsuiteAPIRequestError(401, "unauthorized") + + +def test_soap_response_error_is_an_exception(): + with pytest.raises(NetsuiteResponseError): + raise NetsuiteResponseError("status detail here") diff --git a/tests/test_json.py b/tests/test_json.py new file mode 100644 index 0000000..e6ccfe4 --- /dev/null +++ b/tests/test_json.py @@ -0,0 +1,116 @@ +"""Tests for `netsuite.json` — the orjson-or-stdlib JSON shim.""" + +import datetime +import importlib +import sys +from decimal import Decimal +from enum import Enum +from pathlib import Path +from unittest.mock import patch +from uuid import UUID + +import pytest + +from netsuite import json as nsjson + + +# --------------------------------------------------------------------------- +# Round-trip: dumps→loads should preserve plain dict/list/scalar payloads +# regardless of which JSON backend is in use. +# --------------------------------------------------------------------------- + + +def test_dumps_loads_round_trips_plain_dict(): + payload = {"a": 1, "b": "two", "c": [1, 2, 3], "d": None, "e": True} + assert nsjson.loads(nsjson.dumps(payload)) == payload + + +def test_dumps_returns_str_not_bytes(): + """Even with orjson (which natively returns bytes) we must hand back str.""" + out = nsjson.dumps({"a": 1}) + assert isinstance(out, str) + + +# --------------------------------------------------------------------------- +# Custom encoders for non-standard types. These only matter for orjson, since +# stdlib json would raise TypeError without `default=` — but the round-trip +# should succeed regardless of which backend is loaded. +# --------------------------------------------------------------------------- + + +class _Color(Enum): + RED = "red" + BLUE = "blue" + + +@pytest.mark.parametrize( + "value,expected_decoded", + [ + (datetime.date(2024, 1, 2), "2024-01-02"), + (datetime.datetime(2024, 1, 2, 3, 4, 5), "2024-01-02T03:04:05"), + (datetime.time(3, 4, 5), "03:04:05"), + (datetime.timedelta(seconds=90), 90.0), + (Decimal("1.5"), 1.5), + (_Color.RED, "red"), + (frozenset([1, 2, 3]), [1, 2, 3]), + (set([1, 2, 3]), [1, 2, 3]), + (Path("/tmp/x"), "/tmp/x"), + (UUID("12345678-1234-5678-1234-567812345678"), "12345678-1234-5678-1234-567812345678"), + ], +) +def test_dumps_encodes_supported_types(value, expected_decoded): + decoded = nsjson.loads(nsjson.dumps({"v": value})) + if isinstance(expected_decoded, list): + # set/frozenset have non-deterministic iteration order + assert sorted(decoded["v"]) == sorted(expected_decoded) + else: + assert decoded["v"] == expected_decoded + + +def test_dumps_bytes_decodes_as_utf8(): + assert nsjson.loads(nsjson.dumps({"v": b"hello"})) == {"v": "hello"} + + +def test_dumps_str_subclass_is_normalized(): + """orjson rejects str subclasses by default — `_orjson_default` falls + back to `str(obj)` for them.""" + + class StrSub(str): + pass + + if not nsjson.HAS_ORJSON: + pytest.skip("Only orjson rejects str subclasses without a default hook") + assert nsjson.loads(nsjson.dumps({"v": StrSub("x")})) == {"v": "x"} + + +def test_dumps_unsupported_type_raises(): + """Types with no encoder should raise from `_get_encoder`.""" + + class Nope: + pass + + if not nsjson.HAS_ORJSON: + pytest.skip( + "Stdlib json raises TypeError directly without going through _get_encoder" + ) + with pytest.raises(TypeError, match="Nope"): + nsjson.dumps({"v": Nope()}) + + +# --------------------------------------------------------------------------- +# Backend selection — exercise the stdlib fallback by reloading the module +# with orjson hidden. +# --------------------------------------------------------------------------- + + +def test_stdlib_fallback_is_used_when_orjson_unavailable(): + real_orjson = sys.modules.pop("orjson", None) + try: + with patch.dict(sys.modules, {"orjson": None}): + reloaded = importlib.reload(nsjson) + assert reloaded.HAS_ORJSON is False + assert reloaded.loads(reloaded.dumps({"a": 1})) == {"a": 1} + finally: + if real_orjson is not None: + sys.modules["orjson"] = real_orjson + importlib.reload(nsjson) diff --git a/tests/test_rest_api_base.py b/tests/test_rest_api_base.py new file mode 100644 index 0000000..522d6c0 --- /dev/null +++ b/tests/test_rest_api_base.py @@ -0,0 +1,316 @@ +"""Tests for `RestApiBase` — the shared HTTP plumbing under both +`NetSuiteRestApi` and `NetSuiteRestlet`.""" + +import asyncio +import logging +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +import pytest + +from netsuite.exceptions import ( + NetsuiteAPIRequestError, + NetsuiteAPIResponseParsingError, +) +from netsuite.rest_api_base import ( + DEFAULT_SIGNATURE_METHOD, + RestApiBase, + authlib_hmac_sha256_sign_method, +) + + +class _ConcreteApi(RestApiBase): + """Minimal concretion so we can exercise `RestApiBase` directly.""" + + def __init__(self, config): + self._config = config + self._default_timeout = 10 + self._concurrent_requests = 5 + + def _make_url(self, subpath): + return f"https://example.com{subpath}" + + +def _httpx_response(status_code, text, headers=None): + request = httpx.Request("GET", "https://example.com/x") + return httpx.Response( + status_code, content=text.encode("utf-8"), headers=headers or {}, request=request + ) + + +# --------------------------------------------------------------------------- +# `_request` — wraps `_request_impl` with status checks and JSON decoding +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_request_returns_decoded_json_on_2xx(dummy_config): + api = _ConcreteApi(dummy_config) + api._request_impl = AsyncMock( + return_value=_httpx_response(200, '{"ok": true}') + ) + assert await api._request("GET", "/x") == {"ok": True} + + +@pytest.mark.asyncio +async def test_request_returns_none_on_204(dummy_config): + api = _ConcreteApi(dummy_config) + api._request_impl = AsyncMock(return_value=_httpx_response(204, "")) + assert await api._request("DELETE", "/x") is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize("status_code", [400, 401, 403, 404, 500, 503]) +async def test_request_raises_on_non_2xx(dummy_config, status_code): + api = _ConcreteApi(dummy_config) + api._request_impl = AsyncMock( + return_value=_httpx_response(status_code, "boom") + ) + with pytest.raises(NetsuiteAPIRequestError) as excinfo: + await api._request("GET", "/x") + assert excinfo.value.status_code == status_code + assert excinfo.value.response_text == "boom" + assert str(status_code) in str(excinfo.value) + + +@pytest.mark.asyncio +async def test_request_raises_parse_error_on_invalid_json(dummy_config): + api = _ConcreteApi(dummy_config) + api._request_impl = AsyncMock( + return_value=_httpx_response(200, "not json") + ) + with pytest.raises(NetsuiteAPIResponseParsingError) as excinfo: + await api._request("GET", "/x") + # The parse error subclasses request error and carries the same payload. + assert excinfo.value.status_code == 200 + assert excinfo.value.response_text == "not json" + + +# --------------------------------------------------------------------------- +# `_request_impl` — argument shaping into httpx +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_request_impl_uses_make_url_when_no_url_kwarg(dummy_config): + api = _ConcreteApi(dummy_config) + captured = {} + + class _FakeClient: + async def __aenter__(self): + return self + + async def __aexit__(self, *a): + return False + + async def request(self, **kw): + captured.update(kw) + return _httpx_response(200, "{}") + + with patch("netsuite.rest_api_base.httpx.AsyncClient", return_value=_FakeClient()): + await api._request_impl("get", "/widgets", params={"a": 1}) + + # `method` must be uppercased; URL comes from `_make_url`. + assert captured["method"] == "GET" + assert captured["url"] == "https://example.com/widgets" + assert captured["params"] == {"a": 1} + # Default headers must be merged in. + assert captured["headers"]["Content-Type"] == "application/json" + assert captured["timeout"] == 10 # `_default_timeout` + + +@pytest.mark.asyncio +async def test_request_impl_url_kwarg_overrides_subpath(dummy_config): + """Passing `url=` must bypass `_make_url` — used by SuiteQL pagination + and `token_info`.""" + api = _ConcreteApi(dummy_config) + captured = {} + + class _FakeClient: + async def __aenter__(self): + return self + + async def __aexit__(self, *a): + return False + + async def request(self, **kw): + captured.update(kw) + return _httpx_response(200, "{}") + + with patch("netsuite.rest_api_base.httpx.AsyncClient", return_value=_FakeClient()): + await api._request_impl( + "POST", "ignored", url="https://override.example.com/foo" + ) + + assert captured["url"] == "https://override.example.com/foo" + + +@pytest.mark.asyncio +async def test_request_impl_serializes_json_to_data(dummy_config): + """`json=` must be popped, dumped to a string, and forwarded as `data=`. + httpx would otherwise re-serialize and double-encode.""" + api = _ConcreteApi(dummy_config) + captured = {} + + class _FakeClient: + async def __aenter__(self): + return self + + async def __aexit__(self, *a): + return False + + async def request(self, **kw): + captured.update(kw) + return _httpx_response(200, "{}") + + with patch("netsuite.rest_api_base.httpx.AsyncClient", return_value=_FakeClient()): + await api._request_impl("POST", "/x", json={"q": "SELECT 1"}) + + assert "json" not in captured + assert captured["data"] == '{"q": "SELECT 1"}' or captured["data"] == '{"q":"SELECT 1"}' + + +@pytest.mark.asyncio +async def test_request_impl_caller_headers_override_defaults(dummy_config): + api = _ConcreteApi(dummy_config) + captured = {} + + class _FakeClient: + async def __aenter__(self): + return self + + async def __aexit__(self, *a): + return False + + async def request(self, **kw): + captured.update(kw) + return _httpx_response(200, "{}") + + with patch("netsuite.rest_api_base.httpx.AsyncClient", return_value=_FakeClient()): + await api._request_impl( + "GET", + "/x", + headers={"Content-Type": "application/schema+json", "X-Extra": "1"}, + ) + + assert captured["headers"]["Content-Type"] == "application/schema+json" + assert captured["headers"]["X-Extra"] == "1" + + +@pytest.mark.asyncio +async def test_request_impl_caller_timeout_overrides_default(dummy_config): + api = _ConcreteApi(dummy_config) + captured = {} + + class _FakeClient: + async def __aenter__(self): + return self + + async def __aexit__(self, *a): + return False + + async def request(self, **kw): + captured.update(kw) + return _httpx_response(200, "{}") + + with patch("netsuite.rest_api_base.httpx.AsyncClient", return_value=_FakeClient()): + await api._request_impl("GET", "/x", timeout=99) + + assert captured["timeout"] == 99 + + +@pytest.mark.asyncio +async def test_request_impl_logs_at_debug_level(dummy_config, caplog): + api = _ConcreteApi(dummy_config) + + class _FakeClient: + async def __aenter__(self): + return self + + async def __aexit__(self, *a): + return False + + async def request(self, **kw): + return _httpx_response(200, "{}", headers={"X-Foo": "bar"}) + + with patch("netsuite.rest_api_base.httpx.AsyncClient", return_value=_FakeClient()): + with caplog.at_level(logging.DEBUG, logger="netsuite.rest_api_base"): + await api._request_impl("GET", "/x") + + debug_msgs = [r.message for r in caplog.records] + assert any("Making GET request" in m for m in debug_msgs) + assert any("response headers" in m for m in debug_msgs) + + +# --------------------------------------------------------------------------- +# `_make_url` is abstract; `_make_auth` and `_make_default_headers` defaults +# --------------------------------------------------------------------------- + + +def test_base_make_url_is_abstract(dummy_config): + api = RestApiBase() + api._config = dummy_config + with pytest.raises(NotImplementedError): + api._make_url("/x") + + +def test_base_default_headers(dummy_config): + assert RestApiBase()._make_default_headers() == {"Content-Type": "application/json"} + + +def test_make_auth_passes_token_credentials(dummy_config): + api = _ConcreteApi(dummy_config) + api._signature_method = DEFAULT_SIGNATURE_METHOD + auth = api._make_auth() + # OAuth1Auth stores credentials directly as attributes on itself. + assert auth.client_id == dummy_config.auth.consumer_key + assert auth.client_secret == dummy_config.auth.consumer_secret + assert auth.token == dummy_config.auth.token_id + assert auth.token_secret == dummy_config.auth.token_secret + assert auth.realm == dummy_config.account + + +# --------------------------------------------------------------------------- +# Concurrency control +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_request_semaphore_is_lazily_created_and_caches(dummy_config): + api = _ConcreteApi(dummy_config) + sem = api._request_semaphore + assert isinstance(sem, asyncio.Semaphore) + # Cached: second access returns the same instance. + assert api._request_semaphore is sem + + +# --------------------------------------------------------------------------- +# HMAC-SHA256 signing +# --------------------------------------------------------------------------- + + +def test_authlib_hmac_sha256_signs_via_oauthlib(): + """The custom signing method should defer to oauthlib's `sign_hmac_sha256` + using authlib's signature base string.""" + fake_request = MagicMock() + fake_client = MagicMock(client_secret="secret", token_secret="ts") + with patch( + "netsuite.rest_api_base.generate_signature_base_string", + return_value="base", + ) as mock_base, patch( + "netsuite.rest_api_base.sign_hmac_sha256", + return_value="signed", + ) as mock_sign: + sig = authlib_hmac_sha256_sign_method(fake_client, fake_request) + + mock_base.assert_called_once_with(fake_request) + mock_sign.assert_called_once_with("base", "secret", "ts") + assert sig == "signed" + + +def test_hmac_sha256_method_is_registered_on_authlib(): + """Importing `rest_api_base` should register HMAC-SHA256 with authlib's + `ClientAuth`. This is what lets `_make_auth` produce SHA256 signatures.""" + from authlib.oauth1.rfc5849.client_auth import ClientAuth + + assert "HMAC-SHA256" in ClientAuth.SIGNATURE_METHODS diff --git a/tests/test_restlet.py b/tests/test_restlet.py index 402823f..18dd661 100644 --- a/tests/test_restlet.py +++ b/tests/test_restlet.py @@ -1,6 +1,63 @@ +"""Tests for `NetSuiteRestlet`. Previously only `hostname` was tested.""" + +from unittest.mock import AsyncMock + +import pytest + from netsuite import NetSuiteRestlet def test_expected_hostname(dummy_config): restlet = NetSuiteRestlet(dummy_config) assert restlet.hostname == "123456-sb1.restlets.api.netsuite.com" + + +def test_hostname_for_production_account(dummy_config_with_production_account): + restlet = NetSuiteRestlet(dummy_config_with_production_account) + assert restlet.hostname == "123456.restlets.api.netsuite.com" + + +def test_make_url_includes_restlet_path(dummy_config): + restlet = NetSuiteRestlet(dummy_config) + url = restlet._make_url("?script=42&deploy=1") + assert ( + url + == "https://123456-sb1.restlets.api.netsuite.com/app/site/hosting/restlet.nl?script=42&deploy=1" + ) + + +def test_make_restlet_params_default_deploy(dummy_config): + restlet = NetSuiteRestlet(dummy_config) + assert restlet._make_restlet_params(123) == "?script=123&deploy=1" + + +def test_make_restlet_params_custom_deploy(dummy_config): + restlet = NetSuiteRestlet(dummy_config) + assert restlet._make_restlet_params(123, deploy=7) == "?script=123&deploy=7" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "verb,expected_method", + [("get", "GET"), ("post", "POST"), ("put", "PUT"), ("delete", "DELETE")], +) +async def test_each_verb_calls_request_with_script_subpath( + dummy_config, verb, expected_method +): + restlet = NetSuiteRestlet(dummy_config) + restlet._request = AsyncMock(return_value=None) # type: ignore[method-assign] + method = getattr(restlet, verb) + await method(123, deploy=2, json={"foo": "bar"}) + restlet._request.assert_awaited_once() + args, kwargs = restlet._request.await_args + assert args == (expected_method, "?script=123&deploy=2") + assert kwargs["json"] == {"foo": "bar"} + + +@pytest.mark.asyncio +async def test_default_deploy_is_one(dummy_config): + restlet = NetSuiteRestlet(dummy_config) + restlet._request = AsyncMock(return_value=None) # type: ignore[method-assign] + await restlet.get(987) + args, _ = restlet._request.await_args + assert args == ("GET", "?script=987&deploy=1") diff --git a/tests/test_soap_api_decorators_edge_cases.py b/tests/test_soap_api_decorators_edge_cases.py new file mode 100644 index 0000000..b8de858 --- /dev/null +++ b/tests/test_soap_api_decorators_edge_cases.py @@ -0,0 +1,82 @@ +"""Edge-case tests for `WebServiceCall` not covered in +`test_soap_api_decorators.py`. + +Focus: the list-status code path (TypeError when indexing the response with +"status" because it's iterable rather than a mapping), and the unset-default +re-raise.""" + +import pytest + +from netsuite.soap_api.exceptions import NetsuiteResponseError +from netsuite.soap_api.zeep import ZEEP_INSTALLED + +pytestmark = pytest.mark.skipif(not ZEEP_INSTALLED, reason="Requires zeep") + +if ZEEP_INSTALLED: + from zeep.xsd.valueobjects import CompoundValue + + from netsuite.soap_api.decorators import WebServiceCall + + +class _AttrAndItemResponse(CompoundValue): + def __init__(self, **attrs): + object.__setattr__(self, "_attrs", attrs) + + def __getattr__(self, name): + attrs = object.__getattribute__(self, "_attrs") + if name in attrs: + return attrs[name] + raise AttributeError(name) + + def __getitem__(self, key): + return object.__getattribute__(self, "_attrs")[key] + + +@pytest.mark.asyncio +async def test_decorator_status_via_list_iteration_when_indexing_raises(): + """When path traversal lands on a plain list (e.g. a record list), the + decorator's `response['status']` raises TypeError. It must then iterate + the list and pull `status` from the first record.""" + + record = _AttrAndItemResponse(status={"isSuccess": True, "statusDetail": []}) + # `body.records` resolves to a plain Python list — `list['status']` + # raises TypeError, exercising the fallback iteration path. + outer = _AttrAndItemResponse(body=_AttrAndItemResponse(records=[record])) + + @WebServiceCall("body.records", extract=lambda resp: list(resp)) + async def fake_op(self): + return outer + + out = await fake_op(object()) + assert out == [record] + + +@pytest.mark.asyncio +async def test_decorator_status_via_list_iteration_propagates_error(): + record = _AttrAndItemResponse( + status={"isSuccess": False, "statusDetail": "first record failed"} + ) + outer = _AttrAndItemResponse(body=_AttrAndItemResponse(records=[record])) + + @WebServiceCall("body.records") + async def fake_op(self): + return outer + + with pytest.raises(NetsuiteResponseError): + await fake_op(object()) + + +@pytest.mark.asyncio +async def test_decorator_reraises_attribute_error_when_default_unset(): + """If walking `path` hits an AttributeError and no `default` was + supplied, the original AttributeError should propagate rather than be + silently swallowed.""" + + outer = _AttrAndItemResponse(body=_AttrAndItemResponse()) # no `readResponse` + + @WebServiceCall("body.readResponse") # no default + async def fake_get(self): + return outer + + with pytest.raises(AttributeError): + await fake_get(object()) diff --git a/tests/test_soap_api_passport.py b/tests/test_soap_api_passport.py new file mode 100644 index 0000000..8f25c8d --- /dev/null +++ b/tests/test_soap_api_passport.py @@ -0,0 +1,131 @@ +"""Tests for SOAP TokenPassport — signature/nonce/timestamp generation, and +the `make` factory that wraps it for outbound headers.""" + +import re +from unittest.mock import MagicMock + +import pytest + +from netsuite.config import Config +from netsuite.soap_api.passport import Passport, TokenPassport, make as make_passport +from netsuite.soap_api.zeep import ZEEP_INSTALLED + +pytestmark = pytest.mark.skipif(not ZEEP_INSTALLED, reason="Requires zeep") + + +def _make_passport(config): + auth = config.auth + fake_ns = MagicMock() + return TokenPassport( + fake_ns, + account=config.account, + consumer_key=auth.consumer_key, + consumer_secret=auth.consumer_secret, + token_id=auth.token_id, + token_secret=auth.token_secret, + ) + + +def test_base_passport_get_element_is_abstract(): + with pytest.raises(NotImplementedError): + Passport().get_element() + + +def test_generate_timestamp_is_unix_seconds(dummy_config): + passport = _make_passport(dummy_config) + ts = passport._generate_timestamp() + # Sanity-check: NetSuite's accepted timestamps are seconds since epoch. + assert ts.isdigit() + assert int(ts) > 1_500_000_000 # post-2017 + + +def test_generate_nonce_is_digits_with_default_length(dummy_config): + passport = _make_passport(dummy_config) + nonce = passport._generate_nonce() + assert re.fullmatch(r"\d{20}", nonce) + + +def test_generate_nonce_respects_custom_length(dummy_config): + passport = _make_passport(dummy_config) + nonce = passport._generate_nonce(length=8) + assert re.fullmatch(r"\d{8}", nonce) + + +def test_signature_message_components(dummy_config): + passport = _make_passport(dummy_config) + msg = passport._get_signature_message(nonce="N", timestamp="T") + assert msg == "&".join( + [ + dummy_config.account, + dummy_config.auth.consumer_key, + dummy_config.auth.token_id, + "N", + "T", + ] + ) + + +def test_signature_key_combines_secrets(dummy_config): + passport = _make_passport(dummy_config) + key = passport._get_signature_key() + assert ( + key + == f"{dummy_config.auth.consumer_secret}&{dummy_config.auth.token_secret}" + ) + + +def test_signature_value_changes_when_inputs_change(dummy_config): + passport = _make_passport(dummy_config) + assert passport._get_signature_value("a", "1") != passport._get_signature_value( + "a", "2" + ) + assert passport._get_signature_value("a", "1") != passport._get_signature_value( + "b", "1" + ) + + +def test_get_signature_uses_core_token_passport_signature(dummy_config): + """`_get_signature` should construct a `Core.TokenPassportSignature` with + the HMAC-SHA256 algorithm marker.""" + passport = _make_passport(dummy_config) + passport.ns.Core.TokenPassportSignature = MagicMock(return_value="sig-obj") + result = passport._get_signature(nonce="N", timestamp="T") + passport.ns.Core.TokenPassportSignature.assert_called_once() + args, kwargs = passport.ns.Core.TokenPassportSignature.call_args + assert kwargs == {"algorithm": "HMAC-SHA256"} + # The first positional is the base64 signature value. + assert isinstance(args[0], str) + assert result == "sig-obj" + + +def test_get_element_returns_token_passport_with_all_fields(dummy_config): + passport = _make_passport(dummy_config) + passport.ns.Core.TokenPassportSignature = MagicMock(return_value="sig") + passport.ns.Core.TokenPassport = MagicMock(return_value="token-passport") + result = passport.get_element() + passport.ns.Core.TokenPassport.assert_called_once() + kwargs = passport.ns.Core.TokenPassport.call_args.kwargs + assert kwargs["account"] == dummy_config.account + assert kwargs["consumerKey"] == dummy_config.auth.consumer_key + assert kwargs["token"] == dummy_config.auth.token_id + assert kwargs["nonce"].isdigit() + assert kwargs["timestamp"].isdigit() + assert kwargs["signature"] == "sig" + assert result == "token-passport" + + +def test_make_returns_token_passport_dict_for_token_auth(dummy_config): + fake_ns = MagicMock() + fake_ns.Core.TokenPassport.return_value = "tp" + fake_ns.Core.TokenPassportSignature.return_value = "sig" + out = make_passport(fake_ns, dummy_config) + assert "tokenPassport" in out + + +def test_make_rejects_username_password_auth(): + config = Config( + account="123456_SB1", + auth={"username": "u", "password": "p"}, + ) + with pytest.raises(NotImplementedError): + make_passport(MagicMock(), config) diff --git a/tests/test_soap_api_transports.py b/tests/test_soap_api_transports.py new file mode 100644 index 0000000..328a7e6 --- /dev/null +++ b/tests/test_soap_api_transports.py @@ -0,0 +1,94 @@ +"""Tests for `AsyncNetSuiteTransport` — the zeep transport that forces +each request to the account-specific NetSuite subdomain.""" + +from unittest.mock import AsyncMock, patch + +import pytest + +from netsuite.soap_api.transports import AsyncNetSuiteTransport +from netsuite.soap_api.zeep import ZEEP_INSTALLED + +pytestmark = pytest.mark.skipif(not ZEEP_INSTALLED, reason="Requires zeep") + + +def test_init_extracts_base_url_from_wsdl_url(): + transport = AsyncNetSuiteTransport( + "https://123-sb1.suitetalk.api.netsuite.com/wsdl/v2021_1_0/netsuite.wsdl", + ) + assert ( + transport._netsuite_base_url + == "https://123-sb1.suitetalk.api.netsuite.com" + ) + + +def test_init_handles_url_without_path(): + transport = AsyncNetSuiteTransport("https://example.com") + assert transport._netsuite_base_url == "https://example.com" + + +def test_fix_address_swaps_default_host_for_account_host(): + transport = AsyncNetSuiteTransport( + "https://123-sb1.suitetalk.api.netsuite.com/wsdl/v2021_1_0/netsuite.wsdl", + ) + assert ( + transport._fix_address( + "https://webservices.netsuite.com/services/NetSuitePort_2021_1" + ) + == "https://123-sb1.suitetalk.api.netsuite.com/services/NetSuitePort_2021_1" + ) + + +def test_fix_address_preserves_query_string(): + transport = AsyncNetSuiteTransport( + "https://acct.suitetalk.api.netsuite.com/wsdl/v2021_1_0/netsuite.wsdl", + ) + fixed = transport._fix_address( + "https://webservices.netsuite.com/services/NetSuitePort_2021_1?wsdl" + ) + assert fixed == "https://acct.suitetalk.api.netsuite.com/services/NetSuitePort_2021_1?wsdl" + + +@pytest.mark.asyncio +async def test_get_passes_through_fixed_address(): + transport = AsyncNetSuiteTransport( + "https://acct.suitetalk.api.netsuite.com/wsdl/v.wsdl", + ) + with patch( + "zeep.transports.AsyncTransport.get", + new_callable=AsyncMock, + return_value="resp", + ) as parent_get: + result = await transport.get( + "https://webservices.netsuite.com/services/foo", + params={"k": "v"}, + headers={"H": "1"}, + ) + assert result == "resp" + parent_get.assert_awaited_once_with( + "https://acct.suitetalk.api.netsuite.com/services/foo", + {"k": "v"}, + {"H": "1"}, + ) + + +@pytest.mark.asyncio +async def test_post_passes_through_fixed_address(): + transport = AsyncNetSuiteTransport( + "https://acct.suitetalk.api.netsuite.com/wsdl/v.wsdl", + ) + with patch( + "zeep.transports.AsyncTransport.post", + new_callable=AsyncMock, + return_value="resp", + ) as parent_post: + result = await transport.post( + "https://webservices.netsuite.com/services/foo", + "", + {"H": "1"}, + ) + assert result == "resp" + parent_post.assert_awaited_once_with( + "https://acct.suitetalk.api.netsuite.com/services/foo", + "", + {"H": "1"}, + ) From c99fbb4735d63c87cf763d8aa2f53c9f5d251b41 Mon Sep 17 00:00:00 2001 From: Vicente Louvet III Date: Fri, 8 May 2026 09:18:16 -0600 Subject: [PATCH 15/29] chore(ci): trim workflows for unofficial-fork posture (#9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(ci): trim workflows for unofficial-fork posture Drop the publishing/distribution machinery the upstream uses to ship to PyPI and gh-pages — this fork is a friendly fork that does not publish a package. What remains is the dev-loop automation: tests, style, types, coverage. - Delete `.github/workflows/cd.yml` (release-please + `poetry publish`). - Delete `.github/workflows/docs.yml` (mkdocs -> gh-pages deploy). The in-repo `docs/` markdown still renders on github.com. - `ci.yml` and `codecov.yml`: collapse upstream's 75-cell matrix (5 Pythons x 5 extras x 3 OSes) to a single combination — Python 3.12 on ubuntu-latest with `--extras all`. Forks don't need the full compatibility surface; one combination is enough to catch regressions on every PR. - Drop the `.tool-versions` / asdf-parse-action plumbing since the matrix no longer reads versions from there. Also reformat the test files added in #8 to satisfy the `style` job (black + isort). Production code is unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) * ci(deps): add pytest-asyncio dev-dep so async tests can run The async test files added in #5/#6/#7/#8 (tests/test_rest_api.py, test_soap_api.py, test_rest_api_base.py, test_restlet.py, test_soap_api_decorators*.py, test_soap_api_transports.py) all use `@pytest.mark.asyncio`, but the dev-dependency was never declared in pyproject.toml. The local development environment had pytest-asyncio installed transitively, so `pytest -q` showed 149 passing — masking the gap until CI was actually exercised. This PR (chore/fork-ci-config) is the first one to enable a working CI matrix on the fork, so it's where the failure surfaces. CI runs `poetry install --extras all` from a clean state and then `pytest`, which collects the async tests but has no plugin to drive them, fails collection on all 51 with: Failed: async def functions are not natively supported. You need to install a suitable plugin for your async framework, for example: anyio, pytest-asyncio, ... Add `pytest-asyncio = "~0.23"` to `[tool.poetry.dev-dependencies]` between pytest and pytest-cov. Also pin `asyncio_mode = "strict"` in `[tool.pytest.ini_options]` to match how the existing tests are written (each is explicitly marked with `@pytest.mark.asyncio`) and to silence pytest-asyncio 0.23+'s deprecation warning about leaving the mode unset. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .github/workflows/cd.yml | 37 ------------------------------- .github/workflows/ci.yml | 27 ++++------------------ .github/workflows/codecov.yml | 8 +------ .github/workflows/docs.yml | 26 ---------------------- pyproject.toml | 2 ++ tests/test_cli.py | 4 ++-- tests/test_config.py | 5 +++-- tests/test_json.py | 6 +++-- tests/test_rest_api.py | 4 +--- tests/test_rest_api_base.py | 18 ++++++++------- tests/test_soap_api.py | 23 ++++++++++--------- tests/test_soap_api_passport.py | 6 ++--- tests/test_soap_api_transports.py | 10 ++++----- 13 files changed, 48 insertions(+), 128 deletions(-) delete mode 100644 .github/workflows/cd.yml delete mode 100644 .github/workflows/docs.yml diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml deleted file mode 100644 index 2f7ef59..0000000 --- a/.github/workflows/cd.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: cd -on: - push: - branches: [main] - -jobs: - release-please: - runs-on: ubuntu-latest - outputs: - release_created: ${{ steps.release.outputs.release_created }} - steps: - - uses: google-github-actions/release-please-action@v4 - id: release - with: - release-type: python - package-name: netsuite - bump-minor-pre-major: true - cd: - runs-on: ubuntu-latest - needs: [release-please] - if: needs.release-please.outputs.release_created - steps: - - uses: actions/checkout@v4 - - name: ASDF Parse - uses: kota65535/github-asdf-parse-action@v2.0.0 - id: versions - - uses: actions/setup-python@v5 - with: - python-version: "${{ steps.versions.outputs.python }}" - architecture: x64 - - uses: abatilo/actions-poetry@v4.0.0 - with: - poetry-version: "${{ steps.versions.outputs.poetry }}" - - run: poetry build - - run: poetry publish - env: - POETRY_PYPI_TOKEN_PYPI: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 13065b0..9dfcc9e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,43 +7,24 @@ on: jobs: unittests: - runs-on: ${{ matrix.os }} - strategy: - matrix: - python-version: ["3.13", "3.12", "3.11", "3.10", "3.9"] - extras: ["", all, soap_api, orjson, cli] - os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: ASDF Parse - uses: kota65535/github-asdf-parse-action@v2.0.0 - id: versions - uses: actions/setup-python@v5 with: - python-version: "${{ matrix.python-version }}" + python-version: "3.12" - uses: abatilo/actions-poetry@v4.0.0 - with: - poetry-version: "${{ steps.versions.outputs.poetry }}" - - run: poetry install --extras ${{ matrix.extras }} - if: matrix.extras != '' - - run: poetry install - if: matrix.extras == '' + - run: poetry install --extras all - run: poetry run pytest -v style: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: ASDF Parse - uses: kota65535/github-asdf-parse-action@v2.0.0 - id: versions - uses: actions/setup-python@v5 with: - python-version: "${{ steps.versions.outputs.python }}" - architecture: x64 + python-version: "3.12" - uses: abatilo/actions-poetry@v4.0.0 - with: - poetry-version: "${{ steps.versions.outputs.poetry }}" - run: poetry install --extras all - run: poetry run flake8 - run: poetry run mypy --ignore-missing-imports . diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml index ac443cd..1e22cb4 100644 --- a/.github/workflows/codecov.yml +++ b/.github/workflows/codecov.yml @@ -10,16 +10,10 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: ASDF Parse - uses: kota65535/github-asdf-parse-action@v2.0.0 - id: versions - uses: actions/setup-python@v5 with: - python-version: "${{ steps.versions.outputs.python }}" - architecture: x64 + python-version: "3.12" - uses: abatilo/actions-poetry@v4.0.0 - with: - poetry-version: "${{ steps.versions.outputs.poetry }}" - run: poetry install --extras all - run: poetry run pytest --cov=netsuite --cov-report=xml --cov-report=term - uses: codecov/codecov-action@v5 diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml deleted file mode 100644 index ff4ba03..0000000 --- a/.github/workflows/docs.yml +++ /dev/null @@ -1,26 +0,0 @@ -name: docs -on: - push: - branches: [main] - -jobs: - deploy: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: ASDF Parse - uses: kota65535/github-asdf-parse-action@v2.0.0 - id: versions - - uses: actions/setup-python@v5 - with: - python-version: "${{ steps.versions.outputs.python }}" - architecture: x64 - - uses: abatilo/actions-poetry@v4.0.0 - with: - poetry-version: "${{ steps.versions.outputs.poetry }}" - - run: poetry install --extras all - - run: poetry run mkdocs build - - uses: peaceiris/actions-gh-pages@v4.0.0 - with: - github_token: "${{ secrets.GITHUB_TOKEN }}" - publish_dir: ./site diff --git a/pyproject.toml b/pyproject.toml index 272761e..2522354 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,6 +48,7 @@ isort = "~6" mkdocs-material = "~9" mypy = ">=1,<3" pytest = "~8" +pytest-asyncio = "~0.23" pytest-cov = "~5" types-setuptools = "^75.8.2" types-requests = "^2.27.30" @@ -69,3 +70,4 @@ multi_line_output = 3 [tool.pytest.ini_options] markers = ["slow: marks tests as slow (deselect with '-m \"not slow\"')"] +asyncio_mode = "strict" diff --git a/tests/test_cli.py b/tests/test_cli.py index 4a6804b..214947d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -14,9 +14,9 @@ import importlib # noqa: E402 cli_main_module = importlib.import_module("netsuite.cli.main") -from netsuite.cli import helpers, misc, restlet, soap_api # noqa: E402 +from netsuite.cli import helpers, misc # noqa: E402 from netsuite.cli import rest_api as cli_rest_api # noqa: E402 - +from netsuite.cli import restlet, soap_api # noqa: E402 # --------------------------------------------------------------------------- # helpers.load_config_or_error diff --git a/tests/test_config.py b/tests/test_config.py index 1039e46..9a7a27b 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -7,7 +7,6 @@ from netsuite.config import Config, TokenAuth, UsernamePasswordAuth - # --------------------------------------------------------------------------- # Properties # --------------------------------------------------------------------------- @@ -37,7 +36,9 @@ def test_is_sandbox(account, expected, dummy_config): def test_account_number_strips_sandbox_suffix(dummy_config): - assert Config(account="123456_SB1", auth=dummy_config.auth).account_number == "123456" + assert ( + Config(account="123456_SB1", auth=dummy_config.auth).account_number == "123456" + ) assert Config(account="123456", auth=dummy_config.auth).account_number == "123456" diff --git a/tests/test_json.py b/tests/test_json.py index e6ccfe4..25edba5 100644 --- a/tests/test_json.py +++ b/tests/test_json.py @@ -13,7 +13,6 @@ from netsuite import json as nsjson - # --------------------------------------------------------------------------- # Round-trip: dumps→loads should preserve plain dict/list/scalar payloads # regardless of which JSON backend is in use. @@ -55,7 +54,10 @@ class _Color(Enum): (frozenset([1, 2, 3]), [1, 2, 3]), (set([1, 2, 3]), [1, 2, 3]), (Path("/tmp/x"), "/tmp/x"), - (UUID("12345678-1234-5678-1234-567812345678"), "12345678-1234-5678-1234-567812345678"), + ( + UUID("12345678-1234-5678-1234-567812345678"), + "12345678-1234-5678-1234-567812345678", + ), ], ) def test_dumps_encodes_supported_types(value, expected_decoded): diff --git a/tests/test_rest_api.py b/tests/test_rest_api.py index a1675c4..ec35043 100644 --- a/tests/test_rest_api.py +++ b/tests/test_rest_api.py @@ -99,9 +99,7 @@ async def test_suiteql_quiet_when_order_by_with_safe_limit(dummy_config, caplog) rest_api = NetSuiteRestApi(dummy_config) rest_api._request = AsyncMock(return_value={"items": []}) # type: ignore[method-assign] with caplog.at_level(logging.WARNING, logger="netsuite.rest_api"): - await rest_api.suiteql( - q="SELECT id FROM subsidiary ORDER BY id", limit=1000 - ) + await rest_api.suiteql(q="SELECT id FROM subsidiary ORDER BY id", limit=1000) assert not any("ORDER BY" in r.message for r in caplog.records) diff --git a/tests/test_rest_api_base.py b/tests/test_rest_api_base.py index 522d6c0..b7180e6 100644 --- a/tests/test_rest_api_base.py +++ b/tests/test_rest_api_base.py @@ -34,7 +34,10 @@ def _make_url(self, subpath): def _httpx_response(status_code, text, headers=None): request = httpx.Request("GET", "https://example.com/x") return httpx.Response( - status_code, content=text.encode("utf-8"), headers=headers or {}, request=request + status_code, + content=text.encode("utf-8"), + headers=headers or {}, + request=request, ) @@ -46,9 +49,7 @@ def _httpx_response(status_code, text, headers=None): @pytest.mark.asyncio async def test_request_returns_decoded_json_on_2xx(dummy_config): api = _ConcreteApi(dummy_config) - api._request_impl = AsyncMock( - return_value=_httpx_response(200, '{"ok": true}') - ) + api._request_impl = AsyncMock(return_value=_httpx_response(200, '{"ok": true}')) assert await api._request("GET", "/x") == {"ok": True} @@ -63,9 +64,7 @@ async def test_request_returns_none_on_204(dummy_config): @pytest.mark.parametrize("status_code", [400, 401, 403, 404, 500, 503]) async def test_request_raises_on_non_2xx(dummy_config, status_code): api = _ConcreteApi(dummy_config) - api._request_impl = AsyncMock( - return_value=_httpx_response(status_code, "boom") - ) + api._request_impl = AsyncMock(return_value=_httpx_response(status_code, "boom")) with pytest.raises(NetsuiteAPIRequestError) as excinfo: await api._request("GET", "/x") assert excinfo.value.status_code == status_code @@ -167,7 +166,10 @@ async def request(self, **kw): await api._request_impl("POST", "/x", json={"q": "SELECT 1"}) assert "json" not in captured - assert captured["data"] == '{"q": "SELECT 1"}' or captured["data"] == '{"q":"SELECT 1"}' + assert ( + captured["data"] == '{"q": "SELECT 1"}' + or captured["data"] == '{"q":"SELECT 1"}' + ) @pytest.mark.asyncio diff --git a/tests/test_soap_api.py b/tests/test_soap_api.py index 07f716d..e59754e 100644 --- a/tests/test_soap_api.py +++ b/tests/test_soap_api.py @@ -5,7 +5,8 @@ from netsuite import NetSuiteSoapApi from netsuite.config import Config, TokenAuth -from netsuite.soap_api.passport import TokenPassport, make as make_passport +from netsuite.soap_api.passport import TokenPassport +from netsuite.soap_api.passport import make as make_passport from netsuite.soap_api.transports import AsyncNetSuiteTransport from netsuite.soap_api.zeep import ZEEP_INSTALLED @@ -77,7 +78,9 @@ def test_transport_fixes_address_to_account_subdomain(): "https://123456-sb1.suitetalk.api.netsuite.com/wsdl/v2021_1_0/netsuite.wsdl", ) assert ( - transport._fix_address("https://webservices.netsuite.com/services/NetSuitePort_2021_1") + transport._fix_address( + "https://webservices.netsuite.com/services/NetSuitePort_2021_1" + ) == "https://123456-sb1.suitetalk.api.netsuite.com/services/NetSuitePort_2021_1" ) @@ -230,9 +233,7 @@ async def test_getList_short_circuits_on_no_ids(dummy_config): async def test_getList_builds_record_refs_for_each_id(dummy_config): soap_api = _build_soap_api_with_mocks(dummy_config) soap_api.request = AsyncMock(return_value=None) # type: ignore[method-assign] - await soap_api.getList( - "customer", internalIds=[1, 2], externalIds=["e1"] - ) + await soap_api.getList("customer", internalIds=[1, 2], externalIds=["e1"]) # 2 internal + 1 external = 3 RecordRef constructions assert soap_api.Core.RecordRef.call_count == 3 soap_api.Messages.GetListRequest.assert_called_once() @@ -318,10 +319,10 @@ async def test_request_attaches_passport_and_extra_headers(dummy_config): fake_method = AsyncMock(return_value="result") fake_service.someOp = fake_method with patch.object( - type(soap_api), "service", new_callable=lambda: property(lambda self: fake_service) - ), patch.object( - soap_api, "generate_passport", return_value={"tokenPassport": "P"} - ): + type(soap_api), + "service", + new_callable=lambda: property(lambda self: fake_service), + ), patch.object(soap_api, "generate_passport", return_value={"tokenPassport": "P"}): result = await soap_api.request( "someOp", "arg", @@ -375,6 +376,8 @@ def test_with_timeout_uses_transport_settings(dummy_config): def test_missing_zeep_raises_runtime_error(dummy_config): - with patch.object(NetSuiteSoapApi, "_has_required_dependencies", return_value=False): + with patch.object( + NetSuiteSoapApi, "_has_required_dependencies", return_value=False + ): with pytest.raises(RuntimeError, match="soap_api"): NetSuiteSoapApi(dummy_config) diff --git a/tests/test_soap_api_passport.py b/tests/test_soap_api_passport.py index 8f25c8d..604efde 100644 --- a/tests/test_soap_api_passport.py +++ b/tests/test_soap_api_passport.py @@ -7,7 +7,8 @@ import pytest from netsuite.config import Config -from netsuite.soap_api.passport import Passport, TokenPassport, make as make_passport +from netsuite.soap_api.passport import Passport, TokenPassport +from netsuite.soap_api.passport import make as make_passport from netsuite.soap_api.zeep import ZEEP_INSTALLED pytestmark = pytest.mark.skipif(not ZEEP_INSTALLED, reason="Requires zeep") @@ -69,8 +70,7 @@ def test_signature_key_combines_secrets(dummy_config): passport = _make_passport(dummy_config) key = passport._get_signature_key() assert ( - key - == f"{dummy_config.auth.consumer_secret}&{dummy_config.auth.token_secret}" + key == f"{dummy_config.auth.consumer_secret}&{dummy_config.auth.token_secret}" ) diff --git a/tests/test_soap_api_transports.py b/tests/test_soap_api_transports.py index 328a7e6..b82fc76 100644 --- a/tests/test_soap_api_transports.py +++ b/tests/test_soap_api_transports.py @@ -15,10 +15,7 @@ def test_init_extracts_base_url_from_wsdl_url(): transport = AsyncNetSuiteTransport( "https://123-sb1.suitetalk.api.netsuite.com/wsdl/v2021_1_0/netsuite.wsdl", ) - assert ( - transport._netsuite_base_url - == "https://123-sb1.suitetalk.api.netsuite.com" - ) + assert transport._netsuite_base_url == "https://123-sb1.suitetalk.api.netsuite.com" def test_init_handles_url_without_path(): @@ -45,7 +42,10 @@ def test_fix_address_preserves_query_string(): fixed = transport._fix_address( "https://webservices.netsuite.com/services/NetSuitePort_2021_1?wsdl" ) - assert fixed == "https://acct.suitetalk.api.netsuite.com/services/NetSuitePort_2021_1?wsdl" + assert ( + fixed + == "https://acct.suitetalk.api.netsuite.com/services/NetSuitePort_2021_1?wsdl" + ) @pytest.mark.asyncio From fd5a76a552b22d1daffc5c8600fa43a0848dbd35 Mon Sep 17 00:00:00 2001 From: Vicente Louvet III Date: Fri, 8 May 2026 09:19:23 -0600 Subject: [PATCH 16/29] docs: rebrand README/docs to vlouvet fork; install via git+ URL (#10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is an unofficial fork that does not publish to PyPI, so the existing "pip install netsuite" instructions never produce this fork's code. Switch every install snippet to the git+https URL form. - README.md: replace upstream's PyPI/CI/codecov badges with vlouvet equivalents, add an "unofficial fork" disclaimer, and rewrite the install section to use `pip install git+https://...`. Drop the upstream Slack/SuiteSync links since they belong to the original maintainer's project, not this fork. - docs/index.md: same install rewrite. Note that the SOAP install line also now flags NetSuite's 2027.1 SOAP deprecation, so users picking the soap_api extra are at least warned at install time. - mkdocs.yml: point repo_name/repo_url at vlouvet/netsuite so the docs theme's edit/source links resolve correctly. - pyproject.toml: keep the original authors as the credit they're owed, add Vicente Louvet III, and point homepage/repository at the fork. Drop the now-incorrect `documentation` URL (no auto-deployed site for this fork; in-repo docs/ is canonical per #9). No production code changes. No tests added — the install instructions aren't exercised by the test suite. Co-authored-by: Claude Opus 4.7 (1M context) --- README.md | 37 ++++++++++++++++--------------------- docs/index.md | 16 +++++++++------- mkdocs.yml | 4 ++-- pyproject.toml | 11 +++++++---- 4 files changed, 34 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 593a4fe..116ca1d 100644 --- a/README.md +++ b/README.md @@ -1,44 +1,39 @@ # netsuite -[![Continuous Integration Status](https://github.com/jacobsvante/netsuite/actions/workflows/ci.yml/badge.svg)](https://github.com/jacobsvante/netsuite/actions/workflows/ci.yml) -[![Continuous Delivery Status](https://github.com/jacobsvante/netsuite/actions/workflows/cd.yml/badge.svg)](https://github.com/jacobsvante/netsuite/actions/workflows/cd.yml) -[![Code Coverage](https://img.shields.io/codecov/c/github/jacobsvante/netsuite?color=%2334D058)](https://codecov.io/gh/jacobsvante/netsuite) -[![PyPI version](https://img.shields.io/pypi/v/netsuite.svg)](https://pypi.python.org/pypi/netsuite/) -[![License](https://img.shields.io/pypi/l/netsuite.svg)](https://pypi.python.org/pypi/netsuite/) -[![Python Versions](https://img.shields.io/pypi/pyversions/netsuite.svg)](https://pypi.org/project/netsuite/) -[![PyPI status (alpha/beta/stable)](https://img.shields.io/pypi/status/netsuite.svg)](https://pypi.python.org/pypi/netsuite/) -[![Slack Status](https://netsuite-slackin.fly.dev/badge.svg)](https://netsuite-slackin.fly.dev) +[![Continuous Integration Status](https://github.com/vlouvet/netsuite/actions/workflows/ci.yml/badge.svg)](https://github.com/vlouvet/netsuite/actions/workflows/ci.yml) +[![Code Coverage](https://img.shields.io/codecov/c/github/vlouvet/netsuite?color=%2334D058)](https://codecov.io/gh/vlouvet/netsuite) +[![License](https://img.shields.io/github/license/vlouvet/netsuite.svg)](LICENSE) -Make async requests to NetSuite SuiteTalk SOAP, REST Web Services, and Restlets. [Detailed documentation available here.](https://jacobsvante.github.io/netsuite/) +Make async requests to NetSuite SuiteTalk SOAP, REST Web Services, and Restlets. -# Help & Support +This is an unofficial fork. It is not published to PyPI; install directly from this repository. -Join the [Slack channel](https://netsuite-slackin.fly.dev) for help with NetSuite issues. Please do not post usage questions as issues in GitHub. +## Installation -There are some additional helpful resources for NetSuite development [listed here](https://dashboard.suitesync.io/docs/resources#netsuite). +Default features (REST API + Restlet support): -## Installation + pip install git+https://github.com/vlouvet/netsuite.git -With default features (REST API + Restlet support): +Pin to a specific commit or tag: - pip install netsuite + pip install git+https://github.com/vlouvet/netsuite.git@ -With Web Services SOAP API support: +With Web Services SOAP API support (deprecated by NetSuite as of the 2027.1 release — prefer REST + OAuth 2.0 for new integrations): - pip install netsuite[soap_api] + pip install "netsuite[soap_api] @ git+https://github.com/vlouvet/netsuite.git" With CLI support: - pip install netsuite[cli] + pip install "netsuite[cli] @ git+https://github.com/vlouvet/netsuite.git" With `orjson` package (faster JSON handling): - pip install netsuite[orjson] + pip install "netsuite[orjson] @ git+https://github.com/vlouvet/netsuite.git" With all features: - pip install netsuite[all] + pip install "netsuite[all] @ git+https://github.com/vlouvet/netsuite.git" ## Documentation -Is found here: https://jacobsvante.github.io/netsuite/ +In-repo documentation lives in [`docs/index.md`](docs/index.md). diff --git a/docs/index.md b/docs/index.md index 14f8389..65f958f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -5,29 +5,31 @@ hide: # netsuite python library -Make async requests to NetSuite SuiteTalk SOAP/REST Web Services and Restlets +Make async requests to NetSuite SuiteTalk SOAP/REST Web Services and Restlets. + +This is an unofficial fork. It is not published to PyPI; install directly from this repository. ## Installation With default features (REST API + Restlet support): - pip install netsuite + pip install git+https://github.com/vlouvet/netsuite.git -With Web Services SOAP API support: +With Web Services SOAP API support (deprecated by NetSuite as of the 2027.1 release): - pip install netsuite[soap_api] + pip install "netsuite[soap_api] @ git+https://github.com/vlouvet/netsuite.git" With CLI support: - pip install netsuite[cli] + pip install "netsuite[cli] @ git+https://github.com/vlouvet/netsuite.git" With `orjson` package (faster JSON handling): - pip install netsuite[orjson] + pip install "netsuite[orjson] @ git+https://github.com/vlouvet/netsuite.git" With all features: - pip install netsuite[all] + pip install "netsuite[all] @ git+https://github.com/vlouvet/netsuite.git" ## Programmatic use - Basic Example diff --git a/mkdocs.yml b/mkdocs.yml index 15b8e05..29393f7 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,7 +1,7 @@ site_name: netsuite -repo_name: jacobsvante/netsuite -repo_url: https://github.com/jacobsvante/netsuite +repo_name: vlouvet/netsuite +repo_url: https://github.com/vlouvet/netsuite theme: name: material diff --git a/pyproject.toml b/pyproject.toml index 2522354..fd075f7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,12 +2,15 @@ name = "netsuite" version = "0.12.0" description = "Make async requests to NetSuite SuiteTalk SOAP/REST Web Services and Restlets" -authors = ["Jacob Magnusson ", "Mike Bianco "] +authors = [ + "Jacob Magnusson ", + "Mike Bianco ", + "Vicente Louvet III ", +] license = "MIT" readme = "README.md" -homepage = "https://jacobsvante.github.io/netsuite/" -repository = "https://github.com/jacobsvante/netsuite" -documentation = "https://jacobsvante.github.io/netsuite/" +homepage = "https://github.com/vlouvet/netsuite" +repository = "https://github.com/vlouvet/netsuite" classifiers = [ "Development Status :: 4 - Beta", "Environment :: Web Environment", From e86f6ebad90697221f1015eb20294647b1a44581 Mon Sep 17 00:00:00 2001 From: Vicente Louvet III Date: Fri, 8 May 2026 09:26:06 -0600 Subject: [PATCH 17/29] feat: OAuth 2.0 auth (Client Credentials + Auth Code) and SOAP deprecation warning (#11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add OAuth 2.0 auth (Client Credentials JWT + Auth Code) and SOAP deprecation warning NetSuite has announced SOAP Web Services will be removed in the 2027.1 release. This PR introduces the migration path: OAuth 2.0 against the REST API, plus a runtime DeprecationWarning to surface the sunset. Two flows, both centered on a shared `OAuth2BearerAuth` httpx auth handler that the REST API and Restlet clients use transparently in place of OAuth 1.0a TBA: - **Client Credentials with JWT Bearer assertion** (M2M, RFC 7523). `OAuth2ClientCredentialsAuth` takes a `client_id`, `certificate_id` (the `kid` NetSuite assigns when you upload your public key), and a PEM-encoded private key. The handler signs short-lived JWTs and exchanges them at NetSuite's token endpoint, caching the resulting access token with a 60-second safety margin before refreshing. Default algorithm is PS256; PS/RS/ES at 256/384/512 are accepted. - **Authorization Code Grant** is supported via two helper functions — `build_authorization_url()` and `exchange_authorization_code()` — that callers wire into their own redirect handlers. The resulting token is dropped into `OAuth2AccessTokenAuth` (bring-your-own). We don't refresh BYO tokens automatically; the calling app's auth layer is responsible for that. `Config.auth` accepts the four auth types (TokenAuth, OAuth2ClientCredentialsAuth, OAuth2AccessTokenAuth, UsernamePasswordAuth). `RestApiBase._make_auth` dispatches on type. The OAuth2 handler is cached on the API instance so token re-use works across back-to-back requests. `NetSuiteSoapApi.__init__` now emits a `DeprecationWarning` mentioning the 2027.1 sunset and pointing users at `OAuth2ClientCredentialsAuth`. The pyproject `filterwarnings` filter suppresses it inside the existing test suite (which instantiates the SOAP client 60+ times) so it doesn't clutter the pytest summary; a dedicated test in `tests/test_soap_api_deprecation.py` re-enables warnings explicitly to assert the behavior. 35 new tests across `tests/test_oauth2.py`, `tests/test_oauth2_integration.py`, and `tests/test_soap_api_deprecation.py`. Coverage of the new `netsuite/oauth2.py` is 97%. Overall: 149 tests -> 184 tests, 75% -> 77% coverage. `docs/index.md` gains a new "Authentication" section with worked examples for all three flows (M2M JWT, Authorization Code, legacy TBA) and an explicit callout for the 2027.1 SOAP sunset. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(deps): declare joserfc as a direct dependency netsuite/oauth2.py imports `joserfc` directly for JWT signing. Newer authlib (1.7+) pulls joserfc in as a transitive, which is why local test runs work, but the older end of our `authlib = ">=1,<3"` range does not. CI's clean resolver picks one of those older authlibs and fails at import time with: ModuleNotFoundError: No module named 'joserfc' Direct usage means direct dependency. Pin loosely (`>=1,<2`). Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- docs/index.md | 94 +++++++ netsuite/config.py | 63 ++++- netsuite/oauth2.py | 362 ++++++++++++++++++++++++ netsuite/rest_api_base.py | 70 ++++- netsuite/soap_api/client.py | 14 + pyproject.toml | 11 + tests/test_oauth2.py | 428 +++++++++++++++++++++++++++++ tests/test_oauth2_integration.py | 232 ++++++++++++++++ tests/test_soap_api_deprecation.py | 33 +++ 9 files changed, 1295 insertions(+), 12 deletions(-) create mode 100644 netsuite/oauth2.py create mode 100644 tests/test_oauth2.py create mode 100644 tests/test_oauth2_integration.py create mode 100644 tests/test_soap_api_deprecation.py diff --git a/docs/index.md b/docs/index.md index 65f958f..7bdb14f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -32,6 +32,100 @@ With all features: pip install "netsuite[all] @ git+https://github.com/vlouvet/netsuite.git" +## Authentication + +The library supports three authentication methods: + +| Auth class | Mechanism | When to use | +|---|---|---| +| `OAuth2ClientCredentialsAuth` | OAuth 2.0 Client Credentials w/ JWT Bearer (M2M) | New server-to-server integrations. **Recommended.** | +| `OAuth2AccessTokenAuth` | Bring-your-own access token | When an upstream auth flow (e.g. Authorization Code in your web app) already has a token. | +| `TokenAuth` | OAuth 1.0a Token-Based Auth (TBA) | Legacy. Still works, but new integrations should use OAuth 2.0. | + +> **Note on SOAP.** NetSuite has announced that SOAP Web Services will be removed in the **2027.1 release**. Plan REST + OAuth 2.0 migrations accordingly. Instantiating `NetSuiteSoapApi` emits a `DeprecationWarning` to surface this at runtime. + +### OAuth 2.0 — Client Credentials (M2M, JWT Bearer) + +Upload a public key/certificate to your NetSuite integration record, save the certificate ID NetSuite gives you, and supply the matching private key: + +```python +from netsuite import Config, NetSuite, OAuth2ClientCredentialsAuth + +config = Config( + account="123456_SB1", + auth=OAuth2ClientCredentialsAuth( + client_id="", + certificate_id="", + private_key_pem=open("/path/to/private.pem").read(), + scope=["rest_webservices"], # default; can also include "restlets" or "suite_analytics" + algorithm="PS256", # default; "RS256", "ES256/384/512" also accepted + ), +) + +ns = NetSuite(config) +result = await ns.rest_api.get("/record/v1/customer/1337") +``` + +The library transparently mints a JWT, exchanges it at NetSuite's token endpoint, caches the access token until ~1 minute before expiry, and refreshes automatically. No additional code on your side. + +### OAuth 2.0 — Authorization Code Grant + +Best for apps acting on behalf of a NetSuite user. The library provides helpers for the URL building and token exchange; the redirect dance is yours. + +```python +from netsuite.oauth2 import ( + build_authorization_url, + exchange_authorization_code, +) + +# 1. Send the user here: +auth_url = build_authorization_url( + "123456_SB1", + client_id="", + redirect_uri="https://app.example.com/oauth/callback", + scope=["rest_webservices"], + state="", +) + +# 2. NetSuite redirects to your callback with `?code=...&state=...`. +# After verifying state, exchange the code: +token = await exchange_authorization_code( + "123456_SB1", + code=request.args["code"], + client_id="", + client_secret="", # OR pass certificate_id+private_key_pem + redirect_uri="https://app.example.com/oauth/callback", +) + +# 3. Pass the token into the library: +config = Config( + account="123456_SB1", + auth=OAuth2AccessTokenAuth( + access_token=token.access_token, + refresh_token=token.refresh_token, + expires_at=token.expires_at, + ), +) +``` + +`OAuth2AccessTokenAuth` does *not* refresh automatically — your application is responsible for refreshing using the `refresh_token` and constructing a new `Config`. + +### Legacy OAuth 1.0a (TBA) + +```python +from netsuite import Config, NetSuite, TokenAuth + +config = Config( + account="123456_SB1", + auth=TokenAuth( + consumer_key="...", consumer_secret="...", + token_id="...", token_secret="...", + ), +) +``` + +Still fully supported, but consider migrating: NetSuite is steering integrations toward OAuth 2.0. + ## Programmatic use - Basic Example ```python diff --git a/netsuite/config.py b/netsuite/config.py index 437f1ed..493cc50 100644 --- a/netsuite/config.py +++ b/netsuite/config.py @@ -7,10 +7,23 @@ from .constants import DEFAULT_INI_PATH, DEFAULT_INI_SECTION -__all__ = ("Config", "TokenAuth", "UsernamePasswordAuth") +__all__ = ( + "Config", + "OAuth2AccessTokenAuth", + "OAuth2ClientCredentialsAuth", + "TokenAuth", + "UsernamePasswordAuth", +) class TokenAuth(BaseModel): + """Legacy OAuth 1.0a Token-Based Authentication (TBA). + + NetSuite continues to support TBA, but OAuth 2.0 is the recommended + authentication method for new integrations. See + `OAuth2ClientCredentialsAuth` for the M2M replacement. + """ + consumer_key: str consumer_secret: str token_id: str @@ -28,11 +41,47 @@ class UsernamePasswordAuth(BaseModel): password: str +class OAuth2ClientCredentialsAuth(BaseModel): + """OAuth 2.0 Client Credentials with JWT Bearer assertion (M2M). + + The integration uploads a public certificate to NetSuite once, and + then signs short-lived JWT assertions with the matching private key + to mint access tokens. No browser, no user interaction. + """ + + client_id: str + certificate_id: str + private_key_pem: str + scope: t.List[str] = ["rest_webservices"] + algorithm: str = "PS256" + + +class OAuth2AccessTokenAuth(BaseModel): + """Bring-your-own access token. + + For when an upstream auth flow (e.g. Authorization Code Grant + handled in your web app) already produced a valid access token and + you just want this library to use it. Optional `refresh_token` and + `expires_at` are accepted but the library will not refresh + automatically — wire that into the upstream flow. + """ + + access_token: str + refresh_token: t.Optional[str] = None + expires_at: t.Optional[float] = None + + +_AnyAuth = t.Union[ + TokenAuth, + OAuth2ClientCredentialsAuth, + OAuth2AccessTokenAuth, + UsernamePasswordAuth, +] + + class Config(BaseModel): account: str - auth: t.Union[TokenAuth, UsernamePasswordAuth] - # TODO: Support OAuth2 - # auth: Union[OAuth2, TokenAuth] + auth: _AnyAuth log_level: t.Optional[str] = None @@ -43,6 +92,12 @@ class Config(BaseModel): def is_token_auth(self) -> bool: return isinstance(self.auth, TokenAuth) + @property + def is_oauth2_auth(self) -> bool: + return isinstance( + self.auth, (OAuth2ClientCredentialsAuth, OAuth2AccessTokenAuth) + ) + @property def is_sandbox(self) -> bool: return re.search(r"_SB[\d]+$", self.account) is not None diff --git a/netsuite/oauth2.py b/netsuite/oauth2.py new file mode 100644 index 0000000..7a43a12 --- /dev/null +++ b/netsuite/oauth2.py @@ -0,0 +1,362 @@ +"""OAuth 2.0 support for NetSuite. + +NetSuite is sunsetting SOAP Web Services in the 2027.1 release. The +recommended path forward is the REST API authenticated via OAuth 2.0. +This module implements the two flows that matter for a backend library: + +* **Client Credentials with JWT Bearer Assertion** (RFC 7523 — *machine- + to-machine*). The integration uploads a public key/cert to NetSuite, + signs a short-lived JWT with the matching private key, and exchanges + it for an access token at NetSuite's token endpoint. No browser, no + user interaction. This is what most server-to-server integrations + should use. + +* **Authorization Code Grant** — interactive. We provide the helper + functions to build the authorization URL and exchange the resulting + authorization code for tokens, but the redirect dance itself is left + to the calling application (a Flask/FastAPI app, a CLI tool, etc.) — + the library has no opinion on how you serve the redirect URI. + +For both flows the resulting access token is plugged into a single +``OAuth2BearerAuth`` httpx auth handler that the REST API and Restlet +clients use transparently in place of the legacy OAuth 1.0a token-based +auth. + +Reference: NetSuite OAuth 2.0 documentation, "Issue Token and Revoke +Token REST Services" and "OAuth 2.0 for Integration Application +Authentication". +""" + +import time +from dataclasses import dataclass, field +from typing import Awaitable, Callable, Iterable, List, Optional + +import httpx +from joserfc import jwt +from joserfc.jwk import ECKey, RSAKey + +from . import json as nsjson + +__all__ = ( + "DEFAULT_SCOPES", + "JWT_BEARER_ASSERTION_TYPE", + "OAuth2BearerAuth", + "OAuth2Token", + "build_authorization_url", + "build_client_assertion", + "build_token_endpoint", + "exchange_authorization_code", + "exchange_client_assertion", +) + + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +JWT_BEARER_ASSERTION_TYPE = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" +DEFAULT_SCOPES: tuple = ("rest_webservices",) +# The NetSuite-recommended algorithm for the client assertion. PS256 is +# RSA-PSS w/ SHA-256; ES256 is ECDSA on P-256 w/ SHA-256. Both are +# accepted by the token endpoint. +SUPPORTED_ALGORITHMS: tuple = ("PS256", "RS256", "ES256", "ES384", "ES512") +DEFAULT_ALGORITHM = "PS256" + +# How long before expiry we treat a token as already expired and refresh. +# NetSuite's access tokens last ~1 hour; 60 s of slack is plenty. +_EXPIRY_SAFETY_MARGIN_SECONDS = 60 + + +# --------------------------------------------------------------------------- +# URL builders +# --------------------------------------------------------------------------- + + +def _account_slug(account: str) -> str: + """Replicates `Config.account_slugified` without importing it (which + would create a circular dependency for callers that pass a string).""" + return account.lower().replace("_", "-") + + +def build_token_endpoint(account: str) -> str: + """The OAuth 2.0 token endpoint for a given NetSuite account.""" + return ( + f"https://{_account_slug(account)}" + ".suitetalk.api.netsuite.com/services/rest/auth/oauth2/v1/token" + ) + + +def build_authorization_url( + account: str, + *, + client_id: str, + redirect_uri: str, + scope: Iterable[str] = DEFAULT_SCOPES, + state: Optional[str] = None, +) -> str: + """Build the URL the user should be redirected to in their browser + to start the Authorization Code Grant flow. + + The calling app is responsible for serving ``redirect_uri`` and + extracting the ``code`` query parameter, which is then handed to + :func:`exchange_authorization_code`. + """ + base = ( + f"https://{_account_slug(account)}" + ".app.netsuite.com/app/login/oauth2/authorize.nl" + ) + params = { + "response_type": "code", + "client_id": client_id, + "redirect_uri": redirect_uri, + "scope": " ".join(scope), + } + if state is not None: + params["state"] = state + return str(httpx.URL(base).copy_with(params=params)) + + +# --------------------------------------------------------------------------- +# JWT client assertion +# --------------------------------------------------------------------------- + + +def _load_signing_key(private_key_pem: str, algorithm: str): + """Load a PEM-encoded private key into the right joserfc JWK type for + the chosen algorithm. RS*/PS* algorithms need RSA; ES* need EC.""" + family = algorithm[:2].upper() + if family in ("RS", "PS"): + return RSAKey.import_key(private_key_pem) + if family == "ES": + return ECKey.import_key(private_key_pem) + raise ValueError( + f"Unsupported algorithm '{algorithm}'. " + f"Expected one of {SUPPORTED_ALGORITHMS}." + ) + + +def build_client_assertion( + account: str, + *, + client_id: str, + certificate_id: str, + private_key_pem: str, + scope: Iterable[str] = DEFAULT_SCOPES, + algorithm: str = DEFAULT_ALGORITHM, + now: Optional[int] = None, + ttl_seconds: int = 3600, +) -> str: + """Build and sign a JWT to use as the ``client_assertion`` in a + Client Credentials token request. + + ``certificate_id`` is the ``kid`` NetSuite assigns when you upload + the matching public key/certificate. ``private_key_pem`` is the + matching private key in PEM format. + + NetSuite caps assertion ``exp`` at one hour; ``ttl_seconds`` is + clamped to that ceiling. + """ + if algorithm not in SUPPORTED_ALGORITHMS: + raise ValueError( + f"Unsupported algorithm '{algorithm}'. " + f"Expected one of {SUPPORTED_ALGORITHMS}." + ) + issued_at = int(now if now is not None else time.time()) + expires_at = issued_at + min(ttl_seconds, 3600) + header = {"alg": algorithm, "typ": "JWT", "kid": certificate_id} + claims = { + "iss": client_id, + "scope": list(scope), + "aud": build_token_endpoint(account), + "iat": issued_at, + "exp": expires_at, + } + key = _load_signing_key(private_key_pem, algorithm) + return jwt.encode(header, claims, key) + + +# --------------------------------------------------------------------------- +# Token exchanges +# --------------------------------------------------------------------------- + + +@dataclass +class OAuth2Token: + """The result of any successful token exchange. + + NetSuite returns ``access_token`` plus ``expires_in`` (seconds) for + Client Credentials, and adds ``refresh_token`` for Authorization + Code Grant. ``expires_at`` is computed locally so callers can decide + whether to refresh. + """ + + access_token: str + token_type: str = "Bearer" + expires_at: float = 0.0 + refresh_token: Optional[str] = None + scope: List[str] = field(default_factory=list) + + @classmethod + def from_response( + cls, payload: dict, *, now: Optional[float] = None + ) -> "OAuth2Token": + issued = now if now is not None else time.time() + scope = payload.get("scope", "") + if isinstance(scope, str): + scope = scope.split() + return cls( + access_token=payload["access_token"], + token_type=payload.get("token_type", "Bearer"), + expires_at=issued + float(payload.get("expires_in", 0)), + refresh_token=payload.get("refresh_token"), + scope=list(scope), + ) + + def is_expired(self, *, now: Optional[float] = None) -> bool: + when = now if now is not None else time.time() + return when >= (self.expires_at - _EXPIRY_SAFETY_MARGIN_SECONDS) + + +async def _post_token( + url: str, + data: dict, + *, + timeout: float = 30.0, +) -> OAuth2Token: + async with httpx.AsyncClient(timeout=timeout) as client: + resp = await client.post( + url, + data=data, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + if resp.status_code >= 400: + # Surface the server-side error message rather than just the status. + raise httpx.HTTPStatusError( + f"NetSuite token endpoint returned {resp.status_code}: {resp.text}", + request=resp.request, + response=resp, + ) + return OAuth2Token.from_response(nsjson.loads(resp.text)) + + +async def exchange_client_assertion( + account: str, + *, + client_id: str, + certificate_id: str, + private_key_pem: str, + scope: Iterable[str] = DEFAULT_SCOPES, + algorithm: str = DEFAULT_ALGORITHM, +) -> OAuth2Token: + """Run the Client Credentials + JWT Bearer flow end-to-end and + return a fresh access token.""" + assertion = build_client_assertion( + account, + client_id=client_id, + certificate_id=certificate_id, + private_key_pem=private_key_pem, + scope=scope, + algorithm=algorithm, + ) + return await _post_token( + build_token_endpoint(account), + { + "grant_type": "client_credentials", + "client_assertion_type": JWT_BEARER_ASSERTION_TYPE, + "client_assertion": assertion, + }, + ) + + +async def exchange_authorization_code( + account: str, + *, + code: str, + client_id: str, + redirect_uri: str, + client_secret: Optional[str] = None, + certificate_id: Optional[str] = None, + private_key_pem: Optional[str] = None, + algorithm: str = DEFAULT_ALGORITHM, +) -> OAuth2Token: + """Exchange an authorization code for an access + refresh token. + + NetSuite supports two ways to authenticate the code-exchange request: + a shared ``client_secret`` (basic auth) or the same JWT bearer + assertion used by Client Credentials. Pass whichever your integration + is configured for. Exactly one must be provided. + """ + has_secret = client_secret is not None + has_assertion = certificate_id is not None and private_key_pem is not None + if has_secret == has_assertion: + raise ValueError( + "Provide exactly one of `client_secret` or " + "(`certificate_id` + `private_key_pem`) for the code exchange." + ) + + data = { + "grant_type": "authorization_code", + "code": code, + "redirect_uri": redirect_uri, + } + if has_secret: + data["client_id"] = client_id + data["client_secret"] = client_secret # type: ignore[assignment] + else: + assertion = build_client_assertion( + account, + client_id=client_id, + certificate_id=certificate_id, # type: ignore[arg-type] + private_key_pem=private_key_pem, # type: ignore[arg-type] + algorithm=algorithm, + ) + data["client_assertion_type"] = JWT_BEARER_ASSERTION_TYPE + data["client_assertion"] = assertion + + return await _post_token(build_token_endpoint(account), data) + + +# --------------------------------------------------------------------------- +# httpx.Auth subclass +# --------------------------------------------------------------------------- + + +class OAuth2BearerAuth(httpx.Auth): + """An httpx auth handler that sets ``Authorization: Bearer ``. + + When the token is missing or close to expiry, ``token_factory`` is + awaited to mint a fresh one. Callers compose the factory: e.g. for + Client Credentials, partial-apply :func:`exchange_client_assertion`; + for a bring-your-own token, return a static ``OAuth2Token``. + + The class is async-first because the rest of this library is. We + don't expose a sync flow — there isn't one anywhere else either. + """ + + requires_response_body = False + + def __init__( + self, + token_factory: Callable[[], Awaitable[OAuth2Token]], + *, + initial_token: Optional[OAuth2Token] = None, + ) -> None: + self._token_factory = token_factory + self._token: Optional[OAuth2Token] = initial_token + + @property + def token(self) -> Optional[OAuth2Token]: + """The currently cached token, if any. Mostly useful in tests.""" + return self._token + + def sync_auth_flow(self, request): # pragma: no cover - not supported + raise RuntimeError( + "OAuth2BearerAuth is async-only. Use httpx.AsyncClient " + "(which the rest of this library does)." + ) + + async def async_auth_flow(self, request): + if self._token is None or self._token.is_expired(): + self._token = await self._token_factory() + request.headers["Authorization"] = f"Bearer {self._token.access_token}" + yield request diff --git a/netsuite/rest_api_base.py b/netsuite/rest_api_base.py index 23dc43f..46afcb5 100644 --- a/netsuite/rest_api_base.py +++ b/netsuite/rest_api_base.py @@ -1,5 +1,7 @@ import asyncio +import functools import logging +import time from functools import cached_property import httpx @@ -9,7 +11,17 @@ from oauthlib.oauth1.rfc5849.signature import sign_hmac_sha256 from . import json +from .config import ( + OAuth2AccessTokenAuth, + OAuth2ClientCredentialsAuth, + TokenAuth, +) from .exceptions import NetsuiteAPIRequestError, NetsuiteAPIResponseParsingError +from .oauth2 import ( + OAuth2BearerAuth, + OAuth2Token, + exchange_client_assertion, +) __all__ = ("RestApiBase",) @@ -91,14 +103,56 @@ def _make_url(self, subpath: str): def _make_auth(self): auth = self._config.auth - return OAuth1Auth( - client_id=auth.consumer_key, - client_secret=auth.consumer_secret, - token=auth.token_id, - token_secret=auth.token_secret, - realm=self._config.account, - force_include_body=True, - signature_method=self._signature_method, + if isinstance(auth, TokenAuth): + return OAuth1Auth( + client_id=auth.consumer_key, + client_secret=auth.consumer_secret, + token=auth.token_id, + token_secret=auth.token_secret, + realm=self._config.account, + force_include_body=True, + signature_method=self._signature_method, + ) + if isinstance(auth, OAuth2ClientCredentialsAuth): + # The auth handler caches the token across calls; we lazily + # bind a `token_factory` that knows how to mint a fresh one. + token_factory = functools.partial( + exchange_client_assertion, + self._config.account, + client_id=auth.client_id, + certificate_id=auth.certificate_id, + private_key_pem=auth.private_key_pem, + scope=auth.scope, + algorithm=auth.algorithm, + ) + cached = getattr(self, "_oauth2_handler", None) + if cached is None: + cached = OAuth2BearerAuth(token_factory) + # Cache on the instance so token re-use works across + # back-to-back requests. + self._oauth2_handler = cached # type: ignore[attr-defined] + return cached + if isinstance(auth, OAuth2AccessTokenAuth): + # Bring-your-own token. We don't refresh; the user wired + # that in upstream. We still wrap it in OAuth2BearerAuth so + # the Authorization header is set consistently. + initial = OAuth2Token( + access_token=auth.access_token, + expires_at=auth.expires_at or (time.time() + 3600), + refresh_token=auth.refresh_token, + ) + + async def _no_refresh() -> OAuth2Token: + raise RuntimeError( + "OAuth2AccessTokenAuth does not refresh automatically. " + "Provide a fresh token via your own auth flow." + ) + + return OAuth2BearerAuth(_no_refresh, initial_token=initial) + raise TypeError( + f"Unsupported auth type for HTTP requests: {type(auth).__name__}. " + f"Use TokenAuth, OAuth2ClientCredentialsAuth, or " + f"OAuth2AccessTokenAuth." ) def _make_default_headers(self): diff --git a/netsuite/soap_api/client.py b/netsuite/soap_api/client.py index 9b490aa..ced876c 100644 --- a/netsuite/soap_api/client.py +++ b/netsuite/soap_api/client.py @@ -1,5 +1,6 @@ import logging import re +import warnings from contextlib import contextmanager from datetime import datetime from functools import cached_property @@ -15,6 +16,14 @@ __all__ = ("NetSuiteSoapApi",) +SOAP_DEPRECATION_MESSAGE = ( + "NetSuite has announced that SOAP Web Services will be removed in the " + "2027.1 release. New integrations should use the REST API with OAuth 2.0 " + "(see `netsuite.OAuth2ClientCredentialsAuth`); existing SOAP integrations " + "should plan a migration before 2027.1." +) + + class NetSuiteSoapApi: version = getattr(Config, "wsdl_version", "2021.1.0") wsdl_url_tmpl = "https://{account_slug}.suitetalk.api.netsuite.com/wsdl/v{underscored_version}/netsuite.wsdl" @@ -28,6 +37,11 @@ def __init__( cache: Optional[zeep.cache.Base] = None, ) -> None: self._ensure_required_dependencies() + warnings.warn( + SOAP_DEPRECATION_MESSAGE, + DeprecationWarning, + stacklevel=2, + ) if version is not None: assert re.match(r"\d+\.\d+\.\d+", version) self.version = version diff --git a/pyproject.toml b/pyproject.toml index fd075f7..01ebe5a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,10 @@ python = ">=3.9" authlib = ">=1,<3" # As per httpx recommendation we will lock to a fixed minor version until 1.0 is released httpx = ">=0.25,<0.29" +# Used directly by netsuite/oauth2.py for JWT signing. Newer authlib pulls +# this in transitively, but older authlib in our `>=1,<3` range does not, +# so declare it explicitly to keep CI installs deterministic. +joserfc = ">=1,<2" pydantic = "^2.4.2" orjson = { version = "~3", optional = true } ipython = { version = "~8", optional = true, python = "^3.9" } @@ -74,3 +78,10 @@ multi_line_output = 3 [tool.pytest.ini_options] markers = ["slow: marks tests as slow (deselect with '-m \"not slow\"')"] asyncio_mode = "strict" +# The SOAP DeprecationWarning is intentional and tested explicitly in +# test_soap_api_deprecation. Silence it in every other test path so the +# pytest summary stays useful for surfacing real warnings. +filterwarnings = [ + "default", + "ignore:NetSuite has announced that SOAP Web Services:DeprecationWarning", +] diff --git a/tests/test_oauth2.py b/tests/test_oauth2.py new file mode 100644 index 0000000..2128adc --- /dev/null +++ b/tests/test_oauth2.py @@ -0,0 +1,428 @@ +"""Tests for the OAuth 2.0 module — JWT assertion building, token +exchange, and the httpx auth handler. We do not hit a real NetSuite +instance: the token endpoint is mocked at the httpx layer.""" + +import time +from unittest.mock import AsyncMock, patch + +import httpx +import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from joserfc import jwt as jose_jwt +from joserfc.jwk import RSAKey + +from netsuite.oauth2 import ( + DEFAULT_SCOPES, + JWT_BEARER_ASSERTION_TYPE, + OAuth2BearerAuth, + OAuth2Token, + build_authorization_url, + build_client_assertion, + build_token_endpoint, + exchange_authorization_code, + exchange_client_assertion, +) + +# --------------------------------------------------------------------------- +# Test fixtures: generate a real RSA keypair so the JWT signing path is +# exercised end-to-end (otherwise we'd just be testing mocks). +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="module") +def rsa_private_key_pem(): + private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + return private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ).decode("utf-8") + + +@pytest.fixture(scope="module") +def rsa_public_jwk(rsa_private_key_pem): + """Used to verify the signature on assertions we build in tests.""" + private = RSAKey.import_key(rsa_private_key_pem) + return private # joserfc happily verifies with the same key object + + +# --------------------------------------------------------------------------- +# URL builders +# --------------------------------------------------------------------------- + + +def test_build_token_endpoint_for_sandbox(): + assert ( + build_token_endpoint("123456_SB1") + == "https://123456-sb1.suitetalk.api.netsuite.com/services/rest/auth/oauth2/v1/token" + ) + + +def test_build_token_endpoint_for_production(): + assert ( + build_token_endpoint("123456") + == "https://123456.suitetalk.api.netsuite.com/services/rest/auth/oauth2/v1/token" + ) + + +def test_build_authorization_url_includes_required_params(): + url = build_authorization_url( + "123456_SB1", + client_id="abc", + redirect_uri="https://app.example.com/oauth/callback", + scope=["rest_webservices", "restlets"], + state="opaque-state", + ) + parsed = httpx.URL(url) + assert parsed.host == "123456-sb1.app.netsuite.com" + assert parsed.path == "/app/login/oauth2/authorize.nl" + params = dict(parsed.params.multi_items()) + assert params["response_type"] == "code" + assert params["client_id"] == "abc" + assert params["redirect_uri"] == "https://app.example.com/oauth/callback" + assert params["scope"] == "rest_webservices restlets" + assert params["state"] == "opaque-state" + + +def test_build_authorization_url_omits_state_when_unspecified(): + url = build_authorization_url( + "123456", + client_id="abc", + redirect_uri="https://example.com/cb", + ) + assert "state=" not in url + + +# --------------------------------------------------------------------------- +# Client assertion (JWT) building +# --------------------------------------------------------------------------- + + +def test_client_assertion_carries_required_claims(rsa_private_key_pem, rsa_public_jwk): + assertion = build_client_assertion( + "123456_SB1", + client_id="my-app", + certificate_id="cert-kid-42", + private_key_pem=rsa_private_key_pem, + scope=["rest_webservices"], + algorithm="RS256", + now=1_700_000_000, + ) + decoded = jose_jwt.decode(assertion, rsa_public_jwk) + + # Header + assert decoded.header["alg"] == "RS256" + assert decoded.header["typ"] == "JWT" + assert decoded.header["kid"] == "cert-kid-42" + + # Claims + claims = decoded.claims + assert claims["iss"] == "my-app" + assert claims["scope"] == ["rest_webservices"] + assert claims["aud"] == build_token_endpoint("123456_SB1") + assert claims["iat"] == 1_700_000_000 + # NetSuite caps `exp` at iat + 3600. + assert claims["exp"] == 1_700_000_000 + 3600 + + +def test_client_assertion_clamps_ttl_to_one_hour(rsa_private_key_pem, rsa_public_jwk): + assertion = build_client_assertion( + "123456", + client_id="x", + certificate_id="k", + private_key_pem=rsa_private_key_pem, + algorithm="RS256", + now=1_000_000, + ttl_seconds=99_999, # asking for way more + ) + claims = jose_jwt.decode(assertion, rsa_public_jwk).claims + assert claims["exp"] == 1_000_000 + 3600 # clamped + + +def test_client_assertion_rejects_unsupported_algorithm(rsa_private_key_pem): + with pytest.raises(ValueError, match="Unsupported algorithm"): + build_client_assertion( + "123456", + client_id="x", + certificate_id="k", + private_key_pem=rsa_private_key_pem, + algorithm="HS256", + ) + + +def test_client_assertion_default_scope_is_rest_webservices( + rsa_private_key_pem, rsa_public_jwk +): + assertion = build_client_assertion( + "123", + client_id="x", + certificate_id="k", + private_key_pem=rsa_private_key_pem, + algorithm="RS256", + ) + claims = jose_jwt.decode(assertion, rsa_public_jwk).claims + assert claims["scope"] == list(DEFAULT_SCOPES) + + +# --------------------------------------------------------------------------- +# OAuth2Token dataclass +# --------------------------------------------------------------------------- + + +def test_token_from_response_extracts_fields(): + payload = { + "access_token": "AT", + "expires_in": 3600, + "token_type": "Bearer", + "scope": "rest_webservices restlets", + "refresh_token": "RT", + } + token = OAuth2Token.from_response(payload, now=1_000_000) + assert token.access_token == "AT" + assert token.expires_at == 1_000_000 + 3600 + assert token.refresh_token == "RT" + assert token.scope == ["rest_webservices", "restlets"] + + +def test_token_is_expired_with_safety_margin(): + token = OAuth2Token(access_token="x", expires_at=1_000_000) + assert token.is_expired(now=999_999) # within 60s margin -> already expired + assert token.is_expired(now=1_000_000) + assert not token.is_expired(now=900_000) + + +# --------------------------------------------------------------------------- +# Token exchange (Client Credentials + Authorization Code) +# --------------------------------------------------------------------------- + + +def _mock_post_returning(token_payload): + """Patch httpx.AsyncClient.post to return a successful token response.""" + response = httpx.Response( + 200, + json=token_payload, + request=httpx.Request("POST", "https://example.com"), + ) + return patch("httpx.AsyncClient.post", new=AsyncMock(return_value=response)) + + +@pytest.mark.asyncio +async def test_exchange_client_assertion_posts_correct_form(rsa_private_key_pem): + captured = {} + + async def fake_post(self, url, *, data=None, headers=None, **kw): + captured["url"] = url + captured["data"] = data + captured["headers"] = headers + return httpx.Response( + 200, + json={"access_token": "AT", "expires_in": 3600, "token_type": "Bearer"}, + request=httpx.Request("POST", url), + ) + + with patch("httpx.AsyncClient.post", new=fake_post): + token = await exchange_client_assertion( + "123456_SB1", + client_id="my-app", + certificate_id="cert-1", + private_key_pem=rsa_private_key_pem, + algorithm="RS256", + ) + + assert token.access_token == "AT" + assert captured["url"] == build_token_endpoint("123456_SB1") + assert captured["data"]["grant_type"] == "client_credentials" + assert captured["data"]["client_assertion_type"] == JWT_BEARER_ASSERTION_TYPE + assert "client_assertion" in captured["data"] + assert captured["headers"]["Content-Type"] == "application/x-www-form-urlencoded" + + +@pytest.mark.asyncio +async def test_exchange_client_assertion_raises_on_4xx(rsa_private_key_pem): + error = httpx.Response( + 401, + json={"error": "invalid_client"}, + text='{"error":"invalid_client"}', + request=httpx.Request("POST", "https://x"), + ) + with patch("httpx.AsyncClient.post", new=AsyncMock(return_value=error)): + with pytest.raises(httpx.HTTPStatusError) as excinfo: + await exchange_client_assertion( + "123", + client_id="x", + certificate_id="k", + private_key_pem=rsa_private_key_pem, + algorithm="RS256", + ) + assert "401" in str(excinfo.value) + assert "invalid_client" in str(excinfo.value) + + +@pytest.mark.asyncio +async def test_exchange_authorization_code_with_client_secret(): + captured = {} + + async def fake_post(self, url, *, data=None, headers=None, **kw): + captured["data"] = data + return httpx.Response( + 200, + json={ + "access_token": "AT", + "expires_in": 3600, + "refresh_token": "RT", + }, + request=httpx.Request("POST", url), + ) + + with patch("httpx.AsyncClient.post", new=fake_post): + token = await exchange_authorization_code( + "123", + code="auth-code-xyz", + client_id="my-app", + client_secret="shh", + redirect_uri="https://example.com/cb", + ) + assert token.access_token == "AT" + assert token.refresh_token == "RT" + assert captured["data"]["grant_type"] == "authorization_code" + assert captured["data"]["client_secret"] == "shh" + assert captured["data"]["code"] == "auth-code-xyz" + assert "client_assertion" not in captured["data"] + + +@pytest.mark.asyncio +async def test_exchange_authorization_code_with_jwt_assertion(rsa_private_key_pem): + captured = {} + + async def fake_post(self, url, *, data=None, headers=None, **kw): + captured["data"] = data + return httpx.Response( + 200, + json={"access_token": "AT", "expires_in": 3600}, + request=httpx.Request("POST", url), + ) + + with patch("httpx.AsyncClient.post", new=fake_post): + await exchange_authorization_code( + "123", + code="auth-code-xyz", + client_id="my-app", + certificate_id="cert", + private_key_pem=rsa_private_key_pem, + redirect_uri="https://example.com/cb", + algorithm="RS256", + ) + assert captured["data"]["client_assertion_type"] == JWT_BEARER_ASSERTION_TYPE + assert "client_secret" not in captured["data"] + + +@pytest.mark.asyncio +async def test_exchange_authorization_code_rejects_no_credential(): + with pytest.raises(ValueError, match="exactly one"): + await exchange_authorization_code( + "123", code="x", client_id="a", redirect_uri="b" + ) + + +@pytest.mark.asyncio +async def test_exchange_authorization_code_rejects_both_credentials( + rsa_private_key_pem, +): + with pytest.raises(ValueError, match="exactly one"): + await exchange_authorization_code( + "123", + code="x", + client_id="a", + redirect_uri="b", + client_secret="shh", + certificate_id="cert", + private_key_pem=rsa_private_key_pem, + ) + + +# --------------------------------------------------------------------------- +# OAuth2BearerAuth httpx handler +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_bearer_auth_fetches_initial_token(): + factory_calls = 0 + + async def factory(): + nonlocal factory_calls + factory_calls += 1 + return OAuth2Token(access_token="T1", expires_at=time.time() + 3600) + + auth = OAuth2BearerAuth(factory) + request = httpx.Request("GET", "https://example.com/") + flow = auth.async_auth_flow(request) + sent = await flow.__anext__() + assert sent.headers["Authorization"] == "Bearer T1" + assert factory_calls == 1 + + +@pytest.mark.asyncio +async def test_bearer_auth_reuses_token_until_expiry(): + factory_calls = 0 + + async def factory(): + nonlocal factory_calls + factory_calls += 1 + return OAuth2Token( + access_token=f"T{factory_calls}", expires_at=time.time() + 3600 + ) + + auth = OAuth2BearerAuth(factory) + for _ in range(3): + request = httpx.Request("GET", "https://example.com/") + flow = auth.async_auth_flow(request) + await flow.__anext__() + assert factory_calls == 1 + + +@pytest.mark.asyncio +async def test_bearer_auth_refreshes_when_expired(): + factory_calls = 0 + + async def factory(): + nonlocal factory_calls + factory_calls += 1 + # First token is already past expiry; second is fresh. + if factory_calls == 1: + return OAuth2Token(access_token="stale", expires_at=time.time() - 100) + return OAuth2Token(access_token="fresh", expires_at=time.time() + 3600) + + auth = OAuth2BearerAuth(factory) + request = httpx.Request("GET", "https://example.com/") + sent = await auth.async_auth_flow(request).__anext__() + # The first stale token is replaced before we send the request. + # On the very first call the factory mints stale (which is_expired() True + # immediately), then a SECOND factory call mints fresh. We accept either + # the fresh or stale token here — both pass through the bearer header + # — what we really want to assert is that an expired stored token would + # trigger a refresh on the *next* request. + auth._token = OAuth2Token(access_token="stale2", expires_at=time.time() - 100) + request = httpx.Request("GET", "https://example.com/") + sent = await auth.async_auth_flow(request).__anext__() + assert sent.headers["Authorization"] == "Bearer fresh" + + +@pytest.mark.asyncio +async def test_bearer_auth_initial_token_is_used_as_is(): + initial = OAuth2Token(access_token="prefetched", expires_at=time.time() + 3600) + + async def factory(): + raise AssertionError("factory should not be called when initial token is fresh") + + auth = OAuth2BearerAuth(factory, initial_token=initial) + request = httpx.Request("GET", "https://example.com/") + sent = await auth.async_auth_flow(request).__anext__() + assert sent.headers["Authorization"] == "Bearer prefetched" + + +def test_bearer_auth_sync_flow_is_not_supported(): + auth = OAuth2BearerAuth(token_factory=lambda: None) # type: ignore[arg-type] + with pytest.raises(RuntimeError, match="async-only"): + list(auth.sync_auth_flow(httpx.Request("GET", "https://example.com/"))) diff --git a/tests/test_oauth2_integration.py b/tests/test_oauth2_integration.py new file mode 100644 index 0000000..eaae20d --- /dev/null +++ b/tests/test_oauth2_integration.py @@ -0,0 +1,232 @@ +"""Integration tests for OAuth 2.0: `Config` accepts the new auth types, +and `RestApiBase._make_auth` dispatches to the right httpx handler.""" + +import time +from unittest.mock import patch + +import httpx +import pytest +from authlib.integrations.httpx_client import OAuth1Auth +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa + +from netsuite import ( + Config, + OAuth2AccessTokenAuth, + OAuth2ClientCredentialsAuth, + TokenAuth, +) +from netsuite.oauth2 import OAuth2BearerAuth +from netsuite.rest_api_base import RestApiBase + + +@pytest.fixture(scope="module") +def rsa_private_key_pem(): + private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + return private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ).decode("utf-8") + + +class _ConcreteApi(RestApiBase): + def __init__(self, config): + self._config = config + self._default_timeout = 10 + self._concurrent_requests = 5 + + def _make_url(self, subpath): + return f"https://example.com{subpath}" + + +# --------------------------------------------------------------------------- +# Config integration +# --------------------------------------------------------------------------- + + +def test_config_accepts_oauth2_client_credentials(rsa_private_key_pem): + config = Config( + account="123456_SB1", + auth=OAuth2ClientCredentialsAuth( + client_id="my-app", + certificate_id="cert-1", + private_key_pem=rsa_private_key_pem, + ), + ) + assert config.is_oauth2_auth + assert not config.is_token_auth + + +def test_config_accepts_oauth2_access_token(): + config = Config( + account="123", + auth=OAuth2AccessTokenAuth(access_token="prefetched"), + ) + assert config.is_oauth2_auth + + +def test_config_token_auth_is_not_oauth2(): + config = Config( + account="123", + auth=TokenAuth( + consumer_key="ck", + consumer_secret="cs", + token_id="ti", + token_secret="ts", + ), + ) + assert config.is_token_auth + assert not config.is_oauth2_auth + + +def test_oauth2_client_credentials_default_scope_and_alg(): + auth = OAuth2ClientCredentialsAuth( + client_id="x", + certificate_id="k", + private_key_pem="pem", + ) + assert auth.scope == ["rest_webservices"] + assert auth.algorithm == "PS256" + + +# --------------------------------------------------------------------------- +# Auth dispatch +# --------------------------------------------------------------------------- + + +def test_make_auth_returns_oauth1_for_token_auth(dummy_config): + api = _ConcreteApi(dummy_config) + assert isinstance(api._make_auth(), OAuth1Auth) + + +def test_make_auth_returns_oauth2_bearer_for_client_credentials( + rsa_private_key_pem, +): + config = Config( + account="123", + auth=OAuth2ClientCredentialsAuth( + client_id="my-app", + certificate_id="cert", + private_key_pem=rsa_private_key_pem, + algorithm="RS256", + ), + ) + api = _ConcreteApi(config) + auth = api._make_auth() + assert isinstance(auth, OAuth2BearerAuth) + + +def test_make_auth_caches_oauth2_handler_across_calls(rsa_private_key_pem): + """The handler holds the cached access token; we must not rebuild it + on every request or we'd lose the cache.""" + config = Config( + account="123", + auth=OAuth2ClientCredentialsAuth( + client_id="my-app", + certificate_id="cert", + private_key_pem=rsa_private_key_pem, + algorithm="RS256", + ), + ) + api = _ConcreteApi(config) + first = api._make_auth() + second = api._make_auth() + assert first is second + + +def test_make_auth_returns_oauth2_bearer_for_access_token_auth(): + config = Config( + account="123", + auth=OAuth2AccessTokenAuth( + access_token="prefetched", expires_at=time.time() + 3600 + ), + ) + api = _ConcreteApi(config) + auth = api._make_auth() + assert isinstance(auth, OAuth2BearerAuth) + assert auth.token is not None + assert auth.token.access_token == "prefetched" + + +@pytest.mark.asyncio +async def test_access_token_auth_does_not_refresh_automatically(): + """OAuth2AccessTokenAuth is bring-your-own-token. If the stored token + expires we don't try to refresh — we raise a clear error so the caller + knows to wire that into their upstream auth flow.""" + config = Config( + account="123", + auth=OAuth2AccessTokenAuth( + access_token="stale", + expires_at=time.time() - 1000, # already expired + ), + ) + api = _ConcreteApi(config) + auth = api._make_auth() + request = httpx.Request("GET", "https://example.com/") + with pytest.raises(RuntimeError, match="does not refresh automatically"): + await auth.async_auth_flow(request).__anext__() + + +def test_make_auth_rejects_username_password_auth(dummy_username_password_config): + """ODBC auth has no HTTP equivalent — make sure we don't silently + fall through to a broken request.""" + api = _ConcreteApi(dummy_username_password_config) + with pytest.raises(TypeError, match="Unsupported auth type"): + api._make_auth() + + +# --------------------------------------------------------------------------- +# End-to-end: a request goes through with a Bearer header +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_request_sends_bearer_header_for_client_credentials_auth( + rsa_private_key_pem, +): + """End-to-end: a request through `_request_impl` triggers the OAuth2 + handler, which mints a token via the token factory and stamps it on + the outbound request as `Authorization: Bearer ...`.""" + from netsuite.oauth2 import OAuth2Token + + config = Config( + account="123", + auth=OAuth2ClientCredentialsAuth( + client_id="my-app", + certificate_id="cert", + private_key_pem=rsa_private_key_pem, + algorithm="RS256", + ), + ) + api = _ConcreteApi(config) + captured_authorizations = [] + + class _FakeClient: + async def __aenter__(self): + return self + + async def __aexit__(self, *a): + return False + + async def request(self, **kw): + # httpx normally runs the auth flow itself; we have to do it + # by hand here since we've replaced AsyncClient entirely. + auth_obj = kw["auth"] + req = httpx.Request(kw["method"], kw["url"], headers=kw.get("headers")) + async for prepared in auth_obj.async_auth_flow(req): + captured_authorizations.append(prepared.headers.get("Authorization")) + return httpx.Response(200, json={"ok": True}, request=req) + + # Bypass the real token endpoint by stubbing the exchange function: + # it's the cleanest seam, and it lets us assert on the token that + # ends up in the Authorization header. + async def fake_exchange(*args, **kw): + return OAuth2Token(access_token="live-token", expires_at=time.time() + 3600) + + with patch( + "netsuite.rest_api_base.exchange_client_assertion", new=fake_exchange + ), patch("netsuite.rest_api_base.httpx.AsyncClient", return_value=_FakeClient()): + await api._request_impl("GET", "/x") + + assert captured_authorizations == ["Bearer live-token"] diff --git a/tests/test_soap_api_deprecation.py b/tests/test_soap_api_deprecation.py new file mode 100644 index 0000000..7939edd --- /dev/null +++ b/tests/test_soap_api_deprecation.py @@ -0,0 +1,33 @@ +"""Verify NetSuiteSoapApi emits a DeprecationWarning pointing users at +OAuth 2.0. NetSuite plans to remove SOAP Web Services in the 2027.1 +release, and the warning is the user-facing nudge to migrate.""" + +import warnings + +import pytest + +from netsuite import NetSuiteSoapApi +from netsuite.soap_api.client import SOAP_DEPRECATION_MESSAGE +from netsuite.soap_api.zeep import ZEEP_INSTALLED + +pytestmark = pytest.mark.skipif(not ZEEP_INSTALLED, reason="Requires zeep") + + +def test_soap_api_init_emits_deprecation_warning(dummy_config): + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + NetSuiteSoapApi(dummy_config) + deprecation_warnings = [ + w for w in caught if issubclass(w.category, DeprecationWarning) + ] + assert len(deprecation_warnings) == 1 + assert "2027.1" in str(deprecation_warnings[0].message) + assert "OAuth 2.0" in str(deprecation_warnings[0].message) + + +def test_soap_deprecation_message_mentions_replacement(dummy_config): + """The message should point readers at the OAuth2 config class so they + have a concrete next step, not just a generic warning.""" + assert "OAuth2ClientCredentialsAuth" in SOAP_DEPRECATION_MESSAGE + assert "REST API" in SOAP_DEPRECATION_MESSAGE + assert "2027.1" in SOAP_DEPRECATION_MESSAGE From 9557c07f135980dd00d56e38f59ffdf43b6bd489 Mon Sep 17 00:00:00 2001 From: Vicente Louvet III Date: Fri, 8 May 2026 09:38:27 -0600 Subject: [PATCH 18/29] ci(codecov): pass CODECOV_TOKEN to codecov-action (#16) For private repos, and for forks where Codecov tokenless uploads can be flaky, the token has to be passed explicitly to `codecov/codecov-action@v5`. The repo's `CODECOV_TOKEN` secret is read at run time and forwarded. Co-authored-by: Claude Opus 4.7 (1M context) --- .github/workflows/codecov.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml index 1e22cb4..eeba6a1 100644 --- a/.github/workflows/codecov.yml +++ b/.github/workflows/codecov.yml @@ -19,3 +19,4 @@ jobs: - uses: codecov/codecov-action@v5 with: files: ./coverage.xml + token: ${{ secrets.CODECOV_TOKEN }} From 365939233fb348a03bab6ad8ade891f8caa29020 Mon Sep 17 00:00:00 2001 From: Vicente Louvet III Date: Wed, 17 Jun 2026 12:57:24 -0600 Subject: [PATCH 19/29] build(deps-dev): update black requirement from ~24 to ~25 (#18) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Imports the dependabot bump from upstream PR jacobsvante/netsuite#109. Black 25 introduces the 2025 stable style (PEP 639 license metadata, trailing-comma normalization on typed function parameters, generic function definition wrapping, etc.) but none of those rules trigger on this codebase — `black --check .` is a no-op after the version bump, so no reformat hunks are bundled into this commit. Co-authored-by: Claude Opus 4.7 (1M context) --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 01ebe5a..126643c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,7 +49,7 @@ orjson = ["orjson"] all = ["zeep", "ipython", "orjson", "odbc"] [tool.poetry.dev-dependencies] -black = "~24" +black = "~25" flake8 = "~7" isort = "~6" mkdocs-material = "~9" From 596f63759156185e1b32a90c6d64c04f0a03385f Mon Sep 17 00:00:00 2001 From: Vicente Louvet III Date: Wed, 17 Jun 2026 12:57:58 -0600 Subject: [PATCH 20/29] build(deps-dev): update pytest-cov requirement from ~5 to ~6 (#14) Imports the dependabot bump from upstream PR jacobsvante/netsuite#108. pytest-cov 6 dropped Python 3.8 support; this fork already requires Python >=3.9 in pyproject.toml so the floor is unchanged. Co-authored-by: Claude Opus 4.7 (1M context) --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 126643c..7edede6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,7 +56,7 @@ mkdocs-material = "~9" mypy = ">=1,<3" pytest = "~8" pytest-asyncio = "~0.23" -pytest-cov = "~5" +pytest-cov = "~6" types-setuptools = "^75.8.2" types-requests = "^2.27.30" From 9d81932fc3f7daec3176579c65e5afbea2b82d95 Mon Sep 17 00:00:00 2001 From: Vicente Louvet III Date: Wed, 17 Jun 2026 13:00:06 -0600 Subject: [PATCH 21/29] feat(soap_api): bump default WSDL version 2021.1.0 -> 2024.2.0 (#12) Imports the change from upstream PR jacobsvante/netsuite#99 (merit-finns). NetSuite explicitly informed integrators that SOAP web service WSDL versions 2017.2 through 2021.1 are unsupported and that integrations on those versions need to upgrade. Until our 2027.1 SOAP sunset arrives, the lib's default WSDL needs to be one NetSuite still serves. The `NetSuiteSoapApi(version=...)` kwarg still lets users pin to a specific version when their account hasn't migrated yet. Co-authored-by: Claude Opus 4.7 (1M context) --- netsuite/soap_api/client.py | 2 +- tests/test_soap_api.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/netsuite/soap_api/client.py b/netsuite/soap_api/client.py index ced876c..936b648 100644 --- a/netsuite/soap_api/client.py +++ b/netsuite/soap_api/client.py @@ -25,7 +25,7 @@ class NetSuiteSoapApi: - version = getattr(Config, "wsdl_version", "2021.1.0") + version = getattr(Config, "wsdl_version", "2024.2.0") wsdl_url_tmpl = "https://{account_slug}.suitetalk.api.netsuite.com/wsdl/v{underscored_version}/netsuite.wsdl" def __init__( diff --git a/tests/test_soap_api.py b/tests/test_soap_api.py index e59754e..496795c 100644 --- a/tests/test_soap_api.py +++ b/tests/test_soap_api.py @@ -27,7 +27,7 @@ def test_netsuite_wsdl_url(dummy_config): soap_api = NetSuiteSoapApi(dummy_config) assert ( soap_api.wsdl_url - == "https://123456-sb1.suitetalk.api.netsuite.com/wsdl/v2021_1_0/netsuite.wsdl" + == "https://123456-sb1.suitetalk.api.netsuite.com/wsdl/v2024_2_0/netsuite.wsdl" ) @@ -35,7 +35,7 @@ def test_netsuite_wsdl_url_production_account(dummy_config_with_production_accou soap_api = NetSuiteSoapApi(dummy_config_with_production_account) assert ( soap_api.wsdl_url - == "https://123456.suitetalk.api.netsuite.com/wsdl/v2021_1_0/netsuite.wsdl" + == "https://123456.suitetalk.api.netsuite.com/wsdl/v2024_2_0/netsuite.wsdl" ) From 2f5463e76fbced9de084733623619028178f56bb Mon Sep 17 00:00:00 2001 From: Vicente Louvet III Date: Wed, 17 Jun 2026 13:07:52 -0600 Subject: [PATCH 22/29] build(deps-dev): update types-setuptools requirement from ^75.8.2 to ^80.9.0 (#13) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Imports the dependabot bump from upstream PR jacobsvante/netsuite#117. types-setuptools is type stubs only — no runtime impact. Co-authored-by: Claude Opus 4.7 (1M context) --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 7edede6..d0efd37 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,8 +56,8 @@ mkdocs-material = "~9" mypy = ">=1,<3" pytest = "~8" pytest-asyncio = "~0.23" +types-setuptools = "^80.9.0" pytest-cov = "~6" -types-setuptools = "^75.8.2" types-requests = "^2.27.30" [tool.poetry.scripts] From b97f310ec3fb6bf8845b40fb700f5d23dc279503 Mon Sep 17 00:00:00 2001 From: Vicente Louvet III Date: Wed, 17 Jun 2026 15:07:51 -0600 Subject: [PATCH 23/29] fix(soap): align deprecation wording with official 2026.1 removal plans (#20) The deprecation warning and OAuth2 module docstring asserted SOAP Web Services 'will be removed in the 2027.1 release.' The NetSuite 2026.1 SuiteTalk release notes make no such hard-date claim: removal is gradual, 2025.2 is the last *planned* SOAP endpoint, and older endpoints degrade to unsupported over subsequent releases (later endpoints only released as needed). Soften the message and docstring to match the official wording and point at the SOAP Removal Plans FAQ. The 'NetSuite has announced that SOAP Web Services' prefix is preserved so the pyproject filterwarnings entry still matches. Update test_soap_api_deprecation to assert 2025.2 instead of the removed 2027.1 date. Co-authored-by: Claude Opus 4.8 (1M context) --- netsuite/oauth2.py | 6 ++++-- netsuite/soap_api/client.py | 11 +++++++---- tests/test_soap_api_deprecation.py | 8 ++++---- 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/netsuite/oauth2.py b/netsuite/oauth2.py index 7a43a12..cd22d9e 100644 --- a/netsuite/oauth2.py +++ b/netsuite/oauth2.py @@ -1,7 +1,9 @@ """OAuth 2.0 support for NetSuite. -NetSuite is sunsetting SOAP Web Services in the 2027.1 release. The -recommended path forward is the REST API authenticated via OAuth 2.0. +NetSuite is gradually removing SOAP Web Services (2025.2 is the last +planned SOAP endpoint, with older endpoints losing support over the +following releases). The recommended path forward is the REST API +authenticated via OAuth 2.0. This module implements the two flows that matter for a backend library: * **Client Credentials with JWT Bearer Assertion** (RFC 7523 — *machine- diff --git a/netsuite/soap_api/client.py b/netsuite/soap_api/client.py index 936b648..3f1da69 100644 --- a/netsuite/soap_api/client.py +++ b/netsuite/soap_api/client.py @@ -17,10 +17,13 @@ SOAP_DEPRECATION_MESSAGE = ( - "NetSuite has announced that SOAP Web Services will be removed in the " - "2027.1 release. New integrations should use the REST API with OAuth 2.0 " - "(see `netsuite.OAuth2ClientCredentialsAuth`); existing SOAP integrations " - "should plan a migration before 2027.1." + "NetSuite has announced that SOAP Web Services are being gradually " + "removed. The 2025.2 endpoint is the last planned SOAP endpoint, and " + "older endpoints lose support over the following releases. New " + "integrations should use the REST API with OAuth 2.0 (see " + "`netsuite.OAuth2ClientCredentialsAuth`); existing SOAP integrations " + "should plan a migration. See NetSuite's SOAP Removal Plans FAQ for the " + "endpoint support timeline." ) diff --git a/tests/test_soap_api_deprecation.py b/tests/test_soap_api_deprecation.py index 7939edd..e0f62f0 100644 --- a/tests/test_soap_api_deprecation.py +++ b/tests/test_soap_api_deprecation.py @@ -1,6 +1,6 @@ """Verify NetSuiteSoapApi emits a DeprecationWarning pointing users at -OAuth 2.0. NetSuite plans to remove SOAP Web Services in the 2027.1 -release, and the warning is the user-facing nudge to migrate.""" +OAuth 2.0. NetSuite is gradually removing SOAP Web Services (2025.2 is the +last planned endpoint), and the warning is the user-facing nudge to migrate.""" import warnings @@ -21,7 +21,7 @@ def test_soap_api_init_emits_deprecation_warning(dummy_config): w for w in caught if issubclass(w.category, DeprecationWarning) ] assert len(deprecation_warnings) == 1 - assert "2027.1" in str(deprecation_warnings[0].message) + assert "2025.2" in str(deprecation_warnings[0].message) assert "OAuth 2.0" in str(deprecation_warnings[0].message) @@ -30,4 +30,4 @@ def test_soap_deprecation_message_mentions_replacement(dummy_config): have a concrete next step, not just a generic warning.""" assert "OAuth2ClientCredentialsAuth" in SOAP_DEPRECATION_MESSAGE assert "REST API" in SOAP_DEPRECATION_MESSAGE - assert "2027.1" in SOAP_DEPRECATION_MESSAGE + assert "2025.2" in SOAP_DEPRECATION_MESSAGE From 1e12d13d58b09db8c251a683db60c39ad431f7bb Mon Sep 17 00:00:00 2001 From: Vicente Louvet III Date: Wed, 17 Jun 2026 15:08:33 -0600 Subject: [PATCH 24/29] chore(gitignore): ignore local /specs/ scratch folder (#21) The specs/ folder holds local reference material (NetSuite release-note PDFs, scratch notes) that should not be tracked. Co-authored-by: Claude Opus 4.8 (1M context) --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index ab7e397..f9f8455 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ /.pytest_cache /poetry.lock .DS_Store +/specs/ From 405da70c09963f3521abeabf359e33ab99009c5f Mon Sep 17 00:00:00 2001 From: Vicente Louvet III Date: Wed, 17 Jun 2026 15:10:55 -0600 Subject: [PATCH 25/29] feat(rest): add 2026.1 REST web services operation helpers (#22) Add convenience wrappers on NetSuiteRestApi for the four REST operations introduced in the NetSuite 2026.1 release, mirroring the existing suiteql/jsonschema/openapi helpers. All were already reachable via the generic get/post/patch methods; these absorb the operation-specific URL shapes and custom media-type headers: - attach() / detach(): POST /record/v1/{t}/{id}/!attach|!detach/{tt}/{tid}; optional contact "role" body; returns None on the 204 response. Internal and eid: external IDs both work. - create_form(): POST .../!transform/{tt} with the "Accept: application/vnd.oracle.resource+json; type=create-form" header, returning a record prepopulated from a related record. - select_options(): POST (new instance) or PATCH (existing, via record_id) with the type=select-options Accept header and a comma-joined "fields" param; dependent field values ride in the body. - batch(): async homogeneous add/update/upsert (max 100) with the "Prefer: respond-async" + type=collection headers. Talks to _request_impl directly to surface the async job's Location header, which the JSON-only _request helper discards. Covered by unit tests asserting method, URL, headers, params and body for each operation. Co-authored-by: Claude Opus 4.8 (1M context) --- netsuite/rest_api.py | 221 ++++++++++++++++++++++++++++++++++++++++- tests/test_rest_api.py | 147 +++++++++++++++++++++++++++ 2 files changed, 366 insertions(+), 2 deletions(-) diff --git a/netsuite/rest_api.py b/netsuite/rest_api.py index e9e5e18..4742010 100644 --- a/netsuite/rest_api.py +++ b/netsuite/rest_api.py @@ -1,15 +1,27 @@ import logging import re from functools import cached_property -from typing import Any, AsyncIterator, Dict, Optional, Sequence +from typing import Any, AsyncIterator, Dict, Optional, Sequence, Union -from . import rest_api_base +from . import json, rest_api_base from .config import Config +from .exceptions import NetsuiteAPIRequestError logger = logging.getLogger(__name__) __all__ = ("NetSuiteRestApi",) +# Custom media types introduced for the 2026.1 REST web services +# operations. Batch uses a `collection` content type; create-form and +# selectOptions select their behaviour through the `Accept` header. +_COLLECTION_MEDIA_TYPE = "application/vnd.oracle.resource+json; type=collection" +_CREATE_FORM_MEDIA_TYPE = "application/vnd.oracle.resource+json; type=create-form" +_SELECT_OPTIONS_MEDIA_TYPE = "application/vnd.oracle.resource+json; type=select-options" + +# Batch add/update/upsert verbs (GET/DELETE batches go through the normal +# get()/delete() with an `ids` param instead). +_BATCH_METHODS = ("POST", "PATCH", "PUT") + def _next_link(suiteql_response: Dict[str, Any]) -> Optional[str]: """Return the absolute URL of the `next` link from a SuiteQL response, or None.""" @@ -232,6 +244,211 @@ async def openapi(self, record_types: Sequence[str] = (), **request_kw): **request_kw, ) + async def attach( + self, + record_type: str, + record_id: str, + target_type: str, + target_id: str, + *, + role: Optional[Dict[str, Any]] = None, + **request_kw, + ): + """ + Attach one record instance to another, defining a relationship + between them (new in NetSuite 2026.1 REST web services). + + `record_type`/`record_id` identify the record being attached *to*; + `target_type`/`target_id` identify the record being attached. Both + IDs may be internal IDs or external IDs in `eid:VALUE` form. + + NetSuite currently supports attaching contact and file records only. + When attaching a contact you may pass `role` (e.g. `{"id": "-10"}` + or `{"externalId": "family"}`); otherwise the request body is empty. + + Returns `None` (NetSuite responds with HTTP 204 No Content). + + https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/article_0113084334.html + """ + json_body = request_kw.pop("json", {}) + if role is not None: + json_body = {"role": role, **json_body} + return await self._request( + "POST", + f"/record/v1/{record_type}/{record_id}/!attach/{target_type}/{target_id}", + json=json_body, + **request_kw, + ) + + async def detach( + self, + record_type: str, + record_id: str, + target_type: str, + target_id: str, + **request_kw, + ): + """ + Remove the relationship between two record instances (the inverse of + `attach`). IDs may be internal IDs or `eid:VALUE` external IDs. + + Returns `None` (HTTP 204 No Content). + + https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/article_0113084334.html + """ + return await self._request( + "POST", + f"/record/v1/{record_type}/{record_id}/!detach/{target_type}/{target_id}", + **request_kw, + ) + + async def create_form( + self, + record_type: str, + record_id: str, + target_type: str, + *, + body: Optional[Dict[str, Any]] = None, + **request_kw, + ): + """ + Run the create-form (transform) operation: load a target record with + its fields prepopulated from a related source record, without + submitting it (new in NetSuite 2026.1 REST web services). + + For example, transform a sales order into an item fulfillment to see + every default field and default line ID before you POST the new + record. Pass field overrides in `body`; the operation supports the + `expand`, `expandSubResources` and `fields` query params via + `params`. + + Returns the prepopulated record as a dict. + + https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/article_1217103046.html + """ + headers = { + "Accept": _CREATE_FORM_MEDIA_TYPE, + **request_kw.pop("headers", {}), + } + return await self._request( + "POST", + f"/record/v1/{record_type}/{record_id}/!transform/{target_type}", + headers=headers, + json=body if body is not None else {}, + **request_kw, + ) + + async def select_options( + self, + record_type: str, + fields: Union[str, Sequence[str]], + *, + record_id: Optional[str] = None, + body: Optional[Dict[str, Any]] = None, + **request_kw, + ): + """ + Retrieve the valid select options for one or more fields on a record + (new in NetSuite 2026.1 REST web services). + + Pass a single field name or a sequence of them in `fields` (sublist + fields use dotted names, e.g. `line.dueToFromSubsidiary`). Omit + `record_id` to get the options on a *new* record instance (issued as + a POST); pass `record_id` to query an *existing* instance (issued as + a PATCH). When the options depend on other field values, supply those + in `body` (e.g. `{"subsidiary": {"id": 1}}`). + + Returns the response dict, with a `_selectOptions` block per requested + field (each itself paginated: `items`/`count`/`hasMore`/ + `totalResults`). + + https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/article_0115100241.html + """ + if isinstance(fields, str): + fields = [fields] + headers = { + "Accept": _SELECT_OPTIONS_MEDIA_TYPE, + **request_kw.pop("headers", {}), + } + params = {"fields": ",".join(fields), **request_kw.pop("params", {})} + if record_id is None: + method, subpath = "POST", f"/record/v1/{record_type}" + else: + method, subpath = "PATCH", f"/record/v1/{record_type}/{record_id}" + return await self._request( + method, + subpath, + headers=headers, + params=params, + json=body if body is not None else {}, + **request_kw, + ) + + async def batch( + self, + record_type: str, + items: Sequence[Dict[str, Any]], + *, + method: str = "POST", + idempotency_key: Optional[str] = None, + **request_kw, + ): + """ + Add, update, or upsert up to 100 instances of a single record type in + one asynchronous request (new in NetSuite 2026.1 REST web services). + + `method` is POST (create), PUT (upsert), or PATCH (update). Each item + in `items` is a record body; PATCH/PUT items must carry an `id` or + `externalId`. Pass `idempotency_key` to set the + `X-NetSuite-idempotency-key` header. + + NetSuite processes the batch asynchronously, so this returns a dict + with the HTTP `status_code`, the `location` URL of the async job + (poll it with `get()`), and any response `body`. Unlike the other + helpers it talks to the lower-level request layer directly, because + the async job URL is only exposed in the `Location` response header, + which the JSON helpers discard. + + For batch reads or deletes, use `get()`/`delete()` on the collection + endpoint with an `ids` param instead. + + https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/article_0127092747.html + """ + method = method.upper() + if method not in _BATCH_METHODS: + raise ValueError( + f"batch() method must be one of {_BATCH_METHODS}, got " + f"{method!r}. For batch reads/deletes use get()/delete() with " + "an `ids` param." + ) + headers = { + "Prefer": "respond-async", + "Content-Type": _COLLECTION_MEDIA_TYPE, + **request_kw.pop("headers", {}), + } + if idempotency_key is not None: + headers.setdefault("X-NetSuite-idempotency-key", idempotency_key) + resp = await self._request_impl( + method, + f"/record/v1/{record_type}", + headers=headers, + json={"items": list(items), **request_kw.pop("json", {})}, + **request_kw, + ) + if resp.status_code < 200 or resp.status_code > 299: + raise NetsuiteAPIRequestError(resp.status_code, resp.text) + body = None + if resp.text: + try: + body = json.loads(resp.text) + except Exception: + body = None + return { + "status_code": resp.status_code, + "location": resp.headers.get("Location"), + "body": body, + } + def _make_hostname(self): return f"{self._config.account_slugified}.suitetalk.api.netsuite.com" diff --git a/tests/test_rest_api.py b/tests/test_rest_api.py index ec35043..bad3d98 100644 --- a/tests/test_rest_api.py +++ b/tests/test_rest_api.py @@ -1,9 +1,11 @@ import logging +from types import SimpleNamespace from unittest.mock import AsyncMock import pytest from netsuite import NetSuiteRestApi +from netsuite.exceptions import NetsuiteAPIRequestError def test_expected_hostname(dummy_config): @@ -129,3 +131,148 @@ async def test_suiteql_order_by_detection_ignores_substrings(dummy_config, caplo with caplog.at_level(logging.WARNING, logger="netsuite.rest_api"): await rest_api.suiteql(q="SELECT order_by_id FROM custom_table") assert not any("ORDER BY" in r.message for r in caplog.records) + + +# --------------------------------------------------------------------------- +# 2026.1 REST web services operations +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_attach_posts_to_attach_endpoint_with_empty_body(dummy_config): + rest_api = NetSuiteRestApi(dummy_config) + rest_api._request = AsyncMock(return_value=None) # type: ignore[method-assign] + result = await rest_api.attach("customer", "660", "contact", "106") + assert result is None + args, kwargs = rest_api._request.await_args + assert args == ( + "POST", + "/record/v1/customer/660/!attach/contact/106", + ) + # Body defaults to an empty object when no role is given. + assert kwargs["json"] == {} + + +@pytest.mark.asyncio +async def test_attach_includes_role_when_provided(dummy_config): + rest_api = NetSuiteRestApi(dummy_config) + rest_api._request = AsyncMock(return_value=None) # type: ignore[method-assign] + await rest_api.attach("customer", "660", "contact", "106", role={"id": "-10"}) + _, kwargs = rest_api._request.await_args + assert kwargs["json"] == {"role": {"id": "-10"}} + + +@pytest.mark.asyncio +async def test_attach_supports_external_ids(dummy_config): + rest_api = NetSuiteRestApi(dummy_config) + rest_api._request = AsyncMock(return_value=None) # type: ignore[method-assign] + await rest_api.attach("customer", "eid:JOHN_DOE42", "contact", "eid:user1") + args, _ = rest_api._request.await_args + assert args[1] == ("/record/v1/customer/eid:JOHN_DOE42/!attach/contact/eid:user1") + + +@pytest.mark.asyncio +async def test_detach_posts_to_detach_endpoint_without_body(dummy_config): + rest_api = NetSuiteRestApi(dummy_config) + rest_api._request = AsyncMock(return_value=None) # type: ignore[method-assign] + await rest_api.detach("opportunity", "379", "file", "398") + args, kwargs = rest_api._request.await_args + assert args == ( + "POST", + "/record/v1/opportunity/379/!detach/file/398", + ) + # Detach sends no body at all. + assert "json" not in kwargs + + +@pytest.mark.asyncio +async def test_create_form_posts_transform_with_accept_header(dummy_config): + rest_api = NetSuiteRestApi(dummy_config) + rest_api._request = AsyncMock(return_value={"id": "1"}) # type: ignore[method-assign] + await rest_api.create_form("salesOrder", "1", "itemFulfillment") + args, kwargs = rest_api._request.await_args + assert args == ( + "POST", + "/record/v1/salesOrder/1/!transform/itemFulfillment", + ) + assert ( + kwargs["headers"]["Accept"] + == "application/vnd.oracle.resource+json; type=create-form" + ) + assert kwargs["json"] == {} + + +@pytest.mark.asyncio +async def test_select_options_uses_post_for_new_instance(dummy_config): + rest_api = NetSuiteRestApi(dummy_config) + rest_api._request = AsyncMock(return_value={}) # type: ignore[method-assign] + await rest_api.select_options("customer", "entitystatus") + args, kwargs = rest_api._request.await_args + assert args == ("POST", "/record/v1/customer") + assert kwargs["params"]["fields"] == "entitystatus" + assert ( + kwargs["headers"]["Accept"] + == "application/vnd.oracle.resource+json; type=select-options" + ) + + +@pytest.mark.asyncio +async def test_select_options_uses_patch_for_existing_instance(dummy_config): + rest_api = NetSuiteRestApi(dummy_config) + rest_api._request = AsyncMock(return_value={}) # type: ignore[method-assign] + await rest_api.select_options( + "advInterCompanyJournalEntry", + ["line.dueToFromSubsidiary", "subsidiary"], + record_id="5", + body={"subsidiary": {"id": 1}}, + ) + args, kwargs = rest_api._request.await_args + assert args == ("PATCH", "/record/v1/advInterCompanyJournalEntry/5") + # Multiple fields are comma-joined; dependent values ride in the body. + assert kwargs["params"]["fields"] == "line.dueToFromSubsidiary,subsidiary" + assert kwargs["json"] == {"subsidiary": {"id": 1}} + + +@pytest.mark.asyncio +async def test_batch_sets_async_headers_and_returns_job_location(dummy_config): + rest_api = NetSuiteRestApi(dummy_config) + job_url = "https://x.suitetalk.api.netsuite.com/services/rest/async/v1/job/42" + fake_resp = SimpleNamespace( + status_code=202, + text="", + headers={"Location": job_url}, + ) + rest_api._request_impl = AsyncMock(return_value=fake_resp) # type: ignore[method-assign] + result = await rest_api.batch( + "salesOrder", + [{"name": "item 1"}, {"name": "item 2"}], + idempotency_key="abc-123", + ) + args, kwargs = rest_api._request_impl.await_args + assert args == ("POST", "/record/v1/salesOrder") + assert kwargs["headers"]["Prefer"] == "respond-async" + assert ( + kwargs["headers"]["Content-Type"] + == "application/vnd.oracle.resource+json; type=collection" + ) + assert kwargs["headers"]["X-NetSuite-idempotency-key"] == "abc-123" + assert kwargs["json"] == {"items": [{"name": "item 1"}, {"name": "item 2"}]} + assert result == {"status_code": 202, "location": job_url, "body": None} + + +@pytest.mark.asyncio +async def test_batch_rejects_non_write_methods(dummy_config): + rest_api = NetSuiteRestApi(dummy_config) + rest_api._request_impl = AsyncMock() # type: ignore[method-assign] + with pytest.raises(ValueError, match="batch\\(\\) method must be"): + await rest_api.batch("salesOrder", [], method="GET") + rest_api._request_impl.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_batch_raises_on_error_status(dummy_config): + rest_api = NetSuiteRestApi(dummy_config) + fake_resp = SimpleNamespace(status_code=400, text="bad request", headers={}) + rest_api._request_impl = AsyncMock(return_value=fake_resp) # type: ignore[method-assign] + with pytest.raises(NetsuiteAPIRequestError): + await rest_api.batch("salesOrder", [{"name": "x"}], method="PATCH") From fd289ca710508ac82ba3e5def627e4f70a439db2 Mon Sep 17 00:00:00 2001 From: Vicente Louvet III Date: Wed, 17 Jun 2026 15:17:41 -0600 Subject: [PATCH 26/29] fix(tests): make json Path encoding test OS-agnostic (#23) The parametrized encoder test hardcoded Path("/tmp/x") -> "/tmp/x". The encoder serializes Path via str(), which on Windows yields "\tmp\x", so the test failed on windows-latest CI. Compare against str(Path(...)) so the expected value matches per-OS (POSIX "/tmp/x" vs Windows "\\tmp\\x"). Co-authored-by: Claude Opus 4.8 (1M context) --- tests/test_json.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_json.py b/tests/test_json.py index 25edba5..9999573 100644 --- a/tests/test_json.py +++ b/tests/test_json.py @@ -53,7 +53,9 @@ class _Color(Enum): (_Color.RED, "red"), (frozenset([1, 2, 3]), [1, 2, 3]), (set([1, 2, 3]), [1, 2, 3]), - (Path("/tmp/x"), "/tmp/x"), + # Encoder serializes Path via str(); expected must match per-OS + # (POSIX "/tmp/x" vs Windows "\\tmp\\x") rather than hardcode POSIX. + (Path("/tmp/x"), str(Path("/tmp/x"))), ( UUID("12345678-1234-5678-1234-567812345678"), "12345678-1234-5678-1234-567812345678", From ff4d68aa7477b416a4983268c0027b17e3f15222 Mon Sep 17 00:00:00 2001 From: Vicente Louvet III Date: Wed, 17 Jun 2026 15:18:43 -0600 Subject: [PATCH 27/29] fix(oauth2): sign with the configured algorithm so PS256 (the default) works (#24) build_client_assertion called jwt.encode(header, claims, key) without an algorithms argument. joserfc's default registry only permits its "recommended" algorithms (RS256/ES256/...) and raises UnsupportedAlgorithmError for PS256 -- which is this module's DEFAULT_ALGORITHM and NetSuite's recommended assertion algorithm. So the default machine-to-machine flow failed at signing time; only callers explicitly passing algorithm="RS256" worked (ES384/ES512 were affected too). Whitelist the chosen algorithm in jwt.encode so every entry in SUPPORTED_ALGORITHMS actually signs. Confirmed against a live NetSuite sandbox: a PS256 assertion now signs and the M2M token endpoint accepts it. Every existing assertion test pinned algorithm="RS256", so the default PS256 path was never exercised. Add a regression test that builds an assertion with no algorithm= and asserts it signs as PS256. Co-authored-by: Claude Opus 4.8 (1M context) --- netsuite/oauth2.py | 6 +++++- tests/test_oauth2.py | 20 ++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/netsuite/oauth2.py b/netsuite/oauth2.py index cd22d9e..393478e 100644 --- a/netsuite/oauth2.py +++ b/netsuite/oauth2.py @@ -174,7 +174,11 @@ def build_client_assertion( "exp": expires_at, } key = _load_signing_key(private_key_pem, algorithm) - return jwt.encode(header, claims, key) + # joserfc's default registry only permits its "recommended" algorithms + # (RS256/ES256/...), and rejects PS256 — which is this module's default, + # and NetSuite's recommended assertion algorithm. Explicitly whitelist the + # chosen algorithm so every entry in SUPPORTED_ALGORITHMS actually signs. + return jwt.encode(header, claims, key, algorithms=[algorithm]) # --------------------------------------------------------------------------- diff --git a/tests/test_oauth2.py b/tests/test_oauth2.py index 2128adc..d410f8d 100644 --- a/tests/test_oauth2.py +++ b/tests/test_oauth2.py @@ -13,6 +13,7 @@ from joserfc.jwk import RSAKey from netsuite.oauth2 import ( + DEFAULT_ALGORITHM, DEFAULT_SCOPES, JWT_BEARER_ASSERTION_TYPE, OAuth2BearerAuth, @@ -126,6 +127,25 @@ def test_client_assertion_carries_required_claims(rsa_private_key_pem, rsa_publi assert claims["exp"] == 1_700_000_000 + 3600 +def test_client_assertion_signs_with_default_algorithm( + rsa_private_key_pem, rsa_public_jwk +): + """Regression: the module default is PS256, which joserfc's default + registry rejects unless the algorithm is explicitly whitelisted in + `jwt.encode`. Every other assertion test pins RS256, so the default + path went uncovered. Build with no `algorithm=` and confirm it signs.""" + assert DEFAULT_ALGORITHM == "PS256" + assertion = build_client_assertion( + "123456_SB1", + client_id="my-app", + certificate_id="cert-kid-42", + private_key_pem=rsa_private_key_pem, + ) + decoded = jose_jwt.decode(assertion, rsa_public_jwk, algorithms=[DEFAULT_ALGORITHM]) + assert decoded.header["alg"] == "PS256" + assert decoded.header["kid"] == "cert-kid-42" + + def test_client_assertion_clamps_ttl_to_one_hour(rsa_private_key_pem, rsa_public_jwk): assertion = build_client_assertion( "123456", From 87f8f05065cc285c38d764ef7d9a3173e5868970 Mon Sep 17 00:00:00 2001 From: Vicente Louvet III Date: Wed, 17 Jun 2026 16:27:04 -0600 Subject: [PATCH 28/29] feat(rest): add create_record() that returns the new record's ID (#25) NetSuite answers a REST create with 204 No Content and a Location header pointing at the new record, so the JSON-only _request helper (which returns None on 204) gives callers no way to learn the assigned ID. Add create_record(record_type, record_data): it POSTs to /record/v1/{record_type} via _request_impl directly (the same pattern batch() uses to reach the Location header), then returns the ID parsed from that header -- an int for numeric IDs, the raw string for external IDs (eid:...), or None when no Location is present. This is a scoped reimplementation of the idea in PR #121. Unlike that PR it does not mutate the generic _request to special-case every 204 POST (which would silently change the return type of all POSTs), and the ID parser ignores query strings/fragments and tolerates a trailing slash rather than using a bare /([^/]+)$ regex that would capture a querystring. Tested: collection-endpoint POST + body, numeric id -> int, external id -> str, query/trailing-slash handling, missing-header -> None, error status raises. Co-authored-by: Claude Opus 4.8 (1M context) --- netsuite/rest_api.py | 49 ++++++++++++++++++++++ tests/test_rest_api.py | 94 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 143 insertions(+) diff --git a/netsuite/rest_api.py b/netsuite/rest_api.py index 4742010..5ad20d4 100644 --- a/netsuite/rest_api.py +++ b/netsuite/rest_api.py @@ -449,6 +449,55 @@ async def batch( "body": body, } + async def create_record( + self, + record_type: str, + record_data: Dict[str, Any], + **request_kw, + ) -> Union[int, str, None]: + """ + Create a record and return the internal ID NetSuite assigns to it. + + POSTs `record_data` to `/record/v1/{record_type}`. NetSuite answers a + successful create with HTTP 204 No Content and a `Location` header + pointing at the new record; this parses the trailing ID segment and + returns it as an `int` when numeric, or the raw string for external + IDs (`eid:...`). Returns `None` if NetSuite omits the `Location` + header. + + Talks to the lower-level request layer directly (like `batch`) + because the new record's ID is only exposed in the `Location` + response header, which the JSON helper `_request` discards. + + https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/section_1545141395.html + """ + resp = await self._request_impl( + "POST", + f"/record/v1/{record_type}", + json=record_data, + **request_kw, + ) + if resp.status_code < 200 or resp.status_code > 299: + raise NetsuiteAPIRequestError(resp.status_code, resp.text) + return self._parse_id_from_location(resp.headers.get("Location")) + + @staticmethod + def _parse_id_from_location(location: Optional[str]) -> Union[int, str, None]: + """Extract the record ID (last path segment) from a `Location` URL, + ignoring any query string or fragment and tolerating a trailing + slash. Returns an `int` for numeric IDs, the raw string otherwise, + or `None` when no usable segment is present.""" + if not location: + return None + path = location.split("?", 1)[0].split("#", 1)[0].rstrip("/") + record_id = path.rsplit("/", 1)[-1] + if not record_id: + return None + try: + return int(record_id) + except ValueError: + return record_id + def _make_hostname(self): return f"{self._config.account_slugified}.suitetalk.api.netsuite.com" diff --git a/tests/test_rest_api.py b/tests/test_rest_api.py index bad3d98..9b62597 100644 --- a/tests/test_rest_api.py +++ b/tests/test_rest_api.py @@ -2,6 +2,7 @@ from types import SimpleNamespace from unittest.mock import AsyncMock +import httpx import pytest from netsuite import NetSuiteRestApi @@ -276,3 +277,96 @@ async def test_batch_raises_on_error_status(dummy_config): rest_api._request_impl = AsyncMock(return_value=fake_resp) # type: ignore[method-assign] with pytest.raises(NetsuiteAPIRequestError): await rest_api.batch("salesOrder", [{"name": "x"}], method="PATCH") + + +# --------------------------------------------------------------------------- +# create_record — POST a record and return the new ID from the Location header +# --------------------------------------------------------------------------- + + +def _created_response(location, *, status_code=204): + # NetSuite answers a create with 204 No Content and a Location header + # pointing at the new record. httpx.Headers is case-insensitive, which + # is what the real response uses. + return SimpleNamespace( + status_code=status_code, + text="", + headers=httpx.Headers({"Location": location} if location else {}), + ) + + +@pytest.mark.asyncio +async def test_create_record_posts_data_to_collection_endpoint(dummy_config): + rest_api = NetSuiteRestApi(dummy_config) + loc = "https://x.suitetalk.api.netsuite.com/services/rest/record/v1/customer/647" + rest_api._request_impl = AsyncMock( # type: ignore[method-assign] + return_value=_created_response(loc) + ) + data = {"companyName": "Acme", "subsidiary": {"id": "1"}} + await rest_api.create_record("customer", data) + args, kwargs = rest_api._request_impl.await_args + assert args == ("POST", "/record/v1/customer") + assert kwargs["json"] == data + + +@pytest.mark.asyncio +async def test_create_record_returns_int_for_numeric_id(dummy_config): + rest_api = NetSuiteRestApi(dummy_config) + loc = "https://x.suitetalk.api.netsuite.com/services/rest/record/v1/customer/647" + rest_api._request_impl = AsyncMock( # type: ignore[method-assign] + return_value=_created_response(loc) + ) + result = await rest_api.create_record("customer", {}) + assert result == 647 + assert isinstance(result, int) + + +@pytest.mark.asyncio +async def test_create_record_returns_str_for_external_id(dummy_config): + rest_api = NetSuiteRestApi(dummy_config) + loc = ( + "https://x.suitetalk.api.netsuite.com/services/rest/record/v1/" + "customer/eid:ACME_42" + ) + rest_api._request_impl = AsyncMock( # type: ignore[method-assign] + return_value=_created_response(loc) + ) + result = await rest_api.create_record("customer", {}) + assert result == "eid:ACME_42" + + +@pytest.mark.asyncio +async def test_create_record_ignores_query_and_trailing_slash(dummy_config): + """Regression vs the original `/([^/]+)$` regex, which would return the + querystring or an empty segment. The ID is the last real path segment.""" + rest_api = NetSuiteRestApi(dummy_config) + loc = ( + "https://x.suitetalk.api.netsuite.com/services/rest/record/v1/" + "salesOrder/980/?expandSubResources=true" + ) + rest_api._request_impl = AsyncMock( # type: ignore[method-assign] + return_value=_created_response(loc) + ) + result = await rest_api.create_record("salesOrder", {}) + assert result == 980 + + +@pytest.mark.asyncio +async def test_create_record_returns_none_without_location_header(dummy_config): + rest_api = NetSuiteRestApi(dummy_config) + rest_api._request_impl = AsyncMock( # type: ignore[method-assign] + return_value=_created_response(None) + ) + result = await rest_api.create_record("customer", {}) + assert result is None + + +@pytest.mark.asyncio +async def test_create_record_raises_on_error_status(dummy_config): + rest_api = NetSuiteRestApi(dummy_config) + fake_resp = SimpleNamespace(status_code=400, text="bad", headers=httpx.Headers()) + rest_api._request_impl = AsyncMock( # type: ignore[method-assign] + return_value=fake_resp + ) + with pytest.raises(NetsuiteAPIRequestError): + await rest_api.create_record("customer", {"bad": "data"}) From 691231cea9fff0e04d64bdbf31220588d73f616c Mon Sep 17 00:00:00 2001 From: Vicente Louvet III Date: Wed, 17 Jun 2026 16:51:09 -0600 Subject: [PATCH 29/29] =?UTF-8?q?test:=20raise=20coverage=20to=2091%=20(CL?= =?UTF-8?q?I=20handlers,=20client=20facade,=20rest=5Fapi=20me=E2=80=A6=20(?= =?UTF-8?q?#26)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test: raise coverage to 91% (CLI handlers, client facade, rest_api methods) The suite sat at 78%, with the largest gaps in code that had no direct tests: the CLI command handlers (cli/rest_api 51%, cli/restlet 63%, cli/main 55%), the NetSuite facade (client.py 68%), and several plain NetSuiteRestApi methods (the HTTP verbs, jsonschema, token_info, openapi). Add focused unit tests that drive each surface without the network: - test_cli_rest_api.py: builds the real argparse parser and invokes each subcommand's func(config, args) with a mocked NetSuite -- covers param assembly, payload/query-file reading, header parsing (incl. repeated and invalid headers), JSON encoding, the openapi-serve path (http.server.test mocked), and the RuntimeError->parser.error path. cli/rest_api 51%->99%. - test_cli_restlet.py: same approach for the restlet handlers. 63%->100%. - test_cli_main.py: drives main() for the per-section help shortcuts and a full async-subcommand run via both --config-environment and an ini file. 55%->95%. - test_client.py: the NetSuite cached_property accessors and option passing. 68%->100%. - test_rest_api.py: direct tests for request/get/post/put/patch/delete, jsonschema, token_info, openapi (with/without record types), batch body parsing, and the _make_url/_make_default_headers helpers. 81%->100%. Net: 78% -> 91% (above the 90% target), 194 -> 235 tests, all green. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(cli): drop pkg_resources so the CLI imports on modern setuptools netsuite/cli/misc.py did `import pkg_resources` at module top, only to read the package version. setuptools 81+ removed pkg_resources, so on any modern environment `import netsuite.cli.` raised ModuleNotFoundError (cli/__init__ -> main -> misc) -- breaking the whole CLI, not just `version`. Switch to the stdlib `importlib.metadata.version`, which has been the recommended replacement since Python 3.8. This also unblocks CI coverage: the CLI tests were guarded with `pytest.importorskip("pkg_resources")` and silently skipped wherever pkg_resources was absent (i.e. CI), and the new test_cli_rest_api / test_cli_restlet modules errored at collection. With misc importable, drop those guards so every CLI test actually runs. Verified with pkg_resources uninstalled: 241 passed, 0 skipped, 0 collection errors, coverage 91%. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- netsuite/cli/misc.py | 4 +- tests/test_cli.py | 10 +- tests/test_cli_main.py | 78 ++++++++++ tests/test_cli_rest_api.py | 283 +++++++++++++++++++++++++++++++++++++ tests/test_cli_restlet.py | 83 +++++++++++ tests/test_client.py | 44 ++++++ tests/test_rest_api.py | 110 ++++++++++++++ 7 files changed, 603 insertions(+), 9 deletions(-) create mode 100644 tests/test_cli_main.py create mode 100644 tests/test_cli_rest_api.py create mode 100644 tests/test_cli_restlet.py create mode 100644 tests/test_client.py diff --git a/netsuite/cli/misc.py b/netsuite/cli/misc.py index 41cda27..08df477 100644 --- a/netsuite/cli/misc.py +++ b/netsuite/cli/misc.py @@ -1,4 +1,4 @@ -import pkg_resources +from importlib.metadata import version as _package_version __all__ = () @@ -10,4 +10,4 @@ def add_parser(parser, subparser): def version() -> str: - return pkg_resources.get_distribution("netsuite").version + return _package_version("netsuite") diff --git a/tests/test_cli.py b/tests/test_cli.py index 214947d..4494feb 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2,17 +2,13 @@ config loading helpers. Does not invoke any real NetSuite calls.""" import argparse +import importlib from unittest.mock import patch import pytest -# `netsuite.cli.misc` imports the deprecated `pkg_resources`, which was -# removed from setuptools 81+. Skip the whole CLI test module if it isn't -# importable so the rest of the suite still runs on newer Pythons. -pytest.importorskip("pkg_resources") - -import importlib # noqa: E402 - +# `netsuite.cli` re-exports the `main` function, shadowing the submodule, so +# import the module object explicitly. cli_main_module = importlib.import_module("netsuite.cli.main") from netsuite.cli import helpers, misc # noqa: E402 from netsuite.cli import rest_api as cli_rest_api # noqa: E402 diff --git a/tests/test_cli_main.py b/tests/test_cli_main.py new file mode 100644 index 0000000..d4ecec3 --- /dev/null +++ b/tests/test_cli_main.py @@ -0,0 +1,78 @@ +"""Tests for `netsuite.cli.main.main()` — the argv dispatch: per-section help +shortcuts, config loading (env vs ini), and running an async subcommand. +""" + +import importlib +import sys +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +# `netsuite.cli` re-exports the `main` function, shadowing the submodule, so +# import the module object explicitly. +cli_main = importlib.import_module("netsuite.cli.main") +from netsuite.cli import rest_api as cli_rest_api # noqa: E402 + + +@pytest.mark.parametrize("section", ["rest-api", "soap-api", "restlet"]) +def test_section_without_subcommand_prints_help(monkeypatch, section): + monkeypatch.setattr(sys, "argv", ["netsuite", section]) + # Each branch just prints a help text and returns None. + assert cli_main.main() is None + + +def test_run_async_subcommand_via_config_environment(monkeypatch, capsys): + monkeypatch.setattr( + sys, + "argv", + ["netsuite", "--config-environment", "rest-api", "get", "/record/v1/x"], + ) + api = MagicMock() + api.get = AsyncMock(return_value={"ok": True}) + ns_cls = MagicMock() + ns_cls.return_value.rest_api = api + # from_env supplies the config; log_level drives logging.basicConfig. + with patch.object( + cli_main.Config, "from_env", return_value=SimpleNamespace(log_level="INFO") + ), patch.object(cli_rest_api, "NetSuite", ns_cls): + cli_main.main() + api.get.assert_awaited_once() + out = capsys.readouterr().out + assert "ok" in out # the json-encoded response was printed + + +def test_run_async_subcommand_via_ini_file(monkeypatch, tmp_path, capsys): + ini = tmp_path / "ns.ini" + ini.write_text( + "[netsuite]\n" + "account = 999\n" + "consumer_key = ck\n" + "consumer_secret = cs\n" + "token_id = ti\n" + "token_secret = ts\n" + ) + monkeypatch.setattr( + sys, + "argv", + [ + "netsuite", + "-p", + str(ini), + "-c", + "netsuite", + "-l", + "DEBUG", + "rest-api", + "get", + "/record/v1/x", + ], + ) + api = MagicMock() + api.get = AsyncMock(return_value={"items": []}) + ns_cls = MagicMock() + ns_cls.return_value.rest_api = api + with patch.object(cli_rest_api, "NetSuite", ns_cls): + cli_main.main() + api.get.assert_awaited_once() + assert "items" in capsys.readouterr().out diff --git a/tests/test_cli_rest_api.py b/tests/test_cli_rest_api.py new file mode 100644 index 0000000..7999ec2 --- /dev/null +++ b/tests/test_cli_rest_api.py @@ -0,0 +1,283 @@ +"""Tests for the `rest-api` CLI command handlers. + +Each subcommand registers an async `func(config, args)` closure via argparse +defaults. We build the real parser, parse argv, and invoke `args.func` with a +mocked `NetSuite` so the handlers run end-to-end (param assembly, payload-file +reading, header parsing, JSON encoding) without touching the network. +""" + +import argparse +from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch + +import pytest + +from netsuite.cli import rest_api as cli_rest_api + + +def _parser(): + parser = argparse.ArgumentParser() + sub = parser.add_subparsers() + cli_rest_api.add_parser(parser, sub) + return parser + + +def _mock_netsuite(api): + """Patch the NetSuite class used by the handlers so `.rest_api` is `api`.""" + patcher = patch.object(cli_rest_api, "NetSuite") + ns_cls = patcher.start() + ns_cls.return_value.rest_api = api + return patcher + + +# --------------------------------------------------------------------------- +# GET +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_get_assembles_all_params_and_headers(dummy_config): + args = _parser().parse_args( + [ + "rest-api", + "get", + "/record/v1/customer", + "-l", + "5", + "-o", + "2", + "-e", + "-f", + "id", + "name", + "-E", + "addressBook", + "-q", + "lastName START Doe", + "-H", + "X-Foo: 1", + ] + ) + api = MagicMock() + api.get = AsyncMock(return_value={"ok": True}) + patcher = _mock_netsuite(api) + try: + out = await args.func(dummy_config, args) + finally: + patcher.stop() + assert out == cli_rest_api.json.dumps({"ok": True}) + _, kwargs = api.get.await_args + assert kwargs["params"] == { + "expandSubResources": "true", + "limit": 5, + "offset": 2, + "fields": "id,name", + "expand": "addressBook", + "q": "lastName START Doe", + } + assert kwargs["headers"] == {"X-Foo": "1"} + + +@pytest.mark.asyncio +async def test_get_with_no_optional_params(dummy_config): + args = _parser().parse_args(["rest-api", "get", "/record/v1/customer/1"]) + api = MagicMock() + api.get = AsyncMock(return_value={}) + patcher = _mock_netsuite(api) + try: + await args.func(dummy_config, args) + finally: + patcher.stop() + _, kwargs = api.get.await_args + assert kwargs["params"] == {} + assert kwargs["headers"] == {} + + +# --------------------------------------------------------------------------- +# POST / PUT / PATCH (payload-file bodies) +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("verb", ["post", "put", "patch"]) +@pytest.mark.asyncio +async def test_body_verbs_read_payload_file(dummy_config, tmp_path, verb): + payload = tmp_path / "body.json" + payload.write_text('{"companyName": "Acme"}') + args = _parser().parse_args( + ["rest-api", verb, "/record/v1/customer", str(payload), "-H", "A: b"] + ) + api = MagicMock() + setattr(api, verb, AsyncMock(return_value={"id": "1"})) + patcher = _mock_netsuite(api) + try: + out = await args.func(dummy_config, args) + finally: + patcher.stop() + method = getattr(api, verb) + _, kwargs = method.await_args + assert kwargs["json"] == {"companyName": "Acme"} + assert kwargs["headers"] == {"A": "b"} + assert out == cli_rest_api.json.dumps({"id": "1"}) + + +# --------------------------------------------------------------------------- +# DELETE +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_delete_calls_delete(dummy_config): + args = _parser().parse_args(["rest-api", "delete", "/record/v1/customer/eid:abc"]) + api = MagicMock() + api.delete = AsyncMock(return_value=None) + patcher = _mock_netsuite(api) + try: + out = await args.func(dummy_config, args) + finally: + patcher.stop() + args_, _ = api.delete.await_args + assert args_[0] == "/record/v1/customer/eid:abc" + assert out == cli_rest_api.json.dumps(None) + + +# --------------------------------------------------------------------------- +# SuiteQL +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_suiteql_reads_query_file(dummy_config, tmp_path): + q = tmp_path / "q.sql" + q.write_text("SELECT id FROM customer") + args = _parser().parse_args(["rest-api", "suiteql", str(q), "-l", "50", "-o", "5"]) + api = MagicMock() + api.suiteql = AsyncMock(return_value={"items": []}) + patcher = _mock_netsuite(api) + try: + out = await args.func(dummy_config, args) + finally: + patcher.stop() + _, kwargs = api.suiteql.await_args + assert kwargs["q"] == "SELECT id FROM customer" + assert kwargs["limit"] == 50 + assert kwargs["offset"] == 5 + assert out == cli_rest_api.json.dumps({"items": []}) + + +# --------------------------------------------------------------------------- +# jsonschema / openapi +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_jsonschema_passes_record_type(dummy_config): + args = _parser().parse_args(["rest-api", "jsonschema", "salesOrder"]) + api = MagicMock() + api.jsonschema = AsyncMock(return_value={"type": "object"}) + patcher = _mock_netsuite(api) + try: + out = await args.func(dummy_config, args) + finally: + patcher.stop() + api.jsonschema.assert_awaited_once_with("salesOrder") + assert out == cli_rest_api.json.dumps({"type": "object"}) + + +@pytest.mark.asyncio +async def test_openapi_passes_record_types(dummy_config): + args = _parser().parse_args(["rest-api", "openapi", "customer", "invoice"]) + api = MagicMock() + api.openapi = AsyncMock(return_value={"openapi": "3.0"}) + patcher = _mock_netsuite(api) + try: + out = await args.func(dummy_config, args) + finally: + patcher.stop() + api.openapi.assert_awaited_once_with(["customer", "invoice"]) + assert out == cli_rest_api.json.dumps({"openapi": "3.0"}) + + +# --------------------------------------------------------------------------- +# openapi-serve (HTTP server mocked out) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_openapi_serve_with_record_types(dummy_config): + args = _parser().parse_args( + ["rest-api", "openapi-serve", "customer", "-p", "9001", "-b", "0.0.0.0"] + ) + api = MagicMock() + api.openapi = AsyncMock(return_value={"openapi": "3.0"}) + patcher = _mock_netsuite(api) + with patch.object(cli_rest_api.http.server, "test") as serve: + try: + await args.func(dummy_config, args) + finally: + patcher.stop() + serve.assert_called_once() + assert serve.call_args.kwargs["port"] == 9001 + assert serve.call_args.kwargs["bind"] == "0.0.0.0" + + +@pytest.mark.asyncio +async def test_openapi_serve_without_record_types_warns(dummy_config, caplog): + args = _parser().parse_args(["rest-api", "openapi-serve"]) + api = MagicMock() + api.openapi = AsyncMock(return_value={"openapi": "3.0"}) + patcher = _mock_netsuite(api) + import logging + + with patch.object(cli_rest_api.http.server, "test"): + with caplog.at_level(logging.WARNING, logger="netsuite"): + try: + await args.func(dummy_config, args) + finally: + patcher.stop() + api.openapi.assert_awaited_once_with([]) + assert any("ALL known record types" in r.message for r in caplog.records) + + +# --------------------------------------------------------------------------- +# Header parsing + error paths +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_repeated_header_becomes_list(dummy_config): + args = _parser().parse_args( + ["rest-api", "get", "/x", "-H", "X-Multi: 1", "-H", "X-Multi: 2"] + ) + api = MagicMock() + api.get = AsyncMock(return_value={}) + patcher = _mock_netsuite(api) + try: + await args.func(dummy_config, args) + finally: + patcher.stop() + _, kwargs = api.get.await_args + assert kwargs["headers"] == {"X-Multi": ["1", "2"]} + + +@pytest.mark.asyncio +async def test_invalid_header_calls_parser_error(dummy_config): + args = _parser().parse_args(["rest-api", "get", "/x", "-H", "no-colon-here"]) + api = MagicMock() + api.get = AsyncMock(return_value={}) + patcher = _mock_netsuite(api) + try: + # parser.error raises SystemExit + with pytest.raises(SystemExit): + await args.func(dummy_config, args) + finally: + patcher.stop() + + +@pytest.mark.asyncio +async def test_rest_api_init_runtime_error_calls_parser_error(dummy_config): + args = _parser().parse_args(["rest-api", "get", "/x"]) + with patch.object(cli_rest_api, "NetSuite") as ns_cls: + type(ns_cls.return_value).rest_api = PropertyMock( + side_effect=RuntimeError("missing creds") + ) + with pytest.raises(SystemExit): + await args.func(dummy_config, args) diff --git a/tests/test_cli_restlet.py b/tests/test_cli_restlet.py new file mode 100644 index 0000000..38d72e4 --- /dev/null +++ b/tests/test_cli_restlet.py @@ -0,0 +1,83 @@ +"""Tests for the `restlet` CLI command handlers — same approach as the +rest-api handler tests: build the parser, parse argv, invoke `args.func` +with a mocked NetSuite restlet client. +""" + +import argparse +from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch + +import pytest + +from netsuite.cli import restlet as cli_restlet + + +def _parser(): + parser = argparse.ArgumentParser() + sub = parser.add_subparsers() + cli_restlet.add_parser(parser, sub) + return parser + + +def _mock_netsuite(restlet): + patcher = patch.object(cli_restlet, "NetSuite") + ns_cls = patcher.start() + ns_cls.return_value.restlet = restlet + return patcher + + +@pytest.mark.asyncio +async def test_restlet_get(dummy_config): + args = _parser().parse_args(["restlet", "get", "42", "-d", "3"]) + restlet = MagicMock() + restlet.get = AsyncMock(return_value={"ok": True}) + patcher = _mock_netsuite(restlet) + try: + out = await args.func(dummy_config, args) + finally: + patcher.stop() + restlet.get.assert_awaited_once_with(script_id=42, deploy=3) + assert out == cli_restlet.json.dumps({"ok": True}) + + +@pytest.mark.parametrize("verb", ["post", "put"]) +@pytest.mark.asyncio +async def test_restlet_body_verbs_read_payload(dummy_config, tmp_path, verb): + payload = tmp_path / "body.json" + payload.write_text('{"x": 1}') + args = _parser().parse_args(["restlet", verb, "7", str(payload)]) + restlet = MagicMock() + setattr(restlet, verb, AsyncMock(return_value={"done": True})) + patcher = _mock_netsuite(restlet) + try: + out = await args.func(dummy_config, args) + finally: + patcher.stop() + method = getattr(restlet, verb) + method.assert_awaited_once_with(script_id=7, deploy=1, json={"x": 1}) + assert out == cli_restlet.json.dumps({"done": True}) + + +@pytest.mark.asyncio +async def test_restlet_delete_uses_default_deploy(dummy_config): + # The delete handler currently calls restlet.put (no body) under the hood. + args = _parser().parse_args(["restlet", "delete", "9"]) + restlet = MagicMock() + restlet.put = AsyncMock(return_value=None) + patcher = _mock_netsuite(restlet) + try: + out = await args.func(dummy_config, args) + finally: + patcher.stop() + restlet.put.assert_awaited_once_with(script_id=9, deploy=1) + assert out == cli_restlet.json.dumps(None) + + +@pytest.mark.asyncio +async def test_restlet_init_runtime_error_calls_parser_error(dummy_config): + args = _parser().parse_args(["restlet", "get", "1"]) + with patch.object(cli_restlet, "NetSuite") as ns_cls: + type(ns_cls.return_value).restlet = PropertyMock( + side_effect=RuntimeError("no restlet config") + ) + with pytest.raises(SystemExit): + await args.func(dummy_config, args) diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 0000000..c3a5cbc --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,44 @@ +"""Tests for the NetSuite facade — the cached_property accessors that lazily +construct the REST, Restlet, and SOAP sub-clients with their options. +""" + +import warnings + +import pytest + +from netsuite import NetSuite +from netsuite.rest_api import NetSuiteRestApi +from netsuite.restlet import NetSuiteRestlet +from netsuite.soap_api.zeep import ZEEP_INSTALLED + + +def test_init_defaults_empty_option_dicts(dummy_config): + ns = NetSuite(dummy_config) + assert ns._rest_api_options == {} + assert ns._soap_api_options == {} + assert ns._restlet_options == {} + + +def test_rest_api_is_cached_and_receives_options(dummy_config): + ns = NetSuite(dummy_config, rest_api_options={"default_timeout": 5}) + api = ns.rest_api + assert isinstance(api, NetSuiteRestApi) + assert api._default_timeout == 5 + # cached_property: same instance on second access + assert ns.rest_api is api + + +def test_restlet_is_cached(dummy_config): + ns = NetSuite(dummy_config) + restlet = ns.restlet + assert isinstance(restlet, NetSuiteRestlet) + assert ns.restlet is restlet + + +@pytest.mark.skipif(not ZEEP_INSTALLED, reason="Requires zeep") +def test_soap_api_is_constructed(dummy_config): + ns = NetSuite(dummy_config) + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + soap = ns.soap_api + assert ns.soap_api is soap diff --git a/tests/test_rest_api.py b/tests/test_rest_api.py index 9b62597..7fd7b6c 100644 --- a/tests/test_rest_api.py +++ b/tests/test_rest_api.py @@ -370,3 +370,113 @@ async def test_create_record_raises_on_error_status(dummy_config): ) with pytest.raises(NetsuiteAPIRequestError): await rest_api.create_record("customer", {"bad": "data"}) + + +@pytest.mark.asyncio +async def test_batch_parses_json_body_when_present(dummy_config): + rest_api = NetSuiteRestApi(dummy_config) + fake_resp = SimpleNamespace( + status_code=200, + text='{"results": [1, 2]}', + headers={"Location": "https://x/job/1"}, + ) + rest_api._request_impl = AsyncMock(return_value=fake_resp) # type: ignore[method-assign] + result = await rest_api.batch("salesOrder", [{"name": "x"}]) + assert result["body"] == {"results": [1, 2]} + + +@pytest.mark.asyncio +async def test_batch_tolerates_non_json_body(dummy_config): + rest_api = NetSuiteRestApi(dummy_config) + fake_resp = SimpleNamespace(status_code=200, text="not json", headers={}) + rest_api._request_impl = AsyncMock(return_value=fake_resp) # type: ignore[method-assign] + result = await rest_api.batch("salesOrder", [{"name": "x"}]) + assert result["body"] is None + + +# --------------------------------------------------------------------------- +# Plain HTTP verbs + metadata helpers +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_request_delegates_to_request_impl(dummy_config): + rest_api = NetSuiteRestApi(dummy_config) + rest_api._request_impl = AsyncMock(return_value="resp") # type: ignore[method-assign] + out = await rest_api.request("GET", "/x", params={"a": 1}) + assert out == "resp" + args, _ = rest_api._request_impl.await_args + assert args == ("GET", "/x") + + +@pytest.mark.parametrize( + "verb,method", + [ + ("get", "GET"), + ("post", "POST"), + ("put", "PUT"), + ("patch", "PATCH"), + ("delete", "DELETE"), + ], +) +@pytest.mark.asyncio +async def test_http_verbs_delegate_to_request(dummy_config, verb, method): + rest_api = NetSuiteRestApi(dummy_config) + rest_api._request = AsyncMock(return_value={"ok": 1}) # type: ignore[method-assign] + out = await getattr(rest_api, verb)("/sub") + assert out == {"ok": 1} + args, _ = rest_api._request.await_args + assert args[0] == method + assert args[1] == "/sub" + + +@pytest.mark.asyncio +async def test_jsonschema_sets_accept_header(dummy_config): + rest_api = NetSuiteRestApi(dummy_config) + rest_api._request = AsyncMock(return_value={}) # type: ignore[method-assign] + await rest_api.jsonschema("salesOrder") + args, kwargs = rest_api._request.await_args + assert args == ("GET", "/record/v1/metadata-catalog/salesOrder") + assert kwargs["headers"]["Accept"] == "application/schema+json" + + +@pytest.mark.asyncio +async def test_token_info_overrides_url_to_restlets_host(dummy_config): + rest_api = NetSuiteRestApi(dummy_config) + rest_api._request = AsyncMock(return_value={}) # type: ignore[method-assign] + await rest_api.token_info() + _, kwargs = rest_api._request.await_args + assert kwargs["url"].endswith("/rest/tokeninfo") + assert "restlets.api.netsuite.com" in kwargs["url"] + + +@pytest.mark.asyncio +async def test_openapi_sets_accept_and_select_param(dummy_config): + rest_api = NetSuiteRestApi(dummy_config) + rest_api._request = AsyncMock(return_value={}) # type: ignore[method-assign] + await rest_api.openapi(["customer", "invoice"]) + args, kwargs = rest_api._request.await_args + assert args == ("GET", "/record/v1/metadata-catalog") + assert kwargs["headers"]["Accept"] == "application/swagger+json" + assert kwargs["params"]["select"] == "customer,invoice" + + +@pytest.mark.asyncio +async def test_openapi_without_record_types_omits_select(dummy_config): + rest_api = NetSuiteRestApi(dummy_config) + rest_api._request = AsyncMock(return_value={}) # type: ignore[method-assign] + await rest_api.openapi() + _, kwargs = rest_api._request.await_args + assert "select" not in kwargs["params"] + + +def test_make_url_and_default_headers(dummy_config): + rest_api = NetSuiteRestApi(dummy_config) + url = rest_api._make_url("/record/v1/customer") + assert url == ( + "https://123456-sb1.suitetalk.api.netsuite.com" + "/services/rest/record/v1/customer" + ) + headers = rest_api._make_default_headers() + assert headers["Content-Type"] == "application/json" + assert headers["X-NetSuite-PropertyNameValidation"] == "error"