Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
name: test

# 每次 push / PR 自动跑静态检查与单元测试,保证回归网始终被执行。
# 单测零外部依赖(纯函数 + mock + t.TempDir),无需凭证与容器。
on:
push:
branches: ['*']
pull_request:

permissions:
contents: read

jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
go-version: [stable, oldstable]
steps:
- uses: actions/checkout@v7

- uses: actions/setup-go@v7
with:
go-version: ${{ matrix.go-version }}

- name: Vet
run: go vet ./...

- name: Unit tests
run: go test ./...
20 changes: 20 additions & 0 deletions cmd/checksum_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package cmd

import "testing"

func TestNewHasher(t *testing.T) {
md5h, err := newHasher("md5")
if err != nil || md5h == nil {
t.Errorf("md5 应可用,err=%v", err)
}
sha, err := newHasher("sha256")
if err != nil || sha == nil {
t.Errorf("sha256 应可用,err=%v", err)
}
if _, err := newHasher("crc32"); err == nil {
t.Errorf("非法算法应报错")
}
if _, err := newHasher(""); err == nil {
t.Errorf("空算法应报错")
}
}
30 changes: 30 additions & 0 deletions cmd/cp_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package cmd

import (
"testing"

"github.com/BeCrafter/sail/internal/s3path"
)

// TestDeriveDstKey 校验 cp/mv 目标 S3 key 推导:
// dst.Key 为空或尾 / 表示"进目录",追加 srcBase;否则用 dst.Key。
func TestDeriveDstKey(t *testing.T) {
cases := []struct {
name string
srcBase string
dst s3path.S3Path
want string
}{
{name: "dst 空 key 进目录", srcBase: "a.txt", dst: s3path.S3Path{Bucket: "b"}, want: "a.txt"},
{name: "dst 尾斜杠进目录", srcBase: "a.txt", dst: s3path.S3Path{Bucket: "b", Key: "dir/"}, want: "dir/a.txt"},
{name: "dst 指定完整 key", srcBase: "a.txt", dst: s3path.S3Path{Bucket: "b", Key: "x/y.txt"}, want: "x/y.txt"},
{name: "dst 尾斜杠进子目录", srcBase: "sub/b.log", dst: s3path.S3Path{Bucket: "b", Key: "logs/"}, want: "logs/sub/b.log"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := deriveDstKey(c.srcBase, &c.dst); got != c.want {
t.Errorf("deriveDstKey(%q,%v) = %q,期望 %q", c.srcBase, c.dst, got, c.want)
}
})
}
}
19 changes: 19 additions & 0 deletions cmd/du_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package cmd

import "testing"

func TestDisplayRoot(t *testing.T) {
cases := []struct {
bucket, base string
want string
}{
{"bucket", "", "s3://bucket"},
{"bucket", "logs", "s3://bucket/logs"},
{"bucket", "logs/2026", "s3://bucket/logs/2026"},
}
for _, c := range cases {
if got := displayRoot(c.bucket, c.base); got != c.want {
t.Errorf("displayRoot(%q,%q) = %q,期望 %q", c.bucket, c.base, got, c.want)
}
}
}
110 changes: 110 additions & 0 deletions cmd/find_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package cmd
import (
"testing"
"time"

"github.com/aws/aws-sdk-go-v2/service/s3/types"
)

func TestParseSizeSpec(t *testing.T) {
Expand Down Expand Up @@ -67,3 +69,111 @@ func TestParseTimeArg(t *testing.T) {
t.Errorf("parseTimeArg(2026/01/02) 期望报错")
}
}

func strPtr(s string) *string { return &s }

func sizePtr(n int64) *int64 { return &n }

func timePtr(t time.Time) *time.Time { return &t }

// findObj 构造一个 types.Object 用于 matchFind。
func findObj(size int64, mod time.Time) types.Object {
o := types.Object{Key: strPtr("unused")}
if size >= 0 {
o.Size = sizePtr(size)
}
if !mod.IsZero() {
o.LastModified = timePtr(mod)
}
return o
}

// TestMatchFind 校验 find 四类过滤的组合匹配。
func TestMatchFind(t *testing.T) {
base := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
old := findObj(100, base.Add(-24*time.Hour)) // 对象本身尺寸/时间
new := findObj(500, base.Add(24*time.Hour))

reset := func() {
findMaxDepth = 0
findNames = nil
findSize = ""
findNewer = ""
}
reset()
defer reset()

t.Run("无过滤全命中", func(t *testing.T) {
if !matchFind(old, "logs/a.log", "", nil, time.Time{}) {
t.Errorf("无过滤应命中")
}
})

t.Run("name 通配按 basename", func(t *testing.T) {
findNames = []string{"*.log"}
defer reset()
if !matchFind(old, "logs/a.log", "", nil, time.Time{}) {
t.Errorf("*.log 应命中 a.log")
}
if matchFind(old, "logs/a.txt", "", nil, time.Time{}) {
t.Errorf("*.log 不应命中 a.txt")
}
})

t.Run("size 精确/大于/小于", func(t *testing.T) {
cases := []struct {
spec string
obj types.Object
want bool
}{
{"100", old, true},
{"101", old, false},
{"+200", new, true},
{"+200", old, false},
{"-200", old, true},
{"-200", new, false},
}
for _, c := range cases {
spec, err := parseSizeSpec(c.spec)
if err != nil {
t.Fatalf("parseSizeSpec(%q): %v", c.spec, err)
}
if got := matchFind(c.obj, "a.log", "", spec, time.Time{}); got != c.want {
t.Errorf("size %q 对 obj(size=%d) = %v,期望 %v", c.spec, *c.obj.Size, got, c.want)
}
}
})

t.Run("size 过滤中 Size 为 nil 视为 0", func(t *testing.T) {
noSize := types.Object{Key: strPtr("x")}
if !matchFind(noSize, "x", "", []int64{'=', 0}, time.Time{}) {
t.Errorf("Size nil 应视为 0,=0 应命中")
}
})

t.Run("newer 过滤", func(t *testing.T) {
if !matchFind(new, "a.log", "", nil, base) {
t.Errorf("对象晚于 newer 应命中")
}
if matchFind(old, "a.log", "", nil, base) {
t.Errorf("对象早于 newer 应被拒")
}
noMod := types.Object{Key: strPtr("x")} // LastModified nil
if matchFind(noMod, "x", "", nil, base) {
t.Errorf("LastModified nil 且 newer 非零应被拒")
}
})

t.Run("max-depth 过滤", func(t *testing.T) {
findMaxDepth = 2
defer reset()
// base="logs",rel 层级数
obj := findObj(1, base)
if matchFind(obj, "logs/a/b/c.txt", "logs", nil, time.Time{}) {
t.Errorf("深度 ≥2 应被拒")
}
if !matchFind(obj, "logs/a.txt", "logs", nil, time.Time{}) {
t.Errorf("深度 0 应命中")
}
})
}
52 changes: 52 additions & 0 deletions cmd/head_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package cmd

import (
"io"
"os"
"strings"
"testing"
)

// captureStdout 在 fn 执行期间重定向 os.Stdout,返回其输出。
func captureStdout(t *testing.T, fn func()) string {
t.Helper()
old := os.Stdout
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("os.Pipe: %v", err)
}
os.Stdout = w
defer func() { os.Stdout = old }()
fn()
w.Close()
out, _ := io.ReadAll(r)
return string(out)
}

func TestHeadLinesFromReader(t *testing.T) {
cases := []struct {
name string
in string
n int64
want string
}{
{name: "n=0 无输出", in: "a\nb\n", n: 0, want: ""},
{name: "前 2 行", in: "1\n2\n3\n", n: 2, want: "1\n2\n"},
{name: "n 超实际行数", in: "1\n2\n", n: 10, want: "1\n2\n"},
{name: "末行无换行也输出", in: "1\n2", n: 10, want: "1\n2"},
{name: "空输入", in: "", n: 10, want: ""},
{name: "恰一行无换行 n=1", in: "solo", n: 1, want: "solo"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := captureStdout(t, func() {
if err := headLinesFromReader(strings.NewReader(c.in), c.n); err != nil {
t.Errorf("headLinesFromReader 报错: %v", err)
}
})
if got != c.want {
t.Errorf("headLinesFromReader(%q,%d) = %q,期望 %q", c.in, c.n, got, c.want)
}
})
}
}
32 changes: 32 additions & 0 deletions cmd/ls_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package cmd

import (
"testing"
"time"

"github.com/aws/aws-sdk-go-v2/service/s3/types"
)

func TestObjSize(t *testing.T) {
var o types.Object
if got := objSize(o); got != 0 {
t.Errorf("Size nil 应返回 0,got %d", got)
}
n := int64(42)
o.Size = &n
if got := objSize(o); got != 42 {
t.Errorf("Size=42 应返回 42,got %d", got)
}
}

func TestObjTime(t *testing.T) {
var o types.Object
if got := objTime(o); !got.IsZero() {
t.Errorf("LastModified nil 应返回零值,got %v", got)
}
ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
o.LastModified = &ts
if got := objTime(o); !got.Equal(ts) {
t.Errorf("objTime = %v,期望 %v", got, ts)
}
}
76 changes: 76 additions & 0 deletions cmd/root_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package cmd

import (
"testing"

"github.com/BeCrafter/sail/internal/config"
)

func TestParseS3DefaultBucket(t *testing.T) {
r := &config.Resolved{Bucket: "defbucket"}

// 空桶段填充默认桶
p, err := parseS3("s3:///logs/a.txt", r)
if err != nil {
t.Fatalf("parseS3 报错: %v", err)
}
if p.Bucket != "defbucket" || p.Key != "logs/a.txt" {
t.Errorf("空桶段应填默认桶,got %+v", p)
}

// 显式桶不覆盖
p, err = parseS3("s3://real/a.txt", r)
if err != nil {
t.Fatalf("parseS3 报错: %v", err)
}
if p.Bucket != "real" {
t.Errorf("显式桶被覆盖,got %q", p.Bucket)
}

// r 为 nil 且空桶段 -> 报错
if _, err := parseS3("s3:///a.txt", nil); err == nil {
t.Errorf("无默认桶时应报错")
}

// r.Bucket 为空且空桶段 -> 报错
if _, err := parseS3("s3:///a.txt", &config.Resolved{}); err == nil {
t.Errorf("默认桶为空时应报错")
}
}

func TestLangFlagFromArgs(t *testing.T) {
cases := []struct {
args []string
want string
}{
{[]string{"--lang", "zh", "ls"}, "zh"},
{[]string{"--lang=zh"}, "zh"},
{[]string{"ls", "--lang", "en"}, "en"},
{[]string{"ls"}, ""},
{[]string{"--lang"}, ""}, // 缺值
{[]string{"x", "--lang", "zh", "--help"}, "zh"},
}
for _, c := range cases {
if got := langFlagFromArgs(c.args); got != c.want {
t.Errorf("langFlagFromArgs(%v) = %q,期望 %q", c.args, got, c.want)
}
}
}

func TestConfigFlagFromArgs(t *testing.T) {
cases := []struct {
args []string
want string
}{
{[]string{"-c", "/tmp/x.yaml", "ls"}, "/tmp/x.yaml"},
{[]string{"--config", "/a/b.yaml"}, "/a/b.yaml"},
{[]string{"--config=/a/b.yaml"}, "/a/b.yaml"},
{[]string{"ls"}, ""},
{[]string{"--config"}, ""},
}
for _, c := range cases {
if got := configFlagFromArgs(c.args); got != c.want {
t.Errorf("configFlagFromArgs(%v) = %q,期望 %q", c.args, got, c.want)
}
}
}
Loading