diff --git a/test-int/cli/test_cli_tool_delete_note_integration.py b/test-int/cli/test_cli_tool_delete_note_integration.py new file mode 100644 index 00000000..aaee6dd0 --- /dev/null +++ b/test-int/cli/test_cli_tool_delete_note_integration.py @@ -0,0 +1,268 @@ +"""Integration tests for `basic-memory tool delete-note`.""" + +import json +from pathlib import Path +from typing import Any + +from typer.testing import CliRunner + +from basic_memory.cli.main import app as cli_app + +runner = CliRunner() + + +def _write_note( + title: str, + folder: str, + content: str, + *, + project: str | None = None, +) -> dict[str, Any]: + args = [ + "tool", + "write-note", + "--title", + title, + "--folder", + folder, + "--content", + content, + ] + if project is not None: + args.extend(["--project", project]) + + result = runner.invoke(cli_app, args) + assert result.exit_code == 0, result.output + return json.loads(result.stdout) + + +def _read_note(identifier: str, *, project: str | None = None) -> dict[str, Any]: + args = ["tool", "read-note", identifier] + if project is not None: + args.extend(["--project", project]) + + result = runner.invoke(cli_app, args) + assert result.exit_code == 0, result.output + return json.loads(result.stdout) + + +def _delete_note( + identifier: str, + *, + is_directory: bool = False, + project: str | None = None, + project_id: str | None = None, + local: bool = False, +) -> tuple[int, dict[str, Any], str]: + args = ["tool", "delete-note", identifier] + if is_directory: + args.append("--is-directory") + if project is not None: + args.extend(["--project", project]) + if project_id is not None: + args.extend(["--project-id", project_id]) + if local: + args.append("--local") + + result = runner.invoke(cli_app, args) + payload = json.loads(result.stdout) if result.stdout else {} + return result.exit_code, payload, result.output + + +def _search_notes(query: str, *, page_size: int = 20) -> dict[str, Any]: + result = runner.invoke( + cli_app, + ["tool", "search-notes", query, "--page-size", str(page_size)], + ) + assert result.exit_code == 0, result.output + return json.loads(result.stdout) + + +def _project_file(test_project, file_path: str) -> Path: + return Path(test_project.path) / file_path + + +def test_delete_note_removes_file_database_record_and_search_result( + app, app_config, test_project, config_manager +) -> None: + """Single-note deletion removes the note from every user-visible surface.""" + note = _write_note( + "CLI Delete Single Note", + "delete-cli", + "# CLI Delete Single Note\n\nUniqueSingleDeleteToken\n\n- [status] ready to delete", + ) + note_path = _project_file(test_project, note["file_path"]) + assert note_path.exists() + + exit_code, payload, output = _delete_note(note["permalink"]) + + assert exit_code == 0, output + assert payload == { + "deleted": True, + "title": "CLI Delete Single Note", + "permalink": note["permalink"], + "file_path": note["file_path"], + } + assert not note_path.exists() + + missing = _read_note(note["permalink"]) + assert missing["title"] is None + assert missing["permalink"] is None + assert missing["content"] is None + + search = _search_notes("UniqueSingleDeleteToken") + assert search["total"] == 0 + assert search["results"] == [] + + +def test_delete_note_not_found_returns_json_without_error( + app, app_config, test_project, config_manager +) -> None: + """A missing note is machine-readable and does not produce a CLI failure.""" + exit_code, payload, output = _delete_note("delete-cli/missing-note") + + assert exit_code == 0, output + assert payload == { + "deleted": False, + "title": None, + "permalink": None, + "file_path": None, + } + + +def test_delete_note_case_mismatch_does_not_delete_exact_note( + app, app_config, test_project, config_manager +) -> None: + """Strict CLI deletes must not fuzzy-match a differently cased title.""" + note = _write_note( + "CLI CamelCase Delete Note", + "delete-cli", + "# CLI CamelCase Delete Note\n\nCaseSensitiveDeleteToken", + ) + + exit_code, payload, output = _delete_note("cli camelcase delete note") + + assert exit_code == 0, output + assert payload["deleted"] is False + still_there = _read_note(note["permalink"]) + assert still_there["title"] == "CLI CamelCase Delete Note" + assert "CaseSensitiveDeleteToken" in still_there["content"] + + +def test_delete_note_project_id_takes_precedence_over_wrong_project_name( + app, app_config, test_project, config_manager +) -> None: + """CLI `--project-id` routes destructive operations to the exact project.""" + note = _write_note( + "CLI Delete By Project ID", + "delete-cli", + "# CLI Delete By Project ID\n\nProjectIdDeleteToken", + ) + + exit_code, payload, output = _delete_note( + note["file_path"], + project="not-the-test-project", + project_id=test_project.external_id, + ) + + assert exit_code == 0, output + assert payload["deleted"] is True + assert payload["title"] == "CLI Delete By Project ID" + assert _read_note(note["permalink"])["title"] is None + + +def test_delete_note_memory_url_detects_project_from_identifier( + app, app_config, test_project, config_manager +) -> None: + """A memory:// URL can select the project without a separate --project flag.""" + note = _write_note( + "CLI Delete Memory URL", + "delete-cli", + "# CLI Delete Memory URL\n\nMemoryUrlDeleteToken", + project=test_project.name, + ) + memory_url = f"memory://{test_project.name}/{note['permalink']}" + + exit_code, payload, output = _delete_note(memory_url) + + assert exit_code == 0, output + assert payload["deleted"] is True + assert payload["permalink"] == note["permalink"] + assert _read_note(note["permalink"], project=test_project.name)["title"] is None + + +def test_delete_directory_removes_nested_files_database_records_and_search_results( + app, app_config, test_project, config_manager +) -> None: + """Directory deletion removes nested notes and reports a complete JSON summary.""" + notes = [ + _write_note( + "CLI Delete Directory Root", + "delete-cli-dir", + "# CLI Delete Directory Root\n\nDirectoryDeleteTokenRoot", + ), + _write_note( + "CLI Delete Directory Child", + "delete-cli-dir/child", + "# CLI Delete Directory Child\n\nDirectoryDeleteTokenChild", + ), + _write_note( + "CLI Delete Directory Deep Child", + "delete-cli-dir/child/deep", + "# CLI Delete Directory Deep Child\n\nDirectoryDeleteTokenDeep", + ), + ] + note_paths = [_project_file(test_project, note["file_path"]) for note in notes] + assert all(path.exists() for path in note_paths) + + exit_code, payload, output = _delete_note("delete-cli-dir", is_directory=True, local=True) + + assert exit_code == 0, output + assert payload["deleted"] is True + assert payload["is_directory"] is True + assert payload["identifier"] == "delete-cli-dir" + assert payload["total_files"] == 3 + assert payload["successful_deletes"] == 3 + assert payload["failed_deletes"] == 0 + assert payload["errors"] == [] + assert set(payload["deleted_files"]) == {note["file_path"] for note in notes} + assert not any(path.exists() for path in note_paths) + + for note in notes: + assert _read_note(note["permalink"])["title"] is None + + search = _search_notes("DirectoryDeleteToken") + assert search["total"] == 0 + assert search["results"] == [] + + +def test_delete_directory_without_flag_does_not_delete_child_notes( + app, app_config, test_project, config_manager +) -> None: + """The CLI must not treat a directory path as destructive without --is-directory.""" + note = _write_note( + "CLI Delete Directory Safety", + "delete-cli-safety", + "# CLI Delete Directory Safety\n\nDirectorySafetyToken", + ) + + exit_code, payload, output = _delete_note("delete-cli-safety") + + assert exit_code == 0, output + assert payload["deleted"] is False + still_there = _read_note(note["permalink"]) + assert still_there["title"] == "CLI Delete Directory Safety" + assert _project_file(test_project, note["file_path"]).exists() + + +def test_delete_note_rejects_conflicting_routing_flags( + app, app_config, test_project, config_manager +) -> None: + """delete-note validates the same --local/--cloud conflict as other tool commands.""" + result = runner.invoke( + cli_app, + ["tool", "delete-note", "delete-cli/missing-note", "--local", "--cloud"], + ) + + assert result.exit_code != 0 + assert "Cannot specify both --local and --cloud" in result.output