From 0cdb81a4b9a6d034dc7122c43ec16c78d4855b71 Mon Sep 17 00:00:00 2001 From: "Owen.Morgan-Jones" Date: Wed, 18 Mar 2026 09:36:56 +0000 Subject: [PATCH] Switches repositories to use testcontainer intg tests --- Bottomly.Tests/Bottomly.Tests.csproj | 21 +- .../Infrastructure/MongoDbCollection.cs | 4 + .../Infrastructure/MongoDbFixture.cs | 26 +++ .../KarmaRepositoryIntegrationTests.cs | 192 +++++++++++++++++ .../MemberRepositoryIntegrationTests.cs | 154 ++++++++++++++ .../Repositories/KarmaRepositoryTests.cs | 193 ------------------ .../Repositories/MemberRepositoryTests.cs | 158 -------------- README.md | 5 + 8 files changed, 392 insertions(+), 361 deletions(-) create mode 100644 Bottomly.Tests/Infrastructure/MongoDbCollection.cs create mode 100644 Bottomly.Tests/Infrastructure/MongoDbFixture.cs create mode 100644 Bottomly.Tests/Repositories/Integration/KarmaRepositoryIntegrationTests.cs create mode 100644 Bottomly.Tests/Repositories/Integration/MemberRepositoryIntegrationTests.cs delete mode 100644 Bottomly.Tests/Repositories/KarmaRepositoryTests.cs delete mode 100644 Bottomly.Tests/Repositories/MemberRepositoryTests.cs 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: