diff --git a/Bottomly.Tests/Bottomly.Tests.csproj b/Bottomly.Tests/Bottomly.Tests.csproj
index 4027a65..7337624 100644
--- a/Bottomly.Tests/Bottomly.Tests.csproj
+++ b/Bottomly.Tests/Bottomly.Tests.csproj
@@ -9,22 +9,23 @@
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
-
+
-
+
\ No newline at end of file
diff --git a/Bottomly.Tests/Infrastructure/MongoDbCollection.cs b/Bottomly.Tests/Infrastructure/MongoDbCollection.cs
new file mode 100644
index 0000000..272eeb9
--- /dev/null
+++ b/Bottomly.Tests/Infrastructure/MongoDbCollection.cs
@@ -0,0 +1,4 @@
+namespace Bottomly.Tests.Infrastructure;
+
+[CollectionDefinition("MongoDB")]
+public class MongoDbCollection : ICollectionFixture;
diff --git a/Bottomly.Tests/Infrastructure/MongoDbFixture.cs b/Bottomly.Tests/Infrastructure/MongoDbFixture.cs
new file mode 100644
index 0000000..30956c3
--- /dev/null
+++ b/Bottomly.Tests/Infrastructure/MongoDbFixture.cs
@@ -0,0 +1,26 @@
+using MongoDB.Driver;
+using Testcontainers.MongoDb;
+
+namespace Bottomly.Tests.Infrastructure;
+
+///
+/// Shared xUnit fixture that starts a single MongoDB container for the entire test collection.
+/// Each test class should call with a unique name to ensure isolation.
+///
+public sealed class MongoDbFixture : IAsyncLifetime
+{
+ private readonly MongoDbContainer _container = new MongoDbBuilder("mongo:8")
+ .Build();
+
+ public IMongoClient Client { get; private set; } = null!;
+
+ public IMongoDatabase GetDatabase(string name) => Client.GetDatabase(name);
+
+ public async Task InitializeAsync()
+ {
+ await _container.StartAsync();
+ Client = new MongoClient(_container.GetConnectionString());
+ }
+
+ public async Task DisposeAsync() => await _container.DisposeAsync();
+}
diff --git a/Bottomly.Tests/Repositories/Integration/KarmaRepositoryIntegrationTests.cs b/Bottomly.Tests/Repositories/Integration/KarmaRepositoryIntegrationTests.cs
new file mode 100644
index 0000000..8fd83fa
--- /dev/null
+++ b/Bottomly.Tests/Repositories/Integration/KarmaRepositoryIntegrationTests.cs
@@ -0,0 +1,192 @@
+using Bottomly.Models;
+using Bottomly.Repositories;
+using Bottomly.Tests.Infrastructure;
+using MongoDB.Driver;
+using Shouldly;
+
+namespace Bottomly.Tests.Repositories.Integration;
+
+[Collection("MongoDB")]
+public class KarmaRepositoryIntegrationTests(MongoDbFixture fixture) : IAsyncLifetime
+{
+ private IMongoDatabase _db = null!;
+ private KarmaRepository _sut = null!;
+
+ public Task InitializeAsync()
+ {
+ _db = fixture.GetDatabase($"karma_test_{Guid.NewGuid():N}");
+ _sut = new KarmaRepository(_db);
+ return Task.CompletedTask;
+ }
+
+ public async Task DisposeAsync() =>
+ await fixture.Client.DropDatabaseAsync(_db.DatabaseNamespace.DatabaseName);
+
+ [Fact]
+ public async Task AddAsync_PersistsKarmaDocument()
+ {
+ var karma = MakeKarma("alice", "bob", KarmaType.PozzyPoz, "great PR");
+
+ await _sut.AddAsync(karma);
+
+ // Verify via GetCurrentNetKarmaAsync — if persisted, aggregate returns a value
+ var net = await _sut.GetCurrentNetKarmaAsync("alice");
+ net.ShouldNotBe(0);
+ }
+
+ [Fact]
+ public async Task GetCurrentNetKarmaAsync_WithMixedKarma_ReturnsCorrectNetScore()
+ {
+ // 2× PozzyPoz = –2, 3× NeggyNeg = +3 → net = +1
+ await _sut.AddAsync(MakeKarma("alice", "x", KarmaType.PozzyPoz));
+ await _sut.AddAsync(MakeKarma("alice", "x", KarmaType.PozzyPoz));
+ await _sut.AddAsync(MakeKarma("alice", "x", KarmaType.NeggyNeg));
+ await _sut.AddAsync(MakeKarma("alice", "x", KarmaType.NeggyNeg));
+ await _sut.AddAsync(MakeKarma("alice", "x", KarmaType.NeggyNeg));
+
+ var net = await _sut.GetCurrentNetKarmaAsync("alice");
+
+ net.ShouldBe(1);
+ }
+
+ [Fact]
+ public async Task GetCurrentNetKarmaAsync_IsCaseInsensitive()
+ {
+ await _sut.AddAsync(MakeKarma("Bob", "x", KarmaType.NeggyNeg));
+
+ var net = await _sut.GetCurrentNetKarmaAsync("bob");
+
+ net.ShouldBe(1);
+ }
+
+ [Fact]
+ public async Task GetCurrentNetKarmaAsync_WhenNoKarmaExists_ReturnsZero()
+ {
+ var net = await _sut.GetCurrentNetKarmaAsync("nobody");
+
+ net.ShouldBe(0);
+ }
+
+ [Fact]
+ public async Task GetCurrentNetKarmaAsync_ExcludesExpiredEntries()
+ {
+ var expired = MakeKarma("carol", "x", KarmaType.NeggyNeg);
+ expired.Awarded = DateTime.UtcNow.AddDays(-(Karma.ExpiryDays + 1));
+ await _sut.AddAsync(expired);
+
+ var net = await _sut.GetCurrentNetKarmaAsync("carol");
+
+ net.ShouldBe(0);
+ }
+
+ [Fact]
+ public async Task GetKarmaReasonsAsync_SeparatesReasonedFromReasonless()
+ {
+ await _sut.AddAsync(MakeKarma("dave", "x", KarmaType.PozzyPoz, "fixed the build"));
+ await _sut.AddAsync(MakeKarma("dave", "x", KarmaType.PozzyPoz, "great docs"));
+ await _sut.AddAsync(MakeKarma("dave", "x", KarmaType.NeggyNeg)); // no reason
+
+ var result = await _sut.GetKarmaReasonsAsync("dave");
+
+ result.Reasonless.ShouldBe(1);
+ result.Reasoned.Count.ShouldBe(2);
+ result.Reasoned.Select(k => k.Reason).ShouldBe(
+ ["fixed the build", "great docs"], ignoreOrder: true);
+ }
+
+ [Fact]
+ public async Task GetKarmaReasonsAsync_IsCaseInsensitiveOnRecipient()
+ {
+ await _sut.AddAsync(MakeKarma("Eve", "x", KarmaType.PozzyPoz, "helpful"));
+
+ var result = await _sut.GetKarmaReasonsAsync("eve");
+
+ result.Reasoned.Count.ShouldBe(1);
+ }
+
+ [Fact]
+ public async Task GetKarmaReasonsAsync_ExcludesExpiredEntries()
+ {
+ var expired = MakeKarma("frank", "x", KarmaType.PozzyPoz, "stale reason");
+ expired.Awarded = DateTime.UtcNow.AddDays(-(Karma.ExpiryDays + 1));
+ await _sut.AddAsync(expired);
+
+ var result = await _sut.GetKarmaReasonsAsync("frank");
+
+ result.Reasoned.Count.ShouldBe(0);
+ result.Reasonless.ShouldBe(0);
+ }
+
+ [Fact]
+ public async Task GetLeaderBoardAsync_ReturnsCorrectOrderAndSize()
+ {
+ // net_karma: PozzyPoz → –1, NeggyNeg → +1
+ // Leader board is sorted Descending by net_karma
+ await AddKarmaMultiple("leader-a", KarmaType.NeggyNeg, 5); // net +5
+ await AddKarmaMultiple("leader-b", KarmaType.NeggyNeg, 3); // net +3
+ await AddKarmaMultiple("leader-c", KarmaType.NeggyNeg, 1); // net +1
+ await AddKarmaMultiple("leader-d", KarmaType.PozzyPoz, 2); // net –2 (should not appear in top 3)
+
+ var board = await _sut.GetLeaderBoardAsync(3);
+
+ board.Count.ShouldBe(3);
+ board[0].Username.ShouldBe("leader-a");
+ board[0].NetKarma.ShouldBe(5);
+ board[1].Username.ShouldBe("leader-b");
+ board[1].NetKarma.ShouldBe(3);
+ board[2].Username.ShouldBe("leader-c");
+ board[2].NetKarma.ShouldBe(1);
+ }
+
+ [Fact]
+ public async Task GetLoserBoardAsync_ReturnsCorrectOrderAndSize()
+ {
+ // Loser board is sorted Ascending by net_karma
+ await AddKarmaMultiple("loser-a", KarmaType.PozzyPoz, 5); // net –5
+ await AddKarmaMultiple("loser-b", KarmaType.PozzyPoz, 3); // net –3
+ await AddKarmaMultiple("loser-c", KarmaType.PozzyPoz, 1); // net –1
+ await AddKarmaMultiple("loser-d", KarmaType.NeggyNeg, 2); // net +2 (should not appear in top 3)
+
+ var board = await _sut.GetLoserBoardAsync(3);
+
+ board.Count.ShouldBe(3);
+ board[0].Username.ShouldBe("loser-a");
+ board[0].NetKarma.ShouldBe(-5);
+ board[1].Username.ShouldBe("loser-b");
+ board[1].NetKarma.ShouldBe(-3);
+ board[2].Username.ShouldBe("loser-c");
+ board[2].NetKarma.ShouldBe(-1);
+ }
+
+ [Fact]
+ public async Task GetLeaderBoardAsync_DefaultSizeIsThree()
+ {
+ await AddKarmaMultiple("rank-1", KarmaType.NeggyNeg, 4);
+ await AddKarmaMultiple("rank-2", KarmaType.NeggyNeg, 3);
+ await AddKarmaMultiple("rank-3", KarmaType.NeggyNeg, 2);
+ await AddKarmaMultiple("rank-4", KarmaType.NeggyNeg, 1);
+
+ var board = await _sut.GetLeaderBoardAsync();
+
+ board.Count.ShouldBe(3);
+ }
+
+ // ─── helpers ──────────────────────────────────────────────────────────────
+
+ private static Karma MakeKarma(
+ string awardedTo, string awardedBy, KarmaType type, string reason = "") =>
+ new()
+ {
+ AwardedToUsername = awardedTo,
+ AwardedByUsername = awardedBy,
+ KarmaType = type,
+ Reason = reason,
+ Awarded = DateTime.UtcNow
+ };
+
+ private async Task AddKarmaMultiple(string recipient, KarmaType type, int count)
+ {
+ for (var i = 0; i < count; i++)
+ await _sut.AddAsync(MakeKarma(recipient, "giver", type));
+ }
+}
diff --git a/Bottomly.Tests/Repositories/Integration/MemberRepositoryIntegrationTests.cs b/Bottomly.Tests/Repositories/Integration/MemberRepositoryIntegrationTests.cs
new file mode 100644
index 0000000..4f2edef
--- /dev/null
+++ b/Bottomly.Tests/Repositories/Integration/MemberRepositoryIntegrationTests.cs
@@ -0,0 +1,154 @@
+using Bottomly.Models;
+using Bottomly.Repositories;
+using Bottomly.Tests.Infrastructure;
+using MongoDB.Driver;
+using Shouldly;
+
+namespace Bottomly.Tests.Repositories.Integration;
+
+[Collection("MongoDB")]
+public class MemberRepositoryIntegrationTests(MongoDbFixture fixture) : IAsyncLifetime
+{
+ private IMongoDatabase _db = null!;
+ private MemberRepository _sut = null!;
+
+ public Task InitializeAsync()
+ {
+ _db = fixture.GetDatabase($"member_test_{Guid.NewGuid():N}");
+ _sut = new MemberRepository(_db);
+ return Task.CompletedTask;
+ }
+
+ public async Task DisposeAsync() =>
+ await fixture.Client.DropDatabaseAsync(_db.DatabaseNamespace.DatabaseName);
+
+ [Fact]
+ public async Task GetByUsernameAsync_WhenMemberExists_ReturnsMember()
+ {
+ await _sut.AddAsync(new Member { Username = "alice", SlackId = "U001" });
+
+ var result = await _sut.GetByUsernameAsync("alice");
+
+ result.ShouldNotBeNull();
+ result.Username.ShouldBe("alice");
+ result.SlackId.ShouldBe("U001");
+ }
+
+ [Fact]
+ public async Task GetByUsernameAsync_WhenMemberDoesNotExist_ReturnsNull()
+ {
+ var result = await _sut.GetByUsernameAsync("nobody");
+
+ result.ShouldBeNull();
+ }
+
+ [Fact]
+ public async Task GetBySlackIdAsync_WhenMemberExists_ReturnsMember()
+ {
+ await _sut.AddAsync(new Member { Username = "bob", SlackId = "U002" });
+
+ var result = await _sut.GetBySlackIdAsync("U002");
+
+ result.ShouldNotBeNull();
+ result.SlackId.ShouldBe("U002");
+ result.Username.ShouldBe("bob");
+ }
+
+ [Fact]
+ public async Task GetBySlackIdAsync_WhenMemberDoesNotExist_ReturnsNull()
+ {
+ var result = await _sut.GetBySlackIdAsync("UNOBODY");
+
+ result.ShouldBeNull();
+ }
+
+ [Fact]
+ public async Task GetBySlackIdsAsync_ReturnsOnlyMatchingMembers()
+ {
+ await _sut.AddAsync([
+ new Member { Username = "alice", SlackId = "U001" },
+ new Member { Username = "bob", SlackId = "U002" },
+ new Member { Username = "carol", SlackId = "U003" }
+ ]);
+
+ var result = await _sut.GetBySlackIdsAsync(["U001", "U003"]);
+
+ result.Count.ShouldBe(2);
+ result.Select(m => m.Username).ShouldBe(["alice", "carol"], ignoreOrder: true);
+ }
+
+ [Fact]
+ public async Task AddAsync_SingleMember_PersistsMemberWithAllFields()
+ {
+ var member = new Member
+ {
+ Username = "dave",
+ SlackId = "U004",
+ FullName = "Dave Smith",
+ Gender = Gender.Male,
+ SassLevel = SassLevel.Frequent,
+ MiscInfo = "Loves coffee"
+ };
+
+ await _sut.AddAsync(member);
+
+ var stored = await _sut.GetByUsernameAsync("dave");
+ stored.ShouldNotBeNull();
+ stored.FullName.ShouldBe("Dave Smith");
+ stored.Gender.ShouldBe(Gender.Male);
+ stored.SassLevel.ShouldBe(SassLevel.Frequent);
+ stored.MiscInfo.ShouldBe("Loves coffee");
+ }
+
+ [Fact]
+ public async Task AddAsync_BatchOfMembers_PersistsAllMembers()
+ {
+ var members = new List
+ {
+ new() { Username = "eve", SlackId = "U005" },
+ new() { Username = "frank", SlackId = "U006" },
+ new() { Username = "grace", SlackId = "U007" }
+ };
+
+ await _sut.AddAsync(members);
+
+ var eve = await _sut.GetByUsernameAsync("eve");
+ var frank = await _sut.GetByUsernameAsync("frank");
+ var grace = await _sut.GetByUsernameAsync("grace");
+ eve.ShouldNotBeNull();
+ frank.ShouldNotBeNull();
+ grace.ShouldNotBeNull();
+ }
+
+ [Fact]
+ public async Task UpdateInfoAsync_UpdatesOnlyInfoFields_LeavesSlackIdUntouched()
+ {
+ await _sut.AddAsync(new Member
+ {
+ Username = "heidi",
+ SlackId = "U008",
+ FullName = "Heidi Old",
+ Gender = Gender.Unknown,
+ SassLevel = SassLevel.None,
+ MiscInfo = "Old info"
+ });
+
+ await _sut.UpdateInfoAsync("heidi", "Heidi New", Gender.Female, SassLevel.Constant, "New info");
+
+ var updated = await _sut.GetByUsernameAsync("heidi");
+ updated.ShouldNotBeNull();
+ updated.FullName.ShouldBe("Heidi New");
+ updated.Gender.ShouldBe(Gender.Female);
+ updated.SassLevel.ShouldBe(SassLevel.Constant);
+ updated.MiscInfo.ShouldBe("New info");
+ updated.SlackId.ShouldBe("U008");
+ }
+
+ [Fact]
+ public async Task UpdateInfoAsync_WhenUsernameDoesNotExist_DoesNotThrow()
+ {
+ // Should silently no-op (UpdateOne with no match)
+ await Should.NotThrowAsync(() =>
+ _sut.UpdateInfoAsync("ghost", "Ghost", Gender.Unknown, SassLevel.None, ""));
+ }
+}
diff --git a/Bottomly.Tests/Repositories/KarmaRepositoryTests.cs b/Bottomly.Tests/Repositories/KarmaRepositoryTests.cs
deleted file mode 100644
index f6e1977..0000000
--- a/Bottomly.Tests/Repositories/KarmaRepositoryTests.cs
+++ /dev/null
@@ -1,193 +0,0 @@
-using Bottomly.Models;
-using Bottomly.Repositories;
-using MongoDB.Bson;
-using MongoDB.Driver;
-using Moq;
-using Shouldly;
-
-namespace Bottomly.Tests.Repositories;
-
-public class KarmaRepositoryTests
-{
- private readonly Mock> _mockCollection = new();
- private readonly Mock _mockDatabase = new();
- private readonly KarmaRepository _repository;
-
- public KarmaRepositoryTests()
- {
- _mockDatabase
- .Setup(d => d.GetCollection("karma", It.IsAny()))
- .Returns(_mockCollection.Object);
- _repository = new KarmaRepository(_mockDatabase.Object);
- }
-
- private static Mock> CreateBsonCursor(IEnumerable docs)
- {
- var cursor = new Mock>();
- cursor.Setup(c => c.Current).Returns(docs.ToList());
- cursor.SetupSequence(c => c.MoveNextAsync(It.IsAny()))
- .ReturnsAsync(true)
- .ReturnsAsync(false);
- return cursor;
- }
-
- private void SetupAggregate(IEnumerable results)
- {
- var cursor = CreateBsonCursor(results);
- _mockCollection
- .Setup(c => c.Aggregate(
- It.IsAny>(),
- It.IsAny(),
- It.IsAny()))
- .Returns(cursor.Object);
- }
-
- [Fact]
- public async Task AddAsync_CallsInsertOne()
- {
- var karma = new Karma { AwardedToUsername = "alice", AwardedByUsername = "bob", KarmaType = KarmaType.PozzyPoz };
- _mockCollection
- .Setup(c => c.InsertOneAsync(karma, It.IsAny(), It.IsAny()))
- .Returns(Task.CompletedTask);
-
- await _repository.AddAsync(karma);
-
- _mockCollection.Verify(
- c => c.InsertOneAsync(karma, It.IsAny(), It.IsAny()),
- Times.Once());
- }
-
- [Fact]
- public async Task GetCurrentNetKarmaAsync_WithResults_ReturnsNetKarma()
- {
- var doc = new BsonDocument { { "_id", "alice" }, { "net_karma", 5 } };
- SetupAggregate([doc]);
-
- var result = await _repository.GetCurrentNetKarmaAsync("Alice");
-
- result.ShouldBe(5);
- }
-
- [Fact]
- public async Task GetCurrentNetKarmaAsync_NoResults_ReturnsZero()
- {
- SetupAggregate([]);
-
- var result = await _repository.GetCurrentNetKarmaAsync("nobody");
-
- result.ShouldBe(0);
- }
-
- [Fact]
- public async Task GetCurrentNetKarmaAsync_LowercasesRecipient()
- {
- SetupAggregate([]);
-
- // Should not throw — lowercase conversion is internal
- await _repository.GetCurrentNetKarmaAsync("ALICE");
- }
-
- [Fact]
- public async Task GetLeaderBoardAsync_ReturnsTopScorers()
- {
- var docs = new[]
- {
- new BsonDocument { { "_id", "alice" }, { "net_karma", 10 } },
- new BsonDocument { { "_id", "bob" }, { "net_karma", 7 } },
- new BsonDocument { { "_id", "carol" }, { "net_karma", 4 } }
- };
- SetupAggregate(docs);
-
- var result = await _repository.GetLeaderBoardAsync(3);
-
- result.Count.ShouldBe(3);
- result[0].Username.ShouldBe("alice");
- result[0].NetKarma.ShouldBe(10);
- result[1].Username.ShouldBe("bob");
- }
-
- [Fact]
- public async Task GetLeaderBoardAsync_LimitsResults()
- {
- var docs = new[]
- {
- new BsonDocument { { "_id", "alice" }, { "net_karma", 10 } },
- new BsonDocument { { "_id", "bob" }, { "net_karma", 7 } },
- new BsonDocument { { "_id", "carol" }, { "net_karma", 4 } },
- new BsonDocument { { "_id", "dave" }, { "net_karma", 2 } }
- };
- SetupAggregate(docs);
-
- var result = await _repository.GetLeaderBoardAsync(2);
-
- result.Count.ShouldBe(2);
- }
-
- [Fact]
- public async Task GetLoserBoardAsync_ReturnsLowestScorers()
- {
- var docs = new[]
- {
- new BsonDocument { { "_id", "dave" }, { "net_karma", -5 } },
- new BsonDocument { { "_id", "eve" }, { "net_karma", -3 } }
- };
- SetupAggregate(docs);
-
- var result = await _repository.GetLoserBoardAsync(2);
-
- result.Count.ShouldBe(2);
- result[0].Username.ShouldBe("dave");
- result[0].NetKarma.ShouldBe(-5);
- }
-
- [Fact]
- public async Task GetKarmaReasonsAsync_SeparatesReasonedAndReasonless()
- {
- var docs = new[]
- {
- new BsonDocument
- {
- { "awarded_to_username", "alice" },
- { "awarded_by_username", "bob" },
- { "karma_type", "PozzyPoz" },
- { "awarded", BsonDateTime.Create(DateTime.UtcNow) },
- { "reason", "great work" }
- },
- new BsonDocument
- {
- { "awarded_to_username", "alice" },
- { "awarded_by_username", "carol" },
- { "karma_type", "PozzyPoz" },
- { "awarded", BsonDateTime.Create(DateTime.UtcNow) },
- { "reason", "" }
- },
- new BsonDocument
- {
- { "awarded_to_username", "alice" },
- { "awarded_by_username", "dave" },
- { "karma_type", "NeggyNeg" },
- { "awarded", BsonDateTime.Create(DateTime.UtcNow) }
- // no reason field
- }
- };
- SetupAggregate(docs);
-
- var result = await _repository.GetKarmaReasonsAsync("Alice");
-
- result.Reasoned.Count.ShouldBe(1);
- result.Reasoned[0].AwardedByUsername.ShouldBe("bob");
- result.Reasoned[0].Reason.ShouldBe("great work");
- result.Reasonless.ShouldBe(2);
- }
-
- [Fact]
- public async Task GetKarmaReasonsAsync_EmptyResults_ReturnsEmpty()
- {
- SetupAggregate([]);
-
- var result = await _repository.GetKarmaReasonsAsync("nobody");
-
- result.Reasoned.ShouldBeEmpty();
- result.Reasonless.ShouldBe(0);
- }
-}
diff --git a/Bottomly.Tests/Repositories/MemberRepositoryTests.cs b/Bottomly.Tests/Repositories/MemberRepositoryTests.cs
deleted file mode 100644
index bcf2723..0000000
--- a/Bottomly.Tests/Repositories/MemberRepositoryTests.cs
+++ /dev/null
@@ -1,158 +0,0 @@
-using Bottomly.Models;
-using Bottomly.Repositories;
-using MongoDB.Driver;
-using Moq;
-using Shouldly;
-
-namespace Bottomly.Tests.Repositories;
-
-public class MemberRepositoryTests
-{
- private readonly Mock> _mockCollection = new();
- private readonly Mock _mockDatabase = new();
- private readonly MemberRepository _repository;
-
- public MemberRepositoryTests()
- {
- _mockDatabase
- .Setup(d => d.GetCollection("member", It.IsAny()))
- .Returns(_mockCollection.Object);
- _repository = new MemberRepository(_mockDatabase.Object);
- }
-
- private Mock> CreateCursor(IEnumerable items)
- {
- var cursor = new Mock>();
- cursor.Setup(c => c.Current).Returns(items.ToList());
- cursor.SetupSequence(c => c.MoveNextAsync(It.IsAny()))
- .ReturnsAsync(true)
- .ReturnsAsync(false);
- return cursor;
- }
-
- private void SetupFind(IEnumerable results)
- {
- var cursor = CreateCursor(results);
- _mockCollection
- .Setup(c => c.FindAsync(
- It.IsAny>(),
- It.IsAny>(),
- It.IsAny()))
- .ReturnsAsync(cursor.Object);
- }
-
- [Fact]
- public async Task GetByUsernameAsync_WhenFound_ReturnsMember()
- {
- var member = new Member { Username = "alice", SlackId = "U1" };
- SetupFind([member]);
-
- var result = await _repository.GetByUsernameAsync("alice");
-
- result.ShouldNotBeNull();
- result!.Username.ShouldBe("alice");
- }
-
- [Fact]
- public async Task GetByUsernameAsync_WhenNotFound_ReturnsNull()
- {
- SetupFind([]);
-
- var result = await _repository.GetByUsernameAsync("nobody");
-
- result.ShouldBeNull();
- }
-
- [Fact]
- public async Task GetBySlackIdAsync_WhenFound_ReturnsMember()
- {
- var member = new Member { Username = "bob", SlackId = "U2" };
- SetupFind([member]);
-
- var result = await _repository.GetBySlackIdAsync("U2");
-
- result.ShouldNotBeNull();
- result!.SlackId.ShouldBe("U2");
- }
-
- [Fact]
- public async Task GetBySlackIdAsync_WhenNotFound_ReturnsNull()
- {
- SetupFind([]);
-
- var result = await _repository.GetBySlackIdAsync("U_UNKNOWN");
-
- result.ShouldBeNull();
- }
-
- [Fact]
- public async Task GetBySlackIdsAsync_ReturnsMatchingMembers()
- {
- var members = new List
- {
- new() { Username = "alice", SlackId = "U1" },
- new() { Username = "bob", SlackId = "U2" }
- };
- SetupFind(members);
-
- var result = await _repository.GetBySlackIdsAsync(["U1", "U2"]);
-
- result.Count.ShouldBe(2);
- }
-
- [Fact]
- public async Task AddAsync_SingleMember_CallsInsertOne()
- {
- var member = new Member { Username = "carol", SlackId = "U3" };
- _mockCollection
- .Setup(c => c.InsertOneAsync(member, It.IsAny(), It.IsAny()))
- .Returns(Task.CompletedTask);
-
- await _repository.AddAsync(member);
-
- _mockCollection.Verify(
- c => c.InsertOneAsync(member, It.IsAny(), It.IsAny()),
- Times.Once());
- }
-
- [Fact]
- public async Task AddAsync_MultipleMembers_CallsInsertMany()
- {
- var members = new List
- {
- new() { Username = "alice" },
- new() { Username = "bob" }
- };
- _mockCollection
- .Setup(c => c.InsertManyAsync(members, It.IsAny(), It.IsAny()))
- .Returns(Task.CompletedTask);
-
- await _repository.AddAsync(members);
-
- _mockCollection.Verify(
- c => c.InsertManyAsync(members, It.IsAny(), It.IsAny()),
- Times.Once());
- }
-
- [Fact]
- public async Task UpdateInfoAsync_CallsUpdateOne()
- {
- _mockCollection
- .Setup(c => c.UpdateOneAsync(
- It.IsAny>(),
- It.IsAny>(),
- It.IsAny(),
- It.IsAny()))
- .ReturnsAsync(new UpdateResult.Acknowledged(1, 1, null));
-
- await _repository.UpdateInfoAsync("alice", "Alice Smith", Gender.Female, SassLevel.Moderate, "Likes tea");
-
- _mockCollection.Verify(
- c => c.UpdateOneAsync(
- It.IsAny>(),
- It.IsAny>(),
- It.IsAny(),
- It.IsAny()),
- Times.Once());
- }
-}
diff --git a/README.md b/README.md
index fa05186..2b58a1c 100644
--- a/README.md
+++ b/README.md
@@ -61,6 +61,11 @@ Wraps [OllamaSharp](https://github.com/awaescher/OllamaSharp) to provide convers
Has an internal structure matching the rest of the app. Each app file should have a corresponding test file.
+The test suite is split into two categories:
+
+* **Unit tests** — fast, in-process tests using [Moq](https://github.com/devlooped/moq) for mocking and [Shouldly](https://github.com/shouldly/shouldly) for assertions. Cover commands, event handlers, and other logic that can be exercised without external dependencies.
+* **Integration tests** (`Repositories/Integration/`) — use [Testcontainers](https://dotnet.testcontainers.org/) to spin up a real MongoDB container and exercise the repository layer end-to-end, including aggregation pipelines. These require Docker to be running locally; they run automatically in CI on `ubuntu-latest`.
+
## Configuration
The following secrets/environment variables _must_ be configured for the app to run: