Files
bikoret/vav_pipeline/test/azazel_ingest_test.exs
T
Scott KarpandClaude Opus 4.7 2932fb7447 vav_pipeline: commit 7 — L3 azazel_records + cross-language ingest GenServer
Pass 4 / Finding F, commit 7 of 10 from BUILD_AZAZEL.md. The
operator-internal half of property 9 (observable disposal). Crosses
the Python/Elixir boundary: the Python orchestrator drops completed
disposal records as JSON files; this commit's GenServer reads them
and persists to L3.

The public half (a /peah/azazel_log read endpoint) lands in commit 8;
the routing-from-Korban + Chatzotzrot wiring lands in commit 9. After
those, the Azazel Processor is end-to-end functional.

CREATED:

  vav_pipeline/priv/repo/migrations/20260428000001_create_azazel_records.exs
    azazel_records table — append-only, parallel to interpretations.
    Columns: case_ref, vidui (jsonb), routing, authorized_by,
    shaliach, triggering_call, goral_outcome_id, disposed_at.
    Indexes on case_ref, disposed_at, shaliach.

    Property-12-defending CHECK constraint:
      routing_must_be_eretz_gezerah CHECK (routing = 'eretz_gezerah').
    A future migration that wants to add a destination handle will
    fail this check; the operator must explicitly justify removing
    the constraint, which is the right friction.

    Append-only enforcement matches interpretations: REVOKE
    UPDATE/DELETE on the shorashim_app role.

  vav_pipeline/lib/vav_pipeline/l3/azazel_record.ex
    Ecto schema mirroring the Python AzazelRecord.to_storage_dict()
    shape. Changeset validates:
      - all required fields present
      - routing == "eretz_gezerah" (mirrors the DB CHECK at app layer)
      - vidui is a map with avonot/peshaim/chatot/confessor/
        confessed_at slots present
      - vidui's three categories not all empty (mirrors the Python
        Vidui.__post_init__ EmptyConfessionError)

  vav_pipeline/lib/vav_pipeline/azazel_ingest.ex      ~190 lines
    GenServer that polls a configurable pending-l3 directory
    (default /tmp/shorashim/azazel/pending_l3). For each *.json file:
      1. Read + JSON-parse
      2. Translate Python serialization → Ecto attrs
      3. Call recorder (default L3.Store.record_disposal/1)
      4. On success, shred the source file (property-10 follow-through)
      5. On failure, leave file in place for retry; log warning

    Recorder is injectable for tests — mock recorder lets us verify
    the pipeline without PostgreSQL.

    Poll interval default 5s, configurable. Polling rather than
    file_system events to keep the dependency surface minimal; the
    Azazel cadence is exceptional content, not high-throughput.

    Not added to the supervision tree in this commit — the producer
    side (Python orchestrator dropping files) is wired in commit 9.
    Adding to the supervisor before there's a producer means a
    poller doing nothing useful. Operator can start manually for
    early testing: VavPipeline.AzazelIngest.start_link(...).

CHANGED:

  vav_pipeline/lib/vav_pipeline/l3/store.ex
    + record_disposal/1
        Inserts an AzazelRecord. Returns {:ok, record} or
        {:error, changeset}.

    + count_disposals/1
        Total or since-a-timestamp count. Used by the public
        observability endpoint (commit 8).

    + disposal_summary/1
        Returns categorical info per disposal — case_ref, shaliach,
        triggering_call, disposed_at, categories_filled (which of
        avonot/peshaim/chatot had any items, NOT the items
        themselves). PROPERTY-9 PRIVACY BOUNDARY at the public
        side: items are operator-internal only, accessed via
        future /kodesh/* endpoints.

TESTS:

  vav_pipeline/test/azazel_ingest_test.exs           15 tests, no DB
    Happy path:
      - poll_now ingests valid file and shreds it
      - multiple files processed in sorted order
      - vidui preserved as map (jsonb shape)
    Retry semantics:
      - recorder error → file left in place + state.errors++
      - malformed JSON → file left in place
      - missing disposed_at → file left in place
    State accounting:
      - polls_completed, records_ingested, errors all tracked
      - state survives multiple polls
      - non-json files ignored
    Changeset (no DB):
      - valid attrs
      - rejects routing != eretz_gezerah
      - rejects vidui missing slots
      - rejects vidui all-empty (untagged disposal)
      - rejects missing required fields
      - accepts optional triggering_call + goral_outcome_id

  vav_pipeline/test/l3_store_test.exs                +9 :l3-tagged
    record_disposal:
      - inserts valid disposal
      - rejects routing != eretz_gezerah
      - rejects all-empty vidui
      - rejects missing required fields
    Counts:
      - count_disposals == 0 initially
      - count_disposals after several disposals
      - count_disposals(since) filters by timestamp
    Summary:
      - disposal_summary returns categorical info but NOT items
        (privacy boundary verification)
      - categories_filled accurately reflects vidui shape

Tests: vav_pipeline 353 passed, 0 failures, 33 excluded (was
329/24; +15 visible azazel_ingest + 9 :l3-tagged disposal store).
korban_processor + azazel_processor unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 09:16:21 -04:00

337 lines
10 KiB
Elixir

defmodule VavPipeline.AzazelIngestTest do
@moduledoc """
Tests for the cross-language Azazel ingest GenServer (Pass 4 /
Finding F, commit 7). These tests verify the file-watcher logic
and the AzazelRecord changeset validation; they do NOT require
PostgreSQL because the recorder is injected as a mock.
L3-side tests (the actual record_disposal write) live in
l3_store_test.exs tagged :l3.
"""
use ExUnit.Case, async: false
alias VavPipeline.AzazelIngest
alias VavPipeline.L3.AzazelRecord
# ============================================================
# Test helpers
# ============================================================
setup do
# Per-test pending directory so tests don't share state.
dir =
System.tmp_dir!()
|> Path.join("azazel-ingest-test-#{:erlang.unique_integer([:positive])}")
File.mkdir_p!(dir)
on_exit(fn -> File.rm_rf!(dir) end)
%{pending_dir: dir}
end
defp valid_record_json(case_ref \\ "case-001") do
%{
"case_ref" => case_ref,
"vidui" => %{
"avonot" => [],
"peshaim" => [],
"chatot" => ["empty-vital offering: test"],
"confessor" => "kohen-gadol",
"confessed_at" => "2026-04-28T12:00:00Z"
},
"routing" => "eretz_gezerah",
"authorized_by" => "kohen-gadol",
"shaliach" => "aharon",
"triggering_call" => "external_offering",
"goral_outcome_id" => nil,
"disposed_at" => "2026-04-28T12:00:01Z"
}
|> Jason.encode!()
end
defp drop_file(dir, name, body) do
File.write!(Path.join(dir, name), body)
end
defp start_ingest(dir, opts \\ []) do
name = :"ingest_#{:erlang.unique_integer([:positive])}"
captured = :ets.new(:captured, [:public, :bag])
recorder = fn attrs ->
:ets.insert(captured, {:record, attrs})
{:ok, attrs}
end
{:ok, pid} =
AzazelIngest.start_link(
Keyword.merge(
[
name: name,
pending_dir: dir,
poll_interval_ms: 60_000, # don't auto-poll during tests
recorder: recorder
],
opts
)
)
on_exit(fn -> if Process.alive?(pid), do: GenServer.stop(pid) end)
%{server: name, captured: captured, recorder: recorder}
end
# ============================================================
# Happy path
# ============================================================
test "poll_now ingests a valid file and shreds it", %{pending_dir: dir} do
drop_file(dir, "case-001.json", valid_record_json())
%{server: server, captured: captured} = start_ingest(dir)
AzazelIngest.poll_now(server)
# File was shredded
refute File.exists?(Path.join(dir, "case-001.json"))
# Record was passed to the recorder
assert [{:record, attrs}] = :ets.tab2list(captured)
assert attrs.case_ref == "case-001"
assert attrs.routing == "eretz_gezerah"
assert attrs.authorized_by == "kohen-gadol"
assert attrs.shaliach == "aharon"
assert %DateTime{} = attrs.disposed_at
end
test "poll_now ingests multiple files in sorted order", %{pending_dir: dir} do
drop_file(dir, "case-002.json", valid_record_json("case-002"))
drop_file(dir, "case-001.json", valid_record_json("case-001"))
drop_file(dir, "case-003.json", valid_record_json("case-003"))
%{server: server, captured: captured} = start_ingest(dir)
AzazelIngest.poll_now(server)
refs =
:ets.tab2list(captured)
|> Enum.map(fn {:record, attrs} -> attrs.case_ref end)
assert refs == ["case-001", "case-002", "case-003"]
end
test "ingest preserves the vidui as a map (jsonb column)", %{pending_dir: dir} do
drop_file(dir, "case-vidui.json", valid_record_json())
%{server: server, captured: captured} = start_ingest(dir)
AzazelIngest.poll_now(server)
[{:record, attrs}] = :ets.tab2list(captured)
assert is_map(attrs.vidui)
assert attrs.vidui["chatot"] == ["empty-vital offering: test"]
assert attrs.vidui["confessor"] == "kohen-gadol"
end
# ============================================================
# Retry semantics — failed records left in place
# ============================================================
test "file is left in place when recorder returns error", %{pending_dir: dir} do
drop_file(dir, "case-fail.json", valid_record_json())
name = :"ingest_fail_#{:erlang.unique_integer([:positive])}"
failing_recorder = fn _ -> {:error, :db_unavailable} end
{:ok, pid} =
AzazelIngest.start_link(
name: name,
pending_dir: dir,
poll_interval_ms: 60_000,
recorder: failing_recorder
)
on_exit(fn -> if Process.alive?(pid), do: GenServer.stop(pid) end)
AzazelIngest.poll_now(name)
# File still present for retry
assert File.exists?(Path.join(dir, "case-fail.json"))
state = AzazelIngest.state(name)
assert state.errors == 1
assert state.records_ingested == 0
end
test "file is left in place when JSON is malformed", %{pending_dir: dir} do
drop_file(dir, "case-malformed.json", "not-json{")
%{server: server} = start_ingest(dir)
AzazelIngest.poll_now(server)
# File still present (operator can investigate)
assert File.exists?(Path.join(dir, "case-malformed.json"))
state = AzazelIngest.state(server)
assert state.errors == 1
end
test "file is left in place when disposed_at is missing", %{pending_dir: dir} do
raw =
%{"case_ref" => "case-no-ts", "vidui" => %{}, "routing" => "eretz_gezerah"}
|> Jason.encode!()
drop_file(dir, "case-no-ts.json", raw)
%{server: server} = start_ingest(dir)
AzazelIngest.poll_now(server)
assert File.exists?(Path.join(dir, "case-no-ts.json"))
end
# ============================================================
# State accounting
# ============================================================
test "state tracks polls_completed, records_ingested, errors", %{pending_dir: dir} do
drop_file(dir, "case-001.json", valid_record_json("case-001"))
drop_file(dir, "case-002.json", valid_record_json("case-002"))
drop_file(dir, "case-bad.json", "garbage")
%{server: server} = start_ingest(dir)
AzazelIngest.poll_now(server)
state = AzazelIngest.state(server)
assert state.polls_completed == 1
assert state.records_ingested == 2
assert state.errors == 1
end
test "state survives multiple polls", %{pending_dir: dir} do
%{server: server} = start_ingest(dir)
AzazelIngest.poll_now(server)
AzazelIngest.poll_now(server)
AzazelIngest.poll_now(server)
state = AzazelIngest.state(server)
assert state.polls_completed == 3
end
test "non-json files are ignored", %{pending_dir: dir} do
File.write!(Path.join(dir, "README.txt"), "not a record")
drop_file(dir, "case-001.json", valid_record_json())
%{server: server, captured: captured} = start_ingest(dir)
AzazelIngest.poll_now(server)
# JSON file processed; README left alone
refute File.exists?(Path.join(dir, "case-001.json"))
assert File.exists?(Path.join(dir, "README.txt"))
assert length(:ets.tab2list(captured)) == 1
end
# ============================================================
# Changeset validation (no DB)
# ============================================================
describe "AzazelRecord.changeset/2" do
test "valid attrs produce a valid changeset" do
attrs = %{
case_ref: "case-cs-001",
vidui: %{
"avonot" => [],
"peshaim" => [],
"chatot" => ["something"],
"confessor" => "kohen",
"confessed_at" => "2026-04-28T12:00:00Z"
},
routing: "eretz_gezerah",
authorized_by: "kohen-gadol",
shaliach: "aharon",
disposed_at: ~U[2026-04-28 12:00:01Z]
}
cs = AzazelRecord.changeset(%AzazelRecord{}, attrs)
assert cs.valid?
end
test "rejects routing other than eretz_gezerah" do
attrs = base_attrs(%{routing: "void"})
cs = AzazelRecord.changeset(%AzazelRecord{}, attrs)
refute cs.valid?
assert {:routing, _} = List.keyfind(cs.errors, :routing, 0)
end
test "rejects vidui missing avonot slot" do
attrs =
base_attrs(%{
vidui: %{
"peshaim" => [],
"chatot" => ["x"],
"confessor" => "kohen",
"confessed_at" => "2026-04-28T12:00:00Z"
}
})
cs = AzazelRecord.changeset(%AzazelRecord{}, attrs)
refute cs.valid?
assert {:vidui, {msg, _}} = List.keyfind(cs.errors, :vidui, 0)
assert msg =~ "avonot"
end
test "rejects vidui with all three categories empty" do
attrs =
base_attrs(%{
vidui: %{
"avonot" => [],
"peshaim" => [],
"chatot" => [],
"confessor" => "kohen",
"confessed_at" => "2026-04-28T12:00:00Z"
}
})
cs = AzazelRecord.changeset(%AzazelRecord{}, attrs)
refute cs.valid?
assert {:vidui, {msg, _}} = List.keyfind(cs.errors, :vidui, 0)
assert msg =~ "untagged"
end
test "rejects missing required fields" do
cs = AzazelRecord.changeset(%AzazelRecord{}, %{})
refute cs.valid?
missing = Enum.map(cs.errors, fn {k, _} -> k end)
for required <- [:case_ref, :vidui, :routing, :authorized_by, :shaliach, :disposed_at] do
assert required in missing, "expected #{required} to be missing"
end
end
test "accepts optional triggering_call and goral_outcome_id" do
attrs = base_attrs(%{
triggering_call: "external_offering",
goral_outcome_id: String.duplicate("a", 64)
})
cs = AzazelRecord.changeset(%AzazelRecord{}, attrs)
assert cs.valid?
end
defp base_attrs(overrides \\ %{}) do
Map.merge(
%{
case_ref: "case-base-001",
vidui: %{
"avonot" => [],
"peshaim" => [],
"chatot" => ["something"],
"confessor" => "kohen",
"confessed_at" => "2026-04-28T12:00:00Z"
},
routing: "eretz_gezerah",
authorized_by: "kohen-gadol",
shaliach: "aharon",
disposed_at: ~U[2026-04-28 12:00:01Z]
},
overrides
)
end
end
end