README.md

# AshScenario

Ash Scenario allows you to define reusable test data for your application. It provides two main approaches:

1. **Prototype Definitions**: Reusable data templates defined in your Ash resources
2. **Test Scenarios**: Override and compose prototypes in test modules with named scenarios

It can be used for tests, staging environments, seeding, and more.

## Prototype Definitions
Prototypes are defined on top of Ash resources using a DSL:
- The name of a test resource
- The default attributes
- The default relationships
- Automatic dependency resolution

## Test Scenarios
When writing tests, you can define scenarios that override specific attributes from your prototype definitions while maintaining automatic dependency resolution.


## Quick Start

### Examples directory

For a self-contained demo, explore the Mix project in `examples/`:

```bash
cd examples
mix deps.get
mix test
```

It defines a multi-tenant launch workspace domain (organizations, projects,
tasks, and checklist items) with scenarios that exercise dependency resolution,
overrides, and tenant-aware updates.

### 1. Add the DSL to your resources

```elixir
defmodule Blog do
  use Ash.Resource,
    domain: Domain,
    extensions: [AshScenario.Dsl]

  attributes do
    uuid_primary_key :id
    attribute :name, :string do
      public? true
    end
  end

  actions do
    defaults [:read]
    create :create do
      accept [:name]
    end
  end

  # Define reusable test data prototypes
  prototypes do
    prototype :example_blog do
      attr :name, "Example Blog"
    end

    prototype :tech_blog do
      attr :name, "Tech Blog"
    end
  end
end

defmodule Post do
  use Ash.Resource,
    domain: Domain,
    extensions: [AshScenario.Dsl]

  attributes do
    uuid_primary_key :id
    attribute :title, :string do
      public? true
    end
    attribute :content, :string do
      public? true
    end
  end

  relationships do
    belongs_to :blog, Blog do
      public? true
    end
  end

  actions do
    defaults [:read]
    create :create do
      accept [:title, :content, :blog_id]
    end
  end

  prototypes do
    prototype :example_post do
      attr :title, "A post title"
      attr :content, "The content of the example post"
      # Reference to example_blog prototype
      attr :blog_id, :example_blog
    end

    prototype :another_post do
      attr :title, "Another post title"
      attr :content, "Different content"
      attr :blog_id, :example_blog
    end
  end
end
```

### 2. Create prototypes in your code

```elixir
# Create a single prototype (returns a map)
{:ok, resources} = AshScenario.run([{Blog, :example_blog}], domain: Domain)
blog = resources[{Blog, :example_blog}]

# Create multiple prototypes with automatic dependency resolution
{:ok, resources} = AshScenario.run([
  {Blog, :example_blog},
  {Post, :example_post}
], domain: Domain)

# blog_id reference is automatically resolved to the created blog's ID
blog = resources[{Blog, :example_blog}]
post = resources[{Post, :example_post}]
assert post.blog_id == blog.id
```

Overrides (first-class)

You can override attributes inline when creating prototypes:

```elixir
# Single prototype with overrides
{:ok, resources} = AshScenario.run(
  [{Post, :example_post}],
  domain: Domain,
  overrides: %{title: "Custom title"}
)
post = resources[{Post, :example_post}]

# Multiple prototypes: per-tuple overrides
{:ok, resources} = AshScenario.run([
  {Blog, :example_blog, %{name: "Custom Blog"}},
  {Post, :example_post, %{title: "Custom Post"}}
], domain: Domain)

# Multiple prototypes: top-level overrides map keyed by {Module, :ref}
overrides = %{
  {Blog, :example_blog} => %{name: "Top-level Blog"},
  {Post, :example_post} => %{title: "Top-level Post"}
}
{:ok, resources} = AshScenario.run([
  {Blog, :example_blog},
  {Post, :example_post}
], domain: Domain, overrides: overrides)
```

Notes:
- Overrides are merged with the prototype’s defined attributes before relationship resolution.
- Relationship atoms you set (like `blog_id: :example_blog`) still resolve to IDs as usual.
- For a single-resource call, `overrides: %{...}` is shorthand — no tuple key is needed.

### 3. Test Scenarios

Test scenarios let you override specific attributes while maintaining dependency resolution. They are fully implemented and ready to use:

```elixir
defmodule MyTest do
  use ExUnit.Case
  use AshScenario.Scenario

    scenario :basic_setup do
      prototype :another_post do
        attr(:title, "Custom title for this test")
      end
    end

    scenario :with_custom_blog do
      prototype :tech_blog do
        attr(:name, "My Custom Tech Blog")
      end
      prototype :another_post do
        attr(:title, "Post in custom blog")
        attr(:blog_id, :tech_blog)  # Use the custom blog
      end
  end

  test "basic scenario" do
    {:ok, resources} = AshScenario.run_scenario(__MODULE__, :basic_setup)
    assert resources.another_post.title == "Custom title for this test"
    assert resources.example_blog.name == "Example Blog"  # From prototype defaults
  end
end
```

You can also pass a specific `:domain` if you don't want it inferred from the resource modules:

```elixir
{:ok, resources} = AshScenario.run_scenario(MyTest, :basic_setup, domain: MyApp.Domain)
```

### 4. Custom Functions

You can specify a custom function to create resources instead of using the default `Ash.create` action. This is useful for complex setup logic, factory functions, or integration with existing test data builders:

```elixir
defmodule MyFactory do
  def create_blog(attributes, _opts) do
    # Custom creation logic
    blog = %Blog{
      id: Ash.UUID.generate(),
      name: attributes[:name] || "Default Blog"
    }
    {:ok, blog}
  end

  def create_post_with_tags(attributes, _opts) do
    # More complex creation with additional setup
    post = %Post{
      id: Ash.UUID.generate(),
      title: attributes[:title],
      blog_id: attributes[:blog_id],
      status: attributes[:status] || :draft
    }

    # Custom logic here - add tags, send notifications, etc.
    {:ok, post}
  end
end

defmodule Blog do
  use Ash.Resource,
    domain: Domain,
    extensions: [AshScenario.Dsl]

  # ... attributes, actions, etc. ...

  prototypes do
    # Module-level create configuration for this resource module
    create function: {MyFactory, :create_blog, []}

    prototype :factory_blog do
      attr :name, "Factory Blog"
    end
  end
end

defmodule Post do
  use Ash.Resource,
    domain: Domain,
    extensions: [AshScenario.Dsl]

  # ... attributes, relationships, actions ...

  prototypes do
    # Separate module-level configuration for Post creation
    create function: {MyFactory, :create_post_with_tags, []}

    prototype :custom_post do
      attr :title, "Custom Post"
      # Preserved as atom (not a relationship)
      attr :status, :published
      # Resolved to actual blog ID
      attr :blog_id, :factory_blog
    end
  end
end
```

#### Custom Function Requirements

Your custom function must:
- Accept `(resolved_attributes, opts)` as parameters
- Return `{:ok, created_resource}` or `{:error, reason}`
- Handle the resolved attributes where relationship references are already converted to IDs

Notes:
- You can configure creation at the module level (via `create ...`) or per resource (via `action:`/`function:` on a specific `resource`).
- Precedence: resource.function > resource.action > module-level create.function > module-level create.action (default `:create`).

```elixir
def my_custom_function(resolved_attributes, opts) do
  # resolved_attributes example:
  # %{
  #   name: "Factory Blog",
  #   status: :published,           # Non-relationship atoms preserved
  #   blog_id: "uuid-string-here"   # Relationship references resolved to IDs
  # }

  # Your custom creation logic here
  {:ok, created_resource}
end
```

### Scenario Extension (Inheritance)

Scenarios can extend other scenarios using the `extends` option, allowing you to build hierarchical test setups:

```elixir
defmodule MyTest do
  use ExUnit.Case
  use AshScenario.Scenario

    # Base scenario
    scenario :base_setup do
      prototype :example_post do
        attr(:title, "Base Post 123lshdfkjglsdfg")
        # attr(:content, "Base content")
      end
    end

    # Extended scenario - inherits from base and adds/overrides
    scenario :extended_setup do
      extends(:base_setup)

      prototype :example_post do
        attr(:title, "Extended Post")  # Override title
        # content is inherited as "Base content"
      end

      prototype :another_post do  # Add new resource
        attr(:title, "Additional post")
        attr(:content, "More content")
      end
    end

  test "extended scenario" do
    {:ok, resources} = AshScenario.run_scenario(__MODULE__, :extended_setup)

    # Has inherited resources
    assert resources.example_blog.name == "Base Blog"
    assert resources.example_post.content == "Base content"  # Inherited

    # Has overridden attributes
    assert resources.example_post.title == "Extended Post"  # Overridden

    # Has new resources from extension
    assert resources.another_post.title == "Additional post"
  end
end
```

## Key Features

- **Automatic Dependency Resolution**: Resources are created in the correct order based on relationships
- **Reference Resolution**: `:resource_name` references are automatically resolved to actual IDs
- **Reusable Definitions**: Define resources once, use them in multiple contexts
- **Override Support**: Test scenarios can override specific attributes while keeping defaults
- **Scenario Extension**: Build hierarchical scenarios using `extends: :base_scenario`
- **Custom Functions**: Use any function as an alternative to the default create action
- **Hardened Resolution**: Only relationship attributes are resolved; other atoms are preserved
- **Virtual Attributes**: Pass action arguments (not stored attributes) via `virtual: true`

## Scenario API

### Virtual Attributes (Action Arguments)

Some create actions accept arguments that are not stored as attributes on the resource (e.g., `password`, `password_confirmation` for an auth flow). You can include these in prototype definitions by marking them as virtual. Virtual attributes skip compile-time validation against the resource schema and are passed into the create action input, allowing Ash to treat them as action arguments.

```elixir
defmodule User do
  use Ash.Resource,
    domain: Domain,
    extensions: [AshScenario.Dsl]

  attributes do
    uuid_primary_key :id
    attribute :email, :string do
      public? true
    end
  end

  actions do
    create :register do
      accept [:email]
      # Action arguments that are not attributes
      argument :password, :string, allow_nil?: false
      argument :password_confirmation, :string, allow_nil?: false
    end
  end

  prototypes do
    create action: :register

    prototype :admin_user do
      attr :email, "admin@example.com"
      attr :password, "s3cret", virtual: true
      attr :password_confirmation, "s3cret", virtual: true
    end
  end
end
```

Notes:
- Virtual attributes are not validated against the resource's attributes/relationships.
- They are included in the map passed to `Ash.Changeset.for_create/3`, so if your create action defines corresponding `argument`s, Ash will consume them correctly.
- This also plays nicely with custom `create function:` usage; your factory function receives the same key/value pairs.

```elixir
# Enable the Scenario DSL in a test module
use AshScenario.Scenario

# Define scenarios
  scenario :my_setup do
    prototype :example_post do
      attr(:title, "Overridden title")
    end
  end

# Run a scenario
{:ok, resources} = AshScenario.run_scenario(__MODULE__, :my_setup, domain: MyApp.Domain)

# Access created resources by their prototype names (atoms)
resources.example_post.title
resources.example_blog.id
```

### Identifiers

- Resource definition metadata uses `ref` as the identifier (e.g., `example_post.ref == :example_post`).
- Earlier examples or code using a metadata field named `name` should be updated to use `ref`.
- This does not affect your domain resource attributes (like a blog's `:name` string); those remain unchanged and are still accessed as struct fields (e.g., `blog.name`).

## API Reference

### Prototype Management

```elixir
# Create prototypes with database persistence (default)
AshScenario.run(prototype_list, opts)
AshScenario.run_all(Module, opts)

# Create prototypes as in-memory structs (no database)
AshScenario.run(prototype_list, strategy: :struct)
AshScenario.run_all(Module, strategy: :struct)

# Run named scenarios
AshScenario.run_scenario(TestModule, :scenario_name, opts)
```

### Introspection

```elixir
# New API
AshScenario.prototypes(Module)         # Get all prototype definitions
AshScenario.prototype(Module, :name)   # Get specific prototype definition
AshScenario.has_prototypes?(Module)    # Check if module has prototypes
AshScenario.prototype_names(Module)    # Get all prototype names
```


### Per-Prototype Overrides

You can override creation behavior for a specific prototype instance via a nested `create` (mirrors module-level `create`):

```elixir
prototypes do
  # Use a specific action just for this instance
  prototype :published_example do
    create action: :publish
    attr :title, "Published Title"
    attr :content, "Body"
    attr :blog_id, :example_blog
  end

  # Or override with a custom function just for this resource
  prototype :factory_post do
    create function: {MyFactory, :create_post_with_tags, ["PREFIX"]}
    attr :title, "Factory Post"
    attr :blog_id, :example_blog
  end
end

# Precedence:
# 1) prototype.create.function (or prototype.function)
# 2) prototype.create.action (or prototype.action)
# 3) module-level create.function
# 4) module-level create.action (default :create)
```

## Architecture

- **Dependency Graph**: Prototypes are analyzed for dependencies and created in topological order
- **Reference Resolution**: Prototype references (like `:example_blog`) are resolved to actual resource IDs at runtime
- **Registry**: A GenServer maintains the registry of all prototype definitions across modules

## Contributing

### Development Setup

After cloning the repository and installing dependencies:

```bash
# Install dependencies
mix deps.get
```

The project uses `git_hooks` to manage git hooks. The pre-commit hook will automatically format staged Elixir files to ensure consistent code style.

### Code Quality

Before committing, ensure your code passes all quality checks:

```bash
# Run all quality checks
mix check

# Run tests
mix test

# Format code
mix format
```