Files
links/tests/test_links_api.py
T
junv 174b296c62 feat(links): log CONTENT_EDIT on text changes (no content persisted)
Previously, editing a custom link's text content created a spurious
URL_CHANGE row (fixed last commit) but no audit entry at all for the
actual content change. The product requirement: a change-log row SHOULD
be created so auditors see content was edited, but should NOT persist
the content itself.

LinkUpdateView now:

- Captures self.original_text in get_initial() alongside
  self.original_url.
- After super().form_valid() writes the new text, compares old vs new
  text. If different, creates a LinkChangeLog with
  change_type=CONTENT_EDIT and metadata={'changed': True} — no content
  payload. Applies to both LINK and CUSTOM link types.
- URL-change logging behaviour is unchanged.

Tests in TestCustomLinkEditUrlChangeLog now cover:
  - custom link text edit creates CONTENT_EDIT, no URL_CHANGE
  - custom link text unchanged creates no log
  - regular link text change creates CONTENT_EDIT
  - regular link URL change still logs URL_CHANGE
2026-07-16 11:24:04 +10:00

409 lines
16 KiB
Python

"""
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="del-me", 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": "chatgpt-translate",
"original_url": original_url,
"link_type": Link.LinkType.LINK,
"description": "",
"text": "",
},
follow=True,
)
assert response.status_code == 200
link = Link.objects.get(alias="chatgpt-translate")
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": "chatgpt-verify",
"original_url": original_url,
"link_type": Link.LinkType.LINK,
"description": "",
"text": "",
},
follow=True,
)
assert response.status_code == 200
link = Link.objects.get(alias="chatgpt-verify")
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": "chatgpt-both",
"original_url": original_url,
"link_type": Link.LinkType.LINK,
"description": "",
"text": "",
},
follow=True,
)
assert response.status_code == 200
link = Link.objects.get(alias="chatgpt-both")
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": "bad-url",
"original_url": "not-a-url-at-all",
"link_type": Link.LinkType.LINK,
"description": "",
"text": "",
},
follow=True,
)
assert not Link.objects.filter(alias="bad-url").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": "tagged-link",
"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="tagged-link").exists()
link = Link.objects.get(alias="tagged-link")
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="tagged-link")
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="tagged-link")
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="tagged-link")
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="tagged-link")
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="tagged-link")
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="tagged-link")
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"