diff --git a/test/feed_reader/collector_test.exs b/test/feed_reader/collector_test.exs
new file mode 100644
index 0000000..bcdc03f
--- /dev/null
+++ b/test/feed_reader/collector_test.exs
@@ -0,0 +1,130 @@
+defmodule FeedReader.CollectorTest do
+ use ExUnit.Case, async: true
+
+ alias FeedReader.{Feed, Item, ParseResult}
+
+ @xml ~s(Example- Firstone
- Secondtwo
)
+
+ test "collects binary XML through the local event stream" do
+ assert {:ok, %ParseResult{feed: %Feed{title: "Example"}, items: items}} =
+ FeedReader.parse_binary(@xml, max_items: 2)
+
+ assert [%Item{uuid: "one"}, %Item{uuid: "two"}] = items
+ end
+
+ test "collects chunk enumerables without requiring a path" do
+ chunks = String.graphemes(@xml)
+
+ assert {:ok, %ParseResult{items: [%Item{uuid: "one"}, %Item{uuid: "two"}]}} =
+ FeedReader.parse_stream(chunks, max_items: 2)
+ end
+
+ test "collects caller-owned files and preserves them" do
+ path = write_temp(@xml)
+ on_exit(fn -> File.rm(path) end)
+
+ assert {:ok, %ParseResult{items: items}} = FeedReader.parse_file(path, max_items: 2)
+ assert length(items) == 2
+ assert File.read!(path) == @xml
+ end
+
+ test "requires a positive max_items for every collector" do
+ assert {:error, {:invalid_option, :max_items}} = FeedReader.parse_binary(@xml)
+ assert {:error, {:invalid_option, :max_items}} = FeedReader.parse_file("missing.xml")
+ assert {:error, {:invalid_option, :max_items}} = FeedReader.parse_stream([])
+ assert {:error, {:invalid_option, :max_items}} = FeedReader.parse_binary(@xml, max_items: 0)
+ assert {:error, {:invalid_option, :max_items}} = FeedReader.parse_stream([], max_items: :all)
+ end
+
+ test "propagates local source errors" do
+ assert {:error, :enoent} = FeedReader.parse_file("/path/that/does/not/exist", max_items: 1)
+ end
+
+ test "filters before counting and collecting" do
+ filter = fn %Item{uuid: uuid} -> uuid == "two" end
+
+ assert {:ok, %ParseResult{items: [%Item{uuid: "two"}]}} =
+ FeedReader.parse_binary(@xml, max_items: 1, item_filter: filter)
+ end
+
+ test "reports an item beyond the bound instead of returning a partial result" do
+ assert {:error, {:max_items_exceeded, 1}} = FeedReader.parse_binary(@xml, max_items: 1)
+ end
+
+ test "continues to the terminal parser event before returning a malformed-tail error" do
+ assert {:error, _reason} = FeedReader.parse_binary(@xml <> "")
+ on_exit(fn -> File.rm(path) end)
+
+ assert {:error, {:invalid_option, :item_filter}} =
+ FeedReader.parse_file(path,
+ format: :external_test,
+ formats: [FeedReader.TestSupport.ExternalFormat],
+ max_items: 1,
+ item_filter: :not_a_function
+ )
+ end
+
+ test "rejects collection without feed metadata" do
+ path = write_temp("")
+ on_exit(fn -> File.rm(path) end)
+
+ assert {:error, :feed_metadata_missing} =
+ FeedReader.parse_file(path,
+ format: :external_test,
+ formats: [FeedReader.TestSupport.ExternalFormat],
+ max_items: 1
+ )
+ end
+
+ test "handles duplicate metadata, ignored events, and parser errors" do
+ feed = %Feed{title: "Custom", type: :rss}
+ item = %Item{uuid: "custom"}
+ path = write_temp("")
+ on_exit(fn -> File.rm(path) end)
+
+ opts = [
+ format: :external_test,
+ formats: [FeedReader.TestSupport.ExternalFormat],
+ max_items: 1,
+ collector_events: [{:feed, feed}, :ignored, {:item, item}, {:feed, feed}]
+ ]
+
+ assert {:ok, %ParseResult{feed: ^feed, items: [^item]}} = FeedReader.parse_file(path, opts)
+
+ assert {:error, :custom_parser_error} =
+ FeedReader.parse_file(
+ path,
+ Keyword.put(opts, :collector_events, [{:error, :custom_parser_error}])
+ )
+ end
+
+ test "reports an enumerable failure and still cleans its temporary source" do
+ chunks =
+ Stream.resource(fn -> :ok end, fn _state -> raise "chunk failure" end, fn _ -> :ok end)
+
+ assert {:error, {:stream_write_failed, %RuntimeError{message: "chunk failure"}}} =
+ FeedReader.parse_stream(chunks, max_items: 1)
+ end
+
+ defp write_temp(content) do
+ path =
+ Path.join(
+ System.tmp_dir!(),
+ "feed_reader-collector-#{System.unique_integer([:positive])}.xml"
+ )
+
+ File.write!(path, content)
+ path
+ end
+end
diff --git a/test/support/external_format_handler.ex b/test/support/external_format_handler.ex
index 22b8591..f3fd89d 100644
--- a/test/support/external_format_handler.ex
+++ b/test/support/external_format_handler.ex
@@ -7,6 +7,7 @@ defmodule FeedReader.TestSupport.ExternalFormatHandler do
@impl true
def handle_event(:start_document, _data, %{opts: opts} = state) do
if Keyword.get(opts, :emit_event), do: emit_event(state)
+ Enum.each(Keyword.get(opts, :collector_events, []), fn event -> state.event_sink.(event) end)
{:ok, state}
end