diff --git a/build-requirements.txt b/build-requirements.txt index fc41b39b1e..4529c32840 100644 --- a/build-requirements.txt +++ b/build-requirements.txt @@ -1,15 +1,15 @@ -alembic==1.0.2 --hash=sha256:14024bd47f71d8b51920721dcd63248d07d370fbd0f6afa9bec67b9edaf71f36 -arrow==0.12.1 --hash=sha256:5ef4a593615dc61ed85e62070b1bd27c71f7266233f0f9f385b651370e8c6760 -certifi==2018.10.15 --hash=sha256:a5471c55b011bd45d6155f5c3629310c1d2f1e1a5a899b7e438a223343de583d -chardet==3.0.4 --hash=sha256:9f178988ca4c86e8a319b51aac1185b6fe5192328eb5a163c286f4bf50b7b3d8 -idna==2.7 --hash=sha256:954e65e127d0433a352981f43f291a438423d5b385ebf643c70fd740e0634111 -mako==1.0.7 --hash=sha256:87ee3f74ba3ea544e683a5a22e7e34f4d1cf3ad34414b5f3858becf00facf1d6 -markupsafe==1.0 --hash=sha256:6a7078a2fb4406458d6ae3579e4eb01a9bdc0a9a0686a28fa50c19a039e3fcb8 --hash=sha256:3c9bf8fb4c3cf7dd11fd465132156d4c3cddb926d39bdbd0f0bf5920fd8009a4 -pathlib2==2.3.2 --hash=sha256:8e276e2bf50a9a06c36e20f03b050e59b63dfe0678e37164333deb87af03b6ad -python-dateutil==2.7.5 --hash=sha256:56f285e7fad54cde3e31dc68a31a861543bfee5ada9278da8e85ec20a8f72912 -python-editor==1.0.3 --hash=sha256:44fc57a6db6e04c7922c37a04d0a86d0024a4f0f06245b6c57638cb322176202 -requests==2.20.0 --hash=sha256:2a539dd6af40a611f3b8eb3f99d3567781352ece1698b2fab42bf4c2218705b5 -securedrop-sdk==0.0.12 --hash=sha256:274beb38dccd91988b45517e6479863f80bb31e00953695e61dd8103509f2337 -six==1.11.0 --hash=sha256:4663c7a1dbed033cfb294f2d534bd6151c0698dc12ecabb4eaa3cb041d758528 -sqlalchemy==1.3.3 --hash=sha256:bd030ff97e7a4f3aa34aafa6b62898c7de6999784c8b4828b3e8b31cf69dae9c --hash=sha256:680b2ae6a728941c9fe661c85f2309a69408e7ec8ed8fa39d03e07595259b75b -urllib3==1.24.3 --hash=sha256:028309393606e28e640e2031edd27eb969c94f9364b0871912608aaa8e66c96e +alembic==1.0.2 --hash=sha256:99cc931e11dbef6e41e9376f18be62fc90fe4be9c541eac1b30a3455b3d655f3 +arrow==0.12.1 --hash=sha256:fc8c8e0b587d00f38986bc161f4496e000acea033fe2ce25f4f5bffa9ae53a7c +certifi==2018.10.15 --hash=sha256:173b19dd31ca7faa50d1fcc0eaf30f5e32e8e99e17d8c7fd4cfc8bc8d94e18a6 +chardet==3.0.4 --hash=sha256:f5632e583a4f61f1e16d0cc98127d241fb11c3c6ddfddee159307d4215186837 +idna==2.7 --hash=sha256:491f674364ba3232ed1eb4c1eb7407887f62cef6c300aad7df6e01acd88ffb25 +mako==1.0.7 --hash=sha256:614c22fe1a5b0a3f46f6c5c43ff2e6795e4e784328d559ec9dc49db0f06b3a75 +markupsafe==1.0 --hash=sha256:c6b726d2e9d6300a044cf6a37627f10994268d6ac39464bc0d725126609311a5 +pathlib2==2.3.2 --hash=sha256:460e67b14d0574b0529a0017b1eb05d10d9722681e303fec7077c2a628de60c1 +python-dateutil==2.7.5 --hash=sha256:f6eb9c17acd5a6954e1a5f2f999a41de3e7e25b6bc41baf6344bd053ec25ceeb +python-editor==1.0.3 --hash=sha256:e47dcec4ea883853b8196fbd425b875d7ec791d4ede2e20cfc70b9a25365c65b +requests==2.20.0 --hash=sha256:d87b2085783d31d874ac7bc62660e287932aaee7059e80b41b76462eb18d35cc +securedrop-sdk==0.0.12 --hash=sha256:d05bb78652c8771e6aa1aefcd76ade1fef08c563d2641acbc5ac8e1d635e6a53 +six==1.11.0 --hash=sha256:aa4ad34049ddff178b533062797fd1db9f0038b7c5c2461a7cde2244300b9f3d +sqlalchemy==1.3.3 --hash=sha256:bfb4cd0df5802a01acd738a080a19e60ff4700e030d497de273807f9a8bd4a0a +urllib3==1.24.3 --hash=sha256:3d440cbb168e2c963d5099232bdb3f7390bf031b6270dad1bc79751698a1399a diff --git a/securedrop_client/api_jobs/updatestar.py b/securedrop_client/api_jobs/updatestar.py index ee82eb6ff7..0bb3ae9d3f 100644 --- a/securedrop_client/api_jobs/updatestar.py +++ b/securedrop_client/api_jobs/updatestar.py @@ -1,6 +1,8 @@ import logging import sdclientapi +from typing import Tuple + from sdclientapi import API from sqlalchemy.orm.session import Session @@ -15,7 +17,7 @@ def __init__(self, source_uuid: str, star_status: bool) -> None: self.source_uuid = source_uuid self.star_status = star_status - def call_api(self, api_client: API, session: Session) -> str: + def call_api(self, api_client: API, session: Session) -> Tuple[str, bool]: ''' Override ApiJob. @@ -33,7 +35,9 @@ def call_api(self, api_client: API, session: Session) -> str: else: api_client.add_star(source_sdk_object) - return self.source_uuid + # Identify the source and *new* state of the star so the UI can be + # updated. + return self.source_uuid, not self.star_status except Exception as e: error_message = "Failed to update star on source {uuid} due to {exception}".format( uuid=self.source_uuid, exception=repr(e)) diff --git a/securedrop_client/gui/widgets.py b/securedrop_client/gui/widgets.py index d4fe5bfa90..d1ec1525c3 100644 --- a/securedrop_client/gui/widgets.py +++ b/securedrop_client/gui/widgets.py @@ -37,7 +37,7 @@ from securedrop_client.export import ExportStatus, ExportError from securedrop_client.gui import SecureQLabel, SvgLabel, SvgPushButton, SvgToggleButton from securedrop_client.logic import Controller -from securedrop_client.resources import load_icon, load_image +from securedrop_client.resources import load_icon, load_image, load_movie from securedrop_client.utils import humanize_filesize logger = logging.getLogger(__name__) @@ -995,7 +995,7 @@ def __init__(self, source: Source): summary_layout.setSpacing(0) self.name = QLabel() self.name.setObjectName('source_name') - self.preview = QLabel() + self.preview = SecureQLabel() self.preview.setObjectName('preview') self.preview.setFixedSize(QSize(self.PREVIEW_WIDTH, self.PREVIEW_HEIGHT)) self.preview.setWordWrap(True) @@ -1044,7 +1044,7 @@ def update(self): """ Updates the displayed values with the current values from self.source. """ - self.timestamp.setText(arrow.get(self.source.last_updated).format('DD MMM')) + self.timestamp.setText(_(arrow.get(self.source.last_updated).format('DD MMM'))) self.name.setText(self.source.journalist_designation) if self.source.collection: msg = str(self.source.collection[-1]) @@ -1107,7 +1107,7 @@ def on_toggle(self): """ Tell the controller to make an API call to update the source's starred field. """ - self.controller.update_star(self.source) + self.controller.update_star(self.source, self.on_update) def on_toggle_offline(self): """ @@ -1121,6 +1121,16 @@ def on_toggle_offline(self): if self.source.is_starred: self.set_icon(on='star_on.svg', off='star_on.svg') + def on_update(self, result): + """ + The result is a uuid for the source and boolean flag for the new state + of the star. + """ + enabled = result[1] + self.source.is_starred = enabled + self.controller.update_sources() + self.setChecked(enabled) + class DeleteSourceMessageBox: """Use this to display operation details and confirm user choice.""" @@ -1790,7 +1800,7 @@ class FileWidget(QWidget): } QPushButton#export_print { border: none; - padding: 8px; + padding: 0px 8px; font-family: 'Source Sans Pro'; font-weight: 500; font-size: 13px; @@ -1811,7 +1821,7 @@ class FileWidget(QWidget): padding-right: 8px; font-family: 'Source Sans Pro'; font-weight: 700; - font-size: 14px; + font-size: 13px; color: #2a319d; } QLabel#no_file_name { @@ -1888,6 +1898,7 @@ def __init__( self.download_button.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) self.download_button.setIcon(load_icon('download_file.svg')) self.download_button.setFont(file_buttons_font) + self.download_animation = load_movie("download_animation.gif") self.export_button = QPushButton(_('EXPORT')) self.export_button.setObjectName('export_print') self.export_button.setFont(file_buttons_font) @@ -2008,9 +2019,29 @@ def _on_left_click(self): # Open the already downloaded file. self.controller.on_file_open(self.file.uuid) else: + if self.controller.api: + # Indicate in downloading state... but only after 0.3 seconds (i.e. + # this is taking a noticable amount of time to complete). + QTimer.singleShot(300, self.start_button_animation) # Download the file. self.controller.on_submission_download(File, self.file.uuid) + def start_button_animation(self): + """ + Update the download button to the animated "downloading" state. + """ + self.download_animation.frameChanged.connect(self.set_button_animation_frame) + self.download_animation.start() + self.download_button.setText(_(" DOWNLOADING ")) + self.download_button.setStyleSheet("color: #05a6fe") + + def set_button_animation_frame(self, frame_number): + """ + Sets the download button's icon to the current frame of the spinner + animation. + """ + self.download_button.setIcon(QIcon(self.download_animation.currentPixmap())) + class PrintDialog(QDialog): @@ -2899,7 +2930,7 @@ class LastUpdatedLabel(QLabel): ''' def __init__(self, last_updated): - super().__init__(_(_('{}').format(arrow.get(last_updated).humanize()))) + super().__init__(last_updated) # Set css id self.setObjectName('conversation-title-date') @@ -2949,7 +2980,7 @@ def __init__(self, source, controller): header_layout.setContentsMargins( self.MARGIN_LEFT, self.VERTICAL_MARGIN, self.MARGIN_RIGHT, self.VERTICAL_MARGIN) title = TitleLabel(self.source.journalist_designation) - updated = LastUpdatedLabel(self.source.last_updated) + updated = LastUpdatedLabel(_(arrow.get(self.source.last_updated).format('DD MMM'))) menu = SourceMenuButton(self.source, self.controller) header_layout.addWidget(title, alignment=Qt.AlignLeft) header_layout.addStretch() diff --git a/securedrop_client/logic.py b/securedrop_client/logic.py index 39030b7655..6b52325ff2 100644 --- a/securedrop_client/logic.py +++ b/securedrop_client/logic.py @@ -472,7 +472,6 @@ def on_update_star_success(self, result) -> None: After we star a source, we should sync the API such that the local database is updated. """ self.gui.clear_error_status() # remove any permanent error status message - self.sync_api() # Syncing the API also updates the source list UI def on_update_star_failure(self, result: UpdateStarJobException) -> None: """ @@ -482,7 +481,7 @@ def on_update_star_failure(self, result: UpdateStarJobException) -> None: error = _('Failed to update star.') self.gui.update_error_status(error) - def update_star(self, source_db_object): + def update_star(self, source_db_object, callback): """ Star or unstar. The callback here is the API sync as we first make sure that we apply the change to the server, and then update locally. @@ -493,6 +492,7 @@ def update_star(self, source_db_object): job = UpdateStarJob(source_db_object.uuid, source_db_object.is_starred) job.success_signal.connect(self.on_update_star_success, type=Qt.QueuedConnection) + job.success_signal.connect(callback, type=Qt.QueuedConnection) job.failure_signal.connect(self.on_update_star_failure, type=Qt.QueuedConnection) self.api_job_queue.enqueue(job) @@ -752,8 +752,11 @@ def on_delete_source_success(self, result) -> None: """ Handler for when a source deletion succeeds. """ + # Delete the local version of the source. + storage.delete_local_source_by_uuid(self.session, result) self.gui.clear_error_status() # remove any permanent error status message - self.sync_api() + # Update the sources UI. + self.update_sources() def on_delete_source_failure(self, result: Exception) -> None: logging.info("failed to delete source at server") diff --git a/securedrop_client/resources/__init__.py b/securedrop_client/resources/__init__.py index f073cd56a8..fb76b4cef6 100644 --- a/securedrop_client/resources/__init__.py +++ b/securedrop_client/resources/__init__.py @@ -20,7 +20,7 @@ import os from pkg_resources import resource_filename, resource_string -from PyQt5.QtGui import QPixmap, QIcon, QFontDatabase +from PyQt5.QtGui import QPixmap, QIcon, QFontDatabase, QMovie from PyQt5.QtSvg import QSvgWidget from PyQt5.QtCore import QDir @@ -133,3 +133,10 @@ def load_css(name: str) -> str: Return the contents of the referenced CSS file in the resources. """ return resource_string(__name__, "css/" + name).decode('utf-8') + + +def load_movie(name: str) -> str: + """ + Return a GIF animation to use in the UI. + """ + return QMovie(path(name)) diff --git a/securedrop_client/resources/images/download_active.svg b/securedrop_client/resources/images/download_active.svg new file mode 100644 index 0000000000..f9b7545416 --- /dev/null +++ b/securedrop_client/resources/images/download_active.svg @@ -0,0 +1,11 @@ + + + + Fill 1 + Created with Sketch. + + + + + + diff --git a/securedrop_client/resources/images/download_animation.gif b/securedrop_client/resources/images/download_animation.gif new file mode 100644 index 0000000000..d0d596d24f Binary files /dev/null and b/securedrop_client/resources/images/download_animation.gif differ diff --git a/securedrop_client/storage.py b/securedrop_client/storage.py index 95aae30198..172991bb0f 100644 --- a/securedrop_client/storage.py +++ b/securedrop_client/storage.py @@ -47,6 +47,17 @@ def get_local_sources(session: Session) -> List[Source]: return session.query(Source).all() +def delete_local_source_by_uuid(session: Session, uuid: str) -> None: + """ + Delete the source with the referenced UUID. + """ + source = session.query(Source).filter_by(uuid=uuid).one_or_none() + if source: + session.delete(source) + session.commit() + logger.info("Deleted source with UUID {} from local database.".format(uuid)) + + def get_local_messages(session: Session) -> List[Message]: """ Return all submission objects from the local database. diff --git a/tests/gui/test_widgets.py b/tests/gui/test_widgets.py index 8db1dc3338..165641c775 100644 --- a/tests/gui/test_widgets.py +++ b/tests/gui/test_widgets.py @@ -16,7 +16,7 @@ DeleteSourceMessageBox, DeleteSourceAction, SourceMenu, TopPane, LeftPane, RefreshButton, \ ErrorStatusBar, ActivityStatusBar, UserProfile, UserButton, UserMenu, LoginButton, \ ReplyBoxWidget, ReplyTextEdit, SourceConversationWrapper, StarToggleButton, LoginOfflineLink, \ - LoginErrorBar, EmptyConversationView, ExportDialog, PrintDialog, PasswordEdit + LoginErrorBar, EmptyConversationView, ExportDialog, PrintDialog, PasswordEdit, SecureQLabel from tests import factory @@ -800,6 +800,24 @@ def test_SourceWidget_delete_source_when_user_chooses_cancel(mocker, session, so sw.controller.delete_source.assert_not_called() +def test_SourceWidget_uses_SecureQLabel(mocker): + """ + Ensure the source widget preview uses SecureQLabel and is not injectable + """ + source = mocker.MagicMock() + source.journalist_designation = "Testy McTestface" + source.collection = [factory.Message(content="a" * 121), ] + sw = SourceWidget(source) + + sw.update() + assert isinstance(sw.preview, SecureQLabel) + + sw.preview.setTextFormat = mocker.MagicMock() + sw.preview.setText("bad text") + sw.update() + sw.preview.setTextFormat.assert_called_with(Qt.PlainText) + + def test_StarToggleButton_init_source_starred(mocker): source = factory.Source() source.is_starred = True @@ -895,7 +913,7 @@ def test_StarToggleButton_on_toggle(mocker): stb.on_toggle() - stb.controller.update_star.assert_called_once_with(source) + stb.controller.update_star.assert_called_once_with(source, stb.on_update) assert stb.isCheckable() is True @@ -929,6 +947,22 @@ def test_StarToggleButton_on_toggle_offline_when_checked(mocker): set_icon_fn.assert_called_with(on='star_on.svg', off='star_on.svg') +def test_StarToggleButton_on_update(mocker): + """ + Ensure the on_update callback updates the state of the source and UI + element to the current "enabled" state. + """ + source = mocker.MagicMock() + source.is_starred = True + stb = StarToggleButton(source) + stb.setChecked = mocker.MagicMock() + stb.controller = mocker.MagicMock() + stb.on_update(("uuid", False)) + assert source.is_starred is False + stb.controller.update_sources.assert_called_once_with() + stb.setChecked.assert_called_once_with(False) + + def test_LoginDialog_setup(mocker, i18n): """ The LoginView is correctly initialised. @@ -1387,13 +1421,40 @@ def test_FileWidget_on_left_click_download(mocker, session, source): mock_controller = mocker.MagicMock(get_file=mock_get_file) fw = FileWidget(file_.uuid, mock_controller, mock_signal, 0) + fw.download_button = mocker.MagicMock() mock_get_file.assert_called_once_with(file_.uuid) mock_get_file.reset_mock() - fw._on_left_click() + mock_timer = mocker.MagicMock() + + with mocker.patch("securedrop_client.gui.widgets.QTimer", mock_timer): + fw._on_left_click() mock_get_file.assert_called_once_with(file_.uuid) mock_controller.on_submission_download.assert_called_once_with( db.File, file_.uuid) + mock_timer.singleShot.assert_called_once_with(300, fw.start_button_animation) + + +def test_FileWidget_start_button_animation(mocker, session, source): + """ + Ensure widget state is updated when this method is called. + """ + mock_signal = mocker.MagicMock() # not important for this test + + file_ = factory.File(source=source['source'], + is_downloaded=False, + is_decrypted=None) + session.add(file_) + session.commit() + mock_get_file = mocker.MagicMock(return_value=file_) + mock_controller = mocker.MagicMock(get_file=mock_get_file) + fw = FileWidget(file_.uuid, mock_controller, mock_signal, 0) + fw.download_button = mocker.MagicMock() + fw.start_button_animation() + # Check indicators of activity have been updated. + assert fw.download_button.setIcon.call_count == 1 + fw.download_button.setText.assert_called_once_with(" DOWNLOADING ") + fw.download_button.setStyleSheet.assert_called_once_with("color: #05a6fe") def test_FileWidget_on_left_click_open(mocker, session, source): @@ -1414,6 +1475,28 @@ def test_FileWidget_on_left_click_open(mocker, session, source): fw.controller.on_file_open.assert_called_once_with(file_.uuid) +def test_FileWidget_set_button_animation_frame(mocker, session, source): + """ + Left click on download when file is not downloaded should trigger + a download. + """ + mock_signal = mocker.MagicMock() # not important for this test + + file_ = factory.File(source=source['source'], + is_downloaded=False, + is_decrypted=None) + session.add(file_) + session.commit() + + mock_get_file = mocker.MagicMock(return_value=file_) + mock_controller = mocker.MagicMock(get_file=mock_get_file) + + fw = FileWidget(file_.uuid, mock_controller, mock_signal, 0) + fw.download_button = mocker.MagicMock() + fw.set_button_animation_frame(1) + assert fw.download_button.setIcon.call_count == 1 + + def test_FileWidget_update(mocker, session, source): """ The update method should show/hide widgets if file is downloaded diff --git a/tests/test_logic.py b/tests/test_logic.py index 78d6d46c9b..be68625dae 100644 --- a/tests/test_logic.py +++ b/tests/test_logic.py @@ -529,9 +529,10 @@ def test_Controller_update_star_not_logged_in(homedir, config, mocker, session_m mock_gui = mocker.MagicMock() co = Controller('http://localhost', mock_gui, session_maker, homedir) source_db_object = mocker.MagicMock() + mock_callback = mocker.MagicMock() co.on_action_requiring_login = mocker.MagicMock() co.api = None - co.update_star(source_db_object) + co.update_star(source_db_object, mock_callback) co.on_action_requiring_login.assert_called_with() @@ -547,7 +548,7 @@ def test_Controller_on_update_star_success(homedir, config, mocker, session_make co.call_reset = mocker.MagicMock() co.sync_api = mocker.MagicMock() co.on_update_star_success(result) - co.sync_api.assert_called_once_with() + assert mock_gui.clear_error_status.called def test_Controller_on_update_star_failed(homedir, config, mocker, session_maker): @@ -1270,10 +1271,12 @@ def test_Controller_on_delete_source_success(homedir, config, mocker, session_ma Using the `config` fixture to ensure the config is written to disk. ''' mock_gui = mocker.MagicMock() + storage = mocker.patch('securedrop_client.logic.storage') co = Controller('http://localhost', mock_gui, session_maker, homedir) - co.sync_api = mocker.MagicMock() - co.on_delete_source_success(True) - co.sync_api.assert_called_with() + co.update_sources = mocker.MagicMock() + co.on_delete_source_success("uuid") + storage.delete_local_source_by_uuid.assert_called_once_with(co.session, "uuid") + assert co.update_sources.call_count == 1 def test_Controller_on_delete_source_failure(homedir, config, mocker, session_maker): @@ -1520,7 +1523,9 @@ def test_Controller_call_update_star_success(homedir, config, mocker, session_ma session.add(source) session.commit() - co.update_star(source) + mock_callback = mocker.MagicMock() + + co.update_star(source, mock_callback) mock_job_cls.assert_called_once_with( source.uuid, @@ -1528,8 +1533,12 @@ def test_Controller_call_update_star_success(homedir, config, mocker, session_ma ) mock_queue.enqueue.assert_called_once_with(mock_job) - mock_success_signal.connect.assert_called_once_with( - co.on_update_star_success, type=Qt.QueuedConnection) + assert mock_success_signal.connect.call_count == 2 + cal = mock_success_signal.connect.call_args_list + assert cal[0][0][0] == co.on_update_star_success + assert cal[0][1]['type'] == Qt.QueuedConnection + assert cal[1][0][0] == mock_callback + assert cal[1][1]['type'] == Qt.QueuedConnection mock_failure_signal.connect.assert_called_once_with( co.on_update_star_failure, type=Qt.QueuedConnection) diff --git a/tests/test_resources.py b/tests/test_resources.py index 8f6e3780b7..44ba9b782d 100644 --- a/tests/test_resources.py +++ b/tests/test_resources.py @@ -2,7 +2,7 @@ Tests for the resources sub-module. """ import securedrop_client.resources -from PyQt5.QtGui import QIcon, QPixmap +from PyQt5.QtGui import QIcon, QPixmap, QMovie from PyQt5.QtSvg import QSvgWidget from PyQt5.QtWidgets import QApplication @@ -63,3 +63,11 @@ def test_load_css(mocker): assert 'foo' == securedrop_client.resources.load_css('foo') rs.assert_called_once_with(securedrop_client.resources.__name__, 'css/foo') + + +def test_load_movie(): + """ + Check the load_movie function returns the expected QMovie object. + """ + result = securedrop_client.resources.load_movie('download_animation.gif') + assert isinstance(result, QMovie) diff --git a/tests/test_storage.py b/tests/test_storage.py index 55408a0e1b..125d6ca600 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -17,7 +17,7 @@ delete_single_submission_or_reply_on_disk, rename_file, get_local_files, find_new_files, \ source_exists, set_message_or_reply_content, mark_as_downloaded, mark_as_decrypted, get_file, \ get_message, get_reply, update_and_get_user, update_missing_files, mark_as_not_downloaded, \ - mark_all_pending_drafts_as_failed + mark_all_pending_drafts_as_failed, delete_local_source_by_uuid from securedrop_client import db from tests import factory @@ -72,6 +72,22 @@ def test_get_local_sources(mocker): mock_session.query.assert_called_once_with(securedrop_client.db.Source) +def test_delete_local_source_by_uuid(mocker): + """ + Delete the referenced source in the session. + """ + mock_session = mocker.MagicMock() + source = make_remote_source() + mock_session.query().filter_by().one_or_none.return_value = source + mock_session.query.reset_mock() + delete_local_source_by_uuid(mock_session, "uuid") + mock_session.query.assert_called_once_with(securedrop_client.db.Source) + mock_session.query().filter_by.assert_called_once_with(uuid="uuid") + assert mock_session.query().filter_by().one_or_none.call_count == 1 + mock_session.delete.assert_called_once_with(source) + mock_session.commit.assert_called_once_with() + + def test_get_local_messages(mocker): """ At this moment, just return all messages.