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 b23bc3a568cc1e11385d06c39d389aa7dd7059d18c3fea845552ee87121eed61:


-module(nova_ws_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),
    application:set_env(nova, render_error_pages, false),
    Tree = nova_routing_trie:new(#{use_strict => false, convert_to_binary => true}),
    persistent_term:put(nova_dispatch, Tree),
    persistent_term:put(nova_use_stacktrace, false),
    {ok, Pid} = nova_handlers:start_link(),
    meck:new(test_ws_mod, [non_strict, no_link]),
    meck:expect(test_ws_mod, init, fun(CD) -> {ok, CD} end),
    meck:expect(test_ws_mod, websocket_init, fun(CD) -> {ok, CD} end),
    meck:expect(test_ws_mod, websocket_handle, fun(_Frame, CD) -> {ok, CD} end),
    meck:expect(test_ws_mod, websocket_info, fun(_Msg, CD) -> {ok, CD} end),
    meck:expect(test_ws_mod, terminate, fun(_Reason, _PartialReq, _CD) -> ok end),
    {Prev, Pid}.

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

make_req() ->
    Req = nova_test_helper:mock_req(<<"GET">>, <<"/ws">>),
    Req#{plugins => []}.

make_state() ->
    #{module => test_ws_mod, controller_data => #{}}.

make_ws_state() ->
    #{mod => test_ws_mod, controller_data => #{}, plugins => [], commands => []}.

%%====================================================================
%% init/2
%%====================================================================

init_successful_upgrade_test_() ->
    {setup, ?SETUP, ?CLEANUP, fun() ->
        Req = make_req(),
        State = make_state(),
        Result = nova_ws_handler:init(Req, State),
        ?assertMatch({cowboy_websocket, _, _}, Result),
        {cowboy_websocket, _Req1, State1} = Result,
        ?assert(maps:is_key(controller_data, State1)),
        ?assertEqual(test_ws_mod, maps:get(mod, State1))
    end}.

init_injects_req_into_controller_data_test_() ->
    {setup, ?SETUP, ?CLEANUP, fun() ->
        Req = make_req(),
        State = make_state(),
        {cowboy_websocket, _Req1, State1} = nova_ws_handler:init(Req, State),
        CD = maps:get(controller_data, State1),
        ?assert(maps:is_key(req, CD))
    end}.

init_module_error_renders_status_page_test_() ->
    {setup, ?SETUP, ?CLEANUP, fun() ->
        meck:expect(test_ws_mod, init, fun(_CD) -> {error, bad_stuff} end),
        Req = make_req(),
        State = make_state(),
        Result = nova_ws_handler:init(Req, State),
        ?assertMatch({ok, _, _}, Result),
        {ok, Req1, _Env} = Result,
        ?assertEqual(500, maps:get(resp_status_code, Req1))
    end}.

init_preserves_existing_controller_data_test_() ->
    {setup, ?SETUP, ?CLEANUP, fun() ->
        Req = make_req(),
        State = #{module => test_ws_mod, controller_data => #{existing => value}},
        {cowboy_websocket, _Req1, State1} = nova_ws_handler:init(Req, State),
        CD = maps:get(controller_data, State1),
        ?assertEqual(value, maps:get(existing, CD))
    end}.

init_default_controller_data_test_() ->
    {setup, ?SETUP, ?CLEANUP, fun() ->
        Req = make_req(),
        State = #{module => test_ws_mod},
        {cowboy_websocket, _Req1, State1} = nova_ws_handler:init(Req, State),
        ?assert(maps:is_key(controller_data, State1))
    end}.

%%====================================================================
%% websocket_init/1
%%====================================================================

websocket_init_calls_module_test_() ->
    {setup, ?SETUP, ?CLEANUP, fun() ->
        State = make_ws_state(),
        Result = nova_ws_handler:websocket_init(State),
        ?assertMatch({[], _}, Result),
        ?assert(meck:called(test_ws_mod, websocket_init, '_'))
    end}.

websocket_init_injects_ws_handler_process_test_() ->
    {setup, ?SETUP, ?CLEANUP, fun() ->
        State = make_ws_state(),
        {_Cmds, State1} = nova_ws_handler:websocket_init(State),
        CD = maps:get(controller_data, State1),
        ?assertEqual(self(), maps:get(ws_handler_process, CD))
    end}.

websocket_init_skips_if_not_exported_test_() ->
    {setup, ?SETUP, ?CLEANUP, fun() ->
        meck:new(test_ws_mod_no_init, [non_strict, no_link]),
        meck:expect(test_ws_mod_no_init, websocket_handle, fun(_F, CD) -> {ok, CD} end),
        State = #{mod => test_ws_mod_no_init, controller_data => #{}, plugins => []},
        Result = nova_ws_handler:websocket_init(State),
        ?assertMatch({ok, _}, Result),
        {ok, State1} = Result,
        CD = maps:get(controller_data, State1),
        ?assertEqual(self(), maps:get(ws_handler_process, CD)),
        meck:unload(test_ws_mod_no_init)
    end}.

%%====================================================================
%% websocket_handle/2
%%====================================================================

websocket_handle_ok_test_() ->
    {setup, ?SETUP, ?CLEANUP, fun() ->
        State = make_ws_state(),
        Frame = {text, <<"hello">>},
        Result = nova_ws_handler:websocket_handle(Frame, State),
        ?assertMatch({[], _}, Result),
        {_Cmds, State1} = Result,
        ?assert(maps:is_key(controller_data, State1))
    end}.

websocket_handle_reply_test_() ->
    {setup, ?SETUP, ?CLEANUP, fun() ->
        meck:expect(test_ws_mod, websocket_handle, fun(Frame, CD) ->
            {reply, Frame, CD}
        end),
        State = make_ws_state(),
        Frame = {text, <<"echo">>},
        Result = nova_ws_handler:websocket_handle(Frame, State),
        ?assertMatch({[{text, <<"echo">>}], _}, Result)
    end}.

websocket_handle_reply_hibernate_test_() ->
    {setup, ?SETUP, ?CLEANUP, fun() ->
        meck:expect(test_ws_mod, websocket_handle, fun(Frame, CD) ->
            {reply, Frame, CD, hibernate}
        end),
        State = make_ws_state(),
        Frame = {text, <<"sleep">>},
        Result = nova_ws_handler:websocket_handle(Frame, State),
        ?assertMatch({[{text, <<"sleep">>}], _, hibernate}, Result)
    end}.

websocket_handle_ok_hibernate_test_() ->
    {setup, ?SETUP, ?CLEANUP, fun() ->
        meck:expect(test_ws_mod, websocket_handle, fun(_Frame, CD) ->
            {ok, CD, hibernate}
        end),
        State = make_ws_state(),
        Result = nova_ws_handler:websocket_handle({text, <<"hi">>}, State),
        ?assertMatch({[], _, hibernate}, Result)
    end}.

websocket_handle_stop_test_() ->
    {setup, ?SETUP, ?CLEANUP, fun() ->
        meck:expect(test_ws_mod, websocket_handle, fun(_Frame, CD) ->
            {stop, CD}
        end),
        State = make_ws_state(),
        Result = nova_ws_handler:websocket_handle({text, <<"bye">>}, State),
        ?assertMatch({stop, _}, Result)
    end}.

%%====================================================================
%% websocket_info/2
%%====================================================================

websocket_info_ok_test_() ->
    {setup, ?SETUP, ?CLEANUP, fun() ->
        State = make_ws_state(),
        Result = nova_ws_handler:websocket_info(tick, State),
        ?assertMatch({[], _}, Result)
    end}.

websocket_info_reply_test_() ->
    {setup, ?SETUP, ?CLEANUP, fun() ->
        meck:expect(test_ws_mod, websocket_info, fun(_Msg, CD) ->
            {reply, {text, <<"pong">>}, CD}
        end),
        State = make_ws_state(),
        Result = nova_ws_handler:websocket_info(ping, State),
        ?assertMatch({[{text, <<"pong">>}], _}, Result)
    end}.

%% The commands list goes to cowboy untouched and it reads one command per
%% element, so a controller replying with several frames must produce several
%% elements. Previously the whole list became one element and reached
%% cow_ws:frame/2 as a single frame, taking the connection process down.
websocket_info_reply_frame_list_test_() ->
    {setup, ?SETUP, ?CLEANUP, fun() ->
        meck:expect(test_ws_mod, websocket_info, fun(_Msg, CD) ->
            {reply, [{text, <<"a">>}, {text, <<"b">>}], CD}
        end),
        State = make_ws_state(),
        Result = nova_ws_handler:websocket_info(ping, State),
        ?assertMatch({[{text, <<"a">>}, {text, <<"b">>}], _}, Result)
    end}.

websocket_handle_reply_frame_list_test_() ->
    {setup, ?SETUP, ?CLEANUP, fun() ->
        meck:expect(test_ws_mod, websocket_handle, fun(_Frame, CD) ->
            {reply, [{text, <<"a">>}, {binary, <<"b">>}], CD}
        end),
        State = make_ws_state(),
        Result = nova_ws_handler:websocket_handle({text, <<"hi">>}, State),
        ?assertMatch({[{text, <<"a">>}, {binary, <<"b">>}], _}, Result)
    end}.

%% Every element cowboy receives has to be something cow_ws:frame/2 accepts.
%% Asserting that directly is what would have caught the original defect: the
%% shape looked right in the handler's return value and only failed inside
%% cowboy.
websocket_info_reply_frame_list_is_encodable_test_() ->
    {setup, ?SETUP, ?CLEANUP, fun() ->
        meck:expect(test_ws_mod, websocket_info, fun(_Msg, CD) ->
            {reply, [{text, <<"a">>}, {text, <<"b">>}], CD}
        end),
        {Commands, _State} = nova_ws_handler:websocket_info(ping, make_ws_state()),
        [?assertMatch(<<_/binary>>, iolist_to_binary(cow_ws:frame(F, #{}))) || F <- Commands]
    end}.

%%====================================================================
%% terminate/3
%%====================================================================

terminate_calls_module_terminate_test_() ->
    {setup, ?SETUP, ?CLEANUP, fun() ->
        State = #{mod => test_ws_mod, controller_data => #{foo => bar},
                  plugins => []},
        Result = nova_ws_handler:terminate(normal, #{}, State),
        ?assertMatch({ok, _}, Result),
        ?assert(meck:called(test_ws_mod, terminate, [normal, #{}, #{foo => bar}]))
    end}.

terminate_runs_post_ws_connection_plugins_test_() ->
    {setup, ?SETUP, ?CLEANUP, fun() ->
        meck:new(test_ws_plugin, [non_strict, no_link]),
        meck:expect(test_ws_plugin, post_ws_connection,
            fun(_CS, State, _Opts) -> {ok, #{}, State} end),
        State = #{mod => test_ws_mod, controller_data => #{},
                  plugins => [{post_ws_connection, [{test_ws_plugin, []}]}]},
        nova_ws_handler:terminate(normal, #{}, State),
        ?assert(meck:called(test_ws_plugin, post_ws_connection, '_')),
        meck:unload(test_ws_plugin)
    end}.

terminate_skips_if_not_exported_test_() ->
    {setup, ?SETUP, ?CLEANUP, fun() ->
        meck:new(test_ws_mod_no_term, [non_strict, no_link]),
        State = #{mod => test_ws_mod_no_term, controller_data => #{},
                  plugins => []},
        ?assertEqual(ok, nova_ws_handler:terminate(normal, #{}, State)),
        meck:unload(test_ws_mod_no_term)
    end}.

terminate_catchall_returns_ok_test_() ->
    {setup, ?SETUP, ?CLEANUP, fun() ->
        State = #{some => data},
        ?assertEqual(ok, nova_ws_handler:terminate(normal, #{}, State))
    end}.

%%====================================================================
%% Controller crash in invoke_controller
%%====================================================================

controller_crash_returns_stop_test_() ->
    {setup, ?SETUP, ?CLEANUP, fun() ->
        meck:expect(test_ws_mod, websocket_handle, fun(_Frame, _CD) ->
            error(intentional_crash)
        end),
        State = make_ws_state(),
        Result = nova_ws_handler:websocket_handle({text, <<"boom">>}, State),
        ?assertMatch({stop, _}, Result)
    end}.

%%====================================================================
%% Plugin returning stop signal
%%====================================================================

pre_plugin_stop_signal_test_() ->
    {setup, ?SETUP, ?CLEANUP, fun() ->
        meck:new(test_stop_plugin, [non_strict, no_link]),
        meck:expect(test_stop_plugin, pre_ws_request,
            fun(_CS, State, _Opts) -> {stop, State} end),
        State = #{mod => test_ws_mod, controller_data => #{},
                  plugins => [{pre_ws_request, [{test_stop_plugin, []}]}],
                  commands => []},
        Result = nova_ws_handler:websocket_handle({text, <<"hello">>}, State),
        ?assertMatch({stop, _}, Result),
        %% Controller should not have been called
        ?assertNot(meck:called(test_ws_mod, websocket_handle, '_')),
        meck:unload(test_stop_plugin)
    end}.

%%====================================================================
%% handle_ws with no controller_data in state
%%====================================================================

handle_ws_no_controller_data_test_() ->
    {setup, ?SETUP, ?CLEANUP, fun() ->
        State = #{mod => test_ws_mod, plugins => []},
        Result = nova_ws_handler:websocket_handle({text, <<"hi">>}, State),
        ?assertMatch({[], _}, Result),
        {_Cmds, State1} = Result,
        ?assert(maps:is_key(controller_data, State1))
    end}.

%%====================================================================
%% Plugin error handling
%%====================================================================

pre_plugin_crash_returns_stop_test_() ->
    {setup, ?SETUP, ?CLEANUP, fun() ->
        meck:new(test_crash_plugin, [non_strict, no_link]),
        meck:expect(test_crash_plugin, pre_ws_request,
            fun(_CS, _State, _Opts) -> error(plugin_crash) end),
        State = #{mod => test_ws_mod, controller_data => #{},
                  plugins => [{pre_ws_request, [{test_crash_plugin, []}]}],
                  commands => []},
        Result = nova_ws_handler:websocket_handle({text, <<"hi">>}, State),
        ?assertMatch({stop, _}, Result),
        meck:unload(test_crash_plugin)
    end}.

pre_plugin_error_returns_stop_test_() ->
    {setup, ?SETUP, ?CLEANUP, fun() ->
        meck:new(test_err_plugin, [non_strict, no_link]),
        meck:expect(test_err_plugin, pre_ws_request,
            fun(_CS, _State, _Opts) -> {error, bad_plugin} end),
        State = #{mod => test_ws_mod, controller_data => #{},
                  plugins => [{pre_ws_request, [{test_err_plugin, []}]}],
                  commands => []},
        Result = nova_ws_handler:websocket_handle({text, <<"hi">>}, State),
        ?assertMatch({stop, _}, Result),
        meck:unload(test_err_plugin)
    end}.

pre_plugin_break_skips_remaining_plugins_test_() ->
    {setup, ?SETUP, ?CLEANUP, fun() ->
        meck:new(test_break_plugin, [non_strict, no_link]),
        meck:expect(test_break_plugin, pre_ws_request,
            fun(_CS, State, _Opts) -> {break, State} end),
        meck:new(test_second_plugin, [non_strict, no_link]),
        meck:expect(test_second_plugin, pre_ws_request,
            fun(_CS, State, _Opts) -> {ok, #{}, State} end),
        State = #{mod => test_ws_mod, controller_data => #{},
                  plugins => [{pre_ws_request, [{test_break_plugin, []},
                                                {test_second_plugin, []}]}],
                  commands => []},
        Result = nova_ws_handler:websocket_handle({text, <<"hi">>}, State),
        ?assertMatch({[], _}, Result),
        ?assert(meck:called(test_break_plugin, pre_ws_request, '_')),
        ?assertNot(meck:called(test_second_plugin, pre_ws_request, '_')),
        meck:unload(test_break_plugin),
        meck:unload(test_second_plugin)
    end}.

%%====================================================================
%% Handler not found
%%====================================================================

ws_handler_not_registered_returns_stop_test_() ->
    {setup, ?SETUP, ?CLEANUP, fun() ->
        nova_handlers:unregister_handler(ws),
        State = make_ws_state(),
        Result = nova_ws_handler:websocket_handle({text, <<"hi">>}, State),
        ?assertMatch({stop, _}, Result),
        %% Re-register for other tests
        nova_handlers:register_handler(ws, fun nova_basic_handler:handle_ws/2)
    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.