Browse Source

Fix restore and backup.

pull/909/head
Sebastian 3 years ago
parent
commit
b1dd7c19e7
  1. 6
      backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/AssignContributor.cs
  2. 9
      backend/src/Squidex.Domain.Apps.Entities/Apps/DomainObject/Guards/GuardAppContributors.cs
  3. 55
      backend/src/Squidex.Domain.Apps.Entities/Backup/BackupProcessor.Run.cs
  4. 99
      backend/src/Squidex.Domain.Apps.Entities/Backup/BackupProcessor.cs
  5. 58
      backend/src/Squidex.Domain.Apps.Entities/Backup/RestoreProcessor.Run.cs
  6. 172
      backend/src/Squidex.Domain.Apps.Entities/Backup/RestoreProcessor.cs
  7. 16
      backend/src/Squidex.Infrastructure/Json/System/StringConverter.cs
  8. 34
      backend/src/Squidex.Infrastructure/States/SimpleState.cs
  9. 20
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/DomainObject/Guards/GuardAppContributorsTests.cs
  10. 36
      backend/tests/Squidex.Infrastructure.Tests/NamedIdTests.cs
  11. 25
      backend/tests/Squidex.Infrastructure.Tests/States/SimpleStateTests.cs

6
backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/AssignContributor.cs

@ -1,4 +1,4 @@
// ========================================================================== // ==========================================================================
// Squidex Headless CMS // Squidex Headless CMS
// ========================================================================== // ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt) // Copyright (c) Squidex UG (haftungsbeschraenkt)
@ -15,7 +15,9 @@ namespace Squidex.Domain.Apps.Entities.Apps.Commands
public string Role { get; set; } = Roles.Editor; 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; } public bool Invite { get; set; }
} }

9
backend/src/Squidex.Domain.Apps.Entities/Apps/DomainObject/Guards/GuardAppContributors.cs

@ -1,4 +1,4 @@
// ========================================================================== // ==========================================================================
// Squidex Headless CMS // Squidex Headless CMS
// ========================================================================== // ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt) // Copyright (c) Squidex UG (haftungsbeschraenkt)
@ -43,14 +43,12 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject.Guards
throw new DomainObjectNotFoundException(command.ContributorId); 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)
{ {
@ -58,7 +56,6 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject.Guards
} }
} }
} }
}
}); });
} }

55
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<IBackupHandler> 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.
}
}
}
}
}

99
backend/src/Squidex.Domain.Apps.Entities/Backup/BackupProcessor.cs

@ -21,9 +21,8 @@ using Squidex.Shared.Users;
namespace Squidex.Domain.Apps.Entities.Backup 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 IBackupArchiveLocation backupArchiveLocation;
private readonly IBackupArchiveStore backupArchiveStore; private readonly IBackupArchiveStore backupArchiveStore;
private readonly IBackupHandlerFactory backupHandlerFactory; private readonly IBackupHandlerFactory backupHandlerFactory;
@ -36,44 +35,6 @@ namespace Squidex.Domain.Apps.Entities.Backup
private readonly DomainId appId; private readonly DomainId appId;
private Run? currentRun; 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<IBackupHandler> 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 IClock Clock { get; set; } = SystemClock.Instance;
public BackupProcessor( public BackupProcessor(
@ -195,10 +156,7 @@ namespace Squidex.Domain.Apps.Entities.Backup
writer.WriteEvent(storedEvent, ct); writer.WriteEvent(storedEvent, ct);
run.Job.HandledEvents = writer.WrittenEvents; await LogAsync(run, writer.WrittenEvents, writer.WrittenAttachments);
run.Job.HandledAssets = writer.WrittenAttachments;
lastTimestamp = await WritePeriodically(lastTimestamp);
} }
foreach (var handler in run.Handlers) foreach (var handler in run.Handlers)
@ -224,8 +182,7 @@ namespace Squidex.Domain.Apps.Entities.Backup
await backupArchiveStore.UploadAsync(run.Job.Id, stream, ct); await backupArchiveStore.UploadAsync(run.Job.Id, stream, ct);
} }
await SetStatusAsync(run, JobStatus.Completed);
run.Job.Status = JobStatus.Completed;
} }
catch (OperationCanceledException) catch (OperationCanceledException)
{ {
@ -233,15 +190,9 @@ namespace Squidex.Domain.Apps.Entities.Backup
} }
catch (Exception ex) catch (Exception ex)
{ {
log.LogError(ex, "Faield to make backup with backup id '{backupId}'.", run.Job.Id); await SetStatusAsync(run, JobStatus.Failed);
run.Job.Status = JobStatus.Failed;
}
finally
{
run.Job.Stopped = Clock.GetCurrentInstant();
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())}"; return $"^[^\\-]*-{Regex.Escape(appId.ToString())}";
} }
private async Task<Instant> WritePeriodically(Instant lastTimestamp)
{
var now = Clock.GetCurrentInstant();
if ((now - lastTimestamp) >= UpdateDuration)
{
lastTimestamp = now;
await state.WriteAsync();
}
return lastTimestamp;
}
public Task DeleteAsync(DomainId id) public Task DeleteAsync(DomainId id)
{ {
return scheduler.ScheduleAsync(async _ => return scheduler.ScheduleAsync(async _ =>
@ -301,5 +238,31 @@ namespace Squidex.Domain.Apps.Entities.Backup
await state.WriteAsync(); 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);
}
} }
} }

58
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<IBackupHandler> 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.
}
}
}
}
}

172
backend/src/Squidex.Domain.Apps.Entities/Backup/RestoreProcessor.cs

@ -23,7 +23,7 @@ using Squidex.Shared.Users;
namespace Squidex.Domain.Apps.Entities.Backup namespace Squidex.Domain.Apps.Entities.Backup
{ {
public sealed class RestoreProcessor public sealed partial class RestoreProcessor
{ {
private readonly IBackupArchiveLocation backupArchiveLocation; private readonly IBackupArchiveLocation backupArchiveLocation;
private readonly IBackupHandlerFactory backupHandlerFactory; private readonly IBackupHandlerFactory backupHandlerFactory;
@ -37,65 +37,6 @@ namespace Squidex.Domain.Apps.Entities.Backup
private readonly SimpleState<BackupRestoreState> state; private readonly SimpleState<BackupRestoreState> state;
private Run? currentRun; 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<IBackupHandler> 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 IClock Clock { get; set; } = SystemClock.Instance;
public RestoreProcessor( public RestoreProcessor(
@ -155,7 +96,7 @@ namespace Squidex.Domain.Apps.Entities.Backup
state.Value.Job?.EnsureCanStart(); state.Value.Job?.EnsureCanStart();
// Set the current run first to indicate that we are running a rule at the moment. // 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 Job = new RestoreJob
{ {
@ -192,11 +133,11 @@ namespace Squidex.Domain.Apps.Entities.Backup
{ {
try try
{ {
run.Log("Started. The restore process has the following steps:"); await LogAsync(run, "Started. The restore process has the following steps:");
run.Log(" * Download backup"); await LogAsync(run, " * Download backup");
run.Log(" * Restore events and attachments."); await LogAsync(run, " * Restore events and attachments.");
run.Log(" * Restore all objects like app, schemas and contents"); await LogAsync(run, " * Restore all objects like app, schemas and contents");
run.Log(" * Complete the restore operation for all objects"); 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); 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); await handler.RestoreAsync(run.Context, ct);
} }
run.Log($"Restored {handler.Name}"); await LogAsync(run, $"Restored {handler.Name}");
} }
foreach (var handler in run.Handlers) foreach (var handler in run.Handlers)
@ -226,43 +167,35 @@ namespace Squidex.Domain.Apps.Entities.Backup
await handler.CompleteRestoreAsync(run.Context, run.Job.NewAppName!); await handler.CompleteRestoreAsync(run.Context, run.Job.NewAppName!);
} }
run.Log($"Completed {handler.Name}"); await LogAsync(run, $"Completed {handler.Name}");
} }
await AssignContributorAsync(run); await AssignContributorAsync(run);
run.Job.Status = JobStatus.Completed; await SetStatusAsync(run, JobStatus.Completed, "Completed, Yeah!");
run.Log("Completed, Yeah!");
log.LogInformation("Backup with job id {backupId} from URL '{url}' completed.", run.Job.Id, run.Job.Url); log.LogInformation("Backup with job id {backupId} from URL '{url}' completed.", run.Job.Id, run.Job.Url);
} }
catch (Exception ex) catch (Exception ex)
{ {
var message = "Failed with internal error.";
switch (ex) switch (ex)
{ {
case BackupRestoreException backupException: case BackupRestoreException backupException:
run.Log(backupException.Message); message = backupException.Message;
break; break;
case FileNotFoundException fileNotFoundException: case FileNotFoundException fileNotFoundException:
run.Log(fileNotFoundException.Message); message = fileNotFoundException.Message;
break;
default:
run.Log("Failed with internal error");
break; break;
} }
await CleanupAsync(run); 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); 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,30 +203,39 @@ namespace Squidex.Domain.Apps.Entities.Backup
{ {
if (run.Job.Actor?.IsUser != true) 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; return;
} }
try try
{ {
// Add the current user to the app, so that the admin can see it and verify integrity. // 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, ContributorId = run.Job.Actor.Identifier,
Restoring = true, IgnoreActor = true,
IgnorePlans = true,
Role = Role.Owner Role = Role.Owner
}; });
await commandBus.PublishAsync(command, default);
run.Log("Assigned current user."); await LogAsync(run, "Assigned current user.");
} }
catch (DomainException ex) 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) private async Task CleanupAsync(Run run)
@ -321,11 +263,11 @@ namespace Squidex.Domain.Apps.Entities.Backup
{ {
using (Telemetry.Activities.StartActivity("Download")) 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); var reader = await backupArchiveLocation.OpenReaderAsync(run.Job.Url, run.Job.Id, ct);
run.Log("Downloaded Backup"); await LogAsync(run, "Downloaded Backup");
return reader; return reader;
} }
@ -355,7 +297,7 @@ namespace Squidex.Domain.Apps.Entities.Backup
handled += commits.Count; 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) catch (OperationCanceledException ex)
{ {
@ -453,14 +395,50 @@ namespace Squidex.Domain.Apps.Entities.Backup
using (Telemetry.Activities.StartActivity("CreateUsers")) using (Telemetry.Activities.StartActivity("CreateUsers"))
{ {
run.Log("Creating Users"); await LogAsync(run, "Creating Users");
await userMapping.RestoreAsync(run.Reader, userResolver, ct); 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); 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);
}
} }
} }

16
backend/src/Squidex.Infrastructure/Json/System/StringConverter.cs

@ -32,6 +32,9 @@ namespace Squidex.Infrastructure.Json.System
public override T? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) public override T? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{ {
switch (reader.TokenType)
{
case JsonTokenType.String:
var text = reader.GetString(); var text = reader.GetString();
try try
{ {
@ -42,6 +45,19 @@ namespace Squidex.Infrastructure.Json.System
ThrowHelper.JsonException("Error while converting from string.", ex); ThrowHelper.JsonException("Error while converting from string.", ex);
return default; 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<T>(ref reader, optionsWithoutSelf);
default:
ThrowHelper.JsonException($"Expected string or object, got {reader.TokenType}.");
return default;
}
} }
public override T ReadAsPropertyName(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) public override T ReadAsPropertyName(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)

34
backend/src/Squidex.Infrastructure/States/SimpleState.cs

@ -5,6 +5,7 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using NodaTime;
using Squidex.Infrastructure.EventSourcing; using Squidex.Infrastructure.EventSourcing;
namespace Squidex.Infrastructure.States namespace Squidex.Infrastructure.States
@ -13,6 +14,7 @@ namespace Squidex.Infrastructure.States
{ {
private readonly IPersistence<T> persistence; private readonly IPersistence<T> persistence;
private bool isLoaded; private bool isLoaded;
private Instant lastWrite;
public T Value { get; set; } = new T(); public T Value { get; set; } = new T();
@ -21,6 +23,8 @@ namespace Squidex.Infrastructure.States
get => persistence.Version; get => persistence.Version;
} }
public IClock Clock { get; set; } = SystemClock.Instance;
public SimpleState(IPersistenceFactory<T> persistenceFactory, Type ownerType, string id) public SimpleState(IPersistenceFactory<T> persistenceFactory, Type ownerType, string id)
: this(persistenceFactory, ownerType, DomainId.Create(id)) : this(persistenceFactory, ownerType, DomainId.Create(id))
{ {
@ -52,16 +56,35 @@ namespace Squidex.Infrastructure.States
return persistence.DeleteAsync(ct); return persistence.DeleteAsync(ct);
} }
public Task WriteAsync( public async Task WriteAsync(int ifNotWrittenWithinMs,
CancellationToken ct = default) CancellationToken ct = default)
{ {
return persistence.WriteSnapshotAsync(Value, ct); var now = Clock.GetCurrentInstant();
if (ifNotWrittenWithinMs > 0 && now.Minus(lastWrite).TotalMilliseconds < ifNotWrittenWithinMs)
{
return;
} }
public Task WriteEventAsync(Envelope<IEvent> envelope, await persistence.WriteSnapshotAsync(Value, ct);
lastWrite = now;
}
public async Task WriteAsync(
CancellationToken ct = default) CancellationToken ct = default)
{ {
return persistence.WriteEventAsync(envelope, ct); await persistence.WriteSnapshotAsync(Value, ct);
lastWrite = Clock.GetCurrentInstant();
}
public async Task WriteEventAsync(Envelope<IEvent> envelope,
CancellationToken ct = default)
{
await persistence.WriteEventAsync(envelope, ct);
lastWrite = Clock.GetCurrentInstant();
} }
public Task UpdateAsync(Func<T, bool> updater, int retries = 20, public Task UpdateAsync(Func<T, bool> updater, int retries = 20,
@ -73,6 +96,9 @@ namespace Squidex.Infrastructure.States
public async Task<TResult> UpdateAsync<TResult>(Func<T, (bool, TResult)> updater, int retries = 20, public async Task<TResult> UpdateAsync<TResult>(Func<T, (bool, TResult)> updater, int retries = 20,
CancellationToken ct = default) CancellationToken ct = default)
{ {
Guard.GreaterEquals(retries, 1);
Guard.LessThan(retries, 100);
if (!isLoaded) if (!isLoaded)
{ {
await LoadAsync(ct); await LoadAsync(ct);

20
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] [Fact]
public async Task CanAssign_should_not_throw_exception_if_user_already_exists_with_some_role_but_is_from_restore() 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); 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.")); 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] [Fact]
public async Task CanAssign_should_not_throw_exception_if_user_found() public async Task CanAssign_should_not_throw_exception_if_user_found()
{ {
@ -162,12 +176,12 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject.Guards
} }
[Fact] [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) A.CallTo(() => appPlan.MaxContributors)
.Returns(2); .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_1 = contributors_0.Assign("1", Role.Editor);
var contributors_2 = contributors_1.Assign("2", Role.Editor); var contributors_2 = contributors_1.Assign("2", Role.Editor);

36
backend/tests/Squidex.Infrastructure.Tests/NamedIdTests.cs

@ -5,6 +5,8 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System.Text.Json.Serialization;
using Squidex.Infrastructure.Json.System;
using Squidex.Infrastructure.TestHelpers; using Squidex.Infrastructure.TestHelpers;
using Xunit; using Xunit;
@ -12,6 +14,12 @@ namespace Squidex.Infrastructure
{ {
public class NamedIdTests public class NamedIdTests
{ {
internal sealed record Wrapper
{
[JsonConverter(typeof(StringConverter<NamedId<long>>))]
public NamedId<long> Value { get; set; }
}
[Fact] [Fact]
public void Should_instantiate_token() public void Should_instantiate_token()
{ {
@ -113,6 +121,34 @@ namespace Squidex.Infrastructure
Assert.Equal(value, serialized); 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<NamedId<long>>();
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<Wrapper>();
Assert.Equal(expected, serialized);
}
[Fact] [Fact]
public void Should_throw_exception_if_string_id_is_not_valid() public void Should_throw_exception_if_string_id_is_not_valid()
{ {

25
backend/tests/Squidex.Infrastructure.Tests/States/SimpleStateTests.cs

@ -6,6 +6,7 @@
// ========================================================================== // ==========================================================================
using FakeItEasy; using FakeItEasy;
using NodaTime;
using Squidex.Infrastructure.TestHelpers; using Squidex.Infrastructure.TestHelpers;
using Xunit; using Xunit;
@ -204,5 +205,29 @@ namespace Squidex.Infrastructure.States
A.CallTo(() => testState.Persistence.ReadAsync(A<long>._, ct)) A.CallTo(() => testState.Persistence.ReadAsync(A<long>._, ct))
.MustHaveHappenedANumberOfTimesMatching(x => x == 1); .MustHaveHappenedANumberOfTimesMatching(x => x == 1);
} }
[Fact]
public async Task Should_not_written_if_period_not_over()
{
var now = SystemClock.Instance.GetCurrentInstant();
var clock = A.Fake<IClock>();
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);
}
} }
} }

Loading…
Cancel
Save