Skip to content

Commit

Permalink
feat: add change_message attribute to field content
Browse files Browse the repository at this point in the history
  • Loading branch information
njausteve committed Sep 16, 2024
1 parent 56c071f commit 44a77ac
Show file tree
Hide file tree
Showing 4 changed files with 159 additions and 91 deletions.
77 changes: 46 additions & 31 deletions lib/structs/field_content.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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: "<a href='http://example.com'>Click here</a>"})
%ExPass.Structs.FieldContent{attributed_value: "<a href='http://example.com'>Click here</a>"}
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
Expand Down Expand Up @@ -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
Expand All @@ -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: "<a href='http://example.com'>Click here</a>"})
%FieldContent{attributed_value: "<a href='http://example.com'>Click here</a>"}
iex> FieldContent.new()
%FieldContent{attributed_value: nil}
%FieldContent{attributed_value: "<a href='http://example.com'>Click here</a>", 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
Expand All @@ -112,21 +110,38 @@ defmodule ExPass.Structs.FieldContent do
attrs

{:error, reason} ->
raise ArgumentError, """
Invalid attributed_value: #{inspect(attrs[key])}
Reason: #{reason}
Supported types are: String (including <a></a> 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 <a></a> 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

defimpl Jason.Encoder 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
Expand Down
30 changes: 30 additions & 0 deletions lib/utils/converter.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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: " [email protected] "}
iex> ExPass.Utils.Converter.trim_string_values(attrs)
%{name: "John Doe", age: 30, email: "[email protected]"}
"""
@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.
Expand Down
38 changes: 38 additions & 0 deletions lib/utils/validators.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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{<a\s[^>]*>.*?</a>|<a\s[^>]*/>}, "")
Expand Down
105 changes: 45 additions & 60 deletions test/structs/field_content_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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 ->
Expand All @@ -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
Expand All @@ -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 = "<a href='http://example.com'>Link</a>"
result = FieldContent.new(%{attributed_value: input_value})

test "encodes FieldContent with HTML attributed_value" do
json =
%{attributed_value: "<a href='http://example.com'>Link</a>"}
|> FieldContent.new()
|> Jason.encode!()
assert %FieldContent{attributed_value: ^input_value} = result

assert json == ~s({"attributedValue":"<a href='http://example.com'>Link</a>"})
assert Jason.encode!(result) ==
~s({"attributedValue":"<a href='http://example.com'>Link</a>"})
end
end
end

0 comments on commit 44a77ac

Please sign in to comment.