Files
perler-beads-server/internal/server/regression_test.go
T

191 lines
6.0 KiB
Go

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("<html>SPA</html>"), 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)
}
}