From 74c39971035307e56c181146abe9797b9cfec6d8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Feb 2026 14:10:29 +0000 Subject: [PATCH 01/15] Convert MongoDB from standalone to replica set --- .gitignore | 2 +- .vscode/tasks.json | 4 +- Backend/appsettings.json | 4 +- README.md | 2 +- database/Dockerfile | 20 +- database/init/00-replica-set.js | 81 ++++++++ .../charts/database/templates/database.yaml | 34 ++++ docs/deploy/README.md | 2 +- package.json | 3 +- scripts/setupMongo.js | 7 - scripts/startDatabase.js | 173 ++++++++++++++++++ 11 files changed, 306 insertions(+), 26 deletions(-) create mode 100644 database/init/00-replica-set.js delete mode 100644 scripts/setupMongo.js create mode 100644 scripts/startDatabase.js 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/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..571a3667a2 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", 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); +}); From 0dc62557abb6df77744b94b193403e5933667c5b Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Tue, 3 Mar 2026 17:19:44 -0500 Subject: [PATCH 02/15] Use MongoDB transactions --- .../Controllers/AudioControllerTests.cs | 15 +- .../Controllers/LiftControllerTests.cs | 8 +- .../Controllers/WordControllerTests.cs | 14 +- Backend.Tests/Mocks/MongoDbContextMock.cs | 36 ++ Backend.Tests/Mocks/WordRepositoryMock.cs | 213 +++++-- Backend.Tests/Services/MergeServiceTests.cs | 20 +- .../Services/StatisticsServiceTests.cs | 2 +- Backend.Tests/Services/WordServiceTests.cs | 286 ++++++++-- Backend/Contexts/MongoDbContext.cs | 102 +++- Backend/Controllers/LiftController.cs | 6 +- Backend/Controllers/MergeController.cs | 8 +- Backend/Controllers/WordController.cs | 10 +- Backend/Interfaces/ILiftService.cs | 2 +- Backend/Interfaces/IMongoDbContext.cs | 44 +- Backend/Interfaces/IWordRepository.cs | 14 +- Backend/Interfaces/IWordService.cs | 10 +- Backend/Repositories/WordRepository.cs | 526 ++++++++++++++---- Backend/Services/LiftService.cs | 12 +- Backend/Services/MergeService.cs | 42 +- Backend/Services/WordService.cs | 271 +++++---- Backend/Startup.cs | 3 +- src/api/api/merge-api.ts | 4 +- src/api/api/word-api.ts | 4 +- src/backend/index.ts | 9 +- 24 files changed, 1253 insertions(+), 408 deletions(-) create mode 100644 Backend.Tests/Mocks/MongoDbContextMock.cs 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..58c913d0c8 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,7 +123,16 @@ 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); @@ -144,23 +144,166 @@ public Task> AddFrontier(List words) 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/Services/MergeServiceTests.cs b/Backend.Tests/Services/MergeServiceTests.cs index f3c6366eeb..b91a2facdf 100644 --- a/Backend.Tests/Services/MergeServiceTests.cs +++ b/Backend.Tests/Services/MergeServiceTests.cs @@ -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))); } @@ -349,7 +349,7 @@ public void RemoveFromMergeGraylistTest() _ = _mergeService.AddToMergeGraylist(ProjId, UserId, wordIds).Result; 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] 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..f72d6d3b1d 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,15 @@ 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..d12e2216b0 100644 --- a/Backend/Repositories/WordRepository.cs +++ b/Backend/Repositories/WordRepository.cs @@ -3,7 +3,6 @@ using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Threading.Tasks; -using BackendFramework.Helper; using BackendFramework.Interfaces; using BackendFramework.Models; using BackendFramework.Otel; @@ -15,15 +14,21 @@ namespace BackendFramework.Repositories [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"; + // 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 +39,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 +49,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 +62,20 @@ 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)); } + // 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,17 +164,23 @@ 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"); @@ -228,6 +190,8 @@ public async Task AreInFrontier(string projectId, List wordIds, in } /// 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 +200,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 +210,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 +224,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 +236,143 @@ 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) - { - using var activity = OtelService.StartActivityWithTag(otelTagName, "adding a word to Frontier"); - - await _frontier.InsertOneAsync(word); - return word; - } - /// Adds a list of s only to the Frontier - /// - /// The words created + /// Words to add to Frontier. + /// The words created public async Task> AddFrontier(List words) { using var activity = OtelService.StartActivityWithTag(otelTagName, "adding words to Frontier"); + if (words.Count == 0) + { + return words; + } + await _frontier.InsertManyAsync(words); return words; } - /// 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) + /// + /// 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, "deleting a word from Frontier"); + using var activity = OtelService.StartActivityWithTag( + otelTagName, "adding word to WordsCollection, deleting 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 DeleteFrontierWithSession(s, projectId, wordId, modifyDeletedWord) + ); + } + + /// 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. + public async Task RestoreFrontier(string projectId, string wordId) + { + using var activity = OtelService.StartActivityWithTag(otelTagName, "restoring word to Frontier"); + + return await _dbContext.ExecuteInTransaction( + async s => await RestoreFrontierWithSession(s, projectId, wordId)); + } + + /// + /// 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, "updating a word in WordsCollection and Frontier, deleting old word from Frontier"); + + 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. + 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 +384,220 @@ public async Task CountFrontierWordsWithDomain(string projectId, string dom return (int)await _frontier.CountDocumentsAsync(filter); } + + // 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; + } } } 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..ac135aeb2f 100644 --- a/Backend/Services/MergeService.cs +++ b/Backend/Services/MergeService.cs @@ -139,28 +139,7 @@ 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 @@ -169,24 +148,7 @@ public async Task UndoMerge(string projectId, string userId, MergeUndoIds { 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..c8635469c8 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,188 @@ 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) + { + if (string.IsNullOrEmpty(word.Created)) + { + // Use Roundtrip-suitable ISO 8601 format. + word.Created = Time.UtcNowIso8601(); + } + if (updateModified || string.IsNullOrEmpty(word.Modified)) + { + word.Modified = Time.UtcNowIso8601(); + } + 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 new words with updated edited data. - /// The created word - public async Task> Create(string userId, List words) + /// + /// 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(newWord.Id)) + { + newWord.History.Add(newWord.Id); + } + + // Preserve Created time. + 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; + }; + + /// 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. + 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 +207,41 @@ 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. + 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. + 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..b66ace7d15 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.CreateAsyncScope(); + var userRepo = startupScope.ServiceProvider.GetService(); if (userRepo is not null && CreateAdminUser(userRepo)) { _logger.LogInformation("Stopping application"); 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: From ec53acb166bac7d5d644015c2415701aaecaf207 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Mar 2026 11:16:25 +0000 Subject: [PATCH 03/15] Add WordRepository integration tests --- Backend.Tests/Backend.Tests.csproj | 3 + .../Repositories/MongoDbTestRunner.cs | 213 +++++++ .../Repositories/WordRepositoryTests.cs | 593 ++++++++++++++++++ Backend/Repositories/WordRepository.cs | 2 - package.json | 2 + 5 files changed, 811 insertions(+), 2 deletions(-) create mode 100644 Backend.Tests/Repositories/MongoDbTestRunner.cs create mode 100644 Backend.Tests/Repositories/WordRepositoryTests.cs diff --git a/Backend.Tests/Backend.Tests.csproj b/Backend.Tests/Backend.Tests.csproj index ab67ca9f59..f266389a4b 100644 --- a/Backend.Tests/Backend.Tests.csproj +++ b/Backend.Tests/Backend.Tests.csproj @@ -13,6 +13,9 @@ $(NoWarn);CA1305;CA1859;CS1591 + + + diff --git a/Backend.Tests/Repositories/MongoDbTestRunner.cs b/Backend.Tests/Repositories/MongoDbTestRunner.cs new file mode 100644 index 0000000000..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/Backend/Repositories/WordRepository.cs b/Backend/Repositories/WordRepository.cs index d12e2216b0..dd6b02d99e 100644 --- a/Backend/Repositories/WordRepository.cs +++ b/Backend/Repositories/WordRepository.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Threading.Tasks; using BackendFramework.Interfaces; @@ -11,7 +10,6 @@ namespace BackendFramework.Repositories { /// Atomic database functions for s. - [ExcludeFromCodeCoverage] public class WordRepository(IMongoDbContext dbContext) : IWordRepository { private readonly IMongoDbContext _dbContext = dbContext; diff --git a/package.json b/package.json index 571a3667a2..64bbcf5170 100644 --- a/package.json +++ b/package.json @@ -31,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", From d6f1e64191a1478cef0eda41bb5b93bd9c135348 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Mon, 16 Mar 2026 09:39:24 -0400 Subject: [PATCH 04/15] Expand coverage --- .../Repositories/WordRepositoryTests.cs | 62 ++++++++++++++++--- 1 file changed, 53 insertions(+), 9 deletions(-) diff --git a/Backend.Tests/Repositories/WordRepositoryTests.cs b/Backend.Tests/Repositories/WordRepositoryTests.cs index aa9f0acc37..752f6f52c8 100644 --- a/Backend.Tests/Repositories/WordRepositoryTests.cs +++ b/Backend.Tests/Repositories/WordRepositoryTests.cs @@ -99,7 +99,7 @@ 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)); + Assert.That(retrieved.Id, Is.EqualTo(created.Id)); } [Test] @@ -279,7 +279,7 @@ 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)); + Assert.That(retrieved.Id, Is.EqualTo(word.Id)); } [Test] @@ -368,7 +368,7 @@ public async Task TestDeleteFrontierRemovesFromFrontierAndArchives() 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.Id, Is.Not.EqualTo(created.Id)); Assert.That(deleted.Accessibility, Is.EqualTo(Status.Deleted)); } @@ -419,8 +419,7 @@ public async Task TestRestoreFrontierDeletedWordThrows() _projectId, word.Id, w => w.Accessibility = Status.Deleted); Assert.That(archivedWord, Is.Not.Null); - var ex = Assert.ThrowsAsync(() => - _repo.RestoreFrontier(_projectId, archivedWord!.Id)); + var ex = Assert.ThrowsAsync(() => _repo.RestoreFrontier(_projectId, archivedWord.Id)); Assert.That(ex, Is.Not.Null); } @@ -435,7 +434,7 @@ public async Task TestUpdateFrontierByIdsUpdatesWord() 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.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); @@ -460,12 +459,12 @@ public async Task TestUpdateFrontierByWordUpdatesWord() var result = await _repo.UpdateFrontier(updatedWord, (newWord, oldWord) => { Assert.That(oldWord, Is.Not.Null); - Assert.That(oldWord!.Id, Is.EqualTo(created.Id)); + 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(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); } @@ -527,7 +526,27 @@ public async Task TestReplaceFrontierCreatesWordNotInFrontier() Assert.That(result, Is.Not.Null); Assert.That(result, Has.Count.EqualTo(1)); - Assert.That(await _repo.IsInFrontier(_projectId, result![0].Id), Is.True); + Assert.That(await _repo.IsInFrontier(_projectId, result[0].Id), Is.True); + } + + [Test] + public void TestReplaceFrontierDifferentProjectIdThrows() + { + var newWord = Util.RandomWord(Guid.NewGuid().ToString()); + + var ex = Assert.ThrowsAsync(() => + _repo.ReplaceFrontier(_projectId, [newWord], [], (_, _) => { }, _ => { })); + + Assert.That(ex, Is.Not.Null); + } + + [Test] + public void TestReplaceFrontierMissingDeleteIdThrows() + { + var ex = Assert.ThrowsAsync(() => + _repo.ReplaceFrontier(_projectId, [], [NewObjectId()], (_, _) => { }, _ => { })); + + Assert.That(ex, Is.Not.Null); } // REVERT REPLACE FRONTIER @@ -559,6 +578,31 @@ public async Task TestRevertReplaceFrontierEmptyListsReturnsTrue() Assert.That(result, Is.True); } + [Test] + public async Task TestRevertReplaceFrontierMissingDeleteIdReturnsFalseAndAborts() + { + var toRestore = await CreateWord(); + await _repo.DeleteFrontier(_projectId, toRestore.Id, _ => { }); + + var result = await _repo.RevertReplaceFrontier( + _projectId, idsToRestore: [toRestore.Id], idsToDelete: [NewObjectId()], modifyDeletedWord: _ => { }); + + Assert.That(result, Is.False); + Assert.That(await _repo.IsInFrontier(_projectId, toRestore.Id), Is.False); + } + + [Test] + public async Task TestRevertReplaceFrontierMissingRestoreIdReturnsFalseAndAborts() + { + var toDelete = await CreateWord(); + + var result = await _repo.RevertReplaceFrontier(_projectId, idsToRestore: [NewObjectId()], + idsToDelete: [toDelete.Id], modifyDeletedWord: w => w.Accessibility = Status.Deleted); + + Assert.That(result, Is.False); + Assert.That(await _repo.IsInFrontier(_projectId, toDelete.Id), Is.True); + } + [Test] public async Task TestRevertReplaceFrontierOverlappingIdsThrows() { From d45790167d36811e9e61692838d33a02aa534b58 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Mon, 16 Mar 2026 09:39:47 -0400 Subject: [PATCH 05/15] Don't add created after modified --- Backend/Services/WordService.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Backend/Services/WordService.cs b/Backend/Services/WordService.cs index c8635469c8..5da6d246c5 100644 --- a/Backend/Services/WordService.cs +++ b/Backend/Services/WordService.cs @@ -27,14 +27,15 @@ public class WordService(IWordRepository wordRepo) : IWordService /// 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)) { - // Use Roundtrip-suitable ISO 8601 format. - word.Created = Time.UtcNowIso8601(); + word.Created = string.IsNullOrEmpty(word.Modified) ? now : word.Modified; } if (updateModified || string.IsNullOrEmpty(word.Modified)) { - word.Modified = Time.UtcNowIso8601(); + word.Modified = now; } return word; } From 60a758218715260f9e2e053b7512d2c67863daa7 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Mon, 16 Mar 2026 10:22:31 -0400 Subject: [PATCH 06/15] Fix word update history --- Backend/Services/WordService.cs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/Backend/Services/WordService.cs b/Backend/Services/WordService.cs index 5da6d246c5..75404cbcde 100644 --- a/Backend/Services/WordService.cs +++ b/Backend/Services/WordService.cs @@ -109,13 +109,16 @@ private static Action CreateModifyDeletedWordAction(string userId) => } // Add Id to history. - if (!newWord.History.Contains(newWord.Id)) + if (!newWord.History.Contains(oldWord.Id)) { - newWord.History.Add(newWord.Id); + newWord.History.Add(oldWord.Id); } // Preserve Created time. - newWord.Created = oldWord.Created; + 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. From a4a7b31020ad4428536b5c3b62ce32df71453bc0 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Mon, 16 Mar 2026 10:45:31 -0400 Subject: [PATCH 07/15] Expand abort-transaction coverage; Harden tests against mutation --- .../Repositories/WordRepositoryTests.cs | 263 ++++++++++++++---- 1 file changed, 206 insertions(+), 57 deletions(-) diff --git a/Backend.Tests/Repositories/WordRepositoryTests.cs b/Backend.Tests/Repositories/WordRepositoryTests.cs index 752f6f52c8..d928671ada 100644 --- a/Backend.Tests/Repositories/WordRepositoryTests.cs +++ b/Backend.Tests/Repositories/WordRepositoryTests.cs @@ -255,7 +255,7 @@ public async Task TestGetFrontierCountReturnsCorrectCount() [Test] public async Task TestGetFrontierCountEmptyProjectReturnsZero() { - Assert.That(await _repo.GetFrontierCount(_projectId), Is.EqualTo(0)); + Assert.That(await _repo.GetFrontierCount(_projectId), Is.Zero); } // GET ALL FRONTIER @@ -339,14 +339,14 @@ public async Task TestGetFrontierWithVernacularNoMatchReturnsEmpty() public async Task TestAddFrontierAddsWordsOnlyToFrontier() { var word = Util.RandomWord(_projectId); - word.Id = NewObjectId(); + var newId = NewObjectId(); + word.Id = newId; 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); + Assert.That(await _repo.IsInFrontier(_projectId, newId), Is.True); + Assert.That(await _repo.GetWord(_projectId, newId), Is.Null); } [Test] @@ -362,13 +362,13 @@ public async Task TestAddFrontierEmptyListReturnsEmpty() public async Task TestDeleteFrontierRemovesFromFrontierAndArchives() { var created = await CreateWord(); - var deleted = await _repo.DeleteFrontier( - _projectId, created.Id, w => w.Accessibility = Status.Deleted); + 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, created.Id), Is.False); - // The archived word has a new ID - Assert.That(deleted.Id, Is.Not.EqualTo(created.Id)); + 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)); } @@ -379,18 +379,41 @@ public async Task TestDeleteFrontierNonExistentReturnsNull() Assert.That(result, Is.Null); } + [Test] + public async Task TestDeleteFrontierModifyActionThrowsLeavesRepoUnchanged() + { + var created = await CreateWord(); + var createdId = created.Id; + + var ex = Assert.ThrowsAsync(() => + _repo.DeleteFrontier(_projectId, createdId, w => + { + w.Accessibility = Status.Deleted; + throw new InvalidOperationException(); + })); + + Assert.That(ex, Is.Not.Null); + 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)); + } + // 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 wordId = word.Id; + await _repo.DeleteFrontier(_projectId, wordId, _ => { }); + Assert.That(await _repo.IsInFrontier(_projectId, wordId), Is.False); - var restored = await _repo.RestoreFrontier(_projectId, word.Id); + var restored = await _repo.RestoreFrontier(_projectId, wordId); Assert.That(restored, Is.True); - Assert.That(await _repo.IsInFrontier(_projectId, word.Id), Is.True); + Assert.That(await _repo.IsInFrontier(_projectId, wordId), Is.True); } [Test] @@ -405,8 +428,7 @@ 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)); + var ex = Assert.ThrowsAsync(() => _repo.RestoreFrontier(_projectId, word.Id)); Assert.That(ex, Is.Not.Null); } @@ -415,8 +437,7 @@ 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); + 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)); @@ -429,14 +450,15 @@ public async Task TestRestoreFrontierDeletedWordThrows() public async Task TestUpdateFrontierByIdsUpdatesWord() { var created = await CreateWord(); + var createdId = created.Id; const string newVernacular = "updated_vernacular"; - var updated = await _repo.UpdateFrontier(_projectId, created.Id, w => w.Vernacular = newVernacular); + 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(created.Id)); - Assert.That(await _repo.IsInFrontier(_projectId, created.Id), Is.False); + 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); } @@ -447,35 +469,85 @@ public async Task TestUpdateFrontierByIdsNonExistentReturnsNull() Assert.That(result, Is.Null); } + [Test] + public async Task TestUpdateFrontierByIdsModifyActionThrowsLeavesRepoUnchanged() + { + var created = await CreateWord(); + var createdId = created.Id; + const string newVernacular = "should_not_persist"; + + var ex = Assert.ThrowsAsync(() => + _repo.UpdateFrontier(_projectId, createdId, w => + { + w.Vernacular = newVernacular; + throw new InvalidOperationException(); + })); + + Assert.That(ex, Is.Not.Null); + 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)); + } + // UPDATE FRONTIER (by word and action) [Test] public async Task TestUpdateFrontierByWordUpdatesWord() { var created = await CreateWord(); + var createdId = created.Id; + const string newVernacular = "updated_vernacular"; var updatedWord = created.Clone(); - updatedWord.Vernacular = "new_vernacular"; + updatedWord.Vernacular = newVernacular; 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]; + newWord.History = [oldWord.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(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 TestUpdateFrontierByWordNotInFrontierReturnsNull() + 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; + + var ex = Assert.ThrowsAsync(() => + _repo.UpdateFrontier(updatedWord, (_, _) => throw new InvalidOperationException())); + + Assert.That(ex, Is.Not.Null); + 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)); } // REPLACE FRONTIER @@ -484,27 +556,36 @@ public async Task TestUpdateFrontierByWordNotInFrontierReturnsNull() 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 = "replaced"; - string? capturedOldId = null; + updatedWord.Vernacular = newVernacular; - var result = await _repo.ReplaceFrontier( - _projectId, - newWords: [updatedWord], - idsToDelete: [toUpdate.Id, toDelete.Id], + var result = await _repo.ReplaceFrontier(_projectId, [updatedWord], [toUpdateId, toDeleteId], modifyUpdatedWord: (newWord, oldWord) => { - capturedOldId = oldWord?.Id; - newWord.History = [oldWord?.Id ?? ""]; + Assert.That(oldWord, Is.Not.Null); + 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); + 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] @@ -521,14 +602,54 @@ public async Task TestReplaceFrontierCreatesWordNotInFrontier() var newWord = Util.RandomWord(_projectId); newWord.Id = NewObjectId(); - var result = await _repo.ReplaceFrontier( - _projectId, [newWord], [], (_, _) => { }, _ => { }); + 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; + + var ex = Assert.ThrowsAsync(() => _repo.ReplaceFrontier(_projectId, + [updatedWord], [toUpdateId, toDeleteId], (_, _) => throw new InvalidOperationException(), _ => { })); + + Assert.That(ex, Is.Not.Null); + 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; + + var ex = Assert.ThrowsAsync(() => _repo.ReplaceFrontier( + _projectId, [], [toDeleteId], (_, _) => { }, _ => throw new InvalidOperationException())); + + Assert.That(ex, Is.Not.Null); + 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() { @@ -555,20 +676,19 @@ public void TestReplaceFrontierMissingDeleteIdThrows() 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, toRestore.Id, _ => { }); + await _repo.DeleteFrontier(_projectId, toRestoreId, _ => { }); var result = await _repo.RevertReplaceFrontier( - _projectId, - idsToRestore: [toRestore.Id], - idsToDelete: [toDelete.Id], - modifyDeletedWord: w => w.Accessibility = Status.Deleted); + _projectId, [toRestoreId], [toDeleteId], 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); + Assert.That(await _repo.IsInFrontier(_projectId, toRestoreId), Is.True); + Assert.That(await _repo.IsInFrontier(_projectId, toDeleteId), Is.False); } [Test] @@ -579,28 +699,57 @@ public async Task TestRevertReplaceFrontierEmptyListsReturnsTrue() } [Test] - public async Task TestRevertReplaceFrontierMissingDeleteIdReturnsFalseAndAborts() + public async Task TestRevertReplaceFrontierMissingDeleteIdReturnsFalseAndLeavesRepoUnchanged() { var toRestore = await CreateWord(); - await _repo.DeleteFrontier(_projectId, toRestore.Id, _ => { }); + var toRestoreId = toRestore.Id; + var toDelete = await CreateWord(); + var toDeleteId = toDelete.Id; + await _repo.DeleteFrontier(_projectId, toRestoreId, _ => { }); var result = await _repo.RevertReplaceFrontier( - _projectId, idsToRestore: [toRestore.Id], idsToDelete: [NewObjectId()], modifyDeletedWord: _ => { }); + _projectId, [toRestoreId], [toDeleteId, NewObjectId()], _ => { }); Assert.That(result, Is.False); - Assert.That(await _repo.IsInFrontier(_projectId, toRestore.Id), Is.False); + Assert.That(await _repo.IsInFrontier(_projectId, toRestoreId), Is.False); + Assert.That(await _repo.IsInFrontier(_projectId, toDeleteId), Is.True); } [Test] - public async Task TestRevertReplaceFrontierMissingRestoreIdReturnsFalseAndAborts() + 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, idsToRestore: [NewObjectId()], - idsToDelete: [toDelete.Id], modifyDeletedWord: w => w.Accessibility = Status.Deleted); + var result = await _repo.RevertReplaceFrontier( + _projectId, [toRestoreId, NewObjectId()], [toDeleteId], _ => { }); Assert.That(result, Is.False); - Assert.That(await _repo.IsInFrontier(_projectId, toDelete.Id), Is.True); + 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, _ => { }); + + var ex = Assert.ThrowsAsync(() => _repo.RevertReplaceFrontier( + _projectId, [toRestoreId], [toDeleteId], _ => throw new InvalidOperationException())); + + Assert.That(ex, Is.Not.Null); + 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] @@ -631,7 +780,7 @@ public async Task TestCountFrontierWordsWithDomainNoneMatchReturnsZero() { await CreateWord(); var count = await _repo.CountFrontierWordsWithDomain(_projectId, "99.99"); - Assert.That(count, Is.EqualTo(0)); + Assert.That(count, Is.Zero); } } } From 1c670d783fc8582ee70f519e474d1befae23b0c9 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Mon, 16 Mar 2026 12:28:23 -0400 Subject: [PATCH 08/15] Make teardown null-safe --- Backend.Tests/Repositories/WordRepositoryTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Backend.Tests/Repositories/WordRepositoryTests.cs b/Backend.Tests/Repositories/WordRepositoryTests.cs index d928671ada..ce0a389b00 100644 --- a/Backend.Tests/Repositories/WordRepositoryTests.cs +++ b/Backend.Tests/Repositories/WordRepositoryTests.cs @@ -31,7 +31,7 @@ public static void StartMongo() [OneTimeTearDown] public static void StopMongo() { - _runner.Dispose(); + _runner?.Dispose(); } [SetUp] From 4650e7e0c1109fbe6ab09ce9172f2bbe3efca703 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Mon, 16 Mar 2026 12:33:31 -0400 Subject: [PATCH 09/15] Remove redundant asserts --- .../Repositories/WordRepositoryTests.cs | 47 +++++++------------ 1 file changed, 16 insertions(+), 31 deletions(-) diff --git a/Backend.Tests/Repositories/WordRepositoryTests.cs b/Backend.Tests/Repositories/WordRepositoryTests.cs index ce0a389b00..298a847ad3 100644 --- a/Backend.Tests/Repositories/WordRepositoryTests.cs +++ b/Backend.Tests/Repositories/WordRepositoryTests.cs @@ -385,14 +385,13 @@ public async Task TestDeleteFrontierModifyActionThrowsLeavesRepoUnchanged() var created = await CreateWord(); var createdId = created.Id; - var ex = Assert.ThrowsAsync(() => + Assert.ThrowsAsync(() => _repo.DeleteFrontier(_projectId, createdId, w => { w.Accessibility = Status.Deleted; throw new InvalidOperationException(); })); - Assert.That(ex, Is.Not.Null); Assert.That((await _repo.GetAllWords(_projectId)).Select(w => w.Id), Is.EqualTo([createdId])); Assert.That(await _repo.GetFrontierCount(_projectId), Is.EqualTo(1)); @@ -427,9 +426,7 @@ public async Task TestRestoreFrontierNotFoundReturnsFalse() 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); + Assert.ThrowsAsync(() => _repo.RestoreFrontier(_projectId, word.Id)); } [Test] @@ -440,8 +437,7 @@ public async Task TestRestoreFrontierDeletedWordThrows() 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); + Assert.ThrowsAsync(() => _repo.RestoreFrontier(_projectId, archivedWord.Id)); } // UPDATE FRONTIER (by projectId, wordId, modifyWord) @@ -476,14 +472,13 @@ public async Task TestUpdateFrontierByIdsModifyActionThrowsLeavesRepoUnchanged() var createdId = created.Id; const string newVernacular = "should_not_persist"; - var ex = Assert.ThrowsAsync(() => - _repo.UpdateFrontier(_projectId, createdId, w => - { - w.Vernacular = newVernacular; - throw new InvalidOperationException(); - })); + Assert.ThrowsAsync(() => + _repo.UpdateFrontier(_projectId, createdId, w => + { + w.Vernacular = newVernacular; + throw new InvalidOperationException(); + })); - Assert.That(ex, Is.Not.Null); Assert.That((await _repo.GetAllWords(_projectId)).Select(w => w.Id), Is.EqualTo([createdId])); Assert.That(await _repo.GetFrontierCount(_projectId), Is.EqualTo(1)); @@ -538,10 +533,9 @@ public async Task TestUpdateFrontierByWordModifyActionThrowsLeavesRepoUnchanged( var updatedWord = created.Clone(); updatedWord.Vernacular = newVernacular; - var ex = Assert.ThrowsAsync(() => + Assert.ThrowsAsync(() => _repo.UpdateFrontier(updatedWord, (_, _) => throw new InvalidOperationException())); - Assert.That(ex, Is.Not.Null); Assert.That((await _repo.GetAllWords(_projectId)).Select(w => w.Id), Is.EqualTo([createdId])); Assert.That(await _repo.GetFrontierCount(_projectId), Is.EqualTo(1)); @@ -620,10 +614,9 @@ public async Task TestReplaceFrontierModifyUpdatedActionThrowsLeavesRepoUnchange var updatedWord = toUpdate.Clone(); updatedWord.Vernacular = newVernacular; - var ex = Assert.ThrowsAsync(() => _repo.ReplaceFrontier(_projectId, + Assert.ThrowsAsync(() => _repo.ReplaceFrontier(_projectId, [updatedWord], [toUpdateId, toDeleteId], (_, _) => throw new InvalidOperationException(), _ => { })); - Assert.That(ex, Is.Not.Null); Assert.That((await _repo.GetAllWords(_projectId)).Select(w => w.Id), Is.EquivalentTo([toUpdateId, toDeleteId])); @@ -640,10 +633,9 @@ public async Task TestReplaceFrontierModifyDeletedActionThrowsLeavesRepoUnchange var toDelete = await CreateWord(); var toDeleteId = toDelete.Id; - var ex = Assert.ThrowsAsync(() => _repo.ReplaceFrontier( + Assert.ThrowsAsync(() => _repo.ReplaceFrontier( _projectId, [], [toDeleteId], (_, _) => { }, _ => throw new InvalidOperationException())); - Assert.That(ex, Is.Not.Null); Assert.That((await _repo.GetAllWords(_projectId)).Select(w => w.Id), Is.EqualTo([toDeleteId])); Assert.That(await _repo.GetFrontierCount(_projectId), Is.EqualTo(1)); @@ -654,20 +646,15 @@ public async Task TestReplaceFrontierModifyDeletedActionThrowsLeavesRepoUnchange public void TestReplaceFrontierDifferentProjectIdThrows() { var newWord = Util.RandomWord(Guid.NewGuid().ToString()); - - var ex = Assert.ThrowsAsync(() => + Assert.ThrowsAsync(() => _repo.ReplaceFrontier(_projectId, [newWord], [], (_, _) => { }, _ => { })); - - Assert.That(ex, Is.Not.Null); } [Test] public void TestReplaceFrontierMissingDeleteIdThrows() { - var ex = Assert.ThrowsAsync(() => + Assert.ThrowsAsync(() => _repo.ReplaceFrontier(_projectId, [], [NewObjectId()], (_, _) => { }, _ => { })); - - Assert.That(ex, Is.Not.Null); } // REVERT REPLACE FRONTIER @@ -741,10 +728,9 @@ public async Task TestRevertReplaceFrontierModifyDeletedActionThrowsLeavesRepoUn var toDeleteId = toDelete.Id; await _repo.DeleteFrontier(_projectId, toRestoreId, _ => { }); - var ex = Assert.ThrowsAsync(() => _repo.RevertReplaceFrontier( + Assert.ThrowsAsync(() => _repo.RevertReplaceFrontier( _projectId, [toRestoreId], [toDeleteId], _ => throw new InvalidOperationException())); - Assert.That(ex, Is.Not.Null); Assert.That((await _repo.GetAllWords(_projectId)).Count, Is.EqualTo(3)); Assert.That(await _repo.GetFrontierCount(_projectId), Is.EqualTo(1)); @@ -756,9 +742,8 @@ public async Task TestRevertReplaceFrontierModifyDeletedActionThrowsLeavesRepoUn public async Task TestRevertReplaceFrontierOverlappingIdsThrows() { var word = await CreateWord(); - var ex = Assert.ThrowsAsync(() => + Assert.ThrowsAsync(() => _repo.RevertReplaceFrontier(_projectId, [word.Id], [word.Id], _ => { })); - Assert.That(ex, Is.Not.Null); } // COUNT FRONTIER WORDS WITH DOMAIN From e21c6ae46408313c30702cd470d5a3ee8bc8933e Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Mon, 16 Mar 2026 12:50:13 -0400 Subject: [PATCH 10/15] Add exceptions to XML docs --- Backend/Repositories/WordRepository.cs | 4 ++++ Backend/Services/MergeService.cs | 4 ++++ Backend/Services/WordService.cs | 7 +++++++ 3 files changed, 15 insertions(+) diff --git a/Backend/Repositories/WordRepository.cs b/Backend/Repositories/WordRepository.cs index dd6b02d99e..49618caebc 100644 --- a/Backend/Repositories/WordRepository.cs +++ b/Backend/Repositories/WordRepository.cs @@ -276,6 +276,9 @@ public async Task> AddFrontier(List words) /// 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, "restoring word to Frontier"); @@ -356,6 +359,7 @@ public async Task> ReplaceFrontier(string projectId, List newWo /// 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) { diff --git a/Backend/Services/MergeService.cs b/Backend/Services/MergeService.cs index ac135aeb2f..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"); @@ -144,6 +147,7 @@ public async Task> Merge(string projectId, string userId, List 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"); diff --git a/Backend/Services/WordService.cs b/Backend/Services/WordService.cs index 75404cbcde..3371b51d75 100644 --- a/Backend/Services/WordService.cs +++ b/Backend/Services/WordService.cs @@ -182,6 +182,9 @@ public async Task Create(string userId, Word word) /// 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"); @@ -221,6 +224,9 @@ public async Task RestoreFrontierWord(string projectId, string wordId) /// 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) { @@ -238,6 +244,7 @@ public async Task> MergeReplaceFrontier( /// 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) { From 4ea86377194b1e27ec3812f35b06ba0da85e3560 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Mon, 16 Mar 2026 12:56:31 -0400 Subject: [PATCH 11/15] Use sync scope within a sync method --- Backend/Startup.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Backend/Startup.cs b/Backend/Startup.cs index b66ace7d15..642e1fd5eb 100644 --- a/Backend/Startup.cs +++ b/Backend/Startup.cs @@ -350,7 +350,7 @@ 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. - using var startupScope = app.ApplicationServices.CreateAsyncScope(); + using var startupScope = app.ApplicationServices.CreateScope(); var userRepo = startupScope.ServiceProvider.GetService(); if (userRepo is not null && CreateAdminUser(userRepo)) { From 4b519f31fc52b9ecf4b51bab5a74eb4318cebfe5 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Mon, 16 Mar 2026 13:49:59 -0400 Subject: [PATCH 12/15] Remove unused repo method --- Backend.Tests/Mocks/WordRepositoryMock.cs | 3 +- .../Repositories/WordRepositoryTests.cs | 23 -------- Backend.Tests/Services/MergeServiceTests.cs | 56 +++++++++---------- Backend/Interfaces/IWordRepository.cs | 1 - Backend/Repositories/WordRepository.cs | 16 ------ 5 files changed, 30 insertions(+), 69 deletions(-) diff --git a/Backend.Tests/Mocks/WordRepositoryMock.cs b/Backend.Tests/Mocks/WordRepositoryMock.cs index 58c913d0c8..4d5f976a69 100644 --- a/Backend.Tests/Mocks/WordRepositoryMock.cs +++ b/Backend.Tests/Mocks/WordRepositoryMock.cs @@ -138,7 +138,8 @@ internal Task AddFrontier(Word word) 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); diff --git a/Backend.Tests/Repositories/WordRepositoryTests.cs b/Backend.Tests/Repositories/WordRepositoryTests.cs index 298a847ad3..34497c7ef2 100644 --- a/Backend.Tests/Repositories/WordRepositoryTests.cs +++ b/Backend.Tests/Repositories/WordRepositoryTests.cs @@ -333,29 +333,6 @@ public async Task TestGetFrontierWithVernacularNoMatchReturnsEmpty() Assert.That(results, Is.Empty); } - // ADD FRONTIER - - [Test] - public async Task TestAddFrontierAddsWordsOnlyToFrontier() - { - var word = Util.RandomWord(_projectId); - var newId = NewObjectId(); - word.Id = newId; - - var added = await _repo.AddFrontier([word]); - - Assert.That(added, Has.Count.EqualTo(1)); - Assert.That(await _repo.IsInFrontier(_projectId, newId), Is.True); - Assert.That(await _repo.GetWord(_projectId, newId), Is.Null); - } - - [Test] - public async Task TestAddFrontierEmptyListReturnsEmpty() - { - var result = await _repo.AddFrontier([]); - Assert.That(result, Is.Empty); - } - // DELETE FRONTIER [Test] diff --git a/Backend.Tests/Services/MergeServiceTests.cs b/Backend.Tests/Services/MergeServiceTests.cs index b91a2facdf..53511fffbc 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!; @@ -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,7 +346,7 @@ 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, Is.Empty); @@ -356,7 +356,7 @@ public void RemoveFromMergeGraylistTest() 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); diff --git a/Backend/Interfaces/IWordRepository.cs b/Backend/Interfaces/IWordRepository.cs index f72d6d3b1d..a38531dc08 100644 --- a/Backend/Interfaces/IWordRepository.cs +++ b/Backend/Interfaces/IWordRepository.cs @@ -20,7 +20,6 @@ public interface IWordRepository Task> GetAllFrontier(string projectId); Task GetFrontier(string projectId, string wordId, string? audioFileName = null); Task> GetFrontierWithVernacular(string projectId, string vernacular); - Task> AddFrontier(List words); Task DeleteFrontier(string projectId, string wordId, Action modifyDeletedWord); Task RestoreFrontier(string projectId, string wordId); Task UpdateFrontier(string projectId, string wordId, Action modifyUpdatedWord); diff --git a/Backend/Repositories/WordRepository.cs b/Backend/Repositories/WordRepository.cs index 49618caebc..ad5bc53607 100644 --- a/Backend/Repositories/WordRepository.cs +++ b/Backend/Repositories/WordRepository.cs @@ -234,22 +234,6 @@ public async Task> GetFrontierWithVernacular(string projectId, string return await _frontier.Find(GetAllProjectWordsFilter(projectId, vernacular)).ToListAsync(); } - /// Adds a list of s only to the Frontier - /// Words to add to Frontier. - /// The words created - public async Task> AddFrontier(List words) - { - using var activity = OtelService.StartActivityWithTag(otelTagName, "adding words to Frontier"); - - if (words.Count == 0) - { - return words; - } - - await _frontier.InsertManyAsync(words); - return words; - } - /// /// Removes a from the Frontier, modifies it, and adds it to the WordsCollection. /// From 6da7cf7d91985d2e706e857f7ecda269754026a9 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Mon, 16 Mar 2026 14:08:51 -0400 Subject: [PATCH 13/15] Pick punny nit --- Backend.Tests/Services/MergeServiceTests.cs | 4 ++-- Backend/Repositories/WordRepository.cs | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/Backend.Tests/Services/MergeServiceTests.cs b/Backend.Tests/Services/MergeServiceTests.cs index 53511fffbc..309c30529f 100644 --- a/Backend.Tests/Services/MergeServiceTests.cs +++ b/Backend.Tests/Services/MergeServiceTests.cs @@ -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/Repositories/WordRepository.cs b/Backend/Repositories/WordRepository.cs index ad5bc53607..6c900a4f51 100644 --- a/Backend/Repositories/WordRepository.cs +++ b/Backend/Repositories/WordRepository.cs @@ -462,7 +462,6 @@ private async Task RestoreFrontierWithSession(IClientSessionHandle session 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) { From 7f1fabe1e8caae9720338c0baadf23b13af2fb01 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Mon, 16 Mar 2026 14:30:13 -0400 Subject: [PATCH 14/15] Handle edge cases --- Backend.Tests/Repositories/WordRepositoryTests.cs | 12 ++++++++++++ Backend/Repositories/WordRepository.cs | 10 ++++++++++ 2 files changed, 22 insertions(+) diff --git a/Backend.Tests/Repositories/WordRepositoryTests.cs b/Backend.Tests/Repositories/WordRepositoryTests.cs index 34497c7ef2..7f7b3f76ad 100644 --- a/Backend.Tests/Repositories/WordRepositoryTests.cs +++ b/Backend.Tests/Repositories/WordRepositoryTests.cs @@ -220,6 +220,18 @@ 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() { diff --git a/Backend/Repositories/WordRepository.cs b/Backend/Repositories/WordRepository.cs index 6c900a4f51..bee7a4b022 100644 --- a/Backend/Repositories/WordRepository.cs +++ b/Backend/Repositories/WordRepository.cs @@ -183,6 +183,16 @@ public async Task AreInFrontier(string projectId, List wordIds, in { 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; } From 7e705ee7ddbca3ee955a699219ea8e30a80a6fba Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Tue, 17 Mar 2026 13:46:35 -0400 Subject: [PATCH 15/15] Remove un-idiomatic region comments --- .../Repositories/WordRepositoryTests.cs | 34 ------------------- Backend/Repositories/WordRepository.cs | 12 +++++-- 2 files changed, 9 insertions(+), 37 deletions(-) diff --git a/Backend.Tests/Repositories/WordRepositoryTests.cs b/Backend.Tests/Repositories/WordRepositoryTests.cs index 7f7b3f76ad..e87e541360 100644 --- a/Backend.Tests/Repositories/WordRepositoryTests.cs +++ b/Backend.Tests/Repositories/WordRepositoryTests.cs @@ -65,8 +65,6 @@ private Task CreateWord(string? vernacular = null, string? domainId = null /// 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() { @@ -91,8 +89,6 @@ public async Task TestGetAllWordsOnlyReturnsWordsForProject() Assert.That(otherProjectWords, Is.Empty); } - // GET WORD - [Test] public async Task TestGetWord() { @@ -109,8 +105,6 @@ public async Task TestGetWordNonExistentIdReturnsNull() Assert.That(result, Is.Null); } - // CREATE - [Test] public async Task TestCreateSingleWordAddsToWordsAndFrontier() { @@ -153,8 +147,6 @@ public async Task TestCreateEmptyListReturnsEmpty() Assert.That(created, Is.Empty); } - // DELETE ALL FRONTIER WORDS - [Test] public async Task TestDeleteAllFrontierWordsRemovesAllFrontierWords() { @@ -175,8 +167,6 @@ public async Task TestDeleteAllFrontierWordsEmptyFrontierReturnsFalse() Assert.That(result, Is.False); } - // HAS WORDS / HAS FRONTIER WORDS - [Test] public async Task TestHasWordsAfterCreateReturnsTrue() { @@ -205,8 +195,6 @@ public async Task TestHasFrontierWordsAfterDeleteAllReturnsFalse() Assert.That(await _repo.HasFrontierWords(_projectId), Is.False); } - // IS IN FRONTIER / ARE IN FRONTIER - [Test] public async Task TestIsInFrontierExistingWordReturnsTrue() { @@ -254,8 +242,6 @@ public async Task TestAreInFrontierPartialMatchWithExactCountReturnsFalse() Assert.That(await _repo.AreInFrontier(_projectId, [w1.Id, NewObjectId()], 2), Is.False); } - // GET FRONTIER COUNT - [Test] public async Task TestGetFrontierCountReturnsCorrectCount() { @@ -270,8 +256,6 @@ public async Task TestGetFrontierCountEmptyProjectReturnsZero() Assert.That(await _repo.GetFrontierCount(_projectId), Is.Zero); } - // GET ALL FRONTIER - [Test] public async Task TestGetAllFrontierReturnsAllFrontierWords() { @@ -283,8 +267,6 @@ public async Task TestGetAllFrontierReturnsAllFrontierWords() Assert.That(ids, Contains.Item(w2.Id)); } - // GET FRONTIER (single word) - [Test] public async Task TestGetFrontierExistingWordReturnsWord() { @@ -323,8 +305,6 @@ public async Task TestGetFrontierWithNonMatchingAudioReturnsNull() Assert.That(retrieved, Is.Null); } - // GET FRONTIER WITH VERNACULAR - [Test] public async Task TestGetFrontierWithVernacularReturnsMatchingWords() { @@ -345,8 +325,6 @@ public async Task TestGetFrontierWithVernacularNoMatchReturnsEmpty() Assert.That(results, Is.Empty); } - // DELETE FRONTIER - [Test] public async Task TestDeleteFrontierRemovesFromFrontierAndArchives() { @@ -389,8 +367,6 @@ public async Task TestDeleteFrontierModifyActionThrowsLeavesRepoUnchanged() Assert.That(frontierWord.Accessibility, Is.Not.EqualTo(Status.Deleted)); } - // RESTORE FRONTIER - [Test] public async Task TestRestoreFrontierRestoresWordToFrontier() { @@ -429,8 +405,6 @@ public async Task TestRestoreFrontierDeletedWordThrows() Assert.ThrowsAsync(() => _repo.RestoreFrontier(_projectId, archivedWord.Id)); } - // UPDATE FRONTIER (by projectId, wordId, modifyWord) - [Test] public async Task TestUpdateFrontierByIdsUpdatesWord() { @@ -476,8 +450,6 @@ public async Task TestUpdateFrontierByIdsModifyActionThrowsLeavesRepoUnchanged() Assert.That(frontierWord.Vernacular, Is.Not.EqualTo(newVernacular)); } - // UPDATE FRONTIER (by word and action) - [Test] public async Task TestUpdateFrontierByWordUpdatesWord() { @@ -533,8 +505,6 @@ public async Task TestUpdateFrontierByWordModifyActionThrowsLeavesRepoUnchanged( Assert.That(frontierWord.Vernacular, Is.Not.EqualTo(newVernacular)); } - // REPLACE FRONTIER - [Test] public async Task TestReplaceFrontierUpdatesAndDeletesWords() { @@ -646,8 +616,6 @@ public void TestReplaceFrontierMissingDeleteIdThrows() _repo.ReplaceFrontier(_projectId, [], [NewObjectId()], (_, _) => { }, _ => { })); } - // REVERT REPLACE FRONTIER - [Test] public async Task TestRevertReplaceFrontierRestoresAndDeletes() { @@ -735,8 +703,6 @@ public async Task TestRevertReplaceFrontierOverlappingIdsThrows() _repo.RevertReplaceFrontier(_projectId, [word.Id], [word.Id], _ => { })); } - // COUNT FRONTIER WORDS WITH DOMAIN - [Test] public async Task TestCountFrontierWordsWithDomainReturnsCorrectCount() { diff --git a/Backend/Repositories/WordRepository.cs b/Backend/Repositories/WordRepository.cs index bee7a4b022..0365fdb851 100644 --- a/Backend/Repositories/WordRepository.cs +++ b/Backend/Repositories/WordRepository.cs @@ -18,7 +18,7 @@ public class WordRepository(IMongoDbContext dbContext) : IWordRepository private const string otelTagName = "otel.WordRepository"; - // GET FILTER HELPER METHODS + #region Private get-filter helper methods /// /// Creates a mongo filter for all words in a specified project (and optionally with specified vernacular). @@ -69,7 +69,9 @@ private static FilterDefinition GetProjectWordsFilter(string projectId, IE return filterDef.And(filterDef.Eq(w => w.ProjectId, projectId), filterDef.In(w => w.Id, wordIds)); } - // PUBLIC REPOSITORY METHODS + #endregion + + #region Public repository methods /// Finds all s with specified projectId /// Id of the project to query. @@ -381,7 +383,9 @@ public async Task CountFrontierWordsWithDomain(string projectId, string dom return (int)await _frontier.CountDocumentsAsync(filter); } - // WITH-SESSION HELPER METHODS + #endregion + + #region Private with-session helper methods /// /// Adds words to both WordsCollection and Frontier inside an existing transaction session. @@ -594,5 +598,7 @@ private async Task> ReplaceFrontierWithSession(IClientSessionHandle s } return true; } + + #endregion } }