Skip to content

Commit 1cc8851

Browse files
authored
test(fscache): add filesystem limits tests and benchmarks (#18)
Add comprehensive testing for long URL handling to validate fragmentation behavior.
2 parents f14f6a8 + 4b8d5a8 commit 1cc8851

7 files changed

Lines changed: 172 additions & 37 deletions

File tree

.github/workflows/default.yml

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@ defaults:
1111
jobs:
1212
test:
1313
name: Test
14-
runs-on: ubuntu-latest
14+
strategy:
15+
matrix:
16+
os: [ubuntu-latest, windows-latest, macos-latest]
17+
runs-on: ${{ matrix.os }}
1518
env:
1619
OUTPUTDIR: coverage
1720
COVERPROFILE: coverage.out
@@ -28,11 +31,11 @@ jobs:
2831
run: make test
2932

3033
- name: Upload coverage reports to Codecov
31-
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' }}
34+
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' && matrix.os == 'ubuntu-latest' }}
3235
uses: codecov/codecov-action@0565863a31f2c772f9f0395002a31e3f06189574 # v5.4.0
3336
with:
3437
token: ${{ secrets.CODECOV_TOKEN }}
3538
files: ${{ env.OUTPUTDIR }}/${{ env.COVERPROFILE }}
3639
flags: unittests
3740
disable_search: true
38-
verbose: true
41+
verbose: true

store/acceptance/benchmark.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ package acceptance
55

66
import (
77
"errors"
8+
"strings"
89
"testing"
910

1011
"github.com/bartventer/httpcache/store/driver"
@@ -14,7 +15,7 @@ import (
1415
func RunB(b *testing.B, factory Factory) {
1516
b.Helper()
1617

17-
key := "benchmark_key"
18+
key := "benchmark_key" + strings.Repeat("x", 100)
1819
value := []byte("benchmark_value")
1920
b.Run("Get", func(b *testing.B) { benchmarkGet(b, factory.Make, key) })
2021
b.Run("Set", func(b *testing.B) { benchmarkSet(b, factory.Make, key, value) })

store/fscache/benchmark_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import (
1212

1313
func BenchmarkFSCache(b *testing.B) {
1414
acceptance.RunB(b, acceptance.FactoryFunc(func() (driver.Conn, func()) {
15-
u := makeRoot(b)
15+
u := makeRootURL(b)
1616
cache, err := fromURL(u)
1717
if err != nil {
1818
b.Fatalf("Failed to create fscache: %v", err)

store/fscache/filenamer_test.go

Lines changed: 0 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -16,30 +16,13 @@ package fscache
1616

1717
import (
1818
"encoding/base64"
19-
"fmt"
2019
"path/filepath"
2120
"strings"
2221
"testing"
2322

2423
"github.com/bartventer/httpcache/internal/testutil"
2524
)
2625

27-
func Example_fragmentFileName_short() {
28-
url := "https://short.url/test"
29-
path := fragmentFileName(url)
30-
fmt.Println("Fragmented path:", path)
31-
// Output:
32-
// Fragmented path: aHR0cHM6Ly9zaG9ydC51cmwvdGVzdA
33-
}
34-
35-
func Example_fragmentFileName_long() {
36-
url := "https://example.com/" + strings.Repeat("a", 255)
37-
path := fragmentFileName(url)
38-
fmt.Println("Fragmented path:", path)
39-
// Output:
40-
// Fragmented path: aHR0cHM6Ly9leGFtcGxlLmNvbS9hYWFhYWFhYWFhYWFhYWFh/YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFh/YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFh/YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFh/YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFh/YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFh/YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFh/YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWE
41-
}
42-
4326
func Test_fragmentFileName_fragmentedFileNameToKey(t *testing.T) {
4427
cases := []struct {
4528
name string

store/fscache/fscache.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,9 @@ func WithEncryption(key string) Option {
170170
// WithBaseDir sets the base directory for the cache; default: user's OS cache directory.
171171
func WithBaseDir(base string) Option {
172172
return optionFunc(func(c *fsCache) error {
173-
c.base = base
173+
if base != "" {
174+
c.base = base
175+
}
174176
return nil
175177
})
176178
}

store/fscache/fscache_test.go

Lines changed: 31 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ import (
2020
"io"
2121
"io/fs"
2222
"net/url"
23+
"path/filepath"
24+
"runtime"
2325
"testing"
2426
"time"
2527

@@ -30,9 +32,10 @@ import (
3032

3133
func (c *fsCache) Close() error { return c.root.Close() }
3234

33-
func makeRoot(t testing.TB) *url.URL {
35+
func makeRootURL(t testing.TB) *url.URL {
3436
t.Helper()
35-
u, err := url.Parse("fscache://" + t.TempDir() + "?appname=testapp")
37+
tempDir := filepath.ToSlash(t.TempDir())
38+
u, err := url.Parse("fscache://" + tempDir + "?appname=testapp")
3639
if err != nil {
3740
t.Fatalf("Failed to parse cache URL: %v", err)
3841
}
@@ -41,7 +44,7 @@ func makeRoot(t testing.TB) *url.URL {
4144

4245
func TestFSCache_Acceptance(t *testing.T) {
4346
acceptance.Run(t, acceptance.FactoryFunc(func() (driver.Conn, func()) {
44-
u := makeRoot(t)
47+
u := makeRootURL(t)
4548
cache, err := fromURL(u)
4649
testutil.RequireNoError(t, err, "Failed to create fscache")
4750
cleanup := func() { cache.Close() }
@@ -50,7 +53,7 @@ func TestFSCache_Acceptance(t *testing.T) {
5053
}
5154

5255
func Test_fsCache_SetError(t *testing.T) {
53-
u := makeRoot(t)
56+
u := makeRootURL(t)
5457
cache, err := fromURL(u)
5558
testutil.RequireNoError(t, err, "Failed to create fscache")
5659
t.Cleanup(func() { cache.Close() })
@@ -63,7 +66,7 @@ func Test_fsCache_SetError(t *testing.T) {
6366
}
6467

6568
func Test_fsCache_KeysError(t *testing.T) {
66-
u := makeRoot(t)
69+
u := makeRootURL(t)
6770
cache, err := fromURL(u)
6871
testutil.RequireNoError(t, err, "Failed to create fscache")
6972
t.Cleanup(func() { cache.Close() })
@@ -96,7 +99,7 @@ func TestOpen(t *testing.T) {
9699
{
97100
name: "Valid Root Directory",
98101
args: args{
99-
dsn: "fscache://" + t.TempDir() + "?appname=myapp",
102+
dsn: "fscache://" + filepath.ToSlash(t.TempDir()) + "?appname=myapp",
100103
},
101104
assertion: func(tt *testing.T, got *fsCache, err error) {
102105
testutil.RequireNoError(tt, err)
@@ -119,8 +122,13 @@ func TestOpen(t *testing.T) {
119122
dsn: "fscache://?appname=myapp",
120123
},
121124
setup: func(tt *testing.T) {
122-
tt.Setenv("XDG_CACHE_HOME", "")
123-
tt.Setenv("HOME", "")
125+
switch runtime.GOOS {
126+
case "windows":
127+
tt.Setenv("LocalAppData", "")
128+
default:
129+
tt.Setenv("XDG_CACHE_HOME", "")
130+
tt.Setenv("HOME", "")
131+
}
124132
},
125133
assertion: func(tt *testing.T, got *fsCache, err error) {
126134
testutil.RequireErrorIs(tt, err, ErrUserCacheDir)
@@ -140,7 +148,14 @@ func TestOpen(t *testing.T) {
140148
{
141149
name: "Invalid Root Directory",
142150
args: args{
143-
dsn: "fscache:///../invalid?appname=myapp",
151+
dsn: "fscache://" + filepath.ToSlash(
152+
filepath.VolumeName(t.TempDir())+"/../invalid",
153+
) + "?appname=myapp",
154+
},
155+
setup: func(tt *testing.T) {
156+
if runtime.GOOS == "windows" {
157+
tt.Skip("Skipping invalid path test on Windows")
158+
}
144159
},
145160
assertion: func(tt *testing.T, got *fsCache, err error) {
146161
testutil.RequireErrorIs(tt, err, ErrCreateCacheDir)
@@ -150,10 +165,7 @@ func TestOpen(t *testing.T) {
150165
{
151166
name: "Encryption Enabled with Key",
152167
args: args{
153-
dsn: "fscache://?appname=myapp&encrypt=aesgcm&encrypt_key=" + mustBase64Key(
154-
t,
155-
16,
156-
),
168+
dsn: "fscache://?appname=myapp&encrypt=aesgcm&encrypt_key=" + mustBase64Key(t, 16),
157169
},
158170
assertion: func(tt *testing.T, got *fsCache, err error) {
159171
testutil.RequireNoError(tt, err)
@@ -190,6 +202,11 @@ func TestOpen(t *testing.T) {
190202
args: args{
191203
dsn: "fscache://?appname=myapp&connect_timeout=1ns&timeout=10s",
192204
},
205+
setup: func(tt *testing.T) {
206+
if runtime.GOOS == "windows" {
207+
tt.Skip("Skipping connect timeout test on Windows")
208+
}
209+
},
193210
assertion: func(tt *testing.T, got *fsCache, err error) {
194211
testutil.RequireErrorIs(tt, err, context.DeadlineExceeded)
195212
testutil.AssertNil(tt, got)
@@ -231,7 +248,7 @@ func Test_parseTimeout(t *testing.T) {
231248
}
232249

233250
func TestFSCache_SetGet_WithEncryption(t *testing.T) {
234-
u, err := url.Parse("fscache://" + t.TempDir() +
251+
u, err := url.Parse("fscache://" + filepath.ToSlash(t.TempDir()) +
235252
"?appname=testapp&encrypt=aesgcm&encrypt_key=6S-Ks2YYOW0xMvTzKSv6QD30gZeOi1c6Ydr-As5csWk=")
236253
testutil.RequireNoError(t, err)
237254
cache, err := fromURL(u)

store/fscache/issue16_test.go

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
// Copyright (c) 2025 Bart Venter <bartventer@proton.me>
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package fscache
16+
17+
import (
18+
"fmt"
19+
"os"
20+
"runtime"
21+
"strconv"
22+
"strings"
23+
"testing"
24+
25+
"github.com/bartventer/httpcache/internal/testutil"
26+
)
27+
28+
func Test_Issue16_LongURLFragmentation(t *testing.T) {
29+
t.Attr("GOOS", runtime.GOOS)
30+
t.Attr("GOARCH", runtime.GOARCH)
31+
t.Attr("GOVERSION", runtime.Version())
32+
33+
tempDir := t.TempDir()
34+
cache, err := Open("test-fragmentation", WithBaseDir(tempDir))
35+
testutil.RequireNoError(t, err)
36+
37+
tests := []struct {
38+
name string
39+
urlLen int
40+
expectOK bool
41+
}{
42+
{"normal URL", 100, true},
43+
{"long URL (1KB)", 1024, true},
44+
{"very long URL (4KB)", 4096, true},
45+
{"extremely long URL (10KB)", 10240, true},
46+
{
47+
// While Go's stdlib handles long paths via \\?\ prefix on Windows,
48+
// extremely deep directory hierarchies (2844+ levels) may still hit
49+
// practical filesystem or OS limits beyond just path length.
50+
// See fixLongPath logic in os/path_windows.go (https://cs.opensource.google/go/go/+/refs/tags/go1.25.3:src/os/path_windows.go;l=100;drc=79b809afb325ae266497e21597f126a3e98a1ef7)
51+
"massive URL (100KB)",
52+
102400,
53+
runtime.GOOS != "windows",
54+
},
55+
}
56+
57+
for _, tt := range tests {
58+
t.Run(tt.name, func(t *testing.T) {
59+
// Generate URL of specified length
60+
url := "https://example.com/" + strings.Repeat(
61+
"x",
62+
tt.urlLen-len("https://example.com/"),
63+
)
64+
65+
// Get fragmentation details
66+
filename := cache.fn.FileName(url)
67+
depth := strings.Count(filename, string(os.PathSeparator))
68+
t.Attr("URLLength", strconv.Itoa(len(url)))
69+
t.Attr("PathLength", strconv.Itoa(len(filename)))
70+
t.Attr("DirectoryDepth", strconv.Itoa(depth))
71+
72+
// Test round-trip: Set -> Get -> Delete
73+
data := []byte("test data")
74+
err := cache.Set(url, data)
75+
76+
if tt.expectOK {
77+
testutil.RequireNoError(t, err)
78+
79+
retrieved, getErr := cache.Get(url)
80+
testutil.RequireNoError(t, getErr)
81+
testutil.AssertEqual(t, string(data), string(retrieved))
82+
83+
setErr := cache.Delete(url)
84+
testutil.RequireNoError(t, setErr)
85+
86+
t.Logf("Successfully handled %d byte URL", len(url))
87+
} else if err == nil {
88+
t.Errorf("Expected error for %d byte URL, but got none", len(url))
89+
}
90+
})
91+
}
92+
}
93+
94+
func Benchmark_Issue16_LongURLs(b *testing.B) {
95+
tempDir := b.TempDir()
96+
cache, err := Open("bench-long-urls", WithBaseDir(tempDir))
97+
if err != nil {
98+
b.Fatal(err)
99+
}
100+
101+
urlLengths := []int{100, 1000, 10000, 50000}
102+
103+
for _, length := range urlLengths {
104+
b.Run(fmt.Sprintf("url_length_%d", length), func(b *testing.B) {
105+
url := "https://example.com/" + strings.Repeat("x", length-len("https://example.com/"))
106+
data := []byte("benchmark data")
107+
108+
b.ResetTimer()
109+
for i := 0; b.Loop(); i++ {
110+
key := fmt.Sprintf("%s-%d", url, i)
111+
112+
err := cache.Set(key, data)
113+
if err != nil {
114+
b.Fatal(err)
115+
}
116+
117+
_, err = cache.Get(key)
118+
if err != nil {
119+
b.Fatal(err)
120+
}
121+
122+
err = cache.Delete(key)
123+
if err != nil {
124+
b.Fatal(err)
125+
}
126+
}
127+
})
128+
}
129+
}

0 commit comments

Comments
 (0)