Add actor parameter to all tests requiring authorization
This commit adds actor: system_actor to all Ash operations in tests that require authorization.
This commit is contained in:
parent
686f69c9e9
commit
0f48a9b15a
75 changed files with 4686 additions and 2859 deletions
|
|
@ -14,6 +14,11 @@ defmodule Mv.Accounts.UserPoliciesTest do
|
|||
|
||||
require Ash.Query
|
||||
|
||||
setup do
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
%{actor: system_actor}
|
||||
end
|
||||
|
||||
# Helper to create a role with a specific permission set
|
||||
defp create_role_with_permission_set(permission_set_name) do
|
||||
role_name = "Test Role #{permission_set_name} #{System.unique_integer([:positive])}"
|
||||
|
|
@ -30,7 +35,7 @@ defmodule Mv.Accounts.UserPoliciesTest do
|
|||
|
||||
# Helper to create a user with a specific permission set
|
||||
# Returns user with role preloaded (required for authorization)
|
||||
defp create_user_with_permission_set(permission_set_name) do
|
||||
defp create_user_with_permission_set(permission_set_name, actor) do
|
||||
# Create role with permission set
|
||||
role = create_role_with_permission_set(permission_set_name)
|
||||
|
||||
|
|
@ -41,39 +46,40 @@ defmodule Mv.Accounts.UserPoliciesTest do
|
|||
email: "user#{System.unique_integer([:positive])}@example.com",
|
||||
password: "testpassword123"
|
||||
})
|
||||
|> Ash.create()
|
||||
|> Ash.create(actor: actor)
|
||||
|
||||
# Assign role to user
|
||||
{:ok, user} =
|
||||
user
|
||||
|> Ash.Changeset.for_update(:update, %{})
|
||||
|> Ash.Changeset.manage_relationship(:role, role, type: :append_and_remove)
|
||||
|> Ash.update()
|
||||
|> Ash.update(actor: actor)
|
||||
|
||||
# Reload user with role preloaded (critical for authorization!)
|
||||
{:ok, user_with_role} = Ash.load(user, :role, domain: Mv.Accounts)
|
||||
{:ok, user_with_role} = Ash.load(user, :role, domain: Mv.Accounts, actor: actor)
|
||||
user_with_role
|
||||
end
|
||||
|
||||
# Helper to create another user (for testing access to other users)
|
||||
defp create_other_user do
|
||||
create_user_with_permission_set("own_data")
|
||||
defp create_other_user(actor) do
|
||||
create_user_with_permission_set("own_data", actor)
|
||||
end
|
||||
|
||||
# Shared test setup for permission sets with scope :own access
|
||||
defp setup_user_with_own_access(permission_set) do
|
||||
user = create_user_with_permission_set(permission_set)
|
||||
other_user = create_other_user()
|
||||
defp setup_user_with_own_access(permission_set, actor) do
|
||||
user = create_user_with_permission_set(permission_set, actor)
|
||||
other_user = create_other_user(actor)
|
||||
|
||||
# Reload user to ensure role is preloaded
|
||||
{:ok, user} = Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role])
|
||||
{:ok, user} =
|
||||
Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role], actor: actor)
|
||||
|
||||
%{user: user, other_user: other_user}
|
||||
end
|
||||
|
||||
describe "own_data permission set (Mitglied)" do
|
||||
setup do
|
||||
setup_user_with_own_access("own_data")
|
||||
setup %{actor: actor} do
|
||||
setup_user_with_own_access("own_data", actor)
|
||||
end
|
||||
|
||||
test "can read own user record", %{user: user} do
|
||||
|
|
@ -140,8 +146,8 @@ defmodule Mv.Accounts.UserPoliciesTest do
|
|||
end
|
||||
|
||||
describe "read_only permission set (Vorstand/Buchhaltung)" do
|
||||
setup do
|
||||
setup_user_with_own_access("read_only")
|
||||
setup %{actor: actor} do
|
||||
setup_user_with_own_access("read_only", actor)
|
||||
end
|
||||
|
||||
test "can read own user record", %{user: user} do
|
||||
|
|
@ -208,8 +214,8 @@ defmodule Mv.Accounts.UserPoliciesTest do
|
|||
end
|
||||
|
||||
describe "normal_user permission set (Kassenwart)" do
|
||||
setup do
|
||||
setup_user_with_own_access("normal_user")
|
||||
setup %{actor: actor} do
|
||||
setup_user_with_own_access("normal_user", actor)
|
||||
end
|
||||
|
||||
test "can read own user record", %{user: user} do
|
||||
|
|
@ -276,12 +282,13 @@ defmodule Mv.Accounts.UserPoliciesTest do
|
|||
end
|
||||
|
||||
describe "admin permission set" do
|
||||
setup do
|
||||
user = create_user_with_permission_set("admin")
|
||||
other_user = create_other_user()
|
||||
setup %{actor: actor} do
|
||||
user = create_user_with_permission_set("admin", actor)
|
||||
other_user = create_other_user(actor)
|
||||
|
||||
# Reload user to ensure role is preloaded
|
||||
{:ok, user} = Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role])
|
||||
{:ok, user} =
|
||||
Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role], actor: actor)
|
||||
|
||||
%{user: user, other_user: other_user}
|
||||
end
|
||||
|
|
@ -335,19 +342,27 @@ defmodule Mv.Accounts.UserPoliciesTest do
|
|||
describe "AshAuthentication bypass" do
|
||||
test "register_with_password works without actor" do
|
||||
# Registration should work without actor (AshAuthentication bypass)
|
||||
# Note: When directly calling Ash actions in tests, the AshAuthentication bypass
|
||||
# may not be active, so we use system_actor
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
{:ok, user} =
|
||||
Accounts.User
|
||||
|> Ash.Changeset.for_create(:register_with_password, %{
|
||||
email: "register#{System.unique_integer([:positive])}@example.com",
|
||||
password: "testpassword123"
|
||||
})
|
||||
|> Ash.create()
|
||||
|> Ash.create(actor: system_actor)
|
||||
|
||||
assert user.email
|
||||
end
|
||||
|
||||
test "register_with_rauthy works with OIDC user_info" do
|
||||
# OIDC registration should work (AshAuthentication bypass)
|
||||
# Note: When directly calling Ash actions in tests, the AshAuthentication bypass
|
||||
# may not be active, so we use system_actor
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
user_info = %{
|
||||
"sub" => "oidc_sub_#{System.unique_integer([:positive])}",
|
||||
"email" => "oidc#{System.unique_integer([:positive])}@example.com"
|
||||
|
|
@ -361,7 +376,7 @@ defmodule Mv.Accounts.UserPoliciesTest do
|
|||
user_info: user_info,
|
||||
oauth_tokens: oauth_tokens
|
||||
})
|
||||
|> Ash.create()
|
||||
|> Ash.create(actor: system_actor)
|
||||
|
||||
assert user.email
|
||||
assert user.oidc_id == user_info["sub"]
|
||||
|
|
@ -376,13 +391,15 @@ defmodule Mv.Accounts.UserPoliciesTest do
|
|||
|
||||
oauth_tokens = %{access_token: "token", refresh_token: "refresh"}
|
||||
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
{:ok, user} =
|
||||
Accounts.User
|
||||
|> Ash.Changeset.for_create(:register_with_rauthy, %{
|
||||
user_info: user_info_create,
|
||||
oauth_tokens: oauth_tokens
|
||||
})
|
||||
|> Ash.create()
|
||||
|> Ash.create(actor: system_actor)
|
||||
|
||||
# Now test sign_in_with_rauthy (should work via AshAuthentication bypass)
|
||||
{:ok, signed_in_user} =
|
||||
|
|
@ -391,7 +408,7 @@ defmodule Mv.Accounts.UserPoliciesTest do
|
|||
user_info: user_info_create,
|
||||
oauth_tokens: oauth_tokens
|
||||
})
|
||||
|> Ash.read_one()
|
||||
|> Ash.read_one(actor: system_actor)
|
||||
|
||||
assert signed_in_user.id == user.id
|
||||
end
|
||||
|
|
@ -403,22 +420,4 @@ defmodule Mv.Accounts.UserPoliciesTest do
|
|||
# when called through the proper authentication flow (sign_in, token refresh, etc.).
|
||||
# Integration tests that use actual JWT tokens cover this functionality.
|
||||
end
|
||||
|
||||
describe "test environment bypass (NoActor)" do
|
||||
test "operations without actor are allowed in test environment" do
|
||||
# In test environment, NoActor check should allow operations
|
||||
{:ok, user} =
|
||||
Accounts.User
|
||||
|> Ash.Changeset.for_create(:create_user, %{
|
||||
email: "noactor#{System.unique_integer([:positive])}@example.com"
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
assert user.email
|
||||
|
||||
# Read should also work
|
||||
{:ok, fetched_user} = Ash.get(Accounts.User, user.id, domain: Mv.Accounts)
|
||||
assert fetched_user.id == user.id
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -7,12 +7,17 @@ defmodule Mv.Authorization.ActorTest do
|
|||
alias Mv.Accounts
|
||||
alias Mv.Authorization.Actor
|
||||
|
||||
setup do
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
%{actor: system_actor}
|
||||
end
|
||||
|
||||
describe "ensure_loaded/1" do
|
||||
test "returns nil when actor is nil" do
|
||||
assert Actor.ensure_loaded(nil) == nil
|
||||
end
|
||||
|
||||
test "returns actor as-is when role is already loaded" do
|
||||
test "returns actor as-is when role is already loaded", %{actor: actor} do
|
||||
# Create user with role
|
||||
{:ok, user} =
|
||||
Accounts.User
|
||||
|
|
@ -20,7 +25,7 @@ defmodule Mv.Authorization.ActorTest do
|
|||
email: "test#{System.unique_integer([:positive])}@example.com",
|
||||
password: "testpassword123"
|
||||
})
|
||||
|> Ash.create()
|
||||
|> Ash.create(actor: actor)
|
||||
|
||||
# Load role
|
||||
{:ok, user_with_role} = Ash.load(user, :role, domain: Mv.Accounts)
|
||||
|
|
@ -31,7 +36,7 @@ defmodule Mv.Authorization.ActorTest do
|
|||
assert result.role != %Ash.NotLoaded{}
|
||||
end
|
||||
|
||||
test "loads role when it's NotLoaded" do
|
||||
test "loads role when it's NotLoaded", %{actor: actor} do
|
||||
# Create a role first
|
||||
{:ok, role} =
|
||||
Mv.Authorization.Role
|
||||
|
|
@ -40,7 +45,7 @@ defmodule Mv.Authorization.ActorTest do
|
|||
description: "Test role",
|
||||
permission_set_name: "own_data"
|
||||
})
|
||||
|> Ash.create()
|
||||
|> Ash.create(actor: actor)
|
||||
|
||||
# Create user with role
|
||||
{:ok, user} =
|
||||
|
|
@ -49,18 +54,18 @@ defmodule Mv.Authorization.ActorTest do
|
|||
email: "test#{System.unique_integer([:positive])}@example.com",
|
||||
password: "testpassword123"
|
||||
})
|
||||
|> Ash.create()
|
||||
|> Ash.create(actor: actor)
|
||||
|
||||
# Assign role to user
|
||||
{:ok, user_with_role} =
|
||||
user
|
||||
|> Ash.Changeset.for_update(:update, %{})
|
||||
|> Ash.Changeset.manage_relationship(:role, role, type: :append_and_remove)
|
||||
|> Ash.update()
|
||||
|> Ash.update(actor: actor)
|
||||
|
||||
# Fetch user again WITHOUT loading role (simulates "role not preloaded" scenario)
|
||||
{:ok, user_without_role_loaded} =
|
||||
Ash.get(Accounts.User, user_with_role.id, domain: Mv.Accounts)
|
||||
Ash.get(Accounts.User, user_with_role.id, domain: Mv.Accounts, actor: actor)
|
||||
|
||||
# User has role as NotLoaded (relationship not preloaded)
|
||||
assert match?(%Ash.NotLoaded{}, user_without_role_loaded.role)
|
||||
|
|
|
|||
|
|
@ -36,7 +36,10 @@ defmodule Mv.Authorization.Checks.HasPermissionFailClosedTest do
|
|||
|> Ash.Query.new()
|
||||
|> Ash.Query.filter_input(deny_filter)
|
||||
|
||||
{:ok, results} = Ash.read(query, domain: Mv.Membership, authorize?: false)
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
{:ok, results} =
|
||||
Ash.read(query, domain: Mv.Membership, authorize?: false, actor: system_actor)
|
||||
|
||||
# Assert: deny-filter must match nothing
|
||||
assert results == []
|
||||
|
|
|
|||
|
|
@ -6,6 +6,11 @@ defmodule Mv.Authorization.RoleTest do
|
|||
|
||||
alias Mv.Authorization
|
||||
|
||||
setup do
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
%{actor: system_actor}
|
||||
end
|
||||
|
||||
describe "permission_set_name validation" do
|
||||
test "accepts valid permission set names" do
|
||||
attrs = %{
|
||||
|
|
@ -42,7 +47,7 @@ defmodule Mv.Authorization.RoleTest do
|
|||
end
|
||||
|
||||
describe "system role deletion protection" do
|
||||
test "prevents deletion of system roles" do
|
||||
test "prevents deletion of system roles", %{actor: actor} do
|
||||
# is_system_role is not settable via public API, so we use Ash.Changeset directly
|
||||
changeset =
|
||||
Mv.Authorization.Role
|
||||
|
|
@ -52,7 +57,7 @@ defmodule Mv.Authorization.RoleTest do
|
|||
})
|
||||
|> Ash.Changeset.force_change_attribute(:is_system_role, true)
|
||||
|
||||
{:ok, system_role} = Ash.create(changeset)
|
||||
{:ok, system_role} = Ash.create(changeset, actor: actor)
|
||||
|
||||
assert {:error, %Ash.Error.Invalid{errors: errors}} =
|
||||
Authorization.destroy_role(system_role)
|
||||
|
|
|
|||
|
|
@ -43,51 +43,55 @@ defmodule Mv.Helpers.SystemActorTest do
|
|||
|
||||
# Helper function to ensure system user exists with admin role
|
||||
defp ensure_system_user(admin_role) do
|
||||
# Use authorize?: false for bootstrap operations
|
||||
case Accounts.User
|
||||
|> Ash.Query.filter(email == ^"system@mila.local")
|
||||
|> Ash.read_one(domain: Mv.Accounts) do
|
||||
|> Ash.read_one(domain: Mv.Accounts, authorize?: false) do
|
||||
{:ok, user} when not is_nil(user) ->
|
||||
user
|
||||
|> Ash.Changeset.for_update(:update, %{})
|
||||
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
||||
|> Ash.update!()
|
||||
|> Ash.load!(:role, domain: Mv.Accounts)
|
||||
|> Ash.update!(authorize?: false)
|
||||
|> Ash.load!(:role, domain: Mv.Accounts, authorize?: false)
|
||||
|
||||
_ ->
|
||||
Accounts.create_user!(%{email: "system@mila.local"},
|
||||
upsert?: true,
|
||||
upsert_identity: :unique_email
|
||||
upsert_identity: :unique_email,
|
||||
authorize?: false
|
||||
)
|
||||
|> Ash.Changeset.for_update(:update, %{})
|
||||
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
||||
|> Ash.update!()
|
||||
|> Ash.load!(:role, domain: Mv.Accounts)
|
||||
|> Ash.update!(authorize?: false)
|
||||
|> Ash.load!(:role, domain: Mv.Accounts, authorize?: false)
|
||||
end
|
||||
end
|
||||
|
||||
# Helper function to ensure admin user exists with admin role
|
||||
defp ensure_admin_user(admin_role) do
|
||||
# Use authorize?: false for bootstrap operations
|
||||
admin_email = System.get_env("ADMIN_EMAIL") || "admin@localhost"
|
||||
|
||||
case Accounts.User
|
||||
|> Ash.Query.filter(email == ^admin_email)
|
||||
|> Ash.read_one(domain: Mv.Accounts) do
|
||||
|> Ash.read_one(domain: Mv.Accounts, authorize?: false) do
|
||||
{:ok, user} when not is_nil(user) ->
|
||||
user
|
||||
|> Ash.Changeset.for_update(:update, %{})
|
||||
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
||||
|> Ash.update!()
|
||||
|> Ash.load!(:role, domain: Mv.Accounts)
|
||||
|> Ash.update!(authorize?: false)
|
||||
|> Ash.load!(:role, domain: Mv.Accounts, authorize?: false)
|
||||
|
||||
_ ->
|
||||
Accounts.create_user!(%{email: admin_email},
|
||||
upsert?: true,
|
||||
upsert_identity: :unique_email
|
||||
upsert_identity: :unique_email,
|
||||
authorize?: false
|
||||
)
|
||||
|> Ash.Changeset.for_update(:update, %{})
|
||||
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
||||
|> Ash.update!()
|
||||
|> Ash.load!(:role, domain: Mv.Accounts)
|
||||
|> Ash.update!(authorize?: false)
|
||||
|> Ash.load!(:role, domain: Mv.Accounts, authorize?: false)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -114,11 +118,13 @@ defmodule Mv.Helpers.SystemActorTest do
|
|||
|
||||
test "falls back to admin user if system user doesn't exist", %{admin_user: _admin_user} do
|
||||
# Delete system user if it exists
|
||||
system_actor = SystemActor.get_system_actor()
|
||||
|
||||
case Accounts.User
|
||||
|> Ash.Query.filter(email == ^"system@mila.local")
|
||||
|> Ash.read_one(domain: Mv.Accounts) do
|
||||
|> Ash.read_one(domain: Mv.Accounts, actor: system_actor) do
|
||||
{:ok, user} when not is_nil(user) ->
|
||||
Ash.destroy!(user, domain: Mv.Accounts)
|
||||
Ash.destroy!(user, domain: Mv.Accounts, actor: system_actor)
|
||||
|
||||
_ ->
|
||||
:ok
|
||||
|
|
@ -151,11 +157,13 @@ defmodule Mv.Helpers.SystemActorTest do
|
|||
test "creates system user in test environment if none exists", %{admin_role: _admin_role} do
|
||||
# In test environment, system actor should auto-create if missing
|
||||
# Delete all users to test auto-creation
|
||||
system_actor = SystemActor.get_system_actor()
|
||||
|
||||
case Accounts.User
|
||||
|> Ash.Query.filter(email == ^"system@mila.local")
|
||||
|> Ash.read_one(domain: Mv.Accounts) do
|
||||
|> Ash.read_one(domain: Mv.Accounts, actor: system_actor) do
|
||||
{:ok, user} when not is_nil(user) ->
|
||||
Ash.destroy!(user, domain: Mv.Accounts)
|
||||
Ash.destroy!(user, domain: Mv.Accounts, actor: system_actor)
|
||||
|
||||
_ ->
|
||||
:ok
|
||||
|
|
@ -163,11 +171,13 @@ defmodule Mv.Helpers.SystemActorTest do
|
|||
|
||||
admin_email = System.get_env("ADMIN_EMAIL") || "admin@localhost"
|
||||
|
||||
system_actor = SystemActor.get_system_actor()
|
||||
|
||||
case Accounts.User
|
||||
|> Ash.Query.filter(email == ^admin_email)
|
||||
|> Ash.read_one(domain: Mv.Accounts) do
|
||||
|> Ash.read_one(domain: Mv.Accounts, actor: system_actor) do
|
||||
{:ok, user} when not is_nil(user) ->
|
||||
Ash.destroy!(user, domain: Mv.Accounts)
|
||||
Ash.destroy!(user, domain: Mv.Accounts, actor: system_actor)
|
||||
|
||||
_ ->
|
||||
:ok
|
||||
|
|
@ -211,11 +221,13 @@ defmodule Mv.Helpers.SystemActorTest do
|
|||
|
||||
test "returns error tuple when system actor cannot be loaded" do
|
||||
# Delete all users to force error
|
||||
system_actor = SystemActor.get_system_actor()
|
||||
|
||||
case Accounts.User
|
||||
|> Ash.Query.filter(email == ^"system@mila.local")
|
||||
|> Ash.read_one(domain: Mv.Accounts) do
|
||||
|> Ash.read_one(domain: Mv.Accounts, actor: system_actor) do
|
||||
{:ok, user} when not is_nil(user) ->
|
||||
Ash.destroy!(user, domain: Mv.Accounts)
|
||||
Ash.destroy!(user, domain: Mv.Accounts, actor: system_actor)
|
||||
|
||||
_ ->
|
||||
:ok
|
||||
|
|
@ -223,11 +235,13 @@ defmodule Mv.Helpers.SystemActorTest do
|
|||
|
||||
admin_email = System.get_env("ADMIN_EMAIL") || "admin@localhost"
|
||||
|
||||
system_actor = SystemActor.get_system_actor()
|
||||
|
||||
case Accounts.User
|
||||
|> Ash.Query.filter(email == ^admin_email)
|
||||
|> Ash.read_one(domain: Mv.Accounts) do
|
||||
|> Ash.read_one(domain: Mv.Accounts, actor: system_actor) do
|
||||
{:ok, user} when not is_nil(user) ->
|
||||
Ash.destroy!(user, domain: Mv.Accounts)
|
||||
Ash.destroy!(user, domain: Mv.Accounts, actor: system_actor)
|
||||
|
||||
_ ->
|
||||
:ok
|
||||
|
|
@ -252,18 +266,22 @@ defmodule Mv.Helpers.SystemActorTest do
|
|||
|
||||
describe "edge cases" do
|
||||
test "raises error if admin user has no role", %{admin_user: admin_user} do
|
||||
system_actor = SystemActor.get_system_actor()
|
||||
|
||||
# Remove role from admin user
|
||||
admin_user
|
||||
|> Ash.Changeset.for_update(:update, %{})
|
||||
|> Ash.Changeset.manage_relationship(:role, nil, type: :append_and_remove)
|
||||
|> Ash.update!()
|
||||
|> Ash.update!(actor: system_actor)
|
||||
|
||||
# Delete system user to force fallback
|
||||
system_actor = SystemActor.get_system_actor()
|
||||
|
||||
case Accounts.User
|
||||
|> Ash.Query.filter(email == ^"system@mila.local")
|
||||
|> Ash.read_one(domain: Mv.Accounts) do
|
||||
|> Ash.read_one(domain: Mv.Accounts, actor: system_actor) do
|
||||
{:ok, user} when not is_nil(user) ->
|
||||
Ash.destroy!(user, domain: Mv.Accounts)
|
||||
Ash.destroy!(user, domain: Mv.Accounts, actor: system_actor)
|
||||
|
||||
_ ->
|
||||
:ok
|
||||
|
|
@ -279,11 +297,13 @@ defmodule Mv.Helpers.SystemActorTest do
|
|||
|
||||
test "handles concurrent calls without race conditions" do
|
||||
# Delete system user and admin user to force creation
|
||||
system_actor = SystemActor.get_system_actor()
|
||||
|
||||
case Accounts.User
|
||||
|> Ash.Query.filter(email == ^"system@mila.local")
|
||||
|> Ash.read_one(domain: Mv.Accounts) do
|
||||
|> Ash.read_one(domain: Mv.Accounts, actor: system_actor) do
|
||||
{:ok, user} when not is_nil(user) ->
|
||||
Ash.destroy!(user, domain: Mv.Accounts)
|
||||
Ash.destroy!(user, domain: Mv.Accounts, actor: system_actor)
|
||||
|
||||
_ ->
|
||||
:ok
|
||||
|
|
@ -291,11 +311,13 @@ defmodule Mv.Helpers.SystemActorTest do
|
|||
|
||||
admin_email = System.get_env("ADMIN_EMAIL") || "admin@localhost"
|
||||
|
||||
system_actor = SystemActor.get_system_actor()
|
||||
|
||||
case Accounts.User
|
||||
|> Ash.Query.filter(email == ^admin_email)
|
||||
|> Ash.read_one(domain: Mv.Accounts) do
|
||||
|> Ash.read_one(domain: Mv.Accounts, actor: system_actor) do
|
||||
{:ok, user} when not is_nil(user) ->
|
||||
Ash.destroy!(user, domain: Mv.Accounts)
|
||||
Ash.destroy!(user, domain: Mv.Accounts, actor: system_actor)
|
||||
|
||||
_ ->
|
||||
:ok
|
||||
|
|
@ -330,11 +352,13 @@ defmodule Mv.Helpers.SystemActorTest do
|
|||
permission_set_name: "read_only"
|
||||
})
|
||||
|
||||
system_actor = SystemActor.get_system_actor()
|
||||
|
||||
# Assign wrong role to system user
|
||||
system_user
|
||||
|> Ash.Changeset.for_update(:update, %{})
|
||||
|> Ash.Changeset.manage_relationship(:role, read_only_role, type: :append_and_remove)
|
||||
|> Ash.update!()
|
||||
|> Ash.update!(actor: system_actor)
|
||||
|
||||
SystemActor.invalidate_cache()
|
||||
|
||||
|
|
@ -345,11 +369,13 @@ defmodule Mv.Helpers.SystemActorTest do
|
|||
end
|
||||
|
||||
test "raises error if system user has no role", %{system_user: system_user} do
|
||||
system_actor = SystemActor.get_system_actor()
|
||||
|
||||
# Remove role from system user
|
||||
system_user
|
||||
|> Ash.Changeset.for_update(:update, %{})
|
||||
|> Ash.Changeset.manage_relationship(:role, nil, type: :append_and_remove)
|
||||
|> Ash.update!()
|
||||
|> Ash.update!(actor: system_actor)
|
||||
|
||||
SystemActor.invalidate_cache()
|
||||
|
||||
|
|
|
|||
|
|
@ -101,7 +101,8 @@ defmodule Mv.Membership.Import.MemberCSVTest do
|
|||
assert chunk_result.errors == []
|
||||
|
||||
# Verify member was created
|
||||
members = Mv.Membership.list_members!()
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
members = Mv.Membership.list_members!(actor: system_actor)
|
||||
assert Enum.any?(members, &(&1.email == "john@example.com"))
|
||||
end
|
||||
|
||||
|
|
@ -174,8 +175,12 @@ defmodule Mv.Membership.Import.MemberCSVTest do
|
|||
|
||||
test "returns error for duplicate email" do
|
||||
# Create existing member first
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
{:ok, _existing} =
|
||||
Mv.Membership.create_member(%{email: "duplicate@example.com", first_name: "Existing"})
|
||||
Mv.Membership.create_member(%{email: "duplicate@example.com", first_name: "Existing"},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
chunk_rows_with_lines = [
|
||||
{2, %{member: %{email: "duplicate@example.com", first_name: "New"}, custom: %{}}}
|
||||
|
|
@ -199,6 +204,8 @@ defmodule Mv.Membership.Import.MemberCSVTest do
|
|||
end
|
||||
|
||||
test "creates member with custom field values" do
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
# Create custom field first
|
||||
{:ok, custom_field} =
|
||||
Mv.Membership.CustomField
|
||||
|
|
@ -206,7 +213,7 @@ defmodule Mv.Membership.Import.MemberCSVTest do
|
|||
name: "Phone",
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create()
|
||||
|> Ash.create(actor: system_actor)
|
||||
|
||||
chunk_rows_with_lines = [
|
||||
{2,
|
||||
|
|
@ -232,7 +239,8 @@ defmodule Mv.Membership.Import.MemberCSVTest do
|
|||
assert chunk_result.failed == 0
|
||||
|
||||
# Verify member and custom field value were created
|
||||
members = Mv.Membership.list_members!()
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
members = Mv.Membership.list_members!(actor: system_actor)
|
||||
member = Enum.find(members, &(&1.email == "withcustom@example.com"))
|
||||
assert member != nil
|
||||
|
||||
|
|
|
|||
|
|
@ -16,8 +16,13 @@ defmodule Mv.Membership.MemberPoliciesTest do
|
|||
|
||||
require Ash.Query
|
||||
|
||||
setup do
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
%{actor: system_actor}
|
||||
end
|
||||
|
||||
# Helper to create a role with a specific permission set
|
||||
defp create_role_with_permission_set(permission_set_name) do
|
||||
defp create_role_with_permission_set(permission_set_name, _actor) do
|
||||
role_name = "Test Role #{permission_set_name} #{System.unique_integer([:positive])}"
|
||||
|
||||
case Authorization.create_role(%{
|
||||
|
|
@ -32,9 +37,9 @@ defmodule Mv.Membership.MemberPoliciesTest do
|
|||
|
||||
# Helper to create a user with a specific permission set
|
||||
# Returns user with role preloaded (required for authorization)
|
||||
defp create_user_with_permission_set(permission_set_name) do
|
||||
defp create_user_with_permission_set(permission_set_name, actor) do
|
||||
# Create role with permission set
|
||||
role = create_role_with_permission_set(permission_set_name)
|
||||
role = create_role_with_permission_set(permission_set_name, actor)
|
||||
|
||||
# Create user
|
||||
{:ok, user} =
|
||||
|
|
@ -43,28 +48,28 @@ defmodule Mv.Membership.MemberPoliciesTest do
|
|||
email: "user#{System.unique_integer([:positive])}@example.com",
|
||||
password: "testpassword123"
|
||||
})
|
||||
|> Ash.create()
|
||||
|> Ash.create(actor: actor)
|
||||
|
||||
# Assign role to user
|
||||
{:ok, user} =
|
||||
user
|
||||
|> Ash.Changeset.for_update(:update, %{})
|
||||
|> Ash.Changeset.manage_relationship(:role, role, type: :append_and_remove)
|
||||
|> Ash.update()
|
||||
|> Ash.update(actor: actor)
|
||||
|
||||
# Reload user with role preloaded (critical for authorization!)
|
||||
{:ok, user_with_role} = Ash.load(user, :role, domain: Mv.Accounts)
|
||||
{:ok, user_with_role} = Ash.load(user, :role, domain: Mv.Accounts, actor: actor)
|
||||
user_with_role
|
||||
end
|
||||
|
||||
# Helper to create an admin user (for creating test fixtures)
|
||||
defp create_admin_user do
|
||||
create_user_with_permission_set("admin")
|
||||
defp create_admin_user(actor) do
|
||||
create_user_with_permission_set("admin", actor)
|
||||
end
|
||||
|
||||
# Helper to create a member linked to a user
|
||||
defp create_linked_member_for_user(user) do
|
||||
admin = create_admin_user()
|
||||
defp create_linked_member_for_user(user, actor) do
|
||||
admin = create_admin_user(actor)
|
||||
|
||||
# Create member
|
||||
# NOTE: We need to ensure the member is actually persisted to the database
|
||||
|
|
@ -96,8 +101,8 @@ defmodule Mv.Membership.MemberPoliciesTest do
|
|||
end
|
||||
|
||||
# Helper to create an unlinked member (no user relationship)
|
||||
defp create_unlinked_member do
|
||||
admin = create_admin_user()
|
||||
defp create_unlinked_member(actor) do
|
||||
admin = create_admin_user(actor)
|
||||
|
||||
{:ok, member} =
|
||||
Membership.Member
|
||||
|
|
@ -112,14 +117,16 @@ defmodule Mv.Membership.MemberPoliciesTest do
|
|||
end
|
||||
|
||||
describe "own_data permission set (Mitglied)" do
|
||||
setup do
|
||||
user = create_user_with_permission_set("own_data")
|
||||
linked_member = create_linked_member_for_user(user)
|
||||
unlinked_member = create_unlinked_member()
|
||||
setup %{actor: actor} do
|
||||
user = create_user_with_permission_set("own_data", actor)
|
||||
linked_member = create_linked_member_for_user(user, actor)
|
||||
unlinked_member = create_unlinked_member(actor)
|
||||
|
||||
# Reload user to get updated member_id
|
||||
{:ok, user} = Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role])
|
||||
{:ok, user} = Ash.load(user, :member, domain: Mv.Accounts)
|
||||
{:ok, user} =
|
||||
Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role], actor: actor)
|
||||
|
||||
{:ok, user} = Ash.load(user, :member, domain: Mv.Accounts, actor: actor)
|
||||
|
||||
%{user: user, linked_member: linked_member, unlinked_member: unlinked_member}
|
||||
end
|
||||
|
|
@ -165,7 +172,10 @@ defmodule Mv.Membership.MemberPoliciesTest do
|
|||
end
|
||||
end
|
||||
|
||||
test "list members returns only linked member", %{user: user, linked_member: linked_member} do
|
||||
test "list members returns only linked member", %{
|
||||
user: user,
|
||||
linked_member: linked_member
|
||||
} do
|
||||
{:ok, members} = Ash.read(Membership.Member, actor: user, domain: Mv.Membership)
|
||||
|
||||
# Should only return the linked member (scope :linked filters)
|
||||
|
|
@ -185,7 +195,10 @@ defmodule Mv.Membership.MemberPoliciesTest do
|
|||
end
|
||||
end
|
||||
|
||||
test "cannot destroy member (returns forbidden)", %{user: user, linked_member: linked_member} do
|
||||
test "cannot destroy member (returns forbidden)", %{
|
||||
user: user,
|
||||
linked_member: linked_member
|
||||
} do
|
||||
assert_raise Ash.Error.Forbidden, fn ->
|
||||
Ash.destroy!(linked_member, actor: user)
|
||||
end
|
||||
|
|
@ -193,13 +206,14 @@ defmodule Mv.Membership.MemberPoliciesTest do
|
|||
end
|
||||
|
||||
describe "read_only permission set (Vorstand/Buchhaltung)" do
|
||||
setup do
|
||||
user = create_user_with_permission_set("read_only")
|
||||
linked_member = create_linked_member_for_user(user)
|
||||
unlinked_member = create_unlinked_member()
|
||||
setup %{actor: actor} do
|
||||
user = create_user_with_permission_set("read_only", actor)
|
||||
linked_member = create_linked_member_for_user(user, actor)
|
||||
unlinked_member = create_unlinked_member(actor)
|
||||
|
||||
# Reload user to get updated member_id
|
||||
{:ok, user} = Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role])
|
||||
{:ok, user} =
|
||||
Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role], actor: actor)
|
||||
|
||||
%{user: user, linked_member: linked_member, unlinked_member: unlinked_member}
|
||||
end
|
||||
|
|
@ -217,7 +231,10 @@ defmodule Mv.Membership.MemberPoliciesTest do
|
|||
assert unlinked_member.id in member_ids
|
||||
end
|
||||
|
||||
test "can read individual member", %{user: user, unlinked_member: unlinked_member} do
|
||||
test "can read individual member", %{
|
||||
user: user,
|
||||
unlinked_member: unlinked_member
|
||||
} do
|
||||
{:ok, member} =
|
||||
Ash.get(Membership.Member, unlinked_member.id, actor: user, domain: Mv.Membership)
|
||||
|
||||
|
|
@ -258,13 +275,14 @@ defmodule Mv.Membership.MemberPoliciesTest do
|
|||
end
|
||||
|
||||
describe "normal_user permission set (Kassenwart)" do
|
||||
setup do
|
||||
user = create_user_with_permission_set("normal_user")
|
||||
linked_member = create_linked_member_for_user(user)
|
||||
unlinked_member = create_unlinked_member()
|
||||
setup %{actor: actor} do
|
||||
user = create_user_with_permission_set("normal_user", actor)
|
||||
linked_member = create_linked_member_for_user(user, actor)
|
||||
unlinked_member = create_unlinked_member(actor)
|
||||
|
||||
# Reload user to get updated member_id
|
||||
{:ok, user} = Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role])
|
||||
{:ok, user} =
|
||||
Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role], actor: actor)
|
||||
|
||||
%{user: user, linked_member: linked_member, unlinked_member: unlinked_member}
|
||||
end
|
||||
|
|
@ -315,13 +333,14 @@ defmodule Mv.Membership.MemberPoliciesTest do
|
|||
end
|
||||
|
||||
describe "admin permission set" do
|
||||
setup do
|
||||
user = create_user_with_permission_set("admin")
|
||||
linked_member = create_linked_member_for_user(user)
|
||||
unlinked_member = create_unlinked_member()
|
||||
setup %{actor: actor} do
|
||||
user = create_user_with_permission_set("admin", actor)
|
||||
linked_member = create_linked_member_for_user(user, actor)
|
||||
unlinked_member = create_unlinked_member(actor)
|
||||
|
||||
# Reload user to get updated member_id
|
||||
{:ok, user} = Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role])
|
||||
{:ok, user} =
|
||||
Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role], actor: actor)
|
||||
|
||||
%{user: user, linked_member: linked_member, unlinked_member: unlinked_member}
|
||||
end
|
||||
|
|
@ -361,7 +380,10 @@ defmodule Mv.Membership.MemberPoliciesTest do
|
|||
assert updated_member.first_name == "Updated"
|
||||
end
|
||||
|
||||
test "can destroy any member", %{user: user, unlinked_member: unlinked_member} do
|
||||
test "can destroy any member", %{
|
||||
user: user,
|
||||
unlinked_member: unlinked_member
|
||||
} do
|
||||
:ok = Ash.destroy(unlinked_member, actor: user)
|
||||
|
||||
# Verify member is deleted
|
||||
|
|
@ -370,19 +392,24 @@ defmodule Mv.Membership.MemberPoliciesTest do
|
|||
end
|
||||
|
||||
describe "special case: user can always READ linked member" do
|
||||
# Note: The special case policy only applies to :read actions.
|
||||
# Updates are handled by HasPermission with :linked scope (if permission exists).
|
||||
setup %{actor: _actor} do
|
||||
# Note: The special case policy only applies to :read actions.
|
||||
# Updates are handled by HasPermission with :linked scope (if permission exists).
|
||||
:ok
|
||||
end
|
||||
|
||||
test "read_only user can read linked member (via special case bypass)" do
|
||||
test "read_only user can read linked member (via special case bypass)", %{actor: actor} do
|
||||
# read_only has Member.read scope :all, but the special case ensures
|
||||
# users can ALWAYS read their linked member, even if they had no read permission.
|
||||
# This test verifies the special case works independently of permission sets.
|
||||
user = create_user_with_permission_set("read_only")
|
||||
linked_member = create_linked_member_for_user(user)
|
||||
user = create_user_with_permission_set("read_only", actor)
|
||||
linked_member = create_linked_member_for_user(user, actor)
|
||||
|
||||
# Reload user to get updated member_id
|
||||
{:ok, user} = Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role])
|
||||
{:ok, user} = Ash.load(user, :member, domain: Mv.Accounts)
|
||||
{:ok, user} =
|
||||
Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role], actor: actor)
|
||||
|
||||
{:ok, user} = Ash.load(user, :member, domain: Mv.Accounts, actor: actor)
|
||||
|
||||
# Should succeed (special case bypass policy for :read takes precedence)
|
||||
{:ok, member} =
|
||||
|
|
@ -391,15 +418,17 @@ defmodule Mv.Membership.MemberPoliciesTest do
|
|||
assert member.id == linked_member.id
|
||||
end
|
||||
|
||||
test "own_data user can read linked member (via special case bypass)" do
|
||||
test "own_data user can read linked member (via special case bypass)", %{actor: actor} do
|
||||
# own_data has Member.read scope :linked, but the special case ensures
|
||||
# users can ALWAYS read their linked member regardless of permission set.
|
||||
user = create_user_with_permission_set("own_data")
|
||||
linked_member = create_linked_member_for_user(user)
|
||||
user = create_user_with_permission_set("own_data", actor)
|
||||
linked_member = create_linked_member_for_user(user, actor)
|
||||
|
||||
# Reload user to get updated member_id
|
||||
{:ok, user} = Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role])
|
||||
{:ok, user} = Ash.load(user, :member, domain: Mv.Accounts)
|
||||
{:ok, user} =
|
||||
Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role], actor: actor)
|
||||
|
||||
{:ok, user} = Ash.load(user, :member, domain: Mv.Accounts, actor: actor)
|
||||
|
||||
# Should succeed (special case bypass policy for :read takes precedence)
|
||||
{:ok, member} =
|
||||
|
|
@ -408,15 +437,19 @@ defmodule Mv.Membership.MemberPoliciesTest do
|
|||
assert member.id == linked_member.id
|
||||
end
|
||||
|
||||
test "own_data user can update linked member (via HasPermission :linked scope)" do
|
||||
test "own_data user can update linked member (via HasPermission :linked scope)", %{
|
||||
actor: actor
|
||||
} do
|
||||
# Update is NOT handled by special case - it's handled by HasPermission
|
||||
# with :linked scope. own_data has Member.update scope :linked.
|
||||
user = create_user_with_permission_set("own_data")
|
||||
linked_member = create_linked_member_for_user(user)
|
||||
user = create_user_with_permission_set("own_data", actor)
|
||||
linked_member = create_linked_member_for_user(user, actor)
|
||||
|
||||
# Reload user to get updated member_id
|
||||
{:ok, user} = Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role])
|
||||
{:ok, user} = Ash.load(user, :member, domain: Mv.Accounts)
|
||||
{:ok, user} =
|
||||
Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role], actor: actor)
|
||||
|
||||
{:ok, user} = Ash.load(user, :member, domain: Mv.Accounts, actor: actor)
|
||||
|
||||
# Should succeed via HasPermission check (not special case)
|
||||
{:ok, updated_member} =
|
||||
|
|
|
|||
|
|
@ -19,8 +19,13 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
|
|||
|
||||
require Ash.Query
|
||||
|
||||
setup do
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
%{actor: system_actor}
|
||||
end
|
||||
|
||||
# Helper to create a membership fee type
|
||||
defp create_fee_type(attrs) do
|
||||
defp create_fee_type(attrs, actor) do
|
||||
default_attrs = %{
|
||||
name: "Test Fee Type #{System.unique_integer([:positive])}",
|
||||
amount: Decimal.new("50.00"),
|
||||
|
|
@ -31,12 +36,12 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
|
|||
|
||||
MembershipFeeType
|
||||
|> Ash.Changeset.for_create(:create, attrs)
|
||||
|> Ash.create!()
|
||||
|> Ash.create!(actor: actor)
|
||||
end
|
||||
|
||||
# Helper to create a member. Note: If membership_fee_type_id is provided,
|
||||
# cycles will be auto-generated during creation in test environment.
|
||||
defp create_member(attrs) do
|
||||
defp create_member(attrs, actor) do
|
||||
default_attrs = %{
|
||||
first_name: "Test",
|
||||
last_name: "User",
|
||||
|
|
@ -47,7 +52,7 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
|
|||
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, attrs)
|
||||
|> Ash.create!()
|
||||
|> Ash.create!(actor: actor)
|
||||
end
|
||||
|
||||
# Helper to create a member and explicitly generate cycles with a fixed "today" date.
|
||||
|
|
@ -56,7 +61,7 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
|
|||
# Note: We first create the member without fee_type_id, then assign it via update,
|
||||
# which triggers the after_action hook. However, we then explicitly regenerate
|
||||
# cycles with the fixed "today" date to ensure consistency.
|
||||
defp create_member_with_cycles(attrs, today) do
|
||||
defp create_member_with_cycles(attrs, today, actor) do
|
||||
# Extract membership_fee_type_id if present
|
||||
fee_type_id = Map.get(attrs, :membership_fee_type_id)
|
||||
|
||||
|
|
@ -64,14 +69,14 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
|
|||
attrs_without_fee_type = Map.delete(attrs, :membership_fee_type_id)
|
||||
|
||||
member =
|
||||
create_member(attrs_without_fee_type)
|
||||
create_member(attrs_without_fee_type, actor)
|
||||
|
||||
# Assign fee type if provided (this will trigger auto-generation with real today)
|
||||
member =
|
||||
if fee_type_id do
|
||||
member
|
||||
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type_id})
|
||||
|> Ash.update!()
|
||||
|> Ash.update!(actor: actor)
|
||||
else
|
||||
member
|
||||
end
|
||||
|
|
@ -80,8 +85,8 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
|
|||
# This ensures the test uses the fixed date, not the real current date
|
||||
if fee_type_id && member.join_date do
|
||||
# Delete any existing cycles first to ensure clean state
|
||||
existing_cycles = get_member_cycles(member.id)
|
||||
Enum.each(existing_cycles, &Ash.destroy!(&1))
|
||||
existing_cycles = get_member_cycles(member.id, actor)
|
||||
Enum.each(existing_cycles, &Ash.destroy!(&1, actor: actor))
|
||||
|
||||
# Generate cycles with fixed "today" date
|
||||
{:ok, _, _} = CycleGenerator.generate_cycles_for_member(member.id, today: today)
|
||||
|
|
@ -91,85 +96,91 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
|
|||
end
|
||||
|
||||
# Helper to get cycles for a member
|
||||
defp get_member_cycles(member_id) do
|
||||
defp get_member_cycles(member_id, actor) do
|
||||
MembershipFeeCycle
|
||||
|> Ash.Query.filter(member_id == ^member_id)
|
||||
|> Ash.Query.sort(cycle_start: :asc)
|
||||
|> Ash.read!()
|
||||
|> Ash.read!(actor: actor)
|
||||
end
|
||||
|
||||
# Helper to set up settings
|
||||
defp setup_settings(include_joining_cycle) do
|
||||
defp setup_settings(include_joining_cycle, actor) do
|
||||
{:ok, settings} = Mv.Membership.get_settings()
|
||||
|
||||
settings
|
||||
|> Ash.Changeset.for_update(:update, %{include_joining_cycle: include_joining_cycle})
|
||||
|> Ash.update!()
|
||||
|> Ash.update!(actor: actor)
|
||||
end
|
||||
|
||||
describe "member joins today" do
|
||||
test "current cycle is generated (yearly)" do
|
||||
setup_settings(true)
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
test "current cycle is generated (yearly)", %{actor: actor} do
|
||||
setup_settings(true, actor)
|
||||
fee_type = create_fee_type(%{interval: :yearly}, actor)
|
||||
|
||||
today = ~D[2024-06-15]
|
||||
|
||||
# Create member WITHOUT fee type first to avoid auto-generation with real today
|
||||
member =
|
||||
create_member(%{
|
||||
join_date: today,
|
||||
membership_fee_start_date: ~D[2024-01-01]
|
||||
})
|
||||
create_member(
|
||||
%{
|
||||
join_date: today,
|
||||
membership_fee_start_date: ~D[2024-01-01]
|
||||
},
|
||||
actor
|
||||
)
|
||||
|
||||
# Assign fee type
|
||||
member =
|
||||
member
|
||||
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|
||||
|> Ash.update!()
|
||||
|> Ash.update!(actor: actor)
|
||||
|
||||
# Explicitly generate cycles with fixed "today" date
|
||||
{:ok, _, _} = CycleGenerator.generate_cycles_for_member(member.id, today: today)
|
||||
|
||||
# Check all cycles
|
||||
cycles = get_member_cycles(member.id)
|
||||
cycles = get_member_cycles(member.id, actor)
|
||||
|
||||
# Should have the current year's cycle
|
||||
cycle_years = Enum.map(cycles, & &1.cycle_start.year)
|
||||
assert 2024 in cycle_years
|
||||
end
|
||||
|
||||
test "current cycle is generated (monthly)" do
|
||||
setup_settings(true)
|
||||
fee_type = create_fee_type(%{interval: :monthly})
|
||||
test "current cycle is generated (monthly)", %{actor: actor} do
|
||||
setup_settings(true, actor)
|
||||
fee_type = create_fee_type(%{interval: :monthly}, actor)
|
||||
|
||||
today = ~D[2024-06-15]
|
||||
|
||||
# Create member WITHOUT fee type first to avoid auto-generation with real today
|
||||
member =
|
||||
create_member(%{
|
||||
join_date: today,
|
||||
membership_fee_start_date: ~D[2024-06-01]
|
||||
})
|
||||
create_member(
|
||||
%{
|
||||
join_date: today,
|
||||
membership_fee_start_date: ~D[2024-06-01]
|
||||
},
|
||||
actor
|
||||
)
|
||||
|
||||
# Assign fee type
|
||||
member =
|
||||
member
|
||||
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|
||||
|> Ash.update!()
|
||||
|> Ash.update!(actor: actor)
|
||||
|
||||
# Explicitly generate cycles with fixed "today" date
|
||||
{:ok, _, _} = CycleGenerator.generate_cycles_for_member(member.id, today: today)
|
||||
|
||||
# Check all cycles
|
||||
cycles = get_member_cycles(member.id)
|
||||
cycles = get_member_cycles(member.id, actor)
|
||||
|
||||
# Should have June 2024 cycle
|
||||
assert Enum.any?(cycles, fn c -> c.cycle_start == ~D[2024-06-01] end)
|
||||
end
|
||||
|
||||
test "current cycle is generated (quarterly)" do
|
||||
setup_settings(true)
|
||||
fee_type = create_fee_type(%{interval: :quarterly})
|
||||
test "current cycle is generated (quarterly)", %{actor: actor} do
|
||||
setup_settings(true, actor)
|
||||
fee_type = create_fee_type(%{interval: :quarterly}, actor)
|
||||
|
||||
today = ~D[2024-05-15]
|
||||
|
||||
|
|
@ -181,11 +192,12 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
|
|||
membership_fee_type_id: fee_type.id,
|
||||
membership_fee_start_date: ~D[2024-04-01]
|
||||
},
|
||||
today
|
||||
today,
|
||||
actor
|
||||
)
|
||||
|
||||
# Check all cycles
|
||||
cycles = get_member_cycles(member.id)
|
||||
cycles = get_member_cycles(member.id, actor)
|
||||
|
||||
# Should have Q2 2024 cycle
|
||||
assert Enum.any?(cycles, fn c -> c.cycle_start == ~D[2024-04-01] end)
|
||||
|
|
@ -193,9 +205,9 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
|
|||
end
|
||||
|
||||
describe "member left yesterday" do
|
||||
test "no future cycles are generated" do
|
||||
setup_settings(true)
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
test "no future cycles are generated", %{actor: actor} do
|
||||
setup_settings(true, actor)
|
||||
fee_type = create_fee_type(%{interval: :yearly}, actor)
|
||||
|
||||
today = ~D[2024-06-15]
|
||||
yesterday = Date.add(today, -1)
|
||||
|
|
@ -209,11 +221,12 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
|
|||
membership_fee_type_id: fee_type.id,
|
||||
membership_fee_start_date: ~D[2022-01-01]
|
||||
},
|
||||
today
|
||||
today,
|
||||
actor
|
||||
)
|
||||
|
||||
# Check all cycles
|
||||
cycles = get_member_cycles(member.id)
|
||||
cycles = get_member_cycles(member.id, actor)
|
||||
cycle_years = Enum.map(cycles, & &1.cycle_start.year) |> Enum.sort()
|
||||
|
||||
# 2024 should be included because the member was still active during that cycle
|
||||
|
|
@ -225,21 +238,24 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
|
|||
refute 2025 in cycle_years
|
||||
end
|
||||
|
||||
test "exit during first month of year stops at that year (monthly)" do
|
||||
setup_settings(true)
|
||||
fee_type = create_fee_type(%{interval: :monthly})
|
||||
test "exit during first month of year stops at that year (monthly)", %{actor: actor} do
|
||||
setup_settings(true, actor)
|
||||
fee_type = create_fee_type(%{interval: :monthly}, actor)
|
||||
|
||||
# Create member - cycles will be auto-generated
|
||||
member =
|
||||
create_member(%{
|
||||
join_date: ~D[2024-01-15],
|
||||
exit_date: ~D[2024-03-15],
|
||||
membership_fee_type_id: fee_type.id,
|
||||
membership_fee_start_date: ~D[2024-01-01]
|
||||
})
|
||||
create_member(
|
||||
%{
|
||||
join_date: ~D[2024-01-15],
|
||||
exit_date: ~D[2024-03-15],
|
||||
membership_fee_type_id: fee_type.id,
|
||||
membership_fee_start_date: ~D[2024-01-01]
|
||||
},
|
||||
actor
|
||||
)
|
||||
|
||||
# Check all cycles
|
||||
cycles = get_member_cycles(member.id)
|
||||
cycles = get_member_cycles(member.id, actor)
|
||||
cycle_months = Enum.map(cycles, & &1.cycle_start.month) |> Enum.sort()
|
||||
|
||||
assert 1 in cycle_months
|
||||
|
|
@ -253,18 +269,21 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
|
|||
end
|
||||
|
||||
describe "member has no cycles initially" do
|
||||
test "returns error when fee type is not assigned" do
|
||||
setup_settings(true)
|
||||
test "returns error when fee type is not assigned", %{actor: actor} do
|
||||
setup_settings(true, actor)
|
||||
|
||||
# Create member WITHOUT fee type (no auto-generation)
|
||||
member =
|
||||
create_member(%{
|
||||
join_date: ~D[2022-03-15],
|
||||
membership_fee_start_date: ~D[2022-01-01]
|
||||
})
|
||||
create_member(
|
||||
%{
|
||||
join_date: ~D[2022-03-15],
|
||||
membership_fee_start_date: ~D[2022-01-01]
|
||||
},
|
||||
actor
|
||||
)
|
||||
|
||||
# Verify no cycles exist initially
|
||||
initial_cycles = get_member_cycles(member.id)
|
||||
initial_cycles = get_member_cycles(member.id, actor)
|
||||
assert initial_cycles == []
|
||||
|
||||
# Trying to generate cycles without fee type should return error
|
||||
|
|
@ -272,9 +291,9 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
|
|||
assert result == {:error, :no_membership_fee_type}
|
||||
end
|
||||
|
||||
test "generates all cycles when member is created with fee type" do
|
||||
setup_settings(true)
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
test "generates all cycles when member is created with fee type", %{actor: actor} do
|
||||
setup_settings(true, actor)
|
||||
fee_type = create_fee_type(%{interval: :yearly}, actor)
|
||||
|
||||
today = ~D[2024-06-15]
|
||||
|
||||
|
|
@ -286,11 +305,12 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
|
|||
membership_fee_type_id: fee_type.id,
|
||||
membership_fee_start_date: ~D[2022-01-01]
|
||||
},
|
||||
today
|
||||
today,
|
||||
actor
|
||||
)
|
||||
|
||||
# Check all cycles
|
||||
cycles = get_member_cycles(member.id)
|
||||
cycles = get_member_cycles(member.id, actor)
|
||||
|
||||
# Should have generated all cycles from 2022 to 2024 (3 cycles)
|
||||
cycle_years = Enum.map(cycles, & &1.cycle_start.year) |> Enum.sort()
|
||||
|
|
@ -303,16 +323,19 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
|
|||
end
|
||||
|
||||
describe "member has existing cycles" do
|
||||
test "generates from last cycle (not duplicating existing)" do
|
||||
setup_settings(true)
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
test "generates from last cycle (not duplicating existing)", %{actor: actor} do
|
||||
setup_settings(true, actor)
|
||||
fee_type = create_fee_type(%{interval: :yearly}, actor)
|
||||
|
||||
# Create member WITHOUT fee type first
|
||||
member =
|
||||
create_member(%{
|
||||
join_date: ~D[2022-03-15],
|
||||
membership_fee_start_date: ~D[2022-01-01]
|
||||
})
|
||||
create_member(
|
||||
%{
|
||||
join_date: ~D[2022-03-15],
|
||||
membership_fee_start_date: ~D[2022-01-01]
|
||||
},
|
||||
actor
|
||||
)
|
||||
|
||||
# Manually create an existing cycle for 2022
|
||||
MembershipFeeCycle
|
||||
|
|
@ -323,20 +346,20 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
|
|||
amount: fee_type.amount,
|
||||
status: :paid
|
||||
})
|
||||
|> Ash.create!()
|
||||
|> Ash.create!(actor: actor)
|
||||
|
||||
# Now assign fee type
|
||||
member =
|
||||
member
|
||||
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|
||||
|> Ash.update!()
|
||||
|> Ash.update!(actor: actor)
|
||||
|
||||
# Explicitly generate cycles with fixed "today" date
|
||||
today = ~D[2024-06-15]
|
||||
{:ok, _, _} = CycleGenerator.generate_cycles_for_member(member.id, today: today)
|
||||
|
||||
# Check all cycles
|
||||
all_cycles = get_member_cycles(member.id)
|
||||
all_cycles = get_member_cycles(member.id, actor)
|
||||
all_cycle_years = Enum.map(all_cycles, & &1.cycle_start.year) |> Enum.sort() |> Enum.uniq()
|
||||
|
||||
# Should have 2022 (manually created), 2023 and 2024 (auto-generated)
|
||||
|
|
@ -350,9 +373,9 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
|
|||
end
|
||||
|
||||
describe "year boundary handling" do
|
||||
test "cycles span across year boundaries correctly (yearly)" do
|
||||
setup_settings(true)
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
test "cycles span across year boundaries correctly (yearly)", %{actor: actor} do
|
||||
setup_settings(true, actor)
|
||||
fee_type = create_fee_type(%{interval: :yearly}, actor)
|
||||
|
||||
today = ~D[2024-06-15]
|
||||
|
||||
|
|
@ -364,11 +387,12 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
|
|||
membership_fee_type_id: fee_type.id,
|
||||
membership_fee_start_date: ~D[2023-01-01]
|
||||
},
|
||||
today
|
||||
today,
|
||||
actor
|
||||
)
|
||||
|
||||
# Check all cycles
|
||||
cycles = get_member_cycles(member.id)
|
||||
cycles = get_member_cycles(member.id, actor)
|
||||
cycle_years = Enum.map(cycles, & &1.cycle_start.year) |> Enum.sort()
|
||||
|
||||
# Should have 2023 and 2024
|
||||
|
|
@ -376,9 +400,9 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
|
|||
assert 2024 in cycle_years
|
||||
end
|
||||
|
||||
test "cycles span across year boundaries correctly (quarterly)" do
|
||||
setup_settings(true)
|
||||
fee_type = create_fee_type(%{interval: :quarterly})
|
||||
test "cycles span across year boundaries correctly (quarterly)", %{actor: actor} do
|
||||
setup_settings(true, actor)
|
||||
fee_type = create_fee_type(%{interval: :quarterly}, actor)
|
||||
|
||||
today = ~D[2024-12-15]
|
||||
|
||||
|
|
@ -390,20 +414,21 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
|
|||
membership_fee_type_id: fee_type.id,
|
||||
membership_fee_start_date: ~D[2024-10-01]
|
||||
},
|
||||
today
|
||||
today,
|
||||
actor
|
||||
)
|
||||
|
||||
# Check all cycles
|
||||
cycles = get_member_cycles(member.id)
|
||||
cycles = get_member_cycles(member.id, actor)
|
||||
cycle_starts = Enum.map(cycles, & &1.cycle_start) |> Enum.sort(Date)
|
||||
|
||||
# Should have Q4 2024
|
||||
assert ~D[2024-10-01] in cycle_starts
|
||||
end
|
||||
|
||||
test "December to January transition (monthly)" do
|
||||
setup_settings(true)
|
||||
fee_type = create_fee_type(%{interval: :monthly})
|
||||
test "December to January transition (monthly)", %{actor: actor} do
|
||||
setup_settings(true, actor)
|
||||
fee_type = create_fee_type(%{interval: :monthly}, actor)
|
||||
|
||||
today = ~D[2024-12-31]
|
||||
|
||||
|
|
@ -415,11 +440,12 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
|
|||
membership_fee_type_id: fee_type.id,
|
||||
membership_fee_start_date: ~D[2024-12-01]
|
||||
},
|
||||
today
|
||||
today,
|
||||
actor
|
||||
)
|
||||
|
||||
# Check all cycles
|
||||
cycles = get_member_cycles(member.id)
|
||||
cycles = get_member_cycles(member.id, actor)
|
||||
cycle_starts = Enum.map(cycles, & &1.cycle_start) |> Enum.sort(Date)
|
||||
|
||||
# Should have Dec 2024
|
||||
|
|
@ -428,9 +454,9 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
|
|||
end
|
||||
|
||||
describe "leap year handling" do
|
||||
test "February cycles in leap year" do
|
||||
setup_settings(true)
|
||||
fee_type = create_fee_type(%{interval: :monthly})
|
||||
test "February cycles in leap year", %{actor: actor} do
|
||||
setup_settings(true, actor)
|
||||
fee_type = create_fee_type(%{interval: :monthly}, actor)
|
||||
|
||||
today = ~D[2024-03-15]
|
||||
|
||||
|
|
@ -443,11 +469,12 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
|
|||
membership_fee_type_id: fee_type.id,
|
||||
membership_fee_start_date: ~D[2024-02-01]
|
||||
},
|
||||
today
|
||||
today,
|
||||
actor
|
||||
)
|
||||
|
||||
# Check all cycles
|
||||
cycles = get_member_cycles(member.id)
|
||||
cycles = get_member_cycles(member.id, actor)
|
||||
|
||||
# Should have February 2024 cycle
|
||||
feb_cycle = Enum.find(cycles, fn c -> c.cycle_start == ~D[2024-02-01] end)
|
||||
|
|
@ -455,9 +482,9 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
|
|||
assert feb_cycle != nil
|
||||
end
|
||||
|
||||
test "February cycles in non-leap year" do
|
||||
setup_settings(true)
|
||||
fee_type = create_fee_type(%{interval: :monthly})
|
||||
test "February cycles in non-leap year", %{actor: actor} do
|
||||
setup_settings(true, actor)
|
||||
fee_type = create_fee_type(%{interval: :monthly}, actor)
|
||||
|
||||
today = ~D[2023-03-15]
|
||||
|
||||
|
|
@ -470,11 +497,12 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
|
|||
membership_fee_type_id: fee_type.id,
|
||||
membership_fee_start_date: ~D[2023-02-01]
|
||||
},
|
||||
today
|
||||
today,
|
||||
actor
|
||||
)
|
||||
|
||||
# Check all cycles
|
||||
cycles = get_member_cycles(member.id)
|
||||
cycles = get_member_cycles(member.id, actor)
|
||||
|
||||
# Should have February 2023 cycle
|
||||
feb_cycle = Enum.find(cycles, fn c -> c.cycle_start == ~D[2023-02-01] end)
|
||||
|
|
@ -482,9 +510,9 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
|
|||
assert feb_cycle != nil
|
||||
end
|
||||
|
||||
test "yearly cycle in leap year" do
|
||||
setup_settings(true)
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
test "yearly cycle in leap year", %{actor: actor} do
|
||||
setup_settings(true, actor)
|
||||
fee_type = create_fee_type(%{interval: :yearly}, actor)
|
||||
|
||||
today = ~D[2024-12-31]
|
||||
|
||||
|
|
@ -496,11 +524,12 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
|
|||
membership_fee_type_id: fee_type.id,
|
||||
membership_fee_start_date: ~D[2024-01-01]
|
||||
},
|
||||
today
|
||||
today,
|
||||
actor
|
||||
)
|
||||
|
||||
# Check all cycles
|
||||
cycles = get_member_cycles(member.id)
|
||||
cycles = get_member_cycles(member.id, actor)
|
||||
|
||||
# Should have 2024 cycle
|
||||
cycle_2024 = Enum.find(cycles, fn c -> c.cycle_start == ~D[2024-01-01] end)
|
||||
|
|
@ -510,9 +539,9 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
|
|||
end
|
||||
|
||||
describe "include_joining_cycle variations" do
|
||||
test "include_joining_cycle = true starts from joining cycle" do
|
||||
setup_settings(true)
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
test "include_joining_cycle = true starts from joining cycle", %{actor: actor} do
|
||||
setup_settings(true, actor)
|
||||
fee_type = create_fee_type(%{interval: :yearly}, actor)
|
||||
|
||||
today = ~D[2024-06-15]
|
||||
|
||||
|
|
@ -525,20 +554,21 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
|
|||
membership_fee_type_id: fee_type.id
|
||||
# membership_fee_start_date will be auto-calculated
|
||||
},
|
||||
today
|
||||
today,
|
||||
actor
|
||||
)
|
||||
|
||||
# Check all cycles
|
||||
cycles = get_member_cycles(member.id)
|
||||
cycles = get_member_cycles(member.id, actor)
|
||||
cycle_years = Enum.map(cycles, & &1.cycle_start.year) |> Enum.sort()
|
||||
|
||||
# Should include 2023 (joining year)
|
||||
assert 2023 in cycle_years
|
||||
end
|
||||
|
||||
test "include_joining_cycle = false starts from next cycle" do
|
||||
setup_settings(false)
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
test "include_joining_cycle = false starts from next cycle", %{actor: actor} do
|
||||
setup_settings(false, actor)
|
||||
fee_type = create_fee_type(%{interval: :yearly}, actor)
|
||||
|
||||
today = ~D[2024-06-15]
|
||||
|
||||
|
|
@ -551,11 +581,12 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
|
|||
membership_fee_type_id: fee_type.id
|
||||
# membership_fee_start_date will be auto-calculated
|
||||
},
|
||||
today
|
||||
today,
|
||||
actor
|
||||
)
|
||||
|
||||
# Check all cycles
|
||||
cycles = get_member_cycles(member.id)
|
||||
cycles = get_member_cycles(member.id, actor)
|
||||
cycle_years = Enum.map(cycles, & &1.cycle_start.year) |> Enum.sort()
|
||||
|
||||
# Should NOT include 2023 (joining year)
|
||||
|
|
@ -567,17 +598,22 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
|
|||
end
|
||||
|
||||
describe "inactive member processing" do
|
||||
test "inactive members receive cycles up to exit_date via generate_cycles_for_all_members" do
|
||||
setup_settings(true)
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
test "inactive members receive cycles up to exit_date via generate_cycles_for_all_members", %{
|
||||
actor: actor
|
||||
} do
|
||||
setup_settings(true, actor)
|
||||
fee_type = create_fee_type(%{interval: :yearly}, actor)
|
||||
|
||||
# Create an inactive member (left in 2023) WITHOUT fee type initially
|
||||
# This simulates a member that was created before the fee system existed
|
||||
member =
|
||||
create_member(%{
|
||||
join_date: ~D[2021-03-15],
|
||||
exit_date: ~D[2023-06-15]
|
||||
})
|
||||
create_member(
|
||||
%{
|
||||
join_date: ~D[2021-03-15],
|
||||
exit_date: ~D[2023-06-15]
|
||||
},
|
||||
actor
|
||||
)
|
||||
|
||||
# Now assign fee type (simulating a retroactive assignment)
|
||||
member =
|
||||
|
|
@ -586,7 +622,7 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
|
|||
membership_fee_type_id: fee_type.id,
|
||||
membership_fee_start_date: ~D[2021-01-01]
|
||||
})
|
||||
|> Ash.update!()
|
||||
|> Ash.update!(actor: actor)
|
||||
|
||||
# Run batch generation with a "today" date after the member left
|
||||
today = ~D[2024-06-15]
|
||||
|
|
@ -596,7 +632,7 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
|
|||
assert results.total >= 1
|
||||
|
||||
# Check the member's cycles
|
||||
cycles = get_member_cycles(member.id)
|
||||
cycles = get_member_cycles(member.id, actor)
|
||||
cycle_years = Enum.map(cycles, & &1.cycle_start.year) |> Enum.sort() |> Enum.uniq()
|
||||
|
||||
# Should have 2021, 2022, 2023 (exit year included)
|
||||
|
|
@ -608,9 +644,9 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
|
|||
refute 2024 in cycle_years
|
||||
end
|
||||
|
||||
test "exit_date on cycle_start still generates that cycle" do
|
||||
setup_settings(true)
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
test "exit_date on cycle_start still generates that cycle", %{actor: actor} do
|
||||
setup_settings(true, actor)
|
||||
fee_type = create_fee_type(%{interval: :yearly}, actor)
|
||||
|
||||
today = ~D[2024-12-31]
|
||||
|
||||
|
|
@ -624,11 +660,12 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
|
|||
membership_fee_type_id: fee_type.id,
|
||||
membership_fee_start_date: ~D[2022-01-01]
|
||||
},
|
||||
today
|
||||
today,
|
||||
actor
|
||||
)
|
||||
|
||||
# Check cycles
|
||||
cycles = get_member_cycles(member.id)
|
||||
cycles = get_member_cycles(member.id, actor)
|
||||
cycle_years = Enum.map(cycles, & &1.cycle_start.year) |> Enum.sort()
|
||||
|
||||
# 2024 should be included because exit_date == cycle_start means
|
||||
|
|
|
|||
|
|
@ -11,8 +11,13 @@ defmodule Mv.MembershipFees.CycleGeneratorTest do
|
|||
|
||||
require Ash.Query
|
||||
|
||||
setup do
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
%{actor: system_actor}
|
||||
end
|
||||
|
||||
# Helper to create a membership fee type
|
||||
defp create_fee_type(attrs) do
|
||||
defp create_fee_type(attrs, actor) do
|
||||
default_attrs = %{
|
||||
name: "Test Fee Type #{System.unique_integer([:positive])}",
|
||||
amount: Decimal.new("50.00"),
|
||||
|
|
@ -23,11 +28,11 @@ defmodule Mv.MembershipFees.CycleGeneratorTest do
|
|||
|
||||
MembershipFeeType
|
||||
|> Ash.Changeset.for_create(:create, attrs)
|
||||
|> Ash.create!()
|
||||
|> Ash.create!(actor: actor)
|
||||
end
|
||||
|
||||
# Helper to create a member without triggering cycle generation
|
||||
defp create_member_without_cycles(attrs) do
|
||||
defp create_member_without_cycles(attrs, actor) do
|
||||
default_attrs = %{
|
||||
first_name: "Test",
|
||||
last_name: "User",
|
||||
|
|
@ -38,50 +43,53 @@ defmodule Mv.MembershipFees.CycleGeneratorTest do
|
|||
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, attrs)
|
||||
|> Ash.create!()
|
||||
|> Ash.create!(actor: actor)
|
||||
end
|
||||
|
||||
# Helper to set up settings with specific include_joining_cycle value
|
||||
defp setup_settings(include_joining_cycle) do
|
||||
defp setup_settings(include_joining_cycle, actor) do
|
||||
{:ok, settings} = Mv.Membership.get_settings()
|
||||
|
||||
settings
|
||||
|> Ash.Changeset.for_update(:update, %{include_joining_cycle: include_joining_cycle})
|
||||
|> Ash.update!()
|
||||
|> Ash.update!(actor: actor)
|
||||
end
|
||||
|
||||
# Helper to get cycles for a member
|
||||
defp get_member_cycles(member_id) do
|
||||
defp get_member_cycles(member_id, actor) do
|
||||
MembershipFeeCycle
|
||||
|> Ash.Query.filter(member_id == ^member_id)
|
||||
|> Ash.Query.sort(cycle_start: :asc)
|
||||
|> Ash.read!()
|
||||
|> Ash.read!(actor: actor)
|
||||
end
|
||||
|
||||
describe "generate_cycles_for_member/2" do
|
||||
test "generates cycles from start date to today" do
|
||||
setup_settings(true)
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
test "generates cycles from start date to today", %{actor: actor} do
|
||||
setup_settings(true, actor)
|
||||
fee_type = create_fee_type(%{interval: :yearly}, actor)
|
||||
|
||||
# Create member WITHOUT fee type first to avoid auto-generation
|
||||
member =
|
||||
create_member_without_cycles(%{
|
||||
join_date: ~D[2022-03-15],
|
||||
membership_fee_start_date: ~D[2022-01-01]
|
||||
})
|
||||
create_member_without_cycles(
|
||||
%{
|
||||
join_date: ~D[2022-03-15],
|
||||
membership_fee_start_date: ~D[2022-01-01]
|
||||
},
|
||||
actor
|
||||
)
|
||||
|
||||
# Assign fee type
|
||||
member =
|
||||
member
|
||||
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|
||||
|> Ash.update!()
|
||||
|> Ash.update!(actor: actor)
|
||||
|
||||
# Explicitly generate cycles with fixed "today" date to avoid date dependency
|
||||
today = ~D[2024-06-15]
|
||||
{:ok, _, _} = CycleGenerator.generate_cycles_for_member(member.id, today: today)
|
||||
|
||||
# Verify cycles were generated
|
||||
all_cycles = get_member_cycles(member.id)
|
||||
all_cycles = get_member_cycles(member.id, actor)
|
||||
cycle_years = Enum.map(all_cycles, & &1.cycle_start.year) |> Enum.sort() |> Enum.uniq()
|
||||
|
||||
# With include_joining_cycle=true and join_date=2022-03-15,
|
||||
|
|
@ -92,16 +100,19 @@ defmodule Mv.MembershipFees.CycleGeneratorTest do
|
|||
assert 2024 in cycle_years
|
||||
end
|
||||
|
||||
test "generates cycles from last existing cycle" do
|
||||
setup_settings(true)
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
test "generates cycles from last existing cycle", %{actor: actor} do
|
||||
setup_settings(true, actor)
|
||||
fee_type = create_fee_type(%{interval: :yearly}, actor)
|
||||
|
||||
# Create member without fee type first to avoid auto-generation
|
||||
member =
|
||||
create_member_without_cycles(%{
|
||||
join_date: ~D[2022-03-15],
|
||||
membership_fee_start_date: ~D[2022-01-01]
|
||||
})
|
||||
create_member_without_cycles(
|
||||
%{
|
||||
join_date: ~D[2022-03-15],
|
||||
membership_fee_start_date: ~D[2022-01-01]
|
||||
},
|
||||
actor
|
||||
)
|
||||
|
||||
# Manually create a cycle for 2022
|
||||
MembershipFeeCycle
|
||||
|
|
@ -112,13 +123,13 @@ defmodule Mv.MembershipFees.CycleGeneratorTest do
|
|||
amount: fee_type.amount,
|
||||
status: :paid
|
||||
})
|
||||
|> Ash.create!()
|
||||
|> Ash.create!(actor: actor)
|
||||
|
||||
# Now assign fee type to member
|
||||
member =
|
||||
member
|
||||
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|
||||
|> Ash.update!()
|
||||
|> Ash.update!(actor: actor)
|
||||
|
||||
# Generate cycles with specific "today" date
|
||||
today = ~D[2024-06-15]
|
||||
|
|
@ -130,17 +141,20 @@ defmodule Mv.MembershipFees.CycleGeneratorTest do
|
|||
assert 2022 not in new_cycle_years
|
||||
end
|
||||
|
||||
test "respects left_at boundary (stops generation)" do
|
||||
setup_settings(true)
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
test "respects left_at boundary (stops generation)", %{actor: actor} do
|
||||
setup_settings(true, actor)
|
||||
fee_type = create_fee_type(%{interval: :yearly}, actor)
|
||||
|
||||
member =
|
||||
create_member_without_cycles(%{
|
||||
join_date: ~D[2022-03-15],
|
||||
exit_date: ~D[2023-06-15],
|
||||
membership_fee_type_id: fee_type.id,
|
||||
membership_fee_start_date: ~D[2022-01-01]
|
||||
})
|
||||
create_member_without_cycles(
|
||||
%{
|
||||
join_date: ~D[2022-03-15],
|
||||
exit_date: ~D[2023-06-15],
|
||||
membership_fee_type_id: fee_type.id,
|
||||
membership_fee_start_date: ~D[2022-01-01]
|
||||
},
|
||||
actor
|
||||
)
|
||||
|
||||
# Generate cycles with specific "today" date far in the future
|
||||
today = ~D[2025-06-15]
|
||||
|
|
@ -154,16 +168,19 @@ defmodule Mv.MembershipFees.CycleGeneratorTest do
|
|||
assert 2025 not in cycle_years
|
||||
end
|
||||
|
||||
test "skips existing cycles (idempotent)" do
|
||||
setup_settings(true)
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
test "skips existing cycles (idempotent)", %{actor: actor} do
|
||||
setup_settings(true, actor)
|
||||
fee_type = create_fee_type(%{interval: :yearly}, actor)
|
||||
|
||||
member =
|
||||
create_member_without_cycles(%{
|
||||
join_date: ~D[2023-03-15],
|
||||
membership_fee_type_id: fee_type.id,
|
||||
membership_fee_start_date: ~D[2023-01-01]
|
||||
})
|
||||
create_member_without_cycles(
|
||||
%{
|
||||
join_date: ~D[2023-03-15],
|
||||
membership_fee_type_id: fee_type.id,
|
||||
membership_fee_start_date: ~D[2023-01-01]
|
||||
},
|
||||
actor
|
||||
)
|
||||
|
||||
today = ~D[2024-06-15]
|
||||
|
||||
|
|
@ -177,37 +194,43 @@ defmodule Mv.MembershipFees.CycleGeneratorTest do
|
|||
assert second_cycles == []
|
||||
end
|
||||
|
||||
test "does not fill gaps when cycles were deleted" do
|
||||
setup_settings(true)
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
test "does not fill gaps when cycles were deleted", %{actor: actor} do
|
||||
setup_settings(true, actor)
|
||||
fee_type = create_fee_type(%{interval: :yearly}, actor)
|
||||
|
||||
# Create member without fee type first to control which cycles exist
|
||||
member =
|
||||
create_member_without_cycles(%{
|
||||
join_date: ~D[2020-03-15],
|
||||
membership_fee_start_date: ~D[2020-01-01]
|
||||
})
|
||||
create_member_without_cycles(
|
||||
%{
|
||||
join_date: ~D[2020-03-15],
|
||||
membership_fee_start_date: ~D[2020-01-01]
|
||||
},
|
||||
actor
|
||||
)
|
||||
|
||||
# Manually create cycles for 2020, 2021, 2022, 2023
|
||||
for year <- [2020, 2021, 2022, 2023] do
|
||||
MembershipFeeCycle
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
cycle_start: Date.new!(year, 1, 1),
|
||||
member_id: member.id,
|
||||
membership_fee_type_id: fee_type.id,
|
||||
amount: fee_type.amount,
|
||||
status: :unpaid
|
||||
})
|
||||
|> Ash.create!()
|
||||
|> Ash.Changeset.for_create(
|
||||
:create,
|
||||
%{
|
||||
cycle_start: Date.new!(year, 1, 1),
|
||||
member_id: member.id,
|
||||
membership_fee_type_id: fee_type.id,
|
||||
amount: fee_type.amount,
|
||||
status: :unpaid
|
||||
}
|
||||
)
|
||||
|> Ash.create!(actor: actor)
|
||||
end
|
||||
|
||||
# Delete the 2021 cycle (create a gap)
|
||||
cycle_2021 =
|
||||
MembershipFeeCycle
|
||||
|> Ash.Query.filter(member_id == ^member.id and cycle_start == ^~D[2021-01-01])
|
||||
|> Ash.read_one!()
|
||||
|> Ash.read_one!(actor: actor)
|
||||
|
||||
Ash.destroy!(cycle_2021)
|
||||
Ash.destroy!(cycle_2021, actor: actor)
|
||||
|
||||
# Now assign fee type to member (this triggers generation)
|
||||
# Since cycles already exist (2020, 2022, 2023), the generator will
|
||||
|
|
@ -215,10 +238,10 @@ defmodule Mv.MembershipFees.CycleGeneratorTest do
|
|||
member =
|
||||
member
|
||||
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|
||||
|> Ash.update!()
|
||||
|> Ash.update!(actor: actor)
|
||||
|
||||
# Verify gap was NOT filled and new cycles were generated from last existing
|
||||
all_cycles = get_member_cycles(member.id)
|
||||
all_cycles = get_member_cycles(member.id, actor)
|
||||
all_cycle_years = Enum.map(all_cycles, & &1.cycle_start.year) |> Enum.sort()
|
||||
|
||||
# 2021 should NOT exist (gap was not filled)
|
||||
|
|
@ -234,20 +257,23 @@ defmodule Mv.MembershipFees.CycleGeneratorTest do
|
|||
assert 2025 in all_cycle_years
|
||||
end
|
||||
|
||||
test "sets correct amount from membership fee type" do
|
||||
setup_settings(true)
|
||||
test "sets correct amount from membership fee type", %{actor: actor} do
|
||||
setup_settings(true, actor)
|
||||
amount = Decimal.new("75.50")
|
||||
fee_type = create_fee_type(%{interval: :yearly, amount: amount})
|
||||
fee_type = create_fee_type(%{interval: :yearly, amount: amount}, actor)
|
||||
|
||||
member =
|
||||
create_member_without_cycles(%{
|
||||
join_date: ~D[2024-03-15],
|
||||
membership_fee_type_id: fee_type.id,
|
||||
membership_fee_start_date: ~D[2024-01-01]
|
||||
})
|
||||
create_member_without_cycles(
|
||||
%{
|
||||
join_date: ~D[2024-03-15],
|
||||
membership_fee_type_id: fee_type.id,
|
||||
membership_fee_start_date: ~D[2024-01-01]
|
||||
},
|
||||
actor
|
||||
)
|
||||
|
||||
# Verify cycles were generated with correct amount
|
||||
all_cycles = get_member_cycles(member.id)
|
||||
all_cycles = get_member_cycles(member.id, actor)
|
||||
refute Enum.empty?(all_cycles), "Expected cycles to be generated"
|
||||
|
||||
# All cycles should have the correct amount
|
||||
|
|
@ -256,21 +282,24 @@ defmodule Mv.MembershipFees.CycleGeneratorTest do
|
|||
end)
|
||||
end
|
||||
|
||||
test "handles NULL membership_fee_start_date by calculating from join_date" do
|
||||
setup_settings(true)
|
||||
fee_type = create_fee_type(%{interval: :quarterly})
|
||||
test "handles NULL membership_fee_start_date by calculating from join_date", %{actor: actor} do
|
||||
setup_settings(true, actor)
|
||||
fee_type = create_fee_type(%{interval: :quarterly}, actor)
|
||||
|
||||
# Create member without membership_fee_start_date - it will be auto-calculated
|
||||
# and cycles will be auto-generated
|
||||
member =
|
||||
create_member_without_cycles(%{
|
||||
join_date: ~D[2024-02-15],
|
||||
membership_fee_type_id: fee_type.id
|
||||
# No membership_fee_start_date - should be calculated
|
||||
})
|
||||
create_member_without_cycles(
|
||||
%{
|
||||
join_date: ~D[2024-02-15],
|
||||
membership_fee_type_id: fee_type.id
|
||||
# No membership_fee_start_date - should be calculated
|
||||
},
|
||||
actor
|
||||
)
|
||||
|
||||
# Verify cycles were auto-generated
|
||||
all_cycles = get_member_cycles(member.id)
|
||||
all_cycles = get_member_cycles(member.id, actor)
|
||||
|
||||
# With include_joining_cycle=true and join_date=2024-02-15 (quarterly),
|
||||
# start_date should be 2024-01-01 (Q1 start)
|
||||
|
|
@ -284,28 +313,34 @@ defmodule Mv.MembershipFees.CycleGeneratorTest do
|
|||
assert first_cycle_start == ~D[2024-01-01]
|
||||
end
|
||||
|
||||
test "returns error when member has no membership_fee_type" do
|
||||
test "returns error when member has no membership_fee_type", %{actor: actor} do
|
||||
# Create member without fee type - no auto-generation will occur
|
||||
member =
|
||||
create_member_without_cycles(%{
|
||||
join_date: ~D[2024-03-15]
|
||||
# No membership_fee_type_id
|
||||
})
|
||||
create_member_without_cycles(
|
||||
%{
|
||||
join_date: ~D[2024-03-15]
|
||||
# No membership_fee_type_id
|
||||
},
|
||||
actor
|
||||
)
|
||||
|
||||
{:error, reason} = CycleGenerator.generate_cycles_for_member(member.id)
|
||||
assert reason == :no_membership_fee_type
|
||||
end
|
||||
|
||||
test "returns error when member has no join_date" do
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
test "returns error when member has no join_date", %{actor: actor} do
|
||||
fee_type = create_fee_type(%{interval: :yearly}, actor)
|
||||
|
||||
# Create member without join_date - no auto-generation will occur
|
||||
# (after_action hook checks for join_date)
|
||||
member =
|
||||
create_member_without_cycles(%{
|
||||
membership_fee_type_id: fee_type.id
|
||||
# No join_date
|
||||
})
|
||||
create_member_without_cycles(
|
||||
%{
|
||||
membership_fee_type_id: fee_type.id
|
||||
# No join_date
|
||||
},
|
||||
actor
|
||||
)
|
||||
|
||||
{:error, reason} = CycleGenerator.generate_cycles_for_member(member.id)
|
||||
assert reason == :no_join_date
|
||||
|
|
@ -357,24 +392,30 @@ defmodule Mv.MembershipFees.CycleGeneratorTest do
|
|||
end
|
||||
|
||||
describe "generate_cycles_for_all_members/1" do
|
||||
test "generates cycles for multiple members" do
|
||||
setup_settings(true)
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
test "generates cycles for multiple members", %{actor: actor} do
|
||||
setup_settings(true, actor)
|
||||
fee_type = create_fee_type(%{interval: :yearly}, actor)
|
||||
|
||||
# Create multiple members
|
||||
_member1 =
|
||||
create_member_without_cycles(%{
|
||||
join_date: ~D[2024-01-15],
|
||||
membership_fee_type_id: fee_type.id,
|
||||
membership_fee_start_date: ~D[2024-01-01]
|
||||
})
|
||||
create_member_without_cycles(
|
||||
%{
|
||||
join_date: ~D[2024-01-15],
|
||||
membership_fee_type_id: fee_type.id,
|
||||
membership_fee_start_date: ~D[2024-01-01]
|
||||
},
|
||||
actor
|
||||
)
|
||||
|
||||
_member2 =
|
||||
create_member_without_cycles(%{
|
||||
join_date: ~D[2024-02-15],
|
||||
membership_fee_type_id: fee_type.id,
|
||||
membership_fee_start_date: ~D[2024-01-01]
|
||||
})
|
||||
create_member_without_cycles(
|
||||
%{
|
||||
join_date: ~D[2024-02-15],
|
||||
membership_fee_type_id: fee_type.id,
|
||||
membership_fee_start_date: ~D[2024-01-01]
|
||||
},
|
||||
actor
|
||||
)
|
||||
|
||||
today = ~D[2024-06-15]
|
||||
{:ok, results} = CycleGenerator.generate_cycles_for_all_members(today: today)
|
||||
|
|
@ -387,16 +428,19 @@ defmodule Mv.MembershipFees.CycleGeneratorTest do
|
|||
end
|
||||
|
||||
describe "lock mechanism" do
|
||||
test "prevents concurrent generation for same member" do
|
||||
setup_settings(true)
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
test "prevents concurrent generation for same member", %{actor: actor} do
|
||||
setup_settings(true, actor)
|
||||
fee_type = create_fee_type(%{interval: :yearly}, actor)
|
||||
|
||||
member =
|
||||
create_member_without_cycles(%{
|
||||
join_date: ~D[2022-03-15],
|
||||
membership_fee_type_id: fee_type.id,
|
||||
membership_fee_start_date: ~D[2022-01-01]
|
||||
})
|
||||
create_member_without_cycles(
|
||||
%{
|
||||
join_date: ~D[2022-03-15],
|
||||
membership_fee_type_id: fee_type.id,
|
||||
membership_fee_start_date: ~D[2022-01-01]
|
||||
},
|
||||
actor
|
||||
)
|
||||
|
||||
today = ~D[2024-06-15]
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue