diff --git a/.gitignore b/.gitignore
index c4106cccd5..d58a3b39f3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -34,7 +34,7 @@ Session.vim
scripts/*.js
!scripts/frontendScripts.js
!scripts/jestTest.js
-!scripts/setupMongo.js
+!scripts/startDatabase.js
database/*.js
*.log
*-debug.log*
diff --git a/.vscode/tasks.json b/.vscode/tasks.json
index 13e99593e1..9ce4881b71 100644
--- a/.vscode/tasks.json
+++ b/.vscode/tasks.json
@@ -28,9 +28,9 @@
},
{
"label": "run-mongo",
- "command": "mongod",
+ "command": "npm",
"type": "process",
- "args": ["--dbpath", "${workspaceFolder}/mongo_database"],
+ "args": ["run", "database"],
"problemMatcher": "$tsc"
}
]
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/Controllers/AudioControllerTests.cs b/Backend.Tests/Controllers/AudioControllerTests.cs
index e2bb8978b7..3e92b932fc 100644
--- a/Backend.Tests/Controllers/AudioControllerTests.cs
+++ b/Backend.Tests/Controllers/AudioControllerTests.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.IO;
using Backend.Tests.Mocks;
using BackendFramework.Controllers;
@@ -14,7 +14,7 @@ namespace Backend.Tests.Controllers
internal sealed class AudioControllerTests : IDisposable
{
private IProjectRepository _projRepo = null!;
- private IWordRepository _wordRepo = null!;
+ private WordRepositoryMock _wordRepo = null!;
private PermissionServiceMock _permissionService = null!;
private WordService _wordService = null!;
private AudioController _audioController = null!;
@@ -163,20 +163,19 @@ public void TestDeleteAudioFileInvalidArguments()
[Test]
public void TestDeleteAudioFileNoWordWithAudio()
{
- var result = _audioController.DeleteAudioFile(_projId, "not-a-word", _file.FileName).Result;
- Assert.That(result, Is.InstanceOf());
+ var result1 = _audioController.DeleteAudioFile(_projId, "not-a-word", _file.FileName).Result;
+ Assert.That(result1, Is.InstanceOf());
var wordId = _wordRepo.Create(Util.RandomWord(_projId)).Result.Id;
- result = _audioController.DeleteAudioFile(_projId, wordId, _file.FileName).Result;
- Assert.That(result, Is.InstanceOf());
+ var result2 = _audioController.DeleteAudioFile(_projId, wordId, _file.FileName).Result;
+ Assert.That(result2, Is.InstanceOf());
}
[Test]
public void TestDeleteAudioFile()
{
// Refill test database
- _wordRepo.DeleteAllWords(_projId).Wait();
- _wordRepo.DeleteAllFrontierWords(_projId).Wait();
+ _wordRepo.DeleteAllWords(_projId);
var origWord = Util.RandomWord(_projId);
const string fileName = "a.wav";
origWord.Audio.Add(new Pronunciation(fileName));
diff --git a/Backend.Tests/Controllers/LiftControllerTests.cs b/Backend.Tests/Controllers/LiftControllerTests.cs
index a4b49aa7d7..b233afdacd 100644
--- a/Backend.Tests/Controllers/LiftControllerTests.cs
+++ b/Backend.Tests/Controllers/LiftControllerTests.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
@@ -21,7 +21,7 @@ internal sealed class LiftControllerTests : IDisposable
{
private IProjectRepository _projRepo = null!;
private ISpeakerRepository _speakerRepo = null!;
- private IWordRepository _wordRepo = null!;
+ private WordRepositoryMock _wordRepo = null!;
private ILiftService _liftService = null!;
private IWordService _wordService = null!;
private LiftController _liftController = null!;
@@ -54,8 +54,8 @@ public void Setup()
var permissionService = new PermissionServiceMock();
_wordService = new WordService(_wordRepo);
var logger = new LoggerMock();
- _liftController = new LiftController(_projRepo, semDomRepo, _speakerRepo, _wordRepo, ackService,
- _liftService, notifyService, permissionService, logger);
+ _liftController = new LiftController(_projRepo, semDomRepo, _speakerRepo, _wordRepo, _wordService,
+ ackService, _liftService, notifyService, permissionService, logger);
_projId = _projRepo.Create(new Project { Name = ProjName }).Result!.Id;
_file = new FormFile(_stream, 0, _stream.Length, "Name", FileName);
diff --git a/Backend.Tests/Controllers/WordControllerTests.cs b/Backend.Tests/Controllers/WordControllerTests.cs
index 4125719b44..839c84251a 100644
--- a/Backend.Tests/Controllers/WordControllerTests.cs
+++ b/Backend.Tests/Controllers/WordControllerTests.cs
@@ -14,7 +14,7 @@ namespace Backend.Tests.Controllers
{
internal sealed class WordControllerTests : IDisposable
{
- private IWordRepository _wordRepo = null!;
+ private WordRepositoryMock _wordRepo = null!;
private IPermissionService _permissionService = null!;
private IWordService _wordService = null!;
private WordController _wordController = null!;
@@ -411,15 +411,13 @@ public async Task TestUpdateWordMissingWord()
[Test]
public async Task TestRestoreWord()
{
- var word = await _wordRepo.Create(Util.RandomWord(ProjId));
- await _wordRepo.DeleteFrontier(ProjId, word.Id);
+ var word = await _wordRepo.Add(Util.RandomWord(ProjId));
Assert.That(await _wordRepo.GetAllWords(ProjId), Does.Contain(word).UsingPropertiesComparer());
Assert.That(await _wordRepo.GetAllFrontier(ProjId), Is.Empty);
- var result = await _wordController.RestoreWord(ProjId, word.Id) as OkObjectResult;
- Assert.That(result, Is.Not.Null);
- Assert.That(result.Value, Is.True);
+ var result = await _wordController.RestoreWord(ProjId, word.Id);
+ Assert.That(result, Is.InstanceOf());
Assert.That(await _wordRepo.GetAllWords(ProjId), Does.Contain(word).UsingPropertiesComparer());
Assert.That(await _wordRepo.GetAllFrontier(ProjId), Does.Contain(word).UsingPropertiesComparer());
}
@@ -433,9 +431,7 @@ public async Task TestRestoreWordAlreadyInFrontier()
Assert.That(await _wordRepo.GetAllFrontier(ProjId), Does.Contain(word).UsingPropertiesComparer());
var frontierCount = await _wordRepo.GetFrontierCount(ProjId);
- var result = await _wordController.RestoreWord(ProjId, word.Id) as OkObjectResult;
- Assert.That(result, Is.Not.Null);
- Assert.That(result.Value, Is.False);
+ Assert.ThrowsAsync(async () => await _wordController.RestoreWord(ProjId, word.Id));
Assert.That(await _wordRepo.GetFrontierCount(ProjId), Is.EqualTo(frontierCount));
}
diff --git a/Backend.Tests/Mocks/MongoDbContextMock.cs b/Backend.Tests/Mocks/MongoDbContextMock.cs
new file mode 100644
index 0000000000..f8695cf7bf
--- /dev/null
+++ b/Backend.Tests/Mocks/MongoDbContextMock.cs
@@ -0,0 +1,36 @@
+using System;
+using System.Threading.Tasks;
+using BackendFramework.Interfaces;
+using MongoDB.Driver;
+
+namespace Backend.Tests.Mocks
+{
+ public class MongoDbContextMock : IMongoDbContext
+ {
+ public IMongoDatabase Db => throw new NotSupportedException();
+
+ public Task BeginTransaction()
+ => Task.FromResult(new MongoTransactionMock());
+
+ public Task ExecuteInTransaction(Func> operation)
+ {
+ throw new NotImplementedException();
+ }
+
+ public Task ExecuteInTransactionAllowNull(Func> operation)
+ {
+ throw new NotImplementedException();
+ }
+
+ private sealed class MongoTransactionMock : IMongoTransaction
+ {
+ public IClientSessionHandle Session => null!;
+
+ public Task CommitTransactionAsync() => Task.CompletedTask;
+
+ public Task AbortTransactionAsync() => Task.CompletedTask;
+
+ public void Dispose() { }
+ }
+ }
+}
diff --git a/Backend.Tests/Mocks/WordRepositoryMock.cs b/Backend.Tests/Mocks/WordRepositoryMock.cs
index 11936493ac..4d5f976a69 100644
--- a/Backend.Tests/Mocks/WordRepositoryMock.cs
+++ b/Backend.Tests/Mocks/WordRepositoryMock.cs
@@ -20,7 +20,7 @@ internal sealed class WordRepositoryMock : IWordRepository
/// Sets a delay for the GetFrontier method. The first call to GetFrontier will wait
/// until the provided Task is completed.
///
- public void SetGetFrontierDelay(Task delay)
+ internal void SetGetFrontierDelay(Task delay)
{
_getAllFrontierDelay = delay;
_getAllFrontierCallCount = 0;
@@ -33,50 +33,41 @@ public Task> GetAllWords(string projectId)
public Task GetWord(string projectId, string wordId)
{
- try
- {
- return Task.FromResult(_words.Single(w => w.ProjectId == projectId && w.Id == wordId).Clone());
- }
- catch (InvalidOperationException)
- {
- return Task.FromResult(null);
- }
+ return Task.FromResult(_words.FirstOrDefault(w => w.ProjectId == projectId && w.Id == wordId)?.Clone());
}
- public Task> GetWords(string projectId, List wordIds)
+ public async Task Create(Word word)
{
- return Task.FromResult(
- _words.Where(w => w.ProjectId == projectId && wordIds.Contains(w.Id)).Select(w => w.Clone()).ToList());
- }
-
- public Task Create(Word word)
- {
- word.Id = Guid.NewGuid().ToString();
- _words.Add(word.Clone());
- AddFrontier(word.Clone());
- return Task.FromResult(word.Clone());
+ return (await Create([word])).First();
}
public Task> Create(List words)
{
- foreach (var w in words)
+ if (words.Count == 0)
{
- Create(w);
+ return Task.FromResult(words);
}
+
+ words.ForEach(word =>
+ {
+ word.Id = Guid.NewGuid().ToString();
+ _words.Add(word.Clone());
+ _frontier.Add(word.Clone());
+ });
+
return Task.FromResult(words);
}
- public Task DeleteAllWords(string projectId)
+ /// Removes all words and frontier words for the given projectId.
+ internal void DeleteAllWords(string projectId)
{
_words.RemoveAll(word => word.ProjectId == projectId);
_frontier.RemoveAll(word => word.ProjectId == projectId);
- return Task.FromResult(true);
}
public Task DeleteAllFrontierWords(string projectId)
{
- _frontier.RemoveAll(word => word.ProjectId == projectId);
- return Task.FromResult(true);
+ return Task.FromResult(_frontier.RemoveAll(word => word.ProjectId == projectId) != 0);
}
public Task HasWords(string projectId)
@@ -132,35 +123,188 @@ public Task> GetFrontierWithVernacular(string projectId, string verna
w => w.ProjectId == projectId && w.Vernacular == vernacular).Select(w => w.Clone()).ToList());
}
- public Task AddFrontier(Word word)
+ /// Adds a new word to the words without adding it to the frontier.
+ internal Task Add(Word word)
+ {
+ word.Id = Guid.NewGuid().ToString();
+ _words.Add(word.Clone());
+ return Task.FromResult(word);
+ }
+
+ /// Adds a new word to the frontier without adding it to the words.
+ internal Task AddFrontier(Word word)
{
_frontier.Add(word.Clone());
return Task.FromResult(word);
}
- public Task> AddFrontier(List words)
+ /// Adds new words to the frontier without adding them to the words.
+ internal Task> AddFrontier(List words)
{
words.ForEach(w => _frontier.Add(w.Clone()));
return Task.FromResult(words);
}
- public Task DeleteFrontier(string projectId, string wordId, string? audioFileName = null)
+ public Task DeleteFrontier(string projectId, string wordId, Action modifyDeletedWord)
{
- var word = _frontier.Find(w => w.ProjectId == projectId && w.Id == wordId &&
- (string.IsNullOrEmpty(audioFileName) || w.Audio.Any(a => a.FileName == audioFileName)));
- if (word is null)
+ var removedWord = _frontier.Find(w => w.ProjectId == projectId && w.Id == wordId);
+ if (removedWord is null)
{
return Task.FromResult(null);
}
- _frontier.RemoveAll(w => w.ProjectId == projectId && w.Id == wordId);
- return Task.FromResult(word);
+
+ _frontier.Remove(removedWord);
+
+ var modifiedWord = removedWord.Clone();
+ modifyDeletedWord(modifiedWord);
+ modifiedWord.Id = Guid.NewGuid().ToString();
+
+ _words.Add(modifiedWord.Clone());
+ return Task.FromResult(modifiedWord);
+ }
+
+ private bool CanRestore(string projectId, string wordId)
+ {
+ var word = _words.FirstOrDefault(w => w.ProjectId == projectId && w.Id == wordId);
+ if (word is null)
+ {
+ return false;
+ }
+ if (word.Accessibility == Status.Deleted)
+ {
+ throw new ArgumentException("Cannot add a word with Deleted status to Frontier");
+ }
+ if (_frontier.Any(f => f.Id == word.Id))
+ {
+ throw new ArgumentException("Cannot restore a word with an Id already in the Frontier");
+ }
+ return true;
}
- public Task Add(Word word)
+ public Task RestoreFrontier(string projectId, string wordId)
{
+ if (!CanRestore(projectId, wordId))
+ {
+ return Task.FromResult(false);
+ }
+
+ // Word non-null because of the check in CanRestore.
+ var word = _words.FirstOrDefault(w => w.ProjectId == projectId && w.Id == wordId)!;
+ _frontier.Add(word.Clone());
+ return Task.FromResult(true);
+ }
+
+ private Task UpdateFrontier(Word word, bool createIfNotFound, Action modifyUpdatedWord)
+ {
+ var removedWord = _frontier.Find(w => w.ProjectId == word.ProjectId && w.Id == word.Id);
+ if (removedWord is null && !createIfNotFound)
+ {
+ return Task.FromResult(null);
+ }
+
+ if (removedWord is not null)
+ {
+ _frontier.Remove(removedWord);
+ }
+
+ modifyUpdatedWord(word, removedWord?.Clone());
word.Id = Guid.NewGuid().ToString();
+
_words.Add(word.Clone());
- return Task.FromResult(word);
+ _frontier.Add(word.Clone());
+ return Task.FromResult(word);
+ }
+
+ public Task UpdateFrontier(string projectId, string wordId, Action modifyUpdatedWord)
+ {
+ var removedWord = _frontier.Find(w => w.ProjectId == projectId && w.Id == wordId);
+ if (removedWord is null)
+ {
+ return Task.FromResult(null);
+ }
+
+ var modifiedWord = removedWord.Clone();
+ modifyUpdatedWord(modifiedWord);
+
+ _frontier.Remove(removedWord);
+ modifiedWord.Id = Guid.NewGuid().ToString();
+
+ _words.Add(modifiedWord.Clone());
+ _frontier.Add(modifiedWord.Clone());
+ return Task.FromResult(modifiedWord);
+ }
+
+ public async Task UpdateFrontier(Word word, Action modifyUpdatedWord)
+ {
+ var removedWord = _frontier.Find(w => w.ProjectId == word.ProjectId && w.Id == word.Id);
+ if (removedWord is null)
+ {
+ return null;
+ }
+
+ return await UpdateFrontier(word, createIfNotFound: false, modifyUpdatedWord);
+ }
+
+ public async Task> ReplaceFrontier(string projectId, List newWords,
+ List idsToDelete, Action modifyUpdatedWord, Action modifyDeletedWord)
+ {
+ if (newWords.Any(w => w.ProjectId != projectId))
+ {
+ throw new ArgumentException("All new words must have the specified projectId");
+ }
+
+ var oldIdSet = idsToDelete.ToHashSet();
+ // Make sure the replace is valid, mimicking a canceled transaction in production.
+ if (oldIdSet.Any(id => !_frontier.Any(f => f.ProjectId == projectId && f.Id == id)))
+ {
+ throw new ArgumentException("All old words being replaced must exist in the Frontier");
+ }
+
+ foreach (var word in newWords)
+ {
+ oldIdSet.Remove(word.Id);
+ await UpdateFrontier(word, createIfNotFound: true, modifyUpdatedWord);
+ }
+
+ foreach (var oldId in oldIdSet)
+ {
+ await DeleteFrontier(projectId, oldId, modifyDeletedWord);
+ }
+
+ return newWords;
+ }
+ public async Task RevertReplaceFrontier(
+ string projectId, List idsToRestore, List idsToDelete, Action modifyDeletedWord)
+ {
+ // Remove duplicates and enforce no overlap.
+ var restoreSet = idsToRestore.ToHashSet();
+ var deleteSet = idsToDelete.ToHashSet();
+ if (restoreSet.Intersect(deleteSet).Any())
+ {
+ throw new ArgumentException("Ids to delete and restore must be disjoint");
+ }
+
+ // Make sure the revert is valid, mimicking a canceled transaction in production.
+ if (deleteSet.Any(id => !_frontier.Any(f => f.ProjectId == projectId && f.Id == id)))
+ {
+ return false;
+ }
+ if (restoreSet.Any(id => !CanRestore(projectId, id)))
+ {
+ return false;
+ }
+
+ foreach (var id in deleteSet)
+ {
+ await DeleteFrontier(projectId, id, modifyDeletedWord);
+ }
+ foreach (var id in restoreSet)
+ {
+ // The restore will pass since we checked CanRestore above.
+ await RestoreFrontier(projectId, id);
+ }
+
+ return true;
}
public Task CountFrontierWordsWithDomain(string projectId, string domainId)
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..e87e541360
--- /dev/null
+++ b/Backend.Tests/Repositories/WordRepositoryTests.cs
@@ -0,0 +1,726 @@
+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();
+
+ [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);
+ }
+
+ [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);
+ }
+
+ [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);
+ }
+
+ [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);
+ }
+
+ [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);
+ }
+
+ [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 TestAreInFrontierNegativeCountReturnsTrue()
+ {
+ Assert.That(await _repo.AreInFrontier(_projectId, [], -1), Is.True);
+ }
+
+ [Test]
+ public async Task TestAreInFrontierZeroCountReturnsTrue()
+ {
+ Assert.That(await _repo.AreInFrontier(_projectId, [NewObjectId()], 0), Is.True);
+ }
+
+ [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);
+ }
+
+ [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.Zero);
+ }
+
+ [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));
+ }
+
+ [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);
+ }
+
+ [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);
+ }
+
+ [Test]
+ public async Task TestDeleteFrontierRemovesFromFrontierAndArchives()
+ {
+ var created = await CreateWord();
+ var createdId = created.Id;
+
+ var deleted = await _repo.DeleteFrontier(_projectId, createdId, w => w.Accessibility = Status.Deleted);
+
+ Assert.That(deleted, Is.Not.Null);
+ Assert.That(await _repo.IsInFrontier(_projectId, createdId), Is.False);
+ Assert.That(deleted.Id, Is.Not.EqualTo(createdId));
+ 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);
+ }
+
+ [Test]
+ public async Task TestDeleteFrontierModifyActionThrowsLeavesRepoUnchanged()
+ {
+ var created = await CreateWord();
+ var createdId = created.Id;
+
+ Assert.ThrowsAsync(() =>
+ _repo.DeleteFrontier(_projectId, createdId, w =>
+ {
+ w.Accessibility = Status.Deleted;
+ throw new InvalidOperationException();
+ }));
+
+ Assert.That((await _repo.GetAllWords(_projectId)).Select(w => w.Id), Is.EqualTo([createdId]));
+
+ Assert.That(await _repo.GetFrontierCount(_projectId), Is.EqualTo(1));
+ var frontierWord = await _repo.GetFrontier(_projectId, createdId);
+ Assert.That(frontierWord, Is.Not.Null);
+ Assert.That(frontierWord.Accessibility, Is.Not.EqualTo(Status.Deleted));
+ }
+
+ [Test]
+ public async Task TestRestoreFrontierRestoresWordToFrontier()
+ {
+ var word = await CreateWord();
+ var wordId = word.Id;
+ await _repo.DeleteFrontier(_projectId, wordId, _ => { });
+ Assert.That(await _repo.IsInFrontier(_projectId, wordId), Is.False);
+
+ var restored = await _repo.RestoreFrontier(_projectId, wordId);
+ Assert.That(restored, Is.True);
+ Assert.That(await _repo.IsInFrontier(_projectId, wordId), 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();
+ Assert.ThrowsAsync(() => _repo.RestoreFrontier(_projectId, word.Id));
+ }
+
+ [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);
+
+ Assert.ThrowsAsync(() => _repo.RestoreFrontier(_projectId, archivedWord.Id));
+ }
+
+ [Test]
+ public async Task TestUpdateFrontierByIdsUpdatesWord()
+ {
+ var created = await CreateWord();
+ var createdId = created.Id;
+ const string newVernacular = "updated_vernacular";
+
+ var updated = await _repo.UpdateFrontier(_projectId, createdId, w => w.Vernacular = newVernacular);
+
+ Assert.That(updated, Is.Not.Null);
+ Assert.That(updated.Vernacular, Is.EqualTo(newVernacular));
+ Assert.That(updated.Id, Is.Not.EqualTo(createdId));
+ Assert.That(await _repo.IsInFrontier(_projectId, createdId), 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);
+ }
+
+ [Test]
+ public async Task TestUpdateFrontierByIdsModifyActionThrowsLeavesRepoUnchanged()
+ {
+ var created = await CreateWord();
+ var createdId = created.Id;
+ const string newVernacular = "should_not_persist";
+
+ Assert.ThrowsAsync(() =>
+ _repo.UpdateFrontier(_projectId, createdId, w =>
+ {
+ w.Vernacular = newVernacular;
+ throw new InvalidOperationException();
+ }));
+
+ Assert.That((await _repo.GetAllWords(_projectId)).Select(w => w.Id), Is.EqualTo([createdId]));
+
+ Assert.That(await _repo.GetFrontierCount(_projectId), Is.EqualTo(1));
+ var frontierWord = await _repo.GetFrontier(_projectId, createdId);
+ Assert.That(frontierWord, Is.Not.Null);
+ Assert.That(frontierWord.Vernacular, Is.Not.EqualTo(newVernacular));
+ }
+
+ [Test]
+ public async Task TestUpdateFrontierByWordUpdatesWord()
+ {
+ var created = await CreateWord();
+ var createdId = created.Id;
+ const string newVernacular = "updated_vernacular";
+ var updatedWord = created.Clone();
+ updatedWord.Vernacular = newVernacular;
+
+ var result = await _repo.UpdateFrontier(updatedWord, (newWord, oldWord) =>
+ {
+ Assert.That(oldWord, Is.Not.Null);
+ newWord.History = [oldWord.Id];
+ });
+
+ Assert.That(result, Is.Not.Null);
+ Assert.That(result.History, Is.EqualTo([createdId]));
+ Assert.That(result.Vernacular, Is.EqualTo(newVernacular));
+ Assert.That(await _repo.IsInFrontier(_projectId, createdId), Is.False);
+ Assert.That(await _repo.IsInFrontier(_projectId, result.Id), Is.True);
+ }
+
+ [Test]
+ public async Task TestUpdateFrontierByWordNotInFrontierReturnsNullAndLeavesRepoUnchanged()
+ {
+ var word = Util.RandomWord(_projectId);
+ word.Id = NewObjectId();
+
+ var result = await _repo.UpdateFrontier(word, (_, _) => { });
+
+ Assert.That(result, Is.Null);
+ Assert.That(await _repo.HasWords(_projectId), Is.False);
+ Assert.That(await _repo.HasFrontierWords(_projectId), Is.False);
+ }
+
+ [Test]
+ public async Task TestUpdateFrontierByWordModifyActionThrowsLeavesRepoUnchanged()
+ {
+ var created = await CreateWord();
+ var createdId = created.Id;
+ const string newVernacular = "should_not_persist";
+ var updatedWord = created.Clone();
+ updatedWord.Vernacular = newVernacular;
+
+ Assert.ThrowsAsync(() =>
+ _repo.UpdateFrontier(updatedWord, (_, _) => throw new InvalidOperationException()));
+
+ Assert.That((await _repo.GetAllWords(_projectId)).Select(w => w.Id), Is.EqualTo([createdId]));
+
+ Assert.That(await _repo.GetFrontierCount(_projectId), Is.EqualTo(1));
+ var frontierWord = await _repo.GetFrontier(_projectId, createdId);
+ Assert.That(frontierWord, Is.Not.Null);
+ Assert.That(frontierWord.Vernacular, Is.Not.EqualTo(newVernacular));
+ }
+
+ [Test]
+ public async Task TestReplaceFrontierUpdatesAndDeletesWords()
+ {
+ var toUpdate = await CreateWord();
+ var toUpdateId = toUpdate.Id;
+ var toDelete = await CreateWord();
+ var toDeleteId = toDelete.Id;
+ const string newVernacular = "updated_vernacular";
+ var updatedWord = toUpdate.Clone();
+ updatedWord.Vernacular = newVernacular;
+
+ var result = await _repo.ReplaceFrontier(_projectId, [updatedWord], [toUpdateId, toDeleteId],
+ modifyUpdatedWord: (newWord, oldWord) =>
+ {
+ Assert.That(oldWord, Is.Not.Null);
+ newWord.History = [oldWord.Id];
+ },
+ modifyDeletedWord: w => w.Accessibility = Status.Deleted);
+
+ Assert.That(result, Is.Not.Null);
+ Assert.That(result, Has.Count.EqualTo(1));
+ Assert.That(result[0].History, Is.EqualTo([toUpdateId]));
+ Assert.That(result[0].Vernacular, Is.EqualTo(newVernacular));
+
+ var allWords = await _repo.GetAllWords(_projectId);
+ Assert.That(allWords, Has.Count.EqualTo(4));
+ Assert.That(allWords.Where(w => w.Accessibility != Status.Deleted).Select(w => w.Id),
+ Is.EquivalentTo([toUpdateId, toDeleteId, result[0].Id]));
+
+ Assert.That(await _repo.IsInFrontier(_projectId, toUpdateId), Is.False);
+ Assert.That(await _repo.IsInFrontier(_projectId, toDeleteId), Is.False);
+ Assert.That(await _repo.GetFrontierCount(_projectId), Is.EqualTo(1));
+ Assert.That(await _repo.IsInFrontier(_projectId, result[0].Id), Is.True);
+
+ }
+
+ [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);
+ }
+
+ [Test]
+ public async Task TestReplaceFrontierModifyUpdatedActionThrowsLeavesRepoUnchanged()
+ {
+ var toUpdate = await CreateWord();
+ var toUpdateId = toUpdate.Id;
+ var toDelete = await CreateWord();
+ var toDeleteId = toDelete.Id;
+ const string newVernacular = "should_not_persist";
+ var updatedWord = toUpdate.Clone();
+ updatedWord.Vernacular = newVernacular;
+
+ Assert.ThrowsAsync(() => _repo.ReplaceFrontier(_projectId,
+ [updatedWord], [toUpdateId, toDeleteId], (_, _) => throw new InvalidOperationException(), _ => { }));
+
+ Assert.That((await _repo.GetAllWords(_projectId)).Select(w => w.Id),
+ Is.EquivalentTo([toUpdateId, toDeleteId]));
+
+ Assert.That(await _repo.GetFrontierCount(_projectId), Is.EqualTo(2));
+ Assert.That(await _repo.IsInFrontier(_projectId, toDeleteId), Is.True);
+ var frontierWordToUpdate = await _repo.GetFrontier(_projectId, toUpdateId);
+ Assert.That(frontierWordToUpdate, Is.Not.Null);
+ Assert.That(frontierWordToUpdate.Vernacular, Is.Not.EqualTo(newVernacular));
+ }
+
+ [Test]
+ public async Task TestReplaceFrontierModifyDeletedActionThrowsLeavesRepoUnchanged()
+ {
+ var toDelete = await CreateWord();
+ var toDeleteId = toDelete.Id;
+
+ Assert.ThrowsAsync(() => _repo.ReplaceFrontier(
+ _projectId, [], [toDeleteId], (_, _) => { }, _ => throw new InvalidOperationException()));
+
+ Assert.That((await _repo.GetAllWords(_projectId)).Select(w => w.Id), Is.EqualTo([toDeleteId]));
+
+ Assert.That(await _repo.GetFrontierCount(_projectId), Is.EqualTo(1));
+ Assert.That(await _repo.IsInFrontier(_projectId, toDeleteId), Is.True);
+ }
+
+ [Test]
+ public void TestReplaceFrontierDifferentProjectIdThrows()
+ {
+ var newWord = Util.RandomWord(Guid.NewGuid().ToString());
+ Assert.ThrowsAsync(() =>
+ _repo.ReplaceFrontier(_projectId, [newWord], [], (_, _) => { }, _ => { }));
+ }
+
+ [Test]
+ public void TestReplaceFrontierMissingDeleteIdThrows()
+ {
+ Assert.ThrowsAsync(() =>
+ _repo.ReplaceFrontier(_projectId, [], [NewObjectId()], (_, _) => { }, _ => { }));
+ }
+
+ [Test]
+ public async Task TestRevertReplaceFrontierRestoresAndDeletes()
+ {
+ var toRestore = await CreateWord();
+ var toRestoreId = toRestore.Id;
+ var toDelete = await CreateWord();
+ var toDeleteId = toDelete.Id;
+
+ // Remove toRestore from frontier so it can be restored later
+ await _repo.DeleteFrontier(_projectId, toRestoreId, _ => { });
+
+ var result = await _repo.RevertReplaceFrontier(
+ _projectId, [toRestoreId], [toDeleteId], w => w.Accessibility = Status.Deleted);
+
+ Assert.That(result, Is.True);
+ Assert.That(await _repo.IsInFrontier(_projectId, toRestoreId), Is.True);
+ Assert.That(await _repo.IsInFrontier(_projectId, toDeleteId), Is.False);
+ }
+
+ [Test]
+ public async Task TestRevertReplaceFrontierEmptyListsReturnsTrue()
+ {
+ var result = await _repo.RevertReplaceFrontier(_projectId, [], [], _ => { });
+ Assert.That(result, Is.True);
+ }
+
+ [Test]
+ public async Task TestRevertReplaceFrontierMissingDeleteIdReturnsFalseAndLeavesRepoUnchanged()
+ {
+ var toRestore = await CreateWord();
+ var toRestoreId = toRestore.Id;
+ var toDelete = await CreateWord();
+ var toDeleteId = toDelete.Id;
+ await _repo.DeleteFrontier(_projectId, toRestoreId, _ => { });
+
+ var result = await _repo.RevertReplaceFrontier(
+ _projectId, [toRestoreId], [toDeleteId, NewObjectId()], _ => { });
+
+ Assert.That(result, Is.False);
+ Assert.That(await _repo.IsInFrontier(_projectId, toRestoreId), Is.False);
+ Assert.That(await _repo.IsInFrontier(_projectId, toDeleteId), Is.True);
+ }
+
+ [Test]
+ public async Task TestRevertReplaceFrontierMissingRestoreIdReturnsFalseAndLeavesRepoUnchanged()
+ {
+ var toRestore = await CreateWord();
+ var toRestoreId = toRestore.Id;
+ var toDelete = await CreateWord();
+ var toDeleteId = toDelete.Id;
+ await _repo.DeleteFrontier(_projectId, toRestoreId, _ => { });
+
+ var result = await _repo.RevertReplaceFrontier(
+ _projectId, [toRestoreId, NewObjectId()], [toDeleteId], _ => { });
+
+ Assert.That(result, Is.False);
+ Assert.That(await _repo.IsInFrontier(_projectId, toDeleteId), Is.True);
+ Assert.That(await _repo.IsInFrontier(_projectId, toRestoreId), Is.False);
+ }
+
+ [Test]
+ public async Task TestRevertReplaceFrontierModifyDeletedActionThrowsLeavesRepoUnchanged()
+ {
+ var toRestore = await CreateWord();
+ var toRestoreId = toRestore.Id;
+ var toDelete = await CreateWord();
+ var toDeleteId = toDelete.Id;
+ await _repo.DeleteFrontier(_projectId, toRestoreId, _ => { });
+
+ Assert.ThrowsAsync(() => _repo.RevertReplaceFrontier(
+ _projectId, [toRestoreId], [toDeleteId], _ => throw new InvalidOperationException()));
+
+ Assert.That((await _repo.GetAllWords(_projectId)).Count, Is.EqualTo(3));
+
+ Assert.That(await _repo.GetFrontierCount(_projectId), Is.EqualTo(1));
+ Assert.That(await _repo.IsInFrontier(_projectId, toDeleteId), Is.True);
+ Assert.That(await _repo.IsInFrontier(_projectId, toRestoreId), Is.False);
+ }
+
+ [Test]
+ public async Task TestRevertReplaceFrontierOverlappingIdsThrows()
+ {
+ var word = await CreateWord();
+ Assert.ThrowsAsync(() =>
+ _repo.RevertReplaceFrontier(_projectId, [word.Id], [word.Id], _ => { }));
+ }
+
+ [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.Zero);
+ }
+ }
+}
diff --git a/Backend.Tests/Services/MergeServiceTests.cs b/Backend.Tests/Services/MergeServiceTests.cs
index f3c6366eeb..309c30529f 100644
--- a/Backend.Tests/Services/MergeServiceTests.cs
+++ b/Backend.Tests/Services/MergeServiceTests.cs
@@ -16,7 +16,7 @@ internal sealed class MergeServiceTests
private IMemoryCache _cache = null!;
private IMergeBlacklistRepository _mergeBlacklistRepo = null!;
private IMergeGraylistRepository _mergeGraylistRepo = null!;
- private IWordRepository _wordRepo = null!;
+ private WordRepositoryMock _wordRepo = null!;
private IWordService _wordService = null!;
private IMergeService _mergeService = null!;
@@ -168,14 +168,14 @@ public void UndoMergeOneChildTest()
var childIds = mergeObject.Children.Select(word => word.SrcWordId).ToList();
var parentIds = new List { newWords[0].Id };
var mergedWord = new MergeUndoIds(parentIds, childIds);
- var undo = _mergeService.UndoMerge(ProjId, UserId, mergedWord).Result;
- Assert.That(undo, Is.True);
- var frontierWords = _wordRepo.GetAllFrontier(ProjId).Result;
- var frontierWordIds = frontierWords.Select(word => word.Id).ToList();
+ var result = _mergeService.UndoMerge(ProjId, UserId, mergedWord).Result;
+ Assert.That(result, Is.True);
+ var frontierWords = _wordRepo.GetAllFrontier(ProjId).Result;
Assert.That(frontierWords, Has.Count.EqualTo(1));
- Assert.That(frontierWordIds, Does.Contain(childIds[0]));
+ Assert.That(frontierWords[0].Id, Is.EqualTo(childIds[0]));
+
}
[Test]
@@ -201,13 +201,13 @@ public void UndoMergeMultiChildTest()
var childIds = mergeWords.Children.Select(word => word.SrcWordId).ToList();
var parentIds = new List { newWords[0].Id };
var mergedWord = new MergeUndoIds(parentIds, childIds);
- var undo = _mergeService.UndoMerge(ProjId, UserId, mergedWord).Result;
- Assert.That(undo, Is.True);
- var frontierWords = _wordRepo.GetAllFrontier(ProjId).Result;
- var frontierWordIds = frontierWords.Select(word => word.Id).ToList();
+ var result = _mergeService.UndoMerge(ProjId, UserId, mergedWord).Result;
+ Assert.That(result, Is.True);
+ var frontierWords = _wordRepo.GetAllFrontier(ProjId).Result;
Assert.That(frontierWords, Has.Count.EqualTo(numberOfChildren));
+ var frontierWordIds = frontierWords.Select(w => w.Id).ToList();
childIds.ForEach(id => Assert.That(frontierWordIds, Does.Contain(id)));
}
@@ -217,10 +217,10 @@ public void AddMergeToBlacklistTest()
var wordIds = new List { "1", "2" };
// Adding to blacklist should clear from graylist
- _ = _mergeService.AddToMergeGraylist(ProjId, UserId, wordIds).Result;
+ _mergeService.AddToMergeGraylist(ProjId, UserId, wordIds).Wait();
Assert.That(_mergeGraylistRepo.GetAllSets(ProjId).Result, Is.Not.Empty);
- _ = _mergeService.AddToMergeBlacklist(ProjId, UserId, wordIds).Result;
+ _mergeService.AddToMergeBlacklist(ProjId, UserId, wordIds).Wait();
var blacklist = _mergeBlacklistRepo.GetAllSets(ProjId).Result;
Assert.That(blacklist, Has.Count.EqualTo(1));
var expectedEntry = new MergeWordSet { ProjectId = ProjId, UserId = UserId, WordIds = wordIds };
@@ -247,7 +247,7 @@ public void IsInMergeBlacklistTest()
var subWordIds = new List { "3", "2" };
Assert.That(_mergeService.IsInMergeBlacklist(ProjId, subWordIds).Result, Is.False);
- _ = _mergeService.AddToMergeBlacklist(ProjId, UserId, wordIds).Result;
+ _mergeService.AddToMergeBlacklist(ProjId, UserId, wordIds).Wait();
Assert.That(_mergeService.IsInMergeBlacklist(ProjId, subWordIds).Result, Is.True);
}
@@ -280,8 +280,8 @@ public void UpdateMergeBlacklistTest()
WordIds = ["1", "4"]
};
- _ = _mergeBlacklistRepo.Create(entryA);
- _ = _mergeBlacklistRepo.Create(entryB);
+ _mergeBlacklistRepo.Create(entryA).Wait();
+ _mergeBlacklistRepo.Create(entryB).Wait();
var oldBlacklist = _mergeBlacklistRepo.GetAllSets(ProjId).Result;
Assert.That(oldBlacklist, Has.Count.EqualTo(2));
@@ -293,7 +293,7 @@ public void UpdateMergeBlacklistTest()
new() {Id = "3", ProjectId = ProjId},
new() {Id = "4", ProjectId = ProjId}
};
- _ = _wordRepo.AddFrontier(frontier).Result;
+ _wordRepo.AddFrontier(frontier).Wait();
// All entries affected.
var updatedEntriesCount = _mergeService.UpdateMergeBlacklist(ProjId).Result;
@@ -309,7 +309,7 @@ public void UpdateMergeBlacklistTest()
public void AddMergeToGraylistTest()
{
var wordIds = new List { "1", "2" };
- _ = _mergeService.AddToMergeGraylist(ProjId, UserId, wordIds).Result;
+ _mergeService.AddToMergeGraylist(ProjId, UserId, wordIds).Wait();
var graylist = _mergeGraylistRepo.GetAllSets(ProjId).Result;
Assert.That(graylist, Has.Count.EqualTo(1));
var expectedEntry = new MergeWordSet { ProjectId = ProjId, UserId = UserId, WordIds = wordIds };
@@ -323,11 +323,11 @@ public void AddMergeToGraylistSupersetTest()
var wordIds13 = new List { "1", "3" };
var wordIds123 = new List { "1", "2", "3" };
- _ = _mergeService.AddToMergeGraylist(ProjId, UserId, wordIds12).Result;
- _ = _mergeService.AddToMergeGraylist(ProjId, UserId, wordIds13).Result;
+ _mergeService.AddToMergeGraylist(ProjId, UserId, wordIds12).Wait();
+ _mergeService.AddToMergeGraylist(ProjId, UserId, wordIds13).Wait();
Assert.That(_mergeGraylistRepo.GetAllSets(ProjId).Result, Has.Count.EqualTo(2));
- _ = _mergeService.AddToMergeGraylist(ProjId, UserId, wordIds123).Result;
+ _mergeService.AddToMergeGraylist(ProjId, UserId, wordIds123).Wait();
Assert.That(_mergeGraylistRepo.GetAllSets(ProjId).Result, Has.Count.EqualTo(1));
}
@@ -346,17 +346,17 @@ public void AddMergeToGraylistErrorTest()
public void RemoveFromMergeGraylistTest()
{
var wordIds = new List { "1", "2", "3" };
- _ = _mergeService.AddToMergeGraylist(ProjId, UserId, wordIds).Result;
+ _mergeService.AddToMergeGraylist(ProjId, UserId, wordIds).Wait();
Assert.That(_mergeGraylistRepo.GetAllSets(ProjId).Result, Has.Count.EqualTo(1));
Assert.That(_mergeService.RemoveFromMergeGraylist(ProjId, UserId, wordIds).Result, Is.True);
- Assert.That(_mergeGraylistRepo.GetAllSets(ProjId).Result, Has.Count.EqualTo(0));
+ Assert.That(_mergeGraylistRepo.GetAllSets(ProjId).Result, Is.Empty);
}
[Test]
public void RemoveFromMergeGraylistSupersetTest()
{
var wordIds = new List { "1", "2" };
- _ = _mergeService.AddToMergeGraylist(ProjId, UserId, wordIds).Result;
+ _mergeService.AddToMergeGraylist(ProjId, UserId, wordIds).Wait();
Assert.That(_mergeGraylistRepo.GetAllSets(ProjId).Result, Has.Count.EqualTo(1));
wordIds.Add("3");
@@ -382,7 +382,7 @@ public void IsInMergeGraylistTest()
var subWordIds = new List { "3", "2" };
Assert.That(_mergeService.IsInMergeGraylist(ProjId, subWordIds).Result, Is.False);
- _ = _mergeService.AddToMergeGraylist(ProjId, UserId, wordIds).Result;
+ _mergeService.AddToMergeGraylist(ProjId, UserId, wordIds).Wait();
Assert.That(_mergeService.IsInMergeGraylist(ProjId, subWordIds).Result, Is.True);
}
@@ -415,8 +415,8 @@ public void UpdateMergeGraylistTest()
WordIds = ["1", "4"]
};
- _ = _mergeGraylistRepo.Create(entryA);
- _ = _mergeGraylistRepo.Create(entryB);
+ _mergeGraylistRepo.Create(entryA).Wait();
+ _mergeGraylistRepo.Create(entryB).Wait();
var oldGraylist = _mergeGraylistRepo.GetAllSets(ProjId).Result;
Assert.That(oldGraylist, Has.Count.EqualTo(2));
@@ -428,7 +428,7 @@ public void UpdateMergeGraylistTest()
new() {Id = "3", ProjectId = ProjId},
new() {Id = "4", ProjectId = ProjId}
};
- _ = _wordRepo.AddFrontier(frontier).Result;
+ _wordRepo.AddFrontier(frontier).Wait();
// All entries affected.
var updatedEntriesCount = _mergeService.UpdateMergeGraylist(ProjId).Result;
@@ -443,16 +443,16 @@ public void UpdateMergeGraylistTest()
[Test]
public void HasGraylistEntriesTrueTest()
{
- _ = _mergeGraylistRepo.Create(new() { Id = "A", ProjectId = ProjId, UserId = UserId });
- _ = _mergeGraylistRepo.Create(new()
+ _mergeGraylistRepo.Create(new() { Id = "A", ProjectId = ProjId, UserId = UserId }).Wait();
+ _mergeGraylistRepo.Create(new()
{
Id = "B",
ProjectId = ProjId,
UserId = UserId,
WordIds = ["i", "ii", "iii", "iv"]
- });
- _ = _wordRepo.AddFrontier([new() { Id = "ii", ProjectId = ProjId }]).Result;
- _ = _wordRepo.AddFrontier([new() { Id = "iv", ProjectId = ProjId }]).Result;
+ }).Wait();
+ _wordRepo.AddFrontier([new() { Id = "ii", ProjectId = ProjId }]).Wait();
+ _wordRepo.AddFrontier([new() { Id = "iv", ProjectId = ProjId }]).Wait();
Assert.That(_mergeService.HasGraylistEntries(ProjId, UserId).Result, Is.True);
}
@@ -461,22 +461,22 @@ public void HasGraylistEntriesTrueTest()
public void HasGraylistEntriesRemovesInvalidEntriesTest()
{
// Create graylist entries with fewer than 2 words in the Frontier.
- _ = _mergeGraylistRepo.Create(new() { Id = "A", ProjectId = ProjId, UserId = UserId });
- _ = _mergeGraylistRepo.Create(new()
+ _mergeGraylistRepo.Create(new() { Id = "A", ProjectId = ProjId, UserId = UserId }).Wait();
+ _mergeGraylistRepo.Create(new()
{
Id = "B",
ProjectId = ProjId,
UserId = UserId,
WordIds = ["i", "ii", "iii", "iv"]
- });
- _ = _mergeGraylistRepo.Create(new()
+ }).Wait();
+ _mergeGraylistRepo.Create(new()
{
Id = "C",
ProjectId = ProjId,
UserId = UserId,
WordIds = ["1", "2", "3"]
- });
- _ = _wordRepo.AddFrontier([new() { Id = "1", ProjectId = ProjId }]).Result;
+ }).Wait();
+ _wordRepo.AddFrontier([new() { Id = "1", ProjectId = ProjId }]).Wait();
// Check for graylist entries.
Assert.That(_mergeService.HasGraylistEntries(ProjId, UserId).Result, Is.False);
@@ -533,7 +533,7 @@ public async Task TestGetAndStorePotentialDuplicatesSecondCallWins()
// Delay first GetFrontier call
var delaySignal = new TaskCompletionSource();
- ((WordRepositoryMock)_wordRepo).SetGetFrontierDelay(delaySignal.Task);
+ _wordRepo.SetGetFrontierDelay(delaySignal.Task);
var firstCallTask = _mergeService.GetAndStorePotentialDuplicates(ProjId, 10, 10, userId);
// Give first call time to start
@@ -557,7 +557,7 @@ public async Task TestGetAndStorePotentialDuplicatesMultipleConcurrentUsers()
// Delay first GetFrontier call
var delaySignal = new TaskCompletionSource();
- ((WordRepositoryMock)_wordRepo).SetGetFrontierDelay(delaySignal.Task);
+ _wordRepo.SetGetFrontierDelay(delaySignal.Task);
var firstCallTask = _mergeService.GetAndStorePotentialDuplicates(ProjId, 10, 10, userId1);
// Give first call time to start
diff --git a/Backend.Tests/Services/StatisticsServiceTests.cs b/Backend.Tests/Services/StatisticsServiceTests.cs
index 789f884c2b..a49ee6df6e 100644
--- a/Backend.Tests/Services/StatisticsServiceTests.cs
+++ b/Backend.Tests/Services/StatisticsServiceTests.cs
@@ -13,7 +13,7 @@ internal sealed class StatisticsServiceTests
{
private ISemanticDomainRepository _domainRepo = null!;
private IUserRepository _userRepo = null!;
- private IWordRepository _wordRepo = null!;
+ private WordRepositoryMock _wordRepo = null!;
private IStatisticsService _statsService = null!;
private const string ProjId = "StatsServiceTestProjId";
diff --git a/Backend.Tests/Services/WordServiceTests.cs b/Backend.Tests/Services/WordServiceTests.cs
index bc92f060ad..ae6c9a3ed9 100644
--- a/Backend.Tests/Services/WordServiceTests.cs
+++ b/Backend.Tests/Services/WordServiceTests.cs
@@ -1,3 +1,4 @@
+using System;
using System.Linq;
using Backend.Tests.Mocks;
using BackendFramework.Interfaces;
@@ -9,7 +10,7 @@ namespace Backend.Tests.Services
{
internal sealed class WordServiceTests
{
- private IWordRepository _wordRepo = null!;
+ private WordRepositoryMock _wordRepo = null!;
private IWordService _wordService = null!;
private const string ProjId = "WordServiceTestProjId";
@@ -23,6 +24,35 @@ public void Setup()
_wordService = new WordService(_wordRepo);
}
+ [Test]
+ public void TestImportWordsDoesNotChangeTimestamps()
+ {
+ const string existingCreated = "existing-created";
+ const string existingModified = "existing-modified";
+
+ var importedWords = _wordService.ImportWords([
+ new Word { ProjectId = ProjId },
+ new Word { ProjectId = ProjId, Created = existingCreated, Modified = existingModified },
+ ]).Result;
+
+ Assert.That(importedWords, Has.Count.EqualTo(2));
+ Assert.That(importedWords[0].Created, Is.Not.Empty);
+ Assert.That(importedWords[0].Modified, Is.Not.Empty);
+ Assert.That(importedWords[1].Created, Is.EqualTo(existingCreated));
+ Assert.That(importedWords[1].Modified, Is.EqualTo(existingModified));
+ Assert.That(_wordRepo.GetAllFrontier(ProjId).Result, Has.Count.EqualTo(2));
+ }
+
+ [Test]
+ public void TestImportWordsEmptyInputReturnsEmptyAndDoesNotChangeRepo()
+ {
+ var importedWords = _wordService.ImportWords([]).Result;
+
+ Assert.That(importedWords, Is.Empty);
+ Assert.That(_wordRepo.GetAllFrontier(ProjId).Result, Is.Empty);
+ Assert.That(_wordRepo.GetAllWords(ProjId).Result, Is.Empty);
+ }
+
[Test]
public void TestCreateAddsUserId()
{
@@ -39,22 +69,38 @@ public void TestCreateDoesNotAddDuplicateUserId()
}
[Test]
- public void TestCreateMultipleWords()
+ public void TestCreateBlankUserIdDoesNotAppendEditedBy()
{
- _wordService.Create(UserId, [new() { ProjectId = ProjId }, new() { ProjectId = ProjId }]).Wait();
- Assert.That(_wordRepo.GetAllWords(ProjId).Result, Has.Count.EqualTo(2));
- Assert.That(_wordRepo.GetAllFrontier(ProjId).Result, Has.Count.EqualTo(2));
+ var word = _wordService.Create("", new Word { EditedBy = ["other"] }).Result;
+
+ Assert.That(word.EditedBy, Has.Count.EqualTo(1));
+ Assert.That(word.EditedBy.Last(), Is.EqualTo("other"));
+ }
+
+ [Test]
+ public void TestCreatePreservesCreatedAndUpdatesModified()
+ {
+ const string existingCreated = "existing-created";
+ const string existingModified = "existing-modified";
+
+ var createdWord = _wordService.Create(UserId,
+ new Word { ProjectId = ProjId, Created = existingCreated, Modified = existingModified }).Result;
+
+ Assert.That(createdWord.Created, Is.EqualTo(existingCreated));
+ Assert.That(createdWord.Modified, Is.Not.EqualTo(existingModified));
}
[Test]
- public void TestDeleteAudioBadInputReturnsNull()
+ public void TestDeleteAudioBadInput()
{
var fileName = "audio.mp3";
var wordInFrontier = _wordRepo.Create(
new Word() { Audio = [new() { FileName = fileName }], ProjectId = ProjId }).Result;
Assert.That(_wordService.DeleteAudio("non-proj-id", UserId, wordInFrontier.Id, fileName).Result, Is.Null);
Assert.That(_wordService.DeleteAudio(ProjId, UserId, "non-word-id", fileName).Result, Is.Null);
- Assert.That(_wordService.DeleteAudio(ProjId, UserId, wordInFrontier.Id, "non-file-name").Result, Is.Null);
+
+ var result = _wordService.DeleteAudio(ProjId, UserId, wordInFrontier.Id, "non-file-name").Result;
+ Assert.That(result, Is.Null);
}
[Test]
@@ -81,6 +127,7 @@ public void TestDeleteAudio()
Assert.That(newWord.Id, Is.Not.EqualTo(oldId));
Assert.That(newWord.Audio, Is.Empty);
Assert.That(newWord.EditedBy.Last(), Is.EqualTo(UserId));
+ Assert.That(newWord.History, Has.Count.EqualTo(1));
Assert.That(newWord.History.Last(), Is.EqualTo(oldId));
// New word is only one in frontier
@@ -128,6 +175,66 @@ public void TestDeleteFrontierWordCopiesToWordsAndRemovesFrontier()
Assert.That(_wordRepo.GetAllFrontier(ProjId).Result, Is.Empty);
}
+ [Test]
+ public void TestDeleteFrontierWordPreservesHistoryAndAppendsDeletedId()
+ {
+ var word = _wordRepo.Create(new Word { ProjectId = ProjId, History = ["older-1", "older-2"] }).Result;
+
+ var deletedId = _wordService.DeleteFrontierWord(ProjId, UserId, word.Id).Result;
+ Assert.That(deletedId, Is.Not.Null);
+ var deletedWord = _wordRepo.GetWord(ProjId, deletedId).Result;
+ var expectedHistoryPrefix = new[] { "older-1", "older-2" };
+
+ Assert.That(deletedWord, Is.Not.Null);
+ Assert.That(deletedWord.History, Has.Count.EqualTo(3));
+ Assert.That(deletedWord.History.Take(2), Is.EqualTo(expectedHistoryPrefix));
+ Assert.That(deletedWord.History.Last(), Is.EqualTo(word.Id));
+ }
+
+ [Test]
+ public void TestRestoreFrontierWordAlreadyInFrontierThrows()
+ {
+ var wordInFrontier = _wordRepo.Create(new Word { ProjectId = ProjId }).Result;
+ Assert.That(_wordRepo.GetAllFrontier(ProjId).Result, Has.Count.EqualTo(1));
+
+ var ex = Assert.Throws(
+ () => _wordService.RestoreFrontierWord(ProjId, wordInFrontier.Id).Wait());
+ Assert.That(ex?.InnerException, Is.InstanceOf());
+ }
+
+ [Test]
+ public void TestRestoreFrontierWordDeletedWordThrows()
+ {
+ var deletedWord = _wordRepo.Add(new Word { ProjectId = ProjId, Accessibility = Status.Deleted }).Result;
+
+ var ex = Assert.Throws(
+ () => _wordService.RestoreFrontierWord(ProjId, deletedWord.Id).Wait());
+ Assert.That(ex?.InnerException, Is.InstanceOf());
+ }
+
+ [Test]
+ public void TestRestoreFrontierWordMissingWordReturnsFalse()
+ {
+ _wordRepo.Add(new Word { ProjectId = ProjId }).Wait();
+
+ var result = _wordService.RestoreFrontierWord(ProjId, "NotAnId").Result;
+
+ Assert.That(result, Is.False);
+ Assert.That(_wordRepo.GetAllFrontier(ProjId).Result, Is.Empty);
+ }
+
+ [Test]
+ public void TestRestoreFrontierWordReturnsTrueRestoresWords()
+ {
+ var word = _wordRepo.Add(new Word { ProjectId = ProjId }).Result;
+ Assert.That(_wordRepo.GetAllFrontier(ProjId).Result, Is.Empty);
+
+ var result = _wordService.RestoreFrontierWord(ProjId, word.Id).Result;
+
+ Assert.That(result, Is.True);
+ Assert.That(_wordRepo.GetAllFrontier(ProjId).Result, Has.Count.EqualTo(1));
+ }
+
[Test]
public void TestUpdateNotInFrontierReturnsNull()
{
@@ -175,35 +282,16 @@ public void TestUpdateUsingCitationForm()
}
[Test]
- public void TestRestoreFrontierWordsMissingWordFalse()
+ public void TestUpdateDoesNotDuplicateExistingHistoryId()
{
- var word = _wordRepo.Add(new Word { ProjectId = ProjId }).Result;
-
- var restored = _wordService.RestoreFrontierWords(ProjId, ["NotAnId", word.Id]).Result;
- Assert.That(restored, Is.False);
- }
-
- [Test]
- public void TestRestoreFrontierWordsFrontierWordFalse()
- {
- var wordNoFrontier = _wordRepo.Add(new Word { ProjectId = ProjId }).Result;
- var wordYesFrontier = _wordRepo.Create(new Word { ProjectId = ProjId }).Result;
- Assert.That(_wordRepo.GetAllFrontier(ProjId).Result, Has.Count.EqualTo(1));
-
- var restored = _wordService.RestoreFrontierWords(ProjId, [wordNoFrontier.Id, wordYesFrontier.Id]).Result;
- Assert.That(restored, Is.False);
- }
+ var word = _wordRepo.Create(new Word { ProjectId = ProjId }).Result;
+ var oldId = word.Id;
+ word.History.Add(oldId);
- [Test]
- public void TestRestoreFrontierWordsReturnsTrue()
- {
- var word1 = _wordRepo.Add(new Word { ProjectId = ProjId }).Result;
- var word2 = _wordRepo.Add(new Word { ProjectId = ProjId }).Result;
- Assert.That(_wordRepo.GetAllFrontier(ProjId).Result, Is.Empty);
+ var updatedWord = _wordService.Update(UserId, word).Result;
- var restored = _wordService.RestoreFrontierWords(ProjId, [word1.Id, word2.Id]).Result;
- Assert.That(restored, Is.True);
- Assert.That(_wordRepo.GetAllFrontier(ProjId).Result, Has.Count.EqualTo(2));
+ Assert.That(updatedWord, Is.Not.Null);
+ Assert.That(updatedWord.History.Count(id => id == oldId), Is.EqualTo(1));
}
[Test]
@@ -291,5 +379,137 @@ public void TestFindContainingWordSameVernEmptySensesSameDoms()
var dupId = _wordService.FindContainingWord(newWord).Result;
Assert.That(dupId, Is.EqualTo(oldWord.Id));
}
+
+ [Test]
+ public void TestFindContainingWordIgnoresWordsNotInFrontier()
+ {
+ var oldWordInWordsOnly = Util.RandomWord(ProjId);
+ oldWordInWordsOnly = _wordRepo.Add(oldWordInWordsOnly).Result;
+
+ var newWord = Util.RandomWord(ProjId);
+ newWord.Vernacular = oldWordInWordsOnly.Vernacular;
+ newWord.Senses = oldWordInWordsOnly.Senses.Select(s => s.Clone()).ToList();
+
+ var dupId = _wordService.FindContainingWord(newWord).Result;
+
+ Assert.That(dupId, Is.Null);
+ }
+
+ [Test]
+ public void TestMergeReplaceFrontierUpdatesAndDeletes()
+ {
+ var childToReplace = _wordRepo.Create(Util.RandomWord(ProjId)).Result;
+ var childToDelete = _wordRepo.Create(Util.RandomWord(ProjId)).Result;
+ var parent = Util.RandomWord(ProjId);
+ parent.Id = childToReplace.Id;
+ parent.Vernacular = "merged-vern";
+
+ var mergedParents = _wordService
+ .MergeReplaceFrontier(ProjId, UserId, [parent], [childToReplace.Id, childToDelete.Id]).Result;
+
+ Assert.That(mergedParents, Is.Not.Null);
+ Assert.That(mergedParents, Has.Count.EqualTo(1));
+
+ var mergedParent = mergedParents.Single();
+ Assert.That(mergedParent.Id, Is.Not.EqualTo(childToReplace.Id));
+ Assert.That(mergedParent.History.Last(), Is.EqualTo(childToReplace.Id));
+ Assert.That(mergedParent.EditedBy.Last(), Is.EqualTo(UserId));
+
+ var frontier = _wordRepo.GetAllFrontier(ProjId).Result;
+ Assert.That(frontier, Has.Count.EqualTo(1));
+ Assert.That(frontier.Single().Id, Is.EqualTo(mergedParent.Id));
+
+ var deletedCopy = _wordRepo.GetAllWords(ProjId).Result
+ .Find(w => w.Accessibility == Status.Deleted && w.History.Contains(childToDelete.Id));
+ Assert.That(deletedCopy, Is.Not.Null);
+ Assert.That(deletedCopy.EditedBy.Last(), Is.EqualTo(UserId));
+ }
+
+ [Test]
+ public void TestMergeReplaceFrontierWrongProjectThrows()
+ {
+ var parent = Util.RandomWord("other-project");
+
+ var ex = Assert.Throws(
+ () => _wordService.MergeReplaceFrontier(ProjId, UserId, [parent], []).Wait());
+ Assert.That(ex?.InnerException, Is.InstanceOf());
+ }
+
+ [Test]
+ public void TestMergeReplaceFrontierDeleteOnlyReturnsEmpty()
+ {
+ var kid1 = _wordRepo.Create(new Word { ProjectId = ProjId }).Result;
+ var kid2 = _wordRepo.Create(new Word { ProjectId = ProjId }).Result;
+
+ var mergedParents = _wordService.MergeReplaceFrontier(ProjId, UserId, [], [kid1.Id, kid2.Id]).Result;
+
+ Assert.That(mergedParents, Is.Not.Null);
+ Assert.That(mergedParents, Is.Empty);
+ Assert.That(_wordRepo.GetAllFrontier(ProjId).Result, Is.Empty);
+
+ var allWords = _wordRepo.GetAllWords(ProjId).Result;
+ Assert.That(allWords.Any(w => w.Accessibility == Status.Deleted && w.History.Contains(kid1.Id)), Is.True);
+ Assert.That(allWords.Any(w => w.Accessibility == Status.Deleted && w.History.Contains(kid2.Id)), Is.True);
+ }
+
+ [Test]
+ public void TestRevertMergeReplaceFrontierDeletesAndRestores()
+ {
+ var wordToRestore = _wordRepo.Add(new Word { ProjectId = ProjId }).Result;
+ var frontierWordToDelete = _wordRepo.Create(new Word { ProjectId = ProjId }).Result;
+
+ var result = _wordService.RevertMergeReplaceFrontier(
+ ProjId, UserId, [wordToRestore.Id], [frontierWordToDelete.Id]).Result;
+
+ Assert.That(result, Is.True);
+ Assert.That(_wordRepo.IsInFrontier(ProjId, wordToRestore.Id).Result, Is.True);
+ Assert.That(_wordRepo.IsInFrontier(ProjId, frontierWordToDelete.Id).Result, Is.False);
+
+ var deletedCopy = _wordRepo.GetAllWords(ProjId).Result
+ .Find(w => w.Accessibility == Status.Deleted && w.History.Contains(frontierWordToDelete.Id));
+ Assert.That(deletedCopy, Is.Not.Null);
+ Assert.That(deletedCopy.EditedBy.Last(), Is.EqualTo(UserId));
+ }
+
+ [Test]
+ public void TestRevertMergeReplaceFrontierMissingRestoreReturnsFalse()
+ {
+ var frontierWordToDelete = _wordRepo.Create(new Word { ProjectId = ProjId }).Result;
+
+ var result = _wordService.RevertMergeReplaceFrontier(
+ ProjId, UserId, ["missing-id"], [frontierWordToDelete.Id]).Result;
+
+ Assert.That(result, Is.False);
+ Assert.That(_wordRepo.IsInFrontier(ProjId, frontierWordToDelete.Id).Result, Is.True);
+ }
+
+ [Test]
+ public void TestRevertMergeReplaceFrontierOverlappingIdsThrows()
+ {
+ var word = _wordRepo.Create(new Word { ProjectId = ProjId }).Result;
+
+ var ex = Assert.Throws(
+ () => _wordService.RevertMergeReplaceFrontier(ProjId, UserId, [word.Id], [word.Id]).Wait());
+ Assert.That(ex?.InnerException, Is.InstanceOf());
+ }
+
+ [Test]
+ public void TestRevertMergeReplaceFrontierNoOpReturnsTrueAndLeavesStateUnchanged()
+ {
+ _wordRepo.Create(new Word { ProjectId = ProjId }).Wait();
+ _wordRepo.Add(new Word { ProjectId = ProjId }).Wait();
+
+ var wordsBefore = _wordRepo.GetAllWords(ProjId).Result.Select(w => w.Id).OrderBy(id => id).ToList();
+ var frontierBefore = _wordRepo.GetAllFrontier(ProjId).Result.Select(w => w.Id).OrderBy(id => id).ToList();
+
+ var result = _wordService.RevertMergeReplaceFrontier(ProjId, UserId, [], []).Result;
+
+ var wordsAfter = _wordRepo.GetAllWords(ProjId).Result.Select(w => w.Id).OrderBy(id => id).ToList();
+ var frontierAfter = _wordRepo.GetAllFrontier(ProjId).Result.Select(w => w.Id).OrderBy(id => id).ToList();
+
+ Assert.That(result, Is.True);
+ Assert.That(wordsAfter, Is.EqualTo(wordsBefore));
+ Assert.That(frontierAfter, Is.EqualTo(frontierBefore));
+ }
}
}
diff --git a/Backend/Contexts/MongoDbContext.cs b/Backend/Contexts/MongoDbContext.cs
index 7329b662b5..1046e17c7c 100644
--- a/Backend/Contexts/MongoDbContext.cs
+++ b/Backend/Contexts/MongoDbContext.cs
@@ -1,28 +1,116 @@
using System;
using System.Diagnostics.CodeAnalysis;
+using System.Threading.Tasks;
using BackendFramework.Interfaces;
using Microsoft.Extensions.Options;
using MongoDB.Driver;
namespace BackendFramework.Contexts
{
+ ///
+ /// MongoDB context for accessing the configured database and executing transactional operations.
+ ///
[ExcludeFromCodeCoverage]
public class MongoDbContext : IMongoDbContext
{
- private MongoClient _mongoClient { get; }
-
+ ///
+ /// Gets the configured MongoDB database instance.
+ ///
public IMongoDatabase Db { get; }
+ ///
+ /// Creates a new from application settings.
+ ///
+ /// Options containing the Mongo connection string and database name.
public MongoDbContext(IOptions options)
{
- _mongoClient = new MongoClient(options.Value.ConnectionString);
- Db = _mongoClient.GetDatabase(options.Value.CombineDatabase);
+ var client = new MongoClient(options.Value.ConnectionString);
+ Db = client.GetDatabase(options.Value.CombineDatabase);
+ }
+
+ ///
+ /// Begins a MongoDB transaction and returns a disposable transaction wrapper.
+ ///
+ /// A transaction wrapper containing the active client session.
+ public async Task BeginTransaction()
+ {
+ var session = await Db.Client.StartSessionAsync();
+ try
+ {
+ session.StartTransaction();
+ return new MongoTransactionWrapper(session);
+ }
+ catch
+ {
+ session.Dispose();
+ throw;
+ }
}
- public void Dispose()
+ ///
+ /// Executes an operation in a transaction, committing on success and aborting on exception.
+ ///
+ /// The operation result type.
+ /// Operation to execute with the transaction session.
+ /// The operation result.
+ public async Task ExecuteInTransaction(Func> operation)
{
- _mongoClient.Dispose();
- GC.SuppressFinalize(this);
+ using var transaction = await BeginTransaction();
+ try
+ {
+ var result = await operation(transaction.Session);
+ await transaction.CommitTransactionAsync();
+ return result;
+ }
+ catch
+ {
+ await transaction.AbortTransactionAsync();
+ throw;
+ }
+ }
+
+ ///
+ /// Executes an operation in a transaction, committing when a non-null result is returned.
+ /// Null represents an operation that could complete and shouldn't be committed, so it aborts.
+ ///
+ /// The operation result type.
+ /// Operation to execute with the transaction session.
+ ///
+ /// The operation result when non-null; otherwise after aborting the transaction.
+ ///
+ public async Task ExecuteInTransactionAllowNull(Func> operation)
+ {
+ using var transaction = await BeginTransaction();
+ try
+ {
+ var result = await operation(transaction.Session);
+ if (result is null)
+ {
+ await transaction.AbortTransactionAsync();
+ return default;
+ }
+
+ await transaction.CommitTransactionAsync();
+ return result;
+ }
+ catch
+ {
+ await transaction.AbortTransactionAsync();
+ throw;
+ }
+ }
+
+ private class MongoTransactionWrapper(IClientSessionHandle session) : IMongoTransaction
+ {
+ private readonly IClientSessionHandle _session = session;
+
+ public IClientSessionHandle Session => _session;
+
+ public Task CommitTransactionAsync() => _session.CommitTransactionAsync();
+
+ public Task AbortTransactionAsync() => _session.AbortTransactionAsync();
+
+ public void Dispose() => _session.Dispose();
}
}
}
diff --git a/Backend/Controllers/LiftController.cs b/Backend/Controllers/LiftController.cs
index 34ea2734e4..4235c5b953 100644
--- a/Backend/Controllers/LiftController.cs
+++ b/Backend/Controllers/LiftController.cs
@@ -23,7 +23,8 @@ namespace BackendFramework.Controllers
[Produces("application/json")]
[Route("v1/projects/{projectId}/lift")]
public class LiftController(IProjectRepository projRepo, ISemanticDomainRepository semDomRepo,
- ISpeakerRepository speakerRepo, IWordRepository wordRepo, IAcknowledgmentService ackService,
+ ISpeakerRepository speakerRepo, IWordRepository wordRepo, IWordService wordService,
+ IAcknowledgmentService ackService,
ILiftService liftService, IHubContext notifyService, IPermissionService permissionService,
ILogger logger) : Controller
{
@@ -31,6 +32,7 @@ public class LiftController(IProjectRepository projRepo, ISemanticDomainReposito
private readonly ISemanticDomainRepository _semDomRepo = semDomRepo;
private readonly ISpeakerRepository _speakerRepo = speakerRepo;
private readonly IWordRepository _wordRepo = wordRepo;
+ private readonly IWordService _wordService = wordService;
private readonly IAcknowledgmentService _ackService = ackService;
private readonly ILiftService _liftService = liftService;
private readonly IHubContext _notifyService = notifyService;
@@ -263,7 +265,7 @@ private async Task AddImportToProject(string liftStoragePath, str
int countWordsImported;
// Sets the projectId of our parser to add words to that project
var liftMerger = _liftService.GetLiftImporterExporter(
- projectId, proj.VernacularWritingSystem.Bcp47, _wordRepo);
+ projectId, proj.VernacularWritingSystem.Bcp47, _wordService);
var importedAnalysisWritingSystems = new List();
var doesImportHaveDefinitions = false;
var doesImportHaveGrammaticalInfo = false;
diff --git a/Backend/Controllers/MergeController.cs b/Backend/Controllers/MergeController.cs
index fb875e1432..74e7f00091 100644
--- a/Backend/Controllers/MergeController.cs
+++ b/Backend/Controllers/MergeController.cs
@@ -56,10 +56,11 @@ public async Task MergeWords(
}
/// Undo merge
- /// True if merge was successfully undone
+ /// Ok if merge was successfully undone
[HttpPut("undo", Name = "UndoMerge")]
- [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(bool))]
+ [ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task UndoMerge(string projectId, [FromBody, BindRequired] MergeUndoIds merge)
{
using var activity = OtelService.StartActivityWithTag(otelTagName, "undoing merge");
@@ -71,8 +72,7 @@ public async Task UndoMerge(string projectId, [FromBody, BindRequ
}
var userId = _permissionService.GetUserId(HttpContext);
- var undo = await _mergeService.UndoMerge(projectId, userId, merge);
- return Ok(undo);
+ return await _mergeService.UndoMerge(projectId, userId, merge) ? Ok() : NotFound();
}
/// Add List of Ids to merge blacklist
diff --git a/Backend/Controllers/WordController.cs b/Backend/Controllers/WordController.cs
index f0b37b7796..9623d69e62 100644
--- a/Backend/Controllers/WordController.cs
+++ b/Backend/Controllers/WordController.cs
@@ -248,9 +248,9 @@ public async Task UpdateWord(
}
/// Restore a deleted .
- /// bool: true if restored; false if already in frontier.
+ /// Ok if the word successfully restored.
[HttpGet("restore/{wordId}", Name = "RestoreWord")]
- [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(bool))]
+ [ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task RestoreWord(string projectId, string wordId)
@@ -261,12 +261,8 @@ public async Task RestoreWord(string projectId, string wordId)
{
return Forbid();
}
- if (await _wordRepo.GetWord(projectId, wordId) is null)
- {
- return NotFound();
- }
- return Ok(await _wordService.RestoreFrontierWords(projectId, [wordId]));
+ return await _wordService.RestoreFrontierWord(projectId, wordId) ? Ok() : NotFound();
}
/// Revert words from a dictionary of word ids (key: to revert to; value: from frontier).
diff --git a/Backend/Interfaces/ILiftService.cs b/Backend/Interfaces/ILiftService.cs
index 3328b0c80d..413f99d2e1 100644
--- a/Backend/Interfaces/ILiftService.cs
+++ b/Backend/Interfaces/ILiftService.cs
@@ -8,7 +8,7 @@ namespace BackendFramework.Interfaces
{
public interface ILiftService : IDisposable
{
- ILiftMerger GetLiftImporterExporter(string projectId, string vernLang, IWordRepository wordRepo);
+ ILiftMerger GetLiftImporterExporter(string projectId, string vernLang, IWordService wordService);
Task LdmlImport(string dirPath, IProjectRepository projRepo, Project project);
Task LiftExport(string projectId, IProjectRepository projRepo, ISemanticDomainRepository semDomRepo,
ISpeakerRepository speakerRepo, IWordRepository wordRepo);
diff --git a/Backend/Interfaces/IMongoDbContext.cs b/Backend/Interfaces/IMongoDbContext.cs
index afd45d6a66..1cc9949215 100644
--- a/Backend/Interfaces/IMongoDbContext.cs
+++ b/Backend/Interfaces/IMongoDbContext.cs
@@ -1,10 +1,52 @@
using System;
+using System.Threading.Tasks;
using MongoDB.Driver;
namespace BackendFramework.Interfaces
{
- public interface IMongoDbContext : IDisposable
+ ///
+ /// Abstraction over MongoDB database access and transaction execution.
+ ///
+ public interface IMongoDbContext
{
+ ///
+ /// Gets the configured MongoDB database instance.
+ ///
IMongoDatabase Db { get; }
+
+ ///
+ /// Begins a new transaction and returns the transaction wrapper.
+ ///
+ /// A transaction wrapper containing the active client session.
+ Task BeginTransaction();
+
+ ///
+ /// Executes an operation in a transaction, committing on success and aborting on exception.
+ ///
+ /// The operation result type.
+ /// Operation to execute with the transaction session.
+ /// The operation result.
+ Task ExecuteInTransaction(Func> operation);
+
+ ///
+ /// Executes an operation in a transaction, committing only when a non-null result is returned.
+ /// Null represents an operation that could complete and shouldn't be committed, so it aborts.
+ ///
+ /// The operation result type.
+ /// Operation to execute with the transaction session.
+ ///
+ /// The operation result when non-null; otherwise after aborting the transaction.
+ ///
+ Task ExecuteInTransactionAllowNull(Func> operation);
+ }
+
+ ///
+ /// Represents a MongoDB transaction wrapper that exposes the active session and transaction controls.
+ ///
+ public interface IMongoTransaction : IDisposable
+ {
+ IClientSessionHandle Session { get; }
+ Task CommitTransactionAsync();
+ Task AbortTransactionAsync();
}
}
diff --git a/Backend/Interfaces/IWordRepository.cs b/Backend/Interfaces/IWordRepository.cs
index 1aabed7bbd..a38531dc08 100644
--- a/Backend/Interfaces/IWordRepository.cs
+++ b/Backend/Interfaces/IWordRepository.cs
@@ -1,3 +1,4 @@
+using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using BackendFramework.Models;
@@ -8,11 +9,8 @@ public interface IWordRepository
{
Task> GetAllWords(string projectId);
Task GetWord(string projectId, string wordId);
- Task> GetWords(string projectId, List wordIds);
Task Create(Word word);
Task> Create(List words);
- Task Add(Word word);
- Task DeleteAllWords(string projectId);
Task DeleteAllFrontierWords(string projectId);
Task HasWords(string projectId);
Task HasFrontierWords(string projectId);
@@ -22,9 +20,14 @@ public interface IWordRepository
Task> GetAllFrontier(string projectId);
Task GetFrontier(string projectId, string wordId, string? audioFileName = null);
Task> GetFrontierWithVernacular(string projectId, string vernacular);
- Task AddFrontier(Word word);
- Task> AddFrontier(List words);
- Task DeleteFrontier(string projectId, string wordId, string? audioFileName = null);
+ Task DeleteFrontier(string projectId, string wordId, Action modifyDeletedWord);
+ Task RestoreFrontier(string projectId, string wordId);
+ Task UpdateFrontier(string projectId, string wordId, Action modifyUpdatedWord);
+ Task UpdateFrontier(Word word, Action modifyUpdatedWord);
+ Task> ReplaceFrontier(string projectId, List newWords, List idsToDelete,
+ Action modifyUpdatedWord, Action modifyDeletedWord);
+ Task RevertReplaceFrontier(string projectId, List idsToRestore, List idsToDelete,
+ Action modifyDeletedWord);
Task CountFrontierWordsWithDomain(string projectId, string domainId);
}
}
diff --git a/Backend/Interfaces/IWordService.cs b/Backend/Interfaces/IWordService.cs
index ad878d63a7..ba0d3bb42b 100644
--- a/Backend/Interfaces/IWordService.cs
+++ b/Backend/Interfaces/IWordService.cs
@@ -6,12 +6,16 @@ namespace BackendFramework.Interfaces
{
public interface IWordService
{
+ Task> ImportWords(List words);
Task Create(string userId, Word word);
- Task> Create(string userId, List words);
- Task Update(string userId, Word word);
Task DeleteAudio(string projectId, string userId, string wordId, string fileName);
Task DeleteFrontierWord(string projectId, string userId, string wordId);
- Task RestoreFrontierWords(string projectId, List wordIds);
+ Task RestoreFrontierWord(string projectId, string wordId);
+ Task Update(string userId, Word word);
Task FindContainingWord(Word word);
+ Task> MergeReplaceFrontier(
+ string projectId, string userId, List parents, List idsToDelete);
+ Task RevertMergeReplaceFrontier(
+ string projectId, string userId, List idsToRestore, List idsToDelete);
}
}
diff --git a/Backend/Repositories/WordRepository.cs b/Backend/Repositories/WordRepository.cs
index 1492677079..0365fdb851 100644
--- a/Backend/Repositories/WordRepository.cs
+++ b/Backend/Repositories/WordRepository.cs
@@ -1,9 +1,7 @@
using System;
using System.Collections.Generic;
-using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Threading.Tasks;
-using BackendFramework.Helper;
using BackendFramework.Interfaces;
using BackendFramework.Models;
using BackendFramework.Otel;
@@ -12,18 +10,23 @@
namespace BackendFramework.Repositories
{
/// Atomic database functions for s.
- [ExcludeFromCodeCoverage]
public class WordRepository(IMongoDbContext dbContext) : IWordRepository
{
+ private readonly IMongoDbContext _dbContext = dbContext;
private readonly IMongoCollection _frontier = dbContext.Db.GetCollection("FrontierCollection");
private readonly IMongoCollection _words = dbContext.Db.GetCollection("WordsCollection");
private const string otelTagName = "otel.WordRepository";
+ #region Private get-filter helper methods
+
///
/// Creates a mongo filter for all words in a specified project (and optionally with specified vernacular).
/// Since a variant in FieldWorks can export as an entry without any senses, filters out 0-sense words.
///
+ /// Id of the project to query.
+ /// Optional vernacular to filter by.
+ /// A filter matching words in the project that have at least one sense.
private static FilterDefinition GetAllProjectWordsFilter(string projectId, string? vernacular = null)
{
var filterDef = new FilterDefinitionBuilder();
@@ -34,6 +37,9 @@ private static FilterDefinition GetAllProjectWordsFilter(string projectId,
}
/// Creates a mongo filter for words in a specified project with specified wordId.
+ /// Id of the project to query.
+ /// Id of the word to match.
+ /// A filter matching the requested project and word id.
private static FilterDefinition GetProjectWordFilter(string projectId, string wordId)
{
var filterDef = new FilterDefinitionBuilder();
@@ -41,6 +47,10 @@ private static FilterDefinition GetProjectWordFilter(string projectId, str
}
/// Creates a mongo filter for project words with specified wordId and audio.
+ /// Id of the project to query.
+ /// Id of the word to match.
+ /// Audio file name that must exist on the word.
+ /// A filter matching the requested project, word id, and audio file.
private static FilterDefinition GetProjectWordWithAudioFilter(
string projectId, string wordId, string fileName)
{
@@ -50,13 +60,22 @@ private static FilterDefinition GetProjectWordWithAudioFilter(
}
/// Creates a mongo filter for words in a specified project with specified wordIds.
- private static FilterDefinition GetProjectWordsFilter(string projectId, List wordIds)
+ /// Id of the project to query.
+ /// Ids of words to match.
+ /// A filter matching the requested project and any of the provided word ids.
+ private static FilterDefinition GetProjectWordsFilter(string projectId, IEnumerable wordIds)
{
var filterDef = new FilterDefinitionBuilder();
return filterDef.And(filterDef.Eq(w => w.ProjectId, projectId), filterDef.In(w => w.Id, wordIds));
}
+ #endregion
+
+ #region Public repository methods
+
/// Finds all s with specified projectId
+ /// Id of the project to query.
+ /// All project words with at least one sense.
public async Task> GetAllWords(string projectId)
{
using var activity = OtelService.StartActivityWithTag(otelTagName, "getting all words");
@@ -65,6 +84,9 @@ public async Task> GetAllWords(string projectId)
}
/// Finds with specified wordId and projectId
+ /// Id of the project containing the word.
+ /// Id of the word to retrieve.
+ /// The matching word, or null if not found.
public async Task GetWord(string projectId, string wordId)
{
using var activity = OtelService.StartActivityWithTag(otelTagName, "getting a word");
@@ -80,33 +102,35 @@ public async Task> GetAllWords(string projectId)
}
}
- /// Finds project s with specified ids
- public async Task> GetWords(string projectId, List wordIds)
+ /// Adds a to the WordsCollection and Frontier
+ /// Clears Id to be generated by the database.
+ /// The word to add.
+ /// The word created
+ public async Task Create(Word word)
{
- using var activity = OtelService.StartActivityWithTag(otelTagName, "getting words");
+ using var activity =
+ OtelService.StartActivityWithTag(otelTagName, "creating a word in WordsCollection and Frontier");
- return await _words.Find(GetProjectWordsFilter(projectId, wordIds)).ToListAsync();
+ return (await Create([word])).First();
}
- ///
- /// Removes all s from the WordsCollection and Frontier for specified
- ///
- /// A bool: success of operation
- public async Task DeleteAllWords(string projectId)
+ /// Adds s to the WordsCollection and Frontier
+ /// Clears Ids to be generated by the database.
+ /// Words to add.
+ /// The words created
+ public async Task> Create(List words)
{
using var activity =
- OtelService.StartActivityWithTag(otelTagName, "deleting all words from WordsCollection and Frontier");
-
- var filterDef = new FilterDefinitionBuilder();
- var filter = filterDef.Eq(x => x.ProjectId, projectId);
+ OtelService.StartActivityWithTag(otelTagName, "creating words in WordsCollection and Frontier");
- var deleted = await _words.DeleteManyAsync(filter);
- await _frontier.DeleteManyAsync(filter);
- return deleted.DeletedCount != 0;
+ return words.Count == 0
+ ? words
+ : await _dbContext.ExecuteInTransaction(async s => await CreateWithSession(s, words));
}
/// Removes all s from the Frontier for specified
- /// A bool: success of operation
+ /// Id of the project whose Frontier words should be removed.
+ /// True if at least one Frontier word was deleted; otherwise false.
public async Task DeleteAllFrontierWords(string projectId)
{
using var activity = OtelService.StartActivityWithTag(otelTagName, "deleting all words from Frontier");
@@ -118,79 +142,9 @@ public async Task DeleteAllFrontierWords(string projectId)
return deleted.DeletedCount != 0;
}
- ///
- /// If the Created or Modified times are blank, fill them in the current time.
- ///
- private static void PopulateBlankWordTimes(Word word)
- {
- if (string.IsNullOrEmpty(word.Created))
- {
- // Use Roundtrip-suitable ISO 8601 format.
- word.Created = Time.UtcNowIso8601();
- }
- if (string.IsNullOrEmpty(word.Modified))
- {
- word.Modified = Time.UtcNowIso8601();
- }
- }
-
- /// Adds a to the WordsCollection and Frontier
- ///
- /// If the Created or Modified time fields are blank, they will automatically calculated using the current
- /// time. This allows services to set or clear the values before creation to control these fields.
- ///
- /// The word created
- public async Task Create(Word word)
- {
- using var activity =
- OtelService.StartActivityWithTag(otelTagName, "creating a word in WordsCollection and Frontier");
-
- PopulateBlankWordTimes(word);
- await _words.InsertOneAsync(word);
- await AddFrontier(word);
- return word;
- }
-
- /// Adds a list of s to the WordsCollection and Frontier
- ///
- /// If the Created or Modified time fields are blank, they will automatically calculated using the current
- /// time. This allows services to set or clear the values before creation to control these fields.
- ///
- /// The words created
- public async Task> Create(List words)
- {
- using var activity =
- OtelService.StartActivityWithTag(otelTagName, "creating words in WordsCollection and Frontier");
-
- if (words.Count == 0)
- {
- return words;
- }
- foreach (var w in words)
- {
- PopulateBlankWordTimes(w);
- }
- await _words.InsertManyAsync(words);
- await AddFrontier(words);
- return words;
- }
-
- /// Adds a only to the WordsCollection
- ///
- /// If the Created or Modified time fields are blank, they will automatically calculated using the current
- /// time. This allows services to set or clear the values before creation to control these fields.
- ///
- /// The word created
- public async Task Add(Word word)
- {
- using var activity = OtelService.StartActivityWithTag(otelTagName, "adding a word to WordsCollection");
-
- PopulateBlankWordTimes(word);
- await _words.InsertOneAsync(word);
- return word;
- }
-
/// Checks if Words collection for specified has any words.
+ /// Id of the project to check.
+ /// True when at least one word exists in WordsCollection for the project.
public async Task HasWords(string projectId)
{
using var activity =
@@ -200,6 +154,8 @@ public async Task HasWords(string projectId)
}
/// Checks if Frontier for specified has any words.
+ /// Id of the project to check.
+ /// True when at least one word exists in Frontier for the project.
public async Task HasFrontierWords(string projectId)
{
using var activity = OtelService.StartActivityWithTag(otelTagName, "checking if Frontier has words");
@@ -208,26 +164,44 @@ public async Task HasFrontierWords(string projectId)
}
/// Checks if specified word is in Frontier for specified
+ /// Id of the project to check.
+ /// Id of the word to check.
+ /// True if the word is currently in Frontier; otherwise false.
public async Task IsInFrontier(string projectId, string wordId)
{
using var activity = OtelService.StartActivityWithTag(otelTagName, "checking if Frontier contains a word");
- return (await _frontier.CountDocumentsAsync(GetProjectWordFilter(projectId, wordId))) > 0;
+ return await AreInFrontier(projectId, [wordId], 1);
}
/// Checks if given words are in the project Frontier.
- /// Id of project to check in.
- /// Ids of words to check for.
- /// Minimum number of words required.
+ /// Id of project to check in.
+ /// Ids of words to check for.
+ /// Minimum number of words required.
+ ///
+ /// True if at least of the specified ids are in Frontier; otherwise false.
+ ///
public async Task AreInFrontier(string projectId, List wordIds, int count)
{
using var activity = OtelService.StartActivityWithTag(otelTagName, "checking if Frontier contains words");
+ if (count <= 0)
+ {
+ return true;
+ }
+
+ if (wordIds.Count < count)
+ {
+ return false;
+ }
+
return await _frontier
.CountDocumentsAsync(GetProjectWordsFilter(projectId, wordIds), new() { Limit = count }) == count;
}
/// Gets number of s in the Frontier for specified
+ /// Id of the project to query.
+ /// The number of Frontier words in the project.
public async Task GetFrontierCount(string projectId)
{
using var activity = OtelService.StartActivityWithTag(otelTagName, "getting count of Frontier");
@@ -236,6 +210,8 @@ public async Task GetFrontierCount(string projectId)
}
/// Finds all s in the Frontier for specified
+ /// Id of the project to query.
+ /// All Frontier words for the project with at least one sense.
public async Task> GetAllFrontier(string projectId)
{
using var activity = OtelService.StartActivityWithTag(otelTagName, "getting all Frontier words");
@@ -244,7 +220,10 @@ public async Task> GetAllFrontier(string projectId)
}
/// Gets a specified from the Frontier
- /// The word, or null if not found.
+ /// Id of the project containing the word.
+ /// Id of the word to retrieve.
+ /// Optional audio filename that must exist on the word when provided.
+ /// The word, or null if not found.
public async Task GetFrontier(string projectId, string wordId, string? audioFileName = null)
{
using var activity = OtelService.StartActivityWithTag(otelTagName, "getting a word from Frontier");
@@ -255,7 +234,10 @@ public async Task> GetAllFrontier(string projectId)
.FirstOrDefaultAsync();
}
- /// Finds all s in Frontier of specified project with specified vern
+ /// Finds all s in project Frontier with specified vernacular
+ /// Id of the project to query.
+ /// Vernacular value to match.
+ /// All Frontier words in the project that match the vernacular and have at least one sense.
public async Task> GetFrontierWithVernacular(string projectId, string vernacular)
{
using var activity =
@@ -264,46 +246,131 @@ public async Task> GetFrontierWithVernacular(string projectId, string
return await _frontier.Find(GetAllProjectWordsFilter(projectId, vernacular)).ToListAsync();
}
- /// Adds a only to the Frontier
- ///
- /// The word created
- public async Task AddFrontier(Word word)
+ ///
+ /// Removes a from the Frontier, modifies it, and adds it to the WordsCollection.
+ ///
+ /// Id is cleared before it is added to WordsCollection.
+ /// Id of the project containing the Frontier word.
+ /// Id of the Frontier word to remove.
+ ///
+ /// Action that modifies the removed Frontier word before it is added to WordsCollection.
+ ///
+ ///
+ /// The modified word added to WordsCollection, or null if no matching Frontier word was found to remove.
+ ///
+ public async Task DeleteFrontier(string projectId, string wordId, Action modifyDeletedWord)
{
- using var activity = OtelService.StartActivityWithTag(otelTagName, "adding a word to Frontier");
+ using var activity = OtelService.StartActivityWithTag(
+ otelTagName, "adding word to WordsCollection, deleting word from Frontier");
- await _frontier.InsertOneAsync(word);
- return word;
+ return await _dbContext.ExecuteInTransactionAllowNull(
+ async s => await DeleteFrontierWithSession(s, projectId, wordId, modifyDeletedWord)
+ );
}
- /// Adds a list of s only to the Frontier
- ///
- /// The words created
- public async Task> AddFrontier(List words)
+ /// Restores a non-Frontier word to the Frontier
+ /// Id of the project containing the word.
+ /// Id of the word to restore.
+ /// True if the word was restored; false if it was not found.
+ ///
+ /// Thrown when the word has Deleted status or when its Id already exists in Frontier.
+ ///
+ public async Task RestoreFrontier(string projectId, string wordId)
{
- using var activity = OtelService.StartActivityWithTag(otelTagName, "adding words to Frontier");
+ using var activity = OtelService.StartActivityWithTag(otelTagName, "restoring word to Frontier");
- await _frontier.InsertManyAsync(words);
- return words;
+ return await _dbContext.ExecuteInTransaction(
+ async s => await RestoreFrontierWithSession(s, projectId, wordId));
}
- /// Removes from the Frontier with specified wordId and projectId
- /// The deleted word, or null if not found.
- public async Task DeleteFrontier(string projectId, string wordId, string? audioFileName = null)
+ ///
+ /// Replaces a Frontier by deleting it from Frontier, applying a modification, and
+ /// creating the updated copy in both collections.
+ ///
+ /// Id of the project containing the Frontier word.
+ /// Id of the Frontier word to update.
+ ///
+ /// Action that mutates the cloned Frontier word before it is re-created.
+ ///
+ /// The updated word, or null if no matching Frontier word exists.
+ public async Task UpdateFrontier(string projectId, string wordId, Action modifyUpdatedWord)
{
- using var activity = OtelService.StartActivityWithTag(otelTagName, "deleting a word from Frontier");
+ using var activity = OtelService.StartActivityWithTag(
+ otelTagName, "updating a word in WordsCollection and Frontier, deleting old word from Frontier");
- return string.IsNullOrEmpty(audioFileName)
- ? await _frontier.FindOneAndDeleteAsync(GetProjectWordFilter(projectId, wordId))
- : await _frontier.FindOneAndDeleteAsync(
- GetProjectWordWithAudioFilter(projectId, wordId, audioFileName));
+ return await _dbContext.ExecuteInTransactionAllowNull(
+ async s => await UpdateFrontierWithSession(s, projectId, wordId, modifyUpdatedWord));
+ }
+
+ ///
+ /// Replaces a Frontier with an updated copy in both collections.
+ ///
+ ///
+ /// Removes the existing Frontier word identified by 's Id and ProjectId, modifies the
+ /// provided word based on the removed word using , clears the ID,
+ /// and adds the modified word to WordsCollection and Frontier.
+ ///
+ /// Updated word. Its Id and ProjectId identify the Frontier word to replace.
+ /// Action to modify the new word based on the old word.
+ /// The updated word added to both collections, or null if no Frontier word was found.
+ public async Task UpdateFrontier(Word word, Action modifyUpdatedWord)
+ {
+ using var activity = OtelService.StartActivityWithTag(
+ otelTagName, "creating word in WordsCollection and Frontier, deleting old word from Frontier");
+
+ return await _dbContext.ExecuteInTransactionAllowNull(
+ async s => await UpdateFrontierWithSession(s, word, createIfNotFound: false, modifyUpdatedWord));
+ }
+
+ ///
+ /// Replaces and/or deletes Frontier words in a single transaction.
+ ///
+ /// Id of the project containing the Frontier words.
+ /// Words that replace existing Frontier words.
+ /// Ids of Frontier words to delete without replacement.
+ /// Action applied when building each replacement word.
+ ///
+ /// Action applied to words deleted from Frontier before inserting into WordsCollection.
+ ///
+ /// The replacement words when successful, an empty list if no work is needed.
+ ///
+ /// Thrown when an old word id doesn't exist in Frontier or a replacement word has a different project id.
+ ///
+ public async Task> ReplaceFrontier(string projectId, List newWords,
+ List idsToDelete, Action modifyUpdatedWord, Action modifyDeletedWord)
+ {
+ return (newWords.Count == 0 && idsToDelete.Count == 0)
+ ? newWords
+ : await _dbContext.ExecuteInTransaction(async s => await ReplaceFrontierWithSession(
+ s, projectId, newWords, idsToDelete, modifyUpdatedWord, modifyDeletedWord));
+ }
+
+ ///
+ /// Reverts a previous frontier replacement by deleting added words and restoring removed words.
+ ///
+ /// Id of the project containing the Frontier words.
+ /// Ids of WordsCollection words to restore to Frontier.
+ /// Ids of Frontier words to delete.
+ ///
+ /// Action applied before deleted Frontier words are added to WordsCollection.
+ ///
+ /// True when all requested restores succeed; otherwise false.
+ /// Thrown when ids to restore and delete are not disjoint.
+ public async Task RevertReplaceFrontier(
+ string projectId, List idsToRestore, List idsToDelete, Action modifyDeletedWord)
+ {
+ return idsToRestore.Count == 0 && idsToDelete.Count == 0
+ ? true
+ : await _dbContext.ExecuteInTransactionAllowNull(async s => await RevertReplaceFrontierWithSession(
+ s, projectId, idsToRestore, idsToDelete, modifyDeletedWord)) ?? false;
}
///
/// Counts the number of Frontier words that have the specified semantic domain.
///
- /// The project id
- /// The semantic domain id
- /// The count of words containing at least one sense with the specified domain.
+ /// The project id
+ /// The semantic domain id
+ /// The count of words containing at least one sense with the specified domain.
public async Task CountFrontierWordsWithDomain(string projectId, string domainId)
{
using var activity = OtelService.StartActivityWithTag(otelTagName, "counting frontier words with domain");
@@ -315,5 +382,223 @@ public async Task