From 20129d1dae2b438c4ca36391cb885eef6e1c21fb Mon Sep 17 00:00:00 2001 From: Vishnu Jayavel Date: Fri, 10 Jul 2026 22:17:20 -0700 Subject: [PATCH 1/3] test(feeds): pin calendar empty-200 + truncation behavior (hw-rnqs) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scout hw-xthx blind spot #8 found two untested calendar-producer edge cases: a genuine HTTP 200 with items:[] overwrites the cached last-good result (TUI renders Free while busy, and a later failing poll can only serve the empty envelope — the good cache is unrecoverable), and the events request is capped at MaxResults(20) with NextPageToken never followed, silently truncating busy calendars to the first 20 events. This adds test-only coverage that PINS both behaviors as-is (pinned-pending-policy): the accept-vs-quarantine call for empty-200 responses and the paginate-vs-cap call for truncation are product decisions deferred to Vishnu. The tests document this in their names and comments so nobody "fixes" production code without that call, and they fail loudly if a deliberate policy change lands so they get updated alongside it. The new pinnedCalendarMock emulates the Google API's maxResults semantics (returns at most maxResults items, sets nextPageToken when more remain) so the truncation pin exercises the real request shape: it asserts maxResults=20 is sent, exactly one events call is made, and no request ever carries a pageToken. Zero production changes. Validation: GOMEMLIMIT=4GiB go test -race -p 2 ./internal/feeds/ green (both new tests plus the full existing package). --- .../feeds/producer_calendar_pinned_test.go | 230 ++++++++++++++++++ 1 file changed, 230 insertions(+) create mode 100644 internal/feeds/producer_calendar_pinned_test.go diff --git a/internal/feeds/producer_calendar_pinned_test.go b/internal/feeds/producer_calendar_pinned_test.go new file mode 100644 index 0000000..092f2d4 --- /dev/null +++ b/internal/feeds/producer_calendar_pinned_test.go @@ -0,0 +1,230 @@ +package feeds + +// Pinned-behavior tests (scout hw-xthx blind spot #8, bead hw-rnqs). +// +// These tests PIN the calendar producer's CURRENT behavior for two edge +// cases. They are NOT an endorsement of that behavior — whether each case +// should be accepted or quarantined is a product/policy decision that has +// been explicitly deferred (pinned-pending-policy). If a deliberate policy +// change later alters either behavior, update these tests alongside it. +// +// 1. A genuine HTTP 200 with items:[] is treated as a SUCCESS and +// overwrites the cached last-good result. A transient upstream glitch +// that returns an empty-but-200 body therefore makes the TUI render +// "Free" while the user is actually busy, and destroys the good cache +// that the fail-open fallback would otherwise have kept serving. +// 2. The events request is capped at MaxResults(20) and NextPageToken is +// never followed, so a calendar with more than 20 events in the +// lookahead window is silently truncated to the first 20. + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/vishnujayvel/hookwise/internal/core" +) + +// pinnedCalendarMock emulates the Google Calendar API closely enough to pin +// truncation semantics: it holds a pool of totalEvents events, honors the +// maxResults query param the way the real API does (returns at most +// maxResults items and sets nextPageToken when more remain), and can be +// flipped to return a genuine 200 with items:[]. All mutable state is +// mutex-guarded because httptest handlers run on server goroutines. +type pinnedCalendarMock struct { + mu sync.Mutex + eventCalls int + lastMaxResults string // maxResults query param seen on the last events call + pageTokenSeen bool // true if any events call carried a pageToken param + emptyItems bool // when true, events endpoint returns 200 with items:[] + totalEvents int // size of the emulated busy calendar +} + +func (m *pinnedCalendarMock) setEmptyItems(v bool) { + m.mu.Lock() + defer m.mu.Unlock() + m.emptyItems = v +} + +func (m *pinnedCalendarMock) snapshot() (eventCalls int, lastMaxResults string, pageTokenSeen bool) { + m.mu.Lock() + defer m.mu.Unlock() + return m.eventCalls, m.lastMaxResults, m.pageTokenSeen +} + +func (m *pinnedCalendarMock) ServeHTTP(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/token"): + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"access_token":"pinned-fresh-token","token_type":"Bearer","expires_in":3600}`) + + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/calendars/"): + m.mu.Lock() + m.eventCalls++ + m.lastMaxResults = r.URL.Query().Get("maxResults") + if r.URL.Query().Get("pageToken") != "" { + m.pageTokenSeen = true + } + empty := m.emptyItems + total := m.totalEvents + m.mu.Unlock() + + resp := map[string]interface{}{ + "kind": "calendar#events", + "items": []interface{}{}, + } + if !empty { + maxResults := total + if s := r.URL.Query().Get("maxResults"); s != "" { + if n, err := strconv.Atoi(s); err == nil && n < maxResults { + maxResults = n + } + } + items := make([]interface{}, 0, maxResults) + now := time.Now().UTC() + for i := 0; i < maxResults; i++ { + start := now.Add(time.Duration(i+1) * time.Minute).Format(time.RFC3339) + end := now.Add(time.Duration(i+6) * time.Minute).Format(time.RFC3339) + items = append(items, map[string]interface{}{ + "kind": "calendar#event", + "summary": fmt.Sprintf("Pinned Event %d", i+1), + "start": map[string]interface{}{"dateTime": start}, + "end": map[string]interface{}{"dateTime": end}, + }) + } + resp["items"] = items + if maxResults < total { + resp["nextPageToken"] = "pinned-next-page" + } + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) //nolint:errcheck + + default: + http.NotFound(w, r) + } +} + +func newPinnedCalendarProducer(t *testing.T, srvURL string) *CalendarProducer { + t.Helper() + tokenPath := writeTempTokenFile(t, srvURL+"/token") + p := newCalendarProducerForTest(srvURL + "/") + p.SetFeedsConfig(core.FeedsConfig{ + Calendar: core.CalendarFeedConfig{ + Enabled: true, + TokenPath: tokenPath, + Calendars: []string{"primary"}, + LookaheadMinutes: 120, + }, + }) + return p +} + +func calendarEvents(t *testing.T, result interface{}) []interface{} { + t.Helper() + env, ok := result.(map[string]interface{}) + require.True(t, ok, "result must be map[string]interface{}") + data, ok := env["data"].(map[string]interface{}) + require.True(t, ok, "envelope data must be a map") + events, ok := data["events"].([]interface{}) + require.True(t, ok, "data.events must be []interface{}") + return events +} + +// TestCalendarProducer_Pinned_Genuine200EmptyItems_OverwritesGoodCache pins +// CURRENT behavior (pinned-pending-policy, hw-rnqs): a genuine HTTP 200 whose +// body has items:[] is indistinguishable from "the calendar really is free", +// so the producer accepts it as a success and OVERWRITES the cached last-good +// result. Consequences pinned here: +// +// - the poll immediately renders zero events ("Free") even if the previous +// poll saw real events, and +// - a subsequent FAILING poll serves the empty envelope from cache — the +// good data is gone, the fail-open fallback cannot resurrect it. +// +// Whether an empty-200 should instead be quarantined (e.g. kept out of the +// cache until confirmed by a second poll) is a policy decision deferred to +// Vishnu. Do NOT "fix" this by changing production code without that call. +func TestCalendarProducer_Pinned_Genuine200EmptyItems_OverwritesGoodCache(t *testing.T) { + mock := &pinnedCalendarMock{totalEvents: 1} + srv := httptest.NewServer(mock) + + p := newPinnedCalendarProducer(t, srv.URL) + + // Poll 1: success with one real event — this is the "good cache". + result1, err := p.Produce(context.Background()) + require.NoError(t, err) + require.Len(t, calendarEvents(t, result1), 1, "poll 1 must cache one real event") + + // Poll 2: genuine 200 with items:[]. Current behavior: accepted as + // success, cache overwritten, renders as "Free". + mock.setEmptyItems(true) + result2, err := p.Produce(context.Background()) + require.NoError(t, err, "ARCH-1: empty-200 poll must not error") + env2 := result2.(map[string]interface{}) + assert.Empty(t, calendarEvents(t, result2), + "PINNED: a genuine 200 with items:[] replaces the good result with an empty one") + assert.Nil(t, env2["data"].(map[string]interface{})["next_event"], + "PINNED: empty-200 poll clears next_event") + + // Poll 3: the API dies (transient network failure). The fail-open + // fallback serves the cached envelope — which is now the EMPTY one from + // poll 2, not the good one from poll 1. This is the destructive part of + // the overwrite: fallback can only serve what the empty-200 left behind. + srv.Close() + result3, err := p.Produce(context.Background()) + require.NoError(t, err, "ARCH-1: failing poll must not error") + assert.Empty(t, calendarEvents(t, result3), + "PINNED: after an empty-200, the fallback cache holds zero events — poll 1's good data is unrecoverable") + assert.Equal(t, env2["timestamp"], result3.(map[string]interface{})["timestamp"], + "fallback must serve poll 2's envelope verbatim (frozen timestamp)") +} + +// TestCalendarProducer_Pinned_BusyCalendarTruncatedAt20_NoPagination pins +// CURRENT behavior (pinned-pending-policy, hw-rnqs): the events request is +// issued with maxResults=20 and the response's nextPageToken is never +// followed. Against an emulated busy calendar with 25 events in the +// lookahead window, the envelope silently contains only the first 20 — +// events 21+ are invisible to the TUI, including a potential next_event. +// +// Whether the producer should paginate (or raise MaxResults) is a policy +// decision deferred to Vishnu. Do NOT "fix" this by changing production code +// without that call. This test fails if someone adds pagination or changes +// the MaxResults cap — update it alongside that deliberate change. +func TestCalendarProducer_Pinned_BusyCalendarTruncatedAt20_NoPagination(t *testing.T) { + mock := &pinnedCalendarMock{totalEvents: 25} + srv := httptest.NewServer(mock) + defer srv.Close() + + p := newPinnedCalendarProducer(t, srv.URL) + + result, err := p.Produce(context.Background()) + require.NoError(t, err) + + events := calendarEvents(t, result) + assert.Len(t, events, 20, + "PINNED: 25 upcoming events must be truncated to exactly 20 (MaxResults cap)") + // The truncation keeps the FIRST 20 by start time; event 21+ never appears. + first := events[0].(map[string]interface{}) + last := events[19].(map[string]interface{}) + assert.Equal(t, "Pinned Event 1", first["name"]) + assert.Equal(t, "Pinned Event 20", last["name"]) + + eventCalls, lastMaxResults, pageTokenSeen := mock.snapshot() + assert.Equal(t, "20", lastMaxResults, + "PINNED: the request must carry maxResults=20") + assert.Equal(t, 1, eventCalls, + "PINNED: exactly one events call — nextPageToken must NOT be followed") + assert.False(t, pageTokenSeen, + "PINNED: no request may carry a pageToken param (no pagination)") +} From 6d01a01e4f43792af2cf5c9900a8ce1b37ee4e13 Mon Sep 17 00:00:00 2001 From: Vishnu Jayavel Date: Fri, 10 Jul 2026 22:19:59 -0700 Subject: [PATCH 2/3] test(calendar): pin non-UTC offsets and malformed-date drops (hw-8d9k) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scout hw-xthx blind spot #7: every calendar test used uniform time.UTC, so parseGoogleEventTime's offset handling and its malformed-date silent-drop path (producer_calendar.go zero-time fallthrough) had no coverage. Google delivers event times in the calendar's own timezone (e.g. +05:30), so mixed-offset inputs are the production-normal case. Adds test-only fixtures across the three layers the bead names: (a) parseGoogleEventTime unit table — +05:30/-08:00/Z offsets must parse to the correct instant with the source offset preserved; malformed dateTime/date values (garbage, non-RFC3339, out-of-range, Feb 30) must yield zero-time without claiming all-day. (b) Producer end-to-end with +05:30 events against UTC-now: an in-progress offset event is flagged is_current, the upcoming one wins next_event, and formatted times keep the source offset. Malformed events stay in the list with empty start/end, are never current, and are skipped for next_event (nil when nothing valid remains) — pinning the ARCH-1 silent-drop behavior explicitly. (c) calendarRelativeTime mixed-offset table: durations are instant-based; tomorrow/weekday labels show the event's own wall clock. Cases document current behavior, no production changes. Validation: GOMEMLIMIT=4GiB go test -race -p 2 on internal/feeds and cmd/hookwise — all green, gofmt/vet clean on both touched files. --- cmd/hookwise/cmd_status_line_calendar_test.go | 64 +++++ internal/feeds/producer_calendar_test.go | 264 +++++++++++++++++- 2 files changed, 325 insertions(+), 3 deletions(-) diff --git a/cmd/hookwise/cmd_status_line_calendar_test.go b/cmd/hookwise/cmd_status_line_calendar_test.go index 65380fb..641efd4 100644 --- a/cmd/hookwise/cmd_status_line_calendar_test.go +++ b/cmd/hookwise/cmd_status_line_calendar_test.go @@ -98,3 +98,67 @@ func TestCalendarRelativeTime(t *testing.T) { }) } } + +// TestCalendarRelativeTime_NonUTCOffsets pins label behavior when now and the +// event carry different UTC offsets (hw-8d9k). Google Calendar delivers event +// times in the calendar's own timezone (e.g. +05:30), while render-time "now" +// is the machine's local clock, so mixed-offset inputs are the normal case. +// Durations ("in 30m") are instant-based and must ignore offsets entirely; +// the day labels ("tomorrow 9:00am", "Fri 2:30pm") show the EVENT's own wall +// clock while the tomorrow/weekday split is computed from each side's own +// calendar day — these cases document that current behavior. +func TestCalendarRelativeTime_NonUTCOffsets(t *testing.T) { + ist := time.FixedZone("UTC+05:30", 5*3600+30*60) + pst := time.FixedZone("UTC-08:00", -8*3600) + + tests := []struct { + name string + now time.Time + event time.Time + want string + }{ + { + name: "offset event minutes away from UTC now", + now: time.Date(2026, 6, 15, 10, 0, 0, 0, time.UTC), + event: time.Date(2026, 6, 15, 16, 0, 0, 0, ist), // 10:30 UTC + want: "in 30m", + }, + { + name: "same instant across zones is now", + now: time.Date(2026, 6, 15, 10, 0, 0, 0, time.UTC), + event: time.Date(2026, 6, 15, 15, 30, 0, 0, ist), // 10:00 UTC + want: "now", + }, + { + name: "non-UTC now vs offset event counts real hours", + now: time.Date(2026, 6, 15, 2, 0, 0, 0, pst), // 10:00 UTC + event: time.Date(2026, 6, 15, 19, 0, 0, 0, ist), // 13:30 UTC + want: "in 3h 30m", + }, + { + name: "countdown crossing midnight in the event zone", + now: time.Date(2026, 6, 15, 16, 0, 0, 0, pst), // 2026-06-16 00:00 UTC + event: time.Date(2026, 6, 16, 11, 0, 0, 0, ist), // 2026-06-16 05:30 UTC + want: "in 5h 30m", + }, + { + name: "tomorrow label shows the event's own wall clock", + now: time.Date(2026, 6, 15, 10, 0, 0, 0, time.UTC), + event: time.Date(2026, 6, 17, 9, 0, 0, 0, ist), // 2026-06-16 03:30 UTC, 41.5h out + want: "tomorrow 9:00am", + }, + { + // 2026-06-15 is a Monday; the event instant is Friday in both zones. + name: "weekday label shows the event's own wall clock", + now: time.Date(2026, 6, 15, 10, 0, 0, 0, time.UTC), + event: time.Date(2026, 6, 19, 14, 30, 0, 0, ist), // 2026-06-19 09:00 UTC + want: "Fri 2:30pm", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, calendarRelativeTime(tc.now, tc.event)) + }) + } +} diff --git a/internal/feeds/producer_calendar_test.go b/internal/feeds/producer_calendar_test.go index e3bb4dd..a8da5a4 100644 --- a/internal/feeds/producer_calendar_test.go +++ b/internal/feeds/producer_calendar_test.go @@ -17,6 +17,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/oauth2" + "google.golang.org/api/calendar/v3" "google.golang.org/api/googleapi" "github.com/vishnujayvel/hookwise/internal/core" @@ -75,8 +76,8 @@ func TestWriteBackToken_AtomicAndSecure(t *testing.T) { // POST /token → fresh access token JSON (forces refresh when expiry is past) // GET /calendars/*/events → minimal events.list response with one event type calendarMockServer struct { - tokenCalls int // counts how many token refresh calls were made - eventCalls int // counts how many events list calls were made + tokenCalls int // counts how many token refresh calls were made + eventCalls int // counts how many events list calls were made eventSummary string // tokenError, when non-empty, makes POST /token fail with HTTP 400 and // this RFC 6749 error code (e.g. "invalid_grant" = revoked refresh token). @@ -109,7 +110,7 @@ func (m *calendarMockServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { summary = "Mock Standup" } resp := map[string]interface{}{ - "kind": "calendar#events", + "kind": "calendar#events", "items": []interface{}{ map[string]interface{}{ "kind": "calendar#event", @@ -570,3 +571,260 @@ func TestCalendarProducer_NoPriorSuccess_EmptyFallbackTimestampFrozen(t *testing "empty fallback envelope must not be re-stamped on failing poll %d", i) } } + +// --------------------------------------------------------------------------- +// Timezone + malformed-date fixtures (hw-8d9k): every earlier calendar test +// used uniform time.UTC, leaving parseGoogleEventTime's non-UTC-offset +// handling and its malformed-date silent-drop path unpinned. Google returns +// event times in the calendar's own timezone (e.g. +05:30), so these are the +// timestamps the producer actually sees in the wild. +// --------------------------------------------------------------------------- + +// TestParseGoogleEventTime_OffsetsAndMalformed pins parseGoogleEventTime +// directly: non-UTC offsets must parse to the correct instant with the offset +// preserved, and malformed values must yield zero-time without claiming +// all-day. +func TestParseGoogleEventTime_OffsetsAndMalformed(t *testing.T) { + cases := []struct { + name string + edt *calendar.EventDateTime + wantZero bool + wantAllDay bool + wantUTC time.Time // instant the parsed value must equal (when !wantZero) + wantOffset int // expected zone offset in seconds (when !wantZero) + }{ + { + name: "dateTime with +05:30 offset", + edt: &calendar.EventDateTime{DateTime: "2026-06-15T15:30:00+05:30"}, + wantUTC: time.Date(2026, 6, 15, 10, 0, 0, 0, time.UTC), + wantOffset: 5*3600 + 30*60, + }, + { + name: "dateTime with -08:00 offset", + edt: &calendar.EventDateTime{DateTime: "2026-06-15T02:00:00-08:00"}, + wantUTC: time.Date(2026, 6, 15, 10, 0, 0, 0, time.UTC), + wantOffset: -8 * 3600, + }, + { + name: "dateTime with Z suffix", + edt: &calendar.EventDateTime{DateTime: "2026-06-15T10:00:00Z"}, + wantUTC: time.Date(2026, 6, 15, 10, 0, 0, 0, time.UTC), + wantOffset: 0, + }, + { + name: "all-day date parses as UTC midnight", + edt: &calendar.EventDateTime{Date: "2026-06-15"}, + wantAllDay: true, + wantUTC: time.Date(2026, 6, 15, 0, 0, 0, 0, time.UTC), + wantOffset: 0, + }, + { + name: "nil EventDateTime", + edt: nil, + wantZero: true, + }, + { + name: "empty EventDateTime", + edt: &calendar.EventDateTime{}, + wantZero: true, + }, + { + name: "garbage dateTime", + edt: &calendar.EventDateTime{DateTime: "not-a-timestamp"}, + wantZero: true, + }, + { + name: "non-RFC3339 dateTime (space separator)", + edt: &calendar.EventDateTime{DateTime: "2026-06-15 10:00:00"}, + wantZero: true, + }, + { + name: "out-of-range dateTime components", + edt: &calendar.EventDateTime{DateTime: "2026-13-45T99:99:99Z"}, + wantZero: true, + }, + { + name: "garbage all-day date must not claim all-day", + edt: &calendar.EventDateTime{Date: "not-a-date"}, + wantZero: true, + }, + { + name: "impossible calendar date (Feb 30)", + edt: &calendar.EventDateTime{Date: "2026-02-30"}, + wantZero: true, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, allDay := parseGoogleEventTime(tc.edt) + if tc.wantZero { + assert.True(t, got.IsZero(), "malformed/empty input must yield zero-time") + assert.False(t, allDay, "malformed input must never claim all-day") + return + } + assert.True(t, got.Equal(tc.wantUTC), + "parsed instant %v must equal %v", got, tc.wantUTC) + _, off := got.Zone() + assert.Equal(t, tc.wantOffset, off, "source offset must be preserved") + assert.Equal(t, tc.wantAllDay, allDay) + }) + } +} + +// TestCalendarProducer_OffsetEvents_WindowFiltering runs the producer +// end-to-end against events whose timestamps carry a +05:30 offset while the +// producer's "now" is time.Now().UTC(). is_current and next_event selection +// compare instants, so an in-progress offset event must still be flagged +// current and the upcoming offset event must still win next_event. +func TestCalendarProducer_OffsetEvents_WindowFiltering(t *testing.T) { + ist := time.FixedZone("UTC+05:30", 5*3600+30*60) + now := time.Now() + + // In-progress event (started 10m ago, ends in 20m) expressed in +05:30. + currentStart := now.Add(-10 * time.Minute).In(ist).Format(time.RFC3339) + currentEnd := now.Add(20 * time.Minute).In(ist).Format(time.RFC3339) + // Upcoming event (starts in 45m) expressed in +05:30. + upcomingStart := now.Add(45 * time.Minute).In(ist).Format(time.RFC3339) + upcomingEnd := now.Add(75 * time.Minute).In(ist).Format(time.RFC3339) + + apiResp := fakeCalendarAPIResponse([]map[string]interface{}{ + { + "summary": "IST Current Meeting", + "start": map[string]string{"dateTime": currentStart}, + "end": map[string]string{"dateTime": currentEnd}, + }, + { + "summary": "IST Next Meeting", + "start": map[string]string{"dateTime": upcomingStart}, + "end": map[string]string{"dateTime": upcomingEnd}, + }, + }) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, apiResp) + })) + defer srv.Close() + + tokenPath := writeFakeToken(t, t.TempDir()) + p := &CalendarProducer{baseURL: srv.URL} + p.SetFeedsConfig(core.FeedsConfig{ + Calendar: core.CalendarFeedConfig{TokenPath: tokenPath}, + }) + + result, err := p.Produce(context.Background()) + require.NoError(t, err) + + data := result.(map[string]interface{})["data"].(map[string]interface{}) + events := data["events"].([]interface{}) + require.Len(t, events, 2) + + first := events[0].(map[string]interface{}) + assert.True(t, first["is_current"].(bool), + "offset event spanning UTC-now must be flagged current") + assert.True(t, strings.HasSuffix(first["start"].(string), "+05:30"), + "formatted start must keep the source offset, got %q", first["start"]) + + second := events[1].(map[string]interface{}) + assert.False(t, second["is_current"].(bool)) + + nextEvent := data["next_event"].(map[string]interface{}) + assert.Equal(t, "IST Next Meeting", nextEvent["name"], + "upcoming offset event must be selected as next_event") + + // The stored absolute start must round-trip to the correct instant. + parsed, err := time.Parse(time.RFC3339, nextEvent["start"].(string)) + require.NoError(t, err) + assert.WithinDuration(t, now.Add(45*time.Minute), parsed, 2*time.Second, + "next_event start must be the same instant regardless of offset") +} + +// TestCalendarProducer_MalformedDates_SilentDrop pins the silent-drop +// contract for unparseable event times: the event still appears in the events +// list but with empty start/end strings, is never flagged current, and is +// skipped for next_event selection in favor of the first valid event +// (ARCH-1: no error, no crash). +func TestCalendarProducer_MalformedDates_SilentDrop(t *testing.T) { + now := time.Now() + validStart := now.Add(30 * time.Minute).UTC().Format(time.RFC3339) + validEnd := now.Add(60 * time.Minute).UTC().Format(time.RFC3339) + + apiResp := fakeCalendarAPIResponse([]map[string]interface{}{ + { + "summary": "Broken Meeting", + "start": map[string]string{"dateTime": "not-a-timestamp"}, + "end": map[string]string{"dateTime": "2026-13-45T99:99:99Z"}, + }, + { + "summary": "Valid Meeting", + "start": map[string]string{"dateTime": validStart}, + "end": map[string]string{"dateTime": validEnd}, + }, + }) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, apiResp) + })) + defer srv.Close() + + tokenPath := writeFakeToken(t, t.TempDir()) + p := &CalendarProducer{baseURL: srv.URL} + p.SetFeedsConfig(core.FeedsConfig{ + Calendar: core.CalendarFeedConfig{TokenPath: tokenPath}, + }) + + result, err := p.Produce(context.Background()) + require.NoError(t, err, "ARCH-1: malformed event times must not error") + + data := result.(map[string]interface{})["data"].(map[string]interface{}) + events := data["events"].([]interface{}) + require.Len(t, events, 2, "malformed event stays in the list, not removed") + + broken := events[0].(map[string]interface{}) + assert.Equal(t, "Broken Meeting", broken["name"]) + assert.Equal(t, "", broken["start"], "zero-time start renders as empty string") + assert.Equal(t, "", broken["end"], "zero-time end renders as empty string") + assert.False(t, broken["all_day"].(bool), "malformed dateTime must not claim all-day") + assert.False(t, broken["is_current"].(bool), "zero-time event can never be current") + + nextEvent := data["next_event"].(map[string]interface{}) + assert.Equal(t, "Valid Meeting", nextEvent["name"], + "next_event selection must skip the malformed event") +} + +// TestCalendarProducer_OnlyMalformedDates_NoNextEvent covers the degenerate +// window where every event has unparseable times: the list is served as-is +// (fail-open) but nothing qualifies as next_event. +func TestCalendarProducer_OnlyMalformedDates_NoNextEvent(t *testing.T) { + apiResp := fakeCalendarAPIResponse([]map[string]interface{}{ + { + "summary": "Only Broken Meeting", + "start": map[string]string{"dateTime": "garbage"}, + "end": map[string]string{"dateTime": "garbage"}, + }, + }) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, apiResp) + })) + defer srv.Close() + + tokenPath := writeFakeToken(t, t.TempDir()) + p := &CalendarProducer{baseURL: srv.URL} + p.SetFeedsConfig(core.FeedsConfig{ + Calendar: core.CalendarFeedConfig{TokenPath: tokenPath}, + }) + + result, err := p.Produce(context.Background()) + require.NoError(t, err, "ARCH-1: malformed-only window must not error") + + data := result.(map[string]interface{})["data"].(map[string]interface{}) + events := data["events"].([]interface{}) + require.Len(t, events, 1) + assert.Equal(t, "", events[0].(map[string]interface{})["start"]) + assert.Nil(t, data["next_event"], + "a zero-time event must never be promoted to next_event") +} From f97f160a01f682e39dd439ffe5cd6541e742a0f6 Mon Sep 17 00:00:00 2001 From: Vishnu Jayavel Date: Fri, 10 Jul 2026 22:26:07 -0700 Subject: [PATCH 3/3] test: calendar contract fixture + freshness cross-boundary (hw-q3vr) Scout hw-xthx blind spot #9: the calendar feed crossed the Go->JSON->Python boundary with no shared fixture, the existing cross-boundary pair did not assert the ttl_seconds freshness field the TUI now gates on (post-#249), most calendar renderer formatting branches were untested, and doctor's stale-feed test exercised weather only. Adds, test-only: - testdata/contracts/feeds/calendar.json: a shared calendar feed contract fixture consumed by BOTH sides. It lives in a feeds/ subdirectory because the ARCH-6 dispatch loader (internal/contract) reads only top-level .json and would reject a fixture without an event_type. The Go side (TestCalendarFeedContractFixture_FlattenForTUI) pins the fixture envelope to feeds.CalendarTestFixture() and FlattenForTUI output to expected_flattened via JSONEq; the Python side renders the same expected_flattened entry, so a producer field rename cannot pass one side. - Freshness: the existing Go calendar cross-boundary test now asserts ttl_seconds (it only pinned updated_at); the fixture's frozen updated_at doubles as the stale-case assertion in Python (renders absent as-written, renders the event once fresh-stamped). - TestCalendarFormattingBranches (tui/tests): pins ends-in/(+N more) suffixes, ValueError/KeyError parse fallbacks, and the NOW / in-Xmin / Free-for-Xh buckets, with mid-bucket offsets so test jitter cannot cross a bucket edge. - TestDoctorFeedHealthStale now also covers calendar, with real events in the cache so it hits the staleness WARN rather than the empty-data or zero-liveness paths that run first. Validation: go test -race -p 2 on internal/bridge, internal/contract, cmd/hookwise all pass; tui pytest test_status_segments.py 29/29 pass. Zero production changes. --- cmd/hookwise/cli_test.go | 31 ++++- internal/bridge/bridge_test.go | 59 ++++++++- testdata/contracts/feeds/calendar.json | 41 +++++++ tui/tests/test_status_segments.py | 164 ++++++++++++++++++++++++- 4 files changed, 290 insertions(+), 5 deletions(-) create mode 100644 testdata/contracts/feeds/calendar.json diff --git a/cmd/hookwise/cli_test.go b/cmd/hookwise/cli_test.go index 05206b6..b9dd508 100644 --- a/cmd/hookwise/cli_test.go +++ b/cmd/hookwise/cli_test.go @@ -1434,19 +1434,24 @@ func TestDoctorFeedHealthStale(t *testing.T) { os.MkdirAll(cacheDir, 0o700) // Feed config lives in the GLOBAL config now — the singleton daemon sources - // feeds canonically from there (#89), and doctor reports against that. Weather - // interval = 60s. + // feeds canonically from there (#89), and doctor reports against that. + // Both intervals = 60s, so the stale threshold (2x interval) is 120s. globalYAML := `version: 1 feeds: weather: enabled: true interval_seconds: 60 + calendar: + enabled: true + interval_seconds: 60 ` os.WriteFile(filepath.Join(stateDir, "config.yaml"), []byte(globalYAML), 0o644) // Minimal project config so Check 1 passes without a project feeds: block. os.WriteFile(filepath.Join(tmpDir, core.ProjectConfigFile), []byte("version: 1\n"), 0o644) - // Write a stale weather feed (timestamp 5 minutes ago, interval is 60s, threshold is 120s). + // Write stale feeds (timestamp 5 minutes ago, past the 120s threshold). + // Calendar carries real events so it exercises the staleness WARN, not the + // empty-data or zero-liveness paths that run first. staleTime := time.Now().Add(-5 * time.Minute).UTC().Format(time.RFC3339) writeJSONFile(t, filepath.Join(cacheDir, "weather.json"), map[string]interface{}{ "type": "weather", @@ -1456,11 +1461,31 @@ feeds: "emoji": "☀️", }, }) + writeJSONFile(t, filepath.Join(cacheDir, "calendar.json"), map[string]interface{}{ + "type": "calendar", + "timestamp": staleTime, + "data": map[string]interface{}{ + "events": []interface{}{ + map[string]interface{}{ + "name": "Standup", + "start": "2026-03-07T10:30:00Z", + "end": "2026-03-07T11:00:00Z", + "all_day": false, + "is_current": false, + }, + }, + "next_event": map[string]interface{}{ + "name": "Standup", + "start": "2026-03-07T10:30:00Z", + }, + }, + }) output, err := executeCommand("doctor") require.NoError(t, err, "doctor failed\noutput: %s", output) assert.Contains(t, output, "WARN feed:weather: stale data", "doctor should warn about stale weather feed") + assert.Contains(t, output, "WARN feed:calendar: stale data", "doctor should warn about stale calendar feed") } // A feed whose cache is fresh but whose data payload is empty ({}) is a real diff --git a/internal/bridge/bridge_test.go b/internal/bridge/bridge_test.go index 6dadcc0..6cfad2a 100644 --- a/internal/bridge/bridge_test.go +++ b/internal/bridge/bridge_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "os" "path/filepath" + goruntime "runtime" "testing" "time" @@ -870,9 +871,13 @@ func TestFlattenForTUI_CalendarFieldNamesForPythonTUI(t *testing.T) { require.True(t, ok, "next_event must be a map") require.Contains(t, next, "name", "next_event must expose 'name', not 'title' (issue #155)") - // Envelope stripped + freshness added. + // Envelope stripped + freshness added. Post-#249 the Python TUI gates + // rendering on updated_at+ttl_seconds, so a flattened entry missing either + // is invisible in production — pin both here, not just updated_at. assert.NotContains(t, entry, "data") assert.Equal(t, fixture["timestamp"], entry["updated_at"]) + assert.Equal(t, DefaultTTLSeconds, entry["ttl_seconds"], + "flattened calendar must carry ttl_seconds or the TUI freshness gate drops the segment") } func TestFlattenForTUI_ProjectFieldNamesForPythonTUI(t *testing.T) { @@ -896,6 +901,58 @@ func TestFlattenForTUI_ProjectFieldNamesForPythonTUI(t *testing.T) { assert.Equal(t, fixture["timestamp"], entry["updated_at"]) } +// --------------------------------------------------------------------------- +// Test 30e: calendar feed contract fixture (shared with tui/tests) +// --------------------------------------------------------------------------- + +// calendarFeedFixturePath resolves testdata/contracts/feeds/calendar.json +// relative to the repo root (same walk-up as internal/contract's fixturesDir). +func calendarFeedFixturePath(t *testing.T) string { + t.Helper() + _, filename, _, ok := goruntime.Caller(0) + require.True(t, ok, "runtime.Caller failed") + repoRoot := filepath.Dir(filepath.Dir(filepath.Dir(filename))) + return filepath.Join(repoRoot, "testdata", "contracts", "feeds", "calendar.json") +} + +// TestCalendarFeedContractFixture_FlattenForTUI is the Go half of the shared +// calendar feed contract (ARCH-6 spirit for the Go→JSON→Python boundary). +// The same JSON file is rendered by tui/tests/test_status_segments.py, so the +// two sides can only pass together: a producer field rename breaks the +// envelope pin here, and an expected_flattened edit that no longer renders +// breaks the Python side. +func TestCalendarFeedContractFixture_FlattenForTUI(t *testing.T) { + raw, err := os.ReadFile(calendarFeedFixturePath(t)) + require.NoError(t, err, "shared calendar feed fixture must exist (tui/tests reads the same file)") + + var fixture struct { + Envelope map[string]interface{} `json:"envelope"` + ExpectedFlattened map[string]interface{} `json:"expected_flattened"` + } + require.NoError(t, json.Unmarshal(raw, &fixture)) + require.NotEmpty(t, fixture.Envelope, "fixture must carry an envelope") + require.NotEmpty(t, fixture.ExpectedFlattened, "fixture must carry expected_flattened") + + // Pin the fixture's envelope to the producer's canonical test fixture so + // the shared file can't drift from what the producer actually emits. + wantEnvelope, err := json.Marshal(feeds.CalendarTestFixture()) + require.NoError(t, err) + gotEnvelope, err := json.Marshal(fixture.Envelope) + require.NoError(t, err) + require.JSONEq(t, string(wantEnvelope), string(gotEnvelope), + "fixture envelope must match feeds.CalendarTestFixture(); update both together") + + // FlattenForTUI(envelope) must equal the exact flat entry the TUI renders, + // including the updated_at/ttl_seconds freshness fields the TUI gates on. + flat := FlattenForTUI(map[string]interface{}{"calendar": fixture.Envelope}) + gotFlat, err := json.Marshal(flat["calendar"]) + require.NoError(t, err) + wantFlat, err := json.Marshal(fixture.ExpectedFlattened) + require.NoError(t, err) + assert.JSONEq(t, string(wantFlat), string(gotFlat), + "flattened calendar entry must byte-match the fixture's expected_flattened") +} + // --------------------------------------------------------------------------- // Test 31: FlattenForTUI preserves ttl_seconds from data if present // --------------------------------------------------------------------------- diff --git a/testdata/contracts/feeds/calendar.json b/testdata/contracts/feeds/calendar.json new file mode 100644 index 0000000..1264498 --- /dev/null +++ b/testdata/contracts/feeds/calendar.json @@ -0,0 +1,41 @@ +{ + "name": "calendar_feed_flatten", + "description": "Cross-boundary contract for the calendar feed (ARCH-6 spirit for the Go->JSON->Python boundary): FlattenForTUI must turn the producer envelope (feeds.CalendarTestFixture) into exactly the flat cache entry the Python TUI renders. Consumed by internal/bridge (Go: envelope -> expected_flattened) and tui/tests (Python: expected_flattened -> rendered segment). The frozen updated_at is intentionally in the past: the Python stale-case test consumes the fixture as-written, and the fresh-case test re-stamps updated_at before rendering (post-PR#249 the TUI gates on freshness).", + "feed": "calendar", + "envelope": { + "type": "calendar", + "timestamp": "2026-03-07T10:00:00Z", + "data": { + "events": [ + { + "name": "Standup", + "start": "2026-03-07T10:30:00Z", + "end": "2026-03-07T11:00:00Z", + "all_day": false, + "is_current": false + } + ], + "next_event": { + "name": "Standup", + "start": "2026-03-07T10:30:00Z" + } + } + }, + "expected_flattened": { + "events": [ + { + "name": "Standup", + "start": "2026-03-07T10:30:00Z", + "end": "2026-03-07T11:00:00Z", + "all_day": false, + "is_current": false + } + ], + "next_event": { + "name": "Standup", + "start": "2026-03-07T10:30:00Z" + }, + "updated_at": "2026-03-07T10:00:00Z", + "ttl_seconds": 300 + } +} diff --git a/tui/tests/test_status_segments.py b/tui/tests/test_status_segments.py index 0aecfce..5637a8a 100644 --- a/tui/tests/test_status_segments.py +++ b/tui/tests/test_status_segments.py @@ -12,11 +12,24 @@ from __future__ import annotations +import json from datetime import datetime, timedelta, timezone -from typing import Any +from pathlib import Path +from typing import Any, cast from hookwise_tui.tabs.status import StatusTab +# Shared with internal/bridge's TestCalendarFeedContractFixture_FlattenForTUI: +# the Go side proves envelope -> expected_flattened, this file proves +# expected_flattened -> rendered segment. +_CALENDAR_FIXTURE = ( + Path(__file__).resolve().parents[2] + / "testdata" + / "contracts" + / "feeds" + / "calendar.json" +) + def _fresh() -> dict[str, Any]: """Freshness fields FlattenForTUI stamps on every cache entry. @@ -36,6 +49,44 @@ def _stale() -> dict[str, Any]: return {"updated_at": old, "ttl_seconds": 300} +def _iso_in(minutes: float) -> str: + """An ISO-8601 UTC timestamp `minutes` from now (negative = past).""" + return (datetime.now(timezone.utc) + timedelta(minutes=minutes)).strftime( + "%Y-%m-%dT%H:%M:%SZ" + ) + + +class TestCalendarContractFixture: + """Python half of the shared calendar feed contract fixture + (testdata/contracts/feeds/calendar.json). The Go side (internal/bridge) + pins producer envelope -> flattened entry; this pins flattened entry -> + rendered segment, so a field rename cannot pass one side silently.""" + + @staticmethod + def _fixture_entry() -> dict[str, Any]: + with _CALENDAR_FIXTURE.open() as f: + return cast("dict[str, Any]", json.load(f)["expected_flattened"]) + + def test_fixture_fresh_stamped_renders_producer_event_name(self) -> None: + # The fixture's updated_at is frozen in the past; re-stamp it fresh + # (keeping ttl_seconds and every data field as-written) the way a live + # daemon write would, since post-#249 the renderer gates on freshness. + entry = {**self._fixture_entry(), "updated_at": _fresh()["updated_at"]} + out = StatusTab._render_segment("calendar", {"calendar": entry}) + assert "Standup" in out, ( + "the shared contract fixture must render its event 'name'; a " + "producer field rename that updates the fixture must fail here" + ) + + def test_fixture_as_written_is_stale_and_renders_absent(self) -> None: + # As-written, the fixture's frozen updated_at is far past ttl_seconds: + # the freshness gate must treat the entire entry as absent. + entry = self._fixture_entry() + assert entry["ttl_seconds"] == 300, "fixture must carry the default TTL" + out = StatusTab._render_segment("calendar", {"calendar": entry}) + assert out == "", "a past-TTL fixture entry must render as absent" + + class TestCalendarSegment: """The calendar producer emits event 'name'; the renderer must read it.""" @@ -207,3 +258,114 @@ def test_segment_has_data_true_for_fresh_entry(self) -> None: def test_segment_has_data_false_for_stale_insights(self) -> None: cache = {"insights": {"total_sessions": 5, **_stale()}} assert StatusTab._segment_has_data("insights_pace", cache) is False + + +class TestCalendarFormattingBranches: + """Pins every formatting branch of the calendar renderer (status.py + _render_segment 'calendar'): the current-event path with its ends-in and + (+N more) suffixes and ValueError/KeyError parse fallbacks, and the + next-event NOW / in-Xmin / Free-for-Xh bucketing (scout hw-xthx #9). + Offsets sit mid-bucket so second-level test jitter can't cross an edge.""" + + @staticmethod + def _cache( + events: list[dict[str, Any]], next_event: dict[str, Any] | None = None + ) -> dict[str, Any]: + entry: dict[str, Any] = {"events": events, **_fresh()} + if next_event is not None: + entry["next_event"] = next_event + return {"calendar": entry} + + # -- current-event path -- + + def test_current_event_shows_ends_in_suffix(self) -> None: + events = [ + {"name": "Standup", "start": _iso_in(-30), "end": _iso_in(30), "is_current": True} + ] + out = StatusTab._render_segment("calendar", self._cache(events)) + assert "Standup" in out + assert "ends in" in out, "a parseable 'end' must render the ends-in suffix" + + def test_current_event_missing_end_renders_without_suffix(self) -> None: + # KeyError fallback: no 'end' key must drop the suffix, not crash. + events = [{"name": "Standup", "start": _iso_in(-30), "is_current": True}] + out = StatusTab._render_segment("calendar", self._cache(events)) + assert "Standup" in out + assert "ends in" not in out + + def test_current_event_malformed_end_renders_without_suffix(self) -> None: + # ValueError fallback: an unparseable 'end' must drop the suffix. + events = [{"name": "Standup", "end": "not-a-timestamp", "is_current": True}] + out = StatusTab._render_segment("calendar", self._cache(events)) + assert "Standup" in out + assert "ends in" not in out + + def test_current_event_counts_additional_events(self) -> None: + events = [ + {"name": "Standup", "end": _iso_in(30), "is_current": True}, + {"name": "Review", "start": _iso_in(60), "is_current": False}, + {"name": "1:1", "start": _iso_in(120), "is_current": False}, + ] + out = StatusTab._render_segment("calendar", self._cache(events)) + assert "(+2 more)" in out + + # -- next-event path: fallbacks -- + + def test_no_events_renders_free(self) -> None: + out = StatusTab._render_segment("calendar", self._cache([])) + assert out == "\U0001f4c5 Free" + + def test_next_event_without_name_renders_free(self) -> None: + out = StatusTab._render_segment( + "calendar", self._cache([], next_event={"start": _iso_in(30)}) + ) + assert out == "\U0001f4c5 Free" + + def test_next_event_malformed_start_falls_back_to_bare_name(self) -> None: + # ValueError fallback: name still renders, without any time bucket. + out = StatusTab._render_segment( + "calendar", + self._cache([], next_event={"name": "Standup", "start": "not-a-timestamp"}), + ) + assert out == "\U0001f4c5 Standup" + + def test_next_event_missing_start_falls_back_to_bare_name(self) -> None: + # KeyError fallback: no 'start' key at all. + out = StatusTab._render_segment( + "calendar", self._cache([], next_event={"name": "Standup"}) + ) + assert out == "\U0001f4c5 Standup" + + # -- next-event path: time bucketing -- + + def test_next_event_within_5min_renders_now(self) -> None: + ev = {"name": "Standup", "start": _iso_in(2), "is_current": False} + out = StatusTab._render_segment("calendar", self._cache([ev], next_event=ev)) + assert "Standup NOW" in out + + def test_next_event_within_15min_renders_min_with_bolt(self) -> None: + ev = {"name": "Standup", "start": _iso_in(10), "is_current": False} + out = StatusTab._render_segment("calendar", self._cache([ev], next_event=ev)) + assert "in 10min" in out + assert "⚡" in out, "5-15min out must carry the imminent bolt" + + def test_next_event_within_hour_renders_min_without_bolt(self) -> None: + ev = {"name": "Standup", "start": _iso_in(30), "is_current": False} + out = StatusTab._render_segment("calendar", self._cache([ev], next_event=ev)) + assert "in 30min" in out + assert "⚡" not in out, "15-60min out must not carry the bolt" + + def test_next_event_beyond_hour_renders_free_for_hours(self) -> None: + ev = {"name": "Standup", "start": _iso_in(180), "is_current": False} + out = StatusTab._render_segment("calendar", self._cache([ev], next_event=ev)) + assert "Free for 3h" in out + assert "Standup" not in out, ">60min out shows free time, not the event" + + def test_next_event_path_counts_additional_events(self) -> None: + events = [ + {"name": "Standup", "start": _iso_in(30), "is_current": False}, + {"name": "Review", "start": _iso_in(90), "is_current": False}, + ] + next_event = {"name": "Standup", "start": _iso_in(30)} + out = StatusTab._render_segment("calendar", self._cache(events, next_event)) + assert "(+1 more)" in out