diff --git a/.gitignore b/.gitignore index 9aa16d6..6f3e282 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ dist/ *.egg-info/ __pycache__/ .pytest_cache/ +dev/openmage/.env diff --git a/CHANGELOG.rst b/CHANGELOG.rst index a9d0af1..ad9fa40 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -18,11 +18,22 @@ First release of the maintained fork of fulfilio/python-magento. * Add a test suite (run against a local stand-in for the Magento XML-RPC API) and GitHub Actions CI on Python 3.9 to 3.14. The tests confirm every documented API (``client.product`` etc.) is available (fulfilio#15). -* README: fix typos and a broken indent in the examples, explain how to check that a - connection and credentials work (fulfilio#25), document the +* README: fix typos and a broken indent in the examples, explain how to + check that a connection and credentials work (fulfilio#25), document the ``product.create`` data format (fulfilio#27), and explain that ``order.search`` needs the Fulfil.IO connector extension and is not in the stock Magento 1 API (fulfilio#26). +* Add opt-in integration tests against a real OpenMage LTS 20.18 in Docker + (``dev/openmage``). They are skipped unless ``OPENMAGE_URL`` is set. +* Fix ``protocol='soap'``: connecting failed with ``TypeNotFound: (Array, + http://schemas.xmlsoap.org/soap/encoding/)`` on Magento 1.4.2 and later + and on OpenMage, whose WSDL does not import the SOAP encoding schema. +* README: document the cart/checkout call sequence, the valid + ``cart_customer.set`` modes (fulfilio#28), that ``cart_shipping.method`` + needs a rate code from ``cart_shipping.list`` (fulfilio#29), and how to + place orders without the new-order email (fulfilio#32). All three were + checked against OpenMage; none is a library bug. +* README: SOAP only works for calls without arguments; use XML-RPC. 3.1 (2019-02-22) ---------------- diff --git a/README.rst b/README.rst index abe0534..3a004d7 100644 --- a/README.rst +++ b/README.rst @@ -131,6 +131,65 @@ the attribute set is missing from ``data``, or a value has the wrong format. Look at an existing product with ``client.product.info(sku)`` to see the attribute names and formats your store expects. +Placing an order through the cart API +------------------------------------- + +The cart calls have to run in this order, all with the same store view. +Without ``store_view`` the cart is created in the admin store (ID 0), and +the order ends up there too. + +.. code-block:: python + + store = 'default' # store view code or ID + address = { + 'firstname': 'Jane', 'lastname': 'Doe', 'street': '1 Main Street', + 'city': 'Austin', 'region_id': '57', 'postcode': '73301', + 'country_id': 'US', 'telephone': '5125550100', + } + + quote = client.cart.create(store) + client.cart_product.add(quote, [{'sku': 'MUG-1', 'qty': 1}], store) + client.cart_customer.set(quote, { + 'mode': 'guest', + 'firstname': 'Jane', 'lastname': 'Doe', + 'email': 'jane@example.com', + }, store) + client.cart_customer.addresses(quote, [ + dict(address, mode='billing'), + dict(address, mode='shipping'), + ], store) + + # Pick a rate code from the list, e.g. 'flatrate_flatrate' + rates = client.cart_shipping.list(quote, store) + client.cart_shipping.method(quote, rates[0]['code'], store) + + client.cart_payment.method(quote, {'method': 'checkmo'}, store) + order_increment_id = client.cart.order(quote, store) + +Common errors: + +* **Setting the customer fails** (fulfilio#28). ``mode`` must be + ``'guest'``, ``'register'`` (also needs ``password``) or ``'customer'`` + (needs ``customer_id`` of an existing customer). A missing mode gives + ``Fault 1045``; any other value, such as ``'admin'``, crashes inside + Magento (``Fault 1`` or an HTTP 500 on PHP 8). +* **Fault 1062: 'Shipping method is not available'** (fulfilio#29). + ``cart_shipping.method`` takes the rate ``code`` string from + ``cart_shipping.list`` (``'flatrate_flatrate'``), not the carrier name, + title or a dictionary. ``Fault 1061`` means the shipping address has not + been set yet. + +Placing orders without sending the order email +---------------------------------------------- + +``cart.order`` cannot skip the new-order email (fulfilio#32): Magento +always sends it when *System > Configuration > Sales Emails > Order > +Enabled* is on for the store view. To place orders silently, create a +store view for API orders, set that option to *No* for it, and pass that +store view's code to every ``cart.*`` call. Note that the *Send Email* +button on the admin order page checks the same setting, so it will not +send emails for orders in that store view either. + Searching orders ---------------- @@ -258,10 +317,35 @@ instead: client = magento.API(url, apiuser, apipass, protocol='soap') +SOAP support is limited: suds cannot encode list or dictionary arguments +for Magento's SOAP v1 API, so only calls without arguments (such as +``client.magento.info()``) work over SOAP. Use XML-RPC for everything +else. + A minimal ``protocol='rest'`` client for the Magento 2 REST API is also included, but it only supports GET requests and needs ``requests`` installed (the ``rest`` extra). For Magento 2 work, PyMagento is the better choice. +Integration tests +----------------- + +``uv run pytest -q`` runs offline against a stand-in for the XML-RPC API, +and skips the tests in ``tests/integration``. Those run against a real +OpenMage LTS that ``dev/openmage/bootstrap.sh`` sets up in Docker (PHP 8.3 +with Apache, plus MariaDB). The first run downloads and installs OpenMage +and creates a test-only API user, a product, a customer, and the flat rate +and check/money order methods; later runs reuse the install. The test +credentials are in ``dev/openmage/.env.example``, which the script copies to +``.env``. + +.. code-block:: sh + + dev/openmage/bootstrap.sh + OPENMAGE_URL=http://localhost:18080/ uv run pytest -q -m openmage + docker compose -f dev/openmage/docker-compose.yml stop # keeps the install + docker compose -f dev/openmage/docker-compose.yml start # resume later + dev/openmage/bootstrap.sh --reset # wipe and reinstall + License ------- diff --git a/dev/openmage/.env.example b/dev/openmage/.env.example new file mode 100644 index 0000000..0316dc4 --- /dev/null +++ b/dev/openmage/.env.example @@ -0,0 +1,19 @@ +# TEST-ONLY credentials for the local throwaway OpenMage container. +# Never reuse these anywhere real. bootstrap.sh copies this file to .env +# (gitignored) on first run; edit .env, not this file, to change them. + +OPENMAGE_VERSION=20.18.0 +OPENMAGE_PORT=18080 + +# SOAP/XML-RPC web services user created by bootstrap.sh (all resources). +OPENMAGE_API_USER=pytest +OPENMAGE_API_KEY=pytest-api-key-123 + +# Magento admin login (http://localhost:18080/index.php/admin). +OPENMAGE_ADMIN_USER=admin +OPENMAGE_ADMIN_PASSWORD=admin-test-password-123 + +DB_NAME=openmage +DB_USER=openmage +DB_PASSWORD=openmage +DB_ROOT_PASSWORD=openmage-root diff --git a/dev/openmage/Dockerfile b/dev/openmage/Dockerfile new file mode 100644 index 0000000..49dfd39 --- /dev/null +++ b/dev/openmage/Dockerfile @@ -0,0 +1,47 @@ +# Minimal PHP + Apache image for a throwaway OpenMage LTS test install. +# +# The OpenMage source itself is not baked in: bootstrap.sh downloads the pinned +# release into the "openmage-src" volume on first run, so the image stays small +# and the install survives image rebuilds. +FROM php:8.3-apache + +# git/patch/unzip are needed by composer (cweagans/composer-patches applies +# OpenMage's vendor patches with git or patch). +RUN apt-get update \ + && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + git patch unzip \ + libfreetype6-dev libjpeg62-turbo-dev libpng-dev libicu-dev libxml2-dev libzip-dev \ + && docker-php-ext-configure gd --with-freetype --with-jpeg \ + && docker-php-ext-install -j"$(nproc)" ftp gd intl mysqli pdo_mysql soap zip \ + && rm -rf /var/lib/apt/lists/* /tmp/pear + +ARG COMPOSER_VERSION=2.10.3 +RUN curl -fsSL "https://getcomposer.org/download/${COMPOSER_VERSION}/composer.phar" -o /usr/local/bin/composer \ + && chmod +x /usr/local/bin/composer + +RUN { \ + echo 'memory_limit = 768M'; \ + echo 'max_execution_time = 300'; \ + echo 'date.timezone = UTC'; \ + echo 'display_errors = Off'; \ + echo 'log_errors = On'; \ + echo 'opcache.validate_timestamps = 1'; \ + echo 'opcache.revalidate_freq = 2'; \ + } > /usr/local/etc/php/conf.d/zz-openmage.ini + +# Apache listens on the same port inside the container as on the host. The +# SOAP v1 server fetches its own WSDL from the store base URL +# (http://localhost:$OPENMAGE_PORT/...), so that URL has to resolve from inside +# the container as well as from the host. +RUN a2enmod rewrite headers \ + && sed -ri 's/^Listen 80$/Listen ${OPENMAGE_PORT}/' /etc/apache2/ports.conf \ + && sed -ri 's///' /etc/apache2/sites-available/000-default.conf \ + && printf '\n AllowOverride All\n\n' > /etc/apache2/conf-enabled/openmage.conf + +ENV OPENMAGE_PORT=18080 \ + COMPOSER_HOME=/tmp/composer \ + COMPOSER_ALLOW_SUPERUSER=1 + +# Harness scripts are copied in rather than bind-mounted so the setup works +# with Docker VMs that do not share the checkout's path. +COPY scripts/ /opt/harness/ diff --git a/dev/openmage/bootstrap.sh b/dev/openmage/bootstrap.sh new file mode 100755 index 0000000..a267033 --- /dev/null +++ b/dev/openmage/bootstrap.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# Bring up a local OpenMage LTS install for the integration tests. +# +# ./bootstrap.sh start (and on first run download, install, seed) +# ./bootstrap.sh --reset wipe the containers AND volumes, then start fresh +# +# Every step checks what is already there, so re-running on an existing install +# only starts the containers and re-applies the seed data; it never reinstalls. +# Stop with "docker compose stop" (keeps the install), not "down -v". +set -euo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")" + +if [[ ! -f .env ]]; then + cp .env.example .env + echo "Created .env from .env.example" +fi +set -a +# shellcheck disable=SC1091 +source .env +set +a + +OPENMAGE_VERSION=${OPENMAGE_VERSION:-20.18.0} +OPENMAGE_PORT=${OPENMAGE_PORT:-18080} +BASE_URL="http://localhost:${OPENMAGE_PORT}/" + +if [[ "${1:-}" == "--reset" ]]; then + echo "Removing containers and volumes..." + docker compose down --volumes --remove-orphans +fi + +echo "Starting containers..." +docker compose up -d --build --wait + +# Run a command in the web container as the Apache user. +web() { docker compose exec -T -u www-data -w /var/www/html web "$@"; } + +if ! web test -f app/Mage.php || ! web test -f vendor/autoload.php; then + echo "Fetching OpenMage ${OPENMAGE_VERSION}..." + web bash /opt/harness/fetch-source.sh "${OPENMAGE_VERSION}" +else + echo "OpenMage source already present: $(web php -r 'require "app/Mage.php"; echo Mage::getOpenMageVersion();')" +fi + +if ! web test -f app/etc/local.xml; then + echo "Installing OpenMage..." + web php install.php \ + --license_agreement_accepted yes \ + --locale en_US \ + --timezone UTC \ + --default_currency USD \ + --db_host db \ + --db_name "${DB_NAME}" \ + --db_user "${DB_USER}" \ + --db_pass "${DB_PASSWORD}" \ + --url "${BASE_URL}" \ + --secure_base_url "${BASE_URL}" \ + --use_rewrites yes \ + --use_secure no \ + --use_secure_admin no \ + --skip_url_validation yes \ + --admin_firstname Test \ + --admin_lastname Admin \ + --admin_email admin@example.com \ + --admin_username "${OPENMAGE_ADMIN_USER}" \ + --admin_password "${OPENMAGE_ADMIN_PASSWORD}" +else + echo "OpenMage already installed (app/etc/local.xml exists)." +fi + +echo "Seeding API user, catalogue, customer and checkout config..." +docker compose exec -T -u www-data -w /var/www/html \ + -e OPENMAGE_API_USER="${OPENMAGE_API_USER}" \ + -e OPENMAGE_API_KEY="${OPENMAGE_API_KEY}" \ + web php /opt/harness/seed.php + +echo "Checking the XML-RPC endpoint..." +for _ in $(seq 1 20); do + if curl -fsS -o /dev/null -H 'Content-Type: text/xml' \ + --data 'startSession' \ + "${BASE_URL}index.php/api/xmlrpc"; then + break + fi + sleep 1 +done + +cat <setCurrentStore(Mage_Core_Model_App::ADMIN_STORE_ID); +Mage::register('isSecureArea', true); + +$apiUser = getenv('OPENMAGE_API_USER') ?: 'pytest'; +$apiKey = getenv('OPENMAGE_API_KEY') ?: 'pytest-api-key-123'; + +function say($msg) +{ + fwrite(STDOUT, " - {$msg}\n"); +} + +// --- configuration ------------------------------------------------------- +$config = Mage::getConfig(); +foreach ([ + 'carriers/flatrate/active' => '1', + 'carriers/flatrate/type' => 'O', + 'carriers/flatrate/price' => '5.00', + 'carriers/flatrate/sallowspecific' => '0', + 'payment/checkmo/active' => '1', + 'checkout/options/guest_checkout' => '1', + 'sales_email/order/enabled' => '1', + 'system/smtp/disable' => '1', + 'dev/log/active' => '1', + 'api/config/wsdl_cache_enabled' => '0', +] as $path => $value) { + $config->saveConfig($path, $value, 'default', 0); +} +say('checkout/shipping/payment/email config applied'); + +// --- "noemail" store view ------------------------------------------------ +$website = Mage::app()->getWebsite(1); +$store = Mage::getModel('core/store')->load('noemail', 'code'); +if (!$store->getId()) { + $store->setCode('noemail') + ->setWebsiteId($website->getId()) + ->setGroupId($website->getDefaultGroupId()) + ->setName('No order email') + ->setSortOrder(10) + ->setIsActive(1) + ->save(); + say("created store view noemail (id {$store->getId()})"); +} +$config->saveConfig('sales_email/order/enabled', '0', 'stores', $store->getId()); + +$config->reinit(); +Mage::app()->reinitStores(); + +// --- API role + user ----------------------------------------------------- +$role = Mage::getModel('api/roles')->getCollection() + ->addFieldToFilter('role_name', 'pytest-all') + ->addFieldToFilter('role_type', 'G') + ->getFirstItem(); +if (!$role->getId()) { + $role = Mage::getModel('api/roles') + ->setName('pytest-all') + ->setPid(false) + ->setRoleType('G') + ->save(); + say("created API role pytest-all (id {$role->getId()})"); +} +Mage::getModel('api/rules') + ->setRoleId($role->getId()) + ->setResources(['all']) + ->saveRel(); + +$user = Mage::getModel('api/user')->loadByUsername($apiUser); +if (!$user->getId()) { + $user->setData([ + 'username' => $apiUser, + 'firstname' => 'Pytest', + 'lastname' => 'Integration', + 'email' => $apiUser . '@example.com', + 'api_key' => $apiKey, + 'is_active' => 1, + ])->save(); + say("created API user {$apiUser}"); +} else { + // Re-apply the key from .env so a changed key takes effect. + $user->setNewApiKey($apiKey)->setIsActive(1)->save(); +} +$user = Mage::getModel('api/user')->loadByUsername($apiUser); +$user->setRoleIds([$role->getId()]) + ->setRoleUserId($user->getId()) + ->saveRelations(); + +// --- product ------------------------------------------------------------- +$sku = 'pytest-simple'; +$product = Mage::getModel('catalog/product'); +$productId = $product->getIdBySku($sku); +if (!$productId) { + $product->setStoreId(0) + ->setWebsiteIds([$website->getId()]) + ->setAttributeSetId($product->getDefaultAttributeSetId()) + ->setTypeId(Mage_Catalog_Model_Product_Type::TYPE_SIMPLE) + ->setSku($sku) + ->setName('Pytest Simple Product') + ->setDescription('Simple product used by the python-magento integration tests.') + ->setShortDescription('Integration test product.') + ->setPrice(10.00) + ->setWeight(1) + ->setStatus(Mage_Catalog_Model_Product_Status::STATUS_ENABLED) + ->setVisibility(Mage_Catalog_Model_Product_Visibility::VISIBILITY_BOTH) + ->setTaxClassId(0) + ->setStockData([ + 'use_config_manage_stock' => 1, + 'is_in_stock' => 1, + 'qty' => 100000, + ]) + ->save(); + $productId = $product->getId(); + say("created product {$sku} (id {$productId})"); +} +// Orders placed by the tests decrement stock; top it back up every run. +$stock = Mage::getModel('cataloginventory/stock_item')->loadByProduct($productId); +$stock->setQty(100000)->setIsInStock(1)->save(); + +// --- customer ------------------------------------------------------------ +$email = 'pytest.customer@example.com'; +$customer = Mage::getModel('customer/customer') + ->setWebsiteId($website->getId()) + ->loadByEmail($email); +if (!$customer->getId()) { + $customer->setWebsiteId($website->getId()) + ->setStoreId($website->getDefaultStore()->getId()) + ->setGroupId(1) + ->setFirstname('Pytest') + ->setLastname('Customer') + ->setEmail($email) + ->setPassword('pytest-password-123') + ->save(); + say("created customer {$email} (id {$customer->getId()})"); +} +if (!$customer->getDefaultBilling()) { + $region = Mage::getModel('directory/region')->loadByCode('TX', 'US'); + $address = Mage::getModel('customer/address') + ->setCustomerId($customer->getId()) + ->setFirstname('Pytest') + ->setLastname('Customer') + ->setStreet(['1 Test Street']) + ->setCity('Austin') + ->setRegionId($region->getId()) + ->setPostcode('73301') + ->setCountryId('US') + ->setTelephone('5125550100') + ->setIsDefaultBilling(1) + ->setIsDefaultShipping(1) + ->setSaveInAddressBook(1) + ->save(); + say("created default address (id {$address->getId()})"); +} + +// --- indexes + cache ----------------------------------------------------- +foreach (Mage::getSingleton('index/indexer')->getProcessesCollection() as $process) { + $process->reindexEverything(); +} +Mage::app()->getCacheInstance()->flush(); +say('reindexed and flushed cache'); + +fwrite(STDOUT, "Seed complete.\n"); diff --git a/magento/api.py b/magento/api.py index c36fdc9..49363f7 100644 --- a/magento/api.py +++ b/magento/api.py @@ -22,11 +22,20 @@ try: from suds.client import Client + from suds.xsd.doctor import Import, ImportDoctor except ImportError: pass else: PROTOCOLS.append('soap') +#: Magento 1.4.2+ and OpenMage ship a SOAP v1 WSDL that uses soapenc:Array +#: but has the import of the SOAP encoding schema commented out, so suds +#: fails with ``TypeNotFound: (Array, http://schemas.xmlsoap.org/soap/ +#: encoding/)``. Re-adding the import fixes that; suds resolves it from its +#: bundled copy, so no network fetch happens, and it is skipped when the WSDL +#: already imports it. +SOAP_ENCODING_NS = 'http://schemas.xmlsoap.org/soap/encoding/' + from . import rest try: import requests @@ -163,7 +172,8 @@ def connect(self): self.client = rest.Client(self.url, self.password, verify_ssl=self.verify_ssl) else: - self.client = Client(self.url) + self.client = Client( + self.url, doctor=ImportDoctor(Import(SOAP_ENCODING_NS))) def __enter__(self): """ diff --git a/magento/checkout.py b/magento/checkout.py index 24a3d88..4e71e07 100644 --- a/magento/checkout.py +++ b/magento/checkout.py @@ -155,11 +155,12 @@ def set(self, quote_id, customer_data, store_view=None): { 'firstname': 'testFirstname', 'lastname': 'testLastName', - 'email': 'testEmail', - 'website_id': '0', - 'store_id': '0', + 'email': 'test@example.com', 'mode': 'guest' } + ``mode`` must be 'guest', 'register' (also needs 'password') + or 'customer' (needs 'customer_id'). Magento rejects a missing + mode and crashes on any other value. :param store_view: Store view ID or code :return: boolean, True if information added """ @@ -362,7 +363,10 @@ def method(self, quote_id, shipping_method, store_view=None): Allows you to set a shipping method for a shopping cart (quote). :param quote_id: Shopping cart ID (quote ID) - :param shipping_method, string, shipping method code + :param shipping_method, string, shipping rate code, e.g. + 'flatrate_flatrate'. Use the ``code`` of an entry returned by + :meth:`list`; the carrier name or title gives Fault 1062. + The shipping address must be set first (Fault 1061). :param store_view: Store view ID or code :return: boolean, True if the shipping method is set """ diff --git a/pyproject.toml b/pyproject.toml index 34ec1a0..b592ffe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,3 +53,6 @@ dev = ["pytest>=8"] [tool.pytest.ini_options] testpaths = ["tests"] pythonpath = ["tests"] +markers = [ + "openmage: needs a live OpenMage (set OPENMAGE_URL; see dev/openmage)", +] diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py new file mode 100644 index 0000000..510ecbf --- /dev/null +++ b/tests/integration/conftest.py @@ -0,0 +1,70 @@ +# -*- coding: utf-8 -*- +""" + Fixtures for the opt-in OpenMage integration tests. + + These tests talk to a real OpenMage install (see dev/openmage). They are + skipped unless OPENMAGE_URL is set, so the default test run stays offline. +""" +import os + +import pytest + +import magento + +OPENMAGE_URL = os.environ.get('OPENMAGE_URL') +API_USER = os.environ.get('OPENMAGE_API_USER', 'pytest') +API_KEY = os.environ.get('OPENMAGE_API_KEY', 'pytest-api-key-123') + +# Seeded by dev/openmage/scripts/seed.php. +PRODUCT_SKU = 'pytest-simple' +CUSTOMER_EMAIL = 'pytest.customer@example.com' +STORE = 'default' +NO_EMAIL_STORE = 'noemail' + +US_ADDRESS = { + 'firstname': 'Guest', + 'lastname': 'Buyer', + 'street': '1 Test Street', + 'city': 'Austin', + 'region': 'Texas', + 'region_id': '57', + 'postcode': '73301', + 'country_id': 'US', + 'telephone': '5125550100', +} + + +def pytest_collection_modifyitems(config, items): + if OPENMAGE_URL: + return + skip = pytest.mark.skip( + reason='set OPENMAGE_URL to run against a live OpenMage ' + '(see dev/openmage/bootstrap.sh)') + for item in items: + if 'openmage' in item.keywords: + item.add_marker(skip) + + +@pytest.fixture(scope='session') +def client(): + with magento.API(OPENMAGE_URL, API_USER, API_KEY) as api: + yield api + + +@pytest.fixture(scope='session') +def customer(client): + found = client.customer.list({'email': {'eq': CUSTOMER_EMAIL}}) + assert found, 'seeded customer missing; re-run dev/openmage/bootstrap.sh' + customer = found[0] + customer['address'] = client.customer_address.list( + int(customer['customer_id']))[0] + return customer + + +@pytest.fixture +def quote(client): + """A fresh cart in the default store view holding one seeded product.""" + quote_id = client.cart.create(STORE) + assert client.cart_product.add( + quote_id, [{'sku': PRODUCT_SKU, 'qty': 1}], STORE) + return quote_id diff --git a/tests/integration/test_checkout_issues.py b/tests/integration/test_checkout_issues.py new file mode 100644 index 0000000..34c046e --- /dev/null +++ b/tests/integration/test_checkout_issues.py @@ -0,0 +1,122 @@ +# -*- coding: utf-8 -*- +""" + Reproductions of upstream fulfilio/python-magento checkout issues against a + live OpenMage. Each test pins down what the server actually does, and the + call sequence that works. +""" +from xml.parsers.expat import ExpatError +from xmlrpc.client import Fault, ProtocolError + +import pytest + +from .conftest import NO_EMAIL_STORE, PRODUCT_SKU, STORE, US_ADDRESS + +pytestmark = pytest.mark.openmage + +GUEST = { + 'mode': 'guest', + 'firstname': 'Guest', + 'lastname': 'Buyer', + 'email': 'guest.buyer@example.com', +} + + +def _fault_code(excinfo): + return excinfo.value.faultCode + + +# --- #28 "could not set customer data in CartCustomer" ----------------------- + +def test_issue_28_unknown_mode_crashes_the_server(client, quote): + # 'admin' is not a checkout mode. Magento's switch has no default branch, + # so $customer stays unset and Quote::setCustomer(null) dies: Fault 1 + # "Internal Error" on PHP 5, an uncaught TypeError (HTTP 500) on PHP 8. + data = dict(GUEST, mode='admin') + with pytest.raises((Fault, ProtocolError, ExpatError)): + client.cart_customer.set(quote, data, STORE) + + +def test_issue_28_missing_mode_is_rejected(client, quote): + data = {k: v for k, v in GUEST.items() if k != 'mode'} + with pytest.raises(Fault) as excinfo: + client.cart_customer.set(quote, data, STORE) + assert _fault_code(excinfo) == 1045 # Customer's mode is unknown + + +@pytest.mark.parametrize('data', [ + GUEST, + dict(GUEST, mode='register', email='register.buyer@example.com', + password='pytest-password-123'), +], ids=['guest', 'register']) +def test_issue_28_valid_modes_work(client, quote, data): + assert client.cart_customer.set(quote, data, STORE) + + +def test_issue_28_customer_mode_needs_customer_id(client, customer, quote): + assert client.cart_customer.set(quote, { + 'mode': 'customer', 'customer_id': customer['customer_id']}, STORE) + + +# --- #29 "Fault 1062: Shipping method is not available" ---------------------- + +@pytest.fixture +def addressed_quote(client, quote): + client.cart_customer.set(quote, GUEST, STORE) + client.cart_customer.addresses(quote, [ + dict(US_ADDRESS, mode='billing'), + dict(US_ADDRESS, mode='shipping'), + ], STORE) + return quote + + +@pytest.mark.parametrize('wrong', [ + {'method': 'flatrate'}, # a dict, as in the issue + 'flatrate', # the carrier, not the rate code + 'Flat Rate', # the title +], ids=['dict', 'carrier', 'title']) +def test_issue_29_shipping_method_must_be_the_rate_code( + client, addressed_quote, wrong): + with pytest.raises(Fault) as excinfo: + client.cart_shipping.method(addressed_quote, wrong, STORE) + assert _fault_code(excinfo) == 1062 + + +def test_issue_29_rate_code_from_list_works(client, addressed_quote): + codes = [r['code'] for r in client.cart_shipping.list( + addressed_quote, STORE)] + assert 'flatrate_flatrate' in codes + assert client.cart_shipping.method( + addressed_quote, 'flatrate_flatrate', STORE) + + +def test_issue_29_shipping_needs_an_address_first(client, quote): + with pytest.raises(Fault) as excinfo: + client.cart_shipping.method(quote, 'flatrate_flatrate', STORE) + assert _fault_code(excinfo) == 1061 # shipping address is not set + + +# --- #32 "Disable email sending for new orders" ------------------------------ + +def _place_guest_order(client, store): + quote = client.cart.create(store) + client.cart_product.add(quote, [{'sku': PRODUCT_SKU, 'qty': 1}], store) + client.cart_customer.set(quote, GUEST, store) + client.cart_customer.addresses(quote, [ + dict(US_ADDRESS, mode='billing'), + dict(US_ADDRESS, mode='shipping'), + ], store) + client.cart_shipping.method(quote, 'flatrate_flatrate', store) + client.cart_payment.method(quote, {'method': 'checkmo'}, store) + return client.order.info(client.cart.order(quote, store)) + + +def test_issue_32_order_email_follows_store_config(client): + # cart.order has no "don't email" argument; it always calls + # Order::queueNewOrderEmail(), which only checks the store's + # sales_email/order/enabled setting. The seed script turns that off for + # the "noemail" store view, so orders placed there are not emailed. + emailed = _place_guest_order(client, STORE) + silent = _place_guest_order(client, NO_EMAIL_STORE) + + assert emailed['email_sent'] == '1' + assert not silent['email_sent'] diff --git a/tests/integration/test_openmage.py b/tests/integration/test_openmage.py new file mode 100644 index 0000000..5059d10 --- /dev/null +++ b/tests/integration/test_openmage.py @@ -0,0 +1,95 @@ +# -*- coding: utf-8 -*- +""" + Smoke and checkout tests against a live OpenMage over XML-RPC. +""" +import pytest + +from .conftest import CUSTOMER_EMAIL, PRODUCT_SKU, STORE, US_ADDRESS + +pytestmark = pytest.mark.openmage + + +def test_core_magento_info(client): + info = client.magento.info() + + assert info['magento_edition'] == 'Community' + assert info['magento_version'].startswith('1.9') + assert 'openmage_version' in info + + +def test_product_list_and_info(client): + products = client.product.list({'sku': {'eq': PRODUCT_SKU}}) + assert [p['sku'] for p in products] == [PRODUCT_SKU] + + info = client.product.info(PRODUCT_SKU, identifierType='sku') + assert info['product_id'] == products[0]['product_id'] + assert info['type'] == 'simple' + assert float(info['price']) == 10.0 + + +def test_customer_list(client): + customers = client.customer.list({'email': {'eq': CUSTOMER_EMAIL}}) + + assert len(customers) == 1 + assert customers[0]['firstname'] == 'Pytest' + + +def test_order_list(client): + assert isinstance(client.order.list(), list) + + +def test_cart_checkout_as_registered_customer(client, customer, quote): + address_id = customer['address']['customer_address_id'] + + assert client.cart_customer.set(quote, { + 'mode': 'customer', + 'customer_id': customer['customer_id'], + }, STORE) + assert client.cart_customer.addresses(quote, [ + {'mode': 'billing', 'address_id': address_id}, + {'mode': 'shipping', 'address_id': address_id}, + ], STORE) + + rates = client.cart_shipping.list(quote, STORE) + assert 'flatrate_flatrate' in [rate['code'] for rate in rates] + assert client.cart_shipping.method(quote, 'flatrate_flatrate', STORE) + + methods = client.cart_payment.list(quote, STORE) + assert 'checkmo' in [method['code'] for method in methods] + assert client.cart_payment.method(quote, {'method': 'checkmo'}, STORE) + + totals = {t['title']: float(t['amount']) + for t in client.cart.totals(quote, STORE)} + assert totals['Grand Total'] == 15.0 # 10.00 product + 5.00 flat rate + + increment_id = client.cart.order(quote, STORE) + + order = client.order.info(increment_id) + assert order['customer_email'] == CUSTOMER_EMAIL + assert order['shipping_method'] == 'flatrate_flatrate' + assert order['payment']['method'] == 'checkmo' + assert float(order['grand_total']) == 15.0 + assert [item['sku'] for item in order['items']] == [PRODUCT_SKU] + assert [o['increment_id'] for o in client.order.list( + {'increment_id': {'eq': increment_id}})] == [increment_id] + + +def test_cart_checkout_as_guest(client, quote): + assert client.cart_customer.set(quote, { + 'mode': 'guest', + 'firstname': 'Guest', + 'lastname': 'Buyer', + 'email': 'guest.buyer@example.com', + }, STORE) + assert client.cart_customer.addresses(quote, [ + dict(US_ADDRESS, mode='billing'), + dict(US_ADDRESS, mode='shipping'), + ], STORE) + assert client.cart_shipping.method(quote, 'flatrate_flatrate', STORE) + assert client.cart_payment.method(quote, {'method': 'checkmo'}, STORE) + + increment_id = client.cart.order(quote, STORE) + + order = client.order.info(increment_id) + assert order['customer_is_guest'] == '1' + assert order['customer_email'] == 'guest.buyer@example.com' diff --git a/tests/integration/test_soap.py b/tests/integration/test_soap.py new file mode 100644 index 0000000..1ae1045 --- /dev/null +++ b/tests/integration/test_soap.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- +""" + SOAP v1 smoke tests against a live OpenMage. +""" +import pytest + +import magento + +from .conftest import API_KEY, API_USER, OPENMAGE_URL + +pytestmark = pytest.mark.openmage + + +def _as_dict(soap_map): + """Turn a SOAP v1 ``ns2:Map`` (list of key/value items) into a dict.""" + return {entry.key: entry.value for entry in soap_map.item} + + +@pytest.fixture +def soap_api(): + with magento.API(OPENMAGE_URL, API_USER, API_KEY, protocol='soap') as api: + yield api + + +def test_soap_login_and_core_magento_info(soap_api): + info = _as_dict(soap_api.call('core_magento.info', [])) + + assert info['magento_edition'] == 'Community' + assert 'openmage_version' in info + + +@pytest.mark.xfail( + strict=True, + reason='suds cannot encode a Python list/dict as the xsd:anyType "args" ' + 'part (TypeNotFound: args), so SOAP calls that take arguments fail') +def test_soap_call_with_arguments(soap_api): + soap_api.call('catalog_product.list', [None, None]) diff --git a/tests/test_soap.py b/tests/test_soap.py new file mode 100644 index 0000000..16a7f4f --- /dev/null +++ b/tests/test_soap.py @@ -0,0 +1,138 @@ +# -*- coding: utf-8 -*- +""" + SOAP v1 client construction against a Magento/OpenMage-style WSDL. +""" +import threading +from http.server import BaseHTTPRequestHandler, HTTPServer + +import pytest + +import magento + +# Trimmed from the WSDL OpenMage 20.x serves at /api/?wsdl. As in stock +# Magento since 1.4.2, the soapenc import is commented out while FixedArray +# still restricts soapenc:Array. +OPENMAGE_STYLE_WSDL = b""" + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +""" + + +class _WsdlHandler(BaseHTTPRequestHandler): + + def do_GET(self): + self.send_response(200) + self.send_header('Content-Type', 'text/xml; charset=UTF-8') + self.end_headers() + self.wfile.write(OPENMAGE_STYLE_WSDL) + + def log_message(self, *args): + pass + + +@pytest.fixture +def wsdl_url(): + server = HTTPServer(('127.0.0.1', 0), _WsdlHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield 'http://127.0.0.1:%d/' % server.server_address[1] + finally: + server.shutdown() + server.server_close() + + +def test_soap_connect_handles_wsdl_without_soapenc_import(wsdl_url): + # Regression: suds raised TypeNotFound '(Array, http://schemas.xmlsoap.org + # /soap/encoding/, )' for every Magento >= 1.4.2 / OpenMage WSDL. + client = magento.API(wsdl_url, 'apiuser', 'apikey', protocol='soap') + client.connect() + + methods = {m.name for m in + client.client.wsdl.services[0].ports[0].methods.values()} + assert {'call', 'login', 'resources'} <= methods