From 0cddbfc76ff6b61e53e9cd1df0d804bd57ec0c5d Mon Sep 17 00:00:00 2001 From: Moises Macia Date: Thu, 4 Jun 2026 20:23:58 +0000 Subject: [PATCH] add tests --- test/feed_reader/downloader_test.exs | 150 +++++++++++++++++++++++++++ test/feed_reader/transport_test.exs | 13 +++ test/support/transport_client.ex | 10 +- 3 files changed, 172 insertions(+), 1 deletion(-) create mode 100644 test/feed_reader/downloader_test.exs diff --git a/test/feed_reader/downloader_test.exs b/test/feed_reader/downloader_test.exs new file mode 100644 index 0000000..397d650 --- /dev/null +++ b/test/feed_reader/downloader_test.exs @@ -0,0 +1,150 @@ +defmodule FeedReader.DownloaderTest do + use ExUnit.Case, async: false + + alias FeedReader.Downloader + + test "downloads decoded bytes, computes a lowercase MD5, and releases its file" do + payload = "feed" + response = response([payload], [{"ETag", "\"v1\""}, {"content-encoding", "identity"}]) + + assert {:ok, %{path: path, fetch: fetch}} = download(response, max_decoded_bytes: :invalid) + assert File.read!(path) == payload + assert fetch.checksum == Base.encode16(:crypto.hash(:md5, payload), case: :lower) + assert fetch.etag == "\"v1\"" + assert fetch.status == 200 + assert fetch.final_url == "https://example.com/final.xml" + assert :ok = Downloader.close(%{path: path}) + refute File.exists?(path) + assert :ok = Downloader.close(%{path: path}) + end + + test "decompresses gzip before writing and checksumming" do + payload = "feed" + compressed = :zlib.gzip(payload) + midpoint = div(byte_size(compressed), 2) + + chunks = [ + binary_part(compressed, 0, midpoint), + binary_part(compressed, midpoint, byte_size(compressed) - midpoint) + ] + + response = response(chunks, [{"content-encoding", "gzip"}]) + + assert {:ok, %{path: path, fetch: fetch}} = download(response) + assert File.read!(path) == payload + assert fetch.checksum == Base.encode16(:crypto.hash(:md5, payload), case: :lower) + Downloader.close(%{path: path}) + end + + test "decompresses raw deflate content" do + payload = "deflate" + compressed = deflate(payload) + + assert {:ok, %{path: path, fetch: fetch}} = + download(response([compressed], [{"content-encoding", "deflate"}])) + + assert File.read!(path) == payload + assert fetch.checksum == checksum(payload) + Downloader.close(%{path: path}) + end + + test "maps 304 without creating a temporary file" do + before = download_files() + assert {:ok, :not_modified} = download(response([], [], 304)) + assert download_files() == before + end + + test "returns not changed after checksum and removes the temporary file" do + payload = "unchanged" + before = download_files() + + assert {:ok, :not_changed} = + download(response([payload], []), previous_checksum: checksum(payload)) + + assert download_files() == before + end + + test "enforces compressed and decoded byte limits and cleans failed downloads" do + before = download_files() + + assert {:error, {:resource_limit, :max_compressed_bytes, 2}} = + download(response(["long"], []), max_compressed_bytes: 2) + + assert {:error, {:resource_limit, :max_decoded_bytes, 2}} = + download(response(["long"], []), max_decoded_bytes: 2) + + assert {:error, {:resource_limit, :max_temp_bytes, 2}} = + download(response(["long"], []), max_temp_bytes: 2) + + compressed = :zlib.gzip(String.duplicate("a", 100)) + + assert {:error, {:resource_limit, :max_expansion_ratio, 1}} = + download(response([compressed], [{"content-encoding", "gzip"}]), + max_expansion_ratio: 1 + ) + + assert download_files() == before + end + + test "returns stable errors for invalid URLs, statuses, transport errors, and chunks" do + assert {:error, :invalid_url} = Downloader.download(:invalid, []) + assert {:error, {:http_status, 418}} = download(response([], [], 418)) + assert {:error, :offline} = download(response([], []), request_result: {:error, :offline}) + assert {:error, :invalid_chunk} = download(response([:not_binary], [])) + end + + test "rejects unknown encodings and malformed compressed bodies, cleaning temp files" do + before = download_files() + + assert {:error, :unsupported_content_encoding} = + download(response(["body"], [{"content-encoding", "br, gzip"}])) + + assert {:error, :invalid_compressed_body} = + download(response(["not gzip"], [{"content-encoding", "gzip"}])) + + assert download_files() == before + end + + test "converts exceptions while enumerating response chunks to download errors" do + before = download_files() + chunks = Stream.map(["chunk"], fn _chunk -> raise "chunk read failed" end) + + assert {:error, {:download_failed, %RuntimeError{message: "chunk read failed"}}} = + download(response(chunks, [])) + + assert download_files() == before + end + + defp download(response, opts \\ []) do + Downloader.download( + "https://example.com/feed.xml", + [ + http_client: FeedReader.TestSupport.TransportClient, + response: response, + test_pid: self() + ] ++ + opts + ) + end + + defp response(body, headers, status \\ 200) do + %{status: status, headers: headers, final_url: "https://example.com/final.xml", body: body} + end + + defp checksum(payload), do: Base.encode16(:crypto.hash(:md5, payload), case: :lower) + + defp deflate(payload) do + zlib = :zlib.open() + :ok = :zlib.deflateInit(zlib, :default, :deflated, -15, 8, :default) + compressed = :zlib.deflate(zlib, payload, :finish) |> IO.iodata_to_binary() + :ok = :zlib.deflateEnd(zlib) + :zlib.close(zlib) + compressed + end + + defp download_files do + System.tmp_dir!() + |> Path.join("feed_reader-download-*.xml") + |> Path.wildcard() + end +end diff --git a/test/feed_reader/transport_test.exs b/test/feed_reader/transport_test.exs index 54be975..6a2e4fa 100644 --- a/test/feed_reader/transport_test.exs +++ b/test/feed_reader/transport_test.exs @@ -47,6 +47,19 @@ defmodule FeedReader.TransportTest do assert Enum.join(response.body) == "body" end + test "sends previous ETags verbatim and exposes a 304 response" do + Req.Test.expect(__MODULE__, fn conn -> + assert Plug.Conn.get_req_header(conn, "if-none-match") == ["W/\"exact\""] + Plug.Conn.send_resp(conn, 304, "") + end) + + assert {:ok, %{status: 304, body: []}} = + ReqTransport.request("https://example.com/feed", + previous_etag: "W/\"exact\"", + http_options: [plug: {Req.Test, __MODULE__}] + ) + end + test "follows redirects and reports the final URL" do Req.Test.expect(__MODULE__, 2, fn conn -> case conn.request_path do diff --git a/test/support/transport_client.ex b/test/support/transport_client.ex index 5699c2e..f335dca 100644 --- a/test/support/transport_client.ex +++ b/test/support/transport_client.ex @@ -6,6 +6,14 @@ defmodule FeedReader.TestSupport.TransportClient do @impl true def request(url, opts) do send(opts[:test_pid], {:transport_request, url}) - {:ok, %{status: 200, headers: [], final_url: url, body: ["test"]}} + + case Keyword.get(opts, :request_result) do + nil -> + {:ok, + Keyword.get(opts, :response, %{status: 200, headers: [], final_url: url, body: ["test"]})} + + result -> + result + end end end