Skip to content

Commit 89e5027

Browse files
committed
test: add system integration test suite
Add end-to-end integration tests that verify HTTPDirFS can mount an HTTP directory and correctly serve files. The test suite: - Generates test files with non-alphanumeric filenames (spaces, brackets, hash, tilde, etc.) and randomised sizes (1 KB–10 MB) - Starts a Python HTTP server with Range request support (206 Partial Content), required for httpdirfs file reads - Mounts the directory with httpdirfs and verifies: - File existence (directory listing) - File sizes match expected values - SHA-256 content integrity for every file - Empty and tiny file edge cases - Subdirectory with spaces traversal - Read-only enforcement - Tests cache mode with multithreaded reads: 8 threads reading a 1 GB file concurrently, then re-reading from cache New files: - tests/integration/run_integration_test.sh: main test driver - tests/integration/generate_test_files.py: test file generator - tests/integration/range_http_server.py: HTTP server with Range - tests/integration/multithread_read.py: multithreaded reader Also updates: - tests/meson.build: registers integration test suite - .github/workflows/build.yml: adds fuse3/python3 deps, splits unit and integration test steps - .github/workflows/pre-commit.yml: adds fuse3/python3 deps - .pre-commit-config.yaml: adds integration test hook, scopes existing meson-test hook to exclude integration suite Closes #237
1 parent e50d690 commit 89e5027

8 files changed

Lines changed: 928 additions & 5 deletions

File tree

.github/workflows/build.yml

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,12 +55,14 @@ jobs:
5555
libgumbo-dev \
5656
libcurl4-openssl-dev \
5757
libfuse3-dev \
58+
fuse3 \
5859
uuid-dev \
5960
libexpat1-dev \
6061
libssl-dev \
6162
meson \
6263
ninja-build \
63-
help2man
64+
help2man \
65+
python3
6466
6567
- name: Setup Meson
6668
run: |
@@ -70,5 +72,8 @@ jobs:
7072
- name: Compile
7173
run: meson compile -C builddir
7274

73-
- name: Test
74-
run: meson test -C builddir
75+
- name: Unit tests
76+
run: meson test -C builddir --no-suite integration
77+
78+
- name: Integration tests
79+
run: meson test -C builddir --suite integration --verbose

.github/workflows/pre-commit.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@ jobs:
1919
sudo apt-get install -y \
2020
libgumbo-dev libfuse3-dev libssl-dev \
2121
libcurl4-openssl-dev uuid-dev help2man libexpat1-dev pkg-config \
22-
meson ninja-build astyle clang-tidy clang-format
22+
meson ninja-build astyle clang-tidy clang-format \
23+
fuse3 python3
2324
- name: Set up build directory
2425
run: |
2526
meson setup builddir

.pre-commit-config.yaml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,13 @@ repos:
3232
files: \.(c|h)$
3333
- id: meson-test
3434
name: Meson Test
35-
entry: meson test -C builddir
35+
entry: meson test -C builddir --no-suite integration
3636
language: system
3737
pass_filenames: false
3838
files: \.(c|h)$
39+
- id: integration-test
40+
name: Integration Test
41+
entry: meson test -C builddir --suite integration --verbose
42+
language: system
43+
pass_filenames: false
44+
files: \.(c|h|py|sh)$
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
#!/usr/bin/env python3
2+
"""Generate test files with various filenames for HTTPDirFS integration tests.
3+
4+
Creates a set of files with known content and checksums to verify filesystem
5+
correctness after mounting with httpdirfs. Filenames include
6+
non-alphanumeric characters, spaces, and other edge cases.
7+
8+
File sizes are randomised between 1 KB and 10 MB using a deterministic seed
9+
so that the manifest is reproducible across runs.
10+
"""
11+
12+
import hashlib
13+
import json
14+
import os
15+
import random
16+
import sys
17+
18+
# Fixed seed so that file sizes are reproducible across runs, while still
19+
# being "random" in the sense that they cover a wide range.
20+
RNG_SEED = 20260518
21+
22+
# 64 KB write chunk — balances memory usage and I/O throughput
23+
CHUNK_SIZE = 64 * 1024
24+
25+
26+
def write_deterministic_file(filepath, name, size):
27+
"""Write a file with deterministic content and return its SHA-256.
28+
29+
Uses a name-derived seed with Python's Mersenne Twister PRNG.
30+
Streams to disk in 64 KB chunks so even multi-GB files use constant
31+
memory.
32+
"""
33+
if size == 0:
34+
with open(filepath, "wb"):
35+
pass
36+
return hashlib.sha256(b"").hexdigest()
37+
38+
seed_hash = hashlib.sha256(name.encode()).digest()
39+
file_rng = random.Random(int.from_bytes(seed_hash[:8], "big"))
40+
h = hashlib.sha256()
41+
written = 0
42+
43+
with open(filepath, "wb") as f:
44+
while written < size:
45+
n = min(CHUNK_SIZE, size - written)
46+
chunk = file_rng.randbytes(n)
47+
f.write(chunk)
48+
h.update(chunk)
49+
written += n
50+
51+
return h.hexdigest()
52+
53+
54+
def random_size(rng):
55+
"""Return a random file size between 1 KB and 10 MB."""
56+
return rng.randint(1024, 10 * 1024 * 1024)
57+
58+
59+
def format_size(size):
60+
"""Human-readable size string."""
61+
if size >= 1024 * 1024 * 1024:
62+
return f"{size / (1024**3):.1f} GB"
63+
if size >= 1024 * 1024:
64+
return f"{size / (1024**2):.1f} MB"
65+
if size >= 1024:
66+
return f"{size / 1024:.1f} KB"
67+
return f"{size} bytes"
68+
69+
70+
def main():
71+
if len(sys.argv) < 2:
72+
print(f"Usage: {sys.argv[0]} <output_directory> [--large]",
73+
file=sys.stderr)
74+
sys.exit(1)
75+
76+
output_dir = sys.argv[1]
77+
include_large = "--large" in sys.argv
78+
os.makedirs(output_dir, exist_ok=True)
79+
80+
rng = random.Random(RNG_SEED)
81+
82+
# Filenames that exercise interesting edge cases. Each file gets a
83+
# random size between 1 KB and 10 MB.
84+
filenames = [
85+
# Basic ASCII filenames
86+
"simple.txt",
87+
"UPPERCASE.DAT",
88+
89+
# Filenames with spaces
90+
"file with spaces.txt",
91+
"multiple spaces here.bin",
92+
93+
# Non-alphanumeric characters that are valid in URLs
94+
"file-with-dashes.txt",
95+
"file_with_underscores.txt",
96+
"file.multiple.dots.txt",
97+
"file~tilde.txt",
98+
99+
# Percent-encoded characters in HTTP (common edge cases)
100+
"parens(1).txt",
101+
"brackets[2].txt",
102+
"curly{3}.txt",
103+
"hash#tag.txt",
104+
"at@sign.txt",
105+
"plus+plus.txt",
106+
"equals=value.txt",
107+
"comma,separated.txt",
108+
"semi;colon.txt",
109+
"exclaim!mark.txt",
110+
"single'quote.txt",
111+
112+
# Mixed case and numbers
113+
"CamelCase123.txt",
114+
"MiXeD_cAsE-456.dat",
115+
116+
# Longer filenames
117+
"a" * 200 + ".txt",
118+
]
119+
120+
# Build (filename, size) pairs with random sizes
121+
test_files = [(name, random_size(rng)) for name in filenames]
122+
123+
# Keep a few deterministic edge-case sizes
124+
test_files.append(("empty_file.txt", 0))
125+
test_files.append(("tiny.txt", 1))
126+
127+
# Add the 1 GB file for cache system testing when --large is given
128+
if include_large:
129+
test_files.append(("large_1g.bin", 1024 * 1024 * 1024))
130+
131+
manifest = {}
132+
133+
for filename, size in test_files:
134+
filepath = os.path.join(output_dir, filename)
135+
checksum = write_deterministic_file(filepath, filename, size)
136+
137+
manifest[filename] = {
138+
"size": size,
139+
"sha256": checksum,
140+
}
141+
print(f" Created: {filename} ({format_size(size)},"
142+
f" sha256={checksum[:16]}...)")
143+
144+
# Also create a subdirectory with files (random sizes too)
145+
subdir = os.path.join(output_dir, "subdir with spaces")
146+
os.makedirs(subdir, exist_ok=True)
147+
148+
subdir_filenames = [
149+
"nested file.txt",
150+
"deep-data.bin",
151+
]
152+
153+
for filename in subdir_filenames:
154+
size = random_size(rng)
155+
filepath = os.path.join(subdir, filename)
156+
seed_name = f"subdir/{filename}"
157+
checksum = write_deterministic_file(filepath, seed_name, size)
158+
159+
relative = os.path.join("subdir with spaces", filename)
160+
manifest[relative] = {
161+
"size": size,
162+
"sha256": checksum,
163+
}
164+
print(f" Created: {relative} ({format_size(size)},"
165+
f" sha256={checksum[:16]}...)")
166+
167+
# Write manifest
168+
manifest_path = os.path.join(output_dir, "manifest.json")
169+
with open(manifest_path, "w") as f:
170+
json.dump(manifest, f, indent=2, sort_keys=True)
171+
print(f"\nManifest written to {manifest_path}")
172+
print(f"Total files: {len(manifest)}")
173+
174+
175+
if __name__ == "__main__":
176+
main()
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
#!/usr/bin/env python3
2+
"""Multithreaded file reader for cache system integration testing.
3+
4+
Reads a file from multiple threads simultaneously, each thread reading
5+
different byte ranges. Verifies each range against expected SHA-256 checksums
6+
computed from the original file content.
7+
8+
Usage:
9+
python3 multithread_read.py <filepath> <expected_sha256> <num_threads>
10+
11+
Exit code 0 on success, 1 on verification failure.
12+
"""
13+
14+
import hashlib
15+
import os
16+
import sys
17+
import threading
18+
19+
20+
def read_range(filepath, start, length, results, index):
21+
"""Read a byte range from the file and store it in results."""
22+
try:
23+
with open(filepath, "rb") as f:
24+
f.seek(start)
25+
data = f.read(length)
26+
results[index] = data
27+
except Exception as e:
28+
results[index] = e
29+
30+
31+
def main():
32+
if len(sys.argv) != 4:
33+
print(f"Usage: {sys.argv[0]} <filepath> <expected_sha256>"
34+
f" <num_threads>",
35+
file=sys.stderr)
36+
sys.exit(1)
37+
38+
filepath = sys.argv[1]
39+
expected_sha256 = sys.argv[2]
40+
num_threads = int(sys.argv[3])
41+
42+
file_size = os.path.getsize(filepath)
43+
chunk_size = file_size // num_threads
44+
remainder = file_size % num_threads
45+
46+
print(f" File: {os.path.basename(filepath)}")
47+
print(f" Size: {file_size} bytes ({file_size / (1024*1024):.1f} MB)")
48+
print(f" Threads: {num_threads}")
49+
print(f" Chunk size: {chunk_size} bytes")
50+
51+
# Launch threads to read different ranges
52+
threads = []
53+
results = [None] * num_threads
54+
55+
offset = 0
56+
for i in range(num_threads):
57+
# Last thread gets any remainder bytes
58+
length = chunk_size + (remainder if i == num_threads - 1 else 0)
59+
t = threading.Thread(
60+
target=read_range,
61+
args=(filepath, offset, length, results, i),
62+
)
63+
threads.append(t)
64+
offset += length
65+
66+
# Start all threads simultaneously
67+
for t in threads:
68+
t.start()
69+
70+
# Wait for all to complete
71+
for t in threads:
72+
t.join()
73+
74+
# Check for errors
75+
for i, result in enumerate(results):
76+
if isinstance(result, Exception):
77+
print(f" ERROR: Thread {i} failed: {result}", file=sys.stderr)
78+
sys.exit(1)
79+
if result is None:
80+
print(f" ERROR: Thread {i} returned no data", file=sys.stderr)
81+
sys.exit(1)
82+
83+
# Reassemble and verify
84+
full_data = b"".join(results)
85+
actual_sha256 = hashlib.sha256(full_data).hexdigest()
86+
87+
if actual_sha256 == expected_sha256:
88+
print(f" SHA-256 OK: {actual_sha256}")
89+
sys.exit(0)
90+
else:
91+
print(f" SHA-256 MISMATCH!", file=sys.stderr)
92+
print(f" Expected: {expected_sha256}", file=sys.stderr)
93+
print(f" Actual: {actual_sha256}", file=sys.stderr)
94+
sys.exit(1)
95+
96+
97+
if __name__ == "__main__":
98+
main()

0 commit comments

Comments
 (0)