From 35bbac74d2fed29d6a5e4acd5c75169036021f43 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Mar 2026 11:16:25 +0000 Subject: [PATCH 1/7] Initial plan From fdd21cff01e02a231373bb7d9f4fa92c7f64bc77 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Mar 2026 11:46:23 +0000 Subject: [PATCH 2/7] Add WordRepository integration tests with EphemeralMongo7 Co-authored-by: imnasnainaec <6411521+imnasnainaec@users.noreply.github.com> --- Backend.Tests/Backend.Tests.csproj | 3 + .../Repositories/MongoDbTestRunner.cs | 212 +++++++ .../Repositories/WordRepositoryTests.cs | 583 ++++++++++++++++++ 3 files changed, 798 insertions(+) create mode 100644 Backend.Tests/Repositories/MongoDbTestRunner.cs create mode 100644 Backend.Tests/Repositories/WordRepositoryTests.cs diff --git a/Backend.Tests/Backend.Tests.csproj b/Backend.Tests/Backend.Tests.csproj index ab67ca9f59..f266389a4b 100644 --- a/Backend.Tests/Backend.Tests.csproj +++ b/Backend.Tests/Backend.Tests.csproj @@ -13,6 +13,9 @@ $(NoWarn);CA1305;CA1859;CS1591 + + + diff --git a/Backend.Tests/Repositories/MongoDbTestRunner.cs b/Backend.Tests/Repositories/MongoDbTestRunner.cs new file mode 100644 index 0000000000..67b158428c --- /dev/null +++ b/Backend.Tests/Repositories/MongoDbTestRunner.cs @@ -0,0 +1,212 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Net.Sockets; +using System.Runtime.InteropServices; +using System.Threading; +using MongoDB.Bson; +using MongoDB.Driver; + +namespace Backend.Tests.Repositories +{ + /// + /// Starts and manages an ephemeral MongoDB process for integration testing. + /// Uses the mongod binary from the EphemeralMongo7 NuGet package. + /// Supports single-node replica sets to enable multi-document transactions. + /// + internal sealed class MongoDbTestRunner : IDisposable + { + private const string ReplicaSetName = "rs0"; + + private readonly Process _process; + private readonly string _dataDirectory; + + public string ConnectionString { get; } + + private MongoDbTestRunner(Process process, string dataDirectory, string connectionString) + { + _process = process; + _dataDirectory = dataDirectory; + ConnectionString = connectionString; + } + + /// + /// Starts a MongoDB instance as a single-node replica set. + /// + public static MongoDbTestRunner Start() + { + var binaryPath = FindMongodBinary(); + var port = FindFreePort(); + var dataDirectory = Path.Combine(Path.GetTempPath(), $"mongo-test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(dataDirectory); + + var process = StartMongodProcess(binaryPath, port, dataDirectory); + try + { + WaitForMongoReady(port); + InitializeReplicaSet(port); + WaitForReplicaSetReady(port); + } + catch + { + process.Kill(entireProcessTree: true); + process.Dispose(); + Directory.Delete(dataDirectory, recursive: true); + throw; + } + + var connectionString = $"mongodb://127.0.0.1:{port}/?directConnection=true&replicaSet={ReplicaSetName}"; + return new MongoDbTestRunner(process, dataDirectory, connectionString); + } + + private static string FindMongodBinary() + { + var rid = GetRuntimeId(); + var baseDir = AppContext.BaseDirectory; + var binaryName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "mongod.exe" : "mongod"; + var binaryPath = Path.Combine(baseDir, "runtimes", rid, "native", "mongodb", "bin", binaryName); + + if (!File.Exists(binaryPath)) + { + throw new FileNotFoundException( + $"mongod binary not found at '{binaryPath}'. Ensure one of the EphemeralMongo7.runtime.* packages is installed.", + binaryPath); + } + + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + // Ensure the binary is executable on Unix + var chmod = Process.Start(new ProcessStartInfo("chmod") + { + ArgumentList = { "+x", binaryPath }, + UseShellExecute = false, + }); + chmod?.WaitForExit(); + } + + return binaryPath; + } + + private static string GetRuntimeId() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return "win-x64"; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) return "linux-x64"; + // EphemeralMongo7 only provides an arm64 binary for macOS + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) return "osx-arm64"; + throw new PlatformNotSupportedException("Unsupported operating system."); + } + + private static int FindFreePort() + { + using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + socket.Bind(new System.Net.IPEndPoint(System.Net.IPAddress.Loopback, 0)); + return ((System.Net.IPEndPoint)socket.LocalEndPoint!).Port; + } + + private static Process StartMongodProcess(string binaryPath, int port, string dataDirectory) + { + var args = string.Join(" ", + $"--replSet {ReplicaSetName}", + "--bind_ip 127.0.0.1", + $"--port {port}", + $"--dbpath \"{dataDirectory}\"", + "--noauth", + "--quiet"); + + var process = new Process + { + StartInfo = new ProcessStartInfo(binaryPath, args) + { + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + } + }; + process.Start(); + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + return process; + } + + private static void WaitForMongoReady(int port, int timeoutSeconds = 30) + { + var deadline = DateTime.UtcNow.AddSeconds(timeoutSeconds); + while (DateTime.UtcNow < deadline) + { + try + { + using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + socket.Connect("127.0.0.1", port); + return; + } + catch (SocketException) + { + Thread.Sleep(200); + } + } + + throw new TimeoutException($"MongoDB did not start within {timeoutSeconds} seconds on port {port}."); + } + + private static void InitializeReplicaSet(int port) + { + var client = new MongoClient($"mongodb://127.0.0.1:{port}/?directConnection=true"); + var admin = client.GetDatabase("admin"); + var config = new BsonDocument + { + { "_id", ReplicaSetName }, + { "members", new BsonArray { new BsonDocument { { "_id", 0 }, { "host", $"127.0.0.1:{port}" } } } } + }; + admin.RunCommand(new BsonDocument("replSetInitiate", config)); + } + + private static void WaitForReplicaSetReady(int port, int timeoutSeconds = 30) + { + var client = new MongoClient( + $"mongodb://127.0.0.1:{port}/?directConnection=true&replicaSet={ReplicaSetName}"); + var deadline = DateTime.UtcNow.AddSeconds(timeoutSeconds); + Exception? lastException = null; + while (DateTime.UtcNow < deadline) + { + try + { + var admin = client.GetDatabase("admin"); + var status = admin.RunCommand(new BsonDocument("replSetGetStatus", 1)); + if (status["ok"].AsDouble == 1.0) + { + return; + } + } + catch (Exception ex) + { + lastException = ex; + } + + Thread.Sleep(500); + } + + throw new TimeoutException( + $"Replica set did not become ready within {timeoutSeconds} seconds.", lastException); + } + + public void Dispose() + { + try + { + if (!_process.HasExited) + { + _process.Kill(entireProcessTree: true); + } + } + catch (InvalidOperationException) { } + + _process.Dispose(); + + try + { + Directory.Delete(_dataDirectory, recursive: true); + } + catch (IOException) { } + } + } +} diff --git a/Backend.Tests/Repositories/WordRepositoryTests.cs b/Backend.Tests/Repositories/WordRepositoryTests.cs new file mode 100644 index 0000000000..587e707c55 --- /dev/null +++ b/Backend.Tests/Repositories/WordRepositoryTests.cs @@ -0,0 +1,583 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using BackendFramework.Contexts; +using BackendFramework.Models; +using BackendFramework.Repositories; +using Microsoft.Extensions.Options; +using MongoDB.Bson; +using NUnit.Framework; + +namespace Backend.Tests.Repositories +{ + /// + /// Integration tests for that spin up an actual MongoDB instance. + /// + [TestFixture] + public sealed class WordRepositoryTests + { + private static MongoDbTestRunner _runner = null!; + private WordRepository _repo = null!; + private string _projectId = null!; + + [OneTimeSetUp] + public static void StartMongo() + { + _runner?.Dispose(); + _runner = MongoDbTestRunner.Start(); + } + + [OneTimeTearDown] + public static void StopMongo() + { + _runner.Dispose(); + } + + [SetUp] + public void SetUp() + { + _projectId = Guid.NewGuid().ToString(); + var options = Options.Create(new BackendFramework.Startup.Settings + { + ConnectionString = _runner.ConnectionString, + CombineDatabase = "WordRepositoryTests", + }); + _repo = new WordRepository(new MongoDbContext(options)); + } + + private Task CreateWord(string? vernacular = null, string? domainId = null) + { + var word = Util.RandomWord(_projectId); + if (vernacular is not null) + { + word.Vernacular = vernacular; + } + + if (domainId is not null) + { + word.Senses[0].SemanticDomains = [new SemanticDomain { Id = domainId, Name = "Test" }]; + } + + return _repo.RepoCreate(word); + } + + /// Generates a valid MongoDB ObjectId string that does not exist in the database. + private static string NewObjectId() => ObjectId.GenerateNewId().ToString(); + + // GET ALL WORDS + + [Test] + public async Task TestGetAllWords() + { + var word = await CreateWord(); + var words = await _repo.GetAllWords(_projectId); + Assert.That(words, Has.Count.EqualTo(1)); + Assert.That(words[0].Id, Is.EqualTo(word.Id)); + } + + [Test] + public async Task TestGetAllWordsEmptyProject() + { + var words = await _repo.GetAllWords(_projectId); + Assert.That(words, Is.Empty); + } + + [Test] + public async Task TestGetAllWordsOnlyReturnsWordsForProject() + { + await CreateWord(); + var otherProjectWords = await _repo.GetAllWords(Guid.NewGuid().ToString()); + Assert.That(otherProjectWords, Is.Empty); + } + + // GET WORD + + [Test] + public async Task TestGetWord() + { + var created = await CreateWord(); + var retrieved = await _repo.GetWord(_projectId, created.Id); + Assert.That(retrieved, Is.Not.Null); + Assert.That(retrieved!.Id, Is.EqualTo(created.Id)); + } + + [Test] + public async Task TestGetWordNonExistentIdReturnsNull() + { + var result = await _repo.GetWord(_projectId, NewObjectId()); + Assert.That(result, Is.Null); + } + + // REPO CREATE + + [Test] + public async Task TestRepoCreateSingleWordAddsToWordsAndFrontier() + { + var word = Util.RandomWord(_projectId); + var created = await _repo.RepoCreate(word); + + Assert.That(created.Id, Is.Not.Empty); + Assert.That(await _repo.GetWord(_projectId, created.Id), Is.Not.Null); + Assert.That(await _repo.IsInFrontier(_projectId, created.Id), Is.True); + } + + [Test] + public async Task TestRepoCreateSingleWordClearsOriginalId() + { + var word = Util.RandomWord(_projectId); + var originalId = word.Id; + var created = await _repo.RepoCreate(word); + + Assert.That(created.Id, Is.Not.EqualTo(originalId)); + } + + [Test] + public async Task TestRepoCreateListOfWordsAddsAll() + { + var words = Util.RandomWordList(3, _projectId); + var created = await _repo.RepoCreate(words); + + Assert.That(created, Has.Count.EqualTo(3)); + foreach (var w in created) + { + Assert.That(await _repo.GetWord(_projectId, w.Id), Is.Not.Null); + Assert.That(await _repo.IsInFrontier(_projectId, w.Id), Is.True); + } + } + + [Test] + public async Task TestRepoCreateEmptyListReturnsEmpty() + { + var created = await _repo.RepoCreate([]); + Assert.That(created, Is.Empty); + } + + // REPO UPDATE FRONTIER (by projectId, wordId, modifyWord) + + [Test] + public async Task TestRepoUpdateFrontierByIdsUpdatesWord() + { + var created = await CreateWord(); + const string newVernacular = "updated_vernacular"; + + var updated = await _repo.RepoUpdateFrontier(_projectId, created.Id, w => w.Vernacular = newVernacular); + + Assert.That(updated, Is.Not.Null); + Assert.That(updated!.Vernacular, Is.EqualTo(newVernacular)); + Assert.That(updated.Id, Is.Not.EqualTo(created.Id)); + Assert.That(await _repo.IsInFrontier(_projectId, created.Id), Is.False); + Assert.That(await _repo.IsInFrontier(_projectId, updated.Id), Is.True); + } + + [Test] + public async Task TestRepoUpdateFrontierByIdsNonExistentReturnsNull() + { + var result = await _repo.RepoUpdateFrontier(_projectId, NewObjectId(), _ => { }); + Assert.That(result, Is.Null); + } + + // REPO UPDATE FRONTIER (by word and action) + + [Test] + public async Task TestRepoUpdateFrontierByWordUpdatesWord() + { + var created = await CreateWord(); + var updatedWord = created.Clone(); + updatedWord.Vernacular = "new_vernacular"; + + var result = await _repo.RepoUpdateFrontier(updatedWord, (newWord, oldWord) => + { + Assert.That(oldWord, Is.Not.Null); + Assert.That(oldWord!.Id, Is.EqualTo(created.Id)); + newWord.History = [created.Id]; + }); + + Assert.That(result, Is.Not.Null); + Assert.That(result!.History, Contains.Item(created.Id)); + Assert.That(await _repo.IsInFrontier(_projectId, created.Id), Is.False); + Assert.That(await _repo.IsInFrontier(_projectId, result.Id), Is.True); + } + + [Test] + public async Task TestRepoUpdateFrontierByWordNotInFrontierReturnsNull() + { + var word = Util.RandomWord(_projectId); + word.Id = NewObjectId(); + var result = await _repo.RepoUpdateFrontier(word, (_, _) => { }); + Assert.That(result, Is.Null); + } + + // REPO REPLACE FRONTIER + + [Test] + public async Task TestRepoReplaceFrontierUpdatesAndDeletesWords() + { + var toUpdate = await CreateWord(); + var toDelete = await CreateWord(); + + var updatedWord = toUpdate.Clone(); + updatedWord.Vernacular = "replaced"; + string? capturedOldId = null; + + var result = await _repo.RepoReplaceFrontier( + _projectId, + newWords: [updatedWord], + idsToDelete: [toUpdate.Id, toDelete.Id], + modifyUpdatedWord: (newWord, oldWord) => + { + capturedOldId = oldWord?.Id; + newWord.History = [oldWord?.Id ?? ""]; + }, + modifyDeletedWord: w => w.Accessibility = Status.Deleted); + + Assert.That(result, Is.Not.Null); + Assert.That(capturedOldId, Is.EqualTo(toUpdate.Id)); + Assert.That(await _repo.IsInFrontier(_projectId, toUpdate.Id), Is.False); + Assert.That(await _repo.IsInFrontier(_projectId, toDelete.Id), Is.False); + } + + [Test] + public async Task TestRepoReplaceFrontierEmptyListsReturnsEmpty() + { + var result = await _repo.RepoReplaceFrontier(_projectId, [], [], (_, _) => { }, _ => { }); + Assert.That(result, Is.Not.Null); + Assert.That(result, Is.Empty); + } + + [Test] + public async Task TestRepoReplaceFrontierCreatesWordNotInFrontier() + { + var newWord = Util.RandomWord(_projectId); + newWord.Id = NewObjectId(); + + var result = await _repo.RepoReplaceFrontier( + _projectId, [newWord], [], (_, _) => { }, _ => { }); + + Assert.That(result, Is.Not.Null); + Assert.That(result, Has.Count.EqualTo(1)); + Assert.That(await _repo.IsInFrontier(_projectId, result![0].Id), Is.True); + } + + // REPO REVERT REPLACE FRONTIER + + [Test] + public async Task TestRepoRevertReplaceFrontierRestoresAndDeletes() + { + var toRestore = await CreateWord(); + var toDelete = await CreateWord(); + + // Remove toRestore from frontier so it can be restored later + await _repo.RepoDeleteFrontier(_projectId, toRestore.Id, _ => { }); + + var restored = await _repo.RepoRevertReplaceFrontier( + _projectId, + idsToRestore: [toRestore.Id], + idsToDelete: [toDelete.Id], + modifyDeletedWord: w => w.Accessibility = Status.Deleted); + + Assert.That(restored, Has.Count.EqualTo(1)); + Assert.That(restored[0].Id, Is.EqualTo(toRestore.Id)); + Assert.That(await _repo.IsInFrontier(_projectId, toRestore.Id), Is.True); + Assert.That(await _repo.IsInFrontier(_projectId, toDelete.Id), Is.False); + } + + [Test] + public async Task TestRepoRevertReplaceFrontierEmptyListsReturnsEmpty() + { + var result = await _repo.RepoRevertReplaceFrontier(_projectId, [], [], _ => { }); + Assert.That(result, Is.Empty); + } + + [Test] + public async Task TestRepoRevertReplaceFrontierOverlappingIdsThrows() + { + var word = await CreateWord(); + var ex = Assert.ThrowsAsync(() => + _repo.RepoRevertReplaceFrontier(_projectId, [word.Id], [word.Id], _ => { })); + Assert.That(ex, Is.Not.Null); + } + + // REPO DELETE FRONTIER + + [Test] + public async Task TestRepoDeleteFrontierRemovesFromFrontierAndArchives() + { + var created = await CreateWord(); + var deleted = await _repo.RepoDeleteFrontier( + _projectId, created.Id, w => w.Accessibility = Status.Deleted); + + Assert.That(deleted, Is.Not.Null); + Assert.That(await _repo.IsInFrontier(_projectId, created.Id), Is.False); + // The archived word has a new ID + Assert.That(deleted!.Id, Is.Not.EqualTo(created.Id)); + Assert.That(deleted.Accessibility, Is.EqualTo(Status.Deleted)); + } + + [Test] + public async Task TestRepoDeleteFrontierNonExistentReturnsNull() + { + var result = await _repo.RepoDeleteFrontier(_projectId, NewObjectId(), _ => { }); + Assert.That(result, Is.Null); + } + + // DELETE ALL FRONTIER WORDS + + [Test] + public async Task TestDeleteAllFrontierWordsRemovesAllFrontierWords() + { + await CreateWord(); + await CreateWord(); + + var deleted = await _repo.DeleteAllFrontierWords(_projectId); + + Assert.That(deleted, Is.True); + Assert.That(await _repo.HasFrontierWords(_projectId), Is.False); + Assert.That(await _repo.HasWords(_projectId), Is.True); + } + + [Test] + public async Task TestDeleteAllFrontierWordsEmptyFrontierReturnsFalse() + { + var result = await _repo.DeleteAllFrontierWords(_projectId); + Assert.That(result, Is.False); + } + + // HAS WORDS / HAS FRONTIER WORDS + + [Test] + public async Task TestHasWordsAfterCreateReturnsTrue() + { + await CreateWord(); + Assert.That(await _repo.HasWords(_projectId), Is.True); + } + + [Test] + public async Task TestHasWordsEmptyProjectReturnsFalse() + { + Assert.That(await _repo.HasWords(_projectId), Is.False); + } + + [Test] + public async Task TestHasFrontierWordsAfterCreateReturnsTrue() + { + await CreateWord(); + Assert.That(await _repo.HasFrontierWords(_projectId), Is.True); + } + + [Test] + public async Task TestHasFrontierWordsAfterDeleteAllReturnsFalse() + { + await CreateWord(); + await _repo.DeleteAllFrontierWords(_projectId); + Assert.That(await _repo.HasFrontierWords(_projectId), Is.False); + } + + // IS IN FRONTIER / ARE IN FRONTIER + + [Test] + public async Task TestIsInFrontierExistingWordReturnsTrue() + { + var word = await CreateWord(); + Assert.That(await _repo.IsInFrontier(_projectId, word.Id), Is.True); + } + + [Test] + public async Task TestIsInFrontierNonExistentWordReturnsFalse() + { + Assert.That(await _repo.IsInFrontier(_projectId, NewObjectId()), Is.False); + } + + [Test] + public async Task TestAreInFrontierAllPresentReturnsTrue() + { + var w1 = await CreateWord(); + var w2 = await CreateWord(); + Assert.That(await _repo.AreInFrontier(_projectId, [w1.Id, w2.Id], 2), Is.True); + } + + [Test] + public async Task TestAreInFrontierPartialMatchWithLowerCountReturnsTrue() + { + var w1 = await CreateWord(); + Assert.That(await _repo.AreInFrontier(_projectId, [w1.Id, NewObjectId()], 1), Is.True); + } + + [Test] + public async Task TestAreInFrontierPartialMatchWithExactCountReturnsFalse() + { + var w1 = await CreateWord(); + Assert.That(await _repo.AreInFrontier(_projectId, [w1.Id, NewObjectId()], 2), Is.False); + } + + // GET FRONTIER COUNT + + [Test] + public async Task TestGetFrontierCountReturnsCorrectCount() + { + await CreateWord(); + await CreateWord(); + Assert.That(await _repo.GetFrontierCount(_projectId), Is.EqualTo(2)); + } + + [Test] + public async Task TestGetFrontierCountEmptyProjectReturnsZero() + { + Assert.That(await _repo.GetFrontierCount(_projectId), Is.EqualTo(0)); + } + + // GET ALL FRONTIER + + [Test] + public async Task TestGetAllFrontierReturnsAllFrontierWords() + { + var w1 = await CreateWord(); + var w2 = await CreateWord(); + var frontier = await _repo.GetAllFrontier(_projectId); + var ids = frontier.Select(w => w.Id).ToList(); + Assert.That(ids, Contains.Item(w1.Id)); + Assert.That(ids, Contains.Item(w2.Id)); + } + + // GET FRONTIER (single word) + + [Test] + public async Task TestGetFrontierExistingWordReturnsWord() + { + var word = await CreateWord(); + var retrieved = await _repo.GetFrontier(_projectId, word.Id); + Assert.That(retrieved, Is.Not.Null); + Assert.That(retrieved!.Id, Is.EqualTo(word.Id)); + } + + [Test] + public async Task TestGetFrontierNonExistentWordReturnsNull() + { + var result = await _repo.GetFrontier(_projectId, NewObjectId()); + Assert.That(result, Is.Null); + } + + [Test] + public async Task TestGetFrontierWithMatchingAudioReturnsWord() + { + var word = Util.RandomWord(_projectId); + word.Audio = [new Pronunciation { FileName = "test.mp3" }]; + word = await _repo.RepoCreate(word); + + var retrieved = await _repo.GetFrontier(_projectId, word.Id, "test.mp3"); + Assert.That(retrieved, Is.Not.Null); + } + + [Test] + public async Task TestGetFrontierWithNonMatchingAudioReturnsNull() + { + var word = Util.RandomWord(_projectId); + word.Audio = [new Pronunciation { FileName = "test.mp3" }]; + word = await _repo.RepoCreate(word); + + var retrieved = await _repo.GetFrontier(_projectId, word.Id, "other.mp3"); + Assert.That(retrieved, Is.Null); + } + + // GET FRONTIER WITH VERNACULAR + + [Test] + public async Task TestGetFrontierWithVernacularReturnsMatchingWords() + { + const string vern = "special_vern"; + await CreateWord(vernacular: vern); + await CreateWord(); + + var results = await _repo.GetFrontierWithVernacular(_projectId, vern); + Assert.That(results, Has.Count.EqualTo(1)); + Assert.That(results[0].Vernacular, Is.EqualTo(vern)); + } + + [Test] + public async Task TestGetFrontierWithVernacularNoMatchReturnsEmpty() + { + await CreateWord(vernacular: "other_vern"); + var results = await _repo.GetFrontierWithVernacular(_projectId, "nonexistent"); + Assert.That(results, Is.Empty); + } + + // REPO RESTORE FRONTIER + + [Test] + public async Task TestRepoRestoreFrontierRestoresWordToFrontier() + { + var word = await CreateWord(); + await _repo.RepoDeleteFrontier(_projectId, word.Id, _ => { }); + Assert.That(await _repo.IsInFrontier(_projectId, word.Id), Is.False); + + var restored = await _repo.RepoRestoreFrontier(_projectId, [word.Id]); + Assert.That(restored, Has.Count.EqualTo(1)); + Assert.That(await _repo.IsInFrontier(_projectId, word.Id), Is.True); + } + + [Test] + public async Task TestRepoRestoreFrontierEmptyListReturnsEmpty() + { + var result = await _repo.RepoRestoreFrontier(_projectId, []); + Assert.That(result, Is.Empty); + } + + [Test] + public async Task TestRepoRestoreFrontierDeletedWordThrows() + { + // Create a word and archive it as Deleted + var word = await CreateWord(); + var archivedWord = await _repo.RepoDeleteFrontier( + _projectId, word.Id, w => w.Accessibility = Status.Deleted); + Assert.That(archivedWord, Is.Not.Null); + + var ex = Assert.ThrowsAsync(() => + _repo.RepoRestoreFrontier(_projectId, [archivedWord!.Id])); + Assert.That(ex, Is.Not.Null); + } + + // ADD FRONTIER + + [Test] + public async Task TestAddFrontierAddsWordsOnlyToFrontier() + { + var word = Util.RandomWord(_projectId); + word.Id = NewObjectId(); + + var added = await _repo.AddFrontier([word]); + + Assert.That(added, Has.Count.EqualTo(1)); + Assert.That(await _repo.IsInFrontier(_projectId, word.Id), Is.True); + // Word was NOT added to the words collection + Assert.That(await _repo.GetWord(_projectId, word.Id), Is.Null); + } + + [Test] + public async Task TestAddFrontierEmptyListReturnsEmpty() + { + var result = await _repo.AddFrontier([]); + Assert.That(result, Is.Empty); + } + + // COUNT FRONTIER WORDS WITH DOMAIN + + [Test] + public async Task TestCountFrontierWordsWithDomainReturnsCorrectCount() + { + const string domainId = "1.1"; + await CreateWord(domainId: domainId); + await CreateWord(domainId: domainId); + await CreateWord(); + + var count = await _repo.CountFrontierWordsWithDomain(_projectId, domainId); + Assert.That(count, Is.EqualTo(2)); + } + + [Test] + public async Task TestCountFrontierWordsWithDomainNoneMatchReturnsZero() + { + await CreateWord(); + var count = await _repo.CountFrontierWordsWithDomain(_projectId, "99.99"); + Assert.That(count, Is.EqualTo(0)); + } + } +} From 76bfd9befb570568c31eb8b2ba3cc97ad4176d3f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Mar 2026 11:52:09 +0000 Subject: [PATCH 3/7] Fix CI: remove stale NuGet CDN domain from harden-runner allowlist Co-authored-by: imnasnainaec <6411521+imnasnainaec@users.noreply.github.com> --- .github/workflows/backend.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml index d4f0054cd2..ce06bf51b0 100644 --- a/.github/workflows/backend.yml +++ b/.github/workflows/backend.yml @@ -41,7 +41,6 @@ jobs: dc.services.visualstudio.com:443 deb.debian.org:80 github.com:443 - md-hdd-t032zjxllntc.z26.blob.storage.azure.net:443 objects.githubusercontent.com:443 - name: Checkout repository uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 From 7526f4741febc860a0dfb18ae4efc9c02d363f47 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Mar 2026 18:21:06 +0000 Subject: [PATCH 4/7] Add IntegrationTest category and test:integration/test:unit scripts Co-authored-by: imnasnainaec <6411521+imnasnainaec@users.noreply.github.com> --- Backend.Tests/Repositories/WordRepositoryTests.cs | 1 + package.json | 2 ++ 2 files changed, 3 insertions(+) diff --git a/Backend.Tests/Repositories/WordRepositoryTests.cs b/Backend.Tests/Repositories/WordRepositoryTests.cs index 587e707c55..9750ba92f5 100644 --- a/Backend.Tests/Repositories/WordRepositoryTests.cs +++ b/Backend.Tests/Repositories/WordRepositoryTests.cs @@ -14,6 +14,7 @@ namespace Backend.Tests.Repositories /// Integration tests for that spin up an actual MongoDB instance. /// [TestFixture] + [Category("IntegrationTest")] public sealed class WordRepositoryTests { private static MongoDbTestRunner _runner = null!; diff --git a/package.json b/package.json index 37287230e6..f885d92a8c 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,8 @@ "test": "run-s test-backend test-frontend", "test-backend": " dotnet test Backend.Tests/Backend.Tests.csproj", "test-backend:coverage": "dotnet test Backend.Tests/Backend.Tests.csproj /p:CollectCoverage=true /p:CoverletOutputFormat=lcov /p:Threshold=77", + "test:integration": "dotnet test Backend.Tests/Backend.Tests.csproj --filter \"TestCategory=IntegrationTest\"", + "test:unit": "dotnet test Backend.Tests/Backend.Tests.csproj --filter \"TestCategory!=IntegrationTest\"", "test-frontend": " node scripts/jestTest.js", "test-frontend:coverage": "node scripts/jestTest.js --coverage --watchAll=false", "test-frontend:debug": " node --inspect-brk scripts/jestTest.js --runInBand --no-cache", From 375eed558f2ef79498f830918f8e05d8a09a01aa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Mar 2026 21:20:11 +0000 Subject: [PATCH 5/7] Update WordRepositoryTests to match updated WordRepository API Co-authored-by: imnasnainaec <6411521+imnasnainaec@users.noreply.github.com> --- .../Repositories/WordRepositoryTests.cs | 399 +++++++++--------- 1 file changed, 199 insertions(+), 200 deletions(-) diff --git a/Backend.Tests/Repositories/WordRepositoryTests.cs b/Backend.Tests/Repositories/WordRepositoryTests.cs index 9750ba92f5..dc0f18ebe5 100644 --- a/Backend.Tests/Repositories/WordRepositoryTests.cs +++ b/Backend.Tests/Repositories/WordRepositoryTests.cs @@ -59,7 +59,7 @@ private Task CreateWord(string? vernacular = null, string? domainId = null word.Senses[0].SemanticDomains = [new SemanticDomain { Id = domainId, Name = "Test" }]; } - return _repo.RepoCreate(word); + return _repo.Create(word); } /// Generates a valid MongoDB ObjectId string that does not exist in the database. @@ -109,13 +109,13 @@ public async Task TestGetWordNonExistentIdReturnsNull() Assert.That(result, Is.Null); } - // REPO CREATE + // CREATE [Test] - public async Task TestRepoCreateSingleWordAddsToWordsAndFrontier() + public async Task TestCreateSingleWordAddsToWordsAndFrontier() { var word = Util.RandomWord(_projectId); - var created = await _repo.RepoCreate(word); + var created = await _repo.Create(word); Assert.That(created.Id, Is.Not.Empty); Assert.That(await _repo.GetWord(_projectId, created.Id), Is.Not.Null); @@ -123,20 +123,20 @@ public async Task TestRepoCreateSingleWordAddsToWordsAndFrontier() } [Test] - public async Task TestRepoCreateSingleWordClearsOriginalId() + public async Task TestCreateSingleWordClearsOriginalId() { var word = Util.RandomWord(_projectId); var originalId = word.Id; - var created = await _repo.RepoCreate(word); + var created = await _repo.Create(word); Assert.That(created.Id, Is.Not.EqualTo(originalId)); } [Test] - public async Task TestRepoCreateListOfWordsAddsAll() + public async Task TestCreateListOfWordsAddsAll() { var words = Util.RandomWordList(3, _projectId); - var created = await _repo.RepoCreate(words); + var created = await _repo.Create(words); Assert.That(created, Has.Count.EqualTo(3)); foreach (var w in created) @@ -147,180 +147,12 @@ public async Task TestRepoCreateListOfWordsAddsAll() } [Test] - public async Task TestRepoCreateEmptyListReturnsEmpty() + public async Task TestCreateEmptyListReturnsEmpty() { - var created = await _repo.RepoCreate([]); + var created = await _repo.Create([]); Assert.That(created, Is.Empty); } - // REPO UPDATE FRONTIER (by projectId, wordId, modifyWord) - - [Test] - public async Task TestRepoUpdateFrontierByIdsUpdatesWord() - { - var created = await CreateWord(); - const string newVernacular = "updated_vernacular"; - - var updated = await _repo.RepoUpdateFrontier(_projectId, created.Id, w => w.Vernacular = newVernacular); - - Assert.That(updated, Is.Not.Null); - Assert.That(updated!.Vernacular, Is.EqualTo(newVernacular)); - Assert.That(updated.Id, Is.Not.EqualTo(created.Id)); - Assert.That(await _repo.IsInFrontier(_projectId, created.Id), Is.False); - Assert.That(await _repo.IsInFrontier(_projectId, updated.Id), Is.True); - } - - [Test] - public async Task TestRepoUpdateFrontierByIdsNonExistentReturnsNull() - { - var result = await _repo.RepoUpdateFrontier(_projectId, NewObjectId(), _ => { }); - Assert.That(result, Is.Null); - } - - // REPO UPDATE FRONTIER (by word and action) - - [Test] - public async Task TestRepoUpdateFrontierByWordUpdatesWord() - { - var created = await CreateWord(); - var updatedWord = created.Clone(); - updatedWord.Vernacular = "new_vernacular"; - - var result = await _repo.RepoUpdateFrontier(updatedWord, (newWord, oldWord) => - { - Assert.That(oldWord, Is.Not.Null); - Assert.That(oldWord!.Id, Is.EqualTo(created.Id)); - newWord.History = [created.Id]; - }); - - Assert.That(result, Is.Not.Null); - Assert.That(result!.History, Contains.Item(created.Id)); - Assert.That(await _repo.IsInFrontier(_projectId, created.Id), Is.False); - Assert.That(await _repo.IsInFrontier(_projectId, result.Id), Is.True); - } - - [Test] - public async Task TestRepoUpdateFrontierByWordNotInFrontierReturnsNull() - { - var word = Util.RandomWord(_projectId); - word.Id = NewObjectId(); - var result = await _repo.RepoUpdateFrontier(word, (_, _) => { }); - Assert.That(result, Is.Null); - } - - // REPO REPLACE FRONTIER - - [Test] - public async Task TestRepoReplaceFrontierUpdatesAndDeletesWords() - { - var toUpdate = await CreateWord(); - var toDelete = await CreateWord(); - - var updatedWord = toUpdate.Clone(); - updatedWord.Vernacular = "replaced"; - string? capturedOldId = null; - - var result = await _repo.RepoReplaceFrontier( - _projectId, - newWords: [updatedWord], - idsToDelete: [toUpdate.Id, toDelete.Id], - modifyUpdatedWord: (newWord, oldWord) => - { - capturedOldId = oldWord?.Id; - newWord.History = [oldWord?.Id ?? ""]; - }, - modifyDeletedWord: w => w.Accessibility = Status.Deleted); - - Assert.That(result, Is.Not.Null); - Assert.That(capturedOldId, Is.EqualTo(toUpdate.Id)); - Assert.That(await _repo.IsInFrontier(_projectId, toUpdate.Id), Is.False); - Assert.That(await _repo.IsInFrontier(_projectId, toDelete.Id), Is.False); - } - - [Test] - public async Task TestRepoReplaceFrontierEmptyListsReturnsEmpty() - { - var result = await _repo.RepoReplaceFrontier(_projectId, [], [], (_, _) => { }, _ => { }); - Assert.That(result, Is.Not.Null); - Assert.That(result, Is.Empty); - } - - [Test] - public async Task TestRepoReplaceFrontierCreatesWordNotInFrontier() - { - var newWord = Util.RandomWord(_projectId); - newWord.Id = NewObjectId(); - - var result = await _repo.RepoReplaceFrontier( - _projectId, [newWord], [], (_, _) => { }, _ => { }); - - Assert.That(result, Is.Not.Null); - Assert.That(result, Has.Count.EqualTo(1)); - Assert.That(await _repo.IsInFrontier(_projectId, result![0].Id), Is.True); - } - - // REPO REVERT REPLACE FRONTIER - - [Test] - public async Task TestRepoRevertReplaceFrontierRestoresAndDeletes() - { - var toRestore = await CreateWord(); - var toDelete = await CreateWord(); - - // Remove toRestore from frontier so it can be restored later - await _repo.RepoDeleteFrontier(_projectId, toRestore.Id, _ => { }); - - var restored = await _repo.RepoRevertReplaceFrontier( - _projectId, - idsToRestore: [toRestore.Id], - idsToDelete: [toDelete.Id], - modifyDeletedWord: w => w.Accessibility = Status.Deleted); - - Assert.That(restored, Has.Count.EqualTo(1)); - Assert.That(restored[0].Id, Is.EqualTo(toRestore.Id)); - Assert.That(await _repo.IsInFrontier(_projectId, toRestore.Id), Is.True); - Assert.That(await _repo.IsInFrontier(_projectId, toDelete.Id), Is.False); - } - - [Test] - public async Task TestRepoRevertReplaceFrontierEmptyListsReturnsEmpty() - { - var result = await _repo.RepoRevertReplaceFrontier(_projectId, [], [], _ => { }); - Assert.That(result, Is.Empty); - } - - [Test] - public async Task TestRepoRevertReplaceFrontierOverlappingIdsThrows() - { - var word = await CreateWord(); - var ex = Assert.ThrowsAsync(() => - _repo.RepoRevertReplaceFrontier(_projectId, [word.Id], [word.Id], _ => { })); - Assert.That(ex, Is.Not.Null); - } - - // REPO DELETE FRONTIER - - [Test] - public async Task TestRepoDeleteFrontierRemovesFromFrontierAndArchives() - { - var created = await CreateWord(); - var deleted = await _repo.RepoDeleteFrontier( - _projectId, created.Id, w => w.Accessibility = Status.Deleted); - - Assert.That(deleted, Is.Not.Null); - Assert.That(await _repo.IsInFrontier(_projectId, created.Id), Is.False); - // The archived word has a new ID - Assert.That(deleted!.Id, Is.Not.EqualTo(created.Id)); - Assert.That(deleted.Accessibility, Is.EqualTo(Status.Deleted)); - } - - [Test] - public async Task TestRepoDeleteFrontierNonExistentReturnsNull() - { - var result = await _repo.RepoDeleteFrontier(_projectId, NewObjectId(), _ => { }); - Assert.That(result, Is.Null); - } - // DELETE ALL FRONTIER WORDS [Test] @@ -462,7 +294,7 @@ public async Task TestGetFrontierWithMatchingAudioReturnsWord() { var word = Util.RandomWord(_projectId); word.Audio = [new Pronunciation { FileName = "test.mp3" }]; - word = await _repo.RepoCreate(word); + word = await _repo.Create(word); var retrieved = await _repo.GetFrontier(_projectId, word.Id, "test.mp3"); Assert.That(retrieved, Is.Not.Null); @@ -473,7 +305,7 @@ public async Task TestGetFrontierWithNonMatchingAudioReturnsNull() { var word = Util.RandomWord(_projectId); word.Audio = [new Pronunciation { FileName = "test.mp3" }]; - word = await _repo.RepoCreate(word); + word = await _repo.Create(word); var retrieved = await _repo.GetFrontier(_projectId, word.Id, "other.mp3"); Assert.That(retrieved, Is.Null); @@ -501,64 +333,231 @@ public async Task TestGetFrontierWithVernacularNoMatchReturnsEmpty() Assert.That(results, Is.Empty); } - // REPO RESTORE FRONTIER + // ADD FRONTIER [Test] - public async Task TestRepoRestoreFrontierRestoresWordToFrontier() + public async Task TestAddFrontierAddsWordsOnlyToFrontier() + { + var word = Util.RandomWord(_projectId); + word.Id = NewObjectId(); + + var added = await _repo.AddFrontier([word]); + + Assert.That(added, Has.Count.EqualTo(1)); + Assert.That(await _repo.IsInFrontier(_projectId, word.Id), Is.True); + // Word was NOT added to the words collection + Assert.That(await _repo.GetWord(_projectId, word.Id), Is.Null); + } + + [Test] + public async Task TestAddFrontierEmptyListReturnsEmpty() + { + var result = await _repo.AddFrontier([]); + Assert.That(result, Is.Empty); + } + + // DELETE FRONTIER + + [Test] + public async Task TestDeleteFrontierRemovesFromFrontierAndArchives() + { + var created = await CreateWord(); + var deleted = await _repo.DeleteFrontier( + _projectId, created.Id, w => w.Accessibility = Status.Deleted); + + Assert.That(deleted, Is.Not.Null); + Assert.That(await _repo.IsInFrontier(_projectId, created.Id), Is.False); + // The archived word has a new ID + Assert.That(deleted!.Id, Is.Not.EqualTo(created.Id)); + Assert.That(deleted.Accessibility, Is.EqualTo(Status.Deleted)); + } + + [Test] + public async Task TestDeleteFrontierNonExistentReturnsNull() + { + var result = await _repo.DeleteFrontier(_projectId, NewObjectId(), _ => { }); + Assert.That(result, Is.Null); + } + + // RESTORE FRONTIER + + [Test] + public async Task TestRestoreFrontierRestoresWordToFrontier() { var word = await CreateWord(); - await _repo.RepoDeleteFrontier(_projectId, word.Id, _ => { }); + await _repo.DeleteFrontier(_projectId, word.Id, _ => { }); Assert.That(await _repo.IsInFrontier(_projectId, word.Id), Is.False); - var restored = await _repo.RepoRestoreFrontier(_projectId, [word.Id]); - Assert.That(restored, Has.Count.EqualTo(1)); + var restored = await _repo.RestoreFrontier(_projectId, word.Id); + Assert.That(restored, Is.True); Assert.That(await _repo.IsInFrontier(_projectId, word.Id), Is.True); } [Test] - public async Task TestRepoRestoreFrontierEmptyListReturnsEmpty() + public async Task TestRestoreFrontierNotFoundReturnsFalse() { - var result = await _repo.RepoRestoreFrontier(_projectId, []); - Assert.That(result, Is.Empty); + var result = await _repo.RestoreFrontier(_projectId, NewObjectId()); + Assert.That(result, Is.False); } [Test] - public async Task TestRepoRestoreFrontierDeletedWordThrows() + public async Task TestRestoreFrontierDeletedWordThrows() { // Create a word and archive it as Deleted var word = await CreateWord(); - var archivedWord = await _repo.RepoDeleteFrontier( + var archivedWord = await _repo.DeleteFrontier( _projectId, word.Id, w => w.Accessibility = Status.Deleted); Assert.That(archivedWord, Is.Not.Null); var ex = Assert.ThrowsAsync(() => - _repo.RepoRestoreFrontier(_projectId, [archivedWord!.Id])); + _repo.RestoreFrontier(_projectId, archivedWord!.Id)); Assert.That(ex, Is.Not.Null); } - // ADD FRONTIER + // UPDATE FRONTIER (by projectId, wordId, modifyWord) [Test] - public async Task TestAddFrontierAddsWordsOnlyToFrontier() + public async Task TestUpdateFrontierByIdsUpdatesWord() + { + var created = await CreateWord(); + const string newVernacular = "updated_vernacular"; + + var updated = await _repo.UpdateFrontier(_projectId, created.Id, w => w.Vernacular = newVernacular); + + Assert.That(updated, Is.Not.Null); + Assert.That(updated!.Vernacular, Is.EqualTo(newVernacular)); + Assert.That(updated.Id, Is.Not.EqualTo(created.Id)); + Assert.That(await _repo.IsInFrontier(_projectId, created.Id), Is.False); + Assert.That(await _repo.IsInFrontier(_projectId, updated.Id), Is.True); + } + + [Test] + public async Task TestUpdateFrontierByIdsNonExistentReturnsNull() + { + var result = await _repo.UpdateFrontier(_projectId, NewObjectId(), _ => { }); + Assert.That(result, Is.Null); + } + + // UPDATE FRONTIER (by word and action) + + [Test] + public async Task TestUpdateFrontierByWordUpdatesWord() + { + var created = await CreateWord(); + var updatedWord = created.Clone(); + updatedWord.Vernacular = "new_vernacular"; + + var result = await _repo.UpdateFrontier(updatedWord, (newWord, oldWord) => + { + Assert.That(oldWord, Is.Not.Null); + Assert.That(oldWord!.Id, Is.EqualTo(created.Id)); + newWord.History = [created.Id]; + }); + + Assert.That(result, Is.Not.Null); + Assert.That(result!.History, Contains.Item(created.Id)); + Assert.That(await _repo.IsInFrontier(_projectId, created.Id), Is.False); + Assert.That(await _repo.IsInFrontier(_projectId, result.Id), Is.True); + } + + [Test] + public async Task TestUpdateFrontierByWordNotInFrontierReturnsNull() { var word = Util.RandomWord(_projectId); word.Id = NewObjectId(); + var result = await _repo.UpdateFrontier(word, (_, _) => { }); + Assert.That(result, Is.Null); + } - var added = await _repo.AddFrontier([word]); + // REPLACE FRONTIER - Assert.That(added, Has.Count.EqualTo(1)); - Assert.That(await _repo.IsInFrontier(_projectId, word.Id), Is.True); - // Word was NOT added to the words collection - Assert.That(await _repo.GetWord(_projectId, word.Id), Is.Null); + [Test] + public async Task TestReplaceFrontierUpdatesAndDeletesWords() + { + var toUpdate = await CreateWord(); + var toDelete = await CreateWord(); + + var updatedWord = toUpdate.Clone(); + updatedWord.Vernacular = "replaced"; + string? capturedOldId = null; + + var result = await _repo.ReplaceFrontier( + _projectId, + newWords: [updatedWord], + idsToDelete: [toUpdate.Id, toDelete.Id], + modifyUpdatedWord: (newWord, oldWord) => + { + capturedOldId = oldWord?.Id; + newWord.History = [oldWord?.Id ?? ""]; + }, + modifyDeletedWord: w => w.Accessibility = Status.Deleted); + + Assert.That(result, Is.Not.Null); + Assert.That(capturedOldId, Is.EqualTo(toUpdate.Id)); + Assert.That(await _repo.IsInFrontier(_projectId, toUpdate.Id), Is.False); + Assert.That(await _repo.IsInFrontier(_projectId, toDelete.Id), Is.False); } [Test] - public async Task TestAddFrontierEmptyListReturnsEmpty() + public async Task TestReplaceFrontierEmptyListsReturnsEmpty() { - var result = await _repo.AddFrontier([]); + var result = await _repo.ReplaceFrontier(_projectId, [], [], (_, _) => { }, _ => { }); + Assert.That(result, Is.Not.Null); Assert.That(result, Is.Empty); } + [Test] + public async Task TestReplaceFrontierCreatesWordNotInFrontier() + { + var newWord = Util.RandomWord(_projectId); + newWord.Id = NewObjectId(); + + var result = await _repo.ReplaceFrontier( + _projectId, [newWord], [], (_, _) => { }, _ => { }); + + Assert.That(result, Is.Not.Null); + Assert.That(result, Has.Count.EqualTo(1)); + Assert.That(await _repo.IsInFrontier(_projectId, result![0].Id), Is.True); + } + + // REVERT REPLACE FRONTIER + + [Test] + public async Task TestRevertReplaceFrontierRestoresAndDeletes() + { + var toRestore = await CreateWord(); + var toDelete = await CreateWord(); + + // Remove toRestore from frontier so it can be restored later + await _repo.DeleteFrontier(_projectId, toRestore.Id, _ => { }); + + var result = await _repo.RevertReplaceFrontier( + _projectId, + idsToRestore: [toRestore.Id], + idsToDelete: [toDelete.Id], + modifyDeletedWord: w => w.Accessibility = Status.Deleted); + + Assert.That(result, Is.True); + Assert.That(await _repo.IsInFrontier(_projectId, toRestore.Id), Is.True); + Assert.That(await _repo.IsInFrontier(_projectId, toDelete.Id), Is.False); + } + + [Test] + public async Task TestRevertReplaceFrontierEmptyListsReturnsTrue() + { + var result = await _repo.RevertReplaceFrontier(_projectId, [], [], _ => { }); + Assert.That(result, Is.True); + } + + [Test] + public async Task TestRevertReplaceFrontierOverlappingIdsThrows() + { + var word = await CreateWord(); + var ex = Assert.ThrowsAsync(() => + _repo.RevertReplaceFrontier(_projectId, [word.Id], [word.Id], _ => { })); + Assert.That(ex, Is.Not.Null); + } + // COUNT FRONTIER WORDS WITH DOMAIN [Test] From a27113e96575b80ee408b0d1b10f694e4fd597a4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Mar 2026 20:11:44 +0000 Subject: [PATCH 6/7] Add TestRestoreFrontierAlreadyInFrontierThrows for new explicit check Co-authored-by: imnasnainaec <6411521+imnasnainaec@users.noreply.github.com> --- Backend.Tests/Repositories/WordRepositoryTests.cs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/Backend.Tests/Repositories/WordRepositoryTests.cs b/Backend.Tests/Repositories/WordRepositoryTests.cs index dc0f18ebe5..aa9f0acc37 100644 --- a/Backend.Tests/Repositories/WordRepositoryTests.cs +++ b/Backend.Tests/Repositories/WordRepositoryTests.cs @@ -400,6 +400,16 @@ public async Task TestRestoreFrontierNotFoundReturnsFalse() Assert.That(result, Is.False); } + [Test] + public async Task TestRestoreFrontierAlreadyInFrontierThrows() + { + var word = await CreateWord(); + // Word is already in frontier; restoring it again should throw. + var ex = Assert.ThrowsAsync(() => + _repo.RestoreFrontier(_projectId, word.Id)); + Assert.That(ex, Is.Not.Null); + } + [Test] public async Task TestRestoreFrontierDeletedWordThrows() { From 43e15946704fb40f81ae8d5a0ec1b73f6d7fbb92 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Fri, 13 Mar 2026 12:39:29 -0400 Subject: [PATCH 7/7] [MongoDbTestRunner] Clarify ready condition --- Backend.Tests/Repositories/MongoDbTestRunner.cs | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/Backend.Tests/Repositories/MongoDbTestRunner.cs b/Backend.Tests/Repositories/MongoDbTestRunner.cs index 67b158428c..ae61cfa6db 100644 --- a/Backend.Tests/Repositories/MongoDbTestRunner.cs +++ b/Backend.Tests/Repositories/MongoDbTestRunner.cs @@ -16,6 +16,7 @@ namespace Backend.Tests.Repositories /// internal sealed class MongoDbTestRunner : IDisposable { + private const string Host = "127.0.0.1"; private const string ReplicaSetName = "rs0"; private readonly Process _process; @@ -55,7 +56,7 @@ public static MongoDbTestRunner Start() throw; } - var connectionString = $"mongodb://127.0.0.1:{port}/?directConnection=true&replicaSet={ReplicaSetName}"; + var connectionString = $"mongodb://{Host}:{port}/?directConnection=true&replicaSet={ReplicaSetName}"; return new MongoDbTestRunner(process, dataDirectory, connectionString); } @@ -107,7 +108,7 @@ private static Process StartMongodProcess(string binaryPath, int port, string da { var args = string.Join(" ", $"--replSet {ReplicaSetName}", - "--bind_ip 127.0.0.1", + $"--bind_ip {Host}", $"--port {port}", $"--dbpath \"{dataDirectory}\"", "--noauth", @@ -136,7 +137,7 @@ private static void WaitForMongoReady(int port, int timeoutSeconds = 30) try { using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); - socket.Connect("127.0.0.1", port); + socket.Connect(Host, port); return; } catch (SocketException) @@ -150,12 +151,12 @@ private static void WaitForMongoReady(int port, int timeoutSeconds = 30) private static void InitializeReplicaSet(int port) { - var client = new MongoClient($"mongodb://127.0.0.1:{port}/?directConnection=true"); + var client = new MongoClient($"mongodb://{Host}:{port}/?directConnection=true"); var admin = client.GetDatabase("admin"); var config = new BsonDocument { { "_id", ReplicaSetName }, - { "members", new BsonArray { new BsonDocument { { "_id", 0 }, { "host", $"127.0.0.1:{port}" } } } } + { "members", new BsonArray { new BsonDocument { { "_id", 0 }, { "host", $"{Host}:{port}" } } } } }; admin.RunCommand(new BsonDocument("replSetInitiate", config)); } @@ -163,7 +164,7 @@ private static void InitializeReplicaSet(int port) private static void WaitForReplicaSetReady(int port, int timeoutSeconds = 30) { var client = new MongoClient( - $"mongodb://127.0.0.1:{port}/?directConnection=true&replicaSet={ReplicaSetName}"); + $"mongodb://{Host}:{port}/?directConnection=true&replicaSet={ReplicaSetName}"); var deadline = DateTime.UtcNow.AddSeconds(timeoutSeconds); Exception? lastException = null; while (DateTime.UtcNow < deadline) @@ -172,7 +173,7 @@ private static void WaitForReplicaSetReady(int port, int timeoutSeconds = 30) { var admin = client.GetDatabase("admin"); var status = admin.RunCommand(new BsonDocument("replSetGetStatus", 1)); - if (status["ok"].AsDouble == 1.0) + if (status["ok"].ToInt32() == 1 && status["myState"].ToInt32() == 1) { return; }