diff --git a/app/service-helpers_test.go b/app/service-helpers_test.go new file mode 100644 index 0000000..2ac38ba --- /dev/null +++ b/app/service-helpers_test.go @@ -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) + } +}