more tests

This commit is contained in:
Graham McIntire 2026-02-03 13:20:33 -06:00
parent ecf6edaf85
commit 50257dbe68
No known key found for this signature in database
4 changed files with 369 additions and 3 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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