diff --git a/test/membership/member_search_with_custom_fields_test.exs b/test/membership/member_search_with_custom_fields_test.exs new file mode 100644 index 0000000..3b1b3b9 --- /dev/null +++ b/test/membership/member_search_with_custom_fields_test.exs @@ -0,0 +1,547 @@ +defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do + @moduledoc """ + Tests for full-text search including custom_field_values. + + Tests verify that custom field values are included in the search_vector + and can be found through the fuzzy_search functionality. + """ + use Mv.DataCase, async: false + + alias Mv.Membership.{CustomField, CustomFieldValue, Member} + + setup do + # Create test members + {:ok, member1} = + Member + |> Ash.Changeset.for_create(:create_member, %{ + first_name: "Alice", + last_name: "Anderson", + email: "alice@example.com" + }) + |> Ash.create() + + {:ok, member2} = + Member + |> Ash.Changeset.for_create(:create_member, %{ + first_name: "Bob", + last_name: "Brown", + email: "bob@example.com" + }) + |> Ash.create() + + {:ok, member3} = + Member + |> Ash.Changeset.for_create(:create_member, %{ + first_name: "Charlie", + last_name: "Clark", + email: "charlie@example.com" + }) + |> Ash.create() + + # Create custom fields for different types + {:ok, string_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "membership_number", + value_type: :string + }) + |> Ash.create() + + {:ok, integer_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "member_id_number", + value_type: :integer + }) + |> Ash.create() + + {:ok, email_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "secondary_email", + value_type: :email + }) + |> Ash.create() + + {:ok, date_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "birthday", + value_type: :date + }) + |> Ash.create() + + {:ok, boolean_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "newsletter", + value_type: :boolean + }) + |> Ash.create() + + %{ + member1: member1, + member2: member2, + member3: member3, + string_field: string_field, + integer_field: integer_field, + email_field: email_field, + date_field: date_field, + boolean_field: boolean_field + } + end + + describe "search with custom field values" do + test "finds member by string custom field value", %{ + member1: member1, + string_field: string_field + } do + # Create custom field value + {:ok, _cfv} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member1.id, + custom_field_id: string_field.id, + value: %{"_union_type" => "string", "_union_value" => "MEMBER12345"} + }) + |> Ash.create() + + # Force search_vector update by reloading member + {:ok, _updated_member} = + member1 + |> Ash.Changeset.for_update(:update_member, %{}) + |> Ash.update() + + # Search for the custom field value + results = + Member + |> Member.fuzzy_search(%{query: "MEMBER12345"}) + |> Ash.read!() + + assert length(results) == 1 + assert List.first(results).id == member1.id + end + + test "finds member by integer custom field value", %{ + member1: member1, + integer_field: integer_field + } do + # Create custom field value + {:ok, _cfv} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member1.id, + custom_field_id: integer_field.id, + value: %{"_union_type" => "integer", "_union_value" => 42_424} + }) + |> Ash.create() + + # Force search_vector update + {:ok, _updated_member} = + member1 + |> Ash.Changeset.for_update(:update_member, %{}) + |> Ash.update() + + # Search for the custom field value + results = + Member + |> Member.fuzzy_search(%{query: "42424"}) + |> Ash.read!() + + assert length(results) == 1 + assert List.first(results).id == member1.id + end + + test "finds member by email custom field value", %{ + member1: member1, + email_field: email_field + } do + # Create custom field value + {:ok, _cfv} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member1.id, + custom_field_id: email_field.id, + value: %{"_union_type" => "email", "_union_value" => "alice.secondary@example.com"} + }) + |> Ash.create() + + # Force search_vector update + {:ok, _updated_member} = + member1 + |> Ash.Changeset.for_update(:update_member, %{}) + |> Ash.update() + + # Search for partial custom field value (should work via FTS or custom field filter) + results = + Member + |> Member.fuzzy_search(%{query: "alice.secondary"}) + |> Ash.read!() + + assert length(results) == 1 + assert List.first(results).id == member1.id + + # Search for full email address (should work via custom field filter LIKE) + results_full = + Member + |> Member.fuzzy_search(%{query: "alice.secondary@example.com"}) + |> Ash.read!() + + assert length(results_full) == 1 + assert List.first(results_full).id == member1.id + + # Search for domain part (should work via FTS or custom field filter) + # Note: May return multiple results if other members have same domain + results_domain = + Member + |> Member.fuzzy_search(%{query: "example.com"}) + |> Ash.read!() + + # Verify that member1 is in the results (may have other members too) + ids = Enum.map(results_domain, & &1.id) + assert member1.id in ids + end + + test "finds member by date custom field value", %{ + member1: member1, + date_field: date_field + } do + # Create custom field value + {:ok, _cfv} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member1.id, + custom_field_id: date_field.id, + value: %{"_union_type" => "date", "_union_value" => ~D[1990-05-15]} + }) + |> Ash.create() + + # Force search_vector update + {:ok, _updated_member} = + member1 + |> Ash.Changeset.for_update(:update_member, %{}) + |> Ash.update() + + # Search for the custom field value (date is stored as text in search_vector) + results = + Member + |> Member.fuzzy_search(%{query: "1990-05-15"}) + |> Ash.read!() + + assert length(results) == 1 + assert List.first(results).id == member1.id + end + + test "finds member by boolean custom field value", %{ + member1: member1, + boolean_field: boolean_field + } do + # Create custom field value + {:ok, _cfv} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member1.id, + custom_field_id: boolean_field.id, + value: %{"_union_type" => "boolean", "_union_value" => true} + }) + |> Ash.create() + + # Force search_vector update + {:ok, _updated_member} = + member1 + |> Ash.Changeset.for_update(:update_member, %{}) + |> Ash.update() + + # Search for the custom field value (boolean is stored as "true" or "false" text) + results = + Member + |> Member.fuzzy_search(%{query: "true"}) + |> Ash.read!() + + # Note: "true" might match other things, so we check that member1 is in results + assert Enum.any?(results, fn m -> m.id == member1.id end) + end + + test "custom field value update triggers search_vector update", %{ + member1: member1, + string_field: string_field + } do + # Create initial custom field value + {:ok, cfv} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member1.id, + custom_field_id: string_field.id, + value: %{"_union_type" => "string", "_union_value" => "OLDVALUE"} + }) + |> Ash.create() + + # Force search_vector update + {:ok, _updated_member} = + member1 + |> Ash.Changeset.for_update(:update_member, %{}) + |> Ash.update() + + # Update custom field value + {:ok, _updated_cfv} = + cfv + |> Ash.Changeset.for_update(:update, %{ + value: %{"_union_type" => "string", "_union_value" => "NEWVALUE123"} + }) + |> Ash.update() + + # Search for the new value + results = + Member + |> Member.fuzzy_search(%{query: "NEWVALUE123"}) + |> Ash.read!() + + assert length(results) == 1 + assert List.first(results).id == member1.id + + # Old value should not be found + old_results = + Member + |> Member.fuzzy_search(%{query: "OLDVALUE"}) + |> Ash.read!() + + refute Enum.any?(old_results, fn m -> m.id == member1.id end) + end + + test "custom field value delete triggers search_vector update", %{ + member1: member1, + string_field: string_field + } do + # Create custom field value + {:ok, cfv} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member1.id, + custom_field_id: string_field.id, + value: %{"_union_type" => "string", "_union_value" => "TOBEDELETED"} + }) + |> Ash.create() + + # Force search_vector update + {:ok, _updated_member} = + member1 + |> Ash.Changeset.for_update(:update_member, %{}) + |> Ash.update() + + # Verify it's searchable + results = + Member + |> Member.fuzzy_search(%{query: "TOBEDELETED"}) + |> Ash.read!() + + assert length(results) == 1 + assert List.first(results).id == member1.id + + # Delete custom field value + assert :ok = Ash.destroy(cfv) + + # Value should no longer be found + deleted_results = + Member + |> Member.fuzzy_search(%{query: "TOBEDELETED"}) + |> Ash.read!() + + refute Enum.any?(deleted_results, fn m -> m.id == member1.id end) + end + + test "custom field value create triggers search_vector update", %{ + member1: member1, + string_field: string_field + } do + # Create custom field value (trigger should update search_vector automatically) + {:ok, _cfv} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member1.id, + custom_field_id: string_field.id, + value: %{"_union_type" => "string", "_union_value" => "AUTOUPDATE"} + }) + |> Ash.create() + + # Search should find it immediately (trigger should have updated search_vector) + results = + Member + |> Member.fuzzy_search(%{query: "AUTOUPDATE"}) + |> Ash.read!() + + assert length(results) == 1 + assert List.first(results).id == member1.id + end + + test "member update includes custom field values in search_vector", %{ + member1: member1, + string_field: string_field + } do + # Create custom field value + {:ok, _cfv} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member1.id, + custom_field_id: string_field.id, + value: %{"_union_type" => "string", "_union_value" => "MEMBERUPDATE"} + }) + |> Ash.create() + + # Update member (should trigger search_vector update including custom fields) + {:ok, _updated_member} = + member1 + |> Ash.Changeset.for_update(:update_member, %{notes: "Updated notes"}) + |> Ash.update() + + # Search should find the custom field value + results = + Member + |> Member.fuzzy_search(%{query: "MEMBERUPDATE"}) + |> Ash.read!() + + assert length(results) == 1 + assert List.first(results).id == member1.id + end + + test "multiple custom field values are all searchable", %{ + member1: member1, + string_field: string_field, + integer_field: integer_field, + email_field: email_field + } do + # Create multiple custom field values + {:ok, _cfv1} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member1.id, + custom_field_id: string_field.id, + value: %{"_union_type" => "string", "_union_value" => "MULTI1"} + }) + |> Ash.create() + + {:ok, _cfv2} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member1.id, + custom_field_id: integer_field.id, + value: %{"_union_type" => "integer", "_union_value" => 99_999} + }) + |> Ash.create() + + {:ok, _cfv3} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member1.id, + custom_field_id: email_field.id, + value: %{"_union_type" => "email", "_union_value" => "multi@test.com"} + }) + |> Ash.create() + + # Force search_vector update + {:ok, _updated_member} = + member1 + |> Ash.Changeset.for_update(:update_member, %{}) + |> Ash.update() + + # All values should be searchable + results1 = + Member + |> Member.fuzzy_search(%{query: "MULTI1"}) + |> Ash.read!() + + assert Enum.any?(results1, fn m -> m.id == member1.id end) + + results2 = + Member + |> Member.fuzzy_search(%{query: "99999"}) + |> Ash.read!() + + assert Enum.any?(results2, fn m -> m.id == member1.id end) + + results3 = + Member + |> Member.fuzzy_search(%{query: "multi@test.com"}) + |> Ash.read!() + + assert Enum.any?(results3, fn m -> m.id == member1.id end) + end + + test "finds member by custom field value with numbers in text field (e.g. phone number)", %{ + member1: member1, + string_field: string_field + } do + # Create custom field value with numbers and text (like phone number or ID) + {:ok, _cfv} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member1.id, + custom_field_id: string_field.id, + value: %{"_union_type" => "string", "_union_value" => "M-123-456"} + }) + |> Ash.create() + + # Force search_vector update + {:ok, _updated_member} = + member1 + |> Ash.Changeset.for_update(:update_member, %{}) + |> Ash.update() + + # Search for full value (should work via search_vector) + results_full = + Member + |> Member.fuzzy_search(%{query: "M-123-456"}) + |> Ash.read!() + + assert Enum.any?(results_full, fn m -> m.id == member1.id end), + "Full value search should find member via search_vector" + + # Note: Partial substring search may require additional implementation + # For now, we test that the full value is searchable, which is the primary use case + # Substring matching for custom fields may need to be implemented separately + end + + test "finds member by phone number in Emergency Contact custom field", %{ + member1: member1 + } do + # Create Emergency Contact custom field + {:ok, emergency_contact_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "Emergency Contact", + value_type: :string + }) + |> Ash.create() + + # Create custom field value with phone number + phone_number = "+49 123 456789" + + {:ok, _cfv} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member1.id, + custom_field_id: emergency_contact_field.id, + value: %{"_union_type" => "string", "_union_value" => phone_number} + }) + |> Ash.create() + + # Force search_vector update + {:ok, _updated_member} = + member1 + |> Ash.Changeset.for_update(:update_member, %{}) + |> Ash.update() + + # Search for full phone number (should work via search_vector) + results_full = + Member + |> Member.fuzzy_search(%{query: phone_number}) + |> Ash.read!() + + assert Enum.any?(results_full, fn m -> m.id == member1.id end), + "Full phone number search should find member via search_vector" + + # Note: Partial substring search may require additional implementation + # For now, we test that the full phone number is searchable, which is the primary use case + # Substring matching for custom fields may need to be implemented separately + end + end +end