# API des assistants de test

Jouez le rôle du payeur depuis vos propres tests, sans navigateur : terminer un paiement par carte, payer ou faire expirer un code espèces.

Les assistants de test permettent à un test automatisé de faire ce qu’une personne fait en mode
test : taper une carte de test sur la page de paiement, ou payer un code espèces chez un agent. Ils
se trouvent sous `/v1/test_helpers` et n’acceptent qu’une **clé secrète de test**. Avec une clé de
production, ils répondent `403`, `permission_error`, `test_mode_only`.

Un assistant agit sur une **tentative** (`ch_…`) : le `latest_charge` d’une intention de paiement
qui attend son payeur, en `requires_action`. Il joue le rôle du payeur ou du réseau, rien de plus :
le paiement change ensuite comme il le ferait en production, par la notification du prestataire ou
une vérification de statut, en général en quelques secondes. Relisez l’intention de paiement, ou
attendez le webhook.

## Terminer un paiement par carte

`POST /v1/test_helpers/charges/{charge}/complete` soumet un numéro de carte sur la page de paiement
d’une tentative carte en attente, comme le ferait votre client.

* `card_number` : l’une des [cartes de test](https://pay-docs.adpromax.com/fr/sandbox/test-cards), avec ou sans espaces. Tout autre
  numéro est refusé.
* `three_d_secure`, facultatif : pour la carte 3-D Secure, la réponse au défi simulé (voir la
  référence de l’API).

```bash
curl https://pay-api.adpromax.com/v1/test_helpers/charges/ch_01k7d2j8m3n6b9v2c5x8z1a4qd/complete \
  -u "$APX_SECRET_KEY:" \
  -H "Content-Type: application/json" \
  -d '{"card_number": "4242424242424242"}'
```

```js title="complete.mjs"
const charge = 'ch_01k7d2j8m3n6b9v2c5x8z1a4qd'
const path = `/v1/test_helpers/charges/${charge}/complete`
await fetch(`https://pay-api.adpromax.com${path}`, {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${process.env.APX_SECRET_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ card_number: '4242424242424242' }),
})
```

```ts title="complete.ts"
interface CompleteCharge {
  /** One of the test cards, with or without spaces. */
  card_number: string
}

const charge = 'ch_01k7d2j8m3n6b9v2c5x8z1a4qd'
const path = `/v1/test_helpers/charges/${charge}/complete`
const body: CompleteCharge = { card_number: '4242424242424242' }
await fetch(`https://pay-api.adpromax.com${path}`, {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${process.env.APX_SECRET_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify(body),
})
```

```python title="complete.py"
import os

import requests

charge = "ch_01k7d2j8m3n6b9v2c5x8z1a4qd"
requests.post(
    f"https://pay-api.adpromax.com/v1/test_helpers/charges/{charge}/complete",
    auth=(os.environ["APX_SECRET_KEY"], ""),
    json={"card_number": "4242424242424242"},
)
```

```php title="complete.php"
<?php
$charge = 'ch_01k7d2j8m3n6b9v2c5x8z1a4qd';
$path = "/v1/test_helpers/charges/{$charge}/complete";
$ch = curl_init('https://pay-api.adpromax.com' . $path);
curl_setopt_array($ch, [
    CURLOPT_USERPWD => getenv('APX_SECRET_KEY') . ':',
    CURLOPT_POST => true,
    CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
    CURLOPT_POSTFIELDS => json_encode(['card_number' => '4242424242424242']),
    CURLOPT_RETURNTRANSFER => true,
]);
curl_exec($ch);
```

```ruby title="complete.rb"
require 'json'
require 'net/http'

charge = 'ch_01k7d2j8m3n6b9v2c5x8z1a4qd'
uri = URI("https://pay-api.adpromax.com/v1/test_helpers/charges/#{charge}/complete")
request = Net::HTTP::Post.new(uri)
request.basic_auth(ENV.fetch('APX_SECRET_KEY'), '')
request['Content-Type'] = 'application/json'
request.body = JSON.generate(card_number: '4242424242424242')

Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http|
  http.request(request)
end
```

```go title="complete.go"
package main

import (
	"log"
	"net/http"
	"os"
	"strings"
)

func main() {
	charge := "ch_01k7d2j8m3n6b9v2c5x8z1a4qd"
	req, err := http.NewRequest(http.MethodPost,
		"https://pay-api.adpromax.com/v1/test_helpers/charges/"+charge+"/complete",
		strings.NewReader(`{"card_number": "4242424242424242"}`))
	if err != nil {
		log.Fatal(err)
	}
	req.SetBasicAuth(os.Getenv("APX_SECRET_KEY"), "")
	req.Header.Set("Content-Type", "application/json")

	res, err := http.DefaultClient.Do(req)
	if err != nil {
		log.Fatal(err)
	}
	res.Body.Close()
}
```

```java title="Complete.java"
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

public class Complete {
    public static void main(String[] args) throws Exception {
        String charge = "ch_01k7d2j8m3n6b9v2c5x8z1a4qd";
        String body = """
            {"card_number": "4242424242424242"}
            """;
        HttpRequest request = HttpRequest.newBuilder(URI.create(
                "https://pay-api.adpromax.com/v1/test_helpers/charges/" + charge + "/complete"))
            .header("Authorization", "Bearer " + System.getenv("APX_SECRET_KEY"))
            .header("Content-Type", "application/json")
            .POST(HttpRequest.BodyPublishers.ofString(body))
            .build();
        HttpClient.newHttpClient()
            .send(request, HttpResponse.BodyHandlers.discarding());
    }
}
```

```csharp title="Complete.cs"
using System.Net.Http.Headers;
using System.Net.Http.Json;

using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
    "Bearer", Environment.GetEnvironmentVariable("APX_SECRET_KEY"));

var charge = "ch_01k7d2j8m3n6b9v2c5x8z1a4qd";
using var response = await http.PostAsJsonAsync(
    $"https://pay-api.adpromax.com/v1/test_helpers/charges/{charge}/complete",
    new { card_number = "4242424242424242" });
```

```rust title="main.rs"
// Cargo.toml: reqwest = { version = "0.12", features = ["blocking", "json"] }
//             serde_json = "1"
use serde_json::json;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let charge = "ch_01k7d2j8m3n6b9v2c5x8z1a4qd";
    reqwest::blocking::Client::new()
        .post(format!(
            "https://pay-api.adpromax.com/v1/test_helpers/charges/{charge}/complete"
        ))
        .bearer_auth(std::env::var("APX_SECRET_KEY")?)
        .json(&json!({ "card_number": "4242424242424242" }))
        .send()?;
    Ok(())
}
```

## Payer, faire expirer, refuser ou suspendre un code espèces

Quatre assistants agissent sur une tentative espèces en attente, comme le ferait le réseau de
paiement. Chacun est un `POST` sans corps, sauf `pay` :

| Assistant                                         | Ce qui se passe                                                                                |
| ------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| `/v1/test_helpers/cash_vouchers/{charge}/pay`     | Le code est payé. `amount`, facultatif, en centimes : payer un autre montant que celui du bon. |
| `/v1/test_helpers/cash_vouchers/{charge}/expire`  | Le code atteint son échéance sans être payé.                                                   |
| `/v1/test_helpers/cash_vouchers/{charge}/refuse`  | Le réseau refuse le code.                                                                      |
| `/v1/test_helpers/cash_vouchers/{charge}/suspend` | Le code est mis en attente ; payez-le ensuite avec `pay`.                                      |

L’effet de chaque issue sur le paiement est décrit dans
[Scénarios de test en espèces](https://pay-docs.adpromax.com/fr/sandbox/cash-scenarios).

```bash
CH=ch_01k7d5f3a1s5d9f2g6h4j8k3nb
curl -X POST https://pay-api.adpromax.com/v1/test_helpers/cash_vouchers/$CH/pay \
  -u "$APX_SECRET_KEY:"

# or pay another amount: half of a 150.00 MAD payment
curl https://pay-api.adpromax.com/v1/test_helpers/cash_vouchers/$CH/pay \
  -u "$APX_SECRET_KEY:" \
  -H "Content-Type: application/json" \
  -d '{"amount": 7500}'
```

```js title="cash.mjs"
const charge = 'ch_01k7d5f3a1s5d9f2g6h4j8k3nb'
const helper = action =>
  `https://pay-api.adpromax.com/v1/test_helpers/cash_vouchers/${charge}/${action}`
const headers = {
  'Authorization': `Bearer ${process.env.APX_SECRET_KEY}`,
  'Content-Type': 'application/json',
}

await fetch(helper('pay'), { method: 'POST', headers })
// or: helper('expire'), helper('refuse'), helper('suspend'),
// or pay another amount:
// fetch(helper('pay'), { method: 'POST', headers,
//   body: JSON.stringify({ amount: 7500 }) })
```

```ts title="cash.ts"
type CashAction = 'pay' | 'expire' | 'refuse' | 'suspend'

const charge = 'ch_01k7d5f3a1s5d9f2g6h4j8k3nb'
const helper = (action: CashAction): string =>
  `https://pay-api.adpromax.com/v1/test_helpers/cash_vouchers/${charge}/${action}`
const headers = {
  'Authorization': `Bearer ${process.env.APX_SECRET_KEY}`,
  'Content-Type': 'application/json',
}

await fetch(helper('pay'), { method: 'POST', headers })
// or: helper('expire'), helper('refuse'), helper('suspend'),
// or pay another amount:
// fetch(helper('pay'), { method: 'POST', headers,
//   body: JSON.stringify({ amount: 7500 }) })
```

```python title="cash.py"
import os

import requests

AUTH = (os.environ["APX_SECRET_KEY"], "")
charge = "ch_01k7d5f3a1s5d9f2g6h4j8k3nb"

def cash_helper(action, body=None):
    url = f"https://pay-api.adpromax.com/v1/test_helpers/cash_vouchers/{charge}/{action}"
    return requests.post(url, auth=AUTH, json=body or {})

cash_helper("pay")
# or "expire", "refuse", "suspend"; another amount:
# cash_helper("pay", {"amount": 7500})
```

```php title="cash.php"
<?php
function cash_helper(string $charge, string $action, array $body = []): void
{
    $path = "/v1/test_helpers/cash_vouchers/{$charge}/{$action}";
    $ch = curl_init('https://pay-api.adpromax.com' . $path);
    curl_setopt_array($ch, [
        CURLOPT_USERPWD => getenv('APX_SECRET_KEY') . ':',
        CURLOPT_POST => true,
        CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
        CURLOPT_POSTFIELDS => json_encode((object) $body),
        CURLOPT_RETURNTRANSFER => true,
    ]);
    curl_exec($ch);
}

cash_helper('ch_01k7d5f3a1s5d9f2g6h4j8k3nb', 'pay');
// or 'expire', 'refuse', 'suspend'; another amount:
// cash_helper('ch_…', 'pay', ['amount' => 7500]);
```

```ruby title="cash.rb"
require 'json'
require 'net/http'

CHARGE = 'ch_01k7d5f3a1s5d9f2g6h4j8k3nb'

def cash_helper(action, body = {})
  uri = URI("https://pay-api.adpromax.com/v1/test_helpers/cash_vouchers/#{CHARGE}/#{action}")
  request = Net::HTTP::Post.new(uri)
  request.basic_auth(ENV.fetch('APX_SECRET_KEY'), '')
  request['Content-Type'] = 'application/json'
  request.body = JSON.generate(body)
  Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http|
    http.request(request)
  end
end

cash_helper('pay')
# or 'expire', 'refuse', 'suspend'; another amount:
# cash_helper('pay', amount: 7500)
```

```go title="cash.go"
package main

import (
	"bytes"
	"encoding/json"
	"log"
	"net/http"
	"os"
)

const charge = "ch_01k7d5f3a1s5d9f2g6h4j8k3nb"

func cashHelper(action string, body map[string]any) error {
	payload, err := json.Marshal(body)
	if err != nil {
		return err
	}
	req, err := http.NewRequest(http.MethodPost,
		"https://pay-api.adpromax.com/v1/test_helpers/cash_vouchers/"+charge+"/"+action,
		bytes.NewReader(payload))
	if err != nil {
		return err
	}
	req.SetBasicAuth(os.Getenv("APX_SECRET_KEY"), "")
	req.Header.Set("Content-Type", "application/json")
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		return err
	}
	return res.Body.Close()
}

func main() {
	if err := cashHelper("pay", map[string]any{}); err != nil {
		log.Fatal(err)
	}
	// or "expire", "refuse", "suspend"; another amount:
	// cashHelper("pay", map[string]any{"amount": 7500})
}
```

```java title="Cash.java"
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

public class Cash {
    static final String CHARGE = "ch_01k7d5f3a1s5d9f2g6h4j8k3nb";

    static void cashHelper(String action, String body) throws Exception {
        HttpRequest request = HttpRequest.newBuilder(URI.create(
                "https://pay-api.adpromax.com/v1/test_helpers/cash_vouchers/"
                    + CHARGE + "/" + action))
            .header("Authorization", "Bearer " + System.getenv("APX_SECRET_KEY"))
            .header("Content-Type", "application/json")
            .POST(HttpRequest.BodyPublishers.ofString(body))
            .build();
        HttpClient.newHttpClient()
            .send(request, HttpResponse.BodyHandlers.discarding());
    }

    public static void main(String[] args) throws Exception {
        cashHelper("pay", "{}");
        // or "expire", "refuse", "suspend"; another amount:
        // cashHelper("pay", "{\"amount\": 7500}");
    }
}
```

```csharp title="Cash.cs"
using System.Net.Http.Headers;
using System.Net.Http.Json;

using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
    "Bearer", Environment.GetEnvironmentVariable("APX_SECRET_KEY"));

const string charge = "ch_01k7d5f3a1s5d9f2g6h4j8k3nb";

Task<HttpResponseMessage> CashHelper(string action, object? body = null) =>
    http.PostAsJsonAsync(
        $"https://pay-api.adpromax.com/v1/test_helpers/cash_vouchers/{charge}/{action}",
        body ?? new { });

await CashHelper("pay");
// or "expire", "refuse", "suspend"; another amount:
// await CashHelper("pay", new { amount = 7500 });
```

```rust title="main.rs"
// Cargo.toml: reqwest = { version = "0.12", features = ["blocking", "json"] }
//             serde_json = "1"
use serde_json::{json, Value};

const CHARGE: &str = "ch_01k7d5f3a1s5d9f2g6h4j8k3nb";

fn cash_helper(action: &str, body: Value) -> Result<(), Box<dyn std::error::Error>> {
    reqwest::blocking::Client::new()
        .post(format!(
            "https://pay-api.adpromax.com/v1/test_helpers/cash_vouchers/{CHARGE}/{action}"
        ))
        .bearer_auth(std::env::var("APX_SECRET_KEY")?)
        .json(&body)
        .send()?;
    Ok(())
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    cash_helper("pay", json!({}))?;
    // or "expire", "refuse", "suspend"; another amount:
    // cash_helper("pay", json!({ "amount": 7500 }))?;
    Ok(())
}
```

## Un paiement par carte complet, sans navigateur

Un test de votre intégration peut faire un paiement de bout en bout : créer une intention de
paiement, la confirmer pour une carte, terminer sa tentative avec une carte de test, puis attendre
`succeeded`. Chaque création et chaque confirmation porte sa propre `Idempotency-Key`, nouvelle à
chaque exécution du test.

```bash
API=https://pay-api.adpromax.com/v1
RUN=$(date +%s)
PI=$(curl -s $API/payment_intents -u "$APX_SECRET_KEY:" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: test-$RUN-create" \
  -d '{"amount": 50000, "currency": "mad",
       "payment_method_types": ["card"]}' | jq -r .id)

curl -s $API/payment_intents/$PI/confirm -u "$APX_SECRET_KEY:" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: test-$RUN-confirm" \
  -d '{"payment_method_type": "card",
       "return_url": "https://shop.example.com/done"}' > /dev/null

CH=$(curl -s $API/payment_intents/$PI -u "$APX_SECRET_KEY:" \
  | jq -r .latest_charge)
curl -s $API/test_helpers/charges/$CH/complete -u "$APX_SECRET_KEY:" \
  -H "Content-Type: application/json" \
  -d '{"card_number": "4242424242424242"}' > /dev/null

for i in 1 2 3 4 5 6 7 8 9 10; do
  STATUS=$(curl -s $API/payment_intents/$PI -u "$APX_SECRET_KEY:" \
    | jq -r .status)
  [ "$STATUS" = succeeded ] && break
  sleep 1
done
echo "$PI $STATUS"
```

```js title="card-payment.test.mjs"
import { randomUUID } from 'node:crypto'

const api = 'https://pay-api.adpromax.com/v1'
const auth = `Bearer ${process.env.APX_SECRET_KEY}`
const run = randomUUID()

async function call(method, path, body, key) {
  const headers = { Authorization: auth }
  if (body !== undefined)
    headers['Content-Type'] = 'application/json'
  if (key !== undefined)
    headers['Idempotency-Key'] = `${run}-${key}`
  const response = await fetch(`${api}${path}`, {
    method,
    headers,
    body: body === undefined ? undefined : JSON.stringify(body),
  })
  const answer = await response.json()
  if (!response.ok)
    throw new Error(answer.error.message)
  return answer
}

const intent = await call('POST', '/payment_intents', {
  amount: 50000,
  currency: 'mad',
  payment_method_types: ['card'],
}, 'create')
await call('POST', `/payment_intents/${intent.id}/confirm`, {
  payment_method_type: 'card',
  return_url: 'https://shop.example.com/done',
}, 'confirm')
const charge = (await call('GET', `/payment_intents/${intent.id}`)).latest_charge
await call('POST', `/test_helpers/charges/${charge}/complete`, {
  card_number: '4242424242424242',
})

let status
for (let i = 0; i < 10 && status !== 'succeeded'; i++) {
  await new Promise(resolve => setTimeout(resolve, 1000))
  status = (await call('GET', `/payment_intents/${intent.id}`)).status
}
console.log(intent.id, status)
```

```ts title="card-payment.test.ts"
import { randomUUID } from 'node:crypto'

interface PaymentIntent {
  id: string
  status: string
  latest_charge: string | null
}

interface ApiError {
  error: { type: string, code: string, message: string, request_id: string }
}

const api = 'https://pay-api.adpromax.com/v1'
const auth = `Bearer ${process.env.APX_SECRET_KEY}`
const run = randomUUID()

async function call<T = PaymentIntent>(
  method: 'GET' | 'POST',
  path: string,
  body?: object,
  key?: string,
): Promise<T> {
  const headers: Record<string, string> = { Authorization: auth }
  if (body !== undefined)
    headers['Content-Type'] = 'application/json'
  if (key !== undefined)
    headers['Idempotency-Key'] = `${run}-${key}`
  const response = await fetch(`${api}${path}`, {
    method,
    headers,
    body: body === undefined ? undefined : JSON.stringify(body),
  })
  if (!response.ok) {
    const { error } = (await response.json()) as ApiError
    throw new Error(`${error.code}: ${error.message}`)
  }
  return (await response.json()) as T
}

const intent = await call('POST', '/payment_intents', {
  amount: 50000,
  currency: 'mad',
  payment_method_types: ['card'],
}, 'create')
await call('POST', `/payment_intents/${intent.id}/confirm`, {
  payment_method_type: 'card',
  return_url: 'https://shop.example.com/done',
}, 'confirm')
const charge = (await call('GET', `/payment_intents/${intent.id}`)).latest_charge
await call<unknown>('POST', `/test_helpers/charges/${charge}/complete`, {
  card_number: '4242424242424242',
})

let status: string | undefined
for (let i = 0; i < 10 && status !== 'succeeded'; i++) {
  await new Promise(resolve => setTimeout(resolve, 1000))
  status = (await call('GET', `/payment_intents/${intent.id}`)).status
}
console.log(intent.id, status)
```

```python title="card_payment_test.py"
import os
import time
import uuid

import requests

API = "https://pay-api.adpromax.com/v1"
AUTH = (os.environ["APX_SECRET_KEY"], "")
RUN = uuid.uuid4().hex

def call(method, path, body=None, key=None):
    headers = {"Idempotency-Key": f"{RUN}-{key}"} if key else {}
    response = requests.request(
        method, f"{API}{path}", auth=AUTH, json=body, headers=headers
    )
    answer = response.json()
    response.raise_for_status()
    return answer

intent = call("POST", "/payment_intents", {
    "amount": 50000,
    "currency": "mad",
    "payment_method_types": ["card"],
}, "create")
call("POST", f"/payment_intents/{intent['id']}/confirm", {
    "payment_method_type": "card",
    "return_url": "https://shop.example.com/done",
}, "confirm")
charge = call("GET", f"/payment_intents/{intent['id']}")["latest_charge"]
call("POST", f"/test_helpers/charges/{charge}/complete", {
    "card_number": "4242424242424242",
})

status = None
for _ in range(10):
    time.sleep(1)
    status = call("GET", f"/payment_intents/{intent['id']}")["status"]
    if status == "succeeded":
        break
print(intent["id"], status)
```

```php title="card_payment_test.php"
<?php
function call(
    string $method,
    string $path,
    ?array $body = null,
    ?string $key = null
): array {
    $headers = ['Content-Type: application/json'];
    if ($key !== null) {
        $headers[] = "Idempotency-Key: {$key}";
    }
    $ch = curl_init('https://pay-api.adpromax.com/v1' . $path);
    curl_setopt_array($ch, [
        CURLOPT_USERPWD => getenv('APX_SECRET_KEY') . ':',
        CURLOPT_CUSTOMREQUEST => $method,
        CURLOPT_HTTPHEADER => $headers,
        CURLOPT_RETURNTRANSFER => true,
    ]);
    if ($body !== null) {
        curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
    }
    $answer = json_decode(curl_exec($ch), true);
    return $answer;
}

$run = bin2hex(random_bytes(8));
$intent = call('POST', '/payment_intents', [
    'amount' => 50000,
    'currency' => 'mad',
    'payment_method_types' => ['card'],
], "{$run}-create");
call('POST', "/payment_intents/{$intent['id']}/confirm", [
    'payment_method_type' => 'card',
    'return_url' => 'https://shop.example.com/done',
], "{$run}-confirm");
$charge = call('GET', "/payment_intents/{$intent['id']}")['latest_charge'];
call('POST', "/test_helpers/charges/{$charge}/complete", [
    'card_number' => '4242424242424242',
]);

for ($i = 0, $status = null; $i < 10 && $status !== 'succeeded'; $i++) {
    sleep(1);
    $status = call('GET', "/payment_intents/{$intent['id']}")['status'];
}
echo $intent['id'], ' ', $status, PHP_EOL;
```

```ruby title="card_payment_test.rb"
require 'json'
require 'minitest/autorun'
require 'net/http'
require 'securerandom'

class CardPaymentTest < Minitest::Test
  API = 'https://pay-api.adpromax.com/v1'
  RUN = SecureRandom.uuid

  def api(method, path, body = nil, key = nil)
    uri = URI("#{API}#{path}")
    request = (method == :get ? Net::HTTP::Get : Net::HTTP::Post).new(uri)
    request.basic_auth(ENV.fetch('APX_SECRET_KEY'), '')
    request['Idempotency-Key'] = "#{RUN}-#{key}" if key
    if body
      request['Content-Type'] = 'application/json'
      request.body = JSON.generate(body)
    end
    response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http|
      http.request(request)
    end
    answer = JSON.parse(response.body)
    raise answer.dig('error', 'message') unless response.is_a?(Net::HTTPSuccess)

    answer
  end

  def test_a_card_payment_succeeds
    intent = api(:post, '/payment_intents', {
      amount: 50_000,
      currency: 'mad',
      payment_method_types: ['card']
    }, 'create')
    api(:post, "/payment_intents/#{intent['id']}/confirm", {
      payment_method_type: 'card',
      return_url: 'https://shop.example.com/done'
    }, 'confirm')
    charge = api(:get, "/payment_intents/#{intent['id']}")['latest_charge']
    api(:post, "/test_helpers/charges/#{charge}/complete",
        { card_number: '4242424242424242' })

    status = nil
    10.times do
      sleep 1
      status = api(:get, "/payment_intents/#{intent['id']}")['status']
      break if status == 'succeeded'
    end
    assert_equal 'succeeded', status
  end
end
```

```go title="card_payment_test.go"
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"
	"testing"
	"time"
)

const api = "https://pay-api.adpromax.com/v1"

type paymentIntent struct {
	ID           string `json:"id"`
	Status       string `json:"status"`
	LatestCharge string `json:"latest_charge"`
}

func call(t *testing.T, method, path string, body any, key string) paymentIntent {
	t.Helper()
	var payload io.Reader
	if body != nil {
		encoded, err := json.Marshal(body)
		if err != nil {
			t.Fatal(err)
		}
		payload = bytes.NewReader(encoded)
	}
	req, err := http.NewRequest(method, api+path, payload)
	if err != nil {
		t.Fatal(err)
	}
	req.SetBasicAuth(os.Getenv("APX_SECRET_KEY"), "")
	if body != nil {
		req.Header.Set("Content-Type", "application/json")
	}
	if key != "" {
		req.Header.Set("Idempotency-Key", key)
	}
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		t.Fatal(err)
	}
	defer res.Body.Close()
	if res.StatusCode >= 400 {
		t.Fatalf("%s %s: %s", method, path, res.Status)
	}
	var answer paymentIntent
	if err := json.NewDecoder(res.Body).Decode(&answer); err != nil {
		t.Fatal(err)
	}
	return answer
}

func TestCardPayment(t *testing.T) {
	run := fmt.Sprint(time.Now().UnixNano())
	intent := call(t, "POST", "/payment_intents", map[string]any{
		"amount":               50000,
		"currency":             "mad",
		"payment_method_types": []string{"card"},
	}, run+"-create")
	path := "/payment_intents/" + intent.ID
	call(t, "POST", path+"/confirm", map[string]any{
		"payment_method_type": "card",
		"return_url":          "https://shop.example.com/done",
	}, run+"-confirm")
	charge := call(t, "GET", path, nil, "").LatestCharge
	call(t, "POST", "/test_helpers/charges/"+charge+"/complete",
		map[string]any{"card_number": "4242424242424242"}, "")

	status := ""
	for i := 0; i < 10 && status != "succeeded"; i++ {
		time.Sleep(time.Second)
		status = call(t, "GET", path, nil, "").Status
	}
	if status != "succeeded" {
		t.Fatalf("%s is %q, want succeeded", intent.ID, status)
	}
}
```

```java title="CardPaymentTest.java"
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.UUID;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class CardPaymentTest {
    static final String API = "https://pay-api.adpromax.com/v1";
    static final String RUN = UUID.randomUUID().toString();
    static final HttpClient HTTP = HttpClient.newHttpClient();

    static String call(String method, String path, String body, String key)
            throws Exception {
        HttpRequest.Builder request = HttpRequest.newBuilder(URI.create(API + path))
            .header("Authorization", "Bearer " + System.getenv("APX_SECRET_KEY"))
            .header("Content-Type", "application/json")
            .method(method, body == null
                ? HttpRequest.BodyPublishers.noBody()
                : HttpRequest.BodyPublishers.ofString(body));
        if (key != null) {
            request.header("Idempotency-Key", RUN + "-" + key);
        }
        HttpResponse<String> response = HTTP.send(
            request.build(), HttpResponse.BodyHandlers.ofString());
        if (response.statusCode() >= 400) {
            throw new IllegalStateException(response.body());
        }
        return response.body();
    }

    // No JSON library here: a small regex reads the first field of that name.
    static String field(String json, String name) {
        Matcher match = Pattern
            .compile("\"" + name + "\"\\s*:\\s*\"([^\"]*)\"")
            .matcher(json);
        if (!match.find()) {
            throw new IllegalStateException("no " + name + " in " + json);
        }
        return match.group(1);
    }

    public static void main(String[] args) throws Exception {
        String intent = field(call("POST", "/payment_intents", """
            {
              "amount": 50000,
              "currency": "mad",
              "payment_method_types": ["card"]
            }
            """, "create"), "id");
        String path = "/payment_intents/" + intent;
        call("POST", path + "/confirm", """
            {
              "payment_method_type": "card",
              "return_url": "https://shop.example.com/done"
            }
            """, "confirm");
        String charge = field(call("GET", path, null, null), "latest_charge");
        call("POST", "/test_helpers/charges/" + charge + "/complete", """
            {"card_number": "4242424242424242"}
            """, null);

        String status = null;
        for (int i = 0; i < 10 && !"succeeded".equals(status); i++) {
            Thread.sleep(1000);
            status = field(call("GET", path, null, null), "status");
        }
        if (!"succeeded".equals(status)) {
            throw new AssertionError(intent + " is " + status);
        }
        System.out.println(intent + " " + status);
    }
}
```

```csharp title="CardPaymentTest.cs"
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;

var json = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower };
using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
    "Bearer", Environment.GetEnvironmentVariable("APX_SECRET_KEY"));
var api = "https://pay-api.adpromax.com/v1";
var run = Guid.NewGuid().ToString();

async Task<PaymentIntent> Call(
    HttpMethod method, string path, object? body = null, string? key = null)
{
    using var request = new HttpRequestMessage(method, api + path);
    if (body is not null)
        request.Content = JsonContent.Create(body);
    if (key is not null)
        request.Headers.Add("Idempotency-Key", $"{run}-{key}");
    using var response = await http.SendAsync(request);
    if (!response.IsSuccessStatusCode)
    {
        var failure = await response.Content.ReadFromJsonAsync<ApiError>(json);
        throw new InvalidOperationException(failure?.Error.Message);
    }
    return (await response.Content.ReadFromJsonAsync<PaymentIntent>(json))!;
}

var intent = await Call(HttpMethod.Post, "/payment_intents", new
{
    amount = 50000,
    currency = "mad",
    payment_method_types = new[] { "card" },
}, "create");
var path = $"/payment_intents/{intent.Id}";
await Call(HttpMethod.Post, $"{path}/confirm", new
{
    payment_method_type = "card",
    return_url = "https://shop.example.com/done",
}, "confirm");
var charge = (await Call(HttpMethod.Get, path)).LatestCharge;
await Call(HttpMethod.Post, $"/test_helpers/charges/{charge}/complete",
    new { card_number = "4242424242424242" });

string? status = null;
for (var i = 0; i < 10 && status != "succeeded"; i++)
{
    await Task.Delay(1000);
    status = (await Call(HttpMethod.Get, path)).Status;
}
if (status != "succeeded")
    throw new InvalidOperationException($"{intent.Id} is {status}");
Console.WriteLine($"{intent.Id} {status}");

record PaymentIntent(string Id, string Status, string? LatestCharge);
record ApiError(ApiErrorBody Error);
record ApiErrorBody(string Code, string Message);
```

```rust title="main.rs"
// Cargo.toml: reqwest = { version = "0.12", features = ["blocking", "json"] }
//             serde_json = "1"
// Run it with `cargo test`.
fn main() {}

#[cfg(test)]
mod tests {
    use std::thread;
    use std::time::{Duration, SystemTime, UNIX_EPOCH};

    use reqwest::{blocking::Client, Method};
    use serde_json::{json, Value};

    const API: &str = "https://pay-api.adpromax.com/v1";
    type Error = Box<dyn std::error::Error>;

    fn call(
        method: Method,
        path: &str,
        body: Option<Value>,
        key: Option<String>,
    ) -> Result<Value, Error> {
        let mut request = Client::new()
            .request(method, format!("{API}{path}"))
            .bearer_auth(std::env::var("APX_SECRET_KEY")?);
        if let Some(body) = body {
            request = request.json(&body);
        }
        if let Some(key) = key {
            request = request.header("Idempotency-Key", key);
        }
        let response = request.send()?;
        let failed = !response.status().is_success();
        let answer: Value = response.json()?;
        if failed {
            let message = answer["error"]["message"].as_str();
            return Err(message.unwrap_or("request failed").into());
        }
        Ok(answer)
    }

    #[test]
    fn a_card_payment_succeeds() -> Result<(), Error> {
        let run = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos();
        let intent = call(
            Method::POST,
            "/payment_intents",
            Some(json!({
                "amount": 50000,
                "currency": "mad",
                "payment_method_types": ["card"]
            })),
            Some(format!("{run}-create")),
        )?;
        let id = intent["id"].as_str().ok_or("no id")?;
        let path = format!("/payment_intents/{id}");
        call(
            Method::POST,
            &format!("{path}/confirm"),
            Some(json!({
                "payment_method_type": "card",
                "return_url": "https://shop.example.com/done"
            })),
            Some(format!("{run}-confirm")),
        )?;
        let waiting = call(Method::GET, &path, None, None)?;
        let charge = waiting["latest_charge"].as_str().ok_or("no charge")?;
        call(
            Method::POST,
            &format!("/test_helpers/charges/{charge}/complete"),
            Some(json!({ "card_number": "4242424242424242" })),
            None,
        )?;

        let mut status = String::new();
        for _ in 0..10 {
            thread::sleep(Duration::from_secs(1));
            let current = call(Method::GET, &path, None, None)?;
            status = current["status"].as_str().unwrap_or_default().to_owned();
            if status == "succeeded" {
                break;
            }
        }
        assert_eq!(status, "succeeded", "{id}");
        Ok(())
    }
}
```

## Ailleurs en mode test

* La page du bon que voit votre client porte **Simuler le paiement** et les autres issues espèces.
* Sur le tableau de bord, la page d’un paiement de test a une carte **Assistants de test** avec les
  mêmes actions.
* Ni l’une ni l’autre n’existe en production, où seul un vrai paiement fait avancer un paiement.
