From a366cede4673134743c0ffb4e19fbb44710355c7 Mon Sep 17 00:00:00 2001 From: aasandei-vsp Date: Thu, 16 Jul 2026 11:47:25 +0300 Subject: [PATCH] Add missing properties and fallbacks to StelaFolder and StelaRecord Added accessRole, archiveNumber, folderLinkId, parentFolder.parentFolderLinkId, createdAt, updatedAt, paths.folderLinkIds, paths.archiveNumbers in StelaFolder and StelaRecord so navigateLean and navigateMin can be fully replaced by getWithChildren. Created fallback for missing folderId, call folder and children in parallel for optimization and convert v2 call failure to v1 unsuccessful response. Issue: PER-10476 --- .../shared/services/api/folder.repo.spec.ts | 213 +++++++++++++++++- src/app/shared/services/api/folder.repo.ts | 119 ++++++---- .../shared/services/api/record.repo.spec.ts | 9 + src/app/shared/services/api/record.repo.ts | 2 + 4 files changed, 301 insertions(+), 42 deletions(-) diff --git a/src/app/shared/services/api/folder.repo.spec.ts b/src/app/shared/services/api/folder.repo.spec.ts index 436163dc4..6a6ab7b4e 100644 --- a/src/app/shared/services/api/folder.repo.spec.ts +++ b/src/app/shared/services/api/folder.repo.spec.ts @@ -1,10 +1,10 @@ import { TestBed } from '@angular/core/testing'; import { FolderVO } from '@models/index'; -import { of } from 'rxjs'; +import { Observable, of } from 'rxjs'; import { ShareLink } from '@root/app/share-links/models/share-link'; import { HttpV2Service } from '../http-v2/http-v2.service'; import { HttpService } from '../http/http.service'; -import { FolderRepo } from './folder.repo'; +import { FolderRepo, FolderResponse } from './folder.repo'; const emptyResponse = { items: [] }; const fakeFolderResponse = { @@ -55,6 +55,43 @@ const fakeChildrenResponse = { ], }; +const buildStelaFolderResponse = (overrides: Record = {}) => ({ + items: [ + { + folderId: '42', + archiveNumber: 'ARCH-001', + archive: { id: 'arch-id', name: 'Test Archive' }, + folderLinkId: 100, + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-06-01T00:00:00Z', + description: 'Test', + displayTimestamp: '2024-01-01T00:00:00Z', + displayEndTimestamp: null, + displayName: 'Test Folder', + downloadName: 'Test Folder', + imageRatio: 1, + paths: { + names: ['My Files', 'Test Folder'], + folderLinkIds: ['55', '100'], + archiveNumbers: ['ARCH-000', 'ARCH-001'], + }, + publicAt: null, + sort: null, + thumbnailUrls: null, + type: 'type.folder.generic', + status: 'status.generic.ok', + view: 'grid', + size: 0, + location: null, + parentFolder: { id: 'parent-id', parentFolderLinkId: 55 }, + shares: null, + tags: null, + accessRole: 'access.role.viewer', + ...overrides, + }, + ], +}); + describe('Folder repo', () => { let folderRepo: FolderRepo; let httpSpy: jasmine.SpyObj; @@ -158,10 +195,13 @@ describe('Folder repo', () => { it('should get folder with children using fallback to auth token', async () => { const mockFolderVO = { folderId: 42 } as FolderVO; + // The folder and children requests run in parallel, so the call order + // is: folder (share token), children (share token), then the + // auth-token fallbacks in the same order. httpV2Spy.get.and.returnValues( of([emptyResponse]), + of([{}]), of([fakeFolderResponse]), - of([emptyResponse]), of([fakeChildrenResponse]), ); @@ -184,6 +224,173 @@ describe('Folder repo', () => { expect(result.Results[0].data[0].FolderVO).toBeDefined(); }); + describe('getWithChildren error handling', () => { + it('should return a FolderResponse with isSuccessful falsy when the Stela API throws', async () => { + const folderVO = new FolderVO({ folderId: 42 }); + const apiError = { error: { error: 'Internal server error' } }; + + httpV2Spy.get.and.returnValue( + new Observable((subscriber) => subscriber.error(apiError)), + ); + + const result = await folderRepo.getWithChildren([folderVO]); + + expect(result.isSuccessful).toBeFalsy(); + }); + + it('should surface the error message from err.error.error via getMessage()', async () => { + const folderVO = new FolderVO({ folderId: 42 }); + const apiError = { error: { error: 'Folder not found' } }; + + httpV2Spy.get.and.returnValue( + new Observable((subscriber) => subscriber.error(apiError)), + ); + + const result = await folderRepo.getWithChildren([folderVO]); + + expect(result.getMessage()).toBe('Folder not found'); + }); + + it('should return an empty error message when err.error.error is absent', async () => { + const folderVO = new FolderVO({ folderId: 42 }); + + httpV2Spy.get.and.returnValue( + new Observable((subscriber) => subscriber.error({})), + ); + + const result = await folderRepo.getWithChildren([folderVO]); + + expect(result.getMessage()).toBeUndefined(); + }); + + it('should surface the message of internally thrown errors via getMessage()', async () => { + const folderVO = new FolderVO({ folderId: 42 }); + + // Both the folder and children endpoints return empty results, + // so getWithChildren throws its internal "no folder" Error. + httpV2Spy.get.and.returnValue(of([{ items: [] }])); + + const result = await folderRepo.getWithChildren([folderVO]); + + expect(result.getMessage()).toBe( + 'No folder returned from getStelaFolders', + ); + }); + }); + + describe('resolveFolderId', () => { + it('should not call the legacy /folder/get endpoint when folderId is already present', async () => { + const folderVO = new FolderVO({ folderId: 42 }); + + httpV2Spy.get.and.returnValues( + of([buildStelaFolderResponse()]), + of([{ items: [] }]), + ); + + await folderRepo.getWithChildren([folderVO]); + + expect(httpSpy.sendRequestPromise).not.toHaveBeenCalled(); + }); + + it('should call legacy /folder/get to resolve folderId when it is missing', async () => { + const folderVO = new FolderVO({ + archiveNbr: '0001-0001', + folder_linkId: 123, + }); + + const resolvedFolderResponse = new FolderResponse({ + isSuccessful: true, + Results: [ + { + data: [{ FolderVO: { folderId: 99 } }], + status: true, + message: ['OK'], + resultDT: new Date().toISOString(), + createdDT: null, + updatedDT: null, + }, + ], + }); + + httpSpy.sendRequestPromise.and.resolveTo(resolvedFolderResponse); + httpV2Spy.get.and.returnValues( + of([buildStelaFolderResponse({ folderId: '99' })]), + of([{ items: [] }]), + ); + + await folderRepo.getWithChildren([folderVO]); + + expect(httpSpy.sendRequestPromise).toHaveBeenCalledWith( + '/folder/get', + jasmine.any(Array), + jasmine.any(Object), + ); + + expect(httpV2Spy.get).toHaveBeenCalledWith('v2/folder', { + folderIds: [99], + }); + }); + + it('should return a v1-shaped error response when /folder/get cannot resolve a folderId', async () => { + const folderVO = new FolderVO({ + archiveNbr: '0001-0001', + folder_linkId: 123, + }); + + // The legacy lookup returns no Results, so getFolderVO() is null. + httpSpy.sendRequestPromise.and.resolveTo( + new FolderResponse({ Results: [] }), + ); + + const result = await folderRepo.getWithChildren([folderVO]); + + expect(result.isSuccessful).toBeFalsy(); + expect(result.getMessage()).toBe( + 'Unable to resolve folderId via /folder/get', + ); + + expect(httpV2Spy.get).not.toHaveBeenCalled(); + }); + }); + + describe('convertStelaFolderToFolderVO mapping', () => { + const getConvertedFolder = async () => { + const folderVO = new FolderVO({ folderId: 42 }); + httpV2Spy.get.and.returnValues( + of([buildStelaFolderResponse()]), + of([{ items: [] }]), + ); + const result = await folderRepo.getWithChildren([folderVO]); + return result.getFolderVO(true); + }; + + it('should map parentFolder_linkId from the parentFolder object', async () => { + const folder = await getConvertedFolder(); + + expect(folder.parentFolder_linkId).toBe(55); + }); + + it('should map the folder path arrays for breadcrumbs', async () => { + const folder = await getConvertedFolder(); + + expect(folder.pathAsText).toEqual(['My Files', 'Test Folder']); + expect(folder.pathAsFolder_linkId).toEqual([55, 100]); + expect(folder.pathAsArchiveNbr).toEqual(['ARCH-000', 'ARCH-001']); + }); + + it('should leave folder_linkType undefined since the backend omits it', async () => { + const folder = await getConvertedFolder(); + + expect(folder.folder_linkType).toBeUndefined(); + }); + + it('should map the caller accessRole from the Stela response', async () => { + const folder = await getConvertedFolder(); + + expect(folder.accessRole).toBe('access.role.viewer'); + }); + }); + describe('getFolderShareLink', () => { const mockShareLink: ShareLink = { id: 'link1', diff --git a/src/app/shared/services/api/folder.repo.ts b/src/app/shared/services/api/folder.repo.ts index 188d18012..20707e2e9 100644 --- a/src/app/shared/services/api/folder.repo.ts +++ b/src/app/shared/services/api/folder.repo.ts @@ -1,4 +1,5 @@ import { FolderVO, FolderVOData, ItemVO } from '@root/app/models'; +import { AccessRoleType } from '@models/access-role'; import { BaseResponse, BaseRepo } from '@shared/services/api/base'; import { firstValueFrom, Observable } from 'rxjs'; import { DataStatus } from '@models/data-status.enum'; @@ -48,13 +49,17 @@ interface StelaFolder { location: StelaLocation; parentFolder: { id: string; + parentFolderLinkId: number; }; shares: Array; tags: Array; + accessRole: AccessRoleType; archive: { id: string; name: string; }; + archiveNumber: string; + folderLinkId: number; createdAt: string; updatedAt: string; description: string; @@ -66,6 +71,8 @@ interface StelaFolder { imageRatio: number; paths: { names: string[]; + folderLinkIds: string[]; + archiveNumbers: string[]; }; publicAt: string; sort: string; @@ -103,6 +110,9 @@ const convertStelaFolderToFolderVO = (stelaFolder: StelaFolder): FolderVO => { ...stelaFolder, folderId: stelaFolder.folderId, archiveId: stelaFolder.archive?.id, + archiveNbr: stelaFolder.archiveNumber, + folder_linkId: stelaFolder.folderLinkId, + parentFolder_linkId: stelaFolder.parentFolder?.parentFolderLinkId, displayName: stelaFolder.displayName, displayDT: stelaFolder.displayTimestamp, displayEndDT: stelaFolder.displayEndTimestamp, @@ -126,9 +136,13 @@ const convertStelaFolderToFolderVO = (stelaFolder: StelaFolder): FolderVO => { thumbnail256: stelaFolder.thumbnailUrls?.['256'], thumbnail256CloudPath: stelaFolder.thumbnailUrls?.['256'], status: stelaFolder.status, + createdDT: stelaFolder.createdAt, + updatedDT: stelaFolder.updatedAt, publicDT: stelaFolder.publicAt, parentFolderId: stelaFolder.parentFolder?.id, pathAsText: stelaFolder.paths?.names, + pathAsFolder_linkId: stelaFolder.paths?.folderLinkIds?.map(Number), + pathAsArchiveNbr: stelaFolder.paths?.archiveNumbers, ParentFolderVOs: [new FolderVO({ folderId: stelaFolder.parentFolder?.id })], ChildFolderVOs: childFolderVOs, RecordVOs: childRecordVOs, @@ -139,6 +153,7 @@ const convertStelaFolderToFolderVO = (stelaFolder: StelaFolder): FolderVO => { ), ChildItemVOs: [...childRecordVOs, ...childFolderVOs], ShareVOs: (stelaFolder.shares ?? []).map(convertStelaSharetoShareVO), + accessRole: stelaFolder.accessRole, isFolder: true, }); }; @@ -309,51 +324,77 @@ export class FolderRepo extends BaseRepo { return response[0].items; } + private async resolveFolderId(folderVO: FolderVO): Promise { + if (folderVO.folderId) { + return folderVO; + } + const response = await this.get([folderVO]); + const resolvedFolder = response.getFolderVO(); + if (!resolvedFolder?.folderId) { + throw new Error('Unable to resolve folderId via /folder/get'); + } + return new FolderVO({ ...folderVO, folderId: resolvedFolder.folderId }); + } + public async getWithChildren( folderVOs: FolderVO[], shareToken: string = null, ): Promise { - // Stela has two separate endpoints -- one for loading the folder, one for loading the children. - const requests = folderVOs.map(async (folderVO) => { - const stelaFolders = await this.getStelaFolders([folderVO], shareToken); - const stelaFolderChildren = await this.getStelaFolderChildren( - folderVO, - shareToken, + try { + // Stela has two separate endpoints -- one for loading the folder, one for loading the children. + const requests = folderVOs.map(async (folderVO) => { + const resolvedFolderVO = await this.resolveFolderId(folderVO); + const [stelaFolders, stelaFolderChildren] = await Promise.all([ + this.getStelaFolders([resolvedFolderVO], shareToken), + this.getStelaFolderChildren(resolvedFolderVO, shareToken), + ]); + const stelaFolder = stelaFolders[0]; + if (!stelaFolder) { + throw new Error('No folder returned from getStelaFolders'); + } + return { + ...stelaFolder, + children: stelaFolderChildren, + }; + }); + + const stelaFolders = (await Promise.all(requests)).flat(); + + // We need the `Results` to look the way v1 results look, for now. + const simulatedV1FolderResponseResults = stelaFolders.map( + (stelaFolder) => ({ + data: [ + { + FolderVO: convertStelaFolderToFolderVO(stelaFolder), + }, + ], + message: ['Folder retrieved'], + status: true, + resultDT: new Date().toISOString(), + createdDT: null, + updatedDT: null, + }), ); - const stelaFolder = stelaFolders[0]; - if (!stelaFolder) { - throw new Error('No folder returned from getStelaFolders'); - } - return { - ...stelaFolder, - children: stelaFolderChildren, - }; - }); - - const stelaFolders = (await Promise.all(requests)).flat(); - - // We need the `Results` to look the way v1 results look, for now. - const simulatedV1FolderResponseResults = stelaFolders.map( - (stelaFolder) => ({ - data: [ - { - FolderVO: convertStelaFolderToFolderVO(stelaFolder), - }, - ], - message: ['Folder retrieved'], - status: true, - resultDT: new Date().toISOString(), - createdDT: null, - updatedDT: null, - }), - ); - const folderResponse = new FolderResponse({ - isSuccessful: true, - isSystemUp: true, - Results: simulatedV1FolderResponseResults, - }); - return folderResponse; + const folderResponse = new FolderResponse({ + isSuccessful: true, + isSystemUp: true, + Results: simulatedV1FolderResponseResults, + }); + return folderResponse; + } catch (err) { + // We need the error to look the way v1 errors look too, + // Changing all the error handlers would be error prone + const errorFolderResponse = new FolderResponse(); + errorFolderResponse.Results = [ + { + // Stela API errors carry the message in err.error.error; + // internally thrown Errors carry it in err.message. + message: [err?.error?.error ?? err?.message], + }, + ]; + return errorFolderResponse; + } } public navigate(folderVO: FolderVO): Observable { diff --git a/src/app/shared/services/api/record.repo.spec.ts b/src/app/shared/services/api/record.repo.spec.ts index c0403ec8e..db09e74e4 100644 --- a/src/app/shared/services/api/record.repo.spec.ts +++ b/src/app/shared/services/api/record.repo.spec.ts @@ -448,6 +448,15 @@ describe('RecordRepo', () => { expect(record.displayTime).toBeUndefined(); }); + it('should map the caller accessRole from the stela record', () => { + const record = convertStelaRecordToRecordVO({ + ...baseStelaRecord, + accessRole: 'access.role.editor', + } as any); + + expect(record.accessRole).toBe('access.role.editor'); + }); + it('maps thumbnailUrls to all thumb fields', () => { const record = convertStelaRecordToRecordVO({ ...baseStelaRecord, diff --git a/src/app/shared/services/api/record.repo.ts b/src/app/shared/services/api/record.repo.ts index b49354431..975547013 100644 --- a/src/app/shared/services/api/record.repo.ts +++ b/src/app/shared/services/api/record.repo.ts @@ -108,6 +108,7 @@ export interface StelaShare { } export type StelaRecord = Omit & { tags: Array | null; + accessRole: AccessRoleType; archiveNumber: string; displayDate: string; displayTime?: string; @@ -229,6 +230,7 @@ export const convertStelaRecordToRecordVO = ( timeZoneId: CENTRAL_TIMEZONE_VO.timeZoneId, TimezoneVO: CENTRAL_TIMEZONE_VO, ShareVOs: (stelaRecord.shares ?? []).map(convertStelaSharetoShareVO), + accessRole: stelaRecord.accessRole, }); export class RecordRepo extends BaseRepo {