# Integration Tests

End-to-end HTTP tests for the ows-carveouts service, written in Python/pytest.

Unlike the PHP unit tests (which mock collaborators and assert on controller
internals), these tests hit a **deployed** instance of the service over HTTP and
assert on real responses. They are the safety net that catches routing,
serialization, validation, and persistence regressions that unit tests cannot.

---

## TL;DR

```bash
# From the repo root.

# Safe: every test except the data-mutating write lifecycle tests.
make test_integration_safe

# Full suite (write tests still skip unless you export the TEST_WRITE_* vars).
make test_integration
```

By default the suite targets **QA**
(`https://qa-ows-carveouts.theorchard.io`). Override with `BASE_URL`.

---

## Layout

```
tests/Integration/
├── conftest.py                    # shared pytest fixtures (the `http` session)
├── pytest.ini                     # registered markers
├── requirements.txt               # pytest + requests
├── consts/
│   └── api.py                     # endpoint URLs + env-overridable test data
├── utils/
│   └── __init__.py                # assert_ok() helper
├── test_api.py                    # read endpoints: release/vendor/subaccount/combined
├── test_stores.py                 # /store/{id}, /stores/{classification}, /stores/grats
├── test_projection.py             # /carveout/projection/{upc}/dms/{dms_id}
├── test_structured_territory.py   # /carveout/{upc}/territory/structured (+ GRASS auth)
├── test_health_and_negative.py    # health check, CORS, Correlation-Id, 404 routing
├── test_write_validation.py       # write endpoints: 400 guard paths (no mutation)
└── test_write_lifecycle.py        # write endpoints: create→verify→delete round-trips
```

### How imports resolve

`tests/Integration/__init__.py` makes `Integration` a package. pytest adds the
`tests/` directory to `sys.path`, so modules import as
`from Integration.consts import api` and `from Integration.utils import assert_ok`.
Run pytest from inside `tests/Integration` (as the Makefile does) or from `tests/`.

---

## Markers

Registered in [`pytest.ini`](pytest.ini). Select with `pytest -m <marker>`.

| Marker     | Meaning                                                              |
| ---------- | ------------------------------------------------------------------- |
| `smoke`    | Fast "is the service up?" read checks (e.g. the health endpoint).   |
| `read`     | Read-only endpoint tests. Safe against shared environments.         |
| `negative` | Invalid-input / error-path tests. **Do not mutate data** (they 400/404 before any write). |
| `write`    | Tests that **create / update / delete** data. Require teardown.     |

Useful selections:

```bash
pytest -m "not write"      # everything safe for shared envs (what test_integration_safe runs)
pytest -m smoke            # quick uptime check
pytest -m "read or negative"
```

---

## Coverage map

Every route in [`src/Loaders/RoutesLoader.php`](../../src/Loaders/RoutesLoader.php),
and which test file covers it:

### Read endpoints (safe, always run)

| Endpoint | Test file |
| --- | --- |
| `GET /carveout/release/{upc}` (+ `/dms`, `/territory`, `/territory/{dms}`) | `test_api.py` |
| `GET /carveout/vendor/{id}` (+ `/dms`, `/territory`, `/territory/{dms}`) | `test_api.py` |
| `GET /carveout/subaccount/{id}` (+ `/dms`, `/territory`, `/territory/{dms}`) | `test_api.py` |
| `GET /carveout/{upc}` (combined) (+ `/dms`, `/territory`, `/territory/{dms}`) | `test_api.py` |
| `GET /carveout/projection/{upc}/dms/{dms_id}` | `test_projection.py` |
| `GET /carveout/{upc}/territory/structured` | `test_structured_territory.py` |
| `GET /stores` | `test_api.py` |
| `GET /store/{store_id}` | `test_stores.py` |
| `GET /stores/{classification}` (`physical`, `nr-performer`, `nr-owner`) | `test_stores.py` |
| `GET /stores/grats` | `test_stores.py` |
| `GET /hello/` (health check) | `test_health_and_negative.py` |

### Cross-cutting behaviour (safe, always run)

| Behaviour (wired in [`app.php`](../../app.php)) | Test file |
| --- | --- |
| CORS `Access-Control-Allow-Origin` on responses | `test_health_and_negative.py` |
| CORS `OPTIONS` preflight handling | `test_health_and_negative.py` |
| `Correlation-Id` request header echoed back | `test_health_and_negative.py` |
| Unknown route → JSON 404 | `test_health_and_negative.py` |
| Non-numeric `upc` fails the `\d+` route assert → 404 | `test_health_and_negative.py`, `test_projection.py`, `test_stores.py` |
| GRASS header validation → 400 | `test_structured_territory.py` |

### Write endpoints — validation guard paths (safe, always run)

These return **400 before touching the database**, so they are safe to run
anywhere. Covered in `test_write_validation.py`:

| Endpoint | Guard tested |
| --- | --- |
| `POST/DELETE /admin/bulkupdate/product/store` | schema-invalid + empty body |
| `POST /admin/bulkupdate/product/storereplace` | schema-invalid + empty body |
| `POST/DELETE /admin/bulkupdate/product/substore` | schema-invalid + empty body |
| `POST/DELETE /admin/bulkupdate/product/territory` | schema-invalid + empty body |
| `POST /admin/bulkupdate/product/territoryreplace` | schema-invalid + empty body |
| `POST/DELETE /admin/bulkupdate/product/defaultstore` | schema-invalid + empty body |
| `POST /admin/bulkupdate/product/defaultstorereplace` | schema-invalid + empty body |
| `PUT /carveout/{upc}/territory` | missing required headers + invalid body |
| `POST /carveout/product/{id}/copy/{new_id}` | rejects GRASS-authenticated callers |

### Write endpoints — lifecycle round-trips (skipped by default)

Covered in `test_write_lifecycle.py`. Each test creates a carveout, verifies it
through the read API, then deletes it and re-reads to confirm removal. Cleanup
is a **best-effort finalizer** (registered up front) rather than a re-asserting
`finally` block, so a failure in the test body is never masked by a failing
cleanup delete:

| Test | Endpoints | Required env |
| --- | --- | --- |
| `test_store_carveout_lifecycle` | POST/DELETE `/admin/bulkupdate/product/store` | product, upc, store id, distro types |
| `test_replace_store_carveout` | POST `…/storereplace`, DELETE `…/store` | product, upc, store id, distro types |
| `test_territory_carveout_lifecycle` | POST/DELETE `…/territory` | product, upc, country id |
| `test_default_store_carveout_round_trip` | POST/DELETE `…/defaultstore` | product, upc, distro types |
| `test_substore_carveout_round_trip` | POST/DELETE `…/substore` | product, upc, substore id |
| `test_put_territory_set_and_clear` | PUT `/carveout/{upc}/territory` | product, upc, country id |

### Not yet covered (intentionally)

`POST /carveout/product/{id}/carveout-default-phys-dms` and
`POST /carveout/product/{id}/default-video-dms` have happy paths that depend on
external services (ows-product, ows-account, ows-features) and write data that is
not cleanly reversible from the test alone. Their safe guard paths could be added;
full lifecycle coverage was deferred rather than ship flaky or data-leaking tests.

---

## Environment variables

All are optional; defaults target QA with known-good sample data.

### Targeting / transport

| Variable | Default | Purpose |
| --- | --- | --- |
| `BASE_URL` | `https://qa-ows-carveouts.theorchard.io` | Service under test. |
| `REQUEST_TIMEOUT` | `30` | Per-request timeout (seconds). |

### Read-test data (override to target a different dataset)

`TEST_UPC`, `TEST_TERRITORY_UPC`, `TEST_SUBSTORE_UPC`, `TEST_VENDOR_ID`,
`TEST_SUBSTORE_VENDOR_ID`, `TEST_SUBSTORE_DMS_ID`, `TEST_SUBACCOUNT_ID`,
`TEST_SUBACCOUNT_DMS_ID`, `TEST_DMS_ID`, `TEST_STORE_ID`. See
[`consts/api.py`](consts/api.py) for defaults.

### Write-test data (enables the `write` lifecycle tests)

The lifecycle tests **skip** unless the relevant variables are set. Use a
**throwaway product/release that is safe to mutate** on your target environment.

| Variable | Used by | Example |
| --- | --- | --- |
| `TEST_WRITE_PRODUCT_ID` | all write tests | `123456` |
| `TEST_WRITE_UPC` | all write tests | `191018928940` |
| `TEST_WRITE_STORE_ID` | store / replace-store | `496` |
| `TEST_WRITE_DISTRIBUTION_TYPE_IDS` | store / default-store (comma-separated) | `1,3` |
| `TEST_WRITE_COUNTRY_ID` | territory / put-territory | `58` |
| `TEST_WRITE_SUBSTORE_ID` | substore | `58` |
| `TEST_WRITE_NEW_PRODUCT_ID` | (reserved for copy) | `123457` |
| `TEST_ORCHARD_USER_ID` | write attribution header | `oa:1` |

Each write test has a per-test `skipif` listing exactly which variables it needs,
so you can enable a subset (e.g. only store tests) by setting only those vars.

---

## Running locally

### With the Makefile (creates a venv for you)

```bash
make test_integration_safe   # -m "not write"
make test_integration        # full suite
```

### Manually

```bash
cd tests/Integration
python -m venv env && . env/bin/activate
pip install -r requirements.txt

pytest -m "not write"                          # safe subset
BASE_URL=https://qa-ows-carveouts.theorchard.io pytest   # explicit target
pytest test_stores.py -v                        # one file
pytest -k projection                            # by name
pytest -rs                                       # show skip reasons
```

### Enabling the write lifecycle tests

```bash
cd tests/Integration && . env/bin/activate

export TEST_WRITE_PRODUCT_ID=123456
export TEST_WRITE_UPC=191018928940
export TEST_WRITE_STORE_ID=496
export TEST_WRITE_DISTRIBUTION_TYPE_IDS=1,3
export TEST_WRITE_COUNTRY_ID=58
export TEST_WRITE_SUBSTORE_ID=58

pytest -m write -v
```

:warning: Point write tests at QA or a dev environment, **never production**, and
only with a product/release you own and can freely mutate.

---

## CI

The Jenkins pipeline ([`Jenkinsfile`](../../Jenkinsfile), "Integration Tests"
stage) runs `make ci_test_integration`, which builds the `test-integration`
docker service and runs the suite. The write lifecycle tests skip in CI because
the `TEST_WRITE_*` variables are not set there — CI exercises all read, negative,
and write-validation tests.

---

## Conventions for new tests

- **Pick a marker.** `read`/`smoke` for safe GETs, `negative` for error paths
  that must not mutate, `write` for anything that creates/updates/deletes. This
  is enforced: a `conftest` guard fails any test that makes a *successful* (2xx)
  POST/PUT/PATCH/DELETE without the `write` marker, so a forgotten marker can't
  silently mutate shared QA under `-m "not write"`. (Negative tests may issue
  mutating verbs as long as they get a 4xx.)
- **Use the `http` fixture** (shared session + default timeout) rather than
  calling `requests` directly.
- **Use the assertion helpers** in `Integration.utils`: `assert_ok(response)`
  for the common 200 case and `assert_status(response, code)` for others (204,
  400, …); both surface status, reason, URL, and body on failure.
- **Add endpoint URLs and test data to `consts/api.py`**, making data
  env-overridable so the suite can target other environments.
- **Write tests must clean up** via a best-effort finalizer (see
  `_register_cleanup` in `test_write_lifecycle.py`) and be guarded by a
  `skipif` on their required `TEST_WRITE_*` variables — never hard-code mutable
  data that would run against a shared environment by default.
