SE-123: added product update resolver (#14980)

This commit is contained in:
Ben Blazke 2025-10-21 15:18:01 -07:00 committed by GitHub
parent edcc8bca76
commit fd00f04b0f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 1448 additions and 4 deletions

View File

@ -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(

View File

@ -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.
*
* <p>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<JsonNode> 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;
}
}

View File

@ -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.
*
* <p>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.
*
* <p>Supports an optional {@code refreshCache} argument to clear the cache before fetching.
*/
@Slf4j
public class ProductUpdateResolver implements DataFetcher<CompletableFuture<ProductUpdate>> {
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<ProductUpdate> 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;
}
});
}
}

View File

@ -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!
}

View File

@ -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: <Special> & \\\"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: <Special> & \"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);
}
}

View File

@ -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(), "");
}
}

View File

@ -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)) ||

View File

@ -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',
};

View File

@ -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",

View File

@ -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;

View File

@ -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"

View File

@ -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"
}

View File

@ -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.
*
* <p>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.
*
* <p>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<String, JsonNode> 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.
*
* <p>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.
*
* <p>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<JsonNode> 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<String> 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<JsonNode> 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");
}
}

View File

@ -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<String> 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.<String>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<JsonNode> result1 = service.getLatestProductUpdate();
Optional<JsonNode> result2 = service.getLatestProductUpdate();
Optional<JsonNode> result3 = service.getLatestProductUpdate();
// Verify - HTTP should only be called once (cached after first fetch)
verify(mockHttpClient, times(1)).<String>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.<String>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<JsonNode> result1 = service.getLatestProductUpdate();
service.clearCache();
Optional<JsonNode> result2 = service.getLatestProductUpdate();
// Verify - HTTP should be called twice (once before clear, once after)
verify(mockHttpClient, times(2)).<String>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.<String>send(any(HttpRequest.class), any()))
.thenThrow(new IOException("Connection failed"));
service = new ProductUpdateService(TEST_URL, TEST_RESOURCE_PATH, mockHttpClient);
// Execute
Optional<JsonNode> 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.<String>send(any(HttpRequest.class), any())).thenReturn(mockHttpResponse);
when(mockHttpResponse.statusCode()).thenReturn(404);
service = new ProductUpdateService(TEST_URL, TEST_RESOURCE_PATH, mockHttpClient);
// Execute
Optional<JsonNode> 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.<String>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<JsonNode> 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.<String>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<JsonNode> result1 = service.getLatestProductUpdate();
service.clearCache();
Optional<JsonNode> result2 = service.getLatestProductUpdate();
// Verify - HTTP should be attempted twice
verify(mockHttpClient, times(2)).<String>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<JsonNode> result = service.getLatestProductUpdate();
// Verify - should not attempt HTTP, should use fallback
verify(mockHttpClient, never()).<String>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<JsonNode> result = service.getLatestProductUpdate();
// Verify - should not attempt HTTP, should use fallback
verify(mockHttpClient, never()).<String>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<JsonNode> result = service.getLatestProductUpdate();
// Verify - should not attempt HTTP, should use fallback
verify(mockHttpClient, never()).<String>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<JsonNode> 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.<String>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)).<String>send(any(HttpRequest.class), any());
}
@Test
public void testFallbackJsonStructure() throws Exception {
// Setup
service = new ProductUpdateService(null, TEST_RESOURCE_PATH, mockHttpClient);
// Execute
Optional<JsonNode> 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");
}
}

View File

@ -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"
}