1100 lines
59 KiB
Python
1100 lines
59 KiB
Python
import shutil
|
||
import tempfile
|
||
from datetime import datetime, time, timedelta
|
||
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 django.utils import timezone
|
||
|
||
from .models import ApiKey, Attachment, Collection, CollectionSection, Item, Tag, Team, TeamMembership, UserPreference, Workspace, WorkspaceMembership, get_or_create_personal_workspace
|
||
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 WorkspaceTenancyTests(TestCase):
|
||
def test_same_tag_name_is_scoped_per_personal_workspace(self):
|
||
first_user = get_user_model().objects.create_user(username='tenant-a', password='secret')
|
||
second_user = get_user_model().objects.create_user(username='tenant-b', password='secret')
|
||
first = Item.objects.create(owner=first_user, content='One #shared')
|
||
first.sync_metadata()
|
||
second = Item.objects.create(owner=second_user, content='Two #shared')
|
||
second.sync_metadata()
|
||
|
||
self.assertEqual(Tag.objects.filter(name='shared').count(), 2)
|
||
self.assertNotEqual(first.tags.get(name='shared').workspace_id, second.tags.get(name='shared').workspace_id)
|
||
|
||
def test_same_team_tag_is_allowed_in_different_workspaces(self):
|
||
first_user = get_user_model().objects.create_user(username='team-a', password='secret')
|
||
second_user = get_user_model().objects.create_user(username='team-b', password='secret')
|
||
|
||
first = Team.objects.create(owner=first_user, name='Docs A', slug='docs')
|
||
second = Team.objects.create(owner=second_user, name='Docs B', slug='docs')
|
||
|
||
self.assertNotEqual(first.workspace_id, second.workspace_id)
|
||
|
||
def test_workspace_can_be_created_and_becomes_active(self):
|
||
user = get_user_model().objects.create_user(username='workspace-create', password='secret')
|
||
self.client.force_login(user)
|
||
|
||
response = self.client.post(reverse('settings'), {'action': 'workspace', 'name': 'Acme Inc', 'slug': 'acme'})
|
||
|
||
workspace = Workspace.objects.get(slug='acme')
|
||
self.assertRedirects(response, reverse('settings'))
|
||
self.assertEqual(self.client.session['workspace_id'], workspace.id)
|
||
self.assertTrue(WorkspaceMembership.objects.filter(workspace=workspace, user=user, role=WorkspaceMembership.Role.OWNER).exists())
|
||
|
||
def test_workspace_switch_changes_overview_scope(self):
|
||
user = get_user_model().objects.create_user(username='workspace-switch', password='secret')
|
||
personal = get_or_create_personal_workspace(user)
|
||
company = Workspace.objects.create(owner=user, name='Company', slug='company')
|
||
WorkspaceMembership.objects.create(workspace=company, user=user, role=WorkspaceMembership.Role.OWNER)
|
||
Item.objects.create(workspace=personal, owner=user, content='Personal item')
|
||
Item.objects.create(workspace=company, owner=user, content='Company item')
|
||
self.client.force_login(user)
|
||
|
||
response = self.client.post(reverse('workspace_switch', args=[company.id]), {'next': reverse('index')})
|
||
self.assertRedirects(response, reverse('index'))
|
||
response = self.client.get(reverse('index'))
|
||
|
||
self.assertContains(response, 'Company item')
|
||
self.assertNotContains(response, 'Personal item')
|
||
|
||
def test_workspace_public_item_is_visible_to_workspace_member_only(self):
|
||
owner = get_user_model().objects.create_user(username='workspace-owner', password='secret')
|
||
member = get_user_model().objects.create_user(username='workspace-member', password='secret')
|
||
outsider = get_user_model().objects.create_user(username='workspace-outsider', password='secret')
|
||
workspace = Workspace.objects.create(owner=owner, name='Company', slug='company-public')
|
||
WorkspaceMembership.objects.create(workspace=workspace, user=owner, role=WorkspaceMembership.Role.OWNER)
|
||
WorkspaceMembership.objects.create(workspace=workspace, user=member, role=WorkspaceMembership.Role.MEMBER)
|
||
Item.objects.create(workspace=workspace, owner=owner, content='Workspace public', visibility=Item.Visibility.PUBLIC)
|
||
|
||
self.client.force_login(member)
|
||
self.client.post(reverse('workspace_switch', args=[workspace.id]), {'next': reverse('index')})
|
||
self.assertContains(self.client.get(reverse('index')), 'Workspace public')
|
||
|
||
self.client.force_login(outsider)
|
||
self.assertNotContains(self.client.get(reverse('index')), 'Workspace public')
|
||
|
||
def test_workspace_switcher_shows_due_counts_for_other_workspaces(self):
|
||
user = get_user_model().objects.create_user(username='workspace-due', password='secret')
|
||
personal = get_or_create_personal_workspace(user)
|
||
company = Workspace.objects.create(owner=user, name='Company Due', slug='company-due')
|
||
WorkspaceMembership.objects.create(workspace=company, user=user, role=WorkspaceMembership.Role.OWNER)
|
||
due = timezone.make_aware(datetime.combine(timezone.localdate(), time(12, 0)))
|
||
Item.objects.create(workspace=company, owner=user, kind=Item.Kind.TODO, content='Due elsewhere', due_at=due)
|
||
self.client.force_login(user)
|
||
self.client.post(reverse('workspace_switch', args=[personal.id]), {'next': reverse('index')})
|
||
|
||
response = self.client.get(reverse('index'))
|
||
|
||
self.assertContains(response, 'Company Due')
|
||
self.assertContains(response, 'text-bg-danger">1</span>')
|
||
self.assertNotContains(response, 'Due elsewhere')
|
||
|
||
def test_user_suggestions_are_limited_to_active_workspace(self):
|
||
owner = get_user_model().objects.create_user(username='suggest-owner', password='secret')
|
||
alice = get_user_model().objects.create_user(username='alice-suggest', first_name='Alice', password='secret')
|
||
outsider = get_user_model().objects.create_user(username='alice-foreign', password='secret')
|
||
workspace = Workspace.objects.create(owner=owner, name='Suggest Co', slug='suggest-co')
|
||
WorkspaceMembership.objects.create(workspace=workspace, user=owner, role=WorkspaceMembership.Role.OWNER)
|
||
WorkspaceMembership.objects.create(workspace=workspace, user=alice, role=WorkspaceMembership.Role.MEMBER)
|
||
get_or_create_personal_workspace(outsider)
|
||
self.client.force_login(owner)
|
||
self.client.post(reverse('workspace_switch', args=[workspace.id]), {'next': reverse('index')})
|
||
|
||
response = self.client.get(reverse('api_user_suggestions'), {'q': 'alice'})
|
||
|
||
self.assertEqual(response.status_code, 200)
|
||
usernames = [entry['username'] for entry in response.json()['users']]
|
||
self.assertIn('alice-suggest', usernames)
|
||
self.assertNotIn('alice-foreign', usernames)
|
||
|
||
def test_composer_has_user_mentions_and_image_slash_command(self):
|
||
user = get_user_model().objects.create_user(username='composer-menu', password='secret')
|
||
self.client.force_login(user)
|
||
|
||
response = self.client.get(reverse('index'))
|
||
|
||
self.assertContains(response, '/image')
|
||
self.assertContains(response, '/api/user-suggestions/')
|
||
self.assertContains(response, "fragment.startsWith('@')")
|
||
self.assertContains(response, "token==='![[")
|
||
|
||
def test_todo_can_be_assigned_to_workspace_member_with_mention(self):
|
||
owner = get_user_model().objects.create_user(username='assign-owner', password='secret')
|
||
assignee = get_user_model().objects.create_user(username='alice', password='secret')
|
||
workspace = Workspace.objects.create(owner=owner, name='Assign Co', slug='assign-co')
|
||
WorkspaceMembership.objects.create(workspace=workspace, user=owner, role=WorkspaceMembership.Role.OWNER)
|
||
WorkspaceMembership.objects.create(workspace=workspace, user=assignee, role=WorkspaceMembership.Role.MEMBER)
|
||
self.client.force_login(owner)
|
||
self.client.post(reverse('workspace_switch', args=[workspace.id]), {'next': reverse('index')})
|
||
|
||
response = self.client.post(reverse('index'), {'content': '/todo Prepare report @alice'})
|
||
|
||
self.assertRedirects(response, reverse('index'))
|
||
item = Item.objects.get(content='Prepare report @alice')
|
||
self.assertEqual(item.workspace, workspace)
|
||
self.assertEqual(item.assignee, assignee)
|
||
|
||
def test_assigned_private_todo_is_visible_to_assignee(self):
|
||
owner = get_user_model().objects.create_user(username='assign-owner2', password='secret')
|
||
assignee = get_user_model().objects.create_user(username='bob', password='secret')
|
||
workspace = Workspace.objects.create(owner=owner, name='Assign Co 2', slug='assign-co-2')
|
||
WorkspaceMembership.objects.create(workspace=workspace, user=owner, role=WorkspaceMembership.Role.OWNER)
|
||
WorkspaceMembership.objects.create(workspace=workspace, user=assignee, role=WorkspaceMembership.Role.MEMBER)
|
||
Item.objects.create(workspace=workspace, owner=owner, assignee=assignee, kind=Item.Kind.TODO, content='Assigned private')
|
||
self.client.force_login(assignee)
|
||
self.client.post(reverse('workspace_switch', args=[workspace.id]), {'next': reverse('index')})
|
||
|
||
response = self.client.get(reverse('index'))
|
||
|
||
self.assertContains(response, 'Assigned private')
|
||
self.assertContains(response, '@bob')
|
||
|
||
def test_team_todo_assignment_requires_team_membership(self):
|
||
owner = get_user_model().objects.create_user(username='assign-owner3', password='secret')
|
||
non_team_member = get_user_model().objects.create_user(username='charlie', password='secret')
|
||
workspace = Workspace.objects.create(owner=owner, name='Assign Co 3', slug='assign-co-3')
|
||
WorkspaceMembership.objects.create(workspace=workspace, user=owner, role=WorkspaceMembership.Role.OWNER)
|
||
WorkspaceMembership.objects.create(workspace=workspace, user=non_team_member, role=WorkspaceMembership.Role.MEMBER)
|
||
team = Team.objects.create(workspace=workspace, owner=owner, name='Ops', slug='ops')
|
||
TeamMembership.objects.create(team=team, user=owner, role=TeamMembership.Role.OWNER)
|
||
self.client.force_login(owner)
|
||
self.client.post(reverse('workspace_switch', args=[workspace.id]), {'next': reverse('index')})
|
||
|
||
self.client.post(reverse('index'), {'content': '/todo Team task @charlie #ops'})
|
||
|
||
item = Item.objects.get(content='Team task @charlie #ops')
|
||
self.assertEqual(item.team, team)
|
||
self.assertIsNone(item.assignee)
|
||
|
||
def test_authenticated_overview_is_limited_to_current_workspace(self):
|
||
user = get_user_model().objects.create_user(username='tenant-visible', password='secret')
|
||
other = get_user_model().objects.create_user(username='tenant-other', password='secret')
|
||
own = Item.objects.create(owner=user, content='Own item')
|
||
public_other = Item.objects.create(owner=other, content='Public other item', visibility=Item.Visibility.PUBLIC)
|
||
self.client.force_login(user)
|
||
|
||
response = self.client.get(reverse('index'))
|
||
|
||
self.assertContains(response, 'Own item')
|
||
self.assertNotContains(response, 'Public other item')
|
||
|
||
|
||
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_quick_create_can_override_and_persist_default_from_composer(self):
|
||
response = self.client.post(reverse('index'), {'content': 'Composer note', 'default_item_kind': Item.Kind.NOTE})
|
||
self.assertRedirects(response, reverse('index'))
|
||
self.assertEqual(Item.objects.get().kind, Item.Kind.NOTE)
|
||
self.preferences.refresh_from_db()
|
||
self.assertEqual(self.preferences.default_item_kind, Item.Kind.NOTE)
|
||
|
||
def test_default_item_kind_select_persists_immediately(self):
|
||
response = self.client.post(reverse('set_default_item_kind'), {'default_item_kind': Item.Kind.LINK})
|
||
self.assertEqual(response.status_code, 204)
|
||
self.preferences.refresh_from_db()
|
||
self.assertEqual(self.preferences.default_item_kind, Item.Kind.LINK)
|
||
|
||
response = self.client.get(reverse('index'))
|
||
self.assertContains(response, '<option value="link" selected>', html=False)
|
||
|
||
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('<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 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('<div class="col-12"><label class="form-label">'), 2)
|
||
self.assertNotContains(response, 'name="default_item_kind"')
|
||
|
||
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)
|
||
|
||
def test_inline_edit_comment_is_below_format_toolbar(self):
|
||
item = Item.objects.create(owner=self.user, content='Editable')
|
||
response = self.client.get(reverse('edit_item', args=[item.id]))
|
||
html = response.content.decode()
|
||
|
||
self.assertLess(html.index('name="content"'), html.index('format-toolbar'))
|
||
self.assertLess(html.index('format-toolbar'), html.index('name="comment"'))
|
||
|
||
def test_quick_create_is_disabled_while_inline_editor_is_open(self):
|
||
response = self.client.get(reverse('index'))
|
||
self.assertContains(response, ':disabled="editingItem"')
|
||
self.assertContains(response, 'inline-edit-form')
|
||
item = Item.objects.create(owner=self.user, content='Editable')
|
||
response = self.client.get(reverse('edit_item', args=[item.id]))
|
||
self.assertContains(response, 'inline-edit-form')
|
||
|
||
def test_tag_filter_list_depends_on_current_filters_but_composer_gets_visible_tags(self):
|
||
open_todo = Item.objects.create(owner=self.user, kind=Item.Kind.TODO, content='Open #openonly')
|
||
open_todo.sync_metadata()
|
||
done_todo = Item.objects.create(owner=self.user, kind=Item.Kind.TODO, content='Done #archiveonly', is_done=True)
|
||
done_todo.sync_metadata()
|
||
note = Item.objects.create(owner=self.user, kind=Item.Kind.NOTE, content='Note #noteonly')
|
||
note.sync_metadata()
|
||
other = get_user_model().objects.create_user(username='other-tags', password='secret')
|
||
foreign = Item.objects.create(owner=other, kind=Item.Kind.NOTE, content='Foreign #foreignonly')
|
||
foreign.sync_metadata()
|
||
public = Item.objects.create(owner=other, kind=Item.Kind.NOTE, content='Public #publiconly', visibility=Item.Visibility.PUBLIC)
|
||
public.sync_metadata()
|
||
|
||
response = self.client.get(f'{reverse("index")}?status=archive')
|
||
html = response.content.decode()
|
||
filter_tags = html.split('id="tag-list"', 1)[1].split('</div>', 1)[0]
|
||
self.assertIn('#archiveonly', filter_tags)
|
||
self.assertNotIn('#openonly', filter_tags)
|
||
self.assertNotIn('#noteonly', filter_tags)
|
||
self.assertIn("'openonly'", html)
|
||
self.assertIn("'noteonly'", html)
|
||
self.assertNotIn('publiconly', html)
|
||
self.assertNotIn('foreignonly', html)
|
||
|
||
response = self.client.get(f'{reverse("tag_list")}?status=archive')
|
||
self.assertContains(response, '#archiveonly')
|
||
self.assertNotContains(response, '#openonly')
|
||
|
||
def test_tag_api_returns_only_visible_tags(self):
|
||
own = Item.objects.create(owner=self.user, content='Own #ownapi')
|
||
own.sync_metadata()
|
||
other = get_user_model().objects.create_user(username='other-api-tags', password='secret')
|
||
foreign = Item.objects.create(owner=other, content='Foreign #foreignapi')
|
||
foreign.sync_metadata()
|
||
public = Item.objects.create(owner=other, content='Public #publicapi', visibility=Item.Visibility.PUBLIC)
|
||
public.sync_metadata()
|
||
|
||
response = self.client.get(reverse('api_tags'))
|
||
|
||
self.assertEqual(response.status_code, 200)
|
||
tags = response.json()['tags']
|
||
self.assertIn('ownapi', tags)
|
||
self.assertNotIn('publicapi', tags)
|
||
self.assertNotIn('foreignapi', tags)
|
||
|
||
def test_due_filter_also_shows_next_seven_days_separately(self):
|
||
today = timezone.localdate()
|
||
due = timezone.make_aware(datetime.combine(today, time(12, 0)))
|
||
upcoming = timezone.make_aware(datetime.combine(today + timedelta(days=3), time(12, 0)))
|
||
later = timezone.make_aware(datetime.combine(today + timedelta(days=8), time(12, 0)))
|
||
Item.objects.create(owner=self.user, kind=Item.Kind.TODO, content='Due now', due_at=due)
|
||
Item.objects.create(owner=self.user, kind=Item.Kind.TODO, content='Upcoming soon', due_at=upcoming)
|
||
Item.objects.create(owner=self.user, kind=Item.Kind.TODO, content='Later task', due_at=later)
|
||
|
||
response = self.client.get(f'{reverse("index")}?status=due')
|
||
|
||
self.assertContains(response, 'Due now')
|
||
self.assertContains(response, 'Upcoming soon')
|
||
self.assertContains(response, 'Due in the next 7 days')
|
||
self.assertNotContains(response, 'Later task')
|
||
|
||
|
||
class AttachmentEditTests(TestCase):
|
||
def setUp(self):
|
||
self.media_root = tempfile.mkdtemp()
|
||
self.override = override_settings(MEDIA_ROOT=self.media_root)
|
||
self.override.enable()
|
||
self.user = get_user_model().objects.create_user(username='attachment-editor', password='secret')
|
||
self.client.force_login(self.user)
|
||
|
||
def tearDown(self):
|
||
self.override.disable()
|
||
shutil.rmtree(self.media_root, ignore_errors=True)
|
||
|
||
def test_uploaded_file_is_visible_after_first_inline_edit_save(self):
|
||
item = Item.objects.create(owner=self.user, content='Original')
|
||
uploaded = SimpleUploadedFile('hello.txt', b'hello', content_type='text/plain')
|
||
|
||
response = self.client.post(reverse('edit_item', args=[item.id]), {
|
||
'content': 'Original', 'comment': '', 'visibility': Item.Visibility.PRIVATE, 'url': '', 'files': uploaded,
|
||
}, HTTP_HX_REQUEST='true')
|
||
|
||
self.assertEqual(response.status_code, 200)
|
||
self.assertEqual(item.attachments.count(), 1)
|
||
self.assertContains(response, 'hello.txt')
|
||
|
||
def test_existing_attachments_are_shown_and_can_be_deleted_in_editor(self):
|
||
item = Item.objects.create(owner=self.user, content='Original')
|
||
attachment = Attachment.objects.create(item=item, file=SimpleUploadedFile('duplicate.txt', b'duplicate', content_type='text/plain'))
|
||
|
||
response = self.client.get(reverse('item_editor', args=[item.id]))
|
||
self.assertContains(response, 'duplicate.txt')
|
||
self.assertContains(response, 'name="delete_attachments"')
|
||
|
||
response = self.client.post(reverse('item_editor', args=[item.id]), {
|
||
'content': 'Original', 'comment': '', 'visibility': Item.Visibility.PRIVATE, 'url': '', 'delete_attachments': [str(attachment.id)],
|
||
})
|
||
|
||
self.assertRedirects(response, item.get_absolute_url())
|
||
self.assertFalse(item.attachments.exists())
|
||
|
||
def test_detail_editor_uses_original_return_url_not_partial_endpoint(self):
|
||
item = Item.objects.create(owner=self.user, content='Original')
|
||
response = self.client.get(f'{item.get_absolute_url()}?next=/')
|
||
|
||
self.assertContains(response, f'{reverse("item_editor", args=[item.id])}?next=/')
|
||
self.assertNotContains(response, reverse('item_card', args=[item.id]))
|
||
|
||
def test_unembedded_attachment_images_open_in_lightbox(self):
|
||
item = Item.objects.create(owner=self.user, content='Original')
|
||
Attachment.objects.create(item=item, file=SimpleUploadedFile('photo.jpg', b'image', content_type='image/jpeg'))
|
||
|
||
response = self.client.get(item.get_absolute_url())
|
||
|
||
self.assertContains(response, 'id="image-lightbox"')
|
||
self.assertContains(response, 'class="btn p-0 border-0 bg-transparent attachment-image-preview"')
|
||
self.assertContains(response, 'data-image-url=')
|
||
|
||
|
||
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(workspace=get_or_create_personal_workspace(self.user), 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_tutorial_is_listed_but_chapter_is_hidden_from_collection_overview(self):
|
||
tutorial = Collection.objects.create(owner=self.user, title='my2dos Tutorial', mode=Collection.Mode.TUTORIAL)
|
||
chapter = Collection.objects.create(owner=self.user, title='my2dos Typen', mode=Collection.Mode.CHAPTER)
|
||
|
||
response = self.client.get(reverse('collection_list'))
|
||
|
||
self.assertContains(response, tutorial.title)
|
||
self.assertNotIn(chapter, [entry['collection'] for entry in response.context['entries']])
|
||
|
||
def test_collection_list_can_filter_collection_types(self):
|
||
Collection.objects.create(owner=self.user, title='Manual', mode=Collection.Mode.MANUAL)
|
||
Collection.objects.create(owner=self.user, title='Tutorial', mode=Collection.Mode.TUTORIAL)
|
||
Collection.objects.create(owner=self.user, title='Tagged', mode=Collection.Mode.TAGS)
|
||
Collection.objects.create(owner=self.user, title='Chapter', mode=Collection.Mode.CHAPTER)
|
||
|
||
response = self.client.get(f'{reverse("collection_list")}?mode=chapter')
|
||
|
||
self.assertEqual([entry['collection'].title for entry in response.context['entries']], ['Chapter'])
|
||
|
||
def test_orphan_chapters_are_shown_as_warning(self):
|
||
Collection.objects.create(owner=self.user, title='Lonely chapter', mode=Collection.Mode.CHAPTER)
|
||
|
||
response = self.client.get(reverse('collection_list'))
|
||
|
||
self.assertContains(response, 'Verwaiste Kapitel')
|
||
self.assertContains(response, 'Lonely chapter')
|
||
|
||
def test_tutorial_can_include_chapter_collection(self):
|
||
tutorial = Collection.objects.create(owner=self.user, title='my2dos Tutorial', mode=Collection.Mode.TUTORIAL)
|
||
chapter = Collection.objects.create(owner=self.user, title='my2dos Typen', mode=Collection.Mode.CHAPTER)
|
||
note = Item.objects.create(owner=self.user, kind=Item.Kind.NOTE, content='## Todos')
|
||
CollectionSection.objects.create(collection=chapter, item=note, position=0)
|
||
manage_url = reverse('collection_manage', args=[tutorial.id])
|
||
|
||
response = self.client.post(manage_url, {'action': 'add_collection', 'collection_id': chapter.id})
|
||
|
||
self.assertRedirects(response, manage_url)
|
||
self.assertTrue(tutorial.sections.filter(sub_collection=chapter).exists())
|
||
response = self.client.get(tutorial.get_absolute_url())
|
||
self.assertContains(response, '<h2>my2dos Typen</h2>', html=True)
|
||
self.assertContains(response, '<h3>Todos</h3>', html=True)
|
||
|
||
def test_collection_overview_is_available_on_mobile(self):
|
||
response = self.client.get(reverse('collection_list'))
|
||
|
||
self.assertNotContains(response, 'Collections are currently available on desktop only.')
|
||
self.assertContains(response, 'Sammlungen')
|
||
|
||
def test_collection_manage_links_to_note_editor_with_return_target(self):
|
||
collection = Collection.objects.create(owner=self.user, title='Reading view')
|
||
manage_url = reverse('collection_manage', args=[collection.id])
|
||
|
||
response = self.client.get(manage_url)
|
||
|
||
self.assertContains(response, f'{reverse("new_item")}?kind=note&collection={collection.id}&next={manage_url}')
|
||
|
||
def test_create_note_for_collection_in_editor_adds_it_and_returns(self):
|
||
collection = Collection.objects.create(owner=self.user, title='Reading view')
|
||
manage_url = reverse('collection_manage', args=[collection.id])
|
||
|
||
response = self.client.post(f'{reverse("new_item")}?kind=note&collection={collection.id}&next={manage_url}', {
|
||
'collection': collection.id, 'next': manage_url, 'content': '## Fresh note #new',
|
||
})
|
||
|
||
note = Item.objects.get(content='## Fresh note #new')
|
||
self.assertRedirects(response, manage_url)
|
||
self.assertTrue(collection.sections.filter(item=note).exists())
|
||
self.assertEqual(note.visibility, collection.visibility)
|
||
self.assertEqual(list(note.tags.values_list('name', flat=True)), ['new'])
|
||
|
||
def test_create_chapter_from_tutorial_manage_adds_and_opens_it(self):
|
||
tutorial = Collection.objects.create(owner=self.user, title='my2dos Tutorial', mode=Collection.Mode.TUTORIAL)
|
||
|
||
response = self.client.post(reverse('collection_manage', args=[tutorial.id]), {'action': 'create_chapter', 'title': 'Tags'})
|
||
|
||
chapter = Collection.objects.get(title='Tags')
|
||
self.assertEqual(chapter.mode, Collection.Mode.CHAPTER)
|
||
self.assertTrue(tutorial.sections.filter(sub_collection=chapter).exists())
|
||
self.assertRedirects(response, reverse('collection_manage', args=[chapter.id]))
|
||
|
||
def test_collection_sections_can_be_reordered_by_position(self):
|
||
collection = Collection.objects.create(owner=self.user, title='Reading view')
|
||
first = Item.objects.create(owner=self.user, kind=Item.Kind.NOTE, content='First')
|
||
second = Item.objects.create(owner=self.user, kind=Item.Kind.NOTE, content='Second')
|
||
first_section = CollectionSection.objects.create(collection=collection, item=first, position=0)
|
||
second_section = CollectionSection.objects.create(collection=collection, item=second, position=1)
|
||
|
||
response = self.client.post(reverse('collection_manage', args=[collection.id]), {
|
||
'action': 'move_to', 'section_id': second_section.id, 'position': 0,
|
||
})
|
||
|
||
self.assertRedirects(response, reverse('collection_manage', args=[collection.id]))
|
||
self.assertEqual(list(collection.sections.order_by('position').values_list('id', flat=True)), [second_section.id, first_section.id])
|
||
|
||
def test_collection_detail_is_available_on_mobile(self):
|
||
collection = Collection.objects.create(owner=self.user, title='Mobile reading')
|
||
response = self.client.get(collection.get_absolute_url())
|
||
|
||
self.assertNotContains(response, 'Collections are currently available on desktop only.')
|
||
self.assertContains(response, 'collection-document')
|
||
|
||
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_tag_collection_todos_are_rendered_with_checkbox(self):
|
||
todo = Item.objects.create(owner=self.user, kind=Item.Kind.TODO, content='Check me #project')
|
||
todo.sync_metadata()
|
||
collection = Collection.objects.create(
|
||
owner=self.user, title='Typed tags', mode=Collection.Mode.TAGS,
|
||
include_notes=False, include_todos=True,
|
||
)
|
||
collection.filter_tags.add(Tag.objects.get(name='project'))
|
||
|
||
response = self.client.get(collection.get_absolute_url())
|
||
|
||
self.assertContains(response, 'type="checkbox"')
|
||
self.assertContains(response, reverse('toggle_done', args=[todo.id]))
|
||
|
||
def test_tag_collection_todo_checkbox_toggles_and_returns_to_collection(self):
|
||
todo = Item.objects.create(owner=self.user, kind=Item.Kind.TODO, content='Check me #project')
|
||
todo.sync_metadata()
|
||
collection = Collection.objects.create(
|
||
owner=self.user, title='Typed tags', mode=Collection.Mode.TAGS,
|
||
include_notes=False, include_todos=True,
|
||
)
|
||
collection.filter_tags.add(Tag.objects.get(name='project'))
|
||
next_url = f'{collection.get_absolute_url()}#item-{todo.id}'
|
||
|
||
response = self.client.post(reverse('toggle_done', args=[todo.id]), {'next': next_url})
|
||
|
||
self.assertRedirects(response, next_url, fetch_redirect_response=False)
|
||
todo.refresh_from_db()
|
||
self.assertTrue(todo.is_done)
|
||
|
||
def test_tag_collection_can_filter_included_item_types(self):
|
||
note = Item.objects.create(owner=self.user, kind=Item.Kind.NOTE, content='Matching note #project')
|
||
note.sync_metadata()
|
||
todo = Item.objects.create(owner=self.user, kind=Item.Kind.TODO, content='Matching todo #project')
|
||
todo.sync_metadata()
|
||
link = Item.objects.create(owner=self.user, kind=Item.Kind.LINK, content='Matching link #project')
|
||
link.sync_metadata()
|
||
collection = Collection.objects.create(
|
||
owner=self.user, title='Typed tags', mode=Collection.Mode.TAGS,
|
||
include_notes=False, include_todos=True, include_links=False, include_journal=False,
|
||
)
|
||
collection.filter_tags.add(Tag.objects.get(name='project'))
|
||
|
||
response = self.client.get(collection.get_absolute_url())
|
||
|
||
self.assertContains(response, 'Matching todo')
|
||
self.assertNotContains(response, 'Matching note')
|
||
self.assertNotContains(response, 'Matching link')
|
||
|
||
def test_tag_collection_form_offers_item_type_selection(self):
|
||
response = self.client.get(reverse('collection_create'))
|
||
|
||
self.assertContains(response, 'Entry types')
|
||
self.assertContains(response, 'name="include_notes"')
|
||
self.assertContains(response, 'name="include_todos"')
|
||
self.assertContains(response, 'name="include_links"')
|
||
self.assertContains(response, 'name="include_journal"')
|
||
|
||
def test_current_list_can_be_exported_as_markdown(self):
|
||
Item.objects.create(owner=self.user, kind=Item.Kind.TODO, content='Open task #project').sync_metadata()
|
||
Item.objects.create(owner=self.user, kind=Item.Kind.NOTE, content='Other note')
|
||
|
||
response = self.client.get(f'{reverse("export_items")}?kind=todo&tag=project')
|
||
|
||
self.assertEqual(response.status_code, 200)
|
||
self.assertEqual(response['Content-Type'], 'text/markdown; charset=utf-8')
|
||
body = response.content.decode()
|
||
self.assertIn('- [ ] Open task #project', body)
|
||
self.assertNotIn('Other note', body)
|
||
|
||
def test_collection_can_be_exported_as_markdown(self):
|
||
collection = Collection.objects.create(owner=self.user, title='Reading view', description='Guide')
|
||
todo = Item.objects.create(owner=self.user, kind=Item.Kind.TODO, content='Todo section #project')
|
||
note = Item.objects.create(owner=self.user, kind=Item.Kind.NOTE, content='## Note section #project')
|
||
CollectionSection.objects.create(collection=collection, item=todo, position=0)
|
||
CollectionSection.objects.create(collection=collection, item=note, position=1)
|
||
|
||
response = self.client.get(reverse('collection_export', args=[collection.id]))
|
||
|
||
self.assertEqual(response.status_code, 200)
|
||
body = response.content.decode()
|
||
self.assertIn('# Reading view', body)
|
||
self.assertIn('Guide', body)
|
||
self.assertIn('- [ ] Todo section', body)
|
||
self.assertIn('## Note section', body)
|
||
self.assertNotIn('#project', body)
|
||
|
||
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, 'href="#section-1">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_manage_marks_already_added_notes(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')
|
||
CollectionSection.objects.create(collection=collection, item=note, position=0)
|
||
|
||
response = self.client.get(f'{reverse("collection_manage", args=[collection.id])}?q=Findable')
|
||
|
||
self.assertContains(response, 'Bereits in dieser Sammlung')
|
||
self.assertContains(response, '1× verwendet')
|
||
self.assertContains(response, 'disabled')
|
||
|
||
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_workspace_public_not_anonymous(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, 404)
|
||
|
||
def test_collection_tag_is_not_removed_as_unused(self):
|
||
tag = Tag.objects.create(workspace=get_or_create_personal_workspace(self.user), 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.workspace = get_or_create_personal_workspace(self.user)
|
||
self.help_tag = Tag.objects.create(workspace=self.workspace, name='help')
|
||
self.api_key = ApiKey.objects.create(workspace=self.workspace, 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_settings_only_offer_own_tags(self):
|
||
self.client.force_login(self.user)
|
||
self.create_item('Own tag #own')
|
||
other = get_user_model().objects.create_user(username='api-other', password='secret')
|
||
other_item = Item.objects.create(owner=other, content='Other tag #foreign')
|
||
other_item.sync_metadata()
|
||
|
||
response = self.client.get(reverse('settings'))
|
||
api_section = response.content.decode().split('id="api"', 1)[1]
|
||
|
||
self.assertIn('#own', api_section)
|
||
self.assertNotIn('#foreign', api_section)
|
||
|
||
def test_api_key_cannot_be_created_with_foreign_tag(self):
|
||
self.client.force_login(self.user)
|
||
other = get_user_model().objects.create_user(username='api-other', password='secret')
|
||
other_item = Item.objects.create(owner=other, content='Other tag #foreign')
|
||
other_item.sync_metadata()
|
||
foreign_tag = Tag.objects.get(name='foreign')
|
||
|
||
response = self.client.post(reverse('settings'), {'action': 'apikey', 'name': 'Bad key', 'tags': [foreign_tag.id]})
|
||
|
||
self.assertEqual(response.status_code, 200)
|
||
self.assertFalse(ApiKey.objects.filter(owner=self.user, name='Bad key').exists())
|
||
|
||
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_key_only_returns_owner_items_not_other_public_items(self):
|
||
own = self.create_item('Own public help #help')
|
||
other = get_user_model().objects.create_user(username='api-public-other', password='secret')
|
||
public = Item.objects.create(owner=other, content='Other public help #help', visibility=Item.Visibility.PUBLIC)
|
||
public.sync_metadata()
|
||
|
||
response = self.client.get(reverse('api_items'), HTTP_X_API_KEY=self.api_key.token)
|
||
|
||
self.assertEqual(response.status_code, 200)
|
||
ids = [item['id'] for item in response.json()['items']]
|
||
self.assertIn(own.id, ids)
|
||
self.assertNotIn(public.id, ids)
|
||
|
||
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')
|