Preflight report output filenames
This commit is contained in:
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -276,6 +277,26 @@ func TestGenerateDetailedRejectsConfiguredNonDirectoryBeforeWork(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateDetailedRejectsOverlongOutputBeforeWork(t *testing.T) {
|
||||
missingDirectory := filepath.Join(t.TempDir(), "missing")
|
||||
outputPath := filepath.Join(missingDirectory, strings.Repeat("a", 253)+".md")
|
||||
bundle := generationBundle(t)
|
||||
collector := &generationCollector{bundle: &bundle}
|
||||
executor := &generationExecutor{}
|
||||
|
||||
result, err := GenerateDetailed(context.Background(), GenerateRequest{
|
||||
Config: generationConfig(), Report: ReportDaily,
|
||||
Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"),
|
||||
WorkingDir: t.TempDir(), OutputPath: outputPath, Collector: collector, Executor: executor,
|
||||
})
|
||||
if err == nil || result == nil || collector.called || executor.promptInspections != 0 || executor.called {
|
||||
t.Fatalf("GenerateDetailed() result/error/collector/executor = %#v/%v/%t/%#v", result, err, collector.called, executor)
|
||||
}
|
||||
if _, statErr := os.Stat(missingDirectory); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("missing output directory exists after preflight failure: %v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateDetailedReturnsResolvedResultWhenCollectionFails(t *testing.T) {
|
||||
cfg := config.Defaults()
|
||||
cfg.WeatherAPI.Timezone, cfg.Location.ID = "America/Chicago", "home"
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/comparison"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
)
|
||||
|
||||
@@ -184,6 +185,9 @@ func validateOutputPath(path string) (string, error) {
|
||||
if filepath.Dir(path) == path {
|
||||
return "", fmt.Errorf("final output path %q must not be a filesystem root", path)
|
||||
}
|
||||
if err := fileutil.ValidateAtomicPath(path); err != nil {
|
||||
return "", fmt.Errorf("validate final output path %q: %w", path, err)
|
||||
}
|
||||
if info, err := os.Stat(path); err == nil && info.IsDir() {
|
||||
return "", fmt.Errorf("final output path %q is a directory", path)
|
||||
} else if err != nil && !os.IsNotExist(err) {
|
||||
|
||||
@@ -8,11 +8,28 @@ import (
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
const (
|
||||
maxFileNameBytes = 255
|
||||
temporaryFilePattern = ".weatherreporter-*.tmp"
|
||||
)
|
||||
|
||||
// ValidateAtomicPath verifies that the final path can be safely used with this
|
||||
// package's same-directory atomic-write implementation.
|
||||
func ValidateAtomicPath(path string) error {
|
||||
if len(filepath.Base(path)) > maxFileNameBytes {
|
||||
return fmt.Errorf("final file name exceeds the %d-byte limit", maxFileNameBytes)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func WriteFileAtomic(path string, data []byte) error {
|
||||
if err := ValidateAtomicPath(path); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return fmt.Errorf("create directory %q: %w", filepath.Dir(path), err)
|
||||
}
|
||||
tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*.tmp")
|
||||
tmp, err := os.CreateTemp(filepath.Dir(path), temporaryFilePattern)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temporary file for %q: %w", path, err)
|
||||
}
|
||||
|
||||
@@ -42,6 +42,31 @@ func TestWriteFileAtomicOverwritesTarget(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteFileAtomicSupportsLongestFileName(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
name := strings.Repeat("a", maxFileNameBytes-len(".md")) + ".md"
|
||||
path := filepath.Join(directory, name)
|
||||
if err := os.WriteFile(path, []byte("old"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := WriteFileAtomic(path, []byte("new")); err != nil {
|
||||
t.Fatalf("WriteFileAtomic() error = %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil || string(data) != "new" {
|
||||
t.Fatalf("output/error = %q/%v", data, err)
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil || info.Mode().Perm() != 0o600 {
|
||||
t.Fatalf("output mode/error = %o/%v", info.Mode().Perm(), err)
|
||||
}
|
||||
matches, err := filepath.Glob(filepath.Join(directory, ".weatherreporter-*.tmp"))
|
||||
if err != nil || len(matches) != 0 {
|
||||
t.Fatalf("temporary files/error = %v/%v", matches, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteFileAtomicCleansTemporaryFileAfterRenameError(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
target := filepath.Join(dir, "target")
|
||||
@@ -56,7 +81,7 @@ func TestWriteFileAtomicCleansTemporaryFileAfterRenameError(t *testing.T) {
|
||||
if !strings.Contains(err.Error(), "save") {
|
||||
t.Fatalf("error = %q, want save context", err.Error())
|
||||
}
|
||||
matches, err := filepath.Glob(filepath.Join(dir, ".target.*.tmp"))
|
||||
matches, err := filepath.Glob(filepath.Join(dir, ".weatherreporter-*.tmp"))
|
||||
if err != nil {
|
||||
t.Fatalf("Glob() error = %v", err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user