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