Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions lib/realtime_web/channels/payloads/broadcast.ex
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,17 @@ defmodule RealtimeWeb.Channels.Payloads.Broadcast do
use Ecto.Schema
import Ecto.Changeset
alias RealtimeWeb.Channels.Payloads.Join
alias RealtimeWeb.Channels.Payloads.FlexibleBoolean

embedded_schema do
field :ack, :boolean, default: false
field :self, :boolean, default: false
field :ack, FlexibleBoolean, default: false
field :self, FlexibleBoolean, default: false
embeds_one :replay, RealtimeWeb.Channels.Payloads.Broadcast.Replay
end

def changeset(broadcast, attrs) do
cast(broadcast, attrs, [:ack, :self], message: &Join.error_message/2)
broadcast
|> cast(attrs, [:ack, :self], message: &Join.error_message/2)
|> cast_embed(:replay, invalid_message: "unable to parse, expected a map")
end
end
3 changes: 2 additions & 1 deletion lib/realtime_web/channels/payloads/config.ex
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,13 @@ defmodule RealtimeWeb.Channels.Payloads.Config do
alias RealtimeWeb.Channels.Payloads.Broadcast
alias RealtimeWeb.Channels.Payloads.Presence
alias RealtimeWeb.Channels.Payloads.PostgresChange
alias RealtimeWeb.Channels.Payloads.FlexibleBoolean

embedded_schema do
embeds_one :broadcast, Broadcast
embeds_one :presence, Presence
embeds_many :postgres_changes, PostgresChange
field :private, :boolean, default: false
field :private, FlexibleBoolean, default: false
end

def changeset(config, attrs) do
Expand Down
35 changes: 35 additions & 0 deletions lib/realtime_web/channels/payloads/flexible_boolean.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
defmodule RealtimeWeb.Channels.Payloads.FlexibleBoolean do
@moduledoc """
Custom Ecto type that handles boolean values coming as strings.
Accepts:
- Boolean values (true/false) - used as-is
- Strings "true", "True", "TRUE", etc. - cast to true
- Strings "false", "False", "FALSE", etc. - cast to false
- Any other value - returns error
"""
use Ecto.Type

@impl true
def type, do: :boolean

@impl true
def cast(value) when is_boolean(value), do: {:ok, value}

def cast(value) when is_binary(value) do
case String.downcase(value) do
"true" -> {:ok, true}
"false" -> {:ok, false}
_ -> :error
end
end

def cast(_), do: :error

@impl true
def load(value), do: {:ok, value}

@impl true
def dump(value) when is_boolean(value), do: {:ok, value}
def dump(_), do: :error
end
5 changes: 4 additions & 1 deletion lib/realtime_web/channels/payloads/join.ex
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,10 @@ defmodule RealtimeWeb.Channels.Payloads.Join do
type = Keyword.get(meta, :type)

if type,
do: "unable to parse, expected #{type}",
do: "unable to parse, expected #{format_type(type)}",
else: "unable to parse"
end

defp format_type(RealtimeWeb.Channels.Payloads.FlexibleBoolean), do: :boolean
defp format_type(type), do: type
end
3 changes: 2 additions & 1 deletion lib/realtime_web/channels/payloads/presence.ex
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@ defmodule RealtimeWeb.Channels.Payloads.Presence do
use Ecto.Schema
import Ecto.Changeset
alias RealtimeWeb.Channels.Payloads.Join
alias RealtimeWeb.Channels.Payloads.FlexibleBoolean

embedded_schema do
field :enabled, :boolean, default: true
field :enabled, FlexibleBoolean, default: true
field :key, :any, default: UUID.uuid1(), virtual: true
end

Expand Down
72 changes: 72 additions & 0 deletions test/realtime_web/channels/payloads/flexible_boolean_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
defmodule RealtimeWeb.Channels.Payloads.FlexibleBooleanTest do
use ExUnit.Case, async: true

alias RealtimeWeb.Channels.Payloads.FlexibleBoolean

describe "type/0" do
test "returns :boolean" do
assert FlexibleBoolean.type() == :boolean
end
end

describe "cast/1" do
test "casts boolean true as-is" do
assert FlexibleBoolean.cast(true) == {:ok, true}
end

test "casts boolean false as-is" do
assert FlexibleBoolean.cast(false) == {:ok, false}
end

test "casts string 'true' in any case to boolean true" do
assert FlexibleBoolean.cast("true") == {:ok, true}
assert FlexibleBoolean.cast("True") == {:ok, true}
assert FlexibleBoolean.cast("TRUE") == {:ok, true}
assert FlexibleBoolean.cast("tRuE") == {:ok, true}
end

test "casts string 'false' in any case to boolean false" do
assert FlexibleBoolean.cast("false") == {:ok, false}
assert FlexibleBoolean.cast("False") == {:ok, false}
assert FlexibleBoolean.cast("FALSE") == {:ok, false}
assert FlexibleBoolean.cast("fAlSe") == {:ok, false}
end

test "returns error for invalid string values" do
assert FlexibleBoolean.cast("test") == :error
assert FlexibleBoolean.cast("yes") == :error
assert FlexibleBoolean.cast("no") == :error
assert FlexibleBoolean.cast("1") == :error
assert FlexibleBoolean.cast("0") == :error
assert FlexibleBoolean.cast("") == :error
end

test "returns error for non-boolean, non-string values" do
assert FlexibleBoolean.cast(1) == :error
assert FlexibleBoolean.cast(0) == :error
assert FlexibleBoolean.cast(nil) == :error
assert FlexibleBoolean.cast(%{}) == :error
assert FlexibleBoolean.cast([]) == :error
end
end

describe "load/1" do
test "loads boolean values" do
assert FlexibleBoolean.load(true) == {:ok, true}
assert FlexibleBoolean.load(false) == {:ok, false}
end
end

describe "dump/1" do
test "dumps boolean values" do
assert FlexibleBoolean.dump(true) == {:ok, true}
assert FlexibleBoolean.dump(false) == {:ok, false}
end

test "returns error for non-boolean values" do
assert FlexibleBoolean.dump("true") == :error
assert FlexibleBoolean.dump(1) == :error
assert FlexibleBoolean.dump(nil) == :error
end
end
end
95 changes: 95 additions & 0 deletions test/realtime_web/channels/payloads/join_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -119,5 +119,100 @@ defmodule RealtimeWeb.Channels.Payloads.JoinTest do

assert {:ok, %Join{config: %Config{postgres_changes: []}}} = Join.validate(config)
end

test "accepts string 'true' for boolean fields" do
config = %{
"config" => %{
"private" => "true",
"broadcast" => %{"ack" => "true", "self" => "true"},
"presence" => %{"enabled" => "true"}
}
}

assert {:ok, %Join{config: config_result}} = Join.validate(config)

assert %Config{
private: true,
broadcast: %Broadcast{ack: true, self: true},
presence: %Presence{enabled: true}
} = config_result
end

test "accepts string 'True' for boolean fields" do
config = %{
"config" => %{
"private" => "True",
"broadcast" => %{"ack" => "True", "self" => "True"},
"presence" => %{"enabled" => "True"}
}
}

assert {:ok, %Join{config: config_result}} = Join.validate(config)

assert %Config{
private: true,
broadcast: %Broadcast{ack: true, self: true},
presence: %Presence{enabled: true}
} = config_result
end

test "accepts string 'false' for boolean fields" do
config = %{
"config" => %{
"private" => "false",
"broadcast" => %{"ack" => "false", "self" => "false"},
"presence" => %{"enabled" => "false"}
}
}

assert {:ok, %Join{config: config_result}} = Join.validate(config)

assert %Config{
private: false,
broadcast: %Broadcast{ack: false, self: false},
presence: %Presence{enabled: false}
} = config_result
end

test "accepts string 'False' for boolean fields" do
config = %{
"config" => %{
"private" => "False",
"broadcast" => %{"ack" => "False", "self" => "False"},
"presence" => %{"enabled" => "False"}
}
}

assert {:ok, %Join{config: config_result}} = Join.validate(config)

assert %Config{
private: false,
broadcast: %Broadcast{ack: false, self: false},
presence: %Presence{enabled: false}
} = config_result
end

test "rejects invalid boolean strings" do
config = %{
"config" => %{
"private" => "yes",
"broadcast" => %{"ack" => "a", "self" => "b"},
"presence" => %{"enabled" => "no"}
}
}

assert {:error, :invalid_join_payload, errors} = Join.validate(config)

assert errors == %{
config: %{
private: ["unable to parse, expected boolean"],
broadcast: %{
ack: ["unable to parse, expected boolean"],
self: ["unable to parse, expected boolean"]
},
presence: %{enabled: ["unable to parse, expected boolean"]}
}
}
end
end
end