From b1dd7c19e79b2cf58602ba7a302b505e717d73b2 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Wed, 3 Aug 2022 19:32:38 +0200 Subject: [PATCH] Fix restore and backup. --- .../Apps/Commands/AssignContributor.cs | 8 +- .../Guards/GuardAppContributors.cs | 19 +- .../Backup/BackupProcessor.Run.cs | 55 ++++++ .../Backup/BackupProcessor.cs | 99 ++++------ .../Backup/RestoreProcessor.Run.cs | 58 ++++++ .../Backup/RestoreProcessor.cs | 172 ++++++++---------- .../Json/System/StringConverter.cs | 32 +++- .../States/SimpleState.cs | 34 +++- .../Guards/GuardAppContributorsTests.cs | 20 +- .../NamedIdTests.cs | 36 ++++ .../States/SimpleStateTests.cs | 25 +++ 11 files changed, 364 insertions(+), 194 deletions(-) create mode 100644 backend/src/Squidex.Domain.Apps.Entities/Backup/BackupProcessor.Run.cs create mode 100644 backend/src/Squidex.Domain.Apps.Entities/Backup/RestoreProcessor.Run.cs diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/AssignContributor.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/AssignContributor.cs index b0ebe7ff9..aafa9b2f0 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/AssignContributor.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/AssignContributor.cs @@ -1,4 +1,4 @@ -// ========================================================================== +// ========================================================================== // Squidex Headless CMS // ========================================================================== // Copyright (c) Squidex UG (haftungsbeschraenkt) @@ -15,8 +15,10 @@ namespace Squidex.Domain.Apps.Entities.Apps.Commands public string Role { get; set; } = Roles.Editor; - public bool Restoring { get; set; } + public bool IgnoreActor { get; set; } + + public bool IgnorePlans { get; set; } public bool Invite { get; set; } } -} \ No newline at end of file +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/DomainObject/Guards/GuardAppContributors.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/DomainObject/Guards/GuardAppContributors.cs index 7b4ead6f1..68a76a9e0 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Apps/DomainObject/Guards/GuardAppContributors.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Apps/DomainObject/Guards/GuardAppContributors.cs @@ -1,4 +1,4 @@ -// ========================================================================== +// ========================================================================== // Squidex Headless CMS // ========================================================================== // Copyright (c) Squidex UG (haftungsbeschraenkt) @@ -43,19 +43,16 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject.Guards throw new DomainObjectNotFoundException(command.ContributorId); } - if (!command.Restoring) + if (!command.IgnoreActor && string.Equals(command.ContributorId, command.Actor?.Identifier, StringComparison.OrdinalIgnoreCase)) { - if (string.Equals(command.ContributorId, command.Actor?.Identifier, StringComparison.OrdinalIgnoreCase)) - { - throw new DomainForbiddenException(T.Get("apps.contributors.cannotChangeYourself")); - } + throw new DomainForbiddenException(T.Get("apps.contributors.cannotChangeYourself")); + } - if (!contributors.TryGetValue(command.ContributorId, out _)) + if (!command.IgnorePlans && !contributors.TryGetValue(command.ContributorId, out _)) + { + if (plan.MaxContributors > 0 && contributors.Count >= plan.MaxContributors) { - if (plan.MaxContributors > 0 && contributors.Count >= plan.MaxContributors) - { - e(T.Get("apps.contributors.maxReached")); - } + e(T.Get("apps.contributors.maxReached")); } } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Backup/BackupProcessor.Run.cs b/backend/src/Squidex.Domain.Apps.Entities/Backup/BackupProcessor.Run.cs new file mode 100644 index 000000000..5b309e7c6 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Backup/BackupProcessor.Run.cs @@ -0,0 +1,55 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Domain.Apps.Entities.Backup.State; +using Squidex.Infrastructure; + +#pragma warning disable MA0040 // Flow the cancellation token + +namespace Squidex.Domain.Apps.Entities.Backup +{ + public sealed partial class BackupProcessor + { + // Use a run to store all state that is necessary for a single run. + private sealed class Run : IDisposable + { + private readonly CancellationTokenSource cancellationSource = new CancellationTokenSource(); + private readonly CancellationTokenSource cancellationLinked; + + public IEnumerable Handlers { get; init; } + + public RefToken Actor { get; init; } + + public BackupJob Job { get; init; } + + public CancellationToken CancellationToken => cancellationLinked.Token; + + public Run(CancellationToken ct) + { + cancellationLinked = CancellationTokenSource.CreateLinkedTokenSource(ct, cancellationSource.Token); + } + + public void Dispose() + { + cancellationSource.Dispose(); + cancellationLinked.Dispose(); + } + + public void Cancel() + { + try + { + cancellationSource.Cancel(); + } + catch (ObjectDisposedException) + { + // Cancellation token might have been disposed, if the run is completed. + } + } + } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Backup/BackupProcessor.cs b/backend/src/Squidex.Domain.Apps.Entities/Backup/BackupProcessor.cs index 4d17f2c1e..a5faa2508 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Backup/BackupProcessor.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Backup/BackupProcessor.cs @@ -21,9 +21,8 @@ using Squidex.Shared.Users; namespace Squidex.Domain.Apps.Entities.Backup { - public sealed class BackupProcessor + public sealed partial class BackupProcessor { - private static readonly Duration UpdateDuration = Duration.FromSeconds(1); private readonly IBackupArchiveLocation backupArchiveLocation; private readonly IBackupArchiveStore backupArchiveStore; private readonly IBackupHandlerFactory backupHandlerFactory; @@ -36,44 +35,6 @@ namespace Squidex.Domain.Apps.Entities.Backup private readonly DomainId appId; private Run? currentRun; - // Use a run to store all state that is necessary for a single run. - private sealed class Run : IDisposable - { - private readonly CancellationTokenSource cancellationSource = new CancellationTokenSource(); - private readonly CancellationTokenSource cancellationLinked; - - public IEnumerable Handlers { get; init; } - - public RefToken Actor { get; init; } - - public BackupJob Job { get; init; } - - public CancellationToken CancellationToken => cancellationLinked.Token; - - public Run(CancellationToken ct) - { - cancellationLinked = CancellationTokenSource.CreateLinkedTokenSource(ct, cancellationSource.Token); - } - - public void Dispose() - { - cancellationSource.Dispose(); - cancellationLinked.Dispose(); - } - - public void Cancel() - { - try - { - cancellationSource.Cancel(); - } - catch (ObjectDisposedException) - { - // Cancellation token might have been disposed, if the run is completed. - } - } - } - public IClock Clock { get; set; } = SystemClock.Instance; public BackupProcessor( @@ -195,10 +156,7 @@ namespace Squidex.Domain.Apps.Entities.Backup writer.WriteEvent(storedEvent, ct); - run.Job.HandledEvents = writer.WrittenEvents; - run.Job.HandledAssets = writer.WrittenAttachments; - - lastTimestamp = await WritePeriodically(lastTimestamp); + await LogAsync(run, writer.WrittenEvents, writer.WrittenAttachments); } foreach (var handler in run.Handlers) @@ -224,8 +182,7 @@ namespace Squidex.Domain.Apps.Entities.Backup await backupArchiveStore.UploadAsync(run.Job.Id, stream, ct); } - - run.Job.Status = JobStatus.Completed; + await SetStatusAsync(run, JobStatus.Completed); } catch (OperationCanceledException) { @@ -233,15 +190,9 @@ namespace Squidex.Domain.Apps.Entities.Backup } catch (Exception ex) { - log.LogError(ex, "Faield to make backup with backup id '{backupId}'.", run.Job.Id); - - run.Job.Status = JobStatus.Failed; - } - finally - { - run.Job.Stopped = Clock.GetCurrentInstant(); + await SetStatusAsync(run, JobStatus.Failed); - await state.WriteAsync(default); + log.LogError(ex, "Faield to make backup with backup id '{backupId}'.", run.Job.Id); } } @@ -250,20 +201,6 @@ namespace Squidex.Domain.Apps.Entities.Backup return $"^[^\\-]*-{Regex.Escape(appId.ToString())}"; } - private async Task WritePeriodically(Instant lastTimestamp) - { - var now = Clock.GetCurrentInstant(); - - if ((now - lastTimestamp) >= UpdateDuration) - { - lastTimestamp = now; - - await state.WriteAsync(); - } - - return lastTimestamp; - } - public Task DeleteAsync(DomainId id) { return scheduler.ScheduleAsync(async _ => @@ -301,5 +238,31 @@ namespace Squidex.Domain.Apps.Entities.Backup await state.WriteAsync(); } + + private Task SetStatusAsync(Run run, JobStatus status) + { + var now = Clock.GetCurrentInstant(); + + run.Job.Status = status; + + if (status == JobStatus.Failed || status == JobStatus.Completed) + { + run.Job.Stopped = now; + } + else if (status == JobStatus.Started) + { + run.Job.Started = now; + } + + return state.WriteAsync(ct: default); + } + + private Task LogAsync(Run run, int numEvents, int numAttachments) + { + run.Job.HandledEvents = numEvents; + run.Job.HandledAssets = numAttachments; + + return state.WriteAsync(100, run.CancellationToken); + } } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Backup/RestoreProcessor.Run.cs b/backend/src/Squidex.Domain.Apps.Entities/Backup/RestoreProcessor.Run.cs new file mode 100644 index 000000000..d99db2e26 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Backup/RestoreProcessor.Run.cs @@ -0,0 +1,58 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Domain.Apps.Entities.Backup.State; + +namespace Squidex.Domain.Apps.Entities.Backup +{ + public sealed partial class RestoreProcessor + { + // Use a run to store all state that is necessary for a single run. + private sealed class Run : IDisposable + { + private readonly CancellationTokenSource cancellationSource = new CancellationTokenSource(); + private readonly CancellationTokenSource cancellationLinked; + + public IEnumerable Handlers { get; init; } + + public IBackupReader Reader { get; set; } + + public RestoreJob Job { get; init; } + + public RestoreContext Context { get; set; } + + public StreamMapper StreamMapper { get; set; } + + public CancellationToken CancellationToken => cancellationLinked.Token; + + public Run(CancellationToken ct) + { + cancellationLinked = CancellationTokenSource.CreateLinkedTokenSource(ct, cancellationSource.Token); + } + + public void Dispose() + { + Reader?.Dispose(); + + cancellationSource.Dispose(); + cancellationLinked.Dispose(); + } + + public void Cancel() + { + try + { + cancellationSource.Cancel(); + } + catch (ObjectDisposedException) + { + // Cancellation token might have been disposed, if the run is completed. + } + } + } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Backup/RestoreProcessor.cs b/backend/src/Squidex.Domain.Apps.Entities/Backup/RestoreProcessor.cs index 5d2077bcb..0e1bf901b 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Backup/RestoreProcessor.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Backup/RestoreProcessor.cs @@ -23,7 +23,7 @@ using Squidex.Shared.Users; namespace Squidex.Domain.Apps.Entities.Backup { - public sealed class RestoreProcessor + public sealed partial class RestoreProcessor { private readonly IBackupArchiveLocation backupArchiveLocation; private readonly IBackupHandlerFactory backupHandlerFactory; @@ -37,65 +37,6 @@ namespace Squidex.Domain.Apps.Entities.Backup private readonly SimpleState state; private Run? currentRun; - // Use a run to store all state that is necessary for a single run. - private sealed class Run : IDisposable - { - private readonly CancellationTokenSource cancellationSource = new CancellationTokenSource(); - private readonly CancellationTokenSource cancellationLinked; - private readonly IClock clock; - - public IEnumerable Handlers { get; init; } - - public IBackupReader Reader { get; set; } - - public RestoreJob Job { get; init; } - - public RestoreContext Context { get; set; } - - public StreamMapper StreamMapper { get; set; } - - public CancellationToken CancellationToken => cancellationLinked.Token; - - public Run(IClock clock, CancellationToken ct) - { - cancellationLinked = CancellationTokenSource.CreateLinkedTokenSource(ct, cancellationSource.Token); - - this.clock = clock; - } - - public void Log(string message, bool replace = false) - { - if (replace && Job.Log.Count > 0) - { - Job.Log[^1] = $"{clock.GetCurrentInstant()}: {message}"; - } - else - { - Job.Log.Add($"{clock.GetCurrentInstant()}: {message}"); - } - } - - public void Dispose() - { - Reader?.Dispose(); - - cancellationSource.Dispose(); - cancellationLinked.Dispose(); - } - - public void Cancel() - { - try - { - cancellationSource.Cancel(); - } - catch (ObjectDisposedException) - { - // Cancellation token might have been disposed, if the run is completed. - } - } - } - public IClock Clock { get; set; } = SystemClock.Instance; public RestoreProcessor( @@ -155,7 +96,7 @@ namespace Squidex.Domain.Apps.Entities.Backup state.Value.Job?.EnsureCanStart(); // Set the current run first to indicate that we are running a rule at the moment. - var run = currentRun = new Run(Clock, ct) + var run = currentRun = new Run(ct) { Job = new RestoreJob { @@ -192,11 +133,11 @@ namespace Squidex.Domain.Apps.Entities.Backup { try { - run.Log("Started. The restore process has the following steps:"); - run.Log(" * Download backup"); - run.Log(" * Restore events and attachments."); - run.Log(" * Restore all objects like app, schemas and contents"); - run.Log(" * Complete the restore operation for all objects"); + await LogAsync(run, "Started. The restore process has the following steps:"); + await LogAsync(run, " * Download backup"); + await LogAsync(run, " * Restore events and attachments."); + await LogAsync(run, " * Restore all objects like app, schemas and contents"); + await LogAsync(run, " * Complete the restore operation for all objects"); log.LogInformation("Backup with job id {backupId} with from URL '{url}' started.", run.Job.Id, run.Job.Url); @@ -216,7 +157,7 @@ namespace Squidex.Domain.Apps.Entities.Backup await handler.RestoreAsync(run.Context, ct); } - run.Log($"Restored {handler.Name}"); + await LogAsync(run, $"Restored {handler.Name}"); } foreach (var handler in run.Handlers) @@ -226,43 +167,35 @@ namespace Squidex.Domain.Apps.Entities.Backup await handler.CompleteRestoreAsync(run.Context, run.Job.NewAppName!); } - run.Log($"Completed {handler.Name}"); + await LogAsync(run, $"Completed {handler.Name}"); } await AssignContributorAsync(run); - run.Job.Status = JobStatus.Completed; - run.Log("Completed, Yeah!"); + await SetStatusAsync(run, JobStatus.Completed, "Completed, Yeah!"); log.LogInformation("Backup with job id {backupId} from URL '{url}' completed.", run.Job.Id, run.Job.Url); } catch (Exception ex) { + var message = "Failed with internal error."; + switch (ex) { case BackupRestoreException backupException: - run.Log(backupException.Message); + message = backupException.Message; break; case FileNotFoundException fileNotFoundException: - run.Log(fileNotFoundException.Message); - break; - default: - run.Log("Failed with internal error"); + message = fileNotFoundException.Message; break; } await CleanupAsync(run); - run.Job.Status = JobStatus.Failed; + await SetStatusAsync(run, JobStatus.Failed, message); log.LogError(ex, "Backup with job id {backupId} from URL '{url}' failed.", run.Job.Id, run.Job.Url); } - finally - { - run.Job.Stopped = Clock.GetCurrentInstant(); - - await state.WriteAsync(ct); - } } } @@ -270,32 +203,41 @@ namespace Squidex.Domain.Apps.Entities.Backup { if (run.Job.Actor?.IsUser != true) { - run.Log("Current user not assigned because restore was triggered by client."); + await LogAsync(run, "Current user not assigned because restore was triggered by client."); return; } try { // Add the current user to the app, so that the admin can see it and verify integrity. - var command = new AssignContributor + await PublishAsync(run, new AssignContributor { - Actor = run.Job.Actor, - AppId = run.Job.AppId, ContributorId = run.Job.Actor.Identifier, - Restoring = true, + IgnoreActor = true, + IgnorePlans = true, Role = Role.Owner - }; - - await commandBus.PublishAsync(command, default); + }); - run.Log("Assigned current user."); + await LogAsync(run, "Assigned current user."); } catch (DomainException ex) { - run.Log($"Failed to assign contributor: {ex.Message}"); + await LogAsync(run, $"Failed to assign contributor: {ex.Message}"); } } + private Task PublishAsync(Run run, AppCommand command) + { + command.Actor = run.Job.Actor; + + if (command is IAppCommand appCommand) + { + appCommand.AppId = run.Job.AppId; + } + + return commandBus.PublishAsync(command, default); + } + private async Task CleanupAsync(Run run) { if (run.Job.AppId == null) @@ -321,11 +263,11 @@ namespace Squidex.Domain.Apps.Entities.Backup { using (Telemetry.Activities.StartActivity("Download")) { - run.Log("Downloading Backup"); + await LogAsync(run, "Downloading Backup"); var reader = await backupArchiveLocation.OpenReaderAsync(run.Job.Url, run.Job.Id, ct); - run.Log("Downloaded Backup"); + await LogAsync(run, "Downloaded Backup"); return reader; } @@ -355,7 +297,7 @@ namespace Squidex.Domain.Apps.Entities.Backup handled += commits.Count; - run.Log($"Reading {run.Reader.ReadEvents}/{handled} events and {run.Reader.ReadAttachments} attachments completed.", true); + await LogAsync(run, $"Reading {run.Reader.ReadEvents}/{handled} events and {run.Reader.ReadAttachments} attachments completed.", true); } catch (OperationCanceledException ex) { @@ -453,14 +395,50 @@ namespace Squidex.Domain.Apps.Entities.Backup using (Telemetry.Activities.StartActivity("CreateUsers")) { - run.Log("Creating Users"); + await LogAsync(run, "Creating Users"); await userMapping.RestoreAsync(run.Reader, userResolver, ct); - run.Log("Created Users"); + await LogAsync(run, "Created Users"); } run.Context = new RestoreContext(run.Job.AppId.Id, userMapping, run.Reader, previousAppId); } + + private Task SetStatusAsync(Run run, JobStatus status, string message) + { + var now = Clock.GetCurrentInstant(); + + run.Job.Status = status; + + if (status == JobStatus.Failed || status == JobStatus.Completed) + { + run.Job.Stopped = now; + } + else if (status == JobStatus.Started) + { + run.Job.Started = now; + } + + run.Job.Log.Add($"{now}: {message}"); + + return state.WriteAsync(ct: default); + } + + private Task LogAsync(Run run, string message, bool replace = false) + { + var now = Clock.GetCurrentInstant(); + + if (replace && run.Job.Log.Count > 0) + { + run.Job.Log[^1] = $"{now}: {message}"; + } + else + { + run.Job.Log.Add($"{now}: {message}"); + } + + return state.WriteAsync(100, run.CancellationToken); + } } } diff --git a/backend/src/Squidex.Infrastructure/Json/System/StringConverter.cs b/backend/src/Squidex.Infrastructure/Json/System/StringConverter.cs index 199081d38..2981051b3 100644 --- a/backend/src/Squidex.Infrastructure/Json/System/StringConverter.cs +++ b/backend/src/Squidex.Infrastructure/Json/System/StringConverter.cs @@ -32,15 +32,31 @@ namespace Squidex.Infrastructure.Json.System public override T? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { - var text = reader.GetString(); - try + switch (reader.TokenType) { - return convertFromString(text!); - } - catch (Exception ex) - { - ThrowHelper.JsonException("Error while converting from string.", ex); - return default; + case JsonTokenType.String: + var text = reader.GetString(); + try + { + return convertFromString(text!); + } + catch (Exception ex) + { + ThrowHelper.JsonException("Error while converting from string.", ex); + return default; + } + + case JsonTokenType.StartObject: + var optionsWithoutSelf = new JsonSerializerOptions(options); + + // Remove the current converter, otherwise we would create a stackoverflow exception. + optionsWithoutSelf.Converters.Remove(this); + + return JsonSerializer.Deserialize(ref reader, optionsWithoutSelf); + + default: + ThrowHelper.JsonException($"Expected string or object, got {reader.TokenType}."); + return default; } } diff --git a/backend/src/Squidex.Infrastructure/States/SimpleState.cs b/backend/src/Squidex.Infrastructure/States/SimpleState.cs index ad3752e32..9c8d69fb2 100644 --- a/backend/src/Squidex.Infrastructure/States/SimpleState.cs +++ b/backend/src/Squidex.Infrastructure/States/SimpleState.cs @@ -5,6 +5,7 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using NodaTime; using Squidex.Infrastructure.EventSourcing; namespace Squidex.Infrastructure.States @@ -13,6 +14,7 @@ namespace Squidex.Infrastructure.States { private readonly IPersistence persistence; private bool isLoaded; + private Instant lastWrite; public T Value { get; set; } = new T(); @@ -21,6 +23,8 @@ namespace Squidex.Infrastructure.States get => persistence.Version; } + public IClock Clock { get; set; } = SystemClock.Instance; + public SimpleState(IPersistenceFactory persistenceFactory, Type ownerType, string id) : this(persistenceFactory, ownerType, DomainId.Create(id)) { @@ -52,16 +56,35 @@ namespace Squidex.Infrastructure.States return persistence.DeleteAsync(ct); } - public Task WriteAsync( + public async Task WriteAsync(int ifNotWrittenWithinMs, CancellationToken ct = default) { - return persistence.WriteSnapshotAsync(Value, ct); + var now = Clock.GetCurrentInstant(); + + if (ifNotWrittenWithinMs > 0 && now.Minus(lastWrite).TotalMilliseconds < ifNotWrittenWithinMs) + { + return; + } + + await persistence.WriteSnapshotAsync(Value, ct); + + lastWrite = now; } - public Task WriteEventAsync(Envelope envelope, + public async Task WriteAsync( CancellationToken ct = default) { - return persistence.WriteEventAsync(envelope, ct); + await persistence.WriteSnapshotAsync(Value, ct); + + lastWrite = Clock.GetCurrentInstant(); + } + + public async Task WriteEventAsync(Envelope envelope, + CancellationToken ct = default) + { + await persistence.WriteEventAsync(envelope, ct); + + lastWrite = Clock.GetCurrentInstant(); } public Task UpdateAsync(Func updater, int retries = 20, @@ -73,6 +96,9 @@ namespace Squidex.Infrastructure.States public async Task UpdateAsync(Func updater, int retries = 20, CancellationToken ct = default) { + Guard.GreaterEquals(retries, 1); + Guard.LessThan(retries, 100); + if (!isLoaded) { await LoadAsync(ct); diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/DomainObject/Guards/GuardAppContributorsTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/DomainObject/Guards/GuardAppContributorsTests.cs index b98b954cd..aa87cff66 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/DomainObject/Guards/GuardAppContributorsTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/DomainObject/Guards/GuardAppContributorsTests.cs @@ -88,7 +88,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject.Guards [Fact] public async Task CanAssign_should_not_throw_exception_if_user_already_exists_with_some_role_but_is_from_restore() { - var command = new AssignContributor { ContributorId = "1", Role = Role.Owner, Restoring = true }; + var command = new AssignContributor { ContributorId = "1", Role = Role.Owner, IgnoreActor = true }; var contributors_1 = contributors_0.Assign("1", Role.Owner); @@ -126,6 +126,20 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject.Guards new ValidationError("You have reached the maximum number of contributors for your plan.")); } + [Fact] + public async Task CanAssign_should_not_throw_exception_if_contributor_max_reached_but_ignored() + { + A.CallTo(() => appPlan.MaxContributors) + .Returns(2); + + var command = new AssignContributor { ContributorId = "3", IgnorePlans = true }; + + var contributors_1 = contributors_0.Assign("1", Role.Owner); + var contributors_2 = contributors_1.Assign("2", Role.Editor); + + await GuardAppContributors.CanAssign(command, App(contributors_2), users, appPlan); + } + [Fact] public async Task CanAssign_should_not_throw_exception_if_user_found() { @@ -162,12 +176,12 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject.Guards } [Fact] - public async Task CanAssign_should_not_throw_exception_if_contributor_max_reached_but_from_restore() + public async Task CanAssign_should_not_throw_exception_if_contributor_max_reached_but_ígnored() { A.CallTo(() => appPlan.MaxContributors) .Returns(2); - var command = new AssignContributor { ContributorId = "3", Restoring = true }; + var command = new AssignContributor { ContributorId = "3", IgnorePlans = true }; var contributors_1 = contributors_0.Assign("1", Role.Editor); var contributors_2 = contributors_1.Assign("2", Role.Editor); diff --git a/backend/tests/Squidex.Infrastructure.Tests/NamedIdTests.cs b/backend/tests/Squidex.Infrastructure.Tests/NamedIdTests.cs index 1b922d32f..0d1e716cb 100644 --- a/backend/tests/Squidex.Infrastructure.Tests/NamedIdTests.cs +++ b/backend/tests/Squidex.Infrastructure.Tests/NamedIdTests.cs @@ -5,6 +5,8 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System.Text.Json.Serialization; +using Squidex.Infrastructure.Json.System; using Squidex.Infrastructure.TestHelpers; using Xunit; @@ -12,6 +14,12 @@ namespace Squidex.Infrastructure { public class NamedIdTests { + internal sealed record Wrapper + { + [JsonConverter(typeof(StringConverter>))] + public NamedId Value { get; set; } + } + [Fact] public void Should_instantiate_token() { @@ -113,6 +121,34 @@ namespace Squidex.Infrastructure Assert.Equal(value, serialized); } + [Fact] + public void Should_serialize_and_deserialize_old_object() + { + var value = new { id = 42L, name = "my-name" }; + + var serialized = value.SerializeAndDeserialize>(); + + Assert.Equal(NamedId.Of(42L, "my-name"), serialized); + } + + [Fact] + public void Should_deserialize_from_old_object_with_explicit_converter() + { + var value = new + { + value = new { id = 42, name = "my-name" } + }; + + var expected = new Wrapper + { + Value = NamedId.Of(42L, "my-name") + }; + + var serialized = value.SerializeAndDeserialize(); + + Assert.Equal(expected, serialized); + } + [Fact] public void Should_throw_exception_if_string_id_is_not_valid() { diff --git a/backend/tests/Squidex.Infrastructure.Tests/States/SimpleStateTests.cs b/backend/tests/Squidex.Infrastructure.Tests/States/SimpleStateTests.cs index 380bf48b5..6e2054664 100644 --- a/backend/tests/Squidex.Infrastructure.Tests/States/SimpleStateTests.cs +++ b/backend/tests/Squidex.Infrastructure.Tests/States/SimpleStateTests.cs @@ -6,6 +6,7 @@ // ========================================================================== using FakeItEasy; +using NodaTime; using Squidex.Infrastructure.TestHelpers; using Xunit; @@ -204,5 +205,29 @@ namespace Squidex.Infrastructure.States A.CallTo(() => testState.Persistence.ReadAsync(A._, ct)) .MustHaveHappenedANumberOfTimesMatching(x => x == 1); } + + [Fact] + public async Task Should_not_written_if_period_not_over() + { + var now = SystemClock.Instance.GetCurrentInstant(); + + var clock = A.Fake(); + + A.CallTo(() => clock.GetCurrentInstant()) + .Returns(now).NumberOfTimes(5).Then + .Returns(now.Plus(Duration.FromSeconds(2))); + + sut.Clock = clock; + + for (var i = 0; i < 10; i++) + { + await sut.WriteAsync(1000, ct); + } + + await sut.UpdateAsync(x => true, ct: ct); + + A.CallTo(() => testState.Persistence.WriteSnapshotAsync(sut.Value, ct)) + .MustHaveHappenedANumberOfTimesMatching(x => x == 3); + } } }