happycollision

happycollision

Queries (within validations) potentially behave differently in ex_unit tests

I’ve got two functions that run the same code, one inside an ex_unit test, and one just in a Livebook cell. They are both designed to test that my validation is working, so both should return an error. But the ex_unit one fails the test (of not returning an error).

# inside ex_unit, inside a Livebook cell
ExUnit.start(auto_run: false)

defmodule BoxOfficeFeaturesTest do
  use ExUnit.Case, async: false
  import BoxOffice.Core.Test

  setup do
    BoxOffice.Core.Test.reset()
  end

  describe "Reservations" do
    test "a reserved seat cannot be reserved again for the same performance" do
      create_seating_chart()
      [seat] = create_seats().records
      populate_seating_chart()
      create_production()
      performance = create_performance()

      assert {:ok, _} =
               BoxOffice.Core.Reservation.make(create_patron().id, performance.id, seat.id)

      assert {:error, _} =
               BoxOffice.Core.Reservation.make(create_patron().id, performance.id, seat.id)
    end
  end
end

results = ExUnit.run()

# Livebook cell
defmodule WhoTestsTheTesters do
  import BoxOffice.Core.Test

  def run() do
    create_seating_chart()
    [seat] = create_seats().records
    populate_seating_chart()
    create_production()
    performance = create_performance()

    {:ok, _} =
      BoxOffice.Core.Reservation.make(create_patron().id, performance.id, seat.id)

    {:error, _} =
      BoxOffice.Core.Reservation.make(create_patron().id, performance.id, seat.id)
  end
end

BoxOffice.Core.Test.reset()
WhoTestsTheTesters.run()

The latter is not throwing because it (correctly) matches {:error, _}. Just returns the error value.

Any idea why these two things would behave differently? Here’s the resource under test. In particular, I’ve discovered that previous_reservation is always [] in the test scenario:

defmodule BoxOffice.Core.Reservation do
  require Ash.Query

  use Ash.Resource,
    data_layer: Ash.DataLayer.Ets

  actions do
    defaults([:read, :update])

    # On creation set the patron by providing the id
    create(:make, do: accept([:patron_id, :performance_id, :seat_id]))
  end

  attributes do
    uuid_primary_key(:id)

    create_timestamp(:created_at)
    update_timestamp(:updated_at)
  end

  relationships do
    belongs_to(:patron, BoxOffice.Core.Patron) do
      # Set to writable so you can directly set the patron_id inside the :open action
      attribute_writable?(true)
    end

    belongs_to(:seat, BoxOffice.Core.Seat) do
      attribute_writable?(true)
    end

    belongs_to(:performance, BoxOffice.Core.Performance) do
      attribute_writable?(true)
    end
  end

  code_interface do
    define_for(BoxOffice.Core)

    define(:make, args: [:patron_id, :performance_id, :seat_id])
  end

  identities do
    # pre_check_with might only be necessary for ETS data layer.
    # see https://hexdocs.pm/ash/identities.html#pre-checking
    identity(:reservation_identity, [:seat_id, :performance_id], pre_check_with: BoxOffice.Core)
  end

  validations do
    validate(fn changeset ->
      seat_id = Ash.Changeset.get_attribute(changeset, :seat_id)
      performance_id = Ash.Changeset.get_attribute(changeset, :performance_id)

      previous_reservation =
        __MODULE__
        |> Ash.Query.filter(performance_id == ^performance_id and seat_id == ^seat_id)
        |> BoxOffice.Core.read!()
        |> dbg

      case previous_reservation do
        [] -> :ok
        [_] -> {:error, field: :seat_id, message: "This seat is already reserved"}
      end
    end)
  end
end

First 6 of 6 Posts Switch mode

zachdaniel

zachdaniel

Creator of Ash

Have you added this to your test config in test.exs?

config :ash, :disable_async?, true
happycollision

happycollision

Seems like that could be it, because when I eliminate all the other tests, the one in question passes. But I am in Livebook and am not sure how to translate that config. I mean, I’ve done configs in Livebook before, but I also see some put_env stuff happening with Ash that I don’t understand. So I tried both.

I appreciate your help, and I’ll probably keep digging when Friday rolls around again. But if you are curious, or overly-generous with your time, here’s the entire Livebook:

# Ticketing core

```elixir
# Copy from Ash Livebook Tutorial
Application.put_env(:ash, :validate_api_resource_inclusion?, false)
Application.put_env(:ash, :validate_api_config_inclusion?, false)

# Attempt to make tests pass
Application.put_env(:ash, :disable_async?, true)

Mix.install([:ash, :kino, {:tz, "~> 0.26.5"}],
  config: [
    elixir: [time_zone_database: Tz.TimeZoneDatabase],
    # Separate attempt to make tests pass
    ash: [disable_async?: true]
  ],
  # Copy from Ash Livebook Tutorial
  consolidate_protocols: false
)
```

## Core definitions in Ash

```elixir
defmodule BoxOffice.Core.Patron do
  use Ash.Resource,
    data_layer: Ash.DataLayer.Ets

  actions(do: defaults([:create, :read, :update, :destroy]))

  attributes do
    uuid_primary_key(:id)
    attribute(:name, :string)

    create_timestamp(:created_at)
    update_timestamp(:updated_at)
  end

  # Added the inverse relationship of belongs_to in the reservation.
  # This way we can reference :reservations inside the aggregates.
  relationships do
    has_many(:reservations, BoxOffice.Core.Reservation)
  end

  code_interface do
    define_for(BoxOffice.Core)

    define(:create, args: [:name])
  end

  # <- Add the aggregates here
  aggregates do
    count(:count_of_reservations, :reservations)
  end
end

defmodule BoxOffice.Core.Seat do
  use Ash.Resource,
    data_layer: Ash.DataLayer.Ets

  actions(do: defaults([:create, :read, :update, :destroy]))

  attributes do
    uuid_primary_key(:id)
    attribute(:name, :string)

    create_timestamp(:created_at)
    update_timestamp(:updated_at)
  end

  relationships do
    has_many(:reservations, BoxOffice.Core.Reservation)

    many_to_many :seating_charts, BoxOffice.Core.SeatingChart do
      through(BoxOffice.Core.SeatSeatingChart)
      source_attribute_on_join_resource(:seat_id)
      destination_attribute_on_join_resource(:seating_chart_id)
    end
  end

  code_interface do
    define_for(BoxOffice.Core)

    define(:create, args: [:name])
  end
end

defmodule BoxOffice.Core.SeatSeatingChart do
  use Ash.Resource,
    data_layer: Ash.DataLayer.Ets

  relationships do
    belongs_to(:seat, BoxOffice.Core.Seat, primary_key?: true, allow_nil?: false)
    belongs_to(:seating_chart, BoxOffice.Core.SeatingChart, primary_key?: true, allow_nil?: false)
  end

  actions do
    defaults([:create, :read, :update, :destroy])
  end
end

defmodule BoxOffice.Core.SeatingChart do
  use Ash.Resource,
    data_layer: Ash.DataLayer.Ets

  actions do
    defaults([:create, :read, :update, :destroy])

    update :add_seat do
      accept([])
      argument(:seat_id, :uuid, allow_nil?: false)
      change(manage_relationship(:seat_id, :seats, type: :append))
    end
  end

  attributes do
    uuid_primary_key(:id)
    attribute(:name, :string)

    create_timestamp(:created_at)
    update_timestamp(:updated_at)
  end

  relationships do
    many_to_many :seats, BoxOffice.Core.Seat do
      through(BoxOffice.Core.SeatSeatingChart)
      source_attribute_on_join_resource(:seating_chart_id)
      destination_attribute_on_join_resource(:seat_id)
    end

    has_many(:performances, BoxOffice.Core.Performance)
  end

  code_interface do
    define_for(BoxOffice.Core)

    define(:create, args: [:name])
    define(:add_seat, args: [:seat_id])
  end

  aggregates do
    count(:count_of_seats, :seats)
  end
end

defmodule BoxOffice.Core.Production do
  use Ash.Resource,
    data_layer: Ash.DataLayer.Ets

  attributes do
    uuid_primary_key(:id)
    attribute(:title, :string)

    create_timestamp(:created_at)
    update_timestamp(:updated_at)
  end

  actions(do: defaults([:create, :read, :update, :destroy]))

  relationships do
    has_many(:performances, BoxOffice.Core.Performance)
  end

  code_interface do
    define_for(BoxOffice.Core)

    define(:create, args: [:title])
  end
end

defmodule BoxOffice.Core.Performance do
  use Ash.Resource,
    data_layer: Ash.DataLayer.Ets

  attributes do
    uuid_primary_key(:id)
    attribute(:datetime, :datetime)

    create_timestamp(:created_at)
    update_timestamp(:updated_at)
  end

  actions(do: defaults([:create, :read, :update, :destroy]))

  relationships do
    belongs_to(:production, BoxOffice.Core.Production) do
      attribute_writable?(true)
    end

    belongs_to(:seating_chart, BoxOffice.Core.SeatingChart) do
      attribute_writable?(true)
    end

    has_many(:reservations, BoxOffice.Core.Reservation)
  end

  code_interface do
    define_for(BoxOffice.Core)

    define(:create, args: [:production_id, :datetime, :seating_chart_id])
  end

  aggregates do
    count(:count_of_reserved_seats, :reservations)
  end

  calculations do
    calculate(
      :count_of_unreserved_seats,
      :integer,
      expr(seating_chart.count_of_seats - count_of_reserved_seats)
    )
  end
end

defmodule BoxOffice.Core.Reservation do
  require Ash.Query

  use Ash.Resource,
    data_layer: Ash.DataLayer.Ets

  actions do
    defaults([:read, :update])

    # On creation set the patron by providing the id
    create(:make, do: accept([:patron_id, :performance_id, :seat_id]))
  end

  attributes do
    uuid_primary_key(:id)

    create_timestamp(:created_at)
    update_timestamp(:updated_at)
  end

  relationships do
    belongs_to(:patron, BoxOffice.Core.Patron) do
      # Set to writable so you can directly set the patron_id inside the :open action
      attribute_writable?(true)
    end

    belongs_to(:seat, BoxOffice.Core.Seat) do
      attribute_writable?(true)
    end

    belongs_to(:performance, BoxOffice.Core.Performance) do
      attribute_writable?(true)
    end
  end

  code_interface do
    define_for(BoxOffice.Core)

    define(:make, args: [:patron_id, :performance_id, :seat_id])
  end

  identities do
    # pre_check_with might only be necessary for ETS data layer.
    # see https://hexdocs.pm/ash/identities.html#pre-checking
    identity(:reservation_identity, [:seat_id, :performance_id], pre_check_with: BoxOffice.Core)
  end

  validations do
    validate(fn changeset ->
      seat_id = Ash.Changeset.get_attribute(changeset, :seat_id)
      performance_id = Ash.Changeset.get_attribute(changeset, :performance_id)

      previous_reservation =
        __MODULE__
        |> Ash.Query.filter(performance_id == ^performance_id and seat_id == ^seat_id)
        |> BoxOffice.Core.read!()
        |> dbg

      case previous_reservation do
        [] -> :ok
        [_] -> {:error, field: :seat_id, message: "This seat is already reserved"}
      end
    end)
  end
end

defmodule BoxOffice.Core do
  use Ash.Api

  resources do
    resource(BoxOffice.Core.Reservation)
    resource(BoxOffice.Core.Patron)
    resource(BoxOffice.Core.Production)
    resource(BoxOffice.Core.Performance)
    resource(BoxOffice.Core.Seat)
    resource(BoxOffice.Core.SeatingChart)
    resource(BoxOffice.Core.SeatSeatingChart)
  end
end

require Ash.Query
```

## Test Setup

<!-- livebook:{"reevaluate_automatically":true} -->

```elixir
defmodule BoxOfficeCoreTest.Helpers do
  def reset() do
    BoxOffice.Core
    |> Ash.Api.Info.resources()
    |> Enum.reject(fn resource -> :ets.whereis(resource) == :undefined end)
    |> Enum.each(fn resource -> :ets.delete_all_objects(resource) end)
  end

  def create_patron(name \\ "Sally") do
    BoxOffice.Core.Patron.create!(name)
  end

  def create_production(name \\ "Some Great Show") do
    BoxOffice.Core.Production.create!(name)
  end

  def create_seating_chart(name \\ "main chart") do
    BoxOffice.Core.SeatingChart.create!(name)
  end

  def create_seats(names \\ ["A1"]) when is_list(names) do
    names
    |> Enum.map(fn name -> %{name: name} end)
    |> BoxOffice.Core.bulk_create!(BoxOffice.Core.Seat, :create, return_records?: true)
  end

  def create_performance(date \\ DateTime.from_naive!(~N[2024-01-01 20:00:00], "America/Chicago")) do
    [production] =
      BoxOffice.Core.Production
      |> Ash.Query.sort(created_at: :desc)
      |> Ash.Query.limit(1)
      |> BoxOffice.Core.read!()

    [seating_chart] =
      BoxOffice.Core.SeatingChart
      |> Ash.Query.sort(created_at: :desc)
      |> Ash.Query.limit(1)
      |> BoxOffice.Core.read!()

    create_performance(production, seating_chart, date)
  end

  def create_performance(
        production,
        seating_chart,
        date \\ DateTime.from_naive!(~N[2024-01-01 20:00:00], "America/Chicago")
      ) do
    BoxOffice.Core.Performance.create!(production.id, date, seating_chart.id)
  end

  def populate_seating_chart() do
    [seating_chart] =
      BoxOffice.Core.SeatingChart
      |> Ash.Query.sort(created_at: :desc)
      |> Ash.Query.limit(1)
      |> BoxOffice.Core.read!()

    seats =
      BoxOffice.Core.Seat
      |> Ash.Query.sort(created_at: :desc)
      |> BoxOffice.Core.read!()

    populate_seating_chart(seating_chart, seats)
  end

  def populate_seating_chart(%BoxOffice.Core.SeatingChart{} = seating_chart, seats) do
    seats
    |> Enum.map(fn seat ->
      BoxOffice.Core.SeatingChart.add_seat!(seating_chart, seat.id)
    end)
  end
end
```

## Test

<!-- livebook:{"reevaluate_automatically":true} -->

```elixir
ExUnit.start(auto_run: false)

defmodule BoxOfficeCoreTest do
  use ExUnit.Case
  import BoxOfficeCoreTest.Helpers

  setup do
    BoxOfficeCoreTest.Helpers.reset()
  end

  describe "Reservations" do
    test "we can reserve a seat" do
      create_seating_chart()
      [seat] = create_seats().records
      populate_seating_chart()
      create_production()
      performance = create_performance()

      assert {:ok, _} =
              BoxOffice.Core.Reservation.make(create_patron().id, performance.id, seat.id)
    end

    test "we can query a reservation" do
      create_seating_chart()
      [seat] = create_seats().records
      populate_seating_chart()
      create_production()
      performance = create_performance()

      assert {:ok, reservation} =
              BoxOffice.Core.Reservation.make(create_patron().id, performance.id, seat.id)

      assert [_] =
              BoxOffice.Core.Reservation
              |> Ash.Query.filter(id == ^reservation.id)
              |> BoxOffice.Core.read!()
    end

    test "a reserved seat cannot be reserved again for the same performance" do
      create_seating_chart()
      [seat] = create_seats().records
      populate_seating_chart()
      create_production()
      performance = create_performance()

      assert {:ok, _} =
              BoxOffice.Core.Reservation.make(create_patron().id, performance.id, seat.id)

      assert {:error, _} =
              BoxOffice.Core.Reservation.make(create_patron().id, performance.id, seat.id)
    end

    test "a seat outside the performance's chart is not valid when reserving" do
      create_seating_chart()
      create_seats()
      populate_seating_chart()
      create_production()
      performance = create_performance()

      [seat] = create_seats(["Z99"]).records

      assert {:error, _} =
              BoxOffice.Core.Reservation.make(create_patron().id, performance.id, seat.id)
    end

    test "we can see how many reservations exist" do
      create_seating_chart()

      [seat | _] = create_seats(["1", "2"]).records

      populate_seating_chart()
      create_production()
      performance = create_performance()

      performance =
        BoxOffice.Core.load!(performance, [
          :count_of_reserved_seats,
          :count_of_unreserved_seats
        ])

      assert 0 = performance.count_of_reserved_seats
      assert 2 = performance.count_of_unreserved_seats

      BoxOffice.Core.Reservation.make(create_patron().id, performance.id, seat.id)

      performance =
        BoxOffice.Core.load!(performance, [
          :count_of_reserved_seats,
          :count_of_unreserved_seats
        ])

      assert 1 = performance.count_of_reserved_seats
      assert 1 = performance.count_of_unreserved_seats
    end
  end
end

results = ExUnit.run()
```

## Play with the resources

```elixir
defmodule WhoTestsTheTesters do
  import BoxOfficeCoreTest.Helpers

  def run() do
    create_seating_chart()
    [seat] = create_seats().records
    populate_seating_chart()
    create_production()
    performance = create_performance()

    {:ok, _} =
      BoxOffice.Core.Reservation.make(create_patron().id, performance.id, seat.id)

    # This match works (which is correct), but the same thing fails in the test
    {:error, _} =
      BoxOffice.Core.Reservation.make(create_patron().id, performance.id, seat.id)
  end
end

BoxOfficeCoreTest.Helpers.reset()
WhoTestsTheTesters.run()
```
zachdaniel

zachdaniel

Creator of Ash

Oh, interesting. Probably in the beginning of your Livebook or in setup you can do something like Application.put_env(:ash, :disable_async?, true)

happycollision

happycollision

I tried that and also tried using the configuration portion of Mix.install (both can be seen in the code above), but no dice.

But hey, the code works, it just doesn’t test well in Livebook for me, so I’ll move on and maybe come back and try to figure that out later. Those assert macros are nice, but I can just use regular matching for test purposes if I need to.

zachdaniel

zachdaniel

Creator of Ash

Yeah, that is super strange.

zachdaniel

zachdaniel

Creator of Ash

Thinking about it, I don’t think the disable_async bit really comes into play at all. That is standard for testing but in your case you aren’t using the sandbox adapter for the underlying repo, so it wouldn’t really matter.

I really can’t think of anything special that would cause this to behave differently in a test in a livebook :cold_face:

— All posts loaded —

Where Next?

Trending in Questions Top

jonnycharles
I’m in search of an Elixir library that offers PDF generation capabilities similar to Ruby’s Prawn. While there have been discussions abo...
New
spammy
I’m looking to build a personal workflow to quickly deploy web applications written in elixir/phoenix, for local consumption (ie not on t...
New
silverdr
Using Phoenix.LiveView.TagEngine as an EEx.Engine is deprecated! To compile HEEx, use Phoenix.LiveView.TagEngine.compile/2 instead. Sta...
New
saveman71
Hello ! We want new/edit form pages to POST/PUT to their own URL rather than the resources REST defaults (post /things, put /things/:id)...
New
dli
Before I dive in myself, did anyone successfully sprinkle Hologram into their existing LiveView app? Looking for hints regarding: Addi...
New
bottlenecked
Hi all, I wanted to ask how the community is dealing with post-release steps. Today we have Ecto migrations, which make sure that the db...
New
michallepicki
I am using Oban and occasionally, shortly after a deployment, a handful of jobs can fail because of dependency on other parts of the syst...
New

Other Trending Topics Top

JesseHerrick
Hey, I’m Jesse and I’m the main contributor behind Dexter, a full-featured, lightning-fast Elixir LSP optimized for large codebases. It s...
New
jimsynz
Beam Bots (or just BB for short) is a framework for building fault-tolerant robotics applications in Elixir using familiar OTP patterns. ...
New
Damirados
Hello everyone. After busy few months I am happy to announce v0.1.0 of Emerge &amp; Solve. They are GUI (Emerge) and State management (S...
New
ausimian
Emily is an Elixir library that runs Nx computations on Apple’s MLX. Install it as the default Nx backend and Nx, defn, Axon, Nx.Serving,...
New
type1fool
I just stumbled on a newly redesigned elixir-lang.org. :tada: It looks like @Software_Mansion did the work, and I think it is generally a...
New
akoutmos
@hugobarauna and I (Alex Koutmos) have been hard at work on writing a book on Nerves that takes you from simply blinking LEDs to building...
New

We're in Beta

About us Mission Statement