diff --git a/test/feed_reader/streaming_test.exs b/test/feed_reader/streaming_test.exs
new file mode 100644
index 0000000..a446ef6
--- /dev/null
+++ b/test/feed_reader/streaming_test.exs
@@ -0,0 +1,160 @@
+defmodule FeedReader.StreamingTest do
+ use ExUnit.Case, async: true
+
+ alias FeedReader.{Feed, Item, Session}
+
+ @xml ~s(Examplehttps://example.com/- Firstone
- Secondtwo
)
+ @atom_xml ~s(Atom)
+ @rdf_xml ~s(RDF)
+
+ test "emits feed metadata before items in one local pass" do
+ path = write_temp(@xml)
+
+ on_exit(fn -> File.rm(path) end)
+
+ assert {:ok, %Session{fetch: nil, events: events}} = FeedReader.stream_file(path)
+
+ assert [
+ {:feed, %Feed{title: "Example"}},
+ {:item, %Item{uuid: "one"}},
+ {:item, %Item{uuid: "two"}}
+ ] = Enum.to_list(events)
+
+ assert File.read!(path) == @xml
+ end
+
+ test "delivers a malformed tail as a terminal error after earlier events" do
+ xml = @xml <> " File.rm(path) end)
+
+ assert {:ok, %{events: events}} = FeedReader.stream_file(path)
+ events = Enum.to_list(events)
+
+ assert {:feed, %Feed{}} = Enum.at(events, 0)
+ assert {:item, %Item{uuid: "one"}} = Enum.at(events, 1)
+ assert {:item, %Item{uuid: "two"}} = Enum.at(events, 2)
+ assert {:error, _reason} = Enum.at(events, 3)
+ assert length(events) == 4
+ assert File.read!(path) == xml
+ end
+
+ test "rejects an unknown local format before creating a session" do
+ path = write_temp("")
+ on_exit(fn -> File.rm(path) end)
+
+ assert {:error, :unknown} = FeedReader.stream_file(path)
+ assert File.read!(path) == ""
+ end
+
+ test "validates local paths and accepts an explicitly selected format module" do
+ assert {:error, :invalid_path} = FeedReader.stream_file(123)
+ assert {:error, :enoent} = FeedReader.stream_file("/path/that/does/not/exist")
+
+ assert {:error, :not_a_file} = FeedReader.stream_file(System.tmp_dir!())
+
+ path = write_temp(@xml)
+ on_exit(fn -> File.rm(path) end)
+
+ assert {:ok, %{events: events}} =
+ FeedReader.stream_file(path, format: FeedReader.Formats.RSS)
+
+ assert {:feed, %Feed{type: :rss}} = Enum.at(Enum.to_list(events), 0)
+ end
+
+ test "accepts explicit built-in Atom and RDF format IDs" do
+ atom_path = write_temp(@atom_xml)
+ rdf_path = write_temp(@rdf_xml)
+
+ on_exit(fn ->
+ File.rm(atom_path)
+ File.rm(rdf_path)
+ end)
+
+ assert {:ok, %{events: atom_events}} = FeedReader.stream_file(atom_path, format: :atom)
+ assert [{:feed, %Feed{title: "Atom", type: :atom}}] = Enum.to_list(atom_events)
+
+ assert {:ok, %{events: rdf_events}} = FeedReader.stream_file(rdf_path, format: :rdf)
+ assert [{:feed, %Feed{title: "RDF", type: :rdf}}] = Enum.to_list(rdf_events)
+ end
+
+ test "resolves a custom format by its ID" do
+ path = write_temp("")
+ on_exit(fn -> File.rm(path) end)
+
+ assert {:ok, %{events: events}} =
+ FeedReader.stream_file(path,
+ format: :external_test,
+ formats: [FeedReader.TestSupport.ExternalFormat]
+ )
+
+ assert [] = Enum.to_list(events)
+ end
+
+ test "uses an explicit format even when detection is disabled" do
+ path = write_temp(@xml)
+ on_exit(fn -> File.rm(path) end)
+
+ assert {:ok, %{events: events}} =
+ FeedReader.stream_file(path, format: :rss, max_detection_bytes: 0)
+
+ assert {:feed, %Feed{type: :rss}} = Enum.at(Enum.to_list(events), 0)
+ end
+
+ test "closes the parser when the consumer stops early" do
+ path = write_temp(@xml)
+ on_exit(fn -> File.rm(path) end)
+
+ assert {:ok, %{events: events}} = FeedReader.stream_file(path)
+ assert [{:feed, %Feed{}}] = events |> Stream.take(1) |> Enum.to_list()
+ assert File.read!(path) == @xml
+ end
+
+ test "returns a stable error for a non-atom format option" do
+ path = write_temp(@xml)
+ on_exit(fn -> File.rm(path) end)
+
+ assert {:error, :unknown} = FeedReader.stream_file(path, format: "rss")
+ end
+
+ test "treats an invalid detection limit as an unknown format" do
+ path = write_temp(@xml)
+ on_exit(fn -> File.rm(path) end)
+
+ assert {:error, :unknown} = FeedReader.stream_file(path, max_detection_bytes: 0)
+ end
+
+ test "delivers a parser halt as a terminal error event" do
+ path = write_temp(@xml)
+ on_exit(fn -> File.rm(path) end)
+
+ assert {:ok, %{events: events}} =
+ FeedReader.stream_file(
+ path,
+ format: :parser_halt,
+ formats: [FeedReader.TestSupport.ParserHaltFormat]
+ )
+
+ assert [{:error, :parser_halted}] = Enum.to_list(events)
+ end
+
+ test "stops cleanly when the parser process exits normally" do
+ path = write_temp(@xml)
+ on_exit(fn -> File.rm(path) end)
+
+ assert {:ok, %{events: events}} =
+ FeedReader.stream_file(path,
+ format: :parser_halt,
+ formats: [FeedReader.TestSupport.ParserHaltFormat],
+ normal_exit: true
+ )
+
+ assert [] = Enum.to_list(events)
+ end
+
+ defp write_temp(content) do
+ path = Path.join(System.tmp_dir!(), "feed_reader-#{System.unique_integer([:positive])}.xml")
+ File.write!(path, content)
+ path
+ end
+end
diff --git a/test/support/parser_halt_format.ex b/test/support/parser_halt_format.ex
new file mode 100644
index 0000000..1058bfc
--- /dev/null
+++ b/test/support/parser_halt_format.ex
@@ -0,0 +1,24 @@
+defmodule FeedReader.TestSupport.ParserHaltFormat do
+ @behaviour FeedReader.Format
+
+ @impl true
+ def id, do: :parser_halt
+
+ @impl true
+ def matches_prefix?(_prefix), do: true
+
+ @impl true
+ def handler, do: FeedReader.TestSupport.ParserHaltHandler
+
+ @impl true
+ def new_parser_state(_event_sink, opts) do
+ if Keyword.get(opts, :normal_exit), do: exit(:normal)
+ nil
+ end
+
+ @impl true
+ def normalize_feed(_state, _opts), do: %FeedReader.Feed{}
+
+ @impl true
+ def normalize_item(_raw_item, _feed, _opts), do: :skip
+end
diff --git a/test/support/parser_halt_handler.ex b/test/support/parser_halt_handler.ex
new file mode 100644
index 0000000..12ad564
--- /dev/null
+++ b/test/support/parser_halt_handler.ex
@@ -0,0 +1,6 @@
+defmodule FeedReader.TestSupport.ParserHaltHandler do
+ @behaviour Saxy.Handler
+
+ @impl true
+ def handle_event(_event, _data, _state), do: {:halt, :stopped}
+end