From e714d72ce103b05bdd3642c758e9bcca75274d57 Mon Sep 17 00:00:00 2001 From: Matthieu Vachon Date: Fri, 21 Aug 2026 15:06:35 -0400 Subject: [PATCH] Normalize since/date-range parsing across substreams logs commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit logs connection and logs connections handled time ranges through two unrelated interfaces with incompatible parsers, so a value copied between them broke — most visibly --since 2d on connections, which Go's native time.Duration parser rejects (no d unit). logs connection now takes --since/--date-range flags instead of a positional date-range argument, matching logs connections. Both share one relative-duration/date-range parser now, and all three commands (connection, connections, reexec) gained a 1w shortcut (d == 24h, w == 7d == 168h, fixed-length to avoid calendar ambiguity). --- CHANGELOG.md | 2 + cmd/tools/substreams/daterange.go | 7 +- cmd/tools/substreams/daterange_test.go | 2 + cmd/tools/substreams/logs.go | 79 +++++------- cmd/tools/substreams/logs_connection.go | 40 ++----- cmd/tools/substreams/logs_connection_test.go | 31 ++++- cmd/tools/substreams/logs_reexec.go | 2 +- cmd/tools/substreams/logs_test.go | 120 +++++++++---------- 8 files changed, 139 insertions(+), 144 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e1533d3..7db212b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -71,6 +71,8 @@ If you were at `firehose-core` version `1.0.0` and are bumping to `1.1.0`, you s - Flags inherited from an intermediate command are now printed in their own help section instead of being mixed into `Global Flags`, so `fire{chain} tools ...` commands show a `Tools Flags` section with `--output`, `--bytes-encoding`, `--proto-paths` and `--merged-blocks-bundle-size`. +- `firecore tools substreams logs connection` now takes its time range via `--since`/`--date-range` flags instead of a positional `[]` argument, matching `logs connections`. Both commands now share the same `--since`/`--date-range` parsing: `--since` accepts a relative duration (`30m`, `2h`, `1d`, `1w`, `"1 day ago"`, …) instead of Go's native duration format, which silently rejected `d`/`w` units, and `--date-range` accepts the colon-separated and bare-timestamp forms already supported by `logs reexec`'s date-range argument. `logs reexec` also gained the `1w` shortcut. A value can now be copied between `logs connections` and `logs connection` unchanged. + ### Deprecated - `firecore.HideGlobalFlagsOnChildCmd` is now a no-op and prints a deprecation warning when called, remove the call from your chain. Global flags are all hidden behind `--gh` now, hiding a hand-picked subset of them is no longer useful. diff --git a/cmd/tools/substreams/daterange.go b/cmd/tools/substreams/daterange.go index af37f85..83e0c36 100644 --- a/cmd/tools/substreams/daterange.go +++ b/cmd/tools/substreams/daterange.go @@ -31,7 +31,10 @@ func ParseLogDateRange(args []string) (time.Time, time.Time, error) { var relDurationRegex = regexp.MustCompile(`(?i)^\s*(\d+)\s*([a-z]+?)(?:\s+ago)?\s*$`) -// parseRelativeDuration parses strings like "1d", "2hr", "30m", "1 day ago". +// parseRelativeDuration parses strings like "1d", "2hr", "30m", "1w", "1 day ago". +// +// To avoid calendar ambiguity (months/DST/etc.), units are fixed-length +// shortcuts: d == 24h, w == 7d == 168h. func parseRelativeDuration(s string) (time.Duration, bool) { m := relDurationRegex.FindStringSubmatch(strings.TrimSpace(s)) if m == nil { @@ -43,6 +46,8 @@ func parseRelativeDuration(s string) (time.Duration, bool) { } unit := strings.ToLower(m[2]) switch { + case strings.HasPrefix(unit, "w"): + return time.Duration(n) * 7 * 24 * time.Hour, true case strings.HasPrefix(unit, "d"): return time.Duration(n) * 24 * time.Hour, true case strings.HasPrefix(unit, "h"): diff --git a/cmd/tools/substreams/daterange_test.go b/cmd/tools/substreams/daterange_test.go index b7ce212..e712993 100644 --- a/cmd/tools/substreams/daterange_test.go +++ b/cmd/tools/substreams/daterange_test.go @@ -79,6 +79,8 @@ func TestParseRelativeDuration(t *testing.T) { {"1d", 24 * time.Hour, true}, {"2hr", 2 * time.Hour, true}, {"30m", 30 * time.Minute, true}, + {"1w", 7 * 24 * time.Hour, true}, + {"2w", 14 * 24 * time.Hour, true}, {"1 day ago", 24 * time.Hour, true}, {"2 hours ago", 2 * time.Hour, true}, {"0d", 0, false}, // zero is rejected diff --git a/cmd/tools/substreams/logs.go b/cmd/tools/substreams/logs.go index 43fbaa7..af9b8c4 100644 --- a/cmd/tools/substreams/logs.go +++ b/cmd/tools/substreams/logs.go @@ -30,6 +30,11 @@ Note: Currently only GCP Cloud Logging backend is supported.`, return cmd } +const ( + sinceFlagHelp = "Look back duration: relative value like '30m', '2h', '1d', '1w', or \"1 day ago\". Mutually exclusive with --date-range" + dateRangeFlagHelp = "Date range as '/', ':', or a single '' (end defaults to now). Mutually exclusive with --since" +) + // NewToolsLogsConnectionsCmd returns the "connections" subcommand func NewToolsLogsConnectionsCmd(logger *zap.Logger) *cobra.Command { cmd := &cobra.Command{ @@ -47,8 +52,10 @@ Note: Currently only GCP Cloud Logging backend is supported.`, Example: ` # Show connections for the last hour (default) firecore tools substreams logs connections sfinfra --gcp-project my-project - # Show connections for the last 30 minutes + # Show connections for the last 30 minutes, 2 days, or 1 week firecore tools substreams logs connections sfinfra --gcp-project my-project --since 30m + firecore tools substreams logs connections sfinfra --gcp-project my-project --since 2d + firecore tools substreams logs connections sfinfra --gcp-project my-project --since 1w # Show connections for a specific date range firecore tools substreams logs connections sfinfra --gcp-project my-project --date-range 2024-01-15T10:00:00Z/2024-01-15T12:00:00Z @@ -62,8 +69,8 @@ Note: Currently only GCP Cloud Logging backend is supported.`, } cmd.Flags().String("backend", "gcp", "Log backend to use (currently only 'gcp' supported)") - cmd.Flags().Duration("since", time.Duration(0), "Look back duration (e.g., '1h', '30m', '2d'). Mutually exclusive with --date-range") - cmd.Flags().String("date-range", "", "Date range in format '[/]'. End defaults to now. Mutually exclusive with --since") + cmd.Flags().String("since", "", sinceFlagHelp) + cmd.Flags().String("date-range", "", dateRangeFlagHelp) cmd.Flags().StringP("k8s-namespace", "n", "", "Kubernetes namespace to filter logs") cmd.Flags().String("gcp-project", "", "GCP project ID (required for GCP backend)") @@ -72,7 +79,7 @@ Note: Currently only GCP Cloud Logging backend is supported.`, func runConnections(ctx context.Context, userID string, cmd *cobra.Command, logger *zap.Logger) error { backend := sflags.MustGetString(cmd, "backend") - since := sflags.MustGetDuration(cmd, "since") + since := sflags.MustGetString(cmd, "since") dateRange := sflags.MustGetString(cmd, "date-range") namespace := sflags.MustGetString(cmd, "k8s-namespace") gcpProject := sflags.MustGetString(cmd, "gcp-project") @@ -80,19 +87,19 @@ func runConnections(ctx context.Context, userID string, cmd *cobra.Command, logg logger.Debug("connections command invoked", zap.String("user_id", userID), zap.String("backend", backend), - zap.Duration("since", since), + zap.String("since", since), zap.String("date_range", dateRange), zap.String("namespace", namespace), zap.String("gcp_project", gcpProject), ) // Validate flags - if since != 0 && dateRange != "" { + if since != "" && dateRange != "" { return fmt.Errorf("--since and --date-range are mutually exclusive") } // Parse time range - startTime, endTime, err := parseTimeRange(since, dateRange) + startTime, endTime, err := parseTimeRange(since, dateRange, time.Hour) if err != nil { return fmt.Errorf("parsing time range: %w", err) } @@ -157,58 +164,32 @@ func runConnections(ctx context.Context, userID string, cmd *cobra.Command, logg return nil } -// parseTimeRange parses the --since or --date-range flags into start/end times -// If neither is provided, defaults to 1 hour ago -func parseTimeRange(since time.Duration, dateRange string) (time.Time, time.Time, error) { +// parseTimeRange parses the --since or --date-range flags into start/end times. +// Both are parsed by the same logic used by the 'reexec' date-range argument +// (ParseLogDateRange / parseRelativeDuration), so a value copied from one +// command works unchanged on the other. If neither is provided, looks back +// defaultLookback from now. +func parseTimeRange(since, dateRange string, defaultLookback time.Duration) (time.Time, time.Time, error) { now := time.Now() - // Default: 1 hour ago - if since == 0 && dateRange == "" { - return now.Add(-1 * time.Hour), now, nil + if since == "" && dateRange == "" { + return now.Add(-defaultLookback), now, nil } - if (since != 0) && dateRange != "" { + if since != "" && dateRange != "" { return time.Time{}, time.Time{}, fmt.Errorf("--since and --date-range are mutually exclusive") } - if since != 0 { - return now.Add(-since), now, nil - } - - // Parse --date-range - return parseDateRange(dateRange) -} - -// parseDateRange parses a date range in format "[/]" -// If end is omitted, uses current time -func parseDateRange(dateRange string) (time.Time, time.Time, error) { - // Split by '/' separator - parts := strings.SplitN(dateRange, "/", 2) - - if len(parts) == 0 || parts[0] == "" { - return time.Time{}, time.Time{}, fmt.Errorf("invalid date range format") - } - - startTime, err := parseDateTime(parts[0]) - if err != nil { - return time.Time{}, time.Time{}, fmt.Errorf("parsing start time: %w", err) - } - - var endTime time.Time - if len(parts) == 1 || parts[1] == "" { - endTime = time.Now() - } else { - endTime, err = parseDateTime(parts[1]) - if err != nil { - return time.Time{}, time.Time{}, fmt.Errorf("parsing end time: %w", err) + if since != "" { + d, ok := parseRelativeDuration(since) + if !ok { + return time.Time{}, time.Time{}, fmt.Errorf("invalid --since value %q (try: 30m, 2h, 1d, 1w, \"1 day ago\")", since) } + return now.Add(-d), now, nil } - if endTime.Before(startTime) { - return time.Time{}, time.Time{}, fmt.Errorf("end time must be after start time") - } - - return startTime, endTime, nil + // Parse --date-range, same format as the reexec date-range argument + return ParseLogDateRange([]string{dateRange}) } // Supported date/time formats in order of precedence diff --git a/cmd/tools/substreams/logs_connection.go b/cmd/tools/substreams/logs_connection.go index 22f4ada..c156a56 100644 --- a/cmd/tools/substreams/logs_connection.go +++ b/cmd/tools/substreams/logs_connection.go @@ -20,7 +20,7 @@ import ( // .spkg URL and the relevant Substreams request information for a single trace ID. func NewToolsLogsConnectionCmd(logger *zap.Logger) *cobra.Command { cmd := &cobra.Command{ - Use: "connection []", + Use: "connection ", Short: "Show Substreams request details for a single trace ID", Long: `Looks up the Substreams request matching the given trace ID in GCP Cloud Logging and prints the request details (output module, blocks, mode flags, namespace), the .spkg URL @@ -28,19 +28,11 @@ on the state store, and the request stats (when available). The trace-id must be 0-32 characters. -The date-range is optional; when omitted it defaults to the last 7 days. - -The date-range argument(s) accept various formats: - - Relative: 1d 2hr 30m "1 day ago" "2 hours ago" "30 minutes ago" - Timestamp: 2024-01-15T10:00:00Z (past → search start to now) - Range: "2024-01-15T10:00:00Z:2024-01-15T12:00:00Z" (single arg with colon) - "2024-01-15T10:00:00Z/2024-01-15T12:00:00Z" (single arg with slash) - Two args: 2024-01-15T10:00:00Z 2024-01-15T12:00:00Z`, +When neither --since nor --date-range is provided, defaults to the last 7 days.`, Example: ` firecore tools substreams logs connection bfb0980c436f3fd6f5564a31311d583f \ --gcp-project my-project - firecore tools substreams logs connection bfb0980c436f3fd6f5564a31311d583f 2h \ + firecore tools substreams logs connection bfb0980c436f3fd6f5564a31311d583f --since 2h \ --state-store gs://my-bucket/substreams-states \ --gcp-project my-project @@ -51,14 +43,16 @@ The date-range argument(s) accept various formats: # Skip the 'Logs' section entirely, no question asked firecore tools substreams logs connection bfb0980c436f3fd6f5564a31311d583f \ --gcp-project my-project --logs=false`, - Args: cobra.RangeArgs(1, 3), + Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - return runConnection(cmd.Context(), args, cmd, logger) + return runConnection(cmd.Context(), args[0], cmd, logger) }, } cmd.Flags().String("state-store", "", "State store URL where spkg files are stored (supports file:// and gs://). When set, the spkg is loaded to display the package name and full URL") cmd.Flags().String("gcp-project", "", "GCP project ID used for log querying") + cmd.Flags().String("since", "", sinceFlagHelp) + cmd.Flags().String("date-range", "", dateRangeFlagHelp) cmd.Flags().Bool("logs", false, "Print every log line of the request in a final 'Logs' section, explicitly passing --logs=false skips the section altogether") cmd.Flags().Int("logs-limit", 500, "Maximum number of log lines to print with --logs, keeping the most recent ones (0 means no limit)") cmd.MarkFlagRequired("gcp-project") @@ -66,16 +60,15 @@ The date-range argument(s) accept various formats: return cmd } -func runConnection(ctx context.Context, args []string, cmd *cobra.Command, logger *zap.Logger) error { - traceID := args[0] - dateArgs := args[1:] - +func runConnection(ctx context.Context, traceID string, cmd *cobra.Command, logger *zap.Logger) error { if len(traceID) > 32 { return fmt.Errorf("trace-id must be at most 32 characters, got %d (%q)", len(traceID), traceID) } stateStore := sflags.MustGetString(cmd, "state-store") gcpProject := sflags.MustGetString(cmd, "gcp-project") + since := sflags.MustGetString(cmd, "since") + dateRange := sflags.MustGetString(cmd, "date-range") showLogs, showLogsProvided := sflags.MustGetBoolProvided(cmd, "logs") skipLogs := showLogsProvided && !showLogs logsLimit := sflags.MustGetInt(cmd, "logs-limit") @@ -84,16 +77,9 @@ func runConnection(ctx context.Context, args []string, cmd *cobra.Command, logge return fmt.Errorf("--logs-limit must be greater than or equal to 0, got %d", logsLimit) } - var startTime, endTime time.Time - if len(dateArgs) == 0 { - endTime = time.Now() - startTime = endTime.AddDate(0, 0, -7) - } else { - var err error - startTime, endTime, err = ParseLogDateRange(dateArgs) - if err != nil { - return fmt.Errorf("parsing date range: %w", err) - } + startTime, endTime, err := parseTimeRange(since, dateRange, 7*24*time.Hour) + if err != nil { + return fmt.Errorf("parsing time range: %w", err) } logger.Debug("connection invoked", diff --git a/cmd/tools/substreams/logs_connection_test.go b/cmd/tools/substreams/logs_connection_test.go index ff6b155..6d26ba6 100644 --- a/cmd/tools/substreams/logs_connection_test.go +++ b/cmd/tools/substreams/logs_connection_test.go @@ -13,7 +13,7 @@ import ( func TestNewToolsLogsConnectionCmd(t *testing.T) { cmd := NewToolsLogsConnectionCmd(zlogTest) - assert.Equal(t, "connection []", cmd.Use) + assert.Equal(t, "connection ", cmd.Use) assert.NotEmpty(t, cmd.Short) assert.NotEmpty(t, cmd.Long) assert.NotEmpty(t, cmd.Example) @@ -26,6 +26,12 @@ func TestNewToolsLogsConnectionCmd(t *testing.T) { gcpProjectFlag := flags.Lookup("gcp-project") require.NotNil(t, gcpProjectFlag, "gcp-project flag should exist") + sinceFlag := flags.Lookup("since") + require.NotNil(t, sinceFlag, "since flag should exist") + + dateRangeFlag := flags.Lookup("date-range") + require.NotNil(t, dateRangeFlag, "date-range flag should exist") + logsFlag := flags.Lookup("logs") require.NotNil(t, logsFlag, "logs flag should exist") assert.Equal(t, "false", logsFlag.DefValue) @@ -100,7 +106,7 @@ func TestConnectionCommandRegistered(t *testing.T) { connectionCmd, _, err := logsCmd.Find([]string{"connection"}) require.NoError(t, err) - assert.Equal(t, "connection []", connectionCmd.Use) + assert.Equal(t, "connection ", connectionCmd.Use) } func TestConnectionCommandValidation(t *testing.T) { @@ -114,6 +120,16 @@ func TestConnectionCommandValidation(t *testing.T) { require.Error(t, err) }) + t.Run("extra positional argument rejected", func(t *testing.T) { + cmd := NewToolsLogsConnectionCmd(zlogTest) + cmd.SilenceUsage = true + cmd.SilenceErrors = true + cmd.SetArgs([]string{"abc123", "2h"}) + + err := cmd.Execute() + require.Error(t, err) + }) + t.Run("missing gcp-project flag", func(t *testing.T) { cmd := NewToolsLogsConnectionCmd(zlogTest) cmd.SilenceUsage = true @@ -124,6 +140,17 @@ func TestConnectionCommandValidation(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "gcp-project") }) + + t.Run("since and date-range are mutually exclusive", func(t *testing.T) { + cmd := NewToolsLogsConnectionCmd(zlogTest) + cmd.SilenceUsage = true + cmd.SilenceErrors = true + cmd.SetArgs([]string{"abc123", "--since", "1h", "--date-range", "2024-01-15T10:00:00Z", "--gcp-project", "test"}) + + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "mutually exclusive") + }) } func TestPickConnectionEntries(t *testing.T) { diff --git a/cmd/tools/substreams/logs_reexec.go b/cmd/tools/substreams/logs_reexec.go index 63aac72..a9bb003 100644 --- a/cmd/tools/substreams/logs_reexec.go +++ b/cmd/tools/substreams/logs_reexec.go @@ -44,7 +44,7 @@ The date-range is optional; when omitted it defaults to the last 7 days. The date-range argument(s) accept various formats: - Relative: 1d 2hr 30m "1 day ago" "2 hours ago" "30 minutes ago" + Relative: 1d 2hr 30m 1w "1 day ago" "2 hours ago" "30 minutes ago" Timestamp: 2024-01-15T10:00:00Z (past → search start to now) Range: "2024-01-15T10:00:00Z:2024-01-15T12:00:00Z" (single arg with colon) "2024-01-15T10:00:00Z/2024-01-15T12:00:00Z" (single arg with slash) diff --git a/cmd/tools/substreams/logs_test.go b/cmd/tools/substreams/logs_test.go index 81e2293..0e8a447 100644 --- a/cmd/tools/substreams/logs_test.go +++ b/cmd/tools/substreams/logs_test.go @@ -133,91 +133,83 @@ func TestLogsCommandStructure(t *testing.T) { func TestParseTimeRange(t *testing.T) { now := time.Now() - t.Run("default is 1 hour", func(t *testing.T) { - start, end, err := parseTimeRange(time.Duration(0), "") + t.Run("default lookback", func(t *testing.T) { + start, end, err := parseTimeRange("", "", time.Hour) require.NoError(t, err) assert.WithinDuration(t, now, end, time.Second) assert.WithinDuration(t, now.Add(-time.Hour), start, time.Second) }) + t.Run("default lookback, different value", func(t *testing.T) { + start, end, err := parseTimeRange("", "", 7*24*time.Hour) + require.NoError(t, err) + assert.WithinDuration(t, now, end, time.Second) + assert.WithinDuration(t, now.Add(-7*24*time.Hour), start, time.Second) + }) + t.Run("since 30m", func(t *testing.T) { - start, end, err := parseTimeRange(30*time.Minute, "") + start, end, err := parseTimeRange("30m", "", time.Hour) require.NoError(t, err) assert.WithinDuration(t, now, end, time.Second) assert.WithinDuration(t, now.Add(-30*time.Minute), start, time.Second) }) t.Run("since 2d", func(t *testing.T) { - start, end, err := parseTimeRange(48*time.Hour, "") + start, end, err := parseTimeRange("2d", "", time.Hour) require.NoError(t, err) assert.WithinDuration(t, now, end, time.Second) assert.WithinDuration(t, now.Add(-48*time.Hour), start, time.Second) }) -} -func TestParseDateRange(t *testing.T) { - tests := []struct { - name string - input string - wantStart string - wantEnd string - wantErr bool - }{ - { - name: "full range RFC3339", - input: "2024-01-15T10:00:00Z/2024-01-15T12:00:00Z", - wantStart: "2024-01-15T10:00:00Z", - wantEnd: "2024-01-15T12:00:00Z", - wantErr: false, - }, - { - name: "start only with trailing slash", - input: "2024-01-15T10:00:00Z/", - wantStart: "2024-01-15T10:00:00Z", - wantEnd: "", // Will be "now" - wantErr: false, - }, - { - name: "start only no trailing slash", - input: "2024-01-15T10:00:00Z", - wantStart: "2024-01-15T10:00:00Z", - wantEnd: "", // Will be "now" - wantErr: false, - }, - { - name: "empty string", - input: "", - wantErr: true, - }, - { - name: "end before start", - input: "2024-01-15T12:00:00Z/2024-01-15T10:00:00Z", - wantErr: true, - }, - } + t.Run("since 1w", func(t *testing.T) { + start, end, err := parseTimeRange("1w", "", time.Hour) + require.NoError(t, err) + assert.WithinDuration(t, now, end, time.Second) + assert.WithinDuration(t, now.Add(-7*24*time.Hour), start, time.Second) + }) - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - start, end, err := parseDateRange(tt.input) - if tt.wantErr { - assert.Error(t, err) - return - } + t.Run("invalid since", func(t *testing.T) { + _, _, err := parseTimeRange("not-a-duration", "", time.Hour) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid --since value") + }) - require.NoError(t, err) + t.Run("since and date-range are mutually exclusive", func(t *testing.T) { + _, _, err := parseTimeRange("1h", "2024-01-15T10:00:00Z", time.Hour) + require.Error(t, err) + assert.Contains(t, err.Error(), "mutually exclusive") + }) - expectedStart, _ := time.Parse(time.RFC3339, tt.wantStart) - assert.Equal(t, expectedStart, start) + t.Run("date-range full range RFC3339", func(t *testing.T) { + start, end, err := parseTimeRange("", "2024-01-15T10:00:00Z/2024-01-15T12:00:00Z", time.Hour) + require.NoError(t, err) + expectedStart, _ := time.Parse(time.RFC3339, "2024-01-15T10:00:00Z") + expectedEnd, _ := time.Parse(time.RFC3339, "2024-01-15T12:00:00Z") + assert.Equal(t, expectedStart, start) + assert.Equal(t, expectedEnd, end) + }) - if tt.wantEnd != "" { - expectedEnd, _ := time.Parse(time.RFC3339, tt.wantEnd) - assert.Equal(t, expectedEnd, end) - } else { - // End should be close to now - assert.WithinDuration(t, time.Now(), end, time.Second) - } - }) - } + t.Run("date-range colon-separated range", func(t *testing.T) { + start, end, err := parseTimeRange("", "2024-01-15T10:00:00Z:2024-01-15T12:00:00Z", time.Hour) + require.NoError(t, err) + expectedStart, _ := time.Parse(time.RFC3339, "2024-01-15T10:00:00Z") + expectedEnd, _ := time.Parse(time.RFC3339, "2024-01-15T12:00:00Z") + assert.Equal(t, expectedStart, start) + assert.Equal(t, expectedEnd, end) + }) + + t.Run("date-range start only, no trailing slash", func(t *testing.T) { + start, end, err := parseTimeRange("", "2024-01-15T10:00:00Z", time.Hour) + require.NoError(t, err) + expectedStart, _ := time.Parse(time.RFC3339, "2024-01-15T10:00:00Z") + assert.Equal(t, expectedStart, start) + assert.WithinDuration(t, time.Now(), end, time.Second) + }) + + t.Run("date-range end before start fails", func(t *testing.T) { + _, _, err := parseTimeRange("", "2024-01-15T12:00:00Z/2024-01-15T10:00:00Z", time.Hour) + require.Error(t, err) + }) } func TestParseDateTime(t *testing.T) {