-module(logic_automoderation_tests). -include_lib("eunit/include/eunit.hrl"). -include("records.hrl"). -define(TABLES, [admin, admin_audit, banned_word, automod_settings, automod_hit, user, calendar, event, review, report]). setup() -> eh_test_support:start_mnesia(), eh_test_support:ensure_tables(?TABLES), core_automod_settings:ensure_defaults(), ok. cleanup(_) -> eh_test_support:delete_tables(?TABLES), eh_test_support:stop_mnesia(), ok. logic_automoderation_test_() -> {foreach, fun setup/0, fun cleanup/1, [ {"word_boundary does not match substring inside word", fun test_word_boundary/0}, {"substring mode matches inside word", fun test_substring/0}, {"case insensitive scan", fun test_case_insensitive/0}, {"punctuation word_boundary", fun test_punctuation_boundary/0}, {"multiple banned words collected", fun test_multi_words/0}, {"reject policy blocks save path via evaluate", fun test_reject/0}, {"flag policy soft-hides event", fun test_flag_event/0}, {"flag policy soft-hides calendar", fun test_flag_calendar/0}, {"flag policy hides review", fun test_flag_review/0}, {"censor policy masks words", fun test_censor/0}, {"settings update", fun test_settings/0}, {"settings update denied for moderator", fun test_settings_denied/0}, {"invalid settings field", fun test_settings_invalid/0}, {"approve hit restores event", fun test_approve_hit/0}, {"reject hit keeps freeze", fun test_reject_hit/0}, {"resolve already resolved", fun test_resolve_twice/0}, {"report threshold creates hit and freezes", fun test_report_threshold/0}, {"dismissed reports do not count toward threshold", fun test_dismissed_not_counted/0}, {"custom threshold from settings", fun test_custom_threshold/0}, {"report on review hides", fun test_report_review/0}, {"logic create calendar reject", fun test_logic_calendar_reject/0}, {"logic create event flag", fun test_logic_event_flag/0}, {"logic create review censor", fun test_logic_review_censor/0} ]}. admin_id() -> (eh_test_support:seed_admin(#{id => <<"adm_am">>}))#admin.id. moderator_id() -> (eh_test_support:seed_admin(#{id => <<"mod_am">>, role => moderator, email => <<"mod@test.local">>}))#admin.id. add_word(Word) -> {ok, _} = core_banned_words:add_banned_word(Word, admin_id()). seed_user() -> Id = base64:encode(crypto:strong_rand_bytes(12), #{mode => urlsafe, padding => false}), User = eh_test_support:make_user(#{id => Id, email => <>, status => active}), mnesia:dirty_write(User), Id. test_word_boundary() -> add_word(<<"bad">>), ?assertEqual(clean, logic_automoderation:scan_text(<<"badge">>)), ?assertMatch({hit, [<<"bad">>]}, logic_automoderation:scan_text(<<"this is bad">>)). test_substring() -> add_word(<<"bad">>), {ok, _} = core_automod_settings:update([{match_mode, substring}], admin_id()), ?assertMatch({hit, [<<"bad">>]}, logic_automoderation:scan_text(<<"badge">>)). test_case_insensitive() -> add_word(<<"spam">>), ?assertMatch({hit, [<<"spam">>]}, logic_automoderation:scan_text(<<"Buy SPAM now">>)). test_punctuation_boundary() -> add_word(<<"bad">>), ?assertMatch({hit, [<<"bad">>]}, logic_automoderation:scan_text(<<"really bad!">>)), ?assertMatch({hit, [<<"bad">>]}, logic_automoderation:scan_text(<<"bad,">>)), ?assertEqual(clean, logic_automoderation:scan_text(<<"badger">>)). test_multi_words() -> add_word(<<"foo">>), add_word(<<"bar">>), ?assertMatch({hit, Words} when length(Words) =:= 2, logic_automoderation:scan_text(<<"foo and bar">>)). test_reject() -> add_word(<<"spam">>), {ok, _} = core_automod_settings:update([{keyword_action, reject}], admin_id()), ?assertEqual({reject, [<<"spam">>]}, logic_automoderation:evaluate_texts([<<"buy spam now">>])). test_flag_event() -> add_word(<<"spam">>), {ok, _} = core_automod_settings:update([{keyword_action, flag}], admin_id()), Owner = seed_user(), {ok, Cal} = core_calendar:create(Owner, <<"Cal">>, <<>>, manual), {ok, Action, [Title2], Words} = logic_automoderation:evaluate_texts([<<"spam event">>]), ?assertEqual(flag, Action), {ok, Ev} = core_event:create(Cal#calendar.id, Title2, eh_test_support:future_start(), 60), ok = logic_automoderation:apply_after_save(event, Ev#event.id, Action, Words), {ok, Frozen} = core_event:get_by_id(Ev#event.id), ?assertEqual(frozen, Frozen#event.status), Hits = core_automod_hit:list([{status, open}]), ?assertEqual(1, length(Hits)). test_flag_calendar() -> add_word(<<"spam">>), {ok, _} = core_automod_settings:update([{keyword_action, flag}], admin_id()), Owner = seed_user(), {ok, Cal} = logic_calendar:create_calendar(Owner, <<"spam cal">>, <<"ok">>, manual), {ok, Frozen} = core_calendar:get_by_id(Cal#calendar.id), ?assertEqual(frozen, Frozen#calendar.status), ?assertEqual(1, length(core_automod_hit:list([{entity_type, calendar}]))). test_flag_review() -> add_word(<<"spam">>), {ok, _} = core_automod_settings:update([{keyword_action, flag}], admin_id()), Owner = seed_user(), Reviewer = seed_user(), {ok, Cal} = core_calendar:create(Owner, <<"Cal">>, <<>>, manual), {ok, Ev} = core_event:create(Cal#calendar.id, <<"E">>, eh_test_support:future_start(), 60), %% bypass can_review: create via core then apply_after_save path via logic {ok, _} = core_automod_settings:update([{keyword_action, flag}], admin_id()), {ok, Action, [C], Words} = logic_automoderation:evaluate_texts([<<"spam comment">>]), {ok, Rev} = core_review:create(Reviewer, event, Ev#event.id, 4, C), ok = logic_automoderation:apply_after_save(review, Rev#review.id, Action, Words), {ok, Hidden} = core_review:get_by_id(Rev#review.id), ?assertEqual(hidden, Hidden#review.status). test_censor() -> add_word(<<"bad">>), {ok, _} = core_automod_settings:update([{keyword_action, censor}], admin_id()), {ok, censor, [Out], _} = logic_automoderation:evaluate_texts([<<"This is bad">>]), ?assertEqual(<<"This is ***">>, Out). test_settings() -> AdminId = admin_id(), {ok, S} = logic_automoderation:update_settings(AdminId, [ {keyword_action, reject}, {report_threshold, 5} ]), ?assertEqual(reject, S#automod_settings.keyword_action), ?assertEqual(5, S#automod_settings.report_threshold). test_settings_denied() -> ModId = moderator_id(), ?assertEqual({error, access_denied}, logic_automoderation:update_settings(ModId, [{report_threshold, 9}])). test_settings_invalid() -> AdminId = admin_id(), ?assertMatch({error, {invalid_field, _}}, logic_automoderation:update_settings(AdminId, [{keyword_action, nope}])). test_approve_hit() -> add_word(<<"spam">>), AdminId = admin_id(), {ok, _} = core_automod_settings:update([{keyword_action, flag}], AdminId), Owner = seed_user(), {ok, Cal} = core_calendar:create(Owner, <<"Cal">>, <<>>, manual), {ok, Ev} = core_event:create(Cal#calendar.id, <<"spam">>, eh_test_support:future_start(), 60), ok = logic_automoderation:apply_after_save(event, Ev#event.id, flag, [<<"spam">>]), [Hit] = core_automod_hit:list([{status, open}]), {ok, Resolved} = logic_automoderation:resolve_hit(AdminId, Hit#automod_hit.id, approved), ?assertEqual(approved, Resolved#automod_hit.status), {ok, Active} = core_event:get_by_id(Ev#event.id), ?assertEqual(active, Active#event.status). test_reject_hit() -> add_word(<<"spam">>), AdminId = admin_id(), Owner = seed_user(), {ok, Cal} = core_calendar:create(Owner, <<"Cal">>, <<>>, manual), {ok, Ev} = core_event:create(Cal#calendar.id, <<"ok">>, eh_test_support:future_start(), 60), {ok, Hit} = core_automod_hit:create(event, Ev#event.id, keyword, [<<"spam">>], flag), {ok, _} = logic_automoderation:resolve_hit(AdminId, Hit#automod_hit.id, rejected), {ok, Frozen} = core_event:get_by_id(Ev#event.id), ?assertEqual(frozen, Frozen#event.status). test_resolve_twice() -> AdminId = admin_id(), Owner = seed_user(), {ok, Cal} = core_calendar:create(Owner, <<"Cal">>, <<>>, manual), {ok, Ev} = core_event:create(Cal#calendar.id, <<"ok">>, eh_test_support:future_start(), 60), {ok, Hit} = core_automod_hit:create(event, Ev#event.id, keyword, [], flag), {ok, _} = logic_automoderation:resolve_hit(AdminId, Hit#automod_hit.id, approved), ?assertEqual({error, already_resolved}, logic_automoderation:resolve_hit(AdminId, Hit#automod_hit.id, rejected)). test_report_threshold() -> Owner = seed_user(), {ok, Cal} = core_calendar:create(Owner, <<"Cal">>, <<>>, manual), {ok, Ev} = core_event:create(Cal#calendar.id, <<"E">>, eh_test_support:future_start(), 60), {ok, _} = logic_moderation:create_report(seed_user(), event, Ev#event.id, <<"r1">>), {ok, _} = logic_moderation:create_report(seed_user(), event, Ev#event.id, <<"r2">>), {ok, Ev2} = core_event:get_by_id(Ev#event.id), ?assertEqual(active, Ev2#event.status), {ok, _} = logic_moderation:create_report(seed_user(), event, Ev#event.id, <<"r3">>), {ok, Ev3} = core_event:get_by_id(Ev#event.id), ?assertEqual(frozen, Ev3#event.status), ?assert(length(core_automod_hit:list([{trigger, report_threshold}])) >= 1). test_dismissed_not_counted() -> AdminId = admin_id(), Owner = seed_user(), {ok, Cal} = core_calendar:create(Owner, <<"Cal">>, <<>>, manual), {ok, Ev} = core_event:create(Cal#calendar.id, <<"E">>, eh_test_support:future_start(), 60), {ok, R1} = logic_moderation:create_report(seed_user(), event, Ev#event.id, <<"r1">>), {ok, R2} = logic_moderation:create_report(seed_user(), event, Ev#event.id, <<"r2">>), {ok, _} = logic_moderation:resolve_report(AdminId, R1#report.id, dismissed), {ok, _} = logic_moderation:resolve_report(AdminId, R2#report.id, dismissed), {ok, _} = logic_moderation:create_report(seed_user(), event, Ev#event.id, <<"r3">>), {ok, Ev2} = core_event:get_by_id(Ev#event.id), ?assertEqual(active, Ev2#event.status). test_custom_threshold() -> {ok, _} = core_automod_settings:update([{report_threshold, 2}], admin_id()), Owner = seed_user(), {ok, Cal} = core_calendar:create(Owner, <<"Cal">>, <<>>, manual), {ok, Ev} = core_event:create(Cal#calendar.id, <<"E">>, eh_test_support:future_start(), 60), {ok, _} = logic_moderation:create_report(seed_user(), event, Ev#event.id, <<"a">>), {ok, Ev1} = core_event:get_by_id(Ev#event.id), ?assertEqual(active, Ev1#event.status), {ok, _} = logic_moderation:create_report(seed_user(), event, Ev#event.id, <<"b">>), {ok, Ev2} = core_event:get_by_id(Ev#event.id), ?assertEqual(frozen, Ev2#event.status). test_report_review() -> Owner = seed_user(), Reviewer = seed_user(), {ok, Cal} = core_calendar:create(Owner, <<"Cal">>, <<>>, manual), {ok, Ev} = core_event:create(Cal#calendar.id, <<"E">>, eh_test_support:future_start(), 60), {ok, Rev} = core_review:create(Reviewer, event, Ev#event.id, 3, <<"ok">>), {ok, _} = logic_moderation:create_report(seed_user(), review, Rev#review.id, <<"x">>), {ok, _} = logic_moderation:create_report(seed_user(), review, Rev#review.id, <<"y">>), {ok, _} = logic_moderation:create_report(seed_user(), review, Rev#review.id, <<"z">>), {ok, Hidden} = core_review:get_by_id(Rev#review.id), ?assertEqual(hidden, Hidden#review.status). test_logic_calendar_reject() -> add_word(<<"spam">>), {ok, _} = core_automod_settings:update([{keyword_action, reject}], admin_id()), Owner = seed_user(), ?assertMatch({error, {content_banned, _}}, logic_calendar:create_calendar(Owner, <<"spam title">>, <<"d">>, manual)). test_logic_event_flag() -> add_word(<<"spam">>), {ok, _} = core_automod_settings:update([{keyword_action, flag}], admin_id()), Owner = seed_user(), {ok, Cal} = core_calendar:create(Owner, <<"Cal">>, <<>>, manual), {ok, Ev} = logic_event:create_event(Owner, Cal#calendar.id, <<"spam party">>, eh_test_support:future_start(), 60), ?assertEqual(frozen, Ev#event.status). test_logic_review_censor() -> add_word(<<"bad">>), {ok, _} = core_automod_settings:update([{keyword_action, censor}], admin_id()), Owner = seed_user(), Reviewer = seed_user(), {ok, Cal} = core_calendar:create(Owner, <<"Cal">>, <<>>, manual), {ok, Ev} = core_event:create(Cal#calendar.id, <<"E">>, eh_test_support:future_start(), 60), %% can_review may fail without booking — use evaluate + core like create path {ok, censor, [Comment], Words} = logic_automoderation:evaluate_texts([<<"really bad">>]), {ok, Rev} = core_review:create(Reviewer, event, Ev#event.id, 2, Comment), ok = logic_automoderation:apply_after_save(review, Rev#review.id, censor, Words), {ok, Saved} = core_review:get_by_id(Rev#review.id), ?assertEqual(<<"really ***">>, Saved#review.comment), ?assertEqual(1, length(core_automod_hit:list([{action_taken, censor}]))).