From 5d969cd6206063130bbe5f3d80140ccbb3ffea63 Mon Sep 17 00:00:00 2001 From: Alfonso Manuel Date: Wed, 8 Jul 2026 12:32:29 +0200 Subject: [PATCH 1/3] Increate test coverage --- coveralls.json | 7 +++ lib/dataplane_ex/atproto/account.ex | 18 +++--- lib/dataplane_ex/atproto/commit.ex | 54 +++++++----------- lib/dataplane_ex/atproto/event.ex | 4 +- lib/dataplane_ex/op.ex | 4 +- mix.exs | 13 ++++- mix.lock | 5 +- test/dataplane_ex/atproto/account_test.exs | 47 ++++++++++++++++ test/dataplane_ex/atproto/commit_test.exs | 22 +++++++- test/dataplane_ex/atproto/event_test.exs | 50 +++++++++++++++++ test/dataplane_ex/car_test.exs | 30 +++++++++- test/dataplane_ex/cid_test.exs | 16 ++++++ test/dataplane_ex/csv_test.exs | 25 +++++++++ test/dataplane_ex/op_test.exs | 26 +++++++++ test/dataplane_ex/sync_client_test.exs | 64 ++++++++++++++++++++++ test/support/car_fixtures.ex | 13 +++++ test/support/commit_fixtures.ex | 43 +++++++++++++++ test/support/data_case.ex | 16 ++++++ test/support/tmp_file_fixtures.ex | 22 ++++++++ 19 files changed, 422 insertions(+), 57 deletions(-) create mode 100644 coveralls.json create mode 100644 test/dataplane_ex/atproto/account_test.exs create mode 100644 test/dataplane_ex/op_test.exs create mode 100644 test/dataplane_ex/sync_client_test.exs create mode 100644 test/support/car_fixtures.ex create mode 100644 test/support/commit_fixtures.ex create mode 100644 test/support/data_case.ex create mode 100644 test/support/tmp_file_fixtures.ex diff --git a/coveralls.json b/coveralls.json new file mode 100644 index 0000000..178f144 --- /dev/null +++ b/coveralls.json @@ -0,0 +1,7 @@ +{ + "skip_files": [ + "lib/dataplane_ex.ex", + "lib/dataplane_ex_web.ex", + "lib/dataplane_ex_web/endpoint.ex" + ] +} diff --git a/lib/dataplane_ex/atproto/account.ex b/lib/dataplane_ex/atproto/account.ex index 7309d89..7e538a4 100644 --- a/lib/dataplane_ex/atproto/account.ex +++ b/lib/dataplane_ex/atproto/account.ex @@ -1,9 +1,15 @@ defmodule DataplaneEx.ATProto.Account do + @moduledoc false + use Ecto.Schema import Ecto.Changeset # spec: https://atproto.com/specs/sync#account-events @primary_key false + + @required_attrs [:seq, :did, :time, :active] + @attrs @required_attrs ++ [:status] + embedded_schema do field :seq, :integer field :did, :string @@ -12,11 +18,6 @@ defmodule DataplaneEx.ATProto.Account do field :status, Ecto.Enum, values: [:takendown, :suspended, :deleted, :deactivated] end - @required_attrs [:seq, :did, :time, :active] - - @attrs @required_attrs ++ [:status] - - @doc false def changeset(%__MODULE__{} = account, attrs) do account |> cast(attrs, @attrs) @@ -26,13 +27,10 @@ defmodule DataplaneEx.ATProto.Account do def to_event(%__MODULE__{} = account) do %{did: did, seq: seq, active: active, time: time} = account - kind = :account - event_time = DateTime.utc_now() |> DateTime.to_unix(:microsecond) - %{ did: did, - time_us: event_time, - kind: kind, + time_us: DateTime.utc_now() |> DateTime.to_unix(:microsecond), + kind: :account, account: %{ active: active, did: did, diff --git a/lib/dataplane_ex/atproto/commit.ex b/lib/dataplane_ex/atproto/commit.ex index 8369e97..c2a4d41 100644 --- a/lib/dataplane_ex/atproto/commit.ex +++ b/lib/dataplane_ex/atproto/commit.ex @@ -1,9 +1,15 @@ defmodule DataplaneEx.ATProto.Commit do + @moduledoc false + use Ecto.Schema import Ecto.Changeset # spec: https://atproto.com/specs/sync#commit-events @primary_key false + + @required_attrs [:seq, :repo, :time, :rev, :commit, :tooBig, :blocks, :blobs] + @attrs @required_attrs ++ [:since] + embedded_schema do field :seq, :integer field :repo, :string @@ -13,15 +19,11 @@ defmodule DataplaneEx.ATProto.Commit do field :commit, DataplaneEx.CID field :tooBig, :boolean field :blocks, DataplaneEx.CAR - embeds_many :ops, DataplaneEx.Op field :blobs, {:array, :binary} - end - - @required_attrs [:seq, :repo, :time, :rev, :commit, :tooBig, :blocks, :blobs] - @attrs @required_attrs ++ [:since] + embeds_many :ops, DataplaneEx.Op + end - @doc false def changeset(%__MODULE__{} = commit, attrs) do commit |> cast(attrs, @attrs) @@ -30,58 +32,44 @@ defmodule DataplaneEx.ATProto.Commit do end def to_event(%__MODULE__{} = commit) do - for op <- commit.ops do - to_event(op, commit) - end + Enum.map(commit.ops, &to_event(&1, commit)) end - defp to_event(%{action: "delete"} = op, commit) do - %{repo: did, rev: rev} = commit - kind = :commit - event_time = DateTime.utc_now() |> DateTime.to_unix(:microsecond) - - operation = op.action - + defp to_event(%{action: "delete"} = op, %{repo: did, rev: rev}) do [collection, rkey] = Path.split(op.path) %{ did: did, - time_us: event_time, - kind: kind, + time_us: time_us(), + kind: :commit, commit: %{ rev: rev, - operation: operation, + operation: op.action, collection: collection, rkey: rkey } } end - defp to_event(op, commit) do - %{repo: did, rev: rev, blocks: blocks} = commit - kind = :commit - event_time = DateTime.utc_now() |> DateTime.to_unix(:microsecond) - - operation = op.action + defp to_event(op, %{repo: did, rev: rev, blocks: %{blocks: blocks}}) do cid = op.cid - + record = Map.get(blocks, cid, %{}) [collection, rkey] = Path.split(op.path) - record = Map.get(blocks.blocks, cid, %{}) - cid = DASL.CID.encode(cid) - %{ did: did, - time_us: event_time, - kind: kind, + time_us: time_us(), + kind: :commit, commit: %{ rev: rev, - operation: operation, + operation: op.action, collection: collection, rkey: rkey, record: Map.take(record, ["$type", "createdAt", "subject"]), - cid: cid + cid: DASL.CID.encode(cid) } } end + + defp time_us, do: DateTime.utc_now() |> DateTime.to_unix(:microsecond) end diff --git a/lib/dataplane_ex/atproto/event.ex b/lib/dataplane_ex/atproto/event.ex index dbb592c..84d56dd 100644 --- a/lib/dataplane_ex/atproto/event.ex +++ b/lib/dataplane_ex/atproto/event.ex @@ -32,9 +32,7 @@ defmodule DataplaneEx.ATProto.Event do end end - defp decode("", decoded_acc) do - {:ok, Enum.reverse(decoded_acc)} - end + defp decode("", decoded_acc), do: {:ok, Enum.reverse(decoded_acc)} defp decode(binary, decoded_acc) do case CBOR.decode(binary) do diff --git a/lib/dataplane_ex/op.ex b/lib/dataplane_ex/op.ex index fda110d..829ffce 100644 --- a/lib/dataplane_ex/op.ex +++ b/lib/dataplane_ex/op.ex @@ -3,14 +3,14 @@ defmodule DataplaneEx.Op do import Ecto.Changeset alias DataplaneEx.CID + @attrs [:action, :cid, :path] + embedded_schema do field :action, :string field :cid, CID field :path, :string end - @attrs [:action, :cid, :path] - def changeset(%__MODULE__{} = op, attrs) do changeset = cast(op, attrs, @attrs) diff --git a/mix.exs b/mix.exs index d26e103..a0eba54 100644 --- a/mix.exs +++ b/mix.exs @@ -16,7 +16,15 @@ defmodule DataplaneEx.MixProject do plt_add_apps: [:ex_unit, :mix] ], compilers: [:phoenix_live_view] ++ Mix.compilers(), - listeners: [Phoenix.CodeReloader] + listeners: [Phoenix.CodeReloader], + test_coverage: [tool: ExCoveralls], + preferred_cli_env: [ + coveralls: :test, + "coveralls.detail": :test, + "coveralls.post": :test, + "coveralls.html": :test, + "coveralls.json": :test + ] ] end @@ -63,7 +71,8 @@ defmodule DataplaneEx.MixProject do {:bandit, "~> 1.5"}, {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, {:ex_slop, "~> 0.1", only: [:dev, :test], runtime: false}, - {:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false} + {:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false}, + {:excoveralls, "~> 0.18", only: :test} ] end diff --git a/mix.lock b/mix.lock index 9e98551..0de6f0d 100644 --- a/mix.lock +++ b/mix.lock @@ -12,6 +12,7 @@ "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"}, "erlex": {:hex, :erlex, "0.2.9", "7debbbaa9f4f368b8cd648983e0f1d7963028508e9c59e9d4ed504e94ef52a55", [:mix], [], "hexpm", "8cfffc0ec7159e6d73de2ab28a588064de80f88b2798d5cbe4482cbbc200178b"}, "ex_slop": {:hex, :ex_slop, "0.4.2", "142aba9a82eddfb258e39c45d59392ab3cdb6b5a3ad401b09b362b7134fc54eb", [:mix], [{:credo, "~> 1.7", [hex: :credo, repo: "hexpm", optional: false]}], "hexpm", "c7f5316f755f83566e7a0a049f6fedfcd5ff916fce83c6ebfdf806be62fd7a69"}, + "excoveralls": {:hex, :excoveralls, "0.18.5", "e229d0a65982613332ec30f07940038fe451a2e5b29bce2a5022165f0c9b157e", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "523fe8a15603f86d64852aab2abe8ddbd78e68579c8525ae765facc5eae01562"}, "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, "fine": {:hex, :fine, "0.1.6", "4bf7151493443c454aac9f2fa2f34f5fefd0346a83fb5586a016c4a135c63247", [:mix], [], "hexpm", "5638eb4495488e885ebec167fa57973e5c35e1a50c344eb7666c90ec1c4e3b12"}, "hpax": {:hex, :hpax, "1.0.4", "777de5d433b0fbdc7c418159c8055910faa8047ffdb3d6b31098d2a46cd7685c", [:mix], [], "hexpm", "afc7cb142ebcc2d01ce7816190b98ce5dd49e799111b24249f3443d730f377ca"}, @@ -19,7 +20,7 @@ "lazy_html": {:hex, :lazy_html, "0.1.11", "136c8e9cd616b4f4e9c1562daa683880891120b759606dc4c3b6b18058ba5d79", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "3b1be592929c31eca1a21673d25696e5c14cddfe922d9d1a3e3b48be4163883b"}, "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, "nimble_csv": {:hex, :nimble_csv, "1.3.0", "b7f998dc62b222bce9596e46f028c7a5af04cb5dde6df2ea197c583227c54971", [:mix], [], "hexpm", "41ccdc18f7c8f8bb06e84164fc51635321e80d5a3b450761c4997d620925d619"}, - "phoenix": {:hex, :phoenix, "1.8.8", "ada3d761359274178180c0e992ef0c2b536bd7c3bd75ebba94acbf39ab4347fe", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "f0c843037bd2e7012fc1d1ec9574dfa6972b7e3d09e9b77fd23aa283af0aa994"}, + "phoenix": {:hex, :phoenix, "1.8.9", "a63ed0962ed5b903b146dab0ae8eb8387fe478f8171a5e26d56a165f35996fe1", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "3477e2dd5a4f61820341169031bdfe21275f659923bea9c5c0ea2aa1c3fcc046"}, "phoenix_ecto": {:hex, :phoenix_ecto, "4.7.0", "75c4b9dfb3efdc42aec2bd5f8bccd978aca0651dbcbc7a3f362ea5d9d43153c6", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "1d75011e4254cb4ddf823e81823a9629559a1be93b4321a6a5f11a5306fbf4cc"}, "phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"}, "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.7", "405880012cb4b706f26dd1c6349125bfc903fb9e44d1ea668adaf4e04d4884b7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "3a8625cab39ec261d48a13b7468dc619c0ede099601b084e343968309bd4d7d7"}, @@ -36,6 +37,6 @@ "typedstruct": {:hex, :typedstruct, "0.5.4", "d1d33d58460a74f413e9c26d55e66fd633abd8ac0fb12639add9a11a60a0462a", [:make, :mix], [], "hexpm", "ffaef36d5dbaebdbf4ed07f7fb2ebd1037b2c1f757db6fb8e7bcbbfabbe608d8"}, "varint": {:hex, :varint, "1.6.0", "7bced828599b2eb84491a9f067f8a5a67fc35a946d3b7582278083c7fd434b00", [:mix], [], "hexpm", "2b4f4a20650aeebe993a70b03bb99571bcbddc27c361322c28b586d8a772ce4e"}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, - "websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"}, + "websock_adapter": {:hex, :websock_adapter, "0.6.0", "73db5ab8aaefd1a876a97ce3e6afc96562625de69ef17a4e04426e034849d0b8", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "50021a85bce8f203b086705d9e0c5415e2c7eb05d319111b0428fe71f9934617"}, "websockex": {:hex, :websockex, "0.5.1", "9de28d37bbe34f371eb46e29b79c94c94fff79f93c960d842fbf447253558eb4", [:mix], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8ef39576ed56bc3804c9cd8626f8b5d6b5721848d2726c0ccd4f05385a3c9f14"}, } diff --git a/test/dataplane_ex/atproto/account_test.exs b/test/dataplane_ex/atproto/account_test.exs new file mode 100644 index 0000000..0c1f6a7 --- /dev/null +++ b/test/dataplane_ex/atproto/account_test.exs @@ -0,0 +1,47 @@ +defmodule DataplaneEx.ATProto.AccountTest do + use DataplaneEx.DataCase, async: true + alias DataplaneEx.ATProto.Account + + describe "changeset/2" do + test "casts required attributes" do + attrs = %{ + "seq" => 1, + "did" => "did:plc:alice", + "time" => "2026-01-01T00:00:00.000Z", + "active" => true + } + + assert Account.changeset(%Account{}, attrs).valid? + end + + test "requires seq, did, time and active" do + changeset = Account.changeset(%Account{}, %{}) + + refute changeset.valid? + + assert %{seq: ["can't be blank"], did: ["can't be blank"], time: ["can't be blank"]} = + errors_on(changeset) + end + end + + describe "to_event/1" do + test "builds an account event" do + {:ok, account} = + %Account{} + |> Account.changeset(%{ + "seq" => 7, + "did" => "did:plc:alice", + "time" => "2026-01-01T00:00:00.000Z", + "active" => true + }) + |> Ecto.Changeset.apply_action(:validate) + + event = Account.to_event(account) + + assert event.did == "did:plc:alice" + assert event.kind == :account + assert event.account.seq == 7 + assert event.account.active == true + end + end +end diff --git a/test/dataplane_ex/atproto/commit_test.exs b/test/dataplane_ex/atproto/commit_test.exs index 1d437f6..8469268 100644 --- a/test/dataplane_ex/atproto/commit_test.exs +++ b/test/dataplane_ex/atproto/commit_test.exs @@ -20,9 +20,9 @@ defmodule DataplaneEx.ATProto.CommitTest do blocks: blocks, ops: [ %Op{ - action: "create", cid: cid, - path: "app.bsky.feed.post/abc123" + path: "app.bsky.feed.post/abc123", + action: "create" } ] } @@ -43,4 +43,22 @@ defmodule DataplaneEx.ATProto.CommitTest do "subject" => %{"uri" => "at://did:plc:alice/app.bsky.feed.post/root"} } end + + test "to_event/1 handles delete ops without reading blocks" do + commit = %Commit{ + repo: "did:plc:alice", + rev: "3lxyz", + ops: [%Op{action: "delete", path: "app.bsky.feed.post/abc123"}] + } + + [event] = Commit.to_event(commit) + + assert event.did == "did:plc:alice" + assert event.kind == :commit + assert event.commit.operation == "delete" + assert event.commit.collection == "app.bsky.feed.post" + assert event.commit.rkey == "abc123" + + refute Map.has_key?(event.commit, :cid) + end end diff --git a/test/dataplane_ex/atproto/event_test.exs b/test/dataplane_ex/atproto/event_test.exs index 5b6c933..4c2bd45 100644 --- a/test/dataplane_ex/atproto/event_test.exs +++ b/test/dataplane_ex/atproto/event_test.exs @@ -1,5 +1,6 @@ defmodule DataplaneEx.ATProto.EventTest do use ExUnit.Case, async: true + import DataplaneEx.CommitFixtures alias DataplaneEx.ATProto.Event @moduletag :capture_log @@ -42,4 +43,53 @@ defmodule DataplaneEx.ATProto.EventTest do assert Event.decode(binary) == [] end + + test "decodes commit events" do + binary = + commit_frame("did:plc:alice", "app.bsky.feed.post", %{"$type" => "app.bsky.feed.post"}) + + assert [ + %{ + kind: :commit, + did: "did:plc:alice", + commit: %{operation: "create", collection: "app.bsky.feed.post", rkey: "abc123"} + } + ] = Event.decode(binary) + end + + test "decodes delete commit events" do + binary = commit_frame("did:plc:alice", "app.bsky.feed.post", %{}, action: "delete") + + assert [ + %{ + kind: :commit, + did: "did:plc:alice", + commit: %{operation: "delete", collection: "app.bsky.feed.post", rkey: "abc123"} + } + ] = Event.decode(binary) + end + + test "decodes account events" do + binary = + frame(%{"op" => 1, "t" => "#account"}, %{ + "seq" => 1, + "did" => "did:plc:alice", + "time" => "2026-01-01T00:00:00.000Z", + "active" => true + }) + + assert %{kind: :account, did: "did:plc:alice"} = Event.decode(binary) + end + + test "ignores invalid commit events" do + binary = frame(%{"op" => 1, "t" => "#commit"}, %{"seq" => 1}) + + assert Event.decode(binary) == [] + end + + test "ignores invalid account events" do + binary = frame(%{"op" => 1, "t" => "#account"}, %{"seq" => "not a number"}) + + assert Event.decode(binary) == [] + end end diff --git a/test/dataplane_ex/car_test.exs b/test/dataplane_ex/car_test.exs index a44c22e..280afc4 100644 --- a/test/dataplane_ex/car_test.exs +++ b/test/dataplane_ex/car_test.exs @@ -1,13 +1,12 @@ defmodule DataplaneEx.CARTest do use ExUnit.Case, async: true + import DataplaneEx.CARFixtures alias DASL.CAR.DRISL alias DataplaneEx.CAR test "casts CBOR byte payloads into decoded DASL DRISL CARs" do record = %{"$type" => "app.bsky.feed.post", "text" => "hello"} - {:ok, {car, cid}} = DRISL.add_block(%DRISL{}, record) - {:ok, car} = DRISL.add_root(car, cid) - {:ok, bytes} = DRISL.encode(car) + {_car, cid, bytes} = build_car(record) assert {:ok, decoded} = CAR.cast(%CBOR.Tag{tag: :bytes, value: bytes}) assert %DRISL{} = decoded @@ -24,4 +23,29 @@ defmodule DataplaneEx.CARTest do test "rejects invalid CAR byte payloads" do assert CAR.cast(%CBOR.Tag{tag: :bytes, value: "not a car"}) == :error end + + test "casts raw CAR bytes without a CBOR tag wrapper" do + record = %{"$type" => "app.bsky.feed.post", "text" => "hello"} + {_car, cid, bytes} = build_car(record) + + assert {:ok, decoded} = CAR.cast(bytes) + assert %DRISL{} = decoded + assert decoded.roots == [cid] + end + + test "load/1 returns the CAR unchanged" do + car = %DRISL{} + + assert CAR.load(car) == {:ok, car} + end + + test "dump/1 returns the CAR unchanged" do + car = %DRISL{} + + assert CAR.dump(car) == {:ok, car} + end + + test "type/0 returns :map" do + assert CAR.type() == :map + end end diff --git a/test/dataplane_ex/cid_test.exs b/test/dataplane_ex/cid_test.exs index e551c8b..108573f 100644 --- a/test/dataplane_ex/cid_test.exs +++ b/test/dataplane_ex/cid_test.exs @@ -15,8 +15,24 @@ defmodule DataplaneEx.CIDTest do assert CID.load(cid.bytes) == {:ok, cid} end + test "loads an already-decoded DASL CID struct unchanged" do + cid = DASL.CID.compute("hello world", :drisl) + + assert CID.load(cid) == {:ok, cid} + end + + test "casts an already-decoded DASL CID struct unchanged" do + cid = DASL.CID.compute("hello world", :drisl) + + assert CID.cast(cid) == {:ok, cid} + end + test "rejects invalid CID payloads" do assert CID.cast(%CBOR.Tag{tag: 1, value: "not a cid"}) == :error assert CID.load("not a cid") == :error end + + test "type/0 returns :binary" do + assert CID.type() == :binary + end end diff --git a/test/dataplane_ex/csv_test.exs b/test/dataplane_ex/csv_test.exs index b16d951..e68d588 100644 --- a/test/dataplane_ex/csv_test.exs +++ b/test/dataplane_ex/csv_test.exs @@ -1,5 +1,6 @@ defmodule DataplaneEx.CSVTest do use ExUnit.Case, async: true + import DataplaneEx.TmpFileFixtures alias DataplaneEx.CSV test "parse_users/1 returns user DIDs and unquotes fields" do @@ -47,4 +48,28 @@ defmodule DataplaneEx.CSVTest do assert Enum.to_list(CSV.parse_posts(csv)) == [{0, "did:plc:alice"}, {1000, "did:plc:bob"}] end + + describe "read_meta/1 and write_meta/2" do + test "round-trips metadata through a companion .meta file" do + path = tmp_path("csv_test") + + cleanup_meta(path) + + assert :ok = CSV.write_meta(path, %{total: 42}) + assert CSV.read_meta(path) == %{total: 42} + end + + test "returns an empty map when no .meta file exists" do + assert CSV.read_meta(tmp_path("csv_test")) == %{} + end + + test "ignores malformed lines in a .meta file" do + path = tmp_path("csv_test") + + cleanup_meta(path) + File.write!(meta_path(path), "total: 42\nmalformed line without colon\n") + + assert CSV.read_meta(path) == %{total: 42} + end + end end diff --git a/test/dataplane_ex/op_test.exs b/test/dataplane_ex/op_test.exs new file mode 100644 index 0000000..4f13660 --- /dev/null +++ b/test/dataplane_ex/op_test.exs @@ -0,0 +1,26 @@ +defmodule DataplaneEx.OpTest do + use DataplaneEx.DataCase, async: true + alias DataplaneEx.Op + + describe "changeset/2" do + test "requires action and path" do + changeset = Op.changeset(%Op{}, %{}) + + refute changeset.valid? + assert %{action: ["can't be blank"], path: ["can't be blank"]} = errors_on(changeset) + end + + test "requires a cid for non-delete actions" do + changeset = Op.changeset(%Op{}, %{"action" => "create", "path" => "app.bsky.feed.post/abc"}) + + refute changeset.valid? + assert %{cid: ["can't be blank"]} = errors_on(changeset) + end + + test "does not require a cid for delete actions" do + changeset = Op.changeset(%Op{}, %{"action" => "delete", "path" => "app.bsky.feed.post/abc"}) + + assert changeset.valid? + end + end +end diff --git a/test/dataplane_ex/sync_client_test.exs b/test/dataplane_ex/sync_client_test.exs new file mode 100644 index 0000000..9c4d392 --- /dev/null +++ b/test/dataplane_ex/sync_client_test.exs @@ -0,0 +1,64 @@ +defmodule DataplaneEx.SyncClientTest do + use ExUnit.Case, async: false + alias DataplaneEx.SyncClient + + @moduletag :capture_log + + setup do + Phoenix.PubSub.subscribe(DataplaneEx.PubSub, "firehose") + :ok + end + + describe "handle_frame/2" do + test "broadcasts binary frames to the firehose topic" do + assert {:ok, %{}} = SyncClient.handle_frame({:binary, "payload"}, %{}) + assert_received {:binary, "payload"} + end + + test "ignores non-binary frames" do + assert {:ok, %{}} = SyncClient.handle_frame({:text, "ignored"}, %{}) + refute_received {:text, "ignored"} + end + end + + describe "handle_connect/2" do + test "returns the unchanged state" do + state = %{uri: "wss://example.test"} + assert {:ok, ^state} = SyncClient.handle_connect(%{}, state) + end + end + + describe "handle_disconnect/2" do + test "reconnects after a disconnect" do + state = %{uri: "wss://example.test"} + + assert {:reconnect, ^state} = + SyncClient.handle_disconnect(%{reason: {:remote, :normal}}, state) + + assert {:reconnect, ^state} = + SyncClient.handle_disconnect(%{reason: :closed, attempt_number: 3}, state) + end + end + + describe "handle_info/2" do + test "returns the unchanged state for unexpected messages" do + assert {:ok, %{}} = SyncClient.handle_info(:unexpected, %{}) + end + end + + describe "terminate/2" do + test "returns :ok" do + assert SyncClient.terminate(:normal, %{}) == :ok + end + end + + describe "start_link/1" do + test "starts the process" do + assert {:ok, pid} = SyncClient.start_link(uri: "ws://127.0.0.1:1", name: :sync_client_test) + assert Process.alive?(pid) + + Process.unlink(pid) + Process.exit(pid, :kill) + end + end +end diff --git a/test/support/car_fixtures.ex b/test/support/car_fixtures.ex new file mode 100644 index 0000000..8f7c1e2 --- /dev/null +++ b/test/support/car_fixtures.ex @@ -0,0 +1,13 @@ +defmodule DataplaneEx.CARFixtures do + @moduledoc false + + alias DASL.CAR.DRISL + + def build_car(record) do + {:ok, {car, cid}} = DRISL.add_block(%DRISL{}, record) + {:ok, car} = DRISL.add_root(car, cid) + {:ok, bytes} = DRISL.encode(car) + + {car, cid, bytes} + end +end diff --git a/test/support/commit_fixtures.ex b/test/support/commit_fixtures.ex new file mode 100644 index 0000000..25e57f2 --- /dev/null +++ b/test/support/commit_fixtures.ex @@ -0,0 +1,43 @@ +defmodule DataplaneEx.CommitFixtures do + @moduledoc false + + import DataplaneEx.CARFixtures + alias DASL.CAR.DRISL + alias DASL.CID + + @doc """ + Encodes a `#commit` firehose frame with a single op whose record lives in the commit's CAR blocks. + """ + def commit_frame(repo, collection, record, opts \\ []) do + rkey = Keyword.get(opts, :rkey, "abc123") + action = Keyword.get(opts, :action, "create") + + commit_cid = CID.compute(repo <> collection <> rkey, :drisl) + + {op, car_bytes} = + case action do + "delete" -> + {:ok, empty_car_bytes} = DRISL.encode(%DRISL{}) + {%{"action" => "delete", "path" => "#{collection}/#{rkey}"}, empty_car_bytes} + + _ -> + {_car, cid, bytes} = build_car(record) + op = %{"action" => action, "cid" => CID.to_cbor(cid), "path" => "#{collection}/#{rkey}"} + {op, bytes} + end + + payload = %{ + "seq" => 1, + "repo" => repo, + "time" => "2026-01-01T00:00:00.000Z", + "rev" => "3lxyz", + "commit" => CID.to_cbor(commit_cid), + "tooBig" => false, + "blocks" => car_bytes, + "ops" => [op], + "blobs" => [] + } + + CBOR.encode(%{"op" => 1, "t" => "#commit"}) <> CBOR.encode(payload) + end +end diff --git a/test/support/data_case.ex b/test/support/data_case.ex new file mode 100644 index 0000000..5494043 --- /dev/null +++ b/test/support/data_case.ex @@ -0,0 +1,16 @@ +defmodule DataplaneEx.DataCase do + @moduledoc false + + use ExUnit.CaseTemplate + + using do + quote do + import Ecto.Changeset + import DataplaneEx.DataCase + end + end + + def errors_on(changeset) do + Ecto.Changeset.traverse_errors(changeset, fn {message, _opts} -> message end) + end +end diff --git a/test/support/tmp_file_fixtures.ex b/test/support/tmp_file_fixtures.ex new file mode 100644 index 0000000..cfa0c0a --- /dev/null +++ b/test/support/tmp_file_fixtures.ex @@ -0,0 +1,22 @@ +defmodule DataplaneEx.TmpFileFixtures do + @moduledoc false + + def tmp_path(prefix, ext \\ ".csv") do + Path.join(System.tmp_dir!(), "#{prefix}_#{System.unique_integer([:positive])}#{ext}") + end + + def tmp_file(prefix, content, ext \\ ".csv") do + path = tmp_path(prefix, ext) + + File.write!(path, content) + ExUnit.Callbacks.on_exit(fn -> File.rm(path) end) + + path + end + + def meta_path(path), do: Path.rootname(path) <> ".meta" + + def cleanup_meta(path) do + ExUnit.Callbacks.on_exit(fn -> meta_path(path) |> File.rm() end) + end +end From 5613dfe5d8f7a9c89bb9180594213d8ef723d031 Mon Sep 17 00:00:00 2001 From: Alfonso Manuel Date: Tue, 14 Jul 2026 10:28:30 +0200 Subject: [PATCH 2/3] Replace TmpFixtures by ExUnit :tmp_dir tag --- test/dataplane_ex/csv_test.exs | 19 +++++++++---------- test/support/csv_fixtures.ex | 6 ++++++ test/support/tmp_file_fixtures.ex | 22 ---------------------- 3 files changed, 15 insertions(+), 32 deletions(-) delete mode 100644 test/support/tmp_file_fixtures.ex diff --git a/test/dataplane_ex/csv_test.exs b/test/dataplane_ex/csv_test.exs index e68d588..2661727 100644 --- a/test/dataplane_ex/csv_test.exs +++ b/test/dataplane_ex/csv_test.exs @@ -1,6 +1,6 @@ defmodule DataplaneEx.CSVTest do use ExUnit.Case, async: true - import DataplaneEx.TmpFileFixtures + import DataplaneEx.CSVFixtures alias DataplaneEx.CSV test "parse_users/1 returns user DIDs and unquotes fields" do @@ -50,24 +50,23 @@ defmodule DataplaneEx.CSVTest do end describe "read_meta/1 and write_meta/2" do - test "round-trips metadata through a companion .meta file" do - path = tmp_path("csv_test") + @describetag :tmp_dir - cleanup_meta(path) + test "round-trips metadata through a companion .meta file", %{tmp_dir: tmp_dir} do + path = csv_path(tmp_dir) assert :ok = CSV.write_meta(path, %{total: 42}) assert CSV.read_meta(path) == %{total: 42} end - test "returns an empty map when no .meta file exists" do - assert CSV.read_meta(tmp_path("csv_test")) == %{} + test "returns an empty map when no .meta file exists", %{tmp_dir: tmp_dir} do + assert CSV.read_meta(csv_path(tmp_dir)) == %{} end - test "ignores malformed lines in a .meta file" do - path = tmp_path("csv_test") + test "ignores malformed lines in a .meta file", %{tmp_dir: tmp_dir} do + path = csv_path(tmp_dir) - cleanup_meta(path) - File.write!(meta_path(path), "total: 42\nmalformed line without colon\n") + File.write!(csv_meta_path(path), "total: 42\nmalformed line without colon\n") assert CSV.read_meta(path) == %{total: 42} end diff --git a/test/support/csv_fixtures.ex b/test/support/csv_fixtures.ex index 6dfa02c..07d9d17 100644 --- a/test/support/csv_fixtures.ex +++ b/test/support/csv_fixtures.ex @@ -33,4 +33,10 @@ defmodule DataplaneEx.CSVFixtures do def empty_posts_csv do "offset_ms,user_id\n" end + + @doc "Returns a CSV path inside `dir`." + def csv_path(temp_dir), do: Path.join(temp_dir, "data.csv") + + @doc "Returns the `.meta` companion path for `csv_path`." + def csv_meta_path(csv_path), do: Path.rootname(csv_path) <> ".meta" end diff --git a/test/support/tmp_file_fixtures.ex b/test/support/tmp_file_fixtures.ex deleted file mode 100644 index cfa0c0a..0000000 --- a/test/support/tmp_file_fixtures.ex +++ /dev/null @@ -1,22 +0,0 @@ -defmodule DataplaneEx.TmpFileFixtures do - @moduledoc false - - def tmp_path(prefix, ext \\ ".csv") do - Path.join(System.tmp_dir!(), "#{prefix}_#{System.unique_integer([:positive])}#{ext}") - end - - def tmp_file(prefix, content, ext \\ ".csv") do - path = tmp_path(prefix, ext) - - File.write!(path, content) - ExUnit.Callbacks.on_exit(fn -> File.rm(path) end) - - path - end - - def meta_path(path), do: Path.rootname(path) <> ".meta" - - def cleanup_meta(path) do - ExUnit.Callbacks.on_exit(fn -> meta_path(path) |> File.rm() end) - end -end From 47d00efb951ced3dfee678842de469410c136525 Mon Sep 17 00:00:00 2001 From: Alfonso Manuel Date: Tue, 14 Jul 2026 10:29:41 +0200 Subject: [PATCH 3/3] Update Plug & Ecto --- mix.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mix.lock b/mix.lock index 0de6f0d..8d93436 100644 --- a/mix.lock +++ b/mix.lock @@ -8,7 +8,7 @@ "decimal": {:hex, :decimal, "3.1.1", "430d87b04011ce6cbd4fd205be758311a81f87d552d40904abd00f015935b1d0", [:mix], [], "hexpm", "c5f25f2ced74a0587d03e6023f595db8e924c9d3922c8c8ffd9edfc4498cf1f6"}, "dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"}, "dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"}, - "ecto": {:hex, :ecto, "3.14.0", "2fa64521eebfcb2670d907a86e4ad947290e9933706bb315e6fb5c21b172cb26", [:mix], [{:decimal, "~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "130d69ffb4285f9ce4792b65dfbb994fd13ea4cbc3cbea2524b199aa3de84af3"}, + "ecto": {:hex, :ecto, "3.14.1", "7b740d87bdf45996aa0c2c2e081640906f10caa7ce5ba328fd294c7d49d0cc6f", [:mix], [{:decimal, "~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "24b991956796700f467d0a3ef3d303138a3ef9ddddf8b98f43758ee067b20a30"}, "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"}, "erlex": {:hex, :erlex, "0.2.9", "7debbbaa9f4f368b8cd648983e0f1d7963028508e9c59e9d4ed504e94ef52a55", [:mix], [], "hexpm", "8cfffc0ec7159e6d73de2ab28a588064de80f88b2798d5cbe4482cbbc200178b"}, "ex_slop": {:hex, :ex_slop, "0.4.2", "142aba9a82eddfb258e39c45d59392ab3cdb6b5a3ad401b09b362b7134fc54eb", [:mix], [{:credo, "~> 1.7", [hex: :credo, repo: "hexpm", optional: false]}], "hexpm", "c7f5316f755f83566e7a0a049f6fedfcd5ff916fce83c6ebfdf806be62fd7a69"}, @@ -28,7 +28,7 @@ "phoenix_live_view": {:hex, :phoenix_live_view, "1.1.32", "4977ad4cfab7868f3d2ba390a2cd6c62412614d48f798e85785602281a0a3827", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3c9d8e76373414259ec5699a809991d1324808bee1d297730599e273282cee34"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"}, "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, - "plug": {:hex, :plug, "1.20.2", "adbee2441232412e37fbb357fd5e4cd533fdd253b29f2e1992262b0f1fb01462", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b16baf55877d60891002ffc1ce0b3ff7d6f30a38a23e02e4d4293c4ac266f136"}, + "plug": {:hex, :plug, "1.20.3", "56c480c633ec2ce10140e236e15233bf576e1d323887d7c96711bd02ab5160db", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "be266aee1b8536ef6409d58cf39a3121319f0ec47cfa1b24024485aa0e76ad76"}, "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, "telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"}, "telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"},