mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
4cba7ba01c
Signed-off-by: phernandez <paul@basicmachines.co>
910 lines
32 KiB
Python
910 lines
32 KiB
Python
"""Tests for V2 knowledge graph API routes (ID-based endpoints)."""
|
|
|
|
import uuid
|
|
|
|
import pytest
|
|
from httpx import AsyncClient
|
|
|
|
from basic_memory.models import Project
|
|
from basic_memory.schemas import DeleteEntitiesResponse
|
|
from basic_memory.schemas.response import DirectoryMoveResult, DirectoryDeleteResult
|
|
from basic_memory.schemas.v2 import EntityResponseV2, EntityResolveResponse
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_identifier_by_permalink(
|
|
client: AsyncClient, test_graph, v2_project_url, test_project: Project, entity_repository
|
|
):
|
|
"""Test resolving an identifier by permalink returns correct entity ID."""
|
|
# test_graph fixture creates some test entities
|
|
# We'll use one of them to test resolution
|
|
|
|
# Create an entity first
|
|
entity_data = {
|
|
"title": "TestResolve",
|
|
"directory": "test",
|
|
"content": "Test content for resolve",
|
|
}
|
|
response = await client.post(f"{v2_project_url}/knowledge/entities", json=entity_data)
|
|
assert response.status_code == 200
|
|
created_entity = EntityResponseV2.model_validate(response.json())
|
|
|
|
# V2 create must return id
|
|
assert created_entity.id is not None
|
|
entity_id = created_entity.id
|
|
|
|
# Now resolve it by permalink
|
|
resolve_data = {"identifier": created_entity.permalink}
|
|
response = await client.post(f"{v2_project_url}/knowledge/resolve", json=resolve_data)
|
|
|
|
assert response.status_code == 200
|
|
resolved = EntityResolveResponse.model_validate(response.json())
|
|
assert resolved.entity_id == entity_id
|
|
assert resolved.permalink == created_entity.permalink
|
|
assert resolved.resolution_method == "permalink"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_identifier_not_found(client: AsyncClient, v2_project_url):
|
|
"""Test resolving a non-existent identifier returns 404."""
|
|
resolve_data = {"identifier": "nonexistent/entity"}
|
|
response = await client.post(f"{v2_project_url}/knowledge/resolve", json=resolve_data)
|
|
|
|
assert response.status_code == 404
|
|
assert "Entity not found" in response.json()["detail"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_identifier_no_fuzzy_match(client: AsyncClient, v2_project_url):
|
|
"""Test that resolve uses strict mode - no fuzzy search fallback.
|
|
|
|
This ensures wiki links only resolve to exact matches (permalink, title, or path),
|
|
not to similar-sounding entities via fuzzy search.
|
|
"""
|
|
# Create an entity with a specific name
|
|
entity_data = {
|
|
"title": "link-test",
|
|
"folder": "testing",
|
|
"content": "A test note",
|
|
}
|
|
response = await client.post(f"{v2_project_url}/knowledge/entities", json=entity_data)
|
|
assert response.status_code == 200
|
|
|
|
# Try to resolve "nonexistent" - should NOT fuzzy match to "link-test"
|
|
resolve_data = {"identifier": "nonexistent"}
|
|
response = await client.post(f"{v2_project_url}/knowledge/resolve", json=resolve_data)
|
|
|
|
# Must return 404, not a fuzzy match to "link-test"
|
|
assert response.status_code == 404
|
|
assert "Entity not found" in response.json()["detail"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_identifier_with_source_path_no_fuzzy_match(
|
|
client: AsyncClient, v2_project_url
|
|
):
|
|
"""Test that context-aware resolution also uses strict mode.
|
|
|
|
Even with source_path for context-aware resolution, nonexistent
|
|
links should return 404, not fuzzy match to nearby entities.
|
|
"""
|
|
# Create entities in a folder structure
|
|
entity_data = {
|
|
"title": "link-test",
|
|
"folder": "testing/nested",
|
|
"content": "A nested test note",
|
|
}
|
|
response = await client.post(f"{v2_project_url}/knowledge/entities", json=entity_data)
|
|
assert response.status_code == 200
|
|
|
|
# Try to resolve "nonexistent" with source_path context
|
|
# Should NOT fuzzy match to "link-test" in the same or nearby folder
|
|
resolve_data = {
|
|
"identifier": "nonexistent",
|
|
"source_path": "testing/nested/other-note.md",
|
|
}
|
|
response = await client.post(f"{v2_project_url}/knowledge/resolve", json=resolve_data)
|
|
|
|
# Must return 404, not a fuzzy match
|
|
assert response.status_code == 404
|
|
assert "Entity not found" in response.json()["detail"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_entity_by_id(client: AsyncClient, test_graph, v2_project_url, entity_repository):
|
|
"""Test getting an entity by its external_id (UUID)."""
|
|
# Create an entity first
|
|
entity_data = {
|
|
"title": "TestGetById",
|
|
"directory": "test",
|
|
"content": "Test content for get by ID",
|
|
}
|
|
response = await client.post(f"{v2_project_url}/knowledge/entities", json=entity_data)
|
|
assert response.status_code == 200
|
|
created_entity = EntityResponseV2.model_validate(response.json())
|
|
|
|
# V2 create must return external_id
|
|
assert created_entity.external_id is not None
|
|
entity_external_id = created_entity.external_id
|
|
|
|
# Get it by external_id using v2 endpoint
|
|
response = await client.get(f"{v2_project_url}/knowledge/entities/{entity_external_id}")
|
|
|
|
assert response.status_code == 200
|
|
entity = EntityResponseV2.model_validate(response.json())
|
|
assert entity.external_id == entity_external_id
|
|
assert entity.title == "TestGetById"
|
|
assert entity.api_version == "v2"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_entity_by_id_allows_long_relation_type(
|
|
client: AsyncClient,
|
|
v2_project_url,
|
|
relation_repository,
|
|
):
|
|
"""GET entity should not fail when stored relation_type exceeds 200 characters."""
|
|
source_response = await client.post(
|
|
f"{v2_project_url}/knowledge/entities",
|
|
json={
|
|
"title": "Long Relation Source",
|
|
"directory": "test",
|
|
"content": "Source entity content",
|
|
},
|
|
)
|
|
assert source_response.status_code == 200
|
|
source_entity = EntityResponseV2.model_validate(source_response.json())
|
|
|
|
target_response = await client.post(
|
|
f"{v2_project_url}/knowledge/entities",
|
|
json={
|
|
"title": "Long Relation Target",
|
|
"directory": "test",
|
|
"content": "Target entity content",
|
|
},
|
|
)
|
|
assert target_response.status_code == 200
|
|
target_entity = EntityResponseV2.model_validate(target_response.json())
|
|
|
|
long_relation_type = (
|
|
"**Architecture/efficiency concern:** "
|
|
"the orchestration prompt expanded a short edge label into a full descriptive note "
|
|
"that is much longer than 200 characters but should still serialize cleanly."
|
|
)
|
|
|
|
await relation_repository.create(
|
|
{
|
|
"from_id": source_entity.id,
|
|
"to_id": target_entity.id,
|
|
"to_name": target_entity.title,
|
|
"relation_type": long_relation_type,
|
|
}
|
|
)
|
|
|
|
response = await client.get(f"{v2_project_url}/knowledge/entities/{source_entity.external_id}")
|
|
|
|
assert response.status_code == 200
|
|
entity = EntityResponseV2.model_validate(response.json())
|
|
assert len(entity.relations) == 1
|
|
assert entity.relations[0].relation_type == long_relation_type
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_entity_by_id_not_found(client: AsyncClient, v2_project_url):
|
|
"""Test getting a non-existent entity by external_id returns 404."""
|
|
# Use a UUID format that doesn't exist
|
|
fake_uuid = "00000000-0000-0000-0000-000000000000"
|
|
response = await client.get(f"{v2_project_url}/knowledge/entities/{fake_uuid}")
|
|
|
|
assert response.status_code == 404
|
|
assert "not found" in response.json()["detail"].lower()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_entity(client: AsyncClient, file_service, v2_project_url):
|
|
"""Test creating an entity via v2 endpoint."""
|
|
data = {
|
|
"title": "TestV2Entity",
|
|
"directory": "test",
|
|
"note_type": "test",
|
|
"content_type": "text/markdown",
|
|
"content": "TestContent for V2",
|
|
}
|
|
|
|
response = await client.post(
|
|
f"{v2_project_url}/knowledge/entities", json=data, params={"fast": False}
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
entity = EntityResponseV2.model_validate(response.json())
|
|
|
|
# V2 endpoints must return id field
|
|
assert entity.id is not None
|
|
assert isinstance(entity.id, int)
|
|
assert entity.api_version == "v2"
|
|
|
|
assert entity.permalink == "test-project/test/test-v2-entity"
|
|
assert entity.file_path == "test/TestV2Entity.md"
|
|
assert entity.note_type == data["note_type"]
|
|
|
|
# Verify file was created
|
|
file_path = file_service.get_entity_path(entity)
|
|
file_content, _ = await file_service.read_file(file_path)
|
|
assert data["content"] in file_content
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_entity_conflict_returns_409(client: AsyncClient, v2_project_url):
|
|
"""Test creating a duplicate entity returns 409 Conflict."""
|
|
data = {
|
|
"title": "TestV2EntityConflict",
|
|
"directory": "conflict",
|
|
"note_type": "note",
|
|
"content_type": "text/markdown",
|
|
"content": "Original content for conflict",
|
|
}
|
|
|
|
response = await client.post(
|
|
f"{v2_project_url}/knowledge/entities",
|
|
json=data,
|
|
params={"fast": False},
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
response = await client.post(
|
|
f"{v2_project_url}/knowledge/entities",
|
|
json=data,
|
|
params={"fast": False},
|
|
)
|
|
assert response.status_code == 409
|
|
expected_detail = "Note already exists. Use edit_note to modify it, or delete it first."
|
|
assert response.json()["detail"] == expected_detail
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_entity_returns_content(client: AsyncClient, file_service, v2_project_url):
|
|
"""Test creating an entity always returns file content with frontmatter."""
|
|
data = {
|
|
"title": "TestContentReturn",
|
|
"directory": "test",
|
|
"note_type": "note",
|
|
"content_type": "text/markdown",
|
|
"content": "Body content for return test",
|
|
}
|
|
|
|
response = await client.post(
|
|
f"{v2_project_url}/knowledge/entities",
|
|
json=data,
|
|
params={"fast": False},
|
|
)
|
|
assert response.status_code == 200
|
|
entity = EntityResponseV2.model_validate(response.json())
|
|
|
|
# Content should always be populated with frontmatter
|
|
assert entity.content is not None
|
|
assert "---" in entity.content # frontmatter markers
|
|
assert "title: TestContentReturn" in entity.content
|
|
assert "type: note" in entity.content
|
|
assert "permalink:" in entity.content
|
|
assert data["content"] in entity.content
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_entity_with_observations_and_relations(
|
|
client: AsyncClient, file_service, v2_project_url
|
|
):
|
|
"""Test creating an entity with observations and relations via v2."""
|
|
data = {
|
|
"title": "TestV2Complex",
|
|
"directory": "test",
|
|
"content": """
|
|
# TestV2Complex
|
|
|
|
## Observations
|
|
- [note] This is a test observation #tag1 (context)
|
|
- "related to" [[OtherEntity]]
|
|
""",
|
|
}
|
|
|
|
response = await client.post(
|
|
f"{v2_project_url}/knowledge/entities", json=data, params={"fast": False}
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
entity = EntityResponseV2.model_validate(response.json())
|
|
|
|
# V2 endpoints must return id field
|
|
assert entity.id is not None
|
|
assert isinstance(entity.id, int)
|
|
assert entity.api_version == "v2"
|
|
|
|
assert len(entity.observations) == 1
|
|
assert entity.observations[0].category == "note"
|
|
assert entity.observations[0].content == "This is a test observation #tag1"
|
|
assert entity.observations[0].tags == ["tag1"]
|
|
|
|
assert len(entity.relations) == 1
|
|
assert entity.relations[0].relation_type == "related to"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_entity_by_id(
|
|
client: AsyncClient, file_service, v2_project_url, entity_repository
|
|
):
|
|
"""Test updating an entity by external_id using PUT (replace)."""
|
|
# Create an entity first
|
|
create_data = {
|
|
"title": "TestUpdate",
|
|
"directory": "test",
|
|
"content": "Original content",
|
|
}
|
|
response = await client.post(f"{v2_project_url}/knowledge/entities", json=create_data)
|
|
assert response.status_code == 200
|
|
created_entity = EntityResponseV2.model_validate(response.json())
|
|
|
|
# V2 create must return external_id
|
|
assert created_entity.external_id is not None
|
|
original_external_id = created_entity.external_id
|
|
|
|
# Update it by external_id
|
|
update_data = {
|
|
"title": "TestUpdate",
|
|
"directory": "test",
|
|
"content": "Updated content via V2",
|
|
}
|
|
response = await client.put(
|
|
f"{v2_project_url}/knowledge/entities/{original_external_id}",
|
|
json=update_data,
|
|
params={"fast": False},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
updated_entity = EntityResponseV2.model_validate(response.json())
|
|
|
|
# V2 update must return external_id field
|
|
assert updated_entity.external_id is not None
|
|
assert updated_entity.api_version == "v2"
|
|
assert updated_entity.content is not None
|
|
assert "Updated content via V2" in updated_entity.content
|
|
|
|
# Verify file was updated
|
|
file_path = file_service.get_entity_path(updated_entity)
|
|
file_content, _ = await file_service.read_file(file_path)
|
|
assert "Updated content via V2" in file_content
|
|
assert "Original content" not in file_content
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_entity_by_id_does_not_duplicate(
|
|
client: AsyncClient, v2_project_url, entity_repository
|
|
):
|
|
"""PUT updates the existing external_id without creating duplicates."""
|
|
create_data = {
|
|
"title": "07 - Get Started",
|
|
"directory": "docs",
|
|
"content": "Original content",
|
|
}
|
|
response = await client.post(f"{v2_project_url}/knowledge/entities", json=create_data)
|
|
assert response.status_code == 200
|
|
created_entity = EntityResponseV2.model_validate(response.json())
|
|
|
|
update_data = {
|
|
"title": "07 Get Started",
|
|
"directory": "docs",
|
|
"content": "Updated content",
|
|
}
|
|
response = await client.put(
|
|
f"{v2_project_url}/knowledge/entities/{created_entity.external_id}",
|
|
json=update_data,
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
entities = await entity_repository.find_all()
|
|
assert len(entities) == 1
|
|
assert entities[0].external_id == created_entity.external_id
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_put_entity_with_fast_param_returns_fully_indexed_row(
|
|
client: AsyncClient, v2_project_url, entity_repository
|
|
):
|
|
"""PUT ignores the legacy fast param and still returns a fully indexed row."""
|
|
external_id = str(uuid.uuid4())
|
|
update_data = {
|
|
"title": "FastPutEntity",
|
|
"directory": "test",
|
|
"content": """
|
|
# FastPutEntity
|
|
|
|
## Observations
|
|
- [note] This should be deferred
|
|
|
|
- related_to [[AnotherEntity]]
|
|
""",
|
|
}
|
|
response = await client.put(
|
|
f"{v2_project_url}/knowledge/entities/{external_id}",
|
|
json=update_data,
|
|
params={"fast": True},
|
|
)
|
|
|
|
assert response.status_code == 201
|
|
created_entity = EntityResponseV2.model_validate(response.json())
|
|
assert created_entity.external_id == external_id
|
|
assert len(created_entity.observations) == 1
|
|
assert len(created_entity.relations) == 1
|
|
|
|
db_entity = await entity_repository.get_by_external_id(external_id)
|
|
assert db_entity is not None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_with_fast_param_does_not_schedule_reindex_task(
|
|
client: AsyncClient, v2_project_url, task_scheduler_spy, app_config
|
|
):
|
|
"""Legacy fast=true should not resurrect the removed reindex note-write path."""
|
|
app_config.semantic_search_enabled = False
|
|
start_count = len(task_scheduler_spy)
|
|
response = await client.post(
|
|
f"{v2_project_url}/knowledge/entities",
|
|
json={
|
|
"title": "TaskScheduledEntity",
|
|
"directory": "test",
|
|
"content": "Content for task scheduling",
|
|
},
|
|
params={"fast": True},
|
|
)
|
|
assert response.status_code == 200
|
|
assert len(task_scheduler_spy) == start_count
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_schedules_vector_sync_when_semantic_enabled(
|
|
client: AsyncClient, v2_project_url, task_scheduler_spy, app_config
|
|
):
|
|
"""Create should schedule vector sync when semantic mode is enabled."""
|
|
app_config.semantic_search_enabled = True
|
|
start_count = len(task_scheduler_spy)
|
|
|
|
response = await client.post(
|
|
f"{v2_project_url}/knowledge/entities",
|
|
json={
|
|
"title": "NonFastSemanticEntity",
|
|
"directory": "test",
|
|
"content": "Content for non-fast semantic scheduling",
|
|
},
|
|
params={"fast": False},
|
|
)
|
|
assert response.status_code == 200
|
|
created_entity = EntityResponseV2.model_validate(response.json())
|
|
|
|
assert len(task_scheduler_spy) == start_count + 1
|
|
scheduled = task_scheduler_spy[-1]
|
|
assert scheduled["task_name"] == "sync_entity_vectors"
|
|
assert scheduled["payload"]["entity_id"] == created_entity.id
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_skips_vector_sync_when_semantic_disabled(
|
|
client: AsyncClient, v2_project_url, task_scheduler_spy, app_config
|
|
):
|
|
"""Create should not schedule vector sync when semantic mode is disabled."""
|
|
app_config.semantic_search_enabled = False
|
|
start_count = len(task_scheduler_spy)
|
|
|
|
response = await client.post(
|
|
f"{v2_project_url}/knowledge/entities",
|
|
json={
|
|
"title": "NonFastNoSemanticEntity",
|
|
"directory": "test",
|
|
"content": "Content for non-fast without semantic scheduling",
|
|
},
|
|
params={"fast": False},
|
|
)
|
|
assert response.status_code == 200
|
|
assert len(task_scheduler_spy) == start_count
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_edit_entity_by_id_append(
|
|
client: AsyncClient, file_service, v2_project_url, entity_repository
|
|
):
|
|
"""Test editing an entity by external_id using PATCH (append operation)."""
|
|
# Create an entity first
|
|
create_data = {
|
|
"title": "TestEdit",
|
|
"directory": "test",
|
|
"content": "# TestEdit\n\nOriginal content",
|
|
}
|
|
response = await client.post(f"{v2_project_url}/knowledge/entities", json=create_data)
|
|
assert response.status_code == 200
|
|
created_entity = EntityResponseV2.model_validate(response.json())
|
|
|
|
# V2 create must return external_id
|
|
assert created_entity.external_id is not None
|
|
original_external_id = created_entity.external_id
|
|
|
|
# Edit it by appending
|
|
edit_data = {
|
|
"operation": "append",
|
|
"content": "\n\n## New Section\n\nAppended content",
|
|
}
|
|
response = await client.patch(
|
|
f"{v2_project_url}/knowledge/entities/{original_external_id}",
|
|
json=edit_data,
|
|
params={"fast": False},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
edited_entity = EntityResponseV2.model_validate(response.json())
|
|
|
|
# V2 patch must return external_id field
|
|
assert edited_entity.external_id is not None
|
|
assert edited_entity.api_version == "v2"
|
|
assert edited_entity.content is not None
|
|
assert "Appended content" in edited_entity.content
|
|
|
|
# Verify file has both original and appended content
|
|
file_path = file_service.get_entity_path(edited_entity)
|
|
file_content, _ = await file_service.read_file(file_path)
|
|
assert "Original content" in file_content
|
|
assert "Appended content" in file_content
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_edit_entity_by_id_find_replace(
|
|
client: AsyncClient, file_service, v2_project_url, entity_repository
|
|
):
|
|
"""Test editing an entity by external_id using PATCH (find/replace operation)."""
|
|
# Create an entity first
|
|
create_data = {
|
|
"title": "TestFindReplace",
|
|
"directory": "test",
|
|
"content": "# TestFindReplace\n\nOld text that will be replaced",
|
|
}
|
|
response = await client.post(f"{v2_project_url}/knowledge/entities", json=create_data)
|
|
assert response.status_code == 200
|
|
created_entity = EntityResponseV2.model_validate(response.json())
|
|
|
|
# V2 create must return external_id
|
|
assert created_entity.external_id is not None
|
|
original_external_id = created_entity.external_id
|
|
|
|
# Edit using find/replace
|
|
edit_data = {
|
|
"operation": "find_replace",
|
|
"find_text": "Old text",
|
|
"content": "New text",
|
|
}
|
|
response = await client.patch(
|
|
f"{v2_project_url}/knowledge/entities/{original_external_id}",
|
|
json=edit_data,
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
edited_entity = EntityResponseV2.model_validate(response.json())
|
|
|
|
# V2 patch must return external_id field
|
|
assert edited_entity.external_id is not None
|
|
assert edited_entity.api_version == "v2"
|
|
|
|
# Verify replacement
|
|
file_path = file_service.get_entity_path(created_entity)
|
|
file_content, _ = await file_service.read_file(file_path)
|
|
assert "New text" in file_content
|
|
assert "Old text" not in file_content
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_entity_by_id(
|
|
client: AsyncClient, file_service, v2_project_url, entity_repository
|
|
):
|
|
"""Test deleting an entity by external_id."""
|
|
# Create an entity first
|
|
create_data = {
|
|
"title": "TestDelete",
|
|
"directory": "test",
|
|
"content": "Content to be deleted",
|
|
}
|
|
response = await client.post(f"{v2_project_url}/knowledge/entities", json=create_data)
|
|
assert response.status_code == 200
|
|
created_entity = EntityResponseV2.model_validate(response.json())
|
|
|
|
# V2 create must return external_id
|
|
assert created_entity.external_id is not None
|
|
entity_external_id = created_entity.external_id
|
|
|
|
# Delete it by external_id
|
|
response = await client.delete(f"{v2_project_url}/knowledge/entities/{entity_external_id}")
|
|
|
|
assert response.status_code == 200
|
|
delete_response = DeleteEntitiesResponse.model_validate(response.json())
|
|
assert delete_response.deleted is True
|
|
|
|
# Verify it's gone - trying to get it should return 404
|
|
response = await client.get(f"{v2_project_url}/knowledge/entities/{entity_external_id}")
|
|
assert response.status_code == 404
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_entity_by_id_not_found(client: AsyncClient, v2_project_url):
|
|
"""Test deleting a non-existent entity returns deleted=False (idempotent)."""
|
|
# Use a UUID format that doesn't exist
|
|
fake_uuid = "00000000-0000-0000-0000-000000000000"
|
|
response = await client.delete(f"{v2_project_url}/knowledge/entities/{fake_uuid}")
|
|
|
|
# Delete is idempotent - returns 200 with deleted=False
|
|
assert response.status_code == 200
|
|
delete_response = DeleteEntitiesResponse.model_validate(response.json())
|
|
assert delete_response.deleted is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_move_entity(client: AsyncClient, file_service, v2_project_url, entity_repository):
|
|
"""Test moving an entity to a new location."""
|
|
# Create an entity first
|
|
create_data = {
|
|
"title": "TestMove",
|
|
"directory": "test",
|
|
"content": "Content to be moved",
|
|
}
|
|
response = await client.post(f"{v2_project_url}/knowledge/entities", json=create_data)
|
|
assert response.status_code == 200
|
|
created_entity = EntityResponseV2.model_validate(response.json())
|
|
|
|
# V2 create must return external_id
|
|
assert created_entity.external_id is not None
|
|
original_external_id = created_entity.external_id
|
|
|
|
# Move it to a new folder (V2 uses entity external_id in path)
|
|
move_data = {
|
|
"destination_path": "moved/MovedEntity.md",
|
|
}
|
|
response = await client.put(
|
|
f"{v2_project_url}/knowledge/entities/{created_entity.external_id}/move", json=move_data
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
moved_entity = EntityResponseV2.model_validate(response.json())
|
|
|
|
# V2 move must return external_id field
|
|
assert moved_entity.external_id is not None
|
|
assert isinstance(moved_entity.external_id, str)
|
|
assert moved_entity.api_version == "v2"
|
|
|
|
# external_id should remain the same (stable reference)
|
|
assert moved_entity.external_id == original_external_id
|
|
assert moved_entity.file_path == "moved/MovedEntity.md"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_v2_endpoints_use_project_id_not_name(client: AsyncClient, test_project: Project):
|
|
"""Verify v2 endpoints require project external_id UUID, not name."""
|
|
# Try using project name instead of external_id - should fail
|
|
fake_entity_uuid = "00000000-0000-0000-0000-000000000000"
|
|
response = await client.get(
|
|
f"/v2/projects/{test_project.name}/knowledge/entities/{fake_entity_uuid}"
|
|
)
|
|
|
|
# Should get 404 because name is not a valid project external_id
|
|
assert response.status_code == 404
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_entity_response_v2_has_api_version(
|
|
client: AsyncClient, v2_project_url, entity_repository
|
|
):
|
|
"""Test that EntityResponseV2 includes api_version field."""
|
|
# Create an entity
|
|
entity_data = {
|
|
"title": "TestApiVersion",
|
|
"directory": "test",
|
|
"content": "Test content",
|
|
}
|
|
response = await client.post(f"{v2_project_url}/knowledge/entities", json=entity_data)
|
|
assert response.status_code == 200
|
|
created_entity = EntityResponseV2.model_validate(response.json())
|
|
|
|
# V2 create must return external_id and api_version
|
|
assert created_entity.external_id is not None
|
|
assert created_entity.api_version == "v2"
|
|
entity_external_id = created_entity.external_id
|
|
|
|
# Get it via v2 endpoint
|
|
response = await client.get(f"{v2_project_url}/knowledge/entities/{entity_external_id}")
|
|
assert response.status_code == 200
|
|
|
|
entity_v2 = EntityResponseV2.model_validate(response.json())
|
|
assert entity_v2.api_version == "v2"
|
|
assert entity_v2.external_id == entity_external_id
|
|
|
|
|
|
# --- Move directory tests (V2) ---
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_move_directory_v2_success(client: AsyncClient, v2_project_url):
|
|
"""Test POST /v2/.../move-directory endpoint successfully moves all files."""
|
|
# Create multiple notes in a source directory
|
|
for i in range(3):
|
|
response = await client.post(
|
|
f"{v2_project_url}/knowledge/entities",
|
|
json={
|
|
"title": f"V2DirMoveDoc{i + 1}",
|
|
"directory": "v2-move-source",
|
|
"content": f"Content for document {i + 1}",
|
|
},
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
# Move the entire directory
|
|
move_data = {
|
|
"source_directory": "v2-move-source",
|
|
"destination_directory": "v2-move-dest",
|
|
}
|
|
response = await client.post(f"{v2_project_url}/knowledge/move-directory", json=move_data)
|
|
assert response.status_code == 200
|
|
|
|
result = DirectoryMoveResult.model_validate(response.json())
|
|
assert result.total_files == 3
|
|
assert result.successful_moves == 3
|
|
assert result.failed_moves == 0
|
|
assert len(result.moved_files) == 3
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_move_directory_v2_empty_directory(client: AsyncClient, v2_project_url):
|
|
"""Test move_directory V2 with no files in source returns zero counts."""
|
|
move_data = {
|
|
"source_directory": "v2-nonexistent-source",
|
|
"destination_directory": "v2-some-dest",
|
|
}
|
|
response = await client.post(f"{v2_project_url}/knowledge/move-directory", json=move_data)
|
|
assert response.status_code == 200
|
|
|
|
result = DirectoryMoveResult.model_validate(response.json())
|
|
assert result.total_files == 0
|
|
assert result.successful_moves == 0
|
|
assert result.failed_moves == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_move_directory_v2_validation_error(client: AsyncClient, v2_project_url):
|
|
"""Test move_directory V2 with missing required fields returns validation error."""
|
|
# Missing destination_directory
|
|
response = await client.post(
|
|
f"{v2_project_url}/knowledge/move-directory",
|
|
json={"source_directory": "some-source"},
|
|
)
|
|
assert response.status_code == 422
|
|
|
|
# Missing source_directory
|
|
response = await client.post(
|
|
f"{v2_project_url}/knowledge/move-directory",
|
|
json={"destination_directory": "some-dest"},
|
|
)
|
|
assert response.status_code == 422
|
|
|
|
|
|
# --- Delete directory tests (V2) ---
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_directory_v2_success(client: AsyncClient, v2_project_url):
|
|
"""Test POST /v2/.../delete-directory endpoint successfully deletes all files."""
|
|
# Create multiple notes in a directory to delete
|
|
for i in range(3):
|
|
response = await client.post(
|
|
f"{v2_project_url}/knowledge/entities",
|
|
json={
|
|
"title": f"V2DeleteDoc{i + 1}",
|
|
"directory": "v2-delete-dir",
|
|
"content": f"Content for document {i + 1}",
|
|
},
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
# Verify notes exist
|
|
created_entity = EntityResponseV2.model_validate(response.json())
|
|
get_response = await client.get(
|
|
f"{v2_project_url}/knowledge/entities/{created_entity.external_id}"
|
|
)
|
|
assert get_response.status_code == 200
|
|
|
|
# Delete the entire directory
|
|
delete_data = {
|
|
"directory": "v2-delete-dir",
|
|
}
|
|
response = await client.post(f"{v2_project_url}/knowledge/delete-directory", json=delete_data)
|
|
assert response.status_code == 200
|
|
|
|
result = DirectoryDeleteResult.model_validate(response.json())
|
|
assert result.total_files == 3
|
|
assert result.successful_deletes == 3
|
|
assert result.failed_deletes == 0
|
|
assert len(result.deleted_files) == 3
|
|
|
|
# Verify entity is no longer accessible
|
|
get_response = await client.get(
|
|
f"{v2_project_url}/knowledge/entities/{created_entity.external_id}"
|
|
)
|
|
assert get_response.status_code == 404
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_directory_v2_empty_directory(client: AsyncClient, v2_project_url):
|
|
"""Test delete_directory V2 with no files returns zero counts."""
|
|
delete_data = {
|
|
"directory": "v2-nonexistent-delete-dir",
|
|
}
|
|
response = await client.post(f"{v2_project_url}/knowledge/delete-directory", json=delete_data)
|
|
assert response.status_code == 200
|
|
|
|
result = DirectoryDeleteResult.model_validate(response.json())
|
|
assert result.total_files == 0
|
|
assert result.successful_deletes == 0
|
|
assert result.failed_deletes == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_directory_v2_validation_error(client: AsyncClient, v2_project_url):
|
|
"""Test delete_directory V2 with missing required fields returns validation error."""
|
|
# Missing directory field
|
|
response = await client.post(
|
|
f"{v2_project_url}/knowledge/delete-directory",
|
|
json={},
|
|
)
|
|
assert response.status_code == 422
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_directory_v2_nested_structure(client: AsyncClient, v2_project_url):
|
|
"""Test delete_directory V2 handles nested directory structure."""
|
|
# Create notes in nested structure
|
|
directories = [
|
|
"v2-nested-delete/2024",
|
|
"v2-nested-delete/2024/q1",
|
|
]
|
|
|
|
for dir_path in directories:
|
|
response = await client.post(
|
|
f"{v2_project_url}/knowledge/entities",
|
|
json={
|
|
"title": f"Note in {dir_path.split('/')[-1]}",
|
|
"directory": dir_path,
|
|
"content": f"Content in {dir_path}",
|
|
},
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
# Delete the parent directory
|
|
delete_data = {
|
|
"directory": "v2-nested-delete/2024",
|
|
}
|
|
response = await client.post(f"{v2_project_url}/knowledge/delete-directory", json=delete_data)
|
|
assert response.status_code == 200
|
|
|
|
result = DirectoryDeleteResult.model_validate(response.json())
|
|
assert result.total_files == 2
|
|
assert result.successful_deletes == 2
|
|
assert result.failed_deletes == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_entity_response_includes_user_tracking_fields(client: AsyncClient, v2_project_url):
|
|
"""EntityResponseV2 includes created_by and last_updated_by fields (null for local)."""
|
|
entity_data = {
|
|
"title": "UserTrackingTest",
|
|
"directory": "test",
|
|
"content": "Test content",
|
|
}
|
|
response = await client.post(f"{v2_project_url}/knowledge/entities", json=entity_data)
|
|
assert response.status_code == 200
|
|
|
|
body = response.json()
|
|
# Fields should be present in the response (null for local/CLI usage)
|
|
assert "created_by" in body
|
|
assert "last_updated_by" in body
|
|
assert body["created_by"] is None
|
|
assert body["last_updated_by"] is None
|