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. ## DSL Documentation ### Index #{Extension.doc_index(Smokestack.Dsl.sections())} ### Docs #{Extension.doc(Smokestack.Dsl.sections())} """ 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