smokestack/lib/smokestack.ex
James Harton cb2d0376b5
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
fix: Include :variant in option schema.
This required a bit of a rework of how the options are validated.  Now they're only validated once when `Builder.build` is called instead of inside each builder.
2024-05-28 16:40:43 +12:00

261 lines
8 KiB
Elixir

defmodule Smokestack do
alias Spark.{Dsl, Dsl.Extension}
@moduledoc """
Smokestack provides a way to define test factories for your [Ash
Resources](https://ash-hq.org/docs/module/ash/latest/ash-resource) using a
convenient DSL:
```
defmodule MyApp.Factory do
use Smokestack
factory Character do
attribute :name, &Faker.StarWars.character/0
attribute :affiliation, choose(["Galactic Empire", "Rebel Alliance"])
end
end
defmodule MyApp.CharacterTest do
use MyApp.DataCase
use MyApp.Factory
test "it can build a character" do
assert character = insert!(Character)
end
end
```
## Templates
Each attribute uses a template to generate a value when building a factory.
Templates can be anything that implements the `Smokestack.Template` protocol.
This protocol is automatically implemented for functions with arities zero
through two - meaning you can just drop in your own functions - or use one of
the built-in helpers from `Smokestack.Dsl.Template`.
## Variants
Sometimes you need to make slightly different factories to build a resource in
a specific state for your test scenario.
Here's an example defining an alternate `:trek` variant for the character
factory defined above:
```
factory Character, :trek do
attribute :name, choose(["J.L. Pipes", "Severn", "Slickback"])
attribute :affiliation, choose(["Entrepreneur", "Voyager"])
end
```
## Building resource records
You can use `insert/2` and `insert!/2` to build and insert records. Records
are inserted using `Ash.Seed.seed!/2`, which means that they bypass the usual
Ash lifecycle (actions, validations, etc).
### Options
- `load`: an atom, list of atoms or keyword list of the same listing
relationships, calculations and aggregates that should be loaded after the
record is created.
- `count`: rather than inserting just a single record, you can specify a
number of records to be inserted. A list of records will be returned.
- `build`: an atom, list of atoms or keyword list of the same describing
relationships which you would like built alongside the record. If the
related resource has a variant which matches the current one, it will be
used, and if not the `:default` variant will be.
- `attrs`: A map or keyword list of attributes you would like to set directly
on the created record, rather than using the value provided by the factory.
- `relate`: A keyword list of relationships to records (or lists of records)
to which you wish to directly relate the created record.
## Building parameters
As well as inserting records directly you can use `params/2` and `params!/2`
to build parameters for use testing controller actions, HTTP requests, etc.
### Options
- `encode`: rather than returning a map or maps, provide an encoder module to
serialise the parameters. Commonly you would use `Jason` or `Poison`.
- `nest`: rather than returning a map or maps directly, wrap the result in an
outer map using the provided key.
- `key_case`: change the case of the keys into one of the many cases supported
by [recase](https://hex.pm/packages/recase).
- `key_type`: specify whether the returned map or maps should use string or
atom keys (ignored when using the `encode` option).
- `count`: rather than returning just a single map, you can specify a number
of results to be returned. A list of maps will be returned.
- `build`: an atom, list of atoms or keyword list of the same describing
relationships which you would like built within the result. If the related
resource has a variant which matches the current one, it will be used, and
if not the `:default` variant will be.
- `attrs`: A map or keyword list of attributes you would like to set directly
on the result, rather than using the value provided by the factory.
<!--- ash-hq-hide-start --> <!--- -->
## DSL Documentation
### Index
#{Extension.doc_index(Smokestack.Dsl.sections())}
### Docs
#{Extension.doc(Smokestack.Dsl.sections())}
<!--- ash-hq-hide-stop --> <!--- -->
"""
use Dsl, default_extensions: [extensions: [Smokestack.Dsl]]
alias Ash.Resource
alias Smokestack.{Builder, ParamBuilder, RecordBuilder}
@type t :: module
@type recursive_atom_list :: atom | [atom | {atom, recursive_atom_list()}]
@type param_option :: variant_option | ParamBuilder.option()
@type insert_option :: variant_option | RecordBuilder.option()
@typedoc """
Choose a factory variant to use. Defaults to `:default`.
"""
@type variant_option :: {:variant, atom}
@doc """
Runs a factory and uses it to build a parameters suitable for simulating a
request.
Automatically implemented by modules which `use Smokestack`.
## Options
#{Builder.docs(ParamBuilder, nil)}
"""
@callback params(Resource.t(), [param_option]) ::
{:ok, ParamBuilder.result()} | {:error, any}
@doc """
Raising version of `c:params/2`.
Automatically implemented by modules which `use Smokestack`.
## Options
#{Builder.docs(ParamBuilder, nil)}
"""
@callback params!(Resource.t(), [param_option]) :: ParamBuilder.result() | no_return
@doc """
Runs a factory and uses it to insert Ash resources into their data layers.
Automatically implemented by modules which `use Smokestack`.
## Options
#{Builder.docs(RecordBuilder, nil)}
"""
@callback insert(Resource.t(), [insert_option]) ::
{:ok, RecordBuilder.result()} | {:error, any}
@doc """
Raising version of `c:insert/2`.
Automatically implemented by modules which `use Smokestack`.
## Options
#{Builder.docs(RecordBuilder, nil)}
"""
@callback insert!(Resource.t(), [insert_option]) :: RecordBuilder.result() | no_return
@doc false
defmacro __using__(opts) do
[
quote do
@behaviour Smokestack
@moduledoc @moduledoc ||
"""
A Smokestack factory.
See `Smokestack` for more information.
"""
@doc """
Runs a factory and uses it to build a parameters suitable for simulating a
request.
## Options
#{Builder.docs(ParamBuilder, nil)}
"""
@spec params(Resource.t(), [Smokestack.param_option()]) ::
{:ok, ParamBuilder.result()} | {:error, any}
def params(resource, options \\ []) do
Builder.build(__MODULE__, resource, ParamBuilder, options)
end
@doc """
Raising version of `params/2`.
## Options
#{Builder.docs(ParamBuilder, nil)}
"""
@spec params!(Resource.t(), [Smokestack.param_option()]) ::
ParamBuilder.result() | no_return
def params!(resource, options \\ []) do
case params(resource, options) do
{:ok, result} -> result
{:error, reason} -> raise reason
end
end
@doc """
Execute the matching factory and return an inserted Ash Resource record.
## Options
#{Builder.docs(RecordBuilder, nil)}
"""
@spec insert(Resource.t(), [Smokestack.insert_option()]) ::
{:ok, RecordBuilder.result()} | {:error, any}
def insert(resource, options \\ []) do
Builder.build(__MODULE__, resource, RecordBuilder, options)
end
@doc """
Raising version of `insert/2`.
## Options
#{Builder.docs(RecordBuilder, nil)}
"""
@spec insert!(Resource.t(), [Smokestack.insert_option()]) ::
RecordBuilder.result() | no_return
def insert!(resource, options \\ []) do
case insert(resource, options) do
{:ok, result} -> result
{:error, reason} -> raise reason
end
end
defmacro __using__(_) do
quote do
import unquote(__MODULE__)
end
end
defoverridable params: 2,
params!: 2,
insert: 2,
insert!: 2,
__using__: 1
end
] ++ super(opts)
end
end