- Add new database columns (coverImage, priceAmount, priceUnit, tags, status) to HotelGroup model - Create hotel_group_dict serializer to handle image/coverImage synchronization and tag formatting - Update site config endpoints to use the new serializer and filter published hotel groups - Add Alembic migration for the new database schema changes - Update test fixtures and API contract tests for the new fields - Revise public and admin API documentation to document the new hotel group fields and usage rules
1436 lines
50 KiB
Python
1436 lines
50 KiB
Python
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, 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_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": ""}),
|
||
("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_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"
|