mirror of
https://github.com/wahyd4/links.git
synced 2026-08-09 05:06:16 +10:00
Select2 submits raw text as the value for new tags typed into the tag
box (tags: true + createTag in link_form.html). The previous
ModelMultipleChoiceField silently dropped those values because they
were not PKs of existing tags, so new tags were never persisted.
Add TagInputField, a tolerant ModelMultipleChoiceField subclass:
- existing tag PKs resolve as before
- raw text is treated as a new (or case-insensitive-existing) tag
- names are stripped and lowercased before get_or_create so the tag
namespace stays consistent and case-insensitive duplicates are
avoided. The Tag.save() hook still derives the slug.
LinkForm now uses TagInputField for its tags field, so both
LinkCreateView and LinkUpdateView pick up the new behaviour. The
Select2 createTag callback in link_form.html now lowercases the term
so users see immediately what will be stored.
Add 7 regression tests in TestLinkTagAutoCreate covering: new tag
created + lowercased; multiple new tags; existing tag reused (no
duplicate); mixed PK + new-text submission; no tags submitted; empty
or whitespace-only submission ignored; whitespace stripped.
237 lines
8.8 KiB
Python
237 lines
8.8 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, 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
|