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) } if testBearer != "" { req.Header.Set("Authorization", "Bearer "+testBearer) } 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) key := mustRegister(t, r) pid, key := createSelfProfile(t, r, "1983-06-06", key) 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["outlook"] == nil || sum["planets"] == nil { t.Fatalf("expected outlook+planets in star summary: %#v", sum) } if sum["fortune"] != nil { t.Fatal("legacy fortune key must be removed (ECR-003)") } if raw, _ := json.Marshal(sum); strings.Contains(string(raw), `"lucky"`) { t.Fatal("legacy lucky field must not appear in star summary (ECR-012)") } 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) key := mustRegister(t, r) pid, key := createSelfProfile(t, r, "1992-06-08", key) 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) key := mustRegister(t, r) pid, key := createSelfProfile(t, r, "1990-01-01", key) 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) key := mustRegister(t, r) 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) } }