Skip to content

fix(content): handle language-only locales without a country code#36405

Open
jcastro-dotcms wants to merge 10 commits into
mainfrom
issue-36106-language-only-locale-country-npe
Open

fix(content): handle language-only locales without a country code#36405
jcastro-dotcms wants to merge 10 commits into
mainfrom
issue-36106-language-only-locale-country-npe

Conversation

@jcastro-dotcms

@jcastro-dotcms jcastro-dotcms commented Jul 2, 2026

Copy link
Copy Markdown
Member

Summary

Core fix for #36106 — language-only locales (e.g. es, no country code) crash multiple code paths because several places pass a Language's nullable country/countryCode straight into null-hostile collection builders or the Locale constructor:

  • LanguageViewStrategy.mapLanguage() built a Guava ImmutableMap.Builder, which throws NullPointerException("null value in entry: country=null") on a null value — crashing any transform that maps a contentlet's Language (new Edit Content save, History tab, GraphQL LanguageDataFetcher). Fixed by defaulting country/countryCode to "" via CollectionsUtils.orElseGet(), matching the existing pattern in Language.toMap().
  • VisitorAPIImpl.createVisitor() built a Locale via new Locale(language, country), which throws NullPointerException when country is null — so a page could never render at all in a language-only locale (this runs on every request, via visitor creation). Fixed by switching to Language.asLocale(), which already null-guards this exact case and is already used elsewhere (LanguageWebAPIImpl, JsLanguage).
  • PublishQueueElementTransformer.getMapForLanguage() built a java.util.Map.of(...) directly from language.getCountryCode()Map.of() rejects null values the same way Guava's builder does, crashing the Push Publishing queue view for any queued Language asset in a language-only locale. Fixed the same way (default to blank before building the map).

All three are the same underlying null-country data shape, hit through three independent code paths (response serialization, page rendering, Push Publishing queue).

Test coverage added:

  • LanguageViewStrategyTest — null-country regression (wrapAsMap=false/true), the transform() entry point, isoCode assertions on both, and a non-regression check for locales that do have a country.
  • PublishQueueElementTransformerTest — null-country regression and non-regression check.
  • Both were verified against the unpatched code first, to confirm they reproduce the exact reported exceptions before the fix.

Also included, unrelated to the #36106 fix above: a change to VelocityHealthCheck that defers its readiness probe until WebKeys.DOTCMS_STARTED_UP ("dotcms.started.up") is "true". VelocityUtil.getEngine() triggers a lazy Velocity engine init that touches the DB/company context, which on a fresh install can fire before Task00001LoadSchema runs, throwing a noisy "No Company!" exception during startup. This just quiets that one-time startup exception.

This guard broke VelocityHealthCheckTest's existing assumptions: none of its 3 tests set dotcms.started.up, so every test was short-circuiting on the new deferred branch before ever reaching the mocked engine — one failed outright (returnsUpWhenRenderMarksResolves, expected UP got DOWN), and the other two were passing for the wrong reason, no longer exercising the logic they claim to test. Fixed by having the test set/restore that property around each run, and added returnsDownWhenStartupNotComplete to cover the deferred branch itself, which had no coverage before.

Why System.getProperty() and not Config.getStringProperty() for this flag: Config only resolves values from three sources, in order — environment variables (DOT_* prefix), dotCMS's DB-backed "system table", and dotmarketing-config.properties/dotcms-config-cluster.properties files (see Config.java's own class javadoc and getStringArrayProperty()). There is no code path in Config that ever reads a plain JVM system property set via System.setProperty(...). dotcms.started.up is set exactly that way, in InitServlet.java, as a pure runtime sentinel flipped once at startup completion — it's never written to a config file, env var, or the system table. Routing the read through Config.getStringProperty(WebKeys.DOTCMS_STARTED_UP, ...) would mean this check could never see "true", so the readiness probe would report "startup not yet complete" forever, even long after dotCMS finished starting — a permanent regression, not a style improvement. System.getProperty(...) is also the existing, established pattern for reading this exact flag elsewhere (ServletContainerHealthCheck, MonitorHelper). The one change made here is using the WebKeys.DOTCMS_STARTED_UP constant instead of the hardcoded "dotcms.started.up" string, in both the production code and its test.

Closes #36106

Test plan

  • Added LanguageViewStrategyTest (4 tests) — reproduces the exact reported NullPointerException: null value in entry: country=null on the unpatched code, passes with the fix
  • Added PublishQueueElementTransformerTest (2 tests) — reproduces the Map.of() NullPointerException on the unpatched code, passes with the fix
  • Fixed VelocityHealthCheckTest (now 4 tests) — verified it fails/mis-passes on the unpatched test, passes correctly after the fix
  • ./mvnw -pl :dotcms-core compile -DskipTests — compiles clean
  • Manually reproduced the VisitorAPIImpl NPE (java.lang.NullPointerException at Locale.<init>) by rendering a page in a language-only locale; confirmed fixed with Language.asLocale()
  • Manually verify in the new Edit Content UI: create a new version for a language-only locale (e.g. es) and confirm no "Unknown Error" / 500
  • Manually verify rendering a page in a language-only locale no longer 500s
  • Manually verify the Push Publishing queue view for a language-only-locale Language asset no longer 500s

🤖 Generated with Claude Code

@claude

claude Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Claude finished @jcastro-dotcms's task in 2m 53s —— View job


I'll analyze this and get back to you.

@github-actions

github-actions Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

🤖 dotBot Review (Bedrock)

Reviewed 7 file(s); 3 candidate(s) → 2 confirmed, 0 uncertain (unverified, kept for review).

Confirmed findings

  • 🟡 Medium dotCMS/src/test/java/com/dotcms/health/checks/cdi/VelocityHealthCheckTest.java:63 — Test stubs Velocity engine despite startup check
    The test 'returnsDownWhenStartupNotComplete' stubs VelocityUtil.getEngine() (line 63) even though the production code should short-circuit before reaching VelocityUtil when DOTCMS_STARTED_UP isn't set. This creates a false positive scenario where the test would pass even if the production code incorrectly accessed VelocityUtil during startup phase. The stub masks potential premature engine initialization that the test is meant to validate against.
  • 🟡 Medium dotCMS/src/test/java/com/dotmarketing/portlets/contentlet/transform/strategy/LanguageViewStrategyTest.java:73 — Incorrect ISO code casing in LanguageViewStrategyTest
    The test expects 'es-cr' for country code 'CR', but standard locale formatting uses uppercase country codes (es-CR). The production code uses the raw country code, which is uppercase, leading to a mismatch. The test's assertion is incorrect and should expect 'es-CR' instead.

us.deepseek.r1-v1:0 · Run: #28680764298 · tokens: in: 32834 · out: 7630 · total: 40464 · calls: 10 · est. ~$0.086

…Language

Guava's ImmutableMap.Builder.put() throws NullPointerException on a null
value. Language-only locales (no country code) legitimately have a null
country/countryCode, which crashed any transform path that maps a
contentlet's Language (new Edit Content save, History tab, GraphQL).

Adds LanguageViewStrategyTest covering the null-country regression, the
wrapAsMap=true path, the transform() entry point, and a non-regression
check for locales that do have a country.

Fixes #36106
Not related to the #36106 language locale fix in this PR. This addresses
a separate, unrelated startup-time exception: VelocityUtil.getEngine()
triggers a lazy Velocity engine init that touches the DB/company context,
which on a fresh install can fire before Task00001LoadSchema has run,
throwing a "No Company!" exception during boot. Deferring the probe until
InitServlet sets dotcms.started.up=true avoids that noisy exception.
@jcastro-dotcms jcastro-dotcms force-pushed the issue-36106-language-only-locale-country-npe branch from 1054373 to 0156e31 Compare July 2, 2026 18:40
VisitorAPIImpl.createVisitor() built a Locale via new Locale(language, country),
which throws NullPointerException when country is null. Language-only locales
(no country code) hit this on every page render, since VisitorAPIImpl.getVisitor()
runs on every request. Language.asLocale() already null-guards this exact case
(falls back to the 1-arg Locale constructor), so use it instead of constructing
the Locale manually. Same underlying null-country data shape as the
LanguageViewStrategy fix in this PR, different code path (page rendering /
visitor creation vs. content transform).
PublishQueueElementTransformer.getMapForLanguage() built a java.util.Map.of(...)
directly from language.getCountryCode(), which is null for a language-only
locale (e.g. "es", no country). Map.of() throws NullPointerException on any
null value, same failure mode as Guava's ImmutableMap.Builder fixed elsewhere
in this PR. This crashed the Push Publishing queue view for any queued
Language asset in a language-only locale.

Defaults countryCode to blank before building the map, and adds
PublishQueueElementTransformerTest covering the null-country regression and
a non-regression check for locales with a country.
Addresses dotBot review feedback on PR #36405: the wrapAsMap=true and
transform() test cases checked country/countryCode but not isoCode,
leaving a gap in coverage for the ISO code formatting when country is
absent.
@jcastro-dotcms

Copy link
Copy Markdown
Member Author

Regarding the feedback from the dotBot Review (Bedrock):

I went through all four findings and verified them against the actual code before acting.

The two "Medium" isoCode findings are legitimate — good catch. testMapLanguage_whenWrapAsMapAndCountryIsNull_wrapsBlankCountryUnderLanguageMapKey and testTransform_whenCountryIsNull_doesNotThrowAndPutsBlankCountryIntoMap checked country/countryCode but not isoCode, even though the other two tests in the file do. Added assertEquals("fr", inner.get("isoCode")) and assertEquals("es", languageMap.get("isoCode")) respectively in the latest commit.

The "Critical" finding is incorrect. It claims APIProvider is an interface without a languageAPI field, causing NoSuchFieldException. APIProvider is a concrete class (dotCMS/src/main/java/com/dotcms/api/APIProvider.java) with a real public final LanguageAPI languageAPI field, so APIProvider.class.getDeclaredField("languageAPI") works as written. This test has been run repeatedly (including a before/after check that reproduced the original NPE on the unpatched code) and passes consistently — 4/4 green.

The "Medium" System.getProperty finding doesn't apply here either. dotcms.started.up is set via System.setProperty(...) in InitServlet.java — a pure JVM runtime sentinel flipped once at startup completion, not a config-file value. Two sibling health-check classes in the same package (ServletContainerHealthCheck.java, MonitorHelper.java) already read this exact flag the same way. Routing it through Config.getStringProperty would be inconsistent with how the flag is actually produced and isn't the established pattern for it.

So: 2/4 findings addressed, 2/4 not applicable after verification. Happy to discuss further if I'm missing context on either of the latter two.

VelocityHealthCheck.performCheck() now defers until dotcms.started.up=true
before probing the engine. The test never set that property, so every test
short-circuited on the deferred branch before reaching the mocked engine:
returnsUpWhenRenderMarksResolves failed outright (expected UP, got DOWN),
and the other two passed for the wrong reason, no longer exercising the
literal-marker/engine-exception logic they're meant to cover.

Sets dotcms.started.up=true in setUp() (restored in tearDown()) so the
probe logic actually runs, and adds returnsDownWhenStartupNotComplete to
cover the deferred branch itself, which had no test before.
@jcastro-dotcms

Copy link
Copy Markdown
Member Author

@claude the issue in the LanguageViewStrategyTest flagged by the dotBot Review (Bedrock) is invalid and an explanation has already been posted. Ill post it here once more:

This one doesn't hold up either. LanguageViewStrategy.java:94 applies .toLowerCase() to the whole ISO string unconditionally, right after the concatenation:

final String iso = UtilMethods.isSet(language.getCountryCode())
        ? language.getLanguageCode() + StringPool.DASH + language.getCountryCode()
        : language.getLanguageCode();
builder.put("isoCode", iso.toLowerCase());

So for languageCode="es", countryCode="CR": iso = "es-CR" from the concatenation, then .toLowerCase() turns it into "es-cr" before it's ever stored. "es-CR" (mixed case) is never actually produced by this method — the assertion in the test matches production behavior exactly, and it passes (reran it fresh, bypassing the build cache, to confirm: 4/4 green).

Looks like the case-sensitivity concern is valid in principle for ISO 639-1/3166 formatting, but this codebase's convention (at least in this method) is to normalize the whole isoCode to lowercase, so the test is correct as written.

@claude

claude Bot commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Claude finished @jcastro-dotcms's task in 27s —— View job


Verifying the LanguageViewStrategyTest isoCode finding

  • Read LanguageViewStrategy.java and LanguageViewStrategyTest.java to independently verify the dotBot finding
  • Confirmed the finding is invalid; posted explanation

Confirmed: the dotBot finding is invalid. I independently read both files and the explanation holds up.

LanguageViewStrategy.java:91-94:

final String iso = UtilMethods.isSet(language.getCountryCode())
        ? language.getLanguageCode() + StringPool.DASH + language.getCountryCode()
        : language.getLanguageCode();
builder.put("isoCode", iso.toLowerCase());

For languageCode="es", countryCode="CR" (used in testMapLanguage_whenCountryIsSet_mapsCountryAndIsoCode, LanguageViewStrategyTest.java:71-79): the ternary produces "es-CR", and .toLowerCase() is then applied unconditionally to the whole concatenated string before it's ever stored — the result is "es-cr". Mixed-case "es-CR" is never actually produced by this method, so the test's assertion at line 78 (assertEquals("es-cr", result.get("isoCode"))) matches production behavior exactly.

The case-sensitivity convention (lowercase language, uppercase country) is a valid general ISO 639-1/3166 convention, but it's simply not this method's behavior — mapLanguage() normalizes the entire isoCode string to lowercase by design. The test is correct as written; no change needed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

AI: Safe To Rollback Area : Backend PR changes Java/Maven backend code

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

[DEFECT] New Edit Content: locale version creation crashes with 500 when using a language-only locale (no country)

1 participant