| 1 | package engine |
| 2 | |
| 3 | import ( |
| 4 | "os" |
| 5 | "path/filepath" |
| 6 | "sync" |
| 7 | "testing" |
| 8 | "testing/fstest" |
| 9 | ) |
| 10 | |
| 11 | func newTestFS() fstest.MapFS { |
| 12 | return fstest.MapFS{ |
| 13 | "last30days.py": &fstest.MapFile{Data: []byte("# last30days entry\n"), Mode: 0o644}, |
| 14 | "lib/__init__.py": &fstest.MapFile{Data: []byte(""), Mode: 0o644}, |
| 15 | "lib/env.py": &fstest.MapFile{Data: []byte("# env helpers\n"), Mode: 0o644}, |
| 16 | } |
| 17 | } |
| 18 | |
| 19 | func TestEnsureExtractsEngine(t *testing.T) { |
| 20 | src := newTestFS() |
| 21 | base := t.TempDir() |
| 22 | |
| 23 | cacheDir, err := Ensure(src, base, "v1") |
| 24 | if err != nil { |
| 25 | t.Fatalf("Ensure: %v", err) |
| 26 | } |
| 27 | if cacheDir != filepath.Join(base, cacheSubdir, "v1") { |
| 28 | t.Fatalf("cacheDir = %q, want %q", cacheDir, filepath.Join(base, cacheSubdir, "v1")) |
| 29 | } |
| 30 | mustReadFile(t, filepath.Join(cacheDir, "last30days.py"), "# last30days entry\n") |
| 31 | mustReadFile(t, filepath.Join(cacheDir, "lib/env.py"), "# env helpers\n") |
| 32 | mustReadFile(t, filepath.Join(cacheDir, SentinelFilename), "v1") |
| 33 | } |
| 34 | |
| 35 | func TestEnsureSkipsWhenSentinelMatches(t *testing.T) { |
| 36 | src := newTestFS() |
| 37 | base := t.TempDir() |
| 38 | |
| 39 | cacheDir, err := Ensure(src, base, "v1") |
| 40 | if err != nil { |
| 41 | t.Fatalf("first Ensure: %v", err) |
| 42 | } |
| 43 | target := filepath.Join(cacheDir, "last30days.py") |
| 44 | info1, err := os.Stat(target) |
| 45 | if err != nil { |
| 46 | t.Fatalf("stat: %v", err) |
| 47 | } |
| 48 | |
| 49 | // Reset the sync.Once so a second call would re-extract if not for the |
| 50 | // sentinel short-circuit. Without the reset, sync.Once would skip the |
| 51 | // extraction regardless of sentinel state. |
| 52 | resetOnce(cacheDir) |
| 53 | |
| 54 | if _, err := Ensure(src, base, "v1"); err != nil { |
| 55 | t.Fatalf("second Ensure: %v", err) |
| 56 | } |
| 57 | info2, err := os.Stat(target) |
| 58 | if err != nil { |
| 59 | t.Fatalf("stat second: %v", err) |
| 60 | } |
| 61 | if !info2.ModTime().Equal(info1.ModTime()) { |
| 62 | t.Fatalf("expected file untouched on sentinel match; got mtime %v -> %v", info1.ModTime(), info2.ModTime()) |
| 63 | } |
| 64 | } |
| 65 | |
| 66 | func TestEnsureReExtractsOnVersionChange(t *testing.T) { |
| 67 | v1 := fstest.MapFS{ |
| 68 | "last30days.py": &fstest.MapFile{Data: []byte("v1\n"), Mode: 0o644}, |
| 69 | } |
| 70 | v2 := fstest.MapFS{ |
| 71 | "last30days.py": &fstest.MapFile{Data: []byte("v2\n"), Mode: 0o644}, |
| 72 | } |
| 73 | base := t.TempDir() |
| 74 | |
| 75 | cache1, err := Ensure(v1, base, "v1") |
| 76 | if err != nil { |
| 77 | t.Fatalf("Ensure v1: %v", err) |
| 78 | } |
| 79 | cache2, err := Ensure(v2, base, "v2") |
| 80 | if err != nil { |
| 81 | t.Fatalf("Ensure v2: %v", err) |
| 82 | } |
| 83 | if cache1 == cache2 { |
| 84 | t.Fatalf("expected distinct cache dirs per version, got %q == %q", cache1, cache2) |
| 85 | } |
| 86 | mustReadFile(t, filepath.Join(cache1, "last30days.py"), "v1\n") |
| 87 | mustReadFile(t, filepath.Join(cache2, "last30days.py"), "v2\n") |
| 88 | } |
| 89 | |
| 90 | func TestEnsureConcurrentFirstCall(t *testing.T) { |
| 91 | src := newTestFS() |
| 92 | base := t.TempDir() |
| 93 | |
| 94 | const goroutines = 10 |
| 95 | var wg sync.WaitGroup |
| 96 | wg.Add(goroutines) |
| 97 | results := make([]string, goroutines) |
| 98 | errs := make([]error, goroutines) |
| 99 | for i := 0; i < goroutines; i++ { |
| 100 | i := i |
| 101 | go func() { |
| 102 | defer wg.Done() |
| 103 | results[i], errs[i] = Ensure(src, base, "v1") |
| 104 | }() |
| 105 | } |
| 106 | wg.Wait() |
| 107 | |
| 108 | for i, err := range errs { |
| 109 | if err != nil { |
| 110 | t.Fatalf("goroutine %d: %v", i, err) |
| 111 | } |
| 112 | } |
| 113 | for i := 1; i < goroutines; i++ { |
| 114 | if results[i] != results[0] { |
| 115 | t.Fatalf("goroutine 0 saw %q, goroutine %d saw %q", results[0], i, results[i]) |
| 116 | } |
| 117 | } |
| 118 | mustReadFile(t, filepath.Join(results[0], "last30days.py"), "# last30days entry\n") |
| 119 | } |
| 120 | |
| 121 | func TestEnsureRejectsEmptyVersion(t *testing.T) { |
| 122 | if _, err := Ensure(newTestFS(), t.TempDir(), ""); err == nil { |
| 123 | t.Fatal("expected error for empty version") |
| 124 | } |
| 125 | } |
| 126 | |
| 127 | func TestEnsureReturnsErrorWhenCacheUnwritable(t *testing.T) { |
| 128 | // Place the cache root at a path that cannot exist (a regular file). |
| 129 | // MkdirAll will refuse and Ensure must surface a wrapped error. |
| 130 | base := t.TempDir() |
| 131 | blocker := filepath.Join(base, "blocker") |
| 132 | if err := os.WriteFile(blocker, []byte("not a dir"), 0o644); err != nil { |
| 133 | t.Fatalf("setup: %v", err) |
| 134 | } |
| 135 | |
| 136 | _, err := Ensure(newTestFS(), blocker, "v1") |
| 137 | if err == nil { |
| 138 | t.Fatal("expected error when cache parent is not a directory") |
| 139 | } |
| 140 | } |
| 141 | |
| 142 | func TestEnsureUserCacheHonorsOverride(t *testing.T) { |
| 143 | override := t.TempDir() |
| 144 | t.Setenv(CacheEnvOverride, override) |
| 145 | |
| 146 | src := newTestFS() |
| 147 | cacheDir, err := EnsureUserCache(src, "v1") |
| 148 | if err != nil { |
| 149 | t.Fatalf("EnsureUserCache: %v", err) |
| 150 | } |
| 151 | want := filepath.Join(override, cacheSubdir, "v1") |
| 152 | if cacheDir != want { |
| 153 | t.Fatalf("cacheDir = %q, want %q", cacheDir, want) |
| 154 | } |
| 155 | mustReadFile(t, filepath.Join(cacheDir, "last30days.py"), "# last30days entry\n") |
| 156 | } |
| 157 | |
| 158 | func mustReadFile(t *testing.T, path, want string) { |
| 159 | t.Helper() |
| 160 | data, err := os.ReadFile(path) |
| 161 | if err != nil { |
| 162 | t.Fatalf("read %s: %v", path, err) |
| 163 | } |
| 164 | if string(data) != want { |
| 165 | t.Fatalf("%s: got %q, want %q", path, string(data), want) |
| 166 | } |
| 167 | } |
| 168 |