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