diff --git a/meltano.yml b/meltano.yml index d76de08..18aecae 100644 --- a/meltano.yml +++ b/meltano.yml @@ -16,30 +16,61 @@ plugins: - about - stream-maps - # TODO: Declare settings and their types here: settings: - - name: username - label: Username - description: The username to use for authentication - - - name: password - kind: password - label: Password - description: The password to use for authentication + - name: url + label: URL + description: URL of the data source + + - name: authType + label: Authentication Type + description: "Supported authentication types: API_KEY | JWT" + + - name: apiKey + label: API_KEY Value + description: "Utilized when authType is set to API_KEY" + sensitive: true + + - name: apiKeyHeaderName + label: API_KEY Header Name + description: "Utilized when authType is set to API_KEY" + + - name: clientId + label: Client Id + description: "Utilized when authType is set to JWT" + + - name: secret + label: Secret + description: "Utilized when authType is set to JWT" sensitive: true - - name: start_date - kind: date_iso8601 - label: Start Date - description: Initial date to start extracting data from + - name: audience + label: Audience + description: "Utilized when authType is set to JWT" + + - name: tokenEndpointUrl + label: Token Endpoint URL + description: "Utilized when authType is set to JWT" + + - name: merchantId + label: Merchant Id + description: "Specify a customer when querying the data source" + + - name: tenantId + label: Tenant Id + description: "[Optional] Specify a customer’s operation when querying the data source" - # TODO: Declare required settings here: settings_group_validation: - - [username, password] + - [url, authType] + - [apiKey, apiKeyHeaderName] # When authType is API_KEY + - [clientId, secret, audience, tokenEndpointUrl] # When authType is JWT - # TODO: Declare default configuration values here: config: - start_date: '2010-01-01T00:00:00Z' + url: 'https://api.flowpay-universal.com' + authType: "API_KEY" + apiKey: "token" + apiKeyHeaderName: "Token" + merchantId: "test_merchant" + loaders: - name: target-jsonl diff --git a/poetry.lock b/poetry.lock index 7c20259..3d95f10 100644 --- a/poetry.lock +++ b/poetry.lock @@ -122,6 +122,17 @@ urllib3 = [ [package.extras] crt = ["awscrt (==0.21.5)"] +[[package]] +name = "cachetools" +version = "5.5.0" +description = "Extensible memoizing collections and decorators" +optional = false +python-versions = ">=3.7" +files = [ + {file = "cachetools-5.5.0-py3-none-any.whl", hash = "sha256:02134e8439cdc2ffb62023ce1debca2944c3f289d66bb17ead3ab3dede74b292"}, + {file = "cachetools-5.5.0.tar.gz", hash = "sha256:2cc24fb4cbe39633fb7badd9db9ca6295d766d9c2995f245725a46715d050f2a"}, +] + [[package]] name = "certifi" version = "2024.8.30" @@ -133,6 +144,17 @@ files = [ {file = "certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9"}, ] +[[package]] +name = "chardet" +version = "5.2.0" +description = "Universal encoding detector for Python 3" +optional = false +python-versions = ">=3.7" +files = [ + {file = "chardet-5.2.0-py3-none-any.whl", hash = "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970"}, + {file = "chardet-5.2.0.tar.gz", hash = "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7"}, +] + [[package]] name = "charset-normalizer" version = "3.3.2" @@ -257,6 +279,17 @@ files = [ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +[[package]] +name = "distlib" +version = "0.3.8" +description = "Distribution utilities" +optional = false +python-versions = "*" +files = [ + {file = "distlib-0.3.8-py2.py3-none-any.whl", hash = "sha256:034db59a0b96f8ca18035f36290806a9a6e6bd9d1ff91e45a7f172eb17e51784"}, + {file = "distlib-0.3.8.tar.gz", hash = "sha256:1530ea13e350031b6312d8580ddb6b27a104275a31106523b8f123787f494f64"}, +] + [[package]] name = "exceptiongroup" version = "1.2.2" @@ -285,6 +318,22 @@ files = [ [package.dependencies] python-dateutil = ">=2.4" +[[package]] +name = "filelock" +version = "3.16.1" +description = "A platform independent file lock." +optional = false +python-versions = ">=3.8" +files = [ + {file = "filelock-3.16.1-py3-none-any.whl", hash = "sha256:2082e5703d51fbf98ea75855d9d5527e33d8ff23099bec374a134febee6946b0"}, + {file = "filelock-3.16.1.tar.gz", hash = "sha256:c249fbfcd5db47e5e2d6d62198e565475ee65e4831e2561c8e313fa7eb961435"}, +] + +[package.extras] +docs = ["furo (>=2024.8.6)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4.1)"] +testing = ["covdefaults (>=2.3)", "coverage (>=7.6.1)", "diff-cover (>=9.2)", "pytest (>=8.3.3)", "pytest-asyncio (>=0.24)", "pytest-cov (>=5)", "pytest-mock (>=3.14)", "pytest-timeout (>=2.3.1)", "virtualenv (>=20.26.4)"] +typing = ["typing-extensions (>=4.12.2)"] + [[package]] name = "fs" version = "2.4.16" @@ -569,6 +618,22 @@ files = [ {file = "pkgutil_resolve_name-1.3.10.tar.gz", hash = "sha256:357d6c9e6a755653cfd78893817c0853af365dd51ec97f3d358a819373bbd174"}, ] +[[package]] +name = "platformdirs" +version = "4.3.6" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." +optional = false +python-versions = ">=3.8" +files = [ + {file = "platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb"}, + {file = "platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907"}, +] + +[package.extras] +docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.2)", "pytest-cov (>=5)", "pytest-mock (>=3.14)"] +type = ["mypy (>=1.11.2)"] + [[package]] name = "pluggy" version = "1.5.0" @@ -595,6 +660,25 @@ files = [ {file = "ply-3.11.tar.gz", hash = "sha256:00c7c1aaa88358b9c765b6d3000c6eec0ba42abca5351b095321aef446081da3"}, ] +[[package]] +name = "pyproject-api" +version = "1.8.0" +description = "API to interact with the python pyproject.toml based projects" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pyproject_api-1.8.0-py3-none-any.whl", hash = "sha256:3d7d347a047afe796fd5d1885b1e391ba29be7169bd2f102fcd378f04273d228"}, + {file = "pyproject_api-1.8.0.tar.gz", hash = "sha256:77b8049f2feb5d33eefcc21b57f1e279636277a8ac8ad6b5871037b243778496"}, +] + +[package.dependencies] +packaging = ">=24.1" +tomli = {version = ">=2.0.1", markers = "python_version < \"3.11\""} + +[package.extras] +docs = ["furo (>=2024.8.6)", "sphinx-autodoc-typehints (>=2.4.1)"] +testing = ["covdefaults (>=2.3)", "pytest (>=8.3.3)", "pytest-cov (>=5)", "pytest-mock (>=3.14)", "setuptools (>=75.1)"] + [[package]] name = "pytest" version = "8.3.3" @@ -1174,6 +1258,33 @@ files = [ {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, ] +[[package]] +name = "tox" +version = "4.20.0" +description = "tox is a generic virtualenv management and test command line tool" +optional = false +python-versions = ">=3.8" +files = [ + {file = "tox-4.20.0-py3-none-any.whl", hash = "sha256:21a8005e3d3fe5658a8e36b8ca3ed13a4230429063c5cc2a2fdac6ee5aa0de34"}, + {file = "tox-4.20.0.tar.gz", hash = "sha256:5b78a49b6eaaeab3ae4186415e7c97d524f762ae967c63562687c3e5f0ec23d5"}, +] + +[package.dependencies] +cachetools = ">=5.5" +chardet = ">=5.2" +colorama = ">=0.4.6" +filelock = ">=3.15.4" +packaging = ">=24.1" +platformdirs = ">=4.2.2" +pluggy = ">=1.5" +pyproject-api = ">=1.7.1" +tomli = {version = ">=2.0.1", markers = "python_version < \"3.11\""} +virtualenv = ">=20.26.3" + +[package.extras] +docs = ["furo (>=2024.8.6)", "sphinx (>=8.0.2)", "sphinx-argparse-cli (>=1.17)", "sphinx-autodoc-typehints (>=2.4)", "sphinx-copybutton (>=0.5.2)", "sphinx-inline-tabs (>=2023.4.21)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=24.8)"] +testing = ["build[virtualenv] (>=1.2.2)", "covdefaults (>=2.3)", "detect-test-pollution (>=1.2)", "devpi-process (>=1)", "diff-cover (>=9.1.1)", "distlib (>=0.3.8)", "flaky (>=3.8.1)", "hatch-vcs (>=0.4)", "hatchling (>=1.25)", "psutil (>=6)", "pytest (>=8.3.2)", "pytest-cov (>=5)", "pytest-mock (>=3.14)", "pytest-xdist (>=3.6.1)", "re-assert (>=1.1)", "setuptools (>=74.1.2)", "time-machine (>=2.15)", "wheel (>=0.44)"] + [[package]] name = "typing-extensions" version = "4.12.2" @@ -1201,6 +1312,26 @@ brotli = ["brotli (==1.0.9)", "brotli (>=1.0.9)", "brotlicffi (>=0.8.0)", "brotl secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "ipaddress", "pyOpenSSL (>=0.14)", "urllib3-secure-extra"] socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] +[[package]] +name = "virtualenv" +version = "20.26.5" +description = "Virtual Python Environment builder" +optional = false +python-versions = ">=3.7" +files = [ + {file = "virtualenv-20.26.5-py3-none-any.whl", hash = "sha256:4f3ac17b81fba3ce3bd6f4ead2749a72da5929c01774948e243db9ba41df4ff6"}, + {file = "virtualenv-20.26.5.tar.gz", hash = "sha256:ce489cac131aa58f4b25e321d6d186171f78e6cb13fafbf32a840cee67733ff4"}, +] + +[package.dependencies] +distlib = ">=0.3.7,<1" +filelock = ">=3.12.2,<4" +platformdirs = ">=3.9.1,<5" + +[package.extras] +docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] +test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"] + [[package]] name = "zipp" version = "3.20.1" @@ -1226,4 +1357,4 @@ s3 = ["fs-s3fs"] [metadata] lock-version = "2.0" python-versions = ">=3.8" -content-hash = "94785bf3fd5bf37c1256e35a9691ca51fa6f13eea69a90561858215fc0988264" +content-hash = "d3d3eb2168d989d6619acfaca61ef5c3b80d4a58e75b9dfe696cda9e5c63402a" diff --git a/pyproject.toml b/pyproject.toml index ea64321..f5855fe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,7 @@ importlib-resources = { version = "==6.4.*", python = "<3.9" } singer-sdk = { version="~=0.40.0", extras = ["faker",] } fs-s3fs = { version = "~=1.1.1", optional = true } requests = "~=2.32.3" +tox = "^4.20.0" [tool.poetry.group.dev.dependencies] pytest = ">=8" diff --git a/tap_flowpay_universal/auth.py b/tap_flowpay_universal/auth.py index fd01cb2..bace49b 100644 --- a/tap_flowpay_universal/auth.py +++ b/tap_flowpay_universal/auth.py @@ -1,6 +1,6 @@ """FlowpayUniversal Authentication.""" import json -from datetime import datetime +import datetime from typing import Optional import requests @@ -12,6 +12,9 @@ class MissingCredentialConfigException(Exception): pass +JWT_AUTH = "JWT" +API_KEY_AUTH = "API_KEY" +TOKEN_EXPIRATION_THRESHOLD = 60 # in seconds class OAuth2Authenticator(APIAuthenticatorBase): """API Authenticator for OAuth 2.0 flows.""" @@ -22,16 +25,11 @@ def __init__( config_file: Optional[str] = None, auth_endpoint: Optional[str] = None, ) -> None: - if stream._tap.config.get("client_id") is None: - raise MissingCredentialConfigException("Auth type `JWT` requires `client_id` in config file.") - if stream._tap.config.get("client_secret") is None: - raise MissingCredentialConfigException("Auth type `JWT` requires `client_secret` in config file.") - if stream._tap.config.get("redirect_uri") is None: - raise MissingCredentialConfigException("Auth type `JWT` requires `redirect_uri` in config file.") - if stream._tap.config.get("refresh_token") is None: - raise MissingCredentialConfigException("Auth type `JWT` requires `refresh_token` in config file.") - if stream._tap.config.get("redirect_uri") is None: - raise MissingCredentialConfigException("Auth type `JWT` requires `redirect_uri` in config file.") + required_fields = ["client_id", "client_secret", "redirect_uri", "refresh_token"] + for field in required_fields: + if stream._tap.config.get(field) is None: + raise MissingCredentialConfigException(f"Auth type `{JWT_AUTH}` requires `{field}` in config file.") + super().__init__(stream=stream) self._auth_endpoint = auth_endpoint self._config_file = config_file @@ -79,12 +77,13 @@ def oauth_request_body(self) -> dict: def is_token_valid(self) -> bool: access_token = self._tap._config.get("access_token") - now = round(datetime.utcnow().timestamp()) + now = self.get_now_timestamp() expires_in = self._tap._config.get("expires_in") - return not bool( - (not access_token) or (not expires_in) or ((expires_in - now) < 60) - ) + return bool(access_token and expires_in and ((expires_in - now) >= TOKEN_EXPIRATION_THRESHOLD)) + + def get_now_timestamp(self): + return round(datetime.datetime.now(datetime.timezone.utc).timestamp()) @property def oauth_request_payload(self) -> dict: @@ -95,41 +94,64 @@ def oauth_request_payload(self) -> dict: """ return self.oauth_request_body - @backoff.on_exception(backoff.expo, RetriableAPIError, max_tries=5) - def request_token(self, endpoint, data): - token_response = requests.post(endpoint, data) - if 500 <= token_response.status_code <= 600: - raise RetriableAPIError(f"Auth error: {token_response.text}") - elif 400 <= token_response.status_code < 500: - raise FatalAPIError(f"Auth error: {token_response.text}") - return token_response + @backoff.on_exception(backoff.expo, (RetriableAPIError, requests.exceptions.RequestException), max_tries=5) + def request_token(self, endpoint: str, data: dict) -> requests.Response: + """Request a new access token.""" + try: + token_response = requests.post(endpoint, data) + token_response.raise_for_status() + return token_response + except requests.exceptions.Timeout: + raise RetriableAPIError("Timeout occurred during token request.") + except requests.exceptions.ConnectionError: + raise RetriableAPIError("Connection error occurred during token request.") + except requests.exceptions.HTTPError as http_err: + if 500 <= token_response.status_code < 600: + raise RetriableAPIError(f"Auth error: {token_response.text}") + else: + raise FatalAPIError(f"Auth error: {http_err}") + except Exception as ex: + raise FatalAPIError(f"Failed token request: {ex}") # Authentication and refresh def update_access_token(self) -> None: - """Update `access_token` along with: `last_refreshed` and `expires_in`. + """Update `access_token` and `expires_in`. Raises: RuntimeError: When OAuth login fails. """ - request_time = round(datetime.utcnow().timestamp()) - auth_request_payload = self.oauth_request_payload - token_response = self.request_token(self.auth_endpoint, data=auth_request_payload) + request_time = round(self.get_now_timestamp()) + token_response = self.request_token(self.auth_endpoint, data=self.oauth_request_payload) + try: + # Raise an HTTPError if the response contains a non-200 status code token_response.raise_for_status() - self.logger.info("OAuth authorization attempt was successful.") + token_json = token_response.json() + + # Extract and save the new token and expiration time + self._tap._config["access_token"] = token_json["access_token"] + self._tap._config["expires_in"] = request_time + token_json["expires_in"] + + # Save the updated configuration back to the file + self._save_config() + self.logger.info("OAuth token refreshed successfully.") + except requests.exceptions.HTTPError as http_err: + # Handle different HTTP error responses + raise FatalAPIError(f"HTTP error occurred during token refresh: {http_err}") + except KeyError as key_err: + # Handle missing keys in the token response + raise FatalAPIError(f"Missing key in token response: {key_err}") except Exception as ex: - raise RuntimeError( - f"Failed OAuth login, response was '{token_response.json()}'. {ex}" - ) - token_json = token_response.json() - self.access_token = token_json["access_token"] - expires_in = request_time + token_json["expires_in"] - - self._tap._config["access_token"] = token_json["access_token"] - self._tap._config["expires_in"] = expires_in - with open(self._tap.config_file, "w") as outfile: - json.dump(self._tap._config, outfile, indent=4) + # Catch any other exception and raise it as a FatalAPIError + raise FatalAPIError(f"Failed to refresh access token: {ex}") + def _save_config(self) -> None: + """Save the updated config to the config file.""" + try: + with open(self._tap.config_file, "w") as outfile: + json.dump(self._tap._config, outfile, indent=4) + except IOError as e: + raise FatalAPIError(f"Failed to write config file: {e}") class ApiKeyAuthenticator(APIAuthenticatorBase): """API Authenticator for API KEY flows.""" @@ -140,10 +162,17 @@ def __init__(self, header_name: Optional[str] = None ) -> None: if stream._tap.config.get("api_key") is None: - raise MissingCredentialConfigException("Auth type `API_KEY` requires 'api_key' in config file.") + raise MissingCredentialConfigException(f"Auth type `{API_KEY_AUTH}` requires 'api_key' in config file.") if stream._tap.config.get("api_key_header_name") is None: - raise MissingCredentialConfigException("Auth type `API_KEY` requires 'api_key_header_name' in config file.") + raise MissingCredentialConfigException(f"Auth type `{API_KEY_AUTH}` requires 'api_key_header_name' in config file.") + super().__init__(stream) - self.auth_headers = { - header_name: token - } \ No newline at end of file + self.token = token or stream._tap.config["api_key"] + self.header_name = header_name or stream._tap.config["api_key_header_name"] + + @property + def auth_headers(self) -> dict: + """Return a dictionary of auth headers.""" + headers = super().auth_headers + headers[self.header_name] = self.token + return headers \ No newline at end of file diff --git a/tap_flowpay_universal/client.py b/tap_flowpay_universal/client.py index 9a009e4..9dc2a48 100644 --- a/tap_flowpay_universal/client.py +++ b/tap_flowpay_universal/client.py @@ -14,10 +14,8 @@ class MissingConfig(Exception): class FlowpayUniversalStream(RESTStream): """FlowpayUniversal stream class.""" - # Update this value if necessary or override `parse_response`. - records_jsonpath = "$[*]" + records_jsonpath = "$.data[*]" - # Update this value if necessary or override `get_new_paginator`. next_page_token_jsonpath = "$.next_page" # noqa: S105 @property diff --git a/tap_flowpay_universal/tap.py b/tap_flowpay_universal/tap.py index a46d2fc..13927fe 100644 --- a/tap_flowpay_universal/tap.py +++ b/tap_flowpay_universal/tap.py @@ -22,7 +22,6 @@ class TapFlowpayUniversal(Tap): th.Property("expires_in", th.IntegerType, required=False), th.Property("redirect_uri", th.StringType, required=False), th.Property("token_url", th.StringType, required=False), - th.Property("start_date", th.DateTimeType, required=True), th.Property("merchantId", th.StringType, required=True), th.Property("tenantId", th.StringType, required=False), th.Property("url", th.StringType, required=True), diff --git a/tests/conftest.py b/tests/conftest.py index 190562f..bbc77e7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,39 +1,47 @@ import pytest -@pytest.fixture -def api_key_config(): - """Fixture that provides a valid config.""" +# Helper function for common values +def common_config(): return { - "url": "https://api.flowpay-universal.com", - "start_date": "2022-01-01T00:00:00Z", "merchantId": "test_merchant", - "url": "https://test.flowpay.com", - "auth_type": "API_KEY", - "api_key": "token", - "api_key_header_name": "Token", + "url": "https://test.flowpay.com" } @pytest.fixture -def oauth_config(): - """Fixture that provides a valid config.""" - return { - "url": "https://api.flowpay-universal.com", - "token_url": "https://auth.flowpay-universal.com/oauth/token", - "access_token": "test_access_token", - "refresh_token": "test_refresh_token", - "client_id": "test_client_id", - "client_secret": "test_client_secret", - "redirect_uri": "https://example.com/oauth/callback", - "start_date": "2022-01-01T00:00:00Z", - "merchantId": "test_merchant", - "url": "https://test.flowpay.com", - "auth_type": "JWT", - } +def flowpay_config(request): + """ + Fixture that provides a valid config for Flowpay. + Pass 'auth_type' argument (either 'API_KEY' or 'JWT') to switch between the two configurations. + """ + config = common_config() + # Determine auth type based on the test request parameter or default to API_KEY + auth_type = getattr(request, 'param', 'API_KEY') + + if auth_type == 'API_KEY': + config.update({ + "auth_type": "API_KEY", + "api_key": "token", + "api_key_header_name": "Token", + }) + elif auth_type == 'JWT': + config.update({ + "auth_type": "JWT", + "token_url": "https://auth.flowpay-universal.com/oauth/token", + "access_token": "test_access_token", + "refresh_token": "test_refresh_token", + "client_id": "test_client_id", + "client_secret": "test_client_secret", + "redirect_uri": "https://example.com/oauth/callback", + }) + else: + raise ValueError(f"Unknown auth_type: {auth_type}") + + return config @pytest.fixture def orders_response(): - """Fixture to simulate a valid orders response from the API.""" + """Fixture to simulate a valid orders response from the Flowpay API.""" return { "data": [ { @@ -75,5 +83,4 @@ def orders_response(): } } ] - } - + } \ No newline at end of file diff --git a/tests/test_core.py b/tests/test_core.py index de03cd1..55180fb 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1,23 +1,19 @@ """Tests standard tap features using the built-in SDK tests library.""" import datetime - from singer_sdk.testing import get_tap_test_class - from tap_flowpay_universal.tap import TapFlowpayUniversal - -SAMPLE_CONFIG = { - "start_date": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d"), - "merchantId": "test_merchant", - "url": "https://test.flowpay.com", -} - - -# Run standard built-in tap tests from the SDK: -TestTapFlowpayUniversal = get_tap_test_class( - tap_class=TapFlowpayUniversal, - config=SAMPLE_CONFIG, -) - - -# TODO: Create additional tests as appropriate for your tap. +import pytest + + +@pytest.mark.parametrize('flowpay_config', ['API_KEY', 'JWT'], indirect=True) +def test_api_key_flowpay_universal(flowpay_config): + """ + Test the tap with the API_KEY configuration. + """ + TestTapFlowpayUniversal = get_tap_test_class( + tap_class=TapFlowpayUniversal, + config=flowpay_config, + ) + # Dynamically create the class and let pytest handle discovery + globals()['TestTapFlowpayUniversal'] = TestTapFlowpayUniversal \ No newline at end of file diff --git a/tests/test_orders_stream.py b/tests/test_orders_stream.py index 121a5b6..1b33eda 100644 --- a/tests/test_orders_stream.py +++ b/tests/test_orders_stream.py @@ -1,28 +1,45 @@ from unittest.mock import patch from tap_flowpay_universal.tap import TapFlowpayUniversal from tap_flowpay_universal.streams import OrdersStream +import pytest -# Test if the stream fetches the correct records -def test_orders_stream_parsing(orders_response, api_key_config): - """Test the orders stream data parsing.""" - # Mock the API call - with patch("singer_sdk.streams.RESTStream._request") as mock_get: - mock_get.return_value.status_code = 200 - mock_get.return_value.json.return_value = orders_response +@pytest.mark.parametrize('flowpay_config', ['API_KEY', 'JWT'], indirect=True) +def test_orders_stream_parsing(orders_response, flowpay_config): + """Test the orders stream data parsing with mocked API response.""" + + # Mock the request_records method instead of _request + with patch("singer_sdk.streams.RESTStream.request_records") as mock_request_records: + # Mock the API response + mock_request_records.return_value = iter(orders_response["data"]) + + # Create the stream instance with the appropriate config (API_KEY or JWT) + stream = OrdersStream(tap=TapFlowpayUniversal(config=flowpay_config)) - # Create the stream instance - stream = OrdersStream(tap=TapFlowpayUniversal(config=api_key_config)) - # Get records from the stream (usually an iterator) records = list(stream.get_records(None)) - # Check if the records match the expected result + # Assert that one record is returned assert len(records) == 1 record = records[0] + + # Check specific fields in the record assert record["id"] == "Bj60hk9kkPVAH9QBXr2a" assert record["totalPrice"] == 59.99 assert record["billingAddress"]["city"] == "Praha 6" - assert list(record.keys()) == ["id","createdAt","updatedAt","status","delivery","payment","customerId","customerName","currency","totalPrice","totalDiscount","totalShipping","totalTax", "items", "billingAddress", "shippingAddress"] - assert list(record["items"][0].keys()) == ["productId","productName","quantity","unitPrice","totalPrice","discountAmount","taxAmount"] - assert list(record["billingAddress"].keys()) == ["line1","city","country","zip"] - assert list(record["billingAddress"].keys()) == ["line1","city","country","zip"] \ No newline at end of file + + # Check the structure of the keys in the record + expected_keys = [ + "id", "createdAt", "updatedAt", "status", "delivery", "payment", + "customerId", "customerName", "currency", "totalPrice", "totalDiscount", + "totalShipping", "totalTax", "items", "billingAddress", "shippingAddress" + ] + assert list(record.keys()) == expected_keys + + # Check the structure of the items + expected_item_keys = ["productId", "productName", "quantity", "unitPrice", "totalPrice", "discountAmount", "taxAmount"] + assert list(record["items"][0].keys()) == expected_item_keys + + # Check the structure of the billingAddress + expected_address_keys = ["line1", "city", "country", "zip"] + assert list(record["billingAddress"].keys()) == expected_address_keys + assert list(record["shippingAddress"].keys()) == expected_address_keys \ No newline at end of file diff --git a/tox.ini b/tox.ini index 6be1c11..ab824c1 100644 --- a/tox.ini +++ b/tox.ini @@ -16,4 +16,8 @@ commands = envlist = py{38,39,310,311,312} commands = poetry install -v - poetry run pytest + poetry run pytest {posargs: tests/} + +[pytest] +# Optional: You can add pytest-specific settings here, such as adding specific test directories if needed +addopts = --strict-markers