You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
257 lines
9.8 KiB
257 lines
9.8 KiB
from __future__ import annotations
|
|
|
|
from datetime import UTC, datetime, timedelta
|
|
from decimal import Decimal
|
|
|
|
import pytest
|
|
|
|
from app import create_app
|
|
from app.extensions import db
|
|
from app.models import Account, Household, ShareLink
|
|
from app.services import check_share_token, create_share_link
|
|
|
|
|
|
@pytest.fixture
|
|
def app():
|
|
app = create_app("testing")
|
|
|
|
with app.app_context():
|
|
db.create_all()
|
|
yield app
|
|
db.session.remove()
|
|
db.drop_all()
|
|
|
|
|
|
@pytest.fixture
|
|
def client(app):
|
|
return app.test_client()
|
|
|
|
|
|
def create_account(*, username: str, password: str, role: str) -> Account:
|
|
account = Account(username=username, role=role, status="active")
|
|
account.set_password(password)
|
|
db.session.add(account)
|
|
db.session.commit()
|
|
return account
|
|
|
|
|
|
def create_household(**overrides: object) -> Household:
|
|
household = Household(
|
|
household_code=str(overrides.get("household_code", "S001")),
|
|
head_name=str(overrides.get("head_name", "分享测试户")),
|
|
phone=str(overrides.get("phone", "13800001111")),
|
|
side=str(overrides.get("side", "groom_side")),
|
|
invite_status=str(overrides.get("invite_status", "invited")),
|
|
attendance_status=str(overrides.get("attendance_status", "pending")),
|
|
expected_attendee_count=int(str(overrides.get("expected_attendee_count", 3))),
|
|
actual_attendee_count=int(str(overrides.get("actual_attendee_count", 0))),
|
|
child_count=int(str(overrides.get("child_count", 0))),
|
|
red_packet_child_count=int(str(overrides.get("red_packet_child_count", 0))),
|
|
total_gift_amount=Decimal(str(overrides.get("total_gift_amount", "0.00"))),
|
|
favor_status=str(overrides.get("favor_status", "not_given")),
|
|
candy_status=str(overrides.get("candy_status", "not_given")),
|
|
child_red_packet_status=str(overrides.get("child_red_packet_status", "not_given")),
|
|
note=overrides.get("note", None),
|
|
version=int(str(overrides.get("version", 1))),
|
|
)
|
|
db.session.add(household)
|
|
db.session.commit()
|
|
return household
|
|
|
|
|
|
def login(client, *, username: str, password: str):
|
|
return client.post(
|
|
"/auth/login",
|
|
data={"username": username, "password": password},
|
|
follow_redirects=False,
|
|
)
|
|
|
|
|
|
def test_share_token_check_respects_expired_and_revoked(app) -> None:
|
|
with app.app_context():
|
|
account = create_account(username="admin", password="AdminPass123!", role="admin")
|
|
link = create_share_link(created_by=account.id, label="测试", expire_days=1)
|
|
|
|
ok = check_share_token(link.token)
|
|
assert ok.ok is True
|
|
|
|
link.expires_at = datetime.now(UTC).replace(tzinfo=None) - timedelta(minutes=1)
|
|
expired = check_share_token(link.token)
|
|
assert expired.ok is False
|
|
assert expired.reason == "expired"
|
|
|
|
link.expires_at = datetime.now(UTC).replace(tzinfo=None) + timedelta(days=1)
|
|
link.revoked_at = datetime.now(UTC).replace(tzinfo=None)
|
|
revoked = check_share_token(link.token)
|
|
assert revoked.ok is False
|
|
assert revoked.reason == "revoked"
|
|
|
|
|
|
def test_admin_can_create_and_revoke_share_link(client, app) -> None:
|
|
with app.app_context():
|
|
create_account(username="admin", password="AdminPass123!", role="admin")
|
|
|
|
login(client, username="admin", password="AdminPass123!")
|
|
page_response = client.get("/admin/share-links")
|
|
page = page_response.get_data(as_text=True)
|
|
assert page_response.status_code == 200
|
|
assert "分享链接" in page
|
|
|
|
create_response = client.post(
|
|
"/admin/share-links",
|
|
data={"label": "给亲友", "expire_days": "7"},
|
|
follow_redirects=False,
|
|
)
|
|
assert create_response.status_code == 302
|
|
assert create_response.headers["Location"].endswith("/admin/share-links")
|
|
|
|
with app.app_context():
|
|
link = db.session.execute(db.select(ShareLink).order_by(ShareLink.id.desc())).scalar_one_or_none()
|
|
assert link is not None
|
|
link_id = link.id
|
|
token = link.token
|
|
|
|
revoke_response = client.post(f"/admin/share-links/{link_id}/revoke", follow_redirects=False)
|
|
assert revoke_response.status_code == 302
|
|
assert revoke_response.headers["Location"].endswith("/admin/share-links")
|
|
|
|
with app.app_context():
|
|
link = db.session.get(ShareLink, link_id)
|
|
assert link is not None
|
|
assert link.revoked_at is not None
|
|
|
|
invalid_response = client.get(f"/s/{token}")
|
|
assert invalid_response.status_code == 404
|
|
|
|
|
|
def test_share_page_edit_save_returns_search_and_updates_fields(client, app) -> None:
|
|
with app.app_context():
|
|
account = create_account(username="admin", password="AdminPass123!", role="admin")
|
|
household = create_household()
|
|
link = create_share_link(created_by=account.id, label="公开", expire_days=7)
|
|
db.session.commit()
|
|
household_id = household.id
|
|
token = link.token
|
|
|
|
open_response = client.get(f"/s/{token}/households/{household_id}/edit?q=分享")
|
|
assert open_response.status_code == 200
|
|
edit_page = open_response.get_data(as_text=True)
|
|
assert "编辑信息" in edit_page
|
|
assert 'id="phone"' in edit_page
|
|
|
|
save_response = client.post(
|
|
f"/s/{token}/households/{household_id}/edit",
|
|
data={
|
|
"q": "分享",
|
|
"version": "1",
|
|
"household_code": "S001",
|
|
"head_name": "分享测试户",
|
|
"side": "groom_side",
|
|
"relation_category_option_id": "",
|
|
"relation_detail_option_id": "",
|
|
"invite_status": "invited",
|
|
"expected_attendee_count": "3",
|
|
"phone": "13800002222",
|
|
"attendance_status": "attending",
|
|
"actual_attendee_count": "2",
|
|
"child_count": "1",
|
|
"red_packet_child_count": "1",
|
|
"total_gift_amount": "520.00",
|
|
"gift_method_option_id": "",
|
|
"gift_scene_option_id": "",
|
|
"favor_status": "given",
|
|
"candy_status": "given",
|
|
"child_red_packet_status": "partial",
|
|
"note": "公开页保存",
|
|
},
|
|
follow_redirects=False,
|
|
)
|
|
assert save_response.status_code == 302
|
|
assert save_response.headers["Location"].endswith(f"/s/{token}?q=%E5%88%86%E4%BA%AB")
|
|
|
|
with app.app_context():
|
|
updated = db.session.get(Household, household_id)
|
|
assert updated is not None
|
|
assert updated.phone == "13800002222"
|
|
assert updated.attendance_status == "attending"
|
|
assert updated.actual_attendee_count == 2
|
|
assert updated.total_gift_amount == Decimal("520.00")
|
|
assert updated.note == "公开页保存"
|
|
|
|
|
|
def test_share_search_page_is_public_and_does_not_expose_create_household_action(client, app) -> None:
|
|
with app.app_context():
|
|
account = create_account(username="admin", password="AdminPass123!", role="admin")
|
|
create_household()
|
|
link = create_share_link(created_by=account.id, label="公开", expire_days=7)
|
|
db.session.commit()
|
|
token = link.token
|
|
|
|
response = client.get(f"/s/{token}?q=分享")
|
|
page = response.get_data(as_text=True)
|
|
|
|
assert response.status_code == 200
|
|
assert "分享搜索" in page
|
|
assert "/households/new" not in page
|
|
assert "点击编辑" in page
|
|
|
|
|
|
def test_share_search_supports_pinyin_and_partial_results(client, app) -> None:
|
|
with app.app_context():
|
|
account = create_account(username="admin", password="AdminPass123!", role="admin")
|
|
create_household(household_code="S001", head_name="王大爷")
|
|
create_household(household_code="S002", head_name="李阿姨")
|
|
link = create_share_link(created_by=account.id, label="公开", expire_days=7)
|
|
db.session.commit()
|
|
token = link.token
|
|
|
|
pinyin_response = client.get(f"/s/{token}?q=wangdaye")
|
|
pinyin_page = pinyin_response.get_data(as_text=True)
|
|
assert pinyin_response.status_code == 200
|
|
assert "王大爷" in pinyin_page
|
|
assert "李阿姨" not in pinyin_page
|
|
|
|
partial_response = client.get(
|
|
f"/s/{token}?q=%E7%8E%8B&partial=results",
|
|
headers={"X-HW-Partial": "results"},
|
|
)
|
|
partial_page = partial_response.get_data(as_text=True)
|
|
assert partial_response.status_code == 200
|
|
assert 'id="share-search-results-region"' in partial_page
|
|
assert "王大爷" in partial_page
|
|
assert "<html" not in partial_page
|
|
|
|
|
|
def test_share_search_excludes_soft_deleted_households(client, app) -> None:
|
|
with app.app_context():
|
|
account = create_account(username="admin", password="AdminPass123!", role="admin")
|
|
active = create_household(household_code="S001", head_name="王大爷")
|
|
deleted = create_household(household_code="S002", head_name="李阿姨")
|
|
deleted.deleted_at = datetime(2026, 4, 12, 10, 0)
|
|
link = create_share_link(created_by=account.id, label="公开", expire_days=7)
|
|
db.session.commit()
|
|
token = link.token
|
|
|
|
response = client.get(f"/s/{token}?q=%E9%98%BF%E5%A7%A8")
|
|
page = response.get_data(as_text=True)
|
|
|
|
assert response.status_code == 200
|
|
assert "李阿姨" not in page
|
|
|
|
|
|
def test_deleted_household_cannot_be_opened_from_share_direct_edit_url(client, app) -> None:
|
|
with app.app_context():
|
|
account = create_account(username="admin", password="AdminPass123!", role="admin")
|
|
household = create_household(household_code="S001", head_name="王大爷")
|
|
link = create_share_link(created_by=account.id, label="公开", expire_days=7)
|
|
household.deleted_at = datetime(2026, 4, 12, 10, 0)
|
|
db.session.commit()
|
|
token = link.token
|
|
household_id = household.id
|
|
|
|
response = client.get(f"/s/{token}/households/{household_id}/edit?q=%E7%8E%8B", follow_redirects=True)
|
|
page = response.get_data(as_text=True)
|
|
|
|
assert response.status_code == 200
|
|
assert "未找到要修改的户信息。" in page
|
|
assert "分享搜索" in page
|
|
|