Skip to content

Commit e38ef72

Browse files
committed
Add unit tests
1 parent d713834 commit e38ef72

File tree

2 files changed

+262
-7
lines changed

2 files changed

+262
-7
lines changed

pkg/linkcache/cache.go

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@ package linkcache
33
import (
44
"context"
55
"fmt"
6+
"maps"
67
"os"
78
"path/filepath"
89
"regexp"
10+
"slices"
911
"strings"
1012
"time"
1113

@@ -14,6 +16,23 @@ import (
1416

1517
var partitionNameRegex = regexp.MustCompile(`-part[0-9]+$`)
1618

19+
// fsInterface defines the filesystem operations needed by ListingCache
20+
type fsInterface interface {
21+
ReadDir(name string) ([]os.DirEntry, error)
22+
EvalSymlinks(path string) (string, error)
23+
}
24+
25+
// realFS implements fsInterface using the real filesystem
26+
type realFS struct{}
27+
28+
func (f *realFS) ReadDir(name string) ([]os.DirEntry, error) {
29+
return os.ReadDir(name)
30+
}
31+
32+
func (f *realFS) EvalSymlinks(path string) (string, error) {
33+
return filepath.EvalSymlinks(path)
34+
}
35+
1736
// ListingCache polls the filesystem at the specified directory once per
1837
// period and checks each non-directory entry for a symlink. The results are
1938
// cached. Changes to the cache are logged, as well as the full contents of the
@@ -23,13 +42,15 @@ type ListingCache struct {
2342
period time.Duration
2443
dir string
2544
links *linkCache
45+
fs fsInterface
2646
}
2747

2848
func NewListingCache(period time.Duration, dir string) *ListingCache {
2949
return &ListingCache{
3050
period: period,
3151
dir: dir,
3252
links: newLinkCache(),
53+
fs: &realFS{},
3354
}
3455
}
3556

@@ -67,7 +88,7 @@ func (l *ListingCache) Run(ctx context.Context) {
6788
func (l *ListingCache) listAndUpdate() error {
6889
visited := make(map[string]struct{})
6990

70-
entries, err := os.ReadDir(l.dir)
91+
entries, err := l.fs.ReadDir(l.dir)
7192
if err != nil {
7293
return fmt.Errorf("failed to read directory %s: %w", l.dir, err)
7394
}
@@ -90,7 +111,7 @@ func (l *ListingCache) listAndUpdate() error {
90111
// Otherwise, a broken symlink will lead us to remove it from the cache.
91112
visited[diskByIdPath] = struct{}{}
92113

93-
realFSPath, err := filepath.EvalSymlinks(diskByIdPath)
114+
realFSPath, err := l.fs.EvalSymlinks(diskByIdPath)
94115
if err != nil {
95116
errs = append(errs, fmt.Errorf("failed to evaluate symlink for %s: %w", diskByIdPath, err))
96117
l.links.BrokenSymlink(diskByIdPath)
@@ -153,11 +174,7 @@ func (d *linkCache) RemoveDevice(symlink string) {
153174
}
154175

155176
func (d *linkCache) DeviceIDs() []string {
156-
ids := make([]string, 0, len(d.devices))
157-
for id := range d.devices {
158-
ids = append(ids, id)
159-
}
160-
return ids
177+
return slices.Collect(maps.Keys(d.devices))
161178
}
162179

163180
func (d *linkCache) String() string {

pkg/linkcache/cache_test.go

Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
package linkcache
2+
3+
import (
4+
"os"
5+
"testing"
6+
"testing/fstest"
7+
)
8+
9+
const (
10+
// Test disk names in /dev/disk/by-id format
11+
gcpPersistentDiskID = "google-persistent-disk-0"
12+
gcpPVCID = "google-pvc-f5418f78-dc07-4d69-9487-6c4a7232dd67"
13+
gcpPersistentDiskPartitionID = "google-persistent-disk-0-part1"
14+
15+
// Test device paths in /dev format
16+
devicePathSDA = "/dev/sda"
17+
devicePathSDB = "/dev/sdb"
18+
)
19+
20+
// mockFS implements fsInterface for testing
21+
type mockFS struct {
22+
fstest.MapFS
23+
symlinks map[string]string
24+
}
25+
26+
func newMockFS() *mockFS {
27+
return &mockFS{
28+
MapFS: make(fstest.MapFS),
29+
symlinks: make(map[string]string),
30+
}
31+
}
32+
33+
func (m *mockFS) ReadDir(name string) ([]os.DirEntry, error) {
34+
entries, err := m.MapFS.ReadDir(name)
35+
if err != nil {
36+
return nil, err
37+
}
38+
return entries, nil
39+
}
40+
41+
func (m *mockFS) EvalSymlinks(path string) (string, error) {
42+
if target, ok := m.symlinks[path]; ok {
43+
return target, nil
44+
}
45+
return "", os.ErrNotExist
46+
}
47+
48+
func TestListAndUpdate(t *testing.T) {
49+
tests := []struct {
50+
name string
51+
setupFS func(*mockFS)
52+
expectedLinks map[string]string
53+
expectError bool
54+
}{
55+
{
56+
name: "valid symlinks",
57+
setupFS: func(m *mockFS) {
58+
// Create some device files
59+
m.MapFS[gcpPersistentDiskID] = &fstest.MapFile{}
60+
m.MapFS[gcpPVCID] = &fstest.MapFile{}
61+
// Create symlinks
62+
m.symlinks[gcpPersistentDiskID] = devicePathSDA
63+
m.symlinks[gcpPVCID] = devicePathSDB
64+
},
65+
expectedLinks: map[string]string{
66+
gcpPersistentDiskID: devicePathSDA,
67+
gcpPVCID: devicePathSDB,
68+
},
69+
expectError: false,
70+
},
71+
{
72+
name: "broken symlink not added to cache",
73+
setupFS: func(m *mockFS) {
74+
m.MapFS[gcpPersistentDiskID] = &fstest.MapFile{}
75+
// No symlink target for gcpPersistentDiskID
76+
},
77+
expectedLinks: map[string]string{},
78+
expectError: true,
79+
},
80+
{
81+
name: "partition files ignored",
82+
setupFS: func(m *mockFS) {
83+
m.MapFS[gcpPersistentDiskPartitionID] = &fstest.MapFile{}
84+
m.MapFS[gcpPersistentDiskID] = &fstest.MapFile{}
85+
m.symlinks[gcpPersistentDiskID] = devicePathSDA
86+
},
87+
expectedLinks: map[string]string{
88+
gcpPersistentDiskID: devicePathSDA,
89+
},
90+
expectError: false,
91+
},
92+
}
93+
94+
for _, tt := range tests {
95+
t.Run(tt.name, func(t *testing.T) {
96+
mock := newMockFS()
97+
tt.setupFS(mock)
98+
99+
cache := NewListingCache(0, ".")
100+
cache.fs = mock // Inject our mock filesystem
101+
err := cache.listAndUpdate()
102+
103+
if tt.expectError {
104+
if err == nil {
105+
t.Error("expected error but got none")
106+
}
107+
} else {
108+
if err != nil {
109+
t.Errorf("unexpected error: %v", err)
110+
}
111+
}
112+
113+
// Verify the cache contents
114+
for symlink, expectedTarget := range tt.expectedLinks {
115+
entry, exists := cache.links.devices[symlink]
116+
if !exists {
117+
t.Errorf("symlink %s should exist in cache", symlink)
118+
continue
119+
}
120+
if entry.path != expectedTarget {
121+
t.Errorf("symlink %s should point to %s, got %s", symlink, expectedTarget, entry.path)
122+
}
123+
if entry.brokenSymlink {
124+
t.Errorf("symlink %s should not be marked as broken", symlink)
125+
}
126+
}
127+
})
128+
}
129+
}
130+
131+
func TestListAndUpdateWithChanges(t *testing.T) {
132+
mock := newMockFS()
133+
cache := NewListingCache(0, ".")
134+
cache.fs = mock
135+
136+
// Initial state: one disk with a valid symlink
137+
mock.MapFS[gcpPersistentDiskID] = &fstest.MapFile{}
138+
mock.symlinks[gcpPersistentDiskID] = devicePathSDA
139+
140+
// First listAndUpdate should add the disk to cache
141+
err := cache.listAndUpdate()
142+
if err != nil {
143+
t.Fatalf("unexpected error in first listAndUpdate: %v", err)
144+
}
145+
146+
// Verify initial state
147+
entry, exists := cache.links.devices[gcpPersistentDiskID]
148+
if !exists {
149+
t.Fatal("gcpPersistentDiskID should exist in cache after first listAndUpdate")
150+
}
151+
if entry.path != devicePathSDA {
152+
t.Errorf("gcpPersistentDiskID should point to %s, got %s", devicePathSDA, entry.path)
153+
}
154+
155+
// Add a new disk and update the symlink target
156+
mock.MapFS[gcpPVCID] = &fstest.MapFile{}
157+
mock.symlinks[gcpPVCID] = devicePathSDB
158+
mock.symlinks[gcpPersistentDiskID] = devicePathSDB // Update existing disk's target
159+
160+
// Second listAndUpdate should update the cache
161+
err = cache.listAndUpdate()
162+
if err != nil {
163+
t.Fatalf("unexpected error in second listAndUpdate: %v", err)
164+
}
165+
166+
// Verify both disks are in cache with correct paths
167+
entry, exists = cache.links.devices[gcpPersistentDiskID]
168+
if !exists {
169+
t.Fatal("gcpPersistentDiskID should still exist in cache")
170+
}
171+
if entry.path != devicePathSDB {
172+
t.Errorf("gcpPersistentDiskID should now point to %s, got %s", devicePathSDB, entry.path)
173+
}
174+
175+
entry, exists = cache.links.devices[gcpPVCID]
176+
if !exists {
177+
t.Fatal("gcpPVCID should exist in cache after second listAndUpdate")
178+
}
179+
if entry.path != devicePathSDB {
180+
t.Errorf("gcpPVCID should point to %s, got %s", devicePathSDB, entry.path)
181+
}
182+
183+
// Break the symlink for gcpPersistentDiskID but keep the file
184+
delete(mock.symlinks, gcpPersistentDiskID)
185+
186+
// Third listAndUpdate should mark the disk as broken but keep its last known value
187+
err = cache.listAndUpdate()
188+
if err == nil {
189+
t.Error("expected error for broken symlink")
190+
}
191+
192+
// Verify gcpPersistentDiskID is marked as broken but maintains its last known value
193+
entry, exists = cache.links.devices[gcpPersistentDiskID]
194+
if !exists {
195+
t.Fatal("gcpPersistentDiskID should still exist in cache")
196+
}
197+
if entry.path != devicePathSDB {
198+
t.Errorf("gcpPersistentDiskID should maintain its last known value %s, got %s", devicePathSDB, entry.path)
199+
}
200+
if !entry.brokenSymlink {
201+
t.Error("gcpPersistentDiskID should be marked as broken")
202+
}
203+
204+
// Verify gcpPVCID is still valid
205+
entry, exists = cache.links.devices[gcpPVCID]
206+
if !exists {
207+
t.Fatal("gcpPVCID should still exist in cache")
208+
}
209+
if entry.path != devicePathSDB {
210+
t.Errorf("gcpPVCID should still point to %s, got %s", devicePathSDB, entry.path)
211+
}
212+
if entry.brokenSymlink {
213+
t.Error("gcpPVCID should not be marked as broken")
214+
}
215+
216+
// Remove one disk
217+
delete(mock.MapFS, gcpPersistentDiskID)
218+
delete(mock.symlinks, gcpPersistentDiskID)
219+
220+
// Fourth listAndUpdate should remove the deleted disk
221+
err = cache.listAndUpdate()
222+
if err != nil {
223+
t.Fatalf("unexpected error in fourth listAndUpdate: %v", err)
224+
}
225+
226+
// Verify only gcpPVCID remains
227+
if _, exists := cache.links.devices[gcpPersistentDiskID]; exists {
228+
t.Error("gcpPersistentDiskID should be removed from cache")
229+
}
230+
231+
entry, exists = cache.links.devices[gcpPVCID]
232+
if !exists {
233+
t.Fatal("gcpPVCID should still exist in cache")
234+
}
235+
if entry.path != devicePathSDB {
236+
t.Errorf("gcpPVCID should still point to %s, got %s", devicePathSDB, entry.path)
237+
}
238+
}

0 commit comments

Comments
 (0)