Skip to main content

guides/local-development.md

# 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.