Skip to content

Commit 77e1270

Browse files
feat: custom HTML test report + AM-org scan, 2FA login and personalize coverage
Custom report (target/custom-report/index.html, JS-suite mochawesome parity): - TestReporter JUnit extension (auto-registered): per-test result, duration, expected-vs-actual on failure, and every SDK HTTP call with status, timing, masked copy-paste cURL, truncated response body and detected SDK method - AuthInterceptor gains a minimal RequestObserver seam (the SDK has no JS-style plugins API yet; this is its smallest form) - inert unless set - Pipeline serves the custom report as the primary artifact Follow-ups (all skip gracefully when env is absent): - AssetScanAMAPITest: scan tests on a second stack created in the Asset-Management org (AM_ORG_UID) - JS suite Part-2 parity - TfaLoginAPITest: 2FA/TOTP login coverage (TFA_EMAIL/TFA_PASSWORD/MFA_SECRET), including TOTP generation via mfaSecret and ambiguous-input rejection - TestStackContext creates/deletes an AM-org stack and a Personalize project (PERSONALIZE_HOST) alongside the main dynamic stack Verified: dynamic suite 252 tests / 0 failures / 2 skipped; stack, AM stack and personalize project all cleaned up after the run.
1 parent 6ca2b14 commit 77e1270

9 files changed

Lines changed: 893 additions & 6 deletions

File tree

src/main/java/com/contentstack/cms/core/AuthInterceptor.java

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,41 @@ public Response intercept(Chain chain) throws IOException {
9797
String commaSeparated = String.join(", ", earlyAccess);
9898
request.addHeader(Util.EARLY_ACCESS_HEADER, commaSeparated);
9999
}
100-
return executeRequest(chain, request.build(), 0);
100+
Request finalRequest = request.build();
101+
long startedAt = System.currentTimeMillis();
102+
Response response = executeRequest(chain, finalRequest, 0);
103+
RequestObserver localObserver = observer;
104+
if (localObserver != null) {
105+
try {
106+
localObserver.onCall(finalRequest, response, System.currentTimeMillis() - startedAt);
107+
} catch (Exception ignored) {
108+
// observers must never break real requests
109+
}
110+
}
111+
return response;
112+
}
113+
114+
/**
115+
* Observer for outgoing requests/responses. Intended for test harnesses
116+
* and diagnostics (e.g. capturing cURL commands for test reports);
117+
* not part of the public API surface.
118+
*/
119+
public interface RequestObserver {
120+
void onCall(Request request, Response response, long durationMs);
121+
}
122+
123+
private static volatile RequestObserver observer;
124+
125+
/**
126+
* Registers a JVM-wide request observer (pass null to remove). The observer
127+
* is invoked after each request completes, with the final request (including
128+
* interceptor-added headers) and the response. Exceptions thrown by the
129+
* observer are swallowed.
130+
*
131+
* @param requestObserver the observer, or null to unregister
132+
*/
133+
public static void setRequestObserver(RequestObserver requestObserver) {
134+
observer = requestObserver;
101135
}
102136

103137
/**

src/test/java/com/contentstack/cms/TestReporter.java

Lines changed: 412 additions & 0 deletions
Large diffs are not rendered by default.

src/test/java/com/contentstack/cms/TestStackContext.java

Lines changed: 189 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,19 @@ public final class TestStackContext {
6565
private static String managementToken;
6666
private static String managementTokenUid;
6767

68+
// Optional: second stack inside the Asset-Management org (AM_ORG_UID) for
69+
// DAM / Contentstack Assets scan tests - mirrors the JS suite's Part 2.
70+
private static final String AM_ORG_UID = getEnv("AM_ORG_UID", "amOrgUid", null);
71+
private static String amStackApiKey;
72+
private static String amStackName;
73+
private static boolean amStackCreated = false;
74+
75+
// Optional: Personalize project linked to the test stack (JS suite parity)
76+
private static final String PERSONALIZE_HOST =
77+
getEnv("PERSONALIZE_HOST", "personalizeHost", "personalize-api.contentstack.com").trim();
78+
private static String personalizeProjectUid;
79+
private static boolean personalizeCreated = false;
80+
6881
private static boolean setupAttempted = false;
6982
private static boolean stackCreated = false;
7083
private static boolean tornDown = false;
@@ -131,10 +144,16 @@ public static synchronized void ensureSetup() {
131144
new FixtureSeeder(HOST, stackApiKey, authtoken).seedAll();
132145
}
133146

147+
// Optional extras (both non-fatal; skipped when env not configured)
148+
createAmStack();
149+
createPersonalizeProject();
150+
134151
System.out.println("============================================================");
135152
System.out.println("[TestStackContext] Dynamic setup complete");
136153
System.out.println("[TestStackContext] Stack: " + stackName + " (" + stackApiKey + ")");
137154
System.out.println("[TestStackContext] Management token: " + (managementToken != null ? "created" : "NOT created (tests fall back to authtoken)"));
155+
System.out.println("[TestStackContext] AM stack: " + (amStackCreated ? amStackName + " (" + amStackApiKey + ")" : "not created (AM_ORG_UID unset or failed)"));
156+
System.out.println("[TestStackContext] Personalize project: " + (personalizeCreated ? personalizeProjectUid : "not created"));
138157
System.out.println("============================================================");
139158
} catch (Exception e) {
140159
System.err.println("[TestStackContext] Dynamic setup FAILED: " + e.getMessage());
@@ -159,11 +178,21 @@ public static synchronized void teardown() {
159178
System.out.println("[TestStackContext] Stack: " + stackName);
160179
System.out.println("[TestStackContext] API key: " + stackApiKey);
161180
System.out.println("[TestStackContext] Management token: " + managementToken);
162-
System.out.println("[TestStackContext] Remember to delete it manually when done!");
181+
if (amStackCreated) {
182+
System.out.println("[TestStackContext] AM stack: " + amStackName + " (" + amStackApiKey + ")");
183+
}
184+
if (personalizeCreated) {
185+
System.out.println("[TestStackContext] Personalize project: " + personalizeProjectUid);
186+
}
187+
System.out.println("[TestStackContext] Remember to delete them manually when done!");
163188
writeStatusFile("preserved " + stackName + " " + stackApiKey);
164189
return;
165190
}
166191

192+
// Linked/secondary resources first, then the main stack
193+
deletePersonalizeProject();
194+
deleteAmStack();
195+
167196
try {
168197
Request request = new Request.Builder()
169198
.url("https://" + HOST + "/v3/stacks")
@@ -232,6 +261,21 @@ public static boolean isStackCreated() {
232261
return stackCreated;
233262
}
234263

264+
/** @return api key of the AM-org test stack, or null when not created */
265+
public static String getAmStackApiKey() {
266+
return amStackApiKey;
267+
}
268+
269+
/** @return true when the optional AM-org stack exists (AM_ORG_UID configured) */
270+
public static boolean isAmStackCreated() {
271+
return amStackCreated;
272+
}
273+
274+
/** @return uid of the Personalize project linked to the test stack, or null */
275+
public static String getPersonalizeProjectUid() {
276+
return personalizeProjectUid;
277+
}
278+
235279
// =============================================================================
236280
// Setup steps
237281
// =============================================================================
@@ -376,6 +420,150 @@ private static boolean createManagementTokenWithScope(String tokenName, String[]
376420
}
377421
}
378422

423+
/**
424+
* Creates a second stack inside the Asset-Management org (AM_ORG_UID) for
425+
* DAM / Contentstack Assets scan tests - the JS suite's "Part 2" pattern.
426+
* Skipped silently when AM_ORG_UID is not configured; failures are non-fatal
427+
* (AM tests skip themselves when the stack is absent).
428+
*/
429+
@SuppressWarnings("unchecked")
430+
private static void createAmStack() {
431+
if (AM_ORG_UID == null || AM_ORG_UID.trim().isEmpty()) {
432+
return;
433+
}
434+
amStackName = "SDK_Test_Java_AM_" + shortId();
435+
System.out.println("[TestStackContext] Creating AM-org test stack: " + amStackName + " ...");
436+
try {
437+
JSONObject stack = new JSONObject();
438+
stack.put("name", amStackName);
439+
stack.put("description", "Automated Java CMA SDK AM-org test stack");
440+
stack.put("master_locale", "en-us");
441+
JSONObject body = new JSONObject();
442+
body.put("stack", stack);
443+
Request request = new Request.Builder()
444+
.url("https://" + HOST + "/v3/stacks")
445+
.header("authtoken", authtoken)
446+
.header("organization_uid", AM_ORG_UID.trim())
447+
.post(RequestBody.create(body.toJSONString(), JSON_MEDIA))
448+
.build();
449+
try (Response response = http.newCall(request).execute()) {
450+
String responseBody = response.body() != null ? response.body().string() : "";
451+
if (!response.isSuccessful()) {
452+
System.err.println("[TestStackContext] AM stack creation failed (" + response.code() + "): "
453+
+ responseBody + " - AM scan tests will be skipped");
454+
return;
455+
}
456+
JSONObject json = (JSONObject) parser.parse(responseBody);
457+
JSONObject stackObj = (JSONObject) json.get("stack");
458+
amStackApiKey = (String) stackObj.get("api_key");
459+
amStackCreated = true;
460+
}
461+
System.out.println("[TestStackContext] Created AM stack " + amStackName + " (api_key: " + amStackApiKey + ")");
462+
Thread.sleep(5000); // same provisioning wait as the main stack
463+
} catch (Exception e) {
464+
System.err.println("[TestStackContext] AM stack creation error: " + e.getMessage()
465+
+ " - AM scan tests will be skipped");
466+
}
467+
}
468+
469+
/**
470+
* Creates a Personalize project linked to the test stack (JS suite parity).
471+
* Non-fatal: personalize-dependent tests skip when the project is absent.
472+
*/
473+
@SuppressWarnings("unchecked")
474+
private static void createPersonalizeProject() {
475+
if (PERSONALIZE_HOST == null || PERSONALIZE_HOST.isEmpty()) {
476+
return;
477+
}
478+
String projectName = "SDK_Test_Java_Proj_" + shortId();
479+
System.out.println("[TestStackContext] Creating personalize project: " + projectName + " ...");
480+
try {
481+
JSONObject body = new JSONObject();
482+
body.put("name", projectName);
483+
body.put("description", "Automated Java CMA SDK test project");
484+
body.put("connectedStackApiKey", stackApiKey);
485+
Request request = new Request.Builder()
486+
.url("https://" + PERSONALIZE_HOST + "/projects")
487+
.header("authtoken", authtoken)
488+
.header("organization_uid", ORGANIZATION)
489+
.post(RequestBody.create(body.toJSONString(), JSON_MEDIA))
490+
.build();
491+
try (Response response = http.newCall(request).execute()) {
492+
String responseBody = response.body() != null ? response.body().string() : "";
493+
if (!response.isSuccessful()) {
494+
System.err.println("[TestStackContext] Personalize project creation failed ("
495+
+ response.code() + "): " + truncate(responseBody) + " - personalize tests will be skipped");
496+
return;
497+
}
498+
JSONObject json = (JSONObject) parser.parse(responseBody);
499+
Object uid = json.get("uid") != null ? json.get("uid")
500+
: json.get("project_uid") != null ? json.get("project_uid") : json.get("_id");
501+
personalizeProjectUid = uid != null ? uid.toString() : null;
502+
personalizeCreated = personalizeProjectUid != null;
503+
}
504+
if (personalizeCreated) {
505+
System.out.println("[TestStackContext] Created personalize project: " + personalizeProjectUid);
506+
}
507+
} catch (Exception e) {
508+
System.err.println("[TestStackContext] Personalize project creation error: " + e.getMessage());
509+
}
510+
}
511+
512+
private static void deleteAmStack() {
513+
if (!amStackCreated) {
514+
return;
515+
}
516+
try {
517+
Request request = new Request.Builder()
518+
.url("https://" + HOST + "/v3/stacks")
519+
.header("api_key", amStackApiKey)
520+
.header("authtoken", authtoken)
521+
.delete()
522+
.build();
523+
try (Response response = http.newCall(request).execute()) {
524+
if (response.isSuccessful()) {
525+
System.out.println("[TestStackContext] Deleted AM test stack: " + amStackName);
526+
amStackCreated = false;
527+
} else {
528+
System.err.println("[TestStackContext] AM stack deletion failed with " + response.code()
529+
+ " - delete manually: " + amStackName + " (" + amStackApiKey + ")");
530+
}
531+
}
532+
} catch (Exception e) {
533+
System.err.println("[TestStackContext] AM stack deletion error: " + e.getMessage()
534+
+ " - delete manually: " + amStackName + " (" + amStackApiKey + ")");
535+
}
536+
}
537+
538+
private static void deletePersonalizeProject() {
539+
if (!personalizeCreated) {
540+
return;
541+
}
542+
try {
543+
Request request = new Request.Builder()
544+
.url("https://" + PERSONALIZE_HOST + "/projects/" + personalizeProjectUid)
545+
.header("authtoken", authtoken)
546+
.header("organization_uid", ORGANIZATION)
547+
.delete()
548+
.build();
549+
try (Response response = http.newCall(request).execute()) {
550+
if (response.isSuccessful()) {
551+
System.out.println("[TestStackContext] Deleted personalize project: " + personalizeProjectUid);
552+
personalizeCreated = false;
553+
} else {
554+
System.err.println("[TestStackContext] Personalize project deletion failed with " + response.code()
555+
+ " - delete manually: " + personalizeProjectUid);
556+
}
557+
}
558+
} catch (Exception e) {
559+
System.err.println("[TestStackContext] Personalize project deletion error: " + e.getMessage());
560+
}
561+
}
562+
563+
private static String truncate(String s) {
564+
return s != null && s.length() > 160 ? s.substring(0, 160) + "..." : String.valueOf(s);
565+
}
566+
379567
/**
380568
* Freshly created management tokens are propagated asynchronously on some
381569
* environments (observed on dev11: intermittent 412s when the token is used

src/test/java/com/contentstack/cms/TestSuiteLifecycle.java

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,10 @@ public void launcherSessionOpened(LauncherSession session) {
3333

3434
@Override
3535
public void launcherSessionClosed(LauncherSession session) {
36-
// Intentionally empty - see class javadoc. Teardown runs in the JVM
37-
// shutdown hook so nested launcher sessions can't kill the stack early.
36+
// Stack teardown deliberately does NOT happen here - see class javadoc;
37+
// it runs in the JVM shutdown hook so nested launcher sessions can't
38+
// kill the stack early. Refreshing the custom report here is safe:
39+
// it just overwrites a file, and the shutdown hook writes the final one.
40+
TestReporter.writeReport();
3841
}
3942
}

src/test/java/com/contentstack/cms/stack/APISanityTestSuite.java

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.contentstack.cms.stack;
22

33
import com.contentstack.cms.organization.OrgApiTests;
4+
import com.contentstack.cms.user.TfaLoginAPITest;
45
import org.junit.platform.runner.JUnitPlatform;
56
import org.junit.platform.suite.api.SelectClasses;
67
import org.junit.runner.RunWith;
@@ -29,6 +30,7 @@
2930
LocaleAPITest.class,
3031
EnvironmentAPITest.class,
3132
AssetAPITest.class,
33+
AssetScanAMAPITest.class,
3234
TaxonomyAPITest.class,
3335
// Phase C: schema (global fields before content types)
3436
GlobalFieldAPITest.class,
@@ -47,8 +49,9 @@
4749
RoleAPITest.class,
4850
TokenAPITest.class,
4951
ReleaseAPITest.class,
50-
// Phase G: org-level
51-
OrgApiTests.class
52+
// Phase G: org/user-level (2FA login last - it creates its own sessions)
53+
OrgApiTests.class,
54+
TfaLoginAPITest.class
5255
})
5356
public class APISanityTestSuite {
5457

0 commit comments

Comments
 (0)