Current section

Files

Jump to
paper_tiger lib paper_tiger.ex
Raw

lib/paper_tiger.ex

defmodule PaperTiger do
@moduledoc """
PaperTiger - A stateful mock Stripe server for testing.
## Quick Start
# Start the server
{:ok, _pid} = PaperTiger.start()
# Make API calls via HTTP
response = HTTP.post!("/v1/customers", ...)
# Clean up between tests
PaperTiger.flush()
## Time Control
PaperTiger supports three clock modes for deterministic testing:
- `:real` - Use system time (default)
- `:accelerated` - Time moves faster (useful for subscription billing tests)
- `:manual` - Freeze time and advance manually
## Examples
# Manual time control
PaperTiger.set_clock_mode(:manual, timestamp: 1234567890)
PaperTiger.advance_time(3600) # Advance 1 hour
# Accelerated time (10x speed)
PaperTiger.set_clock_mode(:accelerated, multiplier: 10)
## Resource Cleanup
# Flush specific resource
PaperTiger.flush(:customers)
# Flush all resources
PaperTiger.flush()
"""
alias PaperTiger.Store.{
ApplicationFees,
BalanceTransactions,
BankAccounts,
Cards,
Charges,
CheckoutSessions,
Coupons,
Customers,
Disputes,
Events,
InvoiceItems,
Invoices,
PaymentIntents,
PaymentMethods,
Payouts,
Plans,
Prices,
Products,
Refunds,
Reviews,
SetupIntents,
Sources,
SubscriptionItems,
Subscriptions,
TaxRates,
Tokens,
Topups,
Webhooks
}
@doc """
Starts the PaperTiger application.
## Options
- `:port` - HTTP port (default: 4001, avoids Phoenix's 4000)
- `:clock_mode` - Time mode (default: `:real`)
## Examples
{:ok, _pid} = PaperTiger.start()
{:ok, _pid} = PaperTiger.start(port: 4002, clock_mode: :manual)
"""
@spec start(keyword()) :: {:ok, pid()} | {:error, term()}
def start(opts \\ []) do
opts = Keyword.merge([port: 4001, clock_mode: :real, auto_start: true], opts)
# Configure and start application
Application.put_env(:paper_tiger, :port, opts[:port])
Application.put_env(:paper_tiger, :clock_mode, opts[:clock_mode])
Application.put_env(:paper_tiger, :auto_start, opts[:auto_start])
Application.ensure_all_started(:paper_tiger)
end
@doc """
Returns the current Unix timestamp according to the configured clock.
## Examples
PaperTiger.now()
#=> 1234567890
"""
@spec now() :: integer()
defdelegate now(), to: PaperTiger.Clock
@doc """
Sets the clock mode.
## Modes
- `:real` - Use system time
- `:accelerated` - Time runs faster (specify `:multiplier` option)
- `:manual` - Manual control (specify `:timestamp` option)
## Examples
PaperTiger.set_clock_mode(:real)
PaperTiger.set_clock_mode(:accelerated, multiplier: 10)
PaperTiger.set_clock_mode(:manual, timestamp: 1234567890)
"""
@spec set_clock_mode(atom(), keyword()) :: :ok
defdelegate set_clock_mode(mode, opts \\ []), to: PaperTiger.Clock, as: :set_mode
@doc """
Flushes (clears) all resources or a specific resource type.
Dynamically discovers all PaperTiger ETS tables (`:paper_tiger_*`)
and clears them. This prevents state leakage between tests without
requiring manual maintenance of resource lists.
## Examples
PaperTiger.flush() # Clear all resources
PaperTiger.flush(:customers) # Clear only customers
"""
@spec flush() :: :ok
def flush do
# Dynamically find all PaperTiger ETS tables
:ets.all()
|> Enum.filter(fn table_name ->
# Only process atom table names (some ETS tables use references)
if is_atom(table_name) do
table_str = Atom.to_string(table_name)
String.starts_with?(table_str, "paper_tiger_")
else
false
end
end)
|> Enum.each(fn table_name ->
:ets.delete_all_objects(table_name)
end)
# Also clear idempotency cache via its API
PaperTiger.Idempotency.clear()
# Reset chaos coordinator
PaperTiger.ChaosCoordinator.reset()
# Reload pre-defined Stripe test tokens (pm_card_visa, tok_visa, etc.)
{:ok, _stats} = PaperTiger.TestTokens.load()
:ok
end
@spec flush(atom()) :: :ok | {:error, :unknown_resource}
def flush(:customers), do: Customers.clear()
def flush(:subscriptions), do: Subscriptions.clear()
def flush(:subscription_items), do: SubscriptionItems.clear()
def flush(:invoices), do: Invoices.clear()
def flush(:invoice_items), do: InvoiceItems.clear()
def flush(:products), do: Products.clear()
def flush(:prices), do: Prices.clear()
def flush(:plans), do: Plans.clear()
def flush(:payment_methods), do: PaymentMethods.clear()
def flush(:payment_intents), do: PaymentIntents.clear()
def flush(:setup_intents), do: SetupIntents.clear()
def flush(:charges), do: Charges.clear()
def flush(:refunds), do: Refunds.clear()
def flush(:disputes), do: Disputes.clear()
def flush(:coupons), do: Coupons.clear()
def flush(:tax_rates), do: TaxRates.clear()
def flush(:cards), do: Cards.clear()
def flush(:bank_accounts), do: BankAccounts.clear()
def flush(:sources), do: Sources.clear()
def flush(:tokens), do: Tokens.clear()
def flush(:checkout_sessions), do: CheckoutSessions.clear()
def flush(:webhooks), do: Webhooks.clear()
def flush(:events), do: Events.clear()
def flush(:payouts), do: Payouts.clear()
def flush(:balance_transactions), do: BalanceTransactions.clear()
def flush(:application_fees), do: ApplicationFees.clear()
def flush(:reviews), do: Reviews.clear()
def flush(:topups), do: Topups.clear()
def flush(_), do: {:error, :unknown_resource}
@doc """
Advances time in manual mode.
## Examples
PaperTiger.advance_time(seconds: 3600)
PaperTiger.advance_time(days: 30)
PaperTiger.advance_time(86400) # 1 day
"""
@spec advance_time(integer() | keyword()) :: :ok
defdelegate advance_time(amount), to: PaperTiger.Clock, as: :advance
@doc """
Returns the current clock mode.
## Examples
PaperTiger.clock_mode()
#=> :real
"""
@spec clock_mode() :: atom()
defdelegate clock_mode(), to: PaperTiger.Clock, as: :get_mode
@doc """
Registers a webhook endpoint for test orchestration.
This is used by the `POST /_config/webhooks` endpoint for test setup.
## Parameters
- `:url` - Webhook endpoint URL (required)
- `:secret` - Webhook signing secret (default: "whsec_paper_tiger_test")
- `:events` - List of event types to subscribe to (default: ["*"] for all events)
## Examples
# Register with all events
PaperTiger.register_webhook(url: "http://localhost:4000/webhooks/stripe")
# Register with specific events
PaperTiger.register_webhook(
url: "http://localhost:4000/webhooks/stripe",
secret: "whsec_custom",
events: ["customer.created", "invoice.paid"]
)
"""
@spec register_webhook(keyword()) :: {:ok, map()}
def register_webhook(opts) do
import PaperTiger.Resource
webhook = %{
api_version: "2023-10-16",
connect: false,
created: now(),
enabled_events: Keyword.get(opts, :events, ["*"]),
id: generate_id("we"),
livemode: false,
metadata: %{},
object: "webhook_endpoint",
secret: Keyword.get(opts, :secret, "whsec_paper_tiger_test"),
status: "enabled",
url: Keyword.fetch!(opts, :url),
version: nil
}
Webhooks.insert(webhook)
end
@doc """
Registers webhook endpoints from application configuration.
This function reads webhook endpoints from the `:paper_tiger, :webhooks` config
and registers them automatically. Useful for setting up test webhooks at startup.
## Configuration
# In config/test.exs
config :paper_tiger,
webhooks: [
[url: "http://localhost:4000/webhooks/stripe"],
[url: "http://localhost:4000/webhooks/events", events: ["invoice.paid"]]
]
## Examples
# Register all configured webhooks
PaperTiger.register_configured_webhooks()
#=> {:ok, [%{id: "we_..."}, %{id: "we_..."}]}
## Returns
`{:ok, webhooks}` where webhooks is a list of registered webhook maps.
"""
@spec register_configured_webhooks() :: {:ok, [map()]}
def register_configured_webhooks do
webhooks = Application.get_env(:paper_tiger, :webhooks, [])
registered =
webhooks
|> Enum.map(fn webhook_opts ->
{:ok, webhook} = register_webhook(webhook_opts)
webhook
end)
{:ok, registered}
end
@doc """
Returns configuration for stripity_stripe to use PaperTiger as the Stripe API backend.
This helper generates the configuration needed to point stripity_stripe at
PaperTiger instead of the real Stripe API. Use this in your config files or
test setup to simplify integration.
## Options
- `:port` - PaperTiger port (default: 4001)
- `:host` - PaperTiger host (default: "localhost")
- `:webhook_secret` - Webhook signing secret (default: "whsec_paper_tiger_test")
- `:sandbox` - Enable sandbox isolation for concurrent tests (default: true)
## Sandbox Mode
When `sandbox: true` (the default), the returned config includes
`http_module: PaperTiger.StripityStripeHackney`, which injects namespace
headers for test isolation. This allows tests to run concurrently without
data leakage between tests.
Set `sandbox: false` if you want global (non-isolated) mode, though this
is rarely needed.
## Examples
# In config/test.exs (recommended)
config :stripity_stripe, PaperTiger.stripity_stripe_config()
# With custom options
config :stripity_stripe, PaperTiger.stripity_stripe_config(port: 4002)
# Disable sandbox (not recommended for concurrent tests)
config :stripity_stripe, PaperTiger.stripity_stripe_config(sandbox: false)
## Returns
Keyword list with:
- `:api_key` - Mock API key
- `:public_key` - Mock publishable key
- `:api_base_url` - URL pointing to PaperTiger
- `:webhook_signing_key` - Webhook signing secret
- `:http_module` - Custom HTTP module for sandbox isolation (when sandbox: true)
"""
@spec stripity_stripe_config(keyword()) :: keyword()
def stripity_stripe_config(opts \\ []) do
port = Keyword.get(opts, :port, 4001)
host = Keyword.get(opts, :host, "localhost")
webhook_secret = Keyword.get(opts, :webhook_secret, "whsec_paper_tiger_test")
sandbox = Keyword.get(opts, :sandbox, true)
base_config = [
api_key: "sk_test_paper_tiger",
public_key: "pk_test_paper_tiger",
# NOTE: stripity_stripe appends endpoints like "/v1/products" to this URL,
# so do NOT include "/v1" here
api_base_url: "http://#{host}:#{port}",
webhook_signing_key: webhook_secret
]
if sandbox do
base_config ++ [http_module: PaperTiger.StripityStripeHackney]
else
base_config
end
end
end