From c9d3ff9c202e6e992f3cfb5132593c6b52371160 Mon Sep 17 00:00:00 2001 From: Allie Crevier Date: Wed, 18 Nov 2020 14:52:59 -0800 Subject: [PATCH] ensure no empty seen requests --- securedrop_client/api_jobs/seen.py | 6 +- securedrop_client/gui/widgets.py | 61 ++----- securedrop_client/logic.py | 50 +++++- tests/api_jobs/test_seen.py | 58 ++++++- tests/gui/test_widgets.py | 171 +++++--------------- tests/test_logic.py | 246 ++++++++++++++++++++++++++++- 6 files changed, 397 insertions(+), 195 deletions(-) diff --git a/securedrop_client/api_jobs/seen.py b/securedrop_client/api_jobs/seen.py index 3147575f5..8a33d530e 100644 --- a/securedrop_client/api_jobs/seen.py +++ b/securedrop_client/api_jobs/seen.py @@ -17,6 +17,10 @@ def call_api(self, api_client: API, session: Session) -> None: """ Override ApiJob. - Mark files, messages, and replies as seen + Mark files, messages, and replies as seen. Do not make the request if there are no items to + be marked as seen. """ + if not self.files and not self.messages and not self.replies: + return + api_client.seen(self.files, self.messages, self.replies) diff --git a/securedrop_client/gui/widgets.py b/securedrop_client/gui/widgets.py index 5a45b742d..a20641aec 100644 --- a/securedrop_client/gui/widgets.py +++ b/securedrop_client/gui/widgets.py @@ -641,9 +641,10 @@ def on_source_changed(self): self.controller.session.refresh(source) - # If logged in, mark source as seen - if self.controller.authenticated: - self.source_list.mark_seen.emit(source.uuid) + # Immediately show the selected source as seen in the UI and then make a request to mark + # source as seen. + self.source_list.source_selected.emit(source.uuid) + self.controller.mark_seen(source) # Get or create the SourceConversationWrapper if source.uuid in self.source_conversations: @@ -800,7 +801,7 @@ class SourceList(QListWidget): NUM_SOURCES_TO_ADD_AT_A_TIME = 32 - mark_seen = pyqtSignal(str) + source_selected = pyqtSignal(str) def __init__(self): super().__init__() @@ -872,7 +873,9 @@ def update(self, sources: List[Source]) -> List[str]: # Add widgets for new sources for uuid in sources_to_add: - source_widget = SourceWidget(self.controller, sources_to_add[uuid], self.mark_seen) + source_widget = SourceWidget( + self.controller, sources_to_add[uuid], self.source_selected + ) source_item = SourceListWidgetItem(self) source_item.setSizeHint(source_widget.sizeHint()) self.insertItem(0, source_item) @@ -907,7 +910,7 @@ def schedule_source_management(slice_size=slice_size): for source in sources_slice: try: source_uuid = source.uuid - source_widget = SourceWidget(self.controller, source, self.mark_seen) + source_widget = SourceWidget(self.controller, source, self.source_selected) source_item = SourceListWidgetItem(self) source_item.setSizeHint(source_widget.sizeHint()) self.insertItem(0, source_item) @@ -1003,14 +1006,14 @@ class SourceWidget(QWidget): SOURCE_CSS = load_css("source.css") - def __init__(self, controller: Controller, source: Source, mark_seen_signal: pyqtSignal): + def __init__(self, controller: Controller, source: Source, source_selected_signal: pyqtSignal): super().__init__() self.controller = controller self.controller.source_deleted.connect(self._on_source_deleted) self.controller.source_deletion_failed.connect(self._on_source_deletion_failed) self.controller.authentication_state.connect(self._on_authentication_changed) - mark_seen_signal.connect(self._on_mark_seen) + source_selected_signal.connect(self._on_source_selected) # Store source self.source = source @@ -1171,53 +1174,19 @@ def _on_authentication_changed(self, authenticated: bool) -> None: self.update_styles() @pyqtSlot(str) - def _on_mark_seen(self, source_uuid: str): + def _on_source_selected(self, selected_source_uuid: str): """ - Immediately show the source widget as having been seen and tell the controller to make a - seen API request to mark all files, messages, and replies as unseen by the current user as - seen. + Show widget as having been seen. """ - if self.source_uuid != source_uuid: + if self.source_uuid != selected_source_uuid: return - # Avoid marking as seen when switching to offline mode (this is an edge case since - # we do not emit the mark_seen signal from the SourceList if not authenticated) - if not self.controller.authenticated_user: + if self.seen: return - else: - journalist_id = self.controller.authenticated_user.id - # immediately update styles to mark as seen self.seen = True self.update_styles() - # Prepare the lists of uuids to mark as seen by the current user. Continue to process the - # next item if the source conversation item has already been seen by the current user or if - # it no longer exists. - try: - files = [] # type: List[str] - messages = [] # type: List[str] - replies = [] # type: List[str] - source_items = self.source.collection - for item in source_items: - try: - if item.seen_by(journalist_id): - continue - - if isinstance(item, File): - files.append(item.uuid) - elif isinstance(item, Message): - messages.append(item.uuid) - elif isinstance(item, Reply): - replies.append(item.uuid) - except sqlalchemy.exc.InvalidRequestError as e: - logger.debug(e) - continue - - self.controller.mark_seen(files, messages, replies) - except sqlalchemy.exc.InvalidRequestError as e: - logger.debug(e) - @pyqtSlot(str) def _on_source_deleted(self, source_uuid: str): if self.source_uuid == source_uuid: diff --git a/securedrop_client/logic.py b/securedrop_client/logic.py index dacb4df8c..09ee1d52e 100644 --- a/securedrop_client/logic.py +++ b/securedrop_client/logic.py @@ -27,6 +27,7 @@ import arrow import sdclientapi +import sqlalchemy.orm.exc from PyQt5.QtCore import QObject, QProcess, Qt, QThread, QTimer, pyqtSignal from sdclientapi import RequestTimeoutError, ServerConnectionError from sqlalchemy.orm.session import sessionmaker @@ -613,11 +614,50 @@ def update_sources(self): sources = list(storage.get_local_sources(self.session)) self.gui.show_sources(sources) - def mark_seen(self, files: List[str], messages: List[str], replies: List[str]): - job = SeenJob(files, messages, replies) - job.success_signal.connect(self.on_seen_success, type=Qt.QueuedConnection) - job.failure_signal.connect(self.on_seen_failure, type=Qt.QueuedConnection) - self.add_job.emit(job) + def mark_seen(self, source: db.Source) -> None: + """ + Mark all unseen conversation items of the supplied source as seen by the current + authenticated user. + """ + try: + # If user is logged out then just return + if not self.authenticated_user: + return + + # Prepare the lists of uuids to mark as seen by the current user. Continue to process + # the next item if the source conversation item has already been seen by the current + # user or if it no longer exists (individual conversation items can be deleted via the + # web journalist interface). + current_user_id = self.authenticated_user.id + files = [] # type: List[str] + messages = [] # type: List[str] + replies = [] # type: List[str] + source_items = source.collection + for item in source_items: + try: + if item.seen_by(current_user_id): + continue + + if isinstance(item, db.File): + files.append(item.uuid) + elif isinstance(item, db.Message): + messages.append(item.uuid) + elif isinstance(item, db.Reply): + replies.append(item.uuid) + except sqlalchemy.exc.InvalidRequestError as e: + logger.debug(e) + continue + + # If there's nothing to be marked as seen, just return. + if not files and not messages and not replies: + return + + job = SeenJob(files, messages, replies) + job.success_signal.connect(self.on_seen_success, type=Qt.QueuedConnection) + job.failure_signal.connect(self.on_seen_failure, type=Qt.QueuedConnection) + self.add_job.emit(job) + except sqlalchemy.exc.InvalidRequestError as e: + logger.debug(e) def on_seen_success(self) -> None: pass diff --git a/tests/api_jobs/test_seen.py b/tests/api_jobs/test_seen.py index 595e201ae..ab687f5de 100644 --- a/tests/api_jobs/test_seen.py +++ b/tests/api_jobs/test_seen.py @@ -2,21 +2,65 @@ from tests import factory -def test_seen(homedir, mocker, session, source): +def test_seen(homedir, mocker, session): """ - Check if we call add_star method if a source is not stared. + Check that the job makes the seen api request with the expected items. """ - file = factory.File(id=1, source=source["source"]) - message = factory.Message(id=2, source=source["source"]) - reply = factory.Reply(source=factory.Source()) + api_client = mocker.MagicMock() + file = factory.File() + message = factory.Message() + reply = factory.Reply() session.add(file) session.add(message) session.add(reply) + job = SeenJob([file.uuid], [message.uuid], [reply.uuid]) + job.call_api(api_client, session) + + api_client.seen.assert_called_once_with([file.uuid], [message.uuid], [reply.uuid]) + + +def test_seen_skips_making_request_if_no_items_to_mark_seen(homedir, mocker, session, source): + """ + Check that the job does not make the seen api request if there are no items to mark as seen. + """ + api_client = mocker.MagicMock() + + job = SeenJob([], [], []) + job.call_api(api_client, session) + + api_client.seen.assert_not_called() + + +def test_seen_with_file_only(homedir, mocker, session, source): + api_client = mocker.MagicMock() + file = factory.File() + session.add(file) + + job = SeenJob([file.uuid], [], []) + job.call_api(api_client, session) + + api_client.seen.assert_called_once_with([file.uuid], [], []) + + +def test_seen_with_message_only(homedir, mocker, session, source): + api_client = mocker.MagicMock() + message = factory.Message() + session.add(message) + + job = SeenJob([], [message.uuid], []) + job.call_api(api_client, session) + + api_client.seen.assert_called_once_with([], [message.uuid], []) + + +def test_seen_with_reply_only(homedir, mocker, session, source): api_client = mocker.MagicMock() + reply = factory.Reply() + session.add(reply) - job = SeenJob([file], [message], [reply]) + job = SeenJob([], [], [reply.uuid]) job.call_api(api_client, session) - api_client.seen.assert_called_once_with([file], [message], [reply]) + api_client.seen.assert_called_once_with([], [], [reply.uuid]) diff --git a/tests/gui/test_widgets.py b/tests/gui/test_widgets.py index 7ef8fa1ae..2080eb7e7 100644 --- a/tests/gui/test_widgets.py +++ b/tests/gui/test_widgets.py @@ -3,7 +3,6 @@ """ import random from datetime import datetime -from typing import Type from unittest.mock import Mock, patch import arrow @@ -710,18 +709,21 @@ def test_MainView_on_source_changed_updates_conversation_view(mocker, session): Test that the source collection is displayed in the conversation view. """ mv = MainView(None) - mv.source_list = mocker.MagicMock() + # mv.source_list = mocker.MagicMock() mv.controller = mocker.MagicMock(is_authenticated=True) - s = factory.Source() - session.add(s) - f = factory.File(source=s, filename="0-mock-doc.gpg") - session.add(f) - m = factory.Message(source=s, filename="0-mock-msg.gpg") - session.add(m) - r = factory.Reply(source=s, filename="0-mock-reply.gpg") - session.add(r) + source = factory.Source() + session.add(source) + file = factory.File(source=source, filename="0-mock-doc.gpg") + message = factory.Message(source=source, filename="0-mock-msg.gpg") + reply = factory.Reply(source=source, filename="0-mock-reply.gpg") + session.add(file) + session.add(message) + session.add(reply) session.commit() - mv.source_list.get_selected_source = mocker.MagicMock(return_value=s) + source_selected = mocker.patch("securedrop_client.gui.widgets.SourceList.source_selected") + mocker.patch( + "securedrop_client.gui.widgets.SourceList.get_selected_source", return_value=source + ) add_message_fn = mocker.patch( "securedrop_client.gui.widgets.ConversationView.add_message", new=mocker.Mock() ) @@ -734,6 +736,8 @@ def test_MainView_on_source_changed_updates_conversation_view(mocker, session): mv.on_source_changed() + source_selected.emit.assert_called_once_with(source.uuid) + mv.controller.mark_seen.assert_called_once_with(source) assert add_message_fn.call_count == 1 assert add_reply_fn.call_count == 1 assert add_file_fn.call_count == 1 @@ -746,8 +750,8 @@ def test_MainView_on_source_changed_SourceConversationWrapper_is_preserved(mocke first time, and then it should persist. """ mv = MainView(None) - mv.source_list = mocker.MagicMock() mv.set_conversation = mocker.MagicMock() + source_selected = mocker.patch("securedrop_client.gui.widgets.SourceList.source_selected") mv.controller = mocker.MagicMock(is_authenticated=True) source = factory.Source() source2 = factory.Source() @@ -760,30 +764,40 @@ def test_MainView_on_source_changed_SourceConversationWrapper_is_preserved(mocke ) # We expect on the first call, SourceConversationWrapper.__init__ should be called. - mv.source_list.get_selected_source = mocker.MagicMock(return_value=source) + mocker.patch( + "securedrop_client.gui.widgets.SourceList.get_selected_source", return_value=source + ) mv.on_source_changed() assert mv.set_conversation.call_count == 1 assert source_conversation_init.call_count == 1 + source_selected.emit.assert_called_once_with(source.uuid) - # Reset mock call counts for next call of on_source_changed. + # Reset mocked objects for the next call of on_source_changed. source_conversation_init.reset_mock() mv.set_conversation.reset_mock() + source_selected.reset_mock() # Now click on another source (source2). Since this is the first time we have clicked # on source2, we expect on the first call, SourceConversationWrapper.__init__ should be # called. - mv.source_list.get_selected_source = mocker.MagicMock(return_value=source2) + mocker.patch( + "securedrop_client.gui.widgets.SourceList.get_selected_source", return_value=source2 + ) mv.on_source_changed() assert mv.set_conversation.call_count == 1 assert source_conversation_init.call_count == 1 + source_selected.emit.assert_called_once_with(source2.uuid) - # Reset mock call counts for next call of on_source_changed. + # Reset mocked objects for the next call of on_source_changed. source_conversation_init.reset_mock() mv.set_conversation.reset_mock() + source_selected.reset_mock() # But if we click back (call on_source_changed again) to the source, # its SourceConversationWrapper should _not_ be recreated. - mv.source_list.get_selected_source = mocker.MagicMock(return_value=source) + mocker.patch( + "securedrop_client.gui.widgets.SourceList.get_selected_source", return_value=source + ) conversation_wrapper = mv.source_conversations[source.uuid] conversation_wrapper.conversation_view = mocker.MagicMock() conversation_wrapper.conversation_view.update_conversation = mocker.MagicMock() @@ -795,6 +809,7 @@ def test_MainView_on_source_changed_SourceConversationWrapper_is_preserved(mocke # Conversation should be redrawn even for existing source (bug #467). assert conversation_wrapper.conversation_view.update_conversation.call_count == 1 assert source_conversation_init.call_count == 0 + source_selected.emit.assert_called_once_with(source.uuid) def test_MainView_set_conversation(mocker): @@ -1418,16 +1433,12 @@ def test_SourceWidget__on_authentication_changed(mocker): assert not sw.seen -def test_SourceWidget__on_mark_seen(mocker, session): +def test_SourceWidget__on_source_selected(mocker, session): """ - Ensure that: - - * The source widget is immediately marked as seen. - * All source conversation items that have not been seen by the current user are marked as seen. + Ensure that source widget is marked as seen. """ controller = mocker.MagicMock() controller.authenticated_user = factory.User(id=1) - controller.mark_seen = mocker.MagicMock() source = factory.Source() unseen_file = factory.File(source=source) @@ -1478,138 +1489,38 @@ def test_SourceWidget__on_mark_seen(mocker, session): sw.seen = False sw.update_styles = mocker.MagicMock() - sw._on_mark_seen(source.uuid) + sw._on_source_selected(source.uuid) sw.update_styles.assert_called_once_with() assert sw.seen - controller.mark_seen.assert_called_once_with( - [unseen_file.uuid, unseen_file_for_current_user.uuid], - [unseen_message.uuid, unseen_message_for_current_user.uuid], - [unseen_reply.uuid, unseen_reply_for_current_user.uuid], - ) -def test_SourceWidget__on_mark_seen_skips_op_if_uuid_does_not_match(mocker): +def test_SourceWidget__on_source_selected_skips_op_if_uuid_does_not_match(mocker): """ - Ensure the source widget is immediately marked as seen and that all unseen source conversation - items are passed to the controller to be marked as seen. + Ensure the source widget is unchanged if uuid does not match the selected source. """ controller = mocker.MagicMock() - controller.mark_seen = mocker.MagicMock() source = factory.Source() sw = SourceWidget(controller, source, mocker.MagicMock()) sw.seen = False sw.update_styles = mocker.MagicMock() - sw._on_mark_seen("some-other-uuid") + sw._on_source_selected("some-other-uuid") - controller.mark_seen.assert_not_called() sw.update_styles.assert_not_called() assert not sw.seen -def test_SourceWidget__on_mark_seen_skips_op_if_user_offline(mocker): - """ - Ensure the source widget is immediately marked as seen and that all unseen source conversation - items are passed to the controller to be marked as seen. - """ +def test_SourceWidget__on_source_selected_skips_op_if_already_seen(mocker): controller = mocker.MagicMock() - controller.authenticated_user = None - controller.mark_seen = mocker.MagicMock() source = factory.Source() sw = SourceWidget(controller, source, mocker.MagicMock()) - sw.seen = False + sw.seen = True sw.update_styles = mocker.MagicMock() - sw._on_mark_seen(source.uuid) + sw._on_source_selected(source.uuid) - controller.mark_seen.assert_not_called() sw.update_styles.assert_not_called() - assert not sw.seen # Seen will get switched to True in the authentication change handler - - -class DeletedFile(Mock): - def __class__(self): - return Type(db.File) - - def seen_by(self, journalist_id): - raise sqlalchemy.exc.InvalidRequestError() - - -class SourceWithDeletedFile(Mock): - @property - def collection(self): - deleted_file = DeletedFile() - return [deleted_file] - - -def test_SourceWidget__on_mark_seen_does_not_raise_InvalidRequestError_if_item_deleted(mocker): - """ - If a source item no longer exists in the local data store, ensure we do not raise an exception. - """ - mocker.patch("securedrop_client.gui.widgets.isinstance", return_value=False) - source = SourceWithDeletedFile() - source.seen = mocker.MagicMock() - source.uuid = mocker.MagicMock() - source.last_updated = mocker.MagicMock() - source.is_starred = mocker.MagicMock() - mocker.patch("securedrop_client.gui.widgets.SourceWidget.update") - controller = mocker.MagicMock() - controller.mark_seen = mocker.MagicMock() - sw = SourceWidget(controller, source, mocker.MagicMock()) - sw.seen = False - sw.update_styles = mocker.MagicMock() - debug_logger = mocker.patch("securedrop_client.gui.widgets.logger.debug") - - sw._on_mark_seen(source.uuid) - - sw.update_styles.assert_called_once_with() - assert sw.seen - controller.mark_seen.assert_called_once_with([], [], []) - assert debug_logger.call_count == 1 - - -class DeletedSourceWhenAccessingCollection(Mock): - @property - def collection(self): - raise sqlalchemy.exc.InvalidRequestError() - - @property - def uuid(self): - return "DeletedSourceWhenAccessingCollection_uuid" - - @property - def seen(self): - return False - - @property - def last_updated(self): - return datetime.now() - - @property - def is_starred(self): - return True - - -def test_SourceWidget__on_mark_seen_does_not_raise_InvalidRequestError_if_source_deleted(mocker): - """ - If a source item no longer exists in the local data store, ensure we do not raise an exception. - """ - source = DeletedSourceWhenAccessingCollection() - mocker.patch("securedrop_client.gui.widgets.SourceWidget.update") - controller = mocker.MagicMock() - controller.mark_seen = mocker.MagicMock() - sw = SourceWidget(controller, source, mocker.MagicMock()) - sw.seen = False - sw.update_styles = mocker.MagicMock() - debug_logger = mocker.patch("securedrop_client.gui.widgets.logger.debug") - - sw._on_mark_seen(source.uuid) - - sw.update_styles.assert_called_once_with() - assert sw.seen - controller.mark_seen.assert_not_called() - assert debug_logger.call_count == 1 def test_SourceWidget_update_attachment_icon(mocker): diff --git a/tests/test_logic.py b/tests/test_logic.py index 7fab92619..ac24ccb82 100644 --- a/tests/test_logic.py +++ b/tests/test_logic.py @@ -5,10 +5,12 @@ import datetime import logging import os -from unittest.mock import call +from typing import Type +from unittest.mock import Mock, call import arrow import pytest +import sqlalchemy.orm.exc from PyQt5.QtCore import Qt from sdclientapi import RequestTimeoutError, ServerConnectionError @@ -610,12 +612,14 @@ def test_Controller_update_sources(homedir, config, mocker): mock_gui.show_sources.assert_called_once_with(source_list) -def test_Controller_mark_seen(homedir, config, source, mocker, session, session_maker): +def test_Controller_mark_seen(homedir, config, mocker, session, session_maker): co = Controller("http://localhost", mocker.MagicMock(), session_maker, homedir) + co.authenticated_user = factory.User() co.add_job = mocker.MagicMock() - file = factory.File(id=1, source=source["source"]) - message = factory.Message(id=2, source=source["source"]) - reply = factory.Reply(source=factory.Source()) + source = factory.Source() + file = factory.File(source=source) + message = factory.Message(source=source) + reply = factory.Reply(source=source) session.add(file) session.add(message) session.add(reply) @@ -625,13 +629,243 @@ def test_Controller_mark_seen(homedir, config, source, mocker, session, session_ job.failure_signal = mocker.MagicMock() mocker.patch("securedrop_client.logic.SeenJob", return_value=job) - co.mark_seen([file], [message], [reply]) + co.mark_seen(source) co.add_job.emit.assert_called_once_with(job) job.success_signal.connect.assert_called_once_with(co.on_seen_success, type=Qt.QueuedConnection) job.failure_signal.connect.assert_called_once_with(co.on_seen_failure, type=Qt.QueuedConnection) +def test_Controller_mark_seen_with_unseen_item_of_each_type( + homedir, config, mocker, session, session_maker +): + """ + Ensure that all source conversation items that have not been seen by the current user are marked + as seen. + """ + controller = Controller("http://localhost", mocker.MagicMock(), session_maker, homedir) + controller.authenticated_user = factory.User(id=1) + source = factory.Source() + + unseen_file = factory.File(source=source) + unseen_message = factory.Message(source=source) + unseen_reply = factory.Reply(source=source) + + session.add(unseen_file) + session.add(unseen_message) + session.add(unseen_reply) + + seen_file = factory.File(source=source) + seen_message = factory.Message(source=source) + seen_reply = factory.Reply(source=source) + session.add(seen_file) + session.add(seen_message) + session.add(seen_reply) + + unseen_file_for_current_user = factory.File(source=source) + unseen_message_for_current_user = factory.Message(source=source) + unseen_reply_for_current_user = factory.Reply(source=source) + session.add(unseen_file_for_current_user) + session.add(unseen_message_for_current_user) + session.add(unseen_reply_for_current_user) + + draft_reply_from_current_user = factory.DraftReply( + source=source, journalist_id=controller.authenticated_user.id + ) + draft_reply_from_another_user = factory.DraftReply(source=source, journalist_id=666) + session.add(draft_reply_from_current_user) + session.add(draft_reply_from_another_user) + + session.commit() + + session.add(db.SeenFile(file_id=seen_file.id, journalist_id=controller.authenticated_user.id)) + session.add( + db.SeenMessage(message_id=seen_message.id, journalist_id=controller.authenticated_user.id) + ) + session.add( + db.SeenReply(reply_id=seen_reply.id, journalist_id=controller.authenticated_user.id) + ) + session.add(db.SeenFile(file_id=unseen_file_for_current_user.id, journalist_id=666)) + session.add(db.SeenMessage(message_id=unseen_message_for_current_user.id, journalist_id=666)) + session.add(db.SeenReply(reply_id=unseen_reply_for_current_user.id, journalist_id=666)) + + session.commit() + + job = mocker.patch("securedrop_client.logic.SeenJob") + + controller.mark_seen(source) + + job.assert_called_once_with( + [unseen_file.uuid, unseen_file_for_current_user.uuid], + [unseen_message.uuid, unseen_message_for_current_user.uuid], + [unseen_reply.uuid, unseen_reply_for_current_user.uuid], + ) + + +def test_Controller_mark_seen_with_unseen_file_only( + homedir, config, mocker, session, session_maker +): + co = Controller("http://localhost", mocker.MagicMock(), session_maker, homedir) + co.authenticated_user = factory.User() + co.add_job = mocker.MagicMock() + source = factory.Source() + file = factory.File(source=source, uuid="file-uuid-1") + session.add(file) + + job = mocker.patch("securedrop_client.logic.SeenJob") + + co.mark_seen(source) + + job.assert_called_once_with(["file-uuid-1"], [], []) + + +def test_Controller_mark_seen_with_unseen_message_only( + homedir, config, mocker, session, session_maker +): + co = Controller("http://localhost", mocker.MagicMock(), session_maker, homedir) + co.authenticated_user = factory.User() + co.add_job = mocker.MagicMock() + source = factory.Source() + message = factory.Message(source=source, uuid="msg-uuid-1") + session.add(message) + + job = mocker.patch("securedrop_client.logic.SeenJob") + + co.mark_seen(source) + + job.assert_called_once_with([], ["msg-uuid-1"], []) + + +def test_Controller_mark_seen_with_unseen_reply_only( + homedir, config, mocker, session, session_maker +): + co = Controller("http://localhost", mocker.MagicMock(), session_maker, homedir) + co.authenticated_user = factory.User() + co.add_job = mocker.MagicMock() + source = factory.Source() + reply = factory.Reply(source=source, uuid="reply-uuid-1") + session.add(reply) + + job = mocker.patch("securedrop_client.logic.SeenJob") + + co.mark_seen(source) + + job.assert_called_once_with([], [], ["reply-uuid-1"]) + + +def test_Controller_mark_seen_skips_if_no_unseen_items( + homedir, config, mocker, session, session_maker +): + co = Controller("http://localhost", mocker.MagicMock(), session_maker, homedir) + co.authenticated_user = factory.User() + co.add_job = mocker.MagicMock() + + job = mocker.MagicMock() + job.success_signal = mocker.MagicMock() + job.failure_signal = mocker.MagicMock() + mocker.patch("securedrop_client.logic.SeenJob", return_value=job) + + co.mark_seen(factory.Source()) + + co.add_job.emit.assert_not_called() + job.success_signal.connect.assert_not_called() + job.failure_signal.connect.assert_not_called() + + +def test_Controller_mark_seen_skips_op_if_user_offline( + homedir, config, mocker, session, session_maker +): + co = Controller("http://localhost", mocker.MagicMock(), session_maker, homedir) + co.authenticated_user = None + co.add_job = mocker.MagicMock() + source = factory.Source() + file = factory.File(source=source) + message = factory.Message(source=source) + reply = factory.Reply(source=factory.Source()) + session.add(file) + session.add(message) + session.add(reply) + + job = mocker.MagicMock() + job.success_signal = mocker.MagicMock() + job.failure_signal = mocker.MagicMock() + mocker.patch("securedrop_client.logic.SeenJob", return_value=job) + + co.mark_seen(source) + + co.add_job.emit.assert_not_called() + job.success_signal.connect.assert_not_called() + job.failure_signal.connect.assert_not_called() + + +class DeletedFile(Mock): + def __class__(self): + return Type(db.File) + + def seen_by(self, journalist_id): + raise sqlalchemy.exc.InvalidRequestError() + + +class SourceWithDeletedFile(Mock): + @property + def collection(self): + deleted_file = DeletedFile() + return [deleted_file] + + +def test_Controller_mark_seen_does_not_raise_InvalidRequestError_if_item_deleted( + homedir, config, mocker, session, session_maker +): + """ + If a source item no longer exists in the local data store, ensure we do not raise an exception. + """ + mocker.patch("securedrop_client.logic.isinstance", return_value=True) + debug_logger = mocker.patch("securedrop_client.logic.logger.debug") + co = Controller("http://localhost", mocker.MagicMock(), session_maker, homedir) + co.authenticated_user = factory.User() + + co.mark_seen(SourceWithDeletedFile()) + + assert debug_logger.call_count == 1 + + +class DeletedSourceWhenAccessingCollection(Mock): + @property + def collection(self): + raise sqlalchemy.exc.InvalidRequestError() + + @property + def uuid(self): + return "DeletedSourceWhenAccessingCollection_uuid" + + @property + def seen(self): + return False + + @property + def last_updated(self): + return datetime.now() + + @property + def is_starred(self): + return True + + +def test_Controller_mark_seen_does_not_raise_InvalidRequestError_if_source_deleted( + homedir, config, mocker, session, session_maker +): + """ + If a source item no longer exists in the local data store, ensure we do not raise an exception. + """ + debug_logger = mocker.patch("securedrop_client.logic.logger.debug") + co = Controller("http://localhost", mocker.MagicMock(), session_maker, homedir) + co.authenticated_user = factory.User() + + co.mark_seen(DeletedSourceWhenAccessingCollection()) + + assert debug_logger.call_count == 1 + + def test_Controller_on_seen_success(homedir, mocker, session_maker): co = Controller("http://localhost", mocker.MagicMock(), session_maker, homedir) co.on_seen_success()