diff --git a/notification/notifydata/error_info_test.go b/notification/notifydata/error_info_test.go new file mode 100644 index 000000000..2097fffff --- /dev/null +++ b/notification/notifydata/error_info_test.go @@ -0,0 +1,30 @@ +package notifydata + +import ( + "errors" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/kopia/kopia/internal/clock" +) + +func TestNewErrorInfo(t *testing.T) { + startTime := clock.Now() + endTime := startTime.Add(2 * time.Second) + + err := errors.New("test error") //nolint:err113 + e := NewErrorInfo("test operation", "test details", startTime, endTime, err) + + require.Equal(t, "test operation", e.Operation) + require.Equal(t, "test details", e.OperationDetails) + require.Equal(t, startTime, e.StartTime) + require.Equal(t, endTime, e.EndTime) + require.Equal(t, "test error", e.ErrorMessage) + require.Equal(t, "test error", e.ErrorDetails) + + require.Equal(t, startTime.Truncate(time.Second), e.StartTimestamp()) + require.Equal(t, endTime.Truncate(time.Second), e.EndTimestamp()) + require.Equal(t, 2*time.Second, e.Duration()) +} diff --git a/notification/notifydata/multi_snapshot_status.go b/notification/notifydata/multi_snapshot_status.go index fba5ad1c9..8405fc73d 100644 --- a/notification/notifydata/multi_snapshot_status.go +++ b/notification/notifydata/multi_snapshot_status.go @@ -1,6 +1,8 @@ package notifydata import ( + "fmt" + "strings" "time" "github.com/kopia/kopia/snapshot" @@ -56,19 +58,6 @@ func (m *ManifestWithError) TotalSizeDelta() int64 { return m.Manifest.RootEntry.FileSize } -// New returns the total size of the snapshot in bytes. -func (m *ManifestWithError) New() int64 { - if m.Manifest.RootEntry == nil { - return 0 - } - - if m.Manifest.RootEntry.DirSummary != nil { - return m.Manifest.RootEntry.DirSummary.TotalFileSize - } - - return m.Manifest.RootEntry.FileSize -} - // TotalFiles returns the total number of files in the snapshot. func (m *ManifestWithError) TotalFiles() int64 { if m.Manifest.RootEntry == nil { @@ -134,30 +123,81 @@ func (m *ManifestWithError) Duration() time.Duration { return time.Duration(m.Manifest.EndTime - m.Manifest.StartTime).Round(durationPrecision) } +// Status codes. +const ( + StatusCodeIncomplete = "incomplete" + StatusCodeFatal = "fatal" + StatusCodeError = "error" + StatusCodeSuccess = "success" +) + // StatusCode returns the status code of the manifest. func (m *ManifestWithError) StatusCode() string { if m.Error != "" { - return "fatal" + return StatusCodeFatal } if m.Manifest.IncompleteReason != "" { - return "incomplete" + return StatusCodeIncomplete } if m.Manifest.RootEntry != nil && m.Manifest.RootEntry.DirSummary != nil { if m.Manifest.RootEntry.DirSummary.FatalErrorCount > 0 { - return "fatal" + return StatusCodeFatal } if m.Manifest.RootEntry.DirSummary.IgnoredErrorCount > 0 { - return "error" + return StatusCodeError } } - return "ok" + return StatusCodeSuccess } // MultiSnapshotStatus represents the status of multiple snapshots. type MultiSnapshotStatus struct { Snapshots []*ManifestWithError `json:"snapshots"` } + +// OverallStatus returns the overall status of the snapshots. +func (m MultiSnapshotStatus) OverallStatus() string { + var ( + numIncomplete int + numFatal int + numErrors int + numSuccess int + ) + + for _, s := range m.Snapshots { + switch s.StatusCode() { + case StatusCodeIncomplete: + numIncomplete++ + case StatusCodeError: + numErrors++ + case StatusCodeFatal: + numFatal++ + case StatusCodeSuccess: + numSuccess++ + } + } + + var errorStrings []string + + if numFatal > 1 { + errorStrings = append(errorStrings, fmt.Sprintf("%d fatal errors", numFatal)) + } else if numFatal == 1 { + errorStrings = append(errorStrings, "a fatal error") + } + + if numErrors > 1 { + errorStrings = append(errorStrings, fmt.Sprintf("%d errors", numErrors)) + } else if numErrors == 1 { + errorStrings = append(errorStrings, "an error") + } + + if len(errorStrings) == 0 { + return "success" + } + + return "encountered " + strings.Join(errorStrings, " and ") +} diff --git a/notification/notifydata/multi_snapshot_status_test.go b/notification/notifydata/multi_snapshot_status_test.go new file mode 100644 index 000000000..38ec96f2f --- /dev/null +++ b/notification/notifydata/multi_snapshot_status_test.go @@ -0,0 +1,611 @@ +package notifydata_test + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/kopia/kopia/fs" + "github.com/kopia/kopia/internal/clock" + "github.com/kopia/kopia/notification/notifydata" + "github.com/kopia/kopia/snapshot" +) + +func TestOverallStatus(t *testing.T) { + tests := []struct { + name string + snapshots []*notifydata.ManifestWithError + expected string + }{ + { + name: "all success", + snapshots: []*notifydata.ManifestWithError{ + {Manifest: snapshot.Manifest{}}, + {Manifest: snapshot.Manifest{}}, + }, + expected: "success", + }, + { + name: "one fatal error", + snapshots: []*notifydata.ManifestWithError{ + {Manifest: snapshot.Manifest{}, Error: "fatal error"}, + {Manifest: snapshot.Manifest{}}, + }, + expected: "encountered a fatal error", + }, + { + name: "multiple fatal errors", + snapshots: []*notifydata.ManifestWithError{ + {Manifest: snapshot.Manifest{}, Error: "fatal error"}, + {Manifest: snapshot.Manifest{}, Error: "fatal error"}, + }, + expected: "encountered 2 fatal errors", + }, + { + name: "one error", + snapshots: []*notifydata.ManifestWithError{ + {Manifest: snapshot.Manifest{RootEntry: &snapshot.DirEntry{DirSummary: &fs.DirectorySummary{IgnoredErrorCount: 1}}}}, + {Manifest: snapshot.Manifest{}}, + }, + expected: "encountered an error", + }, + { + name: "one fatal error and two errors", + snapshots: []*notifydata.ManifestWithError{ + {Manifest: snapshot.Manifest{}, Error: "fatal error"}, + {Manifest: snapshot.Manifest{}}, + {Manifest: snapshot.Manifest{RootEntry: &snapshot.DirEntry{DirSummary: &fs.DirectorySummary{IgnoredErrorCount: 1}}}}, + {Manifest: snapshot.Manifest{RootEntry: &snapshot.DirEntry{DirSummary: &fs.DirectorySummary{IgnoredErrorCount: 1}}}}, + }, + expected: "encountered a fatal error and 2 errors", + }, + { + name: "one fatal error and one errors", + snapshots: []*notifydata.ManifestWithError{ + {Manifest: snapshot.Manifest{}, Error: "fatal error"}, + {Manifest: snapshot.Manifest{}}, + {Manifest: snapshot.Manifest{RootEntry: &snapshot.DirEntry{DirSummary: &fs.DirectorySummary{IgnoredErrorCount: 1}}}}, + }, + expected: "encountered a fatal error and an error", + }, + { + name: "multiple errors", + snapshots: []*notifydata.ManifestWithError{ + {Manifest: snapshot.Manifest{RootEntry: &snapshot.DirEntry{DirSummary: &fs.DirectorySummary{IgnoredErrorCount: 1}}}}, + {Manifest: snapshot.Manifest{RootEntry: &snapshot.DirEntry{DirSummary: &fs.DirectorySummary{IgnoredErrorCount: 1}}}}, + }, + expected: "encountered 2 errors", + }, + { + name: "incomplete snapshot", + snapshots: []*notifydata.ManifestWithError{ + {Manifest: snapshot.Manifest{IncompleteReason: "incomplete"}}, + {Manifest: snapshot.Manifest{}}, + }, + expected: "success", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mss := notifydata.MultiSnapshotStatus{Snapshots: tt.snapshots} + require.Equal(t, tt.expected, mss.OverallStatus()) + }) + } +} + +func TestStatusCode(t *testing.T) { + tests := []struct { + name string + manifest notifydata.ManifestWithError + expected string + }{ + { + name: "fatal error", + manifest: notifydata.ManifestWithError{ + Error: "fatal error", + }, + expected: notifydata.StatusCodeFatal, + }, + { + name: "incomplete snapshot", + manifest: notifydata.ManifestWithError{ + Manifest: snapshot.Manifest{ + IncompleteReason: "incomplete", + }, + }, + expected: notifydata.StatusCodeIncomplete, + }, + { + name: "fatal error in dir summary", + manifest: notifydata.ManifestWithError{ + Manifest: snapshot.Manifest{ + RootEntry: &snapshot.DirEntry{ + DirSummary: &fs.DirectorySummary{ + FatalErrorCount: 1, + }, + }, + }, + }, + expected: notifydata.StatusCodeFatal, + }, + { + name: "ignored error in dir summary", + manifest: notifydata.ManifestWithError{ + Manifest: snapshot.Manifest{ + RootEntry: &snapshot.DirEntry{ + DirSummary: &fs.DirectorySummary{ + IgnoredErrorCount: 1, + }, + }, + }, + }, + expected: notifydata.StatusCodeError, + }, + { + name: "success", + manifest: notifydata.ManifestWithError{ + Manifest: snapshot.Manifest{}, + }, + expected: notifydata.StatusCodeSuccess, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.expected, tt.manifest.StatusCode()) + }) + } +} + +func TestManifestWithErrorMethods(t *testing.T) { + startTime := clock.Now().Add(-1*time.Minute - 330*time.Millisecond) + endTime := clock.Now() + + dirSummary := &fs.DirectorySummary{ + TotalFileSize: 1000, + TotalFileCount: 10, + TotalDirCount: 5, + } + + tests := []struct { + name string + manifest notifydata.ManifestWithError + expected struct { + startTimestamp time.Time + endTimestamp time.Time + totalSize int64 + totalFiles int64 + totalDirs int64 + duration time.Duration + } + }{ + { + name: "complete manifest", + manifest: notifydata.ManifestWithError{ + Manifest: snapshot.Manifest{ + StartTime: fs.UTCTimestamp(startTime.UnixNano()), + EndTime: fs.UTCTimestamp(endTime.UnixNano()), + RootEntry: &snapshot.DirEntry{ + DirSummary: dirSummary, + }, + }, + }, + expected: struct { + startTimestamp time.Time + endTimestamp time.Time + totalSize int64 + totalFiles int64 + totalDirs int64 + duration time.Duration + }{ + startTimestamp: startTime.UTC().Truncate(time.Second), + endTimestamp: endTime.UTC().Truncate(time.Second), + totalSize: 1000, + totalFiles: 10, + totalDirs: 5, + duration: endTime.Sub(startTime).Truncate(100 * time.Millisecond), + }, + }, + { + name: "empty manifest", + manifest: notifydata.ManifestWithError{ + Manifest: snapshot.Manifest{}, + }, + expected: struct { + startTimestamp time.Time + endTimestamp time.Time + totalSize int64 + totalFiles int64 + totalDirs int64 + duration time.Duration + }{ + startTimestamp: time.Date(1970, time.January, 1, 0, 0, 0, 0, time.UTC), + endTimestamp: time.Date(1970, time.January, 1, 0, 0, 0, 0, time.UTC), + totalSize: 0, + totalFiles: 0, + totalDirs: 0, + duration: 0, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.expected.startTimestamp, tt.manifest.StartTimestamp()) + require.Equal(t, tt.expected.endTimestamp, tt.manifest.EndTimestamp()) + require.Equal(t, tt.expected.totalSize, tt.manifest.TotalSize()) + require.Equal(t, tt.expected.totalFiles, tt.manifest.TotalFiles()) + require.Equal(t, tt.expected.totalDirs, tt.manifest.TotalDirs()) + require.Equal(t, tt.expected.duration, tt.manifest.Duration()) + }) + } +} + +func TestTotalSizeDelta(t *testing.T) { + tests := []struct { + name string + manifest notifydata.ManifestWithError + expected int64 + }{ + { + name: "no previous manifest", + manifest: notifydata.ManifestWithError{ + Manifest: snapshot.Manifest{ + RootEntry: &snapshot.DirEntry{ + DirSummary: &fs.DirectorySummary{ + TotalFileSize: 1000, + }, + }, + }, + }, + expected: 0, + }, + { + name: "no root entry in current manifest", + manifest: notifydata.ManifestWithError{ + Previous: &snapshot.Manifest{ + RootEntry: &snapshot.DirEntry{ + DirSummary: &fs.DirectorySummary{ + TotalFileSize: 1000, + }, + }, + }, + }, + expected: 0, + }, + { + name: "no dir summary in current manifest", + manifest: notifydata.ManifestWithError{ + Manifest: snapshot.Manifest{ + RootEntry: &snapshot.DirEntry{ + FileSize: 500, + }, + }, + Previous: &snapshot.Manifest{ + RootEntry: &snapshot.DirEntry{ + DirSummary: &fs.DirectorySummary{ + TotalFileSize: 1000, + }, + }, + }, + }, + expected: 500, + }, + { + name: "dir summary in both manifests", + manifest: notifydata.ManifestWithError{ + Manifest: snapshot.Manifest{ + RootEntry: &snapshot.DirEntry{ + DirSummary: &fs.DirectorySummary{ + TotalFileSize: 1500, + }, + }, + }, + Previous: &snapshot.Manifest{ + RootEntry: &snapshot.DirEntry{ + DirSummary: &fs.DirectorySummary{ + TotalFileSize: 1000, + }, + }, + }, + }, + expected: 500, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.expected, tt.manifest.TotalSizeDelta()) + }) + } +} + +func TestTotalFilesDelta(t *testing.T) { + tests := []struct { + name string + manifest notifydata.ManifestWithError + expected int64 + }{ + { + name: "no previous manifest", + manifest: notifydata.ManifestWithError{ + Manifest: snapshot.Manifest{ + RootEntry: &snapshot.DirEntry{ + DirSummary: &fs.DirectorySummary{ + TotalFileCount: 10, + }, + }, + }, + }, + expected: 0, + }, + { + name: "no root entry in current manifest", + manifest: notifydata.ManifestWithError{ + Previous: &snapshot.Manifest{ + RootEntry: &snapshot.DirEntry{ + DirSummary: &fs.DirectorySummary{ + TotalFileCount: 10, + }, + }, + }, + }, + expected: 0, + }, + { + name: "no dir summary in current manifest", + manifest: notifydata.ManifestWithError{ + Manifest: snapshot.Manifest{ + RootEntry: &snapshot.DirEntry{}, + }, + Previous: &snapshot.Manifest{ + RootEntry: &snapshot.DirEntry{ + DirSummary: &fs.DirectorySummary{ + TotalFileCount: 10, + }, + }, + }, + }, + expected: 1, + }, + { + name: "dir summary in both manifests", + manifest: notifydata.ManifestWithError{ + Manifest: snapshot.Manifest{ + RootEntry: &snapshot.DirEntry{ + DirSummary: &fs.DirectorySummary{ + TotalFileCount: 15, + }, + }, + }, + Previous: &snapshot.Manifest{ + RootEntry: &snapshot.DirEntry{ + DirSummary: &fs.DirectorySummary{ + TotalFileCount: 10, + }, + }, + }, + }, + expected: 5, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.expected, tt.manifest.TotalFilesDelta()) + }) + } +} + +func TestTotalDirsDelta(t *testing.T) { + tests := []struct { + name string + manifest notifydata.ManifestWithError + expected int64 + }{ + { + name: "no previous manifest", + manifest: notifydata.ManifestWithError{ + Manifest: snapshot.Manifest{ + RootEntry: &snapshot.DirEntry{ + DirSummary: &fs.DirectorySummary{ + TotalDirCount: 5, + }, + }, + }, + }, + expected: 0, + }, + { + name: "no root entry in current manifest", + manifest: notifydata.ManifestWithError{ + Previous: &snapshot.Manifest{ + RootEntry: &snapshot.DirEntry{ + DirSummary: &fs.DirectorySummary{ + TotalDirCount: 5, + }, + }, + }, + }, + expected: 0, + }, + { + name: "no dir summary in current manifest", + manifest: notifydata.ManifestWithError{ + Manifest: snapshot.Manifest{ + RootEntry: &snapshot.DirEntry{}, + }, + Previous: &snapshot.Manifest{ + RootEntry: &snapshot.DirEntry{ + DirSummary: &fs.DirectorySummary{ + TotalDirCount: 5, + }, + }, + }, + }, + expected: 0, + }, + { + name: "dir summary in both manifests", + manifest: notifydata.ManifestWithError{ + Manifest: snapshot.Manifest{ + RootEntry: &snapshot.DirEntry{ + DirSummary: &fs.DirectorySummary{ + TotalDirCount: 10, + }, + }, + }, + Previous: &snapshot.Manifest{ + RootEntry: &snapshot.DirEntry{ + DirSummary: &fs.DirectorySummary{ + TotalDirCount: 5, + }, + }, + }, + }, + expected: 5, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.expected, tt.manifest.TotalDirsDelta()) + }) + } +} + +func TestTotalFiles(t *testing.T) { + tests := []struct { + name string + manifest notifydata.ManifestWithError + expected int64 + }{ + { + name: "no root entry", + manifest: notifydata.ManifestWithError{ + Manifest: snapshot.Manifest{}, + }, + expected: 0, + }, + { + name: "root entry with dir summary", + manifest: notifydata.ManifestWithError{ + Manifest: snapshot.Manifest{ + RootEntry: &snapshot.DirEntry{ + DirSummary: &fs.DirectorySummary{ + TotalFileCount: 10, + }, + }, + }, + }, + expected: 10, + }, + { + name: "root entry without dir summary", + manifest: notifydata.ManifestWithError{ + Manifest: snapshot.Manifest{ + RootEntry: &snapshot.DirEntry{}, + }, + }, + expected: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.expected, tt.manifest.TotalFiles()) + }) + } +} + +func TestTotalDirs(t *testing.T) { + tests := []struct { + name string + manifest notifydata.ManifestWithError + expected int64 + }{ + { + name: "no root entry", + manifest: notifydata.ManifestWithError{ + Manifest: snapshot.Manifest{}, + }, + expected: 0, + }, + { + name: "root entry with dir summary", + manifest: notifydata.ManifestWithError{ + Manifest: snapshot.Manifest{ + RootEntry: &snapshot.DirEntry{ + DirSummary: &fs.DirectorySummary{ + TotalDirCount: 5, + }, + }, + }, + }, + expected: 5, + }, + { + name: "root entry without dir summary", + manifest: notifydata.ManifestWithError{ + Manifest: snapshot.Manifest{ + RootEntry: &snapshot.DirEntry{}, + }, + }, + expected: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.expected, tt.manifest.TotalDirs()) + }) + } +} + +func TestTotalSize(t *testing.T) { + tests := []struct { + name string + manifest notifydata.ManifestWithError + expected int64 + }{ + { + name: "no root entry", + manifest: notifydata.ManifestWithError{ + Manifest: snapshot.Manifest{}, + }, + expected: 0, + }, + { + name: "root entry with dir summary", + manifest: notifydata.ManifestWithError{ + Manifest: snapshot.Manifest{ + RootEntry: &snapshot.DirEntry{ + DirSummary: &fs.DirectorySummary{ + TotalFileSize: 1000, + }, + }, + }, + }, + expected: 1000, + }, + { + name: "root entry without dir summary", + manifest: notifydata.ManifestWithError{ + Manifest: snapshot.Manifest{ + RootEntry: &snapshot.DirEntry{ + FileSize: 500, + }, + }, + }, + expected: 500, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.expected, tt.manifest.TotalSize()) + }) + } +} diff --git a/notification/notifytemplate/notifytemplate_test.go b/notification/notifytemplate/notifytemplate_test.go index 8a05d3c20..8eb37e4c1 100644 --- a/notification/notifytemplate/notifytemplate_test.go +++ b/notification/notifytemplate/notifytemplate_test.go @@ -7,6 +7,7 @@ "testing" "time" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/kopia/kopia/fs" @@ -187,7 +188,9 @@ func verifyTemplate(t *testing.T, embeddedTemplateName, expectedSuffix string, a want := string(wantBytes) - require.Equal(t, want, buf.String()) + assert.Equal(t, want, buf.String()) - require.NoError(t, os.Remove(actualFileName)) + if want == buf.String() { + require.NoError(t, os.Remove(actualFileName)) + } } diff --git a/notification/notifytemplate/snapshot-report.html b/notification/notifytemplate/snapshot-report.html index 4a279d118..4e552da0e 100644 --- a/notification/notifytemplate/snapshot-report.html +++ b/notification/notifytemplate/snapshot-report.html @@ -1,4 +1,4 @@ -Subject: Kopia created {{ len .EventArgs.Snapshots }} snapshot{{ if gt (len .EventArgs.Snapshots) 1 }}s{{end}} on {{.Hostname}} +Subject: Kopia {{.EventArgs.OverallStatus}} creating {{ if gt (len .EventArgs.Snapshots) 1 }}{{ len .EventArgs.Snapshots }} snapshots{{else}}snapshot{{end}} on {{.Hostname}} diff --git a/notification/notifytemplate/snapshot-report.txt b/notification/notifytemplate/snapshot-report.txt index 2c689c645..5866d8998 100644 --- a/notification/notifytemplate/snapshot-report.txt +++ b/notification/notifytemplate/snapshot-report.txt @@ -1,5 +1,4 @@ -Subject: Kopia created {{ len .EventArgs.Snapshots }} snapshot{{ if gt (len .EventArgs.Snapshots) 1 }}s{{end}} on {{.Hostname}} - +Subject: Kopia {{.EventArgs.OverallStatus}} creating {{ if gt (len .EventArgs.Snapshots) 1 }}{{ len .EventArgs.Snapshots }} snapshots{{else}}snapshot{{end}} on {{.Hostname}} {{ range .EventArgs.Snapshots | sortSnapshotManifestsByName}}Path: {{ .Manifest.Source.Path }} Status: {{ .StatusCode }} diff --git a/notification/notifytemplate/testdata/snapshot-report.html.alt.expected b/notification/notifytemplate/testdata/snapshot-report.html.alt.expected index 459279826..91ebe7342 100644 --- a/notification/notifytemplate/testdata/snapshot-report.html.alt.expected +++ b/notification/notifytemplate/testdata/snapshot-report.html.alt.expected @@ -1,4 +1,4 @@ -Subject: Kopia created 4 snapshots on some-host +Subject: Kopia encountered a fatal error creating 4 snapshots on some-host @@ -90,7 +90,7 @@ Subject: Kopia created 4 snapshots on some-host - + /some/path Wed, 01 Jan 2020 19:04:05 PST 1.1s @@ -104,7 +104,7 @@ Subject: Kopia created 4 snapshots on some-host - + Failed Entries: