Merged PR 739: テンプレートファイル削除API実装

## 概要
[Task3599: テンプレートファイル削除API実装](https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/fa4924a4-d079-4fab-9fb5-a9a11eb205f0/_workitems/edit/3599)

- テンプレートファイル削除APIとテストを実装しました。

## レビューポイント
- テンプレートファイル削除できないエラーの条件は適切でしょうか?
- テストケースは適切でしょうか?

## UIの変更
- なし

## 動作確認状況
- ローカルで確認
This commit is contained in:
makabe.t 2024-02-13 00:22:38 +00:00
parent 447b0e280c
commit 83efd97bdf
11 changed files with 671 additions and 8 deletions

View File

@ -0,0 +1,5 @@
-- +migrate Up
ALTER TABLE `tasks` ADD INDEX `idx_tasks_template_file_id` (template_file_id);
-- +migrate Down
ALTER TABLE `tasks` DROP INDEX `idx_tasks_template_file_id`;

View File

@ -79,4 +79,7 @@ export const ErrorCodes = [
'E015001', // タイピストグループ削除エラー(削除しようとしたタイピストグループがすでに削除済みだった)
'E015002', // タイピストグループ削除エラー削除しようとしたタイピストグループがWorkflowのTypist候補として指定されていた
'E015003', // タイピストグループ削除エラー(削除しようとしたタイピストグループがチェックアウト可能なタスクが存在した)
'E016001', // テンプレートファイル削除エラー(削除しようとしたテンプレートファイルがすでに削除済みだった)
'E016002', // テンプレートファイル削除エラー削除しようとしたテンプレートファイルがWorkflowに指定されていた
'E016003', // テンプレートファイル削除エラー(削除しようとしたテンプレートファイルが未完了のタスクに紐づいていた)
] as const;

View File

@ -68,4 +68,8 @@ export const errors: Errors = {
E015001: 'Typist Group delete failed Error: already deleted',
E015002: 'Typist Group delete failed Error: workflow assigned',
E015003: 'Typist Group delete failed Error: checkout permission existed',
E016001: 'Template file delete failed Error: already deleted',
E016002: 'Template file delete failed Error: workflow assigned',
E016003:
'Template file delete failed Error: not finished task has template file',
};

View File

@ -273,6 +273,16 @@ export const getTask = async (
return task;
};
export const getTasks = async (
datasource: DataSource,
account_id: number,
): Promise<Task[]> => {
const tasks = await datasource.getRepository(Task).find({
where: { account_id: account_id },
});
return tasks;
};
export const getCheckoutPermissions = async (
datasource: DataSource,
task_id: number,

View File

@ -18,7 +18,11 @@ import {
import jwt from 'jsonwebtoken';
import { AccessToken } from '../../common/token';
import { ErrorResponse } from '../../common/error/types/types';
import { DeleteTemplateRequestParam, DeleteTemplateResponse, GetTemplatesResponse } from './types/types';
import {
DeleteTemplateRequestParam,
DeleteTemplateResponse,
GetTemplatesResponse,
} from './types/types';
import { AuthGuard } from '../../common/guards/auth/authguards';
import { RoleGuard } from '../../common/guards/role/roleguards';
import { ADMIN_ROLES } from '../../constants';
@ -132,7 +136,7 @@ export class TemplatesController {
RoleGuard.requireds({ roles: [ADMIN_ROLES.ADMIN], delegation: true }),
)
@Post(':templateFileId/delete')
async deleteTypistGroup(
async deleteTemplateFile(
@Req() req: Request,
@Param() param: DeleteTemplateRequestParam,
): Promise<DeleteTemplateResponse> {
@ -174,7 +178,7 @@ export class TemplatesController {
const context = makeContext(userId, requestId);
this.logger.log(`[${context.getTrackingId()}] ip : ${ip}`);
// TODO: service層呼び出し
await this.templatesService.deleteTemplate(context, userId, templateFileId);
return {};
}

View File

@ -3,9 +3,14 @@ import { TemplatesController } from './templates.controller';
import { TemplatesService } from './templates.service';
import { UsersRepositoryModule } from '../../repositories/users/users.repository.module';
import { TemplateFilesRepositoryModule } from '../../repositories/template_files/template_files.repository.module';
import { BlobstorageModule } from '../../gateways/blobstorage/blobstorage.module';
@Module({
imports: [UsersRepositoryModule, TemplateFilesRepositoryModule],
imports: [
UsersRepositoryModule,
TemplateFilesRepositoryModule,
BlobstorageModule,
],
providers: [TemplatesService],
controllers: [TemplatesController],
})

View File

@ -1,13 +1,26 @@
import { DataSource } from 'typeorm';
import { makeTestingModule } from '../../common/test/modules';
import { TemplatesService } from './templates.service';
import { createTemplateFile } from './test/utility';
import { makeTestAccount } from '../../common/test/utility';
import {
createTemplateFile,
getTemplateFiles,
updateTaskTemplateFile,
} from './test/utility';
import { makeTestAccount, makeTestUser } from '../../common/test/utility';
import { makeContext } from '../../common/log';
import { TemplateFilesRepositoryService } from '../../repositories/template_files/template_files.repository.service';
import { HttpException, HttpStatus } from '@nestjs/common';
import { makeErrorResponse } from '../../common/error/makeErrorResponse';
import { truncateAllTable } from '../../common/test/init';
import { overrideBlobstorageService } from '../../common/test/overrides';
import { TASK_STATUS, USER_ROLES } from '../../constants';
import { createTask, getTasks } from '../tasks/test/utility';
import {
createWorkflow,
createWorkflowTypist,
getWorkflow,
} from '../workflows/test/utility';
import { BlobstorageService } from '../../gateways/blobstorage/blobstorage.service';
describe('getTemplates', () => {
let source: DataSource | null = null;
@ -129,3 +142,355 @@ describe('getTemplates', () => {
}
});
});
describe('deleteTemplate', () => {
let source: DataSource | null = null;
beforeAll(async () => {
if (source == null) {
source = await (async () => {
const s = new DataSource({
type: 'mysql',
host: 'test_mysql_db',
port: 3306,
username: 'user',
password: 'password',
database: 'odms',
entities: [__dirname + '/../../**/*.entity{.ts,.js}'],
synchronize: false, // trueにすると自動的にmigrationが行われるため注意
});
return await s.initialize();
})();
}
});
beforeEach(async () => {
if (source) {
await truncateAllTable(source);
}
});
afterAll(async () => {
await source?.destroy();
source = null;
});
it('指定したテンプレートファイルを削除できる', async () => {
if (!source) fail();
const module = await makeTestingModule(source);
if (!module) fail();
const service = module.get<TemplatesService>(TemplatesService);
const blobStorageService =
module.get<BlobstorageService>(BlobstorageService);
overrideBlobstorageService(service, {
deleteFile: jest.fn(),
});
// 第五階層のアカウント作成
const { account, admin } = await makeTestAccount(source, { tier: 5 });
const { id: authorId } = await makeTestUser(source, {
account_id: account.id,
role: USER_ROLES.AUTHOR,
author_id: 'authorId',
});
const context = makeContext(admin.external_id, 'requestId');
const template1 = await createTemplateFile(
source,
account.id,
'test1',
'https://url1/test1',
);
const template2 = await createTemplateFile(
source,
account.id,
'test2',
'https://url2/test2',
);
const { taskId: taskId1 } = await createTask(
source,
account.id,
authorId,
'authorId',
'',
'01',
'00000001',
TASK_STATUS.FINISHED,
);
await updateTaskTemplateFile(source, taskId1, template1.id);
const { taskId: taskId2 } = await createTask(
source,
account.id,
authorId,
'authorId',
'',
'01',
'00000002',
TASK_STATUS.BACKUP,
);
await updateTaskTemplateFile(source, taskId2, template1.id);
// 作成したデータを確認
{
const templates = await getTemplateFiles(source, account.id);
expect(templates.length).toBe(2);
expect(templates[0].id).toBe(template1.id);
expect(templates[0].file_name).toBe(template1.file_name);
expect(templates[1].id).toBe(template2.id);
expect(templates[1].file_name).toBe(template2.file_name);
const tasks = await getTasks(source, account.id);
expect(tasks.length).toBe(2);
expect(tasks[0].template_file_id).toBe(template1.id);
expect(tasks[1].template_file_id).toBe(template1.id);
}
await service.deleteTemplate(context, admin.external_id, template1.id);
//実行結果を確認
{
const templates = await getTemplateFiles(source, account.id);
expect(templates.length).toBe(1);
expect(templates[0].id).toBe(template2.id);
expect(templates[0].file_name).toBe(template2.file_name);
const tasks = await getTasks(source, account.id);
expect(tasks.length).toBe(2);
expect(tasks[0].template_file_id).toBe(null);
expect(tasks[1].template_file_id).toBe(null);
// Blob削除メソッドが呼ばれているか確認
expect(blobStorageService.deleteFile).toBeCalledWith(
context,
account.id,
account.country,
'Templates/test1',
);
}
});
it('指定したテンプレートファイルが存在しない場合、400エラーとなる', async () => {
if (!source) fail();
const module = await makeTestingModule(source);
if (!module) fail();
const service = module.get<TemplatesService>(TemplatesService);
overrideBlobstorageService(service, {
deleteFile: jest.fn(),
});
// 第五階層のアカウント作成
const { account, admin } = await makeTestAccount(source, { tier: 5 });
const context = makeContext(admin.external_id, 'requestId');
const template1 = await createTemplateFile(
source,
account.id,
'test1',
'https://url1/test1',
);
// 作成したデータを確認
{
const templates = await getTemplateFiles(source, account.id);
expect(templates.length).toBe(1);
expect(templates[0].id).toBe(template1.id);
expect(templates[0].file_name).toBe(template1.file_name);
}
//実行結果を確認
try {
await service.deleteTemplate(context, admin.external_id, 9999);
fail();
} catch (e) {
if (e instanceof HttpException) {
expect(e.getStatus()).toEqual(HttpStatus.BAD_REQUEST);
expect(e.getResponse()).toEqual(makeErrorResponse('E016001'));
} else {
fail();
}
}
});
it('指定したテンプレートファイルがルーティングルールに紐づく場合、400エラーとなる', async () => {
if (!source) fail();
const module = await makeTestingModule(source);
if (!module) fail();
const service = module.get<TemplatesService>(TemplatesService);
overrideBlobstorageService(service, {
deleteFile: jest.fn(),
});
// 第五階層のアカウント作成
const { account, admin } = await makeTestAccount(source, { tier: 5 });
const { id: authorId } = await makeTestUser(source, {
account_id: account.id,
role: USER_ROLES.AUTHOR,
author_id: 'authorId',
});
const { id: typistId } = await makeTestUser(source, {
account_id: account.id,
role: USER_ROLES.TYPIST,
});
const context = makeContext(admin.external_id, 'requestId');
const template1 = await createTemplateFile(
source,
account.id,
'test1',
'https://url1/test1',
);
const { id: workflowId } = await createWorkflow(
source,
account.id,
authorId,
undefined,
template1.id,
);
await createWorkflowTypist(source, workflowId, typistId);
// 作成したデータを確認
{
const templates = await getTemplateFiles(source, account.id);
expect(templates.length).toBe(1);
expect(templates[0].id).toBe(template1.id);
expect(templates[0].file_name).toBe(template1.file_name);
const workflow = await getWorkflow(source, account.id, workflowId);
expect(workflow?.template_id).toBe(template1.id);
}
//実行結果を確認
try {
await service.deleteTemplate(context, admin.external_id, template1.id);
fail();
} catch (e) {
if (e instanceof HttpException) {
expect(e.getStatus()).toEqual(HttpStatus.BAD_REQUEST);
expect(e.getResponse()).toEqual(makeErrorResponse('E016002'));
} else {
fail();
}
}
});
it('指定したテンプレートファイルが未完了のタスクに紐づく場合、400エラーとなる', async () => {
if (!source) fail();
const module = await makeTestingModule(source);
if (!module) fail();
const service = module.get<TemplatesService>(TemplatesService);
overrideBlobstorageService(service, {
deleteFile: jest.fn(),
});
// 第五階層のアカウント作成
const { account, admin } = await makeTestAccount(source, { tier: 5 });
const { id: authorId } = await makeTestUser(source, {
account_id: account.id,
role: USER_ROLES.AUTHOR,
author_id: 'authorId',
});
const context = makeContext(admin.external_id, 'requestId');
const template1 = await createTemplateFile(
source,
account.id,
'test1',
'https://url1/test1',
);
const { taskId: taskId1 } = await createTask(
source,
account.id,
authorId,
'authorId',
'',
'01',
'00000001',
TASK_STATUS.UPLOADED,
);
await updateTaskTemplateFile(source, taskId1, template1.id);
// 作成したデータを確認
{
const templates = await getTemplateFiles(source, account.id);
expect(templates.length).toBe(1);
expect(templates[0].id).toBe(template1.id);
expect(templates[0].file_name).toBe(template1.file_name);
const tasks = await getTasks(source, account.id);
expect(tasks.length).toBe(1);
expect(tasks[0].template_file_id).toBe(template1.id);
expect(tasks[0].status).toBe(TASK_STATUS.UPLOADED);
}
//実行結果を確認
try {
await service.deleteTemplate(context, admin.external_id, template1.id);
fail();
} catch (e) {
if (e instanceof HttpException) {
expect(e.getStatus()).toEqual(HttpStatus.BAD_REQUEST);
expect(e.getResponse()).toEqual(makeErrorResponse('E016003'));
} else {
fail();
}
}
});
it('DBアクセスに失敗した場合、500エラーとなる', async () => {
if (!source) fail();
const module = await makeTestingModule(source);
if (!module) fail();
const service = module.get<TemplatesService>(TemplatesService);
overrideBlobstorageService(service, {
deleteFile: jest.fn(),
});
// 第五階層のアカウント作成
const { account, admin } = await makeTestAccount(source, { tier: 5 });
const context = makeContext(admin.external_id, 'requestId');
const template1 = await createTemplateFile(
source,
account.id,
'test1',
'https://url1/test1',
);
// 作成したデータを確認
{
const templates = await getTemplateFiles(source, account.id);
expect(templates.length).toBe(1);
expect(templates[0].id).toBe(template1.id);
expect(templates[0].file_name).toBe(template1.file_name);
}
//DBアクセスに失敗するようにする
const templateFilesRepositoryService =
module.get<TemplateFilesRepositoryService>(
TemplateFilesRepositoryService,
);
templateFilesRepositoryService.getTemplateFiles = jest
.fn()
.mockRejectedValue('DB failed');
try {
await service.deleteTemplate(context, admin.external_id, template1.id);
} catch (e) {
if (e instanceof HttpException) {
expect(e.getStatus()).toEqual(HttpStatus.INTERNAL_SERVER_ERROR);
expect(e.getResponse()).toEqual(makeErrorResponse('E009999'));
} else {
fail();
}
}
});
});

View File

@ -4,6 +4,13 @@ import { TemplateFile } from './types/types';
import { Context } from '../../common/log';
import { makeErrorResponse } from '../../common/error/makeErrorResponse';
import { TemplateFilesRepositoryService } from '../../repositories/template_files/template_files.repository.service';
import {
NotFinishedTaskHasTemplateDeleteFailedError,
TemplateFileNotExistError,
WorkflowHasTemplateDeleteFailedError,
} from '../../repositories/template_files/errors/types';
import { BlobstorageService } from '../../gateways/blobstorage/blobstorage.service';
import { MANUAL_RECOVERY_REQUIRED } from '../../constants';
@Injectable()
export class TemplatesService {
@ -11,6 +18,7 @@ export class TemplatesService {
constructor(
private readonly usersRepository: UsersRepositoryService,
private readonly templateFilesRepository: TemplateFilesRepositoryService,
private readonly blobStorageService: BlobstorageService,
) {}
/**
@ -55,4 +63,103 @@ export class TemplatesService {
);
}
}
/**
*
* @param context
* @param externalId
* @param templateFileId
* @returns template
*/
async deleteTemplate(
context: Context,
externalId: string,
templateFileId: number,
): Promise<void> {
this.logger.log(
`[IN] [${context.getTrackingId()}] ${
this.deleteTemplate.name
} | params: { externalId: ${externalId}, templateFileId: ${templateFileId} };`,
);
try {
const { account } = await this.usersRepository.findUserByExternalId(
context,
externalId,
);
if (!account) {
throw new Error(`account not found. externalId: ${externalId}`);
}
// テンプレートファイルの取得
const templateFile = await this.templateFilesRepository.getTemplateFile(
context,
account.id,
templateFileId,
);
// DBからのテンプレートファイルの削除
await this.templateFilesRepository.deleteTemplateFile(
context,
account.id,
templateFileId,
);
try {
// Blob Storageからのテンプレートファイルの削除
await this.blobStorageService.deleteFile(
context,
account.id,
account.country,
`Templates/${templateFile.file_name}`,
);
} catch (e) {
// Blob削除失敗時は、MANUAL_RECOVERY_REQUIREDを出して処理続行
this.logger.log(`[${context.getTrackingId()}] ${e}`);
this.logger.log(
`${MANUAL_RECOVERY_REQUIRED} [${context.getTrackingId()}] Failed to delete Blob: accountId: ${
account.id
}, fileName: ${templateFile.file_name}`,
);
}
} catch (e) {
this.logger.error(`[${context.getTrackingId()}] error=${e}`);
if (e instanceof Error) {
switch (e.constructor) {
// 指定されたIDのテンプレートファイルが存在しない
case TemplateFileNotExistError:
throw new HttpException(
makeErrorResponse('E016001'),
HttpStatus.BAD_REQUEST,
);
// 指定されたIDのテンプレートファイルがルーティングルールに設定されている
case WorkflowHasTemplateDeleteFailedError:
throw new HttpException(
makeErrorResponse('E016002'),
HttpStatus.BAD_REQUEST,
);
// 指定されたIDのテンプレートファイルが未完了タスクに紐づいている
case NotFinishedTaskHasTemplateDeleteFailedError:
throw new HttpException(
makeErrorResponse('E016003'),
HttpStatus.BAD_REQUEST,
);
default:
throw new HttpException(
makeErrorResponse('E009999'),
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
throw new HttpException(
makeErrorResponse('E009999'),
HttpStatus.INTERNAL_SERVER_ERROR,
);
} finally {
this.logger.log(
`[OUT] [${context.getTrackingId()}] ${this.deleteTemplate.name}`,
);
}
}
}

View File

@ -1,5 +1,6 @@
import { DataSource } from 'typeorm';
import { TemplateFile } from '../../../repositories/template_files/entity/template_file.entity';
import { Task } from '../../../repositories/tasks/entity/task.entity';
export const createTemplateFile = async (
datasource: DataSource,
@ -41,3 +42,18 @@ export const getTemplateFiles = async (
});
return templates;
};
export const updateTaskTemplateFile = async (
datasource: DataSource,
taskId: number,
templateFileId: number,
): Promise<void> => {
await datasource.getRepository(Task).update(
{ id: taskId },
{
template_file_id: templateFileId,
updated_by: 'updater',
updated_at: new Date(),
},
);
};

View File

@ -5,3 +5,17 @@ export class TemplateFileNotExistError extends Error {
this.name = 'TemplateFileNotExistError';
}
}
export class WorkflowHasTemplateDeleteFailedError extends Error {
constructor(message: string) {
super(message);
this.name = 'WorkflowHasTemplateDeleteFailedError';
}
}
export class NotFinishedTaskHasTemplateDeleteFailedError extends Error {
constructor(message: string) {
super(message);
this.name = 'NotFinishedTaskHasTemplateDeleteFailedError';
}
}

View File

@ -1,8 +1,20 @@
import { Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { DataSource, In } from 'typeorm';
import { TemplateFile } from './entity/template_file.entity';
import { insertEntity, updateEntity } from '../../common/repository';
import {
deleteEntity,
insertEntity,
updateEntity,
} from '../../common/repository';
import { Context } from '../../common/log';
import {
NotFinishedTaskHasTemplateDeleteFailedError,
TemplateFileNotExistError,
WorkflowHasTemplateDeleteFailedError,
} from './errors/types';
import { Workflow } from '../workflows/entity/workflow.entity';
import { Task } from '../tasks/entity/task.entity';
import { TASK_STATUS } from '../../constants';
@Injectable()
export class TemplateFilesRepositoryService {
@ -32,6 +44,36 @@ export class TemplateFilesRepositoryService {
});
}
/**
* IDで指定されたテンプレートファイルを取得する
* @param context
* @param accountId
* @param templateFileId
* @returns template file
*/
async getTemplateFile(
context: Context,
accountId: number,
templateFileId: number,
): Promise<TemplateFile> {
return await this.dataSource.transaction(async (entityManager) => {
const templateFilesRepo = entityManager.getRepository(TemplateFile);
const template = await templateFilesRepo.findOne({
where: { account_id: accountId, id: templateFileId },
comment: `${context.getTrackingId()}_${new Date().toUTCString()}`,
});
if (!template) {
throw new TemplateFileNotExistError(
`template file not found. accountId: ${accountId}, templateFileId: ${templateFileId}`,
);
}
return template;
});
}
/**
*
* @param accountId
@ -79,4 +121,92 @@ export class TemplateFilesRepositoryService {
}
});
}
/**
*
* @param accountId
* @param fileName
* @param url
* @returns template file
*/
async deleteTemplateFile(
context: Context,
accountId: number,
templateFileId: number,
): Promise<void> {
await this.dataSource.transaction(async (entityManager) => {
const workflowRepo = entityManager.getRepository(Workflow);
// テンプレートファイルがワークフローで使用されているか確認
const workflow = await workflowRepo.findOne({
where: {
account_id: accountId,
template_id: templateFileId,
},
lock: { mode: 'pessimistic_write' },
comment: `${context.getTrackingId()}_${new Date().toUTCString()}`,
});
// ワークフローで使用されている場合はエラー
if (workflow) {
throw new WorkflowHasTemplateDeleteFailedError(
`workflow has template file. accountId: ${accountId}, templateFileId: ${templateFileId}`,
);
}
const templateFilesRepo = entityManager.getRepository(TemplateFile);
// アカウント内に指定IDファイルがあるか確認
const template = await templateFilesRepo.findOne({
where: { account_id: accountId, id: templateFileId },
lock: { mode: 'pessimistic_write' },
comment: `${context.getTrackingId()}_${new Date().toUTCString()}`,
});
// ファイルが存在しない場合はエラー
if (!template) {
throw new TemplateFileNotExistError(
`template file not found. accountId: ${accountId}, templateFileId: ${templateFileId}`,
);
}
const taskRepo = entityManager.getRepository(Task);
// テンプレートファイルが未完了タスクで使用されているか確認
const templateUsedTasks = await taskRepo.findOne({
where: {
account_id: accountId,
template_file_id: templateFileId,
status: In([
TASK_STATUS.UPLOADED,
TASK_STATUS.IN_PROGRESS,
TASK_STATUS.PENDING,
]),
},
lock: { mode: 'pessimistic_write' },
comment: `${context.getTrackingId()}_${new Date().toUTCString()}`,
});
// 未完了のタスクでテンプレートファイルが使用されている場合はエラー
if (templateUsedTasks) {
throw new NotFinishedTaskHasTemplateDeleteFailedError(
`not finished task has template file. accountId: ${accountId}, templateFileId: ${templateFileId}`,
);
}
// テンプレートファイルの削除
await deleteEntity(
templateFilesRepo,
{ id: templateFileId },
this.isCommentOut,
context,
);
// 完了済みのタスクからテンプレートファイルの紐づけを解除
await updateEntity(
taskRepo,
{ template_file_id: templateFileId },
{ template_file_id: null },
this.isCommentOut,
context,
);
});
}
}