package server import ( "bytes" "context" "encoding/json" "encoding/xml" "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" "io" "net/http/httptest" "os" "perler-beads-server/internal/domain" "strconv" "strings" "testing" "time" ) func TestRequestCancellationFallback(t *testing.T) { r := (&Server{}).Router() r.GET("/cancel-probe", func(c *gin.Context) { if c.Err() != context.Canceled { t.Error("Gin must propagate request cancellation") } if _, yes := c.Deadline(); !yes { t.Error("request operation deadline missing") } }) ctx, cancel := context.WithCancel(context.Background()) cancel() r.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/cancel-probe", nil).WithContext(ctx)) } func TestPrintGeometryBounds(t *testing.T) { for _, pitch := range []float64{2, 5, 10} { code := "R" p := domain.Pattern{Title: strings.Repeat("标题", 45), Width: 1, Height: 1, Cells: []*string{&code}, Colors: []domain.Color{{Code: code, Hex: "#ff0000"}}} var b bytes.Buffer renderSVG(&b, p, pitch) d := xml.NewDecoder(&b) width, height := 0.0, 0.0 gridFound := false for { token, e := d.Token() if e == io.EOF { break } if e != nil { t.Fatal(e) } el, yes := token.(xml.StartElement) if !yes { continue } attrs := map[string]string{} for _, a := range el.Attr { attrs[a.Name.Local] = a.Value } if el.Name.Local == "svg" { width, _ = strconv.ParseFloat(strings.TrimSuffix(attrs["width"], "mm"), 64) height, _ = strconv.ParseFloat(strings.TrimSuffix(attrs["height"], "mm"), 64) if width < 75 { t.Fatal("ruler exceeds page") } } if el.Name.Local == "text" { x, _ := strconv.ParseFloat(attrs["x"], 64) y, _ := strconv.ParseFloat(attrs["y"], 64) if x >= width || y >= height { t.Fatalf("text outside page: %+v", attrs) } } if el.Name.Local == "rect" && attrs["stroke"] == "#666" { w, _ := strconv.ParseFloat(attrs["width"], 64) h, _ := strconv.ParseFloat(attrs["height"], 64) if w != pitch || h != pitch { t.Fatal("actual bead pitch changed") } gridFound = true } } if !gridFound { t.Fatal("no grid") } } } func TestSingleConnectionHandlers(t *testing.T) { dsn := os.Getenv("TEST_DATABASE_URL") if dsn == "" { t.Skip("TEST_DATABASE_URL absent; PostgreSQL handler regression not run") } cfg, e := pgxpool.ParseConfig(dsn) if e != nil { t.Fatal(e) } if !strings.Contains(cfg.ConnConfig.Database, "test") { t.Fatal("requires independent test database") } cfg.MaxConns = 1 ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) defer cancel() db, e := pgxpool.NewWithConfig(ctx, cfg) if e != nil { t.Fatal(e) } defer db.Close() s := &Server{DB: db} if e = Migrate(ctx, db); e != nil { t.Fatal(e) } id := uuid.NewString() store := "regression-" + id u := domain.User{ID: id, Role: "guest", StoreID: store} p := demoPatterns(store, demoColors())[0] p.ID = "pattern-" + id p.OwnerID = id p.Visibility = "private" if e = s.put(ctx, "pattern", p.ID, store, id, p); e != nil { t.Fatal(e) } if e = s.put(ctx, "tables", store, store, "", []domain.Table{{Code: "T1", Label: "Test", Enabled: true}}); e != nil { t.Fatal(e) } v, _ := json.Marshal(domain.Visit{ID: id, StoreID: store, TableCode: "T1", Status: "active"}) if _, e = db.Exec(ctx, `INSERT INTO visits VALUES($1,$2)`, id, v); e != nil { t.Fatal(e) } if _, e = db.Exec(ctx, `INSERT INTO favorites VALUES($1,$2)`, id, p.ID); e != nil { t.Fatal(e) } defer func() { cleanup, stop := context.WithTimeout(context.Background(), 5*time.Second) defer stop() db.Exec(cleanup, `DELETE FROM favorites WHERE user_id=$1`, id) db.Exec(cleanup, `DELETE FROM visits WHERE user_id=$1`, id) db.Exec(cleanup, `DELETE FROM request_keys WHERE user_id=$1`, id) db.Exec(cleanup, `DELETE FROM documents WHERE store_id=$1`, store) }() r := gin.New() r.ContextWithFallback = true r.Use(func(c *gin.Context) { c.Set("user", u); c.Next() }) r.GET("/favorites", s.favoriteList) r.POST("/requests", s.requestCreate) body := `{"kind":"print","patternId":"` + p.ID + `","requestId":"` + uuid.NewString() + `"}` for _, call := range []struct{ method, path, body string }{{"GET", "/favorites", ""}, {"POST", "/requests", body}, {"POST", "/requests", body}} { w := httptest.NewRecorder() request := httptest.NewRequest(call.method, call.path, strings.NewReader(call.body)).WithContext(ctx) request.Header.Set("Content-Type", "application/json") r.ServeHTTP(w, request) var result struct{ Code int } if json.Unmarshal(w.Body.Bytes(), &result) != nil || result.Code != 200 { t.Fatalf("%s %s", call.path, w.Body) } if ctx.Err() != nil { t.Fatal("single pool connection handler exceeded deadline") } } } func TestWebUIStaticAndSPAFallback(t *testing.T) { dir := t.TempDir() if e := os.WriteFile(dir+"/index.html", []byte("SPA"), 0600); e != nil { t.Fatal(e) } if e := os.WriteFile(dir+"/app.js", []byte("console.log('static')"), 0600); e != nil { t.Fatal(e) } outside := t.TempDir() os.WriteFile(outside+"/secret.txt", []byte("SECRET"), 0600) if e := os.Symlink(outside+"/secret.txt", dir+"/leak.txt"); e != nil { t.Fatal(e) } r := (&Server{WebUIDir: dir}).Router() for _, item := range []struct{ path, want string }{{"/patterns/editor", "SPA"}, {"/app.js", "static"}, {"/api/unknown", `"code":404`}, {"/api", `"code":404`}, {"/leak.txt", `"code":404`}, {"/../secret.txt", `"code":404`}} { w := httptest.NewRecorder() r.ServeHTTP(w, httptest.NewRequest("GET", item.path, nil)) if !strings.Contains(w.Body.String(), item.want) || strings.Contains(w.Body.String(), "SECRET") { t.Fatalf("%s: %s", item.path, w.Body) } } } func TestRecoveryUsesEnvelope(t *testing.T) { r := (&Server{}).Router() r.GET("/panic-probe", func(c *gin.Context) { panic("test panic") }) w := httptest.NewRecorder() r.ServeHTTP(w, httptest.NewRequest("GET", "/panic-probe", nil)) if w.Code != 200 || !strings.Contains(w.Body.String(), `"code":500`) { t.Fatalf("%d %s", w.Code, w.Body) } }