Align knowledge storage with real folders and add WebDAV import surface
Knowledge files were only partitioned in the database, which made nested uploads, local folder visibility, and delete behavior diverge from the UI. This change makes folder selection drive physical storage paths, keeps original filenames, adds a minimal WebDAV mount/sync path, and reshapes the knowledge panel so local and remote sources can share the same surface. Constraint: Existing knowledge flow already depends on local-folder-backed uploads and document indexing Rejected: Real-time bidirectional WebDAV sync | too much conflict and lifecycle complexity for the first pass Confidence: medium Scope-risk: moderate Reversibility: messy Directive: Keep remote mounts single-direction into local knowledge folders until etag-based incremental sync and conflict rules are verified Tested: Python py_compile on new/modified backend files; LSP diagnostics on new frontend/backend files; manual targeted code-path inspection Not-tested: Full pytest/vitest end-to-end runs blocked by environment temp/cache permission errors; live WebDAV server interoperability
This commit is contained in:
@@ -15,6 +15,7 @@ from starlette.datastructures import UploadFile
|
||||
import app.models # noqa: F401
|
||||
from app.database import Base
|
||||
from app.models.document import Document, DocumentChunk
|
||||
from app.models.folder import Folder
|
||||
from app.models.user import User
|
||||
from app.services.auth_service import get_password_hash
|
||||
from app.services.document_service import DocumentService
|
||||
@@ -199,6 +200,29 @@ async def test_upload_document_persists_structured_metadata_json(document_test_e
|
||||
assert stored_document.normalized_content == 'title\n\nplain text body for metadata storage'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_document_stores_file_in_nested_folder_with_original_name(document_test_env):
|
||||
session, user = document_test_env
|
||||
service = DocumentService(session)
|
||||
|
||||
root = Folder(user_id=user.id, name='Projects')
|
||||
session.add(root)
|
||||
await session.flush()
|
||||
child = Folder(user_id=user.id, name='Specs', parent_id=root.id)
|
||||
session.add(child)
|
||||
await session.commit()
|
||||
await session.refresh(child)
|
||||
|
||||
upload = UploadFile(filename='system-design.md', file=BytesIO(b'# Design'))
|
||||
document = await service.upload_document(user.id, upload, folder_id=child.id)
|
||||
|
||||
file_path = Path(document.file_path)
|
||||
assert file_path.name == 'system-design.md'
|
||||
assert file_path.parent.name == 'Specs'
|
||||
assert file_path.parent.parent.name == 'Projects'
|
||||
assert file_path.exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_document_extracts_docx_heading_and_table_structure(document_test_env):
|
||||
session, user = document_test_env
|
||||
|
||||
39
backend/tests/backend/app/services/test_webdav_service.py
Normal file
39
backend/tests/backend/app/services/test_webdav_service.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from app.models.remote_mount import RemoteMount
|
||||
from app.services.secret_service import encrypt_secret
|
||||
from app.services.webdav_service import WebDavService
|
||||
|
||||
|
||||
def test_parse_propfind_returns_sorted_nodes():
|
||||
mount = RemoteMount(
|
||||
user_id='user-1',
|
||||
name='Docs',
|
||||
mount_type='webdav',
|
||||
base_url='https://example.com/dav/',
|
||||
username='alice',
|
||||
password_encrypted=encrypt_secret('secret'),
|
||||
root_path='/knowledge',
|
||||
is_active=True,
|
||||
)
|
||||
payload = """<?xml version="1.0" encoding="utf-8" ?>
|
||||
<d:multistatus xmlns:d="DAV:">
|
||||
<d:response>
|
||||
<d:href>/knowledge/</d:href>
|
||||
<d:propstat><d:prop><d:displayname>knowledge</d:displayname><d:resourcetype><d:collection /></d:resourcetype></d:prop></d:propstat>
|
||||
</d:response>
|
||||
<d:response>
|
||||
<d:href>/knowledge/specs/</d:href>
|
||||
<d:propstat><d:prop><d:displayname>specs</d:displayname><d:resourcetype><d:collection /></d:resourcetype></d:prop></d:propstat>
|
||||
</d:response>
|
||||
<d:response>
|
||||
<d:href>/knowledge/roadmap.md</d:href>
|
||||
<d:propstat><d:prop><d:displayname>roadmap.md</d:displayname><d:getcontentlength>128</d:getcontentlength><d:getetag>"etag-1"</d:getetag><d:getlastmodified>Wed, 09 Apr 2026 10:00:00 GMT</d:getlastmodified><d:resourcetype /></d:prop></d:propstat>
|
||||
</d:response>
|
||||
</d:multistatus>"""
|
||||
|
||||
nodes = WebDavService(mount)._parse_propfind('/knowledge', payload)
|
||||
|
||||
assert [node.name for node in nodes] == ['specs', 'roadmap.md']
|
||||
assert nodes[0].is_dir is True
|
||||
assert nodes[1].is_dir is False
|
||||
assert nodes[1].size == 128
|
||||
assert nodes[1].etag == '"etag-1"'
|
||||
90
backend/tests/backend/app/test_folder_router.py
Normal file
90
backend/tests/backend/app/test_folder_router.py
Normal file
@@ -0,0 +1,90 @@
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
import app.models # noqa: F401
|
||||
from app.database import Base, get_db
|
||||
from app.main import app
|
||||
from app.models.folder import Folder
|
||||
from app.models.user import User
|
||||
from app.routers.auth import get_current_user
|
||||
from app.services.auth_service import get_password_hash
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def folder_router_env(tmp_path, monkeypatch):
|
||||
db_path = tmp_path / 'test_folders_router.db'
|
||||
upload_dir = tmp_path / 'uploads'
|
||||
engine = create_async_engine(f"sqlite+aiosqlite:///{db_path}", future=True)
|
||||
session_factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
async with session_factory() as session:
|
||||
user = User(
|
||||
email='folders@example.com',
|
||||
hashed_password=get_password_hash('secret123'),
|
||||
full_name='Folder Tester',
|
||||
)
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
|
||||
monkeypatch.setattr('app.services.document_service.settings.UPLOAD_DIR', str(upload_dir))
|
||||
|
||||
async def override_get_db():
|
||||
async with session_factory() as session:
|
||||
yield session
|
||||
|
||||
async def override_get_current_user():
|
||||
return user
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
app.dependency_overrides[get_current_user] = override_get_current_user
|
||||
|
||||
try:
|
||||
yield user, upload_dir, session_factory
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_folder_creates_matching_local_directory(folder_router_env):
|
||||
user, upload_dir, _session_factory = folder_router_env
|
||||
transport = ASGITransport(app=app)
|
||||
|
||||
async with AsyncClient(transport=transport, base_url='http://testserver') as client:
|
||||
response = await client.post('/api/folders', json={'name': 'Projects', 'parent_id': None})
|
||||
|
||||
assert response.status_code == 201
|
||||
folder_id = response.json()['id']
|
||||
|
||||
expected_path = upload_dir / user.id / 'Projects'
|
||||
assert expected_path.exists()
|
||||
assert expected_path.is_dir()
|
||||
assert folder_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rename_folder_moves_local_directory(folder_router_env):
|
||||
user, upload_dir, session_factory = folder_router_env
|
||||
|
||||
async with session_factory() as session:
|
||||
folder = Folder(user_id=user.id, name='Old', parent_id=None)
|
||||
session.add(folder)
|
||||
await session.commit()
|
||||
await session.refresh(folder)
|
||||
|
||||
(upload_dir / user.id / 'Old').mkdir(parents=True, exist_ok=True)
|
||||
transport = ASGITransport(app=app)
|
||||
|
||||
async with AsyncClient(transport=transport, base_url='http://testserver') as client:
|
||||
response = await client.put(f'/api/folders/{folder.id}', json={'name': 'New'})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert not (upload_dir / user.id / 'Old').exists()
|
||||
assert (upload_dir / user.id / 'New').exists()
|
||||
Reference in New Issue
Block a user