package httpapi_test import ( "context" "encoding/json" "fmt" "io" "net/http" "net/http/httptest" "os" "strings" "sync" "testing" "time" "ballet-island/backend/internal/database" "ballet-island/backend/internal/httpapi" "ballet-island/backend/internal/identity" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) type testIdentity struct{} func (testIdentity) Exchange(_ context.Context, code string) (string, error) { return "test-app:" + code, nil } type harness struct { t *testing.T h http.Handler now time.Time pool *pgxpool.Pool } func setup(t *testing.T) *harness { t.Helper() url := os.Getenv("TEST_DATABASE_URL") if url == "" { if os.Getenv("REQUIRE_TEST_DATABASE") == "1" { t.Fatal("TEST_DATABASE_URL is required") } t.Skip("PostgreSQL integration: set TEST_DATABASE_URL or run scripts/test-integration.sh") } ctx := context.Background() admin, err := pgxpool.New(ctx, url) if err != nil { t.Fatal(err) } t.Cleanup(admin.Close) schema := fmt.Sprintf("practice_test_%d", time.Now().UnixNano()) quoted := pgx.Identifier{schema}.Sanitize() if _, err = admin.Exec(ctx, "CREATE SCHEMA "+quoted); err != nil { t.Fatal(err) } t.Cleanup(func() { _, _ = admin.Exec(ctx, "DROP SCHEMA "+quoted+" CASCADE") }) config, err := pgxpool.ParseConfig(url) if err != nil { t.Fatal(err) } config.ConnConfig.RuntimeParams["search_path"] = schema pool, err := pgxpool.NewWithConfig(ctx, config) if err != nil { t.Fatal(err) } t.Cleanup(pool.Close) if err := database.Migrate(ctx, pool); err != nil { t.Fatal(err) } // The same migration path is safe on every service restart. if err := database.Migrate(ctx, pool); err != nil { t.Fatal(err) } h := &harness{t: t, pool: pool, now: time.Date(2026, 9, 28, 2, 0, 0, 0, time.UTC)} h.h = httpapi.NewAppHandler(pool, testIdentity{}, func() time.Time { return h.now }) return h } type roundTripFunc func(*http.Request) (*http.Response, error) func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } func TestWeChatExchangeAndSessionExpiry(t *testing.T) { h := setup(t) verifier := &identity.WeChat{AppID: "test-app", AppSecret: "server-only", Client: &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.URL.Scheme != "https" || r.URL.Host != "api.weixin.qq.com" || r.URL.Query().Get("secret") != "server-only" || r.URL.Query().Get("grant_type") != "authorization_code" { t.Fatal("invalid identity exchange") } body := `{"openid":"verified-alice","session_key":"never-return-this"}` if r.URL.Query().Get("js_code") == "invalid" { body = `{"errcode":40029,"errmsg":"invalid code"}` } if r.URL.Query().Get("js_code") == "upstream-down" { body = `{"errcode":-1,"errmsg":"system error"}` } return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body))}, nil })}} h.h = httpapi.NewAppHandler(h.pool, verifier, func() time.Time { return h.now }) h.request("POST", "/v1/session", "", `{"code":"invalid"}`, 401) h.request("POST", "/v1/session", "", `{"code":"upstream-down"}`, 503) session := h.request("POST", "/v1/session", "", `{"code":"valid"}`, 200) serialized, _ := json.Marshal(session) if strings.Contains(string(serialized), "verified-alice") || strings.Contains(string(serialized), "never-return-this") { t.Fatal("private identity details leaked") } token := session["token"].(string) h.request("GET", "/v1/projects", token, "", 200) h.now = h.now.Add(31 * 24 * time.Hour) h.request("GET", "/v1/projects", token, "", 401) h.request("GET", "/v1/projects", h.login("valid"), "", 200) } func (h *harness) request(method, path, token, body string, want int) map[string]any { h.t.Helper() r := httptest.NewRequest(method, path, strings.NewReader(body)) r.Header.Set("Content-Type", "application/json") if token != "" { r.Header.Set("Authorization", "Bearer "+token) } w := httptest.NewRecorder() h.h.ServeHTTP(w, r) if w.Code != want { h.t.Fatalf("%s %s: got %d %s, want %d", method, path, w.Code, w.Body.String(), want) } result := map[string]any{} if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil { h.t.Fatal(err) } return result } func (h *harness) login(code string) string { h.t.Helper() return h.request("POST", "/v1/session", "", `{"code":"`+code+`"}`, 200)["token"].(string) } func TestFirstLoginSeedsOnceAndSessionsRestoreIdentity(t *testing.T) { h := setup(t) token := h.login("alice") projects := h.request("GET", "/v1/projects", token, "", 200)["projects"].([]any) if len(projects) != 8 { t.Fatalf("got %d projects", len(projects)) } first := projects[0].(map[string]any) if first["name"] != "零基础" { t.Fatal(first) } h.request("DELETE", "/v1/projects/"+first["id"].(string), token, "", 200) projects = h.request("GET", "/v1/projects", h.login("alice"), "", 200)["projects"].([]any) if len(projects) != 7 { t.Fatal("login must not restore removed presets") } h.request("GET", "/v1/projects", "", "", 401) h.request("GET", "/v1/projects", "invented-session", "", 401) } func (h *harness) project(token, name string) string { h.t.Helper() body, _ := json.Marshal(map[string]string{"name": name}) return h.request("POST", "/v1/projects", token, string(body), 200)["id"].(string) } func TestProjectOrderPersistsAndRejectsStaleOrForeignLists(t *testing.T) { h := setup(t) alice, bob := h.login("alice"), h.login("bob") projectIDs := func(token string) []string { h.t.Helper() projects := h.request("GET", "/v1/projects", token, "", 200)["projects"].([]any) ids := make([]string, len(projects)) for i, raw := range projects { ids[i] = raw.(map[string]any)["id"].(string) } return ids } setOrder := func(ids []string, status int) { h.t.Helper() body, _ := json.Marshal(map[string][]string{"projectIds": ids}) h.request("PUT", "/v1/projects/order", alice, string(body), status) } initial := projectIDs(alice) want := append([]string(nil), initial...) for i, j := 0, len(want)-1; i < j; i, j = i+1, j-1 { want[i], want[j] = want[j], want[i] } setOrder(want, 200) h.h = httpapi.NewAppHandler(h.pool, testIdentity{}, func() time.Time { return h.now }) if got := projectIDs(h.login("alice")); strings.Join(got, ",") != strings.Join(want, ",") { t.Fatalf("order did not persist: %v", got) } newID := h.project(alice, "新项目") want = append(want, newID) if got := projectIDs(alice); strings.Join(got, ",") != strings.Join(want, ",") { t.Fatalf("new project did not append: %v", got) } duplicate := append([]string(nil), want...) duplicate[0] = duplicate[1] setOrder(duplicate, 400) setOrder(want[:len(want)-1], 400) foreign := append([]string(nil), want...) foreign[0] = projectIDs(bob)[0] setOrder(foreign, 400) if got := projectIDs(alice); strings.Join(got, ",") != strings.Join(want, ",") { t.Fatalf("invalid order changed projects: %v", got) } h.request("POST", "/v1/records", alice, recordBody(want[0], "2026-09-28", 15, "", "sort-archive-0001"), 200) h.request("DELETE", "/v1/projects/"+want[0], alice, "", 200) setOrder(want, 400) active := projectIDs(alice) if strings.Join(active, ",") != strings.Join(want[1:], ",") { t.Fatalf("archived project stayed active: %v", active) } for i, j := 0, len(active)-1; i < j; i, j = i+1, j-1 { active[i], active[j] = active[j], active[i] } setOrder(active, 200) if got := projectIDs(alice); strings.Join(got, ",") != strings.Join(active, ",") { t.Fatalf("reorder after archive failed: %v", got) } } func TestProjectIconsPersistClearAndRespectOwnership(t *testing.T) { h := setup(t) alice, bob := h.login("alice"), h.login("bob") presets := h.request("GET", "/v1/projects", alice, "", 200)["projects"].([]any) want := []string{"plie", "tendu", "flexibility", "foot-hip", "core-glutes", "ball-core", "swan-arms", "breathing"} for i, raw := range presets { if raw.(map[string]any)["iconId"] != want[i] { t.Fatalf("preset %d: %v", i, raw) } } p := h.request("POST", "/v1/projects", alice, `{"name":"自选练习","iconId":"ball-core"}`, 200) id := p["id"].(string) if p["iconId"] != "ball-core" { t.Fatal(p) } // Older clients rename without sending iconId. This must not clear artwork. p = h.request("PUT", "/v1/projects/"+id, alice, `{"name":"晚间练习"}`, 200) if p["iconId"] != "ball-core" { t.Fatal("rename cleared the icon") } for _, icon := range []string{"plie", "tendu", "releve", "port-de-bras", "arabesque", "attitude", "pirouette", "grand-jete", "foot-hip", "flexibility", "ball-core", "swan-arms", "breathing", "core-glutes", "soft-shoes", "pointe-shoes", "barre", "piano"} { p = h.request("PUT", "/v1/projects/"+id, alice, `{"name":"晚间练习","iconId":"`+icon+`"}`, 200) if p["iconId"] != icon { t.Fatalf("icon did not round trip: %s", icon) } } h.request("PUT", "/v1/projects/"+id, bob, `{"name":"偷改","iconId":"breathing"}`, 404) h.request("PUT", "/v1/projects/"+id, alice, `{"name":"不应保存","iconId":"unknown"}`, 400) h.request("POST", "/v1/projects", alice, `{"name":"拒绝外部图片","iconId":"https://example.com/a.svg"}`, 400) h.request("POST", "/v1/projects", alice, `{"name":"拒绝对象","iconId":{}}`, 400) // A fresh handler and login must read the last confirmed value from the database. h.h = httpapi.NewAppHandler(h.pool, testIdentity{}, func() time.Time { return h.now }) find := func(token string) map[string]any { for _, raw := range h.request("GET", "/v1/projects?includeArchived=true", token, "", 200)["projects"].([]any) { project := raw.(map[string]any) if project["id"] == id { return project } } t.Fatal("saved project missing") return nil } p = find(h.login("alice")) if p["name"] != "晚间练习" || p["iconId"] != "piano" { t.Fatal("rejected write changed persisted project", p) } h.request("POST", "/v1/records", alice, recordBody(id, "2026-09-28", 15, "", "icon-archive-0001"), 200) h.request("DELETE", "/v1/projects/"+id, alice, "", 200) if p = find(alice); p["archived"] != true || p["iconId"] != "piano" { t.Fatal("archiving lost artwork", p) } // Clearing a preset stays cleared after login and a service migration restart. presetID := presets[0].(map[string]any)["id"].(string) p = h.request("PUT", "/v1/projects/"+presetID, alice, `{"name":"零基础","iconId":""}`, 200) if p["iconId"] != "" { t.Fatal("explicit clear failed", p) } if err := database.Migrate(context.Background(), h.pool); err != nil { t.Fatal(err) } projects := h.request("GET", "/v1/projects", h.login("alice"), "", 200)["projects"].([]any) if projects[0].(map[string]any)["iconId"] != "" { t.Fatal("restart restored a cleared icon") } } func TestProjectIconMigrationPreservesExistingProjectsAndRecords(t *testing.T) { h := setup(t) token := h.login("alice") id := h.project(token, "自定义旧项目") h.request("POST", "/v1/records", token, recordBody(id, "2026-09-28", 30, "已有笔记", "icon-migrate-001"), 200) // Recreate the version-1 shape only inside this test's isolated schema. ctx := context.Background() if _, err := h.pool.Exec(ctx, "ALTER TABLE projects DROP COLUMN icon_id; DELETE FROM schema_migrations WHERE version=2"); err != nil { t.Fatal(err) } if err := database.Migrate(ctx, h.pool); err != nil { t.Fatal(err) } projects := h.request("GET", "/v1/projects", token, "", 200)["projects"].([]any) if len(projects) != 9 || projects[0].(map[string]any)["iconId"] != "plie" { t.Fatal("existing presets were not migrated", projects) } custom := projects[8].(map[string]any) if custom["id"] != id || custom["iconId"] != "" || custom["name"] != "自定义旧项目" { t.Fatal("migration changed custom project", custom) } records := h.request("GET", "/v1/records", token, "", 200)["records"].([]any) if len(records) != 1 || records[0].(map[string]any)["note"] != "已有笔记" { t.Fatal("migration changed existing history", records) } h.summary(token, 30, 1) } func recordBody(project, date string, minutes int, note, key string) string { body, _ := json.Marshal(map[string]any{"projectId": project, "date": date, "minutes": minutes, "note": note, "requestId": key}) return string(body) } func (h *harness) summary(token string, minutes, days float64) map[string]any { h.t.Helper() review := h.request("GET", "/v1/review?period=week&date=2026-09-28", token, "", 200) total := review["allTime"].(map[string]any) if total["totalMinutes"] != minutes || total["practiceDays"] != days { h.t.Fatalf("unexpected total: %v", total) } var projectMinutes float64 for _, raw := range total["projects"].([]any) { projectMinutes += raw.(map[string]any)["minutes"].(float64) } if projectMinutes != minutes { h.t.Fatalf("project distribution: %v", total) } return review } func TestRecordsRoundTripCorrectionsAndStatistics(t *testing.T) { h := setup(t) token := h.login("alice") class := h.project(token, "晚课") extra := h.project(token, "课后练习") if got := h.summary(token, 0, 0)["allTime"].(map[string]any)["firstDate"]; got != "" { t.Fatalf("empty history has first date %v", got) } one := h.request("POST", "/v1/records", token, recordBody(class, "2026-09-28", 90, "老师反馈:放松肩颈", "lesson-000000001"), 200) two := h.request("POST", "/v1/records", token, recordBody(extra, "2026-09-28", 15, "", "lesson-000000002"), 200) h.summary(token, 105, 1) list := h.request("GET", "/v1/records?from=2026-09-28&to=2026-09-28", token, "", 200)["records"].([]any) if len(list) != 2 { t.Fatal(list) } id1, id2 := one["id"].(string), two["id"].(string) h.request("PUT", "/v1/records/"+id1, token, recordBody(class, "2026-09-27", 90, "改到周日", ""), 200) review := h.summary(token, 105, 2) if got := review["allTime"].(map[string]any)["firstDate"]; got != "2026-09-27" { t.Fatalf("earliest practice date after correction: %v", got) } if review["period"].(map[string]any)["totalMinutes"] != float64(15) { t.Fatal("Sunday must belong to the previous week") } h.request("PUT", "/v1/records/"+id1, token, recordBody(class, "2026-09-28", 60, "", ""), 200) h.summary(token, 75, 1) h.request("DELETE", "/v1/records/"+id2, token, "", 200) h.summary(token, 60, 1) h.request("DELETE", "/v1/records/"+id1, token, "", 200) h.request("DELETE", "/v1/records/"+id1, token, "", 200) if got := h.summary(token, 0, 0)["allTime"].(map[string]any)["firstDate"]; got != "" { t.Fatalf("deleted history retained first date %v", got) } } func TestArchiveRenameAndOwnershipAcrossAccounts(t *testing.T) { h := setup(t) alice, bob := h.login("alice"), h.login("bob") p := h.project(alice, "旧课名") active := h.project(alice, "新练习") r := h.request("POST", "/v1/records", alice, recordBody(p, "2026-09-20", 45, "私人笔记", "archive-000000001"), 200) id := r["id"].(string) h.request("GET", "/v1/records/"+id, bob, "", 404) h.request("PUT", "/v1/records/"+id, bob, recordBody(p, "2026-09-20", 60, "偷改", ""), 404) h.request("DELETE", "/v1/records/"+id, bob, "", 200) h.request("PUT", "/v1/projects/"+p, bob, `{"name":"偷改"}`, 404) h.request("DELETE", "/v1/projects/"+p, bob, "", 404) h.request("POST", "/v1/records", bob, recordBody(p, "2026-09-20", 60, "", "cross-user-000001"), 404) h.summary(bob, 0, 0) h.request("PUT", "/v1/projects/"+p, alice, `{"name":"基础晚课"}`, 200) removed := h.request("DELETE", "/v1/projects/"+p, alice, "", 200) if removed["action"] != "archived" { t.Fatal(removed) } record := h.request("GET", "/v1/records/"+id, alice, "", 200) if record["projectName"] != "基础晚课" || record["note"] != "私人笔记" || record["archived"] != true { t.Fatal(record) } for _, p0 := range h.request("GET", "/v1/projects", alice, "", 200)["projects"].([]any) { if p0.(map[string]any)["id"] == p { t.Fatal("archived project in active choices") } } summary := h.summary(alice, 45, 1)["allTime"].(map[string]any) if summary["projects"].([]any)[0].(map[string]any)["name"] != "基础晚课" { t.Fatal(summary) } h.request("POST", "/v1/records", alice, recordBody(p, "2026-09-20", 30, "", "archive-000000002"), 409) h.request("PUT", "/v1/records/"+id, alice, recordBody(p, "2026-09-21", 30, "仍保留归档项目", ""), 200) h.request("PUT", "/v1/records/"+id, alice, recordBody(active, "2026-09-21", 30, "改入活跃项目", ""), 200) h.request("PUT", "/v1/records/"+id, alice, recordBody(p, "2026-09-21", 30, "不能移回归档项目", ""), 409) // A fresh handler models a second process/device reading the same persisted account. h.h = httpapi.NewAppHandler(h.pool, testIdentity{}, func() time.Time { return h.now }) h.summary(h.login("alice"), 30, 1) } func TestSubmissionRetriesAreIdempotentAndCannotResurrectDeletedRecords(t *testing.T) { h := setup(t) token := h.login("alice") p := h.project(token, "课程") body := recordBody(p, "2026-09-28", 45, "", "retry-0000000001") first := h.request("POST", "/v1/records", token, body, 200) retry := h.request("POST", "/v1/records", h.login("alice"), body, 200) if first["id"] != retry["id"] { t.Fatal("response-loss retry duplicated the record") } h.request("POST", "/v1/records", token, recordBody(p, "2026-09-28", 46, "", "retry-0000000001"), 409) h.summary(token, 45, 1) h.request("POST", "/v1/records", token, recordBody(p, "2026-09-28", 45, "", "retry-0000000002"), 200) h.summary(token, 90, 1) h.request("DELETE", "/v1/records/"+first["id"].(string), token, "", 200) h.request("POST", "/v1/records", token, body, 410) h.summary(token, 45, 1) } func TestInvalidFormsNeverPersistAndDateBoundariesUseShanghai(t *testing.T) { h := setup(t) h.now = time.Date(2026, 9, 27, 16, 1, 0, 0, time.UTC) // already Monday in Shanghai token := h.login("alice") p := h.project(token, "课程") for i, entry := range []struct { date string minutes int note string }{ {"2026-09-28", 0, ""}, {"2026-09-28", -1, ""}, {"2026-09-29", 30, ""}, {"2026-02-30", 30, ""}, {"2026-9-1", 30, ""}, {"0000-01-01", 30, ""}, {"2026-09-28", 30, strings.Repeat("字", 2001)}, {"2026-09-28", 30, "bad\x00note"}, } { h.request("POST", "/v1/records", token, recordBody(p, entry.date, entry.minutes, entry.note, fmt.Sprintf("invalid-%016d", i)), 400) } h.request("POST", "/v1/records", token, `{"projectId":"`+p+`","date":"2026-09-28","minutes":1.5,"requestId":"invalid-decimal1"}`, 400) h.request("POST", "/v1/records", token, recordBody("", "2026-09-28", 30, "", "missing-00000001"), 400) h.request("POST", "/v1/projects", token, `{"name":"  \n "}`, 400) longName, _ := json.Marshal(map[string]string{"name": strings.Repeat("字", 41)}) h.request("POST", "/v1/projects", token, string(longName), 400) h.summary(token, 0, 0) h.request("POST", "/v1/records", token, recordBody(p, "2026-08-31", 45, "", "boundary-0000001"), 200) h.request("POST", "/v1/records", token, recordBody(p, "2026-09-01", 30, "", "boundary-0000002"), 200) h.summary(token, 75, 2) aug := h.request("GET", "/v1/review?period=month&date=2026-08-31", token, "", 200) sep := h.request("GET", "/v1/review?period=month&date=2026-09-28", token, "", 200) if aug["period"].(map[string]any)["totalMinutes"] != float64(45) || sep["period"].(map[string]any)["totalMinutes"] != float64(30) || len(sep["days"].([]any)) != 30 { t.Fatal(aug, sep) } h.request("POST", "/v1/records", token, recordBody(p, "2026-09-28", 1, "", "boundary-0000003"), 200) } func TestRecordPaginationReturnsEveryEntryOnce(t *testing.T) { h := setup(t) token := h.login("alice") p := h.project(token, "课程") for i := 0; i < 5; i++ { h.request("POST", "/v1/records", token, recordBody(p, "2026-09-28", i+1, "", fmt.Sprintf("page-%016d", i)), 200) } latest := h.request("POST", "/v1/records", token, recordBody(p, "2026-08-01", 45, "补记也应出现在近期记录顶部", "page-backdated-1"), 200) recent := h.request("GET", "/v1/records?limit=1", token, "", 200)["records"].([]any) if recent[0].(map[string]any)["id"] != latest["id"] { t.Fatal("a saved backdated entry must appear in recent records") } seen := map[string]bool{} cursor := "" for { page := h.request("GET", "/v1/records?from=2026-09-28&to=2026-09-28&limit=2&cursor="+cursor, token, "", 200) for _, raw := range page["records"].([]any) { id := raw.(map[string]any)["id"].(string) if seen[id] { t.Fatal("duplicate entry") } seen[id] = true } cursor = page["nextCursor"].(string) if cursor == "" { break } } if len(seen) != 5 { t.Fatal(seen) } } func TestConcurrentRetriesAndProjectRemovalKeepHistory(t *testing.T) { h := setup(t) token := h.login("alice") p := h.project(token, "课程") body := recordBody(p, "2026-09-28", 90, "", "concurrent-00001") responses := make(chan *httptest.ResponseRecorder, 8) var wg sync.WaitGroup for i := 0; i < 8; i++ { wg.Go(func() { r := httptest.NewRequest("POST", "/v1/records", strings.NewReader(body)) r.Header.Set("Authorization", "Bearer "+token) w := httptest.NewRecorder() h.h.ServeHTTP(w, r) responses <- w }) } wg.Wait() close(responses) for w := range responses { if w.Code != 200 { t.Fatalf("concurrent save: %d %s", w.Code, w.Body.String()) } } h.summary(token, 90, 1) // Removing a project and creating a record race for the same user. Whichever // arrives first, the original history survives and totals remain exact. var create, remove *httptest.ResponseRecorder wg.Go(func() { r := httptest.NewRequest("POST", "/v1/records", strings.NewReader(recordBody(p, "2026-09-28", 15, "", "concurrent-00002"))) r.Header.Set("Authorization", "Bearer "+token) create = httptest.NewRecorder() h.h.ServeHTTP(create, r) }) wg.Go(func() { r := httptest.NewRequest("DELETE", "/v1/projects/"+p, nil) r.Header.Set("Authorization", "Bearer "+token) remove = httptest.NewRecorder() h.h.ServeHTTP(remove, r) }) wg.Wait() if remove.Code != 200 || (create.Code != 200 && create.Code != 409) { t.Fatal(remove.Code, create.Code) } want := float64(90) if create.Code == 200 { want = 105 } h.summary(token, want, 1) } func TestDatabaseFailureIsNotAnEmptyHistory(t *testing.T) { h := setup(t) token := h.login("alice") h.pool.Close() h.request("GET", "/v1/records", token, "", 503) h.request("GET", "/v1/review?period=week", token, "", 503) h.request("GET", "/healthz", "", "", 200) h.request("GET", "/readyz", "", "", 503) }