1501 lines
36 KiB
Go
1501 lines
36 KiB
Go
//go:build linux || freebsd
|
|
// +build linux freebsd
|
|
|
|
package output
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// --- Helpers ---
|
|
|
|
func tempDir(t *testing.T) string {
|
|
t.Helper()
|
|
dir, err := os.MkdirTemp("", "goget-output-test-*")
|
|
if err != nil {
|
|
t.Fatalf("failed to create temp dir: %v", err)
|
|
}
|
|
t.Cleanup(func() { os.RemoveAll(dir) })
|
|
return dir
|
|
}
|
|
|
|
func touchFile(t *testing.T, path string) {
|
|
t.Helper()
|
|
f, err := os.Create(path)
|
|
if err != nil {
|
|
t.Fatalf("failed to create file %s: %v", path, err)
|
|
}
|
|
f.Close()
|
|
}
|
|
|
|
// ============================================================================
|
|
// atomic.go tests
|
|
// ============================================================================
|
|
|
|
func TestCreateTempFile(t *testing.T) {
|
|
dir := tempDir(t)
|
|
|
|
f, err := CreateTempFile(dir, "test-*.tmp")
|
|
if err != nil {
|
|
t.Fatalf("CreateTempFile failed: %v", err)
|
|
}
|
|
defer f.Close()
|
|
defer os.Remove(f.Name())
|
|
|
|
if f == nil {
|
|
t.Fatal("expected non-nil file")
|
|
}
|
|
if !strings.HasPrefix(filepath.Base(f.Name()), "test-") {
|
|
t.Errorf("temp file name should start with 'test-', got %s", filepath.Base(f.Name()))
|
|
}
|
|
if !strings.HasSuffix(f.Name(), ".tmp") {
|
|
t.Errorf("temp file name should end with '.tmp', got %s", filepath.Base(f.Name()))
|
|
}
|
|
if filepath.Dir(f.Name()) != dir {
|
|
t.Errorf("temp file should be in dir %s, got %s", dir, filepath.Dir(f.Name()))
|
|
}
|
|
}
|
|
|
|
func TestAtomicReplace(t *testing.T) {
|
|
dir := tempDir(t)
|
|
|
|
tempPath := filepath.Join(dir, "tempfile")
|
|
finalPath := filepath.Join(dir, "finalfile")
|
|
|
|
// Write content to temp file
|
|
if err := os.WriteFile(tempPath, []byte("hello world"), 0644); err != nil {
|
|
t.Fatalf("failed to write temp file: %v", err)
|
|
}
|
|
|
|
// Replace atomically
|
|
if err := AtomicReplace(tempPath, finalPath); err != nil {
|
|
t.Fatalf("AtomicReplace failed: %v", err)
|
|
}
|
|
|
|
// Check temp file no longer exists
|
|
if _, err := os.Stat(tempPath); !os.IsNotExist(err) {
|
|
t.Error("temp file should no longer exist after rename")
|
|
}
|
|
|
|
// Check final file exists with correct content
|
|
data, err := os.ReadFile(finalPath)
|
|
if err != nil {
|
|
t.Fatalf("failed to read final file: %v", err)
|
|
}
|
|
if string(data) != "hello world" {
|
|
t.Errorf("expected 'hello world', got '%s'", string(data))
|
|
}
|
|
}
|
|
|
|
func TestAtomicReplaceOverwrite(t *testing.T) {
|
|
dir := tempDir(t)
|
|
|
|
tempPath := filepath.Join(dir, "tempfile")
|
|
finalPath := filepath.Join(dir, "finalfile")
|
|
|
|
// Create final file with old content
|
|
if err := os.WriteFile(finalPath, []byte("old content"), 0644); err != nil {
|
|
t.Fatalf("failed to create final file: %v", err)
|
|
}
|
|
|
|
// Write new content to temp file
|
|
if err := os.WriteFile(tempPath, []byte("new content"), 0644); err != nil {
|
|
t.Fatalf("failed to write temp file: %v", err)
|
|
}
|
|
|
|
// Replace atomically
|
|
if err := AtomicReplace(tempPath, finalPath); err != nil {
|
|
t.Fatalf("AtomicReplace failed: %v", err)
|
|
}
|
|
|
|
data, err := os.ReadFile(finalPath)
|
|
if err != nil {
|
|
t.Fatalf("failed to read final file: %v", err)
|
|
}
|
|
if string(data) != "new content" {
|
|
t.Errorf("expected 'new content', got '%s'", string(data))
|
|
}
|
|
}
|
|
|
|
func TestCleanupTempFile(t *testing.T) {
|
|
dir := tempDir(t)
|
|
|
|
// Create a temp file
|
|
f, err := os.CreateTemp(dir, "cleanup-test-*")
|
|
if err != nil {
|
|
t.Fatalf("failed to create temp file: %v", err)
|
|
}
|
|
path := f.Name()
|
|
f.Close()
|
|
|
|
// Verify it exists
|
|
if _, err := os.Stat(path); os.IsNotExist(err) {
|
|
t.Fatal("temp file should exist before cleanup")
|
|
}
|
|
|
|
// Clean it up
|
|
if err := CleanupTempFile(path); err != nil {
|
|
t.Fatalf("CleanupTempFile failed: %v", err)
|
|
}
|
|
|
|
// Verify it's gone
|
|
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
|
t.Error("temp file should not exist after cleanup")
|
|
}
|
|
}
|
|
|
|
func TestCleanupTempFileNotExists(t *testing.T) {
|
|
dir := tempDir(t)
|
|
nonexistent := filepath.Join(dir, "nonexistent.tmp")
|
|
|
|
err := CleanupTempFile(nonexistent)
|
|
if err == nil {
|
|
t.Error("expected error when removing nonexistent file")
|
|
}
|
|
if !os.IsNotExist(err) {
|
|
t.Errorf("expected IsNotExist error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestGetTempDir(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
path string
|
|
expected string
|
|
}{
|
|
{
|
|
name: "normal file path",
|
|
path: "/tmp/foo/bar.txt",
|
|
expected: "/tmp/foo",
|
|
},
|
|
{
|
|
name: "just filename",
|
|
path: "file.txt",
|
|
expected: ".",
|
|
},
|
|
{
|
|
name: "root file",
|
|
path: "/file.txt",
|
|
expected: "/",
|
|
},
|
|
{
|
|
name: "nested directories",
|
|
path: "/a/b/c/d/file.txt",
|
|
expected: "/a/b/c/d",
|
|
},
|
|
{
|
|
name: "relative path",
|
|
path: "some/relative/path/file.txt",
|
|
expected: "some/relative/path",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := GetTempDir(tt.path)
|
|
if got != tt.expected {
|
|
t.Errorf("GetTempDir(%q) = %q, want %q", tt.path, got, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// resume.go tests
|
|
// ============================================================================
|
|
|
|
func TestNewResumeMetadata(t *testing.T) {
|
|
now := time.Now()
|
|
rm := NewResumeMetadata("https://example.com/file", "abc123", "Mon, 02 Jan 2006 15:04:05 GMT", 1024, 2048)
|
|
|
|
if rm == nil {
|
|
t.Fatal("expected non-nil metadata")
|
|
}
|
|
if rm.URL != "https://example.com/file" {
|
|
t.Errorf("URL = %q, want %q", rm.URL, "https://example.com/file")
|
|
}
|
|
if rm.ETag != "abc123" {
|
|
t.Errorf("ETag = %q, want %q", rm.ETag, "abc123")
|
|
}
|
|
if rm.LastModified != "Mon, 02 Jan 2006 15:04:05 GMT" {
|
|
t.Errorf("LastModified = %q, want %q", rm.LastModified, "Mon, 02 Jan 2006 15:04:05 GMT")
|
|
}
|
|
if rm.Downloaded != 1024 {
|
|
t.Errorf("Downloaded = %d, want %d", rm.Downloaded, 1024)
|
|
}
|
|
if rm.Total != 2048 {
|
|
t.Errorf("Total = %d, want %d", rm.Total, 2048)
|
|
}
|
|
if rm.Version != "1.0" {
|
|
t.Errorf("Version = %q, want %q", rm.Version, "1.0")
|
|
}
|
|
if rm.LastWrite.Before(now) {
|
|
t.Error("LastWrite should be after test start time")
|
|
}
|
|
if rm.LastWrite.After(time.Now()) {
|
|
t.Error("LastWrite should not be in the future")
|
|
}
|
|
}
|
|
|
|
func TestNewResumeMetadataEmptyValues(t *testing.T) {
|
|
rm := NewResumeMetadata("https://example.com/file", "", "", 0, 0)
|
|
|
|
if rm.ETag != "" {
|
|
t.Errorf("ETag should be empty, got %q", rm.ETag)
|
|
}
|
|
if rm.LastModified != "" {
|
|
t.Errorf("LastModified should be empty, got %q", rm.LastModified)
|
|
}
|
|
if rm.Downloaded != 0 {
|
|
t.Errorf("Downloaded should be 0, got %d", rm.Downloaded)
|
|
}
|
|
if rm.Total != 0 {
|
|
t.Errorf("Total should be 0, got %d", rm.Total)
|
|
}
|
|
}
|
|
|
|
func TestSaveAndLoadResumeMetadata(t *testing.T) {
|
|
dir := tempDir(t)
|
|
outputPath := filepath.Join(dir, "download.dat")
|
|
metaPath := outputPath + ResumeMetaFileSuffix
|
|
|
|
rm := NewResumeMetadata("https://example.com/file", "etag-value", "last-mod-value", 5000, 10000)
|
|
|
|
// Save
|
|
if err := rm.Save(outputPath); err != nil {
|
|
t.Fatalf("Save failed: %v", err)
|
|
}
|
|
|
|
// Check meta file exists
|
|
if _, err := os.Stat(metaPath); os.IsNotExist(err) {
|
|
t.Fatal("metadata file was not created")
|
|
}
|
|
|
|
// Load
|
|
loaded, err := LoadResumeMetadata(outputPath)
|
|
if err != nil {
|
|
t.Fatalf("LoadResumeMetadata failed: %v", err)
|
|
}
|
|
if loaded == nil {
|
|
t.Fatal("expected non-nil metadata")
|
|
}
|
|
|
|
// Verify fields
|
|
if loaded.URL != rm.URL {
|
|
t.Errorf("URL = %q, want %q", loaded.URL, rm.URL)
|
|
}
|
|
if loaded.ETag != rm.ETag {
|
|
t.Errorf("ETag = %q, want %q", loaded.ETag, rm.ETag)
|
|
}
|
|
if loaded.LastModified != rm.LastModified {
|
|
t.Errorf("LastModified = %q, want %q", loaded.LastModified, rm.LastModified)
|
|
}
|
|
if loaded.Downloaded != rm.Downloaded {
|
|
t.Errorf("Downloaded = %d, want %d", loaded.Downloaded, rm.Downloaded)
|
|
}
|
|
if loaded.Total != rm.Total {
|
|
t.Errorf("Total = %d, want %d", loaded.Total, rm.Total)
|
|
}
|
|
if loaded.Version != rm.Version {
|
|
t.Errorf("Version = %q, want %q", loaded.Version, rm.Version)
|
|
}
|
|
}
|
|
|
|
// TestSaveResumeMetadataHasSecurePermissions is a regression guard for the
|
|
// BACKLOG entry "Resume metadata saved with 0644". The .goget.meta sidecar
|
|
// stores the source URL (which may embed auth tokens in the query string)
|
|
// along with ETag and Last-Modified — values that must not be world-readable
|
|
// on multi-user systems.
|
|
func TestSaveResumeMetadataHasSecurePermissions(t *testing.T) {
|
|
dir := tempDir(t)
|
|
outputPath := filepath.Join(dir, "download.dat")
|
|
metaPath := outputPath + ResumeMetaFileSuffix
|
|
|
|
rm := NewResumeMetadata(
|
|
"https://user:token@example.com/file?sig=secret",
|
|
"etag-value",
|
|
"last-mod-value",
|
|
100, 200,
|
|
)
|
|
if err := rm.Save(outputPath); err != nil {
|
|
t.Fatalf("Save: %v", err)
|
|
}
|
|
|
|
info, err := os.Stat(metaPath)
|
|
if err != nil {
|
|
t.Fatalf("Stat: %v", err)
|
|
}
|
|
|
|
if mode := info.Mode().Perm(); mode != 0600 {
|
|
t.Errorf("metadata file mode = %#o, want %#o", mode, 0600)
|
|
}
|
|
}
|
|
|
|
func TestLoadResumeMetadataNoFile(t *testing.T) {
|
|
dir := tempDir(t)
|
|
outputPath := filepath.Join(dir, "nonexistent.dat")
|
|
|
|
meta, err := LoadResumeMetadata(outputPath)
|
|
if err != nil {
|
|
t.Fatalf("LoadResumeMetadata should not error for missing file: %v", err)
|
|
}
|
|
if meta != nil {
|
|
t.Error("expected nil metadata for nonexistent file")
|
|
}
|
|
}
|
|
|
|
func TestLoadResumeMetadataCorrupt(t *testing.T) {
|
|
dir := tempDir(t)
|
|
outputPath := filepath.Join(dir, "corrupt.dat")
|
|
metaPath := outputPath + ResumeMetaFileSuffix
|
|
|
|
// Write invalid JSON
|
|
if err := os.WriteFile(metaPath, []byte("{invalid json}"), 0644); err != nil {
|
|
t.Fatalf("failed to write corrupt meta file: %v", err)
|
|
}
|
|
|
|
_, err := LoadResumeMetadata(outputPath)
|
|
if err == nil {
|
|
t.Fatal("expected error for corrupt metadata")
|
|
}
|
|
if !strings.Contains(err.Error(), "failed to parse metadata") {
|
|
t.Errorf("expected parse error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDeleteResumeMetadata(t *testing.T) {
|
|
dir := tempDir(t)
|
|
outputPath := filepath.Join(dir, "download.dat")
|
|
metaPath := outputPath + ResumeMetaFileSuffix
|
|
|
|
// Save metadata first
|
|
rm := NewResumeMetadata("https://example.com/file", "", "", 100, 200)
|
|
if err := rm.Save(outputPath); err != nil {
|
|
t.Fatalf("Save failed: %v", err)
|
|
}
|
|
|
|
// Verify file exists
|
|
if _, err := os.Stat(metaPath); os.IsNotExist(err) {
|
|
t.Fatal("metadata file should exist before delete")
|
|
}
|
|
|
|
// Delete it
|
|
if err := DeleteResumeMetadata(outputPath); err != nil {
|
|
t.Fatalf("DeleteResumeMetadata failed: %v", err)
|
|
}
|
|
|
|
// Verify file is gone
|
|
if _, err := os.Stat(metaPath); !os.IsNotExist(err) {
|
|
t.Error("metadata file should not exist after delete")
|
|
}
|
|
}
|
|
|
|
func TestDeleteResumeMetadataNoFile(t *testing.T) {
|
|
dir := tempDir(t)
|
|
outputPath := filepath.Join(dir, "nonexistent.dat")
|
|
|
|
err := DeleteResumeMetadata(outputPath)
|
|
if err != nil {
|
|
t.Fatalf("DeleteResumeMetadata should not error for missing file: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCanResumeValid(t *testing.T) {
|
|
dir := tempDir(t)
|
|
outputPath := filepath.Join(dir, "download.dat")
|
|
|
|
// Create output file with some content
|
|
data := make([]byte, 5000)
|
|
for i := range data {
|
|
data[i] = byte(i % 256)
|
|
}
|
|
if err := os.WriteFile(outputPath, data, 0644); err != nil {
|
|
t.Fatalf("failed to create output file: %v", err)
|
|
}
|
|
|
|
// Save metadata
|
|
rm := NewResumeMetadata("https://example.com/file", "", "", 5000, 10000)
|
|
if err := rm.Save(outputPath); err != nil {
|
|
t.Fatalf("Save failed: %v", err)
|
|
}
|
|
|
|
// Check resume
|
|
canResume, meta, err := CanResume(outputPath, "https://example.com/file")
|
|
if err != nil {
|
|
t.Fatalf("CanResume failed: %v", err)
|
|
}
|
|
if !canResume {
|
|
t.Fatal("expected canResume = true")
|
|
}
|
|
if meta == nil {
|
|
t.Fatal("expected non-nil metadata")
|
|
}
|
|
if meta.Downloaded != 5000 {
|
|
t.Errorf("Downloaded = %d, want %d", meta.Downloaded, 5000)
|
|
}
|
|
}
|
|
|
|
func TestCanResumeNoFile(t *testing.T) {
|
|
dir := tempDir(t)
|
|
outputPath := filepath.Join(dir, "nonexistent.dat")
|
|
|
|
canResume, meta, err := CanResume(outputPath, "https://example.com/file")
|
|
if err != nil {
|
|
t.Fatalf("CanResume should not error for missing file: %v", err)
|
|
}
|
|
if canResume {
|
|
t.Fatal("expected canResume = false for missing file")
|
|
}
|
|
if meta != nil {
|
|
t.Fatal("expected nil metadata for missing file")
|
|
}
|
|
}
|
|
|
|
func TestCanResumeNoMetadata(t *testing.T) {
|
|
dir := tempDir(t)
|
|
outputPath := filepath.Join(dir, "download.dat")
|
|
|
|
// Create output file but no metadata
|
|
data := make([]byte, 5000)
|
|
if err := os.WriteFile(outputPath, data, 0644); err != nil {
|
|
t.Fatalf("failed to create output file: %v", err)
|
|
}
|
|
|
|
canResume, meta, err := CanResume(outputPath, "https://example.com/file")
|
|
if err != nil {
|
|
t.Fatalf("CanResume failed: %v", err)
|
|
}
|
|
if canResume {
|
|
t.Fatal("expected canResume = false when no metadata")
|
|
}
|
|
if meta != nil {
|
|
t.Fatal("expected nil metadata when no metadata file")
|
|
}
|
|
}
|
|
|
|
func TestCanResumeURLMismatch(t *testing.T) {
|
|
dir := tempDir(t)
|
|
outputPath := filepath.Join(dir, "download.dat")
|
|
|
|
// Create output file
|
|
data := make([]byte, 5000)
|
|
if err := os.WriteFile(outputPath, data, 0644); err != nil {
|
|
t.Fatalf("failed to create output file: %v", err)
|
|
}
|
|
|
|
// Save metadata with different URL
|
|
rm := NewResumeMetadata("https://other.com/file", "", "", 5000, 10000)
|
|
if err := rm.Save(outputPath); err != nil {
|
|
t.Fatalf("Save failed: %v", err)
|
|
}
|
|
|
|
canResume, meta, err := CanResume(outputPath, "https://example.com/file")
|
|
if err == nil {
|
|
t.Fatal("expected error for URL mismatch")
|
|
}
|
|
if !strings.Contains(err.Error(), "url mismatch") {
|
|
t.Errorf("expected url mismatch error, got: %v", err)
|
|
}
|
|
if canResume {
|
|
t.Fatal("expected canResume = false on url mismatch")
|
|
}
|
|
if meta != nil {
|
|
t.Fatal("expected nil metadata on error")
|
|
}
|
|
}
|
|
|
|
func TestCanResumeSizeMismatch(t *testing.T) {
|
|
dir := tempDir(t)
|
|
outputPath := filepath.Join(dir, "download.dat")
|
|
|
|
// Create output file with 5000 bytes
|
|
data := make([]byte, 5000)
|
|
if err := os.WriteFile(outputPath, data, 0644); err != nil {
|
|
t.Fatalf("failed to create output file: %v", err)
|
|
}
|
|
|
|
// Save metadata saying 3000 bytes (mismatch!)
|
|
rm := NewResumeMetadata("https://example.com/file", "", "", 3000, 10000)
|
|
if err := rm.Save(outputPath); err != nil {
|
|
t.Fatalf("Save failed: %v", err)
|
|
}
|
|
|
|
canResume, meta, err := CanResume(outputPath, "https://example.com/file")
|
|
if err == nil {
|
|
t.Fatal("expected error for size mismatch")
|
|
}
|
|
if !strings.Contains(err.Error(), "size mismatch") {
|
|
t.Errorf("expected size mismatch error, got: %v", err)
|
|
}
|
|
if canResume {
|
|
t.Fatal("expected canResume = false on size mismatch")
|
|
}
|
|
if meta != nil {
|
|
t.Fatal("expected nil metadata on error")
|
|
}
|
|
}
|
|
|
|
func TestGetResumeInfo(t *testing.T) {
|
|
dir := tempDir(t)
|
|
outputPath := filepath.Join(dir, "download.dat")
|
|
|
|
// Create output file with known size
|
|
data := make([]byte, 5000)
|
|
if err := os.WriteFile(outputPath, data, 0644); err != nil {
|
|
t.Fatalf("failed to create output file: %v", err)
|
|
}
|
|
|
|
rm := NewResumeMetadata("https://example.com/file", "etag-xyz", "last-mod-value", 5000, 10000)
|
|
|
|
info, err := GetResumeInfo(rm, outputPath)
|
|
if err != nil {
|
|
t.Fatalf("GetResumeInfo failed: %v", err)
|
|
}
|
|
if info == nil {
|
|
t.Fatal("expected non-nil ResumeInfo")
|
|
}
|
|
|
|
if info.DownloadedBytes != 5000 {
|
|
t.Errorf("DownloadedBytes = %d, want %d", info.DownloadedBytes, 5000)
|
|
}
|
|
if info.ETag != "etag-xyz" {
|
|
t.Errorf("ETag = %q, want %q", info.ETag, "etag-xyz")
|
|
}
|
|
if info.LastModified != "last-mod-value" {
|
|
t.Errorf("LastModified = %q, want %q", info.LastModified, "last-mod-value")
|
|
}
|
|
if info.URL != "https://example.com/file" {
|
|
t.Errorf("URL = %q, want %q", info.URL, "https://example.com/file")
|
|
}
|
|
if info.LastWrite.IsZero() {
|
|
t.Error("LastWrite should not be zero")
|
|
}
|
|
}
|
|
|
|
func TestGetResumeInfoNonexistentFile(t *testing.T) {
|
|
dir := tempDir(t)
|
|
outputPath := filepath.Join(dir, "nonexistent.dat")
|
|
|
|
rm := NewResumeMetadata("https://example.com/file", "", "", 0, 0)
|
|
|
|
_, err := GetResumeInfo(rm, outputPath)
|
|
if err == nil {
|
|
t.Fatal("expected error for nonexistent file")
|
|
}
|
|
}
|
|
|
|
func TestFileExists(t *testing.T) {
|
|
dir := tempDir(t)
|
|
|
|
// File that exists
|
|
existingFile := filepath.Join(dir, "exists.txt")
|
|
touchFile(t, existingFile)
|
|
|
|
if !FileExists(existingFile) {
|
|
t.Error("FileExists should return true for existing file")
|
|
}
|
|
|
|
// File that doesn't exist
|
|
if FileExists(filepath.Join(dir, "nonexistent.txt")) {
|
|
t.Error("FileExists should return false for nonexistent file")
|
|
}
|
|
|
|
// Empty path
|
|
if FileExists("") {
|
|
t.Error("FileExists should return false for empty path")
|
|
}
|
|
}
|
|
|
|
func TestGetFileSize(t *testing.T) {
|
|
dir := tempDir(t)
|
|
|
|
// File with known size
|
|
filePath := filepath.Join(dir, "sized.txt")
|
|
content := []byte("hello world, this is a test with 42 characters!!")
|
|
if err := os.WriteFile(filePath, content, 0644); err != nil {
|
|
t.Fatalf("failed to create file: %v", err)
|
|
}
|
|
|
|
size, err := GetFileSize(filePath)
|
|
if err != nil {
|
|
t.Fatalf("GetFileSize failed: %v", err)
|
|
}
|
|
if size != int64(len(content)) {
|
|
t.Errorf("size = %d, want %d", size, len(content))
|
|
}
|
|
|
|
// Empty file
|
|
emptyPath := filepath.Join(dir, "empty.txt")
|
|
touchFile(t, emptyPath)
|
|
|
|
size, err = GetFileSize(emptyPath)
|
|
if err != nil {
|
|
t.Fatalf("GetFileSize failed for empty file: %v", err)
|
|
}
|
|
if size != 0 {
|
|
t.Errorf("size for empty file = %d, want 0", size)
|
|
}
|
|
}
|
|
|
|
func TestGetFileSizeNonexistent(t *testing.T) {
|
|
dir := tempDir(t)
|
|
_, err := GetFileSize(filepath.Join(dir, "nonexistent.txt"))
|
|
if err == nil {
|
|
t.Fatal("expected error for nonexistent file")
|
|
}
|
|
}
|
|
|
|
func TestCleanupResumeFiles(t *testing.T) {
|
|
dir := tempDir(t)
|
|
outputPath := filepath.Join(dir, "download.dat")
|
|
metaPath := outputPath + ResumeMetaFileSuffix
|
|
|
|
// Create output file
|
|
data := make([]byte, 1000)
|
|
if err := os.WriteFile(outputPath, data, 0644); err != nil {
|
|
t.Fatalf("failed to create output file: %v", err)
|
|
}
|
|
|
|
// Save metadata
|
|
rm := NewResumeMetadata("https://example.com/file", "", "", 1000, 2000)
|
|
if err := rm.Save(outputPath); err != nil {
|
|
t.Fatalf("Save failed: %v", err)
|
|
}
|
|
|
|
// Clean up both
|
|
if err := CleanupResumeFiles(outputPath); err != nil {
|
|
t.Fatalf("CleanupResumeFiles failed: %v", err)
|
|
}
|
|
|
|
// Verify both are gone
|
|
if _, err := os.Stat(outputPath); !os.IsNotExist(err) {
|
|
t.Error("output file should not exist after cleanup")
|
|
}
|
|
if _, err := os.Stat(metaPath); !os.IsNotExist(err) {
|
|
t.Error("metadata file should not exist after cleanup")
|
|
}
|
|
}
|
|
|
|
func TestCleanupResumeFilesNoOutput(t *testing.T) {
|
|
dir := tempDir(t)
|
|
outputPath := filepath.Join(dir, "nonexistent.dat")
|
|
|
|
// Should not error even if output file doesn't exist
|
|
err := CleanupResumeFiles(outputPath)
|
|
if err != nil {
|
|
t.Fatalf("CleanupResumeFiles should not error for missing output: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCleanupResumeFilesOnlyOutput(t *testing.T) {
|
|
dir := tempDir(t)
|
|
outputPath := filepath.Join(dir, "download.dat")
|
|
|
|
// Create output file only, no metadata
|
|
touchFile(t, outputPath)
|
|
|
|
if err := CleanupResumeFiles(outputPath); err != nil {
|
|
t.Fatalf("CleanupResumeFiles failed: %v", err)
|
|
}
|
|
|
|
// Output file should be gone
|
|
if _, err := os.Stat(outputPath); !os.IsNotExist(err) {
|
|
t.Error("output file should not exist after cleanup")
|
|
}
|
|
|
|
// Metadata file should never have existed, but cleanup should be idempotent
|
|
metaPath := outputPath + ResumeMetaFileSuffix
|
|
if _, err := os.Stat(metaPath); !os.IsNotExist(err) {
|
|
t.Error("metadata file should not exist")
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// writer.go tests
|
|
// ============================================================================
|
|
|
|
func TestNewWriterDefault(t *testing.T) {
|
|
dir := tempDir(t)
|
|
outputPath := filepath.Join(dir, "output.dat")
|
|
|
|
cfg := DefaultWriterConfig()
|
|
cfg.Output = outputPath
|
|
cfg.Atomic = false // disable atomic for simpler test
|
|
|
|
w, err := NewWriter(cfg)
|
|
if err != nil {
|
|
t.Fatalf("NewWriter failed: %v", err)
|
|
}
|
|
defer w.Close()
|
|
|
|
if w == nil {
|
|
t.Fatal("expected non-nil writer")
|
|
}
|
|
if w.config == nil {
|
|
t.Fatal("config should not be nil")
|
|
}
|
|
if w.config.BufferSize != 32*1024 {
|
|
t.Errorf("BufferSize = %d, want %d", w.config.BufferSize, 32*1024)
|
|
}
|
|
}
|
|
|
|
func TestNewWriterNilConfig(t *testing.T) {
|
|
dir := tempDir(t)
|
|
origDir := dir
|
|
|
|
// When cfg is nil, output is empty, so it goes to stdout
|
|
w, err := NewWriter(nil)
|
|
if err != nil {
|
|
t.Fatalf("NewWriter(nil) failed: %v", err)
|
|
}
|
|
defer w.Close()
|
|
|
|
if w == nil {
|
|
t.Fatal("expected non-nil writer")
|
|
}
|
|
if w.config.BufferSize != 32*1024 {
|
|
t.Errorf("BufferSize = %d, want %d", w.config.BufferSize, 32*1024)
|
|
}
|
|
if w.file != os.Stdout {
|
|
t.Error("expected stdout file when cfg is nil")
|
|
}
|
|
|
|
// Cleanup
|
|
os.RemoveAll(origDir)
|
|
}
|
|
|
|
func TestNewWriterStdout(t *testing.T) {
|
|
cfg := DefaultWriterConfig()
|
|
cfg.Output = "-"
|
|
|
|
w, err := NewWriter(cfg)
|
|
if err != nil {
|
|
t.Fatalf("NewWriter failed: %v", err)
|
|
}
|
|
defer w.Close()
|
|
|
|
if w.file != os.Stdout {
|
|
t.Error("expected writer to use stdout")
|
|
}
|
|
if w.tempFile != nil {
|
|
t.Error("expected no temp file for stdout output")
|
|
}
|
|
}
|
|
|
|
func TestNewWriterFileExistsError(t *testing.T) {
|
|
dir := tempDir(t)
|
|
outputPath := filepath.Join(dir, "existing.dat")
|
|
|
|
// Create file first
|
|
touchFile(t, outputPath)
|
|
|
|
cfg := DefaultWriterConfig()
|
|
cfg.Output = outputPath
|
|
cfg.Atomic = false
|
|
cfg.Resume = false
|
|
|
|
_, err := NewWriter(cfg)
|
|
if err == nil {
|
|
t.Fatal("expected error when file already exists and resume is disabled")
|
|
}
|
|
if !strings.Contains(err.Error(), "file already exists") {
|
|
t.Errorf("expected 'file already exists' error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestWriterWrite(t *testing.T) {
|
|
dir := tempDir(t)
|
|
outputPath := filepath.Join(dir, "output.dat")
|
|
|
|
cfg := DefaultWriterConfig()
|
|
cfg.Output = outputPath
|
|
cfg.Atomic = false
|
|
|
|
w, err := NewWriter(cfg)
|
|
if err != nil {
|
|
t.Fatalf("NewWriter failed: %v", err)
|
|
}
|
|
defer w.Close()
|
|
|
|
data := []byte("hello world")
|
|
n, err := w.Write(data)
|
|
if err != nil {
|
|
t.Fatalf("Write failed: %v", err)
|
|
}
|
|
if n != len(data) {
|
|
t.Errorf("written bytes = %d, want %d", n, len(data))
|
|
}
|
|
if w.Written() != int64(len(data)) {
|
|
t.Errorf("Written() = %d, want %d", w.Written(), len(data))
|
|
}
|
|
}
|
|
|
|
func TestWriterWriteMultiple(t *testing.T) {
|
|
dir := tempDir(t)
|
|
outputPath := filepath.Join(dir, "output.dat")
|
|
|
|
cfg := DefaultWriterConfig()
|
|
cfg.Output = outputPath
|
|
cfg.Atomic = false
|
|
|
|
w, err := NewWriter(cfg)
|
|
if err != nil {
|
|
t.Fatalf("NewWriter failed: %v", err)
|
|
}
|
|
defer w.Close()
|
|
|
|
totalWritten := 0
|
|
chunks := []string{"hello ", "world ", "this ", "is ", "a ", "test"}
|
|
for _, chunk := range chunks {
|
|
n, err := w.Write([]byte(chunk))
|
|
if err != nil {
|
|
t.Fatalf("Write(%q) failed: %v", chunk, err)
|
|
}
|
|
totalWritten += n
|
|
}
|
|
|
|
expected := "hello world this is a test"
|
|
if w.Written() != int64(len(expected)) {
|
|
t.Errorf("Written() = %d, want %d", w.Written(), len(expected))
|
|
}
|
|
|
|
// Verify file content
|
|
data, err := os.ReadFile(outputPath)
|
|
if err != nil {
|
|
t.Fatalf("failed to read output file: %v", err)
|
|
}
|
|
if string(data) != expected {
|
|
t.Errorf("file content = %q, want %q", string(data), expected)
|
|
}
|
|
}
|
|
|
|
func TestWriterProgressCallback(t *testing.T) {
|
|
dir := tempDir(t)
|
|
outputPath := filepath.Join(dir, "output.dat")
|
|
|
|
var mu sync.Mutex
|
|
var calls []struct {
|
|
current int64
|
|
total int64
|
|
speed float64
|
|
}
|
|
|
|
cfg := DefaultWriterConfig()
|
|
cfg.Output = outputPath
|
|
cfg.Atomic = false
|
|
cfg.ProgressCallback = func(current, total int64, speed float64) {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
calls = append(calls, struct {
|
|
current int64
|
|
total int64
|
|
speed float64
|
|
}{current, total, speed})
|
|
}
|
|
|
|
w, err := NewWriter(cfg)
|
|
if err != nil {
|
|
t.Fatalf("NewWriter failed: %v", err)
|
|
}
|
|
defer w.Close()
|
|
|
|
// Write some data
|
|
w.Write([]byte("first chunk "))
|
|
w.Write([]byte("second chunk"))
|
|
w.Close()
|
|
|
|
mu.Lock()
|
|
callCount := len(calls)
|
|
mu.Unlock()
|
|
|
|
if callCount != 2 {
|
|
t.Errorf("expected 2 callback calls, got %d", callCount)
|
|
}
|
|
}
|
|
|
|
func TestWriterProgressCallbackValues(t *testing.T) {
|
|
dir := tempDir(t)
|
|
outputPath := filepath.Join(dir, "output.dat")
|
|
|
|
var lastCurrent, lastTotal int64
|
|
|
|
cfg := DefaultWriterConfig()
|
|
cfg.Output = outputPath
|
|
cfg.Atomic = false
|
|
cfg.ProgressCallback = func(current, total int64, speed float64) {
|
|
lastCurrent = current
|
|
lastTotal = total
|
|
}
|
|
|
|
w, err := NewWriter(cfg)
|
|
if err != nil {
|
|
t.Fatalf("NewWriter failed: %v", err)
|
|
}
|
|
defer w.Close()
|
|
|
|
w.SetTotal(100)
|
|
|
|
w.Write([]byte("hello")) // 5 bytes
|
|
if lastCurrent != 5 {
|
|
t.Errorf("callback current = %d, want %d", lastCurrent, 5)
|
|
}
|
|
if lastTotal != 100 {
|
|
t.Errorf("callback total = %d, want %d", lastTotal, 100)
|
|
}
|
|
|
|
w.Write([]byte(" world")) // 6 bytes
|
|
if lastCurrent != 11 {
|
|
t.Errorf("callback current = %d, want %d", lastCurrent, 11)
|
|
}
|
|
}
|
|
|
|
func TestWriterSetTotal(t *testing.T) {
|
|
dir := tempDir(t)
|
|
outputPath := filepath.Join(dir, "output.dat")
|
|
|
|
cfg := DefaultWriterConfig()
|
|
cfg.Output = outputPath
|
|
cfg.Atomic = false
|
|
|
|
w, err := NewWriter(cfg)
|
|
if err != nil {
|
|
t.Fatalf("NewWriter failed: %v", err)
|
|
}
|
|
defer w.Close()
|
|
|
|
if w.Total() != 0 {
|
|
t.Errorf("initial Total() = %d, want 0", w.Total())
|
|
}
|
|
|
|
w.SetTotal(100500)
|
|
if w.Total() != 100500 {
|
|
t.Errorf("Total() = %d, want %d", w.Total(), 100500)
|
|
}
|
|
}
|
|
|
|
func TestWriterClose(t *testing.T) {
|
|
dir := tempDir(t)
|
|
outputPath := filepath.Join(dir, "output.dat")
|
|
|
|
cfg := DefaultWriterConfig()
|
|
cfg.Output = outputPath
|
|
cfg.Atomic = false
|
|
|
|
w, err := NewWriter(cfg)
|
|
if err != nil {
|
|
t.Fatalf("NewWriter failed: %v", err)
|
|
}
|
|
|
|
data := []byte("test data")
|
|
w.Write(data)
|
|
|
|
if err := w.Close(); err != nil {
|
|
t.Fatalf("Close failed: %v", err)
|
|
}
|
|
|
|
// Verify file content
|
|
content, err := os.ReadFile(outputPath)
|
|
if err != nil {
|
|
t.Fatalf("failed to read output: %v", err)
|
|
}
|
|
if string(content) != string(data) {
|
|
t.Errorf("content = %q, want %q", string(content), string(data))
|
|
}
|
|
}
|
|
|
|
func TestWriterCancel(t *testing.T) {
|
|
dir := tempDir(t)
|
|
outputPath := filepath.Join(dir, "output.dat")
|
|
|
|
cfg := DefaultWriterConfig()
|
|
cfg.Output = outputPath
|
|
|
|
w, err := NewWriter(cfg)
|
|
if err != nil {
|
|
t.Fatalf("NewWriter failed: %v", err)
|
|
}
|
|
|
|
w.Write([]byte("test data"))
|
|
tempPath := w.tempFile.Name()
|
|
|
|
// Cancel the write
|
|
w.Cancel()
|
|
|
|
// The temp file should be removed
|
|
if _, err := os.Stat(tempPath); !os.IsNotExist(err) {
|
|
t.Error("temp file should be removed after cancel")
|
|
}
|
|
|
|
// The final file should NOT exist (temp was cancelled before rename)
|
|
if _, err := os.Stat(outputPath); !os.IsNotExist(err) {
|
|
t.Error("output file should not exist after cancel")
|
|
}
|
|
}
|
|
|
|
func TestWriterCancelNoTempFile(t *testing.T) {
|
|
dir := tempDir(t)
|
|
outputPath := filepath.Join(dir, "output.dat")
|
|
|
|
cfg := DefaultWriterConfig()
|
|
cfg.Output = outputPath
|
|
cfg.Atomic = false
|
|
|
|
w, err := NewWriter(cfg)
|
|
if err != nil {
|
|
t.Fatalf("NewWriter failed: %v", err)
|
|
}
|
|
|
|
w.Write([]byte("test data"))
|
|
|
|
// Cancel should not panic even without temp file
|
|
w.Cancel()
|
|
|
|
// File should still exist since atomic is false and close wasn't called to
|
|
// delete it... actually, with atomic=false and cancel, nothing removes files,
|
|
// but the context is cancelled.
|
|
if _, err := os.Stat(outputPath); os.IsNotExist(err) {
|
|
t.Error("output file should still exist after cancel when no temp file")
|
|
}
|
|
os.Remove(outputPath)
|
|
}
|
|
|
|
func TestWriterGetSpeed(t *testing.T) {
|
|
dir := tempDir(t)
|
|
outputPath := filepath.Join(dir, "output.dat")
|
|
|
|
cfg := DefaultWriterConfig()
|
|
cfg.Output = outputPath
|
|
cfg.Atomic = false
|
|
|
|
w, err := NewWriter(cfg)
|
|
if err != nil {
|
|
t.Fatalf("NewWriter failed: %v", err)
|
|
}
|
|
defer w.Close()
|
|
|
|
// Speed should be 0 initially
|
|
speed := w.GetSpeed()
|
|
if speed != 0 {
|
|
t.Errorf("initial speed = %f, want 0", speed)
|
|
}
|
|
|
|
// Write some data
|
|
w.Write([]byte("test data"))
|
|
|
|
// Speed should be > 0 after writing
|
|
speed = w.GetSpeed()
|
|
if speed <= 0 {
|
|
t.Errorf("speed after write = %f, want > 0", speed)
|
|
}
|
|
}
|
|
|
|
func TestWriterGetProgress(t *testing.T) {
|
|
dir := tempDir(t)
|
|
outputPath := filepath.Join(dir, "output.dat")
|
|
|
|
cfg := DefaultWriterConfig()
|
|
cfg.Output = outputPath
|
|
cfg.Atomic = false
|
|
|
|
w, err := NewWriter(cfg)
|
|
if err != nil {
|
|
t.Fatalf("NewWriter failed: %v", err)
|
|
}
|
|
defer w.Close()
|
|
|
|
// Progress should be 0 when total is 0
|
|
if p := w.GetProgress(); p != 0 {
|
|
t.Errorf("GetProgress() with no total = %f, want 0", p)
|
|
}
|
|
|
|
w.SetTotal(100)
|
|
|
|
// Progress should be 0 before any writes
|
|
if p := w.GetProgress(); p != 0 {
|
|
t.Errorf("GetProgress() before write = %f, want 0", p)
|
|
}
|
|
|
|
w.Write([]byte("hello")) // 5 bytes -> 5%
|
|
|
|
p := w.GetProgress()
|
|
if p < 4.9 || p > 5.1 {
|
|
t.Errorf("GetProgress() = %f, want ~5.0", p)
|
|
}
|
|
|
|
w.Write([]byte("hello")) // 10 bytes -> 10%
|
|
|
|
p = w.GetProgress()
|
|
if p < 9.9 || p > 10.1 {
|
|
t.Errorf("GetProgress() = %f, want ~10.0", p)
|
|
}
|
|
|
|
// Write the remaining 90 bytes
|
|
buf := make([]byte, 90)
|
|
w.Write(buf)
|
|
|
|
p = w.GetProgress()
|
|
if p < 99.9 || p > 100.1 {
|
|
t.Errorf("GetProgress() after full write = %f, want ~100.0", p)
|
|
}
|
|
}
|
|
|
|
func TestWriterAtomicWrite(t *testing.T) {
|
|
dir := tempDir(t)
|
|
outputPath := filepath.Join(dir, "output.dat")
|
|
|
|
cfg := DefaultWriterConfig()
|
|
cfg.Output = outputPath
|
|
cfg.Atomic = true
|
|
|
|
w, err := NewWriter(cfg)
|
|
if err != nil {
|
|
t.Fatalf("NewWriter failed: %v", err)
|
|
}
|
|
|
|
// Before close, output file should NOT exist (data is in temp)
|
|
if _, err := os.Stat(outputPath); !os.IsNotExist(err) {
|
|
t.Error("output file should not exist before Close with atomic write")
|
|
}
|
|
|
|
// Temp file should exist
|
|
if w.tempFile == nil {
|
|
t.Fatal("expected temp file for atomic write")
|
|
}
|
|
tempPath := w.tempFile.Name()
|
|
if _, err := os.Stat(tempPath); os.IsNotExist(err) {
|
|
t.Error("temp file should exist before Close")
|
|
}
|
|
|
|
// Write data
|
|
w.Write([]byte("atomic data"))
|
|
|
|
// Close triggers rename
|
|
if err := w.Close(); err != nil {
|
|
t.Fatalf("Close failed: %v", err)
|
|
}
|
|
|
|
// Temp file should be gone
|
|
if _, err := os.Stat(tempPath); !os.IsNotExist(err) {
|
|
t.Error("temp file should not exist after atomic rename")
|
|
}
|
|
|
|
// Output file should now exist with correct content
|
|
data, err := os.ReadFile(outputPath)
|
|
if err != nil {
|
|
t.Fatalf("failed to read output file: %v", err)
|
|
}
|
|
if string(data) != "atomic data" {
|
|
t.Errorf("content = %q, want %q", string(data), "atomic data")
|
|
}
|
|
}
|
|
|
|
// TestWriterAtomicTempFileHasSecurePermissions is a regression guard for the
|
|
// BACKLOG entry "Resume metadata saved with 0644" (second clause). While
|
|
// the atomic download is in progress the temp file holds the entire payload,
|
|
// which can include private data. The temp file must be owner-only until
|
|
// it is renamed into place.
|
|
func TestWriterAtomicTempFileHasSecurePermissions(t *testing.T) {
|
|
dir := tempDir(t)
|
|
outputPath := filepath.Join(dir, "output.dat")
|
|
|
|
cfg := DefaultWriterConfig()
|
|
cfg.Output = outputPath
|
|
cfg.Atomic = true
|
|
|
|
w, err := NewWriter(cfg)
|
|
if err != nil {
|
|
t.Fatalf("NewWriter: %v", err)
|
|
}
|
|
t.Cleanup(func() { w.Cancel() })
|
|
|
|
tempPath := w.tempFile.Name()
|
|
|
|
info, err := os.Stat(tempPath)
|
|
if err != nil {
|
|
t.Fatalf("Stat(temp): %v", err)
|
|
}
|
|
|
|
if mode := info.Mode().Perm(); mode != 0600 {
|
|
t.Errorf("temp file mode = %#o, want %#o", mode, 0600)
|
|
}
|
|
}
|
|
|
|
func TestWriterAtomicWriteWithStdout(t *testing.T) {
|
|
// When output is stdout, atomic mode should not create temp files
|
|
cfg := DefaultWriterConfig()
|
|
cfg.Output = "-"
|
|
cfg.Atomic = true
|
|
|
|
w, err := NewWriter(cfg)
|
|
if err != nil {
|
|
t.Fatalf("NewWriter failed: %v", err)
|
|
}
|
|
defer w.Close()
|
|
|
|
if w.tempFile != nil {
|
|
t.Error("no temp file should be created for stdout output")
|
|
}
|
|
if w.file != os.Stdout {
|
|
t.Error("should write to stdout")
|
|
}
|
|
}
|
|
|
|
func TestWriterResume(t *testing.T) {
|
|
dir := tempDir(t)
|
|
outputPath := filepath.Join(dir, "resume.dat")
|
|
|
|
// Create initial partial download (50 bytes)
|
|
initialData := make([]byte, 50)
|
|
for i := range initialData {
|
|
initialData[i] = byte(i)
|
|
}
|
|
if err := os.WriteFile(outputPath, initialData, 0644); err != nil {
|
|
t.Fatalf("failed to create initial file: %v", err)
|
|
}
|
|
|
|
// Create metadata
|
|
rm := NewResumeMetadata("https://example.com/file", "", "", 50, 100)
|
|
if err := rm.Save(outputPath); err != nil {
|
|
t.Fatalf("Save failed: %v", err)
|
|
}
|
|
|
|
// Open writer in resume mode
|
|
cfg := DefaultWriterConfig()
|
|
cfg.Output = outputPath
|
|
cfg.Atomic = false
|
|
cfg.Resume = true
|
|
|
|
w, err := NewWriter(cfg)
|
|
if err != nil {
|
|
t.Fatalf("NewWriter failed: %v", err)
|
|
}
|
|
|
|
// Written should start at 50 (from existing file)
|
|
if w.Written() != 50 {
|
|
t.Errorf("Written() after resume open = %d, want 50", w.Written())
|
|
}
|
|
|
|
// Write more data
|
|
moreData := make([]byte, 50)
|
|
for i := range moreData {
|
|
moreData[i] = byte(i + 50)
|
|
}
|
|
n, err := w.Write(moreData)
|
|
if err != nil {
|
|
t.Fatalf("Write failed: %v", err)
|
|
}
|
|
if n != 50 {
|
|
t.Errorf("written bytes = %d, want 50", n)
|
|
}
|
|
|
|
w.Close()
|
|
|
|
// Verify total file is 100 bytes
|
|
fileInfo, err := os.Stat(outputPath)
|
|
if err != nil {
|
|
t.Fatalf("failed to stat output: %v", err)
|
|
}
|
|
if fileInfo.Size() != 100 {
|
|
t.Errorf("final file size = %d, want 100", fileInfo.Size())
|
|
}
|
|
|
|
// Verify file content
|
|
data, err := os.ReadFile(outputPath)
|
|
if err != nil {
|
|
t.Fatalf("failed to read output: %v", err)
|
|
}
|
|
if len(data) != 100 {
|
|
t.Fatalf("data length = %d, want 100", len(data))
|
|
}
|
|
for i := 0; i < 100; i++ {
|
|
if data[i] != byte(i) {
|
|
t.Errorf("data[%d] = %d, want %d", i, data[i], byte(i))
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestWriterSetProgressCallback(t *testing.T) {
|
|
dir := tempDir(t)
|
|
outputPath := filepath.Join(dir, "output.dat")
|
|
|
|
cfg := DefaultWriterConfig()
|
|
cfg.Output = outputPath
|
|
cfg.Atomic = false
|
|
|
|
w, err := NewWriter(cfg)
|
|
if err != nil {
|
|
t.Fatalf("NewWriter failed: %v", err)
|
|
}
|
|
defer w.Close()
|
|
|
|
// No callback initially
|
|
w.Write([]byte("data"))
|
|
|
|
// Set callback
|
|
var called bool
|
|
w.SetProgressCallback(func(current, total int64, speed float64) {
|
|
called = true
|
|
})
|
|
|
|
w.Write([]byte(" more data"))
|
|
|
|
if !called {
|
|
t.Error("expected callback to be called after SetProgressCallback")
|
|
}
|
|
}
|
|
|
|
func TestWriterGetDuration(t *testing.T) {
|
|
dir := tempDir(t)
|
|
outputPath := filepath.Join(dir, "output.dat")
|
|
|
|
cfg := DefaultWriterConfig()
|
|
cfg.Output = outputPath
|
|
cfg.Atomic = false
|
|
|
|
w, err := NewWriter(cfg)
|
|
if err != nil {
|
|
t.Fatalf("NewWriter failed: %v", err)
|
|
}
|
|
defer w.Close()
|
|
|
|
duration := w.GetDuration()
|
|
if duration < 0 {
|
|
t.Errorf("duration should be >= 0, got %v", duration)
|
|
}
|
|
|
|
// A tiny sleep to make duration non-zero
|
|
time.Sleep(time.Millisecond)
|
|
|
|
duration = w.GetDuration()
|
|
if duration <= 0 {
|
|
t.Errorf("duration should be > 0 after sleep, got %v", duration)
|
|
}
|
|
}
|
|
|
|
func TestWriterConcurrentSafety(t *testing.T) {
|
|
dir := tempDir(t)
|
|
outputPath := filepath.Join(dir, "concurrent.dat")
|
|
|
|
cfg := DefaultWriterConfig()
|
|
cfg.Output = outputPath
|
|
cfg.Atomic = false
|
|
|
|
w, err := NewWriter(cfg)
|
|
if err != nil {
|
|
t.Fatalf("NewWriter failed: %v", err)
|
|
}
|
|
defer w.Close()
|
|
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < 10; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
w.Write([]byte("x"))
|
|
w.Written()
|
|
w.Total()
|
|
w.GetSpeed()
|
|
w.GetProgress()
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
|
|
if w.Written() != 10 {
|
|
t.Errorf("Written() = %d, want 10", w.Written())
|
|
}
|
|
}
|
|
|
|
func TestWriterAtomicThenCancel(t *testing.T) {
|
|
dir := tempDir(t)
|
|
outputPath := filepath.Join(dir, "atomic-cancel.dat")
|
|
|
|
cfg := DefaultWriterConfig()
|
|
cfg.Output = outputPath
|
|
cfg.Atomic = true
|
|
|
|
w, err := NewWriter(cfg)
|
|
if err != nil {
|
|
t.Fatalf("NewWriter failed: %v", err)
|
|
}
|
|
|
|
tempPath := w.tempFile.Name()
|
|
|
|
w.Write([]byte("data to cancel"))
|
|
|
|
// Cancel should remove temp file
|
|
w.Cancel()
|
|
|
|
if _, err := os.Stat(tempPath); !os.IsNotExist(err) {
|
|
t.Error("temp file should be removed after cancel")
|
|
}
|
|
|
|
if _, err := os.Stat(outputPath); !os.IsNotExist(err) {
|
|
t.Error("output file should not exist after cancel")
|
|
}
|
|
}
|
|
|
|
func TestWriterContextCancellation(t *testing.T) {
|
|
dir := tempDir(t)
|
|
outputPath := filepath.Join(dir, "context-cancel.dat")
|
|
|
|
cfg := DefaultWriterConfig()
|
|
cfg.Output = outputPath
|
|
cfg.Atomic = true
|
|
|
|
w, err := NewWriter(cfg)
|
|
if err != nil {
|
|
t.Fatalf("NewWriter failed: %v", err)
|
|
}
|
|
defer w.Close()
|
|
|
|
if w.ctx == nil {
|
|
t.Fatal("expected non-nil context")
|
|
}
|
|
|
|
select {
|
|
case <-w.ctx.Done():
|
|
t.Fatal("context should not be done initially")
|
|
default:
|
|
}
|
|
|
|
w.Cancel()
|
|
|
|
select {
|
|
case <-w.ctx.Done():
|
|
// expected
|
|
default:
|
|
t.Fatal("context should be done after cancel")
|
|
}
|
|
}
|
|
|
|
func TestWriterDoubleClose(t *testing.T) {
|
|
dir := tempDir(t)
|
|
outputPath := filepath.Join(dir, "double-close.dat")
|
|
|
|
cfg := DefaultWriterConfig()
|
|
cfg.Output = outputPath
|
|
cfg.Atomic = false
|
|
|
|
w, err := NewWriter(cfg)
|
|
if err != nil {
|
|
t.Fatalf("NewWriter failed: %v", err)
|
|
}
|
|
|
|
w.Write([]byte("data"))
|
|
|
|
if err := w.Close(); err != nil {
|
|
t.Fatalf("first Close failed: %v", err)
|
|
}
|
|
|
|
// Second close should not panic
|
|
if err := w.Close(); err != nil {
|
|
t.Logf("second Close returned: %v (acceptable)", err)
|
|
}
|
|
}
|