feat: P1 合盘/星座/问答与测测完整设计包及模拟器取证工具
落地 synastry/star/ask API 与 H5 页面,补齐 cece-frontend-re complete-design 证据文档,并加入 Android 模拟器截图抓取脚本。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
package integration_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExploreCatalog(t *testing.T) {
|
||||
r, _ := setupAPI(t)
|
||||
var key string
|
||||
env, key := doJSON(t, r, http.MethodGet, "/api/v1/explore/catalog", nil, key)
|
||||
data := decodeData[map[string]any](t, env.Data)
|
||||
cats, ok := data["categories"].([]any)
|
||||
if !ok || len(cats) < 6 {
|
||||
t.Fatalf("categories=%#v", data["categories"])
|
||||
}
|
||||
env, _ = doJSON(t, r, http.MethodGet, "/api/v1/explore/catalog/tests", nil, key)
|
||||
cat := decodeData[map[string]any](t, env.Data)
|
||||
items, _ := cat["items"].([]any)
|
||||
if len(items) < 5 {
|
||||
t.Fatalf("tests items=%d", len(items))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrowthPlanCheckin(t *testing.T) {
|
||||
r, _ := setupAPI(t)
|
||||
var key string
|
||||
env, key := doJSON(t, r, http.MethodPost, "/api/v1/growth/plans", map[string]any{
|
||||
"title": "每晚早睡", "focus": "保护睡眠",
|
||||
}, key)
|
||||
plan := decodeData[map[string]any](t, env.Data)
|
||||
id, _ := plan["id"].(string)
|
||||
if id == "" {
|
||||
t.Fatal("missing plan id")
|
||||
}
|
||||
_, key = doJSON(t, r, http.MethodPost, "/api/v1/growth/plans/"+id+"/checkin", map[string]any{
|
||||
"note": "做到了",
|
||||
}, key)
|
||||
env, _ = doJSON(t, r, http.MethodGet, "/api/v1/growth/plans/"+id+"/checkins", nil, key)
|
||||
out := decodeData[map[string]any](t, env.Data)
|
||||
items, _ := out["items"].([]any)
|
||||
if len(items) < 1 {
|
||||
t.Fatal("expected checkin")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
package integration_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/config"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/db"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/httpserver"
|
||||
)
|
||||
|
||||
type envelope struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
|
||||
func setupAPI(t *testing.T) (*gin.Engine, string) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
cfg := config.Load()
|
||||
pool, err := db.Connect(ctx, cfg.DatabaseURL)
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (run npm run deps:up): %v", err)
|
||||
}
|
||||
t.Cleanup(pool.Close)
|
||||
|
||||
migDir := filepath.Join("..", "..", "migrations")
|
||||
if err := db.Migrate(ctx, pool, migDir); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
return httpserver.NewRouter(pool, cfg), ""
|
||||
}
|
||||
|
||||
func doJSON(t *testing.T, r http.Handler, method, path string, body any, deviceKey string) (envelope, string) {
|
||||
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 != 0 {
|
||||
t.Fatalf("%s %s → code=%d message=%s", method, path, env.Code, env.Message)
|
||||
}
|
||||
key := w.Header().Get("X-Device-Key")
|
||||
if key == "" {
|
||||
key = deviceKey
|
||||
}
|
||||
return env, key
|
||||
}
|
||||
|
||||
func decodeData[T any](t *testing.T, raw json.RawMessage) T {
|
||||
t.Helper()
|
||||
var v T
|
||||
if err := json.Unmarshal(raw, &v); err != nil {
|
||||
t.Fatalf("decode data: %v raw=%s", err, string(raw))
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// Flow 1: create profile → portrait → deep_access mock → detail visible
|
||||
func TestFlowPortraitDeepAccess(t *testing.T) {
|
||||
r, _ := setupAPI(t)
|
||||
var key string
|
||||
|
||||
env, key := doJSON(t, r, http.MethodPost, "/api/v1/profiles", map[string]any{
|
||||
"relation": "self", "birth_date": "1990-05-12", "display_name": "我",
|
||||
}, key)
|
||||
profile := decodeData[map[string]any](t, env.Data)
|
||||
profileID, _ := profile["id"].(string)
|
||||
|
||||
env, key = doJSON(t, r, http.MethodPost, "/api/v1/reports/portrait", map[string]any{
|
||||
"profile_id": profileID,
|
||||
}, key)
|
||||
rep := decodeData[map[string]any](t, env.Data)
|
||||
reportID, _ := rep["id"].(string)
|
||||
if rep["has_deep_access"] == true {
|
||||
t.Fatal("expected gated detail before pay")
|
||||
}
|
||||
if rep["detail"] != nil {
|
||||
t.Fatal("detail should be nil before deep access")
|
||||
}
|
||||
|
||||
env, key = doJSON(t, r, http.MethodPost, "/api/v1/orders", map[string]any{
|
||||
"kind": "deep_access", "report_id": reportID,
|
||||
}, key)
|
||||
order := decodeData[map[string]any](t, env.Data)
|
||||
orderID, _ := order["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 has_deep_access after pay")
|
||||
}
|
||||
detail, ok := unlocked["detail"].(map[string]any)
|
||||
if !ok || len(detail) == 0 {
|
||||
t.Fatalf("expected detail map, got %#v", unlocked["detail"])
|
||||
}
|
||||
if detail["behavior_pattern"] == nil || detail["behavior_pattern"] == "" {
|
||||
t.Fatal("expected behavior_pattern in detail")
|
||||
}
|
||||
}
|
||||
|
||||
// Flow 2: two profiles → relation insight → deep_access → tips visible
|
||||
func TestFlowRelationDeepAccess(t *testing.T) {
|
||||
r, _ := setupAPI(t)
|
||||
var key string
|
||||
|
||||
env, key := doJSON(t, r, http.MethodPost, "/api/v1/profiles", map[string]any{
|
||||
"relation": "self", "birth_date": "1988-03-01", "display_name": "我",
|
||||
}, key)
|
||||
a := decodeData[map[string]any](t, env.Data)
|
||||
|
||||
env, key = doJSON(t, r, http.MethodPost, "/api/v1/profiles", map[string]any{
|
||||
"relation": "other", "birth_date": "1992-08-20", "display_name": "TA", "relation_type": "partner",
|
||||
}, key)
|
||||
b := decodeData[map[string]any](t, env.Data)
|
||||
|
||||
env, key = doJSON(t, r, http.MethodPost, "/api/v1/relation/insight", map[string]any{
|
||||
"profile_a_id": a["id"], "profile_b_id": b["id"],
|
||||
}, key)
|
||||
out := decodeData[map[string]any](t, env.Data)
|
||||
rep, ok := out["report"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("missing report: %#v", out)
|
||||
}
|
||||
reportID, _ := rep["id"].(string)
|
||||
if rep["has_deep_access"] == true {
|
||||
t.Fatal("expected gated tips before pay")
|
||||
}
|
||||
sum, _ := rep["summary"].(map[string]any)
|
||||
if sum["love_index"] == nil || sum["friend_index"] == nil || sum["marriage_index"] == nil {
|
||||
t.Fatalf("expected match indices in summary: %#v", sum)
|
||||
}
|
||||
|
||||
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 access")
|
||||
}
|
||||
detail, ok := unlocked["detail"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("expected detail")
|
||||
}
|
||||
comm, _ := detail["communication"].([]any)
|
||||
if len(comm) == 0 {
|
||||
t.Fatalf("expected communication tips, detail=%#v", detail)
|
||||
}
|
||||
}
|
||||
|
||||
// Flow 3: membership mock → entitlement → portrait detail without per-report deep_access
|
||||
func TestFlowMembershipUnlock(t *testing.T) {
|
||||
r, _ := setupAPI(t)
|
||||
var key string
|
||||
|
||||
env, key := doJSON(t, r, http.MethodPost, "/api/v1/profiles", map[string]any{
|
||||
"relation": "self", "birth_date": "1995-11-07", "display_name": "我",
|
||||
}, key)
|
||||
profileID := decodeData[map[string]any](t, env.Data)["id"].(string)
|
||||
|
||||
env, key = doJSON(t, r, http.MethodPost, "/api/v1/reports/portrait", map[string]any{
|
||||
"profile_id": profileID,
|
||||
}, key)
|
||||
reportID := decodeData[map[string]any](t, env.Data)["id"].(string)
|
||||
|
||||
env, key = doJSON(t, r, http.MethodGet, "/api/v1/membership/me", nil, key)
|
||||
me := decodeData[map[string]any](t, env.Data)
|
||||
if me["active"] == true {
|
||||
t.Fatal("expected inactive membership before subscribe")
|
||||
}
|
||||
|
||||
env, key = doJSON(t, r, http.MethodPost, "/api/v1/orders", map[string]any{
|
||||
"kind": "membership", "plan": "month",
|
||||
}, 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, key = doJSON(t, r, http.MethodGet, "/api/v1/membership/me", nil, key)
|
||||
me = decodeData[map[string]any](t, env.Data)
|
||||
if me["active"] != true {
|
||||
t.Fatalf("expected active membership, got %#v", me)
|
||||
}
|
||||
|
||||
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("membership should unlock report detail")
|
||||
}
|
||||
if unlocked["detail"] == nil {
|
||||
t.Fatal("expected detail via membership")
|
||||
}
|
||||
}
|
||||
|
||||
// Flow 5: profile update + soft
|
||||
func TestFlowProfileUpdateDelete(t *testing.T) {
|
||||
r, _ := setupAPI(t)
|
||||
var key string
|
||||
|
||||
env, key := doJSON(t, r, http.MethodPost, "/api/v1/profiles", map[string]any{
|
||||
"relation": "other", "birth_date": "1993-04-04", "display_name": "旧名", "relation_type": "friend",
|
||||
}, key)
|
||||
id := decodeData[map[string]any](t, env.Data)["id"].(string)
|
||||
|
||||
env, key = doJSON(t, r, http.MethodPatch, "/api/v1/profiles/"+id, map[string]any{
|
||||
"display_name": "新名", "birth_date": "1993-04-05", "relation_type": "partner",
|
||||
}, key)
|
||||
updated := decodeData[map[string]any](t, env.Data)
|
||||
if updated["display_name"] != "新名" {
|
||||
t.Fatalf("display_name not updated: %#v", updated)
|
||||
}
|
||||
|
||||
_, key = doJSON(t, r, http.MethodDelete, "/api/v1/profiles/"+id, nil, key)
|
||||
|
||||
env, _ = doJSON(t, r, http.MethodGet, "/api/v1/profiles", nil, key)
|
||||
items, _ := decodeData[map[string]any](t, env.Data)["items"].([]any)
|
||||
for _, it := range items {
|
||||
m := it.(map[string]any)
|
||||
if m["id"] == id {
|
||||
t.Fatal("deleted profile still listed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Flow 4: profile → ask thread → message → assistant reply + quota
|
||||
func TestFlowAskThread(t *testing.T) {
|
||||
r, _ := setupAPI(t)
|
||||
var key string
|
||||
|
||||
env, key := doJSON(t, r, http.MethodPost, "/api/v1/profiles", map[string]any{
|
||||
"relation": "self", "birth_date": "1991-02-14", "display_name": "我",
|
||||
}, key)
|
||||
profileID := decodeData[map[string]any](t, env.Data)["id"].(string)
|
||||
|
||||
env, key = doJSON(t, r, http.MethodGet, "/api/v1/ask/quota", nil, key)
|
||||
q0 := decodeData[map[string]any](t, env.Data)
|
||||
rem0, _ := q0["remaining"].(float64)
|
||||
if rem0 < 1 {
|
||||
t.Fatalf("expected free quota, got %#v", q0)
|
||||
}
|
||||
|
||||
env, key = doJSON(t, r, http.MethodPost, "/api/v1/ask/threads", map[string]any{
|
||||
"profile_id": profileID, "scene": "self",
|
||||
}, key)
|
||||
threadID := decodeData[map[string]any](t, env.Data)["id"].(string)
|
||||
|
||||
env, key = doJSON(t, r, http.MethodPost, "/api/v1/ask/threads/"+threadID+"/messages", map[string]any{
|
||||
"content": "我想更了解自己",
|
||||
}, key)
|
||||
out := decodeData[map[string]any](t, env.Data)
|
||||
asst, ok := out["assistant_message"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("missing assistant_message: %#v", out)
|
||||
}
|
||||
content, _ := asst["content"].(string)
|
||||
if content == "" {
|
||||
t.Fatal("empty assistant reply")
|
||||
}
|
||||
q1, ok := out["quota"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("missing quota")
|
||||
}
|
||||
rem1, _ := q1["remaining"].(float64)
|
||||
if rem1 != rem0-1 {
|
||||
t.Fatalf("quota should decrease: before=%v after=%v", rem0, rem1)
|
||||
}
|
||||
|
||||
env, _ = doJSON(t, r, http.MethodGet, "/api/v1/ask/threads/"+threadID+"/messages", nil, key)
|
||||
items := decodeData[map[string]any](t, env.Data)["items"].([]any)
|
||||
if len(items) < 2 {
|
||||
t.Fatalf("expected user+assistant history, got %d", len(items))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package integration_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFlowSynastryMultiChartsAndInvite(t *testing.T) {
|
||||
r, _ := setupAPI(t)
|
||||
var key string
|
||||
|
||||
env, key := doJSON(t, r, http.MethodPost, "/api/v1/profiles", map[string]any{
|
||||
"relation": "self", "birth_date": "1990-05-12", "display_name": "我",
|
||||
"birth_time": "10:30", "birth_place": "上海",
|
||||
}, key)
|
||||
pa := decodeData[map[string]any](t, env.Data)
|
||||
idA, _ := pa["id"].(string)
|
||||
|
||||
env, key = doJSON(t, r, http.MethodPost, "/api/v1/profiles", map[string]any{
|
||||
"relation": "other", "birth_date": "1992-08-20", "display_name": "TA",
|
||||
"birth_time": "08:00", "birth_place": "北京",
|
||||
}, key)
|
||||
pb := decodeData[map[string]any](t, env.Data)
|
||||
idB, _ := pb["id"].(string)
|
||||
|
||||
env, key = doJSON(t, r, http.MethodPost, "/api/v1/reports/synastry", map[string]any{
|
||||
"profile_id_a": idA, "profile_id_b": idB, "as_of": "2026-08-02",
|
||||
}, key)
|
||||
rep := decodeData[map[string]any](t, env.Data)
|
||||
if rep["type"] != "synastry" {
|
||||
t.Fatalf("type=%v", rep["type"])
|
||||
}
|
||||
sum, _ := rep["summary"].(map[string]any)
|
||||
if sum == nil {
|
||||
t.Fatal("summary nil")
|
||||
}
|
||||
if sum["as_of"] != "2026-08-02" {
|
||||
t.Fatalf("as_of=%v", sum["as_of"])
|
||||
}
|
||||
charts, _ := sum["charts"].(map[string]any)
|
||||
if charts == nil {
|
||||
t.Fatal("charts missing")
|
||||
}
|
||||
for _, k := range []string{
|
||||
"compare", "composite", "davison", "marks_me", "marks_other", "overlay",
|
||||
"composite_progressed", "davison_progressed", "marks_progressed",
|
||||
} {
|
||||
if charts[k] == nil {
|
||||
t.Fatalf("missing charts.%s", k)
|
||||
}
|
||||
}
|
||||
// no deep access → detail stripped
|
||||
if rep["has_deep_access"] == true {
|
||||
t.Fatal("expected no deep access")
|
||||
}
|
||||
if rep["detail"] != nil {
|
||||
t.Fatalf("detail should be stripped, got %#v", rep["detail"])
|
||||
}
|
||||
|
||||
// invite flow: second device accepts
|
||||
env, key = doJSON(t, r, http.MethodPost, "/api/v1/synastry/invites", map[string]any{
|
||||
"profile_id": idA,
|
||||
}, key)
|
||||
inv := decodeData[map[string]any](t, env.Data)
|
||||
token, _ := inv["token"].(string)
|
||||
if token == "" {
|
||||
t.Fatal("empty token")
|
||||
}
|
||||
|
||||
env2, key2 := doJSON(t, r, http.MethodGet, "/api/v1/profiles", nil, "")
|
||||
_ = env2
|
||||
env2, key2 = doJSON(t, r, http.MethodGet, "/api/v1/synastry/invites/"+token, nil, key2)
|
||||
meta := decodeData[map[string]any](t, env2.Data)
|
||||
if meta["host_name"] == nil {
|
||||
t.Fatal("host_name missing")
|
||||
}
|
||||
|
||||
env2, key2 = doJSON(t, r, http.MethodPost, "/api/v1/synastry/invites/"+token+"/accept", map[string]any{
|
||||
"display_name": "好友", "birth_date": "1995-03-03",
|
||||
}, key2)
|
||||
acc := decodeData[map[string]any](t, env2.Data)
|
||||
if acc["type"] != "synastry" {
|
||||
t.Fatalf("accept type=%v", acc["type"])
|
||||
}
|
||||
_ = key
|
||||
_ = key2
|
||||
}
|
||||
Reference in New Issue
Block a user