Files
WonderQ-Admin/tests/test_api_contracts.py
duanshuwen d11c361aa2 feat: add destination page configurable modules
This commit adds full support for two new destination page modules: a hero banner section and categorized destination regions.

Changes include:
- New SQLAlchemy models and alembic migration for DestinationHero and DestinationRegion tables
- Updated Pydantic SiteConfigPatchIn schema with new optional fields
- Integrated admin CRUD support for the new modules
- Added default seed data for Guizhou destination regions and hero content
- Exposed active modules via public site config API
- Added comprehensive test coverage for admin and public endpoints
2026-07-07 21:24:30 +08:00

1512 lines
53 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.

from datetime import datetime
import pytest
from fastapi.testclient import TestClient
from pydantic import ValidationError
from app import serializers
from app.auth import hash_password, require_admin
from app.database import get_db
from app.main import create_app
from app.models import AdminUser, Campaign, CtaBanner, Destination, DestinationAlias, DestinationHero, DestinationRegion, HeroSlide, HotelGroup, Lead, MapImage, MediaAsset, Product, ProductImage, RouteSection, RouteSectionProduct, ThemeCard, VehicleOption
from app.routers import admin as admin_router
from app.routers.admin import normalize_detail_sections, normalize_images
from app.routers.shared import site_config
from app.schemas import (
AdminProductQuery,
LeadCreateIn,
LeadQuery,
ProductCreateIn,
ProductImageIn,
ProductQuery,
)
class FakeScalarResult:
def __init__(self, items):
self.items = items
def unique(self):
return self
def all(self):
return self.items
def first(self):
return self.items[0] if self.items else None
def one(self):
return self.items[0]
class FakeExecuteResult:
def __init__(self, rows):
self.rows = rows
def all(self):
return self.rows
class FakeDb:
def __init__(self, *, scalar_results=None, scalar_values=None, execute_results=None, get_result=None):
self.scalar_results = list(scalar_results or [])
self.scalar_values = list(scalar_values or [])
self.execute_results = list(execute_results or [])
self.get_result = get_result
self.added = []
self.deleted = []
self.committed = False
def scalars(self, _stmt):
return FakeScalarResult(self.scalar_results.pop(0))
def scalar(self, _stmt):
return self.scalar_values.pop(0)
def execute(self, _stmt):
return FakeExecuteResult(self.execute_results.pop(0))
def get(self, _model, _item_id):
return self.get_result
def add(self, item):
self.added.append(item)
def delete(self, item):
self.deleted.append(item)
def flush(self):
for item in self.added:
if not getattr(item, "id", None):
item.id = f"{item.__class__.__name__.lower()}-test-id"
def commit(self):
self.committed = True
def refresh(self, item):
if not getattr(item, "id", None):
item.id = "lead-test-id"
def make_destination(**overrides):
destination = Destination(
id=overrides.get("id", "dest-test"),
name=overrides.get("name", "测试目的地"),
slug=overrides.get("slug", "test-destination"),
region=overrides.get("region", "测试区域"),
image=overrides.get("image", "/assets/test.jpg"),
isHot=overrides.get("isHot", True),
sortOrder=overrides.get("sortOrder", 1),
isActive=overrides.get("isActive", True),
createdAt=datetime(2026, 1, 1),
updatedAt=datetime(2026, 1, 2),
)
destination.aliases = [
DestinationAlias(id="alias-b", alias="别名B", destinationId=destination.id),
DestinationAlias(id="alias-a", alias="别名A", destinationId=destination.id),
]
destination.products = []
return destination
def make_product(**overrides):
destination = overrides.get("destination", make_destination())
product = Product(
id=overrides.get("id", "product-test"),
sourceId=overrides.get("sourceId", 101),
title=overrides.get("title", "测试线路"),
subtitle=overrides.get("subtitle", "测试副标题"),
destinationId=destination.id,
priceAmount=overrides.get("priceAmount", 1000),
priceUnit=overrides.get("priceUnit", "起/人"),
tags=overrides.get("tags", ["测试", "线路"]),
coverImage=overrides.get("coverImage", "/assets/cover.jpg"),
summary=overrides.get("summary", "测试摘要"),
detailSections=overrides.get("detailSections", None),
status=overrides.get("status", "published"),
sortWeight=overrides.get("sortWeight", 1),
publishedAt=datetime(2026, 1, 3),
createdAt=datetime(2026, 1, 1),
updatedAt=datetime(2026, 1, 2),
)
product.destination = destination
product.images = [
ProductImage(id="img-b", productId=product.id, url="/assets/b.jpg", alt="B", sortOrder=2),
ProductImage(id="img-a", productId=product.id, url="/assets/a.jpg", alt="A", sortOrder=1),
]
return product
def make_hero_slide(**overrides):
return HeroSlide(
id=overrides.get("id", "slide-test"),
title=overrides.get("title", "测试轮播"),
kicker=overrides.get("kicker", "测试副标题"),
image=overrides.get("image", "/assets/slide.jpg"),
targetType=overrides.get("targetType", "campaign"),
targetValue=overrides.get("targetValue", "campaign-test"),
sortOrder=overrides.get("sortOrder", 1),
isActive=overrides.get("isActive", True),
createdAt=datetime(2026, 1, 1),
updatedAt=datetime(2026, 1, 2),
)
def make_theme_card(**overrides):
return ThemeCard(
id=overrides.get("id", "theme-test"),
label=overrides.get("label", "测试主题"),
image=overrides.get("image", "/assets/theme.jpg"),
targetType=overrides.get("targetType", "search"),
targetValue=overrides.get("targetValue", "测试主题"),
sortOrder=overrides.get("sortOrder", 1),
isActive=overrides.get("isActive", True),
createdAt=datetime(2026, 1, 1),
updatedAt=datetime(2026, 1, 2),
)
def make_cta_banner(**overrides):
return CtaBanner(
id=overrides.get("id", "cta-test"),
alt=overrides.get("alt", "测试运营入口"),
image=overrides.get("image", "/assets/cta.jpg"),
targetType=overrides.get("targetType", "lead"),
targetValue=overrides.get("targetValue", "cta-test"),
sortOrder=overrides.get("sortOrder", 1),
isActive=overrides.get("isActive", True),
createdAt=datetime(2026, 1, 1),
updatedAt=datetime(2026, 1, 2),
)
def make_hotel_group(**overrides):
return HotelGroup(
id=overrides.get("id", "hotel-group-test"),
title=overrides.get("title", "经典酒店"),
description=overrides.get("description", "经典城市酒店与山野度假住宿组合"),
image=overrides.get("image", "/assets/hotel.jpg"),
coverImage=overrides.get("coverImage", overrides.get("image", "/assets/hotel.jpg")),
priceAmount=overrides.get("priceAmount", 68000),
priceUnit=overrides.get("priceUnit", "起/晚"),
tags=overrides.get("tags", ["温泉", "亲子"]),
status=overrides.get("status", "published"),
sortOrder=overrides.get("sortOrder", 1),
isActive=overrides.get("isActive", True),
createdAt=datetime(2026, 1, 1),
updatedAt=datetime(2026, 1, 2),
)
def make_vehicle_option(**overrides):
return VehicleOption(
id=overrides.get("id", "vehicle-option-test"),
title=overrides.get("title", "5座舒适用车"),
description=overrides.get("description", "2-8人小团按人数和行李匹配车型"),
image=overrides.get("image", "/assets/vehicle.jpg"),
sortOrder=overrides.get("sortOrder", 2),
isActive=overrides.get("isActive", True),
createdAt=datetime(2026, 1, 1),
updatedAt=datetime(2026, 1, 2),
)
def make_map_image(**overrides):
return MapImage(
id=overrides.get("id", "map-test"),
image=overrides.get("image", "https://cdn.example.test/map.webp"),
isActive=overrides.get("isActive", True),
createdAt=datetime(2026, 1, 1),
updatedAt=datetime(2026, 1, 2),
)
def make_destination_hero(**overrides):
return DestinationHero(
id=overrides.get("id", "destination-hero-test"),
title=overrides.get("title", "目的地页主视觉"),
kicker=overrides.get("kicker", "贵州小包团目的地"),
image=overrides.get("image", "/assets/destination-hero.jpg"),
sortOrder=overrides.get("sortOrder", 0),
isActive=overrides.get("isActive", True),
createdAt=datetime(2026, 1, 1),
updatedAt=datetime(2026, 1, 2),
)
def make_destination_region(**overrides):
return DestinationRegion(
id=overrides.get("id", "destination-region-test"),
label=overrides.get("label", "黔南"),
keyword=overrides.get("keyword", "黔南荔波"),
spots=overrides.get("spots", "荔波小七孔、茂兰"),
sortOrder=overrides.get("sortOrder", 0),
isActive=overrides.get("isActive", True),
createdAt=datetime(2026, 1, 1),
updatedAt=datetime(2026, 1, 2),
)
def make_campaign(**overrides):
return Campaign(
id=overrides.get("id", "campaign-test"),
slug=overrides.get("slug", "classic-deal"),
title=overrides.get("title", "经典打卡特惠"),
description=overrides.get("description", "经典首游活动"),
coverImage=overrides.get("coverImage", "https://cdn.example.test/campaign.webp"),
priceAmount=overrides.get("priceAmount", 162500),
priceUnit=overrides.get("priceUnit", "起/人"),
tags=overrides.get("tags", ["臻藏旅位", "赛事庆典"]),
status=overrides.get("status", "draft"),
startsAt=overrides.get("startsAt"),
endsAt=overrides.get("endsAt"),
createdAt=datetime(2026, 1, 1),
updatedAt=datetime(2026, 1, 2),
)
def make_route_section(**overrides):
section = RouteSection(
id=overrides.get("id", "routes"),
title=overrides.get("title", "经典人文打卡线路"),
subtitle=overrides.get("subtitle", "黄果树、荔波小七孔、千户苗寨、镇远古城、梵净山一次串联"),
sortOrder=overrides.get("sortOrder", 0),
isActive=overrides.get("isActive", True),
createdAt=datetime(2026, 1, 1),
updatedAt=datetime(2026, 1, 2),
)
section.products = overrides.get("products", [])
return section
def make_route_section_product(**overrides):
product = overrides.get("product", make_product(id=overrides.get("productId", "product-test")))
link = RouteSectionProduct(
sectionId=overrides.get("sectionId", "routes"),
productId=overrides.get("productId", product.id),
sortOrder=overrides.get("sortOrder", 0),
)
link.product = product
return link
def make_admin_user():
return AdminUser(
id="admin-test",
email="admin@example.test",
name="Admin",
role="admin",
passwordHash="not-used",
isActive=True,
)
def authenticated_app(fake_db):
app = create_app()
app.dependency_overrides[get_db] = lambda: fake_db
app.dependency_overrides[require_admin] = make_admin_user
return app
def test_public_product_serializer_limits_destination_and_stabilizes_arrays():
data = serializers.public_product_dict(make_product())
assert data["destination"] == {"id": "dest-test", "name": "测试目的地"}
assert [image["id"] for image in data["images"]] == ["img-a", "img-b"]
assert data["detailSections"] == []
def test_destination_serializer_uses_precomputed_product_count_and_sorts_aliases():
destination = make_destination()
data = serializers.destination_dict(destination, include_count=True, product_count=7)
assert data["_count"] == {"products": 7}
assert [alias["id"] for alias in data["aliases"]] == ["alias-a", "alias-b"]
def test_lead_schema_accepts_date_only_string():
lead = LeadCreateIn(phone=" contact handle ", travelDate="2027-01-01")
assert lead.phone == "contact handle"
assert lead.travelDate == datetime(2027, 1, 1)
def test_query_status_validation_rejects_unknown_values():
with pytest.raises(ValidationError):
ProductQuery(status="hidden")
with pytest.raises(ValidationError):
AdminProductQuery(status="hidden")
with pytest.raises(ValidationError):
LeadQuery(status="closed")
def test_normalize_images_reassigns_sort_order_by_payload_order():
images = [
ProductImageIn(url="/assets/first.jpg", sortOrder=99),
ProductImageIn(url="/assets/second.jpg", sortOrder=10),
]
assert [image["sortOrder"] for image in normalize_images(images)] == [0, 1]
def test_empty_detail_sections_are_accepted_and_dropped():
body = ProductCreateIn(
title="测试线路",
detailSections=[{"key": "", "label": "", "blocks": []}],
)
assert normalize_detail_sections(body.detailSections) == []
def test_public_products_endpoint_uses_public_product_contract():
product = make_product()
fake_db = FakeDb(scalar_results=[[product]])
app = create_app()
app.dependency_overrides[get_db] = lambda: fake_db
try:
response = TestClient(app).get("/api/public/products")
finally:
app.dependency_overrides.clear()
assert response.status_code == 200
item = response.json()["items"][0]
assert item["destination"] == {"id": "dest-test", "name": "测试目的地"}
assert item["images"][0]["url"] == "/assets/a.jpg"
def test_public_leads_endpoint_accepts_date_only_and_returns_minimal_response():
fake_db = FakeDb()
app = create_app()
app.dependency_overrides[get_db] = lambda: fake_db
try:
response = TestClient(app).post(
"/api/public/leads",
json={
"phone": " contact handle ",
"travelDate": "2027-01-01",
"peopleCount": 2,
"budgetMin": 0,
"budgetMax": 100,
},
)
finally:
app.dependency_overrides.clear()
assert response.status_code == 201
assert response.json() == {"id": "lead-test-id", "status": "new"}
assert fake_db.added[0].phone == "contact handle"
assert fake_db.added[0].travelDate == datetime(2027, 1, 1)
def test_admin_destinations_endpoint_uses_precomputed_product_counts():
destination = make_destination()
fake_db = FakeDb(scalar_results=[[destination]], execute_results=[[(destination.id, 7)]])
app = create_app()
app.dependency_overrides[get_db] = lambda: fake_db
app.dependency_overrides[require_admin] = lambda: AdminUser(
id="admin-test",
email="admin@example.test",
name="Admin",
role="admin",
passwordHash="not-used",
isActive=True,
)
try:
response = TestClient(app).get("/api/admin/destinations")
finally:
app.dependency_overrides.clear()
assert response.status_code == 200
assert response.json()["items"][0]["_count"] == {"products": 7}
def test_admin_requires_auth_for_protected_endpoint():
response = TestClient(create_app()).get("/api/admin/products")
assert response.status_code == 401
assert response.json() == {"message": "请先登录后台"}
def test_admin_login_returns_token_and_user():
admin_user = AdminUser(
id="admin-test",
email="admin@example.com",
name="Admin",
role="admin",
passwordHash=hash_password("ChangeMe123!", rounds=4),
isActive=True,
)
fake_db = FakeDb(scalar_values=[admin_user])
app = create_app()
app.dependency_overrides[get_db] = lambda: fake_db
try:
response = TestClient(app).post(
"/api/admin/auth/login",
json={"email": "admin@example.com", "password": "ChangeMe123!"},
)
finally:
app.dependency_overrides.clear()
assert response.status_code == 200
body = response.json()
assert body["token"]
assert body["user"] == {
"id": "admin-test",
"email": "admin@example.com",
"name": "Admin",
"role": "admin",
}
def test_site_config_patch_ignores_fields_not_allowed_for_module_and_audits():
destination = make_destination()
fake_db = FakeDb(get_result=destination)
app = create_app()
app.dependency_overrides[get_db] = lambda: fake_db
app.dependency_overrides[require_admin] = lambda: AdminUser(
id="admin-test",
email="admin@example.test",
name="Admin",
role="admin",
passwordHash="not-used",
isActive=True,
)
try:
response = TestClient(app).patch(
"/api/admin/site-config/destinations/dest-test",
json={"name": "新目的地", "targetType": "ignored"},
)
finally:
app.dependency_overrides.clear()
assert response.status_code == 200
assert response.json()["name"] == "新目的地"
assert not hasattr(destination, "targetType")
assert fake_db.committed
assert fake_db.added[-1].entity == "destination"
@pytest.mark.parametrize(
("module", "payload", "expected"),
[
("heroSlides", {"title": "新轮播", "image": None, "targetType": None}, {"title": "新轮播", "image": None}),
("destinations", {"name": "新目的地", "slug": "", "region": None, "isHot": True}, {"name": "新目的地", "slug": "e696b0e79baee79a84e59cb0", "isHot": True}),
("themes", {"label": "新主题", "image": None}, {"label": "新主题", "image": ""}),
("destinationHero", {"title": "目的地页主视觉", "kicker": "贵州小包团目的地", "image": None}, {"title": "目的地页主视觉", "kicker": "贵州小包团目的地", "image": None}),
("destinationRegions", {"label": "黔南", "keyword": "黔南荔波", "spots": "荔波小七孔、茂兰"}, {"label": "黔南", "keyword": "黔南荔波", "spots": "荔波小七孔、茂兰"}),
("ctaBanners", {"alt": "新运营入口", "image": None, "targetType": None}, {"alt": "新运营入口", "image": "", "targetType": ""}),
("hotelGroups", {"title": "经典酒店", "description": "酒店文案", "image": None}, {"title": "经典酒店", "description": "酒店文案", "image": None}),
("vehicleOptions", {"title": "5座舒适用车", "description": "用车文案", "image": None}, {"title": "5座舒适用车", "description": "用车文案", "image": None}),
],
)
def test_site_config_create_modules_defaults_fields_and_audits(module, payload, expected):
fake_db = FakeDb(scalar_values=[4])
app = authenticated_app(fake_db)
try:
response = TestClient(app).post(f"/api/admin/site-config/{module}", json=payload)
finally:
app.dependency_overrides.clear()
assert response.status_code == 201
body = response.json()
for key, value in expected.items():
assert body[key] == value
assert body["isActive"] is True
assert body["sortOrder"] == 5
assert fake_db.committed
assert fake_db.added[-1].action == "create"
def test_site_config_invalid_module_returns_structured_error():
fake_db = FakeDb()
app = authenticated_app(fake_db)
try:
response = TestClient(app).post("/api/admin/site-config/unknown", json={"title": "测试"})
finally:
app.dependency_overrides.clear()
assert response.status_code == 400
assert response.json() == {
"message": "模块不存在或无权限操作",
"code": "MODULE_CONFIG_FORBIDDEN",
"details": {"module": "unknown"},
}
def test_site_config_create_requires_module_primary_field():
fake_db = FakeDb()
app = authenticated_app(fake_db)
try:
response = TestClient(app).post("/api/admin/site-config/themes", json={"image": "/assets/theme.jpg"})
finally:
app.dependency_overrides.clear()
assert response.status_code == 422
assert response.json()["code"] == "MODULE_CONFIG_VALIDATION_ERROR"
assert response.json()["details"] == {"field": "label"}
def test_admin_site_config_hero_slides_use_dedicated_contract_without_targets():
hero_slide = make_hero_slide(targetType="campaign", targetValue="campaign-test")
fake_db = FakeDb(scalar_results=[[], [hero_slide], [], [], [], [], [], [], [], [], []])
app = authenticated_app(fake_db)
try:
response = TestClient(app).get("/api/admin/site-config")
finally:
app.dependency_overrides.clear()
assert response.status_code == 200
hero = response.json()["heroSlides"][0]
assert hero["title"] == "测试轮播"
assert hero["image"] == "/assets/slide.jpg"
assert "targetType" not in hero
assert "targetValue" not in hero
def test_admin_site_config_includes_map_array_with_dedicated_contract():
map_image = make_map_image()
fake_db = FakeDb(scalar_results=[[], [], [], [], [map_image], [], [], [], [], [], []])
app = authenticated_app(fake_db)
try:
response = TestClient(app).get("/api/admin/site-config")
finally:
app.dependency_overrides.clear()
assert response.status_code == 200
body = response.json()
assert body["map"] == [
{
"id": "map-test",
"image": "https://cdn.example.test/map.webp",
"isActive": True,
"createdAt": "2026-01-01T00:00:00",
"updatedAt": "2026-01-02T00:00:00",
}
]
assert "sortOrder" not in body["map"][0]
assert "title" not in body["map"][0]
assert "targetType" not in body["map"][0]
def test_admin_site_config_includes_destination_page_modules():
fake_db = FakeDb(scalar_results=[[], [], [], [], [], [], [], [], [], [], []])
app = authenticated_app(fake_db)
try:
response = TestClient(app).get("/api/admin/site-config")
finally:
app.dependency_overrides.clear()
assert response.status_code == 200
body = response.json()
assert body["destinationHero"] == []
assert body["destinationRegions"] == []
def test_public_site_config_includes_destination_page_modules():
destination_hero = make_destination_hero()
destination_region = make_destination_region()
fake_db = FakeDb(scalar_results=[[], [], [destination_hero], [destination_region], [], [], [], [], [], [], []])
result = site_config(fake_db, active_only=True)
assert result["destinationHero"] == [
{
"id": "destination-hero-test",
"title": "目的地页主视觉",
"kicker": "贵州小包团目的地",
"image": "/assets/destination-hero.jpg",
"sortOrder": 0,
"isActive": True,
"createdAt": "2026-01-01T00:00:00",
"updatedAt": "2026-01-02T00:00:00",
}
]
assert result["destinationRegions"] == [
{
"id": "destination-region-test",
"label": "黔南",
"keyword": "黔南荔波",
"spots": "荔波小七孔、茂兰",
"sortOrder": 0,
"isActive": True,
"createdAt": "2026-01-01T00:00:00",
"updatedAt": "2026-01-02T00:00:00",
}
]
def test_admin_site_config_includes_campaigns_for_special_offers():
campaign = make_campaign(status="published")
fake_db = FakeDb(scalar_results=[[], [], [], [], [], [], [], [campaign], [], [], []])
app = authenticated_app(fake_db)
try:
response = TestClient(app).get("/api/admin/site-config")
finally:
app.dependency_overrides.clear()
assert response.status_code == 200
body = response.json()
assert body["campaigns"] == [
{
"id": "campaign-test",
"slug": "classic-deal",
"title": "经典打卡特惠",
"description": "经典首游活动",
"coverImage": "https://cdn.example.test/campaign.webp",
"priceAmount": 162500,
"priceUnit": "起/人",
"tags": ["臻藏旅位", "赛事庆典"],
"status": "published",
"startsAt": None,
"endsAt": None,
"createdAt": "2026-01-01T00:00:00",
"updatedAt": "2026-01-02T00:00:00",
}
]
assert "sortOrder" not in body["campaigns"][0]
assert "isActive" not in body["campaigns"][0]
assert "targetType" not in body["campaigns"][0]
def test_admin_site_config_includes_route_sections_for_featured_routes():
section = make_route_section(
products=[
make_route_section_product(productId="product-a", sortOrder=1),
make_route_section_product(productId="product-b", sortOrder=0),
],
)
fake_db = FakeDb(scalar_results=[[], [], [], [], [], [], [], [], [section], [], []])
app = authenticated_app(fake_db)
try:
response = TestClient(app).get("/api/admin/site-config")
finally:
app.dependency_overrides.clear()
assert response.status_code == 200
route_sections = response.json()["routeSections"]
assert route_sections == [
{
"id": "routes",
"title": "经典人文打卡线路",
"subtitle": "黄果树、荔波小七孔、千户苗寨、镇远古城、梵净山一次串联",
"productIds": ["product-b", "product-a"],
"isActive": True,
"sortOrder": 0,
"createdAt": "2026-01-01T00:00:00",
"updatedAt": "2026-01-02T00:00:00",
}
]
def test_admin_site_config_includes_hotel_and_vehicle_modules():
hotel_group = make_hotel_group()
vehicle_option = make_vehicle_option()
fake_db = FakeDb(scalar_results=[[], [], [], [], [], [], [], [], [], [hotel_group], [vehicle_option]])
app = authenticated_app(fake_db)
try:
response = TestClient(app).get("/api/admin/site-config")
finally:
app.dependency_overrides.clear()
assert response.status_code == 200
body = response.json()
assert body["hotelGroups"] == [
{
"id": "hotel-group-test",
"title": "经典酒店",
"description": "经典城市酒店与山野度假住宿组合",
"image": "/assets/hotel.jpg",
"coverImage": "/assets/hotel.jpg",
"priceAmount": 68000,
"priceUnit": "起/晚",
"tags": ["温泉", "亲子"],
"status": "published",
"sortOrder": 1,
"isActive": True,
"createdAt": "2026-01-01T00:00:00",
"updatedAt": "2026-01-02T00:00:00",
}
]
assert body["vehicleOptions"] == [
{
"id": "vehicle-option-test",
"title": "5座舒适用车",
"description": "2-8人小团按人数和行李匹配车型",
"image": "/assets/vehicle.jpg",
"sortOrder": 2,
"isActive": True,
"createdAt": "2026-01-01T00:00:00",
"updatedAt": "2026-01-02T00:00:00",
}
]
def test_site_config_create_route_section_generates_dynamic_id_and_audits():
fake_db = FakeDb(scalar_results=[[]])
app = authenticated_app(fake_db)
try:
response = TestClient(app).post(
"/api/admin/site-config/routeSections",
json={"title": " 新人文线路 ", "subtitle": " 新副文案 "},
)
finally:
app.dependency_overrides.clear()
assert response.status_code == 201
body = response.json()
assert body["id"].startswith("routesection-")
assert body["title"] == "新人文线路"
assert body["subtitle"] == "新副文案"
assert body["productIds"] == []
assert body["isActive"] is True
assert body["sortOrder"] == 0
created = fake_db.added[0]
assert isinstance(created, RouteSection)
assert created.id.startswith("routesection-")
assert fake_db.added[-1].entity == "route_section"
assert fake_db.committed
def test_route_sections_module_is_not_fixed():
assert admin_router.SITE_CONFIG_MODULES["routeSections"].get("fixed") is not True
def test_site_config_create_route_section_allows_more_than_three_groups():
items = [
make_route_section(id="route-section-a", title="经典", sortOrder=0),
make_route_section(id="route-section-b", title="户外", sortOrder=1),
make_route_section(id="route-section-c", title="混搭", sortOrder=2),
]
fake_db = FakeDb(scalar_results=[items])
app = authenticated_app(fake_db)
try:
response = TestClient(app).post("/api/admin/site-config/routeSections", json={"title": "第四组"})
finally:
app.dependency_overrides.clear()
assert response.status_code == 201
body = response.json()
assert body["title"] == "第四组"
assert body["sortOrder"] == 3
assert body["productIds"] == []
assert fake_db.committed
def test_site_config_create_hero_slide_ignores_target_fields_and_returns_dedicated_contract():
fake_db = FakeDb(scalar_values=[0])
app = authenticated_app(fake_db)
try:
response = TestClient(app).post(
"/api/admin/site-config/heroSlides",
json={
"title": " 新轮播 ",
"kicker": "",
"image": None,
"targetType": "campaign",
"targetValue": "ignored",
},
)
finally:
app.dependency_overrides.clear()
assert response.status_code == 201
body = response.json()
assert body["title"] == "新轮播"
assert body["image"] is None
assert "targetType" not in body
assert "targetValue" not in body
created = fake_db.added[0]
assert created.targetType is None
assert created.targetValue is None
def test_site_config_create_map_image_defaults_active_and_audits():
fake_db = FakeDb(scalar_results=[[]])
app = authenticated_app(fake_db)
try:
response = TestClient(app).post(
"/api/admin/site-config/map",
json={"image": " https://cdn.example.test/guizhou-map.webp ", "title": "ignored", "sortOrder": 99},
)
finally:
app.dependency_overrides.clear()
assert response.status_code == 201
body = response.json()
assert body["image"] == "https://cdn.example.test/guizhou-map.webp"
assert body["isActive"] is True
assert "title" not in body
assert "sortOrder" not in body
created = fake_db.added[0]
assert isinstance(created, MapImage)
assert created.image == "https://cdn.example.test/guizhou-map.webp"
assert created.isActive is True
assert fake_db.added[-1].entity == "map_image"
assert fake_db.committed
def test_site_config_create_map_image_rejects_duplicate_singleton():
fake_db = FakeDb(scalar_results=[[make_map_image()]])
app = authenticated_app(fake_db)
try:
response = TestClient(app).post("/api/admin/site-config/map", json={"image": "https://cdn.example.test/new.webp"})
finally:
app.dependency_overrides.clear()
assert response.status_code == 409
assert response.json()["code"] == "MAP_IMAGE_ALREADY_EXISTS"
assert not fake_db.added
assert not fake_db.committed
def test_site_config_create_campaign_defaults_draft_and_audits():
fake_db = FakeDb()
app = authenticated_app(fake_db)
try:
response = TestClient(app).post(
"/api/admin/site-config/campaigns",
json={
"title": " Classic Deal ",
"slug": " classic-deal ",
"description": " Deal summary ",
"coverImage": " https://cdn.example.test/campaign.webp ",
"priceAmount": 188000,
"priceUnit": " 起/人 ",
"tags": [" 季节限定 ", "", "小众秘境"],
"image": "ignored",
"isActive": True,
"sortOrder": 9,
},
)
finally:
app.dependency_overrides.clear()
assert response.status_code == 201
body = response.json()
assert body["title"] == "Classic Deal"
assert body["slug"] == "classic-deal"
assert body["description"] == "Deal summary"
assert body["coverImage"] == "https://cdn.example.test/campaign.webp"
assert body["priceAmount"] == 188000
assert body["priceUnit"] == "起/人"
assert body["tags"] == ["季节限定", "小众秘境"]
assert body["status"] == "draft"
assert "image" not in body
assert "isActive" not in body
assert "sortOrder" not in body
created = fake_db.added[0]
assert isinstance(created, Campaign)
assert created.status == "draft"
assert created.priceAmount == 188000
assert created.priceUnit == "起/人"
assert created.tags == ["季节限定", "小众秘境"]
assert fake_db.added[-1].entity == "campaign"
assert fake_db.committed
def test_site_config_create_campaign_rejects_more_than_three_tags():
fake_db = FakeDb()
app = authenticated_app(fake_db)
try:
response = TestClient(app).post(
"/api/admin/site-config/campaigns",
json={
"title": "Classic Deal",
"slug": "classic-deal",
"tags": ["a", "b", "c", "d"],
},
)
finally:
app.dependency_overrides.clear()
assert response.status_code == 422
assert response.json()["code"] == "MODULE_CONFIG_VALIDATION_ERROR"
assert response.json()["details"] == {"field": "tags", "max": 3}
assert not fake_db.added
assert not fake_db.committed
def test_site_config_patch_hero_slide_ignores_target_fields_and_returns_dedicated_contract():
hero_slide = make_hero_slide(targetType="campaign", targetValue="campaign-test")
fake_db = FakeDb(get_result=hero_slide)
app = authenticated_app(fake_db)
try:
response = TestClient(app).patch(
"/api/admin/site-config/heroSlides/slide-test",
json={
"title": "夏日贵州小包团",
"image": None,
"targetType": "search",
"targetValue": "ignored",
},
)
finally:
app.dependency_overrides.clear()
assert response.status_code == 200
body = response.json()
assert body["title"] == "夏日贵州小包团"
assert body["image"] is None
assert "targetType" not in body
assert "targetValue" not in body
assert hero_slide.targetType == "campaign"
assert hero_slide.targetValue == "campaign-test"
def test_site_config_patch_map_image_updates_allowed_fields_only():
map_image = make_map_image(isActive=True)
fake_db = FakeDb(get_result=map_image)
app = authenticated_app(fake_db)
try:
response = TestClient(app).patch(
"/api/admin/site-config/map/map-test",
json={"image": " https://cdn.example.test/new-map.webp ", "isActive": False, "targetType": "ignored", "sortOrder": 1},
)
finally:
app.dependency_overrides.clear()
assert response.status_code == 200
body = response.json()
assert body["image"] == "https://cdn.example.test/new-map.webp"
assert body["isActive"] is False
assert "targetType" not in body
assert "sortOrder" not in body
assert not hasattr(map_image, "targetType")
assert fake_db.added[-1].action == "update"
assert fake_db.added[-1].entity == "map_image"
def test_site_config_patch_campaign_updates_contract_fields_only():
campaign = make_campaign(status="draft")
fake_db = FakeDb(get_result=campaign)
app = authenticated_app(fake_db)
try:
response = TestClient(app).patch(
"/api/admin/site-config/campaigns/campaign-test",
json={
"title": " Summer Deal ",
"slug": " summer-deal ",
"description": None,
"coverImage": " https://cdn.example.test/summer.webp ",
"priceAmount": 57500,
"priceUnit": " 起/人 ",
"tags": ["无可比拟", "尊享礼遇", ""],
"status": "published",
"startsAt": "2026-07-01T08:00:00",
"endsAt": None,
"image": "ignored",
"isActive": False,
"sortOrder": 3,
},
)
finally:
app.dependency_overrides.clear()
assert response.status_code == 200
body = response.json()
assert body["title"] == "Summer Deal"
assert body["slug"] == "summer-deal"
assert body["description"] is None
assert body["coverImage"] == "https://cdn.example.test/summer.webp"
assert body["priceAmount"] == 57500
assert body["priceUnit"] == "起/人"
assert body["tags"] == ["无可比拟", "尊享礼遇"]
assert body["status"] == "published"
assert body["startsAt"] == "2026-07-01T08:00:00"
assert body["endsAt"] is None
assert "image" not in body
assert "isActive" not in body
assert "sortOrder" not in body
assert campaign.status == "published"
assert campaign.priceAmount == 57500
assert campaign.priceUnit == "起/人"
assert campaign.tags == ["无可比拟", "尊享礼遇"]
assert fake_db.added[-1].action == "update"
assert fake_db.added[-1].entity == "campaign"
def test_site_config_patch_route_section_updates_copy_and_product_order():
section = make_route_section()
product_a = make_product(id="product-a")
product_b = make_product(id="product-b")
fake_db = FakeDb(
get_result=section,
scalar_results=[[product_b, product_a], []],
execute_results=[[]],
)
app = authenticated_app(fake_db)
try:
response = TestClient(app).patch(
"/api/admin/site-config/routeSections/routes",
json={
"title": " 新经典线路 ",
"subtitle": " 新副文案 ",
"isActive": False,
"productIds": ["product-b", "product-a"],
},
)
finally:
app.dependency_overrides.clear()
assert response.status_code == 200
body = response.json()
assert body["title"] == "新经典线路"
assert body["subtitle"] == "新副文案"
assert body["isActive"] is False
assert body["productIds"] == ["product-b", "product-a"]
links = [item for item in fake_db.added if isinstance(item, RouteSectionProduct)]
assert [(link.productId, link.sortOrder) for link in links] == [("product-b", 0), ("product-a", 1)]
assert fake_db.added[-1].entity == "route_section"
assert fake_db.committed
def test_site_config_patch_route_section_rejects_product_used_by_another_section():
section = make_route_section(id="routes")
conflict = make_route_section_product(sectionId="route-section-other", productId="product-a")
fake_db = FakeDb(
get_result=section,
scalar_results=[[make_product(id="product-a")], [conflict]],
)
app = authenticated_app(fake_db)
try:
response = TestClient(app).patch(
"/api/admin/site-config/routeSections/routes",
json={"productIds": ["product-a"]},
)
finally:
app.dependency_overrides.clear()
assert response.status_code == 409
assert response.json()["code"] == "ROUTE_SECTION_PRODUCT_CONFLICT"
assert not fake_db.committed
def test_public_site_config_campaigns_include_price_and_tags():
campaign = make_campaign(status="published", tags=["自然奇景", "小众秘境"])
fake_db = FakeDb(scalar_results=[[], [], [], [], [], [], [], [campaign], [], [], []])
result = site_config(fake_db, active_only=True)
assert result["campaigns"] == [
{
"id": "campaign-test",
"slug": "classic-deal",
"title": "经典打卡特惠",
"description": "经典首游活动",
"coverImage": "https://cdn.example.test/campaign.webp",
"priceAmount": 162500,
"priceUnit": "起/人",
"tags": ["自然奇景", "小众秘境"],
"status": "published",
"startsAt": None,
"endsAt": None,
"createdAt": "2026-01-01T00:00:00",
"updatedAt": "2026-01-02T00:00:00",
}
]
def test_public_site_config_route_sections_filter_unpublished_products():
published = make_product(id="product-published", status="published")
draft = make_product(id="product-draft", status="draft")
section = make_route_section(
products=[
make_route_section_product(product=published, productId=published.id, sortOrder=0),
make_route_section_product(product=draft, productId=draft.id, sortOrder=1),
]
)
fake_db = FakeDb(scalar_results=[[], [], [], [], [], [], [], [], [section], [], []])
result = site_config(fake_db, active_only=True)
assert result["routeSections"] == [
{
"id": "routes",
"title": "经典人文打卡线路",
"subtitle": "黄果树、荔波小七孔、千户苗寨、镇远古城、梵净山一次串联",
"productIds": ["product-published"],
"isActive": True,
}
]
def test_public_site_config_includes_hotel_and_vehicle_modules():
hotel_group = make_hotel_group()
vehicle_option = make_vehicle_option()
fake_db = FakeDb(scalar_results=[[], [], [], [], [], [], [], [], [], [hotel_group], [vehicle_option]])
result = site_config(fake_db, active_only=True)
assert result["hotelGroups"][0]["title"] == "经典酒店"
assert result["hotelGroups"][0]["description"] == "经典城市酒店与山野度假住宿组合"
assert result["hotelGroups"][0]["coverImage"] == "/assets/hotel.jpg"
assert result["hotelGroups"][0]["priceAmount"] == 68000
assert result["hotelGroups"][0]["priceUnit"] == "起/晚"
assert result["hotelGroups"][0]["tags"] == ["温泉", "亲子"]
assert result["hotelGroups"][0]["status"] == "published"
assert result["vehicleOptions"][0]["title"] == "5座舒适用车"
assert result["vehicleOptions"][0]["image"] == "/assets/vehicle.jpg"
def test_site_config_patch_updates_destination_contract_fields():
destination = make_destination()
fake_db = FakeDb(get_result=destination)
app = authenticated_app(fake_db)
try:
response = TestClient(app).patch(
"/api/admin/site-config/destinations/dest-test",
json={"slug": "new-slug", "region": None, "image": None, "isHot": False, "sortOrder": 8, "targetType": "ignored"},
)
finally:
app.dependency_overrides.clear()
assert response.status_code == 200
body = response.json()
assert body["slug"] == "new-slug"
assert body["region"] is None
assert body["image"] is None
assert body["isHot"] is False
assert body["sortOrder"] == 8
assert not hasattr(destination, "targetType")
assert fake_db.added[-1].action == "update"
def test_site_config_delete_returns_json_reorders_remaining_items_and_audits():
delete_item = make_theme_card(id="theme-delete", sortOrder=1)
remaining = [make_theme_card(id="theme-b", sortOrder=5), make_theme_card(id="theme-a", sortOrder=9)]
fake_db = FakeDb(get_result=delete_item, scalar_results=[remaining])
app = authenticated_app(fake_db)
try:
response = TestClient(app).delete("/api/admin/site-config/themes/theme-delete")
finally:
app.dependency_overrides.clear()
assert response.status_code == 200
assert response.json() == {"id": "theme-delete"}
assert fake_db.deleted == [delete_item]
assert [item.sortOrder for item in remaining] == [0, 1]
assert fake_db.committed
assert fake_db.added[-1].action == "delete"
assert fake_db.added[-1].entity == "theme_card"
def test_site_config_delete_destination_with_products_returns_conflict():
destination = make_destination()
fake_db = FakeDb(get_result=destination, scalar_values=[1])
app = authenticated_app(fake_db)
try:
response = TestClient(app).delete("/api/admin/site-config/destinations/dest-test")
finally:
app.dependency_overrides.clear()
assert response.status_code == 409
assert response.json()["code"] == "MODULE_CONFIG_CONFLICT"
assert not fake_db.deleted
def test_site_config_reorder_reassigns_sort_order_and_returns_items():
items = [
make_hero_slide(id="slide-1", title="第一张", sortOrder=0),
make_hero_slide(id="slide-2", title="第二张", sortOrder=1),
make_hero_slide(id="slide-3", title="第三张", sortOrder=2),
]
fake_db = FakeDb(scalar_results=[items])
app = authenticated_app(fake_db)
try:
response = TestClient(app).patch(
"/api/admin/site-config/heroSlides/reorder",
json={"itemIds": ["slide-2", "slide-1", "slide-3"]},
)
finally:
app.dependency_overrides.clear()
assert response.status_code == 200
assert [item.id for item in items] == ["slide-1", "slide-2", "slide-3"]
assert {item.id: item.sortOrder for item in items} == {"slide-2": 0, "slide-1": 1, "slide-3": 2}
assert [item["id"] for item in response.json()["items"]] == ["slide-2", "slide-1", "slide-3"]
assert [item["sortOrder"] for item in response.json()["items"]] == [0, 1, 2]
assert fake_db.committed
assert fake_db.added[-1].action == "reorder"
@pytest.mark.parametrize(
"item_ids",
[
["slide-1", "slide-1", "slide-2"],
["slide-1", "slide-2"],
["slide-1", "slide-2", "other-module-id"],
],
)
def test_site_config_reorder_rejects_duplicate_missing_and_unknown_ids(item_ids):
items = [
make_hero_slide(id="slide-1"),
make_hero_slide(id="slide-2"),
make_hero_slide(id="slide-3"),
]
fake_db = FakeDb(scalar_results=[items])
app = authenticated_app(fake_db)
try:
response = TestClient(app).patch("/api/admin/site-config/heroSlides/reorder", json={"itemIds": item_ids})
finally:
app.dependency_overrides.clear()
assert response.status_code == 400
assert response.json()["code"] == "MODULE_CONFIG_REORDER_INVALID"
assert not fake_db.committed
@pytest.mark.parametrize(
"item_ids",
[
["route-section-a", "route-section-a", "route-section-b"],
["route-section-a", "route-section-b"],
["route-section-a", "route-section-b", "route-section-x"],
],
)
def test_site_config_reorder_route_sections_rejects_duplicate_missing_and_unknown_ids(item_ids):
items = [
make_route_section(id="route-section-a", title="A", sortOrder=0),
make_route_section(id="route-section-b", title="B", sortOrder=1),
make_route_section(id="route-section-c", title="C", sortOrder=2),
]
fake_db = FakeDb(scalar_results=[items])
app = authenticated_app(fake_db)
try:
response = TestClient(app).patch("/api/admin/site-config/routeSections/reorder", json={"itemIds": item_ids})
finally:
app.dependency_overrides.clear()
assert response.status_code == 400
assert response.json()["code"] == "MODULE_CONFIG_REORDER_INVALID"
assert not fake_db.committed
def test_site_config_reorder_map_is_not_supported():
fake_db = FakeDb()
app = authenticated_app(fake_db)
try:
response = TestClient(app).patch("/api/admin/site-config/map/reorder", json={"itemIds": ["map-test"]})
finally:
app.dependency_overrides.clear()
assert response.status_code == 400
assert response.json()["code"] == "MODULE_CONFIG_REORDER_UNSUPPORTED"
assert not fake_db.committed
def test_site_config_reorder_campaigns_is_not_supported():
fake_db = FakeDb()
app = authenticated_app(fake_db)
try:
response = TestClient(app).patch("/api/admin/site-config/campaigns/reorder", json={"itemIds": ["campaign-test"]})
finally:
app.dependency_overrides.clear()
assert response.status_code == 400
assert response.json()["code"] == "MODULE_CONFIG_REORDER_UNSUPPORTED"
assert not fake_db.committed
def test_site_config_reorder_route_sections_reassigns_sort_order():
items = [
make_route_section(id="route-section-a", title="经典", sortOrder=0),
make_route_section(id="route-section-b", title="户外", sortOrder=1),
make_route_section(id="route-section-c", title="混搭", sortOrder=2),
]
fake_db = FakeDb(scalar_results=[items])
app = authenticated_app(fake_db)
try:
response = TestClient(app).patch(
"/api/admin/site-config/routeSections/reorder",
json={"itemIds": ["route-section-b", "route-section-a", "route-section-c"]},
)
finally:
app.dependency_overrides.clear()
assert response.status_code == 200
assert {item.id: item.sortOrder for item in items} == {"route-section-b": 0, "route-section-a": 1, "route-section-c": 2}
assert [item["id"] for item in response.json()["items"]] == ["route-section-b", "route-section-a", "route-section-c"]
assert fake_db.added[-1].entity == "route_section"
assert fake_db.committed
def test_site_config_delete_route_section_returns_json_reorders_and_does_not_delete_products():
section = make_route_section(id="route-section-delete", title="待删除", sortOrder=1)
remaining_a = make_route_section(id="route-section-a", title="保留 A", sortOrder=0)
remaining_b = make_route_section(id="route-section-b", title="保留 B", sortOrder=2)
fake_db = FakeDb(get_result=section, scalar_results=[[remaining_a, remaining_b]])
app = authenticated_app(fake_db)
try:
response = TestClient(app).delete("/api/admin/site-config/routeSections/route-section-delete")
finally:
app.dependency_overrides.clear()
assert response.status_code == 200
assert response.json() == {"id": "route-section-delete"}
assert fake_db.deleted == [section]
assert {item.id: item.sortOrder for item in [remaining_a, remaining_b]} == {"route-section-a": 0, "route-section-b": 1}
assert fake_db.added[-1].action == "delete"
assert fake_db.added[-1].entity == "route_section"
assert fake_db.committed
def test_site_config_delete_map_image_returns_json_and_audits_without_reorder():
map_image = make_map_image()
fake_db = FakeDb(get_result=map_image)
app = authenticated_app(fake_db)
try:
response = TestClient(app).delete("/api/admin/site-config/map/map-test")
finally:
app.dependency_overrides.clear()
assert response.status_code == 200
assert response.json() == {"id": "map-test"}
assert fake_db.deleted == [map_image]
assert fake_db.added[-1].action == "delete"
assert fake_db.added[-1].entity == "map_image"
assert fake_db.committed
def test_site_config_delete_campaign_returns_json_and_audits_without_reorder():
campaign = make_campaign()
fake_db = FakeDb(get_result=campaign)
app = authenticated_app(fake_db)
try:
response = TestClient(app).delete("/api/admin/site-config/campaigns/campaign-test")
finally:
app.dependency_overrides.clear()
assert response.status_code == 200
assert response.json() == {"id": "campaign-test"}
assert fake_db.deleted == [campaign]
assert fake_db.added[-1].action == "delete"
assert fake_db.added[-1].entity == "campaign"
assert fake_db.committed
def test_admin_media_upload_streams_image_to_oss_records_asset_and_audits(monkeypatch):
uploaded = {}
def fake_upload(file_obj, key, mime_type, size_bytes):
uploaded["key"] = key
uploaded["mimeType"] = mime_type
uploaded["sizeBytes"] = size_bytes
uploaded["body"] = file_obj.read()
return f"https://cdn.example.test/{key}"
monkeypatch.setattr(admin_router, "upload_image_to_oss", fake_upload)
fake_db = FakeDb()
app = authenticated_app(fake_db)
content = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR"
try:
response = TestClient(app).post(
"/api/admin/media-assets/upload",
data={"group": "heroSlides"},
files={"file": ("hero.png", content, "image/png")},
)
finally:
app.dependency_overrides.clear()
assert response.status_code == 201
body = response.json()
assert body["url"].startswith("https://cdn.example.test/admin/heroSlides/")
assert body["name"] == "hero.png"
assert body["mimeType"] == "image/png"
assert body["sizeBytes"] == len(content)
assert body["group"] == "heroSlides"
assert uploaded["body"] == content
assert uploaded["key"].endswith(".png")
assert isinstance(fake_db.added[0], MediaAsset)
assert fake_db.added[-1].entity == "media_asset"
assert fake_db.committed
def test_admin_media_upload_rejects_non_image_file():
fake_db = FakeDb()
app = authenticated_app(fake_db)
try:
response = TestClient(app).post(
"/api/admin/media-assets/upload",
data={"group": "docs"},
files={"file": ("note.txt", b"hello", "text/plain")},
)
finally:
app.dependency_overrides.clear()
assert response.status_code == 400
assert response.json()["code"] == "MEDIA_UPLOAD_INVALID_TYPE"
assert not fake_db.added
assert not fake_db.committed
def test_admin_lead_status_update_returns_updated_status_and_audits():
lead = Lead(
id="lead-test",
destination="测试目的地",
phone="contact handle",
status="new",
createdAt=datetime(2026, 1, 1),
updatedAt=datetime(2026, 1, 2),
)
fake_db = FakeDb(get_result=lead)
app = create_app()
app.dependency_overrides[get_db] = lambda: fake_db
app.dependency_overrides[require_admin] = lambda: AdminUser(
id="admin-test",
email="admin@example.test",
name="Admin",
role="admin",
passwordHash="not-used",
isActive=True,
)
try:
response = TestClient(app).patch("/api/admin/leads/lead-test/status", json={"status": "contacted"})
finally:
app.dependency_overrides.clear()
assert response.status_code == 200
assert response.json()["status"] == "contacted"
assert lead.status == "contacted"
assert fake_db.committed
assert fake_db.added[-1].entity == "lead"