- commit
- 0d69224
- parent
- f8ad8c5
- author
- Eric Bower
- date
- 2025-12-18 20:36:11 -0500 EST
chore: add tests
2 files changed,
+238,
-1
+1,
-1
1@@ -148,7 +148,7 @@ func (h *handler) Remove(willReceive []*rsyncutils.ReceiverFile) error {
2 return rf.Name == entry.Name()
3 })
4
5- if !exists && entry.Name() != "._pico_keep_dir" {
6+ if !exists && path.Base(entry.Name()) != "._pico_keep_dir" {
7 toDelete = append(toDelete, entry.Name())
8 }
9 }
+237,
-0
1@@ -0,0 +1,237 @@
2+package rsync
3+
4+import (
5+ "bytes"
6+ "context"
7+ "io"
8+ "io/fs"
9+ "log/slog"
10+ "os"
11+ "testing"
12+ "time"
13+
14+ rsyncutils "github.com/picosh/go-rsync-receiver/utils"
15+ "github.com/picosh/pico/pkg/pssh"
16+ "github.com/picosh/pico/pkg/send/utils"
17+ "golang.org/x/crypto/ssh"
18+)
19+
20+// mockFileInfo implements fs.FileInfo for testing.
21+type mockFileInfo struct {
22+ name string
23+ size int64
24+ mode fs.FileMode
25+ modTime time.Time
26+ isDir bool
27+}
28+
29+func (m *mockFileInfo) Name() string { return m.name }
30+func (m *mockFileInfo) Size() int64 { return m.size }
31+func (m *mockFileInfo) Mode() fs.FileMode { return m.mode }
32+func (m *mockFileInfo) ModTime() time.Time { return m.modTime }
33+func (m *mockFileInfo) IsDir() bool { return m.isDir }
34+func (m *mockFileInfo) Sys() any { return nil }
35+
36+// mockWriteHandler implements utils.CopyFromClientHandler for testing.
37+// It records the order of Delete calls to verify deletion order.
38+type mockWriteHandler struct {
39+ entries []os.FileInfo
40+ deleteCalls []string
41+ deleteErrors map[string]error
42+}
43+
44+func (m *mockWriteHandler) Delete(_ *pssh.SSHServerConnSession, entry *utils.FileEntry) error {
45+ m.deleteCalls = append(m.deleteCalls, entry.Filepath)
46+ if m.deleteErrors != nil {
47+ if err, ok := m.deleteErrors[entry.Filepath]; ok {
48+ return err
49+ }
50+ }
51+ return nil
52+}
53+
54+func (m *mockWriteHandler) Write(_ *pssh.SSHServerConnSession, _ *utils.FileEntry) (string, error) {
55+ return "", nil
56+}
57+
58+func (m *mockWriteHandler) Read(_ *pssh.SSHServerConnSession, _ *utils.FileEntry) (os.FileInfo, utils.ReadAndReaderAtCloser, error) {
59+ return nil, nil, nil
60+}
61+
62+func (m *mockWriteHandler) List(_ *pssh.SSHServerConnSession, _ string, _ bool, _ bool) ([]os.FileInfo, error) {
63+ return m.entries, nil
64+}
65+
66+func (m *mockWriteHandler) GetLogger(_ *pssh.SSHServerConnSession) *slog.Logger {
67+ return slog.Default()
68+}
69+
70+func (m *mockWriteHandler) Validate(_ *pssh.SSHServerConnSession) error {
71+ return nil
72+}
73+
74+// mockChannel implements ssh.Channel for testing.
75+type mockChannel struct {
76+ stderr *bytes.Buffer
77+}
78+
79+func (m *mockChannel) Read(_ []byte) (int, error) { return 0, io.EOF }
80+func (m *mockChannel) Write(data []byte) (int, error) { return len(data), nil }
81+func (m *mockChannel) Close() error { return nil }
82+func (m *mockChannel) CloseWrite() error { return nil }
83+func (m *mockChannel) SendRequest(_ string, _ bool, _ []byte) (bool, error) {
84+ return true, nil
85+}
86+func (m *mockChannel) Stderr() io.ReadWriter { return m.stderr }
87+
88+var _ ssh.Channel = (*mockChannel)(nil)
89+
90+// newMockSession creates a mock SSHServerConnSession for testing.
91+func newMockSession() (*pssh.SSHServerConnSession, *bytes.Buffer) {
92+ stderr := &bytes.Buffer{}
93+ channel := &mockChannel{stderr: stderr}
94+
95+ ctx, cancel := context.WithCancel(context.Background())
96+ logger := slog.Default()
97+ server := pssh.NewSSHServer(ctx, logger, &pssh.SSHServerConfig{})
98+ serverConn := pssh.NewSSHServerConn(ctx, logger, &ssh.ServerConn{
99+ Permissions: &ssh.Permissions{
100+ Extensions: map[string]string{},
101+ },
102+ }, server)
103+
104+ session := &pssh.SSHServerConnSession{
105+ Channel: channel,
106+ SSHServerConn: serverConn,
107+ Ctx: ctx,
108+ CancelFunc: cancel,
109+ }
110+
111+ return session, stderr
112+}
113+
114+func TestRemove_DeletesChildrenBeforeParents(t *testing.T) {
115+ session, _ := newMockSession()
116+ mockHandler := &mockWriteHandler{
117+ entries: []os.FileInfo{
118+ &mockFileInfo{name: "a", isDir: true},
119+ &mockFileInfo{name: "a/file.txt", size: 100},
120+ &mockFileInfo{name: "b/c", isDir: true},
121+ &mockFileInfo{name: "b/c/deep.txt", size: 50},
122+ &mockFileInfo{name: "b", isDir: true},
123+ },
124+ }
125+
126+ h := &handler{
127+ session: session,
128+ writeHandler: mockHandler,
129+ root: "test",
130+ }
131+
132+ err := h.Remove([]*rsyncutils.ReceiverFile{})
133+ if err != nil {
134+ t.Fatalf("Remove() returned error: %v", err)
135+ }
136+
137+ if len(mockHandler.deleteCalls) != 5 {
138+ t.Fatalf("expected 5 delete calls, got %d: %v", len(mockHandler.deleteCalls), mockHandler.deleteCalls)
139+ }
140+
141+ indexOfA := -1
142+ indexOfAFile := -1
143+ indexOfB := -1
144+ indexOfBC := -1
145+ indexOfBCDeep := -1
146+
147+ for i, path := range mockHandler.deleteCalls {
148+ switch path {
149+ case "/test/a":
150+ indexOfA = i
151+ case "/test/a/file.txt":
152+ indexOfAFile = i
153+ case "/test/b":
154+ indexOfB = i
155+ case "/test/b/c":
156+ indexOfBC = i
157+ case "/test/b/c/deep.txt":
158+ indexOfBCDeep = i
159+ }
160+ }
161+
162+ if indexOfAFile > indexOfA {
163+ t.Errorf("a/file.txt (index %d) should be deleted before a (index %d)", indexOfAFile, indexOfA)
164+ }
165+ if indexOfBCDeep > indexOfBC {
166+ t.Errorf("b/c/deep.txt (index %d) should be deleted before b/c (index %d)", indexOfBCDeep, indexOfBC)
167+ }
168+ if indexOfBC > indexOfB {
169+ t.Errorf("b/c (index %d) should be deleted before b (index %d)", indexOfBC, indexOfB)
170+ }
171+}
172+
173+func TestRemove_IgnoresPicoKeepDir(t *testing.T) {
174+ session, _ := newMockSession()
175+ mockHandler := &mockWriteHandler{
176+ entries: []os.FileInfo{
177+ &mockFileInfo{name: "dir", isDir: true},
178+ &mockFileInfo{name: "dir/._pico_keep_dir", size: 0},
179+ &mockFileInfo{name: "dir/file.txt", size: 100},
180+ },
181+ }
182+
183+ h := &handler{
184+ session: session,
185+ writeHandler: mockHandler,
186+ root: "test",
187+ }
188+
189+ err := h.Remove([]*rsyncutils.ReceiverFile{})
190+ if err != nil {
191+ t.Fatalf("Remove() returned error: %v", err)
192+ }
193+
194+ for _, path := range mockHandler.deleteCalls {
195+ if path == "/test/dir/._pico_keep_dir" {
196+ t.Error("._pico_keep_dir should not be in delete list")
197+ }
198+ }
199+
200+ if len(mockHandler.deleteCalls) != 2 {
201+ t.Errorf("expected 2 delete calls (dir, dir/file.txt), got %d: %v", len(mockHandler.deleteCalls), mockHandler.deleteCalls)
202+ }
203+}
204+
205+func TestRemove_OnlyDeletesFilesNotInWillReceive(t *testing.T) {
206+ session, _ := newMockSession()
207+ mockHandler := &mockWriteHandler{
208+ entries: []os.FileInfo{
209+ &mockFileInfo{name: "a.txt", size: 100},
210+ &mockFileInfo{name: "b.txt", size: 100},
211+ &mockFileInfo{name: "c.txt", size: 100},
212+ },
213+ }
214+
215+ h := &handler{
216+ session: session,
217+ writeHandler: mockHandler,
218+ root: "test",
219+ }
220+
221+ willReceive := []*rsyncutils.ReceiverFile{
222+ {Name: "a.txt"},
223+ {Name: "c.txt"},
224+ }
225+
226+ err := h.Remove(willReceive)
227+ if err != nil {
228+ t.Fatalf("Remove() returned error: %v", err)
229+ }
230+
231+ if len(mockHandler.deleteCalls) != 1 {
232+ t.Fatalf("expected 1 delete call, got %d: %v", len(mockHandler.deleteCalls), mockHandler.deleteCalls)
233+ }
234+
235+ if mockHandler.deleteCalls[0] != "/test/b.txt" {
236+ t.Errorf("expected to delete /test/b.txt, got %s", mockHandler.deleteCalls[0])
237+ }
238+}