Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions backend/FwLite/FwLiteProjectSync.Tests/EntrySyncTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using MiniLcm.Exceptions;
using MiniLcm.Models;
using MiniLcm.SyncHelpers;
using MiniLcm.Tests;
using MiniLcm.Tests.AutoFakerHelpers;
using Soenneker.Utils.AutoBogus;

Expand Down Expand Up @@ -53,7 +54,10 @@ public abstract class EntrySyncTestsBase(ExtraWritingSystemsSyncFixture fixture)
{
public Task InitializeAsync()
{
Api = GetApi(_fixture);
BaseApi = GetApi(_fixture);
// Mirror production sync (CrdtFwdataProjectSyncService): validation only, no normalization,
// because the data is already normalized on both sides.
Api = TestMiniLcmWrappers.CreateValidationFactory().Create(BaseApi);
return Task.CompletedTask;
}

Expand All @@ -66,6 +70,7 @@ public Task DisposeAsync()

private readonly SyncFixture _fixture = fixture;
protected IMiniLcmApi Api = null!;
protected IMiniLcmApi BaseApi = null!;

private static readonly AutoFaker AutoFaker = new(AutoFakerDefault.MakeConfig(
ExtraWritingSystemsSyncFixture.VernacularWritingSystems));
Expand Down Expand Up @@ -99,12 +104,10 @@ public enum ApiType
public async Task CanSyncRandomEntries(ApiType? roundTripApiType)
{
// arrange
var currentApiType = Api switch
var currentApiType = BaseApi switch
{
FwDataMiniLcmApi => ApiType.FwData,
CrdtMiniLcmApi => ApiType.Crdt,
// This works now, because we're not currently wrapping Api,
// but if we ever do, then we want this to throw, so we know we need to detect the api differently.
_ => throw new InvalidOperationException("Unknown API type")
};

Expand Down
1 change: 1 addition & 0 deletions backend/FwLite/MiniLcm.Tests/MiniLcm.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Soenneker.Utils.AutoBogus" />
<PackageReference Include="System.Linq.Async" />
<PackageReference Include="Moq" />
Expand Down
7 changes: 1 addition & 6 deletions backend/FwLite/MiniLcm.Tests/MiniLcmTestBase.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
using MiniLcm.Normalization;
using MiniLcm.Tests.AutoFakerHelpers;
using MiniLcm.Wrappers;
using Soenneker.Utils.AutoBogus;

namespace MiniLcm.Tests;
Expand All @@ -17,10 +15,7 @@ public virtual async Task InitializeAsync()
{
BaseApi = await NewApi();
BaseApi.Should().NotBeNull();
Api = BaseApi.WrapWith([
new MiniLcmApiQueryNormalizationWrapperFactory(),
new MiniLcmApiWriteNormalizationWrapperFactory(),
], null!);
Api = TestMiniLcmWrappers.CreateUserFacingWrappers().Apply(BaseApi, null!);
Api.Should().NotBeNull();
}

Expand Down
37 changes: 37 additions & 0 deletions backend/FwLite/MiniLcm.Tests/TestMiniLcmWrappers.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
using Microsoft.Extensions.DependencyInjection;
using MiniLcm.Validators;
using MiniLcm.Wrappers;

namespace MiniLcm.Tests;

/// <summary>
/// Builds the production MiniLcm wrapper stack via <see cref="MiniLcmValidatorsExtensions.AddMiniLcmValidators"/>,
/// so tests exercise the same validators and wrapper composition that real API entry points use
/// (and pick up new registrations automatically). The resolved wrappers hold no provider-scoped state,
/// so the throwaway provider is disposed immediately.
/// </summary>
public static class TestMiniLcmWrappers
{
private static ServiceProvider BuildProvider() =>
new ServiceCollection().AddMiniLcmValidators().BuildServiceProvider();

/// <summary>
/// The full user-facing stack (query normalization, validation, write normalization) that real
/// API entry points apply via <see cref="MiniLcmApiUserFacingWrappers"/>.
/// </summary>
public static MiniLcmApiUserFacingWrappers CreateUserFacingWrappers()
{
using var provider = BuildProvider();
return provider.GetRequiredService<MiniLcmApiUserFacingWrappers>();
}

/// <summary>
/// The validation-only wrapper that the sync path applies (see <c>CrdtFwdataProjectSyncService</c>),
/// which deliberately skips normalization because both sides are already normalized.
/// </summary>
public static MiniLcmApiValidationWrapperFactory CreateValidationFactory()
{
using var provider = BuildProvider();
return provider.GetRequiredService<MiniLcmApiValidationWrapperFactory>();
}
}
6 changes: 4 additions & 2 deletions backend/FwLite/MiniLcm.Tests/UpdateEntryTestsBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -84,11 +84,13 @@ public async Task UpdateEntry_SupportsRemovingARichMultiStringWs()
[Fact]
public async Task UpdateEntry_RoundTripsEmptyStrings()
{
var entry = await Api.GetEntry(Entry1Id);
// Empty MultiString values are rejected by validation, so this exercises the raw storage layer:
// empty values can still reach storage (e.g. via sync) and must round-trip.
var entry = await BaseApi.GetEntry(Entry1Id);
ArgumentNullException.ThrowIfNull(entry);
var before = entry.Copy();
entry.CitationForm["en"] = string.Empty;
var updatedEntry = await Api.UpdateEntry(before, entry);
var updatedEntry = await BaseApi.UpdateEntry(before, entry);
updatedEntry.CitationForm["en"].Should().Be(string.Empty);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
using FluentValidation;
using MiniLcm.SyncHelpers;
using Moq;

namespace MiniLcm.Tests.Validators;

/// <summary>
/// Tests the validation wrapper's wiring through the IMiniLcmApi interface - the seam that broke in
/// #2362 - not the validator rules (those have their own unit tests). Two things are worth pinning:
/// that methods we expect to validate actually do (so a silently-dead override like #2362's is caught),
/// and that methods which deliberately DON'T validate despite having a validator (CreateEntry,
/// CreatePublication, CreateMorphType) keep passing through - so a well-meaning "fix" that turns
/// validation on, and breaks sync of empty FLEx values, trips a red test instead.
/// </summary>
public class MiniLcmApiValidationWrapperTests
{
private readonly Mock<IMiniLcmApi> _inner = new();
private readonly IMiniLcmApi _api;

public MiniLcmApiValidationWrapperTests()
{
_api = TestMiniLcmWrappers.CreateValidationFactory().Create(_inner.Object);
}

private static Entry ValidEntry() => new() { Id = Guid.NewGuid(), LexemeForm = new() { { "en", "lexeme" } } };

// An entry the EntryValidator rejects: a MultiString that carries an empty value (NoEmptyValues).
// This is the shape that routinely arrives from FLEx during sync/import.
private static Entry EntryWithEmptyValue() => new()
{
Id = Guid.NewGuid(),
LexemeForm = new() { { "en", "lexeme" } },
CitationForm = new() { { "en", "" } },
};

[Fact]
public async Task UpdateEntry_BeforeAfter_ValidatesTheUpdatedEntry()
{
var act = () => _api.UpdateEntry(ValidEntry(), EntryWithEmptyValue());

await act.Should().ThrowAsync<ValidationException>();
_inner.Verify(a => a.UpdateEntry(It.IsAny<Entry>(), It.IsAny<Entry>(), It.IsAny<IMiniLcmApi?>()), Times.Never);
}

[Fact]
public async Task CreateSense_ValidatesTheSense()
{
var sense = new Sense { Id = Guid.NewGuid(), Gloss = new() { { "en", "" } } };

var act = () => _api.CreateSense(Guid.NewGuid(), sense);

await act.Should().ThrowAsync<ValidationException>();
_inner.Verify(a => a.CreateSense(It.IsAny<Guid>(), It.IsAny<Sense>(), It.IsAny<BetweenPosition?>()), Times.Never);
}

[Fact]
public async Task CreateEntry_DoesNotValidate_SoEmptyFlexValuesReachStorage()
{
var entry = EntryWithEmptyValue();

var act = () => _api.CreateEntry(entry);

await act.Should().NotThrowAsync();
_inner.Verify(a => a.CreateEntry(entry, It.IsAny<CreateEntryOptions?>()), Times.Once);
}

[Fact]
public async Task CreatePublication_DoesNotValidate()
{
var publication = new Publication { Id = Guid.NewGuid(), Name = new() { { "en", "" } } };

var act = () => _api.CreatePublication(publication);

await act.Should().NotThrowAsync();
_inner.Verify(a => a.CreatePublication(publication), Times.Once);
}

[Fact]
public async Task CreateMorphType_DoesNotValidate()
{
var morphType = new MorphType { Id = Guid.NewGuid(), Kind = MorphTypeKind.Unknown, Name = new() { { "en", "" } } };

var act = () => _api.CreateMorphType(morphType);

await act.Should().NotThrowAsync();
_inner.Verify(a => a.CreateMorphType(morphType), Times.Once);
}
}
Loading
Loading