Current section

Files

Jump to
cyclium lib cyclium test checkpoint_migration.ex
Raw

lib/cyclium/test/checkpoint_migration.ex

defmodule Cyclium.Test.CheckpointMigration do
@moduledoc """
Test helpers for property-based testing of checkpoint schema migrations.
Provides generators and assertion helpers so host apps can verify that
`migrate/2` callbacks safely transform any V1 state to the current version.
Requires `{:stream_data, "~> 1.0", only: :test}` in your deps.
## Usage
defmodule MyApp.Checkpoints.POInvestigationTest do
use ExUnit.Case, async: true
use Cyclium.Test.CheckpointMigration
describe "migration safety" do
test "v1 -> current succeeds for any state" do
assert_migration_safe(MyApp.Checkpoints.POInvestigation,
from: 1,
generator: fn ->
StreamData.fixed_map(%{
"vendor_id" => StreamData.string(:alphanumeric, min_length: 1),
"vendor_contacts" => StreamData.list_of(
StreamData.map_of(
StreamData.string(:alphanumeric),
StreamData.string(:alphanumeric)
)
)
})
end
)
end
end
end
If no generator is provided, a default fuzz generator produces random
nested maps with string keys and mixed-type values.
"""
defmacro __using__(_opts) do
quote do
import Cyclium.Test.CheckpointMigration
end
end
@doc """
Assert that migrating from `from` to current version succeeds for all
generated states. Runs 100 iterations by default.
## Options
* `:from` — starting version (required)
* `:generator` — 0-arity function returning a StreamData generator (optional)
* `:iterations` — number of property test iterations (default: 100)
"""
defmacro assert_migration_safe(schema_module, opts) do
quote bind_quoted: [schema_module: schema_module, opts: opts] do
from = Keyword.fetch!(opts, :from)
iterations = Keyword.get(opts, :iterations, 100)
generator =
case Keyword.get(opts, :generator) do
nil -> Cyclium.Test.CheckpointMigration.default_state_generator()
gen_fn -> gen_fn.()
end
generator
|> Enum.take(iterations)
|> Enum.each(fn state ->
case schema_module.migrate_to_current(from, state) do
{:ok, migrated} ->
assert is_map(migrated),
"migrate_to_current(#{from}, #{inspect(state)}) returned non-map: #{inspect(migrated)}"
{:error, reason} ->
flunk("migrate_to_current(#{from}, #{inspect(state)}) failed: #{inspect(reason)}")
end
end)
end
end
@doc """
Assert that migrating a specific state from one version to current produces
the expected result.
"""
defmacro assert_migration(schema_module, from, state, expected) do
quote bind_quoted: [
schema_module: schema_module,
from: from,
state: state,
expected: expected
] do
assert {:ok, ^expected} = schema_module.migrate_to_current(from, state)
end
end
@doc """
Assert that migration is idempotent — migrating an already-current state
returns it unchanged.
"""
defmacro assert_migration_idempotent(schema_module, state) do
quote bind_quoted: [schema_module: schema_module, state: state] do
version = schema_module.__checkpoint_version__()
assert {:ok, ^state} = schema_module.migrate_to_current(version, state)
end
end
@doc """
Returns a default StreamData generator for random map states with
string keys and mixed values. Useful for fuzz testing migrations
that should be resilient to unexpected keys.
Only callable at test time (requires StreamData).
"""
def default_state_generator do
StreamData.map_of(
StreamData.string(:alphanumeric, min_length: 1, max_length: 20),
leaf_generator(),
max_length: 10
)
end
defp leaf_generator do
StreamData.one_of([
StreamData.string(:alphanumeric, max_length: 50),
StreamData.integer(),
StreamData.float(min: -1000.0, max: 1000.0),
StreamData.boolean(),
StreamData.constant(nil),
StreamData.list_of(StreamData.string(:alphanumeric, max_length: 20), max_length: 5)
])
end
end