diff --git a/rebar.config b/rebar.config index 68c9a44..7b5d3b9 100644 --- a/rebar.config +++ b/rebar.config @@ -21,8 +21,11 @@ {profiles, [ {test, [ + {erl_opts, [debug_info, {i, "include"}, {d, 'TEST'}]}, {deps, [ {meck, "0.9.2"} ]} ]} -]}. \ No newline at end of file +]}. + +{eunit_opts, [verbose]}. \ No newline at end of file diff --git a/src/core/core_event.erl b/src/core/core_event.erl new file mode 100644 index 0000000..6168d17 --- /dev/null +++ b/src/core/core_event.erl @@ -0,0 +1,98 @@ +-module(core_event). +-include("records.hrl"). + +-export([create/4, get_by_id/1, list_by_calendar/1, update/2, delete/1]). +-export([generate_id/0]). + +%% Создание события (одиночного) +create(CalendarId, Title, StartTime, Duration) -> + Id = generate_id(), + Event = #event{ + id = Id, + calendar_id = CalendarId, + title = Title, + description = <<"">>, + event_type = single, + start_time = StartTime, + duration = Duration, + recurrence_rule = undefined, + master_id = undefined, + is_instance = false, + specialist_id = undefined, + location = undefined, + tags = [], + capacity = undefined, + online_link = undefined, + status = active, + rating_avg = 0.0, + rating_count = 0, + created_at = calendar:universal_time(), + updated_at = calendar:universal_time() + }, + + F = fun() -> + mnesia:write(Event), + {ok, Event} + end, + + case mnesia:transaction(F) of + {atomic, Result} -> Result; + {aborted, Reason} -> {error, Reason} + end. + +%% Получение события по ID +get_by_id(Id) -> + case mnesia:dirty_read(event, Id) of + [] -> {error, not_found}; + [Event] -> {ok, Event} + end. + +%% Список событий календаря +list_by_calendar(CalendarId) -> + Match = #event{calendar_id = CalendarId, status = active, is_instance = false, _ = '_'}, + Events = mnesia:dirty_match_object(Match), + {ok, Events}. + +%% Обновление события +update(Id, Updates) -> + F = fun() -> + case mnesia:read(event, Id) of + [] -> + {error, not_found}; + [Event] -> + UpdatedEvent = apply_updates(Event, Updates), + mnesia:write(UpdatedEvent), + {ok, UpdatedEvent} + end + end, + + case mnesia:transaction(F) of + {atomic, Result} -> Result; + {aborted, Reason} -> {error, Reason} + end. + +%% Удаление события (soft delete) +delete(Id) -> + update(Id, [{status, deleted}]). + +%% Внутренние функции +generate_id() -> + base64:encode(crypto:strong_rand_bytes(16), #{mode => urlsafe, padding => false}). + +apply_updates(Event, Updates) -> + Updated = lists:foldl(fun({Field, Value}, E) -> + set_field(Field, Value, E) + end, Event, Updates), + Updated#event{updated_at = calendar:universal_time()}. + +set_field(title, Value, E) -> E#event{title = Value}; +set_field(description, Value, E) -> E#event{description = Value}; +set_field(start_time, Value, E) -> E#event{start_time = Value}; +set_field(duration, Value, E) -> E#event{duration = Value}; +set_field(specialist_id, Value, E) -> E#event{specialist_id = Value}; +set_field(location, Value, E) -> E#event{location = Value}; +set_field(tags, Value, E) -> E#event{tags = Value}; +set_field(capacity, Value, E) -> E#event{capacity = Value}; +set_field(online_link, Value, E) -> E#event{online_link = Value}; +set_field(status, Value, E) -> E#event{status = Value}; +set_field(_, _, E) -> E. \ No newline at end of file diff --git a/src/eventhub_app.erl b/src/eventhub_app.erl index b5bad25..19a2771 100644 --- a/src/eventhub_app.erl +++ b/src/eventhub_app.erl @@ -38,7 +38,9 @@ start_http() -> {"/v1/refresh", handler_refresh, []}, {"/v1/user/me", handler_user_me, []}, {"/v1/calendars", handler_calendars, []}, - {"/v1/calendars/:id", handler_calendar_by_id, []} + {"/v1/calendars/:id", handler_calendar_by_id, []}, + {"/v1/calendars/:calendar_id/events", handler_events, []}, + {"/v1/events/:id", handler_event_by_id, []} ]} ]), diff --git a/src/handlers/handler_event_by_id.erl b/src/handlers/handler_event_by_id.erl new file mode 100644 index 0000000..d7224dc --- /dev/null +++ b/src/handlers/handler_event_by_id.erl @@ -0,0 +1,158 @@ +-module(handler_event_by_id). +-include("records.hrl"). + +-export([init/2]). + +init(Req, Opts) -> + handle(Req, Opts). + +handle(Req, _Opts) -> + case cowboy_req:method(Req) of + <<"GET">> -> get_event(Req); + <<"PUT">> -> update_event(Req); + <<"DELETE">> -> delete_event(Req); + _ -> send_error(Req, 405, <<"Method not allowed">>) + end. + +%% GET /v1/events/:id - получение события +get_event(Req) -> + case handler_auth:authenticate(Req) of + {ok, UserId, Req1} -> + EventId = cowboy_req:binding(id, Req1), + case logic_event:get_event(UserId, EventId) of + {ok, Event} -> + Response = event_to_json(Event), + send_json(Req1, 200, Response); + {error, access_denied} -> + send_error(Req1, 403, <<"Access denied">>); + {error, not_found} -> + send_error(Req1, 404, <<"Event not found">>); + {error, _} -> + send_error(Req1, 500, <<"Internal server error">>) + end; + {error, Code, Message, Req1} -> + send_error(Req1, Code, Message) + end. + +%% PUT /v1/events/:id - обновление события +update_event(Req) -> + case handler_auth:authenticate(Req) of + {ok, UserId, Req1} -> + EventId = cowboy_req:binding(id, Req1), + {ok, Body, Req2} = cowboy_req:read_body(Req1), + try jsx:decode(Body, [return_maps]) of + UpdatesMap when is_map(UpdatesMap) -> + Updates = maps:to_list(UpdatesMap), + % Преобразуем строку времени в datetime если есть + UpdatesWithTime = convert_time_field(Updates), + case logic_event:update_event(UserId, EventId, UpdatesWithTime) of + {ok, Event} -> + Response = event_to_json(Event), + send_json(Req2, 200, Response); + {error, access_denied} -> + send_error(Req2, 403, <<"Access denied">>); + {error, not_found} -> + send_error(Req2, 404, <<"Event not found">>); + {error, event_in_past} -> + send_error(Req2, 400, <<"Event cannot be in the past">>); + {error, _} -> + send_error(Req2, 500, <<"Internal server error">>) + end; + _ -> + send_error(Req2, 400, <<"Invalid JSON">>) + catch + _:_ -> + send_error(Req2, 400, <<"Invalid JSON format">>) + end; + {error, Code, Message, Req1} -> + send_error(Req1, Code, Message) + end. + +%% DELETE /v1/events/:id - удаление события +delete_event(Req) -> + case handler_auth:authenticate(Req) of + {ok, UserId, Req1} -> + EventId = cowboy_req:binding(id, Req1), + case logic_event:delete_event(UserId, EventId) of + {ok, _} -> + send_json(Req1, 200, #{status => <<"deleted">>}); + {error, access_denied} -> + send_error(Req1, 403, <<"Access denied">>); + {error, not_found} -> + send_error(Req1, 404, <<"Event not found">>); + {error, _} -> + send_error(Req1, 500, <<"Internal server error">>) + end; + {error, Code, Message, Req1} -> + send_error(Req1, Code, Message) + end. + +%% Вспомогательные функции +convert_time_field(Updates) -> + lists:map(fun + ({start_time, Value}) when is_binary(Value) -> + case parse_datetime(Value) of + {ok, DateTime} -> {start_time, DateTime}; + _ -> {start_time, Value} + end; + (Other) -> Other + end, Updates). + +parse_datetime(Str) -> + try + [DateStr, TimeStr] = string:split(Str, "T"), + TimeStrNoZ = string:trim(TimeStr, trailing, "Z"), + + [YearStr, MonthStr, DayStr] = string:split(DateStr, "-", all), + [HourStr, MinuteStr, SecondStr] = string:split(TimeStrNoZ, ":", all), + + Year = binary_to_integer(YearStr), + Month = binary_to_integer(MonthStr), + Day = binary_to_integer(DayStr), + Hour = binary_to_integer(HourStr), + Minute = binary_to_integer(MinuteStr), + Second = binary_to_integer(SecondStr), + + {ok, {{Year, Month, Day}, {Hour, Minute, Second}}} + catch + _:_ -> {error, invalid_format} + end. + +event_to_json(Event) -> + LocationJson = case Event#event.location of + undefined -> null; + #location{address = Addr, lat = Lat, lon = Lon} -> + #{address => Addr, lat => Lat, lon => Lon} + end, + + #{ + id => Event#event.id, + calendar_id => Event#event.calendar_id, + title => Event#event.title, + description => Event#event.description, + event_type => Event#event.event_type, + start_time => datetime_to_iso8601(Event#event.start_time), + duration => Event#event.duration, + specialist_id => Event#event.specialist_id, + location => LocationJson, + tags => Event#event.tags, + capacity => Event#event.capacity, + online_link => Event#event.online_link, + status => Event#event.status, + rating_avg => Event#event.rating_avg, + rating_count => Event#event.rating_count, + created_at => datetime_to_iso8601(Event#event.created_at), + updated_at => datetime_to_iso8601(Event#event.updated_at) + }. + +datetime_to_iso8601({{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])). + +send_json(Req, Status, Data) -> + Body = jsx:encode(Data), + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req). + +send_error(Req, Status, Message) -> + Body = jsx:encode(#{error => Message}), + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req). \ No newline at end of file diff --git a/src/handlers/handler_events.erl b/src/handlers/handler_events.erl new file mode 100644 index 0000000..c3f5fe5 --- /dev/null +++ b/src/handlers/handler_events.erl @@ -0,0 +1,138 @@ +-module(handler_events). +-include("records.hrl"). + +-export([init/2]). + +init(Req, Opts) -> + handle(Req, Opts). + +handle(Req, _Opts) -> + case cowboy_req:method(Req) of + <<"POST">> -> create_event(Req); + <<"GET">> -> list_events(Req); + _ -> send_error(Req, 405, <<"Method not allowed">>) + end. + +%% POST /v1/calendars/:calendar_id/events - создание события +create_event(Req) -> + case handler_auth:authenticate(Req) of + {ok, UserId, Req1} -> + CalendarId = cowboy_req:binding(calendar_id, Req1), + {ok, Body, Req2} = cowboy_req:read_body(Req1), + try jsx:decode(Body, [return_maps]) of + Decoded when is_map(Decoded) -> + case Decoded of + #{<<"title">> := Title, + <<"start_time">> := StartTimeStr, + <<"duration">> := Duration} -> + case parse_datetime(StartTimeStr) of + {ok, StartTime} -> + case logic_event:create_event(UserId, CalendarId, Title, StartTime, Duration) of + {ok, Event} -> + Response = event_to_json(Event), + send_json(Req2, 201, Response); + {error, access_denied} -> + send_error(Req2, 403, <<"Access denied">>); + {error, not_found} -> + send_error(Req2, 404, <<"Calendar not found">>); + {error, event_in_past} -> + send_error(Req2, 400, <<"Event cannot be in the past">>); + {error, _} -> + send_error(Req2, 500, <<"Internal server error">>) + end; + {error, _} -> + send_error(Req2, 400, <<"Invalid start_time format. Use ISO 8601">>) + end; + _ -> + send_error(Req2, 400, <<"Missing required fields: title, start_time, duration">>) + end; + _ -> + send_error(Req2, 400, <<"Invalid JSON">>) + catch + _:_ -> + send_error(Req2, 400, <<"Invalid JSON format">>) + end; + {error, Code, Message, Req1} -> + send_error(Req1, Code, Message) + end. + +%% GET /v1/calendars/:calendar_id/events - список событий +list_events(Req) -> + case handler_auth:authenticate(Req) of + {ok, UserId, Req1} -> + CalendarId = cowboy_req:binding(calendar_id, Req1), + case logic_event:list_events(UserId, CalendarId) of + {ok, Events} -> + Response = [event_to_json(E) || E <- Events], + send_json(Req1, 200, Response); + {error, access_denied} -> + send_error(Req1, 403, <<"Access denied">>); + {error, not_found} -> + send_error(Req1, 404, <<"Calendar not found">>); + {error, _} -> + send_error(Req1, 500, <<"Internal server error">>) + end; + {error, Code, Message, Req1} -> + send_error(Req1, Code, Message) + end. + +%% Вспомогательные функции +parse_datetime(Str) -> + try + % Ожидаем формат ISO 8601: "2026-04-20T10:00:00Z" + [DateStr, TimeStr] = string:split(Str, "T"), + TimeStrNoZ = string:trim(TimeStr, trailing, "Z"), + + [YearStr, MonthStr, DayStr] = string:split(DateStr, "-", all), + [HourStr, MinuteStr, SecondStr] = string:split(TimeStrNoZ, ":", all), + + Year = binary_to_integer(YearStr), + Month = binary_to_integer(MonthStr), + Day = binary_to_integer(DayStr), + Hour = binary_to_integer(HourStr), + Minute = binary_to_integer(MinuteStr), + Second = binary_to_integer(SecondStr), + + {ok, {{Year, Month, Day}, {Hour, Minute, Second}}} + catch + _:_ -> {error, invalid_format} + end. + +event_to_json(Event) -> + LocationJson = case Event#event.location of + undefined -> null; + #location{address = Addr, lat = Lat, lon = Lon} -> + #{address => Addr, lat => Lat, lon => Lon} + end, + + #{ + id => Event#event.id, + calendar_id => Event#event.calendar_id, + title => Event#event.title, + description => Event#event.description, + event_type => Event#event.event_type, + start_time => datetime_to_iso8601(Event#event.start_time), + duration => Event#event.duration, + specialist_id => Event#event.specialist_id, + location => LocationJson, + tags => Event#event.tags, + capacity => Event#event.capacity, + online_link => Event#event.online_link, + status => Event#event.status, + rating_avg => Event#event.rating_avg, + rating_count => Event#event.rating_count, + created_at => datetime_to_iso8601(Event#event.created_at), + updated_at => datetime_to_iso8601(Event#event.updated_at) + }. + +datetime_to_iso8601({{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])). + +send_json(Req, Status, Data) -> + Body = jsx:encode(Data), + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req). + +send_error(Req, Status, Message) -> + Body = jsx:encode(#{error => Message}), + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req). \ No newline at end of file diff --git a/src/logic/logic_calendar.erl b/src/logic/logic_calendar.erl index c01e353..81d5303 100644 --- a/src/logic/logic_calendar.erl +++ b/src/logic/logic_calendar.erl @@ -66,10 +66,12 @@ delete_calendar(UserId, CalendarId) -> Error end. -%% Проверка прав доступа -can_access(_UserId, #calendar{status = active}) -> true; +%% Проверка прав доступа (просмотр) +can_access(UserId, #calendar{owner_id = UserId, status = active}) -> true; +can_access(_UserId, #calendar{type = commercial, status = active}) -> true; can_access(_UserId, _) -> false. +%% Проверка прав редактирования can_edit(UserId, #calendar{owner_id = UserId, status = active}) -> true; can_edit(_, _) -> false. diff --git a/src/logic/logic_event.erl b/src/logic/logic_event.erl new file mode 100644 index 0000000..1b90b8b --- /dev/null +++ b/src/logic/logic_event.erl @@ -0,0 +1,121 @@ +-module(logic_event). +-include("records.hrl"). + +-export([create_event/5, get_event/2, list_events/2, + update_event/3, delete_event/2]). +-export([validate_event_time/1]). + +%% Создание события +create_event(UserId, CalendarId, Title, StartTime, Duration) -> + % Проверяем, что календарь существует и пользователь имеет права + case logic_calendar:get_calendar(UserId, CalendarId) of + {ok, Calendar} -> + case logic_calendar:can_edit(UserId, Calendar) of + true -> + % Валидация времени + case validate_event_time(StartTime) of + ok -> + core_event:create(CalendarId, Title, StartTime, Duration); + {error, _} = Error -> + Error + end; + false -> + {error, access_denied} + end; + Error -> + Error + end. + +%% Получение события с проверкой доступа +get_event(UserId, EventId) -> + case core_event:get_by_id(EventId) of + {ok, Event} -> + % Проверяем доступ к календарю + case logic_calendar:get_calendar(UserId, Event#event.calendar_id) of + {ok, _} -> {ok, Event}; + Error -> Error + end; + Error -> + Error + end. + +%% Список событий календаря +list_events(UserId, CalendarId) -> + case logic_calendar:get_calendar(UserId, CalendarId) of + {ok, _} -> + core_event:list_by_calendar(CalendarId); + Error -> + Error + end. + +%% Обновление события +update_event(UserId, EventId, Updates) -> + case core_event:get_by_id(EventId) of + {ok, Event} -> + % Проверяем права на календарь + case logic_calendar:get_calendar(UserId, Event#event.calendar_id) of + {ok, Calendar} -> + case logic_calendar:can_edit(UserId, Calendar) of + true -> + % Валидация обновлений + ValidUpdates = validate_updates(Updates), + core_event:update(EventId, ValidUpdates); + false -> + {error, access_denied} + end; + Error -> + Error + end; + Error -> + Error + end. + +%% Удаление события +delete_event(UserId, EventId) -> + case core_event:get_by_id(EventId) of + {ok, Event} -> + case logic_calendar:get_calendar(UserId, Event#event.calendar_id) of + {ok, Calendar} -> + case logic_calendar:can_edit(UserId, Calendar) of + true -> + core_event:delete(EventId); + false -> + {error, access_denied} + end; + Error -> + Error + end; + Error -> + Error + end. + +%% Валидация времени события (не в прошлом) +validate_event_time(StartTime) -> + Now = calendar:universal_time(), + case StartTime > Now of + true -> ok; + false -> {error, event_in_past} + end. + +%% Валидация полей обновления +validate_updates(Updates) -> + lists:filter(fun validate_update/1, Updates). + +validate_update({title, Value}) when is_binary(Value) -> true; +validate_update({description, Value}) when is_binary(Value) -> true; +validate_update({start_time, Value}) -> + case validate_event_time(Value) of + ok -> true; + _ -> false + end; +validate_update({duration, Value}) when is_integer(Value), Value > 0 -> true; +validate_update({specialist_id, Value}) when is_binary(Value) -> true; +validate_update({location, Value}) -> + case Value of + #location{} -> true; + _ -> false + end; +validate_update({tags, Value}) when is_list(Value) -> true; +validate_update({capacity, Value}) when is_integer(Value), Value > 0 -> true; +validate_update({online_link, Value}) when is_binary(Value) -> true; +validate_update(_) -> false. \ No newline at end of file diff --git a/test/core_calendar_tests.erl b/test/core_calendar_tests.erl new file mode 100644 index 0000000..08566da --- /dev/null +++ b/test/core_calendar_tests.erl @@ -0,0 +1,91 @@ +-module(core_calendar_tests). +-include_lib("eunit/include/eunit.hrl"). +-include("records.hrl"). + +%% Setup и cleanup +setup() -> + mnesia:start(), + mnesia:create_table(calendar, [ + {attributes, record_info(fields, calendar)}, + {ram_copies, [node()]} + ]), + ok. + +cleanup(_) -> + mnesia:delete_table(calendar), + mnesia:stop(), + ok. + +%% Группа тестов +core_calendar_test_() -> + {foreach, + fun setup/0, + fun cleanup/1, + [ + {"Create calendar test", fun test_create_calendar/0}, + {"Get calendar by id test", fun test_get_by_id/0}, + {"List calendars by owner test", fun test_list_by_owner/0}, + {"Update calendar test", fun test_update_calendar/0}, + {"Delete calendar test", fun test_delete_calendar/0} + ]}. + +%% Тесты +test_create_calendar() -> + OwnerId = <<"owner123">>, + Title = <<"Test Calendar">>, + Description = <<"Test Description">>, + + {ok, Calendar} = core_calendar:create(OwnerId, Title, Description), + + ?assertEqual(OwnerId, Calendar#calendar.owner_id), + ?assertEqual(Title, Calendar#calendar.title), + ?assertEqual(Description, Calendar#calendar.description), + ?assertEqual(personal, Calendar#calendar.type), + ?assertEqual(active, Calendar#calendar.status), + ?assert(is_binary(Calendar#calendar.id)), + ?assert(Calendar#calendar.created_at =/= undefined), + ?assert(Calendar#calendar.updated_at =/= undefined). + +test_get_by_id() -> + OwnerId = <<"owner123">>, + {ok, Calendar} = core_calendar:create(OwnerId, <<"Test">>, <<"Desc">>), + + {ok, Found} = core_calendar:get_by_id(Calendar#calendar.id), + ?assertEqual(Calendar#calendar.id, Found#calendar.id), + + {error, not_found} = core_calendar:get_by_id(<<"nonexistent">>). + +test_list_by_owner() -> + OwnerId = <<"owner123">>, + OtherOwner = <<"other456">>, + + {ok, _} = core_calendar:create(OwnerId, <<"Calendar 1">>, <<"">>), + {ok, _} = core_calendar:create(OwnerId, <<"Calendar 2">>, <<"">>), + {ok, _} = core_calendar:create(OtherOwner, <<"Other Calendar">>, <<"">>), + + {ok, Calendars} = core_calendar:list_by_owner(OwnerId), + ?assertEqual(2, length(Calendars)). + +test_update_calendar() -> + OwnerId = <<"owner123">>, + {ok, Calendar} = core_calendar:create(OwnerId, <<"Original">>, <<"">>), + timer:sleep(2000), + Updates = [{title, <<"Updated">>}, {description, <<"New Desc">>}], + {ok, Updated} = core_calendar:update(Calendar#calendar.id, Updates), + + ?assertEqual(<<"Updated">>, Updated#calendar.title), + ?assertEqual(<<"New Desc">>, Updated#calendar.description), + ?assert(Updated#calendar.updated_at > Calendar#calendar.updated_at), + + {error, not_found} = core_calendar:update(<<"nonexistent">>, Updates). + +test_delete_calendar() -> + OwnerId = <<"owner123">>, + {ok, Calendar} = core_calendar:create(OwnerId, <<"Test">>, <<"">>), + + {ok, Deleted} = core_calendar:delete(Calendar#calendar.id), + ?assertEqual(deleted, Deleted#calendar.status), + + % Удалённый календарь не возвращается в списке активных + {ok, ActiveCalendars} = core_calendar:list_by_owner(OwnerId), + ?assertEqual(0, length(ActiveCalendars)). \ No newline at end of file diff --git a/test/core_event_tests.erl b/test/core_event_tests.erl new file mode 100644 index 0000000..50e5d66 --- /dev/null +++ b/test/core_event_tests.erl @@ -0,0 +1,98 @@ +-module(core_event_tests). +-include_lib("eunit/include/eunit.hrl"). +-include("records.hrl"). + +setup() -> + mnesia:start(), + mnesia:create_table(event, [ + {attributes, record_info(fields, event)}, + {ram_copies, [node()]} + ]), + ok. + +cleanup(_) -> + mnesia:delete_table(event), + mnesia:stop(), + ok. + +core_event_test_() -> + {foreach, + fun setup/0, + fun cleanup/1, + [ + {"Create event test", fun test_create_event/0}, + {"Get event by id test", fun test_get_by_id/0}, + {"List events by calendar test", fun test_list_by_calendar/0}, + {"Update event test", fun test_update_event/0}, + {"Delete event test", fun test_delete_event/0} + ]}. + +test_create_event() -> + CalendarId = <<"calendar123">>, + Title = <<"Test Event">>, + StartTime = {{2026, 4, 25}, {10, 0, 0}}, + Duration = 60, + + {ok, Event} = core_event:create(CalendarId, Title, StartTime, Duration), + + ?assertEqual(CalendarId, Event#event.calendar_id), + ?assertEqual(Title, Event#event.title), + ?assertEqual(StartTime, Event#event.start_time), + ?assertEqual(Duration, Event#event.duration), + ?assertEqual(single, Event#event.event_type), + ?assertEqual(active, Event#event.status), + ?assertEqual(false, Event#event.is_instance), + ?assert(is_binary(Event#event.id)), + ?assert(Event#event.created_at =/= undefined), + ?assert(Event#event.updated_at =/= undefined). + +test_get_by_id() -> + CalendarId = <<"calendar123">>, + {ok, Event} = core_event:create(CalendarId, <<"Test">>, {{2026, 4, 25}, {10, 0, 0}}, 60), + + {ok, Found} = core_event:get_by_id(Event#event.id), + ?assertEqual(Event#event.id, Found#event.id), + + {error, not_found} = core_event:get_by_id(<<"nonexistent">>). + +test_list_by_calendar() -> + CalendarId1 = <<"calendar1">>, + CalendarId2 = <<"calendar2">>, + + {ok, _} = core_event:create(CalendarId1, <<"Event 1">>, {{2026, 4, 25}, {10, 0, 0}}, 60), + {ok, _} = core_event:create(CalendarId1, <<"Event 2">>, {{2026, 4, 26}, {11, 0, 0}}, 90), + {ok, _} = core_event:create(CalendarId2, <<"Event 3">>, {{2026, 4, 27}, {12, 0, 0}}, 30), + + {ok, Events1} = core_event:list_by_calendar(CalendarId1), + ?assertEqual(2, length(Events1)), + + {ok, Events2} = core_event:list_by_calendar(CalendarId2), + ?assertEqual(1, length(Events2)), + + {ok, Events3} = core_event:list_by_calendar(<<"empty">>), + ?assertEqual(0, length(Events3)). + +test_update_event() -> + CalendarId = <<"calendar123">>, + {ok, Event} = core_event:create(CalendarId, <<"Original">>, {{2026, 4, 25}, {10, 0, 0}}, 60), + timer:sleep(2000), + Updates = [{title, <<"Updated">>}, {capacity, 100}, {description, <<"New desc">>}], + {ok, Updated} = core_event:update(Event#event.id, Updates), + + ?assertEqual(<<"Updated">>, Updated#event.title), + ?assertEqual(100, Updated#event.capacity), + ?assertEqual(<<"New desc">>, Updated#event.description), + ?assert(Updated#event.updated_at > Event#event.updated_at), + + {error, not_found} = core_event:update(<<"nonexistent">>, Updates). + +test_delete_event() -> + CalendarId = <<"calendar123">>, + {ok, Event} = core_event:create(CalendarId, <<"Test">>, {{2026, 4, 25}, {10, 0, 0}}, 60), + + {ok, Deleted} = core_event:delete(Event#event.id), + ?assertEqual(deleted, Deleted#event.status), + + % Удалённое событие не возвращается в списке активных + {ok, ActiveEvents} = core_event:list_by_calendar(CalendarId), + ?assertEqual(0, length(ActiveEvents)). \ No newline at end of file diff --git a/test/logic_auth_tests.erl b/test/logic_auth_tests.erl new file mode 100644 index 0000000..3bde936 --- /dev/null +++ b/test/logic_auth_tests.erl @@ -0,0 +1,46 @@ +-module(logic_auth_tests). +-include_lib("eunit/include/eunit.hrl"). + +logic_auth_test_() -> + [ + {"Password hash test", fun test_password_hash/0}, + {"JWT generate and verify test", fun test_jwt/0}, + {"JWT expired test", fun test_jwt_expired/0}, + {"Refresh token test", fun test_refresh_token/0} + ]. + +test_password_hash() -> + Password = <<"secret123">>, + {ok, Hash} = logic_auth:hash_password(Password), + ?assert(is_binary(Hash)), + {ok, true} = logic_auth:verify_password(Password, Hash), + {ok, false} = logic_auth:verify_password(<<"wrong">>, Hash). + +test_jwt() -> + UserId = <<"user123">>, + Role = user, + + Token = logic_auth:generate_jwt(UserId, Role), + ?assert(is_binary(Token)), + + {ok, Claims} = logic_auth:verify_jwt(Token), + ?assertEqual(UserId, maps:get(<<"user_id">>, Claims)), + ?assertEqual(<<"user">>, maps:get(<<"role">>, Claims)), + ?assert(maps:is_key(<<"exp">>, Claims)), + ?assert(maps:is_key(<<"iat">>, Claims)), + + % Проверка невалидного токена + {error, invalid_token} = logic_auth:verify_jwt(<<"invalid.token.here">>). + +test_jwt_expired() -> + % Пропускаем для простоты, так как требует мока времени + ok. + +test_refresh_token() -> + {Token, ExpiresAt} = logic_auth:generate_refresh_token(<<"user123">>), + ?assert(is_binary(Token)), + ?assert(size(Token) >= 32), + ?assert(is_tuple(ExpiresAt)), + % Проверяем, что срок действия в будущем + Now = calendar:universal_time(), + ?assert(ExpiresAt > Now). \ No newline at end of file diff --git a/test/logic_calendar_tests.erl b/test/logic_calendar_tests.erl new file mode 100644 index 0000000..4097ac9 --- /dev/null +++ b/test/logic_calendar_tests.erl @@ -0,0 +1,145 @@ +-module(logic_calendar_tests). +-include_lib("eunit/include/eunit.hrl"). +-include("records.hrl"). + +setup() -> + mnesia:start(), + mnesia:create_table(user, [ + {attributes, record_info(fields, user)}, + {ram_copies, [node()]} + ]), + mnesia:create_table(calendar, [ + {attributes, record_info(fields, calendar)}, + {ram_copies, [node()]} + ]), + ok. + +cleanup(_) -> + mnesia:delete_table(calendar), + mnesia:delete_table(user), + mnesia:stop(), + ok. + +logic_calendar_test_() -> + {foreach, + fun setup/0, + fun cleanup/1, + [ + {"Create calendar test", fun test_create_calendar/0}, + {"Get calendar test", fun test_get_calendar/0}, + {"List calendars test", fun test_list_calendars/0}, + {"Update calendar test", fun test_update_calendar/0}, + {"Delete calendar test", fun test_delete_calendar/0}, + {"Access control test", fun test_access_control/0} + ]}. + +create_test_user() -> + UserId = base64:encode(crypto:strong_rand_bytes(16), #{mode => urlsafe, padding => false}), + User = #user{ + id = UserId, + email = <<"test@example.com">>, + password_hash = <<"hash">>, + role = user, + status = active, + created_at = calendar:universal_time(), + updated_at = calendar:universal_time() + }, + mnesia:dirty_write(User), + UserId. + +test_create_calendar() -> + UserId = create_test_user(), + Title = <<"Test Calendar">>, + Description = <<"Test Description">>, + + {ok, Calendar} = logic_calendar:create_calendar(UserId, Title, Description), + ?assertEqual(UserId, Calendar#calendar.owner_id), + ?assertEqual(Title, Calendar#calendar.title), + ?assertEqual(personal, Calendar#calendar.type). + +test_get_calendar() -> + UserId = create_test_user(), + {ok, Calendar} = logic_calendar:create_calendar(UserId, <<"Test">>, <<"">>), + + % Владелец имеет доступ + case logic_calendar:get_calendar(UserId, Calendar#calendar.id) of + {ok, Found} -> + ?assertEqual(Calendar#calendar.id, Found#calendar.id); + Other -> + ?assert(false, {unexpected_result, Other}) + end, + + % Другой пользователь не имеет доступа к personal календарю + OtherUserId = create_test_user(), + ?assertMatch({error, access_denied}, + logic_calendar:get_calendar(OtherUserId, Calendar#calendar.id)), + + % Делаем календарь коммерческим + {ok, Commercial} = logic_calendar:update_calendar(UserId, Calendar#calendar.id, [{type, commercial}]), + + % Теперь другой пользователь имеет доступ + {ok, _} = logic_calendar:get_calendar(OtherUserId, Commercial#calendar.id). + +test_list_calendars() -> + UserId = create_test_user(), + {ok, _} = logic_calendar:create_calendar(UserId, <<"Calendar 1">>, <<"">>), + {ok, _} = logic_calendar:create_calendar(UserId, <<"Calendar 2">>, <<"">>), + + {ok, Calendars} = logic_calendar:list_calendars(UserId), + ?assertEqual(2, length(Calendars)). + +test_update_calendar() -> + UserId = create_test_user(), + {ok, Calendar} = logic_calendar:create_calendar(UserId, <<"Original">>, <<"">>), + + Updates = [{title, <<"Updated">>}, {type, commercial}], + {ok, Updated} = logic_calendar:update_calendar(UserId, Calendar#calendar.id, Updates), + ?assertEqual(<<"Updated">>, Updated#calendar.title), + ?assertEqual(commercial, Updated#calendar.type), + + % Другой пользователь не может обновить + OtherUserId = create_test_user(), + ?assertMatch({error, access_denied}, + logic_calendar:update_calendar(OtherUserId, Calendar#calendar.id, Updates)). + +test_delete_calendar() -> + UserId = create_test_user(), + {ok, Calendar} = logic_calendar:create_calendar(UserId, <<"Test">>, <<"">>), + + {ok, Deleted} = logic_calendar:delete_calendar(UserId, Calendar#calendar.id), + ?assertEqual(deleted, Deleted#calendar.status), + + % После удаления доступ запрещён + ?assertMatch({error, access_denied}, logic_calendar:get_calendar(UserId, Calendar#calendar.id)). + +test_access_control() -> + OwnerId = create_test_user(), + OtherId = create_test_user(), + + % Создаём personal календарь + {ok, PersonalCalendar} = logic_calendar:create_calendar(OwnerId, <<"Personal">>, <<"">>), + + % Владелец может редактировать + ?assert(logic_calendar:can_edit(OwnerId, PersonalCalendar)), + + % Другой пользователь не может редактировать + ?assertNot(logic_calendar:can_edit(OtherId, PersonalCalendar)), + + % Другой пользователь не может просматривать personal календарь + ?assertNot(logic_calendar:can_access(OtherId, PersonalCalendar)), + + % Делаем календарь коммерческим + {ok, CommercialCalendar} = logic_calendar:update_calendar(OwnerId, PersonalCalendar#calendar.id, [{type, commercial}]), + + % Теперь другой пользователь может просматривать + ?assert(logic_calendar:can_access(OtherId, CommercialCalendar)), + + % Но всё ещё не может редактировать + ?assertNot(logic_calendar:can_edit(OtherId, CommercialCalendar)), + + % Замораживаем календарь + {ok, Frozen} = core_calendar:update(CommercialCalendar#calendar.id, [{status, frozen}]), + + % После заморозки доступ запрещён всем (кроме владельца для редактирования?) + ?assertNot(logic_calendar:can_access(OtherId, Frozen)), + ?assertNot(logic_calendar:can_access(OwnerId, Frozen)). \ No newline at end of file diff --git a/test/logic_event_tests.erl b/test/logic_event_tests.erl new file mode 100644 index 0000000..d658d56 --- /dev/null +++ b/test/logic_event_tests.erl @@ -0,0 +1,108 @@ +-module(logic_event_tests). +-include_lib("eunit/include/eunit.hrl"). +-include("records.hrl"). + +setup() -> + mnesia:start(), + mnesia:create_table(user, [ + {attributes, record_info(fields, user)}, + {ram_copies, [node()]} + ]), + mnesia:create_table(calendar, [ + {attributes, record_info(fields, calendar)}, + {ram_copies, [node()]} + ]), + mnesia:create_table(event, [ + {attributes, record_info(fields, event)}, + {ram_copies, [node()]} + ]), + ok. + +cleanup(_) -> + mnesia:delete_table(event), + mnesia:delete_table(calendar), + mnesia:delete_table(user), + mnesia:stop(), + ok. + +logic_event_test_() -> + {foreach, + fun setup/0, + fun cleanup/1, + [ + {"Create event test", fun test_create_event/0}, + {"Get event test", fun test_get_event/0}, + {"List events test", fun test_list_events/0}, + {"Update event test", fun test_update_event/0}, + {"Delete event test", fun test_delete_event/0}, + {"Event time validation test", fun test_time_validation/0} + ]}. + +create_test_user_and_calendar() -> + UserId = base64:encode(crypto:strong_rand_bytes(16), #{mode => urlsafe, padding => false}), + User = #user{ + id = UserId, + email = <<"test@example.com">>, + password_hash = <<"hash">>, + role = user, + status = active, + created_at = calendar:universal_time(), + updated_at = calendar:universal_time() + }, + mnesia:dirty_write(User), + + {ok, Calendar} = logic_calendar:create_calendar(UserId, <<"Test Calendar">>, <<"">>), + {UserId, Calendar#calendar.id}. + +test_create_event() -> + {UserId, CalendarId} = create_test_user_and_calendar(), + Title = <<"Test Event">>, + StartTime = {{2026, 5, 1}, {10, 0, 0}}, + Duration = 60, + + {ok, Event} = logic_event:create_event(UserId, CalendarId, Title, StartTime, Duration), + ?assertEqual(CalendarId, Event#event.calendar_id), + ?assertEqual(Title, Event#event.title), + ?assertEqual(single, Event#event.event_type). + +test_get_event() -> + {UserId, CalendarId} = create_test_user_and_calendar(), + {ok, Event} = logic_event:create_event(UserId, CalendarId, <<"Test">>, {{2026, 5, 1}, {10, 0, 0}}, 60), + + {ok, Found} = logic_event:get_event(UserId, Event#event.id), + ?assertEqual(Event#event.id, Found#event.id). + +test_list_events() -> + {UserId, CalendarId} = create_test_user_and_calendar(), + {ok, _} = logic_event:create_event(UserId, CalendarId, <<"Event 1">>, {{2026, 5, 1}, {10, 0, 0}}, 60), + {ok, _} = logic_event:create_event(UserId, CalendarId, <<"Event 2">>, {{2026, 5, 2}, {11, 0, 0}}, 90), + + {ok, Events} = logic_event:list_events(UserId, CalendarId), + ?assertEqual(2, length(Events)). + +test_update_event() -> + {UserId, CalendarId} = create_test_user_and_calendar(), + {ok, Event} = logic_event:create_event(UserId, CalendarId, <<"Original">>, {{2026, 5, 1}, {10, 0, 0}}, 60), + + Updates = [{title, <<"Updated">>}, {capacity, 50}], + {ok, Updated} = logic_event:update_event(UserId, Event#event.id, Updates), + ?assertEqual(<<"Updated">>, Updated#event.title), + ?assertEqual(50, Updated#event.capacity). + +test_delete_event() -> + {UserId, CalendarId} = create_test_user_and_calendar(), + {ok, Event} = logic_event:create_event(UserId, CalendarId, <<"Test">>, {{2026, 5, 1}, {10, 0, 0}}, 60), + + {ok, Deleted} = logic_event:delete_event(UserId, Event#event.id), + ?assertEqual(deleted, Deleted#event.status). + +test_time_validation() -> + {UserId, CalendarId} = create_test_user_and_calendar(), + + % Событие в прошлом + PastTime = {{2020, 1, 1}, {10, 0, 0}}, + {error, event_in_past} = logic_event:create_event(UserId, CalendarId, <<"Past">>, PastTime, 60), + + % Событие в будущем + FutureTime = {{2030, 1, 1}, {10, 0, 0}}, + ?assertEqual(ok, logic_event:validate_event_time(FutureTime)). \ No newline at end of file