191 lines
6.0 KiB
Go
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)
|
|
}
|
|
}
|