diff --git a/src/handlers/handler_calendar_by_id.erl b/src/handlers/handler_calendar_by_id.erl index 52126e8..7919f59 100755 --- a/src/handlers/handler_calendar_by_id.erl +++ b/src/handlers/handler_calendar_by_id.erl @@ -154,6 +154,9 @@ update_calendar(Req) -> handler_utils:send_error(Req2, 402, <<"Subscription required for commercial calendar">>); {error, not_found} -> handler_utils:send_error(Req2, 404, <<"Calendar not found">>); + {error, {invalid_settings, Key}} when is_binary(Key) -> + handler_utils:send_error(Req2, 400, + <<"Invalid settings.", Key/binary>>); {error, _} -> handler_utils:send_error(Req2, 500, <<"Internal server error">>) end; diff --git a/src/logic/logic_calendar.erl b/src/logic/logic_calendar.erl index 9851fbd..7c5706c 100755 --- a/src/logic/logic_calendar.erl +++ b/src/logic/logic_calendar.erl @@ -4,6 +4,7 @@ -export([create_calendar/3, create_calendar/4, create_calendar/5, get_calendar/2, list_calendars/1, update_calendar/3, delete_calendar/2, ensure_default_calendar/1]). -export([can_access/2, can_edit/2, booking_open/1]). +-export([normalize_settings/1]). -export([admin_list_all/0, admin_get_by_id/1, admin_update/2, admin_delete/1]). %% Создание календаря с политикой по умолчанию (manual) @@ -109,12 +110,16 @@ update_calendar(UserId, CalendarId, Updates) -> {ok, Calendar} -> case can_edit(UserId, Calendar) of true -> - ValidUpdates = validate_updates(Updates), - case gate_type_change(UserId, Calendar, ValidUpdates) of + case validate_updates(Updates) of {error, _} = Err -> Err; - {ok, ValidUpdates2} -> - apply_calendar_update(CalendarId, Calendar, ValidUpdates2) + {ok, ValidUpdates} -> + case gate_type_change(UserId, Calendar, ValidUpdates) of + {error, _} = Err -> + Err; + {ok, ValidUpdates2} -> + apply_calendar_update(CalendarId, Calendar, ValidUpdates2) + end end; false -> {error, access_denied} @@ -225,24 +230,41 @@ can_edit(_UserId, #calendar{owner_id = _OwnerId}) -> can_edit(_, _) -> false. -%% Валидация полей обновления +%% Валидация полей обновления. +%% Known settings keys with invalid shape → {error, {invalid_settings, Key}}. +%% Unknown settings keys (e.g. week_patterns) are preserved as-is. validate_updates(Updates) -> - lists:filtermap(fun(U) -> - case normalize_update(U) of - false -> false; - Norm -> - case validate_update(Norm) of - true -> {true, Norm}; - false -> false - end - end - end, Updates). + validate_updates(Updates, []). + +validate_updates([], Acc) -> + {ok, lists:reverse(Acc)}; +validate_updates([U | Rest], Acc) -> + case normalize_update(U) of + false -> + validate_updates(Rest, Acc); + {error, _} = Err -> + Err; + Norm -> + case validate_update(Norm) of + true -> validate_updates(Rest, [Norm | Acc]); + false -> validate_updates(Rest, Acc) + end + end. normalize_update({type, <<"personal">>}) -> {type, personal}; normalize_update({type, <<"commercial">>}) -> {type, commercial}; normalize_update({type, personal}) -> {type, personal}; normalize_update({type, commercial}) -> {type, commercial}; -normalize_update({<<"settings">>, Value}) when is_map(Value) -> {settings, Value}; +normalize_update({<<"settings">>, Value}) when is_map(Value) -> + case normalize_settings(Value) of + {ok, Map} -> {settings, Map}; + {error, _} = Err -> Err + end; +normalize_update({settings, Value}) when is_map(Value) -> + case normalize_settings(Value) of + {ok, Map} -> {settings, Map}; + {error, _} = Err -> Err + end; normalize_update({<<"title">>, Value}) when is_binary(Value) -> {title, Value}; normalize_update({<<"description">>, Value}) when is_binary(Value) -> {description, Value}; normalize_update({<<"tags">>, Value}) when is_list(Value) -> {tags, Value}; @@ -261,7 +283,7 @@ validate_update({short_name, Value}) when is_binary(Value) -> true; validate_update({category, Value}) when is_binary(Value) -> true; validate_update({color, Value}) when is_binary(Value) -> true; validate_update({image_url, Value}) when is_binary(Value) -> true; -%% Opaque JSON object for client prefs (e.g. week_patterns for schedule fill). +%% Documented org-default keys validated in normalize_settings/1; unknown keys passthrough. validate_update({settings, Value}) when is_map(Value) -> true; validate_update({confirmation, Value}) -> case Value of @@ -272,6 +294,77 @@ validate_update({confirmation, Value}) -> end; validate_update(_) -> false. +%%%------------------------------------------------------------------- +%%% @doc Validate documented calendar.settings keys for org defaults. +%%% Unknown keys are kept (forward-compat, e.g. week_patterns). +%%% @end +%%%------------------------------------------------------------------- +-spec normalize_settings(map()) -> {ok, map()} | {error, {invalid_settings, binary()}}. +normalize_settings(Map) when is_map(Map) -> + normalize_settings_keys(maps:keys(Map), Map). + +normalize_settings_keys([], Map) -> + {ok, Map}; +normalize_settings_keys([Key | Rest], Map) -> + case validate_settings_key(Key, maps:get(Key, Map)) of + ok -> + normalize_settings_keys(Rest, Map); + {error, _} = Err -> + Err + end. + +validate_settings_key(<<"default_location">>, Val) -> + validate_default_location(Val); +validate_settings_key(<<"default_duration_minutes">>, Val) -> + validate_default_duration(Val); +validate_settings_key(<<"default_recurrence">>, Val) -> + validate_default_recurrence(Val); +validate_settings_key(_Unknown, _Val) -> + ok. + +validate_default_location(Loc) when is_map(Loc) -> + case maps:get(<<"address">>, Loc, undefined) of + Address when is_binary(Address), byte_size(Address) > 0 -> + HasLat = maps:is_key(<<"lat">>, Loc), + HasLon = maps:is_key(<<"lon">>, Loc), + case {HasLat, HasLon} of + {false, false} -> + ok; + {true, true} -> + Lat = maps:get(<<"lat">>, Loc), + Lon = maps:get(<<"lon">>, Loc), + case is_number(Lat) andalso is_number(Lon) of + true -> ok; + false -> {error, {invalid_settings, <<"default_location">>}} + end; + _ -> + {error, {invalid_settings, <<"default_location">>}} + end; + _ -> + {error, {invalid_settings, <<"default_location">>}} + end; +validate_default_location(_) -> + {error, {invalid_settings, <<"default_location">>}}. + +validate_default_duration(N) when is_integer(N), N >= 1, N =< 1440 -> + ok; +validate_default_duration(_) -> + {error, {invalid_settings, <<"default_duration_minutes">>}}. + +validate_default_recurrence(null) -> + ok; +validate_default_recurrence(Rec) when is_map(Rec) -> + Enabled = maps:get(<<"enabled">>, Rec, undefined), + Freq = maps:get(<<"freq">>, Rec, undefined), + Interval = maps:get(<<"interval">>, Rec, undefined), + ValidFreq = Freq =:= <<"DAILY">> orelse Freq =:= <<"WEEKLY">> orelse Freq =:= <<"MONTHLY">>, + case is_boolean(Enabled) andalso ValidFreq andalso is_integer(Interval) andalso Interval >= 1 of + true -> ok; + false -> {error, {invalid_settings, <<"default_recurrence">>}} + end; +validate_default_recurrence(_) -> + {error, {invalid_settings, <<"default_recurrence">>}}. + %% ─── Административные функции ──────────────────────────────────────── -spec admin_list_all() -> {ok, [map()]} | {error, term()}. admin_list_all() -> diff --git a/test/api/users/user_calendar_by_id_tests.erl b/test/api/users/user_calendar_by_id_tests.erl index bccfe17..bb9d296 100644 --- a/test/api/users/user_calendar_by_id_tests.erl +++ b/test/api/users/user_calendar_by_id_tests.erl @@ -62,7 +62,7 @@ test_update_calendar(Token, CalId) -> ct:pal(" OK"). test_update_calendar_settings(Token, CalId) -> - ct:pal(" TEST: Persist calendar.settings.week_patterns"), + ct:pal(" TEST: Persist calendar.settings week_patterns + org defaults"), Path = <<"/v1/calendars/", CalId/binary>>, Settings = #{ <<"week_patterns">> => [ @@ -73,12 +73,26 @@ test_update_calendar_settings(Token, CalId) -> #{<<"weekday">> => 1, <<"time">> => <<"10:00">>, <<"duration">> => 60, <<"title">> => <<"Open">>} ] } - ] + ], + <<"default_location">> => #{ + <<"address">> => <<"ул. Пример, 1">>, + <<"lat">> => 55.75, + <<"lon">> => 37.61 + }, + <<"default_duration_minutes">> => 60, + <<"default_recurrence">> => #{ + <<"enabled">> => true, + <<"freq">> => <<"WEEKLY">>, + <<"interval">> => 1 + } }, Updated = api_test_runner:client_put(Path, Token, #{settings => Settings}), ?assertEqual(Settings, maps:get(<<"settings">>, Updated)), Got = api_test_runner:client_get(Path, Token), ?assertEqual(Settings, maps:get(<<"settings">>, Got)), + Bad = api_test_runner:client_request(put, Path, Token, + jsx:encode(#{settings => #{default_location => #{address => <<"">>}}})), + ?assertMatch({ok, 400, _, _}, Bad), ct:pal(" OK"). test_update_calendar_forbidden(OtherToken, CalId) -> diff --git a/test/unit/logic_calendar_tests.erl b/test/unit/logic_calendar_tests.erl index da36a1a..f9a31ca 100755 --- a/test/unit/logic_calendar_tests.erl +++ b/test/unit/logic_calendar_tests.erl @@ -37,6 +37,8 @@ logic_calendar_test_() -> {"Update calendar test", fun test_update_calendar/0}, {"Persist calendar settings (week_patterns)", fun test_update_settings/0}, {"Reject non-map settings", fun test_update_settings_invalid/0}, + {"Org defaults settings round-trip", fun test_update_settings_org_defaults/0}, + {"Reject invalid default_location shape", fun test_update_settings_invalid_location/0}, {"Delete calendar test", fun test_delete_calendar/0}, {"Access control test", fun test_access_control/0}, {"Ensure default calendar test", fun test_ensure_default_calendar/0} @@ -152,6 +154,66 @@ test_update_settings_invalid() -> UserId, Calendar#calendar.id, [{settings, [<<"not-a-map">>]}]), ?assertEqual(#{}, Same#calendar.settings). +test_update_settings_org_defaults() -> + UserId = create_test_user(), + {ok, Calendar} = logic_calendar:create_calendar(UserId, <<"Salon">>, <<"">>, manual), + Patterns = [ + #{ + <<"id">> => <<"p1">>, + <<"name">> => <<"Weekdays">>, + <<"slots">> => [ + #{<<"weekday">> => 1, <<"time">> => <<"10:00">>, <<"duration">> => 60} + ] + } + ], + Settings = #{ + <<"week_patterns">> => Patterns, + <<"default_location">> => #{ + <<"address">> => <<"ул. Пример, 1">>, + <<"lat">> => 55.75, + <<"lon">> => 37.61 + }, + <<"default_duration_minutes">> => 90, + <<"default_recurrence">> => #{ + <<"enabled">> => true, + <<"freq">> => <<"WEEKLY">>, + <<"interval">> => 1 + } + }, + {ok, Updated} = logic_calendar:update_calendar( + UserId, Calendar#calendar.id, [{settings, Settings}]), + ?assertEqual(Settings, Updated#calendar.settings), + {ok, Reloaded} = logic_calendar:get_calendar(UserId, Calendar#calendar.id), + ?assertEqual(Settings, Reloaded#calendar.settings), + %% null clears recurrence default + Settings2 = Settings#{<<"default_recurrence">> => null}, + {ok, Updated2} = logic_calendar:update_calendar( + UserId, Calendar#calendar.id, [{settings, Settings2}]), + ?assertEqual(null, maps:get(<<"default_recurrence">>, Updated2#calendar.settings)). + +test_update_settings_invalid_location() -> + UserId = create_test_user(), + {ok, Calendar} = logic_calendar:create_calendar(UserId, <<"Salon">>, <<"">>, manual), + ?assertMatch({error, {invalid_settings, <<"default_location">>}}, + logic_calendar:update_calendar(UserId, Calendar#calendar.id, [ + {settings, #{<<"default_location">> => #{<<"address">> => <<"">>}}} + ])), + ?assertMatch({error, {invalid_settings, <<"default_location">>}}, + logic_calendar:update_calendar(UserId, Calendar#calendar.id, [ + {settings, #{<<"default_location">> => #{<<"address">> => <<"X">>, <<"lat">> => 1}}} + ])), + ?assertMatch({error, {invalid_settings, <<"default_duration_minutes">>}}, + logic_calendar:update_calendar(UserId, Calendar#calendar.id, [ + {settings, #{<<"default_duration_minutes">> => 0}} + ])), + ?assertEqual(#{}, + begin + {ok, Same} = logic_calendar:get_calendar(UserId, Calendar#calendar.id), + Same#calendar.settings + end), + ?assertMatch({error, {invalid_settings, <<"default_location">>}}, + logic_calendar:normalize_settings(#{<<"default_location">> => <<"street">>})). + test_delete_calendar() -> UserId = create_test_user(), {ok, Calendar} = logic_calendar:create_calendar(UserId, <<"Test">>, <<"">>, manual),