diff --git a/CHANGELOG.md b/CHANGELOG.md index e674e4c..ee2230a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## [v0.6.1](https://github.com/contentstack/contentstack-management-dotnet/tree/v0.6.1) (2026-02-02) + - Fix + - Release DELETE request no longer includes Content-Type header to comply with API requirements + ## [v0.6.0](https://github.com/contentstack/contentstack-management-dotnet/tree/v0.6.0) - Enhancement - Refactor retry policy implementation to improve exception handling and retry logic across various scenarios diff --git a/Contentstack.Management.Core.Tests/IntegrationTest/Contentstack004_ReleaseTest.cs b/Contentstack.Management.Core.Tests/IntegrationTest/Contentstack004_ReleaseTest.cs index fb4fbce..66f9014 100644 --- a/Contentstack.Management.Core.Tests/IntegrationTest/Contentstack004_ReleaseTest.cs +++ b/Contentstack.Management.Core.Tests/IntegrationTest/Contentstack004_ReleaseTest.cs @@ -970,6 +970,92 @@ public async Task Test020_Should_Delete_Release_Async() } } + /// + /// Verifies that Delete Release API succeeds when the SDK does not send Content-Type header (DELETE /releases/{uid}). + /// Creates a release, deletes it without Content-Type, asserts success, then verifies the release is gone. + /// + [TestMethod] + [DoNotParallelize] + public void Test021_Should_Delete_Release_Without_Content_Type_Header() + { + try + { + var releaseModel = new ReleaseModel + { + Name = _testReleaseName + " Delete Without Content-Type", + Description = _testReleaseDescription + " (Delete without Content-Type header)", + Locked = false, + Archived = false + }; + + ContentstackResponse createResponse = _stack.Release().Create(releaseModel); + var createResponseJson = createResponse.OpenJObjectResponse(); + Assert.IsTrue(createResponse.IsSuccessStatusCode, "Create release must succeed."); + string releaseToDeleteUid = createResponseJson["release"]["uid"].ToString(); + + ContentstackResponse deleteResponse = _stack.Release(releaseToDeleteUid).Delete(); + + Assert.IsNotNull(deleteResponse); + Assert.IsTrue(deleteResponse.IsSuccessStatusCode, "Delete release (without Content-Type) must succeed."); + + try + { + var fetchResponse = _stack.Release(releaseToDeleteUid).Fetch(); + Assert.IsFalse(fetchResponse.IsSuccessStatusCode, "Release must be gone after delete; Fetch should not succeed."); + } + catch (ContentstackErrorException) + { + Assert.IsTrue(true, "Release not found after delete (exception path)."); + } + } + catch (Exception e) + { + Assert.Fail($"Delete release without Content-Type header failed: {e.Message}"); + } + } + /// + /// Verifies that Delete Release API (async) succeeds when the SDK does not send Content-Type header (DELETE /releases/{uid}). + /// Creates a release, deletes it without Content-Type, asserts success, then verifies the release is gone. + /// + [TestMethod] + [DoNotParallelize] + public async Task Test022_Should_Delete_Release_Async_Without_Content_Type_Header() + { + try + { + var releaseModel = new ReleaseModel + { + Name = _testReleaseName + " Delete Async Without Content-Type", + Description = _testReleaseDescription + " (Delete async without Content-Type header)", + Locked = false, + Archived = false + }; + + ContentstackResponse createResponse = await _stack.Release().CreateAsync(releaseModel); + var createResponseJson = createResponse.OpenJObjectResponse(); + Assert.IsTrue(createResponse.IsSuccessStatusCode, "Create release must succeed."); + string releaseToDeleteUid = createResponseJson["release"]["uid"].ToString(); + + ContentstackResponse deleteResponse = await _stack.Release(releaseToDeleteUid).DeleteAsync(); + + Assert.IsNotNull(deleteResponse); + Assert.IsTrue(deleteResponse.IsSuccessStatusCode, "Delete release async (without Content-Type) must succeed."); + + try + { + var fetchResponse = await _stack.Release(releaseToDeleteUid).FetchAsync(); + Assert.IsFalse(fetchResponse.IsSuccessStatusCode, "Release must be gone after delete; Fetch should not succeed."); + } + catch (ContentstackErrorException) + { + Assert.IsTrue(true, "Release not found after delete (exception path)."); + } + } + catch (Exception e) + { + Assert.Fail($"Delete release async without Content-Type header failed: {e.Message}"); + } + } } } \ No newline at end of file diff --git a/Contentstack.Management.Core.Unit.Tests/Core/Services/ContentstackServiceTest.cs b/Contentstack.Management.Core.Unit.Tests/Core/Services/ContentstackServiceTest.cs index 9b6c28b..873e283 100644 --- a/Contentstack.Management.Core.Unit.Tests/Core/Services/ContentstackServiceTest.cs +++ b/Contentstack.Management.Core.Unit.Tests/Core/Services/ContentstackServiceTest.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Globalization; using System.IO; @@ -279,6 +279,19 @@ public void Return_Value_For_HeaderKey() Assert.AreEqual("application/json", contentstackService.GetHeaderValue(HeadersKey.ContentTypeHeader)); } + [TestMethod] + public void CreateHttpRequest_Should_Set_Content_Type_When_ShouldSetContentType_Returns_True() + { + var contentstackService = new ContentstackService(serializer); + var config = new ContentstackClientOptions(); + config.Authtoken = _fixture.Create(); + + contentstackService.CreateHttpRequest(new HttpClient(), config); + + Assert.IsTrue(contentstackService.Headers.ContainsKey(HeadersKey.ContentTypeHeader)); + Assert.AreEqual("application/json", contentstackService.GetHeaderValue(HeadersKey.ContentTypeHeader)); + } + [TestMethod] public void Return_HttpRequest_On_Create_HttpRequest() { diff --git a/Contentstack.Management.Core.Unit.Tests/Core/Services/Models/FetchDeleteServiceTest.cs b/Contentstack.Management.Core.Unit.Tests/Core/Services/Models/FetchDeleteServiceTest.cs index cf780a2..a822944 100644 --- a/Contentstack.Management.Core.Unit.Tests/Core/Services/Models/FetchDeleteServiceTest.cs +++ b/Contentstack.Management.Core.Unit.Tests/Core/Services/Models/FetchDeleteServiceTest.cs @@ -1,9 +1,13 @@ -using System; +using System; +using System.Net.Http; using AutoFixture; using AutoFixture.AutoMoq; +using Contentstack.Management.Core; using Contentstack.Management.Core.Services.Models; +using Contentstack.Management.Core.Utils; using Microsoft.VisualStudio.TestTools.UnitTesting; using Newtonsoft.Json; + namespace Contentstack.Management.Core.Unit.Tests.Core.Services.Models { [TestClass] @@ -11,8 +15,14 @@ public class FetchDeleteServiceTest { private JsonSerializer serializer = JsonSerializer.Create(new JsonSerializerSettings()); private readonly IFixture _fixture = new Fixture() - .Customize(new AutoMoqCustomization()); + .Customize(new AutoMoqCustomization()); + private static ContentstackClientOptions CreateConfig(IFixture fixture) + { + var config = new ContentstackClientOptions(); + config.Authtoken = fixture.Create(); + return config; + } [TestMethod] public void Should_Throw_On_Null_Serializer() @@ -60,5 +70,63 @@ public void Should_Provide_Valid_Param_On_Initialize() Assert.AreEqual("GET", service.HttpMethod); Assert.AreEqual(resourcePath, service.ResourcePath); } + + [TestMethod] + public void Delete_Release_Should_Not_Include_Content_Type_Header() + { + var stack = new Management.Core.Models.Stack(null, _fixture.Create()); + var service = new FetchDeleteService(serializer, stack, "/releases/release_uid_123", "DELETE"); + + service.CreateHttpRequest(new HttpClient(), CreateConfig(_fixture)); + + Assert.IsFalse(service.Headers.ContainsKey(HeadersKey.ContentTypeHeader), "DELETE /releases/{uid} must not include Content-Type header."); + } + + [TestMethod] + public void Delete_Release_With_Path_Releases_Only_Should_Not_Include_Content_Type_Header() + { + var stack = new Management.Core.Models.Stack(null, _fixture.Create()); + var service = new FetchDeleteService(serializer, stack, "/releases", "DELETE"); + + service.CreateHttpRequest(new HttpClient(), CreateConfig(_fixture)); + + Assert.IsFalse(service.Headers.ContainsKey(HeadersKey.ContentTypeHeader)); + } + + [TestMethod] + public void Delete_Release_Items_Path_Should_Include_Content_Type_Header() + { + var stack = new Management.Core.Models.Stack(null, _fixture.Create()); + var service = new FetchDeleteService(serializer, stack, "/releases/release_uid/item", "DELETE"); + + service.CreateHttpRequest(new HttpClient(), CreateConfig(_fixture)); + + Assert.IsTrue(service.Headers.ContainsKey(HeadersKey.ContentTypeHeader), "DELETE /releases/{uid}/item (FetchDeleteService) should still set Content-Type."); + Assert.AreEqual("application/json", service.GetHeaderValue(HeadersKey.ContentTypeHeader)); + } + + [TestMethod] + public void Fetch_Release_Should_Include_Content_Type_Header() + { + var stack = new Management.Core.Models.Stack(null, _fixture.Create()); + var service = new FetchDeleteService(serializer, stack, "/releases/release_uid_123", "GET"); + + service.CreateHttpRequest(new HttpClient(), CreateConfig(_fixture)); + + Assert.IsTrue(service.Headers.ContainsKey(HeadersKey.ContentTypeHeader)); + Assert.AreEqual("application/json", service.GetHeaderValue(HeadersKey.ContentTypeHeader)); + } + + [TestMethod] + public void Delete_Non_Release_Resource_Should_Include_Content_Type_Header() + { + var stack = new Management.Core.Models.Stack(null, _fixture.Create()); + var service = new FetchDeleteService(serializer, stack, "/contenttypes/ct_uid", "DELETE"); + + service.CreateHttpRequest(new HttpClient(), CreateConfig(_fixture)); + + Assert.IsTrue(service.Headers.ContainsKey(HeadersKey.ContentTypeHeader)); + Assert.AreEqual("application/json", service.GetHeaderValue(HeadersKey.ContentTypeHeader)); + } } } diff --git a/Contentstack.Management.Core.Unit.Tests/Models/ReleaseTest.cs b/Contentstack.Management.Core.Unit.Tests/Models/ReleaseTest.cs index a764b86..a0e42dc 100644 --- a/Contentstack.Management.Core.Unit.Tests/Models/ReleaseTest.cs +++ b/Contentstack.Management.Core.Unit.Tests/Models/ReleaseTest.cs @@ -1,5 +1,6 @@ -using System; +using System; using AutoFixture; +using Contentstack.Management.Core; using Contentstack.Management.Core.Models; using Contentstack.Management.Core.Queryable; using Contentstack.Management.Core.Unit.Tests.Mokes; diff --git a/Contentstack.Management.Core/Services/ContentstackService.cs b/Contentstack.Management.Core/Services/ContentstackService.cs index fcf422f..7e56fba 100644 --- a/Contentstack.Management.Core/Services/ContentstackService.cs +++ b/Contentstack.Management.Core/Services/ContentstackService.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.IO; using System.Text; using System.Net.Http; @@ -151,6 +151,15 @@ public bool HasRequestBody() return HttpMethod == "POST" || HttpMethod == "PUT" || HttpMethod == "PATCH" || HttpMethod == "DELETE"; } + /// + /// Returns true if the request should include Content-Type: application/json header. + /// Override to skip Content-Type for specific requests (e.g. DELETE /releases). + /// + protected virtual bool ShouldSetContentType() + { + return true; + } + public virtual IHttpRequest CreateHttpRequest(HttpClient httpClient, ContentstackClientOptions config, bool addAcceptMediaHeader = false, string apiVersion = null) { ThrowIfDisposed(); @@ -162,7 +171,10 @@ public virtual IHttpRequest CreateHttpRequest(HttpClient httpClient, Contentstac .Add(new MediaTypeWithQualityHeaderValue("image/jpeg")); } Uri requestUri = ContentstackUtilities.ComposeUrI(config.GetUri(), this); - Headers["Content-Type"] = "application/json"; + if (ShouldSetContentType()) + { + Headers["Content-Type"] = "application/json"; + } if (!string.IsNullOrEmpty(this.ManagementToken)) { diff --git a/Contentstack.Management.Core/Services/Models/FetchDeleteService.cs b/Contentstack.Management.Core/Services/Models/FetchDeleteService.cs index 287a408..8bd26bb 100644 --- a/Contentstack.Management.Core/Services/Models/FetchDeleteService.cs +++ b/Contentstack.Management.Core/Services/Models/FetchDeleteService.cs @@ -1,4 +1,4 @@ -using System; +using System; using Contentstack.Management.Core.Queryable; using Newtonsoft.Json; using Contentstack.Management.Core.Utils; @@ -29,5 +29,19 @@ internal FetchDeleteService(JsonSerializer serializer, Core.Models.Stack stack, } } #endregion + + /// + /// Skip Content-Type for DELETE /releases (single release delete). Keep it for DELETE /releases/{uid}/item and other requests. + /// + protected override bool ShouldSetContentType() + { + if (HttpMethod != "DELETE" || string.IsNullOrEmpty(ResourcePath)) + return true; + if (!ResourcePath.StartsWith("/releases", StringComparison.Ordinal)) + return true; + if (ResourcePath.EndsWith("/item", StringComparison.Ordinal)) + return true; + return false; + } } } diff --git a/Directory.Build.props b/Directory.Build.props index 5977029..735f780 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,5 +1,5 @@ - 0.6.0 + 0.6.1