Рефакторинг обработчиков. Часть 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,180 @@
%%%-------------------------------------------------------------------
%%% @doc Тесты административного API для управления администраторами.
%%%
%%% Покрывает эндпоинты:
%%% GET /v1/admin/admins
%%% POST /v1/admin/admins
%%% GET /v1/admin/admins/:id
%%% PUT /v1/admin/admins/:id
%%% DELETE /v1/admin/admins/:id
%%%
%%% Проверяет:
%%% - получение списка администраторов (только суперадмин)
%%% - создание нового администратора
%%% - получение администратора по ID
%%% - обновление администратора
%%% - удаление (блокировку) администратора
%%% - ошибки 403 для обычного администратора
%%% - ошибки 409 (дубликат email) и 400 (неверная роль) при создании
%%% @end
%%%-------------------------------------------------------------------
-module(admin_admins_tests).
-include_lib("eunit/include/eunit.hrl").
-export([test/0]).
%%%===================================================================
%%% Главная тестовая функция
%%%===================================================================
-spec test() -> ok.
test() ->
ct:pal("=== Admin Admins Tests ==="),
SuperToken = api_test_runner:get_superadmin_token(),
% Создаём нового администратора для проверки CRUD (у него будет свой ID)
AdminEmail = api_test_runner:unique_email(<<"newadmin.admins.tests">>),
AdminPassword = <<"AdminPass123">>,
#{<<"id">> := AdminId} = create_admin(SuperToken, AdminEmail, AdminPassword, <<"admin">>),
% Токен обычного администратора (уже существующего admin@eventhub.local)
AdminToken = api_test_runner:get_admin_token(),
% Тесты с правами суперадмина
test_list_admins(SuperToken),
test_get_admin(SuperToken, AdminId),
test_update_admin(SuperToken, AdminId),
test_delete_admin(SuperToken, AdminId),
% Тесты ограничений для обычного админа (используем готовый токен)
test_list_admins_forbidden(AdminToken),
test_create_admin_forbidden(AdminToken),
test_get_admin_forbidden(AdminToken, AdminId),
test_update_admin_forbidden(AdminToken, AdminId),
test_delete_admin_forbidden(AdminToken, AdminId),
% Тесты валидации при создании
test_create_admin_duplicate_email(SuperToken),
test_create_admin_invalid_role(SuperToken),
ct:pal("=== All admin admins tests passed ==="),
ok.
%%%===================================================================
%%% Тестовые функции
%%%===================================================================
%% @doc GET /v1/admin/admins список администраторов.
-spec test_list_admins(binary()) -> ok.
test_list_admins(Token) ->
ct:pal(" TEST: List all admins"),
Admins = api_test_runner:admin_get(<<"/v1/admin/admins">>, Token),
?assert(is_list(Admins)),
?assert(length(Admins) >= 1),
ct:pal(" OK: ~p admins", [length(Admins)]).
%% @doc GET /v1/admin/admins/:id получение администратора.
-spec test_get_admin(binary(), binary()) -> ok.
test_get_admin(Token, AdminId) ->
ct:pal(" TEST: Get admin by ID"),
Path = <<"/v1/admin/admins/", AdminId/binary>>,
Admin = api_test_runner:admin_get(Path, Token),
?assertEqual(AdminId, maps:get(<<"id">>, Admin)),
ct:pal(" OK: ~s", [maps:get(<<"email">>, Admin)]).
%% @doc PUT /v1/admin/admins/:id обновление администратора.
-spec test_update_admin(binary(), binary()) -> ok.
test_update_admin(Token, AdminId) ->
ct:pal(" TEST: Update admin"),
Path = <<"/v1/admin/admins/", AdminId/binary>>,
Updated = api_test_runner:admin_put(Path, Token, #{nickname => <<"UpdatedAdmin">>}),
?assertEqual(<<"UpdatedAdmin">>, maps:get(<<"nickname">>, Updated)),
ct:pal(" OK").
%% @doc DELETE /v1/admin/admins/:id удаление (блокировка).
-spec test_delete_admin(binary(), binary()) -> ok.
test_delete_admin(Token, AdminId) ->
ct:pal(" TEST: Delete (block) admin"),
Path = <<"/v1/admin/admins/", AdminId/binary>>,
Result = api_test_runner:admin_request(delete, Path, Token),
{ok, 200, _, _} = Result,
ct:pal(" OK: admin blocked (or deleted)"),
% Проверяем, что админ больше не в списке
Admins = api_test_runner:admin_get(<<"/v1/admin/admins">>, Token),
?assertNot(lists:any(fun(A) -> maps:get(<<"id">>, A) =:= AdminId end, Admins)).
%% ── Тесты ограничений ──
-spec test_list_admins_forbidden(binary()) -> ok.
test_list_admins_forbidden(Token) ->
ct:pal(" TEST: List admins as non-superadmin (403)"),
Resp = api_test_runner:admin_request(get, <<"/v1/admin/admins">>, Token),
?assertMatch({ok, 403, _, _}, Resp),
ct:pal(" OK: got 403").
-spec test_create_admin_forbidden(binary()) -> ok.
test_create_admin_forbidden(Token) ->
ct:pal(" TEST: Create admin as non-superadmin (403)"),
Resp = api_test_runner:admin_request(post, <<"/v1/admin/admins">>, Token,
jsx:encode(#{email => <<"x@x.com">>, password => <<"p">>, role => <<"moderator">>})),
?assertMatch({ok, 403, _, _}, Resp),
ct:pal(" OK: got 403").
-spec test_get_admin_forbidden(binary(), binary()) -> ok.
test_get_admin_forbidden(Token, AdminId) ->
ct:pal(" TEST: Get admin by ID as non-superadmin (403)"),
Path = <<"/v1/admin/admins/", AdminId/binary>>,
Resp = api_test_runner:admin_request(get, Path, Token),
?assertMatch({ok, 403, _, _}, Resp),
ct:pal(" OK: got 403").
-spec test_update_admin_forbidden(binary(), binary()) -> ok.
test_update_admin_forbidden(Token, AdminId) ->
ct:pal(" TEST: Update admin as non-superadmin (403)"),
Path = <<"/v1/admin/admins/", AdminId/binary>>,
Resp = api_test_runner:admin_request(put, Path, Token, jsx:encode(#{nickname => <<"fail">>})),
?assertMatch({ok, 403, _, _}, Resp),
ct:pal(" OK: got 403").
-spec test_delete_admin_forbidden(binary(), binary()) -> ok.
test_delete_admin_forbidden(Token, AdminId) ->
ct:pal(" TEST: Delete admin as non-superadmin (403)"),
Path = <<"/v1/admin/admins/", AdminId/binary>>,
Resp = api_test_runner:admin_request(delete, Path, Token),
?assertMatch({ok, 403, _, _}, Resp),
ct:pal(" OK: got 403").
%% ── Валидация создания ──
-spec test_create_admin_duplicate_email(binary()) -> ok.
test_create_admin_duplicate_email(SuperToken) ->
ct:pal(" TEST: Create admin with duplicate email (409)"),
Email = api_test_runner:unique_email(<<"dupadmin">>),
% Создаём первого администратора
{ok, 201, _, _} = api_test_runner:admin_request(post, <<"/v1/admin/admins">>, SuperToken,
jsx:encode(#{email => Email, password => <<"Pass1234">>, role => <<"admin">>})),
% Пытаемся создать второго с тем же email
Resp = api_test_runner:admin_request(post, <<"/v1/admin/admins">>, SuperToken,
jsx:encode(#{email => Email, password => <<"Pass1234">>, role => <<"admin">>})),
?assertMatch({ok, 409, _, _}, Resp),
ct:pal(" OK: got 409").
-spec test_create_admin_invalid_role(binary()) -> ok.
test_create_admin_invalid_role(SuperToken) ->
ct:pal(" TEST: Create admin with invalid role (400)"),
Resp = api_test_runner:admin_request(post, <<"/v1/admin/admins">>, SuperToken,
jsx:encode(#{email => <<"badrole@test.local">>, password => <<"Pass1234">>, role => <<"superhero">>})),
?assertMatch({ok, 400, _, _}, Resp),
ct:pal(" OK: got 400").
%%%===================================================================
%%% Вспомогательные функции
%%%===================================================================
%% @private Создаёт администратора и возвращает его данные.
-spec create_admin(binary(), binary(), binary(), binary()) -> map().
create_admin(Token, Email, Password, Role) ->
ct:pal(" Creating test admin ~s...", [Email]),
{ok, 201, _, Body} = api_test_runner:admin_request(post, <<"/v1/admin/admins">>, Token,
jsx:encode(#{email => Email, password => Password, role => Role})),
jsx:decode(list_to_binary(Body), [return_maps]).

View File

@@ -0,0 +1,79 @@
%%%-------------------------------------------------------------------
%%% @doc Тесты административного API для аудита.
%%%
%%% Покрывает эндпоинты:
%%% GET /v1/admin/audit
%%%
%%% Проверяет:
%%% - получение списка записей аудита
%%% - фильтрацию по admin_id
%%% - пагинацию
%%% @end
%%%-------------------------------------------------------------------
-module(admin_audit_tests).
-include_lib("eunit/include/eunit.hrl").
-export([test/0]).
-spec test() -> ok.
test() ->
ct:pal("=== Admin Audit Tests ==="),
UserToken = api_test_runner:get_user_token(),
#{<<"id">> := UserId} = api_test_runner:client_get(<<"/v1/user/me">>, UserToken),
AdminToken = api_test_runner:get_admin_token(),
SuperToken = api_test_runner:get_superadmin_token(),
% Создаём тестовую запись аудита
Me = api_test_runner:admin_get(<<"/v1/admin/me">>, AdminToken),
AdminId = maps:get(<<"id">>, Me),
Path = <<"/v1/admin/user/", UserId/binary>>,
Body = #{<<"action">> => <<"block">>, <<"reason">> => <<"Test">>},
api_test_runner:admin_put(Path, AdminToken, Body),
ct:sleep(200),
Body2 = #{<<"action">> => <<"unblock">>, <<"reason">> => <<"Test">>},
api_test_runner:admin_put(Path, AdminToken, Body2),
test_list_audit(SuperToken),
test_filter_audit(SuperToken, AdminId),
test_audit_pagination(SuperToken),
test_list_admin_forbidden(AdminToken),
ct:pal("=== All admin audit tests passed ==="),
ok.
test_list_audit(Token) ->
ct:pal(" TEST: List all audit records"),
Records = api_test_runner:admin_get(<<"/v1/admin/audit">>, Token),
?assert(is_list(Records)),
?assert(length(Records) >= 1),
ct:pal(" OK: ~p records", [length(Records)]).
test_filter_audit(Token, AdminId) ->
ct:pal(" TEST: Filter audit by admin_id"),
Records = api_test_runner:admin_get(<<"/v1/admin/audit?admin_id=", AdminId/binary>>, Token),
?assert(is_list(Records)),
[?assertEqual(AdminId, maps:get(<<"admin_id">>, R)) || R <- Records],
ct:pal(" OK: ~p records", [length(Records)]).
test_audit_pagination(Token) ->
ct:pal(" TEST: Audit pagination"),
Page1 = api_test_runner:admin_get(<<"/v1/admin/audit?limit=1&offset=0">>, Token),
?assert(length(Page1) >= 1),
Page2 = api_test_runner:admin_get(<<"/v1/admin/audit?limit=1&offset=1">>, Token),
?assert(length(Page2) >= 0),
case {Page1, Page2} of
{[First|_], [Second|_]} ->
Id1 = maps:get(<<"id">>, First),
Id2 = maps:get(<<"id">>, Second),
?assertNotEqual(Id1, Id2);
_ -> ok
end,
ct:pal(" OK").
-spec test_list_admin_forbidden(binary()) -> ok.
test_list_admin_forbidden(Token) ->
ct:pal(" TEST: List audit as non-superadmin (403)"),
Resp = api_test_runner:admin_request(get, <<"/v1/admin/audit">>, Token),
?assertMatch({ok, 403, _, _}, Resp),
ct:pal(" OK: got 403").

View File

@@ -0,0 +1,94 @@
%%%-------------------------------------------------------------------
%%% @doc Тесты административного API для управления бан-словами.
%%%
%%% Покрывает эндпоинты:
%%% GET /v1/admin/banned-words
%%% POST /v1/admin/banned-words
%%% DELETE /v1/admin/banned-words/:word
%%%
%%% Проверяет:
%%% - получение списка слов
%%% - добавление нового слова
%%% - удаление слова
%%% - пагинацию
%%% @end
%%%-------------------------------------------------------------------
-module(admin_banned_words_tests).
-include_lib("eunit/include/eunit.hrl").
-export([test/0]).
%%%===================================================================
%%% Главная тестовая функция
%%%===================================================================
-spec test() -> ok.
test() ->
ct:pal("=== Admin Banned Words Tests ==="),
Token = api_test_runner:get_admin_token(),
% Добавляем два исходных слова
Unique = integer_to_binary(erlang:system_time()),
Word1 = <<"badword1_", Unique/binary>>,
Word2 = <<"badword2_", Unique/binary>>,
api_test_runner:admin_post(<<"/v1/admin/banned-words">>, Token, #{<<"word">> => Word1}),
api_test_runner:admin_post(<<"/v1/admin/banned-words">>, Token, #{<<"word">> => Word2}),
test_list_words(Token, Word1, Word2),
test_add_word(Token),
test_words_pagination(Token),
test_delete_word(Token, Word1),
test_delete_word(Token, Word2),
ct:pal("=== All admin banned words tests passed ==="),
ok.
%%%===================================================================
%%% Тестовые функции
%%%===================================================================
%% @doc GET /v1/admin/banned-words список всех слов.
-spec test_list_words(binary(), binary(), binary()) -> ok.
test_list_words(Token, Word1, Word2) ->
ct:pal(" TEST: List all banned words"),
Words = api_test_runner:admin_get(<<"/v1/admin/banned-words">>, Token),
?assert(is_list(Words)),
?assert(length(Words) >= 2),
?assert(lists:any(fun(W) -> maps:get(<<"word">>, W) =:= Word1 orelse maps:get(<<"word">>, W) =:= Word2 end, Words)),
ct:pal(" OK: ~p words", [length(Words)]).
%% @doc POST /v1/admin/banned-words добавление нового слова.
-spec test_add_word(binary()) -> ok.
test_add_word(Token) ->
ct:pal(" TEST: Add a new banned word"),
Unique = integer_to_binary(erlang:system_time()),
NewWord = <<"newbadword", Unique/binary>>,
Result = api_test_runner:admin_post(<<"/v1/admin/banned-words">>, Token, #{<<"word">> => NewWord}),
?assertEqual(<<"added">>, maps:get(<<"status">>, Result)),
Words = api_test_runner:admin_get(<<"/v1/admin/banned-words">>, Token),
?assert(lists:any(fun(W) -> maps:get(<<"word">>, W) =:= NewWord end, Words)),
ct:pal(" OK").
%% @doc DELETE /v1/admin/banned-words/:word удаление слова.
-spec test_delete_word(binary(), binary()) -> ok.
test_delete_word(Token, Word) ->
ct:pal(" TEST: Delete a banned word"),
Path = <<"/v1/admin/banned-words/", Word/binary>>,
Result = api_test_runner:admin_delete(Path, Token),
?assertEqual(<<"deleted">>, maps:get(<<"status">>, Result)),
Words = api_test_runner:admin_get(<<"/v1/admin/banned-words">>, Token),
?assertNot(lists:any(fun(W) -> maps:get(<<"word">>, W) =:= Word end, Words)),
ct:pal(" OK").
%% @doc GET /v1/admin/banned-words?limit=...&offset=... пагинация.
-spec test_words_pagination(binary()) -> ok.
test_words_pagination(Token) ->
ct:pal(" TEST: Banned words pagination"),
Page1 = api_test_runner:admin_get(<<"/v1/admin/banned-words?limit=1&offset=0">>, Token),
?assert(length(Page1) >= 1),
Page2 = api_test_runner:admin_get(<<"/v1/admin/banned-words?limit=1&offset=1">>, Token),
?assert(length(Page2) >= 1),
Id1 = maps:get(<<"id">>, hd(Page1)),
Id2 = maps:get(<<"id">>, hd(Page2)),
?assertNotEqual(Id1, Id2),
ct:pal(" OK").

View File

@@ -0,0 +1,119 @@
%%%-------------------------------------------------------------------
%%% @doc Тесты административного API для управления событиями.
%%% Покрывает эндпоинты:
%%% GET /v1/admin/events
%%% GET /v1/admin/events/:id
%%% PUT /v1/admin/events/:id
%%% DELETE /v1/admin/events/:id
%%% @end
%%%-------------------------------------------------------------------
-module(admin_events_tests).
-include_lib("eunit/include/eunit.hrl").
-export([test/0]).
%%%===================================================================
%%% EUnit test generator
%%%===================================================================
test() ->
ct:pal("=== Admin Events Tests ==="),
Token = api_test_runner:get_admin_token(),
UserToken = api_test_runner:get_user_token(),
%% Создаём тестовый календарь и два события
CalId = api_test_runner:create_calendar(UserToken, #{title => <<"EventsTestCal">>}),
Event1Id = api_test_runner:create_event(UserToken, CalId, #{
title => <<"Test Event Alpha">>,
start_time => api_test_runner:future_date(),
duration => 60
}),
Event2Id = api_test_runner:create_event(UserToken, CalId, #{
title => <<"Beta Event">>,
start_time => api_test_runner:future_date(),
duration => 60
}),
%% Выполняем тесты
test_list_events(Token, CalId, Event1Id, Event2Id),
test_get_event(Token, Event1Id),
test_update_event(Token, Event1Id),
test_delete_event(Token, Event2Id),
test_filter_events(Token, CalId),
test_search_events(Token, CalId),
test_event_pagination(Token, CalId),
test_delete_event(Token, Event1Id),
ct:pal("=== All admin events tests passed ==="),
ok.
%%%===================================================================
%%% Тестовые функции
%%%===================================================================
%% @doc GET /v1/admin/events список событий (базовая проверка).
test_list_events(Token, _CalId, _Event1Id, _Event2Id) ->
ct:pal(" TEST: List all events"),
Events = api_test_runner:admin_get(<<"/v1/admin/events">>, Token),
?assert(is_list(Events)),
?assert(length(Events) >= 2),
ct:pal(" OK: ~p events found", [length(Events)]).
%% @doc GET /v1/admin/events/:id получение события по ID.
test_get_event(Token, EventId) ->
ct:pal(" TEST: Get event by ID"),
Path = <<"/v1/admin/events/", EventId/binary>>,
Event = api_test_runner:admin_get(Path, Token),
?assertEqual(EventId, maps:get(<<"id">>, Event)),
?assertEqual(<<"active">>, maps:get(<<"status">>, Event)),
ct:pal(" OK: ~s", [maps:get(<<"title">>, Event)]).
%% @doc PUT /v1/admin/events/:id обновление события.
test_update_event(Token, EventId) ->
ct:pal(" TEST: Update event"),
Path = <<"/v1/admin/events/", EventId/binary>>,
Updates = #{<<"title">> => <<"Updated by admin">>, <<"description">> => <<"Test update">>},
Updated = api_test_runner:admin_put(Path, Token, Updates),
?assertEqual(<<"Updated by admin">>, maps:get(<<"title">>, Updated)),
?assertEqual(<<"Test update">>, maps:get(<<"description">>, Updated)),
ct:pal(" OK").
%% @doc DELETE /v1/admin/events/:id удаление события.
test_delete_event(Token, EventId) ->
ct:pal(" TEST: Delete event"),
Path = <<"/v1/admin/events/", EventId/binary>>,
Deleted = api_test_runner:admin_delete(Path, Token),
?assertEqual(<<"deleted">>, maps:get(<<"status">>, Deleted)),
ct:pal(" OK").
%% @doc GET /v1/admin/events?status=active фильтрация по статусу.
test_filter_events(Token, CalId) ->
ct:pal(" TEST: Filter events by status=active"),
Path = <<"/v1/admin/events?calendar_id=", CalId/binary, "&status=active">>,
Events = api_test_runner:admin_get(Path, Token),
?assert(is_list(Events)),
[?assertEqual(<<"active">>, maps:get(<<"status">>, E)) || E <- Events],
ct:pal(" OK: ~p events", [length(Events)]).
%% @doc GET /v1/admin/events?q=... поиск по подстроке.
test_search_events(Token, CalId) ->
ct:pal(" TEST: Search events by title substring"),
Path = <<"/v1/admin/events?calendar_id=", CalId/binary, "&q=Updated">>,
Events = api_test_runner:admin_get(Path, Token),
?assert(is_list(Events)),
?assert(length(Events) >= 1),
[?assertNotEqual(nomatch, binary:match(maps:get(<<"title">>, E), <<"Updated">>)) || E <- Events],
ct:pal(" OK: ~p results", [length(Events)]).
%% @doc GET /v1/admin/events?limit=...&offset=... пагинация.
test_event_pagination(Token, CalId) ->
ct:pal(" TEST: Event pagination"),
Base = <<"/v1/admin/events?calendar_id=", CalId/binary, "&status=all&sort=title&order=asc">>,
Page1 = api_test_runner:admin_get(<<Base/binary, "&limit=1&offset=0">>, Token),
?assertEqual(1, length(Page1)),
Page2 = api_test_runner:admin_get(<<Base/binary, "&limit=1&offset=1">>, Token),
?assertEqual(1, length(Page2)),
Id1 = maps:get(<<"id">>, hd(Page1)),
Id2 = maps:get(<<"id">>, hd(Page2)),
?assertNotEqual(Id1, Id2),
ct:pal(" OK").

View File

@@ -0,0 +1,36 @@
%%%-------------------------------------------------------------------
%%% @doc Тесты административного API для профиля текущего администратора.
%%% Покрывает GET /v1/admin/me и PUT /v1/admin/me.
%%% @end
%%%-------------------------------------------------------------------
-module(admin_me_tests).
-include_lib("eunit/include/eunit.hrl").
-export([test/0]).
-spec test() -> ok.
test() ->
ct:pal("=== Admin Me Tests ==="),
Token = api_test_runner:get_admin_token(),
test_get_me(Token),
test_update_me(Token),
ct:pal("=== All admin me tests passed ==="),
ok.
test_get_me(Token) ->
ct:pal(" TEST: Get current admin profile"),
Me = api_test_runner:admin_get(<<"/v1/admin/me">>, Token),
?assert(is_map(Me)),
?assert(maps:is_key(<<"id">>, Me)),
?assert(maps:is_key(<<"email">>, Me)),
ct:pal(" OK: got profile for ~s", [maps:get(<<"email">>, Me)]).
test_update_me(Token) ->
ct:pal(" TEST: Update current admin profile"),
Updated = api_test_runner:admin_put(<<"/v1/admin/me">>, Token,
#{nickname => <<"TestNick">>, timezone => <<"UTC">>}),
?assertEqual(<<"TestNick">>, maps:get(<<"nickname">>, Updated)),
?assertEqual(<<"UTC">>, maps:get(<<"timezone">>, Updated)),
ct:pal(" OK").

View File

@@ -0,0 +1,133 @@
%%%-------------------------------------------------------------------
%%% @doc Тесты административного API для модерации сущностей.
%%%
%%% Покрывает эндпоинты:
%%% PUT /v1/admin/:target_type/:id
%%%
%%% Проверяет:
%%% - заморозку/разморозку календаря
%%% - заморозку/разморозку события
%%% - скрытие/раскрытие отзыва
%%% - блокировку/разблокировку пользователя
%%% @end
%%%-------------------------------------------------------------------
-module(admin_moderation_tests).
-include_lib("eunit/include/eunit.hrl").
-export([test/0]).
%%%===================================================================
%%% Главная тестовая функция
%%%===================================================================
-spec test() -> ok.
test() ->
ct:pal("=== Admin Moderation Tests ==="),
Token = api_test_runner:get_admin_token(),
UserToken = api_test_runner:get_user_token(),
% Создаём календарь и событие
CalId = api_test_runner:create_calendar(UserToken, #{title => <<"ModTestCal">>}),
EventId = api_test_runner:create_event(UserToken, CalId, #{
title => <<"Event to moderate">>,
start_time => api_test_runner:future_date(),
duration => 60
}),
% Создаём пользователя для блокировки
UserEmail = api_test_runner:unique_email(<<"moduser">>),
UserTok = api_test_runner:register_and_login(UserEmail, <<"pass">>),
#{<<"id">> := UserId} = api_test_runner:client_get(<<"/v1/user/me">>, UserTok),
% Создаём отзыв (требуется участие)
#{<<"id">> := BookingId} = api_test_runner:client_post(
<<"/v1/events/", EventId/binary, "/bookings">>, UserTok, #{}),
api_test_runner:client_put(<<"/v1/bookings/", BookingId/binary>>, UserToken,
#{action => <<"confirm">>}),
#{<<"id">> := ReviewId} = api_test_runner:client_post(<<"/v1/reviews">>, UserTok, #{
target_type => <<"event">>,
target_id => EventId,
rating => 3,
comment => <<"Moderate me">>
}),
% Тесты модерации
test_freeze_calendar(Token, CalId),
test_unfreeze_calendar(Token, CalId),
test_freeze_event(Token, EventId),
test_unfreeze_event(Token, EventId),
test_hide_review(Token, ReviewId),
test_unhide_review(Token, ReviewId),
test_block_user(Token, UserId),
test_unblock_user(Token, UserId),
ct:pal("=== All admin moderation tests passed ==="),
ok.
%%%===================================================================
%%% Тестовые функции
%%%===================================================================
test_freeze_calendar(Token, Id) ->
ct:pal(" TEST: Freeze calendar"),
Path = <<"/v1/admin/calendar/", Id/binary>>,
Body = #{<<"action">> => <<"freeze">>, <<"reason">> => <<"Test">>},
Resp = api_test_runner:admin_put(Path, Token, Body),
?assertEqual(<<"frozen">>, maps:get(<<"status">>, Resp)),
ct:pal(" OK").
test_unfreeze_calendar(Token, Id) ->
ct:pal(" TEST: Unfreeze calendar"),
Path = <<"/v1/admin/calendar/", Id/binary>>,
Body = #{<<"action">> => <<"unfreeze">>, <<"reason">> => <<"Test">>},
Resp = api_test_runner:admin_put(Path, Token, Body),
?assertEqual(<<"active">>, maps:get(<<"status">>, Resp)),
ct:pal(" OK").
test_freeze_event(Token, Id) ->
ct:pal(" TEST: Freeze event"),
Path = <<"/v1/admin/event/", Id/binary>>,
Body = #{<<"action">> => <<"freeze">>, <<"reason">> => <<"Test">>},
Resp = api_test_runner:admin_put(Path, Token, Body),
?assertEqual(<<"frozen">>, maps:get(<<"status">>, Resp)),
ct:pal(" OK").
test_unfreeze_event(Token, Id) ->
ct:pal(" TEST: Unfreeze event"),
Path = <<"/v1/admin/event/", Id/binary>>,
Body = #{<<"action">> => <<"unfreeze">>, <<"reason">> => <<"Test">>},
Resp = api_test_runner:admin_put(Path, Token, Body),
?assertEqual(<<"active">>, maps:get(<<"status">>, Resp)),
ct:pal(" OK").
test_hide_review(Token, ReviewId) ->
ct:pal(" TEST: Hide review"),
Path = <<"/v1/admin/review/", ReviewId/binary>>,
Body = #{<<"action">> => <<"hide">>, <<"reason">> => <<"Test">>},
Resp = api_test_runner:admin_put(Path, Token, Body),
?assertEqual(<<"hidden">>, maps:get(<<"status">>, Resp)),
ct:pal(" OK").
test_unhide_review(Token, ReviewId) ->
ct:pal(" TEST: Unhide review"),
Path = <<"/v1/admin/review/", ReviewId/binary>>,
Body = #{<<"action">> => <<"unhide">>, <<"reason">> => <<"Test">>},
Resp = api_test_runner:admin_put(Path, Token, Body),
?assertEqual(<<"visible">>, maps:get(<<"status">>, Resp)),
ct:pal(" OK").
test_block_user(Token, UserId) ->
ct:pal(" TEST: Block user"),
Path = <<"/v1/admin/user/", UserId/binary>>,
Body = #{<<"action">> => <<"block">>, <<"reason">> => <<"Test">>},
Resp = api_test_runner:admin_put(Path, Token, Body),
?assertEqual(<<"blocked">>, maps:get(<<"status">>, Resp)),
ct:pal(" OK").
test_unblock_user(Token, UserId) ->
ct:pal(" TEST: Unblock user"),
Path = <<"/v1/admin/user/", UserId/binary>>,
Body = #{<<"action">> => <<"unblock">>, <<"reason">> => <<"Test">>},
Resp = api_test_runner:admin_put(Path, Token, Body),
?assertEqual(<<"active">>, maps:get(<<"status">>, Resp)),
ct:pal(" OK").

View File

@@ -0,0 +1,134 @@
%%%-------------------------------------------------------------------
%%% @doc Тесты административного API для управления жалобами.
%%%
%%% Покрывает эндпоинты:
%%% GET /v1/admin/reports
%%% GET /v1/admin/reports/:id
%%% PUT /v1/admin/reports/:id
%%%
%%% Проверяет:
%%% - получение списка жалоб
%%% - получение жалобы по ID
%%% - обновление статуса жалобы (review, dismiss)
%%% - фильтрацию по статусу и типу цели
%%% - пагинацию
%%% @end
%%%-------------------------------------------------------------------
-module(admin_reports_tests).
-include_lib("eunit/include/eunit.hrl").
-export([test/0]).
%%%===================================================================
%%% Главная тестовая функция
%%%===================================================================
-spec test() -> ok.
test() ->
ct:pal("=== Admin Reports Tests ==="),
Token = api_test_runner:get_admin_token(),
UserToken = api_test_runner:get_user_token(),
% Создаём тестовые данные: календарь, событие
CalId = api_test_runner:create_calendar(UserToken, #{title => <<"ReportsTestCal">>}),
EventId = api_test_runner:create_event(UserToken, CalId, #{
title => <<"Event for reporting">>,
start_time => api_test_runner:future_date(),
duration => 60
}),
% Создаём две жалобы от имени пользователя
#{<<"id">> := Report1Id} = api_test_runner:client_post(<<"/v1/reports">>, UserToken, #{
<<"target_type">> => <<"event">>,
<<"target_id">> => EventId,
<<"reason">> => <<"Inappropriate content">>
}),
#{<<"id">> := Report2Id} = api_test_runner:client_post(<<"/v1/reports">>, UserToken, #{
<<"target_type">> => <<"event">>,
<<"target_id">> => EventId,
<<"reason">> => <<"Spam">>
}),
test_list_reports(Token, Report1Id),
test_get_report(Token, Report1Id),
test_review_report(Token, Report1Id),
test_dismiss_report(Token, Report2Id),
test_filter_reports(Token),
test_report_pagination(Token),
ct:pal("=== All admin reports tests passed ==="),
ok.
%%%===================================================================
%%% Тестовые функции
%%%===================================================================
%% @doc GET /v1/admin/reports проверяет получение списка жалоб.
-spec test_list_reports(binary(), binary()) -> ok.
test_list_reports(Token, ReportId) ->
ct:pal(" TEST: List all reports"),
Reports = api_test_runner:admin_get(<<"/v1/admin/reports">>, Token),
?assert(is_list(Reports)),
?assert(length(Reports) >= 2),
?assert(lists:any(fun(R) -> maps:get(<<"id">>, R) =:= ReportId end, Reports)),
ct:pal(" OK: ~p reports", [length(Reports)]).
%% @doc GET /v1/admin/reports/:id проверяет получение жалобы по ID.
-spec test_get_report(binary(), binary()) -> ok.
test_get_report(Token, ReportId) ->
ct:pal(" TEST: Get report by ID"),
Path = <<"/v1/admin/reports/", ReportId/binary>>,
Report = api_test_runner:admin_get(Path, Token),
?assertEqual(ReportId, maps:get(<<"id">>, Report)),
?assertEqual(<<"pending">>, maps:get(<<"status">>, Report)),
ct:pal(" OK").
%% @doc PUT /v1/admin/reports/:id проверяет изменение статуса на reviewed.
-spec test_review_report(binary(), binary()) -> ok.
test_review_report(Token, ReportId) ->
ct:pal(" TEST: Review report (set status to reviewed)"),
Path = <<"/v1/admin/reports/", ReportId/binary>>,
Updated = api_test_runner:admin_put(Path, Token, #{<<"status">> => <<"reviewed">>}),
?assertEqual(<<"reviewed">>, maps:get(<<"status">>, Updated)),
ct:pal(" OK").
%% @doc PUT /v1/admin/reports/:id проверяет изменение статуса на dismissed.
-spec test_dismiss_report(binary(), binary()) -> ok.
test_dismiss_report(Token, ReportId) ->
ct:pal(" TEST: Dismiss report (set status to dismissed)"),
Path = <<"/v1/admin/reports/", ReportId/binary>>,
Updated = api_test_runner:admin_put(Path, Token, #{<<"status">> => <<"dismissed">>}),
?assertEqual(<<"dismissed">>, maps:get(<<"status">>, Updated)),
ct:pal(" OK").
%% @doc GET /v1/admin/reports?status=...&target_type=... проверяет фильтрацию.
-spec test_filter_reports(binary()) -> ok.
test_filter_reports(Token) ->
ct:pal(" TEST: Filter reports by status=reviewed"),
Reports = api_test_runner:admin_get(<<"/v1/admin/reports?status=reviewed">>, Token),
?assert(is_list(Reports)),
[?assertEqual(<<"reviewed">>, maps:get(<<"status">>, R)) || R <- Reports],
ct:pal(" OK: ~p reviewed reports", [length(Reports)]),
ct:pal(" TEST: Filter reports by target_type=event"),
Reports2 = api_test_runner:admin_get(<<"/v1/admin/reports?target_type=event">>, Token),
?assert(is_list(Reports2)),
[?assertEqual(<<"event">>, maps:get(<<"target_type">>, R)) || R <- Reports2],
ct:pal(" OK: ~p reports for events", [length(Reports2)]).
%% @doc GET /v1/admin/reports?limit=...&offset=... проверяет пагинацию.
-spec test_report_pagination(binary()) -> ok.
test_report_pagination(Token) ->
ct:pal(" TEST: Report pagination"),
Page1 = api_test_runner:admin_get(<<"/v1/admin/reports?limit=1&offset=0">>, Token),
?assert(length(Page1) >= 1),
Page2 = api_test_runner:admin_get(<<"/v1/admin/reports?limit=1&offset=1">>, Token),
?assert(length(Page2) >= 0),
case {Page1, Page2} of
{[First|_], [Second|_]} ->
Id1 = maps:get(<<"id">>, First),
Id2 = maps:get(<<"id">>, Second),
?assertNotEqual(Id1, Id2);
_ -> ok
end,
ct:pal(" OK").

View File

@@ -0,0 +1,136 @@
%%%-------------------------------------------------------------------
%%% @doc Тесты административного API для управления отзывами.
%%% Покрывает эндпоинты:
%%% GET /v1/admin/reviews
%%% PATCH /v1/admin/reviews
%%% GET /v1/admin/reviews/:id
%%% PUT /v1/admin/reviews/:id
%%% @end
%%%-------------------------------------------------------------------
-module(admin_reviews_tests).
-include_lib("eunit/include/eunit.hrl").
-export([test/0]).
%%%===================================================================
%%% EUnit test generator
%%%===================================================================
test() ->
ct:pal("=== Admin Reviews Tests ==="),
Token = api_test_runner:get_admin_token(),
UserToken = api_test_runner:get_user_token(),
%% Создаём тестовые данные: календарь, событие
CalId = api_test_runner:create_calendar(UserToken, #{title => <<"ReviewsTestCal">>}),
EventId = api_test_runner:create_event(UserToken, CalId, #{
title => <<"Event for review testing">>,
start_time => api_test_runner:future_date(),
duration => 60
}),
%% Создаём двух пользователей для отзывов
User1Email = api_test_runner:unique_email(<<"rev1">>),
User1Token = api_test_runner:register_and_login(User1Email, <<"pass1">>),
User2Email = api_test_runner:unique_email(<<"rev2">>),
User2Token = api_test_runner:register_and_login(User2Email, <<"pass2">>),
%% Записываем пользователей на событие и подтверждаем бронирования
#{<<"id">> := Booking1Id} = api_test_runner:client_post(
<<"/v1/events/", EventId/binary, "/bookings">>, User1Token, #{}),
#{<<"id">> := Booking2Id} = api_test_runner:client_post(
<<"/v1/events/", EventId/binary, "/bookings">>, User2Token, #{}),
ct:pal(" Confirming bookings as calendar owner..."),
api_test_runner:client_put(<<"/v1/bookings/", Booking1Id/binary>>, UserToken,
#{action => <<"confirm">>}),
api_test_runner:client_put(<<"/v1/bookings/", Booking2Id/binary>>, UserToken,
#{action => <<"confirm">>}),
%% Оставляем отзывы от имени участников
#{<<"id">> := Review1Id} = api_test_runner:client_post(<<"/v1/reviews">>, User1Token, #{
target_type => <<"event">>,
target_id => EventId,
rating => 5,
comment => <<"Great!">>
}),
#{<<"id">> := Review2Id} = api_test_runner:client_post(<<"/v1/reviews">>, User2Token, #{
target_type => <<"event">>,
target_id => EventId,
rating => 4,
comment => <<"Good">>
}),
%% Запускаем тесты
test_list_reviews(Token),
test_filter_reviews(Token, EventId),
test_bulk_update_status(Token, Review1Id, Review2Id),
test_get_review(Token, Review1Id),
test_update_review(Token, Review1Id),
test_review_pagination(Token),
ct:pal("=== All admin reviews tests passed ==="),
ok.
%%%===================================================================
%%% Тестовые функции
%%%===================================================================
%% @doc GET /v1/admin/reviews список отзывов.
test_list_reviews(Token) ->
ct:pal(" TEST: List all reviews"),
Reviews = api_test_runner:admin_get(<<"/v1/admin/reviews">>, Token),
?assert(is_list(Reviews)),
?assert(length(Reviews) >= 2),
ct:pal(" OK: ~p reviews", [length(Reviews)]).
%% @doc GET /v1/admin/reviews?target_type=event&target_id=... фильтрация по цели.
test_filter_reviews(Token, EventId) ->
ct:pal(" TEST: Filter reviews by target"),
Path = <<"/v1/admin/reviews?target_type=event&target_id=", EventId/binary>>,
Reviews = api_test_runner:admin_get(Path, Token),
?assert(is_list(Reviews)),
?assert(length(Reviews) >= 2),
[?assertEqual(EventId, maps:get(<<"target_id">>, R)) || R <- Reviews],
ct:pal(" OK: ~p reviews for event", [length(Reviews)]).
%% @doc PATCH /v1/admin/reviews массовое обновление статусов.
test_bulk_update_status(Token, Review1Id, Review2Id) ->
ct:pal(" TEST: Bulk update review statuses"),
Body = [
#{<<"id">> => Review1Id, <<"status">> => <<"visible">>},
#{<<"id">> => Review2Id, <<"status">> => <<"hidden">>}
],
#{<<"updated_count">> := Count} = api_test_runner:admin_patch(<<"/v1/admin/reviews">>, Token, Body),
?assertEqual(2, Count),
ct:pal(" OK: updated ~p reviews", [Count]).
%% @doc GET /v1/admin/reviews/:id получение отзыва по ID.
test_get_review(Token, ReviewId) ->
ct:pal(" TEST: Get review by ID"),
Path = <<"/v1/admin/reviews/", ReviewId/binary>>,
Review = api_test_runner:admin_get(Path, Token),
?assertEqual(ReviewId, maps:get(<<"id">>, Review)),
?assertEqual(<<"visible">>, maps:get(<<"status">>, Review)),
ct:pal(" OK: ~s", [maps:get(<<"comment">>, Review)]).
%% @doc PUT /v1/admin/reviews/:id обновление отзыва.
test_update_review(Token, ReviewId) ->
ct:pal(" TEST: Update review"),
Path = <<"/v1/admin/reviews/", ReviewId/binary>>,
Updates = #{<<"comment">> => <<"Updated by admin">>},
Updated = api_test_runner:admin_put(Path, Token, Updates),
?assertEqual(<<"Updated by admin">>, maps:get(<<"comment">>, Updated)),
ct:pal(" OK").
%% @doc GET /v1/admin/reviews?limit=...&offset=... пагинация.
test_review_pagination(Token) ->
ct:pal(" TEST: Review pagination"),
Page1 = api_test_runner:admin_get(<<"/v1/admin/reviews?limit=1&offset=0">>, Token),
?assertEqual(1, length(Page1)),
Page2 = api_test_runner:admin_get(<<"/v1/admin/reviews?limit=1&offset=1">>, Token),
?assert(length(Page2) >= 1),
Id1 = maps:get(<<"id">>, hd(Page1)),
Id2 = maps:get(<<"id">>, hd(Page2)),
?assertNotEqual(Id1, Id2),
ct:pal(" OK").

View File

@@ -0,0 +1,77 @@
%%%-------------------------------------------------------------------
%%% @doc Тесты административного API для получения статистики.
%%%
%%% Покрывает эндпоинты:
%%% GET /v1/admin/stats
%%%
%%% Проверяет:
%%% - получение статистики для всех четырёх ролей администраторов
%%% - для superadmin и admin наличие ключевых метрик
%%% - для moderator и support ответ непустой
%%% - работу с фильтром по датам (from, to)
%%% @end
%%%-------------------------------------------------------------------
-module(admin_stats_tests).
-include_lib("eunit/include/eunit.hrl").
-export([test/0]).
%%%===================================================================
%%% Главная тестовая функция
%%%===================================================================
-spec test() -> ok.
test() ->
ct:pal("=== Admin Stats Tests ==="),
SuperToken = api_test_runner:get_superadmin_token(),
AdminToken = api_test_runner:get_admin_token(),
ModerToken = api_test_runner:get_moderator_token(),
SupportToken = api_test_runner:get_support_token(),
test_stats_for_role("Superadmin", SuperToken, strict),
test_stats_for_role("Admin", AdminToken, strict),
test_stats_for_role("Moderator", ModerToken, loose),
test_stats_for_role("Support", SupportToken, loose),
test_stats_with_dates(SuperToken),
ct:pal("=== All admin stats tests passed ==="),
ok.
%%%===================================================================
%%% Тестовые функции
%%%===================================================================
%% @doc Проверяет получение статистики для конкретной роли.
%% strict ожидаем ключи users_total/users и events_total/events
%% loose просто убеждаемся, что ответ непустой
-spec test_stats_for_role(string(), binary(), strict | loose) -> ok.
test_stats_for_role(RoleName, Token, Strictness) ->
ct:pal(" TEST: Get stats for role ~s", [RoleName]),
Stats = api_test_runner:admin_get(<<"/v1/admin/stats">>, Token),
?assert(is_map(Stats)),
case Strictness of
strict ->
HasUsers = maps:is_key(<<"users_total">>, Stats) orelse
maps:is_key(<<"users">>, Stats),
HasEvents = maps:is_key(<<"events_total">>, Stats) orelse
maps:is_key(<<"events">>, Stats),
?assert(HasUsers orelse HasEvents);
loose ->
?assert(map_size(Stats) > 0)
end,
ct:pal(" OK: ~p keys", [length(maps:keys(Stats))]).
%% @doc GET /v1/admin/stats?from=...&to=... проверяет фильтрацию по датам.
-spec test_stats_with_dates(binary()) -> ok.
test_stats_with_dates(Token) ->
ct:pal(" TEST: Get stats with date range"),
From = <<"2026-01-01T00:00:00Z">>,
To = <<"2026-12-31T23:59:59Z">>,
Path = <<"/v1/admin/stats?from=", From/binary, "&to=", To/binary>>,
Stats = api_test_runner:admin_get(Path, Token),
?assert(is_map(Stats)),
?assert(maps:is_key(<<"users_total">>, Stats) orelse
maps:is_key(<<"users">>, Stats)),
ct:pal(" OK").

View File

@@ -0,0 +1,131 @@
%%%-------------------------------------------------------------------
%%% @doc Тесты административного API для управления подписками.
%%%
%%% Покрывает эндпоинты:
%%% GET /v1/admin/subscriptions
%%% GET /v1/admin/subscriptions/:id
%%% PUT /v1/admin/subscriptions/:id
%%% DELETE /v1/admin/subscriptions/:id
%%%
%%% Проверяет:
%%% - получение списка подписок
%%% - получение подписки по ID
%%% - обновление подписки (изменение плана, статуса)
%%% - удаление подписки
%%% - фильтрацию по статусу и плану
%%% - пагинацию
%%% @end
%%%-------------------------------------------------------------------
-module(admin_subscriptions_tests).
-include_lib("eunit/include/eunit.hrl").
-export([test/0]).
%%%===================================================================
%%% Главная тестовая функция
%%%===================================================================
-spec test() -> ok.
test() ->
ct:pal("=== Admin Subscriptions Tests ==="),
Token = api_test_runner:get_admin_token(),
UserToken = api_test_runner:get_user_token(),
% Создаём две подписки для разных проверок
#{<<"id">> := Sub1Id} = api_test_runner:client_post(<<"/v1/subscription">>, UserToken,
#{<<"action">> => <<"start_trial">>}),
% Для второй подписки создаём нового пользователя
User2Email = api_test_runner:unique_email(<<"sub2">>),
User2Token = api_test_runner:register_and_login(User2Email, <<"pass2">>),
#{<<"id">> := Sub2Id} = api_test_runner:client_post(<<"/v1/subscription">>, User2Token,
#{<<"action">> => <<"start_trial">>}),
% Теперь две подписки в системе
test_list_subscriptions(Token, Sub1Id),
test_get_subscription(Token, Sub1Id),
test_update_subscription(Token, Sub1Id),
% После обновления Sub1Id имеет план biannual и статус active
test_filter_subscriptions(Token),
test_subscription_pagination(Token),
% Удаляем вторую подписку, чтобы проверить delete
test_delete_subscription(Token, Sub2Id),
ct:pal("=== All admin subscriptions tests passed ==="),
ok.
%%%===================================================================
%%% Тестовые функции
%%%===================================================================
%% @doc GET /v1/admin/subscriptions список подписок.
-spec test_list_subscriptions(binary(), binary()) -> ok.
test_list_subscriptions(Token, SubId) ->
ct:pal(" TEST: List all subscriptions"),
Subs = api_test_runner:admin_get(<<"/v1/admin/subscriptions">>, Token),
?assert(is_list(Subs)),
?assert(length(Subs) >= 2),
?assert(lists:any(fun(S) -> maps:get(<<"id">>, S) =:= SubId end, Subs)),
ct:pal(" OK: ~p subscriptions", [length(Subs)]).
%% @doc GET /v1/admin/subscriptions/:id получение подписки по ID.
-spec test_get_subscription(binary(), binary()) -> ok.
test_get_subscription(Token, SubId) ->
ct:pal(" TEST: Get subscription by ID"),
Path = <<"/v1/admin/subscriptions/", SubId/binary>>,
Sub = api_test_runner:admin_get(Path, Token),
?assertEqual(SubId, maps:get(<<"id">>, Sub)),
?assert(maps:is_key(<<"plan">>, Sub)),
ct:pal(" OK").
%% @doc PUT /v1/admin/subscriptions/:id обновление подписки (план + статус).
-spec test_update_subscription(binary(), binary()) -> ok.
test_update_subscription(Token, SubId) ->
ct:pal(" TEST: Update subscription (change plan)"),
Path = <<"/v1/admin/subscriptions/", SubId/binary>>,
Updated = api_test_runner:admin_put(Path, Token, #{
<<"plan">> => <<"biannual">>,
<<"status">> => <<"active">>
}),
?assertEqual(<<"biannual">>, maps:get(<<"plan">>, Updated)),
?assertEqual(<<"active">>, maps:get(<<"status">>, Updated)),
ct:pal(" OK").
%% @doc DELETE /v1/admin/subscriptions/:id удаление подписки.
-spec test_delete_subscription(binary(), binary()) -> ok.
test_delete_subscription(Token, SubId) ->
ct:pal(" TEST: Delete subscription"),
Path = <<"/v1/admin/subscriptions/", SubId/binary>>,
Deleted = api_test_runner:admin_delete(Path, Token),
?assertEqual(<<"deleted">>, maps:get(<<"status">>, Deleted)),
ct:pal(" OK").
%% @doc GET /v1/admin/subscriptions?status=...&plan=... фильтрация.
-spec test_filter_subscriptions(binary()) -> ok.
test_filter_subscriptions(Token) ->
ct:pal(" TEST: Filter subscriptions by status=active"),
Subs = api_test_runner:admin_get(<<"/v1/admin/subscriptions?status=active">>, Token),
?assert(is_list(Subs)),
?assert(length(Subs) >= 1),
[?assertEqual(<<"active">>, maps:get(<<"status">>, S)) || S <- Subs],
ct:pal(" OK: ~p active subscriptions", [length(Subs)]),
ct:pal(" TEST: Filter subscriptions by plan=biannual"),
Subs2 = api_test_runner:admin_get(<<"/v1/admin/subscriptions?plan=biannual">>, Token),
?assert(is_list(Subs2)),
?assert(length(Subs2) >= 1),
[?assertEqual(<<"biannual">>, maps:get(<<"plan">>, S)) || S <- Subs2],
ct:pal(" OK: ~p biannual subscriptions", [length(Subs2)]).
%% @doc GET /v1/admin/subscriptions?limit=...&offset=... пагинация.
-spec test_subscription_pagination(binary()) -> ok.
test_subscription_pagination(Token) ->
ct:pal(" TEST: Subscription pagination"),
Page1 = api_test_runner:admin_get(<<"/v1/admin/subscriptions?limit=1&offset=0">>, Token),
?assert(length(Page1) >= 1),
Page2 = api_test_runner:admin_get(<<"/v1/admin/subscriptions?limit=1&offset=1">>, Token),
?assert(length(Page2) >= 1),
Id1 = maps:get(<<"id">>, hd(Page1)),
Id2 = maps:get(<<"id">>, hd(Page2)),
?assertNotEqual(Id1, Id2),
ct:pal(" OK").

View File

@@ -0,0 +1,153 @@
%%%-------------------------------------------------------------------
%%% @doc Тесты административного API для управления тикетами.
%%%
%%% Покрывает эндпоинты:
%%% GET /v1/admin/tickets
%%% GET /v1/admin/tickets/:id
%%% PUT /v1/admin/tickets/:id
%%% DELETE /v1/admin/tickets/:id
%%%
%%% Проверяет:
%%% - получение списка тикетов
%%% - получение тикета по ID
%%% - разрешение (resolve) и закрытие (close) тикета
%%% - назначение исполнителя
%%% - удаление тикета
%%% - фильтрацию по статусу
%%% - пагинацию
%%% @end
%%%-------------------------------------------------------------------
-module(admin_tickets_tests).
-include_lib("eunit/include/eunit.hrl").
-export([test/0]).
%%%===================================================================
%%% Главная тестовая функция
%%%===================================================================
-spec test() -> ok.
test() ->
ct:pal("=== Admin Tickets Tests ==="),
Token = api_test_runner:get_admin_token(),
UserToken = api_test_runner:get_user_token(),
% Создаём два тикета для разных проверок
Ticket1 = api_test_runner:client_post(<<"/v1/tickets">>, UserToken,
#{<<"error_message">> => <<"Test bug">>, <<"stacktrace">> => <<"trace">>}),
#{<<"id">> := Ticket1Id} = Ticket1,
Ticket2 = api_test_runner:client_post(<<"/v1/tickets">>, UserToken,
#{<<"error_message">> => <<"Another bug">>, <<"stacktrace">> => <<"trace2">>}),
#{<<"id">> := Ticket2Id} = Ticket2,
test_list_tickets(Token, Ticket1Id),
test_get_ticket(Token, Ticket1Id),
test_resolve_ticket(Token, Ticket1Id),
test_close_ticket(Token, Ticket1Id),
test_assign_ticket(Token, Ticket1Id),
test_filter_tickets(Token),
test_ticket_pagination(Token, Ticket2Id),
test_delete_ticket(Token, Ticket1Id),
test_delete_ticket(Token, Ticket2Id),
ct:pal("=== All admin tickets tests passed ==="),
ok.
%%%===================================================================
%%% Тестовые функции
%%%===================================================================
%% @doc GET /v1/admin/tickets проверяет получение списка тикетов.
%% Убеждается, что список не пуст и содержит созданный тикет.
-spec test_list_tickets(binary(), binary()) -> ok.
test_list_tickets(Token, TicketId) ->
ct:pal(" TEST: List all tickets"),
Tickets = api_test_runner:admin_get(<<"/v1/admin/tickets">>, Token),
?assert(is_list(Tickets)),
?assert(length(Tickets) >= 1),
?assert(lists:any(fun(T) -> maps:get(<<"id">>, T) =:= TicketId end, Tickets)),
ct:pal(" OK: ~p tickets", [length(Tickets)]).
%% @doc GET /v1/admin/tickets/:id проверяет получение тикета по ID.
%% Убеждается, что статус нового тикета open.
-spec test_get_ticket(binary(), binary()) -> ok.
test_get_ticket(Token, TicketId) ->
ct:pal(" TEST: Get ticket by ID"),
Path = <<"/v1/admin/tickets/", TicketId/binary>>,
Ticket = api_test_runner:admin_get(Path, Token),
?assertEqual(TicketId, maps:get(<<"id">>, Ticket)),
?assertEqual(<<"open">>, maps:get(<<"status">>, Ticket)),
ct:pal(" OK").
%% @doc PUT /v1/admin/tickets/:id разрешение тикета (resolve).
%% Ожидается статус resolved после успешного выполнения.
-spec test_resolve_ticket(binary(), binary()) -> ok.
test_resolve_ticket(Token, TicketId) ->
ct:pal(" TEST: Resolve ticket"),
Path = <<"/v1/admin/tickets/", TicketId/binary>>,
Updated = api_test_runner:admin_put(Path, Token, #{
<<"status">> => <<"resolved">>,
<<"resolution_note">> => <<"Fixed">>
}),
?assertEqual(<<"resolved">>, maps:get(<<"status">>, Updated)),
?assertEqual(<<"Fixed">>, maps:get(<<"resolution_note">>, Updated)),
ct:pal(" OK").
%% @doc PUT /v1/admin/tickets/:id закрытие тикета (close).
-spec test_close_ticket(binary(), binary()) -> ok.
test_close_ticket(Token, TicketId) ->
ct:pal(" TEST: Close ticket"),
Path = <<"/v1/admin/tickets/", TicketId/binary>>,
Updated = api_test_runner:admin_put(Path, Token, #{<<"status">> => <<"closed">>}),
?assertEqual(<<"closed">>, maps:get(<<"status">>, Updated)),
ct:pal(" OK").
%% @doc PUT /v1/admin/tickets/:id назначение исполнителя.
-spec test_assign_ticket(binary(), binary()) -> ok.
test_assign_ticket(Token, TicketId) ->
ct:pal(" TEST: Assign ticket"),
Me = api_test_runner:admin_get(<<"/v1/admin/me">>, Token),
AdminId = maps:get(<<"id">>, Me),
Path = <<"/v1/admin/tickets/", TicketId/binary>>,
Updated = api_test_runner:admin_put(Path, Token, #{<<"assigned_to">> => AdminId}),
?assertEqual(AdminId, maps:get(<<"assigned_to">>, Updated)),
ct:pal(" OK").
%% @doc DELETE /v1/admin/tickets/:id удаление тикета.
-spec test_delete_ticket(binary(), binary()) -> ok.
test_delete_ticket(Token, TicketId) ->
ct:pal(" TEST: Delete ticket"),
Path = <<"/v1/admin/tickets/", TicketId/binary>>,
Deleted = api_test_runner:admin_delete(Path, Token),
?assertEqual(<<"deleted">>, maps:get(<<"status">>, Deleted)),
ct:pal(" OK").
%% @doc GET /v1/admin/tickets?status=... проверяет фильтрацию по статусу open.
%% Использует второй тикет, который всё ещё open.
-spec test_filter_tickets(binary()) -> ok.
test_filter_tickets(Token) ->
ct:pal(" TEST: Filter tickets by status=open"),
Tickets = api_test_runner:admin_get(<<"/v1/admin/tickets?status=open">>, Token),
?assert(is_list(Tickets)),
?assert(length(Tickets) >= 1),
[?assertEqual(<<"open">>, maps:get(<<"status">>, T)) || T <- Tickets],
ct:pal(" OK: ~p open tickets", [length(Tickets)]).
%% @doc GET /v1/admin/tickets?limit=...&offset=... проверяет пагинацию.
%% Использует второй тикет как гарантированно существующий.
-spec test_ticket_pagination(binary(), binary()) -> ok.
test_ticket_pagination(Token, _TicketId) ->
ct:pal(" TEST: Ticket pagination"),
Page1 = api_test_runner:admin_get(<<"/v1/admin/tickets?limit=1&offset=0">>, Token),
?assert(length(Page1) >= 1),
Page2 = api_test_runner:admin_get(<<"/v1/admin/tickets?limit=1&offset=1">>, Token),
?assert(length(Page2) >= 0),
case {Page1, Page2} of
{[First|_], [Second|_]} ->
Id1 = maps:get(<<"id">>, First),
Id2 = maps:get(<<"id">>, Second),
?assertNotEqual(Id1, Id2);
_ -> ok
end,
ct:pal(" OK").

View File

@@ -0,0 +1,67 @@
%%%-------------------------------------------------------------------
%%% @doc Тесты административного API для управления пользователями.
%%% @end
%%%-------------------------------------------------------------------
-module(admin_users_tests).
-include_lib("eunit/include/eunit.hrl").
-export([test/0]).
test() ->
ct:pal("=== Admin Users Tests ==="),
Token = api_test_runner:get_admin_token(),
%% Создаём тестового пользователя
Email = api_test_runner:unique_email(<<"usertest">>),
UserToken = api_test_runner:register_and_login(Email, <<"testpass">>),
Me = api_test_runner:client_get(<<"/v1/user/me">>, UserToken),
UserId = maps:get(<<"id">>, Me),
test_list_users(Token),
test_get_user(Token, UserId),
test_update_user(Token, UserId),
test_filter_users(Token),
test_delete_user(Token, UserId),
ct:pal("=== All admin users tests passed ==="),
ok.
test_list_users(Token) ->
ct:pal(" TEST: List all users"),
Users = api_test_runner:admin_get(<<"/v1/admin/users">>, Token),
?assert(is_list(Users)),
?assert(length(Users) >= 1),
ct:pal(" OK: ~p users", [length(Users)]).
test_get_user(Token, UserId) ->
ct:pal(" TEST: Get user by ID"),
Path = <<"/v1/admin/users/", UserId/binary>>,
User = api_test_runner:admin_get(Path, Token),
?assertEqual(UserId, maps:get(<<"id">>, User)),
ct:pal(" OK: ~s", [maps:get(<<"email">>, User)]).
test_update_user(Token, UserId) ->
ct:pal(" TEST: Update user (change role and status)"),
Path = <<"/v1/admin/users/", UserId/binary>>,
Updated = api_test_runner:admin_put(Path, Token, #{
<<"role">> => <<"bot">>,
<<"status">> => <<"frozen">>,
<<"reason">> => <<"Test freeze">>
}),
?assertEqual(<<"bot">>, maps:get(<<"role">>, Updated)),
?assertEqual(<<"frozen">>, maps:get(<<"status">>, Updated)),
ct:pal(" OK").
test_filter_users(Token) ->
ct:pal(" TEST: Filter users by status=frozen"),
Users = api_test_runner:admin_get(<<"/v1/admin/users?status=frozen">>, Token),
?assert(is_list(Users)),
[?assertEqual(<<"frozen">>, maps:get(<<"status">>, U)) || U <- Users],
ct:pal(" OK: ~p frozen users", [length(Users)]).
test_delete_user(Token, UserId) ->
ct:pal(" TEST: Delete (soft-delete) user"),
Path = <<"/v1/admin/users/", UserId/binary>>,
Deleted = api_test_runner:admin_delete(Path, Token),
?assertEqual(<<"deleted">>, maps:get(<<"status">>, Deleted)),
ct:pal(" OK").

View File

@@ -0,0 +1,258 @@
-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, 80}
end
end;
_ -> {ok, 80}
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.

View File

@@ -1,510 +0,0 @@
-module(api_admin_tests).
-export([test/0]).
%% Учётные данные по умолчанию
-define(FALLBACK_ADMIN_SUPER_EMAIL, <<"superadmin@eventhub.local">>).
-define(FALLBACK_ADMIN_SUPER_PASSWORD, <<"123456">>).
-define(FALLBACK_ADMIN_MODER_EMAIL, <<"moderator@eventhub.local">>).
-define(FALLBACK_ADMIN_MODER_PASSWORD, <<"123456">>).
-define(FALLBACK_ADMIN_SUPPORT_EMAIL, <<"support@eventhub.local">>).
-define(FALLBACK_ADMIN_SUPPORT_PASSWORD,<<"123456">>).
test() ->
ct:pal("Testing admin panel API...~n"),
AdminURL = api_test_runner:get_admin_url(),
UserURL = api_test_runner:get_base_url(),
% Получаем токен суперадмина
AdminToken = api_test_runner:get_admin_token(),
%% TEST 1: Admin healthcheck (public)
ct:pal(" TEST 1: Admin healthcheck... "),
{ok, {{_, 200, _}, _, _}} = httpc:request(get, {AdminURL ++ "/v1/admin/health", []}, [], []),
ct:pal("OK~n"),
%% TEST 2: Admin login (дополнительная проверка)
ct:pal(" TEST 2: Admin login (attempt)... "),
LoginBody = jsx:encode(#{
<<"email">> => ?FALLBACK_ADMIN_SUPER_EMAIL,
<<"password">> => ?FALLBACK_ADMIN_SUPER_PASSWORD
}),
case httpc:request(post, {AdminURL ++ "/v1/admin/login", [], "application/json", LoginBody}, [], []) of
{ok, {{_, 200, _}, _, _}} -> ct:pal("OK (logged in)~n");
_ -> ct:pal("SKIPPED (credentials not found, using runner token)~n")
end,
%% TEST 3: Admin stats (superadmin)
ct:pal(" TEST 3: Admin stats for role... "),
SuperadminToken = api_test_runner:login_custom_admin(?FALLBACK_ADMIN_SUPER_EMAIL, ?FALLBACK_ADMIN_SUPER_PASSWORD),
ModeratorToken = api_test_runner:login_custom_admin(?FALLBACK_ADMIN_MODER_EMAIL, ?FALLBACK_ADMIN_MODER_PASSWORD),
SupportToken = api_test_runner:login_custom_admin(?FALLBACK_ADMIN_SUPPORT_EMAIL, ?FALLBACK_ADMIN_SUPPORT_PASSWORD),
ct:pal(" Admin stats (superadmin)... "),
{ok, {{_, 200, _}, _, StatsResp1}} = httpc:request(get, {AdminURL ++ "/v1/admin/stats", [{"Authorization", "Bearer " ++ binary_to_list(SuperadminToken)}]}, [], []),
Stats1 = jsx:decode(list_to_binary(StatsResp1), [return_maps]),
ct:pal(" OK (Stats 1: ~p)~n", [Stats1]),
true = map_size(Stats1) > 0,
ct:pal(" Admin stats (admin)... "),
{ok, {{_, 200, _}, _, StatsResp2}} = httpc:request(get, {AdminURL ++ "/v1/admin/stats", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []),
Stats2 = jsx:decode(list_to_binary(StatsResp2), [return_maps]),
ct:pal(" OK (Stats 1: ~p)~n", [Stats2]),
true = map_size(Stats2) > 0,
ct:pal(" Admin stats (moderator)... "),
{ok, {{_, 200, _}, _, StatsResp3}} = httpc:request(get, {AdminURL ++ "/v1/admin/stats", [{"Authorization", "Bearer " ++ binary_to_list(ModeratorToken)}]}, [], []),
Stats3 = jsx:decode(list_to_binary(StatsResp3), [return_maps]),
ct:pal(" OK (Stats 1: ~p)~n", [Stats3]),
true = map_size(Stats3) > 0,
ct:pal(" Admin stats (support)... "),
{ok, {{_, 200, _}, _, StatsResp4}} = httpc:request(get, {AdminURL ++ "/v1/admin/stats", [{"Authorization", "Bearer " ++ binary_to_list(SupportToken)}]}, [], []),
Stats4 = jsx:decode(list_to_binary(StatsResp4), [return_maps]),
ct:pal(" OK (Stats 1: ~p)~n", [Stats4]),
true = map_size(Stats4) > 0,
%% TEST 4: List users
ct:pal(" TEST 4: List users... "),
{ok, {{_, 200, _}, _, _}} = httpc:request(get, {AdminURL ++ "/v1/admin/users", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []),
ct:pal("OK~n"),
%% TEST 5: Get user by ID
ct:pal(" TEST 5: Get user by ID... "),
UserId = api_test_runner:get_user_id(),
{ok, {{_, 200, _}, _, _}} = httpc:request(get, {AdminURL ++ "/v1/admin/users/" ++ binary_to_list(UserId), [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []),
ct:pal("OK~n"),
%% TEST 6: List reports
ct:pal(" TEST 6: List reports... "),
{ok, {{_, 200, _}, _, _}} = httpc:request(get, {AdminURL ++ "/v1/admin/reports", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []),
ct:pal("OK~n"),
%% ── TEST 7: Full moderation flow (create event, report, resolve) ──
ct:pal(" TEST 7: Moderation flow... "),
UserToken = api_test_runner:get_user_token(),
CalId = api_test_runner:create_calendar(UserToken, #{title => <<"ModerationTest">>}),
EventId = api_test_runner:create_event(UserToken, CalId, #{
title => <<"Event to report">>,
start_time => api_SUITE:future_date(),
duration => 60
}),
% Подаём жалобу на это событие
CreateBody = jsx:encode(#{
<<"target_type">> => <<"event">>,
<<"target_id">> => EventId,
<<"reason">> => <<"Inappropriate content">>
}),
{ok, {{_, 201, _}, _, CreateResp}} = httpc:request(post, {UserURL ++ "/v1/reports", [{"Authorization", "Bearer " ++ binary_to_list(UserToken)}], "application/json", CreateBody}, [], []),
#{<<"id">> := ReportId} = jsx:decode(list_to_binary(CreateResp), [return_maps]),
% Администратор изменяет статус жалобы
EditBody = jsx:encode(#{
<<"status">> => <<"reviewed">>,
<<"reason">> => <<"Issue resolved">>
}),
{ok, {{_, 200, _}, _, _}} = httpc:request(put, {AdminURL ++ "/v1/admin/reports/" ++ binary_to_list(ReportId), [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}], "application/json", EditBody}, [], []),
ct:pal("OK~n"),
%% TEST 8: List banned words
ct:pal(" TEST 8: List banned words... "),
{ok, {{_, 200, _}, _, _}} = httpc:request(get, {AdminURL ++ "/v1/admin/banned-words", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []),
ct:pal("OK~n"),
%% TEST 9: Add banned word
ct:pal(" TEST 9: Add banned word... "),
BannedWordBody = jsx:encode(#{<<"word">> => <<"test_banned_word">>}),
{ok, {{_, 201, _}, _, _}} = httpc:request(post, {AdminURL ++ "/v1/admin/banned-words", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}], "application/json", BannedWordBody}, [], []),
ct:pal("OK~n"),
%% TEST 10: Delete banned word
ct:pal(" TEST 10: Delete banned word... "),
{ok, {{_, 200, _}, _, _}} = httpc:request(delete, {AdminURL ++ "/v1/admin/banned-words/test_banned_word", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []),
ct:pal("OK~n"),
%% TEST 11: List tickets
ct:pal(" TEST 11: List tickets... "),
{ok, {{_, 200, _}, _, _}} = httpc:request(get, {AdminURL ++ "/v1/admin/tickets", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []),
ct:pal("OK~n"),
%% TEST 12: Create ticket
ct:pal(" TEST 12: Create ticket... "),
TicketBody = jsx:encode(#{
<<"error_message">> => <<"Test error">>,
<<"stacktrace">> => <<"trace">>
}),
{ok, {{_, 201, _}, _, TicketResp}} = httpc:request(post, {UserURL ++ "/v1/tickets", [{"Authorization", "Bearer " ++ binary_to_list(UserToken)}], "application/json", TicketBody}, [], []),
#{<<"id">> := TicketId} = jsx:decode(list_to_binary(TicketResp), [return_maps]),
ct:pal(" OK (TicketId: ~p)~n", [TicketId]),
ct:pal("OK~n"),
%% TEST 13: Get ticket by ID
ct:pal(" TEST 13: Get ticket by ID... "),
{ok, {{_, 200, _}, _, _}} = httpc:request(get, {AdminURL ++ "/v1/admin/tickets/" ++ binary_to_list(TicketId), [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []),
ct:pal("OK~n"),
%% TEST 14: Update ticket
ct:pal(" TEST 14: Update ticket... "),
UpdateTicketBody = jsx:encode(#{<<"status">> => <<"closed">>}),
{ok, {{_, 200, _}, _, _}} = httpc:request(put, {AdminURL ++ "/v1/admin/tickets/" ++ binary_to_list(TicketId), [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}], "application/json", UpdateTicketBody}, [], []),
ct:pal("OK~n"),
%% TEST 15: Delete ticket
ct:pal(" TEST 15: Delete ticket... "),
{ok, {{_, 200, _}, _, _}} = httpc:request(delete, {AdminURL ++ "/v1/admin/tickets/" ++ binary_to_list(TicketId), [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []),
ct:pal("OK~n"),
%% TEST 16: Ticket stats
ct:pal(" TEST 16: Ticket stats... "),
{ok, {{_, 200, _}, _, _}} = httpc:request(get, {AdminURL ++ "/v1/admin/tickets/stats", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []),
ct:pal("OK~n"),
%% TEST 17: List subscriptions
ct:pal(" TEST 17: List subscriptions... "),
{ok, {{_, 200, _}, _, _}} = httpc:request(get, {AdminURL ++ "/v1/admin/subscriptions", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []),
ct:pal("OK~n"),
%% TEST 18: Create subscription
ct:pal(" TEST 18: Create subscription... "),
SubBody = jsx:encode(#{action => <<"activate">>, plan => <<"monthly">>, payment_info => #{card => <<"4242">>}}),
{ok, {{_, 201, _}, _, SubResp}} = httpc:request(post, {UserURL ++ "/v1/subscription", [{"Authorization", "Bearer " ++ binary_to_list(UserToken)}], "application/json", SubBody}, [], []),
#{<<"id">> := SubId} = jsx:decode(list_to_binary(SubResp), [return_maps]),
ct:pal("OK~n"),
%% TEST 19: Get subscription by ID
ct:pal(" TEST 19: Get subscription by ID... "),
{ok, {{_, 200, _}, _, _}} = httpc:request(get, {AdminURL ++ "/v1/admin/subscriptions/" ++ binary_to_list(SubId), [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []),
ct:pal("OK~n"),
%% TEST 20: Update subscription
ct:pal(" TEST 20: Update subscription... "),
UpdateSubBody = jsx:encode(#{<<"status">> => <<"cancelled">>}),
{ok, {{_, 200, _}, _, _}} = httpc:request(put, {AdminURL ++ "/v1/admin/subscriptions/" ++ binary_to_list(SubId), [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}], "application/json", UpdateSubBody}, [], []),
ct:pal("OK~n"),
%% TEST 21: Delete subscription
ct:pal(" TEST 21: Delete subscription... "),
{ok, {{_, 200, _}, _, _}} = httpc:request(delete, {AdminURL ++ "/v1/admin/subscriptions/" ++ binary_to_list(SubId), [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []),
ct:pal("OK~n"),
%% TEST 22: Moderation - block user
ct:pal(" TEST 22: Moderation - block user... "),
ModBody = jsx:encode(#{
<<"action">> => <<"block">>,
<<"reason">> => <<"test">>
}),
{ok, {{_, 200, _}, _, _}} = httpc:request(put, {AdminURL ++ "/v1/admin/user/" ++ binary_to_list(UserId), [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}], "application/json", ModBody}, [], []),
ct:pal("OK~n"),
%% TEST 23: Moderation - unblock user
ct:pal(" TEST 23: Moderation - unblock user... "),
UnblockBody = jsx:encode(#{
<<"action">> => <<"unblock">>,
<<"reason">> => <<"restore">>
}),
{ok, {{_, 200, _}, _, _}} = httpc:request(put, {AdminURL ++ "/v1/admin/user/" ++ binary_to_list(UserId), [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}], "application/json", UnblockBody}, [], []),
ct:pal("OK~n"),
%% ========================================================
%% Admin Reviews list tests
%% ========================================================
%% Подготовка тестовых данных для отзывов
ct:pal(" Preparing test data for reviews... "),
UserToken = api_test_runner:get_user_token(),
% Создаем календарь и событие (отдельные переменные, чтобы не перекрыть TEST 7)
RevCalId = api_test_runner:create_calendar(UserToken, #{title => <<"ReviewsTest">>}),
RevEventId = api_test_runner:create_event(UserToken, RevCalId, #{
title => <<"Event for review testing">>,
start_time => api_SUITE:future_date(),
duration => 60
}),
ct:pal("OK (calendar: ~s, event: ~s)~n", [RevCalId, RevEventId]),
ParticipantEmail = api_test_runner:unique_email(<<"rev_1">>),
ParticipantEmail2 = api_test_runner:unique_email(<<"rev_2">>),
ParticipantToken = api_test_runner:register_and_login(ParticipantEmail, <<"part123">>),
ParticipantToken2 = api_test_runner:register_and_login(ParticipantEmail2, <<"part123">>),
% Создаём и подтверждаем бронирование
BookingId = api_test_runner:extract_json(
api_test_runner:http_post("/v1/events/" ++ binary_to_list(RevEventId) ++ "/bookings", #{}, ParticipantToken), <<"id">>),
api_test_runner:http_put("/v1/bookings/" ++ binary_to_list(BookingId), #{action => <<"confirm">>}, UserToken),
Booking2Id = api_test_runner:extract_json(
api_test_runner:http_post("/v1/events/" ++ binary_to_list(RevEventId) ++ "/bookings", #{}, ParticipantToken2), <<"id">>),
api_test_runner:http_put("/v1/bookings/" ++ binary_to_list(Booking2Id), #{action => <<"confirm">>}, UserToken),
ReviewId = api_test_runner:extract_json(
api_test_runner:http_post("/v1/reviews",
#{target_type => <<"event">>, target_id => RevEventId, rating => 5, comment => <<"Great!">>},
ParticipantToken), <<"id">>),
ct:pal(" Review2Id: ~p~n", [ReviewId]),
Review2Id = api_test_runner:extract_json(
api_test_runner:http_post("/v1/reviews",
#{target_type => <<"event">>, target_id => RevEventId, rating => 5, comment => <<"Great!">>},
ParticipantToken2), <<"id">>),
ct:pal(" Review2Id: ~p~n", [Review2Id]),
%% TEST 24: List all reviews (GET /v1/admin/reviews)
ct:pal(" TEST 24: List all reviews... "),
{ok, {{_, 200, _}, _, ListReviewsResp}} = httpc:request(get, {AdminURL ++ "/v1/admin/reviews", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []),
ReviewsList = jsx:decode(list_to_binary(ListReviewsResp), [return_maps]),
true = is_list(ReviewsList),
ct:pal(" OK (count: ~p)~n", [length(ReviewsList)]),
%% TEST 25: List reviews with filters (GET /v1/admin/reviews?target_type=event&target_id=...)
ct:pal(" TEST 25: List reviews with filters... "),
FilterURL = AdminURL ++ "/v1/admin/reviews?target_type=event&target_id=" ++ binary_to_list(RevEventId),
{ok, {{_, 200, _}, _, FilteredResp}} = httpc:request(get, {FilterURL, [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []),
FilteredList = jsx:decode(list_to_binary(FilteredResp), [return_maps]),
ct:pal(" OK (filtered count: ~p)~n", [length(FilteredList)]),
%% TEST 26: Bulk update review statuses (PATCH /v1/admin/reviews)
ct:pal(" TEST 26: Bulk update review statuses... "),
case ReviewsList of
[FirstReview, SecondReview | _] ->
FirstId = maps:get(<<"id">>, FirstReview),
SecondId = maps:get(<<"id">>, SecondReview),
PatchBody = jsx:encode([
#{<<"id">> => FirstId, <<"status">> => <<"visible">>},
#{<<"id">> => SecondId, <<"status">> => <<"hidden">>}
]),
ct:pal(" OK (PatchBody: ~p)~n", [PatchBody]),
{ok, {{_, 200, _}, _, PatchResp}} = httpc:request(patch, {AdminURL ++ "/v1/admin/reviews", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}], "application/json", PatchBody}, [], []),
#{<<"updated_count">> := UpdatedCount} = jsx:decode(list_to_binary(PatchResp), [return_maps]),
true = (UpdatedCount =:= 2),
ct:pal(" OK (updated: ~p)~n", [UpdatedCount]);
_ ->
ct:pal("SKIPPED (not enough reviews for bulk update)~n")
end,
%% TEST 27: Method not allowed (POST /v1/admin/reviews → 405)
ct:pal(" TEST 27: POST method not allowed... "),
{ok, {{_, 405, _}, _, _}} = httpc:request(post, {AdminURL ++ "/v1/admin/reviews", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}], "application/json", <<"{}">>}, [], []),
ct:pal("OK~n"),
%% ========================================================
%% Admin Events tests
%% ========================================================
FutureDate = api_SUITE:future_date(),
FutureDateStr = binary_to_list(FutureDate),
%% TEST 28: List all events (GET /v1/admin/events)
ct:pal(" TEST 28: List all events... "),
{ok, {{_, 200, _}, _, EventsListResp}} =
httpc:request(get, {AdminURL ++ "/v1/admin/events",
[{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]},
[], []),
EventsList = jsx:decode(list_to_binary(EventsListResp), [return_maps]),
true = is_list(EventsList),
ct:pal(" OK (count: ~p)~n", [length(EventsList)]),
%% TEST 29: List events with date filters
ct:pal(" TEST 29: List events with date filters... "),
FilterEventsURL = AdminURL ++ "/v1/admin/events?from=" ++ FutureDateStr ++
"&to=" ++ FutureDateStr,
{ok, {{_, 200, _}, _, FilteredEventsResp}} =
httpc:request(get, {FilterEventsURL,
[{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]},
[], []),
FilteredEventsList = jsx:decode(list_to_binary(FilteredEventsResp), [return_maps]),
true = is_list(FilteredEventsList),
ct:pal(" OK (filtered count: ~p)~n", [length(FilteredEventsList)]),
%% TEST 30: Get event by ID (GET /v1/admin/events/:id)
ct:pal(" TEST 30: Get event by ID... "),
{ok, {{_, 200, _}, _, EventByIdResp}} =
httpc:request(get, {AdminURL ++ "/v1/admin/events/" ++ binary_to_list(EventId),
[{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]},
[], []),
#{<<"id">> := EventId} = jsx:decode(list_to_binary(EventByIdResp), [return_maps]),
ct:pal(" OK (id: ~s)~n", [binary_to_list(EventId)]),
%% TEST 31: Update event by ID (PUT /v1/admin/events/:id)
ct:pal(" TEST 31: Update event by ID... "),
UpdateEventBody = jsx:encode(#{
<<"title">> => <<"Updated by admin">>,
<<"description">> => <<"Admin test update">>
}),
{ok, {{_, 200, _}, _, UpdateEventResp}} =
httpc:request(put, {AdminURL ++ "/v1/admin/events/" ++ binary_to_list(EventId),
[{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}],
"application/json", UpdateEventBody},
[], []),
#{<<"title">> := <<"Updated by admin">>} =
jsx:decode(list_to_binary(UpdateEventResp), [return_maps]),
ct:pal(" OK~n"),
%% TEST 32: Delete event by ID (DELETE /v1/admin/events/:id)
ct:pal(" TEST 32: Delete event by ID... "),
{ok, {{_, 200, _}, _, DeleteResp}} =
httpc:request(delete, {AdminURL ++ "/v1/admin/events/" ++ binary_to_list(EventId),
[{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]},
[], []),
#{<<"status">> := <<"deleted">>} = jsx:decode(list_to_binary(DeleteResp), [return_maps]),
ct:pal(" OK (status deleted)~n"),
%% TEST 33: Method not allowed (POST /v1/admin/events → 405)
ct:pal(" TEST 33: POST method not allowed... "),
{ok, {{_, 405, _}, _, _}} =
httpc:request(post, {AdminURL ++ "/v1/admin/events",
[{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}],
"application/json", <<"{}">>},
[], []),
ct:pal("OK~n"),
%% ========================================================
%% Extended Admin Events Search & Filter Tests
%% ========================================================
%% ── Подготовка изолированных данных ──
ct:pal(" Preparing isolated search test data... "),
UserToken = api_test_runner:get_user_token(),
SearchCalId = api_test_runner:create_calendar(UserToken, #{title => <<"SearchTestCal">>}),
SearchCalIdStr = binary_to_list(SearchCalId),
AlphaId = api_test_runner:create_event(UserToken, SearchCalId, #{
title => <<"Test Event Alpha">>,
start_time => api_SUITE:future_date(),
duration => 60
}),
BetaId = api_test_runner:create_event(UserToken, SearchCalId, #{
title => <<"Beta Event">>,
start_time => api_SUITE:future_date(),
duration => 60
}),
_AlphaConfId = api_test_runner:create_event(UserToken, SearchCalId, #{
title => <<"Alpha Conference">>,
start_time => api_SUITE:future_date(),
duration => 60
}),
% Отменяем BetaId через административный эндпоинт (PUT /v1/admin/events/:id)
ct:pal(" Cancelling Beta Event (admin)... "),
{ok, {{_, 200, _}, _, _}} =
httpc:request(put, {AdminURL ++ "/v1/admin/events/" ++ binary_to_list(BetaId),
[{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}],
"application/json", jsx:encode(#{<<"status">> => <<"cancelled">>})}, [], []),
ct:pal("OK~n"),
%% ── TEST 34: Filter by status=active ──
ct:pal(" TEST 34: Filter events by status=active... "),
{ok, {{_, 200, _}, _, Body34}} =
httpc:request(get, {AdminURL ++ "/v1/admin/events?calendar_id=" ++ SearchCalIdStr ++ "&status=active",
[{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]},
[], []),
Events34 = jsx:decode(list_to_binary(Body34), [return_maps]),
ct:pal("DEBUG: events34 count = ~p", [length(Events34)]),
ct:pal("DEBUG: events34 = ~p", [Events34]),
true = (length(Events34) >= 2),
Ids34 = [maps:get(<<"id">>, E) || E <- Events34],
ct:pal("OK (count: ~p, ids: ~p)~n", [length(Events34), Ids34]),
%% ── TEST 35: Filter by status=cancelled ──
ct:pal(" TEST 35: Filter events by status=cancelled... "),
{ok, {{_, 200, _}, _, Body35}} =
httpc:request(get, {AdminURL ++ "/v1/admin/events?calendar_id=" ++ SearchCalIdStr ++ "&status=cancelled",
[{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]},
[], []),
Events35 = jsx:decode(list_to_binary(Body35), [return_maps]),
ct:pal("DEBUG: Events35 count = ~p", [length(Events35)]),
ct:pal("DEBUG: Events35 = ~p", [Events35]),
true = (length(Events35) >= 1),
ct:pal("OK (count: ~p)~n", [length(Events35)]),
%% ── TEST 36: Filter by status=all ──
ct:pal(" TEST 36: Filter events by status=all... "),
{ok, {{_, 200, _}, _, Body36}} =
httpc:request(get, {AdminURL ++ "/v1/admin/events?calendar_id=" ++ SearchCalIdStr ++ "&status=all",
[{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]},
[], []),
Events36 = jsx:decode(list_to_binary(Body36), [return_maps]),
ct:pal("DEBUG: Events36 count = ~p", [length(Events36)]),
ct:pal("DEBUG: Events36 = ~p", [Events36]),
true = (length(Events36) >= 3),
ct:pal("OK (count: ~p)~n", [length(Events36)]),
%% ── TEST 37: Filter by calendar_id ──
ct:pal(" TEST 37: Filter by calendar_id... "),
{ok, {{_, 200, _}, _, Body37}} =
httpc:request(get, {AdminURL ++ "/v1/admin/events?calendar_id=" ++ SearchCalIdStr,
[{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]},
[], []),
Events37 = jsx:decode(list_to_binary(Body37), [return_maps]),
ct:pal("DEBUG: Events37 count = ~p", [length(Events37)]),
ct:pal("DEBUG: Events37 = ~p", [Events37]),
true = (length(Events37) >= 3),
ct:pal("OK (count: ~p)~n", [length(Events37)]),
%% ── TEST 38: Exact title match ──
ct:pal(" TEST 38: Exact title match... "),
{ok, {{_, 200, _}, _, Body38}} =
httpc:request(get, {AdminURL ++ "/v1/admin/events?calendar_id=" ++ SearchCalIdStr ++ "&title=Test%20Event%20Alpha",
[{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]},
[], []),
Events38 = jsx:decode(list_to_binary(Body38), [return_maps]),
ct:pal("DEBUG: Events38 count = ~p", [length(Events38)]),
ct:pal("DEBUG: Events38 = ~p", [Events38]),
1 = length(Events38),
#{<<"id">> := AlphaId} = hd(Events38),
ct:pal("OK~n"),
%% ── TEST 39: Substring search (q) ──
ct:pal(" TEST 39: Substring search (q=Alpha)... "),
{ok, {{_, 200, _}, _, Body39}} =
httpc:request(get, {AdminURL ++ "/v1/admin/events?calendar_id=" ++ SearchCalIdStr ++ "&q=Alpha",
[{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]},
[], []),
Events39 = jsx:decode(list_to_binary(Body39), [return_maps]),
ct:pal("DEBUG: Events39 count = ~p", [length(Events39)]),
ct:pal("DEBUG: Events39 = ~p", [Events39]),
true = (length(Events39) >= 2),
Titles39 = [maps:get(<<"title">>, E) || E <- Events39],
ct:pal("OK (count: ~p, titles: ~p)~n", [length(Events39), Titles39]),
%% ── TEST 40: Combined filters (calendar_id + status) ──
ct:pal(" TEST 40: Combined filters (calendar+status)... "),
{ok, {{_, 200, _}, _, Body40}} =
httpc:request(get, {AdminURL ++ "/v1/admin/events?calendar_id=" ++ SearchCalIdStr ++ "&status=active",
[{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]},
[], []),
Events40 = jsx:decode(list_to_binary(Body40), [return_maps]),
ct:pal("DEBUG: Events40 count = ~p", [length(Events40)]),
ct:pal("DEBUG: Events40 = ~p", [Events40]),
true = (length(Events40) >= 2),
ct:pal("OK (count: ~p)~n", [length(Events40)]),
%% ── TEST 41: Pagination (limit & offset) ──
ct:pal(" TEST 41: Pagination... "),
{ok, {{_, 200, _}, Headers41a, Body41a}} =
httpc:request(get, {AdminURL ++ "/v1/admin/events?calendar_id=" ++ SearchCalIdStr ++ "&status=all&limit=2&offset=0&sort=title&order=asc",
[{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]},
[], []),
Events41a = jsx:decode(list_to_binary(Body41a), [return_maps]),
ct:pal("DEBUG: Events41a count = ~p", [length(Events41a)]),
ct:pal("DEBUG: Events41a = ~p", [Events41a]),
2 = length(Events41a),
{"content-range", ContentRange41a} = lists:keyfind("content-range", 1, Headers41a),
ct:pal("page1: ~s; ", [ContentRange41a]),
{ok, {{_, 200, _}, Headers41b, Body41b}} =
httpc:request(get, {AdminURL ++ "/v1/admin/events?calendar_id=" ++ SearchCalIdStr ++ "&status=all&limit=2&offset=2&sort=title&order=asc",
[{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]},
[], []),
Events41b = jsx:decode(list_to_binary(Body41b), [return_maps]),
ct:pal("DEBUG: Events41b count = ~p", [length(Events41b)]),
ct:pal("DEBUG: Events41b = ~p", [Events41b]),
1 = length(Events41b),
{"content-range", ContentRange41b} = lists:keyfind("content-range", 1, Headers41b),
ct:pal("page2: ~s~n", [ContentRange41b]),
%% ── TEST 42: Sorting (order=asc) ──
ct:pal(" TEST 42: Sorting by title ascending... "),
{ok, {{_, 200, _}, _, Body42}} =
httpc:request(get, {AdminURL ++ "/v1/admin/events?calendar_id=" ++ SearchCalIdStr ++ "&status=all&sort=title&order=asc",
[{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]},
[], []),
Events42 = jsx:decode(list_to_binary(Body42), [return_maps]),
SortedTitles = [maps:get(<<"title">>, E) || E <- Events42],
SortedTitles = lists:sort(SortedTitles),
ct:pal("OK (titles: ~p)~n", [SortedTitles]),
ct:pal("~n✅ Admin API tests passed!~n"),
{?MODULE, ok}.

View File

@@ -1,64 +0,0 @@
-module(api_auth_tests).
-export([test/0]).
-define(BASE_URL, api_test_runner:get_base_url()).
test() ->
io:format("Testing authentication API...~n"),
Email = api_test_runner:unique_email(<<"auth_test">>),
Password = <<"test123">>,
% TEST 1: Register
io:format(" TEST 1: Register... "),
RegBody = #{email => Email, password => Password},
Token = api_test_runner:extract_json(
api_test_runner:http_post("/v1/register", RegBody), <<"token">>),
io:format("OK~n"),
% TEST 2: Register with existing email
io:format(" TEST 2: Register duplicate... "),
{ok, {{_, 409, _}, _, _}} = api_test_runner:http_post("/v1/register", RegBody),
io:format("OK~n"),
% TEST 3: Login with correct credentials
io:format(" TEST 3: Login... "),
LoginBody = #{email => Email, password => Password},
RefreshToken = api_test_runner:extract_json(
api_test_runner:http_post("/v1/login", LoginBody), <<"refresh_token">>),
io:format("OK~n"),
% TEST 4: Login with wrong password
io:format(" TEST 4: Login wrong password... "),
{ok, {{_, 401, _}, _, _}} = api_test_runner:http_post("/v1/login", #{email => Email, password => <<"wrong">>}),
io:format("OK~n"),
% TEST 5: Get profile with valid token
io:format(" TEST 5: Get profile... "),
{ok, {{_, 200, _}, _, _}} = api_test_runner:http_get("/v1/user/me", Token),
io:format("OK~n"),
% TEST 6: Get profile with invalid token
io:format(" TEST 6: Get profile invalid token... "),
{ok, {{_, 401, _}, _, _}} = api_test_runner:http_get("/v1/user/me", <<"invalid">>),
io:format("OK~n"),
% TEST 7: Refresh token
io:format(" TEST 7: Refresh token... "),
RefreshBody = #{refresh_token => RefreshToken},
NewToken = api_test_runner:extract_json(
api_test_runner:http_post("/v1/refresh", RefreshBody), <<"token">>),
io:format("OK~n"),
% TEST 8: Refresh with used token (should fail)
io:format(" TEST 8: Refresh with used token... "),
{ok, {{_, 401, _}, _, _}} = api_test_runner:http_post("/v1/refresh", RefreshBody),
io:format("OK~n"),
% TEST 9: Use new token
io:format(" TEST 9: Use new token... "),
{ok, {{_, 200, _}, _, _}} = api_test_runner:http_get("/v1/user/me", NewToken),
io:format("OK~n"),
io:format("~n✅ Authentication API tests passed!~n"),
{?MODULE, ok}.

View File

@@ -1,83 +0,0 @@
-module(api_booking_tests).
-export([test/0]).
-define(BASE_URL, api_test_runner:get_base_url()).
test() ->
io:format("Testing booking API...~n"),
OwnerEmail = api_test_runner:unique_email(<<"book_owner">>),
ParticipantEmail = api_test_runner:unique_email(<<"book_part">>),
OwnerToken = api_test_runner:register_and_login(OwnerEmail, <<"owner123">>),
ParticipantToken = api_test_runner:register_and_login(ParticipantEmail, <<"part123">>),
% Используем COMMERCIAL календари
AutoCalId = api_test_runner:extract_json(
api_test_runner:http_post("/v1/calendars",
#{title => <<"Auto">>, type => <<"commercial">>, confirmation => <<"auto">>}, OwnerToken), <<"id">>),
ManualCalId = api_test_runner:extract_json(
api_test_runner:http_post("/v1/calendars",
#{title => <<"Manual">>, type => <<"commercial">>, confirmation => <<"manual">>}, OwnerToken), <<"id">>),
% Создаём события
AutoEventId = api_test_runner:extract_json(
api_test_runner:http_post("/v1/calendars/" ++ binary_to_list(AutoCalId) ++ "/events",
#{title => <<"Auto Event">>, start_time => <<"2026-06-01T10:00:00Z">>, duration => 60}, OwnerToken), <<"id">>),
ManualEventId = api_test_runner:extract_json(
api_test_runner:http_post("/v1/calendars/" ++ binary_to_list(ManualCalId) ++ "/events",
#{title => <<"Manual Event">>, start_time => <<"2026-06-01T10:00:00Z">>, duration => 60}, OwnerToken), <<"id">>),
% TEST 1: Auto booking
io:format(" TEST 1: Auto booking... "),
AutoBookingId = api_test_runner:extract_json(
api_test_runner:http_post("/v1/events/" ++ binary_to_list(AutoEventId) ++ "/bookings", #{}, ParticipantToken), <<"id">>),
timer:sleep(200),
{ok, {{_, 200, _}, _, _}} = api_test_runner:http_get("/v1/bookings/" ++ binary_to_list(AutoBookingId), ParticipantToken),
io:format("OK~n"),
% TEST 2: Manual booking
io:format(" TEST 2: Manual booking... "),
ManualBookingId = api_test_runner:extract_json(
api_test_runner:http_post("/v1/events/" ++ binary_to_list(ManualEventId) ++ "/bookings", #{}, ParticipantToken), <<"id">>),
io:format("OK~n"),
% TEST 3: Duplicate booking
io:format(" TEST 3: Duplicate booking... "),
{ok, {{_, 409, _}, _, _}} = api_test_runner:http_post("/v1/events/" ++ binary_to_list(AutoEventId) ++ "/bookings", #{}, ParticipantToken),
io:format("OK~n"),
% TEST 4: Owner confirms booking
io:format(" TEST 4: Owner confirms booking... "),
{ok, {{_, 200, _}, _, _}} = api_test_runner:http_put("/v1/bookings/" ++ binary_to_list(ManualBookingId),
#{action => <<"confirm">>}, OwnerToken),
io:format("OK~n"),
% TEST 5: List event bookings
io:format(" TEST 5: List event bookings... "),
{ok, {{_, 200, _}, _, _}} = api_test_runner:http_get("/v1/events/" ++ binary_to_list(ManualEventId) ++ "/bookings", OwnerToken),
io:format("OK~n"),
% TEST 6: List user bookings
io:format(" TEST 6: List user bookings... "),
{ok, {{_, 200, _}, _, _}} = api_test_runner:http_get("/v1/user/bookings", ParticipantToken),
io:format("OK~n"),
% TEST 7: Cancel booking
io:format(" TEST 7: Cancel booking... "),
{ok, {{_, 200, _}, _, _}} = api_test_runner:http_delete("/v1/bookings/" ++ binary_to_list(AutoBookingId), ParticipantToken),
io:format("OK~n"),
% TEST 8: Owner declines booking (новое событие)
io:format(" TEST 8: Owner declines booking... "),
NewEventId = api_test_runner:extract_json(
api_test_runner:http_post("/v1/calendars/" ++ binary_to_list(ManualCalId) ++ "/events",
#{title => <<"Decline Event">>, start_time => <<"2026-06-02T10:00:00Z">>, duration => 60}, OwnerToken), <<"id">>),
NewBookingId = api_test_runner:extract_json(
api_test_runner:http_post("/v1/events/" ++ binary_to_list(NewEventId) ++ "/bookings", #{}, ParticipantToken), <<"id">>),
{ok, {{_, 200, _}, _, _}} = api_test_runner:http_put("/v1/bookings/" ++ binary_to_list(NewBookingId),
#{action => <<"decline">>}, OwnerToken),
io:format("OK~n"),
io:format("~n✅ Booking API tests passed!~n"),
{?MODULE, ok}.

View File

@@ -1,61 +0,0 @@
-module(api_calendar_tests).
-export([test/0]).
-define(BASE_URL, api_test_runner:get_base_url()).
test() ->
io:format("Testing calendar API...~n"),
OwnerEmail = api_test_runner:unique_email(<<"cal_owner">>),
OtherEmail = api_test_runner:unique_email(<<"cal_other">>),
OwnerToken = api_test_runner:register_and_login(OwnerEmail, <<"owner123">>),
OtherToken = api_test_runner:register_and_login(OtherEmail, <<"other123">>),
% TEST 1: Create personal calendar
io:format(" TEST 1: Create personal calendar... "),
CalId = api_test_runner:extract_json(
api_test_runner:http_post("/v1/calendars",
#{title => <<"Personal">>, type => <<"personal">>}, OwnerToken), <<"id">>),
io:format("OK~n"),
% TEST 2: Create commercial calendar
io:format(" TEST 2: Create commercial calendar... "),
CommId = api_test_runner:extract_json(
api_test_runner:http_post("/v1/calendars",
#{title => <<"Commercial">>, type => <<"commercial">>}, OwnerToken), <<"id">>),
io:format("OK~n"),
% TEST 3: List calendars
io:format(" TEST 3: List calendars... "),
{ok, {{_, 200, _}, _, _}} = api_test_runner:http_get("/v1/calendars", OwnerToken),
io:format("OK~n"),
% TEST 4: Get personal calendar (owner)
io:format(" TEST 4: Get personal calendar... "),
{ok, {{_, 200, _}, _, _}} = api_test_runner:http_get("/v1/calendars/" ++ binary_to_list(CalId), OwnerToken),
io:format("OK~n"),
% TEST 5: Get personal calendar (other - denied)
io:format(" TEST 5: Get personal calendar (other)... "),
{ok, {{_, 403, _}, _, _}} = api_test_runner:http_get("/v1/calendars/" ++ binary_to_list(CalId), OtherToken),
io:format("OK~n"),
% TEST 6: Get commercial calendar (other - allowed)
io:format(" TEST 6: Get commercial calendar (other)... "),
{ok, {{_, 200, _}, _, _}} = api_test_runner:http_get("/v1/calendars/" ++ binary_to_list(CommId), OtherToken),
io:format("OK~n"),
% TEST 7: Update calendar
io:format(" TEST 7: Update calendar... "),
{ok, {{_, 200, _}, _, _}} = api_test_runner:http_put("/v1/calendars/" ++ binary_to_list(CalId),
#{title => <<"Updated">>}, OwnerToken),
io:format("OK~n"),
% TEST 8: Delete calendar
io:format(" TEST 8: Delete calendar... "),
{ok, {{_, 200, _}, _, _}} = api_test_runner:http_delete("/v1/calendars/" ++ binary_to_list(CalId), OwnerToken),
io:format("OK~n"),
io:format("~n✅ Calendar API tests passed!~n"),
{?MODULE, ok}.

View File

@@ -1,70 +0,0 @@
-module(api_event_tests).
-export([test/0]).
-define(BASE_URL, api_test_runner:get_base_url()).
test() ->
io:format("Testing event API...~n"),
OwnerEmail = api_test_runner:unique_email(<<"ev_owner">>),
OwnerToken = api_test_runner:register_and_login(OwnerEmail, <<"owner123">>),
CalId = api_test_runner:extract_json(
api_test_runner:http_post("/v1/calendars", #{title => <<"Test">>}, OwnerToken), <<"id">>),
% TEST 1: Create single event
io:format(" TEST 1: Create single event... "),
EventId = api_test_runner:extract_json(
api_test_runner:http_post("/v1/calendars/" ++ binary_to_list(CalId) ++ "/events",
#{title => <<"Test Event">>, start_time => <<"2026-06-01T10:00:00Z">>, duration => 60}, OwnerToken), <<"id">>),
io:format("OK~n"),
% TEST 2: Create event in past (should fail)
io:format(" TEST 2: Create past event... "),
{ok, {{_, 400, _}, _, _}} = api_test_runner:http_post("/v1/calendars/" ++ binary_to_list(CalId) ++ "/events",
#{title => <<"Past Event">>, start_time => <<"2020-01-01T10:00:00Z">>, duration => 60}, OwnerToken),
io:format("OK~n"),
% TEST 3: Create recurring event
io:format(" TEST 3: Create recurring event... "),
RecurringId = api_test_runner:extract_json(
api_test_runner:http_post("/v1/calendars/" ++ binary_to_list(CalId) ++ "/events",
#{title => <<"Weekly Meeting">>, start_time => <<"2026-06-01T10:00:00Z">>, duration => 60,
recurrence => #{freq => <<"WEEKLY">>, interval => 1}}, OwnerToken), <<"id">>),
io:format("OK~n"),
% TEST 4: List events
io:format(" TEST 4: List events... "),
{ok, {{_, 200, _}, _, _}} = api_test_runner:http_get("/v1/calendars/" ++ binary_to_list(CalId) ++ "/events", OwnerToken),
io:format("OK~n"),
% TEST 5: Get event
io:format(" TEST 5: Get event... "),
{ok, {{_, 200, _}, _, _}} = api_test_runner:http_get("/v1/events/" ++ binary_to_list(EventId), OwnerToken),
io:format("OK~n"),
% TEST 6: Update event
io:format(" TEST 6: Update event... "),
{ok, {{_, 200, _}, _, _}} = api_test_runner:http_put("/v1/events/" ++ binary_to_list(EventId),
#{title => <<"Updated Event">>}, OwnerToken),
io:format("OK~n"),
% TEST 7: Get occurrences
io:format(" TEST 7: Get occurrences... "),
{ok, {{_, 200, _}, _, _}} = api_test_runner:http_get(
"/v1/events/" ++ binary_to_list(RecurringId) ++ "/occurrences?from=2026-06-01T00:00:00Z&to=2026-06-30T00:00:00Z", OwnerToken),
io:format("OK~n"),
% TEST 8: Cancel occurrence
io:format(" TEST 8: Cancel occurrence... "),
{ok, {{_, 200, _}, _, _}} = api_test_runner:http_delete(
"/v1/events/" ++ binary_to_list(RecurringId) ++ "/occurrences/2026-06-08T10:00:00Z", OwnerToken),
io:format("OK~n"),
% TEST 9: Delete event
io:format(" TEST 9: Delete event... "),
{ok, {{_, 200, _}, _, _}} = api_test_runner:http_delete("/v1/events/" ++ binary_to_list(EventId), OwnerToken),
io:format("OK~n"),
io:format("~n✅ Event API tests passed!~n"),
{?MODULE, ok}.

View File

@@ -1,72 +0,0 @@
-module(api_moderation_tests).
-export([test/0]).
-define(BASE_URL, api_test_runner:get_base_url()).
-define(ADMIN_BASE_URL, api_test_runner:get_admin_url()).
test() ->
io:format("Testing moderation API...~n"),
AdminToken = api_test_runner:get_admin_token(),
UserToken = api_test_runner:get_user_token(),
%% Создаём календарь и событие
CalId = api_test_runner:extract_json(
api_test_runner:http_post("/v1/calendars", #{title => <<"Mod Cal">>}, UserToken),
<<"id">>),
EventId = api_test_runner:extract_json(
api_test_runner:http_post("/v1/calendars/" ++ binary_to_list(CalId) ++ "/events",
#{title => <<"Mod Event">>,
start_time => <<"2026-06-01T10:00:00Z">>,
duration => 60},
UserToken),
<<"id">>),
%% TEST 1: Create report
io:format(" TEST 1: Create report... "),
ReportId = api_test_runner:extract_json(
api_test_runner:http_post("/v1/reports",
#{target_type => <<"event">>,
target_id => EventId,
reason => <<"Inappropriate">>},
UserToken),
<<"id">>),
io:format("OK~n"),
%% TEST 2: Admin views reports
io:format(" TEST 2: Admin views reports... "),
{ok, {{_, 200, _}, _, _}} = httpc:request(get,
{?ADMIN_BASE_URL ++ "/v1/admin/reports", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []),
io:format("OK~n"),
%% TEST 3: Admin resolves report с reason
io:format(" TEST 3: Admin resolves report... "),
{ok, {{_, 200, _}, _, _}} = httpc:request(put,
{?ADMIN_BASE_URL ++ "/v1/admin/reports/" ++ binary_to_list(ReportId),
[{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}],
"application/json",
jsx:encode(#{status => <<"reviewed">>, reason => <<"Resolved by moderator">>})}, [], []),
io:format("OK~n"),
%% TEST 4: Add banned word
io:format(" TEST 4: Add banned word... "),
{ok, {{_, 201, _}, _, _}} = httpc:request(post,
{?ADMIN_BASE_URL ++ "/v1/admin/banned-words",
[{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}],
"application/json",
jsx:encode(#{<<"word">> => <<"badword">>})}, [], []),
io:format("OK~n"),
%% TEST 5: List banned words
io:format(" TEST 5: List banned words... "),
{ok, {{_, 200, _}, _, _}} = httpc:request(get,
{?ADMIN_BASE_URL ++ "/v1/admin/banned-words", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []),
io:format("OK~n"),
%% TEST 6: Remove banned word
io:format(" TEST 6: Remove banned word... "),
{ok, {{_, 200, _}, _, _}} = httpc:request(delete,
{?ADMIN_BASE_URL ++ "/v1/admin/banned-words/badword", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []),
io:format("OK~n"),
io:format("~n✅ Moderation API tests passed!~n"),
{?MODULE, ok}.

View File

@@ -1,58 +0,0 @@
-module(api_reviews_tests).
-export([test/0]).
-define(BASE_URL, api_test_runner:get_base_url()).
test() ->
io:format("Testing reviews API...~n"),
OwnerEmail = api_test_runner:unique_email(<<"rev_owner">>),
ParticipantEmail = api_test_runner:unique_email(<<"rev_part">>),
OwnerToken = api_test_runner:register_and_login(OwnerEmail, <<"owner123">>),
ParticipantToken = api_test_runner:register_and_login(ParticipantEmail, <<"part123">>),
CalId = api_test_runner:extract_json(
api_test_runner:http_post("/v1/calendars", #{title => <<"Review Cal">>}, OwnerToken), <<"id">>),
EventId = api_test_runner:extract_json(
api_test_runner:http_post("/v1/calendars/" ++ binary_to_list(CalId) ++ "/events",
#{title => <<"Review Event">>, start_time => <<"2026-06-01T10:00:00Z">>, duration => 60}, OwnerToken), <<"id">>),
% Создаём и подтверждаем бронирование
BookingId = api_test_runner:extract_json(
api_test_runner:http_post("/v1/events/" ++ binary_to_list(EventId) ++ "/bookings", #{}, ParticipantToken), <<"id">>),
api_test_runner:http_put("/v1/bookings/" ++ binary_to_list(BookingId), #{action => <<"confirm">>}, OwnerToken),
% TEST 1: Create review
io:format(" TEST 1: Create review... "),
ReviewId = api_test_runner:extract_json(
api_test_runner:http_post("/v1/reviews",
#{target_type => <<"event">>, target_id => EventId, rating => 5, comment => <<"Great!">>},
ParticipantToken), <<"id">>),
io:format("OK~n"),
% TEST 2: Duplicate review
io:format(" TEST 2: Duplicate review... "),
{ok, {{_, 409, _}, _, _}} = api_test_runner:http_post("/v1/reviews",
#{target_type => <<"event">>, target_id => EventId, rating => 4, comment => <<"Again">>}, ParticipantToken),
io:format("OK~n"),
% TEST 3: Get event reviews
io:format(" TEST 3: Get event reviews... "),
{ok, {{_, 200, _}, _, _}} = api_test_runner:http_get("/v1/reviews?target_type=event&target_id=" ++ binary_to_list(EventId), ParticipantToken),
io:format("OK~n"),
% TEST 4: Update review
io:format(" TEST 4: Update review... "),
{ok, {{_, 200, _}, _, _}} = api_test_runner:http_put("/v1/reviews/" ++ binary_to_list(ReviewId),
#{rating => 4}, ParticipantToken),
io:format("OK~n"),
% TEST 5: Delete review
io:format(" TEST 5: Delete review... "),
{ok, {{_, 200, _}, _, _}} = api_test_runner:http_delete("/v1/reviews/" ++ binary_to_list(ReviewId), ParticipantToken),
io:format("OK~n"),
io:format("~n✅ Reviews API tests passed!~n"),
{?MODULE, ok}.

View File

@@ -1,54 +0,0 @@
-module(api_search_tests).
-export([test/0]).
-define(BASE_URL, api_test_runner:get_base_url()).
test() ->
io:format("Testing search API...~n"),
OwnerEmail = api_test_runner:unique_email(<<"search_owner">>),
OwnerToken = api_test_runner:register_and_login(OwnerEmail, <<"owner123">>),
CalId = api_test_runner:extract_json(
api_test_runner:http_post("/v1/calendars", #{title => <<"Search Cal">>}, OwnerToken), <<"id">>),
% Создаём события с тегами
api_test_runner:extract_json(
api_test_runner:http_post("/v1/calendars/" ++ binary_to_list(CalId) ++ "/events",
#{title => <<"Python Workshop">>, start_time => <<"2026-06-01T10:00:00Z">>, duration => 60,
tags => [<<"python">>, <<"workshop">>]}, OwnerToken), <<"id">>),
api_test_runner:extract_json(
api_test_runner:http_post("/v1/calendars/" ++ binary_to_list(CalId) ++ "/events",
#{title => <<"JavaScript">>, start_time => <<"2026-06-15T10:00:00Z">>, duration => 60,
tags => [<<"javascript">>]}, OwnerToken), <<"id">>),
timer:sleep(500),
% TEST 1: Text search
io:format(" TEST 1: Text search... "),
{ok, {{_, 200, _}, _, _}} = api_test_runner:http_get("/v1/search?type=event&q=Python", OwnerToken),
io:format("OK~n"),
% TEST 2: Tag search
io:format(" TEST 2: Tag search... "),
{ok, {{_, 200, _}, _, _}} = api_test_runner:http_get("/v1/search?type=event&tags=workshop", OwnerToken),
io:format("OK~n"),
% TEST 3: Combined search
io:format(" TEST 3: Combined search... "),
{ok, {{_, 200, _}, _, _}} = api_test_runner:http_get("/v1/search?type=event&q=Python&tags=workshop", OwnerToken),
io:format("OK~n"),
% TEST 4: Pagination
io:format(" TEST 4: Pagination... "),
{ok, {{_, 200, _}, _, _}} = api_test_runner:http_get("/v1/search?type=event&limit=2", OwnerToken),
io:format("OK~n"),
% TEST 5: Search calendars
io:format(" TEST 5: Search calendars... "),
{ok, {{_, 200, _}, _, _}} = api_test_runner:http_get("/v1/search?type=calendar", OwnerToken),
io:format("OK~n"),
io:format("~n✅ Search API tests passed!~n"),
{?MODULE, ok}.

View File

@@ -1,36 +0,0 @@
-module(api_subscription_tests).
-export([test/0]).
-define(BASE_URL, api_test_runner:get_base_url()).
test() ->
io:format("Testing subscription API...~n"),
UserEmail = api_test_runner:unique_email(<<"sub_user">>),
UserToken = api_test_runner:register_and_login(UserEmail, <<"user123">>),
% TEST 1: Get subscription (free)
io:format(" TEST 1: Get subscription (free)... "),
{ok, {{_, 200, _}, _, _}} = api_test_runner:http_get("/v1/subscription", UserToken),
io:format("OK~n"),
% TEST 2: Create commercial calendar (auto-activates trial)
io:format(" TEST 2: Create commercial calendar... "),
api_test_runner:extract_json(
api_test_runner:http_post("/v1/calendars",
#{title => <<"Commercial">>, type => <<"commercial">>}, UserToken), <<"id">>),
io:format("OK~n"),
% TEST 3: Get subscription (trial)
io:format(" TEST 3: Get subscription (trial)... "),
{ok, {{_, 200, _}, _, _}} = api_test_runner:http_get("/v1/subscription", UserToken),
io:format("OK~n"),
% TEST 4: Activate paid subscription
io:format(" TEST 4: Activate paid subscription... "),
{ok, {{_, 201, _}, _, _}} = api_test_runner:http_post("/v1/subscription",
#{action => <<"activate">>, plan => <<"monthly">>, payment_info => #{card => <<"4242">>}}, UserToken),
io:format("OK~n"),
io:format("~n✅ Subscription API tests passed!~n"),
{?MODULE, ok}.

View File

@@ -1,277 +1,302 @@
%%%-------------------------------------------------------------------
%%% @doc Централизованный модуль для запуска API-тестов.
%%% Предоставляет функции для выполнения HTTP-запросов
%%% к административному и клиентскому API с автоматическим
%%% логированием, проверкой статусов и конфигурацией
%%% через стандартные переменные окружения.
%%% @end
%%%-------------------------------------------------------------------
-module(api_test_runner).
-export([run_all/0, run/1]).
-export([http_post/2, http_post/3, http_get/1, http_get/2, http_put/3, http_delete/2]).
-export([extract_json/2, extract_json/3, assert_status/2]).
-export([unique_email/1, register_and_login/2, create_calendar/2, create_event/3]).
-export([get_admin_token/0, get_admin_id/0, get_user_token/0, get_user_id/0, get_admin_url/0, get_base_url/0, get_admin_ws_url/0, get_base_ws_url/0, login_admin/2, login_custom_admin/2]).
-export([wait_for_server/0]).
-export([format_datetime/1]).
-define(BASE_URL, base_url()).
-define(ADMIN_URL, admin_base_url()).
-export([
get_admin_url/0,
get_base_url/0,
get_base_ws_url/0,
get_admin_ws_url/0,
get_admin_token/0,
get_superadmin_token/0,
get_moderator_token/0,
get_support_token/0,
get_user_token/0,
unique_email/1,
future_date/0,
register_and_login/2,
create_calendar/2,
create_event/3
]).
-export([
admin_request/3,
admin_request/4,
client_request/3,
client_request/4
]).
-export([
admin_get/2,
admin_post/3,
admin_put/3,
admin_delete/2,
client_get/2,
client_post/3,
client_put/3,
client_delete/2,
admin_patch/3]).
%% Учётные данные по умолчанию (используются в локальном режиме, если словарь пуст)
-define(FALLBACK_ADMIN_EMAIL, <<"admin@eventhub.local">>).
-define(FALLBACK_ADMIN_PASSWORD, <<"123456">>).
-define(USER_EMAIL, <<"global_user@test.com">>).
-define(USER_PASSWORD, <<"user123">>).
%%%===================================================================
%%% Конфигурация окружения (CT_MODE, ...)
%%%===================================================================
%% ------------------------------------------------------------------
%% Выбор базовых URL в зависимости от режима запуска
%% ------------------------------------------------------------------
base_url() ->
case os:getenv("CT_MODE", "local") of
-spec ct_mode() -> string().
ct_mode() ->
os:getenv("CT_MODE", "local").
-spec get_base_url() -> string().
get_base_url() ->
case ct_mode() of
"remote" -> os:getenv("API_HOST", "http://localhost:8080");
_ -> "http://localhost:8080"
end.
base_ws_url() ->
case os:getenv("CT_MODE", "local") of
"remote" -> os:getenv("WS_HOST", "ws://localhost:8081");
_ -> "ws://localhost:8081"
end.
admin_base_url() ->
case os:getenv("CT_MODE", "local") of
-spec get_admin_url() -> string().
get_admin_url() ->
case ct_mode() of
"remote" -> os:getenv("ADMIN_API_HOST", "http://localhost:8445");
_ -> "http://localhost:8445"
end.
admin_ws_url() ->
case os:getenv("CT_MODE", "local") of
-spec get_base_ws_url() -> string().
get_base_ws_url() ->
case ct_mode() of
"remote" -> os:getenv("WS_HOST", "ws://localhost:8081");
_ -> "ws://localhost:8081"
end.
-spec get_admin_ws_url() -> string().
get_admin_ws_url() ->
case ct_mode() of
"remote" -> os:getenv("ADMIN_WS_HOST", "ws://localhost:8446");
_ -> "ws://localhost:8446"
end.
%% ------------------------------------------------------------------
%% Инициализация глобальных тестовых пользователей
%% ------------------------------------------------------------------
init_global_urls() ->
put(admin_url, admin_base_url()),
put(admin_ws_url, admin_ws_url()),
put(base_url, base_url()),
put(base_ws_url, base_ws_url()).
%%%===================================================================
%%% Учётные данные администраторов (из переменных окружения)
%%%===================================================================
init_global_users() ->
case get(admin_token) of
undefined ->
ct:pal("~n=== Initializing global test users ===~n"),
-spec admin_super_email() -> binary().
admin_super_email() ->
list_to_binary(os:getenv("ADMIN_SUPER_EMAIL", "superadmin@eventhub.local")).
%% 1. Администратор
AdminEmail = get(admin_super_email),
AdminPassword = get(admin_super_password),
AdminToken =
if
AdminEmail =/= undefined, AdminPassword =/= undefined ->
%% Учётные данные переданы из api_SUITE (remoteрежим) просто логинимся
login_admin(AdminEmail, AdminPassword);
true ->
%% Локальный режим: админы уже есть, логинимся под суперадмином
login_admin(?FALLBACK_ADMIN_EMAIL, ?FALLBACK_ADMIN_PASSWORD)
end,
-spec admin_super_password() -> binary().
admin_super_password() ->
list_to_binary(os:getenv("ADMIN_SUPER_PASSWORD", "123456")).
%% Получаем ID администратора через /v1/admin/me
MeUrl = ?ADMIN_URL ++ "/v1/admin/me",
{ok, {{_, 200, _}, _, MeBody}} = httpc:request(get,
{MeUrl, [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, ssl_opts(), []),
#{<<"id">> := AdminId} = jsx:decode(list_to_binary(MeBody), [return_maps]),
-spec admin_email() -> binary().
admin_email() ->
list_to_binary(os:getenv("ADMIN_EMAIL", "admin@eventhub.local")).
put(admin_token, AdminToken),
put(admin_id, AdminId),
-spec admin_password() -> binary().
admin_password() ->
list_to_binary(os:getenv("ADMIN_PASSWORD", "123456")).
%% 2. Обычный пользователь
UserToken = register_and_login(?USER_EMAIL, ?USER_PASSWORD),
{ok, {{_, 200, _}, _, UserMeBody}} = http_get("/v1/user/me", UserToken),
#{<<"id">> := UserId} = jsx:decode(list_to_binary(UserMeBody), [return_maps]),
-spec admin_moder_email() -> binary().
admin_moder_email() ->
list_to_binary(os:getenv("ADMIN_MODER_EMAIL", "moderator@eventhub.local")).
put(user_token, UserToken),
put(user_id, UserId),
-spec admin_moder_password() -> binary().
admin_moder_password() ->
list_to_binary(os:getenv("ADMIN_MODER_PASSWORD", "123456")).
ct:pal("Admin ID: ~s, User ID: ~s~n", [AdminId, UserId]),
ct:pal("=== Global users initialized ===~n~n"),
ok;
-spec admin_support_email() -> binary().
admin_support_email() ->
list_to_binary(os:getenv("ADMIN_SUPPORT_EMAIL", "support@eventhub.local")).
-spec admin_support_password() -> binary().
admin_support_password() ->
list_to_binary(os:getenv("ADMIN_SUPPORT_PASSWORD", "123456")).
%%%===================================================================
%%% Получение токенов (с кешированием в persistent_term)
%%%===================================================================
-spec get_admin_token() -> binary().
get_admin_token() ->
get_or_login(admin, admin_email(), admin_password()).
-spec get_superadmin_token() -> binary().
get_superadmin_token() ->
get_or_login(superadmin, admin_super_email(), admin_super_password()).
-spec get_moderator_token() -> binary().
get_moderator_token() ->
get_or_login(moderator, admin_moder_email(), admin_moder_password()).
-spec get_support_token() -> binary().
get_support_token() ->
get_or_login(support, admin_support_email(), admin_support_password()).
-spec get_or_login(atom(), binary(), binary()) -> binary().
get_or_login(Role, Email, Password) ->
Key = {?MODULE, admin_token, Role},
case persistent_term:get(Key, undefined) of
Token when is_binary(Token) -> Token;
_ ->
ct:pal("Global users already initialized.~n"),
ok
Token = login_admin(Email, Password),
persistent_term:put(Key, Token),
timer:apply_after(5 * 60 * 1000, fun() -> persistent_term:erase(Key) end),
Token
end.
%% ------------------------------------------------------------------
%% Вход администратора (используется, когда учётки уже известны)
%% ------------------------------------------------------------------
login_admin(Email, Password) ->
ct:pal("Admin url: ~s~n", [?ADMIN_URL]),
ct:pal("Admin: ~s, password: ~s~n", [Email, Password]),
LoginBody = jsx:encode(#{<<"email">> => Email, <<"password">> => Password}),
ct:pal("url: ~s, body: ~s~n", [?ADMIN_URL ++ "/v1/admin/login", LoginBody]),
{ok, {{_, _, _}, _, LoginResp}} = httpc:request(post,
{?ADMIN_URL ++ "/v1/admin/login", [], "application/json", LoginBody}, ssl_opts(), []),
ct:pal("LoginResp: ~s~n", [LoginResp]),
#{<<"token">> := Token} = jsx:decode(list_to_binary(LoginResp), [return_maps]),
%% @doc Возвращает JWT-токен обычного пользователя.
%% При каждом вызове создаёт нового уникального пользователя,
%% чтобы избежать конфликтов состояния в тестах.
-spec get_user_token() -> binary().
get_user_token() ->
Email = unique_email(<<"testuser">>),
register_and_login(Email, <<"testpass">>).
%%%===================================================================
%%% HTTP-клиент (логирование, заголовки)
%%%===================================================================
-spec admin_request(atom(), binary(), binary()) -> {ok, integer(), proplists:proplist(), binary()} | {error, term()}.
admin_request(Method, Path, Token) ->
admin_request(Method, Path, Token, <<>>).
-spec admin_request(atom(), binary(), binary(), binary()) -> {ok, integer(), proplists:proplist(), binary()} | {error, term()}.
admin_request(Method, Path, Token, Body) ->
request(get_admin_url(), Method, Path, Token, Body, "ADMIN").
-spec client_request(atom(), binary(), binary()) -> {ok, integer(), proplists:proplist(), binary()} | {error, term()}.
client_request(Method, Path, Token) ->
client_request(Method, Path, Token, <<>>).
-spec client_request(atom(), binary(), binary(), binary()) -> {ok, integer(), proplists:proplist(), binary()} | {error, term()}.
client_request(Method, Path, Token, Body) ->
request(get_base_url(), Method, Path, Token, Body, "CLIENT").
%%%===================================================================
%%% Внутренняя реализация HTTP-запроса
%%%===================================================================
-spec request(string(), atom(), binary(), binary(), binary(), string()) -> {ok, integer(), proplists:proplist(), binary()} | {error, term()}.
request(BaseUrl, Method, Path, Token, Body, Prefix) ->
URL = BaseUrl ++ binary_to_list(Path),
Headers0 = [],
Headers = case Token of
<<>> -> Headers0; % пустой токен не добавляем Authorization
_ -> [{"Authorization", "Bearer " ++ binary_to_list(Token)}]
end,
ct:pal("~s REQUEST: ~s ~s", [Prefix, Method, URL]),
RequestArg = case Method of
get -> {URL, Headers};
delete -> {URL, Headers};
_ -> {URL, Headers, "application/json", Body}
end,
Response = httpc:request(Method, RequestArg, [], []),
case Response of
{ok, {{_, Status, _}, RespHeaders, RespBody}} ->
ct:pal("~s RESPONSE: ~p ~s", [Prefix, Status, RespBody]),
{ok, Status, RespHeaders, RespBody};
_ ->
ct:pal("~s REQUEST ERROR: ~p", [Prefix, Response]),
{error, Response}
end.
%%%===================================================================
%%% Высокоуровневые обёртки (GET/POST/PUT/DELETE)
%%%===================================================================
-spec admin_get(binary(), binary()) -> jsx:json_term().
admin_get(Path, Token) ->
{ok, 200, _, Body} = admin_request(get, Path, Token),
jsx:decode(list_to_binary(Body), [return_maps]).
-spec admin_post(binary(), binary(), map()) -> jsx:json_term().
admin_post(Path, Token, BodyMap) ->
Body = jsx:encode(BodyMap),
{ok, 201, _, RespBody} = admin_request(post, Path, Token, Body),
jsx:decode(list_to_binary(RespBody), [return_maps]).
-spec admin_put(binary(), binary(), map()) -> jsx:json_term().
admin_put(Path, Token, BodyMap) ->
Body = jsx:encode(BodyMap),
{ok, 200, _, RespBody} = admin_request(put, Path, Token, Body),
jsx:decode(list_to_binary(RespBody), [return_maps]).
%% В api_test_runner.erl добавить в блок высокоуровневых обёрток:
-spec admin_patch(binary(), binary(), [map()]) -> jsx:json_term().
admin_patch(Path, Token, BodyList) ->
Body = jsx:encode(BodyList),
{ok, 200, _, RespBody} = admin_request(patch, Path, Token, Body),
jsx:decode(list_to_binary(RespBody), [return_maps]).
-spec admin_delete(binary(), binary()) -> jsx:json_term().
admin_delete(Path, Token) ->
{ok, 200, _, Body} = admin_request(delete, Path, Token),
jsx:decode(list_to_binary(Body), [return_maps]).
-spec client_get(binary(), binary()) -> jsx:json_term().
client_get(Path, Token) ->
{ok, 200, _, Body} = client_request(get, Path, Token),
jsx:decode(list_to_binary(Body), [return_maps]).
-spec client_post(binary(), binary(), map()) -> jsx:json_term().
client_post(Path, Token, BodyMap) ->
Body = jsx:encode(BodyMap),
{ok, 201, _, RespBody} = client_request(post, Path, Token, Body),
jsx:decode(list_to_binary(RespBody), [return_maps]).
-spec client_put(binary(), binary(), map()) -> jsx:json_term().
client_put(Path, Token, BodyMap) ->
Body = jsx:encode(BodyMap),
{ok, 200, _, RespBody} = client_request(put, Path, Token, Body),
jsx:decode(list_to_binary(RespBody), [return_maps]).
-spec client_delete(binary(), binary()) -> jsx:json_term().
client_delete(Path, Token) ->
{ok, 200, _, Body} = client_request(delete, Path, Token),
jsx:decode(list_to_binary(Body), [return_maps]).
%%%===================================================================
%%% Фикстуры (создание тестовых данных)
%%%===================================================================
-spec unique_email(binary()) -> binary().
unique_email(Prefix) ->
Unique = integer_to_binary(erlang:system_time()),
<<Prefix/binary, "_", Unique/binary, "@test.local">>.
-spec future_date() -> calendar:datetime().
future_date() ->
Seconds = calendar:datetime_to_gregorian_seconds(calendar:universal_time()) + 86400,
calendar:gregorian_seconds_to_datetime(Seconds).
-spec register_and_login(binary(), binary()) -> binary().
register_and_login(Email, Password) ->
Resp = client_request(post, <<"/v1/register">>, <<>>,
jsx:encode(#{email => Email, password => Password})),
{ok, 201, _, Body} = Resp,
#{<<"token">> := Token} = jsx:decode(list_to_binary(Body), [return_maps]),
Token.
%% ------------------------------------------------------------------
%% Остальные функции (без изменений, только используют ?BASE_URL / ?ADMIN_URL)
%% ------------------------------------------------------------------
get_admin_url() ->
init_global_urls(),
get(admin_url).
get_admin_ws_url() ->
init_global_urls(),
get(admin_ws_url).
get_base_url() ->
init_global_urls(),
get(base_url).
get_base_ws_url() ->
init_global_urls(),
get(base_ws_url).
get_admin_token() ->
init_global_users(),
get(admin_token).
get_admin_id() ->
init_global_users(),
get(admin_id).
get_user_token() ->
init_global_users(),
get(user_token).
get_user_id() ->
init_global_users(),
get(user_id).
run_all() ->
inets:start(),
ssl:start(),
case wait_for_server() of
ok -> ok;
{error, _} -> ct:pal("❌ Server is not running!~n"), exit(server_not_running)
end,
init_global_users(),
ct:pal("Starting API tests...~n"),
Modules = [
api_auth_tests,
api_calendar_tests,
api_event_tests,
api_booking_tests,
api_search_tests,
api_reviews_tests,
api_moderation_tests,
api_tickets_tests,
api_subscription_tests,
api_admin_tests
],
lists:foreach(fun(M) -> M:test() end, Modules).
run(Module) ->
inets:start(),
ssl:start(),
init_global_users(),
Module:test().
%% ── HTTPзапросы ─────────────────────────────────────────
ssl_opts() ->
[{ssl, [{verify, verify_none}]}].
http_post(Url, Body) -> http_post(Url, Body, undefined).
http_post(Url, Body, Token) ->
Headers = case Token of
undefined -> [{"Content-Type", "application/json"}];
_ -> [{"Content-Type", "application/json"}, {"Authorization", "Bearer " ++ binary_to_list(Token)}]
end,
httpc:request(post, {?BASE_URL ++ Url, Headers, "application/json", jsx:encode(Body)}, ssl_opts(), []).
http_get(Url) -> http_get(Url, undefined).
http_get(Url, Token) ->
Headers = case Token of
undefined -> [];
_ -> [{"Authorization", "Bearer " ++ binary_to_list(Token)}]
end,
httpc:request(get, {?BASE_URL ++ Url, Headers}, ssl_opts(), []).
http_put(Url, Body, Token) ->
Headers = [{"Content-Type", "application/json"}, {"Authorization", "Bearer " ++ binary_to_list(Token)}],
httpc:request(put, {?BASE_URL ++ Url, Headers, "application/json", jsx:encode(Body)}, ssl_opts(), []).
http_delete(Url, Token) ->
Headers = [{"Authorization", "Bearer " ++ binary_to_list(Token)}],
httpc:request(delete, {?BASE_URL ++ Url, Headers}, ssl_opts(), []).
%% ── Вспомогательные функции ──────────────────────────────
extract_json({ok, {{_, 200, _}, _, Body}}, Field) ->
Map = jsx:decode(list_to_binary(Body), [return_maps]),
maps:get(Field, Map);
extract_json({ok, {{_, 201, _}, _, Body}}, Field) ->
Map = jsx:decode(list_to_binary(Body), [return_maps]),
maps:get(Field, Map);
extract_json(Response, _Field) ->
error({unexpected_response, Response}).
extract_json(Response, Field, ExpectedStatus) ->
case Response of
{ok, {{_, ExpectedStatus, _}, _, Body}} ->
Map = jsx:decode(list_to_binary(Body), [return_maps]),
maps:get(Field, Map);
_ ->
error({unexpected_response, Response})
end.
assert_status(Status, {ok, {{_, Status, _}, _, _}}) -> ok;
assert_status(Expected, {ok, {{_, Got, _}, _, _}}) ->
error({expected_status, Expected, got, Got}).
unique_email(Prefix) ->
list_to_binary([Prefix, "_", integer_to_binary(os:system_time(millisecond)), "@test.com"]).
register_and_login(Email, Password) ->
RegBody = #{email => Email, password => Password},
case http_post("/v1/register", RegBody) of
{ok, {{_, 201, _}, _, RegResp}} ->
Map = jsx:decode(list_to_binary(RegResp), [return_maps]),
maps:get(<<"token">>, Map);
{ok, {{_, 409, _}, _, _}} ->
LoginBody = #{email => Email, password => Password},
{ok, {{_, 200, _}, _, LoginResp}} = http_post("/v1/login", LoginBody),
Map = jsx:decode(list_to_binary(LoginResp), [return_maps]),
maps:get(<<"token">>, Map)
end.
login_custom_admin(Email, Password) ->
%% LoginBody = #{email => Email, password => Password},
LoginBody = jsx:encode(#{<<"email">> => Email, <<"password">> => Password}),
{ok, {{_, _, _}, _, LoginResp}} = httpc:request(post,
{?ADMIN_URL ++ "/v1/admin/login", [], "application/json", LoginBody}, ssl_opts(), []),
Map = jsx:decode(list_to_binary(LoginResp), [return_maps]),
maps:get(<<"token">>, Map).
-spec create_calendar(binary(), map()) -> binary().
create_calendar(Token, Params) ->
Response = http_post("/v1/calendars", Params, Token),
ct:pal(" create_calendar Response: ~p~n", [Response]),
Id = extract_json(Response, <<"id">>),
Id.
#{<<"id">> := CalId} = client_post(<<"/v1/calendars">>, Token, Params),
CalId.
-spec create_event(binary(), binary(), map()) -> binary().
create_event(Token, CalId, Params) ->
Url = "/v1/calendars/" ++ binary_to_list(CalId) ++ "/events",
Id = extract_json(http_post(Url, Params, Token), <<"id">>),
Id.
Path = <<"/v1/calendars/", CalId/binary, "/events">>,
#{<<"id">> := EventId} = client_post(Path, Token, Params),
EventId.
wait_for_server() -> wait_for_server(30).
wait_for_server(0) -> {error, timeout};
wait_for_server(Attempts) ->
case httpc:request(get, {?BASE_URL ++ "/health", []}, ssl_opts(), [{timeout, 1000}]) of
{ok, {{_, 200, _}, _, _}} -> ok;
_ -> timer:sleep(1000), wait_for_server(Attempts - 1)
end.
%%%===================================================================
%%% Внутренние функции
%%%===================================================================
format_datetime({{Year, Month, Day}, {Hour, Minute, Second}}) ->
iolist_to_binary(
io_lib:format("~4..0B-~2..0B-~2..0BT~2..0B:~2..0B:~2..0BZ",
[Year, Month, Day, Hour, Minute, Second])
).
-spec login_admin(binary(), binary()) -> binary().
login_admin(Email, Password) ->
BodyMap = #{<<"email">> => Email, <<"password">> => Password},
Body = jsx:encode(BodyMap),
{ok, 200, _, RespBody} = admin_request(post, <<"/v1/admin/login">>, <<>>, Body),
#{<<"token">> := Token} = jsx:decode(list_to_binary(RespBody), [return_maps]),
Token.

View File

@@ -1,72 +0,0 @@
-module(api_tickets_tests).
-export([test/0]).
-define(ADMIN_BASE_URL, api_test_runner:get_admin_url()).
-define(BASE_URL, api_test_runner:get_base_url()).
test() ->
io:format("Testing tickets API...~n"),
Token = api_test_runner:get_user_token(),
AdminToken = api_test_runner:get_admin_token(),
%% TEST 1: Create ticket (user)
io:format(" TEST 1: Create ticket... "),
TicketId = api_test_runner:extract_json(
api_test_runner:http_post("/v1/tickets",
#{error_message => <<"Bug">>,
stacktrace => <<"Something broke">>},
Token),
<<"id">>),
ct:pal(" OK (TicketId: ~p)~n", [TicketId]),
io:format("OK~n"),
%% TEST 2: Get my tickets (user)
io:format(" TEST 2: Get my tickets... "),
{ok, {{_, 200, _}, _, _}} = api_test_runner:http_get("/v1/tickets", Token),
io:format("OK~n"),
%% TEST 3: Get single ticket (user)
io:format(" TEST 3: Get single ticket... "),
{ok, {{_, 200, _}, _, _}} = api_test_runner:http_get(
"/v1/tickets/" ++ binary_to_list(TicketId),
Token),
io:format("OK~n"),
%% TEST 4: Admin lists all tickets
io:format(" TEST 4: Admin lists all tickets... "),
{ok, {{_, 200, _}, _, _}} = httpc:request(get,
{?ADMIN_BASE_URL ++ "/v1/admin/tickets", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []),
io:format("OK~n"),
%% TEST 5: Admin updates ticket status
io:format(" TEST 5: Admin updates ticket status... "),
{ok, {{_, 200, _}, _, _}} = httpc:request(put,
{?ADMIN_BASE_URL ++ "/v1/admin/tickets/" ++ binary_to_list(TicketId),
[{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}],
"application/json",
jsx:encode(#{status => <<"in_progress">>})}, [], []),
io:format("OK~n"),
%% TEST 6: Admin assigns ticket
io:format(" TEST 6: Admin assigns ticket... "),
{ok, {{_, 200, _}, _, _}} = httpc:request(put,
{?ADMIN_BASE_URL ++ "/v1/admin/tickets/" ++ binary_to_list(TicketId),
[{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}],
"application/json",
jsx:encode(#{assigned_to => AdminToken})}, [], []),
io:format("OK~n"),
%% TEST 7: Admin views ticket stats
io:format(" TEST 7: Admin views ticket stats... "),
{ok, {{_, 200, _}, _, _}} = httpc:request(get,
{?ADMIN_BASE_URL ++ "/v1/admin/tickets/stats", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []),
io:format("OK~n"),
%% TEST 8: Admin deletes ticket
io:format(" TEST 8: Admin deletes ticket... "),
{ok, {{_, 200, _}, _, _}} = httpc:request(delete,
{?ADMIN_BASE_URL ++ "/v1/admin/tickets/" ++ binary_to_list(TicketId), [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []),
io:format("OK~n"),
io:format("~n✅ Tickets API tests passed!~n"),
{?MODULE, ok}.

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

@@ -1,53 +1,42 @@
-module(api_websocket_tests).
-module(user_websocket_tests).
-export([test/0]).
-define(BASE_URL, api_test_runner:get_base_url()).
-define(WS_URL, api_test_runner:get_base_ws_url() ++ "/ws").
-define(ADMIN_WS_URL, api_test_runner:get_admin_ws_url() ++ "/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(),
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),
% Создаём календарь и событие через новый 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: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),
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]),
% 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)]),
WsUrl = api_test_runner:get_base_ws_url() ++ "/ws",
AdminWsUrl = api_test_runner:get_admin_ws_url() ++ "/admin/ws",
case test_ws_connect_debug(?WS_URL, UserToken) of
%% 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
%% 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");
@@ -59,7 +48,6 @@ test() ->
error(timeout)
end,
% Закрываем соединение
test_ws_close(WS);
{error, Reason} ->
ct:pal(" ERROR: ~p", [Reason]),
@@ -68,42 +56,40 @@ test() ->
ct:pal("~n✅ WebSocket API tests passed!"),
% ============ ТЕСТЫ АДМИНСКОГО WEBSOCKET ============
%% ============ ТЕСТЫ АДМИНСКОГО WEBSOCKET ============
ct:pal("~n=== ADMIN WEBSOCKET TESTS ==="),
% TEST 6: Admin WebSocket connection
%% TEST 6: Admin WebSocket connection
ct:pal(" TEST 6: Admin WebSocket connect..."),
{ok, AdminWS} = test_ws_connect_debug(?ADMIN_WS_URL, AdminToken),
{ok, AdminWS} = test_ws_connect_debug(AdminWsUrl, AdminToken),
ct:pal(" OK - Admin connected"),
% TEST 7: Admin subscribe to reports channel
%% 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
%% 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
%% 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),
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
%% 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
%% 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),
@@ -111,23 +97,23 @@ test() ->
test_ws_close(AdminWS),
% TEST 12: Admin WebSocket with user token (should fail)
%% 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),
{error, {403, _}} = test_ws_connect_debug(AdminWsUrl, UserToken),
ct:pal(" OK - Rejected"),
% TEST 13: Admin WebSocket with invalid token
%% 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),
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] ->
@@ -140,33 +126,21 @@ test_ws_connect_debug(Url, Token) ->
_ ->
"/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] }
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))}
],
{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"),
@@ -207,7 +181,6 @@ test_ws_send(ConnPid, Data) ->
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]),
@@ -245,7 +218,6 @@ 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);