diff --git a/internal/constants/constants.go b/internal/constants/constants.go index 448720d..b5d600c 100644 --- a/internal/constants/constants.go +++ b/internal/constants/constants.go @@ -26,6 +26,9 @@ const ( // ReportExt is the default extension for the report files. ReportExt = ".json" + + // OptOutJSON is the data sent in case of Opt-Out choice. + OptOutJSON = `{"OptOut": true}` ) type options struct { diff --git a/internal/fileutils/fileutils.go b/internal/fileutils/fileutils.go new file mode 100644 index 0000000..0a118d3 --- /dev/null +++ b/internal/fileutils/fileutils.go @@ -0,0 +1,48 @@ +// Package fileutils provides utility functions for handling files. +package fileutils + +import ( + "errors" + "fmt" + "log/slog" + "os" + "path/filepath" +) + +// AtomicWrite writes data to a file atomically. +// If the file already exists, then it will be overwritten. +// Not atomic on Windows. +func AtomicWrite(path string, data []byte) error { + tmp, err := os.CreateTemp(filepath.Dir(path), "consent-*.tmp") + if err != nil { + return fmt.Errorf("could not create temporary file: %v", err) + } + defer func() { + _ = tmp.Close() + if err := os.Remove(tmp.Name()); err != nil && !os.IsNotExist(err) { + slog.Warn("Failed to remove temporary file", "file", tmp.Name(), "error", err) + } + }() + + if _, err := tmp.Write(data); err != nil { + return fmt.Errorf("could not write to temporary file: %v", err) + } + + if err := tmp.Close(); err != nil { + return fmt.Errorf("could not close temporary file: %v", err) + } + + if err := os.Rename(tmp.Name(), path); err != nil { + return fmt.Errorf("could not rename temporary file: %v", err) + } + return nil +} + +// FileExists checks if a file exists at the given path. +func FileExists(path string) (bool, error) { + _, err := os.Stat(path) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return false, err + } + return !errors.Is(err, os.ErrNotExist), nil +} diff --git a/internal/fileutils/fileutils_test.go b/internal/fileutils/fileutils_test.go new file mode 100644 index 0000000..fc05209 --- /dev/null +++ b/internal/fileutils/fileutils_test.go @@ -0,0 +1,103 @@ +package fileutils_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + "github.com/ubuntu/ubuntu-insights/internal/fileutils" +) + +func TestAtomicWrite(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + data []byte + fileExists bool + + wantError bool + }{ + "Empty file": {data: []byte{}, fileExists: false}, + "Non-empty file": {data: []byte("data"), fileExists: false}, + "Override file": {data: []byte("data"), fileExists: true}, + "Override empty file": {data: []byte{}, fileExists: true}, + + "Existing empty file": {data: []byte{}, fileExists: true}, + "Existing non-empty file": {data: []byte("data"), fileExists: true}, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + oldFile := []byte("Old File!") + tempDir := t.TempDir() + path := filepath.Join(tempDir, "file") + if tc.fileExists { + err := fileutils.AtomicWrite(path, oldFile) + require.NoError(t, err, "Setup: AtomicWrite should not return an error") + } + + err := fileutils.AtomicWrite(path, tc.data) + if tc.wantError { + require.Error(t, err, "AtomicWrite should return an error") + + // Check that the file was not overwritten + data, err := os.ReadFile(path) + require.NoError(t, err, "ReadFile should not return an error") + require.Equal(t, oldFile, data, "AtomicWrite should not overwrite the file") + } else { + require.NoError(t, err, "AtomicWrite should not return an error") + + // Check that the file was written + data, err := os.ReadFile(path) + require.NoError(t, err, "ReadFile should not return an error") + require.Equal(t, tc.data, data, "AtomicWrite should write the data to the file") + } + }) + } +} + +func TestFileExists(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + fileExists bool + parentDirIsFile bool + + wantExists bool + wantError bool + }{ + "Returns_true_when_file_exists": {fileExists: true, wantExists: true}, + "Returns_false_when_file_does_not_exist": {fileExists: false, wantExists: false}, + "Returns_false_when_parent_directory_does_not_exist": {fileExists: false, wantExists: false}, + + "Error_when_parent_directory_is_a_file": {parentDirIsFile: true, wantError: true}, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + path := filepath.Join(tempDir, "file") + if tc.fileExists { + err := fileutils.AtomicWrite(path, []byte{}) + require.NoError(t, err, "AtomicWrite should not return an error") + } + if tc.parentDirIsFile { + path = filepath.Join(tempDir, "file", "file") + err := fileutils.AtomicWrite(filepath.Join(tempDir, "file"), []byte{}) + require.NoError(t, err, "AtomicWrite should not return an error") + } + + exists, err := fileutils.FileExists(path) + if tc.wantError { + require.Error(t, err, "FileExists should return an error") + } else { + require.NoError(t, err, "FileExists should not return an error") + } + require.Equal(t, tc.wantExists, exists, "FileExists should return the expected result") + }) + } +} diff --git a/internal/reportutils/reportutils.go b/internal/reportutils/reportutils.go index 615ceb6..307f7e0 100644 --- a/internal/reportutils/reportutils.go +++ b/internal/reportutils/reportutils.go @@ -75,3 +75,40 @@ func GetReportPath(dir string, time int64, period int) (string, error) { return mostRecentReportPath, nil } + +// GetReports returns the paths for the latest report within each period window for a given directory. +// The key of the map is the start of the period window, and the report timestamp. +func GetReports(dir string, period int) (map[int64]int64, error) { + if period <= 0 { + return nil, ErrInvalidPeriod + } + + // Get the most recent report within each period window. + files, err := os.ReadDir(dir) + if err != nil { + slog.Error("Failed to read directory", "directory", dir, "error", err) + return nil, err + } + + // Map to store the most recent report within each period window. + reports := make(map[int64]int64) + for _, file := range files { + if filepath.Ext(file.Name()) != constants.ReportExt { + slog.Info("Skipping non-report file, invalid extension", "file", file.Name()) + continue + } + + reportTime, err := GetReportTime(file.Name()) + if err != nil { + slog.Info("Skipping non-report file, invalid file name", "file", file.Name()) + continue + } + + periodStart := reportTime - (reportTime % int64(period)) + if existingReport, ok := reports[periodStart]; !ok || existingReport < reportTime { + reports[periodStart] = reportTime + } + } + + return reports, nil +} diff --git a/internal/reportutils/reportutils_test.go b/internal/reportutils/reportutils_test.go index 3216cbb..1f4a2d5 100644 --- a/internal/reportutils/reportutils_test.go +++ b/internal/reportutils/reportutils_test.go @@ -154,3 +154,47 @@ func setupTmpDir(t *testing.T, files []string, subDir string, subDirFiles []stri return dir, nil } + +func TestGetReports(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + files []string + subDir string + subDirFiles []string + period int + + wantErr error + }{ + "Empty Directory": {period: 500}, + "Files in subDir": {subDir: "subdir", subDirFiles: []string{"1.json", "2.json"}, period: 500}, + + "Invalid File Extension": {files: []string{"1.txt", "2.txt"}, period: 500}, + "Invalid File Names": {files: []string{"i-1.json", "i-2.json", "i-3.json", "test.json", "one.json"}, period: 500}, + "Mix of Valid and Invalid": {files: []string{"1.json", "2.json", "i-1.json", "i-2.json", "i-3.json", "test.json", "five.json"}, period: 500}, + + "Get Newest of Period": {files: []string{"1.json", "7.json"}, period: 100}, + "Multiple Consecutive Windows": {files: []string{"1.json", "7.json", "101.json", "107.json", "201.json", "207.json"}, period: 100}, + "Multiple Non-Consecutive Windows": {files: []string{"1.json", "7.json", "101.json", "107.json", "251.json", "257.json"}, period: 50}, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + dir, err := setupTmpDir(t, tc.files, tc.subDir, tc.subDirFiles) + require.NoError(t, err, "Setup: failed to setup temporary directory") + defer os.RemoveAll(dir) + + got, err := reportutils.GetReports(dir, tc.period) + if tc.wantErr != nil { + require.ErrorIs(t, err, tc.wantErr) + return + } + require.NoError(t, err, "got an unexpected error") + + want := testutils.LoadWithUpdateFromGoldenYAML(t, got) + require.Equal(t, want, got, "GetReports should return the most recent report within each period window") + }) + } +} diff --git a/internal/reportutils/testdata/TestGetReports/golden/empty_directory b/internal/reportutils/testdata/TestGetReports/golden/empty_directory new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/internal/reportutils/testdata/TestGetReports/golden/empty_directory @@ -0,0 +1 @@ +{} diff --git a/internal/reportutils/testdata/TestGetReports/golden/files_in_subdir b/internal/reportutils/testdata/TestGetReports/golden/files_in_subdir new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/internal/reportutils/testdata/TestGetReports/golden/files_in_subdir @@ -0,0 +1 @@ +{} diff --git a/internal/reportutils/testdata/TestGetReports/golden/get_newest_of_period b/internal/reportutils/testdata/TestGetReports/golden/get_newest_of_period new file mode 100644 index 0000000..017ba7a --- /dev/null +++ b/internal/reportutils/testdata/TestGetReports/golden/get_newest_of_period @@ -0,0 +1 @@ +0: 7 diff --git a/internal/reportutils/testdata/TestGetReports/golden/invalid_file_extension b/internal/reportutils/testdata/TestGetReports/golden/invalid_file_extension new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/internal/reportutils/testdata/TestGetReports/golden/invalid_file_extension @@ -0,0 +1 @@ +{} diff --git a/internal/reportutils/testdata/TestGetReports/golden/invalid_file_names b/internal/reportutils/testdata/TestGetReports/golden/invalid_file_names new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/internal/reportutils/testdata/TestGetReports/golden/invalid_file_names @@ -0,0 +1 @@ +{} diff --git a/internal/reportutils/testdata/TestGetReports/golden/mix_of_valid_and_invalid b/internal/reportutils/testdata/TestGetReports/golden/mix_of_valid_and_invalid new file mode 100644 index 0000000..f196b73 --- /dev/null +++ b/internal/reportutils/testdata/TestGetReports/golden/mix_of_valid_and_invalid @@ -0,0 +1 @@ +0: 2 diff --git a/internal/reportutils/testdata/TestGetReports/golden/multiple_consequtive_windows b/internal/reportutils/testdata/TestGetReports/golden/multiple_consequtive_windows new file mode 100644 index 0000000..8e9a957 --- /dev/null +++ b/internal/reportutils/testdata/TestGetReports/golden/multiple_consequtive_windows @@ -0,0 +1,3 @@ +0: 7 +100: 107 +200: 207 diff --git a/internal/reportutils/testdata/TestGetReports/golden/multiple_non-consequtive_windows b/internal/reportutils/testdata/TestGetReports/golden/multiple_non-consequtive_windows new file mode 100644 index 0000000..f2cb19d --- /dev/null +++ b/internal/reportutils/testdata/TestGetReports/golden/multiple_non-consequtive_windows @@ -0,0 +1,3 @@ +0: 7 +100: 107 +250: 257