336 lines
17 KiB
Python
336 lines
17 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 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)
|
|
|
|
|
|
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')
|