Current section

Files

Jump to
Raw

README.md

# Mutare Phoenix
[![Hex.pm](https://img.shields.io/hexpm/v/mutare_phoenix.svg)](https://hex.pm/packages/mutare_phoenix)
[![Hexdocs](https://img.shields.io/badge/hexdocs-docs-blue.svg)](https://hexdocs.pm/mutare_phoenix)
[![CI](https://github.com/foxbenjaminfox/mutare_phoenix/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/foxbenjaminfox/mutare_phoenix/actions/workflows/ci.yml)
[![License](https://img.shields.io/hexpm/l/mutare_phoenix.svg)](https://github.com/foxbenjaminfox/mutare_phoenix/blob/master/LICENSE)
Custom [Mutare](https://hex.pm/packages/mutare) mutators for the **Phoenix server-side surface** —
the `Phoenix.Controller` calls a controller action performs on the conn, the `Phoenix.Channel`
replies and outbound messages, `Phoenix.PubSub`, and `Phoenix.Token` — plus macro routing
that excludes Phoenix's compile-time macros (the `Phoenix.Router` DSL, `~H`) from mutation
to prevent metamutant compilation failures.
A controller action returns a *transformed conn*, so its whole contract is **which
conn-transforming call ran** — the status it set, where it redirected, whether it halted.
These are exactly the calls a suite tends to under-assert: a test that checks "something
happened" but not *which* transformation leaves a gap. A channel has the same shape of gap
on its outbound side — the reply a `join`/`handle_in` returns, the message it broadcasts or
pushes, the PubSub topic it subscribes to — where a test that only checks the socket came
back leaves the client-facing effect unasserted. `mutare_phoenix` turns each such gap into a
located [Mutare](https://hex.pm/packages/mutare) survivor.
It **builds on** [`mutare_plug`](https://hex.pm/packages/mutare_plug) (the `Plug.Conn`
families — halt, status, session, header, cookie, body) the way `phoenix` builds on `plug`:
it depends on it, so those families are on your code path too, ready to compose.
## The families
`Mutare.Phoenix.all/0` returns three `Phoenix.Controller` families, two `Phoenix.Channel`
ones, a `Phoenix.PubSub` one, and a `Phoenix.Token` one:
| Family | Name | Mutation | The gap a survivor exposes |
| --- | --- | --- | --- |
| `Mutare.Phoenix.Redirect` | `:redirect_status` | swaps the explicit atom `status:` option of `Phoenix.Controller.redirect/2` for a redirect-status sibling (`:found → :see_other`, `:moved_permanently → :permanent_redirect`) | no test pins the exact redirect status |
| `Mutare.Phoenix.Body` | `:controller_body` | blanks the body argument of `Phoenix.Controller.json/2` to `%{}` and of `text/2` / `html/2` to `""` | no test reads the rendered body |
| `Mutare.Phoenix.Download` | `:download_disposition` | flips the explicit `disposition:` option of `Phoenix.Controller.send_download/3` between `:attachment` and `:inline` | no test checks whether the response specifies saving or displaying the file |
| `Mutare.Phoenix.ChannelReply` | `:channel_reply` | drops the reply element of a `Phoenix.Channel` callback return: `{:ok, reply, socket}` → `{:ok, socket}`, `{:reply, reply, socket}` → `{:noreply, socket}`, `{:stop, reason, reply, socket}` → `{:stop, reason, socket}` (gated on `@behaviour Phoenix.Channel`) | no test checks the join reply or `assert_reply`s the `handle_in` reply |
| `Mutare.Phoenix.ChannelMessage` | `:channel_message` | removes a `Phoenix.Channel` outbound message — `broadcast/3` and its `!`/`_from` siblings, `push/3`, `reply/2` — collapsing the call to `:ok` (variants `broadcast`, `push`, `reply`) | no test `assert_broadcast`s / `assert_push`es / `assert_reply`s the message |
| `Mutare.Phoenix.PubSub` | `:pubsub` | removes a `Phoenix.PubSub` `subscribe`, `unsubscribe`, or broadcast call (every `broadcast`/`broadcast_from`/`local_broadcast`/`direct_broadcast` form), collapsing it to `:ok` (variants `subscribe`, `unsubscribe`, `broadcast`) | no test delivers a message on the topic and checks the subscriber reacted, or asserts a broadcast arrived |
| `Mutare.Phoenix.Token` | `:token` | swaps a `Phoenix.Token` call for its sibling scheme (`sign` ↔ `encrypt`, `verify` ↔ `decrypt`; variant `scheme`), blanks a `sign`/`encrypt` payload to `nil` (`payload`), and turns an explicit integer `max_age:` of `verify`/`decrypt` into `:infinity` (`expiry`, with the position marked `:timeout` so the built-in integer family skips the duration literal) | no test round-trips the token, checks its payload, or passes an expired token to `verify`/`decrypt` |
Each call family matches its call written directly (`Phoenix.Controller.redirect(conn, ...)`),
aliased, or bare-imported (`redirect(conn, ...)`, the form `use MyAppWeb, :controller`
produces; `broadcast(socket, ...)`, the form `use Phoenix.Channel` produces). The option
families only mutate an explicit literal atom: redirects and downloads that rely on Phoenix's
default, integer statuses, and variable values are left to other families or skipped.
`render/3` is out of scope for `:controller_body` — its argument names a template, not a
body.
The removal families (`:channel_message`, `:pubsub`) collapse a whole call to its success
value, `:ok`, and only at the call's defined arities, so every generated mutant still
compiles; a call written as a pipe stage is collapsed over the whole pipe. The
families that produce several kinds declare variant labels, so a qualified
`# mutare:ignore[pubsub:subscribe]` silences just one kind at a site.
The `Plug.Conn` side of a controller action — `put_status`, `send_resp`, `put_session`,
`put_resp_header`, `put_resp_cookie`, `halt` — is the base package's six families,
[`Mutare.Plug.all/0`](https://hexdocs.pm/mutare_plug).
## The `:extensions` entry
`Mutare.Phoenix` is also a `Mutare.CallRouting` extension. Listed under `:extensions`, it
routes Phoenix's compile-time-only macro calls `:skip` — each call is an inert leaf, so
neither its arguments nor the call itself is ever mutated: the `Phoenix.Router` DSL (`get`/`post`/`scope`/…), because route definitions run
once at compile time under Mutare's compile-once model and a mutation there could never
activate; and `Phoenix.Component.sigil_H/2` (`~H`), because HEEx sigil arguments must remain
compile-time literals — left unregistered, Mutare's imported-call witness would splice an
unreachable `sigil_H(arg1, arg2)` that Phoenix rejects at compile time, failing the whole
metamutant build. Mutations *around* a `~H` expression, such as a `render/1` return-value
mutant, remain available.
## Usage
`mutare_phoenix` uses the [Mutare](https://hex.pm/packages/mutare) engine and depends on
`mutare_plug`, so add them as `:dev`/`:test` dependencies:
```elixir
# mix.exs
defp deps do
[
{:mutare, "~> 0.4.1", only: [:dev, :test], runtime: false},
{:mutare_plug, "~> 0.2", only: [:dev, :test], runtime: false},
{:mutare_phoenix, "~> 0.3", only: [:dev, :test], runtime: false}
]
end
```
Then list the families in `.mutare.exs`, and `Mutare.Phoenix` under `:extensions`. Setting
`:mutators` **replaces** Mutare's default set, so include the `:builtins` family to keep the
built-ins on:
```elixir
# .mutare.exs
[
mutators: [:builtins] ++ Mutare.Plug.all() ++ Mutare.Phoenix.all(),
extensions: [Mutare.Phoenix]
]
```
`all/0` returns only this package's families — it does **not** include the `mutare_plug`
ones, so compose `Mutare.Plug.all/0` explicitly as shown. Run it the usual way:
```
mix mutare
```
## Why not the built-in atom mutators?
In a status position, Mutare's built-in atom swaps (`:ok → :error` / `:mutare`) produce a
value that **crashes** — a kill that does not indicate whether tests check the status.
`:redirect_status` and `:download_disposition` swap to *valid* siblings (Phoenix rejects any
disposition other than `:attachment` / `:inline`), so a survivor means a genuine missing
assertion rather than a crash, and Mutare's overlap pruning drops the redundant crashing
leaves at the same range. `:controller_body` works the same way for a literal `text` / `html`
body: its whole-call blank covers the string node, so the built-in string family's sentinel
leaves there are pruned automatically and one clean "is the body read?" mutant remains.
## Example
[`examples/demo`](https://github.com/foxbenjaminfox/mutare_phoenix/tree/HEAD/examples/demo) is
a standalone mini-project with an auth plug, controller actions, a room channel,
PubSub notifications, and invitation tokens over small framework stand-ins. Deliberate
test gaps surface survivors in all seven Phoenix families, plus Plug halt and status.
From the repo root:
```
mix compile
mix mutare examples/demo
```
Like the companion packages' examples, the demo has a deliberately partial test suite.
Its walkthrough explains each survivor and the assertion that closes the gap.
## Scope
The `Plug.Conn` families are defined in the base
[`mutare_plug`](https://hex.pm/packages/mutare_plug); LiveView is the companion
`mutare_phoenix_live_view`, which builds on this package. Compose `Mutare.Plug.all/0`
alongside `Mutare.Phoenix.all/0` (see "Usage") for the full conn + controller surface.
## License
MIT — see [LICENSE](LICENSE).