Рефакторинг обработчиков. Часть 3 #21

This commit is contained in:
2026-05-13 23:02:59 +03:00
parent 61bb44ab4a
commit 40806df62a
91 changed files with 6138 additions and 7150 deletions

View File

@@ -0,0 +1,115 @@
%%%-------------------------------------------------------------------
%%% @doc Тесты клиентского API для бронирований.
%%%
%%% Покрывает эндпоинты:
%%% POST /v1/events/:id/bookings
%%% PUT /v1/bookings/:id
%%% DELETE /v1/bookings/:id
%%%
%%% Проверяет:
%%% - создание бронирования участником
%%% - подтверждение бронирования владельцем календаря
%%% - отмену бронирования участником
%%% - ошибку при повторном бронировании
%%% - ошибку 401 без токена
%%% @end
%%%-------------------------------------------------------------------
-module(user_bookings_tests).
-include_lib("eunit/include/eunit.hrl").
-export([test/0]).
%%%===================================================================
%%% Главная тестовая функция
%%%===================================================================
-spec test() -> ok.
test() ->
ct:pal("=== User Bookings Tests ==="),
OwnerToken = api_test_runner:get_user_token(),
ParticipantEmail = api_test_runner:unique_email(<<"participant">>),
ParticipantToken = api_test_runner:register_and_login(ParticipantEmail, <<"pass">>),
% Создаём календарь и событие
CalId = api_test_runner:create_calendar(OwnerToken, #{title => <<"BookingTest">>}),
#{<<"id">> := EventId} = api_test_runner:client_post(
<<"/v1/calendars/", CalId/binary, "/events">>, OwnerToken,
#{title => <<"Event to book">>,
start_time => <<"2026-06-01T10:00:00Z">>,
duration => 60}),
test_create_booking(ParticipantToken, EventId),
test_confirm_booking(OwnerToken, EventId),
test_cancel_booking(ParticipantToken, EventId),
test_duplicate_booking(ParticipantToken, EventId),
test_booking_unauthorized(EventId),
ct:pal("=== All user bookings tests passed ==="),
ok.
%%%===================================================================
%%% Тестовые функции
%%%===================================================================
%% @doc Успешное создание бронирования: 201 Created.
-spec test_create_booking(binary(), binary()) -> ok.
test_create_booking(Token, EventId) ->
ct:pal(" TEST: Create booking"),
Path = <<"/v1/events/", EventId/binary, "/bookings">>,
Resp = api_test_runner:client_request(post, Path, Token, <<"{}">>),
{ok, 201, _, Body} = Resp,
#{<<"id">> := BookingId, <<"status">> := Status} = jsx:decode(list_to_binary(Body), [return_maps]),
?assert(is_binary(BookingId)),
?assertEqual(<<"pending">>, Status),
ct:pal(" OK: booking ~s created", [BookingId]).
%% @doc Подтверждение бронирования владельцем: 200 OK.
-spec test_confirm_booking(binary(), binary()) -> ok.
test_confirm_booking(OwnerToken, EventId) ->
ct:pal(" TEST: Confirm booking as owner"),
% Создаём новое бронирование, которое ещё не подтверждено
Participant2 = api_test_runner:register_and_login(
api_test_runner:unique_email(<<"part2">>), <<"pass">>),
Path = <<"/v1/events/", EventId/binary, "/bookings">>,
#{<<"id">> := BookingId} = api_test_runner:client_post(Path, Participant2, #{}),
% Подтверждаем
ConfirmPath = <<"/v1/bookings/", BookingId/binary>>,
Updated = api_test_runner:client_put(ConfirmPath, OwnerToken,
#{action => <<"confirm">>}),
?assertEqual(<<"confirmed">>, maps:get(<<"status">>, Updated)),
ct:pal(" OK: booking ~s confirmed", [BookingId]).
%% @doc Отмена бронирования участником: 200 OK.
-spec test_cancel_booking(binary(), binary()) -> ok.
test_cancel_booking(OwnerToken, EventId) ->
ct:pal(" TEST: Cancel booking as participant"),
% Создаём нового участника, у которого нет бронирований
CancelUserEmail = api_test_runner:unique_email(<<"canceluser">>),
CancelUserToken = api_test_runner:register_and_login(CancelUserEmail, <<"pass">>),
Path = <<"/v1/events/", EventId/binary, "/bookings">>,
#{<<"id">> := BookingId} = api_test_runner:client_post(Path, CancelUserToken, #{}),
CancelPath = <<"/v1/bookings/", BookingId/binary>>,
Resp = api_test_runner:client_request(delete, CancelPath, CancelUserToken),
?assertMatch({ok, 200, _, _}, Resp),
ct:pal(" OK: booking ~s cancelled", [BookingId]).
%% @doc Повторное бронирование того же события: 409 Conflict.
-spec test_duplicate_booking(binary(), binary()) -> ok.
test_duplicate_booking(ParticipantToken, EventId) ->
ct:pal(" TEST: Duplicate booking"),
Path = <<"/v1/events/", EventId/binary, "/bookings">>,
% Первый раз должно быть 201 (или 200, если уже есть pending)
_ = api_test_runner:client_request(post, Path, ParticipantToken, <<"{}">>),
% Второй раз уже booked
Resp2 = api_test_runner:client_request(post, Path, ParticipantToken, <<"{}">>),
{ok, 409, _, _} = Resp2,
ct:pal(" OK: got 409 conflict").
%% @doc Запрос без токена: 401 Unauthorized.
-spec test_booking_unauthorized(binary()) -> ok.
test_booking_unauthorized(EventId) ->
ct:pal(" TEST: Booking without token"),
Path = <<"/v1/events/", EventId/binary, "/bookings">>,
Resp = api_test_runner:client_request(post, Path, <<>>, <<"{}">>),
?assertMatch({ok, 401, _, _}, Resp),
ct:pal(" OK: got 401").

View File

@@ -0,0 +1,87 @@
%%%-------------------------------------------------------------------
%%% @doc Тесты клиентского API для работы с конкретным календарём.
%%% Покрывает GET, PUT, DELETE /v1/calendars/:id.
%%% @end
%%%-------------------------------------------------------------------
-module(user_calendar_by_id_tests).
-include_lib("eunit/include/eunit.hrl").
-export([test/0]).
-spec test() -> ok.
test() ->
ct:pal("=== User Calendar By ID Tests ==="),
Token = api_test_runner:get_user_token(),
OtherToken = api_test_runner:register_and_login(
api_test_runner:unique_email(<<"other">>), <<"pass">>),
% Создаём календарь для тестов
#{<<"id">> := CalId} = api_test_runner:client_post(<<"/v1/calendars">>, Token,
#{title => <<"TestCal">>, type => <<"personal">>}),
test_get_calendar(Token, CalId),
test_get_calendar_unauthorized(CalId),
test_get_calendar_not_found(Token),
test_update_calendar(Token, CalId),
test_update_calendar_forbidden(OtherToken, CalId),
test_delete_calendar(Token, CalId),
test_delete_calendar_forbidden(OtherToken, CalId),
ct:pal("=== All user calendar by id tests passed ==="),
ok.
test_get_calendar(Token, CalId) ->
ct:pal(" TEST: Get calendar by ID"),
Path = <<"/v1/calendars/", CalId/binary>>,
Cal = api_test_runner:client_get(Path, Token),
?assertEqual(CalId, maps:get(<<"id">>, Cal)),
?assert(maps:is_key(<<"title">>, Cal)),
ct:pal(" OK: ~s", [maps:get(<<"title">>, Cal)]).
test_get_calendar_unauthorized(CalId) ->
ct:pal(" TEST: Get calendar without token (401)"),
Path = <<"/v1/calendars/", CalId/binary>>,
Resp = api_test_runner:client_request(get, Path, <<>>),
?assertMatch({ok, 401, _, _}, Resp),
ct:pal(" OK: got 401").
test_get_calendar_not_found(Token) ->
ct:pal(" TEST: Get non-existent calendar (404)"),
Resp = api_test_runner:client_request(get, <<"/v1/calendars/fakeid">>, Token),
?assertMatch({ok, 404, _, _}, Resp),
ct:pal(" OK: got 404").
test_update_calendar(Token, CalId) ->
ct:pal(" TEST: Update calendar"),
Path = <<"/v1/calendars/", CalId/binary>>,
Updated = api_test_runner:client_put(Path, Token,
#{title => <<"Updated">>, description => <<"New desc">>}),
?assertEqual(<<"Updated">>, maps:get(<<"title">>, Updated)),
?assertEqual(<<"New desc">>, maps:get(<<"description">>, Updated)),
ct:pal(" OK").
test_update_calendar_forbidden(OtherToken, CalId) ->
ct:pal(" TEST: Update calendar by non-owner (403)"),
Path = <<"/v1/calendars/", CalId/binary>>,
Resp = api_test_runner:client_request(put, Path, OtherToken,
jsx:encode(#{title => <<"fail">>})),
?assertMatch({ok, 403, _, _}, Resp),
ct:pal(" OK: got 403").
test_delete_calendar(Token, CalId) ->
ct:pal(" TEST: Delete calendar (soft-delete)"),
Path = <<"/v1/calendars/", CalId/binary>>,
Resp = api_test_runner:client_request(delete, Path, Token),
?assertMatch({ok, 200, _, _}, Resp),
ct:pal(" OK: deleted").
test_delete_calendar_forbidden(OtherToken, CalId) ->
% Первый раз мы уже удалили, но проверим на другом календаре
ct:pal(" TEST: Delete calendar by non-owner (403)"),
% Создадим новый календарь владельцем Token, попробуем удалить OtherToken
#{<<"id">> := NewCalId} = api_test_runner:client_post(<<"/v1/calendars">>, api_test_runner:get_user_token(),
#{title => <<"ForbiddenDel">>, type => <<"personal">>}),
Path = <<"/v1/calendars/", NewCalId/binary>>,
Resp = api_test_runner:client_request(delete, Path, OtherToken),
?assertMatch({ok, 403, _, _}, Resp),
ct:pal(" OK: got 403").

View File

@@ -0,0 +1,61 @@
%%%-------------------------------------------------------------------
%%% @doc Тесты клиентского API для HTML-представления календаря.
%%%
%%% Покрывает эндпоинты:
%%% GET /v1/calendars/:calendar_id/view
%%%
%%% Проверяет:
%%% - успешное получение HTML-страницы (200, text/html)
%%% - ошибку 401 без токена
%%% @end
%%%-------------------------------------------------------------------
-module(user_calendar_view_tests).
-include_lib("eunit/include/eunit.hrl").
-export([test/0]).
%%%===================================================================
%%% Главная тестовая функция
%%%===================================================================
-spec test() -> ok.
test() ->
ct:pal("=== User Calendar View Tests ==="),
Token = api_test_runner:get_user_token(),
% Создаём календарь
CalId = api_test_runner:create_calendar(Token, #{title => <<"ViewCal">>}),
test_get_calendar_view(Token, CalId),
test_get_calendar_view_unauthorized(CalId),
ct:pal("=== All user calendar view tests passed ==="),
ok.
%%%===================================================================
%%% Тестовые функции
%%%===================================================================
%% @doc Успешный запрос HTML-представления: 200 OK, тип text/html.
-spec test_get_calendar_view(binary(), binary()) -> ok.
test_get_calendar_view(Token, CalId) ->
ct:pal(" TEST: Get calendar HTML view"),
Path = <<"/v1/calendars/", CalId/binary, "/view?month=2026-06">>,
Resp = api_test_runner:client_request(get, Path, Token),
{ok, 200, Headers, Body} = Resp,
?assert(lists:keymember("content-type", 1, Headers)),
{"content-type", CT} = lists:keyfind("content-type", 1, Headers),
?assert(string:str(CT, "text/html") > 0),
% Body может быть строкой или binary, приводим к binary и проверяем непустоту
BodyBin = iolist_to_binary(Body),
?assert(byte_size(BodyBin) > 0),
ct:pal(" OK: got HTML of ~p bytes", [byte_size(BodyBin)]).
%% @doc Запрос без токена: 401 Unauthorized.
-spec test_get_calendar_view_unauthorized(binary()) -> ok.
test_get_calendar_view_unauthorized(CalId) ->
ct:pal(" TEST: Get calendar view without token"),
Path = <<"/v1/calendars/", CalId/binary, "/view?month=2026-06">>,
Resp = api_test_runner:client_request(get, Path, <<>>),
?assertMatch({ok, 401, _, _}, Resp),
ct:pal(" OK: got 401").

View File

@@ -0,0 +1,51 @@
%%%-------------------------------------------------------------------
%%% @doc Тесты клиентского API для управления календарями.
%%% Покрывает POST /v1/calendars (создание) и GET /v1/calendars (список).
%%% @end
%%%-------------------------------------------------------------------
-module(user_calendars_tests).
-include_lib("eunit/include/eunit.hrl").
-export([test/0]).
-spec test() -> ok.
test() ->
ct:pal("=== User Calendars Tests ==="),
Token = api_test_runner:get_user_token(),
% Создаём один календарь для тестов
#{<<"id">> := CalId} = api_test_runner:client_post(<<"/v1/calendars">>, Token,
#{title => <<"TestCal">>, type => <<"personal">>}),
test_create_calendar(Token),
test_list_calendars(Token),
test_list_calendars_unauthorized(),
ct:pal("=== All user calendars tests passed ==="),
ok.
test_create_calendar(Token) ->
ct:pal(" TEST: Create a new calendar"),
Resp = api_test_runner:client_request(post, <<"/v1/calendars">>, Token,
jsx:encode(#{title => <<"NewCal">>, type => <<"personal">>})),
{ok, 201, _, Body} = Resp,
#{<<"id">> := Id, <<"title">> := Title} = jsx:decode(list_to_binary(Body), [return_maps]),
?assert(is_binary(Id)),
?assertEqual(<<"NewCal">>, Title),
ct:pal(" OK: created calendar ~s", [Id]).
test_list_calendars(Token) ->
ct:pal(" TEST: List user calendars"),
Calendars = api_test_runner:client_get(<<"/v1/calendars">>, Token),
?assert(is_list(Calendars)),
?assert(length(Calendars) >= 1),
First = hd(Calendars),
?assert(maps:is_key(<<"id">>, First)),
?assert(maps:is_key(<<"title">>, First)),
ct:pal(" OK: ~p calendars found", [length(Calendars)]).
test_list_calendars_unauthorized() ->
ct:pal(" TEST: List calendars without token (401)"),
Resp = api_test_runner:client_request(get, <<"/v1/calendars">>, <<>>),
?assertMatch({ok, 401, _, _}, Resp),
ct:pal(" OK: got 401").

View File

@@ -0,0 +1,114 @@
%%%-------------------------------------------------------------------
%%% @doc Тесты клиентского API для работы с конкретным событием.
%%%
%%% Покрывает эндпоинты:
%%% GET /v1/events/:id
%%% PUT /v1/events/:id
%%% DELETE /v1/events/:id
%%%
%%% Проверяет:
%%% - получение события по ID
%%% - обновление события (владельцем)
%%% - мягкое удаление события (статус становится deleted)
%%% - ошибку 403 при попытке изменения чужого события
%%% - ошибку 404 для несуществующего события
%%% - ошибку 401 без токена
%%% @end
%%%-------------------------------------------------------------------
-module(user_event_by_id_tests).
-include_lib("eunit/include/eunit.hrl").
-export([test/0]).
%%%===================================================================
%%% Главная тестовая функция
%%%===================================================================
-spec test() -> ok.
test() ->
ct:pal("=== User Event By ID Tests ==="),
Token = api_test_runner:get_user_token(),
OtherToken = api_test_runner:register_and_login(
api_test_runner:unique_email(<<"other">>), <<"pass">>),
% Создаём календарь и событие
CalId = api_test_runner:create_calendar(Token, #{title => <<"EvtById">>}),
#{<<"id">> := EventId} = api_test_runner:client_post(
<<"/v1/calendars/", CalId/binary, "/events">>, Token,
#{title => <<"Test Event">>,
start_time => <<"2026-06-01T10:00:00Z">>,
duration => 60}),
test_get_event(Token, EventId),
test_update_event(Token, EventId),
test_update_event_forbidden(OtherToken, EventId),
test_delete_event(Token, EventId),
test_get_event_not_found(Token),
test_get_event_unauthorized(EventId),
ct:pal("=== All user event by id tests passed ==="),
ok.
%%%===================================================================
%%% Тестовые функции
%%%===================================================================
%% @doc GET /v1/events/:id получение события.
-spec test_get_event(binary(), binary()) -> ok.
test_get_event(Token, EventId) ->
ct:pal(" TEST: Get event by ID"),
Path = <<"/v1/events/", EventId/binary>>,
Event = api_test_runner:client_get(Path, Token),
?assertEqual(EventId, maps:get(<<"id">>, Event)),
?assertEqual(<<"Test Event">>, maps:get(<<"title">>, Event)),
ct:pal(" OK: got event ~s", [EventId]).
%% @doc PUT /v1/events/:id обновление события.
-spec test_update_event(binary(), binary()) -> ok.
test_update_event(Token, EventId) ->
ct:pal(" TEST: Update event"),
Path = <<"/v1/events/", EventId/binary>>,
Updated = api_test_runner:client_put(Path, Token,
#{title => <<"Updated Event">>, description => <<"New desc">>}),
?assertEqual(<<"Updated Event">>, maps:get(<<"title">>, Updated)),
?assertEqual(<<"New desc">>, maps:get(<<"description">>, Updated)),
ct:pal(" OK").
%% @doc PUT /v1/events/:id попытка обновления чужим пользователем (403).
-spec test_update_event_forbidden(binary(), binary()) -> ok.
test_update_event_forbidden(OtherToken, EventId) ->
ct:pal(" TEST: Update event by non-owner"),
Path = <<"/v1/events/", EventId/binary>>,
Resp = api_test_runner:client_request(put, Path, OtherToken,
jsx:encode(#{title => <<"fail">>})),
?assertMatch({ok, 403, _, _}, Resp),
ct:pal(" OK: got 403").
%% @doc DELETE /v1/events/:id мягкое удаление события.
-spec test_delete_event(binary(), binary()) -> ok.
test_delete_event(Token, EventId) ->
ct:pal(" TEST: Soft-delete event"),
Path = <<"/v1/events/", EventId/binary>>,
% Удаляем событие
{ok, 200, _, _} = api_test_runner:client_request(delete, Path, Token),
% Проверяем, что событие доступно, но его статус = deleted
Event = api_test_runner:client_get(Path, Token),
?assertEqual(<<"deleted">>, maps:get(<<"status">>, Event)),
ct:pal(" OK: event soft-deleted").
%% @doc GET /v1/events/:id несуществующее событие (404).
-spec test_get_event_not_found(binary()) -> ok.
test_get_event_not_found(Token) ->
ct:pal(" TEST: Get non-existent event"),
Resp = api_test_runner:client_request(get, <<"/v1/events/fakeid">>, Token),
?assertMatch({ok, 404, _, _}, Resp),
ct:pal(" OK: got 404").
%% @doc GET /v1/events/:id без токена (401).
-spec test_get_event_unauthorized(binary()) -> ok.
test_get_event_unauthorized(EventId) ->
ct:pal(" TEST: Get event without token"),
Path = <<"/v1/events/", EventId/binary>>,
Resp = api_test_runner:client_request(get, Path, <<>>),
?assertMatch({ok, 401, _, _}, Resp),
ct:pal(" OK: got 401").

View File

@@ -0,0 +1,80 @@
%%%-------------------------------------------------------------------
%%% @doc Тесты клиентского API для событий.
%%% Покрывает POST /v1/calendars/:id/events и GET /v1/events/:id/occurrences
%%% @end
%%%-------------------------------------------------------------------
-module(user_events_tests).
-include_lib("eunit/include/eunit.hrl").
-export([test/0]).
-spec test() -> ok.
test() ->
ct:pal("=== User Events Tests ==="),
Token = api_test_runner:get_user_token(),
% Создаём календарь
CalId = api_test_runner:create_calendar(Token, #{title => <<"EventsTest">>}),
% Тесты
test_create_single_event(Token, CalId),
test_list_events_with_dates(Token, CalId),
test_create_recurring_event(Token, CalId),
ct:pal("=== All user events tests passed ==="),
ok.
%% @doc POST /v1/calendars/:calendar_id/events одиночное событие.
test_create_single_event(Token, CalId) ->
ct:pal(" TEST: Create single event"),
Path = <<"/v1/calendars/", CalId/binary, "/events">>,
Body = jsx:encode(#{
title => <<"Single Event">>,
start_time => <<"2026-06-01T10:00:00Z">>,
duration => 60
}),
Resp = api_test_runner:client_request(post, Path, Token, Body),
{ok, 201, _, RespBody} = Resp,
#{<<"id">> := EventId, <<"title">> := Title} = jsx:decode(list_to_binary(RespBody), [return_maps]),
?assert(is_binary(EventId)),
?assertEqual(<<"Single Event">>, Title),
ct:pal(" OK: created event ~s", [EventId]).
%% @doc GET /v1/calendars/:calendar_id/events?from=...&to=... список с фильтром.
test_list_events_with_dates(Token, CalId) ->
ct:pal(" TEST: List events with date range"),
Path = <<"/v1/calendars/", CalId/binary, "/events?from=2026-05-01T00:00:00Z&to=2026-07-01T00:00:00Z">>,
Events = api_test_runner:client_get(Path, Token),
?assert(is_list(Events)),
?assert(length(Events) >= 1),
First = hd(Events),
?assert(maps:is_key(<<"id">>, First)),
?assert(maps:is_key(<<"title">>, First)),
ct:pal(" OK: ~p events found", [length(Events)]).
%% @doc POST /v1/calendars/:calendar_id/events повторяющееся событие и проверка вхождений.
test_create_recurring_event(Token, CalId) ->
ct:pal(" TEST: Create recurring event and check occurrences"),
Path = <<"/v1/calendars/", CalId/binary, "/events">>,
Body = jsx:encode(#{
title => <<"Weekly Meeting">>,
start_time => <<"2026-06-01T10:00:00Z">>,
duration => 60,
recurrence => #{
freq => <<"WEEKLY">>,
interval => 1
}
}),
Resp = api_test_runner:client_request(post, Path, Token, Body),
{ok, 201, _, RespBody} = Resp,
#{<<"id">> := RecurringId} = jsx:decode(list_to_binary(RespBody), [return_maps]),
ct:pal(" Created recurring event ~s", [RecurringId]),
% Запрашиваем вхождения на месяц
OccPath = <<"/v1/events/", RecurringId/binary, "/occurrences?from=2026-06-01T00:00:00Z&to=2026-06-30T00:00:00Z">>,
Occs = api_test_runner:client_get(OccPath, Token),
?assert(is_list(Occs)),
?assert(length(Occs) >= 4), % минимум 4 недели в июне
FirstOcc = hd(Occs),
?assert(maps:is_key(<<"start_time">>, FirstOcc)),
ct:pal(" OK: ~p occurrences found", [length(Occs)]).

View File

@@ -0,0 +1,90 @@
%%%-------------------------------------------------------------------
%%% @doc Тесты клиентского API для входа пользователей.
%%%
%%% Покрывает эндпоинты:
%%% POST /v1/login
%%%
%%% Проверяет:
%%% - успешный вход с правильными email и паролем
%%% - ошибку при неверном пароле
%%% - ошибку при несуществующем email
%%% - ошибку при отсутствии обязательных полей
%%% @end
%%%-------------------------------------------------------------------
-module(user_login_tests).
-include_lib("eunit/include/eunit.hrl").
-export([test/0]).
%%%===================================================================
%%% Главная тестовая функция
%%%===================================================================
-spec test() -> ok.
test() ->
ct:pal("=== Client Login Tests ==="),
Email = api_test_runner:unique_email(<<"login">>),
Password = <<"StrongPass1!">>,
% Создаём пользователя для тестов входа
api_test_runner:register_and_login(Email, Password),
test_successful_login(Email, Password),
test_wrong_password(Email),
test_nonexistent_email(),
test_missing_fields(),
ct:pal("=== All client login tests passed ==="),
ok.
%%%===================================================================
%%% Тестовые функции
%%%===================================================================
%% @doc Успешный вход: 200 OK, возвращает токен и данные пользователя.
-spec test_successful_login(binary(), binary()) -> ok.
test_successful_login(Email, Password) ->
ct:pal(" TEST: Successful login"),
Resp = api_test_runner:client_request(post, <<"/v1/login">>, <<>>,
jsx:encode(#{email => Email, password => Password})),
{ok, 200, _, Body} = Resp,
#{<<"token">> := Token, <<"user">> := User} = jsx:decode(list_to_binary(Body), [return_maps]),
?assert(is_binary(Token)),
?assertEqual(Email, maps:get(<<"email">>, User)),
ct:pal(" OK: user ~s logged in", [maps:get(<<"id">>, User)]).
%% @doc Неверный пароль: 401 Unauthorized.
-spec test_wrong_password(binary()) -> ok.
test_wrong_password(Email) ->
ct:pal(" TEST: Wrong password"),
Resp = api_test_runner:client_request(post, <<"/v1/login">>, <<>>,
jsx:encode(#{email => Email, password => <<"WrongPass1">>})),
{ok, 401, _, Body} = Resp,
#{<<"error">> := Msg} = jsx:decode(list_to_binary(Body), [return_maps]),
?assertEqual(<<"Invalid credentials">>, Msg),
ct:pal(" OK: got 401 unauthorized").
%% @doc Несуществующий email: 401 Unauthorized.
-spec test_nonexistent_email() -> ok.
test_nonexistent_email() ->
ct:pal(" TEST: Nonexistent email"),
Resp = api_test_runner:client_request(post, <<"/v1/login">>, <<>>,
jsx:encode(#{email => <<"no@such.user">>, password => <<"Anything1">>})),
{ok, 401, _, Body} = Resp,
#{<<"error">> := Msg} = jsx:decode(list_to_binary(Body), [return_maps]),
?assertEqual(<<"Invalid credentials">>, Msg),
ct:pal(" OK: got 401 unauthorized").
%% @doc Отсутствие обязательных полей: 400 Bad Request.
-spec test_missing_fields() -> ok.
test_missing_fields() ->
ct:pal(" TEST: Missing required fields"),
Resp1 = api_test_runner:client_request(post, <<"/v1/login">>, <<>>,
jsx:encode(#{email => <<"a@b.com">>})),
?assertMatch({ok, 400, _, _}, Resp1),
Resp2 = api_test_runner:client_request(post, <<"/v1/login">>, <<>>,
jsx:encode(#{password => <<"NoEmail1">>})),
?assertMatch({ok, 400, _, _}, Resp2),
ct:pal(" OK: 400 on missing fields").

View File

@@ -0,0 +1,55 @@
%%%-------------------------------------------------------------------
%%% @doc Тесты клиентского API для получения профиля текущего пользователя.
%%%
%%% Покрывает эндпоинты:
%%% GET /v1/user/me
%%%
%%% Проверяет:
%%% - успешное получение профиля с валидным токеном
%%% - ошибку 401 при отсутствии токена
%%% - наличие ключевых полей в ответе
%%% @end
%%%-------------------------------------------------------------------
-module(user_me_tests).
-include_lib("eunit/include/eunit.hrl").
-export([test/0]).
%%%===================================================================
%%% Главная тестовая функция
%%%===================================================================
-spec test() -> ok.
test() ->
ct:pal("=== User Profile (me) Tests ==="),
Token = api_test_runner:get_user_token(),
test_get_me_success(Token),
test_get_me_unauthorized(),
ct:pal("=== All user me tests passed ==="),
ok.
%%%===================================================================
%%% Тестовые функции
%%%===================================================================
%% @doc Успешное получение профиля: 200 OK, возвращает данные пользователя.
-spec test_get_me_success(binary()) -> ok.
test_get_me_success(Token) ->
ct:pal(" TEST: Get current user profile"),
User = api_test_runner:client_get(<<"/v1/user/me">>, Token),
?assert(is_map(User)),
?assert(maps:is_key(<<"id">>, User)),
?assert(maps:is_key(<<"email">>, User)),
?assert(maps:is_key(<<"role">>, User)),
?assert(maps:is_key(<<"status">>, User)),
ct:pal(" OK: got profile for ~s", [maps:get(<<"email">>, User)]).
%% @doc Отсутствие токена: 401 Unauthorized.
-spec test_get_me_unauthorized() -> ok.
test_get_me_unauthorized() ->
ct:pal(" TEST: Get profile without token"),
Resp = api_test_runner:client_request(get, <<"/v1/user/me">>, <<>>),
?assertMatch({ok, 401, _, _}, Resp),
ct:pal(" OK: got 401 unauthorized").

View File

@@ -0,0 +1,69 @@
%%%-------------------------------------------------------------------
%%% @doc Тесты клиентского API для получения своих бронирований.
%%%
%%% Покрывает эндпоинты:
%%% GET /v1/user/bookings
%%%
%%% Проверяет:
%%% - получение списка бронирований текущего пользователя
%%% - что бронирование, созданное пользователем, присутствует в ответе
%%% - ошибку 401 при отсутствии токена
%%% @end
%%%-------------------------------------------------------------------
-module(user_my_bookings_tests).
-include_lib("eunit/include/eunit.hrl").
-export([test/0]).
%%%===================================================================
%%% Главная тестовая функция
%%%===================================================================
-spec test() -> ok.
test() ->
ct:pal("=== User My Bookings Tests ==="),
OwnerToken = api_test_runner:get_user_token(),
ParticipantEmail = api_test_runner:unique_email(<<"mybooker">>),
ParticipantToken = api_test_runner:register_and_login(ParticipantEmail, <<"pass">>),
% Создаём календарь и событие
CalId = api_test_runner:create_calendar(OwnerToken, #{title => <<"MyBookTest">>}),
#{<<"id">> := EventId} = api_test_runner:client_post(
<<"/v1/calendars/", CalId/binary, "/events">>, OwnerToken,
#{title => <<"Event for my booking">>,
start_time => <<"2026-06-01T10:00:00Z">>,
duration => 60}),
% Бронируем событие от имени участника и подтверждаем
#{<<"id">> := BookingId} = api_test_runner:client_post(
<<"/v1/events/", EventId/binary, "/bookings">>, ParticipantToken, #{}),
api_test_runner:client_put(<<"/v1/bookings/", BookingId/binary>>, OwnerToken,
#{action => <<"confirm">>}),
test_get_my_bookings(ParticipantToken, BookingId),
test_get_my_bookings_unauthorized(),
ct:pal("=== All user my bookings tests passed ==="),
ok.
%%%===================================================================
%%% Тестовые функции
%%%===================================================================
%% @doc Успешное получение своих бронирований: 200 OK, содержит бронирование.
-spec test_get_my_bookings(binary(), binary()) -> ok.
test_get_my_bookings(Token, ExpectedBookingId) ->
ct:pal(" TEST: Get my bookings"),
Bookings = api_test_runner:client_get(<<"/v1/user/bookings">>, Token),
?assert(is_list(Bookings)),
?assert(length(Bookings) >= 1),
?assert(lists:any(fun(B) -> maps:get(<<"id">>, B) =:= ExpectedBookingId end, Bookings)),
ct:pal(" OK: booking ~s found in list", [ExpectedBookingId]).
%% @doc Запрос без токена: 401 Unauthorized.
-spec test_get_my_bookings_unauthorized() -> ok.
test_get_my_bookings_unauthorized() ->
ct:pal(" TEST: Get my bookings without token"),
Resp = api_test_runner:client_request(get, <<"/v1/user/bookings">>, <<>>),
?assertMatch({ok, 401, _, _}, Resp),
ct:pal(" OK: got 401").

View File

@@ -0,0 +1,75 @@
%%%-------------------------------------------------------------------
%%% @doc Тесты клиентского API для получения своих отзывов.
%%%
%%% Покрывает эндпоинты:
%%% GET /v1/user/reviews
%%%
%%% Проверяет:
%%% - получение списка отзывов текущего пользователя
%%% - наличие созданного отзыва в ответе
%%% - ошибку 401 при отсутствии токена
%%% @end
%%%-------------------------------------------------------------------
-module(user_my_reviews_tests).
-include_lib("eunit/include/eunit.hrl").
-export([test/0]).
%%%===================================================================
%%% Главная тестовая функция
%%%===================================================================
-spec test() -> ok.
test() ->
ct:pal("=== User My Reviews Tests ==="),
OwnerToken = api_test_runner:get_user_token(),
ParticipantEmail = api_test_runner:unique_email(<<"myreviewer">>),
ParticipantToken = api_test_runner:register_and_login(ParticipantEmail, <<"pass">>),
% Создаём календарь и событие
CalId = api_test_runner:create_calendar(OwnerToken, #{title => <<"MyRevTest">>}),
#{<<"id">> := EventId} = api_test_runner:client_post(
<<"/v1/calendars/", CalId/binary, "/events">>, OwnerToken,
#{title => <<"Event for my review">>,
start_time => <<"2026-06-01T10:00:00Z">>,
duration => 60}),
% Бронируем, подтверждаем, оставляем отзыв
#{<<"id">> := BookingId} = api_test_runner:client_post(
<<"/v1/events/", EventId/binary, "/bookings">>, ParticipantToken, #{}),
api_test_runner:client_put(<<"/v1/bookings/", BookingId/binary>>, OwnerToken,
#{action => <<"confirm">>}),
#{<<"id">> := ReviewId} = api_test_runner:client_post(
<<"/v1/reviews">>, ParticipantToken,
#{target_type => <<"event">>,
target_id => EventId,
rating => 4,
comment => <<"Nice event!">>}),
test_get_my_reviews(ParticipantToken, ReviewId),
test_get_my_reviews_unauthorized(),
ct:pal("=== All user my reviews tests passed ==="),
ok.
%%%===================================================================
%%% Тестовые функции
%%%===================================================================
%% @doc Успешное получение своих отзывов: 200 OK, содержит отзыв.
-spec test_get_my_reviews(binary(), binary()) -> ok.
test_get_my_reviews(Token, ExpectedReviewId) ->
ct:pal(" TEST: Get my reviews"),
Reviews = api_test_runner:client_get(<<"/v1/user/reviews">>, Token),
?assert(is_list(Reviews)),
?assert(length(Reviews) >= 1),
?assert(lists:any(fun(R) -> maps:get(<<"id">>, R) =:= ExpectedReviewId end, Reviews)),
ct:pal(" OK: review ~s found in list", [ExpectedReviewId]).
%% @doc Запрос без токена: 401 Unauthorized.
-spec test_get_my_reviews_unauthorized() -> ok.
test_get_my_reviews_unauthorized() ->
ct:pal(" TEST: Get my reviews without token"),
Resp = api_test_runner:client_request(get, <<"/v1/user/reviews">>, <<>>),
?assertMatch({ok, 401, _, _}, Resp),
ct:pal(" OK: got 401").

View File

@@ -0,0 +1,99 @@
%%%-------------------------------------------------------------------
%%% @doc Тесты клиентского API для отмены вхождения повторяющегося события.
%%%
%%% Покрывает эндпоинты:
%%% DELETE /v1/events/:id/occurrences/:start_time
%%%
%%% Проверяет:
%%% - успешную отмену конкретного вхождения
%%% - ошибку 400 для не-recurring события
%%% - ошибку 403 при попытке отмены чужим пользователем
%%% - ошибку 401 без токена
%%% @end
%%%-------------------------------------------------------------------
-module(user_occurrence_cancel_tests).
-include_lib("eunit/include/eunit.hrl").
-export([test/0]).
%%%===================================================================
%%% Главная тестовая функция
%%%===================================================================
-spec test() -> ok.
test() ->
ct:pal("=== User Occurrence Cancel Tests ==="),
OwnerToken = api_test_runner:get_user_token(),
OtherToken = api_test_runner:register_and_login(
api_test_runner:unique_email(<<"other">>), <<"pass">>),
% Создаём календарь и повторяющееся событие
CalId = api_test_runner:create_calendar(OwnerToken, #{title => <<"OccCancel">>}),
#{<<"id">> := RecurringId} = api_test_runner:client_post(
<<"/v1/calendars/", CalId/binary, "/events">>, OwnerToken,
#{title => <<"Weekly Standup">>,
start_time => <<"2026-06-01T10:00:00Z">>,
duration => 30,
recurrence => #{freq => <<"WEEKLY">>, interval => 1}}),
% Получаем вхождения (ответ список карт)
OccPath = <<"/v1/events/", RecurringId/binary, "/occurrences?from=2026-06-01T00:00:00Z&to=2026-06-30T00:00:00Z">>,
Occurrences = api_test_runner:client_get(OccPath, OwnerToken),
?assert(is_list(Occurrences)),
?assert(length(Occurrences) >= 1),
#{<<"start_time">> := FirstStart} = hd(Occurrences),
test_cancel_occurrence(OwnerToken, RecurringId, FirstStart),
test_cancel_occurrence_on_single_event(OwnerToken, CalId),
test_cancel_occurrence_forbidden(OtherToken, RecurringId, FirstStart),
test_cancel_occurrence_unauthorized(RecurringId, FirstStart),
ct:pal("=== All user occurrence cancel tests passed ==="),
ok.
%%%===================================================================
%%% Тестовые функции
%%%===================================================================
%% @doc Успешная отмена вхождения: 200 OK.
-spec test_cancel_occurrence(binary(), binary(), binary()) -> ok.
test_cancel_occurrence(Token, EventId, StartTime) ->
ct:pal(" TEST: Cancel occurrence"),
Path = <<"/v1/events/", EventId/binary, "/occurrences/", StartTime/binary>>,
Resp = api_test_runner:client_request(delete, Path, Token),
{ok, 200, _, Body} = Resp,
#{<<"status">> := Status} = jsx:decode(list_to_binary(Body), [return_maps]),
?assertEqual(<<"cancelled">>, Status),
ct:pal(" OK: occurrence cancelled").
%% @doc Попытка отменить вхождение для одиночного события: 400.
-spec test_cancel_occurrence_on_single_event(binary(), binary()) -> ok.
test_cancel_occurrence_on_single_event(Token, CalId) ->
ct:pal(" TEST: Cancel occurrence on non-recurring event"),
#{<<"id">> := SingleId} = api_test_runner:client_post(
<<"/v1/calendars/", CalId/binary, "/events">>, Token,
#{title => <<"Single">>,
start_time => <<"2026-06-02T10:00:00Z">>,
duration => 30}),
Path = <<"/v1/events/", SingleId/binary, "/occurrences/2026-06-02T10:00:00Z">>,
Resp = api_test_runner:client_request(delete, Path, Token),
?assertMatch({ok, 400, _, _}, Resp),
ct:pal(" OK: got 400").
%% @doc Попытка отмены чужим пользователем: 403.
-spec test_cancel_occurrence_forbidden(binary(), binary(), binary()) -> ok.
test_cancel_occurrence_forbidden(OtherToken, EventId, StartTime) ->
ct:pal(" TEST: Cancel occurrence by non-owner"),
Path = <<"/v1/events/", EventId/binary, "/occurrences/", StartTime/binary>>,
Resp = api_test_runner:client_request(delete, Path, OtherToken),
?assertMatch({ok, 403, _, _}, Resp),
ct:pal(" OK: got 403").
%% @doc Запрос без токена: 401.
-spec test_cancel_occurrence_unauthorized(binary(), binary()) -> ok.
test_cancel_occurrence_unauthorized(EventId, StartTime) ->
ct:pal(" TEST: Cancel occurrence without token"),
Path = <<"/v1/events/", EventId/binary, "/occurrences/", StartTime/binary>>,
Resp = api_test_runner:client_request(delete, Path, <<>>),
?assertMatch({ok, 401, _, _}, Resp),
ct:pal(" OK: got 401").

View File

@@ -0,0 +1,88 @@
%%%-------------------------------------------------------------------
%%% @doc Тесты клиентского API для обновления токена.
%%%
%%% Покрывает эндпоинты:
%%% POST /v1/refresh
%%%
%%% Проверяет:
%%% - успешное обновление токена по валидному refresh_token
%%% - ошибку 401 при невалидном refresh_token
%%% - ошибку 400 при отсутствии refresh_token в теле запроса
%%% @end
%%%-------------------------------------------------------------------
-module(user_refresh_tests).
-include_lib("eunit/include/eunit.hrl").
-export([test/0]).
%%%===================================================================
%%% Главная тестовая функция
%%%===================================================================
-spec test() -> ok.
test() ->
ct:pal("=== User Refresh Tests ==="),
Token = api_test_runner:get_user_token(),
% Получаем refresh_token через логин (или регистрацию)
Email = api_test_runner:unique_email(<<"refresh">>),
Password = <<"StrongPass1!">>,
#{<<"refresh_token">> := RefreshToken} = register_and_get_refresh(Email, Password),
test_successful_refresh(RefreshToken),
test_invalid_refresh_token(),
test_missing_refresh_token(),
ct:pal("=== All user refresh tests passed ==="),
ok.
%%%===================================================================
%%% Тестовые функции
%%%===================================================================
%% @doc Успешное обновление: 200 OK, возвращает новую пару токенов.
-spec test_successful_refresh(binary()) -> ok.
test_successful_refresh(RefreshToken) ->
ct:pal(" TEST: Successful token refresh"),
Resp = api_test_runner:client_request(post, <<"/v1/refresh">>, <<>>,
jsx:encode(#{refresh_token => RefreshToken})),
{ok, 200, _, Body} = Resp,
#{<<"token">> := NewToken, <<"refresh_token">> := NewRefresh} =
jsx:decode(list_to_binary(Body), [return_maps]),
?assert(is_binary(NewToken)),
?assert(is_binary(NewRefresh)),
?assertNotEqual(RefreshToken, NewRefresh),
ct:pal(" OK: got new token pair").
%% @doc Невалидный refresh_token: 401 Unauthorized.
-spec test_invalid_refresh_token() -> ok.
test_invalid_refresh_token() ->
ct:pal(" TEST: Invalid refresh token"),
Resp = api_test_runner:client_request(post, <<"/v1/refresh">>, <<>>,
jsx:encode(#{refresh_token => <<"invalid_token_here">>})),
{ok, 401, _, _} = Resp,
ct:pal(" OK: got 401").
%% @doc Отсутствие refresh_token в теле: 400 Bad Request.
-spec test_missing_refresh_token() -> ok.
test_missing_refresh_token() ->
ct:pal(" TEST: Missing refresh_token field"),
Resp = api_test_runner:client_request(post, <<"/v1/refresh">>, <<>>,
jsx:encode(#{})),
?assertMatch({ok, 400, _, _}, Resp),
ct:pal(" OK: got 400").
%%%===================================================================
%%% Вспомогательные функции
%%%===================================================================
%% @doc Регистрирует пользователя, выполняет логин и возвращает refresh_token.
-spec register_and_get_refresh(binary(), binary()) -> map().
register_and_get_refresh(Email, Password) ->
% Регистрируем
_ = api_test_runner:client_request(post, <<"/v1/register">>, <<>>,
jsx:encode(#{email => Email, password => Password})),
% Логинимся, чтобы получить refresh_token
{ok, 200, _, Body} = api_test_runner:client_request(post, <<"/v1/login">>, <<>>,
jsx:encode(#{email => Email, password => Password})),
jsx:decode(list_to_binary(Body), [return_maps]).

View File

@@ -0,0 +1,76 @@
%%%-------------------------------------------------------------------
%%% @doc Тесты клиентского API для регистрации пользователей.
%%%
%%% Покрывает эндпоинты:
%%% POST /v1/register
%%%
%%% Проверяет:
%%% - успешную регистрацию нового пользователя
%%% - возврат JWT токена и данных пользователя
%%% - ошибку при повторной регистрации с тем же email
%%% - ошибку при отсутствии обязательных полей
%%% @end
%%%-------------------------------------------------------------------
-module(user_register_tests).
-include_lib("eunit/include/eunit.hrl").
-export([test/0]).
%%%===================================================================
%%% Главная тестовая функция
%%%===================================================================
-spec test() -> ok.
test() ->
ct:pal("=== Client Register Tests ==="),
Email = api_test_runner:unique_email(<<"register">>),
Password = <<"StrongPass1!">>,
test_successful_register(Email, Password),
test_duplicate_register(Email, Password),
test_missing_fields(),
ct:pal("=== All client register tests passed ==="),
ok.
%%%===================================================================
%%% Тестовые функции
%%%===================================================================
%% @doc Успешная регистрация: 201 Created, возвращает токен и пользователя.
-spec test_successful_register(binary(), binary()) -> ok.
test_successful_register(Email, Password) ->
ct:pal(" TEST: Successful registration"),
Resp = api_test_runner:client_request(post, <<"/v1/register">>, <<>>,
jsx:encode(#{email => Email, password => Password})),
{ok, 201, _, Body} = Resp,
#{<<"token">> := Token, <<"user">> := User} = jsx:decode(list_to_binary(Body), [return_maps]),
?assert(is_binary(Token)),
?assert(maps:is_key(<<"id">>, User)),
?assertEqual(Email, maps:get(<<"email">>, User)),
ct:pal(" OK: user ~s created", [maps:get(<<"id">>, User)]).
%% @doc Повторная регистрация с тем же email: 409 Conflict.
-spec test_duplicate_register(binary(), binary()) -> ok.
test_duplicate_register(Email, Password) ->
ct:pal(" TEST: Duplicate registration"),
Resp = api_test_runner:client_request(post, <<"/v1/register">>, <<>>,
jsx:encode(#{email => Email, password => Password})),
{ok, 409, _, Body} = Resp,
#{<<"error">> := ErrorMsg} = jsx:decode(list_to_binary(Body), [return_maps]),
?assertEqual(<<"Email already exists">>, ErrorMsg),
ct:pal(" OK: got 409 conflict").
%% @doc Отсутствие обязательных полей: 400 Bad Request.
-spec test_missing_fields() -> ok.
test_missing_fields() ->
ct:pal(" TEST: Missing required fields"),
Resp1 = api_test_runner:client_request(post, <<"/v1/register">>, <<>>,
jsx:encode(#{email => <<"missing@test.local">>})),
?assertMatch({ok, 400, _, _}, Resp1),
Resp2 = api_test_runner:client_request(post, <<"/v1/register">>, <<>>,
jsx:encode(#{password => <<"NoEmail1">>})),
?assertMatch({ok, 400, _, _}, Resp2),
ct:pal(" OK: 400 on missing fields").

View File

@@ -0,0 +1,101 @@
%%%-------------------------------------------------------------------
%%% @doc Тесты клиентского API для работы с жалобами.
%%%
%%% Покрывает эндпоинты:
%%% POST /v1/reports
%%% GET /v1/reports (требует прав администратора)
%%%
%%% Проверяет:
%%% - успешное создание жалобы (201 Created)
%%% - ошибку 400 при отсутствии обязательных полей
%%% - ошибку 401 при создании без токена
%%% - ошибку 403 при попытке получения списка обычным пользователем
%%% @end
%%%-------------------------------------------------------------------
-module(user_reports_tests).
-include_lib("eunit/include/eunit.hrl").
-export([test/0]).
%%%===================================================================
%%% Главная тестовая функция
%%%===================================================================
-spec test() -> ok.
test() ->
ct:pal("=== User Reports Tests ==="),
UserToken = api_test_runner:get_user_token(),
% Создаём событие, на которое можно пожаловаться
CalId = api_test_runner:create_calendar(UserToken, #{title => <<"ReportTest">>}),
#{<<"id">> := EventId} = api_test_runner:client_post(
<<"/v1/calendars/", CalId/binary, "/events">>, UserToken,
#{title => <<"Event to report">>,
start_time => <<"2026-06-01T10:00:00Z">>,
duration => 60}),
test_create_report(UserToken, EventId),
test_create_report_missing_fields(UserToken),
test_create_report_unauthorized(EventId),
test_list_reports_forbidden(UserToken),
ct:pal("=== All user reports tests passed ==="),
ok.
%%%===================================================================
%%% Тестовые функции
%%%===================================================================
%% @doc Успешное создание жалобы: 201 Created.
-spec test_create_report(binary(), binary()) -> ok.
test_create_report(Token, EventId) ->
ct:pal(" TEST: Create a report"),
Resp = api_test_runner:client_request(post, <<"/v1/reports">>, Token,
jsx:encode(#{
target_type => <<"event">>,
target_id => EventId,
reason => <<"Inappropriate content">>
})),
{ok, 201, _, Body} = Resp,
#{<<"id">> := ReportId, <<"status">> := Status} = jsx:decode(list_to_binary(Body), [return_maps]),
?assert(is_binary(ReportId)),
?assertEqual(<<"pending">>, Status),
ct:pal(" OK: report ~s created", [ReportId]).
%% @doc Отсутствие обязательных полей: 400 Bad Request.
-spec test_create_report_missing_fields(binary()) -> ok.
test_create_report_missing_fields(Token) ->
ct:pal(" TEST: Create report with missing fields"),
Resp1 = api_test_runner:client_request(post, <<"/v1/reports">>, Token,
jsx:encode(#{target_id => <<"id">>, reason => <<"text">>})),
?assertMatch({ok, 400, _, _}, Resp1),
Resp2 = api_test_runner:client_request(post, <<"/v1/reports">>, Token,
jsx:encode(#{target_type => <<"event">>, reason => <<"text">>})),
?assertMatch({ok, 400, _, _}, Resp2),
Resp3 = api_test_runner:client_request(post, <<"/v1/reports">>, Token,
jsx:encode(#{target_type => <<"event">>, target_id => <<"id">>})),
?assertMatch({ok, 400, _, _}, Resp3),
ct:pal(" OK: got 400").
%% @doc Создание жалобы без токена: 401 Unauthorized.
-spec test_create_report_unauthorized(binary()) -> ok.
test_create_report_unauthorized(EventId) ->
ct:pal(" TEST: Create report without token"),
Resp = api_test_runner:client_request(post, <<"/v1/reports">>, <<>>,
jsx:encode(#{
target_type => <<"event">>,
target_id => EventId,
reason => <<"test">>
})),
?assertMatch({ok, 401, _, _}, Resp),
ct:pal(" OK: got 401").
%% @doc GET /v1/reports для обычного пользователя должен вернуть 403.
-spec test_list_reports_forbidden(binary()) -> ok.
test_list_reports_forbidden(Token) ->
ct:pal(" TEST: List reports as regular user"),
Resp = api_test_runner:client_request(get, <<"/v1/reports">>, Token),
?assertMatch({ok, 403, _, _}, Resp),
ct:pal(" OK: got 403").

View File

@@ -0,0 +1,126 @@
%%%-------------------------------------------------------------------
%%% @doc Тесты клиентского API для работы с конкретным отзывом.
%%%
%%% Покрывает эндпоинты:
%%% GET /v1/reviews/:id
%%% PUT /v1/reviews/:id
%%% DELETE /v1/reviews/:id
%%%
%%% Проверяет:
%%% - получение отзыва по ID
%%% - обновление отзыва (автором)
%%% - удаление отзыва (автором)
%%% - ошибку 403 при попытке изменения чужим пользователем
%%% - ошибку 404 для несуществующего отзыва
%%% - ошибку 401 без токена
%%% @end
%%%-------------------------------------------------------------------
-module(user_review_by_id_tests).
-include_lib("eunit/include/eunit.hrl").
-export([test/0]).
%%%===================================================================
%%% Главная тестовая функция
%%%===================================================================
-spec test() -> ok.
test() ->
ct:pal("=== User Review By ID Tests ==="),
OwnerToken = api_test_runner:get_user_token(),
ParticipantEmail = api_test_runner:unique_email(<<"reviewer">>),
ParticipantToken = api_test_runner:register_and_login(ParticipantEmail, <<"pass">>),
StrangerEmail = api_test_runner:unique_email(<<"stranger">>),
StrangerToken = api_test_runner:register_and_login(StrangerEmail, <<"pass">>),
% Создаём календарь, событие, бронирование и отзыв
CalId = api_test_runner:create_calendar(OwnerToken, #{title => <<"RevById">>}),
#{<<"id">> := EventId} = api_test_runner:client_post(
<<"/v1/calendars/", CalId/binary, "/events">>, OwnerToken,
#{title => <<"Event for review">>,
start_time => <<"2026-06-01T10:00:00Z">>,
duration => 60}),
#{<<"id">> := BookingId} = api_test_runner:client_post(
<<"/v1/events/", EventId/binary, "/bookings">>, ParticipantToken, #{}),
api_test_runner:client_put(<<"/v1/bookings/", BookingId/binary>>, OwnerToken,
#{action => <<"confirm">>}),
#{<<"id">> := ReviewId} = api_test_runner:client_post(
<<"/v1/reviews">>, ParticipantToken,
#{target_type => <<"event">>,
target_id => EventId,
rating => 5,
comment => <<"Excellent!">>}),
test_get_review(ParticipantToken, ReviewId),
test_update_review(ParticipantToken, ReviewId),
test_update_review_forbidden(StrangerToken, ReviewId),
test_delete_review(ParticipantToken, ReviewId),
test_get_review_not_found(ParticipantToken),
test_get_review_unauthorized(ReviewId),
ct:pal("=== All user review by id tests passed ==="),
ok.
%%%===================================================================
%%% Тестовые функции
%%%===================================================================
%% @doc GET /v1/reviews/:id получение отзыва.
-spec test_get_review(binary(), binary()) -> ok.
test_get_review(Token, ReviewId) ->
ct:pal(" TEST: Get review by ID"),
Path = <<"/v1/reviews/", ReviewId/binary>>,
Review = api_test_runner:client_get(Path, Token),
?assertEqual(ReviewId, maps:get(<<"id">>, Review)),
?assertEqual(<<"Excellent!">>, maps:get(<<"comment">>, Review)),
ct:pal(" OK: got review").
%% @doc PUT /v1/reviews/:id обновление отзыва автором.
-spec test_update_review(binary(), binary()) -> ok.
test_update_review(Token, ReviewId) ->
ct:pal(" TEST: Update review"),
Path = <<"/v1/reviews/", ReviewId/binary>>,
Updated = api_test_runner:client_put(Path, Token,
#{comment => <<"Updated comment">>, rating => 4}),
?assertEqual(<<"Updated comment">>, maps:get(<<"comment">>, Updated)),
?assertEqual(4, maps:get(<<"rating">>, Updated)),
ct:pal(" OK").
%% @doc PUT /v1/reviews/:id попытка обновления чужим пользователем (403).
-spec test_update_review_forbidden(binary(), binary()) -> ok.
test_update_review_forbidden(StrangerToken, ReviewId) ->
ct:pal(" TEST: Update review by non-author"),
Path = <<"/v1/reviews/", ReviewId/binary>>,
Resp = api_test_runner:client_request(put, Path, StrangerToken,
jsx:encode(#{comment => <<"fail">>})),
?assertMatch({ok, 403, _, _}, Resp),
ct:pal(" OK: got 403").
%% @doc DELETE /v1/reviews/:id удаление отзыва автором.
-spec test_delete_review(binary(), binary()) -> ok.
test_delete_review(Token, ReviewId) ->
ct:pal(" TEST: Delete review"),
Path = <<"/v1/reviews/", ReviewId/binary>>,
% Удаляем
{ok, 200, _, _} = api_test_runner:client_request(delete, Path, Token),
% Проверяем, что отзыв больше недоступен
GetResp = api_test_runner:client_request(get, Path, Token),
?assertMatch({ok, 404, _, _}, GetResp),
ct:pal(" OK: review deleted").
%% @doc GET /v1/reviews/:id несуществующий отзыв (404).
-spec test_get_review_not_found(binary()) -> ok.
test_get_review_not_found(Token) ->
ct:pal(" TEST: Get non-existent review"),
Resp = api_test_runner:client_request(get, <<"/v1/reviews/fakeid">>, Token),
?assertMatch({ok, 404, _, _}, Resp),
ct:pal(" OK: got 404").
%% @doc GET /v1/reviews/:id без токена (401).
-spec test_get_review_unauthorized(binary()) -> ok.
test_get_review_unauthorized(ReviewId) ->
ct:pal(" TEST: Get review without token"),
Path = <<"/v1/reviews/", ReviewId/binary>>,
Resp = api_test_runner:client_request(get, Path, <<>>),
?assertMatch({ok, 401, _, _}, Resp),
ct:pal(" OK: got 401").

View File

@@ -0,0 +1,114 @@
%%%-------------------------------------------------------------------
%%% @doc Тесты клиентского API для отзывов.
%%%
%%% Покрывает эндпоинты:
%%% POST /v1/reviews
%%% GET /v1/reviews
%%%
%%% Проверяет:
%%% - создание отзыва участником подтверждённого бронирования
%%% - ошибку при повторном отзыве на ту же цель
%%% - ошибку при отзыве без участия (403)
%%% - получение списка отзывов для цели
%%% @end
%%%-------------------------------------------------------------------
-module(user_reviews_tests).
-include_lib("eunit/include/eunit.hrl").
-export([test/0]).
%%%===================================================================
%%% Главная тестовая функция
%%%===================================================================
-spec test() -> ok.
test() ->
ct:pal("=== User Reviews Tests ==="),
OwnerToken = api_test_runner:get_user_token(),
ParticipantEmail = api_test_runner:unique_email(<<"reviewer">>),
ParticipantToken = api_test_runner:register_and_login(ParticipantEmail, <<"pass">>),
StrangerEmail = api_test_runner:unique_email(<<"stranger">>),
StrangerToken = api_test_runner:register_and_login(StrangerEmail, <<"pass">>),
% Создаём календарь и событие
CalId = api_test_runner:create_calendar(OwnerToken, #{title => <<"ReviewTest">>}),
#{<<"id">> := EventId} = api_test_runner:client_post(
<<"/v1/calendars/", CalId/binary, "/events">>, OwnerToken,
#{title => <<"Event for review">>,
start_time => <<"2026-06-01T10:00:00Z">>,
duration => 60}),
% Бронируем и подтверждаем участие
#{<<"id">> := BookingId} = api_test_runner:client_post(
<<"/v1/events/", EventId/binary, "/bookings">>, ParticipantToken, #{}),
api_test_runner:client_put(<<"/v1/bookings/", BookingId/binary>>, OwnerToken,
#{action => <<"confirm">>}),
test_create_review(ParticipantToken, EventId),
test_duplicate_review(ParticipantToken, EventId),
test_review_without_booking(StrangerToken, EventId),
test_list_reviews(OwnerToken, EventId),
ct:pal("=== All user reviews tests passed ==="),
ok.
%%%===================================================================
%%% Тестовые функции
%%%===================================================================
%% @doc Успешное создание отзыва: 201 Created.
-spec test_create_review(binary(), binary()) -> ok.
test_create_review(Token, EventId) ->
ct:pal(" TEST: Create a review"),
Resp = api_test_runner:client_request(post, <<"/v1/reviews">>, Token,
jsx:encode(#{
target_type => <<"event">>,
target_id => EventId,
rating => 5,
comment => <<"Excellent!">>
})),
{ok, 201, _, Body} = Resp,
#{<<"id">> := ReviewId, <<"status">> := Status} = jsx:decode(list_to_binary(Body), [return_maps]),
?assert(is_binary(ReviewId)),
?assertEqual(<<"visible">>, Status),
ct:pal(" OK: review ~s created", [ReviewId]).
%% @doc Повторный отзыв на ту же цель: 409 Conflict.
-spec test_duplicate_review(binary(), binary()) -> ok.
test_duplicate_review(Token, EventId) ->
ct:pal(" TEST: Duplicate review"),
Resp = api_test_runner:client_request(post, <<"/v1/reviews">>, Token,
jsx:encode(#{
target_type => <<"event">>,
target_id => EventId,
rating => 3,
comment => <<"Second try">>
})),
{ok, 409, _, _} = Resp,
ct:pal(" OK: got 409 conflict").
%% @doc Попытка оставить отзыв без бронирования: 403 Forbidden.
-spec test_review_without_booking(binary(), binary()) -> ok.
test_review_without_booking(StrangerToken, EventId) ->
ct:pal(" TEST: Review without booking"),
Resp = api_test_runner:client_request(post, <<"/v1/reviews">>, StrangerToken,
jsx:encode(#{
target_type => <<"event">>,
target_id => EventId,
rating => 1,
comment => <<"Not allowed">>
})),
{ok, 403, _, _} = Resp,
ct:pal(" OK: got 403 forbidden").
%% @doc GET /v1/reviews?target_type=event&target_id=... список отзывов.
-spec test_list_reviews(binary(), binary()) -> ok.
test_list_reviews(_, EventId) ->
ct:pal(" TEST: List reviews for event"),
Path = <<"/v1/reviews?target_type=event&target_id=", EventId/binary>>,
Reviews = api_test_runner:client_get(Path, api_test_runner:get_user_token()),
?assert(is_list(Reviews)),
?assert(length(Reviews) >= 1),
First = hd(Reviews),
?assertEqual(<<"Excellent!">>, maps:get(<<"comment">>, First)),
ct:pal(" OK: ~p reviews found", [length(Reviews)]).

View File

@@ -0,0 +1,126 @@
%%%-------------------------------------------------------------------
%%% @doc Тесты клиентского API для поиска.
%%%
%%% Покрывает эндпоинты:
%%% GET /v1/search
%%%
%%% Проверяет:
%%% - полнотекстовый поиск по названиям событий
%%% - поиск с фильтрацией по типу (event/calendar)
%%% - поиск с фильтрацией по тегам
%%% - поиск с фильтрацией по датам (from/to)
%%% - геопоиск (lat, lon, radius)
%%% - пагинацию результатов
%%% - ошибку 401 без токена
%%% @end
%%%-------------------------------------------------------------------
-module(user_search_tests).
-include_lib("eunit/include/eunit.hrl").
-export([test/0]).
%%%===================================================================
%%% Главная тестовая функция
%%%===================================================================
-spec test() -> ok.
test() ->
ct:pal("=== User Search Tests ==="),
Token = api_test_runner:get_user_token(),
% Создаём тестовые данные: календарь и несколько событий с тегами
CalId = api_test_runner:create_calendar(Token, #{title => <<"SearchTestCal">>}),
api_test_runner:client_post(
<<"/v1/calendars/", CalId/binary, "/events">>, Token,
#{title => <<"Python Workshop">>,
start_time => <<"2026-06-01T10:00:00Z">>,
duration => 60,
tags => [<<"python">>, <<"workshop">>]}),
api_test_runner:client_post(
<<"/v1/calendars/", CalId/binary, "/events">>, Token,
#{title => <<"JavaScript Meetup">>,
start_time => <<"2026-06-15T10:00:00Z">>,
duration => 60,
tags => [<<"javascript">>]}),
test_basic_search(Token),
test_type_filter(Token),
test_tag_filter(Token),
test_date_filter(Token),
test_geo_search(Token),
test_search_pagination(Token),
test_search_unauthorized(),
ct:pal("=== All user search tests passed ==="),
ok.
%%%===================================================================
%%% Вспомогательная функция
%%%===================================================================
%% @private Извлекает список событий из результата поиска.
%% Ожидает ответ вида {"results": {"events": [...], "calendars": [...]}}.
-spec extract_events(map()) -> list().
extract_events(#{<<"results">> := #{<<"events">> := Events}}) -> Events.
%%%===================================================================
%%% Тестовые функции
%%%===================================================================
test_basic_search(Token) ->
ct:pal(" TEST: Basic search"),
Events = extract_events(
api_test_runner:client_get(<<"/v1/search?q=Python">>, Token)),
?assert(is_list(Events)),
?assert(length(Events) >= 1),
ct:pal(" OK: ~p events found", [length(Events)]).
test_type_filter(Token) ->
ct:pal(" TEST: Search with type filter"),
Events = extract_events(
api_test_runner:client_get(<<"/v1/search?q=Python&type=event">>, Token)),
?assert(is_list(Events)),
?assert(length(Events) >= 1),
ct:pal(" OK: ~p events", [length(Events)]).
test_tag_filter(Token) ->
ct:pal(" TEST: Search with tag filter"),
Events = extract_events(
api_test_runner:client_get(<<"/v1/search?tags=python">>, Token)),
?assert(is_list(Events)),
?assert(length(Events) >= 1),
ct:pal(" OK: ~p events", [length(Events)]).
test_date_filter(Token) ->
ct:pal(" TEST: Search with date range"),
Events = extract_events(
api_test_runner:client_get(
<<"/v1/search?from=2026-06-01T00:00:00Z&to=2026-06-15T23:59:59Z">>, Token)),
?assert(is_list(Events)),
?assert(length(Events) >= 1),
ct:pal(" OK: ~p events", [length(Events)]).
test_geo_search(Token) ->
ct:pal(" TEST: Geo search"),
Events = extract_events(
api_test_runner:client_get(
<<"/v1/search?lat=55.75&lon=37.61&radius=1">>, Token)),
?assert(is_list(Events)),
?assert(length(Events) >= 0),
ct:pal(" OK: ~p events", [length(Events)]).
test_search_pagination(Token) ->
ct:pal(" TEST: Search pagination"),
Events1 = extract_events(
api_test_runner:client_get(<<"/v1/search?limit=1&offset=0">>, Token)),
?assertEqual(1, length(Events1)),
Events2 = extract_events(
api_test_runner:client_get(<<"/v1/search?limit=1&offset=1">>, Token)),
?assert(length(Events2) >= 0),
ct:pal(" OK").
test_search_unauthorized() ->
ct:pal(" TEST: Search without token"),
Resp = api_test_runner:client_request(get, <<"/v1/search?q=test">>, <<>>),
?assertMatch({ok, 401, _, _}, Resp),
ct:pal(" OK: got 401").

View File

@@ -0,0 +1,78 @@
%%%-------------------------------------------------------------------
%%% @doc Тесты клиентского API для управления подпиской.
%%%
%%% Покрывает эндпоинты:
%%% GET /v1/subscription
%%% POST /v1/subscription
%%%
%%% Проверяет:
%%% - получение информации о подписке (200)
%%% - активацию пробного периода (start_trial, 201)
%%% - ошибку 409 при повторной активации
%%% - ошибку 401 без токена
%%% @end
%%%-------------------------------------------------------------------
-module(user_subscription_tests).
-include_lib("eunit/include/eunit.hrl").
-export([test/0]).
%%%===================================================================
%%% Главная тестовая функция
%%%===================================================================
-spec test() -> ok.
test() ->
ct:pal("=== User Subscription Tests ==="),
% Создаём уникального пользователя у него точно нет активной подписки
Email = api_test_runner:unique_email(<<"subtest">>),
Token = api_test_runner:register_and_login(Email, <<"StrongPass1!">>),
test_get_subscription(Token),
test_start_trial(Token),
test_start_trial_duplicate(Token),
test_get_subscription_unauthorized(),
ct:pal("=== All user subscription tests passed ==="),
ok.
%%%===================================================================
%%% Тестовые функции
%%%===================================================================
%% @doc GET /v1/subscription получение подписки (может быть пустой).
-spec test_get_subscription(binary()) -> ok.
test_get_subscription(Token) ->
ct:pal(" TEST: Get subscription"),
Sub = api_test_runner:client_get(<<"/v1/subscription">>, Token),
?assert(is_map(Sub)),
ct:pal(" OK: subscription info received").
%% @doc POST /v1/subscription успешная активация пробного периода.
-spec test_start_trial(binary()) -> ok.
test_start_trial(Token) ->
ct:pal(" TEST: Start trial"),
Resp = api_test_runner:client_request(post, <<"/v1/subscription">>, Token,
jsx:encode(#{action => <<"start_trial">>})),
{ok, 201, _, Body} = Resp,
#{<<"status">> := Status, <<"plan">> := Plan} = jsx:decode(list_to_binary(Body), [return_maps]),
?assertEqual(<<"active">>, Status),
?assert(is_binary(Plan)),
ct:pal(" OK: trial activated with plan ~s", [Plan]).
%% @doc POST /v1/subscription повторная активация (409).
-spec test_start_trial_duplicate(binary()) -> ok.
test_start_trial_duplicate(Token) ->
ct:pal(" TEST: Start trial again (duplicate)"),
Resp = api_test_runner:client_request(post, <<"/v1/subscription">>, Token,
jsx:encode(#{action => <<"start_trial">>})),
?assertMatch({ok, 409, _, _}, Resp),
ct:pal(" OK: got 409 conflict").
%% @doc GET /v1/subscription без токена (401).
-spec test_get_subscription_unauthorized() -> ok.
test_get_subscription_unauthorized() ->
ct:pal(" TEST: Get subscription without token"),
Resp = api_test_runner:client_request(get, <<"/v1/subscription">>, <<>>),
?assertMatch({ok, 401, _, _}, Resp),
ct:pal(" OK: got 401").

View File

@@ -0,0 +1,137 @@
%%%-------------------------------------------------------------------
%%% @doc Тесты клиентского API для работы с тикетами.
%%%
%%% Покрывает эндпоинты:
%%% POST /v1/tickets
%%% GET /v1/tickets
%%% GET /v1/tickets/:id
%%%
%%% Проверяет:
%%% - успешное создание тикета
%%% - получение списка своих тикетов
%%% - получение конкретного тикета по ID
%%% - ошибку 400 при отсутствии обязательных полей
%%% - ошибку 401 без токена
%%% - ошибку 403 при попытке доступа к чужому тикету
%%% @end
%%%-------------------------------------------------------------------
-module(user_tickets_tests).
-include_lib("eunit/include/eunit.hrl").
-export([test/0]).
%%%===================================================================
%%% Главная тестовая функция
%%%===================================================================
-spec test() -> ok.
test() ->
ct:pal("=== User Tickets Tests ==="),
Token = api_test_runner:get_user_token(),
StrangerEmail = api_test_runner:unique_email(<<"stranger">>),
StrangerToken = api_test_runner:register_and_login(StrangerEmail, <<"pass">>),
% Создаём тикет
#{<<"id">> := TicketId} = api_test_runner:client_post(<<"/v1/tickets">>, Token,
#{error_message => <<"Something broke">>, stacktrace => <<"line 42">>}),
test_create_ticket(Token),
test_create_ticket_missing_fields(Token),
test_create_ticket_unauthorized(),
test_list_tickets(Token, TicketId),
test_list_tickets_unauthorized(),
test_get_ticket(Token, TicketId),
test_get_ticket_forbidden(StrangerToken, TicketId),
test_get_ticket_not_found(Token),
test_get_ticket_unauthorized(TicketId),
ct:pal("=== All user tickets tests passed ==="),
ok.
%%%===================================================================
%%% Тестовые функции
%%%===================================================================
%% @doc Успешное создание тикета: 201 Created.
-spec test_create_ticket(binary()) -> ok.
test_create_ticket(Token) ->
ct:pal(" TEST: Create a ticket"),
Resp = api_test_runner:client_request(post, <<"/v1/tickets">>, Token,
jsx:encode(#{error_message => <<"Test bug">>, stacktrace => <<"trace">>})),
{ok, 201, _, Body} = Resp,
#{<<"id">> := Id, <<"status">> := Status} = jsx:decode(list_to_binary(Body), [return_maps]),
?assert(is_binary(Id)),
?assertEqual(<<"open">>, Status),
ct:pal(" OK: ticket ~s created", [Id]).
%% @doc Отсутствие обязательного поля error_message: 400 Bad Request.
-spec test_create_ticket_missing_fields(binary()) -> ok.
test_create_ticket_missing_fields(Token) ->
ct:pal(" TEST: Create ticket without error_message"),
Resp = api_test_runner:client_request(post, <<"/v1/tickets">>, Token,
jsx:encode(#{stacktrace => <<"trace">>})),
?assertMatch({ok, 400, _, _}, Resp),
ct:pal(" OK: got 400").
%% @doc Создание тикета без токена: 401 Unauthorized.
-spec test_create_ticket_unauthorized() -> ok.
test_create_ticket_unauthorized() ->
ct:pal(" TEST: Create ticket without token"),
Resp = api_test_runner:client_request(post, <<"/v1/tickets">>, <<>>,
jsx:encode(#{error_message => <<"bug">>})),
?assertMatch({ok, 401, _, _}, Resp),
ct:pal(" OK: got 401").
%% @doc GET /v1/tickets получение списка своих тикетов.
-spec test_list_tickets(binary(), binary()) -> ok.
test_list_tickets(Token, ExpectedTicketId) ->
ct:pal(" TEST: List my tickets"),
Tickets = api_test_runner:client_get(<<"/v1/tickets">>, Token),
?assert(is_list(Tickets)),
?assert(length(Tickets) >= 1),
?assert(lists:any(fun(T) -> maps:get(<<"id">>, T) =:= ExpectedTicketId end, Tickets)),
ct:pal(" OK: my ticket found").
%% @doc GET /v1/tickets без токена: 401 Unauthorized.
-spec test_list_tickets_unauthorized() -> ok.
test_list_tickets_unauthorized() ->
ct:pal(" TEST: List tickets without token"),
Resp = api_test_runner:client_request(get, <<"/v1/tickets">>, <<>>),
?assertMatch({ok, 401, _, _}, Resp),
ct:pal(" OK: got 401").
%% @doc GET /v1/tickets/:id получение своего тикета.
-spec test_get_ticket(binary(), binary()) -> ok.
test_get_ticket(Token, TicketId) ->
ct:pal(" TEST: Get my ticket by ID"),
Path = <<"/v1/tickets/", TicketId/binary>>,
Ticket = api_test_runner:client_get(Path, Token),
?assertEqual(TicketId, maps:get(<<"id">>, Ticket)),
?assertEqual(<<"Something broke">>, maps:get(<<"error_message">>, Ticket)),
ct:pal(" OK: got my ticket").
%% @doc GET /v1/tickets/:id попытка доступа к чужому тикету (403).
-spec test_get_ticket_forbidden(binary(), binary()) -> ok.
test_get_ticket_forbidden(StrangerToken, TicketId) ->
ct:pal(" TEST: Get ticket that is not mine"),
Path = <<"/v1/tickets/", TicketId/binary>>,
Resp = api_test_runner:client_request(get, Path, StrangerToken),
?assertMatch({ok, 403, _, _}, Resp),
ct:pal(" OK: got 403").
%% @doc GET /v1/tickets/:id несуществующий тикет (404).
-spec test_get_ticket_not_found(binary()) -> ok.
test_get_ticket_not_found(Token) ->
ct:pal(" TEST: Get non-existent ticket"),
Resp = api_test_runner:client_request(get, <<"/v1/tickets/fakeid">>, Token),
?assertMatch({ok, 404, _, _}, Resp),
ct:pal(" OK: got 404").
%% @doc GET /v1/tickets/:id без токена (401).
-spec test_get_ticket_unauthorized(binary()) -> ok.
test_get_ticket_unauthorized(TicketId) ->
ct:pal(" TEST: Get ticket without token"),
Path = <<"/v1/tickets/", TicketId/binary>>,
Resp = api_test_runner:client_request(get, Path, <<>>),
?assertMatch({ok, 401, _, _}, Resp),
ct:pal(" OK: got 401").

View File

@@ -0,0 +1,255 @@
-module(user_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
CalId = api_test_runner:create_calendar(UserToken, #{title => <<"WS Test Calendar">>, type => <<"commercial">>}),
ct:pal(" CalId: ~s", [CalId]),
EventId = api_test_runner:create_event(UserToken, CalId, #{
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 ==========
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.