my-log/models/entry_test.go
Dan Jones c4e864afa5 Add support for nested fields in log entries and JSON marshalling
This commit introduces the ability to handle nested fields within log entries.
The  file has been updated with a  function
that transforms flat keys with a : delimiter (e.g., obj:foo) into nested
JSON objects (e.g., ).

The  file includes new test cases to verify that:
- Nested fields are correctly unmarshalled from string representations.
- Nested fields are correctly marshalled into JSON objects.

This enhancement allows for more structured and organized metadata within log entries.
2026-02-10 16:13:39 -06:00

348 lines
11 KiB
Go

package models
import (
"bufio"
"encoding"
"encoding/json"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
// Type assertions
var _ encoding.TextMarshaler = Entry{}
var _ encoding.TextUnmarshaler = new(Entry)
var _ json.Marshaler = Entry{}
var _ json.Unmarshaler = new(Entry)
func TestEntryMarshal(t *testing.T) {
when := time.Now()
whens := when.Format(DateFormat)
simple := []Meta{}
nolines := []string{}
tests := []struct {
name string
title string
date time.Time
fields []Meta
first string
lines []string
err error
}{
{"no-title", "", when, simple, "", nolines, ErrorMissingTitle},
{"zero-date", "Empty title", time.Time{}, simple, "", nolines, ErrorMissingDate},
{"one-line", "A Title", when, simple, "@begin " + whens + " - A Title", []string{"@id .+ @end"}, nil},
{
"one-field",
"Title 2",
when,
[]Meta{{"age", 41}},
"@begin " + whens + " - Title 2",
[]string{"@age 41", "@id .*"},
nil,
},
{
"three-fields",
"Title 3",
when,
[]Meta{{"age", 41}, {"cool", true}, {"name", "Jim"}},
"@begin " + whens + " - Title 3",
[]string{"@age 41", "@cool true", "@name Jim"},
nil,
},
{
"json-field",
"Title J",
when,
[]Meta{{"json", json.RawMessage(`{"age": 41, "cool": true, "name": "Jim"}`)}},
"@begin " + whens + " - Title J",
[]string{"@age 41", "@cool true", "@name Jim"},
nil,
},
}
for _, tt := range tests {
t.Run(tt.name, getEntryMarshalTestRunner(tt.title, tt.date, tt.fields, tt.first, tt.lines, tt.err))
}
}
func getEntryMarshalTestRunner(title string, date time.Time, fields []Meta, first string, lines []string, err error) func(*testing.T) {
return func(t *testing.T) {
en := Entry{title, date, fields}
o, er := en.MarshalText()
assert.Equal(t, err, er)
if first == "" {
return
}
os := string(o)
if len(lines) == 0 {
assert.Equal(t, first, os)
return
}
assert.Regexp(t, first, os)
for _, line := range lines {
assert.Regexp(t, "(?m)^"+line, os)
}
}
}
func TestEntryUnmarshal(t *testing.T) {
when := time.Now()
whens := when.Format(DateFormat)
simple := []Meta{}
tests := []struct {
name string
in string
title string
date time.Time
fields []Meta
err error
}{
{"one-line", "@begin " + whens + " - A Title @end", "A Title", when, simple, nil},
{"rfc3999-date", "@begin " + when.Format(time.RFC3339) + " - A Title @end", "A Title", when, simple, nil},
{"multi-title", "@begin " + whens + " - A Title\nwith break @end", "A Title\nwith break", when, simple, nil},
{"no-title", "@begin " + whens + " - @end", "", when, simple, ErrorMissingTitle},
{"parse-error", "this is no good", "", when, simple, ErrorParsing},
{"no-date", "@begin - A Title @end", "A Title", when, simple, ErrorMissingDate},
{"bad-date", "@begin not-a-real date - A Title @end", "A Title", when, simple, ErrorParsing},
{"one-field", "@begin " + whens + " - A Title\n@age 41 @end", "A Title", when, []Meta{{"age", 41}}, nil},
{
"two-fields",
"@begin " + whens + " - A Title\n@age 41\n@cool true @end",
"A Title",
when,
[]Meta{{"age", 41}, {"cool", true}},
nil,
},
{
"obj-field",
"@begin " + whens + " - A Title\n" + `@me {"name":"Dan","coder":true} @end`,
"A Title",
when,
[]Meta{{"me", json.RawMessage(`{"name":"Dan","coder":true}`)}},
nil,
},
{
"nested-field",
"@begin " + whens + " - A Title\n@me:name Dan\n@me:coder true @end",
"A Title",
when,
[]Meta{{"me:name", "Dan"}, {"me:coder", true}},
nil,
},
{
"json-field",
"@begin " + whens + " - Some Guy\n" + `@json {"name":"Dan","coder":true} @end`,
"Some Guy",
when,
[]Meta{{"name", "Dan"}, {"coder", true}},
nil,
},
}
for _, tt := range tests {
t.Run(tt.name, getEntryUnmarshalTestRunner(tt.in, tt.title, tt.date, tt.fields, tt.err))
}
}
func getEntryUnmarshalTestRunner(in string, title string, date time.Time, fields []Meta, err error) func(*testing.T) {
return func(t *testing.T) {
e := &Entry{}
er := e.UnmarshalText([]byte(in))
if err != nil {
assert.ErrorIs(t, er, err)
return
}
assert.Equal(t, title, e.Title)
assert.WithinRange(t, e.Date, date.Add(-time.Second), date.Add(time.Second))
for _, f := range fields {
got := false
for _, m := range e.Fields {
var mVal any = m.Value
var fVal any = f.Value
if mJ, ok := m.Value.(json.RawMessage); ok {
mVal = string(mJ)
}
if fJ, ok := f.Value.(json.RawMessage); ok {
fVal = string(fJ)
}
if m.Key == f.Key && mVal == fVal {
got = true
break
}
}
assert.Truef(t, got, "Couldn't find field %+v. We have %+v", f, e.Fields)
}
}
}
func TestScan(t *testing.T) {
in := "@begin date - Title\nlong\n@foo john\njones\n@bar 42@nobody @end"
read := strings.NewReader(in)
scan := bufio.NewScanner(read)
scan.Split(scanEntry)
assert.True(t, scan.Scan())
assert.Equal(t, "@begin date - Title\nlong", scan.Text())
assert.True(t, scan.Scan())
assert.Equal(t, "@foo john\njones", scan.Text())
assert.True(t, scan.Scan())
assert.Equal(t, "@bar 42@nobody", scan.Text())
assert.False(t, scan.Scan())
}
func TestEntryJsonMarshal(t *testing.T) {
when := time.Now()
whens := when.Format(time.RFC3339)
simple := []Meta{}
tests := []struct {
name string
title string
date time.Time
fields []Meta
out string
err error
}{
{"simple", "A Title", when, simple, `{"title":"A Title","date":"` + whens + `"}`, nil},
{"one-field", "A Title 2", when, []Meta{{"age", 41}}, `{"title":"A Title 2","date":"` + whens + `","age":41}`, nil},
{"skip-title-field", "A Title", when, []Meta{{"title", "Different title"}}, `{"title":"A Title","date":"` + whens + `"}`, nil},
{"skip-date-field", "A Title", when, []Meta{{"date", when.Add(time.Hour)}}, `{"title":"A Title","date":"` + whens + `"}`, nil},
{"skip-dupe-field", "A Title", when, []Meta{{"foo", "bar"}, {"foo", "baz"}}, `{"title":"A Title","date":"` + whens + `","foo": "bar"}`, nil},
{"two-fields", "A Title", when, []Meta{{"foo", "bar"}, {"baz", 42}}, `{"title":"A Title","date":"` + whens + `","foo": "bar","baz":42}`, nil},
{"empty-title", "", when, simple, "", ErrorMissingTitle},
{"empty-date", "A Title", time.Time{}, simple, "", ErrorMissingDate},
{"obj-field", "A Title", when, []Meta{{"obj", json.RawMessage(`{"foo":"bar","title":"Sub-title"}`)}}, `{"title":"A Title","date":"` + whens + `","obj":{"foo":"bar","title":"Sub-title"}}`, nil},
{"date-field", "A Title", when, []Meta{{"when", when.Add(time.Hour)}}, `{"title":"A Title","date":"` + whens + `","when":"` + when.Add(time.Hour).Format(time.RFC3339) + `"}`, nil},
{"json-field", "A Title", when, []Meta{{"json", json.RawMessage(`{"age": 41, "cool": true, "name": "Jim"}`)}}, `{"title":"A Title","date":"` + whens + `","age":41,"cool": true, "name": "Jim"}`, nil},
{"nested-field", "A Title", when, []Meta{{"obj:foo", "bar"}, {"obj:title", "Sub-title"}}, `{"title":"A Title","date":"` + whens + `","obj":{"foo":"bar","title":"Sub-title"}}`, nil},
{"double-nested-field", "A Title", when, []Meta{{"obj:foo", "bar"}, {"obj:me:name", "Dan"}, {"obj:me:age", 27}}, `{"title":"A Title","date":"` + whens + `","obj":{"foo":"bar","me":{"name":"Dan","age":27}}}`, nil},
}
for _, tt := range tests {
t.Run(tt.name, getEntryJsonMarshalTestRunner(tt.title, tt.date, tt.fields, tt.out, tt.err))
}
}
func getEntryJsonMarshalTestRunner(title string, date time.Time, fields []Meta, out string, err error) func(t *testing.T) {
return func(t *testing.T) {
e := Entry{title, date, fields}
o, er := json.Marshal(e)
if err == nil {
assert.JSONEq(t, out, string(o))
} else {
assert.ErrorIs(t, er, err)
}
}
}
func TestEntryJsonUnmarshal(t *testing.T) {
when := time.Now().Truncate(time.Second)
whens := when.Format(time.RFC3339)
simple := []Meta{}
tests := []struct {
name string
in string
title string
date time.Time
fields []Meta
err error
}{
{"simple", `{"title":"A Title","date":"` + whens + `"}`, "A Title", when, simple, nil},
{"missing-title", `{"date":"` + whens + `"}`, "", when, simple, ErrorMissingTitle},
{"missing-date", `{"title":"A Title"}`, "", when, simple, ErrorMissingDate},
{"empty-title", `{"title":"","date":"` + whens + `"}`, "", when, simple, ErrorMissingTitle},
{"empty-date", `{"title":"A Title","date":""}`, "", when, simple, ErrorMissingDate},
{"bad-date", `{"title":"A Title","date":"bad"}`, "", when, simple, ErrorParsing},
{"bad-json", `{"title":"A Title","date":"`, "", when, simple, ErrorParsing},
{
"single-field",
`{"title":"A Title","date":"` + whens + `","hello":"Hi"}`,
"A Title",
when,
[]Meta{{"hello", "Hi"}},
nil,
},
{
"many-fields",
`{"title":"A Title","date":"` + whens + `","hello":"Hi","bye":42,"b":true,"fl":42.13}`,
"A Title",
when,
[]Meta{{"hello", "Hi"}, {"bye", int64(42)}, {"b", true}, {"fl", float64(42.13)}},
nil,
},
{
"date-field",
`{"title":"A Title","date":"` + whens + `","posted":"` + when.Add(-time.Hour).In(time.UTC).Format(time.RFC3339) + `"}`,
"A Title",
when,
[]Meta{{"posted", when.Add(-time.Hour)}},
nil,
},
{
"json-field",
`{"title":"A Title","date":"` + whens + `","json":{"age": 41, "cool": true, "name": "Jim"}}`,
"A Title",
when,
[]Meta{{"age", int64(41)}, {"cool", true}, {"name", "Jim"}},
nil,
},
{
"json-field-embed",
`{"title":"A Title","date":"` + whens + `","json":"{\"age\": 41, \"cool\": true, \"name\": \"Jim\"}"}`,
"A Title",
when,
[]Meta{{"age", int64(41)}, {"cool", true}, {"name", "Jim"}},
nil,
},
}
for _, tt := range tests {
t.Run(tt.name, getEntryJsonUnmarshalTestRunner(tt.in, tt.title, tt.date, tt.fields, tt.err))
}
}
func getEntryJsonUnmarshalTestRunner(in, title string, date time.Time, fields []Meta, err error) func(t *testing.T) {
return func(t *testing.T) {
e := new(Entry)
er := e.UnmarshalJSON([]byte(in))
if err != nil {
assert.ErrorIs(t, er, err)
return
}
assert.Nil(t, er)
assert.Equal(t, title, e.Title)
assert.WithinRange(t, e.Date, date.Add(-time.Second), date.Add(time.Second))
assert.Len(t, e.Fields, len(fields))
for _, f := range fields {
got := false
fTime, isTime := f.Value.(time.Time)
for _, m := range e.Fields {
var mVal any = m.Value
var fVal any = f.Value
if mJ, ok := m.Value.(json.RawMessage); ok {
mVal = string(mJ)
}
if fJ, ok := f.Value.(json.RawMessage); ok {
fVal = string(fJ)
}
if m.Key == f.Key && mVal == fVal {
got = true
break
}
if isTime && m.Key == f.Key {
mTime, _ := mVal.(time.Time)
if assert.WithinRange(t, mTime, fTime.Add(-2*time.Second), fTime.Add(2*time.Second)) {
got = true
break
}
}
}
assert.Truef(t, got, "Couldn't find field %+v. We have %+v", f, e.Fields)
}
}
}