Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 90 additions & 6 deletions internal/api/v2/v2.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -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,
}

Expand Down Expand Up @@ -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.
Expand Down
204 changes: 201 additions & 3 deletions internal/api/v2/v2_visibility_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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()

Expand Down
4 changes: 2 additions & 2 deletions tests/v2_events_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <a href='/incidents/%d'>Test incident creation for api V2 for components: 1, 2. Test should close previous and move components to the new.</a>", result.Result[0].IncidentID), oldIncident.Updates[1].Text)
Expand Down Expand Up @@ -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")
Expand Down
Loading
Loading