From 6ca28023cc95176f70c5e3a22677182d89909cfb Mon Sep 17 00:00:00 2001 From: Sebastian Date: Fri, 24 Jan 2020 22:29:27 +0100 Subject: [PATCH] Tests improved --- .../Api/Config/OpenApi/ErrorDtoProcessor.cs | 2 +- .../Api/Config/OpenApi/ODataExtensions.cs | 4 +- .../OpenApi/ODataQueryParamsProcessor.cs | 2 +- .../Api/Controllers/Apps/AppsController.cs | 14 +- .../Controllers/Assets/AssetsController.cs | 25 +- .../Assets/ImageAssetMetadataSourceTests.cs | 1 - .../Queries/QueryJsonConversionTests.cs | 8 + .../TestSuite.ApiTests/AssetTests.cs | 213 +++++++++++++++++- .../TestSuite.ApiTests/Assets/logo-wide.png | Bin 0 -> 17539 bytes .../TestSuite.ApiTests/ContentQueryTests.cs | 135 +++++++++-- .../TestSuite.ApiTests/ContentUpdateTests.cs | 40 +++- .../TestSuite/TestSuite.ApiTests/Setup.cs | 10 + .../TestSuite.ApiTests.csproj | 5 +- .../ReadingContentBenchmarks.cs | 10 +- .../TestSuite.Shared/Fixtures/AssetFixture.cs | 6 +- .../Fixtures/ContentFixture.cs | 9 +- .../Fixtures/ContentQueryFixture.cs | 9 +- .../TestSuite.Shared/TestSuite.Shared.csproj | 2 +- backend/tools/TestSuite/TestSuite.sln | 6 +- 19 files changed, 422 insertions(+), 79 deletions(-) create mode 100644 backend/tools/TestSuite/TestSuite.ApiTests/Assets/logo-wide.png create mode 100644 backend/tools/TestSuite/TestSuite.ApiTests/Setup.cs diff --git a/backend/src/Squidex/Areas/Api/Config/OpenApi/ErrorDtoProcessor.cs b/backend/src/Squidex/Areas/Api/Config/OpenApi/ErrorDtoProcessor.cs index cd9d1d0c5..7e5beb08b 100644 --- a/backend/src/Squidex/Areas/Api/Config/OpenApi/ErrorDtoProcessor.cs +++ b/backend/src/Squidex/Areas/Api/Config/OpenApi/ErrorDtoProcessor.cs @@ -12,8 +12,8 @@ using NJsonSchema; using NSwag; using NSwag.Generation.Processors; using NSwag.Generation.Processors.Contexts; -using Squidex.ClientLibrary.Management; using Squidex.Pipeline.OpenApi; +using Squidex.Web; namespace Squidex.Areas.Api.Config.OpenApi { diff --git a/backend/src/Squidex/Areas/Api/Config/OpenApi/ODataExtensions.cs b/backend/src/Squidex/Areas/Api/Config/OpenApi/ODataExtensions.cs index 24a31aa72..16ffd1f19 100644 --- a/backend/src/Squidex/Areas/Api/Config/OpenApi/ODataExtensions.cs +++ b/backend/src/Squidex/Areas/Api/Config/OpenApi/ODataExtensions.cs @@ -20,8 +20,8 @@ namespace Squidex.Areas.Api.Config.OpenApi operation.AddQuery("$search", JsonObjectType.String, "Optional OData full text search."); } - operation.AddQuery("$top", JsonObjectType.Number, $"Optional number of {entity} to take."); - operation.AddQuery("$skip", JsonObjectType.Number, $"Optional number of {entity} to skip."); + operation.AddQuery("$top", JsonObjectType.Integer, $"Optional number of {entity} to take."); + operation.AddQuery("$skip", JsonObjectType.Integer, $"Optional number of {entity} to skip."); operation.AddQuery("$orderby", JsonObjectType.String, "Optional OData order definition."); operation.AddQuery("$filter", JsonObjectType.String, "Optional OData filter definition."); } diff --git a/backend/src/Squidex/Areas/Api/Config/OpenApi/ODataQueryParamsProcessor.cs b/backend/src/Squidex/Areas/Api/Config/OpenApi/ODataQueryParamsProcessor.cs index 01329cbdd..5f4bf73d7 100644 --- a/backend/src/Squidex/Areas/Api/Config/OpenApi/ODataQueryParamsProcessor.cs +++ b/backend/src/Squidex/Areas/Api/Config/OpenApi/ODataQueryParamsProcessor.cs @@ -26,7 +26,7 @@ namespace Squidex.Areas.Api.Config.OpenApi public bool Process(OperationProcessorContext context) { - if (context.OperationDescription.Path == supportedPath) + if (context.OperationDescription.Path == supportedPath && context.OperationDescription.Method == "get") { var operation = context.OperationDescription.Operation; diff --git a/backend/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs b/backend/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs index fff15b861..7bf49768a 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs @@ -6,7 +6,6 @@ // ========================================================================== using System; -using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading.Tasks; @@ -14,7 +13,6 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Net.Http.Headers; -using NSwag.Annotations; using Squidex.Areas.Api.Controllers.Apps.Models; using Squidex.Domain.Apps.Entities; using Squidex.Domain.Apps.Entities.Apps; @@ -179,7 +177,7 @@ namespace Squidex.Areas.Api.Controllers.Apps [ProducesResponseType(typeof(AppDto), 201)] [ApiPermission(Permissions.AppUpdateImage)] [ApiCosts(0)] - public async Task UploadImage(string app, [OpenApiIgnore] List file) + public async Task UploadImage(string app, IFormFile file) { var response = await InvokeCommandAsync(CreateCommand(file)); @@ -306,16 +304,16 @@ namespace Squidex.Areas.Api.Controllers.Apps return response; } - private static UploadAppImage CreateCommand(IReadOnlyList file) + private UploadAppImage CreateCommand(IFormFile? file) { - if (file.Count != 1) + if (file == null || Request.Form.Files.Count != 1) { - var error = new ValidationError($"Can only upload one file, found {file.Count} files."); + var error = new ValidationError($"Can only upload one file, found {Request.Form.Files.Count} files."); - throw new ValidationException("Cannot create asset.", error); + throw new ValidationException("Cannot upload image.", error); } - return new UploadAppImage { File = file[0].ToAssetFile() }; + return new UploadAppImage { File = file.ToAssetFile() }; } private static FileStream GetTempStream() diff --git a/backend/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs b/backend/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs index 86f3f8874..ee695cd7a 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs @@ -12,7 +12,6 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; using Microsoft.Net.Http.Headers; -using NSwag.Annotations; using Squidex.Areas.Api.Controllers.Assets.Models; using Squidex.Areas.Api.Controllers.Contents; using Squidex.Domain.Apps.Core.Tags; @@ -20,7 +19,6 @@ using Squidex.Domain.Apps.Entities; using Squidex.Domain.Apps.Entities.Apps.Services; using Squidex.Domain.Apps.Entities.Assets; using Squidex.Domain.Apps.Entities.Assets.Commands; -using Squidex.Infrastructure; using Squidex.Infrastructure.Assets; using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.Validation; @@ -185,7 +183,7 @@ namespace Squidex.Areas.Api.Controllers.Assets [AssetRequestSizeLimit] [ApiPermission(Permissions.AppAssetsCreate)] [ApiCosts(1)] - public async Task PostAsset(string app, [FromQuery] Guid parentId, [OpenApiIgnore] List file) + public async Task PostAsset(string app, [FromQuery] Guid parentId, IFormFile file) { var assetFile = await CheckAssetFileAsync(file); @@ -215,7 +213,7 @@ namespace Squidex.Areas.Api.Controllers.Assets [ProducesResponseType(typeof(AssetDto), 200)] [ApiPermission(Permissions.AppAssetsUpload)] [ApiCosts(1)] - public async Task PutAssetContent(string app, Guid id, [OpenApiIgnore] List file) + public async Task PutAssetContent(string app, Guid id, IFormFile file) { var assetFile = await CheckAssetFileAsync(file); @@ -311,20 +309,11 @@ namespace Squidex.Areas.Api.Controllers.Assets } } - private async Task CheckAssetFileAsync(IReadOnlyList file) + private async Task CheckAssetFileAsync(IFormFile? file) { - if (file.Count != 1) + if (file == null || Request.Form.Files.Count != 1) { - var error = new ValidationError($"Can only upload one file, found {file.Count} files."); - - throw new ValidationException("Cannot create asset.", error); - } - - var formFile = file[0]; - - if (formFile.Length > assetOptions.MaxSize) - { - var error = new ValidationError($"File cannot be bigger than {assetOptions.MaxSize.ToReadableSize()}."); + var error = new ValidationError($"Can only upload one file, found {Request.Form.Files.Count} files."); throw new ValidationException("Cannot create asset.", error); } @@ -333,14 +322,14 @@ namespace Squidex.Areas.Api.Controllers.Assets var currentSize = await assetStatsRepository.GetTotalSizeAsync(AppId); - if (plan.MaxAssetSize > 0 && plan.MaxAssetSize < currentSize + formFile.Length) + if (plan.MaxAssetSize > 0 && plan.MaxAssetSize < currentSize + file.Length) { var error = new ValidationError("You have reached your max asset size."); throw new ValidationException("Cannot create asset.", error); } - return formFile.ToAssetFile(); + return file.ToAssetFile(); } } } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/ImageAssetMetadataSourceTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/ImageAssetMetadataSourceTests.cs index 6404c932e..5fed7180d 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/ImageAssetMetadataSourceTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/ImageAssetMetadataSourceTests.cs @@ -7,7 +7,6 @@ using System.Collections.Generic; using System.IO; -using System.Linq; using System.Threading.Tasks; using FakeItEasy; using Squidex.Domain.Apps.Core.Assets; diff --git a/backend/tests/Squidex.Infrastructure.Tests/Queries/QueryJsonConversionTests.cs b/backend/tests/Squidex.Infrastructure.Tests/Queries/QueryJsonConversionTests.cs index 95c702b93..e1c8d9e10 100644 --- a/backend/tests/Squidex.Infrastructure.Tests/Queries/QueryJsonConversionTests.cs +++ b/backend/tests/Squidex.Infrastructure.Tests/Queries/QueryJsonConversionTests.cs @@ -361,6 +361,14 @@ namespace Squidex.Infrastructure.Queries AssertQuery(json, "Filter: string == 'Hello'; FullText: 'Hello'; Skip: 10; Take: 20"); } + [Fact] + public void Should_parse_query_with_top() + { + var json = new { skip = 10, top = 20, FullText = "Hello", Filter = new { path = "string", op = "eq", value = "Hello" } }; + + AssertQuery(json, "Filter: string == 'Hello'; FullText: 'Hello'; Skip: 10; Take: 20"); + } + [Fact] public void Should_parse_query_with_sorting() { diff --git a/backend/tools/TestSuite/TestSuite.ApiTests/AssetTests.cs b/backend/tools/TestSuite/TestSuite.ApiTests/AssetTests.cs index eb7fdeb0b..8e9ad41a5 100644 --- a/backend/tools/TestSuite/TestSuite.ApiTests/AssetTests.cs +++ b/backend/tools/TestSuite/TestSuite.ApiTests/AssetTests.cs @@ -6,8 +6,12 @@ // ========================================================================== using System; +using System.Collections.Generic; using System.IO; +using System.Net.Http; using System.Threading.Tasks; +using Squidex.ClientLibrary; +using Squidex.ClientLibrary.Management; using TestSuite.Fixtures; using Xunit; @@ -32,11 +36,16 @@ namespace TestSuite.ApiTests using (var stream = new FileStream("Assets/logo-squared.png", FileMode.Open)) { - var asset = await _.Assets.CreateAssetAsync(fileName, "image/png", stream); + var file = new FileParameter(stream, fileName, "image/png"); + var asset = await _.Assets.PostAssetAsync(_.AppName, file); + + // Should create metadata. Assert.True(asset.IsImage); Assert.Equal(600, asset.PixelHeight); Assert.Equal(600, asset.PixelWidth); + Assert.Equal(600L, asset.Metadata["pixelHeight"]); + Assert.Equal(600L, asset.Metadata["pixelWidth"]); } } @@ -47,12 +56,212 @@ namespace TestSuite.ApiTests using (var stream = new FileStream("Assets/logo-squared.png", FileMode.Open)) { - var asset = await _.Assets.CreateAssetAsync(fileName, "image/png", stream); + var file = new FileParameter(stream, fileName, "image/png"); + + var asset = await _.Assets.PostAssetAsync(_.AppName, file); + + // Should create metadata. + Assert.True(asset.IsImage); + Assert.Equal(600, asset.PixelHeight); + Assert.Equal(600, asset.PixelWidth); + Assert.Equal(600L, asset.Metadata["pixelHeight"]); + Assert.Equal(600L, asset.Metadata["pixelWidth"]); + } + } + + [Fact] + public async Task Should_replace_asset() + { + var fileName = $"{Guid.NewGuid()}.png"; + + AssetDto asset; + + // STEP 1: Create asset + using (var stream = new FileStream("Assets/logo-squared.png", FileMode.Open)) + { + var file = new FileParameter(stream, fileName, "image/png"); + + asset = await _.Assets.PostAssetAsync(_.AppName, file); Assert.True(asset.IsImage); Assert.Equal(600, asset.PixelHeight); Assert.Equal(600, asset.PixelWidth); } + + + // STEP 2: Reupload asset + using (var stream = new FileStream("Assets/logo-wide.png", FileMode.Open)) + { + var file = new FileParameter(stream, fileName, "image/png"); + + asset = await _.Assets.PutAssetContentAsync(_.AppName, asset.Id.ToString(), file); + + // Should update metadata. + Assert.True(asset.IsImage); + Assert.Equal(135, asset.PixelHeight); + Assert.Equal(600, asset.PixelWidth); + } + + using (var stream = new FileStream("Assets/logo-wide.png", FileMode.Open)) + { + var downloaded = await DownloadAsync(asset); + + // Should dowload with correct size. + Assert.Equal(stream.Length, downloaded.Length); + } + } + + [Fact] + public async Task Should_annote_asset() + { + var fileName = $"{Guid.NewGuid()}.png"; + + AssetDto asset; + + // STEP 1: Create asset + using (var stream = new FileStream("Assets/logo-squared.png", FileMode.Open)) + { + var file = new FileParameter(stream, fileName, "image/png"); + + asset = await _.Assets.PostAssetAsync(_.AppName, file); + } + + + // STEP 2: Annotate metadata. + var metadataRequest = new AnnotateAssetDto + { + Metadata = new Dictionary + { + ["pw"] = 100L, + ["ph"] = 20L + } + }; + + asset = await _.Assets.PutAssetAsync(_.AppName, asset.Id.ToString(), metadataRequest); + + // Should provide metadata. + Assert.Equal(metadataRequest.Metadata, asset.Metadata); + + + // STEP 3: Annotate slug. + var slugRequest = new AnnotateAssetDto { Slug = "my-image" }; + + asset = await _.Assets.PutAssetAsync(_.AppName, asset.Id.ToString(), slugRequest); + + // Should provide updated slug. + Assert.Equal(slugRequest.Slug, asset.Slug); + + + // STEP 3: Annotate file name. + var fileNameRequest = new AnnotateAssetDto { FileName = "My Image" }; + + asset = await _.Assets.PutAssetAsync(_.AppName, asset.Id.ToString(), fileNameRequest); + + // Should provide updated file name. + Assert.Equal(fileNameRequest.FileName, asset.FileName); + } + + [Fact] + public async Task Should_protect_asset() + { + var fileName = $"{Guid.NewGuid()}.png"; + + AssetDto asset; + + // STEP 1: Create asset + using (var stream = new FileStream("Assets/logo-squared.png", FileMode.Open)) + { + var file = new FileParameter(stream, fileName, "image/png"); + + asset = await _.Assets.PostAssetAsync(_.AppName, file); + } + + + // STEP 2: Download asset + using (var stream = new FileStream("Assets/logo-squared.png", FileMode.Open)) + { + var downloaded = await DownloadAsync(asset); + + // Should dowload with correct size. + Assert.Equal(stream.Length, downloaded.Length); + } + + + // STEP 4: Protect asset + var protectRequest = new AnnotateAssetDto { IsProtected = true }; + + asset = await _.Assets.PutAssetAsync(_.AppName, asset.Id.ToString(), protectRequest); + + + // STEP 5: Download asset with authentication. + using (var stream = new FileStream("Assets/logo-squared.png", FileMode.Open)) + { + var downloaded = new MemoryStream(); + + using (var assetStream = await _.Assets.GetAssetContentAsync(asset.Id.ToString())) + { + await assetStream.Stream.CopyToAsync(downloaded); + } + + // Should dowload with correct size. + Assert.Equal(stream.Length, downloaded.Length); + } + + + // STEP 5: Download asset without key. + using (var stream = new FileStream("Assets/logo-squared.png", FileMode.Open)) + { + var ex = await Assert.ThrowsAsync(() => DownloadAsync(asset)); + + // Should return 403 when not authenticated. + Assert.Contains("403", ex.Message); + } + } + + [Fact] + public async Task Should_delete_asset() + { + var fileName = $"{Guid.NewGuid()}.png"; + + AssetDto asset; + + // STEP 1: Create asset + using (var stream = new FileStream("Assets/logo-squared.png", FileMode.Open)) + { + var file = new FileParameter(stream, fileName, "image/png"); + + asset = await _.Assets.PostAssetAsync(_.AppName, file); + } + + + // STEP 2: Delete asset + await _.Assets.DeleteAssetAsync(_.AppName, asset.Id.ToString()); + + // Should return 404 when asset deleted. + var ex = await Assert.ThrowsAsync(() => _.Assets.GetAssetAsync(_.AppName, asset.Id.ToString())); + + Assert.Equal(404, ex.StatusCode); + } + + private async Task DownloadAsync(AssetDto asset) + { + var temp = new MemoryStream(); + + using (var client = new HttpClient()) + { + var url = $"{_.ServerUrl}{asset._links["content"].Href}"; + + var response = await client.GetAsync(url); + + response.EnsureSuccessStatusCode(); + + using (var stream = await response.Content.ReadAsStreamAsync()) + { + await stream.CopyToAsync(temp); + } + } + + return temp; } } } diff --git a/backend/tools/TestSuite/TestSuite.ApiTests/Assets/logo-wide.png b/backend/tools/TestSuite/TestSuite.ApiTests/Assets/logo-wide.png new file mode 100644 index 0000000000000000000000000000000000000000..9a29fa803b15d7c9a05f7de038a7320863ef5f4d GIT binary patch literal 17539 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.Items.Take(3).Select(x => x.EntityId))); @@ -48,45 +48,138 @@ namespace TestSuite.ApiTests } [Fact] - public async Task Should_return_all() + public async Task Should_return_all_with_odata() { - var items = await _.Contents.GetAsync(new ODataQuery { OrderBy = "data/number/iv asc" }); + var items = await _.Contents.GetAsync(new ContentQuery { OrderBy = "data/number/iv asc" }); AssertItems(items, 10, new[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }); } [Fact] - public async Task Should_return_items_with_skip() + public async Task Should_return_all_with_json() { - var items = await _.Contents.GetAsync(new ODataQuery { Skip = 5, OrderBy = "data/number/iv asc" }); + var items = await _.Contents.GetAsync(new ContentQuery + { + JsonQuery = new + { + sort = new[] + { + new + { + path = "data.number.iv", order = "ascending" + } + } + } + }); + + AssertItems(items, 10, new[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }); + } + + [Fact] + public async Task Should_return_items_with_skip_with_odata() + { + var items = await _.Contents.GetAsync(new ContentQuery { Skip = 5, OrderBy = "data/number/iv asc" }); AssertItems(items, 10, new[] { 6, 7, 8, 9, 10 }); } [Fact] - public async Task Should_return_items_with_skip_and_top() + public async Task Should_return_items_with_skip_with_json() { - var items = await _.Contents.GetAsync(new ODataQuery { Skip = 2, Top = 5, OrderBy = "data/number/iv asc" }); + var items = await _.Contents.GetAsync(new ContentQuery + { + JsonQuery = new + { + sort = new[] + { + new + { + path = "data.number.iv", order = "ascending" + } + }, + skip = 5 + } + }); + + AssertItems(items, 10, new[] { 6, 7, 8, 9, 10 }); + } + + [Fact] + public async Task Should_return_items_with_skip_and_top_with_odata() + { + var items = await _.Contents.GetAsync(new ContentQuery { Skip = 2, Top = 5, OrderBy = "data/number/iv asc" }); AssertItems(items, 10, new[] { 3, 4, 5, 6, 7 }); } [Fact] - public async Task Should_return_items_with_ordering() + public async Task Should_return_items_with_skip_and_top_with_json() { - var items = await _.Contents.GetAsync(new ODataQuery { Skip = 2, Top = 5, OrderBy = "data/number/iv desc" }); + var items = await _.Contents.GetAsync(new ContentQuery + { + JsonQuery = new + { + sort = new[] + { + new + { + path = "data.number.iv", order = "ascending" + } + }, + skip = 2, top = 5 + } + }); - AssertItems(items, 10, new[] { 8, 7, 6, 5, 4 }); + AssertItems(items, 10, new[] { 3, 4, 5, 6, 7 }); } [Fact] - public async Task Should_return_items_with_filter() + public async Task Should_return_items_with_filter_with_odata() { - var items = await _.Contents.GetAsync(new ODataQuery { Filter = "data/number/iv gt 3 and data/number/iv lt 7", OrderBy = "data/number/iv asc" }); + var items = await _.Contents.GetAsync(new ContentQuery { Filter = "data/number/iv gt 3 and data/number/iv lt 7", OrderBy = "data/number/iv asc" }); AssertItems(items, 3, new[] { 4, 5, 6 }); } + [Fact] + public async Task Should_return_items_with_filter_with_json() + { + var items = await _.Contents.GetAsync(new ContentQuery + { + JsonQuery = new + { + sort = new[] + { + new + { + path = "data.number.iv", order = "ascending" + } + }, + filter = new + { + and = new[] + { + new + { + path = "data.number.iv", + op = "gt", + value = 3 + }, + new + { + path = "data.number.iv", + op = "lt", + value = 7 + } + } + } + } + }); + + AssertItems(items, 3, new[] { 4, 5, 6 }); + } + + [Fact] public async Task Should_query_items_with_graphql() { @@ -94,10 +187,10 @@ namespace TestSuite.ApiTests { query = @" { - queryNumbersContents(filter: ""data/number/iv gt 3 and data/number/iv lt 7"", orderby: ""data/number/iv asc"") { + queryMyReadsContents(filter: ""data/number/iv gt 3 and data/number/iv lt 7"", orderby: ""data/number/iv asc"") { id, data { - value { + number { iv } } @@ -109,7 +202,7 @@ namespace TestSuite.ApiTests var items = result.Items; - Assert.Equal(items.Select(x => x.Data.Value).ToArray(), new[] { 4, 5, 6 }); + Assert.Equal(items.Select(x => x.Data.Number).ToArray(), new[] { 4, 5, 6 }); } [Fact] @@ -119,10 +212,10 @@ namespace TestSuite.ApiTests { query = @" { - queryNumbersContents(filter: ""data/number/iv gt 3 and data/number/iv lt 7"", orderby: ""data/number/iv asc"") { + queryMyReadsContents(filter: ""data/number/iv gt 3 and data/number/iv lt 7"", orderby: ""data/number/iv asc"") { id, data { - value { + number { iv } } @@ -132,14 +225,14 @@ namespace TestSuite.ApiTests var result = await _.Contents.GraphQlAsync(query); - var items = result["queryNumbersContents"]; + var items = result["queryMyReadsContents"]; - Assert.Equal(items.Select(x => x["data"]["value"]["iv"].Value()).ToArray(), new[] { 4, 5, 6 }); + Assert.Equal(items.Select(x => x["data"]["number"]["iv"].Value()).ToArray(), new[] { 4, 5, 6 }); } private sealed class QueryResult { - [JsonProperty("queryNumbersContents")] + [JsonProperty("queryMyReadsContents")] public QueryItem[] Items { get; set; } } @@ -153,7 +246,7 @@ namespace TestSuite.ApiTests private sealed class QueryItemData { [JsonConverter(typeof(InvariantConverter))] - public int Value { get; set; } + public int Number { get; set; } } private void AssertItems(SquidexEntities entities, int total, int[] expected) diff --git a/backend/tools/TestSuite/TestSuite.ApiTests/ContentUpdateTests.cs b/backend/tools/TestSuite/TestSuite.ApiTests/ContentUpdateTests.cs index b28fc93ea..9e0005739 100644 --- a/backend/tools/TestSuite/TestSuite.ApiTests/ContentUpdateTests.cs +++ b/backend/tools/TestSuite/TestSuite.ApiTests/ContentUpdateTests.cs @@ -41,7 +41,10 @@ namespace TestSuite.ApiTests } finally { - await _.Contents.DeleteAsync(content.Id); + if (content.Id != null) + { + await _.Contents.DeleteAsync(content.Id); + } } } @@ -57,7 +60,10 @@ namespace TestSuite.ApiTests } finally { - await _.Contents.DeleteAsync(content.Id); + if (content.Id != null) + { + await _.Contents.DeleteAsync(content.Id); + } } } @@ -73,7 +79,10 @@ namespace TestSuite.ApiTests } finally { - await _.Contents.DeleteAsync(content.Id); + if (content.Id != null) + { + await _.Contents.DeleteAsync(content.Id); + } } } @@ -90,7 +99,10 @@ namespace TestSuite.ApiTests } finally { - await _.Contents.DeleteAsync(content.Id); + if (content.Id != null) + { + await _.Contents.DeleteAsync(content.Id); + } } } @@ -108,7 +120,10 @@ namespace TestSuite.ApiTests } finally { - await _.Contents.DeleteAsync(content.Id); + if (content.Id != null) + { + await _.Contents.DeleteAsync(content.Id); + } } } @@ -127,7 +142,10 @@ namespace TestSuite.ApiTests } finally { - await _.Contents.DeleteAsync(content.Id); + if (content.Id != null) + { + await _.Contents.DeleteAsync(content.Id); + } } } @@ -147,7 +165,10 @@ namespace TestSuite.ApiTests } finally { - await _.Contents.DeleteAsync(content.Id); + if (content.Id != null) + { + await _.Contents.DeleteAsync(content.Id); + } } } @@ -167,7 +188,10 @@ namespace TestSuite.ApiTests } finally { - await _.Contents.DeleteAsync(content.Id); + if (content.Id != null) + { + await _.Contents.DeleteAsync(content.Id); + } } } diff --git a/backend/tools/TestSuite/TestSuite.ApiTests/Setup.cs b/backend/tools/TestSuite/TestSuite.ApiTests/Setup.cs new file mode 100644 index 000000000..a9d911028 --- /dev/null +++ b/backend/tools/TestSuite/TestSuite.ApiTests/Setup.cs @@ -0,0 +1,10 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Xunit; + +[assembly: CollectionBehavior(CollectionBehavior.CollectionPerAssembly)] \ No newline at end of file diff --git a/backend/tools/TestSuite/TestSuite.ApiTests/TestSuite.ApiTests.csproj b/backend/tools/TestSuite/TestSuite.ApiTests/TestSuite.ApiTests.csproj index e0a0f9832..14596b2da 100644 --- a/backend/tools/TestSuite/TestSuite.ApiTests/TestSuite.ApiTests.csproj +++ b/backend/tools/TestSuite/TestSuite.ApiTests/TestSuite.ApiTests.csproj @@ -1,4 +1,4 @@ - + Exe netcoreapp3.0 @@ -29,5 +29,8 @@ PreserveNewest + + PreserveNewest + diff --git a/backend/tools/TestSuite/TestSuite.LoadTests/ReadingContentBenchmarks.cs b/backend/tools/TestSuite/TestSuite.LoadTests/ReadingContentBenchmarks.cs index 13961e3e6..8fe47b336 100644 --- a/backend/tools/TestSuite/TestSuite.LoadTests/ReadingContentBenchmarks.cs +++ b/backend/tools/TestSuite/TestSuite.LoadTests/ReadingContentBenchmarks.cs @@ -64,7 +64,7 @@ namespace TestSuite.LoadTests { await Run.Parallel(numUsers, numIterationsPerUser, async () => { - await _.Contents.GetAsync(new ODataQuery { OrderBy = "data/value/iv asc" }); + await _.Contents.GetAsync(new ContentQuery { OrderBy = "data/value/iv asc" }); }); } @@ -74,7 +74,7 @@ namespace TestSuite.LoadTests { await Run.Parallel(numUsers, numIterationsPerUser, async () => { - await _.Contents.GetAsync(new ODataQuery { Skip = 5, OrderBy = "data/value/iv asc" }); + await _.Contents.GetAsync(new ContentQuery { Skip = 5, OrderBy = "data/value/iv asc" }); }); } @@ -84,7 +84,7 @@ namespace TestSuite.LoadTests { await Run.Parallel(numUsers, numIterationsPerUser, async () => { - await _.Contents.GetAsync(new ODataQuery { Skip = 2, Top = 5, OrderBy = "data/value/iv asc" }); + await _.Contents.GetAsync(new ContentQuery { Skip = 2, Top = 5, OrderBy = "data/value/iv asc" }); }); } @@ -94,7 +94,7 @@ namespace TestSuite.LoadTests { await Run.Parallel(numUsers, numIterationsPerUser, async () => { - await _.Contents.GetAsync(new ODataQuery { Skip = 2, Top = 5, OrderBy = "data/value/iv desc" }); + await _.Contents.GetAsync(new ContentQuery { Skip = 2, Top = 5, OrderBy = "data/value/iv desc" }); }); } @@ -104,7 +104,7 @@ namespace TestSuite.LoadTests { await Run.Parallel(numUsers, numIterationsPerUser, async () => { - await _.Contents.GetAsync(new ODataQuery { Filter = "data/value/iv gt 3 and data/value/iv lt 7", OrderBy = "data/value/iv asc" }); + await _.Contents.GetAsync(new ContentQuery { Filter = "data/value/iv gt 3 and data/value/iv lt 7", OrderBy = "data/value/iv asc" }); }); } } diff --git a/backend/tools/TestSuite/TestSuite.Shared/Fixtures/AssetFixture.cs b/backend/tools/TestSuite/TestSuite.Shared/Fixtures/AssetFixture.cs index fb6494f18..bffb4fc09 100644 --- a/backend/tools/TestSuite/TestSuite.Shared/Fixtures/AssetFixture.cs +++ b/backend/tools/TestSuite/TestSuite.Shared/Fixtures/AssetFixture.cs @@ -5,17 +5,17 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using Squidex.ClientLibrary; +using Squidex.ClientLibrary.Management; namespace TestSuite.Fixtures { public class AssetFixture : CreatedAppFixture { - public SquidexAssetClient Assets { get; } + public IAssetsClient Assets { get; } public AssetFixture() { - Assets = ClientManager.GetAssetClient(); + Assets = ClientManager.CreateAssetsClient(); } } } diff --git a/backend/tools/TestSuite/TestSuite.Shared/Fixtures/ContentFixture.cs b/backend/tools/TestSuite/TestSuite.Shared/Fixtures/ContentFixture.cs index 24ed7890c..9ae4eda75 100644 --- a/backend/tools/TestSuite/TestSuite.Shared/Fixtures/ContentFixture.cs +++ b/backend/tools/TestSuite/TestSuite.Shared/Fixtures/ContentFixture.cs @@ -23,7 +23,12 @@ namespace TestSuite.Fixtures public string FieldString { get; } = "string"; - public ContentFixture(string schemaName = "my-schema") + public ContentFixture() + : this("my-writes") + { + } + + protected ContentFixture(string schemaName) { SchemaName = schemaName; @@ -65,7 +70,7 @@ namespace TestSuite.Fixtures throw; } } - }); + }).Wait(); Contents = ClientManager.GetClient(SchemaName); } diff --git a/backend/tools/TestSuite/TestSuite.Shared/Fixtures/ContentQueryFixture.cs b/backend/tools/TestSuite/TestSuite.Shared/Fixtures/ContentQueryFixture.cs index 2cf3db8d1..4e8399769 100644 --- a/backend/tools/TestSuite/TestSuite.Shared/Fixtures/ContentQueryFixture.cs +++ b/backend/tools/TestSuite/TestSuite.Shared/Fixtures/ContentQueryFixture.cs @@ -13,7 +13,12 @@ namespace TestSuite.Fixtures { public class ContentQueryFixture : ContentFixture { - public ContentQueryFixture(string schemaName = "my-schema") + public ContentQueryFixture() + : this("my-reads") + { + } + + protected ContentQueryFixture(string schemaName = "my-schema") : base(schemaName) { Task.Run(async () => @@ -37,7 +42,7 @@ namespace TestSuite.Fixtures { await Contents.DeleteAsync(content); } - }); + }).Wait(); } } } diff --git a/backend/tools/TestSuite/TestSuite.Shared/TestSuite.Shared.csproj b/backend/tools/TestSuite/TestSuite.Shared/TestSuite.Shared.csproj index 5d4562fb4..869feea5d 100644 --- a/backend/tools/TestSuite/TestSuite.Shared/TestSuite.Shared.csproj +++ b/backend/tools/TestSuite/TestSuite.Shared/TestSuite.Shared.csproj @@ -4,7 +4,7 @@ - + diff --git a/backend/tools/TestSuite/TestSuite.sln b/backend/tools/TestSuite/TestSuite.sln index ffa2d3b8a..8bc1f5817 100644 --- a/backend/tools/TestSuite/TestSuite.sln +++ b/backend/tools/TestSuite/TestSuite.sln @@ -3,11 +3,11 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 16 VisualStudioVersion = 16.0.29613.14 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestSuite.Shared", "TestSuite.Shared\TestSuite.Shared.csproj", "{37484845-5542-4E52-AB00-C4576B84FE75}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TestSuite.Shared", "TestSuite.Shared\TestSuite.Shared.csproj", "{37484845-5542-4E52-AB00-C4576B84FE75}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestSuite.ApiTests", "TestSuite.ApiTests\TestSuite.ApiTests.csproj", "{E5F048CB-5307-4E4C-8DAB-2F1C0E5CACF3}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TestSuite.ApiTests", "TestSuite.ApiTests\TestSuite.ApiTests.csproj", "{E5F048CB-5307-4E4C-8DAB-2F1C0E5CACF3}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestSuite.LoadTests", "TestSuite.LoadTests\TestSuite.LoadTests.csproj", "{F37572D9-4880-40F4-B3CB-83F58A40CA48}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TestSuite.LoadTests", "TestSuite.LoadTests\TestSuite.LoadTests.csproj", "{F37572D9-4880-40F4-B3CB-83F58A40CA48}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution