%%%------------------------------------------------------------------- %%% @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 => api_test_runner:future_date_iso8601(), 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.