From a59081ca241ff5d9f8883eeb11526d385615b8c9 Mon Sep 17 00:00:00 2001 From: shamaton Date: Tue, 7 Oct 2025 23:02:24 +0900 Subject: [PATCH 1/3] add configurable timezone for decoded time.Time values --- msgpack.go | 11 +++++++++++ time/decode.go | 18 +++++++++++++++--- time/decode_stream.go | 18 +++++++++++++++--- time/time.go | 8 ++++++++ 4 files changed, 49 insertions(+), 6 deletions(-) create mode 100644 time/time.go diff --git a/msgpack.go b/msgpack.go index 10d125e..5c8d5d7 100644 --- a/msgpack.go +++ b/msgpack.go @@ -10,6 +10,7 @@ import ( "github.com/shamaton/msgpack/v2/internal/encoding" streamdecoding "github.com/shamaton/msgpack/v2/internal/stream/decoding" streamencoding "github.com/shamaton/msgpack/v2/internal/stream/encoding" + "github.com/shamaton/msgpack/v2/time" ) // StructAsArray is encoding option. @@ -82,3 +83,13 @@ func RemoveExtStreamCoder(e ext.StreamEncoder, d ext.StreamDecoder) error { func SetComplexTypeCode(code int8) { def.SetComplexTypeCode(code) } + +// SetDecodedTimeAsUTC sets decoded time.Time values to UTC timezone. +func SetDecodedTimeAsUTC() { + time.SetDecodedAsLocal(false) +} + +// SetDecodedTimeAsLocal sets decoded time.Time values to local timezone. +func SetDecodedTimeAsLocal() { + time.SetDecodedAsLocal(true) +} diff --git a/time/decode.go b/time/decode.go index 46876a9..565adf0 100644 --- a/time/decode.go +++ b/time/decode.go @@ -48,7 +48,11 @@ func (td *timeDecoder) AsValue(offset int, k reflect.Kind, d *[]byte) (interface case def.Fixext4: _, offset = td.ReadSize1(offset, d) bs, offset := td.ReadSize4(offset, d) - return time.Unix(int64(binary.BigEndian.Uint32(bs)), 0), offset, nil + v := time.Unix(int64(binary.BigEndian.Uint32(bs)), 0) + if decodeAsLocal { + return v, offset, nil + } + return v.UTC(), offset, nil case def.Fixext8: _, offset = td.ReadSize1(offset, d) @@ -58,7 +62,11 @@ func (td *timeDecoder) AsValue(offset int, k reflect.Kind, d *[]byte) (interface if nano > 999999999 { return zero, 0, fmt.Errorf("In timestamp 64 formats, nanoseconds must not be larger than 999999999 : %d", nano) } - return time.Unix(int64(data64&0x00000003ffffffff), nano), offset, nil + v := time.Unix(int64(data64&0x00000003ffffffff), nano) + if decodeAsLocal { + return v, offset, nil + } + return v.UTC(), offset, nil case def.Ext8: _, offset = td.ReadSize1(offset, d) @@ -70,7 +78,11 @@ func (td *timeDecoder) AsValue(offset int, k reflect.Kind, d *[]byte) (interface return zero, 0, fmt.Errorf("In timestamp 96 formats, nanoseconds must not be larger than 999999999 : %d", nano) } sec := binary.BigEndian.Uint64(secbs) - return time.Unix(int64(sec), int64(nano)), offset, nil + v := time.Unix(int64(sec), int64(nano)) + if decodeAsLocal { + return v, offset, nil + } + return v.UTC(), offset, nil } return zero, 0, fmt.Errorf("should not reach this line!! code %x decoding %v", code, k) diff --git a/time/decode_stream.go b/time/decode_stream.go index 3dc9457..0de21c7 100644 --- a/time/decode_stream.go +++ b/time/decode_stream.go @@ -33,7 +33,11 @@ func (td *timeStreamDecoder) IsType(code byte, innerType int8, dataLength int) b func (td *timeStreamDecoder) ToValue(code byte, data []byte, k reflect.Kind) (interface{}, error) { switch code { case def.Fixext4: - return time.Unix(int64(binary.BigEndian.Uint32(data)), 0), nil + v := time.Unix(int64(binary.BigEndian.Uint32(data)), 0) + if decodeAsLocal { + return v, nil + } + return v.UTC(), nil case def.Fixext8: data64 := binary.BigEndian.Uint64(data) @@ -41,7 +45,11 @@ func (td *timeStreamDecoder) ToValue(code byte, data []byte, k reflect.Kind) (in if nano > 999999999 { return zero, fmt.Errorf("in timestamp 64 formats, nanoseconds must not be larger than 999999999 : %d", nano) } - return time.Unix(int64(data64&0x00000003ffffffff), nano), nil + v := time.Unix(int64(data64&0x00000003ffffffff), nano) + if decodeAsLocal { + return v, nil + } + return v.UTC(), nil case def.Ext8: nano := binary.BigEndian.Uint32(data[:4]) @@ -49,7 +57,11 @@ func (td *timeStreamDecoder) ToValue(code byte, data []byte, k reflect.Kind) (in return zero, fmt.Errorf("in timestamp 96 formats, nanoseconds must not be larger than 999999999 : %d", nano) } sec := binary.BigEndian.Uint64(data[4:12]) - return time.Unix(int64(sec), int64(nano)), nil + v := time.Unix(int64(sec), int64(nano)) + if decodeAsLocal { + return v, nil + } + return v.UTC(), nil } return zero, fmt.Errorf("should not reach this line!! code %x decoding %v", code, k) diff --git a/time/time.go b/time/time.go new file mode 100644 index 0000000..233bebf --- /dev/null +++ b/time/time.go @@ -0,0 +1,8 @@ +package time + +var decodeAsLocal = true + +// SetDecodedAsLocal sets the decoded time to local time. +func SetDecodedAsLocal(b bool) { + decodeAsLocal = b +} From 84ccecf04d8551b8d117c96c5a52912b22c5abeb Mon Sep 17 00:00:00 2001 From: shamaton Date: Thu, 9 Oct 2025 22:07:39 +0900 Subject: [PATCH 2/3] add time test cases --- msgpack_test.go | 41 ++++ time/decode.go | 4 +- time/decode_stream_test.go | 425 +++++++++++++++++++++++++++++++++++++ time/decode_test.go | 394 ++++++++++++++++++++++++++++++++++ time/encode_stream_test.go | 392 ++++++++++++++++++++++++++++++++++ time/encode_test.go | 325 ++++++++++++++++++++++++++++ 6 files changed, 1579 insertions(+), 2 deletions(-) create mode 100644 time/decode_stream_test.go create mode 100644 time/decode_test.go create mode 100644 time/encode_stream_test.go create mode 100644 time/encode_test.go diff --git a/msgpack_test.go b/msgpack_test.go index e68d096..0b42881 100644 --- a/msgpack_test.go +++ b/msgpack_test.go @@ -17,6 +17,7 @@ import ( "github.com/shamaton/msgpack/v2" "github.com/shamaton/msgpack/v2/def" "github.com/shamaton/msgpack/v2/ext" + tu "github.com/shamaton/msgpack/v2/internal/common/testutil" extTime "github.com/shamaton/msgpack/v2/time" ) @@ -1370,6 +1371,46 @@ func TestTime(t *testing.T) { ErrorContains(t, err, "should not reach this line") }) }) + + loc := time.Local + utc8 := time.FixedZone("UTC-8", -8*60*60) + + time.Local = utc8 + defer func() { time.Local = loc }() + + t.Run("UTC", func(t *testing.T) { + msgpack.SetDecodedTimeAsUTC() + args := []encdecArg[time.Time]{ + { + n: "case", + v: time.Unix(now.Unix(), 0), + vc: func(r time.Time) error { + tu.Equal(t, now.Unix(), r.Unix()) + tu.Equal(t, r.Location(), time.UTC) + return nil + }, + skipEq: true, // skip equal check because of location difference + }, + } + encdec(t, args...) + }) + + t.Run("Local", func(t *testing.T) { + msgpack.SetDecodedTimeAsLocal() + args := []encdecArg[time.Time]{ + { + n: "case", + v: time.Unix(now.Unix(), 0), + vc: func(r time.Time) error { + tu.Equal(t, r.Location(), time.Local) + tu.Equal(t, r.Location(), utc8) + return nil + }, + }, + } + encdec(t, args...) + }) + } func TestMap(t *testing.T) { diff --git a/time/decode.go b/time/decode.go index 565adf0..e5962d2 100644 --- a/time/decode.go +++ b/time/decode.go @@ -60,7 +60,7 @@ func (td *timeDecoder) AsValue(offset int, k reflect.Kind, d *[]byte) (interface data64 := binary.BigEndian.Uint64(bs) nano := int64(data64 >> 34) if nano > 999999999 { - return zero, 0, fmt.Errorf("In timestamp 64 formats, nanoseconds must not be larger than 999999999 : %d", nano) + return zero, 0, fmt.Errorf("in timestamp 64 formats, nanoseconds must not be larger than 999999999 : %d", nano) } v := time.Unix(int64(data64&0x00000003ffffffff), nano) if decodeAsLocal { @@ -75,7 +75,7 @@ func (td *timeDecoder) AsValue(offset int, k reflect.Kind, d *[]byte) (interface secbs, offset := td.ReadSize8(offset, d) nano := binary.BigEndian.Uint32(nanobs) if nano > 999999999 { - return zero, 0, fmt.Errorf("In timestamp 96 formats, nanoseconds must not be larger than 999999999 : %d", nano) + return zero, 0, fmt.Errorf("in timestamp 96 formats, nanoseconds must not be larger than 999999999 : %d", nano) } sec := binary.BigEndian.Uint64(secbs) v := time.Unix(int64(sec), int64(nano)) diff --git a/time/decode_stream_test.go b/time/decode_stream_test.go new file mode 100644 index 0000000..ba3ca67 --- /dev/null +++ b/time/decode_stream_test.go @@ -0,0 +1,425 @@ +package time + +import ( + "encoding/binary" + "reflect" + "testing" + "time" + + "github.com/shamaton/msgpack/v2/def" + tu "github.com/shamaton/msgpack/v2/internal/common/testutil" +) + +func TestStreamDecodeCode(t *testing.T) { + decoder := StreamDecoder + code := decoder.Code() + tu.Equal(t, code, def.TimeStamp) +} + +func TestStreamDecodeIsType(t *testing.T) { + decoder := StreamDecoder + ts := int8(def.TimeStamp) + + tests := []struct { + name string + code byte + innerType int8 + dataLength int + expected bool + }{ + { + name: "Fixext4 with TimeStamp", + code: def.Fixext4, + innerType: ts, + dataLength: 4, + expected: true, + }, + { + name: "Fixext4 with wrong type", + code: def.Fixext4, + innerType: 0x00, + dataLength: 4, + expected: false, + }, + { + name: "Fixext8 with TimeStamp", + code: def.Fixext8, + innerType: ts, + dataLength: 8, + expected: true, + }, + { + name: "Fixext8 with wrong type", + code: def.Fixext8, + innerType: 0x00, + dataLength: 8, + expected: false, + }, + { + name: "Ext8 with length 12 and TimeStamp", + code: def.Ext8, + innerType: ts, + dataLength: 12, + expected: true, + }, + { + name: "Ext8 with wrong length", + code: def.Ext8, + innerType: ts, + dataLength: 10, + expected: false, + }, + { + name: "Ext8 with wrong type", + code: def.Ext8, + innerType: 0x00, + dataLength: 12, + expected: false, + }, + { + name: "Wrong format", + code: def.Nil, + innerType: ts, + dataLength: 0, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := decoder.IsType(tt.code, tt.innerType, tt.dataLength) + tu.Equal(t, result, tt.expected) + }) + } +} + +func TestStreamDecodeToValueFixext4(t *testing.T) { + decoder := StreamDecoder + + tests := []struct { + name string + unixTime int64 + }{ + { + name: "Unix epoch", + unixTime: 0, + }, + { + name: "Small timestamp", + unixTime: 1000, + }, + { + name: "Large timestamp", + unixTime: 1<<32 - 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create Fixext4 format data (without header) + data := make([]byte, 4) + binary.BigEndian.PutUint32(data, uint32(tt.unixTime)) + + value, err := decoder.ToValue(def.Fixext4, data, reflect.TypeOf(time.Time{}).Kind()) + tu.NoError(t, err) + + timeValue, ok := value.(time.Time) + if !ok { + t.Fatalf("Expected time.Time, got %T", value) + } + + tu.Equal(t, timeValue.Unix(), tt.unixTime) + tu.Equal(t, timeValue.Nanosecond(), 0) + }) + } +} + +func TestStreamDecodeToValueFixext8(t *testing.T) { + decoder := StreamDecoder + + tests := []struct { + name string + unixTime int64 + nanosecond int64 + }{ + { + name: "With small nanoseconds", + unixTime: 1000, + nanosecond: 123456789, + }, + { + name: "With max nanoseconds", + unixTime: 67890, + nanosecond: 999999999, + }, + { + name: "Boundary at 2^34-1", + unixTime: (1 << 34) - 1, + nanosecond: 999999999, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create Fixext8 format data (without header) + data := make([]byte, 8) + + // Pack nanoseconds in upper 30 bits and seconds in lower 34 bits + data64 := uint64(tt.nanosecond)<<34 | uint64(tt.unixTime) + binary.BigEndian.PutUint64(data, data64) + + value, err := decoder.ToValue(def.Fixext8, data, reflect.TypeOf(time.Time{}).Kind()) + tu.NoError(t, err) + + timeValue, ok := value.(time.Time) + if !ok { + t.Fatalf("Expected time.Time, got %T", value) + } + + tu.Equal(t, timeValue.Unix(), tt.unixTime) + tu.Equal(t, int64(timeValue.Nanosecond()), tt.nanosecond) + }) + } +} + +func TestStreamDecodeToValueExt8(t *testing.T) { + decoder := StreamDecoder + + tests := []struct { + name string + unixTime int64 + nanosecond int32 + }{ + { + name: "Large timestamp at 2^34", + unixTime: 1 << 34, + nanosecond: 123456789, + }, + { + name: "Very large timestamp", + unixTime: 253402300799, + nanosecond: 999999999, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create Ext8 format data (without header) + data := make([]byte, 12) + binary.BigEndian.PutUint32(data[:4], uint32(tt.nanosecond)) + binary.BigEndian.PutUint64(data[4:12], uint64(tt.unixTime)) + + value, err := decoder.ToValue(def.Ext8, data, reflect.TypeOf(time.Time{}).Kind()) + tu.NoError(t, err) + + timeValue, ok := value.(time.Time) + if !ok { + t.Fatalf("Expected time.Time, got %T", value) + } + + tu.Equal(t, timeValue.Unix(), tt.unixTime) + tu.Equal(t, int64(timeValue.Nanosecond()), int64(tt.nanosecond)) + }) + } +} + +func TestStreamDecodeToValueErrors(t *testing.T) { + decoder := StreamDecoder + + t.Run("Fixext8 with invalid nanoseconds", func(t *testing.T) { + data := make([]byte, 8) + + // Set nanoseconds > 999999999 (invalid) + invalidNano := uint64(1000000000) + data64 := invalidNano<<34 | 1000 + binary.BigEndian.PutUint64(data, data64) + + _, err := decoder.ToValue(def.Fixext8, data, reflect.TypeOf(time.Time{}).Kind()) + tu.ErrorContains(t, err, "in timestamp 64 formats") + }) + + t.Run("Ext8 with invalid nanoseconds", func(t *testing.T) { + data := make([]byte, 12) + + // Set nanoseconds > 999999999 (invalid) + binary.BigEndian.PutUint32(data[:4], 1000000000) + binary.BigEndian.PutUint64(data[4:12], 1000) + + _, err := decoder.ToValue(def.Ext8, data, reflect.TypeOf(time.Time{}).Kind()) + tu.ErrorContains(t, err, "in timestamp 96 formats") + }) + + t.Run("Invalid format code", func(t *testing.T) { + data := []byte{0} + + _, err := decoder.ToValue(def.Nil, data, reflect.TypeOf(time.Time{}).Kind()) + tu.ErrorContains(t, err, "should not reach") + }) +} + +func TestStreamDecodeTimezone(t *testing.T) { + decoder := StreamDecoder + + tests := []struct { + name string + time time.Time + format byte + createData func(time.Time) []byte + }{ + { + name: "Fixext4", + time: time.Unix(1000, 0), + format: def.Fixext4, + createData: func(testTime time.Time) []byte { + data := make([]byte, 4) + binary.BigEndian.PutUint32(data, uint32(testTime.Unix())) + return data + }, + }, + { + name: "Fixext8", + time: time.Unix(1000, 123456789), + format: def.Fixext8, + createData: func(testTime time.Time) []byte { + data := make([]byte, 8) + data64 := uint64(testTime.Nanosecond())<<34 | uint64(testTime.Unix()) + binary.BigEndian.PutUint64(data, data64) + return data + }, + }, + { + name: "Ext8", + time: time.Unix(1<<34, 123456789), + format: def.Ext8, + createData: func(testTime time.Time) []byte { + data := make([]byte, 12) + binary.BigEndian.PutUint32(data[:4], uint32(testTime.Nanosecond())) + binary.BigEndian.PutUint64(data[4:12], uint64(testTime.Unix())) + return data + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name+" - Decode as local (default)", func(t *testing.T) { + // Set to local timezone + SetDecodedAsLocal(true) + defer SetDecodedAsLocal(true) // Reset to default + + data := tt.createData(tt.time) + value, err := decoder.ToValue(tt.format, data, reflect.TypeOf(time.Time{}).Kind()) + tu.NoError(t, err) + + timeValue, ok := value.(time.Time) + if !ok { + t.Fatalf("Expected time.Time, got %T", value) + } + + // Should be in local timezone + tu.Equal(t, timeValue.Location(), time.Local) + }) + + t.Run(tt.name+" - Decode as UTC", func(t *testing.T) { + // Set to UTC timezone + SetDecodedAsLocal(false) + defer SetDecodedAsLocal(true) // Reset to default + + data := tt.createData(tt.time) + value, err := decoder.ToValue(tt.format, data, reflect.TypeOf(time.Time{}).Kind()) + tu.NoError(t, err) + + timeValue, ok := value.(time.Time) + if !ok { + t.Fatalf("Expected time.Time, got %T", value) + } + + // Should be in UTC timezone + tu.Equal(t, timeValue.Location(), time.UTC) + }) + } +} + +func TestStreamDecodeRoundTrip(t *testing.T) { + decoder := StreamDecoder + + tests := []struct { + name string + time time.Time + }{ + { + name: "Fixext4 format", + time: time.Unix(1000, 0), + }, + { + name: "Fixext8 format", + time: time.Unix(67890, 123456789), + }, + { + name: "Ext8 format", + time: time.Unix(17179869184, 987654321), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Use encode_stream to encode + var encodedData []byte + secs := uint64(tt.time.Unix()) + if secs>>34 == 0 { + data := uint64(tt.time.Nanosecond())<<34 | secs + if data&0xffffffff00000000 == 0 { + // Fixext4 + encodedData = make([]byte, 4) + binary.BigEndian.PutUint32(encodedData, uint32(data)) + + // Decode + decodedValue, err := decoder.ToValue(def.Fixext4, encodedData, reflect.TypeOf(time.Time{}).Kind()) + tu.NoError(t, err) + + decodedTime, ok := decodedValue.(time.Time) + if !ok { + t.Fatalf("Expected time.Time, got %T", decodedValue) + } + + tu.Equal(t, decodedTime.Unix(), tt.time.Unix()) + tu.Equal(t, decodedTime.Nanosecond(), tt.time.Nanosecond()) + return + } + + // Fixext8 + encodedData = make([]byte, 8) + binary.BigEndian.PutUint64(encodedData, data) + + // Decode + decodedValue, err := decoder.ToValue(def.Fixext8, encodedData, reflect.TypeOf(time.Time{}).Kind()) + tu.NoError(t, err) + + decodedTime, ok := decodedValue.(time.Time) + if !ok { + t.Fatalf("Expected time.Time, got %T", decodedValue) + } + + tu.Equal(t, decodedTime.Unix(), tt.time.Unix()) + tu.Equal(t, decodedTime.Nanosecond(), tt.time.Nanosecond()) + return + } + + // Ext8 + encodedData = make([]byte, 12) + binary.BigEndian.PutUint32(encodedData[:4], uint32(tt.time.Nanosecond())) + binary.BigEndian.PutUint64(encodedData[4:12], secs) + + // Decode + decodedValue, err := decoder.ToValue(def.Ext8, encodedData, reflect.TypeOf(time.Time{}).Kind()) + tu.NoError(t, err) + + decodedTime, ok := decodedValue.(time.Time) + if !ok { + t.Fatalf("Expected time.Time, got %T", decodedValue) + } + + tu.Equal(t, decodedTime.Unix(), tt.time.Unix()) + tu.Equal(t, decodedTime.Nanosecond(), tt.time.Nanosecond()) + }) + } +} diff --git a/time/decode_test.go b/time/decode_test.go new file mode 100644 index 0000000..c256e4a --- /dev/null +++ b/time/decode_test.go @@ -0,0 +1,394 @@ +package time + +import ( + "encoding/binary" + "reflect" + "testing" + "time" + + "github.com/shamaton/msgpack/v2/def" + tu "github.com/shamaton/msgpack/v2/internal/common/testutil" +) + +func TestDecodeCode(t *testing.T) { + decoder := Decoder + code := decoder.Code() + tu.Equal(t, code, def.TimeStamp) +} + +func TestDecodeIsType(t *testing.T) { + decoder := Decoder + ts := def.TimeStamp + + tests := []struct { + name string + data []byte + expected bool + }{ + { + name: "Fixext4 with TimeStamp", + data: []byte{def.Fixext4, byte(ts), 0, 0, 0, 0}, + expected: true, + }, + { + name: "Fixext4 with wrong type", + data: []byte{def.Fixext4, 0x00, 0, 0, 0, 0}, + expected: false, + }, + { + name: "Fixext8 with TimeStamp", + data: []byte{def.Fixext8, byte(ts), 0, 0, 0, 0, 0, 0, 0, 0}, + expected: true, + }, + { + name: "Fixext8 with wrong type", + data: []byte{def.Fixext8, 0x00, 0, 0, 0, 0, 0, 0, 0, 0}, + expected: false, + }, + { + name: "Ext8 with length 12 and TimeStamp", + data: []byte{def.Ext8, 12, byte(ts), 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, + expected: true, + }, + { + name: "Ext8 with wrong length", + data: []byte{def.Ext8, 10, byte(ts), 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, + expected: false, + }, + { + name: "Ext8 with wrong type", + data: []byte{def.Ext8, 12, 0x00, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, + expected: false, + }, + { + name: "Wrong format", + data: []byte{def.Nil}, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := decoder.IsType(0, &tt.data) + tu.Equal(t, result, tt.expected) + }) + } +} + +func TestDecodeAsValueFixext4(t *testing.T) { + decoder := Decoder + ts := def.TimeStamp + + tests := []struct { + name string + unixTime int64 + }{ + { + name: "Unix epoch", + unixTime: 0, + }, + { + name: "Small timestamp", + unixTime: 1000, + }, + { + name: "Large timestamp", + unixTime: 1<<32 - 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create Fixext4 format data + data := make([]byte, 6) + data[0] = def.Fixext4 + data[1] = byte(ts) + binary.BigEndian.PutUint32(data[2:], uint32(tt.unixTime)) + + value, offset, err := decoder.AsValue(0, reflect.TypeOf(time.Time{}).Kind(), &data) + tu.NoError(t, err) + tu.Equal(t, offset, 6) + + timeValue, ok := value.(time.Time) + if !ok { + t.Fatalf("Expected time.Time, got %T", value) + } + + tu.Equal(t, timeValue.Unix(), tt.unixTime) + tu.Equal(t, timeValue.Nanosecond(), 0) + }) + } +} + +func TestDecodeAsValueFixext8(t *testing.T) { + decoder := Decoder + ts := def.TimeStamp + + tests := []struct { + name string + unixTime int64 + nanosecond int64 + }{ + { + name: "With small nanoseconds", + unixTime: 1000, + nanosecond: 123456789, + }, + { + name: "With max nanoseconds", + unixTime: 67890, + nanosecond: 999999999, + }, + { + name: "Boundary at 2^34-1", + unixTime: (1 << 34) - 1, + nanosecond: 999999999, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create Fixext8 format data + data := make([]byte, 10) + data[0] = def.Fixext8 + data[1] = byte(ts) + + // Pack nanoseconds in upper 30 bits and seconds in lower 34 bits + data64 := uint64(tt.nanosecond)<<34 | uint64(tt.unixTime) + binary.BigEndian.PutUint64(data[2:], data64) + + value, offset, err := decoder.AsValue(0, reflect.TypeOf(time.Time{}).Kind(), &data) + tu.NoError(t, err) + tu.Equal(t, offset, 10) + + timeValue, ok := value.(time.Time) + if !ok { + t.Fatalf("Expected time.Time, got %T", value) + } + + tu.Equal(t, timeValue.Unix(), tt.unixTime) + tu.Equal(t, int64(timeValue.Nanosecond()), tt.nanosecond) + }) + } +} + +func TestDecodeAsValueExt8(t *testing.T) { + decoder := Decoder + ts := def.TimeStamp + + tests := []struct { + name string + unixTime int64 + nanosecond int32 + }{ + { + name: "Large timestamp at 2^34", + unixTime: 1 << 34, + nanosecond: 123456789, + }, + { + name: "Very large timestamp", + unixTime: 253402300799, + nanosecond: 999999999, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create Ext8 format data + data := make([]byte, 15) + data[0] = def.Ext8 + data[1] = 12 // length + data[2] = byte(ts) + binary.BigEndian.PutUint32(data[3:], uint32(tt.nanosecond)) + binary.BigEndian.PutUint64(data[7:], uint64(tt.unixTime)) + + value, offset, err := decoder.AsValue(0, reflect.TypeOf(time.Time{}).Kind(), &data) + tu.NoError(t, err) + tu.Equal(t, offset, 15) + + timeValue, ok := value.(time.Time) + if !ok { + t.Fatalf("Expected time.Time, got %T", value) + } + + tu.Equal(t, timeValue.Unix(), tt.unixTime) + tu.Equal(t, int64(timeValue.Nanosecond()), int64(tt.nanosecond)) + }) + } +} + +func TestDecodeAsValueErrors(t *testing.T) { + decoder := Decoder + ts := def.TimeStamp + + t.Run("Fixext8 with invalid nanoseconds", func(t *testing.T) { + data := make([]byte, 10) + data[0] = def.Fixext8 + data[1] = byte(ts) + + // Set nanoseconds > 999999999 (invalid) + invalidNano := uint64(1000000000) + data64 := invalidNano<<34 | 1000 + binary.BigEndian.PutUint64(data[2:], data64) + + _, _, err := decoder.AsValue(0, reflect.TypeOf(time.Time{}).Kind(), &data) + tu.ErrorContains(t, err, "in timestamp 64 formats") + }) + + t.Run("Ext8 with invalid nanoseconds", func(t *testing.T) { + data := make([]byte, 15) + data[0] = def.Ext8 + data[1] = 12 + data[2] = byte(ts) + + // Set nanoseconds > 999999999 (invalid) + binary.BigEndian.PutUint32(data[3:], 1000000000) + binary.BigEndian.PutUint64(data[7:], 1000) + + _, _, err := decoder.AsValue(0, reflect.TypeOf(time.Time{}).Kind(), &data) + tu.ErrorContains(t, err, "in timestamp 96 formats") + }) + + t.Run("Invalid format code", func(t *testing.T) { + data := []byte{def.Nil} + + _, _, err := decoder.AsValue(0, reflect.TypeOf(time.Time{}).Kind(), &data) + tu.ErrorContains(t, err, "should not reach") + }) +} + +func TestDecodeTimezone(t *testing.T) { + decoder := Decoder + ts := def.TimeStamp + + tests := []struct { + name string + time time.Time + createData func(time.Time) []byte + }{ + { + name: "Fixext4", + time: time.Unix(1000, 0), + createData: func(testTime time.Time) []byte { + data := make([]byte, 6) + data[0] = def.Fixext4 + data[1] = byte(ts) + binary.BigEndian.PutUint32(data[2:], uint32(testTime.Unix())) + return data + }, + }, + { + name: "Fixext8", + time: time.Unix(1000, 123456789), + createData: func(testTime time.Time) []byte { + data := make([]byte, 10) + data[0] = def.Fixext8 + data[1] = byte(ts) + data64 := uint64(testTime.Nanosecond())<<34 | uint64(testTime.Unix()) + binary.BigEndian.PutUint64(data[2:], data64) + return data + }, + }, + { + name: "Ext8", + time: time.Unix(1<<34, 123456789), + createData: func(testTime time.Time) []byte { + data := make([]byte, 15) + data[0] = def.Ext8 + data[1] = 12 + data[2] = byte(ts) + binary.BigEndian.PutUint32(data[3:], uint32(testTime.Nanosecond())) + binary.BigEndian.PutUint64(data[7:], uint64(testTime.Unix())) + return data + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name+" - Decode as local (default)", func(t *testing.T) { + // Set to local timezone + SetDecodedAsLocal(true) + defer SetDecodedAsLocal(true) // Reset to default + + data := tt.createData(tt.time) + value, _, err := decoder.AsValue(0, reflect.TypeOf(time.Time{}).Kind(), &data) + tu.NoError(t, err) + + timeValue, ok := value.(time.Time) + if !ok { + t.Fatalf("Expected time.Time, got %T", value) + } + + // Should be in local timezone + tu.Equal(t, timeValue.Location(), time.Local) + }) + + t.Run(tt.name+" - Decode as UTC", func(t *testing.T) { + // Set to UTC timezone + SetDecodedAsLocal(false) + defer SetDecodedAsLocal(true) // Reset to default + + data := tt.createData(tt.time) + value, _, err := decoder.AsValue(0, reflect.TypeOf(time.Time{}).Kind(), &data) + tu.NoError(t, err) + + timeValue, ok := value.(time.Time) + if !ok { + t.Fatalf("Expected time.Time, got %T", value) + } + + // Should be in UTC timezone + tu.Equal(t, timeValue.Location(), time.UTC) + }) + } +} + +func TestDecodeRoundTrip(t *testing.T) { + encoder := Encoder + decoder := Decoder + + tests := []struct { + name string + time time.Time + }{ + { + name: "Fixext4 format", + time: time.Unix(1000, 0), + }, + { + name: "Fixext8 format", + time: time.Unix(67890, 123456789), + }, + { + name: "Ext8 format", + time: time.Unix(17179869184, 987654321), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Encode + value := reflect.ValueOf(tt.time) + size, err := encoder.CalcByteSize(value) + tu.NoError(t, err) + + bytes := make([]byte, size) + encoder.WriteToBytes(value, 0, &bytes) + + // Decode + decodedValue, offset, err := decoder.AsValue(0, reflect.TypeOf(time.Time{}).Kind(), &bytes) + tu.NoError(t, err) + tu.Equal(t, offset, size) + + decodedTime, ok := decodedValue.(time.Time) + if !ok { + t.Fatalf("Expected time.Time, got %T", decodedValue) + } + + // Compare Unix time and nanoseconds + tu.Equal(t, decodedTime.Unix(), tt.time.Unix()) + tu.Equal(t, decodedTime.Nanosecond(), tt.time.Nanosecond()) + }) + } +} diff --git a/time/encode_stream_test.go b/time/encode_stream_test.go new file mode 100644 index 0000000..083788a --- /dev/null +++ b/time/encode_stream_test.go @@ -0,0 +1,392 @@ +package time + +import ( + "bytes" + "encoding/binary" + "errors" + "reflect" + "testing" + "time" + + "github.com/shamaton/msgpack/v2/def" + "github.com/shamaton/msgpack/v2/ext" + "github.com/shamaton/msgpack/v2/internal/common" + tu "github.com/shamaton/msgpack/v2/internal/common/testutil" +) + +func TestStreamCode(t *testing.T) { + encoder := StreamEncoder + code := encoder.Code() + tu.Equal(t, code, def.TimeStamp) +} + +func TestStreamType(t *testing.T) { + encoder := StreamEncoder + typ := encoder.Type() + expected := reflect.TypeOf(time.Time{}) + tu.Equal(t, typ, expected) +} + +func TestStreamWrite(t *testing.T) { + tests := []struct { + name string + time time.Time + expectedLen int + expectedFormat string + }{ + { + name: "Fixext4 format (32-bit timestamp, no nanoseconds)", + time: time.Unix(0, 0), + expectedLen: 6, // 1 (Fixext4) + 1 (TimeStamp) + 4 (data) + expectedFormat: "fixext4", + }, + { + name: "Fixext4 format (small timestamp, no nanoseconds)", + time: time.Unix(1000, 0), + expectedLen: 6, + expectedFormat: "fixext4", + }, + { + name: "Fixext8 format (needs 64-bit but secs fits in 34 bits)", + time: time.Unix(17179869183, 999999999), // (1 << 34) - 1 + expectedLen: 10, // 1 (Fixext8) + 1 (TimeStamp) + 8 (data) + expectedFormat: "fixext8", + }, + { + name: "Ext8 format (secs >= 2^34)", + time: time.Unix(17179869184, 123456789), // 1 << 34 + expectedLen: 15, // 1 (Ext8) + 1 (12) + 1 (TimeStamp) + 4 (nsec) + 8 (secs) + expectedFormat: "ext8", + }, + { + name: "Ext8 format (large timestamp)", + time: time.Unix(253402300799, 999999999), // 9999-12-31 23:59:59 + expectedLen: 15, + expectedFormat: "ext8", + }, + } + + encoder := StreamEncoder + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + value := reflect.ValueOf(tt.time) + buf := &bytes.Buffer{} + buffer := common.GetBuffer() + defer common.PutBuffer(buffer) + w := ext.CreateStreamWriter(buf, buffer) + + err := encoder.Write(w, value) + tu.NoError(t, err) + + // Flush buffer to writer + err = buffer.Flush(buf) + tu.NoError(t, err) + + b := buf.Bytes() + tu.Equal(t, len(b), tt.expectedLen) + + // Verify format type + switch tt.expectedFormat { + case "fixext4": + tu.Equal(t, b[0], def.Fixext4) + tu.Equal(t, int8(b[1]), def.TimeStamp) + + case "fixext8": + tu.Equal(t, b[0], def.Fixext8) + tu.Equal(t, int8(b[1]), def.TimeStamp) + + case "ext8": + tu.Equal(t, b[0], def.Ext8) + tu.Equal(t, b[1], 12) + tu.Equal(t, int8(b[2]), def.TimeStamp) + + default: + t.Errorf("Unknown expected format: %s", tt.expectedFormat) + } + }) + } +} + +func TestStreamWriteEdgeCases(t *testing.T) { + encoder := StreamEncoder + + tests := []struct { + name string + time time.Time + }{ + { + name: "Unix epoch", + time: time.Unix(0, 0), + }, + { + name: "Maximum nanoseconds", + time: time.Unix(1000, 999999999), + }, + { + name: "Boundary at 2^34 - 1", + time: time.Unix((1<<34)-1, 0), + }, + { + name: "Boundary at 2^34", + time: time.Unix(1<<34, 0), + }, + { + name: "Negative Unix timestamp", + time: time.Unix(-1, 0), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + value := reflect.ValueOf(tt.time) + buf := &bytes.Buffer{} + buffer := common.GetBuffer() + defer common.PutBuffer(buffer) + w := ext.CreateStreamWriter(buf, buffer) + + err := encoder.Write(w, value) + tu.NoError(t, err) + + // Flush buffer to writer + err = buffer.Flush(buf) + tu.NoError(t, err) + + b := buf.Bytes() + // Verify basic structure is valid + if len(b) < 2 { + t.Error("Byte slice too short") + } + }) + } +} + +func TestStreamEncodedDataAccuracy(t *testing.T) { + encoder := StreamEncoder + + tests := []struct { + name string + time time.Time + }{ + { + name: "Fixext4 - simple", + time: time.Unix(12345, 0), + }, + { + name: "Fixext8 - with nanoseconds", + time: time.Unix(67890, 123456789), + }, + { + name: "Ext8 - large", + time: time.Unix(17179869184, 987654321), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + value := reflect.ValueOf(tt.time) + buf := &bytes.Buffer{} + buffer := common.GetBuffer() + defer common.PutBuffer(buffer) + w := ext.CreateStreamWriter(buf, buffer) + + err := encoder.Write(w, value) + tu.NoError(t, err) + + // Flush buffer to writer + err = buffer.Flush(buf) + tu.NoError(t, err) + + b := buf.Bytes() + + // Verify we can extract the correct time back + switch b[0] { + case def.Fixext4: + data := binary.BigEndian.Uint32(b[2:6]) + secs := int64(data) + tu.Equal(t, secs, tt.time.Unix()) + + case def.Fixext8: + data := binary.BigEndian.Uint64(b[2:10]) + nano := int64(data >> 34) + secs := int64(data & 0x00000003ffffffff) + tu.Equal(t, secs, tt.time.Unix()) + tu.Equal(t, nano, int64(tt.time.Nanosecond())) + + case def.Ext8: + nano := binary.BigEndian.Uint32(b[3:7]) + secs := binary.BigEndian.Uint64(b[7:15]) + tu.Equal(t, int64(secs), tt.time.Unix()) + tu.Equal(t, int64(nano), int64(tt.time.Nanosecond())) + } + }) + } +} + +func TestStreamWriteWithVariousNanoseconds(t *testing.T) { + encoder := StreamEncoder + + tests := []struct { + name string + time time.Time + expectFmt byte + description string + }{ + { + name: "Zero nanoseconds", + time: time.Unix(1000, 0), + expectFmt: def.Fixext4, + description: "Should use Fixext4 when nanoseconds is 0", + }, + { + name: "Small nanoseconds (1)", + time: time.Unix(1000, 1), + expectFmt: def.Fixext8, + description: "Should use Fixext8 with nanoseconds", + }, + { + name: "Mid nanoseconds", + time: time.Unix(1000, 500000000), + expectFmt: def.Fixext8, + description: "Should use Fixext8 with mid-range nanoseconds", + }, + { + name: "Max nanoseconds", + time: time.Unix(1000, 999999999), + expectFmt: def.Fixext8, + description: "Should use Fixext8 with max nanoseconds", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + value := reflect.ValueOf(tt.time) + buf := &bytes.Buffer{} + buffer := common.GetBuffer() + defer common.PutBuffer(buffer) + w := ext.CreateStreamWriter(buf, buffer) + + err := encoder.Write(w, value) + tu.NoError(t, err) + + // Flush buffer to writer + err = buffer.Flush(buf) + tu.NoError(t, err) + + b := buf.Bytes() + tu.Equal(t, b[0], tt.expectFmt) + }) + } +} + +type testErrWriter struct { + ErrorBytes []byte + Count int +} + +func (w *testErrWriter) Write(p []byte) (n int, err error) { + if bytes.Equal(w.ErrorBytes, p) { + return 0, errors.New("equal bytes error") + } + return len(p), nil +} + +func TestStreamWriteErrors(t *testing.T) { + encoder := StreamEncoder + + ts := def.TimeStamp + + tests := []struct { + name string + timeValue time.Time + errorBytes []byte + prepareSize int + prepareBytes []byte + }{ + // Fixext4 tests + { + name: "Fixext4 - Error on writing Fixext4 type byte", + timeValue: time.Unix(1000, 0), + errorBytes: []byte{255}, + prepareSize: 1, + prepareBytes: []byte{255}, + }, + { + name: "Fixext4 - Error on writing TimeStamp type byte", + timeValue: time.Unix(1000, 0), + errorBytes: []byte{def.Fixext4}, + prepareSize: 1, + }, + { + name: "Fixext4 - Error on writing 4 bytes of data", + timeValue: time.Unix(1000, 0), + errorBytes: []byte{def.Fixext4, byte(ts)}, + prepareSize: 2, + }, + // Fixext8 tests + { + name: "Fixext8 - Error on writing Fixext8 type byte", + timeValue: time.Unix(1000, 1), + errorBytes: []byte{255}, + prepareSize: 1, + prepareBytes: []byte{255}, + }, + { + name: "Fixext8 - Error on writing TimeStamp type byte", + timeValue: time.Unix(1000, 1), + errorBytes: []byte{def.Fixext8}, + prepareSize: 1, + }, + { + name: "Fixext8 - Error on writing 8 bytes of data", + timeValue: time.Unix(1000, 1), + errorBytes: []byte{def.Fixext8, byte(ts)}, + prepareSize: 2, + }, + // Ext8 tests + { + name: "Ext8 - Error on writing Ext8 type byte", + timeValue: time.Unix(1<<34, 0), + errorBytes: []byte{255}, + prepareSize: 1, + prepareBytes: []byte{255}, + }, + { + name: "Ext8 - Error on writing length byte", + timeValue: time.Unix(1<<34, 0), + errorBytes: []byte{def.Ext8}, + prepareSize: 1, + }, + { + name: "Ext8 - Error on writing TimeStamp type byte", + timeValue: time.Unix(1<<34, 0), + errorBytes: []byte{def.Ext8, 12}, + prepareSize: 2, + }, + { + name: "Ext8 - Error on writing 4 bytes of nanoseconds", + timeValue: time.Unix(1<<34, 0), + errorBytes: []byte{def.Ext8, 12, byte(ts)}, + prepareSize: 3, + }, + { + name: "Ext8 - Error on writing 8 bytes of seconds", + timeValue: time.Unix(1<<34, 123456789), + errorBytes: []byte{def.Ext8, 12, byte(ts), 0x07, 0x5b, 0xcd, 0x15}, + prepareSize: 7, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + buffer := &common.Buffer{Data: make([]byte, tt.prepareSize)} + err := buffer.Write(nil, tt.prepareBytes...) + tu.NoError(t, err) + + errWriter := &testErrWriter{ErrorBytes: tt.errorBytes} + w := ext.CreateStreamWriter(errWriter, buffer) + value := reflect.ValueOf(tt.timeValue) + err = encoder.Write(w, value) + tu.Error(t, err) + }) + } +} diff --git a/time/encode_test.go b/time/encode_test.go new file mode 100644 index 0000000..ae1b56a --- /dev/null +++ b/time/encode_test.go @@ -0,0 +1,325 @@ +package time + +import ( + "encoding/binary" + "reflect" + "testing" + "time" + + "github.com/shamaton/msgpack/v2/def" + tu "github.com/shamaton/msgpack/v2/internal/common/testutil" +) + +func TestCode(t *testing.T) { + encoder := Encoder + code := encoder.Code() + tu.Equal(t, code, def.TimeStamp) +} + +func TestType(t *testing.T) { + encoder := Encoder + typ := encoder.Type() + expected := reflect.TypeOf(time.Time{}) + tu.Equal(t, typ, expected) +} + +func TestCalcByteSize(t *testing.T) { + encoder := Encoder + + tests := []struct { + name string + time time.Time + expected int + }{ + { + name: "Fixext4 - epoch", + time: time.Unix(0, 0), + expected: def.Byte1 + def.Byte1 + def.Byte4, // 6 + }, + { + name: "Fixext4 - small timestamp", + time: time.Unix(1000, 0), + expected: def.Byte1 + def.Byte1 + def.Byte4, // 6 + }, + { + name: "Fixext8 - with nanoseconds", + time: time.Unix(1000, 999999999), + expected: def.Byte1 + def.Byte1 + def.Byte8, // 10 + }, + { + name: "Fixext8 - boundary (2^34-1)", + time: time.Unix((1<<34)-1, 0), + expected: def.Byte1 + def.Byte1 + def.Byte8, // 10 + }, + { + name: "Ext8 - large timestamp (2^34)", + time: time.Unix(1<<34, 0), + expected: def.Byte1 + def.Byte1 + def.Byte1 + def.Byte4 + def.Byte8, // 15 + }, + { + name: "Ext8 - large timestamp with nanoseconds", + time: time.Unix(1<<34, 123456789), + expected: def.Byte1 + def.Byte1 + def.Byte1 + def.Byte4 + def.Byte8, // 15 + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + value := reflect.ValueOf(tt.time) + size, err := encoder.CalcByteSize(value) + tu.NoError(t, err) + tu.Equal(t, size, tt.expected) + }) + } +} + +func TestEncodedDataAccuracy(t *testing.T) { + encoder := Encoder + + tests := []struct { + name string + time time.Time + }{ + { + name: "Fixext4 - simple", + time: time.Unix(12345, 0), + }, + { + name: "Fixext8 - with nanoseconds", + time: time.Unix(67890, 123456789), + }, + { + name: "Ext8 - large", + time: time.Unix(17179869184, 987654321), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + value := reflect.ValueOf(tt.time) + size, err := encoder.CalcByteSize(value) + tu.NoError(t, err) + + bytes := make([]byte, size) + encoder.WriteToBytes(value, 0, &bytes) + + // Verify we can extract the correct time back + switch bytes[0] { + case def.Fixext4: + data := binary.BigEndian.Uint32(bytes[2:6]) + secs := int64(data) + tu.Equal(t, secs, tt.time.Unix()) + + case def.Fixext8: + data := binary.BigEndian.Uint64(bytes[2:10]) + nano := int64(data >> 34) + secs := int64(data & 0x00000003ffffffff) + tu.Equal(t, secs, tt.time.Unix()) + tu.Equal(t, nano, int64(tt.time.Nanosecond())) + + case def.Ext8: + nano := binary.BigEndian.Uint32(bytes[3:7]) + secs := binary.BigEndian.Uint64(bytes[7:15]) + tu.Equal(t, int64(secs), tt.time.Unix()) + tu.Equal(t, int64(nano), int64(tt.time.Nanosecond())) + } + }) + } +} + +func TestWriteToBytes(t *testing.T) { + tests := []struct { + name string + time time.Time + expectedLen int + expectedFormat string + }{ + { + name: "Fixext4 format (32-bit timestamp, no nanoseconds)", + time: time.Unix(0, 0), + expectedLen: 6, // 1 (Fixext4) + 1 (TimeStamp) + 4 (data) + expectedFormat: "fixext4", + }, + { + name: "Fixext4 format (small timestamp, no nanoseconds)", + time: time.Unix(1000, 0), + expectedLen: 6, + expectedFormat: "fixext4", + }, + { + name: "Fixext8 format (needs 64-bit but secs fits in 34 bits)", + time: time.Unix(17179869183, 999999999), // (1 << 34) - 1 + expectedLen: 10, // 1 (Fixext8) + 1 (TimeStamp) + 8 (data) + expectedFormat: "fixext8", + }, + { + name: "Ext8 format (secs >= 2^34)", + time: time.Unix(17179869184, 123456789), // 1 << 34 + expectedLen: 15, // 1 (Ext8) + 1 (12) + 1 (TimeStamp) + 4 (nsec) + 8 (secs) + expectedFormat: "ext8", + }, + { + name: "Ext8 format (large timestamp)", + time: time.Unix(253402300799, 999999999), // 9999-12-31 23:59:59 + expectedLen: 15, + expectedFormat: "ext8", + }, + } + + encoder := Encoder + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + value := reflect.ValueOf(tt.time) + + // Calculate expected byte size + size, err := encoder.CalcByteSize(value) + tu.NoError(t, err) + tu.Equal(t, size, tt.expectedLen) + + // Create byte slice + bytes := make([]byte, size) + + // Write to bytes + offset := encoder.WriteToBytes(value, 0, &bytes) + tu.Equal(t, offset, tt.expectedLen) + + // Verify format type + switch tt.expectedFormat { + case "fixext4": + tu.Equal(t, bytes[0], def.Fixext4) + tu.Equal(t, int8(bytes[1]), def.TimeStamp) + + case "fixext8": + tu.Equal(t, bytes[0], def.Fixext8) + tu.Equal(t, int8(bytes[1]), def.TimeStamp) + + case "ext8": + tu.Equal(t, bytes[0], def.Ext8) + tu.Equal(t, bytes[1], 12) + tu.Equal(t, int8(bytes[2]), def.TimeStamp) + + default: + t.Errorf("Unknown expected format: %s", tt.expectedFormat) + } + }) + } +} + +func TestWriteToBytesOffset(t *testing.T) { + encoder := Encoder + testTime := time.Unix(1000000000, 123456789) + value := reflect.ValueOf(testTime) + + size, err := encoder.CalcByteSize(value) + tu.NoError(t, err) + + // Test with non-zero offset + offset := 10 + bytes := make([]byte, offset+size) + + newOffset := encoder.WriteToBytes(value, offset, &bytes) + tu.Equal(t, newOffset, offset+size) + + // Verify the data is written at correct position + if bytes[offset] != def.Fixext4 && bytes[offset] != def.Fixext8 && bytes[offset] != def.Ext8 { + t.Errorf("Data not written at correct offset") + } +} + +func TestWriteToBytesEdgeCases(t *testing.T) { + encoder := Encoder + + tests := []struct { + name string + time time.Time + }{ + { + name: "Unix epoch", + time: time.Unix(0, 0), + }, + { + name: "Maximum nanoseconds", + time: time.Unix(1000, 999999999), + }, + { + name: "Boundary at 2^34 - 1", + time: time.Unix((1<<34)-1, 0), + }, + { + name: "Boundary at 2^34", + time: time.Unix(1<<34, 0), + }, + { + name: "Negative Unix timestamp", + time: time.Unix(-1, 0), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + value := reflect.ValueOf(tt.time) + + size, err := encoder.CalcByteSize(value) + tu.NoError(t, err) + + bytes := make([]byte, size) + offset := encoder.WriteToBytes(value, 0, &bytes) + tu.Equal(t, offset, size) + + // Verify basic structure is valid + if len(bytes) < 2 { + t.Error("Byte slice too short") + } + }) + } +} + +func TestWriteToBytesWithVariousNanoseconds(t *testing.T) { + encoder := Encoder + + tests := []struct { + name string + time time.Time + expectFmt byte + description string + }{ + { + name: "Zero nanoseconds", + time: time.Unix(1000, 0), + expectFmt: def.Fixext4, + description: "Should use Fixext4 when nanoseconds is 0", + }, + { + name: "Small nanoseconds (1)", + time: time.Unix(1000, 1), + expectFmt: def.Fixext8, + description: "Should use Fixext8 with nanoseconds", + }, + { + name: "Mid nanoseconds", + time: time.Unix(1000, 500000000), + expectFmt: def.Fixext8, + description: "Should use Fixext8 with mid-range nanoseconds", + }, + { + name: "Max nanoseconds", + time: time.Unix(1000, 999999999), + expectFmt: def.Fixext8, + description: "Should use Fixext8 with max nanoseconds", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + value := reflect.ValueOf(tt.time) + size, err := encoder.CalcByteSize(value) + tu.NoError(t, err) + + bytes := make([]byte, size) + encoder.WriteToBytes(value, 0, &bytes) + + tu.Equal(t, bytes[0], tt.expectFmt) + }) + } +} From 44fb0bc59ebd378beb1451d664985f072b543baf Mon Sep 17 00:00:00 2001 From: shamaton Date: Mon, 13 Oct 2025 22:35:39 +0900 Subject: [PATCH 3/3] add Announcement to README --- README.md | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/README.md b/README.md index 97f5b7d..93e3a5c 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,11 @@ [![codecov](https://codecov.io/gh/shamaton/msgpack/branch/master/graph/badge.svg?token=9PD2JUK5V3)](https://codecov.io/gh/shamaton/msgpack) [![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Fshamaton%2Fmsgpack.svg?type=shield)](https://app.fossa.com/projects/git%2Bgithub.com%2Fshamaton%2Fmsgpack?ref=badge_shield) +## 📣 Announcement: `time.Time` decoding defaults to **UTC** in v3 +Starting with **v3.0.0**, when decoding MessagePack **Timestamp** into Go’s `time.Time`, +the default `Location` will be **UTC** (previously `Local`). The instant is unchanged. +To keep the old behavior, use `SetDecodedTimeAsLocal()`. + ## Features * Supported types : primitive / array / slice / struct / map / interface{} and time.Time * Renaming fields via `msgpack:"field_name"` @@ -60,6 +65,51 @@ func handle(w http.ResponseWriter, r *http.Request) { } ``` +## 📣 Announcement: `time.Time` decoding defaults to **UTC** in v3 + +**TL;DR:** Starting with **v3.0.0**, when decoding MessagePack **Timestamp** into Go’s `time.Time`, the default `Location` will be **UTC** (previously `Local`). The **instant** is unchanged—only the display/location changes. This avoids host-dependent differences and aligns with common distributed systems practice. + +### What is changing? + +* **Before (v2.x):** Decoded `time.Time` defaults to `Local`. +* **After (v3.0.0):** Decoded `time.Time` defaults to **UTC**. + +MessagePack’s Timestamp encodes an **instant** (epoch seconds + nanoseconds) and does **not** carry timezone info. Your data’s point in time is the same; only `time.Time.Location()` differs. + +### Why? + +* Eliminate environment-dependent behavior (e.g., different hosts showing different local zones). +* Make “UTC by default” the safe, predictable baseline for logs, APIs, and distributed apps. + +### Who is affected? + +* Apps that **display local time** without explicitly converting from UTC. +* If your code already normalizes to UTC or explicitly sets a location, you’re likely unaffected. + +### Keep the old behavior (Local) + +If you want the v2 behavior on v3: + +```go +msgpack.SetDecodedTimeAsLocal() +``` + +Or convert after the fact: + +```go +var t time.Time +_ = msgpack.Unmarshal(data, &t) +t = t.In(time.Local) +``` + +### Preview the new behavior on v2 (optional) + +You can opt into UTC today on v2.x: + +```go +msgpack.SetDecodedTimeAsUTC() +``` + ## Benchmark This result made from [shamaton/msgpack_bench](https://github.com/shamaton/msgpack_bench)