Skip to content

Commit dfb2b96

Browse files
ryan-williamsclaude
andcommitted
Add more tests and CI workflow
Tests: - Delete endpoint: validation, success, cache invalidation - `scan_id` parameter for time-travel feature - Depth filtering with/without depth column - Cache population behavior Fixtures: - Regenerate parquet fixtures for new depth-first sort order CI: - Run pytest on Python 3.11, 3.12, 3.13 - Type check with mypy (non-blocking) - Build and type check UI 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent 6ae6185 commit dfb2b96

File tree

4 files changed

+297
-0
lines changed

4 files changed

+297
-0
lines changed

.github/workflows/ci.yml

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
jobs:
10+
test:
11+
name: Test
12+
runs-on: ubuntu-latest
13+
strategy:
14+
matrix:
15+
python-version: ["3.11", "3.12", "3.13"]
16+
17+
steps:
18+
- uses: actions/checkout@v4
19+
20+
- name: Install uv
21+
uses: astral-sh/setup-uv@v4
22+
23+
- name: Set up Python ${{ matrix.python-version }}
24+
run: uv python install ${{ matrix.python-version }}
25+
26+
- name: Install dependencies
27+
run: uv sync
28+
29+
- name: Run tests
30+
run: uv run pytest tests/ -v
31+
32+
typecheck:
33+
name: Type Check
34+
runs-on: ubuntu-latest
35+
steps:
36+
- uses: actions/checkout@v4
37+
38+
- name: Install uv
39+
uses: astral-sh/setup-uv@v4
40+
41+
- name: Set up Python
42+
run: uv python install 3.12
43+
44+
- name: Install dependencies
45+
run: uv sync
46+
47+
- name: Run mypy
48+
run: uv run mypy src/disk_tree --ignore-missing-imports
49+
continue-on-error: true # Don't fail build on type errors for now
50+
51+
ui-build:
52+
name: UI Build
53+
runs-on: ubuntu-latest
54+
steps:
55+
- uses: actions/checkout@v4
56+
57+
- uses: pnpm/action-setup@v4
58+
with:
59+
version: 9
60+
61+
- uses: actions/setup-node@v4
62+
with:
63+
node-version: 20
64+
cache: pnpm
65+
cache-dependency-path: ui/pnpm-lock.yaml
66+
67+
- name: Install dependencies
68+
run: cd ui && pnpm install --frozen-lockfile
69+
70+
- name: Type check
71+
run: cd ui && pnpm tsc --noEmit
72+
73+
- name: Build
74+
run: cd ui && pnpm build

tests/data/s3.parquet

-741 Bytes
Binary file not shown.

tests/data/s8g.parquet

-661 Bytes
Binary file not shown.

tests/test_server.py

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -624,3 +624,226 @@ def test_compare_detects_added_removed(self, test_client):
624624
rows_by_status = {r['path']: r['status'] for r in data['rows']}
625625
assert rows_by_status.get('child1') == 'removed'
626626
assert rows_by_status.get('child2') == 'added'
627+
628+
629+
class TestDeleteEndpoint:
630+
"""Tests for POST /api/delete endpoint."""
631+
632+
def test_delete_requires_path(self, test_client):
633+
"""Returns 400 when path is not provided."""
634+
client, _, _ = test_client
635+
response = client.post('/api/delete', json={})
636+
assert response.status_code == 400
637+
assert 'Path is required' in response.json['error']
638+
639+
def test_delete_requires_absolute_path(self, test_client):
640+
"""Returns 400 for relative paths."""
641+
client, _, _ = test_client
642+
response = client.post('/api/delete', json={'path': 'relative/path'})
643+
assert response.status_code == 400
644+
assert 'absolute' in response.json['error'].lower()
645+
646+
def test_delete_nonexistent_path(self, test_client):
647+
"""Returns 404 for paths that don't exist."""
648+
client, _, _ = test_client
649+
response = client.post('/api/delete', json={'path': '/nonexistent/path/12345'})
650+
assert response.status_code == 404
651+
652+
def test_delete_file_success(self, test_client):
653+
"""Successfully deletes a file and returns stats."""
654+
client, db_path, scans_dir = test_client
655+
656+
# Create a file to delete
657+
test_file = os.path.join(scans_dir, 'to_delete.txt')
658+
with open(test_file, 'w') as f:
659+
f.write('test content')
660+
661+
response = client.post('/api/delete', json={'path': test_file})
662+
assert response.status_code == 200
663+
data = response.json
664+
assert data['success'] is True
665+
assert data['path'] == test_file
666+
assert data['deleted_size'] > 0
667+
assert not os.path.exists(test_file)
668+
669+
def test_delete_clears_cache(self, test_client):
670+
"""Delete clears server caches so next request gets fresh data."""
671+
client, db_path, scans_dir = test_client
672+
673+
# Create scan with a file
674+
parquet_path = create_test_parquet(scans_dir, 'test', [
675+
{'path': '.', 'size': 1000, 'mtime': 100, 'kind': 'dir', 'parent': '', 'uri': '/test', 'n_desc': 2, 'n_children': 1},
676+
{'path': 'file.txt', 'size': 500, 'mtime': 80, 'kind': 'file', 'parent': '.', 'uri': '/test/file.txt', 'n_desc': 1, 'n_children': 0},
677+
])
678+
679+
conn = sqlite3.connect(db_path)
680+
conn.execute(
681+
'INSERT INTO scan (path, time, blob, size, n_children, n_desc) VALUES (?, ?, ?, ?, ?, ?)',
682+
('/test', '2025-01-01T12:00:00', parquet_path, 1000, 1, 2),
683+
)
684+
conn.commit()
685+
conn.close()
686+
687+
# First request to populate cache
688+
response1 = client.get('/api/scan?uri=/test')
689+
assert response1.status_code == 200
690+
691+
# Create file to delete (outside of scan data, just to trigger cache clear)
692+
test_file = os.path.join(scans_dir, 'deleteme.txt')
693+
with open(test_file, 'w') as f:
694+
f.write('x')
695+
696+
# Delete should clear caches
697+
from disk_tree.server import _cache, _parquet_cache
698+
assert len(_cache) > 0 or len(_parquet_cache) > 0 # Something should be cached
699+
700+
response = client.post('/api/delete', json={'path': test_file})
701+
assert response.status_code == 200
702+
703+
# Caches should be cleared
704+
assert len(_cache) == 0
705+
assert len(_parquet_cache) == 0
706+
707+
708+
class TestScanIdParameter:
709+
"""Tests for scan_id parameter (time-travel feature)."""
710+
711+
def test_scan_id_uses_specific_scan(self, test_client):
712+
"""scan_id parameter uses the specified scan instead of latest."""
713+
client, db_path, scans_dir = test_client
714+
715+
# Create two scans with different data
716+
parquet1 = create_test_parquet(scans_dir, 'old', [
717+
{'path': '.', 'size': 1000, 'mtime': 100, 'kind': 'dir', 'parent': '', 'uri': '/test', 'n_desc': 1, 'n_children': 0},
718+
])
719+
parquet2 = create_test_parquet(scans_dir, 'new', [
720+
{'path': '.', 'size': 2000, 'mtime': 200, 'kind': 'dir', 'parent': '', 'uri': '/test', 'n_desc': 1, 'n_children': 0},
721+
])
722+
723+
conn = sqlite3.connect(db_path)
724+
conn.execute(
725+
'INSERT INTO scan (path, time, blob, size, n_children, n_desc) VALUES (?, ?, ?, ?, ?, ?)',
726+
('/test', '2025-01-01T12:00:00', parquet1, 1000, 0, 1),
727+
)
728+
conn.execute(
729+
'INSERT INTO scan (path, time, blob, size, n_children, n_desc) VALUES (?, ?, ?, ?, ?, ?)',
730+
('/test', '2025-01-02T12:00:00', parquet2, 2000, 0, 1),
731+
)
732+
conn.commit()
733+
conn.close()
734+
735+
# Without scan_id, should get latest (size=2000)
736+
response = client.get('/api/scan?uri=/test')
737+
assert response.status_code == 200
738+
assert response.json['root']['size'] == 2000
739+
740+
# With scan_id=1, should get old scan (size=1000)
741+
response = client.get('/api/scan?uri=/test&scan_id=1')
742+
assert response.status_code == 200
743+
assert response.json['root']['size'] == 1000
744+
745+
def test_scan_id_invalid_returns_error(self, test_client):
746+
"""Returns 400 if scan_id doesn't cover the requested path."""
747+
client, db_path, scans_dir = test_client
748+
749+
parquet = create_test_parquet(scans_dir, 'test', [
750+
{'path': '.', 'size': 1000, 'mtime': 100, 'kind': 'dir', 'parent': '', 'uri': '/other', 'n_desc': 1, 'n_children': 0},
751+
])
752+
753+
conn = sqlite3.connect(db_path)
754+
conn.execute(
755+
'INSERT INTO scan (path, time, blob) VALUES (?, ?, ?)',
756+
('/other', '2025-01-01T12:00:00', parquet),
757+
)
758+
conn.commit()
759+
conn.close()
760+
761+
# Request /test with scan_id=1 which is for /other
762+
response = client.get('/api/scan?uri=/test&scan_id=1')
763+
assert response.status_code == 400
764+
assert 'does not cover' in response.json['error']
765+
766+
767+
class TestDepthFiltering:
768+
"""Tests for depth-based parquet filtering."""
769+
770+
def test_depth_column_in_parquet(self, test_client):
771+
"""Parquet files with depth column support efficient filtering."""
772+
client, db_path, scans_dir = test_client
773+
774+
# Create parquet with depth column
775+
parquet_path = create_test_parquet(scans_dir, 'deep', [
776+
{'path': '.', 'size': 1000, 'mtime': 100, 'kind': 'dir', 'parent': '', 'uri': '/test', 'n_desc': 4, 'n_children': 1, 'depth': 0},
777+
{'path': 'a', 'size': 800, 'mtime': 90, 'kind': 'dir', 'parent': '.', 'uri': '/test/a', 'n_desc': 3, 'n_children': 1, 'depth': 1},
778+
{'path': 'a/b', 'size': 600, 'mtime': 80, 'kind': 'dir', 'parent': 'a', 'uri': '/test/a/b', 'n_desc': 2, 'n_children': 1, 'depth': 2},
779+
{'path': 'a/b/c', 'size': 400, 'mtime': 70, 'kind': 'file', 'parent': 'a/b', 'uri': '/test/a/b/c', 'n_desc': 1, 'n_children': 0, 'depth': 3},
780+
])
781+
782+
conn = sqlite3.connect(db_path)
783+
conn.execute(
784+
'INSERT INTO scan (path, time, blob, size, n_children, n_desc) VALUES (?, ?, ?, ?, ?, ?)',
785+
('/test', '2025-01-01T12:00:00', parquet_path, 1000, 1, 4),
786+
)
787+
conn.commit()
788+
conn.close()
789+
790+
# Request should work - depth filtering is an implementation detail
791+
response = client.get('/api/scan?uri=/test')
792+
assert response.status_code == 200
793+
assert response.json['root']['size'] == 1000
794+
assert len(response.json['children']) == 1
795+
assert response.json['children'][0]['path'] == 'a'
796+
797+
def test_parquet_without_depth_still_works(self, test_client):
798+
"""Parquet files without depth column still work (fallback to full load)."""
799+
client, db_path, scans_dir = test_client
800+
801+
# Create parquet WITHOUT depth column
802+
parquet_path = create_test_parquet(scans_dir, 'nodepth', [
803+
{'path': '.', 'size': 1000, 'mtime': 100, 'kind': 'dir', 'parent': '', 'uri': '/test', 'n_desc': 2, 'n_children': 1},
804+
{'path': 'child', 'size': 500, 'mtime': 80, 'kind': 'dir', 'parent': '.', 'uri': '/test/child', 'n_desc': 1, 'n_children': 0},
805+
])
806+
807+
conn = sqlite3.connect(db_path)
808+
conn.execute(
809+
'INSERT INTO scan (path, time, blob, size, n_children, n_desc) VALUES (?, ?, ?, ?, ?, ?)',
810+
('/test', '2025-01-01T12:00:00', parquet_path, 1000, 1, 2),
811+
)
812+
conn.commit()
813+
conn.close()
814+
815+
response = client.get('/api/scan?uri=/test')
816+
assert response.status_code == 200
817+
assert response.json['root']['size'] == 1000
818+
819+
820+
class TestCacheInvalidation:
821+
"""Tests for cache behavior."""
822+
823+
def test_cache_populated_on_request(self, test_client):
824+
"""Requests populate the cache."""
825+
client, db_path, scans_dir = test_client
826+
827+
parquet_path = create_test_parquet(scans_dir, 'test', [
828+
{'path': '.', 'size': 1000, 'mtime': 100, 'kind': 'dir', 'parent': '', 'uri': '/test', 'n_desc': 1, 'n_children': 0},
829+
])
830+
831+
conn = sqlite3.connect(db_path)
832+
conn.execute(
833+
'INSERT INTO scan (path, time, blob, size, n_children, n_desc) VALUES (?, ?, ?, ?, ?, ?)',
834+
('/test', '2025-01-01T12:00:00', parquet_path, 1000, 0, 1),
835+
)
836+
conn.commit()
837+
conn.close()
838+
839+
from disk_tree.server import _parquet_cache, clear_cache
840+
clear_cache()
841+
_parquet_cache.clear()
842+
843+
assert len(_parquet_cache) == 0
844+
845+
response = client.get('/api/scan?uri=/test')
846+
assert response.status_code == 200
847+
848+
# Parquet cache should now have an entry
849+
assert len(_parquet_cache) >= 1

0 commit comments

Comments
 (0)