diff --git a/devtools/pgmocktime/mocker.go b/devtools/pgmocktime/mocker.go index 1fe77816ea..91eed291e3 100644 --- a/devtools/pgmocktime/mocker.go +++ b/devtools/pgmocktime/mocker.go @@ -152,7 +152,7 @@ func (m *Mocker) Close() error { m.db.Close(); return nil } // AdvanceTime advances the time by the given duration. func (m *Mocker) AdvanceTime(ctx context.Context, d time.Duration) error { - m.exec(ctx, `update %s.flux_capacitor set ref_time = current_timestamp, base_time = %s.now() + '%d milliseconds'::interval`, m.safeSchema(), m.safeSchema(), d/time.Millisecond) + m.exec(ctx, `update %s.flux_capacitor set ref_time = current_timestamp, base_time = %s.now() + '%d hours'::interval + '%d milliseconds'::interval`, m.safeSchema(), m.safeSchema(), d/time.Hour, (d % time.Hour).Milliseconds()) return m.readErr("advance time") } diff --git a/devtools/resetdb/datagen.go b/devtools/resetdb/datagen.go index a884130b17..e0e56eb711 100644 --- a/devtools/resetdb/datagen.go +++ b/devtools/resetdb/datagen.go @@ -29,7 +29,7 @@ import ( var ( timeZones = []string{"America/Chicago", "Europe/Berlin", "UTC"} - rotationTypes = []rotation.Type{rotation.TypeDaily, rotation.TypeHourly, rotation.TypeWeekly} + rotationTypes = []rotation.Type{rotation.TypeDaily, rotation.TypeHourly, rotation.TypeWeekly, rotation.TypeMonthly} ) type AlertLog struct { diff --git a/gadb/models.go b/gadb/models.go index 891f2c6a9e..be9ee7f4b8 100644 --- a/gadb/models.go +++ b/gadb/models.go @@ -532,9 +532,10 @@ func (ns NullEnumOutgoingMessagesType) Value() (driver.Value, error) { type EnumRotationType string const ( - EnumRotationTypeDaily EnumRotationType = "daily" - EnumRotationTypeHourly EnumRotationType = "hourly" - EnumRotationTypeWeekly EnumRotationType = "weekly" + EnumRotationTypeDaily EnumRotationType = "daily" + EnumRotationTypeHourly EnumRotationType = "hourly" + EnumRotationTypeMonthly EnumRotationType = "monthly" + EnumRotationTypeWeekly EnumRotationType = "weekly" ) func (e *EnumRotationType) Scan(src interface{}) error { diff --git a/graphql2/generated.go b/graphql2/generated.go index 8c3260353e..eba58d5e50 100644 --- a/graphql2/generated.go +++ b/graphql2/generated.go @@ -26600,7 +26600,7 @@ func (ec *executionContext) unmarshalInputCalcRotationHandoffTimesInput(ctx cont asMap[k] = v } - fieldsInOrder := [...]string{"handoff", "from", "timeZone", "shiftLengthHours", "count"} + fieldsInOrder := [...]string{"handoff", "from", "timeZone", "shiftLengthHours", "shiftLength", "count"} for _, k := range fieldsInOrder { v, ok := asMap[k] if !ok { @@ -26638,11 +26638,20 @@ func (ec *executionContext) unmarshalInputCalcRotationHandoffTimesInput(ctx cont var err error ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("shiftLengthHours")) - data, err := ec.unmarshalNInt2int(ctx, v) + data, err := ec.unmarshalOInt2ᚖint(ctx, v) if err != nil { return it, err } it.ShiftLengthHours = data + case "shiftLength": + var err error + + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("shiftLength")) + data, err := ec.unmarshalOISODuration2ᚖgithubᚗcomᚋtargetᚋgoalertᚋutilᚋtimeutilᚐISODuration(ctx, v) + if err != nil { + return it, err + } + it.ShiftLength = data case "count": var err error @@ -41148,6 +41157,22 @@ func (ec *executionContext) marshalOID2ᚖstring(ctx context.Context, sel ast.Se return res } +func (ec *executionContext) unmarshalOISODuration2ᚖgithubᚗcomᚋtargetᚋgoalertᚋutilᚋtimeutilᚐISODuration(ctx context.Context, v interface{}) (*timeutil.ISODuration, error) { + if v == nil { + return nil, nil + } + var res = new(timeutil.ISODuration) + err := res.UnmarshalGQL(v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalOISODuration2ᚖgithubᚗcomᚋtargetᚋgoalertᚋutilᚋtimeutilᚐISODuration(ctx context.Context, sel ast.SelectionSet, v *timeutil.ISODuration) graphql.Marshaler { + if v == nil { + return graphql.Null + } + return v +} + func (ec *executionContext) unmarshalOISOTimestamp2timeᚐTime(ctx context.Context, v interface{}) (time.Time, error) { res, err := UnmarshalISOTimestamp(v) return res, graphql.ErrorOnPath(ctx, err) diff --git a/graphql2/graphqlapp/rotation.go b/graphql2/graphqlapp/rotation.go index d74194764d..5cd60cdf79 100644 --- a/graphql2/graphqlapp/rotation.go +++ b/graphql2/graphqlapp/rotation.go @@ -12,6 +12,7 @@ import ( "github.com/target/goalert/search" "github.com/target/goalert/user" "github.com/target/goalert/util" + "github.com/target/goalert/util/timeutil" "github.com/target/goalert/validation" "github.com/target/goalert/validation/validate" @@ -378,27 +379,39 @@ func (m *Mutation) UpdateRotation(ctx context.Context, input graphql2.UpdateRota } func (a *Query) CalcRotationHandoffTimes(ctx context.Context, input *graphql2.CalcRotationHandoffTimesInput) ([]time.Time, error) { - var result []time.Time - var err error - err = validate.Many( - err, - validate.Range("count", input.Count, 0, 20), - validate.Range("hours", input.ShiftLengthHours, 0, 99999), - ) + err := validate.Range("count", input.Count, 0, 20) if err != nil { - return result, err + return nil, err } loc, err := util.LoadLocation(input.TimeZone) if err != nil { - return result, validation.NewFieldError("timeZone", err.Error()) + return nil, validation.NewFieldError("timeZone", err.Error()) + } + + if input.ShiftLength != nil && input.ShiftLengthHours != nil { + return nil, validation.NewFieldError("shiftLength", "only one of (shiftLength, shiftLengthHours) is allowed") } - rot := &rotation.Rotation{ - Start: input.Handoff.In(loc), - ShiftLength: input.ShiftLengthHours, - Type: rotation.TypeHourly, + rot := rotation.Rotation{ + Start: input.Handoff.In(loc), + } + switch { + case input.ShiftLength != nil: + err = setRotationShiftFromISO(&rot, input.ShiftLength) + if err != nil { + return nil, err + } + case input.ShiftLengthHours != nil: + err = validate.Range("hours", *input.ShiftLengthHours, 0, 99999) + if err != nil { + return nil, err + } + rot.Type = rotation.TypeHourly + rot.ShiftLength = *input.ShiftLengthHours + default: + return nil, validation.NewFieldError("shiftLength", "must be specified") } t := time.Now() @@ -406,6 +419,7 @@ func (a *Query) CalcRotationHandoffTimes(ctx context.Context, input *graphql2.Ca t = input.From.In(loc) } + var result []time.Time for len(result) < input.Count { t = rot.EndTime(t) result = append(result, t) @@ -413,3 +427,47 @@ func (a *Query) CalcRotationHandoffTimes(ctx context.Context, input *graphql2.Ca return result, nil } + +// getRotationFromISO determines the rotation type based on the given ISODuration. An error is given if the unsupported year field or multiple non-zero fields are given. +func setRotationShiftFromISO(rot *rotation.Rotation, dur *timeutil.ISODuration) error { + + // validate only one time field (year, month, days, timepart) is non-zero + nonZeroFields := 0 + + if dur.YearPart > 0 { + // These validation errors are only possible from direct api calls, + // thus using ISO standard terminology "designator" to match the spec. + return validation.NewFieldError("shiftLength", "year designator not allowed") + } + + if dur.MonthPart > 0 { + rot.Type = rotation.TypeMonthly + rot.ShiftLength = dur.MonthPart + nonZeroFields++ + } + if dur.WeekPart > 0 { + rot.Type = rotation.TypeWeekly + rot.ShiftLength = dur.WeekPart + nonZeroFields++ + } + if dur.DayPart > 0 { + rot.Type = rotation.TypeDaily + rot.ShiftLength = dur.DayPart + nonZeroFields++ + } + if dur.HourPart > 0 { + rot.Type = rotation.TypeHourly + rot.ShiftLength = dur.HourPart + nonZeroFields++ + } + + if nonZeroFields == 0 { + return validation.NewFieldError("shiftLength", "must not be zero") + } + if nonZeroFields > 1 { + // Same as above, this error is only possible from direct api calls. + return validation.NewFieldError("shiftLength", "only one of (M, W, D, H) is allowed") + } + + return nil +} diff --git a/graphql2/models_gen.go b/graphql2/models_gen.go index 7af777028d..20de99bdb3 100644 --- a/graphql2/models_gen.go +++ b/graphql2/models_gen.go @@ -78,11 +78,12 @@ type AuthSubjectConnection struct { } type CalcRotationHandoffTimesInput struct { - Handoff time.Time `json:"handoff"` - From *time.Time `json:"from,omitempty"` - TimeZone string `json:"timeZone"` - ShiftLengthHours int `json:"shiftLengthHours"` - Count int `json:"count"` + Handoff time.Time `json:"handoff"` + From *time.Time `json:"from,omitempty"` + TimeZone string `json:"timeZone"` + ShiftLengthHours *int `json:"shiftLengthHours,omitempty"` + ShiftLength *timeutil.ISODuration `json:"shiftLength,omitempty"` + Count int `json:"count"` } type ClearTemporarySchedulesInput struct { diff --git a/graphql2/schema.graphql b/graphql2/schema.graphql index fd198f7a4b..35ea0b922e 100644 --- a/graphql2/schema.graphql +++ b/graphql2/schema.graphql @@ -926,6 +926,7 @@ type Rotation { } enum RotationType { + monthly weekly daily hourly @@ -972,7 +973,12 @@ input CalcRotationHandoffTimesInput { handoff: ISOTimestamp! from: ISOTimestamp timeZone: String! - shiftLengthHours: Int! + shiftLengthHours: Int + @deprecated( + reason: "Only accurate for hourly-type rotations. Use shiftLength instead." + ) + + shiftLength: ISODuration count: Int! } diff --git a/migrate/migrations/20230911153243-add-monthly-rotation.sql b/migrate/migrations/20230911153243-add-monthly-rotation.sql new file mode 100644 index 0000000000..7268d14a5c --- /dev/null +++ b/migrate/migrations/20230911153243-add-monthly-rotation.sql @@ -0,0 +1,5 @@ +-- +migrate Up notransaction +ALTER TYPE enum_rotation_type ADD VALUE IF NOT EXISTS 'monthly'; + +-- +migrate Down + diff --git a/migrate/schema.sql b/migrate/schema.sql index d5602ff0e2..5f3253ed63 100644 --- a/migrate/schema.sql +++ b/migrate/schema.sql @@ -1,7 +1,7 @@ -- This file is auto-generated by "make db-schema"; DO NOT EDIT --- DATA=35c108711cef0a38d78d5872c062fe576844c7f16e309378a051faa813578de0 - --- DISK=b18bab67b9291c444025e882a3360c022c034d85477cad010781b2ec1b49fe3b - --- PSQL=b18bab67b9291c444025e882a3360c022c034d85477cad010781b2ec1b49fe3b - +-- DATA=6b404a10cbecec48dd680c5a6c3312950cef04a97ebf2b8bbff62df0c419d76d - +-- DISK=ef7c2b41f4d00eabfe482d4677365671da36c0562d0872b32d4adc95c74e0f8b - +-- PSQL=ef7c2b41f4d00eabfe482d4677365671da36c0562d0872b32d4adc95c74e0f8b - -- -- pgdump-lite database dump -- @@ -122,6 +122,7 @@ CREATE TYPE enum_outgoing_messages_type AS ENUM ( CREATE TYPE enum_rotation_type AS ENUM ( 'daily', 'hourly', + 'monthly', 'weekly' ); diff --git a/schedule/rotation/rotation.go b/schedule/rotation/rotation.go index 73998a9673..c4f9550df7 100644 --- a/schedule/rotation/rotation.go +++ b/schedule/rotation/rotation.go @@ -32,12 +32,66 @@ func (r Rotation) shiftClock() timeutil.Clock { case TypeWeekly: return timeutil.NewClock(r.ShiftLength*24*7, 0) default: + // monthly is handled separately panic("unexpected rotation type") } } +// monthStartTime recursively calculates the previous handoff time of a rotation active at t. +func (r Rotation) monthStartTime(t time.Time, n int) time.Time { + if n > 10000 { + panic("too many iterations") + } + + if !t.Before(r.Start) { // t is at or after start of rotation + next := r.Start.AddDate(0, r.ShiftLength*n, 0) + if next.After(t) { + return r.Start.AddDate(0, r.ShiftLength*(n-1), 0) + } + + // recursively finds the end of shift time of the rotation which came immediately before t + return r.monthStartTime(t, n+1) + } + + // t is before start of rotation + prev := r.Start.AddDate(0, -r.ShiftLength*n, 0) + if prev.Before(t) { + return prev + } + + // recursively finds the end of shift time of the rotation which came immediately before t when t is before the rotation start time + return r.monthStartTime(t, n+1) +} + +// monthEndTime recursively calculates the end of the rotation (handoff time) that was active at time t. +func (r Rotation) monthEndTime(t time.Time, n int) time.Time { + if n > 10000 { + panic("too many iterations") + } + + if !t.Before(r.Start) { // t is at or after start of rotation + next := r.Start.AddDate(0, r.ShiftLength*n, 0) + if next.After(t) { + return next + } + + // recursively finds the immediate end of shift time after t + return r.monthEndTime(t, n+1) + } + + // t is before start of rotation + prev := r.Start.AddDate(0, -r.ShiftLength*n, 0) + if prev.Before(t) || prev.Equal(t) { + return r.Start.AddDate(0, -r.ShiftLength*(n-1), 0) + } + + // recursively finds the immediate end of shift time after t for cases when t is before the rotation start time + return r.monthEndTime(t, n+1) +} + // StartTime calculates the start of the "shift" that started at (or was active) at t. -// For daily and weekly rotations, start time will be the previous handoff time (from start). +// For daily, weekly, and monthly rotations, start time will be the previous handoff time (from start). +// For monthly rotations, the monthStartTime function is used to recursively handle calculations as the length of months vary. func (r Rotation) StartTime(t time.Time) time.Time { if r.ShiftLength <= 0 { r.ShiftLength = 1 @@ -45,6 +99,10 @@ func (r Rotation) StartTime(t time.Time) time.Time { t = t.In(r.Start.Location()).Truncate(time.Minute) r.Start = r.Start.Truncate(time.Minute) + if r.Type == TypeMonthly { + return r.monthStartTime(t, 1) + } + shiftClockLen := r.shiftClock() rem := timeutil.ClockDiff(r.Start, t) % shiftClockLen @@ -56,6 +114,7 @@ func (r Rotation) StartTime(t time.Time) time.Time { } // EndTime calculates the end of the "shift" that started at (or was active) at t. +// For monthly rotations, the monthEndTime function is used to recursively handle calculations as the length of months vary. // // It is guaranteed to occur after t. func (r Rotation) EndTime(t time.Time) time.Time { @@ -65,6 +124,10 @@ func (r Rotation) EndTime(t time.Time) time.Time { t = t.In(r.Start.Location()).Truncate(time.Minute) r.Start = r.Start.Truncate(time.Minute) + if r.Type == TypeMonthly { + return r.monthEndTime(t, 1) + } + shiftClockLen := r.shiftClock() rem := timeutil.ClockDiff(r.Start, t) % shiftClockLen @@ -88,7 +151,7 @@ func (r Rotation) Normalize() (*Rotation, error) { err := validate.Many( validate.IDName("Name", r.Name), validate.Range("ShiftLength", r.ShiftLength, 1, 9000), - validate.OneOf("Type", r.Type, TypeWeekly, TypeDaily, TypeHourly), + validate.OneOf("Type", r.Type, TypeMonthly, TypeWeekly, TypeDaily, TypeHourly), validate.Text("Description", r.Description, 1, 255), ) if err != nil { diff --git a/schedule/rotation/rotation_test.go b/schedule/rotation/rotation_test.go index cc352ae9a9..de76070f7f 100644 --- a/schedule/rotation/rotation_test.go +++ b/schedule/rotation/rotation_test.go @@ -165,6 +165,36 @@ func TestRotation_StartEnd_BruteForce(t *testing.T) { "2020-11-01 05:00:00 -0600 CST", ) + // monthly rotation sanity check + check(&Rotation{ + Type: TypeMonthly, + ShiftLength: 1, + Start: time.Date(2020, time.January, 2, 1, 30, 0, 0, loc), + }, + time.Date(2020, time.April, 5, 1, 0, 0, 0, loc), + time.Date(2020, time.June, 12, 1, 0, 0, 0, loc), + + "2020-04-02 01:30:00 -0500 CDT", + "2020-05-02 01:30:00 -0500 CDT", + "2020-06-02 01:30:00 -0500 CDT", + "2020-07-02 01:30:00 -0500 CDT", + ) + + // check monthly rotations with shift from daylight savings to standard time + check(&Rotation{ + Type: TypeMonthly, + ShiftLength: 1, + Start: time.Date(2020, time.October, 1, 1, 30, 0, 0, loc), + }, + time.Date(2020, time.October, 2, 1, 0, 0, 0, loc), + time.Date(2020, time.December, 2, 1, 0, 0, 0, loc), + + "2020-10-01 01:30:00 -0500 CDT", + "2020-11-01 01:30:00 -0500 CDT", + "2020-12-01 01:30:00 -0600 CST", + "2021-01-01 01:30:00 -0600 CST", + ) + loc, err = time.LoadLocation("Australia/Lord_Howe") require.NoError(t, err) @@ -288,7 +318,6 @@ func TestRotation_StartEnd_BruteForce(t *testing.T) { "2020-10-05 02:15:00 +1100 +11", "2020-10-06 02:15:00 +1100 +11", ) - } func TestRotation_EndTime_DST(t *testing.T) { @@ -396,8 +425,21 @@ func TestRotation_EndTime(t *testing.T) { dur time.Duration } - // weekly + // monthly data := []dat{ + {s: "Jun 1 2017 12:00 am", l: 1, exp: "Jul 1 2017 12:00 am", dur: time.Hour * 24 * 30}, + {s: "Jul 1 2017 12:00 am", l: 2, exp: "Sep 1 2017 12:00 am", dur: time.Hour * 24 * 31 * 2}, + + // DST tests + {s: "Mar 1 2017 12:00 am", l: 1, exp: "Apr 1 2017 12:00 am", dur: time.Hour*24*31 - time.Hour}, + {s: "Nov 1 2017 12:00 am", l: 2, exp: "Jan 1 2018 12:00 am", dur: time.Hour*(24*(30+31)) + time.Hour}, + } + for _, d := range data { + test(d.s, d.exp, d.l, d.dur, TypeMonthly) + } + + // weekly + data = []dat{ {s: "Jun 10 2017 8:00 am", l: 1, exp: "Jun 17 2017 8:00 am", dur: time.Hour * 24 * 7}, {s: "Jun 10 2017 8:00 am", l: 2, exp: "Jun 24 2017 8:00 am", dur: time.Hour * 24 * 7 * 2}, @@ -468,6 +510,23 @@ func TestRotation_EndTime(t *testing.T) { ts = r.EndTime(ts) assert.Equal(t, orig.AddDate(0, 0, 1).String(), ts.String()) }) + + t.Run("subsequent calls (monthly)", func(t *testing.T) { + orig := time.Date(2020, time.January, 10, 0, 0, 0, 0, time.UTC) + r := &Rotation{ + Type: TypeMonthly, + ShiftLength: 1, + Start: orig, + } + ts := r.EndTime(orig.AddDate(0, -2, 0)) + assert.Equal(t, orig.AddDate(0, -1, 0).String(), ts.String()) + + ts = r.EndTime(ts) + assert.Equal(t, orig.String(), ts.String()) + + ts = r.EndTime(ts) + assert.Equal(t, orig.AddDate(0, 1, 0).String(), ts.String()) + }) } func TestRotation_Normalize(t *testing.T) { @@ -564,8 +623,21 @@ func TestRotation_StartTime(t *testing.T) { dur time.Duration } - // weekly + // monthly data := []dat{ + {s: "Jun 1 2017 12:00 am", l: 1, exp: "Jul 1 2017 12:00 am", dur: time.Hour * 24 * 30}, + {s: "Jul 1 2017 12:00 am", l: 2, exp: "Sep 1 2017 12:00 am", dur: time.Hour * 24 * 31 * 2}, + + // DST tests + {s: "Mar 1 2017 12:00 am", l: 1, exp: "Apr 1 2017 12:00 am", dur: time.Hour*24*31 - time.Hour}, + {s: "Nov 1 2017 12:00 am", l: 2, exp: "Jan 1 2018 12:00 am", dur: time.Hour*(24*(31+30)) + time.Hour}, + } + for _, d := range data { + test(d.s, d.exp, d.l, d.dur, TypeMonthly) + } + + // weekly + data = []dat{ {s: "Jun 10 2017 8:00 am", l: 1, exp: "Jun 17 2017 8:00 am", dur: time.Hour * 24 * 7}, {s: "Jun 10 2017 8:00 am", l: 2, exp: "Jun 24 2017 8:00 am", dur: time.Hour * 24 * 7 * 2}, diff --git a/schedule/rotation/type.go b/schedule/rotation/type.go index 0e0e7d9710..fd90efc63c 100644 --- a/schedule/rotation/type.go +++ b/schedule/rotation/type.go @@ -3,18 +3,20 @@ package rotation import ( "database/sql/driver" "fmt" - "github.com/target/goalert/validation" "io" + "github.com/target/goalert/validation" + "github.com/99designs/gqlgen/graphql" ) type Type string const ( - TypeWeekly Type = "weekly" - TypeDaily Type = "daily" - TypeHourly Type = "hourly" + TypeMonthly Type = "monthly" + TypeWeekly Type = "weekly" + TypeDaily Type = "daily" + TypeHourly Type = "hourly" ) // Scan handles reading a Role from the DB format @@ -34,7 +36,7 @@ func (r *Type) Scan(value interface{}) error { // Value converts the Role to the DB representation func (r Type) Value() (driver.Value, error) { switch r { - case TypeWeekly, TypeDaily, TypeHourly: + case TypeMonthly, TypeWeekly, TypeDaily, TypeHourly: return string(r), nil default: return nil, fmt.Errorf("unknown rotation type specified '%s'", r) @@ -48,6 +50,8 @@ func (t *Type) UnmarshalGQL(v interface{}) error { return err } switch str { + case "monthly": + *t = TypeMonthly case "weekly": *t = TypeWeekly case "daily": @@ -64,6 +68,8 @@ func (t *Type) UnmarshalGQL(v interface{}) error { // MarshalGQL implements the graphql.Marshaler interface func (t Type) MarshalGQL(w io.Writer) { switch t { + case TypeMonthly: + graphql.MarshalString("monthly").MarshalGQL(w) case TypeWeekly: graphql.MarshalString("weekly").MarshalGQL(w) case TypeHourly: diff --git a/test/smoke/harness/graphql.go b/test/smoke/harness/graphql.go index c7522d24f6..418049ab58 100644 --- a/test/smoke/harness/graphql.go +++ b/test/smoke/harness/graphql.go @@ -22,22 +22,23 @@ import ( // DefaultGraphQLAdminUserID is the UserID created & used for GraphQL calls by default. const DefaultGraphQLAdminUserID = "00000000-0000-0000-0000-000000000002" -func (h *Harness) insertGraphQLUser(userID string) string { +func (h *Harness) createGraphQLUser(userID string) { h.t.Helper() - var err error - if userID == DefaultGraphQLAdminUserID { - permission.SudoContext(context.Background(), func(ctx context.Context) { - _, err = h.backend.UserStore.Insert(ctx, &user.User{ - Name: "GraphQL User", - ID: userID, - Role: permission.RoleAdmin, - }) + permission.SudoContext(context.Background(), func(ctx context.Context) { + h.t.Helper() + _, err := h.backend.UserStore.Insert(ctx, &user.User{ + Name: "GraphQL User", + ID: userID, + Role: permission.RoleAdmin, }) if err != nil { h.t.Fatal(errors.Wrap(err, "create GraphQL user")) } - } + }) +} +func (h *Harness) createGraphQLSession(userID string) string { + h.t.Helper() tok, err := h.backend.AuthHandler.CreateSession(context.Background(), "goalert-smoketest", userID) if err != nil { h.t.Fatal(errors.Wrap(err, "create auth session")) @@ -91,43 +92,61 @@ func (h *Harness) GraphQLQueryT(t *testing.T, query string) *QLResponse { // handling authentication. Queries are performed with the provided UserID. func (h *Harness) GraphQLQueryUserT(t *testing.T, userID, query string) *QLResponse { t.Helper() + retry := 1 + var err error + var resp *http.Response + var tok string h.mx.Lock() - tok := h.gqlSessions[userID] + tok = h.gqlSessions[userID] if tok == "" { - tok = h.insertGraphQLUser(userID) + if userID == DefaultGraphQLAdminUserID { + h.createGraphQLUser(userID) + } + tok = h.createGraphQLSession(userID) } h.mx.Unlock() - query = strings.Replace(query, "\t", "", -1) - q := struct{ Query string }{Query: query} + for { + query = strings.Replace(query, "\t", "", -1) + q := struct{ Query string }{Query: query} - data, err := json.Marshal(q) - if err != nil { - h.t.Fatal("failed to marshal graphql query") - } - t.Log("Query:", query) + data, err := json.Marshal(q) + if err != nil { + h.t.Fatal("failed to marshal graphql query") + } + t.Log("Query:", query) - url := h.URL() + "/api/graphql" - req, err := http.NewRequest("POST", url, bytes.NewBuffer(data)) - if err != nil { - t.Fatal("failed to make request:", err) - } - req.Header.Set("Content-Type", "application/json") - req.AddCookie(&http.Cookie{ - Name: auth.CookieName, - Value: tok, - }) - resp, err := http.DefaultClient.Do(req) - if err != nil { - t.Fatal("failed to make http request:", err) + url := h.URL() + "/api/graphql" + req, err := http.NewRequest("POST", url, bytes.NewBuffer(data)) + if err != nil { + t.Fatal("failed to make request:", err) + } + req.Header.Set("Content-Type", "application/json") + req.AddCookie(&http.Cookie{ + Name: auth.CookieName, + Value: tok, + }) + resp, err = http.DefaultClient.Do(req) + if err != nil { + t.Fatal("failed to make http request:", err) + } + if resp.StatusCode == 401 && retry > 0 { + h.t.Logf("GraphQL request failed with 401, retrying with new session (%d left)", retry) + h.mx.Lock() + tok = h.createGraphQLSession(userID) + h.mx.Unlock() + resp.Body.Close() + retry-- + continue + } + if resp.StatusCode != 200 { + data, _ := io.ReadAll(resp.Body) + t.Fatal("failed to make graphql request:", resp.Status, string(data)) + } + break } defer resp.Body.Close() - if resp.StatusCode != 200 { - data, _ := io.ReadAll(resp.Body) - t.Fatal("failed to make graphql request:", resp.Status, string(data)) - } - var r QLResponse err = json.NewDecoder(resp.Body).Decode(&r) if err != nil { diff --git a/test/smoke/rotationmonthly_test.go b/test/smoke/rotationmonthly_test.go new file mode 100644 index 0000000000..4c19413e2a --- /dev/null +++ b/test/smoke/rotationmonthly_test.go @@ -0,0 +1,68 @@ +package smoke + +import ( + "testing" + "time" + + "github.com/target/goalert/test/smoke/harness" +) + +func TestRotation_Monthly(t *testing.T) { + t.Parallel() + + const sql = ` + set timezone = 'America/Chicago'; + + insert into users (id, name, email) + values + ({{uuid "uid1"}}, 'bob', 'joe'), + ({{uuid "uid2"}}, 'ben', 'frank'); + + insert into user_contact_methods (id, user_id, name, type, value) + values + ({{uuid "cm1"}}, {{uuid "uid1"}}, 'personal', 'SMS', {{phone "1"}}), + ({{uuid "cm2"}}, {{uuid "uid2"}}, 'personal', 'SMS', {{phone "2"}}); + + insert into user_notification_rules (user_id, contact_method_id, delay_minutes) + values + ({{uuid "uid1"}}, {{uuid "cm1"}}, 0), + ({{uuid "uid2"}}, {{uuid "cm2"}}, 0); + + insert into escalation_policies (id, name) + values + ({{uuid "eid"}}, 'esc policy'); + insert into escalation_policy_steps (id, escalation_policy_id) + values + ({{uuid "esid"}}, {{uuid "eid"}}); + + insert into rotations (id, name, type, start_time, shift_length, time_zone) + values + ({{uuid "rot1"}}, 'default rotation', 'monthly', now(), 1, 'America/Chicago'); + + insert into rotation_participants (rotation_id, user_id, position) + values + ({{uuid "rot1"}}, {{uuid "uid1"}}, 0), + ({{uuid "rot1"}}, {{uuid "uid2"}}, 1); + + insert into escalation_policy_actions (escalation_policy_step_id, rotation_id) + values + ({{uuid "esid"}}, {{uuid "rot1"}}); + + insert into services (id, escalation_policy_id, name) values + ({{uuid "sid"}}, {{uuid "eid"}}, 'service'); + + ` + + h := harness.NewHarness(t, sql, "add-monthly-rotation") + defer h.Close() + + sid := h.UUID("sid") + uid1 := h.UUID("uid1") + uid2 := h.UUID("uid2") + + h.WaitAndAssertOnCallUsers(sid, uid1) + + h.FastForward(32 * 24 * time.Hour) + + h.WaitAndAssertOnCallUsers(sid, uid2) +} diff --git a/web/src/app/rotations/HandoffSummary.tsx b/web/src/app/rotations/HandoffSummary.tsx index b24ddf2c02..4234a6b04b 100644 --- a/web/src/app/rotations/HandoffSummary.tsx +++ b/web/src/app/rotations/HandoffSummary.tsx @@ -9,14 +9,15 @@ export interface HandoffSummaryProps { timeZone: string } -function dur(p: HandoffSummaryProps): JSX.Element { +function dur(p: HandoffSummaryProps): JSX.Element | string { if (p.type === 'hourly') return