落地埋点 ingest/数据看板、首页宫格 CMS 与测评上下架;含账号引导、问答流式与免责声明去重,以及 review P1 审计同事务修复。 Co-authored-by: Cursor <cursoragent@cursor.com>
387 lines
13 KiB
Go
387 lines
13 KiB
Go
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)
|
|
}
|
|
}
|