Initial commit: Happy Up monorepo through Sprint 5.
Document-driven MVP with FastAPI backend, Vue H5, WeChat mini shell, product demo, and Docker dev stack. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.db import models # noqa: F401
|
||||
from app.db.session import Base, get_db
|
||||
from app.main import app
|
||||
from app.queue.analysis_queue import reset_analysis_queue_for_tests
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client():
|
||||
reset_analysis_queue_for_tests()
|
||||
engine = create_engine(
|
||||
"sqlite:///:memory:",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
TestingSessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||
|
||||
def override_get_db():
|
||||
db = TestingSessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
with TestClient(app) as test_client:
|
||||
yield test_client
|
||||
app.dependency_overrides.clear()
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def auth_headers(client):
|
||||
resp = client.post(
|
||||
"/api/auth/login",
|
||||
json={"loginType": "phone_code", "credential": "18600000000", "code": "682139"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
token = resp.json()["data"]["token"]
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
@@ -0,0 +1,69 @@
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
def test_health(client):
|
||||
resp = client.get("/health")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["stage"] == "sprint5"
|
||||
|
||||
|
||||
def test_login_invalid_code(client):
|
||||
resp = client.post(
|
||||
"/api/auth/login",
|
||||
json={"loginType": "phone_code", "credential": "18600000000", "code": "000000"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
assert resp.json()["code"] == 10002
|
||||
|
||||
|
||||
def test_login_and_children_crud(client, auth_headers):
|
||||
resp = client.get("/api/children", headers=auth_headers)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["data"]["page"]["total"] == 0
|
||||
|
||||
create = client.post(
|
||||
"/api/children",
|
||||
headers={**auth_headers, "Idempotency-Key": "demo-child-1"},
|
||||
json={
|
||||
"name": "小明",
|
||||
"birthday": "2016-03-15",
|
||||
"gender": "male",
|
||||
"height": 142.5,
|
||||
"weight": 36.0,
|
||||
},
|
||||
)
|
||||
assert create.status_code == 201
|
||||
child_id = create.json()["data"]["id"]
|
||||
assert create.json()["data"]["age"] >= 9
|
||||
|
||||
detail = client.get(f"/api/children/{child_id}", headers=auth_headers)
|
||||
assert detail.status_code == 200
|
||||
assert detail.json()["data"]["name"] == "小明"
|
||||
|
||||
updated = client.patch(
|
||||
f"/api/children/{child_id}",
|
||||
headers=auth_headers,
|
||||
json={
|
||||
"name": "小明同学",
|
||||
"birthday": "2016-03-15",
|
||||
"gender": "male",
|
||||
},
|
||||
)
|
||||
assert updated.status_code == 200
|
||||
assert updated.json()["data"]["name"] == "小明同学"
|
||||
|
||||
archived = client.delete(f"/api/children/{child_id}", headers=auth_headers)
|
||||
assert archived.status_code == 204
|
||||
|
||||
missing = client.get(f"/api/children/{child_id}", headers=auth_headers)
|
||||
assert missing.status_code == 404
|
||||
|
||||
|
||||
def test_children_requires_auth(client):
|
||||
resp = client.get("/api/children")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_reports_requires_auth(client):
|
||||
resp = client.get("/api/reports/1")
|
||||
assert resp.status_code == 401
|
||||
@@ -0,0 +1,94 @@
|
||||
def test_screening_pipeline(client, auth_headers):
|
||||
child = client.post(
|
||||
"/api/children",
|
||||
headers=auth_headers,
|
||||
json={"name": "小红", "birthday": "2017-05-01", "gender": "female"},
|
||||
).json()["data"]
|
||||
child_id = child["id"]
|
||||
|
||||
token_resp = client.post(
|
||||
"/api/videos/upload-token",
|
||||
headers=auth_headers,
|
||||
json={
|
||||
"childId": child_id,
|
||||
"fileName": "front.mp4",
|
||||
"contentType": "video/mp4",
|
||||
"size": 1024000,
|
||||
},
|
||||
)
|
||||
assert token_resp.status_code == 200
|
||||
object_key = token_resp.json()["data"]["objectKey"]
|
||||
|
||||
video_resp = client.post(
|
||||
"/api/videos",
|
||||
headers=auth_headers,
|
||||
json={
|
||||
"childId": child_id,
|
||||
"objectKey": object_key,
|
||||
"scene": "front_posture",
|
||||
},
|
||||
)
|
||||
assert video_resp.status_code == 201
|
||||
video_id = video_resp.json()["data"]["id"]
|
||||
|
||||
task_resp = client.post(
|
||||
"/api/analysis/tasks",
|
||||
headers={**auth_headers, "Idempotency-Key": "screening-task-1"},
|
||||
json={
|
||||
"childId": child_id,
|
||||
"videoId": video_id,
|
||||
"taskType": "posture_screening",
|
||||
},
|
||||
)
|
||||
assert task_resp.status_code == 201
|
||||
task = task_resp.json()["data"]
|
||||
assert task["status"] == "SUCCEEDED"
|
||||
assert task["reportId"]
|
||||
|
||||
dup_resp = client.post(
|
||||
"/api/analysis/tasks",
|
||||
headers={**auth_headers, "Idempotency-Key": "screening-task-1"},
|
||||
json={
|
||||
"childId": child_id,
|
||||
"videoId": video_id,
|
||||
"taskType": "posture_screening",
|
||||
},
|
||||
)
|
||||
assert dup_resp.status_code == 409
|
||||
|
||||
report_resp = client.get(
|
||||
f"/api/reports/{task['reportId']}",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert report_resp.status_code == 200
|
||||
report = report_resp.json()["data"]
|
||||
assert report["riskLevel"] == "medium"
|
||||
assert len(report["metrics"]) >= 3
|
||||
|
||||
|
||||
def test_movement_report_pipeline(client, auth_headers):
|
||||
child_id = client.post(
|
||||
"/api/children",
|
||||
headers=auth_headers,
|
||||
json={"name": "小刚", "birthday": "2015-08-12", "gender": "male"},
|
||||
).json()["data"]["id"]
|
||||
|
||||
object_key = f"videos/{child_id}/live.mp4"
|
||||
video_id = client.post(
|
||||
"/api/videos",
|
||||
headers=auth_headers,
|
||||
json={"childId": child_id, "objectKey": object_key, "scene": "side_posture"},
|
||||
).json()["data"]["id"]
|
||||
|
||||
task = client.post(
|
||||
"/api/analysis/tasks",
|
||||
headers=auth_headers,
|
||||
json={
|
||||
"childId": child_id,
|
||||
"videoId": video_id,
|
||||
"taskType": "movement_scoring",
|
||||
},
|
||||
).json()["data"]
|
||||
|
||||
report = client.get(f"/api/reports/{task['reportId']}", headers=auth_headers).json()["data"]
|
||||
assert report["score"] == 81
|
||||
@@ -0,0 +1,54 @@
|
||||
def test_training_plan_and_record(client, auth_headers):
|
||||
child_id = client.post(
|
||||
"/api/children",
|
||||
headers=auth_headers,
|
||||
json={"name": "训练测试", "birthday": "2016-01-01", "gender": "male"},
|
||||
).json()["data"]["id"]
|
||||
|
||||
plan_resp = client.post(
|
||||
"/api/training/plans",
|
||||
headers=auth_headers,
|
||||
json={
|
||||
"childId": child_id,
|
||||
"goal": "改善头前伸",
|
||||
"cycleDays": 28,
|
||||
},
|
||||
)
|
||||
assert plan_resp.status_code == 201
|
||||
plan = plan_resp.json()["data"]
|
||||
plan_id = plan["id"]
|
||||
assert plan["status"] == "active"
|
||||
assert plan["detail"]["cycleDays"] == 28
|
||||
|
||||
detail = client.get(f"/api/training/plans/{plan_id}", headers=auth_headers)
|
||||
assert detail.status_code == 200
|
||||
|
||||
record_resp = client.post(
|
||||
f"/api/training/plans/{plan_id}/records",
|
||||
headers=auth_headers,
|
||||
json={
|
||||
"exerciseId": 1,
|
||||
"completed": True,
|
||||
"score": 81,
|
||||
"durationSeconds": 272,
|
||||
},
|
||||
)
|
||||
assert record_resp.status_code == 201
|
||||
assert record_resp.json()["data"]["score"] == 81
|
||||
|
||||
updated = client.get(f"/api/training/plans/{plan_id}", headers=auth_headers).json()["data"]
|
||||
assert updated["detail"]["completedDays"] == 1
|
||||
|
||||
|
||||
def test_admin_dashboard(client, auth_headers):
|
||||
client.post(
|
||||
"/api/children",
|
||||
headers=auth_headers,
|
||||
json={"name": "看板测试", "birthday": "2015-06-01", "gender": "female"},
|
||||
)
|
||||
dash = client.get("/api/admin/dashboard", headers=auth_headers)
|
||||
assert dash.status_code == 200
|
||||
data = dash.json()["data"]
|
||||
assert "newChildren" in data
|
||||
assert "activePlans" in data
|
||||
assert data["newChildren"] >= 1
|
||||
@@ -0,0 +1,85 @@
|
||||
def test_local_direct_upload(client, auth_headers):
|
||||
child_id = client.post(
|
||||
"/api/children",
|
||||
headers=auth_headers,
|
||||
json={"name": "小直传", "birthday": "2016-03-10", "gender": "female"},
|
||||
).json()["data"]["id"]
|
||||
|
||||
token_resp = client.post(
|
||||
"/api/videos/upload-token",
|
||||
headers=auth_headers,
|
||||
json={
|
||||
"childId": child_id,
|
||||
"fileName": "direct.mp4",
|
||||
"contentType": "video/mp4",
|
||||
"size": 128,
|
||||
},
|
||||
)
|
||||
assert token_resp.status_code == 200
|
||||
payload = token_resp.json()["data"]
|
||||
assert payload["storage"] == "local"
|
||||
assert payload["method"] == "PUT"
|
||||
assert payload["uploadToken"]
|
||||
|
||||
put_resp = client.put(
|
||||
"/api/videos/direct-upload",
|
||||
headers={
|
||||
"X-Upload-Token": payload["uploadToken"],
|
||||
"X-Object-Key": payload["objectKey"],
|
||||
"Content-Type": "video/mp4",
|
||||
},
|
||||
content=b"\x00\x00\x00\x18ftypmp42",
|
||||
)
|
||||
assert put_resp.status_code == 204
|
||||
|
||||
bad_put = client.put(
|
||||
"/api/videos/direct-upload",
|
||||
headers={
|
||||
"X-Upload-Token": "invalid-token",
|
||||
"X-Object-Key": payload["objectKey"],
|
||||
},
|
||||
content=b"bad",
|
||||
)
|
||||
assert bad_put.status_code == 403
|
||||
|
||||
video_resp = client.post(
|
||||
"/api/videos",
|
||||
headers=auth_headers,
|
||||
json={
|
||||
"childId": child_id,
|
||||
"objectKey": payload["objectKey"],
|
||||
"scene": "front_posture",
|
||||
},
|
||||
)
|
||||
assert video_resp.status_code == 201
|
||||
video_id = video_resp.json()["data"]["id"]
|
||||
|
||||
task_resp = client.post(
|
||||
"/api/analysis/tasks",
|
||||
headers={**auth_headers, "Idempotency-Key": "sprint4-direct-upload"},
|
||||
json={
|
||||
"childId": child_id,
|
||||
"videoId": video_id,
|
||||
"taskType": "posture_screening",
|
||||
},
|
||||
)
|
||||
assert task_resp.status_code == 201
|
||||
task = task_resp.json()["data"]
|
||||
assert task["status"] == "SUCCEEDED"
|
||||
assert task["reportId"]
|
||||
|
||||
report_resp = client.get(f"/api/reports/{task['reportId']}", headers=auth_headers)
|
||||
assert report_resp.status_code == 200
|
||||
|
||||
|
||||
def test_pose_analyzer_mock():
|
||||
from pathlib import Path
|
||||
|
||||
from app.ai.pose_analyzer import analyze_task
|
||||
|
||||
out = analyze_task("posture_screening", None)
|
||||
assert out["engine"] == "mock"
|
||||
assert out["report"]
|
||||
|
||||
missing = analyze_task("posture_screening", Path("/tmp/does-not-exist.mp4"))
|
||||
assert missing["engine"] == "mock"
|
||||
@@ -0,0 +1,85 @@
|
||||
def test_pose_metrics_screening():
|
||||
from app.ai.pose_metrics import LEFT_HIP, LEFT_SHOULDER, NOSE, RIGHT_HIP, RIGHT_SHOULDER, build_screening_report
|
||||
|
||||
good_frame = {
|
||||
NOSE: {"x": 0.5, "y": 0.25, "visibility": 0.95},
|
||||
LEFT_SHOULDER: {"x": 0.42, "y": 0.35, "visibility": 0.95},
|
||||
RIGHT_SHOULDER: {"x": 0.58, "y": 0.35, "visibility": 0.95},
|
||||
LEFT_HIP: {"x": 0.44, "y": 0.55, "visibility": 0.95},
|
||||
RIGHT_HIP: {"x": 0.56, "y": 0.55, "visibility": 0.95},
|
||||
}
|
||||
bad_frame = {
|
||||
NOSE: {"x": 0.72, "y": 0.28, "visibility": 0.95},
|
||||
LEFT_SHOULDER: {"x": 0.42, "y": 0.35, "visibility": 0.95},
|
||||
RIGHT_SHOULDER: {"x": 0.58, "y": 0.42, "visibility": 0.95},
|
||||
LEFT_HIP: {"x": 0.44, "y": 0.58, "visibility": 0.95},
|
||||
RIGHT_HIP: {"x": 0.56, "y": 0.52, "visibility": 0.95},
|
||||
}
|
||||
|
||||
report = build_screening_report([good_frame] * 4 + [bad_frame] * 4)
|
||||
assert report["riskLevel"] in ("low", "medium", "high")
|
||||
assert len(report["metrics"]) >= 2
|
||||
assert report["summary"]
|
||||
|
||||
|
||||
def test_pose_metrics_movement():
|
||||
from app.ai.pose_metrics import LEFT_SHOULDER, NOSE, RIGHT_SHOULDER, build_movement_report
|
||||
|
||||
frame = {
|
||||
NOSE: {"x": 0.5, "y": 0.25, "visibility": 0.95},
|
||||
LEFT_SHOULDER: {"x": 0.42, "y": 0.35, "visibility": 0.95},
|
||||
RIGHT_SHOULDER: {"x": 0.58, "y": 0.35, "visibility": 0.95},
|
||||
}
|
||||
report = build_movement_report([frame] * 8)
|
||||
assert 50 <= report["score"] <= 100
|
||||
assert report["dimensions"]["stability"] >= 50
|
||||
|
||||
|
||||
def test_report_list_and_pdf(client, auth_headers):
|
||||
child_id = client.post(
|
||||
"/api/children",
|
||||
headers=auth_headers,
|
||||
json={"name": "PDF测试", "birthday": "2016-01-01", "gender": "male"},
|
||||
).json()["data"]["id"]
|
||||
|
||||
object_key = f"videos/{child_id}/pdf-test.mp4"
|
||||
video_id = client.post(
|
||||
"/api/videos",
|
||||
headers=auth_headers,
|
||||
json={"childId": child_id, "objectKey": object_key, "scene": "front_posture"},
|
||||
).json()["data"]["id"]
|
||||
|
||||
task = client.post(
|
||||
"/api/analysis/tasks",
|
||||
headers={**auth_headers, "Idempotency-Key": "sprint5-pdf"},
|
||||
json={"childId": child_id, "videoId": video_id, "taskType": "posture_screening"},
|
||||
).json()["data"]
|
||||
assert task["reportId"]
|
||||
|
||||
list_resp = client.get(f"/api/reports?childId={child_id}", headers=auth_headers)
|
||||
assert list_resp.status_code == 200
|
||||
assert any(item["id"] == task["reportId"] for item in list_resp.json()["data"]["list"])
|
||||
|
||||
pdf_resp = client.get(f"/api/reports/{task['reportId']}/pdf", headers=auth_headers)
|
||||
assert pdf_resp.status_code == 200
|
||||
assert pdf_resp.headers["content-type"] == "application/pdf"
|
||||
assert pdf_resp.content[:4] == b"%PDF"
|
||||
|
||||
|
||||
def test_oss_public_url(monkeypatch):
|
||||
from app.config import settings
|
||||
from app.services import oss as oss_service
|
||||
|
||||
monkeypatch.setattr(settings, "oss_enabled", True)
|
||||
monkeypatch.setattr(settings, "oss_provider", "aliyun")
|
||||
monkeypatch.setattr(settings, "oss_endpoint", "https://oss-cn-hangzhou.aliyuncs.com")
|
||||
monkeypatch.setattr(settings, "oss_bucket", "happy-up-prod")
|
||||
monkeypatch.setattr(settings, "oss_cdn_base_url", "https://cdn.example.com")
|
||||
|
||||
assert oss_service.public_object_url("videos/1/a.mp4") == "https://cdn.example.com/videos/1/a.mp4"
|
||||
|
||||
monkeypatch.setattr(settings, "oss_cdn_base_url", "")
|
||||
assert (
|
||||
oss_service.public_object_url("videos/1/a.mp4")
|
||||
== "https://oss-cn-hangzhou.aliyuncs.com/happy-up-prod/videos/1/a.mp4"
|
||||
)
|
||||
Reference in New Issue
Block a user