diff --git a/src/app/core/resolves/lean-folder-resolve.service.spec.ts b/src/app/core/resolves/lean-folder-resolve.service.spec.ts new file mode 100644 index 000000000..dee541767 --- /dev/null +++ b/src/app/core/resolves/lean-folder-resolve.service.spec.ts @@ -0,0 +1,211 @@ +import { TestBed } from '@angular/core/testing'; +import * as Testing from '@root/test/testbedConfig'; +import { cloneDeep } from 'lodash'; +import { Router } from '@angular/router'; + +import { LeanFolderResolveService } from '@core/resolves/lean-folder-resolve.service'; +import { ApiService } from '@shared/services/api/api.service'; +import { AccountService } from '@shared/services/account/account.service'; +import { FolderResponse } from '@shared/services/api/folder.repo'; +import { FolderVO } from '@models/index'; +import { + MessageDisplayOptions, + MessageService, +} from '@shared/services/message/message.service'; + +const buildFolderResponse = (folderData: Record) => + new FolderResponse({ + isSuccessful: true, + Results: [{ data: [{ FolderVO: { ChildItemVOs: [], ...folderData } }] }], + }); + +describe('LeanFolderResolveService', () => { + let service: LeanFolderResolveService; + let api: ApiService; + let accountService: AccountService; + let message: MessageService; + let router: Router; + + beforeEach(() => { + const config = cloneDeep(Testing.BASE_TEST_CONFIG); + config.providers.push(LeanFolderResolveService); + TestBed.configureTestingModule(config); + + service = TestBed.inject(LeanFolderResolveService); + api = TestBed.inject(ApiService); + accountService = TestBed.inject(AccountService); + message = TestBed.inject(MessageService); + router = TestBed.inject(Router); + + spyOn(accountService, 'getRootFolder').and.returnValue( + new FolderVO({ + ChildItemVOs: [ + new FolderVO({ + folderId: '11', + type: 'type.folder.root.private', + archiveNbr: '0001-0001', + }), + new FolderVO({ + folderId: '22', + type: 'type.folder.root.app', + archiveNbr: '0001-0002', + }), + ], + }), + ); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should load My Files by default', async () => { + const getSpy = spyOn( + api.folder, + 'getWithChildrenByIdentifier', + ).and.resolveTo(buildFolderResponse({ displayName: 'My Files' })); + + const result = await service.resolve( + { params: {} } as any, + { url: '/private' } as any, + ); + + expect(getSpy).toHaveBeenCalled(); + expect(getSpy.calls.mostRecent().args[0].folderId).toBe('11'); + expect(result.displayName).toBe('My Files'); + }); + + it('should load the apps folder on /apps', async () => { + const getSpy = spyOn( + api.folder, + 'getWithChildrenByIdentifier', + ).and.resolveTo(buildFolderResponse({ displayName: 'Apps' })); + + await service.resolve({ params: {} } as any, { url: '/apps' } as any); + + expect(getSpy.calls.mostRecent().args[0].folderId).toBe('22'); + }); + + it('should pass the route identifiers through for a deep link', async () => { + const getSpy = spyOn( + api.folder, + 'getWithChildrenByIdentifier', + ).and.resolveTo(buildFolderResponse({ displayName: 'Deep Linked' })); + + const result = await service.resolve( + { params: { archiveNbr: '0001-0005', folderLinkId: '99' } } as any, + { url: '/view/timeline/0001-0005/99' } as any, + ); + + const requestedFolder = getSpy.calls.mostRecent().args[0]; + + expect(requestedFolder.archiveNbr).toBe('0001-0005'); + expect(requestedFolder.folder_linkId).toBe(99); + expect(requestedFolder.folderId).toBeUndefined(); + expect(result.displayName).toBe('Deep Linked'); + }); + + it('should splice share crumbs onto a shared record without calling the API', async () => { + const getSpy = spyOn(api.folder, 'getWithChildrenByIdentifier'); + const sharedRecord = { displayName: 'A shared photo' }; + + const result = await service.resolve( + { + params: {}, + parent: { + data: { + sharePreviewVO: { FolderVO: null, RecordVO: sharedRecord }, + currentFolder: new FolderVO({ + pathAsText: ['My Files'], + pathAsArchiveNbr: ['0001-0001'], + pathAsFolder_linkId: [11], + }), + }, + }, + } as any, + { url: '/share/abc123/view/timeline' } as any, + ); + + expect(getSpy).not.toHaveBeenCalled(); + expect(result.pathAsText).toEqual(['Shares', 'Record', 'My Files']); + expect(result.pathAsArchiveNbr).toEqual([ + '0000-0000', + '0000-0000', + '0001-0001', + ]); + + expect(result.pathAsFolder_linkId).toEqual([0, 0, 11]); + expect(result.ChildItemVOs).toEqual([sharedRecord] as any); + }); + + it('should surface the server message when the load fails', async () => { + spyOn(api.folder, 'getWithChildrenByIdentifier').and.rejectWith( + new FolderResponse({ + isSuccessful: false, + Results: [{ message: ['Test Error'] }], + }), + ); + spyOn(accountService, 'logOut').and.resolveTo(null); + spyOn(router, 'navigate'); + let displayedErrorMessage: string; + spyOn(message, 'showError').and.callFake((data: MessageDisplayOptions) => { + displayedErrorMessage = data.message; + }); + + await expectAsync( + service.resolve({ params: {} } as any, { url: '/private' } as any), + ).toBeRejected(); + + expect(displayedErrorMessage).toBe('Test Error'); + }); + + it('should log out when a root folder fails to load', async () => { + spyOn(api.folder, 'getWithChildrenByIdentifier').and.rejectWith( + new Error('Network down'), + ); + const logOutSpy = spyOn(accountService, 'logOut').and.resolveTo(null); + spyOn(router, 'navigate'); + spyOn(message, 'showError'); + + await expectAsync( + service.resolve({ params: {} } as any, { url: '/private' } as any), + ).toBeRejected(); + + expect(logOutSpy).toHaveBeenCalled(); + }); + + it('should fall back to a generic message for a raw error', async () => { + spyOn(api.folder, 'getWithChildrenByIdentifier').and.rejectWith( + new Error('Network down'), + ); + spyOn(accountService, 'logOut').and.resolveTo(null); + spyOn(router, 'navigate'); + let displayedErrorMessage: string; + spyOn(message, 'showError').and.callFake((data: MessageDisplayOptions) => { + displayedErrorMessage = data.message; + }); + + await expectAsync( + service.resolve({ params: {} } as any, { url: '/private' } as any), + ).toBeRejected(); + + expect(displayedErrorMessage).toBe('error.generic.internal'); + }); + + it('should redirect rather than throw when a deep link fails', async () => { + spyOn(api.folder, 'getWithChildrenByIdentifier').and.rejectWith( + new Error('Network down'), + ); + const navigateSpy = spyOn(router, 'navigate'); + spyOn(message, 'showError'); + + await expectAsync( + service.resolve( + { params: { archiveNbr: '0001-0005', folderLinkId: '99' } } as any, + { url: '/view/timeline/0001-0005/99' } as any, + ), + ).toBeRejectedWith(false); + + expect(navigateSpy).toHaveBeenCalledWith(['/private']); + }); +}); diff --git a/src/app/core/resolves/lean-folder-resolve.service.ts b/src/app/core/resolves/lean-folder-resolve.service.ts index 72bbb904a..ef6b4b601 100644 --- a/src/app/core/resolves/lean-folder-resolve.service.ts +++ b/src/app/core/resolves/lean-folder-resolve.service.ts @@ -4,16 +4,15 @@ import { RouterStateSnapshot, Router, } from '@angular/router'; -import { Observable } from 'rxjs'; -import { map } from 'rxjs/operators'; import { find, cloneDeep } from 'lodash'; import { ApiService } from '@shared/services/api/api.service'; import { AccountService } from '@shared/services/account/account.service'; import { MessageService } from '@shared/services/message/message.service'; -import { FolderResponse } from '@shared/services/api/index.repo'; +import { getFolderErrorMessage } from '@shared/utilities/folder-error-message'; import { FolderVO } from '@root/app/models'; +import { toFolderLinkId } from '@shared/services/api/folder.repo'; @Injectable() export class LeanFolderResolveService { @@ -24,16 +23,16 @@ export class LeanFolderResolveService { private router: Router, ) {} - resolve( + async resolve( route: ActivatedRouteSnapshot, state: RouterStateSnapshot, - ): Observable | Promise { + ): Promise { let targetFolder; if (route.params.archiveNbr && route.params.folderLinkId) { targetFolder = new FolderVO({ archiveNbr: route.params.archiveNbr, - folder_linkId: route.params.folderLinkId, + folder_linkId: toFolderLinkId(route.params.folderLinkId), }); } else if (state.url === '/apps') { const apps = find(this.accountService.getRootFolder().ChildItemVOs, { @@ -51,7 +50,7 @@ export class LeanFolderResolveService { folder.pathAsText.unshift('Shares', 'Record'); folder.pathAsFolder_linkId.unshift(0, 0); folder.ChildItemVOs = [sharedRecord]; - return Promise.resolve(folder); + return folder; } } else { const myFiles = find(this.accountService.getRootFolder().ChildItemVOs, { @@ -60,38 +59,35 @@ export class LeanFolderResolveService { targetFolder = new FolderVO(myFiles); } - return this.api.folder - .navigateLean(targetFolder) - .pipe( - map((response: FolderResponse) => { - if (!response.isSuccessful) { - throw response; - } + try { + const folderResponse = + await this.api.folder.getWithChildrenByIdentifier(targetFolder); - return response.getFolderVO(true); - }), - ) - .toPromise() - .catch(async (response: FolderResponse) => { - this.message.showError({ - message: response.getMessage(), - translate: true, - }); - if (targetFolder.type.includes('root')) { - this.accountService - .logOut() - .then(() => { - this.router.navigate(['/login']); - }) - .catch(() => { - this.router.navigate(['/login']); - }); - } else if (state.url.includes('apps')) { - this.router.navigate(['/apps']); - } else { - this.router.navigate(['/private']); - } - return await Promise.reject(false); + if (!folderResponse.isSuccessful) { + throw folderResponse; + } + + return folderResponse.getFolderVO(true); + } catch (error) { + this.message.showError({ + message: getFolderErrorMessage(error), + translate: true, }); + if (targetFolder.type?.includes('root')) { + this.accountService + .logOut() + .then(() => { + this.router.navigate(['/login']); + }) + .catch(() => { + this.router.navigate(['/login']); + }); + } else if (state.url.includes('apps')) { + this.router.navigate(['/apps']); + } else { + this.router.navigate(['/private']); + } + return await Promise.reject(false); + } } } diff --git a/src/app/models/access-role.ts b/src/app/models/access-role.ts index 04c00a45f..1e68dac0b 100644 --- a/src/app/models/access-role.ts +++ b/src/app/models/access-role.ts @@ -62,6 +62,21 @@ export function getAccessRoleFromArchiveMembershipRole( return ARCHIVE_MEMBERSHIP_ROLE_TO_ACCESS_ROLE[archiveMembershipRole]; } +/** + * Spread into VO data so that a role Stela did not send -- or one we cannot + * translate -- leaves no accessRole field behind at all, letting permission + * checks keep using the role the v1 endpoints supplied. + */ +export function getOptionalAccessRoleField( + archiveMembershipRole: ArchiveMembershipRoleType | undefined, +): { accessRole?: AccessRoleType } { + const accessRole = getAccessRoleFromArchiveMembershipRole( + archiveMembershipRole, + ); + + return accessRole ? { accessRole } : {}; +} + // Mapping for share link permissions. Note the stela share link API // mistakenly returns "manager" where it should use "curator" -- see // https://github.com/PermanentOrg/stela/issues/540 diff --git a/src/app/shared/services/api/folder.repo.spec.ts b/src/app/shared/services/api/folder.repo.spec.ts index c8a8927ed..e9ac56675 100644 --- a/src/app/shared/services/api/folder.repo.spec.ts +++ b/src/app/shared/services/api/folder.repo.spec.ts @@ -4,7 +4,7 @@ import { 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 = { @@ -29,7 +29,11 @@ const mockStelaFolder = { displayName: 'Test Folder', downloadName: 'test-folder', imageRatio: 1.5, - paths: { names: ['path1', 'path2'] }, + paths: { + names: ['path1', 'path2'], + folderLinkIds: ['11', '22'], + archiveNumbers: ['0001-0000', '0002-0000'], + }, accessRole: 'owner', publicAt: null, sort: 'name', @@ -50,7 +54,11 @@ const fakeChildrenResponse = { id: 300, name: 'Auth Child', thumbnailUrls: { 200: 'test' }, - paths: { names: 'test' }, + paths: { + names: ['Auth Child'], + folderLinkIds: ['300'], + archiveNumbers: ['0001-0000'], + }, location: { stelaLocation: { id: 13 } }, }, ], @@ -441,67 +449,6 @@ describe('Folder repo', () => { }); }); - describe('access role translation', () => { - const convertFolder = async (overrides: Record) => { - httpV2Spy.get.and.returnValue( - of([{ items: [{ ...mockStelaFolder, ...overrides }] }]), - ); - const result = await folderRepo.getStelaFolderVOs([ - new FolderVO({ folderId: 123 }), - ]); - return result.getFolderVOs()[0]; - }; - - it("should translate Stela's role into ours", async () => { - const folder = await convertFolder({ accessRole: 'owner' }); - - expect(folder.accessRole).toBe('access.role.owner'); - }); - - it('should translate manager to manager, not curator', async () => { - const folder = await convertFolder({ accessRole: 'manager' }); - - expect(folder.accessRole).toBe('access.role.manager'); - }); - - it('should leave the role undefined when Stela sends nothing', async () => { - const folder = await convertFolder({ accessRole: undefined }); - - expect(folder.accessRole).toBeUndefined(); - }); - - it('should merge onto an existing folder without breaking its role', async () => { - const existingFolder = new FolderVO({ - folderId: '123', - accessRole: 'access.role.owner', - }); - - existingFolder.update(await convertFolder({ accessRole: 'owner' })); - - expect(existingFolder.accessRole).toBe('access.role.owner'); - }); - - it('should translate the role on child folders too', async () => { - httpV2Spy.get.and.returnValues( - of([{ items: [mockStelaFolder] }]), - of([ - { - items: [ - { ...mockStelaFolder, folderId: '999', accessRole: 'viewer' }, - ], - }, - ]), - ); - - const result = await folderRepo.getWithChildren([ - new FolderVO({ folderId: 123 }), - ]); - const child = result.getFolderVO(true).ChildItemVOs[0]; - - expect(child.accessRole).toBe('access.role.viewer'); - }); - }); - describe('Stela folder conversion', () => { const convertFolder = async (overrides: Record) => { httpV2Spy.get.and.returnValue( @@ -578,5 +525,203 @@ describe('Folder repo', () => { expect(folder.folder_linkId).toBeUndefined(); }); + + it('should map the breadcrumb archive numbers', async () => { + const folder = await convertFolder({ + paths: { + names: ['My Files', 'Photos'], + folderLinkIds: ['11', '22'], + archiveNumbers: ['0001-0000', '0002-0000'], + }, + }); + + expect(folder.pathAsArchiveNbr).toEqual(['0001-0000', '0002-0000']); + }); + + it('should map the breadcrumb link ids as numbers', async () => { + const folder = await convertFolder({ + paths: { + names: ['My Files', 'Photos'], + folderLinkIds: ['11', '22'], + archiveNumbers: ['0001-0000', '0002-0000'], + }, + }); + + expect(folder.pathAsFolder_linkId).toEqual([11, 22]); + }); + + // Stela types the path archive numbers as nullable, and the breadcrumbs read + // the three arrays by index, so an unusable entry has to leave all three. + it('should drop breadcrumb entries with no archive number', async () => { + const folder = await convertFolder({ + paths: { + names: ['My Files', 'Broken', 'Photos'], + folderLinkIds: ['11', '22', '33'], + archiveNumbers: ['0001-0000', null, '0003-0000'], + }, + }); + + expect(folder.pathAsText).toEqual(['My Files', 'Photos']); + expect(folder.pathAsArchiveNbr).toEqual(['0001-0000', '0003-0000']); + expect(folder.pathAsFolder_linkId).toEqual([11, 33]); + }); + + it('should drop breadcrumb entries with no usable link id', async () => { + const folder = await convertFolder({ + paths: { + names: ['My Files', 'Broken', 'Photos'], + folderLinkIds: ['11', ' ', '33'], + archiveNumbers: ['0001-0000', '0002-0000', '0003-0000'], + }, + }); + + expect(folder.pathAsText).toEqual(['My Files', 'Photos']); + expect(folder.pathAsArchiveNbr).toEqual(['0001-0000', '0003-0000']); + expect(folder.pathAsFolder_linkId).toEqual([11, 33]); + }); + + it('should leave the breadcrumb paths empty when Stela sends none', async () => { + const folder = await convertFolder({ paths: undefined }); + + expect(folder.pathAsText).toEqual([]); + expect(folder.pathAsArchiveNbr).toEqual([]); + expect(folder.pathAsFolder_linkId).toEqual([]); + }); + }); + + describe('getWithChildrenByIdentifier', () => { + it('should go straight to Stela when the folder already has an id', async () => { + httpV2Spy.get.and.returnValues( + of([{ items: [mockStelaFolder] }]), + of([{ items: [] }]), + ); + + await folderRepo.getWithChildrenByIdentifier( + new FolderVO({ folderId: 123 }), + ); + + expect(httpSpy.sendRequestPromise).not.toHaveBeenCalled(); + expect(httpV2Spy.get).toHaveBeenCalled(); + }); + + it('should resolve the id through v1 when the folder has none', async () => { + httpSpy.sendRequestPromise.and.resolveTo( + new FolderResponse({ + isSuccessful: true, + Results: [{ data: [{ FolderVO: { folderId: '123' } }] }], + }), + ); + httpV2Spy.get.and.returnValues( + of([{ items: [mockStelaFolder] }]), + of([{ items: [] }]), + ); + + const result = await folderRepo.getWithChildrenByIdentifier( + new FolderVO({ archiveNbr: '0001-0002', folder_linkId: 55 }), + ); + + expect(httpSpy.sendRequestPromise).toHaveBeenCalled(); + expect(httpV2Spy.get).toHaveBeenCalledWith('v2/folder', { + folderIds: ['123'], + }); + + expect(result.isSuccessful).toBeTrue(); + }); + + it('should throw the v1 response when the id cannot be resolved', async () => { + httpSpy.sendRequestPromise.and.resolveTo( + new FolderResponse({ isSuccessful: false }), + ); + + await expectAsync( + folderRepo.getWithChildrenByIdentifier( + new FolderVO({ archiveNbr: '0001-0002', folder_linkId: 55 }), + ), + ).toBeRejected(); + }); + }); + + describe('access role translation', () => { + const convertFolder = async (overrides: Record) => { + httpV2Spy.get.and.returnValue( + of([{ items: [{ ...mockStelaFolder, ...overrides }] }]), + ); + const result = await folderRepo.getStelaFolderVOs([ + new FolderVO({ folderId: 123 }), + ]); + return result.getFolderVOs()[0]; + }; + + it("should translate Stela's role into ours", async () => { + const folder = await convertFolder({ accessRole: 'owner' }); + + expect(folder.accessRole).toBe('access.role.owner'); + }); + + it('should translate manager to manager, not curator', async () => { + const folder = await convertFolder({ accessRole: 'manager' }); + + expect(folder.accessRole).toBe('access.role.manager'); + }); + + it('should add no role at all when Stela sends nothing', async () => { + const folder = await convertFolder({ accessRole: undefined }); + + expect(Object.hasOwn(folder, 'accessRole')).toBeFalse(); + }); + + it('should add no role at all when Stela sends null', async () => { + const folder = await convertFolder({ accessRole: null }); + + expect(Object.hasOwn(folder, 'accessRole')).toBeFalse(); + }); + + it('should add no role at all when Stela sends one we cannot translate', async () => { + const folder = await convertFolder({ accessRole: 'archivist' }); + + expect(Object.hasOwn(folder, 'accessRole')).toBeFalse(); + }); + + it('should merge onto an existing folder without breaking its role', async () => { + const existingFolder = new FolderVO({ + folderId: '123', + accessRole: 'access.role.owner', + }); + + existingFolder.update(await convertFolder({ accessRole: 'owner' })); + + expect(existingFolder.accessRole).toBe('access.role.owner'); + }); + + it('should leave an existing role alone when Stela sends none', async () => { + const existingFolder = new FolderVO({ + folderId: '123', + accessRole: 'access.role.owner', + }); + + existingFolder.update(await convertFolder({ accessRole: undefined })); + + expect(existingFolder.accessRole).toBe('access.role.owner'); + }); + + it('should translate the role on child folders too', async () => { + httpV2Spy.get.and.returnValues( + of([{ items: [mockStelaFolder] }]), + of([ + { + items: [ + { ...mockStelaFolder, folderId: '999', accessRole: 'viewer' }, + ], + }, + ]), + ); + + const result = await folderRepo.getWithChildren([ + new FolderVO({ folderId: 123 }), + ]); + const child = result.getFolderVO(true).ChildItemVOs[0]; + + expect(child.accessRole).toBe('access.role.viewer'); + }); }); }); diff --git a/src/app/shared/services/api/folder.repo.ts b/src/app/shared/services/api/folder.repo.ts index 148fb30c8..b2571f56f 100644 --- a/src/app/shared/services/api/folder.repo.ts +++ b/src/app/shared/services/api/folder.repo.ts @@ -3,7 +3,7 @@ import { BaseResponse, BaseRepo } from '@shared/services/api/base'; import { firstValueFrom, Observable } from 'rxjs'; import { DataStatus } from '@models/data-status.enum'; import { - getAccessRoleFromArchiveMembershipRole, + getOptionalAccessRoleField, type ArchiveMembershipRoleType, } from '@models/access-role'; import { ShareLink } from '@root/app/share-links/models/share-link'; @@ -74,8 +74,10 @@ interface StelaFolder { imageRatio: number; paths: { names: string[]; + folderLinkIds: string[]; + archiveNumbers: Array; }; - accessRole: ArchiveMembershipRoleType; + accessRole?: ArchiveMembershipRoleType; publicAt: string; sort: string; thumbnailUrls?: { @@ -100,10 +102,7 @@ type StelaFolderChild = StelaFolder | StelaRecord; const isStelaRecord = (child: StelaFolderChild): child is StelaRecord => child && 'recordId' in child; -// Returns undefined rather than NaN for a missing id, so callers can tell -// "not provided" apart from a real link id. Accepts numbers as well as strings -// because different Stela endpoints disagree on which one they send. -const toFolderLinkId = ( +export const toFolderLinkId = ( folderLinkId: string | number | null | undefined, ): number | undefined => { if (typeof folderLinkId === 'number') { @@ -120,6 +119,40 @@ const toFolderLinkId = ( return Number.isFinite(parsedFolderLinkId) ? parsedFolderLinkId : undefined; }; +interface FolderBreadcrumbPaths { + pathAsText: string[]; + pathAsArchiveNbr: string[]; + pathAsFolder_linkId: number[]; +} + +// The breadcrumb components read the three path arrays positionally, so an +// ancestor without an archive number or a link id would build a URL nothing can +// navigate to. Dropping it from all three arrays keeps them aligned. +const convertStelaPathsToBreadcrumbPaths = ( + paths: StelaFolder['paths'] | undefined, +): FolderBreadcrumbPaths => { + const breadcrumbPaths: FolderBreadcrumbPaths = { + pathAsText: [], + pathAsArchiveNbr: [], + pathAsFolder_linkId: [], + }; + + (paths?.names ?? []).forEach((name, pathIndex) => { + const archiveNbr = paths.archiveNumbers?.[pathIndex]; + const folderLinkId = toFolderLinkId(paths.folderLinkIds?.[pathIndex]); + + if (!archiveNbr || folderLinkId === undefined) { + return; + } + + breadcrumbPaths.pathAsText.push(name); + breadcrumbPaths.pathAsArchiveNbr.push(archiveNbr); + breadcrumbPaths.pathAsFolder_linkId.push(folderLinkId); + }); + + return breadcrumbPaths; +}; + const convertStelaFolderToFolderVO = (stelaFolder: StelaFolder): FolderVO => { stelaFolder.children ??= []; const childFolderVOs = stelaFolder.children @@ -128,8 +161,10 @@ const convertStelaFolderToFolderVO = (stelaFolder: StelaFolder): FolderVO => { const childRecordVOs = stelaFolder.children .filter(isStelaRecord) .map(convertStelaRecordToRecordVO); + const { accessRole: stelaAccessRole, ...stelaFolderWithoutAccessRole } = + stelaFolder; return new FolderVO({ - ...stelaFolder, + ...stelaFolderWithoutAccessRole, folderId: stelaFolder.folderId, archiveId: stelaFolder.archive?.id, archiveNbr: stelaFolder.archiveNumber, @@ -156,7 +191,7 @@ const convertStelaFolderToFolderVO = (stelaFolder: StelaFolder): FolderVO => { view: stelaFolder.view, imageRatio: stelaFolder.imageRatio, type: stelaFolder.type, - accessRole: getAccessRoleFromArchiveMembershipRole(stelaFolder.accessRole), + ...getOptionalAccessRoleField(stelaAccessRole), thumbStatus: stelaFolder.status, thumbURL200: stelaFolder.thumbnailUrls?.['200'], thumbURL500: stelaFolder.thumbnailUrls?.['500'], @@ -168,7 +203,7 @@ const convertStelaFolderToFolderVO = (stelaFolder: StelaFolder): FolderVO => { status: stelaFolder.status, publicDT: stelaFolder.publicAt, parentFolderId: stelaFolder.parentFolder?.id, - pathAsText: stelaFolder.paths?.names, + ...convertStelaPathsToBreadcrumbPaths(stelaFolder.paths), ParentFolderVOs: [new FolderVO({ folderId: stelaFolder.parentFolder?.id })], ChildFolderVOs: childFolderVOs, RecordVOs: childRecordVOs, @@ -396,6 +431,27 @@ export class FolderRepo extends BaseRepo { return folderResponse; } + /** + * Stela can only look a folder up by numeric folderId, but our routes and + * breadcrumbs address folders by archiveNbr + folder_linkId, so the id is + * resolved through the v1 endpoint first. Separate from getWithChildren + * because that v1 lookup needs an auth token a share-token visitor lacks. + */ + public async getWithChildrenByIdentifier( + folderVO: FolderVO, + ): Promise { + if (folderVO.folderId) { + return await this.getWithChildren([folderVO]); + } + + const identityResponse = await this.get([folderVO]); + if (!identityResponse.isSuccessful) { + throw identityResponse; + } + + return await this.getWithChildren([identityResponse.getFolderVO()]); + } + public navigateLean(folderVO: FolderVO): Observable { const data = [ { diff --git a/src/app/shared/services/api/record.repo.spec.ts b/src/app/shared/services/api/record.repo.spec.ts index 4cacd2974..3cd8feceb 100644 --- a/src/app/shared/services/api/record.repo.spec.ts +++ b/src/app/shared/services/api/record.repo.spec.ts @@ -485,12 +485,30 @@ describe('RecordRepo', () => { expect(record.accessRole).toBe('access.role.manager'); }); - it('should leave the role undefined when Stela sends nothing', () => { + it('should add no role at all when Stela sends nothing', () => { const record = convertStelaRecordToRecordVO({ ...baseStelaRecord, } as any); - expect(record.accessRole).toBeUndefined(); + expect(Object.hasOwn(record, 'accessRole')).toBeFalse(); + }); + + it('should add no role at all when Stela sends null', () => { + const record = convertStelaRecordToRecordVO({ + ...baseStelaRecord, + accessRole: null, + } as any); + + expect(Object.hasOwn(record, 'accessRole')).toBeFalse(); + }); + + it('should add no role at all when Stela sends one we cannot translate', () => { + const record = convertStelaRecordToRecordVO({ + ...baseStelaRecord, + accessRole: 'archivist', + } as any); + + expect(Object.hasOwn(record, 'accessRole')).toBeFalse(); }); it('should merge onto an existing record without breaking its role', () => { diff --git a/src/app/shared/services/api/record.repo.ts b/src/app/shared/services/api/record.repo.ts index 581eac12e..5bd28a213 100644 --- a/src/app/shared/services/api/record.repo.ts +++ b/src/app/shared/services/api/record.repo.ts @@ -20,7 +20,7 @@ import { FileFormat, PermanentFile } from '@models/file-vo'; import { ShareStatus } from '@models/share-vo'; import { AccessRoleType, - getAccessRoleFromArchiveMembershipRole, + getOptionalAccessRoleField, type ArchiveMembershipRoleType, } from '@models/access-role'; import { ShareLink } from '@root/app/share-links/models/share-link'; @@ -112,7 +112,7 @@ export interface StelaShare { } export type StelaRecord = Omit & { tags: Array | null; - accessRole: ArchiveMembershipRoleType; + accessRole?: ArchiveMembershipRoleType; archiveNumber: string; displayDate: string; displayTime?: string; @@ -204,9 +204,12 @@ export const convertStelaLocationToLocnVOData = ( export const convertStelaRecordToRecordVO = ( stelaRecord: StelaRecord, -): RecordVO => - new RecordVO({ - ...stelaRecord, +): RecordVO => { + const { accessRole: stelaAccessRole, ...stelaRecordWithoutAccessRole } = + stelaRecord; + + return new RecordVO({ + ...stelaRecordWithoutAccessRole, thumbURL200: stelaRecord.thumbnailUrls?.['200'] ?? stelaRecord.thumbURL200, thumbURL500: stelaRecord.thumbnailUrls?.['500'] ?? stelaRecord.thumbURL500, thumbURL1000: @@ -219,7 +222,7 @@ export const convertStelaRecordToRecordVO = ( convertStelaTagToTagVO(stelaTag, stelaRecord.archiveId), ), archiveNbr: stelaRecord.archiveNumber, - accessRole: getAccessRoleFromArchiveMembershipRole(stelaRecord.accessRole), + ...getOptionalAccessRoleField(stelaAccessRole), displayDT: stelaRecord.displayDate, displayTime: stelaRecord.displayTime, folder_linkId: Number.parseInt(stelaRecord.folderLinkId, 10), @@ -236,6 +239,7 @@ export const convertStelaRecordToRecordVO = ( TimezoneVO: CENTRAL_TIMEZONE_VO, ShareVOs: (stelaRecord.shares ?? []).map(convertStelaSharetoShareVO), }); +}; export class RecordRepo extends BaseRepo { private async getRecordIdByArchiveNbr(archiveNbr: string): Promise { diff --git a/src/app/shared/utilities/folder-error-message.ts b/src/app/shared/utilities/folder-error-message.ts new file mode 100644 index 000000000..c631937f3 --- /dev/null +++ b/src/app/shared/utilities/folder-error-message.ts @@ -0,0 +1,15 @@ +import { FolderResponse } from '@shared/services/api/index.repo'; + +export const GENERIC_FOLDER_ERROR_MESSAGE = 'error.generic.internal'; + +/** + * Legacy endpoints failed with a FolderResponse carrying a translatable message, + * while Stela rejects with the raw HTTP error, which has none. + */ +export function getFolderErrorMessage(error: unknown): string { + if (error instanceof FolderResponse) { + return error.getMessage() ?? GENERIC_FOLDER_ERROR_MESSAGE; + } + + return GENERIC_FOLDER_ERROR_MESSAGE; +} diff --git a/src/app/views/components/timeline-view/timeline-breadcrumbs/timeline-breadcrumbs.component.spec.ts b/src/app/views/components/timeline-view/timeline-breadcrumbs/timeline-breadcrumbs.component.spec.ts index 6f63e12f9..b027cac3b 100644 --- a/src/app/views/components/timeline-view/timeline-breadcrumbs/timeline-breadcrumbs.component.spec.ts +++ b/src/app/views/components/timeline-view/timeline-breadcrumbs/timeline-breadcrumbs.component.spec.ts @@ -1,25 +1,66 @@ -// import { async, ComponentFixture, TestBed } from '@angular/core/testing'; - -// import { TimelineBreadcrumbsComponent } from './timeline-breadcrumbs.component'; - -// describe('TimelineBreadcrumbsComponent', () => { -// let component: TimelineBreadcrumbsComponent; -// let fixture: ComponentFixture; - -// beforeEach(async(() => { -// TestBed.configureTestingModule({ -// declarations: [ TimelineBreadcrumbsComponent ] -// }) -// .compileComponents(); -// })); - -// beforeEach(() => { -// fixture = TestBed.createComponent(TimelineBreadcrumbsComponent); -// component = fixture.componentInstance; -// fixture.detectChanges(); -// }); - -// it('should create', () => { -// expect(component).toBeTruthy(); -// }); -// }); +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import * as Testing from '@root/test/testbedConfig'; +import { cloneDeep } from 'lodash'; + +import { DataService } from '@shared/services/data/data.service'; +import { FolderVO } from '@root/app/models'; +import { TimelineBreadcrumbsComponent } from './timeline-breadcrumbs.component'; + +describe('TimelineBreadcrumbsComponent', () => { + let component: TimelineBreadcrumbsComponent; + let fixture: ComponentFixture; + let dataService: DataService; + + beforeEach(async () => { + const config = cloneDeep(Testing.BASE_TEST_CONFIG); + + config.declarations.push(TimelineBreadcrumbsComponent); + config.providers.push(DataService); + + TestBed.configureTestingModule(config).compileComponents(); + + fixture = TestBed.createComponent(TimelineBreadcrumbsComponent); + component = fixture.componentInstance; + dataService = TestBed.inject(DataService); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should build a crumb per path entry from a converted folder', () => { + dataService.currentFolder = new FolderVO({ + pathAsText: ['My Files', 'Photos'], + pathAsArchiveNbr: ['0001-0000', '0002-0000'], + pathAsFolder_linkId: [11, 22], + }); + + component.setFolderBreadcrumbs(); + + expect(component.breadcrumbs.length).toBe(2); + expect(component.breadcrumbs[0]).toEqual( + jasmine.objectContaining({ + type: 'folder', + text: 'My Files', + archiveNbr: '0001-0000', + folder_linkId: 11, + }), + ); + + expect(component.breadcrumbs[1]).toEqual( + jasmine.objectContaining({ + text: 'Photos', + archiveNbr: '0002-0000', + folder_linkId: 22, + }), + ); + }); + + it('should build no crumbs without a current folder', () => { + dataService.currentFolder = undefined; + + component.setFolderBreadcrumbs(); + + expect(component.breadcrumbs).toEqual([]); + }); +}); diff --git a/src/app/views/components/timeline-view/timeline-view.component.spec.ts b/src/app/views/components/timeline-view/timeline-view.component.spec.ts index 5ef60737c..82ac09efd 100644 --- a/src/app/views/components/timeline-view/timeline-view.component.spec.ts +++ b/src/app/views/components/timeline-view/timeline-view.component.spec.ts @@ -1,25 +1,126 @@ -// import { async, ComponentFixture, TestBed } from '@angular/core/testing'; - -// import { TimelineViewComponent } from './timeline-view.component'; - -// describe('TimelineViewComponent', () => { -// let component: TimelineViewComponent; -// let fixture: ComponentFixture; - -// beforeEach(async(() => { -// TestBed.configureTestingModule({ -// declarations: [ TimelineViewComponent ] -// }) -// .compileComponents(); -// })); - -// beforeEach(() => { -// fixture = TestBed.createComponent(TimelineViewComponent); -// component = fixture.componentInstance; -// fixture.detectChanges(); -// }); - -// it('should create', () => { -// expect(component).toBeTruthy(); -// }); -// }); +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import * as Testing from '@root/test/testbedConfig'; +import { cloneDeep } from 'lodash'; +import { ActivatedRoute } from '@angular/router'; +import { Subscription } from 'rxjs'; + +import { ApiService } from '@shared/services/api/api.service'; +import { DataService } from '@shared/services/data/data.service'; +import { MessageService } from '@shared/services/message/message.service'; +import { FolderResponse } from '@shared/services/api/index.repo'; +import { FolderVO } from '@root/app/models'; +import { TimelineViewComponent } from './timeline-view.component'; + +const buildFolderResponse = (displayName: string) => + new FolderResponse({ + isSuccessful: true, + Results: [{ data: [{ FolderVO: { displayName, ChildItemVOs: [] } }] }], + }); + +describe('TimelineViewComponent', () => { + let component: TimelineViewComponent; + let fixture: ComponentFixture; + let api: ApiService; + let dataService: DataService; + let message: MessageService; + + beforeEach(async () => { + const config = cloneDeep(Testing.BASE_TEST_CONFIG); + + config.declarations.push(TimelineViewComponent); + config.providers.push(DataService); + config.providers.push(ApiService); + config.providers.push({ + provide: ActivatedRoute, + useValue: { + snapshot: { data: { currentFolder: new FolderVO({}) }, params: {} }, + }, + }); + + TestBed.configureTestingModule(config).compileComponents(); + + // Deliberately no detectChanges: ngOnInit stands up vis-timeline against a + // real canvas, and none of that is under test here. + fixture = TestBed.createComponent(TimelineViewComponent); + component = fixture.componentInstance; + + api = TestBed.inject(ApiService); + dataService = TestBed.inject(DataService); + message = TestBed.inject(MessageService); + + // ngOnDestroy still runs when the fixture is torn down, and it tears down + // the two things the skipped lifecycle hooks would have created. + component.timeline = { destroy: () => {} } as any; + (component as any).dataServiceSubscription = new Subscription(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('onFolderClick', () => { + it('should publish the loaded folder as the current folder', async () => { + spyOn(api.folder, 'getWithChildrenByIdentifier').and.resolveTo( + buildFolderResponse('Photos'), + ); + const setCurrentFolderSpy = spyOn(dataService, 'setCurrentFolder'); + + await component.onFolderClick(new FolderVO({ folderId: '77' })); + + expect(setCurrentFolderSpy).toHaveBeenCalled(); + expect(setCurrentFolderSpy.calls.mostRecent().args[0].displayName).toBe( + 'Photos', + ); + + expect(component.isNavigating).toBeFalse(); + }); + + it('should pass a breadcrumb folder through unchanged', async () => { + const getSpy = spyOn( + api.folder, + 'getWithChildrenByIdentifier', + ).and.resolveTo(buildFolderResponse('Ancestor')); + spyOn(dataService, 'setCurrentFolder'); + + await component.onFolderClick( + new FolderVO({ archiveNbr: '0001-0005', folder_linkId: 99 }), + ); + + const requestedFolder = getSpy.calls.mostRecent().args[0]; + + expect(requestedFolder.archiveNbr).toBe('0001-0005'); + expect(requestedFolder.folder_linkId).toBe(99); + expect(requestedFolder.folderId).toBeUndefined(); + }); + + it('should show an error instead of rejecting when the load fails', async () => { + spyOn(api.folder, 'getWithChildrenByIdentifier').and.rejectWith( + new Error('Network down'), + ); + const setCurrentFolderSpy = spyOn(dataService, 'setCurrentFolder'); + const showErrorSpy = spyOn(message, 'showError'); + + await expectAsync( + component.onFolderClick(new FolderVO({ folderId: '77' })), + ).toBeResolved(); + + expect(showErrorSpy).toHaveBeenCalledWith({ + message: 'error.generic.internal', + translate: true, + }); + + expect(setCurrentFolderSpy).not.toHaveBeenCalled(); + }); + + it('should stop navigating even when the load fails', async () => { + spyOn(api.folder, 'getWithChildrenByIdentifier').and.rejectWith( + new Error('Network down'), + ); + spyOn(message, 'showError'); + + await component.onFolderClick(new FolderVO({ folderId: '77' })); + + expect(component.isNavigating).toBeFalse(); + }); + }); +}); diff --git a/src/app/views/components/timeline-view/timeline-view.component.ts b/src/app/views/components/timeline-view/timeline-view.component.ts index c67f46154..bcea37f3f 100644 --- a/src/app/views/components/timeline-view/timeline-view.component.ts +++ b/src/app/views/components/timeline-view/timeline-view.component.ts @@ -27,6 +27,8 @@ import { find, throttle, maxBy, debounce, countBy } from 'lodash'; import { Subscription } from 'rxjs'; import { FolderViewService } from '@shared/services/folder-view/folder-view.service'; import { DeviceService } from '@shared/services/device/device.service'; +import { MessageService } from '@shared/services/message/message.service'; +import { getFolderErrorMessage } from '@shared/utilities/folder-error-message'; import { slideUpAnimation } from '@shared/animations'; import { TimelineBreadcrumbsComponent, @@ -148,6 +150,7 @@ export class TimelineViewComponent implements OnInit, AfterViewInit, OnDestroy { private elementRef: ElementRef, private fvService: FolderViewService, private device: DeviceService, + private message: MessageService, ) { this.currentTimespan = TimelineGroupTimespan.Year; this.dataService.showBreadcrumbs = false; @@ -485,11 +488,23 @@ export class TimelineViewComponent implements OnInit, AfterViewInit, OnDestroy { if (folder.isFetching) { await folder.fetched; } - const folderResponse = await this.api.folder - .navigateLean(folder) - .toPromise(); - this.dataService.setCurrentFolder(folderResponse.getFolderVO(true)); - this.isNavigating = false; + try { + const folderResponse = + await this.api.folder.getWithChildrenByIdentifier(folder); + + if (!folderResponse.isSuccessful) { + throw folderResponse; + } + + this.dataService.setCurrentFolder(folderResponse.getFolderVO(true)); + } catch (error) { + this.message.showError({ + message: getFolderErrorMessage(error), + translate: true, + }); + } finally { + this.isNavigating = false; + } } async onRecordClick(record: RecordVO) {