lib/app/service-helpers_test.go

371 lines
12 KiB
Go

package app
import (
"database/sql"
"reflect"
"testing"
"time"
)
// helperTestStruct mirrors the shape of the domain structs GetFieldsAndValues
// is used against: an ID field that must always be excluded, plus a mix of
// field types (string, int, bool, pointer) carrying unrelated db/json struct
// tags. GetFieldsAndValues keys off reflect field Name, not struct tags, so
// these tags exist purely to prove they have no effect on the result.
type helperTestStruct struct {
ID int64 `db:"id" json:"id"`
Name string `db:"name" json:"full_name"`
Age int `db:"age" json:"years"`
Active bool `db:"active" json:"is_active"`
Score *float64
}
func TestGetFieldsAndValues(t *testing.T) {
t.Run("excludes ID and zero-value fields, keeps struct tags out of the result", func(t *testing.T) {
obj := &helperTestStruct{
ID: 100,
Name: "Alice",
Age: 30,
Active: true,
Score: nil,
}
got := GetFieldsAndValues(obj)
wantNames := []string{"Name", "Age", "Active"}
if !reflect.DeepEqual(got.fieldNames, wantNames) {
t.Fatalf("fieldNames = %v, want %v", got.fieldNames, wantNames)
}
wantValues := []interface{}{"Alice", 30, true}
if !reflect.DeepEqual(got.fieldValues, wantValues) {
t.Fatalf("fieldValues = %v, want %v", got.fieldValues, wantValues)
}
for _, name := range got.fieldNames {
if name == "ID" {
t.Fatalf("ID field must never be included, got names %v", got.fieldNames)
}
// Struct tags on this type use snake_case/alternate names
// (e.g. "full_name", "is_active"); confirm none of those
// leaked into the result in place of the Go field name.
if name == "full_name" || name == "is_active" || name == "years" {
t.Fatalf("result used a struct tag name instead of the field name: %v", got.fieldNames)
}
}
})
t.Run("ID excluded even when non-zero and all other fields zero", func(t *testing.T) {
obj := &helperTestStruct{ID: 42}
got := GetFieldsAndValues(obj)
// Name is a string field: per IsZero's documented behavior, strings
// (including "") are never considered zero, so it is always kept.
wantNames := []string{"Name"}
if !reflect.DeepEqual(got.fieldNames, wantNames) {
t.Fatalf("fieldNames = %v, want %v", got.fieldNames, wantNames)
}
wantValues := []interface{}{""}
if !reflect.DeepEqual(got.fieldValues, wantValues) {
t.Fatalf("fieldValues = %v, want %v", got.fieldValues, wantValues)
}
})
t.Run("non-nil pointer to a zero value is treated as zero and excluded", func(t *testing.T) {
zero := 0.0
obj := &helperTestStruct{Name: "x", Score: &zero}
got := GetFieldsAndValues(obj)
for _, name := range got.fieldNames {
if name == "Score" {
t.Fatalf("Score pointing at a zero float64 should be excluded, got names %v", got.fieldNames)
}
}
})
t.Run("non-nil pointer to a non-zero value is included as the pointer itself", func(t *testing.T) {
score := 87.5
obj := &helperTestStruct{Name: "x", Score: &score}
got := GetFieldsAndValues(obj)
idx := -1
for i, name := range got.fieldNames {
if name == "Score" {
idx = i
}
}
if idx == -1 {
t.Fatalf("expected Score field to be included, got names %v", got.fieldNames)
}
gotPtr, ok := got.fieldValues[idx].(*float64)
if !ok {
t.Fatalf("Score value has type %T, want *float64", got.fieldValues[idx])
}
if gotPtr != &score {
t.Fatalf("Score value pointer = %p, want the original field pointer %p", gotPtr, &score)
}
if *gotPtr != score {
t.Fatalf("Score value = %v, want %v", *gotPtr, score)
}
})
t.Run("empty struct fields collapse to an empty result besides always-kept strings", func(t *testing.T) {
obj := &helperTestStruct{}
got := GetFieldsAndValues(obj)
wantNames := []string{"Name"}
if !reflect.DeepEqual(got.fieldNames, wantNames) {
t.Fatalf("fieldNames = %v, want %v", got.fieldNames, wantNames)
}
})
}
func TestIsZero(t *testing.T) {
// numericOnly has no string fields, so its zero value genuinely trips
// the struct-recursion base case in IsZero.
type numericOnly struct {
A int
B bool
}
// nested carries a string field, which per IsZero's documented
// "strings are never zero" rule makes the whole struct read as
// non-zero even when every field is at its zero value.
type nested struct {
A int
B string
}
zero := 0
nonZero := 5
tests := []struct {
name string
v reflect.Value
want bool
}{
{"empty string is treated as non-zero", reflect.ValueOf(""), false},
{"non-empty string is non-zero", reflect.ValueOf("hi"), false},
{"zero int is zero", reflect.ValueOf(0), true},
{"non-zero int is non-zero", reflect.ValueOf(7), false},
{"false bool is zero", reflect.ValueOf(false), true},
{"true bool is non-zero", reflect.ValueOf(true), false},
{"zero float64 is zero", reflect.ValueOf(0.0), true},
{"non-zero float64 is non-zero", reflect.ValueOf(1.5), false},
{"nil slice is zero (len 0)", reflect.ValueOf([]int(nil)), true},
{"empty non-nil slice is zero (len 0)", reflect.ValueOf([]int{}), true},
{"non-empty slice is non-zero even if elements are zero", reflect.ValueOf([]int{0}), false},
{"zero-length array is zero", reflect.ValueOf([0]int{}), true},
{"non-zero-length array of zero elements is non-zero (len-based check)", reflect.ValueOf([3]int{0, 0, 0}), false},
{"zero-value struct with no string fields is zero", reflect.ValueOf(numericOnly{}), true},
{"struct with any non-zero field is non-zero", reflect.ValueOf(numericOnly{A: 1}), false},
{"struct containing a string field (even empty) is always non-zero", reflect.ValueOf(nested{}), false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := IsZero(tt.v); got != tt.want {
t.Errorf("IsZero(%v) = %v, want %v", tt.v, got, tt.want)
}
})
}
t.Run("nil pointer is zero", func(t *testing.T) {
var p *int
if got := IsZero(reflect.ValueOf(p)); got != true {
t.Errorf("IsZero(nil *int) = %v, want true", got)
}
})
t.Run("non-nil pointer to zero value is zero", func(t *testing.T) {
p := &zero
if got := IsZero(reflect.ValueOf(p)); got != true {
t.Errorf("IsZero(&0) = %v, want true", got)
}
})
t.Run("non-nil pointer to non-zero value is non-zero", func(t *testing.T) {
p := &nonZero
if got := IsZero(reflect.ValueOf(p)); got != false {
t.Errorf("IsZero(&5) = %v, want false", got)
}
})
t.Run("nil interface is zero", func(t *testing.T) {
var i interface{}
v := reflect.ValueOf(&i).Elem()
if got := IsZero(v); got != true {
t.Errorf("IsZero(nil interface) = %v, want true", got)
}
})
t.Run("interface holding a zero value is zero", func(t *testing.T) {
var i interface{} = 0
v := reflect.ValueOf(&i).Elem()
if got := IsZero(v); got != true {
t.Errorf("IsZero(interface holding 0) = %v, want true", got)
}
})
t.Run("interface holding a non-zero value is non-zero", func(t *testing.T) {
var i interface{} = 9
v := reflect.ValueOf(&i).Elem()
if got := IsZero(v); got != false {
t.Errorf("IsZero(interface holding 9) = %v, want false", got)
}
})
}
func TestSqlDateToString(t *testing.T) {
t.Run("nil pointer returns nil", func(t *testing.T) {
if got := SqlDateToString(nil); got != nil {
t.Fatalf("SqlDateToString(nil) = %v, want nil", got)
}
})
t.Run("invalid NullTime returns nil", func(t *testing.T) {
d := &sql.NullTime{Valid: false, Time: time.Date(2024, 3, 5, 9, 15, 30, 0, time.UTC)}
if got := SqlDateToString(d); got != nil {
t.Fatalf("SqlDateToString(invalid) = %v, want nil", got)
}
})
t.Run("valid NullTime in UTC formats with the dateTimeFormat literal offset suffix", func(t *testing.T) {
d := &sql.NullTime{Valid: true, Time: time.Date(2024, 3, 5, 9, 15, 30, 0, time.UTC)}
got := SqlDateToString(d)
if got == nil {
t.Fatal("SqlDateToString(valid) = nil, want non-nil")
}
want := "2024-03-05T09:15:30-0800"
if *got != want {
t.Fatalf("SqlDateToString(valid) = %q, want %q", *got, want)
}
})
t.Run("valid NullTime in a different zone still carries the literal -0800 suffix", func(t *testing.T) {
// dateTimeFormat's "-0800" is not a recognized Go reference-time zone
// token (the reference offset is -0700), so Format treats it as a
// literal suffix rather than the Time's actual UTC offset. This test
// pins down that existing behavior rather than "correct" tz math.
est := time.FixedZone("EST", -5*3600)
d := &sql.NullTime{Valid: true, Time: time.Date(2024, 3, 5, 9, 15, 30, 0, est)}
got := SqlDateToString(d)
if got == nil {
t.Fatal("SqlDateToString(valid) = nil, want non-nil")
}
want := "2024-03-05T09:15:30-0800"
if *got != want {
t.Fatalf("SqlDateToString(valid, EST) = %q, want %q", *got, want)
}
})
}
func TestParseDateTime(t *testing.T) {
t.Run("nil pointer returns an error", func(t *testing.T) {
got, err := ParseDateTime(nil)
if err == nil {
t.Fatal("ParseDateTime(nil) error = nil, want error")
}
if got != nil {
t.Fatalf("ParseDateTime(nil) time = %v, want nil", got)
}
})
t.Run("empty string returns an error", func(t *testing.T) {
s := ""
if _, err := ParseDateTime(&s); err == nil {
t.Fatal("ParseDateTime(\"\") error = nil, want error")
}
})
t.Run("unparseable string returns an error", func(t *testing.T) {
s := "not-a-date"
if _, err := ParseDateTime(&s); err == nil {
t.Fatal("ParseDateTime(garbage) error = nil, want error")
}
})
t.Run("dateFormat (date only)", func(t *testing.T) {
s := "2024-01-15"
got, err := ParseDateTime(&s)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
assertDateTimeParts(t, got, 2024, time.January, 15, 0, 0, 0)
})
t.Run("dateTimeFormat requires the literal -0800 suffix", func(t *testing.T) {
s := "2024-01-15T10:30:00-0800"
got, err := ParseDateTime(&s)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
assertDateTimeParts(t, got, 2024, time.January, 15, 10, 30, 0)
})
t.Run("a differently-offset string that matches no supported format errors", func(t *testing.T) {
// -0700 does not literal-match dateTimeFormat's "-0800" suffix, has
// no weekday/month name for RFC1123, and lacks the colon RFC3339
// requires in its offset - so it matches none of the four formats.
s := "2024-01-15T10:30:00-0700"
if _, err := ParseDateTime(&s); err == nil {
t.Fatal("expected error for an offset not matching any supported format")
}
})
t.Run("RFC1123", func(t *testing.T) {
s := "Mon, 15 Jan 2024 15:04:05 UTC"
got, err := ParseDateTime(&s)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
assertDateTimeParts(t, got, 2024, time.January, 15, 15, 4, 5)
})
t.Run("RFC3339 with Z", func(t *testing.T) {
s := "2024-01-15T10:30:00Z"
got, err := ParseDateTime(&s)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
assertDateTimeParts(t, got, 2024, time.January, 15, 10, 30, 0)
})
t.Run("RFC3339 with a colon offset", func(t *testing.T) {
s := "2024-01-15T10:30:00+05:00"
got, err := ParseDateTime(&s)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
assertDateTimeParts(t, got, 2024, time.January, 15, 10, 30, 0)
_, offset := got.Zone()
if offset != 5*3600 {
t.Fatalf("offset = %d seconds, want %d seconds", offset, 5*3600)
}
})
}
// assertDateTimeParts checks the wall-clock components a caller actually
// wrote in the input string. It deliberately avoids asserting on
// time.Time's Location, since ParseDateTime falls back to time.Local for
// formats with no zone info in the string, and that varies by host/CI
// timezone configuration - the numeric components do not.
func assertDateTimeParts(t *testing.T, got *time.Time, year int, month time.Month, day, hour, min, sec int) {
t.Helper()
if got == nil {
t.Fatal("parsed time is nil")
}
if got.Year() != year || got.Month() != month || got.Day() != day {
t.Fatalf("date = %04d-%02d-%02d, want %04d-%02d-%02d", got.Year(), got.Month(), got.Day(), year, month, day)
}
if got.Hour() != hour || got.Minute() != min || got.Second() != sec {
t.Fatalf("time = %02d:%02d:%02d, want %02d:%02d:%02d", got.Hour(), got.Minute(), got.Second(), hour, min, sec)
}
}