mirror of https://github.com/vernonkeenan/lib
Merge pull request #2 from vernonkeenan/test/app-service-helpers
Add unit tests for app/service-helpers.go pure helpersv0.7.4
commit
a906d56678
|
|
@ -0,0 +1,370 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue