defmodule HiiveRisk.ContractTest do use ExUnit.Case, async: true alias HiiveRisk.Contract @fixture_path Path.expand("fixtures/case.json", __DIR__) @external_resource @fixture_path # Generated with .venv/bin/python from next(dataset.pilot_cases())["input"]. @snapshot @fixture_path |> File.read!() |> Jason.decode!() test "accepts the Python pilot snapshot and does not recompute summaries" do assert :ok = Contract.snapshot(@snapshot) assert :ok = @snapshot |> put_in(["summaries", "orders_last_24h"], 999) |> Contract.snapshot() end test "rejects missing, malformed, unknown and nested required fields" do invalid = [ nil, [], "{}", Map.delete(@snapshot, "case_id"), Map.put(@snapshot, "extra", true), put_in(@snapshot, ["account", "age_days"], "288"), put_in(@snapshot, ["account", "id_verified"], 1), put_in(@snapshot, ["account", "accreditation"], "unknown"), update_in(@snapshot, ["company"], &Map.delete(&1, "last_trade_price")), put_in(@snapshot, ["company", "last_trade_price"], 0), put_in(@snapshot, ["texts", "messages"], [%{"minutes_ago" => 1}]) ] for item <- invalid, do: assert({:error, :invalid_snapshot} = Contract.snapshot(item)) end test "requires review time zero and positive, oldest-first history within account lifetime" do event = hd(@snapshot["recent_events"]) lifetime = @snapshot["account"]["age_days"] * 1440 for minutes <- [-1, 1] do assert {:error, :invalid_snapshot} = Contract.snapshot(put_in(@snapshot, ["review_target", "minutes_ago"], minutes)) end for times <- [[0], [-1], [1, 2], [lifetime + 1]] do events = Enum.map(times, &Map.put(event, "minutes_ago", &1)) assert {:error, :invalid_snapshot} = Contract.snapshot(%{@snapshot | "recent_events" => events}) end for times <- [[], [lifetime, 1], [2, 2, 1]] do events = Enum.map(times, &Map.put(event, "minutes_ago", &1)) assert :ok = Contract.snapshot(%{@snapshot | "recent_events" => events}) end end test "checks company, holdings and positive target shares and price" do invalid = [ put_in(@snapshot, ["review_target", "details", "company_id"], "other"), update_in(@snapshot, ["review_target"], &Map.delete(&1, "details")) ] invalid = invalid ++ for( range <- [[0, 1], [-1, 2], [3, 2], [1], [1, 2, 3]], do: put_in(@snapshot, ["company", "typical_employee_holding"], range) ) ++ for( shares <- [nil, true, 0, -1, 1.5, "1"], do: put_in(@snapshot, ["review_target", "details", "shares"], shares) ) ++ for( price <- [nil, true, 0, -1, "1"], do: put_in(@snapshot, ["review_target", "details", "price_per_share"], price) ) for item <- invalid, do: assert({:error, :invalid_snapshot} = Contract.snapshot(item)) assert :ok = @snapshot |> put_in(["company", "typical_employee_holding"], [1, 1]) |> put_in(["review_target", "details", "shares"], 1) |> put_in(["review_target", "details", "price_per_share"], 1) |> Contract.snapshot() end test "entity info is present exactly for entity accounts and validates nested definitions" do info = %{"beneficial_owners" => 1, "owners_changed_after_id_check" => false} entity = put_in(@snapshot, ["account", "account_type"], "entity") assert {:error, :invalid_snapshot} = Contract.snapshot(entity) assert :ok = Contract.snapshot(put_in(entity, ["account", "entity_info"], info)) for type <- ["individual", "broker_dealer"] do item = put_in(@snapshot, ["account", "account_type"], type) assert :ok = Contract.snapshot(item) assert {:error, :invalid_snapshot} = Contract.snapshot(put_in(item, ["account", "entity_info"], info)) end assert {:error, :invalid_snapshot} = Contract.snapshot(put_in(entity, ["account", "entity_info"], %{})) end test "normalizes verdict keys and permits distinct fixed codes" do assert {:ok, %{"risk_level" => 1, "reasons" => ["NO_CONCERN"]}} = Contract.verdict(%{risk_level: 1, reasons: ["NO_CONCERN"]}) verdict = %{"risk_level" => 5, "reasons" => ["SHARED_BANK", "KNOWN_ATTACK_IP"]} assert {:ok, ^verdict} = Contract.verdict(verdict) end test "rejects malformed verdicts, duplicate or unknown codes and mixed NO_CONCERN" do for value <- [ nil, [], "{}", %{}, %{risk_level: 1}, %{risk_level: 0, reasons: ["NO_CONCERN"]}, %{risk_level: 6, reasons: ["NO_CONCERN"]}, %{risk_level: "1", reasons: ["NO_CONCERN"]}, %{risk_level: true, reasons: ["NO_CONCERN"]}, %{risk_level: 1, reasons: []}, %{risk_level: 1, reasons: ["UNKNOWN"]}, %{risk_level: 1, reasons: ["SHARED_BANK", "SHARED_BANK"]}, %{risk_level: 1, reasons: ["NO_CONCERN", "SHARED_BANK"]}, %{risk_level: 1, reasons: ["NO_CONCERN"], extra: true} ] do assert {:error, :invalid_sft_response} = Contract.verdict(value) end end test "accepts inclusive score boundaries and ignores unrelated fields" do for p_review <- [0, 0.0, 0.5, 1, 1.0], answer_mass <- [0, 0.0, 1, 1.0] do expected = %{"p_review" => p_review, "answer_mass" => answer_mass} assert {:ok, ^expected} = Contract.decision(Map.put(expected, "extra", self())) assert {:ok, ^expected} = Contract.decision(%{p_review: p_review, answer_mass: answer_mass}) end end test "rejects missing, nonnumeric and out-of-range scores" do for invalid <- [nil, true, false, "0.5", "NaN", "Infinity", -0.01, 1.01, [], %{}], key <- ["p_review", "answer_mass"] do value = Map.put(%{"p_review" => 0.5, "answer_mass" => 0.5}, key, invalid) assert {:error, :invalid_decision_response} = Contract.decision(value) end for value <- [nil, [], "{}", %{}, %{p_review: 0}, %{answer_mass: 1}] do assert {:error, :invalid_decision_response} = Contract.decision(value) end end end