diff --git a/src/protagonist/API.Tests/Features/DeliveryChannels/Validation/DeliveryChannelPolicyDataValidatorTests.cs b/src/protagonist/API.Tests/Features/DeliveryChannels/Validation/DeliveryChannelPolicyDataValidatorTests.cs new file mode 100644 index 000000000..cf866ba1b --- /dev/null +++ b/src/protagonist/API.Tests/Features/DeliveryChannels/Validation/DeliveryChannelPolicyDataValidatorTests.cs @@ -0,0 +1,119 @@ +using API.Features.DeliveryChannels.Validation; + +namespace API.Tests.Features.DeliveryChannelPolicies.Validation; + +public class DeliveryChannelPolicyDataValidatorTests +{ + private readonly DeliveryChannelPolicyDataValidator sut; + + public DeliveryChannelPolicyDataValidatorTests() + { + sut = new DeliveryChannelPolicyDataValidator(); + } + + [Theory] + [InlineData("[\"400,400\",\"200,200\",\"100,100\"]")] + [InlineData("[\"!400,400\",\"!200,200\",\"!100,100\"]")] + [InlineData("[\"400,\",\"200,\",\"100,\"]")] + [InlineData("[\"!400,\",\"!200,\",\"!100,\"]")] + [InlineData("[\"400,400\"]")] + public void PolicyDataValidator_ReturnsTrue_ForValidThumbSizes(string policyData) + { + // Arrange And Act + var result = sut.Validate(policyData, "thumbs"); + + // Assert + result.Should().BeTrue(); + } + + [Fact] + public void PolicyDataValidator_ReturnsFalse_ForBadThumbSizes() + { + // Arrange + var policyData = "[\"400,400\",\"foo,bar\",\"100,100\"]"; + + // Act + var result = sut.Validate(policyData, "thumbs"); + + // Assert + result.Should().BeFalse(); + } + + [Fact] + public void PolicyDataValidator_ReturnsFalse_ForInvalidThumbSizesJson() + { + // Arrange + var policyData = "[\"400,400\","; + + // Act + var result = sut.Validate(policyData, "thumbs"); + + // Assert + result.Should().BeFalse(); + } + + [Theory] + [InlineData("")] + [InlineData("[]")] + [InlineData("[\"\"]")] + public void PolicyDataValidator_ReturnsFalse_ForEmptyThumbSizes(string policyData) + { + // Arrange and Act + var result = sut.Validate(policyData, "thumbs"); + + // Assert + result.Should().BeFalse(); + } + + [Fact] + public void PolicyDataValidator_ReturnsTrue_ForValidAvPolicy() + { + // Arrange + var policyData = "[\"media-format-quality\"]"; // For now, any single string values are accepted - this will need + // to be rewritten once the API requires policies that exist + + // Act + var result = sut.Validate(policyData, "iiif-av"); + + // Assert + result.Should().BeTrue(); + } + + [Theory] + [InlineData("[\"\"]")] + [InlineData("[\"policy-1\",\"\"]")] + public void PolicyDataValidator_ReturnsFalse_ForBadAvPolicy(string policyData) + { + // Arrange and Act + var result = sut.Validate(policyData, "iiif-av"); + + // Assert + result.Should().BeFalse(); + } + + [Theory] + [InlineData("")] + [InlineData("[]")] + [InlineData("[\"\"]")] + public void PolicyDataValidator_ReturnsFalse_ForEmptyAvPolicy(string policyData) + { + // Arrange and Act + var result = sut.Validate(policyData, "iiif-av"); + + // Assert + result.Should().BeFalse(); + } + + [Fact] + public void PolicyDataValidator_ReturnsFalse_ForInvalidAvPolicyJson() + { + // Arrange + var policyData = "[\"policy-1\","; + + // Act + var result = sut.Validate(policyData, "iiif-av"); + + // Assert + result.Should().BeFalse(); + } +} \ No newline at end of file diff --git a/src/protagonist/API.Tests/Features/DeliveryChannels/Validation/HydraDeliveryChannelPolicyValidatorTests.cs b/src/protagonist/API.Tests/Features/DeliveryChannels/Validation/HydraDeliveryChannelPolicyValidatorTests.cs new file mode 100644 index 000000000..d17586068 --- /dev/null +++ b/src/protagonist/API.Tests/Features/DeliveryChannels/Validation/HydraDeliveryChannelPolicyValidatorTests.cs @@ -0,0 +1,93 @@ +using System; +using API.Features.DeliveryChannels.Validation; +using DLCS.HydraModel; +using FluentValidation.TestHelper; + +namespace API.Tests.Features.DeliveryChannelPolicies.Validation; + +public class HydraDeliveryChannelPolicyValidatorTests +{ + private readonly HydraDeliveryChannelPolicyValidator sut; + + public HydraDeliveryChannelPolicyValidatorTests() + { + sut = new HydraDeliveryChannelPolicyValidator(new DeliveryChannelPolicyDataValidator()); + } + + [Fact] + public void NewDeliveryChannelPolicy_CannotHave_AssetId() + { + var policy = new DeliveryChannelPolicy() + { + Id = "foo", + }; + var result = sut.TestValidate(policy); + result.ShouldHaveValidationErrorFor(p => p.Id); + } + + [Fact] + public void NewDeliveryChannelPolicy_CannotHave_CustomerId() + { + var policy = new DeliveryChannelPolicy() + { + CustomerId = 1, + }; + var result = sut.TestValidate(policy); + result.ShouldHaveValidationErrorFor(p => p.CustomerId); + } + + [Fact] + public void NewDeliveryChannelPolicy_CannotHave_PolicyCreated() + { + var policy = new DeliveryChannelPolicy() + { + Created = DateTime.UtcNow + }; + var result = sut.TestValidate(policy); + result.ShouldHaveValidationErrorFor(p => p.Created); + } + + [Fact] + public void NewDeliveryChannelPolicy_CannotHave_PolicyModified() + { + var policy = new DeliveryChannelPolicy() + { + Modified = DateTime.UtcNow + }; + var result = sut.TestValidate(policy); + result.ShouldHaveValidationErrorFor(p => p.Modified); + } + + [Fact] + public void NewDeliveryChannelPolicy_Requires_Name_OnPost() + { + var policy = new DeliveryChannelPolicy() + { + Name = null + }; + var result = sut.TestValidate(policy, p => p.IncludeRuleSets("default", "post")); + result.ShouldHaveValidationErrorFor(p => p.Name); + } + + [Fact] + public void NewDeliveryChannelPolicy_Requires_PolicyData_OnPost() + { + var policy = new DeliveryChannelPolicy() + { + PolicyData = null, + }; + var result = sut.TestValidate(policy, p => p.IncludeRuleSets("default", "post")); + result.ShouldHaveValidationErrorFor(p => p.PolicyData); + } + + [Fact] + public void NewDeliveryChannelPolicy_Requires_PolicyData_OnPut() + { + var policy = new DeliveryChannelPolicy() + { + PolicyData = null, + }; + var result = sut.TestValidate(policy, p => p.IncludeRuleSets("default", "put")); + result.ShouldHaveValidationErrorFor(p => p.PolicyData); + } +} \ No newline at end of file diff --git a/src/protagonist/API.Tests/Integration/CustomerTests.cs b/src/protagonist/API.Tests/Integration/CustomerTests.cs index acac71d93..089f1b71a 100644 --- a/src/protagonist/API.Tests/Integration/CustomerTests.cs +++ b/src/protagonist/API.Tests/Integration/CustomerTests.cs @@ -180,7 +180,7 @@ public async Task NewlyCreatedCustomer_RollsBackSuccessfully_WhenDeliveryChannel }"; var content = new StringContent(customerJson, Encoding.UTF8, "application/json"); - dbContext.DeliveryChannelPolicies.Add(new DeliveryChannelPolicy() + dbContext.DeliveryChannelPolicies.Add(new DLCS.Model.Policies.DeliveryChannelPolicy() { Id = 250, DisplayName = "A default audio policy", diff --git a/src/protagonist/API.Tests/Integration/DeliveryChannelTests.cs b/src/protagonist/API.Tests/Integration/DeliveryChannelTests.cs new file mode 100644 index 000000000..0ed04963b --- /dev/null +++ b/src/protagonist/API.Tests/Integration/DeliveryChannelTests.cs @@ -0,0 +1,562 @@ +using System; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using API.Client; +using API.Tests.Integration.Infrastructure; +using DLCS.HydraModel; +using DLCS.Repository; +using Hydra.Collections; +using Test.Helpers.Integration; +using Test.Helpers.Integration.Infrastructure; + +namespace API.Tests.Integration; + +[Trait("Category", "Integration")] +[Collection(CollectionDefinitions.DatabaseCollection.CollectionName)] +public class DeliveryChannelTests : IClassFixture> +{ + private readonly HttpClient httpClient; + private readonly DlcsContext dbContext; + + public DeliveryChannelTests(DlcsDatabaseFixture dbFixture, ProtagonistAppFactory factory) + { + dbContext = dbFixture.DbContext; + httpClient = factory.ConfigureBasicAuthedIntegrationTestHttpClient(dbFixture, "API-Test"); + dbFixture.CleanUp(); + } + + [Fact] + public async Task Get_DeliveryChannelPolicy_200() + { + // Arrange + var path = $"customers/99/deliveryChannelPolicies/thumbs/example-thumbs-policy"; + + // Act + var response = await httpClient.AsCustomer(99).GetAsync(path); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var model = await response.ReadAsHydraResponseAsync(); + model.Name.Should().Be("example-thumbs-policy"); + model.DisplayName.Should().Be("Example Thumbnail Policy"); + model.Channel.Should().Be("thumbs"); + model.PolicyData.Should().Be("[\"!1024,1024\",\"!400,400\",\"!200,200\",\"!100,100\"]"); + } + + [Fact] + public async Task Get_DeliveryChannelPolicy_404_IfNotFound() + { + // Arrange + var path = $"customers/99/deliveryChannelPolicies/thumbs/foo"; + + // Act + var response = await httpClient.AsCustomer(99).GetAsync(path); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task Post_DeliveryChannelPolicy_201() + { + // Arrange + const int customerId = 88; + const string newDeliveryChannelPolicyJson = @"{ + ""name"": ""my-iiif-av-policy-1"", + ""displayName"": ""My IIIF AV Policy"", + ""policyData"": ""[\""audio-mp3-128\""]"" + }"; + + var path = $"customers/{customerId}/deliveryChannelPolicies/iiif-av"; + + // Act + var content = new StringContent(newDeliveryChannelPolicyJson, Encoding.UTF8, "application/json"); + var response = await httpClient.AsCustomer(customerId).PostAsync(path, content); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Created); + + var foundPolicy = dbContext.DeliveryChannelPolicies.Single(s => + s.Customer == customerId && + s.Name == "my-iiif-av-policy-1"); + foundPolicy.DisplayName.Should().Be("My IIIF AV Policy"); + foundPolicy.PolicyData.Should().Be("[\"audio-mp3-128\"]"); + } + + [Fact] + public async Task Post_DeliveryChannelPolicy_400_IfChannelInvalid() + { + // Arrange + const int customerId = 88; + const string newDeliveryChannelPolicyJson = @"{ + ""name"": ""post-invalid-policy"", + ""displayName"": ""Invalid Policy"", + ""policyData"": ""[\""audio-mp3-128\""]"" + }"; + + var path = $"customers/{customerId}/deliveryChannelPolicies/foo"; + + // Act + var content = new StringContent(newDeliveryChannelPolicyJson, Encoding.UTF8, "application/json"); + var response = await httpClient.AsCustomer(customerId).PostAsync(path, content); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task Post_DeliveryChannelPolicy_409_IfNameTaken() + { + // Arrange + const int customerId = 88; + const string newDeliveryChannelPolicyJson = @"{ + ""name"": ""post-existing-policy"", + ""policyData"": ""[\""100,100\""]"" + }"; + + var path = $"customers/{customerId}/deliveryChannelPolicies/thumbs"; + var policy = new DLCS.Model.Policies.DeliveryChannelPolicy() + { + Customer = customerId, + Name = "post-existing-policy", + Channel = "thumbs", + PolicyData = "[\"100,100\"]" + }; + + await dbContext.DeliveryChannelPolicies.AddAsync(policy); + await dbContext.SaveChangesAsync(); + + // Act + var content = new StringContent(newDeliveryChannelPolicyJson, Encoding.UTF8, "application/json"); + var response = await httpClient.AsCustomer(customerId).PostAsync(path, content); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Conflict); + } + + [Fact] + public async Task Post_DeliveryChannelPolicy_400_IfNameInvalid() + { + // Arrange + const int customerId = 88; + const string newDeliveryChannelPolicyJson = @"{ + ""name"": ""foo bar"", + ""displayName"": ""Invalid Policy"", + ""policyData"": ""[\""audio-mp3-128\""]"" + }"; + + var path = $"customers/{customerId}/deliveryChannelPolicies/iiif-av"; + + // Act + var content = new StringContent(newDeliveryChannelPolicyJson, Encoding.UTF8, "application/json"); + var response = await httpClient.AsCustomer(customerId).PostAsync(path, content); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Theory] + [InlineData("")] // No PolicyData specified + [InlineData("[]")] // Empty array + [InlineData("[\"\"]")] // Array containing an empty value + [InlineData(@"[\""foo\"",\""bar\""]")] // Invalid data + [InlineData(@"[\""100,100\"",\""200,200\""")] // Invalid JSON + public async Task Post_DeliveryChannelPolicy_400_IfThumbsPolicyDataInvalid(string policyData) + { + // Arrange + const int customerId = 88; + + var newDeliveryChannelPolicyJson = $@"{{ + ""name"": ""post-invalid-thumbs"", + ""displayName"": ""Invalid Policy (Thumbs Policy Data)"", + ""policyData"": ""{policyData}"" + }}"; + var path = $"customers/{customerId}/deliveryChannelPolicies/thumbs"; + + // Act + var content = new StringContent(newDeliveryChannelPolicyJson, Encoding.UTF8, "application/json"); + var response = await httpClient.AsCustomer(customerId).PostAsync(path, content); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Theory] + [InlineData("")] // No PolicyData specified + [InlineData("[]")] // Empty array + [InlineData("[\"\"]")] // Array containing an empty value + [InlineData(@"[\""transcode-policy-1\"",\""\""]")] // Invalid data + [InlineData(@"[\""transcode-policy\""")] // Invalid JSON + public async Task Post_DeliveryChannelPolicy_400_IfAvPolicyDataInvalid(string policyData) + { + // Arrange + const int customerId = 88; + + var newDeliveryChannelPolicyJson = $@"{{ + ""name"": ""post-invalid-iiif-av"", + ""displayName"": ""Invalid Policy (IIIF-AV Policy Data)"", + ""policyData"": ""{policyData}"" + }}"; + var path = $"customers/{customerId}/deliveryChannelPolicies/iiif-av"; + + // Act + var content = new StringContent(newDeliveryChannelPolicyJson, Encoding.UTF8, "application/json"); + var response = await httpClient.AsCustomer(customerId).PostAsync(path, content); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task Put_DeliveryChannelPolicy_200() + { + // Arrange + const int customerId = 88; + const string putDeliveryChannelPolicyJson = @"{ + ""displayName"": ""My IIIF AV Policy 2 (modified)"", + ""policyData"": ""[\""audio-mp3-256\""]"" + }"; + + var policy = new DLCS.Model.Policies.DeliveryChannelPolicy() + { + Customer = customerId, + Name = "put-av-policy-2", + DisplayName = "My IIIF-AV Policy 2", + Channel = "iiif-av", + PolicyData = "[\"audio-mp3-128\"]" + }; + + var path = $"customers/{customerId}/deliveryChannelPolicies/{policy.Channel}/{policy.Name}"; + + await dbContext.DeliveryChannelPolicies.AddAsync(policy); + await dbContext.SaveChangesAsync(); + + // Act + var content = new StringContent(putDeliveryChannelPolicyJson, Encoding.UTF8, "application/json"); + var response = await httpClient.AsCustomer(customerId).PutAsync(path, content); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var foundPolicy = dbContext.DeliveryChannelPolicies.Single(s => + s.Customer == customerId && + s.Name == policy.Name); + foundPolicy.DisplayName.Should().Be("My IIIF AV Policy 2 (modified)"); + foundPolicy.PolicyData.Should().Be("[\"audio-mp3-256\"]"); + } + + [Fact] + public async Task Put_DeliveryChannelPolicy_400_IfChannelInvalid() + { + // Arrange + const int customerId = 88; + const string newDeliveryChannelPolicyJson = @"{ + ""displayName"": ""Invalid Policy"", + ""policyData"": ""[\""audio-mp3-128\""]"" + }"; + + var path = $"customers/{customerId}/deliveryChannelPolicies/foo/put-invalid-channel-policy"; + + // Act + var content = new StringContent(newDeliveryChannelPolicyJson, Encoding.UTF8, "application/json"); + var response = await httpClient.AsCustomer(customerId).PutAsync(path, content); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task Put_DeliveryChannelPolicy_400_IfNameInvalid() + { + // Arrange + const int customerId = 88; + const string newDeliveryChannelPolicyJson = @"{ + ""displayName"": ""Invalid Policy"", + ""policyData"": ""[\""audio-mp3-128\""]""r + }"; + + var path = $"customers/{customerId}/deliveryChannelPolicies/iiif-av/FooBar"; + + // Act + var content = new StringContent(newDeliveryChannelPolicyJson, Encoding.UTF8, "application/json"); + var response = await httpClient.AsCustomer(customerId).PutAsync(path, content); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Theory] + [InlineData("")] // No PolicyData specified + [InlineData("[]")] // Empty array + [InlineData("[\"\"]")] // Array containing an empty value + [InlineData(@"[\""foo\"",\""bar\""]")] // Invalid data + [InlineData(@"[\""100,100\"",\""200,200\""")] // Invalid JSON + public async Task Put_DeliveryChannelPolicy_400_IfThumbsPolicyDataInvalid(string policyData) + { + // Arrange + const int customerId = 88; + + var newDeliveryChannelPolicyJson = $@"{{ + ""displayName"": ""Invalid Policy (Thumbs Policy Data)"", + ""policyData"": ""{policyData}"" + }}"; + var path = $"customers/{customerId}/deliveryChannelPolicies/thumbs/put-invalid-thumbs"; + var policy = new DLCS.Model.Policies.DeliveryChannelPolicy() + { + Customer = customerId, + Name = "put-invalid-thumbs", + DisplayName = "Valid Policy (Thumbs Policy Data)", + Channel = "thumbs", + PolicyData = "[\"100,100\"]" + }; + + await dbContext.DeliveryChannelPolicies.AddAsync(policy); + await dbContext.SaveChangesAsync(); + + // Act + var content = new StringContent(newDeliveryChannelPolicyJson, Encoding.UTF8, "application/json"); + var response = await httpClient.AsCustomer(customerId).PutAsync(path, content); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Theory] + [InlineData("")] // No PolicyData specified + [InlineData("[]")] // Empty array + [InlineData("[\"\"]")] // Array containing an empty value + [InlineData(@"[\""transcode-policy-1\"",\""\""]")] // Invalid data + [InlineData(@"[\""transcode-policy\""")] // Invalid JSON + public async Task Put_DeliveryChannelPolicy_400_IfAvPolicyDataInvalid(string policyData) + { + // Arrange + const int customerId = 88; + + var newDeliveryChannelPolicyJson = $@"{{ + ""displayName"": ""Invalid Policy (IIIF-AV Policy Data)"", + ""policyData"": ""{policyData}"" + }}"; + var policy = new DLCS.Model.Policies.DeliveryChannelPolicy() + { + Customer = customerId, + Name = "put-invalid-iiif-av", + DisplayName = "Valid Policy (IIIF-AV Policy Data)", + Channel = "thumbs", + PolicyData = "[\"100,100\"]" + }; + var path = $"customers/{customerId}/deliveryChannelPolicies/iiif-av/put-invalid-iiif-av"; + + await dbContext.DeliveryChannelPolicies.AddAsync(policy); + await dbContext.SaveChangesAsync(); + + // Act + var content = new StringContent(newDeliveryChannelPolicyJson, Encoding.UTF8, "application/json"); + var response = await httpClient.AsCustomer(customerId).PutAsync(path, content); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task Patch_DeliveryChannelPolicy_201() + { + // Arrange + const int customerId = 88; + const string patchDeliveryChannelPolicyJson = @"{ + ""displayName"": ""My IIIF AV Policy 3 (modified)"", + ""policyData"": ""[\""audio-mp3-256\""]"" + }"; + + var policy = new DLCS.Model.Policies.DeliveryChannelPolicy() + { + Customer = customerId, + Name = "put-av-policy", + DisplayName = "My IIIF-AV Policy 3", + Channel = "iiif-av", + PolicyData = "[\"audio-mp3-128\"]" + }; + + var path = $"customers/{customerId}/deliveryChannelPolicies/{policy.Channel}/{policy.Name}"; + + await dbContext.DeliveryChannelPolicies.AddAsync(policy); + await dbContext.SaveChangesAsync(); + + // Act + var content = new StringContent(patchDeliveryChannelPolicyJson, Encoding.UTF8, "application/json"); + var response = await httpClient.AsCustomer(customerId).PatchAsync(path, content); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var foundPolicy = dbContext.DeliveryChannelPolicies.Single(s => + s.Customer == customerId && + s.Name == policy.Name); + foundPolicy.DisplayName.Should().Be("My IIIF AV Policy 3 (modified)"); + foundPolicy.PolicyData.Should().Be("[\"audio-mp3-256\"]"); + } + + [Theory] + [InlineData("[]")] // Empty array + [InlineData("[\"\"]")] // Array containing an empty value + [InlineData(@"[\""foo\"",\""bar\""]")] // Invalid data + [InlineData(@"[\""100,100\"",\""200,200\""")] // Invalid JSON + public async Task Patch_DeliveryChannelPolicy_400_IfThumbsPolicyDataInvalid(string policyData) + { + // Arrange + const int customerId = 88; + + var newDeliveryChannelPolicyJson = $@"{{ + ""policyData"": ""{policyData}"" + }}"; + var path = $"customers/{customerId}/deliveryChannelPolicies/thumbs/patch-invalid-thumbs"; + var policy = new DLCS.Model.Policies.DeliveryChannelPolicy() + { + Customer = customerId, + Name = "patch-invalid-thumbs", + DisplayName = "Valid Policy (Thumbs Policy Data)", + Channel = "thumbs", + PolicyData = "[\"100,100\"]" + }; + + await dbContext.DeliveryChannelPolicies.AddAsync(policy); + await dbContext.SaveChangesAsync(); + + // Act + var content = new StringContent(newDeliveryChannelPolicyJson, Encoding.UTF8, "application/json"); + var response = await httpClient.AsCustomer(customerId).PatchAsync(path, content); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Theory] + [InlineData("[]")] // Empty array + [InlineData("[\"\"]")] // Array containing an empty value + [InlineData(@"[\""transcode-policy-1\"",\""\""]")] // Invalid data + [InlineData(@"[\""transcode-policy\""")] // Invalid JSON + public async Task Patch_DeliveryChannelPolicy_400_IfAvPolicyDataInvalid(string policyData) + { + // Arrange + const int customerId = 88; + + var newDeliveryChannelPolicyJson = $@"{{ + ""policyData"": ""{policyData}"" + }}"; + var policy = new DLCS.Model.Policies.DeliveryChannelPolicy() + { + Customer = customerId, + Name = "patch-invalid-iiif-av", + DisplayName = "Valid Policy (IIIF-AV Policy Data)", + Channel = "iiif-av", + PolicyData = "[\"100,100\"]" + }; + var path = $"customers/{customerId}/deliveryChannelPolicies/iiif-av/patch-invalid-iiif-av"; + + await dbContext.DeliveryChannelPolicies.AddAsync(policy); + await dbContext.SaveChangesAsync(); + + // Act + var content = new StringContent(newDeliveryChannelPolicyJson, Encoding.UTF8, "application/json"); + var response = await httpClient.AsCustomer(customerId).PatchAsync(path, content); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task Delete_DeliveryChannelPolicy_204() + { + // Arrange + const int customerId = 88; + + var policy = new DLCS.Model.Policies.DeliveryChannelPolicy() + { + Customer = customerId, + Name = "delete-thumbs-policy", + DisplayName = "My Thumbs Policy", + Channel = "thumbs", + PolicyData = "[\"!100,100\"]", + }; + var path = $"customers/{customerId}/deliveryChannelPolicies/{policy.Channel}/{policy.Name}"; + + await dbContext.DeliveryChannelPolicies.AddAsync(policy); + await dbContext.SaveChangesAsync(); + + // Act + var response = await httpClient.AsCustomer(customerId).DeleteAsync(path); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + + var policyExists = dbContext.DeliveryChannelPolicies.Any(p => p.Name == policy.Name); + policyExists.Should().BeFalse(); + } + + [Fact] + public async Task Delete_DeliveryChannelPolicy_404_IfNotFound() + { + // Arrange + const int customerId = 88; + var path = $"customers/{customerId}/deliveryChannelPolicies/thumbs/foo"; + + // Act + var response = await httpClient.AsCustomer(customerId).DeleteAsync(path); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task Get_DeliveryChannelPolicyCollections_200() + { + // Arrange + const int customerId = 88; + + // Act + var response = await httpClient.AsCustomer(customerId).GetAsync($"customers/{customerId}/deliveryChannelPolicies"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var collections = await response.ReadAsHydraResponseAsync>>(); + collections.TotalItems.Should().Be(4); // Should contain iiif-img, thumbs, iiif-av and file + } + + [Fact] + public async Task Get_DeliveryChannelPolicyCollection_200() + { + // Arrange + const int customerId = 99; + + // Act + var response = await httpClient.AsCustomer(customerId).GetAsync($"customers/{customerId}/deliveryChannelPolicies/thumbs"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var collection = await response.ReadAsHydraResponseAsync>(); + collection.TotalItems.Should().Be(1); + + var createdPolicy = collection.Members.FirstOrDefault(); + createdPolicy.Name.Should().Be("example-thumbs-policy"); + createdPolicy.Channel.Should().Be("thumbs"); + createdPolicy.PolicyData.Should().Be("[\"!1024,1024\",\"!400,400\",\"!200,200\",\"!100,100\"]"); + } + + [Fact] + public async Task Get_DeliveryChannelPolicyCollection_400_IfChannelInvalid() + { + // Arrange + const int customerId = 88; + + // Act + var response = await httpClient.AsCustomer(customerId).GetAsync($"customers/{customerId}/deliveryChannelPolicies/foo"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } +} \ No newline at end of file diff --git a/src/protagonist/API/Features/DeliveryChannels/Converters/DeliveryChannelPolicyConverter.cs b/src/protagonist/API/Features/DeliveryChannels/Converters/DeliveryChannelPolicyConverter.cs new file mode 100644 index 000000000..66f87f73a --- /dev/null +++ b/src/protagonist/API/Features/DeliveryChannels/Converters/DeliveryChannelPolicyConverter.cs @@ -0,0 +1,33 @@ +namespace API.Features.DeliveryChannels.Converters; + +public static class DeliveryChannelPolicyConverter +{ + public static DLCS.HydraModel.DeliveryChannelPolicy ToHydra( + this DLCS.Model.Policies.DeliveryChannelPolicy deliveryChannelPolicy, + string baseUrl) + { + return new DLCS.HydraModel.DeliveryChannelPolicy(baseUrl, deliveryChannelPolicy.Customer, + deliveryChannelPolicy.Channel, deliveryChannelPolicy.Name) + { + DisplayName = deliveryChannelPolicy.DisplayName, + PolicyData = deliveryChannelPolicy.PolicyData, + Created = deliveryChannelPolicy.Created, + Modified = deliveryChannelPolicy.Modified, + }; + } + + public static DLCS.Model.Policies.DeliveryChannelPolicy ToDlcsModel( + this DLCS.HydraModel.DeliveryChannelPolicy hydraDeliveryChannelPolicy) + { + return new DLCS.Model.Policies.DeliveryChannelPolicy() + { + Customer = hydraDeliveryChannelPolicy.CustomerId, + Name = hydraDeliveryChannelPolicy.Name, + DisplayName = hydraDeliveryChannelPolicy.DisplayName, + Channel = hydraDeliveryChannelPolicy.Channel, + PolicyData = hydraDeliveryChannelPolicy.PolicyData, + Created = hydraDeliveryChannelPolicy.Created ?? DateTime.MinValue, + Modified = hydraDeliveryChannelPolicy.Modified ?? DateTime.MinValue + }; + } +} \ No newline at end of file diff --git a/src/protagonist/API/Features/DeliveryChannels/DeliveryChannelPoliciesController.cs b/src/protagonist/API/Features/DeliveryChannels/DeliveryChannelPoliciesController.cs new file mode 100644 index 000000000..a5485d3f9 --- /dev/null +++ b/src/protagonist/API/Features/DeliveryChannels/DeliveryChannelPoliciesController.cs @@ -0,0 +1,267 @@ +using System.Collections.Generic; +using System.Text.RegularExpressions; +using API.Features.DeliveryChannels.Converters; +using API.Features.DeliveryChannels.Requests; +using API.Features.DeliveryChannels.Validation; +using API.Infrastructure; +using API.Settings; +using DLCS.HydraModel; +using DLCS.Model.Assets; +using DLCS.Web.Requests; +using FluentValidation; +using Hydra.Collections; +using MediatR; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; + +namespace API.Features.DeliveryChannels; + +/// +/// DLCS REST API Operations for delivery channel policies +/// +[Route("/customers/{customerId}/deliveryChannelPolicies")] +[ApiController] +public class DeliveryChannelPoliciesController : HydraController +{ + public DeliveryChannelPoliciesController( + IMediator mediator, + IOptions options) : base(options.Value, mediator) + { + + } + + /// + /// Get a collection of nested DeliveryChannelPolicy collections, sorted by channel + /// + /// HydraCollection of DeliveryChannelPolicy HydraCollection + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task GetDeliveryChannelPolicyCollections( + [FromRoute] int customerId, + CancellationToken cancellationToken) + { + var request = new GetDeliveryChannelPolicyCollections(customerId, Request.GetDisplayUrl(Request.Path), Request.GetJsonLdId()); + var result = await Mediator.Send(request, cancellationToken); + var policyCollections = new HydraCollection>() + { + WithContext = true, + Members = result.Select(c => + new HydraNestedCollection(request.BaseUrl, c.Key) + { + Title = c.Value, + }).ToArray(), + TotalItems = result.Count, + Id = request.JsonLdId, + }; + + return Ok(policyCollections); + } + + /// + /// Get a collection of the customer's delivery channel policies for a specific channel + /// + /// HydraCollection of DeliveryChannelPolicy + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK)] + [Route("{deliveryChannelName}")] + public async Task GetDeliveryChannelPolicyCollection( + [FromRoute] int customerId, + [FromRoute] string deliveryChannelName, + CancellationToken cancellationToken) + { + if (!AssetDeliveryChannels.IsValidChannel(deliveryChannelName)) + { + return this.HydraProblem($"'{deliveryChannelName}' is not a valid delivery channel", null, + 400, "Invalid delivery channel"); + } + + var request = new GetPoliciesForDeliveryChannel(customerId, deliveryChannelName); + + return await HandleListFetch( + request, + p => p.ToHydra(GetUrlRoots().BaseUrl), + errorTitle: "Failed to get delivery channel policies", + cancellationToken: cancellationToken + ); + } + + /// + /// Create a new policy for a specified delivery channel + /// + /// + /// Sample request: + /// + /// POST: /customers/1/deliveryChannelPolicies/iiif-av + /// { + /// "name": "my-video-policy" + /// "displayName": "My Video Policy", + /// "policyData": "["video-mp4-720p"]" + /// } + /// + [HttpPost] + [Route("{deliveryChannelName}")] + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task PostDeliveryChannelPolicy( + [FromRoute] int customerId, + [FromRoute] string deliveryChannelName, + [FromBody] DeliveryChannelPolicy hydraDeliveryChannelPolicy, + [FromServices] HydraDeliveryChannelPolicyValidator validator, + CancellationToken cancellationToken) + { + hydraDeliveryChannelPolicy.Channel = deliveryChannelName; + + var validationResult = await validator.ValidateAsync(hydraDeliveryChannelPolicy, + policy => policy.IncludeRuleSets("default", "post"), cancellationToken); + if (!validationResult.IsValid) + { + return this.ValidationFailed(validationResult); + } + + hydraDeliveryChannelPolicy.CustomerId = customerId; + var request = new CreateDeliveryChannelPolicy(customerId, hydraDeliveryChannelPolicy.ToDlcsModel()); + + return await HandleUpsert(request, + s => s.ToHydra(GetUrlRoots().BaseUrl), + errorTitle: "Failed to create delivery channel policy", + cancellationToken: cancellationToken); + } + + /// + /// Get a delivery channel policy belonging to a customer + /// + /// DeliveryChannelPolicy + [HttpGet] + [Route("{deliveryChannelName}/{deliveryChannelPolicyName}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetDeliveryChannelPolicy( + [FromRoute] int customerId, + [FromRoute] string deliveryChannelName, + [FromRoute] string deliveryChannelPolicyName, + CancellationToken cancellationToken) + { + var getDeliveryChannelPolicy = + new GetDeliveryChannelPolicy(customerId, deliveryChannelName, deliveryChannelPolicyName); + + return await HandleFetch( + getDeliveryChannelPolicy, + policy => policy.ToHydra(GetUrlRoots().BaseUrl), + errorTitle: "Get delivery channel policy failed", + cancellationToken: cancellationToken); + } + + /// + /// Create or update a specified customer delivery channel policy - "name" must be specified in URI + /// + /// + /// Sample request: + /// + /// PUT: /customers/1/deliveryChannelPolicies/iiif-av/my-video-policy + /// { + /// "displayName": "My Updated Video Policy", + /// "policyData": "["video-mp4-720p"]" + /// } + /// + /// DeliveryChannelPolicy + [HttpPut] + [Route("{deliveryChannelName}/{deliveryChannelPolicyName}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task PutDeliveryChannelPolicy( + [FromRoute] int customerId, + [FromRoute] string deliveryChannelName, + [FromRoute] string deliveryChannelPolicyName, + [FromBody] DeliveryChannelPolicy hydraDeliveryChannelPolicy, + [FromServices] HydraDeliveryChannelPolicyValidator validator, + CancellationToken cancellationToken) + { + hydraDeliveryChannelPolicy.Name = deliveryChannelPolicyName; + hydraDeliveryChannelPolicy.Channel = deliveryChannelName; + + var validationResult = await validator.ValidateAsync(hydraDeliveryChannelPolicy, + policy => policy.IncludeRuleSets("default", "put"), cancellationToken); + if (!validationResult.IsValid) + { + return this.ValidationFailed(validationResult); + } + + hydraDeliveryChannelPolicy.CustomerId = customerId; + + var updateDeliveryChannelPolicy = + new UpdateDeliveryChannelPolicy(customerId, hydraDeliveryChannelPolicy.ToDlcsModel()); + + return await HandleUpsert(updateDeliveryChannelPolicy, + s => s.ToHydra(GetUrlRoots().BaseUrl), + errorTitle: "Failed to update delivery channel policy", + cancellationToken: cancellationToken); + } + + /// + /// Update the supplied fields for a specified customer delivery channel policy + /// + /// + /// Sample request: + /// + /// PATCH: /customers/1/deliveryChannelPolicies/iiif-av/my-video-policy + /// { + /// "displayName": "My Updated Video Policy" + /// } + /// + /// DeliveryChannelPolicy + [HttpPatch] + [Route("{deliveryChannelName}/{deliveryChannelPolicyName}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task PatchDeliveryChannelPolicy( + [FromRoute] int customerId, + [FromRoute] string deliveryChannelName, + [FromRoute] string deliveryChannelPolicyName, + [FromBody] DeliveryChannelPolicy hydraDeliveryChannelPolicy, + [FromServices] HydraDeliveryChannelPolicyValidator validator, + CancellationToken cancellationToken) + { + hydraDeliveryChannelPolicy.Channel = deliveryChannelName; + hydraDeliveryChannelPolicy.Name = deliveryChannelPolicyName; + + var validationResult = await validator.ValidateAsync(hydraDeliveryChannelPolicy, + policy => policy.IncludeRuleSets("default", "patch"), cancellationToken); + if (!validationResult.IsValid) + { + return this.ValidationFailed(validationResult); + } + + hydraDeliveryChannelPolicy.CustomerId = customerId; + + var patchDeliveryChannelPolicy = + new PatchDeliveryChannelPolicy(customerId, deliveryChannelName, deliveryChannelPolicyName, + hydraDeliveryChannelPolicy.DisplayName, hydraDeliveryChannelPolicy.PolicyData); + + return await HandleUpsert(patchDeliveryChannelPolicy, + s => s.ToHydra(GetUrlRoots().BaseUrl), + errorTitle: "Failed to update delivery channel policy", + cancellationToken: cancellationToken); + } + + + /// + /// Delete a specified delivery channel policy + /// + [HttpDelete] + [Route("{deliveryChannelName}/{deliveryChannelPolicyName}")] + [ProducesResponseType(StatusCodes.Status202Accepted)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task DeleteDeliveryChannelPolicy( + [FromRoute] int customerId, + [FromRoute] string deliveryChannelName, + [FromRoute] string deliveryChannelPolicyName) + { + var deleteDeliveryChannelPolicy = + new DeleteDeliveryChannelPolicy(customerId, deliveryChannelName, deliveryChannelPolicyName); + + return await HandleDelete(deleteDeliveryChannelPolicy); + } +} \ No newline at end of file diff --git a/src/protagonist/API/Features/DeliveryChannels/Requests/CreateDeliveryChannelPolicy.cs b/src/protagonist/API/Features/DeliveryChannels/Requests/CreateDeliveryChannelPolicy.cs new file mode 100644 index 000000000..2004a9de1 --- /dev/null +++ b/src/protagonist/API/Features/DeliveryChannels/Requests/CreateDeliveryChannelPolicy.cs @@ -0,0 +1,64 @@ +using API.Features.DeliveryChannels.Validation; +using API.Infrastructure.Requests; +using DLCS.Core; +using DLCS.Model.Policies; +using DLCS.Repository; +using DLCS.Repository.Exceptions; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace API.Features.DeliveryChannels.Requests; + +public class CreateDeliveryChannelPolicy : IRequest> +{ + public int CustomerId { get; } + + public DeliveryChannelPolicy DeliveryChannelPolicy { get; } + + public CreateDeliveryChannelPolicy(int customerId, DeliveryChannelPolicy deliveryChannelPolicy) + { + CustomerId = customerId; + DeliveryChannelPolicy = deliveryChannelPolicy; + } +} + +public class CreateDeliveryChannelPolicyHandler : IRequestHandler> +{ + private readonly DlcsContext dbContext; + + public CreateDeliveryChannelPolicyHandler(DlcsContext dbContext, DeliveryChannelPolicyDataValidator policyDataValidator) + { + this.dbContext = dbContext; + } + + public async Task> Handle(CreateDeliveryChannelPolicy request, CancellationToken cancellationToken) + { + var newDeliveryChannelPolicy = new DeliveryChannelPolicy() + { + Customer = request.CustomerId, + Name = request.DeliveryChannelPolicy.Name, + DisplayName = request.DeliveryChannelPolicy.DisplayName, + Channel = request.DeliveryChannelPolicy.Channel, + System = false, + Modified = DateTime.UtcNow, + Created = DateTime.UtcNow, + PolicyData = request.DeliveryChannelPolicy.PolicyData, + }; + + await dbContext.DeliveryChannelPolicies.AddAsync(newDeliveryChannelPolicy, cancellationToken); + try + { + await dbContext.SaveChangesAsync(cancellationToken); + } + catch (DbUpdateException ex) when (ex.GetDatabaseError() is UniqueConstraintError) + { + return ModifyEntityResult.Failure( + $"A {request.DeliveryChannelPolicy.Channel}' policy called '{request.DeliveryChannelPolicy.Name}' already exists", + WriteResult.Conflict); + } + + return ModifyEntityResult.Success(newDeliveryChannelPolicy, WriteResult.Created); + } +} + + diff --git a/src/protagonist/API/Features/DeliveryChannels/Requests/DeleteDeliveryChannelPolicy.cs b/src/protagonist/API/Features/DeliveryChannels/Requests/DeleteDeliveryChannelPolicy.cs new file mode 100644 index 000000000..0e1e44d52 --- /dev/null +++ b/src/protagonist/API/Features/DeliveryChannels/Requests/DeleteDeliveryChannelPolicy.cs @@ -0,0 +1,66 @@ +using API.Infrastructure.Requests; +using DLCS.Core; +using DLCS.Repository; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace API.Features.DeliveryChannels.Requests; + +public class DeleteDeliveryChannelPolicy: IRequest> +{ + public int CustomerId { get; } + public string DeliveryChannelName { get; } + public string DeliveryChannelPolicyName { get; } + + public DeleteDeliveryChannelPolicy(int customerId, string deliveryChannelName, string deliveryChannelPolicyName) + { + CustomerId = customerId; + DeliveryChannelName = deliveryChannelName; + DeliveryChannelPolicyName = deliveryChannelPolicyName; + } +} + +public class DeleteDeliveryChannelPolicyHandler : IRequestHandler> +{ + private readonly DlcsContext dbContext; + + public DeleteDeliveryChannelPolicyHandler(DlcsContext dbContext) + { + this.dbContext = dbContext; + } + + public async Task> Handle(DeleteDeliveryChannelPolicy request, CancellationToken cancellationToken) + { + var policy = await dbContext.DeliveryChannelPolicies.SingleOrDefaultAsync(p => + p.Customer == request.CustomerId && + p.Name == request.DeliveryChannelPolicyName && + p.Channel == request.DeliveryChannelName, + cancellationToken); + + if (policy == null) + { + return new ResultMessage( + $"Deletion failed - Delivery channel policy ${request.DeliveryChannelPolicyName} was not found", DeleteResult.NotFound); + } + + var policyInUseByDefaultDeliveryChannel = await dbContext.DefaultDeliveryChannels.AnyAsync(c => + c.DeliveryChannelPolicyId == policy.Id, + cancellationToken); + + var policyInUseByAsset = await dbContext.ImageDeliveryChannels.AnyAsync(c => + c.DeliveryChannelPolicyId == policy.Id, + cancellationToken); + + if (policyInUseByDefaultDeliveryChannel || policyInUseByAsset) + { + return new ResultMessage( + $"Deletion failed - Delivery channel policy {request.DeliveryChannelPolicyName} is still in use", DeleteResult.Conflict); + } + + dbContext.DeliveryChannelPolicies.Remove(policy); + await dbContext.SaveChangesAsync(cancellationToken); + + return new ResultMessage( + $"Delivery channel policy {request.DeliveryChannelPolicyName} successfully deleted", DeleteResult.Deleted); + } +} \ No newline at end of file diff --git a/src/protagonist/API/Features/DeliveryChannels/Requests/GetDeliveryChannelPolicy.cs b/src/protagonist/API/Features/DeliveryChannels/Requests/GetDeliveryChannelPolicy.cs new file mode 100644 index 000000000..98532bc9c --- /dev/null +++ b/src/protagonist/API/Features/DeliveryChannels/Requests/GetDeliveryChannelPolicy.cs @@ -0,0 +1,46 @@ +using API.Infrastructure.Requests; +using DLCS.Model.Policies; +using DLCS.Repository; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace API.Features.DeliveryChannels.Requests; + +public class GetDeliveryChannelPolicy: IRequest> +{ + public int CustomerId { get; } + public string DeliveryChannelName { get; } + public string DeliveryChannelPolicyName { get; } + + public GetDeliveryChannelPolicy(int customerId, string deliveryChannelName, string deliveryChannelPolicyName) + { + CustomerId = customerId; + DeliveryChannelName = deliveryChannelName; + DeliveryChannelPolicyName = deliveryChannelPolicyName; + } +} + +public class GetDeliveryChannelPolicyHandler : IRequestHandler> +{ + private readonly DlcsContext dbContext; + + public GetDeliveryChannelPolicyHandler(DlcsContext dbContext) + { + this.dbContext = dbContext; + } + + public async Task> Handle(GetDeliveryChannelPolicy request, CancellationToken cancellationToken) + { + var deliveryChannelPolicy = await dbContext.DeliveryChannelPolicies + .AsNoTracking() + .SingleOrDefaultAsync(p => + p.Customer == request.CustomerId && + p.Channel == request.DeliveryChannelName && + p.Name == request.DeliveryChannelPolicyName, + cancellationToken); + + return deliveryChannelPolicy == null + ? FetchEntityResult.NotFound() + : FetchEntityResult.Success(deliveryChannelPolicy); + } +} \ No newline at end of file diff --git a/src/protagonist/API/Features/DeliveryChannels/Requests/GetDeliveryChannelPolicyCollections.cs b/src/protagonist/API/Features/DeliveryChannels/Requests/GetDeliveryChannelPolicyCollections.cs new file mode 100644 index 000000000..04067a265 --- /dev/null +++ b/src/protagonist/API/Features/DeliveryChannels/Requests/GetDeliveryChannelPolicyCollections.cs @@ -0,0 +1,43 @@ +using System.Collections.Generic; +using DLCS.Model.Assets; +using DLCS.Repository; +using MediatR; + +namespace API.Features.DeliveryChannels.Requests; + +public class GetDeliveryChannelPolicyCollections: IRequest> +{ + public int CustomerId { get; } + public string BaseUrl { get; } + public string JsonLdId { get; } + + public GetDeliveryChannelPolicyCollections(int customerId, string baseUrl, string jsonLdId) + { + CustomerId = customerId; + BaseUrl = baseUrl; + JsonLdId = jsonLdId; + } +} + +public class GetDeliveryChannelPolicyCollectionsHandler : IRequestHandler> +{ + private readonly DlcsContext dbContext; + + public GetDeliveryChannelPolicyCollectionsHandler(DlcsContext dbContext) + { + this.dbContext = dbContext; + } + + public async Task> Handle(GetDeliveryChannelPolicyCollections request, CancellationToken cancellationToken) + { + var policyCollections = new Dictionary() + { + {AssetDeliveryChannels.Image, "Policies for IIIF Image service delivery"}, + {AssetDeliveryChannels.Thumbnails, "Policies for thumbnails as IIIF Image Services"}, + {AssetDeliveryChannels.Timebased, "Policies for Audio and Video delivery"}, + {AssetDeliveryChannels.File, "Policies for File delivery"} + }; + + return policyCollections; + } +} \ No newline at end of file diff --git a/src/protagonist/API/Features/DeliveryChannels/Requests/GetPoliciesForDeliveryChannel.cs b/src/protagonist/API/Features/DeliveryChannels/Requests/GetPoliciesForDeliveryChannel.cs new file mode 100644 index 000000000..4d20055ae --- /dev/null +++ b/src/protagonist/API/Features/DeliveryChannels/Requests/GetPoliciesForDeliveryChannel.cs @@ -0,0 +1,42 @@ +using System.Collections.Generic; +using API.Infrastructure.Requests; +using DLCS.Model.Policies; +using DLCS.Repository; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace API.Features.DeliveryChannels.Requests; + +public class GetPoliciesForDeliveryChannel: IRequest>> +{ + public int CustomerId { get; } + public string DeliveryChannelName { get; } + + public GetPoliciesForDeliveryChannel(int customerId, string deliveryChannelName) + { + CustomerId = customerId; + DeliveryChannelName = deliveryChannelName; + } +} + +public class GetDeliveryChannelPoliciesHandler : IRequestHandler>> +{ + private readonly DlcsContext dbContext; + + public GetDeliveryChannelPoliciesHandler(DlcsContext dbContext) + { + this.dbContext = dbContext; + } + + public async Task>> Handle(GetPoliciesForDeliveryChannel request, CancellationToken cancellationToken) + { + var deliveryChannelPolicies = await dbContext.DeliveryChannelPolicies + .AsNoTracking() + .Where(p => + p.Customer == request.CustomerId && + p.Channel == request.DeliveryChannelName) + .ToListAsync(cancellationToken); + + return FetchEntityResult>.Success(deliveryChannelPolicies); + } +} \ No newline at end of file diff --git a/src/protagonist/API/Features/DeliveryChannels/Requests/PatchDeliveryChannelPolicy.cs b/src/protagonist/API/Features/DeliveryChannels/Requests/PatchDeliveryChannelPolicy.cs new file mode 100644 index 000000000..76ac60816 --- /dev/null +++ b/src/protagonist/API/Features/DeliveryChannels/Requests/PatchDeliveryChannelPolicy.cs @@ -0,0 +1,85 @@ +using API.Features.DeliveryChannels.Validation; +using API.Infrastructure.Requests; +using DLCS.Core; +using DLCS.Core.Strings; +using DLCS.Model.Policies; +using DLCS.Repository; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace API.Features.DeliveryChannels.Requests; + +public class PatchDeliveryChannelPolicy : IRequest> +{ + public int CustomerId { get; } + + public string Channel { get; } + + public string Name { get; } + + public string? DisplayName { get; } + + public string? PolicyData { get; } + + public PatchDeliveryChannelPolicy(int customerId, string channel, string name, string? displayName, string? policyData) + { + CustomerId = customerId; + Channel = channel; + Name = name; + DisplayName = displayName; + PolicyData = policyData; + } +} + +public class PatchDeliveryChannelPolicyHandler : IRequestHandler> +{ + private readonly DlcsContext dbContext; + + public PatchDeliveryChannelPolicyHandler(DlcsContext dbContext, DeliveryChannelPolicyDataValidator policyDataValidator) + { + this.dbContext = dbContext; + } + + public async Task> Handle(PatchDeliveryChannelPolicy request, + CancellationToken cancellationToken) + { + var existingDeliveryChannelPolicy = await dbContext.DeliveryChannelPolicies.SingleOrDefaultAsync(p => + p.Customer == request.CustomerId && + p.Channel == request.Channel && + p.Name == request.Name, + cancellationToken); + + if (existingDeliveryChannelPolicy == null) + { + return ModifyEntityResult.Failure( + $"A policy for delivery channel '{request.Channel}' called '{request.Name}' was not found" , + WriteResult.NotFound); + } + + var hasBeenChanged = false; + + if (request.DisplayName != null) + { + existingDeliveryChannelPolicy.DisplayName = request.DisplayName; + hasBeenChanged = true; + } + + if (request.PolicyData != null) + { + existingDeliveryChannelPolicy.PolicyData = request.PolicyData; + hasBeenChanged = true; + } + + if (hasBeenChanged) + { + existingDeliveryChannelPolicy.Modified = DateTime.UtcNow; + var rowCount = await dbContext.SaveChangesAsync(cancellationToken); + if (rowCount == 0) + { + return ModifyEntityResult.Failure("Unable to patch delivery channel policy", WriteResult.Error); + } + } + + return ModifyEntityResult.Success(existingDeliveryChannelPolicy); + } +} \ No newline at end of file diff --git a/src/protagonist/API/Features/DeliveryChannels/Requests/UpdateDeliveryChannelPolicy.cs b/src/protagonist/API/Features/DeliveryChannels/Requests/UpdateDeliveryChannelPolicy.cs new file mode 100644 index 000000000..93deb3bd4 --- /dev/null +++ b/src/protagonist/API/Features/DeliveryChannels/Requests/UpdateDeliveryChannelPolicy.cs @@ -0,0 +1,71 @@ +using API.Features.DeliveryChannels.Validation; +using API.Infrastructure.Requests; +using DLCS.Core; +using DLCS.Model.Policies; +using DLCS.Repository; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace API.Features.DeliveryChannels.Requests; + +public class UpdateDeliveryChannelPolicy : IRequest> +{ + public int CustomerId { get; } + + public DeliveryChannelPolicy DeliveryChannelPolicy { get; } + + public UpdateDeliveryChannelPolicy(int customerId, DeliveryChannelPolicy deliveryChannelPolicy) + { + CustomerId = customerId; + DeliveryChannelPolicy = deliveryChannelPolicy; + } +} + +public class UpdateDeliveryChannelPolicyHandler : IRequestHandler> +{ + private readonly DlcsContext dbContext; + + public UpdateDeliveryChannelPolicyHandler(DlcsContext dbContext, DeliveryChannelPolicyDataValidator policyDataValidator) + { + this.dbContext = dbContext; + } + + public async Task> Handle(UpdateDeliveryChannelPolicy request, CancellationToken cancellationToken) + { + var existingDeliveryChannelPolicy = await dbContext.DeliveryChannelPolicies.SingleOrDefaultAsync(p => + p.Customer == request.CustomerId && + p.Channel == request.DeliveryChannelPolicy.Channel && + p.Name == request.DeliveryChannelPolicy.Name, + cancellationToken); + + if (existingDeliveryChannelPolicy != null) + { + existingDeliveryChannelPolicy.DisplayName = request.DeliveryChannelPolicy.DisplayName; + existingDeliveryChannelPolicy.Modified = DateTime.UtcNow; + existingDeliveryChannelPolicy.PolicyData = request.DeliveryChannelPolicy.PolicyData; + + await dbContext.SaveChangesAsync(cancellationToken); + + return ModifyEntityResult.Success(existingDeliveryChannelPolicy); + } + else + { + var newDeliveryChannelPolicy = new DeliveryChannelPolicy() + { + Customer = request.CustomerId, + Name = request.DeliveryChannelPolicy.Name, + DisplayName = request.DeliveryChannelPolicy.DisplayName, + Channel = request.DeliveryChannelPolicy.Channel, + System = false, + Modified = DateTime.UtcNow, + Created = DateTime.UtcNow, + PolicyData = request.DeliveryChannelPolicy.PolicyData, + }; + + await dbContext.DeliveryChannelPolicies.AddAsync(newDeliveryChannelPolicy, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + + return ModifyEntityResult.Success(newDeliveryChannelPolicy, WriteResult.Created); + } + } +} \ No newline at end of file diff --git a/src/protagonist/API/Features/DeliveryChannels/Validation/DeliveryChannelPolicyDataValidator.cs b/src/protagonist/API/Features/DeliveryChannels/Validation/DeliveryChannelPolicyDataValidator.cs new file mode 100644 index 000000000..822aa09a5 --- /dev/null +++ b/src/protagonist/API/Features/DeliveryChannels/Validation/DeliveryChannelPolicyDataValidator.cs @@ -0,0 +1,65 @@ +using System.Text.Json; +using DLCS.Core.Collections; +using DLCS.Model.Assets; +using IIIF.ImageApi; + +namespace API.Features.DeliveryChannels.Validation; + +public class DeliveryChannelPolicyDataValidator +{ + public bool Validate(string policyDataJson, string channel) + { + return channel switch + { + AssetDeliveryChannels.Thumbnails => ValidateThumbnailPolicyData(policyDataJson), + AssetDeliveryChannels.Timebased => ValidateTimeBasedPolicyData(policyDataJson), + _ => false // This is only for thumbs and iiif-av for now + }; + } + + private string[]? ParseJsonPolicyData(string policyDataJson) + { + string[]? policyData; + try + { + policyData = JsonSerializer.Deserialize(policyDataJson); + } + catch(JsonException) + { + return null; + } + + return policyData; + } + + private bool ValidateThumbnailPolicyData(string policyDataJson) + { + var policyData = ParseJsonPolicyData(policyDataJson); + + if (policyData.IsNullOrEmpty()) + { + return false; + } + + foreach (var sizeValue in policyData) + { + try + { + SizeParameter.Parse(sizeValue); + } + catch + { + return false; + } + } + + return true; + } + + private bool ValidateTimeBasedPolicyData(string policyDataJson) + { + var policyData = ParseJsonPolicyData(policyDataJson); + + return !(policyData.IsNullOrEmpty() || policyData.Any(string.IsNullOrEmpty)); + } +} \ No newline at end of file diff --git a/src/protagonist/API/Features/DeliveryChannels/Validation/HydraDeliveryChannelPolicyValidator.cs b/src/protagonist/API/Features/DeliveryChannels/Validation/HydraDeliveryChannelPolicyValidator.cs new file mode 100644 index 000000000..4dd1793ca --- /dev/null +++ b/src/protagonist/API/Features/DeliveryChannels/Validation/HydraDeliveryChannelPolicyValidator.cs @@ -0,0 +1,85 @@ +using System.Text.RegularExpressions; +using DLCS.Model.Assets; +using FluentValidation; + +namespace API.Features.DeliveryChannels.Validation; + +/// +/// Validator for model sent to POST /deliveryChannelPolicies and PUT/PATCH /deliveryChannelPolicies/{id} +/// +public class HydraDeliveryChannelPolicyValidator : AbstractValidator +{ + private readonly DeliveryChannelPolicyDataValidator policyDataValidator; + + private readonly string[] allowedDeliveryChannels = + { + AssetDeliveryChannels.Thumbnails, + AssetDeliveryChannels.Timebased, + }; + + public HydraDeliveryChannelPolicyValidator(DeliveryChannelPolicyDataValidator policyDataValidator) + { + this.policyDataValidator = policyDataValidator; + + RuleFor(p => p.Id) + .Empty() + .WithMessage(p => $"DLCS must allocate named delivery channel policy id, but id {p.Id} was supplied"); + RuleFor(p => p.CustomerId) + .Empty() + .WithMessage("Should not include user id"); + RuleFor(p => p.Channel) + .NotEmpty() + .WithMessage("'channel' is required"); + RuleFor(p => p.Channel) + .Must(IsPermittedDeliveryChannel!) + .When(p => !string.IsNullOrEmpty(p.Channel)) + .WithMessage(p => $"'{p.Channel}' is not a valid/permitted delivery channel"); + RuleSet("post", () => + { + RuleFor(p => p.Name) + .NotEmpty() + .WithMessage("'name' is required"); + RuleFor(p => p.Name) + .Must(IsValidName!) + .WithMessage("'name' is invalid"); + RuleFor(p => p.PolicyData) + .NotEmpty() + .WithMessage("'policyData' is required"); + }); + RuleSet("put", () => + { + RuleFor(p => p.Name) + .Must(IsValidName!) + .WithMessage("'name' is invalid"); + RuleFor(p => p.PolicyData) + .NotEmpty() + .WithMessage("'policyData' is required"); + }); + RuleFor(p => p.PolicyData) + .Must((p, pd) => IsValidPolicyData(pd, p.Channel)) + .When(p => !string.IsNullOrEmpty(p.PolicyData)) + .WithMessage(p => $"'policyData' contains bad JSON or invalid data"); + RuleFor(p => p.Modified) + .Empty().WithMessage(c => $"'policyModified' is generated by the DLCS and cannot be set manually"); + RuleFor(p => p.Created) + .Empty().WithMessage(c => $"'policyCreated' is generated by the DLCS and cannot be set manually"); + } + + private bool IsValidName(string inputName) + { + const string regex = "[\\sA-Z]"; // Delivery channel policy names should not contain capital letters or spaces + if (string.IsNullOrEmpty(inputName)) return false; + return !(Regex.IsMatch(inputName, regex)); + } + + private bool IsPermittedDeliveryChannel(string deliveryChannelPolicyName) + { + return allowedDeliveryChannels.Contains(deliveryChannelPolicyName); + } + + private bool IsValidPolicyData(string? policyData, string? channel) + { + if (string.IsNullOrEmpty(policyData) || string.IsNullOrEmpty(channel)) return false; + return policyDataValidator.Validate(policyData, channel); + } +} \ No newline at end of file diff --git a/src/protagonist/API/Startup.cs b/src/protagonist/API/Startup.cs index c1af1b4d1..55316208b 100644 --- a/src/protagonist/API/Startup.cs +++ b/src/protagonist/API/Startup.cs @@ -1,5 +1,6 @@ using System.Security.Claims; using API.Auth; +using API.Features.DeliveryChannels.Validation; using API.Features.Image.Ingest; using API.Features.OriginStrategies.Credentials; using API.Infrastructure; @@ -74,6 +75,7 @@ public void ConfigureServices(IServiceCollection services) .AddSingleton() .AddScoped() .AddTransient() + .AddSingleton() .AddValidatorsFromAssemblyContaining() .ConfigureMediatR() .AddNamedQueriesCore() diff --git a/src/protagonist/DLCS.HydraModel/DeliveryChannelPolicy.cs b/src/protagonist/DLCS.HydraModel/DeliveryChannelPolicy.cs new file mode 100644 index 000000000..763be2601 --- /dev/null +++ b/src/protagonist/DLCS.HydraModel/DeliveryChannelPolicy.cs @@ -0,0 +1,72 @@ +using System; +using Hydra; +using Hydra.Model; +using Newtonsoft.Json; + +namespace DLCS.HydraModel; + +[HydraClass(typeof (DeliveryChannelPolicyClass), + Description = "A policy for a delivery channel.", + UriTemplate = "/customers/{0}/deliveryChannelPolicies/{1}/{2}")] +public class DeliveryChannelPolicy : DlcsResource +{ + [JsonIgnore] + public int CustomerId { get; set; } + + public DeliveryChannelPolicy() + { + } + + public DeliveryChannelPolicy(string baseUrl, int customerId, string channelName, string name) + { + CustomerId = customerId; + Channel = channelName; + Name = name; + Init(baseUrl, true, customerId, channelName, name); + } + + [RdfProperty(Description = "The URL-friendly name of this delivery channel policy.", + Range = Names.XmlSchema.String, ReadOnly = false, WriteOnly = false)] + [JsonProperty(Order = 10, PropertyName = "name")] + public string? Name { get; set; } + + [RdfProperty(Description = "The display name of this delivery channel policy", + Range = Names.XmlSchema.String, ReadOnly = false, WriteOnly = false)] + [JsonProperty(Order = 11, PropertyName = "displayName")] + public string? DisplayName { get; set; } + + [RdfProperty(Description = "The delivery channel this policy is for.", + Range = Names.XmlSchema.String, ReadOnly = false, WriteOnly = false)] + [JsonProperty(Order = 12, PropertyName = "channel")] + public string? Channel { get; set; } + + [RdfProperty(Description = "A JSON object containing configuration for the specified delivery channel - see the DeliveryChannels topic.", + Range = Names.XmlSchema.String, ReadOnly = false, WriteOnly = false)] + [JsonProperty(Order = 13, PropertyName = "policyData")] + public string? PolicyData { get; set; } + + [RdfProperty(Description = "The date this policy was created.", + Range = Names.XmlSchema.DateTime, ReadOnly = true, WriteOnly = false)] + [JsonProperty(Order = 14, PropertyName = "policyCreated")] + public DateTime? Created { get; set; } + + [RdfProperty(Description = "The date this policy was last modified.", + Range = Names.XmlSchema.DateTime, ReadOnly = true, WriteOnly = false)] + [JsonProperty(Order = 15, PropertyName = "policyModified")] + public DateTime? Modified { get; set; } +} + +public class DeliveryChannelPolicyClass: Class +{ + public DeliveryChannelPolicyClass() + { + BootstrapViaReflection(typeof(DeliveryChannelPolicy)); + } + + public override void DefineOperations() + { + SupportedOperations = CommonOperations.GetStandardResourceOperations( + "_:customer_deliveryChannelPolicy_", "Delivery Channel Policy", Id, + "GET", "POST", "PUT", "PATCH", "DELETE"); + } +} \ No newline at end of file diff --git a/src/protagonist/DLCS.Model/Assets/AssetDeliveryChannels.cs b/src/protagonist/DLCS.Model/Assets/AssetDeliveryChannels.cs index 07d331584..c2f86a817 100644 --- a/src/protagonist/DLCS.Model/Assets/AssetDeliveryChannels.cs +++ b/src/protagonist/DLCS.Model/Assets/AssetDeliveryChannels.cs @@ -7,13 +7,14 @@ namespace DLCS.Model.Assets; public static class AssetDeliveryChannels { public const string Image = "iiif-img"; + public const string Thumbnails = "thumbs"; public const string Timebased = "iiif-av"; public const string File = "file"; /// /// All possible delivery channels /// - public static string[] All { get; } = { File, Timebased, Image }; + public static string[] All { get; } = { File, Timebased, Image, Thumbnails }; /// /// All possible delivery channels as a comma-delimited string @@ -40,4 +41,10 @@ public static bool HasDeliveryChannel(this Asset asset, string deliveryChannel) /// public static bool HasSingleDeliveryChannel(this Asset asset, string deliveryChannel) => asset.DeliveryChannels.ContainsOnly(deliveryChannel); + + /// + /// Checks if string is a valid delivery channel + /// + public static bool IsValidChannel(string deliveryChannel) => + All.Contains(deliveryChannel); } \ No newline at end of file diff --git a/src/protagonist/DLCS.Repository/Exceptions/DbError.cs b/src/protagonist/DLCS.Repository/Exceptions/DbError.cs new file mode 100644 index 000000000..878f979bd --- /dev/null +++ b/src/protagonist/DLCS.Repository/Exceptions/DbError.cs @@ -0,0 +1,13 @@ +using System; + +namespace DLCS.Repository.Exceptions; + +/// +/// Provides additional Database specific information about +/// a thrown by EF Core. +/// +/// The table involved, if any. +/// The constraint involved, if any. +/// The unwrapped database provider specific exception. +/// See https://haacked.com/archive/2022/12/12/specific-db-exception for more information +public record DbError(string? TableName, string? ConstraintName, Exception Exception); \ No newline at end of file diff --git a/src/protagonist/DLCS.Repository/Exceptions/DbUniqueConstraintError.cs b/src/protagonist/DLCS.Repository/Exceptions/DbUniqueConstraintError.cs new file mode 100644 index 000000000..d7a143877 --- /dev/null +++ b/src/protagonist/DLCS.Repository/Exceptions/DbUniqueConstraintError.cs @@ -0,0 +1,48 @@ +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore; +using Npgsql; + +namespace DLCS.Repository.Exceptions; + +/// +/// Provides additional Postgres specific information about a +/// thrown by EF Core.This describes +/// the case where the exception is a unique constraint violation. +/// +/// The column names parsed from the constraint +/// name assuming the constraint follows the "IX_{Table}_{Column1}_..._{ColumnN}" naming convention. +/// The table involved, if any. +/// The constraint involved, if any. +/// The unwrapped database provider specific exception. +/// See https://haacked.com/archive/2022/12/12/specific-db-exception for more information. +public record UniqueConstraintError( + IReadOnlyList ColumnNames, + string? TableName, + string? ConstraintName, + Exception Exception) : DbError(TableName, ConstraintName, Exception) { + + /// + /// Creates a from a . + /// + /// The . + /// A with extra information about the unique constraint violation. + public static UniqueConstraintError FromPostgresException(PostgresException postgresException) + { + var constraintName = postgresException.ConstraintName; + var tableName = postgresException.TableName; + + var constrainPrefix = tableName != null ? $"IX_{tableName}_" : null; + + var columnNames = Array.Empty(); + + if (constrainPrefix != null + && constraintName != null + && constraintName.StartsWith(constrainPrefix, StringComparison.Ordinal)) + { + columnNames = constraintName[constrainPrefix.Length..].Split('_'); + } + + return new UniqueConstraintError(columnNames, tableName, constraintName, postgresException); + } +} \ No newline at end of file diff --git a/src/protagonist/DLCS.Repository/Exceptions/DbUpdateExceptionX.cs b/src/protagonist/DLCS.Repository/Exceptions/DbUpdateExceptionX.cs new file mode 100644 index 000000000..37cd97027 --- /dev/null +++ b/src/protagonist/DLCS.Repository/Exceptions/DbUpdateExceptionX.cs @@ -0,0 +1,38 @@ +using Microsoft.EntityFrameworkCore; +using Npgsql; + +namespace DLCS.Repository.Exceptions; + +/// +/// Extensions to used to retrieve more +/// database specific information about the thrown exception. +/// +/// See https://haacked.com/archive/2022/12/12/specific-db-exception for more information. +public static class DbUpdateExceptionX +{ + /// + /// Retrieves a with database specific error + /// information from the thrown by EF Core. + /// + /// The thrown. + /// A or derived class if the inner + /// exception matches one of the supported types. Otherwise returns null. + public static DbError? GetDatabaseError(this DbUpdateException exception) + { + if (exception.InnerException is PostgresException postgresException) + { + return postgresException.SqlState switch + { + PostgresErrorCodes.UniqueViolation => UniqueConstraintError + .FromPostgresException(postgresException), + //... Other error codes mapped to other error types. + _ => new DbError( + postgresException.TableName, + postgresException.ConstraintName, + postgresException) + }; + } + + return null; + } +} \ No newline at end of file diff --git a/src/protagonist/Hydra/Collections/HydraNestedCollection.cs b/src/protagonist/Hydra/Collections/HydraNestedCollection.cs new file mode 100644 index 000000000..a058d0097 --- /dev/null +++ b/src/protagonist/Hydra/Collections/HydraNestedCollection.cs @@ -0,0 +1,21 @@ +using Newtonsoft.Json; + +namespace Hydra.Collections; + +public class HydraNestedCollection : HydraCollection +{ + public override string Type => "Collection"; + + public HydraNestedCollection() + { + + } + + public HydraNestedCollection(string baseUrl, string id) + { + Id = $"{baseUrl}/{id}"; + } + + [JsonProperty(Order = 10, PropertyName = "title")] + public string? Title { get; set; } +} \ No newline at end of file diff --git a/src/protagonist/Test.Helpers/Integration/DlcsDatabaseFixture.cs b/src/protagonist/Test.Helpers/Integration/DlcsDatabaseFixture.cs index 6c3cd68f7..b27c16096 100644 --- a/src/protagonist/Test.Helpers/Integration/DlcsDatabaseFixture.cs +++ b/src/protagonist/Test.Helpers/Integration/DlcsDatabaseFixture.cs @@ -64,7 +64,7 @@ public void CleanUp() DbContext.Database.ExecuteSqlRaw("DELETE FROM \"EntityCounters\" WHERE \"Type\" = 'space' AND \"Customer\" != 99"); DbContext.Database.ExecuteSqlRaw("DELETE FROM \"EntityCounters\" WHERE \"Type\" = 'space-images' AND \"Customer\" != 99"); DbContext.Database.ExecuteSqlRaw("DELETE FROM \"EntityCounters\" WHERE \"Type\" = 'customer-images' AND \"Scope\" != '99'"); - DbContext.Database.ExecuteSqlRaw("DELETE FROM \"DeliveryChannelPolicies\" WHERE \"Customer\" <> 1"); + DbContext.Database.ExecuteSqlRaw("DELETE FROM \"DeliveryChannelPolicies\" WHERE \"Customer\" not in (1,99)"); DbContext.Database.ExecuteSqlRaw("DELETE FROM \"DefaultDeliveryChannels\" WHERE \"Customer\" <> 1"); DbContext.ChangeTracker.Clear(); } @@ -164,6 +164,15 @@ await DbContext.EntityCounters.AddAsync(new EntityCounter() Id = "cust-default", Name = "Customer Scoped", TechnicalDetails = new[] { "default" }, Global = false, Customer = 99 }); + await DbContext.DeliveryChannelPolicies.AddAsync(new DeliveryChannelPolicy() + { + Customer = 99, + Name = "example-thumbs-policy", + DisplayName = "Example Thumbnail Policy", + Channel = "thumbs", + PolicyData = "[\"!1024,1024\",\"!400,400\",\"!200,200\",\"!100,100\"]", + System = false, + }); await DbContext.AuthServices.AddAsync(new AuthService { Customer = customer, Name = "clickthrough", Id = ClickThroughAuthService, Description = "", Label = "",