Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 124 additions & 0 deletions src/Memorizer.IntegrationTests/HybridSearchIntegrationTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
using Memorizer.Extensions;
using Memorizer.Models;
using Memorizer.Models.ValueTypes;
using Memorizer.Services;
using Memorizer.Settings;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Xunit.Abstractions;

namespace Memorizer.IntegrationTests;

/// <summary>
/// Integration tests for the hybrid search behavior that the web UI now uses by
/// default (see ADR 2026-02-14-hybrid-search-rrf.md): short keyword queries that
/// fail with metadata-embedding vector search at the default threshold are still
/// found via the full-text leg.
/// </summary>
[Collection(nameof(IntegrationTestCollection))]
public class HybridSearchIntegrationTests : IDisposable
{
private readonly IntegrationTestFixture _fixture;
private readonly IServiceProvider _services;

public void Dispose()
{
(_services as IDisposable)?.Dispose();
}

public HybridSearchIntegrationTests(IntegrationTestFixture fixture, ITestOutputHelper output)
{
_fixture = fixture;
_services = CreateServices();
}

private IServiceProvider CreateServices()
{
var services = new ServiceCollection();

services.AddSingleton<IConfiguration>(new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["ConnectionStrings:Storage"] = _fixture.PostgresConnectionString,
["Embeddings:ApiUrl"] = _fixture.OllamaApiUrl,
["Embeddings:Model"] = "all-minilm",
["Embeddings:Timeout"] = TimeSpan.FromMinutes(1).ToString()
})
.Build());

services.AddHttpClient<IEmbeddingService, EmbeddingService>(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();

return services.BuildServiceProvider();
}

[Fact]
public async Task HybridSearch_FindsShortKeywordQueries_ThatVectorSearchMisses()
{
// Arrange - the ADR scenario: short keyword queries fail at the default threshold
var storage = _services.GetRequiredService<IStorage>();

var created = new List<MemoryId>();
try
{
created.Add((await storage.StoreMemory(
"reference", "Notes about race conditions in concurrent code and mutex handling", "test",
new[] { "concurrency" }, new Confidence(1.0), "Race condition deep dive")).Id);
created.Add((await storage.StoreMemory(
"reference", "Understanding dependency injection containers and service lifetimes", "test",
new[] { "di" }, new Confidence(1.0), "Dependency injection")).Id);

// Act - hybrid search should surface exact keyword matches via the FTS leg
var hybridResults = await storage.HybridSearch("race condition", limit: 5, minSimilarity: null, filterTags: null);

// Assert
Assert.NotEmpty(hybridResults);
Assert.Contains(hybridResults, m => m.Title?.Contains("Race condition") == true);
}
finally
{
foreach (var id in created)
await storage.Delete(id);
}
}

[Fact]
public async Task VectorSearch_StillReturnsResults_ForShortKeywordQueries()
{
// Arrange
var storage = _services.GetRequiredService<IStorage>();

var created = new List<MemoryId>();
try
{
created.Add((await storage.StoreMemory(
"reference", "Notes about race conditions in concurrent code and mutex handling", "test",
new[] { "concurrency" }, new Confidence(1.0), "Race condition deep dive")).Id);

// Act - method=vector mode must still work (metadata embedding search)
var vectorResults = await storage.SearchWithMetadataEmbedding(
"race condition", limit: 5, new SimilarityScore(0.3), filterTags: null);

// Assert - a lenient threshold should still surface the result
Assert.Contains(vectorResults, m => m.Title?.Contains("Race condition") == true);
}
finally
{
foreach (var id in created)
await storage.Delete(id);
}
}
}
175 changes: 175 additions & 0 deletions src/Memorizer.IntegrationTests/TagCloudIntegrationTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
using Memorizer.Extensions;
using Memorizer.Models;
using Memorizer.Models.Enums;
using Memorizer.Models.ValueTypes;
using Memorizer.Services;
using Memorizer.Settings;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Xunit.Abstractions;

namespace Memorizer.IntegrationTests;

/// <summary>
/// Integration tests for the tag cloud service (ITagCloudService).
/// </summary>
[Collection(nameof(IntegrationTestCollection))]
public class TagCloudIntegrationTests : IDisposable
{
private readonly IntegrationTestFixture _fixture;
private readonly IServiceProvider _services;

public void Dispose()
{
(_services as IDisposable)?.Dispose();
}

public TagCloudIntegrationTests(IntegrationTestFixture fixture, ITestOutputHelper output)
{
_fixture = fixture;
_services = CreateServices();
}

private IServiceProvider CreateServices()
{
var services = new ServiceCollection();

services.AddSingleton<IConfiguration>(new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["ConnectionStrings:Storage"] = _fixture.PostgresConnectionString,
["Embeddings:ApiUrl"] = _fixture.OllamaApiUrl,
["Embeddings:Model"] = "all-minilm",
["Embeddings:Timeout"] = TimeSpan.FromMinutes(1).ToString()
})
.Build());

services.AddHttpClient<IEmbeddingService, EmbeddingService>(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();

return services.BuildServiceProvider();
}

[Fact]
public async Task WorkspaceSubtreeTagCounts_AggregateProjectsAndNestedWorkspaces()
{
// Arrange
var storage = _services.GetRequiredService<IStorage>();
var tagCloud = _services.GetRequiredService<ITagCloudService>();

var workspace = await storage.CreateWorkspaceAsync("TagCloud Root", "root");
var project = await storage.CreateProjectAsync(workspace.Id, "TagCloud Project", "p");
var nested = await storage.CreateWorkspaceAsync("TagCloud Nested", "n", parentId: workspace.Id);

var created = new List<MemoryId>();
try
{
created.Add((await storage.StoreMemory(
"reference", "workspace direct memory", "test", new[] { "alpha" },
new Confidence(1.0), "Workspace Memory", owner: MemoryOwner.ForWorkspace(workspace.Id))).Id);
created.Add((await storage.StoreMemory(
"reference", "project memory", "test", new[] { "beta" },
new Confidence(1.0), "Project Memory", owner: MemoryOwner.ForProject(project.Id))).Id);
created.Add((await storage.StoreMemory(
"reference", "nested workspace memory", "test", new[] { "alpha", "gamma" },
new Confidence(1.0), "Nested Memory", owner: MemoryOwner.ForWorkspace(nested.Id))).Id);

// Act - subtree covers direct + project + nested workspace tags
var subtreeCounts = await tagCloud.GetWorkspaceSubtreeTagCountsAsync(workspace.Id);

// Assert
Assert.Contains(subtreeCounts, x => x.Tag == "alpha" && x.Count == 2);
Assert.Contains(subtreeCounts, x => x.Tag == "beta" && x.Count == 1);
Assert.Contains(subtreeCounts, x => x.Tag == "gamma" && x.Count == 1);
}
finally
{
foreach (var id in created)
await storage.Delete(id);
await storage.DeleteProjectAsync(project.Id);
await storage.DeleteWorkspaceAsync(nested.Id);
await storage.DeleteWorkspaceAsync(workspace.Id);
}
}

[Fact]
public async Task ProjectTagCounts_OnlyIncludeProjectMemories()
{
// Arrange
var storage = _services.GetRequiredService<IStorage>();
var tagCloud = _services.GetRequiredService<ITagCloudService>();

var workspace = await storage.CreateWorkspaceAsync("TagCloud Root", "root");
var project = await storage.CreateProjectAsync(workspace.Id, "TagCloud Project", "p");

var created = new List<MemoryId>();
try
{
created.Add((await storage.StoreMemory(
"reference", "workspace memory", "test", new[] { "alpha" },
new Confidence(1.0), "Workspace Memory", owner: MemoryOwner.ForWorkspace(workspace.Id))).Id);
created.Add((await storage.StoreMemory(
"reference", "project memory", "test", new[] { "beta" },
new Confidence(1.0), "Project Memory", owner: MemoryOwner.ForProject(project.Id))).Id);

// Act
var projectCounts = await tagCloud.GetProjectTagCountsAsync(project.Id);

// Assert - only the project's own tag, not the workspace's
var beta = Assert.Single(projectCounts);
Assert.Equal("beta", beta.Tag);
Assert.Equal(1, beta.Count);
}
finally
{
foreach (var id in created)
await storage.Delete(id);
await storage.DeleteProjectAsync(project.Id);
await storage.DeleteWorkspaceAsync(workspace.Id);
}
}

[Fact]
public async Task GlobalTagCounts_IncludeAllNonArchivedMemories()
{
// Arrange
var storage = _services.GetRequiredService<IStorage>();
var tagCloud = _services.GetRequiredService<ITagCloudService>();

var created = new List<MemoryId>();
try
{
created.Add((await storage.StoreMemory(
"reference", "global memory one", "test", new[] { "global-tag" },
new Confidence(1.0), "Global One")).Id);
created.Add((await storage.StoreMemory(
"reference", "global memory two", "test", new[] { "global-tag", "other-tag" },
new Confidence(1.0), "Global Two")).Id);

// Act
var globalCounts = await tagCloud.GetGlobalTagCountsAsync();

// Assert - counts aggregate across all memories
Assert.Contains(globalCounts, x => x.Tag == "global-tag" && x.Count == 2);
Assert.Contains(globalCounts, x => x.Tag == "other-tag" && x.Count == 1);
}
finally
{
foreach (var id in created)
await storage.Delete(id);
}
}
}
Loading