fix: store registry page timestamps in UTC

- GORM stamped created_at and updated_at in the process's local time,
  which the timestamp columns keep as the wall clock; east of UTC a page
  without a content date got a sitemap lastmod in the future
- Page.BeforeSave stamps both in UTC for the admin's and the API's saves,
  and the admin's address change writes updated_at in UTC
This commit is contained in:
Jakub Zych
2026-11-04 19:24:15 +01:00
parent 49cc241f58
commit dfc85bc805
3 changed files with 70 additions and 3 deletions

View File

@@ -3,6 +3,7 @@ package seo
import ( import (
"context" "context"
"sort" "sort"
"time"
"git.golem15.com/golem15/sm-seo-plugin/classes" "git.golem15.com/golem15/sm-seo-plugin/classes"
"git.golem15.com/golem15/sm-seo-plugin/controllers" "git.golem15.com/golem15/sm-seo-plugin/controllers"
@@ -105,7 +106,7 @@ func (p *Plugin) pagesForm() *pact.AdminFormExtension {
return pact.AdminFormSaveResult{}, refusals return pact.AdminFormSaveResult{}, refusals
} }
if err := in.DB.Model(&models.Page{}).Where("id = ?", page.ID).Updates(map[string]any{ 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 { }).Error; err != nil {
return pact.AdminFormSaveResult{}, err return pact.AdminFormSaveResult{}, err
} }

View File

@@ -52,8 +52,21 @@ type Page struct {
ContentUpdatedAt *lagoon.Date `gorm:"column:content_updated_at"` ContentUpdatedAt *lagoon.Date `gorm:"column:content_updated_at"`
ReportedAt *time.Time `gorm:"column:reported_at"` ReportedAt *time.Time `gorm:"column:reported_at"`
Note string `gorm:"column:note"` Note string `gorm:"column:note"`
CreatedAt *time.Time `gorm:"column:created_at"` CreatedAt *time.Time `gorm:"column:created_at;autoCreateTime:false"`
UpdatedAt *time.Time `gorm:"column:updated_at"` 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" } func (Page) TableName() string { return "golem15_seo_pages" }

View File

@@ -7,6 +7,7 @@ import (
"strings" "strings"
"sync" "sync"
"testing" "testing"
"time"
"git.golem15.com/golem15/sm-sitemap-plugin/contract" "git.golem15.com/golem15/sm-sitemap-plugin/contract"
"git.golem15.com/golem15/summercms/modules/cooler" "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 // 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, // 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 // is listed in the sitemap file of its kind with hreflang alternates, the