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.
This commit is contained in:
@@ -0,0 +1,397 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user