- 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
404 lines
14 KiB
Go
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{} }
|