- form extensions: placement across three plugins, required plugins' tabs and fields, Hide with neighbours and whole tabs, spans, grouping only of extended forms; every remaining boot error with plugin, controller and offending name - record events: the pure snapshot/diff (create, update, pointers, times as instants, timestamps and key omitted, per-locale translations) and the value helpers - descriptors: MatchPattern edge cases, MatchPath ordering, types without Content, duplicate Model/Controller across plugins
270 lines
9.0 KiB
Go
270 lines
9.0 KiB
Go
package cabana
|
|
|
|
import (
|
|
"context"
|
|
"reflect"
|
|
"testing"
|
|
"time"
|
|
|
|
"gorm.io/gorm"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/pact"
|
|
)
|
|
|
|
// The D-92 record-event diff without a database: the snapshot taken before
|
|
// a save, the changes computed after it, and the value rules. The Postgres
|
|
// suite (record_events_postgres_test.go) covers every admin write path.
|
|
|
|
// rdArticle has a column of each kind a change reports.
|
|
type rdArticle struct {
|
|
ID uint `gorm:"column:id;primaryKey"`
|
|
Title string `gorm:"column:title"`
|
|
Views int `gorm:"column:views"`
|
|
Note *string `gorm:"column:note"`
|
|
Published bool `gorm:"column:published"`
|
|
PublishedAt *time.Time `gorm:"column:published_at"`
|
|
Body []byte `gorm:"column:body"`
|
|
Ignored string `gorm:"-"`
|
|
CreatedAt time.Time
|
|
UpdatedAt time.Time
|
|
}
|
|
|
|
// rdWriter is a translation writer with en as the default locale and the
|
|
// stored non-default values in stored (field -> locale -> value).
|
|
type rdWriter struct {
|
|
stored map[string]map[string]string
|
|
calls int
|
|
}
|
|
|
|
func (w *rdWriter) DefaultLocale(context.Context, *gorm.DB) (string, error) { return "en", nil }
|
|
func (w *rdWriter) EnabledLocales(context.Context, *gorm.DB) ([]string, error) {
|
|
return []string{"en", "pl", "de"}, nil
|
|
}
|
|
func (w *rdWriter) WriteTranslated(context.Context, *gorm.DB, any, string, string, string) error {
|
|
return nil
|
|
}
|
|
func (w *rdWriter) TranslatedExact(_ context.Context, _ *gorm.DB, _ any, field, locale string) (string, bool, error) {
|
|
w.calls++
|
|
v, ok := w.stored[field][locale]
|
|
return v, ok, nil
|
|
}
|
|
|
|
func rdStr(s string) *string { return &s }
|
|
|
|
func TestRecordEventDiff(t *testing.T) {
|
|
db, _ := openDryRunDB(t)
|
|
ctx := context.Background()
|
|
at := time.Date(2026, 10, 27, 12, 0, 0, 0, time.UTC)
|
|
|
|
cases := []struct {
|
|
name string
|
|
before *rdArticle // nil: a create
|
|
after rdArticle
|
|
want []pact.RecordChange
|
|
}{
|
|
{"create lists the non-zero columns with a nil Old",
|
|
nil,
|
|
rdArticle{ID: 7, Title: "Hello", Note: rdStr("n"), CreatedAt: at, UpdatedAt: at},
|
|
[]pact.RecordChange{{Attribute: "title", New: "Hello"}, {Attribute: "note", New: "n"}}},
|
|
{"create of a zero record lists nothing",
|
|
nil,
|
|
rdArticle{ID: 7},
|
|
[]pact.RecordChange{}},
|
|
{"update reports a changed scalar column",
|
|
&rdArticle{ID: 7, Title: "Old", Views: 3},
|
|
rdArticle{ID: 7, Title: "New", Views: 3},
|
|
[]pact.RecordChange{{Attribute: "title", Old: "Old", New: "New"}}},
|
|
{"unchanged columns, the primary key and GORM timestamps are omitted",
|
|
&rdArticle{ID: 7, Title: "Same", CreatedAt: at, UpdatedAt: at},
|
|
rdArticle{ID: 7, Title: "Same", CreatedAt: at, UpdatedAt: at.Add(time.Hour)},
|
|
[]pact.RecordChange{}},
|
|
{"nil to value dereferences the pointer",
|
|
&rdArticle{ID: 7},
|
|
rdArticle{ID: 7, Note: rdStr("now set")},
|
|
[]pact.RecordChange{{Attribute: "note", Old: nil, New: "now set"}}},
|
|
{"value to nil",
|
|
&rdArticle{ID: 7, Note: rdStr("gone")},
|
|
rdArticle{ID: 7},
|
|
[]pact.RecordChange{{Attribute: "note", Old: "gone", New: nil}}},
|
|
{"times compare as instants",
|
|
&rdArticle{ID: 7, PublishedAt: &at},
|
|
rdArticle{ID: 7, PublishedAt: func() *time.Time { v := at.In(time.FixedZone("CET", 3600)); return &v }()},
|
|
[]pact.RecordChange{}},
|
|
{"a moved time is a change",
|
|
&rdArticle{ID: 7, PublishedAt: &at},
|
|
rdArticle{ID: 7, PublishedAt: func() *time.Time { v := at.Add(time.Minute); return &v }()},
|
|
[]pact.RecordChange{{Attribute: "published_at", Old: at, New: at.Add(time.Minute)}}},
|
|
{"several columns in declaration order",
|
|
&rdArticle{ID: 7, Title: "a", Views: 1, Published: false},
|
|
rdArticle{ID: 7, Title: "b", Views: 2, Published: true},
|
|
[]pact.RecordChange{{Attribute: "title", Old: "a", New: "b"}, {Attribute: "views", Old: 1, New: 2}, {Attribute: "published", Old: false, New: true}}},
|
|
{"byte columns compare by content",
|
|
&rdArticle{ID: 7, Body: []byte("x")},
|
|
rdArticle{ID: 7, Body: []byte("x")},
|
|
[]pact.RecordChange{}},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
var snap *recordSnapshot
|
|
if tc.before != nil {
|
|
var err error
|
|
snap, err = takeRecordSnapshot(ctx, db, nil, tc.before, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
after := tc.after
|
|
got, err := recordChanges(ctx, db, nil, &after, snap, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !reflect.DeepEqual(got, tc.want) {
|
|
t.Fatalf("changes = %#v\nwant %#v", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
|
|
t.Run("translated values per locale", func(t *testing.T) {
|
|
writer := &rdWriter{stored: map[string]map[string]string{"title": {"pl": "Stary", "de": "Gleich"}}}
|
|
submitted := []mlValue{{field: "title", values: map[string]string{"en": "New", "pl": "Nowy", "de": "Gleich", "fr": "Nouveau"}}}
|
|
before := &rdArticle{ID: 7, Title: "Old"}
|
|
snap, err := takeRecordSnapshot(ctx, db, writer, before, submitted)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, asked := snap.translations["title"]["en"]; asked {
|
|
t.Fatal("the snapshot read the default locale from the translation store")
|
|
}
|
|
after := &rdArticle{ID: 7, Title: "New"}
|
|
got, err := recordChanges(ctx, db, writer, after, snap, submitted)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want := []pact.RecordChange{
|
|
{Attribute: "title", Old: "Old", New: "New"},
|
|
{Attribute: "title", Locale: "fr", Old: nil, New: "Nouveau"},
|
|
{Attribute: "title", Locale: "pl", Old: "Stary", New: "Nowy"},
|
|
}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("changes = %#v\nwant %#v", got, want)
|
|
}
|
|
})
|
|
|
|
t.Run("translated values on create", func(t *testing.T) {
|
|
writer := &rdWriter{}
|
|
submitted := []mlValue{{field: "title", values: map[string]string{"en": "Hello", "pl": "Cześć", "de": ""}}}
|
|
got, err := recordChanges(ctx, db, writer, &rdArticle{ID: 7, Title: "Hello"}, nil, submitted)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want := []pact.RecordChange{
|
|
{Attribute: "title", New: "Hello"},
|
|
{Attribute: "title", Locale: "pl", New: "Cześć"},
|
|
}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("changes = %#v\nwant %#v", got, want)
|
|
}
|
|
})
|
|
|
|
t.Run("no translation store is asked without submitted translations", func(t *testing.T) {
|
|
writer := &rdWriter{}
|
|
snap, err := takeRecordSnapshot(ctx, db, writer, &rdArticle{ID: 7}, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := recordChanges(ctx, db, writer, &rdArticle{ID: 7}, snap, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if writer.calls != 0 {
|
|
t.Fatalf("TranslatedExact called %d times", writer.calls)
|
|
}
|
|
})
|
|
|
|
t.Run("a model GORM cannot parse reports nothing", func(t *testing.T) {
|
|
snap, err := takeRecordSnapshot(ctx, db, nil, 42, nil)
|
|
if err != nil || len(snap.columns) != 0 {
|
|
t.Fatalf("snapshot = %+v, %v", snap, err)
|
|
}
|
|
got, err := recordChanges(ctx, db, nil, 42, snap, nil)
|
|
if err != nil || len(got) != 0 {
|
|
t.Fatalf("changes = %+v, %v", got, err)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestRecordEventDiffValues(t *testing.T) {
|
|
s := "x"
|
|
var nilStr *string
|
|
slice := []string{"a"}
|
|
m := map[string]int{"a": 1}
|
|
for _, tc := range []struct {
|
|
name string
|
|
in any
|
|
want any
|
|
}{
|
|
{"nil", nil, nil},
|
|
{"a value", 3, 3},
|
|
{"a pointer is dereferenced", &s, "x"},
|
|
{"a nil pointer is nil", nilStr, nil},
|
|
{"a nil slice is nil", []string(nil), nil},
|
|
{"a nil map is nil", map[string]int(nil), nil},
|
|
{"a slice is copied", slice, []string{"a"}},
|
|
{"a map is copied", m, map[string]int{"a": 1}},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if got := changeValue(tc.in); !reflect.DeepEqual(got, tc.want) {
|
|
t.Fatalf("changeValue(%#v) = %#v, want %#v", tc.in, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
|
|
t.Run("a copied slice and map do not follow later writes", func(t *testing.T) {
|
|
gotSlice := changeValue(slice).([]string)
|
|
gotMap := changeValue(m).(map[string]int)
|
|
slice[0], m["a"] = "changed", 2
|
|
if gotSlice[0] != "a" || gotMap["a"] != 1 {
|
|
t.Fatalf("copies followed the model: %v %v", gotSlice, gotMap)
|
|
}
|
|
})
|
|
|
|
at := time.Date(2026, 10, 27, 12, 0, 0, 0, time.UTC)
|
|
for _, tc := range []struct {
|
|
name string
|
|
a, b any
|
|
same bool
|
|
}{
|
|
{"equal strings", "a", "a", true},
|
|
{"different strings", "a", "b", false},
|
|
{"the same instant in two zones", at, at.In(time.FixedZone("X", 7200)), true},
|
|
{"two instants", at, at.Add(time.Second), false},
|
|
{"nil and nil", nil, nil, true},
|
|
{"nil and a value", nil, "", false},
|
|
{"int and int64 differ", 1, int64(1), false},
|
|
} {
|
|
t.Run("same "+tc.name, func(t *testing.T) {
|
|
if got := sameChangeValue(tc.a, tc.b); got != tc.same {
|
|
t.Fatalf("sameChangeValue(%#v, %#v) = %v", tc.a, tc.b, got)
|
|
}
|
|
})
|
|
}
|
|
|
|
t.Run("columnValue converts to the column type", func(t *testing.T) {
|
|
type tree struct {
|
|
ParentID *uint `gorm:"column:parent_id"`
|
|
SortOrder int `gorm:"column:sort_order"`
|
|
}
|
|
if got := columnValue(&tree{}, "parent_id", float64(4)); got != uint(4) {
|
|
t.Fatalf("parent_id = %#v", got)
|
|
}
|
|
if got := columnValue(&tree{}, "sort_order", int64(2)); got != 2 {
|
|
t.Fatalf("sort_order = %#v", got)
|
|
}
|
|
if got := columnValue(&tree{}, "missing", "v"); got != "v" {
|
|
t.Fatalf("missing column = %#v", got)
|
|
}
|
|
if got := columnValue(&tree{}, "sort_order", nil); got != nil {
|
|
t.Fatalf("nil = %#v", got)
|
|
}
|
|
})
|
|
}
|