# Tests

## Structure

```
tests/
├── bootstrap.php
├── Functional/
│   ├── BaseWebTestCase.php
│   └── Controller/AcquiringController/
│       ├── AcquiringControllerPurchaseTest.php
│       ├── AcquiringControllerPreAuthorizationTest.php
│       ├── AcquiringControllerCaptureTest.php
│       └── AcquiringControllerRefundTest.php
├── Unit/
│   └── Builder/
│       ├── AcquiringResponseBuilderTest.php
│       ├── ConfigurationBuilderTest.php
│       └── GatewayBuilderTest.php
├── Helper/
│   ├── Constants/AcquiringTestConstants.php
│   ├── Factory/                      # GatewayRequestFactory, CardRequestFactory, InitRequestFactory, TopUpRequestFactory, ThreeDomainRequestFactory
│   └── Responses/
│       ├── CardServiceResponses.php
│       ├── BinServiceResponses.php
│       ├── StoreServiceResponses.php
│       ├── GatewayServiceResponses.php
│       ├── AccountServiceResponses.php
│       └── TransactionServiceResponses.php
└── Mock/
    ├── InstanceProviderMock.php
    ├── RabbitMqProducerMock.php
    └── PayneticsLogManagerMock.php
```

## BaseWebTestCase

All functional tests extend `tests/Functional/BaseWebTestCase.php`.

**What it provides:**
- Boots Symfony kernel in `test` environment
- Creates a `GuzzleHttp\Handler\MockHandler` (`$this->httpClientHandler`) and injects it into the DI container, intercepting all outbound HTTP calls
- `generateAuthHeaders(string $operation, array $body = []): array` — HMAC-SHA256 signed headers (key: `test_api_key`, secret: `test_api_secret`)
- `assertResponseSuccess(?array $expectedData = null): void` — asserts HTTP 2xx + `code: 0`; optional `$expectedData` values can be callables for custom assertions
- `assertResponseError(int $code, ?string $expectedMessage = null, ?array $expectedErrors = null): void` — asserts expected error code/message/field errors
- `getDecodedJsonResponse(): ?array` — returns decoded JSON body
- `buildUrl(string $uri, array $params, array $getParams = []): string` — replaces `{param}` placeholders and appends query string

## Queueing mock HTTP responses

Responses are consumed **FIFO** in the order the application makes outbound HTTP calls:

```php
$this->httpClientHandler->append(
    new Response(200, [], CardServiceResponses::create()),
    new Response(200, [], BinServiceResponses::check()),
    new Response(200, [], StoreServiceResponses::find()),
    // ... one entry per outbound HTTP call, in call order
);
```

If the queue runs out, MockHandler throws — a mismatched queue count is always a test bug.

## Asserting the outbound requests (not just responses)

`BaseWebTestCase` records every outbound request (Guzzle history middleware) into `$this->httpHistory`. To assert *what the app sent* — path, method, body fields — alongside the response it gets back, declare the whole conversation in one list with `expectHttpExchanges()` instead of bare `append()`:

```php
$this->expectHttpExchanges(
    HttpExchange::post('/v1/cards', CardServiceResponses::create(), body: [
        'card_number' => AcquiringTestConstants::CARD_NUMBER_FULL,
    ]),
    HttpExchange::get('/v1/bin/', BinServiceResponses::check()),
    HttpExchange::get('/v1/stores', StoreServiceResponses::find(), query: [
        'merchant' => AcquiringTestConstants::MERCHANT_TOKEN,
        'instance' => AcquiringTestConstants::INSTANCE_TOKEN,
    ]),
    HttpExchange::post('/atos/gicc/ecomm/avs', GatewayServiceResponses::avs(), body: [
        'pan' => AcquiringTestConstants::CARD_NUMBER_FULL,
        'amount' => AcquiringTestConstants::AVS_GATEWAY_AMOUNT, // builder zeroes it
    ]),
);
```

Each `HttpExchange` (`tests/Helper/HttpExchange.php`) is built via `::get/::post/::put/::delete`. Only the path and response are required; `query` and `body` are optional **named** params — pass just the ones a call needs. The expectations are verified in `assertPostConditions()` after the request returns (never inside the HTTP call, so a failed assertion can't be swallowed by the app's exception handling). The check asserts: exact call count, each call's method + path (substring), and that the `body`/`query` fields match. Body keys are the **outbound** serialized names (e.g. gateway uses `pan`, not `card_number`). Query is parsed with `parse_str`, so repeated params (`system[0]=…&system[1]=…`) arrive as an array and assert against an array value.

Matching is **exact by default**: when you list `body`/`query` fields, the request must contain *exactly* those keys — a missing key, a wrong value, **or an unexpected extra key** all fail. Pass `exactBody: false` / `exactQuery: false` to relax that dimension to a subset check (only the listed keys are verified, extras ignored) — useful for fat DTOs like the gateway payload where you only care about a few fields. An empty `body`/`query` is never checked at all.

Tests that only care about responses keep using `$this->httpClientHandler->append(...)` directly — registering no expectations, they skip the post-condition check.

## Helper/Responses

Stateless classes with static methods returning `json_encode(['code' => 0, 'data' => [...]])`.

| Class | Methods |
|---|---|
| `CardServiceResponses` | `create()`, `findInternal($merchant, $withCardDetails)`, `find()`, `update()` — pass `$withCardDetails=true` so `findInternal` also returns cardholder name + expiry (needed by the saved-card `/v1/init` flow, which validates the `card` group after loading) |
| `BinServiceResponses` | `check()` — returns card scheme `visa`, source `atos` |
| `StoreServiceResponses` | `find()`, `notFound()`, `findWithNoActiveTerminal()` |
| `GatewayServiceResponses` | `purchase()`, `capture()`, `refund()`, `refundFailed()`, `diagnostics()`, `diagnosticsFailed()` |
| `AccountServiceResponses` | `merchantFindByApiKey()`, `merchantFindByToken()`, `instanceFindByToken()` |
| `TransactionServiceResponses` | `create()`, `update()`, `find(token, isCaptured, status, typeId, amount, currency, refundableAmount)` |

When adding a new external service call, add a matching response class here.

## Helper/Constants

`AcquiringTestConstants` holds shared test data: amounts, currencies, card numbers (Visa `4111111111111111`), tokens, STAN, auth codes, response codes, and merchant/instance identifiers. Use these constants instead of inline strings in tests.

## Helper/Factory

Builders for request/payload `array`s that more than one test case shares — both the body a test sends *in* and the outbound bodies/queries the app is expected to *forward* for it. Extract here (rather than duplicating fixtures across cases) when the same input/expected-output pair recurs, so the "what we send / what the app forwards" pairs live in one place and a test reads as a single conversation.

One factory per operation/controller group. Each exposes request-body builders (`validCardBody()`, `savedCardBody()`, …) and expected-outbound builders (`expected*Body()`, `expected*Query()`) asserted via `expectHttpExchanges()`.

| Class | Covers |
|---|---|
| `GatewayRequestFactory` | the eight card-payment ops sharing the ~60-field gateway body: purchase, pre-authorization, capture, refund, reversal, credit, OCT and AVS (`AcquiringControllerAvsTest`). A private `gatewayBody(...)` builder takes the per-op deltas (amount, card data, recurring/reversal flags, processor, configuration + the three trailing nested objects); per-op wrappers (`expectedPurchaseGatewayBody()`, `expectedAvsGatewayBody($body)`, …) pin only the fields that differ, and `expectedStoreQuery()`/`expectedCardCreateBody()`/etc. cover the shared upstream calls |
| `CardRequestFactory` | CardController create/update/details/show + the shared `instance`+`merchant` query |
| `InitRequestFactory` | InitController init + hosted-page (`web`) flows |
| `TopUpRequestFactory` | TopUpController top-up + complete (3DS) flows |
| `ThreeDomainRequestFactory` | ThreeDomainController authenticate/enrollment/result |

Methods are `static` and built from `AcquiringTestConstants`. Values not in `AcquiringTestConstants` that are specific to one factory live as `public const` on that factory (e.g. `GatewayRequestFactory::GATEWAY_PATH_*`, MPI protocol defaults) rather than bloating the shared constants. An expected-payload field that is dynamic (timestamp, generated ULID/UUID) is a `\Closure` running a `PHPUnit\Framework\Assert` check on the actual value — the same callable convention `HttpExchange` body/query matching accepts.

Tiny one-off fixtures (a single CRUD body, a lone pagination query) stay as private static helpers in their own test class — only extract to a factory when the input/expected-output pair genuinely recurs across cases.

## Mock classes

| Class | Purpose |
|---|---|
| `InstanceProviderMock` | Returns a mock `Instance`/`Merchant` for `test_api_key`; all operation access flags are `true` |
| `RabbitMqProducerMock` | No-op `publish()` — isolates tests from RabbitMQ |
| `PayneticsLogManagerMock` | No-op `log()` — suppresses log dispatch during tests |

## Functional test call sequences

The number of responses queued must match the exact number of outbound HTTP calls for a given flow:

**Purchase / Pre-Authorization (success):** Card create → BIN check → Store find → Account merchant → Transaction create → Gateway purchase/pre-auth → Card update → Transaction update (8 calls)

**Capture (success):** Transaction find → Card findInternal → Transaction create → BIN check → Gateway capture → Transaction update (capture tx) → Transaction update (pre-auth tx, set is_captured) (7 calls)

**Refund (success):** 10 calls (includes BIN, Store, Account, Transaction, Gateway, and multiple Transaction updates)

**Init — new card (success):** Card create (1 call). Saved-card variant: Card findInternal (1 call). Pure card-group validation failures (missing/invalid PAN data) short-circuit before any call; `init`-group failures (amount/currency/merchant) happen *after* the card is created, so still queue the Card create response.

**Web — payment page (success):** No outbound calls without a saved card; Card find (1 call) when a `card` token is supplied. `redirect=true` (default) returns a 302 to `PAYMENT_PAGE_URL/{operation}/{token}`; `redirect=false` returns `{data: {url}}`. `PAYMENT_PAGE_URL` is defined in `.env.test`.

**OCT — L4T (success):** Card create → BIN check → Store find BPC (not found) → Store find L4T → Account merchant → Transaction create → Gateway OCT → Transaction update (8 calls). For OCT, `ConfigurationManager` always tries a BPC store first and falls back to L4T, so the L4T path makes two store-find calls. The resolved processor comes from the store response `system` field (`l4t` / `bpc`); use `StoreServiceResponses::findForProcessor()`.

**OCT — BPC (success):** Card create → BIN check → Store find BPC → Account merchant → getBalance → Payoo getOctFee → Transaction create v1 → Transaction v2 create → updateBalance (debit fee) → Gateway OCT → Transaction v2 update → Transaction update v1 (12 calls). On decline the v2 success update is simply skipped — no v2-update-failed and no fee credit-back (since TEAM2-753) — so the flow is 11 calls ending with the v1 update. The BPC path needs a `balance` token in the body (`oct-bpc` validation group). `ACCOUNT_SERVICE`, `PAYOO`, and `BPC_INTEGRATION_SERVICE` must be defined in `.env.test` for these calls to resolve.

**Diagnostics — atos (success):** Gateway diagnostics (1 call). The controller (`/internal/diagnostics/atos`, instance auth via `diagnostics_atos_access`) just resolves the gateway from `configuration.processor` and returns the gateway `data` verbatim — no validation, no manager. The processor defaults to `atos` when omitted, so a `configuration` with no `processor` still hits the ATOS gateway and needs the response queued. Paths that fail before the gateway call queue **nothing**: an empty/unsupported processor makes `GatewayRequestManagerFactory::get()` throw `InvalidArgumentException` → mapped to code `11000`; a missing `configuration` dereferences a null `ConfigurationDto` and raises an uncaught `\Error` (Symfony's `HttpKernel` only catches `\Exception`, so it is not converted to a JSON error — a latent controller bug). A gateway error envelope (`code != 0`, `GatewayServiceResponses::diagnosticsFailed()`) maps straight through to that code/message.

## Conventions

- Never use magic strings or numbers in tests. Always use constants from `AcquiringTestConstants` or a dedicated test constants class. Add new constants there when the value you need doesn't exist yet.
- Unit tests extend `PHPUnit\Framework\TestCase`; functional tests extend `BaseWebTestCase`
- Test method names: `test{WhatIsTestedAndExpectedOutcome}()`
- Data providers: `camelCaseNameDataProvider()`, referenced via `#[DataProvider('...')]`
- Endpoint constants: `private const ENDPOINT = '/v1/...'` at the top of each test class
- `dama/doctrine-test-bundle` rolls back DB after each test — no manual cleanup needed
