| 1 | package release |
| 2 | |
| 3 | import ( |
| 4 | "testing" |
| 5 | "time" |
| 6 | ) |
| 7 | |
| 8 | func TestCanaryExpiredFalseForFreshPromotion(t *testing.T) { |
| 9 | p := &ChannelPointer{App: "myapp", Channel: "canary/x", Version: "v1", PromotedAt: time.Now()} |
| 10 | if CanaryExpired(p, time.Now()) { |
| 11 | t.Error("a canary promoted moments ago must not be expired") |
| 12 | } |
| 13 | } |
| 14 | |
| 15 | func TestCanaryExpiredTrueAfterTTL(t *testing.T) { |
| 16 | old := time.Now().Add(-(CanaryTTL + time.Hour)) |
| 17 | p := &ChannelPointer{App: "myapp", Channel: "canary/x", Version: "v1", PromotedAt: old} |
| 18 | if !CanaryExpired(p, time.Now()) { |
| 19 | t.Error("a canary promoted more than CanaryTTL ago must be expired") |
| 20 | } |
| 21 | } |
| 22 | |
| 23 | // Right at the boundary: exactly CanaryTTL old is NOT yet expired — the TTL |
| 24 | // names the lease's length, not a moment strictly before it. |
| 25 | func TestCanaryExpiredBoundary(t *testing.T) { |
| 26 | now := time.Now() |
| 27 | atBoundary := &ChannelPointer{Channel: "canary/x", PromotedAt: now.Add(-CanaryTTL)} |
| 28 | if CanaryExpired(atBoundary, now) { |
| 29 | t.Error("exactly CanaryTTL old must not be expired yet") |
| 30 | } |
| 31 | pastBoundary := &ChannelPointer{Channel: "canary/x", PromotedAt: now.Add(-CanaryTTL - time.Second)} |
| 32 | if !CanaryExpired(pastBoundary, now) { |
| 33 | t.Error("one second past CanaryTTL must be expired") |
| 34 | } |
| 35 | } |
| 36 | |
| 37 | // Re-promoting a canary refreshes PromotedAt, which is the whole point of |
| 38 | // keying age off the pointer's own lease timestamp: an actively-updated |
| 39 | // canary never expires, however old the channel name itself is. |
| 40 | func TestCanaryExpiredRefreshesOnRepromotion(t *testing.T) { |
| 41 | now := time.Now() |
| 42 | stale := &ChannelPointer{Channel: "canary/x", PromotedAt: now.Add(-(CanaryTTL + 24*time.Hour))} |
| 43 | if !CanaryExpired(stale, now) { |
| 44 | t.Fatal("setup: this pointer should read as expired before the repromotion") |
| 45 | } |
| 46 | refreshed := &ChannelPointer{Channel: "canary/x", PromotedAt: now} |
| 47 | if CanaryExpired(refreshed, now) { |
| 48 | t.Error("a freshly re-promoted canary must not be expired, however old the channel is") |
| 49 | } |
| 50 | } |
| 51 | |
| 52 | // Only canary/* channels carry a TTL at all — edge, beta and stable are |
| 53 | // never flagged, however old their PromotedAt is. |
| 54 | func TestCanaryExpiredFalseForNonCanaryChannels(t *testing.T) { |
| 55 | now := time.Now() |
| 56 | ancient := now.Add(-365 * 24 * time.Hour) |
| 57 | for _, ch := range []string{"stable", "beta", "edge", "canary"} { |
| 58 | p := &ChannelPointer{Channel: ch, PromotedAt: ancient} |
| 59 | if CanaryExpired(p, now) { |
| 60 | t.Errorf("channel %q must never be flagged expired (not a canary/* channel)", ch) |
| 61 | } |
| 62 | } |
| 63 | } |
| 64 | |
| 65 | // A pointer written before PromotedAt existed (or any zero value) must not |
| 66 | // be flagged expired — unknown age is not proof of age, and validatePointer |
| 67 | // already refuses a zero PromotedAt on anything read off the wire, so this |
| 68 | // is defense for a directly-constructed pointer, not a live-fleet case. |
| 69 | func TestCanaryExpiredFalseForZeroPromotedAt(t *testing.T) { |
| 70 | p := &ChannelPointer{Channel: "canary/x"} |
| 71 | if CanaryExpired(p, time.Now()) { |
| 72 | t.Error("a zero PromotedAt must not be flagged expired") |
| 73 | } |
| 74 | } |
| 75 | |
| 76 | func TestCanaryExpiredNilPointer(t *testing.T) { |
| 77 | if CanaryExpired(nil, time.Now()) { |
| 78 | t.Error("a nil pointer must not be flagged expired") |
| 79 | } |
| 80 | } |
| 81 | |
| 82 | // ---- CanaryAge / FormatCanaryAge (2026-08-03 review, IMPORTANT 2) --------- |
| 83 | |
| 84 | // CanaryAge must report time PAST the TTL, not total age — a pointer |
| 85 | // CanaryTTL+1h old has been expired for about an hour, not 7 days 1 hour. |
| 86 | func TestCanaryAgeIsTimePastTTLNotTotalAge(t *testing.T) { |
| 87 | now := time.Now() |
| 88 | p := &ChannelPointer{Channel: "canary/x", PromotedAt: now.Add(-(CanaryTTL + time.Hour))} |
| 89 | got := CanaryAge(p, now) |
| 90 | if got < 59*time.Minute || got > 61*time.Minute { |
| 91 | t.Errorf("CanaryAge = %s, want ~1h (total age minus CanaryTTL, not the 169h total age)", got) |
| 92 | } |
| 93 | } |
| 94 | |
| 95 | func TestFormatCanaryAge(t *testing.T) { |
| 96 | cases := []struct { |
| 97 | d time.Duration |
| 98 | want string |
| 99 | }{ |
| 100 | {0, "<1h"}, // the instant it expires |
| 101 | {24 * time.Minute, "<1h"}, // MINOR 7: must not read "0h" |
| 102 | {59 * time.Minute, "<1h"}, |
| 103 | {time.Hour, "1h"}, // review's own example: 7d1h old -> "1h" |
| 104 | {90 * time.Minute, "2h"}, // rounds to nearest hour |
| 105 | {23 * time.Hour, "23h"}, |
| 106 | {24 * time.Hour, "1d"}, |
| 107 | {47 * time.Hour, "1d"}, // deliberate coarseness: truncates, not rounds |
| 108 | {8 * 24 * time.Hour, "8d"}, |
| 109 | {30 * 24 * time.Hour, "30d"}, |
| 110 | } |
| 111 | for _, c := range cases { |
| 112 | if got := FormatCanaryAge(c.d); got != c.want { |
| 113 | t.Errorf("FormatCanaryAge(%s) = %q, want %q", c.d, got, c.want) |
| 114 | } |
| 115 | } |
| 116 | } |
| 117 | |