diff --git a/extensions_pages.go b/extensions_pages.go index e7329f8..df13d8e 100644 --- a/extensions_pages.go +++ b/extensions_pages.go @@ -3,6 +3,7 @@ package seo import ( "context" "sort" + "time" "git.golem15.com/golem15/sm-seo-plugin/classes" "git.golem15.com/golem15/sm-seo-plugin/controllers" @@ -105,7 +106,7 @@ func (p *Plugin) pagesForm() *pact.AdminFormExtension { return pact.AdminFormSaveResult{}, refusals } if err := in.DB.Model(&models.Page{}).Where("id = ?", page.ID).Updates(map[string]any{ - "url": page.URL, "urls": page.URLs, "title": page.Title, "titles": page.Titles, + "url": page.URL, "urls": page.URLs, "title": page.Title, "titles": page.Titles, "updated_at": time.Now().UTC(), }).Error; err != nil { return pact.AdminFormSaveResult{}, err } diff --git a/models/page.go b/models/page.go index 9fdd045..848326f 100644 --- a/models/page.go +++ b/models/page.go @@ -52,8 +52,21 @@ type Page struct { ContentUpdatedAt *lagoon.Date `gorm:"column:content_updated_at"` ReportedAt *time.Time `gorm:"column:reported_at"` Note string `gorm:"column:note"` - CreatedAt *time.Time `gorm:"column:created_at"` - UpdatedAt *time.Time `gorm:"column:updated_at"` + CreatedAt *time.Time `gorm:"column:created_at;autoCreateTime:false"` + UpdatedAt *time.Time `gorm:"column:updated_at;autoUpdateTime:false"` +} + +// BeforeSave stamps UpdatedAt (and CreatedAt of a new row) in UTC on every +// save, the admin's and the API's. GORM's own timestamps use the process's +// local time, which a timestamp column stores as the wall clock: on a +// machine east of UTC that put the page's sitemap lastmod in the future. +func (p *Page) BeforeSave(*gorm.DB) error { + now := time.Now().UTC() + if p.CreatedAt == nil || p.CreatedAt.IsZero() { + p.CreatedAt = &now + } + p.UpdatedAt = &now + return nil } func (Page) TableName() string { return "golem15_seo_pages" } diff --git a/registry_test.go b/registry_test.go index f31ad50..32231df 100644 --- a/registry_test.go +++ b/registry_test.go @@ -7,6 +7,7 @@ import ( "strings" "sync" "testing" + "time" "git.golem15.com/golem15/sm-sitemap-plugin/contract" "git.golem15.com/golem15/summercms/modules/cooler" @@ -32,6 +33,58 @@ func (e *testEnv) refusal(t *testing.T, method, path, token string, body map[str } } +// TestRegistryTimestampsUTC pins the registry page's created_at and +// updated_at in UTC on a machine east of UTC: the admin create, the +// management API's update and the admin's address change each store the +// UTC wall clock, so a page without a content date never gets a lastmod +// in the future. +func TestRegistryTimestampsUTC(t *testing.T) { + local := time.Local + time.Local = time.FixedZone("UTC+3", 3*60*60) + t.Cleanup(func() { time.Local = local }) + env := bootEnv(t) + token := env.admin(t, true) + + stamps := func(id string) (created, updated time.Time) { + t.Helper() + var row struct { + CreatedAt time.Time + UpdatedAt time.Time + } + if err := env.db.Raw(`SELECT created_at, updated_at FROM golem15_seo_pages WHERE id = ?`, id).Scan(&row).Error; err != nil { + t.Fatal(err) + } + return row.CreatedAt, row.UpdatedAt + } + within := func(what string, got, from, to time.Time) { + t.Helper() + if got.Before(from.Add(-time.Second)) || got.After(to.Add(time.Second)) { + t.Fatalf("%s = %s, want between %s and %s (UTC)", what, got.Format(time.RFC3339), from.Format(time.RFC3339), to.Format(time.RFC3339)) + } + } + + from := time.Now().UTC() + id := env.createRegistryPage(t, token, map[string]any{"url": map[string]any{"en": "/kontakt"}, "title": map[string]any{"en": "Kontakt"}}) + created, updated := stamps(id) + within("created_at after the admin create", created, from, time.Now().UTC()) + within("updated_at after the admin create", updated, from, time.Now().UTC()) + + from = time.Now().UTC() + env.expect(t, http.StatusOK, http.MethodPut, "/_seo/api/v1/pages/"+id, token, map[string]any{"title": "Kontakt z nami", "content_updated_at": ""}) + _, updated = stamps(id) + within("updated_at after the API update", updated, from, time.Now().UTC()) + reg, _ := env.app.Lookup[*signpost.Registry]() + rec, err := reg.Describe(t.Context(), "seo-page", id, "") + if err != nil || rec == nil || rec.LastMod == nil || rec.LastMod.After(time.Now().UTC().Add(time.Second)) { + t.Fatalf("describe = %+v err=%v, want a lastmod no later than now", rec, err) + } + + from = time.Now().UTC() + env.expect(t, http.StatusOK, http.MethodPut, pagesAPI+"/"+id, token, map[string]any{"url": map[string]any{"en": "/kontakt-z-nami"}}) + _, updated = stamps(id) + within("updated_at after the admin address change", updated, from, time.Now().UTC()) +} + // TestRegistry covers the page registry (D-33, UI-SPEC C21): a page made // in the Pages screen is a seo-page record with the SEO tab, resolves ok, // is listed in the sitemap file of its kind with hreflang alternates, the