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
This commit is contained in:
Sascha Ißbrücker
2025-06-19 16:47:29 +02:00
committed by GitHub
parent 8be72a5d1f
commit 1672dc0152
59 changed files with 2290 additions and 267 deletions

View File

@@ -17,7 +17,7 @@ from rest_framework import status
from rest_framework.authtoken.models import Token
from rest_framework.test import APITestCase
from bookmarks.models import Bookmark, BookmarkAsset, Tag, User
from bookmarks.models import Bookmark, BookmarkAsset, BookmarkBundle, Tag, User
class BookmarkFactoryMixin:
@@ -166,6 +166,33 @@ class BookmarkFactoryMixin:
def get_numbered_bookmark(self, title: str):
return Bookmark.objects.get(title=title)
def setup_bundle(
self,
user: User = None,
name: str = None,
search: str = "",
any_tags: str = "",
all_tags: str = "",
excluded_tags: str = "",
order: int = 0,
):
if user is None:
user = self.get_or_create_test_user()
if not name:
name = get_random_string(length=32)
bundle = BookmarkBundle(
name=name,
owner=user,
date_created=timezone.now(),
search=search,
any_tags=any_tags,
all_tags=all_tags,
excluded_tags=excluded_tags,
order=order,
)
bundle.save()
return bundle
def setup_asset(
self,
bookmark: Bookmark,
@@ -239,7 +266,7 @@ class BookmarkFactoryMixin:
user.profile.save()
return user
def get_tags_from_bookmarks(self, bookmarks: [Bookmark]):
def get_tags_from_bookmarks(self, bookmarks: list[Bookmark]):
all_tags = []
for bookmark in bookmarks:
all_tags = all_tags + list(bookmark.tags.all())

View File

@@ -844,6 +844,26 @@ class BookmarkActionViewTestCase(
self.assertEqual(0, Bookmark.objects.filter(title__startswith="foo").count())
self.assertEqual(3, Bookmark.objects.filter(title__startswith="bar").count())
def test_index_action_bulk_select_across_respects_bundle(self):
self.setup_numbered_bookmarks(3, prefix="foo")
self.setup_numbered_bookmarks(3, prefix="bar")
self.assertEqual(3, Bookmark.objects.filter(title__startswith="foo").count())
bundle = self.setup_bundle(search="foo")
self.client.post(
reverse("linkding:bookmarks.index.action") + f"?bundle={bundle.id}",
{
"bulk_action": ["bulk_delete"],
"bulk_execute": [""],
"bulk_select_across": ["on"],
},
)
self.assertEqual(0, Bookmark.objects.filter(title__startswith="foo").count())
self.assertEqual(3, Bookmark.objects.filter(title__startswith="bar").count())
def test_archived_action_bulk_select_across_only_affects_archived_bookmarks(self):
self.setup_bulk_edit_scope_test_data()
@@ -889,6 +909,26 @@ class BookmarkActionViewTestCase(
self.assertEqual(0, Bookmark.objects.filter(title__startswith="foo").count())
self.assertEqual(3, Bookmark.objects.filter(title__startswith="bar").count())
def test_archived_action_bulk_select_across_respects_bundle(self):
self.setup_numbered_bookmarks(3, prefix="foo", archived=True)
self.setup_numbered_bookmarks(3, prefix="bar", archived=True)
self.assertEqual(3, Bookmark.objects.filter(title__startswith="foo").count())
bundle = self.setup_bundle(search="foo")
self.client.post(
reverse("linkding:bookmarks.archived.action") + f"?bundle={bundle.id}",
{
"bulk_action": ["bulk_delete"],
"bulk_execute": [""],
"bulk_select_across": ["on"],
},
)
self.assertEqual(0, Bookmark.objects.filter(title__startswith="foo").count())
self.assertEqual(3, Bookmark.objects.filter(title__startswith="bar").count())
def test_shared_action_bulk_select_across_not_supported(self):
self.setup_bulk_edit_scope_test_data()

View File

@@ -9,7 +9,6 @@ from bookmarks.tests.helpers import (
BookmarkFactoryMixin,
BookmarkListTestMixin,
TagCloudTestMixin,
collapse_whitespace,
)
@@ -60,7 +59,23 @@ class BookmarkArchivedViewTestCase(
)
response = self.client.get(reverse("linkding:bookmarks.archived") + "?q=foo")
html = collapse_whitespace(response.content.decode())
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", archived=True
)
invisible_bookmarks = self.setup_numbered_bookmarks(
3, prefix="bar", archived=True
)
bundle = self.setup_bundle(search="foo")
response = self.client.get(
reverse("linkding:bookmarks.archived") + f"?bundle={bundle.id}"
)
self.assertVisibleBookmarks(response, visible_bookmarks)
self.assertInvisibleBookmarks(response, invisible_bookmarks)
@@ -105,6 +120,26 @@ class BookmarkArchivedViewTestCase(
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, archived=True, prefix="foo", tag_prefix="foo"
)
invisible_bookmarks = self.setup_numbered_bookmarks(
3, with_tags=True, archived=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.archived") + 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 = {
@@ -515,3 +550,20 @@ class BookmarkArchivedViewTestCase(
feed = soup.select_one('head link[type="application/rss+xml"]')
self.assertIsNone(feed)
def test_hide_bundles_when_enabled_in_profile(self):
# visible by default
response = self.client.get(reverse("linkding:bookmarks.archived"))
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.archived"))
html = response.content.decode()
self.assertInHTML('<h2 id="bundles-heading">Bundles</h2>', html, count=0)

View File

@@ -585,10 +585,10 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
asset_item = self.find_asset(asset_list, asset)
self.assertIsNotNone(asset_item)
asset_icon = asset_item.select_one(".asset-icon svg")
asset_icon = asset_item.select_one(".list-item-icon svg")
self.assertIsNotNone(asset_icon)
asset_text = asset_item.select_one(".asset-text span")
asset_text = asset_item.select_one(".list-item-text span")
self.assertIsNotNone(asset_text)
self.assertIn(asset.display_name, asset_text.text)
@@ -687,11 +687,11 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
soup = self.get_index_details_modal(bookmark)
asset_item = self.find_asset(soup, pending_asset)
asset_text = asset_item.select_one(".asset-text span")
asset_text = asset_item.select_one(".list-item-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")
asset_text = asset_item.select_one(".list-item-text span")
self.assertIn("(failed)", asset_text.text)
def test_asset_file_size(self):
@@ -703,15 +703,15 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
soup = self.get_index_details_modal(bookmark)
asset_item = self.find_asset(soup, asset1)
asset_text = asset_item.select_one(".asset-text")
asset_text = asset_item.select_one(".list-item-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")
asset_text = asset_item.select_one(".list-item-text")
self.assertIn("53.4\xa0KB", asset_text.text)
asset_item = self.find_asset(soup, asset3)
asset_text = asset_item.select_one(".asset-text")
asset_text = asset_item.select_one(".list-item-text")
self.assertIn("11.0\xa0MB", asset_text.text)
def test_asset_actions_visibility(self):

View File

@@ -34,6 +34,21 @@ class BookmarkIndexViewTestCase(
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"
@@ -58,6 +73,19 @@ class BookmarkIndexViewTestCase(
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"
@@ -96,6 +124,26 @@ class BookmarkIndexViewTestCase(
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 = {
@@ -494,3 +542,43 @@ class BookmarkIndexViewTestCase(
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)

View File

@@ -11,21 +11,25 @@ class BookmarkSearchFormTest(TestCase, BookmarkFactoryMixin):
form = BookmarkSearchForm(search)
self.assertEqual(form["q"].initial, "")
self.assertEqual(form["user"].initial, "")
self.assertEqual(form["bundle"].initial, None)
self.assertEqual(form["sort"].initial, BookmarkSearch.SORT_ADDED_DESC)
self.assertEqual(form["shared"].initial, BookmarkSearch.FILTER_SHARED_OFF)
self.assertEqual(form["unread"].initial, BookmarkSearch.FILTER_UNREAD_OFF)
# with params
bundle = self.setup_bundle()
search = BookmarkSearch(
q="search query",
sort=BookmarkSearch.SORT_ADDED_ASC,
user="user123",
bundle=bundle,
shared=BookmarkSearch.FILTER_SHARED_SHARED,
unread=BookmarkSearch.FILTER_UNREAD_YES,
)
form = BookmarkSearchForm(search)
self.assertEqual(form["q"].initial, "search query")
self.assertEqual(form["user"].initial, "user123")
self.assertEqual(form["bundle"].initial, bundle.id)
self.assertEqual(form["sort"].initial, BookmarkSearch.SORT_ADDED_ASC)
self.assertEqual(form["shared"].initial, BookmarkSearch.FILTER_SHARED_SHARED)
self.assertEqual(form["unread"].initial, BookmarkSearch.FILTER_UNREAD_YES)
@@ -61,17 +65,26 @@ class BookmarkSearchFormTest(TestCase, BookmarkFactoryMixin):
self.assertCountEqual(form.hidden_fields(), [form["q"], form["sort"]])
# all modified params
bundle = self.setup_bundle()
search = BookmarkSearch(
q="search query",
sort=BookmarkSearch.SORT_ADDED_ASC,
user="user123",
bundle=bundle,
shared=BookmarkSearch.FILTER_SHARED_SHARED,
unread=BookmarkSearch.FILTER_UNREAD_YES,
)
form = BookmarkSearchForm(search)
self.assertCountEqual(
form.hidden_fields(),
[form["q"], form["sort"], form["user"], form["shared"], form["unread"]],
[
form["q"],
form["sort"],
form["user"],
form["bundle"],
form["shared"],
form["unread"],
],
)
# some modified params are editable fields

View File

@@ -2,16 +2,23 @@ from django.http import QueryDict
from django.test import TestCase
from bookmarks.models import BookmarkSearch
from bookmarks.tests.helpers import BookmarkFactoryMixin
class BookmarkSearchModelTest(TestCase):
class MockRequest:
def __init__(self, user):
self.user = user
class BookmarkSearchModelTest(TestCase, BookmarkFactoryMixin):
def test_from_request(self):
# no params
query_dict = QueryDict()
search = BookmarkSearch.from_request(query_dict)
search = BookmarkSearch.from_request(None, query_dict)
self.assertEqual(search.q, "")
self.assertEqual(search.user, "")
self.assertEqual(search.bundle, None)
self.assertEqual(search.sort, BookmarkSearch.SORT_ADDED_DESC)
self.assertEqual(search.shared, BookmarkSearch.FILTER_SHARED_OFF)
self.assertEqual(search.unread, BookmarkSearch.FILTER_UNREAD_OFF)
@@ -19,7 +26,7 @@ class BookmarkSearchModelTest(TestCase):
# some params
query_dict = QueryDict("q=search query&user=user123")
bookmark_search = BookmarkSearch.from_request(query_dict)
bookmark_search = BookmarkSearch.from_request(None, query_dict)
self.assertEqual(bookmark_search.q, "search query")
self.assertEqual(bookmark_search.user, "user123")
self.assertEqual(bookmark_search.sort, BookmarkSearch.SORT_ADDED_DESC)
@@ -27,13 +34,16 @@ class BookmarkSearchModelTest(TestCase):
self.assertEqual(search.unread, BookmarkSearch.FILTER_UNREAD_OFF)
# all params
bundle = self.setup_bundle()
request = MockRequest(self.get_or_create_test_user())
query_dict = QueryDict(
"q=search query&sort=title_asc&user=user123&shared=yes&unread=yes"
f"q=search query&sort=title_asc&user=user123&bundle={bundle.id}&shared=yes&unread=yes"
)
search = BookmarkSearch.from_request(query_dict)
search = BookmarkSearch.from_request(request, query_dict)
self.assertEqual(search.q, "search query")
self.assertEqual(search.user, "user123")
self.assertEqual(search.bundle, bundle)
self.assertEqual(search.sort, BookmarkSearch.SORT_TITLE_ASC)
self.assertEqual(search.shared, BookmarkSearch.FILTER_SHARED_SHARED)
self.assertEqual(search.unread, BookmarkSearch.FILTER_UNREAD_YES)
@@ -45,7 +55,7 @@ class BookmarkSearchModelTest(TestCase):
}
query_dict = QueryDict("q=search query")
search = BookmarkSearch.from_request(query_dict, preferences)
search = BookmarkSearch.from_request(None, query_dict, preferences)
self.assertEqual(search.q, "search query")
self.assertEqual(search.user, "")
self.assertEqual(search.sort, BookmarkSearch.SORT_TITLE_ASC)
@@ -60,13 +70,110 @@ class BookmarkSearchModelTest(TestCase):
}
query_dict = QueryDict("sort=title_desc&shared=no&unread=off")
search = BookmarkSearch.from_request(query_dict, preferences)
search = BookmarkSearch.from_request(None, query_dict, preferences)
self.assertEqual(search.q, "")
self.assertEqual(search.user, "")
self.assertEqual(search.sort, BookmarkSearch.SORT_TITLE_DESC)
self.assertEqual(search.shared, BookmarkSearch.FILTER_SHARED_UNSHARED)
self.assertEqual(search.unread, BookmarkSearch.FILTER_UNREAD_OFF)
def test_from_request_ignores_invalid_bundle_param(self):
self.setup_bundle()
# bundle does not exist
request = MockRequest(self.get_or_create_test_user())
query_dict = QueryDict("bundle=99999")
search = BookmarkSearch.from_request(request, query_dict)
self.assertIsNone(search.bundle)
# bundle belongs to another user
other_user = self.setup_user()
bundle = self.setup_bundle(user=other_user)
query_dict = QueryDict(f"bundle={bundle.id}")
search = BookmarkSearch.from_request(request, query_dict)
self.assertIsNone(search.bundle)
def test_query_params(self):
# no params
search = BookmarkSearch()
self.assertEqual(search.query_params, {})
# params are default values
search = BookmarkSearch(
q="", sort=BookmarkSearch.SORT_ADDED_DESC, user="", bundle=None, shared=""
)
self.assertEqual(search.query_params, {})
# some modified params
search = BookmarkSearch(q="search query", sort=BookmarkSearch.SORT_ADDED_ASC)
self.assertEqual(
search.query_params,
{"q": "search query", "sort": BookmarkSearch.SORT_ADDED_ASC},
)
# all modified params
bundle = self.setup_bundle()
search = BookmarkSearch(
q="search query",
sort=BookmarkSearch.SORT_ADDED_ASC,
user="user123",
bundle=bundle,
shared=BookmarkSearch.FILTER_SHARED_SHARED,
unread=BookmarkSearch.FILTER_UNREAD_YES,
)
self.assertEqual(
search.query_params,
{
"q": "search query",
"sort": BookmarkSearch.SORT_ADDED_ASC,
"user": "user123",
"bundle": bundle.id,
"shared": BookmarkSearch.FILTER_SHARED_SHARED,
"unread": BookmarkSearch.FILTER_UNREAD_YES,
},
)
# preferences are not query params if they match default
preferences = {
"sort": BookmarkSearch.SORT_TITLE_ASC,
"unread": BookmarkSearch.FILTER_UNREAD_YES,
}
search = BookmarkSearch(preferences=preferences)
self.assertEqual(search.query_params, {})
# param is not a query param if it matches the preference
preferences = {
"sort": BookmarkSearch.SORT_TITLE_ASC,
"unread": BookmarkSearch.FILTER_UNREAD_YES,
}
search = BookmarkSearch(
sort=BookmarkSearch.SORT_TITLE_ASC,
unread=BookmarkSearch.FILTER_UNREAD_YES,
preferences=preferences,
)
self.assertEqual(search.query_params, {})
# overriding preferences is a query param
preferences = {
"sort": BookmarkSearch.SORT_TITLE_ASC,
"shared": BookmarkSearch.FILTER_SHARED_SHARED,
"unread": BookmarkSearch.FILTER_UNREAD_YES,
}
search = BookmarkSearch(
sort=BookmarkSearch.SORT_TITLE_DESC,
shared=BookmarkSearch.FILTER_SHARED_UNSHARED,
unread=BookmarkSearch.FILTER_UNREAD_OFF,
preferences=preferences,
)
self.assertEqual(
search.query_params,
{
"sort": BookmarkSearch.SORT_TITLE_DESC,
"shared": BookmarkSearch.FILTER_SHARED_UNSHARED,
"unread": BookmarkSearch.FILTER_UNREAD_OFF,
},
)
def test_modified_params(self):
# no params
bookmark_search = BookmarkSearch()
@@ -88,16 +195,18 @@ class BookmarkSearchModelTest(TestCase):
self.assertCountEqual(modified_params, ["q", "sort"])
# all modified params
bundle = self.setup_bundle()
bookmark_search = BookmarkSearch(
q="search query",
sort=BookmarkSearch.SORT_ADDED_ASC,
user="user123",
bundle=bundle,
shared=BookmarkSearch.FILTER_SHARED_SHARED,
unread=BookmarkSearch.FILTER_UNREAD_YES,
)
modified_params = bookmark_search.modified_params
self.assertCountEqual(
modified_params, ["q", "sort", "user", "shared", "unread"]
modified_params, ["q", "sort", "user", "bundle", "shared", "unread"]
)
# preferences are not modified params
@@ -180,7 +289,10 @@ class BookmarkSearchModelTest(TestCase):
)
# only returns preferences
bookmark_search = BookmarkSearch(q="search query", user="user123")
bundle = self.setup_bundle()
bookmark_search = BookmarkSearch(
q="search query", user="user123", bundle=bundle
)
self.assertEqual(
bookmark_search.preferences_dict,
{

View File

@@ -12,7 +12,7 @@ class BookmarkSearchTagTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
request = rf.get(url)
request.user = self.get_or_create_test_user()
request.user_profile = self.get_or_create_test_user().profile
search = BookmarkSearch.from_request(request.GET)
search = BookmarkSearch.from_request(request, request.GET)
context = RequestContext(
request,
{

View File

@@ -114,6 +114,24 @@ class BookmarkSharedViewTestCase(
self.assertVisibleBookmarks(response, visible_bookmarks)
self.assertInvisibleBookmarks(response, invisible_bookmarks)
def test_should_list_bookmarks_matching_bundle(self):
self.authenticate()
user = self.setup_user(enable_sharing=True)
visible_bookmarks = self.setup_numbered_bookmarks(
3, shared=True, user=user, prefix="foo"
)
invisible_bookmarks = self.setup_numbered_bookmarks(3, shared=True, user=user)
bundle = self.setup_bundle(search="foo")
response = self.client.get(
reverse("linkding:bookmarks.shared") + f"?bundle={bundle.id}"
)
self.assertVisibleBookmarks(response, visible_bookmarks)
self.assertInvisibleBookmarks(response, invisible_bookmarks)
def test_should_list_only_publicly_shared_bookmarks_without_login(self):
user1 = self.setup_user(enable_sharing=True, enable_public_sharing=True)
user2 = self.setup_user(enable_sharing=True)
@@ -224,6 +242,45 @@ class BookmarkSharedViewTestCase(
self.assertVisibleTags(response, visible_tags)
self.assertInvisibleTags(response, invisible_tags)
def test_should_list_tags_for_bookmarks_matching_bundle(self):
self.authenticate()
user1 = self.setup_user(enable_sharing=True)
user2 = self.setup_user(enable_sharing=True)
user3 = self.setup_user(enable_sharing=True)
visible_tags = [
self.setup_tag(user=user1),
self.setup_tag(user=user2),
self.setup_tag(user=user3),
]
invisible_tags = [
self.setup_tag(user=user1),
self.setup_tag(user=user2),
self.setup_tag(user=user3),
]
self.setup_bookmark(
shared=True, user=user1, title="searchvalue", tags=[visible_tags[0]]
)
self.setup_bookmark(
shared=True, user=user2, title="searchvalue", tags=[visible_tags[1]]
)
self.setup_bookmark(
shared=True, user=user3, title="searchvalue", tags=[visible_tags[2]]
)
self.setup_bookmark(shared=True, user=user1, tags=[invisible_tags[0]])
self.setup_bookmark(shared=True, user=user2, tags=[invisible_tags[1]])
self.setup_bookmark(shared=True, user=user3, tags=[invisible_tags[2]])
bundle = self.setup_bundle(search="searchvalue")
response = self.client.get(
reverse("linkding:bookmarks.shared") + f"?bundle={bundle.id}"
)
self.assertVisibleTags(response, visible_tags)
self.assertInvisibleTags(response, invisible_tags)
def test_should_list_only_tags_for_publicly_shared_bookmarks_without_login(self):
user1 = self.setup_user(enable_sharing=True, enable_public_sharing=True)
user2 = self.setup_user(enable_sharing=True)

View File

@@ -143,6 +143,19 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
)
self.assertBookmarkListEqual(response.data["results"], bookmarks)
def test_list_bookmarks_should_filter_by_bundle(self):
self.authenticate()
search_value = self.get_random_string()
bookmarks = self.setup_numbered_bookmarks(5, prefix=search_value)
self.setup_numbered_bookmarks(5)
bundle = self.setup_bundle(search=search_value)
response = self.get(
reverse("linkding:bookmark-list") + f"?bundle={bundle.id}",
expected_status_code=status.HTTP_200_OK,
)
self.assertBookmarkListEqual(response.data["results"], bookmarks)
def test_list_bookmarks_filter_unread(self):
self.authenticate()
unread_bookmarks = self.setup_numbered_bookmarks(5, unread=True)
@@ -250,6 +263,21 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
)
self.assertBookmarkListEqual(response.data["results"], archived_bookmarks)
def test_list_archived_bookmarks_should_filter_by_bundle(self):
self.authenticate()
search_value = self.get_random_string()
archived_bookmarks = self.setup_numbered_bookmarks(
5, archived=True, prefix=search_value
)
self.setup_numbered_bookmarks(5, archived=True)
bundle = self.setup_bundle(search=search_value)
response = self.get(
reverse("linkding:bookmark-archived") + f"?bundle={bundle.id}",
expected_status_code=status.HTTP_200_OK,
)
self.assertBookmarkListEqual(response.data["results"], archived_bookmarks)
def test_list_archived_bookmarks_should_respect_sort(self):
self.authenticate()
bookmarks = self.setup_numbered_bookmarks(5, archived=True)

View File

@@ -10,7 +10,7 @@ from django.urls import reverse
from django.utils import timezone, formats
from bookmarks.middlewares import LinkdingMiddleware
from bookmarks.models import Bookmark, UserProfile, User
from bookmarks.models import Bookmark, BookmarkSearch, UserProfile, User
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
from bookmarks.views import contexts
@@ -46,7 +46,6 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
title="View snapshot on the Internet Archive Wayback Machine" target="{link_target}" rel="noopener">
{label_content}
</a>
<span>|</span>
""",
html,
)
@@ -266,6 +265,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
contexts.BookmarkListContext
] = contexts.ActiveBookmarkListContext,
user: User | AnonymousUser = None,
is_preview: bool = False,
) -> str:
rf = RequestFactory()
request = rf.get(url)
@@ -273,7 +273,10 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
middleware = LinkdingMiddleware(lambda r: HttpResponse())
middleware(request)
bookmark_list_context = context_type(request)
search = BookmarkSearch.from_request(request, request.GET)
bookmark_list_context = context_type(request, search)
if is_preview:
bookmark_list_context.is_preview = True
context = RequestContext(request, {"bookmark_list": bookmark_list_context})
template = Template("{% include 'bookmarks/bookmark_list.html' %}")
@@ -1047,3 +1050,21 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
soup = self.make_soup(html)
bookmarks = soup.select("li[ld-bookmark-item]")
self.assertEqual(10, len(bookmarks))
def test_no_actions_rendered_when_is_preview(self):
bookmark = self.setup_bookmark()
bookmark.date_added = timezone.now() - relativedelta(days=8)
bookmark.web_archive_snapshot_url = "https://example.com"
bookmark.save()
html = self.render_template(is_preview=True)
# Verify no actions are rendered
self.assertNoViewLink(html, bookmark)
self.assertNoBookmarkActions(html, bookmark)
self.assertMarkAsReadButton(html, bookmark, count=0)
self.assertUnshareButton(html, bookmark, count=0)
self.assertNotesToggle(html, count=0)
# But date should still be rendered
self.assertWebArchiveLink(html, "1 week ago", bookmark.web_archive_snapshot_url)

View File

@@ -0,0 +1,122 @@
from django.test import TestCase
from django.urls import reverse
from bookmarks.tests.helpers import BookmarkFactoryMixin
class BundleEditViewTestCase(TestCase, BookmarkFactoryMixin):
def setUp(self) -> None:
user = self.get_or_create_test_user()
self.client.force_login(user)
def create_form_data(self, overrides=None):
if overrides is None:
overrides = {}
form_data = {
"name": "Test Bundle",
"search": "test search",
"any_tags": "tag1 tag2",
"all_tags": "required-tag",
"excluded_tags": "excluded-tag",
}
return {**form_data, **overrides}
def test_should_edit_bundle(self):
bundle = self.setup_bundle()
updated_data = self.create_form_data()
response = self.client.post(
reverse("linkding:bundles.edit", args=[bundle.id]), updated_data
)
self.assertRedirects(response, reverse("linkding:bundles.index"))
bundle.refresh_from_db()
self.assertEqual(bundle.name, updated_data["name"])
self.assertEqual(bundle.search, updated_data["search"])
self.assertEqual(bundle.any_tags, updated_data["any_tags"])
self.assertEqual(bundle.all_tags, updated_data["all_tags"])
self.assertEqual(bundle.excluded_tags, updated_data["excluded_tags"])
def test_should_render_edit_form_with_prefilled_fields(self):
bundle = self.setup_bundle(
name="Test Bundle",
search="test search terms",
any_tags="tag1 tag2 tag3",
all_tags="required-tag all-tag",
excluded_tags="excluded-tag banned-tag",
)
response = self.client.get(reverse("linkding:bundles.edit", args=[bundle.id]))
self.assertEqual(response.status_code, 200)
html = response.content.decode()
self.assertInHTML(
f'<input type="text" name="name" value="{bundle.name}" '
'autocomplete="off" placeholder=" " class="form-input" '
'maxlength="256" required id="id_name">',
html,
)
self.assertInHTML(
f'<input type="text" name="search" value="{bundle.search}" '
'autocomplete="off" placeholder=" " class="form-input" '
'maxlength="256" id="id_search">',
html,
)
self.assertInHTML(
f'<input type="text" name="any_tags" value="{bundle.any_tags}" '
'autocomplete="off" autocapitalize="off" class="form-input" '
'maxlength="1024" id="id_any_tags">',
html,
)
self.assertInHTML(
f'<input type="text" name="all_tags" value="{bundle.all_tags}" '
'autocomplete="off" autocapitalize="off" class="form-input" '
'maxlength="1024" id="id_all_tags">',
html,
)
self.assertInHTML(
f'<input type="text" name="excluded_tags" value="{bundle.excluded_tags}" '
'autocomplete="off" autocapitalize="off" class="form-input" '
'maxlength="1024" id="id_excluded_tags">',
html,
)
def test_should_return_422_with_invalid_form(self):
bundle = self.setup_bundle(
name="Test Bundle",
search="test search",
any_tags="tag1 tag2",
all_tags="required-tag",
excluded_tags="excluded-tag",
)
invalid_data = self.create_form_data({"name": ""})
response = self.client.post(
reverse("linkding:bundles.edit", args=[bundle.id]), invalid_data
)
self.assertEqual(response.status_code, 422)
def test_should_not_allow_editing_other_users_bundles(self):
other_user = self.setup_user(name="otheruser")
other_users_bundle = self.setup_bundle(user=other_user)
response = self.client.get(
reverse("linkding:bundles.edit", args=[other_users_bundle.id])
)
self.assertEqual(response.status_code, 404)
updated_data = self.create_form_data()
response = self.client.post(
reverse("linkding:bundles.edit", args=[other_users_bundle.id]), updated_data
)
self.assertEqual(response.status_code, 404)

View File

@@ -0,0 +1,198 @@
from django.test import TestCase
from django.urls import reverse
from bookmarks.models import BookmarkBundle
from bookmarks.tests.helpers import BookmarkFactoryMixin
class BundleIndexViewTestCase(TestCase, BookmarkFactoryMixin):
def setUp(self) -> None:
user = self.get_or_create_test_user()
self.client.force_login(user)
def test_render_bundle_list(self):
bundles = [
self.setup_bundle(name="Bundle 1"),
self.setup_bundle(name="Bundle 2"),
self.setup_bundle(name="Bundle 3"),
]
response = self.client.get(reverse("linkding:bundles.index"))
self.assertEqual(response.status_code, 200)
html = response.content.decode()
for bundle in bundles:
expected_list_item = f"""
<div class="list-item" data-bundle-id="{bundle.id}" draggable="true">
<div class="list-item-icon text-secondary">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M9 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
<path d="M9 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
<path d="M9 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
<path d="M15 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
<path d="M15 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
<path d="M15 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
</svg>
</div>
<div class="list-item-text">
<span class="truncate">{bundle.name}</span>
</div>
<div class="list-item-actions">
<a class="btn btn-link" href="{reverse("linkding:bundles.edit", args=[bundle.id])}">Edit</a>
<button ld-confirm-button type="submit" name="remove_bundle" value="{bundle.id}" class="btn btn-link">Remove</button>
</div>
</div>
"""
self.assertInHTML(expected_list_item, html)
def test_renders_user_owned_bundles_only(self):
user_bundle = self.setup_bundle(name="User Bundle")
other_user = self.setup_user(name="otheruser")
other_user_bundle = self.setup_bundle(name="Other User Bundle", user=other_user)
response = self.client.get(reverse("linkding:bundles.index"))
self.assertEqual(response.status_code, 200)
html = response.content.decode()
self.assertInHTML(f'<span class="truncate">{user_bundle.name}</span>', html)
self.assertNotIn(other_user_bundle.name, html)
def test_empty_state(self):
response = self.client.get(reverse("linkding:bundles.index"))
self.assertEqual(response.status_code, 200)
html = response.content.decode()
self.assertInHTML('<p class="empty-title h5">You have no bundles yet</p>', html)
self.assertInHTML(
'<p class="empty-subtitle">Create your first bundle to get started</p>',
html,
)
def test_add_new_button(self):
response = self.client.get(reverse("linkding:bundles.index"))
self.assertEqual(response.status_code, 200)
html = response.content.decode()
self.assertInHTML(
f'<a href="{reverse("linkding:bundles.new")}" class="btn btn-primary">Add new bundle</a>',
html,
)
def test_remove_bundle(self):
bundle = self.setup_bundle(name="Test Bundle")
response = self.client.post(
reverse("linkding:bundles.action"),
{"remove_bundle": str(bundle.id)},
)
self.assertEqual(response.status_code, 302)
self.assertRedirects(response, reverse("linkding:bundles.index"))
self.assertFalse(BookmarkBundle.objects.filter(id=bundle.id).exists())
def test_remove_other_user_bundle(self):
other_user = self.setup_user(name="otheruser")
other_user_bundle = self.setup_bundle(name="Other User Bundle", user=other_user)
response = self.client.post(
reverse("linkding:bundles.action"),
{"remove_bundle": str(other_user_bundle.id)},
)
self.assertEqual(response.status_code, 404)
self.assertTrue(BookmarkBundle.objects.filter(id=other_user_bundle.id).exists())
def assertBundleOrder(self, expected_bundles, user=None):
if user is None:
user = self.user
actual_bundles = BookmarkBundle.objects.filter(owner=user).order_by("order")
self.assertEqual(len(actual_bundles), len(expected_bundles))
for i, bundle in enumerate(expected_bundles):
self.assertEqual(actual_bundles[i].id, bundle.id)
self.assertEqual(actual_bundles[i].order, i)
def move_bundle(self, bundle: BookmarkBundle, position: int):
return self.client.post(
reverse("linkding:bundles.action"),
{"move_bundle": str(bundle.id), "move_position": position},
)
def test_move_bundle(self):
bundle1 = self.setup_bundle(name="Bundle 1", order=0)
bundle2 = self.setup_bundle(name="Bundle 2", order=1)
bundle3 = self.setup_bundle(name="Bundle 3", order=2)
self.move_bundle(bundle1, 1)
self.assertBundleOrder([bundle2, bundle1, bundle3])
self.move_bundle(bundle1, 0)
self.assertBundleOrder([bundle1, bundle2, bundle3])
self.move_bundle(bundle1, 2)
self.assertBundleOrder([bundle2, bundle3, bundle1])
self.move_bundle(bundle1, 2)
self.assertBundleOrder([bundle2, bundle3, bundle1])
def test_move_bundle_response(self):
bundle1 = self.setup_bundle(name="Bundle 1", order=0)
self.setup_bundle(name="Bundle 2", order=1)
response = self.move_bundle(bundle1, 1)
self.assertEqual(response.status_code, 302)
self.assertRedirects(response, reverse("linkding:bundles.index"))
def test_can_only_move_user_owned_bundles(self):
other_user = self.setup_user()
other_user_bundle1 = self.setup_bundle(user=other_user)
self.setup_bundle(user=other_user)
response = self.move_bundle(other_user_bundle1, 1)
self.assertEqual(response.status_code, 404)
def test_move_bundle_only_affects_own_bundles(self):
user_bundle1 = self.setup_bundle(name="User Bundle 1", order=0)
user_bundle2 = self.setup_bundle(name="User Bundle 2", order=1)
other_user = self.setup_user(name="otheruser")
other_user_bundle = self.setup_bundle(
name="Other User Bundle", user=other_user, order=0
)
# Move user bundle
self.move_bundle(user_bundle1, 1)
self.assertBundleOrder([user_bundle2, user_bundle1], user=self.user)
# Check that other user's bundle is unaffected
self.assertBundleOrder([other_user_bundle], user=other_user)
def test_remove_non_existing_bundle(self):
non_existent_id = 99999
response = self.client.post(
reverse("linkding:bundles.action"),
{"remove_bundle": str(non_existent_id)},
)
self.assertEqual(response.status_code, 404)
def test_post_without_action(self):
bundle = self.setup_bundle(name="Test Bundle")
response = self.client.post(reverse("linkding:bundles.action"), {})
self.assertEqual(response.status_code, 302)
self.assertRedirects(response, reverse("linkding:bundles.index"))
self.assertTrue(BookmarkBundle.objects.filter(id=bundle.id).exists())

View File

@@ -0,0 +1,77 @@
from django.test import TestCase
from django.urls import reverse
from bookmarks.models import BookmarkBundle
from bookmarks.tests.helpers import BookmarkFactoryMixin
class BundleNewViewTestCase(TestCase, BookmarkFactoryMixin):
def setUp(self) -> None:
user = self.get_or_create_test_user()
self.client.force_login(user)
def create_form_data(self, overrides=None):
if overrides is None:
overrides = {}
form_data = {
"name": "Test Bundle",
"search": "test search",
"any_tags": "tag1 tag2",
"all_tags": "required-tag",
"excluded_tags": "excluded-tag",
}
return {**form_data, **overrides}
def test_should_create_new_bundle(self):
form_data = self.create_form_data()
response = self.client.post(reverse("linkding:bundles.new"), form_data)
self.assertEqual(BookmarkBundle.objects.count(), 1)
bundle = BookmarkBundle.objects.first()
self.assertEqual(bundle.owner, self.user)
self.assertEqual(bundle.name, form_data["name"])
self.assertEqual(bundle.search, form_data["search"])
self.assertEqual(bundle.any_tags, form_data["any_tags"])
self.assertEqual(bundle.all_tags, form_data["all_tags"])
self.assertEqual(bundle.excluded_tags, form_data["excluded_tags"])
self.assertRedirects(response, reverse("linkding:bundles.index"))
def test_should_increment_order_for_subsequent_bundles(self):
# Create first bundle
form_data_1 = self.create_form_data({"name": "Bundle 1"})
self.client.post(reverse("linkding:bundles.new"), form_data_1)
bundle1 = BookmarkBundle.objects.get(name="Bundle 1")
self.assertEqual(bundle1.order, 0)
# Create second bundle
form_data_2 = self.create_form_data({"name": "Bundle 2"})
self.client.post(reverse("linkding:bundles.new"), form_data_2)
bundle2 = BookmarkBundle.objects.get(name="Bundle 2")
self.assertEqual(bundle2.order, 1)
# Create another bundle with a higher order
self.setup_bundle(order=5)
# Create third bundle
form_data_3 = self.create_form_data({"name": "Bundle 3"})
self.client.post(reverse("linkding:bundles.new"), form_data_3)
bundle3 = BookmarkBundle.objects.get(name="Bundle 3")
self.assertEqual(bundle3.order, 6)
def test_incrementing_order_ignores_other_user_bookmark(self):
other_user = self.setup_user()
self.setup_bundle(user=other_user, order=10)
form_data = self.create_form_data({"name": "Bundle 1"})
self.client.post(reverse("linkding:bundles.new"), form_data)
bundle1 = BookmarkBundle.objects.get(name="Bundle 1")
self.assertEqual(bundle1.order, 0)
def test_should_return_422_with_invalid_form(self):
form_data = self.create_form_data({"name": ""})
response = self.client.post(reverse("linkding:bundles.new"), form_data)
self.assertEqual(response.status_code, 422)

View File

@@ -0,0 +1,116 @@
from django.test import TestCase
from django.urls import reverse
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
class BundlePreviewViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
def setUp(self) -> None:
user = self.get_or_create_test_user()
self.client.force_login(user)
def test_preview_empty_bundle(self):
bookmark1 = self.setup_bookmark(title="Test Bookmark 1")
bookmark2 = self.setup_bookmark(title="Test Bookmark 2")
response = self.client.get(reverse("linkding:bundles.preview"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Found 2 bookmarks matching this bundle")
self.assertContains(response, bookmark1.title)
self.assertContains(response, bookmark2.title)
self.assertNotContains(response, "No bookmarks match the current bundle")
def test_preview_with_search_terms(self):
bookmark1 = self.setup_bookmark(title="Python Programming")
bookmark2 = self.setup_bookmark(title="JavaScript Tutorial")
bookmark3 = self.setup_bookmark(title="Django Framework")
response = self.client.get(
reverse("linkding:bundles.preview"), {"search": "python"}
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Found 1 bookmarks matching this bundle")
self.assertContains(response, bookmark1.title)
self.assertNotContains(response, bookmark2.title)
self.assertNotContains(response, bookmark3.title)
def test_preview_no_matching_bookmarks(self):
bookmark = self.setup_bookmark(title="Python Guide")
response = self.client.get(
reverse("linkding:bundles.preview"), {"search": "nonexistent"}
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "No bookmarks match the current bundle")
self.assertNotContains(response, bookmark.title)
def test_preview_renders_bookmark(self):
tag = self.setup_tag(name="test-tag")
bookmark = self.setup_bookmark(
title="Test Bookmark",
description="Test description",
url="https://example.com/test",
tags=[tag],
)
response = self.client.get(reverse("linkding:bundles.preview"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, bookmark.title)
self.assertContains(response, bookmark.description)
self.assertContains(response, bookmark.url)
self.assertContains(response, "#test-tag")
def test_preview_renders_bookmark_in_preview_mode(self):
tag = self.setup_tag(name="test-tag")
self.setup_bookmark(
title="Test Bookmark",
description="Test description",
url="https://example.com/test",
tags=[tag],
)
response = self.client.get(reverse("linkding:bundles.preview"))
soup = self.make_soup(response.content.decode())
list_item = soup.select_one("li[ld-bookmark-item]")
actions = list_item.select(".actions > *")
self.assertEqual(len(actions), 1)
def test_preview_ignores_archived_bookmarks(self):
active_bookmark = self.setup_bookmark(title="Active Bookmark")
archived_bookmark = self.setup_bookmark(
title="Archived Bookmark", is_archived=True
)
response = self.client.get(reverse("linkding:bundles.preview"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Found 1 bookmarks matching this bundle")
self.assertContains(response, active_bookmark.title)
self.assertNotContains(response, archived_bookmark.title)
def test_preview_requires_authentication(self):
self.client.logout()
response = self.client.get(reverse("linkding:bundles.preview"), follow=True)
self.assertRedirects(
response, f"/login/?next={reverse('linkding:bundles.preview')}"
)
def test_preview_only_shows_user_bookmarks(self):
other_user = self.setup_user()
own_bookmark = self.setup_bookmark(title="Own Bookmark")
other_bookmark = self.setup_bookmark(title="Other Bookmark", user=other_user)
response = self.client.get(reverse("linkding:bundles.preview"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Found 1 bookmarks matching this bundle")
self.assertContains(response, own_bookmark.title)
self.assertNotContains(response, other_bookmark.title)

View File

@@ -32,7 +32,7 @@ class PaginationTagTest(TestCase, BookmarkFactoryMixin):
)
def assertPrevLink(self, html: str, page_number: int, href: str = None):
href = href if href else "?page={0}".format(page_number)
href = href if href else "http://testserver/test?page={0}".format(page_number)
self.assertInHTML(
"""
<li class="page-item">
@@ -55,7 +55,7 @@ class PaginationTagTest(TestCase, BookmarkFactoryMixin):
)
def assertNextLink(self, html: str, page_number: int, href: str = None):
href = href if href else "?page={0}".format(page_number)
href = href if href else "http://testserver/test?page={0}".format(page_number)
self.assertInHTML(
"""
<li class="page-item">
@@ -76,7 +76,7 @@ class PaginationTagTest(TestCase, BookmarkFactoryMixin):
href: str = None,
):
active_class = "active" if active else ""
href = href if href else "?page={0}".format(page_number)
href = href if href else "http://testserver/test?page={0}".format(page_number)
self.assertInHTML(
"""
<li class="page-item {1}">
@@ -164,20 +164,38 @@ class PaginationTagTest(TestCase, BookmarkFactoryMixin):
rendered_template = self.render_template(
100, 10, 2, url="/test?q=cake&sort=title_asc&page=2"
)
self.assertPrevLink(rendered_template, 1, href="?q=cake&sort=title_asc&page=1")
self.assertPageLink(
rendered_template, 1, False, href="?q=cake&sort=title_asc&page=1"
self.assertPrevLink(
rendered_template,
1,
href="http://testserver/test?q=cake&sort=title_asc&page=1",
)
self.assertPageLink(
rendered_template, 2, True, href="?q=cake&sort=title_asc&page=2"
rendered_template,
1,
False,
href="http://testserver/test?q=cake&sort=title_asc&page=1",
)
self.assertPageLink(
rendered_template,
2,
True,
href="http://testserver/test?q=cake&sort=title_asc&page=2",
)
self.assertNextLink(
rendered_template,
3,
href="http://testserver/test?q=cake&sort=title_asc&page=3",
)
self.assertNextLink(rendered_template, 3, href="?q=cake&sort=title_asc&page=3")
def test_removes_details_parameter(self):
rendered_template = self.render_template(
100, 10, 2, url="/test?details=1&page=2"
)
self.assertPrevLink(rendered_template, 1, href="?page=1")
self.assertPageLink(rendered_template, 1, False, href="?page=1")
self.assertPageLink(rendered_template, 2, True, href="?page=2")
self.assertNextLink(rendered_template, 3, href="?page=3")
self.assertPrevLink(rendered_template, 1, href="http://testserver/test?page=1")
self.assertPageLink(
rendered_template, 1, False, href="http://testserver/test?page=1"
)
self.assertPageLink(
rendered_template, 2, True, href="http://testserver/test?page=2"
)
self.assertNextLink(rendered_template, 3, href="http://testserver/test?page=3")

View File

@@ -153,7 +153,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark(tags=[tag1, tag2, self.setup_tag()]),
]
def assertQueryResult(self, query: QuerySet, item_lists: [[any]]):
def assertQueryResult(self, query: QuerySet, item_lists: list[list]):
expected_items = []
for item_list in item_lists:
expected_items = expected_items + item_list
@@ -1287,3 +1287,267 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
search = BookmarkSearch(added_since="invalid-date")
query = queries.query_bookmarks(self.user, self.profile, search)
self.assertCountEqual(list(query), [older_bookmark, recent_bookmark])
def test_query_bookmarks_with_bundle_search_terms(self):
bundle = self.setup_bundle(search="search_term_A search_term_B")
matching_bookmarks = [
self.setup_bookmark(
title="search_term_A content", description="search_term_B also here"
),
self.setup_bookmark(url="http://example.com/search_term_A/search_term_B"),
]
# Bookmarks that should not match
self.setup_bookmark(title="search_term_A only")
self.setup_bookmark(description="search_term_B only")
self.setup_bookmark(title="unrelated content")
query = queries.query_bookmarks(
self.user, self.profile, BookmarkSearch(q="", bundle=bundle)
)
self.assertQueryResult(query, [matching_bookmarks])
def test_query_bookmarks_with_search_and_bundle_search_terms(self):
bundle = self.setup_bundle(search="bundle_term_B")
search = BookmarkSearch(q="search_term_A", bundle=bundle)
matching_bookmarks = [
self.setup_bookmark(
title="search_term_A content", description="bundle_term_B also here"
)
]
# Bookmarks that should not match
self.setup_bookmark(title="search_term_A only")
self.setup_bookmark(description="bundle_term_B only")
self.setup_bookmark(title="unrelated content")
query = queries.query_bookmarks(self.user, self.profile, search)
self.assertQueryResult(query, [matching_bookmarks])
def test_query_bookmarks_with_bundle_any_tags(self):
bundle = self.setup_bundle(any_tags="bundleTag1 bundleTag2")
tag1 = self.setup_tag(name="bundleTag1")
tag2 = self.setup_tag(name="bundleTag2")
other_tag = self.setup_tag(name="otherTag")
matching_bookmarks = [
self.setup_bookmark(tags=[tag1]),
self.setup_bookmark(tags=[tag2]),
self.setup_bookmark(tags=[tag1, tag2]),
]
# Bookmarks that should not match
self.setup_bookmark(tags=[other_tag])
self.setup_bookmark()
query = queries.query_bookmarks(
self.user, self.profile, BookmarkSearch(q="", bundle=bundle)
)
self.assertQueryResult(query, [matching_bookmarks])
def test_query_bookmarks_with_search_tags_and_bundle_any_tags(self):
bundle = self.setup_bundle(any_tags="bundleTagA bundleTagB")
search = BookmarkSearch(q="#searchTag1 #searchTag2", bundle=bundle)
search_tag1 = self.setup_tag(name="searchTag1")
search_tag2 = self.setup_tag(name="searchTag2")
bundle_tag_a = self.setup_tag(name="bundleTagA")
bundle_tag_b = self.setup_tag(name="bundleTagB")
other_tag = self.setup_tag(name="otherTag")
matching_bookmarks = [
self.setup_bookmark(tags=[search_tag1, search_tag2, bundle_tag_a]),
self.setup_bookmark(tags=[search_tag1, search_tag2, bundle_tag_b]),
self.setup_bookmark(
tags=[search_tag1, search_tag2, bundle_tag_a, bundle_tag_b]
),
]
# Bookmarks that should not match
self.setup_bookmark(tags=[search_tag1, search_tag2, other_tag])
self.setup_bookmark(tags=[search_tag1, search_tag2])
self.setup_bookmark(tags=[search_tag1, bundle_tag_a])
self.setup_bookmark(tags=[search_tag2, bundle_tag_b])
self.setup_bookmark(tags=[bundle_tag_a])
self.setup_bookmark(tags=[bundle_tag_b])
self.setup_bookmark(tags=[bundle_tag_a, bundle_tag_b])
self.setup_bookmark(tags=[other_tag])
self.setup_bookmark()
query = queries.query_bookmarks(self.user, self.profile, search)
self.assertQueryResult(query, [matching_bookmarks])
def test_query_bookmarks_with_bundle_all_tags(self):
bundle = self.setup_bundle(all_tags="bundleTag1 bundleTag2")
tag1 = self.setup_tag(name="bundleTag1")
tag2 = self.setup_tag(name="bundleTag2")
other_tag = self.setup_tag(name="otherTag")
matching_bookmarks = [self.setup_bookmark(tags=[tag1, tag2])]
# Bookmarks that should not match
self.setup_bookmark(tags=[tag1])
self.setup_bookmark(tags=[tag2])
self.setup_bookmark(tags=[tag1, other_tag])
self.setup_bookmark(tags=[other_tag])
self.setup_bookmark()
query = queries.query_bookmarks(
self.user, self.profile, BookmarkSearch(q="", bundle=bundle)
)
self.assertQueryResult(query, [matching_bookmarks])
def test_query_bookmarks_with_search_tags_and_bundle_all_tags(self):
bundle = self.setup_bundle(all_tags="bundleTagA bundleTagB")
search = BookmarkSearch(q="#searchTag1 #searchTag2", bundle=bundle)
search_tag1 = self.setup_tag(name="searchTag1")
search_tag2 = self.setup_tag(name="searchTag2")
bundle_tag_a = self.setup_tag(name="bundleTagA")
bundle_tag_b = self.setup_tag(name="bundleTagB")
other_tag = self.setup_tag(name="otherTag")
matching_bookmarks = [
self.setup_bookmark(
tags=[search_tag1, search_tag2, bundle_tag_a, bundle_tag_b]
)
]
# Bookmarks that should not match
self.setup_bookmark(tags=[search_tag1, search_tag2, bundle_tag_a])
self.setup_bookmark(tags=[search_tag1, bundle_tag_a, bundle_tag_b])
self.setup_bookmark(tags=[search_tag1, search_tag2])
self.setup_bookmark(tags=[bundle_tag_a, bundle_tag_b])
self.setup_bookmark(tags=[search_tag1, bundle_tag_a])
self.setup_bookmark(tags=[other_tag])
self.setup_bookmark()
query = queries.query_bookmarks(self.user, self.profile, search)
self.assertQueryResult(query, [matching_bookmarks])
def test_query_bookmarks_with_bundle_excluded_tags(self):
bundle = self.setup_bundle(excluded_tags="excludeTag1 excludeTag2")
exclude_tag1 = self.setup_tag(name="excludeTag1")
exclude_tag2 = self.setup_tag(name="excludeTag2")
keep_tag = self.setup_tag(name="keepTag")
keep_other_tag = self.setup_tag(name="keepOtherTag")
matching_bookmarks = [
self.setup_bookmark(tags=[keep_tag]),
self.setup_bookmark(tags=[keep_other_tag]),
self.setup_bookmark(tags=[keep_tag, keep_other_tag]),
self.setup_bookmark(),
]
# Bookmarks that should not be returned
self.setup_bookmark(tags=[exclude_tag1])
self.setup_bookmark(tags=[exclude_tag2])
self.setup_bookmark(tags=[exclude_tag1, keep_tag])
self.setup_bookmark(tags=[exclude_tag2, keep_tag])
self.setup_bookmark(tags=[exclude_tag1, exclude_tag2])
self.setup_bookmark(tags=[exclude_tag1, exclude_tag2, keep_tag])
query = queries.query_bookmarks(
self.user, self.profile, BookmarkSearch(q="", bundle=bundle)
)
self.assertQueryResult(query, [matching_bookmarks])
def test_query_bookmarks_with_bundle_combined_tags(self):
bundle = self.setup_bundle(
any_tags="anyTagA anyTagB",
all_tags="allTag1 allTag2",
excluded_tags="excludedTag",
)
any_tag_a = self.setup_tag(name="anyTagA")
any_tag_b = self.setup_tag(name="anyTagB")
all_tag_1 = self.setup_tag(name="allTag1")
all_tag_2 = self.setup_tag(name="allTag2")
other_tag = self.setup_tag(name="otherTag")
excluded_tag = self.setup_tag(name="excludedTag")
matching_bookmarks = [
self.setup_bookmark(tags=[any_tag_a, all_tag_1, all_tag_2]),
self.setup_bookmark(tags=[any_tag_b, all_tag_1, all_tag_2]),
self.setup_bookmark(tags=[any_tag_a, any_tag_b, all_tag_1, all_tag_2]),
self.setup_bookmark(tags=[any_tag_a, all_tag_1, all_tag_2, other_tag]),
self.setup_bookmark(tags=[any_tag_b, all_tag_1, all_tag_2, other_tag]),
]
# Bookmarks that should not match
self.setup_bookmark(tags=[any_tag_a, all_tag_1])
self.setup_bookmark(tags=[any_tag_b, all_tag_2])
self.setup_bookmark(tags=[any_tag_a, any_tag_b, all_tag_1])
self.setup_bookmark(tags=[all_tag_1, all_tag_2])
self.setup_bookmark(tags=[all_tag_1, all_tag_2, other_tag])
self.setup_bookmark(tags=[any_tag_a])
self.setup_bookmark(tags=[any_tag_b])
self.setup_bookmark(tags=[all_tag_1])
self.setup_bookmark(tags=[all_tag_2])
self.setup_bookmark(tags=[any_tag_a, all_tag_1, all_tag_2, excluded_tag])
self.setup_bookmark(tags=[any_tag_b, all_tag_1, all_tag_2, excluded_tag])
self.setup_bookmark(tags=[other_tag])
self.setup_bookmark()
query = queries.query_bookmarks(
self.user, self.profile, BookmarkSearch(q="", bundle=bundle)
)
self.assertQueryResult(query, [matching_bookmarks])
def test_query_archived_bookmarks_with_bundle(self):
bundle = self.setup_bundle(any_tags="bundleTag1 bundleTag2")
tag1 = self.setup_tag(name="bundleTag1")
tag2 = self.setup_tag(name="bundleTag2")
other_tag = self.setup_tag(name="otherTag")
matching_bookmarks = [
self.setup_bookmark(is_archived=True, tags=[tag1]),
self.setup_bookmark(is_archived=True, tags=[tag2]),
self.setup_bookmark(is_archived=True, tags=[tag1, tag2]),
]
# Bookmarks that should not match
self.setup_bookmark(is_archived=True, tags=[other_tag])
self.setup_bookmark(is_archived=True)
self.setup_bookmark(tags=[tag1]),
self.setup_bookmark(tags=[tag2]),
self.setup_bookmark(tags=[tag1, tag2]),
query = queries.query_archived_bookmarks(
self.user, self.profile, BookmarkSearch(q="", bundle=bundle)
)
self.assertQueryResult(query, [matching_bookmarks])
def test_query_shared_bookmarks_with_bundle(self):
user1 = self.setup_user(enable_sharing=True)
user2 = self.setup_user(enable_sharing=True)
bundle = self.setup_bundle(any_tags="bundleTag1 bundleTag2")
tag1 = self.setup_tag(name="bundleTag1")
tag2 = self.setup_tag(name="bundleTag2")
other_tag = self.setup_tag(name="otherTag")
matching_bookmarks = [
self.setup_bookmark(user=user1, shared=True, tags=[tag1]),
self.setup_bookmark(user=user2, shared=True, tags=[tag2]),
self.setup_bookmark(user=user1, shared=True, tags=[tag1, tag2]),
]
# Bookmarks that should not match
self.setup_bookmark(user=user1, shared=True, tags=[other_tag])
self.setup_bookmark(user=user2, shared=True)
self.setup_bookmark(user=user1, shared=False, tags=[tag1]),
self.setup_bookmark(user=user2, shared=False, tags=[tag2]),
self.setup_bookmark(user=user1, shared=False, tags=[tag1, tag2]),
query = queries.query_shared_bookmarks(
None, self.profile, BookmarkSearch(q="", bundle=bundle), False
)
self.assertQueryResult(query, [matching_bookmarks])

View File

@@ -48,6 +48,7 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
"items_per_page": "30",
"sticky_pagination": False,
"collapse_side_panel": False,
"hide_bundles": False,
}
return {**form_data, **overrides}
@@ -119,6 +120,7 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
"items_per_page": "10",
"sticky_pagination": True,
"collapse_side_panel": True,
"hide_bundles": True,
}
response = self.client.post(
reverse("linkding:settings.update"), form_data, follow=True
@@ -199,6 +201,7 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual(
self.user.profile.collapse_side_panel, form_data["collapse_side_panel"]
)
self.assertEqual(self.user.profile.hide_bundles, form_data["hide_bundles"])
self.assertSuccessMessage(html, "Profile updated")

View File

@@ -6,7 +6,7 @@ from django.template import Template, RequestContext
from django.test import TestCase, RequestFactory
from bookmarks.middlewares import LinkdingMiddleware
from bookmarks.models import UserProfile
from bookmarks.models import BookmarkSearch, UserProfile
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
from bookmarks.views import contexts
@@ -24,7 +24,10 @@ class TagCloudTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
middleware = LinkdingMiddleware(lambda r: HttpResponse())
middleware(request)
tag_cloud_context = context_type(request)
search = BookmarkSearch.from_request(
request, request.GET, request.user_profile.search_preferences
)
tag_cloud_context = context_type(request, search)
context = RequestContext(request, {"tag_cloud": tag_cloud_context})
template_to_render = Template("{% include 'bookmarks/tag_cloud.html' %}")
return template_to_render.render(context)

View File

@@ -38,7 +38,7 @@ class ToastsViewTestCase(TestCase, BookmarkFactoryMixin):
response = self.client.get(reverse("linkding:bookmarks.index"))
# Should render toasts container
self.assertContains(response, '<div class="toasts">')
self.assertContains(response, '<div class="message-list">')
# Should render two toasts
self.assertContains(response, '<div class="toast d-flex">', count=2)
@@ -50,7 +50,7 @@ class ToastsViewTestCase(TestCase, BookmarkFactoryMixin):
response = self.client.get(reverse("linkding:bookmarks.index"))
# Should not render toasts container
self.assertContains(response, '<div class="toasts container grid-lg">', count=0)
self.assertContains(response, '<div class="message-list">', count=0)
# Should not render toasts
self.assertContains(response, '<div class="toast">', count=0)
@@ -66,7 +66,7 @@ class ToastsViewTestCase(TestCase, BookmarkFactoryMixin):
response = self.client.get(reverse("linkding:bookmarks.index"))
# Should not render toasts container
self.assertContains(response, '<div class="toasts container grid-lg">', count=0)
self.assertContains(response, '<div class="message-list">', count=0)
# Should not render toasts
self.assertContains(response, '<div class="toast">', count=0)

View File

@@ -12,7 +12,7 @@ class UserSelectTagTest(TestCase, BookmarkFactoryMixin):
request = rf.get(url)
request.user = self.get_or_create_test_user()
request.user_profile = self.get_or_create_test_user().profile
search = BookmarkSearch.from_request(request.GET)
search = BookmarkSearch.from_request(request, request.GET)
context = RequestContext(
request,
{