Files
platform/money/money_test.go
T
2026-07-03 19:30:32 +02:00

145 lines
4.1 KiB
Go

package money
import (
"errors"
"math"
"testing"
)
// TestParseCents_Exact asserts that ParseCents round-trips with zero float
// rounding error. The package's whole reason to exist is "149.95 → 14995
// exactly". If this ever fails, every invoice in the system is at risk.
//
// Float64 representation says 149.95 is actually 149.94999...something.
// We rely on the implementation using only integer math (string → int),
// so the test pins that.
func TestParseCents_Exact(t *testing.T) {
cases := []struct {
in string
want Cents
}{
{"149.95", Cents(14995)},
{"-149.95", Cents(-14995)},
{"0", Cents(0)},
{"0.00", Cents(0)},
{"0.05", Cents(5)},
{"0.5", Cents(50)},
{".5", Cents(50)},
{"-0.05", Cents(-5)},
{"149", Cents(14900)},
{"149.", Cents(14900)},
{"+12.34", Cents(1234)},
}
for _, c := range cases {
got, err := ParseCents(c.in)
if err != nil {
t.Errorf("ParseCents(%q): unexpected error %v", c.in, err)
continue
}
if got != c.want {
t.Errorf("ParseCents(%q) = %d; want %d", c.in, got, c.want)
}
}
} // TestParseCents_Reject asserts the parse error contract. Each row is
// exactly the kind of silent acceptance bug this package exists to
// prevent. Acceptance of "." and other leading-dot forms is locked in
// TestParseCents_Exact.
//
// Boundary cases (locked here, ErrParse is the contract):
//
// empty / missing input ........ "", "-", "+" → ErrParse
// non-digit characters .......... "abc", "1,95" → ErrParse
// multiple dots / too long ...... "1.234", "1.2.3",
// "1.2.34" → ErrParse
// double sign in integer ........ "--1", "++1",
// "+-1", "-+1" → ErrParse
// sign in fractional part ....... ".-", "1.-0" → ErrParse
//
// The fractional-side cases ("-", "1.-0") cover both fracStr
// paddings (length-1 padded to "-0", length-2 unpadded). Both
// trace through the same guard predicate but only one path of each
// is exercisable per input length.
func TestParseCents_Reject(t *testing.T) {
cases := []string{
"",
"-",
"+",
"abc",
"1,95",
"1.234",
"1.2.3",
"1.2.34",
"--1",
"++1",
"+-1",
"-+1",
".-",
"1.-0",
}
for _, in := range cases {
_, err := ParseCents(in)
if err == nil {
t.Errorf("ParseCents(%q): expected error, got nil", in)
continue
}
if !errors.Is(err, ErrParse) {
t.Errorf("ParseCents(%q): error %v should wrap ErrParse", in, err)
}
}
}
// TestString_RoundTrip asserts the inverse of ParseCents is exact: any
// non-overflowing Cents value printed by String() parses back to itself.
func TestString_RoundTrip(t *testing.T) {
for _, c := range []Cents{
0, 1, 99, 100, 14995, -14995, 1, 5, 50, -1,
Cents(math.MaxInt64 / 100),
Cents(math.MinInt64 / 100),
} {
got, err := ParseCents(c.String())
if err != nil {
t.Errorf("ParseCents(String(%d)) err = %v", c, err)
continue
}
if got != c {
t.Errorf("round-trip drift: Cents(%d) → %q → %d", c, c.String(), got)
}
}
}
// TestFromFloatMajor_LossyBoundary documents that FromFloatMajor is the
// only lossy entry point. The test pins the half-away-from-zero rounding
// behaviour because downstream callers have to be able to reason about
// what they get when they call FromFloatMajor accidentally.
func TestFromFloatMajor_LossyBoundary(t *testing.T) {
cases := []struct {
in float64
want Cents
}{
{0.0, Cents(0)},
{0.05, Cents(5)},
{0.055, Cents(6)}, // half-up: 0.055 * 100 = 5.5 → 6
{-0.055, Cents(-6)},
{1.99, Cents(199)},
{-1.99, Cents(-199)},
}
for _, c := range cases {
got := FromFloatMajor(c.in)
if got != c.want {
t.Errorf("FromFloatMajor(%v) = %d; want %d", c.in, got, c.want)
}
}
}
// TestMul_QuantityIsInteger asserts that the explicit Mul(n) exists so
// callers don't write `price * Cents(qty)` (which literally reads as
// "money squared", a nonsense unit). This is a behaviour test on the
// method's existence and correctness, not a type-level test.
func TestMul_QuantityIsInteger(t *testing.T) {
price := Cents(14995) // 149.95
got := price.Mul(3)
if got != Cents(44985) {
t.Errorf("14995 * 3 = %d; want 44985", got)
}
}