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;
}