Browse lessons

Testing Uploads (Direct to Cloud)

If you go to our router, you’ll see:

live_session :movies do
  live "/movies", MovieLive.Index
  live "/movies/:id", MovieLive.Show
end

Our movie uploads are almost identical to album uploads. The key difference is that movie uploads use external uploads (for example, Cloudinary).

Configuring Cloudinary

If you want to follow along in development, create a free Cloudinary account and set:

config :ranger, :cloudinary,
  api_key: System.get_env("CLOUDINARY_API_KEY"),
  api_secret: System.get_env("CLOUDINARY_API_SECRET"),
  cloud_name: System.get_env("CLOUDINARY_CLOUD_NAME")

MovieLive.Index

Two important differences from direct-to-server uploads:

1) allow_upload/3 uses external: &presign_upload/2. 2) In consume_uploaded_entries/3, we generate cloud URLs instead of copying files locally.

def mount(_params, _session, socket) do
  {:ok,
   socket
   |> assign(:changeset, Movie.changeset(%{}))
   |> allow_upload(:posters,
     accept: ~w(.jpg .jpeg .png),
     max_entries: 2,
     external: &presign_upload/2
   )}
end
def handle_event("save", %{"movie" => params}, socket) do
  uploaded_file_paths =
    consume_uploaded_entries(socket, :posters, fn _, entry ->
      {:ok, cloudinary_image_url(entry)}
    end)

  params
  |> Map.put("poster_urls", uploaded_file_paths)
  |> Movie.changeset()
  |> Repo.insert()
  |> case do
    {:ok, movie} ->
      {:noreply, push_navigate(socket, to: ~p"/movies/#{movie}")}

    {:error, changeset} ->
      {:noreply, assign(socket, :changeset, changeset)}
  end
end

presign_upload/2 is where provider metadata gets generated, and cloudinary_image_url/1 is where persisted image URLs are built.

Testing MovieLive

Preview tests remain the same as direct-to-server uploads. The new value here is testing:

  • Upload metadata contract
  • Stored URL correctness

Test correct metadata

test "generates correct metadata for external upload", %{conn: conn} do
  {:ok, view, _html} = live(conn, ~p"/movies")

  {:ok, %{entries: entries}} =
    view
    |> file_input("#upload-form", :posters, [
      %{
        name: "fellowship-poster.jpg",
        content: File.read!("test/support/images/fellowship-poster.jpg"),
        type: "image/jpeg"
      }
    ])
    |> preflight_upload()

  for {_k, v} <- entries do
    assert v.uploader == "Cloudinary"
    assert v.url =~ CloudinaryUpload.image_api_url(cloud_name())
    assert v.fields[:folder] == "testing-liveview"

    assert is_binary(v.fields[:public_id])
    refute String.ends_with?(v.fields[:public_id], ".png")

    assert is_binary(v.fields[:signature])
    refute is_nil(v.fields[:api_key])
    refute is_nil(v.fields[:timestamp])
  end
end

Test correct image path is saved

test "user can create movie and stores correct URLs", %{conn: conn} do
  {:ok, view, _html} = live(conn, ~p"/movies")

  {:ok, show_view, _show_html} =
    view
    |> upload("fellowship-poster.jpg")
    |> create_movie("The Fellowship of the Ring")
    |> follow_redirect(conn)

  assert has_element?(show_view, "h2", "The Fellowship of the Ring")
  assert has_element?(show_view, "[data-role='image']")
  assert hd(last_movie().poster_urls) =~ CloudinaryUpload.image_url(cloud_name())
end

defp last_movie do
  Movie |> Ecto.Query.last() |> Repo.one()
end

This keeps assertions slightly loose (we don’t assert the exact UUID), while still giving confidence that provider URL generation is correct.

Reminder: LiveView tests do not test JS implementation

No matter what we do here, LiveView tests don’t execute browser-side uploader JavaScript.

If you need full end-to-end confidence including provider integration behavior, consider browser automation (for example, Wallaby), with the caveat that cloud upload testing adds integration complexity.