Files
my2dos/core/tests.py
T
2026-08-30 19:27:32 +02:00

483 lines
25 KiB
Python

from django.contrib.auth import get_user_model
from django.test import TestCase
from django.urls import reverse
from .models import ApiKey, 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_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_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')
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 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">'), 3)
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)
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_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_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, '>Section</')
self.assertNotContains(response, '>## 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_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_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_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')