Skip to content

Commit

Permalink
Add file utility functions for atomic writing and existence checking;…
Browse files Browse the repository at this point in the history
… enhance report retrieval logic
  • Loading branch information
hk21702 committed Jan 21, 2025
1 parent b91860c commit 7427c2c
Show file tree
Hide file tree
Showing 13 changed files with 247 additions and 0 deletions.
3 changes: 3 additions & 0 deletions internal/constants/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
48 changes: 48 additions & 0 deletions internal/fileutils/fileutils.go
Original file line number Diff line number Diff line change
@@ -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
}
103 changes: 103 additions & 0 deletions internal/fileutils/fileutils_test.go
Original file line number Diff line number Diff line change
@@ -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")
})
}
}
37 changes: 37 additions & 0 deletions internal/reportutils/reportutils.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
44 changes: 44 additions & 0 deletions internal/reportutils/reportutils_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
})
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
0: 7
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
0: 2
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
0: 7
100: 107
200: 207
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
0: 7
100: 107
250: 257

0 comments on commit 7427c2c

Please sign in to comment.