From 0bd68e7e95d4b4b96df3d98481aa4048a799511b Mon Sep 17 00:00:00 2001 From: Oleh Paduchak Date: Fri, 5 Sep 2025 17:35:13 +0300 Subject: [PATCH 1/5] feat(datacite-tracker): implemented file view and download tracking --- .../file-revisions.component.ts | 7 ++- .../files/mappers/resource-metadata.mapper.ts | 4 +- .../files/models/file-target.model.ts | 1 + .../models/get-file-target-response.model.ts | 6 ++- .../get-resource-short-info-response.model.ts | 3 +- .../models/get-short-info-response.model.ts | 3 +- .../file-detail/file-detail.component.ts | 10 ++-- .../files/pages/files/files.component.ts | 4 ++ .../share-and-download.component.html | 2 +- .../share-and-download.component.ts | 8 ++++ .../preprint-details.component.ts | 14 ++---- .../project-overview.component.spec.ts | 14 +++--- .../overview/project-overview.component.ts | 15 ++---- .../features/registry/registry.component.ts | 15 ++---- .../datacite-tracker.component.ts | 36 -------------- .../files-tree/files-tree.component.ts | 17 +++++-- src/app/shared/components/index.ts | 1 - src/app/shared/mappers/files/files.mapper.ts | 1 + src/app/shared/mappers/identifiers.mapper.ts | 14 ++++++ .../identifiers/identifier-json-api.model.ts | 12 +++++ .../{ => identifiers}/identifier.model.ts | 0 src/app/shared/models/identifiers/index.ts | 2 + src/app/shared/models/index.ts | 2 +- .../nodes/base-node-embeds-json-api.model.ts | 7 +-- .../shared/models/resource-metadata.model.ts | 3 ++ .../services/datacite/datacite.service.ts | 47 ++++++++++++++++++- src/app/shared/services/files.service.ts | 3 +- 27 files changed, 154 insertions(+), 97 deletions(-) delete mode 100644 src/app/shared/components/datacite-tracker/datacite-tracker.component.ts create mode 100644 src/app/shared/mappers/identifiers.mapper.ts create mode 100644 src/app/shared/models/identifiers/identifier-json-api.model.ts rename src/app/shared/models/{ => identifiers}/identifier.model.ts (100%) create mode 100644 src/app/shared/models/identifiers/index.ts diff --git a/src/app/features/files/components/file-revisions/file-revisions.component.ts b/src/app/features/files/components/file-revisions/file-revisions.component.ts index 2fc72a763..9df816239 100644 --- a/src/app/features/files/components/file-revisions/file-revisions.component.ts +++ b/src/app/features/files/components/file-revisions/file-revisions.component.ts @@ -10,11 +10,12 @@ import { map, of } from 'rxjs'; import { DatePipe } from '@angular/common'; import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; -import { toSignal } from '@angular/core/rxjs-interop'; +import { toObservable, toSignal } from '@angular/core/rxjs-interop'; import { ActivatedRoute } from '@angular/router'; import { CopyButtonComponent } from '@osf/shared/components'; import { InfoIconComponent } from '@osf/shared/components/info-icon/info-icon.component'; +import { DataciteService } from '@shared/services/datacite/datacite.service'; import { FilesSelectors } from '../../store'; @@ -39,13 +40,17 @@ import { environment } from 'src/environments/environment'; ], }) export class FileRevisionsComponent { + readonly dataciteService = inject(DataciteService); private readonly route = inject(ActivatedRoute); readonly fileRevisions = select(FilesSelectors.getFileRevisions); readonly isLoading = select(FilesSelectors.isFileRevisionsLoading); + readonly file = select(FilesSelectors.getOpenedFile); + readonly resourceMetadata = toObservable(select(FilesSelectors.getResourceMetadata)); readonly fileGuid = toSignal(this.route.params.pipe(map((params) => params['fileGuid'])) ?? of(undefined)); downloadRevision(version: string): void { + this.dataciteService.logIdentifiableDownload(this.resourceMetadata).subscribe(); if (this.fileGuid()) { window.open(`${environment.downloadUrl}/${this.fileGuid()}/?revision=${version}`)?.focus(); } diff --git a/src/app/features/files/mappers/resource-metadata.mapper.ts b/src/app/features/files/mappers/resource-metadata.mapper.ts index cc51daad7..b5a993af2 100644 --- a/src/app/features/files/mappers/resource-metadata.mapper.ts +++ b/src/app/features/files/mappers/resource-metadata.mapper.ts @@ -1,4 +1,5 @@ -import { ResourceMetadata } from '@shared/models'; +import { ResourceMetadata } from '@osf/shared/models'; +import { IdentifiersMapper } from '@shared/mappers/identifiers.mapper'; import { GetResourceCustomMetadataResponse } from '../models/get-resource-custom-metadata-response.model'; import { GetResourceShortInfoResponse } from '../models/get-resource-short-info-response.model'; @@ -20,6 +21,7 @@ export function MapResourceMetadata( awardUri: funder.award_uri, awardTitle: funder.award_title, })), + identifiers: IdentifiersMapper.fromJsonApi(shortInfo.data.embeds.identifiers), language: customMetadata.data.embeds.custom_metadata.data.attributes.language, resourceTypeGeneral: customMetadata.data.embeds.custom_metadata.data.attributes.resource_type_general, }; diff --git a/src/app/features/files/models/file-target.model.ts b/src/app/features/files/models/file-target.model.ts index 0917b8c43..69d29702e 100644 --- a/src/app/features/files/models/file-target.model.ts +++ b/src/app/features/files/models/file-target.model.ts @@ -21,4 +21,5 @@ export interface OsfFileTarget { public: boolean; type: string; isAnonymous?: boolean; + link: string; } diff --git a/src/app/features/files/models/get-file-target-response.model.ts b/src/app/features/files/models/get-file-target-response.model.ts index 2654a0552..29ce5ef47 100644 --- a/src/app/features/files/models/get-file-target-response.model.ts +++ b/src/app/features/files/models/get-file-target-response.model.ts @@ -31,7 +31,11 @@ export interface FileTargetResponse { }, null, null, - null + { + html: string; + self: string; + iri: string; + } >, null >; diff --git a/src/app/features/files/models/get-resource-short-info-response.model.ts b/src/app/features/files/models/get-resource-short-info-response.model.ts index 8b50228f3..fde3a7134 100644 --- a/src/app/features/files/models/get-resource-short-info-response.model.ts +++ b/src/app/features/files/models/get-resource-short-info-response.model.ts @@ -1,4 +1,5 @@ import { ApiData, JsonApiResponse } from '@osf/shared/models'; +import { IdentifiersJsonApiResponse } from '@shared/models/identifiers/identifier-json-api.model'; export type GetResourceShortInfoResponse = JsonApiResponse< ApiData< @@ -8,7 +9,7 @@ export type GetResourceShortInfoResponse = JsonApiResponse< date_created: string; date_modified: string; }, - null, + { identifiers: IdentifiersJsonApiResponse }, null, null >, diff --git a/src/app/features/files/models/get-short-info-response.model.ts b/src/app/features/files/models/get-short-info-response.model.ts index da3aed695..15ed93578 100644 --- a/src/app/features/files/models/get-short-info-response.model.ts +++ b/src/app/features/files/models/get-short-info-response.model.ts @@ -1,4 +1,5 @@ import { ApiData, JsonApiResponse } from '@shared/models'; +import { IdentifiersJsonApiResponse } from '@shared/models/identifiers/identifier-json-api.model'; export type GetShortInfoResponse = JsonApiResponse< ApiData< @@ -8,7 +9,7 @@ export type GetShortInfoResponse = JsonApiResponse< date_created: string; date_modified: string; }, - null, + { identifiers: IdentifiersJsonApiResponse }, null, null >, diff --git a/src/app/features/files/pages/file-detail/file-detail.component.ts b/src/app/features/files/pages/file-detail/file-detail.component.ts index 22d0e2c9b..ac6d3a8f7 100644 --- a/src/app/features/files/pages/file-detail/file-detail.component.ts +++ b/src/app/features/files/pages/file-detail/file-detail.component.ts @@ -20,7 +20,7 @@ import { inject, signal, } from '@angular/core'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop'; import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; @@ -41,6 +41,7 @@ import { MetadataResourceEnum, ResourceType } from '@osf/shared/enums'; import { pathJoin } from '@osf/shared/helpers'; import { MetadataTabsModel, OsfFile } from '@osf/shared/models'; import { CustomConfirmationService, MetaTagsService, ToastService } from '@osf/shared/services'; +import { DataciteService } from '@shared/services/datacite/datacite.service'; import { FileKeywordsComponent, @@ -99,6 +100,7 @@ export class FileDetailComponent { private readonly metaTags = inject(MetaTagsService); private readonly datePipe = inject(DatePipe); private readonly translateService = inject(TranslateService); + readonly dataciteService = inject(DataciteService); private readonly actions = createDispatchMap({ getFile: GetFile, @@ -115,6 +117,7 @@ export class FileDetailComponent { }); file = select(FilesSelectors.getOpenedFile); + fileMetadata$ = toObservable(select(FilesSelectors.getResourceMetadata)); isFileLoading = select(FilesSelectors.isOpenedFileLoading); cedarRecords = select(MetadataSelectors.getCedarRecords); cedarTemplates = select(MetadataSelectors.getCedarTemplates); @@ -186,8 +189,7 @@ export class FileDetailComponent { return { title: this.fileCustomMetadata()?.title || file.name, description: - this.fileCustomMetadata()?.description ?? - this.translateService.instant('files.metaTagDescriptionPlaceholder'), + this.fileCustomMetadata()?.description ?? this.translateService.instant('files.metaTagDescriptionPlaceholder'), url: pathJoin(environment.webUrl, this.fileGuid), publishedDate: this.datePipe.transform(file.dateCreated, 'yyyy-MM-dd'), modifiedDate: this.datePipe.transform(file.dateModified, 'yyyy-MM-dd'), @@ -247,9 +249,11 @@ export class FileDetailComponent { this.route.params.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((params) => { this.actions.getFileMetadata(params['fileGuid']); }); + this.dataciteService.logIdentifiableView(this.fileMetadata$).subscribe(); } downloadFile(link: string): void { + this.dataciteService.logIdentifiableDownload(this.fileMetadata$).subscribe(); window.open(link)?.focus(); } diff --git a/src/app/features/files/pages/files/files.component.ts b/src/app/features/files/pages/files/files.component.ts index ed1a6e839..c36abde0e 100644 --- a/src/app/features/files/pages/files/files.component.ts +++ b/src/app/features/files/pages/files/files.component.ts @@ -57,6 +57,7 @@ import { ResourceType } from '@osf/shared/enums'; import { hasViewOnlyParam, IS_MEDIUM } from '@osf/shared/helpers'; import { ConfiguredStorageAddonModel, FilesTreeActions, OsfFile, StorageItemModel } from '@shared/models'; import { FilesService } from '@shared/services'; +import { DataciteService } from '@shared/services/datacite/datacite.service'; import { CreateFolderDialogComponent, FileBrowserInfoComponent } from '../../components'; import { FileProvider } from '../../constants'; @@ -132,6 +133,7 @@ export class FilesComponent { readonly isRootFoldersLoading = select(FilesSelectors.isRootFoldersLoading); readonly configuredStorageAddons = select(FilesSelectors.getConfiguredStorageAddons); readonly isConfiguredStorageAddonsLoading = select(FilesSelectors.isConfiguredStorageAddonsLoading); + readonly dataciteService = inject(DataciteService); readonly progress = signal(0); readonly fileName = signal(''); @@ -338,6 +340,8 @@ export class FilesComponent { const folderId = this.currentFolder()?.id ?? ''; const isRootFolder = !this.currentFolder()?.relationships?.parentFolderLink; const provider = this.currentRootFolder()?.folder?.provider ?? 'osfstorage'; + const resourcePath = this.urlMap.get(this.resourceType()) ?? 'nodes'; + this.dataciteService.logFileDownload(resourceId, resourcePath).subscribe(); if (resourceId && folderId) { if (isRootFolder) { diff --git a/src/app/features/preprints/components/preprint-details/share-and-downlaod/share-and-download.component.html b/src/app/features/preprints/components/preprint-details/share-and-downlaod/share-and-download.component.html index 1e4a38960..08465578a 100644 --- a/src/app/features/preprints/components/preprint-details/share-and-downlaod/share-and-download.component.html +++ b/src/app/features/preprints/components/preprint-details/share-and-downlaod/share-and-download.component.html @@ -2,7 +2,7 @@
@if (preprint() && preprintProvider()) { - {{ + {{ 'preprints.details.share.downloadPreprint' | translate: { documentType: preprintProvider()?.preprintWord } }} } diff --git a/src/app/features/preprints/components/preprint-details/share-and-downlaod/share-and-download.component.ts b/src/app/features/preprints/components/preprint-details/share-and-downlaod/share-and-download.component.ts index d2c392944..8a743cd7a 100644 --- a/src/app/features/preprints/components/preprint-details/share-and-downlaod/share-and-download.component.ts +++ b/src/app/features/preprints/components/preprint-details/share-and-downlaod/share-and-download.component.ts @@ -7,12 +7,14 @@ import { Card } from 'primeng/card'; import { Skeleton } from 'primeng/skeleton'; import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core'; +import { toObservable } from '@angular/core/rxjs-interop'; import { PreprintProviderDetails } from '@osf/features/preprints/models'; import { PreprintSelectors } from '@osf/features/preprints/store/preprint'; import { IconComponent } from '@shared/components'; import { ShareableContent } from '@shared/models'; import { SocialShareService } from '@shared/services'; +import { DataciteService } from '@shared/services/datacite/datacite.service'; @Component({ selector: 'osf-preprint-share-and-download', @@ -25,8 +27,10 @@ export class ShareAndDownloadComponent { preprintProvider = input.required(); private readonly socialShareService = inject(SocialShareService); + private readonly dataciteService = inject(DataciteService); preprint = select(PreprintSelectors.getPreprint); + preprint$ = toObservable(this.preprint); isPreprintLoading = select(PreprintSelectors.isPreprintLoading); metrics = computed(() => { @@ -45,6 +49,10 @@ export class ShareAndDownloadComponent { return this.socialShareService.createDownloadUrl(preprint.id); }); + protected logDownload() { + this.dataciteService.logIdentifiableDownload(this.preprint$).subscribe(); + } + private shareableContent = computed((): ShareableContent | null => { const preprint = this.preprint(); const preprintProvider = this.preprintProvider(); diff --git a/src/app/features/preprints/pages/preprint-details/preprint-details.component.ts b/src/app/features/preprints/pages/preprint-details/preprint-details.component.ts index be07f7bbd..9e4f9ff16 100644 --- a/src/app/features/preprints/pages/preprint-details/preprint-details.component.ts +++ b/src/app/features/preprints/pages/preprint-details/preprint-details.component.ts @@ -6,7 +6,7 @@ import { Button } from 'primeng/button'; import { DialogService } from 'primeng/dynamicdialog'; import { Skeleton } from 'primeng/skeleton'; -import { filter, map, Observable, of } from 'rxjs'; +import { filter, map, of } from 'rxjs'; import { DatePipe, Location } from '@angular/common'; import { @@ -46,10 +46,9 @@ import { import { GetPreprintProviderById, PreprintProvidersSelectors } from '@osf/features/preprints/store/preprint-providers'; import { CreateNewVersion, PreprintStepperSelectors } from '@osf/features/preprints/store/preprint-stepper'; import { IS_MEDIUM, pathJoin } from '@osf/shared/helpers'; -import { DataciteTrackerComponent } from '@shared/components/datacite-tracker/datacite-tracker.component'; import { ReviewPermissions, UserPermissions } from '@shared/enums'; -import { Identifier } from '@shared/models'; import { MetaTagsService } from '@shared/services'; +import { DataciteService } from '@shared/services/datacite/datacite.service'; import { ContributorsSelectors } from '@shared/stores'; import { PreprintWarningBannerComponent } from '../../components/preprint-details/preprint-warning-banner/preprint-warning-banner.component'; @@ -77,7 +76,7 @@ import { environment } from 'src/environments/environment'; providers: [DialogService, DatePipe], changeDetection: ChangeDetectionStrategy.OnPush, }) -export class PreprintDetailsComponent extends DataciteTrackerComponent implements OnInit, OnDestroy { +export class PreprintDetailsComponent implements OnInit, OnDestroy { @HostBinding('class') classes = 'flex-1 flex flex-column w-full'; private readonly router = inject(Router); @@ -89,6 +88,7 @@ export class PreprintDetailsComponent extends DataciteTrackerComponent implement private readonly translateService = inject(TranslateService); private readonly metaTags = inject(MetaTagsService); private readonly datePipe = inject(DatePipe); + private readonly dataciteService = inject(DataciteService); private readonly isMedium = toSignal(inject(IS_MEDIUM)); private providerId = toSignal(this.route.params.pipe(map((params) => params['providerId'])) ?? of(undefined)); @@ -284,17 +284,13 @@ export class PreprintDetailsComponent extends DataciteTrackerComponent implement this.fetchPreprint(this.preprintId()); }, }); - this.setupDataciteViewTrackerEffect().subscribe(); + this.dataciteService.logIdentifiableView(this.preprint$).subscribe(); } ngOnDestroy() { this.actions.resetState(); } - protected override get trackable(): Observable<{ identifiers?: Identifier[] } | null> { - return this.preprint$; - } - fetchPreprintVersion(preprintVersionId: string) { const currentUrl = this.router.url; const newUrl = currentUrl.replace(/[^/]+$/, preprintVersionId); diff --git a/src/app/features/project/overview/project-overview.component.spec.ts b/src/app/features/project/overview/project-overview.component.spec.ts index 9a37bbde7..c0bb570fb 100644 --- a/src/app/features/project/overview/project-overview.component.spec.ts +++ b/src/app/features/project/overview/project-overview.component.spec.ts @@ -17,6 +17,13 @@ import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { CollectionSubmissionReviewAction } from '@osf/features/moderation/models'; import { CollectionsModerationSelectors } from '@osf/features/moderation/store/collections-moderation'; +import { + LinkedResourcesComponent, + OverviewComponentsComponent, + OverviewToolbarComponent, + OverviewWikiComponent, + RecentActivityComponent, +} from '@osf/features/project/overview/components'; import { ProjectOverviewSelectors } from '@osf/features/project/overview/store'; import { LoadingSpinnerComponent, @@ -37,13 +44,6 @@ import { } from '@shared/stores'; import { ActivityLogsSelectors } from '@shared/stores/activity-logs'; -import { - LinkedResourcesComponent, - OverviewComponentsComponent, - OverviewToolbarComponent, - OverviewWikiComponent, - RecentActivityComponent, -} from './components'; import { ProjectOverviewComponent } from './project-overview.component'; import { OSFTestingModule } from '@testing/osf.testing.module'; diff --git a/src/app/features/project/overview/project-overview.component.ts b/src/app/features/project/overview/project-overview.component.ts index 546b0d3fc..3672469c3 100644 --- a/src/app/features/project/overview/project-overview.component.ts +++ b/src/app/features/project/overview/project-overview.component.ts @@ -7,8 +7,6 @@ import { DialogService } from 'primeng/dynamicdialog'; import { Message } from 'primeng/message'; import { TagModule } from 'primeng/tag'; -import { Observable } from 'rxjs'; - import { CommonModule } from '@angular/common'; import { ChangeDetectionStrategy, @@ -45,14 +43,13 @@ import { } from '@osf/shared/stores'; import { GetActivityLogs } from '@osf/shared/stores/activity-logs'; import { - DataciteTrackerComponent, LoadingSpinnerComponent, MakeDecisionDialogComponent, ResourceMetadataComponent, SubHeaderComponent, ViewOnlyLinkMessageComponent, } from '@shared/components'; -import { Identifier } from '@shared/models'; +import { DataciteService } from '@shared/services/datacite/datacite.service'; import { LinkedResourcesComponent, @@ -95,7 +92,7 @@ import { providers: [DialogService], changeDetection: ChangeDetectionStrategy.OnPush, }) -export class ProjectOverviewComponent extends DataciteTrackerComponent implements OnInit { +export class ProjectOverviewComponent implements OnInit { @HostBinding('class') classes = 'flex flex-1 flex-column w-full h-full'; private readonly route = inject(ActivatedRoute); @@ -104,6 +101,7 @@ export class ProjectOverviewComponent extends DataciteTrackerComponent implement private readonly toastService = inject(ToastService); private readonly dialogService = inject(DialogService); private readonly translateService = inject(TranslateService); + private readonly dataciteService = inject(DataciteService); isMobile = toSignal(inject(IS_XSMALL)); submissions = select(CollectionsModerationSelectors.getCollectionSubmissions); @@ -202,12 +200,7 @@ export class ProjectOverviewComponent extends DataciteTrackerComponent implement return null; }); - protected override get trackable(): Observable<{ identifiers?: Identifier[] } | null> { - return this.currentProject$; - } - constructor() { - super(); this.setupCollectionsEffects(); this.setupCleanup(); } @@ -225,7 +218,7 @@ export class ProjectOverviewComponent extends DataciteTrackerComponent implement this.actions.getComponents(projectId); this.actions.getLinkedProjects(projectId); this.actions.getActivityLogs(projectId, this.activityDefaultPage.toString(), this.activityPageSize.toString()); - this.setupDataciteViewTrackerEffect().subscribe(); + this.dataciteService.logIdentifiableView(this.currentProject$).subscribe(); } } diff --git a/src/app/features/registry/registry.component.ts b/src/app/features/registry/registry.component.ts index 3c3ec004d..089f4e0d5 100644 --- a/src/app/features/registry/registry.component.ts +++ b/src/app/features/registry/registry.component.ts @@ -1,7 +1,5 @@ import { select } from '@ngxs/store'; -import { Observable } from 'rxjs'; - import { DatePipe } from '@angular/common'; import { ChangeDetectionStrategy, Component, DestroyRef, effect, HostBinding, inject } from '@angular/core'; import { toObservable } from '@angular/core/rxjs-interop'; @@ -9,8 +7,7 @@ import { RouterOutlet } from '@angular/router'; import { pathJoin } from '@osf/shared/helpers'; import { MetaTagsService } from '@osf/shared/services'; -import { DataciteTrackerComponent } from '@shared/components/datacite-tracker/datacite-tracker.component'; -import { Identifier } from '@shared/models'; +import { DataciteService } from '@shared/services/datacite/datacite.service'; import { RegistryOverviewSelectors } from './store/registry-overview'; @@ -24,28 +21,24 @@ import { environment } from 'src/environments/environment'; changeDetection: ChangeDetectionStrategy.OnPush, providers: [DatePipe], }) -export class RegistryComponent extends DataciteTrackerComponent { +export class RegistryComponent { @HostBinding('class') classes = 'flex-1 flex flex-column'; private readonly metaTags = inject(MetaTagsService); private readonly datePipe = inject(DatePipe); + private readonly dataciteService = inject(DataciteService); private readonly destroyRef = inject(DestroyRef); readonly registry = select(RegistryOverviewSelectors.getRegistry); readonly registry$ = toObservable(select(RegistryOverviewSelectors.getRegistry)); constructor() { - super(); effect(() => { if (this.registry()) { this.setMetaTags(); } }); - this.setupDataciteViewTrackerEffect().subscribe(); - } - - protected override get trackable(): Observable<{ identifiers?: Identifier[] } | null> { - return this.registry$; + this.dataciteService.logIdentifiableView(this.registry$).subscribe(); } private setMetaTags(): void { diff --git a/src/app/shared/components/datacite-tracker/datacite-tracker.component.ts b/src/app/shared/components/datacite-tracker/datacite-tracker.component.ts deleted file mode 100644 index 71d17256a..000000000 --- a/src/app/shared/components/datacite-tracker/datacite-tracker.component.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { filter, map, Observable, switchMap, take } from 'rxjs'; - -import { inject, Injectable } from '@angular/core'; - -import { Identifier } from '@shared/models'; -import { DataciteService } from '@shared/services/datacite/datacite.service'; - -@Injectable() -export abstract class DataciteTrackerComponent { - private dataciteService = inject(DataciteService); - /** - * Abstract method to retrieve an observable of resource to be tracked. - * This method is generic enough to support all objects that have `identifiers` property. - * Must be implemented by subclasses. - * - * @returns An Observable that emits an item which may contain DOI identifier or null . - */ - protected abstract get trackable(): Observable<{ identifiers?: Identifier[] } | null>; - - /** - * Sets up a one-time effect to log a "view" event to Datacite for the resource DOI. - * It waits until the DOI is available, takes the first non-null value, - * and then calls `DataciteService.logView`. - * - * @returns An Observable that completes after logging the view. - */ - protected setupDataciteViewTrackerEffect(): Observable { - return this.trackable.pipe( - filter((item) => item != null), - map((item) => item?.identifiers?.find((identifier) => identifier.category == 'doi')?.value ?? null), - filter((doi): doi is string => !!doi), - take(1), - switchMap((doi) => this.dataciteService.logView(doi)) - ); - } -} diff --git a/src/app/shared/components/files-tree/files-tree.component.ts b/src/app/shared/components/files-tree/files-tree.component.ts index 2c8052070..f940ac097 100644 --- a/src/app/shared/components/files-tree/files-tree.component.ts +++ b/src/app/shared/components/files-tree/files-tree.component.ts @@ -33,6 +33,7 @@ import { hasViewOnlyParam } from '@shared/helpers'; import { FileMenuAction, FilesTreeActions, OsfFile } from '@shared/models'; import { FileSizePipe } from '@shared/pipes'; import { CustomConfirmationService, FilesService, ToastService } from '@shared/services'; +import { DataciteService } from '@shared/services/datacite/datacite.service'; import { FileMenuComponent } from '../file-menu/file-menu.component'; import { LoadingSpinnerComponent } from '../loading-spinner/loading-spinner.component'; @@ -65,6 +66,7 @@ export class FilesTreeComponent implements OnDestroy, AfterViewInit { readonly customConfirmationService = inject(CustomConfirmationService); readonly dialogService = inject(DialogService); readonly translateService = inject(TranslateService); + readonly dataciteService = inject(DataciteService); files = input.required(); isLoading = input(); @@ -214,11 +216,7 @@ export class FilesTreeComponent implements OnDestroy, AfterViewInit { switch (value) { case FileMenuType.Download: - if (file.kind === 'file') { - this.downloadFile(file.links.download); - } else { - this.downloadFolder(file.id, false); - } + this.downloadFileOrFolder(file); break; case FileMenuType.Delete: this.confirmDelete(file); @@ -241,6 +239,15 @@ export class FilesTreeComponent implements OnDestroy, AfterViewInit { } } + private downloadFileOrFolder(file: OsfFile) { + this.dataciteService.logFileDownload(file.target.id, file.target.type).subscribe(); + if (file.kind === 'file') { + this.downloadFile(file.links.download); + } else { + this.downloadFolder(file.id, false); + } + } + private handleShareAction(file: OsfFile, shareType?: string): void { const emailLink = `mailto:?subject=${file.name}&body=${file.links.html}`; const twitterLink = `https://twitter.com/intent/tweet?url=${file.links.html}&text=${file.name}&via=OSFramework`; diff --git a/src/app/shared/components/index.ts b/src/app/shared/components/index.ts index d1b13ed3f..9047c1eed 100644 --- a/src/app/shared/components/index.ts +++ b/src/app/shared/components/index.ts @@ -6,7 +6,6 @@ export { ConfirmEmailComponent } from './confirm-email/confirm-email.component'; export { CopyButtonComponent } from './copy-button/copy-button.component'; export { CustomPaginatorComponent } from './custom-paginator/custom-paginator.component'; export { DataResourcesComponent } from './data-resources/data-resources.component'; -export { DataciteTrackerComponent } from './datacite-tracker/datacite-tracker.component'; export { EducationHistoryComponent } from './education-history/education-history.component'; export { EducationHistoryDialogComponent } from './education-history-dialog/education-history-dialog.component'; export { EmploymentHistoryComponent } from './employment-history/employment-history.component'; diff --git a/src/app/shared/mappers/files/files.mapper.ts b/src/app/shared/mappers/files/files.mapper.ts index 39fceca67..39279effe 100644 --- a/src/app/shared/mappers/files/files.mapper.ts +++ b/src/app/shared/mappers/files/files.mapper.ts @@ -72,6 +72,7 @@ export function MapFile( currentUserPermissions: file?.embeds?.target.data.attributes.current_user_permissions, wikiEnabled: file?.embeds?.target.data.attributes.wiki_enabled, public: file?.embeds?.target.data.attributes.public, + link: file?.embeds?.target.data.links.self, }, currentUserCanComment: file.attributes.current_user_can_comment, currentVersion: file.attributes.current_version, diff --git a/src/app/shared/mappers/identifiers.mapper.ts b/src/app/shared/mappers/identifiers.mapper.ts new file mode 100644 index 000000000..84cac2704 --- /dev/null +++ b/src/app/shared/mappers/identifiers.mapper.ts @@ -0,0 +1,14 @@ +import { Identifier, IdentifiersJsonApiData, ResponseJsonApi } from '@shared/models'; + +export class IdentifiersMapper { + static fromJsonApi(response: ResponseJsonApi): Identifier[] { + return response.data.map((rawIdentifier) => { + return { + category: rawIdentifier.attributes.category, + value: rawIdentifier.attributes.value, + id: rawIdentifier.id, + type: rawIdentifier.type, + }; + }); + } +} diff --git a/src/app/shared/models/identifiers/identifier-json-api.model.ts b/src/app/shared/models/identifiers/identifier-json-api.model.ts new file mode 100644 index 000000000..baf466fc8 --- /dev/null +++ b/src/app/shared/models/identifiers/identifier-json-api.model.ts @@ -0,0 +1,12 @@ +import { ApiData, ResponseJsonApi } from '@shared/models'; + +export type IdentifiersJsonApiResponse = ResponseJsonApi; +export type IdentifiersJsonApiData = ApiData; + +export interface IdentifierAttributes { + category: string; + value: string; +} +interface IdentifierLinks { + self: string; +} diff --git a/src/app/shared/models/identifier.model.ts b/src/app/shared/models/identifiers/identifier.model.ts similarity index 100% rename from src/app/shared/models/identifier.model.ts rename to src/app/shared/models/identifiers/identifier.model.ts diff --git a/src/app/shared/models/identifiers/index.ts b/src/app/shared/models/identifiers/index.ts new file mode 100644 index 000000000..7b4388155 --- /dev/null +++ b/src/app/shared/models/identifiers/index.ts @@ -0,0 +1,2 @@ +export * from './identifier.model'; +export * from './identifier-json-api.model'; diff --git a/src/app/shared/models/index.ts b/src/app/shared/models/index.ts index fb9df110d..c35da4c46 100644 --- a/src/app/shared/models/index.ts +++ b/src/app/shared/models/index.ts @@ -16,7 +16,7 @@ export * from './emails'; export * from './files'; export * from './google-drive-folder.model'; export * from './guid-response-json-api.model'; -export * from './identifier.model'; +export * from './identifiers'; export * from './institutions'; export * from './language-code.model'; export * from './license'; diff --git a/src/app/shared/models/nodes/base-node-embeds-json-api.model.ts b/src/app/shared/models/nodes/base-node-embeds-json-api.model.ts index f11f8cc55..0fff1e7ce 100644 --- a/src/app/shared/models/nodes/base-node-embeds-json-api.model.ts +++ b/src/app/shared/models/nodes/base-node-embeds-json-api.model.ts @@ -1,3 +1,5 @@ +import { IdentifierAttributes } from '@shared/models'; + export interface BaseNodeEmbeds { bibliographic_contributors?: { data: ContributorResource[]; @@ -55,11 +57,6 @@ export interface LicenseAttributes { export type LicenseResource = JsonApiResource<'licenses', LicenseAttributes>; -export interface IdentifierAttributes { - category: string; - value: string; -} - export type IdentifierResource = JsonApiResource<'identifiers', IdentifierAttributes>; export interface InstitutionAttributes { diff --git a/src/app/shared/models/resource-metadata.model.ts b/src/app/shared/models/resource-metadata.model.ts index 46fcd5f37..26428181e 100644 --- a/src/app/shared/models/resource-metadata.model.ts +++ b/src/app/shared/models/resource-metadata.model.ts @@ -1,3 +1,5 @@ +import { Identifier } from '@shared/models/identifiers/identifier.model'; + export interface ResourceMetadata { title: string; description: string; @@ -5,6 +7,7 @@ export interface ResourceMetadata { dateModified: Date; language: string; resourceTypeGeneral: string; + identifiers: Identifier[]; funders: { funderName: string; funderIdentifier: string; diff --git a/src/app/shared/services/datacite/datacite.service.ts b/src/app/shared/services/datacite/datacite.service.ts index f4dc70b58..55b0566db 100644 --- a/src/app/shared/services/datacite/datacite.service.ts +++ b/src/app/shared/services/datacite/datacite.service.ts @@ -1,10 +1,12 @@ -import { EMPTY, map, Observable } from 'rxjs'; +import { EMPTY, filter, map, Observable, of, switchMap, take } from 'rxjs'; import { HttpClient } from '@angular/common/http'; import { inject, Injectable } from '@angular/core'; import { ENVIRONMENT } from '@core/constants/environment.token'; +import { Identifier } from '@shared/models'; import { DataciteEvent } from '@shared/models/datacite/datacite-event.enum'; +import { IdentifiersJsonApiResponse } from '@shared/models/identifiers/identifier-json-api.model'; @Injectable({ providedIn: 'root', @@ -13,6 +15,49 @@ export class DataciteService { #http: HttpClient = inject(HttpClient); #environment = inject(ENVIRONMENT); + logIdentifiableView(trackable: Observable<{ identifiers?: Identifier[] } | null>) { + return this.watchIdentifiable(trackable, DataciteEvent.VIEW); + } + + logIdentifiableDownload(trackable: Observable<{ identifiers?: Identifier[] } | null>) { + return this.watchIdentifiable(trackable, DataciteEvent.DOWNLOAD); + } + + logFileDownload(targetId: string, targetType: string) { + return this.logFile(targetId, targetType, DataciteEvent.DOWNLOAD); + } + + logFileView(targetId: string, targetType: string) { + return this.logFile(targetId, targetType, DataciteEvent.VIEW); + } + + watchIdentifiable( + trackable: Observable<{ identifiers?: Identifier[] } | null>, + event: DataciteEvent + ): Observable { + return trackable.pipe( + filter((item) => item != null), + map((item) => item?.identifiers?.find((identifier) => identifier.category == 'doi')?.value ?? null), + filter((doi): doi is string => !!doi), + take(1), + switchMap((doi) => this.logActivity(event, doi)) + ); + } + + logFile(targetId: string, targetType: string, event: DataciteEvent): Observable { + const url = `${this.#environment.webUrl}/${targetType}/${targetId}/identifiers`; + return this.#http.get(url).pipe( + map((item) => ({ + identifiers: item.data.map((identifierData) => ({ + id: identifierData.id, + type: identifierData.type, + category: identifierData.attributes.category, + value: identifierData.attributes.value, + })), + })), + switchMap((trackable) => this.watchIdentifiable(of(trackable), event)) + ); + } /** * Logs a "view" event for a given DOI to the Datacite tracker. * If the DOI is null/empty or the tracker repository ID is not configured, diff --git a/src/app/shared/services/files.service.ts b/src/app/shared/services/files.service.ts index 57e01eb64..40444b867 100644 --- a/src/app/shared/services/files.service.ts +++ b/src/app/shared/services/files.service.ts @@ -209,7 +209,8 @@ export class FilesService { getResourceShortInfo(resourceId: string, resourceType: string): Observable { const params = { - 'fields[nodes]': 'title,description,date_created,date_modified', + 'fields[nodes]': 'title,description,date_created,date_modified,identifiers', + embed: 'identifiers', }; return this.jsonApiService.get( `${environment.apiUrl}/${resourceType}/${resourceId}/`, From f7f7a0d5d1de02bdf0f1fc6b7080b6514f3b5645 Mon Sep 17 00:00:00 2001 From: Oleh Paduchak Date: Mon, 8 Sep 2025 16:52:17 +0300 Subject: [PATCH 2/5] feat(datacite-tracker): implemented preprint version download tracking --- .../file-revisions/file-revisions.component.ts | 3 +-- .../preprint-file-section.component.ts | 10 +++++++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/app/features/files/components/file-revisions/file-revisions.component.ts b/src/app/features/files/components/file-revisions/file-revisions.component.ts index 9df816239..b552a7f93 100644 --- a/src/app/features/files/components/file-revisions/file-revisions.component.ts +++ b/src/app/features/files/components/file-revisions/file-revisions.component.ts @@ -40,12 +40,11 @@ import { environment } from 'src/environments/environment'; ], }) export class FileRevisionsComponent { - readonly dataciteService = inject(DataciteService); + private readonly dataciteService = inject(DataciteService); private readonly route = inject(ActivatedRoute); readonly fileRevisions = select(FilesSelectors.getFileRevisions); readonly isLoading = select(FilesSelectors.isFileRevisionsLoading); - readonly file = select(FilesSelectors.getOpenedFile); readonly resourceMetadata = toObservable(select(FilesSelectors.getResourceMetadata)); readonly fileGuid = toSignal(this.route.params.pipe(map((params) => params['fileGuid'])) ?? of(undefined)); diff --git a/src/app/features/preprints/components/preprint-details/preprint-file-section/preprint-file-section.component.ts b/src/app/features/preprints/components/preprint-details/preprint-file-section/preprint-file-section.component.ts index a29ee1b89..a3d941e5e 100644 --- a/src/app/features/preprints/components/preprint-details/preprint-file-section/preprint-file-section.component.ts +++ b/src/app/features/preprints/components/preprint-details/preprint-file-section/preprint-file-section.component.ts @@ -8,13 +8,14 @@ import { Skeleton } from 'primeng/skeleton'; import { DatePipe } from '@angular/common'; import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core'; -import { toSignal } from '@angular/core/rxjs-interop'; +import { toObservable, toSignal } from '@angular/core/rxjs-interop'; import { DomSanitizer } from '@angular/platform-browser'; import { ProviderReviewsWorkflow } from '@osf/features/preprints/enums'; import { PreprintSelectors } from '@osf/features/preprints/store/preprint'; import { IS_LARGE, IS_MEDIUM } from '@osf/shared/helpers'; import { LoadingSpinnerComponent } from '@shared/components'; +import { DataciteService } from '@shared/services/datacite/datacite.service'; @Component({ selector: 'osf-preprint-file-section', @@ -28,6 +29,7 @@ export class PreprintFileSectionComponent { private readonly sanitizer = inject(DomSanitizer); private readonly datePipe = inject(DatePipe); private readonly translateService = inject(TranslateService); + private readonly dataciteService = inject(DataciteService); providerReviewsWorkflow = input.required(); @@ -35,6 +37,7 @@ export class PreprintFileSectionComponent { isLarge = toSignal(inject(IS_LARGE)); file = select(PreprintSelectors.getPreprintFile); + preprint$ = toObservable(select(PreprintSelectors.getPreprint)); isFileLoading = select(PreprintSelectors.isPreprintFileLoading); safeLink = computed(() => { const link = this.file()?.links.render; @@ -47,6 +50,10 @@ export class PreprintFileSectionComponent { fileVersions = select(PreprintSelectors.getPreprintFileVersions); areFileVersionsLoading = select(PreprintSelectors.arePreprintFileVersionsLoading); + logDownload() { + this.dataciteService.logIdentifiableDownload(this.preprint$); + } + versionMenuItems = computed(() => { const fileVersions = this.fileVersions(); if (!fileVersions.length) return []; @@ -57,6 +64,7 @@ export class PreprintFileSectionComponent { date: this.datePipe.transform(version.dateCreated, 'mm/dd/yyyy hh:mm:ss'), }), url: version.downloadLink, + command: () => this.logDownload(), })); }); From 03069065f52a8bc148ccf43b7e0932bc9af818b4 Mon Sep 17 00:00:00 2001 From: Oleh Paduchak Date: Mon, 8 Sep 2025 23:15:26 +0300 Subject: [PATCH 3/5] chore(datacite-tracker): rewritten existing tests to respect recent refactor --- .../preprint-file-section.component.spec.ts | 45 +++++- .../preprint-details.component.spec.ts | 29 +--- .../project-overview.component.spec.ts | 26 +--- .../overview/project-overview.component.ts | 2 +- .../registry/registry.component.spec.ts | 44 +----- .../datacite/datacite.service.spec.ts | 147 +++++++++++++----- .../services/datacite/datacite.service.ts | 29 +--- 7 files changed, 164 insertions(+), 158 deletions(-) diff --git a/src/app/features/preprints/components/preprint-details/preprint-file-section/preprint-file-section.component.spec.ts b/src/app/features/preprints/components/preprint-details/preprint-file-section/preprint-file-section.component.spec.ts index 539c3c290..b683b038a 100644 --- a/src/app/features/preprints/components/preprint-details/preprint-file-section/preprint-file-section.component.spec.ts +++ b/src/app/features/preprints/components/preprint-details/preprint-file-section/preprint-file-section.component.spec.ts @@ -1,33 +1,43 @@ import { Store } from '@ngxs/store'; +import { TranslateModule } from '@ngx-translate/core'; import { MockProvider } from 'ng-mocks'; -import { BehaviorSubject } from 'rxjs'; +import { BehaviorSubject, of } from 'rxjs'; +import { signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { PreprintSelectors } from '@osf/features/preprints/store/preprint'; import { IS_LARGE, IS_MEDIUM } from '@osf/shared/helpers'; import { MOCK_STORE } from '@shared/mocks'; +import { DataciteService } from '@shared/services/datacite/datacite.service'; import { PreprintFileSectionComponent } from './preprint-file-section.component'; -describe.skip('PreprintFileSectionComponent', () => { +describe('PreprintFileSectionComponent', () => { let component: PreprintFileSectionComponent; let fixture: ComponentFixture; - + let dataciteService: jest.Mocked; const mockStore = MOCK_STORE; let isMediumSubject: BehaviorSubject; let isLargeSubject: BehaviorSubject; + // const beforeEach(async () => { (MOCK_STORE.selectSignal as jest.Mock).mockImplementation((selector) => { if ( selector === PreprintSelectors.isPreprintFileLoading || - selector === PreprintSelectors.getPreprintFileVersions || + // selector === PreprintSelectors.getPreprintFileVersions || selector === PreprintSelectors.arePreprintFileVersionsLoading ) { return () => []; + } else if (selector == PreprintSelectors.getPreprint) { + return () => ({ + id: 1, + }); + } else if (selector == PreprintSelectors.getPreprintFileVersions) { + return signal([{ date: '12312312', downloadUrl: '21312', id: '1' }]); } return () => null; }); @@ -36,9 +46,12 @@ describe.skip('PreprintFileSectionComponent', () => { isLargeSubject = new BehaviorSubject(true); await TestBed.configureTestingModule({ - imports: [PreprintFileSectionComponent], + imports: [PreprintFileSectionComponent, TranslateModule.forRoot()], providers: [ MockProvider(Store, mockStore), + MockProvider(DataciteService, { + logIdentifiableDownload: jest.fn().mockReturnValue(of(void 0)), + }), MockProvider(IS_MEDIUM, isMediumSubject), MockProvider(IS_LARGE, isLargeSubject), ], @@ -47,9 +60,27 @@ describe.skip('PreprintFileSectionComponent', () => { fixture = TestBed.createComponent(PreprintFileSectionComponent); component = fixture.componentInstance; fixture.detectChanges(); + dataciteService = TestBed.inject(DataciteService) as jest.MockedObject; + }); + + it('should call dataciteService.logIdentifiableDownload when logDownload is called', () => { + component.logDownload(); + expect(dataciteService.logIdentifiableDownload).toHaveBeenCalledWith(component.preprint$); }); - it('should create', () => { - expect(component).toBeTruthy(); + it('should call logDownload when version menu item is clicked', () => { + // Get the command from versionMenuItems + fixture.detectChanges(); + const menuItems = component.versionMenuItems(); + expect(menuItems.length).toBeGreaterThan(0); + + const versionCommand = menuItems[0].command!; + jest.spyOn(component, 'logDownload'); + + // simulate clicking the menu item + versionCommand(); + + expect(component.logDownload).toHaveBeenCalled(); + expect(dataciteService.logIdentifiableDownload).toHaveBeenCalledWith(expect.anything()); }); }); diff --git a/src/app/features/preprints/pages/preprint-details/preprint-details.component.spec.ts b/src/app/features/preprints/pages/preprint-details/preprint-details.component.spec.ts index 515161bb0..69dfd4e3e 100644 --- a/src/app/features/preprints/pages/preprint-details/preprint-details.component.spec.ts +++ b/src/app/features/preprints/pages/preprint-details/preprint-details.component.spec.ts @@ -17,7 +17,6 @@ import { ShareAndDownloadComponent } from '@osf/features/preprints/components/pr import { PreprintSelectors } from '@osf/features/preprints/store/preprint'; import { PreprintProvidersSelectors } from '@osf/features/preprints/store/preprint-providers'; import { MOCK_PROVIDER, MOCK_STORE, TranslateServiceMock } from '@shared/mocks'; -import { Identifier } from '@shared/models'; import { DataciteService } from '@shared/services/datacite/datacite.service'; import { PreprintDetailsComponent } from './preprint-details.component'; @@ -51,7 +50,7 @@ describe('PreprintDetailsComponent', () => { }); (MOCK_STORE.dispatch as jest.Mock).mockImplementation(() => of()); dataciteService = { - logView: jest.fn().mockReturnValue(of(void 0)), + logIdentifiableView: jest.fn().mockReturnValue(of(void 0)), } as unknown as jest.Mocked; await TestBed.configureTestingModule({ @@ -86,30 +85,6 @@ describe('PreprintDetailsComponent', () => { it('reacts to sequence of state changes', () => { fixture.detectChanges(); - expect(dataciteService.logView).toHaveBeenCalledTimes(0); - - preprintSignal.set(getPreprint([])); - - fixture.detectChanges(); - expect(dataciteService.logView).toHaveBeenCalledTimes(0); - - preprintSignal.set(getPreprint([{ category: 'dio', value: '123', id: '', type: 'identifier' }])); - fixture.detectChanges(); - expect(dataciteService.logView).toHaveBeenCalledTimes(0); - - preprintSignal.set(getPreprint([{ category: 'doi', value: '123', id: '', type: 'identifier' }])); - - fixture.detectChanges(); - expect(dataciteService.logView).toHaveBeenCalled(); - - preprintSignal.set(getPreprint([{ category: 'doi', value: '456', id: '', type: 'identifier' }])); - fixture.detectChanges(); - expect(dataciteService.logView).toHaveBeenLastCalledWith('123'); + expect(dataciteService.logIdentifiableView).toHaveBeenCalledWith(component.preprint$); }); }); - -function getPreprint(identifiers: Identifier[]) { - return { - identifiers: identifiers, - }; -} diff --git a/src/app/features/project/overview/project-overview.component.spec.ts b/src/app/features/project/overview/project-overview.component.spec.ts index c0bb570fb..e8b5712f9 100644 --- a/src/app/features/project/overview/project-overview.component.spec.ts +++ b/src/app/features/project/overview/project-overview.component.spec.ts @@ -65,6 +65,7 @@ const sampleReviewAction: CollectionSubmissionReviewAction = { describe('ProjectOverviewComponent', () => { let fixture: ComponentFixture; let dataciteService: jest.Mocked; + let component: ProjectOverviewComponent; const projectSignal = signal(getProject()); const activatedRouteMock = { @@ -118,7 +119,7 @@ describe('ProjectOverviewComponent', () => { }); dataciteService = { - logView: jest.fn().mockReturnValue(of(void 0)), + logIdentifiableView: jest.fn().mockReturnValue(of(void 0)), } as unknown as jest.Mocked; await TestBed.configureTestingModule({ @@ -142,6 +143,7 @@ describe('ProjectOverviewComponent', () => { ViewOnlyLinkMessageComponent, ], providers: [ + TranslatePipe, { provide: ActivatedRoute, useValue: activatedRouteMock }, { provide: Store, useValue: MOCK_STORE }, { provide: DataciteService, useValue: dataciteService }, @@ -152,32 +154,14 @@ describe('ProjectOverviewComponent', () => { TranslateService, ], }).compileComponents(); - fixture = TestBed.createComponent(ProjectOverviewComponent); + component = fixture.componentInstance; fixture.detectChanges(); }); it('reacts to sequence of state changes', () => { fixture.detectChanges(); - expect(dataciteService.logView).toHaveBeenCalledTimes(0); - - projectSignal.set(getProject()); - - fixture.detectChanges(); - expect(dataciteService.logView).toHaveBeenCalledTimes(0); - - projectSignal.set(getProject([{ category: 'dio', value: '123', id: '', type: 'identifier' }])); - fixture.detectChanges(); - expect(dataciteService.logView).toHaveBeenCalledTimes(0); - - projectSignal.set(getProject([{ category: 'doi', value: '123', id: '', type: 'identifier' }])); - - fixture.detectChanges(); - expect(dataciteService.logView).toHaveBeenCalled(); - - projectSignal.set(getProject([{ category: 'doi', value: '456', id: '', type: 'identifier' }])); - fixture.detectChanges(); - expect(dataciteService.logView).toHaveBeenLastCalledWith('123'); + expect(dataciteService.logIdentifiableView).toHaveBeenCalledWith(component.currentProject$); }); function getProject(identifiers?: Identifier[]) { diff --git a/src/app/features/project/overview/project-overview.component.ts b/src/app/features/project/overview/project-overview.component.ts index 3672469c3..db1d446c6 100644 --- a/src/app/features/project/overview/project-overview.component.ts +++ b/src/app/features/project/overview/project-overview.component.ts @@ -154,7 +154,7 @@ export class ProjectOverviewComponent implements OnInit { currentProject = select(ProjectOverviewSelectors.getProject); isAnonymous = select(ProjectOverviewSelectors.isProjectAnonymous); - private currentProject$ = toObservable(this.currentProject); + currentProject$ = toObservable(this.currentProject); userPermissions = computed(() => { return this.currentProject()?.currentUserPermissions || []; diff --git a/src/app/features/registry/registry.component.spec.ts b/src/app/features/registry/registry.component.spec.ts index 856c305e4..20ac70ae5 100644 --- a/src/app/features/registry/registry.component.spec.ts +++ b/src/app/features/registry/registry.component.spec.ts @@ -8,20 +8,20 @@ import { TestBed } from '@angular/core/testing'; import { RegistryOverviewSelectors } from '@osf/features/registry/store/registry-overview'; import { MetaTagsService } from '@osf/shared/services'; -import { Identifier } from '@shared/models'; import { DataciteService } from '@shared/services/datacite/datacite.service'; import { RegistryComponent } from './registry.component'; describe('RegistryComponent', () => { let fixture: any; + let component: RegistryComponent; let dataciteService: jest.Mocked; const registrySignal = signal(null); beforeEach(async () => { dataciteService = { - logView: jest.fn().mockReturnValue(of(void 0)), + logIdentifiableView: jest.fn().mockReturnValue(of(void 0)), } as unknown as jest.Mocked; const mockStore = { @@ -47,48 +47,12 @@ describe('RegistryComponent', () => { }).compileComponents(); fixture = TestBed.createComponent(RegistryComponent); + component = fixture.componentInstance; TestBed.inject(MetaTagsService); }); it('reacts to sequence of state changes', () => { - registrySignal.set(null); fixture.detectChanges(); - expect(dataciteService.logView).toHaveBeenCalledTimes(0); - - registrySignal.set(getRegistry([])); - - fixture.detectChanges(); - expect(dataciteService.logView).toHaveBeenCalledTimes(0); - - registrySignal.set(getRegistry([{ category: 'dio', value: '123', id: '', type: 'identifier' }])); - fixture.detectChanges(); - expect(dataciteService.logView).toHaveBeenCalledTimes(0); - - registrySignal.set(getRegistry([{ category: 'doi', value: '123', id: '', type: 'identifier' }])); - - fixture.detectChanges(); - expect(dataciteService.logView).toHaveBeenCalled(); - - registrySignal.set(getRegistry([{ category: 'doi', value: '456', id: '', type: 'identifier' }])); - fixture.detectChanges(); - expect(dataciteService.logView).toHaveBeenLastCalledWith('123'); + expect(dataciteService.logIdentifiableView).toHaveBeenCalledWith(component.registry$); }); }); - -function getRegistry(identifiers: Identifier[]) { - return { - id: 'r1', - title: 'Mock Registry', - description: 'Test description', - dateRegistered: new Date('2023-01-01'), - dateModified: new Date('2023-02-01'), - doi: '10.1000/mockdoi', - tags: ['angular', 'jest'], - license: { name: 'MIT' }, - contributors: [ - { givenName: 'Alice', familyName: 'Smith' }, - { givenName: 'Bob', familyName: 'Brown' }, - ], - identifiers: identifiers, - }; -} diff --git a/src/app/shared/services/datacite/datacite.service.spec.ts b/src/app/shared/services/datacite/datacite.service.spec.ts index 9e4a4c6ef..822092468 100644 --- a/src/app/shared/services/datacite/datacite.service.spec.ts +++ b/src/app/shared/services/datacite/datacite.service.spec.ts @@ -1,17 +1,68 @@ +import { Observable, take } from 'rxjs'; + import { provideHttpClient } from '@angular/common/http'; import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { TestBed } from '@angular/core/testing'; import { ENVIRONMENT } from '@core/constants/environment.token'; +import { Identifier } from '@shared/models'; import { DataciteEvent } from '@shared/models/datacite/datacite-event.enum'; import { DataciteService } from './datacite.service'; +function buildObservable(doi: string) { + return new Observable<{ identifiers?: Identifier[] } | null>((subscriber) => { + subscriber.next({}); + subscriber.next({ identifiers: [] }); + subscriber.next({ + identifiers: [ + { + category: 'doi', + value: doi, + id: '', + type: 'identifier', + }, + ], + }); + subscriber.next({ + identifiers: [ + { + category: 'doi', + value: 'other doi', + id: '', + type: 'identifier', + }, + ], + }); + subscriber.complete(); + }); +} + +function assertSuccess( + httpMock: HttpTestingController, + dataciteTrackerAddress: string, + dataciteTrackerRepoId: string, + doi: string, + event: DataciteEvent +) { + const req = httpMock.expectOne(dataciteTrackerAddress); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual({ + n: event, + u: window.location.href, + i: dataciteTrackerRepoId, + p: doi, + }); + expect(req.request.headers.get('Content-Type')).toBe('application/json'); + req.flush({}); +} + describe('DataciteService', () => { let service: DataciteService; let httpMock: HttpTestingController; const dataciteTrackerAddress = 'https://tracker.test'; + const webUrl = 'https://osf.io'; const dataciteTrackerRepoId = 'repo-123'; describe('with proper configuration', () => { beforeEach(() => { @@ -23,6 +74,7 @@ describe('DataciteService', () => { { provide: ENVIRONMENT, useValue: { + webUrl, dataciteTrackerRepoId, dataciteTrackerAddress, }, @@ -38,46 +90,72 @@ describe('DataciteService', () => { httpMock.verify(); }); - it('logView should POST with correct payload', () => { + it('logIdentifiableView should POST with correct payload', () => { const doi = '10.1234/abcd'; - service.logView(doi).subscribe({ - next: (result) => expect(result).toBeUndefined(), - }); + const observable = buildObservable(doi); + service.logIdentifiableView(observable).subscribe(); + assertSuccess(httpMock, dataciteTrackerAddress, dataciteTrackerRepoId, doi, DataciteEvent.VIEW); + }); - const req = httpMock.expectOne(dataciteTrackerAddress); - expect(req.request.method).toBe('POST'); - expect(req.request.body).toEqual({ - n: DataciteEvent.VIEW, - u: window.location.href, - i: dataciteTrackerRepoId, - p: doi, - }); - expect(req.request.headers.get('Content-Type')).toBe('application/json'); - req.flush({}); + it('logIdentifiableView should notPOST without correct payload', () => { + const doi = '10.1234/abcd'; + const observable = buildObservable(doi).pipe(take(2)); + service.logIdentifiableView(observable).subscribe(); + httpMock.expectNone(dataciteTrackerAddress); }); - it('logDownload should POST with correct payload', () => { + it('logIdentifiableDownload should POST with correct payload', () => { const doi = '10.1234/abcd'; - service.logDownload(doi).subscribe({ - next: (result) => expect(result).toBeUndefined(), - }); + const observable = buildObservable(doi); + service.logIdentifiableDownload(observable).subscribe(); + assertSuccess(httpMock, dataciteTrackerAddress, dataciteTrackerRepoId, doi, DataciteEvent.DOWNLOAD); + }); + it('logFileView should GET identifiers and POST with correct payload', () => { + const doi = '10.1234/fileview'; + const targetId = 'file-1'; + const targetType = 'files'; - const req = httpMock.expectOne(dataciteTrackerAddress); - expect(req.request.body).toEqual({ - n: DataciteEvent.DOWNLOAD, - u: window.location.href, - i: dataciteTrackerRepoId, - p: doi, + service.logFileView(targetId, targetType).subscribe(); + + // First request: GET identifiers + const reqGet = httpMock.expectOne(`${webUrl}/${targetType}/${targetId}/identifiers`); + expect(reqGet.request.method).toBe('GET'); + reqGet.flush({ + data: [ + { + id: 'id-1', + type: 'identifier', + attributes: { category: 'doi', value: doi }, + }, + ], }); - req.flush({}); + + // Second request: POST to datacite tracker + assertSuccess(httpMock, dataciteTrackerAddress, dataciteTrackerRepoId, doi, DataciteEvent.VIEW); }); - it('should return EMPTY when doi is missing', (done: () => void) => { - service.logView('').subscribe({ - next: (result) => expect(result).toBeUndefined(), - complete: () => done(), + it('logFileDownload should GET identifiers and POST with correct payload', () => { + const doi = '10.1234/filedownload'; + const targetId = 'file-2'; + const targetType = 'files'; + + service.logFileDownload(targetId, targetType).subscribe(); + + // First request: GET identifiers + const reqGet = httpMock.expectOne(`${webUrl}/${targetType}/${targetId}/identifiers`); + expect(reqGet.request.method).toBe('GET'); + reqGet.flush({ + data: [ + { + id: 'id-2', + type: 'identifier', + attributes: { category: 'doi', value: doi }, + }, + ], }); - httpMock.expectNone(dataciteTrackerAddress); + + // Second request: POST to datacite tracker + assertSuccess(httpMock, dataciteTrackerAddress, dataciteTrackerRepoId, doi, DataciteEvent.DOWNLOAD); }); }); @@ -102,11 +180,10 @@ describe('DataciteService', () => { httpMock = TestBed.inject(HttpTestingController); }); - it('should return EMPTY when dataciteTrackerRepoId is missing', (done: () => void) => { - service.logView('10.1234/abcd').subscribe({ - next: (result) => expect(result).toBeUndefined(), - complete: () => done(), - }); + it('logIdentifiableView should POST with correct payload', () => { + const doi = '10.1234/abcd'; + const observable = buildObservable(doi); + service.logIdentifiableView(observable).subscribe(); httpMock.expectNone(dataciteTrackerAddress); }); diff --git a/src/app/shared/services/datacite/datacite.service.ts b/src/app/shared/services/datacite/datacite.service.ts index 55b0566db..375e43577 100644 --- a/src/app/shared/services/datacite/datacite.service.ts +++ b/src/app/shared/services/datacite/datacite.service.ts @@ -31,7 +31,7 @@ export class DataciteService { return this.logFile(targetId, targetType, DataciteEvent.VIEW); } - watchIdentifiable( + private watchIdentifiable( trackable: Observable<{ identifiers?: Identifier[] } | null>, event: DataciteEvent ): Observable { @@ -44,7 +44,7 @@ export class DataciteService { ); } - logFile(targetId: string, targetType: string, event: DataciteEvent): Observable { + private logFile(targetId: string, targetType: string, event: DataciteEvent): Observable { const url = `${this.#environment.webUrl}/${targetType}/${targetId}/identifiers`; return this.#http.get(url).pipe( map((item) => ({ @@ -58,31 +58,6 @@ export class DataciteService { switchMap((trackable) => this.watchIdentifiable(of(trackable), event)) ); } - /** - * Logs a "view" event for a given DOI to the Datacite tracker. - * If the DOI is null/empty or the tracker repository ID is not configured, - * (in most cases, due to being used in dev environment), - * returns an empty observable. - * - * @param doi - The DOI (Digital Object Identifier) of the resource. - * @returns An Observable that completes when the request is sent. - */ - logView(doi: string): Observable { - return this.logActivity(DataciteEvent.VIEW, doi); - } - - /** - * Logs a "download" event for a given DOI to the Datacite tracker. - * If the DOI is null/empty or the tracker repository ID is not configured - * (in most cases, due to being used in dev environment), - * returns an empty observable. - * - * @param doi - The DOI (Digital Object Identifier) of the resource. - * @returns An Observable that completes when the request is sent. - */ - logDownload(doi: string): Observable { - return this.logActivity(DataciteEvent.DOWNLOAD, doi); - } /** * Internal helper to log a specific Datacite event for a given DOI. From ecfb271cb9c6bb6fbabe580a0630490b42bd4c2f Mon Sep 17 00:00:00 2001 From: Oleh Paduchak Date: Tue, 9 Sep 2025 17:14:46 +0300 Subject: [PATCH 4/5] chore(datacite-tracker): added tests for file downloads tracking --- jest.config.js | 1 - .../file-detail/file-detail.component.spec.ts | 107 ++++++++++++++++-- .../files/pages/files/files.component.spec.ts | 20 +++- .../files/pages/files/files.component.ts | 2 +- .../share-and-download.component.spec.ts | 60 +++++++++- .../share-and-download.component.ts | 2 +- 6 files changed, 172 insertions(+), 20 deletions(-) diff --git a/jest.config.js b/jest.config.js index bc7fde4ae..4c5f817e7 100644 --- a/jest.config.js +++ b/jest.config.js @@ -68,7 +68,6 @@ module.exports = { '/src/app/features/project/addons/components/confirm-account-connection-modal/', '/src/app/features/files/components', '/src/app/features/files/pages/community-metadata', - '/src/app/features/files/pages/file-detail', '/src/app/features/my-projects/', '/src/app/features/project/analytics/', '/src/app/features/project/contributors/', diff --git a/src/app/features/files/pages/file-detail/file-detail.component.spec.ts b/src/app/features/files/pages/file-detail/file-detail.component.spec.ts index ab916e9af..32e73ecf4 100644 --- a/src/app/features/files/pages/file-detail/file-detail.component.spec.ts +++ b/src/app/features/files/pages/file-detail/file-detail.component.spec.ts @@ -1,29 +1,114 @@ -import { MockComponent } from 'ng-mocks'; +// Dependencies +import { Store } from '@ngxs/store'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; +import { MockProvider } from 'ng-mocks'; + +import { ButtonGroupModule } from 'primeng/buttongroup'; +import { DialogService } from 'primeng/dynamicdialog'; +import { Message } from 'primeng/message'; +import { TagModule } from 'primeng/tag'; -import { SubHeaderComponent } from '@shared/components'; +import { of } from 'rxjs'; + +import { DestroyRef } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormsModule } from '@angular/forms'; +import { ActivatedRoute, Router, RouterLink } from '@angular/router'; -import { FileDetailComponent } from './file-detail.component'; +import { FileDetailComponent } from '@osf/features/files/pages/file-detail/file-detail.component'; +import { + LinkedResourcesComponent, + OverviewComponentsComponent, + OverviewToolbarComponent, + OverviewWikiComponent, + RecentActivityComponent, +} from '@osf/features/project/overview/components'; +import { + LoadingSpinnerComponent, + ResourceMetadataComponent, + SubHeaderComponent, + ViewOnlyLinkMessageComponent, +} from '@shared/components'; +import { MOCK_STORE } from '@shared/mocks'; +import { CustomConfirmationService } from '@shared/services/custom-confirmation.service'; +import { DataciteService } from '@shared/services/datacite/datacite.service'; +import { ToastService } from '@shared/services/toast.service'; -import { OSFTestingStoreModule } from '@testing/osf.testing.module'; +import { OSFTestingModule } from '@testing/osf.testing.module'; describe('FileDetailComponent', () => { - let component: FileDetailComponent; let fixture: ComponentFixture; + let component: FileDetailComponent; + let dataciteService: jest.Mocked; beforeEach(async () => { + window.open = jest.fn(); + dataciteService = { + logIdentifiableView: jest.fn().mockReturnValue(of(void 0)), + logIdentifiableDownload: jest.fn().mockReturnValue(of(void 0)), + } as unknown as jest.Mocked; + + const mockRoute: Partial = { + params: of({ providerId: 'osf', preprintId: 'p1' }), + queryParams: of({ providerId: 'osf', preprintId: 'p1' }), + }; + (MOCK_STORE.selectSignal as jest.Mock).mockImplementation((selector) => { + switch (selector) { + default: + return () => []; + } + }); + await TestBed.configureTestingModule({ - imports: [FileDetailComponent, MockComponent(SubHeaderComponent), OSFTestingStoreModule], - providers: [], + imports: [ + FileDetailComponent, + OSFTestingModule, + ButtonGroupModule, + TagModule, + SubHeaderComponent, + FormsModule, + LoadingSpinnerComponent, + OverviewWikiComponent, + OverviewComponentsComponent, + LinkedResourcesComponent, + RecentActivityComponent, + OverviewToolbarComponent, + ResourceMetadataComponent, + TranslatePipe, + Message, + RouterLink, + ViewOnlyLinkMessageComponent, + ], + providers: [ + TranslatePipe, + { provide: ActivatedRoute, useValue: mockRoute }, + { provide: Store, useValue: MOCK_STORE }, + { provide: DataciteService, useValue: dataciteService }, + Router, + DestroyRef, + MockProvider(ToastService), + MockProvider(CustomConfirmationService), + DialogService, + TranslateService, + ], }).compileComponents(); - fixture = TestBed.createComponent(FileDetailComponent); component = fixture.componentInstance; fixture.detectChanges(); }); - it('should create', () => { - expect(component).toBeTruthy(); + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should call dataciteService.logIdentifiableDownload when downloadFile is triggered', () => { + const link = '123'; + component.downloadFile(link); + expect(dataciteService.logIdentifiableDownload).toHaveBeenCalledWith(component.fileMetadata$); + }); + + it('should call dataciteService.logIdentifiableView on start ', () => { + expect(dataciteService.logIdentifiableView).toHaveBeenCalledWith(component.fileMetadata$); }); }); diff --git a/src/app/features/files/pages/files/files.component.spec.ts b/src/app/features/files/pages/files/files.component.spec.ts index 2b0748949..5502f196a 100644 --- a/src/app/features/files/pages/files/files.component.spec.ts +++ b/src/app/features/files/pages/files/files.component.spec.ts @@ -8,6 +8,8 @@ import { Dialog } from 'primeng/dialog'; import { DialogService } from 'primeng/dynamicdialog'; import { TableModule } from 'primeng/table'; +import { of } from 'rxjs'; + import { signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; @@ -24,6 +26,7 @@ import { import { GoogleFilePickerComponent } from '@osf/shared/components/addons/folder-selector/google-file-picker/google-file-picker.component'; import { OsfFile } from '@osf/shared/models'; import { CustomConfirmationService, FilesService } from '@osf/shared/services'; +import { DataciteService } from '@shared/services/datacite/datacite.service'; import { FilesSelectors } from '../../store'; @@ -40,8 +43,15 @@ describe('Component: Files', () => { let fixture: ComponentFixture; const currentFolderSignal = signal(getNodeFilesMappedData(0)); + let dataciteService: jest.Mocked; + beforeEach(async () => { jest.clearAllMocks(); + window.open = jest.fn(); + dataciteService = { + logFileView: jest.fn().mockReturnValue(of(void 0)), + logFileDownload: jest.fn().mockReturnValue(of(void 0)), + } as unknown as jest.Mocked; await TestBed.configureTestingModule({ imports: [ OSFTestingModule, @@ -63,7 +73,7 @@ describe('Component: Files', () => { FilesService, MockProvider(ActivatedRoute), MockProvider(CustomConfirmationService), - + { provide: DataciteService, useValue: dataciteService }, DialogService, provideMockStore({ signals: [ @@ -183,4 +193,12 @@ describe('Component: Files', () => { expect(dispatchSpy).not.toHaveBeenCalled(); }); }); + + describe('Download file', () => { + it('', () => { + component.resourceId.set('123'); + component.downloadFolder(); + expect(dataciteService.logFileDownload).toHaveBeenCalledWith('123', 'nodes'); + }); + }); }); diff --git a/src/app/features/files/pages/files/files.component.ts b/src/app/features/files/pages/files/files.component.ts index c36abde0e..25d4a373f 100644 --- a/src/app/features/files/pages/files/files.component.ts +++ b/src/app/features/files/pages/files/files.component.ts @@ -341,9 +341,9 @@ export class FilesComponent { const isRootFolder = !this.currentFolder()?.relationships?.parentFolderLink; const provider = this.currentRootFolder()?.folder?.provider ?? 'osfstorage'; const resourcePath = this.urlMap.get(this.resourceType()) ?? 'nodes'; - this.dataciteService.logFileDownload(resourceId, resourcePath).subscribe(); if (resourceId && folderId) { + this.dataciteService.logFileDownload(resourceId, resourcePath).subscribe(); if (isRootFolder) { const link = this.filesService.getFolderDownloadLink(resourceId, provider, '', true); window.open(link, '_blank')?.focus(); diff --git a/src/app/features/preprints/components/preprint-details/share-and-downlaod/share-and-download.component.spec.ts b/src/app/features/preprints/components/preprint-details/share-and-downlaod/share-and-download.component.spec.ts index 084671c5c..0eb766383 100644 --- a/src/app/features/preprints/components/preprint-details/share-and-downlaod/share-and-download.component.spec.ts +++ b/src/app/features/preprints/components/preprint-details/share-and-downlaod/share-and-download.component.spec.ts @@ -2,18 +2,62 @@ import { Store } from '@ngxs/store'; import { MockProvider } from 'ng-mocks'; +import { of } from 'rxjs'; + import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ProviderReviewsWorkflow } from '@osf/features/preprints/enums'; +import { PreprintProviderDetails } from '@osf/features/preprints/models'; import { PreprintSelectors } from '@osf/features/preprints/store/preprint'; +import { ReviewPermissions } from '@osf/shared/enums'; import { MOCK_STORE } from '@shared/mocks'; +import { DataciteService } from '@shared/services/datacite/datacite.service'; import { ShareAndDownloadComponent } from './share-and-download.component'; -describe.skip('ShareAndDownloadComponent', () => { +import { OSFTestingModule } from '@testing/osf.testing.module'; + +export const mockPreprintProvider: PreprintProviderDetails = { + id: 'osf-preprints', + name: 'OSF Preprints', + descriptionHtml: '

Open preprints for all disciplines

', + advisoryBoardHtml: '

Advisory board content here

', + examplePreprintId: '12345', + domain: 'osf.io', + footerLinksHtml: 'About', + preprintWord: 'preprint', + allowSubmissions: true, + assertionsEnabled: false, + reviewsWorkflow: ProviderReviewsWorkflow.PreModeration, + permissions: [ReviewPermissions.ViewSubmissions], + brand: { + id: 'brand-1', + name: 'OSF Brand', + heroLogoImageUrl: 'https://osf.io/assets/hero-logo.png', + heroBackgroundImageUrl: 'https://osf.io/assets/hero-bg.png', + topNavLogoImageUrl: 'https://osf.io/assets/nav-logo.png', + primaryColor: '#0056b3', + secondaryColor: '#ff9900', + backgroundColor: '#ffffff', + }, + iri: 'https://osf.io/preprints/', + faviconUrl: 'https://osf.io/favicon.ico', + squareColorNoTransparentImageUrl: 'https://osf.io/image.png', + facebookAppId: '1234567890', + reviewsCommentsPrivate: null, + reviewsCommentsAnonymous: null, + lastFetched: Date.now(), +}; +describe('ShareAndDownloadComponent', () => { let component: ShareAndDownloadComponent; let fixture: ComponentFixture; + let dataciteService: jest.Mocked; beforeEach(async () => { + dataciteService = { + logIdentifiableView: jest.fn().mockReturnValue(of(void 0)), + logIdentifiableDownload: jest.fn().mockReturnValue(of(void 0)), + } as unknown as jest.Mocked; (MOCK_STORE.selectSignal as jest.Mock).mockImplementation((selector) => { if (selector === PreprintSelectors.getPreprint) return () => null; if (selector === PreprintSelectors.isPreprintLoading) return () => false; @@ -21,16 +65,22 @@ describe.skip('ShareAndDownloadComponent', () => { }); await TestBed.configureTestingModule({ - imports: [ShareAndDownloadComponent], - providers: [MockProvider(Store, MOCK_STORE)], + imports: [ShareAndDownloadComponent, OSFTestingModule], + providers: [MockProvider(Store, MOCK_STORE), { provide: DataciteService, useValue: dataciteService }], }).compileComponents(); fixture = TestBed.createComponent(ShareAndDownloadComponent); component = fixture.componentInstance; + fixture.componentRef.setInput('preprintProvider', mockPreprintProvider); fixture.detectChanges(); }); - it('should create', () => { - expect(component).toBeTruthy(); + it('should call dataciteService.logIdentifiableDownload when logDownload is triggered', () => { + component.logDownload(); + expect(dataciteService.logIdentifiableDownload).toHaveBeenCalledWith(component.preprint$); + }); + + it('should call dataciteService.logIdentifiableView on start ', () => { + expect(dataciteService.logIdentifiableView).toHaveBeenCalledWith(component.preprint$); }); }); diff --git a/src/app/features/preprints/components/preprint-details/share-and-downlaod/share-and-download.component.ts b/src/app/features/preprints/components/preprint-details/share-and-downlaod/share-and-download.component.ts index 8a743cd7a..c510f0400 100644 --- a/src/app/features/preprints/components/preprint-details/share-and-downlaod/share-and-download.component.ts +++ b/src/app/features/preprints/components/preprint-details/share-and-downlaod/share-and-download.component.ts @@ -49,7 +49,7 @@ export class ShareAndDownloadComponent { return this.socialShareService.createDownloadUrl(preprint.id); }); - protected logDownload() { + logDownload() { this.dataciteService.logIdentifiableDownload(this.preprint$).subscribe(); } From c1cf426f6800e231f84070c5d2219ee4b8cf06bb Mon Sep 17 00:00:00 2001 From: Oleh Paduchak Date: Wed, 10 Sep 2025 15:02:57 +0300 Subject: [PATCH 5/5] chore(datacite-tracker): added tests for leftover components and pr comment fixes --- jest.config.js | 9 ++- .../file-revisions.component.spec.ts | 29 ++++++-- .../files/pages/files/files.component.spec.ts | 8 +-- .../preprint-file-section.component.ts | 2 +- .../share-and-download.component.spec.ts | 49 ++----------- .../preprint-details.component.spec.ts | 6 +- .../project-overview.component.spec.ts | 7 +- .../registry/registry.component.spec.ts | 8 +-- .../files-tree/files-tree.component.spec.ts | 29 +++++++- .../files-tree/files-tree.component.ts | 2 +- src/testing/mocks/datacite.service.mock.ts | 12 ++++ src/testing/mocks/osf-file.mock.ts | 70 +++++++++++++++++++ .../mocks/preprint-provider-details.ts | 35 ++++++++++ 13 files changed, 191 insertions(+), 75 deletions(-) create mode 100644 src/testing/mocks/datacite.service.mock.ts create mode 100644 src/testing/mocks/osf-file.mock.ts create mode 100644 src/testing/mocks/preprint-provider-details.ts diff --git a/jest.config.js b/jest.config.js index 4c5f817e7..6ec59c631 100644 --- a/jest.config.js +++ b/jest.config.js @@ -66,7 +66,13 @@ module.exports = { '/src/app/features/project/addons/components/connect-configured-addon/', '/src/app/features/project/addons/components/disconnect-addon-modal/', '/src/app/features/project/addons/components/confirm-account-connection-modal/', - '/src/app/features/files/components', + '/src/app/features/files/components/create-folder-dialog', + '/src/app/features/files/components/edit-file-metadata-dialog', + '/src/app/features/files/components/file-keywords', + '/src/app/features/files/components/file-metadata', + '/src/app/features/files/components/file-resource-metadata', + '/src/app/features/files/components/move-file-dialog', + '/src/app/features/files/components/rename-file-dialog', '/src/app/features/files/pages/community-metadata', '/src/app/features/my-projects/', '/src/app/features/project/analytics/', @@ -98,7 +104,6 @@ module.exports = { '/src/app/features/settings/tokens/mappers/', '/src/app/features/settings/tokens/store/', '/src/app/shared/components/file-menu/', - '/src/app/shared/components/files-tree/', '/src/app/shared/components/line-chart/', '/src/app/shared/components/make-decision-dialog/', '/src/app/shared/components/pie-chart/', diff --git a/src/app/features/files/components/file-revisions/file-revisions.component.spec.ts b/src/app/features/files/components/file-revisions/file-revisions.component.spec.ts index e03f8a315..52dd06fae 100644 --- a/src/app/features/files/components/file-revisions/file-revisions.component.spec.ts +++ b/src/app/features/files/components/file-revisions/file-revisions.component.spec.ts @@ -1,16 +1,36 @@ +import { Store } from '@ngxs/store'; + +import { MockProvider } from 'ng-mocks'; + import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FilesSelectors } from '@osf/features/files/store'; +import { MOCK_STORE } from '@shared/mocks'; +import { DataciteService } from '@shared/services/datacite/datacite.service'; + import { FileRevisionsComponent } from './file-revisions.component'; -import { OSFTestingStoreModule } from '@testing/osf.testing.module'; +import { DataciteMockFactory } from '@testing/mocks/datacite.service.mock'; +import { OSFTestingModule } from '@testing/osf.testing.module'; describe('FileRevisionsComponent', () => { let component: FileRevisionsComponent; let fixture: ComponentFixture; + let dataciteMock: jest.Mocked; beforeEach(async () => { + dataciteMock = DataciteMockFactory(); + (MOCK_STORE.selectSignal as jest.Mock).mockImplementation((selector) => { + switch (selector) { + case FilesSelectors.isFileRevisionsLoading: + return () => false; + default: + return () => []; + } + }); await TestBed.configureTestingModule({ - imports: [FileRevisionsComponent, OSFTestingStoreModule], + providers: [MockProvider(Store, MOCK_STORE), MockProvider(DataciteService, dataciteMock)], + imports: [FileRevisionsComponent, OSFTestingModule], }).compileComponents(); fixture = TestBed.createComponent(FileRevisionsComponent); @@ -18,7 +38,8 @@ describe('FileRevisionsComponent', () => { fixture.detectChanges(); }); - it('should create', () => { - expect(component).toBeTruthy(); + it('should log download', () => { + component.downloadRevision('123'); + expect(dataciteMock.logIdentifiableDownload).toHaveBeenCalledWith(component.resourceMetadata); }); }); diff --git a/src/app/features/files/pages/files/files.component.spec.ts b/src/app/features/files/pages/files/files.component.spec.ts index 5502f196a..334883dbf 100644 --- a/src/app/features/files/pages/files/files.component.spec.ts +++ b/src/app/features/files/pages/files/files.component.spec.ts @@ -8,8 +8,6 @@ import { Dialog } from 'primeng/dialog'; import { DialogService } from 'primeng/dynamicdialog'; import { TableModule } from 'primeng/table'; -import { of } from 'rxjs'; - import { signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; @@ -34,6 +32,7 @@ import { FilesComponent } from './files.component'; import { getConfiguredAddonsMappedData } from '@testing/data/addons/addons.configured.data'; import { getNodeFilesMappedData } from '@testing/data/files/node.data'; +import { DataciteMockFactory } from '@testing/mocks/datacite.service.mock'; import { OSFTestingModule } from '@testing/osf.testing.module'; import { MockComponentWithSignal } from '@testing/providers/component-provider.mock'; import { provideMockStore } from '@testing/providers/store-provider.mock'; @@ -48,10 +47,7 @@ describe('Component: Files', () => { beforeEach(async () => { jest.clearAllMocks(); window.open = jest.fn(); - dataciteService = { - logFileView: jest.fn().mockReturnValue(of(void 0)), - logFileDownload: jest.fn().mockReturnValue(of(void 0)), - } as unknown as jest.Mocked; + dataciteService = DataciteMockFactory(); await TestBed.configureTestingModule({ imports: [ OSFTestingModule, diff --git a/src/app/features/preprints/components/preprint-details/preprint-file-section/preprint-file-section.component.ts b/src/app/features/preprints/components/preprint-details/preprint-file-section/preprint-file-section.component.ts index a3d941e5e..43bc28fcb 100644 --- a/src/app/features/preprints/components/preprint-details/preprint-file-section/preprint-file-section.component.ts +++ b/src/app/features/preprints/components/preprint-details/preprint-file-section/preprint-file-section.component.ts @@ -51,7 +51,7 @@ export class PreprintFileSectionComponent { areFileVersionsLoading = select(PreprintSelectors.arePreprintFileVersionsLoading); logDownload() { - this.dataciteService.logIdentifiableDownload(this.preprint$); + this.dataciteService.logIdentifiableDownload(this.preprint$).subscribe(); } versionMenuItems = computed(() => { diff --git a/src/app/features/preprints/components/preprint-details/share-and-downlaod/share-and-download.component.spec.ts b/src/app/features/preprints/components/preprint-details/share-and-downlaod/share-and-download.component.spec.ts index 0eb766383..12471a8b6 100644 --- a/src/app/features/preprints/components/preprint-details/share-and-downlaod/share-and-download.component.spec.ts +++ b/src/app/features/preprints/components/preprint-details/share-and-downlaod/share-and-download.component.spec.ts @@ -2,62 +2,25 @@ import { Store } from '@ngxs/store'; import { MockProvider } from 'ng-mocks'; -import { of } from 'rxjs'; - import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { ProviderReviewsWorkflow } from '@osf/features/preprints/enums'; -import { PreprintProviderDetails } from '@osf/features/preprints/models'; import { PreprintSelectors } from '@osf/features/preprints/store/preprint'; -import { ReviewPermissions } from '@osf/shared/enums'; import { MOCK_STORE } from '@shared/mocks'; import { DataciteService } from '@shared/services/datacite/datacite.service'; import { ShareAndDownloadComponent } from './share-and-download.component'; +import { DataciteMockFactory } from '@testing/mocks/datacite.service.mock'; +import { PREPRINT_PROVIDER_DETAILS_MOCK } from '@testing/mocks/preprint-provider-details'; import { OSFTestingModule } from '@testing/osf.testing.module'; -export const mockPreprintProvider: PreprintProviderDetails = { - id: 'osf-preprints', - name: 'OSF Preprints', - descriptionHtml: '

Open preprints for all disciplines

', - advisoryBoardHtml: '

Advisory board content here

', - examplePreprintId: '12345', - domain: 'osf.io', - footerLinksHtml: 'About', - preprintWord: 'preprint', - allowSubmissions: true, - assertionsEnabled: false, - reviewsWorkflow: ProviderReviewsWorkflow.PreModeration, - permissions: [ReviewPermissions.ViewSubmissions], - brand: { - id: 'brand-1', - name: 'OSF Brand', - heroLogoImageUrl: 'https://osf.io/assets/hero-logo.png', - heroBackgroundImageUrl: 'https://osf.io/assets/hero-bg.png', - topNavLogoImageUrl: 'https://osf.io/assets/nav-logo.png', - primaryColor: '#0056b3', - secondaryColor: '#ff9900', - backgroundColor: '#ffffff', - }, - iri: 'https://osf.io/preprints/', - faviconUrl: 'https://osf.io/favicon.ico', - squareColorNoTransparentImageUrl: 'https://osf.io/image.png', - facebookAppId: '1234567890', - reviewsCommentsPrivate: null, - reviewsCommentsAnonymous: null, - lastFetched: Date.now(), -}; describe('ShareAndDownloadComponent', () => { let component: ShareAndDownloadComponent; let fixture: ComponentFixture; let dataciteService: jest.Mocked; beforeEach(async () => { - dataciteService = { - logIdentifiableView: jest.fn().mockReturnValue(of(void 0)), - logIdentifiableDownload: jest.fn().mockReturnValue(of(void 0)), - } as unknown as jest.Mocked; + dataciteService = DataciteMockFactory(); (MOCK_STORE.selectSignal as jest.Mock).mockImplementation((selector) => { if (selector === PreprintSelectors.getPreprint) return () => null; if (selector === PreprintSelectors.isPreprintLoading) return () => false; @@ -71,7 +34,7 @@ describe('ShareAndDownloadComponent', () => { fixture = TestBed.createComponent(ShareAndDownloadComponent); component = fixture.componentInstance; - fixture.componentRef.setInput('preprintProvider', mockPreprintProvider); + fixture.componentRef.setInput('preprintProvider', PREPRINT_PROVIDER_DETAILS_MOCK); fixture.detectChanges(); }); @@ -79,8 +42,4 @@ describe('ShareAndDownloadComponent', () => { component.logDownload(); expect(dataciteService.logIdentifiableDownload).toHaveBeenCalledWith(component.preprint$); }); - - it('should call dataciteService.logIdentifiableView on start ', () => { - expect(dataciteService.logIdentifiableView).toHaveBeenCalledWith(component.preprint$); - }); }); diff --git a/src/app/features/preprints/pages/preprint-details/preprint-details.component.spec.ts b/src/app/features/preprints/pages/preprint-details/preprint-details.component.spec.ts index 69dfd4e3e..0b3b3b0d0 100644 --- a/src/app/features/preprints/pages/preprint-details/preprint-details.component.spec.ts +++ b/src/app/features/preprints/pages/preprint-details/preprint-details.component.spec.ts @@ -21,6 +21,8 @@ import { DataciteService } from '@shared/services/datacite/datacite.service'; import { PreprintDetailsComponent } from './preprint-details.component'; +import { DataciteMockFactory } from '@testing/mocks/datacite.service.mock'; + describe('PreprintDetailsComponent', () => { let component: PreprintDetailsComponent; let fixture: ComponentFixture; @@ -49,9 +51,7 @@ describe('PreprintDetailsComponent', () => { } }); (MOCK_STORE.dispatch as jest.Mock).mockImplementation(() => of()); - dataciteService = { - logIdentifiableView: jest.fn().mockReturnValue(of(void 0)), - } as unknown as jest.Mocked; + dataciteService = DataciteMockFactory(); await TestBed.configureTestingModule({ imports: [ diff --git a/src/app/features/project/overview/project-overview.component.spec.ts b/src/app/features/project/overview/project-overview.component.spec.ts index e8b5712f9..90b7d05f0 100644 --- a/src/app/features/project/overview/project-overview.component.spec.ts +++ b/src/app/features/project/overview/project-overview.component.spec.ts @@ -8,8 +8,6 @@ import { DialogService } from 'primeng/dynamicdialog'; import { Message } from 'primeng/message'; import { TagModule } from 'primeng/tag'; -import { of } from 'rxjs'; - import { DestroyRef, signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { FormsModule } from '@angular/forms'; @@ -46,6 +44,7 @@ import { ActivityLogsSelectors } from '@shared/stores/activity-logs'; import { ProjectOverviewComponent } from './project-overview.component'; +import { DataciteMockFactory } from '@testing/mocks/datacite.service.mock'; import { OSFTestingModule } from '@testing/osf.testing.module'; const sampleReviewAction: CollectionSubmissionReviewAction = { @@ -118,9 +117,7 @@ describe('ProjectOverviewComponent', () => { } }); - dataciteService = { - logIdentifiableView: jest.fn().mockReturnValue(of(void 0)), - } as unknown as jest.Mocked; + dataciteService = DataciteMockFactory(); await TestBed.configureTestingModule({ imports: [ diff --git a/src/app/features/registry/registry.component.spec.ts b/src/app/features/registry/registry.component.spec.ts index 20ac70ae5..a3e06cde7 100644 --- a/src/app/features/registry/registry.component.spec.ts +++ b/src/app/features/registry/registry.component.spec.ts @@ -1,7 +1,5 @@ import { Store } from '@ngxs/store'; -import { of } from 'rxjs'; - import { DatePipe } from '@angular/common'; import { signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; @@ -12,6 +10,8 @@ import { DataciteService } from '@shared/services/datacite/datacite.service'; import { RegistryComponent } from './registry.component'; +import { DataciteMockFactory } from '@testing/mocks/datacite.service.mock'; + describe('RegistryComponent', () => { let fixture: any; let component: RegistryComponent; @@ -20,9 +20,7 @@ describe('RegistryComponent', () => { const registrySignal = signal(null); beforeEach(async () => { - dataciteService = { - logIdentifiableView: jest.fn().mockReturnValue(of(void 0)), - } as unknown as jest.Mocked; + dataciteService = DataciteMockFactory(); const mockStore = { selectSignal: jest.fn((selector: any) => { diff --git a/src/app/shared/components/files-tree/files-tree.component.spec.ts b/src/app/shared/components/files-tree/files-tree.component.spec.ts index 45cf3d634..3126b6f5c 100644 --- a/src/app/shared/components/files-tree/files-tree.component.spec.ts +++ b/src/app/shared/components/files-tree/files-tree.component.spec.ts @@ -1,22 +1,45 @@ +import { MockProvider } from 'ng-mocks'; + +import { DialogService } from 'primeng/dynamicdialog'; + import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { CustomConfirmationService, FilesService, ToastService } from '@shared/services'; +import { DataciteService } from '@shared/services/datacite/datacite.service'; + import { FilesTreeComponent } from './files-tree.component'; +import { DataciteMockFactory } from '@testing/mocks/datacite.service.mock'; +import { OSF_FILE_MOCK } from '@testing/mocks/osf-file.mock'; +import { OSFTestingModule } from '@testing/osf.testing.module'; + describe('FilesTreeComponent', () => { let component: FilesTreeComponent; let fixture: ComponentFixture; + let dataciteMock: jest.Mocked; beforeEach(async () => { + dataciteMock = DataciteMockFactory(); await TestBed.configureTestingModule({ - imports: [FilesTreeComponent], + imports: [FilesTreeComponent, OSFTestingModule], + providers: [ + { provide: DataciteService, useValue: dataciteMock }, + MockProvider(FilesService), + MockProvider(ToastService), + MockProvider(CustomConfirmationService), + MockProvider(DialogService), + ], }).compileComponents(); fixture = TestBed.createComponent(FilesTreeComponent); component = fixture.componentInstance; + fixture.componentRef.setInput('currentFolder', null); + fixture.componentRef.setInput('files', []); fixture.detectChanges(); }); - it('should create', () => { - expect(component).toBeTruthy(); + it('should log Download', () => { + component.downloadFileOrFolder(OSF_FILE_MOCK); + expect(dataciteMock.logFileDownload).toHaveBeenCalledWith(OSF_FILE_MOCK.target.id, OSF_FILE_MOCK.target.type); }); }); diff --git a/src/app/shared/components/files-tree/files-tree.component.ts b/src/app/shared/components/files-tree/files-tree.component.ts index f940ac097..3b0155b16 100644 --- a/src/app/shared/components/files-tree/files-tree.component.ts +++ b/src/app/shared/components/files-tree/files-tree.component.ts @@ -239,7 +239,7 @@ export class FilesTreeComponent implements OnDestroy, AfterViewInit { } } - private downloadFileOrFolder(file: OsfFile) { + downloadFileOrFolder(file: OsfFile) { this.dataciteService.logFileDownload(file.target.id, file.target.type).subscribe(); if (file.kind === 'file') { this.downloadFile(file.links.download); diff --git a/src/testing/mocks/datacite.service.mock.ts b/src/testing/mocks/datacite.service.mock.ts new file mode 100644 index 000000000..69ab8d025 --- /dev/null +++ b/src/testing/mocks/datacite.service.mock.ts @@ -0,0 +1,12 @@ +import { of } from 'rxjs'; + +import { DataciteService } from '@shared/services/datacite/datacite.service'; + +export function DataciteMockFactory() { + return { + logFileDownload: jest.fn().mockReturnValue(of(void 0)), + logFileView: jest.fn().mockReturnValue(of(void 0)), + logIdentifiableView: jest.fn().mockReturnValue(of(void 0)), + logIdentifiableDownload: jest.fn().mockReturnValue(of(void 0)), + } as unknown as jest.Mocked; +} diff --git a/src/testing/mocks/osf-file.mock.ts b/src/testing/mocks/osf-file.mock.ts new file mode 100644 index 000000000..f457339cc --- /dev/null +++ b/src/testing/mocks/osf-file.mock.ts @@ -0,0 +1,70 @@ +import { OsfFile } from '@shared/models'; + +export const OSF_FILE_MOCK: OsfFile = { + id: 'file-123', + guid: 'abcd1234', + name: 'example.pdf', + kind: 'file', + path: '/example.pdf', + size: 102400, + provider: 'osfstorage', + materializedPath: '/example.pdf', + lastTouched: null, + dateModified: '2023-08-01T12:00:00Z', + dateCreated: '2023-07-01T09:30:00Z', + extra: { + hashes: { + md5: 'd41d8cd98f00b204e9800998ecf8427e', + sha256: '9c56cc51b374c3ba189210d5b6d4f6b0df3e1f7dba6d9a1e9c8f4c9e614dee56', + }, + downloads: 42, + }, + tags: [], + currentUserCanComment: true, + currentVersion: 3, + showAsUnviewed: false, + links: { + info: '/v2/files/file-123/', + move: '/v2/files/file-123/move/', + upload: '/v2/files/file-123/upload/', + delete: '/v2/files/file-123/delete/', + download: '/v2/files/file-123/download/', + self: '/v2/files/file-123/', + html: 'https://osf.io/abcd1234/', + render: 'https://osf.io/abcd1234/render', + newFolder: '/v2/files/file-123/newfolder/', + }, + relationships: { + parentFolderLink: '/v2/nodes/node-456/files/osfstorage/', + parentFolderId: 'folder-789', + filesLink: '/v2/nodes/node-456/files/', + uploadLink: '/v2/nodes/node-456/files/osfstorage/upload/', + newFolderLink: '/v2/nodes/node-456/files/osfstorage/newfolder/', + }, + target: { + id: 'node-456', + title: 'Example Project', + description: 'A mock OSF project for testing.', + category: 'project', + customCitation: null, + dateCreated: '2023-06-15T10:00:00Z', + dateModified: '2023-08-01T12:00:00Z', + registration: false, + preprint: false, + fork: false, + collection: false, + tags: ['science', 'mock'], + nodeLicense: null, + analyticsKey: 'analytics-key-123', + currentUserCanComment: true, + currentUserPermissions: ['read', 'write'], + currentUserIsContributor: true, + currentUserIsContributorOrGroupMember: true, + wikiEnabled: true, + public: true, + type: 'node', + isAnonymous: false, + link: 'https://osf.io/node-456/', + }, + previousFolder: false, +}; diff --git a/src/testing/mocks/preprint-provider-details.ts b/src/testing/mocks/preprint-provider-details.ts new file mode 100644 index 000000000..200549b3d --- /dev/null +++ b/src/testing/mocks/preprint-provider-details.ts @@ -0,0 +1,35 @@ +import { ProviderReviewsWorkflow } from '@osf/features/preprints/enums'; +import { PreprintProviderDetails } from '@osf/features/preprints/models'; +import { ReviewPermissions } from '@shared/enums'; + +export const PREPRINT_PROVIDER_DETAILS_MOCK: PreprintProviderDetails = { + id: 'osf-preprints', + name: 'OSF Preprints', + descriptionHtml: '

Open preprints for all disciplines

', + advisoryBoardHtml: '

Advisory board content here

', + examplePreprintId: '12345', + domain: 'osf.io', + footerLinksHtml: 'About', + preprintWord: 'preprint', + allowSubmissions: true, + assertionsEnabled: false, + reviewsWorkflow: ProviderReviewsWorkflow.PreModeration, + permissions: [ReviewPermissions.ViewSubmissions], + brand: { + id: 'brand-1', + name: 'OSF Brand', + heroLogoImageUrl: 'https://osf.io/assets/hero-logo.png', + heroBackgroundImageUrl: 'https://osf.io/assets/hero-bg.png', + topNavLogoImageUrl: 'https://osf.io/assets/nav-logo.png', + primaryColor: '#0056b3', + secondaryColor: '#ff9900', + backgroundColor: '#ffffff', + }, + iri: 'https://osf.io/preprints/', + faviconUrl: 'https://osf.io/favicon.ico', + squareColorNoTransparentImageUrl: 'https://osf.io/image.png', + facebookAppId: '1234567890', + reviewsCommentsPrivate: null, + reviewsCommentsAnonymous: null, + lastFetched: Date.now(), +};