diff --git a/docs/ISSUES-PHASE2.md b/docs/ISSUES-PHASE2.md index 058bec6..712611f 100644 --- a/docs/ISSUES-PHASE2.md +++ b/docs/ISSUES-PHASE2.md @@ -1500,14 +1500,14 @@ suggestion. The LLM synthesizes news headlines into risk factors that may affect ```java public interface NewsProvider { - List getRecentHeadlines(String route, int maxResults); + List getRecentHeadlines(String route, int maxResults); } ``` -**`NewsItem` DTO:** +**`MaritimeNewsArticle` DTO:** ```java -public class NewsItem { +public class MaritimeNewsArticle { private String headline; private String source; private LocalDate publishedDate; diff --git a/pom.xml b/pom.xml index 10cf3c7..7a483b6 100644 --- a/pom.xml +++ b/pom.xml @@ -101,6 +101,12 @@ json-schema-validator 1.0.87 + + + com.rometools + rome + 2.1.0 + diff --git a/src/main/java/com/shipping/freightops/dto/MaritimeNewsArticle.java b/src/main/java/com/shipping/freightops/dto/MaritimeNewsArticle.java new file mode 100644 index 0000000..d34563c --- /dev/null +++ b/src/main/java/com/shipping/freightops/dto/MaritimeNewsArticle.java @@ -0,0 +1,57 @@ +package com.shipping.freightops.dto; + +import java.time.LocalDate; + +/** + * Represents a maritime news article used for shipping risk analysis and freight pricing decisions. + * Contains headline, source, publication date, and summary information relevant to maritime + * operations. + */ +public class MaritimeNewsArticle { + private String headline; + private String source; + private LocalDate publishedDate; + private String summary; + + public MaritimeNewsArticle() {} + + public MaritimeNewsArticle( + String headline, String source, LocalDate publishedDate, String summary) { + this.headline = headline; + this.source = source; + this.publishedDate = publishedDate; + this.summary = summary; + } + + public String getHeadline() { + return headline; + } + + public void setHeadline(String headline) { + this.headline = headline; + } + + public String getSource() { + return source; + } + + public void setSource(String source) { + this.source = source; + } + + public LocalDate getPublishedDate() { + return publishedDate; + } + + public void setPublishedDate(LocalDate publishedDate) { + this.publishedDate = publishedDate; + } + + public String getSummary() { + return summary; + } + + public void setSummary(String summary) { + this.summary = summary; + } +} diff --git a/src/main/java/com/shipping/freightops/dto/PriceSuggestionResponse.java b/src/main/java/com/shipping/freightops/dto/PriceSuggestionResponse.java index 9ea3fe6..9e5b149 100644 --- a/src/main/java/com/shipping/freightops/dto/PriceSuggestionResponse.java +++ b/src/main/java/com/shipping/freightops/dto/PriceSuggestionResponse.java @@ -4,6 +4,8 @@ import com.shipping.freightops.enums.ContainerSize; import com.shipping.freightops.enums.PriceSuggestionConfidence; import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; public class PriceSuggestionResponse { private String voyageNumber; @@ -17,6 +19,7 @@ public class PriceSuggestionResponse { private BigDecimal historicalAvgUsd; private BigDecimal historicalMinUsd; private BigDecimal historicalMaxUsd; + private List riskFactors; public String getVoyageNumber() { return voyageNumber; @@ -111,6 +114,14 @@ public void setHistoricalMaxUsd(BigDecimal historicalMaxUsd) { this.historicalMaxUsd = historicalMaxUsd; } + public List getRiskFactors() { + return riskFactors; + } + + public void setRiskFactors(List riskFactors) { + this.riskFactors = riskFactors; + } + /** Creates a fallback response for no-data or parse-failure scenarios. */ public static PriceSuggestionResponse fallback( String voyageNumber, String route, ContainerSize containerSize, String reasoning) { @@ -126,6 +137,7 @@ public static PriceSuggestionResponse fallback( response.setHistoricalAvgUsd(null); response.setHistoricalMinUsd(null); response.setHistoricalMaxUsd(null); + response.setRiskFactors(new ArrayList<>()); return response; } } diff --git a/src/main/java/com/shipping/freightops/dto/RiskFactor.java b/src/main/java/com/shipping/freightops/dto/RiskFactor.java new file mode 100644 index 0000000..8dac5ce --- /dev/null +++ b/src/main/java/com/shipping/freightops/dto/RiskFactor.java @@ -0,0 +1,41 @@ +package com.shipping.freightops.dto; + +import com.shipping.freightops.enums.RiskImpact; + +public class RiskFactor { + private String factor; + private RiskImpact impact; + private String description; + + public RiskFactor() {} + + public RiskFactor(String factor, RiskImpact impact, String description) { + this.factor = factor; + this.impact = impact; + this.description = description; + } + + public String getFactor() { + return factor; + } + + public void setFactor(String factor) { + this.factor = factor; + } + + public RiskImpact getImpact() { + return impact; + } + + public void setImpact(RiskImpact impact) { + this.impact = impact; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } +} diff --git a/src/main/java/com/shipping/freightops/dto/VoyageResponse.java b/src/main/java/com/shipping/freightops/dto/VoyageResponse.java index 9a5ceeb..908d0e6 100644 --- a/src/main/java/com/shipping/freightops/dto/VoyageResponse.java +++ b/src/main/java/com/shipping/freightops/dto/VoyageResponse.java @@ -86,7 +86,7 @@ public LocalDateTime getArrivalDate() { return arrivalTime; } - public void setArrivalDate(LocalDateTime departureTime) { + public void setArrivalDate(LocalDateTime arrivalTime) { this.arrivalTime = arrivalTime; } diff --git a/src/main/java/com/shipping/freightops/enums/RiskImpact.java b/src/main/java/com/shipping/freightops/enums/RiskImpact.java new file mode 100644 index 0000000..ca0d824 --- /dev/null +++ b/src/main/java/com/shipping/freightops/enums/RiskImpact.java @@ -0,0 +1,8 @@ +package com.shipping.freightops.enums; + +/** Represents the potential impact level of a risk factor on shipping pricing. */ +public enum RiskImpact { + HIGH, + MEDIUM, + LOW +} diff --git a/src/main/java/com/shipping/freightops/news/MaritimeNewsSource.java b/src/main/java/com/shipping/freightops/news/MaritimeNewsSource.java new file mode 100644 index 0000000..60b16d1 --- /dev/null +++ b/src/main/java/com/shipping/freightops/news/MaritimeNewsSource.java @@ -0,0 +1,9 @@ +package com.shipping.freightops.news; + +import com.shipping.freightops.dto.MaritimeNewsArticle; +import java.util.List; + +public interface MaritimeNewsSource { + + List getRecentHeadlines(String route, int maxResults); +} diff --git a/src/main/java/com/shipping/freightops/news/ShippingNewsAnalyzer.java b/src/main/java/com/shipping/freightops/news/ShippingNewsAnalyzer.java new file mode 100644 index 0000000..70f25e3 --- /dev/null +++ b/src/main/java/com/shipping/freightops/news/ShippingNewsAnalyzer.java @@ -0,0 +1,80 @@ +package com.shipping.freightops.news; + +import com.shipping.freightops.dto.MaritimeNewsArticle; +import java.util.Arrays; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import org.springframework.stereotype.Service; + +@Service +public class ShippingNewsAnalyzer { + + private static final String ROUTE_SPLIT_REGEX = "[→\\-\\s]+"; + + private static final Set SHIPPING_KEYWORDS = + Set.of( + "port", + "shipping", + "container", + "vessel", + "freight", + "cargo", + "maritime", + "ocean", + "terminal", + "logistics", + "trade", + "export", + "import", + "suez", + "panama", + "canal", + "strait", + "route", + "disruption"); + + private final MaritimeNewsSource maritimeNewsSource; + + public ShippingNewsAnalyzer(MaritimeNewsSource maritimeNewsSource) { + this.maritimeNewsSource = maritimeNewsSource; + } + + public List getRelevantHeadlines(String route, int maxResults) { + List allHeadlines = + maritimeNewsSource.getRecentHeadlines(route, maxResults * 2); + + if (allHeadlines.isEmpty()) { + return List.of(); + } + + Set routeKeywords = extractRouteKeywords(route); + + return allHeadlines.stream() + .filter(item -> isRelevantToRoute(item, routeKeywords)) + .limit(maxResults) + .collect(Collectors.toList()); + } + + private Set extractRouteKeywords(String route) { + return Arrays.stream(route.split(ROUTE_SPLIT_REGEX)) + .map(String::trim) + .map(String::toLowerCase) + .filter(keyword -> !keyword.isEmpty()) + .collect(Collectors.toSet()); + } + + private boolean isRelevantToRoute(MaritimeNewsArticle item, Set routeKeywords) { + String content = (item.getHeadline() + " " + item.getSummary()).toLowerCase(); + + boolean matchesRouteKeywords = + routeKeywords.stream().anyMatch(keyword -> content.contains(keyword)); + boolean matchesShippingKeywords = containsShippingKeywords(content); + + return matchesRouteKeywords || matchesShippingKeywords; + } + + private boolean containsShippingKeywords(String content) { + return SHIPPING_KEYWORDS.stream().anyMatch(content::contains); + } +} diff --git a/src/main/java/com/shipping/freightops/news/config/NewsConfig.java b/src/main/java/com/shipping/freightops/news/config/NewsConfig.java new file mode 100644 index 0000000..e2603a1 --- /dev/null +++ b/src/main/java/com/shipping/freightops/news/config/NewsConfig.java @@ -0,0 +1,23 @@ +package com.shipping.freightops.news.config; + +import java.net.http.HttpClient; +import java.time.Duration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.JdkClientHttpRequestFactory; +import org.springframework.web.client.RestClient; + +@Configuration +public class NewsConfig { + + @Bean + public RestClient.Builder newsRestClientBuilder(NewsProperties properties) { + var requestFactory = + new JdkClientHttpRequestFactory( + HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(properties.getConnectTimeout())) + .build()); + + return RestClient.builder().requestFactory(requestFactory); + } +} diff --git a/src/main/java/com/shipping/freightops/news/config/NewsProperties.java b/src/main/java/com/shipping/freightops/news/config/NewsProperties.java new file mode 100644 index 0000000..7dc37fe --- /dev/null +++ b/src/main/java/com/shipping/freightops/news/config/NewsProperties.java @@ -0,0 +1,54 @@ +package com.shipping.freightops.news.config; + +import java.util.List; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "app.news") +public class NewsProperties { + + private String provider = "static"; + private List feeds = List.of(); + private int maxHeadlines = 5; + private int connectTimeout = 10; + private int readTimeout = 30; + + public String getProvider() { + return provider; + } + + public void setProvider(String provider) { + this.provider = provider; + } + + public List getFeeds() { + return feeds; + } + + public void setFeeds(List feeds) { + this.feeds = feeds; + } + + public int getMaxHeadlines() { + return maxHeadlines; + } + + public void setMaxHeadlines(int maxHeadlines) { + this.maxHeadlines = maxHeadlines; + } + + public int getConnectTimeout() { + return connectTimeout; + } + + public void setConnectTimeout(int connectTimeout) { + this.connectTimeout = connectTimeout; + } + + public int getReadTimeout() { + return readTimeout; + } + + public void setReadTimeout(int readTimeout) { + this.readTimeout = readTimeout; + } +} diff --git a/src/main/java/com/shipping/freightops/news/impl/NoOpMaritimeNewsSource.java b/src/main/java/com/shipping/freightops/news/impl/NoOpMaritimeNewsSource.java new file mode 100644 index 0000000..2140581 --- /dev/null +++ b/src/main/java/com/shipping/freightops/news/impl/NoOpMaritimeNewsSource.java @@ -0,0 +1,17 @@ +package com.shipping.freightops.news.impl; + +import com.shipping.freightops.dto.MaritimeNewsArticle; +import com.shipping.freightops.news.MaritimeNewsSource; +import java.util.List; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +@Component +@ConditionalOnProperty(name = "app.news.provider", havingValue = "noop") +public class NoOpMaritimeNewsSource implements MaritimeNewsSource { + + @Override + public List getRecentHeadlines(String route, int maxResults) { + return List.of(); + } +} diff --git a/src/main/java/com/shipping/freightops/news/impl/RssMaritimeNewsSource.java b/src/main/java/com/shipping/freightops/news/impl/RssMaritimeNewsSource.java new file mode 100644 index 0000000..5d4ebe1 --- /dev/null +++ b/src/main/java/com/shipping/freightops/news/impl/RssMaritimeNewsSource.java @@ -0,0 +1,177 @@ +package com.shipping.freightops.news.impl; + +import com.rometools.rome.feed.synd.SyndEntry; +import com.rometools.rome.feed.synd.SyndFeed; +import com.rometools.rome.io.SyndFeedInput; +import com.shipping.freightops.dto.MaritimeNewsArticle; +import com.shipping.freightops.news.MaritimeNewsSource; +import com.shipping.freightops.news.config.NewsProperties; +import java.io.StringReader; +import java.net.URI; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.stream.Collectors; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClientException; + +@Component +@ConditionalOnProperty(name = "app.news.provider", havingValue = "rss") +public class RssMaritimeNewsSource implements MaritimeNewsSource { + + private static final Logger logger = LoggerFactory.getLogger(RssMaritimeNewsSource.class); + private static final int MAX_SUMMARY_LENGTH = 500; + + private final RestClient restClient; + private final NewsProperties newsProperties; + + public RssMaritimeNewsSource( + RestClient.Builder restClientBuilder, NewsProperties newsProperties) { + this.restClient = restClientBuilder.build(); + this.newsProperties = newsProperties; + } + + @Override + public List getRecentHeadlines(String route, int maxResults) { + List allItems = new ArrayList<>(); + + for (String feedUrl : newsProperties.getFeeds()) { + try { + List feedItems = processFeed(feedUrl); + allItems.addAll(feedItems); + } catch (Exception e) { + logger.warn("Failed to fetch feed: {}", feedUrl, e); + } + } + + return allItems.stream() + .distinct() + .limit(Math.min(maxResults, newsProperties.getMaxHeadlines())) + .collect(Collectors.toList()); + } + + private List processFeed(String feedUrl) { + try { + String rssContent = fetchRssContent(feedUrl); + if (isEmptyContent(rssContent)) { + return List.of(); + } + + SyndFeed feed = parseRssFeed(rssContent); + return transformEntriesToArticles(feed, feedUrl); + + } catch (RestClientException e) { + logger.warn("Network error fetching RSS feed {}: {}", feedUrl, e.getMessage()); + throw e; + } catch (Exception e) { + logger.warn("Error parsing RSS feed {}: {}", feedUrl, e.getMessage()); + throw new RuntimeException("RSS parsing failed", e); + } + } + + private String fetchRssContent(String feedUrl) { + return restClient.get().uri(feedUrl).retrieve().body(String.class); + } + + private boolean isEmptyContent(String content) { + return content == null || content.trim().isEmpty(); + } + + private SyndFeed parseRssFeed(String rssContent) throws Exception { + SyndFeedInput input = new SyndFeedInput(); + return input.build(new StringReader(rssContent)); + } + + private List transformEntriesToArticles(SyndFeed feed, String feedUrl) { + return feed.getEntries().stream() + .map(entry -> transformEntry(entry, feedUrl)) + .filter(article -> article != null) + .collect(Collectors.toList()); + } + + private MaritimeNewsArticle transformEntry(SyndEntry entry, String feedUrl) { + String headline = entry.getTitle(); + if (isInvalidHeadline(headline)) { + return null; + } + + String source = extractSourceFromFeedUrl(feedUrl); + LocalDate publishedDate = parsePublishedDate(entry); + String summary = truncateDescription(entry.getDescription()); + + return new MaritimeNewsArticle(headline.trim(), source, publishedDate, summary); + } + + private boolean isInvalidHeadline(String headline) { + return headline == null || headline.trim().isEmpty(); + } + + private String extractSourceFromFeedUrl(String feedUrl) { + try { + URI uri = URI.create(feedUrl); + String host = uri.getHost(); + if (host != null) { + return removeWwwPrefix(host); + } + } catch (Exception e) { + // Silently fall back to default source + } + return "RSS Feed"; + } + + private String removeWwwPrefix(String host) { + return host.startsWith("www.") ? host.substring(4) : host; + } + + private LocalDate parsePublishedDate(SyndEntry entry) { + LocalDate date = tryParseDate(entry.getPublishedDate()); + if (date != null) { + return date; + } + + date = tryParseDate(entry.getUpdatedDate()); + if (date != null) { + return date; + } + + return LocalDate.now(); + } + + private LocalDate tryParseDate(Date date) { + try { + if (date != null) { + return date.toInstant().atZone(ZoneId.systemDefault()).toLocalDate(); + } + } catch (Exception e) { + // Silently ignore date parsing errors + } + return null; + } + + private String truncateDescription(Object description) { + if (description == null) { + return ""; + } + + String desc = description.toString().trim(); + if (desc.length() <= MAX_SUMMARY_LENGTH) { + return desc; + } + + return truncateAtWordBoundary(desc); + } + + private String truncateAtWordBoundary(String text) { + int lastSpace = text.lastIndexOf(' ', MAX_SUMMARY_LENGTH); + if (lastSpace > 0) { + return text.substring(0, lastSpace) + "..."; + } + return text.substring(0, MAX_SUMMARY_LENGTH) + "..."; + } +} diff --git a/src/main/java/com/shipping/freightops/news/impl/StaticMaritimeNewsSource.java b/src/main/java/com/shipping/freightops/news/impl/StaticMaritimeNewsSource.java new file mode 100644 index 0000000..1f3a818 --- /dev/null +++ b/src/main/java/com/shipping/freightops/news/impl/StaticMaritimeNewsSource.java @@ -0,0 +1,71 @@ +package com.shipping.freightops.news.impl; + +import com.shipping.freightops.dto.MaritimeNewsArticle; +import com.shipping.freightops.news.MaritimeNewsSource; +import java.time.LocalDate; +import java.util.List; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +@Component +@ConditionalOnProperty(name = "app.news.provider", havingValue = "static", matchIfMissing = true) +public class StaticMaritimeNewsSource implements MaritimeNewsSource { + + private static final List SAMPLE_NEWS = + List.of( + new MaritimeNewsArticle( + "Red Sea Disruptions Force Major Shipping Lines to Reroute via Cape of Good Hope", + "Lloyd's List", + LocalDate.now().minusDays(2), + "Ongoing Houthi attacks in the Red Sea have forced major container lines to avoid the Suez Canal, adding 10-14 days to transit times and significantly increasing fuel costs."), + new MaritimeNewsArticle( + "Shanghai Port Experiences Severe Congestion Amid Export Surge", + "The Loadstar", + LocalDate.now().minusDays(1), + "Shanghai terminals report 3-5 day delays as export volumes surge ahead of Q2. Container availability remains tight across major Chinese ports."), + new MaritimeNewsArticle( + "Panama Canal Implements New Water Conservation Measures", + "gCaptain", + LocalDate.now().minusDays(3), + "Drought conditions force Panama Canal Authority to reduce daily transits and implement strict draft restrictions, affecting global shipping schedules."), + new MaritimeNewsArticle( + "Los Angeles Port Workers Reach Tentative Labor Agreement", + "Maritime Executive", + LocalDate.now().minusDays(1), + "Tentative agreement reached between ILWU and port operators, potentially avoiding strikes that could have disrupted West Coast cargo operations."), + new MaritimeNewsArticle( + "Container Freight Rates Surge on Asia-Europe Routes", + "The Loadstar", + LocalDate.now().minusDays(4), + "Spot rates on major Asia-Europe trade lanes increase by 25% week-over-week due to capacity constraints and Red Sea diversions."), + new MaritimeNewsArticle( + "Singapore Port Authority Announces Terminal Expansion", + "Lloyd's List", + LocalDate.now().minusDays(5), + "PSA Singapore unveils plans for new automated terminal to handle growing transshipment volumes and larger container vessels."), + new MaritimeNewsArticle( + "Maersk Reports Strong Q1 Earnings Despite Route Disruptions", + "Maritime Executive", + LocalDate.now().minusDays(3), + "Danish shipping giant posts solid quarterly results while managing increased operational costs from Red Sea route diversions."), + new MaritimeNewsArticle( + "New Environmental Regulations Impact Vessel Operations in European Ports", + "gCaptain", + LocalDate.now().minusDays(6), + "EU's updated emissions standards require additional compliance measures for vessels calling at European terminals, potentially affecting scheduling."), + new MaritimeNewsArticle( + "Typhoon Season Preparations Underway at Asian Ports", + "Maritime Executive", + LocalDate.now().minusDays(2), + "Major ports across Southeast Asia implement enhanced weather monitoring and cargo protection measures ahead of typhoon season."), + new MaritimeNewsArticle( + "Hamburg Port Invests in Digital Infrastructure Upgrades", + "Lloyd's List", + LocalDate.now().minusDays(4), + "Port of Hamburg announces major digitalization initiative to improve cargo tracking and reduce vessel turnaround times.")); + + @Override + public List getRecentHeadlines(String route, int maxResults) { + return SAMPLE_NEWS.stream().limit(Math.min(maxResults, SAMPLE_NEWS.size())).toList(); + } +} diff --git a/src/main/java/com/shipping/freightops/schema/CompositeSchemaBuilder.java b/src/main/java/com/shipping/freightops/schema/CompositeSchemaBuilder.java new file mode 100644 index 0000000..020acda --- /dev/null +++ b/src/main/java/com/shipping/freightops/schema/CompositeSchemaBuilder.java @@ -0,0 +1,59 @@ +package com.shipping.freightops.schema; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.io.IOException; +import org.springframework.stereotype.Component; + +/** + * Builds a composite schema by combining the main price suggestion schema with embedded risk factor + * definitions to resolve external references. + */ +@Component +public class CompositeSchemaBuilder { + + private final PriceSuggestionSchemaBuilder priceSuggestionSchemaBuilder; + private final RiskFactorSchemaBuilder riskFactorSchemaBuilder; + private final ObjectMapper objectMapper; + + public CompositeSchemaBuilder( + PriceSuggestionSchemaBuilder priceSuggestionSchemaBuilder, + RiskFactorSchemaBuilder riskFactorSchemaBuilder, + ObjectMapper objectMapper) { + this.priceSuggestionSchemaBuilder = priceSuggestionSchemaBuilder; + this.riskFactorSchemaBuilder = riskFactorSchemaBuilder; + this.objectMapper = objectMapper; + } + + /** + * Builds a composite schema with risk factor definitions embedded as $defs to resolve external + * references. + */ + public String buildCompositeSchema() throws JsonProcessingException, IOException { + // Get the main schema + String mainSchemaJson = priceSuggestionSchemaBuilder.build(); + ObjectNode mainSchema = (ObjectNode) objectMapper.readTree(mainSchemaJson); + + // Get the risk factor schema + String riskFactorSchemaJson = riskFactorSchemaBuilder.build(); + JsonNode riskFactorSchema = objectMapper.readTree(riskFactorSchemaJson); + + // Add risk factor as a $defs entry to resolve external references + ObjectNode defs = objectMapper.createObjectNode(); + defs.set("riskFactor", riskFactorSchema); + mainSchema.set("$defs", defs); + + // Update the reference to use internal $defs instead of external file + ObjectNode riskFactorsProperty = (ObjectNode) mainSchema.at("/properties/riskFactors"); + if (riskFactorsProperty != null) { + ObjectNode items = (ObjectNode) riskFactorsProperty.get("items"); + if (items != null) { + items.put("$ref", "#/$defs/riskFactor"); + } + } + + return objectMapper.writeValueAsString(mainSchema); + } +} diff --git a/src/main/java/com/shipping/freightops/schema/PriceSuggestionSchemaBuilder.java b/src/main/java/com/shipping/freightops/schema/PriceSuggestionSchemaBuilder.java index 9c3d72e..3831305 100644 --- a/src/main/java/com/shipping/freightops/schema/PriceSuggestionSchemaBuilder.java +++ b/src/main/java/com/shipping/freightops/schema/PriceSuggestionSchemaBuilder.java @@ -28,11 +28,12 @@ public String build() throws JsonProcessingException, IOException { (ObjectNode) objectMapper.readTree(new ClassPathResource(SCHEMA_PATH).getInputStream()); ObjectNode properties = (ObjectNode) root.get("properties"); ObjectNode confidence = (ObjectNode) properties.get("confidence"); - ArrayNode enumArray = objectMapper.createArrayNode(); + ArrayNode confidenceEnumArray = objectMapper.createArrayNode(); for (PriceSuggestionConfidence c : PriceSuggestionConfidence.values()) { - enumArray.add(c.name()); + confidenceEnumArray.add(c.name()); } - confidence.set("enum", enumArray); + confidence.set("enum", confidenceEnumArray); + return objectMapper.writeValueAsString(root); } } diff --git a/src/main/java/com/shipping/freightops/schema/RiskFactorSchemaBuilder.java b/src/main/java/com/shipping/freightops/schema/RiskFactorSchemaBuilder.java new file mode 100644 index 0000000..887095f --- /dev/null +++ b/src/main/java/com/shipping/freightops/schema/RiskFactorSchemaBuilder.java @@ -0,0 +1,39 @@ +package com.shipping.freightops.schema; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.shipping.freightops.enums.RiskImpact; +import java.io.IOException; +import org.springframework.core.io.ClassPathResource; +import org.springframework.stereotype.Component; + +/** Builds the risk factor JSON schema with enum values injected from RiskImpact. */ +@Component +public class RiskFactorSchemaBuilder { + + private static final String SCHEMA_PATH = "schemas/risk-factor.json"; + + private final ObjectMapper objectMapper; + + public RiskFactorSchemaBuilder(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + public String build() throws JsonProcessingException, IOException { + ObjectNode root = + (ObjectNode) objectMapper.readTree(new ClassPathResource(SCHEMA_PATH).getInputStream()); + ObjectNode properties = (ObjectNode) root.get("properties"); + + // Inject RiskImpact enum values + ObjectNode impact = (ObjectNode) properties.get("impact"); + ArrayNode impactEnumArray = objectMapper.createArrayNode(); + for (RiskImpact r : RiskImpact.values()) { + impactEnumArray.add(r.name()); + } + impact.set("enum", impactEnumArray); + + return objectMapper.writeValueAsString(root); + } +} diff --git a/src/main/java/com/shipping/freightops/service/PriceSuggestionService.java b/src/main/java/com/shipping/freightops/service/PriceSuggestionService.java index 19a2f08..3d813ad 100644 --- a/src/main/java/com/shipping/freightops/service/PriceSuggestionService.java +++ b/src/main/java/com/shipping/freightops/service/PriceSuggestionService.java @@ -16,7 +16,7 @@ import com.shipping.freightops.repository.PortRepository; import com.shipping.freightops.repository.VoyagePriceRepository; import com.shipping.freightops.repository.VoyageRepository; -import com.shipping.freightops.schema.PriceSuggestionSchemaBuilder; +import com.shipping.freightops.schema.CompositeSchemaBuilder; import com.shipping.freightops.util.CountryRegionMapper; import java.io.IOException; import java.math.BigDecimal; @@ -43,6 +43,7 @@ public class PriceSuggestionService { private final PortRepository portRepository; private final AiClient aiClient; private final ObjectMapper objectMapper; + private final RiskAnalysisService riskAnalysisService; private final String schemaJson; private final JsonSchema jsonSchema; private final String systemPrompt; @@ -54,15 +55,17 @@ public PriceSuggestionService( PortRepository portRepository, AiClient aiClient, ObjectMapper objectMapper, - PriceSuggestionSchemaBuilder schemaBuilder) { + RiskAnalysisService riskAnalysisService, + CompositeSchemaBuilder compositeSchemaBuilder) { this.voyageRepository = voyageRepository; this.voyagePriceRepository = voyagePriceRepository; this.freightOrderRepository = freightOrderRepository; this.portRepository = portRepository; this.aiClient = aiClient; this.objectMapper = objectMapper; + this.riskAnalysisService = riskAnalysisService; try { - this.schemaJson = schemaBuilder.build(); + this.schemaJson = compositeSchemaBuilder.buildCompositeSchema(); this.systemPrompt = loadSystemPrompt(); } catch (IOException e) { throw new IllegalStateException("Failed to load price suggestion resources", e); @@ -176,6 +179,11 @@ private String buildPrompt( StringBuilder sb = new StringBuilder(); sb.append("Route: ").append(route).append("\n"); sb.append("Container size for which to suggest price: ").append(containerSize).append("\n\n"); + String newsContext = riskAnalysisService.fetchAndBuildNewsContext(route); + if (!newsContext.isEmpty()) { + sb.append(newsContext); + } + sb.append("Historical data:\n"); sb.append("voyageNumber | departureDate | priceUsd | orderCount | containerSize\n"); sb.append("-------------|---------------|----------|------------|---------------\n"); @@ -228,6 +236,7 @@ private PriceSuggestionResponse mapToResponse( response.setHistoricalAvgUsd(computeAvg(historicalPrices)); response.setHistoricalMinUsd(computeMin(historicalPrices)); response.setHistoricalMaxUsd(computeMax(historicalPrices)); + response.setRiskFactors(riskAnalysisService.parseRiskFactors(parsed)); return response; } diff --git a/src/main/java/com/shipping/freightops/service/RiskAnalysisService.java b/src/main/java/com/shipping/freightops/service/RiskAnalysisService.java new file mode 100644 index 0000000..fa841ab --- /dev/null +++ b/src/main/java/com/shipping/freightops/service/RiskAnalysisService.java @@ -0,0 +1,129 @@ +package com.shipping.freightops.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.shipping.freightops.dto.MaritimeNewsArticle; +import com.shipping.freightops.dto.RiskFactor; +import com.shipping.freightops.enums.RiskImpact; +import com.shipping.freightops.news.ShippingNewsAnalyzer; +import com.shipping.freightops.news.config.NewsProperties; +import java.util.ArrayList; +import java.util.List; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +/** + * Service responsible for risk factor analysis and news integration for shipping price suggestions. + * Handles fetching relevant news, building news context for prompts, and parsing risk factors from + * AI responses. + */ +@Service +public class RiskAnalysisService { + + private static final Logger logger = LoggerFactory.getLogger(RiskAnalysisService.class); + + private final ShippingNewsAnalyzer shippingNewsAnalyzer; + private final NewsProperties newsProperties; + + public RiskAnalysisService( + ShippingNewsAnalyzer shippingNewsAnalyzer, NewsProperties newsProperties) { + this.shippingNewsAnalyzer = shippingNewsAnalyzer; + this.newsProperties = newsProperties; + } + + public List fetchRelevantNews(String route) { + try { + return shippingNewsAnalyzer.getRelevantHeadlines(route, newsProperties.getMaxHeadlines()); + } catch (Exception e) { + logger.warn( + "News fetch failed for route '{}', continuing without news: {}", route, e.getMessage()); + return List.of(); + } + } + + public String buildNewsContext(List relevantNews) { + if (relevantNews == null || relevantNews.isEmpty()) { + return ""; + } + + StringBuilder context = new StringBuilder("Recent relevant news:\n"); + + for (MaritimeNewsArticle news : relevantNews) { + context.append("- ").append(news.getHeadline()); + String summary = news.getSummary(); + if (summary != null && !summary.trim().isEmpty()) { + context.append(" (").append(summary.trim()).append(")"); + } + context.append("\n"); + } + + return context.append("\n").toString(); + } + + public List parseRiskFactors(JsonNode parsed) { + JsonNode riskFactorsNode = parsed.path("riskFactors"); + if (!riskFactorsNode.isArray()) { + return List.of(); + } + + List riskFactors = new ArrayList<>(); + int skippedCount = 0; + + for (JsonNode riskNode : riskFactorsNode) { + try { + RiskFactor riskFactor = parseRiskFactor(riskNode); + if (riskFactor != null) { + riskFactors.add(riskFactor); + continue; + } + skippedCount++; + + } catch (Exception e) { + skippedCount++; + logger.debug("Skipped invalid risk factor: {}", riskNode); + } + } + + if (skippedCount > 0) { + logger.info( + "Parsed {} risk factors, skipped {} invalid entries", riskFactors.size(), skippedCount); + } + + return riskFactors; + } + + private RiskFactor parseRiskFactor(JsonNode riskNode) { + String factorText = riskNode.path("factor").asText("").trim(); + if (factorText.isEmpty()) { + return null; + } + + RiskFactor riskFactor = new RiskFactor(); + riskFactor.setFactor(factorText); + riskFactor.setImpact(parseRiskImpact(riskNode.path("impact").asText("").trim())); + + String description = riskNode.path("description").asText("").trim(); + if (!description.isEmpty()) { + riskFactor.setDescription(description); + } + + return riskFactor; + } + + private RiskImpact parseRiskImpact(String impactStr) { + if (impactStr.isEmpty()) { + return RiskImpact.LOW; + } + + try { + return RiskImpact.valueOf(impactStr.toUpperCase()); + } catch (IllegalArgumentException e) { + return RiskImpact.LOW; + } + } + + public String fetchAndBuildNewsContext(String route) { + List relevantNews = fetchRelevantNews(route); + return buildNewsContext(relevantNews); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 333b72f..43ee60c 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -27,3 +27,9 @@ app.ai.anthropic-version=2023-06-01 app.ai.test.enabled=true # Base url app.base-url=http://localhost:8080 +# News provider +app.news.provider=static +app.news.feeds=https://gcaptain.com/feed/,https://theloadstar.com/feed/ +app.news.max-headlines=5 +app.news.connect-timeout=10 +app.news.read-timeout=30 diff --git a/src/main/resources/prompts/price-suggestion-system.txt b/src/main/resources/prompts/price-suggestion-system.txt index 250a32e..c39b87e 100644 --- a/src/main/resources/prompts/price-suggestion-system.txt +++ b/src/main/resources/prompts/price-suggestion-system.txt @@ -1,5 +1,13 @@ [PRICE_SUGGESTION_DEFAULT] -You are a freight pricing analyst. Suggest a price range based on the historical data provided. +You are a freight pricing analyst. Suggest a price range based on the historical data and recent news provided. Respond with JSON only, matching the schema. Use confidence levels: HIGH (10+ data points, same route), MEDIUM (3-9 data points or similar routes), LOW (fewer than 3 data points, set confidence to LOW and note insufficient data). Explain your reasoning in 2-3 sentences. + +When relevant news headlines are provided, analyze them for risk factors that may impact shipping costs on this route. +For each significant risk factor identified, include it in the riskFactors array with: +- factor: A clear, concise description of the risk +- impact: HIGH, MEDIUM, or LOW based on potential price impact +- description: Detailed explanation of how this factor affects shipping costs + +Focus on factors that directly affect operational costs, delays, or route viability. Avoid speculation beyond what the news clearly indicates. diff --git a/src/main/resources/schemas/price-suggestion.json b/src/main/resources/schemas/price-suggestion.json index 8955bb3..1c8ac2f 100644 --- a/src/main/resources/schemas/price-suggestion.json +++ b/src/main/resources/schemas/price-suggestion.json @@ -32,6 +32,12 @@ "historicalMaxUsd": { "type": ["number", "null"], "minimum": 0 + }, + "riskFactors": { + "type": "array", + "items": { + "$ref": "risk-factor.json" + } } }, "required": [ diff --git a/src/main/resources/schemas/risk-factor.json b/src/main/resources/schemas/risk-factor.json new file mode 100644 index 0000000..d82acad --- /dev/null +++ b/src/main/resources/schemas/risk-factor.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "factor": { + "type": "string" + }, + "impact": { + "type": "string", + "enum": [] + }, + "description": { + "type": "string" + } + }, + "required": ["factor", "impact", "description"], + "additionalProperties": false +} diff --git a/src/test/java/com/shipping/freightops/news/RssProviderIntegrationTest.java b/src/test/java/com/shipping/freightops/news/RssProviderIntegrationTest.java new file mode 100644 index 0000000..cd7f490 --- /dev/null +++ b/src/test/java/com/shipping/freightops/news/RssProviderIntegrationTest.java @@ -0,0 +1,124 @@ +package com.shipping.freightops.news; + +import static org.junit.jupiter.api.Assertions.*; + +import com.shipping.freightops.dto.MaritimeNewsArticle; +import com.shipping.freightops.news.config.NewsProperties; +import com.shipping.freightops.service.RiskAnalysisService; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; + +@SpringBootTest +@TestPropertySource( + properties = { + "app.news.provider=static", // Use static provider for reliable testing + "app.news.max-headlines=3" + }) +class RssProviderIntegrationTest { + + @Autowired private ShippingNewsAnalyzer shippingNewsAnalyzer; + + @Autowired private RiskAnalysisService riskAnalysisService; + + @Autowired private MaritimeNewsSource maritimeNewsSource; + + @Autowired private NewsProperties newsProperties; + + @Test + @DisplayName("RSS provider integrates correctly with ShippingNewsAnalyzer") + void testRssProviderIntegration() { + // Given a route + String testRoute = "Shanghai-Rotterdam"; + + // When fetching relevant news through the analyzer + List relevantNews = + shippingNewsAnalyzer.getRelevantHeadlines(testRoute, 5); + + // Then news should be returned + assertNotNull(relevantNews); + // Note: May be empty if no relevant news, but should not be null + + // Verify the news source is properly configured + assertNotNull(maritimeNewsSource); + assertTrue( + maritimeNewsSource.getClass().getSimpleName().contains("MaritimeNewsSource"), + "Expected a MaritimeNewsSource implementation"); + } + + @Test + @DisplayName("RSS provider works with RiskAnalysisService end-to-end") + void testRssProviderWithRiskAnalysisService() { + // Given a route + String testRoute = "Asia-Europe"; + + // When fetching relevant news through the risk analysis service + List relevantNews = riskAnalysisService.fetchRelevantNews(testRoute); + + // Then + assertNotNull(relevantNews); + // Should not throw exceptions and return a valid list (may be empty) + + // When building news context + String newsContext = riskAnalysisService.buildNewsContext(relevantNews); + + // Then + assertNotNull(newsContext); + // Context should be either empty string or contain news information + } + + @Test + @DisplayName("Configuration properties are properly loaded") + void testConfigurationPropertiesLoaded() { + // Verify NewsProperties are properly configured + assertNotNull(newsProperties); + assertEquals(3, newsProperties.getMaxHeadlines()); + assertEquals("static", newsProperties.getProvider()); + } + + @Test + @DisplayName("MaritimeNewsSource can fetch headlines directly") + void testMaritimeNewsSourceDirectAccess() { + // Given + String testRoute = "Test-Route"; + int maxResults = 2; + + // When + List headlines = + maritimeNewsSource.getRecentHeadlines(testRoute, maxResults); + + // Then + assertNotNull(headlines); + // Should not throw exceptions + + // If headlines are returned, verify they have required fields + for (MaritimeNewsArticle article : headlines) { + assertNotNull(article.getHeadline(), "Headline should not be null"); + assertNotNull(article.getSource(), "Source should not be null"); + assertNotNull(article.getPublishedDate(), "Published date should not be null"); + assertNotNull(article.getSummary(), "Summary should not be null"); + } + } + + @Test + @DisplayName("Service layer handles news provider gracefully when no news available") + void testGracefulHandlingOfNoNews() { + // Given a route that likely has no relevant news + String obscureRoute = "NonExistent-Port"; + + // When + List relevantNews = riskAnalysisService.fetchRelevantNews(obscureRoute); + String newsContext = riskAnalysisService.buildNewsContext(relevantNews); + + // Then - should handle gracefully without exceptions + assertNotNull(relevantNews); + assertNotNull(newsContext); + // Empty news should result in empty context + if (relevantNews.isEmpty()) { + assertTrue(newsContext.isEmpty() || newsContext.isBlank()); + } + } +} diff --git a/src/test/java/com/shipping/freightops/news/ShippingNewsAnalyzerTest.java b/src/test/java/com/shipping/freightops/news/ShippingNewsAnalyzerTest.java new file mode 100644 index 0000000..e9404bc --- /dev/null +++ b/src/test/java/com/shipping/freightops/news/ShippingNewsAnalyzerTest.java @@ -0,0 +1,201 @@ +package com.shipping.freightops.news; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; + +import com.shipping.freightops.dto.MaritimeNewsArticle; +import java.time.LocalDate; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +class ShippingNewsAnalyzerTest { + + @Mock private MaritimeNewsSource maritimeNewsSource; + + private ShippingNewsAnalyzer shippingNewsAnalyzer; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + shippingNewsAnalyzer = new ShippingNewsAnalyzer(maritimeNewsSource); + } + + @Test + void getRelevantHeadlines_filtersRouteRelevantNews() { + List mockNews = + List.of( + new MaritimeNewsArticle( + "Shanghai Port Congestion Delays", + "Maritime News", + LocalDate.now(), + "Port congestion in Shanghai"), + new MaritimeNewsArticle( + "Los Angeles Terminal Expansion", + "Port Authority", + LocalDate.now(), + "New terminal in Los Angeles"), + new MaritimeNewsArticle( + "Weather Update for Europe", + "Weather Service", + LocalDate.now(), + "Storms expected in Europe")); + + when(maritimeNewsSource.getRecentHeadlines(anyString(), anyInt())).thenReturn(mockNews); + + List result = + shippingNewsAnalyzer.getRelevantHeadlines("Shanghai → Los Angeles", 5); + + assertThat(result).hasSize(2); + assertThat(result.get(0).getHeadline()).contains("Shanghai"); + assertThat(result.get(1).getHeadline()).contains("Los Angeles"); + } + + @Test + void getRelevantHeadlines_filtersShippingKeywords() { + List mockNews = + List.of( + new MaritimeNewsArticle( + "Container Freight Rates Rise", + "Shipping Times", + LocalDate.now(), + "Container shipping costs increase"), + new MaritimeNewsArticle( + "New Restaurant Opens", + "Local News", + LocalDate.now(), + "A new restaurant opened downtown"), + new MaritimeNewsArticle( + "Maritime Safety Regulations", + "Industry News", + LocalDate.now(), + "New maritime safety rules")); + + when(maritimeNewsSource.getRecentHeadlines(anyString(), anyInt())).thenReturn(mockNews); + + List result = + shippingNewsAnalyzer.getRelevantHeadlines("CityA → CityB", 5); + + assertThat(result).hasSize(2); + assertThat(result.get(0).getHeadline()).contains("Container"); + assertThat(result.get(1).getHeadline()).contains("Maritime"); + } + + @Test + void getRelevantHeadlines_respectsMaxResults() { + List mockNews = + List.of( + new MaritimeNewsArticle("Port News 1", "Source", LocalDate.now(), "Port operations"), + new MaritimeNewsArticle("Port News 2", "Source", LocalDate.now(), "Port congestion"), + new MaritimeNewsArticle("Port News 3", "Source", LocalDate.now(), "Port expansion"), + new MaritimeNewsArticle("Port News 4", "Source", LocalDate.now(), "Port delays")); + + when(maritimeNewsSource.getRecentHeadlines(anyString(), anyInt())).thenReturn(mockNews); + + List result = shippingNewsAnalyzer.getRelevantHeadlines("Test Route", 2); + + assertThat(result).hasSize(2); + } + + @Test + void getRelevantHeadlines_returnsEmptyWhenNoNews() { + when(maritimeNewsSource.getRecentHeadlines(anyString(), anyInt())).thenReturn(List.of()); + + List result = shippingNewsAnalyzer.getRelevantHeadlines("Any Route", 5); + + assertThat(result).isEmpty(); + } + + @Test + void getRelevantHeadlines_filtersIrrelevantNews() { + List mockNews = + List.of( + new MaritimeNewsArticle( + "Basketball Game", "Sports News", LocalDate.now(), "Local team wins championship"), + new MaritimeNewsArticle( + "Weather Update", "Weather Service", LocalDate.now(), "Sunny skies tomorrow"), + new MaritimeNewsArticle( + "Election News", "News Agency", LocalDate.now(), "Voting completed successfully")); + + when(maritimeNewsSource.getRecentHeadlines(anyString(), anyInt())).thenReturn(mockNews); + + List result = + shippingNewsAnalyzer.getRelevantHeadlines("CityA → CityB", 5); + + assertThat(result).isEmpty(); + } + + @Test + void getRelevantHeadlines_matchesRouteKeywords() { + List mockNews = + List.of( + new MaritimeNewsArticle( + "Shanghai Economic Update", + "Economic News", + LocalDate.now(), + "Shanghai economy grows"), + new MaritimeNewsArticle( + "Los Angeles Traffic Report", + "Traffic News", + LocalDate.now(), + "Heavy traffic in Los Angeles"), + new MaritimeNewsArticle( + "New York Weather", + "Weather Service", + LocalDate.now(), + "Rain expected in New York")); + + when(maritimeNewsSource.getRecentHeadlines(anyString(), anyInt())).thenReturn(mockNews); + + List result = + shippingNewsAnalyzer.getRelevantHeadlines("Shanghai → Los Angeles", 5); + + assertThat(result).hasSize(2); + assertThat(result.get(0).getHeadline()).contains("Shanghai"); + assertThat(result.get(1).getHeadline()).contains("Los Angeles"); + } + + @Test + void getRelevantHeadlines_handlesDashSeparatedRoutes() { + List mockNews = + List.of( + new MaritimeNewsArticle( + "Hamburg Port Update", "Port News", LocalDate.now(), "Hamburg port operations"), + new MaritimeNewsArticle( + "Rotterdam Expansion", + "Maritime News", + LocalDate.now(), + "Rotterdam terminal expansion")); + + when(maritimeNewsSource.getRecentHeadlines(anyString(), anyInt())).thenReturn(mockNews); + + List result = + shippingNewsAnalyzer.getRelevantHeadlines("Hamburg-Rotterdam", 5); + + assertThat(result).hasSize(2); + } + + @Test + void getRelevantHeadlines_filtersWhenNoRelevantMatches() { + List mockNews = + List.of( + new MaritimeNewsArticle( + "Technology News", "Tech Source", LocalDate.now(), "New smartphone released"), + new MaritimeNewsArticle( + "Entertainment Update", + "Entertainment News", + LocalDate.now(), + "Movie premiere scheduled")); + + when(maritimeNewsSource.getRecentHeadlines(anyString(), anyInt())).thenReturn(mockNews); + + List result = + shippingNewsAnalyzer.getRelevantHeadlines("Unknown → Route", 5); + + assertThat(result).isEmpty(); + } +} diff --git a/src/test/java/com/shipping/freightops/news/impl/NoOpMaritimeNewsSourceTest.java b/src/test/java/com/shipping/freightops/news/impl/NoOpMaritimeNewsSourceTest.java new file mode 100644 index 0000000..58d28a6 --- /dev/null +++ b/src/test/java/com/shipping/freightops/news/impl/NoOpMaritimeNewsSourceTest.java @@ -0,0 +1,34 @@ +package com.shipping.freightops.news.impl; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.shipping.freightops.dto.MaritimeNewsArticle; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class NoOpMaritimeNewsSourceTest { + + private NoOpMaritimeNewsSource noOpMaritimeNewsSource; + + @BeforeEach + void setUp() { + noOpMaritimeNewsSource = new NoOpMaritimeNewsSource(); + } + + @Test + void getRecentHeadlines_alwaysReturnsEmpty() { + List result = noOpMaritimeNewsSource.getRecentHeadlines("Any Route", 10); + + assertThat(result).isEmpty(); + } + + @Test + void getRecentHeadlines_returnsEmptyRegardlessOfMaxResults() { + List result1 = noOpMaritimeNewsSource.getRecentHeadlines("Route 1", 1); + List result2 = noOpMaritimeNewsSource.getRecentHeadlines("Route 2", 100); + + assertThat(result1).isEmpty(); + assertThat(result2).isEmpty(); + } +} diff --git a/src/test/java/com/shipping/freightops/news/impl/RssMaritimeNewsSourceTest.java b/src/test/java/com/shipping/freightops/news/impl/RssMaritimeNewsSourceTest.java new file mode 100644 index 0000000..486bab0 --- /dev/null +++ b/src/test/java/com/shipping/freightops/news/impl/RssMaritimeNewsSourceTest.java @@ -0,0 +1,225 @@ +package com.shipping.freightops.news.impl; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; +import static org.mockito.Mockito.lenient; + +import com.shipping.freightops.dto.MaritimeNewsArticle; +import com.shipping.freightops.news.config.NewsProperties; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClientException; + +@ExtendWith(MockitoExtension.class) +@SuppressWarnings({"unchecked", "rawtypes"}) +class RssMaritimeNewsSourceTest { + + @Mock private RestClient.Builder restClientBuilder; + @Mock private RestClient restClient; + @Mock private RestClient.RequestHeadersUriSpec requestSpec; + @Mock private RestClient.ResponseSpec responseSpec; + + private NewsProperties newsProperties; + private RssMaritimeNewsSource rssNewsSource; + + private static final String SAMPLE_RSS_XML = + """ + + + + Maritime News + + Red Sea Disruptions Force Major Shipping Lines to Reroute + Ongoing Houthi attacks in the Red Sea have forced major container lines to avoid the Suez Canal. + Mon, 01 Jan 2024 10:00:00 GMT + + + Shanghai Port Experiences Severe Congestion + Shanghai terminals report 3-5 day delays as export volumes surge. + Sun, 31 Dec 2023 15:30:00 GMT + + + + """; + + private static final String MALFORMED_RSS_XML = + """ + + + + Maritime News + + Incomplete Item + <description>Missing closing tags + """; + + @BeforeEach + void setUp() { + newsProperties = new NewsProperties(); + newsProperties.setFeeds(List.of("https://example.com/feed.xml")); + newsProperties.setMaxHeadlines(5); + + lenient().when(restClientBuilder.build()).thenReturn(restClient); + lenient().when(restClient.get()).thenReturn(requestSpec); + lenient().when(requestSpec.uri(anyString())).thenReturn(requestSpec); + lenient().when(requestSpec.retrieve()).thenReturn(responseSpec); + + rssNewsSource = new RssMaritimeNewsSource(restClientBuilder, newsProperties); + } + + @Test + @DisplayName("Successfully parses RSS feed with mock RestClient") + void testSuccessfulRssFeedParsing() { + // Given + when(responseSpec.body(String.class)).thenReturn(SAMPLE_RSS_XML); + + // When + List<MaritimeNewsArticle> articles = rssNewsSource.getRecentHeadlines("test-route", 10); + + // Then + assertNotNull(articles); + assertEquals(2, articles.size()); + + MaritimeNewsArticle firstArticle = articles.get(0); + assertEquals( + "Red Sea Disruptions Force Major Shipping Lines to Reroute", firstArticle.getHeadline()); + assertEquals("example.com", firstArticle.getSource()); + assertNotNull(firstArticle.getPublishedDate()); + assertTrue(firstArticle.getSummary().contains("Houthi attacks")); + + MaritimeNewsArticle secondArticle = articles.get(1); + assertEquals("Shanghai Port Experiences Severe Congestion", secondArticle.getHeadline()); + assertEquals("example.com", secondArticle.getSource()); + assertNotNull(secondArticle.getPublishedDate()); + assertTrue(secondArticle.getSummary().contains("3-5 day delays")); + } + + @Test + @DisplayName("Handles network failure gracefully with empty result") + void testNetworkFailureHandling() { + // Given + when(responseSpec.body(String.class)).thenThrow(new RestClientException("Network error")); + + // When + List<MaritimeNewsArticle> articles = rssNewsSource.getRecentHeadlines("test-route", 10); + + // Then + assertNotNull(articles); + assertTrue(articles.isEmpty()); + } + + @Test + @DisplayName("Handles malformed and empty RSS content gracefully") + void testMalformedAndEmptyRssHandling() { + // Test malformed RSS XML + when(responseSpec.body(String.class)).thenReturn(MALFORMED_RSS_XML); + List<MaritimeNewsArticle> articles = rssNewsSource.getRecentHeadlines("test-route", 10); + assertNotNull(articles); + assertTrue(articles.isEmpty()); + + // Test empty RSS content + when(responseSpec.body(String.class)).thenReturn(""); + articles = rssNewsSource.getRecentHeadlines("test-route", 10); + assertNotNull(articles); + assertTrue(articles.isEmpty()); + + // Test null RSS content + when(responseSpec.body(String.class)).thenReturn(null); + articles = rssNewsSource.getRecentHeadlines("test-route", 10); + assertNotNull(articles); + assertTrue(articles.isEmpty()); + } + + @Test + @DisplayName("Respects result limiting configuration") + void testResultLimiting() { + // Given + newsProperties.setMaxHeadlines(1); + when(responseSpec.body(String.class)).thenReturn(SAMPLE_RSS_XML); + + // When + List<MaritimeNewsArticle> articles = rssNewsSource.getRecentHeadlines("test-route", 10); + + // Then + assertNotNull(articles); + assertEquals(1, articles.size()); + assertEquals( + "Red Sea Disruptions Force Major Shipping Lines to Reroute", articles.get(0).getHeadline()); + } + + @Test + @DisplayName("Processes multiple feeds correctly") + void testMultipleFeedsProcessing() { + // Given + newsProperties.setFeeds(List.of("https://feed1.com/rss", "https://feed2.com/rss")); + when(responseSpec.body(String.class)).thenReturn(SAMPLE_RSS_XML); + + // When + List<MaritimeNewsArticle> articles = rssNewsSource.getRecentHeadlines("test-route", 10); + + // Then + assertNotNull(articles); + assertEquals(4, articles.size()); // 2 articles from each feed + + // Verify both feeds were called + verify(requestSpec, times(1)).uri("https://feed1.com/rss"); + verify(requestSpec, times(1)).uri("https://feed2.com/rss"); + } + + @Test + @DisplayName("Handles mixed feed scenario - one succeeds, one fails") + void testMixedFeedScenario() { + // Given + newsProperties.setFeeds(List.of("https://good-feed.com/rss", "https://bad-feed.com/rss")); + + when(requestSpec.uri("https://good-feed.com/rss")).thenReturn(requestSpec); + when(requestSpec.uri("https://bad-feed.com/rss")).thenReturn(requestSpec); + + // First call (good feed) returns valid RSS, second call (bad feed) throws exception + when(responseSpec.body(String.class)) + .thenReturn(SAMPLE_RSS_XML) + .thenThrow(new RestClientException("Network error")); + + // When + List<MaritimeNewsArticle> articles = rssNewsSource.getRecentHeadlines("test-route", 10); + + // Then + assertNotNull(articles); + assertEquals(2, articles.size()); // Only articles from successful feed + assertEquals( + "Red Sea Disruptions Force Major Shipping Lines to Reroute", articles.get(0).getHeadline()); + assertEquals("Shanghai Port Experiences Severe Congestion", articles.get(1).getHeadline()); + + // Verify both feeds were attempted + verify(requestSpec, times(1)).uri("https://good-feed.com/rss"); + verify(requestSpec, times(1)).uri("https://bad-feed.com/rss"); + } + + @Test + @DisplayName("Handles empty feeds configuration gracefully") + void testEmptyFeedsConfiguration() { + // Given - create a fresh instance with empty feeds to avoid unnecessary stubbing + NewsProperties emptyNewsProperties = new NewsProperties(); + emptyNewsProperties.setFeeds(List.of()); // No feeds configured + emptyNewsProperties.setMaxHeadlines(5); + + RssMaritimeNewsSource emptyFeedsSource = + new RssMaritimeNewsSource(restClientBuilder, emptyNewsProperties); + + // When + List<MaritimeNewsArticle> articles = emptyFeedsSource.getRecentHeadlines("test-route", 10); + + // Then + assertNotNull(articles); + assertTrue(articles.isEmpty()); + + // Verify no HTTP calls were made (no need to verify since no RestClient was built) + } +} diff --git a/src/test/java/com/shipping/freightops/news/impl/StaticMaritimeNewsSourceTest.java b/src/test/java/com/shipping/freightops/news/impl/StaticMaritimeNewsSourceTest.java new file mode 100644 index 0000000..8aa0d6b --- /dev/null +++ b/src/test/java/com/shipping/freightops/news/impl/StaticMaritimeNewsSourceTest.java @@ -0,0 +1,51 @@ +package com.shipping.freightops.news.impl; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.shipping.freightops.dto.MaritimeNewsArticle; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class StaticMaritimeNewsSourceTest { + + private StaticMaritimeNewsSource staticMaritimeNewsSource; + + @BeforeEach + void setUp() { + staticMaritimeNewsSource = new StaticMaritimeNewsSource(); + } + + @Test + void getRecentHeadlines_returnsStaticNews() { + List<MaritimeNewsArticle> result = staticMaritimeNewsSource.getRecentHeadlines("Any Route", 10); + + assertThat(result).isNotEmpty(); + assertThat(result).hasSize(10); + assertThat(result.get(0).getHeadline()).isNotBlank(); + assertThat(result.get(0).getSource()).isNotBlank(); + assertThat(result.get(0).getPublishedDate()).isNotNull(); + } + + @Test + void getRecentHeadlines_respectsMaxResults() { + List<MaritimeNewsArticle> result = staticMaritimeNewsSource.getRecentHeadlines("Any Route", 3); + + assertThat(result).hasSize(3); + } + + @Test + void getRecentHeadlines_handlesLargeMaxResults() { + List<MaritimeNewsArticle> result = + staticMaritimeNewsSource.getRecentHeadlines("Any Route", 1000); + + assertThat(result).hasSizeLessThanOrEqualTo(1000); + } + + @Test + void getRecentHeadlines_handlesZeroMaxResults() { + List<MaritimeNewsArticle> result = staticMaritimeNewsSource.getRecentHeadlines("Any Route", 0); + + assertThat(result).isEmpty(); + } +} diff --git a/src/test/java/com/shipping/freightops/schema/PriceSuggestionSchemaTest.java b/src/test/java/com/shipping/freightops/schema/PriceSuggestionSchemaTest.java index 333e473..4113cc1 100644 --- a/src/test/java/com/shipping/freightops/schema/PriceSuggestionSchemaTest.java +++ b/src/test/java/com/shipping/freightops/schema/PriceSuggestionSchemaTest.java @@ -22,18 +22,15 @@ class PriceSuggestionSchemaTest { @BeforeEach void setUp() throws Exception { - PriceSuggestionSchemaBuilder schemaBuilder = - new PriceSuggestionSchemaBuilder(new ObjectMapper()); - String schemaJson = schemaBuilder.build(); + ObjectMapper mapper = new ObjectMapper(); + PriceSuggestionSchemaBuilder priceSuggestionBuilder = new PriceSuggestionSchemaBuilder(mapper); + RiskFactorSchemaBuilder riskFactorBuilder = new RiskFactorSchemaBuilder(mapper); + CompositeSchemaBuilder compositeBuilder = + new CompositeSchemaBuilder(priceSuggestionBuilder, riskFactorBuilder, mapper); + String schemaJson = compositeBuilder.buildCompositeSchema(); JsonSchemaFactory factory = JsonSchemaFactory.getInstance(SCHEMA_VERSION); jsonSchema = factory.getSchema(schemaJson); - objectMapper = new ObjectMapper(); - } - - @Test - @DisplayName("Schema file is valid JSON Schema") - void schemaIsValid() { - assertTrue(jsonSchema != null); + objectMapper = mapper; } @Test @@ -78,74 +75,34 @@ void rejectsMissingReasoning() throws Exception { } @Test - @DisplayName("JSON with wrong type for confidence is rejected") - void rejectsWrongConfidenceType() throws Exception { - String invalidJson = - """ - { - "suggestedPriceLowUsd": 1100.00, - "suggestedPriceHighUsd": 1350.00, - "confidence": 123, - "reasoning": "Test", - "dataPoints": 12 - } - """; - JsonNode node = objectMapper.readTree(invalidJson); - Set<ValidationMessage> errors = jsonSchema.validate(node); - assertFalse(errors.isEmpty()); - } - - @Test - @DisplayName("JSON with invalid enum value for confidence is rejected") - void rejectsInvalidConfidenceEnum() throws Exception { - String invalidJson = - """ - { - "suggestedPriceLowUsd": 1100.00, - "suggestedPriceHighUsd": 1350.00, - "confidence": "INVALID", - "reasoning": "Test", - "dataPoints": 12 - } - """; - JsonNode node = objectMapper.readTree(invalidJson); - Set<ValidationMessage> errors = jsonSchema.validate(node); - assertFalse(errors.isEmpty()); - } - - @Test - @DisplayName("JSON with additional properties is rejected when additionalProperties is false") - void rejectsAdditionalProperties() throws Exception { - String invalidJson = + @DisplayName("Valid JSON with risk factors passes validation") + void acceptsValidJsonWithRiskFactors() throws Exception { + String validJsonWithRiskFactors = """ { "suggestedPriceLowUsd": 1100.00, "suggestedPriceHighUsd": 1350.00, "confidence": "MEDIUM", - "reasoning": "Test", + "reasoning": "Based on 12 past voyages with risk factors considered.", "dataPoints": 12, - "extraField": "not allowed" - } - """; - JsonNode node = objectMapper.readTree(invalidJson); - Set<ValidationMessage> errors = jsonSchema.validate(node); - assertFalse(errors.isEmpty()); - } - - @Test - @DisplayName("Minimal valid JSON with only required fields passes") - void acceptsMinimalValidJson() throws Exception { - String minimalJson = - """ - { - "suggestedPriceLowUsd": 1000.00, - "suggestedPriceHighUsd": 1200.00, - "confidence": "LOW", - "reasoning": "Insufficient data.", - "dataPoints": 0 + "historicalAvgUsd": 1180.00, + "historicalMinUsd": 950.00, + "historicalMaxUsd": 1400.00, + "riskFactors": [ + { + "factor": "Port congestion in Shanghai", + "impact": "HIGH", + "description": "Severe delays expected due to COVID-19 restrictions affecting port operations" + }, + { + "factor": "Fuel price volatility", + "impact": "MEDIUM", + "description": "Oil prices fluctuating due to geopolitical tensions" + } + ] } """; - JsonNode node = objectMapper.readTree(minimalJson); + JsonNode node = objectMapper.readTree(validJsonWithRiskFactors); Set<ValidationMessage> errors = jsonSchema.validate(node); assertTrue(errors.isEmpty(), "Expected no validation errors: " + errors); } diff --git a/src/test/java/com/shipping/freightops/schema/RiskFactorSchemaTest.java b/src/test/java/com/shipping/freightops/schema/RiskFactorSchemaTest.java new file mode 100644 index 0000000..b7ca338 --- /dev/null +++ b/src/test/java/com/shipping/freightops/schema/RiskFactorSchemaTest.java @@ -0,0 +1,67 @@ +package com.shipping.freightops.schema; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.networknt.schema.JsonSchema; +import com.networknt.schema.JsonSchemaFactory; +import com.networknt.schema.SpecVersion; +import com.networknt.schema.ValidationMessage; +import java.util.Set; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class RiskFactorSchemaTest { + + private JsonSchema jsonSchema; + private ObjectMapper objectMapper; + private static final SpecVersion.VersionFlag SCHEMA_VERSION = SpecVersion.VersionFlag.V202012; + + @BeforeEach + void setUp() throws Exception { + RiskFactorSchemaBuilder schemaBuilder = new RiskFactorSchemaBuilder(new ObjectMapper()); + String schemaJson = schemaBuilder.build(); + JsonSchemaFactory factory = JsonSchemaFactory.getInstance(SCHEMA_VERSION); + jsonSchema = factory.getSchema(schemaJson); + objectMapper = new ObjectMapper(); + } + + @Test + @DisplayName("Valid risk factor with all required fields passes validation") + void acceptsValidRiskFactor() throws Exception { + String validRiskFactor = + """ + { + "factor": "Port congestion in Shanghai", + "impact": "HIGH", + "description": "Severe delays expected due to COVID-19 restrictions affecting port operations" + } + """; + JsonNode node = objectMapper.readTree(validRiskFactor); + Set<ValidationMessage> errors = jsonSchema.validate(node); + assertTrue(errors.isEmpty(), "Expected no validation errors: " + errors); + } + + @Test + @DisplayName("Risk factor with invalid impact enum is rejected") + void rejectsInvalidImpactEnum() throws Exception { + String invalidRiskFactor = + """ + { + "factor": "Test factor", + "impact": "INVALID_IMPACT", + "description": "Test description" + } + """; + JsonNode node = objectMapper.readTree(invalidRiskFactor); + Set<ValidationMessage> errors = jsonSchema.validate(node); + assertFalse(errors.isEmpty()); + assertTrue( + errors.stream() + .anyMatch(e -> e.getMessage().contains("does not have a value in the enumeration")), + "Expected error about invalid impact enum: " + errors); + } +} diff --git a/src/test/java/com/shipping/freightops/service/PriceSuggestionServiceTest.java b/src/test/java/com/shipping/freightops/service/PriceSuggestionServiceTest.java new file mode 100644 index 0000000..a998931 --- /dev/null +++ b/src/test/java/com/shipping/freightops/service/PriceSuggestionServiceTest.java @@ -0,0 +1,355 @@ +package com.shipping.freightops.service; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.shipping.freightops.ai.AiClient; +import com.shipping.freightops.dto.PriceSuggestionResponse; +import com.shipping.freightops.dto.RiskFactor; +import com.shipping.freightops.entity.Port; +import com.shipping.freightops.entity.Voyage; +import com.shipping.freightops.entity.VoyagePrice; +import com.shipping.freightops.enums.ContainerSize; +import com.shipping.freightops.enums.RiskImpact; +import com.shipping.freightops.repository.FreightOrderRepository; +import com.shipping.freightops.repository.PortRepository; +import com.shipping.freightops.repository.VoyagePriceRepository; +import com.shipping.freightops.repository.VoyageRepository; +import com.shipping.freightops.schema.CompositeSchemaBuilder; +import java.io.IOException; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.data.domain.PageRequest; + +class PriceSuggestionServiceTest { + + private static class TestRiskAnalysisService extends RiskAnalysisService { + private String mockNewsContext = ""; + private List<RiskFactor> mockRiskFactors = new ArrayList<>(); + + public TestRiskAnalysisService() { + super(null, null); // We won't use the parent functionality + } + + public void setMockNewsContext(String context) { + this.mockNewsContext = context; + } + + public void setMockRiskFactors(List<RiskFactor> riskFactors) { + this.mockRiskFactors = riskFactors; + } + + @Override + public String fetchAndBuildNewsContext(String route) { + return mockNewsContext; + } + + @Override + public List<RiskFactor> parseRiskFactors(JsonNode parsed) { + return new ArrayList<>(mockRiskFactors); + } + } + + private static class TestCompositeSchemaBuilder extends CompositeSchemaBuilder { + public TestCompositeSchemaBuilder() { + super(null, null, null); // We won't use the parent functionality + } + + @Override + public String buildCompositeSchema() throws JsonProcessingException, IOException { + return "{}"; // Simple empty schema for testing + } + } + + private VoyageRepository voyageRepository; + private VoyagePriceRepository voyagePriceRepository; + private FreightOrderRepository freightOrderRepository; + private PortRepository portRepository; + private AiClient aiClient; + + private PriceSuggestionService priceSuggestionService; + private ObjectMapper objectMapper; + private TestRiskAnalysisService testRiskAnalysisService; + private TestCompositeSchemaBuilder testCompositeSchemaBuilder; + + @BeforeEach + void setUp() throws Exception { + voyageRepository = mock(VoyageRepository.class); + voyagePriceRepository = mock(VoyagePriceRepository.class); + freightOrderRepository = mock(FreightOrderRepository.class); + portRepository = mock(PortRepository.class); + aiClient = mock(AiClient.class); + + objectMapper = new ObjectMapper(); + testRiskAnalysisService = new TestRiskAnalysisService(); + testCompositeSchemaBuilder = new TestCompositeSchemaBuilder(); + + priceSuggestionService = + new PriceSuggestionService( + voyageRepository, + voyagePriceRepository, + freightOrderRepository, + portRepository, + aiClient, + objectMapper, + testRiskAnalysisService, + testCompositeSchemaBuilder); + } + + @Test + @DisplayName("End-to-end price suggestion with news integration") + void testPriceSuggestionWithNewsIntegration() throws Exception { + // Given + Long voyageId = 1L; + ContainerSize containerSize = ContainerSize.TWENTY_FOOT; + + // Mock voyage + Voyage voyage = createMockVoyage(); + when(voyageRepository.findById(voyageId)).thenReturn(Optional.of(voyage)); + + // Mock historical prices + VoyagePrice historicalPrice = createMockVoyagePrice(); + when(voyagePriceRepository.findHistoricalPricesSameRoute( + anyLong(), anyLong(), anyLong(), eq(containerSize), any(PageRequest.class))) + .thenReturn(List.of(historicalPrice)); + + List<Object[]> mockCountResult = Collections.singletonList(new Object[] {1L, 5L}); + when(freightOrderRepository.countByVoyageIds(anyList())) + .thenReturn(mockCountResult); // voyageId=1L, count=5 + + // Setup test news context + testRiskAnalysisService.setMockNewsContext( + "Recent relevant news:\n- Port congestion in Shanghai\n\n"); + + // Mock AI response with risk factors + String aiResponse = + """ + { + "suggestedPriceLowUsd": 1200.00, + "suggestedPriceHighUsd": 1400.00, + "confidence": "MEDIUM", + "reasoning": "Based on historical data and current news", + "dataPoints": 5, + "riskFactors": [ + { + "factor": "Port congestion", + "impact": "HIGH", + "description": "Delays expected" + } + ] + } + """; + when(aiClient.completeWithSchema(anyString(), anyString(), anyString())).thenReturn(aiResponse); + + // Mock risk factor parsing + RiskFactor riskFactor = new RiskFactor(); + riskFactor.setFactor("Port congestion"); + riskFactor.setImpact(RiskImpact.HIGH); + riskFactor.setDescription("Delays expected"); + testRiskAnalysisService.setMockRiskFactors(List.of(riskFactor)); + + // When + PriceSuggestionResponse response = + priceSuggestionService.getPriceSuggestion(voyageId, containerSize); + + // Then + assertNotNull(response); + assertEquals(0, new BigDecimal("1200.00").compareTo(response.getSuggestedPriceLowUsd())); + assertEquals(0, new BigDecimal("1400.00").compareTo(response.getSuggestedPriceHighUsd())); + assertNotNull(response.getRiskFactors()); + assertEquals(1, response.getRiskFactors().size()); + assertEquals("Port congestion", response.getRiskFactors().get(0).getFactor()); + assertEquals(RiskImpact.HIGH, response.getRiskFactors().get(0).getImpact()); + } + + @Test + @DisplayName("Fallback behavior when risk analysis fails") + void testFallbackWhenRiskAnalysisFails() throws Exception { + // Given + Long voyageId = 1L; + ContainerSize containerSize = ContainerSize.TWENTY_FOOT; + + // Mock voyage + Voyage voyage = createMockVoyage(); + when(voyageRepository.findById(voyageId)).thenReturn(Optional.of(voyage)); + + // Mock historical prices + VoyagePrice historicalPrice = createMockVoyagePrice(); + when(voyagePriceRepository.findHistoricalPricesSameRoute( + anyLong(), anyLong(), anyLong(), eq(containerSize), any(PageRequest.class))) + .thenReturn(List.of(historicalPrice)); + + List<Object[]> mockCountResult = Collections.singletonList(new Object[] {1L, 5L}); + when(freightOrderRepository.countByVoyageIds(anyList())) + .thenReturn(mockCountResult); // voyageId=1L, count=5 + + // Setup empty news context (simulating failure) + testRiskAnalysisService.setMockNewsContext(""); // Empty context (news service failed) + + // Mock AI response without risk factors + String aiResponse = + """ + { + "suggestedPriceLowUsd": 1200.00, + "suggestedPriceHighUsd": 1400.00, + "confidence": "MEDIUM", + "reasoning": "Based on historical data only", + "dataPoints": 5 + } + """; + when(aiClient.completeWithSchema(anyString(), anyString(), anyString())).thenReturn(aiResponse); + + testRiskAnalysisService.setMockRiskFactors(List.of()); + + // When + PriceSuggestionResponse response = + priceSuggestionService.getPriceSuggestion(voyageId, containerSize); + + // Then + assertNotNull(response); + assertEquals(0, new BigDecimal("1200.00").compareTo(response.getSuggestedPriceLowUsd())); + assertEquals(0, new BigDecimal("1400.00").compareTo(response.getSuggestedPriceHighUsd())); + assertNotNull(response.getRiskFactors()); + assertTrue(response.getRiskFactors().isEmpty()); + } + + @Test + @DisplayName("Response includes risk factors when news is available") + void testResponseIncludesRiskFactorsWhenNewsAvailable() throws Exception { + // Given + Long voyageId = 1L; + ContainerSize containerSize = ContainerSize.TWENTY_FOOT; + + // Mock voyage + Voyage voyage = createMockVoyage(); + when(voyageRepository.findById(voyageId)).thenReturn(Optional.of(voyage)); + + // Mock historical prices + VoyagePrice historicalPrice = createMockVoyagePrice(); + when(voyagePriceRepository.findHistoricalPricesSameRoute( + anyLong(), anyLong(), anyLong(), eq(containerSize), any(PageRequest.class))) + .thenReturn(List.of(historicalPrice)); + + List<Object[]> mockCountResult = Collections.singletonList(new Object[] {1L, 5L}); + when(freightOrderRepository.countByVoyageIds(anyList())) + .thenReturn(mockCountResult); // voyageId=1L, count=5 + + String newsContext = + """ + Recent relevant news: + - Red Sea disruptions affecting shipping routes + - Shanghai port experiencing congestion + + """; + testRiskAnalysisService.setMockNewsContext(newsContext); + + // Mock AI response + String aiResponse = + """ + { + "suggestedPriceLowUsd": 1300.00, + "suggestedPriceHighUsd": 1600.00, + "confidence": "HIGH", + "reasoning": "Historical data shows increased prices due to current disruptions", + "dataPoints": 8, + "riskFactors": [ + { + "factor": "Red Sea disruptions", + "impact": "HIGH", + "description": "Alternative routes increase transit time and fuel costs" + }, + { + "factor": "Port congestion", + "impact": "MEDIUM", + "description": "Delays at Shanghai port affecting schedule reliability" + } + ] + } + """; + when(aiClient.completeWithSchema(anyString(), anyString(), anyString())).thenReturn(aiResponse); + + // Mock risk factor parsing + RiskFactor riskFactor1 = new RiskFactor(); + riskFactor1.setFactor("Red Sea disruptions"); + riskFactor1.setImpact(RiskImpact.HIGH); + riskFactor1.setDescription("Alternative routes increase transit time and fuel costs"); + + RiskFactor riskFactor2 = new RiskFactor(); + riskFactor2.setFactor("Port congestion"); + riskFactor2.setImpact(RiskImpact.MEDIUM); + riskFactor2.setDescription("Delays at Shanghai port affecting schedule reliability"); + + testRiskAnalysisService.setMockRiskFactors(List.of(riskFactor1, riskFactor2)); + + // When + PriceSuggestionResponse response = + priceSuggestionService.getPriceSuggestion(voyageId, containerSize); + + // Then + assertNotNull(response); + assertNotNull(response.getRiskFactors()); + assertEquals(2, response.getRiskFactors().size()); + + // Verify first risk factor + RiskFactor firstRisk = response.getRiskFactors().get(0); + assertEquals("Red Sea disruptions", firstRisk.getFactor()); + assertEquals(RiskImpact.HIGH, firstRisk.getImpact()); + assertTrue(firstRisk.getDescription().contains("Alternative routes")); + + // Verify second risk factor + RiskFactor secondRisk = response.getRiskFactors().get(1); + assertEquals("Port congestion", secondRisk.getFactor()); + assertEquals(RiskImpact.MEDIUM, secondRisk.getImpact()); + assertTrue(secondRisk.getDescription().contains("Shanghai port")); + + // Verify news integration was properly invoked + verify(aiClient).completeWithSchema(anyString(), contains(newsContext), anyString()); + } + + private Voyage createMockVoyage() { + Voyage voyage = new Voyage(); + voyage.setId(1L); + voyage.setVoyageNumber("TEST001"); + + Port departurePort = new Port(); + departurePort.setId(1L); + departurePort.setName("Shanghai"); + departurePort.setUnlocode("CNSHA"); + + Port arrivalPort = new Port(); + arrivalPort.setId(2L); + arrivalPort.setName("Rotterdam"); + arrivalPort.setUnlocode("NLRTM"); + + voyage.setDeparturePort(departurePort); + voyage.setArrivalPort(arrivalPort); + voyage.setDepartureTime(LocalDateTime.now().plusDays(7)); + voyage.setArrivalTime(LocalDateTime.now().plusDays(21)); + + return voyage; + } + + private VoyagePrice createMockVoyagePrice() { + VoyagePrice voyagePrice = new VoyagePrice(); + voyagePrice.setId(1L); + voyagePrice.setContainerSize(ContainerSize.TWENTY_FOOT); + voyagePrice.setBasePriceUsd(new BigDecimal("1250.00")); + + // Create and set the associated voyage + Voyage associatedVoyage = createMockVoyage(); + voyagePrice.setVoyage(associatedVoyage); + + return voyagePrice; + } +} diff --git a/src/test/java/com/shipping/freightops/service/RiskAnalysisServiceTest.java b/src/test/java/com/shipping/freightops/service/RiskAnalysisServiceTest.java new file mode 100644 index 0000000..ab41f4a --- /dev/null +++ b/src/test/java/com/shipping/freightops/service/RiskAnalysisServiceTest.java @@ -0,0 +1,209 @@ +package com.shipping.freightops.service; + +import static org.junit.jupiter.api.Assertions.*; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.shipping.freightops.dto.MaritimeNewsArticle; +import com.shipping.freightops.dto.RiskFactor; +import com.shipping.freightops.enums.RiskImpact; +import com.shipping.freightops.news.config.NewsProperties; +import java.time.LocalDate; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class RiskAnalysisServiceTest { + + private RiskAnalysisService riskAnalysisService; + private ObjectMapper objectMapper; + + @BeforeEach + void setUp() { + // Create a simple test instance focusing only on standalone methods + // We'll test parseRiskFactors and buildNewsContext which don't need complex dependencies + objectMapper = new ObjectMapper(); + + // Create minimal dependencies for the service + NewsProperties newsProperties = new NewsProperties(); + newsProperties.setMaxHeadlines(5); + + // Use null for ShippingNewsAnalyzer since we won't test methods that use it + riskAnalysisService = new RiskAnalysisService(null, newsProperties); + } + + @Test + @DisplayName("parseRiskFactors handles valid JSON array correctly") + void testParseRiskFactorsWithValidJson() throws Exception { + // Given + String validJson = + """ + { + "riskFactors": [ + { + "factor": "Port congestion in Shanghai", + "impact": "HIGH", + "description": "Severe delays expected due to restrictions" + }, + { + "factor": "Fuel price volatility", + "impact": "MEDIUM", + "description": "Oil prices fluctuating" + } + ] + } + """; + JsonNode parsed = objectMapper.readTree(validJson); + + // When + List<RiskFactor> riskFactors = riskAnalysisService.parseRiskFactors(parsed); + + // Then + assertEquals(2, riskFactors.size()); + + RiskFactor firstRisk = riskFactors.get(0); + assertEquals("Port congestion in Shanghai", firstRisk.getFactor()); + assertEquals(RiskImpact.HIGH, firstRisk.getImpact()); + assertEquals("Severe delays expected due to restrictions", firstRisk.getDescription()); + + RiskFactor secondRisk = riskFactors.get(1); + assertEquals("Fuel price volatility", secondRisk.getFactor()); + assertEquals(RiskImpact.MEDIUM, secondRisk.getImpact()); + assertEquals("Oil prices fluctuating", secondRisk.getDescription()); + } + + @Test + @DisplayName("parseRiskFactors handles malformed and null input gracefully") + void testParseRiskFactorsWithMalformedInput() throws Exception { + // Test with non-array riskFactors + String nonArrayJson = + """ + { + "riskFactors": "not an array" + } + """; + JsonNode parsed = objectMapper.readTree(nonArrayJson); + List<RiskFactor> result = riskAnalysisService.parseRiskFactors(parsed); + assertTrue(result.isEmpty()); + + // Test with missing riskFactors field + String missingFieldJson = + """ + { + "otherField": "value" + } + """; + parsed = objectMapper.readTree(missingFieldJson); + result = riskAnalysisService.parseRiskFactors(parsed); + assertTrue(result.isEmpty()); + + // Test with empty factor (should be skipped) + String emptyFactorJson = + """ + { + "riskFactors": [ + { + "factor": "", + "impact": "HIGH", + "description": "Should be skipped" + }, + { + "factor": "Valid factor", + "impact": "LOW", + "description": "Should be included" + } + ] + } + """; + parsed = objectMapper.readTree(emptyFactorJson); + result = riskAnalysisService.parseRiskFactors(parsed); + assertEquals(1, result.size()); + assertEquals("Valid factor", result.get(0).getFactor()); + + // Test with invalid impact (should default to LOW) + String invalidImpactJson = + """ + { + "riskFactors": [ + { + "factor": "Test factor", + "impact": "INVALID_IMPACT", + "description": "Test description" + } + ] + } + """; + parsed = objectMapper.readTree(invalidImpactJson); + result = riskAnalysisService.parseRiskFactors(parsed); + assertEquals(1, result.size()); + assertEquals(RiskImpact.LOW, result.get(0).getImpact()); + } + + @Test + @DisplayName("parseRiskFactors handles empty and missing fields gracefully") + void testParseRiskFactorsEdgeCases() throws Exception { + // Test with missing description field + String missingDescJson = + """ + { + "riskFactors": [ + { + "factor": "Test factor", + "impact": "HIGH" + } + ] + } + """; + JsonNode parsed = objectMapper.readTree(missingDescJson); + List<RiskFactor> result = riskAnalysisService.parseRiskFactors(parsed); + assertEquals(1, result.size()); + assertEquals("Test factor", result.get(0).getFactor()); + assertEquals(RiskImpact.HIGH, result.get(0).getImpact()); + assertNull(result.get(0).getDescription()); // Should be null when missing + + // Test with missing impact field (should default to LOW) + String missingImpactJson = + """ + { + "riskFactors": [ + { + "factor": "Another test factor" + } + ] + } + """; + parsed = objectMapper.readTree(missingImpactJson); + result = riskAnalysisService.parseRiskFactors(parsed); + assertEquals(1, result.size()); + assertEquals(RiskImpact.LOW, result.get(0).getImpact()); + } + + @Test + @DisplayName("buildNewsContext handles empty news and returns empty string") + void testBuildNewsContextWithEmptyNews() { + // Test with null news + String result = riskAnalysisService.buildNewsContext(null); + assertEquals("", result); + + // Test with empty list + result = riskAnalysisService.buildNewsContext(List.of()); + assertEquals("", result); + + // Test with non-empty news + MaritimeNewsArticle article1 = + new MaritimeNewsArticle( + "Port congestion reported", "Maritime News", LocalDate.now(), "Delays expected"); + MaritimeNewsArticle article2 = + new MaritimeNewsArticle("Fuel prices rising", "Shipping Today", LocalDate.now(), null); + + result = riskAnalysisService.buildNewsContext(List.of(article1, article2)); + + assertNotNull(result); + assertTrue(result.contains("Recent relevant news:")); + assertTrue(result.contains("Port congestion reported")); + assertTrue(result.contains("(Delays expected)")); + assertTrue(result.contains("Fuel prices rising")); + assertFalse(result.contains("(null)")); + } +} diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties index f3dbc33..868f296 100644 --- a/src/test/resources/application.properties +++ b/src/test/resources/application.properties @@ -14,4 +14,6 @@ app.ai.provider=noop app.ai.connect-timeout=30 app.ai.test.enabled=true # Base url -app.base-url=http://localhost:8080 \ No newline at end of file +app.base-url=http://localhost:8080 +# News provider +app.news.provider=noop \ No newline at end of file