Files
summercms/modules/cabana/money_field_postgres_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

110 lines
4.8 KiB
Go

package cabana_test
import (
"encoding/json"
"fmt"
"net/http"
"testing"
"git.golem15.com/golem15/summercms/modules/cabana"
)
// TestMoneyFieldSave extends the plan 01 money smoke on Postgres (Phase 18
// D-07, D-10): an untouched value sent back with another change, or left
// out of the body, is re-saved byte-identical; a negative amount and the
// scale-2 field round-trip exactly; null clears; a 422 on update writes
// nothing; and the list payload serves the stored strings with each row's
// own currency.
func TestMoneyFieldSave(t *testing.T) {
e := newPaymentsEnv(t)
load := func(t *testing.T, id uint) paymentsInvoice {
t.Helper()
var row paymentsInvoice
if err := e.db.First(&row, id).Error; err != nil {
t.Fatal(err)
}
return row
}
rec := e.send(t, http.MethodPost, "/acme/payments/invoices", map[string]any{"number": "S-1", "currency": "PLN", "total": "85.4193", "fee": "12.5"}, true)
if rec.Code != http.StatusCreated {
t.Fatalf("create status=%d body=%s", rec.Code, rec.Body.String())
}
id := dataID(t, rec.Body.Bytes())
if row := load(t, id); row.Total.text != "85.4193" || row.Fee.text != "12.5000" {
t.Fatalf("stored = %+v", row)
}
// Another field changes, the untouched amount comes back as loaded.
rec = e.send(t, http.MethodPut, fmt.Sprintf("/acme/payments/invoices/%d", id), map[string]any{"number": "S-1b", "currency": "EUR", "total": "85.4193", "fee": "12.5000"}, true)
if rec.Code != http.StatusOK {
t.Fatalf("update status=%d body=%s", rec.Code, rec.Body.String())
}
if row := load(t, id); row.Number != "S-1b" || row.Currency != "EUR" || row.Total.text != "85.4193" || row.Fee.text != "12.5000" {
t.Fatalf("after update = %+v", row)
}
// Left out of the body, the amount is not touched.
rec = e.send(t, http.MethodPut, fmt.Sprintf("/acme/payments/invoices/%d", id), map[string]any{"number": "S-1c"}, true)
if rec.Code != http.StatusOK {
t.Fatalf("partial update status=%d body=%s", rec.Code, rec.Body.String())
}
if row := load(t, id); row.Total.text != "85.4193" || row.Fee.text != "12.5000" {
t.Fatalf("after partial update = %+v", row)
}
// Negative amount and the scale-2 field, exactly.
rec = e.send(t, http.MethodPut, fmt.Sprintf("/acme/payments/invoices/%d", id), map[string]any{"number": "S-1c", "total": "-0.5000", "fee": "0.01"}, true)
if rec.Code != http.StatusOK {
t.Fatalf("negative status=%d body=%s", rec.Code, rec.Body.String())
}
if row := load(t, id); row.Total.text != "-0.5000" || row.Fee.text != "0.0100" {
t.Fatalf("after negative = %+v", row)
}
// A 422 on update writes nothing.
for _, body := range []map[string]any{
{"number": "S-X", "total": "1.00001"},
{"number": "S-X", "fee": "0.001"},
{"number": "S-X", "total": "1e2"},
{"number": "S-X", "total": "1000000.0000"},
} {
rec = e.send(t, http.MethodPut, fmt.Sprintf("/acme/payments/invoices/%d", id), body, true)
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("%v status=%d body=%s", body, rec.Code, rec.Body.String())
}
if row := load(t, id); row.Number != "S-1c" || row.Total.text != "-0.5000" || row.Fee.text != "0.0100" {
t.Fatalf("a refused update wrote: %+v", row)
}
}
// null clears.
rec = e.send(t, http.MethodPut, fmt.Sprintf("/acme/payments/invoices/%d", id), map[string]any{"number": "S-1c", "fee": nil}, true)
if rec.Code != http.StatusOK {
t.Fatalf("clear status=%d body=%s", rec.Code, rec.Body.String())
}
if row := load(t, id); row.Fee.valid || row.Total.text != "-0.5000" {
t.Fatalf("after clear = %+v", row)
}
// The list payload: stored strings and each row's own currency.
if rec := e.send(t, http.MethodPost, "/acme/payments/invoices", map[string]any{"number": "S-2", "currency": "PLN", "total": "99999999.9999"}, true); rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("above the column max status=%d body=%s", rec.Code, rec.Body.String())
}
if rec := e.send(t, http.MethodPost, "/acme/payments/invoices", map[string]any{"number": "S-2", "currency": "PLN", "total": "999999.9999", "fee": "1"}, true); rec.Code != http.StatusCreated {
t.Fatalf("create S-2 status=%d body=%s", rec.Code, rec.Body.String())
}
rec = e.send(t, http.MethodGet, "/acme/payments/invoices?sort=number&dir=asc", nil, true)
if rec.Code != http.StatusOK {
t.Fatalf("list status=%d body=%s", rec.Code, rec.Body.String())
}
var list cabana.ListEnvelope[[]cabana.AdminRecord]
if err := json.Unmarshal(rec.Body.Bytes(), &list); err != nil {
t.Fatal(err)
}
if len(list.Data) != 2 {
t.Fatalf("rows = %+v", list.Data)
}
if row := list.Data[0]; row["number"] != "S-1c" || row["total"] != "-0.5000" || row["currency"] != "EUR" || row["fee"] != nil {
t.Fatalf("row 1 = %+v", row)
}
if row := list.Data[1]; row["number"] != "S-2" || row["total"] != "999999.9999" || row["currency"] != "PLN" || row["fee"] != "1.0000" {
t.Fatalf("row 2 = %+v", row)
}
}