Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
65 changes: 57 additions & 8 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down
18 changes: 14 additions & 4 deletions config/test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 8 additions & 1 deletion lib/modules/publishing/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -1491,6 +1494,10 @@ This ensures localized URLs work immediately after deployment without waiting fo
- **Proper Hreflang**: The `<link rel="alternate" hreflang="xx">` 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`
Expand Down
15 changes: 10 additions & 5 deletions lib/modules/publishing/db_storage.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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 """
Expand Down Expand Up @@ -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)
Expand Down
10 changes: 8 additions & 2 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"],
Expand Down
22 changes: 22 additions & 0 deletions test/integration/repo_smoke_test.exs
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading