Files
WonderQ-Admin/tests/test_api_contracts.py
duanshuwen a47b4b5dd0 feat: Add WonderQ-MiniAPP Public API documentation
- Introduced a comprehensive API contract for the WonderQ-MiniAPP, detailing endpoints for site configuration, product listings, and lead submissions.
- Defined data types for various entities including HeroSlide, Destination, Theme, CtaBanner, PublicProduct, and more.
- Specified request and response formats, including error handling guidelines.

chore: Update requirements to include python-multipart

- Added python-multipart dependency to requirements.txt for handling file uploads.

test: Implement API contract tests

- Created test suite for API contracts, validating serializers and endpoints for public products and leads.
- Included tests for destination and product serializers, ensuring correct data handling and validation.

test: Add configuration tests for OSS settings

- Implemented tests to verify that OSS settings are correctly loaded from environment variables.
2026-07-01 16:55:00 +08:00

718 lines
23 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, CtaBanner, Destination, DestinationAlias, HeroSlide, Lead, MediaAsset, Product, ProductImage, ThemeCard
from app.routers import admin as admin_router
from app.routers.admin import normalize_detail_sections, normalize_images
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_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": ""}),
],
)
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_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_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_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
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"