Files
past-go/past-go/internal/handler/api_postgres_test.go
T
Blizzard 4c9708b562 feat: add WeChat Pay, Redis logging, storage tiers and personal albums
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.
2026-11-02 23:40:49 +08:00

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
}