- 新增 LeadDeletionRequest 模型,支持邮箱验证的删除申请/确认流程 - POST /api/v1/custom/leads/deletion-requests/ 发起申请(通用响应避免探测邮箱是否存在) - POST /api/v1/custom/leads/deletion-requests/confirm/ 确认删除(24小时token有效期) - 按邮箱在 Lead.data 中匹配并删除对应记录,跨SQLite/Postgres使用Python级匹配 - LeadDeletionRequest 通过 Django Admin 只读展示供合规审计 - 新增 FRONTEND_BASE_URL 设置用于拼接邮件确认链接 - apps/forms/tests.py 新增6个测试用例(含节流缓存隔离修复),全仓库30个用例通过 - 更新设计文档 §2.15/§2.16 勾选项
202 lines
6.1 KiB
Python
202 lines
6.1 KiB
Python
"""apps.forms 单元测试:线索提交 API(B2B 官网核心转化路径,测试覆盖率要求较高)。"""
|
|
from datetime import timedelta
|
|
|
|
import pytest
|
|
from django.core.cache import cache
|
|
from django.utils import timezone
|
|
from rest_framework.test import APIClient
|
|
|
|
from apps.forms.models import (
|
|
FormDefinition,
|
|
FormDefinitionField,
|
|
Lead,
|
|
LeadDeletionRequest,
|
|
)
|
|
|
|
pytestmark = pytest.mark.django_db
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _clear_throttle_cache():
|
|
"""ScopedRateThrottle 依赖默认缓存记录请求次数,避免测试间相互影响(forms scope 限流 5/min)。"""
|
|
cache.clear()
|
|
yield
|
|
|
|
|
|
@pytest.fixture
|
|
def contact_form():
|
|
form_def = FormDefinition.objects.create(
|
|
name="联系我们",
|
|
success_message="提交成功,我们会尽快联系您。",
|
|
)
|
|
FormDefinitionField.objects.create(
|
|
form=form_def, label="姓名", field_key="name", field_type="text", required=True
|
|
)
|
|
return form_def
|
|
|
|
|
|
def test_lead_submit_creates_lead_and_returns_success_message(contact_form):
|
|
client = APIClient()
|
|
response = client.post(
|
|
"/api/v1/custom/leads/",
|
|
{
|
|
"form_id": contact_form.pk,
|
|
"data": {"name": "张三"},
|
|
"source_url": "https://example.com/contact",
|
|
},
|
|
format="json",
|
|
)
|
|
|
|
assert response.status_code == 201
|
|
assert response.data["success"] is True
|
|
assert response.data["message"] == contact_form.success_message
|
|
|
|
lead = Lead.objects.get()
|
|
assert lead.form_id == contact_form.pk
|
|
assert lead.data == {"name": "张三"}
|
|
assert lead.source_url == "https://example.com/contact"
|
|
|
|
|
|
def test_lead_submit_returns_404_for_unknown_form():
|
|
client = APIClient()
|
|
response = client.post(
|
|
"/api/v1/custom/leads/",
|
|
{"form_id": 9999, "data": {}},
|
|
format="json",
|
|
)
|
|
|
|
assert response.status_code == 404
|
|
assert response.data["error"]["code"] == "NOT_FOUND"
|
|
|
|
|
|
def test_lead_submit_requires_form_id():
|
|
client = APIClient()
|
|
response = client.post(
|
|
"/api/v1/custom/leads/",
|
|
{"data": {"name": "张三"}},
|
|
format="json",
|
|
)
|
|
|
|
assert response.status_code == 400
|
|
|
|
|
|
# --- PIPL 数据删除权(详见设计文档 §2.15) ---
|
|
|
|
|
|
def test_deletion_request_returns_generic_message_and_sends_email(
|
|
contact_form, mailoutbox
|
|
):
|
|
Lead.objects.create(
|
|
form=contact_form,
|
|
data={"name": "张三", "email": "zhangsan@example.com"},
|
|
)
|
|
|
|
client = APIClient()
|
|
response = client.post(
|
|
"/api/v1/custom/leads/deletion-requests/",
|
|
{"contact": "zhangsan@example.com"},
|
|
format="json",
|
|
)
|
|
|
|
assert response.status_code == 202
|
|
assert LeadDeletionRequest.objects.filter(contact="zhangsan@example.com").exists()
|
|
assert len(mailoutbox) == 1
|
|
assert mailoutbox[0].to == ["zhangsan@example.com"]
|
|
|
|
|
|
def test_deletion_request_returns_same_generic_message_for_unknown_contact(mailoutbox):
|
|
"""未提交过表单的邮箱也返回相同提示,避免探测。"""
|
|
client = APIClient()
|
|
response_known = client.post(
|
|
"/api/v1/custom/leads/deletion-requests/",
|
|
{"contact": "known@example.com"},
|
|
format="json",
|
|
)
|
|
response_unknown = client.post(
|
|
"/api/v1/custom/leads/deletion-requests/",
|
|
{"contact": "unknown@example.com"},
|
|
format="json",
|
|
)
|
|
|
|
assert response_known.status_code == response_unknown.status_code == 202
|
|
assert response_known.data["message"] == response_unknown.data["message"]
|
|
|
|
|
|
def test_deletion_confirm_deletes_matching_leads_only(contact_form):
|
|
matching_lead = Lead.objects.create(
|
|
form=contact_form,
|
|
data={"name": "张三", "email": "zhangsan@example.com"},
|
|
)
|
|
other_lead = Lead.objects.create(
|
|
form=contact_form,
|
|
data={"name": "李四", "email": "lisi@example.com"},
|
|
)
|
|
deletion_request = LeadDeletionRequest.objects.create(contact="zhangsan@example.com")
|
|
|
|
client = APIClient()
|
|
response = client.post(
|
|
"/api/v1/custom/leads/deletion-requests/confirm/",
|
|
{"token": str(deletion_request.token)},
|
|
format="json",
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert response.data["success"] is True
|
|
assert response.data["deleted_count"] == 1
|
|
assert not Lead.objects.filter(pk=matching_lead.pk).exists()
|
|
assert Lead.objects.filter(pk=other_lead.pk).exists()
|
|
|
|
deletion_request.refresh_from_db()
|
|
assert deletion_request.status == LeadDeletionRequest.STATUS_COMPLETED
|
|
assert deletion_request.deleted_count == 1
|
|
assert deletion_request.completed_at is not None
|
|
|
|
|
|
def test_deletion_confirm_invalid_token_returns_404():
|
|
client = APIClient()
|
|
response = client.post(
|
|
"/api/v1/custom/leads/deletion-requests/confirm/",
|
|
{"token": "00000000-0000-0000-0000-000000000000"},
|
|
format="json",
|
|
)
|
|
|
|
assert response.status_code == 404
|
|
assert response.data["error"]["code"] == "INVALID_TOKEN"
|
|
|
|
|
|
def test_deletion_confirm_expired_token_returns_400():
|
|
deletion_request = LeadDeletionRequest.objects.create(contact="zhangsan@example.com")
|
|
LeadDeletionRequest.objects.filter(pk=deletion_request.pk).update(
|
|
requested_at=timezone.now() - timedelta(hours=25)
|
|
)
|
|
|
|
client = APIClient()
|
|
response = client.post(
|
|
"/api/v1/custom/leads/deletion-requests/confirm/",
|
|
{"token": str(deletion_request.token)},
|
|
format="json",
|
|
)
|
|
|
|
assert response.status_code == 400
|
|
assert response.data["error"]["code"] == "TOKEN_EXPIRED"
|
|
|
|
deletion_request.refresh_from_db()
|
|
assert deletion_request.status == LeadDeletionRequest.STATUS_EXPIRED
|
|
|
|
|
|
def test_deletion_confirm_already_processed_returns_400():
|
|
deletion_request = LeadDeletionRequest.objects.create(
|
|
contact="zhangsan@example.com",
|
|
status=LeadDeletionRequest.STATUS_COMPLETED,
|
|
)
|
|
|
|
client = APIClient()
|
|
response = client.post(
|
|
"/api/v1/custom/leads/deletion-requests/confirm/",
|
|
{"token": str(deletion_request.token)},
|
|
format="json",
|
|
)
|
|
|
|
assert response.status_code == 400
|
|
assert response.data["error"]["code"] == "ALREADY_PROCESSED"
|