""" Integration tests for the Links feature. Links are managed via Django template views, tested through the Django test client. """ import pytest from links.models import Link, LinkChangeLog, Tag @pytest.mark.django_db class TestLinksUI: def test_list_page_returns_200(self, client): response = client.get("/", follow=True) assert response.status_code == 200 def test_create_link(self, client): response = client.post( "/create/", { "alias": "testlink", "original_url": "https://example.com", "link_type": Link.LinkType.LINK, "description": "", "text": "", }, follow=True, ) assert response.status_code == 200 assert Link.objects.filter(alias="testlink").exists() def test_redirect_follows_alias(self, client): Link.objects.create(alias="gh", original_url="https://github.com") response = client.get("/gh/") assert response.status_code == 302 assert response["Location"] == "https://github.com" def test_redirect_unknown_alias_goes_to_create(self, client): response = client.get("/definitely-not-a-real-alias/") assert response.status_code == 302 assert "create" in response["Location"] def test_delete_link(self, client): link = Link.objects.create(alias="delme", original_url="https://example.com") response = client.post(f"/delete/{link.pk}/") assert response.status_code in (200, 302) assert not Link.objects.filter(pk=link.pk).exists() def test_link_with_template_url(self, client): Link.objects.create( alias="gosearch", original_url="https://google.com/search?q={query,default=hello}", ) response = client.get("/gosearch/") assert response.status_code == 302 assert "q=hello" in response["Location"] def test_link_with_template_param_override(self, client): Link.objects.create( alias="wiki", original_url="https://en.wikipedia.org/wiki/{topic,default=Python}", ) response = client.get("/wiki/Django/") assert response.status_code == 302 assert "Django" in response["Location"] @pytest.mark.django_db class TestTemplateLinkFormValidation: """Tests for template URL validation in LinkForm, including static Unicode text.""" def test_create_link_with_unicode_prefix_in_template_url(self, client): """Static Chinese text before a template param should be accepted.""" original_url = 'https://chatgpt.com/?q=翻译下面的段落为中文: {query,default=Cool beans}' response = client.post( "/create/", { "alias": "chatgpttranslate", "original_url": original_url, "link_type": Link.LinkType.LINK, "description": "", "text": "", }, follow=True, ) assert response.status_code == 200 link = Link.objects.get(alias="chatgpttranslate") assert link.original_url == original_url def test_create_link_with_unicode_suffix_in_template_url(self, client): """Static Chinese text after a template param should be accepted.""" original_url = 'https://chatgpt.com/?q={query,default=Cool beans},保证真实性' response = client.post( "/create/", { "alias": "chatgptverify", "original_url": original_url, "link_type": Link.LinkType.LINK, "description": "", "text": "", }, follow=True, ) assert response.status_code == 200 link = Link.objects.get(alias="chatgptverify") assert link.original_url == original_url def test_create_link_with_unicode_around_template_param(self, client): """Static Unicode text both before and after a template param should be accepted.""" original_url = 'https://chatgpt.com/?q=翻译: {query,default=hello} 保证真实性' response = client.post( "/create/", { "alias": "chatgptboth", "original_url": original_url, "link_type": Link.LinkType.LINK, "description": "", "text": "", }, follow=True, ) assert response.status_code == 200 link = Link.objects.get(alias="chatgptboth") assert link.original_url == original_url def test_create_link_with_invalid_url_still_rejected(self, client): """A completely invalid URL (no scheme/host) must still be rejected.""" response = client.post( "/create/", { "alias": "badurl", "original_url": "not-a-url-at-all", "link_type": Link.LinkType.LINK, "description": "", "text": "", }, follow=True, ) assert not Link.objects.filter(alias="badurl").exists() @pytest.mark.django_db class TestLinkTagAutoCreate: """Select2 submits either existing tag PKs or raw text for new tags. New tags must be created server-side and lowercased.""" def _create(self, client, tags): return client.post( "/create/", { "alias": "taggedlink", "original_url": "https://example.com", "link_type": Link.LinkType.LINK, "description": "", "text": "", "tags": tags, }, follow=True, ) def test_new_tag_is_created_and_lowercased(self, client): existing = Tag.objects.count() response = self._create(client, ["Python"]) assert response.status_code == 200 assert Link.objects.filter(alias="taggedlink").exists() link = Link.objects.get(alias="taggedlink") created = Tag.objects.exclude(pk__in=[t.pk for t in link.tags.all()]) assert Tag.objects.count() == existing + 1 tag = Tag.objects.filter(name__iexact="python").first() assert tag is not None assert tag.name == "python" assert tag.slug == "python" assert tag in link.tags.all() def test_multiple_new_tags_lowercased(self, client): before = Tag.objects.count() response = self._create(client, ["Python", "Django", "WEB"]) assert response.status_code == 200 link = Link.objects.get(alias="taggedlink") names = sorted(t.name for t in link.tags.all()) assert names == ["django", "python", "web"] assert Tag.objects.count() == before + 3 def test_existing_tag_reused_no_duplicate(self, client): existing = Tag.objects.create(name="python", slug="python") before = Tag.objects.count() response = self._create(client, ["Python"]) assert response.status_code == 200 link = Link.objects.get(alias="taggedlink") assert link.tags.count() == 1 assert link.tags.first().pk == existing.pk # No duplicate tag created assert Tag.objects.count() == before assert Tag.objects.filter(name="python").count() == 1 def test_existing_tag_pk_and_new_tag_mixed(self, client): existing = Tag.objects.create(name="existing", slug="existing") before = Tag.objects.count() response = self._create(client, [str(existing.pk), "NewTag"]) assert response.status_code == 200 link = Link.objects.get(alias="taggedlink") names = sorted(t.name for t in link.tags.all()) assert names == ["existing", "newtag"] assert Tag.objects.count() == before + 1 def test_no_tags_submitted_creates_no_tags(self, client): before = Tag.objects.count() response = self._create(client, []) assert response.status_code == 200 link = Link.objects.get(alias="taggedlink") assert link.tags.count() == 0 assert Tag.objects.count() == before def test_empty_and_whitespace_values_ignored(self, client): before = Tag.objects.count() response = self._create(client, ["", " ", "real"]) assert response.status_code == 200 link = Link.objects.get(alias="taggedlink") assert link.tags.count() == 1 assert link.tags.first().name == "real" assert Tag.objects.count() == before + 1 def test_whitespace_in_tag_name_is_stripped(self, client): before = Tag.objects.count() # Select2's createTag trims, but be defensive server-side too response = self._create(client, [" spaced out "]) assert response.status_code == 200 link = Link.objects.get(alias="taggedlink") tag = link.tags.first() assert tag.name == "spaced out" assert tag.slug == "spaced-out" assert Tag.objects.count() == before + 1 @pytest.mark.django_db class TestCustomLinkEditUrlChangeLog: """Editing a CUSTOM link's content (text) must NOT log a spurious "URL changed" entry, because custom-link URLs are derived from the alias and the original_url form field is hidden for custom links. Regression test for the bug where every edit of a custom link produced a LinkChangeLog row. A CONTENT_EDIT entry, however, SHOULD be created whenever the text changes (for either custom or regular links). The actual content is NOT persisted in the log — only a marker that it changed.""" def _update(self, client, link, **payload): data = { "alias": link.alias, # The original_url input is hidden for custom links and not # submitted by the browser, so keep it blank in this helper. "original_url": "", "link_type": Link.LinkType.CUSTOM, "description": "", "text": "", } data.update(payload) return client.post(f"/link/{link.pk}/edit/", data, follow=True) def test_custom_link_text_edit_creates_no_url_change_log(self, client): link = Link.objects.create( alias="mynote", link_type=Link.LinkType.CUSTOM, original_url="/custom/mynote", text="old body text", ) before_url_logs = LinkChangeLog.objects.filter( link=link, change_type=LinkChangeLog.ChangeType.URL_CHANGE ).count() before_content_logs = LinkChangeLog.objects.filter( link=link, change_type=LinkChangeLog.ChangeType.CONTENT_EDIT ).count() response = self._update(client, link, text="new body text") assert response.status_code == 200 link.refresh_from_db() assert link.text == "new body text" assert link.original_url == "/custom/mynote" # No spurious URL-change log assert LinkChangeLog.objects.filter( link=link, change_type=LinkChangeLog.ChangeType.URL_CHANGE ).count() == before_url_logs # A content-edit log IS created, without persisting the content content_logs = LinkChangeLog.objects.filter( link=link, change_type=LinkChangeLog.ChangeType.CONTENT_EDIT ).order_by("-changed_at") assert content_logs.count() == before_content_logs + 1 assert content_logs.first().metadata == {"changed": True} assert "new body text" not in str(content_logs.first().metadata) assert "old body text" not in str(content_logs.first().metadata) def test_regular_link_text_unchanged_creates_no_url_change_log(self, client): link = Link.objects.create( alias="gh", link_type=Link.LinkType.LINK, original_url="https://github.com", ) before = LinkChangeLog.objects.filter(link=link).count() response = client.post( f"/link/{link.pk}/edit/", { "alias": "gh", "original_url": "https://github.com", "link_type": Link.LinkType.LINK, "description": "an edit that keeps the URL", "text": "", }, follow=True, ) assert response.status_code == 200 assert LinkChangeLog.objects.filter(link=link).count() == before def test_custom_link_text_unchanged_creates_no_content_log(self, client): link = Link.objects.create( alias="mynote", link_type=Link.LinkType.CUSTOM, original_url="/custom/mynote", text="same body text", ) before = LinkChangeLog.objects.filter(link=link).count() response = self._update(client, link, text="same body text") assert response.status_code == 200 assert LinkChangeLog.objects.filter(link=link).count() == before def test_regular_link_text_change_creates_content_log(self, client): """Regular (LINK-type) links can also carry text content; editing that text should produce a CONTENT_EDIT entry too.""" link = Link.objects.create( alias="gh", link_type=Link.LinkType.LINK, original_url="https://github.com", text="original notes", ) before = LinkChangeLog.objects.filter(link=link).count() response = client.post( f"/link/{link.pk}/edit/", { "alias": "gh", "original_url": "https://github.com", "link_type": Link.LinkType.LINK, "description": "", "text": "edited notes", }, follow=True, ) assert response.status_code == 200 content_logs = LinkChangeLog.objects.filter( link=link, change_type=LinkChangeLog.ChangeType.CONTENT_EDIT ).order_by("-changed_at") assert content_logs.count() == before + 1 assert content_logs.first().metadata == {"changed": True} assert "edited notes" not in str(content_logs.first().metadata) def test_regular_link_url_change_logs_change(self, client): link = Link.objects.create( alias="gh", link_type=Link.LinkType.LINK, original_url="https://github.com", ) before = LinkChangeLog.objects.filter(link=link).count() response = client.post( f"/link/{link.pk}/edit/", { "alias": "gh", "original_url": "https://github.com/junv", "link_type": Link.LinkType.LINK, "description": "", "text": "", }, follow=True, ) assert response.status_code == 200 logs = LinkChangeLog.objects.filter(link=link).order_by("-changed_at") assert logs.count() == before + 1 assert logs.first().old_url == "https://github.com" assert logs.first().new_url == "https://github.com/junv" def test_custom_link_alias_change_logs_url_change(self, client): """When the alias of a custom link changes, the derived URL changes too, and that should be logged once.""" link = Link.objects.create( alias="mynote", link_type=Link.LinkType.CUSTOM, original_url="/custom/mynote", text="body text", ) before = LinkChangeLog.objects.filter(link=link).count() # Helper defaults text to "" (custom links require non-empty text) response = self._update(client, link, alias="newnote", text="body text") assert response.status_code == 200 link.refresh_from_db() assert link.alias == "newnote" assert link.original_url == "/custom/newnote" logs = LinkChangeLog.objects.filter(link=link).order_by("-changed_at") assert logs.count() == before + 1 assert logs.first().old_url == "/custom/mynote" assert logs.first().new_url == "/custom/newnote"