mirror of
https://github.com/wahyd4/links.git
synced 2026-08-08 21:04:53 +10:00
- Hide action_config JSON field from the form (internal storage, not user-editable)
- Replace Action dropdown with an Apple-style radio card (💬 发消息到 Discord)
- Rename Message template → 消息内容 with plain-language help text
- Add live message preview that highlights {query} as a chip
- Tests: edit page renders card + Chinese labels, no JSON exposed (109 passed)
253 lines
11 KiB
Python
253 lines
11 KiB
Python
"""
|
||
Tests for Action links (link_type=ACTION, e.g. send-to-Discord).
|
||
|
||
Covers: model persistence, form validation/assembly, and the
|
||
redirect_to_original execution path (with mocked webhook).
|
||
"""
|
||
import pytest
|
||
from django.test import Client
|
||
from unittest.mock import patch
|
||
|
||
from links.models import Link
|
||
from links.forms import LinkForm
|
||
from links.actions import render_template, execute_action
|
||
from links import views
|
||
|
||
|
||
@pytest.fixture
|
||
def action_link(db):
|
||
return Link.objects.create(
|
||
alias='testaction',
|
||
link_type=Link.LinkType.ACTION,
|
||
action_config={
|
||
'action_type': 'discord_send',
|
||
'message_template': '@小黑 {query}',
|
||
},
|
||
)
|
||
|
||
|
||
# ── render_template ──────────────────────────────────────────────────────────
|
||
|
||
class TestRenderTemplate:
|
||
def test_replaces_query_placeholder(self):
|
||
assert render_template('@小黑 {query}', '今天天气') == '@小黑 今天天气'
|
||
|
||
def test_empty_template_returns_query(self):
|
||
assert render_template('', 'hello') == 'hello'
|
||
|
||
def test_template_without_placeholder_keeps_query_appended(self):
|
||
# 模板里没有 {query} 时,至少保留 query 内容(用默认拼接)
|
||
assert render_template('@小黑', 'hi') == '@小黑'
|
||
|
||
|
||
# ── execute_action ────────────────────────────────────────────────────────────
|
||
|
||
class TestExecuteAction:
|
||
@patch('links.actions.send_discord_message')
|
||
def test_discord_send_success(self, mock_send):
|
||
result = execute_action(
|
||
{'action_type': 'discord_send', 'message_template': '@小黑 {query}'},
|
||
'帮我查天气',
|
||
)
|
||
mock_send.assert_called_once_with('@小黑 帮我查天气')
|
||
assert result['success'] is True
|
||
assert 'discord_deeplink' in result
|
||
|
||
@patch('links.actions.send_discord_message')
|
||
def test_unknown_action_type(self, mock_send):
|
||
result = execute_action({'action_type': 'teleport'}, 'x')
|
||
mock_send.assert_not_called()
|
||
assert result['success'] is False
|
||
|
||
@patch('links.actions.send_discord_message')
|
||
def test_empty_query_does_not_send(self, mock_send):
|
||
result = execute_action(
|
||
{'action_type': 'discord_send', 'message_template': '@小黑 {query}'},
|
||
' ',
|
||
)
|
||
mock_send.assert_not_called()
|
||
assert result['success'] is False
|
||
assert '缺少参数' in result['message']
|
||
|
||
@patch('links.actions.send_discord_message', side_effect=RuntimeError('boom'))
|
||
def test_webhook_failure_propagates(self, mock_send):
|
||
with pytest.raises(RuntimeError):
|
||
execute_action({'action_type': 'discord_send', 'message_template': '{query}'}, 'x')
|
||
|
||
|
||
# ── LinkForm validation ───────────────────────────────────────────────────────
|
||
|
||
class TestLinkFormActionValidation:
|
||
def _form(self, **overrides):
|
||
data = {
|
||
'alias': 'formaction',
|
||
'link_type': Link.LinkType.ACTION,
|
||
'action_type': 'discord_send',
|
||
'action_message_template': '@小黑 {query}',
|
||
'description': '',
|
||
}
|
||
data.update(overrides)
|
||
return LinkForm(data)
|
||
|
||
def test_valid_action_form_assembles_config(self, db):
|
||
form = self._form()
|
||
assert form.is_valid(), form.errors
|
||
assert form.cleaned_data['action_config'] == {
|
||
'action_type': 'discord_send',
|
||
'message_template': '@小黑 {query}',
|
||
}
|
||
|
||
def test_action_requires_type(self, db):
|
||
form = self._form(action_type='')
|
||
assert not form.is_valid()
|
||
assert 'Please choose an action' in str(form.errors)
|
||
|
||
def test_action_requires_template(self, db):
|
||
form = self._form(action_message_template='')
|
||
assert not form.is_valid()
|
||
assert 'Message template is required' in str(form.errors)
|
||
|
||
def test_link_type_clears_action_config(self, db):
|
||
form = self._form(link_type=Link.LinkType.LINK, original_url='https://example.com')
|
||
assert form.is_valid(), form.errors
|
||
assert form.cleaned_data['action_config'] == {}
|
||
|
||
def test_link_type_requires_url(self, db):
|
||
form = self._form(link_type=Link.LinkType.LINK, original_url='')
|
||
assert not form.is_valid()
|
||
assert 'Original URL is required' in str(form.errors)
|
||
|
||
|
||
# ── redirect_to_original execution ────────────────────────────────────────────
|
||
|
||
class TestRedirectAction:
|
||
def test_action_link_executes_and_redirects(self, action_link):
|
||
"""成功:发送消息并 302 跳转 Discord,不渲染页面。"""
|
||
client = Client()
|
||
with patch('links.actions.send_discord_message') as mock_send:
|
||
resp = client.get('/testaction/今天有什么新闻/')
|
||
assert resp.status_code == 302
|
||
assert resp['Location'].startswith('https://discord.com/channels/')
|
||
mock_send.assert_called_once_with('@小黑 今天有什么新闻')
|
||
|
||
def test_action_link_with_query_param(self, action_link):
|
||
client = Client()
|
||
with patch('links.actions.send_discord_message') as mock_send:
|
||
resp = client.get('/testaction/', {'q': '用查询参数提问'})
|
||
assert resp.status_code == 302
|
||
mock_send.assert_called_once_with('@小黑 用查询参数提问')
|
||
|
||
def test_action_link_dedup_same_query_in_window(self, action_link, monkeypatch):
|
||
"""10 分钟内同一 query 只发一次,刷新不重复发送。"""
|
||
monkeypatch.setenv('DISCORD_WEBHOOK_URL', 'https://example.com/hook')
|
||
client = Client()
|
||
with patch('links.actions.requests.post') as mock_post:
|
||
resp1 = client.get('/testaction/相同问题/')
|
||
resp2 = client.get('/testaction/相同问题/') # 刷新/重复访问
|
||
assert resp1.status_code == 302
|
||
assert resp2.status_code == 302
|
||
assert mock_post.call_count == 1, '同一问题 10 分钟内只应发送一次'
|
||
|
||
def test_action_link_different_query_not_deduped(self, action_link, monkeypatch):
|
||
monkeypatch.setenv('DISCORD_WEBHOOK_URL', 'https://example.com/hook')
|
||
client = Client()
|
||
with patch('links.actions.requests.post') as mock_post:
|
||
client.get('/testaction/问题甲/')
|
||
client.get('/testaction/问题乙/')
|
||
assert mock_post.call_count == 2, '不同问题应各自发送'
|
||
|
||
def test_action_link_webhook_failure_shows_error(self, action_link, monkeypatch):
|
||
import requests as req_lib
|
||
monkeypatch.setenv('DISCORD_WEBHOOK_URL', 'https://example.com/hook')
|
||
client = Client()
|
||
with patch('links.actions.requests.post', side_effect=req_lib.exceptions.RequestException('connection refused')):
|
||
resp = client.get('/testaction/测试/')
|
||
assert resp.status_code == 200
|
||
assert '发送到 Discord 失败' in resp.content.decode()
|
||
assert '打开 Discord' not in resp.content.decode()
|
||
|
||
def test_normal_link_unaffected(self, db):
|
||
Link.objects.create(alias='plainlink', link_type=Link.LinkType.LINK, original_url='https://example.com/path')
|
||
client = Client()
|
||
resp = client.get('/plainlink/')
|
||
assert resp.status_code in (301, 302)
|
||
assert resp['Location'] == 'https://example.com/path'
|
||
|
||
|
||
# ── ask 作为普通 ACTION Link(alias='ask',非固定路由)────────────────────────
|
||
|
||
class TestAskActionLink:
|
||
"""Ask 不再有固定路由:它是一条 link_type=ACTION 的普通 Link(alias='ask'),
|
||
由 data migration 0053 创建。访问 go/ask/问题 走 alias catch-all →
|
||
redirect_to_original → ACTION 分支,与其他动作链接行为一致。"""
|
||
|
||
def _create_ask_link(self, db):
|
||
# data migration 0053 已创建 alias='ask';测试用 get_or_create 复用
|
||
link, _ = Link.objects.get_or_create(
|
||
alias='ask',
|
||
defaults={
|
||
'link_type': Link.LinkType.ACTION,
|
||
'action_config': {
|
||
'action_type': 'discord_send',
|
||
'message_template': '<@1467845106831855796> {query}',
|
||
},
|
||
},
|
||
)
|
||
return link
|
||
|
||
def test_ask_with_question_redirects(self, db, monkeypatch):
|
||
self._create_ask_link(db)
|
||
monkeypatch.setenv('DISCORD_WEBHOOK_URL', 'https://example.com/hook')
|
||
client = Client()
|
||
with patch('links.actions.requests.post') as mock_post:
|
||
resp = client.get('/ask/今天有什么新闻/')
|
||
assert resp.status_code == 302
|
||
assert resp['Location'].startswith('https://discord.com/channels/')
|
||
body = mock_post.call_args.kwargs['json']['content']
|
||
assert '今天有什么新闻' in body
|
||
assert '<@1467845106831855796>' in body
|
||
|
||
def test_ask_with_question_dedup(self, db, monkeypatch):
|
||
self._create_ask_link(db)
|
||
monkeypatch.setenv('DISCORD_WEBHOOK_URL', 'https://example.com/hook')
|
||
client = Client()
|
||
with patch('links.actions.requests.post') as mock_post:
|
||
client.get('/ask/相同问题/')
|
||
client.get('/ask/相同问题/')
|
||
assert mock_post.call_count == 1
|
||
|
||
def test_ask_empty_query_renders_error_not_input_page(self, db):
|
||
self._create_ask_link(db)
|
||
client = Client()
|
||
resp = client.get('/ask/')
|
||
# 无参数时 ACTION 链接渲染错误提示页(不再有 ask.html 输入页)
|
||
assert resp.status_code == 200
|
||
assert '缺少参数' in resp.content.decode()
|
||
assert 'ask.html' not in resp.content.decode()
|
||
|
||
|
||
# ── 编辑页 UI(Apple-style,不暴露 JSON)──────────────────────────────────
|
||
|
||
class TestActionEditPage:
|
||
def test_edit_page_hides_action_config_json(self, db):
|
||
link = Link.objects.create(
|
||
alias='ask2ui',
|
||
link_type=Link.LinkType.ACTION,
|
||
action_config={
|
||
'action_type': 'discord_send',
|
||
'message_template': '<@1467845106831855796> {query}',
|
||
},
|
||
)
|
||
client = Client()
|
||
html = client.get(f'/link/{link.pk}/edit/').content.decode()
|
||
# JSON 不应暴露给用户(key 形态 `"action_type":`,区别于表单字段名 action_type)
|
||
assert '"action_type":' not in html
|
||
assert 'action_config' not in html
|
||
# 应显示苹果式卡片 + 中文标签 + 预览
|
||
assert 'action-card' in html
|
||
assert '发消息到 Discord' in html
|
||
assert '消息内容' in html
|
||
assert 'msg-preview' in html
|
||
# 旧英文标签不应出现
|
||
assert 'Message template' not in html
|