diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml index d4f0054cd2..ce06bf51b0 100644 --- a/.github/workflows/backend.yml +++ b/.github/workflows/backend.yml @@ -41,7 +41,6 @@ jobs: dc.services.visualstudio.com:443 deb.debian.org:80 github.com:443 - md-hdd-t032zjxllntc.z26.blob.storage.azure.net:443 objects.githubusercontent.com:443 - name: Checkout repository uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 diff --git a/Backend.Tests/Backend.Tests.csproj b/Backend.Tests/Backend.Tests.csproj index ab67ca9f59..f266389a4b 100644 --- a/Backend.Tests/Backend.Tests.csproj +++ b/Backend.Tests/Backend.Tests.csproj @@ -13,6 +13,9 @@ $(NoWarn);CA1305;CA1859;CS1591 + + + diff --git a/Backend.Tests/Repositories/MongoDbTestRunner.cs b/Backend.Tests/Repositories/MongoDbTestRunner.cs new file mode 100644 index 0000000000..ae61cfa6db --- /dev/null +++ b/Backend.Tests/Repositories/MongoDbTestRunner.cs @@ -0,0 +1,213 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Net.Sockets; +using System.Runtime.InteropServices; +using System.Threading; +using MongoDB.Bson; +using MongoDB.Driver; + +namespace Backend.Tests.Repositories +{ + /// + /// Starts and manages an ephemeral MongoDB process for integration testing. + /// Uses the mongod binary from the EphemeralMongo7 NuGet package. + /// Supports single-node replica sets to enable multi-document transactions. + /// + internal sealed class MongoDbTestRunner : IDisposable + { + private const string Host = "127.0.0.1"; + private const string ReplicaSetName = "rs0"; + + private readonly Process _process; + private readonly string _dataDirectory; + + public string ConnectionString { get; } + + private MongoDbTestRunner(Process process, string dataDirectory, string connectionString) + { + _process = process; + _dataDirectory = dataDirectory; + ConnectionString = connectionString; + } + + /// + /// Starts a MongoDB instance as a single-node replica set. + /// + public static MongoDbTestRunner Start() + { + var binaryPath = FindMongodBinary(); + var port = FindFreePort(); + var dataDirectory = Path.Combine(Path.GetTempPath(), $"mongo-test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(dataDirectory); + + var process = StartMongodProcess(binaryPath, port, dataDirectory); + try + { + WaitForMongoReady(port); + InitializeReplicaSet(port); + WaitForReplicaSetReady(port); + } + catch + { + process.Kill(entireProcessTree: true); + process.Dispose(); + Directory.Delete(dataDirectory, recursive: true); + throw; + } + + var connectionString = $"mongodb://{Host}:{port}/?directConnection=true&replicaSet={ReplicaSetName}"; + return new MongoDbTestRunner(process, dataDirectory, connectionString); + } + + private static string FindMongodBinary() + { + var rid = GetRuntimeId(); + var baseDir = AppContext.BaseDirectory; + var binaryName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "mongod.exe" : "mongod"; + var binaryPath = Path.Combine(baseDir, "runtimes", rid, "native", "mongodb", "bin", binaryName); + + if (!File.Exists(binaryPath)) + { + throw new FileNotFoundException( + $"mongod binary not found at '{binaryPath}'. Ensure one of the EphemeralMongo7.runtime.* packages is installed.", + binaryPath); + } + + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + // Ensure the binary is executable on Unix + var chmod = Process.Start(new ProcessStartInfo("chmod") + { + ArgumentList = { "+x", binaryPath }, + UseShellExecute = false, + }); + chmod?.WaitForExit(); + } + + return binaryPath; + } + + private static string GetRuntimeId() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return "win-x64"; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) return "linux-x64"; + // EphemeralMongo7 only provides an arm64 binary for macOS + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) return "osx-arm64"; + throw new PlatformNotSupportedException("Unsupported operating system."); + } + + private static int FindFreePort() + { + using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + socket.Bind(new System.Net.IPEndPoint(System.Net.IPAddress.Loopback, 0)); + return ((System.Net.IPEndPoint)socket.LocalEndPoint!).Port; + } + + private static Process StartMongodProcess(string binaryPath, int port, string dataDirectory) + { + var args = string.Join(" ", + $"--replSet {ReplicaSetName}", + $"--bind_ip {Host}", + $"--port {port}", + $"--dbpath \"{dataDirectory}\"", + "--noauth", + "--quiet"); + + var process = new Process + { + StartInfo = new ProcessStartInfo(binaryPath, args) + { + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + } + }; + process.Start(); + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + return process; + } + + private static void WaitForMongoReady(int port, int timeoutSeconds = 30) + { + var deadline = DateTime.UtcNow.AddSeconds(timeoutSeconds); + while (DateTime.UtcNow < deadline) + { + try + { + using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + socket.Connect(Host, port); + return; + } + catch (SocketException) + { + Thread.Sleep(200); + } + } + + throw new TimeoutException($"MongoDB did not start within {timeoutSeconds} seconds on port {port}."); + } + + private static void InitializeReplicaSet(int port) + { + var client = new MongoClient($"mongodb://{Host}:{port}/?directConnection=true"); + var admin = client.GetDatabase("admin"); + var config = new BsonDocument + { + { "_id", ReplicaSetName }, + { "members", new BsonArray { new BsonDocument { { "_id", 0 }, { "host", $"{Host}:{port}" } } } } + }; + admin.RunCommand(new BsonDocument("replSetInitiate", config)); + } + + private static void WaitForReplicaSetReady(int port, int timeoutSeconds = 30) + { + var client = new MongoClient( + $"mongodb://{Host}:{port}/?directConnection=true&replicaSet={ReplicaSetName}"); + var deadline = DateTime.UtcNow.AddSeconds(timeoutSeconds); + Exception? lastException = null; + while (DateTime.UtcNow < deadline) + { + try + { + var admin = client.GetDatabase("admin"); + var status = admin.RunCommand(new BsonDocument("replSetGetStatus", 1)); + if (status["ok"].ToInt32() == 1 && status["myState"].ToInt32() == 1) + { + return; + } + } + catch (Exception ex) + { + lastException = ex; + } + + Thread.Sleep(500); + } + + throw new TimeoutException( + $"Replica set did not become ready within {timeoutSeconds} seconds.", lastException); + } + + public void Dispose() + { + try + { + if (!_process.HasExited) + { + _process.Kill(entireProcessTree: true); + } + } + catch (InvalidOperationException) { } + + _process.Dispose(); + + try + { + Directory.Delete(_dataDirectory, recursive: true); + } + catch (IOException) { } + } + } +} diff --git a/Backend.Tests/Repositories/WordRepositoryTests.cs b/Backend.Tests/Repositories/WordRepositoryTests.cs new file mode 100644 index 0000000000..aa9f0acc37 --- /dev/null +++ b/Backend.Tests/Repositories/WordRepositoryTests.cs @@ -0,0 +1,593 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using BackendFramework.Contexts; +using BackendFramework.Models; +using BackendFramework.Repositories; +using Microsoft.Extensions.Options; +using MongoDB.Bson; +using NUnit.Framework; + +namespace Backend.Tests.Repositories +{ + /// + /// Integration tests for that spin up an actual MongoDB instance. + /// + [TestFixture] + [Category("IntegrationTest")] + public sealed class WordRepositoryTests + { + private static MongoDbTestRunner _runner = null!; + private WordRepository _repo = null!; + private string _projectId = null!; + + [OneTimeSetUp] + public static void StartMongo() + { + _runner?.Dispose(); + _runner = MongoDbTestRunner.Start(); + } + + [OneTimeTearDown] + public static void StopMongo() + { + _runner.Dispose(); + } + + [SetUp] + public void SetUp() + { + _projectId = Guid.NewGuid().ToString(); + var options = Options.Create(new BackendFramework.Startup.Settings + { + ConnectionString = _runner.ConnectionString, + CombineDatabase = "WordRepositoryTests", + }); + _repo = new WordRepository(new MongoDbContext(options)); + } + + private Task CreateWord(string? vernacular = null, string? domainId = null) + { + var word = Util.RandomWord(_projectId); + if (vernacular is not null) + { + word.Vernacular = vernacular; + } + + if (domainId is not null) + { + word.Senses[0].SemanticDomains = [new SemanticDomain { Id = domainId, Name = "Test" }]; + } + + return _repo.Create(word); + } + + /// Generates a valid MongoDB ObjectId string that does not exist in the database. + private static string NewObjectId() => ObjectId.GenerateNewId().ToString(); + + // GET ALL WORDS + + [Test] + public async Task TestGetAllWords() + { + var word = await CreateWord(); + var words = await _repo.GetAllWords(_projectId); + Assert.That(words, Has.Count.EqualTo(1)); + Assert.That(words[0].Id, Is.EqualTo(word.Id)); + } + + [Test] + public async Task TestGetAllWordsEmptyProject() + { + var words = await _repo.GetAllWords(_projectId); + Assert.That(words, Is.Empty); + } + + [Test] + public async Task TestGetAllWordsOnlyReturnsWordsForProject() + { + await CreateWord(); + var otherProjectWords = await _repo.GetAllWords(Guid.NewGuid().ToString()); + Assert.That(otherProjectWords, Is.Empty); + } + + // GET WORD + + [Test] + public async Task TestGetWord() + { + var created = await CreateWord(); + var retrieved = await _repo.GetWord(_projectId, created.Id); + Assert.That(retrieved, Is.Not.Null); + Assert.That(retrieved!.Id, Is.EqualTo(created.Id)); + } + + [Test] + public async Task TestGetWordNonExistentIdReturnsNull() + { + var result = await _repo.GetWord(_projectId, NewObjectId()); + Assert.That(result, Is.Null); + } + + // CREATE + + [Test] + public async Task TestCreateSingleWordAddsToWordsAndFrontier() + { + var word = Util.RandomWord(_projectId); + var created = await _repo.Create(word); + + Assert.That(created.Id, Is.Not.Empty); + Assert.That(await _repo.GetWord(_projectId, created.Id), Is.Not.Null); + Assert.That(await _repo.IsInFrontier(_projectId, created.Id), Is.True); + } + + [Test] + public async Task TestCreateSingleWordClearsOriginalId() + { + var word = Util.RandomWord(_projectId); + var originalId = word.Id; + var created = await _repo.Create(word); + + Assert.That(created.Id, Is.Not.EqualTo(originalId)); + } + + [Test] + public async Task TestCreateListOfWordsAddsAll() + { + var words = Util.RandomWordList(3, _projectId); + var created = await _repo.Create(words); + + Assert.That(created, Has.Count.EqualTo(3)); + foreach (var w in created) + { + Assert.That(await _repo.GetWord(_projectId, w.Id), Is.Not.Null); + Assert.That(await _repo.IsInFrontier(_projectId, w.Id), Is.True); + } + } + + [Test] + public async Task TestCreateEmptyListReturnsEmpty() + { + var created = await _repo.Create([]); + Assert.That(created, Is.Empty); + } + + // DELETE ALL FRONTIER WORDS + + [Test] + public async Task TestDeleteAllFrontierWordsRemovesAllFrontierWords() + { + await CreateWord(); + await CreateWord(); + + var deleted = await _repo.DeleteAllFrontierWords(_projectId); + + Assert.That(deleted, Is.True); + Assert.That(await _repo.HasFrontierWords(_projectId), Is.False); + Assert.That(await _repo.HasWords(_projectId), Is.True); + } + + [Test] + public async Task TestDeleteAllFrontierWordsEmptyFrontierReturnsFalse() + { + var result = await _repo.DeleteAllFrontierWords(_projectId); + Assert.That(result, Is.False); + } + + // HAS WORDS / HAS FRONTIER WORDS + + [Test] + public async Task TestHasWordsAfterCreateReturnsTrue() + { + await CreateWord(); + Assert.That(await _repo.HasWords(_projectId), Is.True); + } + + [Test] + public async Task TestHasWordsEmptyProjectReturnsFalse() + { + Assert.That(await _repo.HasWords(_projectId), Is.False); + } + + [Test] + public async Task TestHasFrontierWordsAfterCreateReturnsTrue() + { + await CreateWord(); + Assert.That(await _repo.HasFrontierWords(_projectId), Is.True); + } + + [Test] + public async Task TestHasFrontierWordsAfterDeleteAllReturnsFalse() + { + await CreateWord(); + await _repo.DeleteAllFrontierWords(_projectId); + Assert.That(await _repo.HasFrontierWords(_projectId), Is.False); + } + + // IS IN FRONTIER / ARE IN FRONTIER + + [Test] + public async Task TestIsInFrontierExistingWordReturnsTrue() + { + var word = await CreateWord(); + Assert.That(await _repo.IsInFrontier(_projectId, word.Id), Is.True); + } + + [Test] + public async Task TestIsInFrontierNonExistentWordReturnsFalse() + { + Assert.That(await _repo.IsInFrontier(_projectId, NewObjectId()), Is.False); + } + + [Test] + public async Task TestAreInFrontierAllPresentReturnsTrue() + { + var w1 = await CreateWord(); + var w2 = await CreateWord(); + Assert.That(await _repo.AreInFrontier(_projectId, [w1.Id, w2.Id], 2), Is.True); + } + + [Test] + public async Task TestAreInFrontierPartialMatchWithLowerCountReturnsTrue() + { + var w1 = await CreateWord(); + Assert.That(await _repo.AreInFrontier(_projectId, [w1.Id, NewObjectId()], 1), Is.True); + } + + [Test] + public async Task TestAreInFrontierPartialMatchWithExactCountReturnsFalse() + { + var w1 = await CreateWord(); + Assert.That(await _repo.AreInFrontier(_projectId, [w1.Id, NewObjectId()], 2), Is.False); + } + + // GET FRONTIER COUNT + + [Test] + public async Task TestGetFrontierCountReturnsCorrectCount() + { + await CreateWord(); + await CreateWord(); + Assert.That(await _repo.GetFrontierCount(_projectId), Is.EqualTo(2)); + } + + [Test] + public async Task TestGetFrontierCountEmptyProjectReturnsZero() + { + Assert.That(await _repo.GetFrontierCount(_projectId), Is.EqualTo(0)); + } + + // GET ALL FRONTIER + + [Test] + public async Task TestGetAllFrontierReturnsAllFrontierWords() + { + var w1 = await CreateWord(); + var w2 = await CreateWord(); + var frontier = await _repo.GetAllFrontier(_projectId); + var ids = frontier.Select(w => w.Id).ToList(); + Assert.That(ids, Contains.Item(w1.Id)); + Assert.That(ids, Contains.Item(w2.Id)); + } + + // GET FRONTIER (single word) + + [Test] + public async Task TestGetFrontierExistingWordReturnsWord() + { + var word = await CreateWord(); + var retrieved = await _repo.GetFrontier(_projectId, word.Id); + Assert.That(retrieved, Is.Not.Null); + Assert.That(retrieved!.Id, Is.EqualTo(word.Id)); + } + + [Test] + public async Task TestGetFrontierNonExistentWordReturnsNull() + { + var result = await _repo.GetFrontier(_projectId, NewObjectId()); + Assert.That(result, Is.Null); + } + + [Test] + public async Task TestGetFrontierWithMatchingAudioReturnsWord() + { + var word = Util.RandomWord(_projectId); + word.Audio = [new Pronunciation { FileName = "test.mp3" }]; + word = await _repo.Create(word); + + var retrieved = await _repo.GetFrontier(_projectId, word.Id, "test.mp3"); + Assert.That(retrieved, Is.Not.Null); + } + + [Test] + public async Task TestGetFrontierWithNonMatchingAudioReturnsNull() + { + var word = Util.RandomWord(_projectId); + word.Audio = [new Pronunciation { FileName = "test.mp3" }]; + word = await _repo.Create(word); + + var retrieved = await _repo.GetFrontier(_projectId, word.Id, "other.mp3"); + Assert.That(retrieved, Is.Null); + } + + // GET FRONTIER WITH VERNACULAR + + [Test] + public async Task TestGetFrontierWithVernacularReturnsMatchingWords() + { + const string vern = "special_vern"; + await CreateWord(vernacular: vern); + await CreateWord(); + + var results = await _repo.GetFrontierWithVernacular(_projectId, vern); + Assert.That(results, Has.Count.EqualTo(1)); + Assert.That(results[0].Vernacular, Is.EqualTo(vern)); + } + + [Test] + public async Task TestGetFrontierWithVernacularNoMatchReturnsEmpty() + { + await CreateWord(vernacular: "other_vern"); + var results = await _repo.GetFrontierWithVernacular(_projectId, "nonexistent"); + Assert.That(results, Is.Empty); + } + + // ADD FRONTIER + + [Test] + public async Task TestAddFrontierAddsWordsOnlyToFrontier() + { + var word = Util.RandomWord(_projectId); + word.Id = NewObjectId(); + + var added = await _repo.AddFrontier([word]); + + Assert.That(added, Has.Count.EqualTo(1)); + Assert.That(await _repo.IsInFrontier(_projectId, word.Id), Is.True); + // Word was NOT added to the words collection + Assert.That(await _repo.GetWord(_projectId, word.Id), Is.Null); + } + + [Test] + public async Task TestAddFrontierEmptyListReturnsEmpty() + { + var result = await _repo.AddFrontier([]); + Assert.That(result, Is.Empty); + } + + // DELETE FRONTIER + + [Test] + public async Task TestDeleteFrontierRemovesFromFrontierAndArchives() + { + var created = await CreateWord(); + var deleted = await _repo.DeleteFrontier( + _projectId, created.Id, w => w.Accessibility = Status.Deleted); + + Assert.That(deleted, Is.Not.Null); + Assert.That(await _repo.IsInFrontier(_projectId, created.Id), Is.False); + // The archived word has a new ID + Assert.That(deleted!.Id, Is.Not.EqualTo(created.Id)); + Assert.That(deleted.Accessibility, Is.EqualTo(Status.Deleted)); + } + + [Test] + public async Task TestDeleteFrontierNonExistentReturnsNull() + { + var result = await _repo.DeleteFrontier(_projectId, NewObjectId(), _ => { }); + Assert.That(result, Is.Null); + } + + // RESTORE FRONTIER + + [Test] + public async Task TestRestoreFrontierRestoresWordToFrontier() + { + var word = await CreateWord(); + await _repo.DeleteFrontier(_projectId, word.Id, _ => { }); + Assert.That(await _repo.IsInFrontier(_projectId, word.Id), Is.False); + + var restored = await _repo.RestoreFrontier(_projectId, word.Id); + Assert.That(restored, Is.True); + Assert.That(await _repo.IsInFrontier(_projectId, word.Id), Is.True); + } + + [Test] + public async Task TestRestoreFrontierNotFoundReturnsFalse() + { + var result = await _repo.RestoreFrontier(_projectId, NewObjectId()); + Assert.That(result, Is.False); + } + + [Test] + public async Task TestRestoreFrontierAlreadyInFrontierThrows() + { + var word = await CreateWord(); + // Word is already in frontier; restoring it again should throw. + var ex = Assert.ThrowsAsync(() => + _repo.RestoreFrontier(_projectId, word.Id)); + Assert.That(ex, Is.Not.Null); + } + + [Test] + public async Task TestRestoreFrontierDeletedWordThrows() + { + // Create a word and archive it as Deleted + var word = await CreateWord(); + var archivedWord = await _repo.DeleteFrontier( + _projectId, word.Id, w => w.Accessibility = Status.Deleted); + Assert.That(archivedWord, Is.Not.Null); + + var ex = Assert.ThrowsAsync(() => + _repo.RestoreFrontier(_projectId, archivedWord!.Id)); + Assert.That(ex, Is.Not.Null); + } + + // UPDATE FRONTIER (by projectId, wordId, modifyWord) + + [Test] + public async Task TestUpdateFrontierByIdsUpdatesWord() + { + var created = await CreateWord(); + const string newVernacular = "updated_vernacular"; + + var updated = await _repo.UpdateFrontier(_projectId, created.Id, w => w.Vernacular = newVernacular); + + Assert.That(updated, Is.Not.Null); + Assert.That(updated!.Vernacular, Is.EqualTo(newVernacular)); + Assert.That(updated.Id, Is.Not.EqualTo(created.Id)); + Assert.That(await _repo.IsInFrontier(_projectId, created.Id), Is.False); + Assert.That(await _repo.IsInFrontier(_projectId, updated.Id), Is.True); + } + + [Test] + public async Task TestUpdateFrontierByIdsNonExistentReturnsNull() + { + var result = await _repo.UpdateFrontier(_projectId, NewObjectId(), _ => { }); + Assert.That(result, Is.Null); + } + + // UPDATE FRONTIER (by word and action) + + [Test] + public async Task TestUpdateFrontierByWordUpdatesWord() + { + var created = await CreateWord(); + var updatedWord = created.Clone(); + updatedWord.Vernacular = "new_vernacular"; + + var result = await _repo.UpdateFrontier(updatedWord, (newWord, oldWord) => + { + Assert.That(oldWord, Is.Not.Null); + Assert.That(oldWord!.Id, Is.EqualTo(created.Id)); + newWord.History = [created.Id]; + }); + + Assert.That(result, Is.Not.Null); + Assert.That(result!.History, Contains.Item(created.Id)); + Assert.That(await _repo.IsInFrontier(_projectId, created.Id), Is.False); + Assert.That(await _repo.IsInFrontier(_projectId, result.Id), Is.True); + } + + [Test] + public async Task TestUpdateFrontierByWordNotInFrontierReturnsNull() + { + var word = Util.RandomWord(_projectId); + word.Id = NewObjectId(); + var result = await _repo.UpdateFrontier(word, (_, _) => { }); + Assert.That(result, Is.Null); + } + + // REPLACE FRONTIER + + [Test] + public async Task TestReplaceFrontierUpdatesAndDeletesWords() + { + var toUpdate = await CreateWord(); + var toDelete = await CreateWord(); + + var updatedWord = toUpdate.Clone(); + updatedWord.Vernacular = "replaced"; + string? capturedOldId = null; + + var result = await _repo.ReplaceFrontier( + _projectId, + newWords: [updatedWord], + idsToDelete: [toUpdate.Id, toDelete.Id], + modifyUpdatedWord: (newWord, oldWord) => + { + capturedOldId = oldWord?.Id; + newWord.History = [oldWord?.Id ?? ""]; + }, + modifyDeletedWord: w => w.Accessibility = Status.Deleted); + + Assert.That(result, Is.Not.Null); + Assert.That(capturedOldId, Is.EqualTo(toUpdate.Id)); + Assert.That(await _repo.IsInFrontier(_projectId, toUpdate.Id), Is.False); + Assert.That(await _repo.IsInFrontier(_projectId, toDelete.Id), Is.False); + } + + [Test] + public async Task TestReplaceFrontierEmptyListsReturnsEmpty() + { + var result = await _repo.ReplaceFrontier(_projectId, [], [], (_, _) => { }, _ => { }); + Assert.That(result, Is.Not.Null); + Assert.That(result, Is.Empty); + } + + [Test] + public async Task TestReplaceFrontierCreatesWordNotInFrontier() + { + var newWord = Util.RandomWord(_projectId); + newWord.Id = NewObjectId(); + + var result = await _repo.ReplaceFrontier( + _projectId, [newWord], [], (_, _) => { }, _ => { }); + + Assert.That(result, Is.Not.Null); + Assert.That(result, Has.Count.EqualTo(1)); + Assert.That(await _repo.IsInFrontier(_projectId, result![0].Id), Is.True); + } + + // REVERT REPLACE FRONTIER + + [Test] + public async Task TestRevertReplaceFrontierRestoresAndDeletes() + { + var toRestore = await CreateWord(); + var toDelete = await CreateWord(); + + // Remove toRestore from frontier so it can be restored later + await _repo.DeleteFrontier(_projectId, toRestore.Id, _ => { }); + + var result = await _repo.RevertReplaceFrontier( + _projectId, + idsToRestore: [toRestore.Id], + idsToDelete: [toDelete.Id], + modifyDeletedWord: w => w.Accessibility = Status.Deleted); + + Assert.That(result, Is.True); + Assert.That(await _repo.IsInFrontier(_projectId, toRestore.Id), Is.True); + Assert.That(await _repo.IsInFrontier(_projectId, toDelete.Id), Is.False); + } + + [Test] + public async Task TestRevertReplaceFrontierEmptyListsReturnsTrue() + { + var result = await _repo.RevertReplaceFrontier(_projectId, [], [], _ => { }); + Assert.That(result, Is.True); + } + + [Test] + public async Task TestRevertReplaceFrontierOverlappingIdsThrows() + { + var word = await CreateWord(); + var ex = Assert.ThrowsAsync(() => + _repo.RevertReplaceFrontier(_projectId, [word.Id], [word.Id], _ => { })); + Assert.That(ex, Is.Not.Null); + } + + // COUNT FRONTIER WORDS WITH DOMAIN + + [Test] + public async Task TestCountFrontierWordsWithDomainReturnsCorrectCount() + { + const string domainId = "1.1"; + await CreateWord(domainId: domainId); + await CreateWord(domainId: domainId); + await CreateWord(); + + var count = await _repo.CountFrontierWordsWithDomain(_projectId, domainId); + Assert.That(count, Is.EqualTo(2)); + } + + [Test] + public async Task TestCountFrontierWordsWithDomainNoneMatchReturnsZero() + { + await CreateWord(); + var count = await _repo.CountFrontierWordsWithDomain(_projectId, "99.99"); + Assert.That(count, Is.EqualTo(0)); + } + } +} diff --git a/package.json b/package.json index 37287230e6..f885d92a8c 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,8 @@ "test": "run-s test-backend test-frontend", "test-backend": " dotnet test Backend.Tests/Backend.Tests.csproj", "test-backend:coverage": "dotnet test Backend.Tests/Backend.Tests.csproj /p:CollectCoverage=true /p:CoverletOutputFormat=lcov /p:Threshold=77", + "test:integration": "dotnet test Backend.Tests/Backend.Tests.csproj --filter \"TestCategory=IntegrationTest\"", + "test:unit": "dotnet test Backend.Tests/Backend.Tests.csproj --filter \"TestCategory!=IntegrationTest\"", "test-frontend": " node scripts/jestTest.js", "test-frontend:coverage": "node scripts/jestTest.js --coverage --watchAll=false", "test-frontend:debug": " node --inspect-brk scripts/jestTest.js --runInBand --no-cache",