defmodule Microwaveprop.Weather.Era5ClientTest do # async: false — tests mutate the global ERA5_CDS_API_KEY env var to match # production's lookup path. Running concurrently with Era5BatchClientTest # (which *deletes* the env var to exercise the missing-key error path) # leaks state in both directions. use ExUnit.Case, async: false alias Microwaveprop.Weather.Era5Client setup do # A real key — stubs don't care about value, only presence. System.put_env("ERA5_CDS_API_KEY", "test-key") on_exit(fn -> System.delete_env("ERA5_CDS_API_KEY") end) :ok end describe "submit_job/2" do test "returns {:ok, job_id} when CDS accepts the request" do Req.Test.stub(Era5Client, fn conn -> assert conn.method == "POST" assert String.ends_with?(conn.request_path, "/reanalysis-era5-single-levels/execution") Req.Test.json(conn, %{"jobID" => "cds-job-123", "status" => "accepted"}) end) assert {:ok, "cds-job-123"} = Era5Client.submit_job("reanalysis-era5-single-levels", %{"foo" => "bar"}) end test "returns {:error, _} when CDS returns 400" do Req.Test.stub(Era5Client, fn conn -> conn |> Plug.Conn.put_resp_content_type("application/json") |> Plug.Conn.resp(400, ~s({"detail":"bad request"})) end) assert {:error, reason} = Era5Client.submit_job("reanalysis-era5-single-levels", %{}) assert reason =~ "HTTP 400" end test "returns {:error, _} when API key is missing" do System.delete_env("ERA5_CDS_API_KEY") assert {:error, reason} = Era5Client.submit_job("reanalysis-era5-single-levels", %{}) assert reason =~ "ERA5_CDS_API_KEY" end end describe "check_status/1" do test "returns :running while CDS job is accepted" do Req.Test.stub(Era5Client, fn conn -> assert conn.method == "GET" assert String.ends_with?(conn.request_path, "/jobs/cds-job-123") Req.Test.json(conn, %{"status" => "accepted"}) end) assert :running = Era5Client.check_status("cds-job-123") end test "returns :running while CDS job is running" do Req.Test.stub(Era5Client, fn conn -> Req.Test.json(conn, %{"status" => "running"}) end) assert :running = Era5Client.check_status("cds-job-123") end test "returns {:done, {:url, href}} when CDS job completes with an asset href" do Req.Test.stub(Era5Client, fn conn -> case conn.request_path do "/api/retrieve/v1/jobs/cds-job-123" -> Req.Test.json(conn, %{"status" => "successful"}) "/api/retrieve/v1/jobs/cds-job-123/results" -> Req.Test.json(conn, %{ "asset" => %{"value" => %{"href" => "https://example.com/file.grib"}} }) end end) assert {:done, {:url, "https://example.com/file.grib"}} = Era5Client.check_status("cds-job-123") end test "returns {:failed, _} when CDS reports the job failed" do Req.Test.stub(Era5Client, fn conn -> assert conn.request_path == "/api/retrieve/v1/jobs/cds-job-123" Req.Test.json(conn, %{"status" => "failed", "message" => "input out of range"}) end) assert {:failed, reason} = Era5Client.check_status("cds-job-123") assert reason =~ "input out of range" end test "returns :not_found when CDS returns 404 for the job" do # 404 means the CDS job was deleted or never existed. This is terminal — # retrying will always 404 — so callers should treat it as a distinct # state from the generic {:error, _} retryable bucket. Req.Test.stub(Era5Client, fn conn -> conn |> Plug.Conn.put_resp_content_type("application/json") |> Plug.Conn.resp( 404, ~s({"detail":"job cds-job-123 deleted","status":404,"title":"job not found"}) ) end) assert :not_found = Era5Client.check_status("cds-job-123") end test "returns {:rejected, reason} when CDS marks the job as rejected" do # CDS rejects submits when the user's in-flight queue is over the cap # (150 jobs). The rejected job still gets a jobID and a GET /jobs/:id # returns a body with status=rejected. This is terminal — re-polling # will never transition to successful — so callers should treat it # like {:failed, _} and re-submit the tile-month. Req.Test.stub(Era5Client, fn conn -> Req.Test.json(conn, %{ "jobID" => "cds-job-123", "status" => "rejected", "metadata" => %{"origin" => "api"} }) end) assert {:rejected, reason} = Era5Client.check_status("cds-job-123") assert is_binary(reason) end end describe "delete_job/1" do test "sends DELETE to the CDS jobs endpoint and returns :ok on 204" do test_pid = self() Req.Test.stub(Era5Client, fn conn -> send(test_pid, {:delete_request, conn.method, conn.request_path}) Plug.Conn.resp(conn, 204, "") end) assert :ok = Era5Client.delete_job("cds-job-123") assert_received {:delete_request, "DELETE", "/api/retrieve/v1/jobs/cds-job-123"} end test "also treats 200/202/404 as success (CDS sometimes returns 200 or the job may already be gone)" do for status <- [200, 202, 404] do Req.Test.stub(Era5Client, fn conn -> Plug.Conn.resp(conn, status, "") end) assert :ok = Era5Client.delete_job("cds-job-#{status}") end end test "returns {:error, _} for unexpected statuses" do Req.Test.stub(Era5Client, fn conn -> Plug.Conn.resp(conn, 500, "server error") end) assert {:error, reason} = Era5Client.delete_job("cds-job-500") assert reason =~ "HTTP 500" end end describe "download_source_to_file/3" do test "writes a {:body, binary} source directly to the target path" do path = Path.join(System.tmp_dir!(), "era5_test_#{System.unique_integer([:positive])}.grib") try do assert :ok = Era5Client.download_source_to_file({:body, "grib-payload"}, nil, path) assert File.read!(path) == "grib-payload" after File.rm(path) end end test "streams a {:url, href} source through Req into the target path" do Req.Test.stub(Era5Client, fn conn -> assert conn.method == "GET" conn |> Plug.Conn.put_resp_content_type("application/octet-stream") |> Plug.Conn.resp(200, "streamed-grib-data") end) path = Path.join(System.tmp_dir!(), "era5_test_#{System.unique_integer([:positive])}.grib") try do assert :ok = Era5Client.download_source_to_file( {:url, "https://example.com/file.grib"}, "test-key", path ) assert File.read!(path) == "streamed-grib-data" after File.rm(path) end end test "removes partial file and returns {:error, _} on HTTP error" do Req.Test.stub(Era5Client, fn conn -> Plug.Conn.resp(conn, 503, "") end) path = Path.join(System.tmp_dir!(), "era5_test_#{System.unique_integer([:positive])}.grib") assert {:error, reason} = Era5Client.download_source_to_file( {:url, "https://example.com/file.grib"}, "test-key", path ) assert reason =~ "HTTP 503" refute File.exists?(path) end end describe "pressure_levels/0" do test "covers upper troposphere and lower stratosphere for the skew-T plot" do levels = Enum.map(Era5Client.pressure_levels(), &String.to_integer/1) # Boundary-layer resolution near the surface stays intact. assert 1000 in levels assert 700 in levels # Upper-air levels needed to fill out the skew-T above 700 mb. for level <- [500, 300, 200, 100] do assert level in levels, "expected #{level} mb in Era5Client.pressure_levels/0" end end end end