import shutil import tempfile 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 .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_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('

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