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 1a571db5b133..14c94da4ebd5 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 2be7b2246fbd..bf74da3366b2 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