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 " 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