-module(api_websocket_tests). -export([test/0]). -define(BASE_URL, "http://localhost:8080"). -define(WS_URL, "ws://localhost:8081/ws"). -define(ADMIN_WS_URL, "ws://localhost:8446/admin/ws"). test() -> ct:pal("Testing WebSocket API..."), % Запускаем gun 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)]), % Создаём календарь и событие для тестов CalId = api_test_runner:extract_json( api_test_runner:http_post("/v1/calendars", #{title => <<"WS Test Calendar">>, type => <<"commercial">>}, UserToken), <<"id">>, 201), ct:pal(" CalId: ~s", [CalId]), EventId = api_test_runner:extract_json( api_test_runner:http_post("/v1/calendars/" ++ binary_to_list(CalId) ++ "/events", #{title => <<"WS Test Event">>, start_time => <<"2026-06-01T10:00:00Z">>, duration => 60}, UserToken), <<"id">>, 201), ct:pal(" EventId: ~s", [EventId]), % TEST 1: Connect to WebSocket with valid token ct:pal(" TEST 1: Connect WebSocket with valid token..."), ct:pal(" URL: ~s", [?WS_URL]), ct:pal(" Token: ~s...", [binary_part(UserToken, 0, 30)]), case test_ws_connect_debug(?WS_URL, 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(?ADMIN_WS_URL, 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..."), % Создаём жалобу через HTTP api_test_runner:http_post("/v1/reports", #{target_type => <<"event">>, target_id => EventId, reason => <<"Test report">>}, UserToken), {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(?ADMIN_WS_URL, 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(?ADMIN_WS_URL, 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, Port = ws_port(Url), Host = "localhost", ct:pal(" Host: ~s", [Host]), ct:pal(" Port: ~p", [Port]), ct:pal(" Path: ~s", [Path]), {ok, ConnPid} = gun:open(Host, Port, #{protocols => [http]}), {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). ws_port("ws://localhost:8081" ++ _) -> 8081; ws_port("ws://localhost:8446" ++ _) -> 8446.