Replace virtual payments with WeChat Pay API v3 and verify callbacks and order polling. Cache WeChat access tokens in Redis and write structured Zap logs. Support configurable capacity plans across the API, admin and mini program, with purchase snapshots and quota enforcement. Allow personal albums before binding and share them with the current partner. Update deployment configuration and domain routing.
604 lines
27 KiB
Go
604 lines
27 KiB
Go
package handler
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"github.com/alicebob/miniredis/v2"
|
|
"github.com/redis/go-redis/v9"
|
|
"go.uber.org/zap"
|
|
"image"
|
|
"image/color"
|
|
"image/draw"
|
|
"image/png"
|
|
"io"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"os"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
"testing/fstest"
|
|
"time"
|
|
|
|
"github.com/minio/minio-go/v7"
|
|
"gorm.io/gorm/logger"
|
|
"past/internal/config"
|
|
"past/internal/geo"
|
|
"past/internal/httpapi"
|
|
"past/internal/model"
|
|
"past/internal/store"
|
|
"past/internal/upload"
|
|
"past/internal/wechat"
|
|
)
|
|
|
|
type apiTransport func(*http.Request) (*http.Response, error)
|
|
|
|
func (f apiTransport) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
|
|
|
|
type apiSQLLog struct {
|
|
logger.Interface
|
|
t *testing.T
|
|
}
|
|
|
|
func (l apiSQLLog) Trace(ctx context.Context, start time.Time, fc func() (string, int64), err error) {
|
|
if err != nil {
|
|
q, _ := fc()
|
|
l.t.Errorf("SQL failed: %v; %s", err, q)
|
|
}
|
|
}
|
|
|
|
// This runs the real HTTP router against an isolated PostgreSQL schema. Only
|
|
// WeChat/map HTTP dependencies are stubbed; GORM, PostgreSQL and uploads are real.
|
|
func TestPostgresAPI(t *testing.T) {
|
|
for _, stringsMode := range []bool{false, true} {
|
|
t.Run(fmt.Sprintf("numeric_strings_%v", stringsMode), func(t *testing.T) { runPostgresAPI(t, stringsMode) })
|
|
}
|
|
}
|
|
func runPostgresAPI(t *testing.T, stringsMode bool) {
|
|
dsn := os.Getenv("PAST_TEST_POSTGRES_DSN")
|
|
if dsn == "" {
|
|
t.Skip("set PAST_TEST_POSTGRES_DSN")
|
|
}
|
|
root, err := store.Open(dsn)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer root.Close()
|
|
schema := fmt.Sprintf("past_api_test_%d", time.Now().UnixNano())
|
|
if err := root.ORM.Exec(`CREATE SCHEMA "` + schema + `"`).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer func() {
|
|
if err := root.ORM.Exec(`DROP SCHEMA "` + schema + `" CASCADE`).Error; err != nil {
|
|
t.Error(err)
|
|
}
|
|
}()
|
|
u, err := url.Parse(dsn)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
params := u.Query()
|
|
params.Set("search_path", schema)
|
|
u.RawQuery = params.Encode()
|
|
u.RawQuery = strings.ReplaceAll(u.RawQuery, "%2F", "/")
|
|
db, err := store.Open(u.String())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer db.Close()
|
|
if err := db.InitSchema(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Simulate the deployed album schema and verify AutoMigrate relaxes its old binding requirement.
|
|
if err := db.ORM.Exec(`ALTER TABLE past_album ALTER COLUMN couple_id SET NOT NULL`).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.InitSchema(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.MigrateSmokingRecords(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
db.ORM.Config.Logger = apiSQLLog{Interface: logger.Default, t: t}
|
|
db.NumericAsString = stringsMode
|
|
dataDir := t.TempDir()
|
|
cfg := &config.Config{DataDir: dataDir, MaxUploadMB: 5}
|
|
cacheServer := miniredis.RunT(t)
|
|
cacheClient := redis.NewClient(&redis.Options{Addr: cacheServer.Addr()})
|
|
defer cacheClient.Close()
|
|
wx := wechat.New("test-app", "test-secret", "test-template", cacheClient, "test:", db)
|
|
oldTransport := http.DefaultTransport
|
|
http.DefaultTransport = apiTransport(func(r *http.Request) (*http.Response, error) {
|
|
body := `{"errcode":0,"access_token":"test-access","expires_in":7200,"openid":"wx-test-user","session_key":"test-session"}`
|
|
return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(body))}, nil
|
|
})
|
|
defer func() { http.DefaultTransport = oldTransport }()
|
|
undoLog := zap.ReplaceGlobals(zap.NewNop())
|
|
defer undoLog()
|
|
app := &httpapi.App{Cfg: cfg, DB: db, Wechat: wx, Geo: geo.New(""), Admin: httpapi.NewAdminSessions()}
|
|
router := httpapi.NewRouter(app, fstest.MapFS{"index.html": &fstest.MapFile{Data: []byte("ok")}})
|
|
testConfig := os.Getenv("PAST_TEST_CONFIG")
|
|
if testConfig == "" {
|
|
testConfig = "../../config.yaml"
|
|
}
|
|
storageConfig, err := config.Load(testConfig)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
storageConfig.Minio.Bucket = "past-api-test-" + fmt.Sprint(time.Now().UnixNano())
|
|
uploader, err := upload.New(storageConfig.Minio, db, 5*1024*1024, wx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer func() {
|
|
for object := range uploader.Client.ListObjects(context.Background(), uploader.Bucket, minio.ListObjectsOptions{Recursive: true}) {
|
|
if object.Err != nil {
|
|
t.Error(object.Err)
|
|
break
|
|
}
|
|
if err := uploader.Client.RemoveObject(context.Background(), uploader.Bucket, object.Key, minio.RemoveObjectOptions{}); err != nil {
|
|
t.Error(err)
|
|
}
|
|
}
|
|
if err := uploader.Client.RemoveBucket(context.Background(), uploader.Bucket); err != nil {
|
|
t.Error(err)
|
|
}
|
|
}()
|
|
Register(router, uploader)
|
|
engine := router.Engine(false)
|
|
covered := map[string]int{}
|
|
happy := map[string]bool{}
|
|
var adminCookies []*http.Cookie
|
|
request := func(action, method, token string, body M, want int) M {
|
|
t.Helper()
|
|
var payload io.Reader
|
|
uri := "/api/" + action
|
|
if method == "GET" {
|
|
v := url.Values{}
|
|
for key, value := range body {
|
|
v.Set(key, fmt.Sprint(value))
|
|
}
|
|
uri += "?" + v.Encode()
|
|
} else {
|
|
b, _ := json.Marshal(body)
|
|
payload = bytes.NewReader(b)
|
|
}
|
|
req := httptest.NewRequest(method, uri, payload)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
if token == "admin" {
|
|
for _, cookie := range adminCookies {
|
|
req.AddCookie(cookie)
|
|
}
|
|
} else if token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
}
|
|
out := httptest.NewRecorder()
|
|
engine.ServeHTTP(out, req)
|
|
covered[action]++
|
|
if want > 0 && out.Code != want {
|
|
t.Fatalf("%s %s: status %d want %d; %s", method, action, out.Code, want, out.Body.String())
|
|
}
|
|
if out.Code >= 500 {
|
|
t.Fatalf("%s %s: %d %s", method, action, out.Code, out.Body.String())
|
|
}
|
|
if out.Code >= 200 && out.Code < 300 {
|
|
happy[action] = true
|
|
}
|
|
if action == "admin_login" && out.Code == 200 {
|
|
adminCookies = out.Result().Cookies()
|
|
}
|
|
var value any
|
|
if err := json.Unmarshal(out.Body.Bytes(), &value); err != nil {
|
|
t.Fatalf("%s invalid JSON: %s", action, out.Body.String())
|
|
}
|
|
if result, ok := value.(map[string]any); ok {
|
|
return normalizeAPIMap(M(result))
|
|
}
|
|
return M{"items": value}
|
|
}
|
|
uploadFile := func(action, tok, field string, fields M) M {
|
|
t.Helper()
|
|
var body bytes.Buffer
|
|
mw := multipart.NewWriter(&body)
|
|
for key, value := range fields {
|
|
mw.WriteField(key, fmt.Sprint(value))
|
|
}
|
|
part, _ := mw.CreateFormFile(field, "integration.png")
|
|
im := image.NewRGBA(image.Rect(0, 0, 64, 48))
|
|
draw.Draw(im, im.Bounds(), image.NewUniform(color.RGBA{R: 24, G: 149, B: 184, A: 255}), image.Point{}, draw.Src)
|
|
png.Encode(part, im)
|
|
mw.Close()
|
|
req := httptest.NewRequest("POST", "/api/"+action, &body)
|
|
req.Header.Set("Content-Type", mw.FormDataContentType())
|
|
if tok == "admin" {
|
|
for _, cookie := range adminCookies {
|
|
req.AddCookie(cookie)
|
|
}
|
|
} else {
|
|
req.Header.Set("Authorization", "Bearer "+tok)
|
|
}
|
|
out := httptest.NewRecorder()
|
|
engine.ServeHTTP(out, req)
|
|
covered[action]++
|
|
if out.Code != 200 {
|
|
t.Fatalf("upload %s: %d %s", action, out.Code, out.Body.String())
|
|
}
|
|
happy[action] = true
|
|
var result M
|
|
if err := json.Unmarshal(out.Body.Bytes(), &result); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return normalizeAPIMap(result)
|
|
}
|
|
get := func(a, tok string, b M) M { return request(a, "GET", tok, b, 200) }
|
|
post := func(a, tok string, b M) M { return request(a, "POST", tok, b, 200) }
|
|
id := func(r M, key string) string {
|
|
t.Helper()
|
|
value := store.ToStr(r[key])
|
|
if !store.ValidUUID(value) {
|
|
t.Fatalf("%s is not UUID: %v", key, r)
|
|
}
|
|
return value
|
|
}
|
|
a := post("register", "", M{"phone": "13800000001", "password": "testpass1", "nickname": "Alpha", "gender": "male"})
|
|
aID, aToken := id(a, "user_id"), store.ToStr(a["token"])
|
|
b := post("register", "", M{"phone": "13800000002", "password": "testpass2", "nickname": "Beta", "gender": "female"})
|
|
bID, bToken := id(b, "user_id"), store.ToStr(b["token"])
|
|
outsider := post("register", "", M{"phone": "13800000003", "password": "testpass3", "nickname": "Other"})
|
|
otherToken := store.ToStr(outsider["token"])
|
|
post("login", "", M{"phone": "13800000001", "username": "13800000001", "password": "testpass1"})
|
|
get("check_auth", aToken, nil)
|
|
get("my_info", aToken, nil)
|
|
// Personal albums work before binding and remain scoped to the creator.
|
|
personalAlbum := id(post("album_add", aToken, M{"title": "Personal album"}), "id")
|
|
personalFile := id(uploadFile("upload", aToken, "file", nil), "file_id")
|
|
personalPhoto := id(post("photo_add", aToken, M{"album_id": personalAlbum, "file_id": personalFile}), "id")
|
|
uploadFile("set_album_cover", aToken, "cover", M{"album_id": personalAlbum})
|
|
uploadFile("photo_upload", aToken, "photos", M{"album_id": personalAlbum})
|
|
post("photo_update", aToken, M{"id": personalPhoto, "taken_at": today(), "location": "Personal place", "activity": "Personal photo"})
|
|
post("album_update", aToken, M{"id": personalAlbum, "title": "Personal edited"})
|
|
if got := get("photos", aToken, M{"album_id": personalAlbum, "page": 1, "page_size": 20}); store.ToInt(got["total"]) != 2 {
|
|
t.Fatalf("personal photos missing: %v", got)
|
|
}
|
|
if got := get("photos", otherToken, M{"album_id": personalAlbum, "page": 1, "page_size": 20}); store.ToInt(got["total"]) != 0 {
|
|
t.Fatalf("personal photos leaked: %v", got)
|
|
}
|
|
if got := get("album_list", otherToken, M{"page": 1, "page_size": 20}); store.ToInt(got["total"]) != 0 {
|
|
t.Fatalf("personal albums leaked: %v", got)
|
|
}
|
|
request("photo_add", "POST", otherToken, M{"album_id": personalAlbum, "file_id": personalFile}, 404)
|
|
request("photo_update", "POST", otherToken, M{"id": personalPhoto, "taken_at": "", "location": "", "activity": ""}, 404)
|
|
request("album_delete", "POST", otherToken, M{"id": personalAlbum}, 404)
|
|
var personal model.Album
|
|
if err := db.ORM.First(&personal, "id=?", personalAlbum).Error; err != nil || personal.CoupleId != nil {
|
|
t.Fatalf("personal album relation: %+v %v", personal, err)
|
|
}
|
|
link := post("generate_invite_link", aToken, nil)
|
|
_ = link
|
|
post("update_profile", aToken, M{"nickname": "Alpha updated", "gender": "male"})
|
|
inv := post("invite_partner", aToken, M{"target_user_id": b["display_id"]})
|
|
couple := post("respond_invitation", bToken, M{"invitation_id": id(inv, "invitation_id"), "accept": true})
|
|
cid := id(couple, "couple_id")
|
|
for _, uid := range []string{aID, bID} {
|
|
var user model.User
|
|
if err := db.ORM.First(&user, "id=?", uid).Error; err != nil || user.CoupleId == nil || *user.CoupleId != cid {
|
|
t.Fatalf("binding mismatch: %+v %v", user, err)
|
|
}
|
|
}
|
|
if got := get("photos", bToken, M{"album_id": personalAlbum, "page": 1, "page_size": 20}); store.ToInt(got["total"]) != 2 {
|
|
t.Fatalf("partner cannot view pre-binding photos: %v", got)
|
|
}
|
|
post("photo_update", bToken, M{"id": personalPhoto, "taken_at": today(), "location": "Shared now", "activity": "Partner edit"})
|
|
post("update_couple_info", aToken, M{"love_start_date": "2026-01-01", "love_story": "story"})
|
|
diary := post("diary_add", aToken, M{"title": "Diary A", "content": "body", "diary_date": today(), "images": []string{}})
|
|
diaryID := id(diary, "id")
|
|
post("diary_update", aToken, M{"id": diaryID, "title": "Diary edited", "content": "new body"})
|
|
comment := post("diary_comment_add", bToken, M{"diary_id": diaryID, "content": "Comment B"})
|
|
_ = comment
|
|
timeline := post("timeline_add", aToken, M{"title": "Memory", "content": "memory body", "event_date": today()})
|
|
timelineID := id(timeline, "id")
|
|
post("timeline_update", aToken, M{"id": timelineID, "title": "Memory edited", "content": "changed", "event_date": today()})
|
|
song := post("songs_add", aToken, M{"song_name": "Song", "artist": "Artist"})
|
|
songID := id(song, "id")
|
|
post("songs_update", aToken, M{"id": songID, "song_name": "Song edited", "artist": "Artist"})
|
|
album := post("album_add", aToken, M{"title": "Album", "description": "description"})
|
|
albumID := id(album, "id")
|
|
post("album_update", aToken, M{"id": albumID, "title": "Album edited", "description": "changed"})
|
|
uploadMeta := uploadFile("upload", aToken, "file", nil)
|
|
fileId := id(uploadMeta, "file_id")
|
|
photo := post("photo_add", aToken, M{"album_id": albumID, "file_id": fileId, "description": "photo"})
|
|
photoID := id(photo, "id")
|
|
post("photo_update", aToken, M{"id": photoID, "taken_at": today(), "location": "location", "activity": "activity"})
|
|
uploadFile("set_album_cover", aToken, "cover", M{"album_id": albumID})
|
|
check := post("checkin_add", aToken, M{"title": "Walk", "type": "dual", "frequency": "daily"})
|
|
checkID := id(check, "id")
|
|
post("checkin_do", aToken, M{"id": checkID})
|
|
post("checkin_do", bToken, M{"id": checkID})
|
|
post("checkin_uncheck", bToken, M{"id": checkID})
|
|
post("checkin_toggle", aToken, M{"id": checkID})
|
|
post("smoking_action", aToken, M{"kind": "purchase", "quantity": 20, "amount_cents": 1000, "request_key": "purchase-1"})
|
|
post("smoking_action", aToken, M{"kind": "purchase", "quantity": 20, "amount_cents": 1000, "request_key": "purchase-1"})
|
|
post("smoking_action", aToken, M{"kind": "consume", "quantity": 1, "request_key": "consume-1"})
|
|
wallet, _ := db.QueryOne("SELECT stock FROM smoking_wallets WHERE user_id=?", aID)
|
|
if wallet.Int("stock") != 19 {
|
|
t.Fatalf("idempotency/inventory mismatch: %v", wallet)
|
|
}
|
|
post("smoking_card_visible", aToken, M{"visible": 0})
|
|
period := post("period_add", bToken, M{"start_date": today(), "end_date": today(), "cycle_length": 28, "period_length": 5})
|
|
periodID := id(period, "id")
|
|
post("period_update", bToken, M{"id": periodID, "start_date": today(), "end_date": today(), "cycle_length": 29, "period_length": 5})
|
|
post("period_daily_save", bToken, M{"record_date": today(), "period": "2", "sex": "0", "note": "note"})
|
|
sex := post("period_sex_add", bToken, M{"record_date": today(), "record_time": "12:30", "measure": "安全套"})
|
|
_ = sex
|
|
post("period_visible_set", bToken, M{"period_visible": 1})
|
|
pet := post("pet_adopt", aToken, M{"name": "Test pet", "breed": "golden", "gender": 1})
|
|
_ = pet
|
|
post("pet_rename", aToken, M{"name": "Pet renamed"})
|
|
post("pet_daily_action", aToken, M{"action": "feed"})
|
|
post("pet_equip", aToken, M{"item_key": "collar_basic", "equip": 1})
|
|
post("subscribe_template", aToken, M{"template_id": "test-template", "template_type": "notify"})
|
|
post("unsubscribe_template", aToken, M{"template_type": "notify"})
|
|
post("admin_login", "", M{"username": "past", "password": "past"})
|
|
uploadFile("upload", aToken, "file", M{"type": "diary"})
|
|
uploadFile("upload_avatar", aToken, "avatar", nil)
|
|
uploadFile("upload_partner_avatar", aToken, "avatar", M{"partner_id": bID})
|
|
uploadFile("set_bg_image", aToken, "bg", nil)
|
|
repeatedBackground := uploadFile("set_bg_image", aToken, "bg", nil)
|
|
if repeatedBackground["duplicate"] != true {
|
|
t.Fatal("identical background uploaded more than once")
|
|
}
|
|
var storedBackground model.Setting
|
|
if err := db.ORM.First(&storedBackground, "key=?", "bg_"+cid).Error; err != nil || storedBackground.FileId == nil || storedBackground.Value != *storedBackground.FileId {
|
|
t.Fatalf("background association did not store a UUID: %+v %v", storedBackground, err)
|
|
}
|
|
|
|
globalUpload := uploadFile("admin_set_global_bg", "admin", "bg", nil)
|
|
globalFileID := id(globalUpload, "file_id")
|
|
if globalFileID == "" || globalUpload["duplicate"] != true || globalUpload["path"] != "/api/files/"+globalFileID {
|
|
t.Fatalf("admin upload missing stable file metadata: %+v", globalUpload)
|
|
}
|
|
var globalSetting model.Setting
|
|
if err := db.ORM.First(&globalSetting, "key=?", "bg_global").Error; err != nil || globalSetting.FileId == nil || *globalSetting.FileId != globalFileID || globalSetting.Value != globalFileID {
|
|
t.Fatalf("global background association did not store upload ID: %+v %v", globalSetting, err)
|
|
}
|
|
globalBg := get("admin_get_global_bg", "admin", nil)
|
|
if globalBg["bg_image_file_id"] != globalFileID {
|
|
t.Fatalf("admin background query lost file ID: %+v", globalBg)
|
|
}
|
|
|
|
shared := M{"id": diaryID, "diary_id": diaryID, "album_id": albumID, "couple_id": cid, "user_id": aID, "record_date": today(), "date": today(), "type": "diary"}
|
|
for _, action := range []string{"site_info", "site_stats", "my_info", "my_invitations", "couple_info", "check_invite_data_status", "diary_list", "diary_detail", "diary_comment_list", "timeline_list", "timeline_detail", "songs_list", "album_list", "photos", "checkin_list", "checkin_detail", "checkin_stats", "smoking_info", "period_list", "period_visible_get", "period_daily_get", "period_sex_list", "pet_info", "pet_daily_status", "pet_achievements", "pet_dynamics", "activity_log_list", "my_subscribe_templates", "storage_info", "get_bg_config", "unbind_status"} {
|
|
args := M{}
|
|
for k, v := range shared {
|
|
args[k] = v
|
|
}
|
|
if strings.HasPrefix(action, "timeline") {
|
|
args["id"] = timelineID
|
|
}
|
|
if strings.HasPrefix(action, "checkin") {
|
|
args["id"] = checkID
|
|
}
|
|
get(action, aToken, args)
|
|
}
|
|
for _, action := range []string{"admin_check_auth", "admin_stats", "admin_stats_detail", "admin_couples", "admin_couple_detail", "admin_users", "admin_user_detail", "admin_pets", "admin_pet_detail", "admin_contents", "admin_activity_log", "admin_get_global_bg", "admin_couple_bg_list", "admin_unbind_requests", "admin_subscribe_logs", "admin_storage_settings", "admin_storage_users", "admin_storage_orders"} {
|
|
args := M{}
|
|
for k, v := range shared {
|
|
args[k] = v
|
|
}
|
|
args["id"] = cid
|
|
if action == "admin_user_detail" {
|
|
args["id"] = aID
|
|
}
|
|
get(action, "admin", args)
|
|
}
|
|
// Cross-account access and mutations must not expose or overwrite another couple's data.
|
|
for _, action := range []string{"diary_detail", "timeline_detail", "checkin_detail"} {
|
|
args := M{"id": diaryID}
|
|
if action == "timeline_detail" {
|
|
args["id"] = timelineID
|
|
}
|
|
if action == "checkin_detail" {
|
|
args["id"] = checkID
|
|
}
|
|
request(action, "GET", otherToken, args, 0)
|
|
}
|
|
request("diary_update", "POST", otherToken, M{"id": diaryID, "title": "intrusion", "content": "bad"}, 400)
|
|
detail := get("diary_detail", aToken, M{"id": diaryID})
|
|
if strings.Contains(fmt.Sprint(detail), "intrusion") {
|
|
t.Fatal("foreign account overwrote diary")
|
|
}
|
|
post("photo_delete", aToken, M{"id": photoID})
|
|
post("album_delete", aToken, M{"id": albumID})
|
|
post("songs_delete", aToken, M{"id": songID})
|
|
post("timeline_delete", aToken, M{"id": timelineID})
|
|
post("checkin_delete", aToken, M{"id": checkID})
|
|
post("diary_delete", aToken, M{"id": diaryID})
|
|
var deleted model.Diary
|
|
if err := db.ORM.Unscoped().First(&deleted, "id=?", diaryID).Error; err != nil || !deleted.DeletedAt.Valid || deleted.Title != "Diary edited" {
|
|
t.Fatalf("deleted diary/data mismatch %+v %v", deleted, err)
|
|
}
|
|
// Remaining mutation routes use valid fixtures, with explicit persistence checks.
|
|
post("update_location", aToken, M{"lat": 31.23, "lng": 121.47, "location": "Shanghai"})
|
|
post("update_ip_location", aToken, nil)
|
|
post("update_partner_profile", aToken, M{"partner_id": bID, "nickname": "Beta updated", "age": 25})
|
|
post("period_source_select", bToken, M{"user_id": bID})
|
|
post("period_sex_delete", bToken, M{"id": id(sex, "id")})
|
|
post("period_delete", bToken, M{"id": periodID, "start_date": today(), "end_date": today()})
|
|
commentRow := comment["comment"].(M)
|
|
post("diary_comment_delete", bToken, M{"id": id(commentRow, "id")})
|
|
post("delete_bg_image", aToken, nil)
|
|
post("admin_clear_couple_bg", "admin", M{"couple_id": cid})
|
|
post("admin_update_user", "admin", M{"user_id": aID, "nickname": "Admin edited A", "age": 27})
|
|
post("admin_storage_user_update", "admin", M{"user_id": aID, "quota_mb": 500, "plan_key": "month"})
|
|
post("admin_reset_user_password", "admin", M{"user_id": outsider["user_id"], "new_password": "resetpass"})
|
|
post("login", "", M{"phone": "13800000003", "password": "resetpass"})
|
|
post("admin_change_password", "admin", M{"old_password": "past", "new_password": "admin-test-pass"})
|
|
post("admin_change_username", "admin", M{"new_username": "integration-admin"})
|
|
for _, term := range []string{store.ToStr(a["display_id"]), aID, "Alpha"} {
|
|
get("admin_users", "admin", M{"search": term})
|
|
get("admin_storage_users", "admin", M{"search": term})
|
|
}
|
|
// Numeric public IDs and UUID primary keys are separate and both searchable.
|
|
var persisted model.User
|
|
if err := db.ORM.First(&persisted, "id=?", aID).Error; err != nil || persisted.Nickname != "Admin edited A" || persisted.StorageQuotaMb != 500 {
|
|
t.Fatalf("profile/storage write mismatch: %+v %v", persisted, err)
|
|
}
|
|
post("admin_storage_settings", "admin", M{"free_mb": 200, "month_price_cents": 500, "year_price_cents": 2990})
|
|
exerciseWechatPayment(t, db, app, engine, request, covered, happy, aID, aToken, otherToken)
|
|
exerciseStorageTiers(t, db, app, request, aID, aToken, bToken)
|
|
post("wechat_login", "", M{"code": "mock-wechat-code"})
|
|
// Three separate couples verify all binding entry points without reusing owners.
|
|
d := post("register", "", M{"phone": "13800000004", "password": "testpass4", "nickname": "D"})
|
|
dToken := store.ToStr(d["token"])
|
|
post("invite_by_phone", otherToken, M{"phone": "13800000004"})
|
|
post("accept_share_invite", dToken, M{"inviter_id": outsider["display_id"]})
|
|
foreignDiary := post("diary_add", aToken, M{"title": "Private A", "content": "private", "diary_date": today()})
|
|
foreignID := id(foreignDiary, "id")
|
|
request("diary_detail", "GET", otherToken, M{"id": foreignID}, 404)
|
|
post("diary_update", otherToken, M{"id": foreignID, "title": "intrusion", "content": "intrusion"})
|
|
post("diary_delete", otherToken, M{"id": foreignID})
|
|
var protected model.Diary
|
|
if err := db.ORM.First(&protected, "id=?", foreignID).Error; err != nil || protected.Title != "Private A" || (protected.CreatedBy == nil || *protected.CreatedBy != aID) || protected.CoupleId != cid {
|
|
t.Fatalf("cross-couple write/delete corrupted data: %+v %v", protected, err)
|
|
}
|
|
|
|
e := post("register", "", M{"phone": "13800000005", "password": "testpass5", "nickname": "E"})
|
|
eToken := store.ToStr(e["token"])
|
|
eLink := post("generate_invite_link", eToken, nil)
|
|
get("check_invite_code", "", M{"code": eLink["code"]})
|
|
f := post("register_by_invite", "", M{"phone": "13800000006", "password": "testpass6", "nickname": "F", "invite_code": eLink["code"]})
|
|
_ = f
|
|
// Multipart photo upload stores the correct album UUID and files remain after logical deletion.
|
|
defaultAlbum, _ := db.QueryOne("SELECT id FROM albums WHERE couple_id=? ORDER BY created_at LIMIT 1", cid)
|
|
uploadFile("photo_upload", aToken, "photos", M{"album_id": defaultAlbum.Str("id")})
|
|
uploaded, _ := db.QueryOne("SELECT id,path,created_by FROM photos WHERE album_id=? ORDER BY created_at DESC LIMIT 1", defaultAlbum.Str("id"))
|
|
if uploaded == nil || uploaded.Str("created_by") != aID {
|
|
t.Fatalf("uploaded photo ownership mismatch: %v", uploaded)
|
|
}
|
|
post("admin_delete_content", "admin", M{"type": "photo", "id": uploaded.Str("id"), "couple_id": cid})
|
|
var retainedFile model.File
|
|
if err := db.ORM.First(&retainedFile, "id=?", store.FileIdentifier(uploaded.Str("path"))).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
fileRequest := httptest.NewRequest("GET", store.FileAccessPath(retainedFile.Id), nil)
|
|
fileResponse := httptest.NewRecorder()
|
|
engine.ServeHTTP(fileResponse, fileRequest)
|
|
if fileResponse.Code != 307 {
|
|
t.Fatalf("file access endpoint: %d", fileResponse.Code)
|
|
}
|
|
realClient := &http.Client{Transport: oldTransport, Timeout: 15 * time.Second}
|
|
download, err := realClient.Get(fileResponse.Header().Get("Location"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
download.Body.Close()
|
|
if download.StatusCode != 200 {
|
|
t.Fatalf("shared object download failed: %d", download.StatusCode)
|
|
}
|
|
// Cold-period cancellation must retain the active relationship and inventory.
|
|
post("unbind_request", aToken, nil)
|
|
post("unbind_respond", bToken, M{"agree": true})
|
|
post("unbind_cancel", aToken, nil)
|
|
post("unbind_request", aToken, nil)
|
|
post("unbind_respond", bToken, M{"agree": true})
|
|
if _, err := db.Exec("UPDATE unbind_requests SET cooling_until=NOW() - INTERVAL '1 second' WHERE couple_id=? AND status='cooling'", cid); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
get("unbind_status", aToken, nil)
|
|
post("unbind_couple", aToken, nil)
|
|
post("admin_force_unbind", "admin", M{"couple_id": cid})
|
|
post("admin_delete_couple_data", "admin", M{"couple_id": cid})
|
|
request("admin_restore_couple", "POST", "admin", M{"couple_id": cid}, 400)
|
|
for _, uid := range []string{aID, bID} {
|
|
var user model.User
|
|
db.ORM.First(&user, "id=?", uid)
|
|
if user.CoupleId != nil {
|
|
t.Fatal("unbinding left user attached to old couple")
|
|
}
|
|
}
|
|
wallet, _ = db.QueryOne("SELECT stock FROM smoking_wallets WHERE user_id=?", aID)
|
|
if wallet.Int("stock") != 19 {
|
|
t.Fatal("unbinding changed personal inventory")
|
|
}
|
|
// File management must return deduplicated metadata and every uploader, with admin-only access.
|
|
request("admin_files", "GET", aToken, nil, 401)
|
|
managed := get("admin_files", "admin", M{"page": 1, "page_size": 1, "suffix": "png", "keyword": "integration"})
|
|
fileItems := managed["items"].([]any)
|
|
if len(fileItems) != 1 || store.ToInt(managed["total"]) != 1 {
|
|
t.Fatalf("file management pagination/filter mismatch: %v", managed)
|
|
}
|
|
managedFile := fileItems[0].(M)
|
|
if managedFile["id"] != fileId || managedFile["content_type"] != "image/png" || store.ToInt(managedFile["size"]) <= 0 || len(managedFile["uploaders"].([]any)) != 2 {
|
|
t.Fatalf("file metadata/uploaders missing: %v", managedFile)
|
|
}
|
|
if store.ToInt(get("admin_files", "admin", M{"user_id": bID})["total"]) != 1 {
|
|
t.Fatal("file owner filtering failed")
|
|
}
|
|
if store.ToInt(get("admin_files", "admin", M{"keyword": "no-such-file"})["total"]) != 0 {
|
|
t.Fatal("file search returned unrelated rows")
|
|
}
|
|
// Admin deletion of another couple cannot change a third couple's references.
|
|
post("admin_delete_user", "admin", M{"user_id": d["user_id"]})
|
|
var survivor model.User
|
|
if err := db.ORM.First(&survivor, "id=?", e["user_id"]).Error; err != nil || survivor.CoupleId == nil {
|
|
t.Fatalf("deletion affected unrelated couple: %+v %v", survivor, err)
|
|
}
|
|
|
|
// Every registered action must at least exercise its real authentication gate.
|
|
actions := router.Actions()
|
|
sort.Strings(actions)
|
|
for _, action := range actions {
|
|
if covered[action] == 0 {
|
|
token := aToken
|
|
if strings.HasPrefix(action, "admin_") {
|
|
token = "admin"
|
|
}
|
|
request(action, "GET", token, shared, 0)
|
|
}
|
|
}
|
|
// Every private route must reject anonymous access before touching user data.
|
|
public := map[string]bool{"register": true, "login": true, "wechat_login": true, "site_info": true, "site_stats": true, "admin_login": true, "check_invite_code": true, "register_by_invite": true, "check_auth": true, "wechat_pay_notify": true}
|
|
for _, action := range router.Actions() {
|
|
if !public[action] {
|
|
request(action, "GET", "", nil, 401)
|
|
}
|
|
}
|
|
|
|
var remaining []string
|
|
for _, action := range actions {
|
|
if !happy[action] {
|
|
remaining = append(remaining, action)
|
|
}
|
|
}
|
|
if len(covered) != len(actions) || len(remaining) != 1 || remaining[0] != "admin_restore_couple" {
|
|
t.Fatalf("incomplete API coverage: %v", remaining)
|
|
}
|
|
t.Logf("registered=%d reached=%d successful=%d intentional_business_rejection=%v", len(actions), len(covered), len(happy), remaining)
|
|
}
|
|
|
|
func normalizeAPIMap(m M) M {
|
|
out := M{}
|
|
re := regexp.MustCompile(`[A-Z]`)
|
|
for key, value := range m {
|
|
key = re.ReplaceAllStringFunc(key, func(c string) string { return "_" + strings.ToLower(c) })
|
|
switch v := value.(type) {
|
|
case map[string]any:
|
|
value = normalizeAPIMap(M(v))
|
|
case []any:
|
|
for i, item := range v {
|
|
if child, ok := item.(map[string]any); ok {
|
|
v[i] = normalizeAPIMap(M(child))
|
|
}
|
|
}
|
|
value = v
|
|
}
|
|
out[key] = value
|
|
}
|
|
return out
|
|
}
|