diff --git a/AGENTS.md b/AGENTS.md index a8a2e10..e9bd648 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,6 +38,7 @@ Python import: mpt_extension_contrib. - [`shared/`](shared): internal reusable code exposed as `mpt_extension_contrib.shared` (distribution `mpt-extension-contrib-shared`). +- [`fixtures/`](fixtures): public package exposed as `mpt_extension_contrib.fixtures` (distribution `mpt-extension-contrib-fixtures`). - [`custom-notifications/`](custom-notifications): public package exposed as `mpt_extension_contrib.custom_notifications` (distribution `mpt-extension-contrib-custom-notifications`). - [`due-date/`](due-date): public package exposed as `mpt_extension_contrib.due_date` (distribution `mpt-extension-contrib-due-date`). diff --git a/README.md b/README.md index 46b22cb..df22a73 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ of them share the `mpt_extension_contrib` namespace. | Directory | Distribution | Python import | Visibility | | --- | --- | --- | --- | | `shared/` | `mpt-extension-contrib-shared` | `mpt_extension_contrib.shared` | internal | +| `fixtures/` | `mpt-extension-contrib-fixtures` | `mpt_extension_contrib.fixtures` | public | | `custom-notifications/` | `mpt-extension-contrib-custom-notifications` | `mpt_extension_contrib.custom_notifications` | public | | `due-date/` | `mpt-extension-contrib-due-date` | `mpt_extension_contrib.due_date` | public | diff --git a/fixtures/.copier-answers.yml b/fixtures/.copier-answers.yml new file mode 100644 index 0000000..aa2a70d --- /dev/null +++ b/fixtures/.copier-answers.yml @@ -0,0 +1,4 @@ +# Managed by Copier — do not edit by hand. Run `copier update` to refresh. +_src_path: /workspace/scripts/templates/module +module: fixtures + diff --git a/fixtures/AGENTS.md b/fixtures/AGENTS.md new file mode 100644 index 0000000..4c78b7c --- /dev/null +++ b/fixtures/AGENTS.md @@ -0,0 +1,29 @@ +# AGENTS.md + +This module is the `fixtures` contrib package. + +Read in this order: + +1. [README.md](README.md) for the module purpose. +2. [docs/usage.md](docs/usage.md) for installing and using the module. +3. [docs/architecture.md](docs/architecture.md) for the public API boundary. +4. [docs/technical-design-review.md](docs/technical-design-review.md) for the problems, requirements, and how they map to usage. +5. [docs/contributing.md](docs/contributing.md) before changing this module. +6. [docs/testing.md](docs/testing.md) before changing tests. +7. [docs/releases.md](docs/releases.md) before releasing this module. +8. [../AGENTS.md](../AGENTS.md) for repository-wide rules and validation commands. + +Operational guidance: + +- Public API is re-exported from the package root (`mpt_extension_contrib.fixtures`) + via a lazy PEP 562 `__getattr__`; implementation lives in the `factories`, + `parameters`, `scenarios`, and `errors` submodules. +- Factories build structurally valid SDK models with empty parameters only — no + product-specific parameter values or business logic. +- Resolve implementation lazily in `__init__.py` and `plugin.py` (the `pytest11` + entry point) so startup registration stays measurable by coverage. See + [docs/architecture.md](docs/architecture.md) for the coverage rationale and the + scoped lint ignores it requires. +- Test the plugin with the `pytester` fixture; unit-test each helper directly. +- Add tests under [`tests/`](tests) for every behavior change. +- Run `make check-all pkg=fixtures` from the repository root while iterating. diff --git a/fixtures/LICENSE b/fixtures/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/fixtures/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/fixtures/README.md b/fixtures/README.md new file mode 100644 index 0000000..102b5a7 --- /dev/null +++ b/fixtures/README.md @@ -0,0 +1,20 @@ +# mpt-extension-contrib-fixtures + +Pytest fixtures and [polyfactory](https://polyfactory.litestar.dev/) factories +for MPT-shaped objects (orders, agreements, subscriptions). Factories build +structurally valid SDK models with empty parameters; the product-specific +parameter catalog stays in the consumer. + +Installing the package registers a pytest plugin (`pytest11` entry point), so +its fixtures are available without any `conftest.py` wiring. + +See [AGENTS.md](AGENTS.md) for the module documentation map. + +## Documentation + +- [Usage](docs/usage.md) +- [Architecture](docs/architecture.md) +- [Technical Design Review](docs/technical-design-review.md) +- [Contributing](docs/contributing.md) +- [Testing](docs/testing.md) +- [Releases](docs/releases.md) diff --git a/fixtures/docs/architecture.md b/fixtures/docs/architecture.md new file mode 100644 index 0000000..4a7244f --- /dev/null +++ b/fixtures/docs/architecture.md @@ -0,0 +1,108 @@ +# Architecture + +`mpt-extension-contrib-fixtures` exposes test helpers for MPT-shaped objects. + +## Public API + +- `mpt_extension_contrib.fixtures` — package root re-exports the public API + (`OrderFactory`, `AgreementFactory`, `SubscriptionFactory`, `MPTModelFactory`, + `ParameterBagFactory`, `parameter_bag`, `Scenarios`, `Spec`, + `mpt_error_factory`). +- `mpt_extension_contrib.fixtures.factories` — polyfactory `ModelFactory` + subclasses and the `MPTModelFactory` base. +- `mpt_extension_contrib.fixtures.parameters` — the `parameter_bag` builder. +- `mpt_extension_contrib.fixtures.scenarios` — scenario and parametrization helpers. +- `mpt_extension_contrib.fixtures.errors` — the `mpt_error_factory` helper. +- `mpt_extension_contrib.fixtures.plugin` — the `pytest11` entry-point module + that registers the convenience fixtures. + +## Design boundaries + +- Factories build structurally valid SDK models with **empty parameters**. + Parameter values are product-specific and supplied by the consumer. +- No product-specific business logic, enums, or catalogs live here. +- Platform / MPT-API objects only — never vendor API objects (Adobe, AWS, …). + +## SDK models vs the API client + +Two object representations exist in the stack, and this library standardises on +the SDK one: + +- `mpt_api_client` (the HTTP client, an SDK dependency) returns **dynamic** + `Model` objects — dicts exposed as attributes — straight from httpx. +- `mpt_extension_sdk.models` are **typed pydantic** models. Every SDK service + wraps client responses into them via `BaseModel.from_payload(...)` + (`OrderService.get_by_id` → `Order.from_payload(...)`; `_paginate` → + `[Model.from_payload(...)]`). Business logic and pipelines only ever see these + typed models; the client's dynamic representation is an internal transport + detail. + +So the factories build **SDK typed models**. For now, tests **mock at the SDK +service layer** and return a factory model directly — it matches what production +code receives from `from_payload`. `.to_dict()` serialises a model to the exact +camelCase JSON the API returns and is the bridge to any future wire-level mocking; +HTTP/wire mocking helpers are not shipped in this iteration (see Roadmap). No +separate "client model" fixtures are needed. + +## Roadmap + +Shipped: `OrderFactory` / `AgreementFactory` / `SubscriptionFactory`, +`parameter_bag`, `Scenarios`, `mpt_error_factory`, the pytest plugin. + +Planned (MPT-22111 — platform/MPT-API scope only): + +- Realistic MPT ids per object (`ORD-####-####-####`, `AGR-…`, `LCE-…`, …) via + per-factory id providers, propagated into nested models. +- More factories where an SDK model exists: Asset, Account/Buyer/Seller/Licensee, + Product/Item, Template, Authorization, Price, `ParameterValueFactory` (rich + parameters with type/phase/constraints), Extension/Installation. +- Event fixtures (`Event` / `TaskEvent` / `EventMetadata`). +- API-response helpers for wire mocking: `api_object`, `api_collection` + (`{"data": [...], "$meta": {...}}`), `api_error`, plus an optional `respx` + fixture. +- Depends on SDK follow-ups: models for Listing / Request / PriceList, and + canonical `OrderType` / `OrderStatus` enums (the SDK still types these as + `str`). + +## Pytest plugin conventions + +This package follows the canonical installable-plugin layout +([pytest docs](https://docs.pytest.org/en/stable/how-to/writing_plugins.html), +[`cookiecutter-pytest-plugin`](https://github.com/pytest-dev/cookiecutter-pytest-plugin)): + +- **Entry point.** `[project.entry-points.pytest11]` maps a name to + `mpt_extension_contrib.fixtures.plugin`; pytest auto-discovers the plugin once + the package is installed — no `conftest.py` wiring for consumers. +- **`plugin.py`.** Holds the fixtures (and would hold hooks such as + `pytest_configure` / `pytest_addoption` and custom marker registration, if + any were needed). +- **Package root re-exports the public API** so `from mpt_extension_contrib.fixtures + import OrderFactory` works. +- **Classifier.** `Framework :: Pytest` is declared so the package is + discoverable as a pytest plugin on PyPI. +- **Testing.** The plugin is verified the canonical way — with the `pytester` + fixture running an inline pytest session + ([test_plugin.py](../tests/test_plugin.py)) — in addition to direct unit tests + of each helper. +- **Assertion rewriting.** Not used here (no shipped assertion helpers); if a + helper module ever grows `assert`s for consumers, register it with + `pytest.register_assert_rewrite(...)`. + +### Coverage gotcha (why imports are lazy) + +pytest loads the `pytest11` plugin — and everything it imports — during startup, +**before** `pytest-cov` begins instrumentation. On Python 3.12 coverage uses +`sys.monitoring` (PEP 669), which only measures code imported *after* it starts; +anything imported at plugin-load time reads as 0% and cannot be recovered +in-process. To keep the implementation measurable: + +- `plugin.py` resolves the implementation lazily (`importlib.import_module`) + inside each fixture, so factory/helper code is first imported during the test + session, not at startup. +- `__init__.py` re-exports lazily via a PEP 562 `__getattr__` for the same + reason. This trips lint rules that expect an eager-re-export `__init__` + (`ruff` `RUF067`, `flake8`/wemake `WPS412`/`WPS413`); those are intentionally + ignored for this one file in the root `pyproject.toml`. +- `plugin.py` itself loads at startup and is excluded from coverage + (`*/plugin.py` in `[tool.coverage.report] omit`); its behaviour is covered by + the `pytester` test instead. diff --git a/fixtures/docs/contributing.md b/fixtures/docs/contributing.md new file mode 100644 index 0000000..2342cb4 --- /dev/null +++ b/fixtures/docs/contributing.md @@ -0,0 +1,9 @@ +# Contributing + +Keep the public API under `mpt_extension_contrib.fixtures`. Replace +the generated example helper and update [architecture.md](architecture.md) +before the first release. + +Use package-scoped validation with `pkg=fixtures`. Follow the +repository-wide [contributing workflow](../../docs/contributing.md) for +dependency changes, validation commands, and pre-commit expectations. diff --git a/fixtures/docs/releases.md b/fixtures/docs/releases.md new file mode 100644 index 0000000..d530a86 --- /dev/null +++ b/fixtures/docs/releases.md @@ -0,0 +1,8 @@ +# Releases + +`mpt-extension-contrib-fixtures` is released independently with a +`fixtures-` tag. + +Release this package only when its own API or declared dependencies change. + +Follow the repository-wide [release workflow](../../docs/releases.md). diff --git a/fixtures/docs/technical-design-review.md b/fixtures/docs/technical-design-review.md new file mode 100644 index 0000000..6e06c79 --- /dev/null +++ b/fixtures/docs/technical-design-review.md @@ -0,0 +1,118 @@ +# Technical Design Review — MPT pytest fixtures (MPT-22111) + +Status: proposed · Scope: platform / MPT-API test fixtures · Owner: _TBD (CODEOWNERS)_ + +This document states the problems we want to solve and the requirements the +`mpt-extension-contrib-fixtures` module must meet, and points at the worked +examples in [usage.md](usage.md) and the design in +[architecture.md](architecture.md). + +## Context + +Every MPT extension re-implements the same test data by hand. A survey of the +suites showed heavy, divergent copies of MPT object factories: + +- **adobe** (`swo-adobe-vipm-extension`) — ~25 generic MPT fixtures, ~2000+ dict-key + accesses across ~50 test files. +- **aws** (`swo-aws-extension`) — ~15 generic fixtures, `mpt_error_factory` copied + verbatim, parameter factories tied to product enums. +- **adobe-ef**, **aws-usage** — flat `agreement_payload` dicts (auth / billing + domains, largely orthogonal). +- **installations** — already on typed SDK models, but needs context/mocks. + +Each copy drifts from the real API shape, mixes product-specific values into +platform structure, and duplicates the same error/parameter/enum helpers. + +## Problems to solve + +1. **Duplication.** The same MPT object builders (order, agreement, subscription, + buyer, seller, licensee, listing, parameters, errors) are re-authored per repo. +2. **Untyped drift.** Factories return raw dicts with hand-maintained camelCase + keys, disconnected from the SDK models and the real API payload shape. +3. **Parameter coupling.** Product-specific parameter catalogs are baked into the + structural fixtures — two different axes (platform/status vs consumer catalog) + are tangled together. +4. **Reinvented parametrization.** Scenario/matrix testing is re-built per repo, + per entity. +5. **Two representations.** The dynamic `mpt_api_client` model and the typed SDK + model coexist, and it is unclear which one to build in tests. + +## Requirements + +Functional: + +- R1 — Reusable factories for MPT-shaped objects, installable as a package, public + API under `mpt_extension_contrib.fixtures`. +- R2 — **Platform / MPT-API objects only.** No vendor (Adobe/AWS) API objects and + no product-specific business logic, enums, or parameter catalogs. +- R3 — Parameters are consumer-owned; the library provides structure and injection + seams, not values. +- R4 — One parametrization primitive that works for any entity. +- R5 — Mocking is done at the **SDK layer only** for now: a mocked SDK service + returns a typed factory model. HTTP/wire-level mocking helpers are out of scope + for this iteration. +- R6 — Realistic MPT identifiers per object type (`ORD-…`, `AGR-…`, `LCE-…`). + +Non-functional: + +- R7 — Compatible with Extension SDK ≥ 6.0; factories build the SDK's typed models. +- R8 — Minimum dependencies; no cross-module dependencies. +- R9 — Unit tests with coverage per repo standard (95%+); lint/type clean. +- R10 — Self-discoverable docs and an explicit owner (CODEOWNERS); published to PyPI. + +## How the module addresses this + +- **Typed SDK-model factories (problems 1, 2; R1, R7).** polyfactory `ModelFactory` + subclasses build the SDK's typed `Order` / `Agreement` / `Subscription` with + empty parameters — see [Building models](usage.md#building-models). This removes + the per-repo dict copies and keeps a single typed source of truth. +- **Consumer-owned parameters (problem 3; R3).** Factories default to empty + parameters. The consumer injects a catalog with `parameter_bag`, a build + override, or a factory subclass; the platform axis (order-type presets) is kept + separate from the consumer catalog — see + [Injecting parameters](usage.md#injecting-parameters-the-consumer-axis) and + [Predefined scenarios](usage.md#predefined-scenarios-not-random). +- **One parametrization primitive (problem 4; R4).** `Scenarios(factory, {...})` + works for any entity via `.build()` / `.cases()` / `.fixture()`. A spec is a + plain set of factory field overrides — it can vary any field (status, type, + lines, subscriptions, parameters, ...); parameters are not special-cased. See + [Named scenarios](usage.md#named-scenarios-scenarios). +- **Shared error helper (problem 1).** `mpt_error_factory` — see + [MPT error payloads](usage.md#mpt-error-payloads). +- **One representation, SDK-level mocking (problem 5; R5).** The library + standardises on the typed SDK model. Tests mock the SDK service and return a + factory model directly. The SDK already wraps every API response into a typed + model via `from_payload`, so this matches production; the boundary is described + in [SDK vs API client](architecture.md#sdk-models-vs-the-api-client). `.to_dict()` + is documented as the bridge to the API JSON shape for the future wire-mocking + helpers, but those are not part of this iteration. +- **Zero-wiring adoption (R1).** Ships as a pytest plugin via the `pytest11` entry + point — see [Fixtures provided by the plugin](usage.md#fixtures-provided-by-the-plugin) + and [plugin conventions](architecture.md#pytest-plugin-conventions). +- **Coverage on a startup-loaded plugin (R9).** Lazy plugin/package init keeps + plugin-startup imports measurable on Python 3.12 — see + [Coverage gotcha](architecture.md#coverage-gotcha-why-imports-are-lazy). +- **Demo.** An Order/Agreement [demo test](../tests/test_demo.py) uses the fixtures + in a passing suite. + +## Non-goals + +- Vendor API objects (Adobe/AWS), product-specific parameter values and enums. +- HTTP/wire-level API mocking helpers (e.g. `respx`) — mocking is done at the SDK + service layer for now. +- Pipeline contexts with service mocks, and billing/auth domains. + +See [Design boundaries](architecture.md#design-boundaries). + +## Open questions & follow-ups + +- **R6 — id generation** is on the roadmap ([Roadmap](architecture.md#roadmap)); + confirm the Licensee prefix (`LCE-` per platform docs vs `LIC-` in current SDK tests). +- **Enums.** Canonical `OrderType` / `OrderStatus` should live in the SDK (it still + types these as `str`); this library keeps strings until then. +- **Missing SDK models.** Listing / Request / PriceList have no SDK model yet — + needed before fixtures can build them. +- **Later iterations.** Expanded factory set (Asset, Account/Buyer/Seller/Licensee, + Product/Item, Template, Authorization, Price, `ParameterValueFactory`), Event + fixtures, and — only if a real need appears — wire-level API-response mocking helpers. +- **R10** — assign CODEOWNERS and cut the first PyPI release. diff --git a/fixtures/docs/testing.md b/fixtures/docs/testing.md new file mode 100644 index 0000000..df90f59 --- /dev/null +++ b/fixtures/docs/testing.md @@ -0,0 +1,14 @@ +# Testing + +Tests for `mpt-extension-contrib-fixtures` live under [`../tests/`](../tests), one +file per module (`test_factories.py`, `test_parameters.py`, `test_scenarios.py`, +`test_errors.py`, `test_plugin.py`). + +The plugin is tested the canonical way — `test_plugin.py` uses the `pytester` +fixture to run an inline pytest session that consumes the registered fixtures. +The `plugin.py` entry-point module is loaded during pytest startup, before +coverage instrumentation, so it is excluded from coverage (see +[docs/architecture.md](architecture.md) for the full rationale). + +Use package-scoped test commands with `pkg=fixtures`. See the +repository-wide [testing strategy](../../docs/testing.md). diff --git a/fixtures/docs/usage.md b/fixtures/docs/usage.md new file mode 100644 index 0000000..8908fff --- /dev/null +++ b/fixtures/docs/usage.md @@ -0,0 +1,264 @@ +# Usage + +`mpt-extension-contrib-fixtures` provides pytest fixtures and +[polyfactory](https://polyfactory.litestar.dev/) factories for MPT-shaped +objects (orders, agreements, subscriptions). Factories build **structurally +valid SDK models with empty parameters**; the product-specific parameter +catalog stays in the consumer. + +## Install + +```bash +pip install mpt-extension-contrib-fixtures +# or, with uv +uv add --dev mpt-extension-contrib-fixtures +``` + +The package registers a pytest plugin via the `pytest11` entry point, so its +fixtures are available as soon as it is installed — no `conftest.py` wiring. + +## Fixtures provided by the plugin + +| Fixture | Returns | +| --- | --- | +| `order_factory` | the `OrderFactory` class | +| `agreement_factory` | the `AgreementFactory` class | +| `subscription_factory` | the `SubscriptionFactory` class | +| `parameter_bag` | the `parameter_bag` helper | +| `mpt_error_factory` | the `mpt_error_factory` helper | + +```python +def test_uses_plugin_fixtures(order_factory, parameter_bag): + order = order_factory.purchase(parameters=parameter_bag(ordering={"accountType": "New"})) + + assert order.type == "Purchase" +``` + +For advanced use (subclassing factories, building scenarios), import from the +package root: + +```python +from mpt_extension_contrib.fixtures import ( + AgreementFactory, + OrderFactory, + Scenarios, + SubscriptionFactory, + mpt_error_factory, + parameter_bag, +) +``` + +## Building models + +Each factory is a polyfactory `ModelFactory`. `build()` returns a fully valid +model; pass any field as a keyword to override it. + +```python +order = OrderFactory.build() # random-but-valid, empty parameters +order = OrderFactory.build(status="Querying") +agreement = AgreementFactory.build(name="ACME") +subscription = SubscriptionFactory.build() +``` + +Parameters default to an empty `ParameterBag` everywhere, including nested +models, so factory output never contains random parameter values. + +### Order-type presets (the platform axis) + +Order `type` is an MPT-platform concept, so presets ship with the library: + +```python +OrderFactory.purchase() # type="Purchase" +OrderFactory.change() # type="Change" +OrderFactory.terminate() # type="Termination" +OrderFactory.configuration() # type="Configuration" +``` + +Each accepts the same field overrides as `build()`. + +## Injecting parameters (the consumer axis) + +Parameter values are product-specific (AWS uses `accountType`/`mpaId`, Adobe +uses `companyName`/3YC, …), so the **catalog belongs to the consumer**. The +library only provides the shape and the injection seams. + +### `parameter_bag` helper + +Turn a flat `{external_id: value}` catalog into a `ParameterBag`: + +```python +bag = parameter_bag( + ordering={"accountType": "New", "mpaId": "123456"}, + fulfillment={"phase": "precondition"}, +) +order = OrderFactory.purchase(parameters=bag) +``` + +### Per-test override + +```python +order = OrderFactory.purchase(parameters=parameter_bag(ordering={"accountType": "Migrate"})) +``` + +### Reusable consumer factory subclass + +Keep your catalog in your own test code and overlay it once: + +```python +from polyfactory import Use +from mpt_extension_contrib.fixtures import OrderFactory, parameter_bag + + +class AwsOrderFactory(OrderFactory): + parameters = Use(lambda: parameter_bag(ordering={"accountType": "New", "mpaId": "123456"})) + + +order = AwsOrderFactory.build() # always carries the AWS catalog +order = AwsOrderFactory.build(status="Querying") # structure still overridable +``` + +## Predefined scenarios (not random) + +When a state implies a specific set of parameters (e.g. an AWS *migration* order +is a `Purchase` with a particular catalog), encode it explicitly rather than +relying on random data. + +### Named preset constructors + +```python +class AwsOrderFactory(OrderFactory): + @classmethod + def migration(cls, **overrides): + return cls.purchase( + parameters=parameter_bag(ordering={"accountType": "Migrate", "mpaId": "123456"}), + **overrides, + ) + + +order = AwsOrderFactory.migration() +order = AwsOrderFactory.migration(status="Processing") +``` + +### Deriving parameters from status + +When one factory should "know" the status-to-parameters rule, compute the +parameters from the already-generated fields with polyfactory's +`PostGenerated`: + +```python +from polyfactory import PostGenerated +from mpt_extension_contrib.fixtures import parameter_bag + + +def _aws_params(_name, values, *_): + rules = { + "Processing": parameter_bag(fulfillment={"phase": "precondition"}), + "Querying": parameter_bag(ordering={"accountType": "New"}), + } + return rules.get(values["status"], parameter_bag()) + + +class AwsOrderFactory(OrderFactory): + parameters = PostGenerated(_aws_params) + + +AwsOrderFactory.build(status="Processing") # parameters filled per the rule +``` + +## Named scenarios (`Scenarios`) + +A `Scenarios` binds a factory to a registry of named specs. A spec is just a dict +of **factory field overrides** passed straight to `build` — so a scenario can vary +anything the model has: `status`, `type`, `lines`, `subscriptions`, `template`, +and `parameters` (parameters are one field among many, not a special case). The +same type works for any entity, so there is no per-entity helper. + +```python +from mpt_extension_contrib.fixtures import OrderFactory, Scenarios, parameter_bag + +ORDERS = Scenarios( + OrderFactory, + { + "new_purchase": {"type": "Purchase", "status": "Processing"}, + "querying": {"type": "Purchase", "status": "Querying"}, + "change": {"type": "Change", "lines": [...]}, + # parameters are just another field: + "with_params": { + "type": "Purchase", + "parameters": parameter_bag(ordering={"accountType": "New"}), + }, + }, +) + +# Build one scenario by name (extra kwargs are field overrides): +order = ORDERS.build("change", status="Processing") +``` + +The same shape works for any factory, e.g. `AGREEMENTS = Scenarios(AgreementFactory, {...})`. + +### Run a test for each scenario + +`Scenarios.fixture()` returns a parametrized fixture; any test that requests it +runs once per scenario, with the registry key as the test id: + +```python +# conftest.py +order = ORDERS.fixture() + + +# test_orders.py +def test_fulfillment(order): # runs once per scenario + assert run_flow(order).ok +``` + +Or parametrize explicitly with `Scenarios.cases()` (built models, optional subset): + +```python +@pytest.mark.parametrize("order", ORDERS.cases()) +def test_flow(order): ... + + +@pytest.mark.parametrize("order", ORDERS.cases(only=["querying", "change"])) +def test_subset(order): ... +``` + +### Schema-driven variety + +For coverage of every schema variant (rather than your curated cases), use +polyfactory directly: + +```python +OrderFactory.batch(5) # five random-but-valid orders +list(OrderFactory.coverage()) # minimal set covering enum/union/optional variants +``` + +## Mocking the MPT API (at the SDK layer) + +Factories build typed SDK models (`mpt_extension_sdk.models`). The SDK wraps every +API response into those models via `from_payload`, so mock at the SDK service layer +and return a factory model directly — it matches what production code receives: + +```python +mocker.patch.object(order_service, "get_by_id", return_value=OrderFactory.purchase()) +``` + +`.to_dict()` serialises a model to the exact camelCase JSON the API returns +(useful when a test needs the raw payload): + +```python +payload = OrderFactory.purchase().to_dict() # API-shaped dict +``` + +> HTTP/wire-level mocking (httpx/`respx`) and response-envelope helpers +> (`api_object`, `api_collection`, `api_error`) are **not** part of this module for +> now — mock at the SDK layer. See [architecture.md](architecture.md) for the +> SDK-vs-client boundary and the roadmap. + +## MPT error payloads + +`mpt_error_factory` builds an MPT API error payload (a plain dict, not a model): + +```python +error = mpt_error_factory(400, "Bad Request", "Invalid order") +error = mpt_error_factory(400, "Bad Request", "x", field_errors={"name": ["required"]}) +``` diff --git a/fixtures/mpt_extension_contrib/fixtures/__init__.py b/fixtures/mpt_extension_contrib/fixtures/__init__.py new file mode 100644 index 0000000..4354dfa --- /dev/null +++ b/fixtures/mpt_extension_contrib/fixtures/__init__.py @@ -0,0 +1,64 @@ +"""MPT pytest fixtures and polyfactory model factories. + +Public API for building MPT-shaped test objects. The factories produce +structurally valid SDK models with empty parameters; product-specific parameter +catalogs and scenarios are supplied by the consumer. The pytest plugin +(registered via the ``pytest11`` entry point) also exposes these as ready-to-use +fixtures, with no ``conftest.py`` wiring required. + +Names are resolved lazily (PEP 562): this package is imported during pytest +startup when the plugin registers, so eagerly importing the implementation here +would run its code before coverage instrumentation starts. +""" + +from __future__ import annotations + +import importlib +from types import MappingProxyType +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from mpt_extension_contrib.fixtures.errors import mpt_error_factory + from mpt_extension_contrib.fixtures.factories import ( + AgreementFactory, + MPTModelFactory, + OrderFactory, + ParameterBagFactory, + SubscriptionFactory, + ) + from mpt_extension_contrib.fixtures.parameters import parameter_bag + from mpt_extension_contrib.fixtures.scenarios import Scenarios, Spec + +__all__ = [ + "AgreementFactory", + "MPTModelFactory", + "OrderFactory", + "ParameterBagFactory", + "Scenarios", + "Spec", + "SubscriptionFactory", + "mpt_error_factory", + "parameter_bag", +] + +_EXPORTS = MappingProxyType( + { + "AgreementFactory": "mpt_extension_contrib.fixtures.factories", + "MPTModelFactory": "mpt_extension_contrib.fixtures.factories", + "OrderFactory": "mpt_extension_contrib.fixtures.factories", + "ParameterBagFactory": "mpt_extension_contrib.fixtures.factories", + "SubscriptionFactory": "mpt_extension_contrib.fixtures.factories", + "Scenarios": "mpt_extension_contrib.fixtures.scenarios", + "Spec": "mpt_extension_contrib.fixtures.scenarios", + "parameter_bag": "mpt_extension_contrib.fixtures.parameters", + "mpt_error_factory": "mpt_extension_contrib.fixtures.errors", + }, +) + + +def __getattr__(name: str) -> Any: + """Resolve a public name to its implementation module on first access.""" + module_path = _EXPORTS.get(name) + if module_path is None: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + return getattr(importlib.import_module(module_path), name) diff --git a/fixtures/mpt_extension_contrib/fixtures/errors.py b/fixtures/mpt_extension_contrib/fixtures/errors.py new file mode 100644 index 0000000..506a58b --- /dev/null +++ b/fixtures/mpt_extension_contrib/fixtures/errors.py @@ -0,0 +1,32 @@ +"""MPT API error payload factory. + +An MPT error is an API payload rather than an SDK model, so it is built as a +plain dict instead of through polyfactory. +""" + +from __future__ import annotations + +from typing import Any + + +def mpt_error_factory( + status: int, + title: str, + detail: str, + *, + trace_id: str = "00-traceid-spanid-00", + field_errors: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Build an MPT API error payload. + + ``field_errors`` is added under the ``errors`` key only when provided. + """ + payload: dict[str, Any] = { + "status": status, + "title": title, + "detail": detail, + "traceId": trace_id, + } + if field_errors is not None: + payload["errors"] = field_errors + return payload diff --git a/fixtures/mpt_extension_contrib/fixtures/factories.py b/fixtures/mpt_extension_contrib/fixtures/factories.py new file mode 100644 index 0000000..1f72149 --- /dev/null +++ b/fixtures/mpt_extension_contrib/fixtures/factories.py @@ -0,0 +1,86 @@ +"""Polyfactory factories for MPT SDK models. + +Factories build structurally valid SDK models with empty parameter bags by +default. Product-specific parameters belong to the consumer and are injected at +build time; see ``docs/usage.md`` for the supported injection seams. +""" + +from __future__ import annotations + +from typing import Any, TypeVar + +from mpt_extension_sdk.models import Agreement, Order, ParameterBag, Subscription +from mpt_extension_sdk.models.base import BaseModel +from polyfactory import Use +from polyfactory.factories.pydantic_factory import ModelFactory + +ModelT = TypeVar("ModelT", bound=BaseModel) + + +class ParameterBagFactory(ModelFactory[ParameterBag]): + """Build an empty parameter bag everywhere a ``ParameterBag`` is needed. + + Registered as the default factory for its type so nested models (agreement, + subscription, order) never receive randomly generated parameters. + """ + + __set_as_default_factory_for_type__ = True + __check_model__ = False + + ordering: Any = Use(list) # noqa: WPS110 - field name mirrors the SDK model + fulfillment: Any = Use(list) + + +class MPTModelFactory(ModelFactory[ModelT]): + """Base factory that keeps SDK model defaults. + + With defaults honoured, optional collections (lines, assets, subscriptions) + and parameter bags stay empty; only required fields are generated. + """ + + __is_base_factory__ = True + __use_defaults__ = True + __check_model__ = False + + +class AgreementFactory(MPTModelFactory[Agreement]): + """Build a structurally valid MPT agreement with empty parameters.""" + + +class SubscriptionFactory(MPTModelFactory[Subscription]): + """Build a structurally valid MPT subscription with empty parameters.""" + + +class OrderFactory(MPTModelFactory[Order]): + """Build a structurally valid MPT order with empty parameters. + + Order-type presets cover the platform axis (purchase, change, termination, + configuration). The consumer overlays the product parameter catalog through + the ``parameters`` build override. + """ + + # TODO: replace the literal order types (and statuses passed by consumers) + # with canonical StrEnums once they land in mpt-extension-sdk, where + # ``Order.type`` / ``Order.status`` still carry their own "add enum" TODO. + # Defining those domain enums in this test library would add a copy that + # production code must not import, so they belong in the SDK, not here. + + @classmethod + def purchase(cls, **overrides: Any) -> Order: + """Build a purchase order.""" + return cls.build(type="Purchase", **overrides) + + @classmethod + def change(cls, **overrides: Any) -> Order: + """Build a change order.""" + return cls.build(type="Change", **overrides) + + @classmethod + def terminate(cls, **overrides: Any) -> Order: + """Build a termination order.""" + return cls.build(type="Termination", **overrides) + + @classmethod + def configuration(cls, **overrides: Any) -> Order: + """Build a configuration order.""" + return cls.build(type="Configuration", **overrides) diff --git a/fixtures/mpt_extension_contrib/fixtures/parameters.py b/fixtures/mpt_extension_contrib/fixtures/parameters.py new file mode 100644 index 0000000..7273329 --- /dev/null +++ b/fixtures/mpt_extension_contrib/fixtures/parameters.py @@ -0,0 +1,37 @@ +"""Build MPT parameter bags from a flat consumer catalog. + +Parameter values are product-specific, so the catalog lives in the consumer. +These helpers only turn a flat ``{external_id: value}`` mapping into the SDK +``ParameterBag`` shape. +""" + +from __future__ import annotations + +from typing import Any + +from mpt_extension_sdk.models import ParameterBag +from mpt_extension_sdk.models.parameter import ParameterValue + + +def parameter_bag( + *, + ordering: dict[str, Any] | None = None, + fulfillment: dict[str, Any] | None = None, +) -> ParameterBag: + """Build a ``ParameterBag`` from flat ordering and fulfillment catalogs. + + Each catalog maps a parameter ``externalId`` to its value. Omitted phases + produce an empty list, which matches the SDK default. + """ + return ParameterBag( + ordering=_parameter_values(ordering), + fulfillment=_parameter_values(fulfillment), + ) + + +def _parameter_values(catalog: dict[str, Any] | None) -> list[ParameterValue]: + """Render a phase catalog into a list of ``ParameterValue`` objects.""" + return [ + ParameterValue(external_id=external_id, value=raw_value) + for external_id, raw_value in (catalog or {}).items() + ] diff --git a/fixtures/mpt_extension_contrib/fixtures/plugin.py b/fixtures/mpt_extension_contrib/fixtures/plugin.py new file mode 100644 index 0000000..32049bb --- /dev/null +++ b/fixtures/mpt_extension_contrib/fixtures/plugin.py @@ -0,0 +1,64 @@ +"""Pytest plugin exposing MPT factories and helpers as fixtures. + +Registered through the ``pytest11`` entry point, so installing the package +makes these fixtures available without importing anything in ``conftest.py``. + +The plugin module loads during pytest startup, before coverage instrumentation +begins. Implementation modules are therefore resolved lazily (via +``importlib``) inside each fixture, so their code stays measurable by the test +suite instead of being imported once, uncovered, at startup. +""" + +from __future__ import annotations + +import importlib +from collections.abc import Callable +from typing import TYPE_CHECKING, Any, cast + +import pytest + +if TYPE_CHECKING: + from mpt_extension_contrib.fixtures.factories import ( + AgreementFactory, + OrderFactory, + SubscriptionFactory, + ) + +_FACTORIES = "mpt_extension_contrib.fixtures.factories" +_BAG_MODULE = "mpt_extension_contrib.fixtures.parameters" +_ERRORS = "mpt_extension_contrib.fixtures.errors" + + +@pytest.fixture +def order_factory() -> type[OrderFactory]: + """Return the order factory class for ``build`` or subclassing.""" + module = importlib.import_module(_FACTORIES) + return cast("type[OrderFactory]", module.OrderFactory) + + +@pytest.fixture +def agreement_factory() -> type[AgreementFactory]: + """Return the agreement factory class for ``build`` or subclassing.""" + module = importlib.import_module(_FACTORIES) + return cast("type[AgreementFactory]", module.AgreementFactory) + + +@pytest.fixture +def subscription_factory() -> type[SubscriptionFactory]: + """Return the subscription factory class for ``build`` or subclassing.""" + module = importlib.import_module(_FACTORIES) + return cast("type[SubscriptionFactory]", module.SubscriptionFactory) + + +@pytest.fixture +def parameter_bag() -> Callable[..., Any]: + """Return the parameter-bag builder helper.""" + module = importlib.import_module(_BAG_MODULE) + return cast("Callable[..., Any]", module.parameter_bag) + + +@pytest.fixture +def mpt_error_factory() -> Callable[..., Any]: + """Return the MPT API error payload factory.""" + module = importlib.import_module(_ERRORS) + return cast("Callable[..., Any]", module.mpt_error_factory) diff --git a/fixtures/mpt_extension_contrib/fixtures/py.typed b/fixtures/mpt_extension_contrib/fixtures/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/fixtures/mpt_extension_contrib/fixtures/scenarios.py b/fixtures/mpt_extension_contrib/fixtures/scenarios.py new file mode 100644 index 0000000..3a5fd99 --- /dev/null +++ b/fixtures/mpt_extension_contrib/fixtures/scenarios.py @@ -0,0 +1,65 @@ +"""Named scenario sets for building MPT models and driving pytest parametrization. + +A ``Scenarios`` binds a factory to a registry of named specs. A spec is just a +dict of factory field overrides, passed straight to ``build`` — so a scenario can +vary anything the model has (status, type, lines, subscriptions, template, +parameters, ...). It works for any model factory, so there is no per-entity helper. +""" + +from __future__ import annotations + +from collections.abc import Iterable, Mapping +from typing import Any + +import pytest +from mpt_extension_sdk.models.base import BaseModel +from polyfactory.factories.pydantic_factory import ModelFactory + +Spec = dict[str, Any] + + +class Scenarios[ModelT: BaseModel]: + """A named set of model specs bound to a factory. + + Build a single model by name with :meth:`build`, or fan the whole set out + over pytest with :meth:`cases` (explicit ``parametrize``) or :meth:`fixture` + (a ready parametrized fixture). The registry key is used as the test id. + """ + + def __init__( + self, + factory: type[ModelFactory[ModelT]], + registry: Mapping[str, Spec], + ) -> None: + self._factory = factory + self._registry = dict(registry) + + def build(self, name: str, **overrides: Any) -> ModelT: + """Build the named scenario, applying any extra field overrides. + + The spec and ``overrides`` are passed to the factory as field values, so + a scenario can set any model field (``status``, ``type``, ``lines``, + ``subscriptions``, ``template``, ``parameters``, ...). + """ + return self._factory.build(**{**self._registry[name], **overrides}) + + def cases(self, only: Iterable[str] | None = None) -> list[Any]: + """Return built models as ``pytest.param`` cases for ``parametrize``. + + Pass ``only`` to select a subset of scenario names. + """ + names = list(self._registry) if only is None else list(only) + return [pytest.param(self.build(name), id=name) for name in names] + + def fixture(self) -> Any: + """Return a parametrized pytest fixture yielding each scenario in turn. + + Wire it once in a ``conftest.py`` (``order = ORDERS.fixture()``); any + test requesting the fixture then runs once per registry entry. + """ + + @pytest.fixture(params=list(self._registry), ids=list(self._registry)) + def factory(request: pytest.FixtureRequest) -> ModelT: + return self.build(request.param) + + return factory diff --git a/fixtures/pyproject.toml b/fixtures/pyproject.toml new file mode 100644 index 0000000..6ef42bf --- /dev/null +++ b/fixtures/pyproject.toml @@ -0,0 +1,34 @@ +[project] +name = "mpt-extension-contrib-fixtures" +version = "0.0.0" +description = "Pytest fixtures and polyfactory factories for MPT-shaped objects (orders, agreements, subscriptions)." +readme = "README.md" +requires-python = ">=3.12" +classifiers = [ + "Framework :: Pytest", +] +dependencies = [ + "mpt-extension-sdk>=6.3,<7", + "polyfactory>=2.22,<3", + "pytest>=8,<10", +] + +[dependency-groups] +dev = [] + +[project.entry-points.pytest11] +mpt_extension_contrib_fixtures = "mpt_extension_contrib.fixtures.plugin" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["mpt_extension_contrib"] + +[tool.hatch.build.targets.sdist] +include = [ + "LICENSE", + "mpt_extension_contrib/fixtures", + "README.md", +] diff --git a/fixtures/tests/conftest.py b/fixtures/tests/conftest.py new file mode 100644 index 0000000..c6481d5 --- /dev/null +++ b/fixtures/tests/conftest.py @@ -0,0 +1 @@ +pytest_plugins = ["pytester"] diff --git a/fixtures/tests/test_demo.py b/fixtures/tests/test_demo.py new file mode 100644 index 0000000..0047562 --- /dev/null +++ b/fixtures/tests/test_demo.py @@ -0,0 +1,69 @@ +"""Readable demo of the fixtures library on orders and agreements. + +Each test reads top-to-bottom as an example; it mirrors docs/usage.md and doubles +as the MPT-22111 demo (fixtures used in a passing test suite). +""" + +from mpt_extension_contrib.fixtures import ( + AgreementFactory, + OrderFactory, + Scenarios, + mpt_error_factory, + parameter_bag, +) + + +def test_build_a_purchase_order() -> None: + result = OrderFactory.purchase() + + assert result.type == "Purchase" + assert result.parameters.ordering == [] + + +def test_inject_product_parameters() -> None: + order = OrderFactory.purchase( + parameters=parameter_bag(ordering={"accountType": "New", "mpaId": "123456"}), + ) + + result = order.parameters.get_ordering_value("accountType") + + assert result == "New" + + +def test_build_an_agreement_with_nested_models() -> None: + result = AgreementFactory.build() + + assert result.client is not None + assert result.licensee is not None + assert result.product is not None + + +def test_named_scenarios_drive_a_test_matrix() -> None: + orders = Scenarios( + OrderFactory, + { + "new_purchase": {"type": "Purchase", "status": "Processing"}, + "change": {"type": "Change", "status": "Querying"}, + }, + ) + + result = orders.build("change") + + assert result.type == "Change" + assert result.status == "Querying" + + +def test_to_dict_bridge_yields_api_shaped_json() -> None: + order = OrderFactory.purchase() + + result = order.to_dict() + + assert result["type"] == "Purchase" + assert isinstance(result["parameters"], dict) + + +def test_mpt_error_payload() -> None: + result = mpt_error_factory(400, "Bad Request", "Invalid order") + + assert result["status"] == 400 + assert result["title"] == "Bad Request" diff --git a/fixtures/tests/test_errors.py b/fixtures/tests/test_errors.py new file mode 100644 index 0000000..d0eb789 --- /dev/null +++ b/fixtures/tests/test_errors.py @@ -0,0 +1,21 @@ +from mpt_extension_contrib.fixtures import mpt_error_factory + + +def test_error_has_core_fields() -> None: + result = mpt_error_factory(400, "Bad Request", "Invalid order") + + assert result["status"] == 400 + assert result["title"] == "Bad Request" + assert result["detail"] == "Invalid order" + + +def test_error_omits_errors_when_absent() -> None: + result = mpt_error_factory(400, "Bad Request", "Invalid order") + + assert "errors" not in result + + +def test_error_includes_field_errors_when_given() -> None: + result = mpt_error_factory(400, "Bad Request", "x", field_errors={"name": ["required"]}) + + assert result["errors"] == {"name": ["required"]} diff --git a/fixtures/tests/test_factories.py b/fixtures/tests/test_factories.py new file mode 100644 index 0000000..37dd6ed --- /dev/null +++ b/fixtures/tests/test_factories.py @@ -0,0 +1,47 @@ +from mpt_extension_contrib.fixtures import OrderFactory, parameter_bag + + +def test_build_has_empty_parameters() -> None: + result = OrderFactory.build() + + assert result.parameters.ordering == [] + assert result.parameters.fulfillment == [] + + +def test_nested_agreement_params_empty() -> None: + result = OrderFactory.build() + + assert result.agreement.parameters.ordering == [] + + +def test_purchase_preset_sets_type() -> None: + result = OrderFactory.purchase() + + assert result.type == "Purchase" + + +def test_change_preset_sets_type() -> None: + result = OrderFactory.change() + + assert result.type == "Change" + + +def test_terminate_preset_sets_type() -> None: + result = OrderFactory.terminate() + + assert result.type == "Termination" + + +def test_configuration_preset_sets_type() -> None: + result = OrderFactory.configuration() + + assert result.type == "Configuration" + + +def test_build_accepts_parameter_override() -> None: + bag = parameter_bag(ordering={"accountType": "New"}) + + result = OrderFactory.purchase(parameters=bag) + + assert result.type == "Purchase" + assert result.parameters.get_ordering_value("accountType") == "New" diff --git a/fixtures/tests/test_parameters.py b/fixtures/tests/test_parameters.py new file mode 100644 index 0000000..11b86fa --- /dev/null +++ b/fixtures/tests/test_parameters.py @@ -0,0 +1,21 @@ +from mpt_extension_contrib.fixtures import parameter_bag + + +def test_parameter_bag_builds_ordering() -> None: + result = parameter_bag(ordering={"accountType": "New", "mpaId": "123"}) + + assert result.get_ordering_value("accountType") == "New" + assert result.get_ordering_value("mpaId") == "123" + + +def test_parameter_bag_builds_fulfillment() -> None: + result = parameter_bag(fulfillment={"phase": "precondition"}) + + assert result.get_fulfillment_value("phase") == "precondition" + + +def test_parameter_bag_defaults_to_empty() -> None: + result = parameter_bag() + + assert result.ordering == [] + assert result.fulfillment == [] diff --git a/fixtures/tests/test_plugin.py b/fixtures/tests/test_plugin.py new file mode 100644 index 0000000..2e7bb88 --- /dev/null +++ b/fixtures/tests/test_plugin.py @@ -0,0 +1,27 @@ +import pytest + + +def test_plugin_registers_fixtures(pytester: pytest.Pytester) -> None: + pytester.makepyfile( + """ + def test_order_factory(order_factory): + assert order_factory.purchase().type == "Purchase" + + def test_agreement_factory(agreement_factory): + assert agreement_factory.build().name + + def test_subscription_factory(subscription_factory): + assert subscription_factory.build().id + + def test_parameter_bag(parameter_bag): + bag = parameter_bag(ordering={"accountType": "New"}) + assert bag.get_ordering_value("accountType") == "New" + + def test_mpt_error_factory(mpt_error_factory): + assert mpt_error_factory(404, "Not Found", "missing")["status"] == 404 + """, + ) + + result = pytester.runpytest_subprocess() + + result.assert_outcomes(passed=5) diff --git a/fixtures/tests/test_scenarios.py b/fixtures/tests/test_scenarios.py new file mode 100644 index 0000000..eb6c4f8 --- /dev/null +++ b/fixtures/tests/test_scenarios.py @@ -0,0 +1,59 @@ +from mpt_extension_contrib.fixtures import OrderFactory, Scenarios, parameter_bag +from mpt_extension_sdk.models import Order + +ORDERS = Scenarios( + OrderFactory, + { + "purchase": {"type": "Purchase"}, + "change": {"type": "Change", "status": "Processing"}, + "with_params": { + "type": "Purchase", + "parameters": parameter_bag(ordering={"accountType": "New"}), + }, + }, +) + +order = ORDERS.fixture() + + +def test_build_applies_spec_fields() -> None: + result = ORDERS.build("change") + + assert result.type == "Change" + assert result.status == "Processing" + + +def test_build_passes_overrides() -> None: + result = ORDERS.build("purchase", status="Querying") + + assert result.status == "Querying" + + +def test_parameters_are_just_a_field() -> None: + result = ORDERS.build("with_params") + + assert result.parameters.get_ordering_value("accountType") == "New" + + +def test_cases_uses_names_as_ids() -> None: + result = ORDERS.cases() + + assert [case.id for case in result] == ["purchase", "change", "with_params"] + + +def test_cases_filters_with_only() -> None: + result = ORDERS.cases(only=["change"]) + + assert [case.id for case in result] == ["change"] + + +def test_cases_yields_built_models() -> None: + result = ORDERS.cases(only=["change"]) + + assert result[0].values[0].type == "Change" + + +def test_fixture_runs_per_case(order: Order) -> None: + result = order.type + + assert result in {"Purchase", "Change"} diff --git a/make/common.mk b/make/common.mk index 9b570f0..90ab952 100644 --- a/make/common.mk +++ b/make/common.mk @@ -2,7 +2,7 @@ DC = docker compose -f compose.yaml RUN = $(DC) run --rm app RUN_IT = $(DC) run --rm -it app -PACKAGES := shared custom-notifications due-date +PACKAGES := shared fixtures custom-notifications due-date TARGETS := $(if $(pkg),$(pkg),$(PACKAGES)) LINT_TARGETS := $(if $(pkg),$(pkg),$(PACKAGES) tests scripts) TYPE_TARGETS := $(if $(pkg),$(pkg),$(PACKAGES) scripts) diff --git a/pyproject.toml b/pyproject.toml index 209f8aa..fe5e780 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,7 @@ [tool.uv.workspace] members = [ "shared", + "fixtures", "custom-notifications", "due-date", ] @@ -129,12 +130,17 @@ convention = "google" "PLC2701", "SLF001", ] +# The fixtures package is a pytest plugin: it is imported at pytest startup, so +# its __init__ resolves names lazily (PEP 562 __getattr__) to stay measurable by +# coverage instead of eagerly re-exporting at import time. +"fixtures/mpt_extension_contrib/fixtures/__init__.py" = ["RUF067"] [tool.pytest.ini_options] testpaths = [ "tests", "scripts/tests", "shared/tests", + "fixtures/tests", "custom-notifications/tests", "due-date/tests", ] @@ -157,6 +163,7 @@ exclude_also = [ ] omit = [ "__init__.py", + "*/plugin.py", ] [tool.flake8] @@ -185,6 +192,9 @@ statistics = false per-file-ignores = [ "/*.py: WPS412", "*tests/**: WPS202, WPS211, WPS432", + # fixtures is a pytest plugin: its __init__ uses a lazy PEP 562 __getattr__ so + # plugin-startup import stays measurable by coverage (see ruff RUF067 ignore). + "fixtures/mpt_extension_contrib/fixtures/__init__.py: WPS412, WPS413", ] [tool.mypy] @@ -208,6 +218,7 @@ ignore_missing_imports = true local_partial_types = true mypy_path = [ "shared", + "fixtures", "custom-notifications", "due-date", ] diff --git a/uv.lock b/uv.lock index fb66993..d654959 100644 --- a/uv.lock +++ b/uv.lock @@ -12,6 +12,7 @@ resolution-markers = [ members = [ "mpt-extension-contrib-custom-notifications", "mpt-extension-contrib-due-date", + "mpt-extension-contrib-fixtures", "mpt-extension-contrib-shared", ] @@ -701,6 +702,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, ] +[[package]] +name = "faker" +version = "40.23.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/d6/fc071e5754815d9058e12ab549cc88e90f8f4ecf4dc33b6b750cdf4b622d/faker-40.23.0.tar.gz", hash = "sha256:f135e563f1f95f19346bb680bc2e43570bc43b7893e566023746f51f32c69dfc", size = 1972975, upload-time = "2026-06-10T20:53:21.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/5f/824e6fb3e9d63408151dc9173994fa65bde620a67dde3a59354f5aecd497/faker-40.23.0-py3-none-any.whl", hash = "sha256:775922453e54afa42eaf60eac478fa3a969357f224d09a8022b93e3ad88f18ae", size = 2013046, upload-time = "2026-06-10T20:53:19.226Z" }, +] + [[package]] name = "fastapi" version = "0.136.3" @@ -1491,6 +1504,26 @@ requires-dist = [ [package.metadata.requires-dev] dev = [{ name = "freezegun", specifier = "==1.5.*" }] +[[package]] +name = "mpt-extension-contrib-fixtures" +version = "0.0.0" +source = { editable = "fixtures" } +dependencies = [ + { name = "mpt-extension-sdk" }, + { name = "polyfactory" }, + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "mpt-extension-sdk", specifier = ">=6.3,<7" }, + { name = "polyfactory", specifier = ">=2.22,<3" }, + { name = "pytest", specifier = ">=8,<10" }, +] + +[package.metadata.requires-dev] +dev = [] + [[package]] name = "mpt-extension-contrib-shared" version = "0.0.0" @@ -1958,6 +1991,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/59/2d/d741fbbbcba7eb6f9f1a829373c558114d59cb882b95f941aa0dc060861f/plumbum-2.0.1-py3-none-any.whl", hash = "sha256:27a454980f91689aae8f18242a36daaf2636219171cf0e6a849744aa1d6fff85", size = 164460, upload-time = "2026-06-08T14:44:04.75Z" }, ] +[[package]] +name = "polyfactory" +version = "2.22.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "faker" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/15/c6/517137955bb3764e813edfa0086e8b3675dc126c49428e8482a70018ec33/polyfactory-2.22.5.tar.gz", hash = "sha256:d641d8c10c7d3e1f0f862ba2733ad1523069af58614bb1c077aeccabb66d26b7", size = 264946, upload-time = "2025-11-15T09:52:39.863Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/87/6cd69c43e86bc5863f93dba3c9fb42ee4fda36a0fe5aa3cbd25001fb26f8/polyfactory-2.22.5-py3-none-any.whl", hash = "sha256:822d1af463520153200b4b62b06b0dc73a1d5edc8911e2e63ed0757ae21cc2b3", size = 63934, upload-time = "2025-11-15T09:52:37.956Z" }, +] + [[package]] name = "pre-commit" version = "4.6.0" @@ -3100,6 +3146,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] +[[package]] +name = "tzdata" +version = "2026.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" }, +] + [[package]] name = "uc-micro-py" version = "2.0.0"