Files
ballet-server/backend/internal/httpapi/practice_test.go
T

557 lines
22 KiB
Go
Raw Normal View History

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)
}