Files
summercms/modules/cabana/payments_smoke_test.go
Jakub Zych 20385e53ee feat(18-01): add the cabana money field (task 1)
- type: money on fields.yaml with currency or currencyFrom, scale (2..4,
  default 4) and max; boot fails naming the field on two sources, none,
  a bad scale or max, a column that is not a string or a Scanner/Valuer,
  or a currencyFrom that is not a string column
- a save answers 422 money.invalid, money.too_precise or money.too_large
  on the field and fills the accepted decimal string unchanged; the same
  check runs on controller, settings and relation child saves
- backend::lang money.* phrases in en and pl
- admin SPA: money.ts (parseMoneyInput, trimMoneyDisplay, formatMoney over
  Intl with the string itself), MoneyField.vue with the currency suffix,
  blur validation and the read-only box, preview through the control,
  the field scope on PreviewView; regenerated OpenAPI and TS types,
  rebuilt modules/boardwalk/dist
- acme.payments fixture with TestMoneyFieldCompile and the Docker
  TestMoneyFieldSmoke; forms.md Money section and cabana README
2026-10-13 11:15:05 +02:00

404 lines
14 KiB
Go

package cabana_test
import (
"database/sql/driver"
"encoding/json"
"fmt"
"io/fs"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"testing/fstest"
"time"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/compass"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
"git.golem15.com/golem15/summercms/modules/phrasebook"
"git.golem15.com/golem15/summercms/modules/surf"
"gorm.io/gorm"
)
// paymentsDir is the neutral acme.payments fixture plugin tree of the Phase
// 18 cabana features: the exact-decimal money field and column. No
// application names appear in framework tests.
const paymentsDir = "testdata/payments"
// rawAmount is the fixture's amount column type: a sql.Scanner and
// driver.Valuer that keeps the decimal string exactly as the database or the
// save handed it, so a test can assert that cabana never rewrote it.
type rawAmount struct {
text string
valid bool
}
// Scan keeps the text of a numeric column; a float would mean the driver
// rounded the value, so it is refused.
func (a *rawAmount) Scan(src any) error {
switch v := src.(type) {
case nil:
*a = rawAmount{}
case string:
*a = rawAmount{text: v, valid: true}
case []byte:
*a = rawAmount{text: string(v), valid: true}
default:
return fmt.Errorf("rawAmount: cannot scan %T", src)
}
return nil
}
// Value writes the text back unchanged, or NULL.
func (a rawAmount) Value() (driver.Value, error) {
if !a.valid {
return nil, nil
}
return a.text, nil
}
// MarshalJSON serves the stored text as a JSON string, or null.
func (a rawAmount) MarshalJSON() ([]byte, error) {
if !a.valid {
return []byte("null"), nil
}
return json.Marshal(a.text)
}
// paymentsInvoice is the fixture model of the invoices controller: a
// currency code column and two money columns, one following the currency
// column and one with a fixed currency.
type paymentsInvoice struct {
ID uint `gorm:"column:id;primaryKey"`
Number string `gorm:"column:number"`
Currency string `gorm:"column:currency"`
Total rawAmount `gorm:"column:total;type:numeric(12,4)"`
Fee rawAmount `gorm:"column:fee;type:numeric(10,4)"`
}
func (paymentsInvoice) TableName() string { return "cabana_payments_invoices" }
func (paymentsInvoice) Fillable() []string {
return []string{"number", "currency", "total", "fee"}
}
func (paymentsInvoice) Rules() map[string]string { return map[string]string{"number": "required"} }
// paymentsModels are the fixture tables, dropped and recreated per env.
func paymentsModels() []any {
return []any{&paymentsInvoice{}}
}
// paymentsPlugin registers the invoices controller.
type paymentsPlugin struct{}
func (paymentsPlugin) ID() string { return "acme.payments" }
func (paymentsPlugin) Requires() []string { return nil }
func (paymentsPlugin) Register(*backpack.App) error { return nil }
func (paymentsPlugin) Boot(*backpack.App) error { return nil }
func (paymentsPlugin) AdminFS() fs.FS { return os.DirFS(paymentsDir) }
func (paymentsPlugin) AdminControllers() []pact.AdminController {
return []pact.AdminController{paymentsInvoices{}}
}
func (paymentsPlugin) Permissions() []pact.Permission {
return []pact.Permission{{Code: "acme.payments.access", Roles: []string{"developer"}}}
}
// paymentsInvoices is the invoices controller.
type paymentsInvoices struct{}
func (paymentsInvoices) ID() string { return "acme.payments.invoices" }
func (paymentsInvoices) ModelName() string { return "Invoice" }
func (paymentsInvoices) ConfigDir() string { return "controllers/invoices" }
func (paymentsInvoices) RequiredPermissions() []string { return []string{"acme.payments.access"} }
func (paymentsInvoices) NewRecord() any { return &paymentsInvoice{} }
// paymentsAssemble boots plugins on the test database with the fixture's
// config: the assembled admin handler, or the boot error.
func paymentsAssemble(t *testing.T, gdb *gorm.DB, plugins []party.Plugin) (http.Handler, error) {
t.Helper()
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: cabana-payments\nlocale: en\nfallback_locale: en\n"), 0o644); err != nil {
t.Fatal(err)
}
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{"SUMMER_ENV=development", "SUMMER_ADMIN__JWT__SECRET=" + adminTestSecret}})
if err != nil {
t.Fatal(err)
}
for key, value := range map[string]any{"http.body_limits.default_bytes": 1048576, "http.body_limits.upload_bytes": 1048576} {
if err := cfg.Set(key, value); err != nil {
t.Fatal(err)
}
}
app := backpack.New(cfg)
if err := lagoon.Publish(app, adminSQL, gdb); err != nil {
t.Fatal(err)
}
if err := phrasebook.Activate(app, plugins); err != nil {
t.Fatal(err)
}
return surf.Assemble(app, plugins)
}
// newPaymentsEnv boots the acme.payments fixture alone on Postgres and signs
// an administrator in.
func newPaymentsEnv(t *testing.T) *conformEnv {
t.Helper()
gdb := adminGorm(t)
if err := gdb.Migrator().DropTable(paymentsModels()...); err != nil {
t.Fatal(err)
}
if err := gdb.AutoMigrate(paymentsModels()...); err != nil {
t.Fatal(err)
}
login := fmt.Sprintf("payments-%d", time.Now().UnixNano())
insertAdmin(t, gdb, login, login+"@example.test", adminTestPassword, true, false)
h, err := paymentsAssemble(t, gdb, []party.Plugin{paymentsPlugin{}})
if err != nil {
t.Fatal(err)
}
env := &conformEnv{h: h, db: gdb, login: login}
if rec := env.send(t, http.MethodPost, "/auth/login", map[string]string{"login": login, "password": adminTestPassword}, false); rec.Code != http.StatusOK {
t.Fatalf("login status=%d body=%s", rec.Code, rec.Body.String())
}
return env
}
// moneyFormFS is a one-controller plugin tree whose invoice fields.yaml is
// the given text, for compile-only checks of the money keys.
func moneyFormFS(fields string) fstest.MapFS {
return fstest.MapFS{
"controllers/invoices/config_form.yaml": {Data: []byte("name: invoices\nform: ~/plugins/acme/payments/models/invoice/fields.yaml\nmodelClass: Invoice\n")},
"models/invoice/fields.yaml": {Data: []byte(fields)},
}
}
// TestMoneyFieldCompile checks the money field's boot gates without a
// database: exactly one currency source, a scale of 2 to 4 (default 4), an
// ISO code, a max within the scale, and the keys refused elsewhere.
func TestMoneyFieldCompile(t *testing.T) {
schema, err := cabana.CompileForm("acme.payments", paymentsInvoices{}, moneyFormFS(`fields:
currency:
type: text
total:
type: money
currencyFrom: currency
max: "999999.9999"
fee:
type: money
currency: EUR
scale: 2
`))
if err != nil {
t.Fatal(err)
}
byName := map[string]cabana.FormField{}
for _, field := range schema.Fields {
byName[field.Name] = field
}
if total := byName["total"]; total.Type != "money" || total.CurrencyFrom != "currency" || total.Currency != "" || total.Scale != 4 || total.Max != "999999.9999" {
t.Fatalf("total = %+v", total)
}
if fee := byName["fee"]; fee.Currency != "EUR" || fee.CurrencyFrom != "" || fee.Scale != 2 || fee.Max != "" {
t.Fatalf("fee = %+v", fee)
}
raw, err := json.Marshal(byName["total"])
if err != nil {
t.Fatal(err)
}
for _, want := range []string{`"currencyFrom":"currency"`, `"scale":4`, `"max":"999999.9999"`} {
if !strings.Contains(string(raw), want) {
t.Fatalf("schema JSON lacks %s: %s", want, raw)
}
}
refused := map[string]string{
"both sources": `fields:
currency:
type: text
total:
type: money
currency: PLN
currencyFrom: currency
`,
"no source": `fields:
total:
type: money
`,
"scale 5": `fields:
total:
type: money
currency: PLN
scale: 5
`,
"scale 1": `fields:
total:
type: money
currency: PLN
scale: 1
`,
"lower-case code": `fields:
total:
type: money
currency: pln
`,
"max beyond scale": `fields:
total:
type: money
currency: PLN
scale: 2
max: "10.001"
`,
"max not a decimal": `fields:
total:
type: money
currency: PLN
max: "1,5"
`,
"options on money": `fields:
total:
type: money
currency: PLN
options: {a: A}
`,
"currency on text": `fields:
total:
type: text
currency: PLN
`,
}
for name, fields := range refused {
t.Run(name, func(t *testing.T) {
_, err := cabana.CompileForm("acme.payments", paymentsInvoices{}, moneyFormFS(fields))
if err == nil {
t.Fatalf("compiled")
}
if !strings.Contains(err.Error(), "field total") {
t.Fatalf("error does not name the field: %v", err)
}
})
}
}
// invoiceRecord is a create or update answer of the invoices controller.
type invoiceRecord struct {
Data map[string]any `json:"data"`
}
// TestMoneyFieldSmoke checks the money save path end to end on Postgres: an
// accepted decimal is stored byte for byte and served back unchanged, and a
// too-precise, malformed or too-large value is a 422 on the field with the
// money.* message (Polish, the request locale of the env).
func TestMoneyFieldSmoke(t *testing.T) {
e := newPaymentsEnv(t)
rec := e.send(t, http.MethodPost, "/acme/payments/invoices", map[string]any{"number": "INV-1", "currency": "PLN", "total": "85.4193", "fee": "1.50"}, true)
if rec.Code != http.StatusCreated {
t.Fatalf("create status=%d body=%s", rec.Code, rec.Body.String())
}
id := dataID(t, rec.Body.Bytes())
var stored paymentsInvoice
if err := e.db.First(&stored, id).Error; err != nil {
t.Fatal(err)
}
if !stored.Total.valid || stored.Total.text != "85.4193" {
t.Fatalf("stored total = %+v, want the exact string 85.4193", stored.Total)
}
if !stored.Fee.valid || stored.Fee.text != "1.5000" {
t.Fatalf("stored fee = %+v, want 1.5000 (numeric(10,4) pads the text; the input was not changed by cabana)", stored.Fee)
}
show := e.send(t, http.MethodGet, fmt.Sprintf("/acme/payments/invoices/%d", id), nil, true)
if show.Code != http.StatusOK {
t.Fatalf("show status=%d body=%s", show.Code, show.Body.String())
}
var record invoiceRecord
if err := json.Unmarshal(show.Body.Bytes(), &record); err != nil {
t.Fatal(err)
}
if record.Data["total"] != "85.4193" {
t.Fatalf("served total = %#v, want the string 85.4193", record.Data["total"])
}
// An untouched value sent back is re-saved byte-identical.
rec = e.send(t, http.MethodPut, fmt.Sprintf("/acme/payments/invoices/%d", id), map[string]any{"number": "INV-1", "currency": "PLN", "total": "85.4193"}, true)
if rec.Code != http.StatusOK {
t.Fatalf("update status=%d body=%s", rec.Code, rec.Body.String())
}
if err := e.db.First(&stored, id).Error; err != nil {
t.Fatal(err)
}
if stored.Total.text != "85.4193" {
t.Fatalf("re-saved total = %+v", stored.Total)
}
expect422 := func(t *testing.T, body map[string]any, field, message string) {
t.Helper()
rec := e.send(t, http.MethodPost, "/acme/payments/invoices", body, true)
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("want 422 on %s, status=%d body=%s", field, rec.Code, rec.Body.String())
}
var out struct {
Error struct {
Details map[string][]string `json:"details"`
} `json:"error"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("422 body: %v\n%s", err, rec.Body.String())
}
msgs := out.Error.Details[field]
if len(msgs) != 1 || !strings.Contains(msgs[0], message) {
t.Fatalf("422 details on %s = %v, want %q; body=%s", field, msgs, message, rec.Body.String())
}
}
expect422(t, map[string]any{"number": "INV-2", "currency": "PLN", "total": "12.34567"}, "total", "najwyżej 4 miejsc")
expect422(t, map[string]any{"number": "INV-2", "currency": "PLN", "total": "1,5"}, "total", "To nie jest kwota")
expect422(t, map[string]any{"number": "INV-2", "currency": "PLN", "total": "1000000.0000"}, "total", "najwyżej 999999.9999")
expect422(t, map[string]any{"number": "INV-2", "currency": "PLN", "total": 85.4193}, "total", "To nie jest kwota")
expect422(t, map[string]any{"number": "INV-2", "currency": "PLN", "fee": "1.234"}, "fee", "najwyżej 2 miejsc")
// Exactly max is accepted; null clears.
rec = e.send(t, http.MethodPost, "/acme/payments/invoices", map[string]any{"number": "INV-3", "currency": "PLN", "total": "999999.9999", "fee": nil}, true)
if rec.Code != http.StatusCreated {
t.Fatalf("max status=%d body=%s", rec.Code, rec.Body.String())
}
var capped paymentsInvoice
if err := e.db.First(&capped, dataID(t, rec.Body.Bytes())).Error; err != nil {
t.Fatal(err)
}
if capped.Total.text != "999999.9999" || capped.Fee.valid {
t.Fatalf("stored = %+v", capped)
}
// A boot whose money column cannot hold the string fails naming the field.
t.Run("column type", func(t *testing.T) {
_, err := paymentsAssemble(t, e.db, []party.Plugin{moneyBadColumnPlugin{}})
if err == nil || !strings.Contains(err.Error(), "field total") {
t.Fatalf("boot with an int money column: %v", err)
}
})
}
// moneyBadInvoice has an integer total: not a money column.
type moneyBadInvoice struct {
ID uint `gorm:"column:id;primaryKey"`
Number string `gorm:"column:number"`
Currency string `gorm:"column:currency"`
Total int64 `gorm:"column:total"`
Fee string `gorm:"column:fee"`
}
func (moneyBadInvoice) TableName() string { return "cabana_payments_bad" }
func (moneyBadInvoice) Fillable() []string { return []string{"number", "currency", "total", "fee"} }
func (moneyBadInvoice) Rules() map[string]string { return map[string]string{} }
type moneyBadColumnPlugin struct{ paymentsPlugin }
func (moneyBadColumnPlugin) AdminControllers() []pact.AdminController {
return []pact.AdminController{moneyBadInvoices{}}
}
type moneyBadInvoices struct{ paymentsInvoices }
func (moneyBadInvoices) NewRecord() any { return &moneyBadInvoice{} }