From 8714636060ef524614dd59442faa82658d43c83e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Mar 2026 12:32:48 +0000 Subject: [PATCH 1/3] Initial plan From ef57d4bc0bb87237c39ba569b7522475983d9192 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Mar 2026 13:11:20 +0000 Subject: [PATCH 2/3] Fix MongoDB.Driver 3.4.0 compilation errors in test mocks - Add 'using MongoDB.Driver.Search' for IMongoSearchIndexManager - Add InsertOneAsync(T, CancellationToken) overload forwarding token - Add ReplaceOne/ReplaceOneAsync overloads accepting UpdateOptions - Wrap MapReduce methods in #pragma warning disable/restore CS0618 - Add DropCollection/DropCollectionAsync overloads with DropCollectionOptions+name - Fix missing 'using MongoDB.Driver' in WordRepositoryTestHelper - Fix using directive issues in WordRepositoryTests (swap Generic for System) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Mocks/InMemoryMongoCollection.cs | 901 ++++++++++++++++++ Backend.Tests/Mocks/InMemoryMongoDatabase.cs | 343 +++++++ .../Mocks/WordRepositoryTestHelper.cs | 162 ++++ .../Repositories/WordRepositoryTests.cs | 418 ++++++++ 4 files changed, 1824 insertions(+) create mode 100644 Backend.Tests/Mocks/InMemoryMongoCollection.cs create mode 100644 Backend.Tests/Mocks/InMemoryMongoDatabase.cs create mode 100644 Backend.Tests/Mocks/WordRepositoryTestHelper.cs create mode 100644 Backend.Tests/Repositories/WordRepositoryTests.cs diff --git a/Backend.Tests/Mocks/InMemoryMongoCollection.cs b/Backend.Tests/Mocks/InMemoryMongoCollection.cs new file mode 100644 index 0000000000..221b7425fd --- /dev/null +++ b/Backend.Tests/Mocks/InMemoryMongoCollection.cs @@ -0,0 +1,901 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using MongoDB.Bson; +using MongoDB.Bson.IO; +using MongoDB.Bson.Serialization; +using MongoDB.Driver; +using MongoDB.Driver.Search; + +namespace Backend.Tests.Mocks; + +/// +/// An in-memory implementation of for testing. +/// Supports the operations used by . +/// +internal sealed class InMemoryMongoCollection : IMongoCollection +{ + private readonly List _documents = []; + private readonly IBsonSerializer _serializer; + private readonly IBsonSerializerRegistry _registry; + + public InMemoryMongoCollection(string collectionName) + { + _serializer = BsonSerializer.LookupSerializer(); + _registry = BsonSerializer.SerializerRegistry; + CollectionNamespace = new CollectionNamespace("testDb", collectionName); + } + + public CollectionNamespace CollectionNamespace { get; } + public IMongoDatabase Database => throw new NotSupportedException(); + public IBsonSerializer DocumentSerializer => _serializer; + public IMongoIndexManager Indexes => throw new NotSupportedException(); + public IMongoSearchIndexManager SearchIndexes => throw new NotSupportedException(); + public MongoCollectionSettings Settings => new MongoCollectionSettings(); + + // --- Core methods used by WordRepository --- + + public Task> FindAsync( + FilterDefinition filter, + FindOptions? options = null, + CancellationToken cancellationToken = default) + => Task.FromResult(BuildCursor(filter, options?.Limit)); + + public Task> FindAsync( + IClientSessionHandle session, + FilterDefinition filter, + FindOptions? options = null, + CancellationToken cancellationToken = default) + => FindAsync(filter, options, cancellationToken); + + public IAsyncCursor FindSync( + FilterDefinition filter, + FindOptions? options = null, + CancellationToken cancellationToken = default) + => BuildCursor(filter, options?.Limit); + + public IAsyncCursor FindSync( + IClientSessionHandle session, + FilterDefinition filter, + FindOptions? options = null, + CancellationToken cancellationToken = default) + => FindSync(filter, options, cancellationToken); + + public Task InsertManyAsync( + IEnumerable documents, + InsertManyOptions? options = null, + CancellationToken cancellationToken = default) + { + DoInsertMany(documents); + return Task.CompletedTask; + } + + public Task InsertManyAsync( + IClientSessionHandle session, + IEnumerable documents, + InsertManyOptions? options = null, + CancellationToken cancellationToken = default) + => InsertManyAsync(documents, options, cancellationToken); + + public Task InsertOneAsync( + T document, + InsertOneOptions? options = null, + CancellationToken cancellationToken = default) + { + DoInsertMany([document]); + return Task.CompletedTask; + } + + public Task InsertOneAsync( + T document, + CancellationToken cancellationToken) + => InsertOneAsync(document, null, cancellationToken); + + public Task InsertOneAsync( + IClientSessionHandle session, + T document, + InsertOneOptions? options = null, + CancellationToken cancellationToken = default) + => InsertOneAsync(document, options, cancellationToken); + + public Task FindOneAndDeleteAsync( + FilterDefinition filter, + FindOneAndDeleteOptions? options = null, + CancellationToken cancellationToken = default) + { + var renderArgs = new RenderArgs(_serializer, _registry); + var renderedFilter = filter.Render(renderArgs); + var doc = _documents.FirstOrDefault(d => MatchesFilter(d, renderedFilter)); + if (doc is null) + { + return Task.FromResult(default!); + } + + _documents.Remove(doc); + return Task.FromResult(BsonSerializer.Deserialize(doc)); + } + + public Task FindOneAndDeleteAsync( + IClientSessionHandle session, + FilterDefinition filter, + FindOneAndDeleteOptions? options = null, + CancellationToken cancellationToken = default) + => FindOneAndDeleteAsync(filter, options, cancellationToken); + + public Task DeleteManyAsync( + FilterDefinition filter, + CancellationToken cancellationToken = default) + => DeleteManyAsync(filter, null, cancellationToken); + + public Task DeleteManyAsync( + FilterDefinition filter, + DeleteOptions? options, + CancellationToken cancellationToken = default) + { + var renderArgs = new RenderArgs(_serializer, _registry); + var renderedFilter = filter.Render(renderArgs); + var toRemove = _documents.Where(d => MatchesFilter(d, renderedFilter)).ToList(); + toRemove.ForEach(d => _documents.Remove(d)); + return Task.FromResult(new DeleteResult.Acknowledged(toRemove.Count)); + } + + public Task CountDocumentsAsync( + FilterDefinition filter, + CountOptions? options = null, + CancellationToken cancellationToken = default) + { + var renderArgs = new RenderArgs(_serializer, _registry); + var renderedFilter = filter.Render(renderArgs); + var count = _documents.Count(d => MatchesFilter(d, renderedFilter)); + if (options?.Limit.HasValue == true) + { + count = Math.Min(count, (int)options.Limit.Value); + } + + return Task.FromResult((long)count); + } + + // --- Private helpers --- + + private void DoInsertMany(IEnumerable documents) + { + foreach (var document in documents) + { + EnsureId(document); + var bsonDoc = SerializeDocument(document); + _documents.Add(bsonDoc); + } + } + + private static void EnsureId(T document) + { + var idProp = typeof(T).GetProperty("Id", BindingFlags.Public | BindingFlags.Instance); + if (idProp?.PropertyType == typeof(string) && idProp.GetValue(document) is string id && + string.IsNullOrEmpty(id)) + { + idProp.SetValue(document, ObjectId.GenerateNewId().ToString()); + } + } + + private BsonDocument SerializeDocument(T document) + { + using var writer = new BsonDocumentWriter(new BsonDocument()); + var context = BsonSerializationContext.CreateRoot(writer); + _serializer.Serialize(context, document); + return writer.Document; + } + + private IEnumerable GetMatchingDocuments(FilterDefinition filter, int? limit) + { + var renderArgs = new RenderArgs(_serializer, _registry); + var renderedFilter = filter.Render(renderArgs); + var matching = _documents.Where(d => MatchesFilter(d, renderedFilter)); + if (limit.HasValue) + { + matching = matching.Take(limit.Value); + } + + return matching.Select(doc => BsonSerializer.Deserialize(doc)); + } + + private IAsyncCursor BuildCursor(FilterDefinition filter, int? limit) + { + var documents = GetMatchingDocuments(filter, limit); + if (typeof(TProjection) != typeof(T)) + { + throw new NotSupportedException("Projection to a different type is not supported in InMemoryMongoCollection"); + } + + return new InMemoryAsyncCursor(documents.Cast()); + } + + // --- BSON filter evaluator --- + + private static bool MatchesFilter(BsonDocument doc, BsonDocument filter) + { + foreach (var element in filter) + { + switch (element.Name) + { + case "$and": + if (!element.Value.AsBsonArray.All(f => MatchesFilter(doc, f.AsBsonDocument))) + { + return false; + } + + break; + default: + var fieldValue = GetFieldValue(doc, element.Name); + if (!MatchesFieldValue(fieldValue, element.Value)) + { + return false; + } + + break; + } + } + + return true; + } + + private static BsonValue GetFieldValue(BsonDocument doc, string fieldPath) + { + var parts = fieldPath.Split('.'); + BsonValue current = doc; + foreach (var part in parts) + { + if (current is BsonDocument bsonDoc) + { + if (!bsonDoc.TryGetValue(part, out current)) + { + return BsonNull.Value; + } + } + else if (current is BsonArray bsonArr) + { + if (int.TryParse(part, out var idx) && idx < bsonArr.Count) + { + current = bsonArr[idx]; + } + else + { + return BsonNull.Value; + } + } + else + { + return BsonNull.Value; + } + } + + return current; + } + + private static bool MatchesFieldValue(BsonValue docValue, BsonValue filterValue) + { + if (filterValue is BsonDocument operators) + { + foreach (var op in operators) + { + switch (op.Name) + { + case "$in": + if (!op.Value.AsBsonArray.Contains(docValue)) + { + return false; + } + + break; + case "$exists": + var shouldExist = op.Value.AsBoolean; + var exists = docValue is not BsonNull && docValue != BsonNull.Value; + if (shouldExist != exists) + { + return false; + } + + break; + case "$elemMatch": + if (docValue is not BsonArray arr || !arr.Any( + elem => elem is BsonDocument elemDoc && MatchesFilter(elemDoc, op.Value.AsBsonDocument))) + { + return false; + } + + break; + default: + throw new NotSupportedException( + $"Filter operator '{op.Name}' is not supported by InMemoryMongoCollection"); + } + } + + return true; + } + + // Simple equality + return docValue.Equals(filterValue); + } + + // --- Unsupported IMongoCollection methods --- + + public IAsyncCursor Aggregate( + PipelineDefinition pipeline, + AggregateOptions? options = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public IAsyncCursor Aggregate( + IClientSessionHandle session, + PipelineDefinition pipeline, + AggregateOptions? options = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public Task> AggregateAsync( + PipelineDefinition pipeline, + AggregateOptions? options = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public Task> AggregateAsync( + IClientSessionHandle session, + PipelineDefinition pipeline, + AggregateOptions? options = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public void AggregateToCollection( + PipelineDefinition pipeline, + AggregateOptions? options = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public void AggregateToCollection( + IClientSessionHandle session, + PipelineDefinition pipeline, + AggregateOptions? options = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public Task AggregateToCollectionAsync( + PipelineDefinition pipeline, + AggregateOptions? options = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public Task AggregateToCollectionAsync( + IClientSessionHandle session, + PipelineDefinition pipeline, + AggregateOptions? options = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public BulkWriteResult BulkWrite( + IEnumerable> requests, + BulkWriteOptions? options = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public BulkWriteResult BulkWrite( + IClientSessionHandle session, + IEnumerable> requests, + BulkWriteOptions? options = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public Task> BulkWriteAsync( + IEnumerable> requests, + BulkWriteOptions? options = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public Task> BulkWriteAsync( + IClientSessionHandle session, + IEnumerable> requests, + BulkWriteOptions? options = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + [Obsolete] + public long Count( + FilterDefinition filter, + CountOptions? options = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + [Obsolete] + public long Count( + IClientSessionHandle session, + FilterDefinition filter, + CountOptions? options = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + [Obsolete] + public Task CountAsync( + FilterDefinition filter, + CountOptions? options = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + [Obsolete] + public Task CountAsync( + IClientSessionHandle session, + FilterDefinition filter, + CountOptions? options = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public long CountDocuments( + FilterDefinition filter, + CountOptions? options = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public long CountDocuments( + IClientSessionHandle session, + FilterDefinition filter, + CountOptions? options = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public Task CountDocumentsAsync( + IClientSessionHandle session, + FilterDefinition filter, + CountOptions? options = null, + CancellationToken cancellationToken = default) + => CountDocumentsAsync(filter, options, cancellationToken); + + public DeleteResult DeleteMany( + FilterDefinition filter, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public DeleteResult DeleteMany( + FilterDefinition filter, + DeleteOptions options, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public DeleteResult DeleteMany( + IClientSessionHandle session, + FilterDefinition filter, + DeleteOptions? options = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public Task DeleteManyAsync( + IClientSessionHandle session, + FilterDefinition filter, + DeleteOptions? options = null, + CancellationToken cancellationToken = default) + => DeleteManyAsync(filter, options, cancellationToken); + + public DeleteResult DeleteOne( + FilterDefinition filter, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public DeleteResult DeleteOne( + FilterDefinition filter, + DeleteOptions? options, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public DeleteResult DeleteOne( + IClientSessionHandle session, + FilterDefinition filter, + DeleteOptions? options = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public Task DeleteOneAsync( + FilterDefinition filter, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public Task DeleteOneAsync( + FilterDefinition filter, + DeleteOptions? options, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public Task DeleteOneAsync( + IClientSessionHandle session, + FilterDefinition filter, + DeleteOptions? options = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public IAsyncCursor Distinct( + FieldDefinition field, + FilterDefinition filter, + DistinctOptions? options = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public IAsyncCursor Distinct( + IClientSessionHandle session, + FieldDefinition field, + FilterDefinition filter, + DistinctOptions? options = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public Task> DistinctAsync( + FieldDefinition field, + FilterDefinition filter, + DistinctOptions? options = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public Task> DistinctAsync( + IClientSessionHandle session, + FieldDefinition field, + FilterDefinition filter, + DistinctOptions? options = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public IAsyncCursor DistinctMany( + FieldDefinition> field, + FilterDefinition filter, + DistinctOptions? options = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public IAsyncCursor DistinctMany( + IClientSessionHandle session, + FieldDefinition> field, + FilterDefinition filter, + DistinctOptions? options = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public Task> DistinctManyAsync( + FieldDefinition> field, + FilterDefinition filter, + DistinctOptions? options = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public Task> DistinctManyAsync( + IClientSessionHandle session, + FieldDefinition> field, + FilterDefinition filter, + DistinctOptions? options = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public long EstimatedDocumentCount( + EstimatedDocumentCountOptions? options = null, + CancellationToken cancellationToken = default) + => _documents.Count; + + public Task EstimatedDocumentCountAsync( + EstimatedDocumentCountOptions? options = null, + CancellationToken cancellationToken = default) + => Task.FromResult((long)_documents.Count); + + public TProjection FindOneAndDelete( + FilterDefinition filter, + FindOneAndDeleteOptions? options = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public TProjection FindOneAndDelete( + IClientSessionHandle session, + FilterDefinition filter, + FindOneAndDeleteOptions? options = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public TProjection FindOneAndReplace( + FilterDefinition filter, + T replacement, + FindOneAndReplaceOptions? options = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public TProjection FindOneAndReplace( + IClientSessionHandle session, + FilterDefinition filter, + T replacement, + FindOneAndReplaceOptions? options = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public Task FindOneAndReplaceAsync( + FilterDefinition filter, + T replacement, + FindOneAndReplaceOptions? options = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public Task FindOneAndReplaceAsync( + IClientSessionHandle session, + FilterDefinition filter, + T replacement, + FindOneAndReplaceOptions? options = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public TProjection FindOneAndUpdate( + FilterDefinition filter, + UpdateDefinition update, + FindOneAndUpdateOptions? options = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public TProjection FindOneAndUpdate( + IClientSessionHandle session, + FilterDefinition filter, + UpdateDefinition update, + FindOneAndUpdateOptions? options = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public Task FindOneAndUpdateAsync( + FilterDefinition filter, + UpdateDefinition update, + FindOneAndUpdateOptions? options = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public Task FindOneAndUpdateAsync( + IClientSessionHandle session, + FilterDefinition filter, + UpdateDefinition update, + FindOneAndUpdateOptions? options = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public void InsertMany( + IEnumerable documents, + InsertManyOptions? options = null, + CancellationToken cancellationToken = default) + => DoInsertMany(documents); + + public void InsertMany( + IClientSessionHandle session, + IEnumerable documents, + InsertManyOptions? options = null, + CancellationToken cancellationToken = default) + => InsertMany(documents, options, cancellationToken); + + public void InsertOne( + T document, + InsertOneOptions? options = null, + CancellationToken cancellationToken = default) + => DoInsertMany([document]); + + public void InsertOne( + IClientSessionHandle session, + T document, + InsertOneOptions? options = null, + CancellationToken cancellationToken = default) + => InsertOne(document, options, cancellationToken); + +#pragma warning disable CS0618 + public IAsyncCursor MapReduce( + BsonJavaScript map, + BsonJavaScript reduce, + MapReduceOptions? options = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public IAsyncCursor MapReduce( + IClientSessionHandle session, + BsonJavaScript map, + BsonJavaScript reduce, + MapReduceOptions? options = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public Task> MapReduceAsync( + BsonJavaScript map, + BsonJavaScript reduce, + MapReduceOptions? options = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public Task> MapReduceAsync( + IClientSessionHandle session, + BsonJavaScript map, + BsonJavaScript reduce, + MapReduceOptions? options = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); +#pragma warning restore CS0618 + + public IFilteredMongoCollection OfType() + where TDerivedDocument : T + => throw new NotSupportedException(); + + public ReplaceOneResult ReplaceOne( + FilterDefinition filter, + T replacement, + ReplaceOptions? options = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public ReplaceOneResult ReplaceOne( + FilterDefinition filter, + T replacement, + UpdateOptions options, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public ReplaceOneResult ReplaceOne( + IClientSessionHandle session, + FilterDefinition filter, + T replacement, + ReplaceOptions? options = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public ReplaceOneResult ReplaceOne( + IClientSessionHandle session, + FilterDefinition filter, + T replacement, + UpdateOptions options, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public Task ReplaceOneAsync( + FilterDefinition filter, + T replacement, + ReplaceOptions? options = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public Task ReplaceOneAsync( + FilterDefinition filter, + T replacement, + UpdateOptions options, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public Task ReplaceOneAsync( + IClientSessionHandle session, + FilterDefinition filter, + T replacement, + ReplaceOptions? options = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public Task ReplaceOneAsync( + IClientSessionHandle session, + FilterDefinition filter, + T replacement, + UpdateOptions options, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public UpdateResult UpdateMany( + FilterDefinition filter, + UpdateDefinition update, + UpdateOptions? options = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public UpdateResult UpdateMany( + IClientSessionHandle session, + FilterDefinition filter, + UpdateDefinition update, + UpdateOptions? options = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public Task UpdateManyAsync( + FilterDefinition filter, + UpdateDefinition update, + UpdateOptions? options = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public Task UpdateManyAsync( + IClientSessionHandle session, + FilterDefinition filter, + UpdateDefinition update, + UpdateOptions? options = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public UpdateResult UpdateOne( + FilterDefinition filter, + UpdateDefinition update, + UpdateOptions? options = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public UpdateResult UpdateOne( + IClientSessionHandle session, + FilterDefinition filter, + UpdateDefinition update, + UpdateOptions? options = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public Task UpdateOneAsync( + FilterDefinition filter, + UpdateDefinition update, + UpdateOptions? options = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public Task UpdateOneAsync( + IClientSessionHandle session, + FilterDefinition filter, + UpdateDefinition update, + UpdateOptions? options = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public IChangeStreamCursor Watch( + PipelineDefinition, TResult> pipeline, + ChangeStreamOptions? options = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public IChangeStreamCursor Watch( + IClientSessionHandle session, + PipelineDefinition, TResult> pipeline, + ChangeStreamOptions? options = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public Task> WatchAsync( + PipelineDefinition, TResult> pipeline, + ChangeStreamOptions? options = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public Task> WatchAsync( + IClientSessionHandle session, + PipelineDefinition, TResult> pipeline, + ChangeStreamOptions? options = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public IMongoCollection WithReadConcern(ReadConcern readConcern) => this; + public IMongoCollection WithReadPreference(ReadPreference readPreference) => this; + public IMongoCollection WithWriteConcern(WriteConcern writeConcern) => this; +} + +/// +/// A simple in-memory that yields all items in a single batch. +/// +internal sealed class InMemoryAsyncCursor(IEnumerable documents) : IAsyncCursor +{ + private readonly IEnumerator _enumerator = documents.GetEnumerator(); + private bool _exhausted; + + public IEnumerable Current { get; private set; } = []; + + public bool MoveNext(CancellationToken cancellationToken = default) + { + if (_exhausted) + { + return false; + } + + _exhausted = true; + var batch = new List(); + while (_enumerator.MoveNext()) + { + batch.Add(_enumerator.Current); + } + + Current = batch; + return batch.Count > 0; + } + + public Task MoveNextAsync(CancellationToken cancellationToken = default) + => Task.FromResult(MoveNext(cancellationToken)); + + public void Dispose() => _enumerator.Dispose(); +} diff --git a/Backend.Tests/Mocks/InMemoryMongoDatabase.cs b/Backend.Tests/Mocks/InMemoryMongoDatabase.cs new file mode 100644 index 0000000000..d62b339af4 --- /dev/null +++ b/Backend.Tests/Mocks/InMemoryMongoDatabase.cs @@ -0,0 +1,343 @@ +using System; +using System.Collections.Concurrent; +using System.Threading; +using System.Threading.Tasks; +using MongoDB.Bson; +using MongoDB.Driver; + +namespace Backend.Tests.Mocks; + +/// +/// An in-memory implementation of for testing. +/// Returns instances keyed by collection name. +/// +internal sealed class InMemoryMongoDatabase : IMongoDatabase +{ + private readonly ConcurrentDictionary _collections = new(); + + public IMongoCollection GetCollection( + string name, + MongoCollectionSettings? settings = null) + { + return (IMongoCollection)_collections.GetOrAdd( + name, + _ => new InMemoryMongoCollection(name)); + } + + // --- Unsupported IMongoDatabase members --- + public IMongoClient Client => throw new NotSupportedException(); + public DatabaseNamespace DatabaseNamespace => throw new NotSupportedException(); + public MongoDatabaseSettings Settings => throw new NotSupportedException(); + + public IAsyncCursor Aggregate( + PipelineDefinition pipeline, + AggregateOptions? options = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public IAsyncCursor Aggregate( + IClientSessionHandle session, + PipelineDefinition pipeline, + AggregateOptions? options = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public Task> AggregateAsync( + PipelineDefinition pipeline, + AggregateOptions? options = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public Task> AggregateAsync( + IClientSessionHandle session, + PipelineDefinition pipeline, + AggregateOptions? options = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public void AggregateToCollection( + PipelineDefinition pipeline, + AggregateOptions? options = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public void AggregateToCollection( + IClientSessionHandle session, + PipelineDefinition pipeline, + AggregateOptions? options = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public Task AggregateToCollectionAsync( + PipelineDefinition pipeline, + AggregateOptions? options = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public Task AggregateToCollectionAsync( + IClientSessionHandle session, + PipelineDefinition pipeline, + AggregateOptions? options = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public void CreateCollection( + string name, + CreateCollectionOptions? options = null, + CancellationToken cancellationToken = default) + { } + + public void CreateCollection( + IClientSessionHandle session, + string name, + CreateCollectionOptions? options = null, + CancellationToken cancellationToken = default) + { } + + public Task CreateCollectionAsync( + string name, + CreateCollectionOptions? options = null, + CancellationToken cancellationToken = default) + => Task.CompletedTask; + + public Task CreateCollectionAsync( + IClientSessionHandle session, + string name, + CreateCollectionOptions? options = null, + CancellationToken cancellationToken = default) + => Task.CompletedTask; + + public void CreateView( + string viewName, + string viewOn, + PipelineDefinition pipeline, + CreateViewOptions? options = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public void CreateView( + IClientSessionHandle session, + string viewName, + string viewOn, + PipelineDefinition pipeline, + CreateViewOptions? options = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public Task CreateViewAsync( + string viewName, + string viewOn, + PipelineDefinition pipeline, + CreateViewOptions? options = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public Task CreateViewAsync( + IClientSessionHandle session, + string viewName, + string viewOn, + PipelineDefinition pipeline, + CreateViewOptions? options = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public void DropCollection( + string name, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public void DropCollection( + string name, + DropCollectionOptions options, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public void DropCollection( + DropCollectionOptions options, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public void DropCollection( + IClientSessionHandle session, + string name, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public void DropCollection( + IClientSessionHandle session, + string name, + DropCollectionOptions options, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public void DropCollection( + IClientSessionHandle session, + DropCollectionOptions options, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public Task DropCollectionAsync( + string name, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public Task DropCollectionAsync( + string name, + DropCollectionOptions options, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public Task DropCollectionAsync( + DropCollectionOptions options, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public Task DropCollectionAsync( + IClientSessionHandle session, + string name, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public Task DropCollectionAsync( + IClientSessionHandle session, + string name, + DropCollectionOptions options, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public Task DropCollectionAsync( + IClientSessionHandle session, + DropCollectionOptions options, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public IAsyncCursor ListCollectionNames( + ListCollectionNamesOptions? options = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public IAsyncCursor ListCollectionNames( + IClientSessionHandle session, + ListCollectionNamesOptions? options = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public Task> ListCollectionNamesAsync( + ListCollectionNamesOptions? options = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public Task> ListCollectionNamesAsync( + IClientSessionHandle session, + ListCollectionNamesOptions? options = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public IAsyncCursor ListCollections( + ListCollectionsOptions? options = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public IAsyncCursor ListCollections( + IClientSessionHandle session, + ListCollectionsOptions? options = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public Task> ListCollectionsAsync( + ListCollectionsOptions? options = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public Task> ListCollectionsAsync( + IClientSessionHandle session, + ListCollectionsOptions? options = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public void RenameCollection( + string oldName, + string newName, + RenameCollectionOptions? options = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public void RenameCollection( + IClientSessionHandle session, + string oldName, + string newName, + RenameCollectionOptions? options = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public Task RenameCollectionAsync( + string oldName, + string newName, + RenameCollectionOptions? options = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public Task RenameCollectionAsync( + IClientSessionHandle session, + string oldName, + string newName, + RenameCollectionOptions? options = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public TResult RunCommand( + Command command, + ReadPreference? readPreference = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public TResult RunCommand( + IClientSessionHandle session, + Command command, + ReadPreference? readPreference = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public Task RunCommandAsync( + Command command, + ReadPreference? readPreference = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public Task RunCommandAsync( + IClientSessionHandle session, + Command command, + ReadPreference? readPreference = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public IChangeStreamCursor Watch( + PipelineDefinition, TResult> pipeline, + ChangeStreamOptions? options = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public IChangeStreamCursor Watch( + IClientSessionHandle session, + PipelineDefinition, TResult> pipeline, + ChangeStreamOptions? options = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public Task> WatchAsync( + PipelineDefinition, TResult> pipeline, + ChangeStreamOptions? options = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public Task> WatchAsync( + IClientSessionHandle session, + PipelineDefinition, TResult> pipeline, + ChangeStreamOptions? options = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public IMongoDatabase WithReadConcern(ReadConcern readConcern) => this; + public IMongoDatabase WithReadPreference(ReadPreference readPreference) => this; + public IMongoDatabase WithWriteConcern(WriteConcern writeConcern) => this; +} diff --git a/Backend.Tests/Mocks/WordRepositoryTestHelper.cs b/Backend.Tests/Mocks/WordRepositoryTestHelper.cs new file mode 100644 index 0000000000..d6116090bc --- /dev/null +++ b/Backend.Tests/Mocks/WordRepositoryTestHelper.cs @@ -0,0 +1,162 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using BackendFramework.Interfaces; +using BackendFramework.Models; +using BackendFramework.Repositories; +using MongoDB.Bson; +using MongoDB.Driver; + +namespace Backend.Tests.Mocks; + +/// +/// A test helper that wraps the real with an in-memory MongoDB context. +/// Provides additional methods needed by test suites to set up test data directly. +/// +/// +/// Each instance has its own isolated , ensuring test isolation. +/// The real code runs against the in-memory MongoDB, so tests exercise +/// the actual repository logic rather than a hand-written mock. +/// +internal sealed class WordRepositoryTestHelper : IWordRepository +{ + private readonly MongoDbContextMock _context; + private readonly WordRepository _repo; + private Task? _getAllFrontierDelay; + private int _getAllFrontierCallCount; + + public WordRepositoryTestHelper() + { + _context = new MongoDbContextMock(); + _repo = new WordRepository(_context); + } + + // --- IWordRepository delegation --- + + public Task> GetAllWords(string projectId) => _repo.GetAllWords(projectId); + + public Task GetWord(string projectId, string wordId) => _repo.GetWord(projectId, wordId); + + public Task RepoCreate(Word word) => _repo.RepoCreate(word); + + public Task> RepoCreate(List words) => _repo.RepoCreate(words); + + public Task RepoUpdateFrontier(string projectId, string wordId, Action modifyWord) + => _repo.RepoUpdateFrontier(projectId, wordId, modifyWord); + + public Task RepoUpdateFrontier(Word word, Action modifyNewWordFromOldWord) + => _repo.RepoUpdateFrontier(word, modifyNewWordFromOldWord); + + public Task?> RepoReplaceFrontier(string projectId, List newWords, + List idsToDelete, Action modifyUpdatedWord, Action modifyDeletedWord) + => _repo.RepoReplaceFrontier(projectId, newWords, idsToDelete, modifyUpdatedWord, modifyDeletedWord); + + public Task> RepoRevertReplaceFrontier(string projectId, List idsToRestore, + List idsToDelete, Action modifyDeletedWord) + => _repo.RepoRevertReplaceFrontier(projectId, idsToRestore, idsToDelete, modifyDeletedWord); + + public Task RepoDeleteFrontier(string projectId, string wordId, Action modifyWord) + => _repo.RepoDeleteFrontier(projectId, wordId, modifyWord); + + public Task DeleteAllFrontierWords(string projectId) => _repo.DeleteAllFrontierWords(projectId); + + public Task HasWords(string projectId) => _repo.HasWords(projectId); + + public Task HasFrontierWords(string projectId) => _repo.HasFrontierWords(projectId); + + public Task IsInFrontier(string projectId, string wordId) => _repo.IsInFrontier(projectId, wordId); + + public Task AreInFrontier(string projectId, List wordIds, int count) + => _repo.AreInFrontier(projectId, wordIds, count); + + public Task GetFrontierCount(string projectId) => _repo.GetFrontierCount(projectId); + + /// + /// Overrides the real GetAllFrontier with optional delay support for concurrency tests. + /// + public async Task> GetAllFrontier(string projectId) + { + if (_getAllFrontierDelay is not null) + { + var callCount = Interlocked.Increment(ref _getAllFrontierCallCount); + if (callCount == 1) + { + await _getAllFrontierDelay; + } + } + + return await _repo.GetAllFrontier(projectId); + } + + public Task GetFrontier(string projectId, string wordId, string? audioFileName = null) + => _repo.GetFrontier(projectId, wordId, audioFileName); + + public Task> GetFrontierWithVernacular(string projectId, string vernacular) + => _repo.GetFrontierWithVernacular(projectId, vernacular); + + public Task> RepoRestoreFrontier(string projectId, List wordIds) + => _repo.RepoRestoreFrontier(projectId, wordIds); + + public Task> AddFrontier(List words) => _repo.AddFrontier(words); + + public Task CountFrontierWordsWithDomain(string projectId, string domainId) + => _repo.CountFrontierWordsWithDomain(projectId, domainId); + + // --- Test-only helper methods (not on IWordRepository) --- + + /// + /// Adds a word directly to the Words collection (not the Frontier). + /// Assigns a new Id if the word has no Id. Mirrors the test behavior of the old WordRepositoryMock.Add. + /// + public async Task Add(Word word) + { + if (string.IsNullOrEmpty(word.Id)) + { + word.Id = ObjectId.GenerateNewId().ToString(); + } + + var wordsCollection = _context.Db.GetCollection("WordsCollection"); + await wordsCollection.InsertOneAsync(word); + return word; + } + + /// + /// Adds a single word directly to the Frontier (not the Words collection). + /// Assigns a new Id if the word has no valid ObjectId. + /// + public async Task AddFrontier(Word word) + { + if (string.IsNullOrEmpty(word.Id) || !ObjectId.TryParse(word.Id, out _)) + { + word.Id = ObjectId.GenerateNewId().ToString(); + } + + await _repo.AddFrontier([word]); + return word; + } + + /// + /// Removes all words and all frontier entries for a given project. + /// Used for test cleanup between test cases. + /// + public async Task DeleteAllWords(string projectId) + { + await _repo.DeleteAllFrontierWords(projectId); + var allWords = await _repo.GetAllWords(projectId); + var wordsCollection = _context.Db.GetCollection("WordsCollection"); + var filterDef = new FilterDefinitionBuilder(); + var filter = filterDef.Eq(w => w.ProjectId, projectId); + await wordsCollection.DeleteManyAsync(filter); + } + + /// + /// Sets a delay to be awaited on the first call to . + /// Used for concurrency testing in MergeServiceTests. + /// + public void SetGetFrontierDelay(Task delay) + { + _getAllFrontierDelay = delay; + _getAllFrontierCallCount = 0; + } +} diff --git a/Backend.Tests/Repositories/WordRepositoryTests.cs b/Backend.Tests/Repositories/WordRepositoryTests.cs new file mode 100644 index 0000000000..cf34648ff8 --- /dev/null +++ b/Backend.Tests/Repositories/WordRepositoryTests.cs @@ -0,0 +1,418 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Backend.Tests.Mocks; +using BackendFramework.Models; +using NUnit.Framework; + +namespace Backend.Tests.Repositories +{ + /// Unit tests for . + internal sealed class WordRepositoryTests + { + private WordRepositoryTestHelper _wordRepo = null!; + + private const string ProjId = "WordRepositoryTestProjId"; + private const string OtherProjId = "WordRepositoryTestOtherProjId"; + + [SetUp] + public void Setup() + { + _wordRepo = new WordRepositoryTestHelper(); + } + + // --- GetAllWords --- + + [Test] + public async Task GetAllWords_EmptyDb_ReturnsEmpty() + { + var result = await _wordRepo.GetAllWords(ProjId); + Assert.That(result, Is.Empty); + } + + [Test] + public async Task GetAllWords_ReturnsOnlyWordsForProject() + { + var word = Util.RandomWord(ProjId); + var otherWord = Util.RandomWord(OtherProjId); + await _wordRepo.RepoCreate(word); + await _wordRepo.RepoCreate(otherWord); + + var result = await _wordRepo.GetAllWords(ProjId); + + Assert.That(result, Has.Count.EqualTo(1)); + Assert.That(result.First().ProjectId, Is.EqualTo(ProjId)); + } + + [Test] + public async Task GetAllWords_ExcludesZeroSenseWords() + { + var noSenses = new Word { ProjectId = ProjId, Senses = [], Vernacular = "v" }; + await _wordRepo.Add(noSenses); + var withSenses = Util.RandomWord(ProjId); + await _wordRepo.RepoCreate(withSenses); + + var result = await _wordRepo.GetAllWords(ProjId); + + // Only the word with senses should be returned + Assert.That(result, Has.Count.EqualTo(1)); + } + + // --- GetWord --- + + [Test] + public async Task GetWord_ExistingWord_ReturnsWord() + { + var created = await _wordRepo.RepoCreate(Util.RandomWord(ProjId)); + var result = await _wordRepo.GetWord(ProjId, created.Id); + + Assert.That(result, Is.Not.Null); + Assert.That(result!.Id, Is.EqualTo(created.Id)); + } + + [Test] + public async Task GetWord_MissingWord_ReturnsNull() + { + var result = await _wordRepo.GetWord(ProjId, "000000000000000000000099"); + Assert.That(result, Is.Null); + } + + [Test] + public async Task GetWord_WrongProject_ReturnsNull() + { + var created = await _wordRepo.RepoCreate(Util.RandomWord(ProjId)); + var result = await _wordRepo.GetWord(OtherProjId, created.Id); + Assert.That(result, Is.Null); + } + + // --- RepoCreate (single word) --- + + [Test] + public async Task RepoCreate_AssignsId() + { + var word = Util.RandomWord(ProjId); + var created = await _wordRepo.RepoCreate(word); + + Assert.That(created.Id, Is.Not.Empty); + } + + [Test] + public async Task RepoCreate_AddsToWordsAndFrontier() + { + var created = await _wordRepo.RepoCreate(Util.RandomWord(ProjId)); + + var words = await _wordRepo.GetAllWords(ProjId); + var frontier = await _wordRepo.GetAllFrontier(ProjId); + + Assert.That(words.Any(w => w.Id == created.Id), Is.True); + Assert.That(frontier.Any(w => w.Id == created.Id), Is.True); + } + + // --- RepoCreate (multiple words) --- + + [Test] + public async Task RepoCreateList_ReturnsAllWords() + { + var words = Util.RandomWordList(3, ProjId); + var created = await _wordRepo.RepoCreate(words); + + Assert.That(created, Has.Count.EqualTo(3)); + Assert.That(created.All(w => !string.IsNullOrEmpty(w.Id)), Is.True); + } + + [Test] + public async Task RepoCreateList_Empty_ReturnsEmpty() + { + var created = await _wordRepo.RepoCreate([]); + Assert.That(created, Is.Empty); + } + + // --- HasWords / HasFrontierWords --- + + [Test] + public async Task HasWords_NoWords_ReturnsFalse() + { + Assert.That(await _wordRepo.HasWords(ProjId), Is.False); + } + + [Test] + public async Task HasWords_AfterCreate_ReturnsTrue() + { + await _wordRepo.RepoCreate(Util.RandomWord(ProjId)); + Assert.That(await _wordRepo.HasWords(ProjId), Is.True); + } + + [Test] + public async Task HasFrontierWords_NoFrontier_ReturnsFalse() + { + Assert.That(await _wordRepo.HasFrontierWords(ProjId), Is.False); + } + + [Test] + public async Task HasFrontierWords_AfterAddFrontier_ReturnsTrue() + { + await _wordRepo.AddFrontier([Util.RandomWord(ProjId)]); + Assert.That(await _wordRepo.HasFrontierWords(ProjId), Is.True); + } + + // --- IsInFrontier / AreInFrontier --- + + [Test] + public async Task IsInFrontier_AfterCreate_ReturnsTrue() + { + var created = await _wordRepo.RepoCreate(Util.RandomWord(ProjId)); + Assert.That(await _wordRepo.IsInFrontier(ProjId, created.Id), Is.True); + } + + [Test] + public async Task IsInFrontier_NotInFrontier_ReturnsFalse() + { + var wordNotInFrontier = await _wordRepo.Add(Util.RandomWord(ProjId)); + Assert.That(await _wordRepo.IsInFrontier(ProjId, wordNotInFrontier.Id), Is.False); + } + + [Test] + public async Task AreInFrontier_AllIds_ReturnsTrue() + { + var w1 = await _wordRepo.RepoCreate(Util.RandomWord(ProjId)); + var w2 = await _wordRepo.RepoCreate(Util.RandomWord(ProjId)); + + Assert.That(await _wordRepo.AreInFrontier(ProjId, [w1.Id, w2.Id], 2), Is.True); + } + + [Test] + public async Task AreInFrontier_MissingId_ReturnsFalse() + { + var w1 = await _wordRepo.RepoCreate(Util.RandomWord(ProjId)); + Assert.That(await _wordRepo.AreInFrontier(ProjId, [w1.Id, "000000000000000000000099"], 2), Is.False); + } + + // --- GetFrontierCount --- + + [Test] + public async Task GetFrontierCount_Empty_ReturnsZero() + { + Assert.That(await _wordRepo.GetFrontierCount(ProjId), Is.EqualTo(0)); + } + + [Test] + public async Task GetFrontierCount_AfterCreate_ReturnsCorrectCount() + { + await _wordRepo.RepoCreate(Util.RandomWord(ProjId)); + await _wordRepo.RepoCreate(Util.RandomWord(ProjId)); + + Assert.That(await _wordRepo.GetFrontierCount(ProjId), Is.EqualTo(2)); + } + + // --- GetAllFrontier --- + + [Test] + public async Task GetAllFrontier_ReturnsOnlyProjectWords() + { + await _wordRepo.RepoCreate(Util.RandomWord(ProjId)); + await _wordRepo.RepoCreate(Util.RandomWord(OtherProjId)); + + var frontier = await _wordRepo.GetAllFrontier(ProjId); + + Assert.That(frontier, Has.Count.EqualTo(1)); + Assert.That(frontier.All(w => w.ProjectId == ProjId), Is.True); + } + + // --- GetFrontier --- + + [Test] + public async Task GetFrontier_ExistingWord_ReturnsWord() + { + var created = await _wordRepo.RepoCreate(Util.RandomWord(ProjId)); + var result = await _wordRepo.GetFrontier(ProjId, created.Id); + + Assert.That(result, Is.Not.Null); + Assert.That(result!.Id, Is.EqualTo(created.Id)); + } + + [Test] + public async Task GetFrontier_MissingWord_ReturnsNull() + { + var result = await _wordRepo.GetFrontier(ProjId, "000000000000000000000099"); + Assert.That(result, Is.Null); + } + + [Test] + public async Task GetFrontier_WithAudioFilter_ReturnsCorrectWord() + { + const string fileName = "audio.mp3"; + var word = Util.RandomWord(ProjId); + word.Audio.Add(new Pronunciation(fileName)); + var created = await _wordRepo.RepoCreate(word); + + var result = await _wordRepo.GetFrontier(ProjId, created.Id, fileName); + Assert.That(result, Is.Not.Null); + + var resultNoFile = await _wordRepo.GetFrontier(ProjId, created.Id, "wrong.mp3"); + Assert.That(resultNoFile, Is.Null); + } + + // --- GetFrontierWithVernacular --- + + [Test] + public async Task GetFrontierWithVernacular_ReturnsMatchingWords() + { + const string vern = "unique-vernacular"; + var word = Util.RandomWord(ProjId); + word.Vernacular = vern; + await _wordRepo.RepoCreate(word); + await _wordRepo.RepoCreate(Util.RandomWord(ProjId)); // Different vernacular + + var result = await _wordRepo.GetFrontierWithVernacular(ProjId, vern); + + Assert.That(result, Has.Count.EqualTo(1)); + Assert.That(result.First().Vernacular, Is.EqualTo(vern)); + } + + // --- DeleteAllFrontierWords --- + + [Test] + public async Task DeleteAllFrontierWords_RemovesAllFrontierWords() + { + await _wordRepo.RepoCreate(Util.RandomWord(ProjId)); + await _wordRepo.RepoCreate(Util.RandomWord(ProjId)); + + var deleted = await _wordRepo.DeleteAllFrontierWords(ProjId); + + Assert.That(deleted, Is.True); + Assert.That(await _wordRepo.GetFrontierCount(ProjId), Is.EqualTo(0)); + } + + [Test] + public async Task DeleteAllFrontierWords_EmptyFrontier_ReturnsFalse() + { + var deleted = await _wordRepo.DeleteAllFrontierWords(ProjId); + Assert.That(deleted, Is.False); + } + + [Test] + public async Task DeleteAllFrontierWords_OnlyDeletesForProject() + { + await _wordRepo.RepoCreate(Util.RandomWord(ProjId)); + await _wordRepo.RepoCreate(Util.RandomWord(OtherProjId)); + + await _wordRepo.DeleteAllFrontierWords(ProjId); + + Assert.That(await _wordRepo.GetFrontierCount(ProjId), Is.EqualTo(0)); + Assert.That(await _wordRepo.GetFrontierCount(OtherProjId), Is.EqualTo(1)); + } + + // --- AddFrontier --- + + [Test] + public async Task AddFrontier_AddsOnlyToFrontier() + { + var words = Util.RandomWordList(2, ProjId); + var added = await _wordRepo.AddFrontier(words); + + Assert.That(added, Has.Count.EqualTo(2)); + Assert.That(await _wordRepo.HasFrontierWords(ProjId), Is.True); + // Words collection should NOT have these words + Assert.That(await _wordRepo.HasWords(ProjId), Is.False); + } + + // --- RepoUpdateFrontier (by id) --- + + [Test] + public async Task RepoUpdateFrontierById_UpdatesWord() + { + const string newVernacular = "updated-vernacular"; + var created = await _wordRepo.RepoCreate(Util.RandomWord(ProjId)); + + var updated = await _wordRepo.RepoUpdateFrontier(ProjId, created.Id, + w => w.Vernacular = newVernacular); + + Assert.That(updated, Is.Not.Null); + Assert.That(updated!.Vernacular, Is.EqualTo(newVernacular)); + // New word should be in frontier + var frontier = await _wordRepo.GetAllFrontier(ProjId); + Assert.That(frontier.Any(w => w.Vernacular == newVernacular), Is.True); + } + + [Test] + public async Task RepoUpdateFrontierById_MissingWord_ReturnsNull() + { + var result = await _wordRepo.RepoUpdateFrontier(ProjId, "000000000000000000000099", + _ => { }); + Assert.That(result, Is.Null); + } + + // --- RepoDeleteFrontier --- + + [Test] + public async Task RepoDeleteFrontier_RemovesFromFrontier_AddsToWords() + { + var created = await _wordRepo.RepoCreate(Util.RandomWord(ProjId)); + + var deleted = await _wordRepo.RepoDeleteFrontier(ProjId, created.Id, _ => { }); + + Assert.That(deleted, Is.Not.Null); + Assert.That(await _wordRepo.IsInFrontier(ProjId, created.Id), Is.False); + // Should still be in words + Assert.That(await _wordRepo.GetAllWords(ProjId), Has.Count.AtLeast(1)); + } + + [Test] + public async Task RepoDeleteFrontier_MissingWord_ReturnsNull() + { + var result = await _wordRepo.RepoDeleteFrontier(ProjId, "000000000000000000000099", _ => { }); + Assert.That(result, Is.Null); + } + + // --- RepoRestoreFrontier --- + + [Test] + public async Task RepoRestoreFrontier_RestoresWordToFrontier() + { + var created = await _wordRepo.RepoCreate(Util.RandomWord(ProjId)); + // Remove from frontier (but keep in words) + await _wordRepo.RepoDeleteFrontier(ProjId, created.Id, _ => { }); + Assert.That(await _wordRepo.IsInFrontier(ProjId, created.Id), Is.False); + + var restored = await _wordRepo.RepoRestoreFrontier(ProjId, [created.Id]); + + Assert.That(restored, Has.Count.EqualTo(1)); + Assert.That(await _wordRepo.IsInFrontier(ProjId, restored.First().Id), Is.True); + } + + [Test] + public async Task RepoRestoreFrontier_EmptyList_ReturnsEmpty() + { + var result = await _wordRepo.RepoRestoreFrontier(ProjId, []); + Assert.That(result, Is.Empty); + } + + // --- CountFrontierWordsWithDomain --- + + [Test] + public async Task CountFrontierWordsWithDomain_ReturnsCorrectCount() + { + const string domainId = "1.1"; + var wordWithDomain = Util.RandomWord(ProjId); + wordWithDomain.Senses.First().SemanticDomains.Add(new SemanticDomain { Id = domainId }); + var wordWithoutDomain = Util.RandomWord(ProjId); + await _wordRepo.RepoCreate(wordWithDomain); + await _wordRepo.RepoCreate(wordWithoutDomain); + + var count = await _wordRepo.CountFrontierWordsWithDomain(ProjId, domainId); + + Assert.That(count, Is.EqualTo(1)); + } + + // --- Transaction behavior: abort on error --- + + [Test] + public void RepoRevertReplaceFrontier_WithInvalidIds_Throws() + { + // Providing idsToRestore that overlap with idsToDelete should throw + Assert.That( + async () => await _wordRepo.RepoRevertReplaceFrontier(ProjId, ["id1"], ["id1"], _ => { }), + Throws.TypeOf()); + } + } +} From 6bb500b368cc1522b328b7f3a042bc7a66d26437 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Mar 2026 13:24:20 +0000 Subject: [PATCH 3/3] Mock MongoDbContext with InMemoryMongoDatabase; add WordRepository unit tests; replace WordRepositoryMock Co-authored-by: imnasnainaec <6411521+imnasnainaec@users.noreply.github.com> --- .../Controllers/AudioControllerTests.cs | 4 +- .../Controllers/LiftControllerTests.cs | 4 +- .../Controllers/MergeControllerTests.cs | 2 +- .../Controllers/WordControllerTests.cs | 4 +- .../Mocks/InMemoryMongoCollection.cs | 17 +++--- Backend.Tests/Mocks/MongoDbContextMock.cs | 38 +++++++----- .../Mocks/WordRepositoryTestHelper.cs | 5 +- Backend.Tests/Services/MergeServiceTests.cs | 58 +++++++++++-------- .../Services/StatisticsServiceTests.cs | 4 +- Backend.Tests/Services/WordServiceTests.cs | 4 +- Backend.Tests/Util.cs | 3 +- 11 files changed, 81 insertions(+), 62 deletions(-) diff --git a/Backend.Tests/Controllers/AudioControllerTests.cs b/Backend.Tests/Controllers/AudioControllerTests.cs index 7c5ad52e40..8941872341 100644 --- a/Backend.Tests/Controllers/AudioControllerTests.cs +++ b/Backend.Tests/Controllers/AudioControllerTests.cs @@ -14,7 +14,7 @@ namespace Backend.Tests.Controllers internal sealed class AudioControllerTests : IDisposable { private IProjectRepository _projRepo = null!; - private WordRepositoryMock _wordRepo = null!; + private WordRepositoryTestHelper _wordRepo = null!; private PermissionServiceMock _permissionService = null!; private WordService _wordService = null!; private AudioController _audioController = null!; @@ -35,7 +35,7 @@ public void Dispose() public void Setup() { _projRepo = new ProjectRepositoryMock(); - _wordRepo = new WordRepositoryMock(); + _wordRepo = new WordRepositoryTestHelper(); _permissionService = new PermissionServiceMock(); _wordService = new WordService(_wordRepo); _audioController = new AudioController(_wordRepo, _wordService, _permissionService); diff --git a/Backend.Tests/Controllers/LiftControllerTests.cs b/Backend.Tests/Controllers/LiftControllerTests.cs index 1b5a371393..70827247bb 100644 --- a/Backend.Tests/Controllers/LiftControllerTests.cs +++ b/Backend.Tests/Controllers/LiftControllerTests.cs @@ -21,7 +21,7 @@ internal sealed class LiftControllerTests : IDisposable { private IProjectRepository _projRepo = null!; private ISpeakerRepository _speakerRepo = null!; - private WordRepositoryMock _wordRepo = null!; + private WordRepositoryTestHelper _wordRepo = null!; private ILiftService _liftService = null!; private IWordService _wordService = null!; private LiftController _liftController = null!; @@ -47,7 +47,7 @@ public void Setup() _projRepo = new ProjectRepositoryMock(); var semDomRepo = new SemanticDomainRepositoryMock(); _speakerRepo = new SpeakerRepositoryMock(); - _wordRepo = new WordRepositoryMock(); + _wordRepo = new WordRepositoryTestHelper(); var ackService = new AcknowledgmentServiceMock(); _liftService = new LiftService(); var notifyService = new HubContextMock(); diff --git a/Backend.Tests/Controllers/MergeControllerTests.cs b/Backend.Tests/Controllers/MergeControllerTests.cs index a64c5555e3..0b019ed0be 100644 --- a/Backend.Tests/Controllers/MergeControllerTests.cs +++ b/Backend.Tests/Controllers/MergeControllerTests.cs @@ -38,7 +38,7 @@ public void Setup() new ServiceCollection().AddMemoryCache().BuildServiceProvider().GetRequiredService(); _mergeBlacklistRepo = new MergeBlacklistRepositoryMock(); _mergeGraylistRepo = new MergeGraylistRepositoryMock(); - _wordRepo = new WordRepositoryMock(); + _wordRepo = new WordRepositoryTestHelper(); var ackService = new AcknowledgmentServiceMock(); _wordService = new WordService(_wordRepo); _mergeService = new MergeService(_cache, _mergeBlacklistRepo, _mergeGraylistRepo, _wordRepo, _wordService); diff --git a/Backend.Tests/Controllers/WordControllerTests.cs b/Backend.Tests/Controllers/WordControllerTests.cs index 1e5a591af0..30e407dec8 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 WordRepositoryMock _wordRepo = null!; + private WordRepositoryTestHelper _wordRepo = null!; private IPermissionService _permissionService = null!; private IWordService _wordService = null!; private WordController _wordController = null!; @@ -31,7 +31,7 @@ public void Dispose() [SetUp] public void Setup() { - _wordRepo = new WordRepositoryMock(); + _wordRepo = new WordRepositoryTestHelper(); _wordService = new WordService(_wordRepo); _permissionService = new PermissionServiceMock(); _wordController = new WordController(_wordRepo, _wordService, _permissionService); diff --git a/Backend.Tests/Mocks/InMemoryMongoCollection.cs b/Backend.Tests/Mocks/InMemoryMongoCollection.cs index 221b7425fd..86a99a2273 100644 --- a/Backend.Tests/Mocks/InMemoryMongoCollection.cs +++ b/Backend.Tests/Mocks/InMemoryMongoCollection.cs @@ -188,7 +188,7 @@ private BsonDocument SerializeDocument(T document) return writer.Document; } - private IEnumerable GetMatchingDocuments(FilterDefinition filter, int? limit) + private IEnumerable GetMatchingBsonDocuments(FilterDefinition filter, int? limit) { var renderArgs = new RenderArgs(_serializer, _registry); var renderedFilter = filter.Render(renderArgs); @@ -198,18 +198,17 @@ private IEnumerable GetMatchingDocuments(FilterDefinition filter, int? lim matching = matching.Take(limit.Value); } - return matching.Select(doc => BsonSerializer.Deserialize(doc)); + return matching; } + private IEnumerable GetMatchingDocuments(FilterDefinition filter, int? limit) + => GetMatchingBsonDocuments(filter, limit).Select(doc => BsonSerializer.Deserialize(doc)); + private IAsyncCursor BuildCursor(FilterDefinition filter, int? limit) { - var documents = GetMatchingDocuments(filter, limit); - if (typeof(TProjection) != typeof(T)) - { - throw new NotSupportedException("Projection to a different type is not supported in InMemoryMongoCollection"); - } - - return new InMemoryAsyncCursor(documents.Cast()); + var matchingDocs = GetMatchingBsonDocuments(filter, limit); + var results = matchingDocs.Select(doc => BsonSerializer.Deserialize(doc)); + return new InMemoryAsyncCursor(results); } // --- BSON filter evaluator --- diff --git a/Backend.Tests/Mocks/MongoDbContextMock.cs b/Backend.Tests/Mocks/MongoDbContextMock.cs index 1b6f1ec56e..4da4b6ebf9 100644 --- a/Backend.Tests/Mocks/MongoDbContextMock.cs +++ b/Backend.Tests/Mocks/MongoDbContextMock.cs @@ -1,13 +1,22 @@ -using System; -using System.Threading.Tasks; +using System.Threading.Tasks; using BackendFramework.Interfaces; +using Moq; using MongoDB.Driver; namespace Backend.Tests.Mocks; +/// +/// A mock of for testing. +/// Provides an as and a no-op transaction. +/// +/// +/// Each instance has its own isolated , so tests using +/// different instances have separate data. +/// public class MongoDbContextMock : IMongoDbContext { - public IMongoDatabase Db => throw new NotSupportedException(); + public IMongoDatabase Db { get; } = new InMemoryMongoDatabase(); + public Task BeginTransaction() { return Task.FromResult(new MongoTransactionMock()); @@ -15,20 +24,19 @@ public Task BeginTransaction() private sealed class MongoTransactionMock : IMongoTransaction { - public IClientSessionHandle Session => null!; + /// + /// A non-null mock session needed so that MongoDB extension methods pass their null check. + /// The in-memory collections ignore the session entirely. + /// + private static readonly IClientSessionHandle MockSession = + new Mock().Object; + + public IClientSessionHandle Session => MockSession; - public Task CommitTransactionAsync() - { - return Task.CompletedTask; - } + public Task CommitTransactionAsync() => Task.CompletedTask; - public Task AbortTransactionAsync() - { - return Task.CompletedTask; - } + public Task AbortTransactionAsync() => Task.CompletedTask; - public void Dispose() - { - } + public void Dispose() { } } } diff --git a/Backend.Tests/Mocks/WordRepositoryTestHelper.cs b/Backend.Tests/Mocks/WordRepositoryTestHelper.cs index d6116090bc..ae3d723023 100644 --- a/Backend.Tests/Mocks/WordRepositoryTestHelper.cs +++ b/Backend.Tests/Mocks/WordRepositoryTestHelper.cs @@ -107,11 +107,12 @@ public Task CountFrontierWordsWithDomain(string projectId, string domainId) /// /// Adds a word directly to the Words collection (not the Frontier). - /// Assigns a new Id if the word has no Id. Mirrors the test behavior of the old WordRepositoryMock.Add. + /// Assigns a new valid ObjectId if the word has an empty or non-ObjectId Id. + /// Mirrors the test behavior of the old WordRepositoryMock.Add. /// public async Task Add(Word word) { - if (string.IsNullOrEmpty(word.Id)) + if (string.IsNullOrEmpty(word.Id) || !ObjectId.TryParse(word.Id, out _)) { word.Id = ObjectId.GenerateNewId().ToString(); } diff --git a/Backend.Tests/Services/MergeServiceTests.cs b/Backend.Tests/Services/MergeServiceTests.cs index b1daf8a092..26a8b6883c 100644 --- a/Backend.Tests/Services/MergeServiceTests.cs +++ b/Backend.Tests/Services/MergeServiceTests.cs @@ -16,13 +16,23 @@ internal sealed class MergeServiceTests private IMemoryCache _cache = null!; private IMergeBlacklistRepository _mergeBlacklistRepo = null!; private IMergeGraylistRepository _mergeGraylistRepo = null!; - private IWordRepository _wordRepo = null!; + private WordRepositoryTestHelper _wordRepo = null!; private IWordService _wordService = null!; private IMergeService _mergeService = null!; private const string ProjId = "MergeServiceTestProjId"; private const string UserId = "MergeServiceTestUserId"; + // Valid ObjectId-format IDs used in merge blacklist/graylist/frontier tests + private const string TestId1 = "000000000000000000000001"; + private const string TestId2 = "000000000000000000000002"; + private const string TestId3 = "000000000000000000000003"; + private const string TestId4 = "000000000000000000000004"; + private const string TestIdi = "000000000000000000000010"; + private const string TestIdii = "000000000000000000000011"; + private const string TestIdiii = "000000000000000000000012"; + private const string TestIdiv = "000000000000000000000013"; + [SetUp] public void Setup() { @@ -30,7 +40,7 @@ public void Setup() new ServiceCollection().AddMemoryCache().BuildServiceProvider().GetRequiredService(); _mergeBlacklistRepo = new MergeBlacklistRepositoryMock(); _mergeGraylistRepo = new MergeGraylistRepositoryMock(); - _wordRepo = new WordRepositoryMock(); + _wordRepo = new WordRepositoryTestHelper(); _wordService = new WordService(_wordRepo); _mergeService = new MergeService(_cache, _mergeBlacklistRepo, _mergeGraylistRepo, _wordRepo, _wordService); } @@ -268,14 +278,14 @@ public void UpdateMergeBlacklistTest() Id = "A", ProjectId = ProjId, UserId = UserId, - WordIds = ["1", "2", "3"] + WordIds = [TestId1, TestId2, TestId3] }; var entryB = new MergeWordSet { Id = "B", ProjectId = ProjId, UserId = UserId, - WordIds = ["1", "4"] + WordIds = [TestId1, TestId4] }; _ = _mergeBlacklistRepo.Create(entryA); @@ -284,12 +294,12 @@ public void UpdateMergeBlacklistTest() var oldBlacklist = _mergeBlacklistRepo.GetAllSets(ProjId).Result; Assert.That(oldBlacklist, Has.Count.EqualTo(2)); - // Make sure all wordIds are in the frontier EXCEPT 1. + // Make sure all wordIds are in the frontier EXCEPT TestId1. var frontier = new List { - new() {Id = "2", ProjectId = ProjId}, - new() {Id = "3", ProjectId = ProjId}, - new() {Id = "4", ProjectId = ProjId} + new() {Id = TestId2, ProjectId = ProjId}, + new() {Id = TestId3, ProjectId = ProjId}, + new() {Id = TestId4, ProjectId = ProjId} }; _ = _wordRepo.AddFrontier(frontier).Result; @@ -300,7 +310,7 @@ public void UpdateMergeBlacklistTest() // The only blacklistEntry with at least two ids in the frontier is A. var newBlacklist = _mergeBlacklistRepo.GetAllSets(ProjId).Result; Assert.That(newBlacklist, Has.Count.EqualTo(1)); - Assert.That(newBlacklist.First().WordIds, Is.EqualTo(new List { "2", "3" })); + Assert.That(newBlacklist.First().WordIds, Is.EqualTo(new List { TestId2, TestId3 })); } [Test] @@ -403,14 +413,14 @@ public void UpdateMergeGraylistTest() Id = "A", ProjectId = ProjId, UserId = UserId, - WordIds = ["1", "2", "3"] + WordIds = [TestId1, TestId2, TestId3] }; var entryB = new MergeWordSet { Id = "B", ProjectId = ProjId, UserId = UserId, - WordIds = ["1", "4"] + WordIds = [TestId1, TestId4] }; _ = _mergeGraylistRepo.Create(entryA); @@ -419,12 +429,12 @@ public void UpdateMergeGraylistTest() var oldGraylist = _mergeGraylistRepo.GetAllSets(ProjId).Result; Assert.That(oldGraylist, Has.Count.EqualTo(2)); - // Make sure all wordIds are in the frontier EXCEPT 1. + // Make sure all wordIds are in the frontier EXCEPT TestId1. var frontier = new List { - new() {Id = "2", ProjectId = ProjId}, - new() {Id = "3", ProjectId = ProjId}, - new() {Id = "4", ProjectId = ProjId} + new() {Id = TestId2, ProjectId = ProjId}, + new() {Id = TestId3, ProjectId = ProjId}, + new() {Id = TestId4, ProjectId = ProjId} }; _ = _wordRepo.AddFrontier(frontier).Result; @@ -435,7 +445,7 @@ public void UpdateMergeGraylistTest() // The only graylistEntry with at least two ids in the frontier is A. var newGraylist = _mergeGraylistRepo.GetAllSets(ProjId).Result; Assert.That(newGraylist, Has.Count.EqualTo(1)); - Assert.That(newGraylist.First().WordIds, Is.EqualTo(new List { "2", "3" })); + Assert.That(newGraylist.First().WordIds, Is.EqualTo(new List { TestId2, TestId3 })); } [Test] @@ -447,10 +457,10 @@ public void HasGraylistEntriesTrueTest() Id = "B", ProjectId = ProjId, UserId = UserId, - WordIds = ["i", "ii", "iii", "iv"] + WordIds = [TestIdi, TestIdii, TestIdiii, TestIdiv] }); - _ = _wordRepo.AddFrontier([new() { Id = "ii", ProjectId = ProjId }]).Result; - _ = _wordRepo.AddFrontier([new() { Id = "iv", ProjectId = ProjId }]).Result; + _ = _wordRepo.AddFrontier([new() { Id = TestIdii, ProjectId = ProjId }]).Result; + _ = _wordRepo.AddFrontier([new() { Id = TestIdiv, ProjectId = ProjId }]).Result; Assert.That(_mergeService.HasGraylistEntries(ProjId, UserId).Result, Is.True); } @@ -465,16 +475,16 @@ public void HasGraylistEntriesRemovesInvalidEntriesTest() Id = "B", ProjectId = ProjId, UserId = UserId, - WordIds = ["i", "ii", "iii", "iv"] + WordIds = [TestIdi, TestIdii, TestIdiii, TestIdiv] }); _ = _mergeGraylistRepo.Create(new() { Id = "C", ProjectId = ProjId, UserId = UserId, - WordIds = ["1", "2", "3"] + WordIds = [TestId1, TestId2, TestId3] }); - _ = _wordRepo.AddFrontier([new() { Id = "1", ProjectId = ProjId }]).Result; + _ = _wordRepo.AddFrontier([new() { Id = TestId1, ProjectId = ProjId }]).Result; // Check for graylist entries. Assert.That(_mergeService.HasGraylistEntries(ProjId, UserId).Result, Is.False); @@ -531,7 +541,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 @@ -555,7 +565,7 @@ public async Task TestGetAndStorePotentialDuplicatesMultipleConcurrentUsers() // Delay first GetFrontier call var delaySignal = new TaskCompletionSource(); - ((WordRepositoryMock)_wordRepo).SetGetFrontierDelay(delaySignal.Task); + _wordRepo.SetGetFrontierDelay(delaySignal.Task); var firstCallTask = _mergeService.GetAndStorePotentialDuplicates(ProjId, 10, 10, userId1); // Give first call time to start diff --git a/Backend.Tests/Services/StatisticsServiceTests.cs b/Backend.Tests/Services/StatisticsServiceTests.cs index a49ee6df6e..b91c007a73 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 WordRepositoryMock _wordRepo = null!; + private WordRepositoryTestHelper _wordRepo = null!; private IStatisticsService _statsService = null!; private const string ProjId = "StatsServiceTestProjId"; @@ -47,7 +47,7 @@ public void Setup() { _domainRepo = new SemanticDomainRepositoryMock(); _userRepo = new UserRepositoryMock(); - _wordRepo = new WordRepositoryMock(); + _wordRepo = new WordRepositoryTestHelper(); _statsService = new StatisticsService(_wordRepo, _domainRepo, _userRepo); } diff --git a/Backend.Tests/Services/WordServiceTests.cs b/Backend.Tests/Services/WordServiceTests.cs index 062d402b52..250cccbc2b 100644 --- a/Backend.Tests/Services/WordServiceTests.cs +++ b/Backend.Tests/Services/WordServiceTests.cs @@ -10,7 +10,7 @@ namespace Backend.Tests.Services { internal sealed class WordServiceTests { - private WordRepositoryMock _wordRepo = null!; + private WordRepositoryTestHelper _wordRepo = null!; private IWordService _wordService = null!; private const string ProjId = "WordServiceTestProjId"; @@ -20,7 +20,7 @@ internal sealed class WordServiceTests [SetUp] public void Setup() { - _wordRepo = new WordRepositoryMock(); + _wordRepo = new WordRepositoryTestHelper(); _wordService = new WordService(_wordRepo); } diff --git a/Backend.Tests/Util.cs b/Backend.Tests/Util.cs index 3338cd5899..99f29e1dfe 100644 --- a/Backend.Tests/Util.cs +++ b/Backend.Tests/Util.cs @@ -3,6 +3,7 @@ using System.IO; using System.Text; using BackendFramework.Models; +using MongoDB.Bson; using NUnit.Framework; using static System.Linq.Enumerable; @@ -53,7 +54,7 @@ public static Word RandomWord(string? projId = null) { return new() { - Id = RandString(), + Id = ObjectId.GenerateNewId().ToString(), Created = RandString(), Vernacular = RandString(), Modified = RandString(),