209 lines
7.5 KiB
Erlang
209 lines
7.5 KiB
Erlang
%%%-------------------------------------------------------------------
|
||
%%% @doc Тесты пользовательского WebSocket API.
|
||
%%%
|
||
%%% Покрывает эндпоинты:
|
||
%%% ws://localhost:8081/ws
|
||
%%%
|
||
%%% Проверяет:
|
||
%%% - подключение с валидным пользовательским токеном
|
||
%%% - подписку на календарь и получение подтверждения
|
||
%%% @end
|
||
%%%-------------------------------------------------------------------
|
||
-module(user_websocket_tests).
|
||
-include_lib("eunit/include/eunit.hrl").
|
||
-export([test/0]).
|
||
|
||
%%%===================================================================
|
||
%%% Главная тестовая функция
|
||
%%%===================================================================
|
||
-spec test() -> ok.
|
||
test() ->
|
||
ct:pal("=== User WebSocket Tests ==="),
|
||
application:ensure_all_started(gun),
|
||
|
||
UserToken = api_test_runner:get_user_token(),
|
||
|
||
% Создаём календарь и событие
|
||
CalId = api_test_runner:create_calendar(UserToken, #{title => <<"WS Test Calendar">>, type => <<"commercial">>}),
|
||
_EventId = api_test_runner:create_event(UserToken, CalId, #{
|
||
title => <<"WS Test Event">>,
|
||
start_time => <<"2026-06-01T10:00:00Z">>,
|
||
duration => 60
|
||
}),
|
||
|
||
WsUrl = api_test_runner:get_base_ws_url() ++ "/ws",
|
||
|
||
% ── Пользовательские WebSocket тесты ──
|
||
{ok, UserWS} = test_user_ws_connect(WsUrl, UserToken),
|
||
test_user_ws_subscribe(UserWS, CalId),
|
||
test_ws_close(UserWS),
|
||
|
||
ct:pal("~n=== All user WebSocket tests passed! ==="),
|
||
{?MODULE, ok}.
|
||
|
||
%%%===================================================================
|
||
%%% Тестовые функции
|
||
%%%===================================================================
|
||
|
||
%% @doc Подключение пользовательского WebSocket с валидным токеном.
|
||
-spec test_user_ws_connect(string(), binary()) -> {ok, pid()}.
|
||
test_user_ws_connect(Url, Token) ->
|
||
ct:pal(" TEST: Connect user WebSocket with valid token"),
|
||
case test_ws_connect_debug(Url, Token) of
|
||
{ok, WS} -> {ok, WS};
|
||
Other -> error({unexpected, Other})
|
||
end.
|
||
|
||
%% @doc Подписка на календарь и получение подтверждения.
|
||
-spec test_user_ws_subscribe(pid(), binary()) -> ok.
|
||
test_user_ws_subscribe(WS, CalId) ->
|
||
ct:pal(" TEST: Subscribe to calendar"),
|
||
SubMsg = #{action => <<"subscribe">>,
|
||
calendar_id => CalId},
|
||
ok = test_ws_send(WS, SubMsg),
|
||
case test_ws_recv(WS) of
|
||
{ok, #{<<"status">> := <<"subscribed">>}} -> ok;
|
||
{ok, Other} -> error({unexpected_response, Other});
|
||
{error, timeout} -> error(timeout)
|
||
end.
|
||
|
||
%% ============ WebSocket хелперы с отладкой ============
|
||
test_ws_connect_debug(Url, Token) ->
|
||
Path = case string:split(Url, "://", trailing) of
|
||
[_, Rest] ->
|
||
case string:split(Rest, "/", leading) of
|
||
[_HostPort, WsPath] ->
|
||
"/" ++ WsPath ++ "?token=" ++ binary_to_list(Token);
|
||
_ ->
|
||
"/ws?token=" ++ binary_to_list(Token)
|
||
end;
|
||
_ ->
|
||
"/ws?token=" ++ binary_to_list(Token)
|
||
end,
|
||
{ok, Port} = extract_port(Url),
|
||
{ok, Host} = extract_host(Url),
|
||
Opts = case Port of
|
||
443 -> #{protocols => [http],
|
||
transport => tls,
|
||
tls_opts => [{verify, verify_none}]};
|
||
_ -> #{protocols => [http]}
|
||
end,
|
||
ct:pal(" Host: ~s", [Host]),
|
||
ct:pal(" Port: ~p", [Port]),
|
||
ct:pal(" Path: ~s", [Path]),
|
||
{ok, ConnPid} = gun:open(Host, Port, Opts),
|
||
{ok, http} = gun:await_up(ConnPid, 5000),
|
||
Headers = [{<<"host">>, list_to_binary(Host ++ ":" ++ integer_to_list(Port))}],
|
||
StreamRef = gun:ws_upgrade(ConnPid, Path, Headers),
|
||
receive
|
||
{gun_upgrade, ConnPid, StreamRef, [<<"websocket">>], _} ->
|
||
ct:pal(" WebSocket upgrade OK"),
|
||
{ok, ConnPid};
|
||
{gun_response, ConnPid, StreamRef, fin, 401, _} ->
|
||
ct:pal(" ERROR: HTTP 401 Unauthorized"),
|
||
gun:close(ConnPid),
|
||
{error, {401, <<"Invalid token">>}};
|
||
{gun_response, ConnPid, StreamRef, fin, 403, _} ->
|
||
ct:pal(" ERROR: HTTP 403 Forbidden"),
|
||
gun:close(ConnPid),
|
||
{error, {403, <<"Admin access required">>}};
|
||
{gun_response, ConnPid, StreamRef, nofin, 403, _} ->
|
||
ct:pal(" ERROR: HTTP 403 Forbidden (nofin)"),
|
||
gun:close(ConnPid),
|
||
{error, {403, <<"Admin access required">>}};
|
||
{gun_response, ConnPid, StreamRef, fin, Status, _} ->
|
||
ct:pal(" ERROR: HTTP ~p", [Status]),
|
||
gun:close(ConnPid),
|
||
{error, {Status, <<"WebSocket upgrade failed">>}};
|
||
{gun_response, ConnPid, StreamRef, nofin, Status, _} ->
|
||
ct:pal(" ERROR: HTTP ~p (nofin)", [Status]),
|
||
gun:close(ConnPid),
|
||
{error, {Status, <<"WebSocket upgrade failed">>}};
|
||
{gun_error, ConnPid, Reason} ->
|
||
ct:pal(" ERROR: ~p", [Reason]),
|
||
gun:close(ConnPid),
|
||
{error, Reason}
|
||
after 5000 ->
|
||
ct:pal(" ERROR: Timeout"),
|
||
gun:close(ConnPid),
|
||
{error, timeout}
|
||
end.
|
||
|
||
test_ws_send(ConnPid, Data) ->
|
||
Msg = jsx:encode(Data),
|
||
ct:pal(" Sending: ~s", [Msg]),
|
||
case catch gun:ws_send(ConnPid, {text, Msg}) of
|
||
ok -> ok;
|
||
{'EXIT', {undef, _}} ->
|
||
gun:ws_send(ConnPid, fin, {text, Msg});
|
||
Other ->
|
||
ct:pal(" ERROR sending: ~p", [Other]),
|
||
error({ws_send_failed, Other})
|
||
end.
|
||
|
||
test_ws_recv(ConnPid) ->
|
||
test_ws_recv(ConnPid, 3000).
|
||
|
||
test_ws_recv(ConnPid, Timeout) ->
|
||
receive
|
||
{gun_ws, ConnPid, _StreamRef, {text, Msg}} ->
|
||
ct:pal(" Received (with StreamRef): ~s", [Msg]),
|
||
{ok, jsx:decode(Msg, [return_maps])};
|
||
{gun_ws, ConnPid, {text, Msg}} ->
|
||
ct:pal(" Received: ~s", [Msg]),
|
||
{ok, jsx:decode(Msg, [return_maps])};
|
||
{gun_ws, ConnPid, _StreamRef, Frame} ->
|
||
ct:pal(" Received frame: ~p", [Frame]),
|
||
{ok, Frame};
|
||
{gun_ws, ConnPid, Frame} ->
|
||
ct:pal(" Received: ~p", [Frame]),
|
||
{ok, Frame};
|
||
{gun_error, ConnPid, Reason} ->
|
||
ct:pal(" ERROR: gun_error ~p", [Reason]),
|
||
{error, Reason};
|
||
Other ->
|
||
ct:pal(" Received unexpected: ~p", [Other]),
|
||
test_ws_recv(ConnPid, Timeout)
|
||
after Timeout ->
|
||
{error, timeout}
|
||
end.
|
||
|
||
test_ws_close(ConnPid) ->
|
||
gun:close(ConnPid).
|
||
|
||
%% ========== URL parsing helpers ==========
|
||
normalize_scheme(S) when is_binary(S) -> S;
|
||
normalize_scheme(S) when is_list(S) -> list_to_binary(S);
|
||
normalize_scheme(S) when is_atom(S) -> atom_to_binary(S, utf8);
|
||
normalize_scheme(_) -> <<"unknown">>.
|
||
|
||
extract_host(Url) ->
|
||
try
|
||
Parsed = uri_string:parse(Url),
|
||
#{scheme := SchemeRaw, host := Host} = Parsed,
|
||
Scheme = normalize_scheme(SchemeRaw),
|
||
if Scheme =:= <<"ws">>; Scheme =:= <<"wss">> -> ok;
|
||
true -> throw({invalid_scheme, SchemeRaw})
|
||
end,
|
||
HostStr = if is_binary(Host) -> binary_to_list(Host); true -> Host end,
|
||
{ok, HostStr}
|
||
catch
|
||
Class:Reason:Stacktrace ->
|
||
{error, {parse_error, {Class, Reason}, Stacktrace}}
|
||
end.
|
||
|
||
extract_port(Url) ->
|
||
try
|
||
Parsed = uri_string:parse(Url),
|
||
#{scheme := SchemeRaw} = Parsed,
|
||
Scheme = normalize_scheme(SchemeRaw),
|
||
DefaultPort = if Scheme =:= <<"ws">> -> 80; Scheme =:= <<"wss">> -> 443; true -> throw({invalid_scheme, SchemeRaw}) end,
|
||
case maps:find(port, Parsed) of
|
||
{ok, P} when is_integer(P) -> {ok, P};
|
||
{ok, P} -> {ok, try list_to_integer(binary_to_list(normalize_scheme(P))) catch _:_ -> DefaultPort end};
|
||
error -> {ok, DefaultPort}
|
||
end
|
||
catch
|
||
Class:Reason:Stacktrace ->
|
||
{error, {parse_error, {Class, Reason}, Stacktrace}}
|
||
end. |