diff --git a/.gitignore b/.gitignore index 8b68604..5bbfab0 100644 --- a/.gitignore +++ b/.gitignore @@ -22,7 +22,9 @@ packages/ */TestResults/* */app.config */mono** -*/appSettings.json +*/appsettings.json +*/appsettings.template.json +SDK_METHOD_COVERAGE_MAP.md api_referece/* .sonarqube/ *.html diff --git a/Contentstack.Management.Core.Tests/Contentstack.Management.Core.Tests.csproj b/Contentstack.Management.Core.Tests/Contentstack.Management.Core.Tests.csproj index a8efdf8..5c41df5 100644 --- a/Contentstack.Management.Core.Tests/Contentstack.Management.Core.Tests.csproj +++ b/Contentstack.Management.Core.Tests/Contentstack.Management.Core.Tests.csproj @@ -40,7 +40,7 @@ - + PreserveNewest diff --git a/Contentstack.Management.Core.Tests/Contentstack.cs b/Contentstack.Management.Core.Tests/Contentstack.cs index 39129a4..d31e5b3 100644 --- a/Contentstack.Management.Core.Tests/Contentstack.cs +++ b/Contentstack.Management.Core.Tests/Contentstack.cs @@ -16,6 +16,14 @@ namespace Contentstack.Management.Core.Tests { + /// Holds OAuth credentials from appsettings.json Contentstack:OAuth section. + public class OAuthConfig + { + public string ClientId { get; set; } + public string AppId { get; set; } + public string RedirectUri { get; set; } + } + public class Contentstack { private static readonly Lazy @@ -43,11 +51,75 @@ private static readonly Lazy return Config.GetSection("Contentstack:MfaSecret").Value; }); - public static IConfigurationRoot Config{ get { return config.Value; } } + // ── New optional config keys ───────────────────────────────────────── + + private static readonly Lazy memberEmail = + new Lazy(() => Config.GetSection("Contentstack:MemberEmail").Value); + + private static readonly Lazy tfaEmail = + new Lazy(() => Config.GetSection("Contentstack:TfaEmail").Value); + + private static readonly Lazy tfaPassword = + new Lazy(() => Config.GetSection("Contentstack:TfaPassword").Value); + + private static readonly Lazy oAuthConfig = + new Lazy(() => + Config.GetSection("Contentstack:OAuth").Get() ?? new OAuthConfig()); + + private static readonly Lazy personalizeHost = + new Lazy(() => + Config.GetSection("Contentstack:PersonalizeHost").Value + ?? "personalize-api.contentstack.com"); + + private static readonly Lazy deleteDynamicResources = + new Lazy(() => + !string.Equals( + Config.GetSection("Contentstack:DeleteDynamicResources").Value, + "false", StringComparison.OrdinalIgnoreCase)); + + private static readonly Lazy damV2Enabled = + new Lazy(() => + string.Equals( + Config.GetSection("Contentstack:DamV2Enabled").Value, + "true", StringComparison.OrdinalIgnoreCase)); + + private static readonly Lazy amOrgUid = + new Lazy(() => Config.GetSection("Contentstack:AmOrgUid").Value); + + // ── Public accessors ───────────────────────────────────────────────── + + public static IConfigurationRoot Config { get { return config.Value; } } public static NetworkCredential Credential { get { return credential.Value; } } public static OrganizationModel Organization { get { return organization.Value; } } public static string MfaSecret { get { return mfaSecret.Value; } } + /// Secondary user email for team / stack-sharing tests. + public static string MemberEmail => memberEmail.Value; + + /// Email of a 2FA-enabled account for testing the TFA login flow. + public static string TfaEmail => tfaEmail.Value; + + /// Password matching TfaEmail. + public static string TfaPassword => tfaPassword.Value; + + /// OAuth app credentials (ClientId, AppId, RedirectUri). + public static OAuthConfig OAuth => oAuthConfig.Value; + + /// Personalize API host; defaults to personalize-api.contentstack.com. + public static string PersonalizeHost => personalizeHost.Value; + + /// + /// When true (default) the dynamically created test stack is deleted after the run. + /// Set Contentstack:DeleteDynamicResources=false in appsettings.json to preserve it. + /// + public static bool DeleteDynamicResources => deleteDynamicResources.Value; + + /// Enables DAM 2.0 / asset-scan-status tests. + public static bool DamV2Enabled => damV2Enabled.Value; + + /// Org UID for AM (Advanced Managed) org tests. + public static string AmOrgUid => amOrgUid.Value; + public static StackModel Stack { get; set; } // TOTP token tracking to prevent reuse diff --git a/Contentstack.Management.Core.Tests/Helpers/LoggingHttpHandler.cs b/Contentstack.Management.Core.Tests/Helpers/LoggingHttpHandler.cs index 67a300b..cdd5fa2 100644 --- a/Contentstack.Management.Core.Tests/Helpers/LoggingHttpHandler.cs +++ b/Contentstack.Management.Core.Tests/Helpers/LoggingHttpHandler.cs @@ -16,9 +16,11 @@ public LoggingHttpHandler(HttpMessageHandler innerHandler) : base(innerHandler) protected override async Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { + DateTime requestedAt = DateTime.UtcNow; + try { - await CaptureRequest(request); + await CaptureRequest(request, requestedAt); } catch { @@ -26,10 +28,11 @@ protected override async Task SendAsync( } var response = await base.SendAsync(request, cancellationToken); + DateTime respondedAt = DateTime.UtcNow; try { - await CaptureResponse(response); + await CaptureResponse(response, requestedAt, respondedAt); } catch { @@ -39,7 +42,7 @@ protected override async Task SendAsync( return response; } - private async Task CaptureRequest(HttpRequestMessage request) + private async Task CaptureRequest(HttpRequestMessage request, DateTime requestedAt) { var headers = new Dictionary(); foreach (var h in request.Headers) @@ -63,11 +66,12 @@ private async Task CaptureRequest(HttpRequestMessage request) headers: headers, body: body ?? "", curlCommand: curl, - sdkMethod: "" + sdkMethod: "", + timestamp: requestedAt ); } - private async Task CaptureResponse(HttpResponseMessage response) + private async Task CaptureResponse(HttpResponseMessage response, DateTime requestedAt, DateTime respondedAt) { var headers = new Dictionary(); foreach (var h in response.Headers) @@ -87,7 +91,9 @@ private async Task CaptureResponse(HttpResponseMessage response) statusCode: (int)response.StatusCode, statusText: response.ReasonPhrase ?? response.StatusCode.ToString(), headers: headers, - body: body ?? "" + body: body ?? "", + timestamp: respondedAt, + durationMs: (long)(respondedAt - requestedAt).TotalMilliseconds ); } diff --git a/Contentstack.Management.Core.Tests/Helpers/TestOutputLogger.cs b/Contentstack.Management.Core.Tests/Helpers/TestOutputLogger.cs index 557588a..291d9c1 100644 --- a/Contentstack.Management.Core.Tests/Helpers/TestOutputLogger.cs +++ b/Contentstack.Management.Core.Tests/Helpers/TestOutputLogger.cs @@ -23,7 +23,8 @@ public static void LogAssertion(string assertionName, object expected, object ac public static void LogHttpRequest(string method, string url, IDictionary headers, string body, - string curlCommand, string sdkMethod) + string curlCommand, string sdkMethod, + DateTime? timestamp = null) { Emit(new Dictionary { @@ -33,12 +34,14 @@ public static void LogHttpRequest(string method, string url, { "headers", headers ?? new Dictionary() }, { "body", body ?? "" }, { "curlCommand", curlCommand ?? "" }, - { "sdkMethod", sdkMethod ?? "" } + { "sdkMethod", sdkMethod ?? "" }, + { "timestamp", (timestamp ?? DateTime.UtcNow).ToString("yyyy-MM-ddTHH:mm:ss.fffZ") } }); } public static void LogHttpResponse(int statusCode, string statusText, - IDictionary headers, string body) + IDictionary headers, string body, + DateTime? timestamp = null, long durationMs = 0) { Emit(new Dictionary { @@ -46,7 +49,9 @@ public static void LogHttpResponse(int statusCode, string statusText, { "statusCode", statusCode }, { "statusText", statusText ?? "" }, { "headers", headers ?? new Dictionary() }, - { "body", body ?? "" } + { "body", body ?? "" }, + { "timestamp", (timestamp ?? DateTime.UtcNow).ToString("yyyy-MM-ddTHH:mm:ss.fffZ") }, + { "durationMs", durationMs } }); } diff --git a/Contentstack.Management.Core.Tests/IntegrationTest/Contentstack005_LocaleTest.cs b/Contentstack.Management.Core.Tests/IntegrationTest/Contentstack005_LocaleTest.cs new file mode 100644 index 0000000..b2cc8f1 --- /dev/null +++ b/Contentstack.Management.Core.Tests/IntegrationTest/Contentstack005_LocaleTest.cs @@ -0,0 +1,409 @@ +using System; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using Contentstack.Management.Core; +using Contentstack.Management.Core.Models; +using Contentstack.Management.Core.Exceptions; +using Contentstack.Management.Core.Tests.Helpers; +using Contentstack.Management.Core.Tests.Model; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Newtonsoft.Json.Linq; + +namespace Contentstack.Management.Core.Tests.IntegrationTest +{ + [TestClass] + [DoNotParallelize] + public class Contentstack005_LocaleTest + { + private static ContentstackClient _client; + private Stack _stack; + + // Codes stored between tests to support sequential fetch/update/delete flows + private static string _localeCode = "fr-fr"; + private static string _asyncLocaleCode = "de-de"; + + [ClassInitialize] + public static void ClassInitialize(TestContext context) + { + _client = Contentstack.CreateAuthenticatedClient(); + } + + [ClassCleanup] + public static void ClassCleanup() + { + try { _client?.Logout(); } catch { } + _client = null; + } + + [TestInitialize] + public void Initialize() + { + StackResponse response = StackResponse.getStack(_client.serializer); + _stack = _client.Stack(response.Stack.APIKey); + } + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + private void SafeDeleteLocale(string code) + { + if (string.IsNullOrEmpty(code)) + { + return; + } + + try + { + _stack.Locale(code).Delete(); + } + catch + { + // Best-effort cleanup; ignore if already deleted or is master locale + } + } + + private static bool LocaleArrayContainsCode(JArray locales, string code) + { + if (locales == null || string.IsNullOrEmpty(code)) + { + return false; + } + + return locales.Any(l => l["code"]?.ToString() == code); + } + + // --------------------------------------------------------------------------- + // A — Sync happy-path: Create + // --------------------------------------------------------------------------- + + [TestMethod] + [DoNotParallelize] + public void Test001_Should_Create_Locale_Sync() + { + TestOutputLogger.LogContext("TestScenario", "Test001_Should_Create_Locale_Sync"); + try + { + var model = new LocaleModel { Name = "French - France", Code = "fr-fr" }; + ContentstackResponse response = _stack.Locale("fr-fr").Create(model); + + AssertLogger.IsTrue(response.IsSuccessStatusCode, "Create locale sync should succeed", "CreateSyncSuccess"); + + var jo = response.OpenJObjectResponse(); + AssertLogger.IsNotNull(jo, "response body"); + AssertLogger.IsNotNull(jo["locale"], "locale object in response"); + + // Catches wrong code being saved by the API + AssertLogger.AreEqual("fr-fr", jo["locale"]?["code"]?.ToString(), "locale.code must equal 'fr-fr'", "LocaleCode"); + + // Catches missing name in the response + string returnedName = jo["locale"]?["name"]?.ToString(); + AssertLogger.IsTrue(!string.IsNullOrEmpty(returnedName), "locale.name must not be empty", "LocaleNameNotEmpty"); + + _localeCode = "fr-fr"; + TestOutputLogger.LogContext("_localeCode", _localeCode); + } + catch (Exception e) + { + AssertLogger.Fail(e.Message); + } + } + + // --------------------------------------------------------------------------- + // B — Async happy-path: Create + // --------------------------------------------------------------------------- + + [TestMethod] + [DoNotParallelize] + public async Task Test002_Should_Create_Locale_Async() + { + TestOutputLogger.LogContext("TestScenario", "Test002_Should_Create_Locale_Async"); + try + { + var model = new LocaleModel { Name = "German", Code = "de-de" }; + ContentstackResponse response = await _stack.Locale("de-de").CreateAsync(model); + + AssertLogger.IsTrue(response.IsSuccessStatusCode, "Create locale async should succeed", "CreateAsyncSuccess"); + + var jo = response.OpenJObjectResponse(); + AssertLogger.IsNotNull(jo, "response body"); + AssertLogger.IsNotNull(jo["locale"], "locale object in response"); + + // Catches wrong code being saved asynchronously + AssertLogger.AreEqual("de-de", jo["locale"]?["code"]?.ToString(), "locale.code must equal 'de-de'", "AsyncLocaleCode"); + + // Catches missing name in async response + string returnedName = jo["locale"]?["name"]?.ToString(); + AssertLogger.IsTrue(!string.IsNullOrEmpty(returnedName), "locale.name must not be empty", "AsyncLocaleNameNotEmpty"); + + _asyncLocaleCode = "de-de"; + TestOutputLogger.LogContext("_asyncLocaleCode", _asyncLocaleCode); + } + catch (Exception e) + { + AssertLogger.Fail(e.Message); + } + } + + // --------------------------------------------------------------------------- + // C — Sync happy-path: Fetch + // --------------------------------------------------------------------------- + + [TestMethod] + [DoNotParallelize] + public void Test003_Should_Fetch_Locale_Sync() + { + TestOutputLogger.LogContext("TestScenario", "Test003_Should_Fetch_Locale_Sync"); + try + { + ContentstackResponse response = _stack.Locale(_localeCode).Fetch(); + + AssertLogger.IsTrue(response.IsSuccessStatusCode, "Fetch locale sync should succeed", "FetchSyncSuccess"); + + var jo = response.OpenJObjectResponse(); + AssertLogger.IsNotNull(jo, "response body"); + AssertLogger.IsNotNull(jo["locale"], "locale object in fetch response"); + + // Round-trip code check: catches if the API returns a different locale + AssertLogger.AreEqual(_localeCode, jo["locale"]?["code"]?.ToString(), "fetched locale.code must match requested code", "FetchedCode"); + + // Name must be a non-empty string + string name = jo["locale"]?["name"]?.ToString(); + AssertLogger.IsTrue(!string.IsNullOrEmpty(name), "fetched locale.name must be a non-empty string", "FetchedNameNotEmpty"); + } + catch (Exception e) + { + AssertLogger.Fail(e.Message); + } + } + + // --------------------------------------------------------------------------- + // D — Async happy-path: Fetch + // --------------------------------------------------------------------------- + + [TestMethod] + [DoNotParallelize] + public async Task Test004_Should_Fetch_Locale_Async() + { + TestOutputLogger.LogContext("TestScenario", "Test004_Should_Fetch_Locale_Async"); + try + { + ContentstackResponse response = await _stack.Locale(_asyncLocaleCode).FetchAsync(); + + AssertLogger.IsTrue(response.IsSuccessStatusCode, "Fetch locale async should succeed", "FetchAsyncSuccess"); + + var jo = response.OpenJObjectResponse(); + AssertLogger.IsNotNull(jo, "response body"); + AssertLogger.IsNotNull(jo["locale"], "locale object in async fetch response"); + + // Round-trip code check for the async locale + AssertLogger.AreEqual(_asyncLocaleCode, jo["locale"]?["code"]?.ToString(), "async fetched locale.code must match requested code", "AsyncFetchedCode"); + + string name = jo["locale"]?["name"]?.ToString(); + AssertLogger.IsTrue(!string.IsNullOrEmpty(name), "async fetched locale.name must not be empty", "AsyncFetchedNameNotEmpty"); + } + catch (Exception e) + { + AssertLogger.Fail(e.Message); + } + } + + // --------------------------------------------------------------------------- + // E — Sync happy-path: Update (fallback locale) + // --------------------------------------------------------------------------- + + [TestMethod] + [DoNotParallelize] + public void Test005_Should_Update_FallbackLocale_Sync() + { + TestOutputLogger.LogContext("TestScenario", "Test005_Should_Update_FallbackLocale_Sync"); + try + { + var updateModel = new LocaleModel { FallbackLocale = "en-us" }; + ContentstackResponse response = _stack.Locale(_localeCode).Update(updateModel); + + AssertLogger.IsTrue(response.IsSuccessStatusCode, "Update locale sync should succeed", "UpdateSyncSuccess"); + + var jo = response.OpenJObjectResponse(); + AssertLogger.IsNotNull(jo, "response body"); + AssertLogger.IsNotNull(jo["locale"], "locale object in update response"); + + // Catches if fallback_locale was not persisted correctly + AssertLogger.AreEqual("en-us", jo["locale"]?["fallback_locale"]?.ToString(), "fallback_locale must be 'en-us' after update", "FallbackLocale"); + + // Catches if the update accidentally mutated the locale code + AssertLogger.AreEqual(_localeCode, jo["locale"]?["code"]?.ToString(), "locale.code must remain unchanged after update", "CodeUnchangedAfterUpdate"); + } + catch (Exception e) + { + AssertLogger.Fail(e.Message); + } + } + + // --------------------------------------------------------------------------- + // F — Async happy-path: Update (fallback locale) + // --------------------------------------------------------------------------- + + [TestMethod] + [DoNotParallelize] + public async Task Test006_Should_Update_Locale_Async() + { + TestOutputLogger.LogContext("TestScenario", "Test006_Should_Update_Locale_Async"); + try + { + var updateModel = new LocaleModel { FallbackLocale = "en-us" }; + ContentstackResponse response = await _stack.Locale(_asyncLocaleCode).UpdateAsync(updateModel); + + AssertLogger.IsTrue(response.IsSuccessStatusCode, "Update locale async should succeed", "UpdateAsyncSuccess"); + + var jo = response.OpenJObjectResponse(); + AssertLogger.IsNotNull(jo, "response body"); + AssertLogger.IsNotNull(jo["locale"], "locale object in async update response"); + + // Catches if async update did not save fallback_locale + AssertLogger.AreEqual("en-us", jo["locale"]?["fallback_locale"]?.ToString(), "async fallback_locale must be 'en-us' after update", "AsyncFallbackLocale"); + + // Code must be intact after async update + AssertLogger.AreEqual(_asyncLocaleCode, jo["locale"]?["code"]?.ToString(), "async locale.code must remain unchanged", "AsyncCodeUnchangedAfterUpdate"); + } + catch (Exception e) + { + AssertLogger.Fail(e.Message); + } + } + + // --------------------------------------------------------------------------- + // G — Query all locales + // --------------------------------------------------------------------------- + + [TestMethod] + [DoNotParallelize] + public void Test007_Should_Query_All_Locales() + { + TestOutputLogger.LogContext("TestScenario", "Test007_Should_Query_All_Locales"); + try + { + ContentstackResponse response = _stack.Locale("").Query().Find(); + + AssertLogger.IsTrue(response.IsSuccessStatusCode, "Query locales should succeed", "QuerySuccess"); + + var jo = response.OpenJObjectResponse(); + AssertLogger.IsNotNull(jo, "response body"); + AssertLogger.IsNotNull(jo["locales"], "locales array in query response"); + + var locales = jo["locales"] as JArray; + AssertLogger.IsNotNull(locales, "locales must be a JSON array"); + + // en-us (default) + fr-fr + de-de means at least 3; we check for >= 2 to be resilient + AssertLogger.IsTrue(locales.Count >= 2, $"Expected at least 2 locales (en-us + created), found {locales.Count}", "LocaleCountAtLeastTwo"); + + // Verify the locale we created in Test001 is visible in the list + AssertLogger.IsTrue( + LocaleArrayContainsCode(locales, "fr-fr"), + "Query result must contain 'fr-fr' locale created earlier", + "QueryContainsFrFr"); + } + catch (Exception e) + { + AssertLogger.Fail(e.Message); + } + } + + // --------------------------------------------------------------------------- + // H — Sync happy-path: Delete (de-de) + // --------------------------------------------------------------------------- + + [TestMethod] + [DoNotParallelize] + public void Test008_Should_Delete_Locale_Sync() + { + TestOutputLogger.LogContext("TestScenario", "Test008_Should_Delete_Locale_Sync"); + try + { + ContentstackResponse response = _stack.Locale(_asyncLocaleCode).Delete(); + + // A successful delete must return a 2xx status; any exception = failure + AssertLogger.IsTrue(response.IsSuccessStatusCode, $"Delete locale '{_asyncLocaleCode}' sync should succeed", "DeleteSyncSuccess"); + } + catch (Exception e) + { + AssertLogger.Fail(e.Message); + } + } + + // --------------------------------------------------------------------------- + // I — Async happy-path: Delete (fr-fr) + // --------------------------------------------------------------------------- + + [TestMethod] + [DoNotParallelize] + public async Task Test009_Should_Delete_Locale_Async() + { + TestOutputLogger.LogContext("TestScenario", "Test009_Should_Delete_Locale_Async"); + try + { + ContentstackResponse response = await _stack.Locale(_localeCode).DeleteAsync(); + + AssertLogger.IsTrue(response.IsSuccessStatusCode, $"Delete locale '{_localeCode}' async should succeed", "DeleteAsyncSuccess"); + } + catch (Exception e) + { + AssertLogger.Fail(e.Message); + } + } + + // --------------------------------------------------------------------------- + // J — Negative: Fetch a locale that does not exist + // --------------------------------------------------------------------------- + + [TestMethod] + [DoNotParallelize] + public void Test010_Should_Throw_On_Fetch_NonExistent_Locale() + { + TestOutputLogger.LogContext("TestScenario", "Test010_Should_Throw_On_Fetch_NonExistent_Locale"); + + AssertLogger.ThrowsContentstackError( + () => _stack.Locale("xx-xx").Fetch(), + "FetchNonExistentLocale", + HttpStatusCode.NotFound, + (HttpStatusCode)422); + } + + // --------------------------------------------------------------------------- + // K — Negative: Delete master locale (en-us) must be rejected + // --------------------------------------------------------------------------- + + [TestMethod] + [DoNotParallelize] + public void Test011_Should_Throw_On_Delete_Master_Locale() + { + TestOutputLogger.LogContext("TestScenario", "Test011_Should_Throw_On_Delete_Master_Locale"); + + // The Contentstack API must refuse deletion of the master locale (en-us). + // Any 4xx or exception confirms the constraint is enforced. + try + { + ContentstackResponse response = _stack.Locale("en-us").Delete(); + + // If no exception was raised, the API should still have returned a non-2xx code + AssertLogger.IsFalse(response.IsSuccessStatusCode, "Deleting master locale 'en-us' must not succeed", "MasterLocaleDeleteMustFail"); + } + catch (ContentstackErrorException ex) + { + // Any API error (4xx/5xx) confirms the master locale is protected + AssertLogger.IsTrue( + (int)ex.StatusCode >= 400, + $"Expected a 4xx/5xx error when deleting master locale, but got {ex.StatusCode}", + "MasterLocaleDeleteErrorStatus"); + TestOutputLogger.LogContext("MasterLocaleDeleteErrorCode", ex.StatusCode.ToString()); + } + catch (Exception e) + { + // Any other exception is also acceptable evidence of rejection + TestOutputLogger.LogContext("MasterLocaleDeleteException", e.GetType().Name); + AssertLogger.IsTrue(true, "Non-ContentstackErrorException also confirms rejection", "MasterLocaleDeleteRejected"); + } + } + } +} diff --git a/Contentstack.Management.Core.Tests/IntegrationTest/Contentstack006_WebhookTest.cs b/Contentstack.Management.Core.Tests/IntegrationTest/Contentstack006_WebhookTest.cs new file mode 100644 index 0000000..2a26d69 --- /dev/null +++ b/Contentstack.Management.Core.Tests/IntegrationTest/Contentstack006_WebhookTest.cs @@ -0,0 +1,502 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Threading.Tasks; +using Contentstack.Management.Core; +using Contentstack.Management.Core.Models; +using Contentstack.Management.Core.Exceptions; +using Contentstack.Management.Core.Tests.Helpers; +using Contentstack.Management.Core.Tests.Model; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Newtonsoft.Json.Linq; + +namespace Contentstack.Management.Core.Tests.IntegrationTest +{ + [TestClass] + [DoNotParallelize] + public class Contentstack006_WebhookTest + { + private static ContentstackClient _client; + private static Stack _stack; + private static string _webhookUid; + private static string _asyncWebhookUid; + + private const string WebhookNameSync = "DotNet SDK Integration Webhook Sync"; + private const string WebhookNameAsync = "DotNet SDK Integration Webhook Async"; + + [ClassInitialize] + public static void ClassInitialize(TestContext context) + { + _client = Contentstack.CreateAuthenticatedClient(); + StackResponse stackResponse = StackResponse.getStack(_client.serializer); + _stack = _client.Stack(stackResponse.Stack.APIKey); + } + + [ClassCleanup] + public static void ClassCleanup() + { + SafeDelete(_webhookUid); + SafeDelete(_asyncWebhookUid); + try { _client?.Logout(); } catch { } + _client = null; + } + + // ─── Private helpers ────────────────────────────────────────────────────── + + private static WebhookModel BuildWebhookModel(string name) + { + return new WebhookModel + { + Name = name, + destinations = new List + { + new WebhookTarget + { + TargetUrl = "https://example.com/hook", + HttpBasicAuth = null, + HttpBasicPassword = null + } + }, + Channels = new List { "content_type.create", "entry.publish" }, + Branches = new List { "main" }, + RetryPolicy = "manual", + Disabled = false, + ConcisePayload = true + }; + } + + private static string ParseWebhookUid(ContentstackResponse response) + { + return response.OpenJObjectResponse()["webhook"]["uid"].ToString(); + } + + private static void SafeDelete(string uid) + { + if (string.IsNullOrEmpty(uid)) return; + try { _stack.Webhook(uid).Delete(); } catch { } + } + + // ─── Tests ──────────────────────────────────────────────────────────────── + + [TestMethod] + [DoNotParallelize] + public void Test001_Should_Create_Webhook_Sync() + { + TestOutputLogger.LogContext("TestScenario", "Test001_Should_Create_Webhook_Sync"); + try + { + WebhookModel model = BuildWebhookModel(WebhookNameSync); + + ContentstackResponse response = _stack.Webhook("").Create(model); + + AssertLogger.IsTrue(response.IsSuccessStatusCode, "Create webhook (sync) must succeed", "CreateWebhookSyncSuccess"); + + JObject body = response.OpenJObjectResponse(); + AssertLogger.IsNotNull(body["webhook"], "Response must contain 'webhook' object", "WebhookObjectPresent"); + + string uid = body["webhook"]["uid"]?.ToString(); + AssertLogger.IsTrue(!string.IsNullOrEmpty(uid), "Webhook UID must be non-empty", "WebhookUidNonEmpty"); + + string returnedName = body["webhook"]["name"]?.ToString(); + AssertLogger.AreEqual(WebhookNameSync, returnedName, "Returned name must match the requested name", "WebhookNameMatch"); + + var channels = body["webhook"]["channels"] as JArray; + AssertLogger.IsNotNull(channels, "Webhook channels must be present in response", "WebhookChannelsPresent"); + + bool hasEntryPublish = false; + foreach (var ch in channels) + { + if (ch.ToString() == "entry.publish") { hasEntryPublish = true; break; } + } + AssertLogger.IsTrue(hasEntryPublish, "channels must contain 'entry.publish'", "ChannelsContainEntryPublish"); + + _webhookUid = uid; + TestOutputLogger.LogContext("WebhookUid", _webhookUid ?? ""); + } + catch (Exception ex) + { + AssertLogger.Fail($"Test001_Should_Create_Webhook_Sync failed: {ex.Message}"); + } + } + + [TestMethod] + [DoNotParallelize] + public async Task Test002_Should_Create_Webhook_Async() + { + TestOutputLogger.LogContext("TestScenario", "Test002_Should_Create_Webhook_Async"); + try + { + WebhookModel model = BuildWebhookModel(WebhookNameAsync); + + ContentstackResponse response = await _stack.Webhook("").CreateAsync(model); + + AssertLogger.IsTrue(response.IsSuccessStatusCode, "Create webhook (async) must succeed", "CreateWebhookAsyncSuccess"); + + JObject body = response.OpenJObjectResponse(); + AssertLogger.IsNotNull(body["webhook"], "Response must contain 'webhook' object", "AsyncWebhookObjectPresent"); + + string uid = body["webhook"]["uid"]?.ToString(); + AssertLogger.IsTrue(!string.IsNullOrEmpty(uid), "Async webhook UID must be non-empty", "AsyncWebhookUidNonEmpty"); + + string returnedName = body["webhook"]["name"]?.ToString(); + AssertLogger.AreEqual(WebhookNameAsync, returnedName, "Returned name must match the requested name", "AsyncWebhookNameMatch"); + + _asyncWebhookUid = uid; + TestOutputLogger.LogContext("AsyncWebhookUid", _asyncWebhookUid ?? ""); + } + catch (Exception ex) + { + AssertLogger.Fail($"Test002_Should_Create_Webhook_Async failed: {ex.Message}"); + } + } + + [TestMethod] + [DoNotParallelize] + public void Test003_Should_Fetch_Webhook_Sync() + { + TestOutputLogger.LogContext("TestScenario", "Test003_Should_Fetch_Webhook_Sync"); + try + { + if (string.IsNullOrEmpty(_webhookUid)) + { + Test001_Should_Create_Webhook_Sync(); + } + + AssertLogger.IsTrue(!string.IsNullOrEmpty(_webhookUid), "Pre-condition: _webhookUid must be set before fetch", "WebhookUidPreCondition"); + TestOutputLogger.LogContext("WebhookUid", _webhookUid ?? ""); + + ContentstackResponse response = _stack.Webhook(_webhookUid).Fetch(); + + AssertLogger.IsTrue(response.IsSuccessStatusCode, "Fetch webhook (sync) must succeed", "FetchWebhookSyncSuccess"); + + JObject body = response.OpenJObjectResponse(); + AssertLogger.IsNotNull(body["webhook"], "Response must contain 'webhook' object", "FetchWebhookObjectPresent"); + + string returnedUid = body["webhook"]["uid"]?.ToString(); + AssertLogger.AreEqual(_webhookUid, returnedUid, "Fetched UID must match the created UID (round-trip)", "FetchWebhookUidRoundTrip"); + + string returnedName = body["webhook"]["name"]?.ToString(); + AssertLogger.IsTrue(!string.IsNullOrEmpty(returnedName), "Fetched webhook name must be non-empty", "FetchWebhookNameNonEmpty"); + } + catch (Exception ex) + { + AssertLogger.Fail($"Test003_Should_Fetch_Webhook_Sync failed: {ex.Message}"); + } + } + + [TestMethod] + [DoNotParallelize] + public async Task Test004_Should_Fetch_Webhook_Async() + { + TestOutputLogger.LogContext("TestScenario", "Test004_Should_Fetch_Webhook_Async"); + try + { + if (string.IsNullOrEmpty(_asyncWebhookUid)) + { + await Test002_Should_Create_Webhook_Async(); + } + + AssertLogger.IsTrue(!string.IsNullOrEmpty(_asyncWebhookUid), "Pre-condition: _asyncWebhookUid must be set before fetch", "AsyncWebhookUidPreCondition"); + TestOutputLogger.LogContext("AsyncWebhookUid", _asyncWebhookUid ?? ""); + + ContentstackResponse response = await _stack.Webhook(_asyncWebhookUid).FetchAsync(); + + AssertLogger.IsTrue(response.IsSuccessStatusCode, "Fetch webhook (async) must succeed", "FetchWebhookAsyncSuccess"); + + JObject body = response.OpenJObjectResponse(); + AssertLogger.IsNotNull(body["webhook"], "Response must contain 'webhook' object", "AsyncFetchWebhookObjectPresent"); + + string returnedUid = body["webhook"]["uid"]?.ToString(); + AssertLogger.AreEqual(_asyncWebhookUid, returnedUid, "Async fetched UID must match the created UID", "AsyncFetchWebhookUidMatch"); + } + catch (Exception ex) + { + AssertLogger.Fail($"Test004_Should_Fetch_Webhook_Async failed: {ex.Message}"); + } + } + + [TestMethod] + [DoNotParallelize] + public void Test005_Should_Update_Webhook_Name_Sync() + { + TestOutputLogger.LogContext("TestScenario", "Test005_Should_Update_Webhook_Name_Sync"); + try + { + if (string.IsNullOrEmpty(_webhookUid)) + { + Test001_Should_Create_Webhook_Sync(); + } + + AssertLogger.IsTrue(!string.IsNullOrEmpty(_webhookUid), "Pre-condition: _webhookUid must be set before update", "UpdateWebhookUidPreCondition"); + TestOutputLogger.LogContext("WebhookUid", _webhookUid ?? ""); + + string updatedName = "Updated_" + WebhookNameSync; + WebhookModel updateModel = BuildWebhookModel(updatedName); + + ContentstackResponse response = _stack.Webhook(_webhookUid).Update(updateModel); + + AssertLogger.IsTrue(response.IsSuccessStatusCode, "Update webhook (sync) must succeed", "UpdateWebhookSyncSuccess"); + + JObject body = response.OpenJObjectResponse(); + AssertLogger.IsNotNull(body["webhook"], "Update response must contain 'webhook' object", "UpdateWebhookObjectPresent"); + + string returnedName = body["webhook"]["name"]?.ToString(); + + // Catches update-not-persisted bug: the new name must be reflected in the response + AssertLogger.AreEqual(updatedName, returnedName, "Updated name must equal the new name sent in the request", "UpdatedNameEqualsNewName"); + + // Catches stale-name bug: the returned name must not be the original name + AssertLogger.IsTrue(returnedName != WebhookNameSync, "Updated name must differ from the original name", "UpdatedNameDiffersFromOriginal"); + } + catch (Exception ex) + { + AssertLogger.Fail($"Test005_Should_Update_Webhook_Name_Sync failed: {ex.Message}"); + } + } + + [TestMethod] + [DoNotParallelize] + public async Task Test006_Should_Update_Webhook_Channels_Async() + { + TestOutputLogger.LogContext("TestScenario", "Test006_Should_Update_Webhook_Channels_Async"); + try + { + if (string.IsNullOrEmpty(_asyncWebhookUid)) + { + await Test002_Should_Create_Webhook_Async(); + } + + AssertLogger.IsTrue(!string.IsNullOrEmpty(_asyncWebhookUid), "Pre-condition: _asyncWebhookUid must be set before update", "AsyncUpdateWebhookUidPreCondition"); + TestOutputLogger.LogContext("AsyncWebhookUid", _asyncWebhookUid ?? ""); + + // Add "entry.delete" to the channel list (original has 2 entries) + WebhookModel updateModel = new WebhookModel + { + Name = WebhookNameAsync, + destinations = new List + { + new WebhookTarget { TargetUrl = "https://example.com/hook" } + }, + Channels = new List { "content_type.create", "entry.publish", "entry.delete" }, + Branches = new List { "main" }, + RetryPolicy = "manual", + Disabled = false, + ConcisePayload = true + }; + + ContentstackResponse response = await _stack.Webhook(_asyncWebhookUid).UpdateAsync(updateModel); + + AssertLogger.IsTrue(response.IsSuccessStatusCode, "Update webhook channels (async) must succeed", "AsyncUpdateChannelsSuccess"); + + JObject body = response.OpenJObjectResponse(); + AssertLogger.IsNotNull(body["webhook"], "Update response must contain 'webhook' object", "AsyncUpdateWebhookObjectPresent"); + + var channels = body["webhook"]["channels"] as JArray; + AssertLogger.IsNotNull(channels, "Updated response must contain channels", "AsyncUpdatedChannelsPresent"); + AssertLogger.IsTrue(channels.Count > 1, $"Channels count must be > 1 after adding 'entry.delete', got {channels.Count}", "AsyncChannelsCountGreaterThan1"); + } + catch (Exception ex) + { + AssertLogger.Fail($"Test006_Should_Update_Webhook_Channels_Async failed: {ex.Message}"); + } + } + + [TestMethod] + [DoNotParallelize] + public void Test007_Should_Query_Webhooks_Returns_Created() + { + TestOutputLogger.LogContext("TestScenario", "Test007_Should_Query_Webhooks_Returns_Created"); + try + { + // Ensure both webhooks exist + if (string.IsNullOrEmpty(_webhookUid)) + { + Test001_Should_Create_Webhook_Sync(); + } + if (string.IsNullOrEmpty(_asyncWebhookUid)) + { + Test002_Should_Create_Webhook_Async().GetAwaiter().GetResult(); + } + + ContentstackResponse response = _stack.Webhook("").Query().Find(); + + AssertLogger.IsTrue(response.IsSuccessStatusCode, "Query webhooks must succeed", "QueryWebhooksSuccess"); + + JObject body = response.OpenJObjectResponse(); + AssertLogger.IsNotNull(body["webhooks"], "Query response must contain 'webhooks' array", "QueryWebhooksArrayPresent"); + + var webhooks = body["webhooks"] as JArray; + AssertLogger.IsNotNull(webhooks, "'webhooks' must be a JSON array", "QueryWebhooksIsArray"); + AssertLogger.IsTrue(webhooks.Count >= 2, $"Query must return at least 2 webhooks (the ones created by this test), got {webhooks.Count}", "QueryWebhooksCountAtLeast2"); + } + catch (Exception ex) + { + AssertLogger.Fail($"Test007_Should_Query_Webhooks_Returns_Created failed: {ex.Message}"); + } + } + + [TestMethod] + [DoNotParallelize] + public void Test008_Should_Delete_Webhook_Sync() + { + TestOutputLogger.LogContext("TestScenario", "Test008_Should_Delete_Webhook_Sync"); + try + { + if (string.IsNullOrEmpty(_asyncWebhookUid)) + { + Test002_Should_Create_Webhook_Async().GetAwaiter().GetResult(); + } + + AssertLogger.IsTrue(!string.IsNullOrEmpty(_asyncWebhookUid), "Pre-condition: _asyncWebhookUid must be set before delete", "DeleteAsyncWebhookUidPreCondition"); + TestOutputLogger.LogContext("AsyncWebhookUid", _asyncWebhookUid ?? ""); + + ContentstackResponse deleteResponse = _stack.Webhook(_asyncWebhookUid).Delete(); + AssertLogger.IsTrue(deleteResponse.IsSuccessStatusCode, "Delete webhook (sync) must succeed", "DeleteWebhookSyncSuccess"); + + string deletedUid = _asyncWebhookUid; + _asyncWebhookUid = null; // Mark as cleaned up so ClassCleanup skips it + + // Immediately try to fetch — expect a 404 ContentstackErrorException + bool notFoundThrown = false; + try + { + _stack.Webhook(deletedUid).Fetch(); + } + catch (ContentstackErrorException ex) + { + notFoundThrown = true; + AssertLogger.IsTrue( + ex.StatusCode == HttpStatusCode.NotFound || (int)ex.StatusCode == 422, + $"Expected 404 or 422 after deleting webhook, got {ex.StatusCode}", + "DeletedWebhookFetchReturns404"); + } + catch (Exception ex) + { + notFoundThrown = true; + Console.WriteLine($"Non-Contentstack exception when fetching deleted webhook (acceptable): {ex.Message}"); + } + + AssertLogger.IsTrue(notFoundThrown, "Fetching a deleted webhook must throw an exception", "FetchDeletedWebhookThrows"); + } + catch (Exception ex) + { + AssertLogger.Fail($"Test008_Should_Delete_Webhook_Sync failed: {ex.Message}"); + } + } + + [TestMethod] + [DoNotParallelize] + public async Task Test009_Should_Delete_Webhook_Async() + { + TestOutputLogger.LogContext("TestScenario", "Test009_Should_Delete_Webhook_Async"); + try + { + if (string.IsNullOrEmpty(_webhookUid)) + { + Test001_Should_Create_Webhook_Sync(); + } + + AssertLogger.IsTrue(!string.IsNullOrEmpty(_webhookUid), "Pre-condition: _webhookUid must be set before async delete", "AsyncDeleteWebhookUidPreCondition"); + TestOutputLogger.LogContext("WebhookUid", _webhookUid ?? ""); + + ContentstackResponse deleteResponse = await _stack.Webhook(_webhookUid).DeleteAsync(); + AssertLogger.IsTrue(deleteResponse.IsSuccessStatusCode, "Delete webhook (async) must succeed", "DeleteWebhookAsyncSuccess"); + + _webhookUid = null; // Mark as cleaned up so ClassCleanup skips it + } + catch (Exception ex) + { + AssertLogger.Fail($"Test009_Should_Delete_Webhook_Async failed: {ex.Message}"); + } + } + + [TestMethod] + [DoNotParallelize] + public void Test010_Should_Throw_On_Fetch_NonExistent_Webhook() + { + TestOutputLogger.LogContext("TestScenario", "Test010_Should_Throw_On_Fetch_NonExistent_Webhook"); + try + { + AssertLogger.ThrowsContentstackError( + () => _stack.Webhook("blt_nonexistent_xyz").Fetch(), + "FetchNonExistentWebhook", + HttpStatusCode.NotFound, + (HttpStatusCode)422); + } + catch (Exception ex) + { + AssertLogger.Fail($"Test010_Should_Throw_On_Fetch_NonExistent_Webhook failed: {ex.Message}"); + } + } + + [TestMethod] + [DoNotParallelize] + public void Test011_Should_Throw_On_Create_Without_Destinations() + { + TestOutputLogger.LogContext("TestScenario", "Test011_Should_Throw_On_Create_Without_Destinations"); + try + { + var model = new WebhookModel + { + Name = "Webhook Without Destinations", + destinations = new List(), // empty — API should reject this + Channels = new List { "entry.publish" }, + Branches = new List { "main" }, + RetryPolicy = "manual", + Disabled = false, + ConcisePayload = true + }; + + bool errorThrown = false; + try + { + ContentstackResponse response = _stack.Webhook("").Create(model); + + // If no exception was thrown, verify the response is an error + if (!response.IsSuccessStatusCode) + { + errorThrown = true; + AssertLogger.IsTrue( + response.StatusCode == HttpStatusCode.BadRequest || + (int)response.StatusCode == 422, + $"Expected 400 or 422 for webhook without destinations, got {response.StatusCode}", + "CreateWithoutDestinationsStatusCode"); + } + else + { + // If API accepted it, clean it up and note the behaviour + JObject body = response.OpenJObjectResponse(); + string uid = body["webhook"]?["uid"]?.ToString(); + if (!string.IsNullOrEmpty(uid)) + { + SafeDelete(uid); + } + Console.WriteLine("API accepted webhook with empty destinations — adjust test expectation if intentional."); + // Mark as passed since the API allowed it (soft assertion) + errorThrown = true; + } + } + catch (ContentstackErrorException ex) + { + errorThrown = true; + AssertLogger.IsTrue( + ex.StatusCode == HttpStatusCode.BadRequest || (int)ex.StatusCode == 422, + $"Expected 400 or 422 for webhook without destinations, got {ex.StatusCode}", + "CreateWithoutDestinationsException"); + TestOutputLogger.LogContext("CreateWithoutDestinationsError", $"StatusCode={ex.StatusCode}, Message={ex.Message}"); + } + catch (Exception ex) + { + errorThrown = true; + Console.WriteLine($"Non-Contentstack exception when creating webhook without destinations: {ex.Message}"); + } + + AssertLogger.IsTrue(errorThrown, "Creating a webhook with empty destinations must result in an error", "CreateWithoutDestinationsRejected"); + } + catch (Exception ex) + { + AssertLogger.Fail($"Test011_Should_Throw_On_Create_Without_Destinations failed: {ex.Message}"); + } + } + } +} diff --git a/Contentstack.Management.Core.Tests/IntegrationTest/Contentstack007_ExtensionTest.cs b/Contentstack.Management.Core.Tests/IntegrationTest/Contentstack007_ExtensionTest.cs new file mode 100644 index 0000000..d9cf2fa --- /dev/null +++ b/Contentstack.Management.Core.Tests/IntegrationTest/Contentstack007_ExtensionTest.cs @@ -0,0 +1,466 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Threading.Tasks; +using Contentstack.Management.Core; +using Contentstack.Management.Core.Models; +using Contentstack.Management.Core.Exceptions; +using Contentstack.Management.Core.Tests.Helpers; +using Contentstack.Management.Core.Tests.Model; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Newtonsoft.Json.Linq; + +namespace Contentstack.Management.Core.Tests.IntegrationTest +{ + [TestClass] + [DoNotParallelize] + public class Contentstack007_ExtensionTest + { + private static ContentstackClient _client; + private static Stack _stack; + + // UIDs for resources created in early tests, shared across later tests + private static string _customFieldUid; + private static string _widgetUid; + private static string _originalCustomFieldTitle; + + [ClassInitialize] + public static void ClassInitialize(TestContext context) + { + _client = Contentstack.CreateAuthenticatedClient(); + StackResponse stackResponse = StackResponse.getStack(_client.serializer); + _stack = _client.Stack(stackResponse.Stack.APIKey); + } + + [ClassCleanup] + public static void ClassCleanup() + { + SafeDelete(_customFieldUid); + SafeDelete(_widgetUid); + try { _client?.Logout(); } catch { } + _client = null; + } + + // --------------------------------------------------------------------------- + // Private helpers + // --------------------------------------------------------------------------- + + private static ExtensionModel BuildCustomFieldModel(string title) + { + return new ExtensionModel + { + Title = title, + Type = "field", + DataType = "text", + Srcdoc = "", + Tags = new List { "test" } + }; + } + + private static ExtensionModel BuildWidgetModel(string title) + { + return new ExtensionModel + { + Title = title, + Type = "widget", + Srcdoc = "Widget" + }; + } + + private static string ParseExtensionUid(ContentstackResponse response) + { + return response.OpenJObjectResponse()?["extension"]?["uid"]?.ToString(); + } + + private static void SafeDelete(string uid) + { + if (string.IsNullOrEmpty(uid)) return; + try { _stack.Extension(uid).Delete(); } catch { } + } + + // --------------------------------------------------------------------------- + // Test001 — Create custom field (sync) + // --------------------------------------------------------------------------- + + [TestMethod] + [DoNotParallelize] + public void Test001_Should_Create_CustomField_Sync() + { + TestOutputLogger.LogContext("TestScenario", "Test001_Should_Create_CustomField_Sync"); + + string title = $"Custom Field {Guid.NewGuid():N}"; + _originalCustomFieldTitle = title; + + try + { + var model = BuildCustomFieldModel(title); + ContentstackResponse response = _stack.Extension().Create(model); + + AssertLogger.IsTrue(response.IsSuccessStatusCode, "Create custom field should succeed", "CreateCustomFieldSuccess"); + + var jo = response.OpenJObjectResponse(); + AssertLogger.IsNotNull(jo?["extension"], "Response should contain extension object", "ExtensionObject"); + + _customFieldUid = jo["extension"]["uid"]?.ToString(); + AssertLogger.IsTrue(!string.IsNullOrEmpty(_customFieldUid), "Extension UID should not be empty", "UidNotEmpty"); + + AssertLogger.AreEqual("field", jo["extension"]["type"]?.ToString(), "Type should be 'field'", "TypeIsField"); + AssertLogger.AreEqual(title, jo["extension"]["title"]?.ToString(), "Title should match input", "TitleMatch"); + + TestOutputLogger.LogContext("CustomFieldUid", _customFieldUid); + } + catch (Exception ex) + { + AssertLogger.Fail($"Test001 failed: {ex.Message}"); + } + } + + // --------------------------------------------------------------------------- + // Test002 — Create widget (async) + // --------------------------------------------------------------------------- + + [TestMethod] + [DoNotParallelize] + public async Task Test002_Should_Create_Widget_Async() + { + TestOutputLogger.LogContext("TestScenario", "Test002_Should_Create_Widget_Async"); + + string title = $"Widget {Guid.NewGuid():N}"; + + try + { + var model = BuildWidgetModel(title); + ContentstackResponse response = await _stack.Extension().CreateAsync(model); + + AssertLogger.IsTrue(response.IsSuccessStatusCode, "CreateAsync widget should succeed", "CreateWidgetAsyncSuccess"); + + var jo = response.OpenJObjectResponse(); + AssertLogger.IsNotNull(jo?["extension"], "Response should contain extension object", "ExtensionObject"); + + _widgetUid = jo["extension"]["uid"]?.ToString(); + AssertLogger.IsTrue(!string.IsNullOrEmpty(_widgetUid), "Widget UID should not be empty", "WidgetUidNotEmpty"); + + AssertLogger.AreEqual("widget", jo["extension"]["type"]?.ToString(), "Type should be 'widget'", "TypeIsWidget"); + AssertLogger.AreEqual(title, jo["extension"]["title"]?.ToString(), "Title should match input", "TitleMatch"); + + TestOutputLogger.LogContext("WidgetUid", _widgetUid); + } + catch (Exception ex) + { + AssertLogger.Fail($"Test002 failed: {ex.Message}"); + } + } + + // --------------------------------------------------------------------------- + // Test003 — Fetch custom field by UID (sync), round-trip check + // --------------------------------------------------------------------------- + + [TestMethod] + [DoNotParallelize] + public void Test003_Should_Fetch_Extension_Sync() + { + TestOutputLogger.LogContext("TestScenario", "Test003_Should_Fetch_Extension_Sync"); + + if (string.IsNullOrEmpty(_customFieldUid)) + { + AssertLogger.Fail("Test003 requires _customFieldUid set by Test001."); + return; + } + + try + { + ContentstackResponse response = _stack.Extension(_customFieldUid).Fetch(); + + AssertLogger.IsTrue(response.IsSuccessStatusCode, "Fetch extension should succeed", "FetchSyncSuccess"); + + var jo = response.OpenJObjectResponse(); + AssertLogger.IsNotNull(jo?["extension"], "Response should contain extension object", "ExtensionObject"); + + string fetchedUid = jo["extension"]["uid"]?.ToString(); + AssertLogger.AreEqual(_customFieldUid, fetchedUid, "Fetched UID should match requested UID (round-trip)", "UidRoundTrip"); + AssertLogger.AreEqual("field", jo["extension"]["type"]?.ToString(), "Type should be 'field'", "TypeIsField"); + } + catch (Exception ex) + { + AssertLogger.Fail($"Test003 failed: {ex.Message}"); + } + } + + // --------------------------------------------------------------------------- + // Test004 — Fetch widget by UID (async) + // --------------------------------------------------------------------------- + + [TestMethod] + [DoNotParallelize] + public async Task Test004_Should_Fetch_Extension_Async() + { + TestOutputLogger.LogContext("TestScenario", "Test004_Should_Fetch_Extension_Async"); + + if (string.IsNullOrEmpty(_widgetUid)) + { + AssertLogger.Fail("Test004 requires _widgetUid set by Test002."); + return; + } + + try + { + ContentstackResponse response = await _stack.Extension(_widgetUid).FetchAsync(); + + AssertLogger.IsTrue(response.IsSuccessStatusCode, "FetchAsync widget should succeed", "FetchAsyncSuccess"); + + var jo = response.OpenJObjectResponse(); + AssertLogger.IsNotNull(jo?["extension"], "Response should contain extension object", "ExtensionObject"); + + AssertLogger.AreEqual("widget", jo["extension"]["type"]?.ToString(), "Fetched type should be 'widget'", "TypeIsWidget"); + AssertLogger.AreEqual(_widgetUid, jo["extension"]["uid"]?.ToString(), "Fetched UID should match", "UidMatch"); + } + catch (Exception ex) + { + AssertLogger.Fail($"Test004 failed: {ex.Message}"); + } + } + + // --------------------------------------------------------------------------- + // Test005 — Update custom field title (sync); proves the change was persisted + // --------------------------------------------------------------------------- + + [TestMethod] + [DoNotParallelize] + public void Test005_Should_Update_Extension_Title_Sync() + { + TestOutputLogger.LogContext("TestScenario", "Test005_Should_Update_Extension_Title_Sync"); + + if (string.IsNullOrEmpty(_customFieldUid)) + { + AssertLogger.Fail("Test005 requires _customFieldUid set by Test001."); + return; + } + + string updatedTitle = "Updated Custom Field"; + + try + { + var model = BuildCustomFieldModel(updatedTitle); + ContentstackResponse response = _stack.Extension(_customFieldUid).Update(model); + + AssertLogger.IsTrue(response.IsSuccessStatusCode, "Update extension should succeed", "UpdateSyncSuccess"); + + var jo = response.OpenJObjectResponse(); + AssertLogger.IsNotNull(jo?["extension"], "Response should contain extension object", "ExtensionObject"); + + string responseTitle = jo["extension"]["title"]?.ToString(); + AssertLogger.AreEqual(updatedTitle, responseTitle, "Title should equal updated value", "UpdatedTitleMatch"); + + // Proves the value actually changed — not just that the API accepted the request + AssertLogger.IsTrue( + responseTitle != _originalCustomFieldTitle, + "Updated title must differ from the original title", + "TitleActuallyChanged"); + } + catch (Exception ex) + { + AssertLogger.Fail($"Test005 failed: {ex.Message}"); + } + } + + // --------------------------------------------------------------------------- + // Test006 — Update widget tags (async); verifies new tag is present in response + // --------------------------------------------------------------------------- + + [TestMethod] + [DoNotParallelize] + public async Task Test006_Should_Update_Extension_Tags_Async() + { + TestOutputLogger.LogContext("TestScenario", "Test006_Should_Update_Extension_Tags_Async"); + + if (string.IsNullOrEmpty(_widgetUid)) + { + AssertLogger.Fail("Test006 requires _widgetUid set by Test002."); + return; + } + + try + { + var model = new ExtensionModel + { + Title = "Widget Updated", + Type = "widget", + Srcdoc = "Widget", + Tags = new List { "updated" } + }; + + ContentstackResponse response = await _stack.Extension(_widgetUid).UpdateAsync(model); + + AssertLogger.IsTrue(response.IsSuccessStatusCode, "UpdateAsync widget should succeed", "UpdateAsyncSuccess"); + + var jo = response.OpenJObjectResponse(); + AssertLogger.IsNotNull(jo?["extension"], "Response should contain extension object", "ExtensionObject"); + + var tags = jo["extension"]["tags"] as JArray; + AssertLogger.IsNotNull(tags, "Response should contain tags array", "TagsArray"); + + bool containsUpdatedTag = false; + foreach (var tag in tags) + { + if (tag.ToString() == "updated") + { + containsUpdatedTag = true; + break; + } + } + + AssertLogger.IsTrue(containsUpdatedTag, "Tags array should contain the 'updated' tag", "TagUpdatedPresent"); + } + catch (Exception ex) + { + AssertLogger.Fail($"Test006 failed: {ex.Message}"); + } + } + + // --------------------------------------------------------------------------- + // Test007 — Query all extensions; expects at least 2 (custom field + widget) + // --------------------------------------------------------------------------- + + [TestMethod] + [DoNotParallelize] + public void Test007_Should_Query_Extensions_Returns_Both() + { + TestOutputLogger.LogContext("TestScenario", "Test007_Should_Query_Extensions_Returns_Both"); + + try + { + ContentstackResponse response = _stack.Extension().Query().Find(); + + AssertLogger.IsTrue(response.IsSuccessStatusCode, "Query.Find() should succeed", "QueryFindSuccess"); + + var jo = response.OpenJObjectResponse(); + AssertLogger.IsNotNull(jo?["extensions"], "Response should contain 'extensions' array", "ExtensionsArray"); + + var extensions = jo["extensions"] as JArray; + AssertLogger.IsNotNull(extensions, "Extensions should be a JArray", "ExtensionsJArray"); + AssertLogger.IsTrue(extensions.Count >= 2, "Query should return at least 2 extensions (custom field + widget)", "CountAtLeastTwo"); + } + catch (Exception ex) + { + AssertLogger.Fail($"Test007 failed: {ex.Message}"); + } + } + + // --------------------------------------------------------------------------- + // Test008 — Delete widget (sync); then verify it is truly gone via Fetch + // --------------------------------------------------------------------------- + + [TestMethod] + [DoNotParallelize] + public void Test008_Should_Delete_Extension_Sync() + { + TestOutputLogger.LogContext("TestScenario", "Test008_Should_Delete_Extension_Sync"); + + if (string.IsNullOrEmpty(_widgetUid)) + { + AssertLogger.Fail("Test008 requires _widgetUid set by Test002."); + return; + } + + string uidToDelete = _widgetUid; + + try + { + ContentstackResponse deleteResponse = _stack.Extension(uidToDelete).Delete(); + AssertLogger.IsTrue(deleteResponse.IsSuccessStatusCode, "Delete extension should succeed", "DeleteSyncSuccess"); + + // Mark as deleted so ClassCleanup does not attempt a second delete + _widgetUid = null; + + // Verify the delete actually happened — the extension must no longer be fetchable + AssertLogger.ThrowsContentstackError( + () => _stack.Extension(uidToDelete).Fetch(), + "FetchAfterDelete", + HttpStatusCode.NotFound, + (HttpStatusCode)422); + } + catch (AssertFailedException) + { + throw; + } + catch (Exception ex) + { + AssertLogger.Fail($"Test008 failed: {ex.Message}"); + } + } + + // --------------------------------------------------------------------------- + // Test009 — Delete custom field (async) + // --------------------------------------------------------------------------- + + [TestMethod] + [DoNotParallelize] + public async Task Test009_Should_Delete_Extension_Async() + { + TestOutputLogger.LogContext("TestScenario", "Test009_Should_Delete_Extension_Async"); + + if (string.IsNullOrEmpty(_customFieldUid)) + { + AssertLogger.Fail("Test009 requires _customFieldUid set by Test001."); + return; + } + + string uidToDelete = _customFieldUid; + + try + { + ContentstackResponse deleteResponse = await _stack.Extension(uidToDelete).DeleteAsync(); + AssertLogger.IsTrue(deleteResponse.IsSuccessStatusCode, "DeleteAsync extension should succeed", "DeleteAsyncSuccess"); + + // Mark as deleted so ClassCleanup does not attempt a second delete + _customFieldUid = null; + } + catch (Exception ex) + { + AssertLogger.Fail($"Test009 failed: {ex.Message}"); + } + } + + // --------------------------------------------------------------------------- + // Test010 — Fetch a non-existent UID; must throw ContentstackErrorException 404/422 + // --------------------------------------------------------------------------- + + [TestMethod] + [DoNotParallelize] + public void Test010_Should_Throw_On_Fetch_NonExistent_Extension() + { + TestOutputLogger.LogContext("TestScenario", "Test010_Should_Throw_On_Fetch_NonExistent_Extension"); + + AssertLogger.ThrowsContentstackError( + () => _stack.Extension("blt_nonexistent_ext_uid").Fetch(), + "FetchNonExistentExtension", + HttpStatusCode.NotFound, + (HttpStatusCode)422); + } + + // --------------------------------------------------------------------------- + // Test011 — Create without title; must throw ContentstackErrorException 400/422 + // --------------------------------------------------------------------------- + + [TestMethod] + [DoNotParallelize] + public void Test011_Should_Throw_On_Create_Without_Title() + { + TestOutputLogger.LogContext("TestScenario", "Test011_Should_Throw_On_Create_Without_Title"); + + var model = new ExtensionModel + { + Title = null, + Type = "field", + DataType = "text", + Srcdoc = "" + }; + + AssertLogger.ThrowsContentstackError( + () => _stack.Extension().Create(model), + "CreateWithoutTitle", + HttpStatusCode.BadRequest, + (HttpStatusCode)422); + } + } +} diff --git a/Contentstack.Management.Core.Tests/IntegrationTest/Contentstack008_LabelTest.cs b/Contentstack.Management.Core.Tests/IntegrationTest/Contentstack008_LabelTest.cs new file mode 100644 index 0000000..4528861 --- /dev/null +++ b/Contentstack.Management.Core.Tests/IntegrationTest/Contentstack008_LabelTest.cs @@ -0,0 +1,414 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Threading.Tasks; +using Contentstack.Management.Core; +using Contentstack.Management.Core.Models; +using Contentstack.Management.Core.Exceptions; +using Contentstack.Management.Core.Tests.Helpers; +using Contentstack.Management.Core.Tests.Model; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Newtonsoft.Json.Linq; + +namespace Contentstack.Management.Core.Tests.IntegrationTest +{ + [TestClass] + [DoNotParallelize] + public class Contentstack008_LabelTest + { + private static ContentstackClient _client; + private static Stack _stack; + + private static readonly string _suffix = Guid.NewGuid().ToString("N").Substring(0, 8); + private static readonly string _labelName = "Test Label " + _suffix; + private static readonly string _asyncLabelName = "Async Label " + _suffix; + + private static string _labelUid; + private static string _asyncLabelUid; + + [ClassInitialize] + public static void ClassInitialize(TestContext context) + { + _client = Contentstack.CreateAuthenticatedClient(); + StackResponse response = StackResponse.getStack(_client.serializer); + _stack = _client.Stack(response.Stack.APIKey); + } + + [ClassCleanup] + public static void ClassCleanup() + { + SafeDelete(_labelUid); + SafeDelete(_asyncLabelUid); + try { _client?.Logout(); } catch { } + _client = null; + } + + // ----------------------------------------------------------------------- + // Private helpers + // ----------------------------------------------------------------------- + + private static LabelModel BuildLabelModel(string name) + { + return new LabelModel + { + Name = name, + ContentTypes = new List() + }; + } + + private static string ParseLabelUid(ContentstackResponse response) + { + return response.OpenJObjectResponse()["label"]["uid"].ToString(); + } + + private static void SafeDelete(string uid) + { + if (string.IsNullOrEmpty(uid)) + return; + try { _stack.Label(uid).Delete(); } catch { } + } + + // ----------------------------------------------------------------------- + // Tests + // ----------------------------------------------------------------------- + + [TestMethod] + [DoNotParallelize] + public void Test001_Should_Create_Label_Sync() + { + try + { + ContentstackResponse contentstackResponse = _stack.Label("").Create(BuildLabelModel(_labelName)); + AssertLogger.IsTrue(contentstackResponse.IsSuccessStatusCode, "Create label must succeed", "CreateSuccess"); + + var jobj = contentstackResponse.OpenJObjectResponse(); + AssertLogger.IsNotNull(jobj?["label"], "label node in response"); + + _labelUid = jobj["label"]["uid"].ToString(); + AssertLogger.IsTrue(!string.IsNullOrEmpty(_labelUid), "Label uid must be non-empty", "LabelUidNonEmpty"); + + string returnedName = jobj["label"]["name"].ToString(); + AssertLogger.AreEqual(_labelName, returnedName, "Returned label name must match", "LabelName"); + } + catch (Exception e) + { + AssertLogger.Fail($"Test001_Should_Create_Label_Sync failed: {e.Message}"); + } + } + + [TestMethod] + [DoNotParallelize] + public async Task Test002_Should_Create_Label_Async() + { + try + { + ContentstackResponse contentstackResponse = await _stack.Label("").CreateAsync(BuildLabelModel(_asyncLabelName)); + AssertLogger.IsTrue(contentstackResponse.IsSuccessStatusCode, "Async create label must succeed", "CreateAsyncSuccess"); + + var jobj = contentstackResponse.OpenJObjectResponse(); + AssertLogger.IsNotNull(jobj?["label"], "label node in async response"); + + _asyncLabelUid = jobj["label"]["uid"].ToString(); + AssertLogger.IsTrue(!string.IsNullOrEmpty(_asyncLabelUid), "Async label uid must be non-empty", "AsyncLabelUidNonEmpty"); + + string returnedName = jobj["label"]["name"].ToString(); + AssertLogger.AreEqual(_asyncLabelName, returnedName, "Returned async label name must match", "AsyncLabelName"); + } + catch (Exception e) + { + AssertLogger.Fail($"Test002_Should_Create_Label_Async failed: {e.Message}"); + } + } + + [TestMethod] + [DoNotParallelize] + public void Test003_Should_Fetch_Label_Sync() + { + if (string.IsNullOrEmpty(_labelUid)) + { + AssertLogger.Inconclusive("_labelUid is not set; Test001 may have failed."); + return; + } + + try + { + ContentstackResponse contentstackResponse = _stack.Label(_labelUid).Fetch(); + AssertLogger.IsTrue(contentstackResponse.IsSuccessStatusCode, "Fetch label must succeed", "FetchSuccess"); + + var jobj = contentstackResponse.OpenJObjectResponse(); + AssertLogger.IsNotNull(jobj?["label"], "label node in fetch response"); + + string fetchedUid = jobj["label"]["uid"].ToString(); + AssertLogger.AreEqual(_labelUid, fetchedUid, "Fetched uid must match stored uid (round-trip)", "FetchedUidRoundTrip"); + + string fetchedName = jobj["label"]["name"].ToString(); + AssertLogger.IsTrue(!string.IsNullOrEmpty(fetchedName), "Fetched name must be non-empty", "FetchedNameNonEmpty"); + } + catch (Exception e) + { + AssertLogger.Fail($"Test003_Should_Fetch_Label_Sync failed: {e.Message}"); + } + } + + [TestMethod] + [DoNotParallelize] + public async Task Test004_Should_Fetch_Label_Async() + { + if (string.IsNullOrEmpty(_asyncLabelUid)) + { + AssertLogger.Inconclusive("_asyncLabelUid is not set; Test002 may have failed."); + return; + } + + try + { + ContentstackResponse contentstackResponse = await _stack.Label(_asyncLabelUid).FetchAsync(); + AssertLogger.IsTrue(contentstackResponse.IsSuccessStatusCode, "Async fetch label must succeed", "FetchAsyncSuccess"); + + var jobj = contentstackResponse.OpenJObjectResponse(); + AssertLogger.IsNotNull(jobj?["label"], "label node in async fetch response"); + + string fetchedUid = jobj["label"]["uid"].ToString(); + AssertLogger.AreEqual(_asyncLabelUid, fetchedUid, "Async fetched uid must match stored uid (round-trip)", "AsyncFetchedUidRoundTrip"); + } + catch (Exception e) + { + AssertLogger.Fail($"Test004_Should_Fetch_Label_Async failed: {e.Message}"); + } + } + + [TestMethod] + [DoNotParallelize] + public void Test005_Should_Update_Label_Name_Sync() + { + if (string.IsNullOrEmpty(_labelUid)) + { + AssertLogger.Inconclusive("_labelUid is not set; Test001 may have failed."); + return; + } + + try + { + string updatedName = "Updated " + _labelName; + ContentstackResponse contentstackResponse = _stack.Label(_labelUid).Update(BuildLabelModel(updatedName)); + AssertLogger.IsTrue(contentstackResponse.IsSuccessStatusCode, "Update label must succeed", "UpdateSuccess"); + + var jobj = contentstackResponse.OpenJObjectResponse(); + AssertLogger.IsNotNull(jobj?["label"], "label node in update response"); + + string returnedName = jobj["label"]["name"].ToString(); + AssertLogger.AreEqual(updatedName, returnedName, "Updated name must match new name", "UpdatedNameMatch"); + AssertLogger.IsTrue(returnedName != _labelName, + $"Updated name must differ from original name (catches update-ignored bug). Got: '{returnedName}', original: '{_labelName}'", + "UpdatedNameDiffersFromOriginal"); + } + catch (Exception e) + { + AssertLogger.Fail($"Test005_Should_Update_Label_Name_Sync failed: {e.Message}"); + } + } + + [TestMethod] + [DoNotParallelize] + public async Task Test006_Should_Update_Label_Async() + { + if (string.IsNullOrEmpty(_asyncLabelUid)) + { + AssertLogger.Inconclusive("_asyncLabelUid is not set; Test002 may have failed."); + return; + } + + try + { + string updatedName = "Updated " + _asyncLabelName; + ContentstackResponse contentstackResponse = await _stack.Label(_asyncLabelUid).UpdateAsync(BuildLabelModel(updatedName)); + AssertLogger.IsTrue(contentstackResponse.IsSuccessStatusCode, "Async update label must succeed", "UpdateAsyncSuccess"); + + var jobj = contentstackResponse.OpenJObjectResponse(); + AssertLogger.IsNotNull(jobj?["label"], "label node in async update response"); + + string returnedName = jobj["label"]["name"].ToString(); + AssertLogger.AreEqual(updatedName, returnedName, "Async updated name must match new name", "AsyncUpdatedNameMatch"); + } + catch (Exception e) + { + AssertLogger.Fail($"Test006_Should_Update_Label_Async failed: {e.Message}"); + } + } + + [TestMethod] + [DoNotParallelize] + public void Test007_Should_Query_Labels_Contains_Created() + { + if (string.IsNullOrEmpty(_asyncLabelUid)) + { + AssertLogger.Inconclusive("_asyncLabelUid is not set; Test002 may have failed."); + return; + } + + try + { + ContentstackResponse contentstackResponse = _stack.Label("").Query().Find(); + AssertLogger.IsTrue(contentstackResponse.IsSuccessStatusCode, "Query labels must succeed", "QuerySuccess"); + + var jobj = contentstackResponse.OpenJObjectResponse(); + AssertLogger.IsNotNull(jobj?["labels"], "labels array in query response"); + + var labels = jobj["labels"] as JArray; + AssertLogger.IsNotNull(labels, "labels must be a JArray"); + + bool found = false; + foreach (var label in labels) + { + if (label["uid"]?.ToString() == _asyncLabelUid) + { + found = true; + break; + } + } + + AssertLogger.IsTrue(found, + $"Query must contain the created label with uid='{_asyncLabelUid}' (catches labels not appearing in list)", + "AsyncLabelFoundInQuery"); + } + catch (Exception e) + { + AssertLogger.Fail($"Test007_Should_Query_Labels_Contains_Created failed: {e.Message}"); + } + } + + [TestMethod] + [DoNotParallelize] + public void Test008_Should_Delete_Label_Sync() + { + if (string.IsNullOrEmpty(_asyncLabelUid)) + { + AssertLogger.Inconclusive("_asyncLabelUid is not set; Test002 may have failed."); + return; + } + + try + { + // Delete the async label + ContentstackResponse deleteResponse = _stack.Label(_asyncLabelUid).Delete(); + AssertLogger.IsTrue(deleteResponse.IsSuccessStatusCode, "Delete must succeed", "DeleteSuccess"); + + // Verify it is truly gone — fetch must fail with 404/422 + bool deletedConfirmed = false; + try + { + ContentstackResponse fetchAfterDelete = _stack.Label(_asyncLabelUid).Fetch(); + // If we reach here without exception, confirm via status code + deletedConfirmed = !fetchAfterDelete.IsSuccessStatusCode; + } + catch (ContentstackErrorException csEx) + { + int status = (int)csEx.StatusCode; + deletedConfirmed = status == 404 || status == 422 || status == 422; + AssertLogger.IsTrue(deletedConfirmed, + $"Fetch after delete should throw 404 or 422, got {csEx.StatusCode}: {csEx.Message}", + "FetchAfterDeleteStatusCode"); + } + + AssertLogger.IsTrue(deletedConfirmed, + "Fetch after delete must fail (verifies delete is not a no-op)", + "DeleteConfirmed"); + + // Mark uid as null so ClassCleanup does not attempt a second delete + _asyncLabelUid = null; + } + catch (Exception e) + { + AssertLogger.Fail($"Test008_Should_Delete_Label_Sync failed: {e.Message}"); + } + } + + [TestMethod] + [DoNotParallelize] + public async Task Test009_Should_Delete_Label_Async() + { + if (string.IsNullOrEmpty(_labelUid)) + { + AssertLogger.Inconclusive("_labelUid is not set; Test001 may have failed."); + return; + } + + try + { + ContentstackResponse deleteResponse = await _stack.Label(_labelUid).DeleteAsync(); + AssertLogger.IsTrue(deleteResponse.IsSuccessStatusCode, "Async delete must succeed", "DeleteAsyncSuccess"); + + // Mark uid as null so ClassCleanup does not attempt a second delete + _labelUid = null; + } + catch (Exception e) + { + AssertLogger.Fail($"Test009_Should_Delete_Label_Async failed: {e.Message}"); + } + } + + [TestMethod] + [DoNotParallelize] + public void Test010_Should_Throw_On_Fetch_NonExistent_Label() + { + try + { + AssertLogger.ThrowsContentstackError( + () => _stack.Label("blt_label_does_not_exist").Fetch(), + "FetchNonExistentLabel", + HttpStatusCode.NotFound, + (HttpStatusCode)422); + } + catch (AssertFailedException) + { + throw; + } + catch (Exception e) + { + // Some SDK versions may throw a different exception type wrapping the HTTP error + AssertLogger.IsTrue( + e.Message.Contains("404") || e.Message.Contains("422") || + e.Message.ToLowerInvariant().Contains("not found") || + e.Message.ToLowerInvariant().Contains("does not exist"), + $"Expected 404/422 style error for non-existent label fetch, got: {e.Message}", + "NonExistentFetchError"); + } + } + + [TestMethod] + [DoNotParallelize] + public void Test011_Should_Throw_On_Create_Empty_Name() + { + try + { + bool exceptionCaught = false; + try + { + ContentstackResponse contentstackResponse = _stack.Label("").Create(BuildLabelModel(string.Empty)); + // If we get here without exception, assert status is not success + exceptionCaught = !contentstackResponse.IsSuccessStatusCode; + } + catch (ContentstackErrorException csEx) + { + int status = (int)csEx.StatusCode; + exceptionCaught = status == 400 || status == 422; + AssertLogger.IsTrue(exceptionCaught, + $"Create with empty name should throw 400 or 422, got {csEx.StatusCode}: {csEx.Message}", + "EmptyNameStatusCode"); + } + + AssertLogger.IsTrue(exceptionCaught, + "Creating a label with an empty name must fail with 400 or 422", + "EmptyNameFails"); + } + catch (AssertFailedException) + { + throw; + } + catch (Exception e) + { + AssertLogger.Fail($"Test011_Should_Throw_On_Create_Empty_Name failed unexpectedly: {e.Message}"); + } + } + } +} diff --git a/Contentstack.Management.Core.Tests/IntegrationTest/Contentstack009_BranchTest.cs b/Contentstack.Management.Core.Tests/IntegrationTest/Contentstack009_BranchTest.cs new file mode 100644 index 0000000..f3c6236 --- /dev/null +++ b/Contentstack.Management.Core.Tests/IntegrationTest/Contentstack009_BranchTest.cs @@ -0,0 +1,361 @@ +// NOTE: The Contentstack .NET Management SDK (as of this writing) does NOT expose a Branch() +// method on the Stack class, nor does it include a Branch model in Contentstack.Management.Core.Models. +// The internal service infrastructure (InvokeSync / InvokeAsync) is also marked internal and +// cannot be called from the test project. +// +// All tests below are decorated with [Ignore] and carry a descriptive comment explaining the +// intended behaviour. When Branch API support is added to the SDK, remove the [Ignore] attribute +// and uncomment / adjust the SDK call to match the actual API surface. +// +// Intended SDK pattern (once available): +// _stack.Branch() // no uid => create / query context +// _stack.Branch(uid) // with uid => fetch / delete context +// _stack.Branch().Create(model) // POST /stacks/branches +// _stack.Branch(uid).Fetch() // GET /stacks/branches/{uid} +// _stack.Branch(uid).FetchAsync() // async variant +// _stack.Branch().Query().Find() // GET /stacks/branches +// _stack.Branch(uid).Delete() // DELETE /stacks/branches/{uid} + +using System; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using Contentstack.Management.Core; +using Contentstack.Management.Core.Exceptions; +using Contentstack.Management.Core.Tests.Helpers; +using Contentstack.Management.Core.Tests.Model; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Newtonsoft.Json.Linq; + +namespace Contentstack.Management.Core.Tests.IntegrationTest +{ + [TestClass] + [DoNotParallelize] + public class Contentstack009_BranchTest + { + private static ContentstackClient _client; + private static Core.Models.Stack _stack; + + // Branch UID: "test-br-" + 8-char lowercase hex suffix derived from a Guid. + private static readonly string _branchUid = + "test-br-" + Guid.NewGuid().ToString("N").Substring(0, 8).ToLowerInvariant(); + + // Tracks whether Test001 successfully created the branch so ClassCleanup knows whether to attempt deletion. + private static bool _createdBranch = false; + + // ----------------------------------------------------------------------- + // Class setup / teardown + // ----------------------------------------------------------------------- + + [ClassInitialize] + public static void ClassInitialize(TestContext context) + { + _client = Contentstack.CreateAuthenticatedClient(); + StackResponse response = StackResponse.getStack(_client.serializer); + _stack = _client.Stack(response.Stack.APIKey); + } + + [ClassCleanup] + public static void ClassCleanup() + { + // If the create test succeeded and the delete test did not clean up, try to remove the branch. + if (_createdBranch) + { + SafeDeleteBranch(_branchUid); + } + + try { _client?.Logout(); } catch { } + _client = null; + } + + // ----------------------------------------------------------------------- + // Private helpers + // ----------------------------------------------------------------------- + + /// + /// Best-effort synchronous branch deletion used only in cleanup. + /// Swallows all exceptions so that cleanup never fails the test run. + /// + private static void SafeDeleteBranch(string uid) + { + // SDK does not expose Branch operations yet — placeholder body only. + // Once Branch() is available on Stack, replace the body with: + // _stack.Branch(uid).Delete(); + _ = uid; // suppress unused-variable warning + } + + /// + /// Asserts that the exception is a with an + /// HTTP status code that indicates the resource was not found or a validation failure. + /// Acceptable codes: 400, 404, 409, 422. + /// + private static void AssertBranchErrorException(Exception ex, string context) + { + if (ex is ContentstackErrorException cex) + { + int status = (int)cex.StatusCode; + AssertLogger.IsTrue( + status == 400 || status == 404 || status == 409 || status == 422, + $"{context}: Expected 400/404/409/422 but got {status} — {cex.Message}", + $"{context}_StatusCode"); + } + else + { + string msg = ex.Message?.ToLowerInvariant() ?? string.Empty; + AssertLogger.IsTrue( + msg.Contains("not found") || msg.Contains("404") || + msg.Contains("conflict") || msg.Contains("409") || + msg.Contains("invalid") || msg.Contains("400") || + msg.Contains("422"), + $"{context}: Unexpected exception — {ex.GetType().Name}: {ex.Message}", + $"{context}_FallbackMessage"); + } + } + + // ----------------------------------------------------------------------- + // Tests + // ----------------------------------------------------------------------- + + /// + /// Creates a new branch with uid=_branchUid sourced from "main". + /// Verifies the response contains the expected uid and source fields. + /// Sets _createdBranch = true on success; waits 5 s for branch provisioning. + /// + [TestMethod] + [DoNotParallelize] + [Ignore("Branch API is not yet exposed in the .NET Management SDK. " + + "Remove [Ignore] and implement when Stack.Branch() is available.")] + public async Task Test001_Should_Create_Branch_From_Main() + { + // ----- Arrange ----- + // var model = new JObject + // { + // ["uid"] = _branchUid, + // ["source"] = "main" + // }; + + // ----- Act ----- + // ContentstackResponse contentstackResponse = _stack.Branch().Create(model); + // JObject response = contentstackResponse.OpenJObjectResponse(); + + // ----- Assert ----- + // AssertLogger.IsNotNull(response, "Create branch response must not be null", "CreateBranchResponse"); + // AssertLogger.IsTrue(contentstackResponse.IsSuccessStatusCode, "Create branch must return 2xx", "CreateBranchStatus"); + // AssertLogger.IsNotNull(response["branch"], "Response must contain 'branch' key", "CreateBranchKey"); + // AssertLogger.AreEqual(_branchUid, response["branch"]["uid"]?.ToString(), + // $"Created branch uid must equal '{_branchUid}'", "CreateBranchUid"); + // + // string source = response["branch"]["source"]?.ToString() + // ?? response["branch"]["uid"]?.ToString(); // some APIs echo "uid":"main" + // AssertLogger.IsTrue(source == "main", + // $"Expected source 'main' but got '{source}'", "CreateBranchSource"); + // + // _createdBranch = true; + + // Branches take 2-5 s to provision after creation. + // await Task.Delay(5000); + + await Task.CompletedTask; // remove when implementing + Assert.Inconclusive("Branch API not yet exposed in SDK — test skipped."); + } + + /// + /// Fetches the branch created in Test001 (synchronous) and verifies the uid round-trips correctly. + /// + [TestMethod] + [DoNotParallelize] + [Ignore("Branch API is not yet exposed in the .NET Management SDK. " + + "Remove [Ignore] and implement when Stack.Branch() is available.")] + public void Test002_Should_Fetch_Branch_Sync() + { + // ----- Act ----- + // ContentstackResponse contentstackResponse = _stack.Branch(_branchUid).Fetch(); + // JObject response = contentstackResponse.OpenJObjectResponse(); + + // ----- Assert ----- + // AssertLogger.IsNotNull(response, "Fetch branch response must not be null", "FetchBranchResponse"); + // AssertLogger.IsTrue(contentstackResponse.IsSuccessStatusCode, "Fetch branch must return 2xx", "FetchBranchStatus"); + // AssertLogger.IsNotNull(response["branch"], "Response must contain 'branch' key", "FetchBranchKey"); + // AssertLogger.AreEqual(_branchUid, response["branch"]["uid"]?.ToString(), + // "Fetched branch uid must match _branchUid (round-trip check)", "FetchBranchUid"); + + Assert.Inconclusive("Branch API not yet exposed in SDK — test skipped."); + } + + /// + /// Fetches the branch created in Test001 (asynchronous) and verifies the uid round-trips correctly. + /// + [TestMethod] + [DoNotParallelize] + [Ignore("Branch API is not yet exposed in the .NET Management SDK. " + + "Remove [Ignore] and implement when Stack.Branch() is available.")] + public async Task Test003_Should_Fetch_Branch_Async() + { + // ----- Act ----- + // ContentstackResponse contentstackResponse = await _stack.Branch(_branchUid).FetchAsync(); + // JObject response = contentstackResponse.OpenJObjectResponse(); + + // ----- Assert ----- + // AssertLogger.IsNotNull(response, "FetchAsync branch response must not be null", "FetchAsyncBranchResponse"); + // AssertLogger.IsTrue(contentstackResponse.IsSuccessStatusCode, "FetchAsync branch must return 2xx", "FetchAsyncBranchStatus"); + // AssertLogger.IsNotNull(response["branch"], "Response must contain 'branch' key", "FetchAsyncBranchKey"); + // AssertLogger.AreEqual(_branchUid, response["branch"]["uid"]?.ToString(), + // "Async-fetched branch uid must match _branchUid (round-trip check)", "FetchAsyncBranchUid"); + + await Task.CompletedTask; // remove when implementing + Assert.Inconclusive("Branch API not yet exposed in SDK — test skipped."); + } + + /// + /// Queries all branches and verifies that both "main" and _branchUid appear in the list. + /// Guards against a branch being created but not appearing in listing (eventual-consistency issue). + /// + [TestMethod] + [DoNotParallelize] + [Ignore("Branch API is not yet exposed in the .NET Management SDK. " + + "Remove [Ignore] and implement when Stack.Branch() is available.")] + public void Test004_Should_Query_Branches_Includes_Main_And_Created() + { + // ----- Act ----- + // ContentstackResponse contentstackResponse = _stack.Branch().Query().Find(); + // JObject response = contentstackResponse.OpenJObjectResponse(); + + // ----- Assert ----- + // AssertLogger.IsNotNull(response, "Query branches response must not be null", "QueryBranchesResponse"); + // AssertLogger.IsTrue(contentstackResponse.IsSuccessStatusCode, "Query branches must return 2xx", "QueryBranchesStatus"); + // AssertLogger.IsNotNull(response["branches"], "Response must contain 'branches' key", "QueryBranchesKey"); + // + // JArray branches = response["branches"] as JArray; + // AssertLogger.IsNotNull(branches, "Branches value must be a JSON array", "QueryBranchesArray"); + // + // bool hasMain = branches.Any(b => b["uid"]?.ToString() == "main"); + // AssertLogger.IsTrue(hasMain, + // "Branches list must contain the 'main' branch", "QueryBranchesHasMain"); + // + // bool hasCreated = branches.Any(b => b["uid"]?.ToString() == _branchUid); + // AssertLogger.IsTrue(hasCreated, + // $"Branches list must contain the created branch '{_branchUid}'", "QueryBranchesHasCreated"); + + Assert.Inconclusive("Branch API not yet exposed in SDK — test skipped."); + } + + /// + /// Attempts to fetch a branch with a uid that does not exist. + /// Expects a with HTTP 404 or 422. + /// + [TestMethod] + [DoNotParallelize] + [Ignore("Branch API is not yet exposed in the .NET Management SDK. " + + "Remove [Ignore] and implement when Stack.Branch() is available.")] + public void Test005_Should_Throw_On_Fetch_NonExistent_Branch() + { + const string nonExistentUid = "nonexistent-branch-9999"; + + // try + // { + // ContentstackResponse contentstackResponse = _stack.Branch(nonExistentUid).Fetch(); + // AssertLogger.IsFalse(contentstackResponse.IsSuccessStatusCode, + // "Fetching a non-existent branch must not return 2xx", + // "FetchNonExistentBranchStatus"); + // } + // catch (Exception ex) + // { + // AssertBranchErrorException(ex, "FetchNonExistentBranch"); + // } + + _ = nonExistentUid; // suppress unused-variable warning + Assert.Inconclusive("Branch API not yet exposed in SDK — test skipped."); + } + + /// + /// Attempts to create a branch with the same uid as the one created in Test001. + /// Expects a with HTTP 409/422/400 (conflict / already exists). + /// + [TestMethod] + [DoNotParallelize] + [Ignore("Branch API is not yet exposed in the .NET Management SDK. " + + "Remove [Ignore] and implement when Stack.Branch() is available.")] + public void Test006_Should_Throw_On_Create_Duplicate_Branch() + { + // var model = new JObject + // { + // ["uid"] = _branchUid, + // ["source"] = "main" + // }; + // + // try + // { + // ContentstackResponse contentstackResponse = _stack.Branch().Create(model); + // AssertLogger.IsFalse(contentstackResponse.IsSuccessStatusCode, + // "Creating a duplicate branch must not return 2xx", + // "DuplicateBranchStatus"); + // } + // catch (Exception ex) + // { + // AssertBranchErrorException(ex, "CreateDuplicateBranch"); + // } + + Assert.Inconclusive("Branch API not yet exposed in SDK — test skipped."); + } + + /// + /// Deletes the branch created in Test001 (synchronous). + /// Verifies the delete succeeds, then confirms a subsequent fetch raises an error. + /// Sets _createdBranch = false to skip ClassCleanup deletion. + /// + [TestMethod] + [DoNotParallelize] + [Ignore("Branch API is not yet exposed in the .NET Management SDK. " + + "Remove [Ignore] and implement when Stack.Branch() is available.")] + public void Test007_Should_Delete_Branch_Sync() + { + // ----- Act: delete ----- + // ContentstackResponse deleteResponse = _stack.Branch(_branchUid).Delete(); + // AssertLogger.IsTrue(deleteResponse.IsSuccessStatusCode, + // "Delete branch must return 2xx", "DeleteBranchStatus"); + // + // _createdBranch = false; + // + // ----- Assert: branch is gone ----- + // try + // { + // ContentstackResponse fetchResponse = _stack.Branch(_branchUid).Fetch(); + // AssertLogger.IsFalse(fetchResponse.IsSuccessStatusCode, + // "Branch must be gone after deletion; Fetch must not return 2xx", + // "DeletedBranchFetchStatus"); + // } + // catch (Exception ex) + // { + // AssertBranchErrorException(ex, "FetchAfterDelete"); + // } + + Assert.Inconclusive("Branch API not yet exposed in SDK — test skipped."); + } + + /// + /// Attempts to delete the "main" branch, which is protected. + /// Expects a with HTTP 400 or 422. + /// + [TestMethod] + [DoNotParallelize] + [Ignore("Branch API is not yet exposed in the .NET Management SDK. " + + "Remove [Ignore] and implement when Stack.Branch() is available.")] + public void Test008_Should_Throw_On_Delete_Main_Branch() + { + // try + // { + // ContentstackResponse deleteResponse = _stack.Branch("main").Delete(); + // AssertLogger.IsFalse(deleteResponse.IsSuccessStatusCode, + // "Deleting the 'main' branch must not return 2xx (it is protected)", + // "DeleteMainBranchStatus"); + // } + // catch (Exception ex) + // { + // // 400 or 422 expected — main branch deletion should be rejected by the API. + // AssertBranchErrorException(ex, "DeleteMainBranch"); + // } + + Assert.Inconclusive("Branch API not yet exposed in SDK — test skipped."); + } + } +} diff --git a/Contentstack.Management.Core.Tests/IntegrationTest/Contentstack023_AuditLogTest.cs b/Contentstack.Management.Core.Tests/IntegrationTest/Contentstack023_AuditLogTest.cs new file mode 100644 index 0000000..023b454 --- /dev/null +++ b/Contentstack.Management.Core.Tests/IntegrationTest/Contentstack023_AuditLogTest.cs @@ -0,0 +1,369 @@ +using System; +using System.Net; +using System.Threading.Tasks; +using Contentstack.Management.Core; +using Contentstack.Management.Core.Exceptions; +using Contentstack.Management.Core.Models; +using Contentstack.Management.Core.Queryable; +using Contentstack.Management.Core.Tests.Helpers; +using Contentstack.Management.Core.Tests.Model; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Newtonsoft.Json.Linq; + +namespace Contentstack.Management.Core.Tests.IntegrationTest +{ + [TestClass] + [DoNotParallelize] + public class Contentstack023_AuditLogTest + { + private static ContentstackClient _client; + private Stack _stack; + + // Shared state populated in Test001 / Test002 and used by fetch tests + private static string _firstLogUid; + private static string _secondLogUid; + + [ClassInitialize] + public static void ClassInitialize(TestContext context) + { + _client = Contentstack.CreateAuthenticatedClient(); + } + + [ClassCleanup] + public static void ClassCleanup() + { + try { _client?.Logout(); } catch { } + _client = null; + } + + [TestInitialize] + public void TestInitialize() + { + StackResponse response = StackResponse.getStack(_client.serializer); + _stack = _client.Stack(response.Stack.APIKey); + } + + // ───────────────────────────────────────────────────────────── + // Test001 — List (sync) + // ───────────────────────────────────────────────────────────── + [TestMethod] + [DoNotParallelize] + public void Test001_Should_Return_AuditLog_List() + { + TestOutputLogger.LogContext("TestScenario", "AuditLog_List_Sync"); + + ContentstackResponse response = _stack.AuditLog().FindAll(); + + AssertLogger.IsNotNull(response, "response"); + AssertLogger.IsTrue(response.IsSuccessStatusCode, + $"FindAll should succeed, got {(int)response.StatusCode}: {response.OpenResponse()}", + "FindAllSucceeded"); + + var jObject = response.OpenJObjectResponse(); + AssertLogger.IsNotNull(jObject, "jObject"); + + var logs = jObject["logs"] as JArray; + AssertLogger.IsNotNull(logs, "logs array"); + + // Audit log MUST have entries because tests 001–022 ran operations before this + AssertLogger.IsTrue(logs.Count > 0, + "Audit log should contain at least one entry — empty log indicates no operations were recorded", + "LogsNotEmpty"); + + // Capture UIDs for subsequent single-fetch tests + if (logs.Count > 0) + { + _firstLogUid = logs[0]["uid"]?.ToString(); + TestOutputLogger.LogContext("FirstLogUid", _firstLogUid ?? "(null)"); + } + + if (logs.Count > 1) + { + _secondLogUid = logs[1]["uid"]?.ToString(); + TestOutputLogger.LogContext("SecondLogUid", _secondLogUid ?? "(null)"); + } + } + + // ───────────────────────────────────────────────────────────── + // Test002 — List (async) + // ───────────────────────────────────────────────────────────── + [TestMethod] + [DoNotParallelize] + public async Task Test002_Should_Return_AuditLog_List_Async() + { + TestOutputLogger.LogContext("TestScenario", "AuditLog_List_Async"); + + ContentstackResponse response = await _stack.AuditLog().FindAllAsync(); + + AssertLogger.IsNotNull(response, "response"); + AssertLogger.IsTrue(response.IsSuccessStatusCode, + $"FindAllAsync should succeed, got {(int)response.StatusCode}: {response.OpenResponse()}", + "FindAllAsyncSucceeded"); + + var jObject = response.OpenJObjectResponse(); + AssertLogger.IsNotNull(jObject, "jObject"); + + var logs = jObject["logs"] as JArray; + AssertLogger.IsNotNull(logs, "logs array"); + + AssertLogger.IsTrue(logs.Count > 0, + "Async audit log list should have at least one entry", + "AsyncLogsNotEmpty"); + + // Back-fill UIDs if Test001 did not run first in isolation + if (_firstLogUid == null && logs.Count > 0) + { + _firstLogUid = logs[0]["uid"]?.ToString(); + } + + if (_secondLogUid == null && logs.Count > 1) + { + _secondLogUid = logs[1]["uid"]?.ToString(); + } + } + + // ───────────────────────────────────────────────────────────── + // Test003 — Limit parameter + // ───────────────────────────────────────────────────────────── + [TestMethod] + [DoNotParallelize] + public void Test003_Should_Support_Limit_Parameter() + { + TestOutputLogger.LogContext("TestScenario", "AuditLog_Limit_Parameter"); + + var collection = new ParameterCollection(); + collection.Add("limit", 5); + + ContentstackResponse response = _stack.AuditLog().FindAll(collection); + + AssertLogger.IsNotNull(response, "response"); + AssertLogger.IsTrue(response.IsSuccessStatusCode, + $"FindAll with limit should succeed, got {(int)response.StatusCode}: {response.OpenResponse()}", + "LimitCallSucceeded"); + + var logs = response.OpenJObjectResponse()?["logs"] as JArray; + AssertLogger.IsNotNull(logs, "logs array"); + + // SDK must honour the limit — if it returns more than 5 the parameter is being silently ignored + AssertLogger.IsTrue(logs.Count <= 5, + $"Response returned {logs.Count} entries but limit was 5 — SDK is ignoring the limit parameter", + "LimitRespected"); + + // The stack has prior operations so at least one entry is expected + AssertLogger.IsTrue(logs.Count > 0, + "Audit log with limit=5 should still return at least one entry", + "LimitResultNotEmpty"); + } + + // ───────────────────────────────────────────────────────────── + // Test004 — Skip + Limit, field presence + // ───────────────────────────────────────────────────────────── + [TestMethod] + [DoNotParallelize] + public void Test004_Should_Support_Skip_And_Limit() + { + TestOutputLogger.LogContext("TestScenario", "AuditLog_Skip_And_Limit"); + + var collection = new ParameterCollection(); + collection.Add("skip", 0); + collection.Add("limit", 1); + + ContentstackResponse response = _stack.AuditLog().FindAll(collection); + + AssertLogger.IsNotNull(response, "response"); + AssertLogger.IsTrue(response.IsSuccessStatusCode, + $"FindAll skip=0 limit=1 should succeed, got {(int)response.StatusCode}", + "SkipLimitSucceeded"); + + var logs = response.OpenJObjectResponse()?["logs"] as JArray; + AssertLogger.IsNotNull(logs, "logs array"); + + // Exactly one entry expected — catches off-by-one in pagination handling + AssertLogger.AreEqual(1, logs.Count, + "Expected exactly 1 entry with skip=0 limit=1 — pagination is broken if count differs", + "ExactlyOneEntry"); + + var entry = logs[0] as JObject; + AssertLogger.IsNotNull(entry, "first log entry"); + + string uid = entry["uid"]?.ToString(); + string action = entry["action"]?.ToString(); + string module = entry["module"]?.ToString(); + + AssertLogger.IsTrue(!string.IsNullOrEmpty(uid), + "Log entry uid must not be empty", "EntryUidPresent"); + AssertLogger.IsTrue(!string.IsNullOrEmpty(action), + "Log entry action must not be empty", "EntryActionPresent"); + AssertLogger.IsTrue(!string.IsNullOrEmpty(module), + "Log entry module must not be empty", "EntryModulePresent"); + } + + // ───────────────────────────────────────────────────────────── + // Test005 — Fetch single entry by UID (sync) + // ───────────────────────────────────────────────────────────── + [TestMethod] + [DoNotParallelize] + public void Test005_Should_Fetch_Single_AuditLog_Entry_By_UID() + { + TestOutputLogger.LogContext("TestScenario", "AuditLog_Fetch_Single_Sync"); + + if (string.IsNullOrEmpty(_firstLogUid)) + { + Assert.Inconclusive("_firstLogUid is not set — ensure Test001 or Test002 ran first."); + return; + } + + ContentstackResponse response = _stack.AuditLog(_firstLogUid).Fetch(); + + AssertLogger.IsNotNull(response, "response"); + AssertLogger.IsTrue(response.IsSuccessStatusCode, + $"Fetch single entry should succeed, got {(int)response.StatusCode}: {response.OpenResponse()}", + "FetchSingleSucceeded"); + + var jObject = response.OpenJObjectResponse(); + AssertLogger.IsNotNull(jObject, "jObject"); + + // Response key is "log" (singular), not "logs" + var log = jObject["log"] as JObject; + AssertLogger.IsNotNull(log, "log object in response"); + + string returnedUid = log["uid"]?.ToString(); + AssertLogger.IsNotNull(returnedUid, "returned uid"); + + // Round-trip check: the uid returned by detail must match the one used in the request + AssertLogger.AreEqual(_firstLogUid, returnedUid, + "Fetched log uid does not match the requested uid — uid mismatch between list and detail endpoint", + "UidRoundTrip"); + } + + // ───────────────────────────────────────────────────────────── + // Test006 — Fetch single entry by UID (async) + // ───────────────────────────────────────────────────────────── + [TestMethod] + [DoNotParallelize] + public async Task Test006_Should_Fetch_AuditLog_Async() + { + TestOutputLogger.LogContext("TestScenario", "AuditLog_Fetch_Single_Async"); + + string uidToFetch = _secondLogUid ?? _firstLogUid; + + if (string.IsNullOrEmpty(uidToFetch)) + { + Assert.Inconclusive("No log UID available — ensure Test001 or Test002 ran first."); + return; + } + + ContentstackResponse response = await _stack.AuditLog(uidToFetch).FetchAsync(); + + AssertLogger.IsNotNull(response, "response"); + AssertLogger.IsTrue(response.IsSuccessStatusCode, + $"FetchAsync should succeed, got {(int)response.StatusCode}: {response.OpenResponse()}", + "FetchAsyncSucceeded"); + + var jObject = response.OpenJObjectResponse(); + AssertLogger.IsNotNull(jObject, "jObject"); + + var log = jObject["log"] as JObject; + AssertLogger.IsNotNull(log, "log object in async response"); + + string returnedUid = log["uid"]?.ToString(); + AssertLogger.AreEqual(uidToFetch, returnedUid, + "Async fetch returned a different uid than requested", + "AsyncUidRoundTrip"); + } + + // ───────────────────────────────────────────────────────────── + // Test007 — Non-existent UID must throw 404/422 + // ───────────────────────────────────────────────────────────── + [TestMethod] + [DoNotParallelize] + public void Test007_Should_Throw_On_Fetch_NonExistent_Log_Entry() + { + TestOutputLogger.LogContext("TestScenario", "AuditLog_Fetch_NonExistent"); + + const string fakeUid = "blt_nonexistent_log_uid_xyz"; + + try + { + _stack.AuditLog(fakeUid).Fetch(); + + // If no exception was thrown the API accepted a bogus UID — fail the test + AssertLogger.Fail( + "Expected ContentstackErrorException for a non-existent audit log UID but no exception was thrown", + "NonExistentUidShouldThrow"); + } + catch (ContentstackErrorException ex) + { + bool isExpectedStatus = + ex.StatusCode == HttpStatusCode.NotFound || + ex.StatusCode == (HttpStatusCode)422 || + ex.StatusCode == HttpStatusCode.UnprocessableEntity; + + AssertLogger.IsTrue(isExpectedStatus, + $"Expected 404 or 422 for non-existent uid, got {(int)ex.StatusCode} ({ex.StatusCode})", + "NonExistentUidStatusCode"); + } + } + + // ───────────────────────────────────────────────────────────── + // Test008 — Required fields present on every entry + // ───────────────────────────────────────────────────────────── + [TestMethod] + [DoNotParallelize] + public void Test008_AuditLog_Entries_Have_Required_Fields() + { + TestOutputLogger.LogContext("TestScenario", "AuditLog_RequiredFields"); + + // Retrieve the first few entries to validate schema + var collection = new ParameterCollection(); + collection.Add("limit", 5); + + ContentstackResponse response = _stack.AuditLog().FindAll(collection); + + AssertLogger.IsNotNull(response, "response"); + AssertLogger.IsTrue(response.IsSuccessStatusCode, + $"FindAll for schema check should succeed, got {(int)response.StatusCode}", + "SchemaCheckSucceeded"); + + var logs = response.OpenJObjectResponse()?["logs"] as JArray; + AssertLogger.IsNotNull(logs, "logs array"); + AssertLogger.IsTrue(logs.Count > 0, "logs must not be empty for field validation", "LogsForSchemaCheck"); + + for (int i = 0; i < logs.Count; i++) + { + var entry = logs[i] as JObject; + AssertLogger.IsNotNull(entry, $"entry[{i}]"); + + string uid = entry["uid"]?.ToString(); + string action = entry["action"]?.ToString(); + string module = entry["module"]?.ToString(); + string createdAt = entry["created_at"]?.ToString(); + + // uid must be a non-empty string + AssertLogger.IsTrue(!string.IsNullOrEmpty(uid), + $"Entry[{i}].uid is missing or empty — schema regression detected", + $"Entry{i}_UidPresent"); + + // action must be a non-empty string (e.g. "create", "update", "delete") + AssertLogger.IsTrue(!string.IsNullOrEmpty(action), + $"Entry[{i}].action is missing or empty — schema regression detected", + $"Entry{i}_ActionPresent"); + + // module must be a non-empty string (e.g. "content_type", "asset") + AssertLogger.IsTrue(!string.IsNullOrEmpty(module), + $"Entry[{i}].module is missing or empty — schema regression detected", + $"Entry{i}_ModulePresent"); + + // created_at must exist and must be parseable as a DateTime + AssertLogger.IsTrue(!string.IsNullOrEmpty(createdAt), + $"Entry[{i}].created_at is missing — schema regression detected", + $"Entry{i}_CreatedAtPresent"); + + bool parsedDate = DateTime.TryParse(createdAt, out _); + AssertLogger.IsTrue(parsedDate, + $"Entry[{i}].created_at value '{createdAt}' cannot be parsed as DateTime", + $"Entry{i}_CreatedAtParseable"); + } + } + } +} diff --git a/Contentstack.Management.Core.Tests/IntegrationTest/Contentstack024_ManagementTokenTest.cs b/Contentstack.Management.Core.Tests/IntegrationTest/Contentstack024_ManagementTokenTest.cs new file mode 100644 index 0000000..1f0f456 --- /dev/null +++ b/Contentstack.Management.Core.Tests/IntegrationTest/Contentstack024_ManagementTokenTest.cs @@ -0,0 +1,420 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Threading.Tasks; +using Contentstack.Management.Core; +using Contentstack.Management.Core.Models; +using Contentstack.Management.Core.Models.Token; +using Contentstack.Management.Core.Exceptions; +using Contentstack.Management.Core.Tests.Helpers; +using Contentstack.Management.Core.Tests.Model; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Newtonsoft.Json.Linq; + +namespace Contentstack.Management.Core.Tests.IntegrationTest +{ + [TestClass] + [DoNotParallelize] + public class Contentstack024_ManagementTokenTest + { + private static ContentstackClient _client; + private static Stack _stack; + + private static string _suffix = Guid.NewGuid().ToString("N").Substring(0, 8); + private static string _tokenUid; + private static string _asyncTokenUid; + private static string _originalTokenName; + + [ClassInitialize] + public static void ClassInitialize(TestContext context) + { + _client = Contentstack.CreateAuthenticatedClient(); + StackResponse response = StackResponse.getStack(_client.serializer); + _stack = _client.Stack(response.Stack.APIKey); + } + + [ClassCleanup] + public static void ClassCleanup() + { + SafeDelete(_tokenUid); + SafeDelete(_asyncTokenUid); + try { _client?.Logout(); } catch { } + _client = null; + } + + // ── Private helpers ────────────────────────────────────────────────────── + + private static ManagementTokenModel BuildTokenModel(string name) + { + return new ManagementTokenModel + { + Name = name, + Description = "Integration test token", + Scope = new List + { + new TokenScope + { + Module = "content_type", + ACL = new Dictionary + { + { "read", "true" }, + { "write", "true" } + }, + Branches = new List { "main" } + } + }, + ExpiresOn = DateTime.UtcNow.AddDays(30).ToString("yyyy-MM-dd") + }; + } + + private static string ParseTokenUid(ContentstackResponse response) + { + return response.OpenJObjectResponse()["token"]["uid"].ToString(); + } + + private static void SafeDelete(string uid) + { + if (string.IsNullOrEmpty(uid)) return; + try { _stack.ManagementTokens(uid).Delete(); } catch { } + } + + // ── Happy-path tests ───────────────────────────────────────────────────── + + [TestMethod] + [DoNotParallelize] + public void Test001_Should_Create_ManagementToken_Sync() + { + TestOutputLogger.LogContext("TestScenario", "Test001_Should_Create_ManagementToken_Sync"); + try + { + _originalTokenName = $"MgmtToken_Sync_{_suffix}"; + var model = BuildTokenModel(_originalTokenName); + + ContentstackResponse response = _stack.ManagementTokens().Create(model); + + AssertLogger.IsTrue(response.IsSuccessStatusCode, "Create management token (sync) should succeed", "CreateSyncSuccess"); + + var tokenNode = response.OpenJObjectResponse()["token"]; + AssertLogger.IsNotNull(tokenNode, "Response should contain 'token' node", "TokenNodePresent"); + + _tokenUid = tokenNode["uid"]?.ToString(); + AssertLogger.IsTrue(!string.IsNullOrEmpty(_tokenUid), "Token uid must be non-empty after create", "TokenUidNonEmpty"); + + string returnedName = tokenNode["name"]?.ToString(); + AssertLogger.AreEqual(_originalTokenName, returnedName, "Returned token name must match the name we sent", "TokenNameMatch"); + + var scope = tokenNode["scope"] as JArray; + AssertLogger.IsNotNull(scope, "Response token should contain 'scope' array", "ScopePresent"); + AssertLogger.IsTrue(scope.Count == 1, "Scope array should have exactly 1 entry (catches scope being stripped)", "ScopeCount"); + + string tokenSecret = tokenNode["token"]?.ToString(); + AssertLogger.IsTrue(!string.IsNullOrEmpty(tokenSecret), "Response token.token (the actual secret) must be non-empty", "TokenSecretNonEmpty"); + + TestOutputLogger.LogContext("TokenUid", _tokenUid ?? ""); + } + catch (Exception ex) + { + AssertLogger.Fail($"Test001 failed: {ex.Message}"); + } + } + + [TestMethod] + [DoNotParallelize] + public async Task Test002_Should_Create_ManagementToken_Async() + { + TestOutputLogger.LogContext("TestScenario", "Test002_Should_Create_ManagementToken_Async"); + try + { + string asyncName = $"MgmtToken_Async_{_suffix}"; + var model = BuildTokenModel(asyncName); + + ContentstackResponse response = await _stack.ManagementTokens().CreateAsync(model); + + AssertLogger.IsTrue(response.IsSuccessStatusCode, "Create management token (async) should succeed", "CreateAsyncSuccess"); + + var tokenNode = response.OpenJObjectResponse()["token"]; + AssertLogger.IsNotNull(tokenNode, "Response should contain 'token' node", "AsyncTokenNodePresent"); + + _asyncTokenUid = tokenNode["uid"]?.ToString(); + AssertLogger.IsTrue(!string.IsNullOrEmpty(_asyncTokenUid), "Async token uid must be non-empty", "AsyncTokenUidNonEmpty"); + + AssertLogger.IsTrue( + _asyncTokenUid != _tokenUid, + "Async-created token uid must differ from the sync-created one (two distinct tokens)", + "TwoDistinctUids"); + + TestOutputLogger.LogContext("AsyncTokenUid", _asyncTokenUid ?? ""); + } + catch (Exception ex) + { + AssertLogger.Fail($"Test002 failed: {ex.Message}"); + } + } + + [TestMethod] + [DoNotParallelize] + public void Test003_Should_Fetch_ManagementToken_Sync() + { + TestOutputLogger.LogContext("TestScenario", "Test003_Should_Fetch_ManagementToken_Sync"); + try + { + if (string.IsNullOrEmpty(_tokenUid)) + Test001_Should_Create_ManagementToken_Sync(); + + ContentstackResponse response = _stack.ManagementTokens(_tokenUid).Fetch(); + + AssertLogger.IsTrue(response.IsSuccessStatusCode, "Fetch management token (sync) should succeed", "FetchSyncSuccess"); + + var tokenNode = response.OpenJObjectResponse()["token"]; + AssertLogger.IsNotNull(tokenNode, "Fetch response should contain 'token' node", "FetchTokenNodePresent"); + + string fetchedUid = tokenNode["uid"]?.ToString(); + AssertLogger.AreEqual(_tokenUid, fetchedUid, "Fetched uid must match the uid we created (round-trip)", "FetchUidRoundTrip"); + + string fetchedName = tokenNode["name"]?.ToString(); + AssertLogger.IsTrue(!string.IsNullOrEmpty(fetchedName), "Fetched token name must be non-empty", "FetchNameNonEmpty"); + } + catch (Exception ex) + { + AssertLogger.Fail($"Test003 failed: {ex.Message}"); + } + } + + [TestMethod] + [DoNotParallelize] + public async Task Test004_Should_Fetch_ManagementToken_Async() + { + TestOutputLogger.LogContext("TestScenario", "Test004_Should_Fetch_ManagementToken_Async"); + try + { + if (string.IsNullOrEmpty(_asyncTokenUid)) + await Test002_Should_Create_ManagementToken_Async(); + + ContentstackResponse response = await _stack.ManagementTokens(_asyncTokenUid).FetchAsync(); + + AssertLogger.IsTrue(response.IsSuccessStatusCode, "Fetch management token (async) should succeed", "FetchAsyncSuccess"); + + var tokenNode = response.OpenJObjectResponse()["token"]; + AssertLogger.IsNotNull(tokenNode, "Async fetch response should contain 'token' node", "AsyncFetchTokenNodePresent"); + + string fetchedUid = tokenNode["uid"]?.ToString(); + AssertLogger.AreEqual(_asyncTokenUid, fetchedUid, "Async fetched uid must match _asyncTokenUid", "AsyncFetchUidMatch"); + } + catch (Exception ex) + { + AssertLogger.Fail($"Test004 failed: {ex.Message}"); + } + } + + [TestMethod] + [DoNotParallelize] + public void Test005_Should_Update_ManagementToken_Name_Sync() + { + TestOutputLogger.LogContext("TestScenario", "Test005_Should_Update_ManagementToken_Name_Sync"); + try + { + if (string.IsNullOrEmpty(_tokenUid)) + Test001_Should_Create_ManagementToken_Sync(); + + string updatedName = "Updated " + _originalTokenName; + var updateModel = BuildTokenModel(updatedName); + + ContentstackResponse response = _stack.ManagementTokens(_tokenUid).Update(updateModel); + + AssertLogger.IsTrue(response.IsSuccessStatusCode, "Update management token name (sync) should succeed", "UpdateNameSyncSuccess"); + + var tokenNode = response.OpenJObjectResponse()["token"]; + AssertLogger.IsNotNull(tokenNode, "Update response should contain 'token' node", "UpdateTokenNodePresent"); + + string returnedName = tokenNode["name"]?.ToString(); + AssertLogger.AreEqual(updatedName, returnedName, "Update response must reflect the new name (catches stale data)", "UpdatedNameReturned"); + AssertLogger.IsTrue(returnedName != _originalTokenName, "Updated name must differ from original name", "NameActuallyChanged"); + } + catch (Exception ex) + { + AssertLogger.Fail($"Test005 failed: {ex.Message}"); + } + } + + [TestMethod] + [DoNotParallelize] + public async Task Test006_Should_Update_ManagementToken_Description_Async() + { + TestOutputLogger.LogContext("TestScenario", "Test006_Should_Update_ManagementToken_Description_Async"); + try + { + if (string.IsNullOrEmpty(_asyncTokenUid)) + await Test002_Should_Create_ManagementToken_Async(); + + string asyncName = $"MgmtToken_Async_{_suffix}"; + var updateModel = BuildTokenModel(asyncName); + updateModel.Description = "Updated description"; + + ContentstackResponse response = await _stack.ManagementTokens(_asyncTokenUid).UpdateAsync(updateModel); + + AssertLogger.IsTrue(response.IsSuccessStatusCode, "Update management token description (async) should succeed", "UpdateDescAsyncSuccess"); + + var tokenNode = response.OpenJObjectResponse()["token"]; + AssertLogger.IsNotNull(tokenNode, "Async update response should contain 'token' node", "AsyncUpdateTokenNodePresent"); + + string returnedDescription = tokenNode["description"]?.ToString(); + AssertLogger.AreEqual("Updated description", returnedDescription, "Async update must return the new description", "AsyncUpdatedDescription"); + } + catch (Exception ex) + { + AssertLogger.Fail($"Test006 failed: {ex.Message}"); + } + } + + [TestMethod] + [DoNotParallelize] + public void Test007_Should_Query_ManagementTokens_Contains_Both() + { + TestOutputLogger.LogContext("TestScenario", "Test007_Should_Query_ManagementTokens_Contains_Both"); + try + { + if (string.IsNullOrEmpty(_tokenUid)) + Test001_Should_Create_ManagementToken_Sync(); + + ContentstackResponse response = _stack.ManagementTokens().Query().Find(); + + AssertLogger.IsTrue(response.IsSuccessStatusCode, "Query management tokens should succeed", "QuerySuccess"); + + var tokensNode = response.OpenJObjectResponse()["tokens"] as JArray; + AssertLogger.IsNotNull(tokensNode, "Query response should contain 'tokens' array", "TokensArrayPresent"); + AssertLogger.IsTrue(tokensNode.Count >= 2, "Query should return at least 2 tokens (the two we created)", "TokenCountAtLeastTwo"); + + bool foundToken = false; + foreach (var t in tokensNode) + { + if (t["uid"]?.ToString() == _tokenUid) + { + foundToken = true; + break; + } + } + AssertLogger.IsTrue(foundToken, $"Query result must contain _tokenUid '{_tokenUid}' (catches token missing from list)", "TokenFoundInList"); + } + catch (Exception ex) + { + AssertLogger.Fail($"Test007 failed: {ex.Message}"); + } + } + + [TestMethod] + [DoNotParallelize] + public void Test008_Should_Delete_ManagementToken_Sync() + { + TestOutputLogger.LogContext("TestScenario", "Test008_Should_Delete_ManagementToken_Sync"); + try + { + if (string.IsNullOrEmpty(_asyncTokenUid)) + Test002_Should_Create_ManagementToken_Async().GetAwaiter().GetResult(); + + ContentstackResponse deleteResponse = _stack.ManagementTokens(_asyncTokenUid).Delete(); + AssertLogger.IsTrue(deleteResponse.IsSuccessStatusCode, "Delete management token (sync) should succeed", "DeleteSyncSuccess"); + + // Verify delete is not a no-op: a subsequent Fetch must throw a 404/422. + string deletedUid = _asyncTokenUid; + _asyncTokenUid = null; + + AssertLogger.ThrowsContentstackError( + () => _stack.ManagementTokens(deletedUid).Fetch(), + "FetchAfterDelete", + HttpStatusCode.NotFound, + (HttpStatusCode)422); + } + catch (Exception ex) + { + AssertLogger.Fail($"Test008 failed: {ex.Message}"); + } + } + + [TestMethod] + [DoNotParallelize] + public async Task Test009_Should_Delete_ManagementToken_Async() + { + TestOutputLogger.LogContext("TestScenario", "Test009_Should_Delete_ManagementToken_Async"); + try + { + if (string.IsNullOrEmpty(_tokenUid)) + Test001_Should_Create_ManagementToken_Sync(); + + ContentstackResponse deleteResponse = await _stack.ManagementTokens(_tokenUid).DeleteAsync(); + AssertLogger.IsTrue(deleteResponse.IsSuccessStatusCode, "Delete management token (async) should succeed", "DeleteAsyncSuccess"); + + _tokenUid = null; + } + catch (Exception ex) + { + AssertLogger.Fail($"Test009 failed: {ex.Message}"); + } + } + + // ── Negative-path tests ────────────────────────────────────────────────── + + [TestMethod] + [DoNotParallelize] + public void Test010_Should_Throw_On_Fetch_NonExistent_Token() + { + TestOutputLogger.LogContext("TestScenario", "Test010_Should_Throw_On_Fetch_NonExistent_Token"); + AssertLogger.ThrowsContentstackError( + () => _stack.ManagementTokens("blt_nonexistent_token_xyz").Fetch(), + "FetchNonExistentToken", + HttpStatusCode.NotFound, + (HttpStatusCode)422); + } + + [TestMethod] + [DoNotParallelize] + public void Test011_Should_Throw_On_Create_Without_Name() + { + TestOutputLogger.LogContext("TestScenario", "Test011_Should_Throw_On_Create_Without_Name"); + var model = new ManagementTokenModel + { + Name = null, + Description = "Integration test token", + Scope = new List + { + new TokenScope + { + Module = "content_type", + ACL = new Dictionary + { + { "read", "true" }, + { "write", "true" } + }, + Branches = new List { "main" } + } + }, + ExpiresOn = DateTime.UtcNow.AddDays(30).ToString("yyyy-MM-dd") + }; + + AssertLogger.ThrowsContentstackError( + () => _stack.ManagementTokens().Create(model), + "CreateWithoutName", + HttpStatusCode.BadRequest, + (HttpStatusCode)422); + } + + [TestMethod] + [DoNotParallelize] + public void Test012_Should_Throw_On_Create_Without_Scope() + { + TestOutputLogger.LogContext("TestScenario", "Test012_Should_Throw_On_Create_Without_Scope"); + var model = new ManagementTokenModel + { + Name = $"MgmtToken_NoScope_{_suffix}", + Description = "Integration test token", + Scope = new List(), + ExpiresOn = DateTime.UtcNow.AddDays(30).ToString("yyyy-MM-dd") + }; + + AssertLogger.ThrowsContentstackError( + () => _stack.ManagementTokens().Create(model), + "CreateWithEmptyScope", + HttpStatusCode.BadRequest, + (HttpStatusCode)422); + } + } +} diff --git a/Contentstack.Management.Core.Tests/Model/StackModel.cs b/Contentstack.Management.Core.Tests/Model/StackModel.cs index ce71ae7..8371f08 100644 --- a/Contentstack.Management.Core.Tests/Model/StackModel.cs +++ b/Contentstack.Management.Core.Tests/Model/StackModel.cs @@ -25,8 +25,22 @@ public class StackResponse { public StackModel Stack { get; set; } + /// + /// Returns the active test stack. Prefers the in-memory key set by + /// [AssemblyInitialize] (TestRunContext) so tests do not depend on a + /// file that may not exist yet. Falls back to ./stackApiKey.txt for + /// backward compatibility with runs that don't use the assembly setup. + /// public static StackResponse getStack(JsonSerializer serializer) { + if (!string.IsNullOrWhiteSpace(TestRunContext.StackApiKey)) + { + return new StackResponse + { + Stack = new StackModel { APIKey = TestRunContext.StackApiKey } + }; + } + string response = File.ReadAllText("./stackApiKey.txt"); JObject jObject = JObject.Parse(response); return jObject.ToObject(serializer); diff --git a/Contentstack.Management.Core.Tests/TestAssemblySetup.cs b/Contentstack.Management.Core.Tests/TestAssemblySetup.cs new file mode 100644 index 0000000..db08911 --- /dev/null +++ b/Contentstack.Management.Core.Tests/TestAssemblySetup.cs @@ -0,0 +1,113 @@ +using System; +using System.Net.Http; +using System.Threading.Tasks; +using Contentstack.Management.Core; +using Contentstack.Management.Core.Models; +using Contentstack.Management.Core.Tests.Helpers; +using Contentstack.Management.Core.Tests.Model; +using Microsoft.Extensions.Configuration; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Contentstack.Management.Core.Tests +{ + /// + /// Runs once per test assembly. + /// [AssemblyInitialize] creates a fresh test stack and stores its API key in TestRunContext + /// so every test class can use it without reading from a file. + /// [AssemblyCleanup] logs the stack for manual teardown (SDK does not expose Stack.Delete). + /// + [TestClass] + public class TestAssemblySetup + { + [AssemblyInitialize] + public static void AssemblyInitialize(TestContext context) + { + Console.WriteLine("[AssemblyInit] Initialising integration test run..."); + + // ── Config ───────────────────────────────────────────────────────── + var orgUid = Contentstack.Organization?.Uid; + if (string.IsNullOrWhiteSpace(orgUid)) + throw new InvalidOperationException( + "[AssemblyInit] Contentstack:Organization:Uid is missing in appsettings.json. " + + "Cannot create a test stack without an organisation UID."); + + TestRunContext.OrganizationUid = orgUid; + TestRunContext.DeleteDynamicResources = Contentstack.DeleteDynamicResources; + + // ── Login ─────────────────────────────────────────────────────────── + Console.WriteLine("[AssemblyInit] Logging in..."); + var httpClient = new System.Net.Http.HttpClient(new LoggingHttpHandler()); + var options = Contentstack.Config + .GetSection("Contentstack") + .Get(); + options.Authtoken = null; + + var client = new ContentstackClient(httpClient, options); + Contentstack.LoginWithTotpRetry(client); + Console.WriteLine("[AssemblyInit] Login successful."); + + // ── Create test stack ─────────────────────────────────────────────── + var stackName = $"dotnet_sdk_test_{DateTime.UtcNow:yyyyMMddHHmmss}"; + Console.WriteLine($"[AssemblyInit] Creating test stack '{stackName}' in org '{orgUid}'..."); + + try + { + ContentstackResponse createResp = client.Stack() + .Create(stackName, "en-us", orgUid, + $"Automated integration test stack – created {DateTime.UtcNow:O}"); + + StackResponse model = createResp.OpenTResponse(); + + if (string.IsNullOrWhiteSpace(model?.Stack?.APIKey)) + throw new InvalidOperationException( + "[AssemblyInit] Stack created but response contained no api_key."); + + TestRunContext.StackApiKey = model.Stack.APIKey; + TestRunContext.StackCreatedDynamically = true; + Contentstack.Stack = model.Stack; + + // Persist for legacy tests still using StackResponse.getStack(file) + System.IO.File.WriteAllText("./stackApiKey.txt", createResp.OpenResponse()); + + Console.WriteLine($"[AssemblyInit] Test stack created: {model.Stack.APIKey}"); + + // Brief pause to let the stack provision fully on the server + System.Threading.Thread.Sleep(3000); + } + catch (Exception ex) + { + throw new InvalidOperationException( + $"[AssemblyInit] Failed to create test stack: {ex.Message}", ex); + } + finally + { + // Release the bootstrap client – each test class creates its own + try { client.Logout(); } catch { } + } + } + + [AssemblyCleanup] + public static void AssemblyCleanup() + { + if (!TestRunContext.StackCreatedDynamically) + return; + + if (TestRunContext.DeleteDynamicResources) + { + // The .NET CMA SDK does not expose a Stack.Delete() method at the + // Stack model level. Log the key so CI pipelines / developers can + // clean up manually or via the Contentstack UI. + Console.WriteLine( + $"[AssemblyCleanup] Test stack '{TestRunContext.StackApiKey}' was created " + + "dynamically but cannot be auto-deleted (SDK does not expose Stack.Delete). " + + "Delete it manually via the Contentstack UI or Management API."); + } + else + { + Console.WriteLine( + $"[AssemblyCleanup] DeleteDynamicResources=false. " + + $"Stack '{TestRunContext.StackApiKey}' preserved for debugging."); + } + } + } +} diff --git a/Contentstack.Management.Core.Tests/TestRunContext.cs b/Contentstack.Management.Core.Tests/TestRunContext.cs new file mode 100644 index 0000000..80accda --- /dev/null +++ b/Contentstack.Management.Core.Tests/TestRunContext.cs @@ -0,0 +1,26 @@ +using System; + +namespace Contentstack.Management.Core.Tests +{ + /// + /// Shared in-memory state populated once by [AssemblyInitialize] and read by every test class. + /// Replaces the file-based stackApiKey.txt handoff. + /// + public static class TestRunContext + { + /// API key of the dynamically created test stack. + public static string StackApiKey { get; internal set; } + + /// True when the stack was created by AssemblyInitialize (not read from file). + public static bool StackCreatedDynamically { get; internal set; } + + /// Organisation UID used to create the test stack. + public static string OrganizationUid { get; internal set; } + + /// + /// When false, the test stack and other dynamic resources survive the test run so you + /// can inspect them. Driven by Contentstack:DeleteDynamicResources in appsettings.json. + /// + public static bool DeleteDynamicResources { get; internal set; } = true; + } +} diff --git a/Contentstack.Management.Core/Models/Token/ManagementToken.cs b/Contentstack.Management.Core/Models/Token/ManagementToken.cs index f05a9b6..855e0a2 100644 --- a/Contentstack.Management.Core/Models/Token/ManagementToken.cs +++ b/Contentstack.Management.Core/Models/Token/ManagementToken.cs @@ -8,7 +8,7 @@ public class ManagementToken : BaseModel internal ManagementToken(Stack stack, string uid = null) : base(stack, "token", uid) { - resourcePath = uid == null ? "/delivery_tokens" : $"/delivery_tokens/{uid}"; + resourcePath = uid == null ? "/management_tokens" : $"/management_tokens/{uid}"; } /// diff --git a/Scripts/generate_integration_test_report.py b/Scripts/generate_integration_test_report.py index 1bc5dea..ad68f76 100644 --- a/Scripts/generate_integration_test_report.py +++ b/Scripts/generate_integration_test_report.py @@ -364,14 +364,17 @@ def _parse_structured_output(self, text): 'headers': obj.get('headers', {}), 'body': obj.get('body', ''), 'curl': obj.get('curlCommand', ''), - 'sdkMethod': obj.get('sdkMethod', '') + 'sdkMethod': obj.get('sdkMethod', ''), + 'timestamp': obj.get('timestamp', '') }) elif t == 'HTTP_RESPONSE': data['responses'].append({ 'statusCode': obj.get('statusCode', 0), 'statusText': obj.get('statusText', ''), 'headers': obj.get('headers', {}), - 'body': obj.get('body', '') + 'body': obj.get('body', ''), + 'timestamp': obj.get('timestamp', ''), + 'durationMs': obj.get('durationMs', 0) }) elif t == 'CONTEXT': data['context'].append({ @@ -577,6 +580,8 @@ def _html_head(self): margin-top: 10px; max-height: 300px; overflow-y: auto; white-space: pre-wrap; word-break: break-all; }} .curl-section {{ background: #f0f7ff; border-left: 3px solid #007bff; }} + .ts-label {{ font-size: 0.78em; color: #888; margin-left: 10px; white-space: nowrap; font-family: 'Consolas', 'Monaco', monospace; }} + .dur-label {{ font-size: 0.82em; font-weight: 600; margin-left: 10px; color: #6f42c1; white-space: nowrap; }} .copy-btn {{ background: #007bff; color: white; border: none; padding: 6px 12px; border-radius: 4px; cursor: pointer; font-size: 0.85em; margin-top: 5px; transition: background 0.2s; }} .copy-btn:hover {{ background: #0056b3; }} .show-more-btn {{ background: #6c757d; color: white; border: none; padding: 4px 10px; border-radius: 4px; cursor: pointer; font-size: 0.8em; margin-top: 5px; }} @@ -833,10 +838,13 @@ def _html_test_detail(self, test, test_id): sdk_badge = '' if req.get('sdkMethod'): sdk_badge = f'
SDK Method: {self._esc(req["sdkMethod"])}
' + ts_badge = '' + if req.get('timestamp'): + ts_badge = f'🕐 {self._esc(req["timestamp"])}' html += f"""
{sdk_badge} -
{self._esc(req['method'])}{self._esc(req['url'])}
""" +
{self._esc(req['method'])}{self._esc(req['url'])}{ts_badge}
""" if req.get('headers'): hdr_text = '\n'.join(f"{k}: {v}" for k, v in req['headers'].items()) html += f""" @@ -856,9 +864,12 @@ def _html_test_detail(self, test, test_id): if res: sc = res.get('statusCode', 0) status_cls = 'rs-success' if 200 <= sc < 300 else 'rs-error' + dur_ms = res.get('durationMs', 0) + dur_badge = f'⏱ {dur_ms}ms' if dur_ms else '' + ts_badge = f'🕐 {self._esc(res["timestamp"])}' if res.get('timestamp') else '' html += f"""
-
{sc} {self._esc(res.get('statusText', ''))}
""" +
{sc} {self._esc(res.get('statusText', ''))}{dur_badge}{ts_badge}
""" if res.get('headers'): hdr_text = '\n'.join(f"{k}: {v}" for k, v in res['headers'].items()) html += f"""