4 Commits
Author SHA1 Message Date
wt f854cbace5 added more tests
Go Build and Test / build-and-test (push) Successful in 25s
2025-08-12 16:55:55 +07:00
wt bca550374f added serve_test.go
Go Build and Test / build-and-test (push) Successful in 26s
2025-08-12 16:25:07 +07:00
wt a1681ab58a Улучшение обработки путей и добавление списка файлов
Go Build and Test / build-and-test (push) Successful in 29s
- Заменена filepath.Ext() на strings.HasSuffix() для корректной проверки
расширений - Оптимизирована логика проверки исключаемых путей: - Упрощён
pattern matching - Добавлена явная проверка префиксов путей - Улучшена
обработка базовых имён файлов - Добавлен новый endpoint GET /backup: -
Возвращает JSON-список файлов в директории бэкапов - Реализована
фильтрация директорий
2025-08-12 16:17:43 +07:00
wt 4639779116 feat: add exclude pattern support for directory backups
- Added --exclude flag to allow excluding files/directories from backups
- Implemented pattern-based exclusion logic for directory backups -
Modified size calculation to account for excluded items - Updated
progress tracking to reflect excluded content - Added helper function
for path exclusion checks - Ensured consistent exclusion handling across
size calculation and backup

Key changes: 1. Added excludePatterns variable and flag binding 2.
Updated getPathSize to skip excluded paths 3. Implemented
shouldExcludePath with multiple matching strategies: - Full path
matching - Prefix matching for directories - Glob pattern matching -
Base name matching 4. Integrated exclusion checks in backupDirectory 5.
Propagated excludePatterns through initialModel 6. Maintained progress
accuracy by adjusting total size calculation

The new functionality allows users to specify exclusion patterns:
backup-tool create -s /data -t backup.tar.gz -e "*.tmp" -e "cache/"
2025-08-12 14:07:05 +07:00
12 changed files with 841 additions and 37 deletions
+2 -1
View File
@@ -6,6 +6,7 @@ on:
branches: branches:
- main - main
- master - master
- develop
pull_request: pull_request:
branches: branches:
- main - main
@@ -25,7 +26,7 @@ jobs:
uses: actions/setup-go@v5 uses: actions/setup-go@v5
with: with:
# Use a Go version that matches your project's go.mod file # Use a Go version that matches your project's go.mod file
go-version: '1.22' go-version: "1.22"
# Step 3: Download Go module dependencies # Step 3: Download Go module dependencies
- name: Tidy and Vendor - name: Tidy and Vendor
+87 -11
View File
@@ -26,6 +26,7 @@ import (
var ( var (
source string source string
target string target string
excludePatterns []string
) )
var createCmd = &cobra.Command{ var createCmd = &cobra.Command{
@@ -33,7 +34,7 @@ var createCmd = &cobra.Command{
Short: "Create a new backup", Short: "Create a new backup",
Long: "Create a new backup of a file, directory, or disk", Long: "Create a new backup of a file, directory, or disk",
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
model, err := initialModel(source, target) model, err := initialModel(source, target, excludePatterns)
if err != nil { if err != nil {
fmt.Println("Error:", err) fmt.Println("Error:", err)
os.Exit(1) os.Exit(1)
@@ -60,13 +61,14 @@ type model struct {
isDirectory bool isDirectory bool
httpTargetInfo *ServerTargetInfo httpTargetInfo *ServerTargetInfo
isP2PTarget bool // Флаг для старого P2P-режима isP2PTarget bool // Флаг для старого P2P-режима
excludePatterns []string
} }
type progressMsg int64 type progressMsg int64
type doneMsg struct{} type doneMsg struct{}
type errorMsg struct{ err error } type errorMsg struct{ err error }
func getPathSize(path string) (int64, error) { func getPathSize(path string, excludePatterns []string) (int64, error) {
fileInfo, err := os.Stat(path) fileInfo, err := os.Stat(path)
if err != nil { if err != nil {
return 0, err return 0, err
@@ -97,10 +99,27 @@ func getPathSize(path string) (int64, error) {
if fileInfo.IsDir() { if fileInfo.IsDir() {
var total int64 var total int64
err := filepath.Walk(path, func(_ string, info os.FileInfo, err error) error { err := filepath.Walk(path, func(current string, info os.FileInfo, err error) error {
if err != nil { if err != nil {
return err return err
} }
relPath, err := filepath.Rel(path, current)
if err != nil {
return err
}
if relPath == "." {
return nil
}
if shouldExcludePath(relPath, excludePatterns) {
if info.IsDir() {
return filepath.SkipDir
}
return nil
}
if !info.IsDir() { if !info.IsDir() {
total += info.Size() total += info.Size()
} }
@@ -112,8 +131,8 @@ func getPathSize(path string) (int64, error) {
return fileInfo.Size(), nil return fileInfo.Size(), nil
} }
func initialModel(src, dst string) (*model, error) { func initialModel(src, dst string, excludePatterns []string) (*model, error) {
totalBytes, err := getPathSize(src) totalBytes, err := getPathSize(src, excludePatterns)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -148,11 +167,11 @@ func initialModel(src, dst string) (*model, error) {
} else { } else {
// Локальный файл // Локальный файл
if isDirectory { if isDirectory {
if filepath.Ext(targetPath) != ".tar.gz" { if !strings.HasSuffix(targetPath, ".tar.gz") {
targetPath += ".tar.gz" targetPath += ".tar.gz"
} }
} else { } else {
if filepath.Ext(targetPath) != ".gz" { if !strings.HasSuffix(targetPath, ".gz") {
targetPath += ".gz" targetPath += ".gz"
} }
} }
@@ -168,6 +187,7 @@ func initialModel(src, dst string) (*model, error) {
isDirectory: isDirectory, isDirectory: isDirectory,
httpTargetInfo: targetInfo, httpTargetInfo: targetInfo,
isP2PTarget: isP2P, isP2PTarget: isP2P,
excludePatterns: excludePatterns,
}, nil }, nil
} }
@@ -278,6 +298,34 @@ func (m *model) backupFileOrDisk(w io.Writer) error {
return err return err
} }
// Вспомогательная функция для проверки исключений
func shouldExcludePath(path string, patterns []string) bool {
cleanPath := filepath.Clean(path)
for _, pattern := range patterns {
// Проверка полного совпадения пути
if matched, _ := filepath.Match(pattern, cleanPath); matched {
return true
}
// Проверка, что паттерн является префиксом пути (как каталог)
if strings.HasPrefix(cleanPath, pattern+string(filepath.Separator)) {
return true
}
// Проверка базового имени файла/директории
base := filepath.Base(cleanPath)
if matched, _ := filepath.Match(pattern, base); matched {
return true
}
}
return false
}
func (m *model) shouldExclude(path string) bool {
return shouldExcludePath(path, m.excludePatterns)
}
func (m *model) backupDirectory(w io.Writer) error { func (m *model) backupDirectory(w io.Writer) error {
progressWriter := &progressTracker{Writer: w, progressCh: m.progressCh} progressWriter := &progressTracker{Writer: w, progressCh: m.progressCh}
gzipWriter := gzip.NewWriter(progressWriter) gzipWriter := gzip.NewWriter(progressWriter)
@@ -291,12 +339,21 @@ func (m *model) backupDirectory(w io.Writer) error {
return err return err
} }
header, err := tar.FileInfoHeader(fi, file) // Получаем относительный путь
relPath, err := filepath.Rel(m.source, file)
if err != nil { if err != nil {
return err return err
} }
relPath, err := filepath.Rel(m.source, file) if relPath != "." {
if m.shouldExclude(relPath) {
if fi.IsDir() {
return filepath.SkipDir
}
return nil // Пропускаем файл
}
}
header, err := tar.FileInfoHeader(fi, file)
if err != nil { if err != nil {
return err return err
} }
@@ -416,8 +473,27 @@ func (m *model) View() string {
func init() { func init() {
rootCmd.AddCommand(createCmd) rootCmd.AddCommand(createCmd)
createCmd.Flags().StringVarP(&source, "source", "s", "", "Source file, directory or disk (e.g., /dev/sda) (required)") createCmd.Flags().StringVarP(
createCmd.Flags().StringVarP(&target, "target", "t", "", "Target backup file or network address (e.g., /path/to/backup.tar.gz, 127.0.0.1:8080/backup.gz, or 127.0.0.1:8080) (required)") &source,
"source",
"s",
"",
"Source file, directory or disk (e.g., /dev/sda) (required)",
)
createCmd.Flags().StringVarP(
&target,
"target",
"t",
"",
"Target backup file or network address (e.g., /path/to/backup.tar.gz, 127.0.0.1:8080/backup.gz, or 127.0.0.1:8080) (required)",
)
createCmd.Flags().StringSliceVarP(
&excludePatterns,
"exclude",
"e",
[]string{},
"Patterns to exclude (can be specified multiple times)",
)
createCmd.MarkFlagRequired("source") createCmd.MarkFlagRequired("source")
createCmd.MarkFlagRequired("target") createCmd.MarkFlagRequired("target")
} }
+180
View File
@@ -0,0 +1,180 @@
package cmd
import (
// "backup/cmd"
"os"
"path/filepath"
"testing"
)
// Вспомогательная функция для создания временной структуры каталогов
func setupTestDir(t *testing.T) (string, func()) {
dir, err := os.MkdirTemp("", "backup_test")
if err != nil {
t.Fatal(err)
}
// Создаем тестовую структуру:
// /test_dir
// /subdir
// file2.txt
// file1.txt
// .hidden
// exclude_me.txt
subDir := filepath.Join(dir, "subdir")
if err := os.Mkdir(subDir, 0755); err != nil {
t.Fatal(err)
}
files := []string{
"file1.txt",
".hidden",
"exclude_me.txt",
filepath.Join("subdir", "file2.txt"),
}
for _, f := range files {
path := filepath.Join(dir, f)
if err := os.WriteFile(path, []byte("test"), 0644); err != nil {
t.Fatal(err)
}
}
return dir, func() { os.RemoveAll(dir) }
}
func TestGetPathSize(t *testing.T) {
t.Run("Directory size calculation", func(t *testing.T) {
dir, cleanup := setupTestDir(t)
defer cleanup()
size, err := getPathSize(dir, nil)
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
// Ожидаемый размер: 4 файла по 4 байта каждый = 16 байт
if size != 16 {
t.Errorf("Expected size 16, got %d", size)
}
})
t.Run("Exclusion patterns", func(t *testing.T) {
dir, cleanup := setupTestDir(t)
defer cleanup()
exclude := []string{"exclude_me.txt", "*.hidden", "subdir/*"}
size, err := getPathSize(dir, exclude)
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
// Должен остаться только file1.txt (4 байта)
if size != 4 {
t.Errorf("Expected size 4 after exclusions, got %d", size)
}
})
t.Run("Single file size", func(t *testing.T) {
dir, cleanup := setupTestDir(t)
defer cleanup()
file := filepath.Join(dir, "file1.txt")
size, err := getPathSize(file, nil)
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if size != 4 {
t.Errorf("Expected size 4, got %d", size)
}
})
}
func TestShouldExcludePath(t *testing.T) {
tests := []struct {
path string
patterns []string
expected bool
}{
// Точное совпадение
{"exclude.txt", []string{"exclude.txt"}, true},
{"dir/exclude.txt", []string{"exclude.txt"}, true},
// Префикс каталога
{"node_modules/file.js", []string{"node_modules"}, true},
{"src/node_modules/file.js", []string{"node_modules"}, false}, // Изменено ожидание
{"src/node_modules", []string{"node_modules"}, true},
// Glob-паттерны
{"temp.jpg", []string{"*.jpg"}, true},
{"backup.tmp", []string{"*.tmp"}, true},
{"image.png", []string{"*.jpg", "*.png"}, true},
{"document.pdf", []string{"*.doc"}, false},
// Базовое имя
{"deeply/nested/.env", []string{".env"}, true},
{"config/.env", []string{".*"}, true},
}
for i, test := range tests {
result := shouldExcludePath(test.path, test.patterns)
if result != test.expected {
t.Errorf("Test %d: For path '%s' and patterns %v expected %t, got %t",
i+1, test.path, test.patterns, test.expected, result)
}
}
}
func TestTargetNaming(t *testing.T) {
tests := []struct {
source string
target string
isDirectory bool
expected string
}{
{source: "test_dir", target: "backup", isDirectory: true, expected: "backup.tar.gz"},
{source: "test_file.txt", target: "backup_file", isDirectory: false, expected: "backup_file.gz"},
{source: "test_dir", target: "archive.tar.gz", isDirectory: true, expected: "archive.tar.gz"},
{source: "test_file.txt", target: "backup.gz", isDirectory: false, expected: "backup.gz"},
{
source: "test_dir",
target: "server:8000/test_dir",
isDirectory: true,
expected: "http://server:8000/backup/test_dir.tar.gz",
},
{
source: "test_file.txt",
target: "server:8000/file",
isDirectory: false,
expected: "http://server:8000/backup/file.gz",
},
{source: "test_dir", target: "192.168.1.100:8080", isDirectory: true, expected: "192.168.1.100:8080"},
{source: "test_file.txt", target: "10.0.0.5:9000", isDirectory: false, expected: "10.0.0.5:9000"},
}
for _, test := range tests {
// Создаем временную структуру для теста
var tempPath string
if test.isDirectory {
tempPath, _ = os.MkdirTemp("", "test_dir")
defer os.RemoveAll(tempPath)
} else {
tempFile, _ := os.CreateTemp("", "test_file.txt")
tempPath = tempFile.Name()
tempFile.Close()
defer os.Remove(tempPath)
}
model, err := initialModel(tempPath, test.target, nil)
if err != nil {
t.Fatalf("Error creating model: %v", err)
}
actual := model.target
if actual != test.expected {
t.Errorf("For source '%s' (dir:%t) and target '%s' expected '%s', got '%s'",
test.source, test.isDirectory, test.target, test.expected, actual)
}
}
}
+3 -1
View File
@@ -6,6 +6,8 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
var osExit = os.Exit
var rootCmd = &cobra.Command{ var rootCmd = &cobra.Command{
Use: "backup", Use: "backup",
Short: "Backup your data", Short: "Backup your data",
@@ -14,6 +16,6 @@ var rootCmd = &cobra.Command{
func Execute() { func Execute() {
if err := rootCmd.Execute(); err != nil { if err := rootCmd.Execute(); err != nil {
os.Exit(1) osExit(1)
} }
} }
+67
View File
@@ -0,0 +1,67 @@
package cmd
import (
"testing"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
)
// Вспомогательная структура для перехвата вызовов osExit
type exitCapture struct {
code int
called bool
}
func (e *exitCapture) capture(code int) {
e.code = code
e.called = true
}
func TestExecute_Success(t *testing.T) {
// Сохраняем оригинальный osExit
originalExit := osExit
defer func() { osExit = originalExit }()
// Настраиваем перехват
capture := &exitCapture{}
osExit = capture.capture
// Запускаем выполнение
Execute()
// Проверяем, что выход не вызывался
if capture.called {
t.Errorf("osExit called with code %d, expected no call", capture.code)
}
}
func TestExecute_Error(t *testing.T) {
originalExit := osExit
defer func() { osExit = originalExit }()
capture := &exitCapture{}
osExit = capture.capture
// Подменяем rootCmd на команду, возвращающую ошибку
originalCmd := rootCmd
defer func() { rootCmd = originalCmd }()
rootCmd = &cobra.Command{
Use: "error-cmd",
RunE: func(cmd *cobra.Command, args []string) error {
return assert.AnError // Имитация ошибки
},
}
// Вызываем Execute
Execute()
// Проверяем, что osExit вызван с кодом 1
if !capture.called {
t.Fatal("osExit not called")
}
if capture.code != 1 {
t.Errorf("expected exit code 1, got %d", capture.code)
}
}
+22
View File
@@ -1,6 +1,7 @@
package cmd package cmd
import ( import (
"encoding/json"
"fmt" "fmt"
"io" "io"
"log" "log"
@@ -37,6 +38,7 @@ var serveCmd = &cobra.Command{
r := mux.NewRouter() r := mux.NewRouter()
r.HandleFunc("/backup/{filename}", uploadHandler).Methods("POST") r.HandleFunc("/backup/{filename}", uploadHandler).Methods("POST")
r.HandleFunc("/backup/{filename}", downloadHandler).Methods("GET") r.HandleFunc("/backup/{filename}", downloadHandler).Methods("GET")
r.HandleFunc("/backup", listHandler).Methods("GET")
log.Printf("Starting server on %s", serveAddress) log.Printf("Starting server on %s", serveAddress)
log.Printf("Using backup directory: %s", serveDirectory) log.Printf("Using backup directory: %s", serveDirectory)
@@ -89,6 +91,26 @@ func downloadHandler(w http.ResponseWriter, r *http.Request) {
log.Printf("Downloaded %s", filename) log.Printf("Downloaded %s", filename)
} }
func listHandler(w http.ResponseWriter, r *http.Request) {
files, err := os.ReadDir(serveDirectory)
if err != nil {
http.Error(w, "Failed to read directory", http.StatusInternalServerError)
log.Printf("Error reading directory: %v", err)
return
}
var filenames []string
for _, file := range files {
if !file.IsDir() {
filenames = append(filenames, file.Name())
}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(filenames)
log.Printf("Listed files")
}
func init() { func init() {
rootCmd.AddCommand(serveCmd) rootCmd.AddCommand(serveCmd)
serveCmd.Flags().StringVarP(&serveAddress, "address", "a", "localhost:8080", "Address and port for the server") serveCmd.Flags().StringVarP(&serveAddress, "address", "a", "localhost:8080", "Address and port for the server")
+184
View File
@@ -0,0 +1,184 @@
package cmd
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"sort"
"testing"
"github.com/gorilla/mux"
)
func setupTest(t *testing.T) (string, func()) {
tempDir := t.TempDir()
oldDir := serveDirectory
serveDirectory = tempDir
return tempDir, func() {
serveDirectory = oldDir
}
}
func TestUploadHandler_Success(t *testing.T) {
tempDir, cleanup := setupTest(t)
defer cleanup()
r := mux.NewRouter()
r.HandleFunc("/backup/{filename}", uploadHandler).Methods("POST")
body := bytes.NewBufferString("test data")
req := httptest.NewRequest("POST", "/backup/testfile", body)
req = mux.SetURLVars(req, map[string]string{"filename": "testfile"})
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
filePath := filepath.Join(tempDir, "testfile")
data, err := os.ReadFile(filePath)
if err != nil {
t.Fatal("File not created:", err)
}
if string(data) != "test data" {
t.Errorf("Expected 'test data', got '%s'", string(data))
}
}
func TestUploadHandler_CreateFileError(t *testing.T) {
_, cleanup := setupTest(t)
defer cleanup()
// Создаем директорию без прав на запись
serveDirectory = "/proc/invalid_path"
r := mux.NewRouter()
r.HandleFunc("/backup/{filename}", uploadHandler).Methods("POST")
body := bytes.NewBufferString("test")
req := httptest.NewRequest("POST", "/backup/testfile", body)
req = mux.SetURLVars(req, map[string]string{"filename": "testfile"})
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("Expected status 500, got %d", w.Code)
}
}
func TestDownloadHandler_Success(t *testing.T) {
tempDir, cleanup := setupTest(t)
defer cleanup()
// Создаем тестовый файл
filePath := filepath.Join(tempDir, "testfile")
if err := os.WriteFile(filePath, []byte("test data"), 0644); err != nil {
t.Fatal(err)
}
r := mux.NewRouter()
r.HandleFunc("/backup/{filename}", downloadHandler).Methods("GET")
req := httptest.NewRequest("GET", "/backup/testfile", nil)
req = mux.SetURLVars(req, map[string]string{"filename": "testfile"})
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
if body := w.Body.String(); body != "test data" {
t.Errorf("Expected 'test data', got '%s'", body)
}
}
func TestDownloadHandler_NotFound(t *testing.T) {
_, cleanup := setupTest(t)
defer cleanup()
r := mux.NewRouter()
r.HandleFunc("/backup/{filename}", downloadHandler).Methods("GET")
req := httptest.NewRequest("GET", "/backup/missing", nil)
req = mux.SetURLVars(req, map[string]string{"filename": "missing"})
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("Expected status 404, got %d", w.Code)
}
}
func TestListHandler_Success(t *testing.T) {
tempDir, cleanup := setupTest(t)
defer cleanup()
// Создаем тестовые файлы
files := []string{"file1.txt", "file2.txt"}
for _, f := range files {
path := filepath.Join(tempDir, f)
if err := os.WriteFile(path, []byte("data"), 0644); err != nil {
t.Fatal(err)
}
}
r := mux.NewRouter()
r.HandleFunc("/backup", listHandler).Methods("GET")
req := httptest.NewRequest("GET", "/backup", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
var result []string
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
t.Fatal("Failed to parse JSON:", err)
}
sort.Strings(result)
expected := []string{"file1.txt", "file2.txt"}
sort.Strings(expected)
if len(result) != len(expected) {
t.Fatalf("Expected %d files, got %d", len(expected), len(result))
}
for i := range expected {
if expected[i] != result[i] {
t.Errorf("Expected %s, got %s", expected[i], result[i])
}
}
}
func TestListHandler_ReadDirError(t *testing.T) {
tempDir, cleanup := setupTest(t)
defer cleanup()
// Удаляем директорию чтобы вызвать ошибку
os.RemoveAll(tempDir)
r := mux.NewRouter()
r.HandleFunc("/backup", listHandler).Methods("GET")
req := httptest.NewRequest("GET", "/backup", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("Expected status 500, got %d", w.Code)
}
}
+10
View File
@@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"io" "io"
"net" "net"
"strconv"
"strings" "strings"
) )
@@ -47,6 +48,15 @@ func parseServerTarget(target string) (*ServerTargetInfo, bool) {
return nil, false // Invalid format return nil, false // Invalid format
} }
addressport := strings.SplitN(parts[0], ":", 2)
if len(addressport) != 2 {
return nil, false // Invalid format
}
if _, err := strconv.Atoi(addressport[1]); err != nil {
return nil, false // Invalid port
}
address := parts[0] address := parts[0]
filename := parts[1] filename := parts[1]
+229
View File
@@ -0,0 +1,229 @@
package cmd
import (
"bytes"
"io"
"testing"
"github.com/stretchr/testify/require"
)
func TestIsNetworkAddress(t *testing.T) {
tests := []struct {
name string
input string
want bool
}{
{"Valid IPv4 with port", "192.168.1.1:8080", true},
{"Valid hostname with port", "example.com:443", true},
{"IPv6 with port", "[::1]:8080", true},
{"Missing port", "127.0.0.1", false},
{"Port only", ":8080", true}, // технически валидный формат
{"Empty string", "", false},
{"Non-address string", "hello/world", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := isNetworkAddress(tt.input)
require.Equal(t, tt.want, got)
})
}
}
func TestFormatBytes(t *testing.T) {
tests := []struct {
size int64
expected string
}{
{500, "500 B"},
{1024, "1.0 KiB"},
{1500, "1.5 KiB"},
{1048576, "1.0 MiB"},
{1572864, "1.5 MiB"},
{1073741824, "1.0 GiB"},
{1610612736, "1.5 GiB"},
{1099511627776, "1.0 TiB"},
}
for _, test := range tests {
result := formatBytes(test.size)
if result != test.expected {
t.Errorf("For %d bytes expected '%s', got '%s'", test.size, test.expected, result)
}
}
}
func TestParseServerTarget(t *testing.T) {
tests := []struct {
name string
input string
wantInfo *ServerTargetInfo
wantSuccess bool
}{
{
name: "Valid address and filename",
input: "localhost:8080/backup.tar.gz",
wantInfo: &ServerTargetInfo{
URL: "http://localhost:8080/backup/backup.tar.gz",
Address: "localhost:8080",
Filename: "backup.tar.gz",
},
wantSuccess: true,
},
{
name: "Missing filename",
input: "127.0.0.1:8000/",
wantInfo: nil,
wantSuccess: false,
},
{
name: "Missing port",
input: "example.com/backup.gz",
wantInfo: nil,
wantSuccess: false,
},
{
name: "Invalid host:port",
input: "invalid_host:port/backup",
wantInfo: nil,
wantSuccess: false,
},
{
name: "Empty string",
input: "",
wantInfo: nil,
wantSuccess: false,
},
{
name: "No slash separator",
input: "localhost:8080",
wantInfo: nil,
wantSuccess: false,
},
{
name: "Multiple slashes",
input: "host:1234/path/to/backup.gz",
wantInfo: &ServerTargetInfo{
URL: "http://host:1234/backup/path/to/backup.gz",
Address: "host:1234",
Filename: "path/to/backup.gz",
},
wantSuccess: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotInfo, gotSuccess := parseServerTarget(tt.input)
require.Equal(t, tt.wantSuccess, gotSuccess)
if tt.wantSuccess {
require.Equal(t, tt.wantInfo, gotInfo)
} else {
require.Nil(t, gotInfo)
}
})
}
}
func TestProgressTracker(t *testing.T) {
t.Run("Write sends delta", func(t *testing.T) {
var buf bytes.Buffer
ch := make(chan int64, 10)
data := []byte("test data")
pt := &progressTracker{
Writer: &buf,
progressCh: ch,
}
n, err := pt.Write(data)
require.NoError(t, err)
require.Equal(t, len(data), n)
require.Equal(t, data, buf.Bytes())
require.Len(t, ch, 1)
require.Equal(t, int64(len(data)), <-ch)
})
t.Run("Read sends cumulative total", func(t *testing.T) {
data := []byte("sample data")
src := bytes.NewReader(data)
ch := make(chan int64, 10)
pt := &progressTracker{
Reader: src,
progressCh: ch,
}
// First read
buf1 := make([]byte, 6)
n1, err1 := pt.Read(buf1)
require.NoError(t, err1)
require.Equal(t, 6, n1)
// Second read
buf2 := make([]byte, 10)
n2, err2 := pt.Read(buf2)
require.NoError(t, err2)
require.Equal(t, 5, n2)
require.Len(t, ch, 2)
require.Equal(t, int64(6), <-ch)
require.Equal(t, int64(11), <-ch)
})
t.Run("Read handles EOF correctly", func(t *testing.T) {
src := bytes.NewReader([]byte{1, 2, 3})
ch := make(chan int64, 3)
pt := &progressTracker{
Reader: src,
progressCh: ch,
}
buf := make([]byte, 5)
// First read (3 bytes + no error)
n, err := pt.Read(buf)
require.NoError(t, err)
require.Equal(t, 3, n)
require.Equal(t, []byte{1, 2, 3}, buf[:n])
// Second read (0 bytes + EOF)
n, err = pt.Read(buf)
require.Equal(t, io.EOF, err)
require.Equal(t, 0, n)
// Only first read sends progress
require.Len(t, ch, 1)
require.Equal(t, int64(3), <-ch)
})
t.Run("Read with immediate EOF", func(t *testing.T) {
// Custom reader that returns 0 and EOF immediately
r := &eofReader{}
ch := make(chan int64, 1)
pt := &progressTracker{
Reader: r,
progressCh: ch,
}
buf := make([]byte, 5)
n, err := pt.Read(buf)
require.Equal(t, io.EOF, err)
require.Equal(t, 0, n)
// No progress should be sent
require.Len(t, ch, 0)
})
}
// eofReader всегда возвращает EOF
type eofReader struct{}
func (r *eofReader) Read(p []byte) (int, error) {
return 0, io.EOF
}
+4
View File
@@ -8,6 +8,7 @@ require (
github.com/charmbracelet/lipgloss v1.1.0 github.com/charmbracelet/lipgloss v1.1.0
github.com/gorilla/mux v1.8.1 github.com/gorilla/mux v1.8.1
github.com/spf13/cobra v1.9.1 github.com/spf13/cobra v1.9.1
github.com/stretchr/testify v1.10.0
golang.org/x/sys v0.30.0 golang.org/x/sys v0.30.0
) )
@@ -18,6 +19,7 @@ require (
github.com/charmbracelet/x/ansi v0.8.0 // indirect github.com/charmbracelet/x/ansi v0.8.0 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
@@ -27,9 +29,11 @@ require (
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect github.com/muesli/termenv v0.16.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.6 // indirect github.com/spf13/pflag v1.0.6 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sync v0.11.0 // indirect golang.org/x/sync v0.11.0 // indirect
golang.org/x/text v0.3.8 // indirect golang.org/x/text v0.3.8 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
) )
+3 -1
View File
@@ -2,6 +2,8 @@ package main
import "backup/cmd" import "backup/cmd"
var execute = cmd.Execute
func main() { func main() {
cmd.Execute() execute()
} }
+27
View File
@@ -0,0 +1,27 @@
package main
import (
"testing"
)
func TestMainFunction(t *testing.T) {
// Сохраняем оригинальную функцию
originalExecute := execute
defer func() {
execute = originalExecute // Восстанавливаем после теста
}()
// Флаг для проверки вызова
called := false
execute = func() {
called = true // Подменяем функцию
}
// Запускаем main
main()
// Проверяем, что функция была вызвана
if !called {
t.Fatal("Expected cmd.Execute to be called, but it wasn't")
}
}