diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 6b7095305..4f487990c 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,10 +3,11 @@ "isRoot": true, "tools": { "dotnet-ef": { - "version": "8.0.10", + "version": "9.0.0", "commands": [ "dotnet-ef" - ] + ], + "rollForward": false } } -} +} \ No newline at end of file diff --git a/.github/workflows/develop-api.yaml b/.github/workflows/develop-api.yaml index 04ddb8c84..17253952f 100644 --- a/.github/workflows/develop-api.yaml +++ b/.github/workflows/develop-api.yaml @@ -5,6 +5,7 @@ on: paths: - 'backend/**' - '!backend/FwLite/**' + - 'frontend/**' - '.github/workflows/lexbox-api.yaml' - '.github/workflows/deploy.yaml' - 'deployment/lexbox-deployment.yaml' @@ -14,6 +15,7 @@ on: paths: - 'backend/**' - '!backend/FwLite/**' + - 'frontend/**' - '.github/workflows/lexbox-api.yaml' - '.github/workflows/deploy.yaml' - 'deployment/lexbox-deployment.yaml' diff --git a/.github/workflows/fw-lite.yaml b/.github/workflows/fw-lite.yaml index 37043dfda..f36d825e6 100644 --- a/.github/workflows/fw-lite.yaml +++ b/.github/workflows/fw-lite.yaml @@ -35,10 +35,14 @@ jobs: submodules: true - uses: actions/setup-dotnet@v4 with: - dotnet-version: '8.x' + dotnet-version: '9.x' - uses: actions/setup-node@v4 with: node-version-file: './frontend/package.json' + + - name: Setup Maui + run: dotnet workload install maui-windows + - name: Set Version id: setVersion shell: bash @@ -86,7 +90,7 @@ jobs: path: frontend/viewer/dist - uses: actions/setup-dotnet@v4 with: - dotnet-version: '8.x' + dotnet-version: '9.x' - name: Dotnet build working-directory: backend/FwLite/LocalWebApp @@ -123,7 +127,7 @@ jobs: path: frontend/viewer/dist - uses: actions/setup-dotnet@v4 with: - dotnet-version: '8.x' + dotnet-version: '9.x' - name: Dotnet build working-directory: backend/FwLite/LocalWebApp @@ -156,7 +160,7 @@ jobs: path: frontend/viewer/dist - uses: actions/setup-dotnet@v4 with: - dotnet-version: '8.x' + dotnet-version: '9.x' - name: Setup Maui run: dotnet workload install maui-windows diff --git a/.github/workflows/integration-test-gha.yaml b/.github/workflows/integration-test-gha.yaml index a604eea41..1e2315e21 100644 --- a/.github/workflows/integration-test-gha.yaml +++ b/.github/workflows/integration-test-gha.yaml @@ -22,6 +22,9 @@ jobs: - uses: actions/checkout@v4 with: submodules: true + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: '9.x' - name: Install Task uses: arduino/setup-task@v2 with: diff --git a/.github/workflows/integration-test.yaml b/.github/workflows/integration-test.yaml index d35311121..e39d17716 100644 --- a/.github/workflows/integration-test.yaml +++ b/.github/workflows/integration-test.yaml @@ -85,7 +85,7 @@ jobs: env: DOTNET_INSTALL_DIR: ${{ inputs.runs-on == 'self-hosted' && '/opt/hostedtoolcache/dotnet' || '' }} #poor man's conditional with: - dotnet-version: '8.x' + dotnet-version: '9.x' - uses: MatteoH2O1999/setup-python@429b7dee8a48c31eb72ce0b420ea938ff51c2f11 # v3.2.1 id: python if: ${{ inputs.runs-on != 'windows-latest' && !env.act && inputs.hg-version == '3' }} diff --git a/.github/workflows/lexbox-api.yaml b/.github/workflows/lexbox-api.yaml index d0774bcd0..135e5ae95 100644 --- a/.github/workflows/lexbox-api.yaml +++ b/.github/workflows/lexbox-api.yaml @@ -49,7 +49,7 @@ jobs: submodules: true - uses: actions/setup-dotnet@v4 with: - dotnet-version: '8.x' + dotnet-version: '9.x' - name: Install Task uses: arduino/setup-task@v2 with: diff --git a/LexBox.sln b/LexBox.sln index 612c0e95e..cfa667dcd 100644 --- a/LexBox.sln +++ b/LexBox.sln @@ -21,7 +21,7 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testing", "backend\Testing\ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FixFwData", "backend\FixFwData\FixFwData.csproj", "{D7FC8B93-15A1-4D0B-9EAB-45596DB147F4}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "LfNext", "LfNext", "{7B6E21C4-5AF4-4505-B7D9-59A3886C5090}" + Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "FwLite", "FwLite", "{7B6E21C4-5AF4-4505-B7D9-59A3886C5090}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LfClassicData", "backend\LfClassicData\LfClassicData.csproj", "{E8BB768B-C3DC-4BE6-9B9F-82319E05AF86}" EndProject @@ -53,6 +53,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MiniLcm.Tests", "backend\Fw EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FwHeadless", "backend\FwHeadless\FwHeadless.csproj", "{ECBA46AB-AF87-4D4D-9716-FD77264B817F}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FwLiteShared", "backend\FwLite\FwLiteShared\FwLiteShared.csproj", "{73DC604C-C501-410D-B56B-0544AD6EF1C2}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -145,6 +147,10 @@ Global {ECBA46AB-AF87-4D4D-9716-FD77264B817F}.Debug|Any CPU.Build.0 = Debug|Any CPU {ECBA46AB-AF87-4D4D-9716-FD77264B817F}.Release|Any CPU.ActiveCfg = Release|Any CPU {ECBA46AB-AF87-4D4D-9716-FD77264B817F}.Release|Any CPU.Build.0 = Release|Any CPU + {73DC604C-C501-410D-B56B-0544AD6EF1C2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {73DC604C-C501-410D-B56B-0544AD6EF1C2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {73DC604C-C501-410D-B56B-0544AD6EF1C2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {73DC604C-C501-410D-B56B-0544AD6EF1C2}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -165,6 +171,7 @@ Global {5A9011D8-6EC1-4550-BDD7-AFF00DB2B921} = {7B6E21C4-5AF4-4505-B7D9-59A3886C5090} {00AE5440-0E36-4488-935B-5B11301BA57D} = {7B6E21C4-5AF4-4505-B7D9-59A3886C5090} {ECBA46AB-AF87-4D4D-9716-FD77264B817F} = {7B6E21C4-5AF4-4505-B7D9-59A3886C5090} + {73DC604C-C501-410D-B56B-0544AD6EF1C2} = {7B6E21C4-5AF4-4505-B7D9-59A3886C5090} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {440AE83C-6DB0-4F18-B2C1-BCD33F0645B6} diff --git a/Taskfile.yml b/Taskfile.yml index 0306ea674..ec5513e7e 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -68,7 +68,12 @@ tasks: cmds: - tilt up - # dev + prod-ui-up: + interactive: true + desc: 'Starts the cluster using the production build of UI, good for automated testing' + cmds: + - tilt up -- --prod-ui-build + # dev infra-up: desc: 'Starts infrastructure for our ui and api, does not forward ports for api, if you want port forwarding use k8s:infra-forward' cmds: diff --git a/Tiltfile b/Tiltfile index 24b320e75..224a00bcf 100644 --- a/Tiltfile +++ b/Tiltfile @@ -4,8 +4,10 @@ version_settings(constraint='>=0.33.20') secret_settings(disable_scrub=True) config.define_bool("lexbox-api-local") +config.define_bool("prod-ui-build") cfg = config.parse() forward_lexbox = not cfg.get("lexbox-api-local", False) +prod_ui_build = cfg.get("prod-ui-build", False) docker_build( 'local-dev-init', @@ -28,21 +30,28 @@ docker_build( context='backend', dockerfile='./backend/FwHeadless/dev.Dockerfile', only=['.'], - ignore=['LexBoxApi'], + ignore=['LexBoxApi', '**/Mercurial', '**/MercurialExtensions'], live_update=[ sync('backend', '/src/backend') ] ) - -docker_build( - 'ghcr.io/sillsdev/lexbox-ui', - context='frontend', - dockerfile='./frontend/dev.Dockerfile', - only=['.'], - live_update=[ - sync('frontend', '/app'), - ] -) +if prod_ui_build: + docker_build( + 'ghcr.io/sillsdev/lexbox-ui', + context='frontend', + dockerfile='./frontend/Dockerfile', + only=['.'] + ) +else: + docker_build( + 'ghcr.io/sillsdev/lexbox-ui', + context='frontend', + dockerfile='./frontend/dev.Dockerfile', + only=['.'], + live_update=[ + sync('frontend', '/app'), + ] + ) docker_build( 'ghcr.io/sillsdev/lexbox-hgweb', diff --git a/backend/Directory.Build.props b/backend/Directory.Build.props index e70320586..574e961ef 100644 --- a/backend/Directory.Build.props +++ b/backend/Directory.Build.props @@ -9,7 +9,10 @@ $(MSBuildProjectDirectory)/bin/container/ + dev + net9.0 false + enable enable Nullable diff --git a/backend/Dockerfile b/backend/Dockerfile index 4c4630a7f..3a43c094f 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,10 +1,10 @@ # syntax=docker/dockerfile:1 -FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base +FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base WORKDIR /app EXPOSE 80 EXPOSE 443 -FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build COPY . . # WORKDIR /src diff --git a/backend/FixFwData/FixFwData.csproj b/backend/FixFwData/FixFwData.csproj index 7c217f173..e96561fca 100644 --- a/backend/FixFwData/FixFwData.csproj +++ b/backend/FixFwData/FixFwData.csproj @@ -2,14 +2,17 @@ WinExe - net8.0 - enable - enable + FixFwData + FixFwData + SIL Global + SIL Global + LexBoxApi Testing + Copyright © 2024 SIL Global - - + + diff --git a/backend/FwHeadless/FwHeadless.csproj b/backend/FwHeadless/FwHeadless.csproj index 82dcf0521..a106c1fa5 100644 --- a/backend/FwHeadless/FwHeadless.csproj +++ b/backend/FwHeadless/FwHeadless.csproj @@ -1,17 +1,15 @@ - net9.0 - enable - enable $(MSBuildProjectDirectory) - + + diff --git a/backend/FwHeadless/HttpClientAuthHandler.cs b/backend/FwHeadless/HttpClientAuthHandler.cs index cc3efa799..41d364334 100644 --- a/backend/FwHeadless/HttpClientAuthHandler.cs +++ b/backend/FwHeadless/HttpClientAuthHandler.cs @@ -34,7 +34,10 @@ protected override async Task SendAsync(HttpRequestMessage private async Task SetAuthHeader(HttpRequestMessage request, CancellationToken cancellationToken, Uri lexboxUrl) { var cookieContainer = new CookieContainer(); - cookieContainer.Add(new Cookie(LexAuthConstants.AuthCookieName, await GetToken(cancellationToken), null, lexboxUrl.Authority)); + cookieContainer.Add(new Cookie(LexAuthConstants.AuthCookieName, await GetToken(cancellationToken), null, lexboxUrl.Host) + { + Port = $"\"{lexboxUrl.Port}\"" + }); request.Headers.Add("Cookie", cookieContainer.GetCookieHeader(lexboxUrl)); } diff --git a/backend/FwHeadless/Program.cs b/backend/FwHeadless/Program.cs index e4e897016..620635504 100644 --- a/backend/FwHeadless/Program.cs +++ b/backend/FwHeadless/Program.cs @@ -4,6 +4,7 @@ using FwLiteProjectSync; using LcmCrdt; using LcmCrdt.RemoteSync; +using LexCore.Sync; using LexData; using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.Extensions.Options; @@ -51,13 +52,13 @@ app.Run(); -static async Task, NotFound, ProblemHttpResult>> ExecuteMergeRequest( +static async Task, NotFound, ProblemHttpResult>> ExecuteMergeRequest( ILogger logger, IServiceProvider services, SendReceiveService srService, IOptions config, FwDataFactory fwDataFactory, - ProjectsService projectsService, + CrdtProjectsService projectsService, ProjectLookupService projectLookupService, CrdtFwdataProjectSyncService syncService, CrdtHttpSyncService crdtHttpSyncService, @@ -69,7 +70,7 @@ if (dryRun) { logger.LogInformation("Dry run, not actually syncing"); - return TypedResults.Ok(new CrdtFwdataProjectSyncService.SyncResult(0, 0)); + return TypedResults.Ok(new SyncResult(0, 0)); } var projectCode = await projectLookupService.GetProjectCode(projectId); @@ -136,7 +137,7 @@ static async Task SetupFwData(FwDataProject fwDataProject, static async Task SetupCrdtProject(string crdtFile, ProjectLookupService projectLookupService, Guid projectId, - ProjectsService projectsService, + CrdtProjectsService projectsService, string projectFolder, Guid fwProjectId, string lexboxUrl) diff --git a/backend/FwHeadless/dev.Dockerfile b/backend/FwHeadless/dev.Dockerfile index db60a422c..38a83bf9d 100644 --- a/backend/FwHeadless/dev.Dockerfile +++ b/backend/FwHeadless/dev.Dockerfile @@ -11,13 +11,13 @@ WORKDIR /src/backend # Uncomment line below if second COPY fails # RUN mkdir -p FwLite && chown www-data:www-data FwLite # Copy the main source project files -COPY --chown=www-data:www-data *.sln FwHeadless/FwHeadless.csproj FixFwData/FixFwData.csproj LexCore/LexCore.csproj LexData/LexData.csproj ./ +COPY --chown=www-data:www-data *.sln FwHeadless/FwHeadless.csproj FixFwData/FixFwData.csproj LexCore/LexCore.csproj LexData/LexData.csproj Directory.Build.props ./ # move them into the proper sub folders, based on the name of the project RUN for file in $(ls *.csproj); do dir=${file%.*}; mkdir -p ${dir}/ && mv -v $file ${dir}/; done # Do the same for csproj files in slightly different hierarchies COPY --chown=www-data:www-data harmony/src/*/*.csproj ./ RUN for file in $(ls *.csproj); do dir=${file%.*}; mkdir -p harmony/src/${dir}/ && mv -v $file harmony/src/${dir}/; done -COPY --chown=www-data:www-data harmony/src/Directory.Build.props ./harmony/src/ +COPY --chown=www-data:www-data harmony/src/Directory.Build.props harmony/Directory.Packages.props ./harmony/src/ COPY --chown=www-data:www-data FwLite/FwDataMiniLcmBridge/FwDataMiniLcmBridge.csproj FwLite/LcmCrdt/LcmCrdt.csproj FwLite/MiniLcm/MiniLcm.csproj FwLite/FwLiteProjectSync/FwLiteProjectSync.csproj ./ RUN for file in $(ls *.csproj); do dir=${file%.*}; mkdir -p FwLite/${dir}/ && mv -v $file FwLite/${dir}/; done diff --git a/backend/FwLite/FwDataMiniLcmBridge.Tests/Fixtures/FwDataTestsKernel.cs b/backend/FwLite/FwDataMiniLcmBridge.Tests/Fixtures/FwDataTestsKernel.cs index cc951c27d..84a55beee 100644 --- a/backend/FwLite/FwDataMiniLcmBridge.Tests/Fixtures/FwDataTestsKernel.cs +++ b/backend/FwLite/FwDataMiniLcmBridge.Tests/Fixtures/FwDataTestsKernel.cs @@ -6,13 +6,16 @@ namespace FwDataMiniLcmBridge.Tests.Fixtures; public static class FwDataTestsKernel { - public static IServiceCollection AddTestFwDataBridge(this IServiceCollection services) + public static IServiceCollection AddTestFwDataBridge(this IServiceCollection services, bool mockProjectLoader = true) { services.AddFwDataBridge(); services.AddSingleton(_ => new ConfigurationRoot([])); - services.AddSingleton(); - services.AddSingleton(sp => sp.GetRequiredService()); - services.AddSingleton(); + if (mockProjectLoader) + { + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(); + } return services; } } diff --git a/backend/FwLite/FwDataMiniLcmBridge.Tests/Fixtures/MockFwProjectList.cs b/backend/FwLite/FwDataMiniLcmBridge.Tests/Fixtures/MockFwProjectList.cs index 43e3a65b8..6463670c1 100644 --- a/backend/FwLite/FwDataMiniLcmBridge.Tests/Fixtures/MockFwProjectList.cs +++ b/backend/FwLite/FwDataMiniLcmBridge.Tests/Fixtures/MockFwProjectList.cs @@ -7,7 +7,7 @@ public class MockFwProjectList(IOptions config, MockFwProjec { public override IEnumerable EnumerateProjects() { - return loader.Projects.Keys.Select(k => new FwDataProject(k, config.Value.ProjectsFolder)); + return loader.Projects.Keys.Select(k => new FwDataProject(k, _config.Value.ProjectsFolder)); } public override FwDataProject? GetProject(string name) diff --git a/backend/FwLite/FwDataMiniLcmBridge.Tests/FwDataMiniLcmBridge.Tests.csproj b/backend/FwLite/FwDataMiniLcmBridge.Tests/FwDataMiniLcmBridge.Tests.csproj index 13c53ae52..c7f4be0c0 100644 --- a/backend/FwLite/FwDataMiniLcmBridge.Tests/FwDataMiniLcmBridge.Tests.csproj +++ b/backend/FwLite/FwDataMiniLcmBridge.Tests/FwDataMiniLcmBridge.Tests.csproj @@ -1,9 +1,6 @@ - net8.0 - enable - enable false true @@ -18,16 +15,16 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - + + + + + - all - runtime; build; native; contentfiles; analyzers; buildtransitive + all + runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/backend/FwLite/FwDataMiniLcmBridge.Tests/MiniLcmTests/SortingTests.cs b/backend/FwLite/FwDataMiniLcmBridge.Tests/MiniLcmTests/SortingTests.cs new file mode 100644 index 000000000..5681d7e93 --- /dev/null +++ b/backend/FwLite/FwDataMiniLcmBridge.Tests/MiniLcmTests/SortingTests.cs @@ -0,0 +1,12 @@ +using FwDataMiniLcmBridge.Tests.Fixtures; + +namespace FwDataMiniLcmBridge.Tests.MiniLcmTests; + +[Collection(ProjectLoaderFixture.Name)] +public class SortingTests(ProjectLoaderFixture fixture) : SortingTestsBase +{ + protected override Task NewApi() + { + return Task.FromResult(fixture.NewProjectApi("sorting-test", "en", "en")); + } +} diff --git a/backend/FwLite/FwDataMiniLcmBridge.Tests/UpdateComplexFormsTests.cs b/backend/FwLite/FwDataMiniLcmBridge.Tests/UpdateComplexFormsTests.cs index 7499f06e3..acc4c96bf 100644 --- a/backend/FwLite/FwDataMiniLcmBridge.Tests/UpdateComplexFormsTests.cs +++ b/backend/FwLite/FwDataMiniLcmBridge.Tests/UpdateComplexFormsTests.cs @@ -34,7 +34,7 @@ await _api.UpdateEntry(complexForm.Id, ComplexFormComponent.FromEntries(complexForm, component))); var entry = await _api.GetEntry(complexForm.Id); entry.Should().NotBeNull(); - entry!.Components.Should() + entry.Components.Should() .ContainSingle(c => c.ComponentEntryId == component.Id && c.ComplexFormEntryId == complexForm.Id); } @@ -63,7 +63,7 @@ await _api.UpdateEntry(complexForm.Id, new UpdateObjectInput().Remove(e => e.Components, 0)); var entry = await _api.GetEntry(complexForm.Id); entry.Should().NotBeNull(); - entry!.Components.Should().BeEmpty(); + entry.Components.Should().BeEmpty(); } [Fact] @@ -92,7 +92,7 @@ await _api.UpdateEntry(complexForm.Id, new UpdateObjectInput().Set(e => e.Components[0].ComponentEntryId, component2.Id)); var entry = await _api.GetEntry(complexForm.Id); entry.Should().NotBeNull(); - var complexFormComponent = entry!.Components.Should().ContainSingle().Subject; + var complexFormComponent = entry.Components.Should().ContainSingle().Subject; complexFormComponent.ComponentEntryId.Should().Be(component2.Id); } @@ -127,7 +127,7 @@ await _api.UpdateEntry(complexForm.Id, new UpdateObjectInput().Set(e => e.Components[0].ComponentSenseId, component2SenseId)); var entry = await _api.GetEntry(complexForm.Id); entry.Should().NotBeNull(); - var complexFormComponent = entry!.Components.Should().ContainSingle().Subject; + var complexFormComponent = entry.Components.Should().ContainSingle().Subject; complexFormComponent.ComponentEntryId.Should().Be(component2.Id); complexFormComponent.ComponentSenseId.Should().Be(component2SenseId); } @@ -163,7 +163,7 @@ await _api.UpdateEntry(complexForm.Id, new UpdateObjectInput().Set(e => e.Components[0].ComponentSenseId, null)); var entry = await _api.GetEntry(complexForm.Id); entry.Should().NotBeNull(); - entry!.Components.Should() + entry.Components.Should() .ContainSingle(c => c.ComponentEntryId == component2.Id && c.ComponentSenseId == null); } @@ -193,7 +193,7 @@ await _api.UpdateEntry(complexForm.Id, new UpdateObjectInput().Set(e => e.Components[0].ComplexFormEntryId, complexForm2.Id)); var entry = await _api.GetEntry(complexForm2.Id); entry.Should().NotBeNull(); - var complexFormComponent = entry!.Components.Should().ContainSingle().Subject; + var complexFormComponent = entry.Components.Should().ContainSingle().Subject; complexFormComponent.ComponentEntryId.Should().Be(component1.Id); } @@ -208,7 +208,7 @@ await _api.UpdateEntry(component.Id, ComplexFormComponent.FromEntries(complexForm, component))); var entry = await _api.GetEntry(component.Id); entry.Should().NotBeNull(); - entry!.ComplexForms.Should() + entry.ComplexForms.Should() .ContainSingle(c => c.ComponentEntryId == component.Id && c.ComplexFormEntryId == complexForm.Id); } @@ -237,7 +237,7 @@ await _api.UpdateEntry(component.Id, new UpdateObjectInput().Remove(e => e.ComplexForms, 0)); var entry = await _api.GetEntry(component.Id); entry.Should().NotBeNull(); - entry!.ComplexForms.Should().BeEmpty(); + entry.ComplexForms.Should().BeEmpty(); } [Fact] @@ -266,7 +266,7 @@ await _api.UpdateEntry(component1.Id, new UpdateObjectInput().Set(e => e.ComplexForms[0].ComplexFormEntryId, complexForm2.Id)); var entry = await _api.GetEntry(component1.Id); entry.Should().NotBeNull(); - var complexFormComponent = entry!.ComplexForms.Should().ContainSingle().Subject; + var complexFormComponent = entry.ComplexForms.Should().ContainSingle().Subject; complexFormComponent.ComplexFormEntryId.Should().Be(complexForm2.Id); } @@ -296,7 +296,7 @@ await _api.UpdateEntry(component1.Id, new UpdateObjectInput().Set(e => e.ComplexForms[0].ComponentEntryId, component2.Id)); var entry = await _api.GetEntry(component2.Id); entry.Should().NotBeNull(); - var complexFormComponent = entry!.ComplexForms.Should().ContainSingle().Subject; + var complexFormComponent = entry.ComplexForms.Should().ContainSingle().Subject; complexFormComponent.ComponentEntryId.Should().Be(component2.Id); complexFormComponent.ComplexFormEntryId.Should().Be(complexFormId); } @@ -338,7 +338,7 @@ await _api.UpdateEntry(component1.Id, new UpdateObjectInput().Set(e => e.ComplexForms[0].ComponentSenseId, component1SenseId2)); var entry = await _api.GetEntry(component1.Id); entry.Should().NotBeNull(); - var complexFormComponent = entry!.ComplexForms.Should().ContainSingle().Subject; + var complexFormComponent = entry.ComplexForms.Should().ContainSingle().Subject; complexFormComponent.ComponentEntryId.Should().Be(componentId1); complexFormComponent.ComponentSenseId.Should().Be(component1SenseId2); } @@ -353,7 +353,7 @@ await _api.UpdateEntry(complexForm.Id, new UpdateObjectInput().Add(e => e.ComplexFormTypes, complexFormType)); var entry = await _api.GetEntry(complexForm.Id); entry.Should().NotBeNull(); - entry!.ComplexFormTypes.Should().ContainSingle(c => c.Id == complexFormType.Id); + entry.ComplexFormTypes.Should().ContainSingle(c => c.Id == complexFormType.Id); } [Fact] @@ -369,7 +369,7 @@ await _api.UpdateEntry(complexForm.Id, new UpdateObjectInput().Remove(e => e.ComplexFormTypes, 0)); var entry = await _api.GetEntry(complexForm.Id); entry.Should().NotBeNull(); - entry!.ComplexFormTypes.Should().BeEmpty(); + entry.ComplexFormTypes.Should().BeEmpty(); } [Fact] @@ -382,6 +382,6 @@ await _api.UpdateEntry(complexForm.Id, new UpdateObjectInput().Set(e => e.ComplexFormTypes[0].Id, complexFormType2.Id)); var entry = await _api.GetEntry(complexForm.Id); entry.Should().NotBeNull(); - entry!.ComplexFormTypes.Should().ContainSingle().Which.Id.Should().Be(complexFormType2.Id); + entry.ComplexFormTypes.Should().ContainSingle().Which.Id.Should().Be(complexFormType2.Id); } } diff --git a/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs b/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs index 900649e58..899823455 100644 --- a/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs +++ b/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs @@ -2,12 +2,15 @@ using System.Globalization; using System.Reflection; using System.Text; +using FluentValidation; using FwDataMiniLcmBridge.Api.UpdateProxy; using FwDataMiniLcmBridge.LcmUtils; using Microsoft.Extensions.Logging; using MiniLcm; +using MiniLcm.Exceptions; using MiniLcm.Models; using MiniLcm.SyncHelpers; +using MiniLcm.Validators; using SIL.LCModel; using SIL.LCModel.Core.KernelInterfaces; using SIL.LCModel.Core.Text; @@ -17,7 +20,7 @@ namespace FwDataMiniLcmBridge.Api; -public class FwDataMiniLcmApi(Lazy cacheLazy, bool onCloseSave, ILogger logger, FwDataProject project) : IMiniLcmApi, IDisposable +public class FwDataMiniLcmApi(Lazy cacheLazy, bool onCloseSave, ILogger logger, FwDataProject project, MiniLcmValidators validators) : IMiniLcmApi, IDisposable { internal LcmCache Cache => cacheLazy.Value; public FwDataProject Project { get; } = project; @@ -37,6 +40,8 @@ public class FwDataMiniLcmApi(Lazy cacheLazy, bool onCloseSave, ILogge private ICmTranslationFactory CmTranslationFactory => Cache.ServiceLocator.GetInstance(); private ICmPossibilityRepository CmPossibilityRepository => Cache.ServiceLocator.GetInstance(); private ICmPossibilityList ComplexFormTypes => Cache.LangProject.LexDbOA.ComplexEntryTypesOA; + private IEnumerable ComplexFormTypesFlattened => ComplexFormTypes.PossibilitiesOS.Cast().Flatten(); + private ICmPossibilityList VariantTypes => Cache.LangProject.LexDbOA.VariantEntryTypesOA; public void Dispose() @@ -132,6 +137,11 @@ private WritingSystem FromLcmWritingSystem(CoreWritingSystemDefinition ws, int i }; } + public Task GetWritingSystem(WritingSystemId id, WritingSystemType type) + { + throw new NotImplementedException(); + } + internal void CompleteExemplars(WritingSystems writingSystems) { var wsExemplars = writingSystems.Vernacular.Concat(writingSystems.Analysis) @@ -183,9 +193,36 @@ public Task CreateWritingSystem(WritingSystemType type, WritingSy return Task.FromResult(FromLcmWritingSystem(ws, index, type)); } - public Task UpdateWritingSystem(WritingSystemId id, WritingSystemType type, UpdateObjectInput update) + public async Task UpdateWritingSystem(WritingSystemId id, WritingSystemType type, UpdateObjectInput update) { - throw new NotImplementedException(); + if (!Cache.ServiceLocator.WritingSystemManager.TryGet(id.Code, out var lcmWritingSystem)) + { + throw new InvalidOperationException($"Writing system {id.Code} not found"); + } + await Cache.DoUsingNewOrCurrentUOW("Update WritingSystem", + "Revert WritingSystem", + async () => + { + var updateProxy = new UpdateWritingSystemProxy(lcmWritingSystem, this) + { + Id = Guid.Empty, + Type = type, + }; + update.Apply(updateProxy); + updateProxy.CommitUpdate(Cache); + }); + return await GetWritingSystem(id, type); + } + + public async Task UpdateWritingSystem(WritingSystem before, WritingSystem after) + { + await Cache.DoUsingNewOrCurrentUOW("Update WritingSystem", + "Revert WritingSystem", + async () => + { + await WritingSystemSync.Sync(after, before, this); + }); + return await GetWritingSystem(after.WsId, after.Type) ?? throw new NullReferenceException($"unable to find {after.Type} writing system with id {after.WsId}"); } public IAsyncEnumerable GetPartsOfSpeech() @@ -235,6 +272,12 @@ public Task UpdatePartOfSpeech(Guid id, UpdateObjectInput UpdatePartOfSpeech(PartOfSpeech before, PartOfSpeech after) + { + await PartOfSpeechSync.Sync(before, after, this); + return await GetPartOfSpeech(after.Id) ?? throw new NullReferenceException($"unable to find part of speech with id {after.Id}"); + } + public Task DeletePartOfSpeech(Guid id) { UndoableUnitOfWorkHelper.DoUsingNewOrCurrentUOW("Delete Part of Speech", @@ -306,6 +349,12 @@ public Task UpdateSemanticDomain(Guid id, UpdateObjectInput UpdateSemanticDomain(SemanticDomain before, SemanticDomain after) + { + await SemanticDomainSync.Sync(before, after, this); + return await GetSemanticDomain(after.Id) ?? throw new NullReferenceException($"unable to find semantic domain with id {after.Id}"); + } + public Task DeleteSemanticDomain(Guid id) { UndoableUnitOfWorkHelper.DoUsingNewOrCurrentUOW("Delete Semantic Domain", @@ -326,18 +375,16 @@ public Task DeleteSemanticDomain(Guid id) public IAsyncEnumerable GetComplexFormTypes() { - return ComplexFormTypes.PossibilitiesOS - .Select(ToComplexFormType) - .ToAsyncEnumerable(); + return ComplexFormTypesFlattened.Select(ToComplexFormType).ToAsyncEnumerable(); } - - private ComplexFormType ToComplexFormType(ICmPossibility t) + private ComplexFormType ToComplexFormType(ILexEntryType t) { return new ComplexFormType() { Id = t.Guid, Name = FromLcmMultiString(t.Name) }; } - public Task CreateComplexFormType(ComplexFormType complexFormType) + public async Task CreateComplexFormType(ComplexFormType complexFormType) { + await validators.ValidateAndThrow(complexFormType); if (complexFormType.Id == default) complexFormType.Id = Guid.NewGuid(); UndoableUnitOfWorkHelper.DoUsingNewOrCurrentUOW("Create complex form type", "Remove complex form type", @@ -350,7 +397,7 @@ public Task CreateComplexFormType(ComplexFormType complexFormTy ComplexFormTypes.PossibilitiesOS.Add(lexComplexFormType); UpdateLcmMultiString(lexComplexFormType.Name, complexFormType.Name); }); - return Task.FromResult(ToComplexFormType(ComplexFormTypes.PossibilitiesOS.Single(c => c.Guid == complexFormType.Id))); + return ToComplexFormType(ComplexFormTypesFlattened.Single(c => c.Guid == complexFormType.Id)); } public IAsyncEnumerable GetVariantTypes() @@ -390,6 +437,15 @@ private Entry FromLexEntry(ILexEntry entry) }; } + private string LexEntryHeadword(ILexEntry entry) + { + return new Entry() + { + LexemeForm = FromLcmMultiString(entry.LexemeFormOA.Form), + CitationForm = FromLcmMultiString(entry.CitationForm), + }.Headword(); + } + private IList ToComplexFormTypes(ILexEntry entry) { return entry.ComplexFormEntryRefs.SingleOrDefault() @@ -438,9 +494,9 @@ private ComplexFormComponent ToEntryReference(ILexEntry component, ILexEntry com return new ComplexFormComponent { ComponentEntryId = component.Guid, - ComponentHeadword = component.HeadWord.Text, + ComponentHeadword = LexEntryHeadword(component), ComplexFormEntryId = complexEntry.Guid, - ComplexFormHeadword = complexEntry.HeadWord.Text + ComplexFormHeadword = LexEntryHeadword(complexEntry) }; } @@ -452,7 +508,7 @@ private ComplexFormComponent ToSenseReference(ILexSense componentSense, ILexEntr ComponentSenseId = componentSense.Guid, ComponentHeadword = componentSense.Entry.HeadWord.Text, ComplexFormEntryId = complexEntry.Guid, - ComplexFormHeadword = complexEntry.HeadWord.Text + ComplexFormHeadword = LexEntryHeadword(complexEntry) }; } @@ -559,40 +615,48 @@ public IAsyncEnumerable SearchEntries(string query, QueryOptions? options public async Task CreateEntry(Entry entry) { entry.Id = entry.Id == default ? Guid.NewGuid() : entry.Id; - UndoableUnitOfWorkHelper.DoUsingNewOrCurrentUOW("Create Entry", - "Remove entry", - Cache.ServiceLocator.ActionHandler, - () => - { - var lexEntry = LexEntryFactory.Create(entry.Id, Cache.ServiceLocator.GetInstance().Singleton.LexDbOA); - lexEntry.LexemeFormOA = Cache.ServiceLocator.GetInstance().Create(); - UpdateLcmMultiString(lexEntry.LexemeFormOA.Form, entry.LexemeForm); - UpdateLcmMultiString(lexEntry.CitationForm, entry.CitationForm); - UpdateLcmMultiString(lexEntry.LiteralMeaning, entry.LiteralMeaning); - UpdateLcmMultiString(lexEntry.Comment, entry.Note); - - foreach (var sense in entry.Senses) - { - CreateSense(lexEntry, sense); - } - - //form types should be created before components, otherwise the form type "unspecified" will be added - foreach (var complexFormType in entry.ComplexFormTypes) - { - AddComplexFormType(lexEntry, complexFormType.Id); - } - - foreach (var component in entry.Components) - { - AddComplexFormComponent(lexEntry, component); - } - - foreach (var complexForm in entry.ComplexForms) + try + { + UndoableUnitOfWorkHelper.DoUsingNewOrCurrentUOW("Create Entry", + "Remove entry", + Cache.ServiceLocator.ActionHandler, + () => { - var complexLexEntry = EntriesRepository.GetObject(complexForm.ComplexFormEntryId); - AddComplexFormComponent(complexLexEntry, complexForm); - } - }); + var lexEntry = LexEntryFactory.Create(entry.Id, + Cache.ServiceLocator.GetInstance().Singleton.LexDbOA); + lexEntry.LexemeFormOA = Cache.ServiceLocator.GetInstance().Create(); + UpdateLcmMultiString(lexEntry.LexemeFormOA.Form, entry.LexemeForm); + UpdateLcmMultiString(lexEntry.CitationForm, entry.CitationForm); + UpdateLcmMultiString(lexEntry.LiteralMeaning, entry.LiteralMeaning); + UpdateLcmMultiString(lexEntry.Comment, entry.Note); + + foreach (var sense in entry.Senses) + { + CreateSense(lexEntry, sense); + } + + //form types should be created before components, otherwise the form type "unspecified" will be added + foreach (var complexFormType in entry.ComplexFormTypes) + { + AddComplexFormType(lexEntry, complexFormType.Id); + } + + foreach (var component in entry.Components) + { + AddComplexFormComponent(lexEntry, component); + } + + foreach (var complexForm in entry.ComplexForms) + { + var complexLexEntry = EntriesRepository.GetObject(complexForm.ComplexFormEntryId); + AddComplexFormComponent(complexLexEntry, complexForm); + } + }); + } + catch (Exception e) + { + throw new CreateObjectException($"Failed to create entry {entry}", e); + } return await GetEntry(entry.Id) ?? throw new InvalidOperationException("Entry was not created"); } @@ -693,7 +757,7 @@ internal void AddComplexFormType(ILexEntry lexEntry, Guid complexFormTypeId) entryRef.HideMinorEntry = 0; } - var lexEntryType = (ILexEntryType)ComplexFormTypes.PossibilitiesOS.Single(c => c.Guid == complexFormTypeId); + var lexEntryType = ComplexFormTypesFlattened.Single(c => c.Guid == complexFormTypeId); entryRef.ComplexEntryTypesRS.Add(lexEntryType); } @@ -701,7 +765,8 @@ internal void RemoveComplexFormType(ILexEntry lexEntry, Guid complexFormTypeId) { ILexEntryRef? entryRef = lexEntry.ComplexFormEntryRefs.SingleOrDefault(); if (entryRef is null) return; - var lexEntryType = (ILexEntryType)ComplexFormTypes.PossibilitiesOS.Single(c => c.Guid == complexFormTypeId); + var lexEntryType = entryRef.ComplexEntryTypesRS.SingleOrDefault(c => c.Guid == complexFormTypeId); + if (lexEntryType is null) return; entryRef.ComplexEntryTypesRS.Remove(lexEntryType); } @@ -881,6 +946,12 @@ public Task DeleteSense(Guid entryId, Guid senseId) return Task.CompletedTask; } + public Task GetExampleSentence(Guid entryId, Guid senseId, Guid id) + { + var lcmExampleSentence = ExampleSentenceRepository.GetObject(id); + return Task.FromResult(lcmExampleSentence is null ? null : FromLexExampleSentence(senseId, lcmExampleSentence)); + } + internal void CreateExampleSentence(ILexSense lexSense, ExampleSentence exampleSentence) { var lexExampleSentence = LexExampleSentenceFactory.Create(exampleSentence.Id, lexSense); @@ -922,6 +993,20 @@ public Task UpdateExampleSentence(Guid entryId, return Task.FromResult(FromLexExampleSentence(senseId, lexExampleSentence)); } + public async Task UpdateExampleSentence(Guid entryId, + Guid senseId, + ExampleSentence before, + ExampleSentence after) + { + await Cache.DoUsingNewOrCurrentUOW("Update Example Sentence", + "Revert Example Sentence", + async () => + { + await ExampleSentenceSync.Sync(entryId, senseId, after, before, this); + }); + return await GetExampleSentence(entryId, senseId, after.Id) ?? throw new NullReferenceException("unable to find example sentence with id " + after.Id); + } + public Task DeleteExampleSentence(Guid entryId, Guid senseId, Guid exampleSentenceId) { var lexExampleSentence = ExampleSentenceRepository.GetObject(exampleSentenceId); diff --git a/backend/FwLite/FwDataMiniLcmBridge/Api/PossibilityExtensions.cs b/backend/FwLite/FwDataMiniLcmBridge/Api/PossibilityExtensions.cs new file mode 100644 index 000000000..e5ee6416f --- /dev/null +++ b/backend/FwLite/FwDataMiniLcmBridge/Api/PossibilityExtensions.cs @@ -0,0 +1,18 @@ +using SIL.LCModel; + +namespace FwDataMiniLcmBridge.Api; + +public static class PossibilityExtensions +{ + public static IEnumerable Flatten(this IEnumerable enumerable) where T : ICmPossibility + { + foreach (var cmPossibility in enumerable) + { + yield return cmPossibility; + foreach (var child in Flatten(cmPossibility.SubPossibilitiesOS.Cast())) + { + yield return child; + } + } + } +} diff --git a/backend/FwLite/FwDataMiniLcmBridge/Api/UpdateProxy/UpdateEntryProxy.cs b/backend/FwLite/FwDataMiniLcmBridge/Api/UpdateProxy/UpdateEntryProxy.cs index 503222fca..d0defe8ad 100644 --- a/backend/FwLite/FwDataMiniLcmBridge/Api/UpdateProxy/UpdateEntryProxy.cs +++ b/backend/FwLite/FwDataMiniLcmBridge/Api/UpdateProxy/UpdateEntryProxy.cs @@ -4,7 +4,7 @@ namespace FwDataMiniLcmBridge.Api.UpdateProxy; -public class UpdateEntryProxy : Entry +public record UpdateEntryProxy : Entry { private readonly ILexEntry _lcmEntry; private readonly FwDataMiniLcmApi _lexboxLcmApi; diff --git a/backend/FwLite/FwDataMiniLcmBridge/Api/UpdateProxy/UpdateWritingSystemProxy.cs b/backend/FwLite/FwDataMiniLcmBridge/Api/UpdateProxy/UpdateWritingSystemProxy.cs new file mode 100644 index 000000000..eaf849fcb --- /dev/null +++ b/backend/FwLite/FwDataMiniLcmBridge/Api/UpdateProxy/UpdateWritingSystemProxy.cs @@ -0,0 +1,69 @@ +using System.Diagnostics.CodeAnalysis; +using MiniLcm.Models; +using SIL.LCModel; +using SIL.LCModel.Core.WritingSystems; +using SIL.LCModel.DomainServices; +using SIL.WritingSystems; + +namespace FwDataMiniLcmBridge.Api.UpdateProxy; + +public record UpdateWritingSystemProxy : WritingSystem +{ + private readonly CoreWritingSystemDefinition _origLcmWritingSystem; + private readonly CoreWritingSystemDefinition _workingLcmWritingSystem; + private readonly FwDataMiniLcmApi _lexboxLcmApi; + + [SetsRequiredMembers] + public UpdateWritingSystemProxy(CoreWritingSystemDefinition lcmWritingSystem, FwDataMiniLcmApi lexboxLcmApi) + { + _origLcmWritingSystem = lcmWritingSystem; + _workingLcmWritingSystem = new CoreWritingSystemDefinition(lcmWritingSystem, cloneId: true); + base.Abbreviation = Abbreviation = _origLcmWritingSystem.Abbreviation ?? ""; + base.Name = Name = _origLcmWritingSystem.LanguageName ?? ""; + base.Font = Font = _origLcmWritingSystem.DefaultFontName ?? ""; + _lexboxLcmApi = lexboxLcmApi; + } + + public void CommitUpdate(LcmCache cache) + { + if (_workingLcmWritingSystem.Id == _origLcmWritingSystem.Id) + { + cache.ServiceLocator.WritingSystemManager.Set(_workingLcmWritingSystem); + } + else + { + // Changing the ID of a writing system requires LCM to do a lot of work, so only go through that process if absolutely required + WritingSystemServices.MergeWritingSystems(cache, _workingLcmWritingSystem, _origLcmWritingSystem); + } + } + + public override required WritingSystemId WsId + { + get => _workingLcmWritingSystem.Id; + set => _workingLcmWritingSystem.Id = value; + } + + public override required string Name + { + get => _workingLcmWritingSystem.LanguageName; + set { } // Silently do nothing; name should be derived from WsId at all times, so if the name should change then so should the WsId + } + + public override required string Abbreviation + { + get => _workingLcmWritingSystem.Abbreviation; + set => _workingLcmWritingSystem.Abbreviation = value; + } + + public override required string Font + { + get => _workingLcmWritingSystem.DefaultFontName; + set + { + if (value != _workingLcmWritingSystem.DefaultFontName) + { + _workingLcmWritingSystem.DefaultFont = new FontDefinition(value); + } + } + } +} diff --git a/backend/FwLite/FwDataMiniLcmBridge/FieldWorksProjectList.cs b/backend/FwLite/FwDataMiniLcmBridge/FieldWorksProjectList.cs index 47cb91b57..f77bf944c 100644 --- a/backend/FwLite/FwDataMiniLcmBridge/FieldWorksProjectList.cs +++ b/backend/FwLite/FwDataMiniLcmBridge/FieldWorksProjectList.cs @@ -6,15 +6,17 @@ namespace FwDataMiniLcmBridge; public class FieldWorksProjectList(IOptions config) { + protected readonly IOptions _config = config; + public virtual IEnumerable EnumerateProjects() { - if (!Directory.Exists(config.Value.ProjectsFolder)) Directory.CreateDirectory(config.Value.ProjectsFolder); - foreach (var directory in Directory.EnumerateDirectories(config.Value.ProjectsFolder)) + if (!Directory.Exists(_config.Value.ProjectsFolder)) Directory.CreateDirectory(_config.Value.ProjectsFolder); + foreach (var directory in Directory.EnumerateDirectories(_config.Value.ProjectsFolder)) { var projectName = Path.GetFileName(directory); if (string.IsNullOrEmpty(projectName)) continue; if (!File.Exists(Path.Combine(directory, projectName + ".fwdata"))) continue; - yield return new FwDataProject(projectName, config.Value.ProjectsFolder); + yield return new FwDataProject(projectName, _config.Value.ProjectsFolder); } } diff --git a/backend/FwLite/FwDataMiniLcmBridge/FwDataBridgeKernel.cs b/backend/FwLite/FwDataMiniLcmBridge/FwDataBridgeKernel.cs index 0e20c2cf4..395c5c78d 100644 --- a/backend/FwLite/FwDataMiniLcmBridge/FwDataBridgeKernel.cs +++ b/backend/FwLite/FwDataMiniLcmBridge/FwDataBridgeKernel.cs @@ -1,6 +1,7 @@ using FwDataMiniLcmBridge.LcmUtils; using Microsoft.Extensions.DependencyInjection; using MiniLcm; +using MiniLcm.Validators; namespace FwDataMiniLcmBridge; @@ -16,6 +17,7 @@ public static IServiceCollection AddFwDataBridge(this IServiceCollection service services.AddSingleton(); services.AddSingleton(); services.AddKeyedScoped(FwDataApiKey, (provider, o) => provider.GetRequiredService().GetCurrentFwDataMiniLcmApi(true)); + services.AddMiniLcmValidators(); services.AddSingleton(); return services; } diff --git a/backend/FwLite/FwDataMiniLcmBridge/FwDataFactory.cs b/backend/FwLite/FwDataMiniLcmBridge/FwDataFactory.cs index 78fc74092..f780b9c13 100644 --- a/backend/FwLite/FwDataMiniLcmBridge/FwDataFactory.cs +++ b/backend/FwLite/FwDataMiniLcmBridge/FwDataFactory.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using MiniLcm.Validators; using SIL.LCModel; namespace FwDataMiniLcmBridge; @@ -14,7 +15,8 @@ public class FwDataFactory( IMemoryCache cache, ILogger logger, IProjectLoader projectLoader, - FieldWorksProjectList fieldWorksProjectList) : IDisposable + FieldWorksProjectList fieldWorksProjectList, + MiniLcmValidators validators) : IDisposable { private bool _shuttingDown = false; public FwDataFactory(FwDataProjectContext context, @@ -23,7 +25,8 @@ public FwDataFactory(FwDataProjectContext context, ILogger logger, IProjectLoader projectLoader, IHostApplicationLifetime lifetime, - FieldWorksProjectList fieldWorksProjectList) : this(context, fwdataLogger, cache, logger, projectLoader, fieldWorksProjectList) + FieldWorksProjectList fieldWorksProjectList, + MiniLcmValidators validators) : this(context, fwdataLogger, cache, logger, projectLoader, fieldWorksProjectList, validators) { lifetime.ApplicationStopping.Register(() => { @@ -43,7 +46,7 @@ public FwDataMiniLcmApi GetFwDataMiniLcmApi(string projectName, bool saveOnDispo public FwDataMiniLcmApi GetFwDataMiniLcmApi(FwDataProject project, bool saveOnDispose) { - return new FwDataMiniLcmApi(new (() => GetProjectServiceCached(project)), saveOnDispose, fwdataLogger, project); + return new FwDataMiniLcmApi(new (() => GetProjectServiceCached(project)), saveOnDispose, fwdataLogger, project, validators); } private HashSet _projects = []; diff --git a/backend/FwLite/FwDataMiniLcmBridge/FwDataMiniLcmBridge.csproj b/backend/FwLite/FwDataMiniLcmBridge/FwDataMiniLcmBridge.csproj index 5ab11b140..dd6ae3743 100644 --- a/backend/FwLite/FwDataMiniLcmBridge/FwDataMiniLcmBridge.csproj +++ b/backend/FwLite/FwDataMiniLcmBridge/FwDataMiniLcmBridge.csproj @@ -1,25 +1,22 @@  - net8.0 - enable - enable $(ApplicationDisplayVersion) $(ApplicationDisplayVersion) - - - - - - + + + + + + diff --git a/backend/FwLite/FwLiteDesktop/FwLiteDesktop.csproj b/backend/FwLite/FwLiteDesktop/FwLiteDesktop.csproj index 043dacd5d..14017906e 100644 --- a/backend/FwLite/FwLiteDesktop/FwLiteDesktop.csproj +++ b/backend/FwLite/FwLiteDesktop/FwLiteDesktop.csproj @@ -2,7 +2,7 @@ - net8.0-windows10.0.19041.0 + net9.0-windows10.0.19041.0 @@ -80,7 +80,6 @@ - diff --git a/backend/FwLite/FwLiteDesktop/FwLiteDesktopKernel.cs b/backend/FwLite/FwLiteDesktop/FwLiteDesktopKernel.cs index 0d7727ee2..ece707252 100644 --- a/backend/FwLite/FwLiteDesktop/FwLiteDesktopKernel.cs +++ b/backend/FwLite/FwLiteDesktop/FwLiteDesktopKernel.cs @@ -1,7 +1,7 @@ using Windows.ApplicationModel; using FwLiteDesktop.ServerBridge; +using FwLiteShared.Auth; using LcmCrdt; -using LocalWebApp.Auth; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; diff --git a/backend/FwLite/FwLiteDesktop/MauiProgram.cs b/backend/FwLite/FwLiteDesktop/MauiProgram.cs index 2dfbdb6fd..ef91d179f 100644 --- a/backend/FwLite/FwLiteDesktop/MauiProgram.cs +++ b/backend/FwLite/FwLiteDesktop/MauiProgram.cs @@ -1,7 +1,6 @@ using FwLiteDesktop.ServerBridge; using LcmCrdt; using LocalWebApp; -using LocalWebApp.Auth; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Microsoft.Maui.LifecycleEvents; diff --git a/backend/FwLite/FwLiteProjectSync.Tests/EntrySyncTests.cs b/backend/FwLite/FwLiteProjectSync.Tests/EntrySyncTests.cs index d1b264839..b2377ae5c 100644 --- a/backend/FwLite/FwLiteProjectSync.Tests/EntrySyncTests.cs +++ b/backend/FwLite/FwLiteProjectSync.Tests/EntrySyncTests.cs @@ -1,7 +1,7 @@ -using FluentAssertions.Equivalency; -using FwLiteProjectSync.Tests.Fixtures; +using FwLiteProjectSync.Tests.Fixtures; using MiniLcm.Models; using MiniLcm.SyncHelpers; +using MiniLcm.Tests; using MiniLcm.Tests.AutoFakerHelpers; using Soenneker.Utils.AutoBogus; @@ -95,8 +95,8 @@ public async Task CanChangeComplexFormViaSync_ComplexForms() [Fact] public async Task CanChangeComplexFormTypeViaSync() { + var complexFormType = await _fixture.CrdtApi.CreateComplexFormType(new() { Name = new() { { "en", "complexFormType" } } }); var entry = await _fixture.CrdtApi.CreateEntry(new() { LexemeForm = { { "en", "complexForm1" } } }); - var complexFormType = await _fixture.CrdtApi.GetComplexFormTypes().FirstAsync(); var after = (Entry) entry.Copy(); after.ComplexFormTypes = [complexFormType]; await EntrySync.Sync(after, entry, _fixture.CrdtApi); diff --git a/backend/FwLite/FwLiteProjectSync.Tests/Fixtures/MercurialTestHelper.cs b/backend/FwLite/FwLiteProjectSync.Tests/Fixtures/MercurialTestHelper.cs new file mode 100644 index 000000000..e0ec3f816 --- /dev/null +++ b/backend/FwLite/FwLiteProjectSync.Tests/Fixtures/MercurialTestHelper.cs @@ -0,0 +1,31 @@ +using Chorus.VcsDrivers.Mercurial; +using SIL.CommandLineProcessing; +using SIL.PlatformUtilities; +using SIL.Progress; + +namespace FwLiteProjectSync.Tests.Fixtures; + +public static class MercurialTestHelper +{ + private static readonly NullProgress NullProgress = new NullProgress(); + + private static string RunHgCommand(string repoPath, string args) + { + var result = HgRunner.Run(args, repoPath, 120, NullProgress); + if (result.ExitCode == 0) return result.StandardOutput; + throw new Exception( + $"hg {args} failed.\nStdOut: {result.StandardOutput}\nStdErr: {result.StandardError}"); + + } + + public static void HgClean(string repoPath, string exclude) + { + RunHgCommand(repoPath, $"purge --no-confirm --exclude {exclude}"); + } + + public static void HgUpdate(string repoPath, string rev) + { + RunHgCommand(repoPath, $"update \"{rev}\""); + } +} + diff --git a/backend/FwLite/FwLiteProjectSync.Tests/Fixtures/Sena3SyncFixture.cs b/backend/FwLite/FwLiteProjectSync.Tests/Fixtures/Sena3SyncFixture.cs new file mode 100644 index 000000000..cc3551181 --- /dev/null +++ b/backend/FwLite/FwLiteProjectSync.Tests/Fixtures/Sena3SyncFixture.cs @@ -0,0 +1,93 @@ +using System.IO.Compression; +using FwDataMiniLcmBridge; +using FwDataMiniLcmBridge.Api; +using LcmCrdt; +using LexCore.Utils; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using SIL.IO; +using SIL.Progress; + +namespace FwLiteProjectSync.Tests.Fixtures; + +public class Sena3Fixture : IAsyncLifetime +{ + private static readonly HttpClient http = new HttpClient(); + + public async Task InitializeAsync() + { + var services = new ServiceCollection() + .AddSyncServices(nameof(Sena3Fixture), false); + var rootServiceProvider = services.BuildServiceProvider(); + var fwProjectsFolder = rootServiceProvider.GetRequiredService>() + .Value + .ProjectsFolder; + if (Path.Exists(fwProjectsFolder)) Directory.Delete(fwProjectsFolder, true); + Directory.CreateDirectory(fwProjectsFolder); + + var crdtProjectsFolder = + rootServiceProvider.GetRequiredService>().Value.ProjectPath; + if (Path.Exists(crdtProjectsFolder)) Directory.Delete(crdtProjectsFolder, true); + rootServiceProvider.Dispose(); + + Directory.CreateDirectory(crdtProjectsFolder); + await DownloadSena3(); + } + + public async Task<(CrdtMiniLcmApi CrdtApi, FwDataMiniLcmApi FwDataApi, IServiceProvider services, IDisposable cleanup)> SetupProjects() + { + var sena3MasterCopy = await DownloadSena3(); + + var rootServiceProvider = new ServiceCollection() + .AddSyncServices(nameof(Sena3Fixture), false) + .BuildServiceProvider(); + var cleanup = Defer.Action(() => rootServiceProvider.Dispose()); + var services = rootServiceProvider.CreateAsyncScope().ServiceProvider; + var projectName = "sena-3_" + Guid.NewGuid().ToString("N"); + + var projectsFolder = services.GetRequiredService>() + .Value + .ProjectsFolder; + var fwDataProject = new FwDataProject(projectName, projectsFolder); + var fwDataProjectPath = Path.Combine(fwDataProject.ProjectsPath, fwDataProject.Name); + DirectoryHelper.Copy(sena3MasterCopy, fwDataProjectPath); + File.Move(Path.Combine(fwDataProjectPath, "sena-3.fwdata"), fwDataProject.FilePath); + var fwDataMiniLcmApi = services.GetRequiredService().GetFwDataMiniLcmApi(fwDataProject, false); + + var crdtProject = await services.GetRequiredService() + .CreateProject(new(projectName, FwProjectId: fwDataMiniLcmApi.ProjectId, SeedNewProjectData: false)); + var crdtMiniLcmApi = (CrdtMiniLcmApi)await services.OpenCrdtProject(crdtProject); + return (crdtMiniLcmApi, fwDataMiniLcmApi, services, cleanup); + } + + public Task DisposeAsync() + { + return Task.CompletedTask; + } + + private async Task DownloadSena3ProjectBackupStream() + { + var backupUrl = new Uri("https://drive.google.com/uc?export=download&id=1I-hwc0RHoQqW774gbS5qR-GHa1E7BlsS"); + var result = await http.GetAsync(backupUrl, HttpCompletionOption.ResponseHeadersRead); + return await result.Content.ReadAsStreamAsync(); + } + + private async Task DownloadSena3() + { + var tempFolder = Path.Combine(Path.GetTempPath(), nameof(Sena3Fixture)); + var sena3MasterCopy = Path.Combine(tempFolder, "sena-3"); + if (!Directory.Exists(sena3MasterCopy) || !File.Exists(Path.Combine(sena3MasterCopy, "sena-3.fwdata"))) + { + Directory.CreateDirectory(sena3MasterCopy); + await using var zipStream = await DownloadSena3ProjectBackupStream(); + //the zip file is structured like this: /sena-3/.hg + //by extracting it to tempFolder it should merge with sena-3 + ZipFile.ExtractToDirectory(zipStream, tempFolder); + + MercurialTestHelper.HgUpdate(sena3MasterCopy, "tip"); + LfMergeBridge.LfMergeBridge.ReassembleFwdataFile(new NullProgress(), false, Path.Combine(sena3MasterCopy, "sena-3.fwdata")); + MercurialTestHelper.HgClean(sena3MasterCopy, "sena-3.fwdata"); + } + return sena3MasterCopy; + } +} diff --git a/backend/FwLite/FwLiteProjectSync.Tests/Fixtures/SyncFixture.cs b/backend/FwLite/FwLiteProjectSync.Tests/Fixtures/SyncFixture.cs index 0b0443e0d..149adf76a 100644 --- a/backend/FwLite/FwLiteProjectSync.Tests/Fixtures/SyncFixture.cs +++ b/backend/FwLite/FwLiteProjectSync.Tests/Fixtures/SyncFixture.cs @@ -4,6 +4,7 @@ using FwDataMiniLcmBridge.LcmUtils; using FwDataMiniLcmBridge.Tests.Fixtures; using LcmCrdt; +using LexCore.Utils; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; @@ -20,7 +21,7 @@ public class SyncFixture : IAsyncLifetime _services.ServiceProvider.GetRequiredService(); public IServiceProvider Services => _services.ServiceProvider; private readonly string _projectName; - private readonly MockProjectContext _projectContext = new(null); + private readonly IDisposable _cleanup; public static SyncFixture Create([CallerMemberName] string projectName = "") => new(projectName); @@ -28,15 +29,10 @@ private SyncFixture(string projectName) { _projectName = projectName; var crdtServices = new ServiceCollection() - .AddLcmCrdtClient() - .AddSingleton(_projectContext) - .AddTestFwDataBridge() - .AddFwLiteProjectSync() - .Configure(c => c.ProjectsFolder = Path.Combine(".", _projectName, "FwData")) - .Configure(c => c.ProjectPath = Path.Combine(".", _projectName, "LcmCrdt")) - .AddLogging(builder => builder.AddDebug()) - .BuildServiceProvider(); - _services = crdtServices.CreateAsyncScope(); + .AddSyncServices(_projectName); + var rootServiceProvider = crdtServices.BuildServiceProvider(); + _cleanup = Defer.Action(() => rootServiceProvider.Dispose()); + _services = rootServiceProvider.CreateAsyncScope(); } public SyncFixture(): this("sena-3_" + Guid.NewGuid().ToString("N")) @@ -50,28 +46,30 @@ public async Task InitializeAsync() if (Path.Exists(projectsFolder)) Directory.Delete(projectsFolder, true); Directory.CreateDirectory(projectsFolder); _services.ServiceProvider.GetRequiredService() - .NewProject(new FwDataProject(_projectName, projectsFolder), "en", "fr"); + .NewProject(new FwDataProject(_projectName, projectsFolder), "en", "en"); FwDataApi = _services.ServiceProvider.GetRequiredService().GetFwDataMiniLcmApi(_projectName, false); var crdtProjectsFolder = _services.ServiceProvider.GetRequiredService>().Value.ProjectPath; if (Path.Exists(crdtProjectsFolder)) Directory.Delete(crdtProjectsFolder, true); Directory.CreateDirectory(crdtProjectsFolder); - var crdtProject = await _services.ServiceProvider.GetRequiredService() - .CreateProject(new(_projectName, FwProjectId: FwDataApi.ProjectId, SeedNewProjectData: true)); + var crdtProject = await _services.ServiceProvider.GetRequiredService() + .CreateProject(new(_projectName, FwProjectId: FwDataApi.ProjectId, SeedNewProjectData: false)); CrdtApi = (CrdtMiniLcmApi) await _services.ServiceProvider.OpenCrdtProject(crdtProject); } public async Task DisposeAsync() { await _services.DisposeAsync(); + _cleanup.Dispose(); } public CrdtMiniLcmApi CrdtApi { get; set; } = null!; public FwDataMiniLcmApi FwDataApi { get; set; } = null!; -} -public class MockProjectContext(CrdtProject? project) : ProjectContext -{ - public override CrdtProject? Project { get; set; } = project; + public void DeleteSyncSnapshot() + { + var snapshotPath = CrdtFwdataProjectSyncService.SnapshotPath(FwDataApi.Project); + if (File.Exists(snapshotPath)) File.Delete(snapshotPath); + } } diff --git a/backend/FwLite/FwLiteProjectSync.Tests/Fixtures/TestingKernel.cs b/backend/FwLite/FwLiteProjectSync.Tests/Fixtures/TestingKernel.cs new file mode 100644 index 000000000..6eb9f8b40 --- /dev/null +++ b/backend/FwLite/FwLiteProjectSync.Tests/Fixtures/TestingKernel.cs @@ -0,0 +1,27 @@ +using FwDataMiniLcmBridge; +using FwDataMiniLcmBridge.Tests.Fixtures; +using LcmCrdt; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace FwLiteProjectSync.Tests.Fixtures; + +public static class TestingKernel +{ + public static IServiceCollection AddSyncServices(this IServiceCollection services, string projectName, bool mockFwProjectLoader = true) + { + return services.AddLcmCrdtClient() + .AddTestFwDataBridge(mockFwProjectLoader) + .AddFwLiteProjectSync() + .AddSingleton(new MockProjectContext(null)) + .Configure(c => c.ProjectsFolder = Path.Combine(".", projectName, "FwData")) + .Configure(c => c.ProjectPath = Path.Combine(".", projectName, "LcmCrdt")) + .AddLogging(builder => builder.AddDebug()); + } + + public class MockProjectContext(CrdtProject? project) : ProjectContext + { + public override CrdtProject? Project { get; set; } = project; + } + +} diff --git a/backend/FwLite/FwLiteProjectSync.Tests/FwLiteProjectSync.Tests.csproj b/backend/FwLite/FwLiteProjectSync.Tests/FwLiteProjectSync.Tests.csproj index 1ae1063f4..abe85567f 100644 --- a/backend/FwLite/FwLiteProjectSync.Tests/FwLiteProjectSync.Tests.csproj +++ b/backend/FwLite/FwLiteProjectSync.Tests/FwLiteProjectSync.Tests.csproj @@ -1,12 +1,9 @@ - net8.0 - enable - enable - false true + $(MSBuildProjectDirectory) @@ -18,16 +15,18 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - - - + + + - + - all - runtime; build; native; contentfiles; analyzers; buildtransitive + all + runtime; build; native; contentfiles; analyzers; buildtransitive - + + + @@ -41,4 +40,12 @@ + + + + + + + + diff --git a/backend/FwLite/FwLiteProjectSync.Tests/Sena3SyncTests.cs b/backend/FwLite/FwLiteProjectSync.Tests/Sena3SyncTests.cs new file mode 100644 index 000000000..77add42f5 --- /dev/null +++ b/backend/FwLite/FwLiteProjectSync.Tests/Sena3SyncTests.cs @@ -0,0 +1,144 @@ +using FluentAssertions.Equivalency; +using FluentAssertions.Execution; +using FwDataMiniLcmBridge.Api; +using FwLiteProjectSync.Tests.Fixtures; +using LcmCrdt; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using MiniLcm; +using MiniLcm.Models; +using SystemTextJsonPatch; + +namespace FwLiteProjectSync.Tests; + +public class Sena3SyncTests : IClassFixture, IAsyncLifetime +{ + private readonly Sena3Fixture _fixture; + private CrdtFwdataProjectSyncService _syncService = null!; + private CrdtMiniLcmApi _crdtApi = null!; + private FwDataMiniLcmApi _fwDataApi = null!; + private IDisposable? _cleanup; + + + public Sena3SyncTests(Sena3Fixture fixture) + { + _fixture = fixture; + } + + public async Task InitializeAsync() + { + (_crdtApi, _fwDataApi, var services, _cleanup) = await _fixture.SetupProjects(); + _syncService = services.GetRequiredService(); + _fwDataApi.EntryCount.Should().BeGreaterThan(100, "project should be loaded and have entries"); + } + + public Task DisposeAsync() + { + _cleanup?.Dispose(); + return Task.CompletedTask; + } + + private void ShouldAllBeEquivalentTo(Dictionary crdtEntries, Dictionary fwdataEntries) + { + crdtEntries.Keys.Should().BeEquivalentTo(fwdataEntries.Keys); + using (new AssertionScope()) + { + foreach (var crdtEntry in crdtEntries.Values) + { + var fwdataEntry = fwdataEntries[crdtEntry.Id]; + crdtEntry.Should().BeEquivalentTo(fwdataEntry, + options => options + .For(e => e.Components).Exclude(c => c.Id) + .For(e => e.ComplexForms).Exclude(c => c.Id), + $"CRDT entry {crdtEntry.Id} was synced with FwData"); + } + } + } + + //by default the first sync is an import, this will skip that so that the sync will actually sync data + private async Task BypassImport() + { + await _syncService.SaveProjectSnapshot(_fwDataApi.Project, new ([], [], [])); + } + + //this lets us query entries when there is no writing system + private async Task WorkaroundMissingWritingSystems() + { + //must have at least one writing system to query for entries + await _crdtApi.CreateWritingSystem(WritingSystemType.Vernacular, (await _fwDataApi.GetWritingSystems()).Vernacular.First()); + + } + + [Fact] + public async Task DryRunImport_MakesNoChanges() + { + await WorkaroundMissingWritingSystems(); + _crdtApi.GetEntries().ToBlockingEnumerable().Should().BeEmpty(); + await _syncService.SyncDryRun(_crdtApi, _fwDataApi); + //should still be empty + _crdtApi.GetEntries().ToBlockingEnumerable().Should().BeEmpty(); + } + + [Fact] + public async Task DryRunImport_MakesTheSameChangesAsImport() + { + var dryRunSyncResult = await _syncService.SyncDryRun(_crdtApi, _fwDataApi); + var syncResult = await _syncService.Sync(_crdtApi, _fwDataApi); + dryRunSyncResult.Should().BeEquivalentTo(syncResult); + } + + [Fact] + public async Task DryRunSync_MakesNoChanges() + { + await BypassImport(); + await WorkaroundMissingWritingSystems(); + _crdtApi.GetEntries().ToBlockingEnumerable().Should().BeEmpty(); + await _syncService.SyncDryRun(_crdtApi, _fwDataApi); + //should still be empty + _crdtApi.GetEntries().ToBlockingEnumerable().Should().BeEmpty(); + } + + [Fact(Skip = "this test is waiting for syncing ComplexFormTypes and WritingSystems")] + public async Task DryRunSync_MakesTheSameChangesAsImport() + { + await BypassImport(); + var dryRunSyncResult = await _syncService.SyncDryRun(_crdtApi, _fwDataApi); + var syncResult = await _syncService.Sync(_crdtApi, _fwDataApi); + dryRunSyncResult.Should().BeEquivalentTo(syncResult); + } + + [Fact] + public async Task FirstSena3SyncJustDoesAnSync() + { + var results = await _syncService.Sync(_crdtApi, _fwDataApi); + results.FwdataChanges.Should().Be(0); + results.CrdtChanges.Should().BeGreaterThanOrEqualTo(_fwDataApi.EntryCount); + + var crdtEntries = await _crdtApi.GetEntries().ToDictionaryAsync(e => e.Id); + var fwdataEntries = await _fwDataApi.GetEntries().ToDictionaryAsync(e => e.Id); + ShouldAllBeEquivalentTo(crdtEntries, fwdataEntries); + } + + [Fact(Skip = "this test is waiting for syncing ComplexFormTypes and WritingSystems")] + public async Task SyncWithoutImport_CrdtShouldMatchFwdata() + { + await BypassImport(); + + var results = await _syncService.Sync(_crdtApi, _fwDataApi); + results.FwdataChanges.Should().Be(0); + results.CrdtChanges.Should().BeGreaterThan(_fwDataApi.EntryCount); + + var crdtEntries = await _crdtApi.GetEntries().ToDictionaryAsync(e => e.Id); + var fwdataEntries = await _fwDataApi.GetEntries().ToDictionaryAsync(e => e.Id); + ShouldAllBeEquivalentTo(crdtEntries, fwdataEntries); + } + + [Fact] + public async Task SecondSena3SyncDoesNothing() + { + await _syncService.Sync(_crdtApi, _fwDataApi); + var secondSync = await _syncService.Sync(_crdtApi, _fwDataApi); + secondSync.CrdtChanges.Should().Be(0); + secondSync.FwdataChanges.Should().Be(0); + } +} diff --git a/backend/FwLite/FwLiteProjectSync.Tests/SyncTests.cs b/backend/FwLite/FwLiteProjectSync.Tests/SyncTests.cs index 752d77fa2..36b4084f2 100644 --- a/backend/FwLite/FwLiteProjectSync.Tests/SyncTests.cs +++ b/backend/FwLite/FwLiteProjectSync.Tests/SyncTests.cs @@ -65,6 +65,8 @@ public async Task DisposeAsync() { await _fixture.CrdtApi.DeleteEntry(entry.Id); } + + _fixture.DeleteSyncSnapshot(); } public SyncTests(SyncFixture fixture) @@ -84,7 +86,18 @@ public async Task FirstSyncJustDoesAnImport() var fwdataEntries = await fwdataApi.GetEntries().ToArrayAsync(); crdtEntries.Should().BeEquivalentTo(fwdataEntries, options => options.For(e => e.Components).Exclude(c => c.Id) - .For(e => e.ComplexForms).Exclude(c => c.Id)); + .For(e => e.ComplexForms).Exclude(c => c.Id)); + } + + [Fact] + public async Task SecondSyncDoesNothing() + { + var crdtApi = _fixture.CrdtApi; + var fwdataApi = _fixture.FwDataApi; + await _syncService.Sync(crdtApi, fwdataApi); + var secondSync = await _syncService.Sync(crdtApi, fwdataApi); + secondSync.CrdtChanges.Should().Be(0); + secondSync.FwdataChanges.Should().Be(0); } [Fact] @@ -138,6 +151,90 @@ await crdtApi.CreateEntry(new Entry() .For(e => e.ComplexForms).Exclude(c => c.Id)); } + [Fact] + public async Task SyncDryRun_NoChangesAreSynced() + { + var crdtApi = _fixture.CrdtApi; + var fwdataApi = _fixture.FwDataApi; + await _syncService.Sync(crdtApi, fwdataApi); + var fwDataEntryId = Guid.NewGuid(); + var crdtEntryId = Guid.NewGuid(); + + await fwdataApi.CreateEntry(new Entry() + { + Id = fwDataEntryId, + LexemeForm = { { "en", "Pear" } }, + Senses = + [ + new Sense() { Gloss = { { "en", "Pear" } }, } + ] + }); + await crdtApi.CreateEntry(new Entry() + { + Id = crdtEntryId, + LexemeForm = { { "en", "Banana" } }, + Senses = + [ + new Sense() { Gloss = { { "en", "Banana" } }, } + ] + }); + await _syncService.SyncDryRun(crdtApi, fwdataApi); + + var crdtEntries = await crdtApi.GetEntries().ToArrayAsync(); + var fwdataEntries = await fwdataApi.GetEntries().ToArrayAsync(); + crdtEntries.Select(e => e.Id).Should().NotContain(fwDataEntryId); + fwdataEntries.Select(e => e.Id).Should().NotContain(crdtEntryId); + } + + [Fact] + public async Task CreatingAComplexEntryInFwDataSyncsWithoutIssue() + { + var crdtApi = _fixture.CrdtApi; + var fwdataApi = _fixture.FwDataApi; + await _syncService.Sync(crdtApi, fwdataApi); + + var hat = await fwdataApi.CreateEntry(new Entry() + { + LexemeForm = { { "en", "Hat" } }, + Senses = + [ + new Sense() { Gloss = { { "en", "Hat" } }, } + ] + }); + var stand = await fwdataApi.CreateEntry(new Entry() + { + LexemeForm = { { "en", "Stand" } }, + Senses = + [ + new Sense() { Gloss = { { "en", "Stand" } }, } + ] + }); + var hatstand = await fwdataApi.CreateEntry(new Entry() + { + LexemeForm = { { "en", "Hatstand" } }, + Senses = + [ + new Sense() { Gloss = { { "en", "Hatstand" } }, } + ], + }); + var component1 = ComplexFormComponent.FromEntries(hatstand, hat); + var component2 = ComplexFormComponent.FromEntries(hatstand, stand); + hatstand.Components = [component1, component2]; + await _syncService.Sync(crdtApi, fwdataApi); + + var crdtEntries = await crdtApi.GetEntries().ToArrayAsync(); + var fwdataEntries = await fwdataApi.GetEntries().ToArrayAsync(); + crdtEntries.Should().BeEquivalentTo(fwdataEntries, + options => options.For(e => e.Components).Exclude(c => c.Id) + .For(e => e.ComplexForms).Exclude(c => c.Id)); + + // Sync again, ensure no problems or changes + var secondSync = await _syncService.Sync(crdtApi, fwdataApi); + secondSync.CrdtChanges.Should().Be(0); + secondSync.FwdataChanges.Should().Be(0); + } + + [Fact] public async Task PartsOfSpeechSyncBothWays() { @@ -273,7 +370,7 @@ await fwdataApi.CreateEntry(new Entry() LexemeForm = { { "en", "Pear" } }, Senses = [ - new Sense() { Gloss = { { "en", "Pear" } }, SemanticDomains = [ semdom3 ] } + new Sense() { Gloss = { { "en", "Pear" } }, SemanticDomains = [semdom3] } ] }); await crdtApi.CreateEntry(new Entry() @@ -281,7 +378,7 @@ await crdtApi.CreateEntry(new Entry() LexemeForm = { { "en", "Banana" } }, Senses = [ - new Sense() { Gloss = { { "en", "Banana" } }, SemanticDomains = [ semdom3 ] } + new Sense() { Gloss = { { "en", "Banana" } }, SemanticDomains = [semdom3] } ] }); await _syncService.Sync(crdtApi, fwdataApi); @@ -365,7 +462,7 @@ public async Task AddingASenseToAnEntryInEachProjectSyncsAcrossBoth() await _syncService.Sync(crdtApi, fwdataApi); await fwdataApi.CreateSense(_testEntry.Id, new Sense() - { + { Gloss = { { "en", "Fruit" } }, Definition = { { "en", "a round fruit, red or yellow" } }, }); @@ -373,7 +470,7 @@ public async Task AddingASenseToAnEntryInEachProjectSyncsAcrossBoth() { Gloss = { { "en", "Tree" } }, Definition = { { "en", "a tall, woody plant, which grows fruit" } }, - }); + }); await _syncService.Sync(crdtApi, fwdataApi); @@ -383,4 +480,24 @@ public async Task AddingASenseToAnEntryInEachProjectSyncsAcrossBoth() options => options.For(e => e.Components).Exclude(c => c.Id) .For(e => e.ComplexForms).Exclude(c => c.Id)); } + + [Fact] + public async Task CanCreateAComplexFormAndItsComponentInOneSync() + { + //ensure they are synced so a real sync will happen when we want it to + await _fixture.SyncService.Sync(_fixture.CrdtApi, _fixture.FwDataApi); + + var complexFormEntry = await _fixture.CrdtApi.CreateEntry(new() { LexemeForm = { { "en", "complexForm" } } }); + var componentEntry = await _fixture.CrdtApi.CreateEntry(new() + { + LexemeForm = { { "en", "component" } }, + ComplexForms = + [ + new ComplexFormComponent() { ComplexFormEntryId = complexFormEntry.Id, ComponentEntryId = Guid.Empty } + ] + }); + + //one of the entries will be created first, it will try to create the reference to the other but it won't exist yet + await _fixture.SyncService.Sync(_fixture.CrdtApi, _fixture.FwDataApi); + } } diff --git a/backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs b/backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs index 12258dcee..58685043b 100644 --- a/backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs +++ b/backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs @@ -1,8 +1,9 @@ using System.Text.Json; +using FwDataMiniLcmBridge; using FwDataMiniLcmBridge.Api; using LcmCrdt; +using LexCore.Sync; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; using MiniLcm; using MiniLcm.Models; using MiniLcm.SyncHelpers; @@ -11,9 +12,18 @@ namespace FwLiteProjectSync; -public class CrdtFwdataProjectSyncService(IOptions lcmCrdtConfig, MiniLcmImport miniLcmImport, ILogger logger) +public class CrdtFwdataProjectSyncService(MiniLcmImport miniLcmImport, ILogger logger) { - public record SyncResult(int CrdtChanges, int FwdataChanges); + public record DryRunSyncResult( + int CrdtChanges, + int FwdataChanges, + List CrdtDryRunRecords, + List FwDataDryRunRecords) : SyncResult(CrdtChanges, FwdataChanges); + + public async Task SyncDryRun(IMiniLcmApi crdtApi, FwDataMiniLcmApi fwdataApi) + { + return (DryRunSyncResult) await Sync(crdtApi, fwdataApi, true); + } public async Task Sync(IMiniLcmApi crdtApi, FwDataMiniLcmApi fwdataApi, bool dryRun = false) { @@ -21,13 +31,13 @@ public async Task Sync(IMiniLcmApi crdtApi, FwDataMiniLcmApi fwdataA { throw new InvalidOperationException($"Project id mismatch, CRDT Id: {crdt.ProjectData.FwProjectId}, FWData Id: {fwdataApi.ProjectId}"); } - var projectSnapshot = await GetProjectSnapshot(fwdataApi.Project.Name, fwdataApi.Project.ProjectsPath); + var projectSnapshot = await GetProjectSnapshot(fwdataApi.Project); SyncResult result = await Sync(crdtApi, fwdataApi, dryRun, fwdataApi.EntryCount, projectSnapshot); fwdataApi.Save(); if (!dryRun) { - await SaveProjectSnapshot(fwdataApi.Project.Name, fwdataApi.Project.ProjectsPath, + await SaveProjectSnapshot(fwdataApi.Project, new ProjectSnapshot( await fwdataApi.GetEntries().ToArrayAsync(), await fwdataApi.GetPartsOfSpeech().ToArrayAsync(), @@ -48,6 +58,7 @@ private async Task Sync(IMiniLcmApi crdtApi, IMiniLcmApi fwdataApi, { await miniLcmImport.ImportProject(crdtApi, fwdataApi, entryCount); LogDryRun(crdtApi, "crdt"); + if (dryRun) return new DryRunSyncResult(entryCount, 0, GetDryRunRecords(crdtApi), []); return new SyncResult(entryCount, 0); } @@ -69,7 +80,7 @@ private async Task Sync(IMiniLcmApi crdtApi, IMiniLcmApi fwdataApi, LogDryRun(fwdataApi, "fwdata"); //todo push crdt changes to lexbox - + if (dryRun) return new DryRunSyncResult(crdtChanges, fwdataChanges, GetDryRunRecords(crdtApi), GetDryRunRecords(fwdataApi)); return new SyncResult(crdtChanges, fwdataChanges); } @@ -84,23 +95,32 @@ private void LogDryRun(IMiniLcmApi api, string type) logger.LogInformation($"Dry run {type} changes: {dryRunApi.DryRunRecords.Count}"); } + private List GetDryRunRecords(IMiniLcmApi api) + { + return ((DryRunMiniLcmApi)api).DryRunRecords; + } + public record ProjectSnapshot(Entry[] Entries, PartOfSpeech[] PartsOfSpeech, SemanticDomain[] SemanticDomains); - private async Task GetProjectSnapshot(string projectName, string? projectPath) + private async Task GetProjectSnapshot(FwDataProject project) { - projectPath ??= lcmCrdtConfig.Value.ProjectPath; - var snapshotPath = Path.Combine(projectPath, $"{projectName}_snapshot.json"); + var snapshotPath = SnapshotPath(project); if (!File.Exists(snapshotPath)) return null; await using var file = File.OpenRead(snapshotPath); return await JsonSerializer.DeserializeAsync(file); } - private async Task SaveProjectSnapshot(string projectName, string? projectPath, ProjectSnapshot projectSnapshot) + internal async Task SaveProjectSnapshot(FwDataProject project, ProjectSnapshot projectSnapshot) { - projectPath ??= lcmCrdtConfig.Value.ProjectPath; - var snapshotPath = Path.Combine(projectPath, $"{projectName}_snapshot.json"); + var snapshotPath = SnapshotPath(project); await using var file = File.Create(snapshotPath); await JsonSerializer.SerializeAsync(file, projectSnapshot); } + internal static string SnapshotPath(FwDataProject project) + { + var projectPath = project.ProjectsPath; + var snapshotPath = Path.Combine(projectPath, $"{project.Name}_snapshot.json"); + return snapshotPath; + } } diff --git a/backend/FwLite/FwLiteProjectSync/DryRunMiniLcmApi.cs b/backend/FwLite/FwLiteProjectSync/DryRunMiniLcmApi.cs index 39e767702..3aac9f480 100644 --- a/backend/FwLite/FwLiteProjectSync/DryRunMiniLcmApi.cs +++ b/backend/FwLite/FwLiteProjectSync/DryRunMiniLcmApi.cs @@ -34,6 +34,12 @@ public async Task UpdateWritingSystem(WritingSystemId id, }).First(w => w.WsId == id); } + public Task UpdateWritingSystem(WritingSystem before, WritingSystem after) + { + DryRunRecords.Add(new DryRunRecord(nameof(UpdateEntry), $"Update {after.Type} writing system {after.WsId}")); + return Task.FromResult(after); + } + public IAsyncEnumerable GetPartsOfSpeech() { return api.GetPartsOfSpeech(); @@ -56,6 +62,12 @@ public Task UpdatePartOfSpeech(Guid id, UpdateObjectInput UpdatePartOfSpeech(PartOfSpeech before, PartOfSpeech after) + { + DryRunRecords.Add(new DryRunRecord(nameof(UpdatePartOfSpeech), $"Update part of speech {after.Id}")); + return Task.FromResult(after); + } + public Task DeletePartOfSpeech(Guid id) { DryRunRecords.Add(new DryRunRecord(nameof(DeletePartOfSpeech), $"Delete part of speech {id}")); @@ -81,13 +93,19 @@ public Task CreateSemanticDomain(SemanticDomain semanticDomain) public Task UpdateSemanticDomain(Guid id, UpdateObjectInput update) { - DryRunRecords.Add(new DryRunRecord(nameof(UpdateSemanticDomain), $"Update part of speech {id}")); + DryRunRecords.Add(new DryRunRecord(nameof(UpdateSemanticDomain), $"Update semantic domain {id}")); return GetSemanticDomain(id)!; } + public Task UpdateSemanticDomain(SemanticDomain before, SemanticDomain after) + { + DryRunRecords.Add(new DryRunRecord(nameof(UpdateSemanticDomain), $"Update semantic domain {after.Id}")); + return Task.FromResult(after); + } + public Task DeleteSemanticDomain(Guid id) { - DryRunRecords.Add(new DryRunRecord(nameof(DeleteSemanticDomain), $"Delete part of speech {id}")); + DryRunRecords.Add(new DryRunRecord(nameof(DeleteSemanticDomain), $"Delete semantic domain {id}")); return Task.CompletedTask; } @@ -194,6 +212,11 @@ public Task RemoveSemanticDomainFromSense(Guid senseId, Guid semanticDomainId) return Task.CompletedTask; } + public Task GetExampleSentence(Guid entryId, Guid senseId, Guid id) + { + return api.GetExampleSentence(entryId, senseId, id); + } + public Task CreateExampleSentence(Guid entryId, Guid senseId, ExampleSentence exampleSentence) { DryRunRecords.Add(new DryRunRecord(nameof(CreateExampleSentence), $"Create example sentence {exampleSentence.Sentence}")); @@ -207,11 +230,17 @@ public async Task UpdateExampleSentence(Guid entryId, { DryRunRecords.Add(new DryRunRecord(nameof(UpdateExampleSentence), $"Update example sentence {exampleSentenceId}, changes: {update.Summarize()}")); - var entry = await GetEntry(entryId) ?? - throw new NullReferenceException($"unable to find entry with id {entryId}"); - var sense = entry.Senses.First(s => s.Id == senseId); - var exampleSentence = sense.ExampleSentences.First(s => s.Id == exampleSentenceId); - return exampleSentence; + var exampleSentence = await GetExampleSentence(entryId, senseId, exampleSentenceId); + return exampleSentence ?? throw new NullReferenceException($"unable to find example sentence with id {exampleSentenceId}"); + } + + public Task UpdateExampleSentence(Guid entryId, + Guid senseId, + ExampleSentence before, + ExampleSentence after) + { + DryRunRecords.Add(new DryRunRecord(nameof(UpdateExampleSentence), $"Update example sentence {after.Id}")); + return Task.FromResult(after); } public Task DeleteExampleSentence(Guid entryId, Guid senseId, Guid exampleSentenceId) @@ -228,7 +257,7 @@ public Task CreateComplexFormComponent(ComplexFormComponen public Task DeleteComplexFormComponent(ComplexFormComponent complexFormComponent) { - DryRunRecords.Add(new DryRunRecord(nameof(DeleteComplexFormComponent), $"Delete complex form component complex entry: {complexFormComponent.ComplexFormHeadword}, component entry: {complexFormComponent.ComponentHeadword}")); + DryRunRecords.Add(new DryRunRecord(nameof(DeleteComplexFormComponent), $"Delete complex form component: {complexFormComponent}")); return Task.CompletedTask; } diff --git a/backend/FwLite/FwLiteProjectSync/FwLiteProjectSync.csproj b/backend/FwLite/FwLiteProjectSync/FwLiteProjectSync.csproj index 7e4c26e04..8cbde01b8 100644 --- a/backend/FwLite/FwLiteProjectSync/FwLiteProjectSync.csproj +++ b/backend/FwLite/FwLiteProjectSync/FwLiteProjectSync.csproj @@ -1,9 +1,6 @@  - net8.0 - enable - enable $(ApplicationDisplayVersion) $(ApplicationDisplayVersion) @@ -14,9 +11,13 @@ - - + + + + + + diff --git a/backend/FwLite/FwLiteProjectSync/Program.cs b/backend/FwLite/FwLiteProjectSync/Program.cs index e59799d28..d587f5fb0 100644 --- a/backend/FwLite/FwLiteProjectSync/Program.cs +++ b/backend/FwLite/FwLiteProjectSync/Program.cs @@ -52,7 +52,7 @@ public static Task Main(string[] args) var services = scope.ServiceProvider; var logger = services.GetRequiredService>(); var fwdataApi = services.GetRequiredService().GetFwDataMiniLcmApi(fwProjectName, true); - var projectsService = services.GetRequiredService(); + var projectsService = services.GetRequiredService(); var crdtProject = projectsService.GetProject(crdtProjectName); if (crdtProject is null) { diff --git a/backend/FwLite/LocalWebApp/Auth/AuthConfig.cs b/backend/FwLite/FwLiteShared/Auth/AuthConfig.cs similarity index 97% rename from backend/FwLite/LocalWebApp/Auth/AuthConfig.cs rename to backend/FwLite/FwLiteShared/Auth/AuthConfig.cs index 542aae2db..788316c6e 100644 --- a/backend/FwLite/LocalWebApp/Auth/AuthConfig.cs +++ b/backend/FwLite/FwLiteShared/Auth/AuthConfig.cs @@ -1,7 +1,7 @@ using System.ComponentModel.DataAnnotations; using LcmCrdt; -namespace LocalWebApp.Auth; +namespace FwLiteShared.Auth; public class AuthConfig { diff --git a/backend/FwLite/FwLiteShared/Auth/AuthService.cs b/backend/FwLite/FwLiteShared/Auth/AuthService.cs new file mode 100644 index 000000000..ec7621a87 --- /dev/null +++ b/backend/FwLite/FwLiteShared/Auth/AuthService.cs @@ -0,0 +1,44 @@ +using FwLiteShared.Projects; +using Microsoft.Extensions.Options; + +namespace FwLiteShared.Auth; + +public record ServerStatus(string DisplayName, bool LoggedIn, string? LoggedInAs, string? Authority); +public class AuthService(LexboxProjectService lexboxProjectService, OAuthClientFactory clientFactory, IOptions options) +{ + public IAsyncEnumerable Servers() + { + return lexboxProjectService.Servers().ToAsyncEnumerable().SelectAwait(async s => + { + var currentName = await clientFactory.GetClient(s).GetCurrentName(); + return new ServerStatus(s.DisplayName, + !string.IsNullOrEmpty(currentName), + currentName, + s.Authority.Authority); + }); + } + + public async Task SignInWebView(LexboxServer server) + { + var result = await clientFactory.GetClient(server).SignIn(string.Empty);//does nothing here + if (!result.HandledBySystemWebView) throw new InvalidOperationException("Sign in not handled by system web view"); + } + + public async Task SignInWebApp(LexboxServer server, string returnUrl) + { + var result = await clientFactory.GetClient(server).SignIn(returnUrl); + if (result.HandledBySystemWebView) throw new InvalidOperationException("Sign in handled by system web view"); + if (result.AuthUri is null) throw new InvalidOperationException("AuthUri is null"); + return result.AuthUri.ToString(); + } + + public async Task Logout(LexboxServer server) + { + await clientFactory.GetClient(server).Logout(); + } + + public async Task GetLoggedInName(LexboxServer server) + { + return await clientFactory.GetClient(server).GetCurrentName(); + } +} diff --git a/backend/FwLite/FwLiteShared/Auth/IRedirectUrlProvider.cs b/backend/FwLite/FwLiteShared/Auth/IRedirectUrlProvider.cs new file mode 100644 index 000000000..4c2b2541b --- /dev/null +++ b/backend/FwLite/FwLiteShared/Auth/IRedirectUrlProvider.cs @@ -0,0 +1,7 @@ +namespace FwLiteShared.Auth; + +public interface IRedirectUrlProvider +{ + string? GetRedirectUrl(); + bool ShouldRecreateAuthHelper(string? redirectUrl); +} diff --git a/backend/FwLite/LocalWebApp/Auth/LoggerAdapter.cs b/backend/FwLite/FwLiteShared/Auth/LoggerAdapter.cs similarity index 88% rename from backend/FwLite/LocalWebApp/Auth/LoggerAdapter.cs rename to backend/FwLite/FwLiteShared/Auth/LoggerAdapter.cs index ea75bdd5c..dab270f29 100644 --- a/backend/FwLite/LocalWebApp/Auth/LoggerAdapter.cs +++ b/backend/FwLite/FwLiteShared/Auth/LoggerAdapter.cs @@ -1,6 +1,7 @@ -using Microsoft.IdentityModel.Abstractions; +using Microsoft.Extensions.Logging; +using Microsoft.IdentityModel.Abstractions; -namespace LocalWebApp.Auth; +namespace FwLiteShared.Auth; public class LoggerAdapter(ILogger logger): IIdentityLogger { diff --git a/backend/FwLite/LocalWebApp/Auth/AuthHelpers.cs b/backend/FwLite/FwLiteShared/Auth/OAuthClient.cs similarity index 84% rename from backend/FwLite/LocalWebApp/Auth/AuthHelpers.cs rename to backend/FwLite/FwLiteShared/Auth/OAuthClient.cs index b33738f06..b9affb158 100644 --- a/backend/FwLite/LocalWebApp/Auth/AuthHelpers.cs +++ b/backend/FwLite/FwLiteShared/Auth/OAuthClient.cs @@ -1,65 +1,56 @@ using System.Net.Http.Headers; -using System.Security.Cryptography; -using LocalWebApp.Routes; -using LocalWebApp.Services; +using FwLiteShared.Projects; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Microsoft.Identity.Client; using Microsoft.Identity.Client.Extensions.Msal; -namespace LocalWebApp.Auth; +namespace FwLiteShared.Auth; /// -/// when injected directly it will use the authority of the current project, to get a different authority use +/// when injected directly it will use the authority of the current project, to get a different authority use /// helper class for using MSAL.net /// docs: https://learn.microsoft.com/en-us/entra/msal/dotnet/acquiring-tokens/overview /// -public class AuthHelpers +public class OAuthClient { public static IReadOnlyCollection DefaultScopes { get; } = ["profile", "openid"]; public const string AuthHttpClientName = "AuthHttpClient"; - private readonly HostString _redirectHost; - private readonly bool _isRedirectHostGuess; + public string? RedirectUrl { get; } private readonly IHttpMessageHandlerFactory _httpMessageHandlerFactory; private readonly OAuthService _oAuthService; - private readonly UrlContext _urlContext; private readonly LexboxServer _lexboxServer; private readonly LexboxProjectService _lexboxProjectService; - private readonly ILogger _logger; + private readonly ILogger _logger; private readonly IPublicClientApplication _application; AuthenticationResult? _authResult; - public AuthHelpers(LoggerAdapter loggerAdapter, + public OAuthClient(LoggerAdapter loggerAdapter, IHttpMessageHandlerFactory httpMessageHandlerFactory, IOptions options, - LinkGenerator linkGenerator, + IRedirectUrlProvider? redirectUrlProvider, OAuthService oAuthService, - UrlContext urlContext, LexboxServer lexboxServer, LexboxProjectService lexboxProjectService, - ILogger logger, + ILogger logger, IHostEnvironment hostEnvironment) { _httpMessageHandlerFactory = httpMessageHandlerFactory; _oAuthService = oAuthService; - _urlContext = urlContext; _lexboxServer = lexboxServer; _lexboxProjectService = lexboxProjectService; _logger = logger; - (var hostUrl, _isRedirectHostGuess) = urlContext.GetUrl(); - _redirectHost = HostString.FromUriComponent(hostUrl); - var redirectUri = options.Value.SystemWebViewLogin + RedirectUrl = options.Value.SystemWebViewLogin ? "http://localhost" //system web view will always have no path, changing this will not do anything in that case - : linkGenerator.GetUriByRouteValues(AuthRoutes.CallbackRoute, - new RouteValueDictionary(), - hostUrl.Scheme, - _redirectHost); + : redirectUrlProvider?.GetRedirectUrl() ?? throw new InvalidOperationException("No IRedirectUrlProvider configured, required for non-system web view login"); //todo configure token cache as seen here //https://github.com/AzureAD/microsoft-authentication-extensions-for-dotnet/wiki/Cross-platform-Token-Cache _application = PublicClientApplicationBuilder.Create(options.Value.ClientId) .WithExperimentalFeatures() .WithLogging(loggerAdapter, hostEnvironment.IsDevelopment()) .WithHttpClientFactory(new HttpClientFactoryAdapter(httpMessageHandlerFactory)) - .WithRedirectUri(redirectUri) + .WithRedirectUri(RedirectUrl) .WithOidcAuthority(lexboxServer.Authority.ToString()) .Build(); _ = MsalCacheHelper.CreateAsync(BuildCacheProperties(options.Value.CacheFileName)).ContinueWith( @@ -99,11 +90,6 @@ private static StorageCreationProperties BuildCacheProperties(string cacheFileNa return propertiesBuilder.Build(); } - public bool IsHostUrlValid() - { - return !_isRedirectHostGuess || _redirectHost == HostString.FromUriComponent(_urlContext.GetUrl().host); - } - private class HttpClientFactoryAdapter(IHttpMessageHandlerFactory httpMessageHandlerFactory) : IMsalHttpClientFactory { @@ -178,10 +164,10 @@ await _application return auth?.AccessToken; } - /// + /// ] /// will return null if no auth token is available /// - public async ValueTask CreateClient() + public async ValueTask CreateHttpClient() { var auth = await GetAuth(); if (auth is null) return null; diff --git a/backend/FwLite/FwLiteShared/Auth/OAuthClientFactory.cs b/backend/FwLite/FwLiteShared/Auth/OAuthClientFactory.cs new file mode 100644 index 000000000..1058e5aa2 --- /dev/null +++ b/backend/FwLite/FwLiteShared/Auth/OAuthClientFactory.cs @@ -0,0 +1,47 @@ +using System.Collections.Concurrent; +using LcmCrdt; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace FwLiteShared.Auth; + +public class OAuthClientFactory(IServiceProvider provider, + IOptions options, + IRedirectUrlProvider? redirectUrlProvider, + ILogger logger) +{ + private readonly ConcurrentDictionary _helpers = new(); + + private string AuthorityKey(LexboxServer server) => "AuthHelper|" + server.Authority.Authority; + + /// + /// gets an Auth Helper for the given server + /// + public OAuthClient GetClient(LexboxServer server) + { + var helper = _helpers.GetOrAdd(AuthorityKey(server), + static (host, arg) => ActivatorUtilities.CreateInstance(arg.provider, arg.server), + (server, provider)); + //an auth helper can get created based on the server host, however in development that will not be the same as the client host + //so we need to recreate it if the host is not valid, this is only required when not using system web view login + if (!options.Value.SystemWebViewLogin && redirectUrlProvider is not null && redirectUrlProvider.ShouldRecreateAuthHelper(helper.RedirectUrl)) + { + logger.LogInformation("Recreating auth helper with Redirect Url {RedirectUrl}", helper.RedirectUrl); + _helpers.TryRemove(AuthorityKey(server), out _); + return GetClient(server); + } + + return helper; + } + + /// + /// get auth helper for a given project + /// + public OAuthClient GetClient(ProjectData project) + { + var originDomain = project.OriginDomain; + if (string.IsNullOrEmpty(originDomain)) throw new InvalidOperationException("No origin domain in project data"); + return GetClient(options.Value.GetServer(project)); + } +} diff --git a/backend/FwLite/LocalWebApp/Auth/OAuthService.cs b/backend/FwLite/FwLiteShared/Auth/OAuthService.cs similarity index 96% rename from backend/FwLite/LocalWebApp/Auth/OAuthService.cs rename to backend/FwLite/FwLiteShared/Auth/OAuthService.cs index fe9d9c94a..9c0605a6c 100644 --- a/backend/FwLite/LocalWebApp/Auth/OAuthService.cs +++ b/backend/FwLite/FwLiteShared/Auth/OAuthService.cs @@ -1,11 +1,12 @@ using System.Threading.Channels; using System.Web; -using LocalWebApp.Utils; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Microsoft.Identity.Client; using Microsoft.Identity.Client.Extensibility; -namespace LocalWebApp.Auth; +namespace FwLiteShared.Auth; //this class is commented with a number of step comments, these are the steps in the OAuth flow //if a step comes before a method that means it awaits that call, if it comes after that means it resumes after the above await @@ -36,7 +37,7 @@ public async Task SubmitLoginRequest(IPublicClientApplication appl private async Task HandleSystemWebViewLogin(IPublicClientApplication application, CancellationToken cancellation) { - var result = await application.AcquireTokenInteractive(AuthHelpers.DefaultScopes) + var result = await application.AcquireTokenInteractive(OAuthClient.DefaultScopes) .WithUseEmbeddedWebView(false) .WithSystemWebViewOptions(new() { }) .ExecuteAsync(cancellation); @@ -69,7 +70,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) { //todo we can get stuck here if the user doesn't complete the login, this basically bricks the login at the moment. We need a timeout or something //step 2 - var result = await loginRequest.Application.AcquireTokenInteractive(AuthHelpers.DefaultScopes) + var result = await loginRequest.Application.AcquireTokenInteractive(OAuthClient.DefaultScopes) .WithCustomWebUi(loginRequest) .ExecuteAsync(stoppingToken); //step 7, causes step 8 to resume diff --git a/backend/FwLite/LocalWebApp/Utils/CancellationTokenExtensions.cs b/backend/FwLite/FwLiteShared/CancellationTokenExtensions.cs similarity index 89% rename from backend/FwLite/LocalWebApp/Utils/CancellationTokenExtensions.cs rename to backend/FwLite/FwLiteShared/CancellationTokenExtensions.cs index 35da0129f..51e0c859b 100644 --- a/backend/FwLite/LocalWebApp/Utils/CancellationTokenExtensions.cs +++ b/backend/FwLite/FwLiteShared/CancellationTokenExtensions.cs @@ -1,4 +1,4 @@ -namespace LocalWebApp.Utils; +namespace FwLiteShared; public static class CancellationTokenExtensions { diff --git a/backend/FwLite/LocalWebApp/Services/ChangeEventBus.cs b/backend/FwLite/FwLiteShared/ChangeEventBus.cs similarity index 52% rename from backend/FwLite/LocalWebApp/Services/ChangeEventBus.cs rename to backend/FwLite/FwLiteShared/ChangeEventBus.cs index 06d331d8e..29734cc0e 100644 --- a/backend/FwLite/LocalWebApp/Services/ChangeEventBus.cs +++ b/backend/FwLite/FwLiteShared/ChangeEventBus.cs @@ -1,33 +1,13 @@ using System.Reactive.Linq; using System.Reactive.Subjects; using LcmCrdt; -using LocalWebApp.Hubs; -using Microsoft.AspNetCore.SignalR; -using Microsoft.Extensions.Caching.Memory; using MiniLcm.Models; -namespace LocalWebApp.Services; +namespace FwLiteShared; -public class ChangeEventBus( - ProjectContext projectContext, - IHubContext hubContext, - ILogger logger, - IMemoryCache cache) +public class ChangeEventBus(ProjectContext projectContext) : IDisposable { - public IDisposable ListenForEntryChanges(string projectName, string connectionId) => - _entryUpdated - .Where(n => n.ProjectName == projectName) - .Subscribe(n => OnEntryChangedExternal(n.Entry, connectionId)); - - private void OnEntryChangedExternal(Entry e, string connectionId) - { - var currentFilter = CrdtMiniLcmApiHub.CurrentProjectFilter(cache, connectionId); - if (currentFilter.Invoke(e)) - { - _ = hubContext.Clients.Client(connectionId).OnEntryUpdated(e); - } - } private record struct ChangeNotification(Entry Entry, string ProjectName); diff --git a/backend/FwLite/FwLiteShared/FwLiteShared.csproj b/backend/FwLite/FwLiteShared/FwLiteShared.csproj new file mode 100644 index 000000000..9da6832da --- /dev/null +++ b/backend/FwLite/FwLiteShared/FwLiteShared.csproj @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/FwLite/FwLiteShared/FwLiteSharedKernel.cs b/backend/FwLite/FwLiteShared/FwLiteSharedKernel.cs new file mode 100644 index 000000000..fc0ca094f --- /dev/null +++ b/backend/FwLite/FwLiteShared/FwLiteSharedKernel.cs @@ -0,0 +1,62 @@ +using FwDataMiniLcmBridge; +using FwLiteProjectSync; +using FwLiteShared.Auth; +using FwLiteShared.Projects; +using FwLiteShared.Sync; +using LcmCrdt; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace FwLiteShared; + +public static class FwLiteSharedKernel +{ + public static IServiceCollection AddFwLiteShared(this IServiceCollection services, IHostEnvironment environment) + { + services.AddHttpClient(); + services.AddAuthHelpers(environment); + services.AddLcmCrdtClient(); + services.AddFwDataBridge(); + services.AddFwLiteProjectSync(); + + services.AddSingleton(); + services.AddScoped(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(s => s.GetRequiredService()); + return services; + } + + private static void AddAuthHelpers(this IServiceCollection services, IHostEnvironment environment) + { + services.AddSingleton(); + services.AddSingleton(); + services.AddScoped(CurrentAuthHelperFactory); + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddOptionsWithValidateOnStart().BindConfiguration("Auth").ValidateDataAnnotations(); + services.AddSingleton(); + var httpClientBuilder = services.AddHttpClient(OAuthClient.AuthHttpClientName); + if (environment.IsDevelopment()) + { + // Allow self-signed certificates in development + httpClientBuilder.ConfigurePrimaryHttpMessageHandler(() => + { + return new HttpClientHandler + { + ClientCertificateOptions = ClientCertificateOption.Manual, + ServerCertificateCustomValidationCallback = (message, certificate2, arg3, arg4) => true + }; + }); + } + } + + private static OAuthClient CurrentAuthHelperFactory(this IServiceProvider serviceProvider) + { + var authHelpersFactory = serviceProvider.GetRequiredService(); + var currentProjectService = serviceProvider.GetRequiredService(); + return authHelpersFactory.GetClient(currentProjectService.ProjectData); + } +} diff --git a/backend/FwLite/FwLiteShared/Projects/CombinedProjectsService.cs b/backend/FwLite/FwLiteShared/Projects/CombinedProjectsService.cs new file mode 100644 index 000000000..f53b28070 --- /dev/null +++ b/backend/FwLite/FwLiteShared/Projects/CombinedProjectsService.cs @@ -0,0 +1,84 @@ +using FwDataMiniLcmBridge; +using FwLiteShared.Auth; +using FwLiteShared.Sync; +using LcmCrdt; +using Microsoft.Extensions.DependencyInjection; + +namespace FwLiteShared.Projects; + +public record ProjectModel( + string Name, + bool Crdt, + bool Fwdata, + bool Lexbox = false, + string? ServerAuthority = null, + Guid? Id = null); + +public record ServerProjects(LexboxServer Server, ProjectModel[] Projects); +public class CombinedProjectsService(LexboxProjectService lexboxProjectService, CrdtProjectsService crdtProjectsService, + FieldWorksProjectList fieldWorksProjectList) +{ + public async Task RemoteProjects() + { + var lexboxServers = lexboxProjectService.Servers(); + ServerProjects[] serverProjects = new ServerProjects[lexboxServers.Length]; + for (var i = 0; i < lexboxServers.Length; i++) + { + var server = lexboxServers[i]; + var lexboxProjects = await lexboxProjectService.GetLexboxProjects(server); + serverProjects[i] = new ServerProjects(server, + lexboxProjects.Select(p => new ProjectModel(p.Name, + Crdt: p.IsCrdtProject, + Fwdata: false, + Lexbox: true, + server.Authority.Authority, + p.Id)) + .ToArray()); + } + + return serverProjects; + } + + public async Task> LocalProjects() + { + var crdtProjects = await crdtProjectsService.ListProjects(); + //todo get project Id and use that to specify the Id in the model. Also pull out server + var projects = crdtProjects.ToDictionary(p => p.Name, + p => + { + var uri = p.Data?.OriginDomain is not null ? new Uri(p.Data.OriginDomain) : null; + return new ProjectModel(p.Name, + true, + false, + p.Data?.OriginDomain is not null, + uri?.Authority, + p.Data?.Id); + }); + //basically populate projects and indicate if they are lexbox or fwdata + foreach (var p in fieldWorksProjectList.EnumerateProjects()) + { + if (projects.TryGetValue(p.Name, out var project)) + { + projects[p.Name] = project with { Fwdata = true }; + } + else + { + projects.Add(p.Name, new ProjectModel(p.Name, false, true)); + } + } + + return projects.Values; + } + + public async Task DownloadProject(Guid lexboxProjectId, string projectName, LexboxServer server) + { + await crdtProjectsService.CreateProject(new(projectName, + lexboxProjectId, + server.Authority, + async (provider, project) => + { + await provider.GetRequiredService().ExecuteSync(); + }, + SeedNewProjectData: false)); + } +} diff --git a/backend/FwLite/LocalWebApp/Services/ImportFwdataService.cs b/backend/FwLite/FwLiteShared/Projects/ImportFwdataService.cs similarity index 85% rename from backend/FwLite/LocalWebApp/Services/ImportFwdataService.cs rename to backend/FwLite/FwLiteShared/Projects/ImportFwdataService.cs index 20f6ab04c..e78cc7a42 100644 --- a/backend/FwLite/LocalWebApp/Services/ImportFwdataService.cs +++ b/backend/FwLite/FwLiteShared/Projects/ImportFwdataService.cs @@ -1,14 +1,16 @@ using System.Diagnostics; -using FwLiteProjectSync; using FwDataMiniLcmBridge; +using FwLiteProjectSync; using Humanizer; using LcmCrdt; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using MiniLcm; -namespace LocalWebApp.Services; +namespace FwLiteShared.Projects; public class ImportFwdataService( - ProjectsService projectsService, + CrdtProjectsService crdtProjectsService, ILogger logger, FwDataFactory fwDataFactory, FieldWorksProjectList fieldWorksProjectList, @@ -26,7 +28,7 @@ public async Task Import(string projectName) try { using var fwDataApi = fwDataFactory.GetFwDataMiniLcmApi(fwDataProject, false); - var project = await projectsService.CreateProject(new(fwDataProject.Name, + var project = await crdtProjectsService.CreateProject(new(fwDataProject.Name, SeedNewProjectData: false, FwProjectId: fwDataApi.ProjectId, AfterCreate: async (provider, project) => diff --git a/backend/FwLite/LocalWebApp/Services/LexboxProjectService.cs b/backend/FwLite/FwLiteShared/Projects/LexboxProjectService.cs similarity index 89% rename from backend/FwLite/LocalWebApp/Services/LexboxProjectService.cs rename to backend/FwLite/FwLiteShared/Projects/LexboxProjectService.cs index c392ce503..b60b2e915 100644 --- a/backend/FwLite/LocalWebApp/Services/LexboxProjectService.cs +++ b/backend/FwLite/FwLiteShared/Projects/LexboxProjectService.cs @@ -1,14 +1,17 @@ -using LcmCrdt; -using LocalWebApp.Auth; -using Microsoft.Extensions.Options; +using System.Net.Http.Json; +using FwLiteShared.Auth; +using FwLiteShared.Sync; +using LcmCrdt; using Microsoft.AspNetCore.SignalR.Client; using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using MiniLcm.Push; -namespace LocalWebApp.Services; +namespace FwLiteShared.Projects; public class LexboxProjectService( - AuthHelpersFactory helpersFactory, + OAuthClientFactory clientFactory, ILogger logger, IHttpMessageHandlerFactory httpMessageHandlerFactory, BackgroundSyncService backgroundSyncService, @@ -28,7 +31,7 @@ public async Task GetLexboxProjects(LexboxServer server) async entry => { entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5); - var httpClient = await helpersFactory.GetHelper(server).CreateClient(); + var httpClient = await clientFactory.GetClient(server).CreateHttpClient(); if (httpClient is null) return []; try { @@ -49,7 +52,7 @@ private static string CacheKey(LexboxServer server) public async Task GetLexboxProjectId(LexboxServer server, string code) { - var httpClient = await helpersFactory.GetHelper(server).CreateClient(); + var httpClient = await clientFactory.GetClient(server).CreateHttpClient(); if (httpClient is null) return null; try { @@ -86,7 +89,7 @@ public async Task ListenForProjectChanges(ProjectData projectData, CancellationT return connection; } - if (await helpersFactory.GetHelper(server).GetCurrentToken() is null) + if (await clientFactory.GetClient(server).GetCurrentToken() is null) { logger.LogWarning("Unable to create signalR client, user is not authenticated to {OriginDomain}", server.Authority); @@ -103,10 +106,10 @@ public async Task ListenForProjectChanges(ProjectData projectData, CancellationT connectionOptions.HttpMessageHandlerFactory = handler => { //use a client that does not validate certs in dev - return httpMessageHandlerFactory.CreateHandler(AuthHelpers.AuthHttpClientName); + return httpMessageHandlerFactory.CreateHandler(OAuthClient.AuthHttpClientName); }; connectionOptions.AccessTokenProvider = - async () => await helpersFactory.GetHelper(server).GetCurrentToken(); + async () => await clientFactory.GetClient(server).GetCurrentToken(); }) .Build(); diff --git a/backend/FwLite/LocalWebApp/BackgroundSyncService.cs b/backend/FwLite/FwLiteShared/Sync/BackgroundSyncService.cs similarity index 87% rename from backend/FwLite/LocalWebApp/BackgroundSyncService.cs rename to backend/FwLite/FwLiteShared/Sync/BackgroundSyncService.cs index bbe93c26c..cacac6867 100644 --- a/backend/FwLite/LocalWebApp/BackgroundSyncService.cs +++ b/backend/FwLite/FwLiteShared/Sync/BackgroundSyncService.cs @@ -1,12 +1,15 @@ using System.Threading.Channels; -using SIL.Harmony; using LcmCrdt; using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using SIL.Harmony; -namespace LocalWebApp; +namespace FwLiteShared.Sync; public class BackgroundSyncService( - ProjectsService projectsService, + CrdtProjectsService crdtProjectsService, IHostApplicationLifetime applicationLifetime, ProjectContext projectContext, ILogger logger, @@ -28,7 +31,7 @@ public void TriggerSync(Guid projectId, Guid? ignoredClientId = null) return; } - var crdtProject = projectsService.GetProject(projectData.Name); + var crdtProject = crdtProjectsService.GetProject(projectData.Name); if (crdtProject is null) { logger.LogWarning("Received project update for unknown project {ProjectName}", projectData.Name); @@ -58,7 +61,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) { //need to wait until application is started, otherwise Server urls will be unknown which prevents creating downstream services await StartedAsync(); - var crdtProjects = await projectsService.ListProjects(); + var crdtProjects = await crdtProjectsService.ListProjects(); foreach (var crdtProject in crdtProjects) { await SyncProject(crdtProject); @@ -76,7 +79,7 @@ private async Task SyncProject(CrdtProject crdtProject) { try { - await using var serviceScope = projectsService.CreateProjectScope(crdtProject); + await using var serviceScope = crdtProjectsService.CreateProjectScope(crdtProject); await serviceScope.ServiceProvider.GetRequiredService().PopulateProjectDataCache(); var syncService = serviceScope.ServiceProvider.GetRequiredService(); return await syncService.ExecuteSync(); diff --git a/backend/FwLite/LocalWebApp/SyncService.cs b/backend/FwLite/FwLiteShared/Sync/SyncService.cs similarity index 76% rename from backend/FwLite/LocalWebApp/SyncService.cs rename to backend/FwLite/FwLiteShared/Sync/SyncService.cs index 7c4be59f6..e797be14d 100644 --- a/backend/FwLite/LocalWebApp/SyncService.cs +++ b/backend/FwLite/FwLiteShared/Sync/SyncService.cs @@ -1,20 +1,21 @@ -using SIL.Harmony; +using FwLiteShared.Auth; +using FwLiteShared.Projects; using LcmCrdt; using LcmCrdt.RemoteSync; -using LocalWebApp.Auth; -using LocalWebApp.Services; +using Microsoft.Extensions.Logging; using MiniLcm; using MiniLcm.Models; -using SIL.Harmony.Entities; +using SIL.Harmony; -namespace LocalWebApp; +namespace FwLiteShared.Sync; public class SyncService( DataModel dataModel, CrdtHttpSyncService remoteSyncServiceServer, - AuthHelpersFactory authHelpersFactory, + OAuthClientFactory oAuthClientFactory, CurrentProjectService currentProjectService, ChangeEventBus changeEventBus, + LexboxProjectService lexboxProjectService, IMiniLcmApi lexboxApi, ILogger logger) { @@ -28,7 +29,7 @@ public async Task ExecuteSync() return new SyncResults([], [], false); } - var httpClient = await authHelpersFactory.GetHelper(project).CreateClient(); + var httpClient = await oAuthClientFactory.GetClient(project).CreateHttpClient(); if (httpClient is null) { logger.LogWarning( @@ -75,4 +76,21 @@ private async Task SendNotifications(SyncResults syncResults) _ => null }; } + + public async Task UploadProject(Guid lexboxProjectId, LexboxServer server) + { + await currentProjectService.SetProjectSyncOrigin(server.Authority, lexboxProjectId); + try + { + await ExecuteSync(); + } + catch + { + await currentProjectService.SetProjectSyncOrigin(null, null); + throw; + } + + //todo maybe decouple this + lexboxProjectService.InvalidateProjectsCache(server); + } } diff --git a/backend/FwLite/LcmCrdt.Tests/Changes/ComplexFormTests.cs b/backend/FwLite/LcmCrdt.Tests/Changes/ComplexFormTests.cs index 5f5be9ed1..e53c60b01 100644 --- a/backend/FwLite/LcmCrdt.Tests/Changes/ComplexFormTests.cs +++ b/backend/FwLite/LcmCrdt.Tests/Changes/ComplexFormTests.cs @@ -11,21 +11,21 @@ public class ComplexFormTests(MiniLcmApiFixture fixture) : IClassFixture e.ComponentEntryId == coatEntry.Id); + complexEntry.Components.Should().ContainSingle(e => e.ComponentEntryId == coatEntry.Id); complexEntry.Components.Should().ContainSingle(e => e.ComponentEntryId == rackEntry.Id); coatEntry = await fixture.Api.GetEntry(coatEntry.Id); coatEntry.Should().NotBeNull(); - coatEntry!.ComplexForms.Should().ContainSingle(e => e.ComplexFormEntryId == complexEntry.Id); + coatEntry.ComplexForms.Should().ContainSingle(e => e.ComplexFormEntryId == complexEntry.Id); } [Fact] @@ -74,11 +74,11 @@ public async Task DeleteEntryComponent() await fixture.DataModel.AddChange(Guid.NewGuid(), new AddEntryComponentChange(ComplexFormComponent.FromEntries(complexEntry, rackEntry))); complexEntry = await fixture.Api.GetEntry(complexEntry.Id); complexEntry.Should().NotBeNull(); - var component = complexEntry!.Components.First(); + var component = complexEntry.Components.First(); await fixture.DataModel.AddChange(Guid.NewGuid(), new DeleteChange(component.Id)); complexEntry = await fixture.Api.GetEntry(complexEntry.Id); complexEntry.Should().NotBeNull(); - complexEntry!.Components.Should().NotContain(c => c.Id == component.Id); + complexEntry.Components.Should().NotContain(c => c.Id == component.Id); } } diff --git a/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyDbModel.verified.txt b/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyDbModel.verified.txt index a7b2cc8c5..5e1e13fe7 100644 --- a/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyDbModel.verified.txt +++ b/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyDbModel.verified.txt @@ -24,6 +24,8 @@ ComponentEntryId (Guid) Required FK Index ComponentHeadword (string) ComponentSenseId (Guid?) FK Index + Annotations: + Relational:ColumnName: ComponentSenseId DeletedAt (DateTimeOffset?) SnapshotId (no field, Guid?) Shadow FK Index Keys: @@ -34,10 +36,15 @@ ComplexFormComponent {'ComponentSenseId'} -> Sense {'Id'} Cascade ComplexFormComponent {'SnapshotId'} -> ObjectSnapshot {'Id'} Unique SetNull Indexes: - ComplexFormEntryId ComponentEntryId ComponentSenseId SnapshotId Unique + ComplexFormEntryId, ComponentEntryId Unique + Annotations: + Relational:Filter: ComponentSenseId IS NULL + ComplexFormEntryId, ComponentEntryId, ComponentSenseId Unique + Annotations: + Relational:Filter: ComponentSenseId IS NOT NULL Annotations: DiscriminatorProperty: Relational:FunctionName: @@ -328,4 +335,4 @@ Relational:ViewName: Relational:ViewSchema: Annotations: - ProductVersion: 8.0.4 \ No newline at end of file + ProductVersion: 8.0.11 \ No newline at end of file diff --git a/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.cs b/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.cs index 359edff20..3173e290d 100644 --- a/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.cs +++ b/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.cs @@ -43,7 +43,7 @@ public async Task InitializeAsync() { await _crdtDbContext.Database.OpenConnectionAsync(); //can't use ProjectsService.CreateProject because it opens and closes the db context, this would wipe out the in memory db. - await ProjectsService.InitProjectDb(_crdtDbContext, + await CrdtProjectsService.InitProjectDb(_crdtDbContext, new ProjectData("Sena 3", Guid.NewGuid(), null, Guid.NewGuid())); await _services.ServiceProvider.GetRequiredService().PopulateProjectDataCache(); } diff --git a/backend/FwLite/LcmCrdt.Tests/JsonPatchEntryRewriteTests.cs b/backend/FwLite/LcmCrdt.Tests/JsonPatchEntryRewriteTests.cs index b2370c85e..13eb26933 100644 --- a/backend/FwLite/LcmCrdt.Tests/JsonPatchEntryRewriteTests.cs +++ b/backend/FwLite/LcmCrdt.Tests/JsonPatchEntryRewriteTests.cs @@ -20,7 +20,6 @@ public void ChangesFromJsonPatch_AddComponentMakesAddEntryComponentChange() changes.Should().ContainSingle().Which.Should().BeOfType().Subject; addEntryComponentChange.ComplexFormEntryId.Should().Be(_entry.Id); addEntryComponentChange.ComponentEntryId.Should().Be(componentEntry.Id); - addEntryComponentChange.ComponentHeadword.Should().Be(componentEntry.Headword()); } [Fact] @@ -84,7 +83,6 @@ public void ChangesFromJsonPatch_AddComplexFormMakesAddEntryComponentChange() changes.Should().ContainSingle().Which.Should().BeOfType().Subject; addEntryComponentChange.ComplexFormEntryId.Should().Be(_entry.Id); addEntryComponentChange.ComponentEntryId.Should().Be(componentEntry.Id); - addEntryComponentChange.ComponentHeadword.Should().Be(componentEntry.Headword()); } [Fact] diff --git a/backend/FwLite/LcmCrdt.Tests/LcmCrdt.Tests.csproj b/backend/FwLite/LcmCrdt.Tests/LcmCrdt.Tests.csproj index d955b734a..82a3eb3d8 100644 --- a/backend/FwLite/LcmCrdt.Tests/LcmCrdt.Tests.csproj +++ b/backend/FwLite/LcmCrdt.Tests/LcmCrdt.Tests.csproj @@ -1,9 +1,6 @@ - net8.0 - enable - enable false true @@ -15,14 +12,14 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - - + + - + - all - runtime; build; native; contentfiles; analyzers; buildtransitive + all + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/backend/FwLite/LcmCrdt.Tests/MiniLcmApiFixture.cs b/backend/FwLite/LcmCrdt.Tests/MiniLcmApiFixture.cs index bc93339cc..1ce5a0321 100644 --- a/backend/FwLite/LcmCrdt.Tests/MiniLcmApiFixture.cs +++ b/backend/FwLite/LcmCrdt.Tests/MiniLcmApiFixture.cs @@ -37,7 +37,7 @@ public async Task InitializeAsync() _crdtDbContext = _services.ServiceProvider.GetRequiredService(); await _crdtDbContext.Database.OpenConnectionAsync(); //can't use ProjectsService.CreateProject because it opens and closes the db context, this would wipe out the in memory db. - await ProjectsService.InitProjectDb(_crdtDbContext, + await CrdtProjectsService.InitProjectDb(_crdtDbContext, new ProjectData("Sena 3", Guid.NewGuid(), null, Guid.NewGuid())); await _services.ServiceProvider.GetRequiredService().PopulateProjectDataCache(); diff --git a/backend/FwLite/LcmCrdt.Tests/MiniLcmTests/SortingTests.cs b/backend/FwLite/LcmCrdt.Tests/MiniLcmTests/SortingTests.cs new file mode 100644 index 000000000..100c89e9d --- /dev/null +++ b/backend/FwLite/LcmCrdt.Tests/MiniLcmTests/SortingTests.cs @@ -0,0 +1,19 @@ +namespace LcmCrdt.Tests.MiniLcmTests; + +public class SortingTests : SortingTestsBase +{ + private readonly MiniLcmApiFixture _fixture = new(); + + protected override async Task NewApi() + { + await _fixture.InitializeAsync(); + var api = _fixture.Api; + return api; + } + + public override async Task DisposeAsync() + { + await base.DisposeAsync(); + await _fixture.DisposeAsync(); + } +} diff --git a/backend/FwLite/LcmCrdt.Tests/OpenProjectTests.cs b/backend/FwLite/LcmCrdt.Tests/OpenProjectTests.cs index cdaa72ab0..9fb67472a 100644 --- a/backend/FwLite/LcmCrdt.Tests/OpenProjectTests.cs +++ b/backend/FwLite/LcmCrdt.Tests/OpenProjectTests.cs @@ -10,12 +10,13 @@ public class OpenProjectTests public async Task OpeningAProjectWorks() { var sqliteConnectionString = "OpeningAProjectWorks.sqlite"; + if (File.Exists(sqliteConnectionString)) File.Delete(sqliteConnectionString); var builder = Host.CreateEmptyApplicationBuilder(null); builder.Services.AddLcmCrdtClient(); using var host = builder.Build(); var services = host.Services; var asyncScope = services.CreateAsyncScope(); - await asyncScope.ServiceProvider.GetRequiredService() + await asyncScope.ServiceProvider.GetRequiredService() .CreateProject(new(Name: "OpeningAProjectWorks", Path: "", SeedNewProjectData: true)); var miniLcmApi = (CrdtMiniLcmApi)await asyncScope.ServiceProvider.OpenCrdtProject(new CrdtProject("OpeningAProjectWorks", sqliteConnectionString)); diff --git a/backend/FwLite/LcmCrdt.Tests/SerializationTests.cs b/backend/FwLite/LcmCrdt.Tests/SerializationTests.cs index 268255141..52002507f 100644 --- a/backend/FwLite/LcmCrdt.Tests/SerializationTests.cs +++ b/backend/FwLite/LcmCrdt.Tests/SerializationTests.cs @@ -76,7 +76,7 @@ public void CanDeserializeMultiString() }; var actualMs = JsonSerializer.Deserialize(json); actualMs.Should().NotBeNull(); - actualMs!.Values.Should().ContainKey("en"); + actualMs.Values.Should().ContainKey("en"); actualMs.Should().BeEquivalentTo(expectedMs); } diff --git a/backend/FwLite/LcmCrdt/Changes/Entries/AddEntryComponentChange.cs b/backend/FwLite/LcmCrdt/Changes/Entries/AddEntryComponentChange.cs index a51313198..f93630058 100644 --- a/backend/FwLite/LcmCrdt/Changes/Entries/AddEntryComponentChange.cs +++ b/backend/FwLite/LcmCrdt/Changes/Entries/AddEntryComponentChange.cs @@ -10,10 +10,8 @@ namespace LcmCrdt.Changes.Entries; public class AddEntryComponentChange : CreateChange, ISelfNamedType { public Guid ComplexFormEntryId { get; } - public string? ComplexFormHeadword { get; } public Guid ComponentEntryId { get; } public Guid? ComponentSenseId { get; } - public string? ComponentHeadword { get; } [JsonConstructor] public AddEntryComponentChange(Guid entityId, @@ -24,9 +22,7 @@ public AddEntryComponentChange(Guid entityId, Guid? componentSenseId = null) : base(entityId) { ComplexFormEntryId = complexFormEntryId; - ComplexFormHeadword = complexFormHeadword; ComponentEntryId = componentEntryId; - ComponentHeadword = componentHeadword; ComponentSenseId = componentSenseId; } @@ -41,17 +37,22 @@ public AddEntryComponentChange(Guid entityId, public override async ValueTask NewEntity(Commit commit, ChangeContext context) { + var complexFormEntry = await context.GetCurrent(ComplexFormEntryId); + var componentEntry = await context.GetCurrent(ComponentEntryId); + Sense? componentSense = null; + if (ComponentSenseId is not null) + componentSense = await context.GetCurrent(ComponentSenseId.Value); return new ComplexFormComponent { Id = EntityId, ComplexFormEntryId = ComplexFormEntryId, - ComplexFormHeadword = ComplexFormHeadword, + ComplexFormHeadword = complexFormEntry?.Headword(), ComponentEntryId = ComponentEntryId, - ComponentHeadword = ComponentHeadword, + ComponentHeadword = componentEntry?.Headword(), ComponentSenseId = ComponentSenseId, - DeletedAt = (await context.IsObjectDeleted(ComponentEntryId) || - await context.IsObjectDeleted(ComplexFormEntryId) || - ComponentSenseId.HasValue && await context.IsObjectDeleted(ComponentSenseId.Value)) + DeletedAt = (complexFormEntry?.DeletedAt is not null || + componentEntry?.DeletedAt is not null || + (ComponentSenseId.HasValue && componentSense?.DeletedAt is not null)) ? commit.DateTime : (DateTime?)null, }; diff --git a/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs b/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs index ad6914812..08fca04dc 100644 --- a/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs +++ b/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs @@ -1,4 +1,5 @@ using System.Linq.Expressions; +using FluentValidation; using SIL.Harmony; using SIL.Harmony.Changes; using LcmCrdt.Changes; @@ -9,23 +10,25 @@ using LinqToDB.EntityFrameworkCore; using MiniLcm.Exceptions; using MiniLcm.SyncHelpers; +using MiniLcm.Validators; using SIL.Harmony.Db; namespace LcmCrdt; -public class CrdtMiniLcmApi(DataModel dataModel, CurrentProjectService projectService, LcmCrdtDbContext dbContext) : IMiniLcmApi +public class CrdtMiniLcmApi(DataModel dataModel, CurrentProjectService projectService, LcmCrdtDbContext dbContext, MiniLcmValidators validators) : IMiniLcmApi { private Guid ClientId { get; } = projectService.ProjectData.ClientId; public ProjectData ProjectData => projectService.ProjectData; - private IQueryable Entries => dataModel.QueryLatest(); - private IQueryable ComplexFormComponents => dataModel.QueryLatest(); - private IQueryable ComplexFormTypes => dataModel.QueryLatest(); - private IQueryable Senses => dataModel.QueryLatest(); - private IQueryable ExampleSentences => dataModel.QueryLatest(); - private IQueryable WritingSystems => dataModel.QueryLatest(); - private IQueryable SemanticDomains => dataModel.QueryLatest(); - private IQueryable PartsOfSpeech => dataModel.QueryLatest(); + private IQueryable Entries => dataModel.QueryLatest().AsTracking(false); + private IQueryable ComplexFormComponents => dataModel.QueryLatest() + .AsTracking(false); + private IQueryable ComplexFormTypes => dataModel.QueryLatest().AsTracking(false); + private IQueryable Senses => dataModel.QueryLatest().AsTracking(false); + private IQueryable ExampleSentences => dataModel.QueryLatest().AsTracking(false); + private IQueryable WritingSystems => dataModel.QueryLatest().AsTracking(false); + private IQueryable SemanticDomains => dataModel.QueryLatest().AsTracking(false); + private IQueryable PartsOfSpeech => dataModel.QueryLatest().AsTracking(false); public async Task GetWritingSystems() { @@ -56,6 +59,12 @@ public async Task UpdateWritingSystem(WritingSystemId id, Writing return await dataModel.GetLatest(ws.Id) ?? throw new NullReferenceException(); } + public async Task UpdateWritingSystem(WritingSystem before, WritingSystem after) + { + await WritingSystemSync.Sync(after, before, this); + return await GetWritingSystem(after.WsId, after.Type) ?? throw new NullReferenceException("unable to find writing system with id " + after.WsId); + } + private WritingSystem? _defaultVernacularWs; private WritingSystem? _defaultAnalysisWs; private async Task GetWritingSystem(WritingSystemId id, WritingSystemType type) @@ -97,6 +106,12 @@ public async Task UpdatePartOfSpeech(Guid id, UpdateObjectInput UpdatePartOfSpeech(PartOfSpeech before, PartOfSpeech after) + { + await PartOfSpeechSync.Sync(before, after, this); + return await GetPartOfSpeech(after.Id) ?? throw new NullReferenceException($"unable to find part of speech with id {after.Id}"); + } + public async Task DeletePartOfSpeech(Guid id) { await dataModel.AddChange(ClientId, new DeleteChange(id)); @@ -127,6 +142,12 @@ public async Task UpdateSemanticDomain(Guid id, UpdateObjectInpu return await GetSemanticDomain(id) ?? throw new NullReferenceException(); } + public async Task UpdateSemanticDomain(SemanticDomain before, SemanticDomain after) + { + await SemanticDomainSync.Sync(before, after, this); + return await GetSemanticDomain(after.Id) ?? throw new NullReferenceException($"unable to find semantic domain with id {after.Id}"); + } + public async Task DeleteSemanticDomain(Guid id) { await dataModel.AddChange(ClientId, new DeleteChange(id)); @@ -134,7 +155,7 @@ public async Task DeleteSemanticDomain(Guid id) public async Task BulkImportSemanticDomains(IEnumerable semanticDomains) { - await dataModel.AddChanges(ClientId, semanticDomains.Select(sd => new CreateSemanticDomainChange(sd.Id, sd.Name, sd.Code))); + await dataModel.AddChanges(ClientId, semanticDomains.Select(sd => new CreateSemanticDomainChange(sd.Id, sd.Name, sd.Code, sd.Predefined))); } public IAsyncEnumerable GetComplexFormTypes() @@ -144,6 +165,7 @@ public IAsyncEnumerable GetComplexFormTypes() public async Task CreateComplexFormType(ComplexFormType complexFormType) { + await validators.ValidateAndThrow(complexFormType); if (complexFormType.Id == default) complexFormType.Id = Guid.NewGuid(); await dataModel.AddChange(ClientId, new CreateComplexFormType(complexFormType.Id, complexFormType.Name)); return await ComplexFormTypes.SingleAsync(c => c.Id == complexFormType.Id); @@ -151,6 +173,11 @@ public async Task CreateComplexFormType(ComplexFormType complex public async Task CreateComplexFormComponent(ComplexFormComponent complexFormComponent) { + var existing = await ComplexFormComponents.SingleOrDefaultAsync(c => + c.ComplexFormEntryId == complexFormComponent.ComplexFormEntryId + && c.ComponentEntryId == complexFormComponent.ComponentEntryId + && c.ComponentSenseId == complexFormComponent.ComponentSenseId); + if (existing is not null) return existing; var addEntryComponentChange = new AddEntryComponentChange(complexFormComponent); await dataModel.AddChange(ClientId, addEntryComponentChange); return (await ComplexFormComponents.SingleOrDefaultAsync(c => c.Id == addEntryComponentChange.EntityId)) ?? throw NotFoundException.ForType(); @@ -206,7 +233,7 @@ private async IAsyncEnumerable GetEntries( queryable = queryable.WhereExemplar(ws.Value, options.Exemplar.Value); } - var sortWs = (await GetWritingSystem(options.Order.WritingSystem, WritingSystemType.Vernacular))?.WsId; + var sortWs = (await GetWritingSystem(options.Order.WritingSystem, WritingSystemType.Vernacular)); if (sortWs is null) throw new NullReferenceException($"sort writing system {options.Order.WritingSystem} not found"); queryable = queryable @@ -214,7 +241,7 @@ private async IAsyncEnumerable GetEntries( .LoadWith(e => e.ComplexForms) .LoadWith(e => e.Components) .AsQueryable() - .OrderBy(e => e.Headword(sortWs.Value)) + .OrderBy(e => e.Headword(sortWs.WsId).CollateUnicode(sortWs)) .ThenBy(e => e.Id) .Skip(options.Offset) .Take(options.Count); @@ -256,7 +283,12 @@ public async Task BulkCreateEntries(IAsyncEnumerable entries) { var semanticDomains = await SemanticDomains.ToDictionaryAsync(sd => sd.Id, sd => sd); var partsOfSpeech = await PartsOfSpeech.ToDictionaryAsync(p => p.Id, p => p); - await dataModel.AddChanges(ClientId, entries.ToBlockingEnumerable().SelectMany(entry => CreateEntryChanges(entry, semanticDomains, partsOfSpeech))); + await dataModel.AddChanges(ClientId, + entries.ToBlockingEnumerable() + .SelectMany(entry => CreateEntryChanges(entry, semanticDomains, partsOfSpeech)) + //force entries to be created first, this avoids issues where references are created before the entry is created + .OrderBy(c => c is CreateEntryChange ? 0 : 1) + ); } private IEnumerable CreateEntryChanges(Entry entry, Dictionary semanticDomains, Dictionary partsOfSpeech) @@ -458,6 +490,14 @@ public async Task CreateExampleSentence(Guid entryId, return await dataModel.GetLatest(exampleSentence.Id) ?? throw new NullReferenceException(); } + public async Task GetExampleSentence(Guid entryId, Guid senseId, Guid id) + { + var exampleSentence = await ExampleSentences.AsTracking(false) + .AsQueryable() + .SingleOrDefaultAsync(e => e.Id == id); + return exampleSentence; + } + public async Task UpdateExampleSentence(Guid entryId, Guid senseId, Guid exampleSentenceId, @@ -469,6 +509,15 @@ public async Task UpdateExampleSentence(Guid entryId, return await dataModel.GetLatest(exampleSentenceId) ?? throw new NullReferenceException(); } + public async Task UpdateExampleSentence(Guid entryId, + Guid senseId, + ExampleSentence before, + ExampleSentence after) + { + await ExampleSentenceSync.Sync(entryId, senseId, after, before, this); + return await GetExampleSentence(entryId, senseId, after.Id) ?? throw new NullReferenceException(); + } + public async Task DeleteExampleSentence(Guid entryId, Guid senseId, Guid exampleSentenceId) { await dataModel.AddChange(ClientId, new DeleteChange(exampleSentenceId)); diff --git a/backend/FwLite/LcmCrdt/ProjectsService.cs b/backend/FwLite/LcmCrdt/CrdtProjectsService.cs similarity index 60% rename from backend/FwLite/LcmCrdt/ProjectsService.cs rename to backend/FwLite/LcmCrdt/CrdtProjectsService.cs index 63aba27fa..aadc0dbaf 100644 --- a/backend/FwLite/LcmCrdt/ProjectsService.cs +++ b/backend/FwLite/LcmCrdt/CrdtProjectsService.cs @@ -1,4 +1,5 @@ -using SIL.Harmony; +using System.Text.RegularExpressions; +using SIL.Harmony; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -7,7 +8,7 @@ namespace LcmCrdt; -public class ProjectsService(IServiceProvider provider, ProjectContext projectContext, ILogger logger, IOptions config, IMemoryCache memoryCache) +public partial class CrdtProjectsService(IServiceProvider provider, ProjectContext projectContext, ILogger logger, IOptions config, IMemoryCache memoryCache) { public Task ListProjects() { @@ -42,8 +43,14 @@ public record CreateProjectRequest( string? Path = null, Guid? FwProjectId = null); + public async Task CreateExampleProject(string name) + { + return await CreateProject(new(name, AfterCreate: SampleProjectData, SeedNewProjectData: true)); + } + public async Task CreateProject(CreateProjectRequest request) { + if (!ProjectName().IsMatch(request.Name)) throw new InvalidOperationException("Project name is invalid"); //poor man's sanitation var name = Path.GetFileName(request.Name); var sqliteFile = Path.Combine(request.Path ?? config.Value.ProjectPath, $"{name}.sqlite"); @@ -104,4 +111,62 @@ public void SetActiveProject(string name) var project = GetProject(name) ?? throw new InvalidOperationException($"Crdt Project {name} not found"); SetProjectScope(project); } + + [GeneratedRegex("^[a-zA-Z0-9][a-zA-Z0-9-_]+$")] + public static partial Regex ProjectName(); + + public static async Task SampleProjectData(IServiceProvider provider, CrdtProject project) + { + var lexboxApi = provider.GetRequiredService(); + await lexboxApi.CreateEntry(new() + { + Id = Guid.NewGuid(), + LexemeForm = { Values = { { "en", "Apple" } } }, + CitationForm = { Values = { { "en", "Apple" } } }, + LiteralMeaning = { Values = { { "en", "Fruit" } } }, + Senses = + [ + new() + { + Gloss = { Values = { { "en", "Fruit" } } }, + Definition = + { + Values = + { + { + "en", + "fruit with red, yellow, or green skin with a sweet or tart crispy white flesh" + } + } + }, + SemanticDomains = [], + ExampleSentences = [new() { Sentence = { Values = { { "en", "We ate an apple" } } } }] + } + ] + }); + + await lexboxApi.CreateWritingSystem(WritingSystemType.Vernacular, + new() + { + Id = Guid.NewGuid(), + Type = WritingSystemType.Vernacular, + WsId = "en", + Name = "English", + Abbreviation = "en", + Font = "Arial", + Exemplars = WritingSystem.LatinExemplars + }); + + await lexboxApi.CreateWritingSystem(WritingSystemType.Analysis, + new() + { + Id = Guid.NewGuid(), + Type = WritingSystemType.Analysis, + WsId = "en", + Name = "English", + Abbreviation = "en", + Font = "Arial", + Exemplars = WritingSystem.LatinExemplars + }); + } } diff --git a/backend/FwLite/LcmCrdt/Data/SetupCollationInterceptor.cs b/backend/FwLite/LcmCrdt/Data/SetupCollationInterceptor.cs new file mode 100644 index 000000000..1a9a1033a --- /dev/null +++ b/backend/FwLite/LcmCrdt/Data/SetupCollationInterceptor.cs @@ -0,0 +1,152 @@ +using System.Data; +using System.Data.Common; +using System.Globalization; +using System.Text; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; + +namespace LcmCrdt.Data; + +public class SetupCollationInterceptor(IMemoryCache cache, ILogger logger) : IDbConnectionInterceptor, ISaveChangesInterceptor +{ + private WritingSystem[] GetWritingSystems(LcmCrdtDbContext dbContext, DbConnection connection) + { + //todo this needs to be invalidated when the writing systems change + return cache.GetOrCreate(CacheKey(connection), + entry => + { + entry.SlidingExpiration = TimeSpan.FromMinutes(30); + try + { + + return dbContext.WritingSystems.ToArray(); + } + catch (SqliteException e) + { + return []; + } + }) ?? []; + } + + private static string CacheKey(DbConnection connection) + { + return $"writingSystems|{connection.ConnectionString}"; + } + + private void InvalidateWritingSystemsCache(DbConnection connection) + { + cache.Remove(CacheKey(connection)); + } + + public void ConnectionOpened(DbConnection connection, ConnectionEndEventData eventData) + { + var context = (LcmCrdtDbContext?)eventData.Context; + if (context is null) throw new InvalidOperationException("context is null"); + var sqliteConnection = (SqliteConnection)connection; + SetupCollations(sqliteConnection, GetWritingSystems(context, connection)); + + //setup general use collation + sqliteConnection.CreateCollation(SqlSortingExtensions.CollateUnicodeNoCase, + CultureInfo.CurrentCulture.CompareInfo, + (compareInfo, x, y) => compareInfo.Compare(x, y, CompareOptions.IgnoreCase)); + } + + public Task ConnectionOpenedAsync(DbConnection connection, + ConnectionEndEventData eventData, + CancellationToken cancellationToken = default) + { + ConnectionOpened(connection, eventData); + return Task.CompletedTask; + } + + public InterceptionResult SavingChanges(DbContextEventData eventData, InterceptionResult result) + { + UpdateCollationsOnSave(eventData.Context); + return result; + } + + public ValueTask> SavingChangesAsync(DbContextEventData eventData, + InterceptionResult result, + CancellationToken cancellationToken = default) + { + UpdateCollationsOnSave(eventData.Context); + return ValueTask.FromResult(result); + } + + private void UpdateCollationsOnSave(DbContext? dbContext) + { + if (dbContext is null) return; + var connection = (SqliteConnection)dbContext.Database.GetDbConnection(); + bool updateWs = false; + foreach (var entityEntry in dbContext.ChangeTracker.Entries()) + { + if (entityEntry.State is EntityState.Added or EntityState.Modified) + { + var writingSystem = entityEntry.Entity; + SetupCollation(connection, writingSystem); + updateWs = true; + } + } + + if (updateWs) + { + InvalidateWritingSystemsCache(connection); + } + } + + private void SetupCollations(SqliteConnection connection, WritingSystem[] writingSystems) + { + foreach (var writingSystem in writingSystems) + { + SetupCollation(connection, writingSystem); + } + } + + private void SetupCollation(SqliteConnection connection, WritingSystem writingSystem) + { + CompareInfo compareInfo; + try + { + //todo use ICU/SLDR instead + compareInfo = CultureInfo.CreateSpecificCulture(writingSystem.WsId.Code).CompareInfo; + } + catch (Exception e) + { + logger.LogError(e, "Failed to create compare info for '{WritingSystemId}'", writingSystem.WsId); + compareInfo = CultureInfo.InvariantCulture.CompareInfo; + } + + //todo use custom comparison based on the writing system + CreateSpanCollation(connection, SqlSortingExtensions.CollationName(writingSystem), + compareInfo, + static (compareInfo, x, y) => compareInfo.Compare(x, y, CompareOptions.IgnoreCase)); + } + + //this is a premature optimization, but it avoids creating strings for each comparison and instead uses spans which avoids allocations + //if the new comparison function does not support spans then we can use SqliteConnection.CreateCollation instead which works with strings + private void CreateSpanCollation(SqliteConnection connection, + string name, T state, + Func, ReadOnlySpan, int> compare) + { + if (connection.State != ConnectionState.Open) + throw new InvalidOperationException("Unable to create custom collation Connection must be open."); + var rc = SQLitePCL.raw.sqlite3__create_collation_utf8(connection.Handle, + name, + Tuple.Create(state, compare), + static (s, x, y) => + { + var (state, compare) = (Tuple, ReadOnlySpan, int>>) s; + Span xSpan = stackalloc char[Encoding.UTF8.GetCharCount(x)]; + Span ySpan = stackalloc char[Encoding.UTF8.GetCharCount(y)]; + Encoding.UTF8.GetChars(x, xSpan); + Encoding.UTF8.GetChars(y, ySpan); + + return compare(state, xSpan, ySpan); + }); + SqliteException.ThrowExceptionForRC(rc, connection.Handle); + + } +} diff --git a/backend/FwLite/LcmCrdt/Data/SqlSortingExtensions.cs b/backend/FwLite/LcmCrdt/Data/SqlSortingExtensions.cs new file mode 100644 index 000000000..8e5465fa2 --- /dev/null +++ b/backend/FwLite/LcmCrdt/Data/SqlSortingExtensions.cs @@ -0,0 +1,30 @@ +using System.Data.SQLite; +using System.Linq.Expressions; +using LinqToDB; +using SIL.WritingSystems; + +namespace LcmCrdt.Data; + +public static class SqlSortingExtensions +{ + public const string CollateUnicodeNoCase = "NOCASE_UNICODE"; + + [ExpressionMethod(nameof(CollateUnicodeExpression))] + internal static string CollateUnicode(this string value, WritingSystem ws) + { + //could optionally just return the value here, but it would work differently than sql + throw new InvalidOperationException("CollateUnicode is a LinqToDB only API."); + } + + private static Expression> CollateUnicodeExpression() + { + //todo maybe in the future we use a custom collation based on the writing system + return (s, ws) => s.Collate(CollationName(ws)); + } + + internal static string CollationName(WritingSystem ws) + { + //don't use ':' in the name, it won't work + return $"NOCASE_WS_{ws.WsId}"; + } +} diff --git a/backend/FwLite/LcmCrdt/HistoryService.cs b/backend/FwLite/LcmCrdt/HistoryService.cs new file mode 100644 index 000000000..e70e6c412 --- /dev/null +++ b/backend/FwLite/LcmCrdt/HistoryService.cs @@ -0,0 +1,104 @@ +using Humanizer; +using SIL.Harmony; +using SIL.Harmony.Changes; +using SIL.Harmony.Core; +using SIL.Harmony.Db; +using LinqToDB; +using LinqToDB.EntityFrameworkCore; +using SIL.Harmony.Entities; + +namespace LcmCrdt; +public record ProjectActivity( + Guid CommitId, + DateTimeOffset Timestamp, + List> Changes) +{ + public string ChangeName => ChangeNameHelper(Changes); + + private static string ChangeNameHelper(List> changeEntities) + { + return changeEntities switch + { + { Count: 0 } => "No changes", + { Count: 1 } => changeEntities[0].Change switch + { + //todo call JsonPatchChange.Summarize() instead of this + IChange change when change.GetType().Name.StartsWith("JsonPatchChange") => "Change " + + change.EntityType.Name, + IChange change => change.GetType().Name.Humanize() + }, + { Count: var count } => $"{count} changes" + }; + } +} + +public record HistoryLineItem( + Guid CommitId, + Guid EntityId, + DateTimeOffset Timestamp, + Guid? SnapshotId, + string? ChangeName, + IObjectWithId? Entity, + string? EntityName) +{ + public HistoryLineItem( + Guid commitId, + Guid entityId, + DateTimeOffset timestamp, + Guid? snapshotId, + IChange? change, + IObjectBase? entity, + string typeName) : this(commitId, + entityId, + new DateTimeOffset(timestamp.Ticks, + TimeSpan.Zero), //todo this is a workaround for linq2db bug where it reads a date and assumes it's local when it's UTC + snapshotId, + change?.GetType().Name, + (IObjectWithId?) entity?.DbObject, + typeName) + { + } +} + +public class HistoryService(ICrdtDbContext dbContext, DataModel dataModel) +{ + public IAsyncEnumerable ProjectActivity() + { + return dbContext.Commits + .DefaultOrderDescending() + .Take(20) + .Select(c => new ProjectActivity(c.Id, c.HybridDateTime.DateTime, c.ChangeEntities)) + .AsAsyncEnumerable(); + } + + public async Task GetSnapshot(Guid snapshotId) + { + return await dbContext.Snapshots.SingleOrDefaultAsync(s => s.Id == snapshotId); + } + + public async Task GetObject(DateTime timestamp, Guid entityId) + { + //todo requires the timestamp to be exact, otherwise the change made on that timestamp will not be included + //consider using a commitId and looking up the timestamp, but then we should be exact to the commit which we aren't right now. + return await dataModel.GetAtTime(new DateTimeOffset(timestamp), entityId); + } + + public IAsyncEnumerable GetHistory(Guid entityId) + { + var changeEntities = dbContext.Set>(); + var query = from commit in dbContext.Commits.DefaultOrder() + from snapshot in dbContext.Snapshots.LeftJoin( + s => s.CommitId == commit.Id && s.EntityId == entityId) + from change in changeEntities.LeftJoin(c => + c.CommitId == commit.Id && c.EntityId == entityId) + where snapshot.Id != null || change.EntityId != null + select new HistoryLineItem(commit.Id, + entityId, + commit.HybridDateTime.DateTime, + snapshot.Id, + change.Change, + snapshot.Entity, + snapshot.TypeName); + return query.ToLinqToDB().AsAsyncEnumerable(); + } +} diff --git a/backend/FwLite/LcmCrdt/LcmCrdt.csproj b/backend/FwLite/LcmCrdt/LcmCrdt.csproj index c2382ec8a..66bd926ff 100644 --- a/backend/FwLite/LcmCrdt/LcmCrdt.csproj +++ b/backend/FwLite/LcmCrdt/LcmCrdt.csproj @@ -1,9 +1,6 @@  - net8.0 - enable - enable $(ApplicationDisplayVersion) $(ApplicationDisplayVersion) @@ -13,13 +10,14 @@ + - + - - - + + + diff --git a/backend/FwLite/LcmCrdt/LcmCrdtDbContext.cs b/backend/FwLite/LcmCrdt/LcmCrdtDbContext.cs index 525d1d8e6..26e37bf6d 100644 --- a/backend/FwLite/LcmCrdt/LcmCrdtDbContext.cs +++ b/backend/FwLite/LcmCrdt/LcmCrdtDbContext.cs @@ -1,16 +1,25 @@ -using System.Text.Json; +using System.Data.Common; +using System.Text.Json; +using LcmCrdt.Data; +using Microsoft.Data.Sqlite; using SIL.Harmony; using SIL.Harmony.Db; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Microsoft.Extensions.Options; namespace LcmCrdt; -public class LcmCrdtDbContext(DbContextOptions dbContextOptions, IOptions options): DbContext(dbContextOptions), ICrdtDbContext +public class LcmCrdtDbContext(DbContextOptions dbContextOptions, IOptions options, SetupCollationInterceptor setupCollationInterceptor) + : DbContext(dbContextOptions), ICrdtDbContext { public DbSet ProjectData => Set(); - public IQueryable Snapshots => ((ICrdtDbContext)this).Snapshots; + public IQueryable WritingSystems => Set().AsNoTracking(); + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + optionsBuilder.AddInterceptors(setupCollationInterceptor); + } protected override void OnModelCreating(ModelBuilder modelBuilder) { diff --git a/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs b/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs index 9fd13ce3e..151e08652 100644 --- a/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs +++ b/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs @@ -5,6 +5,7 @@ using SIL.Harmony.Changes; using LcmCrdt.Changes; using LcmCrdt.Changes.Entries; +using LcmCrdt.Data; using LcmCrdt.Objects; using LcmCrdt.RemoteSync; using LinqToDB; @@ -16,6 +17,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using MiniLcm.Validators; using Refit; using SIL.Harmony.Db; @@ -27,6 +29,7 @@ public static IServiceCollection AddLcmCrdtClient(this IServiceCollection servic { LinqToDBForEFTools.Initialize(); services.AddMemoryCache(); + services.AddSingleton(); services.AddDbContext(ConfigureDbOptions); services.AddOptions().BindConfiguration("LcmCrdt"); @@ -34,9 +37,11 @@ public static IServiceCollection AddLcmCrdtClient(this IServiceCollection servic ConfigureCrdt ); services.AddScoped(); + services.AddMiniLcmValidators(); services.AddScoped(); + services.AddScoped(); services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(); services.AddHttpClient(); services.AddSingleton(provider => new RefitSettings @@ -134,7 +139,22 @@ public static void ConfigureCrdt(CrdtConfig config) .Add() .Add(builder => { + const string componentSenseId = "ComponentSenseId"; builder.ToTable("ComplexFormComponents"); + builder.Property(c => c.ComponentSenseId).HasColumnName(componentSenseId); + //these indexes are used to ensure that we don't create duplicate complex form components + //we need the filter otherwise 2 components which are the same and have a null sense id can be created because 2 rows with the same null are not considered duplicates + builder.HasIndex(component => new + { + component.ComplexFormEntryId, + component.ComponentEntryId, + component.ComponentSenseId + }).IsUnique().HasFilter($"{componentSenseId} IS NOT NULL"); + builder.HasIndex(component => new + { + component.ComplexFormEntryId, + component.ComponentEntryId + }).IsUnique().HasFilter($"{componentSenseId} IS NULL"); }); config.ChangeTypeListBuilder.Add>() @@ -173,7 +193,7 @@ public static Task OpenCrdtProject(this IServiceProvider services, //this method must not be async, otherwise Setting the project scope will not work as expected. //the project is stored in the async scope, if a new scope is created in this method then it will be gone once the method returns //making the lcm api unusable - var projectsService = services.GetRequiredService(); + var projectsService = services.GetRequiredService(); projectsService.SetProjectScope(project); return LoadMiniLcmApi(services); } diff --git a/backend/FwLite/LocalWebApp/Auth/AuthHelpersFactory.cs b/backend/FwLite/LocalWebApp/Auth/AuthHelpersFactory.cs deleted file mode 100644 index d9fc64aac..000000000 --- a/backend/FwLite/LocalWebApp/Auth/AuthHelpersFactory.cs +++ /dev/null @@ -1,60 +0,0 @@ -using System.Collections.Concurrent; -using LcmCrdt; -using LocalWebApp.Services; -using Microsoft.Extensions.Options; - -namespace LocalWebApp.Auth; - -public class AuthHelpersFactory( - IServiceProvider provider, - ProjectContext projectContext, - IOptions options, - IHttpContextAccessor contextAccessor) -{ - private readonly ConcurrentDictionary _helpers = new(); - - private string AuthorityKey(LexboxServer server) => "AuthHelper|" + server.Authority.Authority; - - /// - /// gets an Auth Helper for the given server - /// - public AuthHelpers GetHelper(LexboxServer server) - { - var helper = _helpers.GetOrAdd(AuthorityKey(server), - static (host, arg) => ActivatorUtilities.CreateInstance(arg.provider, arg.server), - (server, provider)); - //an auth helper can get created based on the server host, however in development that will not be the same as the client host - //so we need to recreate it if the host is not valid - if (!helper.IsHostUrlValid()) - { - _helpers.TryRemove(AuthorityKey(server), out _); - return GetHelper(server); - } - - return helper; - } - - /// - /// get auth helper for a given project - /// - public AuthHelpers GetHelper(ProjectData project) - { - var originDomain = project.OriginDomain; - if (string.IsNullOrEmpty(originDomain)) throw new InvalidOperationException("No origin domain in project data"); - return GetHelper(options.Value.GetServer(project)); - } - - /// - /// get the auth helper for the current project, this method is used when trying to inject an AuthHelper into a service - /// - /// when not in the context of a project (typically requests include the project name in the path) - public AuthHelpers GetCurrentHelper() - { - if (projectContext.Project is null) - throw new InvalidOperationException("No current project, probably not in a request context"); - var currentProjectService = - contextAccessor.HttpContext?.RequestServices.GetRequiredService(); - if (currentProjectService is null) throw new InvalidOperationException("No current project service"); - return GetHelper(currentProjectService.ProjectData); - } -} diff --git a/backend/FwLite/LocalWebApp/Hubs/CrdtMiniLcmApiHub.cs b/backend/FwLite/LocalWebApp/Hubs/CrdtMiniLcmApiHub.cs index dcc92ae36..e9725f397 100644 --- a/backend/FwLite/LocalWebApp/Hubs/CrdtMiniLcmApiHub.cs +++ b/backend/FwLite/LocalWebApp/Hubs/CrdtMiniLcmApiHub.cs @@ -1,6 +1,10 @@ -using LcmCrdt; +using FwLiteShared; +using FwLiteShared.Projects; +using FwLiteShared.Sync; +using LcmCrdt; using LcmCrdt.Data; using LocalWebApp.Services; +using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Caching.Memory; using MiniLcm; using MiniLcm.Models; @@ -15,7 +19,9 @@ public class CrdtMiniLcmApiHub( ChangeEventBus changeEventBus, CurrentProjectService projectContext, LexboxProjectService lexboxProjectService, - IMemoryCache memoryCache) : MiniLcmApiHubBase(miniLcmApi) + IMemoryCache memoryCache, + IHubContext hubContext +) : MiniLcmApiHubBase(miniLcmApi) { public const string ProjectRouteKey = "project"; public static string ProjectGroup(string projectName) => "crdt-" + projectName; @@ -31,12 +37,24 @@ public override async Task OnConnectedAsync() await syncService.ExecuteSync(); Cleanup = [ - changeEventBus.ListenForEntryChanges(projectContext.Project.Name, Context.ConnectionId) + changeEventBus.OnEntryUpdated.Subscribe(e => OnEntryChangedExternal(e, hubContext, memoryCache, Context.ConnectionId)) ]; await lexboxProjectService.ListenForProjectChanges(projectContext.ProjectData, Context.ConnectionAborted); } + private static void OnEntryChangedExternal(Entry entry, + IHubContext hubContext, + IMemoryCache cache, + string connectionId) + { + var currentFilter = CurrentProjectFilter(cache, connectionId); + if (currentFilter.Invoke(entry)) + { + _ = hubContext.Clients.Client(connectionId).OnEntryUpdated(entry); + } + } + public override async Task OnDisconnectedAsync(Exception? exception) { await base.OnDisconnectedAsync(exception); diff --git a/backend/FwLite/LocalWebApp/LocalAppKernel.cs b/backend/FwLite/LocalWebApp/LocalAppKernel.cs index aa27e512d..4b375f243 100644 --- a/backend/FwLite/LocalWebApp/LocalAppKernel.cs +++ b/backend/FwLite/LocalWebApp/LocalAppKernel.cs @@ -2,9 +2,11 @@ using SIL.Harmony; using FwLiteProjectSync; using FwDataMiniLcmBridge; +using FwLiteShared; +using FwLiteShared.Auth; +using FwLiteShared.Sync; using LcmCrdt; using LocalWebApp.Services; -using LocalWebApp.Auth; using Microsoft.AspNetCore.Http.Json; using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Options; @@ -17,18 +19,9 @@ public static class LocalAppKernel public static IServiceCollection AddLocalAppServices(this IServiceCollection services, IHostEnvironment environment) { services.AddHttpContextAccessor(); - services.AddHttpClient(); - services.AddAuthHelpers(environment); services.AddSingleton(); - services.AddScoped(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(s => s.GetRequiredService()); - services.AddLcmCrdtClient(); - services.AddFwLiteProjectSync(); - services.AddFwDataBridge(); + services.AddSingleton(); + services.AddFwLiteShared(environment); services.AddOptions().BindConfiguration("LocalWebApp"); @@ -44,28 +37,4 @@ public static IServiceCollection AddLocalAppServices(this IServiceCollection ser }); return services; } - - private static void AddAuthHelpers(this IServiceCollection services, IHostEnvironment environment) - { - services.AddSingleton(); - services.AddTransient(sp => sp.GetRequiredService().GetCurrentHelper()); - services.AddSingleton(); - services.AddSingleton(sp => sp.GetRequiredService()); - services.AddOptionsWithValidateOnStart().BindConfiguration("Auth").ValidateDataAnnotations(); - services.AddSingleton(); - var httpClientBuilder = services.AddHttpClient(AuthHelpers.AuthHttpClientName); - if (environment.IsDevelopment()) - { - // Allow self-signed certificates in development - httpClientBuilder.ConfigurePrimaryHttpMessageHandler(() => - { - return new HttpClientHandler - { - ClientCertificateOptions = ClientCertificateOption.Manual, - ServerCertificateCustomValidationCallback = (message, certificate2, arg3, arg4) => true - }; - }); - } - - } } diff --git a/backend/FwLite/LocalWebApp/LocalWebApp.csproj b/backend/FwLite/LocalWebApp/LocalWebApp.csproj index 6d03a3e54..6b480b782 100644 --- a/backend/FwLite/LocalWebApp/LocalWebApp.csproj +++ b/backend/FwLite/LocalWebApp/LocalWebApp.csproj @@ -1,9 +1,6 @@ - net8.0 - enable - enable Linux true false @@ -20,10 +17,8 @@ - - - - + + @@ -43,6 +38,7 @@ + diff --git a/backend/FwLite/LocalWebApp/LocalWebAppServer.cs b/backend/FwLite/LocalWebApp/LocalWebAppServer.cs index b05a93b34..91f5ba5da 100644 --- a/backend/FwLite/LocalWebApp/LocalWebAppServer.cs +++ b/backend/FwLite/LocalWebApp/LocalWebAppServer.cs @@ -1,9 +1,9 @@ using FwDataMiniLcmBridge; using FwDataMiniLcmBridge.LcmUtils; +using FwLiteShared.Auth; using LcmCrdt; using LocalWebApp; using LocalWebApp.Hubs; -using LocalWebApp.Auth; using LocalWebApp.Routes; using LocalWebApp.Utils; using Microsoft.AspNetCore.SignalR; @@ -89,7 +89,7 @@ public static WebApplication SetupAppServer(WebApplicationOptions options, Actio var projectName = context.GetProjectName(); if (!string.IsNullOrWhiteSpace(projectName)) { - var projectsService = context.RequestServices.GetRequiredService(); + var projectsService = context.RequestServices.GetRequiredService(); projectsService.SetProjectScope(projectsService.GetProject(projectName) ?? throw new InvalidOperationException( $"Project {projectName} not found")); diff --git a/backend/FwLite/LocalWebApp/Routes/ActivityRoutes.cs b/backend/FwLite/LocalWebApp/Routes/ActivityRoutes.cs index f14f8cb1b..4d00e619e 100644 --- a/backend/FwLite/LocalWebApp/Routes/ActivityRoutes.cs +++ b/backend/FwLite/LocalWebApp/Routes/ActivityRoutes.cs @@ -25,38 +25,7 @@ public static IEndpointConventionBuilder MapActivities(this WebApplication app) }); return operation; }); - group.MapGet("/", - (ICrdtDbContext dbcontext) => - { - return dbcontext.Commits - .DefaultOrderDescending() - .Take(20) - .Select(c => new Activity(c.Id, c.HybridDateTime.DateTime, c.ChangeEntities)) - .AsAsyncEnumerable(); - }); + group.MapGet("/", (HistoryService historyService) => historyService.ProjectActivity()); return group; } - - private static string ChangeName(List> changeEntities) - { - return changeEntities switch - { - { Count: 0 } => "No changes", - { Count: 1 } => changeEntities[0].Change switch - { - //todo call JsonPatchChange.Summarize() instead of this - IChange change when change.GetType().Name.StartsWith("JsonPatchChange") => "Change " + change.EntityType.Name, - IChange change => change.GetType().Name.Humanize() - }, - { Count: var count } => $"{count} changes" - }; - } - - public record Activity( - Guid CommitId, - DateTimeOffset Timestamp, - List> Changes) - { - public string ChangeName => ChangeName(Changes); - } } diff --git a/backend/FwLite/LocalWebApp/Routes/AuthRoutes.cs b/backend/FwLite/LocalWebApp/Routes/AuthRoutes.cs index 21f521741..a270c3adc 100644 --- a/backend/FwLite/LocalWebApp/Routes/AuthRoutes.cs +++ b/backend/FwLite/LocalWebApp/Routes/AuthRoutes.cs @@ -1,6 +1,6 @@ using System.Security.AccessControl; using System.Web; -using LocalWebApp.Auth; +using FwLiteShared.Auth; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; @@ -13,28 +13,21 @@ public record ServerStatus(string DisplayName, bool LoggedIn, string? LoggedInAs public static IEndpointConventionBuilder MapAuthRoutes(this WebApplication app) { var group = app.MapGroup("/api/auth").WithOpenApi(); - group.MapGet("/servers", (IOptions options, AuthHelpersFactory factory) => - { - return options.Value.LexboxServers.ToAsyncEnumerable().SelectAwait(async s => - { - var currentName = await factory.GetHelper(s).GetCurrentName(); - return new ServerStatus(s.DisplayName, - !string.IsNullOrEmpty(currentName), - currentName, s.Authority.Authority); - }); - }); + group.MapGet("/servers", (AuthService authService) => authService.Servers()); group.MapGet("/login/{authority}", - async (AuthHelpersFactory factory, string authority, IOptions options, [FromHeader] string referer) => + async (AuthService authService, string authority, IOptions options, [FromHeader] string referer) => { var returnUrl = new Uri(referer).PathAndQuery; - var result = await factory.GetHelper(options.Value.GetServerByAuthority(authority)).SignIn(returnUrl); - if (result.HandledBySystemWebView) + //todo blazor, once we're using blazor this endpoint will only be used for non webview logins + if (options.Value.SystemWebViewLogin) { + await authService.SignInWebView(options.Value.GetServerByAuthority(authority)); return Results.Redirect(returnUrl); } - - if (result.AuthUri is null) throw new InvalidOperationException("AuthUri is null"); - return Results.Redirect(result.AuthUri.ToString()); + else + { + return Results.Redirect(await authService.SignInWebApp(options.Value.GetServerByAuthority(authority), returnUrl)); + } }); group.MapGet("/oauth-callback", async (OAuthService oAuthService, HttpContext context) => @@ -49,14 +42,14 @@ public static IEndpointConventionBuilder MapAuthRoutes(this WebApplication app) return Results.Redirect(returnUrl); }).WithName(CallbackRoute); group.MapGet("/me/{authority}", - async (AuthHelpersFactory factory, string authority, IOptions options) => + async (AuthService authService, string authority, IOptions options) => { - return new { name = await factory.GetHelper(options.Value.GetServerByAuthority(authority)).GetCurrentName() }; + return new { name = await authService.GetLoggedInName(options.Value.GetServerByAuthority(authority)) }; }); group.MapGet("/logout/{authority}", - async (AuthHelpersFactory factory, string authority, IOptions options) => + async (AuthService authService, string authority, IOptions options) => { - await factory.GetHelper(options.Value.GetServerByAuthority(authority)).Logout(); + await authService.Logout(options.Value.GetServerByAuthority(authority)); return Results.Redirect("/"); }); return group; diff --git a/backend/FwLite/LocalWebApp/Routes/HistoryRoutes.cs b/backend/FwLite/LocalWebApp/Routes/HistoryRoutes.cs index 4ea387061..f77ea2d9e 100644 --- a/backend/FwLite/LocalWebApp/Routes/HistoryRoutes.cs +++ b/backend/FwLite/LocalWebApp/Routes/HistoryRoutes.cs @@ -1,4 +1,5 @@ -using SIL.Harmony; +using LcmCrdt; +using SIL.Harmony; using SIL.Harmony.Changes; using SIL.Harmony.Core; using SIL.Harmony.Db; @@ -7,6 +8,7 @@ using LinqToDB.EntityFrameworkCore; using LocalWebApp.Hubs; using Microsoft.OpenApi.Models; +using MiniLcm.Models; namespace LocalWebApp.Routes; @@ -23,63 +25,12 @@ public static IEndpointConventionBuilder MapHistoryRoutes(this WebApplication ap return operation; }); group.MapGet("/snapshot/{snapshotId:guid}", - async (Guid snapshotId, ICrdtDbContext dbcontext) => - { - return await dbcontext.Snapshots.Where(s => s.Id == snapshotId).SingleOrDefaultAsync(); - }); + async (Guid snapshotId, HistoryService historyService) => await historyService.GetSnapshot(snapshotId)); group.MapGet("/snapshot/at/{timestamp}", - async (DateTime timestamp, Guid entityId, DataModel dataModel) => - { - //todo requires the timestamp to be exact, otherwise the change made on that timestamp will not be included - //consider using a commitId and looking up the timestamp, but then we should be exact to the commit which we aren't right now. - return await dataModel.GetAtTime(new DateTimeOffset(timestamp), entityId); - }); + async (DateTime timestamp, Guid entityId, HistoryService historyService) => + await historyService.GetObject(timestamp, entityId)); group.MapGet("/{entityId}", - (Guid entityId, ICrdtDbContext dbcontext) => - { - var query = from commit in dbcontext.Commits.DefaultOrder() - from snapshot in dbcontext.Snapshots.LeftJoin( - s => s.CommitId == commit.Id && s.EntityId == entityId) - from change in dbcontext.Set>().LeftJoin(c => - c.CommitId == commit.Id && c.EntityId == entityId) - where snapshot.Id != null || change.EntityId != null - select new HistoryLineItem(commit.Id, - entityId, - commit.HybridDateTime.DateTime, - snapshot.Id, - change.Change, - snapshot.Entity, - snapshot.TypeName); - return query.ToLinqToDB().AsAsyncEnumerable(); - }); + (Guid entityId, HistoryService historyService) => historyService.GetHistory(entityId)); return group; } - - public record HistoryLineItem( - Guid CommitId, - Guid EntityId, - DateTimeOffset Timestamp, - Guid? SnapshotId, - string? ChangeName, - IObjectBase? Entity, - string? EntityName) - { - public HistoryLineItem( - Guid commitId, - Guid entityId, - DateTimeOffset timestamp, - Guid? snapshotId, - IChange? change, - IObjectBase? entity, - string typeName) : this(commitId, - entityId, - new DateTimeOffset(timestamp.Ticks, - TimeSpan.Zero), //todo this is a workaround for linq2db bug where it reads a date and assumes it's local when it's UTC - snapshotId, - change?.GetType().Name, - entity, - typeName) - { - } - } } diff --git a/backend/FwLite/LocalWebApp/Routes/ImportRoutes.cs b/backend/FwLite/LocalWebApp/Routes/ImportRoutes.cs index 426044b4a..cc057a783 100644 --- a/backend/FwLite/LocalWebApp/Routes/ImportRoutes.cs +++ b/backend/FwLite/LocalWebApp/Routes/ImportRoutes.cs @@ -1,4 +1,5 @@ - using SIL.Harmony.Db; + using FwLiteShared.Projects; + using SIL.Harmony.Db; using LocalWebApp.Services; using Microsoft.OpenApi.Models; using MiniLcm; diff --git a/backend/FwLite/LocalWebApp/Routes/ProjectRoutes.cs b/backend/FwLite/LocalWebApp/Routes/ProjectRoutes.cs index 62316ba1b..dc29e4463 100644 --- a/backend/FwLite/LocalWebApp/Routes/ProjectRoutes.cs +++ b/backend/FwLite/LocalWebApp/Routes/ProjectRoutes.cs @@ -1,7 +1,10 @@ using System.Text.RegularExpressions; using FwDataMiniLcmBridge; +using FwLiteShared; +using FwLiteShared.Auth; +using FwLiteShared.Projects; +using FwLiteShared.Sync; using LcmCrdt; -using LocalWebApp.Auth; using LocalWebApp.Hubs; using LocalWebApp.Services; using Microsoft.AspNetCore.Mvc; @@ -11,174 +14,54 @@ namespace LocalWebApp.Routes; -public static partial class ProjectRoutes +public static class ProjectRoutes { public static IEndpointConventionBuilder MapProjectRoutes(this WebApplication app) { var group = app.MapGroup("/api").WithOpenApi(); group.MapGet("/remoteProjects", - async ( - LexboxProjectService lexboxProjectService, - IOptions options) => + async (CombinedProjectsService combinedProjectsService) => { - var serversProjects = new Dictionary(); - foreach (var server in options.Value.LexboxServers) - { - var lexboxProjects = await lexboxProjectService.GetLexboxProjects(server); - serversProjects.Add(server.Authority.Authority, lexboxProjects.Select(p => new ProjectModel - (p.Name, Crdt: p.IsCrdtProject, Fwdata: false, Lexbox: true, server.Authority.Authority, p.Id)) - .ToArray()); - } - - return serversProjects; + return (await combinedProjectsService.RemoteProjects()).ToDictionary(p => p.Server.Authority.Authority, p => p.Projects); }); group.MapGet("/localProjects", - async ( - ProjectsService projectService, - FieldWorksProjectList fieldWorksProjectList) => - { - var crdtProjects = await projectService.ListProjects(); - //todo get project Id and use that to specify the Id in the model. Also pull out server - var projects = crdtProjects.ToDictionary(p => p.Name, p => - { - var uri = p.Data?.OriginDomain is not null ? new Uri(p.Data.OriginDomain) : null; - return new ProjectModel(p.Name, - true, - false, - p.Data?.OriginDomain is not null, - uri?.Authority, - p.Data?.Id); - }); - //basically populate projects and indicate if they are lexbox or fwdata - foreach (var p in fieldWorksProjectList.EnumerateProjects()) - { - if (projects.TryGetValue(p.Name, out var project)) - { - projects[p.Name] = project with { Fwdata = true }; - } - else - { - projects.Add(p.Name, new ProjectModel(p.Name, false, true)); - } - } - return projects.Values; - }); + async (CombinedProjectsService combinedProjectsService) => await combinedProjectsService.LocalProjects()); group.MapPost("/project", - async (ProjectsService projectService, string name) => + async (CrdtProjectsService projectService, string name) => { if (string.IsNullOrWhiteSpace(name)) return Results.BadRequest("Project name is required"); if (projectService.ProjectExists(name)) return Results.BadRequest("Project already exists"); - if (!ProjectName().IsMatch(name)) + if (!CrdtProjectsService.ProjectName().IsMatch(name)) return Results.BadRequest("Only letters, numbers, '-' and '_' are allowed"); - await projectService.CreateProject(new(name, AfterCreate: AfterCreate, SeedNewProjectData: true)); + await projectService.CreateExampleProject(name); return TypedResults.Ok(); }); group.MapPost($"/upload/crdt/{{serverAuthority}}/{{{CrdtMiniLcmApiHub.ProjectRouteKey}}}", - async (LexboxProjectService lexboxProjectService, - SyncService syncService, + async (SyncService syncService, IOptions options, - CurrentProjectService currentProjectService, string serverAuthority, [FromQuery] Guid lexboxProjectId) => { var server = options.Value.GetServerByAuthority(serverAuthority); - await currentProjectService.SetProjectSyncOrigin(server.Authority, lexboxProjectId); - try - { - await syncService.ExecuteSync(); - } - catch - { - await currentProjectService.SetProjectSyncOrigin(null, null); - throw; - } - lexboxProjectService.InvalidateProjectsCache(server); + await syncService.UploadProject(lexboxProjectId, server); return TypedResults.Ok(); }); group.MapPost("/download/crdt/{serverAuthority}/{projectId}", - async (LexboxProjectService lexboxProjectService, - IOptions options, - ProjectsService projectService, + async (IOptions options, + CombinedProjectsService combinedProjectsService, Guid projectId, [FromQuery] string projectName, string serverAuthority ) => { - if (!ProjectName().IsMatch(projectName)) + if (!CrdtProjectsService.ProjectName().IsMatch(projectName)) return Results.BadRequest("Project name is invalid"); var server = options.Value.GetServerByAuthority(serverAuthority); - await projectService.CreateProject(new(projectName, - projectId, - server.Authority, - async (provider, project) => - { - await provider.GetRequiredService().ExecuteSync(); - }, - SeedNewProjectData: false)); + await combinedProjectsService.DownloadProject(projectId, projectName, server); return TypedResults.Ok(); }); return group; } - - public record ProjectModel(string Name, bool Crdt, bool Fwdata, bool Lexbox = false, string? ServerAuthority = null, Guid? Id = null); - - private static async Task AfterCreate(IServiceProvider provider, CrdtProject project) - { - var lexboxApi = provider.GetRequiredService(); - await lexboxApi.CreateEntry(new() - { - Id = Guid.NewGuid(), - LexemeForm = { Values = { { "en", "Apple" } } }, - CitationForm = { Values = { { "en", "Apple" } } }, - LiteralMeaning = { Values = { { "en", "Fruit" } } }, - Senses = - [ - new() - { - Gloss = { Values = { { "en", "Fruit" } } }, - Definition = - { - Values = - { - { - "en", - "fruit with red, yellow, or green skin with a sweet or tart crispy white flesh" - } - } - }, - SemanticDomains = [], - ExampleSentences = [new() { Sentence = { Values = { { "en", "We ate an apple" } } } }] - } - ] - }); - - await lexboxApi.CreateWritingSystem(WritingSystemType.Vernacular, - new() - { - Id = Guid.NewGuid(), - Type = WritingSystemType.Vernacular, - WsId = "en", - Name = "English", - Abbreviation = "en", - Font = "Arial", - Exemplars = WritingSystem.LatinExemplars - }); - - await lexboxApi.CreateWritingSystem(WritingSystemType.Analysis, - new() - { - Id = Guid.NewGuid(), - Type = WritingSystemType.Analysis, - WsId = "en", - Name = "English", - Abbreviation = "en", - Font = "Arial", - Exemplars = WritingSystem.LatinExemplars - }); - } - - [GeneratedRegex("^[a-zA-Z0-9][a-zA-Z0-9-_]+$")] - private static partial Regex ProjectName(); } diff --git a/backend/FwLite/LocalWebApp/Routes/TestRoutes.cs b/backend/FwLite/LocalWebApp/Routes/TestRoutes.cs index 91cf0ef3d..965442f43 100644 --- a/backend/FwLite/LocalWebApp/Routes/TestRoutes.cs +++ b/backend/FwLite/LocalWebApp/Routes/TestRoutes.cs @@ -1,4 +1,5 @@ -using LocalWebApp.Hubs; +using FwLiteShared; +using LocalWebApp.Hubs; using LocalWebApp.Services; using Microsoft.OpenApi.Models; using MiniLcm; diff --git a/backend/FwLite/LocalWebApp/Services/ServerRedirectUrlProvider.cs b/backend/FwLite/LocalWebApp/Services/ServerRedirectUrlProvider.cs new file mode 100644 index 000000000..531c84c59 --- /dev/null +++ b/backend/FwLite/LocalWebApp/Services/ServerRedirectUrlProvider.cs @@ -0,0 +1,29 @@ +using FwLiteShared.Auth; +using LocalWebApp.Routes; + +namespace LocalWebApp.Services; + +public class ServerRedirectUrlProvider(LinkGenerator linkGenerator, UrlContext urlContext): IRedirectUrlProvider +{ + public string? GetRedirectUrl() + { + var (hostUrl, _) = urlContext.GetUrl(); + return RedirectUrlFromHost(hostUrl); + } + + private string? RedirectUrlFromHost(Uri hostUrl) + { + var redirectHost = HostString.FromUriComponent(hostUrl); + return linkGenerator.GetUriByRouteValues(AuthRoutes.CallbackRoute, + new RouteValueDictionary(), + hostUrl.Scheme, + redirectHost); + } + + public bool ShouldRecreateAuthHelper(string? redirectUrl) + { + var (hostUrl, guess) = urlContext.GetUrl(); + if (guess) return false; + return RedirectUrlFromHost(hostUrl) != redirectUrl; + } +} diff --git a/backend/FwLite/LocalWebApp/UrlContext.cs b/backend/FwLite/LocalWebApp/UrlContext.cs index ec261a555..bafefe88f 100644 --- a/backend/FwLite/LocalWebApp/UrlContext.cs +++ b/backend/FwLite/LocalWebApp/UrlContext.cs @@ -18,7 +18,7 @@ public class UrlContext(IServer server, IHttpContextAccessor contextAccessor) var uriBuilder = new UriBuilder(httpContext.Request.Scheme, httpContext.Request.Host.Host.Replace("127.0.0.1", "localhost"), httpContext.Request.Host.Port ?? 80); return (uriBuilder.Uri, false); } - var address = server.Features.Get()?.Addresses.FirstOrDefault() ?? throw new InvalidOperationException("No server address"); + var address = server.Features.Get()?.Addresses.FirstOrDefault(a => a.StartsWith("http:")) ?? throw new InvalidOperationException("No server address"); if (address.StartsWith("http://127.0.0.1")) address = address.Replace("http://127.0.0.1", "http://localhost"); if (address.StartsWith("https://127.0.0.1")) address = address.Replace("https://127.0.0.1", "http://localhost"); return (new Uri(address), true); diff --git a/backend/FwLite/MiniLcm.Tests/BasicApiTestsBase.cs b/backend/FwLite/MiniLcm.Tests/BasicApiTestsBase.cs index da63a7f4d..9b1f05144 100644 --- a/backend/FwLite/MiniLcm.Tests/BasicApiTestsBase.cs +++ b/backend/FwLite/MiniLcm.Tests/BasicApiTestsBase.cs @@ -202,7 +202,7 @@ public async Task GetEntry() { var entry = await Api.GetEntry(Entry1Id); entry.Should().NotBeNull(); - entry!.LexemeForm.Values.Should().NotBeEmpty(); + entry.LexemeForm.Values.Should().NotBeEmpty(); var sense = entry.Senses.Should() .NotBeEmpty($"because '{entry.LexemeForm.Values.First().Value}' should have a sense").And.Subject.First(); sense.Gloss.Values.Should().NotBeEmpty(); diff --git a/backend/FwLite/MiniLcm.Tests/ComplexFormComponentTestsBase.cs b/backend/FwLite/MiniLcm.Tests/ComplexFormComponentTestsBase.cs index 4f51ca275..d6f646435 100644 --- a/backend/FwLite/MiniLcm.Tests/ComplexFormComponentTestsBase.cs +++ b/backend/FwLite/MiniLcm.Tests/ComplexFormComponentTestsBase.cs @@ -50,6 +50,22 @@ public async Task CreateComplexFormComponent_Works() component.ComponentHeadword.Should().Be("component"); } + [Fact] + public async Task CreateComplexFormComponent_UsingTheSameComponentWithNullSenseDoesNothing() + { + var component1 = await Api.CreateComplexFormComponent(ComplexFormComponent.FromEntries(_complexFormEntry, _componentEntry)); + var component2 = await Api.CreateComplexFormComponent(ComplexFormComponent.FromEntries(_complexFormEntry, _componentEntry)); + component2.Should().BeEquivalentTo(component1); + } + + [Fact] + public async Task CreateComplexFormComponent_UsingTheSameComponentWithSenseDoesNothing() + { + var component1 = await Api.CreateComplexFormComponent(ComplexFormComponent.FromEntries(_complexFormEntry, _componentEntry, _componentSenseId1)); + var component2 = await Api.CreateComplexFormComponent(ComplexFormComponent.FromEntries(_complexFormEntry, _componentEntry, _componentSenseId1)); + component2.Should().BeEquivalentTo(component1); + } + [Fact] public async Task CreateComplexFormComponent_WorksWithSense() { @@ -83,4 +99,43 @@ public async Task CreateComplexFormType_Works() var types = await Api.GetComplexFormTypes().ToArrayAsync(); types.Should().ContainSingle(t => t.Id == complexFormType.Id); } + + [Fact] + public async Task AddComplexFormType_Works() + { + var complexFormType = new ComplexFormType() { Id = Guid.NewGuid(), Name = new() { { "en", "test" } } }; + await Api.CreateComplexFormType(complexFormType); + await Api.AddComplexFormType(_complexFormEntryId, complexFormType.Id); + var entry = await Api.GetEntry(_complexFormEntryId); + entry.Should().NotBeNull(); + entry!.ComplexFormTypes.Should().ContainSingle(c => c.Id == complexFormType.Id); + } + + [Fact] + public async Task RemoveComplexFormType_Works() + { + await AddComplexFormType_Works(); + var entry = await Api.GetEntry(_complexFormEntryId); + await Api.RemoveComplexFormType(_complexFormEntryId, entry!.ComplexFormTypes[0].Id); + entry = await Api.GetEntry(_complexFormEntryId); + entry.Should().NotBeNull(); + entry!.ComplexFormTypes.Should().BeEmpty(); + } + + [Fact] + public async Task RemoveComplexFormType_WorksWhenTypeDoesNotExist() + { + await Api.CreateComplexFormComponent(ComplexFormComponent.FromEntries(_complexFormEntry, _componentEntry)); + await Api.RemoveComplexFormType(_complexFormEntryId, Guid.NewGuid()); + } + + [Fact] + public async Task RemoveComplexFormType_WorksWhenTypeIsNotOnEntry() + { + //FW projects react differently if an entry has complex forms or not + await Api.CreateComplexFormComponent(ComplexFormComponent.FromEntries(_complexFormEntry, _componentEntry)); + var complexFormType = new ComplexFormType() { Id = Guid.NewGuid(), Name = new() { { "en", "test" } } }; + await Api.CreateComplexFormType(complexFormType); + await Api.RemoveComplexFormType(_complexFormEntryId, Guid.NewGuid()); + } } diff --git a/backend/FwLite/MiniLcm.Tests/CreateEntryTestsBase.cs b/backend/FwLite/MiniLcm.Tests/CreateEntryTestsBase.cs index 477362c03..37fef2f21 100644 --- a/backend/FwLite/MiniLcm.Tests/CreateEntryTestsBase.cs +++ b/backend/FwLite/MiniLcm.Tests/CreateEntryTestsBase.cs @@ -9,7 +9,7 @@ public async Task CanCreateEntry() { var entry = await Api.CreateEntry(new() { LexemeForm = { { "en", "test" } } }); entry.Should().NotBeNull(); - entry!.LexemeForm.Values.Should().ContainKey("en"); + entry.LexemeForm.Values.Should().ContainKey("en"); entry.LexemeForm.Values["en"].Should().Be("test"); } @@ -53,7 +53,7 @@ public async Task CanCreate_WithComponentsProperty() }); entry = await Api.GetEntry(entry.Id); entry.Should().NotBeNull(); - entry!.Components.Should().ContainSingle(c => c.ComponentEntryId == component.Id); + entry.Components.Should().ContainSingle(c => c.ComponentEntryId == component.Id); } [Fact] @@ -78,7 +78,7 @@ public async Task CanCreate_WithComplexFormsProperty() }); entry = await Api.GetEntry(entry.Id); entry.Should().NotBeNull(); - entry!.ComplexForms.Should().ContainSingle(c => c.ComplexFormEntryId == complexForm.Id); + entry.ComplexForms.Should().ContainSingle(c => c.ComplexFormEntryId == complexForm.Id); } [Fact] @@ -110,11 +110,11 @@ await Api.CreateEntry(new() var entry = await Api.GetEntry(component.Id); entry.Should().NotBeNull(); - entry!.ComplexForms.Should().ContainSingle().Which.ComponentSenseId.Should().Be(componentSenseId); + entry.ComplexForms.Should().ContainSingle().Which.ComponentSenseId.Should().Be(componentSenseId); entry = await Api.GetEntry(complexFormEntryId); entry.Should().NotBeNull(); - entry!.Components.Should().ContainSingle(c => + entry.Components.Should().ContainSingle(c => c.ComplexFormEntryId == complexFormEntryId && c.ComponentEntryId == component.Id && c.ComponentSenseId == componentSenseId); } @@ -133,6 +133,6 @@ public async Task CanCreate_WithComplexFormTypesProperty() }); entry = await Api.GetEntry(entry.Id); entry.Should().NotBeNull(); - entry!.ComplexFormTypes.Should().ContainSingle(c => c.Id == complexFormType.Id); + entry.ComplexFormTypes.Should().ContainSingle(c => c.Id == complexFormType.Id); } } diff --git a/backend/FwLite/MiniLcm.Tests/MiniLcm.Tests.csproj b/backend/FwLite/MiniLcm.Tests/MiniLcm.Tests.csproj index 83030857f..1e5de3a28 100644 --- a/backend/FwLite/MiniLcm.Tests/MiniLcm.Tests.csproj +++ b/backend/FwLite/MiniLcm.Tests/MiniLcm.Tests.csproj @@ -1,10 +1,6 @@ - net8.0 - enable - enable - false true @@ -12,14 +8,17 @@ - - - + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + all runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/backend/FwLite/MiniLcm.Tests/Models/EntryTests.cs b/backend/FwLite/MiniLcm.Tests/Models/EntryTests.cs new file mode 100644 index 000000000..8b680e716 --- /dev/null +++ b/backend/FwLite/MiniLcm.Tests/Models/EntryTests.cs @@ -0,0 +1,18 @@ +namespace MiniLcm.Tests.Models; + +public class EntryTests +{ + [Fact] + public void Headword_SameResultForDifferentOrderedMultiStrings() + { + var entry = new Entry() + { + LexemeForm = new MultiString() { Values = { { "en", "test" }, { "fr", "test2" } } } + }; + var entry2 = new Entry() + { + LexemeForm = new MultiString() { Values = { { "fr", "test2" }, { "en", "test" } } } + }; + entry.Headword().Should().Be(entry2.Headword()); + } +} diff --git a/backend/FwLite/MiniLcm.Tests/SemanticDomainTestsBase.cs b/backend/FwLite/MiniLcm.Tests/SemanticDomainTestsBase.cs index 6704a50f0..38b6a5f63 100644 --- a/backend/FwLite/MiniLcm.Tests/SemanticDomainTestsBase.cs +++ b/backend/FwLite/MiniLcm.Tests/SemanticDomainTestsBase.cs @@ -31,11 +31,11 @@ await Api.CreateEntry(new Entry() }); } - private Task GetEntry() + private async Task GetEntry() { - var entry = Api.GetEntry(_entryId); + var entry = await Api.GetEntry(_entryId); entry.Should().NotBeNull(); - return entry!; + return entry; } [Fact] @@ -55,7 +55,7 @@ public async Task Sense_HasSemanticDomains() { var entry = await GetEntry(); entry.Should().NotBeNull(); - var sense = entry!.Senses.First(s => s.SemanticDomains.Any()); + var sense = entry.Senses.First(s => s.SemanticDomains.Any()); sense.SemanticDomains.Should().NotBeEmpty(); sense.SemanticDomains.Should().AllSatisfy(sd => { diff --git a/backend/FwLite/MiniLcm.Tests/SortingTestsBase.cs b/backend/FwLite/MiniLcm.Tests/SortingTestsBase.cs new file mode 100644 index 000000000..be58f4bee --- /dev/null +++ b/backend/FwLite/MiniLcm.Tests/SortingTestsBase.cs @@ -0,0 +1,62 @@ +namespace MiniLcm.Tests; + +public abstract class SortingTestsBase : MiniLcmTestBase +{ + public override async Task InitializeAsync() + { + await base.InitializeAsync(); + await Api.CreateWritingSystem(WritingSystemType.Analysis, + new WritingSystem() + { + Id = Guid.NewGuid(), + Type = WritingSystemType.Analysis, + WsId = "en", + Name = "English", + Abbreviation = "En", + Font = "Arial", + Exemplars = [] + }); + await Api.CreateWritingSystem(WritingSystemType.Vernacular, + new WritingSystem() + { + Id = Guid.NewGuid(), + Type = WritingSystemType.Vernacular, + WsId = "en-US", + Name = "English", + Abbreviation = "En", + Font = "Arial", + Exemplars = [] + }); + } + + private Task CreateEntry(string headword) + { + return Api.CreateEntry(new() { LexemeForm = { { "en", headword } }, }); + } + + + // ReSharper disable InconsistentNaming + const string Ru_A= "\u0410"; + const string Ru_a = "\u0430"; + const string Ru_Б= "\u0411"; + const string Ru_б = "\u0431"; + const string Ru_В= "\u0412"; + const string Ru_в = "\u0432"; + // ReSharper restore InconsistentNaming + + [Theory] + [InlineData("aa,ab,ac")] + [InlineData("aa,Ab,ac")] + [InlineData($"{Ru_a}{Ru_a},{Ru_a}{Ru_б},{Ru_a}{Ru_в}")] + [InlineData($"{Ru_a}{Ru_a},{Ru_A}{Ru_б},{Ru_a}{Ru_в}")] + public async Task EntriesAreSorted(string headwords) + { + var headwordList = headwords.Split(','); + foreach (var headword in headwordList.OrderBy(h => Random.Shared.Next())) + { + await CreateEntry(headword); + } + var entries = await Api.GetEntries().Select(e => e.Headword()).ToArrayAsync(); + entries.Should().Equal(headwordList); + } +} diff --git a/backend/FwLite/MiniLcm.Tests/Validators/ComplexFormTypeValidationTests.cs b/backend/FwLite/MiniLcm.Tests/Validators/ComplexFormTypeValidationTests.cs new file mode 100644 index 000000000..8b6005add --- /dev/null +++ b/backend/FwLite/MiniLcm.Tests/Validators/ComplexFormTypeValidationTests.cs @@ -0,0 +1,43 @@ +using FluentValidation.TestHelper; +using MiniLcm.Validators; + +namespace MiniLcm.Tests.Validators; + +public class ComplexFormTypeValidationTests +{ + private readonly ComplexFormTypeValidator _validator = new(); + + [Fact] + public void FailsForEmptyName() + { + var complexFormType = new ComplexFormType() { Name = new MultiString() }; + _validator.TestValidate(complexFormType).ShouldHaveValidationErrorFor(c => c.Name); + } + [Fact] + public void FailsForNameWithEmptyStringValue() + { + var complexFormType = new ComplexFormType() { Name = new(){ { "en", string.Empty } } }; + _validator.TestValidate(complexFormType).ShouldHaveValidationErrorFor(c => c.Name); + } + + [Fact] + public void FailsForNonNullDeletedAt() + { + var complexFormType = new ComplexFormType() + { + Name = new() { { "en", "test" } }, DeletedAt = DateTimeOffset.UtcNow + }; + _validator.TestValidate(complexFormType).ShouldHaveValidationErrorFor(c => c.DeletedAt); + } + + [Fact] + public void Succeeds() + { + var complexFormType = new ComplexFormType() + { + Name = new() { { "en", "test" } }, + DeletedAt = null + }; + _validator.TestValidate(complexFormType).ShouldNotHaveAnyValidationErrors(); + } +} diff --git a/backend/FwLite/MiniLcm.Tests/Validators/EntryValidatorTests.cs b/backend/FwLite/MiniLcm.Tests/Validators/EntryValidatorTests.cs new file mode 100644 index 000000000..3a92fc6b9 --- /dev/null +++ b/backend/FwLite/MiniLcm.Tests/Validators/EntryValidatorTests.cs @@ -0,0 +1,34 @@ +using FluentValidation.TestHelper; +using MiniLcm.Validators; + +namespace MiniLcm.Tests.Validators; + +public class EntryValidatorTests +{ + private readonly EntryValidator _validator = new(); + + [Fact] + public void Succeeds_WhenSenseEntryIdIsGuidEmpty() + { + var entryId = Guid.NewGuid(); + var entry = new Entry() { Id = entryId, Senses = [new Sense() { EntryId = Guid.Empty, }] }; + _validator.TestValidate(entry).ShouldNotHaveAnyValidationErrors(); + } + + [Fact] + public void Succeeds_WhenSenseEntryIdMatchesEntry() + { + + var entryId = Guid.NewGuid(); + var entry = new Entry() { Id = entryId, Senses = [new Sense() { EntryId = entryId, }] }; + _validator.TestValidate(entry).ShouldNotHaveAnyValidationErrors(); + } + + [Fact] + public void Fails_WhenSenseEntryIdDoesNotMatchEntry() + { + var entryId = Guid.NewGuid(); + var entry = new Entry() { Id = entryId, Senses = [new Sense() { EntryId = Guid.NewGuid(), }] }; + _validator.TestValidate(entry).ShouldHaveValidationErrorFor("Senses[0].EntryId"); + } +} diff --git a/backend/FwLite/MiniLcm.Tests/WritingSystemIdTests.cs b/backend/FwLite/MiniLcm.Tests/WritingSystemIdTests.cs index 6448f77b0..14290cc07 100644 --- a/backend/FwLite/MiniLcm.Tests/WritingSystemIdTests.cs +++ b/backend/FwLite/MiniLcm.Tests/WritingSystemIdTests.cs @@ -1,6 +1,4 @@ -using MiniLcm.Models; - -namespace MiniLcm.Tests; +namespace MiniLcm.Tests; public class WritingSystemIdTests { @@ -11,7 +9,7 @@ public class WritingSystemIdTests public void ValidWritingSystemId_ShouldNotThrow(string code) { var ws = new WritingSystemId(code); - ws.Should().NotBeNull(); + ws.Should().NotBe(default); } [Theory] @@ -29,6 +27,6 @@ public void InvalidWritingSystemId_ShouldThrow(string code) public void DefaultWritingSystemId_IsValid() { var ws = new WritingSystemId("default"); - ws.Should().NotBeNull(); + ws.Should().NotBe(default); } } diff --git a/backend/FwLite/MiniLcm/Exceptions/CreateObjectException.cs b/backend/FwLite/MiniLcm/Exceptions/CreateObjectException.cs new file mode 100644 index 000000000..128cb0515 --- /dev/null +++ b/backend/FwLite/MiniLcm/Exceptions/CreateObjectException.cs @@ -0,0 +1,12 @@ +namespace MiniLcm.Exceptions; + +public class CreateObjectException: Exception +{ + public CreateObjectException(string? message) : base(message) + { + } + + public CreateObjectException(string? message, Exception? innerException) : base(message, innerException) + { + } +} diff --git a/backend/FwLite/MiniLcm/Exceptions/SyncObjectException.cs b/backend/FwLite/MiniLcm/Exceptions/SyncObjectException.cs new file mode 100644 index 000000000..e1f284cde --- /dev/null +++ b/backend/FwLite/MiniLcm/Exceptions/SyncObjectException.cs @@ -0,0 +1,12 @@ +namespace MiniLcm.Exceptions; + +public class SyncObjectException: Exception +{ + public SyncObjectException(string? message) : base(message) + { + } + + public SyncObjectException(string? message, Exception? innerException) : base(message, innerException) + { + } +} diff --git a/backend/FwLite/MiniLcm/IMiniLcmReadApi.cs b/backend/FwLite/MiniLcm/IMiniLcmReadApi.cs index 40f4914e7..c54795bb8 100644 --- a/backend/FwLite/MiniLcm/IMiniLcmReadApi.cs +++ b/backend/FwLite/MiniLcm/IMiniLcmReadApi.cs @@ -15,6 +15,7 @@ public interface IMiniLcmReadApi Task GetSense(Guid entryId, Guid id); Task GetPartOfSpeech(Guid id); Task GetSemanticDomain(Guid id); + Task GetExampleSentence(Guid entryId, Guid senseId, Guid id); } public record QueryOptions( diff --git a/backend/FwLite/MiniLcm/IMiniLcmWriteApi.cs b/backend/FwLite/MiniLcm/IMiniLcmWriteApi.cs index e039cbe90..a279e565d 100644 --- a/backend/FwLite/MiniLcm/IMiniLcmWriteApi.cs +++ b/backend/FwLite/MiniLcm/IMiniLcmWriteApi.cs @@ -11,17 +11,20 @@ public interface IMiniLcmWriteApi Task UpdateWritingSystem(WritingSystemId id, WritingSystemType type, UpdateObjectInput update); - + Task UpdateWritingSystem(WritingSystem before, WritingSystem after); + // Note there's no Task DeleteWritingSystem(Guid id) because deleting writing systems needs careful consideration, as it can cause a massive cascade of data deletion #region PartOfSpeech Task CreatePartOfSpeech(PartOfSpeech partOfSpeech); Task UpdatePartOfSpeech(Guid id, UpdateObjectInput update); + Task UpdatePartOfSpeech(PartOfSpeech before, PartOfSpeech after); Task DeletePartOfSpeech(Guid id); #endregion #region SemanticDomain Task CreateSemanticDomain(SemanticDomain semanticDomain); Task UpdateSemanticDomain(Guid id, UpdateObjectInput update); + Task UpdateSemanticDomain(SemanticDomain before, SemanticDomain after); Task DeleteSemanticDomain(Guid id); #endregion @@ -54,6 +57,10 @@ Task UpdateExampleSentence(Guid entryId, Guid senseId, Guid exampleSentenceId, UpdateObjectInput update); + Task UpdateExampleSentence(Guid entryId, + Guid senseId, + ExampleSentence before, + ExampleSentence after); Task DeleteExampleSentence(Guid entryId, Guid senseId, Guid exampleSentenceId); #endregion diff --git a/backend/FwLite/MiniLcm/MiniLcm.csproj b/backend/FwLite/MiniLcm/MiniLcm.csproj index 8f4dc0c48..e806ed4ff 100644 --- a/backend/FwLite/MiniLcm/MiniLcm.csproj +++ b/backend/FwLite/MiniLcm/MiniLcm.csproj @@ -1,17 +1,19 @@  - net8.0 - enable - enable $(ApplicationDisplayVersion) $(ApplicationDisplayVersion) + + - + + + + diff --git a/backend/FwLite/MiniLcm/Models/Entry.cs b/backend/FwLite/MiniLcm/Models/Entry.cs index 191db2753..cabb8c9bb 100644 --- a/backend/FwLite/MiniLcm/Models/Entry.cs +++ b/backend/FwLite/MiniLcm/Models/Entry.cs @@ -1,6 +1,6 @@ namespace MiniLcm.Models; -public class Entry : IObjectWithId +public record Entry : IObjectWithId { public Guid Id { get; set; } public DateTimeOffset? DeletedAt { get; set; } @@ -28,8 +28,10 @@ public class Entry : IObjectWithId public string Headword() { - var word = CitationForm.Values.Values.FirstOrDefault(); - if (string.IsNullOrEmpty(word)) word = LexemeForm.Values.Values.FirstOrDefault(); + //order by code to ensure the headword is stable + //todo choose ws by preference based on ws order/default + var word = CitationForm.Values.OrderBy(kvp => kvp.Key.Code).FirstOrDefault().Value; + if (string.IsNullOrEmpty(word)) word = LexemeForm.Values.OrderBy(kvp => kvp.Key.Code).FirstOrDefault().Value; return word?.Trim() ?? "(Unknown)"; } @@ -68,6 +70,11 @@ public Guid[] GetReferences() public void RemoveReference(Guid id, DateTimeOffset time) { } + + public Entry WithoutEntryRefs() + { + return this with { Components = [], ComplexForms = [] }; + } } public class Variants diff --git a/backend/FwLite/MiniLcm/Models/WritingSystem.cs b/backend/FwLite/MiniLcm/Models/WritingSystem.cs index ed96bcddc..e23627cd7 100644 --- a/backend/FwLite/MiniLcm/Models/WritingSystem.cs +++ b/backend/FwLite/MiniLcm/Models/WritingSystem.cs @@ -3,10 +3,10 @@ public record WritingSystem: IObjectWithId { public required Guid Id { get; set; } - public required WritingSystemId WsId { get; set; } - public required string Name { get; set; } - public required string Abbreviation { get; set; } - public required string Font { get; set; } + public virtual required WritingSystemId WsId { get; set; } + public virtual required string Name { get; set; } + public virtual required string Abbreviation { get; set; } + public virtual required string Font { get; set; } public DateTimeOffset? DeletedAt { get; set; } public required WritingSystemType Type { get; set; } diff --git a/backend/FwLite/MiniLcm/Models/WritingSystemId.cs b/backend/FwLite/MiniLcm/Models/WritingSystemId.cs index 7441fb99d..c12509fbb 100644 --- a/backend/FwLite/MiniLcm/Models/WritingSystemId.cs +++ b/backend/FwLite/MiniLcm/Models/WritingSystemId.cs @@ -33,7 +33,8 @@ public override void WriteAsPropertyName(Utf8JsonWriter writer, WritingSystemId public WritingSystemId(string code) { - if (code == "default" || IetfLanguageTag.IsValid(code)) + //__key is used by the LfClassicMiniLcmApi to smuggle non guid ids with possibilitie lists + if (code == "default" || code == "__key" || IetfLanguageTag.IsValid(code)) { Code = code; } diff --git a/backend/FwLite/MiniLcm/SyncHelpers/DiffCollection.cs b/backend/FwLite/MiniLcm/SyncHelpers/DiffCollection.cs index 2cc251f2b..33bda1e46 100644 --- a/backend/FwLite/MiniLcm/SyncHelpers/DiffCollection.cs +++ b/backend/FwLite/MiniLcm/SyncHelpers/DiffCollection.cs @@ -4,6 +4,73 @@ namespace MiniLcm.SyncHelpers; public static class DiffCollection { + /// + /// Diffs a list, for new items calls add, it will then call update for the item returned from the add, using that as the before item for the replace call + /// + /// + /// + /// + /// + /// api, value, return value to be used as the before item for the replace call + /// + /// api, before, after is the parameter order + /// + /// + /// + public static async Task DiffAddThenUpdate( + IMiniLcmApi api, + IList before, + IList after, + Func identity, + Func> add, + Func> remove, + Func> replace) where TId : notnull + { + var changes = 0; + var afterEntriesDict = after.ToDictionary(identity); + + foreach (var beforeEntry in before) + { + if (afterEntriesDict.TryGetValue(identity(beforeEntry), out var afterEntry)) + { + changes += await replace(api, beforeEntry, afterEntry); + } + else + { + changes += await remove(api, beforeEntry); + } + + afterEntriesDict.Remove(identity(beforeEntry)); + } + + var postAddUpdates = new List<(T created, T after)>(afterEntriesDict.Values.Count); + foreach (var value in afterEntriesDict.Values) + { + changes++; + postAddUpdates.Add((await add(api, value), value)); + } + foreach ((T createdItem, T afterItem) in postAddUpdates) + { + //todo this may do a lot more work than it needs to, eg sense will be created during add, but they will be checked again here when we know they didn't change + await replace(api, createdItem, afterItem); + } + + return changes; + } + + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// api, before, after is the parameter order + /// + /// + /// public static async Task Diff( IMiniLcmApi api, IList before, @@ -36,6 +103,7 @@ public static async Task Diff( return changes; } + public static async Task Diff( IMiniLcmApi api, IList before, diff --git a/backend/FwLite/MiniLcm/SyncHelpers/EntrySync.cs b/backend/FwLite/MiniLcm/SyncHelpers/EntrySync.cs index 50fe78892..c8f2db5a3 100644 --- a/backend/FwLite/MiniLcm/SyncHelpers/EntrySync.cs +++ b/backend/FwLite/MiniLcm/SyncHelpers/EntrySync.cs @@ -10,10 +10,13 @@ public static async Task Sync(Entry[] afterEntries, Entry[] beforeEntries, IMiniLcmApi api) { - Func> add = static async (api, afterEntry) => + Func> add = static async (api, afterEntry) => { - await api.CreateEntry(afterEntry); - return 1; + //create each entry without components. + //After each entry is created, then replace will be called to create those components + var entryWithoutEntryRefs = afterEntry.WithoutEntryRefs(); + await api.CreateEntry(entryWithoutEntryRefs); + return entryWithoutEntryRefs; }; Func> remove = static async (api, beforeEntry) => { @@ -21,19 +24,26 @@ public static async Task Sync(Entry[] afterEntries, return 1; }; Func> replace = static async (api, beforeEntry, afterEntry) => await Sync(afterEntry, beforeEntry, api); - return await DiffCollection.Diff(api, beforeEntries, afterEntries, add, remove, replace); + return await DiffCollection.DiffAddThenUpdate(api, beforeEntries, afterEntries, entry => entry.Id, add, remove, replace); } public static async Task Sync(Entry afterEntry, Entry beforeEntry, IMiniLcmApi api) { - var updateObjectInput = EntryDiffToUpdate(beforeEntry, afterEntry); - if (updateObjectInput is not null) await api.UpdateEntry(afterEntry.Id, updateObjectInput); - var changes = await SensesSync(afterEntry.Id, afterEntry.Senses, beforeEntry.Senses, api); + try + { + var updateObjectInput = EntryDiffToUpdate(beforeEntry, afterEntry); + if (updateObjectInput is not null) await api.UpdateEntry(afterEntry.Id, updateObjectInput); + var changes = await SensesSync(afterEntry.Id, afterEntry.Senses, beforeEntry.Senses, api); - changes += await Sync(afterEntry.Components, beforeEntry.Components, api); - changes += await Sync(afterEntry.ComplexForms, beforeEntry.ComplexForms, api); - changes += await Sync(afterEntry.Id, afterEntry.ComplexFormTypes, beforeEntry.ComplexFormTypes, api); - return changes + (updateObjectInput is null ? 0 : 1); + changes += await Sync(afterEntry.Components, beforeEntry.Components, api); + changes += await Sync(afterEntry.ComplexForms, beforeEntry.ComplexForms, api); + changes += await Sync(afterEntry.Id, afterEntry.ComplexFormTypes, beforeEntry.ComplexFormTypes, api); + return changes + (updateObjectInput is null ? 0 : 1); + } + catch (Exception e) + { + throw new SyncObjectException($"Failed to sync entry {afterEntry}", e); + } } private static async Task Sync(Guid entryId, diff --git a/backend/FwLite/MiniLcm/SyncHelpers/ExampleSentenceSync.cs b/backend/FwLite/MiniLcm/SyncHelpers/ExampleSentenceSync.cs index 5652e77de..ef2e6cf1a 100644 --- a/backend/FwLite/MiniLcm/SyncHelpers/ExampleSentenceSync.cs +++ b/backend/FwLite/MiniLcm/SyncHelpers/ExampleSentenceSync.cs @@ -22,13 +22,8 @@ public static async Task Sync(Guid entryId, return 1; }; Func> replace = - async (api, beforeExampleSentence, afterExampleSentence) => - { - var updateObjectInput = DiffToUpdate(beforeExampleSentence, afterExampleSentence); - if (updateObjectInput is null) return 0; - await api.UpdateExampleSentence(entryId, senseId, beforeExampleSentence.Id, updateObjectInput); - return 1; - }; + (api, beforeExampleSentence, afterExampleSentence) => + Sync(entryId, senseId, afterExampleSentence, beforeExampleSentence, api); return await DiffCollection.Diff(api, beforeExampleSentences, afterExampleSentences, @@ -37,6 +32,18 @@ public static async Task Sync(Guid entryId, replace); } + public static async Task Sync(Guid entryId, + Guid senseId, + ExampleSentence afterExampleSentence, + ExampleSentence beforeExampleSentence, + IMiniLcmApi api) + { + var updateObjectInput = DiffToUpdate(beforeExampleSentence, afterExampleSentence); + if (updateObjectInput is null) return 0; + await api.UpdateExampleSentence(entryId, senseId, beforeExampleSentence.Id, updateObjectInput); + return 1; + } + public static UpdateObjectInput? DiffToUpdate(ExampleSentence beforeExampleSentence, ExampleSentence afterExampleSentence) { diff --git a/backend/FwLite/MiniLcm/SyncHelpers/PartOfSpeechSync.cs b/backend/FwLite/MiniLcm/SyncHelpers/PartOfSpeechSync.cs index a37e88cc8..9a7cf0bba 100644 --- a/backend/FwLite/MiniLcm/SyncHelpers/PartOfSpeechSync.cs +++ b/backend/FwLite/MiniLcm/SyncHelpers/PartOfSpeechSync.cs @@ -1,8 +1,8 @@ -using MiniLcm; using MiniLcm.Models; -using MiniLcm.SyncHelpers; using SystemTextJsonPatch; +namespace MiniLcm.SyncHelpers; + public static class PartOfSpeechSync { public static async Task Sync(PartOfSpeech[] currentPartsOfSpeech, @@ -23,12 +23,16 @@ public static async Task Sync(PartOfSpeech[] currentPartsOfSpeech, await api.DeletePartOfSpeech(previousPos.Id); return 1; }, - async (api, previousPos, currentPos) => - { - var updateObjectInput = PartOfSpeechDiffToUpdate(previousPos, currentPos); - if (updateObjectInput is not null) await api.UpdatePartOfSpeech(currentPos.Id, updateObjectInput); - return updateObjectInput is null ? 0 : 1; - }); + (api, previousPos, currentPos) => Sync(previousPos, currentPos, api)); + } + + public static async Task Sync(PartOfSpeech before, + PartOfSpeech after, + IMiniLcmApi api) + { + var updateObjectInput = PartOfSpeechDiffToUpdate(before, after); + if (updateObjectInput is not null) await api.UpdatePartOfSpeech(after.Id, updateObjectInput); + return updateObjectInput is null ? 0 : 1; } public static UpdateObjectInput? PartOfSpeechDiffToUpdate(PartOfSpeech previousPartOfSpeech, PartOfSpeech currentPartOfSpeech) diff --git a/backend/FwLite/MiniLcm/SyncHelpers/SemanticDomainSync.cs b/backend/FwLite/MiniLcm/SyncHelpers/SemanticDomainSync.cs index 1219db9aa..3892e8ed1 100644 --- a/backend/FwLite/MiniLcm/SyncHelpers/SemanticDomainSync.cs +++ b/backend/FwLite/MiniLcm/SyncHelpers/SemanticDomainSync.cs @@ -1,8 +1,8 @@ -using MiniLcm; using MiniLcm.Models; -using MiniLcm.SyncHelpers; using SystemTextJsonPatch; +namespace MiniLcm.SyncHelpers; + public static class SemanticDomainSync { public static async Task Sync(SemanticDomain[] currentSemanticDomains, @@ -23,12 +23,16 @@ public static async Task Sync(SemanticDomain[] currentSemanticDomains, await api.DeleteSemanticDomain(previousPos.Id); return 1; }, - async (api, previousPos, currentPos) => - { - var updateObjectInput = SemanticDomainDiffToUpdate(previousPos, currentPos); - if (updateObjectInput is not null) await api.UpdateSemanticDomain(currentPos.Id, updateObjectInput); - return updateObjectInput is null ? 0 : 1; - }); + (api, previousSemdom, currentSemdom) => Sync(previousSemdom, currentSemdom, api)); + } + + public static async Task Sync(SemanticDomain before, + SemanticDomain after, + IMiniLcmApi api) + { + var updateObjectInput = SemanticDomainDiffToUpdate(before, after); + if (updateObjectInput is not null) await api.UpdateSemanticDomain(after.Id, updateObjectInput); + return updateObjectInput is null ? 0 : 1; } public static UpdateObjectInput? SemanticDomainDiffToUpdate(SemanticDomain previousSemanticDomain, SemanticDomain currentSemanticDomain) diff --git a/backend/FwLite/MiniLcm/SyncHelpers/SimpleStringDiff.cs b/backend/FwLite/MiniLcm/SyncHelpers/SimpleStringDiff.cs new file mode 100644 index 000000000..37f9e7870 --- /dev/null +++ b/backend/FwLite/MiniLcm/SyncHelpers/SimpleStringDiff.cs @@ -0,0 +1,16 @@ +using SystemTextJsonPatch.Operations; + +namespace MiniLcm.SyncHelpers; + +public static class SimpleStringDiff +{ + public static IEnumerable> GetStringDiff(string path, + string? before, + string? after) where T : class + { + if (before == after) yield break; + if (after is null) yield return new Operation("remove", $"/{path}", null); + else if (before is null) yield return new Operation("add", $"/{path}", null); + else yield return new Operation("replace", $"/{path}", null, after); + } +} diff --git a/backend/FwLite/MiniLcm/SyncHelpers/WritingSystemSync.cs b/backend/FwLite/MiniLcm/SyncHelpers/WritingSystemSync.cs new file mode 100644 index 000000000..1930059ce --- /dev/null +++ b/backend/FwLite/MiniLcm/SyncHelpers/WritingSystemSync.cs @@ -0,0 +1,61 @@ +using MiniLcm.Models; +using SystemTextJsonPatch; + +namespace MiniLcm.SyncHelpers; + +public static class WritingSystemSync +{ + public static async Task Sync(WritingSystem[] currentWritingSystems, + WritingSystem[] previousWritingSystems, + IMiniLcmApi api) + { + return await DiffCollection.Diff(api, + previousWritingSystems, + currentWritingSystems, + ws => (ws.WsId, ws.Type), + async (api, currentWs) => + { + await api.CreateWritingSystem(currentWs.Type, currentWs); + return 1; + }, + async (api, previousWs) => + { + // await api.DeleteWritingSystem(previousWs.Id); // Deleting writing systems is dangerous as it causes cascading data deletion. Needs careful thought. + // TODO: should we throw an exception? + return 0; + }, + async (api, previousWs, currentWs) => + { + return await Sync(currentWs, previousWs, api); + }); + } + + public static async Task Sync(WritingSystem afterWs, WritingSystem beforeWs, IMiniLcmApi api) + { + var updateObjectInput = WritingSystemDiffToUpdate(beforeWs, afterWs); + if (updateObjectInput is not null) await api.UpdateWritingSystem(afterWs.WsId, afterWs.Type, updateObjectInput); + return updateObjectInput is null ? 0 : 1; + } + + public static UpdateObjectInput? WritingSystemDiffToUpdate(WritingSystem previousWritingSystem, WritingSystem currentWritingSystem) + { + JsonPatchDocument patchDocument = new(); + if (previousWritingSystem.WsId != currentWritingSystem.WsId) + { + // TODO: Throw? Or silently ignore? + throw new InvalidOperationException($"Tried to change immutable WsId from {previousWritingSystem.WsId} to {currentWritingSystem.WsId}"); + } + patchDocument.Operations.AddRange(SimpleStringDiff.GetStringDiff(nameof(WritingSystem.Name), + previousWritingSystem.Name, + currentWritingSystem.Name)); + patchDocument.Operations.AddRange(SimpleStringDiff.GetStringDiff(nameof(WritingSystem.Abbreviation), + previousWritingSystem.Abbreviation, + currentWritingSystem.Abbreviation)); + patchDocument.Operations.AddRange(SimpleStringDiff.GetStringDiff(nameof(WritingSystem.Font), + previousWritingSystem.Font, + currentWritingSystem.Font)); + // TODO: Exemplars, Order, and do we need DeletedAt? + if (patchDocument.Operations.Count == 0) return null; + return new UpdateObjectInput(patchDocument); + } +} diff --git a/backend/FwLite/MiniLcm/Validators/ComplexFormTypeValidator.cs b/backend/FwLite/MiniLcm/Validators/ComplexFormTypeValidator.cs new file mode 100644 index 000000000..645d3c81a --- /dev/null +++ b/backend/FwLite/MiniLcm/Validators/ComplexFormTypeValidator.cs @@ -0,0 +1,14 @@ +using FluentValidation; +using FluentValidation.Validators; +using MiniLcm.Models; + +namespace MiniLcm.Validators; + +internal class ComplexFormTypeValidator : AbstractValidator +{ + public ComplexFormTypeValidator() + { + RuleFor(c => c.DeletedAt).Null(); + RuleFor(c => c.Name).Required(); + } +} diff --git a/backend/FwLite/MiniLcm/Validators/EntryValidator.cs b/backend/FwLite/MiniLcm/Validators/EntryValidator.cs new file mode 100644 index 000000000..a68076b26 --- /dev/null +++ b/backend/FwLite/MiniLcm/Validators/EntryValidator.cs @@ -0,0 +1,13 @@ +using FluentValidation; +using MiniLcm.Models; + +namespace MiniLcm.Validators; + +public class EntryValidator : AbstractValidator +{ + public EntryValidator() + { + RuleForEach(e => e.Senses).SetValidator(entry => new SenseValidator(entry)); + //todo just a stub as an example for senses + } +} diff --git a/backend/FwLite/MiniLcm/Validators/MiniLcmValidators.cs b/backend/FwLite/MiniLcm/Validators/MiniLcmValidators.cs new file mode 100644 index 000000000..2da5c773c --- /dev/null +++ b/backend/FwLite/MiniLcm/Validators/MiniLcmValidators.cs @@ -0,0 +1,23 @@ +using FluentValidation; +using Microsoft.Extensions.DependencyInjection; +using MiniLcm.Models; + +namespace MiniLcm.Validators; + +public record MiniLcmValidators(IValidator ComplexFormTypeValidator) +{ + public async Task ValidateAndThrow(ComplexFormType value) + { + await ComplexFormTypeValidator.ValidateAndThrowAsync(value); + } +} + +public static class MiniLcmValidatorsExtensions +{ + public static IServiceCollection AddMiniLcmValidators(this IServiceCollection services) + { + services.AddTransient(); + services.AddTransient, ComplexFormTypeValidator>(); + return services; + } +} diff --git a/backend/FwLite/MiniLcm/Validators/MultiStringValidator.cs b/backend/FwLite/MiniLcm/Validators/MultiStringValidator.cs new file mode 100644 index 000000000..0ee9959de --- /dev/null +++ b/backend/FwLite/MiniLcm/Validators/MultiStringValidator.cs @@ -0,0 +1,17 @@ +using FluentValidation; +using MiniLcm.Models; + +namespace MiniLcm.Validators; + +internal static class MultiStringValidator +{ + public static IRuleBuilderOptions Required(this IRuleBuilder ruleBuilder) + { + return ruleBuilder.NotEmpty().NoEmptyValues(); + } + public static IRuleBuilderOptions NoEmptyValues(this IRuleBuilder ruleBuilder) + { + return ruleBuilder.Must(ms => ms.Values.All(v => !string.IsNullOrEmpty(v.Value))).WithMessage((parent, ms) => + $"MultiString must not contain empty values, but [{string.Join(", ", ms.Values.Where(v => string.IsNullOrWhiteSpace(v.Value)).Select(v => v.Key))}] was empty"); + } +} diff --git a/backend/FwLite/MiniLcm/Validators/SenseValidator.cs b/backend/FwLite/MiniLcm/Validators/SenseValidator.cs new file mode 100644 index 000000000..43f72508d --- /dev/null +++ b/backend/FwLite/MiniLcm/Validators/SenseValidator.cs @@ -0,0 +1,18 @@ +using FluentValidation; +using MiniLcm.Models; + +namespace MiniLcm.Validators; + +public class SenseValidator : AbstractValidator +{ + public SenseValidator() + { + //todo add validation for the other properties + } + + public SenseValidator(Entry entry): this() + { + //it's ok if senses EntryId is an Empty guid + RuleFor(s => s.EntryId).Equal(entry.Id).When(s => s.EntryId != Guid.Empty).WithMessage(sense => $"Sense (Id: {sense.Id}) EntryId must match Entry {entry.Id}, but instead was {sense.EntryId}"); + } +} diff --git a/backend/LexBoxApi/Controllers/CrdtController.cs b/backend/LexBoxApi/Controllers/CrdtController.cs index 4c4e98bb0..4051d2b52 100644 --- a/backend/LexBoxApi/Controllers/CrdtController.cs +++ b/backend/LexBoxApi/Controllers/CrdtController.cs @@ -1,11 +1,14 @@ using System.Text.Json.Serialization; -using LexBoxApi.Auth; using SIL.Harmony.Core; +using LexBoxApi.Auth; +using LexBoxApi.Auth.Attributes; using LexBoxApi.Hub; using LexBoxApi.Services; using LexCore.Entities; using LexCore.ServiceInterfaces; +using LexCore.Sync; using LexData; +using Microsoft.AspNetCore.Http.Timeouts; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; @@ -21,7 +24,8 @@ public class CrdtController( IHubContext hubContext, IPermissionService permissionService, LoggedInContext loggedInContext, - ProjectService projectService) : ControllerBase + ProjectService projectService, + FwHeadlessClient fwHeadlessClient) : ControllerBase { private DbSet ServerCommits => dbContext.Set(); @@ -90,4 +94,14 @@ public async Task> GetProjectId(string code) return Ok(projectId); } + + [HttpPost("sync/{projectId}")] + [AdminRequired] + [RequestTimeout(300_000)]//5 minutes + public async Task> ExecuteMerge(Guid projectId) + { + var result = await fwHeadlessClient.CrdtSync(projectId); + if (result is null) return Problem("Failed to sync CRDT"); + return result; + } } diff --git a/backend/LexBoxApi/GraphQL/CustomTypes/UserGqlConfiguration.cs b/backend/LexBoxApi/GraphQL/CustomTypes/UserGqlConfiguration.cs index 51c50b312..fae1808b1 100644 --- a/backend/LexBoxApi/GraphQL/CustomTypes/UserGqlConfiguration.cs +++ b/backend/LexBoxApi/GraphQL/CustomTypes/UserGqlConfiguration.cs @@ -1,4 +1,6 @@ -using LexBoxApi.Auth.Attributes; +using HotChocolate.Data.Filters; +using HotChocolate.Data.Sorting; +using LexBoxApi.Auth.Attributes; using LexCore.Entities; namespace LexBoxApi.GraphQL.CustomTypes; @@ -11,6 +13,7 @@ protected override void Configure(IObjectTypeDescriptor descriptor) descriptor.Ignore(u => u.Salt); descriptor.Ignore(u => u.PasswordHash); descriptor.Ignore(u => u.CanLogin()); + descriptor.Ignore(u => u.GoogleId); descriptor.Field(u => u.Email).AdminRequired(); descriptor.Field(u => u.EmailVerified).AdminRequired(); @@ -19,3 +22,34 @@ protected override void Configure(IObjectTypeDescriptor descriptor) descriptor.Field(u => u.Locked).AdminRequired(); } } + +[ObjectType] +public class UserFilterType : FilterInputType +{ + protected override void Configure( + IFilterInputTypeDescriptor descriptor) + { + descriptor.BindFieldsExplicitly(); + descriptor.AllowOr(); + descriptor.Field(t => t.Name); + descriptor.Field(t => t.Email); + descriptor.Field(t => t.IsAdmin); + descriptor.Field(t => t.CreatedById); + descriptor.Field(t => t.Username); + descriptor.Field(t => t.Id); + } +} + +[ObjectType] +public class UserSortType : SortInputType +{ + protected override void Configure( + ISortInputTypeDescriptor descriptor) + { + descriptor.BindFieldsExplicitly(); + descriptor.Field(t => t.Name); + descriptor.Field(t => t.Email); + descriptor.Field(t => t.Username); + descriptor.Field(t => t.CreatedDate); + } +} diff --git a/backend/LexBoxApi/GraphQL/LexQueries.cs b/backend/LexBoxApi/GraphQL/LexQueries.cs index f2326d2ef..1810b0950 100644 --- a/backend/LexBoxApi/GraphQL/LexQueries.cs +++ b/backend/LexBoxApi/GraphQL/LexQueries.cs @@ -43,11 +43,11 @@ public IQueryable Projects(LexBoxDbContext context, bool withDeleted = { if (withDeleted) { - return context.Projects.IgnoreQueryFilters(); + return context.Projects.AsNoTracking().IgnoreQueryFilters(); } else { - return context.Projects; + return context.Projects.AsNoTracking(); } } @@ -56,7 +56,7 @@ public IQueryable Projects(LexBoxDbContext context, bool withDeleted = public IQueryable MyDraftProjects(LoggedInContext loggedInContext, LexBoxDbContext context) { var userId = loggedInContext.User.Id; - return context.DraftProjects.Where(p => p.ProjectManagerId == userId); + return context.DraftProjects.AsNoTracking().Where(p => p.ProjectManagerId == userId); } [UseProjection] @@ -65,7 +65,7 @@ public IQueryable MyDraftProjects(LoggedInContext loggedInContext, [AdminRequired] public IQueryable DraftProjects(LexBoxDbContext context) { - return context.DraftProjects; + return context.DraftProjects.AsNoTracking(); } public record ProjectsByLangCodeAndOrgInput(Guid OrgId, string LangCode); @@ -75,8 +75,8 @@ public IQueryable ProjectsByLangCodeAndOrg(LoggedInContext loggedInCont { if (!loggedInContext.User.IsAdmin && !permissionService.IsOrgMember(input.OrgId)) throw new UnauthorizedAccessException(); // Convert 3-letter code to 2-letter code if relevant, otherwise leave as-is - var langCode = Services.LangTagConstants.ThreeToTwo.GetValueOrDefault(input.LangCode, input.LangCode); - var query = context.Projects.Where(p => + var langCode = LangTagConstants.ThreeToTwo.GetValueOrDefault(input.LangCode, input.LangCode); + var query = context.Projects.AsNoTracking().Where(p => p.Organizations.Any(o => o.Id == input.OrgId) && p.FlexProjectMetadata != null && p.FlexProjectMetadata.WritingSystems != null && @@ -103,7 +103,7 @@ public record ProjectsInMyOrgInput(Guid OrgId); public IQueryable ProjectsInMyOrg(LoggedInContext loggedInContext, LexBoxDbContext context, IPermissionService permissionService, ProjectsInMyOrgInput input) { if (!loggedInContext.User.IsAdmin && !permissionService.IsOrgMember(input.OrgId)) throw new UnauthorizedAccessException(); - var query = context.Projects.Where(p => p.Organizations.Any(o => o.Id == input.OrgId)); + var query = context.Projects.AsNoTracking().Where(p => p.Organizations.Any(o => o.Id == input.OrgId)); // Org admins can see all projects, everyone else can only see non-confidential if (!permissionService.CanEditOrg(input.OrgId)) { @@ -117,7 +117,7 @@ public IQueryable ProjectsInMyOrg(LoggedInContext loggedInContext, LexB public async Task> ProjectById(LexBoxDbContext context, IPermissionService permissionService, Guid projectId) { await permissionService.AssertCanViewProject(projectId); - return context.Projects.Where(p => p.Id == projectId); + return context.Projects.AsNoTracking().Where(p => p.Id == projectId); } [UseProjection] @@ -141,20 +141,12 @@ public async Task> ProjectById(LexBoxDbContext context, IPer return project; } - [UseSingleOrDefault] - [UseProjection] - [AdminRequired] - public IQueryable DraftProjectByCode(LexBoxDbContext context, string code) - { - return context.DraftProjects.Where(p => p.Code == code); - } - [UseProjection] [UseFiltering] [UseSorting] public IQueryable Orgs(LexBoxDbContext context) { - return context.Orgs; + return context.Orgs.AsNoTracking(); } [UseProjection] @@ -181,10 +173,9 @@ public async Task> MyOrgs( [UseProjection] [UseFiltering] [UseSorting] - public IQueryable UsersInMyOrg(LexBoxDbContext context, LoggedInContext loggedInContext) + public IQueryable UsersICanSee(UserService userService, LoggedInContext loggedInContext) { - var myOrgIds = loggedInContext.User.Orgs.Select(o => o.OrgId).ToList(); - return context.Users.Where(u => u.Organizations.Any(orgMember => myOrgIds.Contains(orgMember.OrgId))); + return userService.UserQueryForTypeahead(loggedInContext.User); } [UseProjection] @@ -269,7 +260,7 @@ public IQueryable Users(LexBoxDbContext context) // Only site admins and org admins are allowed to run this query if (!permissionService.CanEditOrg(orgId)) return null; - var user = await context.Users.Include(u => u.Organizations).Include(u => u.CreatedBy).Where(u => u.Id == userId).FirstOrDefaultAsync(); + var user = await context.Users.AsNoTracking().Include(u => u.Organizations).Include(u => u.CreatedBy).Where(u => u.Id == userId).FirstOrDefaultAsync(); if (user is null) return null; var userInOrg = user.Organizations.Any(om => om.OrgId == orgId); diff --git a/backend/LexBoxApi/GraphQL/ProjectMutations.cs b/backend/LexBoxApi/GraphQL/ProjectMutations.cs index 2f6df4039..55e2b8ed5 100644 --- a/backend/LexBoxApi/GraphQL/ProjectMutations.cs +++ b/backend/LexBoxApi/GraphQL/ProjectMutations.cs @@ -26,28 +26,28 @@ public enum CreateProjectResult Requested } - public record CreateProjectResponse(Guid? Id, CreateProjectResult Result); + public record CreateProjectResponse(Guid Id, CreateProjectResult Result); [Error] [Error] [Error] [UseMutationConvention] [RefreshJwt] [VerifiedEmailRequired] - public async Task CreateProject( + public async Task CreateProject( LoggedInContext loggedInContext, IPermissionService permissionService, CreateProjectInput input, ProjectService projectService, IEmailService emailService) { - if (!loggedInContext.User.IsAdmin) + if (!loggedInContext.User.IsAdmin || input.ForceDraft) //draft projects should always have a manager { // For non-admins we always implicitly set them as the project manager // Only admins can create empty projects or projects for other users input = input with { ProjectManagerId = loggedInContext.User.Id }; } - if (!permissionService.HasProjectCreatePermission()) + if (!permissionService.HasProjectCreatePermission() || input.ForceDraft) { if (!permissionService.HasProjectRequestPermission()) throw new ProjectCreatorsMustHaveEmail("Project creators must have a valid email address"); var draftProjectId = await projectService.CreateDraftProject(input); diff --git a/backend/LexBoxApi/LexBoxApi.csproj b/backend/LexBoxApi/LexBoxApi.csproj index baffc9fb9..638ba148e 100644 --- a/backend/LexBoxApi/LexBoxApi.csproj +++ b/backend/LexBoxApi/LexBoxApi.csproj @@ -1,12 +1,8 @@ - net8.0 - enable - enable Linux true - dev 7392cddf-9b3b-441c-9316-203bb5c4a6bc 1 @@ -27,21 +23,23 @@ - - - + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - + + - + - - - + + + @@ -53,6 +51,7 @@ + diff --git a/backend/LexBoxApi/LexBoxKernel.cs b/backend/LexBoxApi/LexBoxKernel.cs index 347449122..a92217cf2 100644 --- a/backend/LexBoxApi/LexBoxKernel.cs +++ b/backend/LexBoxApi/LexBoxKernel.cs @@ -49,6 +49,9 @@ public static void AddLexBoxApi(this IServiceCollection services, .ValidateDataAnnotations() .ValidateOnStart(); services.AddHttpClient(); + services.AddServiceDiscovery(); + services.AddHttpClient(client => client.BaseAddress = new ("http://fwHeadless")) + .AddServiceDiscovery();//service discovery means that we lookup the hostname in Services__fwHeadless__http in config services.AddHttpContextAccessor(); services.AddMemoryCache(); services.AddScoped(); diff --git a/backend/LexBoxApi/Models/Project/CreateProjectInput.cs b/backend/LexBoxApi/Models/Project/CreateProjectInput.cs index 57ec07ed8..99ed92179 100644 --- a/backend/LexBoxApi/Models/Project/CreateProjectInput.cs +++ b/backend/LexBoxApi/Models/Project/CreateProjectInput.cs @@ -13,5 +13,6 @@ public record CreateProjectInput( RetentionPolicy RetentionPolicy, bool IsConfidential, Guid? ProjectManagerId, - Guid? OrgId + Guid? OrgId, + bool ForceDraft = false ); diff --git a/backend/LexBoxApi/Program.cs b/backend/LexBoxApi/Program.cs index a25d36415..76c03493f 100644 --- a/backend/LexBoxApi/Program.cs +++ b/backend/LexBoxApi/Program.cs @@ -169,9 +169,7 @@ app.UseAuthorization(); app.MapSecurityTxt(); app.MapNitroApp("/api/graphql/ui").WithOptions(new (){ServeMode = GraphQLToolServeMode.Embedded}).AllowAnonymous(); -if (app.Environment.IsDevelopment()) - //required for vite to generate types - app.MapGraphQLSchema("/api/graphql/schema.graphql").AllowAnonymous(); +app.MapGraphQLSchema("/api/graphql/schema.graphql").AllowAnonymous(); app.MapGraphQLHttp("/api/graphql"); app.MapQuartzUI("/api/quartz").RequireAuthorization(new AdminRequiredAttribute()); diff --git a/backend/LexBoxApi/Services/DevGqlSchemaWriterService.cs b/backend/LexBoxApi/Services/DevGqlSchemaWriterService.cs index a40a93c3f..c0b77997e 100644 --- a/backend/LexBoxApi/Services/DevGqlSchemaWriterService.cs +++ b/backend/LexBoxApi/Services/DevGqlSchemaWriterService.cs @@ -33,6 +33,7 @@ public static async Task GenerateGqlSchema(string[] args) .AddScoped() .AddScoped() .AddScoped() + .AddScoped() .AddScoped() .AddLexGraphQL(builder.Environment, true); var host = builder.Build(); diff --git a/backend/LexBoxApi/Services/FwHeadlessClient.cs b/backend/LexBoxApi/Services/FwHeadlessClient.cs new file mode 100644 index 000000000..48324f218 --- /dev/null +++ b/backend/LexBoxApi/Services/FwHeadlessClient.cs @@ -0,0 +1,19 @@ +using LexCore.Sync; + +namespace LexBoxApi.Services; + +public class FwHeadlessClient(HttpClient httpClient, ILogger logger) +{ + public async Task CrdtSync(Guid projectId) + { + var response = await httpClient.PostAsync($"/api/crdt-sync?projectId={projectId}", null); + if (response.IsSuccessStatusCode) + return await response.Content.ReadFromJsonAsync(); + logger.LogError("Failed to sync CRDT: {StatusCode} {StatusDescription}, projectId: {ProjectId}, response: {Response}", + response.StatusCode, + response.ReasonPhrase, + projectId, + await response.Content.ReadAsStringAsync()); + return null; + } +} diff --git a/backend/LexBoxApi/Services/UserService.cs b/backend/LexBoxApi/Services/UserService.cs index 277eba5a1..c7a476f64 100644 --- a/backend/LexBoxApi/Services/UserService.cs +++ b/backend/LexBoxApi/Services/UserService.cs @@ -1,13 +1,14 @@ using System.Net.Mail; -using LexBoxApi.Auth; using LexBoxApi.Services.Email; +using LexCore.Auth; +using LexCore.Entities; using LexCore.Exceptions; using LexData; using Microsoft.EntityFrameworkCore; namespace LexBoxApi.Services; -public class UserService(LexBoxDbContext dbContext, IEmailService emailService, LexAuthService lexAuthService) +public class UserService(LexBoxDbContext dbContext, IEmailService emailService) { public async Task ForgotPassword(string email) { @@ -83,4 +84,17 @@ public static (string name, string? email, string? username) ExtractNameAndAddre } return (name, email, username); } + + public IQueryable UserQueryForTypeahead(LexAuthUser user) + { + var myOrgIds = user.Orgs.Select(o => o.OrgId).ToList(); + var myProjectIds = user.Projects.Select(p => p.ProjectId).ToList(); + var myManagedProjectIds = user.Projects.Where(p => p.Role == ProjectRole.Manager).Select(p => p.ProjectId).ToList(); + return dbContext.Users.AsNoTracking().Where(u => + u.Id == user.Id || + u.Organizations.Any(orgMember => myOrgIds.Contains(orgMember.OrgId)) || + u.Projects.Any(projMember => + myManagedProjectIds.Contains(projMember.ProjectId) || + (projMember.Project != null && projMember.Project.IsConfidential != true && myProjectIds.Contains(projMember.ProjectId)))); + } } diff --git a/backend/LexBoxApi/appsettings.Development.json b/backend/LexBoxApi/appsettings.Development.json index 35f3ab4dc..1705db6f7 100644 --- a/backend/LexBoxApi/appsettings.Development.json +++ b/backend/LexBoxApi/appsettings.Development.json @@ -77,5 +77,12 @@ "From": "Lexbox ", "EmailRenderHost": "localhost:3000", "BaseUrl": "http://localhost:3000" + }, + "Services": { + "fwHeadless": { + "http": [ + "localhost:5275" + ] + } } } diff --git a/backend/LexBoxApi/appsettings.json b/backend/LexBoxApi/appsettings.json index 6f3e5ef2e..3ae6b1f17 100644 --- a/backend/LexBoxApi/appsettings.json +++ b/backend/LexBoxApi/appsettings.json @@ -70,5 +70,10 @@ }, "Email": { "CreateProjectEmailDestination": "lexbox_support@groups.sil.org" + }, + "Services": { + "fwHeadless": { + "http": ["fw-headless"] + } } } diff --git a/backend/LexBoxApi/dev.Dockerfile b/backend/LexBoxApi/dev.Dockerfile index bbedcbe09..77d9b6d5d 100644 --- a/backend/LexBoxApi/dev.Dockerfile +++ b/backend/LexBoxApi/dev.Dockerfile @@ -1,5 +1,5 @@ # syntax=docker/dockerfile:1 -FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build EXPOSE 80 EXPOSE 443 RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ @@ -9,13 +9,13 @@ RUN mkdir -p /var/www && chown -R www-data:www-data /var/www USER www-data:www-data WORKDIR /src/backend # Copy the main source project files -COPY */*.csproj *.sln ./ +COPY */*.csproj *.sln Directory.Build.props ./ # move them into the proper sub folders, based on the name of the project RUN for file in $(ls *.csproj); do dir=${file%.*}; mkdir -p ${dir}/ && mv -v $file ${dir}/; done # Do the same for csproj files in slightly different hierarchies COPY harmony/src/*/*.csproj ./ RUN for file in $(ls *.csproj); do dir=${file%.*}; mkdir -p harmony/src/${dir}/ && mv -v $file harmony/src/${dir}/; done -COPY harmony/src/Directory.Build.props ./harmony/src/ +COPY harmony/src/Directory.Build.props harmony/Directory.Packages.props ./harmony/src/ COPY FwLite/*/*.csproj ./ RUN for file in $(ls *.csproj); do dir=${file%.*}; mkdir -p FwLite/${dir}/ && mv -v $file FwLite/${dir}/; done diff --git a/backend/LexCore/LexCore.csproj b/backend/LexCore/LexCore.csproj index 276363f1a..db0930955 100644 --- a/backend/LexCore/LexCore.csproj +++ b/backend/LexCore/LexCore.csproj @@ -1,14 +1,10 @@ - net8.0 - enable - enable - dev - + diff --git a/backend/LexCore/Sync/SyncResult.cs b/backend/LexCore/Sync/SyncResult.cs new file mode 100644 index 000000000..70b7d0aa6 --- /dev/null +++ b/backend/LexCore/Sync/SyncResult.cs @@ -0,0 +1,3 @@ +namespace LexCore.Sync; + +public record SyncResult(int CrdtChanges, int FwdataChanges); diff --git a/backend/LexData/DataKernel.cs b/backend/LexData/DataKernel.cs index e12fc003c..25dc734ca 100644 --- a/backend/LexData/DataKernel.cs +++ b/backend/LexData/DataKernel.cs @@ -1,5 +1,6 @@ using LexData.Configuration; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; @@ -18,6 +19,9 @@ public static void AddLexData(this IServiceCollection services, options.EnableDetailedErrors(); options.UseNpgsql(serviceProvider.GetRequiredService>().Value.LexBoxConnectionString); options.UseProjectables(); + //todo remove this once this bug is fixed: https://github.com/dotnet/efcore/issues/35110 + //we ended up not upgrading to EF Core 9, so this was disabled for now, may or may not be needed in the future + // options.ConfigureWarnings(builder => builder.Ignore(RelationalEventId.PendingModelChangesWarning)); if (useOpenIddict) options.UseOpenIddict(); #if DEBUG options.EnableSensitiveDataLogging(); diff --git a/backend/LexData/LexData.csproj b/backend/LexData/LexData.csproj index 1ad6d57d9..65ede2fc7 100644 --- a/backend/LexData/LexData.csproj +++ b/backend/LexData/LexData.csproj @@ -1,28 +1,22 @@ - net8.0 - enable - enable - dev - + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - + + + + + - + diff --git a/backend/LexData/SeedingData.cs b/backend/LexData/SeedingData.cs index bb7e2fb44..553699e8b 100644 --- a/backend/LexData/SeedingData.cs +++ b/backend/LexData/SeedingData.cs @@ -156,6 +156,7 @@ private async Task SeedUserData(CancellationToken cancellationToken = default) Description = "Eastern Lawa project", Code = "elawa-dev-flex", Type = ProjectType.FLEx, + FlexProjectMetadata = new(), ProjectOrigin = ProjectMigrationStatus.Migrated, LastCommit = DateTimeOffset.UtcNow, RetentionPolicy = RetentionPolicy.Dev, diff --git a/backend/LfClassicData/LfClassicData.csproj b/backend/LfClassicData/LfClassicData.csproj index cbe4974fa..2a35852ac 100644 --- a/backend/LfClassicData/LfClassicData.csproj +++ b/backend/LfClassicData/LfClassicData.csproj @@ -1,15 +1,12 @@  - net8.0 - enable - enable - - + + diff --git a/backend/LfClassicData/LfClassicMiniLcmApi.cs b/backend/LfClassicData/LfClassicMiniLcmApi.cs index 25f9e2819..b2377bce6 100644 --- a/backend/LfClassicData/LfClassicMiniLcmApi.cs +++ b/backend/LfClassicData/LfClassicMiniLcmApi.cs @@ -206,7 +206,7 @@ private async IAsyncEnumerable Query(QueryOptions? options = null, string new BsonDocument("$ne", new BsonArray { new BsonDocument("$trim", new BsonDocument("input", $"$citationForm.{sortWs}.value")), "" }), }) }, - { "then", $"$citationForm.{sortWs}.value" }, + { "then", new BsonDocument("$toLower", $"$citationForm.{sortWs}.value") }, { "else", new BsonDocument("$cond", new BsonDocument { { "if", new BsonDocument("$and", new BsonArray @@ -216,7 +216,7 @@ private async IAsyncEnumerable Query(QueryOptions? options = null, string new BsonDocument("$ne", new BsonArray { new BsonDocument("$trim", new BsonDocument("input", $"$lexeme.{sortWs}.value")), "" }), }) }, - { "then", $"$lexeme.{sortWs}.value" }, + { "then", new BsonDocument("$toLower", $"$lexeme.{sortWs}.value") }, { "else", "" } }) } @@ -323,4 +323,15 @@ private static SemanticDomain ToSemanticDomain(Entities.OptionListItem item) if (sense is null) return null; return ToSense(entryId, sense); } + + public async Task GetExampleSentence(Guid entryId, Guid senseId, Guid id) + { + var entry = await Entries.Find(e => e.Guid == entryId).FirstOrDefaultAsync(); + if (entry is null) return null; + var sense = entry.Senses?.FirstOrDefault(s => s?.Guid == senseId); + if (sense is null) return null; + var exampleSentence = sense.Examples?.FirstOrDefault(e => e?.Guid == id); + if (exampleSentence is null) return null; + return ToExampleSentence(sense.Guid, exampleSentence); + } } diff --git a/backend/LfNext/LcmDebugger/LcmDebugger.csproj b/backend/LfNext/LcmDebugger/LcmDebugger.csproj index e47e15988..92e8860a6 100644 --- a/backend/LfNext/LcmDebugger/LcmDebugger.csproj +++ b/backend/LfNext/LcmDebugger/LcmDebugger.csproj @@ -2,9 +2,6 @@ Exe - net8.0 - enable - enable diff --git a/backend/SyncReverseProxy/Dockerfile b/backend/SyncReverseProxy/Dockerfile deleted file mode 100644 index 8c431359b..000000000 --- a/backend/SyncReverseProxy/Dockerfile +++ /dev/null @@ -1,20 +0,0 @@ -FROM mcr.microsoft.com/dotnet/aspnet:7.0 AS base -WORKDIR /app -EXPOSE 80 -EXPOSE 443 - -FROM mcr.microsoft.com/dotnet/sdk:7.0 AS build -WORKDIR /src -COPY ["SyncReverseProxy/SyncReverseProxy.csproj", "SyncReverseProxy/"] -RUN dotnet restore "SyncReverseProxy/SyncReverseProxy.csproj" -COPY . . -WORKDIR "/src/SyncReverseProxy" -RUN dotnet build "SyncReverseProxy.csproj" -c Release -o /app/build - -FROM build AS publish -RUN dotnet publish "SyncReverseProxy.csproj" -c Release -o /app/publish - -FROM base AS final -WORKDIR /app -COPY --from=publish /app/publish . -ENTRYPOINT ["dotnet", "SyncReverseProxy.dll"] diff --git a/backend/SyncReverseProxy/SyncReverseProxy.csproj b/backend/SyncReverseProxy/SyncReverseProxy.csproj index 0a58ba985..fe72988bf 100644 --- a/backend/SyncReverseProxy/SyncReverseProxy.csproj +++ b/backend/SyncReverseProxy/SyncReverseProxy.csproj @@ -1,27 +1,23 @@ - net8.0 - enable - enable Linux LexSyncReverseProxy - dev - - + + - - + + - - - + + + - + diff --git a/backend/Testing/ApiTests/ApiTestBase.cs b/backend/Testing/ApiTests/ApiTestBase.cs index 1f9901865..cbf22d856 100644 --- a/backend/Testing/ApiTests/ApiTestBase.cs +++ b/backend/Testing/ApiTests/ApiTestBase.cs @@ -1,10 +1,10 @@ using System.Diagnostics.CodeAnalysis; using System.Net.Http.Json; using System.Text.Json.Nodes; +using FluentAssertions; using LexCore.Auth; using Microsoft.Extensions.Http.Resilience; using Polly; -using Shouldly; using Testing.LexCore.Utils; using Testing.Services; @@ -67,14 +67,14 @@ public async Task ExecuteGql([StringSyntax("graphql")] string gql, b var response = await HttpClient.PostAsJsonAsync($"{BaseUrl}/api/graphql{jwtParam}", new { query = gql }); if (JwtHelper.TryGetJwtFromLoginResponse(response, out var jwt)) CurrJwt = jwt; var jsonResponse = await response.Content.ReadFromJsonAsync(); - jsonResponse.ShouldNotBeNull($"for query {gql} ({(int)response.StatusCode} ({response.ReasonPhrase}))"); + jsonResponse.Should().NotBeNull($"for query {gql} ({(int)response.StatusCode} ({response.ReasonPhrase}))"); GqlUtils.ValidateGqlErrors(jsonResponse, expectGqlError); if (expectSuccessCode) - response.IsSuccessStatusCode.ShouldBeTrue($"code was {(int)response.StatusCode} ({response.ReasonPhrase})"); + response.IsSuccessStatusCode.Should().BeTrue($"code was {(int)response.StatusCode} ({response.ReasonPhrase})"); return jsonResponse; } - public async Task GetProjectLastCommit(string projectCode) + public async Task GetProjectLastCommit(string projectCode) { var jsonResult = await ExecuteGql($$""" query projectLastCommit { @@ -83,8 +83,9 @@ query projectLastCommit { } } """); - var project = jsonResult?["data"]?["projectByCode"].ShouldBeOfType(); - return project?["lastCommit"]?.ToString(); + var project = jsonResult?["data"]?["projectByCode"].Should().BeOfType().Subject; + var stringDate = project?["lastCommit"]?.ToString(); + return stringDate == null ? null : DateTimeOffset.Parse(stringDate); } public async Task StartLexboxProjectReset(string projectCode) diff --git a/backend/Testing/ApiTests/AuthTests.cs b/backend/Testing/ApiTests/AuthTests.cs index 36fa74ae5..33a065e4f 100644 --- a/backend/Testing/ApiTests/AuthTests.cs +++ b/backend/Testing/ApiTests/AuthTests.cs @@ -7,7 +7,7 @@ using LexCore.Auth; using LexSyncReverseProxy; using LfClassicData; -using Shouldly; +using FluentAssertions; using Testing.Services; namespace Testing.ApiTests; @@ -22,16 +22,16 @@ public async Task TestLoginAndVerifyDifferentUsers() var managerResponse = await HttpClient.SendAsync(new HttpRequestMessage(HttpMethod.Get, $"{BaseUrl}/api/user/currentUser"), HttpCompletionOption.ResponseContentRead); - managerResponse.StatusCode.ShouldBe(HttpStatusCode.OK); + managerResponse.StatusCode.Should().Be(HttpStatusCode.OK); var manager = await managerResponse.Content.ReadFromJsonAsync(); - manager.GetProperty("email").GetString().ShouldBe("manager@test.com"); + manager.GetProperty("email").GetString().Should().Be("manager@test.com"); await LoginAs("admin", TestingEnvironmentVariables.DefaultPassword); var response = await HttpClient.SendAsync(new HttpRequestMessage(HttpMethod.Get, $"{BaseUrl}/api/user/currentUser"), HttpCompletionOption.ResponseContentRead); var admin = await response.Content.ReadFromJsonAsync(); - admin.GetProperty("email").GetString().ShouldBe("admin@test.com"); + admin.GetProperty("email").GetString().Should().Be("admin@test.com"); } [Fact] @@ -41,20 +41,20 @@ public async Task TestGqlVerifyDifferentUsers() var query = """query testGetMe { meAuth { id email }}"""; await LoginAs("manager", TestingEnvironmentVariables.DefaultPassword); var manager = await ExecuteGql(query); - manager.ShouldNotBeNull(); - manager["data"]!["meAuth"]!["email"]!.ToString().ShouldBe("manager@test.com"); + manager.Should().NotBeNull(); + manager["data"]!["meAuth"]!["email"]!.ToString().Should().Be("manager@test.com"); await LoginAs("admin", TestingEnvironmentVariables.DefaultPassword); var admin = await ExecuteGql(query); - admin.ShouldNotBeNull(); - admin["data"]!["meAuth"]!["email"]!.ToString().ShouldBe("admin@test.com"); + admin.Should().NotBeNull(); + admin["data"]!["meAuth"]!["email"]!.ToString().Should().Be("admin@test.com"); } [Fact] public async Task NotLoggedInIsNotPermittedToCallRequiresAuthApi() { var response = await HttpClient.GetAsync($"{BaseUrl}/api/AuthTesting/requires-auth"); - response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); } [Fact] @@ -70,7 +70,7 @@ public async Task ManagerIsForbiddenFromAdminApi() { await LoginAs("manager", TestingEnvironmentVariables.DefaultPassword); var response = await HttpClient.GetAsync($"{BaseUrl}/api/AuthTesting/requires-admin"); - response.StatusCode.ShouldBe(HttpStatusCode.Forbidden); + response.StatusCode.Should().Be(HttpStatusCode.Forbidden); } [Fact] @@ -86,11 +86,11 @@ public async Task NoOneCanCallForgotPasswordApi() { await LoginAs("manager", TestingEnvironmentVariables.DefaultPassword); var response = await HttpClient.GetAsync($"{BaseUrl}/api/AuthTesting/requires-forgot-password"); - response.StatusCode.ShouldBe(HttpStatusCode.Forbidden); + response.StatusCode.Should().Be(HttpStatusCode.Forbidden); await LoginAs("admin", TestingEnvironmentVariables.DefaultPassword); response = await HttpClient.GetAsync($"{BaseUrl}/api/AuthTesting/requires-forgot-password"); - response.StatusCode.ShouldBe(HttpStatusCode.Forbidden); + response.StatusCode.Should().Be(HttpStatusCode.Forbidden); } [Fact] @@ -99,7 +99,7 @@ public async Task ClearingCookiesWorks() await LoginAs("manager", TestingEnvironmentVariables.DefaultPassword); ClearCookies(); var response = await HttpClient.GetAsync($"{BaseUrl}/api/AuthTesting/requires-auth"); - response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); } [Fact(Skip = "Not working due to oauth, to solve we should setup a login via oauth to use the right jwt")] @@ -136,7 +136,7 @@ public async Task JwtWithInvalidSignatureFailsAuth() { Headers = { Authorization = new AuthenticationHeaderValue("Bearer", newJwt) } }); - response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); } //these must match because auth determines the project code from the route key using the method in HgHelpers @@ -144,6 +144,6 @@ public async Task JwtWithInvalidSignatureFailsAuth() [Fact] public void RouteKeyInLfClassicRoutesMustMatchRouteKeyInProxyConstants() { - LfClassicRoutes.ProjectCodeRouteKey.ShouldBe(ProxyConstants.HgProjectCodeRouteKey); + LfClassicRoutes.ProjectCodeRouteKey.Should().Be(ProxyConstants.HgProjectCodeRouteKey); } } diff --git a/backend/Testing/ApiTests/FlexJwtTests.cs b/backend/Testing/ApiTests/FlexJwtTests.cs index 4594590a1..3f3c82108 100644 --- a/backend/Testing/ApiTests/FlexJwtTests.cs +++ b/backend/Testing/ApiTests/FlexJwtTests.cs @@ -1,9 +1,7 @@ -using System.IdentityModel.Tokens.Jwt; -using System.Net.Http.Json; -using System.Security.Claims; +using System.Net.Http.Json; using System.Text.Json; using LexCore.Auth; -using Shouldly; +using FluentAssertions; using Testing.Services; namespace Testing.ApiTests; @@ -37,18 +35,18 @@ public async Task CanGetProjectSpecificToken() //intentionally not using the RefreshResponse class to make sure this test still fails if properties are renamed var json = await response.Content.ReadFromJsonAsync(); var projectToken = json.GetProperty("projectToken").GetString(); - projectToken.ShouldNotBeEmpty(); + projectToken.Should().NotBeNullOrEmpty(); var user = ParseUserToken(projectToken); - user.Projects.ShouldHaveSingleItem(); - user.Audience.ShouldBe(LexboxAudience.SendAndReceive); + user.Projects.Should().ContainSingle(); + user.Audience.Should().Be(LexboxAudience.SendAndReceive); var flexToken = json.GetProperty("flexToken").GetString(); - flexToken.ShouldNotBeEmpty(); + flexToken.Should().NotBeNullOrEmpty(); var flexUser = ParseUserToken(flexToken); - flexUser.Projects.ShouldBeEmpty(); - flexUser.Audience.ShouldBe(LexboxAudience.SendAndReceiveRefresh); + flexUser.Projects.Should().BeEmpty(); + flexUser.Audience.Should().Be(LexboxAudience.SendAndReceiveRefresh); - json.GetProperty("projectTokenExpiresAt").GetDateTime().ShouldNotBe(default); - json.GetProperty("flexTokenExpiresAt").GetDateTime().ShouldNotBe(default); + json.GetProperty("projectTokenExpiresAt").GetDateTime().Should().NotBe(default); + json.GetProperty("flexTokenExpiresAt").GetDateTime().Should().NotBe(default); } } diff --git a/backend/Testing/ApiTests/GqlMiddlewareTests.cs b/backend/Testing/ApiTests/GqlMiddlewareTests.cs index d6c0ed834..72074b694 100644 --- a/backend/Testing/ApiTests/GqlMiddlewareTests.cs +++ b/backend/Testing/ApiTests/GqlMiddlewareTests.cs @@ -1,6 +1,6 @@ using System.Text.Json.Nodes; using LexCore.Entities; -using Shouldly; +using FluentAssertions; using Testing.Fixtures; using static Testing.Services.Utils; @@ -80,11 +80,11 @@ await Task.WhenAll( // if the user is allowed to view all members var json = await QueryMyProjectsWithMembers(); - json.ShouldNotBeNull(); + json.Should().NotBeNull(); var myProjects = json["data"]!["myProjects"]!.AsArray(); var ids = myProjects.Select(p => p!["id"]!.GetValue()); - projects.Select(p => p.Id).ShouldBeSubsetOf(ids); + projects.Select(p => p.Id).Should().BeSubsetOf(ids); } [Fact] @@ -104,7 +104,7 @@ await _adminApiTester.ExecuteGql($$""" } } """, expectGqlError: true); // we're not a member yet - _adminApiTester.CurrJwt.ShouldBe(editorJwt); // token wasn't updated + _adminApiTester.CurrJwt.Should().Be(editorJwt); // token wasn't updated await AddMemberToProject(config, _adminApiTester, "editor", ProjectRole.Editor, _adminJwt); @@ -116,7 +116,7 @@ await _adminApiTester.ExecuteGql($$""" } } """, expectGqlError: true); // we're a member, but didn't query for users, so... - _adminApiTester.CurrJwt.ShouldBe(editorJwt); // token wasn't updated + _adminApiTester.CurrJwt.Should().Be(editorJwt); // token wasn't updated var response = await _adminApiTester.ExecuteGql($$""" query { @@ -129,6 +129,6 @@ await _adminApiTester.ExecuteGql($$""" } } """, expectGqlError: false); // we queried for users, so... - _adminApiTester.CurrJwt.ShouldNotBe(editorJwt); // token was updated + _adminApiTester.CurrJwt.Should().NotBe(editorJwt); // token was updated } } diff --git a/backend/Testing/ApiTests/HeaderTests.cs b/backend/Testing/ApiTests/HeaderTests.cs index 8c3bf2bfc..97648256e 100644 --- a/backend/Testing/ApiTests/HeaderTests.cs +++ b/backend/Testing/ApiTests/HeaderTests.cs @@ -1,5 +1,5 @@ using System.Net; -using Shouldly; +using FluentAssertions; namespace Testing.ApiTests; @@ -31,7 +31,7 @@ public async Task CheckCloudflareHeaderSizeLimit() if (response.StatusCode != HttpStatusCode.OK) failStatusCodes.Add(response.StatusCode); } - failStatusCodes.ShouldBeEmpty(); + failStatusCodes.Should().BeEmpty(); } private string RandomString(int length) diff --git a/backend/Testing/ApiTests/InvalidRouteTests.cs b/backend/Testing/ApiTests/InvalidRouteTests.cs index 6886ca88b..9894056d3 100644 --- a/backend/Testing/ApiTests/InvalidRouteTests.cs +++ b/backend/Testing/ApiTests/InvalidRouteTests.cs @@ -1,5 +1,5 @@ using System.Net; -using Shouldly; +using FluentAssertions; using Testing.Services; namespace Testing.ApiTests; @@ -11,13 +11,13 @@ public class InvalidRouteTests : ApiTestBase public async Task ApiPathRequestsShouldBeServedByDotnetForAnonymous() { var response = await HttpClient.GetAsync($"{BaseUrl}/api/login/not-exists"); - response.StatusCode.ShouldBe(HttpStatusCode.NotFound); + response.StatusCode.Should().Be(HttpStatusCode.NotFound); } [Fact] public async Task ApiBasePathRequestsShouldBeServedByDotnetForAuthenticated() { await LoginAs("manager", TestingEnvironmentVariables.DefaultPassword); var response = await HttpClient.GetAsync($"{BaseUrl}/api/login/not-exists"); - response.StatusCode.ShouldBe(HttpStatusCode.NotFound); + response.StatusCode.Should().Be(HttpStatusCode.NotFound); } } diff --git a/backend/Testing/ApiTests/NewProjectRaceCondition.cs b/backend/Testing/ApiTests/NewProjectRaceCondition.cs index 2eed7b63b..6dcd30899 100644 --- a/backend/Testing/ApiTests/NewProjectRaceCondition.cs +++ b/backend/Testing/ApiTests/NewProjectRaceCondition.cs @@ -1,5 +1,5 @@ using System.Text.Json.Nodes; -using Shouldly; +using FluentAssertions; using Testing.Services; namespace Testing.ApiTests; @@ -47,12 +47,17 @@ private async Task CreateQueryAndVerifyProject(Guid id) createProjectResponse { id } + errors { + ... on Error { + message + } + } } } """); - var project = response["data"]!["createProject"]!["createProjectResponse"].ShouldBeOfType(); - project["id"]!.GetValue().ShouldBe(id.ToString()); + var project = response["data"]!["createProject"]!["createProjectResponse"].Should().BeOfType().Subject; + project["id"]!.GetValue().Should().Be(id.ToString()); // Query a 2nd time to ensure the instability of new repos isn't causing trouble response = await ExecuteGql($$""" @@ -66,7 +71,7 @@ private async Task CreateQueryAndVerifyProject(Guid id) } """); - project = response["data"]!["projectByCode"].ShouldBeOfType(); - project["name"]!.GetValue().ShouldBe(name); + project = response["data"]!["projectByCode"].Should().BeOfType().Subject; + project["name"]!.GetValue().Should().Be(name); } } diff --git a/backend/Testing/ApiTests/OrgPermissionTests.cs b/backend/Testing/ApiTests/OrgPermissionTests.cs index fabeab52a..dc8b59873 100644 --- a/backend/Testing/ApiTests/OrgPermissionTests.cs +++ b/backend/Testing/ApiTests/OrgPermissionTests.cs @@ -1,6 +1,6 @@ using System.Text.Json.Nodes; using LexData; -using Shouldly; +using FluentAssertions; namespace Testing.ApiTests; @@ -37,42 +37,42 @@ private async Task QueryOrg(Guid orgId) private static JsonObject GetOrg(JsonObject json) { var org = json["data"]?["orgById"]?.AsObject(); - org.ShouldNotBeNull(); + org.Should().NotBeNull(); return org; } private void MustHaveOneMemberWithEmail(JsonNode org) { org["members"]!.AsArray().Where(m => m?["user"]?["email"]?.GetValue() is { Length: > 0 }) - .ShouldNotBeEmpty(); + .Should().NotBeNullOrEmpty(); } private void MustNotHaveMemberWithEmail(JsonNode org) { org["members"]!.AsArray().Where(m => m?["user"]?["email"]?.GetValue() is { Length: > 0 }) - .ShouldBeEmpty(); + .Should().BeEmpty(); } private void MustHaveOneMemberWithUsername(JsonNode org) { org["members"]!.AsArray().Where(m => m?["user"]?["username"]?.GetValue() is { Length: > 0 }) - .ShouldNotBeEmpty(); + .Should().NotBeNullOrEmpty(); } private void MustNotHaveMemberWithUsername(JsonNode org) { org["members"]!.AsArray().Where(m => m?["user"]?["username"]?.GetValue() is { Length: > 0 }) - .ShouldBeEmpty(); + .Should().BeEmpty(); } private void MustHaveUserNames(JsonNode org) { org["members"]!.AsArray() .Where(m => m?["user"]?["name"]?.GetValue() is { Length: > 0 }) - .ShouldNotBeEmpty(); + .Should().NotBeNullOrEmpty(); } private void MustContainUser(JsonNode org, Guid id) { - org["members"]!.AsArray().ShouldContain( + org["members"]!.AsArray().Should().Contain( m => m!["user"]!["id"]!.GetValue() == id, $"org: '{org["name"]}' members were: {org["members"]!.ToJsonString()}"); } @@ -81,14 +81,14 @@ private void MustHaveOnlyManagers(JsonNode org) { org["members"]!.AsArray() .Where(m => m?["role"]?.GetValue() is not "ADMIN") - .ShouldBeEmpty(); + .Should().BeEmpty(); } private void MustHaveNonManagers(JsonNode org) { org["members"]!.AsArray() .Where(m => m?["role"]?.GetValue() is not "ADMIN") - .ShouldNotBeEmpty(); + .Should().NotBeNullOrEmpty(); } [Fact] @@ -113,8 +113,8 @@ public async Task CanNotListOrgsAndListOrgUsers() """, true, false); var error = json["errors"]?.AsArray().First()?.AsObject(); - error.ShouldNotBeNull(); - error["extensions"]?["code"]?.GetValue().ShouldBe("AUTH_NOT_AUTHORIZED"); + error.Should().NotBeNull(); + error["extensions"]?["code"]?.GetValue().Should().Be("AUTH_NOT_AUTHORIZED"); } [Fact] @@ -134,8 +134,8 @@ public async Task CanNotListOrgsAndListOrgProjects() """, true, false); var error = json["errors"]?.AsArray().First()?.AsObject(); - error.ShouldNotBeNull(); - error["extensions"]?["code"]?.GetValue().ShouldBe("AUTH_NOT_AUTHORIZED"); + error.Should().NotBeNull(); + error["extensions"]?["code"]?.GetValue().Should().Be("AUTH_NOT_AUTHORIZED"); } [Fact] @@ -167,7 +167,7 @@ public async Task OrgMemberCanSeeThemselvesInOrg() { await LoginAs("editor"); var org = GetOrg(await QueryOrg(SeedingData.TestOrgId)); - org.ShouldNotBeNull(); + org.Should().NotBeNull(); MustContainUser(org, SeedingData.EditorId); } @@ -176,7 +176,7 @@ public async Task OrgMemberCanNotSeeMemberEmails() { await LoginAs("editor"); var org = GetOrg(await QueryOrg(SeedingData.TestOrgId)); - org.ShouldNotBeNull(); + org.Should().NotBeNull(); MustHaveUserNames(org); MustNotHaveMemberWithEmail(org); } @@ -186,7 +186,7 @@ public async Task OrgMemberCanNotSeeMemberUsernames() { await LoginAs("editor"); var org = GetOrg(await QueryOrg(SeedingData.TestOrgId)); - org.ShouldNotBeNull(); + org.Should().NotBeNull(); MustHaveUserNames(org); MustNotHaveMemberWithUsername(org); } @@ -203,24 +203,24 @@ public async Task NonMemberCanOnlyQueryManagers() private void MustNotShowConfidentialProjects(JsonNode org) { var projects = org["projects"]!.AsArray(); - projects.ShouldNotBeEmpty(); + projects.Should().NotBeNullOrEmpty(); projects .Where(p => p?["isConfidential"]?.GetValue() != false) - .ShouldBeEmpty(); + .Should().BeEmpty(); } private void MustContainProject(JsonNode org, Guid projectId) { var projects = org["projects"]!.AsArray(); - projects.ShouldNotBeEmpty(); - projects.ShouldContain(p => p!["id"]!.GetValue() == projectId, $"project id '{projectId}' should exist in: {projects.ToJsonString()}"); + projects.Should().NotBeNullOrEmpty(); + projects.Should().Contain(p => p!["id"]!.GetValue() == projectId, $"project id '{projectId}' should exist in: {projects.ToJsonString()}"); } private void MustNotContainProject(JsonNode org, Guid projectId) { var projects = org["projects"]!.AsArray(); if ((projects?.Count ?? 0) == 0) return; - projects!.ShouldNotContain(p => p!["id"]!.GetValue() == projectId, $"project id '{projectId}' should not exist in: {projects!.ToJsonString()}"); + projects!.Should().NotContain(p => p!["id"]!.GetValue() == projectId, $"project id '{projectId}' should not exist in: {projects!.ToJsonString()}"); } [Fact] diff --git a/backend/Testing/ApiTests/ProjectPermissionTests.cs b/backend/Testing/ApiTests/ProjectPermissionTests.cs index 36f781ffc..4a0bf5c3b 100644 --- a/backend/Testing/ApiTests/ProjectPermissionTests.cs +++ b/backend/Testing/ApiTests/ProjectPermissionTests.cs @@ -1,5 +1,5 @@ using System.Text.Json.Nodes; -using Shouldly; +using FluentAssertions; using Testing.Services; namespace Testing.ApiTests; @@ -56,27 +56,27 @@ ... on Error { private JsonObject GetProject(JsonObject json) { var project = json["data"]!["projectByCode"]?.AsObject(); - project.ShouldNotBeNull(); + project.Should().NotBeNull(); return project; } private void MustHaveMembers(JsonObject project, int? count = null) { var members = project["users"]!.AsArray(); - members.ShouldNotBeNull().ShouldNotBeEmpty(); - if (count is not null) members.Count.ShouldBe(count.Value); + members.Should().NotBeNullOrEmpty(); + if (count is not null) members.Count.Should().Be(count.Value); } private void MustNotHaveMembers(JsonObject project) { var users = project["users"]!.AsArray(); - users.ShouldBeEmpty(); + users.Should().BeEmpty(); } private void MustHaveOnlyUserAsMember(JsonObject project, Guid userId) { var users = project["users"]!.AsArray(); - users.ShouldContain(node => node!["user"]!["id"]!.GetValue() == userId, + users.Should().Contain(node => node!["user"]!["id"]!.GetValue() == userId, "user list " + users.ToJsonString()); } @@ -132,7 +132,7 @@ public async Task ConfidentialProject_NonMemberCannotSeeProject() await LoginAs("user"); var json = await QueryProject(project.Code, expectGqlError: true); var error = json["errors"]!.AsArray().First()?.AsObject(); - error.ShouldNotBeNull(); - error["extensions"]?["code"]?.GetValue().ShouldBe("AUTH_NOT_AUTHORIZED"); + error.Should().NotBeNull(); + error["extensions"]?["code"]?.GetValue().Should().Be("AUTH_NOT_AUTHORIZED"); } } diff --git a/backend/Testing/ApiTests/ResetProjectRaceConditions.cs b/backend/Testing/ApiTests/ResetProjectRaceConditions.cs index 358d96790..cffb0ae1c 100644 --- a/backend/Testing/ApiTests/ResetProjectRaceConditions.cs +++ b/backend/Testing/ApiTests/ResetProjectRaceConditions.cs @@ -1,4 +1,4 @@ -using Shouldly; +using FluentAssertions; using Testing.Fixtures; using static Testing.Services.Utils; @@ -41,9 +41,9 @@ public async Task SimultaneousResetsDontResultIn404s() var lastCommitBefore2 = await _adminApiTester.GetProjectLastCommit(config2.Code); var lastCommitBefore3 = await _adminApiTester.GetProjectLastCommit(config3.Code); - lastCommitBefore1.ShouldBeNullOrWhiteSpace(); - lastCommitBefore2.ShouldBeNullOrWhiteSpace(); - lastCommitBefore3.ShouldBeNullOrWhiteSpace(); + lastCommitBefore1.Should().BeNull(); + lastCommitBefore2.Should().BeNull(); + lastCommitBefore3.Should().BeNull(); // Reset and fill projects on server var newLastCommits = await Task.WhenAll( @@ -52,9 +52,9 @@ public async Task SimultaneousResetsDontResultIn404s() DoFullProjectResetAndVerifyLastCommit(config3.Code) ); - newLastCommits[0].ShouldNotBeNullOrWhiteSpace(); - newLastCommits[0].ShouldBe(newLastCommits[1]); - newLastCommits[0].ShouldBe(newLastCommits[2]); + newLastCommits[0].Should().NotBeNull(); + newLastCommits[0].Should().Be(newLastCommits[1]); + newLastCommits[0].Should().Be(newLastCommits[2]); // we need a short delay between resets or we'll get naming collisions on the backups of the reset projects await Task.Delay(1000); @@ -68,15 +68,15 @@ public async Task SimultaneousResetsDontResultIn404s() ); } - private async Task DoFullProjectResetAndVerifyLastCommit(string projectCode, string? expectedLastCommit = null) + private async Task DoFullProjectResetAndVerifyLastCommit(string projectCode, DateTimeOffset? expectedLastCommit = null) { await _adminApiTester.StartLexboxProjectReset(projectCode); var lastCommitBefore = await _adminApiTester.GetProjectLastCommit(projectCode); - lastCommitBefore.ShouldBeNullOrWhiteSpace(); + lastCommitBefore.Should().BeNull(); await _fixture.FinishLexboxProjectResetWithTemplateRepo(projectCode); var lastCommit = await _adminApiTester.GetProjectLastCommit(projectCode); - if (expectedLastCommit is not null) lastCommit.ShouldBe(expectedLastCommit); - else lastCommit.ShouldNotBeNullOrWhiteSpace(); + if (expectedLastCommit is not null) lastCommit.Should().Be(expectedLastCommit); + else lastCommit.Should().NotBeNull(); return lastCommit; } } diff --git a/backend/Testing/ApiTests/UsersICanSeeQueryTests.cs b/backend/Testing/ApiTests/UsersICanSeeQueryTests.cs new file mode 100644 index 000000000..e5deaa51c --- /dev/null +++ b/backend/Testing/ApiTests/UsersICanSeeQueryTests.cs @@ -0,0 +1,98 @@ +using System.Text.Json.Nodes; +using FluentAssertions; +using Testing.Services; + +namespace Testing.ApiTests; + +[Trait("Category", "Integration")] +public class UsersICanSeeQueryTests : ApiTestBase +{ + private async Task QueryUsersICanSee(bool expectGqlError = false) + { + var json = await ExecuteGql( + $$""" + query { + usersICanSee(take: 10) { + totalCount + items { + id + name + } + } + } + """, + expectGqlError, expectSuccessCode: false); + return json; + } + + private async Task AddUserToProject(Guid projectId, string username) + { + await ExecuteGql( + $$""" + mutation { + addProjectMember(input: { + projectId: "{{projectId}}", + usernameOrEmail: "{{username}}", + role: EDITOR, + canInvite: false + }) { + project { + id + } + errors { + __typename + ... on Error { + message + } + } + } + } + """); + } + + private JsonArray GetUsers(JsonObject json) + { + var users = json["data"]!["usersICanSee"]!["items"]!.AsArray(); + users.Should().NotBeNull(); + return users; + } + + private void MustHaveUser(JsonArray users, string userName) + { + users.Should().NotBeNull().And.NotBeEmpty(); + users.Should().Contain(node => node!["name"]!.GetValue() == userName, + "user list " + users.ToJsonString()); + } + + private void MustNotHaveUser(JsonArray users, string userName) + { + users.Should().NotBeNull().And.NotBeEmpty(); + users.Should().NotContain(node => node!["name"]!.GetValue() == userName, + "user list " + users.ToJsonString()); + } + + [Fact] + public async Task ManagerCanSeeProjectMembersOfAllProjects() + { + await LoginAs("manager"); + await using var project = await this.RegisterProjectInLexBox(Utils.GetNewProjectConfig(isConfidential: true)); + //refresh jwt + await LoginAs("manager"); + await AddUserToProject(project.Id, "qa@test.com"); + var json = GetUsers(await QueryUsersICanSee()); + MustHaveUser(json, "Qa Admin"); + } + + [Fact] + public async Task MemberCanSeeNotProjectMembersOfConfidentialProjects() + { + await LoginAs("manager"); + await using var project = await this.RegisterProjectInLexBox(Utils.GetNewProjectConfig(isConfidential: true)); + //refresh jwt + await LoginAs("manager"); + await AddUserToProject(project.Id, "qa@test.com"); + await LoginAs("editor"); + var json = GetUsers(await QueryUsersICanSee()); + MustNotHaveUser(json, "Qa Admin"); + } +} diff --git a/backend/Testing/Fixtures/IntegrationFixture.cs b/backend/Testing/Fixtures/IntegrationFixture.cs index df951bb8d..77a1f282a 100644 --- a/backend/Testing/Fixtures/IntegrationFixture.cs +++ b/backend/Testing/Fixtures/IntegrationFixture.cs @@ -1,7 +1,7 @@ using System.IO.Compression; using System.Runtime.CompilerServices; using LexCore.Utils; -using Shouldly; +using FluentAssertions; using Squidex.Assets; using Testing.ApiTests; using Testing.Services; @@ -16,7 +16,7 @@ public class IntegrationFixture : IAsyncLifetime public static readonly DirectoryInfo TemplateRepo = new(Path.Join(BasePath, "_template-repo_")); public ApiTestBase AdminApiTester { get; private set; } = new(); private string? _adminJwt = null; - public string AdminJwt => _adminJwt.ShouldNotBeNull(); + public string AdminJwt => _adminJwt.Should().NotBeNull().And.Subject; static IntegrationFixture() { @@ -69,7 +69,7 @@ public void InitLocalFlexProjectWithRepo(ProjectPath projectPath) var projectDir = Directory.CreateDirectory(projectPath.Dir); FileUtils.CopyFilesRecursively(TemplateRepo, projectDir); File.Move(Path.Join(projectPath.Dir, "kevin-test-01.fwdata"), projectPath.FwDataFile); - Directory.EnumerateFiles(projectPath.Dir).ShouldContain(projectPath.FwDataFile); + Directory.EnumerateFiles(projectPath.Dir).Should().Contain(projectPath.FwDataFile); } public async Task FinishLexboxProjectResetWithTemplateRepo(string projectCode) diff --git a/backend/Testing/Fixtures/IntegrationFixtureTests.cs b/backend/Testing/Fixtures/IntegrationFixtureTests.cs index 18697e956..d606fbacc 100644 --- a/backend/Testing/Fixtures/IntegrationFixtureTests.cs +++ b/backend/Testing/Fixtures/IntegrationFixtureTests.cs @@ -1,5 +1,5 @@ using Moq; -using Shouldly; +using FluentAssertions; using Testing.ApiTests; namespace Testing.Fixtures; @@ -18,7 +18,7 @@ public async Task InitCreatesARepoWithTheProject() await fixture.InitializeAsync(Mock.Of()); IntegrationFixture.TemplateRepo.EnumerateFiles() .Select(f => f.Name) - .ShouldContain("kevin-test-01.fwdata"); + .Should().Contain("kevin-test-01.fwdata"); } [Fact] @@ -27,7 +27,7 @@ public async Task CanFindTheProjectZipFile() await fixture.InitializeAsync(Mock.Of()); IntegrationFixture.TemplateRepoZip .Directory!.EnumerateFiles().Select(f => f.Name) - .ShouldContain(IntegrationFixture.TemplateRepoZip.Name); + .Should().Contain(IntegrationFixture.TemplateRepoZip.Name); } [Fact] @@ -36,6 +36,6 @@ public async Task CanInitFlexProjectRepo() await fixture.InitializeAsync(Mock.Of()); var projectConfig = fixture.InitLocalFlexProjectWithRepo(); Directory.EnumerateFiles(projectConfig.Dir) - .ShouldContain(projectConfig.FwDataFile); + .Should().Contain(projectConfig.FwDataFile); } } diff --git a/backend/Testing/Fixtures/TempProjectWithoutRepo.cs b/backend/Testing/Fixtures/TempProjectWithoutRepo.cs new file mode 100644 index 000000000..81c5aa702 --- /dev/null +++ b/backend/Testing/Fixtures/TempProjectWithoutRepo.cs @@ -0,0 +1,39 @@ +using LexCore.Entities; +using LexData; +using Testing.Services; + +namespace Testing.Fixtures; + +public class TempProjectWithoutRepo(LexBoxDbContext dbContext, Project project) : IAsyncDisposable +{ + public Project Project => project; + public static async Task Create(LexBoxDbContext dbContext, bool isConfidential = false, Guid? managerId = null) + { + var config = Utils.GetNewProjectConfig(isConfidential: isConfidential); + var project = new Project + { + Name = config.Name, + Code = config.Code, + IsConfidential = config.IsConfidential, + LastCommit = null, + Organizations = [], + Users = [], + RetentionPolicy = RetentionPolicy.Test, + Type = ProjectType.FLEx, + Id = config.Id, + }; + if (managerId is Guid id) + { + project.Users.Add(new ProjectUsers { ProjectId = project.Id, UserId = id, Role = ProjectRole.Manager }); + } + dbContext.Add(project); + await dbContext.SaveChangesAsync(); + return new TempProjectWithoutRepo(dbContext, project); + } + + public async ValueTask DisposeAsync() + { + dbContext.Remove(project); + await dbContext.SaveChangesAsync(); + } +} diff --git a/backend/Testing/Fixtures/Tests/ServicesFixtureTests.cs b/backend/Testing/Fixtures/Tests/ServicesFixtureTests.cs index 519c396f8..8369b562b 100644 --- a/backend/Testing/Fixtures/Tests/ServicesFixtureTests.cs +++ b/backend/Testing/Fixtures/Tests/ServicesFixtureTests.cs @@ -1,4 +1,4 @@ -using Shouldly; +using FluentAssertions; namespace Testing.Fixtures.Tests; @@ -13,6 +13,6 @@ public async Task CanSetupServices() await fixture.InitializeAsync(); await fixture.DisposeAsync(); }; - Should.CompleteIn(act, TimeSpan.FromSeconds(10)); + await act.Should().CompleteWithinAsync(TimeSpan.FromSeconds(10)); } } diff --git a/backend/Testing/GraphQL/LexAuthUserOutOfSyncExtensionsTests.cs b/backend/Testing/GraphQL/LexAuthUserOutOfSyncExtensionsTests.cs index b8996ea79..3df103e75 100644 --- a/backend/Testing/GraphQL/LexAuthUserOutOfSyncExtensionsTests.cs +++ b/backend/Testing/GraphQL/LexAuthUserOutOfSyncExtensionsTests.cs @@ -1,7 +1,7 @@ using LexBoxApi.GraphQL; using LexCore.Auth; using LexCore.Entities; -using Shouldly; +using FluentAssertions; namespace Testing.GraphQL; @@ -20,10 +20,10 @@ public class LexAuthUserOutOfSyncExtensionsTests public void DetectsUserAddedToProject() { var project = NewProject(); - user.IsOutOfSyncWithProject(project).ShouldBeFalse(); + user.IsOutOfSyncWithProject(project).Should().BeFalse(); project.Users.Add(new() { UserId = user.Id, Role = ProjectRole.Editor }); - user.IsOutOfSyncWithProject(project).ShouldBeTrue(); + user.IsOutOfSyncWithProject(project).Should().BeTrue(); } [Fact] @@ -32,10 +32,10 @@ public void DetectsUserRemovedFromProject() var project = NewProject(); project.Users.Add(new() { UserId = user.Id, Role = ProjectRole.Editor }); var editorUser = user with { Projects = [new AuthUserProject(ProjectRole.Editor, project.Id)] }; - editorUser.IsOutOfSyncWithProject(project).ShouldBeFalse(); + editorUser.IsOutOfSyncWithProject(project).Should().BeFalse(); project.Users.Clear(); - editorUser.IsOutOfSyncWithProject(project).ShouldBeTrue(); + editorUser.IsOutOfSyncWithProject(project).Should().BeTrue(); } [Fact] @@ -45,10 +45,10 @@ public void DetectsUserProjectRoleChanged() var projectUser = new ProjectUsers { UserId = user.Id, Role = ProjectRole.Editor }; project.Users.Add(projectUser); var editorUser = user with { Projects = [new AuthUserProject(ProjectRole.Editor, project.Id)] }; - editorUser.IsOutOfSyncWithProject(project).ShouldBeFalse(); + editorUser.IsOutOfSyncWithProject(project).Should().BeFalse(); projectUser.Role = ProjectRole.Manager; - editorUser.IsOutOfSyncWithProject(project).ShouldBeTrue(); + editorUser.IsOutOfSyncWithProject(project).Should().BeTrue(); } [Fact] @@ -58,7 +58,7 @@ public void DoesNotDetectsUserProjectRoleChangedIfRolesNotAvailable() var projectUser = new ProjectUsers { UserId = user.Id, Role = ProjectRole.Unknown }; project.Users.Add(projectUser); var editorUser = user with { Projects = [new AuthUserProject(ProjectRole.Editor, project.Id)] }; - editorUser.IsOutOfSyncWithProject(project).ShouldBeFalse(); // might be out of sync, but we can't tell + editorUser.IsOutOfSyncWithProject(project).Should().BeFalse(); // might be out of sync, but we can't tell } [Fact] @@ -69,7 +69,7 @@ public void DoesNotDetectChangesWithoutProjectUsersIfNotMyProject() project.Users = null!; var editorUser = user with { Projects = [new AuthUserProject(ProjectRole.Editor, project.Id)] }; - editorUser.IsOutOfSyncWithProject(project).ShouldBeFalse(); // might be out of sync, but we can't tell + editorUser.IsOutOfSyncWithProject(project).Should().BeFalse(); // might be out of sync, but we can't tell } [Fact] @@ -79,9 +79,9 @@ public void DetectsAddedToMyProjectWithoutProjectUsers() // simulate Users not projected in GQL query project.Users = null!; - user.IsOutOfSyncWithProject(project).ShouldBeFalse(); // might be out of sync, but we can't tell - user.IsOutOfSyncWithProject(project, isMyProject: true).ShouldBeTrue(); - user.IsOutOfSyncWithMyProjects([project]).ShouldBeTrue(); + user.IsOutOfSyncWithProject(project).Should().BeFalse(); // might be out of sync, but we can't tell + user.IsOutOfSyncWithProject(project, isMyProject: true).Should().BeTrue(); + user.IsOutOfSyncWithMyProjects([project]).Should().BeTrue(); } [Fact] @@ -92,18 +92,18 @@ public void DetectsRemovedFromMyProjectWithoutProjectUsers() project.Users = null!; var editorUser = user with { Projects = [new AuthUserProject(ProjectRole.Editor, project.Id)] }; - editorUser.IsOutOfSyncWithProject(project).ShouldBeFalse(); // might be out of sync, but we can't tell - editorUser.IsOutOfSyncWithMyProjects([]).ShouldBeTrue(); + editorUser.IsOutOfSyncWithProject(project).Should().BeFalse(); // might be out of sync, but we can't tell + editorUser.IsOutOfSyncWithMyProjects([]).Should().BeTrue(); } [Fact] public void DetectsUserAddedToOrg() { var org = NewOrg(); - user.IsOutOfSyncWithOrg(org).ShouldBeFalse(); + user.IsOutOfSyncWithOrg(org).Should().BeFalse(); org.Members.Add(new() { UserId = user.Id, Role = OrgRole.User }); - user.IsOutOfSyncWithOrg(org).ShouldBeTrue(); + user.IsOutOfSyncWithOrg(org).Should().BeTrue(); } [Fact] @@ -112,10 +112,10 @@ public void DetectsUserRemovedFromOrg() var org = NewOrg(); org.Members.Add(new() { UserId = user.Id, Role = OrgRole.User }); var editorUser = user with { Orgs = [new AuthUserOrg(OrgRole.User, org.Id)] }; - editorUser.IsOutOfSyncWithOrg(org).ShouldBeFalse(); + editorUser.IsOutOfSyncWithOrg(org).Should().BeFalse(); org.Members.Clear(); - editorUser.IsOutOfSyncWithOrg(org).ShouldBeTrue(); + editorUser.IsOutOfSyncWithOrg(org).Should().BeTrue(); } [Fact] @@ -125,10 +125,10 @@ public void DetectsUserOrgRoleChanged() var orgUser = new OrgMember { UserId = user.Id, Role = OrgRole.User }; org.Members.Add(orgUser); var editorUser = user with { Orgs = [new AuthUserOrg(OrgRole.User, org.Id)] }; - editorUser.IsOutOfSyncWithOrg(org).ShouldBeFalse(); + editorUser.IsOutOfSyncWithOrg(org).Should().BeFalse(); orgUser.Role = OrgRole.Admin; - editorUser.IsOutOfSyncWithOrg(org).ShouldBeTrue(); + editorUser.IsOutOfSyncWithOrg(org).Should().BeTrue(); } [Fact] @@ -138,7 +138,7 @@ public void DoesNotDetectsUserOrgRoleChangedIfRolesNotAvailable() var orgUser = new OrgMember { UserId = user.Id, Role = OrgRole.Unknown }; org.Members.Add(orgUser); var editorUser = user with { Orgs = [new AuthUserOrg(OrgRole.User, org.Id)] }; - editorUser.IsOutOfSyncWithOrg(org).ShouldBeFalse(); // might be out of sync, but we can't tell + editorUser.IsOutOfSyncWithOrg(org).Should().BeFalse(); // might be out of sync, but we can't tell } [Fact] @@ -147,10 +147,10 @@ public void DetectsChangesWithOrgProjects() var org = NewOrg(); var project = NewProject(); org.Projects = [project]; - user.IsOutOfSyncWithOrg(org).ShouldBeFalse(); + user.IsOutOfSyncWithOrg(org).Should().BeFalse(); project.Users.Add(new() { UserId = user.Id, Role = ProjectRole.Editor }); - user.IsOutOfSyncWithOrg(org).ShouldBeTrue(); + user.IsOutOfSyncWithOrg(org).Should().BeTrue(); } [Fact] @@ -161,7 +161,7 @@ public void DoesNotDetectChangesWithoutOrgMembersIfNotMyOrg() org.Members = null!; var editorUser = user with { Orgs = [new AuthUserOrg(OrgRole.User, org.Id)] }; - editorUser.IsOutOfSyncWithOrg(org).ShouldBeFalse(); // might be out of sync, but we can't tell + editorUser.IsOutOfSyncWithOrg(org).Should().BeFalse(); // might be out of sync, but we can't tell } [Fact] @@ -171,9 +171,9 @@ public void DetectsAddedToMyOrgWithoutOrgMembers() // simulate Members not projected in GQL query org.Members = null!; - user.IsOutOfSyncWithOrg(org).ShouldBeFalse(); // might be out of sync, but we can't tell - user.IsOutOfSyncWithOrg(org, isMyOrg: true).ShouldBeTrue(); - user.IsOutOfSyncWithMyOrgs([org]).ShouldBeTrue(); + user.IsOutOfSyncWithOrg(org).Should().BeFalse(); // might be out of sync, but we can't tell + user.IsOutOfSyncWithOrg(org, isMyOrg: true).Should().BeTrue(); + user.IsOutOfSyncWithMyOrgs([org]).Should().BeTrue(); } [Fact] @@ -184,8 +184,8 @@ public void DetectsRemovedFromMyOrgWithoutOrgMembers() org.Members = null!; var editorUser = user with { Orgs = [new AuthUserOrg(OrgRole.User, org.Id)] }; - editorUser.IsOutOfSyncWithOrg(org).ShouldBeFalse(); // might be out of sync, but we can't tell - editorUser.IsOutOfSyncWithMyOrgs([]).ShouldBeTrue(); + editorUser.IsOutOfSyncWithOrg(org).Should().BeFalse(); // might be out of sync, but we can't tell + editorUser.IsOutOfSyncWithMyOrgs([]).Should().BeTrue(); } private static Project NewProject() diff --git a/backend/Testing/LexCore/CrdtServerCommitTests.cs b/backend/Testing/LexCore/CrdtServerCommitTests.cs index 4deff4c38..31ed6c747 100644 --- a/backend/Testing/LexCore/CrdtServerCommitTests.cs +++ b/backend/Testing/LexCore/CrdtServerCommitTests.cs @@ -4,7 +4,7 @@ using LexData.Entities; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; -using Shouldly; +using FluentAssertions; using Testing.Fixtures; namespace Testing.LexCore; @@ -71,8 +71,8 @@ public async Task CanRoundTripCommitChanges() await _dbContext.SaveChangesAsync(); var actualCommit = await _dbContext.Set().AsNoTracking().FirstAsync(c => c.Id == commitId); - actualCommit.ShouldNotBeSameAs(expectedCommit); - JsonSerializer.Serialize(actualCommit.ChangeEntities[0].Change).ShouldBe(changeJson); + actualCommit.Should().NotBeSameAs(expectedCommit); + JsonSerializer.Serialize(actualCommit.ChangeEntities[0].Change).Should().Be(changeJson); } [Fact] @@ -80,6 +80,6 @@ public void TypePropertyShouldAlwaysBeFirst() { var changeJson = """{"name":"Joe","$type":"test"}"""; var jsonChange = JsonSerializer.Deserialize(changeJson); - JsonSerializer.Serialize(jsonChange).ShouldBe("""{"$type":"test","name":"Joe"}"""); + JsonSerializer.Serialize(jsonChange).Should().Be("""{"$type":"test","name":"Joe"}"""); } } diff --git a/backend/Testing/LexCore/LexAuthUserTests.cs b/backend/Testing/LexCore/LexAuthUserTests.cs index 967db437a..ed919e901 100644 --- a/backend/Testing/LexCore/LexAuthUserTests.cs +++ b/backend/Testing/LexCore/LexAuthUserTests.cs @@ -12,7 +12,8 @@ using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Logging; using Microsoft.IdentityModel.Tokens; -using Shouldly; +using FluentAssertions; +using FluentAssertions.Execution; namespace Testing.LexCore; @@ -58,12 +59,13 @@ public void CanGetClaimsFromUser() var emailClaim = new Claim(LexAuthConstants.EmailClaimType, _user.Email); var roleClaim = new Claim(LexAuthConstants.RoleClaimType, _user.Role.ToString()); var projectClaim = new Claim("proj", _user.ProjectsJson); - claims.ShouldSatisfyAllConditions( - () => claims.ShouldContain(idClaim.ToString()), - () => claims.ShouldContain(emailClaim.ToString()), - () => claims.ShouldContain(roleClaim.ToString()), - () => claims.ShouldContain(projectClaim.ToString()) - ); + using (new AssertionScope()) + { + claims.Should().Contain(idClaim.ToString()); + claims.Should().Contain(emailClaim.ToString()); + claims.Should().Contain(roleClaim.ToString()); + claims.Should().Contain(projectClaim.ToString()); + } } [Fact] @@ -71,7 +73,7 @@ public void CanRoundTripClaimsThroughAPrincipal() { var claims = _user.GetPrincipal("Testing"); var newUser = LexAuthUser.FromClaimsPrincipal(claims); - newUser.ShouldBeEquivalentTo(_user); + newUser.Should().BeEquivalentTo(_user); } [Fact] @@ -83,7 +85,7 @@ public void CanRoundTripClaimsThroughJwt() var outputJwt = tokenHandler.ReadJwtToken(encodedJwt); var principal = new ClaimsPrincipal(new ClaimsIdentity(outputJwt.Claims, "Testing")); var newUser = LexAuthUser.FromClaimsPrincipal(principal); - newUser.ShouldBeEquivalentTo(_user); + newUser.Should().BeEquivalentTo(_user); } [Fact] @@ -105,11 +107,11 @@ public void CanRoundTripClaimsWhenUsingSecurityTokenDescriptor() ); var tokenHandler = new JwtSecurityTokenHandler(); var token = tokenHandler.ReadJwtToken(jwt); - token.ValidTo.ShouldBe(expires.DateTime); - token.ValidFrom.ShouldBe(issuedAt.DateTime); - token.IssuedAt.ShouldBe(issuedAt.DateTime); + token.ValidTo.Should().Be(expires.DateTime); + token.ValidFrom.Should().Be(issuedAt.DateTime); + token.IssuedAt.Should().Be(issuedAt.DateTime); //props get converted to claims, but some we want to exclude because they are used elsewhere. - token.Claims.ShouldNotContain(c => c.Type == "props.issued" || c.Type == "props.expires"); + token.Claims.Should().NotContain(c => c.Type == "props.issued" || c.Type == "props.expires"); var json = Base64UrlEncoder.Decode(token.RawPayload); LexAuthUser? newUser; @@ -122,7 +124,7 @@ public void CanRoundTripClaimsWhenUsingSecurityTokenDescriptor() throw new JsonException("Could not deserialize user, json: " + json, e); } - newUser.ShouldBeEquivalentTo(_user); + newUser.Should().BeEquivalentTo(_user); } [Fact] @@ -149,15 +151,15 @@ public void CanRoundTripFromAuthTicketToAuthTicket() jwtUserOptions ); var actualTicket = JwtTicketDataFormat.ConvertJwtToAuthTicket(jwt, JwtBearerOptions, NullLogger.Instance); - actualTicket.ShouldNotBeNull(); - actualTicket.Properties.IssuedUtc.ShouldBe(ticket.Properties.IssuedUtc); - actualTicket.Properties.ExpiresUtc.ShouldBe(ticket.Properties.ExpiresUtc); + actualTicket.Should().NotBeNull(); + actualTicket.Properties.IssuedUtc.Should().Be(ticket.Properties.IssuedUtc); + actualTicket.Properties.ExpiresUtc.Should().Be(ticket.Properties.ExpiresUtc); //order by is because the order isn't important but the assertion fails if the order is different actualTicket.Properties.Items.OrderBy(kvp => kvp.Key) - .ShouldBe(ticket.Properties.Items.OrderBy(kvp => kvp.Key)); + .Should().Equal(ticket.Properties.Items.OrderBy(kvp => kvp.Key)); var newUser = LexAuthUser.FromClaimsPrincipal(actualTicket.Principal); - newUser.ShouldBeEquivalentTo(_user); + newUser.Should().BeEquivalentTo(_user); } [Fact] @@ -169,7 +171,7 @@ public void CanRoundTripJwtFromUserThroughLexAuthService() var outputJwt = tokenHandler.ReadJwtToken(jwt); var principal = new ClaimsPrincipal(new ClaimsIdentity(outputJwt.Claims, "Testing")); var newUser = LexAuthUser.FromClaimsPrincipal(principal); - newUser.ShouldBeEquivalentTo(_user); + newUser.Should().BeEquivalentTo(_user); } private const string knownGoodJwt = @@ -182,12 +184,12 @@ public void CanParseFromKnownGoodJwt() var outputJwt = tokenHandler.ReadJwtToken(knownGoodJwt); var principal = new ClaimsPrincipal(new ClaimsIdentity(outputJwt.Claims, "Testing")); var newUser = LexAuthUser.FromClaimsPrincipal(principal); - newUser.ShouldNotBeNull(); - newUser.UpdatedDate.ShouldBe(0); + newUser.Should().NotBeNull(); + newUser.UpdatedDate.Should().Be(0); //old jwt doesn't have updated date or orgs, we're ok with that so we correct the values to make the equivalence work newUser.Orgs = [ new AuthUserOrg(OrgRole.Admin, LexData.SeedingData.TestOrgId) ]; newUser.UpdatedDate = _user.UpdatedDate; - newUser.ShouldBeEquivalentTo(_user); + newUser.Should().BeEquivalentTo(_user); } [Fact] @@ -200,7 +202,7 @@ public void CheckingJwtLength() .ToArray() }; var (jwt, _, _) = _lexAuthService.GenerateJwt(user); - jwt.Length.ShouldBeLessThan(LexAuthUser.MaxJwtLength); + jwt.Length.Should().BeLessThan(LexAuthUser.MaxJwtLength); } [Fact] @@ -223,6 +225,6 @@ public void CanRoundTripThroughRefresh() var loggedInPrincipal = new ClaimsPrincipal(new ClaimsIdentity(tokenHandler.ReadJwtToken(redirectJwt).Claims, "Testing")); var newUser = LexAuthUser.FromClaimsPrincipal(loggedInPrincipal); - newUser.ShouldBeEquivalentTo(_user with { Audience = LexboxAudience.ForgotPassword }); + newUser.Should().BeEquivalentTo(_user with { Audience = LexboxAudience.ForgotPassword }); } } diff --git a/backend/Testing/LexCore/PasswordHashingTests.cs b/backend/Testing/LexCore/PasswordHashingTests.cs index 1ce079acb..4d33b9a37 100644 --- a/backend/Testing/LexCore/PasswordHashingTests.cs +++ b/backend/Testing/LexCore/PasswordHashingTests.cs @@ -1,5 +1,5 @@ using LexCore; -using Shouldly; +using FluentAssertions; namespace Testing.LexCore; @@ -10,6 +10,6 @@ public class PasswordHashingTests [Theory] public void CanHashPassword(string pw, string salt, string hash) { - PasswordHashing.RedminePasswordHash(pw, salt, false).ShouldBe(hash); + PasswordHashing.RedminePasswordHash(pw, salt, false).Should().Be(hash); } -} \ No newline at end of file +} diff --git a/backend/Testing/LexCore/ProjectCodeTests.cs b/backend/Testing/LexCore/ProjectCodeTests.cs index 49b387bfa..6f6945adf 100644 --- a/backend/Testing/LexCore/ProjectCodeTests.cs +++ b/backend/Testing/LexCore/ProjectCodeTests.cs @@ -1,5 +1,5 @@ using LexCore.Entities; -using Shouldly; +using FluentAssertions; namespace Testing.LexCore; @@ -29,7 +29,7 @@ public void InvalidCodesThrows(string code) public void ValidCodes(string code) { var projectCode = new ProjectCode(code); - projectCode.Value.ShouldBe(code); - projectCode.ToString().ShouldBe(code); + projectCode.Value.Should().Be(code); + projectCode.ToString().Should().Be(code); } } diff --git a/backend/Testing/LexCore/Services/HgServiceTests.cs b/backend/Testing/LexCore/Services/HgServiceTests.cs index 4372635f4..e3ff8e0f1 100644 --- a/backend/Testing/LexCore/Services/HgServiceTests.cs +++ b/backend/Testing/LexCore/Services/HgServiceTests.cs @@ -1,15 +1,13 @@ using System.IO.Compression; using LexBoxApi.Services; using LexCore.Config; -using LexCore.Entities; using LexCore.Exceptions; using LexSyncReverseProxy; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Moq; using Moq.Contrib.HttpClient; -using Shouldly; -using Testing.Fixtures; +using FluentAssertions; namespace Testing.LexCore.Services; @@ -59,7 +57,7 @@ private void CleanUpTempDir() [InlineData(HgType.resumable, LexboxResumable)] public void DetermineProjectPrefixWorks(HgType type, string expectedUrl) { - HgService.DetermineProjectUrlPrefix(type, _hgConfig).ShouldBe(expectedUrl); + HgService.DetermineProjectUrlPrefix(type, _hgConfig).Should().Be(expectedUrl); } [Theory] @@ -82,7 +80,7 @@ public void HgDatesConvertedAccurately(string? input, string? expectedStr) { DateTimeOffset? expected = expectedStr == null ? null : DateTimeOffset.Parse(expectedStr); var actual = HgService.ConvertHgDate(input); - actual.ShouldBe(expected); + actual.Should().Be(expected); } [Theory] @@ -108,7 +106,7 @@ public async Task CanFinishResetByUnZippingAnArchive(string filePath) var repoPath = Path.GetFullPath(Path.Join(_hgConfig.RepoPath, "u", code)); Directory.EnumerateFiles(repoPath, "*", SearchOption.AllDirectories) .Select(p => Path.GetRelativePath(repoPath, p)) - .ShouldHaveSingleItem().ShouldBe(Path.Join(".hg", "important-file.bin")); + .Should().ContainSingle().Which.Should().Be(Path.Join(".hg", "important-file.bin")); } [Theory] @@ -143,6 +141,6 @@ public async Task ThrowsIfNoHgFolderIsFound() stream.Position = 0; var act = () => _hgService.FinishReset(code, stream); - act.ShouldThrow(); + await act.Should().ThrowAsync(); } } diff --git a/backend/Testing/LexCore/Services/ProjectServiceTest.cs b/backend/Testing/LexCore/Services/ProjectServiceTest.cs index 48dcc9739..6accdbbe8 100644 --- a/backend/Testing/LexCore/Services/ProjectServiceTest.cs +++ b/backend/Testing/LexCore/Services/ProjectServiceTest.cs @@ -8,7 +8,7 @@ using Microsoft.Extensions.DependencyInjection; using Moq; using Npgsql; -using Shouldly; +using FluentAssertions; using Testing.Fixtures; namespace Testing.LexCore.Services; @@ -51,7 +51,7 @@ public async Task CanCreateProject() { var projectId = await _projectService.CreateProject( new(null, "TestProject", "Test", "test1", ProjectType.FLEx, RetentionPolicy.Test, false, null, null)); - projectId.ShouldNotBe(default); + projectId.Should().NotBe(Guid.Empty); } [Fact] @@ -61,8 +61,8 @@ public async Task CanUpdateProjectLangTags() new(null, "TestProject", "Test", "test2", ProjectType.FLEx, RetentionPolicy.Test, false, null, null)); await _projectService.UpdateProjectLangTags(projectId); var project = await _lexBoxDbContext.Projects.Include(p => p.FlexProjectMetadata).SingleAsync(p => p.Id == projectId); - project.FlexProjectMetadata.ShouldNotBeNull(); - project.FlexProjectMetadata.WritingSystems.ShouldBeEquivalentTo(_writingSystems); + project.FlexProjectMetadata.Should().NotBeNull(); + project.FlexProjectMetadata.WritingSystems.Should().BeEquivalentTo(_writingSystems); } [Fact] @@ -72,11 +72,12 @@ public async Task ShouldErrorIfCreatingAProjectWithTheSameCode() await _projectService.CreateProject( new(null, "TestProject", "Test", "test-dup-code", ProjectType.FLEx, RetentionPolicy.Test, false, null, null)); - var exception = await _projectService.CreateProject( + var act = () => _projectService.CreateProject( new(null, "Test2", "Test desc", "test-dup-code", ProjectType.Unknown, RetentionPolicy.Dev, false, null, null) - ).ShouldThrowAsync(); + ); - exception.InnerException.ShouldBeOfType() - .SqlState.ShouldBe(PostgresErrorCodes.UniqueViolation); + (await act.Should().ThrowAsync()) + .WithInnerException() + .Which.SqlState.Should().Be(PostgresErrorCodes.UniqueViolation); } } diff --git a/backend/Testing/LexCore/Services/UserServiceTest.cs b/backend/Testing/LexCore/Services/UserServiceTest.cs new file mode 100644 index 000000000..2ba2f2f5c --- /dev/null +++ b/backend/Testing/LexCore/Services/UserServiceTest.cs @@ -0,0 +1,262 @@ +using LexBoxApi.Services; +using LexBoxApi.Services.Email; +using LexCore.Auth; +using LexCore.Entities; +using LexData; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using Testing.Fixtures; +using FluentAssertions; + +namespace Testing.LexCore.Services; + +[Collection(nameof(TestingServicesFixture))] +public class UserServiceTest : IAsyncLifetime +{ + private readonly UserService _userService; + + private readonly LexBoxDbContext _lexBoxDbContext; + private List ManagedProjects { get; } = []; + private List ManagedUsers { get; } = []; + private List ManagedOrgs { get; } = []; + + // Users created for this test + private User? Robin { get; set; } + private User? John { get; set; } + private User? Alan { get; set; } + private User? Marian { get; set; } + private User? Bishop { get; set; } + private User? Tuck { get; set; } + private User? Sheriff { get; set; } + private User? Guy { get; set; } + // Projects created for this test + private Project? Sherwood { get; set; } + private Project? Nottingham { get; set; } + // Orgs created for this test + private Organization? Outlaws { get; set; } + private Organization? LawEnforcement { get; set; } + private Organization? Church { get; set; } + + public UserServiceTest(TestingServicesFixture testing) + { + var serviceProvider = testing.ConfigureServices(s => + { + s.AddScoped(_ => Mock.Of()); + s.AddScoped(); + }); + _userService = serviceProvider.GetRequiredService(); + _lexBoxDbContext = serviceProvider.GetRequiredService(); + } + + public Task InitializeAsync() + { + Robin = CreateUser("Robin Hood"); + John = CreateUser("Little John"); + Alan = CreateUser("Alan a Dale"); + Marian = CreateUser("Maid Marian"); + Bishop = CreateUser("Bishop of Hereford"); + Tuck = CreateUser("Friar Tuck"); + Sheriff = CreateUser("Sheriff of Nottingham"); + Guy = CreateUser("Guy of Gisbourne"); + + Nottingham = CreateProject([Sheriff.Id], [Marian.Id, Tuck.Id]); + Sherwood = CreateConfidentialProject([Robin.Id, Marian.Id], [John.Id, Alan.Id, Tuck.Id]); + + Outlaws = CreateOrg([Robin.Id], [John.Id]); // Alan a Dale should *NOT* be in this org + LawEnforcement = CreateOrg([Sheriff.Id], [Guy.Id]); + Church = CreateOrg([Bishop.Id], [Tuck.Id]); + + return _lexBoxDbContext.SaveChangesAsync(); + } + + public Task DisposeAsync() + { + foreach (var project in ManagedProjects) + { + _lexBoxDbContext.Remove(project); + } + foreach (var user in ManagedUsers) + { + _lexBoxDbContext.Remove(user); + } + foreach (var org in ManagedOrgs) + { + _lexBoxDbContext.Remove(org); + } + return _lexBoxDbContext.SaveChangesAsync(); + } + + private void UserListShouldBe(IEnumerable actual, IEnumerable expected) + { + var actualNames = actual.Select(u => u.Name); + var expectedNames = expected.Select(u => u?.Name ?? ""); + actualNames.Should().BeEquivalentTo(expectedNames, options => options.WithoutStrictOrdering()); + } + + [Fact] + public async Task ManagerCanSeeAllUsersEvenInConfidentialProjects() + { + // Robin Hood is in Outlaws org (admin) and Sherwood project (private, manager) + var authUser = new LexAuthUser(Robin!); + var users = await _userService.UserQueryForTypeahead(authUser).ToArrayAsync(); + // John, who is in both the Outlaws org (user) and Sherwood project (member) is not duplicated + UserListShouldBe(users, [Robin, Marian, John, Alan, Tuck]); + } + + [Fact] + public async Task NonManagerCanNotSeeUsersInConfidentialProjects() + { + // Little John is in Outlaws org (user) and Sherwood project (private, member) + var authUser = new LexAuthUser(John!); + var users = await _userService.UserQueryForTypeahead(authUser).ToArrayAsync(); + // John can see Robin because he shares an org, but not Marian even though she's a manager of the Sherwood project + UserListShouldBe(users, [Robin, John]); + } + + [Fact] + public async Task ManagerOfOneProjectAndMemberOfAnotherPublicProjectCanSeeUsersInBoth() + { + // Maid Marian is in no orgs and two projects: Sherwood (private, manager) and Nottingham (public, member) + var authUser = new LexAuthUser(Marian!); + var users = await _userService.UserQueryForTypeahead(authUser).ToArrayAsync(); + // Marian can see everyone in both projects; Tuck is not duplicated despite being in both projects + UserListShouldBe(users, [Robin, Marian, John, Alan, Tuck, Sheriff]); + } + + [Fact] + public async Task ManagerOfOneProjectAndMemberOfAnotherConfidentialProjectCanNotSeeUsersInConfidentialProject() + { + // Sheriff of Nottingham is in LawEnforcement org (admin) and Nottingham project (pulbic, manager) + try + { + // Sheriff tries to sneak into Sherwood... + await AddUserToProject(Sherwood!, Sheriff!); + // ... but can still only see the users in Nottingham and LawEnforcement + var authUser = new LexAuthUser(Sheriff!); + var users = await _userService.UserQueryForTypeahead(authUser).ToArrayAsync(); + UserListShouldBe(users, [Sheriff, Guy, Marian, Tuck]); + } + finally + { + await RemoveUserFromProject(Sherwood!, Sheriff!); + } + } + + [Fact] + public async Task OrgAdminsInNoProjectsCanSeeOnlyTheirOrg() + { + // Bishop of Hereford is in Church org (admin) but no projects + var authUser = new LexAuthUser(Bishop!); + var users = await _userService.UserQueryForTypeahead(authUser).ToArrayAsync(); + // Bishop can only see members of Church org + UserListShouldBe(users, [Bishop, Tuck]); + } + + [Fact] + public async Task OrgMembersInNoProjectsCanSeeOnlyTheirOrg() + { + // Guy of Gisborne is in LawEnforcement org (user) but no projects + var authUser = new LexAuthUser(Guy!); + var users = await _userService.UserQueryForTypeahead(authUser).ToArrayAsync(); + // Guy can only see members of LawEnforcement org + UserListShouldBe(users, [Sheriff, Guy]); + } + + [Fact] + public async Task OrgAndProjectMembersCanSeeFellowOrgMembersAndFellowPublicProjectMembersButNotFellowPrivateProjectMembers() + { + // Friar Tuck is in Church org (user) and two projects: Nottingham (public, member) and Sherwood (private, member) + var authUser = new LexAuthUser(Tuck!); + var users = await _userService.UserQueryForTypeahead(authUser).ToArrayAsync(); + // Tuck can see everyone in Church and Nottingham, but nobody in Sherwood because it's private — though he can see Marian because he shares a public project with her + UserListShouldBe(users, [Bishop, Tuck, Sheriff, Marian]); + } + + [Fact] + public async Task MemberOfOnePrivateProjectButNoOrgsCanOnlySeeHimself() + { + // Alan a Dale is in Sherwood project (private, member) but no orgs + var authUser = new LexAuthUser(Alan!); + var users = await _userService.UserQueryForTypeahead(authUser).ToArrayAsync(); + // Alan can see himself in the Sherwood project, but nobody else because it's private + UserListShouldBe(users, [Alan]); + } + + private User CreateUser(string name) + { + var email = name.ToLowerInvariant().Replace(' ', '_') + "@example.com"; + var user = new User + { + Name = name, + Email = email, + CanCreateProjects = true, + EmailVerified = true, + IsAdmin = name.Contains("Admin"), + PasswordHash = "", + Salt = "" + }; + _lexBoxDbContext.Add(user); + ManagedUsers.Add(user); + return user; // Caller must call SaveChanges after all users and projects are added + } + + private Project CreateProject(IEnumerable managers, IEnumerable members, bool isConfidential = false) + { + var config = Testing.Services.Utils.GetNewProjectConfig(); + var project = new Project + { + Name = config.Name, + Code = config.Code, + IsConfidential = isConfidential, + LastCommit = null, + Organizations = [], + Users = [], + RetentionPolicy = RetentionPolicy.Test, + Type = ProjectType.FLEx, + Id = config.Id, + }; + project.Users.AddRange(managers.Select(userId => new ProjectUsers { UserId = userId, Role = ProjectRole.Manager })); + project.Users.AddRange(members.Select(userId => new ProjectUsers { UserId = userId, Role = ProjectRole.Editor })); + _lexBoxDbContext.Add(project); + ManagedProjects.Add(project); + return project; // Caller must call SaveChanges after all users and projects are added + } + + private Project CreateConfidentialProject(IEnumerable managers, IEnumerable members) + { + return CreateProject(managers, members, true); + } + + private async Task AddUserToProject(Project project, User user, ProjectRole role = ProjectRole.Editor) + { + var pu = project.Users.FirstOrDefault(pu => pu.UserId == user.Id); + if (pu is null) project.Users.Add(new ProjectUsers { UserId = user.Id, Role = role }); + else pu.Role = role; + await _lexBoxDbContext.SaveChangesAsync(); + } + + private async Task RemoveUserFromProject(Project project, User user) + { + var pu = project.Users.FirstOrDefault(pu => pu.UserId == user.Id); + if (pu is not null) project.Users.Remove(pu); + await _lexBoxDbContext.SaveChangesAsync(); + } + + private Organization CreateOrg(IEnumerable managers, IEnumerable members) + { + var id = Guid.NewGuid(); + var shortId = id.ToString().Split("-")[0]; + var org = new Organization + { + Name = shortId, + Members = [], + Projects = [], + }; + org.Members.AddRange(managers.Select(userId => new OrgMember { UserId = userId, Role = OrgRole.Admin })); + org.Members.AddRange(members.Select(userId => new OrgMember { UserId = userId, Role = OrgRole.User })); + _lexBoxDbContext.Add(org); + ManagedOrgs.Add(org); + return org; + } +} diff --git a/backend/Testing/LexCore/Utils/ConcurrentWeakDictionaryTests.cs b/backend/Testing/LexCore/Utils/ConcurrentWeakDictionaryTests.cs index 1ac10e717..58dbb9699 100644 --- a/backend/Testing/LexCore/Utils/ConcurrentWeakDictionaryTests.cs +++ b/backend/Testing/LexCore/Utils/ConcurrentWeakDictionaryTests.cs @@ -1,5 +1,5 @@ using LexCore.Utils; -using Shouldly; +using FluentAssertions; namespace Testing.LexCore.Utils; @@ -12,8 +12,8 @@ public void Add_Then_Try_Get_Value_Test() var obj = new object(); var dict = new ConcurrentWeakDictionary(); dict.Add("key", obj); - dict.TryGetValue("key", out var value).ShouldBeTrue(); - value.ShouldBe(obj); + dict.TryGetValue("key", out var value).Should().BeTrue(); + value.Should().Be(obj); } [Fact] @@ -25,9 +25,9 @@ public void GetOrAdd_New_Key_Should_Add_And_Return_New_Value_Test() var returnedValue = dictionary.GetOrAdd("key", k => value); - returnedValue.ShouldBe(value); - dictionary.TryGetValue("key", out var existingValue).ShouldBeTrue(); - existingValue.ShouldBe(value); + returnedValue.Should().Be(value); + dictionary.TryGetValue("key", out var existingValue).Should().BeTrue(); + existingValue.Should().Be(value); } [Fact] @@ -41,9 +41,9 @@ public void GetOrAdd_Existing_Key_Should_Return_Existing_Value_Test() var returnedValue = dictionary.GetOrAdd(key, k => new object()); - returnedValue.ShouldBe(value); - dictionary.TryGetValue(key, out var existingValue).ShouldBeTrue(); - existingValue.ShouldBe(value); + returnedValue.Should().Be(value); + dictionary.TryGetValue(key, out var existingValue).Should().BeTrue(); + existingValue.Should().Be(value); } private ConcurrentWeakDictionary Setup(string key) @@ -69,6 +69,6 @@ public void Add_Then_Collect_And_Check_That_Key_Is_Removed_Test() GC.WaitForPendingFinalizers(); // Check that the value for the key no longer exists. - dictionary.TryGetValue(key, out var result).ShouldBeFalse(); + dictionary.TryGetValue(key, out var result).Should().BeFalse(); } } diff --git a/backend/Testing/LexCore/Utils/GqlUtils.cs b/backend/Testing/LexCore/Utils/GqlUtils.cs index b67c4597a..de2c0eb73 100644 --- a/backend/Testing/LexCore/Utils/GqlUtils.cs +++ b/backend/Testing/LexCore/Utils/GqlUtils.cs @@ -1,5 +1,5 @@ using System.Text.Json.Nodes; -using Shouldly; +using FluentAssertions; namespace Testing.LexCore.Utils; @@ -9,13 +9,13 @@ public static void ValidateGqlErrors(JsonObject json, bool expectError = false) { if (!expectError) { - json["errors"].ShouldBeNull(); + json!["errors"]?.Should().BeNull(); if (json["data"] is JsonObject data) { foreach (var (_, resultValue) in data) { if (resultValue is JsonObject resultObject) - resultObject["errors"].ShouldBeNull(); + resultObject["errors"]?.Should().BeNull(); } } } @@ -37,7 +37,7 @@ public static void ValidateGqlErrors(JsonObject json, bool expectError = false) } } } - foundError.ShouldBeTrue(); + foundError.Should().BeTrue(); } } } diff --git a/backend/Testing/Services/CleanupResetProjectsTests.cs b/backend/Testing/Services/CleanupResetProjectsTests.cs index 6946903d5..431d2b1de 100644 --- a/backend/Testing/Services/CleanupResetProjectsTests.cs +++ b/backend/Testing/Services/CleanupResetProjectsTests.cs @@ -1,6 +1,6 @@ using LexBoxApi.Services; using LexCore.Utils; -using Shouldly; +using FluentAssertions; namespace Testing.Services; @@ -12,8 +12,8 @@ public void ResetRegexCanFindTimestampFromResetRepoName() var date = DateTimeOffset.UtcNow; var repoName = HgService.DeletedRepoName("test", HgService.ResetSoftDeleteSuffix(date)); var match = HgService.ResetProjectsRegex().Match(repoName); - match.Success.ShouldBeTrue(); - match.Groups[1].Value.ShouldBe(FileUtils.ToTimestamp(date)); + match.Success.Should().BeTrue(); + match.Groups[1].Value.Should().Be(FileUtils.ToTimestamp(date)); } [Fact] @@ -22,8 +22,8 @@ public void CanGetDateFromResetRepoName() var expected = DateTimeOffset.Now; var repoName = HgService.DeletedRepoName("test", HgService.ResetSoftDeleteSuffix(expected)); var actual = HgService.GetResetDate(repoName); - actual.ShouldNotBeNull(); - TruncateToMinutes(actual.Value).ShouldBe(TruncateToMinutes(expected)); + actual.Should().NotBeNull(); + TruncateToMinutes(actual!.Value).Should().Be(TruncateToMinutes(expected)); } private DateTimeOffset TruncateToMinutes(DateTimeOffset date) @@ -36,8 +36,8 @@ private DateTimeOffset TruncateToMinutes(DateTimeOffset date) public void ResetRegexCanFindTimestamp(string repoName, string timestamp) { var match = HgService.ResetProjectsRegex().Match(repoName); - match.Success.ShouldBeTrue(); - match.Groups[1].Value.ShouldBe(timestamp); + match.Success.Should().BeTrue(); + match.Groups[1].Value.Should().Be(timestamp); } [Theory] @@ -47,7 +47,7 @@ public void ResetRegexCanFindTimestamp(string repoName, string timestamp) public void ResetRegexDoesNotMatchNonResets(string repoName) { var match = HgService.ResetProjectsRegex().Match(repoName); - match.Success.ShouldBeFalse(); + match.Success.Should().BeFalse(); } } diff --git a/backend/Testing/Services/IsLanguageForgeProjectDataLoaderTests.cs b/backend/Testing/Services/IsLanguageForgeProjectDataLoaderTests.cs index 8f48a0929..3d16d6902 100644 --- a/backend/Testing/Services/IsLanguageForgeProjectDataLoaderTests.cs +++ b/backend/Testing/Services/IsLanguageForgeProjectDataLoaderTests.cs @@ -1,7 +1,7 @@ using LexBoxApi.GraphQL.CustomTypes; using Microsoft.Extensions.Time.Testing; using Polly; -using Shouldly; +using FluentAssertions; namespace Testing.Services; @@ -36,14 +36,14 @@ private ValueTask>> Execute(Exception? exceptio private void VerifyEmptyResult(Outcome> result) { - result.Exception.ShouldBeNull(); - result.Result.ShouldBe(new Dictionary() { { "test", false } }); + result.Exception.Should().BeNull(); + result.Result.Should().BeEquivalentTo(new Dictionary() { { "test", false } }); } private void VerifySuccessResult(Outcome> result) { - result.Exception.ShouldBeNull(); - result.Result.ShouldBe(new Dictionary() { { "test", true } }); + result.Exception.Should().BeNull(); + result.Result.Should().BeEquivalentTo(new Dictionary() { { "test", true } }); } [Fact] diff --git a/backend/Testing/Services/JwtHelper.cs b/backend/Testing/Services/JwtHelper.cs index af6a8172a..5f839c832 100644 --- a/backend/Testing/Services/JwtHelper.cs +++ b/backend/Testing/Services/JwtHelper.cs @@ -8,7 +8,7 @@ using Microsoft.Extensions.Http.Resilience; using Mono.Unix.Native; using Polly; -using Shouldly; +using FluentAssertions; using Testing.ApiTests; namespace Testing.Services; @@ -58,7 +58,7 @@ public static async Task ExecuteLogin(SendReceiveAuth auth, public static string GetJwtFromLoginResponse(HttpResponseMessage response) { TryGetJwtFromLoginResponse(response, out var jwt); - jwt.ShouldNotBeNullOrEmpty(); + jwt.Should().NotBeNullOrEmpty(); return jwt; } diff --git a/backend/Testing/Services/SendReceiveService.cs b/backend/Testing/Services/SendReceiveService.cs index aabf4ea7f..778f0369b 100644 --- a/backend/Testing/Services/SendReceiveService.cs +++ b/backend/Testing/Services/SendReceiveService.cs @@ -1,7 +1,7 @@ using System.Diagnostics; using Chorus; using Nini.Ini; -using Shouldly; +using FluentAssertions; using SIL.Progress; using Testing.Logging; using Xunit.Abstractions; @@ -86,19 +86,19 @@ public string RunCloneSendReceive(SendReceiveParams sendReceiveParams, SendRecei // Clone var cloneResult = CloneProject(sendReceiveParams, auth); - Directory.Exists(projectDir).ShouldBeTrue($"Directory {projectDir} not found. Clone response: {cloneResult}"); - Directory.EnumerateFiles(projectDir).ShouldContain(fwDataFile); + Directory.Exists(projectDir).Should().BeTrue($"Directory {projectDir} not found. Clone response: {cloneResult}"); + Directory.EnumerateFiles(projectDir).Should().Contain(fwDataFile); var fwDataFileInfo = new FileInfo(fwDataFile); - fwDataFileInfo.Length.ShouldBeGreaterThan(0); + fwDataFileInfo.Length.Should().BeGreaterThan(0); var fwDataFileOriginalLength = fwDataFileInfo.Length; // SendReceive var srResult = SendReceiveProject(sendReceiveParams, auth); - srResult.ShouldContain("no changes from others"); + srResult.Should().Contain("No changes from others"); fwDataFileInfo.Refresh(); - fwDataFileInfo.Exists.ShouldBeTrue(); - fwDataFileInfo.Length.ShouldBe(fwDataFileOriginalLength); + fwDataFileInfo.Exists.Should().BeTrue(); + fwDataFileInfo.Length.Should().Be(fwDataFileOriginalLength); return $"Clone: {cloneResult}{Environment.NewLine}SendReceive: {srResult}"; } diff --git a/backend/Testing/Services/Utils.cs b/backend/Testing/Services/Utils.cs index dfea6d1bd..48117e172 100644 --- a/backend/Testing/Services/Utils.cs +++ b/backend/Testing/Services/Utils.cs @@ -2,7 +2,7 @@ using System.Text.RegularExpressions; using LexCore.Entities; using Quartz.Util; -using Shouldly; +using FluentAssertions; using Testing.ApiTests; using static Testing.Services.Constants; @@ -24,6 +24,7 @@ public static SendReceiveParams GetParams(HgProtocol protocol, public static ProjectConfig GetNewProjectConfig(HgProtocol? protocol = null, bool isConfidential = false, Guid? owningOrgId = null, [CallerMemberName] string projectName = "") { + projectName = projectName[..Math.Min(projectName.Length, 40)]; // make sure the path isn't too long if (protocol.HasValue) projectName += $" ({protocol.Value.ToString()[..5]})"; var id = Guid.NewGuid(); var shortId = id.ToString().Split("-")[0]; @@ -111,9 +112,9 @@ ... on InvalidEmailError { public static void ValidateSendReceiveOutput(string srOutput) { - srOutput.ShouldNotContain("abort"); - srOutput.ShouldNotContain("failure"); - srOutput.ShouldNotContain("error"); + srOutput.Should().NotContain("abort"); + srOutput.Should().NotContain("failure"); + srOutput.Should().NotContain("error"); } public static string ToProjectCodeFriendlyString(string name) @@ -132,12 +133,13 @@ public static async Task WaitForHgRefreshIntervalAsync() private static string GetNewProjectDir(string projectCode, [CallerMemberName] string projectName = "") { + projectName = projectName[..Math.Min(projectName.Length, 40)]; // make sure the path isn't too long var projectDir = projectName.IsNullOrWhiteSpace() ? BasePath : Path.Join(BasePath, projectName); // Add a random id to the path to be certain we prevent naming clashes var randomIndexedId = $"{_folderIndex++}-{Guid.NewGuid().ToString().Split("-")[0]}"; //fwdata file containing folder name will be the same as the file name projectDir = Path.Join(projectDir, randomIndexedId, projectCode); - projectDir.Length.ShouldBeLessThan(150, $"Path may be too long with mercurial directories {projectDir}"); + projectDir.Length.Should().BeLessThan(150, $"Path may be too long with mercurial directories {projectDir}"); return projectDir; } } diff --git a/backend/Testing/Services/UtilsTests.cs b/backend/Testing/Services/UtilsTests.cs index 0d52a5875..81801b647 100644 --- a/backend/Testing/Services/UtilsTests.cs +++ b/backend/Testing/Services/UtilsTests.cs @@ -1,4 +1,4 @@ -using Shouldly; +using FluentAssertions; using Testing.SyncReverseProxy; namespace Testing.Services; @@ -14,6 +14,6 @@ public class UtilsTests [InlineData("SimultaneousResetsDontResultIn404S", "simultaneous-resets-dont-result-in-404-s")] public void VerifyToProjectCodeFriendlyString(string input, string expected) { - Utils.ToProjectCodeFriendlyString(input).ShouldBe(expected); + Utils.ToProjectCodeFriendlyString(input).Should().Be(expected); } } diff --git a/backend/Testing/SyncReverseProxy/LegacyProjectApiTests.cs b/backend/Testing/SyncReverseProxy/LegacyProjectApiTests.cs index 5a3b47712..ab48a04d8 100644 --- a/backend/Testing/SyncReverseProxy/LegacyProjectApiTests.cs +++ b/backend/Testing/SyncReverseProxy/LegacyProjectApiTests.cs @@ -1,9 +1,9 @@ using System.Net; using System.Net.Http.Json; -using System.Text; using System.Text.Json; using System.Text.Json.Nodes; -using Shouldly; +using FluentAssertions; +using FluentAssertions.Execution; using Testing.ApiTests; using Testing.Services; @@ -28,27 +28,28 @@ public class LegacyProjectApiTests private async Task ValidateResponse(HttpResponseMessage response) { - response.StatusCode.ShouldBe(HttpStatusCode.OK); + response.StatusCode.Should().Be(HttpStatusCode.OK); var content = await response.Content.ReadFromJsonAsync(); - content.ValueKind.ShouldBe(JsonValueKind.Array); + content.ValueKind.Should().Be(JsonValueKind.Array); var projectArray = JsonArray.Create(content); - projectArray.ShouldNotBeNull(); - projectArray.Count.ShouldBeGreaterThan(0); + projectArray.Should().NotBeNull(); + projectArray.Count.Should().BeGreaterThan(0); var project = projectArray.First(p => p?["identifier"]?.GetValue() == TestingEnvironmentVariables.ProjectCode) as JsonObject; - project.ShouldNotBeNull(); + project.Should().NotBeNull(); var projectDict = new Dictionary(project); - projectDict.ShouldSatisfyAllConditions( - () => projectDict.ShouldContainKey("identifier"), - () => projectDict.ShouldContainKey("name"), - () => projectDict.ShouldContainKey("repository"), - () => projectDict.ShouldContainKey("role") - ); - project["identifier"]!.GetValue().ShouldBe(TestingEnvironmentVariables.ProjectCode); - project["name"]!.GetValue().ShouldBe("Sena 3"); - project["repository"]!.GetValue().ShouldBe("http://public.languagedepot.org"); + using (new AssertionScope()) + { + projectDict.Should().ContainKey("identifier"); + projectDict.Should().ContainKey("name"); + projectDict.Should().ContainKey("repository"); + projectDict.Should().ContainKey("role"); + } + project["identifier"]!.GetValue().Should().Be(TestingEnvironmentVariables.ProjectCode); + project["name"]!.GetValue().Should().Be("Sena 3"); + project["repository"]!.GetValue().Should().Be("http://public.languagedepot.org"); //todo what is role for? returns unknown in my single test - project["role"]!.GetValue().ShouldNotBeEmpty(); + project["role"]!.GetValue().Should().NotBeEmpty(); } [Fact] @@ -96,13 +97,13 @@ public async Task TestInvalidPassword() $"{_baseUrl}/api/user/{TestData.User}/projects", new FormUrlEncodedContent( new[] { new KeyValuePair("password", "bad password") })); - response.StatusCode.ShouldBe(HttpStatusCode.Forbidden); + response.StatusCode.Should().Be(HttpStatusCode.Forbidden); var content = await response.Content.ReadFromJsonAsync(); - content.ValueKind.ShouldBe(JsonValueKind.Object); + content.ValueKind.Should().Be(JsonValueKind.Object); var responseObject = JsonObject.Create(content); - responseObject.ShouldNotBeNull(); - responseObject.ShouldContainKey("error"); - responseObject["error"]!.GetValue().ShouldBe("Bad password"); + responseObject.Should().NotBeNull(); + responseObject.Should().ContainKey("error"); + responseObject["error"]!.GetValue().Should().Be("Bad password"); } [Fact] @@ -112,13 +113,13 @@ public async Task TestInvalidUser() $"{_baseUrl}/api/user/not-a-real-user-account/projects", new FormUrlEncodedContent( new[] { new KeyValuePair("password", "doesn't matter") })); - response.StatusCode.ShouldBe(HttpStatusCode.NotFound); + response.StatusCode.Should().Be(HttpStatusCode.NotFound); var content = await response.Content.ReadFromJsonAsync(); - content.ValueKind.ShouldBe(JsonValueKind.Object); + content.ValueKind.Should().Be(JsonValueKind.Object); var responseObject = JsonObject.Create(content); - responseObject.ShouldNotBeNull(); - responseObject.ShouldContainKey("error"); - responseObject["error"]!.GetValue().ShouldBe("Unknown user"); + responseObject.Should().NotBeNull(); + responseObject.Should().ContainKey("error"); + responseObject["error"]!.GetValue().Should().Be("Unknown user"); } // LF sends lots of requests with no password/request body. Chorus might as well. @@ -127,6 +128,6 @@ public async Task TestInvalidUser() public async Task MissingPasswordReturns403() { var response = await Client.PostAsJsonAsync($"{_baseUrl}/api/user/{TestData.User}/projects", null); - response.StatusCode.ShouldBe(HttpStatusCode.Forbidden); + response.StatusCode.Should().Be(HttpStatusCode.Forbidden); } } diff --git a/backend/Testing/SyncReverseProxy/ProxyHgRequestTests.cs b/backend/Testing/SyncReverseProxy/ProxyHgRequestTests.cs index d5afda11b..7f808c870 100644 --- a/backend/Testing/SyncReverseProxy/ProxyHgRequestTests.cs +++ b/backend/Testing/SyncReverseProxy/ProxyHgRequestTests.cs @@ -3,7 +3,7 @@ using System.Net.Http.Json; using System.Text; using LexBoxApi.Auth; -using Shouldly; +using FluentAssertions; using Testing.ApiTests; using Testing.Services; @@ -18,7 +18,7 @@ public class ProxyHgRequests private void ShouldBeValidResponse(HttpResponseMessage responseMessage) { //the Basic realm part is required by the HG client, otherwise it won't request again with a basic auth header - responseMessage.Headers.WwwAuthenticate.ToString().ShouldContain("Basic realm=\""); + responseMessage.Headers.WwwAuthenticate.ToString().Should().Contain("Basic realm=\""); } [Theory] @@ -35,7 +35,7 @@ public async Task TestGet(string user) Convert.ToBase64String(Encoding.ASCII.GetBytes($"{user}:{TestData.Password}"))) } }); - responseMessage.StatusCode.ShouldBe(HttpStatusCode.OK); + responseMessage.StatusCode.Should().Be(HttpStatusCode.OK); } [Fact] @@ -50,7 +50,7 @@ public async Task TestGetPrefixHg() Convert.ToBase64String(Encoding.ASCII.GetBytes($"{TestData.User}:{TestData.Password}"))) } }); - responseMessage.StatusCode.ShouldBe(HttpStatusCode.OK); + responseMessage.StatusCode.Should().Be(HttpStatusCode.OK); } [Theory] @@ -59,7 +59,7 @@ public async Task TestGetPrefixHg() public async Task TestGetWithJwtInBasicAuth(string user) { var jwt = await JwtHelper.GetJwtForUser(new(user, TestData.Password)); - jwt.ShouldNotBeNullOrEmpty(); + jwt.Should().NotBeNullOrEmpty(); var responseMessage = await Client.SendAsync(new HttpRequestMessage(HttpMethod.Get, $"{_baseUrl}/{TestingEnvironmentVariables.ProjectCode}") @@ -69,7 +69,7 @@ public async Task TestGetWithJwtInBasicAuth(string user) Authorization = new ("Basic", Convert.ToBase64String(Encoding.ASCII.GetBytes($"bearer:{jwt}"))) } }); - responseMessage.StatusCode.ShouldBe(HttpStatusCode.OK); + responseMessage.StatusCode.Should().Be(HttpStatusCode.OK); } [Fact] @@ -85,7 +85,7 @@ public async Task TestGetBadPassword() Convert.ToBase64String(Encoding.ASCII.GetBytes($"{TestData.User}:{password}"))) } }); - responseMessage.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); + responseMessage.StatusCode.Should().Be(HttpStatusCode.Unauthorized); ShouldBeValidResponse(responseMessage); } @@ -95,7 +95,7 @@ public async Task TestNoAuthResponse() var responseMessage = await Client.SendAsync(new HttpRequestMessage(HttpMethod.Get, $"{_baseUrl}/{TestingEnvironmentVariables.ProjectCode}")); - responseMessage.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); + responseMessage.StatusCode.Should().Be(HttpStatusCode.Unauthorized); ShouldBeValidResponse(responseMessage); } @@ -112,9 +112,9 @@ public async Task SimpleClone() }; batchRequest.Headers.Add("x-hgarg-1", "cmds=heads+%3Bknown+nodes%3D"); var batchResponse = await Client.SendAsync(batchRequest); - batchResponse.StatusCode.ShouldBe(HttpStatusCode.OK); + batchResponse.StatusCode.Should().Be(HttpStatusCode.OK); var batchBody = await batchResponse.Content.ReadAsStringAsync(); - batchBody.ShouldEndWith(";"); + batchBody.Should().EndWith(";"); var heads = batchBody.Split('\n')[^2]; var getBundleRequest = new HttpRequestMessage(HttpMethod.Get, $"{_baseUrl}/{projectCode}?cmd=getbundle") diff --git a/backend/Testing/SyncReverseProxy/ResumableTests.cs b/backend/Testing/SyncReverseProxy/ResumableTests.cs index 3c121f5a4..32593b1f9 100644 --- a/backend/Testing/SyncReverseProxy/ResumableTests.cs +++ b/backend/Testing/SyncReverseProxy/ResumableTests.cs @@ -2,7 +2,7 @@ using System.Net; using System.Net.Http.Headers; using System.Text; -using Shouldly; +using FluentAssertions; using Testing.ApiTests; using Testing.Services; @@ -28,10 +28,10 @@ public async Task IsAvailable(string user) } }, HttpCompletionOption.ResponseHeadersRead); var responseString = await responseMessage.Content.ReadAsStringAsync(); - responseString.ShouldBeNullOrEmpty(); - responseMessage.StatusCode.ShouldBe(HttpStatusCode.OK); + responseString.Should().BeNullOrEmpty(); + responseMessage.StatusCode.Should().Be(HttpStatusCode.OK); var headers = responseMessage.Headers.ToDictionary(kvp => kvp.Key, kvp => string.Join(',', kvp.Value), StringComparer.OrdinalIgnoreCase); - headers.ShouldContainKeyAndValue("X-HgR-Version", "3"); + headers.Should().Contain("X-HgR-Version", "3"); } [Theory] @@ -40,7 +40,7 @@ public async Task IsAvailable(string user) public async Task IsAvailableJwtInBasicAuth(string user) { var jwt = await JwtHelper.GetJwtForUser(new(user, TestData.Password)); - jwt.ShouldNotBeNullOrEmpty(); + jwt.Should().NotBeNullOrEmpty(); var responseMessage = await Client.SendAsync(new(HttpMethod.Get, $"{_baseUrl}/api/v03/isAvailable?repoId={TestingEnvironmentVariables.ProjectCode}") @@ -51,10 +51,10 @@ public async Task IsAvailableJwtInBasicAuth(string user) } }, HttpCompletionOption.ResponseHeadersRead); var responseString = await responseMessage.Content.ReadAsStringAsync(); - responseString.ShouldBeNullOrEmpty(); - responseMessage.StatusCode.ShouldBe(HttpStatusCode.OK); + responseString.Should().BeNullOrEmpty(); + responseMessage.StatusCode.Should().Be(HttpStatusCode.OK); var headers = responseMessage.Headers.ToDictionary(kvp => kvp.Key, kvp => string.Join(',', kvp.Value), StringComparer.OrdinalIgnoreCase); - headers.ShouldContainKeyAndValue("X-HgR-Version", "3"); + headers.Should().Contain("X-HgR-Version", "3"); } [Fact] @@ -69,7 +69,7 @@ public async Task WithBadUser() Convert.ToBase64String(Encoding.ASCII.GetBytes($"not a user:doesnt matter"))) } }); - responseMessage.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); + responseMessage.StatusCode.Should().Be(HttpStatusCode.Unauthorized); } [Fact] @@ -84,7 +84,7 @@ public async Task WithBadPassword() Convert.ToBase64String(Encoding.ASCII.GetBytes($"{TestData.User}:wrong password"))) } }); - responseMessage.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); + responseMessage.StatusCode.Should().Be(HttpStatusCode.Unauthorized); } [Fact] @@ -99,7 +99,7 @@ public async Task WithBadNotValidProject() Convert.ToBase64String(Encoding.ASCII.GetBytes($"{TestData.User}:{TestData.Password}"))) } }); - responseMessage.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); + responseMessage.StatusCode.Should().Be(HttpStatusCode.Unauthorized); } [Fact] @@ -115,6 +115,6 @@ public async Task WithUnauthorizedUser() Convert.ToBase64String(Encoding.ASCII.GetBytes($"{userWithoutPermission}:{TestData.Password}"))) } }); - responseMessage.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); + responseMessage.StatusCode.Should().Be(HttpStatusCode.Unauthorized); } } diff --git a/backend/Testing/SyncReverseProxy/SendReceiveServiceTests.cs b/backend/Testing/SyncReverseProxy/SendReceiveServiceTests.cs index be5dd69d3..8d6edc151 100644 --- a/backend/Testing/SyncReverseProxy/SendReceiveServiceTests.cs +++ b/backend/Testing/SyncReverseProxy/SendReceiveServiceTests.cs @@ -1,6 +1,6 @@ using Chorus.VcsDrivers.Mercurial; using LexBoxApi.Auth; -using Shouldly; +using FluentAssertions; using SIL.Progress; using System.Net.Http.Json; using System.Text.Json.Nodes; @@ -35,10 +35,10 @@ public SendReceiveServiceTests(ITestOutputHelper output, IntegrationFixture send public async Task VerifyHgWorking() { var version = await _sendReceiveService.GetHgVersion(); - version.ShouldStartWith("Mercurial Distributed SCM"); + version.Should().StartWith("Mercurial Distributed SCM"); _output.WriteLine("Hg version: " + version); HgRunner.Run("hg version", Environment.CurrentDirectory, 5, new XunitStringBuilderProgress(_output) { ShowVerbose = true }); - HgRepository.GetEnvironmentReadinessMessage("en").ShouldBeNull(); + HgRepository.GetEnvironmentReadinessMessage("en").Should().BeNull(); } [Theory] @@ -65,7 +65,7 @@ public async Task CloneConfidentialProjectAsOrgManager(HgProtocol protocol) // Verify pushed var lastCommitDate = await _adminApiTester.GetProjectLastCommit(projectConfig.Code); - lastCommitDate.ShouldNotBeNullOrEmpty(); + lastCommitDate.Should().NotBeNull(); } [Theory] @@ -105,11 +105,11 @@ public async Task ModifyProjectData(HgProtocol protocol) // Verify pushed and store last commit var lastCommitDate = await _adminApiTester.GetProjectLastCommit(projectConfig.Code); - lastCommitDate.ShouldNotBeNullOrEmpty(); + lastCommitDate.Should().NotBeNull(); // Modify var fwDataFileInfo = new FileInfo(sendReceiveParams.FwDataFile); - fwDataFileInfo.Length.ShouldBeGreaterThan(0); + fwDataFileInfo.Length.Should().BeGreaterThan(0); ModifyProjectHelper.ModifyProject(sendReceiveParams.FwDataFile); // Push changes @@ -117,7 +117,7 @@ public async Task ModifyProjectData(HgProtocol protocol) // Verify the push updated the last commit date var lastCommitDateAfter = await _adminApiTester.GetProjectLastCommit(projectConfig.Code); - lastCommitDateAfter.ShouldBeGreaterThan(lastCommitDate); + lastCommitDateAfter.Should().BeAfter(lastCommitDate.Value); } [Theory] @@ -137,7 +137,7 @@ public async Task SendReceiveAfterProjectReset(HgProtocol protocol) var response = await _adminApiTester.HttpClient.GetAsync(tipUri); var jsonResult = await response.Content.ReadFromJsonAsync(); var originalTip = jsonResult?["node"]?.AsValue()?.ToString(); - originalTip.ShouldNotBeNull(); + originalTip.Should().NotBeNull(); // /api/project/resetProject/{code} // /api/project/finishResetProject/{code} // leave project empty @@ -152,9 +152,8 @@ public async Task SendReceiveAfterProjectReset(HgProtocol protocol) response = await _adminApiTester.HttpClient.GetAsync(tipUri); jsonResult = await response.Content.ReadFromJsonAsync(); var emptyTip = jsonResult?["node"]?.AsValue()?.ToString(); - emptyTip.ShouldNotBeNull(); - emptyTip.ShouldNotBeEmpty(); - emptyTip.Replace("0", "").ShouldBeEmpty(); + emptyTip.Should().NotBeNullOrEmpty(); + emptyTip.Replace("0", "").Should().BeEmpty(); // Step 3: do Send/Receive if (protocol == HgProtocol.Resumable) @@ -175,8 +174,8 @@ public async Task SendReceiveAfterProjectReset(HgProtocol protocol) response = await _adminApiTester.HttpClient.GetAsync(tipUri); jsonResult = await response.Content.ReadFromJsonAsync(); var postSRTip = jsonResult?["node"]?.AsValue()?.ToString(); - postSRTip.ShouldNotBeNull(); - postSRTip.ShouldBe(originalTip); + postSRTip.Should().NotBeNull(); + postSRTip.Should().Be(originalTip); } [Fact] @@ -207,7 +206,7 @@ private async Task SendNewProject(int totalSizeMb, int fileCount) var fileName = $"test-file{i}.bin"; WriteFile(Path.Combine(sendReceiveParams.Dir, fileName), totalSizeMb / fileCount); HgRunner.Run($"hg add {fileName}", sendReceiveParams.Dir, 5, progress); - HgRunner.Run($"""hg commit -m "large file commit {i}" """, sendReceiveParams.Dir, 5, progress).ExitCode.ShouldBe(0); + HgRunner.Run($"""hg commit -m "large file commit {i}" """, sendReceiveParams.Dir, 5, progress).ExitCode.Should().Be(0); } var srResult = _sendReceiveService.SendReceiveProject(sendReceiveParams, AdminAuth); @@ -235,7 +234,7 @@ public void InvalidPassOnCloneHgWeb() var sendReceiveParams = GetParams(HgProtocol.Hgweb); var act = () => _sendReceiveService.CloneProject(sendReceiveParams, InvalidPass); - act.ShouldThrow(); + act.Should().Throw(); } [Fact] @@ -244,7 +243,7 @@ public void InvalidPassOnCloneHgResumable() var sendReceiveParams = GetParams(HgProtocol.Resumable); var act = () => _sendReceiveService.CloneProject(sendReceiveParams, InvalidPass); - act.ShouldThrow(); + act.Should().Throw(); } [Fact] @@ -254,7 +253,7 @@ public void InvalidPassOnSendReceiveHgWeb() _sendReceiveService.CloneProject(sendReceiveParams, ManagerAuth); var act = () => _sendReceiveService.SendReceiveProject(sendReceiveParams, InvalidPass); - act.ShouldThrow(); + act.Should().Throw(); } [Fact] @@ -264,7 +263,7 @@ public void InvalidPassOnSendReceiveHgResumable() _sendReceiveService.CloneProject(sendReceiveParams, ManagerAuth); var act = () => _sendReceiveService.SendReceiveProject(sendReceiveParams, InvalidPass); - act.ShouldThrow(); + act.Should().Throw(); } [Fact] @@ -272,7 +271,7 @@ public void InvalidUserCloneHgWeb() { var sendReceiveParams = GetParams(HgProtocol.Hgweb); var act = () => _sendReceiveService.CloneProject(sendReceiveParams, InvalidUser); - act.ShouldThrow(); + act.Should().Throw(); } [Fact] @@ -280,7 +279,7 @@ public void InvalidUserCloneHgResumable() { var sendReceiveParams = GetParams(HgProtocol.Resumable); var act = () => _sendReceiveService.CloneProject(sendReceiveParams, InvalidUser); - act.ShouldThrow(); + act.Should().Throw(); } [Fact] @@ -289,8 +288,8 @@ public void InvalidProjectAdminLogin() var sendReceiveParams = GetParams(HgProtocol.Hgweb, "non-existent-project"); var act = () => _sendReceiveService.CloneProject(sendReceiveParams, AdminAuth); - act.ShouldThrow(); - Directory.GetFiles(sendReceiveParams.Dir).ShouldBeEmpty(); + act.Should().Throw(); + Directory.GetFiles(sendReceiveParams.Dir).Should().BeEmpty(); } [Fact] @@ -299,8 +298,8 @@ public void InvalidProjectManagerLogin() var sendReceiveParams = GetParams(HgProtocol.Hgweb, "non-existent-project"); var act = () => _sendReceiveService.CloneProject(sendReceiveParams, ManagerAuth); - act.ShouldThrow(); - Directory.GetFiles(sendReceiveParams.Dir).ShouldBeEmpty(); + act.Should().Throw(); + Directory.GetFiles(sendReceiveParams.Dir).Should().BeEmpty(); } [Fact] @@ -309,7 +308,7 @@ public void UnauthorizedUserCloneHgWeb() var sendReceiveParams = GetParams(HgProtocol.Hgweb); var act = () => _sendReceiveService.CloneProject(sendReceiveParams, UnauthorizedUser); - act.ShouldThrow(); + act.Should().Throw(); } [Fact] @@ -318,6 +317,6 @@ public void UnauthorizedUserCloneHgResumable() var sendReceiveParams = GetParams(HgProtocol.Resumable); var act = () => _sendReceiveService.CloneProject(sendReceiveParams, UnauthorizedUser); - act.ShouldThrow(); + act.Should().Throw(); } } diff --git a/backend/Testing/Testing.csproj b/backend/Testing/Testing.csproj index 04fa2ae8c..a473fcdaa 100644 --- a/backend/Testing/Testing.csproj +++ b/backend/Testing/Testing.csproj @@ -1,10 +1,6 @@ - net8.0 - enable - enable - false $(MSBuildProjectDirectory) @@ -16,7 +12,7 @@ - + @@ -27,22 +23,22 @@ - - + + - - - - + + + + - + - + - runtime; build; native; contentfiles; analyzers; buildtransitive all + runtime; build; native; contentfiles; analyzers; buildtransitive runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/backend/Testing/Usings.cs b/backend/Testing/Usings.cs index 8c927eb74..c802f4480 100644 --- a/backend/Testing/Usings.cs +++ b/backend/Testing/Usings.cs @@ -1 +1 @@ -global using Xunit; \ No newline at end of file +global using Xunit; diff --git a/backend/harmony b/backend/harmony index 33b1aba76..c13987d13 160000 --- a/backend/harmony +++ b/backend/harmony @@ -1 +1 @@ -Subproject commit 33b1aba763633e8fc63f97b1d02332d1e1739c5a +Subproject commit c13987d13f7fa4c37e0ebdd28b04e42a31df7e4c diff --git a/deployment/base/lexbox-deployment.yaml b/deployment/base/lexbox-deployment.yaml index 15f25a185..a7c4da81a 100644 --- a/deployment/base/lexbox-deployment.yaml +++ b/deployment/base/lexbox-deployment.yaml @@ -205,6 +205,9 @@ spec: value: /tmp/tus-test-upload - name: Tus__ResetUploadPath value: /tmp/tus-reset-upload + - name: Services__fwHeadless__http__0 + value: fw-headless + - name: otel-collector image: otel/opentelemetry-collector-contrib:0.101.0 diff --git a/frontend/Taskfile.yml b/frontend/Taskfile.yml index 4c37bd215..ead41d753 100644 --- a/frontend/Taskfile.yml +++ b/frontend/Taskfile.yml @@ -21,7 +21,8 @@ tasks: - corepack enable || true - pnpm install playwright-tests: - cmd: pnpm test + aliases: [ pt ] + cmd: pnpm run test {{.CLI_ARGS}} playwright-generate-tests: cmds: diff --git a/frontend/schema.graphql b/frontend/schema.graphql index 9e136c3a6..3ee3ef5e3 100644 --- a/frontend/schema.graphql +++ b/frontend/schema.graphql @@ -134,7 +134,7 @@ type CreateProjectPayload { } type CreateProjectResponse { - id: UUID + id: UUID! result: CreateProjectResult! } @@ -192,6 +192,10 @@ type InvalidEmailError implements Error { address: String! } +type InvalidOperationError implements Error { + message: String! +} + type IsAdminResponse { value: Boolean! } @@ -441,6 +445,7 @@ type Query { orgs(where: OrganizationFilterInput @cost(weight: "10") orderBy: [OrganizationSortInput!] @cost(weight: "10")): [Organization!]! @cost(weight: "10") myOrgs(where: OrganizationFilterInput @cost(weight: "10") orderBy: [OrganizationSortInput!] @cost(weight: "10")): [Organization!]! @cost(weight: "10") usersInMyOrg(skip: Int take: Int where: UserFilterInput @cost(weight: "10") orderBy: [UserSortInput!] @cost(weight: "10")): UsersInMyOrgCollectionSegment @listSize(assumedSize: 1000, slicingArguments: [ "take" ], sizedFields: [ "items" ]) @cost(weight: "10") + usersICanSee(skip: Int take: Int where: UserFilterInput @cost(weight: "10") orderBy: [UserSortInput!] @cost(weight: "10")): UsersICanSeeCollectionSegment @listSize(assumedSize: 1000, slicingArguments: [ "take" ], sizedFields: [ "items" ]) @cost(weight: "10") orgById(orgId: UUID!): OrgById @cost(weight: "10") users(skip: Int take: Int where: UserFilterInput @cost(weight: "10") orderBy: [UserSortInput!] @cost(weight: "10")): UsersCollectionSegment @authorize(policy: "AdminRequiredPolicy") @listSize(assumedSize: 1000, slicingArguments: [ "take" ], sizedFields: [ "items" ]) @cost(weight: "10") me: MeDto @cost(weight: "10") @@ -541,7 +546,6 @@ type User { createdById: UUID createdBy: User usersICreated: [User!]! - googleId: String organizations: [OrgMember!]! id: UUID! createdDate: DateTime! @@ -562,6 +566,15 @@ type UsersCollectionSegment { totalCount: Int! @cost(weight: "10") } +"A segment of a collection." +type UsersICanSeeCollectionSegment { + "Information to aid in pagination." + pageInfo: CollectionSegmentInfo! + "A flattened list of the items." + items: [User!] + totalCount: Int! @cost(weight: "10") +} + "A segment of a collection." type UsersInMyOrgCollectionSegment { "Information to aid in pagination." @@ -615,7 +628,7 @@ union LeaveProjectError = NotFoundError | LastMemberCantLeaveError union RemoveProjectFromOrgError = DbError | NotFoundError -union SendNewVerificationEmailByAdminError = NotFoundError | DbError | UniqueValueError +union SendNewVerificationEmailByAdminError = NotFoundError | DbError | InvalidOperationError union SetOrgMemberRoleError = DbError | NotFoundError | OrgMemberInvitedByEmail @@ -741,6 +754,7 @@ input CreateProjectInput { isConfidential: Boolean! projectManagerId: UUID orgId: UUID + forceDraft: Boolean! = false } input DateTimeOperationFilterInput { @@ -885,13 +899,6 @@ input ListFilterInputTypeOfProjectUsersFilterInput { any: Boolean @cost(weight: "10") } -input ListFilterInputTypeOfUserFilterInput { - all: UserFilterInput @cost(weight: "10") - none: UserFilterInput @cost(weight: "10") - some: UserFilterInput @cost(weight: "10") - any: Boolean @cost(weight: "10") -} - input OrgMemberFilterInput { and: [OrgMemberFilterInput!] or: [OrgMemberFilterInput!] @@ -1126,46 +1133,17 @@ input UserFilterInput { or: [UserFilterInput!] name: StringOperationFilterInput email: StringOperationFilterInput - localizationCode: StringOperationFilterInput isAdmin: BooleanOperationFilterInput - passwordHash: StringOperationFilterInput - salt: StringOperationFilterInput - passwordStrength: IntOperationFilterInput - lastActive: DateTimeOperationFilterInput - emailVerified: BooleanOperationFilterInput - canCreateProjects: BooleanOperationFilterInput createdById: UuidOperationFilterInput - createdBy: UserFilterInput - usersICreated: ListFilterInputTypeOfUserFilterInput - locked: BooleanOperationFilterInput username: StringOperationFilterInput - googleId: StringOperationFilterInput - projects: ListFilterInputTypeOfProjectUsersFilterInput - organizations: ListFilterInputTypeOfOrgMemberFilterInput id: UuidOperationFilterInput - createdDate: DateTimeOperationFilterInput - updatedDate: DateTimeOperationFilterInput } input UserSortInput { name: SortEnumType @cost(weight: "10") email: SortEnumType @cost(weight: "10") - localizationCode: SortEnumType @cost(weight: "10") - isAdmin: SortEnumType @cost(weight: "10") - passwordHash: SortEnumType @cost(weight: "10") - salt: SortEnumType @cost(weight: "10") - passwordStrength: SortEnumType @cost(weight: "10") - lastActive: SortEnumType @cost(weight: "10") - emailVerified: SortEnumType @cost(weight: "10") - canCreateProjects: SortEnumType @cost(weight: "10") - createdById: SortEnumType @cost(weight: "10") - createdBy: UserSortInput @cost(weight: "10") - locked: SortEnumType @cost(weight: "10") username: SortEnumType @cost(weight: "10") - googleId: SortEnumType @cost(weight: "10") - id: SortEnumType @cost(weight: "10") createdDate: SortEnumType @cost(weight: "10") - updatedDate: SortEnumType @cost(weight: "10") } input UuidOperationFilterInput { diff --git a/frontend/src/lib/forms/Button.svelte b/frontend/src/lib/forms/Button.svelte index b2f8c1e9c..595e951ba 100644 --- a/frontend/src/lib/forms/Button.svelte +++ b/frontend/src/lib/forms/Button.svelte @@ -7,13 +7,16 @@ export let type: undefined | 'submit' = undefined; export let size: undefined | 'btn-sm' = undefined; export let disabled = false; + export let customLoader = false; diff --git a/frontend/src/lib/forms/UserTypeahead.svelte b/frontend/src/lib/forms/UserTypeahead.svelte index 2f300197d..4f721f17e 100644 --- a/frontend/src/lib/forms/UserTypeahead.svelte +++ b/frontend/src/lib/forms/UserTypeahead.svelte @@ -1,6 +1,6 @@ {#if icon} - + {/if} diff --git a/frontend/src/routes/(authenticated)/org/[org_id]/AddOrgMemberModal.svelte b/frontend/src/routes/(authenticated)/org/[org_id]/AddOrgMemberModal.svelte index f79ae9d11..b7131b88b 100644 --- a/frontend/src/routes/(authenticated)/org/[org_id]/AddOrgMemberModal.svelte +++ b/frontend/src/routes/(authenticated)/org/[org_id]/AddOrgMemberModal.svelte @@ -10,7 +10,7 @@ import { SupHelp, helpLinks } from '$lib/components/help'; import type { UUID } from 'crypto'; import { _addOrgMember, type Org } from './+page'; - import type { SingleUserInMyOrgTypeaheadResult, SingleUserTypeaheadResult } from '$lib/gql/typeahead-queries'; + import type { SingleUserICanSeeTypeaheadResult, SingleUserTypeaheadResult } from '$lib/gql/typeahead-queries'; import UserProjects, { type Project } from '$lib/components/Users/UserProjects.svelte'; export let org: Org; @@ -37,7 +37,7 @@ selectedProjects = []; } - function populateUserProjects(user: SingleUserTypeaheadResult | SingleUserInMyOrgTypeaheadResult | null): void { + function populateUserProjects(user: SingleUserTypeaheadResult | SingleUserICanSeeTypeaheadResult | null): void { resetProjects(); if (user && 'projects' in user) { const userProjects = [...user.projects.map(p => ({memberRole: p.role, id: p.project.id, code: p.project.code, name: p.project.name}))]; @@ -102,6 +102,7 @@ error={errors.usernameOrEmail} on:selectedUser={(event) => populateUserProjects(event.detail)} autofocus + exclude={org.members.map(m => m.user.id)} /> {:else} {/if} {#if project.type === ProjectType.FlEx && $isDev} + {:else} @@ -517,7 +519,7 @@ {$t('project_page.add_user.add_button')} - + diff --git a/frontend/src/routes/(authenticated)/project/[project_code]/AddProjectMember.svelte b/frontend/src/routes/(authenticated)/project/[project_code]/AddProjectMember.svelte index 4d0249557..13d4fa305 100644 --- a/frontend/src/routes/(authenticated)/project/[project_code]/AddProjectMember.svelte +++ b/frontend/src/routes/(authenticated)/project/[project_code]/AddProjectMember.svelte @@ -4,14 +4,14 @@ import { ProjectRole } from '$lib/gql/types'; import t from '$lib/i18n'; import { z } from 'zod'; - import { _addProjectMember } from './+page'; + import { _addProjectMember, type Project } from './+page'; import { useNotifications } from '$lib/notify'; import { page } from '$app/stores' import UserTypeahead from '$lib/forms/UserTypeahead.svelte'; import { SupHelp, helpLinks } from '$lib/components/help'; import Checkbox from '$lib/forms/Checkbox.svelte'; - export let projectId: string; + export let project: Project; const schema = z.object({ usernameOrEmail: z.string().trim() .min(1, $t('project_page.add_user.empty_user_field')) @@ -31,7 +31,7 @@ if (initialUserId) selectedUserId = initialUserId; const { response, formState } = await formModal.open(initialValue, async () => { const { error } = await _addProjectMember({ - projectId, + projectId: project.id, usernameOrEmail: $form.usernameOrEmail ?? '', userId: selectedUserId, role: $form.role, @@ -86,6 +86,7 @@ on:selectedUserId={({ detail }) => { selectedUserId = detail; }} + exclude={project.users.map(m => m.user.id)} /> diff --git a/frontend/src/routes/(authenticated)/project/[project_code]/CrdtSyncButton.svelte b/frontend/src/routes/(authenticated)/project/[project_code]/CrdtSyncButton.svelte new file mode 100644 index 000000000..324c40126 --- /dev/null +++ b/frontend/src/routes/(authenticated)/project/[project_code]/CrdtSyncButton.svelte @@ -0,0 +1,43 @@ + + + diff --git a/frontend/src/routes/(authenticated)/project/create/+page.svelte b/frontend/src/routes/(authenticated)/project/create/+page.svelte index a5cb73764..a651beca8 100644 --- a/frontend/src/routes/(authenticated)/project/create/+page.svelte +++ b/frontend/src/routes/(authenticated)/project/create/+page.svelte @@ -21,6 +21,7 @@ import { NewTabLinkRenderer } from '$lib/components/Markdown'; import Button from '$lib/forms/Button.svelte'; import {projectUrl} from '$lib/util/project'; + import DevContent from '$lib/layout/DevContent.svelte'; export let data; $: user = data.user; @@ -47,6 +48,7 @@ isConfidential: z.boolean().default(false), orgId: z.string().trim() }); + let forceDraft = false; //random guid let projectId:string = crypto.randomUUID(); @@ -61,6 +63,7 @@ isConfidential: $form.isConfidential, projectManagerId: requestingUser?.id, orgId: $form.orgId === '' ? null : $form.orgId, + forceDraft }); if (result.error) { if (result.error.byCode(DbErrorCode.Duplicate)) { @@ -329,6 +332,9 @@ + + + {#if data.user.canCreateProjects} diff --git a/frontend/src/routes/(authenticated)/project/create/+page.ts b/frontend/src/routes/(authenticated)/project/create/+page.ts index 6d04d4f5d..2733219c0 100644 --- a/frontend/src/routes/(authenticated)/project/create/+page.ts +++ b/frontend/src/routes/(authenticated)/project/create/+page.ts @@ -1,9 +1,9 @@ -import type { $OpResult, AskToJoinProjectMutation, CreateProjectInput, CreateProjectMutation, ProjectsByLangCodeAndOrgQuery, ProjectsByNameAndOrgQuery } from '$lib/gql/types'; -import { getClient, graphql } from '$lib/gql'; +import type {$OpResult, AskToJoinProjectMutation, CreateProjectInput, CreateProjectMutation, ProjectsByLangCodeAndOrgQuery, ProjectsByNameAndOrgQuery} from '$lib/gql/types'; +import {getClient, graphql} from '$lib/gql'; -import type { PageLoadEvent } from './$types'; -import { getSearchParam } from '$lib/util/query-params'; -import { isGuid } from '$lib/util/guid'; +import type {PageLoadEvent} from './$types'; +import {getSearchParam} from '$lib/util/query-params'; +import {isGuid} from '$lib/util/guid'; export async function load(event: PageLoadEvent) { const userIsAdmin = (await event.parent()).user.isAdmin; @@ -14,7 +14,7 @@ export async function load(event: PageLoadEvent) { const userResultsPromise = await client.query(graphql(` query loadRequestingUser($userId: UUID!) { users( - where: {id: {eq: $userId}}) { + where: {id: {eq: $userId}}, take: 1) { items { id name diff --git a/frontend/tests/draftProject.test.ts b/frontend/tests/draftProject.test.ts new file mode 100644 index 000000000..e010eca6c --- /dev/null +++ b/frontend/tests/draftProject.test.ts @@ -0,0 +1,56 @@ +import * as testEnv from './envVars'; + +import {AdminDashboardPage} from './pages/adminDashboardPage'; +import {ProjectPage} from './pages/projectPage'; +import {loginAs} from './utils/authHelpers'; +import {test, type CreateProjectResponse} from './fixtures'; +import {waitForGqlResponse} from './utils/gqlHelpers'; +import {expect} from '@playwright/test'; +import {UserDashboardPage} from './pages/userDashboardPage'; +import {EmailSubjects} from './email/email-page'; +import {UserAccountSettingsPage} from './pages/userAccountSettingsPage'; + +test('Request and approve draft project', async ({page, tempUser, uniqueTestId}) => { + const userDashboardPage = await test.step('Login as user without project create permission', async () => { + await loginAs(page.request, tempUser.email, tempUser.password); + return await new UserDashboardPage(page).goto(); + }); + + await test.step('Verify email so we can request a project', async () => { + const emailPage = await tempUser.mailbox.openEmail(page, EmailSubjects.VerifyEmail); + const pagePromise = emailPage.page.context().waitForEvent('page'); + await emailPage.clickVerifyEmail(); + const newPage = await pagePromise; + await new UserAccountSettingsPage(newPage).waitFor(); + await newPage.close(); + }); + + let project = await test.step('Request a new project', async () => { + await userDashboardPage.page.reload(); + await userDashboardPage.waitFor(); + const requestProjectPage = await userDashboardPage.clickCreateProject(); + const projectCode = `draft-project-test-${uniqueTestId}`; + const project = await requestProjectPage.fillForm({code: projectCode, purpose: 'Testing'}); // Software Developer is only available for admins + await requestProjectPage.submit(); + await userDashboardPage.waitFor(); + return project; + }); + + const createProjectResponse = await test.step('Approve the project as admin', async () => { + await loginAs(page.request, 'admin'); + const adminDashboard = await new AdminDashboardPage(page).goto(); + const approveProjectPage = await adminDashboard.openDraftProject(project.name); + project = await approveProjectPage.fillForm({...project, purpose: 'Software Developer'}); // Software Developer, so that it can be hard deleted + const createProjectResponse = await waitForGqlResponse(page, async () => { + await approveProjectPage.submit(); + }); + await new ProjectPage(page, project.name, project.code).waitFor(); + return createProjectResponse; + }); + + await test.step('Delete the project', async () => { + const projectId = createProjectResponse.data.createProject.createProjectResponse.id; + const deleteResponse = await page.request.delete(`${testEnv.serverBaseUrl}/api/project/${projectId}`); + expect(deleteResponse.ok()).toBeTruthy(); + }); +}); diff --git a/frontend/tests/logout.test.ts b/frontend/tests/logout.test.ts index f67c92f22..dd1236a40 100644 --- a/frontend/tests/logout.test.ts +++ b/frontend/tests/logout.test.ts @@ -2,7 +2,8 @@ import {AdminDashboardPage} from './pages/adminDashboardPage'; import {loginAs} from './utils/authHelpers'; import {test} from './fixtures'; -test('Back button after logout redirects back to login page', async ({page}) => { +test('Back button after logout redirects back to login page', async ({page, browserName}) => { + test.skip(browserName === 'firefox', 'Support for Clear-Site-Data: "cache" was removed and is WIP (https://bugzilla.mozilla.org/show_bug.cgi?id=1838506)'); await loginAs(page.request, 'admin'); const adminPage = await new AdminDashboardPage(page).goto(); const drawer = await adminPage.openDrawer(); diff --git a/frontend/tests/pages/adminDashboardPage.ts b/frontend/tests/pages/adminDashboardPage.ts index 92c9af3f2..9a8d38274 100644 --- a/frontend/tests/pages/adminDashboardPage.ts +++ b/frontend/tests/pages/adminDashboardPage.ts @@ -1,8 +1,8 @@ -import type { Locator, Page } from '@playwright/test'; +import type {Locator, Page} from '@playwright/test'; -import { AuthenticatedBasePage } from './authenticatedBasePage'; -import { CreateProjectPage } from './createProjectPage'; -import { ProjectPage } from './projectPage'; +import {AuthenticatedBasePage} from './authenticatedBasePage'; +import {CreateProjectPage} from './createProjectPage'; +import {ProjectPage} from './projectPage'; export class AdminDashboardPage extends AuthenticatedBasePage { get projectFilterBarInput(): Locator { return this.page.locator('.filter-bar').nth(0).getByRole('textbox'); } @@ -16,6 +16,11 @@ export class AdminDashboardPage extends AuthenticatedBasePage { return await new ProjectPage(this.page, projectName, projectCode).waitFor(); } + async openDraftProject(projectName: string): Promise { + await this.clickProject(projectName); + return await new CreateProjectPage(this.page).waitFor(); + } + async clickProject(projectName: string): Promise { await this.projectFilterBarInput.fill(projectName); // make sure project is visible const table = this.page.locator('table').nth(0); diff --git a/frontend/tests/pages/createProjectPage.ts b/frontend/tests/pages/createProjectPage.ts index 93b743ddf..97054bfec 100644 --- a/frontend/tests/pages/createProjectPage.ts +++ b/frontend/tests/pages/createProjectPage.ts @@ -1,25 +1,38 @@ -import { BasePage } from './basePage'; -import type { Page } from '@playwright/test'; +import {BasePage} from './basePage'; +import type {Page} from '@playwright/test'; + +type ProjectConfig = { + code: string; + customCode: boolean; + name: string; + type: string; + purpose: 'Software Developer' | 'Testing' | 'Training' | 'Language Project'; + description: string; +}; export class CreateProjectPage extends BasePage { constructor(page: Page) { - super(page, page.getByRole('heading', { name: 'Create Project' }), `/project/create`); + super(page, page.getByRole('heading', { name: /(Create|Request) Project/ }), `/project/create`); } - async fillForm(values: { code: string, customCode?: boolean, name?: string, type?: string, purpose?: string, description?: string }): Promise { - const { code, customCode = false, name = code, type, purpose = 'Software Developer', description = name } = values; + async fillForm(values: Pick & Partial): Promise { + let code = values.code; + const { customCode = false, name = code, type = 'FLEx', purpose = 'Software Developer', description = name } = values; await this.page.getByLabel('Name').fill(name); await this.page.getByLabel('Description').fill(description ?? name); - if (type) await this.page.getByLabel('Project type').selectOption({ label: type }); - if (purpose) await this.page.getByLabel('Purpose').selectOption({ label: purpose }); + await this.page.getByLabel('Project type').selectOption({ label: type }); + await this.page.getByLabel('Purpose').selectOption({ label: purpose }); await this.page.getByLabel('Language Code').fill(code); if (customCode) { await this.page.getByLabel('Custom Code').check(); await this.page.getByLabel('Code', { exact: true }).fill(code); + } else { + code = await this.page.getByLabel('Code', {exact: true}).inputValue(); } + return { code, name, type, purpose, description, customCode }; } async submit(): Promise { - await this.page.getByRole('button', {name: 'Create Project'}).click(); + await this.page.getByRole('button', {name: /(Create|Request) Project/}).click(); } } diff --git a/frontend/tests/pages/userDashboardPage.ts b/frontend/tests/pages/userDashboardPage.ts index 2450db5ce..a99fa0d66 100644 --- a/frontend/tests/pages/userDashboardPage.ts +++ b/frontend/tests/pages/userDashboardPage.ts @@ -1,6 +1,7 @@ -import type { Page } from '@playwright/test'; -import { AuthenticatedBasePage } from './authenticatedBasePage'; -import { ProjectPage } from './projectPage'; +import {AuthenticatedBasePage} from './authenticatedBasePage'; +import {CreateProjectPage} from './createProjectPage'; +import type {Page} from '@playwright/test'; +import {ProjectPage} from './projectPage'; export class UserDashboardPage extends AuthenticatedBasePage { constructor(page: Page) { @@ -8,9 +9,19 @@ export class UserDashboardPage extends AuthenticatedBasePage { } async openProject(projectName: string, projectCode: string): Promise { + await this.selectGridView(); const projectHeader = this.page.getByRole('heading', {name: projectName}); const projectCard = this.page.locator('.card', {has: projectHeader}); await projectCard.click(); return new ProjectPage(this.page, projectName, projectCode).waitFor(); } + + async selectGridView(): Promise { + await this.page.click('.i-mdi-grid'); + } + + async clickCreateProject(): Promise { + await this.page.getByRole('link', {name: /(Create|Request) Project/, exact: true}).click(); + return new CreateProjectPage(this.page).waitFor(); + } } diff --git a/frontend/viewer/src/lib/Editor.svelte b/frontend/viewer/src/lib/Editor.svelte index 71bf2492f..bb56788bc 100644 --- a/frontend/viewer/src/lib/Editor.svelte +++ b/frontend/viewer/src/lib/Editor.svelte @@ -26,12 +26,14 @@ const viewSettings = useViewSettings(); async function onChange(e: { entry: IEntry, sense?: ISense, example?: IExampleSentence }) { + if (readonly) return; await updateEntry(e.entry); dispatch('change', {entry: e.entry}); updateInitialEntry(); } async function onDelete(e: { entry: IEntry, sense?: ISense, example?: IExampleSentence }) { + if (readonly) return; if (e.example !== undefined && e.sense !== undefined) { await saveHandler(() => lexboxApi.DeleteExampleSentence(e.entry.id, e.sense!.id, e.example!.id)); } else if (e.sense !== undefined) {