Files
ballet-server/backend/internal/httpapi/practice_test.go
T
yuxuanhui b0c62d461e feat: add project icon picker component and integrate with project management
- Implemented ProjectIconPicker component for selecting project icons.
- Created ProjectIcon component to display selected icons.
- Updated calendar page to remove unused PageHeading component.
- Modified growth page to enhance UI and improve text clarity.
- Refactored index page to include project icons and improve layout.
- Added mine page for user profile management with avatar selection.
- Introduced projects-sort page for managing project order with drag-and-drop functionality.
- Enhanced projects page to allow icon selection when adding or editing projects.
- Updated record page to streamline user experience and improve form actions.
- Adjusted practice service to support project icon management.
- Added tests for project saving functionality to ensure correct handling of icons.
- Updated global type definitions to support SVG and PNG imports.
2026-09-29 18:40:25 +08:00

557 lines
22 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 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)
}