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) } }