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 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, "课后练习") h.summary(token, 0, 0) 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 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) h.summary(token, 0, 0) } 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) }