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
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..ae61cfa6db
--- /dev/null
+++ b/Backend.Tests/Repositories/MongoDbTestRunner.cs
@@ -0,0 +1,213 @@
+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 Host = "127.0.0.1";
+ 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://{Host}:{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 {Host}",
+ $"--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(Host, 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://{Host}:{port}/?directConnection=true");
+ var admin = client.GetDatabase("admin");
+ var config = new BsonDocument
+ {
+ { "_id", ReplicaSetName },
+ { "members", new BsonArray { new BsonDocument { { "_id", 0 }, { "host", $"{Host}:{port}" } } } }
+ };
+ admin.RunCommand(new BsonDocument("replSetInitiate", config));
+ }
+
+ private static void WaitForReplicaSetReady(int port, int timeoutSeconds = 30)
+ {
+ var client = new MongoClient(
+ $"mongodb://{Host}:{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"].ToInt32() == 1 && status["myState"].ToInt32() == 1)
+ {
+ 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..aa9f0acc37
--- /dev/null
+++ b/Backend.Tests/Repositories/WordRepositoryTests.cs
@@ -0,0 +1,593 @@
+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]
+ [Category("IntegrationTest")]
+ 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.Create(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);
+ }
+
+ // CREATE
+
+ [Test]
+ public async Task TestCreateSingleWordAddsToWordsAndFrontier()
+ {
+ var word = Util.RandomWord(_projectId);
+ var created = await _repo.Create(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 TestCreateSingleWordClearsOriginalId()
+ {
+ var word = Util.RandomWord(_projectId);
+ var originalId = word.Id;
+ var created = await _repo.Create(word);
+
+ Assert.That(created.Id, Is.Not.EqualTo(originalId));
+ }
+
+ [Test]
+ public async Task TestCreateListOfWordsAddsAll()
+ {
+ var words = Util.RandomWordList(3, _projectId);
+ var created = await _repo.Create(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 TestCreateEmptyListReturnsEmpty()
+ {
+ var created = await _repo.Create([]);
+ Assert.That(created, Is.Empty);
+ }
+
+ // 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.Create(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.Create(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);
+ }
+
+ // 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);
+ }
+
+ // 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.DeleteFrontier(_projectId, word.Id, _ => { });
+ Assert.That(await _repo.IsInFrontier(_projectId, word.Id), Is.False);
+
+ 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 TestRestoreFrontierNotFoundReturnsFalse()
+ {
+ var result = await _repo.RestoreFrontier(_projectId, NewObjectId());
+ 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()
+ {
+ // Create a word and archive it as Deleted
+ var word = await CreateWord();
+ var archivedWord = await _repo.DeleteFrontier(
+ _projectId, word.Id, w => w.Accessibility = Status.Deleted);
+ Assert.That(archivedWord, Is.Not.Null);
+
+ var ex = Assert.ThrowsAsync(() =>
+ _repo.RestoreFrontier(_projectId, archivedWord!.Id));
+ Assert.That(ex, Is.Not.Null);
+ }
+
+ // UPDATE FRONTIER (by projectId, wordId, modifyWord)
+
+ [Test]
+ 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);
+ }
+
+ // REPLACE FRONTIER
+
+ [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 TestReplaceFrontierEmptyListsReturnsEmpty()
+ {
+ 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]
+ 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));
+ }
+ }
+}
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",