diff --git a/Squidex.sln b/Squidex.sln index 67e85c17a..fb24873ad 100644 --- a/Squidex.sln +++ b/Squidex.sln @@ -67,6 +67,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Benchmarks", "tests\Benchma EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Squidex.Domain.Apps.Entities", "src\Squidex.Domain.Apps.Entities\Squidex.Domain.Apps.Entities.csproj", "{79FEF326-CA5E-4698-B2BA-C16A4580B4D5}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Squidex.Domain.Apps.Entities.Tests", "tests\Squidex.Domain.Apps.Entities.Tests\Squidex.Domain.Apps.Entities.Tests.csproj", "{AA003372-CD8D-4DBC-962C-F61E0C93CF05}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -329,6 +331,18 @@ Global {79FEF326-CA5E-4698-B2BA-C16A4580B4D5}.Release|x64.Build.0 = Release|Any CPU {79FEF326-CA5E-4698-B2BA-C16A4580B4D5}.Release|x86.ActiveCfg = Release|Any CPU {79FEF326-CA5E-4698-B2BA-C16A4580B4D5}.Release|x86.Build.0 = Release|Any CPU + {AA003372-CD8D-4DBC-962C-F61E0C93CF05}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AA003372-CD8D-4DBC-962C-F61E0C93CF05}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AA003372-CD8D-4DBC-962C-F61E0C93CF05}.Debug|x64.ActiveCfg = Debug|Any CPU + {AA003372-CD8D-4DBC-962C-F61E0C93CF05}.Debug|x64.Build.0 = Debug|Any CPU + {AA003372-CD8D-4DBC-962C-F61E0C93CF05}.Debug|x86.ActiveCfg = Debug|Any CPU + {AA003372-CD8D-4DBC-962C-F61E0C93CF05}.Debug|x86.Build.0 = Debug|Any CPU + {AA003372-CD8D-4DBC-962C-F61E0C93CF05}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AA003372-CD8D-4DBC-962C-F61E0C93CF05}.Release|Any CPU.Build.0 = Release|Any CPU + {AA003372-CD8D-4DBC-962C-F61E0C93CF05}.Release|x64.ActiveCfg = Release|Any CPU + {AA003372-CD8D-4DBC-962C-F61E0C93CF05}.Release|x64.Build.0 = Release|Any CPU + {AA003372-CD8D-4DBC-962C-F61E0C93CF05}.Release|x86.ActiveCfg = Release|Any CPU + {AA003372-CD8D-4DBC-962C-F61E0C93CF05}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -359,6 +373,7 @@ Global {F0A83301-50A5-40EA-A1A2-07C7858F5A3F} = {C9809D59-6665-471E-AD87-5AC624C65892} {6B3F75B6-5888-468E-BA4F-4FC725DAEF31} = {C9809D59-6665-471E-AD87-5AC624C65892} {79FEF326-CA5E-4698-B2BA-C16A4580B4D5} = {C9809D59-6665-471E-AD87-5AC624C65892} + {AA003372-CD8D-4DBC-962C-F61E0C93CF05} = {C9809D59-6665-471E-AD87-5AC624C65892} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {02F2E872-3141-44F5-BD6A-33CD84E9FE08} diff --git a/src/Squidex.Domain.Apps.Entities/Apps/AppCommandMiddleware.cs b/src/Squidex.Domain.Apps.Entities/Apps/AppCommandMiddleware.cs index ae6a2e3ce..584471e4d 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/AppCommandMiddleware.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/AppCommandMiddleware.cs @@ -62,7 +62,7 @@ namespace Squidex.Domain.Apps.Entities.Apps { return handler.UpdateAsync(context, async a => { - await GuardAppContributors.CanAssign(a.State.Contributors, command, userResolver, appPlansProvider.GetPlan(a.State.Plan.PlanId)); + await GuardAppContributors.CanAssign(a.State.Contributors, command, userResolver, appPlansProvider.GetPlan(a.State.Plan?.PlanId)); a.AssignContributor(command); }); diff --git a/src/Squidex.Domain.Apps.Entities/Apps/AppDomainObject.cs b/src/Squidex.Domain.Apps.Entities/Apps/AppDomainObject.cs index e3c1ef9b7..40593cc2c 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/AppDomainObject.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/AppDomainObject.cs @@ -28,7 +28,9 @@ namespace Squidex.Domain.Apps.Entities.Apps var appId = new NamedId(command.AppId, command.Name); - UpdateState(command, s => s.Name = command.Name); + UpdateState(command, s => { s.Id = appId.Id; s.Name = command.Name; }); + + UpdateContributors(command, c => c.Assign(command.Actor.Identifier, AppContributorPermission.Owner)); RaiseEvent(SimpleMapper.Map(command, CreateInitalEvent(appId))); RaiseEvent(SimpleMapper.Map(command, CreateInitialOwner(appId, command))); @@ -158,7 +160,7 @@ namespace Squidex.Domain.Apps.Entities.Apps { ThrowIfNotCreated(); - UpdateState(command, s => s.Plan = new AppPlan(command.Actor, command.PlanId)); + UpdateState(command, s => s.Plan = command.PlanId != null ? new AppPlan(command.Actor, command.PlanId) : null); RaiseEvent(SimpleMapper.Map(command, new AppPlanChanged())); diff --git a/src/Squidex.Domain.Apps.Entities/Apps/State/AppState.cs b/src/Squidex.Domain.Apps.Entities/Apps/State/AppState.cs index b9c40344e..857f66678 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/State/AppState.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/State/AppState.cs @@ -8,11 +8,14 @@ using Newtonsoft.Json; using Squidex.Domain.Apps.Core.Apps; +using Squidex.Infrastructure; namespace Squidex.Domain.Apps.Entities.Apps.State { public sealed class AppState : DomainObjectState, IAppEntity { + private static readonly LanguagesConfig English = LanguagesConfig.Build(Language.EN); + [JsonProperty] public string Name { get; set; } @@ -26,6 +29,6 @@ namespace Squidex.Domain.Apps.Entities.Apps.State public AppContributors Contributors { get; set; } = AppContributors.Empty; [JsonProperty] - public LanguagesConfig LanguagesConfig { get; set; } + public LanguagesConfig LanguagesConfig { get; set; } = English; } } diff --git a/src/Squidex.Domain.Apps.Entities/CachingProviderBase.cs b/src/Squidex.Domain.Apps.Entities/CachingProviderBase.cs new file mode 100644 index 000000000..e625dc23d --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/CachingProviderBase.cs @@ -0,0 +1,30 @@ +// ========================================================================== +// CachingProviderBase.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using Microsoft.Extensions.Caching.Memory; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities +{ + public abstract class CachingProviderBase + { + private readonly IMemoryCache cache; + + protected IMemoryCache Cache + { + get { return cache; } + } + + protected CachingProviderBase(IMemoryCache cache) + { + Guard.NotNull(cache, nameof(cache)); + + this.cache = cache; + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/ContentHistoryEventsCreator.cs b/src/Squidex.Domain.Apps.Entities/Contents/ContentHistoryEventsCreator.cs new file mode 100644 index 000000000..fc0d29266 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Contents/ContentHistoryEventsCreator.cs @@ -0,0 +1,49 @@ +// ========================================================================== +// ContentHistoryEventsCreator.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System.Threading.Tasks; +using Squidex.Domain.Apps.Entities.History; +using Squidex.Domain.Apps.Events.Contents; +using Squidex.Infrastructure; +using Squidex.Infrastructure.EventSourcing; + +namespace Squidex.Domain.Apps.Entities.Contents +{ + public sealed class ContentHistoryEventsCreator : HistoryEventsCreatorBase + { + public ContentHistoryEventsCreator(TypeNameRegistry typeNameRegistry) + : base(typeNameRegistry) + { + AddEventMessage( + "created content item."); + + AddEventMessage( + "updated content item."); + + AddEventMessage( + "deleted content item."); + + AddEventMessage( + "changed status of content item to {[Status]}."); + } + + protected override Task CreateEventCoreAsync(Envelope @event) + { + var channel = $"contents.{@event.Headers.AggregateId()}"; + + var result = ForEvent(@event.Payload, channel); + + if (@event.Payload is ContentStatusChanged contentStatusChanged) + { + result = result.AddParameter("Status", contentStatusChanged.Status); + } + + return Task.FromResult(result); + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/ContentQueryService.cs b/src/Squidex.Domain.Apps.Entities/Contents/ContentQueryService.cs new file mode 100644 index 000000000..d04adaa1c --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Contents/ContentQueryService.cs @@ -0,0 +1,216 @@ +// ========================================================================== +// ContentQueryService.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.OData; +using Microsoft.OData.UriParser; +using NodaTime; +using Squidex.Domain.Apps.Core.Contents; +using Squidex.Domain.Apps.Core.Scripting; +using Squidex.Domain.Apps.Entities.Apps; +using Squidex.Domain.Apps.Entities.Contents.Edm; +using Squidex.Domain.Apps.Entities.Contents.Repositories; +using Squidex.Domain.Apps.Entities.Schemas; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Reflection; +using Squidex.Infrastructure.Security; + +namespace Squidex.Domain.Apps.Entities.Contents +{ + public sealed class ContentQueryService : IContentQueryService + { + private readonly IContentRepository contentRepository; + private readonly IAppProvider appProvider; + private readonly IScriptEngine scriptEngine; + private readonly EdmModelBuilder modelBuilder; + + public ContentQueryService( + IContentRepository contentRepository, + IAppProvider appProvider, + IScriptEngine scriptEngine, + EdmModelBuilder modelBuilder) + { + Guard.NotNull(contentRepository, nameof(contentRepository)); + Guard.NotNull(scriptEngine, nameof(scriptEngine)); + Guard.NotNull(modelBuilder, nameof(modelBuilder)); + Guard.NotNull(appProvider, nameof(appProvider)); + + this.contentRepository = contentRepository; + this.appProvider = appProvider; + this.scriptEngine = scriptEngine; + this.modelBuilder = modelBuilder; + } + + public async Task<(ISchemaEntity Schema, IContentEntity Content)> FindContentAsync(IAppEntity app, string schemaIdOrName, ClaimsPrincipal user, Guid id) + { + Guard.NotNull(app, nameof(app)); + Guard.NotNull(user, nameof(user)); + Guard.NotNullOrEmpty(schemaIdOrName, nameof(schemaIdOrName)); + + var isFrontendClient = user.IsInClient("squidex-frontend"); + + var schema = await FindSchemaAsync(app, schemaIdOrName); + + var content = await contentRepository.FindContentAsync(app, schema, id); + + if (content == null || (content.Status != Status.Published && !isFrontendClient)) + { + throw new DomainObjectNotFoundException(id.ToString(), typeof(ISchemaEntity)); + } + + content = TransformContent(user, schema, new List { content })[0]; + + return (schema, content); + } + + public async Task<(ISchemaEntity Schema, long Total, IReadOnlyList Items)> QueryWithCountAsync(IAppEntity app, string schemaIdOrName, ClaimsPrincipal user, bool archived, string query) + { + Guard.NotNull(app, nameof(app)); + Guard.NotNull(user, nameof(user)); + Guard.NotNullOrEmpty(schemaIdOrName, nameof(schemaIdOrName)); + + var schema = await FindSchemaAsync(app, schemaIdOrName); + + var parsedQuery = ParseQuery(app, query, schema); + + var status = ParseStatus(user, archived); + + var taskForItems = contentRepository.QueryAsync(app, schema, status.ToArray(), parsedQuery); + var taskForCount = contentRepository.CountAsync(app, schema, status.ToArray(), parsedQuery); + + await Task.WhenAll(taskForItems, taskForCount); + + var list = TransformContent(user, schema, taskForItems.Result.ToList()); + + return (schema, taskForCount.Result, list); + } + + public async Task<(ISchemaEntity Schema, long Total, IReadOnlyList Items)> QueryWithCountAsync(IAppEntity app, string schemaIdOrName, ClaimsPrincipal user, bool archived, HashSet ids) + { + Guard.NotNull(ids, nameof(ids)); + Guard.NotNull(app, nameof(app)); + Guard.NotNull(user, nameof(user)); + Guard.NotNullOrEmpty(schemaIdOrName, nameof(schemaIdOrName)); + + var schema = await FindSchemaAsync(app, schemaIdOrName); + + var status = ParseStatus(user, archived); + + var taskForItems = contentRepository.QueryAsync(app, schema, status.ToArray(), ids); + var taskForCount = contentRepository.CountAsync(app, schema, status.ToArray(), ids); + + await Task.WhenAll(taskForItems, taskForCount); + + var list = TransformContent(user, schema, taskForItems.Result.ToList()); + + return (schema, taskForCount.Result, list); + } + + private List TransformContent(ClaimsPrincipal user, ISchemaEntity schema, List contents) + { + var scriptText = schema.ScriptQuery; + + if (!string.IsNullOrWhiteSpace(scriptText)) + { + for (var i = 0; i < contents.Count; i++) + { + var content = contents[i]; + var contentData = scriptEngine.Transform(new ScriptContext { User = user, Data = content.Data, ContentId = content.Id }, scriptText); + + contents[i] = SimpleMapper.Map(content, new Content { Data = contentData }); + } + } + + return contents; + } + + private ODataUriParser ParseQuery(IAppEntity app, string query, ISchemaEntity schema) + { + try + { + var model = modelBuilder.BuildEdmModel(schema, app); + + return model.ParseQuery(query); + } + catch (ODataException ex) + { + throw new ValidationException($"Failed to parse query: {ex.Message}", ex); + } + } + + public async Task FindSchemaAsync(IAppEntity app, string schemaIdOrName) + { + Guard.NotNull(app, nameof(app)); + + ISchemaEntity schema = null; + + if (Guid.TryParse(schemaIdOrName, out var id)) + { + schema = await appProvider.GetSchemaAsync(app.Name, id); + } + + if (schema == null) + { + schema = await appProvider.GetSchemaAsync(app.Name, schemaIdOrName); + } + + if (schema == null) + { + throw new DomainObjectNotFoundException(schemaIdOrName, typeof(ISchemaEntity)); + } + + return schema; + } + + private static List ParseStatus(ClaimsPrincipal user, bool archived) + { + var status = new List(); + + if (user.IsInClient("squidex-frontend")) + { + if (archived) + { + status.Add(Status.Archived); + } + else + { + status.Add(Status.Draft); + status.Add(Status.Published); + } + } + else + { + status.Add(Status.Published); + } + + return status; + } + + private sealed class Content : IContentEntity + { + public Guid Id { get; set; } + public Guid AppId { get; set; } + + public long Version { get; set; } + + public Instant Created { get; set; } + public Instant LastModified { get; set; } + + public RefToken CreatedBy { get; set; } + public RefToken LastModifiedBy { get; set; } + + public NamedContentData Data { get; set; } + + public Status Status { get; set; } + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/Edm/EdmModelBuilder.cs b/src/Squidex.Domain.Apps.Entities/Contents/Edm/EdmModelBuilder.cs new file mode 100644 index 000000000..bf67aa1f6 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Contents/Edm/EdmModelBuilder.cs @@ -0,0 +1,74 @@ +// ========================================================================== +// EdmModelBuilder.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.OData.Edm; +using Squidex.Domain.Apps.Core; +using Squidex.Domain.Apps.Core.GenerateEdmSchema; +using Squidex.Domain.Apps.Core.Schemas; +using Squidex.Domain.Apps.Entities.Apps; +using Squidex.Domain.Apps.Entities.Schemas; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities.Contents.Edm +{ + public class EdmModelBuilder : CachingProviderBase + { + public EdmModelBuilder(IMemoryCache cache) + : base(cache) + { + } + + public virtual IEdmModel BuildEdmModel(ISchemaEntity schema, IAppEntity app) + { + Guard.NotNull(schema, nameof(schema)); + + var cacheKey = $"{schema.Id}_{schema.Version}_{app.Id}_{app.Version}"; + + var result = Cache.GetOrCreate(cacheKey, entry => + { + entry.AbsoluteExpiration = DateTimeOffset.UtcNow.AddMinutes(60); + + return BuildEdmModel(schema.SchemaDef, app.PartitionResolver()); + }); + + return result; + } + + private static EdmModel BuildEdmModel(Schema schema, PartitionResolver partitionResolver) + { + var model = new EdmModel(); + + var container = new EdmEntityContainer("Squidex", "Container"); + + var schemaType = schema.BuildEdmType(partitionResolver, x => + { + model.AddElement(x); + + return x; + }); + + var entityType = new EdmEntityType("Squidex", schema.Name); + entityType.AddStructuralProperty("data", new EdmComplexTypeReference(schemaType, false)); + entityType.AddStructuralProperty("version", EdmPrimitiveTypeKind.Int32); + entityType.AddStructuralProperty("created", EdmPrimitiveTypeKind.DateTimeOffset); + entityType.AddStructuralProperty("createdBy", EdmPrimitiveTypeKind.String); + entityType.AddStructuralProperty("lastModified", EdmPrimitiveTypeKind.DateTimeOffset); + entityType.AddStructuralProperty("lastModifiedBy", EdmPrimitiveTypeKind.String); + + model.AddElement(container); + model.AddElement(schemaType); + model.AddElement(entityType); + + container.AddEntitySet("ContentSet", entityType); + + return model; + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/Edm/EdmModelExtensions.cs b/src/Squidex.Domain.Apps.Entities/Contents/Edm/EdmModelExtensions.cs new file mode 100644 index 000000000..4e414d746 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Contents/Edm/EdmModelExtensions.cs @@ -0,0 +1,39 @@ +// ========================================================================== +// EdmModelExtensions.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using System.Linq; +using Microsoft.OData.Edm; +using Microsoft.OData.UriParser; + +namespace Squidex.Domain.Apps.Entities.Contents.Edm +{ + public static class EdmModelExtensions + { + public static ODataUriParser ParseQuery(this IEdmModel model, string query) + { + if (!model.EntityContainer.EntitySets().Any()) + { + return null; + } + + query = query ?? string.Empty; + + var path = model.EntityContainer.EntitySets().First().Path.Path.Split('.').Last(); + + if (query.StartsWith("?", StringComparison.Ordinal)) + { + query = query.Substring(1); + } + + var parser = new ODataUriParser(model, new Uri($"{path}?{query}", UriKind.Relative)); + + return parser; + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/CachingGraphQLService.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/CachingGraphQLService.cs new file mode 100644 index 000000000..1a7f208e1 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/CachingGraphQLService.cs @@ -0,0 +1,85 @@ +// ========================================================================== +// CachingGraphQLService.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using System.Linq; +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.Extensions.Caching.Memory; +using Squidex.Domain.Apps.Entities.Apps; +using Squidex.Domain.Apps.Entities.Assets.Repositories; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities.Contents.GraphQL +{ + public sealed class CachingGraphQLService : CachingProviderBase, IGraphQLService + { + private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(10); + private readonly IContentQueryService contentQuery; + private readonly IGraphQLUrlGenerator urlGenerator; + private readonly IAssetRepository assetRepository; + private readonly IAppProvider appProvider; + + public CachingGraphQLService(IMemoryCache cache, + IAppProvider appProvider, + IAssetRepository assetRepository, + IContentQueryService contentQuery, + IGraphQLUrlGenerator urlGenerator) + : base(cache) + { + Guard.NotNull(appProvider, nameof(appProvider)); + Guard.NotNull(assetRepository, nameof(assetRepository)); + Guard.NotNull(contentQuery, nameof(urlGenerator)); + Guard.NotNull(contentQuery, nameof(contentQuery)); + + this.appProvider = appProvider; + this.assetRepository = assetRepository; + this.contentQuery = contentQuery; + this.urlGenerator = urlGenerator; + } + + public async Task<(object Data, object[] Errors)> QueryAsync(IAppEntity app, ClaimsPrincipal user, GraphQLQuery query) + { + Guard.NotNull(app, nameof(app)); + Guard.NotNull(query, nameof(query)); + + if (string.IsNullOrWhiteSpace(query.Query)) + { + return (new object(), new object[0]); + } + + var modelContext = await GetModelAsync(app); + var queryContext = new GraphQLQueryContext(app, assetRepository, contentQuery, user, urlGenerator); + + return await modelContext.ExecuteAsync(queryContext, query); + } + + private async Task GetModelAsync(IAppEntity app) + { + var cacheKey = CreateCacheKey(app.Id, app.Version.ToString()); + + var modelContext = Cache.Get(cacheKey); + + if (modelContext == null) + { + var allSchemas = await appProvider.GetSchemasAsync(app.Name); + + modelContext = new GraphQLModel(app, allSchemas.Where(x => x.IsPublished), urlGenerator); + + Cache.Set(cacheKey, modelContext, CacheDuration); + } + + return modelContext; + } + + private static object CreateCacheKey(Guid appId, string etag) + { + return $"GraphQLModel_{appId}_{etag}"; + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLModel.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLModel.cs new file mode 100644 index 000000000..05921a5ca --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLModel.cs @@ -0,0 +1,229 @@ +// ========================================================================== +// GraphQLContext.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using GraphQL; +using GraphQL.Resolvers; +using GraphQL.Types; +using Squidex.Domain.Apps.Core; +using Squidex.Domain.Apps.Core.Contents; +using Squidex.Domain.Apps.Core.Schemas; +using Squidex.Domain.Apps.Entities.Apps; +using Squidex.Domain.Apps.Entities.Assets; +using Squidex.Domain.Apps.Entities.Contents.GraphQL.Types; +using Squidex.Domain.Apps.Entities.Schemas; +using Squidex.Infrastructure; +using GraphQLSchema = GraphQL.Types.Schema; + +namespace Squidex.Domain.Apps.Entities.Contents.GraphQL +{ + public sealed class GraphQLModel : IGraphQLContext + { + private readonly Dictionary> fieldInfos; + private readonly Dictionary schemaTypes = new Dictionary(); + private readonly Dictionary schemas; + private readonly PartitionResolver partitionResolver; + private readonly IAppEntity app; + private readonly IGraphType assetType; + private readonly IGraphType assetListType; + private readonly GraphQLSchema graphQLSchema; + + public bool CanGenerateAssetSourceUrl { get; } + + public GraphQLModel(IAppEntity app, IEnumerable schemas, IGraphQLUrlGenerator urlGenerator) + { + this.app = app; + + CanGenerateAssetSourceUrl = urlGenerator.CanGenerateAssetSourceUrl; + + partitionResolver = app.PartitionResolver(); + + assetType = new AssetGraphType(this); + assetListType = new ListGraphType(new NonNullGraphType(assetType)); + + fieldInfos = new Dictionary> + { + { + typeof(StringField), + field => ResolveDefault("String") + }, + { + typeof(BooleanField), + field => ResolveDefault("Boolean") + }, + { + typeof(NumberField), + field => ResolveDefault("Float") + }, + { + typeof(DateTimeField), + field => ResolveDefault("Date") + }, + { + typeof(JsonField), + field => ResolveDefault("Json") + }, + { + typeof(TagsField), + field => ResolveDefault("String") + }, + { + typeof(GeolocationField), + field => ResolveDefault("Geolocation") + }, + { + typeof(AssetsField), + field => ResolveAssets(assetListType) + }, + { + typeof(ReferencesField), + field => ResolveReferences(field) + } + }; + + this.schemas = schemas.ToDictionary(x => x.Id); + + graphQLSchema = new GraphQLSchema { Query = new ContentQueryGraphType(this, this.schemas.Values) }; + + foreach (var schemaType in schemaTypes.Values) + { + schemaType.Initialize(); + } + } + + private static (IGraphType ResolveType, IFieldResolver Resolver) ResolveDefault(string name) + { + return (new NoopGraphType(name), new FuncFieldResolver(c => c.Source.GetOrDefault(c.FieldName))); + } + + public IFieldResolver ResolveAssetUrl() + { + var resolver = new FuncFieldResolver(c => + { + var context = (GraphQLQueryContext)c.UserContext; + + return context.UrlGenerator.GenerateAssetUrl(app, c.Source); + }); + + return resolver; + } + + public IFieldResolver ResolveAssetSourceUrl() + { + var resolver = new FuncFieldResolver(c => + { + var context = (GraphQLQueryContext)c.UserContext; + + return context.UrlGenerator.GenerateAssetSourceUrl(app, c.Source); + }); + + return resolver; + } + + public IFieldResolver ResolveAssetThumbnailUrl() + { + var resolver = new FuncFieldResolver(c => + { + var context = (GraphQLQueryContext)c.UserContext; + + return context.UrlGenerator.GenerateAssetThumbnailUrl(app, c.Source); + }); + + return resolver; + } + + public IFieldResolver ResolveContentUrl(ISchemaEntity schema) + { + var resolver = new FuncFieldResolver(c => + { + var context = (GraphQLQueryContext)c.UserContext; + + return context.UrlGenerator.GenerateContentUrl(app, schema, c.Source); + }); + + return resolver; + } + + private static ValueTuple ResolveAssets(IGraphType assetListType) + { + var resolver = new FuncFieldResolver(c => + { + var context = (GraphQLQueryContext)c.UserContext; + var contentIds = c.Source.GetOrDefault(c.FieldName); + + return context.GetReferencedAssetsAsync(contentIds); + }); + + return (assetListType, resolver); + } + + private ValueTuple ResolveReferences(Field field) + { + var schemaId = ((ReferencesField)field).Properties.SchemaId; + var schemaType = GetSchemaType(schemaId); + + if (schemaType == null) + { + return (null, null); + } + + var resolver = new FuncFieldResolver(c => + { + var context = (GraphQLQueryContext)c.UserContext; + var contentIds = c.Source.GetOrDefault(c.FieldName); + + return context.GetReferencedContentsAsync(schemaId, contentIds); + }); + + var schemaFieldType = new ListGraphType(new NonNullGraphType(GetSchemaType(schemaId))); + + return (schemaFieldType, resolver); + } + + public async Task<(object Data, object[] Errors)> ExecuteAsync(GraphQLQueryContext context, GraphQLQuery query) + { + Guard.NotNull(context, nameof(context)); + + var result = await new DocumentExecuter().ExecuteAsync(options => + { + options.Query = query.Query; + options.Schema = graphQLSchema; + options.Inputs = query.Variables?.ToInputs() ?? new Inputs(); + options.UserContext = context; + options.OperationName = query.OperationName; + }).ConfigureAwait(false); + + return (result.Data, result.Errors?.Select(x => (object)new { x.Message, x.Locations }).ToArray()); + } + + public IFieldPartitioning ResolvePartition(Partitioning key) + { + return partitionResolver(key); + } + + public IGraphType GetAssetType() + { + return assetType; + } + + public (IGraphType ResolveType, IFieldResolver Resolver) GetGraphType(Field field) + { + return fieldInfos[field.GetType()](field); + } + + public IGraphType GetSchemaType(Guid schemaId) + { + var schema = schemas.GetOrDefault(schemaId); + + return schema != null ? schemaTypes.GetOrAdd(schemaId, k => new ContentGraphType(schema, this)) : null; + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLQuery.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLQuery.cs new file mode 100644 index 000000000..6d4163321 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLQuery.cs @@ -0,0 +1,23 @@ +// ========================================================================== +// GraphQLQuery.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using Newtonsoft.Json.Linq; + +namespace Squidex.Domain.Apps.Entities.Contents.GraphQL +{ + public class GraphQLQuery + { + public string OperationName { get; set; } + + public string NamedQuery { get; set; } + + public string Query { get; set; } + + public JObject Variables { get; set; } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLQueryContext.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLQueryContext.cs new file mode 100644 index 000000000..8ed50cea9 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLQueryContext.cs @@ -0,0 +1,67 @@ +// ========================================================================== +// GraphQLQueryContext.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Security.Claims; +using System.Threading.Tasks; +using Newtonsoft.Json.Linq; +using Squidex.Domain.Apps.Entities.Apps; +using Squidex.Domain.Apps.Entities.Assets; +using Squidex.Domain.Apps.Entities.Assets.Repositories; + +namespace Squidex.Domain.Apps.Entities.Contents.GraphQL +{ + public sealed class GraphQLQueryContext : QueryContext + { + public IGraphQLUrlGenerator UrlGenerator { get; } + + public GraphQLQueryContext(IAppEntity app, IAssetRepository assetRepository, IContentQueryService contentQuery, ClaimsPrincipal user, + IGraphQLUrlGenerator urlGenerator) + : base(app, assetRepository, contentQuery, user) + { + UrlGenerator = urlGenerator; + } + + public Task> GetReferencedAssetsAsync(JToken value) + { + var ids = ParseIds(value); + + return GetReferencedAssetsAsync(ids); + } + + public Task> GetReferencedContentsAsync(Guid schemaId, JToken value) + { + var ids = ParseIds(value); + + return GetReferencedContentsAsync(schemaId, ids); + } + + private static ICollection ParseIds(JToken value) + { + try + { + var result = new List(); + + if (value is JArray) + { + foreach (var id in value) + { + result.Add(Guid.Parse(id.ToString())); + } + } + + return result; + } + catch + { + return new List(); + } + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/IGraphQLContext.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/IGraphQLContext.cs new file mode 100644 index 000000000..1bd15fb37 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/IGraphQLContext.cs @@ -0,0 +1,38 @@ +// ========================================================================== +// SchemaGraphType.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using GraphQL.Resolvers; +using GraphQL.Types; +using Squidex.Domain.Apps.Core; +using Squidex.Domain.Apps.Core.Schemas; +using Squidex.Domain.Apps.Entities.Schemas; + +namespace Squidex.Domain.Apps.Entities.Contents.GraphQL +{ + public interface IGraphQLContext + { + bool CanGenerateAssetSourceUrl { get; } + + IFieldPartitioning ResolvePartition(Partitioning key); + + IGraphType GetAssetType(); + + IGraphType GetSchemaType(Guid schemaId); + + IFieldResolver ResolveAssetUrl(); + + IFieldResolver ResolveAssetSourceUrl(); + + IFieldResolver ResolveAssetThumbnailUrl(); + + IFieldResolver ResolveContentUrl(ISchemaEntity schema); + + (IGraphType ResolveType, IFieldResolver Resolver) GetGraphType(Field field); + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/IGraphQLService.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/IGraphQLService.cs new file mode 100644 index 000000000..8b0ce31b2 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/IGraphQLService.cs @@ -0,0 +1,19 @@ +// ========================================================================== +// IGraphQLService.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System.Security.Claims; +using System.Threading.Tasks; +using Squidex.Domain.Apps.Entities.Apps; + +namespace Squidex.Domain.Apps.Entities.Contents.GraphQL +{ + public interface IGraphQLService + { + Task<(object Data, object[] Errors)> QueryAsync(IAppEntity app, ClaimsPrincipal user, GraphQLQuery query); + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/IGraphQLUrlGenerator.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/IGraphQLUrlGenerator.cs new file mode 100644 index 000000000..983778efc --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/IGraphQLUrlGenerator.cs @@ -0,0 +1,27 @@ +// ========================================================================== +// IGraphQLUrlGenerator.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using Squidex.Domain.Apps.Entities.Apps; +using Squidex.Domain.Apps.Entities.Assets; +using Squidex.Domain.Apps.Entities.Schemas; + +namespace Squidex.Domain.Apps.Entities.Contents.GraphQL +{ + public interface IGraphQLUrlGenerator + { + bool CanGenerateAssetSourceUrl { get; } + + string GenerateAssetUrl(IAppEntity app, IAssetEntity asset); + + string GenerateAssetThumbnailUrl(IAppEntity app, IAssetEntity asset); + + string GenerateAssetSourceUrl(IAppEntity app, IAssetEntity asset); + + string GenerateContentUrl(IAppEntity app, ISchemaEntity schema, IContentEntity content); + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AssetGraphType.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AssetGraphType.cs new file mode 100644 index 000000000..c75d1c80b --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AssetGraphType.cs @@ -0,0 +1,170 @@ +// ========================================================================== +// AssetGraphType.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using GraphQL.Resolvers; +using GraphQL.Types; +using Squidex.Domain.Apps.Entities.Assets; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types +{ + public sealed class AssetGraphType : ObjectGraphType + { + public AssetGraphType(IGraphQLContext context) + { + Name = "AssetDto"; + + AddField(new FieldType + { + Name = "id", + Resolver = Resolver(x => x.Id.ToString()), + ResolvedType = new NonNullGraphType(new StringGraphType()), + Description = "The id of the asset." + }); + + AddField(new FieldType + { + Name = "version", + Resolver = Resolver(x => x.Version), + ResolvedType = new NonNullGraphType(new IntGraphType()), + Description = "The version of the asset." + }); + + AddField(new FieldType + { + Name = "created", + Resolver = Resolver(x => x.Created.ToDateTimeUtc()), + ResolvedType = new NonNullGraphType(new DateGraphType()), + Description = "The date and time when the asset has been created." + }); + + AddField(new FieldType + { + Name = "createdBy", + Resolver = Resolver(x => x.CreatedBy.ToString()), + ResolvedType = new NonNullGraphType(new StringGraphType()), + Description = "The user that has created the asset." + }); + + AddField(new FieldType + { + Name = "lastModified", + Resolver = Resolver(x => x.LastModified.ToDateTimeUtc()), + ResolvedType = new NonNullGraphType(new DateGraphType()), + Description = "The date and time when the asset has been modified last." + }); + + AddField(new FieldType + { + Name = "lastModifiedBy", + Resolver = Resolver(x => x.LastModifiedBy.ToString()), + ResolvedType = new NonNullGraphType(new StringGraphType()), + Description = "The user that has updated the asset last." + }); + + AddField(new FieldType + { + Name = "mimeType", + Resolver = Resolver(x => x.MimeType), + ResolvedType = new NonNullGraphType(new StringGraphType()), + Description = "The mime type." + }); + + AddField(new FieldType + { + Name = "url", + Resolver = context.ResolveAssetUrl(), + ResolvedType = new NonNullGraphType(new StringGraphType()), + Description = "The url to the asset." + }); + + AddField(new FieldType + { + Name = "thumbnailUrl", + Resolver = context.ResolveAssetThumbnailUrl(), + ResolvedType = new StringGraphType(), + Description = "The thumbnail url to the asset." + }); + + AddField(new FieldType + { + Name = "fileName", + Resolver = Resolver(x => x.FileName), + ResolvedType = new NonNullGraphType(new StringGraphType()), + Description = "The file name." + }); + + AddField(new FieldType + { + Name = "fileType", + Resolver = Resolver(x => x.FileName.FileType()), + ResolvedType = new NonNullGraphType(new StringGraphType()), + Description = "The file type." + }); + + AddField(new FieldType + { + Name = "fileSize", + Resolver = Resolver(x => x.FileSize), + ResolvedType = new NonNullGraphType(new IntGraphType()), + Description = "The size of the file in bytes." + }); + + AddField(new FieldType + { + Name = "fileVersion", + Resolver = Resolver(x => x.FileVersion), + ResolvedType = new NonNullGraphType(new IntGraphType()), + Description = "The version of the file." + }); + + AddField(new FieldType + { + Name = "isImage", + Resolver = Resolver(x => x.IsImage), + ResolvedType = new NonNullGraphType(new BooleanGraphType()), + Description = "Determines of the created file is an image." + }); + + AddField(new FieldType + { + Name = "pixelWidth", + Resolver = Resolver(x => x.PixelWidth), + ResolvedType = new IntGraphType(), + Description = "The width of the image in pixels if the asset is an image." + }); + + AddField(new FieldType + { + Name = "pixelHeight", + Resolver = Resolver(x => x.PixelHeight), + ResolvedType = new IntGraphType(), + Description = "The height of the image in pixels if the asset is an image." + }); + + if (context.CanGenerateAssetSourceUrl) + { + AddField(new FieldType + { + Name = "sourceUrl", + Resolver = context.ResolveAssetSourceUrl(), + ResolvedType = new StringGraphType(), + Description = "The source url of the asset." + }); + } + + Description = "An asset"; + } + + private static IFieldResolver Resolver(Func action) + { + return new FuncFieldResolver(c => action(c.Source)); + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentDataGraphType.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentDataGraphType.cs new file mode 100644 index 000000000..af48d1087 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentDataGraphType.cs @@ -0,0 +1,68 @@ +// ========================================================================== +// ContentDataGraphType.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System.Linq; +using GraphQL.Resolvers; +using GraphQL.Types; +using Squidex.Domain.Apps.Core.Contents; +using Squidex.Infrastructure; +using Schema = Squidex.Domain.Apps.Core.Schemas.Schema; + +namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types +{ + public sealed class ContentDataGraphType : ObjectGraphType + { + public ContentDataGraphType(Schema schema, IGraphQLContext context) + { + var schemaName = schema.Properties.Label.WithFallback(schema.Name); + + Name = $"{schema.Name.ToPascalCase()}DataDto"; + + foreach (var field in schema.Fields.Where(x => !x.IsHidden)) + { + var fieldInfo = context.GetGraphType(field); + + if (fieldInfo.ResolveType != null) + { + var fieldName = field.RawProperties.Label.WithFallback(field.Name); + + var fieldGraphType = new ObjectGraphType + { + Name = $"{schema.Name.ToPascalCase()}Data{field.Name.ToPascalCase()}Dto" + }; + + var partition = context.ResolvePartition(field.Partitioning); + + foreach (var partitionItem in partition) + { + fieldGraphType.AddField(new FieldType + { + Name = partitionItem.Key, + Resolver = fieldInfo.Resolver, + ResolvedType = fieldInfo.ResolveType, + Description = field.RawProperties.Hints + }); + } + + fieldGraphType.Description = $"The structure of the {fieldName} of a {schemaName} content type."; + + var fieldResolver = new FuncFieldResolver(c => c.Source.GetOrDefault(field.Name)); + + AddField(new FieldType + { + Name = field.Name.ToCamelCase(), + Resolver = fieldResolver, + ResolvedType = fieldGraphType + }); + } + } + + Description = $"The structure of a {schemaName} content type."; + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentGraphType.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentGraphType.cs new file mode 100644 index 000000000..0ef578e8a --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentGraphType.cs @@ -0,0 +1,112 @@ +// ========================================================================== +// SchemaGraphType.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using System.Linq; +using GraphQL.Resolvers; +using GraphQL.Types; +using Squidex.Domain.Apps.Entities.Schemas; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types +{ + public sealed class ContentGraphType : ObjectGraphType + { + private readonly ISchemaEntity schema; + private readonly IGraphQLContext context; + + public ContentGraphType(ISchemaEntity schema, IGraphQLContext context) + { + this.context = context; + this.schema = schema; + + Name = $"{schema.Name.ToPascalCase()}Dto"; + } + + public void Initialize() + { + var schemaName = schema.SchemaDef.Properties.Label.WithFallback(schema.Name); + + AddField(new FieldType + { + Name = "id", + Resolver = Resolver(x => x.Id.ToString()), + ResolvedType = new NonNullGraphType(new StringGraphType()), + Description = $"The id of the {schemaName} content." + }); + + AddField(new FieldType + { + Name = "version", + Resolver = Resolver(x => x.Version), + ResolvedType = new NonNullGraphType(new IntGraphType()), + Description = $"The version of the {schemaName} content." + }); + + AddField(new FieldType + { + Name = "created", + Resolver = Resolver(x => x.Created.ToDateTimeUtc()), + ResolvedType = new NonNullGraphType(new DateGraphType()), + Description = $"The date and time when the {schemaName} content has been created." + }); + + AddField(new FieldType + { + Name = "createdBy", + Resolver = Resolver(x => x.CreatedBy.ToString()), + ResolvedType = new NonNullGraphType(new StringGraphType()), + Description = $"The user that has created the {schemaName} content." + }); + + AddField(new FieldType + { + Name = "lastModified", + Resolver = Resolver(x => x.LastModified.ToDateTimeUtc()), + ResolvedType = new NonNullGraphType(new DateGraphType()), + Description = $"The date and time when the {schemaName} content has been modified last." + }); + + AddField(new FieldType + { + Name = "lastModifiedBy", + Resolver = Resolver(x => x.LastModifiedBy.ToString()), + ResolvedType = new NonNullGraphType(new StringGraphType()), + Description = $"The user that has updated the {schemaName} content last." + }); + + AddField(new FieldType + { + Name = "url", + Resolver = context.ResolveContentUrl(schema), + ResolvedType = new NonNullGraphType(new StringGraphType()), + Description = $"The url to the the {schemaName} content." + }); + + var dataType = new ContentDataGraphType(schema.SchemaDef, context); + + if (dataType.Fields.Any()) + { + AddField(new FieldType + { + Name = "data", + Resolver = Resolver(x => x.Data), + ResolvedType = new NonNullGraphType(dataType), + Description = $"The data of the {schemaName} content." + }); + } + + Description = $"The structure of a {schemaName} content type."; + } + + private static IFieldResolver Resolver(Func action) + { + return new FuncFieldResolver(c => action(c.Source)); + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentQueryGraphType.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentQueryGraphType.cs new file mode 100644 index 000000000..4d980761d --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentQueryGraphType.cs @@ -0,0 +1,192 @@ +// ========================================================================== +// GraphModelType.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Linq; +using GraphQL.Resolvers; +using GraphQL.Types; +using Squidex.Domain.Apps.Entities.Schemas; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types +{ + public sealed class ContentQueryGraphType : ObjectGraphType + { + public ContentQueryGraphType(IGraphQLContext graphQLContext, IEnumerable schemas) + { + AddAssetFind(graphQLContext); + AddAssetsQuery(graphQLContext); + + foreach (var schema in schemas) + { + var schemaName = schema.SchemaDef.Properties.Label.WithFallback(schema.SchemaDef.Name); + var schemaType = graphQLContext.GetSchemaType(schema.Id); + + AddContentFind(schema, schemaType, schemaName); + AddContentQuery(schema, schemaType, schemaName); + } + + Description = "The app queries."; + } + + private void AddAssetFind(IGraphQLContext graphQLContext) + { + AddField(new FieldType + { + Name = "findAsset", + Arguments = new QueryArguments + { + new QueryArgument(typeof(StringGraphType)) + { + Name = "id", + Description = "The id of the asset.", + DefaultValue = string.Empty + } + }, + ResolvedType = graphQLContext.GetAssetType(), + Resolver = new FuncFieldResolver(c => + { + var context = (GraphQLQueryContext)c.UserContext; + var contentId = Guid.Parse(c.GetArgument("id", Guid.Empty.ToString())); + + return context.FindAssetAsync(contentId); + }), + Description = "Find an asset by id." + }); + } + + private void AddContentFind(ISchemaEntity schema, IGraphType schemaType, string schemaName) + { + AddField(new FieldType + { + Name = $"find{schema.Name.ToPascalCase()}Content", + Arguments = new QueryArguments + { + new QueryArgument(typeof(StringGraphType)) + { + Name = "id", + Description = $"The id of the {schemaName} content.", + DefaultValue = string.Empty + } + }, + ResolvedType = schemaType, + Resolver = new FuncFieldResolver(c => + { + var context = (GraphQLQueryContext)c.UserContext; + var contentId = Guid.Parse(c.GetArgument("id", Guid.Empty.ToString())); + + return context.FindContentAsync(schema.Id, contentId); + }), + Description = $"Find an {schemaName} content by id." + }); + } + + private void AddAssetsQuery(IGraphQLContext graphQLContext) + { + AddField(new FieldType + { + Name = "queryAssets", + Arguments = new QueryArguments + { + new QueryArgument(typeof(IntGraphType)) + { + Name = "top", + Description = "Optional number of assets to take.", + DefaultValue = 20 + }, + new QueryArgument(typeof(IntGraphType)) + { + Name = "skip", + Description = "Optional number of assets to skip.", + DefaultValue = 0 + }, + new QueryArgument(typeof(StringGraphType)) + { + Name = "search", + Description = "Optional query.", + DefaultValue = string.Empty + } + }, + ResolvedType = new ListGraphType(new NonNullGraphType(graphQLContext.GetAssetType())), + Resolver = new FuncFieldResolver(c => + { + var context = (GraphQLQueryContext)c.UserContext; + + var argTop = c.GetArgument("top", 20); + var argSkip = c.GetArgument("skip", 0); + var argQuery = c.GetArgument("search", string.Empty); + + return context.QueryAssetsAsync(argQuery, argSkip, argTop); + }), + Description = "Query assets items." + }); + } + + private void AddContentQuery(ISchemaEntity schema, IGraphType schemaType, string schemaName) + { + AddField(new FieldType + { + Name = $"query{schema.Name.ToPascalCase()}Contents", + Arguments = new QueryArguments + { + new QueryArgument(typeof(IntGraphType)) + { + Name = "top", + Description = "Optional number of contents to take.", + DefaultValue = 20 + }, + new QueryArgument(typeof(IntGraphType)) + { + Name = "skip", + Description = "Optional number of contents to skip.", + DefaultValue = 0 + }, + new QueryArgument(typeof(StringGraphType)) + { + Name = "filter", + Description = "Optional OData filter.", + DefaultValue = string.Empty + }, + new QueryArgument(typeof(StringGraphType)) + { + Name = "search", + Description = "Optional OData full text search.", + DefaultValue = string.Empty + }, + new QueryArgument(typeof(StringGraphType)) + { + Name = "orderby", + Description = "Optional OData order definition.", + DefaultValue = string.Empty + } + }, + ResolvedType = new ListGraphType(new NonNullGraphType(schemaType)), + Resolver = new FuncFieldResolver(c => + { + var context = (GraphQLQueryContext)c.UserContext; + var contentQuery = BuildODataQuery(c); + + return context.QueryContentsAsync(schema.Id.ToString(), contentQuery); + }), + Description = $"Query {schemaName} content items." + }); + } + + private static string BuildODataQuery(ResolveFieldContext c) + { + var odataQuery = "?" + + string.Join("&", + c.Arguments + .Select(x => new { x.Key, Value = x.Value.ToString() }).Where(x => !string.IsNullOrWhiteSpace(x.Value)) + .Select(x => $"${x.Key}={x.Value}")); + + return odataQuery; + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/NoopGraphType.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/NoopGraphType.cs new file mode 100644 index 000000000..90c87258c --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/NoopGraphType.cs @@ -0,0 +1,37 @@ +// ========================================================================== +// NoopGraphType.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using GraphQL.Language.AST; +using GraphQL.Types; + +namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types +{ + public sealed class NoopGraphType : ScalarGraphType + { + public NoopGraphType(string name) + { + Name = name; + } + + public override object Serialize(object value) + { + return value; + } + + public override object ParseValue(object value) + { + return value; + } + + public override object ParseLiteral(IValue value) + { + throw new NotSupportedException(); + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/IContentEntity.cs b/src/Squidex.Domain.Apps.Entities/Contents/IContentEntity.cs new file mode 100644 index 000000000..7e3ae5451 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Contents/IContentEntity.cs @@ -0,0 +1,25 @@ +// ========================================================================== +// IContentEntity.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== +// ========================================================================== + +using Squidex.Domain.Apps.Core.Contents; + +namespace Squidex.Domain.Apps.Entities.Contents +{ + public interface IContentEntity : + IEntity, + IEntityWithAppRef, + IEntityWithCreatedBy, + IEntityWithLastModifiedBy, + IEntityWithVersion + { + Status Status { get; } + + NamedContentData Data { get; } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/IContentQueryService.cs b/src/Squidex.Domain.Apps.Entities/Contents/IContentQueryService.cs new file mode 100644 index 000000000..99517290d --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Contents/IContentQueryService.cs @@ -0,0 +1,28 @@ +// ========================================================================== +// IContentQueryService.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Security.Claims; +using System.Threading.Tasks; +using Squidex.Domain.Apps.Entities.Apps; +using Squidex.Domain.Apps.Entities.Schemas; + +namespace Squidex.Domain.Apps.Entities.Contents +{ + public interface IContentQueryService + { + Task<(ISchemaEntity Schema, long Total, IReadOnlyList Items)> QueryWithCountAsync(IAppEntity app, string schemaIdOrName, ClaimsPrincipal user, bool archived, HashSet ids); + + Task<(ISchemaEntity Schema, long Total, IReadOnlyList Items)> QueryWithCountAsync(IAppEntity app, string schemaIdOrName, ClaimsPrincipal user, bool archived, string query); + + Task<(ISchemaEntity Schema, IContentEntity Content)> FindContentAsync(IAppEntity app, string schemaIdOrName, ClaimsPrincipal user, Guid id); + + Task FindSchemaAsync(IAppEntity app, string schemaIdOrName); + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/QueryContext.cs b/src/Squidex.Domain.Apps.Entities/Contents/QueryContext.cs new file mode 100644 index 000000000..fc5ccd4ea --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Contents/QueryContext.cs @@ -0,0 +1,146 @@ +// ========================================================================== +// QueryContext.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Security.Claims; +using System.Threading.Tasks; +using Squidex.Domain.Apps.Entities.Apps; +using Squidex.Domain.Apps.Entities.Assets; +using Squidex.Domain.Apps.Entities.Assets.Repositories; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities.Contents +{ + public class QueryContext + { + private readonly ConcurrentDictionary cachedContents = new ConcurrentDictionary(); + private readonly ConcurrentDictionary cachedAssets = new ConcurrentDictionary(); + private readonly IContentQueryService contentQuery; + private readonly IAssetRepository assetRepository; + private readonly IAppEntity app; + private readonly ClaimsPrincipal user; + + public QueryContext( + IAppEntity app, + IAssetRepository assetRepository, + IContentQueryService contentQuery, + ClaimsPrincipal user) + { + Guard.NotNull(assetRepository, nameof(assetRepository)); + Guard.NotNull(contentQuery, nameof(contentQuery)); + Guard.NotNull(app, nameof(app)); + Guard.NotNull(user, nameof(user)); + + this.assetRepository = assetRepository; + this.contentQuery = contentQuery; + + this.user = user; + + this.app = app; + } + + public async Task FindAssetAsync(Guid id) + { + var asset = cachedAssets.GetOrDefault(id); + + if (asset == null) + { + asset = await assetRepository.FindAssetAsync(id); + + if (asset != null) + { + cachedAssets[asset.Id] = asset; + } + } + + return asset; + } + + public async Task FindContentAsync(Guid schemaId, Guid id) + { + var content = cachedContents.GetOrDefault(id); + + if (content == null) + { + content = (await contentQuery.FindContentAsync(app, schemaId.ToString(), user, id)).Content; + + if (content != null) + { + cachedContents[content.Id] = content; + } + } + + return content; + } + + public async Task> QueryAssetsAsync(string query, int skip = 0, int take = 10) + { + var assets = await assetRepository.QueryAsync(app.Id, null, null, query, take, skip); + + foreach (var asset in assets) + { + cachedAssets[asset.Id] = asset; + } + + return assets; + } + + public async Task> QueryContentsAsync(string schemaIdOrName, string query) + { + var contents = await contentQuery.QueryWithCountAsync(app, schemaIdOrName, user, false, query); + + foreach (var content in contents.Items) + { + cachedContents[content.Id] = content; + } + + return contents.Items; + } + + public async Task> GetReferencedAssetsAsync(ICollection ids) + { + Guard.NotNull(ids, nameof(ids)); + + var notLoadedAssets = new HashSet(ids.Where(id => !cachedAssets.ContainsKey(id))); + + if (notLoadedAssets.Count > 0) + { + var assets = await assetRepository.QueryAsync(app.Id, null, notLoadedAssets, null, int.MaxValue); + + foreach (var asset in assets) + { + cachedAssets[asset.Id] = asset; + } + } + + return ids.Select(id => cachedAssets.GetOrDefault(id)).Where(x => x != null).ToList(); + } + + public async Task> GetReferencedContentsAsync(Guid schemaId, ICollection ids) + { + Guard.NotNull(ids, nameof(ids)); + + var notLoadedContents = new HashSet(ids.Where(id => !cachedContents.ContainsKey(id))); + + if (notLoadedContents.Count > 0) + { + var contents = await contentQuery.QueryWithCountAsync(app, schemaId.ToString(), user, false, notLoadedContents); + + foreach (var content in contents.Items) + { + cachedContents[content.Id] = content; + } + } + + return ids.Select(id => cachedContents.GetOrDefault(id)).Where(x => x != null).ToList(); + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/Repositories/IContentRepository.cs b/src/Squidex.Domain.Apps.Entities/Contents/Repositories/IContentRepository.cs new file mode 100644 index 000000000..79de0dbf0 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Contents/Repositories/IContentRepository.cs @@ -0,0 +1,33 @@ +// ========================================================================== +// IContentRepository.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.OData.UriParser; +using Squidex.Domain.Apps.Core.Contents; +using Squidex.Domain.Apps.Entities.Apps; +using Squidex.Domain.Apps.Entities.Schemas; + +namespace Squidex.Domain.Apps.Entities.Contents.Repositories +{ + public interface IContentRepository + { + Task> QueryAsync(IAppEntity app, ISchemaEntity schema, Status[] status, HashSet ids); + + Task> QueryAsync(IAppEntity app, ISchemaEntity schema, Status[] status, ODataUriParser odataQuery); + + Task> QueryNotFoundAsync(Guid appId, Guid schemaId, IList contentIds); + + Task CountAsync(IAppEntity app, ISchemaEntity schema, Status[] status, HashSet ids); + + Task CountAsync(IAppEntity app, ISchemaEntity schema, Status[] status, ODataUriParser odataQuery); + + Task FindContentAsync(IAppEntity app, ISchemaEntity schema, Guid id); + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Rules/Guards/GuardRule.cs b/src/Squidex.Domain.Apps.Entities/Rules/Guards/GuardRule.cs index ccf3023f1..5a06c7b0c 100644 --- a/src/Squidex.Domain.Apps.Entities/Rules/Guards/GuardRule.cs +++ b/src/Squidex.Domain.Apps.Entities/Rules/Guards/GuardRule.cs @@ -8,7 +8,6 @@ using System.Threading.Tasks; using Squidex.Domain.Apps.Core.Rules; -using Squidex.Domain.Apps.Entities; using Squidex.Domain.Apps.Entities.Rules.Commands; using Squidex.Infrastructure; diff --git a/src/Squidex.Domain.Apps.Entities/Rules/Guards/RuleTriggerValidator.cs b/src/Squidex.Domain.Apps.Entities/Rules/Guards/RuleTriggerValidator.cs index dbe01af6f..febb77364 100644 --- a/src/Squidex.Domain.Apps.Entities/Rules/Guards/RuleTriggerValidator.cs +++ b/src/Squidex.Domain.Apps.Entities/Rules/Guards/RuleTriggerValidator.cs @@ -12,7 +12,6 @@ using System.Linq; using System.Threading.Tasks; using Squidex.Domain.Apps.Core.Rules; using Squidex.Domain.Apps.Core.Rules.Triggers; -using Squidex.Domain.Apps.Entities; using Squidex.Domain.Apps.Entities.Schemas; using Squidex.Infrastructure; diff --git a/src/Squidex.Domain.Apps.Events/Squidex.Domain.Apps.Events.csproj b/src/Squidex.Domain.Apps.Events/Squidex.Domain.Apps.Events.csproj index 9e69912e3..de665699c 100644 --- a/src/Squidex.Domain.Apps.Events/Squidex.Domain.Apps.Events.csproj +++ b/src/Squidex.Domain.Apps.Events/Squidex.Domain.Apps.Events.csproj @@ -11,9 +11,12 @@ + + + diff --git a/src/Squidex.Infrastructure.GetEventStore/EventSourcing/Events/GetEventStore.cs b/src/Squidex.Infrastructure.GetEventStore/EventSourcing/Events/GetEventStore.cs index bc7cda891..cd65c5e43 100644 --- a/src/Squidex.Infrastructure.GetEventStore/EventSourcing/Events/GetEventStore.cs +++ b/src/Squidex.Infrastructure.GetEventStore/EventSourcing/Events/GetEventStore.cs @@ -12,7 +12,6 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; using EventStore.ClientAPI; -using EventStore.ClientAPI.Exceptions; namespace Squidex.Infrastructure.EventSourcing { diff --git a/src/Squidex.Infrastructure/Commands/DomainObjectBase.cs b/src/Squidex.Infrastructure/Commands/DomainObjectBase.cs index caf135179..40c1a0c03 100644 --- a/src/Squidex.Infrastructure/Commands/DomainObjectBase.cs +++ b/src/Squidex.Infrastructure/Commands/DomainObjectBase.cs @@ -15,11 +15,11 @@ using Squidex.Infrastructure.States; namespace Squidex.Infrastructure.Commands { - public abstract class DomainObjectBase : IDomainObject + public abstract class DomainObjectBase : IDomainObject where TState : new() { private readonly List> uncomittedEvents = new List>(); private int version = -1; - private TState state; + private TState state = new TState(); private IPersistence persistence; public TState State @@ -32,6 +32,16 @@ namespace Squidex.Infrastructure.Commands get { return version; } } + public IReadOnlyList> GetUncomittedEvents() + { + return uncomittedEvents; + } + + public void ClearUncommittedEvents() + { + uncomittedEvents.Clear(); + } + public Task ActivateAsync(string key, IStore store) { persistence = store.WithSnapshots(key, s => state = s); diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppCommandMiddlewareTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppCommandMiddlewareTests.cs new file mode 100644 index 000000000..05b7b1069 --- /dev/null +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppCommandMiddlewareTests.cs @@ -0,0 +1,244 @@ +// ========================================================================== +// AppCommandMiddlewareTests.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using System.Threading.Tasks; +using FakeItEasy; +using Squidex.Domain.Apps.Entities.Apps.Commands; +using Squidex.Domain.Apps.Entities.Apps.Services; +using Squidex.Domain.Apps.Entities.Apps.Services.Implementations; +using Squidex.Domain.Apps.Entities.TestHelpers; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Commands; +using Squidex.Shared.Users; +using Xunit; + +namespace Squidex.Domain.Apps.Entities.Apps +{ + public class AppCommandMiddlewareTests : HandlerTestBase + { + private readonly IAppProvider appProvider = A.Fake(); + private readonly IAppPlansProvider appPlansProvider = A.Fake(); + private readonly IAppPlanBillingManager appPlansBillingManager = A.Fake(); + private readonly IUserResolver userResolver = A.Fake(); + private readonly AppCommandMiddleware sut; + private readonly AppDomainObject app; + private readonly Language language = Language.DE; + private readonly string contributorId = Guid.NewGuid().ToString(); + private readonly string clientName = "client"; + + public AppCommandMiddlewareTests() + { + app = new AppDomainObject(); + + A.CallTo(() => appProvider.GetAppAsync(AppName)) + .Returns((IAppEntity)null); + + A.CallTo(() => userResolver.FindByIdAsync(contributorId)) + .Returns(A.Fake()); + + sut = new AppCommandMiddleware(Handler, appProvider, appPlansProvider, appPlansBillingManager, userResolver); + } + + [Fact] + public async Task Create_should_create_domain_object() + { + var context = CreateContextForCommand(new CreateApp { Name = AppName, AppId = AppId }); + + await TestCreate(app, async _ => + { + await sut.HandleAsync(context); + }); + + Assert.Equal(AppId, context.Result>().IdOrValue); + } + + [Fact] + public async Task AssignContributor_should_update_domain_object_if_user_found() + { + A.CallTo(() => appPlansProvider.GetPlan(null)) + .Returns(new ConfigAppLimitsPlan { MaxContributors = -1 }); + + CreateApp(); + + var context = CreateContextForCommand(new AssignContributor { ContributorId = contributorId }); + + await TestUpdate(app, async _ => + { + await sut.HandleAsync(context); + }); + } + + [Fact] + public async Task RemoveContributor_should_update_domain_object() + { + CreateApp() + .AssignContributor(CreateCommand(new AssignContributor { ContributorId = contributorId })); + + var context = CreateContextForCommand(new RemoveContributor { ContributorId = contributorId }); + + await TestUpdate(app, async _ => + { + await sut.HandleAsync(context); + }); + } + + [Fact] + public async Task AttachClient_should_update_domain_object() + { + CreateApp(); + + var context = CreateContextForCommand(new AttachClient { Id = clientName }); + + await TestUpdate(app, async _ => + { + await sut.HandleAsync(context); + }); + } + + [Fact] + public async Task RenameClient_should_update_domain_object() + { + CreateApp() + .AttachClient(CreateCommand(new AttachClient { Id = clientName })); + + var context = CreateContextForCommand(new UpdateClient { Id = clientName, Name = "New Name" }); + + await TestUpdate(app, async _ => + { + await sut.HandleAsync(context); + }); + } + + [Fact] + public async Task RevokeClient_should_update_domain_object() + { + CreateApp() + .AttachClient(CreateCommand(new AttachClient { Id = clientName })); + + var context = CreateContextForCommand(new RevokeClient { Id = clientName }); + + await TestUpdate(app, async _ => + { + await sut.HandleAsync(context); + }); + } + + [Fact] + public async Task ChangePlan_should_update_domain_object() + { + A.CallTo(() => appPlansProvider.IsConfiguredPlan("my-plan")) + .Returns(true); + + CreateApp(); + + var context = CreateContextForCommand(new ChangePlan { PlanId = "my-plan" }); + + await TestUpdate(app, async _ => + { + await sut.HandleAsync(context); + }); + + A.CallTo(() => appPlansBillingManager.ChangePlanAsync(User.Identifier, AppId, AppName, "my-plan")) + .MustHaveHappened(); + } + + [Fact] + public async Task ChangePlan_should_not_make_update_for_redirect_result() + { + A.CallTo(() => appPlansProvider.IsConfiguredPlan("my-plan")) + .Returns(true); + + A.CallTo(() => appPlansBillingManager.ChangePlanAsync(User.Identifier, AppId, AppName, "my-plan")) + .Returns(CreateRedirectResult()); + + CreateApp(); + + var context = CreateContextForCommand(new ChangePlan { PlanId = "my-plan" }); + + await TestUpdate(app, async _ => + { + await sut.HandleAsync(context); + }); + + Assert.Null(app.State.Plan); + } + + [Fact] + public async Task ChangePlan_should_not_call_billing_manager_for_callback() + { + A.CallTo(() => appPlansProvider.IsConfiguredPlan("my-plan")) + .Returns(true); + + CreateApp(); + + var context = CreateContextForCommand(new ChangePlan { PlanId = "my-plan", FromCallback = true }); + + await TestUpdate(app, async _ => + { + await sut.HandleAsync(context); + }); + + A.CallTo(() => appPlansBillingManager.ChangePlanAsync(User.Identifier, AppId, AppName, "my-plan")) + .MustNotHaveHappened(); + } + + [Fact] + public async Task AddLanguage_should_update_domain_object() + { + CreateApp(); + + var context = CreateContextForCommand(new AddLanguage { Language = language }); + + await TestUpdate(app, async _ => + { + await sut.HandleAsync(context); + }); + } + + [Fact] + public async Task RemoveLanguage_should_update_domain_object() + { + CreateApp() + .AddLanguage(CreateCommand(new AddLanguage { Language = language })); + + var context = CreateContextForCommand(new RemoveLanguage { Language = language }); + + await TestUpdate(app, async _ => + { + await sut.HandleAsync(context); + }); + } + + [Fact] + public async Task UpdateLanguage_should_update_domain_object() + { + CreateApp() + .AddLanguage(CreateCommand(new AddLanguage { Language = language })); + + var context = CreateContextForCommand(new UpdateLanguage { Language = language }); + + await TestUpdate(app, async _ => + { + await sut.HandleAsync(context); + }); + } + + private AppDomainObject CreateApp() + { + app.Create(CreateCommand(new CreateApp { AppId = AppId, Name = AppName })); + + return app; + } + + private static Task CreateRedirectResult() + { + return Task.FromResult(new RedirectToCheckoutResult(new Uri("http://squidex.io"))); + } + } +} diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppDomainObjectTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppDomainObjectTests.cs new file mode 100644 index 000000000..c94358368 --- /dev/null +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppDomainObjectTests.cs @@ -0,0 +1,284 @@ +// ========================================================================== +// AppDomainObjectTests.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Linq; +using Squidex.Domain.Apps.Core.Apps; +using Squidex.Domain.Apps.Entities.Apps.Commands; +using Squidex.Domain.Apps.Entities.TestHelpers; +using Squidex.Domain.Apps.Events.Apps; +using Squidex.Infrastructure; +using Xunit; + +namespace Squidex.Domain.Apps.Entities.Apps +{ + public class AppDomainObjectTests : HandlerTestBase + { + private readonly AppDomainObject sut; + private readonly string contributorId = Guid.NewGuid().ToString(); + private readonly string clientId = "client"; + private readonly string clientNewName = "My Client"; + private readonly string planId = "premium"; + + public AppDomainObjectTests() + { + sut = new AppDomainObject(); + } + + [Fact] + public void Create_should_throw_exception_if_created() + { + CreateApp(); + + Assert.Throws(() => + { + sut.Create(CreateCommand(new CreateApp { Name = AppName })); + }); + } + + [Fact] + public void Create_should_specify_name_and_owner() + { + sut.Create(CreateCommand(new CreateApp { Name = AppName, Actor = User, AppId = AppId })); + + Assert.Equal(AppName, sut.State.Name); + + sut.GetUncomittedEvents() + .ShouldHaveSameEvents( + CreateEvent(new AppCreated { Name = AppName }), + CreateEvent(new AppContributorAssigned { ContributorId = User.Identifier, Permission = AppContributorPermission.Owner }), + CreateEvent(new AppLanguageAdded { Language = Language.EN }) + ); + } + + [Fact] + public void ChangePlan_should_throw_exception_if_not_created() + { + Assert.Throws(() => + { + sut.ChangePlan(CreateCommand(new ChangePlan { PlanId = planId })); + }); + } + + [Fact] + public void ChangePlan_should_create_events() + { + CreateApp(); + + sut.ChangePlan(CreateCommand(new ChangePlan { PlanId = planId })); + + sut.GetUncomittedEvents() + .ShouldHaveSameEvents( + CreateEvent(new AppPlanChanged { PlanId = planId }) + ); + } + + [Fact] + public void AssignContributor_should_throw_exception_if_not_created() + { + Assert.Throws(() => + { + sut.AssignContributor(CreateCommand(new AssignContributor { ContributorId = contributorId })); + }); + } + + [Fact] + public void AssignContributor_should_create_events() + { + CreateApp(); + + sut.AssignContributor(CreateCommand(new AssignContributor { ContributorId = contributorId, Permission = AppContributorPermission.Editor })); + + sut.GetUncomittedEvents() + .ShouldHaveSameEvents( + CreateEvent(new AppContributorAssigned { ContributorId = contributorId, Permission = AppContributorPermission.Editor }) + ); + } + + [Fact] + public void RemoveContributor_should_throw_exception_if_not_created() + { + Assert.Throws(() => + { + sut.RemoveContributor(CreateCommand(new RemoveContributor { ContributorId = contributorId })); + }); + } + + [Fact] + public void RemoveContributor_should_create_events_and_remove_contributor() + { + CreateApp(); + + sut.AssignContributor(CreateCommand(new AssignContributor { ContributorId = contributorId, Permission = AppContributorPermission.Editor })); + sut.RemoveContributor(CreateCommand(new RemoveContributor { ContributorId = contributorId })); + + sut.GetUncomittedEvents().Skip(1) + .ShouldHaveSameEvents( + CreateEvent(new AppContributorRemoved { ContributorId = contributorId }) + ); + } + + [Fact] + public void AttachClient_should_throw_exception_if_not_created() + { + Assert.Throws(() => + { + sut.AttachClient(CreateCommand(new AttachClient { Id = clientId })); + }); + } + + [Fact] + public void AttachClient_should_create_events() + { + var command = new AttachClient { Id = clientId }; + + CreateApp(); + + sut.AttachClient(CreateCommand(command)); + + sut.GetUncomittedEvents() + .ShouldHaveSameEvents( + CreateEvent(new AppClientAttached { Id = clientId, Secret = command.Secret }) + ); + } + + [Fact] + public void RevokeClient_should_throw_exception_if_not_created() + { + Assert.Throws(() => + { + sut.RevokeClient(CreateCommand(new RevokeClient { Id = "not-found" })); + }); + } + + [Fact] + public void RevokeClient_should_create_events() + { + CreateApp(); + CreateClient(); + + sut.RevokeClient(CreateCommand(new RevokeClient { Id = clientId })); + + sut.GetUncomittedEvents() + .ShouldHaveSameEvents( + CreateEvent(new AppClientRevoked { Id = clientId }) + ); + } + + [Fact] + public void UpdateClient_should_throw_exception_if_not_created() + { + Assert.Throws(() => + { + sut.UpdateClient(CreateCommand(new UpdateClient { Id = "not-found", Name = clientNewName })); + }); + } + + [Fact] + public void UpdateClient_should_create_events() + { + CreateApp(); + CreateClient(); + + sut.UpdateClient(CreateCommand(new UpdateClient { Id = clientId, Name = clientNewName, Permission = AppClientPermission.Developer })); + + sut.GetUncomittedEvents() + .ShouldHaveSameEvents( + CreateEvent(new AppClientRenamed { Id = clientId, Name = clientNewName }), + CreateEvent(new AppClientUpdated { Id = clientId, Permission = AppClientPermission.Developer }) + ); + } + + [Fact] + public void AddLanguage_should_throw_exception_if_not_created() + { + Assert.Throws(() => + { + sut.AddLanguage(CreateCommand(new AddLanguage { Language = Language.DE })); + }); + } + + [Fact] + public void AddLanguage_should_create_events() + { + CreateApp(); + + sut.AddLanguage(CreateCommand(new AddLanguage { Language = Language.DE })); + + sut.GetUncomittedEvents() + .ShouldHaveSameEvents( + CreateEvent(new AppLanguageAdded { Language = Language.DE }) + ); + } + + [Fact] + public void RemoveLanguage_should_throw_exception_if_not_created() + { + Assert.Throws(() => + { + sut.RemoveLanguage(CreateCommand(new RemoveLanguage { Language = Language.EN })); + }); + } + + [Fact] + public void RemoveLanguage_should_create_events() + { + CreateApp(); + CreateLanguage(Language.DE); + + sut.RemoveLanguage(CreateCommand(new RemoveLanguage { Language = Language.DE })); + + sut.GetUncomittedEvents() + .ShouldHaveSameEvents( + CreateEvent(new AppLanguageRemoved { Language = Language.DE }) + ); + } + + [Fact] + public void UpdateLanguage_should_throw_exception_if_not_created() + { + Assert.Throws(() => + { + sut.UpdateLanguage(CreateCommand(new UpdateLanguage { Language = Language.EN })); + }); + } + + [Fact] + public void UpdateLanguage_should_create_events() + { + CreateApp(); + CreateLanguage(Language.DE); + + sut.UpdateLanguage(CreateCommand(new UpdateLanguage { Language = Language.DE, Fallback = new List { Language.EN } })); + + sut.GetUncomittedEvents() + .ShouldHaveSameEvents( + CreateEvent(new AppLanguageUpdated { Language = Language.DE, Fallback = new List { Language.EN } }) + ); + } + + private void CreateApp() + { + sut.Create(CreateCommand(new CreateApp { Name = AppName })); + sut.ClearUncommittedEvents(); + } + + private void CreateClient() + { + sut.AttachClient(CreateCommand(new AttachClient { Id = clientId })); + sut.ClearUncommittedEvents(); + } + + private void CreateLanguage(Language language) + { + sut.AddLanguage(CreateCommand(new AddLanguage { Language = language })); + sut.ClearUncommittedEvents(); + } + } +} diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppEventTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppEventTests.cs new file mode 100644 index 000000000..7e590590e --- /dev/null +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppEventTests.cs @@ -0,0 +1,51 @@ +// ========================================================================== +// AppEventTests.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using Squidex.Domain.Apps.Core.Apps; +using Squidex.Domain.Apps.Entities.TestHelpers; +using Squidex.Domain.Apps.Events; +using Squidex.Domain.Apps.Events.Apps; +using Squidex.Domain.Apps.Events.Apps.Old; +using Squidex.Infrastructure; +using Xunit; + +#pragma warning disable CS0612 // Type or member is obsolete + +namespace Squidex.Domain.Apps.Entities.Apps +{ + public class AppEventTests + { + private readonly RefToken actor = new RefToken("User", Guid.NewGuid().ToString()); + private readonly NamedId appId = new NamedId(Guid.NewGuid(), "my-app"); + + [Fact] + public void Should_migrate_client_changed_as_reader_to_client_updated() + { + var source = CreateEvent(new AppClientChanged { IsReader = true }); + + source.Migrate().ShouldBeSameEvent(CreateEvent(new AppClientUpdated { Permission = AppClientPermission.Reader })); + } + + [Fact] + public void Should_migrate_client_changed_as_writer_to_client_updated() + { + var source = CreateEvent(new AppClientChanged { IsReader = false }); + + source.Migrate().ShouldBeSameEvent(CreateEvent(new AppClientUpdated { Permission = AppClientPermission.Editor })); + } + + private T CreateEvent(T contentEvent) where T : AppEvent + { + contentEvent.Actor = actor; + contentEvent.AppId = appId; + + return contentEvent; + } + } +} diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Apps/ConfigAppLimitsProviderTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Apps/ConfigAppLimitsProviderTests.cs new file mode 100644 index 000000000..f6a6066fb --- /dev/null +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Apps/ConfigAppLimitsProviderTests.cs @@ -0,0 +1,159 @@ +// ========================================================================== +// ConfigAppLimitsProviderTests.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System.Linq; +using FakeItEasy; +using FluentAssertions; +using Squidex.Domain.Apps.Core.Apps; +using Squidex.Domain.Apps.Entities.Apps.Services.Implementations; +using Squidex.Infrastructure; +using Xunit; + +namespace Squidex.Domain.Apps.Entities.Apps +{ + public class ConfigAppLimitsProviderTests + { + private static readonly ConfigAppLimitsPlan InfinitePlan = new ConfigAppLimitsPlan + { + Id = "infinite", + Name = "Infinite", + MaxApiCalls = -1, + MaxAssetSize = -1, + MaxContributors = -1 + }; + + private static readonly ConfigAppLimitsPlan FreePlan = new ConfigAppLimitsPlan + { + Id = "free", + Name = "Free", + MaxApiCalls = 50000, + MaxAssetSize = 1024 * 1024 * 10, + MaxContributors = 2 + }; + + private static readonly ConfigAppLimitsPlan BasicPlan = new ConfigAppLimitsPlan + { + Id = "basic", + Name = "Basic", + MaxApiCalls = 150000, + MaxAssetSize = 1024 * 1024 * 2, + MaxContributors = 5 + }; + + private static readonly ConfigAppLimitsPlan[] Plans = { BasicPlan, FreePlan }; + + [Fact] + public void Should_return_plans() + { + var sut = new ConfigAppPlansProvider(Plans); + + Plans.OrderBy(x => x.MaxApiCalls).ShouldBeEquivalentTo(sut.GetAvailablePlans()); + } + + [Theory] + [InlineData(null)] + [InlineData("my-plan")] + public void Should_return_infinite_if_nothing_configured(string planId) + { + var sut = new ConfigAppPlansProvider(Enumerable.Empty()); + + var plan = sut.GetPlanForApp(CreateApp(planId)); + + plan.ShouldBeEquivalentTo(InfinitePlan); + } + + [Fact] + public void Should_return_fitting_app_plan() + { + var sut = new ConfigAppPlansProvider(Plans); + + var plan = sut.GetPlanForApp(CreateApp("basic")); + + plan.ShouldBeEquivalentTo(BasicPlan); + } + + [Fact] + public void Should_smallest_plan_if_none_fits() + { + var sut = new ConfigAppPlansProvider(Plans); + + var plan = sut.GetPlanForApp(CreateApp("enterprise")); + + plan.ShouldBeEquivalentTo(FreePlan); + } + + [Fact] + public void Should_return_second_plan_for_upgrade_if_plan_is_null() + { + var sut = new ConfigAppPlansProvider(Plans); + + var upgradePlan = sut.GetPlanUpgrade(null); + + upgradePlan.ShouldBeEquivalentTo(BasicPlan); + } + + [Fact] + public void Should_return_second_plan_for_upgrade_if_plan_not_found() + { + var sut = new ConfigAppPlansProvider(Plans); + + var upgradePlan = sut.GetPlanUpgradeForApp(CreateApp("enterprise")); + + upgradePlan.ShouldBeEquivalentTo(BasicPlan); + } + + [Fact] + public void Should_not_return_plan_for_upgrade_if_plan_is_highest_plan() + { + var sut = new ConfigAppPlansProvider(Plans); + + var upgradePlan = sut.GetPlanUpgradeForApp(CreateApp("basic")); + + Assert.Null(upgradePlan); + } + + [Fact] + public void Should_return_next_plan_if_plan_is_upgradeable() + { + var sut = new ConfigAppPlansProvider(Plans); + + var upgradePlan = sut.GetPlanUpgradeForApp(CreateApp("free")); + + upgradePlan.ShouldBeEquivalentTo(BasicPlan); + } + + [Fact] + public void Should_check_plan_exists() + { + var sut = new ConfigAppPlansProvider(Plans); + + Assert.True(sut.IsConfiguredPlan("basic")); + Assert.True(sut.IsConfiguredPlan("free")); + + Assert.False(sut.IsConfiguredPlan("infinite")); + Assert.False(sut.IsConfiguredPlan("invalid")); + Assert.False(sut.IsConfiguredPlan(null)); + } + + private static IAppEntity CreateApp(string plan) + { + var app = A.Dummy(); + + if (plan != null) + { + A.CallTo(() => app.Plan).Returns(new AppPlan(new RefToken("user", "me"), plan)); + } + else + { + A.CallTo(() => app.Plan).Returns(null); + } + + return app; + } + } +} diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Guards/GuardAppClientsTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Guards/GuardAppClientsTests.cs new file mode 100644 index 000000000..5db4d3687 --- /dev/null +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Guards/GuardAppClientsTests.cs @@ -0,0 +1,142 @@ +// ========================================================================== +// GuardAppClientsTests.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using Squidex.Domain.Apps.Core.Apps; +using Squidex.Domain.Apps.Entities.Apps.Commands; +using Squidex.Infrastructure; +using Xunit; + +#pragma warning disable SA1310 // Field names must not contain underscore + +namespace Squidex.Domain.Apps.Entities.Apps.Guards +{ + public class GuardAppClientsTests + { + private readonly AppClients clients_0 = AppClients.Empty; + + [Fact] + public void CanAttach_should_throw_execption_if_client_id_is_null() + { + var command = new AttachClient(); + + Assert.Throws(() => GuardAppClients.CanAttach(clients_0, command)); + } + + [Fact] + public void CanAttach_should_throw_exception_if_client_already_exists() + { + var command = new AttachClient { Id = "android" }; + + var clients_1 = clients_0.Add("android", "secret"); + + Assert.Throws(() => GuardAppClients.CanAttach(clients_1, command)); + } + + [Fact] + public void CanAttach_should_not_throw_exception_if_client_is_free() + { + var command = new AttachClient { Id = "ios" }; + + var clients_1 = clients_0.Add("android", "secret"); + + GuardAppClients.CanAttach(clients_1, command); + } + + [Fact] + public void CanRevoke_should_throw_execption_if_client_id_is_null() + { + var command = new RevokeClient(); + + Assert.Throws(() => GuardAppClients.CanRevoke(clients_0, command)); + } + + [Fact] + public void CanRevoke_should_throw_exception_if_client_is_not_found() + { + var command = new RevokeClient { Id = "ios" }; + + Assert.Throws(() => GuardAppClients.CanRevoke(clients_0, command)); + } + + [Fact] + public void CanRevoke_should_not_throw_exception_if_client_is_found() + { + var command = new RevokeClient { Id = "ios" }; + + var clients_1 = clients_0.Add("ios", "secret"); + + GuardAppClients.CanRevoke(clients_1, command); + } + + [Fact] + public void CanUpdate_should_throw_execption_if_client_id_is_null() + { + var command = new UpdateClient(); + + Assert.Throws(() => GuardAppClients.CanUpdate(clients_0, command)); + } + + [Fact] + public void UpdateClient_should_throw_exception_if_client_is_not_found() + { + var command = new UpdateClient { Id = "ios", Name = "iOS" }; + + Assert.Throws(() => GuardAppClients.CanUpdate(clients_0, command)); + } + + [Fact] + public void UpdateClient_should_throw_exception_if_client_has_no_name_and_permission() + { + var command = new UpdateClient { Id = "ios" }; + + var clients_1 = clients_0.Add("ios", "secret"); + + Assert.Throws(() => GuardAppClients.CanUpdate(clients_1, command)); + } + + [Fact] + public void UpdateClient_should_throw_exception_if_client_has_invalid_permission() + { + var command = new UpdateClient { Id = "ios", Permission = (AppClientPermission)10 }; + + var clients_1 = clients_0.Add("ios", "secret"); + + Assert.Throws(() => GuardAppClients.CanUpdate(clients_1, command)); + } + + [Fact] + public void UpdateClient_should_throw_exception_if_client_has_same_name() + { + var command = new UpdateClient { Id = "ios", Name = "ios" }; + + var clients_1 = clients_0.Add("ios", "secret"); + + Assert.Throws(() => GuardAppClients.CanUpdate(clients_1, command)); + } + + [Fact] + public void UpdateClient_should_throw_exception_if_client_has_same_permission() + { + var command = new UpdateClient { Id = "ios", Permission = AppClientPermission.Editor }; + + var clients_1 = clients_0.Add("ios", "secret"); + + Assert.Throws(() => GuardAppClients.CanUpdate(clients_1, command)); + } + + [Fact] + public void UpdateClient_should_not_throw_exception_if_command_is_valid() + { + var command = new UpdateClient { Id = "ios", Name = "iOS", Permission = AppClientPermission.Reader }; + + var clients_1 = clients_0.Add("ios", "secret"); + + GuardAppClients.CanUpdate(clients_1, command); + } + } +} diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Guards/GuardAppContributorsTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Guards/GuardAppContributorsTests.cs new file mode 100644 index 000000000..6bdfb21c7 --- /dev/null +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Guards/GuardAppContributorsTests.cs @@ -0,0 +1,158 @@ +// ========================================================================== +// GuardAppContributorsTests.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System.Threading.Tasks; +using FakeItEasy; +using Squidex.Domain.Apps.Core.Apps; +using Squidex.Domain.Apps.Entities.Apps.Commands; +using Squidex.Domain.Apps.Entities.Apps.Services; +using Squidex.Infrastructure; +using Squidex.Shared.Users; +using Xunit; + +#pragma warning disable SA1310 // Field names must not contain underscore + +namespace Squidex.Domain.Apps.Entities.Apps.Guards +{ + public class GuardAppContributorsTests + { + private readonly IUserResolver users = A.Fake(); + private readonly IAppLimitsPlan appPlan = A.Fake(); + private readonly AppContributors contributors_0 = AppContributors.Empty; + + public GuardAppContributorsTests() + { + A.CallTo(() => users.FindByIdAsync(A.Ignored)) + .Returns(A.Fake()); + + A.CallTo(() => appPlan.MaxContributors) + .Returns(10); + } + + [Fact] + public Task CanAssign_should_throw_exception_if_contributor_id_is_null() + { + var command = new AssignContributor(); + + return Assert.ThrowsAsync(() => GuardAppContributors.CanAssign(contributors_0, command, users, appPlan)); + } + + [Fact] + public Task CanAssign_should_throw_exception_if_permission_not_valid() + { + var command = new AssignContributor { ContributorId = "1", Permission = (AppContributorPermission)10 }; + + return Assert.ThrowsAsync(() => GuardAppContributors.CanAssign(contributors_0, command, users, appPlan)); + } + + [Fact] + public Task CanAssign_should_throw_exception_if_user_already_exists_with_same_permission() + { + var command = new AssignContributor { ContributorId = "1" }; + + var contributors_1 = contributors_0.Assign("1", AppContributorPermission.Owner); + + return Assert.ThrowsAsync(() => GuardAppContributors.CanAssign(contributors_1, command, users, appPlan)); + } + + [Fact] + public Task CanAssign_should_throw_exception_if_user_not_found() + { + A.CallTo(() => users.FindByIdAsync(A.Ignored)) + .Returns(Task.FromResult(null)); + + var command = new AssignContributor { ContributorId = "1", Permission = (AppContributorPermission)10 }; + + return Assert.ThrowsAsync(() => GuardAppContributors.CanAssign(contributors_0, command, users, appPlan)); + } + + [Fact] + public Task CanAssign_should_throw_exception_if_contributor_max_reached() + { + A.CallTo(() => appPlan.MaxContributors) + .Returns(2); + + var command = new AssignContributor { ContributorId = "3" }; + + var contributors_1 = contributors_0.Assign("1", AppContributorPermission.Owner); + var contributors_2 = contributors_1.Assign("2", AppContributorPermission.Editor); + + return Assert.ThrowsAsync(() => GuardAppContributors.CanAssign(contributors_2, command, users, appPlan)); + } + + [Fact] + public Task CanAssign_should_not_throw_exception_if_user_found() + { + var command = new AssignContributor { ContributorId = "1" }; + + return GuardAppContributors.CanAssign(contributors_0, command, users, appPlan); + } + + [Fact] + public Task CanAssign_should_not_throw_exception_if_contributor_has_another_permission() + { + var command = new AssignContributor { ContributorId = "1" }; + + var contributors_1 = contributors_0.Assign("1", AppContributorPermission.Editor); + + return GuardAppContributors.CanAssign(contributors_1, command, users, appPlan); + } + + [Fact] + public Task CanAssign_should_not_throw_exception_if_contributor_max_reached_but_permission_changed() + { + A.CallTo(() => appPlan.MaxContributors) + .Returns(2); + + var command = new AssignContributor { ContributorId = "1" }; + + var contributors_1 = contributors_0.Assign("1", AppContributorPermission.Editor); + var contributors_2 = contributors_1.Assign("2", AppContributorPermission.Editor); + + return GuardAppContributors.CanAssign(contributors_2, command, users, appPlan); + } + + [Fact] + public void CanRemove_should_throw_exception_if_contributor_id_is_null() + { + var command = new RemoveContributor(); + + Assert.Throws(() => GuardAppContributors.CanRemove(contributors_0, command)); + } + + [Fact] + public void CanRemove_should_throw_exception_if_contributor_not_found() + { + var command = new RemoveContributor { ContributorId = "1" }; + + Assert.Throws(() => GuardAppContributors.CanRemove(contributors_0, command)); + } + + [Fact] + public void CanRemove_should_throw_exception_if_contributor_is_only_owner() + { + var command = new RemoveContributor { ContributorId = "1" }; + + var contributors_1 = contributors_0.Assign("1", AppContributorPermission.Owner); + var contributors_2 = contributors_1.Assign("2", AppContributorPermission.Editor); + + Assert.Throws(() => GuardAppContributors.CanRemove(contributors_2, command)); + } + + [Fact] + public void CanRemove_should_not_throw_exception_if_contributor_not_only_owner() + { + var command = new RemoveContributor { ContributorId = "1" }; + + var contributors_1 = contributors_0.Assign("1", AppContributorPermission.Owner); + var contributors_2 = contributors_1.Assign("2", AppContributorPermission.Owner); + + GuardAppContributors.CanRemove(contributors_2, command); + } + } +} diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Guards/GuardAppLanguagesTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Guards/GuardAppLanguagesTests.cs new file mode 100644 index 000000000..a3ae0baaf --- /dev/null +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Guards/GuardAppLanguagesTests.cs @@ -0,0 +1,131 @@ +// ========================================================================== +// GuardAppLanguagesTests.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System.Collections.Generic; +using Squidex.Domain.Apps.Core.Apps; +using Squidex.Domain.Apps.Entities.Apps.Commands; +using Squidex.Infrastructure; +using Xunit; + +#pragma warning disable SA1310 // Field names must not contain underscore + +namespace Squidex.Domain.Apps.Entities.Apps.Guards +{ + public class GuardAppLanguagesTests + { + private readonly LanguagesConfig languages_0 = LanguagesConfig.Build(Language.DE); + + [Fact] + public void CanAddLanguage_should_throw_exception_if_language_is_null() + { + var command = new AddLanguage(); + + Assert.Throws(() => GuardAppLanguages.CanAdd(languages_0, command)); + } + + [Fact] + public void CanAddLanguage_should_throw_exception_if_language_already_added() + { + var command = new AddLanguage { Language = Language.DE }; + + Assert.Throws(() => GuardAppLanguages.CanAdd(languages_0, command)); + } + + [Fact] + public void CanAddLanguage_should_not_throw_exception_if_language_valid() + { + var command = new AddLanguage { Language = Language.EN }; + + GuardAppLanguages.CanAdd(languages_0, command); + } + + [Fact] + public void CanRemoveLanguage_should_throw_exception_if_language_is_null() + { + var command = new RemoveLanguage(); + + Assert.Throws(() => GuardAppLanguages.CanRemove(languages_0, command)); + } + + [Fact] + public void CanRemoveLanguage_should_throw_exception_if_language_not_found() + { + var command = new RemoveLanguage { Language = Language.EN }; + + Assert.Throws(() => GuardAppLanguages.CanRemove(languages_0, command)); + } + + [Fact] + public void CanRemoveLanguage_should_throw_exception_if_language_is_master() + { + var command = new RemoveLanguage { Language = Language.DE }; + + Assert.Throws(() => GuardAppLanguages.CanRemove(languages_0, command)); + } + + [Fact] + public void CanRemoveLanguage_should_not_throw_exception_if_language_is_valid() + { + var command = new RemoveLanguage { Language = Language.EN }; + + var languages_1 = languages_0.Set(new LanguageConfig(Language.EN)); + + GuardAppLanguages.CanRemove(languages_1, command); + } + + [Fact] + public void CanUpdateLanguage_should_throw_exception_if_language_is_null() + { + var command = new UpdateLanguage(); + + var languages_1 = languages_0.Set(new LanguageConfig(Language.EN)); + + Assert.Throws(() => GuardAppLanguages.CanUpdate(languages_1, command)); + } + + [Fact] + public void CanUpdateLanguage_should_throw_exception_if_language_is_optional_and_master() + { + var command = new UpdateLanguage { Language = Language.DE, IsOptional = true }; + + var languages_1 = languages_0.Set(new LanguageConfig(Language.EN)); + + Assert.Throws(() => GuardAppLanguages.CanUpdate(languages_1, command)); + } + + [Fact] + public void CanUpdateLanguage_should_throw_exception_if_language_has_invalid_fallback() + { + var command = new UpdateLanguage { Language = Language.DE, Fallback = new List { Language.IT } }; + + var languages_1 = languages_0.Set(new LanguageConfig(Language.EN)); + + Assert.Throws(() => GuardAppLanguages.CanUpdate(languages_1, command)); + } + + [Fact] + public void CanUpdateLanguage_should_throw_exception_if_not_found() + { + var command = new UpdateLanguage { Language = Language.IT }; + + var languages_1 = languages_0.Set(new LanguageConfig(Language.EN)); + + Assert.Throws(() => GuardAppLanguages.CanUpdate(languages_1, command)); + } + + [Fact] + public void CanUpdateLanguage_should_not_throw_exception_if_language_is_valid() + { + var command = new UpdateLanguage { Language = Language.DE, Fallback = new List { Language.EN } }; + + var languages_1 = languages_0.Set(new LanguageConfig(Language.EN)); + + GuardAppLanguages.CanUpdate(languages_1, command); + } + } +} diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Guards/GuardAppTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Guards/GuardAppTests.cs new file mode 100644 index 000000000..d653a3635 --- /dev/null +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Guards/GuardAppTests.cs @@ -0,0 +1,118 @@ +// ========================================================================== +// GuardAppTests.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System.Threading.Tasks; +using FakeItEasy; +using Squidex.Domain.Apps.Core.Apps; +using Squidex.Domain.Apps.Entities.Apps.Commands; +using Squidex.Domain.Apps.Entities.Apps.Services; +using Squidex.Infrastructure; +using Squidex.Shared.Users; +using Xunit; + +namespace Squidex.Domain.Apps.Entities.Apps.Guards +{ + public class GuardAppTests + { + private readonly IAppProvider apps = A.Fake(); + private readonly IUserResolver users = A.Fake(); + private readonly IAppPlansProvider appPlans = A.Fake(); + + public GuardAppTests() + { + A.CallTo(() => apps.GetAppAsync("new-app")) + .Returns(Task.FromResult(null)); + + A.CallTo(() => users.FindByIdAsync(A.Ignored)) + .Returns(A.Fake()); + + A.CallTo(() => appPlans.GetPlan("free")) + .Returns(A.Fake()); + } + + [Fact] + public Task CanCreate_should_throw_exception_if_name_already_in_use() + { + A.CallTo(() => apps.GetAppAsync("new-app")) + .Returns(A.Fake()); + + var command = new CreateApp { Name = "new-app" }; + + return Assert.ThrowsAsync(() => GuardApp.CanCreate(command, apps)); + } + + [Fact] + public Task CanCreate_should_throw_exception_if_name_not_valid() + { + var command = new CreateApp { Name = "INVALID NAME" }; + + return Assert.ThrowsAsync(() => GuardApp.CanCreate(command, apps)); + } + + [Fact] + public Task CanCreate_should_not_throw_exception_if_app_name_is_free() + { + var command = new CreateApp { Name = "new-app" }; + + return GuardApp.CanCreate(command, apps); + } + + [Fact] + public void CanChangePlan_should_throw_exception_if_plan_id_null() + { + var command = new ChangePlan { Actor = new RefToken("user", "me") }; + + AppPlan plan = null; + + Assert.Throws(() => GuardApp.CanChangePlan(command, plan, appPlans)); + } + + [Fact] + public void CanChangePlan_should_throw_exception_if_plan_not_found() + { + A.CallTo(() => appPlans.GetPlan("free")) + .Returns(null); + + var command = new ChangePlan { PlanId = "free", Actor = new RefToken("user", "me") }; + + AppPlan plan = null; + + Assert.Throws(() => GuardApp.CanChangePlan(command, plan, appPlans)); + } + + [Fact] + public void CanChangePlan_should_throw_exception_if_plan_was_configured_from_another_user() + { + var command = new ChangePlan { PlanId = "free", Actor = new RefToken("user", "me") }; + + var plan = new AppPlan(new RefToken("user", "other"), "premium"); + + Assert.Throws(() => GuardApp.CanChangePlan(command, plan, appPlans)); + } + + [Fact] + public void CanChangePlan_should_throw_exception_if_plan_is_the_same() + { + var command = new ChangePlan { PlanId = "free", Actor = new RefToken("user", "me") }; + + var plan = new AppPlan(new RefToken("user", "me"), "free"); + + Assert.Throws(() => GuardApp.CanChangePlan(command, plan, appPlans)); + } + + [Fact] + public void CanChangePlan_should_not_throw_exception_if_same_user_but_other_plan() + { + var command = new ChangePlan { PlanId = "free", Actor = new RefToken("user", "me") }; + + var plan = new AppPlan(new RefToken("user", "me"), "premium"); + + GuardApp.CanChangePlan(command, plan, appPlans); + } + } +} diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Apps/NoopAppPlanBillingManagerTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Apps/NoopAppPlanBillingManagerTests.cs new file mode 100644 index 000000000..76162f128 --- /dev/null +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Apps/NoopAppPlanBillingManagerTests.cs @@ -0,0 +1,38 @@ +// ========================================================================== +// NoopAppPlanBillingManagerTests.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using System.Threading.Tasks; +using Squidex.Domain.Apps.Entities.Apps.Services.Implementations; +using Xunit; + +namespace Squidex.Domain.Apps.Entities.Apps +{ + public class NoopAppPlanBillingManagerTests + { + private readonly NoopAppPlanBillingManager sut = new NoopAppPlanBillingManager(); + + [Fact] + public void Should_not_have_portal() + { + Assert.False(sut.HasPortal); + } + + [Fact] + public async Task Should_do_nothing_when_changing_plan() + { + await sut.ChangePlanAsync(null, Guid.Empty, null, null); + } + + [Fact] + public async Task Should_not_return_portal_link() + { + Assert.Equal(string.Empty, await sut.GetPortalLinkAsync(null)); + } + } +} diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentQueryServiceTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentQueryServiceTests.cs new file mode 100644 index 000000000..90fddc2c4 --- /dev/null +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentQueryServiceTests.cs @@ -0,0 +1,220 @@ +// ========================================================================== +// ContentQueryServiceTests.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Security.Claims; +using System.Threading.Tasks; +using FakeItEasy; +using Microsoft.OData.UriParser; +using Squidex.Domain.Apps.Core.Contents; +using Squidex.Domain.Apps.Core.Scripting; +using Squidex.Domain.Apps.Entities.Apps; +using Squidex.Domain.Apps.Entities.Contents.Edm; +using Squidex.Domain.Apps.Entities.Contents.Repositories; +using Squidex.Domain.Apps.Entities.Schemas; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Security; +using Xunit; + +namespace Squidex.Domain.Apps.Entities.Contents +{ + public class ContentQueryServiceTests + { + private readonly IContentRepository contentRepository = A.Fake(); + private readonly IScriptEngine scriptEngine = A.Fake(); + private readonly ISchemaEntity schema = A.Fake(); + private readonly IContentEntity content = A.Fake(); + private readonly IAppEntity app = A.Fake(); + private readonly IAppProvider appProvider = A.Fake(); + private readonly Guid appId = Guid.NewGuid(); + private readonly Guid schemaId = Guid.NewGuid(); + private readonly Guid contentId = Guid.NewGuid(); + private readonly string appName = "my-app"; + private readonly NamedContentData data = new NamedContentData(); + private readonly NamedContentData transformedData = new NamedContentData(); + private readonly ClaimsPrincipal user; + private readonly ClaimsIdentity identity = new ClaimsIdentity(); + private readonly EdmModelBuilder modelBuilder = A.Fake(); + private readonly ContentQueryService sut; + + public ContentQueryServiceTests() + { + user = new ClaimsPrincipal(identity); + + A.CallTo(() => app.Id).Returns(appId); + A.CallTo(() => app.Name).Returns(appName); + + A.CallTo(() => content.Id).Returns(contentId); + A.CallTo(() => content.Data).Returns(data); + A.CallTo(() => content.Status).Returns(Status.Published); + + sut = new ContentQueryService(contentRepository, appProvider, scriptEngine, modelBuilder); + } + + [Fact] + public async Task Should_return_schema_from_id_if_string_is_guid() + { + A.CallTo(() => appProvider.GetSchemaAsync(appName, schemaId, false)) + .Returns(schema); + + var result = await sut.FindSchemaAsync(app, schemaId.ToString()); + + Assert.Equal(schema, result); + } + + [Fact] + public async Task Should_return_schema_from_name_if_string_not_guid() + { + A.CallTo(() => appProvider.GetSchemaAsync(appName, "my-schema", false)) + .Returns(schema); + + var result = await sut.FindSchemaAsync(app, "my-schema"); + + Assert.Equal(schema, result); + } + + [Fact] + public async Task Should_throw_if_schema_not_found() + { + A.CallTo(() => appProvider.GetSchemaAsync(appName, "my-schema", false)) + .Returns((ISchemaEntity)null); + + await Assert.ThrowsAsync(() => sut.FindSchemaAsync(app, "my-schema")); + } + + [Fact] + public async Task Should_return_content_from_repository_and_transform() + { + A.CallTo(() => appProvider.GetSchemaAsync(appName, schemaId, false)) + .Returns(schema); + A.CallTo(() => contentRepository.FindContentAsync(app, schema, contentId)) + .Returns(content); + + A.CallTo(() => schema.ScriptQuery) + .Returns(""); + + A.CallTo(() => scriptEngine.Transform(A.That.Matches(x => x.User == user && x.ContentId == contentId && ReferenceEquals(x.Data, data)), "")) + .Returns(transformedData); + + var result = await sut.FindContentAsync(app, schemaId.ToString(), user, contentId); + + Assert.Equal(schema, result.Schema); + Assert.Equal(data, result.Content.Data); + Assert.Equal(content.Id, result.Content.Id); + } + + [Fact] + public async Task Should_throw_if_content_to_find_does_not_exist() + { + A.CallTo(() => appProvider.GetSchemaAsync(appName, schemaId, false)) + .Returns(schema); + A.CallTo(() => contentRepository.FindContentAsync(app, schema, contentId)) + .Returns((IContentEntity)null); + + await Assert.ThrowsAsync(async () => await sut.FindContentAsync(app, schemaId.ToString(), user, contentId)); + } + + [Fact] + public async Task Should_return_contents_with_ids_from_repository_and_transform() + { + await TestManyIdRequest(true, false, new HashSet { Guid.NewGuid() }, Status.Draft, Status.Published); + } + + [Fact] + public async Task Should_return_non_archived_contents_from_repository_and_transform() + { + await TestManyRequest(true, false, Status.Draft, Status.Published); + } + + [Fact] + public async Task Should_return_archived_contents_from_repository_and_transform() + { + await TestManyRequest(true, true, Status.Archived); + } + + [Fact] + public async Task Should_return_draft_contents_from_repository_and_transform() + { + await TestManyRequest(false, false, Status.Published); + } + + [Fact] + public async Task Should_return_draft_contents_from_repository_and_transform_when_requesting_archive_as_non_frontend() + { + await TestManyRequest(false, true, Status.Published); + } + + private async Task TestManyRequest(bool isFrontend, bool archive, params Status[] status) + { + SetupClaims(isFrontend); + + SetupFakeWithOdataQuery(status); + SetupFakeWithScripting(); + + var result = await sut.QueryWithCountAsync(app, schemaId.ToString(), user, archive, string.Empty); + + Assert.Equal(123, result.Total); + Assert.Equal(schema, result.Schema); + Assert.Equal(data, result.Items[0].Data); + Assert.Equal(content.Id, result.Items[0].Id); + } + + private async Task TestManyIdRequest(bool isFrontend, bool archive, HashSet ids, params Status[] status) + { + SetupClaims(isFrontend); + + SetupFakeWithIdQuery(status, ids); + SetupFakeWithScripting(); + + var result = await sut.QueryWithCountAsync(app, schemaId.ToString(), user, archive, ids); + + Assert.Equal(123, result.Total); + Assert.Equal(schema, result.Schema); + Assert.Equal(data, result.Items[0].Data); + Assert.Equal(content.Id, result.Items[0].Id); + } + + private void SetupClaims(bool isFrontend) + { + if (isFrontend) + { + identity.AddClaim(new Claim(OpenIdClaims.ClientId, "squidex-frontend")); + } + } + + private void SetupFakeWithIdQuery(Status[] status, HashSet ids) + { + A.CallTo(() => appProvider.GetSchemaAsync(appName, schemaId, false)) + .Returns(schema); + A.CallTo(() => contentRepository.QueryAsync(app, schema, A.That.IsSameSequenceAs(status), ids)) + .Returns(new List { content }); + A.CallTo(() => contentRepository.CountAsync(app, schema, A.That.IsSameSequenceAs(status), ids)) + .Returns(123); + } + + private void SetupFakeWithOdataQuery(Status[] status) + { + A.CallTo(() => appProvider.GetSchemaAsync(appName, schemaId, false)) + .Returns(schema); + A.CallTo(() => contentRepository.QueryAsync(app, schema, A.That.IsSameSequenceAs(status), A.Ignored)) + .Returns(new List { content }); + A.CallTo(() => contentRepository.CountAsync(app, schema, A.That.IsSameSequenceAs(status), A.Ignored)) + .Returns(123); + } + + private void SetupFakeWithScripting() + { + A.CallTo(() => schema.ScriptQuery) + .Returns(""); + + A.CallTo(() => scriptEngine.Transform(A.That.Matches(x => x.User == user && x.ContentId == contentId && ReferenceEquals(x.Data, data)), "")) + .Returns(transformedData); + } + } +} diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQLTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQLTests.cs new file mode 100644 index 000000000..c4624d1ff --- /dev/null +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQLTests.cs @@ -0,0 +1,689 @@ +// ========================================================================== +// GraphQLTests.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Security.Claims; +using System.Threading.Tasks; +using FakeItEasy; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using NodaTime.Extensions; +using Squidex.Domain.Apps.Core; +using Squidex.Domain.Apps.Core.Apps; +using Squidex.Domain.Apps.Core.Contents; +using Squidex.Domain.Apps.Core.Schemas; +using Squidex.Domain.Apps.Entities.Apps; +using Squidex.Domain.Apps.Entities.Assets; +using Squidex.Domain.Apps.Entities.Assets.Repositories; +using Squidex.Domain.Apps.Entities.Contents.GraphQL; +using Squidex.Domain.Apps.Entities.Contents.TestData; +using Squidex.Domain.Apps.Entities.Schemas; +using Squidex.Infrastructure; +using Xunit; + +#pragma warning disable SA1311 // Static readonly fields must begin with upper-case letter + +namespace Squidex.Domain.Apps.Entities.Contents +{ + public class GraphQLTests + { + private static readonly Guid schemaId = Guid.NewGuid(); + private static readonly Guid appId = Guid.NewGuid(); + private static readonly string appName = "my-app"; + private readonly Schema schemaDef; + private readonly IContentQueryService contentQuery = A.Fake(); + private readonly IAssetRepository assetRepository = A.Fake(); + private readonly ISchemaEntity schema = A.Fake(); + private readonly IMemoryCache cache = new MemoryCache(Options.Create(new MemoryCacheOptions())); + private readonly IAppProvider appProvider = A.Fake(); + private readonly IAppEntity app = A.Dummy(); + private readonly ClaimsPrincipal user = new ClaimsPrincipal(); + private readonly IGraphQLService sut; + + public GraphQLTests() + { + schemaDef = + new Schema("my-schema") + .AddField(new JsonField(1, "my-json", Partitioning.Invariant, + new JsonFieldProperties())) + .AddField(new StringField(2, "my-string", Partitioning.Language, + new StringFieldProperties())) + .AddField(new NumberField(3, "my-number", Partitioning.Invariant, + new NumberFieldProperties())) + .AddField(new AssetsField(4, "my-assets", Partitioning.Invariant, + new AssetsFieldProperties())) + .AddField(new BooleanField(5, "my-boolean", Partitioning.Invariant, + new BooleanFieldProperties())) + .AddField(new DateTimeField(6, "my-datetime", Partitioning.Invariant, + new DateTimeFieldProperties())) + .AddField(new ReferencesField(7, "my-references", Partitioning.Invariant, + new ReferencesFieldProperties { SchemaId = schemaId })) + .AddField(new ReferencesField(9, "my-invalid", Partitioning.Invariant, + new ReferencesFieldProperties { SchemaId = Guid.NewGuid() })) + .AddField(new GeolocationField(10, "my-geolocation", Partitioning.Invariant, + new GeolocationFieldProperties())) + .AddField(new TagsField(11, "my-tags", Partitioning.Invariant, + new TagsFieldProperties())); + + A.CallTo(() => app.Id).Returns(appId); + A.CallTo(() => app.Name).Returns(appName); + A.CallTo(() => app.LanguagesConfig).Returns(LanguagesConfig.Build(Language.DE)); + + A.CallTo(() => schema.Id).Returns(schemaId); + A.CallTo(() => schema.Name).Returns(schemaDef.Name); + A.CallTo(() => schema.SchemaDef).Returns(schemaDef); + A.CallTo(() => schema.IsPublished).Returns(true); + A.CallTo(() => schema.ScriptQuery).Returns(""); + + var allSchemas = new List { schema }; + + A.CallTo(() => appProvider.GetSchemasAsync(appName)).Returns(allSchemas); + + sut = new CachingGraphQLService(cache, appProvider, assetRepository, contentQuery, new FakeUrlGenerator()); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task Should_return_empty_object_for_empty_query(string query) + { + var result = await sut.QueryAsync(app, user, new GraphQLQuery { Query = query }); + + var expected = new + { + data = new + { + } + }; + + AssertJson(expected, new { data = result.Data }); + } + + [Fact] + public async Task Should_return_multiple_assets_when_querying_assets() + { + const string query = @" + query { + queryAssets(search: ""my-query"", top: 30, skip: 5) { + id + version + created + createdBy + lastModified + lastModifiedBy + url + thumbnailUrl + sourceUrl + mimeType + fileName + fileSize + fileVersion + isImage + pixelWidth + pixelHeight + } + }"; + + var asset = CreateAsset(Guid.NewGuid()); + + var assets = new List { asset }; + + A.CallTo(() => assetRepository.QueryAsync(app.Id, null, null, "my-query", 30, 5)) + .Returns(assets); + + var result = await sut.QueryAsync(app, user, new GraphQLQuery { Query = query }); + + var expected = new + { + data = new + { + queryAssets = new dynamic[] + { + new + { + id = asset.Id, + version = 1, + created = asset.Created.ToDateTimeUtc(), + createdBy = "subject:user1", + lastModified = asset.LastModified.ToDateTimeUtc(), + lastModifiedBy = "subject:user2", + url = $"assets/{asset.Id}", + thumbnailUrl = $"assets/{asset.Id}?width=100", + sourceUrl = $"assets/source/{asset.Id}", + mimeType = "image/png", + fileName = "MyFile.png", + fileSize = 1024, + fileVersion = 123, + isImage = true, + pixelWidth = 800, + pixelHeight = 600 + } + } + } + }; + + AssertJson(expected, new { data = result.Data }); + } + + [Fact] + public async Task Should_return_single_asset_when_finding_asset() + { + var assetId = Guid.NewGuid(); + var asset = CreateAsset(Guid.NewGuid()); + + var query = $@" + query {{ + findAsset(id: ""{assetId}"") {{ + id + version + created + createdBy + lastModified + lastModifiedBy + url + thumbnailUrl + sourceUrl + mimeType + fileName + fileSize + fileVersion + isImage + pixelWidth + pixelHeight + }} + }}"; + + A.CallTo(() => assetRepository.FindAssetAsync(assetId)) + .Returns(asset); + + var result = await sut.QueryAsync(app, user, new GraphQLQuery { Query = query }); + + var expected = new + { + data = new + { + findAsset = new + { + id = asset.Id, + version = 1, + created = asset.Created.ToDateTimeUtc(), + createdBy = "subject:user1", + lastModified = asset.LastModified.ToDateTimeUtc(), + lastModifiedBy = "subject:user2", + url = $"assets/{asset.Id}", + thumbnailUrl = $"assets/{asset.Id}?width=100", + sourceUrl = $"assets/source/{asset.Id}", + mimeType = "image/png", + fileName = "MyFile.png", + fileSize = 1024, + fileVersion = 123, + isImage = true, + pixelWidth = 800, + pixelHeight = 600 + } + } + }; + + AssertJson(expected, new { data = result.Data }); + } + + [Fact] + public async Task Should_return_multiple_contents_when_querying_contents() + { + const string query = @" + query { + queryMySchemaContents(top: 30, skip: 5) { + id + version + created + createdBy + lastModified + lastModifiedBy + url + data { + myString { + de + } + myNumber { + iv + } + myBoolean { + iv + } + myDatetime { + iv + } + myJson { + iv + } + myGeolocation { + iv + } + myTags { + iv + } + } + } + }"; + + var content = CreateContent(Guid.NewGuid(), Guid.Empty, Guid.Empty); + + var contents = new List { content }; + + A.CallTo(() => contentQuery.QueryWithCountAsync(app, schema.Id.ToString(), user, false, "?$top=30&$skip=5")) + .Returns((schema, 0L, (IReadOnlyList)contents)); + + var result = await sut.QueryAsync(app, user, new GraphQLQuery { Query = query }); + + var expected = new + { + data = new + { + queryMySchemaContents = new dynamic[] + { + new + { + id = content.Id, + version = 1, + created = content.Created.ToDateTimeUtc(), + createdBy = "subject:user1", + lastModified = content.LastModified.ToDateTimeUtc(), + lastModifiedBy = "subject:user2", + url = $"contents/my-schema/{content.Id}", + data = new + { + myString = new + { + de = "value" + }, + myNumber = new + { + iv = 1 + }, + myBoolean = new + { + iv = true + }, + myDatetime = new + { + iv = content.LastModified.ToDateTimeUtc() + }, + myJson = new + { + iv = new + { + value = 1 + } + }, + myGeolocation = new + { + iv = new + { + latitude = 10, + longitude = 20 + } + }, + myTags = new + { + iv = new[] + { + "tag1", + "tag2" + } + } + } + } + } + } + }; + + AssertJson(expected, new { data = result.Data }); + } + + [Fact] + public async Task Should_return_single_content_when_finding_content() + { + var contentId = Guid.NewGuid(); + var content = CreateContent(contentId, Guid.Empty, Guid.Empty); + + var query = $@" + query {{ + findMySchemaContent(id: ""{contentId}"") {{ + id + version + created + createdBy + lastModified + lastModifiedBy + url + data {{ + myString {{ + de + }} + myNumber {{ + iv + }} + myBoolean {{ + iv + }} + myDatetime {{ + iv + }} + myJson {{ + iv + }} + myGeolocation {{ + iv + }} + myTags {{ + iv + }} + }} + }} + }}"; + + A.CallTo(() => contentQuery.FindContentAsync(app, schema.Id.ToString(), user, contentId)) + .Returns((schema, content)); + + var result = await sut.QueryAsync(app, user, new GraphQLQuery { Query = query }); + + var expected = new + { + data = new + { + findMySchemaContent = new + { + id = content.Id, + version = 1, + created = content.Created.ToDateTimeUtc(), + createdBy = "subject:user1", + lastModified = content.LastModified.ToDateTimeUtc(), + lastModifiedBy = "subject:user2", + url = $"contents/my-schema/{content.Id}", + data = new + { + myString = new + { + de = "value" + }, + myNumber = new + { + iv = 1 + }, + myBoolean = new + { + iv = true + }, + myDatetime = new + { + iv = content.LastModified.ToDateTimeUtc() + }, + myJson = new + { + iv = new + { + value = 1 + } + }, + myGeolocation = new + { + iv = new + { + latitude = 10, + longitude = 20 + } + }, + myTags = new + { + iv = new[] + { + "tag1", + "tag2" + } + } + } + } + } + }; + + AssertJson(expected, new { data = result.Data }); + } + + [Fact] + public async Task Should_also_fetch_referenced_contents_when_field_is_included_in_query() + { + var contentRefId = Guid.NewGuid(); + var contentRef = CreateContent(contentRefId, Guid.Empty, Guid.Empty); + + var contentId = Guid.NewGuid(); + var content = CreateContent(contentId, contentRefId, Guid.Empty); + + var query = $@" + query {{ + findMySchemaContent(id: ""{contentId}"") {{ + id + data {{ + myReferences {{ + iv {{ + id + }} + }} + }} + }} + }}"; + + var refContents = new List { contentRef }; + + A.CallTo(() => contentQuery.FindContentAsync(app, schema.Id.ToString(), user, contentId)) + .Returns((schema, content)); + + A.CallTo(() => contentQuery.QueryWithCountAsync(app, schema.Id.ToString(), user, false, A>.That.Matches(x => x.Contains(contentRefId)))) + .Returns((schema, 0L, (IReadOnlyList)refContents)); + + var result = await sut.QueryAsync(app, user, new GraphQLQuery { Query = query }); + + var expected = new + { + data = new + { + findMySchemaContent = new + { + id = content.Id, + data = new + { + myReferences = new + { + iv = new[] + { + new + { + id = contentRefId + } + } + } + } + } + } + }; + + AssertJson(expected, new { data = result.Data }); + } + + [Fact] + public async Task Should_also_fetch_referenced_assets_when_field_is_included_in_query() + { + var assetRefId = Guid.NewGuid(); + var assetRef = CreateAsset(assetRefId); + + var contentId = Guid.NewGuid(); + var content = CreateContent(contentId, Guid.Empty, assetRefId); + + var query = $@" + query {{ + findMySchemaContent(id: ""{contentId}"") {{ + id + data {{ + myAssets {{ + iv {{ + id + }} + }} + }} + }} + }}"; + + var refAssets = new List { assetRef }; + + A.CallTo(() => contentQuery.FindContentAsync(app, schema.Id.ToString(), user, contentId)) + .Returns((schema, content)); + + A.CallTo(() => assetRepository.QueryAsync(app.Id, null, A>.That.Matches(x => x.Contains(assetRefId)), null, int.MaxValue, 0)) + .Returns(refAssets); + + var result = await sut.QueryAsync(app, user, new GraphQLQuery { Query = query }); + + var expected = new + { + data = new + { + findMySchemaContent = new + { + id = content.Id, + data = new + { + myAssets = new + { + iv = new[] + { + new + { + id = assetRefId + } + } + } + } + } + } + }; + + AssertJson(expected, new { data = result.Data }); + } + + [Fact] + public async Task Should_not_return_data_when_field_not_part_of_content() + { + var contentId = Guid.NewGuid(); + var content = CreateContent(contentId, Guid.Empty, Guid.Empty, new NamedContentData()); + + var query = $@" + query {{ + findMySchemaContent(id: ""{contentId}"") {{ + id + version + created + createdBy + lastModified + lastModifiedBy + url + data {{ + myInvalid {{ + iv + }} + }} + }} + }}"; + + A.CallTo(() => contentQuery.FindContentAsync(app, schema.Id.ToString(), user, contentId)) + .Returns((schema, content)); + + var result = await sut.QueryAsync(app, user, new GraphQLQuery { Query = query }); + + var expected = new + { + data = (object)null + }; + + AssertJson(expected, new { data = result.Data }); + } + + private static IContentEntity CreateContent(Guid id, Guid refId, Guid assetId, NamedContentData data = null) + { + var now = DateTime.UtcNow.ToInstant(); + + data = data ?? + new NamedContentData() + .AddField("my-json", + new ContentFieldData().AddValue("iv", JToken.FromObject(new { value = 1 }))) + .AddField("my-string", + new ContentFieldData().AddValue("de", "value")) + .AddField("my-assets", + new ContentFieldData().AddValue("iv", JToken.FromObject(new[] { assetId }))) + .AddField("my-number", + new ContentFieldData().AddValue("iv", 1)) + .AddField("my-boolean", + new ContentFieldData().AddValue("iv", true)) + .AddField("my-datetime", + new ContentFieldData().AddValue("iv", now.ToDateTimeUtc())) + .AddField("my-tags", + new ContentFieldData().AddValue("iv", JToken.FromObject(new[] { "tag1", "tag2" }))) + .AddField("my-references", + new ContentFieldData().AddValue("iv", JToken.FromObject(new[] { refId }))) + .AddField("my-geolocation", + new ContentFieldData().AddValue("iv", JToken.FromObject(new { latitude = 10, longitude = 20 }))); + + var content = new FakeContentEntity + { + Id = id, + Version = 1, + Created = now, + CreatedBy = new RefToken("subject", "user1"), + LastModified = now, + LastModifiedBy = new RefToken("subject", "user2"), + Data = data + }; + + return content; + } + + private static IAssetEntity CreateAsset(Guid id) + { + var now = DateTime.UtcNow.ToInstant(); + + var asset = new FakeAssetEntity + { + Id = id, + Version = 1, + Created = now, + CreatedBy = new RefToken("subject", "user1"), + LastModified = now, + LastModifiedBy = new RefToken("subject", "user2"), + FileName = "MyFile.png", + FileSize = 1024, + FileVersion = 123, + MimeType = "image/png", + IsImage = true, + PixelWidth = 800, + PixelHeight = 600 + }; + + return asset; + } + + private static void AssertJson(object expected, object result) + { + var resultJson = JsonConvert.SerializeObject(result, Formatting.Indented); + var expectJson = JsonConvert.SerializeObject(expected, Formatting.Indented); + + Assert.Equal(expectJson, resultJson); + } + } +} diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ODataQueryTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ODataQueryTests.cs new file mode 100644 index 000000000..bd00e634b --- /dev/null +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ODataQueryTests.cs @@ -0,0 +1,394 @@ +// ========================================================================== +// ODataQueryTests.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +/* +using System; +using System.Collections.Immutable; +using FakeItEasy; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; +using Microsoft.OData.Edm; +using MongoDB.Bson.Serialization; +using MongoDB.Driver; +using Squidex.Domain.Apps.Core; +using Squidex.Domain.Apps.Core.Apps; +using Squidex.Domain.Apps.Core.Schemas; +using Squidex.Domain.Apps.Entities.Apps; +using Squidex.Domain.Apps.Entities.Contents.Edm; +using Squidex.Domain.Apps.Entities.MongoDb.Contents; +using Squidex.Domain.Apps.Entities.MongoDb.Contents.Visitors; +using Squidex.Domain.Apps.Entities.Schemas; +using Squidex.Infrastructure; +using Squidex.Infrastructure.MongoDb; +using Xunit; + +namespace Squidex.Domain.Apps.Entities.Contents +{ + public class ODataQueryTests + { + private readonly Schema schemaDef; + private readonly IBsonSerializerRegistry registry = BsonSerializer.SerializerRegistry; + private readonly IBsonSerializer serializer = BsonSerializer.SerializerRegistry.GetSerializer(); + private readonly IEdmModel edmModel; + private readonly LanguagesConfig languagesConfig = LanguagesConfig.Build(Language.EN, Language.DE); + + static ODataQueryTests() + { + InstantSerializer.Register(); + } + + public ODataQueryTests() + { + schemaDef = + new Schema("user") + .AddField(new StringField(1, "firstName", Partitioning.Language, + new StringFieldProperties { Label = "FirstName", IsRequired = true, AllowedValues = ImmutableList.Create("1", "2") })) + .AddField(new StringField(2, "lastName", Partitioning.Language, + new StringFieldProperties { Hints = "Last Name", Editor = StringFieldEditor.Input })) + .AddField(new BooleanField(3, "isAdmin", Partitioning.Invariant, + new BooleanFieldProperties())) + .AddField(new NumberField(4, "age", Partitioning.Invariant, + new NumberFieldProperties { MinValue = 1, MaxValue = 10 })) + .AddField(new DateTimeField(5, "birthday", Partitioning.Invariant, + new DateTimeFieldProperties())) + .AddField(new AssetsField(6, "pictures", Partitioning.Invariant, + new AssetsFieldProperties())) + .AddField(new ReferencesField(7, "friends", Partitioning.Invariant, + new ReferencesFieldProperties())) + .AddField(new StringField(8, "dashed-field", Partitioning.Invariant, + new StringFieldProperties())) + .Update(new SchemaProperties { Hints = "The User" }); + + var builder = new EdmModelBuilder(new MemoryCache(Options.Create(new MemoryCacheOptions()))); + + var schema = A.Dummy(); + A.CallTo(() => schema.Id).Returns(Guid.NewGuid()); + A.CallTo(() => schema.Version).Returns(3); + A.CallTo(() => schema.SchemaDef).Returns(schemaDef); + + var app = A.Dummy(); + A.CallTo(() => app.Id).Returns(Guid.NewGuid()); + A.CallTo(() => app.Version).Returns(3); + A.CallTo(() => app.LanguagesConfig).Returns(languagesConfig); + + edmModel = builder.BuildEdmModel(schema, app); + } + + [Fact] + public void Should_parse_query() + { + var parser = edmModel.ParseQuery("$filter=data/firstName/de eq 'Sebastian'"); + + Assert.NotNull(parser); + } + + [Fact] + public void Should_make_query_with_underscore_field() + { + var i = F("$filter=data/dashed_field/iv eq 'Value'"); + var o = C("{ 'do.8.iv' : 'Value' }"); + + Assert.Equal(o, i); + } + + [Fact] + public void Should_create_not_operator() + { + var i = F("$filter=not endswith(data/firstName/de, 'Sebastian')"); + var o = C("{ 'do.1.de' : { '$not' : /Sebastian$/i } }"); + + Assert.Equal(o, i); + } + + [Fact] + public void Should_create_starts_with_query() + { + var i = F("$filter=startswith(data/firstName/de, 'Sebastian')"); + var o = C("{ 'do.1.de' : /^Sebastian/i }"); + + Assert.Equal(o, i); + } + + [Fact] + public void Should_create_ends_with_query() + { + var i = F("$filter=endswith(data/firstName/de, 'Sebastian')"); + var o = C("{ 'do.1.de' : /Sebastian$/i }"); + + Assert.Equal(o, i); + } + + [Fact] + public void Should_create_contains_query() + { + var i = F("$filter=contains(data/firstName/de, 'Sebastian')"); + var o = C("{ 'do.1.de' : /Sebastian/i }"); + + Assert.Equal(o, i); + } + + [Fact] + public void Should_create_contains_query_with_equals() + { + var i = F("$filter=contains(data/firstName/de, 'Sebastian') eq true"); + var o = C("{ 'do.1.de' : /Sebastian/i }"); + + Assert.Equal(o, i); + } + + [Fact] + public void Should_create_negated_contains_query_with_equals() + { + var i = F("$filter=contains(data/firstName/de, 'Sebastian') eq false"); + var o = C("{ 'do.1.de' : { '$not' : /Sebastian/i } }"); + + Assert.Equal(o, i); + } + + [Fact] + public void Should_create_negated_contains_query_and_other() + { + var i = F("$filter=contains(data/firstName/de, 'Sebastian') eq false and data/isAdmin/iv eq true"); + var o = C("{ 'do.1.de' : { '$not' : /Sebastian/i }, 'do.3.iv' : true }"); + + Assert.Equal(o, i); + } + + [Fact] + public void Should_create_string_equals_query() + { + var i = F("$filter=data/firstName/de eq 'Sebastian'"); + var o = C("{ 'do.1.de' : 'Sebastian' }"); + + Assert.Equal(o, i); + } + + [Fact] + public void Should_create_datetime_equals_query() + { + var i = F("$filter=data/birthday/iv eq 1988-01-19T12:00:00Z"); + var o = C("{ 'do.5.iv' : ISODate(\"1988-01-19T12:00:00Z\") }"); + + Assert.Equal(o, i); + } + + [Fact] + public void Should_create_boolean_equals_query() + { + var i = F("$filter=data/isAdmin/iv eq true"); + var o = C("{ 'do.3.iv' : true }"); + + Assert.Equal(o, i); + } + + [Fact] + public void Should_create_string_not_equals_query() + { + var i = F("$filter=data/firstName/de ne 'Sebastian'"); + var o = C("{ '$or' : [{ 'do.1.de' : { '$exists' : false } }, { 'do.1.de' : { '$ne' : 'Sebastian' } }] }"); + + Assert.Equal(o, i); + } + + [Fact] + public void Should_create_number_less_than_query() + { + var i = F("$filter=data/age/iv lt 1"); + var o = C("{ 'do.4.iv' : { '$lt' : 1.0 } }"); + + Assert.Equal(o, i); + } + + [Fact] + public void Should_create_number_less_equals_query() + { + var i = F("$filter=data/age/iv le 1"); + var o = C("{ 'do.4.iv' : { '$lte' : 1.0 } }"); + + Assert.Equal(o, i); + } + + [Fact] + public void Should_create_number_greater_than_query() + { + var i = F("$filter=data/age/iv gt 1"); + var o = C("{ 'do.4.iv' : { '$gt' : 1.0 } }"); + + Assert.Equal(o, i); + } + + [Fact] + public void Should_create_number_greater_equals_query() + { + var i = F("$filter=data/age/iv ge 1"); + var o = C("{ 'do.4.iv' : { '$gte' : 1.0 } }"); + + Assert.Equal(o, i); + } + + [Fact] + public void Should_create_equals_query_for_assets() + { + var i = F("$filter=data/pictures/iv eq 'guid'"); + var o = C("{ 'do.6.iv' : 'guid' }"); + + Assert.Equal(o, i); + } + + [Fact] + public void Should_create_equals_query_for_references() + { + var i = F("$filter=data/friends/iv eq 'guid'"); + var o = C("{ 'do.7.iv' : 'guid' }"); + + Assert.Equal(o, i); + } + + [Fact] + public void Should_create_and_query() + { + var i = F("$filter=data/age/iv eq 1 and data/age/iv eq 2"); + var o = C("{ '$and' : [{ 'do.4.iv' : 1.0 }, { 'do.4.iv' : 2.0 }] }"); + + Assert.Equal(o, i); + } + + [Fact] + public void Should_create_or_query() + { + var i = F("$filter=data/age/iv eq 1 or data/age/iv eq 2"); + var o = C("{ '$or' : [{ 'do.4.iv' : 1.0 }, { 'do.4.iv' : 2.0 }] }"); + + Assert.Equal(o, i); + } + + [Fact] + public void Should_create_full_text_query() + { + var i = F("$search=Hello my World"); + var o = C("{ '$text' : { '$search' : 'Hello my World' } }"); + + Assert.Equal(o, i); + } + + [Fact] + public void Should_create_full_text_query_with_and() + { + var i = F("$search=A and B"); + var o = C("{ '$text' : { '$search' : 'A and B' } }"); + + Assert.Equal(o, i); + } + + [Fact] + public void Should_convert_orderby_with_single_statements() + { + var i = S("$orderby=data/age/iv desc"); + var o = C("{ 'do.4.iv' : -1 }"); + + Assert.Equal(o, i); + } + + [Fact] + public void Should_convert_orderby_with_multiple_statements() + { + var i = S("$orderby=data/age/iv, data/firstName/en desc"); + var o = C("{ 'do.4.iv' : 1, 'do.1.en' : -1 }"); + + Assert.Equal(o, i); + } + + [Fact] + public void Should_set_top() + { + var parser = edmModel.ParseQuery("$top=3"); + var cursor = A.Fake>(); + + cursor.Take(parser); + + A.CallTo(() => cursor.Limit(3)).MustHaveHappened(); + } + + [Fact] + public void Should_set_max_top_if_larger() + { + var parser = edmModel.ParseQuery("$top=300"); + var cursor = A.Fake>(); + + cursor.Take(parser); + + A.CallTo(() => cursor.Limit(200)).MustHaveHappened(); + } + + [Fact] + public void Should_set_default_top() + { + var parser = edmModel.ParseQuery(string.Empty); + var cursor = A.Fake>(); + + cursor.Take(parser); + + A.CallTo(() => cursor.Limit(20)).MustHaveHappened(); + } + + [Fact] + public void Should_set_skip() + { + var parser = edmModel.ParseQuery("$skip=3"); + var cursor = A.Fake>(); + + cursor.Skip(parser); + + A.CallTo(() => cursor.Skip(3)).MustHaveHappened(); + } + + [Fact] + public void Should_not_set_skip() + { + var parser = edmModel.ParseQuery(string.Empty); + var cursor = A.Fake>(); + + cursor.Take(parser); + + A.CallTo(() => cursor.Skip(A.Ignored)).MustNotHaveHappened(); + } + + private static string C(string value) + { + return value.Replace('\'', '"'); + } + + private string S(string value) + { + var parser = edmModel.ParseQuery(value); + var cursor = A.Fake>(); + + var i = string.Empty; + + A.CallTo(() => cursor.Sort(A>.Ignored)) + .Invokes((SortDefinition sortDefinition) => + { + i = sortDefinition.Render(serializer, registry).ToString(); + }); + + cursor.Sort(parser, schemaDef); + + return i; + } + + private string F(string value) + { + var parser = edmModel.ParseQuery(value); + + var query = FilterBuilder.Build(parser, schemaDef).Render(serializer, registry).ToString(); + + return query; + } + } +} +*/ \ No newline at end of file diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Contents/TestData/FakeAssetEntity.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/TestData/FakeAssetEntity.cs new file mode 100644 index 000000000..261c1d4f0 --- /dev/null +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/TestData/FakeAssetEntity.cs @@ -0,0 +1,50 @@ +// ========================================================================== +// MockupAssetEntity.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using NodaTime; +using Squidex.Domain.Apps.Entities.Assets; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities.Contents.TestData +{ + public sealed class FakeAssetEntity : IAssetEntity + { + public Guid Id { get; set; } + + public Guid AppId { get; set; } + + public Guid AssetId { get; set; } + + public Instant Created { get; set; } + + public Instant LastModified { get; set; } + + public RefToken CreatedBy { get; set; } + + public RefToken LastModifiedBy { get; set; } + + public long Version { get; set; } + + public string MimeType { get; set; } + + public string FileName { get; set; } + + public long FileSize { get; set; } + + public long FileVersion { get; set; } + + public bool IsImage { get; set; } + + public bool IsDeleted { get; set; } + + public int? PixelWidth { get; set; } + + public int? PixelHeight { get; set; } + } +} diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Contents/TestData/FakeContentEntity.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/TestData/FakeContentEntity.cs new file mode 100644 index 000000000..fe06f5165 --- /dev/null +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/TestData/FakeContentEntity.cs @@ -0,0 +1,36 @@ +// ========================================================================== +// FakeContentEntity.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using NodaTime; +using Squidex.Domain.Apps.Core.Contents; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities.Contents.TestData +{ + public sealed class FakeContentEntity : IContentEntity + { + public Guid Id { get; set; } + + public Guid AppId { get; set; } + + public long Version { get; set; } + + public Instant Created { get; set; } + + public Instant LastModified { get; set; } + + public RefToken CreatedBy { get; set; } + + public RefToken LastModifiedBy { get; set; } + + public NamedContentData Data { get; set; } + + public Status Status { get; set; } + } +} diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Contents/TestData/FakeUrlGenerator.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/TestData/FakeUrlGenerator.cs new file mode 100644 index 000000000..900d01fc1 --- /dev/null +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/TestData/FakeUrlGenerator.cs @@ -0,0 +1,40 @@ +// ========================================================================== +// FakeUrlGenerator.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using Squidex.Domain.Apps.Entities.Apps; +using Squidex.Domain.Apps.Entities.Assets; +using Squidex.Domain.Apps.Entities.Contents.GraphQL; +using Squidex.Domain.Apps.Entities.Schemas; + +namespace Squidex.Domain.Apps.Entities.Contents.TestData +{ + public sealed class FakeUrlGenerator : IGraphQLUrlGenerator + { + public bool CanGenerateAssetSourceUrl { get; } = true; + + public string GenerateAssetUrl(IAppEntity app, IAssetEntity asset) + { + return $"assets/{asset.Id}"; + } + + public string GenerateAssetThumbnailUrl(IAppEntity app, IAssetEntity asset) + { + return $"assets/{asset.Id}?width=100"; + } + + public string GenerateAssetSourceUrl(IAppEntity app, IAssetEntity asset) + { + return $"assets/source/{asset.Id}"; + } + + public string GenerateContentUrl(IAppEntity app, ISchemaEntity schema, IContentEntity content) + { + return $"contents/{schema.Name}/{content.Id}"; + } + } +} diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleDequeuerTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleDequeuerTests.cs new file mode 100644 index 000000000..288d66e22 --- /dev/null +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleDequeuerTests.cs @@ -0,0 +1,99 @@ +// ========================================================================== +// RuleDequeuerGrainTests.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using System.Threading.Tasks; +using FakeItEasy; +using NodaTime; +using Squidex.Domain.Apps.Core.HandleRules; +using Squidex.Domain.Apps.Core.Rules; +using Squidex.Domain.Apps.Entities.Rules.Repositories; +using Squidex.Infrastructure.Log; +using Xunit; + +#pragma warning disable RECS0165 // Asynchronous methods should return a Task instead of void + +namespace Squidex.Domain.Apps.Entities.Rules +{ + public class RuleDequeuerTests + { + private readonly IClock clock = A.Fake(); + private readonly ISemanticLog log = A.Fake(); + private readonly IAppProvider appProvider = A.Fake(); + private readonly IRuleEventRepository ruleEventRepository = A.Fake(); + private readonly RuleService ruleService = A.Fake(); + private readonly RuleDequeuer sut; + private readonly Instant now = SystemClock.Instance.GetCurrentInstant(); + + public RuleDequeuerTests() + { + A.CallTo(() => clock.GetCurrentInstant()).Returns(now); + + sut = new RuleDequeuer( + ruleService, + ruleEventRepository, + log, + clock); + } + + [Theory] + [InlineData(0, 0, RuleResult.Success, RuleJobResult.Success)] + [InlineData(0, 5, RuleResult.Timeout, RuleJobResult.Retry)] + [InlineData(1, 60, RuleResult.Timeout, RuleJobResult.Retry)] + [InlineData(2, 360, RuleResult.Failed, RuleJobResult.Retry)] + [InlineData(3, 720, RuleResult.Failed, RuleJobResult.Retry)] + [InlineData(4, 0, RuleResult.Failed, RuleJobResult.Failed)] + public async Task Should_set_next_attempt_based_on_num_calls(int calls, int minutes, RuleResult result, RuleJobResult jobResult) + { + var actionData = new RuleJobData(); + var actionName = "MyAction"; + + var @event = CreateEvent(calls, actionName, actionData); + + var requestElapsed = TimeSpan.FromMinutes(1); + var requestDump = "Dump"; + + A.CallTo(() => ruleService.InvokeAsync(@event.Job.ActionName, @event.Job.ActionData)) + .Returns((requestDump, result, requestElapsed)); + + Instant? nextCall = null; + + if (minutes > 0) + { + nextCall = now.Plus(Duration.FromMinutes(minutes)); + } + + await sut.HandleAsync(@event); + + sut.Dispose(); + + A.CallTo(() => ruleEventRepository.MarkSentAsync(@event.Id, requestDump, result, jobResult, requestElapsed, nextCall)) + .MustHaveHappened(); + } + + private IRuleEventEntity CreateEvent(int numCalls, string actionName, RuleJobData actionData) + { + var @event = A.Fake(); + + var job = new RuleJob + { + RuleId = Guid.NewGuid(), + ActionData = actionData, + ActionName = actionName, + Created = now + }; + + A.CallTo(() => @event.Id).Returns(Guid.NewGuid()); + A.CallTo(() => @event.Job).Returns(job); + A.CallTo(() => @event.Created).Returns(now); + A.CallTo(() => @event.NumCalls).Returns(numCalls); + + return @event; + } + } +} diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleEnqueuerTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleEnqueuerTests.cs new file mode 100644 index 000000000..3cd294d17 --- /dev/null +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleEnqueuerTests.cs @@ -0,0 +1,102 @@ +// ========================================================================== +// RuleEnqueuerTests.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using FakeItEasy; +using NodaTime; +using Squidex.Domain.Apps.Core.HandleRules; +using Squidex.Domain.Apps.Core.Rules; +using Squidex.Domain.Apps.Core.Rules.Actions; +using Squidex.Domain.Apps.Core.Rules.Triggers; +using Squidex.Domain.Apps.Entities.Rules.Repositories; +using Squidex.Domain.Apps.Events.Contents; +using Squidex.Infrastructure; +using Squidex.Infrastructure.EventSourcing; +using Xunit; + +namespace Squidex.Domain.Apps.Entities.Rules +{ + public class RuleEnqueuerTests + { + private readonly IAppProvider appProvider = A.Fake(); + private readonly IRuleEventRepository ruleEventRepository = A.Fake(); + private readonly RuleService ruleService = A.Fake(); + private readonly Instant now = SystemClock.Instance.GetCurrentInstant(); + private readonly NamedId appId = new NamedId(Guid.NewGuid(), "my-app"); + private readonly RuleEnqueuer sut; + + public RuleEnqueuerTests() + { + sut = new RuleEnqueuer( + ruleEventRepository, + appProvider, + ruleService); + } + + [Fact] + public void Should_return_contents_filter_for_events_filter() + { + Assert.Equal(".*", sut.EventsFilter); + } + + [Fact] + public void Should_return_type_name_for_name() + { + Assert.Equal(typeof(RuleEnqueuer).Name, sut.Name); + } + + [Fact] + public Task Should_do_nothing_on_clear() + { + return sut.ClearAsync(); + } + + [Fact] + public async Task Should_update_repositories_on_with_jobs_from_sender() + { + var @event = Envelope.Create(new ContentCreated { AppId = appId }); + + var rule1 = new Rule(new ContentChangedTrigger(), new WebhookAction { Url = new Uri("https://squidex.io") }); + var rule2 = new Rule(new ContentChangedTrigger(), new WebhookAction { Url = new Uri("https://squidex.io") }); + var rule3 = new Rule(new ContentChangedTrigger(), new WebhookAction { Url = new Uri("https://squidex.io") }); + + var job1 = new RuleJob { Created = now }; + var job2 = new RuleJob { Created = now }; + + var ruleEntity1 = A.Fake(); + var ruleEntity2 = A.Fake(); + var ruleEntity3 = A.Fake(); + + A.CallTo(() => ruleEntity1.RuleDef).Returns(rule1); + A.CallTo(() => ruleEntity2.RuleDef).Returns(rule2); + A.CallTo(() => ruleEntity3.RuleDef).Returns(rule3); + + A.CallTo(() => appProvider.GetRulesAsync(appId.Name)) + .Returns(new List { ruleEntity1, ruleEntity2, ruleEntity3 }); + + A.CallTo(() => ruleService.CreateJob(rule1, @event)) + .Returns(job1); + + A.CallTo(() => ruleService.CreateJob(rule2, @event)) + .Returns(job2); + + A.CallTo(() => ruleService.CreateJob(rule3, @event)) + .Returns(null); + + await sut.On(@event); + + A.CallTo(() => ruleEventRepository.EnqueueAsync(job1, now)) + .MustHaveHappened(); + + A.CallTo(() => ruleEventRepository.EnqueueAsync(job2, now)) + .MustHaveHappened(); + } + } +} \ No newline at end of file diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Squidex.Domain.Apps.Entities.Tests.csproj b/tests/Squidex.Domain.Apps.Entities.Tests/Squidex.Domain.Apps.Entities.Tests.csproj new file mode 100644 index 000000000..eab4caa37 --- /dev/null +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Squidex.Domain.Apps.Entities.Tests.csproj @@ -0,0 +1,36 @@ + + + Exe + netcoreapp2.0 + Squidex.Domain.Apps.Entities + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ..\..\Squidex.ruleset + + \ No newline at end of file diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/TestHelpers/AssertHelper.cs b/tests/Squidex.Domain.Apps.Entities.Tests/TestHelpers/AssertHelper.cs new file mode 100644 index 000000000..6003f51c4 --- /dev/null +++ b/tests/Squidex.Domain.Apps.Entities.Tests/TestHelpers/AssertHelper.cs @@ -0,0 +1,45 @@ +// ========================================================================== +// AssertHelper.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System.Collections.Generic; +using System.Linq; +using FluentAssertions; +using Squidex.Infrastructure.EventSourcing; + +namespace Squidex.Domain.Apps.Entities.TestHelpers +{ + public static class AssertHelper + { + public static void ShouldHaveSameEvents(this IEnumerable> events, params IEvent[] others) + { + var source = events.Select(x => x.Payload).ToArray(); + + source.Should().HaveSameCount(others); + + for (var i = 0; i < source.Length; i++) + { + var lhs = source[i]; + var rhs = others[i]; + + lhs.ShouldBeSameEvent(rhs); + } + } + + public static void ShouldBeSameEvent(this IEvent lhs, IEvent rhs) + { + lhs.Should().BeOfType(rhs.GetType()); + + ((object)lhs).ShouldBeEquivalentTo(rhs, o => o.IncludingAllDeclaredProperties()); + } + + public static void ShouldBeSameEventType(this IEvent lhs, IEvent rhs) + { + lhs.Should().BeOfType(rhs.GetType()); + } + } +} diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/TestHelpers/HandlerTestBase.cs b/tests/Squidex.Domain.Apps.Entities.Tests/TestHelpers/HandlerTestBase.cs new file mode 100644 index 000000000..763b0db25 --- /dev/null +++ b/tests/Squidex.Domain.Apps.Entities.Tests/TestHelpers/HandlerTestBase.cs @@ -0,0 +1,162 @@ +// ========================================================================== +// HandlerTestBase.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using System.Threading.Tasks; +using Squidex.Domain.Apps.Events; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Commands; + +#pragma warning disable IDE0019 // Use pattern matching + +namespace Squidex.Domain.Apps.Entities.TestHelpers +{ + public abstract class HandlerTestBase where T : IDomainObject + { + private sealed class MockupHandler : IAggregateHandler + { + private T domainObject; + + public bool IsCreated { get; private set; } + public bool IsUpdated { get; private set; } + + public void Init(T newDomainObject) + { + domainObject = newDomainObject; + + IsCreated = false; + IsUpdated = false; + } + + public async Task CreateAsync(CommandContext context, Func creator) where V : class, IDomainObject + { + IsCreated = true; + + var @do = domainObject as V; + + await creator(domainObject as V); + + return @do; + } + + public async Task UpdateAsync(CommandContext context, Func updater) where V : class, IDomainObject + { + IsUpdated = true; + + var @do = domainObject as V; + + await updater(domainObject as V); + + return @do; + } + } + + private readonly MockupHandler handler = new MockupHandler(); + + protected RefToken User { get; } = new RefToken("subject", Guid.NewGuid().ToString()); + + protected Guid AppId { get; } = Guid.NewGuid(); + + protected Guid SchemaId { get; } = Guid.NewGuid(); + + protected string AppName { get; } = "my-app"; + + protected string SchemaName { get; } = "my-schema"; + + protected NamedId AppNamedId + { + get { return new NamedId(AppId, AppName); } + } + + protected NamedId SchemaNamedId + { + get { return new NamedId(SchemaId, SchemaName); } + } + + protected IAggregateHandler Handler + { + get { return handler; } + } + + protected CommandContext CreateContextForCommand(TCommand command) where TCommand : SquidexCommand + { + return new CommandContext(CreateCommand(command)); + } + + protected async Task TestCreate(T domainObject, Func action, bool shouldCreate = true) + { + handler.Init(domainObject); + + await action(domainObject); + + if (!handler.IsCreated && shouldCreate) + { + throw new InvalidOperationException("Create not called."); + } + } + + protected async Task TestUpdate(T domainObject, Func action, bool shouldUpdate = true) + { + handler.Init(domainObject); + + await action(domainObject); + + if (!handler.IsUpdated && shouldUpdate) + { + throw new InvalidOperationException("Update not called."); + } + } + + protected TCommand CreateCommand(TCommand command) where TCommand : SquidexCommand + { + if (command.Actor == null) + { + command.Actor = User; + } + + var appCommand = command as AppCommand; + + if (appCommand != null && appCommand.AppId == null) + { + appCommand.AppId = AppNamedId; + } + + var schemaCommand = command as SchemaCommand; + + if (schemaCommand != null && schemaCommand.SchemaId == null) + { + schemaCommand.SchemaId = SchemaNamedId; + } + + return command; + } + + protected TEvent CreateEvent(TEvent @event) where TEvent : SquidexEvent + { + @event.Actor = User; + + var appEvent = @event as AppEvent; + + if (appEvent != null) + { + appEvent.AppId = AppNamedId; + } + + var schemaEvent = @event as SchemaEvent; + + if (schemaEvent != null) + { + schemaEvent.SchemaId = SchemaNamedId; + } + + return @event; + } + } +} + +#pragma warning restore IDE0019 // Use pattern matching \ No newline at end of file