diff --git a/include/records.hrl b/include/records.hrl old mode 100644 new mode 100755 index 205b092..de81c91 --- a/include/records.hrl +++ b/include/records.hrl @@ -103,6 +103,14 @@ rights :: read | write | admin }). +%% Follow чужого календаря (не путать с платной subscription / calendar_share) +-record(calendar_follow, { + id :: binary(), + calendar_id :: binary(), + user_id :: binary(), + created_at :: calendar:datetime() +}). + %% ------------------- Специалисты календаря --------------------------- -record(calendar_specialist, { calendar_id :: binary(), diff --git a/src/core/core_calendar_follow.erl b/src/core/core_calendar_follow.erl new file mode 100755 index 0000000..8a0a421 --- /dev/null +++ b/src/core/core_calendar_follow.erl @@ -0,0 +1,111 @@ +%%%------------------------------------------------------------------- +%%% @doc Хранение follow чужих календарей (уникальность calendar_id+user_id). +%%% @end +%%%------------------------------------------------------------------- +-module(core_calendar_follow). +-include("records.hrl"). + +-export([follow/2, unfollow/2, is_following/2, list_by_user/1, + list_by_calendar/1, delete_by_calendar/1]). + +%%%------------------------------------------------------------------- +%%% @doc Подписаться на календарь. Идемпотентно, если уже follow. +%%% @end +%%%------------------------------------------------------------------- +-spec follow(CalendarId :: binary(), UserId :: binary()) -> + {ok, #calendar_follow{}} | {error, term()}. +follow(CalendarId, UserId) -> + Now = calendar:universal_time(), + F = fun() -> + case find(CalendarId, UserId) of + [#calendar_follow{} = Existing] -> + {ok, Existing}; + [] -> + Rec = #calendar_follow{ + id = infra_utils:generate_id(16), + calendar_id = CalendarId, + user_id = UserId, + created_at = Now + }, + mnesia:write(Rec), + {ok, Rec} + end + end, + case mnesia:transaction(F) of + {atomic, Result} -> Result; + {aborted, Reason} -> {error, Reason} + end. + +%%%------------------------------------------------------------------- +%%% @doc Отписаться. Идемпотентно, если follow нет. +%%% @end +%%%------------------------------------------------------------------- +-spec unfollow(CalendarId :: binary(), UserId :: binary()) -> ok | {error, term()}. +unfollow(CalendarId, UserId) -> + F = fun() -> + case find(CalendarId, UserId) of + [] -> + ok; + [#calendar_follow{id = Id}] -> + mnesia:delete({calendar_follow, Id}), + ok + end + end, + case mnesia:transaction(F) of + {atomic, ok} -> ok; + {aborted, Reason} -> {error, Reason} + end. + +%%%------------------------------------------------------------------- +%%% @doc Есть ли follow у пользователя. +%%% @end +%%%------------------------------------------------------------------- +-spec is_following(UserId :: binary(), CalendarId :: binary()) -> boolean(). +is_following(UserId, CalendarId) -> + case mnesia:dirty_match_object( + #calendar_follow{calendar_id = CalendarId, user_id = UserId, _ = '_'}) of + [] -> false; + _ -> true + end. + +%%%------------------------------------------------------------------- +%%% @doc Все follow пользователя. +%%% @end +%%%------------------------------------------------------------------- +-spec list_by_user(UserId :: binary()) -> [#calendar_follow{}]. +list_by_user(UserId) -> + mnesia:dirty_match_object(#calendar_follow{user_id = UserId, _ = '_'}). + +%%%------------------------------------------------------------------- +%%% @doc Все follow календаря. +%%% @end +%%%------------------------------------------------------------------- +-spec list_by_calendar(CalendarId :: binary()) -> [#calendar_follow{}]. +list_by_calendar(CalendarId) -> + mnesia:dirty_match_object(#calendar_follow{calendar_id = CalendarId, _ = '_'}). + +%%%------------------------------------------------------------------- +%%% @doc Удалить все follow календаря (при удалении calendar). +%%% @end +%%%------------------------------------------------------------------- +-spec delete_by_calendar(CalendarId :: binary()) -> ok. +delete_by_calendar(CalendarId) -> + F = fun() -> + lists:foreach( + fun(#calendar_follow{id = Id}) -> + mnesia:delete({calendar_follow, Id}) + end, + mnesia:match_object(#calendar_follow{calendar_id = CalendarId, _ = '_'})), + ok + end, + case mnesia:transaction(F) of + {atomic, ok} -> ok; + {aborted, Reason} -> error({delete_follows_failed, Reason}) + end. + +%%%=================================================================== +%%% Internal +%%%=================================================================== + +find(CalendarId, UserId) -> + mnesia:match_object(#calendar_follow{calendar_id = CalendarId, user_id = UserId, _ = '_'}). diff --git a/src/eventhub_app.erl b/src/eventhub_app.erl old mode 100644 new mode 100755 index e2248d8..3d837e1 --- a/src/eventhub_app.erl +++ b/src/eventhub_app.erl @@ -90,9 +90,11 @@ start_http() -> {"/v1/user/me", handler_user_me, []}, {"/v1/user/bookings", handler_user_bookings, []}, {"/v1/user/reviews", handler_user_reviews, []}, + {"/v1/user/following", handler_user_following, []}, {"/v1/search", handler_search, []}, {"/v1/calendars", handler_calendars, []}, {"/v1/calendars/:id", handler_calendar_by_id, []}, + {"/v1/calendars/:id/follow", handler_calendar_follow, []}, {"/v1/calendars/:calendar_id/view", handler_calendar_view, []}, {"/v1/calendars/:calendar_id/events", handler_events, []}, {"/v1/events/:id", handler_event_by_id, []}, diff --git a/src/handlers/handler_calendar_by_id.erl b/src/handlers/handler_calendar_by_id.erl old mode 100644 new mode 100755 index fdcec73..6c753a7 --- a/src/handlers/handler_calendar_by_id.erl +++ b/src/handlers/handler_calendar_by_id.erl @@ -114,7 +114,9 @@ get_calendar(Req) -> CalendarId = cowboy_req:binding(id, Req1), case logic_calendar:get_calendar(UserId, CalendarId) of {ok, Calendar} -> - handler_utils:send_json(Req1, 200, handler_utils:calendar_to_json(Calendar)); + Json0 = handler_utils:calendar_to_json(Calendar), + Following = logic_calendar_follow:is_following(UserId, CalendarId), + handler_utils:send_json(Req1, 200, Json0#{following => Following}); {error, access_denied} -> handler_utils:send_error(Req1, 403, <<"Access denied">>); {error, not_found} -> diff --git a/src/handlers/handler_calendar_follow.erl b/src/handlers/handler_calendar_follow.erl new file mode 100755 index 0000000..80a100e --- /dev/null +++ b/src/handlers/handler_calendar_follow.erl @@ -0,0 +1,123 @@ +%%%------------------------------------------------------------------- +%%% @doc Follow / unfollow чужого календаря. +%%% +%%% POST /v1/calendars/:id/follow — подписаться +%%% DELETE /v1/calendars/:id/follow — отписаться +%%% @end +%%%------------------------------------------------------------------- +-module(handler_calendar_follow). +-behaviour(cowboy_handler). + +-export([init/2]). +-export([trails/0]). + +-include("records.hrl"). + +-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}. +init(Req, Opts) -> + handle(Req, Opts). + +-spec trails() -> [map()]. +trails() -> + BaseParams = [ + #{ + name => <<"id">>, + in => <<"path">>, + description => <<"Calendar ID">>, + required => true, + schema => #{type => string} + } + ], + FollowResponse = #{ + type => object, + properties => #{ + calendar_id => #{type => string}, + following => #{type => boolean} + } + }, + [ + #{ + path => <<"/v1/calendars/:id/follow">>, + method => <<"POST">>, + description => <<"Follow a calendar (not paid subscription)">>, + tags => [<<"Calendars">>], + parameters => BaseParams, + responses => #{ + 200 => #{ + description => <<"Following">>, + content => #{<<"application/json">> => #{schema => FollowResponse}} + }, + 401 => #{description => <<"Unauthorized">>}, + 403 => #{description => <<"Own calendar or access denied">>}, + 404 => #{description => <<"Calendar not found">>} + } + }, + #{ + path => <<"/v1/calendars/:id/follow">>, + method => <<"DELETE">>, + description => <<"Unfollow a calendar">>, + tags => [<<"Calendars">>], + parameters => BaseParams, + responses => #{ + 200 => #{ + description => <<"Unfollowed">>, + content => #{<<"application/json">> => #{schema => FollowResponse}} + }, + 401 => #{description => <<"Unauthorized">>}, + 403 => #{description => <<"Own calendar">>}, + 404 => #{description => <<"Calendar not found">>} + } + } + ]. + +-spec handle(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}. +handle(Req, _Opts) -> + case cowboy_req:method(Req) of + <<"POST">> -> follow(Req); + <<"DELETE">> -> unfollow(Req); + _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>) + end. + +-spec follow(cowboy_req:req()) -> {ok, cowboy_req:req(), any()}. +follow(Req) -> + case handler_utils:auth_user(Req) of + {ok, UserId, Req1} -> + CalendarId = cowboy_req:binding(id, Req1), + case logic_calendar_follow:follow(UserId, CalendarId) of + {ok, _} -> + handler_utils:send_json(Req1, 200, #{ + calendar_id => CalendarId, + following => true + }); + {error, not_found} -> + handler_utils:send_error(Req1, 404, <<"Calendar not found">>); + {error, own_calendar} -> + handler_utils:send_error(Req1, 403, <<"Cannot follow own calendar">>); + {error, access_denied} -> + handler_utils:send_error(Req1, 403, <<"Access denied">>); + {error, _} -> + handler_utils:send_error(Req1, 500, <<"Internal server error">>) + end; + {error, Code, Message, Req1} -> + handler_utils:send_error(Req1, Code, Message) + end. + +-spec unfollow(cowboy_req:req()) -> {ok, cowboy_req:req(), any()}. +unfollow(Req) -> + case handler_utils:auth_user(Req) of + {ok, UserId, Req1} -> + CalendarId = cowboy_req:binding(id, Req1), + case logic_calendar_follow:unfollow(UserId, CalendarId) of + ok -> + handler_utils:send_json(Req1, 200, #{ + calendar_id => CalendarId, + following => false + }); + {error, own_calendar} -> + handler_utils:send_error(Req1, 403, <<"Cannot unfollow own calendar">>); + {error, _} -> + handler_utils:send_error(Req1, 500, <<"Internal server error">>) + end; + {error, Code, Message, Req1} -> + handler_utils:send_error(Req1, Code, Message) + end. diff --git a/src/handlers/handler_user_following.erl b/src/handlers/handler_user_following.erl new file mode 100755 index 0000000..aa210c7 --- /dev/null +++ b/src/handlers/handler_user_following.erl @@ -0,0 +1,55 @@ +%%%------------------------------------------------------------------- +%%% @doc Список календарей, которые пользователь отслеживает (follow). +%%% +%%% GET /v1/user/following +%%% @end +%%%------------------------------------------------------------------- +-module(handler_user_following). +-behaviour(cowboy_handler). + +-export([init/2]). +-export([trails/0]). + +-include("records.hrl"). + +-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}. +init(Req0, _State) -> + case cowboy_req:method(Req0) of + <<"GET">> -> list_following(Req0); + _ -> handler_utils:send_error(Req0, 405, <<"Method not allowed">>) + end. + +-spec trails() -> [map()]. +trails() -> + [ + #{ + path => <<"/v1/user/following">>, + method => <<"GET">>, + description => <<"List calendars the current user follows">>, + tags => [<<"Calendars">>], + responses => #{ + 200 => #{ + description => <<"Array of followed calendars">>, + content => #{<<"application/json">> => #{schema => #{ + type => array, + items => #{type => object} + }}} + }, + 401 => #{description => <<"Unauthorized">>} + } + } + ]. + +-spec list_following(cowboy_req:req()) -> {ok, cowboy_req:req(), any()}. +list_following(Req) -> + case handler_utils:auth_user(Req) of + {ok, UserId, Req1} -> + {ok, Calendars} = logic_calendar_follow:list_following_calendars(UserId), + Response = [ + maps:put(following, true, handler_utils:calendar_to_json(C)) + || C <- Calendars + ], + handler_utils:send_json(Req1, 200, Response); + {error, Code, Message, Req1} -> + handler_utils:send_error(Req1, Code, Message) + end. diff --git a/src/infra/infra_mnesia.erl b/src/infra/infra_mnesia.erl old mode 100644 new mode 100755 index f7479f0..18c77ce --- a/src/infra/infra_mnesia.erl +++ b/src/infra/infra_mnesia.erl @@ -14,7 +14,7 @@ -define(TABLES, [ user, session, verification, admin, admin_session, auth_session, - calendar, calendar_share, calendar_specialist, + calendar, calendar_share, calendar_follow, calendar_specialist, event, recurrence_exception, booking, review, review_vote, report, banned_word, automod_settings, automod_hit, @@ -317,6 +317,7 @@ table_opts(user) -> [{disc_copies, [node()]}, {attributes, record_info(fields, u table_opts(admin) -> [{disc_copies, [node()]}, {attributes, record_info(fields, admin)}]; table_opts(calendar) -> [{disc_copies, [node()]}, {attributes, record_info(fields, calendar)}]; table_opts(calendar_share) -> [{disc_copies, [node()]}, {attributes, record_info(fields, calendar_share)}]; +table_opts(calendar_follow) -> [{disc_copies, [node()]}, {attributes, record_info(fields, calendar_follow)}]; table_opts(calendar_specialist) -> [{disc_copies, [node()]}, {attributes, record_info(fields, calendar_specialist)}]; table_opts(event) -> [{disc_copies, [node()]}, {attributes, record_info(fields, event)}]; table_opts(recurrence_exception) -> [{disc_copies, [node()]}, {attributes, record_info(fields, recurrence_exception)}]; diff --git a/src/infra/migration_engine.erl b/src/infra/migration_engine.erl old mode 100644 new mode 100755 index 059fc44..2ac8011 --- a/src/infra/migration_engine.erl +++ b/src/infra/migration_engine.erl @@ -24,7 +24,8 @@ '20260716230000_ticket_source_and_hash_index', '20260717180000_stats_counters', '20260717190000_admin_stats_indexes', - '20260719210000_review_vote' + '20260719210000_review_vote', + '20260720210000_calendar_follow' ]). %% ------------------------------ diff --git a/src/logic/logic_calendar_follow.erl b/src/logic/logic_calendar_follow.erl new file mode 100755 index 0000000..71ada5e --- /dev/null +++ b/src/logic/logic_calendar_follow.erl @@ -0,0 +1,84 @@ +%%%------------------------------------------------------------------- +%%% @doc Бизнес-логика follow чужого календаря. +%%% Follow не даёт право на отзыв (см. logic_review:can_review/3). +%%% @end +%%%------------------------------------------------------------------- +-module(logic_calendar_follow). +-include("records.hrl"). + +-export([follow/2, unfollow/2, is_following/2, list_following_calendars/1]). + +%%%------------------------------------------------------------------- +%%% @doc Follow доступного чужого календаря. +%%% @end +%%%------------------------------------------------------------------- +-spec follow(UserId :: binary(), CalendarId :: binary()) -> + {ok, #calendar_follow{}} | {error, term()}. +follow(UserId, CalendarId) -> + case core_calendar:get_by_id(CalendarId) of + {error, not_found} -> + {error, not_found}; + {ok, #calendar{owner_id = UserId}} -> + {error, own_calendar}; + {ok, Calendar} -> + case logic_calendar:can_access(UserId, Calendar) of + false -> + {error, access_denied}; + true -> + case Calendar#calendar.status of + active -> + core_calendar_follow:follow(CalendarId, UserId); + _ -> + {error, not_found} + end + end + end. + +%%%------------------------------------------------------------------- +%%% @doc Unfollow. Идемпотентно. +%%% @end +%%%------------------------------------------------------------------- +-spec unfollow(UserId :: binary(), CalendarId :: binary()) -> ok | {error, term()}. +unfollow(UserId, CalendarId) -> + case core_calendar:get_by_id(CalendarId) of + {error, not_found} -> + %% всё равно снимаем локальный follow, если был + core_calendar_follow:unfollow(CalendarId, UserId); + {ok, #calendar{owner_id = UserId}} -> + {error, own_calendar}; + {ok, _} -> + core_calendar_follow:unfollow(CalendarId, UserId) + end. + +%%%------------------------------------------------------------------- +%%% @doc Флаг following для текущего пользователя. +%%% @end +%%%------------------------------------------------------------------- +-spec is_following(UserId :: binary(), CalendarId :: binary()) -> boolean(). +is_following(UserId, CalendarId) -> + core_calendar_follow:is_following(UserId, CalendarId). + +%%%------------------------------------------------------------------- +%%% @doc Список календарей, на которые подписан пользователь (active, доступные). +%%% @end +%%%------------------------------------------------------------------- +-spec list_following_calendars(UserId :: binary()) -> {ok, [#calendar{}]}. +list_following_calendars(UserId) -> + Follows = core_calendar_follow:list_by_user(UserId), + Calendars = lists:filtermap( + fun(#calendar_follow{calendar_id = CalId}) -> + case core_calendar:get_by_id(CalId) of + {ok, #calendar{status = active} = Cal} -> + case logic_calendar:can_access(UserId, Cal) of + true -> {true, Cal}; + false -> false + end; + _ -> + false + end + end, + Follows), + Sorted = lists:sort( + fun(#calendar{title = A}, #calendar{title = B}) -> A =< B end, + Calendars), + {ok, Sorted}. diff --git a/src/migrations/20260720210000_calendar_follow.erl b/src/migrations/20260720210000_calendar_follow.erl new file mode 100755 index 0000000..a6ab1f1 --- /dev/null +++ b/src/migrations/20260720210000_calendar_follow.erl @@ -0,0 +1,37 @@ +%% @doc Create calendar_follow table and indexes if missing. +-module('20260720210000_calendar_follow'). + +-export([up/0, down/0]). + +-include("records.hrl"). + +up() -> + ensure_table(calendar_follow, record_info(fields, calendar_follow)), + ensure_index(calendar_follow, calendar_id), + ensure_index(calendar_follow, user_id), + ok. + +down() -> + _ = mnesia:delete_table(calendar_follow), + ok. + +ensure_table(Table, Attrs) -> + case lists:member(Table, mnesia:system_info(tables)) of + true -> + ok; + false -> + case mnesia:create_table(Table, [{disc_copies, [node()]}, {attributes, Attrs}]) of + {atomic, ok} -> ok; + {aborted, {already_exists, Table}} -> ok; + {aborted, Reason} -> error({create_table_failed, Table, Reason}) + end + end. + +ensure_index(Table, Attr) -> + case mnesia:add_table_index(Table, Attr) of + {atomic, ok} -> ok; + {aborted, {already_exists, Table, _Pos}} -> ok; + {aborted, {already_exists, Table, Attr}} -> ok; + {aborted, {already_exists, _}} -> ok; + {aborted, Reason} -> error({add_index_failed, Table, Attr, Reason}) + end. diff --git a/src/swagger/eventhub_trails.erl b/src/swagger/eventhub_trails.erl old mode 100644 new mode 100755 index 05095cd..fc9d435 --- a/src/swagger/eventhub_trails.erl +++ b/src/swagger/eventhub_trails.erl @@ -72,6 +72,7 @@ user() -> handler_booking_by_id, handler_bookings, handler_calendar_by_id, + handler_calendar_follow, handler_calendar_view, handler_calendars, handler_event_by_id, @@ -86,6 +87,7 @@ user() -> handler_ticket_by_id, handler_tickets, handler_user_bookings, + handler_user_following, handler_user_me, handler_user_reviews ], diff --git a/test/api/users/user_calendar_follow_tests.erl b/test/api/users/user_calendar_follow_tests.erl new file mode 100755 index 0000000..7ecd012 --- /dev/null +++ b/test/api/users/user_calendar_follow_tests.erl @@ -0,0 +1,113 @@ +%%%------------------------------------------------------------------- +%%% @doc Тесты follow / unfollow чужого календаря. +%%% +%%% POST /v1/calendars/:id/follow +%%% DELETE /v1/calendars/:id/follow +%%% GET /v1/user/following +%%% GET /v1/calendars/:id (поле following) +%%% @end +%%%------------------------------------------------------------------- +-module(user_calendar_follow_tests). +-include_lib("eunit/include/eunit.hrl"). + +-export([test/0]). + +-spec test() -> ok. +test() -> + ct:pal("=== User Calendar Follow Tests ==="), + OwnerEmail = api_test_runner:unique_email(<<"flowner">>), + OwnerToken = api_test_runner:register_and_login(OwnerEmail, <<"pass">>), + FollowerEmail = api_test_runner:unique_email(<<"fluser">>), + FollowerToken = api_test_runner:register_and_login(FollowerEmail, <<"pass">>), + + %% commercial calendar requires subscription + {ok, 201, _, _} = api_test_runner:client_request( + post, <<"/v1/subscription">>, OwnerToken, + jsx:encode(#{action => <<"start_trial">>})), + CalId = api_test_runner:create_calendar(OwnerToken, #{ + title => <<"FollowMe">>, + type => <<"commercial">> + }), + FollowPath = <<"/v1/calendars/", CalId/binary, "/follow">>, + + test_follow(FollowerToken, FollowPath, CalId), + test_follow_idempotent(FollowerToken, FollowPath, CalId), + test_get_calendar_following(FollowerToken, CalId, true), + test_list_following(FollowerToken, CalId), + test_own_forbidden(OwnerToken, FollowPath), + test_unfollow(FollowerToken, FollowPath, CalId), + test_unfollow_idempotent(FollowerToken, FollowPath, CalId), + test_get_calendar_following(FollowerToken, CalId, false), + test_list_following_empty(FollowerToken), + test_unauthorized(FollowPath), + + ct:pal("=== All user calendar follow tests passed ==="), + ok. + +%%%=================================================================== +%%% Cases +%%%=================================================================== + +test_follow(Token, Path, CalId) -> + ct:pal(" TEST: POST follow"), + {ok, 200, _, Body} = api_test_runner:client_request(post, Path, Token, <<"{}">>), + Resp = jsx:decode(list_to_binary(Body), [return_maps]), + ?assertEqual(CalId, maps:get(<<"calendar_id">>, Resp)), + ?assertEqual(true, maps:get(<<"following">>, Resp)), + ct:pal(" OK"). + +test_follow_idempotent(Token, Path, CalId) -> + ct:pal(" TEST: POST follow idempotent"), + {ok, 200, _, Body} = api_test_runner:client_request(post, Path, Token, <<"{}">>), + Resp = jsx:decode(list_to_binary(Body), [return_maps]), + ?assertEqual(CalId, maps:get(<<"calendar_id">>, Resp)), + ?assertEqual(true, maps:get(<<"following">>, Resp)), + ct:pal(" OK"). + +test_get_calendar_following(Token, CalId, Expected) -> + ct:pal(" TEST: GET calendar following=~p", [Expected]), + Cal = api_test_runner:client_get(<<"/v1/calendars/", CalId/binary>>, Token), + ?assertEqual(Expected, maps:get(<<"following">>, Cal)), + ct:pal(" OK"). + +test_list_following(Token, CalId) -> + ct:pal(" TEST: GET /v1/user/following"), + List = api_test_runner:client_get(<<"/v1/user/following">>, Token), + ?assert(is_list(List)), + Match = [C || C <- List, maps:get(<<"id">>, C) =:= CalId], + ?assertMatch([_], Match), + [C] = Match, + ?assertEqual(true, maps:get(<<"following">>, C)), + ct:pal(" OK"). + +test_own_forbidden(Token, Path) -> + ct:pal(" TEST: follow own calendar forbidden"), + Resp = api_test_runner:client_request(post, Path, Token, <<"{}">>), + ?assertMatch({ok, 403, _, _}, Resp), + ct:pal(" OK"). + +test_unfollow(Token, Path, CalId) -> + ct:pal(" TEST: DELETE unfollow"), + Resp = api_test_runner:client_delete(Path, Token), + ?assertEqual(CalId, maps:get(<<"calendar_id">>, Resp)), + ?assertEqual(false, maps:get(<<"following">>, Resp)), + ct:pal(" OK"). + +test_unfollow_idempotent(Token, Path, CalId) -> + ct:pal(" TEST: DELETE unfollow idempotent"), + Resp = api_test_runner:client_delete(Path, Token), + ?assertEqual(CalId, maps:get(<<"calendar_id">>, Resp)), + ?assertEqual(false, maps:get(<<"following">>, Resp)), + ct:pal(" OK"). + +test_list_following_empty(Token) -> + ct:pal(" TEST: GET following empty"), + List = api_test_runner:client_get(<<"/v1/user/following">>, Token), + ?assertEqual([], List), + ct:pal(" OK"). + +test_unauthorized(Path) -> + ct:pal(" TEST: unauthorized"), + Resp = api_test_runner:client_request(post, Path, <<>>, <<"{}">>), + ?assertMatch({ok, 401, _, _}, Resp), + ct:pal(" OK"). diff --git a/test/api_users_SUITE.erl b/test/api_users_SUITE.erl old mode 100644 new mode 100755 index 2ca3bc7..ebbed59 --- a/test/api_users_SUITE.erl +++ b/test/api_users_SUITE.erl @@ -40,6 +40,7 @@ all() -> user_test_reviews, user_test_review_by_id, user_test_review_vote, + user_test_calendar_follow, user_test_my_reviews, user_test_search, user_test_refresh, @@ -140,6 +141,9 @@ user_test_review_by_id(_Config) -> user_test_review_vote(_Config) -> user_review_vote_tests:test(). +user_test_calendar_follow(_Config) -> + user_calendar_follow_tests:test(). + user_test_my_reviews(_Config) -> user_my_reviews_tests:test(). diff --git a/test/unit/core_calendar_follow_tests.erl b/test/unit/core_calendar_follow_tests.erl new file mode 100755 index 0000000..0e44c33 --- /dev/null +++ b/test/unit/core_calendar_follow_tests.erl @@ -0,0 +1,49 @@ +-module(core_calendar_follow_tests). +-include_lib("eunit/include/eunit.hrl"). +-include("records.hrl"). + +-define(TABLES, [calendar_follow]). + +setup() -> + eh_test_support:start_mnesia(), + eh_test_support:ensure_tables(?TABLES), + ok. + +cleanup(_) -> + eh_test_support:delete_tables(?TABLES), + eh_test_support:stop_mnesia(), + ok. + +core_calendar_follow_test_() -> + {foreach, fun setup/0, fun cleanup/1, [ + {"follow and is_following", fun test_follow/0}, + {"follow idempotent", fun test_follow_idempotent/0}, + {"unfollow", fun test_unfollow/0}, + {"list_by_user", fun test_list_by_user/0} + ]}. + +test_follow() -> + {ok, F} = core_calendar_follow:follow(<<"cal1">>, <<"u1">>), + ?assertEqual(<<"cal1">>, F#calendar_follow.calendar_id), + ?assertEqual(<<"u1">>, F#calendar_follow.user_id), + ?assert(core_calendar_follow:is_following(<<"u1">>, <<"cal1">>)), + ?assertNot(core_calendar_follow:is_following(<<"u2">>, <<"cal1">>)). + +test_follow_idempotent() -> + {ok, F1} = core_calendar_follow:follow(<<"cal1">>, <<"u1">>), + {ok, F2} = core_calendar_follow:follow(<<"cal1">>, <<"u1">>), + ?assertEqual(F1#calendar_follow.id, F2#calendar_follow.id), + ?assertEqual(1, length(core_calendar_follow:list_by_user(<<"u1">>))). + +test_unfollow() -> + {ok, _} = core_calendar_follow:follow(<<"cal1">>, <<"u1">>), + ok = core_calendar_follow:unfollow(<<"cal1">>, <<"u1">>), + ?assertNot(core_calendar_follow:is_following(<<"u1">>, <<"cal1">>)), + ok = core_calendar_follow:unfollow(<<"cal1">>, <<"u1">>). + +test_list_by_user() -> + {ok, _} = core_calendar_follow:follow(<<"cal1">>, <<"u1">>), + {ok, _} = core_calendar_follow:follow(<<"cal2">>, <<"u1">>), + {ok, _} = core_calendar_follow:follow(<<"cal1">>, <<"u2">>), + ?assertEqual(2, length(core_calendar_follow:list_by_user(<<"u1">>))), + ?assertEqual(2, length(core_calendar_follow:list_by_calendar(<<"cal1">>))). diff --git a/test/unit/eh_test_support.erl b/test/unit/eh_test_support.erl old mode 100644 new mode 100755 index 466f57e..cb51252 --- a/test/unit/eh_test_support.erl +++ b/test/unit/eh_test_support.erl @@ -126,6 +126,8 @@ table_opts(calendar) -> [{ram_copies, [node()]}, {attributes, record_info(fields, calendar)}]; table_opts(calendar_share) -> [{ram_copies, [node()]}, {attributes, record_info(fields, calendar_share)}]; +table_opts(calendar_follow) -> + [{ram_copies, [node()]}, {attributes, record_info(fields, calendar_follow)}]; table_opts(calendar_specialist) -> [{ram_copies, [node()]}, {attributes, record_info(fields, calendar_specialist)}]; table_opts(event) -> diff --git a/test/unit/migration_engine_tests.erl b/test/unit/migration_engine_tests.erl old mode 100644 new mode 100755 index 20f5125..672564e --- a/test/unit/migration_engine_tests.erl +++ b/test/unit/migration_engine_tests.erl @@ -10,7 +10,8 @@ "20260716230000_ticket_source_and_hash_index", "20260717180000_stats_counters", "20260717190000_admin_stats_indexes", - "20260719210000_review_vote" + "20260719210000_review_vote", + "20260720210000_calendar_follow" ]). setup() ->