diff --git a/api/apps/api/src/modules/clone/import/application/import-application.module.ts b/api/apps/api/src/modules/clone/import/application/import-application.module.ts index 81005c0ae4..213e791eec 100644 --- a/api/apps/api/src/modules/clone/import/application/import-application.module.ts +++ b/api/apps/api/src/modules/clone/import/application/import-application.module.ts @@ -5,11 +5,13 @@ import { ImportAdaptersModule } from '../adapters/import-adapters.module'; import { CompleteImportPieceHandler } from './complete-import-piece.handler'; import { ExportConfigReader } from './export-config-reader'; import { ImportProjectHandler } from './import-project.handler'; +import { ImportScenarioHandler } from './import-scenario.handler'; @Module({ imports: [CqrsModule, ImportAdaptersModule, ArchiveReaderModule], providers: [ ImportProjectHandler, + ImportScenarioHandler, CompleteImportPieceHandler, ExportConfigReader, Logger, diff --git a/api/apps/api/src/modules/clone/import/application/import-scenario.command.ts b/api/apps/api/src/modules/clone/import/application/import-scenario.command.ts new file mode 100644 index 0000000000..2ddf52e648 --- /dev/null +++ b/api/apps/api/src/modules/clone/import/application/import-scenario.command.ts @@ -0,0 +1,15 @@ +import { ArchiveLocation } from '@marxan/cloning/domain'; +import { Failure as ArchiveReadError } from '@marxan/cloning/infrastructure/archive-reader.port'; +import { Command } from '@nestjs-architects/typed-cqrs'; +import { Either } from 'fp-ts/lib/Either'; +import { SaveError } from './import.repository.port'; + +export type ImportScenarioError = SaveError | ArchiveReadError; + +export class ImportScenario extends Command< + Either +> { + constructor(public readonly archiveLocation: ArchiveLocation) { + super(); + } +} diff --git a/api/apps/api/src/modules/clone/import/application/import-scenario.handler.spec.ts b/api/apps/api/src/modules/clone/import/application/import-scenario.handler.spec.ts new file mode 100644 index 0000000000..5636d41d36 --- /dev/null +++ b/api/apps/api/src/modules/clone/import/application/import-scenario.handler.spec.ts @@ -0,0 +1,272 @@ +import { + ArchiveLocation, + ClonePiece, + ComponentId, + ResourceId, + ResourceKind, +} from '@marxan/cloning/domain'; +import { + Failure as ArchiveFailure, + invalidFiles, +} from '@marxan/cloning/infrastructure/archive-reader.port'; +import { + ExportConfigContent, + ProjectExportConfigContent, + ScenarioExportConfigContent, +} from '@marxan/cloning/infrastructure/clone-piece-data/export-config'; +import { FixtureType } from '@marxan/utils/tests/fixture-type'; +import { CqrsModule, EventBus, IEvent } from '@nestjs/cqrs'; +import { Test } from '@nestjs/testing'; +import { Either, isLeft, isRight, left, Right, right } from 'fp-ts/Either'; +import { PromiseType } from 'utility-types'; +import { v4 } from 'uuid'; +import { MemoryImportRepository } from '../adapters/memory-import.repository.adapter'; +import { + ImportComponent, + ImportId, + ImportRequested, + PieceImportRequested, +} from '../domain'; +import { ImportComponentStatuses } from '../domain/import/import-component-status'; +import { ExportConfigReader } from './export-config-reader'; +import { ImportResourcePieces } from './import-resource-pieces.port'; +import { ImportScenario } from './import-scenario.command'; +import { ImportScenarioHandler } from './import-scenario.handler'; +import { ImportRepository } from './import.repository.port'; + +let fixtures: FixtureType; + +beforeEach(async () => { + fixtures = await getFixtures(); +}); + +it(`importing invalid archive`, async () => { + fixtures.GivenExtractingArchiveFails(); + const result = await fixtures.WhenRequestingImport(); + fixtures.ThenImportFails(result); +}); + +it(`importing archive with sequential components`, async () => { + fixtures.GivenExtractingArchiveHasSequentialComponents(); + const result = await fixtures.WhenRequestingImport(); + + fixtures.ThenRequestImportIsSaved(result); + fixtures.ThenImportRequestedIsEmitted(); + fixtures.ThenLowestOrderComponentIsRequested(); +}); + +it(`importing archive with equal order components`, async () => { + fixtures.GivenExtractingArchiveHasEqualComponents(); + const result = await fixtures.WhenRequestingImport(); + fixtures.ThenRequestImportIsSaved(result); + fixtures.ThenImportRequestedIsEmitted(); + fixtures.ThenAllComponentsAreRequested(); +}); + +const getFixtures = async () => { + const sandbox = await Test.createTestingModule({ + imports: [CqrsModule], + providers: [ + { + provide: ExportConfigReader, + useClass: FakeExportConfigReader, + }, + { + provide: ImportRepository, + useClass: MemoryImportRepository, + }, + { provide: ImportResourcePieces, useClass: FakeImportResourcePieces }, + ImportScenarioHandler, + ], + }).compile(); + await sandbox.init(); + + let resourceId: ResourceId; + + const events: IEvent[] = []; + sandbox.get(EventBus).subscribe((event) => events.push(event)); + + const sut = sandbox.get(ImportScenarioHandler); + const repo: MemoryImportRepository = sandbox.get(ImportRepository); + const exportConfigReader: FakeExportConfigReader = sandbox.get( + ExportConfigReader, + ); + const importResourcePieces: FakeImportResourcePieces = sandbox.get( + ImportResourcePieces, + ); + + return { + GivenExtractingArchiveFails: () => { + exportConfigReader.mock.mockImplementation(async () => + left(invalidFiles), + ); + }, + GivenExtractingArchiveHasSequentialComponents: () => { + importResourcePieces.mockSequentialPieces(); + }, + GivenExtractingArchiveHasEqualComponents: () => { + importResourcePieces.mockEqualPieces(); + }, + WhenRequestingImport: async () => { + const importResult = await sut.execute( + new ImportScenario(new ArchiveLocation(`whatever`)), + ); + if (isRight(importResult)) + resourceId = new ResourceId( + repo.entities[importResult.right].resourceId, + ); + return importResult; + }, + ThenRequestImportIsSaved: ( + importResult: PromiseType>, + ) => { + expect(isRight(importResult)).toBeTruthy(); + expect( + repo.entities[(importResult as Right).right], + ).toBeDefined(); + }, + ThenImportFails: ( + importResult: PromiseType>, + ) => { + expect(isLeft(importResult)).toBeTruthy(); + }, + ThenImportRequestedIsEmitted: () => { + expect( + events.filter((event) => event instanceof ImportRequested), + ).toEqual([ + { + importId: expect.any(ImportId), + resourceId, + resourceKind: ResourceKind.Scenario, + }, + ]); + }, + ThenLowestOrderComponentIsRequested: () => { + expect( + events.filter((event) => event instanceof PieceImportRequested), + ).toMatchObject([ + { + importId: expect.any(ImportId), + componentId: expect.any(ComponentId), + }, + ]); + }, + ThenAllComponentsAreRequested: () => { + expect( + events.filter((event) => event instanceof PieceImportRequested), + ).toMatchObject([ + { + importId: expect.any(ImportId), + componentId: expect.any(ComponentId), + }, + { + importId: expect.any(ImportId), + componentId: expect.any(ComponentId), + }, + ]); + }, + }; +}; + +class FakeExportConfigReader { + mock: jest.MockedFunction< + ExportConfigReader['read'] + > = jest.fn().mockResolvedValue( + right({ + resourceKind: ResourceKind.Scenario, + projectId: v4(), + } as ExportConfigContent), + ); + + async read( + location: ArchiveLocation, + ): Promise> { + return this.mock(location); + } +} + +class FakeImportResourcePieces extends ImportResourcePieces { + mock: jest.MockedFunction< + ImportResourcePieces['resolveForScenario'] + > = jest.fn(); + + resolveForProject( + id: ResourceId, + archiveLocation: ArchiveLocation, + pieces: ProjectExportConfigContent['pieces'], + ): ImportComponent[] { + return []; + } + + resolveForScenario( + id: ResourceId, + archiveLocation: ArchiveLocation, + pieces: ScenarioExportConfigContent['pieces'], + kind: ResourceKind, + oldScenarioId: string, + ): ImportComponent[] { + return this.mock(id, archiveLocation, pieces, kind, oldScenarioId); + } + + mockSequentialPieces() { + this.mock.mockImplementation((resourceId: ResourceId) => [ + ImportComponent.fromSnapshot({ + status: ImportComponentStatuses.Submitted, + order: 0, + resourceId: resourceId.value, + id: `import component unique id`, + piece: ClonePiece.ScenarioMetadata, + uris: [ + { + uri: `/tmp/scenario-metadata-random-uuid.json`, + relativePath: `scenario-metadata.json`, + }, + ], + }), + ImportComponent.fromSnapshot({ + status: ImportComponentStatuses.Submitted, + order: 1, + resourceId: resourceId.value, + id: `some other piece`, + piece: ClonePiece.ScenarioRunResults, + uris: [ + { + uri: `/tmp/scenario-run-results-random-uuid.json`, + relativePath: `scenario-run-results.json`, + }, + ], + }), + ]); + } + + mockEqualPieces() { + this.mock.mockImplementation((resourceId: ResourceId) => [ + ImportComponent.fromSnapshot({ + status: ImportComponentStatuses.Submitted, + order: 2, + resourceId: resourceId.value, + id: `import component unique id`, + piece: ClonePiece.ScenarioMetadata, + uris: [ + { + uri: `/tmp/scenario-metadata-random-uuid.json`, + relativePath: `scenario-metadata.json`, + }, + ], + }), + ImportComponent.fromSnapshot({ + status: ImportComponentStatuses.Submitted, + order: 2, + resourceId: resourceId.value, + id: `some other piece`, + piece: ClonePiece.ScenarioRunResults, + uris: [ + { + uri: `/tmp/scenario-run-results-random-uuid.json`, + relativePath: `scenario-run-results.json`, + }, + ], + }), + ]); + } +} diff --git a/api/apps/api/src/modules/clone/import/application/import-scenario.handler.ts b/api/apps/api/src/modules/clone/import/application/import-scenario.handler.ts new file mode 100644 index 0000000000..2e776f93b2 --- /dev/null +++ b/api/apps/api/src/modules/clone/import/application/import-scenario.handler.ts @@ -0,0 +1,64 @@ +import { ResourceId, ResourceKind } from '@marxan/cloning/domain'; +import { ScenarioExportConfigContent } from '@marxan/cloning/infrastructure/clone-piece-data/export-config'; +import { + CommandHandler, + EventPublisher, + IInferredCommandHandler, +} from '@nestjs/cqrs'; +import { Either, isLeft, right } from 'fp-ts/Either'; +import { Import } from '../domain/import/import'; +import { ExportConfigReader } from './export-config-reader'; +import { ImportResourcePieces } from './import-resource-pieces.port'; +import { ImportScenario, ImportScenarioError } from './import-scenario.command'; +import { ImportRepository } from './import.repository.port'; + +@CommandHandler(ImportScenario) +export class ImportScenarioHandler + implements IInferredCommandHandler { + constructor( + private readonly exportConfigReader: ExportConfigReader, + private readonly importRepo: ImportRepository, + private readonly eventPublisher: EventPublisher, + private readonly importResourcePieces: ImportResourcePieces, + ) {} + + async execute({ + archiveLocation, + }: ImportScenario): Promise> { + const exportConfigOrError = await this.exportConfigReader.read( + archiveLocation, + ); + if (isLeft(exportConfigOrError)) return exportConfigOrError; + + const exportConfig = exportConfigOrError.right as ScenarioExportConfigContent; + const importResourceId = ResourceId.create(); + + const pieces = this.importResourcePieces.resolveForScenario( + importResourceId, + archiveLocation, + exportConfig.pieces, + ResourceKind.Scenario, + exportConfig.resourceId, + ); + + const importRequest = this.eventPublisher.mergeObjectContext( + Import.newOne( + importResourceId, + ResourceKind.Scenario, + new ResourceId(exportConfig.projectId), + archiveLocation, + pieces, + ), + ); + + importRequest.run(); + + const result = await this.importRepo.save(importRequest); + + if (isLeft(result)) return result; + + importRequest.commit(); + + return right(importRequest.importId.value); + } +}