Fosi Fossil Service
Small teams. Open source. Built on Fossil.

CaaS / Lamina

Unified Management for Fossil Repositories

Public | Owner: CaaS | Default branch: trunk | Last update: 2026-10-25 00:41

Artifact 67584e2e9bd9f9173a298ef1c600a9e67170bc86d6bd41dc47bff7c04f7a0107:


-module(nova_plugin_handler_tests).
-include_lib("eunit/include/eunit.hrl").

-define(SETUP, fun() -> setup() end).
-define(CLEANUP, fun(S) -> cleanup(S) end).

setup() ->
    Prev = nova_test_helper:setup_nova_env(),
    application:set_env(nova, dispatch_backend, persistent_term),
    Tree = nova_routing_trie:new(#{use_strict => false, convert_to_binary => true}),
    persistent_term:put(nova_dispatch, Tree),
    persistent_term:put(nova_plugins, []),
    persistent_term:put(nova_use_stacktrace, false),
    {ok, Pid} = nova_plugin_manager:start_link(),
    meck:new(cowboy_req, [passthrough, no_link]),
    meck:expect(cowboy_req, reply, fun(Status, Req) ->
        Req#{resp_status_code => Status}
    end),
    meck:expect(cowboy_req, set_resp_headers, fun(Headers, Req) ->
        Existing = maps:get(resp_headers, Req, #{}),
        Req#{resp_headers => maps:merge(Existing, Headers)}
    end),
    meck:expect(cowboy_req, set_resp_body, fun(Body, Req) ->
        Req#{resp_body => Body}
    end),
    {Prev, Pid}.

cleanup({Prev, Pid}) ->
    gen_server:stop(Pid),
    meck:unload(cowboy_req),
    persistent_term:erase(nova_dispatch),
    persistent_term:erase(nova_plugins),
    persistent_term:erase(nova_use_stacktrace),
    nova_test_helper:cleanup_nova_env(Prev).

req() ->
    nova_test_helper:mock_req(<<"GET">>, <<"/">>).

req(Plugins) ->
    R = nova_test_helper:mock_req(<<"GET">>, <<"/">>),
    R#{plugins => Plugins}.

env() ->
    #{app => test_app, controller_data => #{}}.

env(Extra) ->
    maps:merge(env(), Extra).

plugin(Fun) ->
    {Fun, #{}}.

%%====================================================================
%% No plugins key in Req -> passthrough
%%====================================================================

no_plugins_key_test_() ->
    {setup, ?SETUP, ?CLEANUP, fun() ->
        Req = req(),
        Env = env(),
        ?assertEqual({ok, Req, Env}, nova_plugin_handler:execute(Req, Env))
    end}.

%%====================================================================
%% Empty pre_request plugins -> sets plugin_state
%%====================================================================

empty_pre_plugins_test_() ->
    {setup, ?SETUP, ?CLEANUP, fun() ->
        Req = req([{pre_request, []}]),
        Env = env(),
        {ok, Req1, Env1} = nova_plugin_handler:execute(Req, Env),
        ?assertEqual(Req, Req1),
        ?assertEqual(pre_request, maps:get(plugin_state, Env1))
    end}.

%%====================================================================
%% Pre plugin returns {ok, Req, State} -> continues chain
%%====================================================================

pre_plugin_ok_test_() ->
    {setup, ?SETUP, ?CLEANUP, fun() ->
        P = fun(R, _Env, _Opts, _State) ->
            {ok, R#{plugin_touched => true}, #{ran => true}}
        end,
        Req = req([{pre_request, [plugin(P)]}]),
        {ok, Req1, Env1} = nova_plugin_handler:execute(Req, env()),
        ?assertEqual(true, maps:get(plugin_touched, Req1)),
        ?assertEqual(pre_request, maps:get(plugin_state, Env1))
    end}.

%%====================================================================
%% Pre plugin returns {ok, Reply, Req, State} -> handles reply, continues
%%====================================================================

pre_plugin_ok_reply_test_() ->
    {setup, ?SETUP, ?CLEANUP, fun() ->
        P = fun(R, _Env, _Opts, _State) ->
            {ok, {reply, <<"hello">>}, R, #{}}
        end,
        Req = req([{pre_request, [plugin(P)]}]),
        {ok, Req1, Env1} = nova_plugin_handler:execute(Req, env()),
        ?assertEqual(200, maps:get(resp_status_code, Req1)),
        ?assertEqual(<<"hello">>, maps:get(resp_body, Req1)),
        ?assertEqual(pre_request, maps:get(plugin_state, Env1))
    end}.

%%====================================================================
%% Pre plugin returns {break, Req, State} -> stops chain
%%====================================================================

pre_plugin_break_test_() ->
    {setup, ?SETUP, ?CLEANUP, fun() ->
        P = fun(R, _Env, _Opts, _State) ->
            {break, R#{broke => true}, #{}}
        end,
        Req = req([{pre_request, [plugin(P)]}]),
        {ok, Req1} = nova_plugin_handler:execute(Req, env()),
        ?assertEqual(true, maps:get(broke, Req1))
    end}.

%%====================================================================
%% Pre plugin returns {break, Reply, Req, State} -> handles reply, stops
%%====================================================================

pre_plugin_break_reply_test_() ->
    {setup, ?SETUP, ?CLEANUP, fun() ->
        P = fun(R, _Env, _Opts, _State) ->
            {break, {reply, 403, <<"forbidden">>}, R, #{}}
        end,
        Req = req([{pre_request, [plugin(P)]}]),
        {ok, Req1} = nova_plugin_handler:execute(Req, env()),
        ?assertEqual(403, maps:get(resp_status_code, Req1)),
        ?assertEqual(<<"forbidden">>, maps:get(resp_body, Req1))
    end}.

%%====================================================================
%% Pre plugin returns {stop, Req, State} -> {stop, Req}
%%====================================================================

pre_plugin_stop_test_() ->
    {setup, ?SETUP, ?CLEANUP, fun() ->
        P = fun(R, _Env, _Opts, _State) ->
            {stop, R#{stopped => true}, #{}}
        end,
        Req = req([{pre_request, [plugin(P)]}]),
        {stop, Req1} = nova_plugin_handler:execute(Req, env()),
        ?assertEqual(true, maps:get(stopped, Req1))
    end}.

%%====================================================================
%% Pre plugin returns {stop, Reply, Req, State} -> replies, {stop, Req}
%%====================================================================

pre_plugin_stop_reply_test_() ->
    {setup, ?SETUP, ?CLEANUP, fun() ->
        P = fun(R, _Env, _Opts, _State) ->
            {stop, {reply, 503, <<"unavailable">>}, R, #{}}
        end,
        Req = req([{pre_request, [plugin(P)]}]),
        {stop, Req1} = nova_plugin_handler:execute(Req, env()),
        ?assertEqual(503, maps:get(resp_status_code, Req1)),
        ?assertEqual(<<"unavailable">>, maps:get(resp_body, Req1)),
        ?assert(meck:called(cowboy_req, reply, [503, '_']))
    end}.

%%====================================================================
%% Post request path (plugin_state already pre_request in Env)
%%====================================================================

post_plugin_path_test_() ->
    {setup, ?SETUP, ?CLEANUP, fun() ->
        P = fun(R, _Env, _Opts, _State) ->
            {ok, R#{post_ran => true}, #{}}
        end,
        Req = req([{post_request, [plugin(P)]}]),
        Env = env(#{plugin_state => pre_request}),
        {ok, Req1, Env1} = nova_plugin_handler:execute(Req, Env),
        ?assertEqual(true, maps:get(post_ran, Req1)),
        ?assertEqual(post_request, maps:get(plugin_state, Env1))
    end}.

%%====================================================================
%% Plugin crash -> renders 500
%%====================================================================

plugin_crash_test_() ->
    {setup, ?SETUP, ?CLEANUP, fun() ->
        P = fun(_R, _Env, _Opts, _State) ->
            error(boom)
        end,
        Req = req([{pre_request, [plugin(P)]}]),
        {ok, Req1, _Env1} = nova_plugin_handler:execute(Req, env()),
        ?assertEqual(500, maps:get(resp_status_code, Req1)),
        ?assert(is_map(maps:get(crash_info, Req1)))
    end}.

%%====================================================================
%% Multiple plugins chain in order
%%====================================================================

multiple_plugins_chain_test_() ->
    {setup, ?SETUP, ?CLEANUP, fun() ->
        P1 = fun(R, _Env, _Opts, _State) ->
            Order = maps:get(order, R, []),
            {ok, R#{order => Order ++ [first]}, #{}}
        end,
        P2 = fun(R, _Env, _Opts, _State) ->
            Order = maps:get(order, R, []),
            {ok, R#{order => Order ++ [second]}, #{}}
        end,
        P3 = fun(R, _Env, _Opts, _State) ->
            Order = maps:get(order, R, []),
            {ok, R#{order => Order ++ [third]}, #{}}
        end,
        Req = req([{pre_request, [plugin(P1), plugin(P2), plugin(P3)]}]),
        {ok, Req1, _} = nova_plugin_handler:execute(Req, env()),
        ?assertEqual([first, second, third], maps:get(order, Req1))
    end}.

%%====================================================================
%% Break in middle of chain stops remaining plugins
%%====================================================================

break_stops_chain_test_() ->
    {setup, ?SETUP, ?CLEANUP, fun() ->
        P1 = fun(R, _Env, _Opts, _State) ->
            {ok, R#{p1 => true}, #{}}
        end,
        P2 = fun(R, _Env, _Opts, _State) ->
            {break, R#{p2 => true}, #{}}
        end,
        P3 = fun(R, _Env, _Opts, _State) ->
            {ok, R#{p3 => true}, #{}}
        end,
        Req = req([{pre_request, [plugin(P1), plugin(P2), plugin(P3)]}]),
        {ok, Req1} = nova_plugin_handler:execute(Req, env()),
        ?assertEqual(true, maps:get(p1, Req1)),
        ?assertEqual(true, maps:get(p2, Req1)),
        ?assertNot(maps:is_key(p3, Req1))
    end}.

%%====================================================================
%% handle_reply with {reply, Body} -> 200 default
%%====================================================================

handle_reply_body_only_test_() ->
    {setup, ?SETUP, ?CLEANUP, fun() ->
        P = fun(R, _Env, _Opts, _State) ->
            {ok, {reply, <<"body">>}, R, #{}}
        end,
        Req = req([{pre_request, [plugin(P)]}]),
        {ok, Req1, _} = nova_plugin_handler:execute(Req, env()),
        ?assertEqual(200, maps:get(resp_status_code, Req1)),
        ?assertEqual(<<"body">>, maps:get(resp_body, Req1))
    end}.

%%====================================================================
%% handle_reply with {reply, Status, Body}
%%====================================================================

handle_reply_status_body_test_() ->
    {setup, ?SETUP, ?CLEANUP, fun() ->
        P = fun(R, _Env, _Opts, _State) ->
            {ok, {reply, 201, <<"created">>}, R, #{}}
        end,
        Req = req([{pre_request, [plugin(P)]}]),
        {ok, Req1, _} = nova_plugin_handler:execute(Req, env()),
        ?assertEqual(201, maps:get(resp_status_code, Req1)),
        ?assertEqual(<<"created">>, maps:get(resp_body, Req1))
    end}.

%%====================================================================
%% handle_reply with {reply, Status, Headers, Body}
%%====================================================================

handle_reply_full_test_() ->
    {setup, ?SETUP, ?CLEANUP, fun() ->
        P = fun(R, _Env, _Opts, _State) ->
            {ok, {reply, 202, #{<<"x-custom">> => <<"val">>}, <<"accepted">>}, R, #{}}
        end,
        Req = req([{pre_request, [plugin(P)]}]),
        {ok, Req1, _} = nova_plugin_handler:execute(Req, env()),
        ?assertEqual(202, maps:get(resp_status_code, Req1)),
        ?assertEqual(<<"accepted">>, maps:get(resp_body, Req1)),
        Headers = maps:get(resp_headers, Req1),
        ?assertEqual(<<"val">>, maps:get(<<"x-custom">>, Headers))
    end}.

%%====================================================================
%% handle_reply passthrough with unknown format
%%====================================================================

handle_reply_passthrough_test_() ->
    {setup, ?SETUP, ?CLEANUP, fun() ->
        P = fun(R, _Env, _Opts, _State) ->
            {ok, something_unknown, R, #{}}
        end,
        Req = req([{pre_request, [plugin(P)]}]),
        {ok, Req1, _} = nova_plugin_handler:execute(Req, env()),
        ?assertNot(maps:is_key(resp_status_code, Req1))
    end}.

%%====================================================================
%% Empty post_request plugins -> sets plugin_state to post_request
%%====================================================================

empty_post_plugins_test_() ->
    {setup, ?SETUP, ?CLEANUP, fun() ->
        Req = req([{post_request, []}]),
        Env = env(#{plugin_state => pre_request}),
        {ok, _Req1, Env1} = nova_plugin_handler:execute(Req, Env),
        ?assertEqual(post_request, maps:get(plugin_state, Env1))
    end}.

%%====================================================================
%% Stop with reply uses default 200 when no resp_status_code set
%%====================================================================

stop_reply_default_status_test_() ->
    {setup, ?SETUP, ?CLEANUP, fun() ->
        P = fun(R, _Env, _Opts, _State) ->
            {stop, {reply, <<"bye">>}, R, #{}}
        end,
        Req = req([{pre_request, [plugin(P)]}]),
        {stop, Req1} = nova_plugin_handler:execute(Req, env()),
        ?assertEqual(200, maps:get(resp_status_code, Req1)),
        ?assert(meck:called(cowboy_req, reply, [200, '_']))
    end}.

Clone

You can clone this repository with the following command:

fossil clone https://fosi.dev/CaaS/Lamina

Contribute

The best way to contribute is through e-mail. See the project documentation for more details.