Files
my2dos/core/tests.py
T
2026-08-27 16:48:56 +02:00

303 lines
16 KiB
Python

from django.contrib.auth import get_user_model
from django.test import TestCase
from django.urls import reverse
from .models import Collection, CollectionSection, Item, Tag, Team, TeamMembership, UserPreference
from .templatetags.markdown_extras import markdown
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')))
def test_hash_in_fenced_code_is_unchanged(self):
rendered = str(markdown('```python\n# comment\n```'))
self.assertIn('# comment', rendered)
self.assertNotIn('\\# comment', rendered)
def test_underline_is_allowed(self):
self.assertIn('<u>underlined</u>', str(markdown('<u>underlined</u>')))
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 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 #guide'), html.index('Second #guide'))
self.assertNotContains(response, 'Ignored #guide')
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_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 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')