# `Bourse.Testnet`
[🔗](https://github.com/ZenHive/bourse/blob/main/lib/bourse/testnet.ex#L1)

ETS-backed credential registry for integration testing.

Supports multiple credential sets per exchange for multi-API exchanges
(e.g., Binance spot vs futures testnets require separate credentials).

Credentials are registered once at test startup (in `test_helper.exs`),
then retrieved by generated tests at runtime. ETS with `read_concurrency: true`
allows lock-free reads from any process; the table is `:protected`, so writes
serialize through the owning GenServer (no foreign process can mutate or
overwrite registered credentials).

Not an application child: only test and recording harnesses use it, so callers
start it explicitly rather than every consumer paying for it at boot.

## When the registry is not running

Because nothing in the library starts it, every function here has to answer for
the case where the caller forgot to. `Application.ensure_all_started(:bourse)`
does not help — the application starts fine, this process simply is not in its
tree. Ask `started?/0` when in doubt. The two halves of the API answer
differently, by shape:

- **Writes** (`register/3`, `register_from_env/3`, `register_all_from_env/1`,
  `unregister/2`, `clear/0`) return `{:error, :not_started}`. They are called
  from harness setup code, where a matchable value lets the caller report the
  problem instead of dying inside it.
- **Reads** (`creds/2`, `creds!/2`, `registered?/2`, `registered_exchanges/0`,
  `exchanges_with_creds/0`) raise `ArgumentError` naming `start_link/1`. Their
  return shapes already mean something (`nil` is "not registered", `false` is
  "not registered here"), and quietly widening them would let a missing
  registry read as an empty one.

## Usage

    # In test_helper.exs - start the registry, then register each sandbox
    {:ok, _} = Bourse.Testnet.start_link([])

    Bourse.Testnet.register_all_from_env([
      {:bybit, testnet: true},
      {:binance, testnet: true},
      {:binance, :futures, testnet: true}
    ])

    # In tests - retrieve credentials for specific sandbox
    creds = Bourse.Testnet.creds(:bybit)             # default sandbox
    creds = Bourse.Testnet.creds(:binance, :futures) # futures sandbox

## Sandbox Keys

Multi-API exchanges have different testnets per API section:

| Sandbox Key | Env Var Infix | Example Hostname           |
|-------------|---------------|----------------------------|
| `:default`  | (none)        | testnet.binance.vision     |
| `:futures`  | `_FUTURES`    | demo-fapi.binance.com      |
| `:coinm`    | `_COINM`      | demo-dapi.binance.com      |

## Env Var Convention

- `{EXCHANGE}[_{SANDBOX}]_TESTNET_API_KEY`
- `{EXCHANGE}[_{SANDBOX}]_TESTNET_API_SECRET`
- `{EXCHANGE}_PASSPHRASE` (if `passphrase: true`)

OKX is an explicit exception: integration tests use the international demo
credentials `OKX_INTL_API_KEY`, `OKX_INTL_API_SECRET`, and
`OKX_INTL_PASSPHRASE` against `www.okx.com`.

When `:testnet` is true, the `_TEST_` infix is accepted as a silent fallback
(e.g. `BINANCE_FUTURES_TEST_API_KEY` is treated as an alias for
`BINANCE_FUTURES_TESTNET_API_KEY`). The canonical `_TESTNET_` name remains
preferred and is the one surfaced in `creds!/2` error messages.

# `config_entry`

```elixir
@type config_entry() :: {atom(), register_opts()} | {atom(), atom(), register_opts()}
```

Config entry for register_all_from_env/1

# `not_started`

```elixir
@type not_started() :: {:error, :not_started}
```

Returned by every write when the registry process is not running

# `register_opts`

```elixir
@type register_opts() :: [
  testnet: boolean(),
  passphrase: boolean(),
  sandbox: boolean(),
  secret_suffix: String.t()
]
```

Options for register_from_env/2,3

# `child_spec`

Returns a specification to start this module under a supervisor.

See `Supervisor`.

# `clear`

```elixir
@spec clear() :: :ok | not_started()
```

Clears all registered credentials (for test isolation).

Returns `{:error, :not_started}` if the registry is not running.

# `creds`

```elixir
@spec creds(atom(), atom()) :: Bourse.Credentials.t() | nil
```

Get credentials for an exchange/sandbox. Returns `nil` if unregistered.

Raises `ArgumentError` if the registry is not running — `nil` there would mean
"not registered", which is a claim this function cannot make without a table.

# `creds!`

```elixir
@spec creds!(atom(), atom()) :: Bourse.Credentials.t()
```

Get credentials or raise `ArgumentError` with a helpful message.

# `env_var_prefix`

```elixir
@spec env_var_prefix(atom(), atom()) :: String.t()
```

Env var prefix for a given exchange + sandbox combination.

## Examples

    iex> Bourse.Testnet.env_var_prefix(:binance, :default)
    "BINANCE_TESTNET"

    iex> Bourse.Testnet.env_var_prefix(:binance, :futures)
    "BINANCE_FUTURES_TESTNET"

# `exchanges_with_creds`

```elixir
@spec exchanges_with_creds() :: [atom()]
```

List unique exchange atoms with any registered credentials.

Raises `ArgumentError` if the registry is not running.

# `register`

```elixir
@spec register(atom(), atom() | keyword(), keyword()) ::
  :ok | :skipped | not_started()
```

Register credentials directly.

Returns `:ok` if credentials validate, `:skipped` if required fields are
missing, and `{:error, :not_started}` if the registry is not running.

# `register_all_from_env`

```elixir
@spec register_all_from_env([config_entry()]) :: [{atom(), atom()}] | not_started()
```

Register credentials for multiple exchanges from environment variables.

Returns the list of successfully registered `{exchange, sandbox_key}` tuples,
or `{:error, :not_started}` if the registry is not running — an empty list
means every entry was skipped for absent env vars, which is a different
condition and stays distinguishable.

# `register_from_env`

```elixir
@spec register_from_env(atom(), atom() | register_opts(), register_opts()) ::
  :ok | :skipped | not_started()
```

Register credentials from environment variables.

Env var pattern: `{EXCHANGE}[_{SANDBOX}][_TESTNET]_API_KEY|_API_SECRET`.

## Options

- `:testnet` - Include `_TESTNET` in env var names (default: `false`)
- `:passphrase` - Also load passphrase from `{EXCHANGE}_PASSPHRASE` (default: `false`)
- `:sandbox` - Value for `credentials.sandbox` (default: value of `:testnet`)
- `:secret_suffix` - Override secret env var suffix (default: `"API_SECRET"`)

Returns `:ok` on success, `:skipped` when required env vars are absent, and
`{:error, :not_started}` when there are credentials to register but no registry
running to hold them.

# `registered?`

```elixir
@spec registered?(atom(), atom()) :: boolean()
```

Returns `true` when credentials are registered for the given exchange/sandbox.

Raises `ArgumentError` if the registry is not running — `false` there would
claim the credentials are absent when the registry is.

# `registered_exchanges`

```elixir
@spec registered_exchanges() :: [{atom(), atom()}]
```

List all registered `{exchange, sandbox_key}` tuples.

Raises `ArgumentError` if the registry is not running — an empty list there
would read as "nothing registered yet".

# `sandbox_key_from_url`

```elixir
@spec sandbox_key_from_url(String.t() | nil) :: atom()
```

Derive sandbox_key from a sandbox URL's hostname/path.

## Examples

    iex> Bourse.Testnet.sandbox_key_from_url("https://testnet.binance.vision/api/v3")
    :default

    iex> Bourse.Testnet.sandbox_key_from_url("https://demo-fapi.binance.com/fapi/v1")
    :futures

    iex> Bourse.Testnet.sandbox_key_from_url("https://demo-dapi.binance.com/dapi/v1")
    :coinm

# `start_link`

```elixir
@spec start_link(keyword()) :: GenServer.on_start()
```

Starts the Testnet registry GenServer (owns the ETS table).

# `started?`

```elixir
@spec started?() :: boolean()
```

Returns `true` when the registry process is running.

The registry is not an application child, so a consumer that has not started it
will find every read raising and every write answering `{:error, :not_started}`.

# `unregister`

```elixir
@spec unregister(atom(), atom()) :: :ok | not_started()
```

Removes the credentials registered for a single `{exchange, sandbox_key}`.

Returns `:ok` whether or not an entry existed. Unlike direct `:ets.delete/2`,
this serializes through the owning GenServer, so it is the correct way to
clean up a single registration from a foreign process (e.g. an ExUnit
`on_exit` callback, which runs outside the test process). Use this instead of
`clear/0` when concurrent (`async: true`) tests share the registry and a
full wipe would clobber their registrations.

Returns `{:error, :not_started}` if the registry is not running.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
