From 5adc0a975b2ce64c500b13db523cdcb45518f40e Mon Sep 17 00:00:00 2001 From: John Bencina Date: Mon, 19 May 2025 19:39:03 -0700 Subject: [PATCH 1/6] Add cli settings unit test --- src/vecsync/cli/settings.py | 1 + tests/cli/test_cli_settings.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+) create mode 100644 tests/cli/test_cli_settings.py diff --git a/src/vecsync/cli/settings.py b/src/vecsync/cli/settings.py index 0f66a98..5c5936e 100644 --- a/src/vecsync/cli/settings.py +++ b/src/vecsync/cli/settings.py @@ -9,6 +9,7 @@ def clear(): """Clear the settings file.""" settings = Settings() settings.delete() + click.echo(colored("Settings file cleared.", "green")) @click.command() diff --git a/tests/cli/test_cli_settings.py b/tests/cli/test_cli_settings.py new file mode 100644 index 0000000..b36506c --- /dev/null +++ b/tests/cli/test_cli_settings.py @@ -0,0 +1,32 @@ +from click.testing import CliRunner + +import vecsync.cli.settings as cli +from vecsync.settings import Settings + + +def test_settings_show(monkeypatch, tmp_path): + # Mock the Settings class and its methods + settings_file = tmp_path / "settings.json" + monkeypatch.setattr("vecsync.cli.settings.Settings", lambda: Settings(settings_file)) + + runner = CliRunner() + result = runner.invoke(cli.show) + assert result.exit_code == 0 + assert f"Settings file location: {settings_file}" in result.output + assert "Settings file data:" in result.output + + +def test_settings_delete(monkeypatch, tmp_path): + # Mock the Settings class and its methods + settings_file = tmp_path / "settings.json" + settings = Settings(settings_file) + settings["key"] = "value" + + monkeypatch.setattr("vecsync.cli.settings.Settings", lambda: Settings(settings_file)) + + runner = CliRunner() + result = runner.invoke(cli.clear) + + assert result.exit_code == 0 + assert "Settings file cleared." in result.output + assert not settings_file.exists() From 43fd06dd19396c1f48305e5234825a899e8d2f27 Mon Sep 17 00:00:00 2001 From: John Bencina Date: Mon, 19 May 2025 23:01:44 -0700 Subject: [PATCH 2/6] Remove extra file --- src/vecsync/cli.py | 150 --------------------------------------------- 1 file changed, 150 deletions(-) delete mode 100644 src/vecsync/cli.py diff --git a/src/vecsync/cli.py b/src/vecsync/cli.py deleted file mode 100644 index e4573c2..0000000 --- a/src/vecsync/cli.py +++ /dev/null @@ -1,150 +0,0 @@ -import click -from dotenv import load_dotenv -from termcolor import cprint - -from vecsync.chat.clients.openai import OpenAIClient -from vecsync.chat.interface import ConsoleInterface, GradioInterface -from vecsync.settings import Settings -from vecsync.store.file import FileStore -from vecsync.store.openai import OpenAiVectorStore -from vecsync.store.zotero import ZoteroStore - -# --- Store commands --- -load_dotenv(override=True) - - -@click.command() -def files(): - """List files in the remote vector store.""" - store = OpenAiVectorStore("test") - files = store.get_files() - - cprint(f"Files in store {store.name}:", "green") - for file in files: - cprint(f" - {file.name}", "yellow") - - -@click.command() -def delete(): - """Delete all files in the remote vector store.""" - vstore = OpenAiVectorStore("test") - vstore.delete() - - -@click.group() -def store(): - """Manage the vector store.""" - pass - - -store.add_command(files) -store.add_command(delete) - -# --- Sync command (default behavior) --- - - -@click.command() -@click.option( - "--source", - "-s", - type=str, - default="file", - help="Choose the source (file or zotero).", -) -def sync(source: str): - """Sync files from local to remote vector store.""" - if source == "file": - store = FileStore() - elif source == "zotero": - try: - store = ZoteroStore.client() - except FileNotFoundError as e: - cprint(f'Zotero not found at "{str(e)}". Aborting.', "red") - return - else: - raise ValueError("Invalid source. Use 'file' or 'zotero'.") - - vstore = OpenAiVectorStore("test") - vstore.get_or_create() - - files = store.get_files() - - cprint(f"Synching {len(files)} files from local to OpenAI", "green") - - result = vstore.sync(files) - cprint("🏁 Sync results:", "green") - cprint( - f"Saved: {result.files_saved} | Deleted: {result.files_deleted} | Skipped: {result.files_skipped} ", - "yellow", - ) - cprint(f"Remote count: {result.remote_count}", "yellow") - cprint(f"Duration: {result.duration:.2f} seconds", "yellow") - - -# --- Assistant commands --- - - -@click.command("chat") -def chat_assistant(): - """Chat with the assistant.""" - client = OpenAIClient("test") - ui = ConsoleInterface(client) - print('Type "exit" to quit at any time.') - - while True: - print() - prompt = input("> ") - if prompt.lower() == "exit": - break - ui.prompt(prompt) - - -@click.command("ui") -def chat_ui(): - client = OpenAIClient("test") - ui = GradioInterface(client) - ui.chat_interface() - - -@click.group() -def assistant(): - """Assistant commands.""" - pass - - -assistant.add_command(chat_assistant) -assistant.add_command(chat_ui) - -# --- Settings commands --- - - -@click.command("clear") -def clear_settings(): - """Clear the settings file.""" - settings = Settings() - settings.delete() - - -@click.group() -def settings(): - pass - - -settings.add_command(clear_settings) - -# --- CLI Group (main entry point) --- - - -@click.group() -def cli(): - """vecsync CLI tool""" - pass - - -cli.add_command(store) -cli.add_command(sync) -cli.add_command(assistant) -cli.add_command(settings) - -if __name__ == "__main__": - cli() From 5104af848ee36483e821ca5f07558fb44302b74c Mon Sep 17 00:00:00 2001 From: John Bencina Date: Mon, 19 May 2025 23:01:56 -0700 Subject: [PATCH 3/6] Exclude entry script --- src/vecsync/cli/entry.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/vecsync/cli/entry.py b/src/vecsync/cli/entry.py index 172e2c5..46ec859 100644 --- a/src/vecsync/cli/entry.py +++ b/src/vecsync/cli/entry.py @@ -1,3 +1,5 @@ +# pragma: exclude file + import click from vecsync.cli.assistants import group as assistants_group From 18ed0bc40506f65b108cf33629860ad2e0a546d6 Mon Sep 17 00:00:00 2001 From: John Bencina Date: Mon, 19 May 2025 23:02:14 -0700 Subject: [PATCH 4/6] Move mock classes --- tests/conftest.py | 303 ++++++++++++++++++++++++++++++++++++++- tests/openai/conftest.py | 303 --------------------------------------- 2 files changed, 302 insertions(+), 304 deletions(-) delete mode 100644 tests/openai/conftest.py diff --git a/tests/conftest.py b/tests/conftest.py index 2576ff8..57409cb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,8 +1,18 @@ +import os import sqlite3 +from datetime import datetime +from types import SimpleNamespace +from typing import Any +import pytest +from pydantic import BaseModel from pytest import fixture +import vecsync.chat.clients.openai as client_mod +from vecsync.chat.clients.openai import OpenAIClient, OpenAIHandler +from vecsync.chat.formatter import ConsoleFormatter from vecsync.settings import SettingExists, SettingMissing, Settings +from vecsync.store.openai import OpenAiVectorStore @fixture(scope="session") @@ -36,7 +46,7 @@ def settings_mock(): return MockSettings -@fixture(scope="session") +@fixture def zotero_db_mock(tmp_path_factory): # Prepare a fake DB dbfile = tmp_path_factory.mktemp("db") / "zotero.sqlite" @@ -48,3 +58,294 @@ def zotero_db_mock(tmp_path_factory): conn.close() return dbfile + + +class MockAssistant(BaseModel): + id: str + name: str + + +class MockThread(BaseModel): + id: str + + +class MockMessageContentText(BaseModel): + value: str + + +class MockMessageContent(BaseModel): + type: str + text: MockMessageContentText + + +class MockMessageData(BaseModel): + content: list[MockMessageContent] + created_at: int # TODO: Check if this is really at both levels + role: str + + +class MockMessage(BaseModel): + data: MockMessageData + thread_id: str + created_at: int + + +class MockThreadMessageResponse(BaseModel): + thread_id: str + data: list[MockMessageData] + + +class MockVectorStore(BaseModel): + id: str + name: str + + +class MockFileUpload(BaseModel): + id: str + file: Any + + +class MockFile(BaseModel): + id: str + filename: str + + +class MockFileDeletedResult(BaseModel): + deleted: bool + + +class MockVectorStoreDeletedResult(BaseModel): + deleted: bool + + +class MockStreamResponseAnnotation(BaseModel): + type: str + text: str + + +class MockStreamResponseText(BaseModel): + value: str + annotations: list[MockStreamResponseAnnotation] + + +class MockStreamResponseContent(BaseModel): + type: str + text: MockStreamResponseText + + +class MockStreamResponse(BaseModel): + content: list[MockStreamResponseContent] + + +def mock_vector_store(): + vector_store = [] + file_store = [] + vector_file_store = [] + + def create_vector_store(name): + store = MockVectorStore(id=f"vector_store_{len(vector_store) + 1}", name=name) + vector_store.append(store) + return store + + def delete_vector_store(vector_store_id): + for store in vector_store: + if store.id == vector_store_id: + vector_store.remove(store) + return MockFileDeletedResult(deleted=True) + return MockFileDeletedResult(deleted=False) + + def list_vector_stores(): + return vector_store + + def list_files(): + return file_store + + def list_vector_store_files(vector_store_id): + return vector_file_store + + def delete_vector_store_file(vector_store_id, file_id): + for vector_file in vector_file_store: + if vector_file.id == file_id: + vector_file_store.remove(vector_file) + + def delete_file(file_id): + for file in file_store: + if file.id == file_id: + file_store.remove(file) + return MockFileDeletedResult(deleted=True) + return MockFileDeletedResult(deleted=False) + + def create_file(**kwargs): + base_name = os.path.basename(kwargs["file"].name) + file = MockFileUpload(id=f"file_{len(file_store) + 1}", file=kwargs["file"]) + file_store.append(MockFile(id=file.id, filename=base_name)) + return file + + def create_and_poll(vector_store_id, file_id): + for store in vector_store: + if store.id == vector_store_id: + vector_file = MockFile(id=file_id, filename=f"file_{file_id}") + vector_file_store.append(vector_file) + return vector_file + return None + + # attach methods + vs_files_ns = SimpleNamespace() + vs_files_ns.list = list_vector_store_files + vs_files_ns.delete = delete_vector_store_file + vs_files_ns.create_and_poll = create_and_poll + + stores_ns = SimpleNamespace() + stores_ns.create = create_vector_store + stores_ns.delete = delete_vector_store + stores_ns.list = list_vector_stores + stores_ns.files = vs_files_ns + + files_ns = SimpleNamespace() + files_ns.list = list_files + files_ns.delete = delete_file + files_ns.create = create_file + + # build your “client” + client = SimpleNamespace() + client.vector_stores = stores_ns + client.files = files_ns + + return client + + +def mock_client_backend(): + # our in‐memory store + assistant_store = [] + threads_store = [] + message_store = [] + + def create_assistant(**kwargs): + name = kwargs["name"] + assistant = MockAssistant(id=f"assistant_{name}_{len(assistant_store) + 1}", name=name) + assistant_store.append(assistant) + return assistant + + def list_assistants(): + return assistant_store + + def delete_assistant(assistant_id): + for assistant in assistant_store: + if assistant.id == assistant_id: + assistant_store.remove(assistant) + + def create_thread(**kwargs): + thread = MockThread(id=f"thread_{len(threads_store) + 1}") + threads_store.append(thread) + return thread + + def create_message(**kwargs): + created_at = int(datetime.now().timestamp()) + + message = MockMessage( + created_at=created_at, + data=MockMessageData( + created_at=created_at, + content=[MockMessageContent(type="text", text=MockMessageContentText(value=kwargs["content"]))], + role=kwargs["role"], + ), + thread_id=kwargs["thread_id"], + ) + message_store.append(message) + return message + + def list_messages(**kwargs): + thread_id = kwargs["thread_id"] + messages = [message.data for message in message_store if message.thread_id == thread_id] + return MockThreadMessageResponse(thread_id=thread_id, data=messages) + + def stream_response(**kwargs): + class StreamManager: + def __init__(self, handler): + self.handler = handler + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, traceback): + return False + + def until_done(self): + text = """This is a test message from the assistant""" + for delta in text.split(): + message = MockStreamResponse( + content=[ + MockStreamResponseContent( + type="text", text=MockStreamResponseText(value=delta, annotations=[]) + ) + ] + ) + self.handler.on_message_delta(delta=message, snapshot=None) + + self.handler.on_message_done(message=None) + + return StreamManager(handler=kwargs["event_handler"]) + + # attach methods + assistants_ns = SimpleNamespace() + assistants_ns.create = create_assistant + assistants_ns.list = list_assistants + assistants_ns.delete = delete_assistant + + messages_ns = SimpleNamespace() + messages_ns.create = create_message + messages_ns.list = list_messages + + runs_ns = SimpleNamespace() + runs_ns.stream = stream_response + + threads_ns = SimpleNamespace() + threads_ns.create = create_thread + threads_ns.messages = messages_ns + threads_ns.runs = runs_ns + + # build your “client” + client = SimpleNamespace() + client.beta = SimpleNamespace() + client.beta.assistants = assistants_ns + client.beta.threads = threads_ns + + return client + + +@pytest.fixture +def mocked_vector_store(): + store = OpenAiVectorStore(name="test_store") + store.client = mock_vector_store() + store.create() + return store + + +@pytest.fixture +def mocked_client(tmp_path, mocked_vector_store, monkeypatch): + monkeypatch.setattr(client_mod, "OpenAiVectorStore", lambda store_name: mocked_vector_store) + + settings_path = tmp_path / "settings.json" + client = OpenAIClient(store_name="test_store", settings_path=settings_path) + client.client = mock_client_backend() + + return client + + +@pytest.fixture +def mocked_client_handler(): + return OpenAIHandler( + files={"file_1", "filename.txt"}, + formatter=ConsoleFormatter(), + ) + + +@pytest.fixture +def create_test_upload(tmp_path): + files = set() + for i in range(3): + file = tmp_path / f"test_file_{i}.txt" + files.add(file) + with open(file, "w") as f: + f.write(f"This is test file {i}") + return files diff --git a/tests/openai/conftest.py b/tests/openai/conftest.py deleted file mode 100644 index 6360fe9..0000000 --- a/tests/openai/conftest.py +++ /dev/null @@ -1,303 +0,0 @@ -import os -from datetime import datetime -from types import SimpleNamespace -from typing import Any - -import pytest -from pydantic import BaseModel - -import vecsync.chat.clients.openai as client_mod -from vecsync.chat.clients.openai import OpenAIClient, OpenAIHandler -from vecsync.chat.formatter import ConsoleFormatter -from vecsync.store.openai import OpenAiVectorStore - - -class MockAssistant(BaseModel): - id: str - name: str - - -class MockThread(BaseModel): - id: str - - -class MockMessageContentText(BaseModel): - value: str - - -class MockMessageContent(BaseModel): - type: str - text: MockMessageContentText - - -class MockMessageData(BaseModel): - content: list[MockMessageContent] - created_at: int # TODO: Check if this is really at both levels - role: str - - -class MockMessage(BaseModel): - data: MockMessageData - thread_id: str - created_at: int - - -class MockThreadMessageResponse(BaseModel): - thread_id: str - data: list[MockMessageData] - - -class MockVectorStore(BaseModel): - id: str - name: str - - -class MockFileUpload(BaseModel): - id: str - file: Any - - -class MockFile(BaseModel): - id: str - filename: str - - -class MockFileDeletedResult(BaseModel): - deleted: bool - - -class MockVectorStoreDeletedResult(BaseModel): - deleted: bool - - -class MockStreamResponseAnnotation(BaseModel): - type: str - text: str - - -class MockStreamResponseText(BaseModel): - value: str - annotations: list[MockStreamResponseAnnotation] - - -class MockStreamResponseContent(BaseModel): - type: str - text: MockStreamResponseText - - -class MockStreamResponse(BaseModel): - content: list[MockStreamResponseContent] - - -def mock_vector_store(): - vector_store = [] - file_store = [] - vector_file_store = [] - - def create_vector_store(name): - store = MockVectorStore(id=f"vector_store_{len(vector_store) + 1}", name=name) - vector_store.append(store) - return store - - def delete_vector_store(vector_store_id): - for store in vector_store: - if store.id == vector_store_id: - vector_store.remove(store) - return MockFileDeletedResult(deleted=True) - return MockFileDeletedResult(deleted=False) - - def list_vector_stores(): - return vector_store - - def list_files(): - return file_store - - def list_vector_store_files(vector_store_id): - return vector_file_store - - def delete_vector_store_file(vector_store_id, file_id): - for vector_file in vector_file_store: - if vector_file.id == file_id: - vector_file_store.remove(vector_file) - - def delete_file(file_id): - for file in file_store: - if file.id == file_id: - file_store.remove(file) - return MockFileDeletedResult(deleted=True) - return MockFileDeletedResult(deleted=False) - - def create_file(**kwargs): - base_name = os.path.basename(kwargs["file"].name) - file = MockFileUpload(id=f"file_{len(file_store) + 1}", file=kwargs["file"]) - file_store.append(MockFile(id=file.id, filename=base_name)) - return file - - def create_and_poll(vector_store_id, file_id): - for store in vector_store: - if store.id == vector_store_id: - vector_file = MockFile(id=file_id, filename=f"file_{file_id}") - vector_file_store.append(vector_file) - return vector_file - return None - - # attach methods - vs_files_ns = SimpleNamespace() - vs_files_ns.list = list_vector_store_files - vs_files_ns.delete = delete_vector_store_file - vs_files_ns.create_and_poll = create_and_poll - - stores_ns = SimpleNamespace() - stores_ns.create = create_vector_store - stores_ns.delete = delete_vector_store - stores_ns.list = list_vector_stores - stores_ns.files = vs_files_ns - - files_ns = SimpleNamespace() - files_ns.list = list_files - files_ns.delete = delete_file - files_ns.create = create_file - - # build your “client” - client = SimpleNamespace() - client.vector_stores = stores_ns - client.files = files_ns - - return client - - -def mock_client_backend(): - # our in‐memory store - assistant_store = [] - threads_store = [] - message_store = [] - - def create_assistant(**kwargs): - name = kwargs["name"] - assistant = MockAssistant(id=f"assistant_{name}_{len(assistant_store) + 1}", name=name) - assistant_store.append(assistant) - return assistant - - def list_assistants(): - return assistant_store - - def delete_assistant(assistant_id): - for assistant in assistant_store: - if assistant.id == assistant_id: - assistant_store.remove(assistant) - - def create_thread(**kwargs): - thread = MockThread(id=f"thread_{len(threads_store) + 1}") - threads_store.append(thread) - return thread - - def create_message(**kwargs): - created_at = int(datetime.now().timestamp()) - - message = MockMessage( - created_at=created_at, - data=MockMessageData( - created_at=created_at, - content=[MockMessageContent(type="text", text=MockMessageContentText(value=kwargs["content"]))], - role=kwargs["role"], - ), - thread_id=kwargs["thread_id"], - ) - message_store.append(message) - return message - - def list_messages(**kwargs): - thread_id = kwargs["thread_id"] - messages = [message.data for message in message_store if message.thread_id == thread_id] - return MockThreadMessageResponse(thread_id=thread_id, data=messages) - - def stream_response(**kwargs): - class StreamManager: - def __init__(self, handler): - self.handler = handler - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc_value, traceback): - return False - - def until_done(self): - text = """This is a test message from the assistant""" - for delta in text.split(): - message = MockStreamResponse( - content=[ - MockStreamResponseContent( - type="text", text=MockStreamResponseText(value=delta, annotations=[]) - ) - ] - ) - self.handler.on_message_delta(delta=message, snapshot=None) - - self.handler.on_message_done(message=None) - - return StreamManager(handler=kwargs["event_handler"]) - - # attach methods - assistants_ns = SimpleNamespace() - assistants_ns.create = create_assistant - assistants_ns.list = list_assistants - assistants_ns.delete = delete_assistant - - messages_ns = SimpleNamespace() - messages_ns.create = create_message - messages_ns.list = list_messages - - runs_ns = SimpleNamespace() - runs_ns.stream = stream_response - - threads_ns = SimpleNamespace() - threads_ns.create = create_thread - threads_ns.messages = messages_ns - threads_ns.runs = runs_ns - - # build your “client” - client = SimpleNamespace() - client.beta = SimpleNamespace() - client.beta.assistants = assistants_ns - client.beta.threads = threads_ns - - return client - - -@pytest.fixture -def mocked_vector_store(): - store = OpenAiVectorStore(name="test_store") - store.client = mock_vector_store() - store.create() - return store - - -@pytest.fixture -def mocked_client(tmp_path, mocked_vector_store, monkeypatch): - monkeypatch.setattr(client_mod, "OpenAiVectorStore", lambda store_name: mocked_vector_store) - - settings_path = tmp_path / "settings.json" - client = OpenAIClient(store_name="test_store", settings_path=settings_path) - client.client = mock_client_backend() - - return client - - -@pytest.fixture -def mocked_client_handler(): - return OpenAIHandler( - files={"file_1", "filename.txt"}, - formatter=ConsoleFormatter(), - ) - - -@pytest.fixture -def create_test_upload(tmp_path): - files = set() - for i in range(3): - file = tmp_path / f"test_file_{i}.txt" - files.add(file) - with open(file, "w") as f: - f.write(f"This is test file {i}") - return files From d011bd54dd766924a25ec09e45d9d7493d33202c Mon Sep 17 00:00:00 2001 From: John Bencina Date: Mon, 19 May 2025 23:02:33 -0700 Subject: [PATCH 5/6] Add CLI test cases --- tests/cli/test_cli_assistants.py | 70 ++++++++++++++++++++++++++++++++ tests/cli/test_cli_store.py | 45 ++++++++++++++++++++ tests/cli/test_cli_sync.py | 22 ++++++++++ 3 files changed, 137 insertions(+) create mode 100644 tests/cli/test_cli_assistants.py create mode 100644 tests/cli/test_cli_store.py create mode 100644 tests/cli/test_cli_sync.py diff --git a/tests/cli/test_cli_assistants.py b/tests/cli/test_cli_assistants.py new file mode 100644 index 0000000..3e199a3 --- /dev/null +++ b/tests/cli/test_cli_assistants.py @@ -0,0 +1,70 @@ +from click.testing import CliRunner + +import vecsync.cli.assistants as cli + + +def test_list_assistants_empty(monkeypatch, mocked_client): + monkeypatch.setattr("vecsync.cli.assistants.OpenAIClient", lambda store_name: mocked_client) + + runner = CliRunner() + result = runner.invoke(cli.list_assistants) + assert result.exit_code == 0 + + assert "No assistants found." in result.output + + +def test_list_assistants_non_empty(monkeypatch, mocked_client): + mocked_client.client.beta.assistants.create(name="vecsync-1") + mocked_client.client.beta.assistants.create(name="other-1") + monkeypatch.setattr("vecsync.cli.assistants.OpenAIClient", lambda store_name: mocked_client) + + runner = CliRunner() + result = runner.invoke(cli.list_assistants) + assert result.exit_code == 0 + + assert "Assistants in your OpenAI account:" in result.output + assert "vecsync-1" in result.output + assert "other-1" not in result.output + + +def test_clean_assistants_empty(monkeypatch, mocked_client): + monkeypatch.setattr("vecsync.cli.assistants.OpenAIClient", lambda store_name: mocked_client) + + runner = CliRunner() + result = runner.invoke(cli.clean) + assert result.exit_code == 0 + + assert "No deletable assistants found." in result.output + + +def test_clean_assistants_non_empty(monkeypatch, mocked_client): + mocked_client.client.beta.assistants.create(name="vecsync-1") + monkeypatch.setattr("vecsync.cli.assistants.OpenAIClient", lambda store_name: mocked_client) + + runner = CliRunner() + result = runner.invoke(cli.clean, input="y\n") + assert result.exit_code == 0 + + assert "Deleting assistant vecsync-1" in result.output + + +def test_clean_assistants_abort(monkeypatch, mocked_client): + mocked_client.client.beta.assistants.create(name="vecsync-1") + monkeypatch.setattr("vecsync.cli.assistants.OpenAIClient", lambda store_name: mocked_client) + + runner = CliRunner() + result = runner.invoke(cli.clean, input="n\n") + assert result.exit_code == 0 + + assert "Aborting" in result.output + + +def test_clean_assistants_invalid(monkeypatch, mocked_client): + mocked_client.client.beta.assistants.create(name="vecsync-1") + monkeypatch.setattr("vecsync.cli.assistants.OpenAIClient", lambda store_name: mocked_client) + + runner = CliRunner() + result = runner.invoke(cli.clean, input="f\n") + assert result.exit_code == 1 + + assert "Please enter" in result.output diff --git a/tests/cli/test_cli_store.py b/tests/cli/test_cli_store.py new file mode 100644 index 0000000..c168b21 --- /dev/null +++ b/tests/cli/test_cli_store.py @@ -0,0 +1,45 @@ +from click.testing import CliRunner + +import vecsync.cli.store as cli + + +def test_list_stores_empty(monkeypatch, mocked_vector_store): + monkeypatch.setattr("vecsync.cli.store.OpenAiVectorStore", lambda _: mocked_vector_store) + + runner = CliRunner() + result = runner.invoke(cli.list_stores) + assert result.exit_code == 0 + + assert "0 Files in store 'test_store':" in result.output + + +def test_list_stores_non_empty(monkeypatch, mocked_vector_store, tmp_path): + filename = tmp_path / "data.pdf" + with open(filename, "w") as f: + f.write("Test data") + + mocked_vector_store._upload_files({filename}) + monkeypatch.setattr("vecsync.cli.store.OpenAiVectorStore", lambda _: mocked_vector_store) + + runner = CliRunner() + result = runner.invoke(cli.list_stores) + assert result.exit_code == 0 + + assert "1 Files in store 'test_store':" in result.output + assert "data.pdf" in result.output + + +def test_delete_stores(monkeypatch, mocked_vector_store, tmp_path): + filename = tmp_path / "data.pdf" + with open(filename, "w") as f: + f.write("Test data") + + mocked_vector_store._upload_files({filename}) + monkeypatch.setattr("vecsync.cli.store.OpenAiVectorStore", lambda _: mocked_vector_store) + + runner = CliRunner() + result = runner.invoke(cli.delete) + assert result.exit_code == 0 + + assert "Deleting 1 files from" in result.output + assert "Deleting vector store" in result.output diff --git a/tests/cli/test_cli_sync.py b/tests/cli/test_cli_sync.py new file mode 100644 index 0000000..3c3992e --- /dev/null +++ b/tests/cli/test_cli_sync.py @@ -0,0 +1,22 @@ +from click.testing import CliRunner + +import vecsync.cli.sync as cli + + +def test_sync_filesource(monkeypatch, tmp_path, mocked_vector_store): + filename = tmp_path / "data.pdf" + + with open(filename, "w") as f: + f.write("Test data") + + monkeypatch.chdir(tmp_path) + monkeypatch.setattr("vecsync.cli.sync.OpenAiVectorStore", lambda _: mocked_vector_store) + + runner = CliRunner() + result = runner.invoke(cli.sync, ["--source", "file"]) + assert result.exit_code == 0 + + assert "Syncing 1 files from local to OpenAI" in result.output + assert "Saved: 1 | Deleted: 0 | Skipped: 0" in result.output + + assert len(mocked_vector_store.get_files()) == 1 From 59c513af2628aa93869f0ca5e488b291e441db34 Mon Sep 17 00:00:00 2001 From: John Bencina Date: Mon, 19 May 2025 23:05:37 -0700 Subject: [PATCH 6/6] Update changelog --- CHANGELOG.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b42743..dfefb05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,13 +4,18 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) +## [0.6.1] +### Added +- Test cases for most CLI commands [#18](https://github.com/jbencina/vecsync/issues/18) +### Changed +- Moved OpenAI mock classes for better unit test sharing + ## [0.6.0] ### Added - Added CLI commands for `assistants list` and `assistants clean` - Automatic cleanup of any extra assistants in user account when initiating chat - Added docstrings for undocumented functions - Test case coverage for most OpenAI chat and vector store operations [#15](https://github.com/jbencina/vecsync/issues/15) - ### Changed - Updated CLI chat command to `vs chat` - Refactored CLI into separate modules