@@ -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