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 @@
+
+
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.