From a92221576c45d75fdb5fcf2cd1544e621551f0a1 Mon Sep 17 00:00:00 2001 From: "Vila,Jordi (IT EDP)" Date: Fri, 17 Apr 2026 16:35:26 +0200 Subject: [PATCH 1/2] Update latest contracts with extra tokens --- openapi-specs/component-catalog-v1.0.0.yaml | 187 ++++++++++++++++-- .../projects-info-service-v1.0.0.yaml | 20 -- src/app/app.component.ts | 31 ++- src/app/services/catalog.service.spec.ts | 18 +- src/app/services/catalog.service.ts | 20 +- src/app/services/project.service.spec.ts | 116 ++--------- src/app/services/project.service.ts | 46 ++--- 7 files changed, 225 insertions(+), 213 deletions(-) diff --git a/openapi-specs/component-catalog-v1.0.0.yaml b/openapi-specs/component-catalog-v1.0.0.yaml index 73f9788..82ca393 100644 --- a/openapi-specs/component-catalog-v1.0.0.yaml +++ b/openapi-specs/component-catalog-v1.0.0.yaml @@ -48,12 +48,6 @@ paths: required: true schema: type: string - - name: accessToken - in: query - description: access token for azure queries. - required: true - schema: - type: string responses: "200": description: A list of Project Component Information @@ -162,6 +156,72 @@ paths: application/json: schema: $ref: '#/components/schemas/RestErrorMessage' + /catalog-items/slug/{slug}: + get: + tags: + - CatalogItems + summary: Returns the CatalogItem associated to the provided slug. + description: > + Returns the CatalogItem identified by a composite slug with format `{project-key}_{catalog-item-repository-name}`.
+ The separator is the first underscore (`_`); everything after it (the repo name) may itself contain underscores.
+ The project-key is the normalised (lowercase) Bitbucket project key that owns the item's repository. + The catalog-item-repository-name is matched against the Bitbucket repository slug of the item.
+ Returns 404 if no catalog item matches the provided slug. + operationId: getCatalogItemBySlug + parameters: + - name: slug + in: path + description: > + Composite slug with format `{project-key}_{catalog-item-repository-name}`. + The separator is the first underscore; the repo name may contain additional underscores. + Example: `myproject_my-component-repo` + required: true + schema: + type: string + example: 'myproject_my-component-repo' + responses: + "200": + description: The CatalogItem. + content: + application/json: + schema: + $ref: '#/components/schemas/CatalogItem' + "400": + description: Invalid or malformed slug provided. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "401": + description: Invalid client token on the request. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "403": + description: Insufficient permissions for the client to access the resource. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "404": + description: No CatalogItem associated to the provided slug. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "422": + description: Invalid CatalogItem associated to the provided slug. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "500": + description: Server error. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' /catalog-items: get: tags: @@ -291,12 +351,6 @@ paths: required: true schema: type: string - - name: accessToken - in: query - description: access token for azure queries. - required: true - schema: - type: string - name: sortByTitle in: query description: Sort the returned CatalogItems by title, either in ascending or descending order. @@ -370,12 +424,6 @@ paths: required: true schema: type: string - - name: accessToken - in: query - description: access token for azure queries. - required: true - schema: - type: string responses: "200": description: The CatalogItem. @@ -642,9 +690,9 @@ paths: put: tags: - ProvisionerActions - summary: Notify provisioning Status Update + summary: Create new project component description: > - This endpoint receives provisioning status update notifications from AWX. + This endpoint will create a new project component. operationId: notifyProvisioningStatusUpdate parameters: - name: project-key @@ -678,6 +726,45 @@ paths: description: Insufficient permissions for the client to access the resource. "500": description: Server error. + patch: + tags: + - ProvisionerActions + summary: Update an existing project component + description: > + This endpoint receives provisioning status update notifications from AWX. + operationId: notifyProvisioningStatusUpdatePartially + parameters: + - name: project-key + in: path + description: Project key of the provisioned component. + required: true + schema: + type: string + - name: status + in: path + description: Provisioning status for the component. + required: true + example: CREATING + schema: + type: string + enum: [ CREATING, CREATED, FAILED, DELETING, UNKNOWN ] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ProvisioningStatusUpdateRequest' + responses: + "200": + description: Provisioning status update completed. + "400": + description: Bad request. + "401": + description: Invalid client token on the request. + "403": + description: Insufficient permissions for the client to access the resource. + "500": + description: Server error. /provision/{project-key}: delete: @@ -787,6 +874,12 @@ components: id: type: string example: 'aSdFam...yCg==' + slug: + type: string + description: > + Composite slug computed from the normalised Bitbucket project key and the repository slug of the item, + in the format `{project-key}_{repo-name}`. Calculated at mapping time; not retrieved from Bitbucket. + example: 'myproject_my-component-repo' path: type: string example: projects/SOMEPROJECT/repos/some-repo/raw/CatalogItem.yaml?at=refs/heads/master @@ -836,6 +929,7 @@ components: - date example: id: aSdFam...yCg== + slug: myproject_some-repo path: projects/SOMEPROJECT/repos/some-repo/raw/CatalogItem.yaml?at=refs/heads/master title: An item title shortDescription: This is a short description for the item @@ -974,6 +1068,10 @@ components: type: string nullable: true example: 'some hint for a workflow' + sendOnDeletion: + type: boolean + nullable: true + example: false visible: type: boolean example: 'true' @@ -1096,15 +1194,40 @@ components: pattern: '^(?!\s*$).+' # reject whitespace-only description: The componentId set by the user. example: "any-component-id-from-backend" + catalogItemId: type: string description: The base64 encoded path for the catalogItem. It may include branch reference. example: "cHJvamVjdHMvQ0FURVNUL3JlcG9zL3VzZXItYWN0aW9ucy1pdGVtL3Jhdy9DYXRhbG9nSXRlbS55YW1sP2F0PXJlZnMvaGVhZHMvbWFzdGVy" + componentUrl: type: string description: the repository url where the component was provisioned example: "https://bitbucket.com/projects/DEVSTACK/repos/devstack-component-catalog" nullable: true + + parameters: + type: array + description: List of name/value string parameters. + items: + type: object + required: + - name + - value + properties: + name: + type: string + description: Parameter name + example: "environment" + values: + type: array + description: Parameter values + items: + type: string + example: + - "production" + - "staging" + ProvisioningDeleteRequest: type: object properties: @@ -1113,4 +1236,26 @@ components: minLength: 1 # disallows empty string "" pattern: '^(?!\s*$).+' # reject whitespace-only description: The componentId set by the user. - example: "any-component-id-from-backend" \ No newline at end of file + example: "any-component-id-from-backend" + + parameters: + type: array + description: List of name/value string parameters. + items: + type: object + required: + - name + - value + properties: + name: + type: string + description: Parameter name + example: "environment" + values: + type: array + description: Parameter values + items: + type: string + example: + - "production" + - "staging" \ No newline at end of file diff --git a/openapi-specs/projects-info-service-v1.0.0.yaml b/openapi-specs/projects-info-service-v1.0.0.yaml index 251ab8f..b7532fd 100644 --- a/openapi-specs/projects-info-service-v1.0.0.yaml +++ b/openapi-specs/projects-info-service-v1.0.0.yaml @@ -34,13 +34,6 @@ paths: description: > This endpoint receives an azure token, and returns all the groups associated to the user. operationId: getAzureGroups - parameters: - - name: token - in: header - required: true - schema: - type: string - description: Azure token used to get the groups. responses: "200": description: List of azure groups associated to the user. @@ -77,13 +70,6 @@ paths: Get all the projects a user get access to. For that, first of all it will get all the azure groups associated to the user, and then it will get all the projects associated to those groups. operationId: getProjects - parameters: - - name: token - in: header - required: true - schema: - type: string - description: Azure token used to get the groups. responses: "200": description: List of projects the user has access to. @@ -120,12 +106,6 @@ paths: Get all project info and cluster for a given project key. operationId: getProjectClusters parameters: - - name: token - in: header - required: true - schema: - type: string - description: Azure token used to get the groups. - name: projectKey in: path required: true diff --git a/src/app/app.component.ts b/src/app/app.component.ts index dfcb6ea..ff5257f 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -139,25 +139,22 @@ export class AppComponent implements OnInit, OnDestroy { // Apply optimistic UI, start with it and later apply validations after fetching projects to avoid empty parts this.projectPicker = {...this.projectPicker, label: 'Project: ', selected: currentProjectForUi.projectKey}; } - this.azureService.getAccessToken().then((accessToken: string) => { - this.projectService.getUserProjects(accessToken).subscribe((projects: string[]) => { - user.projects = projects; - this.initializeNats(user); - if (projects.length > 0) { - const latestCurrentProject = this.projectService.getCurrentProject(); - if (latestCurrentProject != null && projects.includes(latestCurrentProject.projectKey)) { - this.pickProject(latestCurrentProject.projectKey); - this.projectPicker = {...this.projectPicker, label: 'Project: ', selected: latestCurrentProject.projectKey, options: projects}; - } else { - this.pickProject(projects[0]); - this.projectPicker = {...this.projectPicker, label: 'Project: ', selected: projects[0], options: projects}; - } + this.projectService.getUserProjects().subscribe((projects: string[]) => { + user.projects = projects; + this.initializeNats(user); + if (projects.length > 0) { + const latestCurrentProject = this.projectService.getCurrentProject(); + if (latestCurrentProject != null && projects.includes(latestCurrentProject.projectKey)) { + this.pickProject(latestCurrentProject.projectKey); + this.projectPicker = {...this.projectPicker, label: 'Project: ', selected: latestCurrentProject.projectKey, options: projects}; } else { - this.pickProject(null); - this.projectPicker = {...this.projectPicker, label: 'Select project', selected: undefined, options: [] }; + this.pickProject(projects[0]); + this.projectPicker = {...this.projectPicker, label: 'Project: ', selected: projects[0], options: projects}; } - - }); + } else { + this.pickProject(null); + this.projectPicker = {...this.projectPicker, label: 'Select project', selected: undefined, options: [] }; + } }); } }); diff --git a/src/app/services/catalog.service.spec.ts b/src/app/services/catalog.service.spec.ts index 0dfdb4c..03655be 100644 --- a/src/app/services/catalog.service.spec.ts +++ b/src/app/services/catalog.service.spec.ts @@ -6,8 +6,6 @@ import { BASE_PATH, Catalog, CatalogDescriptor, CatalogDescriptorsService, Catal import { of, throwError } from 'rxjs'; import { provideHttpClient } from '@angular/common/http'; import { AppProduct } from '../models/app-product'; -import { AzureService } from './azure.service'; - const currentDate = new Date(); @@ -21,8 +19,6 @@ const fakeProductsFromItems: AppProduct[] = [ { id: '2', title: 'Product 2', shortDescription: 'Short description 2', description: 'Description 2', image: undefined, tags: [{label: 'cat1', options: []}, {label: '', options: ['tag-without-label']}], authors: ['author2'], date: currentDate, actions: [{label: 'action', id: 'name', triggerMessage: 'fake', url: 'url', requestable: false, restrictionMessage: '', parameters: [{name: 'name', label: 'label', required: true, type: 'string', visible: true, defaultValue: 'test', validations: [{regex: '*', errorMessage: 'Error'}]}, {name: 'name 2', label: 'label 2', required: true, type: 'string', visible: true, defaultValue: null, locations: [{location: 'location 1', value: 'test 2'}, {location: 'location 2', value: 'test 22'}]}]}, {label: 'action2', id: 'name2', triggerMessage: 'fake2', url: 'url2', requestable: false, restrictionMessage: '', parameters: []}] } ]; -const userAccessToken = 'fakeToken'; - describe('CatalogService', () => { let service: CatalogService; let catalogItemsServiceSpy: jasmine.SpyObj; @@ -30,7 +26,6 @@ describe('CatalogService', () => { let filesServiceSpy: jasmine.SpyObj; let catalogsServiceSpy: jasmine.SpyObj; let catalogDescriptorsServiceSpy: jasmine.SpyObj; - let azureServiceSpy: jasmine.SpyObj; beforeEach(() => { try { @@ -44,7 +39,6 @@ describe('CatalogService', () => { filesServiceSpy = jasmine.createSpyObj('FilesService', ['getFileById']); catalogsServiceSpy = jasmine.createSpyObj('CatalogsService', ['getCatalog']); catalogDescriptorsServiceSpy = jasmine.createSpyObj('CatalogDescriptorsService', ['getCatalogDescriptors']); - azureServiceSpy = jasmine.createSpyObj('AzureService', ['getRefreshedAccessToken']); TestBed.configureTestingModule({ providers: [ @@ -54,14 +48,12 @@ describe('CatalogService', () => { { provide: FilesService, useValue: filesServiceSpy }, { provide: CatalogsService, useValue: catalogsServiceSpy }, { provide: CatalogDescriptorsService, useValue: catalogDescriptorsServiceSpy }, - { provide: AzureService, useValue: azureServiceSpy }, { provide: BASE_PATH, useValue: '/component-catalog' }, provideHttpClient() ] }); service = TestBed.inject(CatalogService); - azureServiceSpy.getRefreshedAccessToken.and.returnValue(of(userAccessToken)); }); it('should be created', () => { @@ -505,8 +497,7 @@ describe('CatalogService', () => { catalogsServiceSpy as unknown as CatalogsService, catalogItemsServiceSpy as unknown as CatalogItemsService, catalogFiltersServiceSpy as unknown as CatalogFiltersService, - filesServiceSpy as unknown as FilesService, - azureServiceSpy + filesServiceSpy as unknown as FilesService ); expect(getItemSpy).toHaveBeenCalledWith('catalogSlug'); @@ -523,8 +514,7 @@ describe('CatalogService', () => { catalogsServiceSpy as unknown as CatalogsService, catalogItemsServiceSpy as unknown as CatalogItemsService, catalogFiltersServiceSpy as unknown as CatalogFiltersService, - filesServiceSpy as unknown as FilesService, - azureServiceSpy + filesServiceSpy as unknown as FilesService ); expect(getItemSpy).toHaveBeenCalledWith('catalogSlug'); @@ -560,7 +550,7 @@ describe('CatalogService', () => { expect(product.tags).toEqual(expectedProduct.tags); expect(product.title).toEqual(expectedProduct.title); } - expect(catalogItemsServiceSpy.getCatalogItemsForProjectKey).toHaveBeenCalledWith(catalogDescriptor.id!, userAccessToken, 'asc', projectKey); + expect(catalogItemsServiceSpy.getCatalogItemsForProjectKey).toHaveBeenCalledWith(catalogDescriptor.id!, 'asc', projectKey); done(); }); }); @@ -583,7 +573,7 @@ describe('CatalogService', () => { expect(product.shortDescription).toEqual(expectedProduct.shortDescription); expect(product.tags).toEqual(expectedProduct.tags); expect(product.title).toEqual(expectedProduct.title); - expect(catalogItemsServiceSpy.getCatalogItemByIdForProjectKey).toHaveBeenCalledWith(expectedProduct.id, projectKey, userAccessToken); + expect(catalogItemsServiceSpy.getCatalogItemByIdForProjectKey).toHaveBeenCalledWith(expectedProduct.id, projectKey); done(); }); }); diff --git a/src/app/services/catalog.service.ts b/src/app/services/catalog.service.ts index 09daf21..a2afe8d 100644 --- a/src/app/services/catalog.service.ts +++ b/src/app/services/catalog.service.ts @@ -5,7 +5,6 @@ import { Catalog, CatalogDescriptor, CatalogDescriptorsService, CatalogFiltersSe import { AppProduct } from '../models/app-product'; import { ProductActionParameter } from '../models/product-action-parameter'; import { ProductAction } from '../models/product-action'; -import { AzureService } from './azure.service'; @Injectable({ providedIn: 'root' @@ -27,8 +26,7 @@ export class CatalogService { private readonly catalogsService: CatalogsService, private readonly catalogItemsService: CatalogItemsService, private readonly catalogFiltersService: CatalogFiltersService, - private readonly filesService: FilesService, - private readonly azureService: AzureService + private readonly filesService: FilesService ) {} setSelectedCatalogSlug(slug: string | null): void { @@ -104,10 +102,8 @@ export class CatalogService { } getProjectProductsList(projectKey: string, catalogDescriptor: CatalogDescriptor): Observable { - return this.withAccessToken(accessToken => - this.catalogItemsService.getCatalogItemsForProjectKey(catalogDescriptor.id!, accessToken, 'asc', projectKey).pipe( - switchMap(items => this.mapItemsToProductListItems(items, catalogDescriptor.slug!)) - ) + return this.catalogItemsService.getCatalogItemsForProjectKey(catalogDescriptor.id!, 'asc', projectKey).pipe( + switchMap(items => this.mapItemsToProductListItems(items, catalogDescriptor.slug!)) ); } @@ -118,17 +114,11 @@ export class CatalogService { } getProjectProduct(projectKey: string, id: string): Observable { - return this.withAccessToken(accessToken => - this.catalogItemsService.getCatalogItemByIdForProjectKey(id, projectKey, accessToken).pipe( - switchMap(item => this.fetchProductDetails(item)) - ) + return this.catalogItemsService.getCatalogItemByIdForProjectKey(id, projectKey).pipe( + switchMap(item => this.fetchProductDetails(item)) ); } - private withAccessToken(fn: (accessToken: string) => Observable): Observable { - return from(this.azureService.getRefreshedAccessToken()).pipe(switchMap(fn)); - } - private async fetchProductDetails(item: CatalogItem): Promise { let description = ''; try { diff --git a/src/app/services/project.service.spec.ts b/src/app/services/project.service.spec.ts index aa5cd5a..93e63c4 100644 --- a/src/app/services/project.service.spec.ts +++ b/src/app/services/project.service.spec.ts @@ -32,7 +32,6 @@ describe('ProjectService', () => { ] }); - azureServiceSpy.getAccessToken.and.returnValue(Promise.resolve('dummy-token')); catalogServiceSpy.getProductImage.and.returnValue(Promise.resolve('http://example.com/image.png')); service = TestBed.inject(ProjectService); @@ -49,10 +48,10 @@ describe('ProjectService', () => { const mockProjects: any = ['project1', 'project2']; projectsServiceSpy.getProjects.and.returnValue(of(mockProjects)); - service.getUserProjects('test-token').subscribe(projects => { + service.getUserProjects().subscribe(projects => { expect(projects).toEqual(mockProjects); expect(service.getCachedUserProjects()).toEqual(mockProjects); - expect(projectsServiceSpy.getProjects).toHaveBeenCalledWith('test-token'); + expect(projectsServiceSpy.getProjects).toHaveBeenCalled(); done(); }); }); @@ -61,7 +60,7 @@ describe('ProjectService', () => { const mockProjects: any = ['project1', 'project2']; projectsServiceSpy.getProjects.and.returnValue(of(mockProjects)); - service.getUserProjects('test-token').subscribe(); + service.getUserProjects().subscribe(); expect(service.getCachedUserProjects()).toEqual(mockProjects); projectsServiceSpy.getProjects.calls.reset(); @@ -73,9 +72,9 @@ describe('ProjectService', () => { const mockProjectInfo: ProjectInfo = { projectKey: 'project1', clusters: ['cluster1', 'cluster2'] }; projectsServiceSpy.getProjectClusters = jasmine.createSpy().and.returnValue(of(mockProjectInfo)); - service.getProjectCluster('project1', 'test-token').subscribe(cluster => { + service.getProjectCluster('project1').subscribe(cluster => { expect(cluster).toBe('cluster1'); - expect(projectsServiceSpy.getProjectClusters).toHaveBeenCalledWith('test-token', 'project1'); + expect(projectsServiceSpy.getProjectClusters).toHaveBeenCalledWith('project1'); done(); }); }); @@ -84,7 +83,7 @@ describe('ProjectService', () => { const mockProjectInfo: ProjectInfo = { projectKey: 'project1', clusters: [] }; projectsServiceSpy.getProjectClusters = jasmine.createSpy().and.returnValue(of(mockProjectInfo)); - service.getProjectCluster('project1', 'test-token').subscribe(cluster => { + service.getProjectCluster('project1').subscribe(cluster => { expect(cluster).toBe(''); done(); }); @@ -114,7 +113,6 @@ describe('ProjectService', () => { const storedProject = JSON.parse(localStorage.getItem('selectedProject')!); expect(storedProject).toEqual({ projectKey: 'project1', location: 'cluster1' }); expect(service.getCurrentProject()).toEqual({ projectKey: 'project1', location: 'cluster1' }); - expect(azureServiceSpy.getAccessToken).toHaveBeenCalled(); done(); }, 100); }); @@ -153,78 +151,59 @@ describe('ProjectService', () => { service.setCurrentProject('project1'); }); - it('ensureUserProjectsLoaded should return cached value and not call getAccessToken', (done) => { + it('ensureUserProjectsLoaded should return cached value and not call backend', (done) => { const mockProjects: string[] = ['project1', 'project2']; projectsServiceSpy.getProjects.and.returnValue(of(mockProjects as any) as any); - service.getUserProjects('test-token').subscribe(() => { + service.getUserProjects().subscribe(() => { projectsServiceSpy.getProjects.calls.reset(); - azureServiceSpy.getAccessToken.calls.reset(); service.ensureUserProjectsLoaded().subscribe(projects => { expect(projects).toEqual(mockProjects); - expect(azureServiceSpy.getAccessToken).not.toHaveBeenCalled(); expect(projectsServiceSpy.getProjects).not.toHaveBeenCalled(); done(); }); }); }); - it('ensureUserProjectsLoaded should return same in-flight request for concurrent calls', fakeAsync(() => { + it('ensureUserProjectsLoaded should return same in-flight request for concurrent calls', () => { const mockProjects: string[] = ['project1', 'project2']; - let resolveToken: ((value: any) => void) | null = null; - const tokenPromise = new Promise(resolve => { - resolveToken = resolve; - }); - - azureServiceSpy.getAccessToken.and.returnValue(tokenPromise as any); projectsServiceSpy.getProjects.and.returnValue(of(mockProjects as any) as any); const request1$ = service.ensureUserProjectsLoaded(); const request2$ = service.ensureUserProjectsLoaded(); expect(request2$).toBe(request1$); - expect(azureServiceSpy.getAccessToken).toHaveBeenCalledTimes(1); let result1: string[] | undefined; let result2: string[] | undefined; request1$.subscribe(r => (result1 = r)); request2$.subscribe(r => (result2 = r)); - resolveToken!('dummy-token'); - flushMicrotasks(); - expect(projectsServiceSpy.getProjects).toHaveBeenCalledTimes(1); - expect(projectsServiceSpy.getProjects).toHaveBeenCalledWith('dummy-token'); expect(result1).toEqual(mockProjects); expect(result2).toEqual(mockProjects); expect(service.getCachedUserProjects()).toEqual(mockProjects); - })); + }); - it('ensureUserProjectsLoaded should load from backend when cache is empty and populate cache', fakeAsync(() => { + it('ensureUserProjectsLoaded should load from backend when cache is empty and populate cache', () => { const mockProjects: string[] = ['project1', 'project2']; - azureServiceSpy.getAccessToken.and.returnValue(Promise.resolve('dummy-token')); projectsServiceSpy.getProjects.and.returnValue(of(mockProjects as any) as any); let result: string[] | undefined; service.ensureUserProjectsLoaded().subscribe(r => (result = r)); - flushMicrotasks(); expect(result).toEqual(mockProjects); expect(service.getCachedUserProjects()).toEqual(mockProjects); - expect(azureServiceSpy.getAccessToken).toHaveBeenCalledTimes(1); - expect(projectsServiceSpy.getProjects).toHaveBeenCalledWith('dummy-token'); + expect(projectsServiceSpy.getProjects).toHaveBeenCalledTimes(1); - azureServiceSpy.getAccessToken.calls.reset(); projectsServiceSpy.getProjects.calls.reset(); service.ensureUserProjectsLoaded().subscribe(); - expect(azureServiceSpy.getAccessToken).not.toHaveBeenCalled(); expect(projectsServiceSpy.getProjects).not.toHaveBeenCalled(); - })); + }); - it('ensureUserProjectsLoaded should reset in-flight request on error (finalize) and allow retry', fakeAsync(() => { - azureServiceSpy.getAccessToken.and.returnValue(Promise.resolve('dummy-token')); + it('ensureUserProjectsLoaded should reset in-flight request on error (finalize) and allow retry', () => { projectsServiceSpy.getProjects.and.returnValue(throwError(() => new Error('backend error'))); let firstError: any; @@ -232,41 +211,30 @@ describe('ProjectService', () => { next: () => fail('Expected error'), error: err => (firstError = err) }); - flushMicrotasks(); expect(firstError).toBeTruthy(); expect((service as any).userProjectsSubject.value).toBeNull(); projectsServiceSpy.getProjects.and.returnValue(of(['project1'] as any) as any); let retryResult: string[] | undefined; service.ensureUserProjectsLoaded().subscribe(r => (retryResult = r)); - flushMicrotasks(); - expect(azureServiceSpy.getAccessToken).toHaveBeenCalledTimes(2); expect(retryResult).toEqual(['project1']); expect(service.getCachedUserProjects()).toEqual(['project1']); - })); + }); it('should ignore stale async cluster result when setCurrentProject is called again (requestId guard)', fakeAsync(() => { spyOn(localStorage, 'setItem').and.callThrough(); const clusterObservers: Record = {}; - projectsServiceSpy.getProjectClusters = jasmine.createSpy().and.callFake((_token: string, projectKey: string) => { + projectsServiceSpy.getProjectClusters = jasmine.createSpy().and.callFake((projectKey: string) => { return new Observable(observer => { clusterObservers[projectKey] = observer; }); }); - azureServiceSpy.getAccessToken.and.returnValues( - Promise.resolve('t1'), - Promise.resolve('t2') - ); - service.setCurrentProject('project1'); - flushMicrotasks(); // resolves refreshToken #1 and subscribes to project1 clusters - service.setCurrentProject('project2'); - flushMicrotasks(); // resolves refreshToken #2 and subscribes to project2 clusters // Emit the first project's cluster after the second call has superseded it. clusterObservers['project1'].next({ projectKey: 'project1', clusters: ['cluster1'] } as any); @@ -284,49 +252,6 @@ describe('ProjectService', () => { expect(setItemValues.some(v => v.includes('"projectKey":"project2"'))).toBeTrue(); })); - it('should ignore stale getAccessToken resolution when setCurrentProject is called again (requestId guard before getProjectClusters)', fakeAsync(() => { - spyOn(localStorage, 'setItem').and.callThrough(); - - let resolveToken1: ((value: any) => void) | undefined; - let resolveToken2: ((value: any) => void) | undefined; - - const tokenPromise1 = new Promise(resolve => (resolveToken1 = resolve)); - const tokenPromise2 = new Promise(resolve => (resolveToken2 = resolve)); - - azureServiceSpy.getAccessToken.and.returnValues(tokenPromise1 as any, tokenPromise2 as any); - - projectsServiceSpy.getProjectClusters = jasmine.createSpy().and.callFake((token: string, projectKey: string) => { - return of({ projectKey, clusters: [`${projectKey}-cluster`] } as any); - }); - - service.setCurrentProject('project1'); - service.setCurrentProject('project2'); - - // Resolve the second token first; it should win. - resolveToken2!('t2'); - flushMicrotasks(); - - expect(projectsServiceSpy.getProjectClusters).toHaveBeenCalledTimes(1); - expect(projectsServiceSpy.getProjectClusters).toHaveBeenCalledWith('t2', 'project2'); - - expect(service.getCurrentProject()).toEqual({ projectKey: 'project2', location: 'project2-cluster' }); - expect(JSON.parse(localStorage.getItem('selectedProject')!)).toEqual({ - projectKey: 'project2', - location: 'project2-cluster' - }); - - // Now resolve the first token after it's stale; it should be ignored entirely (no backend call, no storage update). - resolveToken1!('t1'); - flushMicrotasks(); - - expect(projectsServiceSpy.getProjectClusters).toHaveBeenCalledTimes(1); - expect(service.getCurrentProject()).toEqual({ projectKey: 'project2', location: 'project2-cluster' }); - - const setItemValues = (localStorage.setItem as jasmine.Spy).calls.allArgs().map(args => args[1] as string); - expect(setItemValues.some(v => v.includes('"projectKey":"project1"'))).toBeFalse(); - expect(setItemValues.some(v => v.includes('"projectKey":"project2"'))).toBeTrue(); - })); - it('should get project components with mapped data', fakeAsync(() => { const mockComponents = [ { @@ -345,7 +270,6 @@ describe('ProjectService', () => { } ]; - azureServiceSpy.getAccessToken.and.returnValue(Promise.resolve('test-token')); projectComponentsServiceSpy.getProjectComponents = jasmine.createSpy().and.returnValue(of(mockComponents as any)); let result: any; @@ -355,8 +279,7 @@ describe('ProjectService', () => { flushMicrotasks(); - expect(azureServiceSpy.getAccessToken).toHaveBeenCalled(); - expect(projectComponentsServiceSpy.getProjectComponents).toHaveBeenCalledWith('PROJECT_1', 'test-token'); + expect(projectComponentsServiceSpy.getProjectComponents).toHaveBeenCalledWith('PROJECT_1'); expect(result).toEqual([ { name: 'comp1', @@ -396,7 +319,6 @@ describe('ProjectService', () => { } ]; - azureServiceSpy.getAccessToken.and.returnValue(Promise.resolve('test-token')); projectComponentsServiceSpy.getProjectComponents = jasmine.createSpy().and.returnValue(of(mockComponentsWithMissingData as any)); let result: any; @@ -406,8 +328,7 @@ describe('ProjectService', () => { flushMicrotasks(); - expect(azureServiceSpy.getAccessToken).toHaveBeenCalled(); - expect(projectComponentsServiceSpy.getProjectComponents).toHaveBeenCalledWith('PROJECT_1', 'test-token'); + expect(projectComponentsServiceSpy.getProjectComponents).toHaveBeenCalledWith('PROJECT_1'); expect(result).toEqual([ { name: '', @@ -451,7 +372,6 @@ describe('ProjectService', () => { } ]; - azureServiceSpy.getAccessToken.and.returnValue(Promise.resolve('test-token')); projectComponentsServiceSpy.getProjectComponents = jasmine.createSpy().and.returnValue(of(mockComponents as any)); catalogServiceSpy.getProductImage.and.returnValue(Promise.resolve(undefined)); diff --git a/src/app/services/project.service.ts b/src/app/services/project.service.ts index 2daba67..714bdb7 100644 --- a/src/app/services/project.service.ts +++ b/src/app/services/project.service.ts @@ -35,8 +35,8 @@ export class ProjectService { } } - getUserProjects(userToken: string): Observable { - return this.projectsService.getProjects(userToken).pipe( + getUserProjects(): Observable { + return this.projectsService.getProjects().pipe( tap(projects => { this.userProjects = projects; this.userProjectsSubject.next(projects); @@ -58,8 +58,7 @@ export class ProjectService { return this.userProjectsRequest$; } - this.userProjectsRequest$ = from(this.azureService.getAccessToken()).pipe( - switchMap((accessToken: string) => this.getUserProjects(accessToken)), + this.userProjectsRequest$ = this.getUserProjects().pipe( finalize(() => { this.userProjectsRequest$ = null; }), @@ -69,8 +68,8 @@ export class ProjectService { return this.userProjectsRequest$; } - getProjectCluster(project: string, userToken: string): Observable { - return this.projectsService.getProjectClusters(userToken, project).pipe( + getProjectCluster(project: string): Observable { + return this.projectsService.getProjectClusters(project).pipe( map(projectInfo => projectInfo.clusters.length > 0 ? projectInfo.clusters[0] : '') ); } @@ -82,18 +81,13 @@ export class ProjectService { setCurrentProject(projectKey: string | null): void { const requestId = ++this.setProjectRequestId; if (projectKey) { - this.azureService.getAccessToken().then((accessToken: string) => { + this.getProjectCluster(projectKey).subscribe(cluster => { if (requestId !== this.setProjectRequestId) { return; } - this.getProjectCluster(projectKey, accessToken).subscribe(cluster => { - if (requestId !== this.setProjectRequestId) { - return; - } - const project: AppProject = { projectKey: projectKey, location: cluster }; - localStorage.setItem(this.PROJECT_STORAGE_KEY, JSON.stringify(project)); - this.projectSubject.next(project); - }); + const project: AppProject = { projectKey: projectKey, location: cluster }; + localStorage.setItem(this.PROJECT_STORAGE_KEY, JSON.stringify(project)); + this.projectSubject.next(project); }); } else { localStorage.removeItem(this.PROJECT_STORAGE_KEY); @@ -102,19 +96,15 @@ export class ProjectService { } getProjectComponents(projectKey: string): Observable { - return from(this.azureService.getAccessToken()).pipe( - switchMap((accessToken: string) => - this.projectComponentsService.getProjectComponents(projectKey, accessToken).pipe( - switchMap(components => - from(Promise.all(components.map(async component => ({ - name: component.componentId || '', - status: (component.status as ComponentStatus) || 'UNKNOWN', - logo: component.logoUrl ? (await this.catalogService.getProductImage(component.logoUrl)) ?? null : null, - url: component.componentUrl || '', - canDelete: component.canBeDeleted || false - })))) - ) - ) + return this.projectComponentsService.getProjectComponents(projectKey).pipe( + switchMap(components => + from(Promise.all(components.map(async component => ({ + name: component.componentId || '', + status: (component.status as ComponentStatus) || 'UNKNOWN', + logo: component.logoUrl ? (await this.catalogService.getProductImage(component.logoUrl)) ?? null : null, + url: component.componentUrl || '', + canDelete: component.canBeDeleted || false + })))) ) ); } From fc44228e5c2740f3f820b4b397d9f5ea316b570c Mon Sep 17 00:00:00 2001 From: "Vila,Jordi (IT EDP)" Date: Fri, 17 Apr 2026 16:39:56 +0200 Subject: [PATCH 2/2] Remove unused import --- src/app/services/catalog.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/services/catalog.service.ts b/src/app/services/catalog.service.ts index a2afe8d..f3f0bd1 100644 --- a/src/app/services/catalog.service.ts +++ b/src/app/services/catalog.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@angular/core'; import { AppShellFilter } from '@opendevstack/ngx-appshell'; -import { BehaviorSubject, firstValueFrom, from, map, Observable, switchMap } from 'rxjs'; +import { BehaviorSubject, firstValueFrom, map, Observable, switchMap } from 'rxjs'; import { Catalog, CatalogDescriptor, CatalogDescriptorsService, CatalogFiltersService, CatalogItem, CatalogItemsService, CatalogsService, FileFormat, FilesService } from '../openapi/component-catalog'; import { AppProduct } from '../models/app-product'; import { ProductActionParameter } from '../models/product-action-parameter';