# Local Development
This guide explains how to develop against `wallet_passes` without real Apple
or Google credentials — using the bundled `dev/wallet_passes_dev/` Phoenix
sandbox for interactive work, and the library's test-only HTTP base-URL
overrides plus [`bypass`](https://hex.pm/packages/bypass) for automated
consumer tests.
## Overview
### Why a dev sandbox exists
Apple Wallet and Google Wallet have gnarly credential workflows. A
"Hello World" pass requires a pass-type certificate, signing key, and WWDR
intermediate from the Apple Developer portal, plus a Google Cloud service
account JSON with Wallet API access granted via Google's partner console,
a Google-enrolled issuer ID, and a publicly-reachable HTTPS callback URL.
That's hours of setup before any code runs.
To make iterating tractable, the repo ships:
1. A Phoenix sandbox at `dev/wallet_passes_dev/` that runs the full library
stack against in-process mocks. No real credentials, no network.
2. Three configurable base-URL keys — `:apple_push_base_url`,
`:google_api_base_url`, `:google_token_url` — that consumers can point
at [`bypass`](https://hex.pm/packages/bypass) in their own tests.
3. Mock plug routers (`MockApplePush`, `MockGoogleApi`) under
`test/support/`, reused by both the library's test suite and the sandbox.
You don't need the sandbox to develop a consumer app — `bypass`-backed
unit tests are usually enough. The sandbox is for visual work (previews,
end-to-end flows, demos) and for hacking on the library itself.
### Forward links
- [Getting Started](getting-started.md) — real credential setup.
- [Add-ons](addons.md) — the LiveView preview that the sandbox uses is the
same component consumers can mount.
- [Apple Wallet](apple-wallet.md) — `.pkpass` ZIP shape that tests assert on.
- [Google Wallet](google-wallet.md) — JSON shape for object/class payloads.
## The Dev Sandbox
The sandbox is a Phoenix LiveView app under `dev/wallet_passes_dev/` that
boots the full library stack against in-process mocks. There are no real
HTTP calls and no real credentials.
### Running it
```bash
cd dev/wallet_passes_dev
mix setup # deps.get, ecto.create, gen.migration, ecto.migrate, assets
mix phx.server # http://localhost:4000
```
`mix setup` chains `deps.get`, `ecto.setup` (which runs
`mix wallet_passes.gen.migration` then `mix ecto.migrate` against a local
`wallet_passes_dev` PostgreSQL database), and asset install/build. You
need Postgres running locally; everything else is self-contained.
### What the sandbox provides
- **Pass Preview (`/`)** — side-by-side Apple and Google Wallet previews
that update live as you edit form fields. The preview uses
`WalletPasses.Preview.Components` directly, so what you see is what
consumers see when they mount the same components.
- **API Activity Log (`/api-log`)** — real-time feed of every request
hitting the in-process mock Google Wallet API and Apple Push endpoints.
Shows method, path, status, timestamp. Useful for understanding what
payloads the library generates.
### How the mocks are wired
`config/dev.exs` points the library at localhost:
```elixir
config :wallet_passes,
apple_push_base_url: "http://localhost:4000/mock/apple-push",
google_api_base_url: "http://localhost:4000/mock/google/walletobjects/v1",
google_token_url: "http://localhost:4000/mock/google/token"
```
The dev app's router forwards those paths to the `MockApplePush` and
`MockGoogleApi` plug routers from `test/support/` (the dev mix project's
`elixirc_paths` includes that directory).
`config/runtime.exs` loads test-generated certificates and a fake Google
service account JSON from `WalletPasses.TestCredentials`, so signing
paths produce real `.pkpass` bundles and real JWTs that just never
leave the process.
The form-backed `WalletPassesDev.PassDataProvider` (an `Agent`) implements
the `PassDataProvider` behaviour. Whatever you type into the preview form
syncs to the agent, so the Apple callback router (`/passes/apple`) serves
the pass currently on screen.
## Testing Patterns
Two pieces handle most consumer test needs: the base-URL config keys
redirect HTTP to a `bypass` instance, and a stub `PassDataProvider` covers
any code path that looks up pass data by serial number.
### Redirecting HTTP with `bypass`
Add `{:bypass, "~> 2.1", only: :test}` to your `deps/0`. Then in your test:
```elixir
defmodule MyApp.WalletIntegrationTest do
use ExUnit.Case, async: false
setup do
bypass = Bypass.open()
base = "http://localhost:#{bypass.port}"
Application.put_env(:wallet_passes, :google_api_base_url, "#{base}/walletobjects/v1")
Application.put_env(:wallet_passes, :google_token_url, "#{base}/token")
Application.put_env(:wallet_passes, :apple_push_base_url, base)
on_exit(fn ->
Application.delete_env(:wallet_passes, :google_api_base_url)
Application.delete_env(:wallet_passes, :google_token_url)
Application.delete_env(:wallet_passes, :apple_push_base_url)
end)
%{bypass: bypass}
end
test "create_object hits Google API", %{bypass: bypass} do
Bypass.expect(bypass, "POST", "/token", fn conn ->
Plug.Conn.resp(conn, 200, ~s({"access_token": "fake-token"}))
end)
Bypass.expect(bypass, "POST", "/walletobjects/v1/eventTicketObject", fn conn ->
{:ok, body, conn} = Plug.Conn.read_body(conn)
payload = Jason.decode!(body)
assert payload["state"] == "ACTIVE"
assert payload["id"] =~ "."
Plug.Conn.resp(conn, 200, Jason.encode!(%{"id" => payload["id"]}))
end)
{:ok, _object_id} = WalletPasses.Google.Api.create_object(pass_data, visual)
end
end
```
The same pattern works for Apple push: point `:apple_push_base_url` at
Bypass and match `POST /3/device/:token`. The library's
`test/wallet_passes/apple/push_integration_test.exs` and
`test/wallet_passes/google/api_integration_test.exs` are full worked
examples — use them as references.
Three things to note:
1. **`async: false`** — `Application.put_env/3` is global, so don't run
these tests concurrently.
2. **Token URL is separate.** Google's OAuth2 exchange uses
`:google_token_url`, not `:google_api_base_url`. Override both.
3. **Defaults are the production URLs.** If you forget to override
`:google_api_base_url`, your test will try to hit
`walletobjects.googleapis.com` and fail in a confusing way. The library
only raises on missing required keys (issuer ID, credentials) — base
URLs are always optional.
### A stub `PassDataProvider`
Any code path that hits Apple's callback router or fires a sync job needs
a `PassDataProvider`. The smallest useful stub:
```elixir
defmodule MyApp.StubPassDataProvider do
@behaviour WalletPasses.PassDataProvider
@impl true
def build_pass_data("missing"), do: {:error, :not_found}
def build_pass_data(serial_number) do
{:ok, %{
pass_data: %WalletPasses.PassData{
serial_number: serial_number,
description: "Test pass",
organization_name: "Test Org",
},
apple: %WalletPasses.Apple.Visual{},
google: %WalletPasses.Google.Visual{},
}}
end
end
```
For tests where you want to vary the returned data, an `Agent`-backed
provider works well — that's exactly the pattern the dev sandbox uses
(see `dev/wallet_passes_dev/lib/wallet_passes_dev/pass_data_provider.ex`).
Configure it for the test environment:
```elixir
# config/test.exs
config :wallet_passes, pass_data_provider: MyApp.StubPassDataProvider
```
### Asserting `.pkpass` ZIP contents
A `.pkpass` is a ZIP archive. Decode it in memory with `:zip.unzip/2` and
assert on the entries directly:
```elixir
{:ok, pkpass_bin} = WalletPasses.build_apple_pass(pass_data, visual)
{:ok, entries} = :zip.unzip(pkpass_bin, [:memory])
files = Map.new(entries, fn {name, content} -> {to_string(name), content} end)
assert Map.has_key?(files, "pass.json")
assert Map.has_key?(files, "manifest.json")
assert Map.has_key?(files, "signature")
pass_json = Jason.decode!(files["pass.json"])
assert pass_json["serialNumber"] == "TEST-2026-AB12"
assert pass_json["passTypeIdentifier"] == "pass.com.example.test"
manifest = Jason.decode!(files["manifest.json"])
assert manifest["pass.json"] == :crypto.hash(:sha, files["pass.json"]) |> Base.encode16(case: :lower)
```
For localized passes, assert that `<locale>.lproj/pass.strings` and any
localized images appear under their `.lproj/` prefix. The library's
`test/wallet_passes/apple/builder_localization_test.exs` has the full
pattern.
### Asserting Google JSON shape
Google calls don't produce a single artifact — they post payloads to
Google's API. The cleanest way to assert on shape is to inspect the body
inside your `Bypass.expect/3` callback:
```elixir
Bypass.expect(bypass, "POST", "/walletobjects/v1/eventTicketObject", fn conn ->
{:ok, body, conn} = Plug.Conn.read_body(conn)
payload = Jason.decode!(body)
assert payload["id"] == "1234567890.TEST-2026-AB12"
assert payload["state"] == "ACTIVE"
assert payload["barcode"]["type"] == "QR_CODE"
assert payload["eventName"]["defaultValue"]["value"] == "Summer Festival"
Plug.Conn.resp(conn, 200, Jason.encode!(%{"id" => payload["id"]}))
end)
```
For pure-shape tests that don't need an HTTP round-trip, call the builder
functions directly — `WalletPasses.Google.Api.build_pass_object/3` and
`build_class_object/1` return the JSON-able map you would have POSTed.
Examples in `test/wallet_passes/google/api_test.exs` and
`api_localization_test.exs`.
## The `:tz` Dev Dependency
`mix.exs` declares `{:tz, "~> 0.28", only: :test}`. This is **not** a
runtime dependency — consumers don't need to install `:tz` or `:tzdata`.
The library calls `DateTime.new/3` when formatting pass dates, which
requires a `Calendar.TimeZoneDatabase` configured for named zones like
`"America/New_York"`. The library defers to whatever the consuming app
configures. For the test suite we install `:tz` rather than `:tzdata`
because it's leaner — no on-disk IANA blob to fetch at boot.
If you maintain a consumer app, configure either `:tz` or `:tzdata` in
your own runtime — the library doesn't pick. If you contribute to the
library, keep `:tz` in `only: :test` so the package doesn't impose a
timezone-implementation choice on consumers.
## API Reference
Config keys relevant to local development. All are optional and default to
production URLs.
| Key | Default | Purpose |
|---------------------------|------------------------------------------------------------------|------------------------------------------------------------------------|
| `:apple_push_base_url` | `"https://api.push.apple.com:443"` | APNs base URL for `WalletPasses.notify_apple_devices/1`. |
| `:google_api_base_url` | `"https://walletobjects.googleapis.com/walletobjects/v1"` | Google Wallet API base URL for object/class CRUD. |
| `:google_token_url` | `"https://oauth2.googleapis.com/token"` | OAuth2 token endpoint for the service account JWT exchange. |
| `:pass_data_provider` | required (no default) | Module implementing `WalletPasses.PassDataProvider`. Swap in tests. |
The library reads these via `WalletPasses.Config` on every call — there is
no boot-time caching — so `Application.put_env/3` inside a test `setup`
takes effect immediately.
See the dev sandbox's `config/dev.exs` for a full working override set,
and `test/support/fake_services.ex` for the helpers that wire `bypass` to
the `MockApplePush` and `MockGoogleApi` routers. Both can be copied or
adapted into a consumer's own test helpers.