diff --git a/lib/structs/field_content.ex b/lib/structs/field_content.ex
index b291efe..72b20e8 100644
--- a/lib/structs/field_content.ex
+++ b/lib/structs/field_content.ex
@@ -5,21 +5,14 @@ defmodule ExPass.Structs.FieldContent do
A field displays information on the front or back of a pass, such as a customer's name,
a balance, or an expiration date.
+ For more details, see the [Apple Developer Documentation](https://developer.apple.com/documentation/walletpasses/passfieldcontent).
+
## Attributes
- `attributed_value`: The field's value, which can include HTML markup for enhanced
formatting or interactivity. It can be a string, number, or ISO 8601 date string.
-
- ## Examples
-
- iex> field = ExPass.Structs.FieldContent.new(%{attributed_value: "Click here"})
- %ExPass.Structs.FieldContent{attributed_value: "Click here"}
-
- iex> field = ExPass.Structs.FieldContent.new(%{attributed_value: 42})
- %ExPass.Structs.FieldContent{attributed_value: 42}
-
- iex> field = ExPass.Structs.FieldContent.new(%{attributed_value: "2023-04-15T14:30:00Z"})
- %ExPass.Structs.FieldContent{attributed_value: "2023-04-15T14:30:00Z"}
+ - `change_message`: A message that describes the change to the field's value.
+ It should include the '%@' placeholder for the new value.
"""
use TypedStruct
@@ -56,13 +49,15 @@ defmodule ExPass.Structs.FieldContent do
typedstruct do
field :attributed_value, attributed_value(), default: nil
+ field :change_message, String.t(), default: nil
end
@doc """
Creates a new FieldContent struct.
This function initializes a new FieldContent struct with the given attributes.
- It validates the `attributed_value` using the `ExPass.Utils.Validators.validate_attributed_value/1` function.
+ It validates both the `attributed_value` using the `ExPass.Utils.Validators.validate_attributed_value/1` function
+ and the `change_message` using the `ExPass.Utils.Validators.validate_change_message/1` function.
## Parameters
@@ -75,33 +70,36 @@ defmodule ExPass.Structs.FieldContent do
## Raises
* `ArgumentError` if the `attributed_value` is invalid. The error message will include details about the invalid value and supported types.
+ * `ArgumentError` if the `change_message` is invalid. The error message will include details about the invalid value and the required format.
## Examples
iex> FieldContent.new(%{attributed_value: "Hello, World!"})
- %FieldContent{attributed_value: "Hello, World!"}
+ %FieldContent{attributed_value: "Hello, World!", change_message: nil}
iex> FieldContent.new(%{attributed_value: 42})
- %FieldContent{attributed_value: 42}
+ %FieldContent{attributed_value: 42, change_message: nil}
- iex> FieldContent.new(%{attributed_value: DateTime.utc_now()})
- %FieldContent{attributed_value: #DateTime<...>}
+ iex> datetime = DateTime.utc_now()
+ iex> field_content = FieldContent.new(%{attributed_value: datetime})
+ iex> %FieldContent{attributed_value: ^datetime} = field_content
+ iex> field_content.change_message
+ nil
- iex> FieldContent.new(%{attributed_value: Date.utc_today()})
- %FieldContent{attributed_value: ~D[...]}
+ iex> date = Date.utc_today()
+ iex> FieldContent.new(%{attributed_value: date})
+ %FieldContent{attributed_value: date, change_message: nil}
iex> FieldContent.new(%{attributed_value: "Click here"})
- %FieldContent{attributed_value: "Click here"}
-
- iex> FieldContent.new()
- %FieldContent{attributed_value: nil}
-
+ %FieldContent{attributed_value: "Click here", change_message: nil}
"""
@spec new(map()) :: %__MODULE__{}
def new(attrs \\ %{}) do
attrs =
attrs
+ |> Converter.trim_string_values()
|> validate(:attributed_value, &Validators.validate_attributed_value/1)
+ |> validate(:change_message, &Validators.validate_change_message/1)
struct!(__MODULE__, attrs)
end
@@ -112,11 +110,27 @@ defmodule ExPass.Structs.FieldContent do
attrs
{:error, reason} ->
- raise ArgumentError, """
- Invalid attributed_value: #{inspect(attrs[key])}
- Reason: #{reason}
- Supported types are: String (including tag), number, DateTime and Date
- """
+ cond do
+ key == :attributed_value ->
+ raise ArgumentError, """
+ Invalid attributed_value: #{inspect(attrs[key])}
+ Reason: #{reason}
+ Supported types are: String (including tag), number, DateTime and Date
+ """
+
+ key == :change_message ->
+ raise ArgumentError, """
+ Invalid change_message: #{inspect(attrs[key])}
+ Reason: #{reason}
+ The change_message must be a string containing the '%@' placeholder for the new value.
+ """
+
+ true ->
+ raise ArgumentError, """
+ Invalid value for #{key}: #{inspect(attrs[key])}
+ Reason: #{reason}
+ """
+ end
end
end
@@ -124,9 +138,10 @@ defmodule ExPass.Structs.FieldContent do
def encode(field_content, opts) do
field_content
|> Map.from_struct()
- |> Enum.filter(fn {_, v} -> v != nil end)
- |> Enum.map(fn {k, v} -> {Converter.camelize_key(k), v} end)
- |> Enum.into(%{})
+ |> Enum.reduce(%{}, fn
+ {_k, nil}, acc -> acc
+ {k, v}, acc -> Map.put(acc, Converter.camelize_key(k), v)
+ end)
|> Jason.Encode.map(opts)
end
end
diff --git a/lib/utils/converter.ex b/lib/utils/converter.ex
index 8585899..4e86d36 100644
--- a/lib/utils/converter.ex
+++ b/lib/utils/converter.ex
@@ -4,6 +4,36 @@ defmodule ExPass.Utils.Converter do
focusing on key format conversions.
"""
+ @doc """
+ Trims whitespace from string values in a map while preserving non-string values.
+
+ This function iterates through the key-value pairs of the input map. For each pair,
+ if the value is a string, it trims leading and trailing whitespace. Non-string
+ values are left unchanged.
+
+ ## Parameters
+
+ * `attrs` - A map containing key-value pairs to be processed.
+
+ ## Returns
+
+ * A new map with the same keys as the input, but with string values trimmed.
+
+ ## Examples
+
+ iex> attrs = %{name: " John Doe ", age: 30, email: " john@example.com "}
+ iex> ExPass.Utils.Converter.trim_string_values(attrs)
+ %{name: "John Doe", age: 30, email: "john@example.com"}
+
+ """
+ @spec trim_string_values(map()) :: map()
+ def trim_string_values(attrs) do
+ Map.new(attrs, fn {key, value} ->
+ trimmed_value = if is_binary(value), do: String.trim(value), else: value
+ {key, trimmed_value}
+ end)
+ end
+
@doc """
Converts a key (atom or string) to camelCase format.
diff --git a/lib/utils/validators.ex b/lib/utils/validators.ex
index 097bca8..90aeb3d 100644
--- a/lib/utils/validators.ex
+++ b/lib/utils/validators.ex
@@ -68,6 +68,44 @@ defmodule ExPass.Utils.Validators do
def validate_attributed_value(_), do: {:error, "invalid attributed_value type"}
+ @doc """
+ Validates the change_message field.
+
+ The change_message must be a string containing the '%@' placeholder for the new value.
+
+ ## Returns
+
+ * `:ok` if the value is a valid change_message string.
+ * `{:error, reason}` if the value is not valid, where reason is a string explaining the error.
+
+ ## Examples
+
+ iex> validate_change_message("Gate changed to %@")
+ :ok
+
+ iex> validate_change_message("Invalid message without placeholder")
+ {:error, "change_message must contain '%@' placeholder"}
+
+ iex> validate_change_message(nil)
+ :ok
+
+ iex> validate_change_message(42)
+ {:error, "change_message must be a string"}
+
+ """
+ @spec validate_change_message(String.t() | nil) :: :ok | {:error, String.t()}
+ def validate_change_message(nil), do: :ok
+
+ def validate_change_message(value) when is_binary(value) do
+ if String.contains?(value, "%@") do
+ :ok
+ else
+ {:error, "change_message must contain '%@' placeholder"}
+ end
+ end
+
+ def validate_change_message(_), do: {:error, "change_message must be a string"}
+
defp contains_unsupported_html_tags?(string) do
# Remove all valid anchor tags
string_without_anchors = String.replace(string, ~r{]*>.*?|]*/>}, "")
diff --git a/test/structs/field_content_test.exs b/test/structs/field_content_test.exs
index 0e49925..58e43cf 100644
--- a/test/structs/field_content_test.exs
+++ b/test/structs/field_content_test.exs
@@ -2,30 +2,60 @@ defmodule ExPass.Structs.FieldContentTest do
@moduledoc false
use ExUnit.Case
-
alias ExPass.Structs.FieldContent
- describe "field struct" do
+ doctest FieldContent
+
+ describe "FieldContent struct change_message" do
+ test "new/1 raises ArgumentError for invalid change_message without '%@' placeholder" do
+ message = "Balance updated"
+
+ assert_raise ArgumentError, ~r/Invalid change_message: "Balance updated"/, fn ->
+ FieldContent.new(%{change_message: message})
+ end
+ end
+
+ test "new/1 creates a FieldContent struct with valid change_message containing '%@' placeholder" do
+ message = "Balance updated to %@"
+ result = FieldContent.new(%{change_message: message})
+
+ assert result.change_message == message
+ assert Jason.encode!(result) == ~s({"changeMessage":"Balance updated to %@"})
+ end
+
+ test "new/1 trims whitespace from change_message while preserving '%@' placeholder" do
+ message = " Trimmed message %@ "
+ result = FieldContent.new(%{change_message: message})
+
+ assert result.change_message == "Trimmed message %@"
+ assert Jason.encode!(result) == ~s({"changeMessage":"Trimmed message %@"})
+ end
+ end
+
+ describe "FieldContent struct attributed_value" do
test "new/1 creates an empty FieldContent struct when no attributes are provided" do
assert %FieldContent{attributed_value: nil} = FieldContent.new()
+ assert Jason.encode!(FieldContent.new()) == ~s({})
end
test "new/1 creates a valid FieldContent struct with string" do
input_string = "Hello, World!"
result = FieldContent.new(%{attributed_value: input_string})
- assert result.attributed_value == input_string
+ assert %FieldContent{attributed_value: ^input_string} = result
+ assert Jason.encode!(result) == ~s({"attributedValue":"Hello, World!"})
end
test "new/1 creates a valid FieldContent struct with number" do
input_number = 42
result = FieldContent.new(%{attributed_value: input_number})
- assert result.attributed_value == input_number
+ assert %FieldContent{attributed_value: ^input_number} = result
+ assert Jason.encode!(result) == ~s({"attributedValue":42})
end
test "new/1 raises ArgumentError for invalid attributed_value types" do
- invalid_values = [%{}, [1, 2, 3], self(), :stephen]
+ invalid_values = [%{}, [1, 2, 3], self(), :atom]
for invalid_value <- invalid_values do
assert_raise ArgumentError, ~r/Invalid attributed_value:/, fn ->
@@ -38,14 +68,16 @@ defmodule ExPass.Structs.FieldContentTest do
input_time = DateTime.utc_now()
result = FieldContent.new(%{attributed_value: input_time})
- assert result.attributed_value == input_time
+ assert %FieldContent{attributed_value: ^input_time} = result
+ assert Jason.encode!(result) == ~s({"attributedValue":"#{DateTime.to_iso8601(input_time)}"})
end
test "new/1 creates a valid FieldContent struct with Date" do
input_date = Date.utc_today()
result = FieldContent.new(%{attributed_value: input_date})
- assert result.attributed_value == input_date
+ assert %FieldContent{attributed_value: ^input_date} = result
+ assert Jason.encode!(result) == ~s({"attributedValue":"#{Date.to_iso8601(input_date)}"})
end
test "new/1 raises ArgumentError for attributed_value with unsupported HTML tag" do
@@ -55,62 +87,15 @@ defmodule ExPass.Structs.FieldContentTest do
FieldContent.new(%{attributed_value: input_value})
end
end
- end
-
- describe "JSON encoding" do
- test "encodes FieldContent with string attributed_value" do
- json =
- %{attributed_value: "Hello, World!"}
- |> FieldContent.new()
- |> Jason.encode!()
- assert json == ~s({"attributedValue":"Hello, World!"})
- end
-
- test "encodes FieldContent with number attributed_value" do
- json =
- %{attributed_value: 42}
- |> FieldContent.new()
- |> Jason.encode!()
-
- assert json == ~s({"attributedValue":42})
- end
-
- test "encodes FieldContent with DateTime attributed_value" do
- datetime = DateTime.from_naive!(~N[2023-01-01 12:00:00], "Etc/UTC")
-
- json =
- %{attributed_value: datetime}
- |> FieldContent.new()
- |> Jason.encode!()
-
- assert json == ~s({"attributedValue":"2023-01-01T12:00:00Z"})
- end
-
- test "encodes FieldContent with Date attributed_value" do
- json =
- %{attributed_value: ~D[2023-01-01]}
- |> FieldContent.new()
- |> Jason.encode!()
-
- assert json == ~s({"attributedValue":"2023-01-01"})
- end
-
- test "FieldContent with nil attributed_value are excluded from the final encoded json" do
- json =
- FieldContent.new()
- |> Jason.encode!()
-
- assert json == ~s({})
- end
+ test "new/1 creates a valid FieldContent struct with supported HTML tag" do
+ input_value = "Link"
+ result = FieldContent.new(%{attributed_value: input_value})
- test "encodes FieldContent with HTML attributed_value" do
- json =
- %{attributed_value: "Link"}
- |> FieldContent.new()
- |> Jason.encode!()
+ assert %FieldContent{attributed_value: ^input_value} = result
- assert json == ~s({"attributedValue":"Link"})
+ assert Jason.encode!(result) ==
+ ~s({"attributedValue":"Link"})
end
end
end