LemonPlatformTest.PluginCase (lemon_platform_test v0.1.0)

View Source

Compliance suite for LemonChannels.Plugin implementations.

What a plugin is

A channel plugin connects Lemon to a messaging surface — Telegram, Discord, WhatsApp, XMTP, X, your company's internal chat. It is the platform's most used extension point, and the one with the most exposure to input you did not write: everything arriving from the network passes through normalize_inbound/1 before the platform will look at it.

A plugin is a module, not a process. It describes a process tree through child_spec/1, which LemonChannels starts under its own supervisor once the plugin is registered. Registration can happen at boot from configuration, or at runtime from another application:

LemonChannels.Application.register_and_start_adapter(MyApp.ChannelAdapter, [])

That runtime path is how a satellite package (an integration living in its own repo, depending on lemon_channels from Hex) plugs itself in without the platform knowing it exists. This suite exercises it, because "my adapter works but the platform can't see it" is the most common way a third-party channel fails.

The contract

id/0 is an identity, not a label

A short, stable, lowercase slug matching ~r/^[a-z][a-z0-9_-]*$/. It is the registry key, it appears in LemonCore.InboundMessage.channel_id and LemonChannels.OutboundPayload.channel_id, and it ends up in persisted routing state — so changing it later strands existing bindings. It must be pure: same value on every call, no configuration lookups.

meta/0 describes capabilities, and is also pure

%{label: binary, capabilities: map, docs: binary | nil}. :label is what operators see. :capabilities is how the renderer decides whether it may edit a message instead of resending it, how long a chunk may be, whether voice or attachments are worth trying; LemonChannels.Capabilities interprets it. Callers hit meta/0 on every status query, so it must not do I/O.

child_spec/1 is the standard OTP one

Returns a child spec map with :id and :start. Return %{id: __MODULE__, start: {SomeSupervisor, :start_link, [opts]}, type: :supervisor} for a plugin with a process tree. A plugin that has nothing to run should still return a valid spec whose start function returns :ignore — the registry starts whatever you hand it.

normalize_inbound/1 must not raise

It receives whatever the transport handed you: an incomplete webhook body, a message type you have never seen, a payload from a version of the upstream API that shipped this morning. Return {:ok, %LemonCore.InboundMessage{}} for something you understand and {:error, reason} for anything else. Raising takes down the process that was reading from the network, which usually means a reconnect loop, and the malformed message is still there when you come back.

Messages you return must carry your own channel_id (the platform routes replies by it), a peer with :kind in [:dm, :group, :channel] and a binary :id, and a message map with a binary :text. For anything you normalize from a real update — the :inbound_fixtures you hand this suite — the peer id must also be non-empty, or the platform has nowhere to send the reply.

deliver/1 reports failure, it does not raise

Given a LemonChannels.OutboundPayload, return {:ok, delivery_ref} or {:error, reason}. delivery_ref is opaque to the platform and is what the outbox hands back to whoever asked for the send. Unsupported payload kinds are an {:error, _}, not a crash: the renderer will try :edit on a channel that claimed edit_support, and a network hiccup must not take the adapter with it.

gateway_methods/0 may be empty

A list of %{name: binary, scopes: [atom], handler: module} control-plane methods your channel adds. Most channels return [].

Minimal implementation

defmodule MyApp.ChannelAdapter do
  @behaviour LemonChannels.Plugin

  alias LemonChannels.OutboundPayload
  alias LemonCore.InboundMessage

  @impl true
  def id, do: "my-channel"

  @impl true
  def meta do
    %{
      label: "My Channel",
      capabilities: %{edit_support: false, chunk_limit: 2_000},
      docs: "https://example.com/api"
    }
  end

  @impl true
  def child_spec(opts) do
    %{id: __MODULE__, start: {MyApp.Channel.Supervisor, :start_link, [opts]}, type: :supervisor}
  end

  @impl true
  def normalize_inbound(%{"chat" => %{"id" => chat_id}, "text" => text} = raw)
      when is_binary(text) do
    {:ok,
     InboundMessage.new(
       channel_id: id(),
       account_id: "default",
       peer: %{kind: :dm, id: to_string(chat_id), thread_id: nil},
       message: %{id: nil, text: text, timestamp: nil, reply_to_id: nil},
       raw: raw
     )}
  end

  def normalize_inbound(_raw), do: {:error, :unsupported_payload}

  @impl true
  def deliver(%OutboundPayload{kind: :text} = payload) do
    MyApp.Api.send_message(payload.peer.id, payload.content)
  end

  def deliver(%OutboundPayload{kind: kind}), do: {:error, {:unsupported_kind, kind}}

  @impl true
  def gateway_methods, do: []
end

Running the suite

defmodule MyApp.ChannelAdapterComplianceTest do
  use LemonPlatformTest.PluginCase, async: false, adapter: MyApp.ChannelAdapter
end

async: false is required whenever :registry is enabled (the default), because the suite registers and unregisters the adapter in the node-global LemonChannels.Registry. It restores whatever registration it found.

Options

  • :adapter — required, the plugin module under test.
  • :registry — round-trip the adapter through LemonChannels.Registry. Default true; requires :lemon_channels to be started.
  • :start_adapter — also exercise LemonChannels.Application.register_and_start_adapter/2, which starts your child_spec/1 under the channels supervisor. Default false; only enable it if starting your tree in a test environment is harmless (no credentials, no outbound connections).
  • :deliver_probe{Module, :function} returning an %LemonChannels.OutboundPayload{}, called with the test context. The suite asserts deliver/1 answers {:ok, _} or {:error, _} for it. There is no default, on purpose: only you know which payload cannot reach a real user. A payload with a :kind your adapter does not support is usually the right choice.
  • :inbound_fixtures{Module, :function} returning a list of raw values that must normalize successfully. The suite checks the resulting InboundMessage structs. Optional, but this is where you prove your adapter understands its own wire format.
  • :hostile_inbound — extra raw values that must not raise, appended to the built-in list (nil, %{}, "", integers, unexpected shapes).

Known gaps in the behaviour

  • meta/0's :capabilities map is typed map(); the keys the renderer actually reads (:edit_support, :chunk_limit, …) are documented only by example, and LemonChannels.Capabilities.from_legacy/1 fills in defaults for anything missing. The suite therefore checks the shape of meta/0 but cannot check that a capability claim is honest.
  • Nothing forbids an unroutable InboundMessage. normalize_inbound/1 may answer {:ok, message} with an empty peer.id, and at least one built-in adapter does exactly that when handed a truncated update — the message then flows into routing and fails much later. The suite enforces a non-empty peer id only for :inbound_fixtures, since tightening the hostile-input path would fail an adapter the behaviour currently permits.
  • deliver/1's delivery_ref is term(). Adapters return wildly different things (a message id, a map, an API response). Nothing consumes it generically, so nothing can.

Summary

Functions

Asserts adapter.meta/0 has the shape the channel registry expects.

Starts :lemon_channels if it is not already running.

The raw inbound values every plugin is probed with.

Functions

assert_meta!(adapter)

@spec assert_meta!(module()) :: :ok

Asserts adapter.meta/0 has the shape the channel registry expects.

Lives here rather than in the generated test so that the assertions run against a module() the compiler cannot constant-fold: adapters usually return a literal map from meta/0, and checking docs == nil on a literal makes Elixir's type checker (rightly) complain that the comparison is decided at compile time.

ensure_channels_started!()

@spec ensure_channels_started!() :: :ok

Starts :lemon_channels if it is not already running.

The registration round-trip needs LemonChannels.Registry alive. Call this from your test_helper.exs instead if you prefer the application started once for the whole suite.

hostile_inbound()

@spec hostile_inbound() :: [term()]

The raw inbound values every plugin is probed with.

Exposed so you can reuse them in your adapter's own tests.