package integration_test import ( "bytes" "context" "encoding/json" "fmt" "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() cfg.Admin.BootstrapUsername = "admin" cfg.Admin.BootstrapPassword = "change-me" 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) } 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 != 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 } var testBearer string func mustRegister(t *testing.T, r http.Handler) string { t.Helper() testBearer = "" phone := fmt.Sprintf("1%010d", time.Now().UnixNano()%10_000_000_000) env, key := doJSON(t, r, http.MethodPost, "/api/v1/auth/register", map[string]any{ "phone": phone, "password": "secret12", "nickname": "测", }, "") sess := decodeData[map[string]any](t, env.Data) tok, _ := sess["token"].(string) if tok == "" { t.Fatal("missing token from register") } testBearer = tok t.Cleanup(func() { testBearer = "" }) return 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) key := mustRegister(t, r) 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) key := mustRegister(t, r) 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) key := mustRegister(t, r) 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) key := mustRegister(t, r) 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) key := mustRegister(t, r) 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)) } } // Flow 4b: exhaust free ask quota → buy ask_pack → can ask again func TestFlowAskPackPurchase(t *testing.T) { r, _ := setupAPI(t) key := mustRegister(t, r) 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.MethodPost, "/api/v1/ask/threads", map[string]any{ "profile_id": profileID, "scene": "self", }, key) threadID := decodeData[map[string]any](t, env.Data)["id"].(string) for i := 0; i < 3; i++ { _, key = doJSON(t, r, http.MethodPost, "/api/v1/ask/threads/"+threadID+"/messages", map[string]any{ "content": "第几问", }, key) } env, key, status := doJSONExpect(t, r, http.MethodPost, "/api/v1/ask/threads/"+threadID+"/messages", map[string]any{ "content": "应该没额度了", }, key, 40210) if status != http.StatusPaymentRequired { t.Fatalf("expected 402 after free exhaust, got %d %#v", status, env) } env, key = doJSON(t, r, http.MethodPost, "/api/v1/orders", map[string]any{ "kind": "ask_pack", "plan": "pack10", }, 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/ask/quota", nil, key) q := decodeData[map[string]any](t, env.Data) rem, _ := q["remaining"].(float64) paid, _ := q["paid_left"].(float64) if rem < 10 || paid < 10 { t.Fatalf("expected paid pack quota, got %#v", q) } env, _ = doJSON(t, r, http.MethodPost, "/api/v1/ask/threads/"+threadID+"/messages", map[string]any{ "content": "买完额度继续问", }, key) out := decodeData[map[string]any](t, env.Data) q2, _ := out["quota"].(map[string]any) rem2, _ := q2["remaining"].(float64) if rem2 != rem-1 { t.Fatalf("paid quota should decrease: before=%v after=%v", rem, rem2) } }