- 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
376 lines
12 KiB
Go
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)
|
|
}
|
|
}
|