From 0934e2c413c1ea01a47d569197021c84f9502805 Mon Sep 17 00:00:00 2001 From: Aleksey Sabilin Date: Sun, 19 Jul 2026 23:26:34 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20PATCH=20/v1/user/me=20=E2=80=94=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D1=84=D0=B8=D0=BB=D1=8C=20=D0=B8=20=D1=81?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D0=B0=20=D0=BF=D0=B0=D1=80=D0=BE=D0=BB=D1=8F?= =?UTF-8?q?.=20Refs=20EventHub/EventHubBack#48?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/handlers/handler_user_me.erl | 91 +++++++++++++++++- src/logic/logic_user.erl | 132 ++++++++++++++++++++++++++- src/swagger/client-swagger.json | 152 +++++++++++++++++++++++++++++++ test/api/api_test_runner.erl | 7 ++ test/api/users/user_me_tests.erl | 152 +++++++++++++++++++++++++++---- test/unit/logic_user_tests.erl | 143 +++++++++++++++++++++++++++++ 6 files changed, 652 insertions(+), 25 deletions(-) create mode 100644 test/unit/logic_user_tests.erl diff --git a/src/handlers/handler_user_me.erl b/src/handlers/handler_user_me.erl index 5d52669..5617130 100644 --- a/src/handlers/handler_user_me.erl +++ b/src/handlers/handler_user_me.erl @@ -1,7 +1,8 @@ %%%------------------------------------------------------------------- %%% @doc Обработчик профиля текущего пользователя (клиентский API). %%% -%%% GET – получить информацию о своём профиле. +%%% GET – получить информацию о своём профиле. +%%% PATCH – частичное обновление профиля / смена пароля. %%% @end %%%------------------------------------------------------------------- -module(handler_user_me). @@ -34,6 +35,26 @@ trails() -> 401 => #{description => <<"Unauthorized">>}, 404 => #{description => <<"User not found">>} } + }, + #{ + path => <<"/v1/user/me">>, + method => <<"PATCH">>, + description => <<"Update current user profile (partial) or change password">>, + tags => [<<"Users">>], + requestBody => #{ + required => true, + content => #{<<"application/json">> => #{schema => user_update_schema()}} + }, + responses => #{ + 200 => #{ + description => <<"Updated profile">>, + content => #{<<"application/json">> => #{schema => user_schema()}} + }, + 400 => #{description => <<"Invalid body / unknown field">>}, + 401 => #{description => <<"Unauthorized">>}, + 403 => #{description => <<"Wrong current password or account not active">>}, + 404 => #{description => <<"User not found">>} + } } ]. @@ -49,7 +70,7 @@ user_schema() -> nickname => #{type => string, nullable => true}, avatar_url => #{type => string, nullable => true}, timezone => #{type => string, nullable => true}, - language => #{type => string, nullable => true}, + language => #{type => string, enum => [<<"ru">>, <<"en">>], nullable => true}, social_links => #{type => array, items => #{type => string}, nullable => true}, phone => #{type => string, nullable => true}, preferences => #{type => object, nullable => true}, @@ -59,6 +80,23 @@ user_schema() -> } }. +user_update_schema() -> + #{ + type => object, + properties => #{ + language => #{type => string, enum => [<<"ru">>, <<"en">>]}, + nickname => #{type => string, nullable => true}, + timezone => #{type => string, nullable => true}, + phone => #{type => string, nullable => true}, + avatar_url => #{type => string, nullable => true}, + preferences => #{type => object, nullable => true}, + password => #{type => string, format => <<"password">>, + description => <<"New password; requires current_password">>}, + current_password => #{type => string, format => <<"password">>, + description => <<"Current password; required with password">>} + } + }. + %%%=================================================================== %%% HTTP-методы %%%=================================================================== @@ -67,8 +105,9 @@ user_schema() -> -spec handle(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}. handle(Req, _Opts) -> case cowboy_req:method(Req) of - <<"GET">> -> get_me(Req); - _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>) + <<"GET">> -> get_me(Req); + <<"PATCH">> -> patch_me(Req); + _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>) end. %% @doc GET /v1/user/me — получение профиля текущего пользователя. @@ -84,4 +123,46 @@ get_me(Req) -> end; {error, Code, Message, Req1} -> handler_utils:send_error(Req1, Code, Message) - end. \ No newline at end of file + end. + +%% @doc PATCH /v1/user/me — частичное обновление профиля / смена пароля. +-spec patch_me(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}. +patch_me(Req) -> + case handler_utils:auth_user(Req) of + {ok, UserId, Req1} -> + {ok, Body, Req2} = cowboy_req:read_body(Req1), + try jsx:decode(Body, [return_maps]) of + Decoded when is_map(Decoded) -> + case logic_user:update_me(UserId, Decoded) of + {ok, User} -> + handler_utils:send_json(Req2, 200, handler_utils:user_to_json(User)); + {error, not_found} -> + handler_utils:send_error(Req2, 404, <<"User not found">>); + {error, wrong_password} -> + handler_utils:send_error(Req2, 403, <<"Wrong current password">>); + {error, forbidden} -> + handler_utils:send_error(Req2, 403, <<"Account is not active">>); + {error, unknown_field} -> + handler_utils:send_error(Req2, 400, <<"Unknown field">>); + {error, empty_body} -> + handler_utils:send_error(Req2, 400, <<"Empty update body">>); + {error, password_pair_required} -> + handler_utils:send_error(Req2, 400, + <<"password and current_password must be sent together">>); + {error, invalid_password} -> + handler_utils:send_error(Req2, 400, <<"Invalid password fields">>); + {error, invalid_language} -> + handler_utils:send_error(Req2, 400, <<"language must be ru or en">>); + {error, Reason} when is_atom(Reason) -> + handler_utils:send_error(Req2, 400, atom_to_binary(Reason, utf8)); + {error, _} -> + handler_utils:send_error(Req2, 400, <<"Invalid request">>) + end; + _ -> + handler_utils:send_error(Req2, 400, <<"Invalid JSON">>) + catch + _:_ -> handler_utils:send_error(Req2, 400, <<"Invalid JSON format">>) + end; + {error, Code, Message, Req1} -> + handler_utils:send_error(Req1, Code, Message) + end. diff --git a/src/logic/logic_user.erl b/src/logic/logic_user.erl index d6cc203..7f902c0 100644 --- a/src/logic/logic_user.erl +++ b/src/logic/logic_user.erl @@ -1,6 +1,7 @@ -module(logic_user). -export([list_users_admin/2, get_user_admin/1, update_user_admin/2, delete_user_admin/1]). +-export([update_me/2]). -include("records.hrl"). @@ -51,10 +52,139 @@ delete_user_admin(UserId) -> Error end. +%%%------------------------------------------------------------------- +%%% @doc Обновление собственного профиля (whitelist + смена пароля). +%%% BodyMap — JSON map с бинарными ключами. +%%% @end +%%%------------------------------------------------------------------- +-spec update_me(UserId :: binary(), BodyMap :: map()) -> + {ok, #user{}} | {error, term()}. +update_me(UserId, BodyMap) when is_map(BodyMap) -> + case parse_me_body(BodyMap) of + {error, _} = Err -> + Err; + {ok, ProfileUpdates, PasswordOpt} -> + case core_user:get_by_id(UserId) of + {error, _} = Err -> + Err; + {ok, #user{status = Status}} when Status =/= active -> + {error, forbidden}; + {ok, User} -> + case apply_password_change(User, PasswordOpt) of + {error, _} = Err -> + Err; + {ok, PassUpdates} -> + Updates = ProfileUpdates ++ PassUpdates, + case Updates of + [] -> + {error, empty_body}; + _ -> + core_user:update(UserId, Updates) + end + end + end + end; +update_me(_, _) -> + {error, invalid_json}. + %%%=================================================================== %%% Внутренние функции %%%=================================================================== +-define(ME_KEYS, [<<"language">>, <<"nickname">>, <<"timezone">>, <<"phone">>, + <<"avatar_url">>, <<"preferences">>, <<"password">>, <<"current_password">>]). + +parse_me_body(BodyMap) -> + case maps:keys(BodyMap) -- ?ME_KEYS of + [_ | _] -> + {error, unknown_field}; + [] -> + case parse_password_pair(BodyMap) of + {error, _} = Err -> + Err; + {ok, PasswordOpt} -> + case parse_profile_fields(BodyMap) of + {error, _} = Err -> + Err; + {ok, ProfileUpdates} -> + {ok, ProfileUpdates, PasswordOpt} + end + end + end. + +parse_password_pair(BodyMap) -> + HasNew = maps:is_key(<<"password">>, BodyMap), + HasCur = maps:is_key(<<"current_password">>, BodyMap), + case {HasNew, HasCur} of + {false, false} -> + {ok, undefined}; + {true, true} -> + New = maps:get(<<"password">>, BodyMap), + Cur = maps:get(<<"current_password">>, BodyMap), + case {New, Cur} of + {N, C} when is_binary(N), N =/= <<>>, is_binary(C), C =/= <<>> -> + {ok, {C, N}}; + _ -> + {error, invalid_password} + end; + _ -> + {error, password_pair_required} + end. + +parse_profile_fields(BodyMap) -> + Keys = [<<"language">>, <<"nickname">>, <<"timezone">>, <<"phone">>, + <<"avatar_url">>, <<"preferences">>], + try + Updates = lists:filtermap(fun(Key) -> + case maps:find(Key, BodyMap) of + error -> false; + {ok, Val} -> + case normalize_me_field(Key, Val) of + {ok, Atom, Norm} -> {true, {Atom, Norm}}; + {error, Reason} -> throw({invalid_field, Reason}) + end + end + end, Keys), + {ok, Updates} + catch + throw:{invalid_field, Reason} -> {error, Reason} + end. + +normalize_me_field(<<"language">>, <<"ru">>) -> {ok, language, <<"ru">>}; +normalize_me_field(<<"language">>, <<"en">>) -> {ok, language, <<"en">>}; +normalize_me_field(<<"language">>, _) -> {error, invalid_language}; +normalize_me_field(<<"nickname">>, null) -> {ok, nickname, <<>>}; +normalize_me_field(<<"nickname">>, V) when is_binary(V) -> {ok, nickname, V}; +normalize_me_field(<<"nickname">>, _) -> {error, invalid_nickname}; +normalize_me_field(<<"timezone">>, null) -> {ok, timezone, <<>>}; +normalize_me_field(<<"timezone">>, V) when is_binary(V) -> {ok, timezone, V}; +normalize_me_field(<<"timezone">>, _) -> {error, invalid_timezone}; +normalize_me_field(<<"phone">>, null) -> {ok, phone, <<>>}; +normalize_me_field(<<"phone">>, V) when is_binary(V) -> {ok, phone, V}; +normalize_me_field(<<"phone">>, _) -> {error, invalid_phone}; +normalize_me_field(<<"avatar_url">>, null) -> {ok, avatar_url, <<>>}; +normalize_me_field(<<"avatar_url">>, V) when is_binary(V) -> {ok, avatar_url, V}; +normalize_me_field(<<"avatar_url">>, _) -> {error, invalid_avatar_url}; +normalize_me_field(<<"preferences">>, null) -> {ok, preferences, #{}}; +normalize_me_field(<<"preferences">>, V) when is_map(V) -> {ok, preferences, V}; +normalize_me_field(<<"preferences">>, _) -> {error, invalid_preferences}; +normalize_me_field(_, _) -> {error, unknown_field}. + +apply_password_change(_User, undefined) -> + {ok, []}; +apply_password_change(#user{password_hash = Hash}, {Current, New}) -> + case logic_auth:verify_password(Current, Hash) of + {ok, true} -> + case logic_auth:hash_password(New) of + {ok, NewHash} -> + {ok, [{password_hash, NewHash}]}; + _ -> + {error, password_hash_failed} + end; + _ -> + {error, wrong_password} + end. + apply_filters(Users, Filters) -> Role = maps:get(role, Filters, undefined), StatusBin = maps:get(status, Filters, undefined), @@ -106,4 +236,4 @@ validate_user_update({timezone, V}) when is_binary(V); V =:= undefined -> true; validate_user_update({language, V}) when is_binary(V); V =:= undefined -> true; validate_user_update({phone, V}) when is_binary(V); V =:= undefined -> true; validate_user_update({preferences, V}) when is_map(V); V =:= undefined -> true; -validate_user_update(_) -> false. \ No newline at end of file +validate_user_update(_) -> false. diff --git a/src/swagger/client-swagger.json b/src/swagger/client-swagger.json index 5480db2..ae8e412 100644 --- a/src/swagger/client-swagger.json +++ b/src/swagger/client-swagger.json @@ -2638,6 +2638,158 @@ "description": "User not found" } } + }, + "patch": { + "description": "Update current user profile (partial) or change password", + "tags": [ + "Users" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "language": { + "type": "string", + "enum": [ + "ru", + "en" + ] + }, + "nickname": { + "type": "string", + "nullable": true + }, + "timezone": { + "type": "string", + "nullable": true + }, + "phone": { + "type": "string", + "nullable": true + }, + "avatar_url": { + "type": "string", + "nullable": true + }, + "preferences": { + "type": "object", + "nullable": true + }, + "password": { + "type": "string", + "format": "password" + }, + "current_password": { + "type": "string", + "format": "password" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Updated profile", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "email": { + "type": "string", + "format": "email" + }, + "role": { + "type": "string", + "enum": [ + "user", + "bot" + ] + }, + "status": { + "type": "string", + "enum": [ + "active", + "frozen", + "deleted" + ] + }, + "reason": { + "type": "string", + "nullable": true + }, + "nickname": { + "type": "string", + "nullable": true + }, + "avatar_url": { + "type": "string", + "nullable": true + }, + "timezone": { + "type": "string", + "nullable": true + }, + "language": { + "type": "string", + "enum": [ + "ru", + "en" + ], + "nullable": true + }, + "social_links": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "phone": { + "type": "string", + "nullable": true + }, + "preferences": { + "type": "object", + "nullable": true + }, + "last_login": { + "type": "string", + "format": "date-time" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + } + } + } + }, + "400": { + "description": "Invalid body / unknown field" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Wrong current password or account not active" + }, + "404": { + "description": "User not found" + } + } } }, "/v1/user/reviews": { diff --git a/test/api/api_test_runner.erl b/test/api/api_test_runner.erl index 6ed5425..02bf116 100644 --- a/test/api/api_test_runner.erl +++ b/test/api/api_test_runner.erl @@ -43,6 +43,7 @@ client_get/2, client_post/3, client_put/3, + client_patch/3, client_delete/2, admin_patch/3]). -export([verify_user/2]). @@ -314,6 +315,12 @@ client_put(Path, Token, BodyMap) -> {ok, 200, _, RespBody} = client_request(put, Path, Token, Body), jsx:decode(list_to_binary(RespBody), [return_maps]). +-spec client_patch(binary(), binary(), map()) -> jsx:json_term(). +client_patch(Path, Token, BodyMap) -> + Body = jsx:encode(BodyMap), + {ok, 200, _, RespBody} = client_request(patch, Path, Token, Body), + jsx:decode(list_to_binary(RespBody), [return_maps]). + -spec client_delete(binary(), binary()) -> jsx:json_term(). client_delete(Path, Token) -> {ok, 200, _, Body} = client_request(delete, Path, Token), diff --git a/test/api/users/user_me_tests.erl b/test/api/users/user_me_tests.erl index 989b95b..bd9006d 100644 --- a/test/api/users/user_me_tests.erl +++ b/test/api/users/user_me_tests.erl @@ -1,13 +1,8 @@ %%%------------------------------------------------------------------- -%%% @doc Тесты клиентского API для получения профиля текущего пользователя. +%%% @doc Тесты клиентского API профиля текущего пользователя. %%% -%%% Покрывает эндпоинты: -%%% GET /v1/user/me -%%% -%%% Проверяет: -%%% - успешное получение профиля с валидным токеном -%%% - ошибку 401 при отсутствии токена -%%% - наличие ключевых полей в ответе +%%% GET /v1/user/me +%%% PATCH /v1/user/me %%% @end %%%------------------------------------------------------------------- -module(user_me_tests). @@ -15,27 +10,36 @@ -export([test/0]). -%%%=================================================================== -%%% Главная тестовая функция -%%%=================================================================== - -spec test() -> ok. test() -> ct:pal("=== User Profile (me) Tests ==="), - Token = api_test_runner:get_user_token(), + Email = api_test_runner:unique_email(<<"meuser">>), + Pass0 = <<"oldpass1">>, + Token = api_test_runner:register_and_login(Email, Pass0), test_get_me_success(Token), test_get_me_unauthorized(), + test_patch_language(Token), + test_patch_profile_fields(Token), + test_patch_avatar_and_preferences(Token), + test_patch_null_clears(Token), + test_patch_invalid_language(Token), + test_patch_empty_body(Token), + test_patch_invalid_json(Token), + test_patch_unknown_field(Token), + test_patch_forbid_email(Token), + test_patch_unauthorized(), + test_patch_password_wrong_current(Token, Pass0), + test_patch_password_ok(Token, Pass0, <<"newpass2">>, Email), + test_patch_password_pair_required(Token), ct:pal("=== All user me tests passed ==="), ok. %%%=================================================================== -%%% Тестовые функции +%%% GET %%%=================================================================== -%% @doc Успешное получение профиля: 200 OK, возвращает данные пользователя. --spec test_get_me_success(binary()) -> ok. test_get_me_success(Token) -> ct:pal(" TEST: Get current user profile"), User = api_test_runner:client_get(<<"/v1/user/me">>, Token), @@ -46,10 +50,120 @@ test_get_me_success(Token) -> ?assert(maps:is_key(<<"status">>, User)), ct:pal(" OK: got profile for ~s", [maps:get(<<"email">>, User)]). -%% @doc Отсутствие токена: 401 Unauthorized. --spec test_get_me_unauthorized() -> ok. test_get_me_unauthorized() -> ct:pal(" TEST: Get profile without token"), Resp = api_test_runner:client_request(get, <<"/v1/user/me">>, <<>>), ?assertMatch({ok, 401, _, _}, Resp), - ct:pal(" OK: got 401 unauthorized"). \ No newline at end of file + ct:pal(" OK: got 401 unauthorized"). + +%%%=================================================================== +%%% PATCH +%%%=================================================================== + +test_patch_language(Token) -> + ct:pal(" TEST: PATCH language"), + User = api_test_runner:client_patch(<<"/v1/user/me">>, Token, + #{language => <<"en">>}), + ?assertEqual(<<"en">>, maps:get(<<"language">>, User)), + ct:pal(" OK"). + +test_patch_profile_fields(Token) -> + ct:pal(" TEST: PATCH nickname/timezone/phone"), + User = api_test_runner:client_patch(<<"/v1/user/me">>, Token, + #{nickname => <<"Nick">>, timezone => <<"Europe/Moscow">>, phone => <<"+7000">>}), + ?assertEqual(<<"Nick">>, maps:get(<<"nickname">>, User)), + ?assertEqual(<<"Europe/Moscow">>, maps:get(<<"timezone">>, User)), + ?assertEqual(<<"+7000">>, maps:get(<<"phone">>, User)), + ct:pal(" OK"). + +test_patch_avatar_and_preferences(Token) -> + ct:pal(" TEST: PATCH avatar_url and preferences"), + User = api_test_runner:client_patch(<<"/v1/user/me">>, Token, + #{avatar_url => <<"https://cdn.example/a.png">>, + preferences => #{<<"theme">> => <<"dark">>, <<"notify">> => true}}), + ?assertEqual(<<"https://cdn.example/a.png">>, maps:get(<<"avatar_url">>, User)), + Prefs = maps:get(<<"preferences">>, User), + ?assertEqual(<<"dark">>, maps:get(<<"theme">>, Prefs)), + ?assertEqual(true, maps:get(<<"notify">>, Prefs)), + ct:pal(" OK"). + +test_patch_null_clears(Token) -> + ct:pal(" TEST: PATCH null clears optional fields"), + User = api_test_runner:client_patch(<<"/v1/user/me">>, Token, + #{nickname => null, phone => null, avatar_url => null, + timezone => null, preferences => null}), + ?assertEqual(<<>>, maps:get(<<"nickname">>, User)), + ?assertEqual(<<>>, maps:get(<<"phone">>, User)), + ?assertEqual(<<>>, maps:get(<<"avatar_url">>, User)), + ?assertEqual(<<>>, maps:get(<<"timezone">>, User)), + ?assertEqual(#{}, maps:get(<<"preferences">>, User)), + ct:pal(" OK"). + +test_patch_invalid_language(Token) -> + ct:pal(" TEST: PATCH invalid language -> 400"), + Body = jsx:encode(#{language => <<"de">>}), + Resp = api_test_runner:client_request(patch, <<"/v1/user/me">>, Token, Body), + ?assertMatch({ok, 400, _, _}, Resp), + ct:pal(" OK"). + +test_patch_empty_body(Token) -> + ct:pal(" TEST: PATCH empty body -> 400"), + Resp = api_test_runner:client_request(patch, <<"/v1/user/me">>, Token, <<"{}">>), + ?assertMatch({ok, 400, _, _}, Resp), + ct:pal(" OK"). + +test_patch_invalid_json(Token) -> + ct:pal(" TEST: PATCH invalid JSON -> 400"), + Resp = api_test_runner:client_request(patch, <<"/v1/user/me">>, Token, <<"{not-json">>), + ?assertMatch({ok, 400, _, _}, Resp), + ct:pal(" OK"). + +test_patch_unknown_field(Token) -> + ct:pal(" TEST: PATCH unknown field -> 400"), + Body = jsx:encode(#{email => <<"hack@x.com">>}), + Resp = api_test_runner:client_request(patch, <<"/v1/user/me">>, Token, Body), + ?assertMatch({ok, 400, _, _}, Resp), + ct:pal(" OK"). + +test_patch_forbid_email(Token) -> + ct:pal(" TEST: PATCH role -> 400"), + Body = jsx:encode(#{role => <<"bot">>}), + Resp = api_test_runner:client_request(patch, <<"/v1/user/me">>, Token, Body), + ?assertMatch({ok, 400, _, _}, Resp), + ct:pal(" OK"). + +test_patch_unauthorized() -> + ct:pal(" TEST: PATCH without token -> 401"), + Body = jsx:encode(#{language => <<"ru">>}), + Resp = api_test_runner:client_request(patch, <<"/v1/user/me">>, <<>>, Body), + ?assertMatch({ok, 401, _, _}, Resp), + ct:pal(" OK"). + +test_patch_password_wrong_current(Token, Current) -> + ct:pal(" TEST: PATCH password wrong current -> 403"), + Body = jsx:encode(#{current_password => <<"nope">>, password => <<"x", Current/binary>>}), + Resp = api_test_runner:client_request(patch, <<"/v1/user/me">>, Token, Body), + ?assertMatch({ok, 403, _, _}, Resp), + ct:pal(" OK"). + +test_patch_password_ok(Token, OldPass, NewPass, Email) -> + ct:pal(" TEST: PATCH password ok"), + User = api_test_runner:client_patch(<<"/v1/user/me">>, Token, + #{current_password => OldPass, password => NewPass}), + ?assert(maps:is_key(<<"id">>, User)), + %% login with new password + Login = api_test_runner:client_request(post, <<"/v1/login">>, <<>>, + jsx:encode(#{email => Email, password => NewPass})), + ?assertMatch({ok, 200, _, _}, Login), + %% old password fails + OldLogin = api_test_runner:client_request(post, <<"/v1/login">>, <<>>, + jsx:encode(#{email => Email, password => OldPass})), + ?assertMatch({ok, 401, _, _}, OldLogin), + ct:pal(" OK"). + +test_patch_password_pair_required(Token) -> + ct:pal(" TEST: PATCH password without current -> 400"), + Body = jsx:encode(#{password => <<"alone">>}), + Resp = api_test_runner:client_request(patch, <<"/v1/user/me">>, Token, Body), + ?assertMatch({ok, 400, _, _}, Resp), + ct:pal(" OK"). diff --git a/test/unit/logic_user_tests.erl b/test/unit/logic_user_tests.erl new file mode 100644 index 0000000..a401fda --- /dev/null +++ b/test/unit/logic_user_tests.erl @@ -0,0 +1,143 @@ +%%%------------------------------------------------------------------- +%%% @doc EUnit: logic_user:update_me/2 (PATCH /v1/user/me). +%%% @end +%%%------------------------------------------------------------------- +-module(logic_user_tests). +-include_lib("eunit/include/eunit.hrl"). +-include("records.hrl"). + +-define(TABLES, [user]). +-define(PASS, <<"oldpass1">>). +-define(PASS2, <<"newpass2">>). + +setup() -> + eh_test_support:start_mnesia(), + eh_test_support:ensure_tables(?TABLES), + case mnesia:add_table_index(user, email) of + {atomic, ok} -> ok; + {aborted, {already_exists, user, email}} -> ok; + {aborted, Reason} -> error({add_index_failed, Reason}) + end, + ok. + +cleanup(_) -> + eh_test_support:clear_tables(?TABLES), + eh_test_support:delete_tables(?TABLES), + eh_test_support:stop_mnesia(), + ok. + +logic_user_update_me_test_() -> + {foreach, fun setup/0, fun cleanup/1, [ + {"Success profile fields", fun test_success_profile_fields/0}, + {"Language validation", fun test_language_validation/0}, + {"Unknown field", fun test_unknown_field/0}, + {"Password pair required", fun test_password_pair_required/0}, + {"Wrong password", fun test_wrong_password/0}, + {"Successful password change", fun test_password_change_ok/0}, + {"Empty body", fun test_empty_body/0}, + {"Non-active user forbidden", fun test_non_active_forbidden/0} + ]}. + +%%%=================================================================== +%%% Helpers +%%%=================================================================== + +seed_active_user() -> + {ok, Hash} = logic_auth:hash_password(?PASS), + User = eh_test_support:make_user(#{ + id => <<"me_user_1">>, + email => <<"me@test.local">>, + password_hash => Hash, + status => active, + nickname => <<"oldnick">>, + language => <<"ru">>, + timezone => <<"UTC">>, + phone => <<"+1000">>, + avatar_url => <<"http://old/avatar.png">>, + preferences => #{<<"theme">> => <<"light">>} + }), + ok = mnesia:dirty_write(User), + User. + +%%%=================================================================== +%%% Cases +%%%=================================================================== + +test_success_profile_fields() -> + User = seed_active_user(), + Body = #{ + <<"language">> => <<"en">>, + <<"nickname">> => <<"Nick">>, + <<"timezone">> => <<"Europe/Moscow">>, + <<"phone">> => <<"+7000">>, + <<"avatar_url">> => <<"http://cdn/a.png">>, + <<"preferences">> => #{<<"theme">> => <<"dark">>} + }, + {ok, Updated} = logic_user:update_me(User#user.id, Body), + ?assertEqual(<<"en">>, Updated#user.language), + ?assertEqual(<<"Nick">>, Updated#user.nickname), + ?assertEqual(<<"Europe/Moscow">>, Updated#user.timezone), + ?assertEqual(<<"+7000">>, Updated#user.phone), + ?assertEqual(<<"http://cdn/a.png">>, Updated#user.avatar_url), + ?assertEqual(#{<<"theme">> => <<"dark">>}, Updated#user.preferences). + +test_language_validation() -> + User = seed_active_user(), + ?assertEqual({error, invalid_language}, + logic_user:update_me(User#user.id, #{<<"language">> => <<"de">>})), + ?assertEqual({error, invalid_language}, + logic_user:update_me(User#user.id, #{<<"language">> => <<"RU">>})), + {ok, Ok} = logic_user:update_me(User#user.id, #{<<"language">> => <<"en">>}), + ?assertEqual(<<"en">>, Ok#user.language). + +test_unknown_field() -> + User = seed_active_user(), + ?assertEqual({error, unknown_field}, + logic_user:update_me(User#user.id, #{<<"email">> => <<"x@y.z">>})), + ?assertEqual({error, unknown_field}, + logic_user:update_me(User#user.id, #{<<"role">> => <<"bot">>})). + +test_password_pair_required() -> + User = seed_active_user(), + ?assertEqual({error, password_pair_required}, + logic_user:update_me(User#user.id, #{<<"password">> => ?PASS2})), + ?assertEqual({error, password_pair_required}, + logic_user:update_me(User#user.id, #{<<"current_password">> => ?PASS})). + +test_wrong_password() -> + User = seed_active_user(), + ?assertEqual({error, wrong_password}, + logic_user:update_me(User#user.id, #{ + <<"current_password">> => <<"nope">>, + <<"password">> => ?PASS2 + })). + +test_password_change_ok() -> + User = seed_active_user(), + {ok, Updated} = logic_user:update_me(User#user.id, #{ + <<"current_password">> => ?PASS, + <<"password">> => ?PASS2 + }), + ?assertEqual({ok, true}, logic_auth:verify_password(?PASS2, Updated#user.password_hash)), + ?assertEqual({ok, false}, logic_auth:verify_password(?PASS, Updated#user.password_hash)). + +test_empty_body() -> + User = seed_active_user(), + ?assertEqual({error, empty_body}, logic_user:update_me(User#user.id, #{})), + ?assertEqual({error, invalid_json}, logic_user:update_me(User#user.id, not_a_map)). + +test_non_active_forbidden() -> + {ok, Hash} = logic_auth:hash_password(?PASS), + Frozen = eh_test_support:make_user(#{ + id => <<"me_frozen">>, + email => <<"frozen@test.local">>, + password_hash => Hash, + status => frozen + }), + ok = mnesia:dirty_write(Frozen), + ?assertEqual({error, forbidden}, + logic_user:update_me(Frozen#user.id, #{<<"nickname">> => <<"x">>})), + Pending = Frozen#user{id = <<"me_pending">>, email = <<"pending@test.local">>, status = pending}, + ok = mnesia:dirty_write(Pending), + ?assertEqual({error, forbidden}, + logic_user:update_me(Pending#user.id, #{<<"nickname">> => <<"x">>})).