Compare commits

...

22 Commits

Author SHA1 Message Date
Sascha Ißbrücker
d39ce076ec Bump version 2021-08-25 10:25:22 +02:00
Chris Cesare
aa0258d3b6 remove duplicate word in README (#136) 2021-08-25 10:20:35 +02:00
Taku Izumi
937858cf58 Fix website scraper decoding content incorrectly (#126)
* Avoid stall on web scraping

This patch fixes stall on web scraping.
I encountered a stall (scraping never ends) when adding
a bookmark of some site.
To avoid this case, adding a timeout parameter at requests.get()
function is a solution.

Signed-off-by: Taku Izumi <admin@orz-style.com>

* Avoid character corruption of scraping some Japanese sites

This patch fixes character corruption of scraping some Japanese
sites. To avoid character corruption, I use r.content instead
of r.text in load_page function.

The reason of character corruption is encoding problem, I think.
r.text handles data as unicode encoded text, so if scraping
web site's charset is not unicode encoded, character corruption
occurs. r.content handles data as str[], we can avoid encoding
problem.

Signed-off-by: Taku Izumi <admin@orz-style.com>

* use charset_normalizer to determine response encoding

Co-authored-by: Taku Izumi <admin@orz-style.com>
Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@googlemail.com>
2021-08-25 10:16:23 +02:00
Sascha Ißbrücker
8047ba6c63 Fix importer not validating bookmark models (#149) 2021-08-25 09:20:01 +02:00
Damanpreet Singh
de903bc341 Add about section in settings (#134)
* About section in settings

* Added about section in settings tab

* fix code style

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@googlemail.com>
2021-08-24 19:47:58 +02:00
Sascha Ißbrücker
c8fcc426b0 Update CHANGELOG.md 2021-08-17 06:07:40 +02:00
Sascha Ißbrücker
eb915210d3 Update CHANGELOG.md 2021-08-17 06:02:07 +02:00
Sascha Ißbrücker
ad9a0f84f2 Bump version 2021-08-17 05:53:25 +02:00
Sascha Ißbrücker
cc04a17e2f Upgrade Django major (#144)
* Bump dependency versions

* Configure default auto field implementation

* fix admin to use token proxy model

* update django docs link
2021-08-17 05:48:45 +02:00
Thomas Bouve
69105d3d3c Support running container as an arbitrary user of the root group (#138)
* Support OpenShift containers.

* Improve comment wording

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@googlemail.com>
2021-08-16 09:16:24 +02:00
Sascha Ißbrücker
c269d16855 Update CHANGELOG.md 2021-08-15 10:47:46 +02:00
Sascha Ißbrücker
90ee3cdb94 Update CHANGELOG.md 2021-08-15 10:46:23 +02:00
Sascha Ißbrücker
2c19266ef8 bump version 2021-08-15 09:52:25 +02:00
ulixxe
048a8b1162 improve tag query performance (#142)
* changed query on tag search for speedup related to issues #112 and #141

* fix tests and only conditionally append tag filter

* add bookmark tags query tests

* reuse bookmark queries for tag queries

* fix tag query test setup

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@googlemail.com>
2021-08-15 09:28:40 +02:00
Sascha Ißbrücker
2fb0bb1224 Exclude tests from coverage 2021-05-14 23:45:32 +02:00
Sascha Ißbrücker
3e48b22095 Add settings view tests 2021-05-14 23:34:53 +02:00
Sascha Ißbrücker
9aa17d0528 Add code coverage script 2021-05-14 12:23:11 +02:00
Sascha Ißbrücker
d643fca98f Add query tests 2021-05-14 10:26:33 +02:00
Sascha Ißbrücker
f293fa15bc Update CI config to install Node dependencies 2021-05-14 02:38:44 +02:00
Sascha Ißbrücker
f58434077b Add bookmark view tests 2021-05-14 02:32:19 +02:00
Sascha Ißbrücker
59641e787c Update CHANGELOG.md 2021-05-13 20:09:53 +02:00
Sascha Ißbrücker
0d36a3bb86 Update CHANGELOG.md 2021-05-13 19:21:21 +02:00
46 changed files with 1460 additions and 98 deletions

3
.coveragerc Normal file
View File

@@ -0,0 +1,3 @@
[run]
source = bookmarks
omit = bookmarks/tests/*

View File

@@ -12,7 +12,13 @@ jobs:
uses: actions/setup-python@v1
with:
python-version: 3.7
- name: Install dependencies
- name: Set up Node
uses: actions/setup-node@v2
with:
node-version: 14
- name: Install Python dependencies
run: pip install -r requirements.txt
- name: Install Node dependencies
run: npm install
- name: Run tests
run: python manage.py test

5
.idea/codeStyles/codeStyleConfig.xml generated Normal file
View File

@@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
</state>
</component>

View File

@@ -1,5 +1,20 @@
# Changelog
## v1.7.0 (17/08/2021)
- Upgrade to Django 3
- Bump other dependencies
---
## v1.6.5 (15/08/2021)
- [**enhancement**] query with multiple hashtags very slow [#112](https://github.com/sissbruecker/linkding/issues/112)
---
## v1.6.4 (13/05/2021)
- Update dependencies for security fixes
---
## v1.6.3 (07/04/2021)
- [**bug**] relative names use the wrong "today" after day change [#107](https://github.com/sissbruecker/linkding/issues/107)

View File

@@ -47,6 +47,8 @@ EXPOSE 9090
# Activate virtual env
ENV VIRTUAL_ENV /opt/venv
ENV PATH /opt/venv/bin:$PATH
# Allow running containers as an an arbitrary user in the root group, to support deployment scenarios like OpenShift, Podman
RUN ["chmod", "g+w", "."]
# Run bootstrap logic
RUN ["chmod", "+x", "./bootstrap.sh"]
CMD ["./bootstrap.sh"]

View File

@@ -88,7 +88,7 @@ If you can not or don't want to use Docker you can install the application manua
### Hosting
The application runs in a web-server called [uWSGI](https://uwsgi-docs.readthedocs.io/en/latest/) that is production-ready and that you can expose to the web. If you don't know how to configure your server to expose the application to the web there are several more steps involved. I can not support support the process here, but I can give some pointers on what to search for:
The application runs in a web-server called [uWSGI](https://uwsgi-docs.readthedocs.io/en/latest/) that is production-ready and that you can expose to the web. If you don't know how to configure your server to expose the application to the web there are several more steps involved. I can not support the process here, but I can give some pointers on what to search for:
- first get the app running (described in this document)
- open the port that the application is running on in your servers firewall
- depending on your network configuration, forward the opened port in your network router, so that the application can be addressed from the internet using your public IP address and the opened port
@@ -126,7 +126,7 @@ Note that any proxy servers that you are running in front of linkding may have t
## Development
The application is open source, so you are free to modify or contribute. The application is built using the Django web framework. You can get started by checking out the excellent Django docs: https://docs.djangoproject.com/en/3.0/. The `bookmarks` folder contains the actual bookmark application, `siteroot` is the Django root application. Other than that the code should be self-explanatory / standard Django stuff 🙂.
The application is open source, so you are free to modify or contribute. The application is built using the Django web framework. You can get started by checking out the excellent Django docs: https://docs.djangoproject.com/en/3.2/. The `bookmarks` folder contains the actual bookmark application, `siteroot` is the Django root application. Other than that the code should be self-explanatory / standard Django stuff 🙂.
### Prerequisites
- Python 3

View File

@@ -5,7 +5,7 @@ from django.contrib.auth.models import User
from django.db.models import Count, QuerySet
from django.utils.translation import ngettext, gettext
from rest_framework.authtoken.admin import TokenAdmin
from rest_framework.authtoken.models import Token
from rest_framework.authtoken.models import TokenProxy
from bookmarks.models import Bookmark, Tag, UserProfile
from bookmarks.services.bookmarks import archive_bookmark, unarchive_bookmark
@@ -97,4 +97,4 @@ linkding_admin_site = LinkdingAdminSite()
linkding_admin_site.register(Bookmark, AdminBookmark)
linkding_admin_site.register(Tag, AdminTag)
linkding_admin_site.register(User, AdminCustomUser)
linkding_admin_site.register(Token, TokenAdmin)
linkding_admin_site.register(TokenProxy, TokenAdmin)

View File

@@ -62,43 +62,17 @@ def _base_bookmarks_query(user: User, query_string: str) -> QuerySet:
def query_bookmark_tags(user: User, query_string: str) -> QuerySet:
return _base_bookmark_tags_query(user, query_string) \
.filter(bookmark__is_archived=False) \
.distinct()
bookmarks_query = query_bookmarks(user, query_string)
query_set = Tag.objects.filter(bookmark__in=bookmarks_query)
return query_set.distinct()
def query_archived_bookmark_tags(user: User, query_string: str) -> QuerySet:
return _base_bookmark_tags_query(user, query_string) \
.filter(bookmark__is_archived=True) \
.distinct()
bookmarks_query = query_archived_bookmarks(user, query_string)
def _base_bookmark_tags_query(user: User, query_string: str) -> QuerySet:
query_set = Tag.objects
# Filter for user
query_set = query_set.filter(owner=user)
# Only show tags which have bookmarks
query_set = query_set.filter(bookmark__isnull=False)
# Split query into search terms and tags
query = _parse_query_string(query_string)
# Filter for search terms and tags
for term in query['search_terms']:
query_set = query_set.filter(
Q(bookmark__title__contains=term)
| Q(bookmark__description__contains=term)
| Q(bookmark__website_title__contains=term)
| Q(bookmark__website_description__contains=term)
| Q(bookmark__url__contains=term)
)
for tag_name in query['tag_names']:
query_set = query_set.filter(
bookmark__tags__name__iexact=tag_name
)
query_set = Tag.objects.filter(bookmark__in=bookmarks_query)
return query_set.distinct()

View File

@@ -3,6 +3,7 @@ from dataclasses import dataclass
from datetime import datetime
from django.contrib.auth.models import User
from django.utils import timezone
from bookmarks.models import Bookmark, parse_tag_string
from bookmarks.services.parser import parse, NetscapeBookmark
@@ -45,7 +46,10 @@ def _import_bookmark_tag(netscape_bookmark: NetscapeBookmark, user: User):
bookmark = _get_or_create_bookmark(netscape_bookmark.href, user)
bookmark.url = netscape_bookmark.href
bookmark.date_added = datetime.utcfromtimestamp(int(netscape_bookmark.date_added)).astimezone()
if netscape_bookmark.date_added:
bookmark.date_added = datetime.utcfromtimestamp(int(netscape_bookmark.date_added)).astimezone()
else:
bookmark.date_added = timezone.now()
bookmark.date_modified = bookmark.date_added
bookmark.unread = False
bookmark.title = netscape_bookmark.title
@@ -53,6 +57,7 @@ def _import_bookmark_tag(netscape_bookmark: NetscapeBookmark, user: User):
bookmark.description = netscape_bookmark.description
bookmark.owner = user
bookmark.full_clean()
bookmark.save()
# Set tags

View File

@@ -1,5 +1,4 @@
from dataclasses import dataclass
from datetime import datetime
import pyparsing as pp
@@ -9,7 +8,7 @@ class NetscapeBookmark:
href: str
title: str
description: str
date_added: int
date_added: str
tag_string: str
@@ -17,8 +16,7 @@ def extract_bookmark_link(tag):
href = tag[0].href
title = tag[0].text
tag_string = tag[0].tags
date_added_string = tag[0].add_date if tag[0].add_date else datetime.now().timestamp()
date_added = int(date_added_string)
date_added = tag[0].add_date
return {
'href': href,

View File

@@ -2,6 +2,7 @@ from dataclasses import dataclass
import requests
from bs4 import BeautifulSoup
from charset_normalizer import from_bytes
@dataclass
@@ -33,5 +34,11 @@ def load_website_metadata(url: str):
def load_page(url: str):
r = requests.get(url)
return r.text
r = requests.get(url, timeout=10)
# Use charset_normalizer to determine encoding that best matches the response content
# Several sites seem to specify the response encoding incorrectly, so we ignore it and use custom logic instead
# This is different from Response.text which does respect the encoding specified in the response first,
# before trying to determine one
results = from_bytes(r.content)
return str(results.best())

View File

@@ -3,7 +3,7 @@
<ul class="bookmark-list">
{% for bookmark in bookmarks %}
<li>
<li data-is-bookmark-item>
<label class="form-checkbox bulk-edit-toggle">
<input type="checkbox" name="bookmark_id" value="{{ bookmark.id }}">
<i class="form-icon"></i>

View File

@@ -7,13 +7,13 @@
{# Highlight first char of first tag in group #}
{% if forloop.counter == 1 %}
<a href="?{% append_query_param q=tag.name|hash_tag %}"
class="mr-2">
class="mr-2" data-is-tag-item>
<span class="highlight-char">{{ tag.name|first_char }}</span><span>{{ tag.name|remaining_chars:1 }}</span>
</a>
{% else %}
{# Render remaining tags normally #}
<a href="?{% append_query_param q=tag.name|hash_tag %}"
class="mr-2">
class="mr-2" data-is-tag-item>
<span>{{ tag.name }}</span>
</a>
{% endif %}

View File

@@ -17,7 +17,7 @@
</div>
<p><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.</p>
<p>If you think that a token was compromised you can revoke (delete) it in the <a href="{% url 'admin:authtoken_token_changelist' %}">admin panel</a>. After deleting the token, a new one will be generated when you reload this settings page.</p>
<p>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.</p>
</section>
</div>

View File

@@ -69,6 +69,15 @@
{% endif %}
</section>
{# About section #}
<section class="content-area">
<h2>About</h2>
<p>Version: {{ app_version }}</p>
<p>
Code: <a href="https://github.com/sissbruecker/linkding/"
target="_blank">GitHub</a>
</p>
</section>
</div>
{% endblock %}

View File

@@ -1,3 +1,6 @@
import random
import logging
from django.contrib.auth.models import User
from django.utils import timezone
from django.utils.crypto import get_random_string
@@ -16,12 +19,29 @@ class BookmarkFactoryMixin:
return self.user
def setup_bookmark(self, is_archived: bool = False, tags: [Tag] = [], user: User = None):
def setup_bookmark(self,
is_archived: bool = False,
tags=None,
user: User = None,
url: str = '',
title: str = '',
description: str = '',
website_title: str = '',
website_description: str = '',
):
if tags is None:
tags = []
if user is None:
user = self.get_or_create_test_user()
unique_id = get_random_string(length=32)
if not url:
unique_id = get_random_string(length=32)
url = 'https://example.com/' + unique_id
bookmark = Bookmark(
url='https://example.com/' + unique_id,
url=url,
title=title,
description=description,
website_title=website_title,
website_description=website_description,
date_added=timezone.now(),
date_modified=timezone.now(),
owner=user,
@@ -33,10 +53,11 @@ class BookmarkFactoryMixin:
bookmark.save()
return bookmark
def setup_tag(self, user: User = None):
def setup_tag(self, user: User = None, name: str = ''):
if user is None:
user = self.get_or_create_test_user()
name = get_random_string(length=32)
if not name:
name = get_random_string(length=32)
tag = Tag(name=name, date_added=timezone.now(), owner=user)
tag.save()
return tag
@@ -62,3 +83,49 @@ class LinkdingApiTestCase(APITestCase):
response = self.client.delete(url)
self.assertEqual(response.status_code, expected_status_code)
return response
_words = [
'quasi',
'consequatur',
'necessitatibus',
'debitis',
'quod',
'vero',
'qui',
'commodi',
'quod',
'odio',
'aliquam',
'veniam',
'architecto',
'consequatur',
'autem',
'qui',
'iste',
'asperiores',
'soluta',
'et',
]
def random_sentence(num_words: int = None, including_word: str = ''):
if num_words is None:
num_words = random.randint(5, 10)
selected_words = random.choices(_words, k=num_words)
if including_word:
selected_words.append(including_word)
random.shuffle(selected_words)
return ' '.join(selected_words)
def disable_logging(f):
def wrapper(*args):
logging.disable(logging.CRITICAL)
result = f(*args)
logging.disable(logging.NOTSET)
return result
return wrapper

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -0,0 +1,20 @@
<!DOCTYPE NETSCAPE-Bookmark-file-1>
<META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=UTF-8">
<TITLE>Bookmarks</TITLE>
<H1>Bookmarks</H1>
<DL><p>
<DT><A HREF="https://example.com/1" ADD_DATE="1616337559" PRIVATE="0" TOREAD="0" TAGS="tag1">test title 1</A>
<DD>test description 1
<DT><A HREF="https://example.com/2" ADD_DATE="1616337559" PRIVATE="0" TOREAD="0" TAGS="tag2">test title 2</A>
<DD>test description 2
<DT><A HREF="https://example.com/3" ADD_DATE="1616337559" PRIVATE="0" TOREAD="0" TAGS="tag3">test title 3</A>
<DD>test description 3
</DL><p>

View File

@@ -0,0 +1,20 @@
<!DOCTYPE NETSCAPE-Bookmark-file-1>
<META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=UTF-8">
<TITLE>Bookmarks</TITLE>
<H1>Bookmarks</H1>
<DL><p>
<DT><A HREF="https://example.com/1" ADD_DATE="invaliddate" PRIVATE="0" TOREAD="0" TAGS="tag1">test title 1</A>
<DD>test description 1
<DT><A HREF="https://example.com/2" ADD_DATE="1616337559" PRIVATE="0" TOREAD="0" TAGS="tag2">test title 2</A>
<DD>test description 2
<DT><A HREF="https://example.com/3" ADD_DATE="1616337559" PRIVATE="0" TOREAD="0" TAGS="tag3">test title 3</A>
<DD>test description 3
</DL><p>

View File

@@ -0,0 +1,35 @@
from django.test import TestCase
from django.urls import reverse
from bookmarks.tests.helpers import BookmarkFactoryMixin
class BookmarkArchiveViewTestCase(TestCase, BookmarkFactoryMixin):
def setUp(self) -> None:
user = self.get_or_create_test_user()
self.client.force_login(user)
def test_should_archive_bookmark(self):
bookmark = self.setup_bookmark()
self.client.get(reverse('bookmarks:archive', args=[bookmark.id]))
bookmark.refresh_from_db()
self.assertTrue(bookmark.is_archived)
def test_should_redirect_to_index(self):
bookmark = self.setup_bookmark()
response = self.client.get(reverse('bookmarks:archive', args=[bookmark.id]))
self.assertRedirects(response, reverse('bookmarks:index'))
def test_should_redirect_to_return_url_when_specified(self):
bookmark = self.setup_bookmark()
response = self.client.get(
reverse('bookmarks:archive', args=[bookmark.id]) + '?return_url=' + reverse('bookmarks:close')
)
self.assertRedirects(response, reverse('bookmarks:close'))

View File

@@ -0,0 +1,132 @@
from django.contrib.auth.models import User
from django.test import TestCase
from django.urls import reverse
from bookmarks.models import Bookmark, Tag
from bookmarks.tests.helpers import BookmarkFactoryMixin
class BookmarkArchivedViewTestCase(TestCase, BookmarkFactoryMixin):
def setUp(self) -> None:
user = self.get_or_create_test_user()
self.client.force_login(user)
def assertVisibleBookmarks(self, response, bookmarks: [Bookmark]):
html = response.content.decode()
self.assertContains(response, 'data-is-bookmark-item', count=len(bookmarks))
for bookmark in bookmarks:
self.assertInHTML(
'<a href="{0}" target="_blank" rel="noopener">{1}</a>'.format(bookmark.url, bookmark.resolved_title),
html
)
def assertInvisibleBookmarks(self, response, bookmarks: [Bookmark]):
html = response.content.decode()
for bookmark in bookmarks:
self.assertInHTML(
'<a href="{0}" target="_blank" rel="noopener">{1}</a>'.format(bookmark.url, bookmark.resolved_title),
html,
count=0
)
def assertVisibleTags(self, response, tags: [Tag]):
self.assertContains(response, 'data-is-tag-item', count=len(tags))
for tag in tags:
self.assertContains(response, tag.name)
def assertInvisibleTags(self, response, tags: [Tag]):
for tag in tags:
self.assertNotContains(response, tag.name)
def test_should_list_archived_and_user_owned_bookmarks(self):
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
visible_bookmarks = [
self.setup_bookmark(is_archived=True),
self.setup_bookmark(is_archived=True),
self.setup_bookmark(is_archived=True)
]
invisible_bookmarks = [
self.setup_bookmark(is_archived=False),
self.setup_bookmark(is_archived=True, user=other_user),
]
response = self.client.get(reverse('bookmarks:archived'))
self.assertContains(response, '<ul class="bookmark-list">') # Should render list
self.assertVisibleBookmarks(response, visible_bookmarks)
self.assertInvisibleBookmarks(response, invisible_bookmarks)
def test_should_list_bookmarks_matching_query(self):
visible_bookmarks = [
self.setup_bookmark(is_archived=True, title='searchvalue'),
self.setup_bookmark(is_archived=True, title='searchvalue'),
self.setup_bookmark(is_archived=True, title='searchvalue')
]
invisible_bookmarks = [
self.setup_bookmark(is_archived=True),
self.setup_bookmark(is_archived=True),
self.setup_bookmark(is_archived=True)
]
response = self.client.get(reverse('bookmarks:archived') + '?q=searchvalue')
self.assertContains(response, '<ul class="bookmark-list">') # Should render list
self.assertVisibleBookmarks(response, visible_bookmarks)
self.assertInvisibleBookmarks(response, invisible_bookmarks)
def test_should_list_tags_for_archived_and_user_owned_bookmarks(self):
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
visible_tags = [
self.setup_tag(),
self.setup_tag(),
self.setup_tag(),
]
invisible_tags = [
self.setup_tag(), # unused tag
self.setup_tag(), # used in archived bookmark
self.setup_tag(user=other_user), # belongs to other user
]
# Assign tags to some bookmarks with duplicates
self.setup_bookmark(is_archived=True, tags=[visible_tags[0]])
self.setup_bookmark(is_archived=True, tags=[visible_tags[0]])
self.setup_bookmark(is_archived=True, tags=[visible_tags[1]])
self.setup_bookmark(is_archived=True, tags=[visible_tags[1]])
self.setup_bookmark(is_archived=True, tags=[visible_tags[2]])
self.setup_bookmark(is_archived=True, tags=[visible_tags[2]])
self.setup_bookmark(is_archived=False, tags=[invisible_tags[1]])
self.setup_bookmark(is_archived=True, tags=[invisible_tags[2]], user=other_user)
response = self.client.get(reverse('bookmarks:archived'))
self.assertVisibleTags(response, visible_tags)
self.assertInvisibleTags(response, invisible_tags)
def test_should_list_tags_for_bookmarks_matching_query(self):
visible_tags = [
self.setup_tag(),
self.setup_tag(),
self.setup_tag(),
]
invisible_tags = [
self.setup_tag(),
self.setup_tag(),
self.setup_tag(),
]
self.setup_bookmark(is_archived=True, tags=[visible_tags[0]], title='searchvalue'),
self.setup_bookmark(is_archived=True, tags=[visible_tags[1]], title='searchvalue')
self.setup_bookmark(is_archived=True, tags=[visible_tags[2]], title='searchvalue')
self.setup_bookmark(is_archived=True, tags=[invisible_tags[0]])
self.setup_bookmark(is_archived=True, tags=[invisible_tags[1]])
self.setup_bookmark(is_archived=True, tags=[invisible_tags[2]])
response = self.client.get(reverse('bookmarks:archived') + '?q=searchvalue')
self.assertVisibleTags(response, visible_tags)
self.assertInvisibleTags(response, invisible_tags)

View File

@@ -6,7 +6,7 @@ from bookmarks.models import Bookmark
from bookmarks.tests.helpers import BookmarkFactoryMixin
class BulkEditIntegrationTests(TestCase, BookmarkFactoryMixin):
class BookmarkBulkEditViewTestCase(TestCase, BookmarkFactoryMixin):
def setUp(self) -> None:
user = self.get_or_create_test_user()

View File

@@ -0,0 +1,97 @@
from django.test import TestCase
from django.urls import reverse
from bookmarks.models import build_tag_string
from bookmarks.tests.helpers import BookmarkFactoryMixin
class BookmarkEditViewTestCase(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 = {
'url': 'http://example.com/edited',
'tag_string': 'editedtag1 editedtag2',
'title': 'edited title',
'description': 'edited description',
'return_url': reverse('bookmarks:index'),
}
return {**form_data, **overrides}
def test_should_edit_bookmark(self):
bookmark = self.setup_bookmark()
form_data = self.create_form_data({'id': bookmark.id})
self.client.post(reverse('bookmarks:edit', args=[bookmark.id]), form_data)
bookmark.refresh_from_db()
self.assertEqual(bookmark.owner, self.user)
self.assertEqual(bookmark.url, form_data['url'])
self.assertEqual(bookmark.title, form_data['title'])
self.assertEqual(bookmark.description, form_data['description'])
self.assertEqual(bookmark.tags.count(), 2)
self.assertEqual(bookmark.tags.all()[0].name, 'editedtag1')
self.assertEqual(bookmark.tags.all()[1].name, 'editedtag2')
def test_should_use_bookmark_index_as_default_return_url(self):
bookmark = self.setup_bookmark()
response = self.client.get(reverse('bookmarks:edit', args=[bookmark.id]))
html = response.content.decode()
self.assertInHTML(
'<input type="hidden" name="return_url" value="{0}" '
'id="id_return_url">'.format(reverse('bookmarks:index')),
html)
def test_should_prefill_bookmark_form_fields(self):
tag1 = self.setup_tag()
tag2 = self.setup_tag()
bookmark = self.setup_bookmark(tags=[tag1, tag2], title='edited title', description='edited description')
response = self.client.get(reverse('bookmarks:edit', args=[bookmark.id]))
html = response.content.decode()
self.assertInHTML('<input type="text" name="url" '
'value="{0}" placeholder=" " '
'autofocus class="form-input" required '
'id="id_url">'.format(bookmark.url),
html)
tag_string = build_tag_string(bookmark.tag_names, ' ')
self.assertInHTML('<input type="text" name="tag_string" '
'value="{0}" autocomplete="off" '
'class="form-input" '
'id="id_tag_string">'.format(tag_string),
html)
self.assertInHTML('<input type="text" name="title" maxlength="512" '
'autocomplete="off" class="form-input" '
'value="{0}" id="id_title">'.format(bookmark.title),
html)
self.assertInHTML('<textarea name="description" cols="40" rows="4" class="form-input" id="id_description">{0}'
'</textarea>'.format(bookmark.description),
html)
def test_should_prefill_return_url_from_url_parameter(self):
bookmark = self.setup_bookmark()
response = self.client.get(reverse('bookmarks:edit', args=[bookmark.id]) + '?return_url=/test-return-url')
html = response.content.decode()
self.assertInHTML('<input type="hidden" name="return_url" value="/test-return-url" id="id_return_url">', html)
def test_should_redirect_to_return_url(self):
bookmark = self.setup_bookmark()
form_data = self.create_form_data({'return_url': reverse('bookmarks:close')})
response = self.client.post(reverse('bookmarks:edit', args=[bookmark.id]), form_data)
self.assertRedirects(response, form_data['return_url'])

View File

@@ -0,0 +1,132 @@
from django.contrib.auth.models import User
from django.test import TestCase
from django.urls import reverse
from bookmarks.models import Bookmark, Tag
from bookmarks.tests.helpers import BookmarkFactoryMixin
class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin):
def setUp(self) -> None:
user = self.get_or_create_test_user()
self.client.force_login(user)
def assertVisibleBookmarks(self, response, bookmarks: [Bookmark]):
html = response.content.decode()
self.assertContains(response, 'data-is-bookmark-item', count=len(bookmarks))
for bookmark in bookmarks:
self.assertInHTML(
'<a href="{0}" target="_blank" rel="noopener">{1}</a>'.format(bookmark.url, bookmark.resolved_title),
html
)
def assertInvisibleBookmarks(self, response, bookmarks: [Bookmark]):
html = response.content.decode()
for bookmark in bookmarks:
self.assertInHTML(
'<a href="{0}" target="_blank" rel="noopener">{1}</a>'.format(bookmark.url, bookmark.resolved_title),
html,
count=0
)
def assertVisibleTags(self, response, tags: [Tag]):
self.assertContains(response, 'data-is-tag-item', count=len(tags))
for tag in tags:
self.assertContains(response, tag.name)
def assertInvisibleTags(self, response, tags: [Tag]):
for tag in tags:
self.assertNotContains(response, tag.name)
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_bookmark(),
self.setup_bookmark(),
self.setup_bookmark()
]
invisible_bookmarks = [
self.setup_bookmark(is_archived=True),
self.setup_bookmark(user=other_user),
]
response = self.client.get(reverse('bookmarks:index'))
self.assertContains(response, '<ul class="bookmark-list">') # Should render list
self.assertVisibleBookmarks(response, visible_bookmarks)
self.assertInvisibleBookmarks(response, invisible_bookmarks)
def test_should_list_bookmarks_matching_query(self):
visible_bookmarks = [
self.setup_bookmark(title='searchvalue'),
self.setup_bookmark(title='searchvalue'),
self.setup_bookmark(title='searchvalue')
]
invisible_bookmarks = [
self.setup_bookmark(),
self.setup_bookmark(),
self.setup_bookmark()
]
response = self.client.get(reverse('bookmarks:index') + '?q=searchvalue')
self.assertContains(response, '<ul class="bookmark-list">') # Should render list
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_tags = [
self.setup_tag(),
self.setup_tag(),
self.setup_tag(),
]
invisible_tags = [
self.setup_tag(), # unused tag
self.setup_tag(), # used in archived bookmark
self.setup_tag(user=other_user), # belongs to other user
]
# Assign tags to some bookmarks with duplicates
self.setup_bookmark(tags=[visible_tags[0]])
self.setup_bookmark(tags=[visible_tags[0]])
self.setup_bookmark(tags=[visible_tags[1]])
self.setup_bookmark(tags=[visible_tags[1]])
self.setup_bookmark(tags=[visible_tags[2]])
self.setup_bookmark(tags=[visible_tags[2]])
self.setup_bookmark(tags=[invisible_tags[1]], is_archived=True)
self.setup_bookmark(tags=[invisible_tags[2]], user=other_user)
response = self.client.get(reverse('bookmarks:index'))
self.assertVisibleTags(response, visible_tags)
self.assertInvisibleTags(response, invisible_tags)
def test_should_list_tags_for_bookmarks_matching_query(self):
visible_tags = [
self.setup_tag(),
self.setup_tag(),
self.setup_tag(),
]
invisible_tags = [
self.setup_tag(),
self.setup_tag(),
self.setup_tag(),
]
self.setup_bookmark(tags=[visible_tags[0]], title='searchvalue'),
self.setup_bookmark(tags=[visible_tags[1]], title='searchvalue')
self.setup_bookmark(tags=[visible_tags[2]], title='searchvalue')
self.setup_bookmark(tags=[invisible_tags[0]])
self.setup_bookmark(tags=[invisible_tags[1]])
self.setup_bookmark(tags=[invisible_tags[2]])
response = self.client.get(reverse('bookmarks:index') + '?q=searchvalue')
self.assertVisibleTags(response, visible_tags)
self.assertInvisibleTags(response, invisible_tags)

View File

@@ -0,0 +1,81 @@
from django.test import TestCase
from django.urls import reverse
from bookmarks.models import Bookmark
from bookmarks.tests.helpers import BookmarkFactoryMixin
class BookmarkNewViewTestCase(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 = {
'url': 'http://example.com',
'tag_string': 'tag1 tag2',
'title': 'test title',
'description': 'test description',
'auto_close': '',
}
return {**form_data, **overrides}
def test_should_create_new_bookmark(self):
form_data = self.create_form_data()
self.client.post(reverse('bookmarks:new'), form_data)
self.assertEqual(Bookmark.objects.count(), 1)
bookmark = Bookmark.objects.first()
self.assertEqual(bookmark.owner, self.user)
self.assertEqual(bookmark.url, form_data['url'])
self.assertEqual(bookmark.title, form_data['title'])
self.assertEqual(bookmark.description, form_data['description'])
self.assertEqual(bookmark.tags.count(), 2)
self.assertEqual(bookmark.tags.all()[0].name, 'tag1')
self.assertEqual(bookmark.tags.all()[1].name, 'tag2')
def test_should_prefill_url_from_url_parameter(self):
response = self.client.get(reverse('bookmarks:new') + '?url=http://example.com')
html = response.content.decode()
self.assertInHTML(
'<input type="text" name="url" value="http://example.com" '
'placeholder=" " autofocus class="form-input" required '
'id="id_url">',
html)
def test_should_enable_auto_close_when_specified_in_url_parameter(self):
response = self.client.get(
reverse('bookmarks:new') + '?auto_close')
html = response.content.decode()
self.assertInHTML(
'<input type="hidden" name="auto_close" value="true" '
'id="id_auto_close">',
html)
def test_should_not_enable_auto_close_when_not_specified_in_url_parameter(
self):
response = self.client.get(reverse('bookmarks:new'))
html = response.content.decode()
self.assertInHTML('<input type="hidden" name="auto_close" id="id_auto_close">',html)
def test_should_redirect_to_index_view(self):
form_data = self.create_form_data()
response = self.client.post(reverse('bookmarks:new'), form_data)
self.assertRedirects(response, reverse('bookmarks:index'))
def test_auto_close_should_redirect_to_close_view(self):
form_data = self.create_form_data({'auto_close': 'true'})
response = self.client.post(reverse('bookmarks:new'), form_data)
self.assertRedirects(response, reverse('bookmarks:close'))

View File

@@ -0,0 +1,35 @@
from django.test import TestCase
from django.urls import reverse
from bookmarks.models import Bookmark
from bookmarks.tests.helpers import BookmarkFactoryMixin
class BookmarkRemoveViewTestCase(TestCase, BookmarkFactoryMixin):
def setUp(self) -> None:
user = self.get_or_create_test_user()
self.client.force_login(user)
def test_should_delete_bookmark(self):
bookmark = self.setup_bookmark()
self.client.get(reverse('bookmarks:remove', args=[bookmark.id]))
self.assertEqual(Bookmark.objects.count(), 0)
def test_should_redirect_to_index(self):
bookmark = self.setup_bookmark()
response = self.client.get(reverse('bookmarks:remove', args=[bookmark.id]))
self.assertRedirects(response, reverse('bookmarks:index'))
def test_should_redirect_to_return_url_when_specified(self):
bookmark = self.setup_bookmark()
response = self.client.get(
reverse('bookmarks:remove', args=[bookmark.id]) + '?return_url=' + reverse('bookmarks:close')
)
self.assertRedirects(response, reverse('bookmarks:close'))

View File

@@ -0,0 +1,35 @@
from django.test import TestCase
from django.urls import reverse
from bookmarks.tests.helpers import BookmarkFactoryMixin
class BookmarkUnarchiveViewTestCase(TestCase, BookmarkFactoryMixin):
def setUp(self) -> None:
user = self.get_or_create_test_user()
self.client.force_login(user)
def test_should_unarchive_bookmark(self):
bookmark = self.setup_bookmark(is_archived=True)
self.client.get(reverse('bookmarks:unarchive', args=[bookmark.id]))
bookmark.refresh_from_db()
self.assertFalse(bookmark.is_archived)
def test_should_redirect_to_archive(self):
bookmark = self.setup_bookmark()
response = self.client.get(reverse('bookmarks:unarchive', args=[bookmark.id]))
self.assertRedirects(response, reverse('bookmarks:archived'))
def test_should_redirect_to_return_url_when_specified(self):
bookmark = self.setup_bookmark()
response = self.client.get(
reverse('bookmarks:unarchive', args=[bookmark.id]) + '?return_url=' + reverse('bookmarks:close')
)
self.assertRedirects(response, reverse('bookmarks:close'))

View File

@@ -34,6 +34,27 @@ class BookmarkValidationTestCase(TestCase):
def setUp(self) -> None:
self.user = User.objects.create_user('testuser', 'test@example.com', 'password123')
def test_bookmark_model_should_not_allow_missing_url(self):
bookmark = Bookmark(
date_added=datetime.datetime.now(),
date_modified=datetime.datetime.now(),
owner=self.user
)
with self.assertRaises(ValidationError):
bookmark.full_clean()
def test_bookmark_model_should_not_allow_empty_url(self):
bookmark = Bookmark(
url='',
date_added=datetime.datetime.now(),
date_modified=datetime.datetime.now(),
owner=self.user
)
with self.assertRaises(ValidationError):
bookmark.full_clean()
@override_settings(LD_DISABLE_URL_VALIDATION=False)
def test_bookmark_model_should_validate_url_if_not_disabled_in_settings(self):
self._run_bookmark_model_url_validity_checks(ENABLED_URL_VALIDATION_TEST_CASES)

View File

@@ -4,8 +4,8 @@ from django.template import Template, RequestContext
from django.test import TestCase, RequestFactory
from django.utils import timezone, formats
from bookmarks.tests.helpers import BookmarkFactoryMixin
from bookmarks.models import UserProfile
from bookmarks.tests.helpers import BookmarkFactoryMixin
class BookmarkListTagTest(TestCase, BookmarkFactoryMixin):

View File

@@ -0,0 +1,33 @@
from django.test import TestCase
from bookmarks.services.importer import import_netscape_html
from bookmarks.tests.helpers import BookmarkFactoryMixin, disable_logging
class ImporterTestCase(TestCase, BookmarkFactoryMixin):
def create_import_html(self, bookmark_tags_string: str):
return f'''
<!DOCTYPE NETSCAPE-Bookmark-file-1>
<META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=UTF-8">
<TITLE>Bookmarks</TITLE>
<H1>Bookmarks</H1>
<DL><p>
{bookmark_tags_string}
</DL><p>
'''
@disable_logging
def test_validate_empty_or_missing_bookmark_url(self):
test_html = self.create_import_html(f'''
<!-- Empty URL -->
<DT><A HREF="" ADD_DATE="1616337559" PRIVATE="0" TOREAD="0" TAGS="tag3">Empty URL</A>
<DD>Empty URL
<!-- Missing URL -->
<DT><A ADD_DATE="1616337559" PRIVATE="0" TOREAD="0" TAGS="tag3">Missing URL</A>
<DD>Missing URL
''')
import_result = import_netscape_html(test_html, self.get_or_create_test_user())
self.assertEqual(import_result.success, 0)

View File

@@ -1,6 +1,6 @@
from django.core.paginator import Paginator
from django.test import SimpleTestCase, RequestFactory
from django.template import Template, RequestContext
from django.test import SimpleTestCase, RequestFactory
class PaginationTagTest(SimpleTestCase):

View File

@@ -1,14 +1,223 @@
import operator
from django.contrib.auth import get_user_model
from django.db.models import QuerySet
from django.test import TestCase
from bookmarks import queries
from bookmarks.tests.helpers import BookmarkFactoryMixin
from bookmarks.models import Bookmark
from bookmarks.tests.helpers import BookmarkFactoryMixin, random_sentence
from bookmarks.utils import unique
User = get_user_model()
class QueriesTestCase(TestCase, BookmarkFactoryMixin):
def setup_bookmark_search_data(self) -> None:
tag1 = self.setup_tag(name='tag1')
tag2 = self.setup_tag(name='tag2')
self.setup_tag(name='unused_tag1')
self.other_bookmarks = [
self.setup_bookmark(),
self.setup_bookmark(),
self.setup_bookmark(),
]
self.term1_bookmarks = [
self.setup_bookmark(url='http://example.com/term1'),
self.setup_bookmark(title=random_sentence(including_word='term1')),
self.setup_bookmark(description=random_sentence(including_word='term1')),
self.setup_bookmark(website_title=random_sentence(including_word='term1')),
self.setup_bookmark(website_description=random_sentence(including_word='term1')),
]
self.term1_term2_bookmarks = [
self.setup_bookmark(url='http://example.com/term1/term2'),
self.setup_bookmark(title=random_sentence(including_word='term1'),
description=random_sentence(including_word='term2')),
self.setup_bookmark(description=random_sentence(including_word='term1'),
title=random_sentence(including_word='term2')),
self.setup_bookmark(website_title=random_sentence(including_word='term1'),
title=random_sentence(including_word='term2')),
self.setup_bookmark(website_description=random_sentence(including_word='term1'),
title=random_sentence(including_word='term2')),
]
self.tag1_bookmarks = [
self.setup_bookmark(tags=[tag1]),
self.setup_bookmark(title=random_sentence(), tags=[tag1]),
self.setup_bookmark(description=random_sentence(), tags=[tag1]),
self.setup_bookmark(website_title=random_sentence(), tags=[tag1]),
self.setup_bookmark(website_description=random_sentence(), tags=[tag1]),
]
self.term1_tag1_bookmarks = [
self.setup_bookmark(url='http://example.com/term1', tags=[tag1]),
self.setup_bookmark(title=random_sentence(including_word='term1'), tags=[tag1]),
self.setup_bookmark(description=random_sentence(including_word='term1'), tags=[tag1]),
self.setup_bookmark(website_title=random_sentence(including_word='term1'), tags=[tag1]),
self.setup_bookmark(website_description=random_sentence(including_word='term1'), tags=[tag1]),
]
self.tag2_bookmarks = [
self.setup_bookmark(tags=[tag2]),
]
self.tag1_tag2_bookmarks = [
self.setup_bookmark(tags=[tag1, tag2]),
]
def setup_tag_search_data(self):
tag1 = self.setup_tag(name='tag1')
tag2 = self.setup_tag(name='tag2')
self.setup_tag(name='unused_tag1')
self.other_bookmarks = [
self.setup_bookmark(tags=[self.setup_tag()]),
self.setup_bookmark(tags=[self.setup_tag()]),
self.setup_bookmark(tags=[self.setup_tag()]),
]
self.term1_bookmarks = [
self.setup_bookmark(url='http://example.com/term1', tags=[self.setup_tag()]),
self.setup_bookmark(title=random_sentence(including_word='term1'), tags=[self.setup_tag()]),
self.setup_bookmark(description=random_sentence(including_word='term1'), tags=[self.setup_tag()]),
self.setup_bookmark(website_title=random_sentence(including_word='term1'), tags=[self.setup_tag()]),
self.setup_bookmark(website_description=random_sentence(including_word='term1'), tags=[self.setup_tag()]),
]
self.term1_term2_bookmarks = [
self.setup_bookmark(url='http://example.com/term1/term2', tags=[self.setup_tag()]),
self.setup_bookmark(title=random_sentence(including_word='term1'),
description=random_sentence(including_word='term2'),
tags=[self.setup_tag()]),
self.setup_bookmark(description=random_sentence(including_word='term1'),
title=random_sentence(including_word='term2'),
tags=[self.setup_tag()]),
self.setup_bookmark(website_title=random_sentence(including_word='term1'),
title=random_sentence(including_word='term2'),
tags=[self.setup_tag()]),
self.setup_bookmark(website_description=random_sentence(including_word='term1'),
title=random_sentence(including_word='term2'),
tags=[self.setup_tag()]),
]
self.tag1_bookmarks = [
self.setup_bookmark(tags=[tag1, self.setup_tag()]),
self.setup_bookmark(title=random_sentence(), tags=[tag1, self.setup_tag()]),
self.setup_bookmark(description=random_sentence(), tags=[tag1, self.setup_tag()]),
self.setup_bookmark(website_title=random_sentence(), tags=[tag1, self.setup_tag()]),
self.setup_bookmark(website_description=random_sentence(), tags=[tag1, self.setup_tag()]),
]
self.term1_tag1_bookmarks = [
self.setup_bookmark(url='http://example.com/term1', tags=[tag1, self.setup_tag()]),
self.setup_bookmark(title=random_sentence(including_word='term1'), tags=[tag1, self.setup_tag()]),
self.setup_bookmark(description=random_sentence(including_word='term1'), tags=[tag1, self.setup_tag()]),
self.setup_bookmark(website_title=random_sentence(including_word='term1'), tags=[tag1, self.setup_tag()]),
self.setup_bookmark(website_description=random_sentence(including_word='term1'),
tags=[tag1, self.setup_tag()]),
]
self.tag2_bookmarks = [
self.setup_bookmark(tags=[tag2, self.setup_tag()]),
]
self.tag1_tag2_bookmarks = [
self.setup_bookmark(tags=[tag1, tag2, self.setup_tag()]),
]
def get_tags_from_bookmarks(self, bookmarks: [Bookmark]):
all_tags = []
for bookmark in bookmarks:
all_tags = all_tags + list(bookmark.tags.all())
return all_tags
def assertQueryResult(self, query: QuerySet, item_lists: [[any]]):
expected_items = []
for item_list in item_lists:
expected_items = expected_items + item_list
expected_items = unique(expected_items, operator.attrgetter('id'))
self.assertCountEqual(list(query), expected_items)
def test_query_bookmarks_should_return_all_for_empty_query(self):
self.setup_bookmark_search_data()
query = queries.query_bookmarks(self.get_or_create_test_user(), '')
self.assertQueryResult(query, [
self.other_bookmarks,
self.term1_bookmarks,
self.term1_term2_bookmarks,
self.tag1_bookmarks,
self.term1_tag1_bookmarks,
self.tag2_bookmarks,
self.tag1_tag2_bookmarks
])
def test_query_bookmarks_should_search_single_term(self):
self.setup_bookmark_search_data()
query = queries.query_bookmarks(self.get_or_create_test_user(), 'term1')
self.assertQueryResult(query, [
self.term1_bookmarks,
self.term1_term2_bookmarks,
self.term1_tag1_bookmarks
])
def test_query_bookmarks_should_search_multiple_terms(self):
self.setup_bookmark_search_data()
query = queries.query_bookmarks(self.get_or_create_test_user(), 'term2 term1')
self.assertQueryResult(query, [self.term1_term2_bookmarks])
def test_query_bookmarks_should_search_single_tag(self):
self.setup_bookmark_search_data()
query = queries.query_bookmarks(self.get_or_create_test_user(), '#tag1')
self.assertQueryResult(query, [self.tag1_bookmarks, self.tag1_tag2_bookmarks, self.term1_tag1_bookmarks])
def test_query_bookmarks_should_search_multiple_tags(self):
self.setup_bookmark_search_data()
query = queries.query_bookmarks(self.get_or_create_test_user(), '#tag1 #tag2')
self.assertQueryResult(query, [self.tag1_tag2_bookmarks])
def test_query_bookmarks_should_search_multiple_tags_ignoring_casing(self):
self.setup_bookmark_search_data()
query = queries.query_bookmarks(self.get_or_create_test_user(), '#Tag1 #TAG2')
self.assertQueryResult(query, [self.tag1_tag2_bookmarks])
def test_query_bookmarks_should_search_terms_and_tags_combined(self):
self.setup_bookmark_search_data()
query = queries.query_bookmarks(self.get_or_create_test_user(), 'term1 #tag1')
self.assertQueryResult(query, [self.term1_tag1_bookmarks])
def test_query_bookmarks_should_return_no_matches(self):
self.setup_bookmark_search_data()
query = queries.query_bookmarks(self.get_or_create_test_user(), 'term3')
self.assertQueryResult(query, [])
query = queries.query_bookmarks(self.get_or_create_test_user(), 'term1 term3')
self.assertQueryResult(query, [])
query = queries.query_bookmarks(self.get_or_create_test_user(), 'term1 #tag2')
self.assertQueryResult(query, [])
query = queries.query_bookmarks(self.get_or_create_test_user(), '#tag3')
self.assertQueryResult(query, [])
# Unused tag
query = queries.query_bookmarks(self.get_or_create_test_user(), '#unused_tag1')
self.assertQueryResult(query, [])
# Unused tag combined with tag that is used
query = queries.query_bookmarks(self.get_or_create_test_user(), '#tag1 #unused_tag1')
self.assertQueryResult(query, [])
# Unused tag combined with term that is used
query = queries.query_bookmarks(self.get_or_create_test_user(), 'term1 #unused_tag1')
self.assertQueryResult(query, [])
def test_query_bookmarks_should_not_return_archived_bookmarks(self):
bookmark1 = self.setup_bookmark()
bookmark2 = self.setup_bookmark()
@@ -18,7 +227,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
query = queries.query_bookmarks(self.get_or_create_test_user(), '')
self.assertCountEqual([bookmark1, bookmark2], list(query))
self.assertQueryResult(query, [[bookmark1, bookmark2]])
def test_query_archived_bookmarks_should_not_return_unarchived_bookmarks(self):
bookmark1 = self.setup_bookmark(is_archived=True)
@@ -29,7 +238,156 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
query = queries.query_archived_bookmarks(self.get_or_create_test_user(), '')
self.assertCountEqual([bookmark1, bookmark2], list(query))
self.assertQueryResult(query, [[bookmark1, bookmark2]])
def test_query_bookmarks_should_only_return_user_owned_bookmarks(self):
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
owned_bookmarks = [
self.setup_bookmark(),
self.setup_bookmark(),
self.setup_bookmark(),
]
self.setup_bookmark(user=other_user)
self.setup_bookmark(user=other_user)
self.setup_bookmark(user=other_user)
query = queries.query_bookmarks(self.user, '')
self.assertQueryResult(query, [owned_bookmarks])
def test_query_archived_bookmarks_should_only_return_user_owned_bookmarks(self):
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
owned_bookmarks = [
self.setup_bookmark(is_archived=True),
self.setup_bookmark(is_archived=True),
self.setup_bookmark(is_archived=True),
]
self.setup_bookmark(is_archived=True, user=other_user)
self.setup_bookmark(is_archived=True, user=other_user)
self.setup_bookmark(is_archived=True, user=other_user)
query = queries.query_archived_bookmarks(self.user, '')
self.assertQueryResult(query, [owned_bookmarks])
def test_query_bookmarks_should_use_tag_projection(self):
self.setup_bookmark_search_data()
# Test projection on bookmarks with tags
query = queries.query_bookmarks(self.user, '#tag1 #tag2')
for bookmark in query:
self.assertEqual(bookmark.tag_count, 2)
self.assertEqual(bookmark.tag_string, 'tag1,tag2')
self.assertTrue(bookmark.tag_projection)
# Test projection on bookmarks without tags
query = queries.query_bookmarks(self.user, 'term2')
for bookmark in query:
self.assertEqual(bookmark.tag_count, 0)
self.assertEqual(bookmark.tag_string, None)
self.assertTrue(bookmark.tag_projection)
def test_query_bookmark_tags_should_return_all_tags_for_empty_query(self):
self.setup_tag_search_data()
query = queries.query_bookmark_tags(self.user, '')
self.assertQueryResult(query, [
self.get_tags_from_bookmarks(self.other_bookmarks),
self.get_tags_from_bookmarks(self.term1_bookmarks),
self.get_tags_from_bookmarks(self.term1_term2_bookmarks),
self.get_tags_from_bookmarks(self.tag1_bookmarks),
self.get_tags_from_bookmarks(self.term1_tag1_bookmarks),
self.get_tags_from_bookmarks(self.tag2_bookmarks),
self.get_tags_from_bookmarks(self.tag1_tag2_bookmarks),
])
def test_query_bookmark_tags_should_search_single_term(self):
self.setup_tag_search_data()
query = queries.query_bookmark_tags(self.user, 'term1')
self.assertQueryResult(query, [
self.get_tags_from_bookmarks(self.term1_bookmarks),
self.get_tags_from_bookmarks(self.term1_term2_bookmarks),
self.get_tags_from_bookmarks(self.term1_tag1_bookmarks),
])
def test_query_bookmark_tags_should_search_multiple_terms(self):
self.setup_tag_search_data()
query = queries.query_bookmark_tags(self.user, 'term2 term1')
self.assertQueryResult(query, [
self.get_tags_from_bookmarks(self.term1_term2_bookmarks),
])
def test_query_bookmark_tags_should_search_single_tag(self):
self.setup_tag_search_data()
query = queries.query_bookmark_tags(self.user, '#tag1')
self.assertQueryResult(query, [
self.get_tags_from_bookmarks(self.tag1_bookmarks),
self.get_tags_from_bookmarks(self.term1_tag1_bookmarks),
self.get_tags_from_bookmarks(self.tag1_tag2_bookmarks),
])
def test_query_bookmark_tags_should_search_multiple_tags(self):
self.setup_tag_search_data()
query = queries.query_bookmark_tags(self.user, '#tag1 #tag2')
self.assertQueryResult(query, [
self.get_tags_from_bookmarks(self.tag1_tag2_bookmarks),
])
def test_query_bookmark_tags_should_search_multiple_tags_ignoring_casing(self):
self.setup_tag_search_data()
query = queries.query_bookmark_tags(self.user, '#Tag1 #TAG2')
self.assertQueryResult(query, [
self.get_tags_from_bookmarks(self.tag1_tag2_bookmarks),
])
def test_query_bookmark_tags_should_search_term_and_tag_combined(self):
self.setup_tag_search_data()
query = queries.query_bookmark_tags(self.user, 'term1 #tag1')
self.assertQueryResult(query, [
self.get_tags_from_bookmarks(self.term1_tag1_bookmarks),
])
def test_query_bookmark_tags_should_return_no_matches(self):
self.setup_tag_search_data()
query = queries.query_bookmark_tags(self.get_or_create_test_user(), 'term3')
self.assertQueryResult(query, [])
query = queries.query_bookmark_tags(self.get_or_create_test_user(), 'term1 term3')
self.assertQueryResult(query, [])
query = queries.query_bookmark_tags(self.get_or_create_test_user(), 'term1 #tag2')
self.assertQueryResult(query, [])
query = queries.query_bookmark_tags(self.get_or_create_test_user(), '#tag3')
self.assertQueryResult(query, [])
# Unused tag
query = queries.query_bookmark_tags(self.get_or_create_test_user(), '#unused_tag1')
self.assertQueryResult(query, [])
# Unused tag combined with tag that is used
query = queries.query_bookmark_tags(self.get_or_create_test_user(), '#tag1 #unused_tag1')
self.assertQueryResult(query, [])
# Unused tag combined with term that is used
query = queries.query_bookmark_tags(self.get_or_create_test_user(), 'term1 #unused_tag1')
self.assertQueryResult(query, [])
def test_query_bookmark_tags_should_return_tags_for_unarchived_bookmarks_only(self):
tag1 = self.setup_tag()
@@ -40,7 +398,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
query = queries.query_bookmark_tags(self.get_or_create_test_user(), '')
self.assertCountEqual([tag1], list(query))
self.assertQueryResult(query, [[tag1]])
def test_query_bookmark_tags_should_return_distinct_tags(self):
tag = self.setup_tag()
@@ -50,7 +408,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
query = queries.query_bookmark_tags(self.get_or_create_test_user(), '')
self.assertCountEqual([tag], list(query))
self.assertQueryResult(query, [[tag]])
def test_query_archived_bookmark_tags_should_return_tags_for_archived_bookmarks_only(self):
tag1 = self.setup_tag()
@@ -61,7 +419,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
query = queries.query_archived_bookmark_tags(self.get_or_create_test_user(), '')
self.assertCountEqual([tag2], list(query))
self.assertQueryResult(query, [[tag2]])
def test_query_archived_bookmark_tags_should_return_distinct_tags(self):
tag = self.setup_tag()
@@ -71,4 +429,34 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
query = queries.query_archived_bookmark_tags(self.get_or_create_test_user(), '')
self.assertCountEqual([tag], list(query))
self.assertQueryResult(query, [[tag]])
def test_query_bookmark_tags_should_only_return_user_owned_tags(self):
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
owned_bookmarks = [
self.setup_bookmark(tags=[self.setup_tag()]),
self.setup_bookmark(tags=[self.setup_tag()]),
self.setup_bookmark(tags=[self.setup_tag()]),
]
self.setup_bookmark(user=other_user, tags=[self.setup_tag(user=other_user)])
self.setup_bookmark(user=other_user, tags=[self.setup_tag(user=other_user)])
self.setup_bookmark(user=other_user, tags=[self.setup_tag(user=other_user)])
query = queries.query_bookmark_tags(self.user, '')
self.assertQueryResult(query, [self.get_tags_from_bookmarks(owned_bookmarks)])
def test_query_archived_bookmark_tags_should_only_return_user_owned_tags(self):
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
owned_bookmarks = [
self.setup_bookmark(is_archived=True, tags=[self.setup_tag()]),
self.setup_bookmark(is_archived=True, tags=[self.setup_tag()]),
self.setup_bookmark(is_archived=True, tags=[self.setup_tag()]),
]
self.setup_bookmark(is_archived=True, user=other_user, tags=[self.setup_tag(user=other_user)])
self.setup_bookmark(is_archived=True, user=other_user, tags=[self.setup_tag(user=other_user)])
self.setup_bookmark(is_archived=True, user=other_user, tags=[self.setup_tag(user=other_user)])
query = queries.query_archived_bookmark_tags(self.user, '')
self.assertQueryResult(query, [self.get_tags_from_bookmarks(owned_bookmarks)])

View File

@@ -0,0 +1,40 @@
from django.test import TestCase
from django.urls import reverse
from rest_framework.authtoken.models import Token
from bookmarks.tests.helpers import BookmarkFactoryMixin
class SettingsApiViewTestCase(TestCase, BookmarkFactoryMixin):
def setUp(self) -> None:
user = self.get_or_create_test_user()
self.client.force_login(user)
def test_should_render_successfully(self):
response = self.client.get(reverse('bookmarks:settings.api'))
self.assertEqual(response.status_code, 200)
def test_should_check_authentication(self):
self.client.logout()
response = self.client.get(reverse('bookmarks:settings.api'), follow=True)
self.assertRedirects(response, reverse('login') + '?next=' + reverse('bookmarks:settings.api'))
def test_should_generate_api_token_if_not_exists(self):
self.assertEqual(Token.objects.count(), 0)
self.client.get(reverse('bookmarks:settings.api'))
self.assertEqual(Token.objects.count(), 1)
token = Token.objects.first()
self.assertEqual(token.user, self.user)
def test_should_not_generate_api_token_if_exists(self):
Token.objects.get_or_create(user=self.user)
self.assertEqual(Token.objects.count(), 1)
self.client.get(reverse('bookmarks:settings.api'))
self.assertEqual(Token.objects.count(), 1)

View File

@@ -0,0 +1,45 @@
from unittest.mock import patch
from django.test import TestCase
from django.urls import reverse
from bookmarks.tests.helpers import BookmarkFactoryMixin
class SettingsExportViewTestCase(TestCase, BookmarkFactoryMixin):
def setUp(self) -> None:
user = self.get_or_create_test_user()
self.client.force_login(user)
def assertFormErrorHint(self, response, text: str):
self.assertContains(response, '<div class="has-error">')
self.assertContains(response, text)
def test_should_export_successfully(self):
self.setup_bookmark(tags=[self.setup_tag()])
self.setup_bookmark(tags=[self.setup_tag()])
self.setup_bookmark(tags=[self.setup_tag()])
response = self.client.get(
reverse('bookmarks:settings.export'),
follow=True
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response['content-type'], 'text/plain; charset=UTF-8')
self.assertEqual(response['Content-Disposition'], 'attachment; filename="bookmarks.html"')
def test_should_check_authentication(self):
self.client.logout()
response = self.client.get(reverse('bookmarks:settings.export'), follow=True)
self.assertRedirects(response, reverse('login') + '?next=' + reverse('bookmarks:settings.export'))
def test_should_show_hint_when_export_raises_error(self):
with patch('bookmarks.services.exporter.export_netscape_html') as mock_export_netscape_html:
mock_export_netscape_html.side_effect = Exception('Nope')
response = self.client.get(reverse('bookmarks:settings.export'), follow=True)
self.assertTemplateUsed(response, 'settings/general.html')
self.assertFormErrorHint(response, 'An error occurred during bookmark export.')

View File

@@ -0,0 +1,36 @@
from django.test import TestCase
from django.urls import reverse
from bookmarks.tests.helpers import BookmarkFactoryMixin
from bookmarks.models import UserProfile
class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
def setUp(self) -> None:
user = self.get_or_create_test_user()
self.client.force_login(user)
def test_should_render_successfully(self):
response = self.client.get(reverse('bookmarks:settings.general'))
self.assertEqual(response.status_code, 200)
def test_should_check_authentication(self):
self.client.logout()
response = self.client.get(reverse('bookmarks:settings.general'), follow=True)
self.assertRedirects(response, reverse('login') + '?next=' + reverse('bookmarks:settings.general'))
def test_should_save_profile(self):
form_data = {
'theme': UserProfile.THEME_DARK,
'bookmark_date_display': UserProfile.BOOKMARK_DATE_DISPLAY_HIDDEN,
}
response = self.client.post(reverse('bookmarks:settings.general'), form_data)
self.user.profile.refresh_from_db()
self.assertEqual(response.status_code, 200)
self.assertEqual(self.user.profile.theme, form_data['theme'])
self.assertEqual(self.user.profile.bookmark_date_display, form_data['bookmark_date_display'])

View File

@@ -0,0 +1,79 @@
from django.test import TestCase
from django.urls import reverse
from bookmarks.tests.helpers import BookmarkFactoryMixin, disable_logging
class SettingsImportViewTestCase(TestCase, BookmarkFactoryMixin):
def setUp(self) -> None:
user = self.get_or_create_test_user()
self.client.force_login(user)
def assertFormSuccessHint(self, response, text: str):
self.assertContains(response, '<div class="has-success">')
self.assertContains(response, text)
def assertNoFormSuccessHint(self, response):
self.assertNotContains(response, '<div class="has-success">')
def assertFormErrorHint(self, response, text: str):
self.assertContains(response, '<div class="has-error">')
self.assertContains(response, text)
def assertNoFormErrorHint(self, response):
self.assertNotContains(response, '<div class="has-error">')
def test_should_import_successfully(self):
with open('bookmarks/tests/resources/simple_valid_import_file.html') as import_file:
response = self.client.post(
reverse('bookmarks:settings.import'),
{'import_file': import_file},
follow=True
)
self.assertRedirects(response, reverse('bookmarks:settings.general'))
self.assertFormSuccessHint(response, '3 bookmarks were successfully imported')
self.assertNoFormErrorHint(response)
def test_should_check_authentication(self):
self.client.logout()
response = self.client.get(reverse('bookmarks:settings.import'), follow=True)
self.assertRedirects(response, reverse('login') + '?next=' + reverse('bookmarks:settings.import'))
def test_should_show_hint_if_there_is_no_file(self):
response = self.client.post(
reverse('bookmarks:settings.import'),
follow=True
)
self.assertRedirects(response, reverse('bookmarks:settings.general'))
self.assertNoFormSuccessHint(response)
self.assertFormErrorHint(response, 'Please select a file to import.')
@disable_logging
def test_should_show_hint_if_import_raises_exception(self):
with open('bookmarks/tests/resources/invalid_import_file.png', 'rb') as import_file:
response = self.client.post(
reverse('bookmarks:settings.import'),
{'import_file': import_file},
follow=True
)
self.assertRedirects(response, reverse('bookmarks:settings.general'))
self.assertNoFormSuccessHint(response)
self.assertFormErrorHint(response, 'An error occurred during bookmark import.')
@disable_logging
def test_should_show_respective_hints_if_not_all_bookmarks_were_imported_successfully(self):
with open('bookmarks/tests/resources/simple_valid_import_file_with_one_invalid_bookmark.html') as import_file:
response = self.client.post(
reverse('bookmarks:settings.import'),
{'import_file': import_file},
follow=True
)
self.assertRedirects(response, reverse('bookmarks:settings.general'))
self.assertFormSuccessHint(response, '2 bookmarks were successfully imported')
self.assertFormErrorHint(response, '1 bookmarks could not be imported')

View File

@@ -0,0 +1,22 @@
from django.test import TestCase
from django.urls import reverse
from bookmarks.tests.helpers import BookmarkFactoryMixin
class SettingsIntegrationsViewTestCase(TestCase, BookmarkFactoryMixin):
def setUp(self) -> None:
user = self.get_or_create_test_user()
self.client.force_login(user)
def test_should_render_successfully(self):
response = self.client.get(reverse('bookmarks:settings.integrations'))
self.assertEqual(response.status_code, 200)
def test_should_check_authentication(self):
self.client.logout()
response = self.client.get(reverse('bookmarks:settings.integrations'), follow=True)
self.assertRedirects(response, reverse('login') + '?next=' + reverse('bookmarks:settings.integrations'))

View File

@@ -1,4 +1,5 @@
import datetime
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.utils import timezone

View File

@@ -1,5 +1,5 @@
from django.test import TestCase
from django.contrib.auth.models import User
from django.test import TestCase
from bookmarks.models import UserProfile

View File

@@ -9,11 +9,17 @@ from rest_framework.authtoken.models import Token
from bookmarks.models import UserProfileForm
from bookmarks.queries import query_bookmarks
from bookmarks.services.exporter import export_netscape_html
from bookmarks.services.importer import import_netscape_html
from bookmarks.services import exporter
from bookmarks.services import importer
logger = logging.getLogger(__name__)
try:
with open("version.txt", "r") as f:
app_version = f.read().strip("\n")
except Exception as exc:
logging.exception(exc)
pass
@login_required
def general(request):
@@ -30,6 +36,7 @@ def general(request):
'form': form,
'import_success_message': import_success_message,
'import_errors_message': import_errors_message,
'app_version': app_version
})
@@ -55,11 +62,11 @@ def bookmark_import(request):
if import_file is None:
messages.error(request, 'Please select a file to import.', 'bookmark_import_errors')
return HttpResponseRedirect(reverse('bookmarks:settings.index'))
return HttpResponseRedirect(reverse('bookmarks:settings.general'))
try:
content = import_file.read().decode()
result = import_netscape_html(content, request.user)
result = importer.import_netscape_html(content, request.user)
success_msg = str(result.success) + ' bookmarks were successfully imported.'
messages.success(request, success_msg, 'bookmark_import_success')
if result.failed > 0:
@@ -78,7 +85,7 @@ def bookmark_export(request):
# noinspection PyBroadException
try:
bookmarks = query_bookmarks(request.user, '')
file_content = export_netscape_html(bookmarks)
file_content = exporter.export_netscape_html(bookmarks)
response = HttpResponse(content_type='text/plain; charset=UTF-8')
response['Content-Disposition'] = 'attachment; filename="bookmarks.html"'

5
coverage.sh Executable file
View File

@@ -0,0 +1,5 @@
#!/usr/bin/env bash
coverage erase
coverage run manage.py test
coverage report --sort=cover

View File

@@ -1,6 +1,6 @@
{
"name": "linkding",
"version": "1.4.0",
"version": "1.7.1",
"description": "",
"main": "index.js",
"scripts": {

View File

@@ -1,20 +1,22 @@
asgiref==3.4.1
beautifulsoup4==4.7.1
certifi==2019.6.16
chardet==3.0.4
charset-normalizer==2.0.4
confusable-homoglyphs==3.2.0
Django==2.2.20
Django==3.2.6
django-generate-secret-key==1.0.2
django-picklefield==2.0
django-registration==3.1.2
django-sass-processor==0.7.3
django-widget-tweaks==1.4.5
djangorestframework==3.11.2
django-picklefield==3.0.1
django-registration==3.2
django-sass-processor==1.0.1
django-widget-tweaks==1.4.8
djangorestframework==3.12.4
idna==2.8
pyparsing==2.4.7
python-dateutil==2.8.1
pytz==2019.1
requests==2.22.0
pytz==2021.1
requests==2.26.0
soupsieve==1.9.2
sqlparse==0.3.0
urllib3==1.25.8
sqlparse==0.4.1
typing-extensions==3.10.0.0
urllib3==1.26.6
uWSGI==2.0.18

View File

@@ -1,26 +1,29 @@
asgiref==3.4.1
beautifulsoup4==4.7.1
certifi==2019.6.16
chardet==3.0.4
charset-normalizer==2.0.4
confusable-homoglyphs==3.2.0
Django==2.2.20
django-appconf==1.0.3
django-compressor==2.3
coverage==5.5
Django==3.2.6
django-appconf==1.0.4
django-compressor==2.4.1
django-debug-toolbar==3.2.1
django-generate-secret-key==1.0.2
django-picklefield==2.0
django-registration==3.1.2
django-sass-processor==0.7.3
django-widget-tweaks==1.4.5
djangorestframework==3.11.2
django-picklefield==3.0.1
django-registration==3.2
django-sass-processor==1.0.1
django-widget-tweaks==1.4.8
djangorestframework==3.12.4
idna==2.8
libsass==0.19.2
libsass==0.21.0
pyparsing==2.4.7
python-dateutil==2.8.1
pytz==2019.1
pytz==2021.1
rcssmin==1.0.6
requests==2.22.0
requests==2.26.0
rjsmin==1.1.0
six==1.12.0
six==1.16.0
soupsieve==1.9.2
sqlparse==0.3.0
urllib3==1.25.8
sqlparse==0.4.1
typing-extensions==3.10.0.0
urllib3==1.26.6

View File

@@ -73,6 +73,8 @@ TEMPLATES = [
},
]
DEFAULT_AUTO_FIELD = 'django.db.models.AutoField'
WSGI_APPLICATION = 'siteroot.wsgi.application'
# Database

View File

@@ -1 +1 @@
1.6.4
1.7.1