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