diff --git a/Backend/Application/Interfaces/IFileRepository.py b/Backend/Application/Interfaces/IFileRepository.py index 9e7785a..908af32 100644 --- a/Backend/Application/Interfaces/IFileRepository.py +++ b/Backend/Application/Interfaces/IFileRepository.py @@ -23,3 +23,11 @@ def get_all(self) -> list[File]: @abstractmethod def update_status(self, file_id: str, status: FileStatus) -> None: pass + + @abstractmethod + def update_folder(self, file_id: str, folder_id: str | None) -> None: + pass + + @abstractmethod + def get_by_folder_id(self, folder_id: str) -> list[File]: + pass diff --git a/Backend/Application/Interfaces/IFolderRepository.py b/Backend/Application/Interfaces/IFolderRepository.py new file mode 100644 index 0000000..d13cefe --- /dev/null +++ b/Backend/Application/Interfaces/IFolderRepository.py @@ -0,0 +1,29 @@ +"""文件夹仓储抽象接口,定义文件夹的持久化操作契约。""" +from abc import ABC, abstractmethod +from Backend.Domain.Entities.folder import Folder + + +class IFolderRepository(ABC): + @abstractmethod + def save(self, folder: Folder) -> None: + pass + + @abstractmethod + def get_by_id(self, folder_id: str) -> Folder | None: + pass + + @abstractmethod + def get_by_name(self, name: str) -> Folder | None: + pass + + @abstractmethod + def get_all(self) -> list[Folder]: + pass + + @abstractmethod + def update_name(self, folder_id: str, new_name: str) -> None: + pass + + @abstractmethod + def delete(self, folder_id: str) -> None: + pass diff --git a/Backend/Application/Skills/FaultTreeSkill.py b/Backend/Application/Skills/FaultTreeSkill.py index 18eac34..52e4407 100644 --- a/Backend/Application/Skills/FaultTreeSkill.py +++ b/Backend/Application/Skills/FaultTreeSkill.py @@ -46,7 +46,7 @@ }, "gate_type": { "type": "string", - "enum": ["AND", "OR"], + "enum": ["AND", "OR", "XOR", "INHIBIT", "PRIORITY_AND"], "description": "逻辑门类型,仅当 node_type 为 gate 时需要", }, "remark": { @@ -114,7 +114,7 @@ }, "gate_type": { "type": "string", - "enum": ["AND", "OR"], + "enum": ["AND", "OR", "XOR", "INHIBIT", "PRIORITY_AND"], "description": "逻辑门类型,仅 gate 节点需要", }, "remark": {"type": "string", "description": "备注信息"}, diff --git a/Backend/Application/UseCases/FolderUseCase.py b/Backend/Application/UseCases/FolderUseCase.py new file mode 100644 index 0000000..438aae5 --- /dev/null +++ b/Backend/Application/UseCases/FolderUseCase.py @@ -0,0 +1,63 @@ +"""文件夹用例层,处理创建、重命名、删除文件夹及文件归类的业务逻辑。""" +import logging +from Backend.Domain.Entities.folder import Folder +from Backend.Application.Interfaces.IFolderRepository import IFolderRepository +from Backend.Application.Interfaces.IFileRepository import IFileRepository + +logger = logging.getLogger(__name__) + + +class FolderUseCase: + def __init__( + self, + folder_repository: IFolderRepository, + file_repository: IFileRepository, + ): + self._folder_repo = folder_repository + self._file_repo = file_repository + + def create_folder(self, name: str) -> Folder: + existing = self._folder_repo.get_by_name(name) + if existing is not None: + raise ValueError(f"Folder already exists: {name}") + folder = Folder(name=name) + self._folder_repo.save(folder) + logger.info("Folder created: %s (%s)", folder.name, folder.id) + return folder + + def rename_folder(self, folder_id: str, new_name: str) -> Folder: + folder = self._folder_repo.get_by_id(folder_id) + if folder is None: + raise ValueError(f"Folder not found: {folder_id}") + existing = self._folder_repo.get_by_name(new_name) + if existing is not None and existing.id != folder_id: + raise ValueError(f"Folder name already taken: {new_name}") + folder.rename(new_name) + self._folder_repo.update_name(folder_id, new_name) + logger.info("Folder renamed: %s -> %s", folder_id, new_name) + return folder + + def list_folders(self) -> list[dict]: + folders = self._folder_repo.get_all() + result = [] + for folder in folders: + files = self._file_repo.get_by_folder_id(folder.id) + folder_dict = folder.to_dict() + folder_dict["files"] = [f.to_dict() for f in files] + result.append(folder_dict) + return result + + def delete_folder(self, folder_id: str) -> None: + folder = self._folder_repo.get_by_id(folder_id) + if folder is None: + raise ValueError(f"Folder not found: {folder_id}") + self._folder_repo.delete(folder_id) + logger.info("Folder deleted: %s (%s)", folder.name, folder_id) + + def move_file_to_folder(self, file_id: str, folder_id: str | None) -> None: + if folder_id is not None: + folder = self._folder_repo.get_by_id(folder_id) + if folder is None: + raise ValueError(f"Folder not found: {folder_id}") + self._file_repo.update_folder(file_id, folder_id) + logger.info("File %s moved to folder %s", file_id, folder_id) diff --git a/Backend/Domain/Entities/file.py b/Backend/Domain/Entities/file.py index 3aae31c..4698286 100644 --- a/Backend/Domain/Entities/file.py +++ b/Backend/Domain/Entities/file.py @@ -12,12 +12,14 @@ def __init__( file_id: str | None = None, created_at: datetime | None = None, status: FileStatus = FileStatus.PENDING, + folder_id: str | None = None, ): self.id = file_id or str(uuid.uuid4()) self.file_name = file_name self.file_type = file_type self.created_at = created_at or datetime.now() self.status = status + self.folder_id = folder_id def mark_embedded(self): self.status = FileStatus.EMBEDDED @@ -32,4 +34,5 @@ def to_dict(self) -> dict: "file_type": self.file_type.value, "created_at": self.created_at.isoformat(), "status": self.status.value, + "folder_id": self.folder_id, } diff --git a/Backend/Domain/Entities/folder.py b/Backend/Domain/Entities/folder.py new file mode 100644 index 0000000..046b182 --- /dev/null +++ b/Backend/Domain/Entities/folder.py @@ -0,0 +1,25 @@ +"""文件夹领域实体,用于对上传文件进行分类管理。""" +import uuid +from datetime import datetime + + +class Folder: + def __init__( + self, + name: str, + folder_id: str | None = None, + created_at: datetime | None = None, + ): + self.id = folder_id or str(uuid.uuid4()) + self.name = name + self.created_at = created_at or datetime.now() + + def rename(self, new_name: str): + self.name = new_name + + def to_dict(self) -> dict: + return { + "id": self.id, + "name": self.name, + "created_at": self.created_at.isoformat(), + } diff --git a/Backend/Infrastructure/persistence/FileRepository.py b/Backend/Infrastructure/persistence/FileRepository.py index aee88c4..89bed2c 100644 --- a/Backend/Infrastructure/persistence/FileRepository.py +++ b/Backend/Infrastructure/persistence/FileRepository.py @@ -11,8 +11,8 @@ def save(self, file: File) -> None: conn = get_connection() try: conn.execute( - "INSERT INTO files (id, file_name, file_type, created_at, status) VALUES (?, ?, ?, ?, ?)", - (file.id, file.file_name, file.file_type.value, file.created_at.isoformat(), file.status.value), + "INSERT INTO files (id, file_name, file_type, created_at, status, folder_id) VALUES (?, ?, ?, ?, ?, ?)", + (file.id, file.file_name, file.file_type.value, file.created_at.isoformat(), file.status.value, file.folder_id), ) conn.commit() finally: @@ -52,6 +52,22 @@ def update_status(self, file_id: str, status: FileStatus) -> None: finally: conn.close() + def update_folder(self, file_id: str, folder_id: str | None) -> None: + conn = get_connection() + try: + conn.execute("UPDATE files SET folder_id = ? WHERE id = ?", (folder_id, file_id)) + conn.commit() + finally: + conn.close() + + def get_by_folder_id(self, folder_id: str) -> list[File]: + conn = get_connection() + try: + rows = conn.execute("SELECT * FROM files WHERE folder_id = ? ORDER BY created_at DESC", (folder_id,)).fetchall() + return [self._row_to_entity(row) for row in rows] + finally: + conn.close() + @staticmethod def _row_to_entity(row) -> File: return File( @@ -60,4 +76,5 @@ def _row_to_entity(row) -> File: file_type=FileType(row["file_type"]), created_at=datetime.fromisoformat(row["created_at"]), status=FileStatus(row["status"]), + folder_id=row["folder_id"], ) diff --git a/Backend/Infrastructure/persistence/FolderRepository.py b/Backend/Infrastructure/persistence/FolderRepository.py new file mode 100644 index 0000000..c705089 --- /dev/null +++ b/Backend/Infrastructure/persistence/FolderRepository.py @@ -0,0 +1,71 @@ +"""文件夹仓储的 SQLite 实现,负责文件夹的增删改查持久化。""" +from datetime import datetime +from Backend.Application.Interfaces.IFolderRepository import IFolderRepository +from Backend.Domain.Entities.folder import Folder +from Backend.Infrastructure.persistence.database import get_connection + + +class SQLiteFolderRepository(IFolderRepository): + def save(self, folder: Folder) -> None: + conn = get_connection() + try: + conn.execute( + "INSERT INTO folders (id, name, created_at) VALUES (?, ?, ?)", + (folder.id, folder.name, folder.created_at.isoformat()), + ) + conn.commit() + finally: + conn.close() + + def get_by_id(self, folder_id: str) -> Folder | None: + conn = get_connection() + try: + row = conn.execute("SELECT * FROM folders WHERE id = ?", (folder_id,)).fetchone() + if row is None: + return None + return self._row_to_entity(row) + finally: + conn.close() + + def get_by_name(self, name: str) -> Folder | None: + conn = get_connection() + try: + row = conn.execute("SELECT * FROM folders WHERE name = ?", (name,)).fetchone() + if row is None: + return None + return self._row_to_entity(row) + finally: + conn.close() + + def get_all(self) -> list[Folder]: + conn = get_connection() + try: + rows = conn.execute("SELECT * FROM folders ORDER BY created_at DESC").fetchall() + return [self._row_to_entity(row) for row in rows] + finally: + conn.close() + + def update_name(self, folder_id: str, new_name: str) -> None: + conn = get_connection() + try: + conn.execute("UPDATE folders SET name = ? WHERE id = ?", (new_name, folder_id)) + conn.commit() + finally: + conn.close() + + def delete(self, folder_id: str) -> None: + conn = get_connection() + try: + conn.execute("UPDATE files SET folder_id = NULL WHERE folder_id = ?", (folder_id,)) + conn.execute("DELETE FROM folders WHERE id = ?", (folder_id,)) + conn.commit() + finally: + conn.close() + + @staticmethod + def _row_to_entity(row) -> Folder: + return Folder( + folder_id=row["id"], + name=row["name"], + created_at=datetime.fromisoformat(row["created_at"]), + ) diff --git a/Backend/Infrastructure/persistence/database.py b/Backend/Infrastructure/persistence/database.py index b9e84e7..af80bc6 100644 --- a/Backend/Infrastructure/persistence/database.py +++ b/Backend/Infrastructure/persistence/database.py @@ -10,7 +10,17 @@ file_name TEXT NOT NULL, file_type TEXT NOT NULL, created_at TEXT NOT NULL, - status TEXT NOT NULL + status TEXT NOT NULL, + folder_id TEXT, + FOREIGN KEY (folder_id) REFERENCES folders(id) +); +""" + +_CREATE_FOLDERS_SQL = """ +CREATE TABLE IF NOT EXISTS folders ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + created_at TEXT NOT NULL ); """ @@ -85,6 +95,7 @@ def init_db() -> None: conn = get_connection() try: with _lock: + conn.execute(_CREATE_FOLDERS_SQL) conn.execute(_CREATE_TABLE_SQL) conn.execute(_CREATE_CONVERSATIONS_SQL) conn.execute(_CREATE_CHAT_ROUNDS_SQL) diff --git a/Backend/Web/Endpoints/FolderEndpoint.py b/Backend/Web/Endpoints/FolderEndpoint.py new file mode 100644 index 0000000..8928a06 --- /dev/null +++ b/Backend/Web/Endpoints/FolderEndpoint.py @@ -0,0 +1,73 @@ +"""文件夹管理 REST API 端点,提供文件夹的增删改查及文件移动接口。""" +from flask import Blueprint, request +from Backend.Application.UseCases.FolderUseCase import FolderUseCase + +folder_bp = Blueprint("folders", __name__, url_prefix="/api/folders") + + +def create_folder_blueprint(folder_use_case: FolderUseCase) -> Blueprint: + + @folder_bp.route("", methods=["POST"]) + def create_folder(): + data = request.get_json() + if not data or not data.get("name"): + return {"error": "Folder name is required"}, 400 + name = data["name"].strip() + if not name: + return {"error": "Folder name cannot be empty"}, 400 + try: + folder = folder_use_case.create_folder(name) + return folder.to_dict(), 201 + except ValueError as e: + return {"error": str(e)}, 409 + + @folder_bp.route("", methods=["GET"]) + def list_folders(): + folders = folder_use_case.list_folders() + return folders + + @folder_bp.route("/", methods=["PATCH"]) + def rename_folder(folder_id: str): + data = request.get_json() + if not data or not data.get("name"): + return {"error": "New folder name is required"}, 400 + new_name = data["name"].strip() + if not new_name: + return {"error": "Folder name cannot be empty"}, 400 + try: + folder = folder_use_case.rename_folder(folder_id, new_name) + return folder.to_dict(), 200 + except ValueError as e: + return {"error": str(e)}, 404 + + @folder_bp.route("/", methods=["DELETE"]) + def delete_folder(folder_id: str): + try: + folder_use_case.delete_folder(folder_id) + return {"message": "Folder deleted"}, 200 + except ValueError as e: + return {"error": str(e)}, 404 + + @folder_bp.route("//files", methods=["POST"]) + def move_file_to_folder(folder_id: str): + data = request.get_json() + if not data or not data.get("file_id"): + return {"error": "file_id is required"}, 400 + try: + folder_use_case.move_file_to_folder(data["file_id"], folder_id) + return {"message": "File moved to folder"}, 200 + except ValueError as e: + return {"error": str(e)}, 404 + + @folder_bp.route("/unfile", methods=["POST"]) + def remove_file_from_folder(): + data = request.get_json() + if not data or not data.get("file_id"): + return {"error": "file_id is required"}, 400 + try: + folder_use_case.move_file_to_folder(data["file_id"], None) + return {"message": "File removed from folder"}, 200 + except ValueError as e: + return {"error": str(e)}, 404 + + return folder_bp diff --git a/Backend/Web/app_factory.py b/Backend/Web/app_factory.py index 8bedb5a..068c3b1 100644 --- a/Backend/Web/app_factory.py +++ b/Backend/Web/app_factory.py @@ -4,6 +4,7 @@ from Backend.Infrastructure.persistence.LocalFileStorage import LocalFileStorage from Backend.Infrastructure.persistence.ConversationRepository import SQLiteConversationRepository from Backend.Infrastructure.persistence.FaultTreeRepository import SQLiteFaultTreeRepository +from Backend.Infrastructure.persistence.FolderRepository import SQLiteFolderRepository from Backend.Infrastructure.document.DocumentProcessorPro import DocumentProcessorPro from Backend.Infrastructure.vectorstore.ChromaVectorStoreRepository import ChromaVectorStoreRepository from Backend.Infrastructure.llm.LLMService import LLMService @@ -12,10 +13,12 @@ from Backend.Application.UseCases.ChatUseCase import ChatUseCase from Backend.Application.UseCases.DeleteConversationUseCase import DeleteConversationUseCase from Backend.Application.UseCases.FaultTreeUseCase import FaultTreeUseCase +from Backend.Application.UseCases.FolderUseCase import FolderUseCase from Backend.Application.Skills.FaultTreeSkill import FaultTreeSkill from Backend.Web.Endpoints.FileEndpoint import create_file_blueprint from Backend.Web.Endpoints.ChatEndpoint import create_chat_blueprint from Backend.Web.Endpoints.FaultTreeEndpoint import create_fault_tree_blueprint +from Backend.Web.Endpoints.FolderEndpoint import create_folder_blueprint def create_app() -> Flask: @@ -31,6 +34,7 @@ def create_app() -> Flask: vector_store_repository = ChromaVectorStoreRepository(persist_directory="db") conversation_repository = SQLiteConversationRepository() fault_tree_repository = SQLiteFaultTreeRepository() + folder_repository = SQLiteFolderRepository() llm_service = LLMService() # Skills 组装 @@ -61,6 +65,10 @@ def create_app() -> Flask: fault_tree_use_case = FaultTreeUseCase( fault_tree_repository=fault_tree_repository, ) + folder_use_case = FolderUseCase( + folder_repository=folder_repository, + file_repository=file_repository, + ) # 注册 Blueprint file_bp = create_file_blueprint(import_use_case, delete_use_case, file_repository) @@ -72,4 +80,7 @@ def create_app() -> Flask: fault_tree_bp = create_fault_tree_blueprint(fault_tree_use_case) app.register_blueprint(fault_tree_bp) + folder_bp = create_folder_blueprint(folder_use_case) + app.register_blueprint(folder_bp) + return app diff --git a/tests/test_folder_api.py b/tests/test_folder_api.py new file mode 100644 index 0000000..02c922a --- /dev/null +++ b/tests/test_folder_api.py @@ -0,0 +1,326 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""文件夹管理模块 API 测试 —— 覆盖文件夹增删改查及文件归类操作""" + +import os +import time +import requests + +BASE_URL = "http://127.0.0.1:8080" +TEST_DIR = os.path.dirname(os.path.abspath(__file__)) + + +# ────────────────── 辅助函数 ────────────────── + +def _create_test_file(filename: str, content: str) -> str: + filepath = os.path.join(TEST_DIR, filename) + with open(filepath, "w", encoding="utf-8") as f: + f.write(content) + return filepath + + +def _cleanup_test_file(filename: str): + filepath = os.path.join(TEST_DIR, filename) + if os.path.exists(filepath): + os.remove(filepath) + + +def _upload_file(filename: str, content: str) -> dict: + """上传一个测试文件并等待向量化完成,返回文件实体 dict。""" + filepath = _create_test_file(filename, content) + try: + with open(filepath, "rb") as f: + resp = requests.post( + f"{BASE_URL}/api/files", + files={"file": (filename, f, "text/plain")}, + timeout=10, + ) + assert resp.status_code == 202, f"上传失败: {resp.status_code} {resp.text}" + file_entity = resp.json() + + # 等待向量化完成(最多 60 秒) + for _ in range(60): + updated = requests.get(f"{BASE_URL}/api/files", timeout=5).json() + match = [fe for fe in updated if fe["id"] == file_entity["id"]] + if match and match[0]["status"] in ("embedded", "failed"): + return match[0] + time.sleep(1) + return file_entity + finally: + _cleanup_test_file(filename) + + +def _delete_file(file_name: str): + requests.delete(f"{BASE_URL}/api/files/{file_name}", timeout=10) + + +# ────────────────── 文件夹 CRUD 测试 ────────────────── + +def test_create_folder(): + """测试 1: 创建文件夹""" + print("\n" + "=" * 60) + print("TEST 1: POST /api/folders — 创建文件夹") + print("=" * 60) + + resp = requests.post(f"{BASE_URL}/api/folders", json={"name": "安全分析"}, timeout=10) + result = resp.json() + + print(f"[✓] 状态码: {resp.status_code}") + print(f"[✓] 文件夹: id={result.get('id')}, name={result.get('name')}") + + assert resp.status_code == 201 + assert result["name"] == "安全分析" + return result["id"] + + +def test_create_folder_duplicate(): + """测试 2: 创建重名文件夹应返回 409""" + print("\n" + "=" * 60) + print("TEST 2: POST /api/folders — 重名文件夹") + print("=" * 60) + + resp = requests.post(f"{BASE_URL}/api/folders", json={"name": "安全分析"}, timeout=10) + print(f"[✓] 状态码: {resp.status_code}") + print(f"[✓] 错误: {resp.json().get('error')}") + assert resp.status_code == 409 + + +def test_create_folder_empty(): + """测试 3: 空名称应返回 400""" + print("\n" + "=" * 60) + print("TEST 3: POST /api/folders — 空名称") + print("=" * 60) + + resp = requests.post(f"{BASE_URL}/api/folders", json={"name": ""}, timeout=10) + print(f"[✓] 状态码: {resp.status_code}") + print(f"[✓] 错误: {resp.json().get('error')}") + assert resp.status_code == 400 + + +def test_list_folders(): + """测试 4: 获取文件夹列表""" + print("\n" + "=" * 60) + print("TEST 4: GET /api/folders — 文件夹列表") + print("=" * 60) + + resp = requests.get(f"{BASE_URL}/api/folders", timeout=10) + folders = resp.json() + + print(f"[✓] 状态码: {resp.status_code}") + print(f"[✓] 文件夹数: {len(folders)}") + for f in folders: + print(f" - {f['name']} (文件数: {len(f.get('files', []))})") + + assert resp.status_code == 200 + assert isinstance(folders, list) + assert len(folders) >= 1 + + +def test_rename_folder(folder_id: str): + """测试 5: 重命名文件夹""" + print("\n" + "=" * 60) + print("TEST 5: PATCH /api/folders/ — 重命名") + print("=" * 60) + + resp = requests.patch( + f"{BASE_URL}/api/folders/{folder_id}", + json={"name": "故障分析"}, + timeout=10, + ) + result = resp.json() + + print(f"[✓] 状态码: {resp.status_code}") + print(f"[✓] 新名称: {result.get('name')}") + + assert resp.status_code == 200 + assert result["name"] == "故障分析" + + +def test_rename_nonexistent(): + """测试 6: 重命名不存在的文件夹""" + print("\n" + "=" * 60) + print("TEST 6: PATCH /api/folders/<不存在> — 404") + print("=" * 60) + + resp = requests.patch( + f"{BASE_URL}/api/folders/no-such-id", + json={"name": "x"}, + timeout=10, + ) + print(f"[✓] 状态码: {resp.status_code}") + assert resp.status_code == 404 + + +# ────────────────── 文件归类测试 ────────────────── + +def test_move_file_to_folder(folder_id: str, file_id: str): + """测试 7: 将文件移入文件夹""" + print("\n" + "=" * 60) + print("TEST 7: POST /api/folders//files — 移入文件") + print("=" * 60) + + resp = requests.post( + f"{BASE_URL}/api/folders/{folder_id}/files", + json={"file_id": file_id}, + timeout=10, + ) + print(f"[✓] 状态码: {resp.status_code}") + print(f"[✓] {resp.json()}") + assert resp.status_code == 200 + + +def test_verify_file_in_folder(folder_id: str, file_id: str): + """测试 8: 验证文件已出现在文件夹列表中""" + print("\n" + "=" * 60) + print("TEST 8: GET /api/folders — 验证文件归类") + print("=" * 60) + + resp = requests.get(f"{BASE_URL}/api/folders", timeout=10) + folders = resp.json() + target = [f for f in folders if f["id"] == folder_id] + + assert len(target) == 1, "目标文件夹未找到" + file_ids = [fi["id"] for fi in target[0].get("files", [])] + print(f"[✓] 文件夹 '{target[0]['name']}' 中的文件: {file_ids}") + assert file_id in file_ids, "文件未出现在文件夹中" + print(f"[✓] 文件 {file_id} 已确认在文件夹内") + + +def test_file_list_has_folder_id(file_id: str, folder_id: str): + """测试 9: 文件列表中 folder_id 字段正确""" + print("\n" + "=" * 60) + print("TEST 9: GET /api/files — 文件的 folder_id 字段") + print("=" * 60) + + resp = requests.get(f"{BASE_URL}/api/files", timeout=10) + files = resp.json() + target = [f for f in files if f["id"] == file_id] + + assert len(target) == 1 + print(f"[✓] 文件 folder_id = {target[0].get('folder_id')}") + assert target[0].get("folder_id") == folder_id + + +def test_remove_file_from_folder(file_id: str): + """测试 10: 将文件移出文件夹""" + print("\n" + "=" * 60) + print("TEST 10: POST /api/folders/unfile — 移出文件") + print("=" * 60) + + resp = requests.post( + f"{BASE_URL}/api/folders/unfile", + json={"file_id": file_id}, + timeout=10, + ) + print(f"[✓] 状态码: {resp.status_code}") + assert resp.status_code == 200 + + # 确认 folder_id 已清空 + files = requests.get(f"{BASE_URL}/api/files", timeout=10).json() + target = [f for f in files if f["id"] == file_id] + assert target[0].get("folder_id") is None + print(f"[✓] 文件 folder_id 已清空") + + +# ────────────────── 删除文件夹测试 ────────────────── + +def test_delete_folder(folder_id: str): + """测试 11: 删除文件夹""" + print("\n" + "=" * 60) + print("TEST 11: DELETE /api/folders/ — 删除文件夹") + print("=" * 60) + + resp = requests.delete(f"{BASE_URL}/api/folders/{folder_id}", timeout=10) + print(f"[✓] 状态码: {resp.status_code}") + assert resp.status_code == 200 + + # 确认已删除 + folders = requests.get(f"{BASE_URL}/api/folders", timeout=10).json() + assert all(f["id"] != folder_id for f in folders) + print(f"[✓] 文件夹已从列表中移除") + + +def test_delete_nonexistent(): + """测试 12: 删除不存在的文件夹""" + print("\n" + "=" * 60) + print("TEST 12: DELETE /api/folders/<不存在> — 404") + print("=" * 60) + + resp = requests.delete(f"{BASE_URL}/api/folders/no-such-id", timeout=10) + print(f"[✓] 状态码: {resp.status_code}") + assert resp.status_code == 404 + + +# ────────────────── 主流程 ────────────────── + +def main(): + print("\n╔" + "=" * 58 + "╗") + print("║" + " 文件夹管理 + 文件归类 — API 综合测试".center(50) + "║") + print("╚" + "=" * 58 + "╝") + + passed = 0 + failed = 0 + folder_id = None + file_entity = None + + def _run(name, fn, *args): + nonlocal passed, failed + try: + result = fn(*args) + passed += 1 + return result + except Exception as e: + print(f"[✗ FAIL] {name}: {e}") + failed += 1 + return None + + # ── 上传一个测试文件 ── + print("\n>>> 准备:上传测试文件 <<<") + file_entity = _run("上传测试文件", _upload_file, "folder_test.txt", "文件夹功能测试文本。\n" * 20) + file_id = file_entity["id"] if file_entity else None + file_name = file_entity["file_name"] if file_entity else None + if file_entity: + print(f"[✓] 测试文件就绪: id={file_id}, status={file_entity['status']}") + + # ── 文件夹 CRUD ── + print("\n>>> 文件夹 CRUD 测试 <<<") + folder_id = _run("创建文件夹", test_create_folder) + _run("重名文件夹", test_create_folder_duplicate) + _run("空名称", test_create_folder_empty) + _run("文件夹列表", test_list_folders) + if folder_id: + _run("重命名", test_rename_folder, folder_id) + _run("重命名不存在", test_rename_nonexistent) + + # ── 文件归类 ── + if folder_id and file_id: + print("\n>>> 文件归类测试 <<<") + _run("移入文件", test_move_file_to_folder, folder_id, file_id) + _run("验证归类", test_verify_file_in_folder, folder_id, file_id) + _run("文件 folder_id", test_file_list_has_folder_id, file_id, folder_id) + _run("移出文件", test_remove_file_from_folder, file_id) + else: + print("\n[⚠ SKIP] 缺少文件夹或文件,跳过归类测试") + + # ── 删除 ── + print("\n>>> 删除测试 <<<") + if folder_id: + _run("删除文件夹", test_delete_folder, folder_id) + _run("删除不存在", test_delete_nonexistent) + + # ── 清理测试文件 ── + if file_name: + _delete_file(file_name) + print(f"\n[清理] 已删除测试文件: {file_name}") + + # ── 汇总 ── + print("\n" + "=" * 60) + print(f"结果: {passed} 通过, {failed} 失败") + print("=" * 60 + "\n") + + return failed == 0 + + +if __name__ == "__main__": + success = main() + exit(0 if success else 1) diff --git a/uploads/folder_test.txt b/uploads/folder_test.txt new file mode 100644 index 0000000..f15315d --- /dev/null +++ b/uploads/folder_test.txt @@ -0,0 +1,20 @@ +文件夹功能测试文本。 +文件夹功能测试文本。 +文件夹功能测试文本。 +文件夹功能测试文本。 +文件夹功能测试文本。 +文件夹功能测试文本。 +文件夹功能测试文本。 +文件夹功能测试文本。 +文件夹功能测试文本。 +文件夹功能测试文本。 +文件夹功能测试文本。 +文件夹功能测试文本。 +文件夹功能测试文本。 +文件夹功能测试文本。 +文件夹功能测试文本。 +文件夹功能测试文本。 +文件夹功能测试文本。 +文件夹功能测试文本。 +文件夹功能测试文本。 +文件夹功能测试文本。