diff --git a/src/Memorizer.IntegrationTests/WorkspaceSlugLookupTests.cs b/src/Memorizer.IntegrationTests/WorkspaceSlugLookupTests.cs new file mode 100644 index 0000000..caeee0d --- /dev/null +++ b/src/Memorizer.IntegrationTests/WorkspaceSlugLookupTests.cs @@ -0,0 +1,163 @@ +using Memorizer.Extensions; +using Memorizer.IntegrationTests.Logging; +using Memorizer.Services; +using Memorizer.Settings; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using PostgMem.Tools; +using Xunit.Abstractions; + +namespace Memorizer.IntegrationTests; + +/// +/// End-to-end tests for resolving workspaces by slug through the real +/// MCP tool against a live PostgreSQL database. +/// Exercises the tool logic, slug normalization, and the underlying SQL. +/// +[Collection(nameof(IntegrationTestCollection))] +public class WorkspaceSlugLookupTests : IDisposable +{ + private readonly IntegrationTestFixture _fixture; + private readonly ITestOutputHelper _output; + private readonly IServiceProvider _services; + + public WorkspaceSlugLookupTests(IntegrationTestFixture fixture, ITestOutputHelper output) + { + _fixture = fixture; + _output = output; + _services = CreateServices(); + } + + public void Dispose() => (_services as IDisposable)?.Dispose(); + + private IServiceProvider CreateServices() + { + var services = new ServiceCollection(); + + services.AddSingleton(new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["ConnectionStrings:Storage"] = _fixture.PostgresConnectionString, + ["Embeddings:ApiUrl"] = _fixture.OllamaApiUrl, + ["Embeddings:Model"] = "all-minilm", + ["Embeddings:Timeout"] = TimeSpan.FromMinutes(1).ToString() + }) + .Build()); + + services.AddHttpClient(client => + { + client.BaseAddress = new Uri(_fixture.OllamaApiUrl); + client.Timeout = TimeSpan.FromMinutes(1); + }); + + services.AddSingleton(new EmbeddingSettings + { + ApiUrl = new Uri(_fixture.OllamaApiUrl), + Model = "all-minilm", + Timeout = TimeSpan.FromMinutes(1) + }); + + services.AddMemorizer(); + services.AddLogging(builder => builder.AddXUnit(_output)); + + return services.BuildServiceProvider(); + } + + private WorkspaceTools CreateTools() + { + var storage = _services.GetRequiredService(); + var urlService = _services.GetRequiredService(); + return new WorkspaceTools(storage, NullLogger.Instance, urlService); + } + + [Fact] + public async Task GetWorkspace_BySlug_ResolvesWorkspaceEndToEnd() + { + var storage = _services.GetRequiredService(); + var tools = CreateTools(); + + var created = await storage.CreateWorkspaceAsync("Engineering", "Root workspace", cancellationToken: default); + _output.WriteLine($"Created workspace {created.Id.Value} with slug '{created.Slug}'"); + + try + { + var result = await tools.GetWorkspace(slug: created.Slug); + + Assert.Contains("Workspace: Engineering", result); + Assert.Contains($"Slug: {created.Slug}", result); + Assert.Contains(created.Id.Value.ToString(), result); + } + finally + { + await storage.DeleteWorkspaceAsync(created.Id, default); + } + } + + [Fact] + public async Task GetWorkspace_BySlug_IsCaseInsensitive() + { + var storage = _services.GetRequiredService(); + var tools = CreateTools(); + + var created = await storage.CreateWorkspaceAsync("Platform Team", cancellationToken: default); + _output.WriteLine($"Created workspace with slug '{created.Slug}'"); + + try + { + // Caller passes an upper-cased slug; tool normalizes to the stored lowercase form. + var result = await tools.GetWorkspace(slug: created.Slug.ToUpperInvariant()); + + Assert.Contains("Workspace: Platform Team", result); + Assert.Contains($"Slug: {created.Slug}", result); + } + finally + { + await storage.DeleteWorkspaceAsync(created.Id, default); + } + } + + [Fact] + public async Task GetWorkspace_BySlug_ScopesToParent() + { + var storage = _services.GetRequiredService(); + var tools = CreateTools(); + + // Same slug at root and nested under a parent — parentWorkspaceId disambiguates. + var parent = await storage.CreateWorkspaceAsync("Platform", cancellationToken: default); + var rootDupe = await storage.CreateWorkspaceAsync("Backend", "root scope", cancellationToken: default); + var childDupe = await storage.CreateWorkspaceAsync("Backend", "child scope", parentId: parent.Id, cancellationToken: default); + + Assert.Equal(rootDupe.Slug, childDupe.Slug); + _output.WriteLine($"Root '{rootDupe.Id.Value}' and child '{childDupe.Id.Value}' share slug '{rootDupe.Slug}'"); + + try + { + var rootResult = await tools.GetWorkspace(slug: rootDupe.Slug); + var childResult = await tools.GetWorkspace(slug: childDupe.Slug, parentWorkspaceId: parent.Id.Value.ToString()); + + Assert.Contains(rootDupe.Id.Value.ToString(), rootResult); + Assert.DoesNotContain(childDupe.Id.Value.ToString(), rootResult); + + Assert.Contains(childDupe.Id.Value.ToString(), childResult); + Assert.DoesNotContain(rootDupe.Id.Value.ToString(), childResult); + } + finally + { + await storage.DeleteWorkspaceAsync(childDupe.Id, default); + await storage.DeleteWorkspaceAsync(rootDupe.Id, default); + await storage.DeleteWorkspaceAsync(parent.Id, default); + } + } + + [Fact] + public async Task GetWorkspace_BySlug_WhenMissing_ReturnsNotFound() + { + var tools = CreateTools(); + + var result = await tools.GetWorkspace(slug: "definitely-does-not-exist"); + + Assert.Contains("Workspace with slug 'definitely-does-not-exist' not found among root workspaces.", result); + } +} diff --git a/src/Memorizer.UnitTests/Tools/FakeCanonicalUrlService.cs b/src/Memorizer.UnitTests/Tools/FakeCanonicalUrlService.cs new file mode 100644 index 0000000..23ea0fa --- /dev/null +++ b/src/Memorizer.UnitTests/Tools/FakeCanonicalUrlService.cs @@ -0,0 +1,35 @@ +using Memorizer.Models; +using Memorizer.Models.ValueTypes; +using Memorizer.Services; + +namespace Memorizer.UnitTests.Tools; + +/// +/// Fake canonical URL service for workspace/project tool tests. +/// +internal class FakeCanonicalUrlService : ICanonicalUrlService +{ + public bool IsConfigured { get; set; } + public string BaseUrl { get; set; } = ""; + public bool GetMemoryUrlCalled { get; private set; } + public bool GetWorkspaceUrlCalled { get; private set; } + public bool GetProjectUrlCalled { get; private set; } + + public string? GetMemoryUrl(MemoryId memoryId) + { + GetMemoryUrlCalled = true; + return IsConfigured ? $"{BaseUrl.TrimEnd('/')}/view/{memoryId.Value}" : null; + } + + public string? GetWorkspaceUrl(WorkspaceId workspaceId) + { + GetWorkspaceUrlCalled = true; + return IsConfigured ? $"{BaseUrl.TrimEnd('/')}/workspace/{workspaceId.Value}" : null; + } + + public string? GetProjectUrl(ProjectId projectId) + { + GetProjectUrlCalled = true; + return IsConfigured ? $"{BaseUrl.TrimEnd('/')}/project/{projectId.Value}" : null; + } +} diff --git a/src/Memorizer.UnitTests/Tools/FakeWorkspaceStorage.cs b/src/Memorizer.UnitTests/Tools/FakeWorkspaceStorage.cs new file mode 100644 index 0000000..1010931 --- /dev/null +++ b/src/Memorizer.UnitTests/Tools/FakeWorkspaceStorage.cs @@ -0,0 +1,185 @@ +using Memorizer.Models; +using Memorizer.Models.Enums; +using Memorizer.Models.ValueTypes; +using Memorizer.Services; +using Memorizer.Settings; +using Pgvector; + +namespace Memorizer.UnitTests.Tools; + +/// +/// Fake storage that implements just enough of IStorage for workspace/project tool tests. +/// Uses manual fakes instead of a mocking framework. +/// +internal class FakeWorkspaceStorage : IStorage +{ + public Workspace? Workspace { get; set; } + public Workspace? CreatedWorkspace { get; set; } + public Workspace? WorkspaceBySlug { get; set; } + public Project? Project { get; set; } + public Project? CreatedProject { get; set; } + + // Captured arguments for slug lookups + public string? LastSlugQueried { get; private set; } + public WorkspaceId? LastParentIdQueried { get; private set; } + + // Workspace operations used by tests + public Task GetWorkspaceAsync(WorkspaceId id, CancellationToken cancellationToken = default) + => Task.FromResult(Workspace); + + public Task CreateWorkspaceAsync(string name, string? description = null, WorkspaceId? parentId = null, CancellationToken cancellationToken = default) + => Task.FromResult(CreatedWorkspace ?? throw new InvalidOperationException("CreatedWorkspace not set")); + + public Task> GetWorkspacesAsync(WorkspaceId? parentId = null, bool includeSystem = false, CancellationToken cancellationToken = default) + => Task.FromResult>(new List()); + + public Task> GetWorkspacePathAsync(WorkspaceId id, CancellationToken cancellationToken = default) + => Task.FromResult>(new List()); + + public Task UpdateWorkspaceAsync(WorkspaceId id, string? name = null, string? description = null, WorkspaceId? newParentId = null, bool makeTopLevel = false, CancellationToken cancellationToken = default) + => throw new NotImplementedException(); + + public Task MoveProjectToWorkspaceAsync(ProjectId id, WorkspaceId newWorkspaceId, ProjectId? newParentId = null, CancellationToken cancellationToken = default) + => throw new NotImplementedException(); + + public Task DeleteWorkspaceAsync(WorkspaceId id, CancellationToken cancellationToken = default) + => throw new NotImplementedException(); + + public Task> SearchWorkspacesAsync(string query, bool includeSystem = false, CancellationToken cancellationToken = default) + => Task.FromResult>(new List()); + + public Task GetWorkspaceBySlugAsync(string slug, WorkspaceId? parentId = null, CancellationToken cancellationToken = default) + { + LastSlugQueried = slug; + LastParentIdQueried = parentId; + return Task.FromResult(WorkspaceBySlug); + } + + // Project operations used by tests + public Task GetProjectAsync(ProjectId id, CancellationToken cancellationToken = default) + => Task.FromResult(Project); + + public Task CreateProjectAsync(WorkspaceId workspaceId, string name, string? description = null, ProjectId? parentId = null, CancellationToken cancellationToken = default) + => Task.FromResult(CreatedProject ?? throw new InvalidOperationException("CreatedProject not set")); + + public Task> GetProjectsAsync(WorkspaceId workspaceId, ProjectId? parentId = null, ProjectStatusEnum? statusFilter = null, CancellationToken cancellationToken = default) + => Task.FromResult>(new List()); + + public Task GetProjectPathAsync(ProjectId id, CancellationToken cancellationToken = default) + => Task.FromResult(new ProjectPath { WorkspacePath = Array.Empty(), ProjectAncestors = Array.Empty() }); + + public Task UpdateProjectAsync(ProjectId id, string? name = null, string? description = null, ProjectStatusEnum? status = null, string? victoryConditions = null, ProjectId? newParentId = null, bool makeTopLevel = false, CancellationToken cancellationToken = default) + => Task.FromResult(CreatedProject ?? throw new InvalidOperationException("CreatedProject not set")); + + public Task DeleteProjectAsync(ProjectId id, CancellationToken cancellationToken = default) + => throw new NotImplementedException(); + + public Task> SearchProjectsAsync(string query, ProjectStatusEnum? statusFilter = null, CancellationToken cancellationToken = default) + => Task.FromResult>(new List()); + + // Memory operations (stubs) + public Task StoreMemory(string type, string content, string source, string[]? tags, Confidence confidence, string title, MemoryId? relatedTo = null, string? relationshipType = null, MemoryOwner? owner = null, ArchetypeEnum archetype = ArchetypeEnum.Document, CancellationToken cancellationToken = default) + => throw new NotImplementedException(); + public Task Get(MemoryId id, CancellationToken cancellationToken = default) + => throw new NotImplementedException(); + public Task> GetMany(IEnumerable ids, CancellationToken cancellationToken = default) + => throw new NotImplementedException(); + public Task Delete(MemoryId id, CancellationToken cancellationToken = default) + => throw new NotImplementedException(); + public Task UpdateMemory(MemoryId id, string type, string content, string source, string[]? tags, Confidence confidence, string? title, CancellationToken cancellationToken) + => throw new NotImplementedException(); + public Task CreateRelationship(MemoryId fromId, MemoryId toId, string type, CancellationToken cancellationToken = default) + => throw new NotImplementedException(); + public Task CreateRelationship(MemoryId fromId, MemoryId toId, string type, SimilarityScore? score, CancellationToken cancellationToken = default) + => throw new NotImplementedException(); + public Task> GetRelationships(MemoryId memoryId, string? type = null, bool includeArchivedTargets = false, CancellationToken cancellationToken = default) + => Task.FromResult(new List()); + public Task> GetSimilarMemories(MemoryId memoryId, SimilarityScore? minSimilarity = null, int limit = 10, CancellationToken cancellationToken = default) + => Task.FromResult(new List()); + public Task> GetEvents(MemoryId memoryId, int? limit = null, CancellationToken cancellationToken = default) + => throw new NotImplementedException(); + public Task> GetVersionHistory(MemoryId memoryId, int? limit = null, CancellationToken cancellationToken = default) + => throw new NotImplementedException(); + public Task GetVersion(MemoryId memoryId, VersionNumber versionNumber, CancellationToken cancellationToken = default) + => throw new NotImplementedException(); + public Task RevertToVersion(MemoryId memoryId, VersionNumber versionNumber, string? changedBy = null, CancellationToken cancellationToken = default) + => throw new NotImplementedException(); + public Task PurgeVersionsKeepingLatest(MemoryId memoryId, int versionsToKeep, CancellationToken cancellationToken = default) + => throw new NotImplementedException(); + public Task PurgeVersionsOlderThan(DateTime cutoffDate, CancellationToken cancellationToken = default) + => throw new NotImplementedException(); + public Task GetVersionStats(CancellationToken cancellationToken = default) + => throw new NotImplementedException(); + public Task> Search(string query, int limit = 10, SimilarityScore? minSimilarity = null, string[]? filterTags = null, bool includeArchived = false, CancellationToken cancellationToken = default) + => throw new NotImplementedException(); + public Task<(List Memories, int TotalCount)> GetMemoriesPaginated(int page = 1, int pageSize = 20, string? memoryType = null, CancellationToken cancellationToken = default) + => throw new NotImplementedException(); + public Task> GetDistinctMemoryTypes(CancellationToken cancellationToken = default) + => throw new NotImplementedException(); + public Task> GetDistinctTagsAsync(MemoryOwner? owner = null, CancellationToken cancellationToken = default) + => throw new NotImplementedException(); + public Task> GetDistinctOwnersAsync(string[]? tags = null, string? memoryType = null, CancellationToken cancellationToken = default) + => Task.FromResult(new List()); + public Task> GetMemoriesWithoutTitles(int limit = 50, CancellationToken cancellationToken = default) + => throw new NotImplementedException(); + public Task UpdateMemoryTitle(MemoryId id, string title, CancellationToken cancellationToken = default) + => throw new NotImplementedException(); + public Task UpdateMemoryOwner(MemoryId id, MemoryOwner owner, CancellationToken cancellationToken = default) + => throw new NotImplementedException(); + public Task SetMemoryOwnerAsync(MemoryId memoryId, MemoryOwner owner, CancellationToken cancellationToken = default) + => Task.CompletedTask; + public Task MoveMemoryToUnfiledAsync(MemoryId memoryId, CancellationToken cancellationToken = default) + => Task.CompletedTask; + public Task CountMemoriesWithoutMetadataEmbeddings(CancellationToken cancellationToken = default) + => throw new NotImplementedException(); + public Task> GetMemoriesWithoutMetadataEmbeddings(int limit, bool includeExisting = false, CancellationToken cancellationToken = default) + => throw new NotImplementedException(); + public Task UpdateMemoryMetadataEmbedding(MemoryId memoryId, Vector embedding, CancellationToken cancellationToken = default) + => throw new NotImplementedException(); + public Task UpdateMemoryEmbeddings(MemoryId memoryId, Vector contentEmbedding, Vector metadataEmbedding, CancellationToken cancellationToken = default) + => throw new NotImplementedException(); + public Task> SearchWithFullEmbedding(string query, int limit = 10, SimilarityScore? minSimilarity = null, string[]? filterTags = null, bool includeArchived = false, CancellationToken cancellationToken = default) + => throw new NotImplementedException(); + public Task> SearchWithMetadataEmbedding(string query, int limit = 10, SimilarityScore? minSimilarity = null, string[]? filterTags = null, ProjectId? projectId = null, bool includeUnassigned = false, bool includeArchived = false, bool includeSystem = false, WorkspaceId? workspaceId = null, CancellationToken cancellationToken = default) + => throw new NotImplementedException(); + public Task<(List FullResults, List MetadataResults)> CompareSearchMethods(string query, int limit = 10, SimilarityScore? minSimilarity = null, string[]? filterTags = null, CancellationToken cancellationToken = default) + => throw new NotImplementedException(); + public Task> HybridSearch(string query, int limit = 10, SimilarityScore? minSimilarity = null, string[]? filterTags = null, ProjectId? projectId = null, bool includeUnassigned = false, bool includeArchived = false, bool includeSystem = false, WorkspaceId? workspaceId = null, CancellationToken cancellationToken = default) + => throw new NotImplementedException(); + public Task UpdateMemoryArchetypeAsync(MemoryId memoryId, ArchetypeEnum newArchetype, CancellationToken cancellationToken = default) + => throw new NotImplementedException(); + public Task<(IReadOnlyList Memories, int TotalCount)> GetArchivedMemoriesAsync(int page = 1, int pageSize = 50, ProjectId? projectId = null, CancellationToken cancellationToken = default) + => Task.FromResult<(IReadOnlyList Memories, int TotalCount)>((new List(), 0)); + public Task> GetMemoriesByOwnerAsync(MemoryOwner owner, int page = 1, int pageSize = 50, string? memoryType = null, CancellationToken cancellationToken = default) + => Task.FromResult>(new List()); + public Task GetMemoryCountByOwnerAsync(MemoryOwner owner, string? memoryType = null, CancellationToken cancellationToken = default) + => Task.FromResult(0); + public Task> GetUnfiledMemoriesAsync(int page = 1, int pageSize = 50, string? memoryType = null, CancellationToken cancellationToken = default) + => Task.FromResult>(new List()); + public Task GetUnfiledMemoryCountAsync(string? memoryType = null, CancellationToken cancellationToken = default) + => Task.FromResult(0); + public Task<(IReadOnlyList Memories, int TotalCount)> GetMemoriesByTagAsync(string[] tags, int page = 1, int pageSize = 20, MemoryOwner? owner = null, string? memoryType = null, CancellationToken cancellationToken = default) + => Task.FromResult<(IReadOnlyList, int)>((new List(), 0)); + + // Provider settings + public Task GetActiveProviderAsync(string providerType, CancellationToken cancellationToken = default) + => throw new NotImplementedException(); + public Task> GetAllProvidersAsync(string providerType, CancellationToken cancellationToken = default) + => throw new NotImplementedException(); + public Task SaveProviderSettingsAsync(ProviderSettings settings, CancellationToken cancellationToken = default) + => throw new NotImplementedException(); + public Task SetActiveProviderAsync(string providerType, string providerName, CancellationToken cancellationToken = default) + => Task.CompletedTask; + + // System memory seeding + public Task<(int ProjectsSeeded, int WorkspacesSeeded)> SeedProjectAndWorkspaceSystemMemoriesAsync(CancellationToken cancellationToken = default) + => Task.FromResult((0, 0)); + + // Data migration tracking + public Task HasDataMigrationRunAsync(string migrationName, CancellationToken cancellationToken = default) + => Task.FromResult(false); + public Task RecordDataMigrationAsync(string migrationName, string? description = null, CancellationToken cancellationToken = default) + => Task.CompletedTask; + public Task ExecuteDataMigrationIfNeededAsync(string migrationName, string description, Func migrationAction, CancellationToken cancellationToken = default) + => Task.FromResult(false); +} diff --git a/src/Memorizer.UnitTests/Tools/WorkspaceToolsCanonicalUrlTests.cs b/src/Memorizer.UnitTests/Tools/WorkspaceToolsCanonicalUrlTests.cs index 63c4c58..7a171b4 100644 --- a/src/Memorizer.UnitTests/Tools/WorkspaceToolsCanonicalUrlTests.cs +++ b/src/Memorizer.UnitTests/Tools/WorkspaceToolsCanonicalUrlTests.cs @@ -1,12 +1,8 @@ using Memorizer.Models; using Memorizer.Models.Enums; using Memorizer.Models.ValueTypes; -using Memorizer.Services; -using Memorizer.Settings; using Microsoft.Extensions.Logging.Abstractions; using PostgMem.Tools; -using Pgvector; -using System.Text.Json; namespace Memorizer.UnitTests.Tools; @@ -204,198 +200,4 @@ private static Project CreateTestProject(ProjectId id, WorkspaceId workspaceId, VictoryConditions = null }; } - - /// - /// Fake storage that implements just enough of IStorage for workspace/project tests - /// - private class FakeWorkspaceStorage : IStorage - { - public Workspace? Workspace { get; set; } - public Workspace? CreatedWorkspace { get; set; } - public Project? Project { get; set; } - public Project? CreatedProject { get; set; } - - // Workspace operations used by tests - public Task GetWorkspaceAsync(WorkspaceId id, CancellationToken cancellationToken = default) - => Task.FromResult(Workspace); - - public Task CreateWorkspaceAsync(string name, string? description = null, WorkspaceId? parentId = null, CancellationToken cancellationToken = default) - => Task.FromResult(CreatedWorkspace ?? throw new InvalidOperationException("CreatedWorkspace not set")); - - public Task> GetWorkspacesAsync(WorkspaceId? parentId = null, bool includeSystem = false, CancellationToken cancellationToken = default) - => Task.FromResult>(new List()); - - public Task> GetWorkspacePathAsync(WorkspaceId id, CancellationToken cancellationToken = default) - => Task.FromResult>(new List()); - - public Task UpdateWorkspaceAsync(WorkspaceId id, string? name = null, string? description = null, WorkspaceId? newParentId = null, bool makeTopLevel = false, CancellationToken cancellationToken = default) - => throw new NotImplementedException(); - - public Task MoveProjectToWorkspaceAsync(ProjectId id, WorkspaceId newWorkspaceId, ProjectId? newParentId = null, CancellationToken cancellationToken = default) - => throw new NotImplementedException(); - - public Task DeleteWorkspaceAsync(WorkspaceId id, CancellationToken cancellationToken = default) - => throw new NotImplementedException(); - - public Task> SearchWorkspacesAsync(string query, bool includeSystem = false, CancellationToken cancellationToken = default) - => Task.FromResult>(new List()); - - public Task GetWorkspaceBySlugAsync(string slug, WorkspaceId? parentId = null, CancellationToken cancellationToken = default) - => throw new NotImplementedException(); - - // Project operations used by tests - public Task GetProjectAsync(ProjectId id, CancellationToken cancellationToken = default) - => Task.FromResult(Project); - - public Task CreateProjectAsync(WorkspaceId workspaceId, string name, string? description = null, ProjectId? parentId = null, CancellationToken cancellationToken = default) - => Task.FromResult(CreatedProject ?? throw new InvalidOperationException("CreatedProject not set")); - - public Task> GetProjectsAsync(WorkspaceId workspaceId, ProjectId? parentId = null, ProjectStatusEnum? statusFilter = null, CancellationToken cancellationToken = default) - => Task.FromResult>(new List()); - - public Task GetProjectPathAsync(ProjectId id, CancellationToken cancellationToken = default) - => Task.FromResult(new ProjectPath { WorkspacePath = Array.Empty(), ProjectAncestors = Array.Empty() }); - - public Task UpdateProjectAsync(ProjectId id, string? name = null, string? description = null, ProjectStatusEnum? status = null, string? victoryConditions = null, ProjectId? newParentId = null, bool makeTopLevel = false, CancellationToken cancellationToken = default) - => Task.FromResult(CreatedProject ?? throw new InvalidOperationException("CreatedProject not set")); - - public Task DeleteProjectAsync(ProjectId id, CancellationToken cancellationToken = default) - => throw new NotImplementedException(); - - public Task> SearchProjectsAsync(string query, ProjectStatusEnum? statusFilter = null, CancellationToken cancellationToken = default) - => Task.FromResult>(new List()); - - // Memory operations (stubs) - public Task StoreMemory(string type, string content, string source, string[]? tags, Confidence confidence, string title, MemoryId? relatedTo = null, string? relationshipType = null, MemoryOwner? owner = null, ArchetypeEnum archetype = ArchetypeEnum.Document, CancellationToken cancellationToken = default) - => throw new NotImplementedException(); - public Task Get(MemoryId id, CancellationToken cancellationToken = default) - => throw new NotImplementedException(); - public Task> GetMany(IEnumerable ids, CancellationToken cancellationToken = default) - => throw new NotImplementedException(); - public Task Delete(MemoryId id, CancellationToken cancellationToken = default) - => throw new NotImplementedException(); - public Task UpdateMemory(MemoryId id, string type, string content, string source, string[]? tags, Confidence confidence, string? title, CancellationToken cancellationToken) - => throw new NotImplementedException(); - public Task CreateRelationship(MemoryId fromId, MemoryId toId, string type, CancellationToken cancellationToken = default) - => throw new NotImplementedException(); - public Task CreateRelationship(MemoryId fromId, MemoryId toId, string type, SimilarityScore? score, CancellationToken cancellationToken = default) - => throw new NotImplementedException(); - public Task> GetRelationships(MemoryId memoryId, string? type = null, bool includeArchivedTargets = false, CancellationToken cancellationToken = default) - => Task.FromResult(new List()); - public Task> GetSimilarMemories(MemoryId memoryId, SimilarityScore? minSimilarity = null, int limit = 10, CancellationToken cancellationToken = default) - => Task.FromResult(new List()); - public Task> GetEvents(MemoryId memoryId, int? limit = null, CancellationToken cancellationToken = default) - => throw new NotImplementedException(); - public Task> GetVersionHistory(MemoryId memoryId, int? limit = null, CancellationToken cancellationToken = default) - => throw new NotImplementedException(); - public Task GetVersion(MemoryId memoryId, VersionNumber versionNumber, CancellationToken cancellationToken = default) - => throw new NotImplementedException(); - public Task RevertToVersion(MemoryId memoryId, VersionNumber versionNumber, string? changedBy = null, CancellationToken cancellationToken = default) - => throw new NotImplementedException(); - public Task PurgeVersionsKeepingLatest(MemoryId memoryId, int versionsToKeep, CancellationToken cancellationToken = default) - => throw new NotImplementedException(); - public Task PurgeVersionsOlderThan(DateTime cutoffDate, CancellationToken cancellationToken = default) - => throw new NotImplementedException(); - public Task GetVersionStats(CancellationToken cancellationToken = default) - => throw new NotImplementedException(); - public Task> Search(string query, int limit = 10, SimilarityScore? minSimilarity = null, string[]? filterTags = null, bool includeArchived = false, CancellationToken cancellationToken = default) - => throw new NotImplementedException(); - public Task<(List Memories, int TotalCount)> GetMemoriesPaginated(int page = 1, int pageSize = 20, string? memoryType = null, CancellationToken cancellationToken = default) - => throw new NotImplementedException(); - public Task> GetDistinctMemoryTypes(CancellationToken cancellationToken = default) - => throw new NotImplementedException(); - public Task> GetDistinctTagsAsync(MemoryOwner? owner = null, CancellationToken cancellationToken = default) - => throw new NotImplementedException(); - public Task> GetDistinctOwnersAsync(string[]? tags = null, string? memoryType = null, CancellationToken cancellationToken = default) - => Task.FromResult(new List()); - public Task> GetMemoriesWithoutTitles(int limit = 50, CancellationToken cancellationToken = default) - => throw new NotImplementedException(); - public Task UpdateMemoryTitle(MemoryId id, string title, CancellationToken cancellationToken = default) - => throw new NotImplementedException(); - public Task UpdateMemoryOwner(MemoryId id, MemoryOwner owner, CancellationToken cancellationToken = default) - => throw new NotImplementedException(); - public Task SetMemoryOwnerAsync(MemoryId memoryId, MemoryOwner owner, CancellationToken cancellationToken = default) - => Task.CompletedTask; - public Task MoveMemoryToUnfiledAsync(MemoryId memoryId, CancellationToken cancellationToken = default) - => Task.CompletedTask; - public Task CountMemoriesWithoutMetadataEmbeddings(CancellationToken cancellationToken = default) - => throw new NotImplementedException(); - public Task> GetMemoriesWithoutMetadataEmbeddings(int limit, bool includeExisting = false, CancellationToken cancellationToken = default) - => throw new NotImplementedException(); - public Task UpdateMemoryMetadataEmbedding(MemoryId memoryId, Vector embedding, CancellationToken cancellationToken = default) - => throw new NotImplementedException(); - public Task UpdateMemoryEmbeddings(MemoryId memoryId, Vector contentEmbedding, Vector metadataEmbedding, CancellationToken cancellationToken = default) - => throw new NotImplementedException(); - public Task> SearchWithFullEmbedding(string query, int limit = 10, SimilarityScore? minSimilarity = null, string[]? filterTags = null, bool includeArchived = false, CancellationToken cancellationToken = default) - => throw new NotImplementedException(); - public Task> SearchWithMetadataEmbedding(string query, int limit = 10, SimilarityScore? minSimilarity = null, string[]? filterTags = null, ProjectId? projectId = null, bool includeUnassigned = false, bool includeArchived = false, bool includeSystem = false, WorkspaceId? workspaceId = null, CancellationToken cancellationToken = default) - => throw new NotImplementedException(); - public Task<(List FullResults, List MetadataResults)> CompareSearchMethods(string query, int limit = 10, SimilarityScore? minSimilarity = null, string[]? filterTags = null, CancellationToken cancellationToken = default) - => throw new NotImplementedException(); - public Task> HybridSearch(string query, int limit = 10, SimilarityScore? minSimilarity = null, string[]? filterTags = null, ProjectId? projectId = null, bool includeUnassigned = false, bool includeArchived = false, bool includeSystem = false, WorkspaceId? workspaceId = null, CancellationToken cancellationToken = default) - => throw new NotImplementedException(); - public Task UpdateMemoryArchetypeAsync(MemoryId memoryId, ArchetypeEnum newArchetype, CancellationToken cancellationToken = default) - => throw new NotImplementedException(); - public Task<(IReadOnlyList Memories, int TotalCount)> GetArchivedMemoriesAsync(int page = 1, int pageSize = 50, ProjectId? projectId = null, CancellationToken cancellationToken = default) - => Task.FromResult<(IReadOnlyList Memories, int TotalCount)>((new List(), 0)); - public Task> GetMemoriesByOwnerAsync(MemoryOwner owner, int page = 1, int pageSize = 50, string? memoryType = null, CancellationToken cancellationToken = default) - => Task.FromResult>(new List()); - public Task GetMemoryCountByOwnerAsync(MemoryOwner owner, string? memoryType = null, CancellationToken cancellationToken = default) - => Task.FromResult(0); - public Task> GetUnfiledMemoriesAsync(int page = 1, int pageSize = 50, string? memoryType = null, CancellationToken cancellationToken = default) - => Task.FromResult>(new List()); - public Task GetUnfiledMemoryCountAsync(string? memoryType = null, CancellationToken cancellationToken = default) - => Task.FromResult(0); - public Task<(IReadOnlyList Memories, int TotalCount)> GetMemoriesByTagAsync(string[] tags, int page = 1, int pageSize = 20, MemoryOwner? owner = null, string? memoryType = null, CancellationToken cancellationToken = default) - => Task.FromResult<(IReadOnlyList, int)>((new List(), 0)); - - // Provider settings - public Task GetActiveProviderAsync(string providerType, CancellationToken cancellationToken = default) - => throw new NotImplementedException(); - public Task> GetAllProvidersAsync(string providerType, CancellationToken cancellationToken = default) - => throw new NotImplementedException(); - public Task SaveProviderSettingsAsync(ProviderSettings settings, CancellationToken cancellationToken = default) - => throw new NotImplementedException(); - public Task SetActiveProviderAsync(string providerType, string providerName, CancellationToken cancellationToken = default) - => Task.CompletedTask; - - // System memory seeding - public Task<(int ProjectsSeeded, int WorkspacesSeeded)> SeedProjectAndWorkspaceSystemMemoriesAsync(CancellationToken cancellationToken = default) - => Task.FromResult((0, 0)); - - // Data migration tracking - public Task HasDataMigrationRunAsync(string migrationName, CancellationToken cancellationToken = default) - => Task.FromResult(false); - public Task RecordDataMigrationAsync(string migrationName, string? description = null, CancellationToken cancellationToken = default) - => Task.CompletedTask; - public Task ExecuteDataMigrationIfNeededAsync(string migrationName, string description, Func migrationAction, CancellationToken cancellationToken = default) - => Task.FromResult(false); - } - - private class FakeCanonicalUrlService : ICanonicalUrlService - { - public bool IsConfigured { get; set; } - public string BaseUrl { get; set; } = ""; - public bool GetMemoryUrlCalled { get; private set; } - public bool GetWorkspaceUrlCalled { get; private set; } - public bool GetProjectUrlCalled { get; private set; } - - public string? GetMemoryUrl(MemoryId memoryId) - { - GetMemoryUrlCalled = true; - return IsConfigured ? $"{BaseUrl.TrimEnd('/')}/view/{memoryId.Value}" : null; - } - - public string? GetWorkspaceUrl(WorkspaceId workspaceId) - { - GetWorkspaceUrlCalled = true; - return IsConfigured ? $"{BaseUrl.TrimEnd('/')}/workspace/{workspaceId.Value}" : null; - } - - public string? GetProjectUrl(ProjectId projectId) - { - GetProjectUrlCalled = true; - return IsConfigured ? $"{BaseUrl.TrimEnd('/')}/project/{projectId.Value}" : null; - } - } } diff --git a/src/Memorizer.UnitTests/Tools/WorkspaceToolsSlugTests.cs b/src/Memorizer.UnitTests/Tools/WorkspaceToolsSlugTests.cs new file mode 100644 index 0000000..7fe514f --- /dev/null +++ b/src/Memorizer.UnitTests/Tools/WorkspaceToolsSlugTests.cs @@ -0,0 +1,146 @@ +using Memorizer.Models; +using Memorizer.Models.ValueTypes; +using Microsoft.Extensions.Logging.Abstractions; +using PostgMem.Tools; + +namespace Memorizer.UnitTests.Tools; + +/// +/// Tests that verify the GetWorkspace MCP tool resolves workspaces by slug. +/// Uses manual fakes instead of a mocking framework. +/// +public class WorkspaceToolsSlugTests +{ + [Fact] + public async Task GetWorkspace_BySlug_ShouldReturnWorkspaceDetails() + { + // Arrange + var workspaceId = new WorkspaceId(Guid.Parse("b775bb37-4af5-46fe-ad14-7f6fba7889aa")); + var fakeStorage = new FakeWorkspaceStorage + { + WorkspaceBySlug = CreateTestWorkspace(workspaceId, "Engineering") + }; + var tools = CreateTools(fakeStorage); + + // Act + var result = await tools.GetWorkspace(slug: "engineering"); + + // Assert + Assert.Contains("Workspace: Engineering", result); + Assert.Contains("Slug: engineering", result); + Assert.Equal("engineering", fakeStorage.LastSlugQueried); + Assert.Null(fakeStorage.LastParentIdQueried); + } + + [Fact] + public async Task GetWorkspace_BySlug_WithParent_ShouldScopeLookupToParent() + { + // Arrange + var workspaceId = new WorkspaceId(Guid.Parse("b775bb37-4af5-46fe-ad14-7f6fba7889aa")); + var parentId = new WorkspaceId(Guid.Parse("a1874a6b-8a15-4da6-a413-99bf3249d1e4")); + var fakeStorage = new FakeWorkspaceStorage + { + WorkspaceBySlug = CreateTestWorkspace(workspaceId, "Backend") + }; + var tools = CreateTools(fakeStorage); + + // Act + var result = await tools.GetWorkspace(slug: "backend", parentWorkspaceId: parentId.Value.ToString()); + + // Assert + Assert.Contains("Workspace: Backend", result); + Assert.Equal("backend", fakeStorage.LastSlugQueried); + Assert.Equal(parentId, fakeStorage.LastParentIdQueried); + } + + [Fact] + public async Task GetWorkspace_BySlug_ShouldNormalizeToLowercase() + { + // Arrange + var workspaceId = new WorkspaceId(Guid.Parse("b775bb37-4af5-46fe-ad14-7f6fba7889aa")); + var fakeStorage = new FakeWorkspaceStorage + { + WorkspaceBySlug = CreateTestWorkspace(workspaceId, "Engineering") + }; + var tools = CreateTools(fakeStorage); + + // Act: caller passes a mixed-case, padded slug + var result = await tools.GetWorkspace(slug: " Engineering "); + + // Assert: storage is queried with the canonical lowercase, trimmed slug + Assert.Equal("engineering", fakeStorage.LastSlugQueried); + Assert.Contains("Workspace: Engineering", result); + } + + [Fact] + public async Task GetWorkspace_BySlug_WhenNotFound_ShouldReturnNotFoundMessage() + { + // Arrange + var fakeStorage = new FakeWorkspaceStorage + { + WorkspaceBySlug = null + }; + var tools = CreateTools(fakeStorage); + + // Act + var result = await tools.GetWorkspace(slug: "missing"); + + // Assert + Assert.Contains("Workspace with slug 'missing' not found among root workspaces.", result); + } + + [Fact] + public async Task GetWorkspace_BySlug_WhenNotFoundUnderParent_ShouldMentionParentScope() + { + // Arrange + var parentId = new WorkspaceId(Guid.Parse("a1874a6b-8a15-4da6-a413-99bf3249d1e4")); + var fakeStorage = new FakeWorkspaceStorage + { + WorkspaceBySlug = null + }; + var tools = CreateTools(fakeStorage); + + // Act + var result = await tools.GetWorkspace(slug: "missing", parentWorkspaceId: parentId.Value.ToString()); + + // Assert + Assert.Contains($"Workspace with slug 'missing' not found under parent {parentId.Value}", result); + } + + [Fact] + public async Task GetWorkspace_QueryTakesPrecedenceOverSlug() + { + // Arrange + var fakeStorage = new FakeWorkspaceStorage(); + var tools = CreateTools(fakeStorage); + + // Act + var result = await tools.GetWorkspace(slug: "engineering", query: "eng"); + + // Assert: query path was taken, so slug lookup was never invoked + Assert.Null(fakeStorage.LastSlugQueried); + Assert.Contains("No workspaces found matching 'eng'", result); + } + + private static WorkspaceTools CreateTools(FakeWorkspaceStorage storage) + { + var logger = new NullLogger(); + var urlService = new FakeCanonicalUrlService { IsConfigured = false }; + return new WorkspaceTools(storage, logger, urlService); + } + + private static Workspace CreateTestWorkspace(WorkspaceId id, string name) + { + return new Workspace + { + Id = id, + Name = name, + Slug = name.ToLower().Replace(" ", "-"), + Description = null, + IsSystem = false, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + ParentId = null + }; + } +} diff --git a/src/Memorizer/Tools/WorkspaceTools.cs b/src/Memorizer/Tools/WorkspaceTools.cs index 8caa8f4..36bd2f3 100644 --- a/src/Memorizer/Tools/WorkspaceTools.cs +++ b/src/Memorizer/Tools/WorkspaceTools.cs @@ -29,10 +29,12 @@ public WorkspaceTools(IStorage storage, ILogger logger, ICanonic // ===== Workspace Tools ===== - [McpServerTool, Description("Get workspace information. Without an ID, lists root workspaces with hints about nested content. With an ID, shows detailed workspace info. With a query, searches all workspaces by name. Workspaces are organizational containers (e.g., 'Engineering', 'Sales') that persist indefinitely and can be nested.")] + [McpServerTool, Description("Get workspace information. Without an ID, lists root workspaces with hints about nested content. With an ID, shows detailed workspace info. With a slug, looks up a workspace by its URL-safe identifier (optionally scoped to a parent). With a query, searches all workspaces by name. Workspaces are organizational containers (e.g., 'Engineering', 'Sales') that persist indefinitely and can be nested.")] public async Task GetWorkspace( [Description("Optional workspace ID. If omitted, lists root workspaces.")] string? workspaceId = null, + [Description("Optional workspace slug (URL-safe identifier). Unique within its parent scope; use parentWorkspaceId to disambiguate nested workspaces.")] string? slug = null, [Description("Optional search query to find workspaces by name (searches all levels). Case-insensitive partial match.")] string? query = null, + [Description("Optional parent workspace ID to scope a slug lookup. Omit to look up a slug among root workspaces.")] string? parentWorkspaceId = null, [Description("Include system workspaces (like 'Unfiled'). Only applies when listing workspaces.")] bool includeSystem = false, CancellationToken cancellationToken = default ) @@ -43,6 +45,22 @@ public async Task GetWorkspace( return await SearchWorkspacesAsync(query, includeSystem, cancellationToken); } + // If slug is provided, look it up (optionally scoped to a parent workspace) + if (!string.IsNullOrWhiteSpace(slug)) + { + // Slugs are stored lowercase (see GenerateSlug); normalize so lookups are case-insensitive. + var normalizedSlug = slug.Trim().ToLowerInvariant(); + var parsedParentId = ParseOptionalGuid(parentWorkspaceId); + var parentId = parsedParentId.HasValue ? new WorkspaceId(parsedParentId.Value) : (WorkspaceId?)null; + var bySlug = await _storage.GetWorkspaceBySlugAsync(normalizedSlug, parentId, cancellationToken); + if (bySlug == null) + { + var scope = parentId.HasValue ? $" under parent {parentId.Value.Value}" : " among root workspaces"; + return $"Workspace with slug '{normalizedSlug}' not found{scope}."; + } + return await GetWorkspaceDetailsAsync(bySlug, cancellationToken); + } + // Parse optional Guid defensively — MCP clients may send empty strings or "null" var parsedWorkspaceId = ParseOptionalGuid(workspaceId); @@ -53,7 +71,12 @@ public async Task GetWorkspace( } // Get specific workspace details - return await GetWorkspaceDetailsAsync(new WorkspaceId(parsedWorkspaceId.Value), cancellationToken); + var workspace = await _storage.GetWorkspaceAsync(new WorkspaceId(parsedWorkspaceId.Value), cancellationToken); + if (workspace == null) + { + return $"Workspace with ID {parsedWorkspaceId.Value} not found."; + } + return await GetWorkspaceDetailsAsync(workspace, cancellationToken); } private async Task SearchWorkspacesAsync(string query, bool includeSystem, CancellationToken cancellationToken) @@ -131,14 +154,9 @@ private async Task ListRootWorkspacesAsync(bool includeSystem, Cancellat return result.ToString(); } - private async Task GetWorkspaceDetailsAsync(WorkspaceId workspaceId, CancellationToken cancellationToken) + private async Task GetWorkspaceDetailsAsync(Workspace workspace, CancellationToken cancellationToken) { - var workspace = await _storage.GetWorkspaceAsync(workspaceId, cancellationToken); - - if (workspace == null) - { - return $"Workspace with ID {workspaceId.Value} not found."; - } + var workspaceId = workspace.Id; // Get path for breadcrumb var path = await _storage.GetWorkspacePathAsync(workspaceId, cancellationToken);