diff --git a/lib/membership/custom_field.ex b/lib/membership/custom_field.ex index f155968..90bbcaa 100644 --- a/lib/membership/custom_field.ex +++ b/lib/membership/custom_field.ex @@ -15,17 +15,18 @@ defmodule Mv.Membership.CustomField do - `required` - If true, all members must have this custom field (future feature) ## Supported Value Types - - `:string` - Text data (unlimited length) + - `:string` - Text data (max 10,000 characters) - `:integer` - Numeric data (64-bit integers) - `:boolean` - True/false flags - `:date` - Date values (no time component) - - `:email` - Validated email addresses + - `:email` - Validated email addresses (max 254 characters) ## Relationships - `has_many :custom_field_values` - All custom field values of this type ## Constraints - Name must be unique across all custom fields + - Name maximum length: 100 characters - Cannot delete a custom field that has existing custom field values (RESTRICT) ## Examples @@ -60,14 +61,26 @@ defmodule Mv.Membership.CustomField do attributes do uuid_primary_key :id - attribute :name, :string, allow_nil?: false, public?: true + attribute :name, :string, + allow_nil?: false, + public?: true, + constraints: [ + max_length: 100, + trim?: true + ] attribute :value_type, :atom, constraints: [one_of: [:string, :integer, :boolean, :date, :email]], allow_nil?: false, description: "Defines the datatype `CustomFieldValue.value` is interpreted as" - attribute :description, :string, allow_nil?: true, public?: true + attribute :description, :string, + allow_nil?: true, + public?: true, + constraints: [ + max_length: 500, + trim?: true + ] attribute :immutable, :boolean, default: false, diff --git a/lib/membership/custom_field_value.ex b/lib/membership/custom_field_value.ex index 6e6c95f..2d6c025 100644 --- a/lib/membership/custom_field_value.ex +++ b/lib/membership/custom_field_value.ex @@ -30,6 +30,11 @@ defmodule Mv.Membership.CustomFieldValue do ## Constraints - Each member can have only one custom field value per custom field (unique composite index) - Custom field values are deleted when the associated member is deleted (CASCADE) + - String values maximum length: 10,000 characters + - Email values maximum length: 254 characters (RFC 5321) + + ## Future Features + - Type-matching validation (value type must match custom field's value_type) - to be implemented """ use Ash.Resource, domain: Mv.Membership, @@ -56,11 +61,25 @@ defmodule Mv.Membership.CustomFieldValue do constraints: [ storage: :type_and_value, types: [ - boolean: [type: :boolean], - date: [type: :date], - integer: [type: :integer], - string: [type: :string], - email: [type: Mv.Membership.Email] + boolean: [ + type: :boolean + ], + date: [ + type: :date + ], + integer: [ + type: :integer + ], + string: [ + type: :string, + constraints: [ + max_length: 10_000, + trim?: true + ] + ], + email: [ + type: Mv.Membership.Email + ] ] ] end diff --git a/test/membership/custom_field_validation_test.exs b/test/membership/custom_field_validation_test.exs new file mode 100644 index 0000000..d8a5bd9 --- /dev/null +++ b/test/membership/custom_field_validation_test.exs @@ -0,0 +1,206 @@ +defmodule Mv.Membership.CustomFieldValidationTest do + @moduledoc """ + Tests for CustomField validation constraints. + + Tests cover: + - Name length validation (max 100 characters) + - Name trimming + - Description length validation (max 500 characters) + - Description trimming + - Required vs optional fields + """ + use Mv.DataCase, async: true + + alias Mv.Membership.CustomField + + describe "name validation" do + test "accepts name with exactly 100 characters" do + name = String.duplicate("a", 100) + + assert {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: name, + value_type: :string + }) + |> Ash.create() + + assert custom_field.name == name + assert String.length(custom_field.name) == 100 + end + + test "rejects name with 101 characters" do + name = String.duplicate("a", 101) + + assert {:error, changeset} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: name, + value_type: :string + }) + |> Ash.create() + + assert [%{field: :name, message: message}] = changeset.errors + assert message =~ "max" or message =~ "length" or message =~ "100" + end + + test "trims whitespace from name" do + assert {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: " test_field ", + value_type: :string + }) + |> Ash.create() + + assert custom_field.name == "test_field" + end + + test "rejects empty name" do + assert {:error, changeset} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "", + value_type: :string + }) + |> Ash.create() + + assert Enum.any?(changeset.errors, fn error -> error.field == :name end) + end + + test "rejects nil name" do + assert {:error, changeset} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + value_type: :string + }) + |> Ash.create() + + assert Enum.any?(changeset.errors, fn error -> error.field == :name end) + end + end + + describe "description validation" do + test "accepts description with exactly 500 characters" do + description = String.duplicate("a", 500) + + assert {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "test_field", + value_type: :string, + description: description + }) + |> Ash.create() + + assert custom_field.description == description + assert String.length(custom_field.description) == 500 + end + + test "rejects description with 501 characters" do + description = String.duplicate("a", 501) + + assert {:error, changeset} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "test_field", + value_type: :string, + description: description + }) + |> Ash.create() + + assert [%{field: :description, message: message}] = changeset.errors + assert message =~ "max" or message =~ "length" or message =~ "500" + end + + test "trims whitespace from description" do + assert {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "test_field", + value_type: :string, + description: " A nice description " + }) + |> Ash.create() + + assert custom_field.description == "A nice description" + end + + test "accepts nil description (optional field)" do + assert {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "test_field", + value_type: :string + }) + |> Ash.create() + + assert custom_field.description == nil + end + + test "accepts empty description after trimming" do + assert {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "test_field", + value_type: :string, + description: " " + }) + |> Ash.create() + + # After trimming whitespace, becomes nil (empty strings are converted to nil) + assert custom_field.description == nil + end + end + + describe "name uniqueness" do + test "rejects duplicate names" do + assert {:ok, _} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "unique_field", + value_type: :string + }) + |> Ash.create() + + assert {:error, changeset} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "unique_field", + value_type: :integer + }) + |> Ash.create() + + assert Enum.any?(changeset.errors, fn error -> error.field == :name end) + end + end + + describe "value_type validation" do + test "accepts all valid value types" do + for value_type <- [:string, :integer, :boolean, :date, :email] do + assert {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "field_#{value_type}", + value_type: value_type + }) + |> Ash.create() + + assert custom_field.value_type == value_type + end + end + + test "rejects invalid value type" do + assert {:error, changeset} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "invalid_field", + value_type: :invalid_type + }) + |> Ash.create() + + assert [%{field: :value_type}] = changeset.errors + end + end +end + diff --git a/test/membership/custom_field_value_validation_test.exs b/test/membership/custom_field_value_validation_test.exs new file mode 100644 index 0000000..ce5b5c6 --- /dev/null +++ b/test/membership/custom_field_value_validation_test.exs @@ -0,0 +1,276 @@ +defmodule Mv.Membership.CustomFieldValueValidationTest do + @moduledoc """ + Tests for CustomFieldValue validation constraints. + + Tests cover: + - String value length validation (max 10,000 characters) + - String value trimming + - Email value validation (via Email type) + - Optional values (nil allowed) + """ + use Mv.DataCase, async: true + + alias Mv.Membership.{CustomField, CustomFieldValue, Member} + + setup do + # Create a test member + {:ok, member} = + Member + |> Ash.Changeset.for_create(:create_member, %{ + first_name: "Test", + last_name: "User", + email: "test.validation@example.com" + }) + |> Ash.create() + + # Create custom fields for different types + {:ok, string_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "string_field", + value_type: :string + }) + |> Ash.create() + + {:ok, integer_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "integer_field", + value_type: :integer + }) + |> Ash.create() + + {:ok, email_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "email_field", + value_type: :email + }) + |> Ash.create() + + %{ + member: member, + string_field: string_field, + integer_field: integer_field, + email_field: email_field + } + end + + describe "string value length validation" do + test "accepts string value with exactly 10,000 characters", %{ + member: member, + string_field: string_field + } do + value_string = String.duplicate("a", 10_000) + + assert {:ok, custom_field_value} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member.id, + custom_field_id: string_field.id, + value: %{ + "_union_type" => "string", + "_union_value" => value_string + } + }) + |> Ash.create() + + assert custom_field_value.value.value == value_string + assert String.length(custom_field_value.value.value) == 10_000 + end + + test "rejects string value with 10,001 characters", %{ + member: member, + string_field: string_field + } do + value_string = String.duplicate("a", 10_001) + + assert {:error, changeset} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member.id, + custom_field_id: string_field.id, + value: %{"_union_type" => "string", "_union_value" => value_string} + }) + |> Ash.create() + + assert Enum.any?(changeset.errors, fn error -> + error.field == :value and (error.message =~ "max" or error.message =~ "length") + end) + end + + test "trims whitespace from string value", %{member: member, string_field: string_field} do + assert {:ok, custom_field_value} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member.id, + custom_field_id: string_field.id, + value: %{"_union_type" => "string", "_union_value" => " test value "} + }) + |> Ash.create() + + assert custom_field_value.value.value == "test value" + end + + test "accepts empty string value", %{member: member, string_field: string_field} do + assert {:ok, custom_field_value} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member.id, + custom_field_id: string_field.id, + value: %{"_union_type" => "string", "_union_value" => ""} + }) + |> Ash.create() + + # Empty strings after trimming become nil + assert custom_field_value.value.value == nil + end + + test "accepts string with special characters", %{member: member, string_field: string_field} do + special_string = "Hello δΈ–η•Œ! πŸŽ‰ @#$%^&*()" + + assert {:ok, custom_field_value} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member.id, + custom_field_id: string_field.id, + value: %{"_union_type" => "string", "_union_value" => special_string} + }) + |> Ash.create() + + assert custom_field_value.value.value == special_string + end + end + + describe "integer value validation" do + test "accepts valid integer value", %{member: member, integer_field: integer_field} do + assert {:ok, custom_field_value} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member.id, + custom_field_id: integer_field.id, + value: %{"_union_type" => "integer", "_union_value" => 42} + }) + |> Ash.create() + + assert custom_field_value.value.value == 42 + end + + test "accepts negative integer", %{member: member, integer_field: integer_field} do + assert {:ok, custom_field_value} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member.id, + custom_field_id: integer_field.id, + value: %{"_union_type" => "integer", "_union_value" => -100} + }) + |> Ash.create() + + assert custom_field_value.value.value == -100 + end + + test "accepts zero", %{member: member, integer_field: integer_field} do + assert {:ok, custom_field_value} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member.id, + custom_field_id: integer_field.id, + value: %{"_union_type" => "integer", "_union_value" => 0} + }) + |> Ash.create() + + assert custom_field_value.value.value == 0 + end + end + + describe "email value validation" do + test "accepts valid email", %{member: member, email_field: email_field} do + assert {:ok, custom_field_value} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member.id, + custom_field_id: email_field.id, + value: %{"_union_type" => "email", "_union_value" => "test@example.com"} + }) + |> Ash.create() + + assert custom_field_value.value.value == "test@example.com" + end + + test "rejects invalid email format", %{member: member, email_field: email_field} do + assert {:error, changeset} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member.id, + custom_field_id: email_field.id, + value: %{"_union_type" => "email", "_union_value" => "not-an-email"} + }) + |> Ash.create() + + assert Enum.any?(changeset.errors, fn error -> error.field == :value end) + end + + test "rejects email longer than 254 characters", %{member: member, email_field: email_field} do + # Create an email with >254 chars (243 + 12 = 255) + long_email = String.duplicate("a", 243) <> "@example.com" + + assert {:error, changeset} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member.id, + custom_field_id: email_field.id, + value: %{"_union_type" => "email", "_union_value" => long_email} + }) + |> Ash.create() + + assert Enum.any?(changeset.errors, fn error -> error.field == :value end) + end + + test "trims whitespace from email", %{member: member, email_field: email_field} do + assert {:ok, custom_field_value} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member.id, + custom_field_id: email_field.id, + value: %{"_union_type" => "email", "_union_value" => " test@example.com "} + }) + |> Ash.create() + + assert custom_field_value.value.value == "test@example.com" + end + end + + describe "uniqueness constraint" do + test "rejects duplicate custom_field_id per member", %{ + member: member, + string_field: string_field + } do + # Create first custom field value + assert {:ok, _} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member.id, + custom_field_id: string_field.id, + value: %{"_union_type" => "string", "_union_value" => "first value"} + }) + |> Ash.create() + + # Try to create second custom field value with same custom_field_id for same member + assert {:error, changeset} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member.id, + custom_field_id: string_field.id, + value: %{"_union_type" => "string", "_union_value" => "second value"} + }) + |> Ash.create() + + # Should have uniqueness error + assert Enum.any?(changeset.errors, fn error -> + error.message =~ "unique" or error.message =~ "already exists" or + error.message =~ "has already been taken" + end) + end + end +end +