Files
ballet-server/backend/internal/httpapi/practice_test.go
T
yuxuanhui 6224ef5980 feat: add growth tracking page and related functionality
- Implemented a new Growth page to track practice time and trends.
- Added API integration for fetching review data.
- Created components for displaying practice statistics and trends.
- Updated navigation titles for the main index and growth pages.
- Removed unused styles from the index page.
- Introduced a Projects management page for adding and editing practice projects.
- Developed a Record form for logging practice sessions with validation.
- Added utility functions for date manipulation and duration formatting.
- Implemented error handling and session management in the practice service.
- Created unit tests for the practice service to ensure reliability.
2026-09-29 13:47:26 +08:00

398 lines
15 KiB
Go
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}