# Testing

> Try your whole integration without spending money, with the test environment, simulated payments, test players and the demo game.

## The test environment

Every game has a **test** environment, picked by the test keys (`gc_sk_test_…`, `gc_pk_test_…`). It behaves like the real thing, with two differences:

- **Payments are simulated.** The payment page shows « Payer » and « Refuser » buttons. No money moves.
- **Data is disposable.** Players, balances, the ledger, the inventory and orders of `test` are separate from those of `live`, and you can leave them behind.

Your **catalog** (currencies, items, packs) is shared between the two environments. You define it once and test it as it will be sold.

> [!NOTE]
> `test` is the only environment you can use today. Real payments are not available yet, so nothing you test here costs money, and nothing you test here is real.

## Four ways to try it

| Way | You write code? | Good for |
|---|---|---|
| The dashboard's test players | No | Seeing balances, orders and refunds without any code |
| The demo game | No | Seeing the browser SDK sell your own catalog |
| `curl` or a server SDK | A little | Checking a call, a header or an error |
| Your own game | Yes | The real integration |

### Test players in the dashboard

The dashboard is in French for now. Open your game, go to « Joueurs », and check that the environment (« Environnement ») is **Test**. Then:

1. « Nouveau joueur de test »: give it an id such as `joueur-test-1`. This is an `ext:` player: its API name is `ext:joueur-test-1`.
2. On the player's page, « Donner de la monnaie » grants currency, and « Simuler un achat » opens a simulated payment for a pack of your choice, in a new tab.
3. Pay in that tab. Back on the player's page, the balance, the inventory and the order are updated.
4. In « Commandes », open the order, and use « Rembourser » to try a refund.

« Bloquer » on the player's page blocks the player: all their client calls answer `PLAYER_BLOCKED`.

### The demo game

The demo is a small clicker built on the browser SDK. It reads **your** catalog and shows the balance live, sells your packs and items, and lets you consume them. From your game's overview in the dashboard, use « Essayer dans le jeu de démonstration »: it opens the demo with your test key. You can also open [the demo game](/demo/index.html) and paste a publishable test key on its start screen.

The demo is in French. Its code is a good example of the SDK in a real page.

## The simulated payment

When you create a checkout in `test`, its payment page offers two buttons:

| Button | What happens | Order status | Where the player goes |
|---|---|---|---|
| « Payer » | The payment succeeds. The pack is delivered at once. | `fulfilled` | `successUrl`, with `?order=<id>&status=fulfilled` |
| « Refuser » | The payment fails. Nothing is delivered. | `failed` | `cancelUrl`, with `?order=<id>&status=failed` |

If the player closes the page without choosing, the order stays `pending` and expires after one hour.

The page also lets the buyer change the country, so you can see how the VAT changes. See [Selling packs](/docs/guides/selling-packs).

## A test plan

Try each of these before you call your integration done. Every one is a real case your players will meet.

| Case | How to try it | Expect |
|---|---|---|
| A new player | Open the game in a fresh browser profile or a private window | A new anonymous player, with balances at 0 |
| The same player returns | Reload the page | Same player, same balance |
| A paid pack | Pay in the simulated page | Status `fulfilled`, balance up by the pack, `change` event fired |
| A declined payment | Press « Refuser » | Status `failed`, balance unchanged, a clear message |
| A closed payment window | Close it without paying | Status `pending`, balance unchanged |
| Not enough currency | Spend more than the balance | `INSUFFICIENT_FUNDS` with `required` and `available`; your shop opens |
| An item at its limit | Buy a durable item twice | `LIMIT_REACHED`; your game shows "already owned" |
| A double click | Click "buy" twice quickly | Two purchases: each call has its own key. Disable the button while a call runs. A second `gc.checkout()` during the first fails with `CONFLICT`. |
| A retry | Send the same write twice with one idempotency key | The same answer, `Idempotent-Replayed: true` |
| An expired token | Wait an hour, or use a token you altered | The SDK renews it once; your `onTokenExpired` runs |
| A blocked player | « Bloquer » in the dashboard | `PLAYER_BLOCKED` on every call |
| A refund | « Rembourser » on an order, after spending some of it | A debt, or a shortfall: see [Refunds](/docs/guides/refunds) |
| A popup blocker | Turn it on in the browser | The SDK falls back to a redirect, and `gc.returnedOrder` has the order on return |

## Test with a script

For automated tests, give each run its own players. The ledger never forgets, so a player you reuse carries the balance of the last run. Make the id from the run:

<!-- tabs:start -->
```bash tab="curl"
PLAYER="ext%3Aci-$(date +%s)"

curl -X POST "https://gamecoin.apilow.com/api/v1/players/$PLAYER/wallet/grant" \
  -H "Authorization: Bearer $GAMECOIN_SECRET_KEY" \
  -H "Idempotency-Key: seed-$PLAYER" \
  -H "Content-Type: application/json" \
  -d '{"currency":"gems","amount":100}'
```
```javascript tab="Node.js"
import assert from "node:assert/strict";
import { ErrorCode, GameCoinError } from "@apilow/gamecoin";

const player = ext(`ci-${Date.now()}`);

await gamecoin.wallet.grant(player, { currency: "gems", amount: 100 });
await gamecoin.wallet.spend(player, { currency: "gems", amount: 30 });
assert.equal((await gamecoin.wallet.get(player)).balances.find((b) => b.currency === "gems").total, 70);

await assert.rejects(
  gamecoin.wallet.spend(player, { currency: "gems", amount: 1000 }),
  (error) => error instanceof GameCoinError && error.code === ErrorCode.INSUFFICIENT_FUNDS && error.details.available === 70,
);
console.log("ok");
```
```python tab="Python"
import time

from gamecoin import ErrorCode, GameCoinError

player = ext(f"ci-{int(time.time() * 1000)}")

gamecoin.wallet.grant(player, currency="gems", amount=100)
gamecoin.wallet.spend(player, currency="gems", amount=30)
assert gamecoin.wallet.get(player).balance_of("gems").total == 70

try:
    gamecoin.wallet.spend(player, currency="gems", amount=1000)
except GameCoinError as error:
    assert error.code == ErrorCode.INSUFFICIENT_FUNDS and error.details["available"] == 70
else:
    raise AssertionError("the spend should have been refused")
print("ok")
```
```php tab="PHP"
use Apilow\GameCoin\ErrorCode;
use Apilow\GameCoin\GameCoinException;

$player = PlayerRef::ext('ci-' . (int) (microtime(true) * 1000));

$gamecoin->wallet->grant($player, ['currency' => 'gems', 'amount' => 100]);
$gamecoin->wallet->spend($player, ['currency' => 'gems', 'amount' => 30]);
if ($gamecoin->wallet->get($player)->balanceOf('gems')?->total !== 70) {
    throw new RuntimeException('expected 70 gems');
}

try {
    $gamecoin->wallet->spend($player, ['currency' => 'gems', 'amount' => 1000]);
    throw new RuntimeException('the spend should have been refused');
} catch (GameCoinException $e) {
    if ($e->errorCode !== ErrorCode::INSUFFICIENT_FUNDS || $e->details['available'] !== 70) {
        throw $e;
    }
}
echo "ok\n";
```
```go tab="Go"
player := gamecoin.Ext(fmt.Sprintf("ci-%d", time.Now().UnixMilli()))

if _, err := client.Wallet.Grant(ctx, player, gamecoin.GrantParams{Currency: "gems", Amount: 100}); err != nil {
	log.Fatal(err)
}
if _, err := client.Wallet.Spend(ctx, player, gamecoin.SpendParams{Currency: "gems", Amount: 30}); err != nil {
	log.Fatal(err)
}
wallet, err := client.Wallet.Get(ctx, player)
if err != nil {
	log.Fatal(err)
}
for _, b := range wallet.Balances {
	if b.Currency == "gems" && b.Total != 70 {
		log.Fatalf("expected 70 gems, got %d", b.Total)
	}
}

_, err = client.Wallet.Spend(ctx, player, gamecoin.SpendParams{Currency: "gems", Amount: 1000})
var gcErr *gamecoin.Error
if !errors.As(err, &gcErr) || gcErr.Code != gamecoin.CodeInsufficientFunds {
	log.Fatalf("the spend should have been refused, got %v", err)
}
if available, _ := gcErr.DetailInt64("available"); available != 70 {
	log.Fatalf("expected 70 available, got %d", available)
}
fmt.Println("ok")
```
```csharp tab="C#"
using Apilow.GameCoin;

var player = PlayerRef.Ext($"ci-{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}");

await gamecoin.Wallet.GrantAsync(player, new GrantRequest { Currency = "gems", Amount = 100 });
await gamecoin.Wallet.SpendAsync(player, new SpendRequest { Currency = "gems", Amount = 30 });
var wallet = await gamecoin.Wallet.GetAsync(player);
if (wallet.Balances.Single(b => b.Currency == "gems").Total != 70) throw new InvalidOperationException("expected 70 gems");

try
{
    await gamecoin.Wallet.SpendAsync(player, new SpendRequest { Currency = "gems", Amount = 1000 });
    throw new InvalidOperationException("the spend should have been refused");
}
catch (GameCoinException ex) when (ex.Code == GameCoinErrorCodes.InsufficientFunds && ex.Details["available"].GetInt64() == 70)
{
    Console.WriteLine("ok");
}
```
<!-- tabs:end -->

Keep a game for testing, with its own `test` keys, and put its secret key in the secret store of your CI, never in the repository.

The server SDKs generate an idempotency key for every call, so the script above needs none. With `curl` you give one.

To test a payment from a script, create the checkout through the API, then open `checkoutUrl` and press « Payer » once by hand, or drive the page with a browser test tool. Then read the order with `GET /orders/{orderId}`.

## Go live

The `live` environment is for verified studios and real payments, and neither is available yet. Because the key picks the environment, going live later means changing the keys that your game and your server read, and nothing else. Until then, this page is how to be ready.

## Where next

- [Selling packs](/docs/guides/selling-packs)
- [Refunds](/docs/guides/refunds)
- [Errors](/docs/concepts/errors)
