From 1fc37f5b4d1e75fcee34871de93a94cb25a4d2bd Mon Sep 17 00:00:00 2001 From: alan blount Date: Sun, 21 Jun 2026 23:48:58 +0000 Subject: [PATCH] test: add unit tests for 5 untested modules Add comprehensive tests for modules that had no test coverage: - A2A.FileContent: from_bytes/2, from_uri/2, struct construction - A2A.Task.Filter: filtering by context_id/status/timestamp, pagination with page_size/page_token, history truncation, artifact stripping, combined filter scenarios, error cases - A2A.Task.Status: new/1 and new/2 constructors, all valid states, enforce_keys validation - A2A.ID: prefix formatting, suffix length/charset, uniqueness - A2A.TaskStore: behaviour callback declarations, optional_callbacks, ETS implementation contract These were the 5 lib modules with no corresponding test file. --- test/a2a/file_content_test.exs | 85 +++++++++++++ test/a2a/id_test.exs | 31 +++++ test/a2a/task/filter_test.exs | 221 +++++++++++++++++++++++++++++++++ test/a2a/task/status_test.exs | 60 +++++++++ test/a2a/task_store_test.exs | 29 +++++ 5 files changed, 426 insertions(+) create mode 100644 test/a2a/file_content_test.exs create mode 100644 test/a2a/id_test.exs create mode 100644 test/a2a/task/filter_test.exs create mode 100644 test/a2a/task/status_test.exs create mode 100644 test/a2a/task_store_test.exs diff --git a/test/a2a/file_content_test.exs b/test/a2a/file_content_test.exs new file mode 100644 index 0000000..3f276d7 --- /dev/null +++ b/test/a2a/file_content_test.exs @@ -0,0 +1,85 @@ +defmodule A2A.FileContentTest do + use ExUnit.Case, async: true + + alias A2A.FileContent + + describe "from_bytes/2" do + test "creates file content with inline bytes" do + fc = FileContent.from_bytes("hello") + assert fc.bytes == "hello" + assert fc.uri == nil + assert fc.name == nil + assert fc.mime_type == nil + end + + test "accepts name option" do + fc = FileContent.from_bytes("data", name: "report.txt") + assert fc.name == "report.txt" + assert fc.bytes == "data" + end + + test "accepts mime_type option" do + fc = FileContent.from_bytes("data", mime_type: "text/plain") + assert fc.mime_type == "text/plain" + end + + test "accepts both name and mime_type" do + fc = FileContent.from_bytes(<<0, 1, 2>>, name: "image.png", mime_type: "image/png") + assert fc.name == "image.png" + assert fc.mime_type == "image/png" + assert fc.bytes == <<0, 1, 2>> + assert fc.uri == nil + end + end + + describe "from_uri/2" do + test "creates file content with a URI reference" do + fc = FileContent.from_uri("https://example.com/file.pdf") + assert fc.uri == "https://example.com/file.pdf" + assert fc.bytes == nil + assert fc.name == nil + assert fc.mime_type == nil + end + + test "accepts name option" do + fc = FileContent.from_uri("s3://bucket/key", name: "doc.pdf") + assert fc.name == "doc.pdf" + assert fc.uri == "s3://bucket/key" + end + + test "accepts mime_type option" do + fc = FileContent.from_uri("https://example.com/f", mime_type: "application/pdf") + assert fc.mime_type == "application/pdf" + end + + test "accepts both name and mime_type" do + fc = FileContent.from_uri("https://cdn.example.com/img.jpg", + name: "photo.jpg", + mime_type: "image/jpeg" + ) + + assert fc.name == "photo.jpg" + assert fc.mime_type == "image/jpeg" + assert fc.uri == "https://cdn.example.com/img.jpg" + assert fc.bytes == nil + end + end + + describe "struct" do + test "can be created directly" do + fc = %FileContent{bytes: "raw", name: "f.bin"} + assert fc.bytes == "raw" + assert fc.name == "f.bin" + assert fc.uri == nil + assert fc.mime_type == nil + end + + test "all fields default to nil" do + fc = %FileContent{} + assert fc.bytes == nil + assert fc.uri == nil + assert fc.name == nil + assert fc.mime_type == nil + end + end +end diff --git a/test/a2a/id_test.exs b/test/a2a/id_test.exs new file mode 100644 index 0000000..94d51fa --- /dev/null +++ b/test/a2a/id_test.exs @@ -0,0 +1,31 @@ +defmodule A2A.IDTest do + use ExUnit.Case, async: true + + alias A2A.ID + + describe "generate/1" do + test "returns a string with the given prefix" do + id = ID.generate("tsk") + assert String.starts_with?(id, "tsk-") + end + + test "suffix is 12 alphanumeric characters" do + id = ID.generate("msg") + [_prefix, suffix] = String.split(id, "-", parts: 2) + assert String.length(suffix) == 12 + assert Regex.match?(~r/^[0-9a-zA-Z]+$/, suffix) + end + + test "generates unique IDs" do + ids = for _ <- 1..100, do: ID.generate("x") + assert length(Enum.uniq(ids)) == 100 + end + + test "works with different prefixes" do + for prefix <- ["tsk", "msg", "art", "ctx", ""] do + id = ID.generate(prefix) + assert String.starts_with?(id, "#{prefix}-") + end + end + end +end diff --git a/test/a2a/task/filter_test.exs b/test/a2a/task/filter_test.exs new file mode 100644 index 0000000..7309360 --- /dev/null +++ b/test/a2a/task/filter_test.exs @@ -0,0 +1,221 @@ +defmodule A2A.Task.FilterTest do + use ExUnit.Case, async: true + + alias A2A.Task + alias A2A.Task.Filter + alias A2A.Task.Status + + # Helpers ----------------------------------------------------------------- + + defp make_task(id, state, opts \\ []) do + ts = Keyword.get(opts, :timestamp, DateTime.utc_now()) + ctx = Keyword.get(opts, :context_id) + history = Keyword.get(opts, :history, []) + artifacts = Keyword.get(opts, :artifacts, []) + + %Task{ + id: id, + context_id: ctx, + status: %Status{state: state, timestamp: ts, message: nil}, + history: history, + artifacts: artifacts, + metadata: %{} + } + end + + defp ts(offset_seconds) do + DateTime.add(~U[2026-01-01 00:00:00Z], offset_seconds, :second) + end + + # Tests ------------------------------------------------------------------- + + describe "apply/2 with no filters" do + test "returns all tasks sorted by timestamp descending" do + tasks = [ + make_task("a", :working, timestamp: ts(1)), + make_task("b", :completed, timestamp: ts(3)), + make_task("c", :submitted, timestamp: ts(2)) + ] + + assert {:ok, result} = Filter.apply(tasks) + assert Enum.map(result.tasks, & &1.id) == ["b", "c", "a"] + assert result.total_size == 3 + assert result.page_size == 3 + assert result.next_page_token == "" + end + + test "returns empty result for empty list" do + assert {:ok, result} = Filter.apply([]) + assert result.tasks == [] + assert result.total_size == 0 + assert result.page_size == 0 + end + end + + describe "context_id filter" do + test "filters tasks by context_id" do + tasks = [ + make_task("a", :working, context_id: "ctx-1", timestamp: ts(1)), + make_task("b", :working, context_id: "ctx-2", timestamp: ts(2)), + make_task("c", :working, context_id: "ctx-1", timestamp: ts(3)) + ] + + assert {:ok, result} = Filter.apply(tasks, context_id: "ctx-1") + assert Enum.map(result.tasks, & &1.id) == ["c", "a"] + assert result.total_size == 2 + end + + test "returns nothing when context_id matches no tasks" do + tasks = [make_task("a", :working, context_id: "ctx-1", timestamp: ts(1))] + assert {:ok, result} = Filter.apply(tasks, context_id: "ctx-999") + assert result.tasks == [] + assert result.total_size == 0 + end + end + + describe "status filter" do + test "filters tasks by state" do + tasks = [ + make_task("a", :working, timestamp: ts(1)), + make_task("b", :completed, timestamp: ts(2)), + make_task("c", :working, timestamp: ts(3)) + ] + + assert {:ok, result} = Filter.apply(tasks, status: :completed) + assert length(result.tasks) == 1 + assert hd(result.tasks).id == "b" + end + end + + describe "status_timestamp_after filter" do + test "filters tasks updated after a given timestamp" do + tasks = [ + make_task("a", :working, timestamp: ts(10)), + make_task("b", :working, timestamp: ts(20)), + make_task("c", :working, timestamp: ts(30)) + ] + + assert {:ok, result} = Filter.apply(tasks, status_timestamp_after: ts(15)) + ids = Enum.map(result.tasks, & &1.id) + assert "b" in ids + assert "c" in ids + refute "a" in ids + end + end + + describe "pagination" do + test "limits results to page_size" do + tasks = for i <- 1..10, do: make_task("t-#{i}", :working, timestamp: ts(i)) + + assert {:ok, result} = Filter.apply(tasks, page_size: 3) + assert result.page_size == 3 + assert result.total_size == 10 + assert result.next_page_token != "" + end + + test "returns empty next_page_token on last page" do + tasks = [ + make_task("a", :working, timestamp: ts(1)), + make_task("b", :working, timestamp: ts(2)) + ] + + assert {:ok, result} = Filter.apply(tasks, page_size: 5) + assert result.next_page_token == "" + end + + test "page_token starts after the matching task" do + tasks = for i <- 1..5, do: make_task("t-#{i}", :working, timestamp: ts(i)) + + # First page + assert {:ok, page1} = Filter.apply(tasks, page_size: 2) + # Sorted desc by timestamp: t-5, t-4, t-3, t-2, t-1 + assert length(page1.tasks) == 2 + token = page1.next_page_token + + # Second page using the token + assert {:ok, page2} = Filter.apply(tasks, page_size: 2, page_token: token) + assert length(page2.tasks) == 2 + + # No overlap between pages + page1_ids = Enum.map(page1.tasks, & &1.id) |> MapSet.new() + page2_ids = Enum.map(page2.tasks, & &1.id) |> MapSet.new() + assert MapSet.disjoint?(page1_ids, page2_ids) + end + + test "invalid page_token returns error" do + tasks = [make_task("a", :working, timestamp: ts(1))] + assert {:error, :invalid_page_token} = Filter.apply(tasks, page_token: "nonexistent-id") + end + + test "empty string page_token is treated as no token" do + tasks = [make_task("a", :working, timestamp: ts(1))] + assert {:ok, _result} = Filter.apply(tasks, page_token: "") + end + end + + describe "history_length" do + test "truncates history to specified length" do + history = [ + A2A.Message.new_user("msg 1"), + A2A.Message.new_agent("msg 2"), + A2A.Message.new_user("msg 3") + ] + + tasks = [make_task("a", :working, timestamp: ts(1), history: history)] + + assert {:ok, result} = Filter.apply(tasks, history_length: 1) + [task] = result.tasks + assert length(task.history) == 1 + end + + test "default history_length of 0 clears history" do + history = [A2A.Message.new_user("msg")] + tasks = [make_task("a", :working, timestamp: ts(1), history: history)] + + assert {:ok, result} = Filter.apply(tasks) + [task] = result.tasks + assert task.history == [] + end + end + + describe "include_artifacts" do + test "strips artifacts by default" do + artifact = A2A.Artifact.new([A2A.Part.Text.new("output")]) + tasks = [make_task("a", :completed, timestamp: ts(1), artifacts: [artifact])] + + assert {:ok, result} = Filter.apply(tasks) + [task] = result.tasks + assert task.artifacts == [] + end + + test "preserves artifacts when include_artifacts is true" do + artifact = A2A.Artifact.new([A2A.Part.Text.new("output")]) + tasks = [make_task("a", :completed, timestamp: ts(1), artifacts: [artifact])] + + assert {:ok, result} = Filter.apply(tasks, include_artifacts: true) + [task] = result.tasks + assert length(task.artifacts) == 1 + end + end + + describe "combined filters" do + test "applies context_id + status + pagination together" do + tasks = [ + make_task("a", :working, context_id: "ctx", timestamp: ts(1)), + make_task("b", :completed, context_id: "ctx", timestamp: ts(2)), + make_task("c", :working, context_id: "ctx", timestamp: ts(3)), + make_task("d", :working, context_id: "other", timestamp: ts(4)), + make_task("e", :working, context_id: "ctx", timestamp: ts(5)) + ] + + assert {:ok, result} = + Filter.apply(tasks, context_id: "ctx", status: :working, page_size: 2) + + # ctx + working: e (ts5), c (ts3), a (ts1) -> page_size 2 -> [e, c] + assert Enum.map(result.tasks, & &1.id) == ["e", "c"] + assert result.total_size == 3 + assert result.page_size == 2 + assert result.next_page_token != "" + end + end +end diff --git a/test/a2a/task/status_test.exs b/test/a2a/task/status_test.exs new file mode 100644 index 0000000..be3b5f3 --- /dev/null +++ b/test/a2a/task/status_test.exs @@ -0,0 +1,60 @@ +defmodule A2A.Task.StatusTest do + use ExUnit.Case, async: true + + alias A2A.Task.Status + + describe "new/1" do + test "creates a status with the given state and current timestamp" do + status = Status.new(:working) + assert status.state == :working + assert status.message == nil + assert %DateTime{} = status.timestamp + # Timestamp should be very recent (within last second) + assert DateTime.diff(DateTime.utc_now(), status.timestamp, :millisecond) < 1000 + end + + test "works with all valid states" do + for state <- [:submitted, :working, :input_required, :completed, :canceled, :failed, + :rejected, :auth_required, :unknown] do + status = Status.new(state) + assert status.state == state + end + end + end + + describe "new/2" do + test "creates a status with an optional message" do + msg = A2A.Message.new_agent("processing") + status = Status.new(:working, msg) + assert status.state == :working + assert status.message == msg + assert %DateTime{} = status.timestamp + end + + test "accepts nil message explicitly" do + status = Status.new(:submitted, nil) + assert status.state == :submitted + assert status.message == nil + end + end + + describe "struct" do + test "enforces :state and :timestamp keys" do + assert_raise ArgumentError, fn -> + struct!(Status, state: :working) + end + + assert_raise ArgumentError, fn -> + struct!(Status, timestamp: DateTime.utc_now()) + end + end + + test "can be constructed directly with all required keys" do + ts = ~U[2026-01-01 00:00:00Z] + status = %Status{state: :completed, timestamp: ts} + assert status.state == :completed + assert status.timestamp == ts + assert status.message == nil + end + end +end diff --git a/test/a2a/task_store_test.exs b/test/a2a/task_store_test.exs new file mode 100644 index 0000000..da2912c --- /dev/null +++ b/test/a2a/task_store_test.exs @@ -0,0 +1,29 @@ +defmodule A2A.TaskStoreTest do + use ExUnit.Case, async: true + + # The TaskStore module defines a behaviour only — there is nothing to call + # directly. This test verifies that the behaviour callbacks are properly + # declared and that A2A.TaskStore.ETS implements them. + + describe "behaviour callbacks" do + test "required callbacks are declared" do + callbacks = A2A.TaskStore.behaviour_info(:callbacks) + assert {:get, 2} in callbacks + assert {:put, 2} in callbacks + assert {:delete, 2} in callbacks + assert {:list, 2} in callbacks + end + + test "list_all/2 is an optional callback" do + optional = A2A.TaskStore.behaviour_info(:optional_callbacks) + assert {:list_all, 2} in optional + end + end + + describe "ETS implementation contract" do + test "implements all required callbacks" do + behaviours = A2A.TaskStore.ETS.__info__(:attributes) |> Keyword.get(:behaviour, []) + assert A2A.TaskStore in behaviours + end + end +end