From 2c0d516578e0383492140024d5a844b999f62ad2 Mon Sep 17 00:00:00 2001 From: frischler Date: Sat, 9 Apr 2022 19:08:53 +0200 Subject: [PATCH] Improved Messages UI and shrinking includes/user_messages.php --- config/routes.php | 7 + includes/pages/user_messages.php | 168 +---- resources/assets/themes/base.scss | 11 + resources/lang/de_DE/additional.po | 3 + resources/lang/de_DE/default.po | 23 +- resources/lang/en_US/additional.po | 3 + resources/lang/en_US/default.po | 15 + resources/views/layouts/parts/navbar.twig | 2 +- .../views/pages/messages/conversation.twig | 66 ++ resources/views/pages/messages/overview.twig | 64 ++ src/Controllers/MessagesController.php | 285 +++++++++ src/Middleware/LegacyMiddleware.php | 4 - src/Models/Message.php | 10 + src/Models/User/User.php | 14 + .../Controllers/MessagesControllerTest.php | 585 ++++++++++++++++++ tests/Unit/Models/User/UserTest.php | 42 ++ 16 files changed, 1125 insertions(+), 177 deletions(-) create mode 100644 resources/views/pages/messages/conversation.twig create mode 100644 resources/views/pages/messages/overview.twig create mode 100644 src/Controllers/MessagesController.php create mode 100644 tests/Unit/Controllers/MessagesControllerTest.php diff --git a/config/routes.php b/config/routes.php index cd61a709..bebacf4f 100644 --- a/config/routes.php +++ b/config/routes.php @@ -50,6 +50,13 @@ $route->post('/questions', 'QuestionsController@delete'); $route->get('/questions/new', 'QuestionsController@add'); $route->post('/questions/new', 'QuestionsController@save'); +// Messages +$route->get('/messages', 'MessagesController@index'); +$route->post('/messages', 'MessagesController@toConversation'); +$route->get('/messages/{user_id:\d+}', 'MessagesController@conversation'); +$route->post('/messages/{user_id:\d+}', 'MessagesController@send'); +$route->post('/messages/{user_id:\d+}/{msg_id:\d+}', 'MessagesController@delete'); + // API $route->get('/api[/{resource:.+}]', 'ApiController@index'); diff --git a/includes/pages/user_messages.php b/includes/pages/user_messages.php index 21134733..50e95e51 100644 --- a/includes/pages/user_messages.php +++ b/includes/pages/user_messages.php @@ -1,174 +1,14 @@ user(); + $count = app()->make(MessagesController::class) + ->numberOfUnreadMessages(); - if ($user) { - $new_messages = $user->messagesReceived() - ->where('read', false) - ->count(); - - if ($new_messages > 0) { - return ' ' . $new_messages . ''; - } - } - return ''; -} - -/** - * @return string - */ -function user_messages() -{ - $user = auth()->user(); - $request = request(); - - if (!$request->has('action')) { - /** @var User[] $users */ - $users = User::query() - ->where('user_id', '!=', $user->id) - ->leftJoin('users_personal_data', 'users.id', '=', 'users_personal_data.user_id') - ->orderBy('name') - ->get(['id', 'name', 'pronoun']); - - $to_select_data = [ - '' => __('Select recipient...') - ]; - - foreach ($users as $u) { - $pronoun = ((config('enable_pronoun') && $u->pronoun) ? ' (' . htmlspecialchars($u->pronoun) . ')' : ''); - $to_select_data[$u->id] = $u->name . $pronoun; - } - - $to_select = html_select_key('to', 'to', $to_select_data, ''); - - $messages = $user->messages; - - $messages_table = [ - [ - 'news' => '', - 'timestamp' => date(__('Y-m-d H:i')), - 'from' => User_Nick_render($user), - 'to' => $to_select, - 'text' => form_textarea('text', '', ''), - 'actions' => form_submit('submit', __('Send')) - ] - ]; - - foreach ($messages as $message) { - $sender_user_source = $message->user; - $receiver_user_source = $message->receiver; - - $messages_table_entry = [ - 'new' => !$message->read ? icon('envelope') : '', - 'timestamp' => $message->created_at->format(__('Y-m-d H:i')), - 'from' => User_Nick_render($sender_user_source), - 'to' => User_Nick_render($receiver_user_source), - 'text' => nl2br(htmlspecialchars($message->text)) - ]; - - if ($message->receiver_id == $user->id) { - if (!$message->read) { - $messages_table_entry['actions'] = button( - page_link_to('user_messages', ['action' => 'read', 'id' => $message->id]), - __('mark as read'), - 'btn-sm' - ); - } - } else { - $messages_table_entry['actions'] = button( - page_link_to('user_messages', ['action' => 'delete', 'id' => $message->id]), - __('delete message'), - 'btn-sm' - ); - } - $messages_table[] = $messages_table_entry; - } - - return page_with_title(messages_title(), [ - msg(), - sprintf(__('Hello %s, here can you leave messages for other angels'), User_Nick_render($user)), - form([ - table([ - 'new' => __('New'), - 'timestamp' => __('Date'), - 'from' => __('Transmitted'), - 'to' => __('Recipient'), - 'text' => __('Message'), - 'actions' => '' - ], $messages_table) - ], page_link_to('user_messages', ['action' => 'send'])) - ]); - } else { - switch ($request->input('action')) { - case 'read': - if ($request->has('id') && preg_match('/^\d{1,11}$/', $request->input('id'))) { - $message_id = $request->input('id'); - } else { - return error(__('Incomplete call, missing Message ID.'), true); - } - - $message = Message::find($message_id); - if ($message !== null && $message->receiver_id == $user->id) { - $message->read = true; - $message->save(); - throw_redirect(page_link_to('user_messages')); - } else { - return error(__('No Message found.'), true); - } - break; - - case 'delete': - if ($request->has('id') && preg_match('/^\d{1,11}$/', $request->input('id'))) { - $message_id = $request->input('id'); - } else { - return error(__('Incomplete call, missing Message ID.'), true); - } - - $message = Message::find($message_id); - if ($message !== null && $message->user_id == $user->id) { - $message->delete(); - throw_redirect(page_link_to('user_messages')); - } else { - return error(__('No Message found.'), true); - } - break; - - case 'send': - $receiver = User::find($request->input('to')); - $text = $request->input('text'); - - if ($receiver !== null && !empty($text)) { - Message::create([ - 'user_id' => $user->id, - 'receiver_id' => $request->input('to'), - 'text' => $request->input('text') - ]); - throw_redirect(page_link_to('user_messages')); - } else { - return error(__('Transmitting was terminated with an Error.'), true); - } - break; - - default: - return error(__('Wrong action.'), true); - } - } - - return ''; + return $count > 0 ? ' ' . $count . '' : ''; } diff --git a/resources/assets/themes/base.scss b/resources/assets/themes/base.scss index af5fda3f..4f84b48f 100644 --- a/resources/assets/themes/base.scss +++ b/resources/assets/themes/base.scss @@ -348,3 +348,14 @@ code { content: ""; } } + +.conversation { + height: 60vh; + overflow-x: hidden; + overflow-y: auto +} + +.message { + max-width: 75%; + display: inline-block; +} diff --git a/resources/lang/de_DE/additional.po b/resources/lang/de_DE/additional.po index c0307a26..f6bee335 100644 --- a/resources/lang/de_DE/additional.po +++ b/resources/lang/de_DE/additional.po @@ -154,3 +154,6 @@ msgstr "Eine Beschreibung findest du unter %2$s" msgid "user.edit.success" msgstr "Benutzer erfolgreich bearbeitet." + +msgid "messages.delete.success" +msgstr "Nachricht erfolgreich gelöscht." diff --git a/resources/lang/de_DE/default.po b/resources/lang/de_DE/default.po index d76a7f52..54fcac97 100644 --- a/resources/lang/de_DE/default.po +++ b/resources/lang/de_DE/default.po @@ -1308,10 +1308,6 @@ msgstr "Zu löschende Schichten" msgid "It's done!" msgstr "Erledigt!" -#: includes/pages/user_messages.php:121 -msgid "Message" -msgstr "Nachricht" - #: includes/pages/admin_rooms.php:202 #: includes/view/User_view.php:129 msgid "Delete" @@ -1685,10 +1681,6 @@ msgstr "Nachname" msgid "Entry required!" msgstr "Pflichtfeld!" -#: includes/pages/user_messages.php:11 -msgid "Messages" -msgstr "Nachrichten" - #: includes/pages/user_messages.php:49 msgid "Select recipient..." msgstr "Empfänger auswählen..." @@ -3038,3 +3030,18 @@ msgstr "Angekommen" msgid "user.got_shirt" msgstr "Shirt bekommen" + +msgid "messages.title" +msgstr "Nachrichten" + +msgid "messages.choose.an.angel" +msgstr "Wähle einen Engel" + +msgid "messages.to.conversation" +msgstr "Zur Konversation" + +msgid "angel" +msgstr "Engel" + +msgid "message" +msgstr "Nachricht" diff --git a/resources/lang/en_US/additional.po b/resources/lang/en_US/additional.po index dc85913c..58aacd0f 100644 --- a/resources/lang/en_US/additional.po +++ b/resources/lang/en_US/additional.po @@ -152,3 +152,6 @@ msgstr "You can find a description at %2$s" msgid "user.edit.success" msgstr "User edited successfully." + +msgid "messages.delete.success" +msgstr "Message successfully deleted." diff --git a/resources/lang/en_US/default.po b/resources/lang/en_US/default.po index b1b5825f..ce1c7ffd 100644 --- a/resources/lang/en_US/default.po +++ b/resources/lang/en_US/default.po @@ -300,3 +300,18 @@ msgstr "Arrived" msgid "user.got_shirt" msgstr "Got shirt" + +msgid "messages.title" +msgstr "Messages" + +msgid "messages.choose.an.angel" +msgstr "Choose an Angel" + +msgid "messages.to.conversation" +msgstr "To Conversation" + +msgid "angel" +msgstr "Angel" + +msgid "message" +msgstr "Message" diff --git a/resources/views/layouts/parts/navbar.twig b/resources/views/layouts/parts/navbar.twig index 558d917b..155df01b 100644 --- a/resources/views/layouts/parts/navbar.twig +++ b/resources/views/layouts/parts/navbar.twig @@ -44,7 +44,7 @@ {% endif %} {% if is_user() and has_permission_to('user_messages') %} - {{ _self.toolbar_item(menuUserMessages(), url('user-messages'), 'user-messages', 'envelope') }} + {{ _self.toolbar_item(menuUserMessages(), url('messages'), 'messages', 'envelope') }} {% endif %} {{ menuUserHints() }} diff --git a/resources/views/pages/messages/conversation.twig b/resources/views/pages/messages/conversation.twig new file mode 100644 index 00000000..249e8328 --- /dev/null +++ b/resources/views/pages/messages/conversation.twig @@ -0,0 +1,66 @@ +{% extends "layouts/app.twig" %} +{% import 'macros/base.twig' as m %} +{% import 'macros/form.twig' as f %} + +{% block title %}{{ __('messages.title') }}: {{ other_user.nameWithPronoun() }}{% endblock %} + +{% block content %} +
+ + +
+
+
+ {% for msg in messages %} + {% if msg.user_id == other_user.id %} +
+
+
+
{{ msg.text | nl2br }}
+
+ {{ msg.created_at }} +
+ {% if msg.read == false %} + + New alerts + + {% endif %} +
+
+
+ {% else %} +
+
+
+
{{ msg.text | nl2br }}
+
+ +
+ {{ csrf() }} + {{ msg.created_at }} + {{ f.submit(m.icon('trash'), {'btn_type': 'primary', 'size': 'sm'}) }} +
+
+
+
+
+ {% endif %} + {% endfor %} +
+ +
+ {{ csrf() }} + +
+ + {{ f.submit(m.icon('send-fill')) }} +
+
+ +
+{% endblock %} diff --git a/resources/views/pages/messages/overview.twig b/resources/views/pages/messages/overview.twig new file mode 100644 index 00000000..9e1fdb71 --- /dev/null +++ b/resources/views/pages/messages/overview.twig @@ -0,0 +1,64 @@ +{% extends "layouts/app.twig" %} +{% import 'macros/base.twig' as m %} +{% import 'macros/form.twig' as f %} + +{% block title %}{{ __('messages.title') }}{% endblock %} + +{% block content %} +
+ +
+ {{ csrf() }} +
+
+ +
+
+ {{ f.submit(__('messages.to.conversation'), {'btn_type': 'secondary'}) }} +
+
+
+ + + + + + + + + + + {% for c in conversations %} + + + + + + {% endfor %} + +
{{ __('angel') }}{{ __('message') }}{{ __('Date') }}
+ + {{ c.other_user.nameWithPronoun() }} + {% if c.unread_messages > 0 %} + {{ c.unread_messages }} + {% endif %} + + + {{ c.latest_message.text|length > 100 ? c.latest_message.text|slice(0, 100) ~ '...' : c.latest_message.text }} + + + {{ c.latest_message.created_at }} +
+ +
+ +{% endblock %} diff --git a/src/Controllers/MessagesController.php b/src/Controllers/MessagesController.php new file mode 100644 index 00000000..2265f6fb --- /dev/null +++ b/src/Controllers/MessagesController.php @@ -0,0 +1,285 @@ +auth = $auth; + $this->log = $log; + $this->redirect = $redirect; + $this->response = $response; + $this->request = $request; + $this->db = $db; + $this->message = $message; + $this->user = $user; + } + + public function index(): Response + { + return $this->listConversations(); + } + + /** + * Returns a list of conversations of the current user, each containing the other participant, + * the most recent message, and the number of unread messages. + */ + public function listConversations(): Response + { + $current_user = $this->auth->user(); + + $latest_messages = $this->latestMessagePerConversation($current_user); + $numberOfUnreadMessages = $this->numberOfUnreadMessagesPerConversation($current_user); + + $conversations = []; + foreach ($latest_messages as $msg) { + $other_user = $msg->user_id == $current_user->id ? $msg->receiver : $msg->sender; + $unread_messages = $numberOfUnreadMessages[$other_user->id] ?? 0; + array_push($conversations, [ + 'other_user' => $other_user, + 'latest_message' => $msg, + 'unread_messages' => $unread_messages + ]); + } + + $users = $this->user->orderBy('name')->get() + ->except($current_user->id) + ->mapWithKeys(function ($u) { + return [ $u->id => $u->nameWithPronoun() ]; + }); + + return $this->response->withView( + 'pages/messages/overview.twig', + [ + 'conversations' => $conversations, + 'users' => $users + ] + ); + } + + /** + * Forwards to the conversation with the user of the given id. + */ + public function toConversation(Request $request): Response + { + $data = $this->validate($request, [ 'user_id' => 'required|int' ]); + return $this->redirect->to('/messages/' . $data['user_id']); + } + + /** + * Returns a list of messages between the current user and a user with the given id. The ids shall not be the same. + * Unread messages will be marked as read during this call. Still, they will be shown as unread in the frontend to + * highlight them to the user as new. + */ + public function conversation(Request $request): Response + { + $current_user = $this->auth->user(); + $other_user = $this->user->findOrFail($request->getAttribute('user_id')); + + if ($current_user->id == $other_user->id) { + throw new HttpForbidden('You can not start a conversation with yourself.'); + } + + $messages = $this->message + ->where(function ($q) use ($current_user, $other_user) { + $q->whereUserId($current_user->id) + ->whereReceiverId($other_user->id); + }) + ->orWhere(function ($q) use ($current_user, $other_user) { + $q->whereUserId($other_user->id) + ->whereReceiverId($current_user->id); + }) + ->orderBy('created_at') + ->get(); + + $unread_messages = $messages->filter(function ($m) use ($other_user) { + return $m->user_id == $other_user->id && !$m->read; + }); + + foreach ($unread_messages as $msg) { + $msg->read = true; + $msg->save(); + $msg->read = false; // change back to true to display it to the frontend one more time. + } + + return $this->response->withView( + 'pages/messages/conversation.twig', + ['messages' => $messages, 'other_user' => $other_user] + ); + } + + /** + * Sends a message to another user. + */ + public function send(Request $request): Response + { + $current_user = $this->auth->user(); + + $data = $this->validate($request, [ 'text' => 'required' ]); + + $other_user = $this->user->findOrFail($request->getAttribute('user_id')); + + if ($other_user->id == $current_user->id) { + throw new HttpForbidden('You can not send a message to yourself.'); + } + + $new_message = new Message(); + $new_message->sender()->associate($current_user); + $new_message->receiver()->associate($other_user); + $new_message->text = $data['text']; + $new_message->read = false; + $new_message->save(); + + $this->log->info( + 'User {from} has written a message to user {to}', + [ + 'from' => $current_user->id, + 'to' => $other_user->id + ] + ); + return $this->redirect->to('/messages/' . $other_user->id); + } + + /** + * Deletes a message from a given id, as long as this message was send by the current user. The given user_id + * The given user_id is used to redirect back to the conversation with that user. + */ + public function delete(Request $request): Response + { + $current_user = $this->auth->user(); + $other_user_id = $request->getAttribute('user_id'); + $msg_id = $request->getAttribute('msg_id'); + $msg = $this->message->findOrFail($msg_id); + + if ($msg->user_id == $current_user->id) { + $msg->delete(); + + $this->log->info( + 'User {from} deleted message {msg} in a conversation with user {to}', + [ + 'from' => $current_user->id, + 'to' => $other_user_id, + 'msg' => $msg_id + ] + ); + } else { + $this->log->warning( + 'User {from} tried to delete message {msg} which was not written by them, ' . + 'in a conversation with user {to}', + [ + 'from' => $current_user->id, + 'to' => $other_user_id, + 'msg' => $msg_id + ] + ); + + throw new HttpForbidden('You can not delete a message you haven\'t send'); + } + + return $this->redirect->to('/messages/' . $other_user_id); + } + + public function numberOfUnreadMessages(): int + { + return $this->auth->user() + ->messagesReceived() + ->where('read', false) + ->count(); + } + + protected function numberOfUnreadMessagesPerConversation($current_user): Collection + { + return $current_user->messagesReceived() + ->select('user_id', $this->raw('count(*) as amount')) + ->where('read', false) + ->groupBy('user_id') + ->get(['user_id', 'amount']) + ->mapWithKeys(function ($unread) { + return [ $unread->user_id => $unread->amount ]; + }); + } + + protected function latestMessagePerConversation($current_user): Collection + { + $latest_message_ids = $this->message + ->select($this->raw('max(id) as last_id')) + ->where('user_id', "=", $current_user->id) + ->orWhere('receiver_id', "=", $current_user->id) + ->groupBy($this->raw( + '(CASE WHEN user_id = ' . $current_user->id . + ' THEN receiver_id ELSE user_id END)' + )); + + return $this->message + ->joinSub($latest_message_ids, 'conversations', function ($join) { + $join->on('messages.id', '=', 'conversations.last_id'); + }) + ->orderBy('created_at', 'DESC') + ->get(); + } + + protected function raw($value): QueryExpression + { + return $this->db->getConnection()->raw($value); + } +} diff --git a/src/Middleware/LegacyMiddleware.php b/src/Middleware/LegacyMiddleware.php index 9edba882..4ed9e122 100644 --- a/src/Middleware/LegacyMiddleware.php +++ b/src/Middleware/LegacyMiddleware.php @@ -140,10 +140,6 @@ class LegacyMiddleware implements MiddlewareInterface return [$title, $content]; case 'user_worklog': return user_worklog_controller(); - case 'user_messages': - $title = messages_title(); - $content = user_messages(); - return [$title, $content]; case 'user_settings': $title = settings_title(); $content = user_settings(); diff --git a/src/Models/Message.php b/src/Models/Message.php index 55780fc3..3e433c22 100644 --- a/src/Models/Message.php +++ b/src/Models/Message.php @@ -20,8 +20,10 @@ use Illuminate\Support\Carbon; * @property string $text * @property Carbon|null $created_at * @property Carbon|null $updated_at + * @property-read User $sender * @property-read User $receiver * @method static Builder|Message whereId($value) + * @method static Builder|Message whereUserId($value) * @method static Builder|Message whereReceiverId($value) * @method static Builder|Message whereRead($value) * @method static Builder|Message whereText($value) @@ -56,6 +58,14 @@ class Message extends BaseModel 'read' => false, ]; + /** + * @return BelongsTo + */ + public function sender(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id'); + } + /** * @return BelongsTo */ diff --git a/src/Models/User/User.php b/src/Models/User/User.php index 2d06468d..62363b45 100644 --- a/src/Models/User/User.php +++ b/src/Models/User/User.php @@ -225,4 +225,18 @@ class User extends BaseModel ->orderBy('read') ->orderBy('id', 'DESC'); } + + /** + * Return either just the user name or the name alongside with the pronoun. + * @return string + */ + public function nameWithPronoun(): string + { + if (config('enable_pronoun')) { + $pronoun = $this->personalData->pronoun; + return $pronoun ? $this->name . ' (' . $pronoun . ')' : $this->name; + } else { + return $this->name; + } + } } diff --git a/tests/Unit/Controllers/MessagesControllerTest.php b/tests/Unit/Controllers/MessagesControllerTest.php new file mode 100644 index 00000000..d16354ea --- /dev/null +++ b/tests/Unit/Controllers/MessagesControllerTest.php @@ -0,0 +1,585 @@ + returnsCorrectViewAndData + */ + public function testIndexUnderNormalConditionsReturnsCorrectViewAndData() + { + $this->response->expects($this->once()) + ->method('withView') + ->willReturnCallback(function (string $view, array $data) { + $this->assertEquals('pages/messages/overview.twig', $view); + $this->assertArrayHasKey('conversations', $data); + $this->assertArrayHasKey('users', $data); + $this->assertArrayOrCollection($data['conversations']); + $this->assertArrayOrCollection($data['users']); + return $this->response; + }); + + $this->controller->index(); + } + + /** + * @testdox index: otherUsersExist -> returnsUsersWithoutMeOrderedByName + */ + public function testIndexOtherUsersExistReturnsUsersWithoutMeOrderedByName() + { + $this->response->expects($this->once()) + ->method('withView') + ->willReturnCallback(function (string $view, array $data) { + $users = $data['users']; + + $this->assertEquals(1, count($users)); + $this->assertEquals('b', $users[$this->user_b->id]); + + return $this->response; + }); + + $this->controller->index(); + } + + /** + * @testdox index: pronounsDeactivated -> userListHasNoPronouns + */ + public function testIndexPronounsDeactivatedUserListHasNoPronouns() + { + $this->user_with_pronoun = User::factory(['name' => 'x']) + ->has(PersonalData::factory(['pronoun' => 'X']))->create(); + $this->user_without_pronoun = $this->user_b; + + $this->response->expects($this->once()) + ->method('withView') + ->willReturnCallback(function (string $view, array $data) { + $users = $data['users']; + + $this->assertEquals('x', $users[$this->user_with_pronoun->id]); + $this->assertEquals('b', $users[$this->user_without_pronoun->id]); + + return $this->response; + }); + + $this->controller->index(); + } + + /** + * @testdox index: pronounsActivated -> userListHasPronouns + */ + public function testIndexPronounsActivatedUserListHasPronouns() + { + config(['enable_pronoun' => true]); + + $this->user_with_pronoun = User::factory(['name' => 'x']) + ->has(PersonalData::factory(['pronoun' => 'X']))->create(); + $this->user_without_pronoun = $this->user_b; + + $this->response->expects($this->once()) + ->method('withView') + ->willReturnCallback(function (string $view, array $data) { + $users = $data['users']; + + $this->assertEquals('x (X)', $users[$this->user_with_pronoun->id]); + $this->assertEquals('b', $users[$this->user_without_pronoun->id]); + + return $this->response; + }); + + $this->controller->index(); + } + + /** + * @testdox index: withNoConversation -> returnsEmptyConversationList + */ + public function testIndexWithNoConversationReturnsEmptyConversationList() + { + $this->response->expects($this->once()) + ->method('withView') + ->willReturnCallback(function (string $view, array $data) { + $this->assertEquals(0, count($data['conversations'])); + return $this->response; + }); + + $this->controller->index(); + } + + /** + * @testdox index: withConversation -> conversationContainsCorrectData + */ + public function testIndexWithConversationConversationContainsCorrectData() + { + // save messages in wrong order to ensure latest message considers creation date, not id. + $this->createMessage($this->user_a, $this->user_b, 'a>b', $this->now); + $this->createMessage($this->user_b, $this->user_a, 'b>a', $this->two_minutes_ago); + $this->createMessage($this->user_b, $this->user_a, 'b>a', $this->one_minute_ago); + + $this->response->expects($this->once()) + ->method('withView') + ->willReturnCallback(function (string $view, array $data) { + $conversations = $data['conversations']; + + $this->assertEquals(1, count($conversations)); + $c = $conversations[0]; + + $this->assertArrayHasKey('other_user', $c); + $this->assertArrayHasKey('latest_message', $c); + $this->assertArrayHasKey('unread_messages', $c); + + $this->assertTrue($c['other_user'] instanceof User); + $this->assertTrue($c['latest_message'] instanceof Message); + $this->assertEquals('string', gettype($c['unread_messages'])); + + $this->assertEquals('b', $c['other_user']->name); + $this->assertEquals('b>a', $c['latest_message']->text); + $this->assertEquals(2, $c['unread_messages']); + + return $this->response; + }); + + $this->controller->index(); + } + + /** + * @testdox index: withConversations -> onlyContainsConversationsWithMe + */ + public function testIndexWithConversationsOnlyContainsConversationsWithMe() + { + $user_c = User::factory(['name' => 'c'])->create(); + + // save messages in wrong order to ensure latest message considers creation date, not id. + $this->createMessage($this->user_a, $this->user_b, 'a>b', $this->now); + $this->createMessage($this->user_b, $user_c, 'b>c', $this->now); + $this->createMessage($user_c, $this->user_a, 'c>a', $this->now); + + $this->response->expects($this->once()) + ->method('withView') + ->willReturnCallback(function (string $view, array $data) { + $conversations = $data['conversations']; + + $this->assertEquals(2, count($conversations)); + $msg0 = $conversations[0]['latest_message']->text; + $msg1 = $conversations[1]['latest_message']->text; + $this->assertTrue(($msg0 == 'a>b' && $msg1 == 'c>a') || ($msg1 == 'c>a' && $msg0 == 'a>b')); + + return $this->response; + }); + + $this->controller->index(); + } + + /** + * @testdox index: withConversations -> conversationsOrderedByDate + */ + public function testIndexWithConversationsConversationsOrderedByDate() + { + $user_c = User::factory(['name' => 'c'])->create(); + $user_d = User::factory(['name' => 'd'])->create(); + + $this->createMessage($this->user_a, $this->user_b, 'a>b', $this->now); + $this->createMessage($user_d, $this->user_a, 'd>a', $this->two_minutes_ago); + $this->createMessage($this->user_a, $user_c, 'a>c', $this->one_minute_ago); + + $this->response->expects($this->once()) + ->method('withView') + ->willReturnCallback(function (string $view, array $data) { + $conversations = $data['conversations']; + + $this->assertEquals('a>b', $conversations[0]['latest_message']->text); + $this->assertEquals('a>c', $conversations[1]['latest_message']->text); + $this->assertEquals('d>a', $conversations[2]['latest_message']->text); + + return $this->response; + }); + + $this->controller->index(); + } + + /** + * @testdox ToConversation: withNoUserIdGiven -> throwsException + */ + public function testToConversationWithNoUserIdGivenThrowsException() + { + $this->expectException(ValidationException::class); + $this->controller->toConversation($this->request); + } + + /** + * @testdox ToConversation: withUserIdGiven -> redirect + */ + public function testToConversationWithUserIdGivenRedirect() + { + $this->request = $this->request->withParsedBody([ + 'user_id' => '1', + ]); + $this->response->expects($this->once()) + ->method('redirectTo') + ->with('http://localhost/messages/1') + ->willReturn($this->response); + + $this->controller->toConversation($this->request); + } + + /** + * @testdox conversation: withNoUserIdGiven -> throwsException + */ + public function testConversationWithNoUserIdGivenThrowsException() + { + $this->expectException(ModelNotFoundException::class); + $this->controller->conversation($this->request); + } + + /** + * @testdox conversation: withMyUserIdGiven -> throwsException + */ + public function testConversationWithMyUserIdGivenThrowsException() + { + $this->request->attributes->set('user_id', $this->user_a->id); + $this->expectException(HttpForbidden::class); + $this->controller->conversation($this->request); + } + + /** + * @testdox conversation: withUnknownUserIdGiven -> throwsException + */ + public function testConversationWithUnknownUserIdGivenThrowsException() + { + $this->request->attributes->set('user_id', '1234'); + $this->expectException(ModelNotFoundException::class); + $this->controller->conversation($this->request); + } + + /** + * @testdox conversation: underNormalConditions -> returnsCorrectViewAndData + */ + public function testConversationUnderNormalConditionsReturnsCorrectViewAndData() + { + $this->request->attributes->set('user_id', $this->user_b->id); + + $this->response->expects($this->once()) + ->method('withView') + ->willReturnCallback(function (string $view, array $data) { + $this->assertEquals('pages/messages/conversation.twig', $view); + $this->assertArrayHasKey('messages', $data); + $this->assertArrayHasKey('other_user', $data); + $this->assertArrayOrCollection($data['messages']); + $this->assertTrue($data['other_user'] instanceof User); + return $this->response; + }); + + $this->controller->conversation($this->request); + } + + /** + * @testdox conversation: withNoMessages -> returnsEmptyMessageList + */ + public function testConversationWithNoMessagesReturnsEmptyMessageList() + { + $this->request->attributes->set('user_id', $this->user_b->id); + + $this->response->expects($this->once()) + ->method('withView') + ->willReturnCallback(function (string $view, array $data) { + $this->assertEquals(0, count($data['messages'])); + return $this->response; + }); + + $this->controller->conversation($this->request); + } + + /** + * @testdox conversation: withMessages -> messagesOnlyWithThatUserOrderedByDate + */ + public function testConversationWithMessagesMessagesOnlyWithThatUserOrderedByDate() + { + $this->request->attributes->set('user_id', $this->user_b->id); + + $user_c = User::factory(['name' => 'c'])->create(); + + // to be listed + $this->createMessage($this->user_a, $this->user_b, 'a>b', $this->now); + $this->createMessage($this->user_b, $this->user_a, 'b>a', $this->two_minutes_ago); + $this->createMessage($this->user_b, $this->user_a, 'b>a2', $this->one_minute_ago); + + // not to be listed + $this->createMessage($this->user_a, $user_c, 'a>c', $this->now); + $this->createMessage($user_c, $this->user_b, 'b>c', $this->now); + + $this->response->expects($this->once()) + ->method('withView') + ->willReturnCallback(function (string $view, array $data) { + $messages = $data['messages']; + $this->assertEquals(3, count($messages)); + $this->assertEquals('b>a', $messages[0]->text); + $this->assertEquals('b>a2', $messages[1]->text); + $this->assertEquals('a>b', $messages[2]->text); + + return $this->response; + }); + + $this->controller->conversation($this->request); + } + + /** + * @testdox conversation: withUnreadMessages -> messagesToMeWillStillBeReturnedAsUnread + */ + public function testConversationWithUnreadMessagesMessagesToMeWillStillBeReturnedAsUnread() + { + $this->request->attributes->set('user_id', $this->user_b->id); + $this->createMessage($this->user_b, $this->user_a, 'b>a', $this->now); + + $this->response->expects($this->once()) + ->method('withView') + ->willReturnCallback(function (string $view, array $data) { + $this->assertFalse($data['messages'][0]->read); + return $this->response; + }); + $this->controller->conversation($this->request); + } + + /** + * @testdox conversation: withUnreadMessages -> messagesToMeWillBeMarkedAsRead + */ + public function testConversationWithUnreadMessagesMessagesToMeWillBeMarkedAsRead() + { + $this->request->attributes->set('user_id', $this->user_b->id); + $this->response->expects($this->once()) + ->method('withView') + ->willReturnCallback(function (string $view, array $data) { + return $this->response; + }); + + $msg = $this->createMessage($this->user_b, $this->user_a, 'b>a', $this->now); + $this->controller->conversation($this->request); + $this->assertTrue(Message::whereId($msg->id)->first()->read); + } + + /** + * @testdox send: withNoTextGiven -> throwsException + */ + public function testSendWithNoTextGivenThrowsException() + { + $this->expectException(ValidationException::class); + $this->controller->send($this->request); + } + + /** + * @testdox send: withNoUserIdGiven -> throwsException + */ + public function testSendWithNoUserIdGivenThrowsException() + { + $this->request = $this->request->withParsedBody([ + 'text' => 'a', + ]); + $this->expectException(ModelNotFoundException::class); + $this->controller->send($this->request); + } + + /** + * @testdox send: withMyUserIdGiven -> throwsException + */ + public function testSendWithMyUserIdGivenThrowsException() + { + $this->request = $this->request->withParsedBody([ + 'text' => 'a', + ]); + $this->request->attributes->set('user_id', $this->user_a->id); + $this->expectException(HttpForbidden::class); + $this->controller->send($this->request); + } + + /** + * @testdox send: withUnknownUserIdGiven -> throwsException + */ + public function testSendWithUnknownUserIdGivenThrowsException() + { + $this->request = $this->request->withParsedBody([ + 'text' => 'a', + ]); + $this->request->attributes->set('user_id', '1234'); + $this->expectException(ModelNotFoundException::class); + $this->controller->send($this->request); + } + + /** + * @testdox send: withUserAndTextGiven -> savesMessage + */ + public function testSendWithUserAndTextGivenSavesMessage() + { + $this->request = $this->request->withParsedBody([ + 'text' => 'a', + ]); + $this->request->attributes->set('user_id', $this->user_b->id); + + $this->response->expects($this->once()) + ->method('redirectTo') + ->with('http://localhost/messages/' . $this->user_b->id) + ->willReturn($this->response); + + $this->controller->send($this->request); + + $msg = Message::whereText('a')->first(); + $this->assertEquals($this->user_a->id, $msg->user_id); + $this->assertEquals($this->user_b->id, $msg->receiver_id); + $this->assertFalse($msg->read); + } + + /** + * @testdox delete: withNoMsgIdGiven -> throwsException + */ + public function testDeleteWithNoMsgIdGivenThrowsException() + { + $this->expectException(ModelNotFoundException::class); + $this->controller->delete($this->request); + } + + /** + * @testdox delete: tryingToDeleteSomeonesMessage -> throwsException + */ + public function testDeleteTryingToDeleteSomeonesMessageThrowsException() + { + $this->expectException(HttpForbidden::class); + + $msg = $this->createMessage($this->user_b, $this->user_a, 'a>b', $this->now); + $this->request->attributes->set('msg_id', $msg->id); + + $this->controller->delete($this->request); + } + + /** + * @testdox delete: tryingToDeleteMyMessage -> deletesItAndRedirect + */ + public function testDeleteTryingToDeleteMyMessageDeletesItAndRedirect() + { + $msg = $this->createMessage($this->user_a, $this->user_b, 'a>b', $this->now); + $this->request->attributes->set('msg_id', $msg->id); + $this->request->attributes->set('user_id', '1'); + + $this->response->expects($this->once()) + ->method('redirectTo') + ->with('http://localhost/messages/1') + ->willReturn($this->response); + + $this->controller->delete($this->request); + + $this->assertEquals(0, count(Message::whereId($msg->id)->get())); + } + + /** + * @testdox NumberOfUnreadMessages: withNoMessages -> returns0 + */ + public function testNumberOfUnreadMessagesWithNoMessagesReturns0() + { + $this->assertEquals(0, $this->controller->numberOfUnreadMessages()); + } + + /** + * @testdox NumberOfUnreadMessages: withMessagesNotToMe -> messagesNotToMeAreIgnored + */ + public function testNumberOfUnreadMessagesWithMessagesNotToMeMessagesNotToMeAreIgnored() + { + $user_c = User::factory(['name' => 'c'])->create(); + + $this->createMessage($this->user_a, $this->user_b, 'a>b', $this->now); + $this->createMessage($this->user_b, $user_c, 'b>c', $this->now); + $this->assertEquals(0, $this->controller->numberOfUnreadMessages()); + } + + /** + * @testdox NumberOfUnreadMessages: withMessages -> returnsSumOfUnreadMessagesSentToMe + */ + public function testNumberOfUnreadMessagesWithMessagesReturnsSumOfUnreadMessagesSentToMe() + { + $user_c = User::factory(['name' => 'c'])->create(); + + $this->createMessage($this->user_b, $this->user_a, 'b>a1', $this->now); + $this->createMessage($this->user_b, $this->user_a, 'b>a2', $this->now); + $this->createMessage($user_c, $this->user_a, 'c>a', $this->now); + + $this->assertEquals(3, $this->controller->numberOfUnreadMessages()); + } + + /** + * Setup environment + */ + public function setUp(): void + { + parent::setUp(); + + $this->auth = $this->createMock(Authenticator::class); + $this->app->instance(Authenticator::class, $this->auth); + + $this->app->bind(UrlGeneratorInterface::class, UrlGenerator::class); + + $this->user_a = User::factory(['name' => 'a'])->create(); + $this->user_b = User::factory(['name' => 'b'])->create(); + $this->setExpects($this->auth, 'user', null, $this->user_a, $this->any()); + + $this->now = Carbon::now(); + $this->one_minute_ago = Carbon::now()->subMinute(); + $this->two_minutes_ago = Carbon::now()->subMinutes(2); + + $this->controller = $this->app->get(MessagesController::class); + $this->controller->setValidator(new Validator()); + } + + protected function assertArrayOrCollection($obj) + { + $this->assertTrue(gettype($obj) == 'array' || $obj instanceof Collection); + } + + protected function createMessage(User $from, User $to, string $text, Carbon $at): Message + { + Message::unguard(); // unguard temporarily to save custom creation dates. + $msg = new Message([ + 'user_id' => $from->id, + 'receiver_id' => $to->id, + 'text' => $text, + 'created_at' => $at, + 'updated_at' => $at, + ]); + $msg->save(); + Message::reguard(); + + return $msg; + } +} diff --git a/tests/Unit/Models/User/UserTest.php b/tests/Unit/Models/User/UserTest.php index 664b1332..c2365078 100644 --- a/tests/Unit/Models/User/UserTest.php +++ b/tests/Unit/Models/User/UserTest.php @@ -4,6 +4,7 @@ namespace Engelsystem\Test\Unit\Models\User; use Carbon\Carbon; use DMS\PHPUnitExtensions\ArraySubset\ArraySubsetAsserts; +use Engelsystem\Config\Config; use Engelsystem\Models\BaseModel; use Engelsystem\Models\News; use Engelsystem\Models\NewsComment; @@ -276,4 +277,45 @@ class UserTest extends ModelTest $this->assertContains($question1->id, $answers); $this->assertContains($question2->id, $answers); } + + /** + * @testdox nameWithPronoun: pronounsDeactivated -> returnNameOnly + */ + public function testNameWithPronounPronounsDeactivatedReturnNameOnly() + { + $user_with_pronoun = User::factory(['name' => 'x']) + ->has(PersonalData::factory(['pronoun' => 'X']))->create(); + $user_without_pronoun = User::factory(['name' => 'y'])->create(); + + $this->assertEquals('x', $user_with_pronoun->nameWithPronoun()); + $this->assertEquals('y', $user_without_pronoun->nameWithPronoun()); + } + + /** + * @testdox nameWithPronoun: pronounsActivated -> returnNameAndPronoun + */ + public function testNameWithPronounPronounsActivatedReturnNameAndPronoun() + { + config(['enable_pronoun' => true]); + + $user_with_pronoun = User::factory(['name' => 'x']) + ->has(PersonalData::factory(['pronoun' => 'X']))->create(); + $user_without_pronoun = User::factory(['name' => 'y'])->create(); + + $this->assertEquals('x (X)', $user_with_pronoun->nameWithPronoun()); + $this->assertEquals('y', $user_without_pronoun->nameWithPronoun()); + } + + /** + * Prepare test + */ + protected function setUp(): void + { + parent::setUp(); + + // config needed for checking if pronouns are activated. + $config = new Config(); + $this->app->instance('config', $config); + $this->app->instance(Config::class, $config); + } }