Return bookmark tags in RSS feeds (#810)

This commit is contained in:
Sascha Ißbrücker
2024-08-31 22:41:22 +02:00
committed by GitHub
parent aad62f61c9
commit 20fe88dd57
3 changed files with 201 additions and 269 deletions

View File

@@ -2,7 +2,8 @@ import unicodedata
from dataclasses import dataclass from dataclasses import dataclass
from django.contrib.syndication.views import Feed from django.contrib.syndication.views import Feed
from django.db.models import QuerySet from django.db.models import QuerySet, prefetch_related_objects
from django.http import HttpRequest
from django.urls import reverse from django.urls import reverse
from bookmarks import queries from bookmarks import queries
@@ -11,6 +12,7 @@ from bookmarks.models import Bookmark, BookmarkSearch, FeedToken, UserProfile
@dataclass @dataclass
class FeedContext: class FeedContext:
request: HttpRequest
feed_token: FeedToken | None feed_token: FeedToken | None
query_set: QuerySet[Bookmark] query_set: QuerySet[Bookmark]
@@ -26,13 +28,23 @@ def sanitize(text: str):
class BaseBookmarksFeed(Feed): class BaseBookmarksFeed(Feed):
def get_object(self, request, feed_key: str): def get_object(self, request, feed_key: str | None):
feed_token = FeedToken.objects.get(key__exact=feed_key) feed_token = FeedToken.objects.get(key__exact=feed_key) if feed_key else None
search = BookmarkSearch(q=request.GET.get("q", "")) search = BookmarkSearch(q=request.GET.get("q", ""))
query_set = queries.query_bookmarks( query_set = self.get_query_set(feed_token, search)
feed_token.user, feed_token.user.profile, search return FeedContext(request, feed_token, query_set)
)
return FeedContext(feed_token, query_set) def get_query_set(self, feed_token: FeedToken, search: BookmarkSearch):
raise NotImplementedError
def items(self, context: FeedContext):
limit = context.request.GET.get("limit", 100)
if limit:
data = context.query_set[: int(limit)]
else:
data = list(context.query_set)
prefetch_related_objects(data, "tags")
return data
def item_title(self, item: Bookmark): def item_title(self, item: Bookmark):
return sanitize(item.resolved_title) return sanitize(item.resolved_title)
@@ -46,60 +58,56 @@ class BaseBookmarksFeed(Feed):
def item_pubdate(self, item: Bookmark): def item_pubdate(self, item: Bookmark):
return item.date_added return item.date_added
def item_categories(self, item: Bookmark):
return item.tag_names
class AllBookmarksFeed(BaseBookmarksFeed): class AllBookmarksFeed(BaseBookmarksFeed):
title = "All bookmarks" title = "All bookmarks"
description = "All bookmarks" description = "All bookmarks"
def get_query_set(self, feed_token: FeedToken, search: BookmarkSearch):
return queries.query_bookmarks(feed_token.user, feed_token.user.profile, search)
def link(self, context: FeedContext): def link(self, context: FeedContext):
return reverse("bookmarks:feeds.all", args=[context.feed_token.key]) return reverse("bookmarks:feeds.all", args=[context.feed_token.key])
def items(self, context: FeedContext):
return context.query_set
class UnreadBookmarksFeed(BaseBookmarksFeed): class UnreadBookmarksFeed(BaseBookmarksFeed):
title = "Unread bookmarks" title = "Unread bookmarks"
description = "All unread bookmarks" description = "All unread bookmarks"
def get_query_set(self, feed_token: FeedToken, search: BookmarkSearch):
return queries.query_bookmarks(
feed_token.user, feed_token.user.profile, search
).filter(unread=True)
def link(self, context: FeedContext): def link(self, context: FeedContext):
return reverse("bookmarks:feeds.unread", args=[context.feed_token.key]) return reverse("bookmarks:feeds.unread", args=[context.feed_token.key])
def items(self, context: FeedContext):
return context.query_set.filter(unread=True)
class SharedBookmarksFeed(BaseBookmarksFeed): class SharedBookmarksFeed(BaseBookmarksFeed):
title = "Shared bookmarks" title = "Shared bookmarks"
description = "All shared bookmarks" description = "All shared bookmarks"
def get_object(self, request, feed_key: str): def get_query_set(self, feed_token: FeedToken, search: BookmarkSearch):
feed_token = FeedToken.objects.get(key__exact=feed_key) return queries.query_shared_bookmarks(
search = BookmarkSearch(q=request.GET.get("q", ""))
query_set = queries.query_shared_bookmarks(
None, feed_token.user.profile, search, False None, feed_token.user.profile, search, False
) )
return FeedContext(feed_token, query_set)
def link(self, context: FeedContext): def link(self, context: FeedContext):
return reverse("bookmarks:feeds.shared", args=[context.feed_token.key]) return reverse("bookmarks:feeds.shared", args=[context.feed_token.key])
def items(self, context: FeedContext):
return context.query_set
class PublicSharedBookmarksFeed(BaseBookmarksFeed): class PublicSharedBookmarksFeed(BaseBookmarksFeed):
title = "Public shared bookmarks" title = "Public shared bookmarks"
description = "All public shared bookmarks" description = "All public shared bookmarks"
def get_object(self, request): def get_object(self, request):
search = BookmarkSearch(q=request.GET.get("q", "")) return super().get_object(request, None)
default_profile = UserProfile()
query_set = queries.query_shared_bookmarks(None, default_profile, search, True) def get_query_set(self, feed_token: FeedToken, search: BookmarkSearch):
return FeedContext(None, query_set) return queries.query_shared_bookmarks(None, UserProfile(), search, True)
def link(self, context: FeedContext): def link(self, context: FeedContext):
return reverse("bookmarks:feeds.public_shared") return reverse("bookmarks:feeds.public_shared")
def items(self, context: FeedContext):
return context.query_set

View File

@@ -7,15 +7,18 @@
<section class="content-area"> <section class="content-area">
<h2>Browser Extension</h2> <h2>Browser Extension</h2>
<p>The browser extension allows you to quickly add new bookmarks without leaving the page that you are on. The extension is available in the official extension stores for:</p> <p>The browser extension allows you to quickly add new bookmarks without leaving the page that you are on. The
extension is available in the official extension stores for:</p>
<ul> <ul>
<li><a href="https://addons.mozilla.org/firefox/addon/linkding-extension/" target="_blank">Firefox</a></li> <li><a href="https://addons.mozilla.org/firefox/addon/linkding-extension/" target="_blank">Firefox</a></li>
<li><a href="https://chrome.google.com/webstore/detail/linkding-extension/beakmhbijpdhipnjhnclmhgjlddhidpe" target="_blank">Chrome</a></li> <li><a href="https://chrome.google.com/webstore/detail/linkding-extension/beakmhbijpdhipnjhnclmhgjlddhidpe"
target="_blank">Chrome</a></li>
</ul> </ul>
<p>The extension is <a href="https://github.com/sissbruecker/linkding-extension" target="_blank">open source</a> as well, which enables you to build and manually load it into any browser that supports Chrome extensions.</p> <p>The extension is <a href="https://github.com/sissbruecker/linkding-extension" target="_blank">open source</a>
as well, which enables you to build and manually load it into any browser that supports Chrome extensions.</p>
<h2>Bookmarklet</h2> <h2>Bookmarklet</h2>
<p>The bookmarklet is an alternative, cross-browser way to quickly add new bookmarks without opening the linkding application <p>The bookmarklet is an alternative, cross-browser way to quickly add new bookmarks without opening the linkding
first. Here's how it works:</p> application first. Here's how it works:</p>
<ul> <ul>
<li>Drag the bookmarklet below into your browsers bookmark bar / toolbar</li> <li>Drag the bookmarklet below into your browsers bookmark bar / toolbar</li>
<li>Open the website that you want to bookmark</li> <li>Open the website that you want to bookmark</li>
@@ -23,7 +26,7 @@
<li>linkding opens in a new window or tab and allows you to add a bookmark for the site</li> <li>linkding opens in a new window or tab and allows you to add a bookmark for the site</li>
<li>After saving the bookmark the linkding window closes and you are back on your website</li> <li>After saving the bookmark the linkding window closes and you are back on your website</li>
</ul> </ul>
<p>Drag the following bookmarklet to your browsers toolbar:</p> <p>Drag the following bookmarklet to your browser's toolbar:</p>
<a href="javascript: {% include 'bookmarks/bookmarklet.js' %}" <a href="javascript: {% include 'bookmarks/bookmarklet.js' %}"
class="btn btn-primary">📎 Add bookmark</a> class="btn btn-primary">📎 Add bookmark</a>
</section> </section>
@@ -41,7 +44,8 @@
<p> <p>
<strong>Please treat this token as you would any other credential.</strong> <strong>Please treat this token as you would any other credential.</strong>
Any party with access to this token can access and manage all your bookmarks. Any party with access to this token can access and manage all your bookmarks.
If you think that a token was compromised you can revoke (delete) it in the <a href="{% url 'admin:authtoken_tokenproxy_changelist' %}">admin panel</a>. If you think that a token was compromised you can revoke (delete) it in the <a
href="{% url 'admin:authtoken_tokenproxy_changelist' %}">admin panel</a>.
After deleting the token, a new one will be generated when you reload this settings page. After deleting the token, a new one will be generated when you reload this settings page.
</p> </p>
</section> </section>
@@ -53,16 +57,26 @@
<li><a href="{{ all_feed_url }}">All bookmarks</a></li> <li><a href="{{ all_feed_url }}">All bookmarks</a></li>
<li><a href="{{ unread_feed_url }}">Unread bookmarks</a></li> <li><a href="{{ unread_feed_url }}">Unread bookmarks</a></li>
<li><a href="{{ shared_feed_url }}">Shared bookmarks</a></li> <li><a href="{{ shared_feed_url }}">Shared bookmarks</a></li>
<li><a href="{{ public_shared_feed_url }}">Public shared bookmarks</a><br><span class="text-small text-gray">The public shared feed does not contain an authentication token and can be shared with other people. Only shows shared bookmarks from users who have explicitly enabled public sharing.</span></li> <li><a href="{{ public_shared_feed_url }}">Public shared bookmarks</a><br><span class="text-small text-gray">The public shared feed does not contain an authentication token and can be shared with other people. Only shows shared bookmarks from users who have explicitly enabled public sharing.</span>
</li>
</ul> </ul>
<p> <p>
All URLs support appending a <code>q</code> URL parameter for specifying a search query. All URLs support the following URL parameters:
You can get an example by doing a search in the bookmarks view and then copying the parameter from the URL.
</p> </p>
<ul style="list-style-position: outside;">
<li>A <code>q</code> URL parameter for specifying a search query. You can get an example by doing a search in
the bookmarks view and then copying the parameter from the URL.
</li>
<li>A <code>limit</code> parameter for specifying the maximum number of bookmarks to include in the feed. By
default, only the latest 100 matching bookmarks are included.
</li>
</ul>
<p> <p>
<strong>Please note that these URLs include an authentication token that should be treated like any other credential.</strong> <strong>Please note that these URLs include an authentication token that should be treated like any other
credential.</strong>
Any party with access to these URLs can read all your bookmarks. Any party with access to these URLs can read all your bookmarks.
If you think that a URL was compromised you can delete the feed token for your user in the <a href="{% url 'admin:bookmarks_feedtoken_changelist' %}">admin panel</a>. If you think that a URL was compromised you can delete the feed token for your user in the <a
href="{% url 'admin:bookmarks_feedtoken_changelist' %}">admin panel</a>.
After deleting the feed token, new URLs will be generated when you reload this settings page. After deleting the feed token, new URLs will be generated when you reload this settings page.
</p> </p>
</section> </section>

View File

@@ -23,6 +23,26 @@ class FeedsTestCase(TestCase, BookmarkFactoryMixin):
self.client.force_login(user) self.client.force_login(user)
self.token = FeedToken.objects.get_or_create(user=user)[0] self.token = FeedToken.objects.get_or_create(user=user)[0]
def assertFeedItems(self, response, bookmarks):
self.assertContains(response, "<item>", count=len(bookmarks))
for bookmark in bookmarks:
categories = []
for tag in bookmark.tag_names:
categories.append(f"<category>{tag}</category>")
expected_item = (
"<item>"
f"<title>{bookmark.resolved_title}</title>"
f"<link>{bookmark.url}</link>"
f"<description>{bookmark.resolved_description}</description>"
f"<pubDate>{rfc2822_date(bookmark.date_added)}</pubDate>"
f"<guid>{bookmark.url}</guid>"
f"{''.join(categories)}"
"</item>"
)
self.assertContains(response, expected_item, count=1)
def test_all_returns_404_for_unknown_feed_token(self): def test_all_returns_404_for_unknown_feed_token(self):
response = self.client.get(reverse("bookmarks:feeds.all", args=["foo"])) response = self.client.get(reverse("bookmarks:feeds.all", args=["foo"]))
@@ -54,51 +74,7 @@ class FeedsTestCase(TestCase, BookmarkFactoryMixin):
reverse("bookmarks:feeds.all", args=[self.token.key]) reverse("bookmarks:feeds.all", args=[self.token.key])
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertFeedItems(response, bookmarks)
self.assertContains(response, "<item>", count=len(bookmarks))
for bookmark in bookmarks:
expected_item = (
"<item>"
f"<title>{bookmark.resolved_title}</title>"
f"<link>{bookmark.url}</link>"
f"<description>{bookmark.resolved_description}</description>"
f"<pubDate>{rfc2822_date(bookmark.date_added)}</pubDate>"
f"<guid>{bookmark.url}</guid>"
"</item>"
)
self.assertContains(response, expected_item, count=1)
def test_all_with_query(self):
tag1 = self.setup_tag()
bookmark1 = self.setup_bookmark()
bookmark2 = self.setup_bookmark(tags=[tag1])
bookmark3 = self.setup_bookmark(tags=[tag1])
self.setup_bookmark()
self.setup_bookmark()
self.setup_bookmark()
feed_url = reverse("bookmarks:feeds.all", args=[self.token.key])
url = feed_url + f"?q={bookmark1.title}"
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<item>", count=1)
self.assertContains(response, f"<guid>{bookmark1.url}</guid>", count=1)
url = feed_url + "?q=" + urllib.parse.quote("#" + tag1.name)
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<item>", count=2)
self.assertContains(response, f"<guid>{bookmark2.url}</guid>", count=1)
self.assertContains(response, f"<guid>{bookmark3.url}</guid>", count=1)
url = feed_url + "?q=" + urllib.parse.quote(f"#{tag1.name} {bookmark2.title}")
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<item>", count=1)
self.assertContains(response, f"<guid>{bookmark2.url}</guid>", count=1)
def test_all_returns_only_user_owned_bookmarks(self): def test_all_returns_only_user_owned_bookmarks(self):
other_user = User.objects.create_user( other_user = User.objects.create_user(
@@ -115,23 +91,6 @@ class FeedsTestCase(TestCase, BookmarkFactoryMixin):
self.assertContains(response, "<item>", count=0) self.assertContains(response, "<item>", count=0)
def test_strip_control_characters(self):
self.setup_bookmark(
title="test\n\r\t\0\x08title", description="test\n\r\t\0\x08description"
)
response = self.client.get(
reverse("bookmarks:feeds.all", args=[self.token.key])
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<item>", count=1)
self.assertContains(response, f"<title>test\n\r\ttitle</title>", count=1)
self.assertContains(
response, f"<description>test\n\r\tdescription</description>", count=1
)
def test_sanitize_with_none_text(self):
self.assertEqual("", sanitize(None))
def test_unread_returns_404_for_unknown_feed_token(self): def test_unread_returns_404_for_unknown_feed_token(self):
response = self.client.get(reverse("bookmarks:feeds.unread", args=["foo"])) response = self.client.get(reverse("bookmarks:feeds.unread", args=["foo"]))
@@ -169,51 +128,7 @@ class FeedsTestCase(TestCase, BookmarkFactoryMixin):
reverse("bookmarks:feeds.unread", args=[self.token.key]) reverse("bookmarks:feeds.unread", args=[self.token.key])
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertFeedItems(response, unread_bookmarks)
self.assertContains(response, "<item>", count=len(unread_bookmarks))
for bookmark in unread_bookmarks:
expected_item = (
"<item>"
f"<title>{bookmark.resolved_title}</title>"
f"<link>{bookmark.url}</link>"
f"<description>{bookmark.resolved_description}</description>"
f"<pubDate>{rfc2822_date(bookmark.date_added)}</pubDate>"
f"<guid>{bookmark.url}</guid>"
"</item>"
)
self.assertContains(response, expected_item, count=1)
def test_unread_with_query(self):
tag1 = self.setup_tag()
bookmark1 = self.setup_bookmark(unread=True)
bookmark2 = self.setup_bookmark(unread=True, tags=[tag1])
bookmark3 = self.setup_bookmark(unread=True, tags=[tag1])
self.setup_bookmark(unread=True)
self.setup_bookmark(unread=True)
self.setup_bookmark(unread=True)
feed_url = reverse("bookmarks:feeds.unread", args=[self.token.key])
url = feed_url + f"?q={bookmark1.title}"
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<item>", count=1)
self.assertContains(response, f"<guid>{bookmark1.url}</guid>", count=1)
url = feed_url + "?q=" + urllib.parse.quote("#" + tag1.name)
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<item>", count=2)
self.assertContains(response, f"<guid>{bookmark2.url}</guid>", count=1)
self.assertContains(response, f"<guid>{bookmark3.url}</guid>", count=1)
url = feed_url + "?q=" + urllib.parse.quote(f"#{tag1.name} {bookmark2.title}")
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<item>", count=1)
self.assertContains(response, f"<guid>{bookmark2.url}</guid>", count=1)
def test_unread_returns_only_user_owned_bookmarks(self): def test_unread_returns_only_user_owned_bookmarks(self):
other_user = User.objects.create_user( other_user = User.objects.create_user(
@@ -265,53 +180,7 @@ class FeedsTestCase(TestCase, BookmarkFactoryMixin):
reverse("bookmarks:feeds.shared", args=[self.token.key]) reverse("bookmarks:feeds.shared", args=[self.token.key])
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertFeedItems(response, shared_bookmarks)
self.assertContains(response, "<item>", count=len(shared_bookmarks))
for bookmark in shared_bookmarks:
expected_item = (
"<item>"
f"<title>{bookmark.resolved_title}</title>"
f"<link>{bookmark.url}</link>"
f"<description>{bookmark.resolved_description}</description>"
f"<pubDate>{rfc2822_date(bookmark.date_added)}</pubDate>"
f"<guid>{bookmark.url}</guid>"
"</item>"
)
self.assertContains(response, expected_item, count=1)
def test_shared_with_query(self):
user = self.setup_user(enable_sharing=True)
tag1 = self.setup_tag(user=user)
bookmark1 = self.setup_bookmark(shared=True, user=user)
bookmark2 = self.setup_bookmark(shared=True, tags=[tag1], user=user)
bookmark3 = self.setup_bookmark(shared=True, tags=[tag1], user=user)
self.setup_bookmark(shared=True, user=user)
self.setup_bookmark(shared=True, user=user)
self.setup_bookmark(shared=True, user=user)
feed_url = reverse("bookmarks:feeds.shared", args=[self.token.key])
url = feed_url + f"?q={bookmark1.title}"
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<item>", count=1)
self.assertContains(response, f"<guid>{bookmark1.url}</guid>", count=1)
url = feed_url + "?q=" + urllib.parse.quote("#" + tag1.name)
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<item>", count=2)
self.assertContains(response, f"<guid>{bookmark2.url}</guid>", count=1)
self.assertContains(response, f"<guid>{bookmark3.url}</guid>", count=1)
url = feed_url + "?q=" + urllib.parse.quote(f"#{tag1.name} {bookmark2.title}")
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<item>", count=1)
self.assertContains(response, f"<guid>{bookmark2.url}</guid>", count=1)
def test_public_shared_does_not_require_auth(self): def test_public_shared_does_not_require_auth(self):
response = self.client.get(reverse("bookmarks:feeds.public_shared")) response = self.client.get(reverse("bookmarks:feeds.public_shared"))
@@ -351,34 +220,19 @@ class FeedsTestCase(TestCase, BookmarkFactoryMixin):
response = self.client.get(reverse("bookmarks:feeds.public_shared")) response = self.client.get(reverse("bookmarks:feeds.public_shared"))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertFeedItems(response, public_shared_bookmarks)
self.assertContains(response, "<item>", count=len(public_shared_bookmarks)) def test_with_query(self):
tag1 = self.setup_tag()
bookmark1 = self.setup_bookmark()
bookmark2 = self.setup_bookmark(tags=[tag1])
bookmark3 = self.setup_bookmark(tags=[tag1])
for bookmark in public_shared_bookmarks: self.setup_bookmark()
expected_item = ( self.setup_bookmark()
"<item>" self.setup_bookmark()
f"<title>{bookmark.resolved_title}</title>"
f"<link>{bookmark.url}</link>"
f"<description>{bookmark.resolved_description}</description>"
f"<pubDate>{rfc2822_date(bookmark.date_added)}</pubDate>"
f"<guid>{bookmark.url}</guid>"
"</item>"
)
self.assertContains(response, expected_item, count=1)
def test_public_shared_with_query(self): feed_url = reverse("bookmarks:feeds.all", args=[self.token.key])
user = self.setup_user(enable_sharing=True, enable_public_sharing=True)
tag1 = self.setup_tag(user=user)
bookmark1 = self.setup_bookmark(shared=True, user=user)
bookmark2 = self.setup_bookmark(shared=True, tags=[tag1], user=user)
bookmark3 = self.setup_bookmark(shared=True, tags=[tag1], user=user)
self.setup_bookmark(shared=True, user=user)
self.setup_bookmark(shared=True, user=user)
self.setup_bookmark(shared=True, user=user)
feed_url = reverse("bookmarks:feeds.shared", args=[self.token.key])
url = feed_url + f"?q={bookmark1.title}" url = feed_url + f"?q={bookmark1.title}"
response = self.client.get(url) response = self.client.get(url)
@@ -398,3 +252,59 @@ class FeedsTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertContains(response, "<item>", count=1) self.assertContains(response, "<item>", count=1)
self.assertContains(response, f"<guid>{bookmark2.url}</guid>", count=1) self.assertContains(response, f"<guid>{bookmark2.url}</guid>", count=1)
def test_with_tags(self):
bookmarks = [
self.setup_bookmark(description="test description"),
self.setup_bookmark(
description="test description",
tags=[self.setup_tag(), self.setup_tag()],
),
]
response = self.client.get(
reverse("bookmarks:feeds.all", args=[self.token.key])
)
self.assertEqual(response.status_code, 200)
self.assertFeedItems(response, bookmarks)
def test_with_limit(self):
self.setup_numbered_bookmarks(200)
# without limit - defaults to 100
response = self.client.get(
reverse("bookmarks:feeds.all", args=[self.token.key])
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<item>", count=100)
# with increased limit
response = self.client.get(
reverse("bookmarks:feeds.all", args=[self.token.key]) + "?limit=200"
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<item>", count=200)
# with decreased limit
response = self.client.get(
reverse("bookmarks:feeds.all", args=[self.token.key]) + "?limit=5"
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<item>", count=5)
def test_strip_control_characters(self):
self.setup_bookmark(
title="test\n\r\t\0\x08title", description="test\n\r\t\0\x08description"
)
response = self.client.get(
reverse("bookmarks:feeds.all", args=[self.token.key])
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<item>", count=1)
self.assertContains(response, f"<title>test\n\r\ttitle</title>", count=1)
self.assertContains(
response, f"<description>test\n\r\tdescription</description>", count=1
)
def test_sanitize_with_none_text(self):
self.assertEqual("", sanitize(None))