Skip to content

Commit

Permalink
fix(FTM): avoid executing ftm logic for immediate publish date in liv…
Browse files Browse the repository at this point in the history
…e mode #31442 (#31448)

This pull request includes changes to the `PageResource` class and
updates to the Future Time Machine (FTM) tests. The most important
changes include importing additional utility classes, adding a new
method to handle the FTM grace window logic, and updating the test
scenarios to validate the new logic.

### Enhancements to Time Machine Functionality:

*
[`dotCMS/src/main/java/com/dotcms/rest/api/v1/page/PageResource.java`](diffhunk://#diff-225a703de3d295e7f6f361bd33455f27f09021c58403adbda9569399eef95da0R29):
Added `TimeMachineUtil` to handle time machine date parsing and
validation.
*
[`dotCMS/src/main/java/com/dotcms/util/TimeMachineUtil.java`](diffhunk://#diff-d8f93700798e41883a5646a31a10a707d7832eaf17a8419196d5743eefd585e7R6-R23):
Introduced methods `parseTimeMachineDate` and `isOlderThanGraceWindow`
to validate and parse ISO 8601 date strings, ensuring dates are older
than a configurable grace window.

### Test Coverage Improvements:

*
[`dotcms-integration/src/test/java/com/dotcms/util/TimeMachineUtilTest.java`](diffhunk://#diff-9d38338bff66501965ab2d3ce3abad7acd7a88b962384e9e17e1a5898d6743a9R4-R28):
Added test cases for `parseTimeMachineDate` and `isOlderThanGraceWindow`
methods, covering scenarios with null, invalid, valid within grace
window, and valid outside grace window dates.
### Test Scenario Updates:

*
[`test-karate/src/test/java/graphql/ftm/setup.feature`](diffhunk://#diff-37f0b51013b66c2d7cf33fbd083df1c5ef5e71f805299725e33f0bd1de57b97cL41-R63):
Updated the setup to include a new content piece and its version within
the grace window. This ensures the test environment is correctly
configured for the new Time Machine scenarios.
*
[`test-karate/src/test/java/tests/graphql/ftm/CheckingTimeMachine.feature`](diffhunk://#diff-644e56e9ca92e212f8d0926f9788c1c35088eea4e1a6b2db75d6be1060523692R8-R10):
Added a new test scenario to verify that content with a publish date
within the grace window is not displayed by the Time Machine
functionality.
  • Loading branch information
valentinogiardino authored Feb 25, 2025
1 parent df7c06c commit 9d69c74
Show file tree
Hide file tree
Showing 6 changed files with 190 additions and 29 deletions.
27 changes: 8 additions & 19 deletions dotCMS/src/main/java/com/dotcms/rest/api/v1/page/PageResource.java
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
import com.dotcms.util.ConversionUtils;
import com.dotcms.util.HttpRequestDataUtil;
import com.dotcms.util.PaginationUtil;
import com.dotcms.util.TimeMachineUtil;
import com.dotcms.util.pagination.ContentTypesPaginator;
import com.dotcms.util.pagination.OrderDirection;
import com.dotcms.vanityurl.business.VanityUrlAPI;
Expand Down Expand Up @@ -61,12 +62,7 @@
import com.dotmarketing.portlets.languagesmanager.model.Language;
import com.dotmarketing.portlets.templates.model.Template;
import com.dotmarketing.portlets.workflows.model.WorkflowAction;
import com.dotmarketing.util.DateUtil;
import com.dotmarketing.util.Logger;
import com.dotmarketing.util.PageMode;
import com.dotmarketing.util.StringUtils;
import com.dotmarketing.util.UtilMethods;
import com.dotmarketing.util.WebKeys;
import com.dotmarketing.util.*;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
Expand Down Expand Up @@ -355,19 +351,12 @@ private PageRenderParams optionalRenderParams(final String modeParam,
if (null != deviceInode){
builder.deviceInode(deviceInode);
}
if (null != timeMachineDateAsISO8601) {
final Date date;
try {
date = Try.of(() -> DateUtil.convertDate(timeMachineDateAsISO8601)).getOrElseThrow(
e -> new IllegalArgumentException(
String.format("Error Parsing date: %s", timeMachineDateAsISO8601),
e));
} catch (IllegalArgumentException e) {
throw new RuntimeException(e);
}
final Instant instant = date.toInstant();
builder.timeMachineDate(instant);
}
TimeMachineUtil.parseTimeMachineDate(timeMachineDateAsISO8601).ifPresentOrElse(
builder::timeMachineDate,
() -> Logger.debug(this, () -> String.format(
"Date %s is not older than the grace window. Skipping Time Machine setup.",
timeMachineDateAsISO8601))
);
return builder.build();
}

Expand Down
48 changes: 47 additions & 1 deletion dotCMS/src/main/java/com/dotcms/util/TimeMachineUtil.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,24 @@
import com.dotcms.api.web.HttpServletRequestThreadLocal;

import com.dotcms.rest.api.v1.page.PageResource;
import com.dotmarketing.util.Config;
import com.dotmarketing.util.DateUtil;
import io.vavr.Lazy;
import io.vavr.control.Try;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
import java.time.Duration;
import java.time.Instant;
import java.util.Date;
import java.util.Objects;
import java.util.Optional;

public final class TimeMachineUtil {

private TimeMachineUtil(){}

private static final Lazy<Integer> FTM_GRACE_WINDOW_LIMIT =
Lazy.of(() -> Config.getIntProperty("FTM_GRACE_WINDOW_LIMIT", 5));
/**
* If Time Machine is running return the timestamp of the Time Machine date
* Running Time Machine is determined by the presence of the attribute PageResource.TM_DATE in the request or session
Expand Down Expand Up @@ -46,4 +56,40 @@ public static boolean isRunning(){
public static boolean isNotRunning(){
return !isRunning();
}

/**
* Parses and validates the given date string in ISO 8601 format.
*
* @param dateAsISO8601 The date string in ISO 8601 format. If null, an empty {@link Optional} is returned.
* @return An {@link Optional} containing a valid {@link Instant} if parsing is successful and the date meets the validation criteria.
* Returns an empty {@link Optional} if the date is invalid or does not meet the validation criteria.
* @throws IllegalArgumentException If the date string cannot be parsed.
*/
public static Optional<Instant> parseTimeMachineDate(final String dateAsISO8601) {
if (Objects.isNull(dateAsISO8601)) {
return Optional.empty();
}
Instant instant = Try.of(() -> DateUtil.convertDate(dateAsISO8601))
.map(Date::toInstant)
.getOrElseThrow(e ->
new IllegalArgumentException(
String.format("Error Parsing date: %s", dateAsISO8601), e)
);
return isOlderThanGraceWindow(instant) ? Optional.of(instant) : Optional.empty();
}


/**
* Determines if the FTM logic should be applied based on the given timeMachineDate.
* It checks if the date is older than the grace window (not too recent),
* using a configurable time limit.
*
* @param timeMachineDate The Time Machine date from the request.
* @return true if the timeMachineDate is older than the grace window, meaning FTM logic should be applied,
* false otherwise (if within the grace window).
*/
public static boolean isOlderThanGraceWindow(final Instant timeMachineDate) {
final Instant graceWindowTime = Instant.now().plus(Duration.ofMinutes(FTM_GRACE_WINDOW_LIMIT.get()));
return timeMachineDate.isAfter(graceWindowTime);
}
}
Original file line number Diff line number Diff line change
@@ -1,25 +1,31 @@
package com.dotcms.util;

import com.dotcms.api.web.HttpServletRequestThreadLocal;
import com.dotmarketing.util.DateUtil;
import org.junit.BeforeClass;
import org.junit.Test;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;

import java.text.ParseException;
import java.time.Duration;
import java.time.Instant;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.Date;
import java.util.Optional;

import static org.junit.Assert.*;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.*;

public class TimeMachineUtilTest {

@BeforeClass
public static void prepare() throws Exception {
//Setting web app environment
IntegrationTestInitService.getInstance().init();
mockStatic(DateUtil.class);
}

/**
Expand Down Expand Up @@ -95,4 +101,91 @@ public void whenTimeMachineIsNotRunningShouldReturnTrue(){

assertTrue(TimeMachineUtil.isRunning());
}
}

/**
* Method to Test: {@link TimeMachineUtil#parseTimeMachineDate(String)}
* When: When the input date is null
* Should: Return an empty {@link Optional}
*/
@Test
public void testParseTimeMachineDate_NullDate() {
Optional<Instant> result = TimeMachineUtil.parseTimeMachineDate(null);
assertFalse(result.isPresent());
}

/**
* Method to Test: {@link TimeMachineUtil#parseTimeMachineDate(String)}
* When: When the input date is invalid
* Should: Throw an {@link IllegalArgumentException}
*/
@Test(expected = IllegalArgumentException.class)
public void testParseTimeMachineDate_InvalidDate() {
TimeMachineUtil.parseTimeMachineDate("invalid-date");
}

/**
* Method to Test: {@link TimeMachineUtil#parseTimeMachineDate(String)}
* When: When the input date is valid and within the grace window
* Should: Return an empty {@link Optional}
*/
@Test
public void testParseTimeMachineDate_ValidDateWithinGraceWindow() throws ParseException {
Instant now = Instant.now().plus(Duration.ofMinutes(3));

// Format the Instant to a string that DateUtil.convertDate can parse
DateTimeFormatter formatter = DateTimeFormatter.ISO_INSTANT;
String dateWithinGraceWindow = formatter.format(now);

// Convert Instant to Date
Date dateObject = Date.from(now);
when(DateUtil.convertDate(dateWithinGraceWindow)).thenReturn(dateObject);

Optional<Instant> result = TimeMachineUtil.parseTimeMachineDate(dateWithinGraceWindow);
assertTrue(result.isEmpty());
}

/**
* Method to Test: {@link TimeMachineUtil#parseTimeMachineDate(String)}
* When: When the input date is valid and outside the grace window
* Should: Return a present {@link Optional} with the parsed date
*/
@Test
public void testParseTimeMachineDate_ValidDateOutsideGraceWindow() throws ParseException {
Instant now = Instant.now().plus(Duration.ofMinutes(10));

// Format the Instant to a string that DateUtil.convertDate can parse
DateTimeFormatter formatter = DateTimeFormatter.ISO_INSTANT;
String dateWithinGraceWindow = formatter.format(now);

Date dateObject = Date.from(now);
when(DateUtil.convertDate(dateWithinGraceWindow)).thenReturn(dateObject);

Optional<Instant> result = TimeMachineUtil.parseTimeMachineDate(dateWithinGraceWindow);
assertTrue(result.isPresent());
assertEquals(now.truncatedTo(ChronoUnit.SECONDS), result.get().truncatedTo(ChronoUnit.SECONDS));
}

/**
* Method to Test: {@link TimeMachineUtil#isOlderThanGraceWindow(Instant)}
* When: When the input date is within the grace window
* Should: Return false
*/
@Test
public void testIsOlderThanGraceWindow_shouldReturnTrue() {
Instant now = Instant.now();
Instant futureDate= now.plus(Duration.ofMinutes(5)); // 5 minutes in the future
assertFalse(TimeMachineUtil.isOlderThanGraceWindow(futureDate));
}

/**
* Method to Test: {@link TimeMachineUtil#isOlderThanGraceWindow(Instant)}
* When: When the input date is outside the grace window
* Should: Return true
*/
@Test
public void testIsOlderThanGraceWindow_shouldReturnFalse() {
Instant now = Instant.now();
Instant futureDate = now.plus(Duration.ofMinutes(6)); // 6 minutes in the future
assertTrue(TimeMachineUtil.isOlderThanGraceWindow(futureDate));
}
}
2 changes: 1 addition & 1 deletion test-karate/src/test/java/graphql/ftm/newVTL.feature
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
Feature: Create a Page
Feature: Upload FileAsset
Background:

* def fileName = __arg.fileName
Expand Down
16 changes: 12 additions & 4 deletions test-karate/src/test/java/graphql/ftm/setup.feature
Original file line number Diff line number Diff line change
Expand Up @@ -38,21 +38,29 @@ Feature: Setting up the Future Time Machine Test
* def contentPieceTwoId = contentPieceTwo.map(result => Object.keys(result)[0])
* def contentPieceTwoId = contentPieceTwoId[0]

# Create a couple of new pieces of content
* def createContentPieceThreeResult = callonce read('classpath:graphql/ftm/newContent.feature') { contentTypeId: '#(contentTypeId)', title: 'test 3' }
* def contentPieceThree = createContentPieceThreeResult.response.entity.results
* def contentPieceThreeId = contentPieceThree.map(result => Object.keys(result)[0])
* def contentPieceThreeId = contentPieceThreeId[0]

* def createBannerContentPieceOneResult = callonce read('classpath:graphql/ftm/newContent.feature') { contentTypeId: '#(bannerContentTypeId)', title: 'banner 1'}
* def bannerContentPieceOne = createBannerContentPieceOneResult.response.entity.results
* def bannerContentPieceOneId = bannerContentPieceOne.map(result => Object.keys(result)[0])
* def bannerContentPieceOneId = bannerContentPieceOneId[0]

# Now lets create a new version for each piece of content
* def formatter = java.time.format.DateTimeFormatter.ofPattern('yyyy-MM-dd')
* def now = java.time.LocalDateTime.now()
* def formatter = java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'")
* def now = java.time.ZonedDateTime.now(java.time.ZoneOffset.UTC)
* def formattedCurrentDateTime = now.format(formatter)
* def futureDateTime = now.plusDays(10)
* def formattedFutureDateTime = futureDateTime.format(formatter)
* def futureDateTimeInGraceWindow = now.plusMinutes(4)
* def formattedFutureDateTimeInGraceWindow = futureDateTimeInGraceWindow.format(formatter)
* karate.log('formattedFutureDateTimeInGraceWindow:', formattedFutureDateTimeInGraceWindow)

* def newContentPiceOneVersion2 = callonce read('classpath:graphql/ftm/newContentVersion.feature') { contentTypeId: '#(contentTypeId)', identifier: '#(contentPieceOneId)', title: 'test 1 v2 (This ver will be publshed in the future)', publishDate: '#(formattedFutureDateTime)' }
* def newContentPiceTwoVersion2 = callonce read('classpath:graphql/ftm/newContentVersion.feature') { contentTypeId: '#(contentTypeId)', identifier: '#(contentPieceTwoId)', title: 'test 2 v2' }
* def newContentPiceThreeVersion2 = callonce read('classpath:graphql/ftm/newContentVersion.feature') { contentTypeId: '#(contentTypeId)', identifier: '#(contentPieceThreeId)', title: 'test 3 v2', publishDate: '#(formattedFutureDateTimeInGraceWindow)' }
* def newContentBannerOneVersion2 = callonce read('classpath:graphql/ftm/newContentVersion.feature') { contentTypeId: '#(bannerContentTypeId)', identifier: '#(bannerContentPieceOneId)', title: 'banner 1 v2', publishDate: '#(formattedFutureDateTime)' }

# Lets create a new non-published piece of content wiht a publish date in the future
Expand All @@ -72,7 +80,7 @@ Feature: Setting up the Future Time Machine Test
* def pageId = pageId[0]

# Now lets add the pieces of content to the page
* def publishPageResult = callonce read('classpath:graphql/ftm/publishPage.feature') { page_id: '#(pageId)', banner_content_ids: ['#(bannerContentPieceOneId)'], content_ids: ['#(contentPieceOneId)', '#(contentPieceTwoId)', '#(nonPublishedPieceId)'], container_id: '#(containerId)' }
* def publishPageResult = callonce read('classpath:graphql/ftm/publishPage.feature') { page_id: '#(pageId)', banner_content_ids: ['#(bannerContentPieceOneId)'], content_ids: ['#(contentPieceOneId)', '#(contentPieceTwoId)', '#(contentPieceThreeId)', '#(nonPublishedPieceId)'], container_id: '#(containerId)' }

* karate.log('Page created and Published ::', pageUrl)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ Feature: Test Time Machine functionality
* def CONTENTLET_ONE_V1 = 'test 1'
* def CONTENTLET_ONE_V2 = 'test 1 v2 (This ver will be publshed in the future)'
* def CONTENTLET_TWO_V2 = 'test 2 v2'
* def CONTENTLET_THREE_V1 = 'test 3'
* def CONTENTLET_THREE_V2 = 'test 3 v2'

* def BANNER_CONTENTLET_ONE_V1 = 'banner 1'
* def BANNER_CONTENTLET_ONE_V2 = 'banner 1 v2'
* def NON_PUBLISHED_CONTENTLET = 'Working version Only! with publish date'
Expand Down Expand Up @@ -53,7 +56,7 @@ Feature: Test Time Machine functionality
* def titles = pageContents.map(x => x.title)
# This is the first version of the content, test 1 v2 as the title says it will be published in the future
* match titles contains CONTENTLET_ONE_V1
# This is the second version of the content, This one is already published therefore it should be displayed
# This is the second version of the content, this one is already published therefore it should be displayed
* match titles contains CONTENTLET_TWO_V2
# This is the first version of the banner content which is already published therefore it should be displayed
* match titles contains BANNER_CONTENTLET_ONE_V1
Expand All @@ -74,6 +77,28 @@ Feature: Test Time Machine functionality
* match rendered !contains NON_PUBLISHED_CONTENTLET
* match rendered !contains BANNER_CONTENTLET_ONE_V2

@positive @ftm
Scenario: Test Time Machine functionality when a publish date is provided within grace window expect the future content not to be displayed
Given url baseUrl + '/api/v1/page/render/'+pageUrl+'?language_id=1&mode=LIVE&publishDate='+formattedFutureDateTimeInGraceWindow
And headers commonHeaders
When method GET
Then status 200
* karate.log('request date now:: ', java.time.LocalDateTime.now())
* def pageContents = extractContentlets (response)
* def titles = pageContents.map(x => x.title)
# This is the first version of the content, this one is already published therefore it should be displayed
* match titles contains CONTENTLET_THREE_V1
# This is the second version of the content, this one is already published but is within the FTM grace window,
# therefore it should not be displayed
* match titles !contains CONTENTLET_THREE_V2

* karate.log('pageContents:', pageContents)
# Check the rendered page. The same items included as contentlets should be displayed here too
* def rendered = response.entity.page.rendered
* karate.log('rendered:', rendered)
* match rendered contains CONTENTLET_THREE_V1
* match rendered !contains CONTENTLET_THREE_V2


@positive @ftm
Scenario: Test Time Machine functionality when a publish date is provided expect the future content to be displayed
Expand Down

0 comments on commit 9d69c74

Please sign in to comment.