diff --git a/CHANGELOG.md b/CHANGELOG.md index bf6d41b..ae72d37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - LVN.concat/2 - :interface- special attribute support in tags - async_result/1 +- render_upload support in LiveViewNativeTest ### Changed diff --git a/lib/live_view_native.ex b/lib/live_view_native.ex index cbffb9e..ddbdbf4 100644 --- a/lib/live_view_native.ex +++ b/lib/live_view_native.ex @@ -150,7 +150,7 @@ defmodule LiveViewNative do template_engine: config[:template_engine], stylesheet: config[:stylesheet], stylesheet_rules_parser: config[:stylesheet_rules_parser], - client: config[:client] + test_client: config[:test_client] def __lvn_client__, do: true end diff --git a/lib/live_view_native/test/client_proxy.ex b/lib/live_view_native/test/client_proxy.ex index 5e3072a..0979aee 100644 --- a/lib/live_view_native/test/client_proxy.ex +++ b/lib/live_view_native/test/client_proxy.ex @@ -180,8 +180,8 @@ defmodule LiveViewNativeTest.ClientProxy do defp maybe_put_container(state, %{} = _resp), do: state defp build_client_view(%ClientProxy{} = proxy) do - %{id: id, ref: ref, topic: topic, module: module, endpoint: endpoint, pid: pid} = proxy - %View{id: id, pid: pid, proxy: {ref, topic, self()}, module: module, endpoint: endpoint} + %{id: id, ref: ref, topic: topic, module: module, endpoint: endpoint, pid: pid, client: client} = proxy + %View{id: id, pid: pid, proxy: {ref, topic, self()}, module: module, endpoint: endpoint, client: client} end defp mount_view(state, view, url, redirect_url) do @@ -856,13 +856,14 @@ defmodule LiveViewNativeTest.ClientProxy do |> put_reply(ref, view.pid, from, callback) end - defp build_child(%ClientProxy{ref: ref, proxy: proxy, endpoint: endpoint, connect_params: connect_params}, attrs) do + defp build_child(%ClientProxy{ref: ref, proxy: proxy, endpoint: endpoint, connect_params: connect_params, client: client}, attrs) do attrs_with_defaults = Keyword.merge(attrs, ref: ref, proxy: proxy, endpoint: endpoint, connect_params: Map.take(connect_params, ["_format", "_interface"]), + client: client, topic: "lv:#{Keyword.fetch!(attrs, :id)}" ) @@ -1145,7 +1146,7 @@ defmodule LiveViewNativeTest.ClientProxy do {:error, _, _} = error -> error end - type == :change and tag in ~w(input select textarea) -> + type == :change and tag in tags.changeables -> {:ok, form_defaults(node, Query.decode_init()) |> Query.decode_done()} true -> diff --git a/lib/live_view_native/test/structs.ex b/lib/live_view_native/test/structs.ex index 9d0b34b..8f383c9 100644 --- a/lib/live_view_native/test/structs.ex +++ b/lib/live_view_native/test/structs.ex @@ -19,7 +19,8 @@ defmodule LiveViewNativeTest.View do pid: nil, proxy: nil, endpoint: nil, - target: nil + target: nil, + client: nil end defmodule LiveViewNativeTest.Element do @@ -68,9 +69,9 @@ defmodule LiveViewNativeTest.Upload do cid: nil @doc false - def new(pid, %Phoenix.LiveViewTest.View{} = view, form_selector, name, entries, cid) do + def new(pid, %LiveViewNativeTest.View{} = view, form_selector, name, entries, cid) do populated_entries = Enum.map(entries, fn entry -> populate_entry(entry) end) - selector = "#{form_selector} input[type=\"file\"][name=\"#{name}\"]" + selector = "#{form_selector} #{view.client.tags.upload_input}[type=\"file\"][name=\"#{name}\"]" %Upload{ pid: pid, diff --git a/lib/live_view_native/test/upload_client.ex b/lib/live_view_native/test/upload_client.ex index 7ac0e5e..8eafd59 100644 --- a/lib/live_view_native/test/upload_client.ex +++ b/lib/live_view_native/test/upload_client.ex @@ -4,7 +4,7 @@ defmodule LiveViewNativeTest.UploadClient do require Logger require Phoenix.ChannelTest - alias Phoenix.LiveViewTest.{Upload, ClientProxy} + alias LiveViewNativeTest.{Upload, ClientProxy} def child_spec(opts) do %{ diff --git a/lib/live_view_native_test.ex b/lib/live_view_native_test.ex index f334087..3dbf90b 100644 --- a/lib/live_view_native_test.ex +++ b/lib/live_view_native_test.ex @@ -424,7 +424,7 @@ defmodule LiveViewNativeTest do %{"_format" => format} = opts.connect_params - %{client: client} = LiveViewNative.fetch_plugin!(format) + %{test_client: client} = LiveViewNative.fetch_plugin!(format) opts = Map.merge(opts, %{ @@ -1266,7 +1266,7 @@ defmodule LiveViewNativeTest do quote bind_quoted: [view: view, selector: form_selector, name: name, entries: entries] do require Phoenix.ChannelTest builder = fn -> Phoenix.ChannelTest.connect(Phoenix.LiveView.Socket, %{}) end - Phoenix.LiveViewTest.__file_input__(view, selector, name, entries, builder) + LiveViewNativeTest.__file_input__(view, selector, name, entries, builder) end end diff --git a/mix.lock b/mix.lock index 581da2a..afed20d 100644 --- a/mix.lock +++ b/mix.lock @@ -3,17 +3,17 @@ "cowboy": {:hex, :cowboy, "2.12.0", "f276d521a1ff88b2b9b4c54d0e753da6c66dd7be6c9fca3d9418b561828a3731", [:make, :rebar3], [{:cowlib, "2.13.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "8a7abe6d183372ceb21caa2709bec928ab2b72e18a3911aa1771639bef82651e"}, "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, "cowlib": {:hex, :cowlib, "2.13.0", "db8f7505d8332d98ef50a3ef34b34c1afddec7506e4ee4dd4a3a266285d282ca", [:make, :rebar3], [], "hexpm", "e1e1284dc3fc030a64b1ad0d8382ae7e99da46c3246b815318a4b848873800a4"}, - "earmark_parser": {:hex, :earmark_parser, "1.4.41", "ab34711c9dc6212dda44fcd20ecb87ac3f3fce6f0ca2f28d4a00e4154f8cd599", [:mix], [], "hexpm", "a81a04c7e34b6617c2792e291b5a2e57ab316365c2644ddc553bb9ed863ebefa"}, + "earmark_parser": {:hex, :earmark_parser, "1.4.42", "f23d856f41919f17cd06a493923a722d87a2d684f143a1e663c04a2b93100682", [:mix], [], "hexpm", "6915b6ca369b5f7346636a2f41c6a6d78b5af419d61a611079189233358b8b8b"}, "ex_doc": {:hex, :ex_doc, "0.35.1", "de804c590d3df2d9d5b8aec77d758b00c814b356119b3d4455e4b8a8687aecaf", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "2121c6402c8d44b05622677b761371a759143b958c6c19f6558ff64d0aed40df"}, "expo": {:hex, :expo, "1.1.0", "f7b9ed7fb5745ebe1eeedf3d6f29226c5dd52897ac67c0f8af62a07e661e5c75", [:mix], [], "hexpm", "fbadf93f4700fb44c331362177bdca9eeb8097e8b0ef525c9cc501cb9917c960"}, "file_system": {:hex, :file_system, "1.0.1", "79e8ceaddb0416f8b8cd02a0127bdbababe7bf4a23d2a395b983c1f8b3f73edd", [:mix], [], "hexpm", "4414d1f38863ddf9120720cd976fce5bdde8e91d8283353f0e31850fa89feb9e"}, "floki": {:hex, :floki, "0.37.0", "b83e0280bbc6372f2a403b2848013650b16640cd2470aea6701f0632223d719e", [:mix], [], "hexpm", "516a0c15a69f78c47dc8e0b9b3724b29608aa6619379f91b1ffa47109b5d0dd3"}, "gettext": {:hex, :gettext, "0.26.2", "5978aa7b21fada6deabf1f6341ddba50bc69c999e812211903b169799208f2a8", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "aa978504bcf76511efdc22d580ba08e2279caab1066b76bb9aa81c4a1e0a32a5"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, - "live_view_native_test_endpoint": {:git, "https://github.com/liveview-native/live_view_native_test_endpoint.git", "29153c238498426354529ae0816e00a172247bb3", [branch: "main"]}, + "live_view_native_test_endpoint": {:git, "https://github.com/liveview-native/live_view_native_test_endpoint.git", "be09319cc2def0e93a2aba79d8db7ba989560afa", [branch: "main"]}, "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, "makeup_eex": {:hex, :makeup_eex, "1.0.0", "436d4c00204c250b17a775d64e197798aaf374627e6a4f2d3fd3074a8db61db4", [:mix], [{:makeup, "~> 1.2.1 or ~> 1.3", [hex: :makeup, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_html, "~> 0.1.0 or ~> 1.0", [hex: :makeup_html, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "3bb699bc519e4f509f1bf8a2e0ba0e08429edf3580053cd31a4f9c1bc5da86c8"}, - "makeup_elixir": {:hex, :makeup_elixir, "1.0.0", "74bb8348c9b3a51d5c589bf5aebb0466a84b33274150e3b6ece1da45584afc82", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "49159b7d7d999e836bedaf09dcf35ca18b312230cf901b725a64f3f42e407983"}, + "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, "makeup_erlang": {:hex, :makeup_erlang, "1.0.1", "c7f58c120b2b5aa5fd80d540a89fdf866ed42f1f3994e4fe189abebeab610839", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "8a89a1eeccc2d798d6ea15496a6e4870b75e014d1af514b1b71fa33134f57814"}, "makeup_html": {:hex, :makeup_html, "0.1.2", "19d4050c0978a4f1618ffe43054c0049f91fe5feeb9ae8d845b5dc79c6008ae5", [:mix], [{:makeup, "~> 1.2", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "b7fb9afedd617d167e6644a0430e49c1279764bfd3153da716d4d2459b0998c5"}, "mime": {:hex, :mime, "2.0.6", "8f18486773d9b15f95f4f4f1e39b710045fa1de891fada4516559967276e4dc2", [:mix], [], "hexpm", "c9945363a6b26d747389aac3643f8e0e09d30499a138ad64fe8fd1d13d9b153e"}, diff --git a/test/live_view_native/integrations/live_components_test.exs b/test/live_view_native/integrations/live_components_test.exs index 22acddc..a7341ac 100644 --- a/test/live_view_native/integrations/live_components_test.exs +++ b/test/live_view_native/integrations/live_components_test.exs @@ -131,7 +131,7 @@ defmodule LiveViewNative.LiveComponentsTest do assert view |> element("#bumper") |> render_click() =~ "Bump: 1" # Now click the form - assert view |> element("Form") |> render_submit() =~ "loading..." + assert view |> element("LiveForm") |> render_submit() =~ "loading..." # Which will be reset almost immediately assert render(view) =~ "Hello World" diff --git a/test/live_view_native/upload/channel_test.exs b/test/live_view_native/upload/channel_test.exs new file mode 100644 index 0000000..3b7b656 --- /dev/null +++ b/test/live_view_native/upload/channel_test.exs @@ -0,0 +1,972 @@ +defmodule LiveViewNative.UploadChannelTest do + use ExUnit.Case, async: false + require Phoenix.ChannelTest + + import LiveViewNativeTest + + alias Phoenix.{Component, LiveView} + alias LiveViewNativeTest.UploadClient + alias LiveViewNativeTest.{UploadLive, UploadLiveWithComponent} + + @endpoint LiveViewNativeTest.Endpoint + + defmodule TestWriter do + @behaviour Phoenix.LiveView.UploadWriter + + @impl true + def init(test_name) do + send(test_name, :init) + {:ok, test_name} + end + + @impl true + def meta(test_name) do + send(test_name, :meta) + test_name + end + + @impl true + def write_chunk("error", test_name) do + {:error, :custom_error, test_name} + end + + def write_chunk(data, test_name) do + send(test_name, {:write_chunk, data}) + {:ok, test_name} + end + + @impl true + def close(test_name, reason) do + send(test_name, {:close, reason}) + {:ok, test_name} + end + end + + def build_writer(_name, %Phoenix.LiveView.UploadEntry{}, %Phoenix.LiveView.Socket{}) do + {TestWriter, :test_writer} + end + + def valid_token(lv_pid, ref) do + LiveView.Static.sign_token(@endpoint, %{pid: lv_pid, ref: ref}) + end + + def mount_lv(setup) when is_function(setup, 1) do + conn = Plug.Test.init_test_session(Phoenix.ConnTest.build_conn(), %{}) + {:ok, lv, _} = live_isolated(conn, UploadLive, session: %{}, _format: :gameboy) + :ok = GenServer.call(lv.pid, {:setup, setup}) + {:ok, lv} + end + + def mount_lv_with_component(setup) when is_function(setup, 1) do + conn = Plug.Test.init_test_session(Phoenix.ConnTest.build_conn(), %{}) + {:ok, lv, _} = live_isolated(conn, UploadLiveWithComponent, session: %{}, _format: :gameboy) + :ok = GenServer.call(lv.pid, {:run, setup}) + {:ok, lv} + end + + def get_uploaded_entries(lv, name) do + UploadLive.run(lv, fn socket -> + {:reply, Phoenix.LiveView.uploaded_entries(socket, name), socket} + end) + end + + def build_entries(count, opts \\ []) do + content = String.duplicate("0", 100) + size = byte_size(content) + + for i <- 1..count do + Enum.into(opts, %{ + last_modified: 1_594_171_879_000, + name: "myfile#{i}.jpeg", + relative_path: "./myfile#{i}.jpeg", + content: content, + size: size, + type: "image/jpeg" + }) + end + end + + def unlink( + channel_pid, + %LiveViewNativeTest.View{} = lv, + %LiveViewNativeTest.Upload{} = upload + ) do + Process.unlink(upload.pid) + unlink(channel_pid, lv) + end + + def unlink(channel_pid, %LiveViewNativeTest.View{} = lv) do + Process.flag(:trap_exit, true) + Process.unlink(UploadLive.proxy_pid(lv)) + Process.unlink(lv.pid) + Process.unlink(channel_pid) + end + + def consume(%LiveView.UploadEntry{} = entry, socket) do + socket = + cond do + entry.client_name == "redirect.jpeg" -> + Phoenix.LiveView.push_navigate(socket, to: "/redirected") + + entry.done? -> + name = + Phoenix.LiveView.consume_uploaded_entry(socket, entry, fn _ -> + {:ok, entry.client_name} + end) + + Phoenix.Component.update(socket, :consumed, fn consumed -> [name] ++ consumed end) + + true -> + socket + end + + {:noreply, socket} + end + + setup_all do + start_supervised!(Phoenix.PubSub.child_spec(name: Phoenix.LiveView.PubSub)) + :ok + end + + test "rejects invalid token" do + {:ok, socket} = Phoenix.ChannelTest.connect(Phoenix.LiveView.Socket, %{}) + + assert {:error, %{reason: :invalid_token}} = + Phoenix.ChannelTest.subscribe_and_join(socket, "lvu:123", %{"token" => "bad"}) + end + + defp setup_lv(%{allow: opts}) do + opts = opts_for_allow_upload(opts) + {:ok, lv} = mount_lv(fn socket -> Phoenix.LiveView.allow_upload(socket, :avatar, opts) end) + {:ok, lv: lv} + end + + defp setup_component(%{allow: opts}) do + opts = opts_for_allow_upload(opts) + + {:ok, lv} = + mount_lv_with_component(fn component_socket -> + new_socket = Phoenix.LiveView.allow_upload(component_socket, :avatar, opts) + {:reply, :ok, new_socket} + end) + + {:ok, lv: lv} + end + + defp opts_for_allow_upload(opts) do + case Keyword.fetch(opts, :progress) do + {:ok, progress} -> + Keyword.put(opts, :progress, fn _, entry, socket -> + apply(__MODULE__, progress, [entry, socket]) + end) + + :error -> + opts + end + end + + for context <- [:lv, :component] do + @context context + + describe "#{@context} with valid token" do + setup :"setup_#{@context}" + + @tag allow: [accept: :any] + test "upload channel exits when LiveView channel exits", %{lv: lv} do + avatar = file_input(lv, "LiveForm", :avatar, build_entries(1)) + assert render_upload(avatar, "myfile1.jpeg", 1) =~ "#{@context}:myfile1.jpeg:1%" + assert %{"myfile1.jpeg" => channel_pid} = UploadClient.channel_pids(avatar) + + unlink(channel_pid, lv, avatar) + Process.monitor(channel_pid) + Process.exit(lv.pid, :kill) + assert_receive {:DOWN, _ref, :process, ^channel_pid, :killed} + end + + @tag allow: [accept: :any] + test "abnormal channel exit brings down LiveView", %{lv: lv} do + avatar = file_input(lv, "LiveForm", :avatar, build_entries(1)) + assert render_upload(avatar, "myfile1.jpeg", 1) =~ "#{@context}:myfile1.jpeg:1%" + assert %{"myfile1.jpeg" => channel_pid} = UploadClient.channel_pids(avatar) + + lv_pid = lv.pid + unlink(channel_pid, lv, avatar) + Process.monitor(lv_pid) + Process.exit(channel_pid, :kill) + + assert_receive {:DOWN, _ref, :process, ^lv_pid, + {:shutdown, {:channel_upload_exit, :killed}}} + end + + @tag allow: [accept: :any] + test "normal channel exit is cleaned up by LiveView", %{lv: lv} do + avatar = file_input(lv, "LiveForm", :avatar, build_entries(1)) + assert render_upload(avatar, "myfile1.jpeg", 1) =~ "#{@context}:myfile1.jpeg:1%" + assert %{"myfile1.jpeg" => channel_pid} = UploadClient.channel_pids(avatar) + + lv_pid = lv.pid + unlink(channel_pid, lv) + Process.monitor(lv_pid) + + assert render(lv) =~ "channel:#{UploadLive.inspect_html_safe(channel_pid)}" + GenServer.stop(channel_pid, :normal) + refute_receive {:DOWN, _ref, :process, ^lv_pid, _} + refute render(lv) =~ "channel:" + end + + @tag allow: [accept: :any, max_file_size: 100] + test "upload channel exits when client sends more bytes than allowed", %{lv: lv} do + avatar = + file_input(lv, "LiveForm", :avatar, [ + %{name: "foo.jpeg", content: String.duplicate("0", 100)} + ]) + + assert render_upload(avatar, "foo.jpeg", 1) =~ "#{@context}:foo.jpeg:1%" + assert %{"foo.jpeg" => channel_pid} = UploadClient.channel_pids(avatar) + + unlink(channel_pid, lv) + Process.monitor(channel_pid) + + assert UploadClient.simulate_attacker_chunk( + avatar, + "foo.jpeg", + String.duplicate("0", 1000) + ) == + {:error, %{limit: 100, reason: :file_size_limit_exceeded}} + + assert_receive {:DOWN, _ref, :process, ^channel_pid, {:shutdown, :closed}}, 1000 + end + + @tag allow: [accept: :any, max_file_size: 100, chunk_timeout: 500] + test "upload channel exits when client does not send chunk after timeout", %{lv: lv} do + avatar = + file_input(lv, "LiveForm", :avatar, [ + %{name: "foo.jpeg", content: String.duplicate("0", 100)} + ]) + + assert render_upload(avatar, "foo.jpeg", 1) =~ "#{@context}:foo.jpeg:1%" + assert %{"foo.jpeg" => channel_pid} = UploadClient.channel_pids(avatar) + + unlink(channel_pid, lv) + Process.monitor(channel_pid) + + assert_receive {:DOWN, _ref, :process, ^channel_pid, {:shutdown, :closed}}, 1000 + end + + @tag allow: [max_entries: 3, accept: :any] + test "multiple entries under max", %{lv: lv} do + avatar = file_input(lv, "LiveForm", :avatar, build_entries(2)) + assert render_upload(avatar, "myfile1.jpeg", 1) =~ "#{@context}:myfile1.jpeg:1%" + assert render_upload(avatar, "myfile2.jpeg", 2) =~ "#{@context}:myfile2.jpeg:2%" + + assert %{"myfile1.jpeg" => chan1_pid, "myfile2.jpeg" => chan2_pid} = + UploadClient.channel_pids(avatar) + + assert render(lv) =~ "channel:#{UploadLive.inspect_html_safe(chan1_pid)}" + assert render(lv) =~ "channel:#{UploadLive.inspect_html_safe(chan2_pid)}" + end + + @tag allow: [max_entries: 1, accept: :any] + test "too many entries over max", %{lv: lv} do + avatar = file_input(lv, "LiveForm", :avatar, build_entries(2)) + + assert lv + |> form("LiveForm", user: %{}) + |> render_change(avatar) =~ "config_error::too_many_files" + + assert {:error, [[_ref, :too_many_files]]} = render_upload(avatar, "myfile1.jpeg", 1) + end + + @tag allow: [accept: :any] + test "registering returns too_many_files on back-to-back entries", %{lv: lv} do + avatar = file_input(lv, "LiveForm", :avatar, build_entries(1)) + assert render_upload(avatar, "myfile1.jpeg", 1) =~ "#{@context}:myfile1.jpeg:1%" + dup_avatar = file_input(lv, "LiveForm", :avatar, build_entries(1)) + assert {:error, [[_, :too_many_files]]} = preflight_upload(dup_avatar) + end + + @tag allow: [max_entries: 3, accept: :any] + test "preflight_upload", %{lv: lv} do + avatar = file_input(lv, "LiveForm", :avatar, build_entries(1)) + assert {:ok, %{ref: _ref, config: %{chunk_size: _}}} = preflight_upload(avatar) + end + + @tag allow: [max_entries: 3, accept: :any] + test "preflighting an already in progress entry is ignored", %{lv: lv} do + avatar = file_input(lv, "LiveForm", :avatar, build_entries(1)) + assert render_upload(avatar, "myfile1.jpeg", 1) =~ "1%" + assert %{"myfile1.jpeg" => channel_pid} = UploadClient.channel_pids(avatar) + assert render(lv) =~ "channel:#{UploadLive.inspect_html_safe(channel_pid)}" + + assert {:ok, _} = preflight_upload(avatar) + assert %{"myfile1.jpeg" => ^channel_pid} = UploadClient.channel_pids(avatar) + end + + @tag allow: [max_entries: 3, chunk_size: 20, accept: :any] + test "render_upload uploads entire file by default", %{lv: lv} do + avatar = + file_input(lv, "LiveForm", :avatar, [ + %{name: "foo.jpeg", content: String.duplicate("0", 100)} + ]) + + assert render_upload(avatar, "foo.jpeg") =~ "100%" + end + + @tag allow: [max_entries: 3, chunk_size: 20, accept: :any] + test "render_upload uploads specified chunk percentage", %{lv: lv} do + avatar = + file_input(lv, "LiveForm", :avatar, [ + %{name: "foo.jpeg", content: String.duplicate("0", 100)} + ]) + + assert render_upload(avatar, "foo.jpeg", 20) =~ "#{@context}:foo.jpeg:20%" + assert render_upload(avatar, "foo.jpeg", 25) =~ "#{@context}:foo.jpeg:45%" + end + + @tag allow: [max_entries: 3, chunk_size: 20, accept: :any, progress: :consume] + test "render_upload uploads with progress callback", %{lv: lv} do + avatar = + file_input(lv, "LiveForm", :avatar, [ + %{name: "foo.jpeg", content: String.duplicate("0", 100)} + ]) + + assert render_upload(avatar, "foo.jpeg") =~ "consumed:foo.jpeg" + end + + @tag allow: [max_entries: 3, chunk_size: 20, accept: :any, progress: :consume] + test "render_upload uploads with progress redirect", %{lv: lv} do + avatar = + file_input(lv, "LiveForm", :avatar, [ + %{name: "redirect.jpeg", content: String.duplicate("0", 100)} + ]) + + assert {:error, {:live_redirect, redir}} = render_upload(avatar, "redirect.jpeg") + assert redir[:to] == "/redirected" + end + + @tag allow: [max_entries: 3, chunk_size: 20, accept: :any] + test "render_upload with unknown entry", %{lv: lv} do + avatar = + file_input(lv, "LiveForm", :avatar, [ + %{name: "foo.jpeg", content: String.duplicate("0", 100)} + ]) + + assert UploadLive.exits_with(lv, avatar, RuntimeError, fn -> + render_upload(avatar, "unknown.jpeg") + end) =~ "no file input with name \"unknown.jpeg\"" + end + + @tag allow: [max_entries: 1, chunk_size: 20, accept: :any, max_file_size: 1] + test "render_change error with upload", %{lv: lv} do + avatar = file_input(lv, "LiveForm", :avatar, [%{name: "foo.jpeg", content: "overmax"}]) + + assert lv + |> form("LiveForm", user: %{}) + |> render_change(avatar) =~ "entry_error::too_large" + + assert {:error, [[_ref, :too_large]]} = render_upload(avatar, "foo.jpeg") + end + + @tag allow: [max_entries: 1, chunk_size: 20, accept: :any, auto_upload: true] + test "render_upload too many files with auto_upload", %{lv: lv} do + avatar = + file_input(lv, "LiveForm", :avatar, [ + %{name: "foo1.jpeg", content: "bytes"}, + %{name: "foo2.jpeg", content: "bytes"} + ]) + + html = + lv + |> form("LiveForm", user: %{}) + |> render_change(avatar) + + assert html =~ "config_error::too_many_files" + assert html =~ "foo1.jpeg:0%" + assert html =~ "foo2.jpeg:0%" + + assert render_upload(avatar, "foo1.jpeg") =~ "foo1.jpeg:100%" + assert {:error, :not_allowed} = render_upload(avatar, "foo2.jpeg") + end + + @tag allow: [ + max_entries: 1, + chunk_size: 20, + accept: :any, + max_file_size: 1, + auto_upload: true + ] + test "render_upload invalid with auto_upload", %{lv: lv} do + avatar = file_input(lv, "LiveForm", :avatar, [%{name: "foo.jpeg", content: "overmax"}]) + + html = + lv + |> form("LiveForm", user: %{}) + |> render_change(avatar) + + assert html =~ "entry_error::too_large" + assert html =~ "foo.jpeg:0%" + + assert {:error, [[_ref, :too_large]]} = render_upload(avatar, "foo.jpeg") + end + + @tag allow: [max_entries: 1, chunk_size: 20, accept: :any] + test "render_change success with upload", %{lv: lv} do + avatar = file_input(lv, "LiveForm", :avatar, [%{name: "foo.jpeg", content: "ok"}]) + + refute lv + |> form("LiveForm", user: %{}) + |> render_change(avatar) =~ "error" + + assert render_upload(avatar, "foo.jpeg") =~ "100%" + end + + @tag allow: [max_entries: 1, chunk_size: 20, accept: :any] + test "get_uploaded_entries", %{lv: lv} do + avatar = + file_input(lv, "LiveForm", :avatar, [ + %{name: "foo.jpeg", content: String.duplicate("0", 100)} + ]) + + assert get_uploaded_entries(lv, :avatar) == {[], []} + assert render_upload(avatar, "foo.jpeg", 1) =~ "1%" + + assert {[], [%Phoenix.LiveView.UploadEntry{progress: 1}]} = + get_uploaded_entries(lv, :avatar) + + assert render_upload(avatar, "foo.jpeg", 99) =~ "100%" + + assert {[%Phoenix.LiveView.UploadEntry{progress: 100}], []} = + get_uploaded_entries(lv, :avatar) + end + + @tag allow: [max_entries: 1, chunk_size: 20, accept: :any] + test "consume_uploaded_entries executes function against all entries, cleans up tmp file, and shuts down", + %{lv: lv} do + parent = self() + avatar = file_input(lv, "LiveForm", :avatar, [%{name: "foo.jpeg", content: "123"}]) + avatar_pid = avatar.pid + assert render_upload(avatar, "foo.jpeg") =~ "100%" + assert %{"foo.jpeg" => channel_pid} = UploadClient.channel_pids(avatar) + + Process.monitor(avatar_pid) + Process.monitor(channel_pid) + + UploadLive.run(lv, fn socket -> + Phoenix.LiveView.consume_uploaded_entries(socket, :avatar, fn %{path: path}, entry -> + {:ok, send(parent, {:file, path, entry.client_name, File.read!(path)})} + end) + + {:reply, :ok, socket} + end) + + # Wait for the UploadClient and UploadChannel to shutdown + assert_receive {:DOWN, _ref, :process, ^avatar_pid, {:shutdown, :closed}}, 1000 + assert_receive {:DOWN, _ref, :process, ^channel_pid, {:shutdown, :closed}}, 1000 + assert_receive {:file, tmp_path, "foo.jpeg", "123"} + # synchronize with LV and Plug.Upload to ensure they have processed DOWN + assert render(lv) + assert :sys.get_state(Plug.Upload) + refute File.exists?(tmp_path) + end + + @tag allow: [max_entries: 1, chunk_size: 20, accept: :any] + test "consume_uploaded_entry executes function, cleans up tmp file, and shuts down", %{ + lv: lv + } do + parent = self() + avatar = file_input(lv, "LiveForm", :avatar, [%{name: "foo.jpeg", content: "123"}]) + avatar_pid = avatar.pid + assert render_upload(avatar, "foo.jpeg") =~ "100%" + Process.monitor(avatar_pid) + + UploadLive.run(lv, fn socket -> + {[entry], []} = Phoenix.LiveView.uploaded_entries(socket, :avatar) + + Phoenix.LiveView.consume_uploaded_entry(socket, entry, fn %{path: path} -> + {:ok, send(parent, {:file, path, entry.client_name, File.read!(path)})} + end) + + {:reply, :ok, socket} + end) + + assert_receive {:DOWN, _ref, :process, ^avatar_pid, {:shutdown, :closed}}, 1000 + assert_receive {:file, tmp_path, "foo.jpeg", "123"} + # synchronize with LV to ensure it has processed DOWN + assert render(lv) + # synchronize with Plug.Upload to ensure it has processed DOWN + :sys.get_state(Plug.Upload) + refute File.exists?(tmp_path) + end + + @tag allow: [max_entries: 1, chunk_size: 20, accept: :any] + test "consume_uploaded_entries returns empty list when no uploads exist", %{lv: lv} do + parent = self() + + UploadLive.run(lv, fn socket -> + result = + Phoenix.LiveView.consume_uploaded_entries(socket, :avatar, fn _file, _entry -> + :boom + end) + + send(parent, {:consumed, result}) + {:reply, :ok, socket} + end) + + assert_receive {:consumed, []} + end + + @tag allow: [max_entries: 1, chunk_size: 20, accept: :any] + test "consume_uploaded_entries raises when upload is still in progress", %{lv: lv} do + avatar = + file_input(lv, "LiveForm", :avatar, [ + %{name: "foo.jpeg", content: String.duplicate("0", 100)} + ]) + + assert render_upload(avatar, "foo.jpeg", 1) =~ "1%" + + try do + UploadLive.run(lv, fn socket -> + Phoenix.LiveView.consume_uploaded_entries(socket, :avatar, fn _file, _entry -> + :boom + end) + end) + catch + :exit, {{%ArgumentError{message: msg}, _}, _} -> + assert msg =~ "cannot consume uploaded files when entries are still in progress" + end + end + + @tag allow: [max_entries: 1, chunk_size: 20, accept: :any] + test "consume_uploaded_entry raises when upload is still in progress", %{lv: lv} do + avatar = + file_input(lv, "LiveForm", :avatar, [ + %{name: "foo.jpeg", content: String.duplicate("0", 100)} + ]) + + assert render_upload(avatar, "foo.jpeg", 1) =~ "1%" + + try do + UploadLive.run(lv, fn socket -> + {[], [in_progress_entry]} = Phoenix.LiveView.uploaded_entries(socket, :avatar) + + Phoenix.LiveView.consume_uploaded_entry(socket, in_progress_entry, fn _file -> + :boom + end) + end) + catch + :exit, {{%ArgumentError{message: msg}, _}, _} -> + assert msg =~ "cannot consume uploaded files when entries are still in progress" + end + end + + @tag allow: [max_entries: 1, chunk_size: 20, accept: :any] + test "consume_uploaded_entries can postpone consumption", + %{lv: lv} do + parent = self() + avatar = file_input(lv, "LiveForm", :avatar, [%{name: "foo.jpeg", content: "123"}]) + avatar_pid = avatar.pid + assert render_upload(avatar, "foo.jpeg") =~ "100%" + assert %{"foo.jpeg" => channel_pid} = UploadClient.channel_pids(avatar) + + Process.monitor(avatar_pid) + Process.monitor(channel_pid) + + UploadLive.run(lv, fn socket -> + results = + Phoenix.LiveView.consume_uploaded_entries(socket, :avatar, fn %{path: path}, entry -> + send(parent, {:file, path, entry.client_name, File.read!(path)}) + {:postpone, {:postponed, path}} + end) + + send(parent, {:results, results}) + {:reply, :ok, socket} + end) + + assert_receive {:results, [{:postponed, tmp_path}]} + assert_receive {:file, ^tmp_path, "foo.jpeg", "123"} + refute_receive {:DOWN, _ref, :process, ^avatar_pid, _} + refute_receive {:DOWN, _ref, :process, ^channel_pid, _} + assert File.exists?(tmp_path) + + UploadLive.run(lv, fn socket -> + results = + Phoenix.LiveView.consume_uploaded_entries(socket, :avatar, fn %{path: path}, entry -> + send(parent, {:file, path, entry.client_name, File.read!(path)}) + {:ok, {:consumed, path}} + end) + + send(parent, {:results, results}) + {:reply, :ok, socket} + end) + + assert_receive {:results, [{:consumed, tmp_path}]} + assert_receive {:file, ^tmp_path, "foo.jpeg", "123"} + assert_receive {:DOWN, _ref, :process, ^avatar_pid, {:shutdown, :closed}}, 1000 + assert_receive {:DOWN, _ref, :process, ^channel_pid, {:shutdown, :closed}}, 1000 + # synchronize with LV to ensure it has processed DOWN + assert render(lv) + # synchronize with Plug.Upload to ensure it has processed DOWN + :sys.get_state(Plug.Upload) + refute File.exists?(tmp_path) + end + + @tag allow: [max_entries: 1, chunk_size: 20, accept: :any] + test "consume_uploaded_entry can postpone consumption", + %{lv: lv} do + parent = self() + avatar = file_input(lv, "LiveForm", :avatar, [%{name: "foo.jpeg", content: "123"}]) + avatar_pid = avatar.pid + assert render_upload(avatar, "foo.jpeg") =~ "100%" + assert %{"foo.jpeg" => channel_pid} = UploadClient.channel_pids(avatar) + + Process.monitor(avatar_pid) + Process.monitor(channel_pid) + + UploadLive.run(lv, fn socket -> + {[entry], []} = Phoenix.LiveView.uploaded_entries(socket, :avatar) + + result = + Phoenix.LiveView.consume_uploaded_entry(socket, entry, fn %{path: path} -> + send(parent, {:file, path, entry.client_name, File.read!(path)}) + {:postpone, {:postponed, path}} + end) + + send(parent, {:result, result}) + {:reply, :ok, socket} + end) + + assert_receive {:result, {:postponed, tmp_path}} + assert_receive {:file, ^tmp_path, "foo.jpeg", "123"} + refute_receive {:DOWN, _ref, :process, ^avatar_pid, _} + refute_receive {:DOWN, _ref, :process, ^channel_pid, _} + assert File.exists?(tmp_path) + + UploadLive.run(lv, fn socket -> + {[entry], []} = Phoenix.LiveView.uploaded_entries(socket, :avatar) + + result = + Phoenix.LiveView.consume_uploaded_entry(socket, entry, fn %{path: path} -> + send(parent, {:file, path, entry.client_name, File.read!(path)}) + {:ok, {:consumed, path}} + end) + + send(parent, {:result, result}) + {:reply, :ok, socket} + end) + + assert_receive {:result, {:consumed, tmp_path}} + assert_receive {:file, ^tmp_path, "foo.jpeg", "123"} + assert_receive {:DOWN, _ref, :process, ^avatar_pid, {:shutdown, :closed}}, 1000 + assert_receive {:DOWN, _ref, :process, ^channel_pid, {:shutdown, :closed}}, 1000 + # synchronize with LV to ensure it has processed DOWN + assert render(lv) + # synchronize with Plug.Upload to ensure it has processed DOWN + :sys.get_state(Plug.Upload) + refute File.exists?(tmp_path) + end + + @tag allow: [max_entries: 1, accept: :any] + test "cancel_upload with invalid ref", %{lv: lv} do + avatar = + file_input(lv, "LiveForm", :avatar, [ + %{name: "foo.jpeg", content: String.duplicate("0", 100)} + ]) + + assert UploadLive.exits_with(lv, avatar, ArgumentError, fn -> + UploadLive.run(lv, fn socket -> + {:reply, :ok, Phoenix.LiveView.cancel_upload(socket, :avatar, "bad_ref")} + end) + end) =~ "no entry in upload \":avatar\" with ref \"bad_ref\"" + end + + @tag allow: [max_entries: 1, chunk_size: 20, accept: :any] + test "cancel_upload in progress", %{lv: lv} do + avatar = + file_input(lv, "LiveForm", :avatar, [ + %{name: "foo.jpeg", content: String.duplicate("0", 100)} + ]) + + assert render_upload(avatar, "foo.jpeg", 1) =~ "1%" + assert %{"foo.jpeg" => channel_pid} = UploadClient.channel_pids(avatar) + + unlink(channel_pid, lv, avatar) + Process.monitor(channel_pid) + + UploadLive.run(lv, fn socket -> + {[], [%{ref: ref}]} = Phoenix.LiveView.uploaded_entries(socket, :avatar) + {:reply, :ok, Phoenix.LiveView.cancel_upload(socket, :avatar, ref)} + end) + + assert_receive {:DOWN, _ref, :process, ^channel_pid, {:shutdown, :closed}}, 1000 + + assert UploadLive.run(lv, fn socket -> + {:reply, Phoenix.LiveView.uploaded_entries(socket, :avatar), socket} + end) == {[], []} + end + + @tag allow: [max_entries: 1, chunk_size: 20, accept: :any] + test "cancel_upload not yet in progress", %{lv: lv} do + file_name = "foo.jpeg" + avatar = file_input(lv, "LiveForm", :avatar, [%{name: file_name, content: "ok"}]) + + assert lv + |> form("LiveForm", user: %{}) + |> render_change(avatar) =~ file_name + + assert UploadClient.channel_pids(avatar) == %{} + + assert {[], [%{ref: ref}]} = + UploadLive.run(lv, fn socket -> + {:reply, Phoenix.LiveView.uploaded_entries(socket, :avatar), socket} + end) + + UploadLive.run(lv, fn socket -> + {:reply, :ok, Phoenix.LiveView.cancel_upload(socket, :avatar, ref)} + end) + + refute render(lv) =~ file_name + end + + @tag allow: [max_entries: 1, chunk_size: 20, accept: :any] + test "allow_upload with active entries", %{lv: lv} do + avatar = + file_input(lv, "LiveForm", :avatar, [ + %{name: "foo.jpeg", content: String.duplicate("0", 100)} + ]) + + assert render_upload(avatar, "foo.jpeg", 1) =~ "1%" + + assert UploadLive.exits_with(lv, avatar, ArgumentError, fn -> + UploadLive.run(lv, fn socket -> + {:reply, :ok, Phoenix.LiveView.allow_upload(socket, :avatar, accept: :any)} + end) + end) =~ "cannot allow_upload on an existing upload with active entries" + end + + @tag allow: [ + max_entries: 1, + chunk_size: 50, + accept: :any, + writer: &__MODULE__.build_writer/3 + ] + test "writer can be configured", %{lv: lv} do + Process.register(self(), :test_writer) + + content = String.duplicate("0", 100) + + avatar = + file_input(lv, "LiveForm", :avatar, [ + %{name: "foo.jpeg", content: content} + ]) + + assert render_upload(avatar, "foo.jpeg", 50) =~ "#{@context}:foo.jpeg:50%" + assert render_upload(avatar, "foo.jpeg", 50) =~ "#{@context}:foo.jpeg:100%" + + metas = + UploadLive.run(lv, fn socket -> + results = + Phoenix.LiveView.consume_uploaded_entries(socket, :avatar, fn meta, _entry -> + {:ok, meta} + end) + + {:reply, results, socket} + end) + + assert metas == [:test_writer] + assert_receive :init + assert_receive :meta + assert_receive {:write_chunk, chunk1} + assert_receive {:write_chunk, chunk2} + refute_receive {:write_chunk, _} + assert chunk1 <> chunk2 == content + assert_receive {:close, :done} + end + + @tag allow: [ + max_entries: 1, + chunk_size: 50, + accept: :any, + writer: &__MODULE__.build_writer/3 + ] + test "writer with LiveView exit", %{lv: lv} do + Process.register(self(), :test_writer) + + content = String.duplicate("0", 100) + + avatar = + file_input(lv, "LiveForm", :avatar, [ + %{name: "foo.jpeg", content: content} + ]) + + assert render_upload(avatar, "foo.jpeg", 50) =~ "#{@context}:foo.jpeg:50%" + + assert %{"foo.jpeg" => channel_pid} = UploadClient.channel_pids(avatar) + + unlink(channel_pid, lv, avatar) + Process.monitor(channel_pid) + Process.exit(lv.pid, :kill) + + assert_receive {:write_chunk, _chunk1} + refute_receive {:write_chunk, _} + assert_receive {:close, :cancel} + end + + @tag allow: [ + max_entries: 1, + chunk_size: 50, + accept: :any, + writer: &__MODULE__.build_writer/3 + ] + test "writer with error", %{lv: lv} do + Process.register(self(), :test_writer) + + content = "error" + + avatar = + file_input(lv, "LiveForm", :avatar, [ + %{name: "foo.jpeg", content: content} + ]) + + assert render_upload(avatar, "foo.jpeg") =~ + ~s/entry_error:{:writer_failure, :custom_error}/ + + assert_receive {:close, {:error, :custom_error}} + end + end + end + + describe "component uploads" do + setup :setup_component + + @tag allow: [accept: :any] + test "liveview exits when duplicate name registered for another cid", %{lv: lv} do + avatar = file_input(lv, "#upload0", :avatar, build_entries(1)) + assert render_upload(avatar, "myfile1.jpeg", 1) =~ "component:myfile1.jpeg:1%" + + GenServer.call( + lv.pid, + {:setup, fn socket -> Component.assign(socket, uploads_count: 2) end} + ) + + GenServer.call( + lv.pid, + {:setup, + fn socket -> + run = fn component_socket -> + new_socket = LiveView.allow_upload(component_socket, :avatar, accept: :any) + {:reply, :ok, new_socket} + end + + LiveView.send_update(LiveViewNativeTest.UploadComponent, + id: "upload1", + run: {run, nil} + ) + + socket + end} + ) + + dup_avatar = file_input(lv, "#upload1", :avatar, build_entries(1)) + + assert UploadLive.exits_with(lv, dup_avatar, RuntimeError, fn -> + render_upload(dup_avatar, "myfile1.jpeg", 1) + end) =~ "existing upload for avatar already allowed in another component" + + refute Process.alive?(lv.pid) + end + + @tag allow: [max_entries: 1, chunk_size: 20, accept: :any] + test "cancel_upload in progress when component is removed", %{lv: lv} do + avatar = file_input(lv, "#upload0", :avatar, build_entries(1)) + assert render_upload(avatar, "myfile1.jpeg", 1) =~ "component:myfile1.jpeg:1%" + assert %{"myfile1.jpeg" => channel_pid} = UploadClient.channel_pids(avatar) + + unlink(channel_pid, lv, avatar) + Process.monitor(channel_pid) + + assert render(lv) =~ "myfile1.jpeg" + GenServer.call(lv.pid, {:uploads, 0}) + + refute render(lv) =~ "myfile1.jpeg" + + assert_receive {:DOWN, _ref, :process, ^channel_pid, {:shutdown, :closed}}, 1000 + + # retry with new component + GenServer.call(lv.pid, {:uploads, 1}) + + UploadLive.run(lv, fn component_socket -> + new_socket = Phoenix.LiveView.allow_upload(component_socket, :avatar, accept: :any) + {:reply, :ok, new_socket} + end) + + avatar = file_input(lv, "#upload0", :avatar, build_entries(1)) + assert render_upload(avatar, "myfile1.jpeg", 100) =~ "component:myfile1.jpeg:100%" + end + + @tag allow: [max_entries: 1, chunk_size: 20, accept: :any] + test "cancel_upload not yet in progress when component is removed", %{lv: lv} do + file_name = "myfile1.jpeg" + avatar = file_input(lv, "#upload0", :avatar, [%{name: file_name, content: "ok"}]) + + assert lv + |> form("LiveForm", user: %{}) + |> render_change(avatar) =~ file_name + + assert UploadClient.channel_pids(avatar) == %{} + + assert render(lv) =~ file_name + + GenServer.call(lv.pid, {:uploads, 0}) + + refute render(lv) =~ file_name + + # retry with new component + GenServer.call(lv.pid, {:uploads, 1}) + + UploadLive.run(lv, fn component_socket -> + new_socket = Phoenix.LiveView.allow_upload(component_socket, :avatar, accept: :any) + {:reply, :ok, new_socket} + end) + + avatar = file_input(lv, "#upload0", :avatar, build_entries(1)) + assert render_upload(avatar, "myfile1.jpeg", 100) =~ "component:myfile1.jpeg:100%" + end + + @tag allow: [accept: :any] + test "get allowed uploads from the form's target cid", %{lv: lv} do + GenServer.call( + lv.pid, + {:setup, fn socket -> Component.assign(socket, uploads_count: 2) end} + ) + + GenServer.call( + lv.pid, + {:setup, + fn socket -> + run = fn component_socket -> + new_socket = + component_socket + |> Phoenix.LiveView.allow_upload(:avatar, accept: :any) + |> Phoenix.LiveView.allow_upload(:background, accept: :any) + + {:reply, :ok, new_socket} + end + + LiveView.send_update(LiveViewNativeTest.UploadComponent, + id: "upload1", + run: {run, nil} + ) + + socket + end} + ) + + assert %LiveViewNativeTest.Upload{} = + file_input(lv, "#upload1", :background, build_entries(1)) + + assert_raise RuntimeError, "no uploads allowed for background", fn -> + file_input(lv, "#upload0", :background, build_entries(1)) + end + end + end +end diff --git a/test/live_view_native/upload/external_test.exs b/test/live_view_native/upload/external_test.exs new file mode 100644 index 0000000..946ffd0 --- /dev/null +++ b/test/live_view_native/upload/external_test.exs @@ -0,0 +1,310 @@ +defmodule LiveViewNative.UploadExternalTest do + use ExUnit.Case, async: true + + @endpoint LiveViewNativeTest.Endpoint + + import LiveViewNativeTest + + alias Phoenix.LiveView + alias LiveViewNativeTest.UploadLive + + def inspect_html_safe(term) do + term + |> inspect() + |> Phoenix.HTML.html_escape() + |> Phoenix.HTML.safe_to_string() + end + + def run(lv, func) do + GenServer.call(lv.pid, {:run, func}) + end + + def mount_lv(setup) when is_function(setup, 1) do + conn = Plug.Test.init_test_session(Phoenix.ConnTest.build_conn(), %{}) + {:ok, lv, _} = live_isolated(conn, UploadLive, session: %{}, _format: :gameboy) + :ok = GenServer.call(lv.pid, {:setup, setup}) + {:ok, lv} + end + + setup %{allow: opts} do + external = Keyword.fetch!(opts, :external) + + opts = + Keyword.put(opts, :external, fn entry, socket -> + apply(__MODULE__, external, [entry, socket]) + end) + + opts = + case Keyword.fetch(opts, :progress) do + {:ok, progress} -> + Keyword.put(opts, :progress, fn _, entry, socket -> + apply(__MODULE__, progress, [entry, socket]) + end) + + :error -> + opts + end + + {:ok, lv} = mount_lv(fn socket -> Phoenix.LiveView.allow_upload(socket, :avatar, opts) end) + + {:ok, lv: lv} + end + + def preflight(%LiveView.UploadEntry{} = entry, socket) do + new_socket = + Phoenix.Component.update(socket, :preflights, fn preflights -> + [entry.client_name | preflights] + end) + + {:ok, %{uploader: "S3"}, new_socket} + end + + def consume(%LiveView.UploadEntry{} = entry, socket) do + if entry.done? do + Phoenix.LiveView.consume_uploaded_entry(socket, entry, fn _ -> {:ok, :ok} end) + end + + {:noreply, socket} + end + + @tag allow: [max_entries: 2, chunk_size: 20, accept: :any, external: :preflight] + test "external with relative path from file_input/4 helper", %{lv: lv} do + avatar = + file_input(lv, "LiveForm", :avatar, [ + %{ + name: "foo1.jpeg", + content: String.duplicate("ok", 100), + relative_path: "some/path/to/foo1.jpeg" + } + ]) + + assert render_upload(avatar, "foo1.jpeg", 1) =~ "relative path:some/path/to/foo1.jpeg" + end + + @tag allow: [max_entries: 2, chunk_size: 20, accept: :any, external: :preflight] + test "external upload invokes preflight per entry", %{lv: lv} do + avatar = + file_input(lv, "LiveForm", :avatar, [ + %{name: "foo1.jpeg", content: String.duplicate("ok", 100)}, + %{name: "foo2.jpeg", content: String.duplicate("ok", 100)} + ]) + + assert lv + |> form("LiveForm", user: %{}) + |> render_change(avatar) =~ "foo1.jpeg:0%" + + assert render_upload(avatar, "foo1.jpeg", 1) =~ "foo1.jpeg:1%" + assert render(lv) =~ "preflight:#{UploadLive.inspect_html_safe("foo1.jpeg")}" + assert render(lv) =~ "preflight:#{UploadLive.inspect_html_safe("foo2.jpeg")}" + end + + @tag allow: [max_entries: 1, chunk_size: 20, accept: :any, external: :preflight] + test "external with too many entries", %{lv: lv} do + avatar = + file_input(lv, "LiveForm", :avatar, [ + %{name: "foo1.jpeg", content: String.duplicate("ok", 100)}, + %{name: "foo2.jpeg", content: String.duplicate("ok", 100)} + ]) + + assert lv + |> form("LiveForm", user: %{}) + |> render_change(avatar) =~ "foo1.jpeg:0%" + + assert {:error, [[_ref, :too_many_files]]} = render_upload(avatar, "foo1.jpeg", 1) + end + + @tag allow: [ + max_entries: 1, + chunk_size: 20, + auto_upload: true, + accept: :any, + external: :preflight + ] + test "external auto upload with too many entries", %{lv: lv} do + avatar = + file_input(lv, "LiveForm", :avatar, [ + %{name: "foo1.jpeg", content: String.duplicate("ok", 100)}, + %{name: "foo2.jpeg", content: String.duplicate("ok", 100)} + ]) + + html = + lv + |> form("LiveForm", user: %{}) + |> render_change(avatar) + + assert html =~ "foo1.jpeg:0%" + assert html =~ "foo2.jpeg:0%" + + assert render_upload(avatar, "foo1.jpeg", 1) =~ "foo1.jpeg:1%" + assert {:error, :not_allowed} = render_upload(avatar, "foo2.jpeg", 1) + end + + @tag allow: [ + max_entries: 1, + max_file_size: 1, + auto_upload: true, + accept: :any, + external: :preflight + ] + test "external auto upload with exceeded max file size", %{lv: lv} do + avatar = + file_input(lv, "LiveForm", :avatar, [ + %{name: "foo1.jpeg", content: String.duplicate("ok", 100)}, + %{name: "foo2.jpeg", content: String.duplicate("ok", 100)} + ]) + + html = + lv + |> form("LiveForm", user: %{}) + |> render_change(avatar) + + assert html =~ "foo1.jpeg:0%" + assert html =~ "foo2.jpeg:0%" + + assert {:error, [[_, %{reason: :too_large}]]} = render_upload(avatar, "foo1.jpeg", 1) + assert {:error, :not_allowed} = render_upload(avatar, "foo2.jpeg", 1) + end + + def bad_preflight(%LiveView.UploadEntry{} = _entry, socket), do: {:ok, %{}, socket} + + @tag allow: [max_entries: 1, chunk_size: 20, accept: :any, external: :bad_preflight] + test "external preflight raises when missing required :uploader key", %{lv: lv} do + avatar = + file_input(lv, "LiveForm", :avatar, [%{name: "foo.jpeg", content: String.duplicate("ok", 100)}]) + + assert UploadLive.exits_with(lv, avatar, ArgumentError, fn -> + render_upload(avatar, "foo.jpeg", 1) =~ "foo.jpeg:1%" + end) =~ "external uploader metadata requires an :uploader key." + end + + def error_preflight(%LiveView.UploadEntry{} = entry, socket) do + if entry.client_name == "bad.jpeg" do + {:error, %{reason: "bad name"}, socket} + else + {:ok, %{uploader: "S3"}, socket} + end + end + + @tag allow: [max_entries: 2, chunk_size: 20, accept: :any, external: :error_preflight] + test "preflight with error return", %{lv: lv} do + avatar = + file_input(lv, "LiveForm", :avatar, [ + %{name: "foo.jpeg", content: String.duplicate("ok", 100)}, + %{name: "bad.jpeg", content: String.duplicate("ok", 100)} + ]) + + assert {:error, [[ref, %{reason: "bad name"}]]} = render_upload(avatar, "bad.jpeg", 1) + assert {:error, [[^ref, %{reason: "bad name"}]]} = render_upload(avatar, "foo.jpeg", 1) + assert render(lv) =~ "bad name" + end + + @tag allow: [ + max_entries: 2, + chunk_size: 20, + auto_upload: true, + accept: :any, + external: :error_preflight + ] + test "preflight with auto_upload with error return", %{lv: lv} do + avatar = + file_input(lv, "LiveForm", :avatar, [ + %{name: "foo.jpeg", content: String.duplicate("ok", 100)}, + %{name: "bad.jpeg", content: String.duplicate("ok", 100)} + ]) + + assert {:error, [[_, %{reason: "bad name"}]]} = render_upload(avatar, "bad.jpeg", 1) + html = render_upload(avatar, "foo.jpeg", 1) + assert html =~ "foo.jpeg:1%" + assert html =~ "bad.jpeg:0%" + end + + @tag allow: [max_entries: 2, chunk_size: 20, accept: :any, external: :preflight] + test "consume_uploaded_entries", %{lv: lv} do + upload_complete = "foo.jpeg:100%" + parent = self() + + avatar = + file_input(lv, "LiveForm", :avatar, [ + %{ + name: "foo.jpeg", + content: String.duplicate("ok", 100), + last_modified: 1_594_171_879_000 + } + ]) + + assert render_upload(avatar, "foo.jpeg", 100) =~ upload_complete + + run(lv, fn socket -> + Phoenix.LiveView.consume_uploaded_entries(socket, :avatar, fn meta, entry -> + {:ok, send(parent, {:consume, meta, entry.client_name, entry.client_last_modified})} + end) + + {:reply, :ok, socket} + end) + + assert_receive {:consume, %{uploader: "S3"}, "foo.jpeg", 1_594_171_879_000} + refute render(lv) =~ upload_complete + end + + @tag allow: [max_entries: 2, chunk_size: 20, accept: :any, external: :preflight] + test "consume_uploaded_entry", %{lv: lv} do + upload_complete = "foo.jpeg:100%" + parent = self() + + avatar = + file_input(lv, "LiveForm", :avatar, [%{name: "foo.jpeg", content: String.duplicate("ok", 100)}]) + + assert render_upload(avatar, "foo.jpeg", 100) =~ upload_complete + + run(lv, fn socket -> + {[entry], []} = Phoenix.LiveView.uploaded_entries(socket, :avatar) + + Phoenix.LiveView.consume_uploaded_entry(socket, entry, fn meta -> + {:ok, send(parent, {:individual_consume, meta, entry.client_name})} + end) + + {:reply, :ok, socket} + end) + + assert_receive {:individual_consume, %{uploader: "S3"}, "foo.jpeg"} + refute render(lv) =~ upload_complete + end + + @tag allow: [ + max_entries: 5, + chunk_size: 20, + accept: :any, + external: :preflight, + progress: :consume + ] + test "consume_uploaded_entry/3 maintains entries state after drop", %{lv: lv} do + parent = self() + + # Note we are building a unique `%Upload{}` for each file. + # This is to avoid the upload progress calls serializing in a + # single UploadClient. + files_inputs = + for i <- 1..5, + file = %{name: "#{i}.png", content: String.duplicate("ok", 100)}, + input = file_input(lv, "LiveForm", :avatar, [file]) do + render_upload(input, file.name, 99) + {file, input} + end + + tasks = + for {file, input} <- files_inputs do + Task.async(fn -> render_upload(input, file.name, 1) end) + end + + [_ | _] = Task.yield_many(tasks, 5000) + + run(lv, fn socket -> + entries = Phoenix.LiveView.uploaded_entries(socket, :avatar) + send(parent, {:consistent_consume, :avatar, entries}) + {:reply, :ok, socket} + end) + + assert_receive {:consistent_consume, :avatar, entries} + assert entries == {[], []} + end +end diff --git a/test/support/clients/gameboy.ex b/test/support/clients/gameboy.ex index 42b0805..abc8e55 100644 --- a/test/support/clients/gameboy.ex +++ b/test/support/clients/gameboy.ex @@ -4,5 +4,5 @@ defmodule LiveViewNativeTest.GameBoy do component: LiveViewNativeTest.GameBoy.Component, module_suffix: :GameBoy, template_engine: LiveViewNative.Engine, - client: LiveViewNative.GameBoy.Client + test_client: %LiveViewNativeTest.GameBoy.TestClient{} end diff --git a/test/support/clients/gameboy/client.ex b/test/support/clients/gameboy/client.ex deleted file mode 100644 index c53f0ff..0000000 --- a/test/support/clients/gameboy/client.ex +++ /dev/null @@ -1,8 +0,0 @@ -defmodule LiveViewNativeTest.GameBoy.Client do - @moduledoc false - - defstruct tags: %{ - form: "LiveForm", - button: "Button" - } -end diff --git a/test/support/clients/gameboy/component.ex b/test/support/clients/gameboy/component.ex index 20ebd1b..4a0c692 100644 --- a/test/support/clients/gameboy/component.ex +++ b/test/support/clients/gameboy/component.ex @@ -1,7 +1,44 @@ defmodule LiveViewNativeTest.GameBoy.Component do defmacro __using__(_) do - quote do + quote location: :keep do import LiveViewNative.Component, only: [sigil_LVN: 2] + + attr(:upload, Phoenix.LiveView.UploadConfig, + required: true, + doc: "The `Phoenix.LiveView.UploadConfig` struct" + ) + + attr(:accept, :string, + doc: + "the optional override for the accept attribute. Defaults to :accept specified by allow_upload" + ) + + attr(:rest, :global, include: ~w(webkitdirectory required disabled capture form)) + + def live_file_input(assigns) + def live_file_input(%{upload: upload} = var!(assigns)) do + var!(assigns) = assign_new(var!(assigns), :accept, fn -> upload.accept != :any && upload.accept end) + + ~LVN""" + 1, do: Map.put(@rest, :multiple, true), else: @rest} + /> + """ + end + defp join_refs(entries), do: Enum.join(entries, ",") end end -end \ No newline at end of file +end diff --git a/test/support/clients/gameboy/test_client.ex b/test/support/clients/gameboy/test_client.ex new file mode 100644 index 0000000..35cfe5f --- /dev/null +++ b/test/support/clients/gameboy/test_client.ex @@ -0,0 +1,10 @@ +defmodule LiveViewNativeTest.GameBoy.TestClient do + @moduledoc false + + defstruct tags: %{ + form: "LiveForm", + button: "Button", + upload_input: "Input", + changeables: ~w(Input LiveForm) + } +end diff --git a/test/support/clients/switch.ex b/test/support/clients/switch.ex index d78b3cd..01fa134 100644 --- a/test/support/clients/switch.ex +++ b/test/support/clients/switch.ex @@ -4,5 +4,5 @@ defmodule LiveViewNativeTest.Switch do component: LiveViewNativeTest.Switch.Component, module_suffix: :Switch, template_engine: LiveViewNative.Engine, - client: LiveViewNative.Switch.Client + test_client: %LiveViewNativeTest.Switch.TestClient{} end diff --git a/test/support/clients/switch/client.ex b/test/support/clients/switch/client.ex deleted file mode 100644 index cb688c4..0000000 --- a/test/support/clients/switch/client.ex +++ /dev/null @@ -1,8 +0,0 @@ -defmodule LiveViewNativeTest.Switch.Client do - @moduledoc false - - defstruct tags: %{ - form: "LiveForm", - button: "Button" - } -end diff --git a/test/support/clients/switch/component.ex b/test/support/clients/switch/component.ex index ced0773..d2046e5 100644 --- a/test/support/clients/switch/component.ex +++ b/test/support/clients/switch/component.ex @@ -1,7 +1,44 @@ defmodule LiveViewNativeTest.Switch.Component do defmacro __using__(_) do - quote do + quote location: :keep do import LiveViewNative.Component, only: [sigil_LVN: 2] + + attr(:upload, Phoenix.LiveView.UploadConfig, + required: true, + doc: "The `Phoenix.LiveView.UploadConfig` struct" + ) + + attr(:accept, :string, + doc: + "the optional override for the accept attribute. Defaults to :accept specified by allow_upload" + ) + + attr(:rest, :global, include: ~w(webkitdirectory required disabled capture form)) + + def live_file_input(assigns) + def live_file_input(%{upload: upload} = var!(assigns)) do + var!(assigns) = assign_new(var!(assigns), :accept, fn -> upload.accept != :any && upload.accept end) + + ~LVN""" + 1, do: Map.put(@rest, :multiple, true), else: @rest} + /> + """ + end + defp join_refs(entries), do: Enum.join(entries, ",") end end -end \ No newline at end of file +end diff --git a/test/support/clients/switch/test_client.ex b/test/support/clients/switch/test_client.ex new file mode 100644 index 0000000..58e9068 --- /dev/null +++ b/test/support/clients/switch/test_client.ex @@ -0,0 +1,10 @@ +defmodule LiveViewNativeTest.Switch.TestClient do + @moduledoc false + + defstruct tags: %{ + form: "LiveForm", + button: "Button", + upload_input: "Input", + changeables: ~w(Input LiveForm) + } +end diff --git a/test/support/live/cids_destroyed_live.ex b/test/support/live/cids_destroyed_live.ex index 129d283..5aaf4cc 100644 --- a/test/support/live/cids_destroyed_live.ex +++ b/test/support/live/cids_destroyed_live.ex @@ -36,9 +36,9 @@ defmodule LiveViewNativeTest.CidsDestroyedLive do def render(assigns) do ~LVN""" <%= if @form do %> -
+ <% else %>