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: []
endRunning the suite
defmodule MyApp.ChannelAdapterComplianceTest do
use LemonPlatformTest.PluginCase, async: false, adapter: MyApp.ChannelAdapter
endasync: 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 throughLemonChannels.Registry. Defaulttrue; requires:lemon_channelsto be started.:start_adapter— also exerciseLemonChannels.Application.register_and_start_adapter/2, which starts yourchild_spec/1under the channels supervisor. Defaultfalse; 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 assertsdeliver/1answers{: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:kindyour 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 resultingInboundMessagestructs. 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:capabilitiesmap is typedmap(); the keys the renderer actually reads (:edit_support,:chunk_limit, …) are documented only by example, andLemonChannels.Capabilities.from_legacy/1fills in defaults for anything missing. The suite therefore checks the shape ofmeta/0but cannot check that a capability claim is honest.- Nothing forbids an unroutable
InboundMessage.normalize_inbound/1may answer{:ok, message}with an emptypeer.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'sdelivery_refisterm(). 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
@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.
@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.
@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.