2026-09-29 13:47:26 +08:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-29 18:40:25 +08:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-29 13:47:26 +08:00
|
|
|
|
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, "课后练习")
|
2026-09-29 18:40:25 +08:00
|
|
|
|
if got := h.summary(token, 0, 0)["allTime"].(map[string]any)["firstDate"]; got != "" {
|
|
|
|
|
|
t.Fatalf("empty history has first date %v", got)
|
|
|
|
|
|
}
|
2026-09-29 13:47:26 +08:00
|
|
|
|
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)
|
2026-09-29 18:40:25 +08:00
|
|
|
|
if got := review["allTime"].(map[string]any)["firstDate"]; got != "2026-09-27" {
|
|
|
|
|
|
t.Fatalf("earliest practice date after correction: %v", got)
|
|
|
|
|
|
}
|
2026-09-29 13:47:26 +08:00
|
|
|
|
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)
|
2026-09-29 18:40:25 +08:00
|
|
|
|
if got := h.summary(token, 0, 0)["allTime"].(map[string]any)["firstDate"]; got != "" {
|
|
|
|
|
|
t.Fatalf("deleted history retained first date %v", got)
|
|
|
|
|
|
}
|
2026-09-29 13:47:26 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
}
|