Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -120,4 +121,14 @@ ResponseEntityView<Language> update(@PathParam("languageId") String languageId,
summary = " Deletes an existing language from the system"
)
ResponseEntityView<String> 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<Language> makeDefault(@PathParam("languageId") String languageId,
Map<String, Boolean> form);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 &gt; 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.
*
* <p>Strategy:
* <ol>
* <li>List languages</li>
* <li>If the entry marked {@code defaultLanguage=true} has a valid id, we are done.</li>
* <li>Otherwise pick the first language with id &gt; 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.</li>
* </ol>
*/
private void warmLanguageCacheIfNeeded() {
if (languageCacheWarmed) {
Expand All @@ -77,15 +87,19 @@ private void warmLanguageCacheIfNeeded() {
final int maxAttempts = 3;
for (int attempt = 1; attempt <= maxAttempts; attempt++) {
try {
final ResponseEntityView<List<Language>> response =
clientFactory.getClient(LanguageAPI.class).list();
final List<Language> 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<Language> 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
}
Expand All @@ -98,6 +112,44 @@ private void warmLanguageCacheIfNeeded() {
}
}

private List<Language> listLanguages() {
final ResponseEntityView<List<Language>> response =
clientFactory.getClient(LanguageAPI.class).list();
return response != null ? response.entity() : null;
}

private static boolean hasValidDefault(final List<Language> 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<Language> 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
Expand Down
Loading