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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user