diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/configurations/CloudServicesConfig.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/configurations/CloudServicesConfig.java index 37ce1978c2..71c89f21fa 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/configurations/CloudServicesConfig.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/configurations/CloudServicesConfig.java @@ -17,6 +17,9 @@ public class CloudServicesConfig { @Value("${appsmith.cloud_services.password}") private String password; + @Value("${appsmith.cloud_services.template_upload_auth_header}") + private String templateUploadAuthHeader; + @Autowired public void setBaseUrl(@Value("${appsmith.cloud_services.base_url:}") String value) { baseUrl = StringUtils.isEmpty(value) ? "https://cs.appsmith.com" : value; diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/controllers/ce/ApplicationTemplateControllerCE.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/controllers/ce/ApplicationTemplateControllerCE.java index 4ae29c04bb..d1b78ce802 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/controllers/ce/ApplicationTemplateControllerCE.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/controllers/ce/ApplicationTemplateControllerCE.java @@ -2,8 +2,10 @@ package com.appsmith.server.controllers.ce; import com.appsmith.external.views.Views; import com.appsmith.server.constants.FieldName; +import com.appsmith.server.domains.Application; import com.appsmith.server.dtos.ApplicationImportDTO; import com.appsmith.server.dtos.ApplicationTemplate; +import com.appsmith.server.dtos.CommunityTemplateDTO; import com.appsmith.server.dtos.ResponseDTO; import com.appsmith.server.services.ApplicationTemplateService; import com.fasterxml.jackson.annotation.JsonView; @@ -92,4 +94,13 @@ public class ApplicationTemplateControllerCE { .mergeTemplateWithApplication(templateId, applicationId, organizationId, branchName, pagesToImport) .map(importedApp -> new ResponseDTO<>(HttpStatus.OK.value(), importedApp, null)); } + + @JsonView(Views.Public.class) + @PostMapping("publish/{applicationId}/{organizationId}") + public Mono> publishAsCommunityTemplate( + @RequestBody(required = true) CommunityTemplateDTO resource) { + return applicationTemplateService + .publishAsCommunityTemplate(resource) + .map(template -> new ResponseDTO<>(HttpStatus.OK.value(), template, null)); + } } diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/domains/Application.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/domains/Application.java index 484852b25e..d08c45d26c 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/domains/Application.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/domains/Application.java @@ -194,6 +194,10 @@ public class Application extends BaseDomain { @JsonView(Views.Public.class) Boolean forkWithConfiguration; + // isCommunityTemplate represents whether this application has been published as a community template + @JsonView(Views.Public.class) + Boolean isCommunityTemplate; + @JsonView(Views.Internal.class) @Deprecated String defaultPermissionGroup; diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/dtos/ApplicationTemplate.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/dtos/ApplicationTemplate.java index 9c6f82fbc8..7b86179f54 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/dtos/ApplicationTemplate.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/dtos/ApplicationTemplate.java @@ -1,6 +1,7 @@ package com.appsmith.server.dtos; import com.appsmith.external.models.BaseDomain; +import jakarta.validation.constraints.Email; import lombok.Getter; import lombok.Setter; @@ -34,4 +35,11 @@ public class ApplicationTemplate extends BaseDomain { private Boolean featured; private List tags; private Boolean allowPageImport; + // This flag denotes whether a template is an official template + // or a community template + private Boolean isCommunityTemplate; + // This will point to the email of the template's author. This cannot be + // null if the template is a community template + @Email + private String authorEmail; } diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/dtos/CommunityTemplateDTO.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/dtos/CommunityTemplateDTO.java new file mode 100644 index 0000000000..6159f002c3 --- /dev/null +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/dtos/CommunityTemplateDTO.java @@ -0,0 +1,20 @@ +package com.appsmith.server.dtos; + +import com.appsmith.external.models.BaseDomain; +import lombok.Getter; +import lombok.Setter; + +import java.util.List; + +@Getter +@Setter +public class CommunityTemplateDTO extends BaseDomain { + String applicationId; + String workspaceId; + String branchName; + String title; + String headline; + String description; + List useCases; + String authorEmail; +} diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/dtos/CommunityTemplateUploadDTO.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/dtos/CommunityTemplateUploadDTO.java new file mode 100644 index 0000000000..196f50f35a --- /dev/null +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/dtos/CommunityTemplateUploadDTO.java @@ -0,0 +1,11 @@ +package com.appsmith.server.dtos; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class CommunityTemplateUploadDTO { + ApplicationTemplate applicationTemplate; + ApplicationJson appJson; +} diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/ApplicationTemplateServiceCE.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/ApplicationTemplateServiceCE.java index bc8537c515..3b40b2d738 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/ApplicationTemplateServiceCE.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/ApplicationTemplateServiceCE.java @@ -1,7 +1,9 @@ package com.appsmith.server.services.ce; +import com.appsmith.server.domains.Application; import com.appsmith.server.dtos.ApplicationImportDTO; import com.appsmith.server.dtos.ApplicationTemplate; +import com.appsmith.server.dtos.CommunityTemplateDTO; import org.springframework.util.MultiValueMap; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @@ -24,4 +26,6 @@ public interface ApplicationTemplateServiceCE { String templateId, String applicationId, String workspaceId, String branchName, List pagesToImport); Mono getFilters(); + + Mono publishAsCommunityTemplate(CommunityTemplateDTO resource); } diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/ApplicationTemplateServiceCEImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/ApplicationTemplateServiceCEImpl.java index 7d61e471ea..07a03c6e00 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/ApplicationTemplateServiceCEImpl.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/ApplicationTemplateServiceCEImpl.java @@ -7,9 +7,7 @@ import com.appsmith.server.constants.FieldName; import com.appsmith.server.domains.Application; import com.appsmith.server.domains.ApplicationMode; import com.appsmith.server.domains.UserData; -import com.appsmith.server.dtos.ApplicationImportDTO; -import com.appsmith.server.dtos.ApplicationJson; -import com.appsmith.server.dtos.ApplicationTemplate; +import com.appsmith.server.dtos.*; import com.appsmith.server.exceptions.AppsmithError; import com.appsmith.server.exceptions.AppsmithException; import com.appsmith.server.helpers.ResponseUtils; @@ -20,13 +18,17 @@ import com.appsmith.server.solutions.ApplicationPermission; import com.appsmith.server.solutions.ImportExportApplicationService; import com.appsmith.server.solutions.ReleaseNotesService; import com.appsmith.util.WebClientUtils; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.ObjectWriter; import com.google.gson.Gson; import com.google.gson.GsonBuilder; import com.google.gson.reflect.TypeToken; import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; import org.springframework.stereotype.Service; import org.springframework.util.CollectionUtils; import org.springframework.util.MultiValueMap; +import org.springframework.web.reactive.function.BodyInserters; import org.springframework.web.reactive.function.client.ExchangeStrategies; import org.springframework.web.reactive.function.client.WebClient; import org.springframework.web.util.DefaultUriBuilderFactory; @@ -309,4 +311,66 @@ public class ApplicationTemplateServiceCEImpl implements ApplicationTemplateServ return Mono.create( sink -> importedApplicationMono.subscribe(sink::success, sink::error, null, sink.currentContext())); } + + private CommunityTemplateUploadDTO createCommunityTemplateUploadDTO( + ApplicationJson appJson, CommunityTemplateDTO templateDetails) { + ApplicationTemplate applicationTemplate = new ApplicationTemplate(); + applicationTemplate.setTitle(templateDetails.getTitle()); + applicationTemplate.setExcerpt(templateDetails.getHeadline()); + applicationTemplate.setDescription(templateDetails.getDescription()); + applicationTemplate.setUseCases(templateDetails.getUseCases()); + applicationTemplate.setAuthorEmail(templateDetails.getAuthorEmail()); + + CommunityTemplateUploadDTO communityTemplate = new CommunityTemplateUploadDTO(); + communityTemplate.setAppJson(appJson); + communityTemplate.setApplicationTemplate(applicationTemplate); + return communityTemplate; + } + + private Mono uploadCommunityTemplateToCS(CommunityTemplateUploadDTO communityTemplate) { + String url = cloudServicesConfig.getBaseUrl() + "/api/v1/app-templates/upload-community-template"; + String authHeader = "Authorization"; + String payload; + try { + ObjectWriter ow = new ObjectMapper().writer().withDefaultPrettyPrinter(); + payload = ow.writeValueAsString(communityTemplate); + } catch (Exception e) { + return Mono.error(e); + } + + return WebClient.create() + .post() + .uri(url) + .header(authHeader, cloudServicesConfig.getTemplateUploadAuthHeader()) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .body(BodyInserters.fromValue(payload)) + .retrieve() + .bodyToMono(ApplicationTemplate.class); + } + + private Mono updateApplicationFlags(String applicationId, String branchId) { + return applicationService + .findById(applicationId, applicationPermission.getEditPermission()) + .flatMap(application -> { + application.setForkingEnabled(true); + application.setIsCommunityTemplate(true); + + return applicationService.update(applicationId, application, branchId); + }); + } + + @Override + public Mono publishAsCommunityTemplate(CommunityTemplateDTO resource) { + return importExportApplicationService + .exportApplicationById(resource.getApplicationId(), resource.getBranchName()) + .flatMap(appJson -> uploadCommunityTemplateToCS(createCommunityTemplateUploadDTO(appJson, resource))) + .then(updateApplicationFlags(resource.getApplicationId(), resource.getBranchName())) + .flatMap(application -> { + ApplicationAccessDTO applicationAccessDTO = new ApplicationAccessDTO(); + applicationAccessDTO.setPublicAccess(true); + return applicationService.changeViewAccess( + application.getId(), resource.getBranchName(), applicationAccessDTO); + }); + } } diff --git a/app/server/appsmith-server/src/main/resources/application.properties b/app/server/appsmith-server/src/main/resources/application.properties index b57cc0ae42..479aac34f4 100644 --- a/app/server/appsmith-server/src/main/resources/application.properties +++ b/app/server/appsmith-server/src/main/resources/application.properties @@ -80,6 +80,7 @@ appsmith.cloud_services.base_url = ${APPSMITH_CLOUD_SERVICES_BASE_URL:} appsmith.cloud_services.signature_base_url = ${APPSMITH_CLOUD_SERVICES_SIGNATURE_BASE_URL:} appsmith.cloud_services.username = ${APPSMITH_CLOUD_SERVICES_USERNAME:} appsmith.cloud_services.password = ${APPSMITH_CLOUD_SERVICES_PASSWORD:} +appsmith.cloud_services.template_upload_auth_header = ${APPSMITH_CLOUD_SERVICES_TEMPLATE_UPLOAD_AUTH} github_repo = ${APPSMITH_GITHUB_REPO:} # MANDATORY!! No default properties are being provided for encryption password and salt for security. diff --git a/app/server/appsmith-server/src/test/java/com/appsmith/server/services/ApplicationTemplateServiceTest.java b/app/server/appsmith-server/src/test/java/com/appsmith/server/services/ApplicationTemplateServiceTest.java index c6bc27d60d..07091f6738 100644 --- a/app/server/appsmith-server/src/test/java/com/appsmith/server/services/ApplicationTemplateServiceTest.java +++ b/app/server/appsmith-server/src/test/java/com/appsmith/server/services/ApplicationTemplateServiceTest.java @@ -1,68 +1,47 @@ package com.appsmith.server.services; import com.appsmith.server.configurations.CloudServicesConfig; -import com.appsmith.server.domains.UserData; -import com.appsmith.server.dtos.ApplicationTemplate; -import com.appsmith.server.dtos.PageNameIdDTO; -import com.appsmith.server.helpers.ResponseUtils; -import com.appsmith.server.solutions.ApplicationPermission; -import com.appsmith.server.solutions.ImportExportApplicationService; -import com.appsmith.server.solutions.ReleaseNotesService; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; +import com.appsmith.server.domains.Application; +import com.appsmith.server.domains.GitApplicationMetadata; +import com.appsmith.server.domains.Workspace; +import com.appsmith.server.dtos.CommunityTemplateDTO; +import lombok.extern.slf4j.Slf4j; import mockwebserver3.MockResponse; import mockwebserver3.MockWebServer; -import mockwebserver3.RecordedRequest; -import org.json.JSONArray; -import org.json.JSONObject; -import org.junit.jupiter.api.AfterAll; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.*; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mockito; -import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.test.context.support.WithUserDetails; +import org.springframework.test.annotation.DirtiesContext; import org.springframework.test.context.junit.jupiter.SpringExtension; -import reactor.core.publisher.Mono; import reactor.test.StepVerifier; import java.io.IOException; +import java.time.Instant; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; -/** - * This test is written based on the inspiration from the tutorial: https://www.baeldung.com/spring-mocking-webclient - */ +@Slf4j @ExtendWith(SpringExtension.class) +@SpringBootTest +@DirtiesContext +@TestMethodOrder(MethodOrderer.MethodName.class) public class ApplicationTemplateServiceTest { - private static final ObjectMapper objectMapper = new ObjectMapper(); private static MockWebServer mockCloudServices; + + @Autowired ApplicationTemplateService applicationTemplateService; - @MockBean - ApplicationPermission applicationPermission; + @Autowired + WorkspaceService workspaceService; - @MockBean - private UserDataService userDataService; + @Autowired + ApplicationPageService applicationPageService; - @MockBean - private CloudServicesConfig cloudServicesConfig; - - @MockBean - private ReleaseNotesService releaseNotesService; - - @MockBean - private ImportExportApplicationService importExportApplicationService; - - @MockBean - private AnalyticsService analyticsService; - - @MockBean - private ApplicationService applicationService; - - @MockBean - private ResponseUtils responseUtils; + @Autowired + CloudServicesConfig cloudServicesConfig; @BeforeAll public static void setUp() throws IOException { @@ -75,129 +54,46 @@ public class ApplicationTemplateServiceTest { mockCloudServices.shutdown(); } - @BeforeEach - public void initialize() { - String baseUrl = String.format("http://localhost:%s", mockCloudServices.getPort()); + private Application setUpTestApplication() { + Workspace workspace = new Workspace(); + workspace.setName("Import-Export-Test-Workspace"); + Workspace savedWorkspace = workspaceService.create(workspace).block(); - // mock the cloud services config so that it returns mock server url as cloud service base url - Mockito.when(cloudServicesConfig.getBaseUrl()).thenReturn(baseUrl); + Application testApplication = new Application(); + testApplication.setName("Export-Application-Test-Application"); + testApplication.setWorkspaceId(savedWorkspace.getId()); + testApplication.setUpdatedAt(Instant.now()); + testApplication.setLastDeployedAt(Instant.now()); + testApplication.setModifiedBy("some-user"); + testApplication.setGitApplicationMetadata(new GitApplicationMetadata()); - applicationTemplateService = new ApplicationTemplateServiceImpl( - cloudServicesConfig, - releaseNotesService, - importExportApplicationService, - analyticsService, - userDataService, - applicationService, - responseUtils, - applicationPermission); - } - - private ApplicationTemplate create(String id, String title) { - ApplicationTemplate applicationTemplate = new ApplicationTemplate(); - applicationTemplate.setId(id); - applicationTemplate.setTitle(title); - return applicationTemplate; - } - - @Test - public void getActiveTemplates_WhenRecentlyUsedExists_RecentOnesComesFirst() throws JsonProcessingException { - ApplicationTemplate templateOne = create("id-one", "First template"); - ApplicationTemplate templateTwo = create("id-two", "Seonds template"); - ApplicationTemplate templateThree = create("id-three", "Third template"); - - // mock the server to return the above three templates - mockCloudServices.enqueue(new MockResponse() - .setBody(objectMapper.writeValueAsString(List.of(templateOne, templateTwo, templateThree))) - .addHeader("Content-Type", "application/json")); - - // mock the user data to set second template as recently used - UserData mockUserData = new UserData(); - mockUserData.setRecentlyUsedTemplateIds(List.of("id-two")); - Mockito.when(userDataService.getForCurrentUser()).thenReturn(Mono.just(mockUserData)); - - Mono> templateListMono = applicationTemplateService.getActiveTemplates(null); - - StepVerifier.create(templateListMono) - .assertNext(applicationTemplates -> { - assertThat(applicationTemplates.size()).isEqualTo(3); - assertThat(applicationTemplates.get(0).getId()).isEqualTo("id-two"); // second one should come first - }) - .verifyComplete(); - } - - @Test - public void getRecentlyUsedTemplates_WhenNoRecentTemplate_ReturnsEmpty() { - // mock the user data to that has no recent template - Mockito.when(userDataService.getForCurrentUser()).thenReturn(Mono.just(new UserData())); - - StepVerifier.create(applicationTemplateService.getRecentlyUsedTemplates()) - .verifyComplete(); - } - - @Test - public void getRecentlyUsedTemplates_WhenRecentTemplatesExist_ReturnsTemplates() - throws InterruptedException, JsonProcessingException { - // mock the user data to set recently used template ids - UserData mockUserData = new UserData(); - mockUserData.setRecentlyUsedTemplateIds(List.of("id-one", "id-two")); - Mockito.when(userDataService.getForCurrentUser()).thenReturn(Mono.just(mockUserData)); - - // mock the server to return a template when it's called - mockCloudServices.enqueue(new MockResponse() - .setBody(objectMapper.writeValueAsString(List.of(create("id-one", "First template")))) - .addHeader("Content-Type", "application/json")); - - // make sure we've received the response returned by the mockCloudServices - StepVerifier.create(applicationTemplateService.getRecentlyUsedTemplates()) - .assertNext( - applicationTemplates -> assertThat(applicationTemplates).hasSize(1)) - .verifyComplete(); - - // verify that mockCloudServices was called with the query param id i.e. id=id-one&id=id-two - RecordedRequest recordedRequest = mockCloudServices.takeRequest(); - assert recordedRequest.getRequestUrl() != null; - List queryParameterValues = recordedRequest.getRequestUrl().queryParameterValues("id"); - assertThat(queryParameterValues).containsExactly("id-one", "id-two"); - } - - @Test - public void get_WhenPageMetaDataExists_PageMetaDataParsedProperly() throws JsonProcessingException { - JSONObject jsonObject = new JSONObject(); - jsonObject.put("id", "1234567890"); - jsonObject.put("name", "My Page"); - jsonObject.put("icon", "flight"); - jsonObject.put("isDefault", true); - JSONArray pages = new JSONArray(); - pages.put(jsonObject); - - JSONObject templateObj = new JSONObject(); - templateObj.put("title", "My Template"); - templateObj.put("pages", pages); - - JSONArray templates = new JSONArray(); - templates.put(templateObj); - - // mock the server to return a template when it's called + cloudServicesConfig.setBaseUrl(String.format("http://localhost:%s", mockCloudServices.getPort())); mockCloudServices.enqueue( - new MockResponse().setBody(templates.toString()).addHeader("Content-Type", "application/json")); + new MockResponse().setBody("{\"status\": 1}").addHeader("Content-Type", "application/json")); - // mock the user data to set recently used template ids - UserData mockUserData = new UserData(); - mockUserData.setRecentlyUsedTemplateIds(List.of()); - Mockito.when(userDataService.getForCurrentUser()).thenReturn(Mono.just(mockUserData)); + return applicationPageService + .createApplication(testApplication, savedWorkspace.getId()) + .block(); + } - // make sure we've received the response returned by the mockCloudServices - StepVerifier.create(applicationTemplateService.getActiveTemplates(null)) - .assertNext(applicationTemplates -> { - assertThat(applicationTemplates.size()).isEqualTo(1); - ApplicationTemplate applicationTemplate = applicationTemplates.get(0); - assertThat(applicationTemplate.getPages()).hasSize(1); - PageNameIdDTO pageNameIdDTO = applicationTemplate.getPages().get(0); - assertThat(pageNameIdDTO.getId()).isEqualTo("1234567890"); - assertThat(pageNameIdDTO.getName()).isEqualTo("My Page"); - assertThat(pageNameIdDTO.getIcon()).isEqualTo("flight"); - assertThat(pageNameIdDTO.getIsDefault()).isTrue(); + @Test + @WithUserDetails(value = "api_user") + public void test_application_published_as_community_template() { + Application testApp = setUpTestApplication(); + CommunityTemplateDTO communityTemplateDTO = new CommunityTemplateDTO(); + communityTemplateDTO.setApplicationId(testApp.getId()); + communityTemplateDTO.setWorkspaceId(testApp.getWorkspaceId()); + communityTemplateDTO.setTitle("Some title"); + communityTemplateDTO.setHeadline("Some headline"); + communityTemplateDTO.setDescription("Some description"); + communityTemplateDTO.setUseCases(List.of("uc1", "uc2")); + communityTemplateDTO.setAuthorEmail("test@user.com"); + + StepVerifier.create(applicationTemplateService.publishAsCommunityTemplate(communityTemplateDTO)) + .assertNext(updatedApplication -> { + assertThat(updatedApplication.getIsCommunityTemplate()).isTrue(); + assertThat(updatedApplication.getForkingEnabled()).isTrue(); + assertThat(updatedApplication.getIsPublic()).isTrue(); }) .verifyComplete(); } diff --git a/app/server/appsmith-server/src/test/java/com/appsmith/server/services/ApplicationTemplateServiceUnitTest.java b/app/server/appsmith-server/src/test/java/com/appsmith/server/services/ApplicationTemplateServiceUnitTest.java new file mode 100644 index 0000000000..6d67eea51e --- /dev/null +++ b/app/server/appsmith-server/src/test/java/com/appsmith/server/services/ApplicationTemplateServiceUnitTest.java @@ -0,0 +1,204 @@ +package com.appsmith.server.services; + +import com.appsmith.server.configurations.CloudServicesConfig; +import com.appsmith.server.domains.UserData; +import com.appsmith.server.dtos.ApplicationTemplate; +import com.appsmith.server.dtos.PageNameIdDTO; +import com.appsmith.server.helpers.ResponseUtils; +import com.appsmith.server.solutions.ApplicationPermission; +import com.appsmith.server.solutions.ImportExportApplicationService; +import com.appsmith.server.solutions.ReleaseNotesService; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import mockwebserver3.MockResponse; +import mockwebserver3.MockWebServer; +import mockwebserver3.RecordedRequest; +import org.json.JSONArray; +import org.json.JSONObject; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mockito; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.io.IOException; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * This test is written based on the inspiration from the tutorial: https://www.baeldung.com/spring-mocking-webclient + */ +@ExtendWith(SpringExtension.class) +public class ApplicationTemplateServiceUnitTest { + private static final ObjectMapper objectMapper = new ObjectMapper(); + private static MockWebServer mockCloudServices; + ApplicationTemplateService applicationTemplateService; + + @MockBean + ApplicationPermission applicationPermission; + + @MockBean + private UserDataService userDataService; + + @MockBean + private CloudServicesConfig cloudServicesConfig; + + @MockBean + private ReleaseNotesService releaseNotesService; + + @MockBean + private ImportExportApplicationService importExportApplicationService; + + @MockBean + private AnalyticsService analyticsService; + + @MockBean + private ApplicationService applicationService; + + @MockBean + private ResponseUtils responseUtils; + + @BeforeAll + public static void setUp() throws IOException { + mockCloudServices = new MockWebServer(); + mockCloudServices.start(); + } + + @AfterAll + public static void tearDown() throws IOException { + mockCloudServices.shutdown(); + } + + @BeforeEach + public void initialize() { + String baseUrl = String.format("http://localhost:%s", mockCloudServices.getPort()); + + // mock the cloud services config so that it returns mock server url as cloud service base url + Mockito.when(cloudServicesConfig.getBaseUrl()).thenReturn(baseUrl); + + applicationTemplateService = new ApplicationTemplateServiceImpl( + cloudServicesConfig, + releaseNotesService, + importExportApplicationService, + analyticsService, + userDataService, + applicationService, + responseUtils, + applicationPermission); + } + + private ApplicationTemplate create(String id, String title) { + ApplicationTemplate applicationTemplate = new ApplicationTemplate(); + applicationTemplate.setId(id); + applicationTemplate.setTitle(title); + return applicationTemplate; + } + + @Test + public void getActiveTemplates_WhenRecentlyUsedExists_RecentOnesComesFirst() throws JsonProcessingException { + ApplicationTemplate templateOne = create("id-one", "First template"); + ApplicationTemplate templateTwo = create("id-two", "Seonds template"); + ApplicationTemplate templateThree = create("id-three", "Third template"); + + // mock the server to return the above three templates + mockCloudServices.enqueue(new MockResponse() + .setBody(objectMapper.writeValueAsString(List.of(templateOne, templateTwo, templateThree))) + .addHeader("Content-Type", "application/json")); + + // mock the user data to set second template as recently used + UserData mockUserData = new UserData(); + mockUserData.setRecentlyUsedTemplateIds(List.of("id-two")); + Mockito.when(userDataService.getForCurrentUser()).thenReturn(Mono.just(mockUserData)); + + Mono> templateListMono = applicationTemplateService.getActiveTemplates(null); + + StepVerifier.create(templateListMono) + .assertNext(applicationTemplates -> { + assertThat(applicationTemplates.size()).isEqualTo(3); + assertThat(applicationTemplates.get(0).getId()).isEqualTo("id-two"); // second one should come first + }) + .verifyComplete(); + } + + @Test + public void getRecentlyUsedTemplates_WhenNoRecentTemplate_ReturnsEmpty() { + // mock the user data to that has no recent template + Mockito.when(userDataService.getForCurrentUser()).thenReturn(Mono.just(new UserData())); + + StepVerifier.create(applicationTemplateService.getRecentlyUsedTemplates()) + .verifyComplete(); + } + + @Test + public void getRecentlyUsedTemplates_WhenRecentTemplatesExist_ReturnsTemplates() + throws InterruptedException, JsonProcessingException { + // mock the user data to set recently used template ids + UserData mockUserData = new UserData(); + mockUserData.setRecentlyUsedTemplateIds(List.of("id-one", "id-two")); + Mockito.when(userDataService.getForCurrentUser()).thenReturn(Mono.just(mockUserData)); + + // mock the server to return a template when it's called + mockCloudServices.enqueue(new MockResponse() + .setBody(objectMapper.writeValueAsString(List.of(create("id-one", "First template")))) + .addHeader("Content-Type", "application/json")); + + // make sure we've received the response returned by the mockCloudServices + StepVerifier.create(applicationTemplateService.getRecentlyUsedTemplates()) + .assertNext( + applicationTemplates -> assertThat(applicationTemplates).hasSize(1)) + .verifyComplete(); + + // verify that mockCloudServices was called with the query param id i.e. id=id-one&id=id-two + RecordedRequest recordedRequest = mockCloudServices.takeRequest(); + assert recordedRequest.getRequestUrl() != null; + List queryParameterValues = recordedRequest.getRequestUrl().queryParameterValues("id"); + assertThat(queryParameterValues).containsExactly("id-one", "id-two"); + } + + @Test + public void get_WhenPageMetaDataExists_PageMetaDataParsedProperly() throws JsonProcessingException { + JSONObject jsonObject = new JSONObject(); + jsonObject.put("id", "1234567890"); + jsonObject.put("name", "My Page"); + jsonObject.put("icon", "flight"); + jsonObject.put("isDefault", true); + JSONArray pages = new JSONArray(); + pages.put(jsonObject); + + JSONObject templateObj = new JSONObject(); + templateObj.put("title", "My Template"); + templateObj.put("pages", pages); + + JSONArray templates = new JSONArray(); + templates.put(templateObj); + + // mock the server to return a template when it's called + mockCloudServices.enqueue( + new MockResponse().setBody(templates.toString()).addHeader("Content-Type", "application/json")); + + // mock the user data to set recently used template ids + UserData mockUserData = new UserData(); + mockUserData.setRecentlyUsedTemplateIds(List.of()); + Mockito.when(userDataService.getForCurrentUser()).thenReturn(Mono.just(mockUserData)); + + // make sure we've received the response returned by the mockCloudServices + StepVerifier.create(applicationTemplateService.getActiveTemplates(null)) + .assertNext(applicationTemplates -> { + assertThat(applicationTemplates.size()).isEqualTo(1); + ApplicationTemplate applicationTemplate = applicationTemplates.get(0); + assertThat(applicationTemplate.getPages()).hasSize(1); + PageNameIdDTO pageNameIdDTO = applicationTemplate.getPages().get(0); + assertThat(pageNameIdDTO.getId()).isEqualTo("1234567890"); + assertThat(pageNameIdDTO.getName()).isEqualTo("My Page"); + assertThat(pageNameIdDTO.getIcon()).isEqualTo("flight"); + assertThat(pageNameIdDTO.getIsDefault()).isTrue(); + }) + .verifyComplete(); + } +}