Files
links/tests/test_actions.py
T
OpenClaw Sub-agent c4a4b7a299 feat: Apple-style action link edit UI — hide JSON, card picker, live preview
- 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)
2026-08-01 21:16:00 +10:00

253 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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 Linkalias='ask',非固定路由)────────────────────────
class TestAskActionLink:
"""Ask 不再有固定路由:它是一条 link_type=ACTION 的普通 Linkalias='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()
# ── 编辑页 UIApple-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