mirror of
https://github.com/wahyd4/links.git
synced 2026-08-09 05:06:16 +10:00
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
409 lines
16 KiB
Python
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"
|