湯本 開 9803ba4e46 Merged PR 326: テストを最新化(パートナー追加)
## 概要
[Task2401: テストを最新化(パートナー追加)](https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/fa4924a4-d079-4fab-9fb5-a9a11eb205f0/_workitems/edit/2401)

- DBテストに修正
- Utilityを追加
- 実装側の変数名やコメント等を修正
- テストに使用するメールアドレスを一般的に使用するべきドメインに修正
  - 参考:
    - https://zenn.dev/progfay/articles/email-example-com
    - https://qiita.com/suzutsuki0220/items/4ad83ed2e2adbb6507a4
- `Promise<void>` となっていた部分をテスト用に `Promise<{accountId: number}>` に修正
  - 返り値を使用しているのはテスト側のみ

## レビューポイント
- 将来的にBlobStorageやSendMailで失敗したケース等も必要だが、それは異常系実装タスク内でテストが追加される想定なので今回追加していないが認識は合っているか
- 各種修正に対して、疑問点や問題点はないか

## 動作確認状況
- npm run testで成功
2023-08-18 02:11:09 +00:00

539 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { HttpException, HttpStatus, Injectable, Logger } from '@nestjs/common';
import { makeErrorResponse } from '../../common/error/makeErrorResponse';
import { AccessToken } from '../../common/token';
import { UsersRepositoryService } from '../../repositories/users/users.repository.service';
import { TasksRepositoryService } from '../../repositories/tasks/tasks.repository.service';
import { BlobstorageService } from '../../gateways/blobstorage/blobstorage.service';
import { AudioOptionItem, AudioUploadFinishedResponse } from './types/types';
import {
OPTION_ITEM_NUM,
TASK_STATUS,
USER_ROLES,
} from '../../constants/index';
import { User } from '../../repositories/users/entity/user.entity';
import {
AudioFileNotFoundError,
AuthorUserNotMatchError,
TemplateFileNotFoundError,
} from './errors/types';
import {
AccountNotMatchError,
StatusNotMatchError,
TasksNotFoundError,
TypistUserNotFoundError,
} from '../../repositories/tasks/errors/types';
import { Context } from '../../common/log';
@Injectable()
export class FilesService {
private readonly logger = new Logger(FilesService.name);
constructor(
private readonly usersRepository: UsersRepositoryService,
private readonly tasksRepository: TasksRepositoryService,
private readonly tasksRepositoryService: TasksRepositoryService,
private readonly blobStorageService: BlobstorageService,
) {}
/**
* Uploads finished
* @param url アップロード先Blob Storage(ファイル名含む)
* @param authorId 自分自身(ログイン認証)したAuthorID
* @param fileName 音声ファイル名
* @param duration 音声ファイルの録音時間(ミリ秒の整数値)
* @param createdDate 音声ファイルの録音作成日時(開始日時)yyyy-mm-ddThh:mm:ss.sss'
* @param finishedDate 音声ファイルの録音作成終了日時yyyy-mm-ddThh:mm:ss.sss
* @param uploadedDate 音声ファイルのアップロード日時yyyy-mm-ddThh:mm:ss.sss
* @param fileSize 音声ファイルのファイルサイズByte
* @param priority 優先度 "00":Normal / "01":High
* @param audioFormat 録音形式: DSS/DS2(SP)/DS2(QP)
* @param comment コメント
* @param workType WorkType
* @param optionItemList オプションアイテム音声メタデータ10個固定
* @param isEncrypted 暗号化されているか
* @returns finished
*/
async uploadFinished(
context: Context,
userId: string,
url: string,
authorId: string,
fileName: string,
duration: string,
createdDate: string,
finishedDate: string,
uploadedDate: string,
fileSize: number,
priority: string,
audioFormat: string,
comment: string,
workType: string,
optionItemList: AudioOptionItem[],
isEncrypted: boolean,
): Promise<AudioUploadFinishedResponse> {
this.logger.log(
`[IN] [${context.trackingId}] ${this.uploadFinished.name} | params: { ` +
`url: ${url}, ` +
`authorId: ${authorId}, ` +
`fileName: ${fileName}, ` +
`duration: ${duration}, ` +
`createdDate: ${createdDate}, ` +
`finishedDate: ${finishedDate}, ` +
`uploadedDate: ${uploadedDate}, ` +
`fileSize: ${fileSize}, ` +
`priority: ${priority}, ` +
`audioFormat: ${audioFormat}, ` +
`comment: ${comment}, ` +
`workType: ${workType}, ` +
`optionItemList: ${JSON.stringify(optionItemList)}, ` +
`isEncrypted: ${isEncrypted} };`,
);
const formattedCreatedDate = new Date(createdDate);
const formattedFinishedDate = new Date(finishedDate);
const formattedUploadedDate = new Date(uploadedDate);
const isInvalidCreatedDate = isNaN(formattedCreatedDate.getTime());
const isInvalidFinishedDate = isNaN(formattedFinishedDate.getTime());
const isInvalidUploadedDate = isNaN(formattedUploadedDate.getTime());
// 日付フォーマットが不正ならパラメータ不正
if (
isInvalidCreatedDate ||
isInvalidFinishedDate ||
isInvalidUploadedDate
) {
if (isInvalidCreatedDate) {
this.logger.error(
`param createdDate is invalid format:[createdDate=${createdDate}]`,
);
}
if (isInvalidFinishedDate) {
this.logger.error(
`param finishedDate is invalid format:[finishedDate=${finishedDate}]`,
);
}
if (isInvalidUploadedDate) {
this.logger.error(
`param uploadedDate is invalid format:[uploadedDate=${uploadedDate}]`,
);
}
this.logger.log(
`[OUT] [${context.trackingId}] ${this.uploadFinished.name}`,
);
throw new HttpException(
makeErrorResponse('E010001'),
HttpStatus.BAD_REQUEST,
);
}
// オプションアイテムが10個ない場合はパラメータ不正
if (optionItemList.length !== OPTION_ITEM_NUM) {
this.logger.error(
`param optionItemList expects ${OPTION_ITEM_NUM} items, but has ${optionItemList.length} items`,
);
this.logger.log(
`[OUT] [${context.trackingId}] ${this.uploadFinished.name}`,
);
throw new HttpException(
makeErrorResponse('E010001'),
HttpStatus.BAD_REQUEST,
);
}
let user: User;
try {
// ユーザー取得
user = await this.usersRepository.findUserByExternalId(userId);
} catch (e) {
this.logger.error(`error=${e}`);
this.logger.log(
`[OUT] [${context.trackingId}] ${this.uploadFinished.name}`,
);
throw new HttpException(
makeErrorResponse('E009999'),
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
try {
// URLにSASトークンがついている場合は取り除く
const urlObj = new URL(url);
urlObj.search = '';
const fileUrl = urlObj.toString();
this.logger.log(`Request URL: ${url}, Without param URL${fileUrl}`);
// 文字起こしタスク追加(音声ファイルとオプションアイテムも同時に追加)
// 追加時に末尾のJOBナンバーにインクリメントする
const task = await this.tasksRepositoryService.create(
user.account_id,
user.id,
priority,
fileUrl,
fileName,
authorId,
workType,
formattedCreatedDate,
duration,
formattedFinishedDate,
formattedUploadedDate,
fileSize,
audioFormat,
comment,
isEncrypted,
optionItemList,
);
return { jobNumber: task.job_number };
} catch (e) {
this.logger.error(`error=${e}`);
throw new HttpException(
makeErrorResponse('E009999'),
HttpStatus.INTERNAL_SERVER_ERROR,
);
} finally {
this.logger.log(
`[OUT] [${context.trackingId}] ${this.uploadFinished.name}`,
);
}
}
/**
* Publishs upload sas
* @param companyName
* @returns upload sas
*/
async publishUploadSas(
context: Context,
token: AccessToken,
): Promise<string> {
this.logger.log(
`[IN] [${context.trackingId}] ${this.publishUploadSas.name}`,
);
//DBから国情報とアカウントIDを取得する
let accountId: number;
let country: string;
try {
const user = await this.usersRepository.findUserByExternalId(
token.userId,
);
accountId = user.account.id;
country = user.account.country;
} catch (e) {
this.logger.error(`error=${e}`);
this.logger.log(
`[OUT] [${context.trackingId}] ${this.publishUploadSas.name}`,
);
throw new HttpException(
makeErrorResponse('E009999'),
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
try {
// 国に応じたリージョンのBlobストレージにコンテナが存在するか確認
await this.blobStorageService.containerExists(
context,
accountId,
country,
);
} catch (e) {
this.logger.error(`error=${e}`);
this.logger.log(
`[OUT] [${context.trackingId}] ${this.publishUploadSas.name}`,
);
throw new HttpException(
makeErrorResponse('E009999'),
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
try {
// SASトークン発行
const url = await this.blobStorageService.publishUploadSas(
context,
accountId,
country,
);
return url;
} catch (e) {
this.logger.error(`error=${e}`);
throw new HttpException(
makeErrorResponse('E009999'),
HttpStatus.INTERNAL_SERVER_ERROR,
);
} finally {
this.logger.log(
`[OUT] [${context.trackingId}] ${this.publishUploadSas.name}`,
);
}
}
/**
* 指定したIDの音声ファイルのダウンロードURLを取得する
* @param externalId
* @param audioFileId
* @returns audio file download sas
*/
async publishAudioFileDownloadSas(
context: Context,
externalId: string,
audioFileId: number,
): Promise<string> {
this.logger.log(
`[IN] [${context.trackingId}] ${this.publishAudioFileDownloadSas.name} | params: { externalId: ${externalId}, audioFileId: ${audioFileId} };`,
);
//DBから国情報とアカウントID,ユーザーIDを取得する
let accountId: number;
let userId: number;
let country: string;
let isTypist: boolean;
let authorId: string;
try {
const user = await this.usersRepository.findUserByExternalId(externalId);
accountId = user.account.id;
userId = user.id;
country = user.account.country;
isTypist = user.role === USER_ROLES.TYPIST;
authorId = user.author_id;
} catch (e) {
this.logger.error(`error=${e}`);
this.logger.log(
`[OUT] [${context.trackingId}] ${this.publishAudioFileDownloadSas.name}`,
);
throw new HttpException(
makeErrorResponse('E009999'),
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
try {
const status = isTypist
? [TASK_STATUS.IN_PROGRESS, TASK_STATUS.PENDING]
: Object.values(TASK_STATUS);
const task = await this.tasksRepository.getTaskAndAudioFile(
audioFileId,
accountId,
status,
);
const file = task.file;
// タスクに紐づく音声ファイルだけが消される場合がある。
// その場合はダウンロード不可なので不在エラーとして扱う
if (!file) {
throw new AudioFileNotFoundError(
`Audio file is not exists in DB. audio_file_id:${audioFileId}`,
);
}
// ユーザーがAuthorの場合、自身が追加したタスクでない場合はエラー
if (!isTypist && task.file.author_id !== authorId) {
throw new AuthorUserNotMatchError(
`task author is not match. audio_file_id:${audioFileId}, task.file.author_id:${task.file.author_id}, authorId:${authorId}`,
);
}
// ユーザーがTypistの場合、自身が担当したタスクでない場合はエラー
if (isTypist && task.typist_user_id !== userId) {
throw new AuthorUserNotMatchError(
`task typist is not match. audio_file_id:${audioFileId}, task.typist_user_id:${task.typist_user_id}, userId:${userId}`,
);
}
const filePath = `${file.file_name}`;
const isFileExist = await this.blobStorageService.fileExists(
context,
accountId,
country,
filePath,
);
if (!isFileExist) {
this.logger.log(`filePath:${filePath}`);
throw new AudioFileNotFoundError(
`Audio file is not exists in blob storage. audio_file_id:${audioFileId}, url:${file.url}, fileName:${file.file_name}`,
);
}
// SASトークン発行
const url = await this.blobStorageService.publishDownloadSas(
context,
accountId,
country,
filePath,
);
return url;
} catch (e) {
this.logger.error(`error=${e}`);
if (e instanceof Error) {
switch (e.constructor) {
case TasksNotFoundError:
case AccountNotMatchError:
case StatusNotMatchError:
case AuthorUserNotMatchError:
case TypistUserNotFoundError:
throw new HttpException(
makeErrorResponse('E010603'),
HttpStatus.BAD_REQUEST,
);
case AudioFileNotFoundError:
throw new HttpException(
makeErrorResponse('E010701'),
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.trackingId}] ${this.publishAudioFileDownloadSas.name}`,
);
}
}
/**
* 指定したIDの音声ファイルに紐づいた文字起こしテンプレートファイルのダウンロードURLを取得する
* @param externalId
* @param audioFileId
* @returns template file download sas
*/
async publishTemplateFileDownloadSas(
context: Context,
externalId: string,
audioFileId: number,
): Promise<string> {
this.logger.log(
`[IN] [${context.trackingId}] ${this.publishTemplateFileDownloadSas.name} | params: { externalId: ${externalId}, audioFileId: ${audioFileId} };`,
);
//DBから国情報とアカウントID,ユーザーIDを取得する
let accountId: number;
let userId: number;
let country: string;
let isTypist: boolean;
let authorId: string;
try {
const user = await this.usersRepository.findUserByExternalId(externalId);
accountId = user.account.id;
userId = user.id;
country = user.account.country;
isTypist = user.role === USER_ROLES.TYPIST;
authorId = user.author_id;
} catch (e) {
this.logger.error(`error=${e}`);
this.logger.log(
`[OUT] [${context.trackingId}] ${this.publishTemplateFileDownloadSas.name}`,
);
throw new HttpException(
makeErrorResponse('E009999'),
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
try {
const status = isTypist
? [TASK_STATUS.IN_PROGRESS, TASK_STATUS.PENDING]
: Object.values(TASK_STATUS);
const task = await this.tasksRepository.getTaskAndAudioFile(
audioFileId,
accountId,
status,
);
const template_file = task.template_file;
// タスクに紐づくテンプレートファイルがない場合がある。
// その場合はダウンロード不可なので不在エラーとして扱う
if (!template_file) {
throw new TemplateFileNotFoundError(
`Template file is not exists in DB. audio_file_id:${audioFileId}`,
);
}
// ユーザーがAuthorの場合、自身が追加したタスクでない場合はエラー
if (!isTypist && task.file.author_id !== authorId) {
throw new AuthorUserNotMatchError(
`task author is not match. audio_file_id:${audioFileId}, task.file.author_id:${task.file.author_id}, authorId:${authorId}`,
);
}
// ユーザーがTypistの場合、自身が担当したタスクでない場合はエラー
if (isTypist && task.typist_user_id !== userId) {
throw new AuthorUserNotMatchError(
`task typist is not match. audio_file_id:${audioFileId}, task.typist_user_id:${task.typist_user_id}, userId:${userId}`,
);
}
const filePath = `Templates/${template_file.file_name}`;
const isFileExist = await this.blobStorageService.fileExists(
context,
accountId,
country,
filePath,
);
if (!isFileExist) {
throw new TemplateFileNotFoundError(
`Template file is not exists in blob storage. audio_file_id:${audioFileId}, url:${template_file.url}, fileName:${template_file.file_name}`,
);
}
// SASトークン発行
const url = await this.blobStorageService.publishDownloadSas(
context,
accountId,
country,
filePath,
);
return url;
} catch (e) {
this.logger.error(`error=${e}`);
if (e instanceof Error) {
switch (e.constructor) {
case TasksNotFoundError:
case AccountNotMatchError:
case StatusNotMatchError:
case AuthorUserNotMatchError:
case TypistUserNotFoundError:
throw new HttpException(
makeErrorResponse('E010603'),
HttpStatus.BAD_REQUEST,
);
case TemplateFileNotFoundError:
throw new HttpException(
makeErrorResponse('E010701'),
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.trackingId}] ${this.publishTemplateFileDownloadSas.name}`,
);
}
}
}