落地 synastry/star/ask API 与 H5 页面,补齐 cece-frontend-re complete-design 证据文档,并加入 Android 模拟器截图抓取脚本。 Co-authored-by: Cursor <cursoragent@cursor.com>
223 lines
7.1 KiB
Go
223 lines
7.1 KiB
Go
package integration_test
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func doJSONExpect(t *testing.T, r http.Handler, method, path string, body any, deviceKey string, wantCode int) (envelope, string, int) {
|
|
t.Helper()
|
|
var buf bytes.Buffer
|
|
if body != nil {
|
|
if err := json.NewEncoder(&buf).Encode(body); err != nil {
|
|
t.Fatalf("encode: %v", err)
|
|
}
|
|
}
|
|
req := httptest.NewRequest(method, path, &buf)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
if deviceKey != "" {
|
|
req.Header.Set("X-Device-Key", deviceKey)
|
|
}
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
if w.Code >= 500 {
|
|
t.Fatalf("%s %s → HTTP %d: %s", method, path, w.Code, w.Body.String())
|
|
}
|
|
var env envelope
|
|
if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil {
|
|
t.Fatalf("decode envelope: %v body=%s", err, w.Body.String())
|
|
}
|
|
if env.Code != wantCode {
|
|
t.Fatalf("%s %s → code=%d want=%d message=%s body=%s", method, path, env.Code, wantCode, env.Message, w.Body.String())
|
|
}
|
|
key := w.Header().Get("X-Device-Key")
|
|
if key == "" {
|
|
key = deviceKey
|
|
}
|
|
return env, key, w.Code
|
|
}
|
|
|
|
func assertNoForbidden(t *testing.T, blob string) {
|
|
t.Helper()
|
|
for _, bad := range []string{"算命"} {
|
|
if strings.Contains(blob, bad) {
|
|
t.Fatalf("forbidden lexicon %q in response", bad)
|
|
}
|
|
}
|
|
}
|
|
|
|
func createSelfProfile(t *testing.T, r http.Handler, birth, key string) (profileID, newKey string) {
|
|
t.Helper()
|
|
env, newKey := doJSON(t, r, http.MethodPost, "/api/v1/profiles", map[string]any{
|
|
"relation": "self", "birth_date": birth, "display_name": "我",
|
|
}, key)
|
|
return decodeData[map[string]any](t, env.Data)["id"].(string), newKey
|
|
}
|
|
|
|
// Flow: star report → gated detail → mock deep unlock
|
|
func TestFlowStarDeepAccess(t *testing.T) {
|
|
r, _ := setupAPI(t)
|
|
pid, key := createSelfProfile(t, r, "1983-06-06", "")
|
|
|
|
env, key := doJSON(t, r, http.MethodPost, "/api/v1/reports/star", map[string]any{
|
|
"profile_id": pid,
|
|
}, key)
|
|
rep := decodeData[map[string]any](t, env.Data)
|
|
assertNoForbidden(t, string(env.Data))
|
|
if rep["type"] != "star" {
|
|
t.Fatalf("type=%v", rep["type"])
|
|
}
|
|
if rep["has_deep_access"] == true || rep["detail"] != nil {
|
|
t.Fatal("expected gated star detail")
|
|
}
|
|
sum, _ := rep["summary"].(map[string]any)
|
|
if sum["headline"] == nil || sum["headline"] == "" {
|
|
t.Fatalf("missing headline: %#v", sum)
|
|
}
|
|
if sum["fortune"] == nil || sum["planets"] == nil {
|
|
t.Fatalf("expected fortune+planets in star summary: %#v", sum)
|
|
}
|
|
reportID := rep["id"].(string)
|
|
|
|
env, key = doJSON(t, r, http.MethodPost, "/api/v1/orders", map[string]any{
|
|
"kind": "deep_access", "report_id": reportID,
|
|
}, key)
|
|
orderID := decodeData[map[string]any](t, env.Data)["order_id"].(string)
|
|
_, key = doJSON(t, r, http.MethodPost, "/api/v1/orders/"+orderID+"/pay-mock", nil, key)
|
|
|
|
env, _ = doJSON(t, r, http.MethodGet, "/api/v1/reports/"+reportID, nil, key)
|
|
unlocked := decodeData[map[string]any](t, env.Data)
|
|
if unlocked["has_deep_access"] != true {
|
|
t.Fatal("expected deep after pay")
|
|
}
|
|
detail, ok := unlocked["detail"].(map[string]any)
|
|
if !ok || detail["sections"] == nil {
|
|
t.Fatalf("expected sections, got %#v", unlocked["detail"])
|
|
}
|
|
}
|
|
|
|
// Flow: rhythm report gated + unlock
|
|
func TestFlowRhythmDeepAccess(t *testing.T) {
|
|
r, _ := setupAPI(t)
|
|
pid, key := createSelfProfile(t, r, "1992-06-08", "")
|
|
|
|
env, key := doJSON(t, r, http.MethodPost, "/api/v1/reports/rhythm", map[string]any{
|
|
"profile_id": pid,
|
|
}, key)
|
|
rep := decodeData[map[string]any](t, env.Data)
|
|
assertNoForbidden(t, string(env.Data))
|
|
if rep["type"] != "rhythm" {
|
|
t.Fatalf("type=%v", rep["type"])
|
|
}
|
|
if rep["detail"] != nil {
|
|
t.Fatal("expected gated rhythm detail")
|
|
}
|
|
reportID := rep["id"].(string)
|
|
|
|
env, key = doJSON(t, r, http.MethodPost, "/api/v1/orders", map[string]any{
|
|
"kind": "deep_access", "report_id": reportID,
|
|
}, key)
|
|
orderID := decodeData[map[string]any](t, env.Data)["order_id"].(string)
|
|
_, key = doJSON(t, r, http.MethodPost, "/api/v1/orders/"+orderID+"/pay-mock", nil, key)
|
|
|
|
env, _ = doJSON(t, r, http.MethodGet, "/api/v1/reports/"+reportID, nil, key)
|
|
unlocked := decodeData[map[string]any](t, env.Data)
|
|
if unlocked["has_deep_access"] != true {
|
|
t.Fatal("expected deep")
|
|
}
|
|
if unlocked["detail"] == nil {
|
|
t.Fatal("expected detail after unlock")
|
|
}
|
|
}
|
|
|
|
// Flow: image card scenes → draw → quota exhaust → depth unlock via mock pay
|
|
func TestFlowImageCardQuotaAndDepth(t *testing.T) {
|
|
r, _ := setupAPI(t)
|
|
pid, key := createSelfProfile(t, r, "1990-01-01", "")
|
|
|
|
env, key := doJSON(t, r, http.MethodGet, "/api/v1/image-cards/scenes", nil, key)
|
|
scenes := decodeData[map[string]any](t, env.Data)
|
|
items, _ := scenes["items"].([]any)
|
|
if len(items) < 1 {
|
|
t.Fatal("expected scenes")
|
|
}
|
|
|
|
env, key = doJSON(t, r, http.MethodGet, "/api/v1/image-cards/quota", nil, key)
|
|
q := decodeData[map[string]any](t, env.Data)
|
|
if int(q["remaining"].(float64)) < 1 {
|
|
t.Fatalf("expected free quota, got %#v", q)
|
|
}
|
|
|
|
var reportID string
|
|
for i := 0; i < 3; i++ {
|
|
env, key = doJSON(t, r, http.MethodPost, "/api/v1/image-cards/draw", map[string]any{
|
|
"profile_id": pid, "scene": "情绪整理", "depth": true,
|
|
}, key)
|
|
out := decodeData[map[string]any](t, env.Data)
|
|
assertNoForbidden(t, string(env.Data))
|
|
cards, _ := out["cards"].([]any)
|
|
if len(cards) < 1 {
|
|
t.Fatalf("draw %d: no cards", i)
|
|
}
|
|
rep, _ := out["report"].(map[string]any)
|
|
reportID, _ = rep["id"].(string)
|
|
if rep["has_deep_access"] == true {
|
|
t.Fatal("free user should not have deep on draw")
|
|
}
|
|
}
|
|
|
|
_, key, httpStatus := doJSONExpect(t, r, http.MethodPost, "/api/v1/image-cards/draw", map[string]any{
|
|
"profile_id": pid, "scene": "情绪整理",
|
|
}, key, 40201)
|
|
if httpStatus != http.StatusPaymentRequired {
|
|
t.Fatalf("want HTTP 402, got %d", httpStatus)
|
|
}
|
|
|
|
env, key = doJSON(t, r, http.MethodPost, "/api/v1/orders", map[string]any{
|
|
"kind": "deep_access", "report_id": reportID,
|
|
}, key)
|
|
orderID := decodeData[map[string]any](t, env.Data)["order_id"].(string)
|
|
_, key = doJSON(t, r, http.MethodPost, "/api/v1/orders/"+orderID+"/pay-mock", nil, key)
|
|
|
|
env, _ = doJSON(t, r, http.MethodGet, "/api/v1/reports/"+reportID, nil, key)
|
|
unlocked := decodeData[map[string]any](t, env.Data)
|
|
if unlocked["has_deep_access"] != true {
|
|
t.Fatal("expected deep on image_card report")
|
|
}
|
|
if unlocked["type"] != "image_card" {
|
|
t.Fatalf("type=%v", unlocked["type"])
|
|
}
|
|
}
|
|
|
|
// Flow: solar terms + mood save/read
|
|
func TestFlowCompanionMood(t *testing.T) {
|
|
r, _ := setupAPI(t)
|
|
var key string
|
|
|
|
env, key := doJSON(t, r, http.MethodGet, "/api/v1/solar-terms/today", nil, key)
|
|
term := decodeData[map[string]any](t, env.Data)
|
|
assertNoForbidden(t, string(env.Data))
|
|
if term["name"] == nil || term["tip"] == nil {
|
|
t.Fatalf("bad solar term: %#v", term)
|
|
}
|
|
|
|
env, key = doJSON(t, r, http.MethodPost, "/api/v1/moods", map[string]any{
|
|
"score": 4, "note": "今天还不错",
|
|
}, key)
|
|
mood := decodeData[map[string]any](t, env.Data)
|
|
if mood["score"].(float64) != 4 {
|
|
t.Fatalf("score=%v", mood["score"])
|
|
}
|
|
|
|
env, _ = doJSON(t, r, http.MethodGet, "/api/v1/moods/today", nil, key)
|
|
today := decodeData[map[string]any](t, env.Data)
|
|
m, ok := today["mood"].(map[string]any)
|
|
if !ok || m["score"].(float64) != 4 {
|
|
t.Fatalf("today mood=%#v", today)
|
|
}
|
|
}
|