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 {