From dd4d7a9ec174ee55c6a345caf0c9d6833e71e46a Mon Sep 17 00:00:00 2001 From: "daniel.solis" Date: Fri, 22 May 2026 12:22:26 -0600 Subject: [PATCH] test(cli): fix default-language test env state, not just retry (#35780) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Diagnosis of the latest CLI failure showed the SiteAPIIT failure is deterministic, not a timing race. The CI dotCMS instance returns: {"language":"LANG__404", "id":-1, "defaultLanguage": true}, {"language":"English", "id": 1, "defaultLanguage": false} Bumping retries or backoff cannot fix this — the default is permanently the LANG__404 sentinel (id=-1). The existing warmup passed because it only verified that ANY language had id>0 (English does), not that the DEFAULT language had id>0. This strengthens the warmup: - After listing, check whether the entry marked defaultLanguage=true actually has id>0. If yes, we are done. - If not, pick the first language with id>0 (English in starter data) and call PUT /api/v2/languages/{id}/_makedefault with fireTransferAssetsJob=false to fix the broken state. - Re-list to confirm before declaring success. Adds the corresponding makeDefault() method to the CLI LanguageAPI interface so the warmup can call the endpoint via the existing Quarkus REST client. The retry-on-language-null workaround from #35803 is preserved as defense-in-depth, but should not be needed if the warmup succeeds. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../main/java/com/dotcms/api/LanguageAPI.java | 11 +++ .../test/java/com/dotcms/api/SiteAPIIT.java | 76 ++++++++++++++++--- 2 files changed, 75 insertions(+), 12 deletions(-) diff --git a/tools/dotcms-cli/api-data-model/src/main/java/com/dotcms/api/LanguageAPI.java b/tools/dotcms-cli/api-data-model/src/main/java/com/dotcms/api/LanguageAPI.java index 1a571db5b13..14c94da4ebd 100644 --- a/tools/dotcms-cli/api-data-model/src/main/java/com/dotcms/api/LanguageAPI.java +++ b/tools/dotcms-cli/api-data-model/src/main/java/com/dotcms/api/LanguageAPI.java @@ -8,6 +8,7 @@ import com.dotcms.model.views.CommonViews.LanguageFileView; import com.fasterxml.jackson.annotation.JsonView; import java.util.List; +import java.util.Map; import jakarta.ws.rs.Consumes; import jakarta.ws.rs.DELETE; import jakarta.ws.rs.GET; @@ -120,4 +121,14 @@ ResponseEntityView update(@PathParam("languageId") String languageId, summary = " Deletes an existing language from the system" ) ResponseEntityView delete(@PathParam("languageId") String languageId); + + @PUT + @Path("/{languageId}/_makedefault") + @Operation( + summary = " Makes the language with the given id the default language. The body field " + + "'fireTransferAssetsJob' controls whether a background job is scheduled to " + + "transfer assets from the previous default language." + ) + ResponseEntityView makeDefault(@PathParam("languageId") String languageId, + Map form); } diff --git a/tools/dotcms-cli/api-data-model/src/test/java/com/dotcms/api/SiteAPIIT.java b/tools/dotcms-cli/api-data-model/src/test/java/com/dotcms/api/SiteAPIIT.java index 2be7b2246fb..bf74da3366b 100644 --- a/tools/dotcms-cli/api-data-model/src/test/java/com/dotcms/api/SiteAPIIT.java +++ b/tools/dotcms-cli/api-data-model/src/test/java/com/dotcms/api/SiteAPIIT.java @@ -17,6 +17,7 @@ import java.io.IOException; import java.net.URL; import java.util.List; +import java.util.Map; import java.util.function.Supplier; import jakarta.inject.Inject; import jakarta.ws.rs.NotFoundException; @@ -63,12 +64,21 @@ public void setupTest() throws IOException { } /** - * Warms the dotCMS language cache by listing languages, retrying up to 3 times to - * cover the race window right after a fresh dotCMS start where the default-language - * lookup can briefly return a non-positive id. When that happens, downstream site - * creation fails with HTTP 500 "Language cannot be null" because the contentlet ends - * up with languageId <= 0 and a unique-field validation tries to resolve it back to - * a Language. See issue #35780. + * Ensures the dotCMS test environment has a real default language (id > 0) before + * the SiteAPIIT tests run. Without this, {@code POST /api/v1/site} resolves the Host + * contentlet's languageId from {@code getDefaultLanguage()} — which in some CI test + * environments returns the {@code LANG__404} sentinel with id=-1, breaking the + * downstream unique-field validation with HTTP 500 "Language cannot be null". + * See issue #35780. + * + *

Strategy: + *

    + *
  1. List languages
  2. + *
  3. If the entry marked {@code defaultLanguage=true} has a valid id, we are done.
  4. + *
  5. Otherwise pick the first language with id > 0 (English in starter data) + * and call {@code PUT /api/v2/languages/{id}/_makedefault} to fix the broken + * default. {@code fireTransferAssetsJob=false} so this is a fast metadata change.
  6. + *
*/ private void warmLanguageCacheIfNeeded() { if (languageCacheWarmed) { @@ -77,15 +87,19 @@ private void warmLanguageCacheIfNeeded() { final int maxAttempts = 3; for (int attempt = 1; attempt <= maxAttempts; attempt++) { try { - final ResponseEntityView> response = - clientFactory.getClient(LanguageAPI.class).list(); - final List languages = response != null ? response.entity() : null; - final boolean hasValidLanguage = languages != null && languages.stream() - .anyMatch(l -> l != null && l.id().isPresent() && l.id().get() > 0); - if (hasValidLanguage) { + final List languages = listLanguages(); + if (hasValidDefault(languages)) { languageCacheWarmed = true; return; } + final Long fallbackId = pickFallbackLanguageId(languages); + if (fallbackId != null && tryMakeDefault(fallbackId)) { + // Re-list to confirm the fix took before declaring success. + if (hasValidDefault(listLanguages())) { + languageCacheWarmed = true; + return; + } + } } catch (Exception ignored) { // fall through to retry } @@ -98,6 +112,44 @@ private void warmLanguageCacheIfNeeded() { } } + private List listLanguages() { + final ResponseEntityView> response = + clientFactory.getClient(LanguageAPI.class).list(); + return response != null ? response.entity() : null; + } + + private static boolean hasValidDefault(final List languages) { + if (languages == null) { + return false; + } + return languages.stream().anyMatch(l -> + l != null + && l.id().isPresent() && l.id().get() > 0 + && l.defaultLanguage().orElse(false)); + } + + private static Long pickFallbackLanguageId(final List languages) { + if (languages == null) { + return null; + } + return languages.stream() + .filter(l -> l != null && l.id().isPresent() && l.id().get() > 0) + .map(l -> l.id().get()) + .findFirst() + .orElse(null); + } + + private boolean tryMakeDefault(final Long languageId) { + try { + clientFactory.getClient(LanguageAPI.class) + .makeDefault(String.valueOf(languageId), + Map.of("fireTransferAssetsJob", Boolean.FALSE)); + return true; + } catch (Exception ignored) { + return false; + } + } + /** * Retries the supplied Site create call when the server returns HTTP 500 * "Language cannot be null". The {@link #warmLanguageCacheIfNeeded()} warmup