package cabana import ( "context" "reflect" "testing" "time" "gorm.io/gorm" "git.golem15.com/golem15/summercms/modules/pact" "git.golem15.com/golem15/summercms/modules/towel" ) // 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) } }) } // TestRecordEventLocale (WR-02): an admin record event reports the primary // tag of the request locale, never a raw Accept-Language header. func TestRecordEventLocale(t *testing.T) { for in, want := range map[string]string{ "": "", "pl": "pl", "pl-PL,pl;q=0.9,en-US;q=0.8": "pl-PL", " en ; q=1": "en", } { ctx := context.Background() if in != "" { ctx = towel.WithLocale(ctx, in) } if got := eventLocale(ctx); got != want { t.Errorf("eventLocale(%q) = %q, want %q", in, got, want) } } }