Skip to content
Merged
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
4 changes: 1 addition & 3 deletions .azure/container-group.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,6 @@ properties:
value: ${APPINSIGHTS_CONN_STR}
- name: ConnectionStrings__bottomlymodel
secureValue: "Endpoint=http://localhost:11434;Key=${OLLAMA_API_KEY}"
- name: EnableLlm
value: "true"
- name: ollama
properties:
image: ollama/ollama:latest
Expand All @@ -59,5 +57,5 @@ properties:
protocol: TCP
osType: Linux
restartPolicy: Always
tags: {}
tags: { }
type: Microsoft.ContainerInstance/containerGroups
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
using Bottomly.Repositories;
using Bottomly.Tests.Infrastructure;
using MongoDB.Driver;
using Shouldly;

namespace Bottomly.Tests.Repositories.Integration;

[Collection("MongoDB")]
public class FeatureFlagRepositoryIntegrationTests(MongoDbFixture fixture) : IAsyncLifetime
{
private IMongoDatabase _db = null!;
private FeatureFlagRepository _sut = null!;

public Task InitializeAsync()
{
_db = fixture.GetDatabase($"flags_test_{Guid.NewGuid():N}");
_sut = new FeatureFlagRepository(_db);
return Task.CompletedTask;
}

public async Task DisposeAsync() =>
await fixture.Client.DropDatabaseAsync(_db.DatabaseNamespace.DatabaseName);

// ─── GetAsync ──────────────────────────────────────────────────────────────

[Fact]
public async Task GetAsync_WhenFlagDoesNotExist_ReturnsFalse()
{
var result = await _sut.GetAsync("EnableLlm");

result.ShouldBeFalse();
}

[Fact]
public async Task GetAsync_WhenFlagIsTrue_ReturnsTrue()
{
await _sut.SetAsync("EnableLlm", true);

var result = await _sut.GetAsync("EnableLlm");

result.ShouldBeTrue();
}

[Fact]
public async Task GetAsync_WhenFlagIsFalse_ReturnsFalse()
{
await _sut.SetAsync("EnableLlm", false);

var result = await _sut.GetAsync("EnableLlm");

result.ShouldBeFalse();
}

// ─── SetAsync ──────────────────────────────────────────────────────────────

[Fact]
public async Task SetAsync_CreatesDocumentWhenItDoesNotExist()
{
await _sut.SetAsync("EnableLlm", true);

var result = await _sut.GetAsync("EnableLlm");
result.ShouldBeTrue();
}

[Fact]
public async Task SetAsync_UpdatesExistingDocument()
{
await _sut.SetAsync("EnableLlm", true);
await _sut.SetAsync("EnableLlm", false);

var result = await _sut.GetAsync("EnableLlm");
result.ShouldBeFalse();
}

[Fact]
public async Task SetAsync_ToggleOnThenOff_ReturnsCorrectValue()
{
await _sut.SetAsync("EnableLlm", false);
await _sut.SetAsync("EnableLlm", true);
await _sut.SetAsync("EnableLlm", false);

var result = await _sut.GetAsync("EnableLlm");
result.ShouldBeFalse();
}

// ─── SeedAsync ─────────────────────────────────────────────────────────────

[Fact]
public async Task SeedAsync_WhenFlagDoesNotExist_CreatesWithDefaultValue()
{
await _sut.SeedAsync("EnableLlm", true);

var result = await _sut.GetAsync("EnableLlm");
result.ShouldBeTrue();
}

[Fact]
public async Task SeedAsync_WhenFlagAlreadyExists_DoesNotOverwrite()
{
await _sut.SetAsync("EnableLlm", true);

await _sut.SeedAsync("EnableLlm", false);

var result = await _sut.GetAsync("EnableLlm");
result.ShouldBeTrue();
}

[Fact]
public async Task SeedAsync_CalledMultipleTimes_Idempotent()
{
await _sut.SeedAsync("EnableLlm", false);
await _sut.SeedAsync("EnableLlm", true);
await _sut.SeedAsync("EnableLlm", true);

var result = await _sut.GetAsync("EnableLlm");
result.ShouldBeFalse();
}

// ─── Multiple flags ────────────────────────────────────────────────────────

[Fact]
public async Task SetAsync_DifferentFlagIds_AreStoredIndependently()
{
await _sut.SetAsync("EnableLlm", true);
await _sut.SetAsync("AnotherFlag", false);

(await _sut.GetAsync("EnableLlm")).ShouldBeTrue();
(await _sut.GetAsync("AnotherFlag")).ShouldBeFalse();
}
}
161 changes: 161 additions & 0 deletions Bottomly.Tests/Repositories/Unit/CachingMemberRepositoryTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
using Bottomly.Models;
using Bottomly.Repositories;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
using Moq;
using Shouldly;

namespace Bottomly.Tests.Repositories.Unit;

public class CachingMemberRepositoryTests
{
private readonly Mock<IMemberRepository> _mockInner = new();
private readonly IMemoryCache _cache = new MemoryCache(Options.Create(new MemoryCacheOptions()));
private readonly CachingMemberRepository _repo;

public CachingMemberRepositoryTests()
{
_repo = new CachingMemberRepository(_mockInner.Object, _cache);
}

[Fact]
public async Task GetBySlackIdAsync_CacheMiss_CallsInnerAndCachesResult()
{
var member = new Member { SlackId = "U1", Username = "alice" };
_mockInner.Setup(r => r.GetBySlackIdAsync("U1")).ReturnsAsync(member);

var result = await _repo.GetBySlackIdAsync("U1");

result.ShouldBe(member);
_mockInner.Verify(r => r.GetBySlackIdAsync("U1"), Times.Once);
}

[Fact]
public async Task GetBySlackIdAsync_CacheHit_DoesNotCallInner()
{
var member = new Member { SlackId = "U1", Username = "alice" };
_mockInner.Setup(r => r.GetBySlackIdAsync("U1")).ReturnsAsync(member);

await _repo.GetBySlackIdAsync("U1"); // Populates cache
await _repo.GetBySlackIdAsync("U1"); // Should hit cache

_mockInner.Verify(r => r.GetBySlackIdAsync("U1"), Times.Once);
}

[Fact]
public async Task GetByUsernameAsync_CacheMiss_CallsInnerAndCachesResult()
{
var member = new Member { SlackId = "U1", Username = "alice" };
_mockInner.Setup(r => r.GetByUsernameAsync("alice")).ReturnsAsync(member);

var result = await _repo.GetByUsernameAsync("alice");

result.ShouldBe(member);
_mockInner.Verify(r => r.GetByUsernameAsync("alice"), Times.Once);
}

[Fact]
public async Task GetByUsernameAsync_CacheHit_DoesNotCallInner()
{
var member = new Member { SlackId = "U1", Username = "alice" };
_mockInner.Setup(r => r.GetByUsernameAsync("alice")).ReturnsAsync(member);

await _repo.GetByUsernameAsync("alice"); // Populates cache
await _repo.GetByUsernameAsync("alice"); // Should hit cache

_mockInner.Verify(r => r.GetByUsernameAsync("alice"), Times.Once);
}

[Fact]
public async Task GetBySlackIdAsync_AfterGetByUsernameAsync_DoesNotCallInner()
{
var member = new Member { SlackId = "U1", Username = "alice" };
_mockInner.Setup(r => r.GetByUsernameAsync("alice")).ReturnsAsync(member);

await _repo.GetByUsernameAsync("alice"); // Caches by both keys
await _repo.GetBySlackIdAsync("U1"); // Should hit cache (cross-key)

_mockInner.Verify(r => r.GetBySlackIdAsync("U1"), Times.Never);
}

[Fact]
public async Task GetAllAsync_PopulatesBothCacheKeys()
{
var members = new List<Member>
{
new() { SlackId = "U1", Username = "alice" },
new() { SlackId = "U2", Username = "bob" }
};
_mockInner.Setup(r => r.GetAllAsync()).ReturnsAsync(members);

var result = await _repo.GetAllAsync();

result.ShouldBe(members);
// Subsequent lookups should hit cache
await _repo.GetBySlackIdAsync("U1");
await _repo.GetByUsernameAsync("bob");
_mockInner.Verify(r => r.GetBySlackIdAsync(It.IsAny<string>()), Times.Never);
_mockInner.Verify(r => r.GetByUsernameAsync(It.IsAny<string>()), Times.Never);
}

[Fact]
public async Task GetBySlackIdsAsync_AllCached_DoesNotCallInner()
{
var members = new List<Member>
{
new() { SlackId = "U1", Username = "alice" },
new() { SlackId = "U2", Username = "bob" }
};
_mockInner.Setup(r => r.GetAllAsync()).ReturnsAsync(members);
await _repo.GetAllAsync(); // Warm the cache

var result = await _repo.GetBySlackIdsAsync(["U1", "U2"]);

result.Count.ShouldBe(2);
_mockInner.Verify(r => r.GetBySlackIdsAsync(It.IsAny<IEnumerable<string>>()), Times.Never);
}

[Fact]
public async Task GetBySlackIdsAsync_PartialCacheMiss_FetchesMissesFromInner()
{
var cachedMember = new Member { SlackId = "U1", Username = "alice" };
var missedMember = new Member { SlackId = "U2", Username = "bob" };
_mockInner.Setup(r => r.GetBySlackIdAsync("U1")).ReturnsAsync(cachedMember);
_mockInner.Setup(r => r.GetBySlackIdsAsync(It.Is<IEnumerable<string>>(ids => ids.Contains("U2"))))
.ReturnsAsync([missedMember]);

await _repo.GetBySlackIdAsync("U1"); // Cache U1 only

var result = await _repo.GetBySlackIdsAsync(["U1", "U2"]);

result.Count.ShouldBe(2);
_mockInner.Verify(r => r.GetBySlackIdsAsync(It.IsAny<IEnumerable<string>>()), Times.Once);
}

[Fact]
public async Task AddAsync_SingleMember_CachesMember()
{
var member = new Member { SlackId = "U1", Username = "alice" };
_mockInner.Setup(r => r.AddAsync(member)).Returns(Task.CompletedTask);

await _repo.AddAsync(member);
await _repo.GetBySlackIdAsync("U1");

_mockInner.Verify(r => r.GetBySlackIdAsync(It.IsAny<string>()), Times.Never);
}

[Fact]
public async Task UpdateInfoAsync_UpdatesCacheWithFreshData()
{
var updated = new Member { SlackId = "U1", Username = "alice", FullName = "Alice Smith" };
_mockInner.Setup(r => r.UpdateInfoAsync("alice", "Alice Smith", It.IsAny<Gender>(), It.IsAny<SassLevel>(), It.IsAny<string>()))
.Returns(Task.CompletedTask);
_mockInner.Setup(r => r.GetByUsernameAsync("alice")).ReturnsAsync(updated);

await _repo.UpdateInfoAsync("alice", "Alice Smith", Gender.Unknown, SassLevel.Moderate, string.Empty);
var result = await _repo.GetByUsernameAsync("alice");

result!.FullName.ShouldBe("Alice Smith");
_mockInner.Verify(r => r.GetByUsernameAsync("alice"), Times.Once); // Called once during UpdateInfoAsync
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,17 +19,22 @@ public class ConversationMessageHandlerTests
private readonly Mock<ISlackApiClient> _mockApiClient = new();
private readonly Mock<IConversationsApi> _mockConversations = new();
private readonly Mock<IMemberRepository> _mockMemberRepo = new();
private readonly Mock<IFeatureFlagRepository> _mockFeatureFlagRepo = new();
private readonly ConversationMessageHandler _handler;

public ConversationMessageHandlerTests()
{
_mockApiClient.Setup(a => a.Conversations).Returns(_mockConversations.Object);
_mockFeatureFlagRepo.Setup(r => r.GetAsync("EnableLlm")).ReturnsAsync(true);
_mockMemberRepo.Setup(r => r.GetByUsernameAsync("bottomly"))
.ReturnsAsync(new Member { Username = "bottomly", SlackId = "UBOTID" });

_handler = new ConversationMessageHandler(
_mockLlmBroker.Object,
_mockSlackBroker.Object,
_mockApiClient.Object,
_mockMemberRepo.Object,
_mockFeatureFlagRepo.Object,
NullLogger<ConversationMessageHandler>.Instance);
}

Expand All @@ -40,13 +45,16 @@ private static MessageEvent CreateMessage(string text, string user = "U1", strin
[InlineData("hey bottomly what do you think?")]
[InlineData("bottomly, help me")]
[InlineData("I asked bottomly already")]
[InlineData("<@UBOTID> what do you think?")]
[InlineData("<@UBOTID>")]
public void CanHandle_MessageContainsBottomly_ReturnsTrue(string text) =>
_handler.CanHandle(CreateMessage(text)).ShouldBeTrue();

[Theory]
[InlineData("hello there")]
[InlineData("_karma alice")]
[InlineData("")]
[InlineData("<@UOTHERID> what do you think?")]
public void CanHandle_MessageWithoutBottomly_ReturnsFalse(string text) =>
_handler.CanHandle(CreateMessage(text)).ShouldBeFalse();

Expand Down Expand Up @@ -123,6 +131,21 @@ public async Task HandleAsync_ErrorLlmResponseInThread_SendsReplyToThread()
_mockSlackBroker.Verify(b => b.SendMessageAsync(It.IsAny<string>(), "C1", "thread_ts1"), Times.Once());
}

[Fact]
public async Task HandleAsync_LlmFlagDisabled_SkipsLlmAndSendsNothing()
{
_mockFeatureFlagRepo.Setup(r => r.GetAsync("EnableLlm")).ReturnsAsync(false);

await _handler.HandleAsync(CreateMessage("bottomly what is 2+2?", "U1", "C1"));

_mockLlmBroker.Verify(
b => b.Respond(It.IsAny<BottomlyInputMessage>(), It.IsAny<MessageHistoryContext>()),
Times.Never());
_mockSlackBroker.Verify(
b => b.SendMessageAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string?>()),
Times.Never());
}

private void SetupConversationHistory(string channel, List<MessageEvent> messages) =>
_mockConversations
.Setup(c => c.History(channel, It.IsAny<string>(), It.IsAny<string>(), It.IsAny<bool>(),
Expand Down
Loading
Loading