diff --git a/src/app/file-browser/components/publish/publish.component.spec.ts b/src/app/file-browser/components/publish/publish.component.spec.ts index 8319e2f87..f31fa0bfe 100644 --- a/src/app/file-browser/components/publish/publish.component.spec.ts +++ b/src/app/file-browser/components/publish/publish.component.spec.ts @@ -3,59 +3,89 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { AccountService } from '@shared/services/account/account.service'; import { FolderVO, RecordVO } from '@models/index'; import { FolderResponse } from '@shared/services/api/folder.repo'; -import { Observable } from 'rxjs'; import { MessageService } from '@shared/services/message/message.service'; import { EventService } from '@shared/services/event/event.service'; +import { GoogleAnalyticsService } from '@shared/services/google-analytics/google-analytics.service'; import { DIALOG_DATA, DialogRef } from '@angular/cdk/dialog'; import { Router } from '@angular/router'; import { ArchiveVO } from '../../../models/archive-vo'; import { ApiService } from '../../../shared/services/api/api.service'; import { PublishComponent } from './publish.component'; -const mockAccountService = { - getArchive: () => { - const archive = new ArchiveVO({ accessRole: 'access.role.viewer' }); - return archive; - }, - getRootFolder: () => ({ - ChildItemVOs: [], - }), - refreshAccountDebounced: () => {}, -}; +const PUBLIC_ROOT = new FolderVO({ + folderId: '140683', + folder_linkId: 55, + archiveNbr: '0001-0000', + type: 'type.folder.root.public', +}); + +// Shaped the way getWithChildren returns it: a v1-style envelope whose child items +// are the converted Stela folders. +function publicRootResponseWithChildren( + children: Array>, +): FolderResponse { + return new FolderResponse({ + isSuccessful: true, + isSystemUp: true, + Results: [ + { + data: [ + { + FolderVO: { + ...PUBLIC_ROOT, + ChildItemVOs: children, + }, + }, + ], + }, + ], + }); +} class MockDialogRef { close() {} } -const mockApiService = { - folder: { - copy: async ( - folderVOs: FolderVO[], - destination: FolderVO, - ): Promise => await Promise.resolve(new FolderResponse({})), - navigateLean: (folder: FolderVO): Observable => - new Observable(), - }, - publish: { - getInternetArchiveLink: async () => ({ - getPublishIaVO: () => null, - }), - publishToInternetArchive: async () => ({ - getPublishIaVO: () => null, - }), - }, - record: { - copy: async () => ({ - getRecordVO: () => new RecordVO({}), - }), - }, -}; - describe('PublishComponent', () => { let component: PublishComponent; let fixture: ComponentFixture; + let mockApiService: any; + let mockAccountService: any; + let showErrorSpy: jasmine.Spy; beforeEach(async () => { + mockAccountService = { + getArchive: () => new ArchiveVO({ accessRole: 'access.role.owner' }), + getRootFolder: () => ({ ChildItemVOs: [PUBLIC_ROOT] }), + refreshAccountDebounced: () => {}, + }; + + mockApiService = { + folder: { + copy: jasmine + .createSpy('copy') + .and.resolveTo(new FolderResponse({ isSuccessful: true })), + getWithChildren: jasmine + .createSpy('getWithChildren') + .and.resolveTo(publicRootResponseWithChildren([])), + }, + publish: { + getInternetArchiveLink: async () => ({ + getPublishIaVO: () => null, + }), + publishToInternetArchive: async () => ({ + getPublishIaVO: () => null, + }), + }, + record: { + copy: async () => ({ + getRecordVO: () => new RecordVO({}), + }), + }, + }; + + showErrorSpy = jasmine.createSpy('showError'); + await TestBed.configureTestingModule({ declarations: [PublishComponent], providers: [ @@ -71,15 +101,15 @@ describe('PublishComponent', () => { EventService, { provide: MessageService, - useValue: { - showError: () => {}, - }, + useValue: { showError: showErrorSpy }, }, { provide: Router, - useValue: { - navigate: () => {}, - }, + useValue: { navigate: () => {} }, + }, + { + provide: GoogleAnalyticsService, + useValue: { sendEvent: () => {} }, }, ], schemas: [CUSTOM_ELEMENTS_SCHEMA], @@ -95,6 +125,10 @@ describe('PublishComponent', () => { }); it('should disaple the public to internet archive button if the user does not have the correct access role', () => { + mockAccountService.getArchive = () => + new ArchiveVO({ accessRole: 'access.role.viewer' }); + fixture = TestBed.createComponent(PublishComponent); + component = fixture.componentInstance; component.publicItem = new RecordVO({ recordId: 1 }); component.publishIa = null; component.publicLink = null; @@ -105,4 +139,129 @@ describe('PublishComponent', () => { expect(button.disabled).toBeTruthy(); }); + + describe('publishing a folder', () => { + beforeEach(() => { + component.sourceItem = new FolderVO({ + folderId: '900', + archiveNbr: '0002-0001', + folder_linkId: 12, + displayName: 'Trip to Iceland', + type: 'type.folder.private', + }); + }); + + it('should load the public root through getWithChildren', async () => { + mockApiService.folder.getWithChildren.and.resolveTo( + publicRootResponseWithChildren([ + { + folderId: '901', + archiveNbr: '0001-0002', + folder_linkId: 71, + displayName: 'Trip to Iceland', + type: 'type.folder.public', + updatedDT: '2026-08-10T10:00:00Z', + ChildItemVOs: [], + }, + ]), + ); + + await component.publishItem(); + + expect(mockApiService.folder.getWithChildren).toHaveBeenCalledWith([ + PUBLIC_ROOT, + ]); + + expect(mockApiService.folder.getWithChildren).toHaveBeenCalledTimes(1); + }); + + it('should pick the most recently updated folder matching the source name', async () => { + mockApiService.folder.getWithChildren.and.resolveTo( + publicRootResponseWithChildren([ + { + folderId: '901', + archiveNbr: '0001-0002', + folder_linkId: 71, + displayName: 'Trip to Iceland', + type: 'type.folder.public', + updatedDT: '2026-01-01T10:00:00Z', + ChildItemVOs: [], + }, + { + folderId: '902', + archiveNbr: '0001-0003', + folder_linkId: 72, + displayName: 'Trip to Iceland', + type: 'type.folder.public', + updatedDT: '2026-08-10T10:00:00Z', + ChildItemVOs: [], + }, + ]), + ); + + await component.publishItem(); + + // The newer of the two copies. This asserts the selection given a mapped + // updatedDT; that the Stela conversion actually populates it is covered + // in folder.repo.spec.ts. + expect(component.publicItem.folder_linkId).toBe(72); + expect(component.publicLink).toContain('0001-0003'); + }); + + it('should ignore child records when looking for the copy', async () => { + mockApiService.folder.getWithChildren.and.resolveTo( + publicRootResponseWithChildren([ + { + recordId: '500', + archiveNbr: '0001-0009', + folder_linkId: 80, + displayName: 'Trip to Iceland', + updatedDT: '2026-08-10T12:00:00Z', + }, + { + folderId: '901', + archiveNbr: '0001-0002', + folder_linkId: 71, + displayName: 'Trip to Iceland', + type: 'type.folder.public', + updatedDT: '2026-08-10T10:00:00Z', + ChildItemVOs: [], + }, + ]), + ); + + await component.publishItem(); + + expect(component.publicItem instanceof FolderVO).toBeTrue(); + expect(component.publicItem.folder_linkId).toBe(71); + }); + + it('should surface a generic error when the request rejects without a message', async () => { + mockApiService.folder.getWithChildren.and.rejectWith( + new Error('500 from the server'), + ); + + await component.publishItem(); + + expect(showErrorSpy).toHaveBeenCalledWith({ + message: 'error.generic.internal', + translate: true, + }); + + expect(component.waiting).toBeFalse(); + }); + + it('should keep showing the server message when one is available', async () => { + mockApiService.folder.copy.and.rejectWith({ + getMessage: () => 'warning.record.copy_status', + }); + + await component.publishItem(); + + expect(showErrorSpy).toHaveBeenCalledWith({ + message: + 'Sorry, this record cannot be copied or published until processing completes.', + }); + }); + }); }); diff --git a/src/app/file-browser/components/publish/publish.component.ts b/src/app/file-browser/components/publish/publish.component.ts index 6c418e652..1c3b4aec1 100644 --- a/src/app/file-browser/components/publish/publish.component.ts +++ b/src/app/file-browser/components/publish/publish.component.ts @@ -8,7 +8,6 @@ import { PublicLinkPipe } from '@shared/pipes/public-link.pipe'; import { AccountService } from '@shared/services/account/account.service'; import { GoogleAnalyticsService } from '@shared/services/google-analytics/google-analytics.service'; import { EVENTS } from '@shared/services/google-analytics/events'; -import { FolderResponse } from '@shared/services/api/index.repo'; import { PublicRoutePipe } from '@shared/pipes/public-route.pipe'; import { Router } from '@angular/router'; import { PublishIaData } from '@models/publish-ia-vo'; @@ -80,9 +79,9 @@ export class PublishComponent { let tries = 0; while (!this.publicItem && tries < 10) { tries += 1; - const publicRootResponse = (await this.api.folder - .navigateLean(publicRoot) - .toPromise()) as FolderResponse; + const publicRootResponse = await this.api.folder.getWithChildren([ + publicRoot, + ]); const publicRootFull = publicRootResponse.getFolderVO(true); const publicFolders: FolderVO[] = publicRootFull.ChildItemVOs.filter( (i) => i instanceof FolderVO, @@ -125,6 +124,14 @@ export class PublishComponent { message: err.getMessage(), }); } + } else { + // getWithChildren rejects with the raw HTTP error rather than a + // FolderResponse, so there is no server message to read. Without + // this branch the failure would be swallowed silently. + this.messageService.showError({ + message: 'error.generic.internal', + translate: true, + }); } } finally { this.waiting = false; diff --git a/src/app/shared/services/api/folder.repo.spec.ts b/src/app/shared/services/api/folder.repo.spec.ts index 1e8033331..e97fe0412 100644 --- a/src/app/shared/services/api/folder.repo.spec.ts +++ b/src/app/shared/services/api/folder.repo.spec.ts @@ -288,6 +288,50 @@ describe('Folder repo', () => { }); }); + describe('folder timestamps', () => { + it('should map createdAt and updatedAt onto the FolderVO', async () => { + const folderVO = new FolderVO({ folderId: 123 }); + + httpV2Spy.get.and.returnValue(of([{ items: [mockStelaFolder] }])); + + const result = await folderRepo.getStelaFolderVOs([folderVO]); + const folder = result.getFolderVOs()[0]; + + expect(folder.createdDT).toBe('2024-01-01T00:00:00Z'); + expect(folder.updatedDT).toBe('2024-01-02T00:00:00Z'); + }); + + it('should map timestamps onto child folders too, so callers can pick the most recent one', async () => { + const olderChild = { + ...mockStelaFolder, + folderId: '900', + displayName: 'Older', + updatedAt: '2024-03-01T00:00:00Z', + }; + const newerChild = { + ...mockStelaFolder, + folderId: '901', + displayName: 'Newer', + updatedAt: '2024-05-01T00:00:00Z', + }; + + httpV2Spy.get.and.returnValues( + of([{ items: [mockStelaFolder] }]), + of([{ items: [olderChild, newerChild] }]), + ); + + const result = await folderRepo.getWithChildren([ + new FolderVO({ folderId: 123 }), + ]); + const children = result.getFolderVO(true).ChildItemVOs; + + expect(children.map((child) => child.updatedDT)).toEqual([ + '2024-03-01T00:00:00Z', + '2024-05-01T00:00:00Z', + ]); + }); + }); + describe('getStelaFolderVOs', () => { it('should fetch single folder and return FolderResponse', async () => { const folderVO = new FolderVO({ folderId: 123 }); diff --git a/src/app/shared/services/api/folder.repo.ts b/src/app/shared/services/api/folder.repo.ts index ef687f720..e2ecf9a53 100644 --- a/src/app/shared/services/api/folder.repo.ts +++ b/src/app/shared/services/api/folder.repo.ts @@ -138,6 +138,11 @@ const convertStelaFolderToFolderVO = (stelaFolder: StelaFolder): FolderVO => { displayTime: stelaFolder.displayTime, derivedDT: stelaFolder.displayTimestamp, derivedEndDT: stelaFolder.displayEndTimestamp, + // Stela names these createdAt / updatedAt. Records already map them; folders + // did not, so anything picking the most recently updated folder was comparing + // undefined values. + createdDT: stelaFolder.createdAt, + updatedDT: stelaFolder.updatedAt, note: '', description: stelaFolder.description, sort: stelaFolder.sort,