From fd00f04b0f8e95dac55cd5864f40bdb3a4101ee5 Mon Sep 17 00:00:00 2001 From: Ben Blazke Date: Tue, 21 Oct 2025 15:18:01 -0700 Subject: [PATCH] SE-123: added product update resolver (#14980) --- .../datahub/graphql/GmsGraphQLEngine.java | 10 + .../resolvers/config/ProductUpdateParser.java | 72 ++++ .../config/ProductUpdateResolver.java | 75 ++++ .../src/main/resources/app.graphql | 47 +++ .../config/ProductUpdateParserTest.java | 386 ++++++++++++++++++ .../config/ProductUpdateResolverTest.java | 360 ++++++++++++++++ .../src/app/shared/product/update/hooks.ts | 4 +- .../app/shared/product/update/latestUpdate.ts | 6 +- .../PropertiesCollectorConfigurationTest.java | 2 + .../graphql/featureflags/FeatureFlags.java | 2 + .../src/main/resources/application.yaml | 2 + .../src/main/resources/product-update.json | 8 + .../service/ProductUpdateService.java | 188 +++++++++ .../service/ProductUpdateServiceTest.java | 282 +++++++++++++ .../resources/product-update-for-test.json | 8 + 15 files changed, 1448 insertions(+), 4 deletions(-) create mode 100644 datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/config/ProductUpdateParser.java create mode 100644 datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/config/ProductUpdateResolver.java create mode 100644 datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/config/ProductUpdateParserTest.java create mode 100644 datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/config/ProductUpdateResolverTest.java create mode 100644 metadata-service/configuration/src/main/resources/product-update.json create mode 100644 metadata-service/services/src/main/java/com/linkedin/metadata/service/ProductUpdateService.java create mode 100644 metadata-service/services/src/test/java/com/linkedin/metadata/service/ProductUpdateServiceTest.java create mode 100644 metadata-service/services/src/test/resources/product-update-for-test.json diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/GmsGraphQLEngine.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/GmsGraphQLEngine.java index 1f0ec329f6..f9ab6650d7 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/GmsGraphQLEngine.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/GmsGraphQLEngine.java @@ -53,6 +53,7 @@ import com.linkedin.datahub.graphql.resolvers.businessattribute.UpdateBusinessAt import com.linkedin.datahub.graphql.resolvers.chart.BrowseV2Resolver; import com.linkedin.datahub.graphql.resolvers.chart.ChartStatsSummaryResolver; import com.linkedin.datahub.graphql.resolvers.config.AppConfigResolver; +import com.linkedin.datahub.graphql.resolvers.config.ProductUpdateResolver; import com.linkedin.datahub.graphql.resolvers.connection.UpsertConnectionResolver; import com.linkedin.datahub.graphql.resolvers.container.ContainerEntitiesResolver; import com.linkedin.datahub.graphql.resolvers.container.ParentContainersResolver; @@ -340,6 +341,7 @@ import com.linkedin.metadata.service.LineageService; import com.linkedin.metadata.service.OwnershipTypeService; import com.linkedin.metadata.service.PageModuleService; import com.linkedin.metadata.service.PageTemplateService; +import com.linkedin.metadata.service.ProductUpdateService; import com.linkedin.metadata.service.QueryService; import com.linkedin.metadata.service.SettingsService; import com.linkedin.metadata.service.ViewService; @@ -405,6 +407,7 @@ public class GmsGraphQLEngine { private final InviteTokenService inviteTokenService; private final PostService postService; private final SettingsService settingsService; + private final ProductUpdateService productUpdateService; private final ViewService viewService; private final OwnershipTypeService ownershipTypeService; private final LineageService lineageService; @@ -547,6 +550,10 @@ public class GmsGraphQLEngine { this.viewService = args.viewService; this.ownershipTypeService = args.ownershipTypeService; this.settingsService = args.settingsService; + this.productUpdateService = + new ProductUpdateService( + args.featureFlags.getProductUpdatesJsonUrl(), + args.featureFlags.getProductUpdatesJsonFallbackResourceUrl()); this.lineageService = args.lineageService; this.queryService = args.queryService; this.erModelRelationshipService = args.erModelRelationshipService; @@ -988,6 +995,9 @@ public class GmsGraphQLEngine { this.featureFlags, this.chromeExtensionConfiguration, this.settingsService)) + .dataFetcher( + "latestProductUpdate", + new ProductUpdateResolver(this.productUpdateService, this.featureFlags)) .dataFetcher("me", new MeResolver(this.entityClient, featureFlags)) .dataFetcher("search", new SearchResolver(this.entityClient)) .dataFetcher( diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/config/ProductUpdateParser.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/config/ProductUpdateParser.java new file mode 100644 index 0000000000..3c36504400 --- /dev/null +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/config/ProductUpdateParser.java @@ -0,0 +1,72 @@ +package com.linkedin.datahub.graphql.resolvers.config; + +import com.fasterxml.jackson.databind.JsonNode; +import com.linkedin.datahub.graphql.generated.ProductUpdate; +import java.util.Optional; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import lombok.extern.slf4j.Slf4j; + +/** + * Utility for parsing product update JSON into GraphQL ProductUpdate objects. + * + *

Handles validation, field extraction, and error cases for product update data. + */ +@Slf4j +public class ProductUpdateParser { + + private ProductUpdateParser() { + // Utility class, no instantiation + } + + /** + * Parse JSON into a ProductUpdate object. + * + * @param jsonOpt Optional JSON node containing product update data + * @return ProductUpdate object if parsing succeeds and update is enabled, null otherwise + */ + @Nullable + public static ProductUpdate parseProductUpdate(@Nonnull Optional jsonOpt) { + if (jsonOpt.isEmpty()) { + log.debug("No product update JSON available"); + return null; + } + + JsonNode json = jsonOpt.get(); + + // Parse and validate required fields + if (!json.has("enabled") || !json.has("id") || !json.has("title")) { + log.warn("Product update JSON missing required fields (enabled, id, or title)"); + return null; + } + + boolean enabled = json.get("enabled").asBoolean(); + if (!enabled) { + log.debug("Product update is disabled in JSON"); + return null; + } + + String id = json.get("id").asText(); + String title = json.get("title").asText(); + String ctaText = json.has("ctaText") ? json.get("ctaText").asText() : "Learn more"; + String ctaLink = json.has("ctaLink") ? json.get("ctaLink").asText() : ""; + + // Build the ProductUpdate response + ProductUpdate productUpdate = new ProductUpdate(); + productUpdate.setEnabled(enabled); + productUpdate.setId(id); + productUpdate.setTitle(title); + productUpdate.setCtaText(ctaText); + productUpdate.setCtaLink(ctaLink); + + // Optional fields + if (json.has("description")) { + productUpdate.setDescription(json.get("description").asText()); + } + if (json.has("image")) { + productUpdate.setImage(json.get("image").asText()); + } + + return productUpdate; + } +} diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/config/ProductUpdateResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/config/ProductUpdateResolver.java new file mode 100644 index 0000000000..dbe2b545a2 --- /dev/null +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/config/ProductUpdateResolver.java @@ -0,0 +1,75 @@ +package com.linkedin.datahub.graphql.resolvers.config; + +import com.linkedin.datahub.graphql.featureflags.FeatureFlags; +import com.linkedin.datahub.graphql.generated.ProductUpdate; +import com.linkedin.metadata.service.ProductUpdateService; +import graphql.schema.DataFetcher; +import graphql.schema.DataFetchingEnvironment; +import java.util.concurrent.CompletableFuture; +import javax.annotation.Nonnull; +import lombok.extern.slf4j.Slf4j; + +/** + * Resolver for fetching the latest product update information. + * + *

Attempts to fetch from remote URL (with 6-hour cache), falling back to bundled classpath + * resource if unavailable. Returns null only if both remote and fallback fail, or if the feature is + * disabled. + * + *

Supports an optional {@code refreshCache} argument to clear the cache before fetching. + */ +@Slf4j +public class ProductUpdateResolver implements DataFetcher> { + + private final ProductUpdateService _productUpdateService; + private final FeatureFlags _featureFlags; + + public ProductUpdateResolver( + @Nonnull final ProductUpdateService productUpdateService, + @Nonnull final FeatureFlags featureFlags) { + this._productUpdateService = productUpdateService; + this._featureFlags = featureFlags; + } + + @Override + public CompletableFuture get(DataFetchingEnvironment environment) { + final Boolean refreshCache = environment.getArgument("refreshCache"); + final boolean shouldRefresh = refreshCache != null && refreshCache; + + return CompletableFuture.supplyAsync( + () -> { + try { + if (!_featureFlags.isShowProductUpdates()) { + log.debug("Product updates feature is disabled"); + return null; + } + + if (shouldRefresh) { + log.info("Clearing product update cache and refetching"); + _productUpdateService.clearCache(); + } + + ProductUpdate productUpdate = + ProductUpdateParser.parseProductUpdate( + _productUpdateService.getLatestProductUpdate()); + + if (productUpdate != null) { + log.debug( + "Successfully {} product update: {}", + shouldRefresh ? "refreshed" : "resolved", + productUpdate.getId()); + } + + return productUpdate; + + } catch (Exception e) { + log.warn( + "Failed to {} product update: {}", + shouldRefresh ? "refresh" : "resolve", + e.getMessage()); + log.debug("Product update error details", e); + return null; + } + }); + } +} diff --git a/datahub-graphql-core/src/main/resources/app.graphql b/datahub-graphql-core/src/main/resources/app.graphql index 780c3b5bc9..f0964f5701 100644 --- a/datahub-graphql-core/src/main/resources/app.graphql +++ b/datahub-graphql-core/src/main/resources/app.graphql @@ -27,6 +27,12 @@ extend type Query { Global settings related to the home page for an instance """ globalHomePageSettings: GlobalHomePageSettings + + """ + Fetch the latest product update information from configured JSON source. + Returns null if the JSON source is unavailable or feature is disabled. + """ + latestProductUpdate(refreshCache: Boolean = false): ProductUpdate } extend type Mutation { @@ -909,3 +915,44 @@ type GlobalHomePageSettings { """ defaultTemplate: DataHubPageTemplate } + +""" +Product update information fetched from remote JSON source +""" +type ProductUpdate { + """ + Whether this update is enabled and should be shown + """ + enabled: Boolean! + + """ + Unique identifier for this update (e.g., "v1.2.1") + Version changes trigger re-display for users who dismissed previous versions + """ + id: String! + + """ + Title of the update notification + """ + title: String! + + """ + Optional URL to an image to display with the update + """ + image: String + + """ + Optional description text for the update + """ + description: String + + """ + Call-to-action button text (e.g., "Read updates") + """ + ctaText: String! + + """ + Call-to-action link URL, with telemetry client ID appended + """ + ctaLink: String! +} diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/config/ProductUpdateParserTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/config/ProductUpdateParserTest.java new file mode 100644 index 0000000000..22783965d7 --- /dev/null +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/config/ProductUpdateParserTest.java @@ -0,0 +1,386 @@ +package com.linkedin.datahub.graphql.resolvers.config; + +import static org.testng.Assert.*; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.linkedin.datahub.graphql.generated.ProductUpdate; +import java.util.Optional; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +@SuppressWarnings("null") +public class ProductUpdateParserTest { + + private ObjectMapper objectMapper; + + @BeforeMethod + public void setupTest() { + objectMapper = new ObjectMapper(); + } + + @Test + public void testParseProductUpdateSuccess() throws Exception { + String jsonString = + "{" + + "\"enabled\": true," + + "\"id\": \"v1.0.0\"," + + "\"title\": \"What's New\"," + + "\"description\": \"New features\"," + + "\"image\": \"https://example.com/image.png\"," + + "\"ctaText\": \"Learn more\"," + + "\"ctaLink\": \"https://example.com\"" + + "}"; + JsonNode jsonNode = objectMapper.readTree(jsonString); + + ProductUpdate result = ProductUpdateParser.parseProductUpdate(Optional.of(jsonNode)); + + assertNotNull(result); + assertTrue(result.getEnabled()); + assertEquals(result.getId(), "v1.0.0"); + assertEquals(result.getTitle(), "What's New"); + assertEquals(result.getDescription(), "New features"); + assertEquals(result.getImage(), "https://example.com/image.png"); + assertEquals(result.getCtaText(), "Learn more"); + assertEquals(result.getCtaLink(), "https://example.com"); + } + + @Test + public void testParseProductUpdateMinimalFields() throws Exception { + String jsonString = + "{" + "\"enabled\": true," + "\"id\": \"v1.0.0\"," + "\"title\": \"What's New\"" + "}"; + JsonNode jsonNode = objectMapper.readTree(jsonString); + + ProductUpdate result = ProductUpdateParser.parseProductUpdate(Optional.of(jsonNode)); + + assertNotNull(result); + assertTrue(result.getEnabled()); + assertEquals(result.getId(), "v1.0.0"); + assertEquals(result.getTitle(), "What's New"); + assertNull(result.getDescription()); + assertNull(result.getImage()); + assertEquals(result.getCtaText(), "Learn more"); + assertEquals(result.getCtaLink(), ""); + } + + @Test + public void testParseProductUpdateWithCustomCta() throws Exception { + String jsonString = + "{" + + "\"enabled\": true," + + "\"id\": \"v2.0.0\"," + + "\"title\": \"Major Update\"," + + "\"ctaText\": \"View Release Notes\"," + + "\"ctaLink\": \"https://docs.example.com/v2.0\"" + + "}"; + JsonNode jsonNode = objectMapper.readTree(jsonString); + + ProductUpdate result = ProductUpdateParser.parseProductUpdate(Optional.of(jsonNode)); + + assertNotNull(result); + assertEquals(result.getCtaText(), "View Release Notes"); + assertEquals(result.getCtaLink(), "https://docs.example.com/v2.0"); + } + + @Test + public void testParseProductUpdateEmptyOptional() { + ProductUpdate result = ProductUpdateParser.parseProductUpdate(Optional.empty()); + + assertNull(result); + } + + @Test + public void testParseProductUpdateMissingEnabledField() throws Exception { + String jsonString = "{" + "\"id\": \"v1.0.0\"," + "\"title\": \"What's New\"" + "}"; + JsonNode jsonNode = objectMapper.readTree(jsonString); + + ProductUpdate result = ProductUpdateParser.parseProductUpdate(Optional.of(jsonNode)); + + assertNull(result); + } + + @Test + public void testParseProductUpdateMissingIdField() throws Exception { + String jsonString = "{" + "\"enabled\": true," + "\"title\": \"What's New\"" + "}"; + JsonNode jsonNode = objectMapper.readTree(jsonString); + + ProductUpdate result = ProductUpdateParser.parseProductUpdate(Optional.of(jsonNode)); + + assertNull(result); + } + + @Test + public void testParseProductUpdateMissingTitleField() throws Exception { + String jsonString = "{" + "\"enabled\": true," + "\"id\": \"v1.0.0\"" + "}"; + JsonNode jsonNode = objectMapper.readTree(jsonString); + + ProductUpdate result = ProductUpdateParser.parseProductUpdate(Optional.of(jsonNode)); + + assertNull(result); + } + + @Test + public void testParseProductUpdateDisabledInJson() throws Exception { + String jsonString = + "{" + "\"enabled\": false," + "\"id\": \"v1.0.0\"," + "\"title\": \"What's New\"" + "}"; + JsonNode jsonNode = objectMapper.readTree(jsonString); + + ProductUpdate result = ProductUpdateParser.parseProductUpdate(Optional.of(jsonNode)); + + assertNull(result); + } + + @Test + public void testParseProductUpdateNullValues() throws Exception { + String jsonString = + "{" + + "\"enabled\": true," + + "\"id\": \"v1.0.0\"," + + "\"title\": \"What's New\"," + + "\"description\": null," + + "\"image\": null" + + "}"; + JsonNode jsonNode = objectMapper.readTree(jsonString); + + ProductUpdate result = ProductUpdateParser.parseProductUpdate(Optional.of(jsonNode)); + + assertNotNull(result); + assertNotNull(result.getDescription()); + assertNotNull(result.getImage()); + assertEquals(result.getDescription(), "null"); + assertEquals(result.getImage(), "null"); + } + + @Test + public void testParseProductUpdateEmptyStrings() throws Exception { + String jsonString = + "{" + + "\"enabled\": true," + + "\"id\": \"\"," + + "\"title\": \"\"," + + "\"description\": \"\"," + + "\"ctaText\": \"\"," + + "\"ctaLink\": \"\"" + + "}"; + JsonNode jsonNode = objectMapper.readTree(jsonString); + + ProductUpdate result = ProductUpdateParser.parseProductUpdate(Optional.of(jsonNode)); + + assertNotNull(result); + assertNotNull(result.getId()); + assertNotNull(result.getTitle()); + assertNotNull(result.getDescription()); + assertNotNull(result.getCtaText()); + assertNotNull(result.getCtaLink()); + assertEquals(result.getId(), ""); + assertEquals(result.getTitle(), ""); + assertEquals(result.getDescription(), ""); + assertEquals(result.getCtaText(), ""); + assertEquals(result.getCtaLink(), ""); + } + + @Test + public void testParseProductUpdateEnabledFalse() throws Exception { + String jsonString = + "{" + + "\"enabled\": false," + + "\"id\": \"v1.0.0\"," + + "\"title\": \"What's New\"," + + "\"description\": \"This should not be shown\"" + + "}"; + JsonNode jsonNode = objectMapper.readTree(jsonString); + + ProductUpdate result = ProductUpdateParser.parseProductUpdate(Optional.of(jsonNode)); + + assertNull(result); + } + + @Test + public void testParseProductUpdatePartialOptionalFields() throws Exception { + String jsonString = + "{" + + "\"enabled\": true," + + "\"id\": \"v1.0.0\"," + + "\"title\": \"What's New\"," + + "\"description\": \"Has description but no image\"" + + "}"; + JsonNode jsonNode = objectMapper.readTree(jsonString); + + ProductUpdate result = ProductUpdateParser.parseProductUpdate(Optional.of(jsonNode)); + + assertNotNull(result); + assertNotNull(result.getDescription()); + assertEquals(result.getDescription(), "Has description but no image"); + assertNull(result.getImage()); + } + + @Test + public void testParseProductUpdateImageOnly() throws Exception { + String jsonString = + "{" + + "\"enabled\": true," + + "\"id\": \"v1.0.0\"," + + "\"title\": \"What's New\"," + + "\"image\": \"https://example.com/image.png\"" + + "}"; + JsonNode jsonNode = objectMapper.readTree(jsonString); + + ProductUpdate result = ProductUpdateParser.parseProductUpdate(Optional.of(jsonNode)); + + assertNotNull(result); + assertNull(result.getDescription()); + assertNotNull(result.getImage()); + assertEquals(result.getImage(), "https://example.com/image.png"); + } + + @Test + public void testParseProductUpdateBooleanTypesForEnabled() throws Exception { + String jsonString = + "{" + "\"enabled\": 1," + "\"id\": \"v1.0.0\"," + "\"title\": \"What's New\"" + "}"; + JsonNode jsonNode = objectMapper.readTree(jsonString); + + ProductUpdate result = ProductUpdateParser.parseProductUpdate(Optional.of(jsonNode)); + + assertNotNull(result); + assertNotNull(result.getEnabled()); + assertTrue(result.getEnabled()); + } + + @Test + public void testParseProductUpdateZeroEnabledField() throws Exception { + String jsonString = + "{" + "\"enabled\": 0," + "\"id\": \"v1.0.0\"," + "\"title\": \"What's New\"" + "}"; + JsonNode jsonNode = objectMapper.readTree(jsonString); + + ProductUpdate result = ProductUpdateParser.parseProductUpdate(Optional.of(jsonNode)); + + assertNull(result); + } + + @Test + public void testParseProductUpdateExtraFields() throws Exception { + String jsonString = + "{" + + "\"enabled\": true," + + "\"id\": \"v1.0.0\"," + + "\"title\": \"What's New\"," + + "\"unknownField\": \"Should be ignored\"," + + "\"anotherField\": 12345" + + "}"; + JsonNode jsonNode = objectMapper.readTree(jsonString); + + ProductUpdate result = ProductUpdateParser.parseProductUpdate(Optional.of(jsonNode)); + + assertNotNull(result); + assertNotNull(result.getId()); + assertNotNull(result.getTitle()); + assertEquals(result.getId(), "v1.0.0"); + assertEquals(result.getTitle(), "What's New"); + } + + @Test + public void testParseProductUpdateCtaTextWithoutLink() throws Exception { + String jsonString = + "{" + + "\"enabled\": true," + + "\"id\": \"v1.0.0\"," + + "\"title\": \"What's New\"," + + "\"ctaText\": \"Click here\"" + + "}"; + JsonNode jsonNode = objectMapper.readTree(jsonString); + + ProductUpdate result = ProductUpdateParser.parseProductUpdate(Optional.of(jsonNode)); + + assertNotNull(result); + assertNotNull(result.getCtaText()); + assertNotNull(result.getCtaLink()); + assertEquals(result.getCtaText(), "Click here"); + assertEquals(result.getCtaLink(), ""); + } + + @Test + public void testParseProductUpdateCtaLinkWithoutText() throws Exception { + String jsonString = + "{" + + "\"enabled\": true," + + "\"id\": \"v1.0.0\"," + + "\"title\": \"What's New\"," + + "\"ctaLink\": \"https://example.com\"" + + "}"; + JsonNode jsonNode = objectMapper.readTree(jsonString); + + ProductUpdate result = ProductUpdateParser.parseProductUpdate(Optional.of(jsonNode)); + + assertNotNull(result); + assertNotNull(result.getCtaText()); + assertNotNull(result.getCtaLink()); + assertEquals(result.getCtaText(), "Learn more"); + assertEquals(result.getCtaLink(), "https://example.com"); + } + + @Test + public void testParseProductUpdateSpecialCharactersInFields() throws Exception { + String jsonString = + "{" + + "\"enabled\": true," + + "\"id\": \"v1.0.0-rc.1+build.123\"," + + "\"title\": \"What's New: & \\\"Quoted\\\"\"," + + "\"description\": \"Line 1\\nLine 2\\tTabbed\"," + + "\"ctaLink\": \"https://example.com/path?query=value&other=123#anchor\"" + + "}"; + JsonNode jsonNode = objectMapper.readTree(jsonString); + + ProductUpdate result = ProductUpdateParser.parseProductUpdate(Optional.of(jsonNode)); + + assertNotNull(result); + assertNotNull(result.getId()); + assertNotNull(result.getTitle()); + assertNotNull(result.getDescription()); + assertNotNull(result.getCtaLink()); + assertEquals(result.getId(), "v1.0.0-rc.1+build.123"); + assertEquals(result.getTitle(), "What's New: & \"Quoted\""); + assertEquals(result.getDescription(), "Line 1\nLine 2\tTabbed"); + assertEquals(result.getCtaLink(), "https://example.com/path?query=value&other=123#anchor"); + } + + @Test + public void testParseProductUpdateUnicodeCharacters() throws Exception { + String jsonString = + "{" + + "\"enabled\": true," + + "\"id\": \"v1.0.0\"," + + "\"title\": \"新機能 🎉\"," + + "\"description\": \"Nouveau fonctionnalités 中文测试\"" + + "}"; + JsonNode jsonNode = objectMapper.readTree(jsonString); + + ProductUpdate result = ProductUpdateParser.parseProductUpdate(Optional.of(jsonNode)); + + assertNotNull(result); + assertNotNull(result.getTitle()); + assertNotNull(result.getDescription()); + assertEquals(result.getTitle(), "新機能 🎉"); + assertEquals(result.getDescription(), "Nouveau fonctionnalités 中文测试"); + } + + @Test + public void testParseProductUpdateLongStrings() throws Exception { + String longDescription = "x".repeat(10000); + String jsonString = + "{" + + "\"enabled\": true," + + "\"id\": \"v1.0.0\"," + + "\"title\": \"What's New\"," + + "\"description\": \"" + + longDescription + + "\"" + + "}"; + JsonNode jsonNode = objectMapper.readTree(jsonString); + + ProductUpdate result = ProductUpdateParser.parseProductUpdate(Optional.of(jsonNode)); + + assertNotNull(result); + assertNotNull(result.getDescription()); + assertEquals(result.getDescription().length(), 10000); + } +} diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/config/ProductUpdateResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/config/ProductUpdateResolverTest.java new file mode 100644 index 0000000000..dbacd1b885 --- /dev/null +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/config/ProductUpdateResolverTest.java @@ -0,0 +1,360 @@ +package com.linkedin.datahub.graphql.resolvers.config; + +import static org.mockito.Mockito.*; +import static org.testng.Assert.*; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.linkedin.datahub.graphql.featureflags.FeatureFlags; +import com.linkedin.datahub.graphql.generated.ProductUpdate; +import com.linkedin.metadata.service.ProductUpdateService; +import graphql.schema.DataFetchingEnvironment; +import java.util.Optional; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +public class ProductUpdateResolverTest { + + @Mock private ProductUpdateService mockProductUpdateService; + @Mock private FeatureFlags mockFeatureFlags; + @Mock private DataFetchingEnvironment mockDataFetchingEnvironment; + + private ProductUpdateResolver resolver; + private ObjectMapper objectMapper; + + @BeforeMethod + public void setupTest() { + MockitoAnnotations.openMocks(this); + objectMapper = new ObjectMapper(); + resolver = new ProductUpdateResolver(mockProductUpdateService, mockFeatureFlags); + } + + @Test + public void testGetProductUpdateSuccess() throws Exception { + // Setup + when(mockFeatureFlags.isShowProductUpdates()).thenReturn(true); + + String jsonString = + "{" + + "\"enabled\": true," + + "\"id\": \"v1.0.0\"," + + "\"title\": \"What's New\"," + + "\"description\": \"New features\"," + + "\"image\": \"https://example.com/image.png\"," + + "\"ctaText\": \"Learn more\"," + + "\"ctaLink\": \"https://example.com\"" + + "}"; + JsonNode jsonNode = objectMapper.readTree(jsonString); + when(mockProductUpdateService.getLatestProductUpdate()).thenReturn(Optional.of(jsonNode)); + + // Execute + ProductUpdate result = resolver.get(mockDataFetchingEnvironment).get(); + + // Verify + verify(mockProductUpdateService).getLatestProductUpdate(); + verify(mockProductUpdateService, never()).clearCache(); // Should NOT clear cache + assertNotNull(result); + assertTrue(result.getEnabled()); + assertEquals(result.getId(), "v1.0.0"); + assertEquals(result.getTitle(), "What's New"); + assertEquals(result.getDescription(), "New features"); + assertEquals(result.getImage(), "https://example.com/image.png"); + assertEquals(result.getCtaText(), "Learn more"); + assertEquals(result.getCtaLink(), "https://example.com"); + } + + @Test + public void testGetProductUpdateMinimalFields() throws Exception { + // Setup - only required fields + when(mockFeatureFlags.isShowProductUpdates()).thenReturn(true); + + String jsonString = + "{" + "\"enabled\": true," + "\"id\": \"v1.0.0\"," + "\"title\": \"What's New\"" + "}"; + JsonNode jsonNode = objectMapper.readTree(jsonString); + when(mockProductUpdateService.getLatestProductUpdate()).thenReturn(Optional.of(jsonNode)); + + // Execute + ProductUpdate result = resolver.get(mockDataFetchingEnvironment).get(); + + // Verify + assertNotNull(result); + assertTrue(result.getEnabled()); + assertEquals(result.getId(), "v1.0.0"); + assertEquals(result.getTitle(), "What's New"); + assertNull(result.getDescription()); + assertNull(result.getImage()); + assertEquals(result.getCtaText(), "Learn more"); // default value + assertEquals(result.getCtaLink(), ""); // default value + } + + @Test + public void testGetProductUpdateFeatureDisabled() throws Exception { + // Setup + when(mockFeatureFlags.isShowProductUpdates()).thenReturn(false); + + // Execute + ProductUpdate result = resolver.get(mockDataFetchingEnvironment).get(); + + // Verify + verify(mockProductUpdateService, never()).getLatestProductUpdate(); + assertNull(result); + } + + @Test + public void testGetProductUpdateNoJsonAvailable() throws Exception { + // Setup + when(mockFeatureFlags.isShowProductUpdates()).thenReturn(true); + when(mockProductUpdateService.getLatestProductUpdate()).thenReturn(Optional.empty()); + + // Execute + ProductUpdate result = resolver.get(mockDataFetchingEnvironment).get(); + + // Verify + verify(mockProductUpdateService).getLatestProductUpdate(); + assertNull(result); + } + + @Test + public void testGetProductUpdateMissingEnabledField() throws Exception { + // Setup + when(mockFeatureFlags.isShowProductUpdates()).thenReturn(true); + + String jsonString = "{" + "\"id\": \"v1.0.0\"," + "\"title\": \"What's New\"" + "}"; + JsonNode jsonNode = objectMapper.readTree(jsonString); + when(mockProductUpdateService.getLatestProductUpdate()).thenReturn(Optional.of(jsonNode)); + + // Execute + ProductUpdate result = resolver.get(mockDataFetchingEnvironment).get(); + + // Verify + assertNull(result); + } + + @Test + public void testGetProductUpdateMissingIdField() throws Exception { + // Setup + when(mockFeatureFlags.isShowProductUpdates()).thenReturn(true); + + String jsonString = "{" + "\"enabled\": true," + "\"title\": \"What's New\"" + "}"; + JsonNode jsonNode = objectMapper.readTree(jsonString); + when(mockProductUpdateService.getLatestProductUpdate()).thenReturn(Optional.of(jsonNode)); + + // Execute + ProductUpdate result = resolver.get(mockDataFetchingEnvironment).get(); + + // Verify + assertNull(result); + } + + @Test + public void testGetProductUpdateMissingTitleField() throws Exception { + // Setup + when(mockFeatureFlags.isShowProductUpdates()).thenReturn(true); + + String jsonString = "{" + "\"enabled\": true," + "\"id\": \"v1.0.0\"" + "}"; + JsonNode jsonNode = objectMapper.readTree(jsonString); + when(mockProductUpdateService.getLatestProductUpdate()).thenReturn(Optional.of(jsonNode)); + + // Execute + ProductUpdate result = resolver.get(mockDataFetchingEnvironment).get(); + + // Verify + assertNull(result); + } + + @Test + public void testGetProductUpdateDisabledInJson() throws Exception { + // Setup + when(mockFeatureFlags.isShowProductUpdates()).thenReturn(true); + + String jsonString = + "{" + "\"enabled\": false," + "\"id\": \"v1.0.0\"," + "\"title\": \"What's New\"" + "}"; + JsonNode jsonNode = objectMapper.readTree(jsonString); + when(mockProductUpdateService.getLatestProductUpdate()).thenReturn(Optional.of(jsonNode)); + + // Execute + ProductUpdate result = resolver.get(mockDataFetchingEnvironment).get(); + + // Verify + assertNull(result); + } + + @Test + public void testGetProductUpdateExceptionHandling() throws Exception { + // Setup + when(mockFeatureFlags.isShowProductUpdates()).thenReturn(true); + when(mockProductUpdateService.getLatestProductUpdate()) + .thenThrow(new RuntimeException("Service error")); + + // Execute + ProductUpdate result = resolver.get(mockDataFetchingEnvironment).get(); + + // Verify - should handle exception gracefully and return null + assertNull(result); + } + + @Test + public void testGetProductUpdateWithCustomCta() throws Exception { + // Setup + when(mockFeatureFlags.isShowProductUpdates()).thenReturn(true); + + String jsonString = + "{" + + "\"enabled\": true," + + "\"id\": \"v2.0.0\"," + + "\"title\": \"Major Update\"," + + "\"ctaText\": \"View Release Notes\"," + + "\"ctaLink\": \"https://docs.example.com/v2.0\"" + + "}"; + JsonNode jsonNode = objectMapper.readTree(jsonString); + when(mockProductUpdateService.getLatestProductUpdate()).thenReturn(Optional.of(jsonNode)); + + // Execute + ProductUpdate result = resolver.get(mockDataFetchingEnvironment).get(); + + // Verify + assertNotNull(result); + assertEquals(result.getCtaText(), "View Release Notes"); + assertEquals(result.getCtaLink(), "https://docs.example.com/v2.0"); + } + + @Test + public void testGetProductUpdateNullValues() throws Exception { + // Setup + when(mockFeatureFlags.isShowProductUpdates()).thenReturn(true); + + String jsonString = + "{" + + "\"enabled\": true," + + "\"id\": \"v1.0.0\"," + + "\"title\": \"What's New\"," + + "\"description\": null," + + "\"image\": null" + + "}"; + JsonNode jsonNode = objectMapper.readTree(jsonString); + when(mockProductUpdateService.getLatestProductUpdate()).thenReturn(Optional.of(jsonNode)); + + // Execute + ProductUpdate result = resolver.get(mockDataFetchingEnvironment).get(); + + // Verify - Jackson's asText() converts null JSON values to string "null" + assertNotNull(result); + assertEquals(result.getDescription(), "null"); + assertEquals(result.getImage(), "null"); + } + + @Test + public void testGetProductUpdateUsesCache() throws Exception { + // Setup + when(mockFeatureFlags.isShowProductUpdates()).thenReturn(true); + when(mockDataFetchingEnvironment.getArgument("refreshCache")).thenReturn(null); + + String jsonString = + "{" + "\"enabled\": true," + "\"id\": \"v1.0.0\"," + "\"title\": \"What's New\"" + "}"; + JsonNode jsonNode = objectMapper.readTree(jsonString); + when(mockProductUpdateService.getLatestProductUpdate()).thenReturn(Optional.of(jsonNode)); + + // Execute multiple times + resolver.get(mockDataFetchingEnvironment).get(); + resolver.get(mockDataFetchingEnvironment).get(); + resolver.get(mockDataFetchingEnvironment).get(); + + // Verify - service is called each time (caching happens in the service layer) + verify(mockProductUpdateService, times(3)).getLatestProductUpdate(); + // But clearCache should never be called by this resolver + verify(mockProductUpdateService, never()).clearCache(); + } + + @Test + public void testGetProductUpdateWithRefreshCacheTrue() throws Exception { + // Setup + when(mockFeatureFlags.isShowProductUpdates()).thenReturn(true); + when(mockDataFetchingEnvironment.getArgument("refreshCache")).thenReturn(true); + + String jsonString = + "{" + + "\"enabled\": true," + + "\"id\": \"v1.0.0\"," + + "\"title\": \"What's New\"," + + "\"description\": \"New features\"" + + "}"; + JsonNode jsonNode = objectMapper.readTree(jsonString); + when(mockProductUpdateService.getLatestProductUpdate()).thenReturn(Optional.of(jsonNode)); + + // Execute + ProductUpdate result = resolver.get(mockDataFetchingEnvironment).get(); + + // Verify + verify(mockProductUpdateService).clearCache(); // Should clear cache first + verify(mockProductUpdateService).getLatestProductUpdate(); + assertNotNull(result); + assertEquals(result.getId(), "v1.0.0"); + assertEquals(result.getTitle(), "What's New"); + } + + @Test + public void testGetProductUpdateWithRefreshCacheFalse() throws Exception { + // Setup + when(mockFeatureFlags.isShowProductUpdates()).thenReturn(true); + when(mockDataFetchingEnvironment.getArgument("refreshCache")).thenReturn(false); + + String jsonString = + "{" + "\"enabled\": true," + "\"id\": \"v1.0.0\"," + "\"title\": \"What's New\"" + "}"; + JsonNode jsonNode = objectMapper.readTree(jsonString); + when(mockProductUpdateService.getLatestProductUpdate()).thenReturn(Optional.of(jsonNode)); + + // Execute + ProductUpdate result = resolver.get(mockDataFetchingEnvironment).get(); + + // Verify + verify(mockProductUpdateService, never()).clearCache(); // Should NOT clear cache + verify(mockProductUpdateService).getLatestProductUpdate(); + assertNotNull(result); + } + + @Test + public void testGetProductUpdateRefreshCacheWhenFeatureDisabled() throws Exception { + // Setup - even if refreshCache is true, feature flag should be checked first + when(mockFeatureFlags.isShowProductUpdates()).thenReturn(false); + when(mockDataFetchingEnvironment.getArgument("refreshCache")).thenReturn(true); + + // Execute + ProductUpdate result = resolver.get(mockDataFetchingEnvironment).get(); + + // Verify - nothing should be called because feature is disabled + verify(mockProductUpdateService, never()).clearCache(); + verify(mockProductUpdateService, never()).getLatestProductUpdate(); + assertNull(result); + } + + @Test + public void testGetProductUpdateEmptyStrings() throws Exception { + // Setup + when(mockFeatureFlags.isShowProductUpdates()).thenReturn(true); + + String jsonString = + "{" + + "\"enabled\": true," + + "\"id\": \"\"," + + "\"title\": \"\"," + + "\"description\": \"\"," + + "\"ctaText\": \"\"," + + "\"ctaLink\": \"\"" + + "}"; + JsonNode jsonNode = objectMapper.readTree(jsonString); + when(mockProductUpdateService.getLatestProductUpdate()).thenReturn(Optional.of(jsonNode)); + + // Execute + ProductUpdate result = resolver.get(mockDataFetchingEnvironment).get(); + + // Verify - empty strings should be accepted + assertNotNull(result); + assertEquals(result.getId(), ""); + assertEquals(result.getTitle(), ""); + assertEquals(result.getDescription(), ""); + assertEquals(result.getCtaText(), ""); + assertEquals(result.getCtaLink(), ""); + } +} diff --git a/datahub-web-react/src/app/shared/product/update/hooks.ts b/datahub-web-react/src/app/shared/product/update/hooks.ts index 3fd277f67a..4a6f815fb6 100644 --- a/datahub-web-react/src/app/shared/product/update/hooks.ts +++ b/datahub-web-react/src/app/shared/product/update/hooks.ts @@ -46,13 +46,15 @@ export function useIsProductAnnouncementVisible(update: ProductUpdate): ProductA fetchPolicy: 'cache-first', }); - if (loading || error) { + // If query is loading or has an error or userUrn is not loaded yet, don't show the announcement (wait for user context to load) + if (!userUrn || loading || error) { return { visible: false, refetch, }; } + // Show announcement if the step state doesn't exist (user hasn't dismissed it) const visible = (data?.batchGetStepStates?.results && !data?.batchGetStepStates?.results?.some((result) => result?.id === productUpdateStepId)) || diff --git a/datahub-web-react/src/app/shared/product/update/latestUpdate.ts b/datahub-web-react/src/app/shared/product/update/latestUpdate.ts index 7532404f40..a3c2fe81e8 100644 --- a/datahub-web-react/src/app/shared/product/update/latestUpdate.ts +++ b/datahub-web-react/src/app/shared/product/update/latestUpdate.ts @@ -15,10 +15,10 @@ export type ProductUpdate = { // TODO: Migrate this to be served via an aspect! export const latestUpdate: ProductUpdate = { enabled: true, - id: 'v1.2.0', // Very important, when changed it will trigger the announcement to be re-displayed for a user. + id: 'v1.2.1', // Very important, when changed it will trigger the announcement to be re-displayed for a user. title: "What's New In DataHub", - description: 'Explore version v1.2.0', + description: 'Explore version v1.2.1', image: SampleImage, // Import and use image., ctaText: 'Read updates', - ctaLink: 'https://docs.datahub.com/docs/releases#v1-2-0', + ctaLink: 'https://docs.datahub.com/docs/releases#v1-2-1', }; diff --git a/metadata-io/src/test/java/com/linkedin/metadata/system_info/collectors/PropertiesCollectorConfigurationTest.java b/metadata-io/src/test/java/com/linkedin/metadata/system_info/collectors/PropertiesCollectorConfigurationTest.java index 6ebe72a7d2..3b17b7853c 100644 --- a/metadata-io/src/test/java/com/linkedin/metadata/system_info/collectors/PropertiesCollectorConfigurationTest.java +++ b/metadata-io/src/test/java/com/linkedin/metadata/system_info/collectors/PropertiesCollectorConfigurationTest.java @@ -504,6 +504,8 @@ public class PropertiesCollectorConfigurationTest extends AbstractTestNGSpringCo "featureFlags.showManageTags", "featureFlags.showNavBarRedesign", "featureFlags.showProductUpdates", + "featureFlags.productUpdatesJsonUrl", + "featureFlags.productUpdatesJsonFallbackResource", "featureFlags.showStatsTabRedesign", "featureFlags.showSearchBarAutocompleteRedesign", "featureFlags.showSearchFiltersV2", diff --git a/metadata-service/configuration/src/main/java/com/linkedin/datahub/graphql/featureflags/FeatureFlags.java b/metadata-service/configuration/src/main/java/com/linkedin/datahub/graphql/featureflags/FeatureFlags.java index 27671e30a7..8e59b5c6f1 100644 --- a/metadata-service/configuration/src/main/java/com/linkedin/datahub/graphql/featureflags/FeatureFlags.java +++ b/metadata-service/configuration/src/main/java/com/linkedin/datahub/graphql/featureflags/FeatureFlags.java @@ -46,6 +46,8 @@ public class FeatureFlags { private boolean showHomePageRedesign = false; private boolean lineageGraphV3 = true; private boolean showProductUpdates = false; + private String productUpdatesJsonUrl; + private String productUpdatesJsonFallbackResourceUrl; private boolean logicalModelsEnabled = false; private boolean showHomepageUserRole = false; private boolean assetSummaryPageV1 = false; diff --git a/metadata-service/configuration/src/main/resources/application.yaml b/metadata-service/configuration/src/main/resources/application.yaml index a2ef85a3f3..1d98bc7317 100644 --- a/metadata-service/configuration/src/main/resources/application.yaml +++ b/metadata-service/configuration/src/main/resources/application.yaml @@ -847,6 +847,8 @@ featureFlags: showHomePageRedesign: ${SHOW_HOME_PAGE_REDESIGN:false} # If turned on, show the re-designed home page lineageGraphV3: ${LINEAGE_GRAPH_V3:false} # Enables the redesign of the lineage v2 graph showProductUpdates: ${SHOW_PRODUCT_UPDATES:true} # Whether to show in-product update popover on new updates. + productUpdatesJsonUrl: ${PRODUCT_UPDATES_JSON_URL:https://raw.githubusercontent.com/datahub-project/datahub/master/metadata-service/configuration/src/main/resources/product-update.json} # URL to fetch product updates JSON from remote source. + productUpdatesJsonFallbackResource: ${PRODUCT_UPDATES_JSON_FALLBACK_RESOURCE:product-update.json} # Classpath resource to use as fallback when remote fetch fails. logicalModelsEnabled: ${LOGICAL_MODELS_ENABLED:false} # Enables logical models feature showHomepageUserRole: ${SHOW_HOMEPAGE_USER_ROLE:false} # Enables displaying the homepage user role underneath the name. Only relevant for custom home page fineGrainedLineageNotAllowedForPlatforms: ${FINE_GRAINED_LINEAGE_NOT_ALLOWED_FOR_PLATFORMS:} # Comma separated list of platforms for which schemaFields entity edges will not be allowed to be created. for example: "hdfs, s3" diff --git a/metadata-service/configuration/src/main/resources/product-update.json b/metadata-service/configuration/src/main/resources/product-update.json new file mode 100644 index 0000000000..3db6eb8db8 --- /dev/null +++ b/metadata-service/configuration/src/main/resources/product-update.json @@ -0,0 +1,8 @@ +{ + "enabled": true, + "id": "v1.2.1", + "title": "What's New In DataHub", + "description": "Explore version v1.2.1", + "ctaText": "Read updates", + "ctaLink": "https://docs.datahub.com/docs/releases#v1-2-1" +} diff --git a/metadata-service/services/src/main/java/com/linkedin/metadata/service/ProductUpdateService.java b/metadata-service/services/src/main/java/com/linkedin/metadata/service/ProductUpdateService.java new file mode 100644 index 0000000000..155ffc9b00 --- /dev/null +++ b/metadata-service/services/src/main/java/com/linkedin/metadata/service/ProductUpdateService.java @@ -0,0 +1,188 @@ +package com.linkedin.metadata.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.common.cache.Cache; +import com.google.common.cache.CacheBuilder; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.Optional; +import java.util.concurrent.TimeUnit; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import lombok.extern.slf4j.Slf4j; + +/** + * Service for fetching product update information from a remote JSON source. + * + *

This service attempts to fetch from a remote URL and falls back to a bundled classpath + * resource if the remote fetch fails. This ensures product updates work in both internet-connected + * and air-gapped deployments. + * + *

The service uses an in-memory cache with a 6-hour TTL to reduce network requests. + */ +@Slf4j +public class ProductUpdateService { + + private static final String CACHE_KEY = "product_update_json"; + private static final long CACHE_TTL_HOURS = 6; + private static final int HTTP_TIMEOUT_SECONDS = 10; + + private final String jsonUrl; + private final String fallbackResource; + private final HttpClient httpClient; + private final ObjectMapper objectMapper; + private final Cache cache; + + public ProductUpdateService( + @Nullable final String jsonUrl, @Nonnull final String fallbackResource) { + this( + jsonUrl, + fallbackResource, + HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(HTTP_TIMEOUT_SECONDS)).build()); + } + + public ProductUpdateService( + @Nullable final String jsonUrl, + @Nonnull final String fallbackResource, + @Nonnull final HttpClient httpClient) { + this.jsonUrl = jsonUrl; + this.fallbackResource = fallbackResource; + this.httpClient = httpClient; + this.objectMapper = new ObjectMapper(); + this.cache = + CacheBuilder.newBuilder().expireAfterWrite(CACHE_TTL_HOURS, TimeUnit.HOURS).build(); + + log.info( + "ProductUpdateService initialized with URL: {}, fallback resource: {}", + jsonUrl, + fallbackResource); + } + + /** + * Fetches the latest product update JSON from the configured URL, falling back to classpath + * resource if remote fetch fails. + * + *

This method gracefully handles all errors by returning a fallback from the classpath. This + * ensures product updates work in both internet-connected and air-gapped deployments. + * + *

Remote results are cached for 6 hours to reduce network load. + * + * @return Optional containing the parsed JSON node from remote or fallback, empty Optional only + * if both fail + */ + @Nonnull + public Optional getLatestProductUpdate() { + // Try remote fetch if URL is configured + if (jsonUrl != null && !jsonUrl.trim().isEmpty()) { + try { + // Check cache first + JsonNode cachedValue = cache.getIfPresent(CACHE_KEY); + if (cachedValue != null) { + log.debug("Returning cached product update JSON"); + return Optional.of(cachedValue); + } + + // Fetch from remote source + log.debug("Fetching product update JSON from: {}", jsonUrl); + JsonNode result = fetchFromRemote(); + + if (result != null) { + cache.put(CACHE_KEY, result); + log.info("Successfully fetched and cached product update JSON from remote"); + return Optional.of(result); + } + } catch (Exception e) { + log.warn( + "Failed to fetch product update JSON from {}: {}, falling back to classpath resource", + jsonUrl, + e.getMessage()); + log.debug("Product update fetch error details", e); + } + } else { + log.debug("Product update JSON URL not configured, using classpath fallback"); + } + + // Fallback to classpath resource + return loadFromClasspath(); + } + + /** + * Fetches the JSON from the remote URL. + * + * @return the parsed JSON node, or null if fetch failed + */ + @Nullable + private JsonNode fetchFromRemote() { + try { + HttpRequest request = + HttpRequest.newBuilder() + .uri(URI.create(jsonUrl)) + .timeout(Duration.ofSeconds(HTTP_TIMEOUT_SECONDS)) + .GET() + .build(); + + HttpResponse response = + httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() != 200) { + log.warn("Product update JSON fetch returned non-200 status: {}", response.statusCode()); + return null; + } + + String body = response.body(); + if (body == null || body.trim().isEmpty()) { + log.warn("Product update JSON fetch returned empty body"); + return null; + } + + JsonNode jsonNode = objectMapper.readTree(body); + log.info("Successfully parsed product update JSON: {}", jsonNode.toString()); + return jsonNode; + + } catch (Exception e) { + log.warn("Error fetching product update JSON: {}", e.getMessage()); + log.debug("Fetch error details", e); + return null; + } + } + + /** + * Loads the product update JSON from the classpath. + * + * @return Optional containing the parsed JSON node, empty if resource not found or invalid + */ + @Nonnull + private Optional loadFromClasspath() { + try { + log.debug("Loading product update JSON from classpath: {}", fallbackResource); + var inputStream = getClass().getClassLoader().getResourceAsStream(fallbackResource); + + if (inputStream == null) { + log.error("Fallback resource not found on classpath: {}", fallbackResource); + return Optional.empty(); + } + + JsonNode jsonNode = objectMapper.readTree(inputStream); + log.info("Successfully loaded product update JSON from classpath fallback"); + return Optional.of(jsonNode); + + } catch (Exception e) { + log.error( + "Failed to load product update JSON from classpath resource {}: {}", + fallbackResource, + e.getMessage(), + e); + return Optional.empty(); + } + } + + /** Clears the cache. Useful for testing or forcing a refresh. */ + public void clearCache() { + cache.invalidateAll(); + log.debug("Product update cache cleared"); + } +} diff --git a/metadata-service/services/src/test/java/com/linkedin/metadata/service/ProductUpdateServiceTest.java b/metadata-service/services/src/test/java/com/linkedin/metadata/service/ProductUpdateServiceTest.java new file mode 100644 index 0000000000..f311c5b8ed --- /dev/null +++ b/metadata-service/services/src/test/java/com/linkedin/metadata/service/ProductUpdateServiceTest.java @@ -0,0 +1,282 @@ +package com.linkedin.metadata.service; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; +import static org.testng.Assert.*; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.Optional; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +/** + * Tests for ProductUpdateService focusing on HTTP fetching, caching behavior, and fallback logic. + */ +public class ProductUpdateServiceTest { + + private static final String TEST_RESOURCE_PATH = "product-update-for-test.json"; + private static final String TEST_URL = "https://example.com/product-update.json"; + + private static final String MOCK_PRODUCT_JSON_V1 = + "{" + + "\"enabled\": true," + + "\"id\": \"remote-v1\"," + + "\"title\": \"Remote Update V1\"," + + "\"description\": \"First remote version\"" + + "}"; + + private static final String MOCK_PRODUCT_JSON_V2 = + "{" + + "\"enabled\": true," + + "\"id\": \"remote-v2\"," + + "\"title\": \"Remote Update V2\"," + + "\"description\": \"Second remote version\"" + + "}"; + + @Mock private HttpClient mockHttpClient; + @Mock private HttpResponse mockHttpResponse; + + private ProductUpdateService service; + private ObjectMapper objectMapper; + + @BeforeMethod + public void setupTest() { + MockitoAnnotations.openMocks(this); + objectMapper = new ObjectMapper(); + } + + @Test + public void testSuccessfulRemoteFetchIsCached() throws Exception { + // Setup - mock successful HTTP response + when(mockHttpClient.send(any(HttpRequest.class), any())).thenReturn(mockHttpResponse); + when(mockHttpResponse.statusCode()).thenReturn(200); + when(mockHttpResponse.body()).thenReturn(MOCK_PRODUCT_JSON_V1); + + service = new ProductUpdateService(TEST_URL, TEST_RESOURCE_PATH, mockHttpClient); + + // Execute - fetch multiple times + Optional result1 = service.getLatestProductUpdate(); + Optional result2 = service.getLatestProductUpdate(); + Optional result3 = service.getLatestProductUpdate(); + + // Verify - HTTP should only be called once (cached after first fetch) + verify(mockHttpClient, times(1)).send(any(HttpRequest.class), any()); + + // All results should be the remote version + assertTrue(result1.isPresent()); + assertTrue(result2.isPresent()); + assertTrue(result3.isPresent()); + assertEquals(result1.get().get("id").asText(), "remote-v1"); + assertEquals(result2.get().get("id").asText(), "remote-v1"); + assertEquals(result3.get().get("id").asText(), "remote-v1"); + } + + @Test + public void testClearCacheRefetchesFromRemote() throws Exception { + // Setup - mock HTTP to return V1 first, then V2 + when(mockHttpClient.send(any(HttpRequest.class), any())).thenReturn(mockHttpResponse); + when(mockHttpResponse.statusCode()).thenReturn(200); + when(mockHttpResponse.body()).thenReturn(MOCK_PRODUCT_JSON_V1).thenReturn(MOCK_PRODUCT_JSON_V2); + + service = new ProductUpdateService(TEST_URL, TEST_RESOURCE_PATH, mockHttpClient); + + // Execute - fetch, clear cache, fetch again + Optional result1 = service.getLatestProductUpdate(); + service.clearCache(); + Optional result2 = service.getLatestProductUpdate(); + + // Verify - HTTP should be called twice (once before clear, once after) + verify(mockHttpClient, times(2)).send(any(HttpRequest.class), any()); + + // Results should reflect the different versions + assertTrue(result1.isPresent()); + assertTrue(result2.isPresent()); + assertEquals(result1.get().get("id").asText(), "remote-v1"); + assertEquals(result2.get().get("id").asText(), "remote-v2"); + } + + @Test + public void testRemoteFetchFailsFallsBackToClasspath() throws Exception { + // Setup - mock HTTP failure + when(mockHttpClient.send(any(HttpRequest.class), any())) + .thenThrow(new IOException("Connection failed")); + + service = new ProductUpdateService(TEST_URL, TEST_RESOURCE_PATH, mockHttpClient); + + // Execute + Optional result = service.getLatestProductUpdate(); + + // Verify - should fall back to classpath resource + assertTrue(result.isPresent()); + assertEquals(result.get().get("id").asText(), "test-product-update"); + } + + @Test + public void testRemoteReturnsNon200FallsBackToClasspath() throws Exception { + // Setup - mock HTTP 404 response + when(mockHttpClient.send(any(HttpRequest.class), any())).thenReturn(mockHttpResponse); + when(mockHttpResponse.statusCode()).thenReturn(404); + + service = new ProductUpdateService(TEST_URL, TEST_RESOURCE_PATH, mockHttpClient); + + // Execute + Optional result = service.getLatestProductUpdate(); + + // Verify - should fall back to classpath resource + assertTrue(result.isPresent()); + assertEquals(result.get().get("id").asText(), "test-product-update"); + } + + @Test + public void testRemoteReturnsEmptyBodyFallsBackToClasspath() throws Exception { + // Setup - mock empty response body + when(mockHttpClient.send(any(HttpRequest.class), any())).thenReturn(mockHttpResponse); + when(mockHttpResponse.statusCode()).thenReturn(200); + when(mockHttpResponse.body()).thenReturn(""); + + service = new ProductUpdateService(TEST_URL, TEST_RESOURCE_PATH, mockHttpClient); + + // Execute + Optional result = service.getLatestProductUpdate(); + + // Verify - should fall back to classpath resource + assertTrue(result.isPresent()); + assertEquals(result.get().get("id").asText(), "test-product-update"); + } + + @Test + public void testAfterRemoteFailsClearCacheRetries() throws Exception { + // Setup - mock HTTP to fail first, then succeed + when(mockHttpClient.send(any(HttpRequest.class), any())) + .thenThrow(new IOException("Connection failed")) + .thenReturn(mockHttpResponse); + when(mockHttpResponse.statusCode()).thenReturn(200); + when(mockHttpResponse.body()).thenReturn(MOCK_PRODUCT_JSON_V1); + + service = new ProductUpdateService(TEST_URL, TEST_RESOURCE_PATH, mockHttpClient); + + // Execute - first fetch fails and falls back, clear cache, fetch again succeeds + Optional result1 = service.getLatestProductUpdate(); + service.clearCache(); + Optional result2 = service.getLatestProductUpdate(); + + // Verify - HTTP should be attempted twice + verify(mockHttpClient, times(2)).send(any(HttpRequest.class), any()); + + // First result should be fallback, second should be remote + assertTrue(result1.isPresent()); + assertTrue(result2.isPresent()); + assertEquals(result1.get().get("id").asText(), "test-product-update"); + assertEquals(result2.get().get("id").asText(), "remote-v1"); + } + + @Test + public void testNoUrlConfiguredUsesFallback() throws Exception { + // Setup - no URL configured + service = new ProductUpdateService(null, TEST_RESOURCE_PATH, mockHttpClient); + + // Execute + Optional result = service.getLatestProductUpdate(); + + // Verify - should not attempt HTTP, should use fallback + verify(mockHttpClient, never()).send(any(HttpRequest.class), any()); + assertTrue(result.isPresent()); + assertEquals(result.get().get("id").asText(), "test-product-update"); + } + + @Test + public void testEmptyUrlUsesFallback() throws Exception { + // Setup - empty URL + service = new ProductUpdateService("", TEST_RESOURCE_PATH, mockHttpClient); + + // Execute + Optional result = service.getLatestProductUpdate(); + + // Verify - should not attempt HTTP, should use fallback + verify(mockHttpClient, never()).send(any(HttpRequest.class), any()); + assertTrue(result.isPresent()); + assertEquals(result.get().get("id").asText(), "test-product-update"); + } + + @Test + public void testWhitespaceUrlUsesFallback() throws Exception { + // Setup - whitespace URL + service = new ProductUpdateService(" ", TEST_RESOURCE_PATH, mockHttpClient); + + // Execute + Optional result = service.getLatestProductUpdate(); + + // Verify - should not attempt HTTP, should use fallback + verify(mockHttpClient, never()).send(any(HttpRequest.class), any()); + assertTrue(result.isPresent()); + assertEquals(result.get().get("id").asText(), "test-product-update"); + } + + @Test + public void testNonExistentFallbackReturnsEmpty() throws Exception { + // Setup - no URL and non-existent fallback + service = new ProductUpdateService(null, "non-existent-resource.json", mockHttpClient); + + // Execute + Optional result = service.getLatestProductUpdate(); + + // Verify - should return empty when fallback doesn't exist + assertNotNull(result); + assertFalse(result.isPresent()); + } + + @Test + public void testMultipleInstancesHaveIndependentCaches() throws Exception { + // Setup - two service instances with mocked responses + when(mockHttpClient.send(any(HttpRequest.class), any())).thenReturn(mockHttpResponse); + when(mockHttpResponse.statusCode()).thenReturn(200); + when(mockHttpResponse.body()).thenReturn(MOCK_PRODUCT_JSON_V1); + + ProductUpdateService service1 = + new ProductUpdateService(TEST_URL, TEST_RESOURCE_PATH, mockHttpClient); + ProductUpdateService service2 = + new ProductUpdateService(TEST_URL, TEST_RESOURCE_PATH, mockHttpClient); + + // Execute - fetch from both, clear one, fetch again + service1.getLatestProductUpdate(); + service2.getLatestProductUpdate(); + service1.clearCache(); + service1.getLatestProductUpdate(); + service2.getLatestProductUpdate(); + + // Verify - service1 should fetch twice (initial + after clear), service2 only once (cached) + // Total HTTP calls: 3 (1 from service1 initial, 1 from service2 initial, 1 from service1 after + // clear) + verify(mockHttpClient, times(3)).send(any(HttpRequest.class), any()); + } + + @Test + public void testFallbackJsonStructure() throws Exception { + // Setup + service = new ProductUpdateService(null, TEST_RESOURCE_PATH, mockHttpClient); + + // Execute + Optional result = service.getLatestProductUpdate(); + + // Verify - validate expected JSON structure from test resource + assertTrue(result.isPresent()); + JsonNode json = result.get(); + + assertTrue(json.isObject()); + assertTrue(json.has("id")); + assertTrue(json.has("enabled")); + assertTrue(json.has("title")); + assertTrue(json.has("description")); + + assertEquals(json.get("id").asText(), "test-product-update"); + assertTrue(json.get("enabled").asBoolean()); + assertEquals(json.get("title").asText(), "Test Product Update"); + } +} diff --git a/metadata-service/services/src/test/resources/product-update-for-test.json b/metadata-service/services/src/test/resources/product-update-for-test.json new file mode 100644 index 0000000000..198603dd8d --- /dev/null +++ b/metadata-service/services/src/test/resources/product-update-for-test.json @@ -0,0 +1,8 @@ +{ + "enabled": true, + "id": "test-product-update", + "title": "Test Product Update", + "description": "This is a test fallback product update", + "ctaText": "Learn more", + "ctaLink": "https://example.com" +}