diff --git a/internal/api/v2/v2.go b/internal/api/v2/v2.go index 68f205d..9ace5b4 100644 --- a/internal/api/v2/v2.go +++ b/internal/api/v2/v2.go @@ -330,8 +330,12 @@ func toAPIEvent(inc *db.Incident, isAuth bool) *Incident { components[i] = int(comp.ID) } + updates, latestDescription := mapEventUpdates(inc.Statuses, isAuth, inc.Status, inc.EndDate, inc.Type) + var description string - if inc.Description != nil { + if latestDescription != "" { + description = latestDescription + } else if inc.Description != nil { description = *inc.Description } @@ -343,8 +347,8 @@ func toAPIEvent(inc *db.Incident, isAuth bool) *Incident { StartDate: *inc.StartDate, EndDate: inc.EndDate, System: &inc.System, - Updates: mapEventUpdates(inc.Statuses, isAuth), - Status: inc.Status, + Updates: updates, + Status: eventStatus(inc), Type: inc.Type, } @@ -1848,23 +1852,103 @@ func PatchEventUpdateTextHandler(dbInst *db.DB, logger *zap.Logger) gin.HandlerF } } -func mapEventUpdates(statuses []db.IncidentStatus, isAuth bool) []EventUpdateData { +// mapEventUpdates maps stored status rows to the API updates array. Rows with +// status "description" are not status entries: they are dropped from the array +// and the latest one is returned as the event description. +// statusDescription marks an update row that carries a description change rather +// than a status change; the API folds it into the event description field. +const statusDescription event.Status = "description" + +func mapEventUpdates( + statuses []db.IncidentStatus, isAuth bool, eventStatus event.Status, + endDate *time.Time, eventType string, +) ([]EventUpdateData, string) { updates := make([]EventUpdateData, 0, len(statuses)) idx := 0 + var latestDescription string + // "changed" and "impact changed" are not status changes: they collapse to the + // previous normalized status. Seeding that with the event status keeps a + // leading such row from collapsing to an empty value. + lastStatus := normalizeStatus(eventStatus, endDate, eventType) for _, s := range statuses { if !isAuth && isInternalStatus(s.Status) { continue } + if s.Status == statusDescription { + latestDescription = s.Text + continue + } + status := normalizeStatus(s.Status, endDate, eventType) + if status == event.IncidentChanged || status == event.IncidentImpactChanged { + status = lastStatus + } + lastStatus = status updates = append(updates, EventUpdateData{ ID: idx, - Status: s.Status, + Status: status, Text: s.Text, Timestamp: s.Timestamp, }) idx++ } - return updates + return updates, latestDescription +} + +// eventStatus normalizes the stored event status. "changed" and "impact changed" +// annotate a change rather than denote a state, so they are collapsed to the +// previous status from the update history, matching the updates sequence. It +// walks the full history, so unlike updates[] the result does not depend on the +// caller's visibility. +func eventStatus(inc *db.Incident) event.Status { + status := normalizeStatus(inc.Status, inc.EndDate, inc.Type) + if status != event.IncidentChanged && status != event.IncidentImpactChanged { + return status + } + + prev := status + for _, s := range inc.Statuses { + if s.Status == statusDescription { + continue + } + row := normalizeStatus(s.Status, inc.EndDate, inc.Type) + if row == event.IncidentChanged || row == event.IncidentImpactChanged { + row = prev + } + prev = row + } + + return prev +} + +// normalizeStatus maps non-canonical status values stored in the database to +// their canonical form. "changed" and "impact changed" are not status changes; +// they are collapsed to the previous status by eventStatus and mapEventUpdates. +// OutDatedSystem without an end date is kept verbatim: it has no canonical +// terminal status, and consumers map it to the previous status. +func normalizeStatus(raw event.Status, endDate *time.Time, eventType string) event.Status { + switch raw { + case "analyzing": + return event.IncidentAnalysing + case "in progress": + return event.MaintenanceInProgress + case "scheduled": + return event.MaintenancePlanned + case event.OutDatedSystem: + if endDate == nil { + return raw + } + switch eventType { + case event.TypeIncident: + return event.IncidentResolved + case event.TypeInformation: + return event.InfoCompleted + default: + return event.MaintenanceCompleted + } + default: + return raw + } } // isInternalStatus returns true for statuses that should not be exposed to public (non-authenticated) users. diff --git a/internal/api/v2/v2_visibility_test.go b/internal/api/v2/v2_visibility_test.go index d029b88..67dd030 100644 --- a/internal/api/v2/v2_visibility_test.go +++ b/internal/api/v2/v2_visibility_test.go @@ -7,6 +7,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stackmon/otc-status-dashboard/internal/db" + "github.com/stackmon/otc-status-dashboard/internal/event" ) func TestMapEventUpdates_FiltersInternalStatuses(t *testing.T) { @@ -20,27 +21,224 @@ func TestMapEventUpdates_FiltersInternalStatuses(t *testing.T) { } t.Run("authenticated sees all statuses", func(t *testing.T) { - updates := mapEventUpdates(statuses, true) + updates, _ := mapEventUpdates(statuses, true, event.MaintenancePlanned, nil, event.TypeMaintenance) assert.Len(t, updates, 4) assert.Equal(t, "pending_review", string(updates[0].Status)) assert.Equal(t, "reviewed", string(updates[1].Status)) }) t.Run("unauthenticated sees only public statuses", func(t *testing.T) { - updates := mapEventUpdates(statuses, false) + updates, _ := mapEventUpdates(statuses, false, event.MaintenancePlanned, nil, event.TypeMaintenance) assert.Len(t, updates, 2) assert.Equal(t, "planned", string(updates[0].Status)) assert.Equal(t, "in_progress", string(updates[1].Status)) }) t.Run("IDs are sequential after filtering", func(t *testing.T) { - updates := mapEventUpdates(statuses, false) + updates, _ := mapEventUpdates(statuses, false, event.MaintenancePlanned, nil, event.TypeMaintenance) for i, u := range updates { assert.Equal(t, i, u.ID) } }) } +func TestMapEventUpdates_DescriptionRows(t *testing.T) { + testTime := time.Now().UTC() + + statuses := []db.IncidentStatus{ + {ID: 1, Status: "planned", Text: "Planned", Timestamp: testTime}, + {ID: 2, Status: "description", Text: "First description", Timestamp: testTime}, + {ID: 3, Status: "in_progress", Text: "In progress", Timestamp: testTime}, + {ID: 4, Status: "description", Text: "Latest description", Timestamp: testTime}, + } + + t.Run("description rows are removed and latest text is returned", func(t *testing.T) { + updates, description := mapEventUpdates(statuses, true, event.MaintenancePlanned, nil, event.TypeMaintenance) + assert.Len(t, updates, 2) + assert.Equal(t, "planned", string(updates[0].Status)) + assert.Equal(t, "in_progress", string(updates[1].Status)) + assert.Equal(t, "Latest description", description) + }) + + t.Run("IDs are sequential after description removal", func(t *testing.T) { + updates, _ := mapEventUpdates(statuses, false, event.MaintenancePlanned, nil, event.TypeMaintenance) + for i, u := range updates { + assert.Equal(t, i, u.ID) + } + }) + + t.Run("no description rows returns empty description", func(t *testing.T) { + _, description := mapEventUpdates(statuses[:1], true, event.MaintenancePlanned, nil, event.TypeMaintenance) + assert.Empty(t, description) + }) +} + +func TestMapEventUpdates_NormalizesStatuses(t *testing.T) { + testTime := time.Now().UTC() + endDate := testTime.Add(time.Hour) + + statuses := []db.IncidentStatus{ + {ID: 1, Status: "analyzing", Text: "Analysing", Timestamp: testTime}, + {ID: 2, Status: "in progress", Text: "In progress", Timestamp: testTime}, + {ID: 3, Status: "scheduled", Text: "Scheduled", Timestamp: testTime}, + } + + updates, _ := mapEventUpdates(statuses, true, event.MaintenancePlanned, &endDate, event.TypeMaintenance) + assert.Equal(t, "analysing", string(updates[0].Status)) + assert.Equal(t, "in_progress", string(updates[1].Status)) + assert.Equal(t, "planned", string(updates[2].Status)) + + systemStatuses := []db.IncidentStatus{ + {ID: 1, Status: event.OutDatedSystem, Text: "Moved", Timestamp: testTime}, + } + systemUpdates, _ := mapEventUpdates(systemStatuses, true, event.MaintenancePlanned, &endDate, event.TypeMaintenance) + assert.Equal(t, event.MaintenanceCompleted, systemUpdates[0].Status) +} + +func TestEventStatus(t *testing.T) { + testTime := time.Now().UTC() + endDate := testTime.Add(time.Hour) + past := testTime.Add(-time.Hour) + + t.Run("changed on a closed incident collapses to the previous status", func(t *testing.T) { + inc := &db.Incident{ + Type: event.TypeIncident, + Status: event.IncidentChanged, + EndDate: &endDate, + Statuses: []db.IncidentStatus{ + {Status: event.IncidentDetected, Timestamp: past}, + {Status: event.IncidentResolved, Timestamp: past}, + }, + } + assert.Equal(t, event.IncidentResolved, eventStatus(inc)) + }) + + t.Run("impact changed on an open incident collapses to the previous status", func(t *testing.T) { + inc := &db.Incident{ + Type: event.TypeIncident, + Status: event.IncidentImpactChanged, + Statuses: []db.IncidentStatus{ + {Status: event.IncidentDetected, Timestamp: past}, + {Status: event.IncidentAnalysing, Timestamp: past}, + }, + } + assert.Equal(t, event.IncidentAnalysing, eventStatus(inc)) + }) + + t.Run("a plain status is returned normalized", func(t *testing.T) { + inc := &db.Incident{ + Type: event.TypeIncident, + Status: "analyzing", + Statuses: []db.IncidentStatus{{Status: "analyzing", Timestamp: past}}, + } + assert.Equal(t, event.IncidentAnalysing, eventStatus(inc)) + }) + + t.Run("SYSTEM without an end date is kept verbatim", func(t *testing.T) { + inc := &db.Incident{ + Type: event.TypeIncident, + Status: event.OutDatedSystem, + Statuses: []db.IncidentStatus{{Status: event.OutDatedSystem, Timestamp: past}}, + } + assert.Equal(t, event.OutDatedSystem, eventStatus(inc)) + }) +} + +func TestEventStatus_MatchesLastUpdate(t *testing.T) { + testTime := time.Now().UTC() + endDate := testTime.Add(time.Hour) + + statuses := []db.IncidentStatus{ + {Status: event.IncidentDetected, Text: "detected", Timestamp: testTime}, + {Status: event.IncidentResolved, Text: "resolved", Timestamp: testTime}, + {Status: event.IncidentChanged, Text: "changed", Timestamp: testTime}, + } + inc := &db.Incident{ + Type: event.TypeIncident, + Status: event.IncidentChanged, + EndDate: &endDate, + Statuses: statuses, + } + + updates, _ := mapEventUpdates(statuses, true, inc.Status, inc.EndDate, inc.Type) + assert.Equal(t, eventStatus(inc), updates[len(updates)-1].Status) +} + +func TestEventStatus_DoesNotDependOnVisibility(t *testing.T) { + testTime := time.Now().UTC() + + statuses := []db.IncidentStatus{ + {Status: event.MaintenancePlanned, Text: "planned", Timestamp: testTime}, + {Status: event.MaintenanceReviewed, Text: "reviewed", Timestamp: testTime}, + } + inc := &db.Incident{Type: event.TypeMaintenance, Status: event.MaintenanceReviewed, Statuses: statuses} + + assert.Equal(t, event.MaintenanceReviewed, eventStatus(inc)) + + // The unauthenticated view filters the trailing internal row, so the last + // visible update can differ from the view-independent event status. + public, _ := mapEventUpdates(statuses, false, inc.Status, inc.EndDate, inc.Type) + assert.Equal(t, event.MaintenancePlanned, public[len(public)-1].Status) +} + +func TestMapEventUpdates_ChangedStatusesKeepPrevious(t *testing.T) { + testTime := time.Now().UTC() + + statuses := []db.IncidentStatus{ + {ID: 1, Status: "analysing", Text: "Analysing", Timestamp: testTime}, + {ID: 2, Status: "impact changed", Text: "Impact changed", Timestamp: testTime}, + {ID: 3, Status: "changed", Text: "Changed", Timestamp: testTime}, + } + + updates, _ := mapEventUpdates(statuses, true, event.IncidentAnalysing, nil, event.TypeIncident) + assert.Len(t, updates, 3) + assert.Equal(t, "analysing", string(updates[0].Status)) + assert.Equal(t, "analysing", string(updates[1].Status)) + assert.Equal(t, "analysing", string(updates[2].Status)) +} + +func TestMapEventUpdates_LeadingChangedUsesEventStatus(t *testing.T) { + testTime := time.Now().UTC() + + statuses := []db.IncidentStatus{ + {ID: 1, Status: "changed", Text: "Changed", Timestamp: testTime}, + } + + updates, _ := mapEventUpdates(statuses, true, event.IncidentResolved, nil, event.TypeIncident) + assert.Len(t, updates, 1) + assert.Equal(t, event.IncidentResolved, updates[0].Status) +} + +func TestNormalizeStatus(t *testing.T) { + endDate := time.Now().UTC() + + tests := []struct { + name string + raw event.Status + endDate *time.Time + eventType string + want event.Status + }{ + {name: "analyzing to analysing", raw: "analyzing", want: event.IncidentAnalysing}, + {name: "in progress to in_progress", raw: "in progress", want: event.MaintenanceInProgress}, + {name: "scheduled to planned", raw: "scheduled", want: event.MaintenancePlanned}, + {name: "SYSTEM incident with end date", raw: event.OutDatedSystem, endDate: &endDate, eventType: event.TypeIncident, want: event.IncidentResolved}, + {name: "SYSTEM maintenance with end date", raw: event.OutDatedSystem, endDate: &endDate, eventType: event.TypeMaintenance, want: event.MaintenanceCompleted}, + {name: "SYSTEM info with end date", raw: event.OutDatedSystem, endDate: &endDate, eventType: event.TypeInformation, want: event.InfoCompleted}, + {name: "SYSTEM without end date passes through", raw: event.OutDatedSystem, want: event.OutDatedSystem}, + {name: "changed passes through", raw: event.IncidentChanged, want: event.IncidentChanged}, + {name: "impact changed passes through", raw: event.IncidentImpactChanged, want: event.IncidentImpactChanged}, + {name: "canonical status passes through", raw: event.IncidentResolved, want: event.IncidentResolved}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := normalizeStatus(tt.raw, tt.endDate, tt.eventType) + assert.Equal(t, tt.want, got) + }) + } +} + func TestIsCancelledWithoutPublicStatus(t *testing.T) { testTime := time.Now().UTC() diff --git a/tests/v2_events_test.go b/tests/v2_events_test.go index 59c63cb..ed14954 100644 --- a/tests/v2_events_test.go +++ b/tests/v2_events_test.go @@ -223,7 +223,7 @@ func TestV2PostEventsHandler(t *testing.T) { assert.Len(t, oldIncident.Updates, 3) t.Logf("STATUS updates: %v", oldIncident.Updates) assert.Equal(t, event.IncidentDetected, oldIncident.Updates[0].Status) - assert.Equal(t, event.OutDatedSystem, oldIncident.Updates[1].Status) + assert.Equal(t, event.IncidentResolved, oldIncident.Updates[1].Status) assert.Equal(t, event.IncidentResolved, oldIncident.Updates[2].Status) assert.Equal(t, "The incident is detected.", oldIncident.Updates[0].Text) assert.Equal(t, fmt.Sprintf("Cloud Container Engine (Container, EU-DE, cce) moved to Test incident creation for api V2 for components: 1, 2. Test should close previous and move components to the new.", result.Result[0].IncidentID), oldIncident.Updates[1].Text) @@ -506,7 +506,7 @@ func TestV2PatchEventHandler(t *testing.T) { inc = internalPatch(incID, &pData) assert.Equal(t, startDate.Truncate(time.Microsecond), inc.StartDate) - assert.Equal(t, event.IncidentChanged, inc.Status) + assert.Equal(t, event.IncidentResolved, inc.Status, "changing dates on a closed incident keeps the resolved status") require.NotNil(t, inc.EndDate) assert.Equal(t, endDate.Truncate(time.Microsecond), inc.EndDate.Truncate(time.Microsecond)) assert.Nil(t, inc.Version, "Version must not be exposed for non-maintenance events") diff --git a/tests/v2_test.go b/tests/v2_test.go index 14b4488..d94a3c6 100644 --- a/tests/v2_test.go +++ b/tests/v2_test.go @@ -379,7 +379,7 @@ func TestV2PostIncidentsHandler(t *testing.T) { assert.Len(t, oldIncident.Updates, 3) t.Logf("STATUS updates: %v", oldIncident.Updates) assert.Equal(t, event.IncidentDetected, oldIncident.Updates[0].Status) - assert.Equal(t, event.OutDatedSystem, oldIncident.Updates[1].Status) + assert.Equal(t, event.IncidentResolved, oldIncident.Updates[1].Status) assert.Equal(t, event.IncidentResolved, oldIncident.Updates[2].Status) assert.Equal(t, "The incident is detected.", oldIncident.Updates[0].Text) assert.Equal(t, fmt.Sprintf("Cloud Container Engine (Container, EU-DE, cce) moved to Test incident creation for api V2 for components: 1, 2. Test should close previous and move components to the new.", result.Result[0].IncidentID), oldIncident.Updates[1].Text) @@ -664,7 +664,7 @@ func TestV2PatchIncidentHandler(t *testing.T) { inc = internalPatch(incID, &pData) assert.Equal(t, startDate.Truncate(time.Microsecond), inc.StartDate) - assert.Equal(t, event.IncidentChanged, inc.Status) + assert.Equal(t, event.IncidentResolved, inc.Status, "changing dates on a closed incident keeps the resolved status") require.NotNil(t, inc.EndDate) assert.Equal(t, endDate.Truncate(time.Microsecond), inc.EndDate.Truncate(time.Microsecond))