Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
170 changes: 170 additions & 0 deletions Contentstack.Management.Core.Tests/Helpers/PersonalizeTestHelper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
using System;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Threading.Tasks;
using Contentstack.Management.Core.Endpoints;

namespace Contentstack.Management.Core.Tests.Helpers
{
/// <summary>
/// Raw-HTTP client for the Contentstack Personalize Management API, used only by
/// integration tests. The core SDK has no Project/Audience/Experience wrapper classes
/// (Personalize is a separate product surface from Content Management), so this helper
/// drives Personalize directly to auto-provision real Variant Group/Variant data on a
/// stack instead of relying on a hardcoded variant UID.
///
/// NOTE(verify at implementation time): exact endpoint paths, payload shapes, and
/// required headers below are best-effort based on Contentstack's published Personalize
/// API conventions and have not been exercised against a live org in this change.
/// Confirm against https://www.contentstack.com/docs/developers/apis/personalize-management-api
/// before relying on this in CI, and adjust the marked TODO(verify) spots as needed.
/// </summary>
internal class PersonalizeTestHelper
{
private readonly HttpClient _httpClient;
private readonly string _baseUrl;
private readonly string _organizationUid;

internal PersonalizeTestHelper(string authtoken, string organizationUid, string region = "na")
{
if (string.IsNullOrEmpty(authtoken))
{
throw new ArgumentException("authtoken is required", nameof(authtoken));
}
if (string.IsNullOrEmpty(organizationUid))
{
throw new ArgumentException("organizationUid is required", nameof(organizationUid));
}

_organizationUid = organizationUid;
_baseUrl = Endpoint.GetContentstackEndpoint(region, "personalizeManagement").TrimEnd('/');

_httpClient = new HttpClient(new LoggingHttpHandler())
{
Timeout = TimeSpan.FromSeconds(30)
};
_httpClient.DefaultRequestHeaders.Add("authtoken", authtoken);
_httpClient.DefaultRequestHeaders.Add("organization_uid", organizationUid);
}

/// <summary>
/// Creates a Personalize project connected to the given stack, so that Audiences/
/// Experiences created within it can auto-provision Variant Groups on that stack.
/// TODO(verify): confirm path is "/projects" and payload shape against Personalize docs.
/// </summary>
internal async Task<string> CreateProjectAsync(string stackApiKey, string projectName)
{
var payload = new JsonObject
{
["name"] = projectName,
["connectedStackApiKey"] = stackApiKey
};

var project = await PostAsync("/projects", payload, null);
return project?["uid"]?.ToString();
}

/// <summary>
/// Returns the uid of an existing audience on the project if one is found, otherwise
/// creates a new default audience and returns its uid.
/// TODO(verify): confirm paths "/audiences" (GET list / POST create) and payload shape.
/// </summary>
internal async Task<string> GetOrCreateDefaultAudienceAsync(string projectUid, string audienceName)
{
var existing = await GetAsync("/audiences", projectUid);
var audiences = existing?["audiences"]?.AsArray();
if (audiences != null && audiences.Count > 0)
{
return audiences[0]?["uid"]?.ToString();
}

var payload = new JsonObject
{
["name"] = audienceName,
["definition"] = new JsonObject
{
["rules"] = new JsonArray(),
["operator"] = "and"
}
};

var audience = await PostAsync("/audiences", payload, projectUid);
return audience?["uid"]?.ToString();
}

/// <summary>
/// Creates an Experience linked to the given audience. In the real product, creating
/// an Experience on a stack-connected project auto-provisions a Variant Group (and its
/// Variants) on the Content Management side of that stack.
/// TODO(verify): confirm path "/experiences" and payload shape (variant count/short_uids).
/// </summary>
internal async Task<string> CreateExperienceAsync(string projectUid, string audienceUid, string experienceName)
{
var payload = new JsonObject
{
["name"] = experienceName,
["audiences"] = new JsonArray(audienceUid),
["variants"] = new JsonArray(new JsonObject { ["name"] = "Variant A" })
};

var experience = await PostAsync("/experiences", payload, projectUid);
return experience?["uid"]?.ToString();
}

/// <summary>Best-effort teardown of an experience created for a test run. Failures are non-fatal.</summary>
internal async Task DeleteExperienceAsync(string projectUid, string experienceUid)
{
await DeleteAsync($"/experiences/{experienceUid}", projectUid);
}

/// <summary>Best-effort teardown of a project created for a test run. Failures are non-fatal.</summary>
internal async Task DeleteProjectAsync(string projectUid)
{
await DeleteAsync($"/projects/{projectUid}", null);
}

private async Task<JsonObject> PostAsync(string path, JsonObject payload, string projectUid)
{
using var request = new HttpRequestMessage(HttpMethod.Post, _baseUrl + path);
ApplyProjectHeader(request, projectUid);
request.Content = new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json");

using var response = await _httpClient.SendAsync(request);
string body = await response.Content.ReadAsStringAsync();
response.EnsureSuccessStatusCode();

return string.IsNullOrEmpty(body) ? null : JsonNode.Parse(body)?.AsObject();
}

private async Task<JsonObject> GetAsync(string path, string projectUid)
{
using var request = new HttpRequestMessage(HttpMethod.Get, _baseUrl + path);
ApplyProjectHeader(request, projectUid);

using var response = await _httpClient.SendAsync(request);
string body = await response.Content.ReadAsStringAsync();
response.EnsureSuccessStatusCode();

return string.IsNullOrEmpty(body) ? null : JsonNode.Parse(body)?.AsObject();
}

private async Task DeleteAsync(string path, string projectUid)
{
using var request = new HttpRequestMessage(HttpMethod.Delete, _baseUrl + path);
ApplyProjectHeader(request, projectUid);
using var response = await _httpClient.SendAsync(request);
response.EnsureSuccessStatusCode();
}

private static void ApplyProjectHeader(HttpRequestMessage request, string projectUid)
{
if (!string.IsNullOrEmpty(projectUid))
{
request.Headers.TryAddWithoutValidation("x-project-uid", projectUid);
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using Contentstack.Management.Core;
using Contentstack.Management.Core.Exceptions;
using Contentstack.Management.Core.Models;
using Contentstack.Management.Core.Models.Token;
using Contentstack.Management.Core.Tests.Helpers;
using Contentstack.Management.Core.Tests.Model;
using Microsoft.VisualStudio.TestTools.UnitTesting;
Expand Down Expand Up @@ -1191,5 +1192,71 @@ private static void AssertStackAuthError(Exception ex, string assertionName)
}

#endregion

#region Global management token (shared infrastructure for variant/personalize tests)

[TestMethod]
[DoNotParallelize]
public void Test065_Should_Create_Management_Token()
{
TestOutputLogger.LogContext("TestScenario", "CreateGlobalManagementToken");
AssertStackApiKeyOrInconclusive();
try
{
Stack stack = _client.Stack(Contentstack.Stack.APIKey);
var model = new ManagementTokenModel
{
Name = "DotNet SDK Integration Test Token",
Description = "Shared management token used by the .NET SDK integration test suite",
Scope = new List<TokenScope>
{
new TokenScope
{
Module = "content_type",
ACL = new Dictionary<string, string> { { "read", "true" }, { "write", "true" } }
},
new TokenScope
{
Module = "entry",
ACL = new Dictionary<string, string> { { "read", "true" }, { "write", "true" } }
},
new TokenScope
{
Module = "asset",
ACL = new Dictionary<string, string> { { "read", "true" }, { "write", "true" } }
},
new TokenScope
{
Module = "environment",
ACL = new Dictionary<string, string> { { "read", "true" }, { "write", "true" } }
},
new TokenScope
{
Module = "taxonomy",
ACL = new Dictionary<string, string> { { "read", "true" }, { "write", "true" } }
}
}
};

ContentstackResponse contentstackResponse = stack.ManagementTokens().Create(model);

AssertLogger.IsTrue(contentstackResponse.IsSuccessStatusCode, $"Create management token failed: {contentstackResponse.OpenResponse()}", "CreateManagementTokenSuccess");

File.WriteAllText("./managementTokenInfo.txt", contentstackResponse.OpenResponse());

ManagementTokenResponse tokenResponse = contentstackResponse.OpenTResponse<ManagementTokenResponse>();
AssertLogger.IsNotNull(tokenResponse.Token, "tokenResponse.Token");
AssertLogger.IsNotNull(tokenResponse.Token.Uid, "tokenResponse.Token.Uid");
AssertLogger.IsNotNull(tokenResponse.Token.Token, "tokenResponse.Token.Token");

TestOutputLogger.LogContext("ManagementTokenUid", tokenResponse.Token.Uid ?? "");
}
catch (Exception e)
{
AssertLogger.Fail(e.Message);
}
}

#endregion
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Contentstack.Management.Core.Exceptions;
Expand Down Expand Up @@ -38,6 +39,9 @@ public class Contentstack021_EntryVariantTest
private static string _variantUid;
private static string _variantGroupUid;

private const int VariantGroupPollTimeoutSeconds = 90;
private const int VariantGroupPollIntervalSeconds = 5;

#region Helper Methods

/// <summary>
Expand Down Expand Up @@ -235,17 +239,25 @@ public static void ClassCleanup()
[TestInitialize]
public void TestInitialize()
{
// Read the API key from appSettings.json
string apiKey = Contentstack.Config["Contentstack:Stack:api_key"];

// Optional: Fallback to stackApiKey.txt if it's missing in appSettings.json
if (string.IsNullOrEmpty(apiKey))
// Always use the dynamically-created stack shared by the rest of the suite
// (matches Contentstack012_ContentTypeTest.cs and friends) rather than the
// static Contentstack:Stack:api_key from appSettings.json.
StackResponse response = StackResponse.getStack(_client.serializer);
string apiKey = response.Stack.APIKey;

string managementToken = null;
try
{
StackResponse response = StackResponse.getStack(_client.serializer);
apiKey = response.Stack.APIKey;
managementToken = ManagementTokenResponse.getManagementToken(_client.serializer)?.Token?.Token;
}

_stack = _client.Stack(apiKey);
catch
{
// managementTokenInfo.txt missing (e.g. isolated test run) — fall back to session-token-only stack.
}

_stack = string.IsNullOrEmpty(managementToken)
? _client.Stack(apiKey)
: _client.Stack(apiKey, managementToken);
}

[TestMethod]
Expand All @@ -254,21 +266,74 @@ public async System.Threading.Tasks.Task Test001_Ensure_Setup_Data()
{
TestOutputLogger.LogContext("TestScenario", "ProductBannerVariantLifecycle_Setup");

// 1. Ensure Variant Group exists
// 1. Ensure a Variant Group (with real Variants) exists on the dynamic stack.
var collection = new global::Contentstack.Management.Core.Queryable.ParameterCollection();
collection.Add("include_variant_info", "true");
collection.Add("include_variant_count", "true");

var vgResponse = await _stack.VariantGroup().FindAsync(collection);
Console.WriteLine("Variant Groups Response: " + vgResponse.OpenResponse());
async Task<JsonArray> FindVariantGroupsAsync()
{
var response = await _stack.VariantGroup().FindAsync(collection);
Console.WriteLine("Variant Groups Response: " + response.OpenResponse());
return response.OpenJsonObjectResponse()["variant_groups"]?.AsArray();
}

var vgJsonObject = vgResponse.OpenJsonObjectResponse();
var groups = vgJsonObject["variant_groups"]?.AsArray();
var groups = await FindVariantGroupsAsync();

if (groups == null || groups.Count == 0)
{
Assert.Inconclusive("No variant groups found in the stack. Create one to run EntryVariant tests. Response was: " + vgResponse.OpenResponse());
return;
// No pre-existing variant group — drive the Personalize API to create a
// project/audience/experience on this stack, which auto-provisions a real
// Variant Group + Variants on the Content Management side.
try
{
var personalize = new PersonalizeTestHelper(_client.contentstackOptions.Authtoken, Contentstack.Organization.Uid);
long runId = DateTimeOffset.UtcNow.ToUnixTimeSeconds();

string projectUid = await personalize.CreateProjectAsync(_stack.APIKey, $"DotNet SDK Integration Test Project {runId}");
string audienceUid = await personalize.GetOrCreateDefaultAudienceAsync(projectUid, $"DotNet SDK Test Audience {runId}");
string experienceUid = await personalize.CreateExperienceAsync(projectUid, audienceUid, $"DotNet SDK Test Experience {runId}");

TestOutputLogger.LogContext("PersonalizeProjectUid", projectUid ?? "");
TestOutputLogger.LogContext("PersonalizeAudienceUid", audienceUid ?? "");
TestOutputLogger.LogContext("PersonalizeExperienceUid", experienceUid ?? "");

var pollDeadline = DateTime.UtcNow.AddSeconds(VariantGroupPollTimeoutSeconds);
while (DateTime.UtcNow < pollDeadline)
{
groups = await FindVariantGroupsAsync();
if (groups != null && groups.Count > 0)
{
break;
}
await Task.Delay(TimeSpan.FromSeconds(VariantGroupPollIntervalSeconds));
}

if (groups == null || groups.Count == 0)
{
// Personalize setup succeeded but the CMS side never produced a
// Variant Group in time — a real product/integration bug, not an
// environment precondition, so fail loudly instead of silently
// degrading to a hardcoded UID like this test used to.
Assert.Fail(
$"Personalize project/audience/experience were created (project={projectUid}, " +
$"audience={audienceUid}, experience={experienceUid}) but no Variant Group appeared " +
$"on the stack within {VariantGroupPollTimeoutSeconds}s.");
return;
}
}
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.Forbidden || ex.StatusCode == HttpStatusCode.Unauthorized)
{
Assert.Inconclusive("Personalize is not enabled/entitled for this organization; cannot drive dynamic variant setup. " + ex.Message);
return;
}
catch (Exception ex)
{
// Environment/transient issue (network, unexpected schema, etc.) — don't
// block the rest of the suite on this, but make it loudly diagnosable.
Assert.Inconclusive("Personalize-driven variant setup failed unexpectedly: " + ex.Message);
return;
}
}

_variantGroupUid = groups[0]?["uid"]?.ToString();
Expand All @@ -289,9 +354,8 @@ public async System.Threading.Tasks.Task Test001_Ensure_Setup_Data()

if (string.IsNullOrEmpty(_variantUid))
{
// Fallback to demo UIDs if none are returned by the API so the test doesn't skip
_variantUid = "cs372c03252b23f623";
Console.WriteLine("Warning: The variant group had no variants. Using a hardcoded variant UID for testing: " + _variantUid);
Assert.Fail("Variant Group was found/created but contains no variants.");
return;
}

TestOutputLogger.LogContext("VariantGroup", _variantGroupUid);
Expand Down
Loading
Loading