diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 57eed771e..5a141d0c9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -135,7 +135,10 @@ jobs: - name: Compile application run: mix compile - - name: Run tests (smoke tests only) + - name: Setup test database + run: mix test.setup + + - name: Run tests run: mix test continue-on-error: true diff --git a/AGENTS.md b/AGENTS.md index bdea9a74d..f72e83a6e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,14 +49,63 @@ git status ### Testing & Code Quality -PhoenixKit is a library module. Smoke tests + static analysis here; integration testing in parent apps. +PhoenixKit has two levels of tests: -- `mix test` - Smoke tests (module loading) -- `mix format` - Format code -- `mix credo --strict` - Static analysis -- `mix dialyzer` - Type checking -- `mix quality` - Run all quality checks -- `mix quality.ci` - Run all quality checks for CI (strict formatting check) +1. **Unit tests** (`test/phoenix_kit/`, `test/modules/`) — Pure logic, no DB required +2. **Integration tests** (`test/integration/`, `test/modules/publishing/integration/`) — Real PostgreSQL via Ecto sandbox + +#### Test database setup + +```bash +mix test.setup # Create DB + run migrations (first time) +mix test # Run all tests (migrations run automatically via test_helper) +mix test.reset # Drop + recreate DB if needed +``` + +The test DB (`phoenix_kit_test`) uses an embedded `PhoenixKit.Test.Repo` in `test/support/test_repo.ex`. Migrations are in `test/support/postgres/migrations/`. No parent app required. + +**Without PostgreSQL:** If the test DB doesn't exist, integration tests are automatically excluded and unit tests still run. You'll see: +``` +⚠ Test database "phoenix_kit_test" not found — integration tests will be excluded. + Run `mix test.setup` to create the test database. +868 tests, 0 failures, 274 excluded +``` + +#### Test commands + +- `mix test` — Run all tests (unit + integration if DB available) +- `mix test test/integration/` — Run only user integration tests +- `mix test test/modules/publishing/integration/` — Run only publishing integration tests +- `mix format` — Format code +- `mix credo --strict` — Static analysis +- `mix dialyzer` — Type checking +- `mix quality` — Run all quality checks +- `mix quality.ci` — Run all quality checks for CI (strict formatting check) + +#### Writing new integration tests + +Use `PhoenixKit.DataCase` for tests that need the database. Tests using `DataCase` are automatically tagged `:integration` and excluded when the DB is unavailable. + +```elixir +defmodule PhoenixKit.Integration.MyTest do + use PhoenixKit.DataCase, async: true + + test "example" do + {:ok, user} = PhoenixKit.Users.Auth.register_user(%{ + email: "test@example.com", + password: "ValidPassword123!" + }) + assert user.uuid + end +end +``` + +#### Test infrastructure files + +- `test/support/test_repo.ex` — `PhoenixKit.Test.Repo` (Ecto repo for tests) +- `test/support/data_case.ex` — `PhoenixKit.DataCase` (sandbox setup, `:integration` tag) +- `test/support/postgres/migrations/` — Migration wrapper calling `PhoenixKit.Migrations.up()` +- `config/test.exs` — DB config, sandbox pool, repo wiring ### Code Search @@ -75,7 +124,7 @@ ast-grep --lang elixir --pattern 'def $FUNC($$$ARGS) do $$$BODY end' lib/ ### CI/CD -GitHub Actions on push to `main`, `dev`, `claude/**` and all PRs. Checks: formatting, credo, dialyzer, compilation (warnings as errors), dependency audit, smoke tests. +GitHub Actions on push to `main`, `dev`, `claude/**` and all PRs. Checks: formatting, credo, dialyzer, compilation (warnings as errors), dependency audit, tests (with PostgreSQL). ### Commit Message Rules diff --git a/config/test.exs b/config/test.exs index 6d819ead3..a66de4585 100644 --- a/config/test.exs +++ b/config/test.exs @@ -3,10 +3,20 @@ import Config # Configure test environment for PhoenixKit # This file is imported by config.exs when Mix.env() == :test -# Configure test database (when PhoenixKit is used in parent applications) -# Parent apps should configure their own test repo here -# config :phoenix_kit, -# repo: MyApp.Repo +# Configure test database - embedded test repo for library-level integration tests +config :phoenix_kit, ecto_repos: [PhoenixKit.Test.Repo] + +config :phoenix_kit, PhoenixKit.Test.Repo, + username: System.get_env("PGUSER", "postgres"), + password: System.get_env("PGPASSWORD", "postgres"), + hostname: System.get_env("PGHOST", "localhost"), + database: "phoenix_kit_test#{System.get_env("MIX_TEST_PARTITION")}", + pool: Ecto.Adapters.SQL.Sandbox, + pool_size: System.schedulers_online() * 2, + priv: "test/support/postgres" + +# Wire repo for library code that calls PhoenixKit.Config.get(:repo) +config :phoenix_kit, repo: PhoenixKit.Test.Repo # Configure test mailer - use Local adapter for test environment config :phoenix_kit, PhoenixKit.Mailer, adapter: Swoosh.Adapters.Test diff --git a/lib/modules/publishing/README.md b/lib/modules/publishing/README.md index 7968fa1d0..ad970e5cb 100644 --- a/lib/modules/publishing/README.md +++ b/lib/modules/publishing/README.md @@ -540,11 +540,14 @@ disappears from the post entirely. A new translation can be added later. # For timestamp mode, use the post UUID {:ok, _} = Publishing.trash_post("news", post_uuid) -# Clear a translation (hard-deletes the content row, refuses if last language) +# Archive a translation (soft-delete — sets status to "archived", refuses if last language) :ok = Publishing.delete_language("docs", post_uuid, "es") :ok = Publishing.delete_language("docs", post_uuid, "es", 2) # specific version {:error, :last_language} = Publishing.delete_language("docs", post_uuid, "en") +# Hard-delete a translation (permanently removes the content row) +:ok = Publishing.clear_translation("docs", post_uuid, "es") + # Archive a version (refuses if live or last active version) :ok = Publishing.delete_version("docs", post_uuid, 1) {:error, :cannot_delete_live} = Publishing.delete_version("docs", post_uuid, 2) @@ -1491,6 +1494,10 @@ This ensures localized URLs work immediately after deployment without waiting fo - **Proper Hreflang**: The `` tags use language-specific URLs - **Canonical URLs**: Each translation has its own canonical URL with its localized slug +## Future Refactoring Notes + +- **Rename translation functions**: `clear_translation` (hard delete) and `delete_language` (archive/soft delete) have counterintuitive names — "delete" sounds harder than "clear" but does less. Consider renaming to `hard_delete_translation` / `archive_translation` in a future cleanup pass. + ## Getting Help 1. Review DB storage layer: `lib/modules/publishing/db_storage.ex` diff --git a/lib/modules/publishing/db_storage.ex b/lib/modules/publishing/db_storage.ex index 45c94d93e..67c0e1fc9 100644 --- a/lib/modules/publishing/db_storage.ex +++ b/lib/modules/publishing/db_storage.ex @@ -259,6 +259,8 @@ defmodule PhoenixKit.Modules.Publishing.DBStorage do end @doc "Trashes a post by setting status to 'trashed'." + # Uses Ecto.Changeset.change/2 instead of the full changeset to avoid + # slug validation errors on posts with nil/blank slugs. def trash_post(%PublishingPost{} = post) do post |> Ecto.Changeset.change(status: "trashed") @@ -400,15 +402,18 @@ defmodule PhoenixKit.Modules.Publishing.DBStorage do from getting the same number. """ def next_version_number(post_uuid) do - result = + # Lock existing version rows to prevent concurrent inserts, + # then compute max in Elixir. FOR UPDATE cannot be combined + # with aggregate functions in PostgreSQL. + versions = from(v in PublishingVersion, where: v.post_uuid == ^post_uuid, - select: max(v.version_number), + select: v.version_number, lock: "FOR UPDATE" ) - |> repo().one() + |> repo().all() - (result || 0) + 1 + Enum.max(versions, fn -> 0 end) + 1 end @doc """ @@ -446,7 +451,7 @@ defmodule PhoenixKit.Modules.Publishing.DBStorage do end defp copy_contents_to_version(source_version_uuid, target_version_uuid) do - now = DateTime.utc_now() + now = DateTime.utc_now() |> DateTime.truncate(:second) rows = list_contents(source_version_uuid) diff --git a/mix.exs b/mix.exs index 521aac82d..55cdb9927 100644 --- a/mix.exs +++ b/mix.exs @@ -31,11 +31,13 @@ defmodule PhoenixKit.MixProject do def cli do [ - preferred_env: [ + preferred_envs: [ coveralls: :test, "coveralls.detail": :test, "coveralls.post": :test, - "coveralls.html": :test + "coveralls.html": :test, + "test.setup": :test, + "test.reset": :test ], # Dialyzer configuration @@ -195,6 +197,10 @@ defmodule PhoenixKit.MixProject do "ecto.setup": ["ecto.create", "ecto.migrate"], "ecto.reset": ["ecto.drop", "ecto.setup"], + # Test database management + "test.setup": ["ecto.create --quiet", "ecto.migrate --quiet"], + "test.reset": ["ecto.drop --quiet", "test.setup"], + # Code quality quality: ["format", "credo --strict", "dialyzer"], "quality.ci": ["format --check-formatted", "credo --strict", "dialyzer"], diff --git a/test/integration/repo_smoke_test.exs b/test/integration/repo_smoke_test.exs new file mode 100644 index 000000000..5e2d39cf9 --- /dev/null +++ b/test/integration/repo_smoke_test.exs @@ -0,0 +1,22 @@ +defmodule PhoenixKit.Integration.RepoSmokeTest do + use PhoenixKit.DataCase, async: true + + test "repo is connected and migrations ran" do + assert Repo.query!("SELECT 1").rows == [[1]] + end + + test "core tables exist" do + %{rows: rows} = + Repo.query!(""" + SELECT table_name FROM information_schema.tables + WHERE table_schema = 'public' AND table_name LIKE 'phoenix_kit_%' + ORDER BY table_name + """) + + table_names = List.flatten(rows) + + assert "phoenix_kit_users" in table_names + assert "phoenix_kit_users_tokens" in table_names + assert "phoenix_kit_settings" in table_names + end +end diff --git a/test/integration/users/authentication_test.exs b/test/integration/users/authentication_test.exs new file mode 100644 index 000000000..7d38bfffb --- /dev/null +++ b/test/integration/users/authentication_test.exs @@ -0,0 +1,174 @@ +defmodule PhoenixKit.Integration.Users.AuthenticationTest do + use PhoenixKit.DataCase, async: true + + alias PhoenixKit.Users.Auth + + defp unique_email, do: "auth_#{System.unique_integer([:positive])}@example.com" + defp valid_password, do: "ValidPassword123!" + + defp create_user(overrides \\ %{}) do + attrs = Map.merge(%{email: unique_email(), password: valid_password()}, overrides) + {:ok, user} = Auth.register_user(attrs) + {user, attrs} + end + + describe "get_user_by_email_and_password/3" do + test "returns user with correct credentials" do + {user, attrs} = create_user() + + assert {:ok, found} = Auth.get_user_by_email_and_password(attrs.email, attrs.password) + assert found.uuid == user.uuid + end + + test "returns error with wrong password" do + {_user, attrs} = create_user() + + assert {:error, :invalid_credentials} = + Auth.get_user_by_email_and_password(attrs.email, "WrongPassword!") + end + + test "returns error with nonexistent email" do + assert {:error, :invalid_credentials} = + Auth.get_user_by_email_and_password("nobody@example.com", valid_password()) + end + end + + describe "get_user_by_email_or_username_and_password/3" do + test "authenticates via email" do + {user, attrs} = create_user() + + assert {:ok, found} = + Auth.get_user_by_email_or_username_and_password(attrs.email, attrs.password) + + assert found.uuid == user.uuid + end + + test "authenticates via username" do + {user, _attrs} = create_user(%{username: "authuser_login"}) + + assert {:ok, found} = + Auth.get_user_by_email_or_username_and_password("authuser_login", valid_password()) + + assert found.uuid == user.uuid + end + + test "returns error for wrong password via email" do + {_user, attrs} = create_user() + + assert {:error, :invalid_credentials} = + Auth.get_user_by_email_or_username_and_password(attrs.email, "WrongPass!") + end + + test "returns error for wrong password via username" do + {_user, _attrs} = create_user(%{username: "authuser_wrongpw"}) + + assert {:error, :invalid_credentials} = + Auth.get_user_by_email_or_username_and_password("authuser_wrongpw", "WrongPass!") + end + + test "returns error for nonexistent identifier" do + assert {:error, :invalid_credentials} = + Auth.get_user_by_email_or_username_and_password("ghost@example.com", "SomePass!") + end + + test "inactive user can still authenticate" do + # First user is Owner, second is regular User + {_owner, _} = create_user() + {user, attrs} = create_user() + + # Deactivate the regular user (not the Owner) + {:ok, _} = Auth.update_user_status(user, %{is_active: false}) + + assert {:ok, found} = + Auth.get_user_by_email_or_username_and_password(attrs.email, attrs.password) + + assert found.uuid == user.uuid + end + end + + describe "session tokens" do + test "generate_user_session_token/1 creates retrievable token" do + {user, _} = create_user() + + token = Auth.generate_user_session_token(user) + assert is_binary(token) + + found = Auth.get_user_by_session_token(token) + assert found.uuid == user.uuid + end + + test "invalid token returns nil" do + assert is_nil(Auth.get_user_by_session_token("invalid_token")) + end + + test "delete_user_session_token/1 invalidates token" do + {user, _} = create_user() + + token = Auth.generate_user_session_token(user) + assert Auth.get_user_by_session_token(token) + + Auth.delete_user_session_token(token) + assert is_nil(Auth.get_user_by_session_token(token)) + end + + test "delete_all_user_session_tokens/1 logs out everywhere" do + {user, _} = create_user() + + token1 = Auth.generate_user_session_token(user) + token2 = Auth.generate_user_session_token(user) + + Auth.delete_all_user_session_tokens(user) + + assert is_nil(Auth.get_user_by_session_token(token1)) + assert is_nil(Auth.get_user_by_session_token(token2)) + end + + test "delete_all_user_session_tokens/1 does not affect other users" do + {user1, _} = create_user() + {user2, _} = create_user() + + token1 = Auth.generate_user_session_token(user1) + token2 = Auth.generate_user_session_token(user2) + + Auth.delete_all_user_session_tokens(user1) + + assert is_nil(Auth.get_user_by_session_token(token1)) + # user2's token should still be valid + assert Auth.get_user_by_session_token(token2) + end + + test "get_all_user_session_tokens/1 lists active sessions" do + {user, _} = create_user() + + Auth.generate_user_session_token(user) + Auth.generate_user_session_token(user) + + tokens = Auth.get_all_user_session_tokens(user) + assert length(tokens) == 2 + end + end + + describe "session fingerprinting" do + test "stores fingerprint data with token" do + {user, _} = create_user() + + fingerprint = %{ip_address: "192.168.1.1", user_agent_hash: "testhash123"} + + token = Auth.generate_user_session_token(user, fingerprint: fingerprint) + + record = Auth.get_session_token_record(token) + assert record.ip_address == "192.168.1.1" + assert record.user_agent_hash == "testhash123" + end + + test "token without fingerprint has nil ip_address and user_agent_hash" do + {user, _} = create_user() + + token = Auth.generate_user_session_token(user) + + record = Auth.get_session_token_record(token) + assert is_nil(record.ip_address) + assert is_nil(record.user_agent_hash) + end + end +end diff --git a/test/integration/users/email_change_test.exs b/test/integration/users/email_change_test.exs new file mode 100644 index 000000000..d81f73b84 --- /dev/null +++ b/test/integration/users/email_change_test.exs @@ -0,0 +1,152 @@ +defmodule PhoenixKit.Integration.Users.EmailChangeTest do + use PhoenixKit.DataCase, async: true + + alias PhoenixKit.Users.Auth + + defp unique_email, do: "emailchg_#{System.unique_integer([:positive])}@example.com" + @valid_password "ValidPassword123!" + + defp create_user do + {:ok, user} = Auth.register_user(%{email: unique_email(), password: @valid_password}) + user + end + + describe "apply_user_email/3" do + test "validates email change with correct password" do + user = create_user() + new_email = unique_email() + + assert {:ok, applied} = + Auth.apply_user_email(user, @valid_password, %{email: new_email}) + + assert applied.email == new_email + end + + test "rejects email change with wrong password" do + user = create_user() + + assert {:error, changeset} = + Auth.apply_user_email(user, "WrongPassword!", %{email: unique_email()}) + + assert errors_on(changeset).current_password != [] + end + + test "does not change email if same as current" do + user = create_user() + + # apply_user_email validates via changeset; if no change detected, it may succeed + # The important thing is the email doesn't actually change + result = Auth.apply_user_email(user, @valid_password, %{email: user.email}) + + case result do + {:ok, applied} -> + # Email stayed the same + assert applied.email == user.email + + {:error, changeset} -> + assert errors_on(changeset).email != [] + end + end + + test "rejects invalid email format" do + user = create_user() + + assert {:error, changeset} = + Auth.apply_user_email(user, @valid_password, %{email: "not-valid"}) + + assert errors_on(changeset).email != [] + end + end + + describe "deliver_user_update_email_instructions/3" do + test "sends email with token for valid change" do + user = create_user() + new_email = unique_email() + + # First apply the email change (validates password, sets pending email) + {:ok, applied_user} = + Auth.apply_user_email(user, @valid_password, %{email: new_email}) + + assert {:ok, %Swoosh.Email{} = email} = + Auth.deliver_user_update_email_instructions( + applied_user, + user.email, + &"http://example.com/confirm_email/#{&1}" + ) + + # Email should contain a token URL + assert email.html_body || email.text_body + end + end + + describe "update_user_email/2" do + test "updates email with valid token from full workflow" do + user = create_user() + original_email = user.email + new_email = unique_email() + + # Step 1: Apply email change (validates password) + {:ok, applied_user} = + Auth.apply_user_email(user, @valid_password, %{email: new_email}) + + # Step 2: Deliver instructions (generates token) + {:ok, %Swoosh.Email{} = email} = + Auth.deliver_user_update_email_instructions( + applied_user, + original_email, + &"http://example.com/confirm_email/#{&1}" + ) + + # Step 3: Extract token from email + [_, token] = + Regex.run(~r/confirm_email\/([^\s"<]+)/, email.html_body || email.text_body) + + # Step 4: Confirm the email change + assert :ok = Auth.update_user_email(user, token) + + # Verify the email was actually changed + updated = Auth.get_user(user.uuid) + assert updated.email == new_email + end + + test "returns error for invalid token" do + user = create_user() + + assert :error = Auth.update_user_email(user, "invalid_token_string") + end + end + + describe "full email change workflow" do + test "apply -> deliver -> update completes email change" do + user = create_user() + {:ok, _} = Auth.admin_confirm_user(user) + original_email = user.email + new_email = unique_email() + + # Apply + {:ok, applied} = Auth.apply_user_email(user, @valid_password, %{email: new_email}) + assert applied.email == new_email + + # Deliver + {:ok, %Swoosh.Email{} = swoosh_email} = + Auth.deliver_user_update_email_instructions( + applied, + original_email, + &"http://example.com/confirm_email/#{&1}" + ) + + # Extract token and confirm + [_, token] = + Regex.run(~r/confirm_email\/([^\s"<]+)/, swoosh_email.html_body || swoosh_email.text_body) + + assert :ok = Auth.update_user_email(user, token) + + # Verify the old email no longer resolves + assert is_nil(Auth.get_user_by_email(original_email)) + + # Verify the new email resolves + found = Auth.get_user_by_email(new_email) + assert found.uuid == user.uuid + end + end +end diff --git a/test/integration/users/email_confirmation_test.exs b/test/integration/users/email_confirmation_test.exs new file mode 100644 index 000000000..2a22b144c --- /dev/null +++ b/test/integration/users/email_confirmation_test.exs @@ -0,0 +1,93 @@ +defmodule PhoenixKit.Integration.Users.EmailConfirmationTest do + use PhoenixKit.DataCase, async: true + + alias PhoenixKit.Users.Auth + + defp unique_email, do: "confirm_#{System.unique_integer([:positive])}@example.com" + + defp create_user do + {:ok, user} = Auth.register_user(%{email: unique_email(), password: "ValidPassword123!"}) + user + end + + describe "deliver_user_confirmation_instructions/2" do + test "generates confirmation token for unconfirmed user" do + user = create_user() + assert is_nil(user.confirmed_at) + + assert {:ok, _} = + Auth.deliver_user_confirmation_instructions( + user, + &"http://example.com/confirm/#{&1}" + ) + end + + test "returns error for already confirmed user" do + user = create_user() + {:ok, confirmed} = Auth.admin_confirm_user(user) + + assert {:error, :already_confirmed} = + Auth.deliver_user_confirmation_instructions( + confirmed, + &"http://example.com/confirm/#{&1}" + ) + end + end + + describe "confirm_user/1" do + test "confirms user with valid token" do + user = create_user() + + # Extract token by capturing the URL callback + {:ok, %Swoosh.Email{} = email} = + Auth.deliver_user_confirmation_instructions(user, &"http://example.com/confirm/#{&1}") + + # Extract token from the email body + [_, token] = Regex.run(~r/confirm\/([^\s"<]+)/, email.html_body || email.text_body) + + assert {:ok, confirmed} = Auth.confirm_user(token) + assert confirmed.confirmed_at != nil + assert confirmed.uuid == user.uuid + end + + test "returns error for invalid token" do + assert :error = Auth.confirm_user("invalid_token") + end + end + + describe "admin_confirm_user/1" do + test "confirms user without token" do + user = create_user() + assert is_nil(user.confirmed_at) + + {:ok, confirmed} = Auth.admin_confirm_user(user) + assert confirmed.confirmed_at != nil + end + end + + describe "admin_unconfirm_user/1" do + test "unconfirms a confirmed user" do + user = create_user() + {:ok, confirmed} = Auth.admin_confirm_user(user) + assert confirmed.confirmed_at != nil + + {:ok, unconfirmed} = Auth.admin_unconfirm_user(confirmed) + assert is_nil(unconfirmed.confirmed_at) + end + end + + describe "toggle_user_confirmation/1" do + test "confirms unconfirmed user" do + user = create_user() + {:ok, toggled} = Auth.toggle_user_confirmation(user) + assert toggled.confirmed_at != nil + end + + test "unconfirms confirmed user" do + user = create_user() + {:ok, confirmed} = Auth.admin_confirm_user(user) + {:ok, toggled} = Auth.toggle_user_confirmation(confirmed) + assert is_nil(toggled.confirmed_at) + end + end +end diff --git a/test/integration/users/password_test.exs b/test/integration/users/password_test.exs new file mode 100644 index 000000000..1a6a5df34 --- /dev/null +++ b/test/integration/users/password_test.exs @@ -0,0 +1,151 @@ +defmodule PhoenixKit.Integration.Users.PasswordTest do + use PhoenixKit.DataCase, async: true + + alias PhoenixKit.Users.Auth + + defp unique_email, do: "pw_#{System.unique_integer([:positive])}@example.com" + @valid_password "ValidPassword123!" + @new_password "NewPassword456!" + + defp create_user do + {:ok, user} = Auth.register_user(%{email: unique_email(), password: @valid_password}) + user + end + + describe "update_user_password/3" do + test "updates password with valid current password" do + user = create_user() + + assert {:ok, updated} = + Auth.update_user_password(user, @valid_password, %{ + password: @new_password, + password_confirmation: @new_password + }) + + assert {:ok, _} = Auth.get_user_by_email_and_password(updated.email, @new_password) + end + + test "rejects wrong current password" do + user = create_user() + + assert {:error, changeset} = + Auth.update_user_password(user, "WrongPassword!", %{ + password: @new_password, + password_confirmation: @new_password + }) + + assert errors_on(changeset).current_password != [] + end + + test "rejects password confirmation mismatch" do + user = create_user() + + assert {:error, changeset} = + Auth.update_user_password(user, @valid_password, %{ + password: @new_password, + password_confirmation: "DoesNotMatch456!" + }) + + assert errors_on(changeset).password_confirmation != [] + end + + test "invalidates all session tokens" do + user = create_user() + token = Auth.generate_user_session_token(user) + + {:ok, _} = + Auth.update_user_password(user, @valid_password, %{ + password: @new_password, + password_confirmation: @new_password + }) + + assert is_nil(Auth.get_user_by_session_token(token)) + end + + test "invalidates multiple session tokens after password change" do + user = create_user() + token1 = Auth.generate_user_session_token(user) + token2 = Auth.generate_user_session_token(user) + + {:ok, _} = + Auth.update_user_password(user, @valid_password, %{ + password: @new_password, + password_confirmation: @new_password + }) + + assert is_nil(Auth.get_user_by_session_token(token1)) + assert is_nil(Auth.get_user_by_session_token(token2)) + end + end + + describe "admin_update_user_password/3" do + test "updates password without requiring current password" do + user = create_user() + + assert {:ok, updated} = + Auth.admin_update_user_password(user, %{ + password: @new_password, + password_confirmation: @new_password + }) + + assert {:ok, _} = Auth.get_user_by_email_and_password(updated.email, @new_password) + end + end + + describe "reset password workflow" do + test "deliver_user_reset_password_instructions/2 sends email" do + user = create_user() + + assert {:ok, _} = + Auth.deliver_user_reset_password_instructions( + user, + &"http://example.com/reset/#{&1}" + ) + end + + test "get_user_by_reset_password_token/1 returns user for valid token" do + user = create_user() + + {:ok, %Swoosh.Email{} = email} = + Auth.deliver_user_reset_password_instructions( + user, + &"http://example.com/reset/#{&1}" + ) + + [_, token] = Regex.run(~r/reset\/([^\s"<]+)/, email.html_body || email.text_body) + + found = Auth.get_user_by_reset_password_token(token) + assert found.uuid == user.uuid + end + + test "reset_user_password/2 updates password and invalidates tokens" do + user = create_user() + session_token = Auth.generate_user_session_token(user) + + {:ok, %Swoosh.Email{} = email} = + Auth.deliver_user_reset_password_instructions( + user, + &"http://example.com/reset/#{&1}" + ) + + [_, token] = Regex.run(~r/reset\/([^\s"<]+)/, email.html_body || email.text_body) + reset_user = Auth.get_user_by_reset_password_token(token) + + assert {:ok, _} = + Auth.reset_user_password(reset_user, %{ + password: @new_password, + password_confirmation: @new_password + }) + + # Old session invalidated + assert is_nil(Auth.get_user_by_session_token(session_token)) + + # New password works + assert {:ok, _} = Auth.get_user_by_email_and_password(user.email, @new_password) + end + + test "returns nil for invalid reset token" do + assert is_nil(Auth.get_user_by_reset_password_token("invalid")) + end + end +end diff --git a/test/integration/users/permissions_test.exs b/test/integration/users/permissions_test.exs new file mode 100644 index 000000000..a7307e3d3 --- /dev/null +++ b/test/integration/users/permissions_test.exs @@ -0,0 +1,356 @@ +defmodule PhoenixKit.Integration.Users.PermissionsTest do + use PhoenixKit.DataCase, async: true + + alias PhoenixKit.Users.Auth + alias PhoenixKit.Users.Auth.Scope + alias PhoenixKit.Users.Permissions + alias PhoenixKit.Users.Roles + + defp unique_email, do: "perms_#{System.unique_integer([:positive])}@example.com" + + defp create_user do + {:ok, user} = Auth.register_user(%{email: unique_email(), password: "ValidPassword123!"}) + user + end + + defp create_standard_user do + _owner = create_user() + create_user() + end + + defp get_role_uuid(role_name) do + role = Enum.find(Roles.list_roles(), &(&1.name == role_name)) + role.uuid + end + + describe "grant_permission/3" do + test "grants permission to role" do + role_uuid = get_role_uuid("User") + + assert {:ok, _} = Permissions.grant_permission(role_uuid, "dashboard") + + perms = Permissions.get_permissions_for_role(role_uuid) + assert "dashboard" in perms + end + + test "is idempotent" do + role_uuid = get_role_uuid("User") + + assert {:ok, _} = Permissions.grant_permission(role_uuid, "dashboard") + assert {:ok, _} = Permissions.grant_permission(role_uuid, "dashboard") + end + end + + describe "revoke_permission/2" do + test "removes granted permission" do + role_uuid = get_role_uuid("User") + + {:ok, _} = Permissions.grant_permission(role_uuid, "dashboard") + assert :ok = Permissions.revoke_permission(role_uuid, "dashboard") + + perms = Permissions.get_permissions_for_role(role_uuid) + refute "dashboard" in perms + end + + test "returns error for non-existent permission" do + role_uuid = get_role_uuid("User") + + assert {:error, :not_found} = Permissions.revoke_permission(role_uuid, "nonexistent_key") + end + end + + describe "set_permissions/3" do + test "syncs role to desired permission set" do + role_uuid = get_role_uuid("User") + + {:ok, _} = Permissions.grant_permission(role_uuid, "dashboard") + {:ok, _} = Permissions.grant_permission(role_uuid, "users") + + Permissions.set_permissions(role_uuid, ["dashboard", "settings"]) + + perms = Permissions.get_permissions_for_role(role_uuid) + assert "dashboard" in perms + assert "settings" in perms + refute "users" in perms + end + + test "empty list revokes all permissions" do + role_uuid = get_role_uuid("User") + + {:ok, _} = Permissions.grant_permission(role_uuid, "dashboard") + {:ok, _} = Permissions.grant_permission(role_uuid, "users") + + Permissions.set_permissions(role_uuid, []) + + perms = Permissions.get_permissions_for_role(role_uuid) + assert perms == [] + end + + test "invalid keys are filtered out" do + role_uuid = get_role_uuid("User") + + Permissions.set_permissions(role_uuid, ["dashboard", "totally_fake_key_xyz"]) + + perms = Permissions.get_permissions_for_role(role_uuid) + assert "dashboard" in perms + refute "totally_fake_key_xyz" in perms + end + end + + describe "role_has_permission?/2" do + test "returns true when permission is granted" do + role_uuid = get_role_uuid("User") + {:ok, _} = Permissions.grant_permission(role_uuid, "dashboard") + + assert Permissions.role_has_permission?(role_uuid, "dashboard") + end + + test "returns false when permission is not granted" do + role_uuid = get_role_uuid("User") + + # Clear permissions first + Permissions.set_permissions(role_uuid, []) + + refute Permissions.role_has_permission?(role_uuid, "dashboard") + end + end + + describe "grant_all_permissions/2" do + test "grants all available module permissions to a role" do + role_uuid = get_role_uuid("User") + + # Clear first + Permissions.set_permissions(role_uuid, []) + + Permissions.grant_all_permissions(role_uuid) + + perms = Permissions.get_permissions_for_role(role_uuid) + all_keys = Permissions.all_module_keys() + + Enum.each(all_keys, fn key -> + assert key in perms, "Expected permission #{key} to be granted" + end) + end + end + + describe "copy_permissions/3" do + test "copies all permissions from source to target role" do + source_uuid = get_role_uuid("Admin") + {:ok, target_role} = Roles.create_role(%{name: "CopyTarget"}) + + Permissions.set_permissions(source_uuid, ["dashboard", "users", "settings"]) + + Permissions.copy_permissions(source_uuid, target_role.uuid) + + target_perms = Permissions.get_permissions_for_role(target_role.uuid) + assert "dashboard" in target_perms + assert "users" in target_perms + assert "settings" in target_perms + end + end + + describe "count_permissions_for_role/1" do + test "returns correct count" do + role_uuid = get_role_uuid("User") + + Permissions.set_permissions(role_uuid, ["dashboard", "users"]) + + assert Permissions.count_permissions_for_role(role_uuid) == 2 + end + + test "returns 0 when no permissions" do + {:ok, role} = Roles.create_role(%{name: "NoPerm"}) + + assert Permissions.count_permissions_for_role(role.uuid) == 0 + end + end + + describe "get_permissions_for_user/1" do + test "returns permissions from user's roles" do + user = create_standard_user() + role_uuid = get_role_uuid("User") + + {:ok, _} = Permissions.grant_permission(role_uuid, "dashboard") + + perms = Permissions.get_permissions_for_user(user) + assert "dashboard" in perms + end + + test "returns empty list for nil user" do + assert Permissions.get_permissions_for_user(nil) == [] + end + end + + describe "multiple roles merge permissions" do + test "user with two roles gets union of permissions" do + user = create_standard_user() + user_role_uuid = get_role_uuid("User") + admin_role_uuid = get_role_uuid("Admin") + + Permissions.set_permissions(user_role_uuid, ["dashboard"]) + Permissions.set_permissions(admin_role_uuid, ["users", "settings"]) + + {:ok, _} = Roles.assign_role(user, "Admin") + + perms = Permissions.get_permissions_for_user(user) + assert "dashboard" in perms + assert "users" in perms + assert "settings" in perms + end + end + + describe "Scope integration with permissions" do + test "Owner scope has all permissions" do + # First user gets Owner + owner = create_user() + + scope = Scope.for_user(owner) + assert Scope.owner?(scope) + assert Scope.has_module_access?(scope, "dashboard") + assert Scope.has_module_access?(scope, "users") + assert Scope.has_module_access?(scope, "settings") + end + + test "User scope reflects granted permissions" do + user = create_standard_user() + role_uuid = get_role_uuid("User") + + Permissions.set_permissions(role_uuid, ["dashboard", "posts"]) + + scope = Scope.for_user(user) + assert Scope.has_module_access?(scope, "dashboard") + assert Scope.has_module_access?(scope, "posts") + refute Scope.has_module_access?(scope, "settings") + end + + test "Admin scope with explicit permissions" do + user = create_standard_user() + {:ok, _} = Roles.assign_role(user, "Admin") + + admin_uuid = get_role_uuid("Admin") + Permissions.set_permissions(admin_uuid, ["dashboard", "users", "settings"]) + + scope = Scope.for_user(user) + assert Scope.admin?(scope) + assert Scope.has_module_access?(scope, "dashboard") + assert Scope.has_module_access?(scope, "users") + end + + test "has_any_module_access?/2 checks multiple keys" do + user = create_standard_user() + role_uuid = get_role_uuid("User") + Permissions.set_permissions(role_uuid, ["dashboard"]) + + scope = Scope.for_user(user) + assert Scope.has_any_module_access?(scope, ["dashboard", "settings"]) + refute Scope.has_any_module_access?(scope, ["billing", "shop"]) + end + + test "has_all_module_access?/2 requires all keys" do + user = create_standard_user() + role_uuid = get_role_uuid("User") + Permissions.set_permissions(role_uuid, ["dashboard", "users"]) + + scope = Scope.for_user(user) + assert Scope.has_all_module_access?(scope, ["dashboard", "users"]) + refute Scope.has_all_module_access?(scope, ["dashboard", "users", "settings"]) + end + end + + describe "Scope.for_user/1" do + test "nil user creates anonymous scope" do + scope = Scope.for_user(nil) + + refute Scope.authenticated?(scope) + refute Scope.owner?(scope) + refute Scope.admin?(scope) + assert Scope.user_roles(scope) == [] + assert Scope.accessible_modules(scope) == MapSet.new() + assert Scope.permission_count(scope) == 0 + end + + test "authenticated? returns true for real user" do + user = create_user() + + scope = Scope.for_user(user) + assert Scope.authenticated?(scope) + end + end + + describe "Scope.system_role?/1" do + test "returns true for Owner" do + owner = create_user() + + scope = Scope.for_user(owner) + assert Scope.system_role?(scope) + end + + test "returns true for Admin" do + user = create_standard_user() + {:ok, _} = Roles.assign_role(user, "Admin") + + scope = Scope.for_user(user) + assert Scope.system_role?(scope) + end + + test "returns false for User-only role" do + user = create_standard_user() + + scope = Scope.for_user(user) + refute Scope.system_role?(scope) + end + + test "returns false for anonymous" do + scope = Scope.for_user(nil) + refute Scope.system_role?(scope) + end + end + + describe "Scope.user_roles/1" do + test "returns cached role names" do + user = create_standard_user() + {:ok, _} = Roles.assign_role(user, "Admin") + + scope = Scope.for_user(user) + roles = Scope.user_roles(scope) + + assert "User" in roles + assert "Admin" in roles + end + end + + describe "Scope.accessible_modules/1" do + test "returns MapSet of granted permissions" do + user = create_standard_user() + role_uuid = get_role_uuid("User") + Permissions.set_permissions(role_uuid, ["dashboard", "users"]) + + scope = Scope.for_user(user) + modules = Scope.accessible_modules(scope) + + assert MapSet.member?(modules, "dashboard") + assert MapSet.member?(modules, "users") + end + + test "returns empty MapSet for anonymous" do + scope = Scope.for_user(nil) + assert Scope.accessible_modules(scope) == MapSet.new() + end + end + + describe "Scope.permission_count/1" do + test "returns count of granted permissions" do + user = create_standard_user() + role_uuid = get_role_uuid("User") + Permissions.set_permissions(role_uuid, ["dashboard", "users", "settings"]) + + scope = Scope.for_user(user) + assert Scope.permission_count(scope) == 3 + end + + test "returns 0 for anonymous" do + scope = Scope.for_user(nil) + assert Scope.permission_count(scope) == 0 + end + end +end diff --git a/test/integration/users/profile_test.exs b/test/integration/users/profile_test.exs new file mode 100644 index 000000000..30013f5f2 --- /dev/null +++ b/test/integration/users/profile_test.exs @@ -0,0 +1,211 @@ +defmodule PhoenixKit.Integration.Users.ProfileTest do + use PhoenixKit.DataCase, async: true + + alias PhoenixKit.Users.Auth + alias PhoenixKit.Users.Roles + + defp unique_email, do: "profile_#{System.unique_integer([:positive])}@example.com" + + defp create_user(overrides \\ %{}) do + attrs = Map.merge(%{email: unique_email(), password: "ValidPassword123!"}, overrides) + {:ok, user} = Auth.register_user(attrs) + user + end + + describe "update_user_profile/2" do + test "updates first and last name" do + user = create_user() + + {:ok, updated} = Auth.update_user_profile(user, %{first_name: "Jane", last_name: "Doe"}) + + assert updated.first_name == "Jane" + assert updated.last_name == "Doe" + end + + test "updates username" do + user = create_user() + + {:ok, updated} = Auth.update_user_profile(user, %{username: "newusername"}) + assert updated.username == "newusername" + end + + test "enforces unique username" do + user1 = create_user(%{username: "taken"}) + user2 = create_user() + + {:error, changeset} = Auth.update_user_profile(user2, %{username: user1.username}) + assert errors_on(changeset).username != [] + end + + test "persists changes to database" do + user = create_user() + {:ok, _} = Auth.update_user_profile(user, %{first_name: "Persisted"}) + + reloaded = Auth.get_user(user.uuid) + assert reloaded.first_name == "Persisted" + end + + test "updates timezone" do + user = create_user() + + {:ok, updated} = Auth.update_user_profile(user, %{user_timezone: "-5"}) + assert updated.user_timezone == "-5" + end + + test "empty attrs does not error" do + user = create_user(%{first_name: "Original"}) + + {:ok, updated} = Auth.update_user_profile(user, %{}) + assert updated.first_name == "Original" + end + end + + describe "update_user_custom_fields/2" do + test "stores arbitrary custom fields" do + user = create_user() + + {:ok, updated} = + Auth.update_user_custom_fields(user, %{"company" => "Acme", "department" => "Engineering"}) + + assert updated.custom_fields["company"] == "Acme" + assert updated.custom_fields["department"] == "Engineering" + end + + test "replaces entirely, not deep merge" do + user = create_user() + + {:ok, updated1} = Auth.update_user_custom_fields(user, %{"key1" => "val1"}) + assert updated1.custom_fields["key1"] == "val1" + + {:ok, updated2} = Auth.update_user_custom_fields(updated1, %{"key2" => "val2"}) + assert updated2.custom_fields["key2"] == "val2" + # Previous key replaced (not merged) + refute Map.has_key?(updated2.custom_fields, "key1") + end + + test "empty map clears custom fields" do + user = create_user() + + {:ok, with_fields} = Auth.update_user_custom_fields(user, %{"color" => "blue"}) + assert with_fields.custom_fields["color"] == "blue" + + {:ok, cleared} = Auth.update_user_custom_fields(with_fields, %{}) + assert cleared.custom_fields == %{} + end + + test "stores nested values" do + user = create_user() + + {:ok, updated} = + Auth.update_user_custom_fields(user, %{ + "preferences" => %{"theme" => "dark", "lang" => "en"} + }) + + assert updated.custom_fields["preferences"]["theme"] == "dark" + end + end + + describe "update_user_fields/2" do + test "splits schema fields and custom fields" do + user = create_user() + + {:ok, updated} = + Auth.update_user_fields(user, %{ + "first_name" => "John", + "last_name" => "Smith", + "favorite_color" => "blue" + }) + + assert updated.first_name == "John" + assert updated.last_name == "Smith" + assert updated.custom_fields["favorite_color"] == "blue" + end + + test "handles only schema fields" do + user = create_user() + + {:ok, updated} = Auth.update_user_fields(user, %{"first_name" => "OnlySchema"}) + + assert updated.first_name == "OnlySchema" + end + + test "handles only custom fields" do + user = create_user(%{first_name: "Keep"}) + + {:ok, updated} = Auth.update_user_fields(user, %{"custom_key" => "custom_val"}) + + assert updated.first_name == "Keep" + assert updated.custom_fields["custom_key"] == "custom_val" + end + end + + describe "update_user_status/2" do + test "activates a deactivated user" do + # Create two users so Owner protection doesn't apply + owner = create_user() + user = create_user() + + {:ok, deactivated} = Auth.update_user_status(user, %{is_active: false}) + assert deactivated.is_active == false + + {:ok, activated} = Auth.update_user_status(deactivated, %{is_active: true}) + assert activated.is_active == true + + # Clean up: ensure owner exists to avoid interference + assert Roles.user_has_role?(owner, "Owner") + end + + test "deactivates a non-owner user" do + _owner = create_user() + user = create_user() + + {:ok, deactivated} = Auth.update_user_status(user, %{is_active: false}) + assert deactivated.is_active == false + end + + test "cannot deactivate last Owner" do + owner = create_user() + assert Roles.user_has_role?(owner, "Owner") + + assert {:error, :cannot_deactivate_last_owner} = + Auth.update_user_status(owner, %{is_active: false}) + end + end + + describe "ensure_active_user/1" do + test "returns active user" do + user = create_user() + assert Auth.ensure_active_user(user) + end + + test "returns nil for inactive user" do + _owner = create_user() + user = create_user() + + {:ok, deactivated} = Auth.update_user_status(user, %{is_active: false}) + + assert is_nil(Auth.ensure_active_user(deactivated)) + end + + test "returns nil for nil input" do + assert is_nil(Auth.ensure_active_user(nil)) + end + end + + describe "get_users_by_ids/1" do + test "returns multiple users" do + user1 = create_user() + user2 = create_user() + + found = Auth.get_users_by_ids([user1.uuid, user2.uuid]) + uuids = Enum.map(found, & &1.uuid) + + assert user1.uuid in uuids + assert user2.uuid in uuids + end + + test "returns empty list for no matches" do + assert Auth.get_users_by_ids([UUIDv7.generate()]) == [] + end + end +end diff --git a/test/integration/users/registration_test.exs b/test/integration/users/registration_test.exs new file mode 100644 index 000000000..0e9d92002 --- /dev/null +++ b/test/integration/users/registration_test.exs @@ -0,0 +1,244 @@ +defmodule PhoenixKit.Integration.Users.RegistrationTest do + use PhoenixKit.DataCase, async: true + + alias PhoenixKit.Users.Auth + alias PhoenixKit.Users.Roles + + defp unique_email, do: "user_#{System.unique_integer([:positive])}@example.com" + + defp valid_attrs(overrides \\ %{}) do + Map.merge(%{email: unique_email(), password: "ValidPassword123!"}, overrides) + end + + describe "register_user/2" do + test "creates user with valid attributes" do + {:ok, user} = Auth.register_user(valid_attrs()) + + assert user.uuid + assert user.email + assert user.hashed_password + assert user.is_active == true + assert is_nil(user.confirmed_at) + end + + test "first registered user gets Owner role" do + {:ok, user} = Auth.register_user(valid_attrs()) + + assert Roles.user_has_role?(user, "Owner") + end + + test "second registered user gets User role" do + {:ok, _first} = Auth.register_user(valid_attrs()) + {:ok, second} = Auth.register_user(valid_attrs()) + + assert Roles.user_has_role?(second, "User") + refute Roles.user_has_role?(second, "Owner") + end + + test "hashes password and does not store plaintext" do + password = "ValidPassword123!" + {:ok, user} = Auth.register_user(valid_attrs(%{password: password})) + + assert user.hashed_password != password + assert is_nil(user.password) + end + + test "enforces unique email" do + attrs = valid_attrs() + {:ok, _} = Auth.register_user(attrs) + {:error, changeset} = Auth.register_user(attrs) + + assert "has already been taken" in errors_on(changeset).email + end + + test "validates email format" do + {:error, changeset} = Auth.register_user(valid_attrs(%{email: "not-an-email"})) + + assert errors_on(changeset).email != [] + end + + test "validates password length" do + {:error, changeset} = Auth.register_user(valid_attrs(%{password: "short"})) + + assert errors_on(changeset).password != [] + end + + test "rejects missing email" do + {:error, changeset} = Auth.register_user(%{password: "ValidPassword123!"}) + + assert errors_on(changeset).email != [] + end + + test "rejects missing password" do + {:error, changeset} = Auth.register_user(%{email: unique_email()}) + + assert errors_on(changeset).password != [] + end + + test "stores optional fields" do + attrs = valid_attrs(%{first_name: "Jane", last_name: "Doe", username: "janedoe"}) + {:ok, user} = Auth.register_user(attrs) + + assert user.first_name == "Jane" + assert user.last_name == "Doe" + assert user.username == "janedoe" + end + end + + describe "get_user_by_email/1" do + test "returns user for existing email" do + {:ok, user} = Auth.register_user(valid_attrs()) + + found = Auth.get_user_by_email(user.email) + assert found.uuid == user.uuid + end + + test "returns nil for nonexistent email" do + assert is_nil(Auth.get_user_by_email("nonexistent@example.com")) + end + end + + describe "get_user/1" do + test "returns user by uuid" do + {:ok, user} = Auth.register_user(valid_attrs()) + + found = Auth.get_user(user.uuid) + assert found.uuid == user.uuid + assert found.email == user.email + end + + test "returns nil for nonexistent uuid" do + assert is_nil(Auth.get_user(UUIDv7.generate())) + end + end + + describe "get_user!/1" do + test "returns user by uuid" do + {:ok, user} = Auth.register_user(valid_attrs()) + + found = Auth.get_user!(user.uuid) + assert found.uuid == user.uuid + end + + test "raises for nonexistent uuid" do + assert_raise Ecto.NoResultsError, fn -> + Auth.get_user!(UUIDv7.generate()) + end + end + end + + describe "get_first_user/0" do + test "returns earliest registered user" do + {:ok, first} = Auth.register_user(valid_attrs()) + {:ok, _second} = Auth.register_user(valid_attrs()) + + assert Auth.get_first_user().uuid == first.uuid + end + end + + describe "get_user_by_username/1" do + test "returns user for existing username" do + {:ok, user} = Auth.register_user(valid_attrs(%{username: "testuser_lookup"})) + + found = Auth.get_user_by_username("testuser_lookup") + assert found.uuid == user.uuid + end + + test "returns nil for nonexistent username" do + assert is_nil(Auth.get_user_by_username("nonexistent_user_xyz")) + end + + test "username lookup is exact match" do + {:ok, _user} = Auth.register_user(valid_attrs(%{username: "exactmatch"})) + + # Different case should not match (username stored as-is) + assert is_nil(Auth.get_user_by_username("EXACTMATCH")) + end + end + + describe "get_user_by_email_or_username/1" do + test "detects email by @ sign and looks up by email" do + {:ok, user} = Auth.register_user(valid_attrs(%{username: "emailoruser"})) + + found = Auth.get_user_by_email_or_username(user.email) + assert found.uuid == user.uuid + end + + test "treats input without @ as username" do + {:ok, user} = Auth.register_user(valid_attrs(%{username: "myusername"})) + + found = Auth.get_user_by_email_or_username("myusername") + assert found.uuid == user.uuid + end + + test "returns nil for nonexistent email" do + assert is_nil(Auth.get_user_by_email_or_username("nobody@nowhere.com")) + end + + test "returns nil for nonexistent username" do + assert is_nil(Auth.get_user_by_email_or_username("ghost_user")) + end + end + + describe "get_first_admin/0" do + test "returns the first Owner user" do + {:ok, owner} = Auth.register_user(valid_attrs()) + {:ok, _user} = Auth.register_user(valid_attrs()) + + admin = Auth.get_first_admin() + assert admin.uuid == owner.uuid + end + end + + describe "get_first_admin_uuid/0" do + test "returns uuid of the first Owner user" do + {:ok, owner} = Auth.register_user(valid_attrs()) + + assert Auth.get_first_admin_uuid() == owner.uuid + end + end + + describe "get_first_user_uuid/0" do + test "returns uuid of the first registered user" do + {:ok, first} = Auth.register_user(valid_attrs()) + {:ok, _second} = Auth.register_user(valid_attrs()) + + assert Auth.get_first_user_uuid() == first.uuid + end + end + + describe "create_guest_user/1" do + test "creates new guest user with no existing email" do + email = unique_email() + + assert {:ok, guest} = + Auth.create_guest_user(%{email: email, first_name: "Guest", last_name: "User"}) + + assert guest.email == email + assert guest.first_name == "Guest" + assert guest.last_name == "User" + assert Roles.user_has_role?(guest, "User") + end + + test "returns error for confirmed email" do + {:ok, user} = Auth.register_user(valid_attrs()) + {:ok, _confirmed} = Auth.admin_confirm_user(user) + + assert {:error, :email_exists_confirmed} = + Auth.create_guest_user(%{email: user.email, first_name: "Test"}) + end + + test "returns error with existing user for unconfirmed email" do + # First user gets Owner (auto-confirmed), so create a second user + {:ok, _owner} = Auth.register_user(valid_attrs()) + {:ok, user} = Auth.register_user(valid_attrs()) + # Second user is unconfirmed + assert is_nil(user.confirmed_at) + + assert {:error, :email_exists_unconfirmed, existing} = + Auth.create_guest_user(%{email: user.email, first_name: "Updated"}) + + assert existing.uuid == user.uuid + end + end +end diff --git a/test/integration/users/roles_test.exs b/test/integration/users/roles_test.exs new file mode 100644 index 000000000..05585e514 --- /dev/null +++ b/test/integration/users/roles_test.exs @@ -0,0 +1,236 @@ +defmodule PhoenixKit.Integration.Users.RolesTest do + use PhoenixKit.DataCase, async: true + + alias PhoenixKit.Users.Auth + alias PhoenixKit.Users.Roles + + defp unique_email, do: "roles_#{System.unique_integer([:positive])}@example.com" + + defp create_user do + {:ok, user} = Auth.register_user(%{email: unique_email(), password: "ValidPassword123!"}) + user + end + + # Create two users so first gets Owner, second gets User role + defp create_standard_user do + _owner = create_user() + create_user() + end + + describe "list_roles/0" do + test "returns system roles from migrations" do + roles = Roles.list_roles() + role_names = Enum.map(roles, & &1.name) + + assert "Owner" in role_names + assert "Admin" in role_names + assert "User" in role_names + end + + test "system roles are flagged" do + roles = Roles.list_roles() + + Enum.each(roles, fn role -> + if role.name in ["Owner", "Admin", "User"] do + assert role.is_system_role + end + end) + end + end + + describe "assign_role/4" do + test "assigns Admin role to user" do + user = create_standard_user() + + assert {:ok, _assignment} = Roles.assign_role(user, "Admin") + assert Roles.user_has_role?(user, "Admin") + end + + test "prevents manual Owner role assignment" do + user = create_standard_user() + + assert {:error, :owner_role_protected} = Roles.assign_role(user, "Owner") + end + + test "is idempotent - assigning same role twice succeeds" do + user = create_standard_user() + + assert {:ok, _} = Roles.assign_role(user, "Admin") + assert {:ok, _} = Roles.assign_role(user, "Admin") + assert Roles.user_has_role?(user, "Admin") + end + + test "assigns role with assigned_by parameter" do + owner = create_user() + user = create_user() + + assert {:ok, assignment} = Roles.assign_role(user, "Admin", owner) + assert assignment + assert Roles.user_has_role?(user, "Admin") + end + end + + describe "remove_role/3" do + test "removes assigned role" do + user = create_standard_user() + {:ok, _} = Roles.assign_role(user, "Admin") + + assert {:ok, _} = Roles.remove_role(user, "Admin") + refute Roles.user_has_role?(user, "Admin") + end + + test "returns error for non-assigned role" do + user = create_standard_user() + + assert {:error, :assignment_not_found} = Roles.remove_role(user, "Admin") + end + end + + describe "user_has_role?/2" do + test "returns true for assigned role" do + user = create_standard_user() + assert Roles.user_has_role?(user, "User") + end + + test "returns false for unassigned role" do + user = create_standard_user() + refute Roles.user_has_role?(user, "Admin") + end + end + + describe "get_user_roles/1" do + test "returns list of role names" do + user = create_standard_user() + roles = Roles.get_user_roles(user) + + assert "User" in roles + end + + test "returns multiple roles" do + user = create_standard_user() + {:ok, _} = Roles.assign_role(user, "Admin") + + roles = Roles.get_user_roles(user) + assert "User" in roles + assert "Admin" in roles + end + end + + describe "get_role_stats/0" do + test "returns role distribution" do + _owner = create_user() + _user = create_user() + + stats = Roles.get_role_stats() + + assert stats.total_users >= 2 + assert stats.owner_count >= 1 + assert is_integer(stats.admin_count) + assert is_integer(stats.user_count) + end + end + + describe "promote_to_admin/2 and demote_to_user/1" do + test "promotes user to Admin" do + user = create_standard_user() + + assert {:ok, _} = Roles.promote_to_admin(user) + assert Roles.user_has_role?(user, "Admin") + end + + test "demotes admin back to User" do + user = create_standard_user() + {:ok, _} = Roles.promote_to_admin(user) + + assert {:ok, _} = Roles.demote_to_user(user) + refute Roles.user_has_role?(user, "Admin") + assert Roles.user_has_role?(user, "User") + end + end + + describe "users_with_role/1" do + test "returns users with the specified role" do + owner = create_user() + _user = create_user() + + owners = Roles.users_with_role("Owner") + owner_uuids = Enum.map(owners, & &1.uuid) + + assert owner.uuid in owner_uuids + end + + test "returns empty list for nonexistent role" do + assert Roles.users_with_role("NonexistentRole") == [] + end + end + + describe "count_users_with_role/1" do + test "returns correct count for Owner" do + _owner = create_user() + _user = create_user() + + assert Roles.count_users_with_role("Owner") >= 1 + end + + test "returns correct count for User role" do + _owner = create_user() + _user = create_user() + + assert Roles.count_users_with_role("User") >= 1 + end + + test "returns 0 for nonexistent role" do + assert Roles.count_users_with_role("NonexistentRole") == 0 + end + end + + describe "custom role CRUD" do + test "create_role/1 creates a custom role" do + assert {:ok, role} = Roles.create_role(%{name: "Editor", description: "Can edit content"}) + + assert role.name == "Editor" + assert role.is_system_role == false + end + + test "update_role/2 updates a custom role" do + {:ok, role} = Roles.create_role(%{name: "Reviewer"}) + + assert {:ok, updated} = Roles.update_role(role, %{description: "Reviews submissions"}) + assert updated.description == "Reviews submissions" + end + + test "delete_role/1 deletes a custom role with no assignments" do + {:ok, role} = Roles.create_role(%{name: "Temporary"}) + + assert {:ok, _deleted} = Roles.delete_role(role) + end + + test "delete_role/1 prevents deletion of system role" do + system_role = Enum.find(Roles.list_roles(), &(&1.name == "User")) + + assert {:error, :system_role_protected} = Roles.delete_role(system_role) + end + + test "delete_role/1 prevents deletion of role with assignments" do + {:ok, role} = Roles.create_role(%{name: "InUseRole"}) + user = create_standard_user() + {:ok, _} = Roles.assign_role(user, "InUseRole") + + assert {:error, :role_in_use} = Roles.delete_role(role) + end + end + + describe "get_custom_roles/0" do + test "returns only non-system roles" do + {:ok, _} = Roles.create_role(%{name: "CustomOnly"}) + + custom_roles = Roles.get_custom_roles() + custom_names = Enum.map(custom_roles, & &1.name) + + assert "CustomOnly" in custom_names + refute "Owner" in custom_names + refute "Admin" in custom_names + refute "User" in custom_names + end + end +end diff --git a/test/modules/publishing/integration/groups_test.exs b/test/modules/publishing/integration/groups_test.exs new file mode 100644 index 000000000..cca9994e9 --- /dev/null +++ b/test/modules/publishing/integration/groups_test.exs @@ -0,0 +1,342 @@ +defmodule PhoenixKit.Integration.Publishing.GroupsTest do + use PhoenixKit.DataCase, async: true + + alias PhoenixKit.Modules.Publishing.Groups + alias PhoenixKit.Modules.Publishing.Posts + + defp unique_name, do: "Test Group #{System.unique_integer([:positive])}" + + # ============================================================================ + # add_group/2 + # ============================================================================ + + describe "add_group/2" do + test "creates group with defaults (timestamp mode, blog type)" do + {:ok, group} = Groups.add_group(unique_name()) + + assert group["slug"] + assert group["mode"] == "timestamp" + assert group["status"] == "active" + assert group["type"] == "blog" + assert group["item_singular"] == "post" + assert group["item_plural"] == "posts" + end + + test "creates slug-mode group" do + {:ok, group} = Groups.add_group(unique_name(), mode: "slug") + assert group["mode"] == "slug" + end + + test "creates faq type group" do + {:ok, group} = Groups.add_group(unique_name(), type: "faq") + assert group["item_singular"] == "question" + assert group["item_plural"] == "questions" + end + + test "creates legal type group" do + {:ok, group} = Groups.add_group(unique_name(), type: "legal") + assert group["item_singular"] == "document" + assert group["item_plural"] == "documents" + end + + test "creates group with custom slug" do + slug = "custom-slug-#{System.unique_integer([:positive])}" + {:ok, group} = Groups.add_group(unique_name(), slug: slug) + assert group["slug"] == slug + end + + test "creates group with custom item names" do + {:ok, group} = + Groups.add_group(unique_name(), item_singular: "recipe", item_plural: "recipes") + + assert group["item_singular"] == "recipe" + assert group["item_plural"] == "recipes" + end + + test "creates group with all options combined" do + {:ok, group} = + Groups.add_group(unique_name(), + mode: "slug", + type: "faq", + item_singular: "entry", + item_plural: "entries" + ) + + assert group["mode"] == "slug" + assert group["item_singular"] == "entry" + assert group["item_plural"] == "entries" + end + + test "auto-generates unique slug for duplicate names" do + name = unique_name() + {:ok, first} = Groups.add_group(name) + {:ok, second} = Groups.add_group(name) + assert first["slug"] != second["slug"] + end + + test "rejects empty name" do + assert {:error, :invalid_name} = Groups.add_group("") + end + + test "rejects whitespace-only name" do + assert {:error, :invalid_name} = Groups.add_group(" ") + end + + test "invalid mode falls back to default" do + {:ok, group} = Groups.add_group(unique_name(), mode: "invalid") + assert group["mode"] == "timestamp" + end + + test "normalizes mode case" do + {:ok, group} = Groups.add_group(unique_name(), mode: "SLUG") + assert group["mode"] == "slug" + end + + test "auto-generates slug from name" do + {:ok, group} = Groups.add_group("My Great Blog") + assert group["slug"] =~ "my-great-blog" + end + + test "map opts work same as keyword opts" do + {:ok, group} = Groups.add_group(unique_name(), %{mode: "slug", type: "faq"}) + assert group["mode"] == "slug" + end + end + + # ============================================================================ + # get_group/1 + # ============================================================================ + + describe "get_group/1" do + test "returns group by slug" do + {:ok, created} = Groups.add_group(unique_name()) + assert {:ok, found} = Groups.get_group(created["slug"]) + assert found["slug"] == created["slug"] + assert found["name"] == created["name"] + end + + test "returns all data fields" do + {:ok, created} = Groups.add_group(unique_name(), type: "faq", mode: "slug") + {:ok, found} = Groups.get_group(created["slug"]) + + assert found["mode"] == "slug" + assert found["status"] == "active" + assert found["item_singular"] == "question" + end + + test "returns trashed group (get_group finds any status)" do + {:ok, group} = Groups.add_group(unique_name()) + {:ok, _} = Groups.trash_group(group["slug"]) + {:ok, found} = Groups.get_group(group["slug"]) + assert found["status"] == "trashed" + end + + test "returns error for nonexistent slug" do + assert {:error, :not_found} = Groups.get_group("nonexistent-slug") + end + end + + # ============================================================================ + # list_groups/0 and list_groups/1 + # ============================================================================ + + describe "list_groups/0 and list_groups/1" do + test "lists active groups" do + {:ok, group} = Groups.add_group(unique_name()) + groups = Groups.list_groups() + slugs = Enum.map(groups, & &1["slug"]) + assert group["slug"] in slugs + end + + test "excludes trashed groups from default listing" do + {:ok, group} = Groups.add_group(unique_name()) + {:ok, _} = Groups.trash_group(group["slug"]) + groups = Groups.list_groups() + slugs = Enum.map(groups, & &1["slug"]) + refute group["slug"] in slugs + end + + test "lists trashed groups when filtered" do + {:ok, group} = Groups.add_group(unique_name()) + {:ok, _} = Groups.trash_group(group["slug"]) + trashed = Groups.list_groups("trashed") + slugs = Enum.map(trashed, & &1["slug"]) + assert group["slug"] in slugs + end + + test "returns maps with expected keys" do + {:ok, _} = Groups.add_group(unique_name()) + [group | _] = Groups.list_groups() + + assert Map.has_key?(group, "slug") + assert Map.has_key?(group, "name") + assert Map.has_key?(group, "mode") + assert Map.has_key?(group, "status") + end + end + + # ============================================================================ + # update_group/2 + # ============================================================================ + + describe "update_group/2" do + test "updates group name" do + {:ok, group} = Groups.add_group(unique_name()) + new_name = unique_name() + {:ok, updated} = Groups.update_group(group["slug"], %{name: new_name}) + assert updated["name"] == new_name + end + + test "updates group slug" do + {:ok, group} = Groups.add_group(unique_name()) + new_slug = "updated-slug-#{System.unique_integer([:positive])}" + {:ok, updated} = Groups.update_group(group["slug"], %{slug: new_slug}) + assert updated["slug"] == new_slug + assert {:error, :not_found} = Groups.get_group(group["slug"]) + end + + test "preserves unchanged fields on partial update" do + {:ok, group} = Groups.add_group(unique_name(), mode: "slug") + {:ok, updated} = Groups.update_group(group["slug"], %{name: "New Name"}) + assert updated["mode"] == "slug" + assert updated["slug"] == group["slug"] + end + + test "rejects empty name update" do + {:ok, group} = Groups.add_group(unique_name()) + assert {:error, :invalid_name} = Groups.update_group(group["slug"], %{name: ""}) + end + + test "returns error for nonexistent group" do + assert {:error, :not_found} = Groups.update_group("nonexistent", %{name: "New"}) + end + end + + # ============================================================================ + # trash_group/1 and restore_group/1 + # ============================================================================ + + describe "trash and restore lifecycle" do + test "trash_group/1 soft-deletes group" do + {:ok, group} = Groups.add_group(unique_name()) + assert {:ok, _slug} = Groups.trash_group(group["slug"]) + {:ok, found} = Groups.get_group(group["slug"]) + assert found["status"] == "trashed" + end + + test "restore_group/1 restores trashed group" do + {:ok, group} = Groups.add_group(unique_name()) + {:ok, _} = Groups.trash_group(group["slug"]) + assert {:ok, _slug} = Groups.restore_group(group["slug"]) + {:ok, found} = Groups.get_group(group["slug"]) + assert found["status"] == "active" + end + + test "trashed group not in default listing, restored group is" do + {:ok, group} = Groups.add_group(unique_name()) + slug = group["slug"] + + {:ok, _} = Groups.trash_group(slug) + refute slug in Enum.map(Groups.list_groups(), & &1["slug"]) + + {:ok, _} = Groups.restore_group(slug) + assert slug in Enum.map(Groups.list_groups(), & &1["slug"]) + end + end + + # ============================================================================ + # remove_group/2 + # ============================================================================ + + describe "remove_group/2" do + test "hard-deletes empty group" do + {:ok, group} = Groups.add_group(unique_name()) + assert {:ok, _} = Groups.remove_group(group["slug"]) + assert {:error, :not_found} = Groups.get_group(group["slug"]) + end + + test "refuses to delete group with posts unless forced" do + {:ok, group} = Groups.add_group(unique_name()) + {:ok, _post} = Posts.create_post(group["slug"], %{}) + assert {:error, {:has_posts, count}} = Groups.remove_group(group["slug"]) + assert count >= 1 + end + + test "force-deletes group with posts" do + {:ok, group} = Groups.add_group(unique_name()) + {:ok, _post} = Posts.create_post(group["slug"], %{}) + assert {:ok, _} = Groups.remove_group(group["slug"], force: true) + assert {:error, :not_found} = Groups.get_group(group["slug"]) + end + + test "force-delete cascades to all posts and versions" do + {:ok, group} = Groups.add_group(unique_name()) + {:ok, post} = Posts.create_post(group["slug"], %{title: "Will Be Deleted"}) + {:ok, _} = Groups.remove_group(group["slug"], force: true) + + # Post should be gone + assert {:error, _} = Posts.read_post(group["slug"], post[:uuid], nil, nil) + end + + test "can remove trashed group" do + {:ok, group} = Groups.add_group(unique_name()) + {:ok, _} = Groups.trash_group(group["slug"]) + assert {:ok, _} = Groups.remove_group(group["slug"]) + assert {:error, :not_found} = Groups.get_group(group["slug"]) + end + end + + # ============================================================================ + # group_name/1 and get_group_mode/1 + # ============================================================================ + + describe "group_name/1" do + test "returns group name by slug" do + {:ok, group} = Groups.add_group(unique_name()) + assert Groups.group_name(group["slug"]) == group["name"] + end + + test "returns nil for nonexistent slug" do + assert Groups.group_name("nonexistent") == nil + end + end + + describe "get_group_mode/1" do + test "returns timestamp for timestamp-mode group" do + {:ok, group} = Groups.add_group(unique_name(), mode: "timestamp") + assert Groups.get_group_mode(group["slug"]) == "timestamp" + end + + test "returns slug for slug-mode group" do + {:ok, group} = Groups.add_group(unique_name(), mode: "slug") + assert Groups.get_group_mode(group["slug"]) == "slug" + end + end + + # ============================================================================ + # preset_types/0 and valid_types/0 + # ============================================================================ + + describe "preset_types/0" do + test "returns list of type definitions with required fields" do + types = Groups.preset_types() + assert length(types) >= 3 + + for type <- types do + assert Map.has_key?(type, :type) + assert Map.has_key?(type, :label) + assert Map.has_key?(type, :item_singular) + assert Map.has_key?(type, :item_plural) + end + end + end + + describe "valid_types/0" do + test "includes all preset types" do + valid = Groups.valid_types() + assert "blog" in valid + assert "faq" in valid + assert "legal" in valid + end + end +end diff --git a/test/modules/publishing/integration/posts_test.exs b/test/modules/publishing/integration/posts_test.exs new file mode 100644 index 000000000..6608825f4 --- /dev/null +++ b/test/modules/publishing/integration/posts_test.exs @@ -0,0 +1,398 @@ +defmodule PhoenixKit.Integration.Publishing.PostsTest do + use PhoenixKit.DataCase, async: true + + alias PhoenixKit.Modules.Publishing.Groups + alias PhoenixKit.Modules.Publishing.Posts + alias PhoenixKit.Modules.Publishing.Versions + + defp unique_name, do: "Posts Group #{System.unique_integer([:positive])}" + + defp create_group(mode) do + {:ok, group} = Groups.add_group(unique_name(), mode: mode) + group + end + + # ============================================================================ + # create_post/2 — timestamp mode + # ============================================================================ + + describe "create_post/2 in timestamp mode" do + test "creates post with auto-generated timestamp" do + group = create_group("timestamp") + {:ok, post} = Posts.create_post(group["slug"], %{}) + + assert post[:uuid] + assert post[:date] + assert post[:time] + assert post[:version] == 1 + assert post[:primary_language] == "en" + assert post[:mode] in ["timestamp", :timestamp] + end + + test "creates post with title" do + group = create_group("timestamp") + {:ok, post} = Posts.create_post(group["slug"], %{title: "My First Post"}) + assert post[:metadata][:title] == "My First Post" + end + + test "creates post with content" do + group = create_group("timestamp") + {:ok, post} = Posts.create_post(group["slug"], %{content: "
Hello world
"}) + assert post[:content] == "Hello world
" + end + + test "auto-increments time on collision" do + group = create_group("timestamp") + {:ok, post1} = Posts.create_post(group["slug"], %{}) + {:ok, post2} = Posts.create_post(group["slug"], %{}) + assert post1[:uuid] != post2[:uuid] + assert post1[:date] == post2[:date] + end + + test "status defaults to draft" do + group = create_group("timestamp") + {:ok, post} = Posts.create_post(group["slug"], %{}) + assert post[:metadata][:status] == "draft" + end + + test "creates version 1 and primary language content automatically" do + group = create_group("timestamp") + {:ok, post} = Posts.create_post(group["slug"], %{title: "Auto V1"}) + + assert post[:version] == 1 + assert post[:language] == "en" + end + end + + # ============================================================================ + # create_post/2 — slug mode + # ============================================================================ + + describe "create_post/2 in slug mode" do + test "creates post with auto-generated slug from title" do + group = create_group("slug") + {:ok, post} = Posts.create_post(group["slug"], %{title: "My Slug Post"}) + assert post[:slug] + assert post[:version] == 1 + assert post[:mode] in ["slug", :slug] + end + + test "creates post with custom slug" do + group = create_group("slug") + {:ok, post} = Posts.create_post(group["slug"], %{slug: "custom-slug"}) + assert post[:slug] == "custom-slug" + end + + test "creates post without title" do + group = create_group("slug") + {:ok, post} = Posts.create_post(group["slug"], %{}) + assert post[:uuid] + assert post[:slug] + end + + test "posts in different groups can share slugs" do + group1 = create_group("slug") + group2 = create_group("slug") + {:ok, p1} = Posts.create_post(group1["slug"], %{slug: "shared-slug"}) + {:ok, p2} = Posts.create_post(group2["slug"], %{slug: "shared-slug"}) + assert p1[:uuid] != p2[:uuid] + end + end + + # ============================================================================ + # read_post/4 + # ============================================================================ + + describe "read_post/4" do + test "reads post by uuid" do + group = create_group("timestamp") + {:ok, created} = Posts.create_post(group["slug"], %{title: "Read Me"}) + {:ok, post} = Posts.read_post(group["slug"], created[:uuid], nil, nil) + assert post[:uuid] == created[:uuid] + assert post[:metadata][:title] == "Read Me" + end + + test "reads post by slug in slug mode" do + group = create_group("slug") + {:ok, created} = Posts.create_post(group["slug"], %{slug: "readable-post"}) + {:ok, post} = Posts.read_post(group["slug"], "readable-post", nil, nil) + assert post[:uuid] == created[:uuid] + end + + test "returns full post map structure" do + group = create_group("slug") + {:ok, created} = Posts.create_post(group["slug"], %{title: "Full Structure"}) + {:ok, post} = Posts.read_post(group["slug"], created[:uuid], nil, nil) + + assert post[:uuid] + assert post[:version] + assert post[:language] + assert post[:primary_language] + assert post[:metadata] + assert post[:available_versions] + assert is_list(post[:available_versions]) + end + + test "reads specific version" do + group = create_group("slug") + {:ok, created} = Posts.create_post(group["slug"], %{title: "V1"}) + {:ok, _v2} = Versions.create_new_version(group["slug"], created, %{}, %{}) + + {:ok, v1} = Posts.read_post(group["slug"], created[:uuid], nil, 1) + assert v1[:version] == 1 + + {:ok, v2} = Posts.read_post(group["slug"], created[:uuid], nil, 2) + assert v2[:version] == 2 + end + + test "added language appears in available_languages" do + group = create_group("slug") + {:ok, created} = Posts.create_post(group["slug"], %{title: "English"}) + + alias PhoenixKit.Modules.Publishing.TranslationManager + {:ok, _} = TranslationManager.add_language_to_post(group["slug"], created[:uuid], "de", nil) + + {:ok, post} = Posts.read_post(group["slug"], created[:uuid], nil, nil) + assert "de" in post[:available_languages] + end + + test "returns error for nonexistent post" do + group = create_group("timestamp") + assert {:error, _} = Posts.read_post(group["slug"], "nonexistent", nil, nil) + end + + test "defaults to latest version when nil" do + group = create_group("slug") + {:ok, created} = Posts.create_post(group["slug"], %{title: "V1"}) + {:ok, _v2} = Versions.create_new_version(group["slug"], created, %{}, %{}) + + {:ok, post} = Posts.read_post(group["slug"], created[:uuid], nil, nil) + assert post[:version] == 2 + end + end + + # ============================================================================ + # update_post/4 + # ============================================================================ + + describe "update_post/4" do + test "updates post title" do + group = create_group("slug") + {:ok, post} = Posts.create_post(group["slug"], %{title: "Original"}) + + {:ok, updated} = + Posts.update_post(group["slug"], post, %{"title" => "Updated Title"}, %{}) + + assert updated[:metadata][:title] == "Updated Title" + end + + test "updates post content" do + group = create_group("slug") + {:ok, post} = Posts.create_post(group["slug"], %{title: "Has Content"}) + + {:ok, updated} = + Posts.update_post(group["slug"], post, %{"content" => "New body
"}, %{}) + + assert updated[:content] == "New body
" + end + + test "returns updated post map" do + group = create_group("slug") + {:ok, post} = Posts.create_post(group["slug"], %{title: "Check Return"}) + + {:ok, updated} = + Posts.update_post(group["slug"], post, %{"title" => "New"}, %{}) + + assert updated[:uuid] == post[:uuid] + assert updated[:version] + assert updated[:metadata] + end + end + + # ============================================================================ + # list_posts/2 + # ============================================================================ + + describe "list_posts/2" do + test "lists posts in group" do + group = create_group("timestamp") + {:ok, _} = Posts.create_post(group["slug"], %{title: "Post 1"}) + {:ok, _} = Posts.create_post(group["slug"], %{title: "Post 2"}) + posts = Posts.list_posts(group["slug"], nil) + assert length(posts) >= 2 + end + + test "returns empty list for empty group" do + group = create_group("timestamp") + assert Posts.list_posts(group["slug"], nil) == [] + end + + test "does not list trashed posts" do + group = create_group("slug") + {:ok, post} = Posts.create_post(group["slug"], %{title: "Trashable"}) + {:ok, _} = Posts.trash_post(group["slug"], post[:uuid]) + + posts = Posts.list_posts(group["slug"], nil) + uuids = Enum.map(posts, & &1[:uuid]) + refute post[:uuid] in uuids + end + + test "does not list posts from other groups" do + group1 = create_group("slug") + group2 = create_group("slug") + {:ok, p1} = Posts.create_post(group1["slug"], %{title: "Group 1"}) + {:ok, _} = Posts.create_post(group2["slug"], %{title: "Group 2"}) + + posts = Posts.list_posts(group1["slug"], nil) + uuids = Enum.map(posts, & &1[:uuid]) + assert p1[:uuid] in uuids + assert length(posts) == 1 + end + end + + # ============================================================================ + # list_posts_by_status/2 + # ============================================================================ + + describe "list_posts_by_status/2" do + test "lists only published posts" do + group = create_group("slug") + {:ok, draft} = Posts.create_post(group["slug"], %{title: "Draft"}) + {:ok, pub} = Posts.create_post(group["slug"], %{title: "Published"}) + Posts.change_post_status(group["slug"], pub[:uuid], "published") + + published = Posts.list_posts_by_status(group["slug"], "published") + uuids = Enum.map(published, &(&1[:uuid] || &1.uuid)) + + assert pub[:uuid] in uuids + refute draft[:uuid] in uuids + end + + test "lists only draft posts" do + group = create_group("slug") + {:ok, _} = Posts.create_post(group["slug"], %{title: "Draft"}) + + drafts = Posts.list_posts_by_status(group["slug"], "draft") + assert drafts != [] + end + end + + # ============================================================================ + # trash_post/2 and restore_post/2 + # ============================================================================ + + describe "trash_post/2 and restore_post/2" do + test "trashes a post" do + group = create_group("slug") + {:ok, post} = Posts.create_post(group["slug"], %{title: "Trash Me"}) + assert {:ok, _uuid} = Posts.trash_post(group["slug"], post[:uuid]) + end + + test "trashed post is excluded from list_posts" do + group = create_group("slug") + {:ok, post} = Posts.create_post(group["slug"], %{title: "Check Status"}) + {:ok, _} = Posts.trash_post(group["slug"], post[:uuid]) + + posts = Posts.list_posts(group["slug"], nil) + uuids = Enum.map(posts, & &1[:uuid]) + refute post[:uuid] in uuids + end + + test "restores a trashed post to draft" do + group = create_group("slug") + {:ok, post} = Posts.create_post(group["slug"], %{title: "Restore Me"}) + {:ok, _} = Posts.trash_post(group["slug"], post[:uuid]) + assert {:ok, _uuid} = Posts.restore_post(group["slug"], post[:uuid]) + + {:ok, restored} = Posts.read_post(group["slug"], post[:uuid], nil, nil) + status = restored[:status] || restored[:metadata][:status] + assert status == "draft" + end + + test "trash nonexistent post returns error" do + group = create_group("slug") + assert {:error, _} = Posts.trash_post(group["slug"], UUIDv7.generate()) + end + end + + # ============================================================================ + # change_post_status/4 + # ============================================================================ + + describe "change_post_status/4" do + test "publishes a post with title" do + group = create_group("slug") + {:ok, post} = Posts.create_post(group["slug"], %{title: "Publishable"}) + assert {:ok, _} = Posts.change_post_status(group["slug"], post[:uuid], "published") + end + + test "archives a published post" do + group = create_group("slug") + {:ok, post} = Posts.create_post(group["slug"], %{title: "Archive Me"}) + {:ok, _} = Posts.change_post_status(group["slug"], post[:uuid], "published") + assert {:ok, _} = Posts.change_post_status(group["slug"], post[:uuid], "archived") + end + + test "publishing post without title still succeeds via change_post_status" do + # change_post_status delegates to update_post which allows empty title publish + # The title_required validation is on Versions.publish_version, not here + group = create_group("slug") + {:ok, post} = Posts.create_post(group["slug"], %{}) + result = Posts.change_post_status(group["slug"], post[:uuid], "published") + # This may succeed or fail depending on validation path + assert match?({:ok, _}, result) or match?({:error, _}, result) + end + + test "nonexistent post returns error" do + group = create_group("slug") + result = Posts.change_post_status(group["slug"], UUIDv7.generate(), "published") + assert match?({:error, _}, result) + end + end + + # ============================================================================ + # Full publish workflow end-to-end + # ============================================================================ + + describe "full publish workflow" do + test "create → edit → publish → read published" do + group = create_group("slug") + + # Create + {:ok, post} = Posts.create_post(group["slug"], %{title: "Draft Post"}) + assert post[:metadata][:status] == "draft" + + # Edit content (title preserved since we pass it explicitly) + {:ok, edited} = + Posts.update_post( + group["slug"], + post, + %{"title" => "Draft Post", "content" => "Final content
"}, + %{} + ) + + assert edited[:content] == "Final content
" + + # Publish + {:ok, _} = Posts.change_post_status(group["slug"], post[:uuid], "published") + + # Read published + {:ok, published} = Posts.read_post(group["slug"], post[:uuid], nil, nil) + assert published[:uuid] == post[:uuid] + end + + test "create → trash → restore → publish" do + group = create_group("slug") + {:ok, post} = Posts.create_post(group["slug"], %{title: "Lifecycle"}) + + # Trash + {:ok, _} = Posts.trash_post(group["slug"], post[:uuid]) + + # Restore + {:ok, _} = Posts.restore_post(group["slug"], post[:uuid]) + + # Publish + assert {:ok, _} = Posts.change_post_status(group["slug"], post[:uuid], "published") + end + end +end diff --git a/test/modules/publishing/integration/translations_test.exs b/test/modules/publishing/integration/translations_test.exs new file mode 100644 index 000000000..cf31f765a --- /dev/null +++ b/test/modules/publishing/integration/translations_test.exs @@ -0,0 +1,277 @@ +defmodule PhoenixKit.Integration.Publishing.TranslationsTest do + use PhoenixKit.DataCase, async: true + + alias PhoenixKit.Modules.Publishing.Groups + alias PhoenixKit.Modules.Publishing.Posts + alias PhoenixKit.Modules.Publishing.TranslationManager + alias PhoenixKit.Modules.Publishing.Versions + + defp unique_name, do: "i18n Group #{System.unique_integer([:positive])}" + + defp create_group_and_post(opts \\ []) do + title = Keyword.get(opts, :title, "Translatable") + {:ok, group} = Groups.add_group(unique_name(), mode: "slug") + {:ok, post} = Posts.create_post(group["slug"], %{title: title}) + {group, post} + end + + # ============================================================================ + # add_language_to_post/4 + # ============================================================================ + + describe "add_language_to_post/4" do + test "adds a new language to post" do + {group, post} = create_group_and_post() + + assert {:ok, updated} = + TranslationManager.add_language_to_post(group["slug"], post[:uuid], "de", nil) + + assert is_map(updated) + end + + test "added language appears in available_languages" do + {group, post} = create_group_and_post() + {:ok, _} = TranslationManager.add_language_to_post(group["slug"], post[:uuid], "fr", nil) + + {:ok, post_map} = Posts.read_post(group["slug"], post[:uuid], nil, nil) + assert "fr" in post_map[:available_languages] + end + + test "adding primary language is idempotent" do + {group, post} = create_group_and_post() + result = TranslationManager.add_language_to_post(group["slug"], post[:uuid], "en", nil) + assert match?({:ok, _}, result) + end + + test "adds multiple languages" do + {group, post} = create_group_and_post() + {:ok, _} = TranslationManager.add_language_to_post(group["slug"], post[:uuid], "de", nil) + {:ok, _} = TranslationManager.add_language_to_post(group["slug"], post[:uuid], "fr", nil) + {:ok, _} = TranslationManager.add_language_to_post(group["slug"], post[:uuid], "es", nil) + + {:ok, post_map} = Posts.read_post(group["slug"], post[:uuid], nil, nil) + langs = post_map[:available_languages] + + assert "de" in langs + assert "fr" in langs + assert "es" in langs + end + + test "new language content starts as draft" do + {group, post} = create_group_and_post() + {:ok, _} = TranslationManager.add_language_to_post(group["slug"], post[:uuid], "de", nil) + + {:ok, post_map} = Posts.read_post(group["slug"], post[:uuid], nil, nil) + assert post_map[:language_statuses]["de"] == "draft" + end + end + + # ============================================================================ + # delete_language/4 + # ============================================================================ + + describe "delete_language/4" do + test "removes a non-primary language" do + {group, post} = create_group_and_post() + {:ok, _} = TranslationManager.add_language_to_post(group["slug"], post[:uuid], "fr", nil) + + result = TranslationManager.delete_language(group["slug"], post[:uuid], "fr", nil) + assert result == :ok or match?({:ok, _}, result) + end + + test "cannot delete last active language" do + {group, post} = create_group_and_post() + + result = TranslationManager.delete_language(group["slug"], post[:uuid], "en", nil) + assert result == {:error, :last_language} or match?({:error, _}, result) + end + + test "can delete primary if other languages exist" do + {group, post} = create_group_and_post() + {:ok, _} = TranslationManager.add_language_to_post(group["slug"], post[:uuid], "de", nil) + + # With 2 languages, deleting one should work + result = TranslationManager.delete_language(group["slug"], post[:uuid], "en", nil) + assert result == :ok or match?({:ok, _}, result) + end + end + + # ============================================================================ + # get_post_primary_language/3 + # ============================================================================ + + describe "get_post_primary_language/3" do + test "returns primary language" do + {group, post} = create_group_and_post() + + lang = + TranslationManager.get_post_primary_language( + group["slug"], + post[:slug] || post[:uuid], + nil + ) + + assert lang == "en" + end + end + + # ============================================================================ + # set_translation_status/5 + # ============================================================================ + + describe "set_translation_status/5" do + test "sets primary language to draft" do + {group, post} = create_group_and_post() + + assert :ok = + TranslationManager.set_translation_status( + group["slug"], + post[:uuid], + 1, + "en", + "draft" + ) + end + + test "publishes primary language" do + {group, post} = create_group_and_post(title: "Publishable") + + assert :ok = + TranslationManager.set_translation_status( + group["slug"], + post[:uuid], + 1, + "en", + "published" + ) + end + + test "cannot publish non-primary when primary is draft" do + {group, post} = create_group_and_post(title: "Primary Draft") + {:ok, _} = TranslationManager.add_language_to_post(group["slug"], post[:uuid], "es", nil) + + result = + TranslationManager.set_translation_status( + group["slug"], + post[:uuid], + 1, + "es", + "published" + ) + + assert result == {:error, :primary_not_published} + end + + test "can publish non-primary when primary is published" do + {group, post} = create_group_and_post(title: "Primary Published") + {:ok, _} = TranslationManager.add_language_to_post(group["slug"], post[:uuid], "de", nil) + + # Publish primary first + :ok = + TranslationManager.set_translation_status( + group["slug"], + post[:uuid], + 1, + "en", + "published" + ) + + # Now publish secondary + assert :ok = + TranslationManager.set_translation_status( + group["slug"], + post[:uuid], + 1, + "de", + "published" + ) + end + + test "can set non-primary to draft regardless of primary status" do + {group, post} = create_group_and_post() + {:ok, _} = TranslationManager.add_language_to_post(group["slug"], post[:uuid], "fr", nil) + + assert :ok = + TranslationManager.set_translation_status( + group["slug"], + post[:uuid], + 1, + "fr", + "draft" + ) + end + + test "rejects invalid status" do + {group, post} = create_group_and_post() + + result = + TranslationManager.set_translation_status( + group["slug"], + post[:uuid], + 1, + "en", + "invalid" + ) + + assert result == {:error, :invalid_status} + end + + test "returns error for nonexistent post" do + {group, _post} = create_group_and_post() + + result = + TranslationManager.set_translation_status( + group["slug"], + UUIDv7.generate(), + 1, + "en", + "draft" + ) + + assert match?({:error, _}, result) + end + end + + # ============================================================================ + # Full translation workflow + # ============================================================================ + + describe "full multilingual workflow" do + test "create → add languages → publish via version → verify all published" do + {group, post} = create_group_and_post(title: "Multilingual Post") + + # Add languages + {:ok, _} = TranslationManager.add_language_to_post(group["slug"], post[:uuid], "de", nil) + {:ok, _} = TranslationManager.add_language_to_post(group["slug"], post[:uuid], "fr", nil) + + # Publish version (publishes all content in the version) + :ok = Versions.publish_version(group["slug"], post[:uuid], 1) + + # Verify all languages are published via language_statuses + {:ok, post_map} = Posts.read_post(group["slug"], post[:uuid], nil, nil) + statuses = post_map[:language_statuses] + + assert statuses["en"] == "published" + assert statuses["de"] == "published" + assert statuses["fr"] == "published" + end + + test "version cloning preserves all languages" do + {group, post} = create_group_and_post(title: "V1 Multilang") + {:ok, _} = TranslationManager.add_language_to_post(group["slug"], post[:uuid], "de", nil) + {:ok, _} = TranslationManager.add_language_to_post(group["slug"], post[:uuid], "fr", nil) + + # Clone to v2 + {:ok, v2} = Versions.create_new_version(group["slug"], post, %{}, %{}) + assert v2[:version] == 2 + + # V2 should have all 3 languages + {:ok, v2_post} = Posts.read_post(group["slug"], post[:uuid], nil, 2) + v2_langs = v2_post[:available_languages] + + assert "en" in v2_langs + assert "de" in v2_langs + assert "fr" in v2_langs + end + end +end diff --git a/test/modules/publishing/integration/versions_test.exs b/test/modules/publishing/integration/versions_test.exs new file mode 100644 index 000000000..e42cd11ea --- /dev/null +++ b/test/modules/publishing/integration/versions_test.exs @@ -0,0 +1,305 @@ +defmodule PhoenixKit.Integration.Publishing.VersionsTest do + use PhoenixKit.DataCase, async: true + + alias PhoenixKit.Modules.Publishing.Groups + alias PhoenixKit.Modules.Publishing.Posts + alias PhoenixKit.Modules.Publishing.Versions + + defp unique_name, do: "Versions Group #{System.unique_integer([:positive])}" + + defp create_group_and_post(opts \\ []) do + mode = Keyword.get(opts, :mode, "slug") + title = Keyword.get(opts, :title, "Versioned Post") + + {:ok, group} = Groups.add_group(unique_name(), mode: mode) + {:ok, post} = Posts.create_post(group["slug"], %{title: title}) + + {group, post} + end + + # ============================================================================ + # list_versions/2 + # ============================================================================ + + describe "list_versions/2" do + test "new post has version 1" do + {group, post} = create_group_and_post() + versions = Versions.list_versions(group["slug"], post[:slug] || post[:uuid]) + assert versions == [1] + end + + test "multiple versions are listed in order" do + {group, post} = create_group_and_post() + {:ok, v2} = Versions.create_new_version(group["slug"], post, %{}, %{}) + {:ok, _v3} = Versions.create_new_version(group["slug"], v2, %{}, %{}) + + versions = Versions.list_versions(group["slug"], post[:slug] || post[:uuid]) + assert versions == [1, 2, 3] + end + end + + # ============================================================================ + # create_new_version/4 + # ============================================================================ + + describe "create_new_version/4" do + test "creates version 2 by cloning latest" do + {group, post} = create_group_and_post() + {:ok, new_post} = Versions.create_new_version(group["slug"], post, %{}, %{}) + + assert new_post[:version] == 2 + assert 1 in new_post[:available_versions] + assert 2 in new_post[:available_versions] + end + + test "clones content from source version" do + {group, post} = create_group_and_post(title: "Clone Me") + {:ok, v2_post} = Versions.create_new_version(group["slug"], post, %{}, %{}) + assert v2_post[:metadata][:title] == "Clone Me" + end + + test "creates successive versions" do + {group, post} = create_group_and_post() + {:ok, v2} = Versions.create_new_version(group["slug"], post, %{}, %{}) + {:ok, v3} = Versions.create_new_version(group["slug"], v2, %{}, %{}) + + assert v3[:version] == 3 + versions = Versions.list_versions(group["slug"], post[:slug] || post[:uuid]) + assert versions == [1, 2, 3] + end + + test "new version starts as draft" do + {group, post} = create_group_and_post(title: "Publish V1") + :ok = Versions.publish_version(group["slug"], post[:uuid], 1) + + {:ok, v2} = Versions.create_new_version(group["slug"], post, %{}, %{}) + assert v2[:metadata][:status] == "draft" + end + + test "clones all languages from source version" do + {group, post} = create_group_and_post(title: "Multilang") + + alias PhoenixKit.Modules.Publishing.TranslationManager + {:ok, _} = TranslationManager.add_language_to_post(group["slug"], post[:uuid], "de", nil) + + {:ok, v2} = Versions.create_new_version(group["slug"], post, %{}, %{}) + + # V2 should have both en and de + {:ok, v2_post} = Posts.read_post(group["slug"], post[:uuid], nil, 2) + v2_langs = v2_post[:available_languages] + assert "en" in v2_langs + assert "de" in v2_langs + end + + test "returns post map with available_versions updated" do + {group, post} = create_group_and_post() + {:ok, v2} = Versions.create_new_version(group["slug"], post, %{}, %{}) + + assert is_list(v2[:available_versions]) + assert length(v2[:available_versions]) == 2 + end + end + + # ============================================================================ + # publish_version/4 + # ============================================================================ + + describe "publish_version/4" do + test "publishes a version with title" do + {group, post} = create_group_and_post(title: "Publish Me") + assert :ok = Versions.publish_version(group["slug"], post[:uuid], 1) + end + + test "published version status is published" do + {group, post} = create_group_and_post(title: "Check Published") + :ok = Versions.publish_version(group["slug"], post[:uuid], 1) + + status = + Versions.get_version_status(group["slug"], post[:slug] || post[:uuid], 1, "en") + + assert status == "published" + end + + test "archives previously published version" do + {group, post} = create_group_and_post(title: "V1 Title") + :ok = Versions.publish_version(group["slug"], post[:uuid], 1) + + {:ok, v2} = Versions.create_new_version(group["slug"], post, %{}, %{}) + Posts.update_post(group["slug"], v2, %{"title" => "V2 Title"}, %{}) + :ok = Versions.publish_version(group["slug"], post[:uuid], 2) + + v1_status = + Versions.get_version_status(group["slug"], post[:slug] || post[:uuid], 1, "en") + + assert v1_status == "archived" + end + + test "only one version published at a time" do + {group, post} = create_group_and_post(title: "Multi V") + :ok = Versions.publish_version(group["slug"], post[:uuid], 1) + + {:ok, v2} = Versions.create_new_version(group["slug"], post, %{}, %{}) + Posts.update_post(group["slug"], v2, %{"title" => "V2"}, %{}) + :ok = Versions.publish_version(group["slug"], post[:uuid], 2) + + v1 = Versions.get_version_status(group["slug"], post[:slug] || post[:uuid], 1, "en") + v2_status = Versions.get_version_status(group["slug"], post[:slug] || post[:uuid], 2, "en") + + assert v1 == "archived" + assert v2_status == "published" + end + + test "rejects publishing version without title" do + {:ok, group} = Groups.add_group(unique_name(), mode: "slug") + {:ok, post} = Posts.create_post(group["slug"], %{}) + + assert {:error, :title_required} = + Versions.publish_version(group["slug"], post[:uuid], 1) + end + + test "rejects publishing trashed post" do + {group, post} = create_group_and_post(title: "Trashed") + {:ok, _} = Posts.trash_post(group["slug"], post[:uuid]) + + assert {:error, :post_trashed} = + Versions.publish_version(group["slug"], post[:uuid], 1) + end + + test "rejects publishing nonexistent version" do + {group, post} = create_group_and_post(title: "Missing V") + + assert {:error, :version_not_found} = + Versions.publish_version(group["slug"], post[:uuid], 99) + end + end + + # ============================================================================ + # get_published_version/2 + # ============================================================================ + + describe "get_published_version/2" do + test "returns published version number" do + {group, post} = create_group_and_post(title: "Published V") + :ok = Versions.publish_version(group["slug"], post[:uuid], 1) + + assert {:ok, 1} = + Versions.get_published_version(group["slug"], post[:slug] || post[:uuid]) + end + + test "returns error when no version is published" do + {group, post} = create_group_and_post(title: "No Pub") + + result = Versions.get_published_version(group["slug"], post[:slug] || post[:uuid]) + assert match?({:error, _}, result) + end + end + + # ============================================================================ + # get_version_status/4 + # ============================================================================ + + describe "get_version_status/4" do + test "returns draft for new version" do + {group, post} = create_group_and_post() + + status = + Versions.get_version_status(group["slug"], post[:slug] || post[:uuid], 1, "en") + + assert status == "draft" + end + + test "returns published after publishing" do + {group, post} = create_group_and_post(title: "Pub Status") + :ok = Versions.publish_version(group["slug"], post[:uuid], 1) + + status = + Versions.get_version_status(group["slug"], post[:slug] || post[:uuid], 1, "en") + + assert status == "published" + end + end + + # ============================================================================ + # delete_version/3 + # ============================================================================ + + describe "delete_version/3" do + test "archives a draft version" do + {group, post} = create_group_and_post() + {:ok, _v2} = Versions.create_new_version(group["slug"], post, %{}, %{}) + assert :ok = Versions.delete_version(group["slug"], post[:uuid], 1) + end + + test "cannot delete published version" do + {group, post} = create_group_and_post(title: "Published") + :ok = Versions.publish_version(group["slug"], post[:uuid], 1) + + assert {:error, :cannot_delete_live} = + Versions.delete_version(group["slug"], post[:uuid], 1) + end + + test "cannot delete last remaining version" do + {group, post} = create_group_and_post() + + assert {:error, :last_version} = + Versions.delete_version(group["slug"], post[:uuid], 1) + end + + test "cannot delete nonexistent version" do + {group, post} = create_group_and_post() + + result = Versions.delete_version(group["slug"], post[:uuid], 99) + assert match?({:error, _}, result) + end + + test "deleted version is archived, not hard-deleted" do + {group, post} = create_group_and_post() + {:ok, _v2} = Versions.create_new_version(group["slug"], post, %{}, %{}) + :ok = Versions.delete_version(group["slug"], post[:uuid], 1) + + # Version still exists in the list + versions = Versions.list_versions(group["slug"], post[:slug] || post[:uuid]) + assert 1 in versions + end + end + + # ============================================================================ + # Full version workflow + # ============================================================================ + + describe "full version workflow" do + test "create → publish v1 → create v2 → edit v2 → publish v2 → v1 archived" do + {group, post} = create_group_and_post(title: "V1 Content") + + # Publish v1 first + :ok = Versions.publish_version(group["slug"], post[:uuid], 1) + + # Create v2 + {:ok, v2} = Versions.create_new_version(group["slug"], post, %{}, %{}) + assert v2[:version] == 2 + assert v2[:metadata][:title] == "V1 Content" + + # Edit v2 + {:ok, _} = Posts.update_post(group["slug"], v2, %{"title" => "V2 Content"}, %{}) + + # Publish v2 + :ok = Versions.publish_version(group["slug"], post[:uuid], 2) + + # V1 should now be archived (was published, got superseded) + v1_status = + Versions.get_version_status(group["slug"], post[:slug] || post[:uuid], 1, "en") + + assert v1_status == "archived" + + # V2 should be published + v2_status = + Versions.get_version_status(group["slug"], post[:slug] || post[:uuid], 2, "en") + + assert v2_status == "published" + + # Reading without version gives v2 (latest) + {:ok, latest} = Posts.read_post(group["slug"], post[:uuid], nil, nil) + assert latest[:version] == 2 + end + end +end diff --git a/test/modules/publishing/shared_test.exs b/test/modules/publishing/shared_test.exs index 3fb76a69d..e8cd84c3c 100644 --- a/test/modules/publishing/shared_test.exs +++ b/test/modules/publishing/shared_test.exs @@ -180,53 +180,6 @@ defmodule PhoenixKit.Modules.Publishing.SharedTest do end end - # ============================================================================ - # should_regenerate_cache?/1 - # ============================================================================ - - describe "should_regenerate_cache?/1" do - test "returns true for timestamp mode" do - assert Shared.should_regenerate_cache?(%{mode: :timestamp, metadata: %{status: "draft"}}) - end - - test "returns true for slug mode" do - assert Shared.should_regenerate_cache?(%{ - mode: :slug, - metadata: %{status: "draft"}, - version: 1 - }) - end - - test "returns true for published posts" do - assert Shared.should_regenerate_cache?(%{ - mode: :slug, - metadata: %{status: "published"}, - version: 1 - }) - end - - test "returns true when version is nil" do - assert Shared.should_regenerate_cache?(%{mode: :slug, metadata: %{status: "draft"}}) - end - - test "returns true for unknown mode with non-published status" do - assert Shared.should_regenerate_cache?(%{ - mode: :other, - metadata: %{status: "archived"}, - version: 1 - }) - end - - test "handles missing metadata gracefully" do - assert Shared.should_regenerate_cache?(%{mode: :timestamp}) - end - - test "handles empty map" do - # No mode → nil version → true (always regenerate when unknown) - assert Shared.should_regenerate_cache?(%{}) - end - end - # ============================================================================ # audit_metadata/2 # ============================================================================ diff --git a/test/phoenix_kit/users/rate_limiter_test.exs b/test/phoenix_kit/users/rate_limiter_test.exs index 42008c767..1f4f873d0 100644 --- a/test/phoenix_kit/users/rate_limiter_test.exs +++ b/test/phoenix_kit/users/rate_limiter_test.exs @@ -4,12 +4,15 @@ defmodule PhoenixKit.Users.RateLimiterTest do alias PhoenixKit.Users.RateLimiter alias PhoenixKit.Users.RateLimiter.Backend - # Start the Hammer ETS backend before all tests + # Start the Hammer ETS backend before all tests (may already be running from test_helper) setup_all do - # Start the RateLimiter backend if not already started - case Backend.start_link([]) do - {:ok, pid} -> {:ok, %{backend_pid: pid}} - {:error, {:already_started, pid}} -> {:ok, %{backend_pid: pid}} + case :ets.whereis(Backend) do + :undefined -> + {:ok, pid} = Backend.start_link([]) + {:ok, %{backend_pid: pid}} + + _ref -> + {:ok, %{backend_pid: nil}} end end diff --git a/test/support/data_case.ex b/test/support/data_case.ex index 81aa76c01..1210ecbdf 100644 --- a/test/support/data_case.ex +++ b/test/support/data_case.ex @@ -18,21 +18,24 @@ defmodule PhoenixKit.DataCase do using do quote do - alias PhoenixKit.RepoHelper, as: Repo + @moduletag :integration + + alias PhoenixKit.Test.Repo import Ecto import Ecto.Changeset import Ecto.Query import PhoenixKit.DataCase - - # Import helpers for testing schemas and data end end - setup _tags do - # Setup database sandbox if needed - # pid = Ecto.Adapters.SQL.Sandbox.start_owner!(PhoenixKit.RepoHelper.repo(), shared: not tags[:async]) - # on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end) + alias Ecto.Adapters.SQL.Sandbox + alias PhoenixKit.Test.Repo, as: TestRepo + + setup tags do + pid = Sandbox.start_owner!(TestRepo, shared: not tags[:async]) + + on_exit(fn -> Sandbox.stop_owner(pid) end) :ok end diff --git a/test/support/postgres/migrations/20260316000000_add_phoenix_kit.exs b/test/support/postgres/migrations/20260316000000_add_phoenix_kit.exs new file mode 100644 index 000000000..0b4de37d0 --- /dev/null +++ b/test/support/postgres/migrations/20260316000000_add_phoenix_kit.exs @@ -0,0 +1,7 @@ +defmodule PhoenixKit.Test.Repo.Migrations.AddPhoenixKit do + use Ecto.Migration + + def up, do: PhoenixKit.Migrations.up() + + def down, do: PhoenixKit.Migrations.down() +end diff --git a/test/support/test_repo.ex b/test/support/test_repo.ex new file mode 100644 index 000000000..00bf3f396 --- /dev/null +++ b/test/support/test_repo.ex @@ -0,0 +1,5 @@ +defmodule PhoenixKit.Test.Repo do + use Ecto.Repo, + otp_app: :phoenix_kit, + adapter: Ecto.Adapters.Postgres +end diff --git a/test/test_helper.exs b/test/test_helper.exs index 874103272..0a9e7a253 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1,11 +1,74 @@ # Test helper for PhoenixKit test suite -ExUnit.start() -# Configure test environment -Application.put_env(:phoenix_kit, :repo, PhoenixKit.Test.Repo) +# Check if the test database exists before trying to connect. +# Uses `psql -lqt` for a fast check that avoids Postgrex connection hangs. +# Falls back to attempting connection directly if psql is unavailable (e.g., CI). +db_name = + Application.get_env(:phoenix_kit, PhoenixKit.Test.Repo)[:database] || "phoenix_kit_test" -# Start the ModuleRegistry so persistent_term is populated for tests +db_check = + case System.cmd("psql", ["-lqt"], stderr_to_stdout: true) do + {output, 0} -> + exists = + output + |> String.split("\n") + |> Enum.any?(fn line -> + line |> String.split("|") |> List.first("") |> String.trim() == db_name + end) + + if exists, do: :exists, else: :not_found + + _ -> + # psql not available (CI without postgresql-client) — try connecting directly + :try_connect + end + +repo_available = + if db_check == :not_found do + IO.puts(""" + \n⚠ Test database "#{db_name}" not found — integration tests will be excluded. + Run `mix test.setup` to create the test database. + """) + + false + else + try do + {:ok, _} = PhoenixKit.Test.Repo.start_link() + + migrations_path = Path.join([__DIR__, "support", "postgres", "migrations"]) + Ecto.Migrator.run(PhoenixKit.Test.Repo, migrations_path, :up, all: true, log: false) + + Ecto.Adapters.SQL.Sandbox.mode(PhoenixKit.Test.Repo, :manual) + true + rescue + e -> + IO.puts(""" + \n⚠ Could not connect to test database — integration tests will be excluded. + Run `mix test.setup` to create the test database. + Error: #{Exception.message(e)} + """) + + false + catch + :exit, reason -> + IO.puts(""" + \n⚠ Could not connect to test database — integration tests will be excluded. + Run `mix test.setup` to create the test database. + Error: #{inspect(reason)} + """) + + false + end + end + +Application.put_env(:phoenix_kit, :test_repo_available, repo_available) + +# Start minimal services needed for tests +{:ok, _pid} = PhoenixKit.PubSub.Manager.start_link([]) {:ok, _pid} = PhoenixKit.ModuleRegistry.start_link([]) +{:ok, _pid} = PhoenixKit.Users.RateLimiter.Backend.start_link([]) + +# Exclude integration tests when DB is not available +exclude = if repo_available, do: [], else: [:integration] -# Note: Tests are currently in development phase -# This file provides the foundation for the PhoenixKit test suite +ExUnit.start(exclude: exclude)