Current section
Files
Jump to
Current section
Files
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.{
Accounts,
ApplicationFeeRefunds,
ApplicationFees,
BalanceTransactions,
BankAccounts,
BillingPortalConfigurations,
BillingPortalSessions,
Cards,
Charges,
CheckoutSessions,
ConfirmationTokens,
Coupons,
CreditNotes,
CustomerBalanceTransactions,
Customers,
Disputes,
Events,
InvoiceItems,
Invoices,
Mandates,
PaymentIntents,
PaymentLinks,
PaymentMethodConfigurations,
PaymentMethodDomains,
PaymentMethods,
Payouts,
Plans,
Prices,
Products,
PromotionCodes,
Refunds,
Reviews,
SetupAttempts,
SetupIntents,
Sources,
SubscriptionItems,
Subscriptions,
TaxRates,
Tokens,
Topups,
TransferReversals,
Transfers,
Webhooks
}
@doc """
Starts the PaperTiger application.
## Options
- `:port` - HTTP port (default: random port 59000-60000, reserved before startup)
- `:transport_options` - TCP listen options passed to Thousand Island (default: `[]`)
- `: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([clock_mode: :real, auto_start: true], opts)
# Configure and start application
# Only set port if explicitly provided - otherwise Application will pick random available port
if Keyword.has_key?(opts, :port) do
Application.put_env(:paper_tiger, :port, opts[:port])
end
if Keyword.has_key?(opts, :transport_options) do
Application.put_env(:paper_tiger, :transport_options, opts[:transport_options])
end
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 port PaperTiger will use.
If PaperTiger is already running, returns the actual port in use.
If not started yet, it resolves and caches the port so config and runtime stay consistent.
## Examples
PaperTiger.get_port()
#=> 59342
"""
@spec get_port() :: integer()
def get_port do
PaperTiger.Port.resolve()
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.
PaperTiger supports namespace-based isolation (used by `PaperTiger.Test`) so
multiple test suites can run concurrently. `flush/0` clears data **only for
the current namespace** (default `:global`).
If you need to wipe *all* namespaces, use `flush_all/0`.
## Examples
PaperTiger.flush() # Clear all resources in current namespace
PaperTiger.flush(:customers) # Clear only customers
"""
@spec flush() :: :ok
def flush do
namespace = PaperTiger.Test.current_namespace()
# Clear only the current namespace so concurrent sandboxed tests don't
# destroy each other's state.
:ok = PaperTiger.Test.cleanup_namespace(namespace)
# Reset ChaosCoordinator for this namespace.
PaperTiger.ChaosCoordinator.reset()
# Pre-defined Stripe test tokens live in the global namespace.
if namespace == :global do
{:ok, _stats} = PaperTiger.TestTokens.load()
end
:ok
end
@doc """
Flushes (clears) all resources across all namespaces.
This is the legacy behavior of `flush/0`. Prefer `flush/0` when running tests
concurrently with `PaperTiger.Test` sandboxing.
"""
@spec flush_all() :: :ok
def flush_all 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 (global namespace)
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(:accounts), do: Accounts.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(:credit_notes), do: CreditNotes.clear()
def flush(:products), do: Products.clear()
def flush(:prices), do: Prices.clear()
def flush(:payment_links), do: PaymentLinks.clear()
def flush(:plans), do: Plans.clear()
def flush(:payment_methods), do: PaymentMethods.clear()
def flush(:payment_method_domains), do: PaymentMethodDomains.clear()
def flush(:payment_method_configurations), do: PaymentMethodConfigurations.clear()
def flush(:confirmation_tokens), do: ConfirmationTokens.clear()
def flush(:mandates), do: Mandates.clear()
def flush(:payment_intents), do: PaymentIntents.clear()
def flush(:setup_attempts), do: SetupAttempts.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(:promotion_codes), do: PromotionCodes.clear()
def flush(:customer_balance_transactions), do: CustomerBalanceTransactions.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(:billing_portal_configurations), do: BillingPortalConfigurations.clear()
def flush(:billing_portal_sessions), do: BillingPortalSessions.clear()
def flush(:webhooks), do: Webhooks.clear()
def flush(:events), do: Events.clear()
def flush(:payouts), do: Payouts.clear()
def flush(:transfers), do: Transfers.clear()
def flush(:transfer_reversals), do: TransferReversals.clear()
def flush(:balance_transactions), do: BalanceTransactions.clear()
def flush(:application_fees), do: ApplicationFees.clear()
def flush(:application_fee_refunds), do: ApplicationFeeRefunds.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: resolve and cache before startup)
- `: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
# Resolve port deterministically even before PaperTiger starts
port = Keyword.get_lazy(opts, :port, fn -> get_port() end)
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