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 CountFrontierWordsWithDomain(string projectId, string dom return (int)await _frontier.CountDocumentsAsync(filter); } + + #endregion + + #region Private with-session helper methods + + /// + /// Adds words to both WordsCollection and Frontier inside an existing transaction session. + /// + /// Each word's Id is cleared so MongoDB generates a new id. + /// Mongo transaction session. + /// Words to add. + /// The inserted words. + private async Task> CreateWithSession(IClientSessionHandle session, List words) + { + if (words.Count == 0) + { + return words; + } + + words.ForEach(w => w.Id = ""); + // Don't clone, but insert the same instance in both collections. + // The first collection insert will generate the id, which should match in the second collection. + await _words.InsertManyAsync(session, words); + await _frontier.InsertManyAsync(session, words); + return words; + } + + /// + /// Deletes a Frontier word, modifies it, and inserts the modified copy into WordsCollection. + /// + /// Mongo transaction session. + /// Id of the project containing the Frontier word. + /// Id of the Frontier word to delete. + /// + /// Action applied before the deleted word is inserted into WordsCollection. + /// + /// The modified word inserted into WordsCollection, or null if no Frontier word was found. + private async Task DeleteFrontierWithSession(IClientSessionHandle session, + string projectId, string wordId, Action modifyDeletedWord) + { + var deletedWord = await _frontier.FindOneAndDeleteAsync(session, GetProjectWordFilter(projectId, wordId)); + if (deletedWord is null) + { + return null; + } + + var modifiedWord = deletedWord.Clone(); + modifyDeletedWord(modifiedWord); + modifiedWord.Id = ""; + await _words.InsertOneAsync(session, modifiedWord); + return modifiedWord; + } + + /// Restores non-Frontier words to the Frontier + /// Throws if the found word is marked as deleted or if its id is already in Frontier. + /// Mongo transaction session. + /// Id of the project containing the word. + /// Id of the word to restore. + /// A bool: true if restored, false if not found + /// + /// Thrown when the word to restore has either Deleted status or Id already in the Frontier. + /// + private async Task RestoreFrontierWithSession(IClientSessionHandle session, + string projectId, string wordId) + { + var word = await _words.Find(session, GetProjectWordFilter(projectId, wordId)).FirstOrDefaultAsync(); + if (word is null) + { + return false; + } + if (word.Accessibility == Status.Deleted) + { + throw new ArgumentException("Cannot add a word with Deleted status to Frontier"); + } + if (await _frontier.Find(session, GetProjectWordFilter(projectId, wordId)).AnyAsync()) + { + throw new ArgumentException("Cannot restore a word with an Id already in the Frontier"); + } + + await _frontier.InsertOneAsync(session, word); + return true; + } + + /// + /// Replaces a Frontier word by deleting it, applying a modification, and re-creating it in both collections. + /// + /// Mongo transaction session. + /// Id of the project containing the Frontier word. + /// Id of the Frontier word to update. + /// Action that mutates the cloned word before it is re-created. + /// The updated word, or null if no matching Frontier word was found. + private async Task UpdateFrontierWithSession(IClientSessionHandle session, + string projectId, string wordId, Action modifyUpdatedWord) + { + var deletedWord = await _frontier.FindOneAndDeleteAsync(session, GetProjectWordFilter(projectId, wordId)); + if (deletedWord is null) + { + return null; + } + + var word = deletedWord.Clone(); + modifyUpdatedWord(word); + await CreateWithSession(session, [word]); + return word; + } + + /// + /// 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. + /// + /// Mongo transaction session. + /// Updated word whose Id and ProjectId identify the Frontier word to replace. + /// Whether to create the word if Frontier word not found to update. + /// Action to modify the new word using the deleted old word. + /// The updated word added to both collections, or null if not found and create not allowed. + private async Task UpdateFrontierWithSession(IClientSessionHandle session, + Word word, bool createIfNotFound, Action modifyUpdatedWord) + { + // Make sure old word exists in the Frontier. + var deletedWord = + await _frontier.FindOneAndDeleteAsync(session, GetProjectWordFilter(word.ProjectId, word.Id)); + if (deletedWord is null && !createIfNotFound) + { + return null; + } + + modifyUpdatedWord(word, deletedWord?.Clone()); + await CreateWithSession(session, [word]); + return word; + } + + /// + /// Replaces and/or deletes Frontier words within an existing transaction session. + /// + /// Mongo transaction session. + /// Id of the project containing the Frontier words. + /// Words that replace existing Frontier words. + /// Ids of Frontier words that will be replaced or deleted. + /// Action applied when building each replacement word. + /// Action applied on deleted Frontier words added to WordsCollection. + /// The replaced words. + /// + /// Thrown when an old word id doesn't exist in Frontier or a replacement word has a different project id. + /// + private async Task> ReplaceFrontierWithSession(IClientSessionHandle session, + string projectId, List newWords, IEnumerable oldWordIds, + Action modifyUpdatedWord, Action modifyDeletedWord) + { + if (newWords.Any(w => w.ProjectId != projectId)) + { + throw new ArgumentException("All new words must have the specified projectId"); + } + + var oldIdSet = oldWordIds.ToHashSet(); // Remove duplicates and allow easy removal for each update. + + foreach (var word in newWords) + { + // Remove the id from the old ids (if present) before the word is updated and given a new id. + oldIdSet.Remove(word.Id); + // `createIfNotFound: true` so the word is created even if the id isn't in the Frontier. + await UpdateFrontierWithSession(session, word, createIfNotFound: true, modifyUpdatedWord); + } + + // Delete remaining old words that weren't updated with a new word + foreach (var id in oldIdSet) + { + if (await DeleteFrontierWithSession(session, projectId, id, modifyDeletedWord) is null) + { + throw new ArgumentException("All old words being replaced must exist in the Frontier"); + } + } + return newWords; + } + + /// + /// Reverts a frontier replacement operation within an existing transaction session. + /// + /// Mongo transaction session. + /// Id of the project containing the Frontier words. + /// Ids of WordsCollection words to restore to Frontier. + /// Ids of Frontier words to delete. + /// Action applied on deleted Frontier words added to WordsCollection. + /// True when all requested restores succeed; otherwise false. + /// Thrown when restore and delete id sets are not disjoint. + private async Task RevertReplaceFrontierWithSession(IClientSessionHandle session, + string projectId, IEnumerable idsToRestore, IEnumerable 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"); + } + + foreach (var id in deleteSet) + { + if (await DeleteFrontierWithSession(session, projectId, id, modifyDeletedWord) is null) + { + return null; // Return null instead of false to abort transaction. + } + } + foreach (var id in restoreSet) + { + if (!await RestoreFrontierWithSession(session, projectId, id)) + { + return null; // Return null instead of false to abort transaction. + } + } + return true; + } + + #endregion } } diff --git a/Backend/Services/LiftService.cs b/Backend/Services/LiftService.cs index 20c9493ff3..cbf75d8a2b 100644 --- a/Backend/Services/LiftService.cs +++ b/Backend/Services/LiftService.cs @@ -687,9 +687,9 @@ private static void LdmlExport(string filePath, WritingSystem vernacularWS, List wsr.Save(); } - public ILiftMerger GetLiftImporterExporter(string projectId, string vernLang, IWordRepository wordRepo) + public ILiftMerger GetLiftImporterExporter(string projectId, string vernLang, IWordService wordService) { - return new LiftMerger(projectId, vernLang, wordRepo); + return new LiftMerger(projectId, vernLang, wordService); } private static void WriteRangeElement(XmlWriter liftRangesWriter, @@ -728,14 +728,14 @@ private sealed class LiftMerger : ILiftMerger private readonly string _projectId; private readonly List _customSemDoms = []; private readonly string _vernLang; - private readonly IWordRepository _wordRepo; + private readonly IWordService _wordService; private readonly List _importEntries = []; - public LiftMerger(string projectId, string vernLang, IWordRepository wordRepo) + public LiftMerger(string projectId, string vernLang, IWordService wordService) { _projectId = projectId; _vernLang = vernLang; - _wordRepo = wordRepo; + _wordService = wordService; } /// @@ -784,7 +784,7 @@ public List GetImportAnalysisWritingSystems() /// The words saved. public async Task> SaveImportEntries() { - var savedWords = new List(await _wordRepo.Create(_importEntries)); + var savedWords = new List(await _wordService.ImportWords(_importEntries)); _importEntries.Clear(); return savedWords; } diff --git a/Backend/Services/MergeService.cs b/Backend/Services/MergeService.cs index cc60cf97ad..5d593e3440 100644 --- a/Backend/Services/MergeService.cs +++ b/Backend/Services/MergeService.cs @@ -129,6 +129,9 @@ private async Task MergePrepParent(string projectId, MergeWords mergeWords /// then removes from the frontier the children that weren't updated. /// /// List of new words added. + /// + /// Thrown when a parent word has a different project id or a child id isn't in Frontier. + /// public async Task> Merge(string projectId, string userId, List mergeWordsList) { using var activity = OtelService.StartActivityWithTag(otelTagName, "merging words"); @@ -139,54 +142,17 @@ public async Task> Merge(string projectId, string userId, List m.Children.Select(c => c.SrcWordId)).ToHashSet(); - - // Create the parents - var addedParents = new List(); - foreach (var parent in parents) - { - var parentId = parent.Id; // Capture the id in case of changes. - Word? updatedParent = null; - if (childrenIds.Contains(parentId)) - { - updatedParent = await _wordService.Update(userId, parent); - if (updatedParent is not null) - { - childrenIds.Remove(parentId); - } - } - addedParents.Add(updatedParent ?? await _wordService.Create(userId, parent)); - } - - // Remove the children - await Task.WhenAll(childrenIds.Select(id => _wordService.DeleteFrontierWord(projectId, userId, id))); - - return addedParents; + return await _wordService.MergeReplaceFrontier(projectId, userId, parents.ToList(), childrenIds.ToList()); } /// Undo merge /// A bool: true if merge children were successfully restored + /// Thrown when ids to restore and delete are not disjoint. public async Task UndoMerge(string projectId, string userId, MergeUndoIds ids) { using var activity = OtelService.StartActivityWithTag(otelTagName, "undoing merge"); - var parentIds = ids.ParentIds.Distinct().ToList(); - - // If any of the parents aren't in the Frontier, they've been changed since the merge. - if (!await _wordRepo.AreInFrontier(projectId, parentIds, parentIds.Count)) - { - return false; - } - - // If children are not restorable, return without deleting the merge parents. - if (!await _wordService.RestoreFrontierWords(projectId, ids.ChildIds)) - { - return false; - } - - // Remove the parents - await Task.WhenAll(parentIds.Select(id => _wordService.DeleteFrontierWord(projectId, userId, id))); - - return true; + return await _wordService.RevertMergeReplaceFrontier(projectId, userId, ids.ChildIds, ids.ParentIds); } /// Adds a List of wordIds to MergeBlacklist of specified . diff --git a/Backend/Services/WordService.cs b/Backend/Services/WordService.cs index 5d55909e29..3371b51d75 100644 --- a/Backend/Services/WordService.cs +++ b/Backend/Services/WordService.cs @@ -1,6 +1,8 @@ +using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using BackendFramework.Helper; using BackendFramework.Interfaces; using BackendFramework.Models; using BackendFramework.Otel; @@ -15,157 +17,195 @@ public class WordService(IWordRepository wordRepo) : IWordService private const string otelTagName = "otel.WordService"; /// - /// Clear the given word's Id and Metadata to be generated by the word repo, + /// Adds Created time if blank. + /// Updates Modified time to now if blank or if updateModified is true. + /// + /// Word to mutate. + /// + /// If true, always update the Modified timestamp; otherwise only set it when blank. + /// + /// The mutated word. + private static Word UpdateTimes(Word word, bool updateModified) + { + // Use Roundtrip-suitable ISO 8601 format. + var now = Time.UtcNowIso8601(); + if (string.IsNullOrEmpty(word.Created)) + { + word.Created = string.IsNullOrEmpty(word.Modified) ? now : word.Modified; + } + if (updateModified || string.IsNullOrEmpty(word.Modified)) + { + word.Modified = now; + } + return word; + } + + /// + /// Update the given word's timestamps /// and add the given userId to EditedBy if it's not already last on the list. /// + /// Id of the user editing the word. + /// Word to mutate. + /// The mutated word. private static Word PrepEditedData(string userId, Word word) { - word.Id = ""; - word.Modified = ""; - if (!string.IsNullOrWhiteSpace(userId) && userId != word.EditedBy.LastOrDefault("")) + UpdateTimes(word, updateModified: true); + if (!string.IsNullOrEmpty(userId) && userId != word.EditedBy.LastOrDefault("")) { word.EditedBy.Add(userId); } return word; } - /// Creates a new word with updated edited data. - /// The created word - public async Task Create(string userId, Word word) - { - using var activity = OtelService.StartActivityWithTag(otelTagName, "creating a word"); + /// + /// Creates an action to remove a specified audio file from a word. + /// + /// Id of the user performing the delete. + /// Name of the audio file to remove. + /// An action that updates edit metadata and removes the requested audio file. + /// + /// Thrown by the returned action when is not found on the word. + /// + private static Action CreateDeleteAudioAction(string userId, string fileName) => + word => + { + PrepEditedData(userId, word); + if (word.Audio.RemoveAll(a => a.FileName == fileName) == 0) + { + throw new ArgumentException("Audio file name not found on word."); + } + word.History.Add(word.Id); + }; - return await _wordRepo.Create(PrepEditedData(userId, word)); - } + /// + /// Creates an action to modify the metadata of a deleted Frontier word for saving to the words collection. + /// + /// Id of the user performing the delete. + /// An action that marks a word as deleted and appends its id to history. + private static Action CreateModifyDeletedWordAction(string userId) => + word => + { + PrepEditedData(userId, word); + word.Accessibility = Status.Deleted; + word.History.Add(word.Id); + }; + + /// + /// Creates an action to modify the metadata of an update to a Frontier word. + /// + /// Id of the user performing the update. + /// + /// An action that updates edit metadata, preserves Created time, and records prior ids in history. + /// + private static Action CreateModifyUpdatedWordAction(string userId) => + (newWord, oldWord) => + { + PrepEditedData(userId, newWord); + + // Allow use with a new word that has no predecessor. + if (oldWord is null) + { + return; + } + + // Add Id to history. + if (!newWord.History.Contains(oldWord.Id)) + { + newWord.History.Add(oldWord.Id); + } + + // Preserve Created time. + if (!string.IsNullOrEmpty(oldWord.Created)) + { + newWord.Created = oldWord.Created; + } + + // If an imported word was using the citation form for its Vernacular, + // only keep UsingCitationForm true if the Vernacular hasn't changed. + newWord.UsingCitationForm &= newWord.Vernacular == oldWord.Vernacular; + }; - /// Creates new words with updated edited data. - /// The created word - public async Task> Create(string userId, List words) + /// Adds a list of s to the WordsCollection and Frontier. + /// Words to import. + /// The imported words with timestamps updated. + public async Task> ImportWords(List words) { - using var activity = OtelService.StartActivityWithTag(otelTagName, "creating words"); + using var activity = OtelService.StartActivityWithTag(otelTagName, "creating words in repo"); - return await _wordRepo.Create(words.Select(w => PrepEditedData(userId, w)).ToList()); + return await _wordRepo.Create(words.Select(w => UpdateTimes(w, updateModified: false)).ToList()); } - /// Adds a new word with updated edited data. - /// The added word - private async Task Add(string userId, Word word) + /// Creates a new word with updated edited data. + /// Id of the user creating the word. + /// Word to create. + /// The created word. + public async Task Create(string userId, Word word) { - return await _wordRepo.Add(PrepEditedData(userId, word)); + using var activity = OtelService.StartActivityWithTag(otelTagName, "creating a word"); + + return await _wordRepo.Create(PrepEditedData(userId, word)); } /// Removes audio with specified fileName from a Frontier word - /// Updated word, or null if not found + /// Id of the project containing the word. + /// Id of the user deleting the audio. + /// Id of the Frontier word to update. + /// Name of the audio file to remove. + /// Updated word, or null if not found. public async Task DeleteAudio(string projectId, string userId, string wordId, string fileName) { using var activity = OtelService.StartActivityWithTag(otelTagName, "deleting an audio"); - var wordWithAudioToDelete = (await _wordRepo.GetFrontier(projectId, wordId, fileName))?.Clone(); - if (wordWithAudioToDelete is null) + try + { + return await _wordRepo.UpdateFrontier(projectId, wordId, CreateDeleteAudioAction(userId, fileName)); + } + catch (ArgumentException) { return null; } - - wordWithAudioToDelete.Audio.RemoveAll(a => a.FileName == fileName); - return await Update(userId, wordWithAudioToDelete); } /// Removes word from Frontier and adds a Deleted copy in the words collection - /// A string: id of deleted word, or null if not found + /// Id of the project containing the Frontier word. + /// Id of the user deleting the word. + /// Id of the Frontier word to remove. + /// Id of deleted word, or null if not found. public async Task DeleteFrontierWord(string projectId, string userId, string wordId) { using var activity = OtelService.StartActivityWithTag(otelTagName, "deleting a word from Frontier"); - var word = (await _wordRepo.GetFrontier(projectId, wordId))?.Clone(); - if (word is null) - { - return null; - } - - word.Accessibility = Status.Deleted; - word.History.Add(wordId); - - var deletedWord = await Add(userId, word); - - // Don't remove the Frontier word until the copy is successfully stored as deleted. - await _wordRepo.DeleteFrontier(projectId, wordId); - - return deletedWord.Id; + return (await _wordRepo.DeleteFrontier(projectId, wordId, CreateModifyDeletedWordAction(userId)))?.Id; } /// Restores words to the Frontier that aren't in the Frontier - /// - /// Aborts if any word can't be restored for any of the following reasons: - /// doesn't exist; has Status.Deleted; or is already in the Frontier - /// - /// A bool: true if all successfully restored; false if none restored. - public async Task RestoreFrontierWords(string projectId, List wordIds) + /// Id of the project containing the word. + /// Id of the word to restore. + /// True if the word is restored; false if it is not found. + /// + /// Thrown when the word has Deleted status or when its Id already exists in Frontier. + /// + public async Task RestoreFrontierWord(string projectId, string wordId) { using var activity = OtelService.StartActivityWithTag(otelTagName, "restoring words to Frontier"); - // Allow calls that don't specify any wordIds, but don't do any work. - if (wordIds.Count == 0) - { - return true; - } - - wordIds = wordIds.Distinct().ToList(); - - // Make sure none of the words are in the Frontier. - if (await _wordRepo.AreInFrontier(projectId, wordIds, 1)) - { - return false; - } - - // Make sure all the words exist and are valid. - var wordsToRestore = await _wordRepo.GetWords(projectId, wordIds); - if (wordsToRestore.Count != wordIds.Count) - { - return false; - } - if (wordsToRestore.Any(w => w.Accessibility == Status.Deleted)) - { - // We should be restoring words that were removed from the Frontier, - // and not their "Deleted" copies in the words collection. - return false; - } - - await _wordRepo.AddFrontier(wordsToRestore); - return true; + return await _wordRepo.RestoreFrontier(projectId, wordId); } /// Makes a new word in the Frontier with changes made - /// Updated word, or null if word-to-update not found + /// Id of the user updating the word. + /// Updated word whose id identifies the Frontier word to replace. + /// Updated word, or null if word-to-update not found. public async Task Update(string userId, Word word) { using var activity = OtelService.StartActivityWithTag(otelTagName, "updating a word in Frontier"); - var oldWordId = word.Id; // Capture the id in case of changes. - var oldWord = await _wordRepo.GetFrontier(word.ProjectId, oldWordId); - if (oldWord is null) - { - return null; - } - - word.Created = oldWord.Created; - if (!word.History.Contains(oldWordId)) - { - word.History.Add(oldWordId); - } - // If an imported word was using the citation form for its Vernacular, - // only keep UsingCitationForm true if the Vernacular hasn't changed. - word.UsingCitationForm &= word.Vernacular == oldWord.Vernacular; - - var newWord = await Create(userId, word); - - // Don't remove the old Frontier word until the new word is successfully created. - await _wordRepo.DeleteFrontier(word.ProjectId, oldWordId); - - return newWord; + return await _wordRepo.UpdateFrontier(word, CreateModifyUpdatedWordAction(userId)); } /// Checks if a word being added is a duplicate of a preexisting word. - /// The id string of the existing word, or null if none. + /// Word to compare against existing Frontier words. + /// The id string of the existing word, or null if none. public async Task FindContainingWord(Word word) { using var activity = OtelService.StartActivityWithTag(otelTagName, "checking for duplicates of a word"); @@ -174,5 +214,45 @@ public async Task RestoreFrontierWords(string projectId, List word var duplicatedWord = wordsWithVern.Find(w => w.Contains(word)); return duplicatedWord?.Id; } + + /// + /// Replaces merge children in the Frontier with prepared parent words where possible, + /// creates remaining parents, and deletes remaining children from the Frontier. + /// + /// Id of the project containing the merge children and parents. + /// Id of the user performing the merge. + /// Parent words to create or use as replacements. + /// Ids of merge children to delete from Frontier. + /// The updated parent words. + /// + /// Thrown when a parent word has a different project id or a child id to replace/delete isn't in Frontier. + /// + public async Task> MergeReplaceFrontier( + string projectId, string userId, List parents, List idsToDelete) + { + using var activity = OtelService.StartActivityWithTag(otelTagName, "replacing frontier words for merge"); + + return await _wordRepo.ReplaceFrontier(projectId, parents, idsToDelete, + CreateModifyUpdatedWordAction(userId), CreateModifyDeletedWordAction(userId)); + } + + /// + /// Reverts a merge replacement by deleting created/replaced frontier words and restoring removed children. + /// + /// Id of the project containing the words. + /// Id of the user performing the revert. + /// Ids of words to restore to Frontier. + /// Ids of Frontier words to delete. + /// True when all requested restores succeed; otherwise false. + /// Thrown when ids to restore and delete are not disjoint. + public async Task RevertMergeReplaceFrontier( + string projectId, string userId, List idsToRestore, List idsToDelete) + { + using var activity = + OtelService.StartActivityWithTag(otelTagName, "reverting replaced frontier words for merge"); + + return await _wordRepo.RevertReplaceFrontier( + projectId, idsToRestore, idsToDelete, CreateModifyDeletedWordAction(userId)); + } } } diff --git a/Backend/Startup.cs b/Backend/Startup.cs index 068409c652..642e1fd5eb 100644 --- a/Backend/Startup.cs +++ b/Backend/Startup.cs @@ -350,7 +350,8 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env, IHostApp // If an admin user has been created via the command line, treat that as a single action and shut the // server down so the calling script knows it's been completed successfully or unsuccessfully. - var userRepo = app.ApplicationServices.GetService(); + using var startupScope = app.ApplicationServices.CreateScope(); + var userRepo = startupScope.ServiceProvider.GetService(); if (userRepo is not null && CreateAdminUser(userRepo)) { _logger.LogInformation("Stopping application"); diff --git a/Backend/appsettings.json b/Backend/appsettings.json index f03b8c6e7f..b42b2f478f 100644 --- a/Backend/appsettings.json +++ b/Backend/appsettings.json @@ -1,7 +1,7 @@ { "MongoDB": { - "ConnectionString": "mongodb://localhost:27017", - "ContainerConnectionString": "mongodb://database:27017", + "ConnectionString": "mongodb://localhost:27017/?replicaSet=rs0", + "ContainerConnectionString": "mongodb://database:27017/?replicaSet=rs0", "CombineDatabase": "CombineDatabase" }, "Logging": { diff --git a/README.md b/README.md index 3d49a57918..7401b17f83 100644 --- a/README.md +++ b/README.md @@ -473,7 +473,7 @@ npm run license-report-frontend To browse the database locally during development, open [MongoDB Compass](https://www.mongodb.com/try/download/compass). -1. Under New Connection, enter `mongodb://localhost:27017` +1. Under New Connection, enter `mongodb://localhost:27017/?replicaSet=rs0` 2. Under Databases, select CombineDatabase ### Add or Update Dictionary Files diff --git a/database/Dockerfile b/database/Dockerfile index a540f28816..45e87b4a11 100644 --- a/database/Dockerfile +++ b/database/Dockerfile @@ -5,21 +5,21 @@ # - Intel/AMD 64-bit # - ARM 64-bit ############################################################ -FROM mongo:7.0.28-jammy@sha256:8ddd3db4d2638eb914cce56284e2f0d6daf140bba31679b2af86f7d790a4c77e +FROM mongo:7.0.30-jammy@sha256:aee9bae9f1a5507a51e19f24b015162cbcd7004695d99175dbccc427e20760e2 WORKDIR / -RUN mkdir /data/semantic-domains +RUN mkdir -p /data/semantic-domains /opt/thecombine # Copy semantic domain import files COPY semantic_domains/* /data/semantic-domains/ # from https://hub.docker.com/_/mongo -# Initializing a fresh instance -# When a container is started for the first time it will execute files -# with extensions .sh and .js that are found in /docker-entrypoint-initdb.d. -# Files will be executed in alphabetical order. .js files will be executed -# by mongosh (mongo on versions below 6) using the database specified by -# the MONGO_INITDB_DATABASE variable, if it is present, or test otherwise. -# You may also switch databases within the .js script. -COPY init/* /docker-entrypoint-initdb.d/ +# Scripts in /docker-entrypoint-initdb.d run only on first startup of an empty +# data directory. We intentionally keep setup scripts out of initdb.d and run +# them from Kubernetes postStart to avoid first-boot race conditions. +COPY init/update-semantic-domains.sh /opt/thecombine/update-semantic-domains.sh + +# Replica set readiness/alignment runs from Kubernetes postStart hook on every +# container start, so this script is intentionally kept out of initdb.d. +COPY init/00-replica-set.js /opt/thecombine/00-replica-set.js diff --git a/database/init/00-replica-set.js b/database/init/00-replica-set.js new file mode 100644 index 0000000000..41fa2343b1 --- /dev/null +++ b/database/init/00-replica-set.js @@ -0,0 +1,81 @@ +// Ensure a single-node replica set is initialized and advertising the +// expected host for this environment. +// +// MONGO_INITDB_REPLICA_HOST can be set to the resolvable hostname:port +// used to advertise this member (e.g. "database:27017" in Kubernetes). +const host = process.env.MONGO_INITDB_REPLICA_HOST || "localhost:27017"; +const maxWaitMs = 60 * 1000; +const intervalMs = 1000; +const start = Date.now(); + +/** Ensure the primary host is correctly configured */ +function ensurePrimaryHost(forceReconfig) { + let conf; + try { + conf = rs.conf(); + } catch (error) { + conf = db.getSiblingDB("local").system.replset.findOne(); + if (!forceReconfig || !conf) { + throw error; + } + } + + if (!conf.members?.length) { + throw new Error("Replica set config has no members"); + } + + if (conf.members[0].host !== host) { + print(`Updating replica set member host to ${host}`); + conf.members[0].host = host; + conf.version = (conf.version || 1) + 1; + rs.reconfig(conf, { force: forceReconfig }); + return false; + } + + return true; +} + +// Wait for replica set to be initialized. +let replicaSetInitiated = false; +while (Date.now() - start < maxWaitMs) { + try { + rs.initiate({ _id: "rs0", members: [{ _id: 0, host: host }] }); + print(`Initialized replica set with host ${host}`); + replicaSetInitiated = true; + break; + } catch (err) { + if (String(err).includes("already initialized")) { + print("Replica set already initialized"); + replicaSetInitiated = true; + break; + } + + print(`Replica set init deferred: ${err}`); + } + + sleep(intervalMs); +} +if (!replicaSetInitiated) { + throw new Error(`Replica set not initialized after ${maxWaitMs}ms`); +} + +// Wait for this member to be PRIMARY with the correct host. +while (Date.now() - start < maxWaitMs) { + try { + if (db.hello().isWritablePrimary) { + if (ensurePrimaryHost(false)) { + print(`Replica set is PRIMARY with correct host: ${host}`); + quit(0); + } + } else { + ensurePrimaryHost(true); + } + } catch (err) { + print(`Host alignment deferred: ${err}`); + } + + sleep(intervalMs); +} +throw new Error( + `Replica set did not reach PRIMARY state with host ${host} after ${maxWaitMs}ms` +); diff --git a/deploy/helm/thecombine/charts/database/templates/database.yaml b/deploy/helm/thecombine/charts/database/templates/database.yaml index 7c30530888..71e162907c 100644 --- a/deploy/helm/thecombine/charts/database/templates/database.yaml +++ b/deploy/helm/thecombine/charts/database/templates/database.yaml @@ -46,6 +46,40 @@ spec: - image: {{ include "database.containerImage" . }} imagePullPolicy: {{ .Values.global.imagePullPolicy }} name: database + args: + - "--replSet" + - "rs0" + lifecycle: + postStart: + exec: + command: + - /bin/sh + - -c + - | + set -e + echo "[postStart] Waiting for mongod to accept connections" + attempts=0 + until mongosh --quiet --host 127.0.0.1 --eval "db.adminCommand({ ping: 1 }).ok" >/dev/null 2>&1; do + attempts=$((attempts + 1)) + if [ "${attempts}" -ge 120 ]; then + echo "[postStart] Failed to connect to mongod after ${attempts} attempts, exiting" + exit 1 + fi + sleep 1 + done + echo "[postStart] Ensuring replica set host" + mongosh --quiet --host 127.0.0.1 /opt/thecombine/00-replica-set.js || exit $? + needs_semantic_import="$(mongosh --quiet --host 127.0.0.1 --eval "const combineDb = db.getSiblingDB('CombineDatabase'); const treeCount = combineDb.SemanticDomainTree.countDocuments({}); const domainCount = combineDb.SemanticDomains.countDocuments({}); print(treeCount === 0 || domainCount === 0 ? 'yes' : 'no');")" + if [ "${needs_semantic_import}" = "yes" ]; then + /bin/bash /opt/thecombine/update-semantic-domains.sh + fi + env: + - name: POD_IP + valueFrom: + fieldRef: + fieldPath: status.podIP + - name: MONGO_INITDB_REPLICA_HOST + value: "$(POD_IP):27017" ports: - containerPort: 27017 resources: diff --git a/docs/deploy/README.md b/docs/deploy/README.md index b9cc53ce25..064870c6cc 100644 --- a/docs/deploy/README.md +++ b/docs/deploy/README.md @@ -415,7 +415,7 @@ Notes: rerun manually: ```console - kubectl -n thecombine exec deployment/database -- /docker-entrypoint-initdb.d/update-semantic-domains.sh + kubectl -n thecombine exec deployment/database -- /opt/thecombine/update-semantic-domains.sh ``` ## Maintenance diff --git a/package.json b/package.json index 37287230e6..64bbcf5170 100644 --- a/package.json +++ b/package.json @@ -8,8 +8,7 @@ "backend": "dotnet watch --project Backend/BackendFramework.csproj", "build": "parcel build", "build:analyze": "npm run build -- --reporter @parcel/reporter-bundle-analyzer", - "predatabase": "node scripts/setupMongo.js", - "database": "mongod --dbpath=./mongo_database", + "database": "node scripts/startDatabase.js", "drop-database": "tsc scripts/dropDB.ts && node scripts/dropDB.js", "find-circular-deps": "npx --ignore-scripts -y madge -c src/index.tsx --ts-config tsconfig.json", "fmt-backend": " dotnet format && dotnet format Backend.Tests", @@ -32,6 +31,8 @@ "test": "run-s test-backend test-frontend", "test-backend": " dotnet test Backend.Tests/Backend.Tests.csproj", "test-backend:coverage": "dotnet test Backend.Tests/Backend.Tests.csproj /p:CollectCoverage=true /p:CoverletOutputFormat=lcov /p:Threshold=77", + "test-backend:integration": "dotnet test Backend.Tests/Backend.Tests.csproj --filter \"TestCategory=IntegrationTest\"", + "test-backend:unit": "dotnet test Backend.Tests/Backend.Tests.csproj --filter \"TestCategory!=IntegrationTest\"", "test-frontend": " node scripts/jestTest.js", "test-frontend:coverage": "node scripts/jestTest.js --coverage --watchAll=false", "test-frontend:debug": " node --inspect-brk scripts/jestTest.js --runInBand --no-cache", diff --git a/scripts/setupMongo.js b/scripts/setupMongo.js deleted file mode 100644 index 6bd1bbb0cb..0000000000 --- a/scripts/setupMongo.js +++ /dev/null @@ -1,7 +0,0 @@ -"use strict"; - -const { ensureDir } = require("fs-extra"); - -const directory = "./mongo_database"; - -ensureDir(directory); diff --git a/scripts/startDatabase.js b/scripts/startDatabase.js new file mode 100644 index 0000000000..51b561317c --- /dev/null +++ b/scripts/startDatabase.js @@ -0,0 +1,173 @@ +"use strict"; + +const { spawn, spawnSync } = require("child_process"); +const { emitKeypressEvents } = require("readline"); + +const { ensureDir } = require("fs-extra"); + +const dbPath = "./mongo_database"; +const replSetName = "rs0"; +const maxAttempts = 30; +const retryIntervalSeconds = 1; +const mongoshTimeoutSeconds = 10; +let mongodProcess; +let exiting = false; + +/** Check if TTY and raw mode are available. */ +function canUseRawMode() { + return process.stdin.isTTY && typeof process.stdin.setRawMode === "function"; +} + +/** Disable raw mode. */ +function stopRawMode() { + if (canUseRawMode()) { + process.stdin.setRawMode(false); + process.stdin.pause(); + } +} + +/** Enable raw mode to capture Ctrl+C when it doesn't otherwise work. */ +function startRawMode() { + if (canUseRawMode()) { + emitKeypressEvents(process.stdin); + process.stdin.setRawMode(true); + process.stdin.resume(); + process.stdin.on("keypress", (_text, key) => { + if (key?.ctrl && key.name === "c") { + forceExit(130); + } + }); + } +} + +/** Unless already exiting, forcibly kill mongod and exit process. */ +function forceExit(code = 0) { + if (exiting) { + return; + } + + exiting = true; + + stopRawMode(); + + if (mongodProcess && !mongodProcess.killed) { + mongodProcess.kill("SIGKILL"); + } + process.exit(code); +} + +/** Set up handlers for various interrupts. */ +function setUpInterruptHandling() { + process.on("SIGINT", () => forceExit(130)); + process.on("SIGBREAK", () => forceExit(131)); + process.on("SIGTERM", () => forceExit(143)); + startRawMode(); +} + +/** Convert error to string. */ +function getErrorMessage(error) { + return error instanceof Error + ? `${error.code}: ${error.message}` + : String(error); +} + +/** Run a mongosh command with timeout and interrupt handling. */ +function runMongosh(args, options = {}) { + const result = spawnSync("mongosh", [...args, "--quiet"], { + timeout: mongoshTimeoutSeconds * 1000, + killSignal: "SIGTERM", + ...options, + }); + + if (result.error) { + throw result.error; + } + if (result.signal) { + throw new Error(`mongosh exited due to signal ${result.signal}`); + } + + return result; +} + +/** Ping with mongosh until available, up to a preset number of attempts. */ +async function waitForMongo() { + for (let i = 0; i < maxAttempts; i++) { + let result; + try { + result = runMongosh(["--eval", "db.adminCommand('ping')"]); + } catch (err) { + console.warn(`ping attempt ${i + 1} failed: ${getErrorMessage(err)}`); + } + if (result?.status === 0) { + return true; + } + if (i < maxAttempts - 1) { + await new Promise((res) => setTimeout(res, retryIntervalSeconds * 1000)); + } + } + + console.error(`MongoDB pings failed after ${maxAttempts} attempts.`); + return false; +} + +/** Start a replica set if not already initialized. */ +async function initReplicaSet() { + try { + const result = runMongosh( + [ + "--eval", + `try { rs.status() } catch { rs.initiate({ _id: '${replSetName}', members: [{ _id: 0, host: 'localhost:27017' }] }) }`, + ], + { stdio: "inherit" } + ); + return result.status === 0; + } catch (error) { + console.error(`Replica set init failed: ${getErrorMessage(error)}`); + return false; + } +} + +async function main() { + setUpInterruptHandling(); + + await ensureDir(dbPath); + + // Start the mongod process + mongodProcess = spawn( + "mongod", + ["--dbpath", dbPath, "--replSet", replSetName, "--quiet"], + { stdio: "inherit" } + ); + + // Exit when the mongod process errors + mongodProcess.on("error", (err) => { + console.error(`mongod error: ${err.message}`); + forceExit(1); + }); + + // Exit when the mongod process exits + mongodProcess.on("exit", (code, signal) => { + if (exiting) { + return; + } + + if (code || signal) { + console.error(`mongod exited with code ${code}, signal ${signal}`); + } + forceExit(signal ? 1 : (code ?? 1)); + }); + + if (!(await waitForMongo())) { + console.error("MongoDB did not start in time"); + forceExit(1); + } + if (!(await initReplicaSet())) { + console.error("Replica set initialization failed"); + forceExit(1); + } +} + +main().catch((err) => { + console.error(err); + forceExit(1); +}); diff --git a/src/api/api/merge-api.ts b/src/api/api/merge-api.ts index d844eecefc..d3d7bbb5b6 100644 --- a/src/api/api/merge-api.ts +++ b/src/api/api/merge-api.ts @@ -824,7 +824,7 @@ export const MergeApiFp = function (configuration?: Configuration) { mergeUndoIds: MergeUndoIds, options?: any ): Promise< - (axios?: AxiosInstance, basePath?: string) => AxiosPromise + (axios?: AxiosInstance, basePath?: string) => AxiosPromise > { const localVarAxiosArgs = await localVarAxiosParamCreator.undoMerge( projectId, @@ -1011,7 +1011,7 @@ export const MergeApiFactory = function ( projectId: string, mergeUndoIds: MergeUndoIds, options?: any - ): AxiosPromise { + ): AxiosPromise { return localVarFp .undoMerge(projectId, mergeUndoIds, options) .then((request) => request(axios, basePath)); diff --git a/src/api/api/word-api.ts b/src/api/api/word-api.ts index 53ac796c1b..ab07e3dcfd 100644 --- a/src/api/api/word-api.ts +++ b/src/api/api/word-api.ts @@ -1041,7 +1041,7 @@ export const WordApiFp = function (configuration?: Configuration) { wordId: string, options?: any ): Promise< - (axios?: AxiosInstance, basePath?: string) => AxiosPromise + (axios?: AxiosInstance, basePath?: string) => AxiosPromise > { const localVarAxiosArgs = await localVarAxiosParamCreator.restoreWord( projectId, @@ -1315,7 +1315,7 @@ export const WordApiFactory = function ( projectId: string, wordId: string, options?: any - ): AxiosPromise { + ): AxiosPromise { return localVarFp .restoreWord(projectId, wordId, options) .then((request) => request(axios, basePath)); diff --git a/src/backend/index.ts b/src/backend/index.ts index a8cdef7214..0109e66a3b 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -339,12 +339,12 @@ export async function mergeWords(mergeWords: MergeWords[]): Promise { } /** Restores words that were previously merged and deletes the merge result. */ -export async function undoMerge(wordIds: MergeUndoIds): Promise { +export async function undoMerge(wordIds: MergeUndoIds): Promise { const params = { projectId: LocalStorage.getProjectId(), mergeUndoIds: wordIds, }; - return (await mergeApi.undoMerge(params, defaultOptions())).data; + await mergeApi.undoMerge(params, defaultOptions()); } /** Adds a list of wordIds to current project's merge blacklist. */ @@ -959,10 +959,9 @@ export async function isInFrontier( export async function restoreWord( wordId: string, projectId?: string -): Promise { +): Promise { projectId ||= LocalStorage.getProjectId(); - const params = { projectId, wordId }; - return (await wordApi.restoreWord(params, defaultOptions())).data; + await wordApi.restoreWord({ projectId, wordId }, defaultOptions()); } /** Revert word updates given in dictionary of word ids: