Mutare.Phoenix.Swoosh.RenderBody (mutare_phoenix_swoosh v0.1.0)

Copy Markdown View Source

:render_body — mutates the calls that decide which body parts an email renders: Phoenix.Swoosh.render_body/2,3, the call that renders an email's templates onto its html_body/text_body fields, and the format map of Phoenix.Swoosh.put_new_formats/2, which decides what "both parts" even means. Three kinds, variant-labelled:

Removal (label remove) — the call collapses to the email it received, so the email is built, addressed, and delivered with no rendered body at all:

render_body(email, :welcome, %{name: name})  ->  email
email |> render_body(:welcome)               ->  Elixir.Function.identity()

A survivor means no test asserts the rendered body content — the mail pipeline is exercised, but nothing checks that html_body/text_body ever got set.

Atom-template narrowing (labels html_only / text_only) — an atom template renders both the .html and the .text template; the string form renders exactly one. Narrowing the literal atom to each string form drops the other body part:

render_body(email, :welcome, assigns)  ->  render_body(email, "welcome.html", assigns)
                                       ->  render_body(email, "welcome.text", assigns)

A survivor means no test asserts the other body part — the classic "the text part broke and nobody noticed" gap. Swoosh.TestAssertions.assert_email_sent/1 on both bodies, or a direct email.text_body =~ ..., kills it. Narrowing fires only on a literal atom template; a string or computed template gets the removal mutant alone.

Format dropping (label format) — a literal put_new_formats/2 map with more than one entry loses one entry per mutant, so that extension's template stops rendering:

put_new_formats(email, %{"html" => :html_body, "amp" => :html_body})
  ->  put_new_formats(email, %{"amp" => :html_body})
  ->  put_new_formats(email, %{"html" => :html_body})

This is the custom-formats form of the same gap the narrowings probe — and the only form of it once a mailer configures :formats, since the narrowings' .html/.text extensions may not exist there (a narrowed mutant then dies as a missing-template crash: an uninformative kill, never a wrong survivor). A single-entry map produces nothing: dropping the only format renders no body at all, which is the remove mutant's diff, and no mutant has two homes.

Suppress one kind with # mutare:ignore[render_body:remove] / [render_body:html_only] / [render_body:text_only] / [render_body:format], or the family with # mutare:ignore[render_body].

Only the real effective arities fire — render_body/2,3 (/2 is the use-injected wrapper's default-assigns form) and put_new_formats/2 — so a name-matched call of any other arity is left alone, keeping every metamutant compiling. Piped calls are removed with the Elixir.Function.identity() no-op stage (the :Elixir-led alias is never rewritten by alias resolution, so the no-op always names the real Function.identity/1).

How the three kinds relate

At an atom-template site the removal mutant is the weakest of the three: any assertion that kills a narrowing also kills the removal, so a surviving remove where both narrowings also survive is one gap reported three times, not three gaps. It is kept because it is the only mutant at a string or computed template site, and the only one that also drops the assigns merge (a test that reads email.assigns rather than the bodies kills it alone).

The template and format positions are pinned

The template name (effective argument 1 — atom or string) is a structural identifier, not a computed value: a perturbed name is a missing-template crash at render time (an uninformative crash-kill), never a signal. put_new_formats/2's extension→field map is the same kind of value — a perturbed extension key is a missing template, a perturbed field value is a crash-free but meaningless field swap. This family registers all three calls in the call-routing registry with those positions :raw, so no family — core value literals included — mutates them or anything inside them, at bare, qualified, and aliased call sites alike. The registry route (rather than an argument mark) is deliberate: it covers the position's whole subtree, and registration is also what makes the bare forms resolvable and witness-safe at all (see below). Keeping a position raw for everyone and then minting the one safe mutation there from its owner is exactly what the routing contract is for.

Resolution: why this family is half of a matched pair

use Phoenix.Swoosh injects import Phoenix.Swoosh, except: [render_body: 3] plus a local def render_body/2,3 wrapper — so a mailer's bare render_body calls resolve to a local definition invisible to Mutare. Mutare.Phoenix.Swoosh (listed under :extensions) overrides that use to surface a whole import Phoenix.Swoosh in its place; this family's registry entries then complete the picture twice over:

  • the /2 wrapper arity is not a real Phoenix.Swoosh export, so import reflection can never resolve a bare render_body(email, :welcome) — the registry's whole-import fallback is what resolves it;
  • a bare imported call normally carries a compile-time witness that re-imports the believed provider next to the mutant — which, next to the real injected local def, is an import/local conflict that would fail the single metamutant compile. Registered calls have that witness dropped.

Without the extension, bare render_body sites are neither mutated nor pinned (the faithfully-harvested except: import excludes exactly this one name); qualified and aliased sites work regardless.

Deliberately left alone

  • The assigns argument is ordinary runtime data — core's families mutate the values inside it as they would anywhere (a wrong interpolated value in a body only a body-content assertion catches). Its keys are likewise left to core's uniform map-literal judgment, not pinned here. The one assigns entry this package does speak for is :layout, which is phoenix_swoosh vocabulary rather than template data — Mutare.Phoenix.Swoosh.Layout owns it.
  • Dropping an assigns entry was considered and rejected: a template referencing the dropped assign raises KeyError/ArgumentError at render — a crash-kill, not a survivor question.
  • Swapping a format's body field ("html" => :html_body:text_body) is not minted: the rendered html would land in text_body alongside whatever the text template put there, and which one survives depends on map ordering — a mutant whose behaviour a reader cannot predict from its diff.