Skip to content

Commit

Permalink
feat(scenarios): create input zip archive from files
Browse files Browse the repository at this point in the history
  • Loading branch information
kgajowy committed Jul 20, 2021
1 parent 516606d commit 82f3a90
Show file tree
Hide file tree
Showing 8 changed files with 261 additions and 11 deletions.
2 changes: 2 additions & 0 deletions api/apps/api/src/modules/scenarios/input-files/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,5 @@ export {
inputZipNotYetAvailable,
InputZipFailure,
} from './input-files.service';

export { InputFilesArchiverService } from './input-files-archiver.service';
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import { Test } from '@nestjs/testing';
import { Injectable } from '@nestjs/common';
import { PromiseType } from 'utility-types';
import { Either, isLeft } from 'fp-ts/Either';
import * as unzipper from 'unzipper';

import { InputFilesArchiverService } from './input-files-archiver.service';
import { IoSettings } from './input-params/io-settings';
import { InputFilesService } from './input-files.service';
import { Writable } from 'stream';

let fixtures: PromiseType<ReturnType<typeof getFixtures>>;

beforeEach(async () => {
fixtures = await getFixtures();
});

describe(`when all input files are available`, () => {
beforeEach(() => {
fixtures.GivenInputDirectoryIsDefined();
fixtures.GivenFilesContentIsAvailable();
});

it(`should include them in archive`, async () => {
const archive = await fixtures.sut.archive(fixtures.scenarioId);
await fixtures.ThenArchiveContainsInputFiles(archive);
});
});

const getFixtures = async () => {
const app = await Test.createTestingModule({
providers: [
InputFilesArchiverService,
{
provide: InputFilesService,
useClass: FakeInputFiles,
},
],
}).compile();
const inputFileService: FakeInputFiles = app.get(InputFilesService);
const scenarioId = `scenario-id`;
const inputDirectoryName = 'input-directory';
const specFileName = `spec-dat-file`;
const boundFileName = `bound-dat-file`;
const puvsprFileName = `puvspr-dat-file`;
const puFileName = `pu-dat-file`;

return {
sut: app.get(InputFilesArchiverService),
scenarioId,
GivenInputDirectoryIsDefined: () => {
inputFileService.settingsMock.mockImplementationOnce(() => ({
INPUTDIR: inputDirectoryName,
SPECNAME: specFileName,
BOUNDNAME: boundFileName,
PUVSPRNAME: puvsprFileName,
PUNAME: puFileName,
}));
},
GivenFilesContentIsAvailable: () => {
inputFileService.inputParamsMock.mockImplementationOnce(
async () => `input.dat content`,
);
inputFileService.specMock.mockImplementationOnce(
async () => `spec.dat content`,
);
inputFileService.boundMock.mockImplementationOnce(
async () => `bound.dat content`,
);
inputFileService.puvsprMock.mockImplementationOnce(
async () => `puvspr.dat content`,
);
inputFileService.puMock.mockImplementationOnce(
(_: string, writable: Writable) => {
'costsurface'.split('').forEach((chunk) => {
setTimeout(() => {
writable.write(chunk);
if (chunk === 'e') {
writable.end();
}
}, 10);
});
},
);
},
ThenArchiveContainsInputFiles: async (archive: Either<any, Buffer>) => {
if (isLeft(archive)) {
expect(archive.left).toBeUndefined();
return;
}
const directory = await unzipper.Open.buffer(archive.right);

const inputDat = directory.files.find((e) => e.path === `input.dat`);
expect(inputDat).toBeDefined();
expect((await inputDat!.buffer?.()).toString()).toEqual(
`input.dat content`,
);
expect(inputFileService.inputParamsMock).toHaveBeenCalledWith(scenarioId);

const specDat = directory.files.find(
(e) => e.path === `${inputDirectoryName}/${specFileName}`,
);
expect(specDat).toBeDefined();
expect((await specDat!.buffer?.()).toString()).toEqual(
`spec.dat content`,
);
expect(inputFileService.specMock).toHaveBeenCalledWith(scenarioId);

const boundDat = directory.files.find(
(e) => e.path === `${inputDirectoryName}/${boundFileName}`,
);
expect(boundDat).toBeDefined();
expect((await boundDat!.buffer?.()).toString()).toEqual(
`bound.dat content`,
);
expect(inputFileService.boundMock).toHaveBeenCalledWith(scenarioId);

const puvSprDat = directory.files.find(
(e) => e.path === `${inputDirectoryName}/${puvsprFileName}`,
);
expect(puvSprDat).toBeDefined();
expect((await puvSprDat!.buffer?.()).toString()).toEqual(
`puvspr.dat content`,
);
expect(inputFileService.puvsprMock).toHaveBeenCalledWith(scenarioId);

const puDat = directory.files.find(
(e) => e.path === `${inputDirectoryName}/${puFileName}`,
);
expect(puDat).toBeDefined();
expect((await puDat!.buffer?.()).toString()).toEqual(`costsurface`);
expect(inputFileService.puMock).toHaveBeenCalledWith(
scenarioId,
expect.anything(),
);
},
};
};

@Injectable()
class FakeInputFiles {
settingsMock = jest.fn<Partial<IoSettings>, []>();
inputParamsMock = jest.fn();
specMock = jest.fn();
boundMock = jest.fn();
puvsprMock = jest.fn();
puMock = jest.fn();

getSettings() {
return this.settingsMock();
}

getInputParameterFile(scenarioId: string) {
return this.inputParamsMock(scenarioId);
}

getSpecDatContent(scenarioId: string) {
return this.specMock(scenarioId);
}

getBoundDatContent(scenarioId: string) {
return this.boundMock(scenarioId);
}

getPuvsprDatContent(scenarioId: string) {
return this.puvsprMock(scenarioId);
}

readCostSurface(scenarioId: string, writeStream: Writable) {
return this.puMock(scenarioId, writeStream);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { Injectable } from '@nestjs/common';
import { Either, right } from 'fp-ts/Either';
import * as archiver from 'archiver';

import { InputFilesService, InputZipFailure } from './input-files.service';
import { PassThrough } from 'stream';

@Injectable()
export class InputFilesArchiverService {
constructor(private readonly inputFiles: InputFilesService) {}

async archive(scenarioId: string): Promise<Either<InputZipFailure, Buffer>> {
const archive = archiver('zip', {
zlib: { level: 9 },
});

const settings = await this.inputFiles.getSettings();
const inputDirectory = settings.INPUTDIR;

const inputDatContent = this.inputFiles.getInputParameterFile(scenarioId);
const specDatContent = this.inputFiles.getSpecDatContent(scenarioId);
const boundDatContent = this.inputFiles.getBoundDatContent(scenarioId);
const puvsprDatContent = this.inputFiles.getPuvsprDatContent(scenarioId);
const costSurfaceStreamContent = new PassThrough();

await this.inputFiles.readCostSurface(scenarioId, costSurfaceStreamContent);

archive.append(costSurfaceStreamContent, {
name: `${inputDirectory}/${settings.PUNAME}`,
});
archive.append(await inputDatContent, {
name: `input.dat`,
});
archive.append(await specDatContent, {
name: `${inputDirectory}/${settings.SPECNAME}`,
});
archive.append(await boundDatContent, {
name: `${inputDirectory}/${settings.BOUNDNAME}`,
});
archive.append(await puvsprDatContent, {
name: `${inputDirectory}/${settings.PUVSPRNAME}`,
});

return new Promise((resolve, reject) => {
const buffers: Buffer[] = [];
archive.on('data', (chunk) => {
buffers.push(chunk);
});
archive.on('finish', () => {
resolve(right(Buffer.concat(buffers)));
});
archive.on('error', function (err) {
reject(err);
});
archive.finalize();
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { InputFilesService } from './input-files.service';
import { CostSurfaceViewService } from './cost-surface-view.service';
import { ioSettingsProvider } from './input-params/io-settings';
import { InputParameterFileProvider } from './input-params/input-parameter-file.provider';
import { InputFilesArchiverService } from './input-files-archiver.service';

@Module({
imports: [
Expand All @@ -41,7 +42,8 @@ import { InputParameterFileProvider } from './input-params/input-parameter-file.
CostSurfaceViewService,
ioSettingsProvider,
InputParameterFileProvider,
InputFilesArchiverService,
],
exports: [InputFilesService],
exports: [InputFilesService, InputFilesArchiverService],
})
export class InputFilesModule {}
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { Injectable } from '@nestjs/common';
import { Either, right } from 'fp-ts/Either';
import { Writable } from 'stream';

import { BoundDatService } from './bound.dat.service';
Expand Down Expand Up @@ -45,12 +44,6 @@ export class InputFilesService {
return this.costSurfaceService.read(scenarioId, stream);
}

async getArchive(
_scenarioId: string,
): Promise<Either<InputZipFailure, Buffer>> {
return right(Buffer.from([]));
}

async getInputParameterFile(scenarioId: string) {
return this.inputParameterFileProvider.getInputParameterFile(scenarioId);
}
Expand Down
6 changes: 3 additions & 3 deletions api/apps/api/src/modules/scenarios/scenarios.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,9 @@ import { CreateScenarioDTO } from './dto/create.scenario.dto';
import { UpdateScenarioDTO } from './dto/update.scenario.dto';
import { UpdateScenarioPlanningUnitLockStatusDto } from './dto/update-scenario-planning-unit-lock-status.dto';
import { SolutionResultCrudService } from './solutions-result/solution-result-crud.service';
import { CostSurfaceViewService } from './input-files/cost-surface-view.service';
import { OutputFilesService } from './output-files/output-files.service';
import { notFound, RunService } from './marxan-run';
import { InputFilesService } from './input-files';
import { InputFilesService, InputFilesArchiverService } from './input-files';

@Injectable()
export class ScenariosService {
Expand All @@ -49,6 +48,7 @@ export class ScenariosService {
private readonly runService: RunService,
private readonly inputFilesService: InputFilesService,
private readonly outputFilesService: OutputFilesService,
private readonly inputArchiveService: InputFilesArchiverService,
) {}

async findAllPaginated(
Expand Down Expand Up @@ -265,6 +265,6 @@ export class ScenariosService {

async getMarxanExecutionInputArchive(scenarioId: string) {
await this.assertScenario(scenarioId);
return this.inputFilesService.getArchive(scenarioId);
return this.inputArchiveService.archive(scenarioId);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@ afterEach(async () => {
await world?.cleanup();
});

describe(`when getting input.zip`, () => {
it(`should contain required input files`, async () => {
const archiveResponse = await world.WhenGettingArchivedInput();
await world.ThenArchiveContainsRequiredFiles(archiveResponse);
});
});

describe(`when getting spec.dat`, () => {
it(`should resolve text/*`, async () => {
expect((await world.WhenGettingSpecDat()).text).toMatchInlineSnapshot(`
Expand Down
16 changes: 16 additions & 0 deletions api/apps/api/test/scenario-input-files/world.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { bootstrapApplication } from '../utils/api-application';
import { GivenScenarioExists } from '../steps/given-scenario-exists';
import { ScenariosTestUtils } from '../utils/scenarios.test.utils';
import * as request from 'supertest';
import * as unzipper from 'unzipper';

export const createWorld = async () => {
const app = await bootstrapApplication();
Expand Down Expand Up @@ -42,5 +43,20 @@ export const createWorld = async () => {
request(app.getHttpServer())
.get(`/api/v1/scenarios/${scenario.id}/marxan/dat/spec.dat`)
.set('Authorization', `Bearer ${jwtToken}`),
WhenGettingArchivedInput: () =>
request(app.getHttpServer())
.get(`/api/v1/scenarios/${scenario.id}/marxan/input`)
.set('Authorization', `Bearer ${jwtToken}`),
ThenArchiveContainsRequiredFiles: async (response: request.Response) => {
expect(response.header['content-type']).toEqual('application/zip');
expect(response.header[`content-disposition`]).toEqual(
`attachment; filename="input.zip"`,
);
console.log(response.body);
console.log(response.text);
const directory = await unzipper.Open.buffer(response.body);
console.log(directory);
expect(directory.files.length).toEqual(1);
},
};
};

0 comments on commit 82f3a90

Please sign in to comment.