-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()]} ]), mnesia:create_table(subscription, [ {attributes, record_info(fields, subscription)}, {ram_copies, [node()]} ]), ok. cleanup(_) -> mnesia:delete_table(subscription), 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}, {"Create commercial requires subscription", fun test_create_commercial_gate/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}, {"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}, {"Second personal rejected", fun test_personal_exists/0}, {"Personal type change forbidden", fun test_default_calendar_type_change/0}, {"Backfill extras to commercial or delete", fun test_backfill_extras/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, nickname = <<>>, 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">>, Confirmation = auto, {ok, Calendar} = logic_calendar:create_calendar(UserId, Title, Description, Confirmation), ?assertEqual(UserId, Calendar#calendar.owner_id), ?assertEqual(Title, Calendar#calendar.title), ?assertEqual(personal, Calendar#calendar.type), ?assertEqual(Confirmation, Calendar#calendar.confirmation). test_create_commercial_gate() -> UserId = create_test_user(), ?assertMatch({error, subscription_required}, logic_calendar:create_calendar(UserId, <<"Studio">>, <<>>, manual, commercial)), {ok, Personal} = logic_calendar:create_calendar(UserId, <<"Personal">>, <<>>, manual, personal), ?assertEqual(personal, Personal#calendar.type), {ok, _} = logic_subscription:start_trial(UserId), {ok, Commercial} = logic_calendar:create_calendar(UserId, <<"Studio">>, <<>>, manual, commercial), ?assertEqual(commercial, Commercial#calendar.type). test_get_calendar() -> UserId = create_test_user(), {ok, Calendar} = logic_calendar:create_calendar(UserId, <<"Test">>, <<"">>, manual), 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, OtherUserId = create_test_user(), ?assertMatch({error, access_denied}, logic_calendar:get_calendar(OtherUserId, Calendar#calendar.id)). test_list_calendars() -> UserId = create_test_user(), {ok, _} = logic_calendar:create_calendar(UserId, <<"Calendar 1">>, <<"">>, manual), {ok, _} = logic_subscription:start_trial(UserId), {ok, _} = logic_calendar:create_calendar(UserId, <<"Calendar 2">>, <<"">>, auto, commercial), {ok, Calendars} = logic_calendar:list_calendars(UserId), ?assertEqual(2, length(Calendars)). test_update_calendar() -> UserId = create_test_user(), {ok, _} = logic_subscription:start_trial(UserId), {ok, Calendar} = logic_calendar:create_calendar( UserId, <<"Original">>, <<"">>, manual, commercial), Updates = [{title, <<"Updated">>}, {confirmation, auto}], {ok, Updated} = logic_calendar:update_calendar(UserId, Calendar#calendar.id, Updates), ?assertEqual(<<"Updated">>, Updated#calendar.title), ?assertEqual(commercial, Updated#calendar.type), ?assertEqual(auto, Updated#calendar.confirmation), OtherUserId = create_test_user(), ?assertMatch({error, access_denied}, logic_calendar:update_calendar(OtherUserId, Calendar#calendar.id, Updates)). test_update_settings() -> UserId = create_test_user(), {ok, Calendar} = logic_calendar:create_calendar(UserId, <<"Studio">>, <<"">>, manual), Patterns = [ #{ <<"id">> => <<"p1">>, <<"name">> => <<"Weekdays">>, <<"updated_at">> => <<"2026-07-29T12:00:00Z">>, <<"slots">> => [ #{<<"weekday">> => 1, <<"time">> => <<"10:00">>, <<"duration">> => 60, <<"title">> => <<"Open">>} ] } ], Settings = #{<<"week_patterns">> => Patterns}, {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), %% binary key path (as from raw JSON list before convert_field) Settings2 = Settings#{<<"foo">> => <<"bar">>}, {ok, Updated2} = logic_calendar:update_calendar( UserId, Calendar#calendar.id, [{<<"settings">>, Settings2}]), ?assertEqual(Settings2, Updated2#calendar.settings). test_update_settings_invalid() -> UserId = create_test_user(), {ok, Calendar} = logic_calendar:create_calendar(UserId, <<"Studio">>, <<"">>, manual), %% non-map settings silently dropped by validate_updates — calendar unchanged {ok, Same} = logic_calendar:update_calendar( 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, Personal} = logic_calendar:create_calendar(UserId, <<"Test">>, <<"">>, manual), ?assertMatch({error, default_calendar}, logic_calendar:delete_calendar(UserId, Personal#calendar.id)), {ok, _} = logic_subscription:start_trial(UserId), {ok, Commercial} = logic_calendar:create_calendar( UserId, <<"Studio">>, <<"">>, manual, commercial), {ok, Deleted} = logic_calendar:delete_calendar(UserId, Commercial#calendar.id), ?assertEqual(deleted, Deleted#calendar.status), ?assertMatch({error, access_denied}, logic_calendar:get_calendar(UserId, Commercial#calendar.id)). test_access_control() -> OwnerId = create_test_user(), OtherId = create_test_user(), {ok, PersonalCalendar} = logic_calendar:create_calendar(OwnerId, <<"Personal">>, <<"">>, manual), ?assert(logic_calendar:can_edit(OwnerId, PersonalCalendar)), ?assertNot(logic_calendar:can_edit(OtherId, PersonalCalendar)), ?assertNot(logic_calendar:can_access(OtherId, PersonalCalendar)), {ok, _} = logic_subscription:start_trial(OwnerId), {ok, CommercialCalendar} = logic_calendar:create_calendar( OwnerId, <<"Studio">>, <<"">>, manual, 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)). test_ensure_default_calendar() -> UserId = create_test_user(), ok = logic_calendar:ensure_default_calendar(UserId), {ok, Calendars} = logic_calendar:list_calendars(UserId), ?assertEqual(1, length(Calendars)), Cal = hd(Calendars), ?assertEqual(personal, Cal#calendar.type), ?assertEqual(<<"Default">>, Cal#calendar.title), ?assertEqual(<<>>, Cal#calendar.short_name), ?assertEqual(active, Cal#calendar.status), ok = logic_calendar:ensure_default_calendar(UserId), {ok, Calendars2} = logic_calendar:list_calendars(UserId), ?assertEqual(1, length(Calendars2)), ?assertEqual(<<"Default">>, (hd(Calendars2))#calendar.title). test_personal_exists() -> UserId = create_test_user(), {ok, _} = logic_calendar:create_calendar(UserId, <<"One">>, <<"">>, manual, personal), ?assertMatch({error, personal_exists}, logic_calendar:create_calendar(UserId, <<"Two">>, <<"">>, manual, personal)). test_default_calendar_type_change() -> UserId = create_test_user(), {ok, Personal} = logic_calendar:create_calendar(UserId, <<"One">>, <<"">>, manual, personal), {ok, _} = logic_subscription:start_trial(UserId), ?assertMatch({error, default_calendar}, logic_calendar:update_calendar(UserId, Personal#calendar.id, [{type, commercial}])). test_backfill_extras() -> UserId = create_test_user(), {ok, Keep0} = core_calendar:create(UserId, <<"Old">>, <<"">>, manual, personal), {ok, Extra0} = core_calendar:create(UserId, <<"Extra">>, <<"">>, manual, personal), %% datetime resolution is 1s — pin order explicitly mnesia:dirty_write(Keep0#calendar{created_at = {{2020, 1, 1}, {0, 0, 0}}}), mnesia:dirty_write(Extra0#calendar{created_at = {{2020, 1, 2}, {0, 0, 0}}}), ok = logic_calendar:backfill_single_personal(), {ok, List} = logic_calendar:list_calendars(UserId), Personals = [C || C <- List, C#calendar.type =:= personal], ?assertEqual(1, length(Personals)), ?assertEqual(Keep0#calendar.id, (hd(Personals))#calendar.id), ?assertEqual(<<"Default">>, (hd(Personals))#calendar.title), {ok, ExtraAfter} = core_calendar:get_by_id(Extra0#calendar.id), ?assertEqual(deleted, ExtraAfter#calendar.status).