fix(updates): tag Window JSON so the persisted maintenance window round-trips

The admin API persists the auto-update maintenance window as lowercase
JSON {"start","end"} (platform_settings key "update_window"), but
updates.Window had no json tags, so it marshaled/unmarshaled with
capitalized keys. The natural decode the update runner will use --
json.Unmarshal(stored, &updates.Window{}) -- would therefore miss every
key and silently yield the zero Window. That fails closed (a zero window
Contains nothing, so notify-only, never a rogue apply), so it is safe but
a latent silent-zero trap for the not-yet-built runner.

Add json:"start"/json:"end" to updates.Window so the obvious decode is
correct by construction; value time.Time treats a stored null as a no-op,
so a cleared/never-set window still decodes to the zero Window. Nothing
in the package serialized Window before, so this changes no existing
behavior.

Guarded by a cross-package contract test in internal/api that marshals
the real api.updateWindow DTO and unmarshals it into updates.Window --
asserting the interval survives (Contains(mid) is true) and that an empty
window decodes to the fail-closed zero Window -- so the two shapes cannot
drift apart silently.
This commit is contained in:
flyemoji committed 2026-07-01 18:26:42 +09:00
1 parent 3673af63c2
commit 7464fa700b
2 files changed
+65 -2

No files matched your search

@@ -0,0 +1,58 @@
package api
import (
"encoding/json"
"testing"
"time"
"felis.lolicon.best/internal/updates"
)
// TestUpdateWindowStorageShapeDecodesIntoCoreWindow is a cross-package contract
// guard. The maintenance window THIS package persists (api.updateWindow, lowercase
// {"start","end"}) must decode straight into the update decision core's
// updates.Window — because the (INTEGRATION-ONLY) `felis update` runner reads the
// stored bytes back into a Window. The test marshals the REAL api DTO rather than a
// hand-written JSON literal (which would drift silently if either shape changed) and
// unmarshals into updates.Window, proving the on-disk bytes the admin API writes are
// exactly what the runner will read back — no silent zero-window from a key-casing
// mismatch. updates.Window carries json:"start"/json:"end" tags precisely so this
// holds; without them the natural Unmarshal would zero every field.
func TestUpdateWindowStorageShapeDecodesIntoCoreWindow(t *testing.T) {
start := time.Date(2026, 8, 1, 2, 0, 0, 0, time.UTC)
end := time.Date(2026, 8, 1, 4, 0, 0, 0, time.UTC)
raw, err := json.Marshal(updateWindow{Start: &start, End: &end})
if err != nil {
t.Fatalf("marshal api window: %v", err)
}
var win updates.Window
if err := json.Unmarshal(raw, &win); err != nil {
t.Fatalf("unmarshal into updates.Window: %v", err)
}
if !win.Start.Equal(start) || !win.End.Equal(end) {
t.Fatalf("decoded window = {%s,%s}, want {%s,%s}", win.Start, win.End, start, end)
}
mid := time.Date(2026, 8, 1, 3, 0, 0, 0, time.UTC)
if !win.Contains(mid) {
t.Fatalf("Contains(%s) = false, want true — the window did not survive the round-trip", mid)
}
// A cleared/never-set window ({null,null}) must decode to the zero Window, which
// fails closed (Contains always false) — never a spurious open apply slot.
rawEmpty, err := json.Marshal(updateWindow{})
if err != nil {
t.Fatalf("marshal empty api window: %v", err)
}
var empty updates.Window
if err := json.Unmarshal(rawEmpty, &empty); err != nil {
t.Fatalf("unmarshal empty into updates.Window: %v", err)
}
if !empty.Start.IsZero() || !empty.End.IsZero() {
t.Fatalf("empty window decoded to non-zero {%s,%s}", empty.Start, empty.End)
}
if empty.Contains(mid) {
t.Fatal("zero window Contains returned true, want false (must fail closed)")
}
}
+7 -2
View File
@@ -34,9 +34,14 @@ const (
// on top. A zero Window (both ends zero) is "unset" and Contains always returns
// false, so a Scheduled component with no window set can never auto-apply — it holds
// at notify until a human actually schedules a time.
// The json tags are load-bearing across a package boundary: the maintenance window
// is persisted by internal/api (platform_settings key "update_window") as lowercase
// {"start","end"}, and the update runner reads those bytes back into a Window. With
// value (not pointer) time.Time, a stored null unmarshals as a no-op, so a cleared
// or never-set window decodes to the zero Window — Contains false, fails closed.
type Window struct {
Start time.Time
End time.Time
Start time.Time `json:"start"`
End time.Time `json:"end"`
}
// Contains reports whether now is inside the window. An unset (zero) or inverted