diff --git a/lib/accounts/accounts.ex b/lib/accounts/accounts.ex index 7bc5073..a96b5ac 100644 --- a/lib/accounts/accounts.ex +++ b/lib/accounts/accounts.ex @@ -21,4 +21,15 @@ defmodule Mv.Accounts do resource Mv.Accounts.Token end + + @doc """ + Register a new user with password using AshAuthentication's standard action. + This creates a user and the notifier will automatically create a member. + """ + def register_with_password(params) do + # Use AshAuthentication's standard register_with_password action + Mv.Accounts.User + |> Ash.Changeset.for_create(:register_with_password, params) + |> Ash.create(domain: __MODULE__) + end end diff --git a/lib/accounts/user.ex b/lib/accounts/user.ex index d8c7a66..7163649 100644 --- a/lib/accounts/user.ex +++ b/lib/accounts/user.ex @@ -5,7 +5,8 @@ defmodule Mv.Accounts.User do use Ash.Resource, domain: Mv.Accounts, data_layer: AshPostgres.DataLayer, - extensions: [AshAuthentication] + extensions: [AshAuthentication], + notifiers: [Mv.Accounts.User.MemberCreationNotifier] # authorizers: [Ash.Policy.Authorizer] @@ -64,11 +65,16 @@ defmodule Mv.Accounts.User do defaults [:read, :create, :destroy, :update] create :create_user do - accept [:email] + accept [:email, :member_id] + argument :member, :map + change manage_relationship(:member, type: :create) end update :update_user do - accept [:email] + require_atomic? false + accept [:email, :member_id] + argument :member, :map + change manage_relationship(:member, on_match: :update, on_no_match: :create) end # Admin action for direct password changes in admin panel @@ -152,6 +158,7 @@ defmodule Mv.Accounts.User do identities do identity :unique_email, [:email] identity :unique_oidc_id, [:oidc_id] + identity :unique_member_id, [:member_id] end # You can customize this if you wish, but this is a safe default that diff --git a/lib/accounts/user/member_creation_notifier.ex b/lib/accounts/user/member_creation_notifier.ex new file mode 100644 index 0000000..9cc6ef8 --- /dev/null +++ b/lib/accounts/user/member_creation_notifier.ex @@ -0,0 +1,41 @@ +defmodule Mv.Accounts.User.MemberCreationNotifier do + @moduledoc """ + Notifier that automatically creates a member for users who don't have one. + This ensures that every user has an associated member profile. + """ + + use Ash.Notifier + + def notify(%Ash.Notifier.Notification{ + action: %{name: action_name}, + resource: Mv.Accounts.User, + data: user + }) + when action_name in [:create_user, :register_with_password] do + # Only create member if user doesn't have one + if is_nil(user.member_id) do + create_member_for_user(user) + else + :ok + end + end + + def notify(_), do: :ok + + defp create_member_for_user(user) do + member_attrs = %{ + member_email: user.email + } + + case Mv.Membership.create_member(member_attrs) do + {:ok, member} -> + # Update the user with the new member_id + Mv.Accounts.update_user(user, %{member_id: member.id}) + + {:error, _error} -> + # Log error but don't fail the user creation + # In a real application, you might want to handle this differently + :ok + end + end +end diff --git a/lib/membership/member.ex b/lib/membership/member.ex index 583f173..1ac0f0f 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -18,7 +18,7 @@ defmodule Mv.Membership.Member do accept [ :first_name, :last_name, - :email, + :member_email, :birth_date, :paid, :phone_number, @@ -42,7 +42,7 @@ defmodule Mv.Membership.Member do accept [ :first_name, :last_name, - :email, + :member_email, :birth_date, :paid, :phone_number, @@ -60,12 +60,7 @@ defmodule Mv.Membership.Member do end validations do - # Required fields are covered by allow_nil? false - - # First name and last name must not be empty - validate present(:first_name) - validate present(:last_name) - validate present(:email) + # All fields are optional - no required validations # Birth date not in the future validate compare(:birth_date, less_than_or_equal_to: &Date.utc_today/0), @@ -92,38 +87,38 @@ defmodule Mv.Membership.Member do where: [present(:postal_code)], message: "must consist of 5 digits" - # Email validation with EctoCommons.EmailValidator + # Email validation with EctoCommons.EmailValidator (only for member_email) validate fn changeset, _ -> - email = Ash.Changeset.get_attribute(changeset, :email) + member_email = Ash.Changeset.get_attribute(changeset, :member_email) - changeset2 = - {%{}, %{email: :string}} - |> Ecto.Changeset.cast(%{email: email}, [:email]) - |> EctoCommons.EmailValidator.validate_email(:email, checks: [:html_input, :pow]) + changeset2 = + {%{}, %{email: :string}} + |> Ecto.Changeset.cast(%{email: member_email}, [:email]) + |> EctoCommons.EmailValidator.validate_email(:email, checks: [:html_input, :pow]) - if changeset2.valid? do - :ok - else - {:error, field: :email, message: "is not a valid email"} - end - end + if changeset2.valid? do + :ok + else + {:error, field: :member_email, message: "is not a valid email"} + end + end, + where: [present(:member_email)] end attributes do uuid_v7_primary_key :id attribute :first_name, :string do - allow_nil? false - constraints min_length: 1 + allow_nil? true end attribute :last_name, :string do - allow_nil? false - constraints min_length: 1 + allow_nil? true end - attribute :email, :string do - allow_nil? false + # Internal email field for members without users + attribute :member_email, :string do + allow_nil? true constraints min_length: 5, max_length: 254 end @@ -170,5 +165,10 @@ defmodule Mv.Membership.Member do relationships do has_many :properties, Mv.Membership.Property + has_one :user, Mv.Accounts.User, destination_attribute: :member_id + end + + calculations do + calculate :email, :string, Mv.Membership.MemberEmailCalculation end end diff --git a/lib/membership/member_email_calculation.ex b/lib/membership/member_email_calculation.ex new file mode 100644 index 0000000..0f577d2 --- /dev/null +++ b/lib/membership/member_email_calculation.ex @@ -0,0 +1,27 @@ +defmodule Mv.Membership.MemberEmailCalculation do + use Ash.Resource.Calculation + + @impl true + def load(_query, _opts, _context) do + # We need member_email and user.email + [:member_email, user: [:email]] + end + + @impl true + def calculate(records, _opts, _context) do + Enum.map(records, fn record -> + case record.user do + %{email: user_email} -> + # Convert Ash.CiString to string if needed + if is_struct(user_email, Ash.CiString) do + to_string(user_email) + else + user_email + end + + _ -> + record.member_email + end + end) + end +end diff --git a/lib/membership/membership.ex b/lib/membership/membership.ex index 0c7c14d..afed432 100644 --- a/lib/membership/membership.ex +++ b/lib/membership/membership.ex @@ -10,6 +10,7 @@ defmodule Mv.Membership do resource Mv.Membership.Member do define :create_member, action: :create_member define :list_members, action: :read + define :get_member!, action: :read, get_by: [:id] define :update_member, action: :update_member define :destroy_member, action: :destroy end diff --git a/mix.lock b/mix.lock index 2060db6..4392c65 100644 --- a/mix.lock +++ b/mix.lock @@ -60,7 +60,7 @@ "phoenix_view": {:hex, :phoenix_view, "2.0.4", "b45c9d9cf15b3a1af5fb555c674b525391b6a1fe975f040fb4d913397b31abf4", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "4e992022ce14f31fe57335db27a28154afcc94e9983266835bb3040243eb620b"}, "plug": {:hex, :plug, "1.18.1", "5067f26f7745b7e31bc3368bc1a2b818b9779faa959b49c934c17730efc911cf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "57a57db70df2b422b564437d2d33cf8d33cd16339c1edb190cd11b1a3a546cc2"}, "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, - "postgrex": {:hex, :postgrex, "0.20.0", "363ed03ab4757f6bc47942eff7720640795eb557e1935951c1626f0d303a3aed", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "d36ef8b36f323d29505314f704e21a1a038e2dc387c6409ee0cd24144e187c0f"}, + "postgrex": {:hex, :postgrex, "0.21.1", "2c5cc830ec11e7a0067dd4d623c049b3ef807e9507a424985b8dcf921224cd88", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "27d8d21c103c3cc68851b533ff99eef353e6a0ff98dc444ea751de43eb48bdac"}, "reactor": {:hex, :reactor, "0.15.6", "d717f9add549b25a089a94c90197718d2d838e35d81dd776b1d81587d4cf2aaa", [:mix], [{:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:libgraph, "~> 0.16", [hex: :libgraph, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}, {:ymlr, "~> 5.0", [hex: :ymlr, repo: "hexpm", optional: false]}], "hexpm", "74db98165e3644d86e0f723672d91ceca4339eaa935bcad7e78bf146a46d77b9"}, "req": {:hex, :req, "0.5.15", "662020efb6ea60b9f0e0fac9be88cd7558b53fe51155a2d9899de594f9906ba9", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "a6513a35fad65467893ced9785457e91693352c70b58bbc045b47e5eb2ef0c53"}, "rewrite": {:hex, :rewrite, "1.1.2", "f5a5d10f5fed1491a6ff48e078d4585882695962ccc9e6c779bae025d1f92eda", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "7f8b94b1e3528d0a47b3e8b7bfeca559d2948a65fa7418a9ad7d7712703d39d4"}, diff --git a/priv/repo/migrations/20250724161006_add_unique_member_id.exs b/priv/repo/migrations/20250724161006_add_unique_member_id.exs new file mode 100644 index 0000000..973b957 --- /dev/null +++ b/priv/repo/migrations/20250724161006_add_unique_member_id.exs @@ -0,0 +1,17 @@ +defmodule Mv.Repo.Migrations.AddUniqueMemberId do + @moduledoc """ + Updates resources based on their most recent snapshots. + + This file was autogenerated with `mix ash_postgres.generate_migrations` + """ + + use Ecto.Migration + + def up do + create unique_index(:users, [:member_id], name: "users_unique_member_id_index") + end + + def down do + drop_if_exists unique_index(:users, [:member_id], name: "users_unique_member_id_index") + end +end diff --git a/priv/repo/migrations/20250805131958_add_member_email_to_members.exs b/priv/repo/migrations/20250805131958_add_member_email_to_members.exs new file mode 100644 index 0000000..24ce69b --- /dev/null +++ b/priv/repo/migrations/20250805131958_add_member_email_to_members.exs @@ -0,0 +1,25 @@ +defmodule Mv.Repo.Migrations.AddMemberEmailToMembers do + @moduledoc """ + Updates resources based on their most recent snapshots. + + This file was autogenerated with `mix ash_postgres.generate_migrations` + """ + + use Ecto.Migration + + def up do + rename table(:members), :email, to: :member_email + + alter table(:members) do + modify :member_email, :text, null: true + end + end + + def down do + alter table(:members) do + modify :email, :text, null: false + end + + rename table(:members), :member_email, to: :email + end +end diff --git a/priv/repo/migrations/20250805151226_update_member_constraints.exs b/priv/repo/migrations/20250805151226_update_member_constraints.exs new file mode 100644 index 0000000..ec123ca --- /dev/null +++ b/priv/repo/migrations/20250805151226_update_member_constraints.exs @@ -0,0 +1,23 @@ +defmodule Mv.Repo.Migrations.UpdateMemberConstraints do + @moduledoc """ + Updates resources based on their most recent snapshots. + + This file was autogenerated with `mix ash_postgres.generate_migrations` + """ + + use Ecto.Migration + + def up do + alter table(:members) do + modify :last_name, :text, null: true + modify :first_name, :text, null: true + end + end + + def down do + alter table(:members) do + modify :first_name, :text, null: false + modify :last_name, :text, null: false + end + end +end diff --git a/priv/resource_snapshots/repo/members/20250805131958.json b/priv/resource_snapshots/repo/members/20250805131958.json new file mode 100644 index 0000000..86cc148 --- /dev/null +++ b/priv/resource_snapshots/repo/members/20250805131958.json @@ -0,0 +1,187 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"uuid_generate_v7()\")", + "generated?": false, + "precision": null, + "primary_key?": true, + "references": null, + "scale": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "first_name", + "type": "text" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "last_name", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "member_email", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "birth_date", + "type": "date" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "paid", + "type": "boolean" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "phone_number", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "join_date", + "type": "date" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "exit_date", + "type": "date" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "notes", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "city", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "street", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "house_number", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "postal_code", + "type": "text" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "F783CF3A9A24DD4D635BD2820236F3DB9A95F7FA6EBA94A3C15A3F054D579999", + "identities": [], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.Mv.Repo", + "schema": null, + "table": "members" +} \ No newline at end of file diff --git a/priv/resource_snapshots/repo/members/20250805151226.json b/priv/resource_snapshots/repo/members/20250805151226.json new file mode 100644 index 0000000..563f3b6 --- /dev/null +++ b/priv/resource_snapshots/repo/members/20250805151226.json @@ -0,0 +1,187 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"uuid_generate_v7()\")", + "generated?": false, + "precision": null, + "primary_key?": true, + "references": null, + "scale": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "first_name", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "last_name", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "member_email", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "birth_date", + "type": "date" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "paid", + "type": "boolean" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "phone_number", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "join_date", + "type": "date" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "exit_date", + "type": "date" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "notes", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "city", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "street", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "house_number", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "postal_code", + "type": "text" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "A287EF28CB6E052DB408E8045D54E43CED1252820DD43D261E26E1DDF5CA7966", + "identities": [], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.Mv.Repo", + "schema": null, + "table": "members" +} \ No newline at end of file diff --git a/test/accounts/empty_member_creation_test.exs b/test/accounts/empty_member_creation_test.exs new file mode 100644 index 0000000..8044354 --- /dev/null +++ b/test/accounts/empty_member_creation_test.exs @@ -0,0 +1,52 @@ +defmodule Mv.Accounts.EmptyMemberCreationTest do + use Mv.DataCase, async: true + alias Mv.Accounts + alias Mv.Membership + + describe "Empty Member Creation" do + test "can create completely empty member" do + {:ok, member} = Membership.create_member(%{}) + + assert member.id + assert member.first_name == nil + assert member.last_name == nil + assert member.member_email == nil + assert member.birth_date == nil + assert member.paid == nil + assert member.phone_number == nil + assert member.join_date == nil + assert member.exit_date == nil + assert member.notes == nil + assert member.city == nil + assert member.street == nil + assert member.house_number == nil + assert member.postal_code == nil + end + + test "user creation creates empty member automatically" do + {:ok, user} = Accounts.create_user(%{email: "test@example.com"}) + + # Reload user to get the member_id + user = Ash.reload!(user, domain: Mv.Accounts) + assert user.member_id + + # Get the created member + member = Ash.get!(Mv.Membership.Member, user.member_id, domain: Mv.Membership) + + # Member should be mostly empty except for email + assert member.member_email == "test@example.com" + assert member.first_name == nil + assert member.last_name == nil + assert member.birth_date == nil + assert member.paid == nil + assert member.phone_number == nil + assert member.join_date == nil + assert member.exit_date == nil + assert member.notes == nil + assert member.city == nil + assert member.street == nil + assert member.house_number == nil + assert member.postal_code == nil + end + end +end diff --git a/test/accounts/password_email_identification_test.exs b/test/accounts/password_email_identification_test.exs new file mode 100644 index 0000000..db93187 --- /dev/null +++ b/test/accounts/password_email_identification_test.exs @@ -0,0 +1,80 @@ +defmodule Mv.Accounts.PasswordEmailIdentificationTest do + use Mv.DataCase, async: true + + describe "Password Email Identification" do + test "user can sign in with email and password" do + # Get the password strategy + strategy = AshAuthentication.Info.strategy!(Mv.Accounts.User, :password) + + # Create a user with password + {:ok, user} = + AshAuthentication.Strategy.action(strategy, :register, %{ + "email" => "test@example.com", + "password" => "password123" + }) + + # Sign in with email + {:ok, signed_in_user} = + AshAuthentication.Strategy.action(strategy, :sign_in, %{ + "email" => "test@example.com", + "password" => "password123" + }) + + assert signed_in_user.id == user.id + assert to_string(signed_in_user.email) == "test@example.com" + end + + test "sign in fails with wrong password" do + # Get the password strategy + strategy = AshAuthentication.Info.strategy!(Mv.Accounts.User, :password) + + # Create a user with password + {:ok, _user} = + AshAuthentication.Strategy.action(strategy, :register, %{ + "email" => "test2@example.com", + "password" => "password123" + }) + + # Try to sign in with wrong password + {:error, _} = + AshAuthentication.Strategy.action(strategy, :sign_in, %{ + "email" => "test2@example.com", + "password" => "wrongpassword" + }) + end + + test "sign in fails with non-existent email" do + # Get the password strategy + strategy = AshAuthentication.Info.strategy!(Mv.Accounts.User, :password) + + # Try to sign in with non-existent email + {:error, _} = + AshAuthentication.Strategy.action(strategy, :sign_in, %{ + "email" => "nonexistent@example.com", + "password" => "password123" + }) + end + + test "user gets member automatically created during registration" do + # Get the password strategy + strategy = AshAuthentication.Info.strategy!(Mv.Accounts.User, :password) + + # Register a user + {:ok, user} = + AshAuthentication.Strategy.action(strategy, :register, %{ + "email" => "member@example.com", + "password" => "password123" + }) + + # Reload user to get member_id + user = Ash.reload!(user, domain: Mv.Accounts) + + # User should have a member + assert user.member_id + + # Member should have the same email + member = Ash.get!(Mv.Membership.Member, user.member_id, domain: Mv.Membership) + assert member.member_email == "member@example.com" + end + end +end diff --git a/test/accounts/user_member_integration_test.exs b/test/accounts/user_member_integration_test.exs new file mode 100644 index 0000000..4352448 --- /dev/null +++ b/test/accounts/user_member_integration_test.exs @@ -0,0 +1,71 @@ +defmodule Mv.Accounts.UserMemberIntegrationTest do + use Mv.DataCase, async: true + alias Mv.Accounts + alias Mv.Membership + alias Mv.Accounts.User.MemberCreationNotifier + + describe "User-Member-Relation" do + test "ein User kann einem Member zugeordnet werden" do + {:ok, member} = + Membership.create_member(%{ + member_email: "max@example.com" + }) + + {:ok, user} = Accounts.create_user(%{email: "user1@example.com", member_id: member.id}) + assert user.member_id == member.id + end + + test "ein Member kann nur einem User zugeordnet werden (unique constraint)" do + {:ok, member} = + Membership.create_member(%{ + member_email: "anna@example.com" + }) + + {:ok, user1} = Accounts.create_user(%{email: "user2@example.com", member_id: member.id}) + assert user1.member_id == member.id + + {:error, %Ash.Error.Invalid{errors: errors}} = + Accounts.create_user(%{email: "user3@example.com", member_id: member.id}) + + assert Enum.any?(errors, fn error -> + error.message =~ "already been taken" or error.field == :member_id + end) + end + + test "ein User ohne Member ist nicht erlaubt (bei Registrierung/Erstellung)" do + # Create user without member first + result = Accounts.create_user(%{email: "user4@example.com"}) + + case result do + {:ok, user} -> + # User is created but doesn't have member yet + assert user.member_id == nil + + # Manually trigger the notifier to simulate automatic member creation + notification = %Ash.Notifier.Notification{ + action: %{name: :create_user}, + resource: Mv.Accounts.User, + data: user + } + + {:ok, _updated_user} = MemberCreationNotifier.notify(notification) + + # Reload user and verify member was created and assigned + user = Ash.reload!(user, domain: Mv.Accounts) + assert user.member_id, "User should have a member_id assigned after notifier" + + {:error, _} -> + flunk("User creation should succeed") + end + end + + test "ein Member kann ohne User existieren" do + {:ok, member} = + Membership.create_member(%{ + member_email: "lisa@example.com" + }) + + assert member.id + end + end +end diff --git a/test/membership/member_email_test.exs b/test/membership/member_email_test.exs new file mode 100644 index 0000000..3a37eff --- /dev/null +++ b/test/membership/member_email_test.exs @@ -0,0 +1,99 @@ +defmodule Mv.Membership.MemberEmailTest do + use Mv.DataCase, async: true + alias Mv.Membership + + describe "member_email and computed email field" do + test "email shows member_email when no user is assigned" do + {:ok, member} = + Membership.create_member(%{ + member_email: "memberonly@example.com" + }) + + # Load the email calculation + member = Ash.load!(member, :email, domain: Membership) + + assert member.member_email == "memberonly@example.com" + assert member.email == "memberonly@example.com" + end + + test "updating member_email updates the computed email when no user is assigned" do + {:ok, member} = + Membership.create_member(%{ + member_email: "old@example.com" + }) + + {:ok, member} = + Membership.update_member(member, %{member_email: "new@example.com"}) + + # Load the email calculation + member = Ash.load!(member, :email, domain: Membership) + + assert member.member_email == "new@example.com" + assert member.email == "new@example.com" + end + + test "member can be created without member_email" do + {:ok, member} = + Membership.create_member(%{}) + + # Load the email calculation with user relationship + member = Ash.load!(member, [:email, user: [:email]], domain: Membership) + + assert member.member_email == nil + assert member.email == nil + end + + test "email shows user.email when user is assigned" do + # Create a member first + {:ok, member} = + Membership.create_member(%{ + member_email: "member@example.com" + }) + + # Create a user and assign it to the member + {:ok, _user} = + Mv.Accounts.create_user(%{ + email: "user@example.com", + member_id: member.id + }) + + # Load the email calculation with user relationship + member = Ash.load!(member, [:email, user: [:email]], domain: Membership) + + assert member.member_email == "member@example.com" + # Should show user email + assert member.email == "user@example.com" + end + + test "email updates when user email changes" do + # Create a member + {:ok, member} = + Membership.create_member(%{ + member_email: "member@example.com" + }) + + # Create a user + {:ok, user} = + Mv.Accounts.create_user(%{ + email: "old@example.com", + member_id: member.id + }) + + # Load the email calculation initially + member = Ash.load!(member, [:email, user: [:email]], domain: Membership) + assert member.email == "old@example.com" + + # Update user email + {:ok, _updated_user} = + Mv.Accounts.update_user(user, %{ + email: "new@example.com" + }) + + # Reload member and check email calculation + member = Ash.reload!(member, domain: Membership) + member = Ash.load!(member, [:email, user: [:email]], domain: Membership) + + assert member.email == "new@example.com" + end + end +end diff --git a/test/membership/member_test.exs b/test/membership/member_test.exs index 7015d34..37e4a9d 100644 --- a/test/membership/member_test.exs +++ b/test/membership/member_test.exs @@ -8,7 +8,7 @@ defmodule Mv.Membership.MemberTest do last_name: "Doe", birth_date: ~D[1990-01-01], paid: true, - email: "john@example.com", + member_email: "john@example.com", phone_number: "+49123456789", join_date: ~D[2020-01-01], exit_date: nil, @@ -19,28 +19,25 @@ defmodule Mv.Membership.MemberTest do postal_code: "12345" } - test "First name is required and must not be empty" do - attrs = Map.put(@valid_attrs, :first_name, "") - assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs) - assert error_message(errors, :first_name) =~ "must be present" + test "First name is optional" do + attrs = Map.delete(@valid_attrs, :first_name) + assert {:ok, _member} = Membership.create_member(attrs) end - test "Last name is required and must not be empty" do - attrs = Map.put(@valid_attrs, :last_name, "") - assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs) - assert error_message(errors, :last_name) =~ "must be present" + test "Last name is optional" do + attrs = Map.delete(@valid_attrs, :last_name) + assert {:ok, _member} = Membership.create_member(attrs) end - test "Email is required" do - attrs = Map.put(@valid_attrs, :email, "") - assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs) - assert error_message(errors, :email) =~ "must be present" + test "Email is optional" do + attrs = Map.delete(@valid_attrs, :member_email) + assert {:ok, _member} = Membership.create_member(attrs) end - test "Email must be valid" do - attrs = Map.put(@valid_attrs, :email, "test@") + test "Email must be valid if provided" do + attrs = Map.put(@valid_attrs, :member_email, "test@") assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs) - assert error_message(errors, :email) =~ "is not a valid email" + assert error_message(errors, :member_email) =~ "is not a valid email" end test "Birth date is optional but must not be in the future" do