Files
summercms/modules/cabana/money_field_test.go
Jakub Zych 0f35c9a0ca test(18-07): framework unit coverage of money, settings secrets, tabs, richeditor and danger actions (task 2)
- money_field_test.go: compile gates (moved from the smoke file and
  extended), model column gate, save-time value check, list projection
- settings secret projection and put without a database; tabs, settings
  richeditor sanitize and the danger flag as internal unit tests
- Postgres: TestMoneyFieldSave, TestSettingsSecretPostgres and the Phase 18
  payload cases of TestPhase10OpenAPIConformance
- vitest: money.ts helpers spec, MoneyField blur-only and server-error cases
- gate arrays and the cabana README Testing section name every test
2026-10-13 16:58:03 +02:00

376 lines
12 KiB
Go

package cabana_test
import (
"database/sql"
"encoding/json"
"strings"
"testing"
"git.golem15.com/golem15/summercms/modules/cabana"
)
// Unit tests of the cabana money field and column (Phase 18 D-07 with the
// amended D-09 and D-10): the compile-only boot gates, the model column
// gate, the save-time value check and the list row projection. The
// Postgres save round trip is TestMoneyFieldSave and the smoke tests of
// plan 01 in payments_smoke_test.go; the fixtures (acme.payments) live
// there. No application names appear in framework tests.
// 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)
}
}
// The smallest valid field takes the defaults: scale 4 and no max.
minimal, err := cabana.CompileForm("acme.payments", paymentsInvoices{}, moneyFormFS("fields:\n total:\n type: money\n currency: PLN\n"))
if err != nil {
t.Fatal(err)
}
if f := minimal.Fields[0]; f.Scale != 4 || f.Max != "" || f.Currency != "PLN" {
t.Fatalf("minimal = %+v", f)
}
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
`,
"currencyFrom on text": `fields:
total:
type: text
currencyFrom: currency
`,
"currencyFrom not a column name": `fields:
total:
type: money
currencyFrom: "currency code"
`,
"negative max": `fields:
total:
type: money
currency: PLN
max: "-1.00"
`,
"exponent max": `fields:
total:
type: money
currency: PLN
max: "1e6"
`,
"scale not a number": `fields:
total:
type: money
currency: PLN
scale: four
`,
"preset on money": `fields:
total:
type: money
currency: PLN
preset: number
`,
}
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)
}
})
}
}
// moneyShapes is a model with every column shape the money gate decides on.
type moneyShapes struct {
ID uint `gorm:"column:id;primaryKey"`
Currency string `gorm:"column:currency"`
Code *string `gorm:"column:code"`
Number int `gorm:"column:number"`
Text string `gorm:"column:text"`
TextPtr *string `gorm:"column:text_ptr"`
Raw rawAmount `gorm:"column:raw"`
RawPtr *rawAmount `gorm:"column:raw_ptr"`
Nullable sql.NullString `gorm:"column:nullable"`
Plain struct{ V int } `gorm:"column:plain"`
Float float64 `gorm:"column:float"`
Whole int64 `gorm:"column:whole"`
}
// TestMoneyFieldColumnGate covers the model column gate the boot runs on
// every money field: a string, *string or sql.Scanner plus driver.Valuer
// struct column is accepted; an integer, a float or a plain struct is
// refused naming the field; currencyFrom must name a string column of the
// model.
func TestMoneyFieldColumnGate(t *testing.T) {
accepted := []string{"text", "text_ptr", "raw", "raw_ptr", "nullable"}
for _, column := range accepted {
if err := cabana.CheckMoneyType(&moneyShapes{}, cabana.FormField{Name: column, Type: "money", Currency: "PLN"}); err != nil {
t.Fatalf("%s refused: %v", column, err)
}
}
for _, column := range []string{"number", "plain", "float", "whole"} {
err := cabana.CheckMoneyType(&moneyShapes{}, cabana.FormField{Name: column, Type: "money", Currency: "PLN"})
if err == nil || !strings.Contains(err.Error(), "field "+column+": money needs a string column") {
t.Fatalf("%s: %v", column, err)
}
}
if err := cabana.CheckMoneyType(&moneyShapes{}, cabana.FormField{Name: "missing", Type: "money", Currency: "PLN"}); err == nil || !strings.Contains(err.Error(), "field missing is not a model column") {
t.Fatalf("missing column: %v", err)
}
for _, sibling := range []string{"currency", "code"} {
if err := cabana.CheckMoneyType(&moneyShapes{}, cabana.FormField{Name: "raw", Type: "money", CurrencyFrom: sibling}); err != nil {
t.Fatalf("currencyFrom %s refused: %v", sibling, err)
}
}
for _, sibling := range []string{"number", "id", "absent"} {
err := cabana.CheckMoneyType(&moneyShapes{}, cabana.FormField{Name: "raw", Type: "money", CurrencyFrom: sibling})
if err == nil || !strings.Contains(err.Error(), "field raw: currencyFrom "+sibling+" is not a string column") {
t.Fatalf("currencyFrom %s: %v", sibling, err)
}
}
}
// TestMoneyFieldValidate checks the save-time value check: a decimal string
// with a dot and at most scale fraction digits is accepted exactly as sent
// (the check never rewrites it), null is accepted, and every other input
// fails with the money.* key and its placeholders, comparing magnitudes as
// digit strings rather than floats.
func TestMoneyFieldValidate(t *testing.T) {
const (
invalid = "backend::lang.money.invalid"
tooPrecise = "backend::lang.money.too_precise"
tooLarge = "backend::lang.money.too_large"
)
for _, tc := range []struct {
value any
scale int
max string
key string
params map[string]string
}{
// Accepted.
{nil, 4, "999999.9999", "", nil},
{"85.4193", 4, "", "", nil},
{"-0.5000", 4, "", "", nil},
{"0", 4, "", "", nil},
{"12", 2, "", "", nil},
{"12.5", 4, "", "", nil},
{"999999.9999", 4, "999999.9999", "", nil},
{"0999999.99990", 4, "999999.9999", "", nil},
{"12345678901234567890.1234", 4, "", "", nil},
// Trailing zeros beyond the scale carry no precision: a scale-2 field
// over a four-decimal column re-saves its loaded value.
{"12.5000", 2, "", "", nil},
{"0.10000", 4, "", "", nil},
{"999999.990000", 2, "999999.99", "", nil},
// money.invalid: not the decimal grammar, or not a string.
{"1,5", 4, "", invalid, nil},
{"1.2.3", 4, "", invalid, nil},
{"1e3", 4, "", invalid, nil},
{" 12.50", 4, "", invalid, nil},
{"12.50 ", 4, "", invalid, nil},
{"abc", 4, "", invalid, nil},
{"", 4, "", invalid, nil},
{".5", 4, "", invalid, nil},
{"5.", 4, "", invalid, nil},
{"+5", 4, "", invalid, nil},
{"--5", 4, "", invalid, nil},
{"1 000", 4, "", invalid, nil},
{85.4193, 4, "", invalid, nil},
{int64(85), 4, "", invalid, nil},
{true, 4, "", invalid, nil},
{json.Number("85.4193"), 4, "", invalid, nil},
// money.too_precise: more fraction digits than the scale, never rounded.
{"12.34567", 4, "", tooPrecise, map[string]string{"scale": "4"}},
{"1.234", 2, "", tooPrecise, map[string]string{"scale": "2"}},
{"0.10001", 4, "", tooPrecise, map[string]string{"scale": "4"}},
{"1.2301", 2, "", tooPrecise, map[string]string{"scale": "2"}},
// money.too_large: above max in absolute magnitude.
{"1000000.0000", 4, "999999.9999", tooLarge, map[string]string{"max": "999999.9999"}},
{"1000000", 4, "999999.9999", tooLarge, map[string]string{"max": "999999.9999"}},
{"-1000000.00", 4, "999999.9999", tooLarge, map[string]string{"max": "999999.9999"}},
{"10.01", 2, "10.00", tooLarge, map[string]string{"max": "10.00"}},
} {
key, params := cabana.ValidateMoneyValue(tc.value, tc.scale, tc.max)
if key != tc.key {
t.Errorf("%#v (scale %d, max %q): key %q, want %q", tc.value, tc.scale, tc.max, key, tc.key)
continue
}
if len(params) != len(tc.params) {
t.Errorf("%#v: params %v, want %v", tc.value, params, tc.params)
continue
}
for k, v := range tc.params {
if params[k] != v {
t.Errorf("%#v: params %v, want %v", tc.value, params, tc.params)
}
}
}
}
// moneyRow is a list row of the invoices fixture with the currency column
// that the money columns follow.
type moneyRow struct {
ID uint `gorm:"column:id;primaryKey"`
Number string `gorm:"column:number"`
Currency string `gorm:"column:currency"`
Total rawAmount `gorm:"column:total"`
Fee rawAmount `gorm:"column:fee"`
}
// TestMoneyListColumn checks the money list column without a database: a
// currencyFrom column compiles not searchable and not sortable by default,
// and every projected row carries the sibling currency value under its own
// key although currency is not a declared column, so each row's amount is
// formatted with that row's code; a literal currency column adds no key.
func TestMoneyListColumn(t *testing.T) {
schema, err := cabana.CompileList("acme.payments", paymentsInvoices{}, moneyListFS(`columns:
number:
label: Number
total:
label: Total
type: money
currencyFrom: currency
`))
if err != nil {
t.Fatal(err)
}
var total cabana.ListColumn
for _, col := range schema.Columns {
if col.Key == "total" {
total = col
}
if col.Key == "currency" {
t.Fatalf("currency became a declared column: %+v", schema.Columns)
}
}
if total.Type != "money" || total.CurrencyFrom != "currency" || total.Searchable || total.Sortable {
t.Fatalf("total = %+v", total)
}
rows := []moneyRow{
{ID: 1, Number: "A", Currency: "PLN", Total: rawAmount{text: "85.4193", valid: true}},
{ID: 2, Number: "B", Currency: "EUR", Total: rawAmount{text: "5.0000", valid: true}},
{ID: 3, Number: "C", Currency: "", Total: rawAmount{}},
}
want := []map[string]any{
{"total": "85.4193", "currency": "PLN"},
{"total": "5.0000", "currency": "EUR"},
{"total": nil, "currency": ""},
}
for i, row := range rows {
out := cabana.ProjectRow(&row, paymentsInvoices{}, schema.Columns)
raw, err := json.Marshal(out)
if err != nil {
t.Fatal(err)
}
var got map[string]any
if err := json.Unmarshal(raw, &got); err != nil {
t.Fatal(err)
}
for key, value := range want[i] {
if got[key] != value {
t.Fatalf("row %d %s = %#v, want %#v (row %s)", i+1, key, got[key], value, raw)
}
}
if got["number"] != row.Number {
t.Fatalf("row %d number = %#v", i+1, got["number"])
}
}
literal, err := cabana.CompileList("acme.payments", paymentsInvoices{}, moneyListFS("columns:\n fee:\n type: money\n currency: EUR\n"))
if err != nil {
t.Fatal(err)
}
out := cabana.ProjectRow(&rows[0], paymentsInvoices{}, literal.Columns)
if _, has := out["currency"]; has {
t.Fatalf("a literal-currency column added the currency key: %#v", out)
}
}