From 345011d708b6fc07a83b1ad1b0981f6897a0b5cb Mon Sep 17 00:00:00 2001 From: Pham Hoang Hung <84316773+phhung-axonivy@users.noreply.github.com> Date: Thu, 10 Oct 2024 15:33:54 +0700 Subject: [PATCH 01/19] MARP-1185 Only accept token of Octopus members to call SYNC API (#186) --- .../constants/ErrorMessageConstants.java | 1 + .../market/constants/GitHubConstants.java | 2 +- .../ExternalDocumentController.java | 3 +- .../market/controller/FeedbackController.java | 1 - .../market/controller/ProductController.java | 9 ++- .../market/github/service/GitHubService.java | 4 +- .../service/impl/GitHubServiceImpl.java | 57 ++++++++++++------- .../controller/ProductControllerTest.java | 4 +- .../service/impl/GitHubServiceImplTest.java | 6 ++ 9 files changed, 56 insertions(+), 31 deletions(-) diff --git a/marketplace-service/src/main/java/com/axonivy/market/constants/ErrorMessageConstants.java b/marketplace-service/src/main/java/com/axonivy/market/constants/ErrorMessageConstants.java index 431d7311e..7380b621b 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/constants/ErrorMessageConstants.java +++ b/marketplace-service/src/main/java/com/axonivy/market/constants/ErrorMessageConstants.java @@ -7,4 +7,5 @@ public class ErrorMessageConstants { public static final String INVALID_MISSING_HEADER_ERROR_MESSAGE = "Invalid or missing header"; public static final String CURRENT_CLIENT_ID_MISMATCH_MESSAGE = " Client ID mismatch (Request ID: %s, Server ID: %s)"; + public static final String INVALID_USER_ERROR = "%s - User must be a member of team %s and organization %s"; } diff --git a/marketplace-service/src/main/java/com/axonivy/market/constants/GitHubConstants.java b/marketplace-service/src/main/java/com/axonivy/market/constants/GitHubConstants.java index a86ebc8c8..1e99d7621 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/constants/GitHubConstants.java +++ b/marketplace-service/src/main/java/com/axonivy/market/constants/GitHubConstants.java @@ -6,6 +6,7 @@ @NoArgsConstructor(access = AccessLevel.PRIVATE) public class GitHubConstants { public static final String AXONIVY_MARKET_ORGANIZATION_NAME = "axonivy-market"; + public static final String AXONIVY_MARKET_TEAM_NAME = "team-octopus"; public static final String AXONIVY_MARKETPLACE_REPO_NAME = "market"; public static final String AXONIVY_MARKETPLACE_PATH = "market"; public static final String GITHUB_PROVIDER_NAME = "GitHub"; @@ -30,6 +31,5 @@ public static class Json { public static class Url { private static final String BASE_URL = "https://api.github.com"; public static final String USER = BASE_URL + "/user"; - public static final String USER_ORGS = USER + "/orgs"; } } diff --git a/marketplace-service/src/main/java/com/axonivy/market/controller/ExternalDocumentController.java b/marketplace-service/src/main/java/com/axonivy/market/controller/ExternalDocumentController.java index bd9d2eb59..9df9b0069 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/controller/ExternalDocumentController.java +++ b/marketplace-service/src/main/java/com/axonivy/market/controller/ExternalDocumentController.java @@ -59,7 +59,8 @@ public ResponseEntity syncDocumentForProduct( @RequestHeader(value = AUTHORIZATION) String authorizationHeader, @RequestParam(value = RESET_SYNC, required = false) Boolean resetSync) { String token = AuthorizationUtils.getBearerToken(authorizationHeader); - gitHubService.validateUserOrganization(token, GitHubConstants.AXONIVY_MARKET_ORGANIZATION_NAME); + gitHubService.validateUserInOrganizationAndTeam(token, GitHubConstants.AXONIVY_MARKET_ORGANIZATION_NAME, + GitHubConstants.AXONIVY_MARKET_TEAM_NAME); var message = new Message(); List products = externalDocumentService.findAllProductsHaveDocument(); if (ObjectUtils.isEmpty(products)) { diff --git a/marketplace-service/src/main/java/com/axonivy/market/controller/FeedbackController.java b/marketplace-service/src/main/java/com/axonivy/market/controller/FeedbackController.java index 998fc254b..5afd3de45 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/controller/FeedbackController.java +++ b/marketplace-service/src/main/java/com/axonivy/market/controller/FeedbackController.java @@ -1,7 +1,6 @@ package com.axonivy.market.controller; import com.axonivy.market.assembler.FeedbackModelAssembler; -import com.axonivy.market.constants.CommonConstants; import com.axonivy.market.entity.Feedback; import com.axonivy.market.model.FeedbackModel; import com.axonivy.market.model.FeedbackModelRequest; diff --git a/marketplace-service/src/main/java/com/axonivy/market/controller/ProductController.java b/marketplace-service/src/main/java/com/axonivy/market/controller/ProductController.java index d40565bd5..7a0bf9491 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/controller/ProductController.java +++ b/marketplace-service/src/main/java/com/axonivy/market/controller/ProductController.java @@ -85,7 +85,8 @@ public ResponseEntity> findProducts( public ResponseEntity syncProducts(@RequestHeader(value = AUTHORIZATION) String authorizationHeader, @RequestParam(value = RESET_SYNC, required = false) Boolean resetSync) { String token = AuthorizationUtils.getBearerToken(authorizationHeader); - gitHubService.validateUserOrganization(token, GitHubConstants.AXONIVY_MARKET_ORGANIZATION_NAME); + gitHubService.validateUserInOrganizationAndTeam(token, GitHubConstants.AXONIVY_MARKET_ORGANIZATION_NAME, + GitHubConstants.AXONIVY_MARKET_TEAM_NAME); if (Boolean.TRUE.equals(resetSync)) { productService.clearAllProducts(); } @@ -110,7 +111,8 @@ public ResponseEntity syncProducts(@RequestHeader(value = AUTHORIZATION @Operation(hidden = true) public ResponseEntity syncProductVersions(@RequestHeader(value = AUTHORIZATION) String authorizationHeader) { String token = AuthorizationUtils.getBearerToken(authorizationHeader); - gitHubService.validateUserOrganization(token, GitHubConstants.AXONIVY_MARKET_ORGANIZATION_NAME); + gitHubService.validateUserInOrganizationAndTeam(token, GitHubConstants.AXONIVY_MARKET_ORGANIZATION_NAME, + GitHubConstants.AXONIVY_MARKET_TEAM_NAME); int nonSyncResult = metadataService.syncAllProductsMetadata(); var message = new Message(); HttpStatus statusCode = HttpStatus.OK; @@ -131,7 +133,8 @@ public ResponseEntity createCustomSortProducts( @RequestHeader(value = AUTHORIZATION) String authorizationHeader, @RequestBody @Valid ProductCustomSortRequest productCustomSortRequest) { String token = AuthorizationUtils.getBearerToken(authorizationHeader); - gitHubService.validateUserOrganization(token, GitHubConstants.AXONIVY_MARKET_ORGANIZATION_NAME); + gitHubService.validateUserInOrganizationAndTeam(token, GitHubConstants.AXONIVY_MARKET_ORGANIZATION_NAME, + GitHubConstants.AXONIVY_MARKET_TEAM_NAME); productService.addCustomSortProduct(productCustomSortRequest); var message = new Message(ErrorCode.SUCCESSFUL.getCode(), ErrorCode.SUCCESSFUL.getHelpText(), "Custom product sort order added successfully"); diff --git a/marketplace-service/src/main/java/com/axonivy/market/github/service/GitHubService.java b/marketplace-service/src/main/java/com/axonivy/market/github/service/GitHubService.java index a31309e4a..dca4173d9 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/github/service/GitHubService.java +++ b/marketplace-service/src/main/java/com/axonivy/market/github/service/GitHubService.java @@ -19,6 +19,8 @@ public interface GitHubService { GitHub getGitHub() throws IOException; + GitHub getGitHub(String accessToken) throws IOException; + GHOrganization getOrganization(String orgName) throws IOException; GHRepository getRepository(String repositoryPath) throws IOException; @@ -34,5 +36,5 @@ GitHubAccessTokenResponse getAccessToken(String code, GitHubProperty gitHubPrope User getAndUpdateUser(String accessToken); - void validateUserOrganization(String accessToken, String organization) throws UnauthorizedException; + void validateUserInOrganizationAndTeam(String accessToken, String team, String org) throws UnauthorizedException; } diff --git a/marketplace-service/src/main/java/com/axonivy/market/github/service/impl/GitHubServiceImpl.java b/marketplace-service/src/main/java/com/axonivy/market/github/service/impl/GitHubServiceImpl.java index 4ff0bc3e8..293850c62 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/github/service/impl/GitHubServiceImpl.java +++ b/marketplace-service/src/main/java/com/axonivy/market/github/service/impl/GitHubServiceImpl.java @@ -1,6 +1,5 @@ package com.axonivy.market.github.service.impl; -import com.axonivy.market.constants.CommonConstants; import com.axonivy.market.constants.ErrorMessageConstants; import com.axonivy.market.constants.GitHubConstants; import com.axonivy.market.entity.User; @@ -12,13 +11,13 @@ import com.axonivy.market.github.model.GitHubAccessTokenResponse; import com.axonivy.market.github.model.GitHubProperty; import com.axonivy.market.github.service.GitHubService; -import com.axonivy.market.github.util.GitHubUtils; import com.axonivy.market.repository.UserRepository; import lombok.extern.log4j.Log4j2; import org.kohsuke.github.GHContent; import org.kohsuke.github.GHOrganization; import org.kohsuke.github.GHRepository; import org.kohsuke.github.GHTag; +import org.kohsuke.github.GHTeam; import org.kohsuke.github.GitHub; import org.kohsuke.github.GitHubBuilder; import org.springframework.boot.web.client.RestTemplateBuilder; @@ -30,9 +29,9 @@ import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; -import org.springframework.web.client.HttpClientErrorException; import org.springframework.web.client.RestTemplate; import java.io.IOException; @@ -64,6 +63,11 @@ public GitHub getGitHub() throws IOException { Optional.ofNullable(gitHubProperty).map(GitHubProperty::getToken).orElse(EMPTY).trim()).build(); } + @Override + public GitHub getGitHub(String accessToken) throws IOException { + return new GitHubBuilder().withOAuthToken(accessToken).build(); + } + @Override public GHOrganization getOrganization(String orgName) throws IOException { return getGitHub().getOrganization(orgName); @@ -156,31 +160,40 @@ public User getAndUpdateUser(String accessToken) { } @Override - public void validateUserOrganization(String accessToken, String organization) throws UnauthorizedException { - List> userOrganizations = getUserOrganizations(accessToken); - for (var org : userOrganizations) { - if (org.get("login").equals(organization)) { + public void validateUserInOrganizationAndTeam(String accessToken, String organization, + String team) throws UnauthorizedException { + try { + var gitHub = getGitHub(accessToken); + if (isUserInOrganizationAndTeam(gitHub, organization, team)) { return; } + } catch (IOException e) { + log.error(e.getStackTrace()); } + throw new UnauthorizedException(ErrorCode.GITHUB_USER_UNAUTHORIZED.getCode(), - ErrorCode.GITHUB_USER_UNAUTHORIZED.getHelpText() - + "-User must be a member of the Axon Ivy Market Organization"); + String.format(ErrorMessageConstants.INVALID_USER_ERROR, ErrorCode.GITHUB_USER_UNAUTHORIZED.getHelpText(), + team, organization)); } - public List> getUserOrganizations(String accessToken) throws UnauthorizedException { - HttpHeaders headers = new HttpHeaders(); - headers.setBearerAuth(accessToken); - HttpEntity entity = new HttpEntity<>(headers); - try { - ResponseEntity>> response = restTemplate.exchange(GitHubConstants.Url.USER_ORGS, - HttpMethod.GET, entity, new ParameterizedTypeReference<>() { - }); - return response.getBody(); - } catch (HttpClientErrorException exception) { - throw new UnauthorizedException(ErrorCode.GITHUB_USER_UNAUTHORIZED.getCode(), - ErrorCode.GITHUB_USER_UNAUTHORIZED.getHelpText() + CommonConstants.DASH_SEPARATOR - + GitHubUtils.extractMessageFromExceptionMessage(exception.getMessage())); + private boolean isUserInOrganizationAndTeam(GitHub gitHub, String organization, + String teamName) throws IOException { + if (gitHub == null) { + return false; + } + + var hashMapTeams = gitHub.getMyTeams(); + var hashSetTeam = hashMapTeams.get(organization); + if (CollectionUtils.isEmpty(hashSetTeam)) { + return false; } + + for (GHTeam team: hashSetTeam) { + if (teamName.equals(team.getName())) { + return true; + } + } + + return false; } } diff --git a/marketplace-service/src/test/java/com/axonivy/market/controller/ProductControllerTest.java b/marketplace-service/src/test/java/com/axonivy/market/controller/ProductControllerTest.java index 63ef385d0..a794cbe84 100644 --- a/marketplace-service/src/test/java/com/axonivy/market/controller/ProductControllerTest.java +++ b/marketplace-service/src/test/java/com/axonivy/market/controller/ProductControllerTest.java @@ -130,7 +130,7 @@ void testSyncProductsWithResetSuccess() { void testSyncProductsInvalidToken() { doThrow(new UnauthorizedException(ErrorCode.GITHUB_USER_UNAUTHORIZED.getCode(), ErrorCode.GITHUB_USER_UNAUTHORIZED.getHelpText())).when(gitHubService) - .validateUserOrganization(any(String.class), any(String.class)); + .validateUserInOrganizationAndTeam(any(String.class), any(String.class), any(String.class)); UnauthorizedException exception = assertThrows(UnauthorizedException.class, () -> productController.syncProducts(INVALID_AUTHORIZATION_HEADER, false)); @@ -156,7 +156,7 @@ void testSyncMavenVersionSuccess() { void testSyncMavenVersionWithInvalidToken() { doThrow(new UnauthorizedException(ErrorCode.GITHUB_USER_UNAUTHORIZED.getCode(), ErrorCode.GITHUB_USER_UNAUTHORIZED.getHelpText())).when(gitHubService) - .validateUserOrganization(any(String.class), any(String.class)); + .validateUserInOrganizationAndTeam(any(String.class), any(String.class), any(String.class)); UnauthorizedException exception = assertThrows(UnauthorizedException.class, () -> productController.syncProductVersions(INVALID_AUTHORIZATION_HEADER)); diff --git a/marketplace-service/src/test/java/com/axonivy/market/service/impl/GitHubServiceImplTest.java b/marketplace-service/src/test/java/com/axonivy/market/service/impl/GitHubServiceImplTest.java index 1cfd8f00d..f253bf455 100644 --- a/marketplace-service/src/test/java/com/axonivy/market/service/impl/GitHubServiceImplTest.java +++ b/marketplace-service/src/test/java/com/axonivy/market/service/impl/GitHubServiceImplTest.java @@ -63,4 +63,10 @@ void testGetDirectoryContent() throws IOException { var result = gitHubService.getDirectoryContent(ghRepository, "", ""); assertEquals(0, result.size()); } + + @Test + void testGithubWithToken() throws IOException { + var result = gitHubService.getGitHub("accessToken"); + assertEquals(DUMMY_API_URL, result.getApiUrl()); + } } From 4da73daadf5e6afe70a817f1318155591715daa5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ph=E1=BA=A1m=20Duy=20Linh?= <138570547+linhpd-axonivy@users.noreply.github.com> Date: Fri, 11 Oct 2024 09:20:22 +0700 Subject: [PATCH 02/19] MARP-1013: fix: Product feedback sorting dropdown (#191) --- .../feedback-filter.component.spec.ts | 45 ++++++++++++++ .../feedback-filter.component.ts | 25 +++++++- .../feedback-filter.service.spec.ts.js | 42 +++++++++++++ .../feedback-filter.service.ts | 18 ++++++ .../product-feedback.service.spec.ts | 9 ++- .../product-feedback.service.ts | 61 +++++++++++++------ 6 files changed, 176 insertions(+), 24 deletions(-) create mode 100644 marketplace-ui/src/app/modules/product/product-detail/product-detail-feedback/product-feedbacks-panel/feedback-filter/feedback-filter.service.spec.ts.js create mode 100644 marketplace-ui/src/app/modules/product/product-detail/product-detail-feedback/product-feedbacks-panel/feedback-filter/feedback-filter.service.ts diff --git a/marketplace-ui/src/app/modules/product/product-detail/product-detail-feedback/product-feedbacks-panel/feedback-filter/feedback-filter.component.spec.ts b/marketplace-ui/src/app/modules/product/product-detail/product-detail-feedback/product-feedbacks-panel/feedback-filter/feedback-filter.component.spec.ts index c055923be..ed33d05d4 100644 --- a/marketplace-ui/src/app/modules/product/product-detail/product-detail-feedback/product-feedbacks-panel/feedback-filter/feedback-filter.component.spec.ts +++ b/marketplace-ui/src/app/modules/product/product-detail/product-detail-feedback/product-feedbacks-panel/feedback-filter/feedback-filter.component.spec.ts @@ -10,12 +10,21 @@ import { ItemDropdown } from '../../../../../../shared/models/item-dropdown.mode import { TypeOption } from '../../../../../../shared/enums/type-option.enum'; import { SortOption } from '../../../../../../shared/enums/sort-option.enum'; import { FeedbackSortType } from '../../../../../../shared/enums/feedback-sort-type'; +import { FeedbackFilterService } from './feedback-filter.service'; +import { Subject } from 'rxjs'; describe('FeedbackFilterComponent', () => { + const mockEvent = { + value: FeedbackSortType.NEWEST, + label: 'common.sort.value.newest', + sortFn: 'updatedAt,desc' + } as ItemDropdown; + let component: FeedbackFilterComponent; let fixture: ComponentFixture; let translateService: jasmine.SpyObj; let productFeedbackService: jasmine.SpyObj; + let feedbackFilterService: FeedbackFilterService; beforeEach(async () => { const productFeedbackServiceSpy = jasmine.createSpyObj('ProductFeedbackService', ['sort']); @@ -23,6 +32,14 @@ describe('FeedbackFilterComponent', () => { await TestBed.configureTestingModule({ imports: [FeedbackFilterComponent, FormsModule, TranslateModule.forRoot() ], providers: [ + { + provide: FeedbackFilterService, + useValue: { + event$: new Subject(), + data: null, + changeSortByLabel: jasmine.createSpy('changeSortByLabel') + } + }, TranslateService, { provide: ProductFeedbackService, useValue: productFeedbackServiceSpy } ] @@ -31,6 +48,7 @@ describe('FeedbackFilterComponent', () => { translateService = TestBed.inject(TranslateService) as jasmine.SpyObj; productFeedbackService = TestBed.inject(ProductFeedbackService) as jasmine.SpyObj; + feedbackFilterService = TestBed.inject(FeedbackFilterService); }); beforeEach(() => { @@ -69,4 +87,31 @@ describe('FeedbackFilterComponent', () => { const dropdownComponent = fixture.debugElement.query(By.directive(CommonDropdownComponent)).componentInstance; expect(dropdownComponent.items).toBe(component.feedbackSortTypes); }); + + it('should emit sortChange event when onSortChange is called', () => { + spyOn(component.sortChange, 'emit'); + component.onSortChange(mockEvent); + expect(component.sortChange.emit).toHaveBeenCalledWith(mockEvent.sortFn); + }); + + it('should listen to feedbackFilterService event$ and call changeSortByLabel', () => { + spyOn(component, 'changeSortByLabel').and.callThrough(); + component.ngOnInit(); // Subscribes to event$ + (feedbackFilterService.event$ as Subject).next(mockEvent); // Trigger the event + expect(component.changeSortByLabel).toHaveBeenCalledWith(mockEvent); + }); + + it('should NOT call changeSortByLabel if feedbackFilterService.data does not exist', () => { + feedbackFilterService.data = undefined; + spyOn(component, 'changeSortByLabel'); + component.ngOnInit() + expect(component.changeSortByLabel).not.toHaveBeenCalled(); + }); + + it('should call changeSortByLabel if feedbackFilterService.data exists', () => { + feedbackFilterService.data = mockEvent; + spyOn(component, 'changeSortByLabel'); + component.ngOnInit() + expect(component.changeSortByLabel).toHaveBeenCalledWith(mockEvent); + }); }); diff --git a/marketplace-ui/src/app/modules/product/product-detail/product-detail-feedback/product-feedbacks-panel/feedback-filter/feedback-filter.component.ts b/marketplace-ui/src/app/modules/product/product-detail/product-detail-feedback/product-feedbacks-panel/feedback-filter/feedback-filter.component.ts index 91338569f..bf62bf80f 100644 --- a/marketplace-ui/src/app/modules/product/product-detail/product-detail-feedback/product-feedbacks-panel/feedback-filter/feedback-filter.component.ts +++ b/marketplace-ui/src/app/modules/product/product-detail/product-detail-feedback/product-feedbacks-panel/feedback-filter/feedback-filter.component.ts @@ -1,4 +1,4 @@ -import { Component, EventEmitter, inject, Output } from '@angular/core'; +import { Component, EventEmitter, inject, OnInit, Output } from '@angular/core'; import { TranslateModule } from '@ngx-translate/core'; import { FEEDBACK_SORT_TYPES } from '../../../../../../shared/constants/common.constant'; import { FormsModule } from '@angular/forms'; @@ -8,6 +8,7 @@ import { CommonDropdownComponent } from '../../../../../../shared/components/com import { CommonUtils } from '../../../../../../shared/utils/common.utils'; import { ItemDropdown } from '../../../../../../shared/models/item-dropdown.model'; import { FeedbackSortType } from '../../../../../../shared/enums/feedback-sort-type'; +import { FeedbackFilterService } from './feedback-filter.service'; @Component({ selector: 'app-feedback-filter', @@ -16,18 +17,36 @@ import { FeedbackSortType } from '../../../../../../shared/enums/feedback-sort-t templateUrl: './feedback-filter.component.html', styleUrl: './feedback-filter.component.scss' }) -export class FeedbackFilterComponent { +export class FeedbackFilterComponent implements OnInit { feedbackSortTypes = FEEDBACK_SORT_TYPES; @Output() sortChange = new EventEmitter(); + feedbackFilterService = inject(FeedbackFilterService); + productFeedbackService = inject(ProductFeedbackService); languageService = inject(LanguageService); selectedSortTypeLabel: string = CommonUtils.getLabel(FEEDBACK_SORT_TYPES[0].value, FEEDBACK_SORT_TYPES); + ngOnInit() { + if (this.feedbackFilterService.data) { + this.changeSortByLabel(this.feedbackFilterService.data); + } + this.feedbackFilterService.event$.subscribe(event => { + this.changeSortByLabel(event); + }); + } + onSortChange(event: ItemDropdown): void { - this.selectedSortTypeLabel = CommonUtils.getLabel(event.value, FEEDBACK_SORT_TYPES); + this.changeSortByLabel(event); this.sortChange.emit(event.sortFn); + this.feedbackFilterService.changeSortByLabel(event); } + changeSortByLabel(event: ItemDropdown): void { + this.selectedSortTypeLabel = CommonUtils.getLabel( + event.value, + FEEDBACK_SORT_TYPES + ); + } } diff --git a/marketplace-ui/src/app/modules/product/product-detail/product-detail-feedback/product-feedbacks-panel/feedback-filter/feedback-filter.service.spec.ts.js b/marketplace-ui/src/app/modules/product/product-detail/product-detail-feedback/product-feedbacks-panel/feedback-filter/feedback-filter.service.spec.ts.js new file mode 100644 index 000000000..067c84334 --- /dev/null +++ b/marketplace-ui/src/app/modules/product/product-detail/product-detail-feedback/product-feedbacks-panel/feedback-filter/feedback-filter.service.spec.ts.js @@ -0,0 +1,42 @@ +import { TestBed } from '@angular/core/testing'; +import { FeedbackFilterService } from './feedback-filter.service'; + +describe('FeedbackFilterService', () => { + let service: FeedbackFilterService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(FeedbackFilterService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should have undefined data initially', () => { + expect(service.data).toBeUndefined(); + }); + + it('should update data and emit the value through event$', () => { + const mockData = { value: 'test', label: 'Test Label' }; + + // Spy on the next method of the Subject (sortBySubject) + spyOn(service['sortBySubject'], 'next').and.callThrough(); + + // Subscribe to the event$ observable to listen for changes + let emittedValue: any; + service.event$.subscribe(value => emittedValue = value); + + // Call the changeSortByLabel function + service.changeSortByLabel(mockData); + + // Expect the data to be updated + expect(service.data).toEqual(mockData); + + // Expect the next method to have been called with the correct value + expect(service['sortBySubject'].next).toHaveBeenCalledWith(mockData); + + // Expect the emitted value to match the mockData + expect(emittedValue).toEqual(mockData); + }); +}); diff --git a/marketplace-ui/src/app/modules/product/product-detail/product-detail-feedback/product-feedbacks-panel/feedback-filter/feedback-filter.service.ts b/marketplace-ui/src/app/modules/product/product-detail/product-detail-feedback/product-feedbacks-panel/feedback-filter/feedback-filter.service.ts new file mode 100644 index 000000000..31dfbfd28 --- /dev/null +++ b/marketplace-ui/src/app/modules/product/product-detail/product-detail-feedback/product-feedbacks-panel/feedback-filter/feedback-filter.service.ts @@ -0,0 +1,18 @@ +import { Injectable } from '@angular/core'; +import { Subject } from 'rxjs'; +import { ItemDropdown } from '../../../../../../shared/models/item-dropdown.model'; +import { FeedbackSortType } from '../../../../../../shared/enums/feedback-sort-type'; + +@Injectable({ + providedIn: 'root' +}) +export class FeedbackFilterService { + private readonly sortBySubject = new Subject>(); + data: ItemDropdown | undefined; + event$ = this.sortBySubject.asObservable(); + + changeSortByLabel(data: ItemDropdown) { + this.data = data; + this.sortBySubject.next(data); + } +} diff --git a/marketplace-ui/src/app/modules/product/product-detail/product-detail-feedback/product-feedbacks-panel/product-feedback.service.spec.ts b/marketplace-ui/src/app/modules/product/product-detail/product-detail-feedback/product-feedbacks-panel/product-feedback.service.spec.ts index 258f434cf..87ee61820 100644 --- a/marketplace-ui/src/app/modules/product/product-detail/product-detail-feedback/product-feedbacks-panel/product-feedback.service.spec.ts +++ b/marketplace-ui/src/app/modules/product/product-detail/product-detail-feedback/product-feedbacks-panel/product-feedback.service.spec.ts @@ -93,14 +93,17 @@ describe('ProductFeedbackService', () => { service.loadMoreFeedbacks(); const loadMoreReq = httpMock.expectOne('api/feedback/product/123?page=1&size=8&sort=updatedAt,desc'); - loadMoreReq.flush({ _embedded: { feedbacks: additionalFeedback } }); - + loadMoreReq.flush({ _embedded: { feedbacks: additionalFeedback }, page: { totalPages: 2, totalElements: 5 } }); + expect(service.feedbacks()).toEqual([...initialFeedback, ...additionalFeedback]); }); it('should change sort and fetch feedbacks', () => { const mockResponse = { - _embedded: { feedbacks: [{ content: 'Sorting test', rating: 3, productId: '123' }] } + _embedded: { + feedbacks: [{ content: 'Sorting test', rating: 3, productId: '123' }] + }, + page: { totalPages: 2, totalElements: 5 } }; productDetailService.productId.and.returnValue('123'); diff --git a/marketplace-ui/src/app/modules/product/product-detail/product-detail-feedback/product-feedbacks-panel/product-feedback.service.ts b/marketplace-ui/src/app/modules/product/product-detail/product-detail-feedback/product-feedbacks-panel/product-feedback.service.ts index 3236febe2..f96fe48b6 100644 --- a/marketplace-ui/src/app/modules/product/product-detail/product-detail-feedback/product-feedbacks-panel/product-feedback.service.ts +++ b/marketplace-ui/src/app/modules/product/product-detail/product-detail-feedback/product-feedbacks-panel/product-feedback.service.ts @@ -11,7 +11,7 @@ import { signal, WritableSignal } from '@angular/core'; -import { catchError, Observable, of, tap } from 'rxjs'; +import { BehaviorSubject, catchError, concatMap, EMPTY, Observable, of, tap } from 'rxjs'; import { AuthService } from '../../../../../auth/auth.service'; import { SkipLoading } from '../../../../../core/interceptors/api.interceptor'; import { FeedbackApiResponse } from '../../../../../shared/models/apis/feedback-response.model'; @@ -29,6 +29,12 @@ export class ProductFeedbackService { private readonly productDetailService = inject(ProductDetailService); private readonly productStarRatingService = inject(ProductStarRatingService); private readonly http = inject(HttpClient); + private readonly feedbackRequestQueue$ = new BehaviorSubject<{ + productId: string; + page: number; + sort: string; + size: number; + } | null>(null); sort: WritableSignal = signal('updatedAt,desc'); page: WritableSignal = signal(0); @@ -45,6 +51,25 @@ export class ProductFeedbackService { totalPages: WritableSignal = signal(1); totalElements: WritableSignal = signal(0); + constructor() { + this.feedbackRequestQueue$ + .pipe( + concatMap(requestParams => { + if (requestParams) { + return this.executeFindProductFeedbacksByCriteria( + requestParams.productId, + requestParams.page, + requestParams.sort, + requestParams.size + ); + } else { + return EMPTY; + } + }) + ) + .subscribe(); + } + submitFeedback(feedback: Feedback): Observable { const headers = new HttpHeaders().set( 'X-Authorization', @@ -69,6 +94,15 @@ export class ProductFeedbackService { page: number = this.page(), sort: string = this.sort(), size: number = SIZE + ): void { + this.feedbackRequestQueue$.next({ productId, page, sort, size }); + } + + private executeFindProductFeedbacksByCriteria( + productId: string = this.productDetailService.productId(), + page: number = this.page(), + sort: string = this.sort(), + size: number = SIZE ): Observable { const requestParams = new HttpParams() .set('page', page.toString()) @@ -82,14 +116,9 @@ export class ProductFeedbackService { }) .pipe( tap(response => { - if (page === 0) { - this.feedbacks.set(response._embedded.feedbacks); - } else { - this.feedbacks.set([ - ...this.feedbacks(), - ...response._embedded.feedbacks - ]); - } + this.totalPages.set(response.page.totalPages); + this.totalElements.set(response.page.totalElements); + this.feedbacks.set([...this.feedbacks(), ...response._embedded.feedbacks]); }) ); } @@ -100,10 +129,8 @@ export class ProductFeedbackService { const params = new HttpParams() .set('productId', productId) .set('userId', this.authService.getUserId() ?? ''); - const requestURL = FEEDBACK_API_URL; - return this.http - .get(requestURL, { + .get(FEEDBACK_API_URL, { params, context: new HttpContext().set(SkipLoading, true) }) @@ -125,20 +152,18 @@ export class ProductFeedbackService { initFeedbacks(): void { this.page.set(0); - this.findProductFeedbacksByCriteria().subscribe(response => { - this.totalPages.set(response.page.totalPages); - this.totalElements.set(response.page.totalElements); - }); + this.findProductFeedbacksByCriteria(); } loadMoreFeedbacks(): void { this.page.update(value => value + 1); - this.findProductFeedbacksByCriteria().subscribe(); + this.findProductFeedbacksByCriteria(); } changeSort(newSort: string): void { + this.feedbacks.set([]); this.page.set(0); this.sort.set(newSort); - this.findProductFeedbacksByCriteria().subscribe(); + this.findProductFeedbacksByCriteria(); } } From c884bb7a2219cd61b7c5b025614b1fae5784217c Mon Sep 17 00:00:00 2001 From: Hoan Nguyen <83745591+nqhoan-axonivy@users.noreply.github.com> Date: Fri, 11 Oct 2024 13:45:51 +0700 Subject: [PATCH 03/19] MARP-1167 Portal externallink to documentation is not shown anymore status undefined (#192) --- .../ExternalDocumentController.java | 23 +++-- .../market/entity/ExternalDocumentMeta.java | 4 + .../market/model/ExternalDocumentModel.java | 43 +++++++++ .../ExternalDocumentMetaRepository.java | 2 + .../service/ExternalDocumentService.java | 3 +- .../impl/ExternalDocumentServiceImpl.java | 27 ++++-- .../com/axonivy/market/util/MavenUtils.java | 8 +- .../ExternalDocumentControllerTest.java | 14 ++- .../impl/CustomProductRepositoryImplTest.java | 3 +- .../impl/ExternalDocumentServiceImplTest.java | 11 +-- .../service/impl/GitHubServiceImplTest.java | 1 - .../service/impl/ProductServiceImplTest.java | 1 - .../app/core/interceptors/api.interceptor.ts | 27 ++++-- ...duct-detail-information-tab.component.html | 13 ++- ...t-detail-information-tab.component.spec.ts | 94 +++++++++++++++++-- ...roduct-detail-information-tab.component.ts | 42 ++++++++- .../product-detail.service.spec.ts | 11 ++- .../product-detail/product-detail.service.ts | 17 +++- .../modules/product/product.service.spec.ts | 11 +-- .../app/modules/product/product.service.ts | 21 ++--- .../external-document.component.spec.ts | 7 +- .../external-document.component.ts | 16 ++-- .../src/app/shared/constants/api.constant.ts | 10 ++ .../src/app/shared/mocks/mock-data.ts | 9 ++ .../shared/models/external-document.model.ts | 15 +++ marketplace-ui/src/assets/i18n/de.yaml | 2 + marketplace-ui/src/assets/i18n/en.yaml | 2 + 27 files changed, 349 insertions(+), 88 deletions(-) create mode 100644 marketplace-service/src/main/java/com/axonivy/market/model/ExternalDocumentModel.java create mode 100644 marketplace-ui/src/app/shared/constants/api.constant.ts create mode 100644 marketplace-ui/src/app/shared/models/external-document.model.ts diff --git a/marketplace-service/src/main/java/com/axonivy/market/controller/ExternalDocumentController.java b/marketplace-service/src/main/java/com/axonivy/market/controller/ExternalDocumentController.java index 9df9b0069..ac8d850b7 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/controller/ExternalDocumentController.java +++ b/marketplace-service/src/main/java/com/axonivy/market/controller/ExternalDocumentController.java @@ -1,9 +1,11 @@ package com.axonivy.market.controller; import com.axonivy.market.constants.GitHubConstants; +import com.axonivy.market.entity.ExternalDocumentMeta; import com.axonivy.market.entity.Product; import com.axonivy.market.enums.ErrorCode; import com.axonivy.market.github.service.GitHubService; +import com.axonivy.market.model.ExternalDocumentModel; import com.axonivy.market.model.Message; import com.axonivy.market.service.ExternalDocumentService; import com.axonivy.market.util.AuthorizationUtils; @@ -13,7 +15,6 @@ import io.swagger.v3.oas.annotations.tags.Tag; import lombok.AllArgsConstructor; import org.apache.commons.lang3.ObjectUtils; -import org.apache.commons.lang3.StringUtils; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; @@ -24,12 +25,12 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import java.net.URI; -import java.net.URISyntaxException; import java.util.List; import static com.axonivy.market.constants.RequestMappingConstants.*; import static com.axonivy.market.constants.RequestParamConstants.*; +import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.linkTo; +import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.methodOn; import static org.springframework.http.HttpHeaders.AUTHORIZATION; @RestController @@ -41,16 +42,22 @@ public class ExternalDocumentController { final GitHubService gitHubService; @GetMapping(BY_ID_AND_VERSION) - public ResponseEntity findExternalDocumentURI( + public ResponseEntity findExternalDocument( @PathVariable(ID) @Parameter(description = "Product id (from meta.json)", example = "portal", in = ParameterIn.PATH) String id, @PathVariable(VERSION) @Parameter(description = "Release version (from maven metadata.xml)", example = "10.0.20", - in = ParameterIn.PATH) String version) throws URISyntaxException { - String externalDocumentURI = externalDocumentService.findExternalDocumentURI(id, version); - if (StringUtils.isBlank(externalDocumentURI)) { + in = ParameterIn.PATH) String version) { + ExternalDocumentMeta externalDocument = externalDocumentService.findExternalDocument(id, version); + if (externalDocument == null) { return new ResponseEntity<>(HttpStatus.NOT_FOUND); } - return new ResponseEntity<>(new URI(externalDocumentURI), HttpStatus.OK); + + var model = ExternalDocumentModel.builder().productId(externalDocument.getProductId()) + .version(externalDocument.getVersion()).relativeLink(externalDocument.getRelativeLink()) + .artifactName(externalDocument.getArtifactName()).build(); + model.add(linkTo(methodOn(ExternalDocumentController.class).findExternalDocument(id, version)).withSelfRel()); + + return new ResponseEntity<>(model, HttpStatus.OK); } @PutMapping(SYNC) diff --git a/marketplace-service/src/main/java/com/axonivy/market/entity/ExternalDocumentMeta.java b/marketplace-service/src/main/java/com/axonivy/market/entity/ExternalDocumentMeta.java index a52393a28..2fb652d6e 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/entity/ExternalDocumentMeta.java +++ b/marketplace-service/src/main/java/com/axonivy/market/entity/ExternalDocumentMeta.java @@ -1,6 +1,7 @@ package com.axonivy.market.entity; import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; @@ -17,11 +18,14 @@ @Setter @AllArgsConstructor @NoArgsConstructor +@Builder @Document(EXTERNAL_DOCUMENT_META) public class ExternalDocumentMeta { @Id private String id; private String productId; + private String artifactId; + private String artifactName; private String version; private String storageDirectory; private String relativeLink; diff --git a/marketplace-service/src/main/java/com/axonivy/market/model/ExternalDocumentModel.java b/marketplace-service/src/main/java/com/axonivy/market/model/ExternalDocumentModel.java new file mode 100644 index 000000000..a7ff07d39 --- /dev/null +++ b/marketplace-service/src/main/java/com/axonivy/market/model/ExternalDocumentModel.java @@ -0,0 +1,43 @@ +package com.axonivy.market.model; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.apache.commons.lang3.builder.EqualsBuilder; +import org.apache.commons.lang3.builder.HashCodeBuilder; +import org.springframework.hateoas.RepresentationModel; + +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ExternalDocumentModel extends RepresentationModel { + @Schema(description = "Product id", example = "portal") + private String productId; + @Schema(description = "Name of artifact", example = "Portal Guide") + private String artifactName; + @Schema(description = "Version of artifact", example = "10.0.0") + private String version; + @Schema(description = "Relative link of document page", + example = "/market-cache/portal/portal-guide/10.0.12/doc/index.html") + private String relativeLink; + + @Override + public int hashCode() { + return new HashCodeBuilder().append(productId).append(version).hashCode(); + } + + @Override + public boolean equals(Object obj) { + if (obj == null || this.getClass() != obj.getClass()) { + return false; + } + var compareExternalDocumentModel = (ExternalDocumentModel) obj; + return new EqualsBuilder().append(productId, compareExternalDocumentModel.getProductId()) + .append(version, compareExternalDocumentModel.getVersion()).isEquals(); + } +} diff --git a/marketplace-service/src/main/java/com/axonivy/market/repository/ExternalDocumentMetaRepository.java b/marketplace-service/src/main/java/com/axonivy/market/repository/ExternalDocumentMetaRepository.java index 8b6e5c4c4..0c6ee535f 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/repository/ExternalDocumentMetaRepository.java +++ b/marketplace-service/src/main/java/com/axonivy/market/repository/ExternalDocumentMetaRepository.java @@ -12,4 +12,6 @@ public interface ExternalDocumentMetaRepository extends MongoRepository findByProductIdAndVersion(String productId, String version); List findByProductId(String productId); + + void deleteByProductIdAndVersion(String productId, String version); } diff --git a/marketplace-service/src/main/java/com/axonivy/market/service/ExternalDocumentService.java b/marketplace-service/src/main/java/com/axonivy/market/service/ExternalDocumentService.java index e2b5139f0..2a09fddbe 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/service/ExternalDocumentService.java +++ b/marketplace-service/src/main/java/com/axonivy/market/service/ExternalDocumentService.java @@ -1,5 +1,6 @@ package com.axonivy.market.service; +import com.axonivy.market.entity.ExternalDocumentMeta; import com.axonivy.market.entity.Product; import java.util.List; @@ -9,5 +10,5 @@ public interface ExternalDocumentService { List findAllProductsHaveDocument(); - String findExternalDocumentURI(String productId, String version); + ExternalDocumentMeta findExternalDocument(String productId, String version); } diff --git a/marketplace-service/src/main/java/com/axonivy/market/service/impl/ExternalDocumentServiceImpl.java b/marketplace-service/src/main/java/com/axonivy/market/service/impl/ExternalDocumentServiceImpl.java index 4b15c2438..799a647b9 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/service/impl/ExternalDocumentServiceImpl.java +++ b/marketplace-service/src/main/java/com/axonivy/market/service/impl/ExternalDocumentServiceImpl.java @@ -1,6 +1,7 @@ package com.axonivy.market.service.impl; import com.axonivy.market.bo.Artifact; +import com.axonivy.market.constants.CommonConstants; import com.axonivy.market.constants.DirectoryConstants; import com.axonivy.market.entity.ExternalDocumentMeta; import com.axonivy.market.entity.Product; @@ -15,6 +16,7 @@ import lombok.extern.log4j.Log4j2; import org.apache.commons.lang3.BooleanUtils; import org.apache.commons.lang3.ObjectUtils; +import org.apache.commons.lang3.RegExUtils; import org.apache.commons.lang3.StringUtils; import org.springframework.stereotype.Service; @@ -29,6 +31,7 @@ public class ExternalDocumentServiceImpl implements ExternalDocumentService { private static final String DOC_URL_PATTERN = "/%s/index.html"; + private static final String MS_WIN_SEPARATOR = "\\\\"; final ProductRepository productRepo; final ExternalDocumentMetaRepository externalDocumentMetaRepo; final FileDownloadService fileDownloadService; @@ -56,37 +59,43 @@ public List findAllProductsHaveDocument() { } @Override - public String findExternalDocumentURI(String productId, String version) { + public ExternalDocumentMeta findExternalDocument(String productId, String version) { var product = productRepo.findById(productId); if (product.isEmpty()) { - return EMPTY; + return null; } List docMetas = externalDocumentMetaRepo.findByProductId(productId); List docMetaVersion = docMetas.stream().map(ExternalDocumentMeta::getVersion).toList(); String resolvedVersion = VersionFactory.get(docMetaVersion, version); return docMetas.stream().filter(meta -> StringUtils.equals(meta.getVersion(), resolvedVersion)) - .map(ExternalDocumentMeta::getRelativeLink).findAny().orElse(EMPTY); + .findAny().orElse(null); } private void syncDocumentationForProduct(String productId, boolean isResetSync, Artifact artifact, List releasedVersions) { for (var version : releasedVersions) { - List documentMetas = externalDocumentMetaRepo.findByProductIdAndVersion(productId, version); - if (!isResetSync && ObjectUtils.isNotEmpty(documentMetas)) { - continue; + if (isResetSync) { + externalDocumentMetaRepo.deleteByProductIdAndVersion(productId, version); + } else { + if (ObjectUtils.isNotEmpty(externalDocumentMetaRepo.findByProductIdAndVersion(productId, version))) { + continue; + } } String downloadDocUrl = MavenUtils.buildDownloadUrl(artifact, version); String location = downloadDocAndUnzipToShareFolder(downloadDocUrl, isResetSync); if (StringUtils.isNoneBlank(location)) { - // Remove all old records - externalDocumentMetaRepo.deleteAll(documentMetas); var documentMeta = new ExternalDocumentMeta(); documentMeta.setProductId(productId); + documentMeta.setArtifactId(artifact.getArtifactId()); + documentMeta.setArtifactName(artifact.getName()); documentMeta.setVersion(version); documentMeta.setStorageDirectory(location); + // remove prefix 'data' and replace all ms win separator to slash if present var locationRelative = location.substring(location.indexOf(DirectoryConstants.CACHE_DIR)); - documentMeta.setRelativeLink(String.format(DOC_URL_PATTERN, locationRelative)); + locationRelative = RegExUtils.replaceAll(String.format(DOC_URL_PATTERN, locationRelative), MS_WIN_SEPARATOR, + CommonConstants.SLASH); + documentMeta.setRelativeLink(locationRelative); externalDocumentMetaRepo.save(documentMeta); } } diff --git a/marketplace-service/src/main/java/com/axonivy/market/util/MavenUtils.java b/marketplace-service/src/main/java/com/axonivy/market/util/MavenUtils.java index f6b6fdd02..a29c4af9a 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/util/MavenUtils.java +++ b/marketplace-service/src/main/java/com/axonivy/market/util/MavenUtils.java @@ -249,10 +249,10 @@ public static Metadata buildSnapShotMetadataFromVersion(Metadata metadata, Strin } public static MavenArtifactModel buildMavenArtifactModelFromMetadata(String version, Metadata metadata) { - return new MavenArtifactModel(metadata.getName(), - buildDownloadUrl(metadata.getArtifactId(), version, metadata.getType(), - metadata.getRepoUrl(), metadata.getGroupId(), metadata.getSnapshotVersionValue()), - metadata.getArtifactId().contains(metadata.getGroupId())); + String downloadUrl = buildDownloadUrl(metadata.getArtifactId(), version, metadata.getType(), metadata.getRepoUrl(), + metadata.getGroupId(), metadata.getSnapshotVersionValue()); + return MavenArtifactModel.builder().name(metadata.getName()).downloadUrl(downloadUrl).isInvalidArtifact( + metadata.getArtifactId().contains(metadata.getGroupId())).build(); } public static String getMetadataContentFromUrl(String metadataUrl) { diff --git a/marketplace-service/src/test/java/com/axonivy/market/controller/ExternalDocumentControllerTest.java b/marketplace-service/src/test/java/com/axonivy/market/controller/ExternalDocumentControllerTest.java index 2bf4fe79c..97df45462 100644 --- a/marketplace-service/src/test/java/com/axonivy/market/controller/ExternalDocumentControllerTest.java +++ b/marketplace-service/src/test/java/com/axonivy/market/controller/ExternalDocumentControllerTest.java @@ -1,5 +1,6 @@ package com.axonivy.market.controller; +import com.axonivy.market.entity.ExternalDocumentMeta; import com.axonivy.market.entity.Product; import com.axonivy.market.github.service.GitHubService; import com.axonivy.market.service.ExternalDocumentService; @@ -11,7 +12,6 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.http.HttpStatus; -import java.net.URISyntaxException; import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -36,9 +36,9 @@ class ExternalDocumentControllerTest { @Test - void testFindProductDoc() throws URISyntaxException { - when(service.findExternalDocumentURI(any(), any())).thenReturn("/market-cache/portal/10.0.0/doc/index.html"); - var result = externalDocumentController.findExternalDocumentURI("portal", "10.0"); + void testFindProductDoc() { + when(service.findExternalDocument(any(), any())).thenReturn(createExternalDocumentMock()); + var result = externalDocumentController.findExternalDocument("portal", "10.0"); assertEquals(HttpStatus.OK, result.getStatusCode()); assertTrue(result.hasBody()); assertTrue(ObjectUtils.isNotEmpty(result.getBody())); @@ -54,4 +54,10 @@ void testSyncDocumentForProduct() { result = externalDocumentController.syncDocumentForProduct(TOKEN, true); assertEquals(HttpStatus.OK, result.getStatusCode(), "Should return at least one product"); } + + private ExternalDocumentMeta createExternalDocumentMock() { + return ExternalDocumentMeta.builder() + .relativeLink("/market-cache/portal/10.0.0/doc/index.html") + .build(); + } } diff --git a/marketplace-service/src/test/java/com/axonivy/market/repository/impl/CustomProductRepositoryImplTest.java b/marketplace-service/src/test/java/com/axonivy/market/repository/impl/CustomProductRepositoryImplTest.java index 6a202229c..573220118 100644 --- a/marketplace-service/src/test/java/com/axonivy/market/repository/impl/CustomProductRepositoryImplTest.java +++ b/marketplace-service/src/test/java/com/axonivy/market/repository/impl/CustomProductRepositoryImplTest.java @@ -21,7 +21,6 @@ import java.util.List; import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) @@ -113,7 +112,7 @@ void testGetProductById_andFindProductModuleContentByNewestVersion() { when(aggregationResults.getUniqueMappedResult()).thenReturn(mockProduct); - Product actualProduct = repo.getProductByIdWithNewestReleaseVersion(ID,false); + Product actualProduct = repo.getProductByIdWithNewestReleaseVersion(ID, false); verify(contentRepo, times(1)).findByTagAndProductId("v11.3.0", ID); assertEquals(mockProduct, actualProduct); diff --git a/marketplace-service/src/test/java/com/axonivy/market/service/impl/ExternalDocumentServiceImplTest.java b/marketplace-service/src/test/java/com/axonivy/market/service/impl/ExternalDocumentServiceImplTest.java index a5d7f1cc6..0e0eb5412 100644 --- a/marketplace-service/src/test/java/com/axonivy/market/service/impl/ExternalDocumentServiceImplTest.java +++ b/marketplace-service/src/test/java/com/axonivy/market/service/impl/ExternalDocumentServiceImplTest.java @@ -6,7 +6,6 @@ import com.axonivy.market.repository.ExternalDocumentMetaRepository; import com.axonivy.market.repository.ProductRepository; import com.axonivy.market.service.FileDownloadService; -import org.apache.commons.lang3.StringUtils; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; @@ -47,7 +46,7 @@ void testSyncDocumentForProduct() throws IOException { verify(externalDocumentMetaRepository, times(0)).findByProductIdAndVersion(any(), any()); when(productRepository.findById(PORTAL)).thenReturn(mockPortalProduct()); - service.syncDocumentForProduct(PORTAL, true); + service.syncDocumentForProduct(PORTAL, false); verify(externalDocumentMetaRepository, times(2)).findByProductIdAndVersion(any(), any()); when(fileDownloadService.downloadAndUnzipFile(any(), anyBoolean())).thenReturn("data" + RELATIVE_LOCATION); @@ -72,17 +71,17 @@ void testFindExternalDocumentURI() { var mockVersion = "10.0.0"; var mockProductDocumentMeta = new ExternalDocumentMeta(); when(productRepository.findById(PORTAL)).thenReturn(mockPortalProduct()); - var result = service.findExternalDocumentURI(PORTAL, mockVersion); + var result = service.findExternalDocument(PORTAL, mockVersion); verify(productRepository, times(1)).findById(any()); - assertTrue(StringUtils.isEmpty(result)); + assertNull(result); mockProductDocumentMeta.setProductId(PORTAL); mockProductDocumentMeta.setVersion(mockVersion); mockProductDocumentMeta.setRelativeLink(RELATIVE_LOCATION); when(externalDocumentMetaRepository.findByProductId(PORTAL)).thenReturn(List.of(mockProductDocumentMeta)); - result = service.findExternalDocumentURI(PORTAL, mockVersion); + result = service.findExternalDocument(PORTAL, mockVersion); assertNotNull(result); - assertTrue(result.contains("/index.html")); + assertTrue(result.getRelativeLink().contains("/index.html")); } private Optional mockPortalProduct() { diff --git a/marketplace-service/src/test/java/com/axonivy/market/service/impl/GitHubServiceImplTest.java b/marketplace-service/src/test/java/com/axonivy/market/service/impl/GitHubServiceImplTest.java index f253bf455..a3b9c0bfc 100644 --- a/marketplace-service/src/test/java/com/axonivy/market/service/impl/GitHubServiceImplTest.java +++ b/marketplace-service/src/test/java/com/axonivy/market/service/impl/GitHubServiceImplTest.java @@ -16,7 +16,6 @@ import java.io.IOException; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; diff --git a/marketplace-service/src/test/java/com/axonivy/market/service/impl/ProductServiceImplTest.java b/marketplace-service/src/test/java/com/axonivy/market/service/impl/ProductServiceImplTest.java index 3982eac1f..3a8385971 100644 --- a/marketplace-service/src/test/java/com/axonivy/market/service/impl/ProductServiceImplTest.java +++ b/marketplace-service/src/test/java/com/axonivy/market/service/impl/ProductServiceImplTest.java @@ -71,7 +71,6 @@ import static com.axonivy.market.enums.DocumentField.SHORT_DESCRIPTIONS; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) diff --git a/marketplace-ui/src/app/core/interceptors/api.interceptor.ts b/marketplace-ui/src/app/core/interceptors/api.interceptor.ts index 91b9b729c..678d2dc90 100644 --- a/marketplace-ui/src/app/core/interceptors/api.interceptor.ts +++ b/marketplace-ui/src/app/core/interceptors/api.interceptor.ts @@ -13,11 +13,16 @@ import { ERROR_CODES, ERROR_PAGE_PATH } from '../../shared/constants/common.cons export const REQUEST_BY = 'X-Requested-By'; export const IVY = 'marketplace-website'; -/** This is option for exclude loading api +/** SkipLoading: This option for exclude loading api * @Example return httpClient.get('apiEndPoint', { context: new HttpContext().set(SkipLoading, true) }) */ export const SkipLoading = new HttpContextToken(() => false); +/** ForwardingError: This option for forwarding responce error to the caller + * @Example return httpClient.get('apiEndPoint', { context: new HttpContext().set(ForwardingError, true) }) + */ +export const ForwardingError = new HttpContextToken(() => false); + export const apiInterceptor: HttpInterceptorFn = (req, next) => { const router = inject(Router); const loadingService = inject(LoadingService); @@ -36,23 +41,25 @@ export const apiInterceptor: HttpInterceptorFn = (req, next) => { headers: addIvyHeaders(req.headers) }); - if (req.context.get(SkipLoading)) { - return next(cloneReq); + if (!req.context.get(SkipLoading)) { + loadingService.show(); } - loadingService.show(); - return next(cloneReq).pipe( catchError(error => { - if (ERROR_CODES.includes(error.status)) { - router.navigate([`${ERROR_PAGE_PATH}/${error.status}`]); - } else { - router.navigate([ERROR_PAGE_PATH]); + if (!req.context.get(ForwardingError)) { + if (ERROR_CODES.includes(error.status)) { + router.navigate([`${ERROR_PAGE_PATH}/${error.status}`]); + } else { + router.navigate([ERROR_PAGE_PATH]); + } } return EMPTY; }), finalize(() => { - loadingService.hide(); + if (!req.context.get(SkipLoading)) { + loadingService.hide(); + } }) ); }; diff --git a/marketplace-ui/src/app/modules/product/product-detail/product-detail-information-tab/product-detail-information-tab.component.html b/marketplace-ui/src/app/modules/product/product-detail/product-detail-information-tab/product-detail-information-tab.component.html index 374a868b9..cd306a61b 100644 --- a/marketplace-ui/src/app/modules/product/product-detail/product-detail-information-tab/product-detail-information-tab.component.html +++ b/marketplace-ui/src/app/modules/product/product-detail/product-detail-information-tab/product-detail-information-tab.component.html @@ -14,7 +14,7 @@

- {{ selectedVersion.replaceAll('Version ', '') }} + {{ displayVersion }} @if(productDetail.compatibility) { @@ -42,6 +42,17 @@

{{ productDetail.language }} + @if (externalDocumentLink !== '') { +
+
+ + {{ 'common.product.detail.information.value.documentation' | translate }} + + + {{ displayExternalDocName ?? 'common.product.detail.information.value.defaultDocName' | translate }} + +
+ }
diff --git a/marketplace-ui/src/app/modules/product/product-detail/product-detail-information-tab/product-detail-information-tab.component.spec.ts b/marketplace-ui/src/app/modules/product/product-detail/product-detail-information-tab/product-detail-information-tab.component.spec.ts index bd5cdacc9..0ad112e39 100644 --- a/marketplace-ui/src/app/modules/product/product-detail/product-detail-information-tab/product-detail-information-tab.component.spec.ts +++ b/marketplace-ui/src/app/modules/product/product-detail/product-detail-information-tab/product-detail-information-tab.component.spec.ts @@ -1,30 +1,104 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ProductDetailInformationTabComponent } from './product-detail-information-tab.component'; -import { MOCK_PRODUCT_DETAIL } from '../../../../shared/mocks/mock-data'; +import { of } from 'rxjs'; +import { SimpleChanges } from '@angular/core'; +import { ProductDetailService } from '../product-detail.service'; +import { LanguageService } from '../../../../core/services/language/language.service'; +import { ProductDetail } from '../../../../shared/models/product-detail.model'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import { MOCK_EXTERNAL_DOCUMENT } from '../../../../shared/mocks/mock-data'; -describe('InformationDetailComponent', () => { +const TEST_ID = 'portal'; +const TEST_VERSION = 'v10.0.0'; +const TEST_ACTUAL_VERSION = '10.0.0'; +const TEST_ARTIFACT_ID = 'portal-guide'; +const TEST_ARTIFACT_NAME = 'Portal Guide'; +const TEST_DOC_URL = '/market-cache/portal/portal-guide/10.0.0/doc/index.html'; + +describe('ProductDetailInformationTabComponent', () => { let component: ProductDetailInformationTabComponent; let fixture: ComponentFixture; + let productDetailService: jasmine.SpyObj; beforeEach(async () => { + const productDetailServiceSpy = jasmine.createSpyObj('ProductDetailService', ['getExteralDocumentForProductByVersion']); + await TestBed.configureTestingModule({ - imports: [ - ProductDetailInformationTabComponent, + imports: [ProductDetailInformationTabComponent, TranslateModule.forRoot() ], - providers: [TranslateService] + providers: [ + { provide: ProductDetailService, useValue: productDetailServiceSpy }, + LanguageService, + TranslateService + ] }).compileComponents(); fixture = TestBed.createComponent(ProductDetailInformationTabComponent); component = fixture.componentInstance; - component.productDetail = MOCK_PRODUCT_DETAIL; - component.selectedVersion = '1.0.0'; - fixture.detectChanges(); + productDetailService = TestBed.inject(ProductDetailService) as jasmine.SpyObj; }); it('should create', () => { expect(component).toBeTruthy(); }); -}); + + it('should set externalDocumentLink and displayExternalDocName on valid version change', () => { + productDetailService.getExteralDocumentForProductByVersion.and.returnValue(of({ ...MOCK_EXTERNAL_DOCUMENT })); + + component.productDetail = { id: TEST_ID, newestReleaseVersion: TEST_VERSION } as ProductDetail; + component.selectedVersion = TEST_VERSION; + const changes: SimpleChanges = { + selectedVersion: { + currentValue: TEST_VERSION, + previousValue: 'v8.0.0', + firstChange: false, + isFirstChange: () => false + }, + productDetail: { + currentValue: component.productDetail, + previousValue: null, + firstChange: true, + isFirstChange: () => true + } + }; + + component.ngOnChanges(changes); + + expect(productDetailService.getExteralDocumentForProductByVersion).toHaveBeenCalledWith(TEST_ID, TEST_ACTUAL_VERSION); + expect(component.externalDocumentLink).toBe(TEST_DOC_URL); + expect(component.displayExternalDocName).toBe(TEST_ARTIFACT_NAME); + }); + + it('should not set externalDocumentLink if version is invalid', () => { + component.productDetail = { id: TEST_ID, newestReleaseVersion: '' } as ProductDetail; + component.selectedVersion = ''; + const changes: SimpleChanges = { + selectedVersion: { + currentValue: '', + previousValue: 'v8.0.0', + firstChange: false, + isFirstChange: () => false + }, + productDetail: { + currentValue: component.productDetail, + previousValue: null, + firstChange: true, + isFirstChange: () => true + } + }; + + component.ngOnChanges(changes); + + expect(productDetailService.getExteralDocumentForProductByVersion).not.toHaveBeenCalled(); + expect(component.externalDocumentLink).toBe(''); + expect(component.displayExternalDocName).toBe(''); + }); + + it('should extract version value correctly', () => { + const versionDisplayName = TEST_VERSION; + const extractedValue = component.extractVersionValue(versionDisplayName); + expect(extractedValue).toBe(TEST_ACTUAL_VERSION); + }); +}); \ No newline at end of file diff --git a/marketplace-ui/src/app/modules/product/product-detail/product-detail-information-tab/product-detail-information-tab.component.ts b/marketplace-ui/src/app/modules/product/product-detail/product-detail-information-tab/product-detail-information-tab.component.ts index 5d7bb86dc..2a847c82f 100644 --- a/marketplace-ui/src/app/modules/product/product-detail/product-detail-information-tab/product-detail-information-tab.component.ts +++ b/marketplace-ui/src/app/modules/product/product-detail/product-detail-information-tab/product-detail-information-tab.component.ts @@ -1,9 +1,13 @@ import { CommonModule } from '@angular/common'; -import { Component, inject, Input } from '@angular/core'; +import { Component, inject, Input, OnChanges, SimpleChanges } from '@angular/core'; import { TranslateModule } from '@ngx-translate/core'; import { ProductDetail } from '../../../../shared/models/product-detail.model'; import { LanguageService } from '../../../../core/services/language/language.service'; +import { ProductDetailService } from '../product-detail.service'; +import { VERSION } from '../../../../shared/constants/common.constant'; +const SELECTED_VERSION = 'selectedVersion'; +const PRODUCT_DETAIL = 'productDetail'; @Component({ selector: 'app-product-detail-information-tab', standalone: true, @@ -11,11 +15,43 @@ import { LanguageService } from '../../../../core/services/language/language.ser templateUrl: './product-detail-information-tab.component.html', styleUrl: './product-detail-information-tab.component.scss' }) -export class ProductDetailInformationTabComponent { +export class ProductDetailInformationTabComponent implements OnChanges { @Input() productDetail!: ProductDetail; @Input() selectedVersion!: string; - + externalDocumentLink = ''; + displayVersion = ''; + displayExternalDocName: string | null = ''; languageService = inject(LanguageService); + productDetailService = inject(ProductDetailService); + + ngOnChanges(changes: SimpleChanges): void { + let version = ''; + const changedSelectedVersion = changes[SELECTED_VERSION]; + if (changedSelectedVersion && changedSelectedVersion.currentValue === changedSelectedVersion.previousValue) { + return; + } + const changedProduct = changes[PRODUCT_DETAIL]; + if (changedProduct && changedProduct.currentValue !== changedProduct.previousValue) { + version = this.productDetail.newestReleaseVersion; + } else { + version = this.selectedVersion; + } + // Invalid version + if (version === undefined || version === '') { + return; + } + + this.productDetailService.getExteralDocumentForProductByVersion(this.productDetail.id, this.extractVersionValue(version)) + .subscribe(response => { + this.externalDocumentLink = response.relativeLink; + this.displayExternalDocName = response.artifactName; + }); + this.displayVersion = this.extractVersionValue(this.selectedVersion); + } + + extractVersionValue(versionDisplayName: string) { + return versionDisplayName.replace(VERSION.displayPrefix, '').replace(VERSION.tagPrefix, ''); + } } diff --git a/marketplace-ui/src/app/modules/product/product-detail/product-detail.service.spec.ts b/marketplace-ui/src/app/modules/product/product-detail/product-detail.service.spec.ts index 70a8b44da..aae29c669 100644 --- a/marketplace-ui/src/app/modules/product/product-detail/product-detail.service.spec.ts +++ b/marketplace-ui/src/app/modules/product/product-detail/product-detail.service.spec.ts @@ -1,15 +1,24 @@ import { TestBed } from '@angular/core/testing'; import { ProductDetailService } from './product-detail.service'; import { DisplayValue } from '../../../shared/models/display-value.model'; +import { HttpClient, provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; describe('ProductDetailService', () => { let service: ProductDetailService; + let httpMock: HttpTestingController; + let httpClient: jasmine.SpyObj; beforeEach(() => { TestBed.configureTestingModule({ - providers: [ProductDetailService] + providers: [ProductDetailService, + provideHttpClient(withInterceptorsFromDi()), + provideHttpClientTesting(), + { provide: HttpClient, useValue: httpClient } + ] }); service = TestBed.inject(ProductDetailService); + httpMock = TestBed.inject(HttpTestingController); }); it('should be created', () => { diff --git a/marketplace-ui/src/app/modules/product/product-detail/product-detail.service.ts b/marketplace-ui/src/app/modules/product/product-detail/product-detail.service.ts index 8272d25d2..ccbe32af4 100644 --- a/marketplace-ui/src/app/modules/product/product-detail/product-detail.service.ts +++ b/marketplace-ui/src/app/modules/product/product-detail/product-detail.service.ts @@ -1,5 +1,11 @@ -import { Injectable, signal, WritableSignal } from '@angular/core'; +import { inject, Injectable, signal, WritableSignal } from '@angular/core'; import { DisplayValue } from '../../../shared/models/display-value.model'; +import { HttpClient, HttpContext } from '@angular/common/http'; +import { LoadingService } from '../../../core/services/loading/loading.service'; +import { Observable } from 'rxjs'; +import { API_URI } from '../../../shared/constants/api.constant'; +import { ForwardingError } from '../../../core/interceptors/api.interceptor'; +import { ExternalDocument } from '../../../shared/models/external-document.model'; @Injectable({ providedIn: 'root' @@ -7,4 +13,13 @@ import { DisplayValue } from '../../../shared/models/display-value.model'; export class ProductDetailService { productId: WritableSignal = signal(''); productNames: WritableSignal = signal({} as DisplayValue); + httpClient = inject(HttpClient); + loadingService = inject(LoadingService); + + + getExteralDocumentForProductByVersion(productId: string, version: string): Observable { + return this.httpClient.get( + `${API_URI.EXTERNAL_DOCUMENT}/${productId}/${version}`, { context: new HttpContext().set(ForwardingError, true)} + ); + } } diff --git a/marketplace-ui/src/app/modules/product/product.service.spec.ts b/marketplace-ui/src/app/modules/product/product.service.spec.ts index d69a3a56b..b95dff075 100644 --- a/marketplace-ui/src/app/modules/product/product.service.spec.ts +++ b/marketplace-ui/src/app/modules/product/product.service.spec.ts @@ -14,6 +14,7 @@ import { Criteria } from '../../shared/models/criteria.model'; import { VersionData } from '../../shared/models/vesion-artifact.model'; import { ProductService } from './product.service'; import { DEFAULT_PAGEABLE, DEFAULT_PAGEABLE_IN_REST_CLIENT } from '../../shared/constants/common.constant'; +import { API_URI } from '../../shared/constants/api.constant'; describe('ProductService', () => { let products = MOCK_PRODUCTS._embedded.products; @@ -185,7 +186,7 @@ describe('ProductService', () => { const req = httpMock.expectOne(request => { return ( - request.url === `api/product-details/${productId}/versions` && + request.url === `${API_URI.PRODUCT_DETAILS}/${productId}/versions` && request.params.get('designerVersion') === designerVersion && request.params.get('isShowDevVersion') === showDevVersion.toString() ); @@ -207,7 +208,7 @@ describe('ProductService', () => { }); const req = httpMock.expectOne(request => { - expect(request.url).toEqual(`api/product-details/${productId}/${tag}`); + expect(request.url).toEqual(`${API_URI.PRODUCT_DETAILS}/${productId}/${tag}`); return true; }); @@ -221,9 +222,8 @@ describe('ProductService', () => { expect(response).toBe(3); }); - const req = httpMock.expectOne(`api/product-details/installationcount/${productId}?designerVersion=${designerVersion}`); + const req = httpMock.expectOne(`${API_URI.PRODUCT_DETAILS}/installationcount/${productId}?designerVersion=${designerVersion}`); expect(req.request.method).toBe('PUT'); - expect(req.request.headers.get('X-Requested-By')).toBe('ivy'); req.flush(3); }); @@ -237,9 +237,8 @@ describe('ProductService', () => { expect(response[2].version).toBe('10.0.0'); }); - const req = httpMock.expectOne(`api/product-details/${productId}/designerversions`); + const req = httpMock.expectOne(`${API_URI.PRODUCT_DETAILS}/${productId}/designerversions`); expect(req.request.method).toBe('GET'); - expect(req.request.headers.get('X-Requested-By')).toBe('ivy'); req.flush([{ version: '10.0.2' }, {version: '10.0.1'}, {version: '10.0.0'}]); }); diff --git a/marketplace-ui/src/app/modules/product/product.service.ts b/marketplace-ui/src/app/modules/product/product.service.ts index f52665155..5d991c227 100644 --- a/marketplace-ui/src/app/modules/product/product.service.ts +++ b/marketplace-ui/src/app/modules/product/product.service.ts @@ -9,8 +9,8 @@ import { ProductDetail } from '../../shared/models/product-detail.model'; import { VersionData } from '../../shared/models/vesion-artifact.model'; import { SkipLoading } from '../../core/interceptors/api.interceptor'; import { VersionAndUrl } from '../../shared/models/version-and-url'; +import { API_URI } from '../../shared/constants/api.constant'; -const PRODUCT_API_URL = 'api/product'; @Injectable() export class ProductService { httpClient = inject(HttpClient); @@ -18,7 +18,7 @@ export class ProductService { findProductsByCriteria(criteria: Criteria): Observable { let requestParams = new HttpParams(); - let requestURL = PRODUCT_API_URL; + let requestURL = API_URI.PRODUCT; if (criteria.nextPageHref) { requestURL = criteria.nextPageHref; } else { @@ -41,7 +41,7 @@ export class ProductService { tag: string ): Observable { return this.httpClient.get( - `api/product-details/${productId}/${tag}` + `${API_URI.PRODUCT_DETAILS}/${productId}/${tag}` ); } @@ -50,13 +50,13 @@ export class ProductService { tag: string ): Observable { return this.httpClient.get( - `api/product-details/${productId}/${tag}/bestmatch` + `${API_URI.PRODUCT_DETAILS}/${productId}/${tag}/bestmatch` ); } getProductDetails(productId: string, isShowDevVersion: boolean): Observable { return this.httpClient.get( - `api/product-details/${productId}?isShowDevVersion=${isShowDevVersion}` + `${API_URI.PRODUCT_DETAILS}/${productId}?isShowDevVersion=${isShowDevVersion}` ); } @@ -65,7 +65,7 @@ export class ProductService { showDevVersion: boolean, designerVersion: string ): Observable { - const url = `api/product-details/${productId}/versions`; + const url = `${API_URI.PRODUCT_DETAILS}/${productId}/versions`; const params = new HttpParams() .append('designerVersion', designerVersion) .append('isShowDevVersion', showDevVersion); @@ -76,14 +76,13 @@ export class ProductService { } sendRequestToUpdateInstallationCount(productId: string, designerVersion: string) { - const url = 'api/product-details/installationcount/' + productId; - const headers = { 'X-Requested-By': 'ivy' }; + const url = `${API_URI.PRODUCT_DETAILS}/installationcount/${productId}`; const params = new HttpParams().append('designerVersion', designerVersion); - return this.httpClient.put(url, null, { headers, params }); + return this.httpClient.put(url, null, { params }); } sendRequestToGetProductVersionsForDesigner(productId: string) { - const url = `api/product-details/${productId}/designerversions`; - return this.httpClient.get(url, { headers: { 'X-Requested-By': 'ivy' } }); + const url = `${API_URI.PRODUCT_DETAILS}/${productId}/designerversions`; + return this.httpClient.get(url); } } diff --git a/marketplace-ui/src/app/shared/components/external-document/external-document.component.spec.ts b/marketplace-ui/src/app/shared/components/external-document/external-document.component.spec.ts index d3b2b40c6..b4594ed8f 100644 --- a/marketplace-ui/src/app/shared/components/external-document/external-document.component.spec.ts +++ b/marketplace-ui/src/app/shared/components/external-document/external-document.component.spec.ts @@ -6,6 +6,8 @@ import { ROUTER } from '../../constants/router.constant'; import { HttpClient, provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; import { ExternalDocumentComponent } from './external-document.component'; import { of } from 'rxjs'; +import { API_URI } from '../../constants/api.constant'; +import { MOCK_EXTERNAL_DOCUMENT } from '../../mocks/mock-data'; describe('ExternalDocumentComponent', () => { let component: ExternalDocumentComponent; @@ -55,12 +57,13 @@ describe('ExternalDocumentComponent', () => { it('should not redirect if response URL matches current URL', () => { const currentUrl = window.location.href; - const mockResponse = currentUrl; + let mockResponse = { ...MOCK_EXTERNAL_DOCUMENT }; + mockResponse.relativeLink = currentUrl; httpClient.get.and.returnValue(of(mockResponse)); component.ngOnInit(); - expect(httpClient.get).toHaveBeenCalledWith(`api/externaldocument/portal/10.0`); + expect(httpClient.get).toHaveBeenCalledWith(`${API_URI.EXTERNAL_DOCUMENT}/portal/10.0`); expect(window.location.href).toBe(currentUrl); }); }); diff --git a/marketplace-ui/src/app/shared/components/external-document/external-document.component.ts b/marketplace-ui/src/app/shared/components/external-document/external-document.component.ts index 70cceeb82..ea8cf6d69 100644 --- a/marketplace-ui/src/app/shared/components/external-document/external-document.component.ts +++ b/marketplace-ui/src/app/shared/components/external-document/external-document.component.ts @@ -4,9 +4,10 @@ import { ActivatedRoute, Router } from '@angular/router'; import { ROUTER } from '../../constants/router.constant'; import { TranslateModule } from '@ngx-translate/core'; import { ERROR_PAGE_PATH } from '../../constants/common.constant'; +import { API_URI } from '../../constants/api.constant'; +import { ExternalDocument } from '../../models/external-document.model'; const INDEX_FILE = '/index.html'; -const DOC_API = 'api/externaldocument'; @Component({ selector: 'app-external-document', @@ -32,19 +33,20 @@ export class ExternalDocumentComponent implements OnInit { } fetchDocumentUrl(product: string, version: string, currentUrl: string): void { - this.httpClient.get(`${DOC_API}/${product}/${version}`) + this.httpClient.get(`${API_URI.EXTERNAL_DOCUMENT}/${product}/${version}`) .subscribe({ - next: (response: string) => this.handleRedirection(response, currentUrl) + next: (response: ExternalDocument) => this.handleRedirection(response, currentUrl) }); } - handleRedirection(response: string, currentUrl: string): void { - if (response === null || response === '') { + handleRedirection(response: ExternalDocument, currentUrl: string): void { + if (response === null || response.relativeLink === '') { this.router.navigate([ERROR_PAGE_PATH]); } - const isSameUrl = currentUrl === response || currentUrl + INDEX_FILE === response; + const relativeUrl = response.relativeLink; + const isSameUrl = currentUrl === relativeUrl || currentUrl + INDEX_FILE === relativeUrl; if (!isSameUrl) { - window.location.href = response; + window.location.href = relativeUrl; } } } diff --git a/marketplace-ui/src/app/shared/constants/api.constant.ts b/marketplace-ui/src/app/shared/constants/api.constant.ts new file mode 100644 index 000000000..2f6527b59 --- /dev/null +++ b/marketplace-ui/src/app/shared/constants/api.constant.ts @@ -0,0 +1,10 @@ + +const API = 'api'; + +export const API_URI = { + APP: '/', + PRODUCT: `${API}/product`, + PRODUCT_DETAILS: `${API}/product-details`, + EXTERNAL_DOCUMENT: `${API}/externaldocument`, + FEEDBACK: `${API}/feedback`, +} \ No newline at end of file diff --git a/marketplace-ui/src/app/shared/mocks/mock-data.ts b/marketplace-ui/src/app/shared/mocks/mock-data.ts index 582f1dd97..d6b547cec 100644 --- a/marketplace-ui/src/app/shared/mocks/mock-data.ts +++ b/marketplace-ui/src/app/shared/mocks/mock-data.ts @@ -1,4 +1,5 @@ import { ProductApiResponse } from '../models/apis/product-response.model'; +import { ExternalDocument } from '../models/external-document.model'; import { ProductDetail } from '../models/product-detail.model'; import { ProductModuleContent } from '../models/product-module-content.model'; @@ -318,3 +319,11 @@ export const MOCK_PRODUCT_DETAIL: ProductDetail = { } } }; + +export const MOCK_EXTERNAL_DOCUMENT: ExternalDocument = { + productId: 'portal', + version: 'v10.0.0', + artifactId: 'portal-guide', + artifactName: 'Portal Guide', + relativeLink: '/market-cache/portal/portal-guide/10.0.0/doc/index.html' +}; \ No newline at end of file diff --git a/marketplace-ui/src/app/shared/models/external-document.model.ts b/marketplace-ui/src/app/shared/models/external-document.model.ts new file mode 100644 index 000000000..06b691c69 --- /dev/null +++ b/marketplace-ui/src/app/shared/models/external-document.model.ts @@ -0,0 +1,15 @@ +import { DisplayValue } from './display-value.model'; +import { MavenArtifact } from './maven-artifact.model'; + +export interface ExternalDocument { + productId: string; + artifactId: string; + artifactName: string; + version: string; + relativeLink: string; + _links?: { + self: { + href: string; + }; + }; +} diff --git a/marketplace-ui/src/assets/i18n/de.yaml b/marketplace-ui/src/assets/i18n/de.yaml index 154228e2f..80671ad7a 100644 --- a/marketplace-ui/src/assets/i18n/de.yaml +++ b/marketplace-ui/src/assets/i18n/de.yaml @@ -76,6 +76,8 @@ common: status: Status moreInformation: Mehr Informationen contactUs: Kontakt + documentation: Dokumentation + defaultDocName: Doc install: buttonLabel: Jetzt installieren buttonLabelInDesigner: Installieren diff --git a/marketplace-ui/src/assets/i18n/en.yaml b/marketplace-ui/src/assets/i18n/en.yaml index 951849ed8..37477fcaa 100644 --- a/marketplace-ui/src/assets/i18n/en.yaml +++ b/marketplace-ui/src/assets/i18n/en.yaml @@ -80,6 +80,8 @@ common: status: Status moreInformation: More Information contactUs: Contact us + documentation: Documentation + defaultDocName: Doc install: buttonLabel: Install Now buttonLabelInDesigner: Install From cc45bfb3453fa330ce6573113c90f7638862f820 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tr=E1=BA=A7n=20V=C4=A9nh=20Thi=E1=BB=87n=20Ph=C3=BAc?= <143604440+tvtphuc-axonivy@users.noreply.github.com> Date: Mon, 14 Oct 2024 10:23:24 +0700 Subject: [PATCH 04/19] MARP-1032 get setup file (#188) --- .../market/constants/GitHubConstants.java | 2 + .../market/constants/ReadmeConstants.java | 1 + .../market/enums/NonStandardProduct.java | 6 ++ .../service/GHAxonIvyProductRepoService.java | 4 ++ .../impl/GHAxonIvyProductRepoServiceImpl.java | 42 ++++++++++++- .../market/util/ProductContentUtils.java | 2 +- .../java/com/axonivy/market/BaseSetup.java | 5 ++ .../GHAxonIvyProductRepoServiceImplTest.java | 59 ++++++++++++++++++- .../src/test/resources/setup.md | 11 ++++ 9 files changed, 127 insertions(+), 5 deletions(-) create mode 100644 marketplace-service/src/test/resources/setup.md diff --git a/marketplace-service/src/main/java/com/axonivy/market/constants/GitHubConstants.java b/marketplace-service/src/main/java/com/axonivy/market/constants/GitHubConstants.java index 1e99d7621..3419c083f 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/constants/GitHubConstants.java +++ b/marketplace-service/src/main/java/com/axonivy/market/constants/GitHubConstants.java @@ -14,6 +14,8 @@ public class GitHubConstants { public static final String README_FILE_LOCALE_REGEX = "_(..)"; public static final String STANDARD_TAG_PREFIX = "v"; public static final String COMMON_IMAGES_FOLDER_NAME = "images"; + public static final String MS_GRAPH_PRODUCT_DIRECTORY = "msgraph-connector-product"; + public static final String MG_GRAPH_IMAGES_FOR_SETUP_FILE = "doc"; @NoArgsConstructor(access = AccessLevel.PRIVATE) public static class Json { diff --git a/marketplace-service/src/main/java/com/axonivy/market/constants/ReadmeConstants.java b/marketplace-service/src/main/java/com/axonivy/market/constants/ReadmeConstants.java index 2f4e4f946..d2e11abd7 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/constants/ReadmeConstants.java +++ b/marketplace-service/src/main/java/com/axonivy/market/constants/ReadmeConstants.java @@ -10,4 +10,5 @@ public class ReadmeConstants { public static final String README_FILE_NAME = "README"; public static final String DEMO_PART = "## Demo"; public static final String SETUP_PART = "## Setup"; + public static final String SETUP_FILE = "setup.md"; } diff --git a/marketplace-service/src/main/java/com/axonivy/market/enums/NonStandardProduct.java b/marketplace-service/src/main/java/com/axonivy/market/enums/NonStandardProduct.java index 68b1f72cf..fa2c08c15 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/enums/NonStandardProduct.java +++ b/marketplace-service/src/main/java/com/axonivy/market/enums/NonStandardProduct.java @@ -5,6 +5,7 @@ import org.apache.commons.lang3.StringUtils; import java.util.Arrays; +import java.util.List; import java.util.Map; import java.util.function.Function; import java.util.stream.Collectors; @@ -66,4 +67,9 @@ public static String findById(String id, String currentPath) { String nonStandardPath = findById(id).pathToProductFolder; return StringUtils.isNotBlank(nonStandardPath) ? nonStandardPath : currentPath; } + + public static boolean isMsGraphProduct(String productId) { + return List.of(MICROSOFT_REPO_NAME.id, MICROSOFT_365.id, + MICROSOFT_CALENDAR.id, MICROSOFT_MAIL.id, MICROSOFT_TEAMS.id, MICROSOFT_TODO.id).contains(productId); + } } \ No newline at end of file diff --git a/marketplace-service/src/main/java/com/axonivy/market/github/service/GHAxonIvyProductRepoService.java b/marketplace-service/src/main/java/com/axonivy/market/github/service/GHAxonIvyProductRepoService.java index b4b8821c1..1ad461751 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/github/service/GHAxonIvyProductRepoService.java +++ b/marketplace-service/src/main/java/com/axonivy/market/github/service/GHAxonIvyProductRepoService.java @@ -8,6 +8,7 @@ import java.io.IOException; import java.util.List; +import java.util.Map; public interface GHAxonIvyProductRepoService { @@ -19,4 +20,7 @@ public interface GHAxonIvyProductRepoService { void extractReadMeFileFromContents(Product product, List contents, ProductModuleContent productModuleContent); + + void updateSetupPartForProductModuleContent(Product product, + Map> moduleContents, String tag) throws IOException; } diff --git a/marketplace-service/src/main/java/com/axonivy/market/github/service/impl/GHAxonIvyProductRepoServiceImpl.java b/marketplace-service/src/main/java/com/axonivy/market/github/service/impl/GHAxonIvyProductRepoServiceImpl.java index 45d681b8e..e0613b209 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/github/service/impl/GHAxonIvyProductRepoServiceImpl.java +++ b/marketplace-service/src/main/java/com/axonivy/market/github/service/impl/GHAxonIvyProductRepoServiceImpl.java @@ -8,6 +8,7 @@ import com.axonivy.market.constants.ReadmeConstants; import com.axonivy.market.entity.Product; import com.axonivy.market.entity.ProductModuleContent; +import com.axonivy.market.enums.Language; import com.axonivy.market.enums.NonStandardProduct; import com.axonivy.market.github.service.GHAxonIvyProductRepoService; import com.axonivy.market.github.service.GitHubService; @@ -18,6 +19,7 @@ import com.axonivy.market.util.VersionUtils; import lombok.extern.log4j.Log4j2; import org.apache.commons.io.IOUtils; +import org.apache.commons.lang3.ObjectUtils; import org.kohsuke.github.GHContent; import org.kohsuke.github.GHOrganization; import org.kohsuke.github.GHRepository; @@ -37,6 +39,10 @@ import java.util.Optional; import static com.axonivy.market.constants.CommonConstants.IMAGE_ID_PREFIX; +import static com.axonivy.market.constants.GitHubConstants.MG_GRAPH_IMAGES_FOR_SETUP_FILE; +import static com.axonivy.market.constants.GitHubConstants.MS_GRAPH_PRODUCT_DIRECTORY; +import static com.axonivy.market.constants.ReadmeConstants.SETUP_FILE; +import static com.axonivy.market.util.ProductContentUtils.SETUP; @Log4j2 @Service @@ -56,7 +62,9 @@ public GHAxonIvyProductRepoServiceImpl(GitHubService gitHubService, ImageService private static GHContent getProductJsonFile(List contents) { return contents.stream().filter(GHContent::isFile) - .filter(content -> ProductJsonConstants.PRODUCT_JSON_FILE.equals(content.getName())).findFirst().orElse(null); + .filter(content -> ProductJsonConstants.PRODUCT_JSON_FILE.equals(content.getName())) + .findFirst() + .orElse(null); } @Override @@ -111,6 +119,8 @@ public void extractReadMeFileFromContents(Product product, List conte readmeContents = updateImagesWithDownloadUrl(product, contents, readmeContents); } ProductContentUtils.getExtractedPartsOfReadme(moduleContents, readmeContents, readmeFile.getName()); + updateSetupPartForProductModuleContent(product, moduleContents, + productModuleContent.getTag()); } ProductContentUtils.updateProductModuleTabContents(productModuleContent, moduleContents); } @@ -119,6 +129,36 @@ public void extractReadMeFileFromContents(Product product, List conte } } + @Override + public void updateSetupPartForProductModuleContent(Product product, + Map> moduleContents, String tag) throws IOException { + if (!NonStandardProduct.isMsGraphProduct(product.getId())) { + return; + } + + GHRepository ghRepository = gitHubService.getRepository(product.getRepositoryName()); + List contents = ghRepository.getDirectoryContent(MS_GRAPH_PRODUCT_DIRECTORY, tag); + + GHContent setupFile = contents.stream().filter(GHContent::isFile) + .filter(content -> content.getName().equalsIgnoreCase(SETUP_FILE)) + .findFirst().orElse(null); + + if (ObjectUtils.isNotEmpty(setupFile)) { + String setupContent = new String(setupFile.read().readAllBytes()); + if (ProductContentUtils.hasImageDirectives(setupContent)) { + List setupImagesFolder = + contents.stream().filter(content -> content.getName().equals(MG_GRAPH_IMAGES_FOR_SETUP_FILE)).toList(); + setupContent = updateImagesWithDownloadUrl(product, setupImagesFolder, setupContent); + } + + if (setupContent.contains(ReadmeConstants.SETUP_PART)) { + List extractSetupContent = List.of(setupContent.split(ReadmeConstants.SETUP_PART)); + setupContent = ProductContentUtils.removeFirstLine(extractSetupContent.get(1)); + } + ProductContentUtils.addLocaleContent(moduleContents, SETUP, setupContent, Language.EN.getValue()); + } + } + private void updateDependencyContentsFromProductJson(ProductModuleContent productModuleContent, List contents, Product product) throws IOException { GHContent productJsonFile = getProductJsonFile(contents); diff --git a/marketplace-service/src/main/java/com/axonivy/market/util/ProductContentUtils.java b/marketplace-service/src/main/java/com/axonivy/market/util/ProductContentUtils.java index f8ba2d5e2..6ca43bb7b 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/util/ProductContentUtils.java +++ b/marketplace-service/src/main/java/com/axonivy/market/util/ProductContentUtils.java @@ -90,7 +90,7 @@ public static void getExtractedPartsOfReadme(Map> mo addLocaleContent(moduleContents, SETUP, setup.trim(), locale); } - private static void addLocaleContent(Map> moduleContents, String type, String content, + public static void addLocaleContent(Map> moduleContents, String type, String content, String locale) { moduleContents.computeIfAbsent(type, key -> new HashMap<>()).put(locale, content); } diff --git a/marketplace-service/src/test/java/com/axonivy/market/BaseSetup.java b/marketplace-service/src/test/java/com/axonivy/market/BaseSetup.java index a8cfc76d6..ddcfe5af2 100644 --- a/marketplace-service/src/test/java/com/axonivy/market/BaseSetup.java +++ b/marketplace-service/src/test/java/com/axonivy/market/BaseSetup.java @@ -50,6 +50,7 @@ public class BaseSetup { protected static final String MOCK_METADATA_FILE_PATH = "src/test/resources/metadata.xml"; protected static final String MOCK_SNAPSHOT_METADATA_FILE_PATH = "src/test/resources/snapshotMetadata.xml"; protected static final String INAVALID_FILE_PATH = "test/file/path"; + protected static final String MOCK_SETUP_MD_PATH = "src/test/resources/setup.md"; protected static final String MOCK_MAVEN_URL = "https://maven.axonivy.com/com/axonivy/util/bpmn-statistic/maven" + "-metadata.xml"; protected static final String MOCK_SNAPSHOT_MAVEN_URL = "https://maven.axonivy.com/com/axonivy/util/bpmn-statistic" + @@ -104,6 +105,10 @@ protected static ProductJsonContent getMockProductJsonContent() { return result; } + protected static String getMockSetupMd() { + return getContentFromTestResourcePath(MOCK_SETUP_MD_PATH); + } + private static String getContentFromTestResourcePath(String path) { try { return Files.readString(Paths.get(path)); diff --git a/marketplace-service/src/test/java/com/axonivy/market/service/impl/GHAxonIvyProductRepoServiceImplTest.java b/marketplace-service/src/test/java/com/axonivy/market/service/impl/GHAxonIvyProductRepoServiceImplTest.java index e047768de..82abce976 100644 --- a/marketplace-service/src/test/java/com/axonivy/market/service/impl/GHAxonIvyProductRepoServiceImplTest.java +++ b/marketplace-service/src/test/java/com/axonivy/market/service/impl/GHAxonIvyProductRepoServiceImplTest.java @@ -7,6 +7,7 @@ import com.axonivy.market.constants.ProductJsonConstants; import com.axonivy.market.constants.ReadmeConstants; import com.axonivy.market.entity.Image; +import com.axonivy.market.entity.Product; import com.axonivy.market.enums.Language; import com.axonivy.market.github.service.GitHubService; import com.axonivy.market.github.service.impl.GHAxonIvyProductRepoServiceImpl; @@ -14,6 +15,7 @@ import com.axonivy.market.repository.ProductJsonContentRepository; import com.axonivy.market.service.ImageService; import com.axonivy.market.util.MavenUtils; +import com.axonivy.market.util.ProductContentUtils; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -38,11 +40,18 @@ import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; - +import java.util.Map; + +import static com.axonivy.market.constants.GitHubConstants.MG_GRAPH_IMAGES_FOR_SETUP_FILE; +import static com.axonivy.market.constants.GitHubConstants.MS_GRAPH_PRODUCT_DIRECTORY; +import static com.axonivy.market.constants.MongoDBConstants.TAG; +import static com.axonivy.market.constants.ProductJsonConstants.EN_LANGUAGE; +import static com.axonivy.market.constants.ReadmeConstants.SETUP_FILE; +import static com.axonivy.market.enums.NonStandardProduct.MICROSOFT_TEAMS; import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -391,4 +400,48 @@ void testExtractedContentStream() { assertNull(GitHubUtils.extractedContentStream(null)); assertNull(GitHubUtils.extractedContentStream(content)); } + + @Test + void testUpdateProductModuleContentSetupFromSetupMd() throws IOException { + when(gitHubService.getRepository(anyString())).thenReturn(ghRepository); + + InputStream mockReadmeInputStream = mock(InputStream.class); + + String setupStringContent = getMockSetupMd(); + + GHContent setupFileContent = mock(GHContent.class); + when(setupFileContent.isFile()).thenReturn(true); + when(setupFileContent.getName()).thenReturn(SETUP_FILE); + when(setupFileContent.read()).thenReturn(mockReadmeInputStream); + when(mockReadmeInputStream.readAllBytes()).thenReturn(setupStringContent.getBytes()); + + GHContent setupImageContent = mock(GHContent.class); + when(setupImageContent.isDirectory()).thenReturn(true); + when(setupImageContent.getName()).thenReturn(MG_GRAPH_IMAGES_FOR_SETUP_FILE); + + GHContent mockImageFile3 = mock(GHContent.class); + when(mockImageFile3.getName()).thenReturn(IMAGE_NAME); + + PagedIterable pagedIterable = mock(String.valueOf(GHContent.class)); + when(setupImageContent.listDirectoryContent()).thenReturn(pagedIterable); + when(pagedIterable.toList()).thenReturn(List.of(mockImageFile3)); + + when(ghRepository.getDirectoryContent(MS_GRAPH_PRODUCT_DIRECTORY, TAG)) + .thenReturn(List.of(setupFileContent, setupImageContent)); + + when(imageService.mappingImageFromGHContent(any(), any(), anyBoolean())).thenReturn(mockImage()); + + Product mockProduct = Product.builder() + .id(MICROSOFT_TEAMS.getId()) + .repositoryName("market/connector/microsoft365/chat/") + .build(); + Map> moduleContents = new HashMap<>(); + + moduleContents.put(ProductContentUtils.SETUP, new HashMap<>(Map.of(EN_LANGUAGE, "setup file content"))); + + axonivyProductRepoServiceImpl.updateSetupPartForProductModuleContent(mockProduct, moduleContents, TAG); + + assertTrue(moduleContents.get(ProductContentUtils.SETUP).get(EN_LANGUAGE).contains(ProductContentUtils.removeFirstLine( + setupStringContent.replace(IMAGE_NAME, "imageId-66e2b14868f2f95b2f95549a")))); + } } diff --git a/marketplace-service/src/test/resources/setup.md b/marketplace-service/src/test/resources/setup.md new file mode 100644 index 000000000..46d23b7f0 --- /dev/null +++ b/marketplace-service/src/test/resources/setup.md @@ -0,0 +1,11 @@ +## Setup +### Variables +In order to use this product you must configure multiple variables. + +Add the following block to your `config/variables.yaml` file of ours +main Business Project that will make use of this product: + +``` +@variables.yaml@ +``` +![set-redirect](image.png) \ No newline at end of file From 354dcd803c63a4a0f375c554bb08c2240e2c377e Mon Sep 17 00:00:00 2001 From: Pham Hoang Hung <84316773+phhung-axonivy@users.noreply.github.com> Date: Mon, 14 Oct 2024 10:31:42 +0700 Subject: [PATCH 05/19] MARP-1093 Write api that sync only one product (#179) --- .../constants/RequestMappingConstants.java | 1 + .../constants/RequestParamConstants.java | 3 + .../market/controller/ProductController.java | 48 ++++++- .../market/factory/ProductFactory.java | 1 + .../service/GHAxonIvyMarketRepoService.java | 2 + .../impl/GHAxonIvyMarketRepoServiceImpl.java | 11 ++ .../market/repository/MetadataRepository.java | 2 + .../repository/MetadataSyncRepository.java | 1 + .../ProductJsonContentRepository.java | 2 + .../ProductModuleContentRepository.java | 2 + .../market/service/MetadataService.java | 4 + .../market/service/ProductService.java | 1 + .../service/impl/MetadataServiceImpl.java | 81 ++++++------ .../service/impl/ProductServiceImpl.java | 119 ++++++++++++++---- .../java/com/axonivy/market/BaseSetup.java | 1 + .../controller/ProductControllerTest.java | 50 +++++++- .../GHAxonIvyMarketRepoServiceImplTest.java | 10 ++ .../service/impl/ProductServiceImplTest.java | 47 +++++-- 18 files changed, 315 insertions(+), 71 deletions(-) diff --git a/marketplace-service/src/main/java/com/axonivy/market/constants/RequestMappingConstants.java b/marketplace-service/src/main/java/com/axonivy/market/constants/RequestMappingConstants.java index 89fc726bb..1f811fd7c 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/constants/RequestMappingConstants.java +++ b/marketplace-service/src/main/java/com/axonivy/market/constants/RequestMappingConstants.java @@ -14,6 +14,7 @@ public class RequestMappingConstants { public static final String IMAGE = API + "/image"; public static final String SYNC = "sync"; public static final String SYNC_PRODUCT_VERSION = SYNC + "/product-version"; + public static final String SYNC_ONE_PRODUCT_BY_ID = "sync/{id}"; public static final String SWAGGER_URL = "/swagger-ui/index.html"; public static final String GIT_HUB_LOGIN = "/github/login"; public static final String AUTH = "/auth"; diff --git a/marketplace-service/src/main/java/com/axonivy/market/constants/RequestParamConstants.java b/marketplace-service/src/main/java/com/axonivy/market/constants/RequestParamConstants.java index b1fff6169..4dc26f44c 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/constants/RequestParamConstants.java +++ b/marketplace-service/src/main/java/com/axonivy/market/constants/RequestParamConstants.java @@ -16,4 +16,7 @@ public class RequestParamConstants { public static final String SHOW_DEV_VERSION = "isShowDevVersion"; public static final String DESIGNER_VERSION = "designerVersion"; public static final String VERSION = "version"; + public static final String MARKET_ITEM_PATH = "marketItemPath"; + public static final String OVERRIDE_MARKET_ITEM_PATH = "overrideMarketItemPath"; + } diff --git a/marketplace-service/src/main/java/com/axonivy/market/controller/ProductController.java b/marketplace-service/src/main/java/com/axonivy/market/controller/ProductController.java index 7a0bf9491..a52d09613 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/controller/ProductController.java +++ b/marketplace-service/src/main/java/com/axonivy/market/controller/ProductController.java @@ -4,6 +4,7 @@ import com.axonivy.market.constants.GitHubConstants; import com.axonivy.market.entity.Product; import com.axonivy.market.enums.ErrorCode; +import com.axonivy.market.github.service.GHAxonIvyMarketRepoService; import com.axonivy.market.github.service.GitHubService; import com.axonivy.market.model.Message; import com.axonivy.market.model.ProductCustomSortRequest; @@ -19,6 +20,7 @@ import jakarta.validation.Valid; import lombok.AllArgsConstructor; import org.apache.commons.lang3.ObjectUtils; +import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.time.StopWatch; import org.springdoc.core.annotations.ParameterObject; import org.springframework.data.domain.Page; @@ -28,10 +30,20 @@ import org.springframework.hateoas.PagedModel; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; import java.util.List; +import org.springframework.util.CollectionUtils; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + import static com.axonivy.market.constants.RequestMappingConstants.*; import static com.axonivy.market.constants.RequestParamConstants.*; import static org.springframework.http.HttpHeaders.AUTHORIZATION; @@ -46,6 +58,7 @@ public class ProductController { private final ProductModelAssembler assembler; private final PagedResourcesAssembler pagedResourcesAssembler; private final MetadataService metadataService; + private final GHAxonIvyMarketRepoService axonIvyMarketRepoService; @GetMapping() @Operation(summary = "Retrieve a paginated list of all products, optionally filtered by type, keyword, and language", @@ -127,6 +140,39 @@ public ResponseEntity syncProductVersions(@RequestHeader(value = AUTHOR return new ResponseEntity<>(message, statusCode); } + @PutMapping(SYNC_ONE_PRODUCT_BY_ID) + @Operation(hidden = true) + public ResponseEntity syncOneProduct( + @RequestHeader(value = AUTHORIZATION) String authorizationHeader, + @PathVariable(ID) @Parameter(description = "Product Id is defined in meta.json file", example = "a-trust", + in = ParameterIn.PATH) String productId, + @RequestParam(value = MARKET_ITEM_PATH) @Parameter( + description = "Item folder path of the market in https://github.com/axonivy-market/market", + example = "market/connector/a-trust") String marketItemPath, + @RequestParam(value = OVERRIDE_MARKET_ITEM_PATH, required = false) Boolean overrideMarketItemPath) { + String token = AuthorizationUtils.getBearerToken(authorizationHeader); + gitHubService.validateUserInOrganizationAndTeam(token, GitHubConstants.AXONIVY_MARKET_ORGANIZATION_NAME, + GitHubConstants.AXONIVY_MARKET_TEAM_NAME); + + var message = new Message(); + if (StringUtils.isNotBlank(marketItemPath) && Boolean.TRUE.equals( + overrideMarketItemPath) && CollectionUtils.isEmpty( + axonIvyMarketRepoService.getMarketItemByPath(marketItemPath))) { + message.setHelpCode(ErrorCode.PRODUCT_NOT_FOUND.getCode()); + message.setMessageDetails(ErrorCode.PRODUCT_NOT_FOUND.getHelpText()); + return new ResponseEntity<>(message, HttpStatus.OK); + } + + var isSuccess = productService.syncOneProduct(productId, marketItemPath, overrideMarketItemPath); + if (isSuccess) { + message.setHelpCode(ErrorCode.SUCCESSFUL.getCode()); + message.setMessageDetails("Sync successfully!"); + } else { + message.setMessageDetails("Sync unsuccessfully!"); + } + return new ResponseEntity<>(message, HttpStatus.OK); + } + @PostMapping(CUSTOM_SORT) @Operation(hidden = true) public ResponseEntity createCustomSortProducts( diff --git a/marketplace-service/src/main/java/com/axonivy/market/factory/ProductFactory.java b/marketplace-service/src/main/java/com/axonivy/market/factory/ProductFactory.java index b4f6dc31f..922b45171 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/factory/ProductFactory.java +++ b/marketplace-service/src/main/java/com/axonivy/market/factory/ProductFactory.java @@ -81,6 +81,7 @@ public static Product mappingByMetaJSONFile(Product product, GHContent ghContent } public static void transferComputedPersistedDataToProduct(Product persisted, Product product) { + product.setMarketDirectory(persisted.getMarketDirectory()); product.setCustomOrder(persisted.getCustomOrder()); product.setNewestReleaseVersion(persisted.getNewestReleaseVersion()); product.setReleasedVersions(persisted.getReleasedVersions()); diff --git a/marketplace-service/src/main/java/com/axonivy/market/github/service/GHAxonIvyMarketRepoService.java b/marketplace-service/src/main/java/com/axonivy/market/github/service/GHAxonIvyMarketRepoService.java index a75787bd1..45523a09b 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/github/service/GHAxonIvyMarketRepoService.java +++ b/marketplace-service/src/main/java/com/axonivy/market/github/service/GHAxonIvyMarketRepoService.java @@ -17,4 +17,6 @@ public interface GHAxonIvyMarketRepoService { List fetchMarketItemsBySHA1Range(String fromSHA1, String toSHA1); GHRepository getRepository(); + + List getMarketItemByPath(String itemPath); } diff --git a/marketplace-service/src/main/java/com/axonivy/market/github/service/impl/GHAxonIvyMarketRepoServiceImpl.java b/marketplace-service/src/main/java/com/axonivy/market/github/service/impl/GHAxonIvyMarketRepoServiceImpl.java index e59833d5e..aa56cc4ad 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/github/service/impl/GHAxonIvyMarketRepoServiceImpl.java +++ b/marketplace-service/src/main/java/com/axonivy/market/github/service/impl/GHAxonIvyMarketRepoServiceImpl.java @@ -135,4 +135,15 @@ public GHRepository getRepository() { return repository; } + @Override + public List getMarketItemByPath(String itemPath) { + List ghContent = new ArrayList<>(); + try { + ghContent = gitHubService.getDirectoryContent(getRepository(), + itemPath, marketRepoBranch); + } catch (Exception e) { + log.error("Cannot fetch GHContent: ", e); + } + return ghContent; + } } \ No newline at end of file diff --git a/marketplace-service/src/main/java/com/axonivy/market/repository/MetadataRepository.java b/marketplace-service/src/main/java/com/axonivy/market/repository/MetadataRepository.java index ebd264657..57cfbe622 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/repository/MetadataRepository.java +++ b/marketplace-service/src/main/java/com/axonivy/market/repository/MetadataRepository.java @@ -7,4 +7,6 @@ public interface MetadataRepository extends MongoRepository { List findByProductId(String productId); + + void deleteAllByProductId(String productId); } diff --git a/marketplace-service/src/main/java/com/axonivy/market/repository/MetadataSyncRepository.java b/marketplace-service/src/main/java/com/axonivy/market/repository/MetadataSyncRepository.java index b4e7317aa..e124f4ef1 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/repository/MetadataSyncRepository.java +++ b/marketplace-service/src/main/java/com/axonivy/market/repository/MetadataSyncRepository.java @@ -4,4 +4,5 @@ import org.springframework.data.mongodb.repository.MongoRepository; public interface MetadataSyncRepository extends MongoRepository { + void deleteAllByProductId(String productId); } diff --git a/marketplace-service/src/main/java/com/axonivy/market/repository/ProductJsonContentRepository.java b/marketplace-service/src/main/java/com/axonivy/market/repository/ProductJsonContentRepository.java index 7b616da5f..428d1b631 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/repository/ProductJsonContentRepository.java +++ b/marketplace-service/src/main/java/com/axonivy/market/repository/ProductJsonContentRepository.java @@ -10,4 +10,6 @@ public interface ProductJsonContentRepository extends MongoRepository { List findByProductIdAndVersion(String productId, String version); + + void deleteAllByProductId(String productId); } diff --git a/marketplace-service/src/main/java/com/axonivy/market/repository/ProductModuleContentRepository.java b/marketplace-service/src/main/java/com/axonivy/market/repository/ProductModuleContentRepository.java index 1436e6d90..760f0407e 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/repository/ProductModuleContentRepository.java +++ b/marketplace-service/src/main/java/com/axonivy/market/repository/ProductModuleContentRepository.java @@ -8,4 +8,6 @@ public interface ProductModuleContentRepository extends MongoRepository, CustomProductModuleContentRepository { ProductModuleContent findByTagAndProductId(String tag, String productId); + + void deleteAllByProductId(String productId); } diff --git a/marketplace-service/src/main/java/com/axonivy/market/service/MetadataService.java b/marketplace-service/src/main/java/com/axonivy/market/service/MetadataService.java index b0e134240..bbb275f2e 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/service/MetadataService.java +++ b/marketplace-service/src/main/java/com/axonivy/market/service/MetadataService.java @@ -1,5 +1,9 @@ package com.axonivy.market.service; +import com.axonivy.market.entity.Product; + public interface MetadataService { + int syncAllProductsMetadata(); + boolean syncProductMetadata(Product product); } diff --git a/marketplace-service/src/main/java/com/axonivy/market/service/ProductService.java b/marketplace-service/src/main/java/com/axonivy/market/service/ProductService.java index 73ae5de55..eaafb2b7d 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/service/ProductService.java +++ b/marketplace-service/src/main/java/com/axonivy/market/service/ProductService.java @@ -27,4 +27,5 @@ public interface ProductService { Product fetchProductDetailByIdAndVersion(String id, String version); + boolean syncOneProduct(String productId, String marketItemPath, Boolean overrideMarketItemPath); } diff --git a/marketplace-service/src/main/java/com/axonivy/market/service/impl/MetadataServiceImpl.java b/marketplace-service/src/main/java/com/axonivy/market/service/impl/MetadataServiceImpl.java index 0d6d53dee..7ffcf8649 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/service/impl/MetadataServiceImpl.java +++ b/marketplace-service/src/main/java/com/axonivy/market/service/impl/MetadataServiceImpl.java @@ -85,48 +85,59 @@ public int syncAllProductsMetadata() { log.warn("**MetadataService: Start to sync version for {} product(s)", products.size()); int nonUpdatedSyncCount = 0; for (Product product : products) { - // Set up cache before sync - String productId = product.getId(); - Set metadataSet = new HashSet<>(metadataRepo.findByProductId(product.getId())); - MavenArtifactVersion artifactVersionCache = mavenArtifactVersionRepo.findById(product.getId()).orElse( - MavenArtifactVersion.builder().productId(productId).build()); - MetadataSync syncCache = metadataSyncRepo.findById(product.getId()).orElse( - MetadataSync.builder().productId(product.getId()).syncedVersions(new HashSet<>()).build()); - Set artifactsFromNewTags = new HashSet<>(); - - // Find artifacts from unhandled tags - List nonSyncedVersionOfTags = VersionUtils.removeSyncedVersionsFromReleasedVersions( - product.getReleasedVersions(), syncCache.getSyncedVersions()); - if (ObjectUtils.isNotEmpty(nonSyncedVersionOfTags)) { - artifactsFromNewTags.addAll(getArtifactsFromNonSyncedVersion(product.getId(), nonSyncedVersionOfTags)); - syncCache.getSyncedVersions().addAll(nonSyncedVersionOfTags); - log.info("**MetadataService: New tags detected: {} in product {}", nonSyncedVersionOfTags.toString(), - productId); - } - - // Sync versions from maven & update artifacts-version table - metadataSet.addAll(MavenUtils.convertArtifactsToMetadataSet(artifactsFromNewTags, productId)); - if (ObjectUtils.isNotEmpty(product.getArtifacts())) { - metadataSet.addAll( - MavenUtils.convertArtifactsToMetadataSet(new HashSet<>(product.getArtifacts()), productId)); - } - if (CollectionUtils.isEmpty(metadataSet)) { - log.info("**MetadataService: No artifact found in product {}", productId); + if (!syncProductMetadata(product)) { nonUpdatedSyncCount += 1; - continue; } - artifactVersionCache.setAdditionalArtifactsByVersion(new HashMap<>()); - updateMavenArtifactVersionData(productId, product.getReleasedVersions(), metadataSet, artifactVersionCache); - - // Persist changed - metadataSyncRepo.save(syncCache); - mavenArtifactVersionRepo.save(artifactVersionCache); - metadataRepo.saveAll(metadataSet); } log.warn("**MetadataService: version sync finished"); return nonUpdatedSyncCount; } + @Override + public boolean syncProductMetadata(Product product) { + if (product == null) { + return false; + } + + // Set up cache before sync + String productId = product.getId(); + Set metadataSet = new HashSet<>(metadataRepo.findByProductId(product.getId())); + MavenArtifactVersion artifactVersionCache = mavenArtifactVersionRepo.findById(product.getId()).orElse( + MavenArtifactVersion.builder().productId(productId).build()); + MetadataSync syncCache = metadataSyncRepo.findById(product.getId()).orElse( + MetadataSync.builder().productId(product.getId()).syncedVersions(new HashSet<>()).build()); + Set artifactsFromNewTags = new HashSet<>(); + + // Find artifacts from unhandled tags + List nonSyncedVersionOfTags = VersionUtils.removeSyncedVersionsFromReleasedVersions( + product.getReleasedVersions(), syncCache.getSyncedVersions()); + if (ObjectUtils.isNotEmpty(nonSyncedVersionOfTags)) { + artifactsFromNewTags.addAll(getArtifactsFromNonSyncedVersion(product.getId(), nonSyncedVersionOfTags)); + syncCache.getSyncedVersions().addAll(nonSyncedVersionOfTags); + log.info("**MetadataService: New tags detected: {} in product {}", nonSyncedVersionOfTags.toString(), + productId); + } + + // Sync versions from maven & update artifacts-version table + metadataSet.addAll(MavenUtils.convertArtifactsToMetadataSet(artifactsFromNewTags, productId)); + if (ObjectUtils.isNotEmpty(product.getArtifacts())) { + metadataSet.addAll( + MavenUtils.convertArtifactsToMetadataSet(new HashSet<>(product.getArtifacts()), productId)); + } + if (CollectionUtils.isEmpty(metadataSet)) { + log.info("**MetadataService: No artifact found in product {}", productId); + return false; + } + artifactVersionCache.setAdditionalArtifactsByVersion(new HashMap<>()); + updateMavenArtifactVersionData(productId, product.getReleasedVersions(), metadataSet, artifactVersionCache); + + // Persist changed + metadataSyncRepo.save(syncCache); + mavenArtifactVersionRepo.save(artifactVersionCache); + metadataRepo.saveAll(metadataSet); + return true; + } + public void updateContentsFromNonMatchVersions(String productId, List releasedVersions, Metadata metadata) { List productModuleContents = new ArrayList<>(); diff --git a/marketplace-service/src/main/java/com/axonivy/market/service/impl/ProductServiceImpl.java b/marketplace-service/src/main/java/com/axonivy/market/service/impl/ProductServiceImpl.java index 424704ca0..79e77edff 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/service/impl/ProductServiceImpl.java +++ b/marketplace-service/src/main/java/com/axonivy/market/service/impl/ProductServiceImpl.java @@ -3,6 +3,7 @@ import com.axonivy.market.comparator.MavenVersionComparator; import com.axonivy.market.constants.CommonConstants; import com.axonivy.market.constants.GitHubConstants; +import com.axonivy.market.constants.MetaConstants; import com.axonivy.market.constants.ProductJsonConstants; import com.axonivy.market.criteria.ProductSearchCriteria; import com.axonivy.market.entity.GitHubRepoMeta; @@ -27,10 +28,14 @@ import com.axonivy.market.repository.GitHubRepoMetaRepository; import com.axonivy.market.repository.ImageRepository; import com.axonivy.market.repository.MavenArtifactVersionRepository; +import com.axonivy.market.repository.MetadataRepository; +import com.axonivy.market.repository.MetadataSyncRepository; import com.axonivy.market.repository.ProductCustomSortRepository; +import com.axonivy.market.repository.ProductJsonContentRepository; import com.axonivy.market.repository.ProductModuleContentRepository; import com.axonivy.market.repository.ProductRepository; import com.axonivy.market.service.ImageService; +import com.axonivy.market.service.MetadataService; import com.axonivy.market.service.ProductService; import com.axonivy.market.util.MavenUtils; import com.axonivy.market.util.VersionUtils; @@ -74,6 +79,7 @@ import java.util.function.Predicate; import static com.axonivy.market.constants.CommonConstants.SLASH; +import static com.axonivy.market.constants.MetaConstants.META_FILE; import static com.axonivy.market.constants.ProductJsonConstants.LOGO_FILE; import static com.axonivy.market.enums.DocumentField.MARKET_DIRECTORY; import static com.axonivy.market.enums.DocumentField.SHORT_DESCRIPTIONS; @@ -94,9 +100,13 @@ public class ProductServiceImpl implements ProductService { private final GitHubService gitHubService; private final ProductCustomSortRepository productCustomSortRepository; private final MavenArtifactVersionRepository mavenArtifactVersionRepo; + private final MetadataSyncRepository metadataSyncRepository; + private final MetadataRepository metadataRepository; + private final ProductJsonContentRepository productJsonContentRepository; private final ImageRepository imageRepository; private final ImageService imageService; private final MongoTemplate mongoTemplate; + private final MetadataService metadataService; private final ObjectMapper mapper = new ObjectMapper(); private final SecureRandom random = new SecureRandom(); private GHCommit lastGHCommit; @@ -111,8 +121,9 @@ public ProductServiceImpl(ProductRepository productRepository, GHAxonIvyMarketRepoService axonIvyMarketRepoService, GHAxonIvyProductRepoService axonIvyProductRepoService, GitHubRepoMetaRepository gitHubRepoMetaRepository, GitHubService gitHubService, ProductCustomSortRepository productCustomSortRepository, MavenArtifactVersionRepository mavenArtifactVersionRepo, - ImageRepository imageRepository1, - ImageService imageService, MongoTemplate mongoTemplate) { + ImageRepository imageRepository, MetadataService metadataService, MetadataSyncRepository metadataSyncRepository, + MetadataRepository metadataRepository, ImageService imageService, MongoTemplate mongoTemplate, + ProductJsonContentRepository productJsonContentRepository) { this.productRepository = productRepository; this.productModuleContentRepository = productModuleContentRepository; this.axonIvyMarketRepoService = axonIvyMarketRepoService; @@ -121,9 +132,13 @@ public ProductServiceImpl(ProductRepository productRepository, this.gitHubService = gitHubService; this.productCustomSortRepository = productCustomSortRepository; this.mavenArtifactVersionRepo = mavenArtifactVersionRepo; - this.imageRepository = imageRepository1; + this.metadataSyncRepository = metadataSyncRepository; + this.metadataRepository = metadataRepository; + this.metadataService = metadataService; + this.imageRepository = imageRepository; this.imageService = imageService; this.mongoTemplate = mongoTemplate; + this.productJsonContentRepository = productJsonContentRepository; } private static Predicate filterNonPersistGhTagName(List currentTags) { @@ -375,18 +390,6 @@ private void updateLatestReleaseTagContentsFromProductRepo() { } } - private void updateProductContentForNonStandardProduct(Map.Entry> ghContentEntity, - Product product) { - ProductModuleContent initialContent = new ProductModuleContent(); - initialContent.setTag(INITIAL_VERSION); - initialContent.setProductId(product.getId()); - ProductFactory.mappingIdForProductModuleContent(initialContent); - product.setReleasedVersions(List.of(INITIAL_VERSION)); - product.setNewestReleaseVersion(INITIAL_VERSION); - axonIvyProductRepoService.extractReadMeFileFromContents(product, ghContentEntity.getValue(), initialContent); - productModuleContentRepository.save(initialContent); - } - private void getProductContents(Product product) { try { GHRepository productRepo = gitHubService.getRepository(product.getRepositoryName()); @@ -411,12 +414,7 @@ private List syncProductsFromGitHubRepo() { if (productRepository.findById(product.getId()).isPresent()) { continue; } - if (StringUtils.isNotBlank(product.getRepositoryName())) { - updateProductCompatibility(product); - getProductContents(product); - } else { - updateProductContentForNonStandardProduct(ghContentEntity, product); - } + updateRelatedThingsOfProductFromGHContent(ghContentEntity.getValue(), product); transferComputedDataFromDB(product); syncedProductIds.add(productRepository.save(product).getId()); } @@ -424,7 +422,7 @@ private List syncProductsFromGitHubRepo() { } private void mappingLogoFromGHContent(Product product, GHContent ghContent) { - if (StringUtils.endsWith(ghContent.getName(), LOGO_FILE)) { + if (ghContent != null && StringUtils.endsWith(ghContent.getName(), LOGO_FILE)) { Optional.ofNullable(imageService.mappingImageFromGHContent(product, ghContent, true)) .ifPresent(image -> product.setLogoId(image.getId())); } @@ -591,7 +589,82 @@ public void removeFieldFromAllProductDocuments(String fieldName) { public void transferComputedDataFromDB(Product product) { productRepository.findById(product.getId()).ifPresent(persistedData -> - ProductFactory.transferComputedPersistedDataToProduct(persistedData, product)); + ProductFactory.transferComputedPersistedDataToProduct(persistedData, product) + ); } + @Override + public boolean syncOneProduct(String productId, String marketItemPath, Boolean overrideMarketItemPath) { + try { + log.info("Sync product {} is starting ...", productId); + log.info("Clean up product {}", productId); + Product product = renewProductById(productId, marketItemPath, overrideMarketItemPath); + log.info("Get data of product {} from the git hub", productId); + var gitHubContents = axonIvyMarketRepoService.getMarketItemByPath(product.getMarketDirectory()); + if (!CollectionUtils.isEmpty(gitHubContents)) { + log.info("Update data of product {} from meta.json and logo files", productId); + mappingMetaDataAndLogoFromGHContent(gitHubContents, product); + updateRelatedThingsOfProductFromGHContent(gitHubContents, product); + productRepository.save(product); + metadataService.syncProductMetadata(product); + log.info("Sync product {} is finished!", productId); + return true; + } + } catch (Exception e) { + log.error(e.getStackTrace()); + } + return false; + } + + private Product renewProductById(String productId, String marketItemPath, Boolean overrideMarketItemPath) { + Product product = new Product(); + productRepository.findById(productId).ifPresent(foundProduct -> { + ProductFactory.transferComputedPersistedDataToProduct(foundProduct, product); + imageRepository.deleteAllByProductId(foundProduct.getId()); + metadataRepository.deleteAllByProductId(foundProduct.getId()); + metadataSyncRepository.deleteAllByProductId(foundProduct.getId()); + mavenArtifactVersionRepo.deleteAllById(List.of(foundProduct.getId())); + productModuleContentRepository.deleteAllByProductId(foundProduct.getId()); + productJsonContentRepository.deleteAllByProductId(foundProduct.getId()); + productRepository.delete(foundProduct); + } + ); + + if (StringUtils.isNotBlank(marketItemPath) && Boolean.TRUE.equals(overrideMarketItemPath)) { + product.setMarketDirectory(marketItemPath); + } + product.setNewestReleaseVersion(EMPTY); + + return product; + } + + private void mappingMetaDataAndLogoFromGHContent(List gitHubContent, Product product) { + var gitHubContents = new ArrayList<>(gitHubContent); + gitHubContents.sort((f1, f2) -> GitHubUtils.sortMetaJsonFirst(f1.getName(), f2.getName())); + for (var content : gitHubContent) { + ProductFactory.mappingByGHContent(product, content); + mappingLogoFromGHContent(product, content); + } + } + + private void updateRelatedThingsOfProductFromGHContent(List gitHubContents, Product product) { + if (StringUtils.isNotBlank(product.getRepositoryName())) { + updateProductCompatibility(product); + getProductContents(product); + } else { + updateProductContentForNonStandardProduct(gitHubContents, product); + } + } + + private void updateProductContentForNonStandardProduct(List ghContentEntity, + Product product) { + ProductModuleContent initialContent = new ProductModuleContent(); + initialContent.setTag(INITIAL_VERSION); + initialContent.setProductId(product.getId()); + ProductFactory.mappingIdForProductModuleContent(initialContent); + product.setReleasedVersions(List.of(INITIAL_VERSION)); + product.setNewestReleaseVersion(INITIAL_VERSION); + axonIvyProductRepoService.extractReadMeFileFromContents(product, ghContentEntity, initialContent); + productModuleContentRepository.save(initialContent); + } } diff --git a/marketplace-service/src/test/java/com/axonivy/market/BaseSetup.java b/marketplace-service/src/test/java/com/axonivy/market/BaseSetup.java index ddcfe5af2..ed5441ef0 100644 --- a/marketplace-service/src/test/java/com/axonivy/market/BaseSetup.java +++ b/marketplace-service/src/test/java/com/axonivy/market/BaseSetup.java @@ -30,6 +30,7 @@ @Log4j2 public class BaseSetup { protected static final String SAMPLE_PRODUCT_ID = "amazon-comprehend"; + protected static final String SAMPLE_PRODUCT_PATH = "/market/connector/amazon-comprehend"; protected static final String SAMPLE_PRODUCT_NAME = "prody Comprehend"; protected static final Pageable PAGEABLE = PageRequest.of(0, 20, Sort.by(SortOption.ALPHABETICALLY.getOption()).descending()); diff --git a/marketplace-service/src/test/java/com/axonivy/market/controller/ProductControllerTest.java b/marketplace-service/src/test/java/com/axonivy/market/controller/ProductControllerTest.java index a794cbe84..cb60e7829 100644 --- a/marketplace-service/src/test/java/com/axonivy/market/controller/ProductControllerTest.java +++ b/marketplace-service/src/test/java/com/axonivy/market/controller/ProductControllerTest.java @@ -7,6 +7,7 @@ import com.axonivy.market.enums.SortOption; import com.axonivy.market.enums.TypeOption; import com.axonivy.market.exceptions.model.UnauthorizedException; +import com.axonivy.market.github.service.GHAxonIvyMarketRepoService; import com.axonivy.market.github.service.GitHubService; import com.axonivy.market.model.ProductCustomSortRequest; import com.axonivy.market.service.MetadataService; @@ -15,6 +16,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.kohsuke.github.GHContent; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -32,11 +34,12 @@ import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.doThrow; -import static org.mockito.Mockito.when; +import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class ProductControllerTest { + private static final String PRODUCT_ID_SAMPLE = "a-trust"; + private static final String PRODUCT_PATH_SAMPLE = "market/connector/a-trust"; private static final String PRODUCT_NAME_SAMPLE = "Amazon Comprehend"; private static final String PRODUCT_NAME_DE_SAMPLE = "Amazon Comprehend DE"; private static final String PRODUCT_DESC_SAMPLE = "Amazon Comprehend is a AI service that uses machine learning to " + @@ -64,6 +67,9 @@ class ProductControllerTest { @Mock private MetadataService metadataService; + @Mock + private GHAxonIvyMarketRepoService axonIvyMarketRepoService; + @BeforeEach void setup() { assembler = new ProductModelAssembler(); @@ -164,6 +170,46 @@ void testSyncMavenVersionWithInvalidToken() { assertEquals(ErrorCode.GITHUB_USER_UNAUTHORIZED.getHelpText(), exception.getMessage()); } + @Test + void testSyncOneProductInvalidProductPath() { + Product product = new Product(); + product.setId("a-trust"); + when(axonIvyMarketRepoService.getMarketItemByPath(any(String.class))).thenReturn(new ArrayList<>()); + var response = productController.syncOneProduct(AUTHORIZATION_HEADER, PRODUCT_ID_SAMPLE, + PRODUCT_PATH_SAMPLE, true); + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertTrue(response.hasBody()); + assertEquals(ErrorCode.PRODUCT_NOT_FOUND.getHelpText(), response.getBody().getMessageDetails()); + } + + @Test + void testSyncOneProductSuccess() { + Product product = new Product(); + product.setId("a-trust"); + GHContent content = mock(GHContent.class); + List contents = new ArrayList<>(); + contents.add(content); + when(axonIvyMarketRepoService.getMarketItemByPath(any(String.class))).thenReturn(contents); + when(service.syncOneProduct(any(String.class), any(String.class), any(Boolean.class))).thenReturn(true); + var response = productController.syncOneProduct(AUTHORIZATION_HEADER, PRODUCT_ID_SAMPLE, + PRODUCT_PATH_SAMPLE, true); + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertTrue(response.hasBody()); + assertEquals("Sync successfully!", response.getBody().getMessageDetails()); + } + + @Test + void testSyncOneProductInvalidToken() { + doThrow(new UnauthorizedException(ErrorCode.GITHUB_USER_UNAUTHORIZED.getCode(), + ErrorCode.GITHUB_USER_UNAUTHORIZED.getHelpText())).when(gitHubService) + .validateUserInOrganizationAndTeam(any(String.class), any(String.class), any(String.class)); + + UnauthorizedException exception = assertThrows(UnauthorizedException.class, + () -> productController.syncOneProduct(INVALID_AUTHORIZATION_HEADER, PRODUCT_ID_SAMPLE, + PRODUCT_PATH_SAMPLE, false)); + + assertEquals(ErrorCode.GITHUB_USER_UNAUTHORIZED.getHelpText(), exception.getMessage()); + } @Test void testCreateCustomSortProductsSuccess() { diff --git a/marketplace-service/src/test/java/com/axonivy/market/service/impl/GHAxonIvyMarketRepoServiceImplTest.java b/marketplace-service/src/test/java/com/axonivy/market/service/impl/GHAxonIvyMarketRepoServiceImplTest.java index fc3406146..62f0c48c4 100644 --- a/marketplace-service/src/test/java/com/axonivy/market/service/impl/GHAxonIvyMarketRepoServiceImplTest.java +++ b/marketplace-service/src/test/java/com/axonivy/market/service/impl/GHAxonIvyMarketRepoServiceImplTest.java @@ -112,4 +112,14 @@ void testGetLastCommit() { var lastCommit = axonIvyMarketRepoServiceImpl.getLastCommit(0L); assertNull(lastCommit); } + + @Test + void testGetMarketItemByPath() throws IOException { + var mockGHContent = mock(GHContent.class); + List mockGhContents = new ArrayList<>(); + mockGhContents.add(mockGHContent); + when(gitHubService.getDirectoryContent(any(), any(), any())).thenReturn(mockGhContents); + var ghContents = axonIvyMarketRepoServiceImpl.getMarketItemByPath("market/connector/a-trust"); + assertEquals(1, ghContents.size()); + } } diff --git a/marketplace-service/src/test/java/com/axonivy/market/service/impl/ProductServiceImplTest.java b/marketplace-service/src/test/java/com/axonivy/market/service/impl/ProductServiceImplTest.java index 3a8385971..97e824104 100644 --- a/marketplace-service/src/test/java/com/axonivy/market/service/impl/ProductServiceImplTest.java +++ b/marketplace-service/src/test/java/com/axonivy/market/service/impl/ProductServiceImplTest.java @@ -23,10 +23,14 @@ import com.axonivy.market.repository.GitHubRepoMetaRepository; import com.axonivy.market.repository.ImageRepository; import com.axonivy.market.repository.MavenArtifactVersionRepository; +import com.axonivy.market.repository.MetadataRepository; +import com.axonivy.market.repository.MetadataSyncRepository; import com.axonivy.market.repository.ProductCustomSortRepository; +import com.axonivy.market.repository.ProductJsonContentRepository; import com.axonivy.market.repository.ProductModuleContentRepository; import com.axonivy.market.repository.ProductRepository; import com.axonivy.market.service.ImageService; +import com.axonivy.market.service.MetadataService; import com.axonivy.market.util.MavenUtils; import com.axonivy.market.util.VersionUtils; import org.junit.jupiter.api.BeforeEach; @@ -105,15 +109,21 @@ class ProductServiceImplTest extends BaseSetup { @Mock private ProductModuleContentRepository productModuleContentRepository; @Mock + private ProductJsonContentRepository productJsonContentRepository; + @Mock private GHAxonIvyMarketRepoService marketRepoService; @Mock private GitHubRepoMetaRepository repoMetaRepository; @Mock private GitHubService gitHubService; - + @Mock + private MetadataService metadataService; @Mock private ImageRepository imageRepository; - + @Mock + private MetadataRepository metadataRepository; + @Mock + private MetadataSyncRepository metadataSyncRepository; @Mock private ProductCustomSortRepository productCustomSortRepository; @Mock @@ -318,15 +328,8 @@ void testSyncProductsFirstTime() throws IOException { when(mockGHCommit.getCommitDate()).thenReturn(new Date()); when(gitHubService.getRepositoryTags(anyString())).thenReturn(List.of(mockTag)); - var mockContent = mockGHContentAsMetaJSON(); - InputStream inputStream = this.getClass().getResourceAsStream(SLASH.concat(META_FILE)); - when(mockContent.read()).thenReturn(inputStream); - - var mockContentLogo = mockGHContentAsLogo(); - List mockMetaJsonAndLogoList = new ArrayList<>(List.of(mockContent, mockContentLogo)); - Map> mockGHContentMap = new HashMap<>(); - mockGHContentMap.put(SAMPLE_PRODUCT_ID, mockMetaJsonAndLogoList); + mockGHContentMap.put(SAMPLE_PRODUCT_ID, mockMetaJsonAndLogoList()); when(marketRepoService.fetchAllMarketItems()).thenReturn(mockGHContentMap); when(productModuleContentRepository.saveAll(anyList())).thenReturn(List.of(mockReadmeProductContent())); @@ -709,4 +712,28 @@ void testUpdateNewLogoFromGitHub_ModifyLogo() throws IOException { verify(productRepository, times(1)).deleteById(anyString()); verify(imageRepository, times(1)).deleteAllByProductId(anyString()); } + + @Test + void testSyncOneProduct() throws IOException { + Product mockProduct = new Product(); + mockProduct.setId(SAMPLE_PRODUCT_ID); + mockProduct.setMarketDirectory(SAMPLE_PRODUCT_PATH); + when(productRepository.findById(anyString())).thenReturn(Optional.of(mockProduct)); + var mockContents = mockMetaJsonAndLogoList(); + when(marketRepoService.getMarketItemByPath(anyString())).thenReturn(mockContents); + when(metadataService.syncProductMetadata(any(Product.class))).thenReturn(true); + when(productRepository.save(any(Product.class))).thenReturn(mockProduct); + // Executes + var result = productService.syncOneProduct(SAMPLE_PRODUCT_PATH, SAMPLE_PRODUCT_ID, false); + assertTrue(result); + } + + private List mockMetaJsonAndLogoList() throws IOException { + var mockContent = mockGHContentAsMetaJSON(); + InputStream inputStream = this.getClass().getResourceAsStream(SLASH.concat(META_FILE)); + when(mockContent.read()).thenReturn(inputStream); + + var mockContentLogo = mockGHContentAsLogo(); + return new ArrayList<>(List.of(mockContent, mockContentLogo)); + } } From ea91c69b8f11778da29fbdaa0ef3a30febc4f5f3 Mon Sep 17 00:00:00 2001 From: Hoan Nguyen Date: Tue, 15 Oct 2024 16:10:37 +0700 Subject: [PATCH 06/19] MARP-1167 Portal externallink to documentation is not shown anymore status undefined - Handle feedback --- .../app/core/interceptors/api.interceptor.ts | 14 ++++++++------ ...oduct-detail-information-tab.component.html | 2 +- ...product-detail-information-tab.component.ts | 18 ++++++++++++++---- 3 files changed, 23 insertions(+), 11 deletions(-) diff --git a/marketplace-ui/src/app/core/interceptors/api.interceptor.ts b/marketplace-ui/src/app/core/interceptors/api.interceptor.ts index 678d2dc90..a9447f370 100644 --- a/marketplace-ui/src/app/core/interceptors/api.interceptor.ts +++ b/marketplace-ui/src/app/core/interceptors/api.interceptor.ts @@ -45,14 +45,16 @@ export const apiInterceptor: HttpInterceptorFn = (req, next) => { loadingService.show(); } + if (req.context.get(ForwardingError)) { + return next(cloneReq); + } + return next(cloneReq).pipe( catchError(error => { - if (!req.context.get(ForwardingError)) { - if (ERROR_CODES.includes(error.status)) { - router.navigate([`${ERROR_PAGE_PATH}/${error.status}`]); - } else { - router.navigate([ERROR_PAGE_PATH]); - } + if (ERROR_CODES.includes(error.status)) { + router.navigate([`${ERROR_PAGE_PATH}/${error.status}`]); + } else { + router.navigate([ERROR_PAGE_PATH]); } return EMPTY; }), diff --git a/marketplace-ui/src/app/modules/product/product-detail/product-detail-information-tab/product-detail-information-tab.component.html b/marketplace-ui/src/app/modules/product/product-detail/product-detail-information-tab/product-detail-information-tab.component.html index cd306a61b..f110f2a8e 100644 --- a/marketplace-ui/src/app/modules/product/product-detail/product-detail-information-tab/product-detail-information-tab.component.html +++ b/marketplace-ui/src/app/modules/product/product-detail/product-detail-information-tab/product-detail-information-tab.component.html @@ -48,7 +48,7 @@

{{ 'common.product.detail.information.value.documentation' | translate }} - + {{ displayExternalDocName ?? 'common.product.detail.information.value.defaultDocName' | translate }}

diff --git a/marketplace-ui/src/app/modules/product/product-detail/product-detail-information-tab/product-detail-information-tab.component.ts b/marketplace-ui/src/app/modules/product/product-detail/product-detail-information-tab/product-detail-information-tab.component.ts index 2a847c82f..bab1515bd 100644 --- a/marketplace-ui/src/app/modules/product/product-detail/product-detail-information-tab/product-detail-information-tab.component.ts +++ b/marketplace-ui/src/app/modules/product/product-detail/product-detail-information-tab/product-detail-information-tab.component.ts @@ -5,6 +5,8 @@ import { ProductDetail } from '../../../../shared/models/product-detail.model'; import { LanguageService } from '../../../../core/services/language/language.service'; import { ProductDetailService } from '../product-detail.service'; import { VERSION } from '../../../../shared/constants/common.constant'; +import { error } from 'console'; +import { LoadingService } from '../../../../core/services/loading/loading.service'; const SELECTED_VERSION = 'selectedVersion'; const PRODUCT_DETAIL = 'productDetail'; @@ -25,6 +27,7 @@ export class ProductDetailInformationTabComponent implements OnChanges { displayExternalDocName: string | null = ''; languageService = inject(LanguageService); productDetailService = inject(ProductDetailService); + loadingService = inject(LoadingService); ngOnChanges(changes: SimpleChanges): void { let version = ''; @@ -44,10 +47,17 @@ export class ProductDetailInformationTabComponent implements OnChanges { } this.productDetailService.getExteralDocumentForProductByVersion(this.productDetail.id, this.extractVersionValue(version)) - .subscribe(response => { - this.externalDocumentLink = response.relativeLink; - this.displayExternalDocName = response.artifactName; - }); + .subscribe({ + next: response => { + this.externalDocumentLink = response.relativeLink; + this.displayExternalDocName = response.artifactName; + }, + error: () => { + this.externalDocumentLink = ''; + this.displayExternalDocName = ''; + this.loadingService.hide(); + } + }); this.displayVersion = this.extractVersionValue(this.selectedVersion); } From ce6ead08e592e10b4967c7ae2156495441f0dcae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tr=E1=BA=A7n=20V=C4=A9nh=20Thi=E1=BB=87n=20Ph=C3=BAc?= <143604440+tvtphuc-axonivy@users.noreply.github.com> Date: Tue, 15 Oct 2024 16:40:16 +0700 Subject: [PATCH 07/19] MARP-1074 Several UI adaptations (#197) --- marketplace-ui/src/app/app.component.scss | 2 +- ...duct-detail-information-tab.component.html | 4 +- ...duct-detail-information-tab.component.scss | 6 ++- ...roduct-detail-maven-content.component.scss | 9 +++- ...oduct-detail-version-action.component.scss | 6 +-- .../product-detail.component.html | 6 +-- .../product-detail.component.scss | 20 +++++--- ...t-installation-count-action.component.scss | 4 +- .../product-star-rating-number.component.scss | 2 +- .../product-filter.component.html | 2 +- .../product-filter.component.scss | 12 ++++- .../modules/product/product.component.html | 32 ++++++------ .../modules/product/product.component.scss | 50 ++++++++++++++++++- .../common-dropdown.component.scss | 4 +- .../components/footer/footer.component.html | 9 ++-- .../components/footer/footer.component.scss | 43 +++++++++++++--- .../navigation/navigation.component.scss | 11 ++-- .../search-bar/search-bar.component.html | 4 +- .../star-rating/star-rating.component.scss | 2 +- marketplace-ui/src/styles.scss | 6 ++- 20 files changed, 173 insertions(+), 61 deletions(-) diff --git a/marketplace-ui/src/app/app.component.scss b/marketplace-ui/src/app/app.component.scss index dba00e98b..693e97949 100644 --- a/marketplace-ui/src/app/app.component.scss +++ b/marketplace-ui/src/app/app.component.scss @@ -32,7 +32,7 @@ footer { top: 0; left: 0; width: 100vw; - padding-bottom: 3rem; + padding-bottom: 7rem; } .header-mobile-container { diff --git a/marketplace-ui/src/app/modules/product/product-detail/product-detail-information-tab/product-detail-information-tab.component.html b/marketplace-ui/src/app/modules/product/product-detail/product-detail-information-tab/product-detail-information-tab.component.html index f110f2a8e..3128d9571 100644 --- a/marketplace-ui/src/app/modules/product/product-detail/product-detail-information-tab/product-detail-information-tab.component.html +++ b/marketplace-ui/src/app/modules/product/product-detail/product-detail-information-tab/product-detail-information-tab.component.html @@ -1,4 +1,4 @@ -

+

{{ 'common.product.detail.information.label' | translate }}

@@ -106,4 +106,4 @@

@@ -119,7 +119,7 @@

@@ -177,7 +177,7 @@

} } - } + }