From 289939dc95dc325118068b8656480adf00323d79 Mon Sep 17 00:00:00 2001 From: Aaron Plahn Date: Thu, 16 Jan 2025 15:01:45 -0800 Subject: [PATCH] feat(api): publish events in command handlers (#623) * publish events in command handlers * WIP publish events in command handlers * initialize term view to have empty contributions list * fix lint * refactor based on PR #623 * update integration test setup to include an event publisher provider * switch to state-based test setup for term by ID query test * use state based test setup for term fetch many queries --- .../controllers/__tests__/createTestModule.ts | 35 ++- .../command/command.controller.e2e.spec.ts | 8 +- .../src/domain/common/events/event.module.ts | 10 +- .../events/in-memory-event-publisher.ts | 7 +- .../coscrad-event-publisher.interface.ts | 2 + .../events/sync-in-memory-event-publisher.ts | 15 +- ...line-item-to-transcript.command-handler.ts | 9 +- ...rticipant-to-transcript.command-handler.ts | 7 +- .../create-transcript.command-handler.ts | 7 +- ...-bibliographic-citation.command-handler.ts | 7 +- ...-bibliographic-citation.command-handler.ts | 7 +- ...-bibliographic-citation.command-handler.ts | 7 +- ...-audio-item-to-playlist.command-handler.ts | 7 +- .../create-playlist.command-handler.ts | 7 +- ...translate-playlist-name.command-handler.ts | 7 +- .../command-handlers/base-command-handler.ts | 4 +- .../base-create-command-handler.ts | 12 +- .../base-update-command-handler.ts | 8 + .../resource-published.event-handler.ts | 2 + .../add-lyrics-for-song.command-handler.ts | 7 +- .../commands/create-song.command-handler.ts | 7 +- .../translate-song-lyrics.command-handler.ts | 7 +- .../translate-song-title.command-handler.ts | 7 +- .../relabel-tag.command-handler.ts | 8 +- .../tag-resource-or-note.command-handler.ts | 7 +- .../create-term/term-created.event-handler.ts | 4 - .../arango-term-query-repository.ts | 8 +- .../add-user-to-group.command-handler.ts | 7 +- .../grant-user-role.command-handler.ts | 7 +- .../arango-database-for-collection.ts | 1 - .../api/src/persistence/persistence.module.ts | 1 + .../viewModels/term.view-model.ts | 6 +- .../term-queries-fetch-by-id.e2e.spec.ts.snap | 14 + .../term-queries-fetch-many.e2e.spec.ts.snap | 61 +++-- .../term/term-queries-fetch-by-id.e2e.spec.ts | 259 +++++------------- .../term/term-queries-fetch-many.e2e.spec.ts | 180 +++++------- 36 files changed, 396 insertions(+), 363 deletions(-) diff --git a/apps/api/src/app/controllers/__tests__/createTestModule.ts b/apps/api/src/app/controllers/__tests__/createTestModule.ts index a1385d18d..5cac7e95a 100644 --- a/apps/api/src/app/controllers/__tests__/createTestModule.ts +++ b/apps/api/src/app/controllers/__tests__/createTestModule.ts @@ -10,7 +10,10 @@ import { MockJwtAuthGuard } from '../../../authorization/mock-jwt-auth-guard'; import { MockJwtStrategy } from '../../../authorization/mock-jwt.strategy'; import { OptionalJwtAuthGuard } from '../../../authorization/optional-jwt-auth-guard'; import { ConsoleCoscradCliLogger } from '../../../coscrad-cli/logging'; -import { CoscradEventFactory, CoscradEventUnion } from '../../../domain/common'; +import { CoscradEventFactory, CoscradEventUnion, EventModule } from '../../../domain/common'; +import { ObservableInMemoryEventPublisher } from '../../../domain/common/events/in-memory-event-publisher'; +import { EVENT_PUBLISHER_TOKEN } from '../../../domain/common/events/interfaces'; +import { SyncInMemoryEventPublisher } from '../../../domain/common/events/sync-in-memory-event-publisher'; import { ID_MANAGER_TOKEN } from '../../../domain/interfaces/id-manager.interface'; import { AudioItemController } from '../../../domain/models/audio-visual/application/audio-item.controller'; import { VideoController } from '../../../domain/models/audio-visual/application/video.controller'; @@ -143,8 +146,12 @@ import { UnpublishResource, UnpublishResourceCommandHandler, } from '../../../domain/models/shared/common-commands'; +import { ResourceReadAccessGrantedToUserEventHandler } from '../../../domain/models/shared/common-commands/grant-resource-read-access-to-user/resource-read-access-granted-to-user.event-handler'; import { ResourcePublished } from '../../../domain/models/shared/common-commands/publish-resource/resource-published.event'; -import { IQueryRepositoryProvider } from '../../../domain/models/shared/common-commands/publish-resource/resource-published.event-handler'; +import { + IQueryRepositoryProvider, + ResourcePublishedEventHandler, +} from '../../../domain/models/shared/common-commands/publish-resource/resource-published.event-handler'; import { AddLyricsForSong, AddLyricsForSongCommandHandler, @@ -187,10 +194,15 @@ import { PromptTermCreated, TermCreated, TermElicitedFromPrompt, + TermElicitedFromPromptEventHandler, TermTranslated, TranslateTerm, TranslateTermCommandHandler, } from '../../../domain/models/term/commands'; +import { AudioAddedForTermEventHandler } from '../../../domain/models/term/commands/add-audio-for-term/audio-added-for-term.event-handler'; +import { PromptTermCreatedEventHandler } from '../../../domain/models/term/commands/create-prompt-term/prompt-term-created.event-handler'; +import { TermCreatedEventHandler } from '../../../domain/models/term/commands/create-term/term-created.event-handler'; +import { TermTranslatedEventHandler } from '../../../domain/models/term/commands/translate-term/term-translated.event-handler'; import { Term } from '../../../domain/models/term/entities/term.entity'; import { ITermQueryRepository, @@ -380,6 +392,7 @@ export default async ( CommandModule, PassportModule.register({ defaultStrategy: 'jwt' }), DynamicDataTypeModule, + EventModule, ], providers: [ CommandInfoService, @@ -391,6 +404,10 @@ export default async ( buildConfigFilePath(Environment.test) ), }, + { + provide: EVENT_PUBLISHER_TOKEN, + useValue: new SyncInMemoryEventPublisher(new ConsoleCoscradCliLogger()), + }, { provide: ArangoConnectionProvider, useFactory: async (configService: ConfigService) => { @@ -674,7 +691,13 @@ export default async ( DynamicDataTypeFinderService, ], }, + { + provide: 'COSCRAD_EVENT_PUBLISHER', + useFactory: () => + new ObservableInMemoryEventPublisher(new ConsoleCoscradCliLogger()), + }, ...dataClassProviders, + /** * TODO [https://www.pivotaltracker.com/story/show/182576828] * @@ -799,6 +822,14 @@ export default async ( AddContentToDigitalTextPageCommandHandler, CreatePhotograph, CreatePhotographCommandHandler, + // Event Handlers + ResourcePublishedEventHandler, + ResourceReadAccessGrantedToUserEventHandler, + TermCreatedEventHandler, + TermTranslatedEventHandler, + PromptTermCreatedEventHandler, + TermElicitedFromPromptEventHandler, + AudioAddedForTermEventHandler, ], controllers: [ diff --git a/apps/api/src/app/controllers/command/command.controller.e2e.spec.ts b/apps/api/src/app/controllers/command/command.controller.e2e.spec.ts index d8a3eb435..7271ec3cc 100644 --- a/apps/api/src/app/controllers/command/command.controller.e2e.spec.ts +++ b/apps/api/src/app/controllers/command/command.controller.e2e.spec.ts @@ -3,7 +3,9 @@ import { CommandHandlerService, FluxStandardAction } from '@coscrad/commands'; import { CoscradUserRole } from '@coscrad/data-types'; import { INestApplication } from '@nestjs/common'; import * as request from 'supertest'; +import { ConsoleCoscradCliLogger } from '../../../coscrad-cli/logging'; import getValidAggregateInstanceForTest from '../../../domain/__tests__/utilities/getValidAggregateInstanceForTest'; +import { ObservableInMemoryEventPublisher } from '../../../domain/common/events/in-memory-event-publisher'; import { IIdManager } from '../../../domain/interfaces/id-manager.interface'; import { buildFakeTimersConfig } from '../../../domain/models/__tests__/utilities/buildFakeTimersConfig'; import { CreateSong } from '../../../domain/models/song/commands/create-song.command'; @@ -80,7 +82,11 @@ describe('The Command Controller', () => { */ commandHandlerService.registerHandler( 'CREATE_SONG', - new CreateSongCommandHandler(testRepositoryProvider, idManager) + new CreateSongCommandHandler( + testRepositoryProvider, + idManager, + new ObservableInMemoryEventPublisher(new ConsoleCoscradCliLogger()) + ) ); jest.useFakeTimers(buildFakeTimersConfig()); diff --git a/apps/api/src/domain/common/events/event.module.ts b/apps/api/src/domain/common/events/event.module.ts index 014c90fc2..138924a4a 100644 --- a/apps/api/src/domain/common/events/event.module.ts +++ b/apps/api/src/domain/common/events/event.module.ts @@ -26,10 +26,6 @@ import { SyncInMemoryEventPublisher } from './sync-in-memory-event-publisher'; { provide: EVENT_PUBLISHER_TOKEN, useFactory: () => new SyncInMemoryEventPublisher(new ConsoleCoscradCliLogger()), - // new InMemoryEventPublisher( - // // TODO We need to work on our logging strategy - // new ConsoleCoscradCliLogger() - // ), }, ], exports: [CoscradEventFactory, EVENT_PUBLISHER_TOKEN], @@ -45,13 +41,15 @@ export class EventModule { ) {} onApplicationBootstrap() { - const handlersAndTypes = this.discoveryService + const allInstancesAndMeta = this.discoveryService .getProviders() .filter((provider) => provider.instance) .map((provider) => [ provider.instance, getCoscradEventConsumerMeta(Object.getPrototypeOf(provider.instance).constructor), - ]) + ]); + + const handlersAndTypes = allInstancesAndMeta .filter( ( instanceAndMeta diff --git a/apps/api/src/domain/common/events/in-memory-event-publisher.ts b/apps/api/src/domain/common/events/in-memory-event-publisher.ts index 94bdeea72..3dcdf3bd5 100644 --- a/apps/api/src/domain/common/events/in-memory-event-publisher.ts +++ b/apps/api/src/domain/common/events/in-memory-event-publisher.ts @@ -17,7 +17,6 @@ export class ObservableInMemoryEventPublisher super(); } - // todo make this async publish(eventOrEvents: ICoscradEvent | ICoscradEvent[]): void { const eventsToPublish = Array.isArray(eventOrEvents) ? eventOrEvents : [eventOrEvents]; @@ -56,7 +55,11 @@ export class ObservableInMemoryEventPublisher } private ofEventType(type: string) { - return this.subject$.pipe(filter((event) => event.isOfType(type))); + return this.subject$.pipe( + filter((event) => { + return event.isOfType(type); + }) + ); } onModuleDestroy() { diff --git a/apps/api/src/domain/common/events/interfaces/coscrad-event-publisher.interface.ts b/apps/api/src/domain/common/events/interfaces/coscrad-event-publisher.interface.ts index 3c4782987..83fac80ff 100644 --- a/apps/api/src/domain/common/events/interfaces/coscrad-event-publisher.interface.ts +++ b/apps/api/src/domain/common/events/interfaces/coscrad-event-publisher.interface.ts @@ -1,6 +1,8 @@ import { ICoscradEventHandler } from '../coscrad-event-handler.interface'; import { ICoscradEvent } from '../coscrad-event.interface'; +export const EVENT_PUBLISHER_TOKEN = 'EVENT_PUBLISHER_TOKEN'; + export interface ICoscradEventPublisher { publish(eventsToPublish: ICoscradEvent | ICoscradEvent[]): void; diff --git a/apps/api/src/domain/common/events/sync-in-memory-event-publisher.ts b/apps/api/src/domain/common/events/sync-in-memory-event-publisher.ts index 1661f0d9c..e4fff4341 100644 --- a/apps/api/src/domain/common/events/sync-in-memory-event-publisher.ts +++ b/apps/api/src/domain/common/events/sync-in-memory-event-publisher.ts @@ -48,6 +48,19 @@ export class SyncInMemoryEventPublisher implements ICoscradEventPublisher { this.eventTypeToConsumers.set(eventType, []); } - this.eventTypeToConsumers.get(eventType).push(eventConsumer); + /** + * We want registration to be idempotent in case this module is somehow + * initialized multiple times. + */ + if (!this.has(eventType, eventConsumer)) + this.eventTypeToConsumers.get(eventType).push(eventConsumer); + } + + private has(eventType: string, eventConsumer: ICoscradEventHandler) { + return ( + this.eventTypeToConsumers.has(eventType) && + // Compare by reference + this.eventTypeToConsumers.get(eventType).some((handler) => handler === eventConsumer) + ); } } diff --git a/apps/api/src/domain/models/audio-visual/shared/commands/transcripts/add-line-item-to-transcript/add-line-item-to-transcript.command-handler.ts b/apps/api/src/domain/models/audio-visual/shared/commands/transcripts/add-line-item-to-transcript/add-line-item-to-transcript.command-handler.ts index bc228d63f..e86c2aea0 100644 --- a/apps/api/src/domain/models/audio-visual/shared/commands/transcripts/add-line-item-to-transcript/add-line-item-to-transcript.command-handler.ts +++ b/apps/api/src/domain/models/audio-visual/shared/commands/transcripts/add-line-item-to-transcript/add-line-item-to-transcript.command-handler.ts @@ -1,5 +1,9 @@ import { CommandHandler } from '@coscrad/commands'; import { Inject } from '@nestjs/common'; +import { + EVENT_PUBLISHER_TOKEN, + ICoscradEventPublisher, +} from '../../../../../../../domain/common/events/interfaces'; import { InternalError } from '../../../../../../../lib/errors/InternalError'; import { isNotFound } from '../../../../../../../lib/types/not-found'; import { REPOSITORY_PROVIDER_TOKEN } from '../../../../../../../persistence/constants/persistenceConstants'; @@ -31,9 +35,10 @@ export class AddLineItemtoTranscriptCommandHandler extends BaseCommandHandler { diff --git a/apps/api/src/domain/models/audio-visual/shared/commands/transcripts/add-participant-to-transcript/add-participant-to-transcript.command-handler.ts b/apps/api/src/domain/models/audio-visual/shared/commands/transcripts/add-participant-to-transcript/add-participant-to-transcript.command-handler.ts index 108adcb3e..902dc5d89 100644 --- a/apps/api/src/domain/models/audio-visual/shared/commands/transcripts/add-participant-to-transcript/add-participant-to-transcript.command-handler.ts +++ b/apps/api/src/domain/models/audio-visual/shared/commands/transcripts/add-participant-to-transcript/add-participant-to-transcript.command-handler.ts @@ -1,5 +1,7 @@ import { CommandHandler } from '@coscrad/commands'; import { Inject } from '@nestjs/common'; +import { EVENT_PUBLISHER_TOKEN } from '../../../../../../../domain/common'; +import { ICoscradEventPublisher } from '../../../../../../../domain/common/events/interfaces'; import { InternalError } from '../../../../../../../lib/errors/InternalError'; import { isNotFound } from '../../../../../../../lib/types/not-found'; import { REPOSITORY_PROVIDER_TOKEN } from '../../../../../../../persistence/constants/persistenceConstants'; @@ -34,9 +36,10 @@ export class AddParticipantToTranscriptCommandHandler extends BaseCommandHandler constructor( @Inject(REPOSITORY_PROVIDER_TOKEN) protected readonly repositoryProvider: IRepositoryProvider, - @Inject(ID_MANAGER_TOKEN) protected readonly idManager: IIdManager + @Inject(ID_MANAGER_TOKEN) protected readonly idManager: IIdManager, + @Inject(EVENT_PUBLISHER_TOKEN) protected readonly eventPublisher: ICoscradEventPublisher ) { - super(repositoryProvider, idManager); + super(repositoryProvider, idManager, eventPublisher); } protected async createOrFetchWriteContext({ diff --git a/apps/api/src/domain/models/audio-visual/shared/commands/transcripts/create-transcript/create-transcript.command-handler.ts b/apps/api/src/domain/models/audio-visual/shared/commands/transcripts/create-transcript/create-transcript.command-handler.ts index ad31028fc..aff813d29 100644 --- a/apps/api/src/domain/models/audio-visual/shared/commands/transcripts/create-transcript/create-transcript.command-handler.ts +++ b/apps/api/src/domain/models/audio-visual/shared/commands/transcripts/create-transcript/create-transcript.command-handler.ts @@ -1,5 +1,7 @@ import { CommandHandler } from '@coscrad/commands'; import { Inject } from '@nestjs/common'; +import { EVENT_PUBLISHER_TOKEN } from '../../../../../../../domain/common'; +import { ICoscradEventPublisher } from '../../../../../../../domain/common/events/interfaces'; import { InternalError } from '../../../../../../../lib/errors/InternalError'; import { isNotFound } from '../../../../../../../lib/types/not-found'; import { REPOSITORY_PROVIDER_TOKEN } from '../../../../../../../persistence/constants/persistenceConstants'; @@ -28,9 +30,10 @@ export class CreateTranscriptCommandHandler extends BaseCommandHandler( ResourceType.playlist diff --git a/apps/api/src/domain/models/playlist/commands/translate-playlist-name/translate-playlist-name.command-handler.ts b/apps/api/src/domain/models/playlist/commands/translate-playlist-name/translate-playlist-name.command-handler.ts index 0aa5f6b41..4c7556cbb 100644 --- a/apps/api/src/domain/models/playlist/commands/translate-playlist-name/translate-playlist-name.command-handler.ts +++ b/apps/api/src/domain/models/playlist/commands/translate-playlist-name/translate-playlist-name.command-handler.ts @@ -1,9 +1,11 @@ import { CommandHandler } from '@coscrad/commands'; import { Inject } from '@nestjs/common'; +import { EVENT_PUBLISHER_TOKEN } from '../../../../../domain/common'; import { MultilingualTextItem, MultilingualTextItemRole, } from '../../../../../domain/common/entities/multilingual-text'; +import { ICoscradEventPublisher } from '../../../../../domain/common/events/interfaces'; import { Valid } from '../../../../../domain/domainModelValidators/Valid'; import { ID_MANAGER_TOKEN, @@ -27,9 +29,10 @@ export class TranslatePlaylistNameCommandHandler extends BaseUpdateCommandHandle constructor( @Inject(REPOSITORY_PROVIDER_TOKEN) protected readonly repositoryProvider: IRepositoryProvider, - @Inject(ID_MANAGER_TOKEN) protected readonly idManager: IIdManager + @Inject(ID_MANAGER_TOKEN) protected readonly idManager: IIdManager, + @Inject(EVENT_PUBLISHER_TOKEN) protected readonly eventPublisher: ICoscradEventPublisher ) { - super(repositoryProvider, idManager); + super(repositoryProvider, idManager, eventPublisher); } // note that we don't prevent duplication in translation of a name, only the orignal diff --git a/apps/api/src/domain/models/shared/command-handlers/base-command-handler.ts b/apps/api/src/domain/models/shared/command-handlers/base-command-handler.ts index 43dd1ee22..5fd50cf6a 100644 --- a/apps/api/src/domain/models/shared/command-handlers/base-command-handler.ts +++ b/apps/api/src/domain/models/shared/command-handlers/base-command-handler.ts @@ -10,6 +10,7 @@ import { InternalError, isInternalError } from '../../../../lib/errors/InternalE import { ValidationResult } from '../../../../lib/errors/types/ValidationResult'; import { REPOSITORY_PROVIDER_TOKEN } from '../../../../persistence/constants/persistenceConstants'; import { ResultOrError } from '../../../../types/ResultOrError'; +import { EVENT_PUBLISHER_TOKEN, ICoscradEventPublisher } from '../../../common/events/interfaces'; import { Valid } from '../../../domainModelValidators/Valid'; import { IIdManager } from '../../../interfaces/id-manager.interface'; import { IRepositoryForAggregate } from '../../../repositories/interfaces/repository-for-aggregate.interface'; @@ -31,7 +32,8 @@ export abstract class BaseCommandHandler implement constructor( @Inject(REPOSITORY_PROVIDER_TOKEN) protected readonly repositoryProvider: IRepositoryProvider, - @Inject('ID_MANAGER') protected readonly idManager: IIdManager + @Inject('ID_MANAGER') protected readonly idManager: IIdManager, + @Inject(EVENT_PUBLISHER_TOKEN) protected readonly eventPublisher: ICoscradEventPublisher ) {} protected getAggregateIdFromCommand({ diff --git a/apps/api/src/domain/models/shared/command-handlers/base-create-command-handler.ts b/apps/api/src/domain/models/shared/command-handlers/base-create-command-handler.ts index 6843e3ff9..ea680e615 100644 --- a/apps/api/src/domain/models/shared/command-handlers/base-create-command-handler.ts +++ b/apps/api/src/domain/models/shared/command-handlers/base-create-command-handler.ts @@ -69,6 +69,16 @@ export abstract class BaseCreateCommandHandler< await this.getRepositoryForCommand(command).create( instanceToPersistWithUpdatedEventHistory ); + + console.log(`Publishing: ${event}`); + + /** + * TODO + * 1. Share this logic with the base-update-command handler + * 2. Move event publication out of process by pulling events from the + * command database and publishing via a proper messaging queue. + */ + this.eventPublisher.publish(event); } /** @@ -88,7 +98,7 @@ export abstract class BaseCreateCommandHandler< if (isNotFound(idStatus)) return new UuidNotGeneratedInternallyError(newId); if (isNotAvailable(idStatus)) - // Consider throwing as this is a system error (i.e. exception) adn not necessarily a user error + // Consider throwing as this is a system error (i.e. exception) and not necessarily a user error return new UuidNotAvailableForUseError(newId); if (!isOK(idStatus)) { diff --git a/apps/api/src/domain/models/shared/command-handlers/base-update-command-handler.ts b/apps/api/src/domain/models/shared/command-handlers/base-update-command-handler.ts index 72eae9739..e5c92d674 100644 --- a/apps/api/src/domain/models/shared/command-handlers/base-update-command-handler.ts +++ b/apps/api/src/domain/models/shared/command-handlers/base-update-command-handler.ts @@ -58,5 +58,13 @@ export abstract class BaseUpdateCommandHandler< await this.getRepositoryForCommand(command).update( instanceToPersistWithUpdatedEventHistory ); + + /** + * TODO + * 1. Share this logic with the base-create-command handler + * 2. Move event publication out of process by pulling events from the + * command database and publishing via a proper messaging queue. + */ + this.eventPublisher.publish(event); } } diff --git a/apps/api/src/domain/models/shared/common-commands/publish-resource/resource-published.event-handler.ts b/apps/api/src/domain/models/shared/common-commands/publish-resource/resource-published.event-handler.ts index db74d8624..28b39f6fd 100644 --- a/apps/api/src/domain/models/shared/common-commands/publish-resource/resource-published.event-handler.ts +++ b/apps/api/src/domain/models/shared/common-commands/publish-resource/resource-published.event-handler.ts @@ -43,5 +43,7 @@ export class ResourcePublishedEventHandler implements ICoscradEventHandler { } await queryRepository.publish(id); + + console.log('done'); } } diff --git a/apps/api/src/domain/models/song/commands/add-lyrics-for-song/add-lyrics-for-song.command-handler.ts b/apps/api/src/domain/models/song/commands/add-lyrics-for-song/add-lyrics-for-song.command-handler.ts index 8d86b17b6..c3812eac1 100644 --- a/apps/api/src/domain/models/song/commands/add-lyrics-for-song/add-lyrics-for-song.command-handler.ts +++ b/apps/api/src/domain/models/song/commands/add-lyrics-for-song/add-lyrics-for-song.command-handler.ts @@ -1,6 +1,8 @@ import { AggregateType, ResourceType } from '@coscrad/api-interfaces'; import { CommandHandler, ICommand } from '@coscrad/commands'; import { Inject } from '@nestjs/common'; +import { EVENT_PUBLISHER_TOKEN } from '../../../../../domain/common'; +import { ICoscradEventPublisher } from '../../../../../domain/common/events/interfaces'; import { Valid } from '../../../../../domain/domainModelValidators/Valid'; import { ID_MANAGER_TOKEN, @@ -29,9 +31,10 @@ export class AddLyricsForSongCommandHandler extends BaseUpdateCommandHandler { constructor( @Inject(REPOSITORY_PROVIDER_TOKEN) protected readonly repositoryProvider: IRepositoryProvider, - @Inject('ID_MANAGER') protected readonly idManager: IIdManager + @Inject('ID_MANAGER') protected readonly idManager: IIdManager, + @Inject(EVENT_PUBLISHER_TOKEN) protected readonly eventPublisher: ICoscradEventPublisher ) { - super(repositoryProvider, idManager); + super(repositoryProvider, idManager, eventPublisher); this.repositoryForCommandsTargetAggregate = this.repositoryProvider.forResource( ResourceType.song diff --git a/apps/api/src/domain/models/song/commands/translate-song-lyrics/translate-song-lyrics.command-handler.ts b/apps/api/src/domain/models/song/commands/translate-song-lyrics/translate-song-lyrics.command-handler.ts index 729601478..ccaff8a0f 100644 --- a/apps/api/src/domain/models/song/commands/translate-song-lyrics/translate-song-lyrics.command-handler.ts +++ b/apps/api/src/domain/models/song/commands/translate-song-lyrics/translate-song-lyrics.command-handler.ts @@ -1,5 +1,7 @@ import { CommandHandler, ICommand } from '@coscrad/commands'; import { Inject } from '@nestjs/common'; +import { EVENT_PUBLISHER_TOKEN } from '../../../../../domain/common'; +import { ICoscradEventPublisher } from '../../../../../domain/common/events/interfaces'; import { InternalError } from '../../../../../lib/errors/InternalError'; import { REPOSITORY_PROVIDER_TOKEN } from '../../../../../persistence/constants/persistenceConstants'; import { ResultOrError } from '../../../../../types/ResultOrError'; @@ -26,9 +28,10 @@ export class TranslateSongLyricsCommandHandler extends BaseUpdateCommandHandler< constructor( @Inject(REPOSITORY_PROVIDER_TOKEN) protected readonly repositoryProvider: IRepositoryProvider, - @Inject(ID_MANAGER_TOKEN) protected readonly idManager: IIdManager + @Inject(ID_MANAGER_TOKEN) protected readonly idManager: IIdManager, + @Inject(EVENT_PUBLISHER_TOKEN) protected readonly eventPublisher: ICoscradEventPublisher ) { - super(repositoryProvider, idManager); + super(repositoryProvider, idManager, eventPublisher); this.repositoryForCommandsTargetAggregate = repositoryProvider.forResource( ResourceType.song diff --git a/apps/api/src/domain/models/song/commands/translate-song-title/translate-song-title.command-handler.ts b/apps/api/src/domain/models/song/commands/translate-song-title/translate-song-title.command-handler.ts index fc1025a27..abbeb073c 100644 --- a/apps/api/src/domain/models/song/commands/translate-song-title/translate-song-title.command-handler.ts +++ b/apps/api/src/domain/models/song/commands/translate-song-title/translate-song-title.command-handler.ts @@ -1,6 +1,8 @@ import { AggregateType, ResourceType } from '@coscrad/api-interfaces'; import { CommandHandler, ICommand } from '@coscrad/commands'; import { Inject } from '@nestjs/common'; +import { EVENT_PUBLISHER_TOKEN } from '../../../../../domain/common'; +import { ICoscradEventPublisher } from '../../../../../domain/common/events/interfaces'; import { Valid } from '../../../../../domain/domainModelValidators/Valid'; import { ID_MANAGER_TOKEN, @@ -30,9 +32,10 @@ export class TranslateSongTitleCommandHandler extends BaseUpdateCommandHandler { constructor( @Inject(REPOSITORY_PROVIDER_TOKEN) protected readonly repositoryProvider: IRepositoryProvider, - @Inject('ID_MANAGER') protected readonly idManager: IIdManager + @Inject('ID_MANAGER') protected readonly idManager: IIdManager, + @Inject(EVENT_PUBLISHER_TOKEN) protected readonly eventPublisher: ICoscradEventPublisher ) { - super(repositoryProvider, idManager); - + super(repositoryProvider, idManager, eventPublisher); this.repositoryForCommandsTargetAggregate = repositoryProvider.getTagRepository(); } diff --git a/apps/api/src/domain/models/tag/commands/tag-resource-or-note/tag-resource-or-note.command-handler.ts b/apps/api/src/domain/models/tag/commands/tag-resource-or-note/tag-resource-or-note.command-handler.ts index 1c091e978..3aba0073b 100644 --- a/apps/api/src/domain/models/tag/commands/tag-resource-or-note/tag-resource-or-note.command-handler.ts +++ b/apps/api/src/domain/models/tag/commands/tag-resource-or-note/tag-resource-or-note.command-handler.ts @@ -1,5 +1,7 @@ import { CommandHandler } from '@coscrad/commands'; import { Inject } from '@nestjs/common'; +import { EVENT_PUBLISHER_TOKEN } from '../../../../../domain/common'; +import { ICoscradEventPublisher } from '../../../../../domain/common/events/interfaces'; import { InternalError } from '../../../../../lib/errors/InternalError'; import { isNotFound } from '../../../../../lib/types/not-found'; import { REPOSITORY_PROVIDER_TOKEN } from '../../../../../persistence/constants/persistenceConstants'; @@ -28,9 +30,10 @@ export class TagResourceOrNoteCommandHandler extends BaseUpdateCommandHandler { - // TODO use dynamic registration - // Can we remove this now? - if (!event.isOfType('TERM_CREATED')) return; - const { meta: { contributorIds = [] } = { contributorIds: [] } } = event; const term = TermViewModel.fromTermCreated(event); diff --git a/apps/api/src/domain/models/term/repositories/arango-term-query-repository.ts b/apps/api/src/domain/models/term/repositories/arango-term-query-repository.ts index 89db673af..f1c08ff27 100644 --- a/apps/api/src/domain/models/term/repositories/arango-term-query-repository.ts +++ b/apps/api/src/domain/models/term/repositories/arango-term-query-repository.ts @@ -47,7 +47,9 @@ export class ArangoTermQueryRepository implements ITermQueryRepository { } async createMany(views: TermViewModel[]): Promise { - return this.database.createMany(views.map(mapEntityDTOToDatabaseDocument)); + const documents = views.map(mapEntityDTOToDatabaseDocument); + + return this.database.createMany(documents); } async publish(id: AggregateId): Promise { @@ -75,7 +77,9 @@ export class ArangoTermQueryRepository implements ITermQueryRepository { throw new InternalError(`Failed to translate term via TermRepository: ${reason}`); }); - await cursor.all(); + const result = await cursor.all(); + + result; } /** diff --git a/apps/api/src/domain/models/user-management/group/commands/add-user-to-group/add-user-to-group.command-handler.ts b/apps/api/src/domain/models/user-management/group/commands/add-user-to-group/add-user-to-group.command-handler.ts index 0dd1dbea6..6eb57e377 100644 --- a/apps/api/src/domain/models/user-management/group/commands/add-user-to-group/add-user-to-group.command-handler.ts +++ b/apps/api/src/domain/models/user-management/group/commands/add-user-to-group/add-user-to-group.command-handler.ts @@ -1,5 +1,7 @@ import { CommandHandler } from '@coscrad/commands'; import { Inject } from '@nestjs/common'; +import { EVENT_PUBLISHER_TOKEN } from '../../../../../../domain/common'; +import { ICoscradEventPublisher } from '../../../../../../domain/common/events/interfaces'; import { InternalError } from '../../../../../../lib/errors/InternalError'; import { REPOSITORY_PROVIDER_TOKEN } from '../../../../../../persistence/constants/persistenceConstants'; import { ResultOrError } from '../../../../../../types/ResultOrError'; @@ -21,9 +23,10 @@ export class AddUserToGroupCommandHandler extends BaseUpdateCommandHandler { // Commands (mutate state) async create(databaseDocument: ArangoDatabaseDocument) { - // Handle the difference in _id \ _key between model and database return this.#arangoDatabase.create(databaseDocument, this.#collectionID).catch((error) => { throw new InternalError( `ArangoDatabase for collection: ${ diff --git a/apps/api/src/persistence/persistence.module.ts b/apps/api/src/persistence/persistence.module.ts index 1d15604cf..f5eba9b46 100644 --- a/apps/api/src/persistence/persistence.module.ts +++ b/apps/api/src/persistence/persistence.module.ts @@ -213,6 +213,7 @@ export class PersistenceModule implements OnApplicationShutdown { audioQueryRepositoryProvider, termQueryRepositoryProvider, queryRepositoryProvider, + EventModule, ], global: true, }; diff --git a/apps/api/src/queries/buildViewModelForResource/viewModels/term.view-model.ts b/apps/api/src/queries/buildViewModelForResource/viewModels/term.view-model.ts index bce7a96ca..e26bc59d8 100644 --- a/apps/api/src/queries/buildViewModelForResource/viewModels/term.view-model.ts +++ b/apps/api/src/queries/buildViewModelForResource/viewModels/term.view-model.ts @@ -61,6 +61,10 @@ export class TermViewModel implements ITermViewModel, HasAggregateId { term.actions = []; // TODO build all actions here + /** + * Note that this must be written in the DB by the event-handler, as + * we do not have access to the contributors in this scope. + */ term.contributions = []; /** @@ -130,7 +134,7 @@ export class TermViewModel implements ITermViewModel, HasAggregateId { const { contributions, name, id, actions, accessControlList, mediaItemId, isPublished } = dto; - term.contributions = contributions; + term.contributions = Array.isArray(contributions) ? contributions : []; term.name = name; diff --git a/apps/api/src/queries/term/__snapshots__/term-queries-fetch-by-id.e2e.spec.ts.snap b/apps/api/src/queries/term/__snapshots__/term-queries-fetch-by-id.e2e.spec.ts.snap index a3ec9b090..5004f9836 100644 --- a/apps/api/src/queries/term/__snapshots__/term-queries-fetch-by-id.e2e.spec.ts.snap +++ b/apps/api/src/queries/term/__snapshots__/term-queries-fetch-by-id.e2e.spec.ts.snap @@ -2,6 +2,20 @@ exports[`when querying for a term: fetch by Id when the user is authenticated when the user is a project admin when there is a term with the given Id when the term is published should return the expected result 1`] = ` [ + { + "description": "Make a resource visible to the public", + "form": { + "fields": [], + "prepopulatedFields": { + "aggregateCompositeIdentifier": { + "id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b100001", + "type": "term", + }, + }, + }, + "label": "Publish Resource", + "type": "PUBLISH_RESOURCE", + }, { "description": "creates a note about this particular resource", "form": { diff --git a/apps/api/src/queries/term/__snapshots__/term-queries-fetch-many.e2e.spec.ts.snap b/apps/api/src/queries/term/__snapshots__/term-queries-fetch-many.e2e.spec.ts.snap index b8c1f4570..bf74cd28d 100644 --- a/apps/api/src/queries/term/__snapshots__/term-queries-fetch-many.e2e.spec.ts.snap +++ b/apps/api/src/queries/term/__snapshots__/term-queries-fetch-many.e2e.spec.ts.snap @@ -15,7 +15,7 @@ exports[`when querying for a term: fetch many when the user is a project admin s "fields": [], "prepopulatedFields": { "aggregateCompositeIdentifier": { - "id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b100102", + "id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b100101", "type": "term", }, }, @@ -100,7 +100,7 @@ exports[`when querying for a term: fetch many when the user is a project admin s ], "prepopulatedFields": { "aggregateCompositeIdentifier": { - "id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b100102", + "id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b100101", "type": "term", }, }, @@ -193,7 +193,7 @@ exports[`when querying for a term: fetch many when the user is a project admin s ], "prepopulatedFields": { "aggregateCompositeIdentifier": { - "id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b100102", + "id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b100101", "type": "term", }, }, @@ -270,7 +270,7 @@ exports[`when querying for a term: fetch many when the user is a project admin s ], "prepopulatedFields": { "aggregateCompositeIdentifier": { - "id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b100102", + "id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b100101", "type": "term", }, }, @@ -350,7 +350,7 @@ exports[`when querying for a term: fetch many when the user is a project admin s ], "prepopulatedFields": { "aggregateCompositeIdentifier": { - "id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b100102", + "id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b100101", "type": "term", }, }, @@ -359,9 +359,14 @@ exports[`when querying for a term: fetch many when the user is a project admin s "type": "ADD_AUDIO_FOR_TERM", }, ], - "contributions": [], - "id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b100102", - "isPublished": false, + "contributions": [ + { + "fullName": "Dumb McContributor", + "id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b110901", + }, + ], + "id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b100101", + "isPublished": true, "name": { "items": [ { @@ -735,7 +740,12 @@ exports[`when querying for a term: fetch many when the user is a project admin s "type": "ADD_AUDIO_FOR_TERM", }, ], - "contributions": [], + "contributions": [ + { + "fullName": "Dumb McContributor", + "id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b110901", + }, + ], "id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b100103", "isPublished": false, "name": { @@ -759,6 +769,20 @@ exports[`when querying for a term: fetch many when the user is a project admin s "allowedUserIds": [], }, "actions": [ + { + "description": "Make a resource visible to the public", + "form": { + "fields": [], + "prepopulatedFields": { + "aggregateCompositeIdentifier": { + "id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b100102", + "type": "term", + }, + }, + }, + "label": "Publish Resource", + "type": "PUBLISH_RESOURCE", + }, { "description": "creates a note about this particular resource", "form": { @@ -836,7 +860,7 @@ exports[`when querying for a term: fetch many when the user is a project admin s ], "prepopulatedFields": { "aggregateCompositeIdentifier": { - "id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b100101", + "id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b100102", "type": "term", }, }, @@ -929,7 +953,7 @@ exports[`when querying for a term: fetch many when the user is a project admin s ], "prepopulatedFields": { "aggregateCompositeIdentifier": { - "id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b100101", + "id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b100102", "type": "term", }, }, @@ -1006,7 +1030,7 @@ exports[`when querying for a term: fetch many when the user is a project admin s ], "prepopulatedFields": { "aggregateCompositeIdentifier": { - "id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b100101", + "id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b100102", "type": "term", }, }, @@ -1086,7 +1110,7 @@ exports[`when querying for a term: fetch many when the user is a project admin s ], "prepopulatedFields": { "aggregateCompositeIdentifier": { - "id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b100101", + "id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b100102", "type": "term", }, }, @@ -1095,9 +1119,14 @@ exports[`when querying for a term: fetch many when the user is a project admin s "type": "ADD_AUDIO_FOR_TERM", }, ], - "contributions": [], - "id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b100101", - "isPublished": true, + "contributions": [ + { + "fullName": "Dumb McContributor", + "id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b110901", + }, + ], + "id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b100102", + "isPublished": false, "name": { "items": [ { diff --git a/apps/api/src/queries/term/term-queries-fetch-by-id.e2e.spec.ts b/apps/api/src/queries/term/term-queries-fetch-by-id.e2e.spec.ts index 078f9eda6..dccf2820f 100644 --- a/apps/api/src/queries/term/term-queries-fetch-by-id.e2e.spec.ts +++ b/apps/api/src/queries/term/term-queries-fetch-by-id.e2e.spec.ts @@ -4,23 +4,15 @@ import { IDetailQueryResult, ITermViewModel, LanguageCode, - ResourceType, } from '@coscrad/api-interfaces'; import { INestApplication } from '@nestjs/common'; import * as request from 'supertest'; import httpStatusCodes from '../../app/constants/httpStatusCodes'; import setUpIntegrationTest from '../../app/controllers/__tests__/setUpIntegrationTest'; import getValidAggregateInstanceForTest from '../../domain/__tests__/utilities/getValidAggregateInstanceForTest'; -import { ICoscradEvent, ICoscradEventHandler } from '../../domain/common'; import buildDummyUuid from '../../domain/models/__tests__/utilities/buildDummyUuid'; -import { ResourceReadAccessGrantedToUser } from '../../domain/models/shared/common-commands'; -import { ResourceReadAccessGrantedToUserEventHandler } from '../../domain/models/shared/common-commands/grant-resource-read-access-to-user/resource-read-access-granted-to-user.event-handler'; -import { ResourcePublished } from '../../domain/models/shared/common-commands/publish-resource/resource-published.event'; -import { ResourcePublishedEventHandler } from '../../domain/models/shared/common-commands/publish-resource/resource-published.event-handler'; -import { TermCreated, TermTranslated } from '../../domain/models/term/commands'; -import { AudioAddedForTermEventHandler } from '../../domain/models/term/commands/add-audio-for-term/audio-added-for-term.event-handler'; -import { TermCreatedEventHandler } from '../../domain/models/term/commands/create-term/term-created.event-handler'; -import { TermTranslatedEventHandler } from '../../domain/models/term/commands/translate-term/term-translated.event-handler'; +import { AccessControlList } from '../../domain/models/shared/access-control/access-control-list.entity'; +import { TermCreated } from '../../domain/models/term/commands'; import { ITermQueryRepository, TERM_QUERY_REPOSITORY_TOKEN, @@ -31,14 +23,14 @@ import { CoscradUserWithGroups } from '../../domain/models/user-management/user/ import { CoscradUser } from '../../domain/models/user-management/user/entities/user/coscrad-user.entity'; import { FullName } from '../../domain/models/user-management/user/entities/user/full-name.entity'; import { AggregateId } from '../../domain/types/AggregateId'; -import { InternalError } from '../../lib/errors/InternalError'; +import { clonePlainObjectWithOverrides } from '../../lib/utilities/clonePlainObjectWithOverrides'; import { ArangoDatabaseProvider } from '../../persistence/database/database.provider'; import TestRepositoryProvider from '../../persistence/repositories/__tests__/TestRepositoryProvider'; import generateDatabaseNameForTestSuite from '../../persistence/repositories/__tests__/generateDatabaseNameForTestSuite'; import buildTestDataInFlatFormat from '../../test-data/buildTestDataInFlatFormat'; import { TestEventStream } from '../../test-data/events'; -import { DynamicDataTypeFinderService } from '../../validation'; import { BaseResourceViewModel } from '../buildViewModelForResource/viewModels/base-resource.view-model'; +import { TermViewModel } from '../buildViewModelForResource/viewModels/term.view-model'; // Set up endpoints: index endpoint, id endpoint const indexEndpoint = `/resources/terms`; @@ -65,12 +57,13 @@ const termText = `Term (in the language)`; const originalLanguage = LanguageCode.Haida; -const termTranslation = `Term (in English)`; - -const translationLanguage = LanguageCode.English; - const termId = buildDummyUuid(1); +const termCompositeId = { + type: AggregateType.term, + id: termId, +}; + const dummyContributorFirstName = 'Dumb'; const dummyContributorLastName = 'McContributor'; @@ -93,23 +86,24 @@ const termCreated = new TestEventStream().andThen({ }, }); -const termTranslated = termCreated.andThen({ - type: 'TERM_TRANSLATED', - payload: { - translation: termTranslation, - languageCode: translationLanguage, - }, -}); +const dummyTerm = TermViewModel.fromTermCreated(termCreated.as(termCompositeId)[0] as TermCreated); -const privateTermThatUserCanAccess = termTranslated.andThen({ - type: 'RESOURCE_READ_ACCESS_GRANTED_TO_USER', - payload: { - userId: dummyQueryUserId, - }, +const targetTermView = clonePlainObjectWithOverrides(dummyTerm, { + isPublished: true, + contributions: [ + { + id: dummyContributor.id, + fullName: dummyContributor.fullName.toString(), + }, + ], }); -const termPublished = termTranslated.andThen({ - type: 'RESOURCE_PUBLISHED', +const privateTermThatUserCanAccess = clonePlainObjectWithOverrides(dummyTerm, { + accessControlList: new AccessControlList({ + allowedUserIds: [dummyQueryUserId], + allowedGroupIds: [], + }), + isPublished: false, }); // TODO Add happy path cases for a prompt term @@ -128,32 +122,6 @@ const assertResourceHasContributionFor = ( expect(hasContribution).toBe(true); }; -/** - * TODO We need to find a more maintainable way of - * seeding the required initial state. - */ -const buildEventHandlers = (termQueryRepository: ITermQueryRepository) => { - return [ - new TermCreatedEventHandler(termQueryRepository), - new TermTranslatedEventHandler(termQueryRepository), - new AudioAddedForTermEventHandler(termQueryRepository), - // TODO update this to take in a generic query repository provider - new ResourceReadAccessGrantedToUserEventHandler(termQueryRepository), - new ResourcePublishedEventHandler({ - // TODO break this out into an ArangoQueryRepositoryProvider - forResource(resourceType: ResourceType) { - if (resourceType === ResourceType.term) { - return termQueryRepository; - } - - throw new InternalError( - `Resource Type: ${resourceType} is not supported by the query repository provider` - ); - }, - }), - ]; -}; - describe(`when querying for a term: fetch by Id`, () => { const testDatabaseName = generateDatabaseNameForTestSuite(); @@ -165,10 +133,9 @@ describe(`when querying for a term: fetch by Id`, () => { let termQueryRepository: ITermQueryRepository; - let handlers: ICoscradEventHandler[]; - - let seedTerms: (eventHistory: ICoscradEvent[]) => Promise; + let seedTerms: (terms: TermViewModel[]) => Promise; + // let eventPublisher: ICoscradEventPublisher; beforeEach(async () => { await testRepositoryProvider.testSetup(); @@ -190,27 +157,22 @@ describe(`when querying for a term: fetch by Id`, () => { // no authenticated user )); - termQueryRepository = app.get(TERM_QUERY_REPOSITORY_TOKEN); + // eventPublisher = app.get(EVENT_PUBLISHER_TOKEN); - handlers = buildEventHandlers(termQueryRepository); + termQueryRepository = app.get(TERM_QUERY_REPOSITORY_TOKEN); - seedTerms = async (events: ICoscradEvent[]) => { - for (const e of events) { - for (const h of handlers) { - await h.handle(e); - } - } + /** + * We need to use the proper publisher to make sure events only + * run if they match the pattern for the given handler. + */ + seedTerms = async (terms: TermViewModel[]) => { + await termQueryRepository.createMany(terms); }; }); describe(`when there is a term with the given Id`, () => { describe(`when a term is published`, () => { beforeEach(async () => { - const eventHistoryForTerm = termPublished.as({ - type: AggregateType.term, - id: termId, - }); - /** * It is important that we do this before allowing the event * handlers to run, as they will only add contributions @@ -220,7 +182,11 @@ describe(`when querying for a term: fetch by Id`, () => { .getContributorRepository() .create(dummyContributor); - await seedTerms(eventHistoryForTerm); + console.log('clearing database'); + + await databaseProvider.getDatabaseForCollection('term__VIEWS').clear(); + + await seedTerms([targetTermView]); }); /** @@ -246,13 +212,13 @@ describe(`when querying for a term: fetch by Id`, () => { describe(`when a term is not published`, () => { beforeEach(async () => { // note that there is no publication event in this event history - const eventHistoryForTerm = termTranslated.as({ - type: AggregateType.term, - id: termId, - }); - // TODO: we need to check that contributors come through - await seedTerms(eventHistoryForTerm); + // TODO: we need to check that contributors come through + await seedTerms([ + clonePlainObjectWithOverrides(targetTermView, { + isPublished: false, + }), + ]); }); // We pretend the resource does not exist when the user @@ -295,35 +261,16 @@ describe(`when querying for a term: fetch by Id`, () => { } )); - await app.get(DynamicDataTypeFinderService).bootstrapDynamicTypes(); - - termQueryRepository = app.get(TERM_QUERY_REPOSITORY_TOKEN); - - /** - * TODO We need to find a more maintainable way of - * seeding the required initial state. - */ - handlers = buildEventHandlers(termQueryRepository); - - seedTerms = async (events: ICoscradEvent[]) => { - for (const e of events) { - for (const h of handlers) { - await h.handle(e); - } - } + seedTerms = async (terms: TermViewModel[]) => { + await termQueryRepository.createMany(terms); }; }); describe(`when there is a term with the given Id`, () => { describe(`when the term is published`, () => { beforeEach(async () => { - const eventHistoryForTerm = termPublished.as({ - type: AggregateType.term, - id: termId, - }); - // TODO: we need to check that contributors come through - await seedTerms(eventHistoryForTerm); + await seedTerms([targetTermView]); }); it(`should return the expected result`, async () => { @@ -341,13 +288,14 @@ describe(`when querying for a term: fetch by Id`, () => { describe(`when the term is not published`, () => { beforeEach(async () => { // note that there is no publication event in this event history - const eventHistoryForTerm = termTranslated.as({ - type: AggregateType.term, - id: termId, - }); + // TODO: we need to check that contributors come through - await seedTerms(eventHistoryForTerm); + await seedTerms([ + clonePlainObjectWithOverrides(targetTermView, { + isPublished: false, + }), + ]); }); it(`should return the expected result`, async () => { @@ -365,13 +313,10 @@ describe(`when querying for a term: fetch by Id`, () => { describe(`when the term is not published but the user has explicit access`, () => { beforeEach(async () => { // note that there is no publication event in this event history - const eventHistoryForPrivateTerm = privateTermThatUserCanAccess.as({ - type: AggregateType.term, - id: termId, - }); + // TODO: we need to check that contributors come through - await seedTerms(eventHistoryForPrivateTerm); + await seedTerms([privateTermThatUserCanAccess]); }); it(`should return the expected result`, async () => { @@ -414,33 +359,17 @@ describe(`when querying for a term: fetch by Id`, () => { } )); - termQueryRepository = app.get(TERM_QUERY_REPOSITORY_TOKEN); - - /** - * TODO We need to find a more maintainable way of - * seeding the required initial state. - */ - handlers = buildEventHandlers(termQueryRepository); - - seedTerms = async (events: ICoscradEvent[]) => { - for (const e of events) { - for (const h of handlers) { - await h.handle(e); - } - } + seedTerms = async (terms: TermViewModel[]) => { + await termQueryRepository.createMany(terms); }; }); describe(`when there is a term with the given Id`, () => { describe(`when the term is published`, () => { beforeEach(async () => { - const eventHistoryForTerm = termPublished.as({ - type: AggregateType.term, - id: termId, - }); // TODO: we need to check that contributors come through - await seedTerms(eventHistoryForTerm); + await seedTerms([targetTermView]); }); it(`should return the expected result`, async () => { @@ -468,14 +397,11 @@ describe(`when querying for a term: fetch by Id`, () => { describe(`when the term is not published`, () => { beforeEach(async () => { - // note that there is no publication event in this event history - const eventHistoryForTerm = termTranslated.as({ - type: AggregateType.term, - id: termId, - }); - // TODO: we need to check that contributors come through - - await seedTerms(eventHistoryForTerm); + await seedTerms([ + clonePlainObjectWithOverrides(targetTermView, { + isPublished: false, + }), + ]); }); it(`should return the expected result`, async () => { @@ -494,14 +420,7 @@ describe(`when querying for a term: fetch by Id`, () => { // This case is a bit unclear: does this project admin have access to // the project this term is a part of? beforeEach(async () => { - // note that there is no publication event in this event history - const eventHistoryForPrivateTerm = privateTermThatUserCanAccess.as({ - type: AggregateType.term, - id: termId, - }); - // TODO: we need to check that contributors come through - - await seedTerms(eventHistoryForPrivateTerm); + await seedTerms([privateTermThatUserCanAccess]); }); it(`should return the expected result`, async () => { @@ -539,33 +458,15 @@ describe(`when querying for a term: fetch by Id`, () => { } )); - termQueryRepository = app.get(TERM_QUERY_REPOSITORY_TOKEN); - - /** - * TODO We need to find a more maintainable way of - * seeding the required initial state. - */ - handlers = buildEventHandlers(termQueryRepository); - - seedTerms = async (events: ICoscradEvent[]) => { - for (const e of events) { - for (const h of handlers) { - await h.handle(e); - } - } + seedTerms = async (terms: TermViewModel[]) => { + await termQueryRepository.createMany(terms); }; }); describe(`when there is a term with the given Id`, () => { describe(`when the term is published`, () => { beforeEach(async () => { - const eventHistoryForTerm = termPublished.as({ - type: AggregateType.term, - id: termId, - }); - // TODO: we need to check that contributors come through - - await seedTerms(eventHistoryForTerm); + await seedTerms([targetTermView]); }); it(`should return the expected result`, async () => { @@ -582,14 +483,13 @@ describe(`when querying for a term: fetch by Id`, () => { describe(`when the term is not published and the user does not have access`, () => { beforeEach(async () => { - // note that there is no publication event in this event history - const eventHistoryForTerm = termTranslated.as({ - type: AggregateType.term, - id: termId, - }); - // TODO: we need to check that contributors come through - - await seedTerms(eventHistoryForTerm); + await seedTerms([ + clonePlainObjectWithOverrides(targetTermView, { + isPublished: false, + // no special access here + accessControlList: new AccessControlList(), + }), + ]); }); // We pretend the resource does not exist when the user @@ -605,14 +505,7 @@ describe(`when querying for a term: fetch by Id`, () => { describe(`when the term is not published but the user has access`, () => { beforeEach(async () => { - // note that there is no publication event in this event history - const eventHistoryForPrivateTerm = privateTermThatUserCanAccess.as({ - type: AggregateType.term, - id: termId, - }); - // TODO: we need to check that contributors come through - - await seedTerms(eventHistoryForPrivateTerm); + await seedTerms([privateTermThatUserCanAccess]); }); it(`should return the expected result`, async () => { diff --git a/apps/api/src/queries/term/term-queries-fetch-many.e2e.spec.ts b/apps/api/src/queries/term/term-queries-fetch-many.e2e.spec.ts index 7790616cd..fe8091faf 100644 --- a/apps/api/src/queries/term/term-queries-fetch-many.e2e.spec.ts +++ b/apps/api/src/queries/term/term-queries-fetch-many.e2e.spec.ts @@ -3,23 +3,17 @@ import { CoscradUserRole, HttpStatusCode, LanguageCode, - ResourceType, + MultilingualTextItemRole, } from '@coscrad/api-interfaces'; import { INestApplication } from '@nestjs/common'; import * as request from 'supertest'; import httpStatusCodes from '../../app/constants/httpStatusCodes'; import setUpIntegrationTest from '../../app/controllers/__tests__/setUpIntegrationTest'; import getValidAggregateInstanceForTest from '../../domain/__tests__/utilities/getValidAggregateInstanceForTest'; -import { ICoscradEvent, ICoscradEventHandler } from '../../domain/common'; +import { MultilingualText } from '../../domain/common/entities/multilingual-text'; import buildDummyUuid from '../../domain/models/__tests__/utilities/buildDummyUuid'; -import { ResourceReadAccessGrantedToUser } from '../../domain/models/shared/common-commands'; -import { ResourceReadAccessGrantedToUserEventHandler } from '../../domain/models/shared/common-commands/grant-resource-read-access-to-user/resource-read-access-granted-to-user.event-handler'; -import { ResourcePublished } from '../../domain/models/shared/common-commands/publish-resource/resource-published.event'; -import { ResourcePublishedEventHandler } from '../../domain/models/shared/common-commands/publish-resource/resource-published.event-handler'; -import { TermCreated, TermTranslated } from '../../domain/models/term/commands'; -import { AudioAddedForTermEventHandler } from '../../domain/models/term/commands/add-audio-for-term/audio-added-for-term.event-handler'; -import { TermCreatedEventHandler } from '../../domain/models/term/commands/create-term/term-created.event-handler'; -import { TermTranslatedEventHandler } from '../../domain/models/term/commands/translate-term/term-translated.event-handler'; +import { AccessControlList } from '../../domain/models/shared/access-control/access-control-list.entity'; +import { TermCreated } from '../../domain/models/term/commands'; import { ITermQueryRepository, TERM_QUERY_REPOSITORY_TOKEN, @@ -28,14 +22,14 @@ import { CoscradUserGroup } from '../../domain/models/user-management/group/enti import { CoscradUserWithGroups } from '../../domain/models/user-management/user/entities/user/coscrad-user-with-groups'; import { CoscradUser } from '../../domain/models/user-management/user/entities/user/coscrad-user.entity'; import { FullName } from '../../domain/models/user-management/user/entities/user/full-name.entity'; -import { InternalError } from '../../lib/errors/InternalError'; +import { clonePlainObjectWithOverrides } from '../../lib/utilities/clonePlainObjectWithOverrides'; import { ArangoCollectionId } from '../../persistence/database/collection-references/ArangoCollectionId'; import { ArangoDatabaseProvider } from '../../persistence/database/database.provider'; import TestRepositoryProvider from '../../persistence/repositories/__tests__/TestRepositoryProvider'; import generateDatabaseNameForTestSuite from '../../persistence/repositories/__tests__/generateDatabaseNameForTestSuite'; import buildTestDataInFlatFormat from '../../test-data/buildTestDataInFlatFormat'; import { TestEventStream } from '../../test-data/events'; -import { TermViewModel } from '../buildViewModelForResource/viewModels'; +import { TermViewModel } from '../buildViewModelForResource/viewModels/term.view-model'; const indexEndpoint = `/resources/terms`; @@ -65,6 +59,11 @@ const translationLanguage = LanguageCode.English; const termId = buildDummyUuid(101); +const termCompositeIdentifier = { + type: AggregateType.term, + id: termId, +}; + const termIdUnpublishedNoUserAccessId = buildDummyUuid(102); const termIdUnpublishedWithUserAccessId = buildDummyUuid(103); @@ -92,51 +91,43 @@ const termCreated = new TestEventStream().andThen({ }, }); -const termTranslated = termCreated.andThen({ - type: 'TERM_TRANSLATED', - payload: { - translation: termTranslation, +const dummyTermView = clonePlainObjectWithOverrides( + TermViewModel.fromTermCreated(termCreated.as(termCompositeIdentifier)[0] as TermCreated), + { + contributions: [ + { + id: dummyContributor.id, + fullName: dummyContributor.fullName.toString(), + }, + ], + } +); + +const publicTermView = clonePlainObjectWithOverrides(dummyTermView, { + isPublished: true, + name: new MultilingualText(dummyTermView.name).translate({ + text: termTranslation, languageCode: translationLanguage, - }, + role: MultilingualTextItemRole.freeTranslation, + // we are insisting by casting that the call to `translate` won't fail above + }) as MultilingualText, }); -const termPrivateThatUserCanAccess = termTranslated.andThen({ - type: 'RESOURCE_READ_ACCESS_GRANTED_TO_USER', - payload: { - userId: dummyQueryUserId, - }, +const privateTermThatUserCanAccess = clonePlainObjectWithOverrides(publicTermView, { + id: termIdUnpublishedWithUserAccessId, + isPublished: false, + accessControlList: new AccessControlList().allowUser(dummyQueryUserId), }); -const termPublished = termTranslated.andThen({ - type: 'RESOURCE_PUBLISHED', +const privateTermUserCannotAccess = clonePlainObjectWithOverrides(publicTermView, { + id: termIdUnpublishedNoUserAccessId, + isPublished: false, + // no special access + accessControlList: new AccessControlList(), }); // const promptTermId = buildDummyUuid(2) -/** - * TODO We need to find a more maintainable way of - * seeding the required initial state. - */ -const buildEventHandlers = (termQueryRepository: ITermQueryRepository) => [ - new TermCreatedEventHandler(termQueryRepository), - new TermTranslatedEventHandler(termQueryRepository), - new AudioAddedForTermEventHandler(termQueryRepository), - // TODO update this to take in a generic query repository provider - new ResourceReadAccessGrantedToUserEventHandler(termQueryRepository), - new ResourcePublishedEventHandler({ - // TODO break this out into an ArangoQueryRepositoryProvider - forResource(resourceType: ResourceType) { - if (resourceType === ResourceType.term) { - return termQueryRepository; - } - - throw new InternalError( - `Resource Type: ${resourceType} is not supported by the query repository provider` - ); - }, - }), -]; - describe(`when querying for a term: fetch many`, () => { const testDatabaseName = generateDatabaseNameForTestSuite(); @@ -144,13 +135,11 @@ describe(`when querying for a term: fetch many`, () => { let testRepositoryProvider: TestRepositoryProvider; - let termQueryRepository: ITermQueryRepository; - let databaseProvider: ArangoDatabaseProvider; - let handlers: ICoscradEventHandler[]; + let termQueryRepository: ITermQueryRepository; - let seedTerms: (eventHistory: ICoscradEvent[]) => Promise; + let seedTerms: (terms: TermViewModel[]) => Promise; afterAll(async () => { await app.close(); @@ -158,26 +147,6 @@ describe(`when querying for a term: fetch many`, () => { databaseProvider.close(); }); - const eventsForPrivateTermUserCannotAccess = termTranslated.as({ - id: termIdUnpublishedNoUserAccessId, - type: AggregateType.term, - }); - - const eventsForPrivateTermUserCanAccess = termPrivateThatUserCanAccess.as({ - id: termIdUnpublishedWithUserAccessId, - type: AggregateType.term, - }); - - const eventHistoryForManyUnpublished = [ - ...eventsForPrivateTermUserCannotAccess, - ...eventsForPrivateTermUserCanAccess, - ]; - - const eventHistoryForManyWithPublishedTerm = [ - ...eventHistoryForManyUnpublished, - ...termPublished.as({ id: termId, type: AggregateType.term }), - ]; - describe(`when the user is unauthenticated`, () => { beforeAll(async () => { ({ app, testRepositoryProvider, databaseProvider } = await setUpIntegrationTest( @@ -191,14 +160,8 @@ describe(`when querying for a term: fetch many`, () => { termQueryRepository = app.get(TERM_QUERY_REPOSITORY_TOKEN); - handlers = buildEventHandlers(termQueryRepository); - - seedTerms = async (events: ICoscradEvent[]) => { - for (const e of events) { - for (const h of handlers) { - await h.handle(e); - } - } + seedTerms = async (terms: TermViewModel[]) => { + await termQueryRepository.createMany(terms); }; }); @@ -212,7 +175,11 @@ describe(`when querying for a term: fetch many`, () => { await testRepositoryProvider.getContributorRepository().create(dummyContributor); - await seedTerms(eventHistoryForManyWithPublishedTerm); + await seedTerms([ + publicTermView, + privateTermThatUserCanAccess, + privateTermUserCannotAccess, + ]); }); it(`should only return published terms`, async () => { @@ -259,7 +226,11 @@ describe(`when querying for a term: fetch many`, () => { await testRepositoryProvider.getContributorRepository().create(dummyContributor); - await seedTerms(eventHistoryForManyUnpublished); + /** + * Note that there is no ordinary system user authenticated for the request + * in this scenario. This is a public request. + */ + await seedTerms([privateTermUserCannotAccess, privateTermThatUserCanAccess]); }); it(`should return no terms`, async () => { @@ -285,14 +256,8 @@ describe(`when querying for a term: fetch many`, () => { termQueryRepository = app.get(TERM_QUERY_REPOSITORY_TOKEN); - handlers = buildEventHandlers(termQueryRepository); - - seedTerms = async (events: ICoscradEvent[]) => { - for (const e of events) { - for (const h of handlers) { - await h.handle(e); - } - } + seedTerms = async (terms: TermViewModel[]) => { + await termQueryRepository.createMany(terms); }; }); @@ -305,7 +270,12 @@ describe(`when querying for a term: fetch many`, () => { .getDatabaseForCollection(ArangoCollectionId.contributors) .clear(); - await seedTerms(eventHistoryForManyWithPublishedTerm); + await seedTerms([ + publicTermView, + privateTermThatUserCanAccess, + // This one should not be visible to an ordinary user + privateTermUserCannotAccess, + ]); await testRepositoryProvider .getContributorRepository() @@ -334,7 +304,10 @@ describe(`when querying for a term: fetch many`, () => { .getDatabaseForCollection(ArangoCollectionId.contributors) .clear(); - await seedTerms(eventHistoryForManyUnpublished); + await seedTerms([ + // we only seed the accessible term here + privateTermThatUserCanAccess, + ]); await testRepositoryProvider .getContributorRepository() @@ -346,16 +319,11 @@ describe(`when querying for a term: fetch many`, () => { expect(res.status).toBe(httpStatusCodes.ok); - /** - * + published - * + private, but user in ACL - * - private, user not in ACL - */ - const { body: { entities }, } = res; + // this is the first and only term we seeded const result = entities[0] as TermViewModel; expect(result.id).toBe(termIdUnpublishedWithUserAccessId); @@ -388,14 +356,8 @@ describe(`when querying for a term: fetch many`, () => { termQueryRepository = app.get(TERM_QUERY_REPOSITORY_TOKEN); - handlers = buildEventHandlers(termQueryRepository); - - seedTerms = async (events: ICoscradEvent[]) => { - for (const e of events) { - for (const h of handlers) { - await h.handle(e); - } - } + seedTerms = async (terms: TermViewModel[]) => { + await termQueryRepository.createMany(terms); }; }); @@ -406,7 +368,11 @@ describe(`when querying for a term: fetch many`, () => { .getDatabaseForCollection(ArangoCollectionId.contributors) .clear(); - await seedTerms(eventHistoryForManyWithPublishedTerm); + await seedTerms([ + publicTermView, + privateTermThatUserCanAccess, + privateTermUserCannotAccess, + ]); await testRepositoryProvider.getContributorRepository().create(dummyContributor); });