From 50257dbe68d56452dd30d512ff29ea5e4906a8c2 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Tue, 3 Feb 2026 13:20:33 -0600 Subject: [PATCH] more tests --- lib/towerops/result.ex | 6 +- test/towerops/result_test.exs | 172 ++++++++++++++++++ .../plugs/remote_ip_logger_test.exs | 83 +++++++++ .../plugs/update_session_activity_test.exs | 111 +++++++++++ 4 files changed, 369 insertions(+), 3 deletions(-) create mode 100644 test/towerops/result_test.exs create mode 100644 test/towerops_web/plugs/remote_ip_logger_test.exs create mode 100644 test/towerops_web/plugs/update_session_activity_test.exs diff --git a/lib/towerops/result.ex b/lib/towerops/result.ex index d53fa588..7cd07abf 100644 --- a/lib/towerops/result.ex +++ b/lib/towerops/result.ex @@ -72,10 +72,10 @@ defmodule Towerops.Result do ## Examples - iex> Result.and_then({:ok, user}, fn u -> {:ok, u.email} end) - {:ok, "user@example.com"} + iex> Result.and_then({:ok, 10}, fn x -> {:ok, x * 2} end) + {:ok, 20} - iex> Result.and_then({:error, :not_found}, fn u -> {:ok, u.email} end) + iex> Result.and_then({:error, :not_found}, fn x -> {:ok, x * 2} end) {:error, :not_found} """ @spec and_then(t(a, e), (a -> t(b, e))) :: t(b, e) when a: var, b: var, e: var diff --git a/test/towerops/result_test.exs b/test/towerops/result_test.exs new file mode 100644 index 00000000..a6674f9f --- /dev/null +++ b/test/towerops/result_test.exs @@ -0,0 +1,172 @@ +defmodule Towerops.ResultTest do + use ExUnit.Case, async: true + + alias Towerops.Result + + doctest Result + + describe "map/2" do + test "applies function to success value" do + assert Result.map({:ok, 5}, fn x -> x * 2 end) == {:ok, 10} + assert Result.map({:ok, "hello"}, &String.upcase/1) == {:ok, "HELLO"} + end + + test "passes through error unchanged" do + assert Result.map({:error, :not_found}, fn x -> x * 2 end) == {:error, :not_found} + assert Result.map({:error, "failure"}, &String.upcase/1) == {:error, "failure"} + end + + test "works with complex transformations" do + result = {:ok, %{name: "John", age: 30}} + assert Result.map(result, fn user -> user.name end) == {:ok, "John"} + end + end + + describe "map_error/2" do + test "applies function to error value" do + assert Result.map_error({:error, :timeout}, fn _ -> :network_error end) == + {:error, :network_error} + + assert Result.map_error({:error, "db_error"}, &String.upcase/1) == {:error, "DB_ERROR"} + end + + test "passes through success unchanged" do + assert Result.map_error({:ok, 42}, fn _ -> :network_error end) == {:ok, 42} + assert Result.map_error({:ok, "success"}, &String.upcase/1) == {:ok, "success"} + end + + test "works with complex error transformations" do + result = {:error, %{code: 500, message: "Internal Error"}} + + assert Result.map_error(result, fn err -> err.code end) == {:error, 500} + end + end + + describe "and_then/2" do + test "chains successful operations" do + result = {:ok, 5} + + assert Result.and_then(result, fn x -> {:ok, x * 2} end) == {:ok, 10} + end + + test "stops on first error" do + result = {:error, :not_found} + + assert Result.and_then(result, fn x -> {:ok, x * 2} end) == {:error, :not_found} + end + + test "propagates error from chained function" do + result = {:ok, 5} + + assert Result.and_then(result, fn _ -> {:error, :invalid} end) == {:error, :invalid} + end + + test "works with nested operations" do + result = {:ok, 10} + + final_result = + result + |> Result.and_then(fn x -> {:ok, x + 5} end) + |> Result.and_then(fn x -> {:ok, x * 2} end) + |> Result.and_then(fn x -> {:ok, "Result: #{x}"} end) + + assert final_result == {:ok, "Result: 30"} + end + + test "short-circuits on error in chain" do + result = {:ok, 10} + + final_result = + result + |> Result.and_then(fn x -> {:ok, x + 5} end) + |> Result.and_then(fn _ -> {:error, :failed} end) + |> Result.and_then(fn x -> {:ok, x * 2} end) + + assert final_result == {:error, :failed} + end + end + + describe "unwrap_or/2" do + test "returns success value when ok" do + assert Result.unwrap_or({:ok, 42}, 0) == 42 + assert Result.unwrap_or({:ok, "hello"}, "default") == "hello" + end + + test "returns default value on error" do + assert Result.unwrap_or({:error, :not_found}, 0) == 0 + assert Result.unwrap_or({:error, "failure"}, "default") == "default" + end + + test "works with nil values" do + assert Result.unwrap_or({:ok, nil}, "default") == nil + assert Result.unwrap_or({:error, :not_found}, nil) == nil + end + + test "works with complex default values" do + default = %{name: "Unknown", age: 0} + assert Result.unwrap_or({:error, :not_found}, default) == default + end + end + + describe "ok?/1" do + test "returns true for success" do + assert Result.ok?({:ok, 42}) == true + assert Result.ok?({:ok, nil}) == true + assert Result.ok?({:ok, []}) == true + end + + test "returns false for error" do + assert Result.ok?({:error, :not_found}) == false + assert Result.ok?({:error, "failure"}) == false + assert Result.ok?({:error, nil}) == false + end + end + + describe "error?/1" do + test "returns true for error" do + assert Result.error?({:error, :not_found}) == true + assert Result.error?({:error, "failure"}) == true + assert Result.error?({:error, nil}) == true + end + + test "returns false for success" do + assert Result.error?({:ok, 42}) == false + assert Result.error?({:ok, nil}) == false + assert Result.error?({:ok, []}) == false + end + end + + describe "combined operations" do + test "map and and_then together" do + result = {:ok, "5"} + + final_result = + result + |> Result.map(&String.to_integer/1) + |> Result.and_then(fn x -> {:ok, x * 2} end) + |> Result.map(fn x -> x + 3 end) + + assert final_result == {:ok, 13} + end + + test "map_error after failed operation" do + result = {:error, :db_timeout} + + final_result = + result + |> Result.map(fn x -> x * 2 end) + |> Result.map_error(fn _ -> :service_unavailable end) + + assert final_result == {:error, :service_unavailable} + end + + test "conditional unwrap based on ok?" do + success = {:ok, 42} + error = {:error, :not_found} + + assert Result.ok?(success) && Result.unwrap_or(success, 0) == 42 + refute Result.ok?(error) + assert Result.unwrap_or(error, 0) == 0 + end + end +end diff --git a/test/towerops_web/plugs/remote_ip_logger_test.exs b/test/towerops_web/plugs/remote_ip_logger_test.exs new file mode 100644 index 00000000..659e0143 --- /dev/null +++ b/test/towerops_web/plugs/remote_ip_logger_test.exs @@ -0,0 +1,83 @@ +defmodule ToweropsWeb.Plugs.RemoteIpLoggerTest do + use ToweropsWeb.ConnCase, async: true + + alias ToweropsWeb.Plugs.RemoteIpLogger + + require Logger + + describe "init/1" do + test "returns options unchanged" do + assert RemoteIpLogger.init([]) == [] + assert RemoteIpLogger.init(some: :option) == [some: :option] + end + end + + describe "call/2" do + test "extracts IP from X-Forwarded-For header" do + build_conn() + |> put_req_header("x-forwarded-for", "203.0.113.195") + |> RemoteIpLogger.call([]) + + assert Logger.metadata()[:remote_ip] == "203.0.113.195" + end + + test "extracts first IP from comma-separated X-Forwarded-For" do + build_conn() + |> put_req_header("x-forwarded-for", "203.0.113.195, 10.0.0.1, 192.168.1.1") + |> RemoteIpLogger.call([]) + + assert Logger.metadata()[:remote_ip] == "203.0.113.195" + end + + test "trims whitespace from X-Forwarded-For IP" do + build_conn() + |> put_req_header("x-forwarded-for", " 203.0.113.195 , 10.0.0.1") + |> RemoteIpLogger.call([]) + + assert Logger.metadata()[:remote_ip] == "203.0.113.195" + end + + test "falls back to X-Real-IP when X-Forwarded-For is not present" do + build_conn() + |> put_req_header("x-real-ip", "198.51.100.42") + |> RemoteIpLogger.call([]) + + assert Logger.metadata()[:remote_ip] == "198.51.100.42" + end + + test "formats IPv4 from conn.remote_ip when no proxy headers present" do + # ConnCase builds a conn with remote_ip {127, 0, 0, 1} + RemoteIpLogger.call(build_conn(), []) + + assert Logger.metadata()[:remote_ip] == "127.0.0.1" + end + + test "formats IPv6 from conn.remote_ip" do + build_conn() + |> Map.put(:remote_ip, {8193, 3512, 0, 0, 0, 0, 0, 1}) + |> RemoteIpLogger.call([]) + + # IPv6 formatted as hex segments separated by colons (uppercase) + assert Logger.metadata()[:remote_ip] == "2001:DB8:0:0:0:0:0:1" + end + + test "prefers X-Forwarded-For over X-Real-IP" do + build_conn() + |> put_req_header("x-forwarded-for", "203.0.113.195") + |> put_req_header("x-real-ip", "198.51.100.42") + |> RemoteIpLogger.call([]) + + assert Logger.metadata()[:remote_ip] == "203.0.113.195" + end + + test "returns conn unchanged" do + original_conn = build_conn() + result_conn = RemoteIpLogger.call(original_conn, []) + + # Conn should pass through unchanged (only metadata is modified) + assert result_conn.adapter == original_conn.adapter + assert result_conn.method == original_conn.method + assert result_conn.path_info == original_conn.path_info + end + end +end diff --git a/test/towerops_web/plugs/update_session_activity_test.exs b/test/towerops_web/plugs/update_session_activity_test.exs new file mode 100644 index 00000000..c6d1b4a3 --- /dev/null +++ b/test/towerops_web/plugs/update_session_activity_test.exs @@ -0,0 +1,111 @@ +defmodule ToweropsWeb.Plugs.UpdateSessionActivityTest do + use ToweropsWeb.ConnCase, async: true + + import Towerops.AccountsFixtures + + alias Towerops.Accounts + alias ToweropsWeb.Plugs.UpdateSessionActivity + + describe "init/1" do + test "returns options unchanged" do + assert UpdateSessionActivity.init([]) == [] + assert UpdateSessionActivity.init(some: :option) == [some: :option] + end + end + + describe "call/2" do + test "returns conn unchanged" do + conn = Plug.Test.init_test_session(build_conn(), %{}) + + result = UpdateSessionActivity.call(conn, []) + + assert result == conn + end + + test "spawns background task to update session activity" do + user = user_fixture() + {token, user_token} = Accounts.generate_user_session_token_with_record(user) + + now = DateTime.utc_now() + + # Create browser session + {:ok, _session} = + Accounts.create_browser_session(%{ + user_id: user.id, + user_token_id: user_token.id, + device_name: "Test Browser", + ip_address: "127.0.0.1", + user_agent: "Mozilla/5.0 Test", + last_activity_at: now, + expires_at: DateTime.add(now, 30, :day) + }) + + # Call plug with session token - should spawn background task without blocking + result = + build_conn() + |> Plug.Test.init_test_session(%{user_token: token}) + |> UpdateSessionActivity.call([]) + + # Should return immediately (not block on background task) + assert result + end + + test "handles missing session token gracefully" do + build_conn() + |> Plug.Test.init_test_session(%{}) + |> UpdateSessionActivity.call([]) + + # Should not raise an error - test passes if no exception + end + + test "handles invalid session token gracefully" do + build_conn() + |> Plug.Test.init_test_session(%{user_token: "invalid-token-does-not-exist"}) + |> UpdateSessionActivity.call([]) + + # Wait for background task to complete + Process.sleep(100) + + # Should not raise an error - test passes if no exception + end + + test "does not block the request while updating" do + user = user_fixture() + {token, user_token} = Accounts.generate_user_session_token_with_record(user) + + now = DateTime.utc_now() + + {:ok, _session} = + Accounts.create_browser_session(%{ + user_id: user.id, + user_token_id: user_token.id, + device_name: "Test Browser", + ip_address: "127.0.0.1", + user_agent: "Mozilla/5.0 Test", + last_activity_at: now, + expires_at: DateTime.add(now, 30, :day) + }) + + # Time the request - should be fast even though update happens in background + start_time = System.monotonic_time(:millisecond) + + build_conn() + |> Plug.Test.init_test_session(%{user_token: token}) + |> UpdateSessionActivity.call([]) + + end_time = System.monotonic_time(:millisecond) + duration = end_time - start_time + + # Should complete very quickly (under 50ms) since update is async + assert duration < 50 + end + + test "handles nil token gracefully" do + build_conn() + |> Plug.Test.init_test_session(%{user_token: nil}) + |> UpdateSessionActivity.call([]) + + # Should not raise an error - test passes if no exception + end + end +end