Files
my2dos/core/tests.py
T

922 lines
48 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import shutil
import tempfile
from datetime import datetime, time, timedelta
from unittest.mock import patch
from django.contrib.auth import get_user_model
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase, override_settings
from django.urls import reverse
from django.utils import timezone
from .models import ApiKey, Attachment, Collection, CollectionSection, Item, Tag, Team, TeamMembership, UserPreference
from .templatetags.markdown_extras import markdown, markdown_title
from .views import parse_due_command, parse_quick_content
class ItemMetadataTests(TestCase):
def setUp(self):
self.user = get_user_model().objects.create_user(username='tester', password='secret')
def test_code_sections_are_ignored_when_extracting_tags(self):
item = Item.objects.create(
owner=self.user,
content='Outside #real\n```python\n# fenced\n```\nInline `#inline` text',
)
item.sync_metadata()
self.assertEqual(list(item.tags.values_list('name', flat=True)), ['real'])
self.assertFalse(Tag.objects.filter(name__in=['fenced', 'inline']).exists())
def test_unused_tags_are_removed_after_sync_and_delete(self):
item = Item.objects.create(owner=self.user, content='#old')
item.sync_metadata()
item.content = '#new'
item.save()
item.sync_metadata()
self.assertFalse(Tag.objects.filter(name='old').exists())
self.assertTrue(Tag.objects.filter(name='new').exists())
item.delete()
self.assertFalse(Tag.objects.filter(name='new').exists())
class DefaultItemKindTests(TestCase):
def setUp(self):
self.user = get_user_model().objects.create_user(username='tester', password='secret')
self.client.force_login(self.user)
self.preferences = UserPreference.objects.create(user=self.user, default_item_kind=Item.Kind.TODO)
def test_quick_create_uses_user_default(self):
response = self.client.post(reverse('index'), {'content': 'Default task'})
self.assertRedirects(response, reverse('index'))
self.assertEqual(Item.objects.get().kind, Item.Kind.TODO)
def test_quick_create_can_override_and_persist_default_from_composer(self):
response = self.client.post(reverse('index'), {'content': 'Composer note', 'default_item_kind': Item.Kind.NOTE})
self.assertRedirects(response, reverse('index'))
self.assertEqual(Item.objects.get().kind, Item.Kind.NOTE)
self.preferences.refresh_from_db()
self.assertEqual(self.preferences.default_item_kind, Item.Kind.NOTE)
def test_default_item_kind_select_persists_immediately(self):
response = self.client.post(reverse('set_default_item_kind'), {'default_item_kind': Item.Kind.LINK})
self.assertEqual(response.status_code, 204)
self.preferences.refresh_from_db()
self.assertEqual(self.preferences.default_item_kind, Item.Kind.LINK)
response = self.client.get(reverse('index'))
self.assertContains(response, '<option value="link" selected>', html=False)
def test_explicit_command_overrides_default(self):
kind, _, content, _ = parse_quick_content('/note Explicit note', Item.Kind.TODO)
self.assertEqual(kind, Item.Kind.NOTE)
self.assertEqual(content, 'Explicit note')
def test_new_item_editor_uses_user_default(self):
response = self.client.get(reverse('new_item'))
self.assertEqual(response.context['requested_kind'], Item.Kind.TODO)
class DueDateCommandTests(TestCase):
def setUp(self):
self.user = get_user_model().objects.create_user(username='dates', password='secret')
self.client.force_login(self.user)
def test_datum_command_is_removed_and_parsed(self):
cleaned, due_at, found = parse_due_command('/todo Call back /datum 20.10.26 14:30 #work')
self.assertTrue(found)
self.assertEqual(cleaned, '/todo Call back #work')
self.assertEqual((due_at.year, due_at.month, due_at.day, due_at.hour, due_at.minute), (2026, 10, 20, 14, 30))
def test_relative_command_sets_due_date_on_quick_create(self):
response = self.client.post(reverse('index'), {'content': '/todo Call back /morgen'})
self.assertRedirects(response, reverse('index'))
item = Item.objects.get()
self.assertNotIn('/morgen', item.content)
self.assertIsNotNone(item.due_at)
def test_due_commands_inside_code_are_ignored(self):
raw = '/todo Example\n```\n/morgen\n/datum 20.10.26\n```'
cleaned, due_at, found = parse_due_command(raw)
self.assertFalse(found)
self.assertIsNone(due_at)
self.assertEqual(cleaned, raw)
def test_type_and_date_commands_are_available_in_menu(self):
response = self.client.get(reverse('index'))
self.assertContains(response, "{token:'/note'")
self.assertContains(response, "{token:'/datum'")
self.assertContains(response, 'queryStart')
self.assertContains(response, "group:'Typ'")
class SlashMenuContextTests(TestCase):
def test_type_command_only_counts_at_start(self):
leading_kind, _, leading_content, _ = parse_quick_content('/todo Leading')
later_kind, _, later_content, _ = parse_quick_content('Text /todo later')
self.assertEqual(leading_kind, Item.Kind.TODO)
self.assertEqual(leading_content, 'Leading')
self.assertEqual(later_kind, Item.Kind.NOTE)
self.assertEqual(later_content, 'Text /todo later')
def test_visibility_commands_inside_code_are_ignored(self):
kind, visibility, content, _ = parse_quick_content('/note Example ` /public `\n```\n/private\n```')
self.assertEqual(kind, Item.Kind.NOTE)
self.assertEqual(visibility, Item.Visibility.PRIVATE)
self.assertIn('/public', content)
self.assertIn('/private', content)
@patch('core.views.fetch_page_title', return_value='Example page')
def test_url_on_first_line_becomes_link_with_title_and_extra_text(self, _fetch_title):
kind, _, content, url = parse_quick_content('https://example.com/page\nEigener Text #tag')
self.assertEqual(kind, Item.Kind.LINK)
self.assertEqual(url, 'https://example.com/page')
self.assertEqual(content, 'Example page\nEigener Text #tag')
@patch('core.views.fetch_page_title', return_value='Example page')
def test_explicit_link_command_uses_first_line_url_title(self, _fetch_title):
kind, _, content, url = parse_quick_content('/link https://example.com/page\n#tag')
self.assertEqual(kind, Item.Kind.LINK)
self.assertEqual(url, 'https://example.com/page')
self.assertEqual(content, 'Example page\n#tag')
class MarkdownHeadingTests(TestCase):
def test_tag_at_line_start_is_not_a_heading(self):
rendered = str(markdown('#project starts here'))
self.assertIn('<p>#project starts here</p>', rendered)
self.assertNotIn('<h1>', rendered)
def test_heading_with_space_remains_a_heading(self):
self.assertIn('<h1>Project</h1>', str(markdown('# Project')))
self.assertIn('<h2>Section</h2>', str(markdown('## Section')))
def test_hash_in_fenced_code_is_unchanged(self):
rendered = str(markdown('```python\n# comment\n```'))
self.assertIn('# comment', rendered)
self.assertNotIn('\\# comment', rendered)
self.assertNotIn('<h1>', rendered)
def test_hash_in_inline_code_is_not_a_heading(self):
rendered = str(markdown('```# Das ist Kommentar im Code```'))
self.assertIn('<code># Das ist Kommentar im Code</code>', rendered)
self.assertNotIn('<h1>', rendered)
def test_underline_is_allowed(self):
self.assertIn('<u>underlined</u>', str(markdown('<u>underlined</u>')))
def test_markdown_title_strips_heading_marker(self):
self.assertEqual(markdown_title('## Ziel von my2dos\n\nText'), 'Ziel von my2dos')
class QuickCreateFilterTests(TestCase):
def setUp(self):
self.user = get_user_model().objects.create_user(username='quick-filter', password='secret')
self.client.force_login(self.user)
def test_active_tag_is_added_only_when_content_has_no_tags(self):
self.client.post(f'{reverse("index")}?tag=work', {'content': 'Task without tag'})
first = Item.objects.get(content='Task without tag #work')
self.assertEqual(list(first.tags.values_list('name', flat=True)), ['work'])
self.client.post(f'{reverse("index")}?tag=work', {'content': 'Task with own #other'})
second = Item.objects.get(content='Task with own #other')
self.assertEqual(list(second.tags.values_list('name', flat=True)), ['other'])
def test_hx_create_response_keeps_active_tag_marked(self):
response = self.client.post(
f'{reverse("index")}?tag=work', {'content': 'Task without tag'}, HTTP_HX_REQUEST='true',
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'hx-swap-oob="true"')
self.assertContains(response, '#work ×')
self.assertContains(response, 'text-bg-dark')
class FormattingToolbarTests(TestCase):
def test_toolbar_and_shortcuts_are_available(self):
user = get_user_model().objects.create_user(username='formatting', password='secret')
self.client.force_login(user)
response = self.client.get(reverse('index'))
self.assertContains(response, 'class="format-toolbar')
self.assertContains(response, "formatSelection('bold')")
self.assertContains(response, "u:'underline'")
class NavigationAndLayoutTests(TestCase):
def setUp(self):
self.user = get_user_model().objects.create_user(username='navigation', password='secret')
self.preferences = UserPreference.objects.create(user=self.user)
self.client.force_login(self.user)
def test_desktop_navigation_contains_overview_hidden_links_and_kanban(self):
self.preferences.show_notes = False
self.preferences.save(update_fields=['show_notes'])
response = self.client.get(reverse('index'))
self.assertContains(response, 'd-none d-md-flex')
self.assertContains(response, f'{reverse("index")}?kind=note')
self.assertContains(response, reverse('kanban_list'))
def test_preference_selects_are_stacked(self):
response = self.client.get(reverse('settings'))
preferences = response.content.decode().split('id="preferences"', 1)[1].split('name="due_first"', 1)[0]
self.assertEqual(preferences.count('<div class="col-12"><label class="form-label">'), 2)
self.assertNotContains(response, 'name="default_item_kind"')
def test_composer_menu_is_before_toolbar_and_file_controls(self):
response = self.client.get(reverse('index'))
html = response.content.decode()
menu_position = html.index('composer-slash-menu')
toolbar_position = html.index('format-toolbar', menu_position)
file_position = html.index('desktop-file', toolbar_position)
self.assertLess(menu_position, toolbar_position)
self.assertLess(toolbar_position, file_position)
def test_quick_create_is_disabled_while_inline_editor_is_open(self):
response = self.client.get(reverse('index'))
self.assertContains(response, ':disabled="editingItem"')
self.assertContains(response, 'inline-edit-form')
item = Item.objects.create(owner=self.user, content='Editable')
response = self.client.get(reverse('edit_item', args=[item.id]))
self.assertContains(response, 'inline-edit-form')
def test_tag_filter_list_depends_on_current_filters_but_composer_gets_visible_tags(self):
open_todo = Item.objects.create(owner=self.user, kind=Item.Kind.TODO, content='Open #openonly')
open_todo.sync_metadata()
done_todo = Item.objects.create(owner=self.user, kind=Item.Kind.TODO, content='Done #archiveonly', is_done=True)
done_todo.sync_metadata()
note = Item.objects.create(owner=self.user, kind=Item.Kind.NOTE, content='Note #noteonly')
note.sync_metadata()
other = get_user_model().objects.create_user(username='other-tags', password='secret')
foreign = Item.objects.create(owner=other, kind=Item.Kind.NOTE, content='Foreign #foreignonly')
foreign.sync_metadata()
public = Item.objects.create(owner=other, kind=Item.Kind.NOTE, content='Public #publiconly', visibility=Item.Visibility.PUBLIC)
public.sync_metadata()
response = self.client.get(f'{reverse("index")}?status=archive')
html = response.content.decode()
filter_tags = html.split('id="tag-list"', 1)[1].split('</div>', 1)[0]
self.assertIn('#archiveonly', filter_tags)
self.assertNotIn('#openonly', filter_tags)
self.assertNotIn('#noteonly', filter_tags)
self.assertIn("'openonly'", html)
self.assertIn("'noteonly'", html)
self.assertIn("'publiconly'", html)
self.assertNotIn('foreignonly', html)
response = self.client.get(f'{reverse("tag_list")}?status=archive')
self.assertContains(response, '#archiveonly')
self.assertNotContains(response, '#openonly')
def test_tag_api_returns_only_visible_tags(self):
own = Item.objects.create(owner=self.user, content='Own #ownapi')
own.sync_metadata()
other = get_user_model().objects.create_user(username='other-api-tags', password='secret')
foreign = Item.objects.create(owner=other, content='Foreign #foreignapi')
foreign.sync_metadata()
public = Item.objects.create(owner=other, content='Public #publicapi', visibility=Item.Visibility.PUBLIC)
public.sync_metadata()
response = self.client.get(reverse('api_tags'))
self.assertEqual(response.status_code, 200)
tags = response.json()['tags']
self.assertIn('ownapi', tags)
self.assertIn('publicapi', tags)
self.assertNotIn('foreignapi', tags)
def test_due_filter_also_shows_next_seven_days_separately(self):
today = timezone.localdate()
due = timezone.make_aware(datetime.combine(today, time(12, 0)))
upcoming = timezone.make_aware(datetime.combine(today + timedelta(days=3), time(12, 0)))
later = timezone.make_aware(datetime.combine(today + timedelta(days=8), time(12, 0)))
Item.objects.create(owner=self.user, kind=Item.Kind.TODO, content='Due now', due_at=due)
Item.objects.create(owner=self.user, kind=Item.Kind.TODO, content='Upcoming soon', due_at=upcoming)
Item.objects.create(owner=self.user, kind=Item.Kind.TODO, content='Later task', due_at=later)
response = self.client.get(f'{reverse("index")}?status=due')
self.assertContains(response, 'Due now')
self.assertContains(response, 'Upcoming soon')
self.assertContains(response, 'Due in the next 7 days')
self.assertNotContains(response, 'Later task')
class AttachmentEditTests(TestCase):
def setUp(self):
self.media_root = tempfile.mkdtemp()
self.override = override_settings(MEDIA_ROOT=self.media_root)
self.override.enable()
self.user = get_user_model().objects.create_user(username='attachment-editor', password='secret')
self.client.force_login(self.user)
def tearDown(self):
self.override.disable()
shutil.rmtree(self.media_root, ignore_errors=True)
def test_uploaded_file_is_visible_after_first_inline_edit_save(self):
item = Item.objects.create(owner=self.user, content='Original')
uploaded = SimpleUploadedFile('hello.txt', b'hello', content_type='text/plain')
response = self.client.post(reverse('edit_item', args=[item.id]), {
'content': 'Original', 'comment': '', 'visibility': Item.Visibility.PRIVATE, 'url': '', 'files': uploaded,
}, HTTP_HX_REQUEST='true')
self.assertEqual(response.status_code, 200)
self.assertEqual(item.attachments.count(), 1)
self.assertContains(response, 'hello.txt')
def test_existing_attachments_are_shown_and_can_be_deleted_in_editor(self):
item = Item.objects.create(owner=self.user, content='Original')
attachment = Attachment.objects.create(item=item, file=SimpleUploadedFile('duplicate.txt', b'duplicate', content_type='text/plain'))
response = self.client.get(reverse('item_editor', args=[item.id]))
self.assertContains(response, 'duplicate.txt')
self.assertContains(response, 'name="delete_attachments"')
response = self.client.post(reverse('item_editor', args=[item.id]), {
'content': 'Original', 'comment': '', 'visibility': Item.Visibility.PRIVATE, 'url': '', 'delete_attachments': [str(attachment.id)],
})
self.assertRedirects(response, item.get_absolute_url())
self.assertFalse(item.attachments.exists())
def test_detail_editor_uses_original_return_url_not_partial_endpoint(self):
item = Item.objects.create(owner=self.user, content='Original')
response = self.client.get(f'{item.get_absolute_url()}?next=/')
self.assertContains(response, f'{reverse("item_editor", args=[item.id])}?next=/')
self.assertNotContains(response, reverse('item_card', args=[item.id]))
def test_unembedded_attachment_images_open_in_lightbox(self):
item = Item.objects.create(owner=self.user, content='Original')
Attachment.objects.create(item=item, file=SimpleUploadedFile('photo.jpg', b'image', content_type='image/jpeg'))
response = self.client.get(item.get_absolute_url())
self.assertContains(response, 'id="image-lightbox"')
self.assertContains(response, 'class="btn p-0 border-0 bg-transparent attachment-image-preview"')
self.assertContains(response, 'data-image-url=')
class TeamItemEditTests(TestCase):
def setUp(self):
self.user = get_user_model().objects.create_user(username='team-editor', password='secret')
self.team = Team.objects.create(name='Docs team', slug='docs', owner=self.user)
TeamMembership.objects.create(team=self.team, user=self.user, role=TeamMembership.Role.OWNER)
self.client.force_login(self.user)
def test_editing_team_item_preserves_team_link(self):
item = Item.objects.create(
owner=self.user, team=self.team, kind=Item.Kind.TODO,
content='Original #docs', visibility=Item.Visibility.TEAM,
)
item.sync_metadata()
response = self.client.post(reverse('edit_item', args=[item.id]), {
'content': 'Updated #docs', 'comment': '', 'visibility': Item.Visibility.TEAM, 'url': '',
})
self.assertEqual(response.status_code, 200)
item.refresh_from_db()
self.assertEqual(item.team, self.team)
self.assertEqual(item.visibility, Item.Visibility.TEAM)
def test_team_visibility_is_selected_in_edit_forms(self):
item = Item.objects.create(
owner=self.user, team=self.team, content='Original #docs', visibility=Item.Visibility.TEAM,
)
response = self.client.get(reverse('edit_item', args=[item.id]))
self.assertContains(response, '<option value="team" selected>', html=False)
response = self.client.get(reverse('item_editor', args=[item.id]))
self.assertContains(response, '<option value="team" selected>', html=False)
def test_team_item_can_be_made_public_even_with_team_tag(self):
item = Item.objects.create(
owner=self.user, team=self.team, content='Original #docs', visibility=Item.Visibility.TEAM,
)
item.sync_metadata()
response = self.client.post(reverse('item_editor', args=[item.id]), {
'content': 'Updated #docs', 'comment': '', 'visibility': Item.Visibility.PUBLIC, 'url': '',
})
self.assertRedirects(response, item.get_absolute_url())
item.refresh_from_db()
self.assertEqual(item.visibility, Item.Visibility.PUBLIC)
self.assertIsNone(item.team)
class CollectionTests(TestCase):
def setUp(self):
self.user = get_user_model().objects.create_user(username='collector', password='secret')
self.client.force_login(self.user)
def test_create_collection_and_add_compatible_note(self):
response = self.client.post(reverse('collection_create'), {
'title': 'Documentation', 'description': 'Guide', 'visibility': Item.Visibility.PRIVATE, 'mode': Collection.Mode.MANUAL,
})
collection = Collection.objects.get()
self.assertRedirects(response, collection.get_absolute_url())
note = Item.objects.create(owner=self.user, kind=Item.Kind.NOTE, content='Introduction')
manage_url = reverse('collection_manage', args=[collection.id])
response = self.client.post(manage_url, {'action': 'add', 'item_id': note.id})
self.assertRedirects(response, manage_url)
self.assertTrue(CollectionSection.objects.filter(collection=collection, item=note).exists())
def test_public_note_can_be_added_to_private_collection_without_conversion(self):
collection = Collection.objects.create(owner=self.user, title='Private guide', visibility=Item.Visibility.PRIVATE)
note = Item.objects.create(owner=self.user, kind=Item.Kind.NOTE, content='Already public', visibility=Item.Visibility.PUBLIC)
manage_url = reverse('collection_manage', args=[collection.id])
response = self.client.post(manage_url, {'action': 'add', 'item_id': note.id})
self.assertRedirects(response, manage_url)
self.assertTrue(collection.sections.filter(item=note).exists())
note.refresh_from_db()
self.assertEqual(note.visibility, Item.Visibility.PUBLIC)
def test_public_collection_asks_before_copying_private_note(self):
collection = Collection.objects.create(owner=self.user, title='Public guide', visibility=Item.Visibility.PUBLIC)
note = Item.objects.create(owner=self.user, kind=Item.Kind.NOTE, content='Private section')
manage_url = reverse('collection_manage', args=[collection.id])
response = self.client.post(manage_url, {'action': 'add', 'item_id': note.id})
self.assertContains(response, 'name="mode" value="copy"')
response = self.client.post(manage_url, {
'action': 'confirm_add', 'item_id': note.id, 'mode': 'copy',
})
self.assertRedirects(response, manage_url)
copied = collection.sections.get().item
self.assertNotEqual(copied.id, note.id)
self.assertEqual(copied.visibility, Item.Visibility.PUBLIC)
note.refresh_from_db()
self.assertEqual(note.visibility, Item.Visibility.PRIVATE)
def test_note_used_by_collection_cannot_be_deleted(self):
collection = Collection.objects.create(owner=self.user, title='Protected')
note = Item.objects.create(owner=self.user, kind=Item.Kind.NOTE, content='Do not delete')
CollectionSection.objects.create(collection=collection, item=note, position=0)
response = self.client.post(reverse('delete_item', args=[note.id]))
self.assertEqual(response.status_code, 409)
self.assertTrue(Item.objects.filter(pk=note.id).exists())
def test_team_selection_creates_team_collection(self):
team = Team.objects.create(name='Docs team', slug='docs', owner=self.user)
TeamMembership.objects.create(team=team, user=self.user, role=TeamMembership.Role.OWNER)
response = self.client.post(reverse('collection_create'), {
'title': 'Team guide', 'visibility': Item.Visibility.PRIVATE,
'mode': Collection.Mode.MANUAL, 'team': team.id,
})
collection = Collection.objects.get()
self.assertRedirects(response, collection.get_absolute_url())
self.assertEqual(collection.team, team)
self.assertEqual(collection.visibility, Item.Visibility.TEAM)
def test_other_users_public_notes_are_not_offered_for_embedding(self):
other = get_user_model().objects.create_user(username='other')
public_note = Item.objects.create(owner=other, kind=Item.Kind.NOTE, content='External', visibility=Item.Visibility.PUBLIC)
collection = Collection.objects.create(owner=self.user, title='Own collection')
response = self.client.get(reverse('collection_manage', args=[collection.id]))
self.assertNotContains(response, f'name="item_id" value="{public_note.id}"')
def test_collection_form_separates_teams_and_content_tags(self):
team = Team.objects.create(name='Docs team', slug='docs', owner=self.user)
TeamMembership.objects.create(team=team, user=self.user, role=TeamMembership.Role.OWNER)
Tag.objects.create(name='docs')
note = Item.objects.create(owner=self.user, content='#ordinary')
note.sync_metadata()
response = self.client.get(reverse('collection_create'))
form = response.context['form']
self.assertEqual(list(form.fields['team'].queryset), [team])
self.assertEqual(list(form.fields['filter_tags'].queryset.values_list('name', flat=True)), ['ordinary'])
def test_tutorial_is_listed_but_chapter_is_hidden_from_collection_overview(self):
tutorial = Collection.objects.create(owner=self.user, title='my2dos Tutorial', mode=Collection.Mode.TUTORIAL)
chapter = Collection.objects.create(owner=self.user, title='my2dos Typen', mode=Collection.Mode.CHAPTER)
response = self.client.get(reverse('collection_list'))
self.assertContains(response, tutorial.title)
self.assertNotIn(chapter, [entry['collection'] for entry in response.context['entries']])
def test_collection_list_can_filter_collection_types(self):
Collection.objects.create(owner=self.user, title='Manual', mode=Collection.Mode.MANUAL)
Collection.objects.create(owner=self.user, title='Tutorial', mode=Collection.Mode.TUTORIAL)
Collection.objects.create(owner=self.user, title='Tagged', mode=Collection.Mode.TAGS)
Collection.objects.create(owner=self.user, title='Chapter', mode=Collection.Mode.CHAPTER)
response = self.client.get(f'{reverse("collection_list")}?mode=chapter')
self.assertEqual([entry['collection'].title for entry in response.context['entries']], ['Chapter'])
def test_orphan_chapters_are_shown_as_warning(self):
Collection.objects.create(owner=self.user, title='Lonely chapter', mode=Collection.Mode.CHAPTER)
response = self.client.get(reverse('collection_list'))
self.assertContains(response, 'Verwaiste Kapitel')
self.assertContains(response, 'Lonely chapter')
def test_tutorial_can_include_chapter_collection(self):
tutorial = Collection.objects.create(owner=self.user, title='my2dos Tutorial', mode=Collection.Mode.TUTORIAL)
chapter = Collection.objects.create(owner=self.user, title='my2dos Typen', mode=Collection.Mode.CHAPTER)
note = Item.objects.create(owner=self.user, kind=Item.Kind.NOTE, content='## Todos')
CollectionSection.objects.create(collection=chapter, item=note, position=0)
manage_url = reverse('collection_manage', args=[tutorial.id])
response = self.client.post(manage_url, {'action': 'add_collection', 'collection_id': chapter.id})
self.assertRedirects(response, manage_url)
self.assertTrue(tutorial.sections.filter(sub_collection=chapter).exists())
response = self.client.get(tutorial.get_absolute_url())
self.assertContains(response, '<h2>my2dos Typen</h2>', html=True)
self.assertContains(response, '<h3>Todos</h3>', html=True)
def test_collection_overview_is_available_on_mobile(self):
response = self.client.get(reverse('collection_list'))
self.assertNotContains(response, 'Collections are currently available on desktop only.')
self.assertContains(response, 'Sammlungen')
def test_collection_manage_links_to_note_editor_with_return_target(self):
collection = Collection.objects.create(owner=self.user, title='Reading view')
manage_url = reverse('collection_manage', args=[collection.id])
response = self.client.get(manage_url)
self.assertContains(response, f'{reverse("new_item")}?kind=note&collection={collection.id}&next={manage_url}')
def test_create_note_for_collection_in_editor_adds_it_and_returns(self):
collection = Collection.objects.create(owner=self.user, title='Reading view')
manage_url = reverse('collection_manage', args=[collection.id])
response = self.client.post(f'{reverse("new_item")}?kind=note&collection={collection.id}&next={manage_url}', {
'collection': collection.id, 'next': manage_url, 'content': '## Fresh note #new',
})
note = Item.objects.get(content='## Fresh note #new')
self.assertRedirects(response, manage_url)
self.assertTrue(collection.sections.filter(item=note).exists())
self.assertEqual(note.visibility, collection.visibility)
self.assertEqual(list(note.tags.values_list('name', flat=True)), ['new'])
def test_create_chapter_from_tutorial_manage_adds_and_opens_it(self):
tutorial = Collection.objects.create(owner=self.user, title='my2dos Tutorial', mode=Collection.Mode.TUTORIAL)
response = self.client.post(reverse('collection_manage', args=[tutorial.id]), {'action': 'create_chapter', 'title': 'Tags'})
chapter = Collection.objects.get(title='Tags')
self.assertEqual(chapter.mode, Collection.Mode.CHAPTER)
self.assertTrue(tutorial.sections.filter(sub_collection=chapter).exists())
self.assertRedirects(response, reverse('collection_manage', args=[chapter.id]))
def test_collection_sections_can_be_reordered_by_position(self):
collection = Collection.objects.create(owner=self.user, title='Reading view')
first = Item.objects.create(owner=self.user, kind=Item.Kind.NOTE, content='First')
second = Item.objects.create(owner=self.user, kind=Item.Kind.NOTE, content='Second')
first_section = CollectionSection.objects.create(collection=collection, item=first, position=0)
second_section = CollectionSection.objects.create(collection=collection, item=second, position=1)
response = self.client.post(reverse('collection_manage', args=[collection.id]), {
'action': 'move_to', 'section_id': second_section.id, 'position': 0,
})
self.assertRedirects(response, reverse('collection_manage', args=[collection.id]))
self.assertEqual(list(collection.sections.order_by('position').values_list('id', flat=True)), [second_section.id, first_section.id])
def test_collection_detail_is_available_on_mobile(self):
collection = Collection.objects.create(owner=self.user, title='Mobile reading')
response = self.client.get(collection.get_absolute_url())
self.assertNotContains(response, 'Collections are currently available on desktop only.')
self.assertContains(response, 'collection-document')
def test_tag_collection_is_dynamic_and_sorted_by_creation(self):
first = Item.objects.create(owner=self.user, kind=Item.Kind.NOTE, content='First #guide #basics')
first.sync_metadata()
Item.objects.create(owner=self.user, kind=Item.Kind.NOTE, content='Ignored #guide').sync_metadata()
second = Item.objects.create(owner=self.user, kind=Item.Kind.NOTE, content='Second #guide #basics')
second.sync_metadata()
collection = Collection.objects.create(owner=self.user, title='Automatic', mode=Collection.Mode.TAGS)
collection.filter_tags.set(Tag.objects.filter(name__in=['guide', 'basics']))
response = self.client.get(collection.get_absolute_url())
html = response.content.decode()
self.assertContains(response, 'Tag-Sammlung')
self.assertLess(html.index('First'), html.index('Second'))
self.assertNotContains(response, 'First #guide')
self.assertNotContains(response, 'Second #guide')
self.assertNotContains(response, 'Ignored')
self.assertContains(response, reverse('collection_edit', args=[collection.id]))
self.assertNotContains(response, reverse('collection_manage', args=[collection.id]))
manage_response = self.client.get(reverse('collection_manage', args=[collection.id]))
self.assertRedirects(manage_response, reverse('collection_edit', args=[collection.id]))
def test_tag_collection_todos_are_rendered_with_checkbox(self):
todo = Item.objects.create(owner=self.user, kind=Item.Kind.TODO, content='Check me #project')
todo.sync_metadata()
collection = Collection.objects.create(
owner=self.user, title='Typed tags', mode=Collection.Mode.TAGS,
include_notes=False, include_todos=True,
)
collection.filter_tags.add(Tag.objects.get(name='project'))
response = self.client.get(collection.get_absolute_url())
self.assertContains(response, 'type="checkbox"')
self.assertContains(response, reverse('toggle_done', args=[todo.id]))
def test_tag_collection_todo_checkbox_toggles_and_returns_to_collection(self):
todo = Item.objects.create(owner=self.user, kind=Item.Kind.TODO, content='Check me #project')
todo.sync_metadata()
collection = Collection.objects.create(
owner=self.user, title='Typed tags', mode=Collection.Mode.TAGS,
include_notes=False, include_todos=True,
)
collection.filter_tags.add(Tag.objects.get(name='project'))
next_url = f'{collection.get_absolute_url()}#item-{todo.id}'
response = self.client.post(reverse('toggle_done', args=[todo.id]), {'next': next_url})
self.assertRedirects(response, next_url, fetch_redirect_response=False)
todo.refresh_from_db()
self.assertTrue(todo.is_done)
def test_tag_collection_can_filter_included_item_types(self):
note = Item.objects.create(owner=self.user, kind=Item.Kind.NOTE, content='Matching note #project')
note.sync_metadata()
todo = Item.objects.create(owner=self.user, kind=Item.Kind.TODO, content='Matching todo #project')
todo.sync_metadata()
link = Item.objects.create(owner=self.user, kind=Item.Kind.LINK, content='Matching link #project')
link.sync_metadata()
collection = Collection.objects.create(
owner=self.user, title='Typed tags', mode=Collection.Mode.TAGS,
include_notes=False, include_todos=True, include_links=False, include_journal=False,
)
collection.filter_tags.add(Tag.objects.get(name='project'))
response = self.client.get(collection.get_absolute_url())
self.assertContains(response, 'Matching todo')
self.assertNotContains(response, 'Matching note')
self.assertNotContains(response, 'Matching link')
def test_tag_collection_form_offers_item_type_selection(self):
response = self.client.get(reverse('collection_create'))
self.assertContains(response, 'Entry types')
self.assertContains(response, 'name="include_notes"')
self.assertContains(response, 'name="include_todos"')
self.assertContains(response, 'name="include_links"')
self.assertContains(response, 'name="include_journal"')
def test_current_list_can_be_exported_as_markdown(self):
Item.objects.create(owner=self.user, kind=Item.Kind.TODO, content='Open task #project').sync_metadata()
Item.objects.create(owner=self.user, kind=Item.Kind.NOTE, content='Other note')
response = self.client.get(f'{reverse("export_items")}?kind=todo&tag=project')
self.assertEqual(response.status_code, 200)
self.assertEqual(response['Content-Type'], 'text/markdown; charset=utf-8')
body = response.content.decode()
self.assertIn('- [ ] Open task #project', body)
self.assertNotIn('Other note', body)
def test_collection_can_be_exported_as_markdown(self):
collection = Collection.objects.create(owner=self.user, title='Reading view', description='Guide')
todo = Item.objects.create(owner=self.user, kind=Item.Kind.TODO, content='Todo section #project')
note = Item.objects.create(owner=self.user, kind=Item.Kind.NOTE, content='## Note section #project')
CollectionSection.objects.create(collection=collection, item=todo, position=0)
CollectionSection.objects.create(collection=collection, item=note, position=1)
response = self.client.get(reverse('collection_export', args=[collection.id]))
self.assertEqual(response.status_code, 200)
body = response.content.decode()
self.assertIn('# Reading view', body)
self.assertIn('Guide', body)
self.assertIn('- [ ] Todo section', body)
self.assertIn('## Note section', body)
self.assertNotIn('#project', body)
def test_collection_opens_as_continuous_reading_view(self):
collection = Collection.objects.create(owner=self.user, title='Reading view')
first = Item.objects.create(owner=self.user, kind=Item.Kind.NOTE, content='First paragraph')
second = Item.objects.create(owner=self.user, kind=Item.Kind.NOTE, content='Second paragraph')
CollectionSection.objects.create(collection=collection, item=first, title='First', position=0)
CollectionSection.objects.create(collection=collection, item=second, position=1)
response = self.client.get(collection.get_absolute_url())
self.assertContains(response, 'collection-flow')
self.assertContains(response, reverse('collection_manage', args=[collection.id]))
self.assertNotContains(response, 'Add existing note')
self.assertNotContains(response, 'section_id')
def test_collection_reading_view_hides_note_tags(self):
collection = Collection.objects.create(owner=self.user, title='Reading view')
note = Item.objects.create(owner=self.user, kind=Item.Kind.NOTE, content='## Section #tutorial\nText #my2dos and `#code`')
CollectionSection.objects.create(collection=collection, item=note, position=0)
response = self.client.get(collection.get_absolute_url())
self.assertContains(response, '<h2>Section</h2>', html=True)
self.assertContains(response, '<code>#code</code>', html=True)
self.assertNotContains(response, '#tutorial')
self.assertNotContains(response, '#my2dos')
def test_collection_manage_links_note_editor_back_to_collection(self):
collection = Collection.objects.create(owner=self.user, title='Reading view')
note = Item.objects.create(owner=self.user, kind=Item.Kind.NOTE, content='## Section')
CollectionSection.objects.create(collection=collection, item=note, position=0)
manage_url = reverse('collection_manage', args=[collection.id])
response = self.client.get(manage_url)
self.assertContains(response, 'id="collection-manage"')
self.assertContains(response, 'hx-boost="true"')
self.assertContains(response, 'hx-swap="outerHTML show:none"')
self.assertContains(response, f'{reverse("item_editor", args=[note.id])}?next={manage_url}')
self.assertContains(response, 'href="#section-1">Section')
def test_collection_manage_preserves_search_when_adding_note(self):
collection = Collection.objects.create(owner=self.user, title='Reading view')
note = Item.objects.create(owner=self.user, kind=Item.Kind.NOTE, content='Findable note')
manage_url = reverse('collection_manage', args=[collection.id])
response = self.client.post(f'{manage_url}?q=Findable', {'action': 'add', 'item_id': note.id})
self.assertRedirects(response, f'{manage_url}?q=Findable')
def test_collection_manage_marks_already_added_notes(self):
collection = Collection.objects.create(owner=self.user, title='Reading view')
note = Item.objects.create(owner=self.user, kind=Item.Kind.NOTE, content='Findable note')
CollectionSection.objects.create(collection=collection, item=note, position=0)
response = self.client.get(f'{reverse("collection_manage", args=[collection.id])}?q=Findable')
self.assertContains(response, 'Bereits in dieser Sammlung')
self.assertContains(response, '1× verwendet')
self.assertContains(response, 'disabled')
def test_collection_can_include_collection(self):
parent = Collection.objects.create(owner=self.user, title='Parent')
child = Collection.objects.create(owner=self.user, title='Child')
note = Item.objects.create(owner=self.user, kind=Item.Kind.NOTE, content='## Child note #internal')
CollectionSection.objects.create(collection=child, item=note, position=0)
manage_url = reverse('collection_manage', args=[parent.id])
response = self.client.post(manage_url, {'action': 'add_collection', 'collection_id': child.id})
self.assertRedirects(response, manage_url)
self.assertTrue(parent.sections.filter(sub_collection=child).exists())
response = self.client.get(parent.get_absolute_url())
self.assertContains(response, '<h2>Child</h2>', html=True)
self.assertContains(response, '<h3>Child note</h3>', html=True)
self.assertNotContains(response, '#internal')
def test_collection_loop_is_rejected(self):
parent = Collection.objects.create(owner=self.user, title='Parent')
child = Collection.objects.create(owner=self.user, title='Child')
CollectionSection.objects.create(collection=parent, sub_collection=child, position=0)
manage_url = reverse('collection_manage', args=[child.id])
response = self.client.post(manage_url, {'action': 'add_collection', 'collection_id': parent.id}, follow=True)
self.assertContains(response, 'collection loop')
self.assertFalse(child.sections.filter(sub_collection=parent).exists())
def test_public_collection_cannot_include_private_collection(self):
parent = Collection.objects.create(owner=self.user, title='Parent', visibility=Item.Visibility.PUBLIC)
child = Collection.objects.create(owner=self.user, title='Child', visibility=Item.Visibility.PRIVATE)
manage_url = reverse('collection_manage', args=[parent.id])
response = self.client.post(manage_url, {'action': 'add_collection', 'collection_id': child.id}, follow=True)
self.assertContains(response, 'incompatible visibility')
self.assertFalse(parent.sections.filter(sub_collection=child).exists())
def test_private_collection_can_include_public_collection(self):
parent = Collection.objects.create(owner=self.user, title='Parent', visibility=Item.Visibility.PRIVATE)
child = Collection.objects.create(owner=self.user, title='Child', visibility=Item.Visibility.PUBLIC)
manage_url = reverse('collection_manage', args=[parent.id])
response = self.client.post(manage_url, {'action': 'add_collection', 'collection_id': child.id})
self.assertRedirects(response, manage_url)
self.assertTrue(parent.sections.filter(sub_collection=child).exists())
def test_public_collection_is_visible_without_login(self):
collection = Collection.objects.create(owner=self.user, title='Public', visibility=Item.Visibility.PUBLIC)
self.client.logout()
response = self.client.get(collection.get_absolute_url())
self.assertEqual(response.status_code, 200)
self.assertNotContains(response, reverse('collection_manage', args=[collection.id]))
def test_collection_tag_is_not_removed_as_unused(self):
tag = Tag.objects.create(name='documentation')
collection = Collection.objects.create(owner=self.user, title='Tagged', mode=Collection.Mode.TAGS)
collection.filter_tags.add(tag)
note = Item.objects.create(owner=self.user, content='#temporary')
note.sync_metadata()
note.content = ''
note.save()
note.sync_metadata()
self.assertTrue(Tag.objects.filter(pk=tag.pk).exists())
def test_api_cannot_make_collection_note_incompatible(self):
collection = Collection.objects.create(owner=self.user, title='Public', visibility=Item.Visibility.PUBLIC)
note = Item.objects.create(owner=self.user, kind=Item.Kind.NOTE, content='Published', visibility=Item.Visibility.PUBLIC)
CollectionSection.objects.create(collection=collection, item=note, position=0)
response = self.client.patch(
reverse('api_item_detail', args=[note.id]),
data='{"visibility":"private"}', content_type='application/json',
)
self.assertEqual(response.status_code, 409)
note.refresh_from_db()
self.assertEqual(note.visibility, Item.Visibility.PUBLIC)
class ApiItemFilterTests(TestCase):
def setUp(self):
self.user = get_user_model().objects.create_user(username='api-filter', password='secret')
self.help_tag = Tag.objects.create(name='help')
self.api_key = ApiKey.objects.create(owner=self.user, name='Help CLI')
self.api_key.tags.add(self.help_tag)
def create_item(self, content):
item = Item.objects.create(owner=self.user, content=content)
item.sync_metadata()
return item
def test_api_key_settings_only_offer_own_tags(self):
self.client.force_login(self.user)
self.create_item('Own tag #own')
other = get_user_model().objects.create_user(username='api-other', password='secret')
other_item = Item.objects.create(owner=other, content='Other tag #foreign')
other_item.sync_metadata()
response = self.client.get(reverse('settings'))
api_section = response.content.decode().split('id="api"', 1)[1]
self.assertIn('#own', api_section)
self.assertNotIn('#foreign', api_section)
def test_api_key_cannot_be_created_with_foreign_tag(self):
self.client.force_login(self.user)
other = get_user_model().objects.create_user(username='api-other', password='secret')
other_item = Item.objects.create(owner=other, content='Other tag #foreign')
other_item.sync_metadata()
foreign_tag = Tag.objects.get(name='foreign')
response = self.client.post(reverse('settings'), {'action': 'apikey', 'name': 'Bad key', 'tags': [foreign_tag.id]})
self.assertEqual(response.status_code, 200)
self.assertFalse(ApiKey.objects.filter(owner=self.user, name='Bad key').exists())
def test_api_key_scope_and_request_tags_are_combined(self):
matching = self.create_item('Keyboard shortcuts #help #tasten')
self.create_item('Git help #help #git')
self.create_item('Private keyboard note #tasten')
response = self.client.get(
reverse('api_items'), {'tag': 'tasten', 'tag_mode': 'all'},
HTTP_X_API_KEY=self.api_key.token,
)
self.assertEqual(response.status_code, 200)
ids = [item['id'] for item in response.json()['items']]
self.assertEqual(ids, [matching.id])
def test_api_key_only_returns_owner_items_not_other_public_items(self):
own = self.create_item('Own public help #help')
other = get_user_model().objects.create_user(username='api-public-other', password='secret')
public = Item.objects.create(owner=other, content='Other public help #help', visibility=Item.Visibility.PUBLIC)
public.sync_metadata()
response = self.client.get(reverse('api_items'), HTTP_X_API_KEY=self.api_key.token)
self.assertEqual(response.status_code, 200)
ids = [item['id'] for item in response.json()['items']]
self.assertIn(own.id, ids)
self.assertNotIn(public.id, ids)
def test_api_items_supports_limit_and_search(self):
self.create_item('First #help #shell')
second = self.create_item('Second #help #shell keyboard')
response = self.client.get(
reverse('api_items'), {'tag': 'shell', 'q': 'keyboard', 'limit': '1'},
HTTP_X_API_KEY=self.api_key.token,
)
self.assertEqual(response.status_code, 200)
items = response.json()['items']
self.assertEqual(len(items), 1)
self.assertEqual(items[0]['id'], second.id)
class CopyButtonTests(TestCase):
def test_item_has_copy_button_with_original_text(self):
user = get_user_model().objects.create_user(username='tester', password='secret')
item = Item.objects.create(owner=user, content='Text to copy')
self.client.force_login(user)
response = self.client.get(reverse('item_detail', args=[item.pk]))
self.assertContains(response, 'class="copy-item')
self.assertContains(response, 'Text to copy')