Archive snapshots of websites locally (#672)

* Add basic HTML snapshots

* Implement asset list

* Add snapshot creation tests

* Add deletion tests

* Show file size

* Remove snapshots

* Create new snapshots

* Switch to single-file

* CSS tweak

* Remove auto refresh

* Show delete link when there is no file yet

* Add current date to display name

* Add flag for snapshot support

* Add option for disabling automatic snapshots

* Make snapshots sharable

* Document image variants

* Update README.md

* Add migrations

* Fix tests
This commit is contained in:
Sascha Ißbrücker
2024-04-01 15:19:38 +02:00
committed by GitHub
parent db1906942a
commit 4280ab40c6
46 changed files with 1603 additions and 240 deletions

View File

@@ -1,6 +1,6 @@
import random
import logging
import datetime
from datetime import datetime
from typing import List
from bs4 import BeautifulSoup
@@ -10,7 +10,7 @@ from django.utils.crypto import get_random_string
from rest_framework import status
from rest_framework.test import APITestCase
from bookmarks.models import Bookmark, Tag
from bookmarks.models import Bookmark, BookmarkAsset, Tag
class BookmarkFactoryMixin:
@@ -133,6 +133,38 @@ class BookmarkFactoryMixin:
def get_numbered_bookmark(self, title: str):
return Bookmark.objects.get(title=title)
def setup_asset(
self,
bookmark: Bookmark,
date_created: datetime = None,
file: str = None,
file_size: int = None,
asset_type: str = BookmarkAsset.TYPE_SNAPSHOT,
content_type: str = "image/html",
display_name: str = None,
status: str = BookmarkAsset.STATUS_COMPLETE,
gzip: bool = False,
):
if date_created is None:
date_created = timezone.now()
if not file:
file = get_random_string(length=32)
if not display_name:
display_name = file
asset = BookmarkAsset(
bookmark=bookmark,
date_created=date_created,
file=file,
file_size=file_size,
asset_type=asset_type,
content_type=content_type,
display_name=display_name,
status=status,
gzip=gzip,
)
asset.save()
return asset
def setup_tag(self, user: User = None, name: str = ""):
if user is None:
user = self.get_or_create_test_user()

View File

@@ -0,0 +1,125 @@
import os
from django.conf import settings
from django.test import TestCase
from django.urls import reverse
from bookmarks.tests.helpers import (
BookmarkFactoryMixin,
)
class BookmarkAssetViewTestCase(TestCase, BookmarkFactoryMixin):
def setUp(self) -> None:
user = self.get_or_create_test_user()
self.client.force_login(user)
def tearDown(self):
temp_files = [
f for f in os.listdir(settings.LD_ASSET_FOLDER) if f.startswith("temp")
]
for temp_file in temp_files:
os.remove(os.path.join(settings.LD_ASSET_FOLDER, temp_file))
def setup_asset_file(self, filename):
if not os.path.exists(settings.LD_ASSET_FOLDER):
os.makedirs(settings.LD_ASSET_FOLDER)
filepath = os.path.join(settings.LD_ASSET_FOLDER, filename)
with open(filepath, "w") as f:
f.write("test")
def setup_asset_with_file(self, bookmark):
filename = f"temp_{bookmark.id}.html.gzip"
self.setup_asset_file(filename)
asset = self.setup_asset(bookmark=bookmark, file=filename)
return asset
def test_view_access(self):
# own bookmark
bookmark = self.setup_bookmark()
asset = self.setup_asset_with_file(bookmark)
response = self.client.get(reverse("bookmarks:assets.view", args=[asset.id]))
self.assertEqual(response.status_code, 200)
# other user's bookmark
other_user = self.setup_user()
bookmark = self.setup_bookmark(user=other_user)
asset = self.setup_asset_with_file(bookmark)
response = self.client.get(reverse("bookmarks:assets.view", args=[asset.id]))
self.assertEqual(response.status_code, 404)
# shared, sharing disabled
bookmark = self.setup_bookmark(user=other_user, shared=True)
asset = self.setup_asset_with_file(bookmark)
response = self.client.get(reverse("bookmarks:assets.view", args=[asset.id]))
self.assertEqual(response.status_code, 404)
# unshared, sharing enabled
profile = other_user.profile
profile.enable_sharing = True
profile.save()
bookmark = self.setup_bookmark(user=other_user, shared=False)
asset = self.setup_asset_with_file(bookmark)
response = self.client.get(reverse("bookmarks:assets.view", args=[asset.id]))
self.assertEqual(response.status_code, 404)
# shared, sharing enabled
bookmark = self.setup_bookmark(user=other_user, shared=True)
asset = self.setup_asset_with_file(bookmark)
response = self.client.get(reverse("bookmarks:assets.view", args=[asset.id]))
self.assertEqual(response.status_code, 200)
def test_view_access_guest_user(self):
self.client.logout()
# unshared, sharing disabled
bookmark = self.setup_bookmark()
asset = self.setup_asset_with_file(bookmark)
response = self.client.get(reverse("bookmarks:assets.view", args=[asset.id]))
self.assertEqual(response.status_code, 404)
# shared, sharing disabled
bookmark = self.setup_bookmark(shared=True)
asset = self.setup_asset_with_file(bookmark)
response = self.client.get(reverse("bookmarks:assets.view", args=[asset.id]))
self.assertEqual(response.status_code, 404)
# unshared, sharing enabled
profile = self.get_or_create_test_user().profile
profile.enable_sharing = True
profile.save()
bookmark = self.setup_bookmark(shared=False)
asset = self.setup_asset_with_file(bookmark)
response = self.client.get(reverse("bookmarks:assets.view", args=[asset.id]))
self.assertEqual(response.status_code, 404)
# shared, sharing enabled
bookmark = self.setup_bookmark(shared=True)
asset = self.setup_asset_with_file(bookmark)
response = self.client.get(reverse("bookmarks:assets.view", args=[asset.id]))
self.assertEqual(response.status_code, 404)
# unshared, public sharing enabled
profile.enable_public_sharing = True
profile.save()
bookmark = self.setup_bookmark(shared=False)
asset = self.setup_asset_with_file(bookmark)
response = self.client.get(reverse("bookmarks:assets.view", args=[asset.id]))
self.assertEqual(response.status_code, 404)
# shared, public sharing enabled
bookmark = self.setup_bookmark(shared=True)
asset = self.setup_asset_with_file(bookmark)
response = self.client.get(reverse("bookmarks:assets.view", args=[asset.id]))
self.assertEqual(response.status_code, 200)

View File

@@ -0,0 +1,89 @@
import os
from django.conf import settings
from django.test import TestCase
from bookmarks.tests.helpers import (
BookmarkFactoryMixin,
)
from bookmarks.services import bookmarks
class BookmarkAssetsTestCase(TestCase, BookmarkFactoryMixin):
def tearDown(self):
temp_files = [
f for f in os.listdir(settings.LD_ASSET_FOLDER) if f.startswith("temp")
]
for temp_file in temp_files:
os.remove(os.path.join(settings.LD_ASSET_FOLDER, temp_file))
def setup_asset_file(self, filename):
if not os.path.exists(settings.LD_ASSET_FOLDER):
os.makedirs(settings.LD_ASSET_FOLDER)
filepath = os.path.join(settings.LD_ASSET_FOLDER, filename)
with open(filepath, "w") as f:
f.write("test")
def setup_asset_with_file(self, bookmark):
filename = f"temp_{bookmark.id}.html.gzip"
self.setup_asset_file(filename)
asset = self.setup_asset(bookmark=bookmark, file=filename)
return asset
def test_delete_bookmark_deletes_asset_file(self):
bookmark = self.setup_bookmark()
asset = self.setup_asset_with_file(bookmark)
self.assertTrue(
os.path.exists(os.path.join(settings.LD_ASSET_FOLDER, asset.file))
)
bookmark.delete()
self.assertFalse(
os.path.exists(os.path.join(settings.LD_ASSET_FOLDER, asset.file))
)
def test_bulk_delete_bookmarks_deletes_asset_files(self):
bookmark1 = self.setup_bookmark()
asset1 = self.setup_asset_with_file(bookmark1)
bookmark2 = self.setup_bookmark()
asset2 = self.setup_asset_with_file(bookmark2)
bookmark3 = self.setup_bookmark()
asset3 = self.setup_asset_with_file(bookmark3)
self.assertTrue(
os.path.exists(os.path.join(settings.LD_ASSET_FOLDER, asset1.file))
)
self.assertTrue(
os.path.exists(os.path.join(settings.LD_ASSET_FOLDER, asset2.file))
)
self.assertTrue(
os.path.exists(os.path.join(settings.LD_ASSET_FOLDER, asset3.file))
)
bookmarks.delete_bookmarks(
[bookmark1.id, bookmark2.id, bookmark3.id], self.get_or_create_test_user()
)
self.assertFalse(
os.path.exists(os.path.join(settings.LD_ASSET_FOLDER, asset1.file))
)
self.assertFalse(
os.path.exists(os.path.join(settings.LD_ASSET_FOLDER, asset2.file))
)
self.assertFalse(
os.path.exists(os.path.join(settings.LD_ASSET_FOLDER, asset3.file))
)
def test_save_updates_file_size(self):
# File does not exist initially
bookmark = self.setup_bookmark()
asset = self.setup_asset(bookmark=bookmark, file="temp.html.gz")
self.assertIsNone(asset.file_size)
# Add file, save again
self.setup_asset_file(asset.file)
asset.save()
self.assertEqual(asset.file_size, 4)
# Create asset with initial file
asset = self.setup_asset(bookmark=bookmark, file="temp.html.gz")
self.assertEqual(asset.file_size, 4)

View File

@@ -1,8 +1,11 @@
from django.test import TestCase
from unittest.mock import patch
from django.test import TestCase, override_settings
from django.urls import reverse
from django.utils import formats
from bookmarks.models import UserProfile
from bookmarks.models import BookmarkAsset, UserProfile
from bookmarks.services import tasks
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
@@ -11,8 +14,15 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
user = self.get_or_create_test_user()
self.client.force_login(user)
def get_view_name(self):
return "bookmarks:details_modal"
def get_base_url(self, bookmark):
return reverse("bookmarks:details_modal", args=[bookmark.id])
return reverse(self.get_view_name(), args=[bookmark.id])
def get_details_form(self, soup, bookmark):
expected_url = reverse("bookmarks:details", args=[bookmark.id])
return soup.find("form", {"action": expected_url})
def get_details(self, bookmark, return_url=""):
url = self.get_base_url(bookmark)
@@ -35,43 +45,38 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
def find_weblink(self, soup, url):
return soup.find("a", {"class": "weblink", "href": url})
def test_access(self):
def find_asset(self, soup, asset):
return soup.find("div", {"data-asset-id": asset.id})
def details_route_access_test(self, view_name: str, shareable: bool):
# own bookmark
bookmark = self.setup_bookmark()
response = self.client.get(
reverse("bookmarks:details_modal", args=[bookmark.id])
)
response = self.client.get(reverse(view_name, args=[bookmark.id]))
self.assertEqual(response.status_code, 200)
# other user's bookmark
other_user = self.setup_user()
bookmark = self.setup_bookmark(user=other_user)
response = self.client.get(
reverse("bookmarks:details_modal", args=[bookmark.id])
)
response = self.client.get(reverse(view_name, args=[bookmark.id]))
self.assertEqual(response.status_code, 404)
# non-existent bookmark
response = self.client.get(reverse("bookmarks:details_modal", args=[9999]))
response = self.client.get(reverse(view_name, args=[9999]))
self.assertEqual(response.status_code, 404)
# guest user
self.client.logout()
response = self.client.get(
reverse("bookmarks:details_modal", args=[bookmark.id])
)
self.assertEqual(response.status_code, 404)
response = self.client.get(reverse(view_name, args=[bookmark.id]))
self.assertEqual(response.status_code, 404 if shareable else 302)
def test_access_with_sharing(self):
def details_route_sharing_access_test(self, view_name: str, shareable: bool):
# shared bookmark, sharing disabled
other_user = self.setup_user()
bookmark = self.setup_bookmark(shared=True, user=other_user)
response = self.client.get(
reverse("bookmarks:details_modal", args=[bookmark.id])
)
response = self.client.get(reverse(view_name, args=[bookmark.id]))
self.assertEqual(response.status_code, 404)
# shared bookmark, sharing enabled
@@ -79,26 +84,38 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
profile.enable_sharing = True
profile.save()
response = self.client.get(
reverse("bookmarks:details_modal", args=[bookmark.id])
)
self.assertEqual(response.status_code, 200)
response = self.client.get(reverse(view_name, args=[bookmark.id]))
self.assertEqual(response.status_code, 200 if shareable else 404)
# shared bookmark, guest user, no public sharing
self.client.logout()
response = self.client.get(
reverse("bookmarks:details_modal", args=[bookmark.id])
)
self.assertEqual(response.status_code, 404)
response = self.client.get(reverse(view_name, args=[bookmark.id]))
self.assertEqual(response.status_code, 404 if shareable else 302)
# shared bookmark, guest user, public sharing
profile.enable_public_sharing = True
profile.save()
response = self.client.get(
reverse("bookmarks:details_modal", args=[bookmark.id])
)
self.assertEqual(response.status_code, 200)
response = self.client.get(reverse(view_name, args=[bookmark.id]))
self.assertEqual(response.status_code, 200 if shareable else 302)
def test_access(self):
self.details_route_access_test(self.get_view_name(), True)
def test_access_with_sharing(self):
self.details_route_sharing_access_test(self.get_view_name(), True)
def test_form_partial_access(self):
# form partial is only used when submitting forms, which should be only
# accessible to the owner of the bookmark. As such assume it requires
# login.
self.details_route_access_test("bookmarks:partials.details_form", False)
def test_form_partial_access_with_sharing(self):
# form partial is only used when submitting forms, which should be only
# accessible to the owner of the bookmark. As such assume it requires
# login.
self.details_route_sharing_access_test("bookmarks:partials.details_form", False)
def test_displays_title(self):
# with title
@@ -246,9 +263,8 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
# renders form
bookmark = self.setup_bookmark()
soup = self.get_details(bookmark)
section = self.get_section(soup, "Status")
form = section.find("form")
form = self.get_details_form(soup, bookmark)
self.assertIsNotNone(form)
self.assertEqual(
form["action"], reverse("bookmarks:details", args=[bookmark.id])
@@ -312,30 +328,21 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
bookmark = self.setup_bookmark()
soup = self.get_details(bookmark)
section = self.find_section(soup, "Status")
form_action = reverse("bookmarks:details", args=[bookmark.id])
form = soup.find("form", {"action": form_action})
self.assertIsNotNone(section)
self.assertIsNotNone(form)
# other user's bookmark
other_user = self.setup_user(enable_sharing=True)
bookmark = self.setup_bookmark(user=other_user, shared=True)
soup = self.get_details(bookmark)
section = self.find_section(soup, "Status")
form_action = reverse("bookmarks:details", args=[bookmark.id])
form = soup.find("form", {"action": form_action})
self.assertIsNone(section)
self.assertIsNone(form)
# guest user
self.client.logout()
bookmark = self.setup_bookmark(user=other_user, shared=True)
soup = self.get_details(bookmark)
section = self.find_section(soup, "Status")
form_action = reverse("bookmarks:details", args=[bookmark.id])
form = soup.find("form", {"action": form_action})
self.assertIsNone(section)
self.assertIsNone(form)
def test_status_update(self):
bookmark = self.setup_bookmark()
@@ -560,3 +567,215 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
delete_button = soup.find("button", {"type": "submit", "name": "remove"})
self.assertIsNone(edit_link)
self.assertIsNone(delete_button)
def test_assets_visibility_no_snapshot_support(self):
bookmark = self.setup_bookmark()
soup = self.get_details(bookmark)
section = self.find_section(soup, "Files")
self.assertIsNone(section)
@override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_assets_visibility_with_snapshot_support(self):
bookmark = self.setup_bookmark()
soup = self.get_details(bookmark)
section = self.find_section(soup, "Files")
self.assertIsNotNone(section)
@override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_asset_list_visibility(self):
# no assets
bookmark = self.setup_bookmark()
soup = self.get_details(bookmark)
section = self.get_section(soup, "Files")
asset_list = section.find("div", {"class": "assets"})
self.assertIsNone(asset_list)
# with assets
bookmark = self.setup_bookmark()
self.setup_asset(bookmark)
soup = self.get_details(bookmark)
section = self.get_section(soup, "Files")
asset_list = section.find("div", {"class": "assets"})
self.assertIsNotNone(asset_list)
@override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_asset_list(self):
bookmark = self.setup_bookmark()
assets = [
self.setup_asset(bookmark),
self.setup_asset(bookmark),
self.setup_asset(bookmark),
]
soup = self.get_details(bookmark)
section = self.get_section(soup, "Files")
asset_list = section.find("div", {"class": "assets"})
for asset in assets:
asset_item = self.find_asset(asset_list, asset)
self.assertIsNotNone(asset_item)
asset_icon = asset_item.select_one(".asset-icon svg")
self.assertIsNotNone(asset_icon)
asset_text = asset_item.select_one(".asset-text span")
self.assertIsNotNone(asset_text)
self.assertIn(asset.display_name, asset_text.text)
view_url = reverse("bookmarks:assets.view", args=[asset.id])
view_link = asset_item.find("a", {"href": view_url})
self.assertIsNotNone(view_link)
@override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_asset_without_file(self):
bookmark = self.setup_bookmark()
asset = self.setup_asset(bookmark)
asset.file = ""
asset.save()
soup = self.get_details(bookmark)
asset_item = self.find_asset(soup, asset)
view_url = reverse("bookmarks:assets.view", args=[asset.id])
view_link = asset_item.find("a", {"href": view_url})
self.assertIsNone(view_link)
@override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_asset_status(self):
bookmark = self.setup_bookmark()
pending_asset = self.setup_asset(bookmark, status=BookmarkAsset.STATUS_PENDING)
failed_asset = self.setup_asset(bookmark, status=BookmarkAsset.STATUS_FAILURE)
soup = self.get_details(bookmark)
asset_item = self.find_asset(soup, pending_asset)
asset_text = asset_item.select_one(".asset-text span")
self.assertIn("(queued)", asset_text.text)
asset_item = self.find_asset(soup, failed_asset)
asset_text = asset_item.select_one(".asset-text span")
self.assertIn("(failed)", asset_text.text)
@override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_asset_file_size(self):
bookmark = self.setup_bookmark()
asset1 = self.setup_asset(bookmark, file_size=None)
asset2 = self.setup_asset(bookmark, file_size=54639)
asset3 = self.setup_asset(bookmark, file_size=11492020)
soup = self.get_details(bookmark)
asset_item = self.find_asset(soup, asset1)
asset_text = asset_item.select_one(".asset-text")
self.assertEqual(asset_text.text.strip(), asset1.display_name)
asset_item = self.find_asset(soup, asset2)
asset_text = asset_item.select_one(".asset-text")
self.assertIn("53.4\xa0KB", asset_text.text)
asset_item = self.find_asset(soup, asset3)
asset_text = asset_item.select_one(".asset-text")
self.assertIn("11.0\xa0MB", asset_text.text)
@override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_asset_actions_visibility(self):
bookmark = self.setup_bookmark()
# with file
asset = self.setup_asset(bookmark)
soup = self.get_details(bookmark)
asset_item = self.find_asset(soup, asset)
view_link = asset_item.find("a", string="View")
delete_button = asset_item.find(
"button", {"type": "submit", "name": "remove_asset"}
)
self.assertIsNotNone(view_link)
self.assertIsNotNone(delete_button)
# without file
asset.file = ""
asset.save()
soup = self.get_details(bookmark)
asset_item = self.find_asset(soup, asset)
view_link = asset_item.find("a", string="View")
delete_button = asset_item.find(
"button", {"type": "submit", "name": "remove_asset"}
)
self.assertIsNone(view_link)
self.assertIsNotNone(delete_button)
# shared bookmark
other_user = self.setup_user(enable_sharing=True, enable_public_sharing=True)
bookmark = self.setup_bookmark(shared=True, user=other_user)
asset = self.setup_asset(bookmark)
soup = self.get_details(bookmark)
asset_item = self.find_asset(soup, asset)
view_link = asset_item.find("a", string="View")
delete_button = asset_item.find(
"button", {"type": "submit", "name": "remove_asset"}
)
self.assertIsNotNone(view_link)
self.assertIsNone(delete_button)
# shared bookmark, guest user
self.client.logout()
soup = self.get_details(bookmark)
asset_item = self.find_asset(soup, asset)
view_link = asset_item.find("a", string="View")
delete_button = asset_item.find(
"button", {"type": "submit", "name": "remove_asset"}
)
self.assertIsNotNone(view_link)
self.assertIsNone(delete_button)
def test_remove_asset(self):
# remove asset
bookmark = self.setup_bookmark()
asset = self.setup_asset(bookmark)
response = self.client.post(
self.get_base_url(bookmark), {"remove_asset": asset.id}
)
self.assertEqual(response.status_code, 302)
self.assertFalse(BookmarkAsset.objects.filter(id=asset.id).exists())
# non-existent asset
response = self.client.post(self.get_base_url(bookmark), {"remove_asset": 9999})
self.assertEqual(response.status_code, 404)
# post without asset ID does not remove
asset = self.setup_asset(bookmark)
response = self.client.post(self.get_base_url(bookmark))
self.assertEqual(response.status_code, 302)
self.assertTrue(BookmarkAsset.objects.filter(id=asset.id).exists())
# guest user
asset = self.setup_asset(bookmark)
self.client.logout()
response = self.client.post(
self.get_base_url(bookmark), {"remove_asset": asset.id}
)
self.assertEqual(response.status_code, 404)
self.assertTrue(BookmarkAsset.objects.filter(id=asset.id).exists())
@override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_create_snapshot(self):
with patch.object(
tasks, "_create_html_snapshot_task"
) as mock_create_html_snapshot_task:
bookmark = self.setup_bookmark()
response = self.client.post(
self.get_base_url(bookmark), {"create_snapshot": ""}
)
self.assertEqual(response.status_code, 302)
mock_create_html_snapshot_task.assert_called_with(bookmark.id)
self.assertEqual(bookmark.bookmarkasset_set.count(), 1)

View File

@@ -1,8 +1,6 @@
from django.urls import reverse
from bookmarks.tests.test_bookmark_details_modal import BookmarkDetailsModalTestCase
class BookmarkDetailsViewTestCase(BookmarkDetailsModalTestCase):
def get_base_url(self, bookmark):
return reverse("bookmarks:details", args=[bookmark.id])
def get_view_name(self):
return "bookmarks:details"

View File

@@ -105,6 +105,24 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
mock_load_favicon.assert_called_once_with(self.user, bookmark)
def test_create_should_load_html_snapshot(self):
with patch.object(tasks, "create_html_snapshot") as mock_create_html_snapshot:
bookmark_data = Bookmark(url="https://example.com")
bookmark = create_bookmark(bookmark_data, "tag1,tag2", self.user)
mock_create_html_snapshot.assert_called_once_with(bookmark)
def test_create_should_not_load_html_snapshot_when_setting_is_disabled(self):
profile = self.get_or_create_test_user().profile
profile.enable_automatic_html_snapshots = False
profile.save()
with patch.object(tasks, "create_html_snapshot") as mock_create_html_snapshot:
bookmark_data = Bookmark(url="https://example.com")
create_bookmark(bookmark_data, "tag1,tag2", self.user)
mock_create_html_snapshot.assert_not_called()
def test_update_should_create_web_archive_snapshot_if_url_did_change(self):
with patch.object(
tasks, "create_web_archive_snapshot"
@@ -167,6 +185,14 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
mock_load_favicon.assert_called_once_with(self.user, bookmark)
def test_update_should_not_create_html_snapshot(self):
with patch.object(tasks, "create_html_snapshot") as mock_create_html_snapshot:
bookmark = self.setup_bookmark()
bookmark.title = "updated title"
update_bookmark(bookmark, "tag1,tag2", self.user)
mock_create_html_snapshot.assert_not_called()
def test_archive_bookmark(self):
bookmark = Bookmark(
url="https://example.com",

View File

@@ -1,18 +1,20 @@
import datetime
import os.path
from dataclasses import dataclass
from typing import Any
from unittest import mock
import waybackpy
from background_task.models import Task
from django.conf import settings
from django.contrib.auth.models import User
from django.test import TestCase, override_settings
from waybackpy.exceptions import WaybackError
import bookmarks.services.favicon_loader
import bookmarks.services.wayback
from bookmarks.models import UserProfile
from bookmarks.services import tasks
from bookmarks.models import BookmarkAsset, UserProfile
from bookmarks.services import tasks, singlefile
from bookmarks.tests.helpers import BookmarkFactoryMixin, disable_logging
@@ -626,3 +628,86 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
tasks.schedule_refresh_favicons(self.get_or_create_test_user())
self.assertEqual(Task.objects.count(), 0)
@override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_create_html_snapshot_should_create_pending_asset(self):
bookmark = self.setup_bookmark()
with mock.patch("bookmarks.services.monolith.create_snapshot"):
tasks.create_html_snapshot(bookmark)
self.assertEqual(BookmarkAsset.objects.count(), 1)
tasks.create_html_snapshot(bookmark)
self.assertEqual(BookmarkAsset.objects.count(), 2)
assets = BookmarkAsset.objects.filter(bookmark=bookmark)
for asset in assets:
self.assertEqual(asset.bookmark, bookmark)
self.assertEqual(asset.asset_type, BookmarkAsset.TYPE_SNAPSHOT)
self.assertEqual(asset.content_type, BookmarkAsset.CONTENT_TYPE_HTML)
self.assertIn("HTML snapshot", asset.display_name)
self.assertEqual(asset.status, BookmarkAsset.STATUS_PENDING)
@override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_create_html_snapshot_should_update_file_info(self):
bookmark = self.setup_bookmark(url="https://example.com")
with mock.patch("bookmarks.services.singlefile.create_snapshot") as mock_create:
tasks.create_html_snapshot(bookmark)
asset = BookmarkAsset.objects.get(bookmark=bookmark)
asset.date_created = datetime.datetime(2021, 1, 2, 3, 44, 55)
asset.save()
expected_filename = "snapshot_2021-01-02_034455_https___example.com.html.gz"
self.run_pending_task(tasks._create_html_snapshot_task)
mock_create.assert_called_once_with(
"https://example.com",
os.path.join(settings.LD_ASSET_FOLDER, expected_filename),
)
asset = BookmarkAsset.objects.get(bookmark=bookmark)
self.assertEqual(asset.status, BookmarkAsset.STATUS_COMPLETE)
self.assertEqual(asset.file, expected_filename)
self.assertTrue(asset.gzip)
@override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_create_html_snapshot_should_handle_error(self):
bookmark = self.setup_bookmark(url="https://example.com")
with mock.patch("bookmarks.services.singlefile.create_snapshot") as mock_create:
mock_create.side_effect = singlefile.SingeFileError("Error")
tasks.create_html_snapshot(bookmark)
self.run_pending_task(tasks._create_html_snapshot_task)
asset = BookmarkAsset.objects.get(bookmark=bookmark)
self.assertEqual(asset.status, BookmarkAsset.STATUS_FAILURE)
self.assertEqual(asset.file, "")
self.assertFalse(asset.gzip)
@override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_create_html_snapshot_should_handle_missing_bookmark(self):
with mock.patch("bookmarks.services.singlefile.create_snapshot") as mock_create:
tasks._create_html_snapshot_task(123)
self.run_pending_task(tasks._create_html_snapshot_task)
mock_create.assert_not_called()
@override_settings(LD_ENABLE_SNAPSHOTS=False)
def test_create_html_snapshot_should_not_run_when_single_file_is_disabled(
self,
):
bookmark = self.setup_bookmark()
tasks.create_html_snapshot(bookmark)
self.assertEqual(Task.objects.count(), 0)
@override_settings(LD_ENABLE_SNAPSHOTS=True, LD_DISABLE_BACKGROUND_TASKS=True)
def test_create_html_snapshot_should_not_run_when_background_tasks_are_disabled(
self,
):
bookmark = self.setup_bookmark()
tasks.create_html_snapshot(bookmark)
self.assertEqual(Task.objects.count(), 0)

View File

@@ -0,0 +1,44 @@
import gzip
import os
from unittest import mock
import subprocess
from django.test import TestCase
from bookmarks.services import monolith
class MonolithServiceTestCase(TestCase):
html_content = "<html><body><h1>Hello, World!</h1></body></html>"
html_filepath = "temp.html.gz"
temp_html_filepath = "temp.html.gz.tmp"
def tearDown(self):
if os.path.exists(self.html_filepath):
os.remove(self.html_filepath)
if os.path.exists(self.temp_html_filepath):
os.remove(self.temp_html_filepath)
def create_test_file(self, *args, **kwargs):
with open(self.temp_html_filepath, "w") as file:
file.write(self.html_content)
def test_create_snapshot(self):
with mock.patch("subprocess.run") as mock_run:
mock_run.side_effect = self.create_test_file
monolith.create_snapshot("http://example.com", self.html_filepath)
self.assertTrue(os.path.exists(self.html_filepath))
self.assertFalse(os.path.exists(self.temp_html_filepath))
with gzip.open(self.html_filepath, "rt") as file:
content = file.read()
self.assertEqual(content, self.html_content)
def test_create_snapshot_failure(self):
with mock.patch("subprocess.run") as mock_run:
mock_run.side_effect = subprocess.CalledProcessError(1, "command")
with self.assertRaises(monolith.MonolithError):
monolith.create_snapshot("http://example.com", self.html_filepath)

View File

@@ -31,6 +31,7 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
"enable_sharing": False,
"enable_public_sharing": False,
"enable_favicons": False,
"enable_automatic_html_snapshots": True,
"tag_search": UserProfile.TAG_SEARCH_STRICT,
"display_url": False,
"display_view_bookmark_action": True,
@@ -69,6 +70,7 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
"enable_sharing": True,
"enable_public_sharing": True,
"enable_favicons": True,
"enable_automatic_html_snapshots": False,
"tag_search": UserProfile.TAG_SEARCH_LAX,
"display_url": True,
"display_view_bookmark_action": False,
@@ -110,6 +112,10 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual(
self.user.profile.enable_favicons, form_data["enable_favicons"]
)
self.assertEqual(
self.user.profile.enable_automatic_html_snapshots,
form_data["enable_automatic_html_snapshots"],
)
self.assertEqual(self.user.profile.tag_search, form_data["tag_search"])
self.assertEqual(self.user.profile.display_url, form_data["display_url"])
self.assertEqual(
@@ -285,6 +291,35 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
count=0,
)
def test_automatic_html_snapshots_should_be_hidden_when_snapshots_not_supported(
self,
):
response = self.client.get(reverse("bookmarks:settings.general"))
html = response.content.decode()
self.assertInHTML(
"""
<input type="checkbox" name="enable_automatic_html_snapshots" id="id_enable_automatic_html_snapshots" checked="">
""",
html,
count=0,
)
@override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_automatic_html_snapshots_should_be_visible_when_snapshots_supported(
self,
):
response = self.client.get(reverse("bookmarks:settings.general"))
html = response.content.decode()
self.assertInHTML(
"""
<input type="checkbox" name="enable_automatic_html_snapshots" id="id_enable_automatic_html_snapshots" checked="">
""",
html,
count=1,
)
def test_about_shows_version_info(self):
response = self.client.get(reverse("bookmarks:settings.general"))
html = response.content.decode()

View File

@@ -0,0 +1,50 @@
import gzip
import os
from unittest import mock
import subprocess
from django.test import TestCase
from bookmarks.services import singlefile
class SingleFileServiceTestCase(TestCase):
html_content = "<html><body><h1>Hello, World!</h1></body></html>"
html_filepath = "temp.html.gz"
temp_html_filepath = "temp.html.gz.tmp"
def tearDown(self):
if os.path.exists(self.html_filepath):
os.remove(self.html_filepath)
if os.path.exists(self.temp_html_filepath):
os.remove(self.temp_html_filepath)
def create_test_file(self, *args, **kwargs):
with open(self.temp_html_filepath, "w") as file:
file.write(self.html_content)
def test_create_snapshot(self):
with mock.patch("subprocess.run") as mock_run:
mock_run.side_effect = self.create_test_file
singlefile.create_snapshot("http://example.com", self.html_filepath)
self.assertTrue(os.path.exists(self.html_filepath))
self.assertFalse(os.path.exists(self.temp_html_filepath))
with gzip.open(self.html_filepath, "rt") as file:
content = file.read()
self.assertEqual(content, self.html_content)
def test_create_snapshot_failure(self):
# subprocess fails - which it probably doesn't as single-file doesn't return exit codes
with mock.patch("subprocess.run") as mock_run:
mock_run.side_effect = subprocess.CalledProcessError(1, "command")
with self.assertRaises(singlefile.SingeFileError):
singlefile.create_snapshot("http://example.com", self.html_filepath)
# so also check that it raises error if output file isn't created
with mock.patch("subprocess.run") as mock_run:
with self.assertRaises(singlefile.SingeFileError):
singlefile.create_snapshot("http://example.com", self.html_filepath)