Files
linkding/bookmarks/tests/test_bookmark_index_view.py
Sascha Ißbrücker 1672dc0152 Add bundles for organizing bookmarks (#1097)
* add bundle model and query logic

* cleanup tests

* add basic form

* add success message

* Add form tests

* Add bundle list view

* fix edit view

* Add remove button

* Add basic preview logic

* Make pagination use absolute URLs

* Hide bookmark edits when rendering preview

* Render bookmark list in preview

* Reorder bundles

* Show bundles in bookmark view

* Make bookmark search respect selected bundle

* UI tweaks

* Fix bookmark scope

* Improve bundle preview

* Skip preview if form is submitted

* Show correct preview after invalid form submission

* Add option to hide bundles

* Merge new migrations

* Add tests for bundle menu

* Improve check for preview being removed
2025-06-19 16:47:29 +02:00

585 lines
21 KiB
Python

import urllib.parse
from django.contrib.auth.models import User
from django.test import TestCase
from django.urls import reverse
from bookmarks.models import BookmarkSearch, UserProfile
from bookmarks.tests.helpers import (
BookmarkFactoryMixin,
BookmarkListTestMixin,
TagCloudTestMixin,
)
class BookmarkIndexViewTestCase(
TestCase, BookmarkFactoryMixin, BookmarkListTestMixin, TagCloudTestMixin
):
def setUp(self) -> None:
user = self.get_or_create_test_user()
self.client.force_login(user)
def assertEditLink(self, response, url):
html = response.content.decode()
self.assertInHTML(
f"""
<a href="{url}">Edit</a>
""",
html,
)
def assertBulkActionForm(self, response, url: str):
soup = self.make_soup(response.content.decode())
form = soup.select_one("form.bookmark-actions")
self.assertIsNotNone(form)
self.assertEqual(form.attrs["action"], url)
def assertVisibleBundles(self, soup, bundles):
bundle_list = soup.select_one("ul.bundle-menu")
self.assertIsNotNone(bundle_list)
list_items = bundle_list.select("li.bundle-menu-item")
self.assertEqual(len(list_items), len(bundles))
for index, list_item in enumerate(list_items):
bundle = bundles[index]
link = list_item.select_one("a")
href = link.attrs["href"]
self.assertEqual(bundle.name, list_item.text.strip())
self.assertEqual(f"?bundle={bundle.id}", href)
def test_should_list_unarchived_and_user_owned_bookmarks(self):
other_user = User.objects.create_user(
"otheruser", "otheruser@example.com", "password123"
)
visible_bookmarks = self.setup_numbered_bookmarks(3)
invisible_bookmarks = [
self.setup_bookmark(is_archived=True),
self.setup_bookmark(user=other_user),
]
response = self.client.get(reverse("linkding:bookmarks.index"))
self.assertVisibleBookmarks(response, visible_bookmarks)
self.assertInvisibleBookmarks(response, invisible_bookmarks)
def test_should_list_bookmarks_matching_query(self):
visible_bookmarks = self.setup_numbered_bookmarks(3, prefix="foo")
invisible_bookmarks = self.setup_numbered_bookmarks(3, prefix="bar")
response = self.client.get(reverse("linkding:bookmarks.index") + "?q=foo")
self.assertVisibleBookmarks(response, visible_bookmarks)
self.assertInvisibleBookmarks(response, invisible_bookmarks)
def test_should_list_bookmarks_matching_bundle(self):
visible_bookmarks = self.setup_numbered_bookmarks(3, prefix="foo")
invisible_bookmarks = self.setup_numbered_bookmarks(3, prefix="bar")
bundle = self.setup_bundle(search="foo")
response = self.client.get(
reverse("linkding:bookmarks.index") + f"?bundle={bundle.id}"
)
self.assertVisibleBookmarks(response, visible_bookmarks)
self.assertInvisibleBookmarks(response, invisible_bookmarks)
def test_should_list_tags_for_unarchived_and_user_owned_bookmarks(self):
other_user = User.objects.create_user(
"otheruser", "otheruser@example.com", "password123"
)
visible_bookmarks = self.setup_numbered_bookmarks(3, with_tags=True)
archived_bookmarks = self.setup_numbered_bookmarks(
3, with_tags=True, archived=True, tag_prefix="archived"
)
other_user_bookmarks = self.setup_numbered_bookmarks(
3, with_tags=True, user=other_user, tag_prefix="otheruser"
)
visible_tags = self.get_tags_from_bookmarks(visible_bookmarks)
invisible_tags = self.get_tags_from_bookmarks(
archived_bookmarks + other_user_bookmarks
)
response = self.client.get(reverse("linkding:bookmarks.index"))
self.assertVisibleTags(response, visible_tags)
self.assertInvisibleTags(response, invisible_tags)
def test_should_list_tags_for_bookmarks_matching_query(self):
visible_bookmarks = self.setup_numbered_bookmarks(
3, with_tags=True, prefix="foo", tag_prefix="foo"
)
invisible_bookmarks = self.setup_numbered_bookmarks(
3, with_tags=True, prefix="bar", tag_prefix="bar"
)
visible_tags = self.get_tags_from_bookmarks(visible_bookmarks)
invisible_tags = self.get_tags_from_bookmarks(invisible_bookmarks)
response = self.client.get(reverse("linkding:bookmarks.index") + "?q=foo")
self.assertVisibleTags(response, visible_tags)
self.assertInvisibleTags(response, invisible_tags)
def test_should_list_tags_for_bookmarks_matching_bundle(self):
visible_bookmarks = self.setup_numbered_bookmarks(
3, with_tags=True, prefix="foo", tag_prefix="foo"
)
invisible_bookmarks = self.setup_numbered_bookmarks(
3, with_tags=True, prefix="bar", tag_prefix="bar"
)
visible_tags = self.get_tags_from_bookmarks(visible_bookmarks)
invisible_tags = self.get_tags_from_bookmarks(invisible_bookmarks)
bundle = self.setup_bundle(search="foo")
response = self.client.get(
reverse("linkding:bookmarks.index") + f"?bundle={bundle.id}"
)
self.assertVisibleTags(response, visible_tags)
self.assertInvisibleTags(response, invisible_tags)
def test_should_list_bookmarks_and_tags_for_search_preferences(self):
user_profile = self.user.profile
user_profile.search_preferences = {
"unread": BookmarkSearch.FILTER_UNREAD_YES,
}
user_profile.save()
unread_bookmarks = self.setup_numbered_bookmarks(
3, unread=True, with_tags=True, prefix="unread", tag_prefix="unread"
)
read_bookmarks = self.setup_numbered_bookmarks(
3, unread=False, with_tags=True, prefix="read", tag_prefix="read"
)
unread_tags = self.get_tags_from_bookmarks(unread_bookmarks)
read_tags = self.get_tags_from_bookmarks(read_bookmarks)
response = self.client.get(reverse("linkding:bookmarks.index"))
self.assertVisibleBookmarks(response, unread_bookmarks)
self.assertInvisibleBookmarks(response, read_bookmarks)
self.assertVisibleTags(response, unread_tags)
self.assertInvisibleTags(response, read_tags)
def test_should_display_selected_tags_from_query(self):
tags = [
self.setup_tag(),
self.setup_tag(),
self.setup_tag(),
self.setup_tag(),
self.setup_tag(),
]
self.setup_bookmark(tags=tags)
response = self.client.get(
reverse("linkding:bookmarks.index")
+ f"?q=%23{tags[0].name}+%23{tags[1].name.upper()}"
)
self.assertSelectedTags(response, [tags[0], tags[1]])
def test_should_not_display_search_terms_from_query_as_selected_tags_in_strict_mode(
self,
):
tags = [
self.setup_tag(),
self.setup_tag(),
self.setup_tag(),
self.setup_tag(),
self.setup_tag(),
]
self.setup_bookmark(title=tags[0].name, tags=tags)
response = self.client.get(
reverse("linkding:bookmarks.index")
+ f"?q={tags[0].name}+%23{tags[1].name.upper()}"
)
self.assertSelectedTags(response, [tags[1]])
def test_should_display_search_terms_from_query_as_selected_tags_in_lax_mode(self):
self.user.profile.tag_search = UserProfile.TAG_SEARCH_LAX
self.user.profile.save()
tags = [
self.setup_tag(),
self.setup_tag(),
self.setup_tag(),
self.setup_tag(),
self.setup_tag(),
]
self.setup_bookmark(tags=tags)
response = self.client.get(
reverse("linkding:bookmarks.index")
+ f"?q={tags[0].name}+%23{tags[1].name.upper()}"
)
self.assertSelectedTags(response, [tags[0], tags[1]])
def test_should_open_bookmarks_in_new_page_by_default(self):
visible_bookmarks = self.setup_numbered_bookmarks(3)
response = self.client.get(reverse("linkding:bookmarks.index"))
self.assertVisibleBookmarks(response, visible_bookmarks, "_blank")
def test_should_open_bookmarks_in_same_page_if_specified_in_user_profile(self):
user = self.get_or_create_test_user()
user.profile.bookmark_link_target = UserProfile.BOOKMARK_LINK_TARGET_SELF
user.profile.save()
visible_bookmarks = self.setup_numbered_bookmarks(3)
response = self.client.get(reverse("linkding:bookmarks.index"))
self.assertVisibleBookmarks(response, visible_bookmarks, "_self")
def test_edit_link_return_url_respects_search_options(self):
bookmark = self.setup_bookmark(title="foo")
edit_url = reverse("linkding:bookmarks.edit", args=[bookmark.id])
base_url = reverse("linkding:bookmarks.index")
# without query params
return_url = urllib.parse.quote(base_url)
url = f"{edit_url}?return_url={return_url}"
response = self.client.get(base_url)
self.assertEditLink(response, url)
# with query
url_params = "?q=foo"
return_url = urllib.parse.quote(base_url + url_params)
url = f"{edit_url}?return_url={return_url}"
response = self.client.get(base_url + url_params)
self.assertEditLink(response, url)
# with query and sort and page
url_params = "?q=foo&sort=title_asc&page=2"
return_url = urllib.parse.quote(base_url + url_params)
url = f"{edit_url}?return_url={return_url}"
response = self.client.get(base_url + url_params)
self.assertEditLink(response, url)
def test_bulk_edit_respects_search_options(self):
action_url = reverse("linkding:bookmarks.index.action")
base_url = reverse("linkding:bookmarks.index")
# without params
url = f"{action_url}"
response = self.client.get(base_url)
self.assertBulkActionForm(response, url)
# with query
url_params = "?q=foo"
url = f"{action_url}?q=foo"
response = self.client.get(base_url + url_params)
self.assertBulkActionForm(response, url)
# with query and sort
url_params = "?q=foo&sort=title_asc"
url = f"{action_url}?q=foo&sort=title_asc"
response = self.client.get(base_url + url_params)
self.assertBulkActionForm(response, url)
def test_allowed_bulk_actions(self):
url = reverse("linkding:bookmarks.index")
response = self.client.get(url)
html = response.content.decode()
self.assertInHTML(
f"""
<select name="bulk_action" class="form-select select-sm">
<option value="bulk_archive">Archive</option>
<option value="bulk_delete">Delete</option>
<option value="bulk_tag">Add tags</option>
<option value="bulk_untag">Remove tags</option>
<option value="bulk_read">Mark as read</option>
<option value="bulk_unread">Mark as unread</option>
<option value="bulk_refresh">Refresh from website</option>
</select>
""",
html,
)
def test_allowed_bulk_actions_with_sharing_enabled(self):
user_profile = self.user.profile
user_profile.enable_sharing = True
user_profile.save()
url = reverse("linkding:bookmarks.index")
response = self.client.get(url)
html = response.content.decode()
self.assertInHTML(
f"""
<select name="bulk_action" class="form-select select-sm">
<option value="bulk_archive">Archive</option>
<option value="bulk_delete">Delete</option>
<option value="bulk_tag">Add tags</option>
<option value="bulk_untag">Remove tags</option>
<option value="bulk_read">Mark as read</option>
<option value="bulk_unread">Mark as unread</option>
<option value="bulk_share">Share</option>
<option value="bulk_unshare">Unshare</option>
<option value="bulk_refresh">Refresh from website</option>
</select>
""",
html,
)
def test_apply_search_preferences(self):
# no params
response = self.client.post(reverse("linkding:bookmarks.index"))
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, reverse("linkding:bookmarks.index"))
# some params
response = self.client.post(
reverse("linkding:bookmarks.index"),
{
"q": "foo",
"sort": BookmarkSearch.SORT_TITLE_ASC,
},
)
self.assertEqual(response.status_code, 302)
self.assertEqual(
response.url, reverse("linkding:bookmarks.index") + "?q=foo&sort=title_asc"
)
# params with default value are removed
response = self.client.post(
reverse("linkding:bookmarks.index"),
{
"q": "foo",
"user": "",
"sort": BookmarkSearch.SORT_ADDED_DESC,
"shared": BookmarkSearch.FILTER_SHARED_OFF,
"unread": BookmarkSearch.FILTER_UNREAD_YES,
},
)
self.assertEqual(response.status_code, 302)
self.assertEqual(
response.url, reverse("linkding:bookmarks.index") + "?q=foo&unread=yes"
)
# page is removed
response = self.client.post(
reverse("linkding:bookmarks.index"),
{
"q": "foo",
"page": "2",
"sort": BookmarkSearch.SORT_TITLE_ASC,
},
)
self.assertEqual(response.status_code, 302)
self.assertEqual(
response.url, reverse("linkding:bookmarks.index") + "?q=foo&sort=title_asc"
)
def test_save_search_preferences(self):
user_profile = self.user.profile
# no params
self.client.post(
reverse("linkding:bookmarks.index"),
{
"save": "",
},
)
user_profile.refresh_from_db()
self.assertEqual(
user_profile.search_preferences,
{
"sort": BookmarkSearch.SORT_ADDED_DESC,
"shared": BookmarkSearch.FILTER_SHARED_OFF,
"unread": BookmarkSearch.FILTER_UNREAD_OFF,
},
)
# with param
self.client.post(
reverse("linkding:bookmarks.index"),
{
"save": "",
"sort": BookmarkSearch.SORT_TITLE_ASC,
},
)
user_profile.refresh_from_db()
self.assertEqual(
user_profile.search_preferences,
{
"sort": BookmarkSearch.SORT_TITLE_ASC,
"shared": BookmarkSearch.FILTER_SHARED_OFF,
"unread": BookmarkSearch.FILTER_UNREAD_OFF,
},
)
# add a param
self.client.post(
reverse("linkding:bookmarks.index"),
{
"save": "",
"sort": BookmarkSearch.SORT_TITLE_ASC,
"unread": BookmarkSearch.FILTER_UNREAD_YES,
},
)
user_profile.refresh_from_db()
self.assertEqual(
user_profile.search_preferences,
{
"sort": BookmarkSearch.SORT_TITLE_ASC,
"shared": BookmarkSearch.FILTER_SHARED_OFF,
"unread": BookmarkSearch.FILTER_UNREAD_YES,
},
)
# remove a param
self.client.post(
reverse("linkding:bookmarks.index"),
{
"save": "",
"unread": BookmarkSearch.FILTER_UNREAD_YES,
},
)
user_profile.refresh_from_db()
self.assertEqual(
user_profile.search_preferences,
{
"sort": BookmarkSearch.SORT_ADDED_DESC,
"shared": BookmarkSearch.FILTER_SHARED_OFF,
"unread": BookmarkSearch.FILTER_UNREAD_YES,
},
)
# ignores non-preferences
self.client.post(
reverse("linkding:bookmarks.index"),
{
"save": "",
"q": "foo",
"user": "john",
"page": "3",
"sort": BookmarkSearch.SORT_TITLE_ASC,
},
)
user_profile.refresh_from_db()
self.assertEqual(
user_profile.search_preferences,
{
"sort": BookmarkSearch.SORT_TITLE_ASC,
"shared": BookmarkSearch.FILTER_SHARED_OFF,
"unread": BookmarkSearch.FILTER_UNREAD_OFF,
},
)
def test_url_encode_bookmark_actions_url(self):
url = reverse("linkding:bookmarks.index") + "?q=%23foo"
response = self.client.get(url)
html = response.content.decode()
soup = self.make_soup(html)
actions_form = soup.select("form.bookmark-actions")[0]
self.assertEqual(
actions_form.attrs["action"],
"/bookmarks/action?q=%23foo",
)
def test_encode_search_params(self):
bookmark = self.setup_bookmark(description="alert('xss')")
url = reverse("linkding:bookmarks.index") + "?q=alert(%27xss%27)"
response = self.client.get(url)
self.assertNotContains(response, "alert('xss')")
self.assertContains(response, bookmark.url)
url = reverse("linkding:bookmarks.index") + "?sort=alert(%27xss%27)"
response = self.client.get(url)
self.assertNotContains(response, "alert('xss')")
url = reverse("linkding:bookmarks.index") + "?unread=alert(%27xss%27)"
response = self.client.get(url)
self.assertNotContains(response, "alert('xss')")
url = reverse("linkding:bookmarks.index") + "?shared=alert(%27xss%27)"
response = self.client.get(url)
self.assertNotContains(response, "alert('xss')")
url = reverse("linkding:bookmarks.index") + "?user=alert(%27xss%27)"
response = self.client.get(url)
self.assertNotContains(response, "alert('xss')")
url = reverse("linkding:bookmarks.index") + "?page=alert(%27xss%27)"
response = self.client.get(url)
self.assertNotContains(response, "alert('xss')")
def test_turbo_frame_details_modal_renders_details_modal_update(self):
bookmark = self.setup_bookmark()
url = reverse("linkding:bookmarks.index") + f"?bookmark_id={bookmark.id}"
response = self.client.get(url, headers={"Turbo-Frame": "details-modal"})
self.assertEqual(200, response.status_code)
soup = self.make_soup(response.content.decode())
self.assertIsNotNone(soup.select_one("turbo-frame#details-modal"))
self.assertIsNone(soup.select_one("#bookmark-list-container"))
self.assertIsNone(soup.select_one("#tag-cloud-container"))
def test_does_not_include_rss_feed(self):
response = self.client.get(reverse("linkding:bookmarks.index"))
soup = self.make_soup(response.content.decode())
feed = soup.select_one('head link[type="application/rss+xml"]')
self.assertIsNone(feed)
def test_list_bundles(self):
books = self.setup_bundle(name="Books bundle", order=3)
music = self.setup_bundle(name="Music bundle", order=1)
tools = self.setup_bundle(name="Tools bundle", order=2)
response = self.client.get(reverse("linkding:bookmarks.index"))
html = response.content.decode()
soup = self.make_soup(html)
self.assertVisibleBundles(soup, [music, tools, books])
def test_list_bundles_only_shows_user_owned_bundles(self):
user_bundles = [self.setup_bundle(), self.setup_bundle(), self.setup_bundle()]
other_user = self.setup_user()
self.setup_bundle(user=other_user)
self.setup_bundle(user=other_user)
self.setup_bundle(user=other_user)
response = self.client.get(reverse("linkding:bookmarks.index"))
html = response.content.decode()
soup = self.make_soup(html)
self.assertVisibleBundles(soup, user_bundles)
def test_hide_bundles_when_enabled_in_profile(self):
# visible by default
response = self.client.get(reverse("linkding:bookmarks.index"))
html = response.content.decode()
self.assertInHTML('<h2 id="bundles-heading">Bundles</h2>', html)
# hidden when disabled in profile
user_profile = self.get_or_create_test_user().profile
user_profile.hide_bundles = True
user_profile.save()
response = self.client.get(reverse("linkding:bookmarks.index"))
html = response.content.decode()
self.assertInHTML('<h2 id="bundles-heading">Bundles</h2>', html, count=0)