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('

#project starts here

', rendered) self.assertNotIn('

', rendered) def test_heading_with_space_remains_a_heading(self): self.assertIn('

Project

', str(markdown('# Project'))) self.assertIn('

Section

', 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('

', rendered) def test_hash_in_inline_code_is_not_a_heading(self): rendered = str(markdown('```# Das ist Kommentar im Code```')) self.assertIn('# Das ist Kommentar im Code', rendered) self.assertNotIn('

', rendered) def test_underline_is_allowed(self): self.assertIn('underlined', str(markdown('underlined'))) 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('

', html=True) self.assertContains(response, '

Child note

', 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')