-module(admin_websocket_tests). -export([test/0]). test() -> ct:pal("Testing WebSocket API..."), application:ensure_all_started(gun), AdminToken = api_test_runner:get_admin_token(), UserToken = api_test_runner:get_user_token(), ct:pal(" AdminToken: ~s...", [binary_part(AdminToken, 0, 30)]), ct:pal(" UserToken: ~s...", [binary_part(UserToken, 0, 30)]), % Создаём календарь и событие через новый api_test_runner #{<<"id">> := CalId} = api_test_runner:client_post( <<"/v1/calendars">>, UserToken, #{title => <<"WS Test Calendar">>, type => <<"commercial">>}), ct:pal(" CalId: ~s", [CalId]), #{<<"id">> := EventId} = api_test_runner:client_post( <<"/v1/calendars/", CalId/binary, "/events">>, UserToken, #{title => <<"WS Test Event">>, start_time => <<"2026-06-01T10:00:00Z">>, duration => 60}), ct:pal(" EventId: ~s", [EventId]), WsUrl = api_test_runner:get_base_ws_url() ++ "/ws", AdminWsUrl = api_test_runner:get_admin_ws_url() ++ "/admin/ws", %% TEST 1: Connect to WebSocket with valid token ct:pal(" TEST 1: Connect WebSocket with valid token..."), ct:pal(" URL: ~s", [WsUrl]), ct:pal(" Token: ~s...", [binary_part(UserToken, 0, 30)]), case test_ws_connect_debug(WsUrl, UserToken) of {ok, WS} -> ct:pal(" OK - Connected"), %% TEST 2: Subscribe to calendar updates ct:pal(" TEST 2: Subscribe to calendar..."), SubMsg = #{action => <<"subscribe">>, calendar_id => CalId}, ct:pal(" Sending: ~p", [SubMsg]), ok = test_ws_send(WS, SubMsg), case test_ws_recv(WS) of {ok, #{<<"status">> := <<"subscribed">>}} -> ct:pal(" OK - Subscribed"); {ok, Other} -> ct:pal(" ERROR: Unexpected response: ~p", [Other]), error({unexpected_response, Other}); {error, timeout} -> ct:pal(" ERROR: Timeout waiting for response"), error(timeout) end, test_ws_close(WS); {error, Reason} -> ct:pal(" ERROR: ~p", [Reason]), error({websocket_connect_failed, Reason}) end, ct:pal("~n✅ WebSocket API tests passed!"), %% ============ ТЕСТЫ АДМИНСКОГО WEBSOCKET ============ ct:pal("~n=== ADMIN WEBSOCKET TESTS ==="), %% TEST 6: Admin WebSocket connection ct:pal(" TEST 6: Admin WebSocket connect..."), {ok, AdminWS} = test_ws_connect_debug(AdminWsUrl, AdminToken), ct:pal(" OK - Admin connected"), %% TEST 7: Admin subscribe to reports channel ct:pal(" TEST 7: Admin subscribe to reports channel..."), ok = test_ws_send(AdminWS, #{action => <<"subscribe">>, channel => <<"reports">>}), {ok, #{<<"status">> := <<"subscribed">>}} = test_ws_recv(AdminWS), ct:pal(" OK - Subscribed to reports"), %% TEST 8: Admin subscribe to tickets channel ct:pal(" TEST 8: Admin subscribe to tickets channel..."), ok = test_ws_send(AdminWS, #{action => <<"subscribe">>, channel => <<"tickets">>}), {ok, #{<<"status">> := <<"subscribed">>}} = test_ws_recv(AdminWS), ct:pal(" OK - Subscribed to tickets"), %% TEST 9: Admin receives report notification ct:pal(" TEST 9: Admin receives report notification..."), api_test_runner:client_post(<<"/v1/reports">>, UserToken, #{target_type => <<"event">>, target_id => EventId, reason => <<"Test report">>}), {ok, #{<<"type">> := <<"report_created">>}} = test_ws_recv(AdminWS, 5000), ct:pal(" OK - Received report notification"), %% TEST 10: Admin Ping/Pong ct:pal(" TEST 10: Admin Ping/Pong..."), ok = test_ws_send(AdminWS, #{action => <<"ping">>}), {ok, #{<<"status">> := <<"pong">>}} = test_ws_recv(AdminWS), ct:pal(" OK - Admin Ping/Pong"), %% TEST 11: Admin unsubscribe ct:pal(" TEST 11: Admin unsubscribe from reports..."), ok = test_ws_send(AdminWS, #{action => <<"unsubscribe">>, channel => <<"reports">>}), {ok, #{<<"status">> := <<"unsubscribed">>}} = test_ws_recv(AdminWS), ct:pal(" OK - Unsubscribed"), test_ws_close(AdminWS), %% TEST 12: Admin WebSocket with user token (should fail) ct:pal(" TEST 12: Admin WS with user token..."), {error, {403, _}} = test_ws_connect_debug(AdminWsUrl, UserToken), ct:pal(" OK - Rejected"), %% TEST 13: Admin WebSocket with invalid token ct:pal(" TEST 13: Admin WS with invalid token..."), Chars = <<"abcdefghijklmnopqrstuvwxyz0123456789">>, InvalidToken = << <<(binary:at(Chars, rand:uniform(byte_size(Chars)) - 1))>> || _ <- lists:seq(1, 30) >>, {error, {401, _}} = test_ws_connect_debug(AdminWsUrl, InvalidToken), ct:pal(" OK - Rejected"), ct:pal("~n✅ Admin WebSocket API tests passed!"), {?MODULE, ok}. %% ============ 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 ========== extract_port(Url) -> case string:split(Url, "://", trailing) of [_, Rest] -> HostPort = case string:split(Rest, "/", leading) of [H, _] -> H; [H] -> H end, case string:split(HostPort, ":", trailing) of [_, PortStr] -> {ok, list_to_integer(PortStr)}; _ -> case string:split(Rest, "://", trailing) of [_, R] -> extract_port("https://" ++ R); _ -> {ok, default_port(Url)} end end; _ -> {ok, default_port(Url)} end. default_port(Url) -> case string:prefix(Url, "wss://") of nomatch -> case string:prefix(Url, "ws://") of nomatch -> 80; _ -> 80 end; _ -> 443 end. extract_host(Url) -> case string:split(Url, "://", trailing) of [_, Rest] -> HostPort = case string:split(Rest, "/", leading) of [H, _] -> H; [H] -> H end, case string:split(HostPort, ":", trailing) of [Host, _] -> {ok, Host}; [Host] -> {ok, Host} end; _ -> {ok, "localhost"} end.