Merged PR 831: 親アカウント変更API実装

## 概要
[Task3853: 親アカウント変更API実装](https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/fa4924a4-d079-4fab-9fb5-a9a11eb205f0/_workitems/edit/3853)

- 親アカウント切り替えAPIを実装しました。

## レビューポイント
- Service層の関数の分け方に改善点ないか?
- テストケースで他にあったほうがいいものや観点などあるか?

## UIの変更
- なし

## クエリの変更
- なし

## 動作確認状況
- ローカルで全テスト通ることを確認
- 行った修正がデグレを発生させていないことを確認できるか
    - 新規APIの実装のため既存実装に変更なし
This commit is contained in:
Kentaro Fukunaga 2024-03-18 05:47:24 +00:00
parent cab7a75ec1
commit 75f0a49fc1
7 changed files with 695 additions and 9 deletions

View File

@ -167,9 +167,7 @@ import { CheckHeaderMiddleware } from './common/check-header.middleware';
})
export class AppModule {
configure(consumer: MiddlewareConsumer) {
consumer
.apply(LoggerMiddleware)
.forRoutes('');
consumer.apply(LoggerMiddleware).forRoutes('');
// stage=localの場合はmiddlewareを適用しない
// ローカル環境ではサーバーから静的ファイルも返すため、APIリクエスト以外のリクエストにもmiddlewareが適用されてしまう
if (process.env.STAGE !== 'local') {

View File

@ -2393,13 +2393,8 @@ export class AccountsController {
const context = makeContext(userId, requestId);
this.logger.log(`[${context.getTrackingId()}] ip : ${ip}`);
// TODO:service層を呼び出す。本実装時に以下は削除する。
const { to, children } = body;
this.logger.log(
`[${context.getTrackingId()}] to : ${to}, children : ${children.join(
', ',
)}`,
);
await this.accountService.switchParent(context, to, children);
return {};
}

View File

@ -19,6 +19,7 @@ import {
createLicenseSetExpiryDateAndStatus,
createOptionItems,
createWorktype,
getLicenseOrders,
getOptionItems,
getSortCriteria,
getTypistGroup,
@ -92,6 +93,7 @@ import { truncateAllTable } from '../../common/test/init';
import { createTask, getCheckoutPermissions } from '../tasks/test/utility';
import { createCheckoutPermissions } from '../tasks/test/utility';
import { TestLogger } from '../../common/test/logger';
import { Account } from '../../repositories/accounts/entity/account.entity';
describe('createAccount', () => {
let source: DataSource | null = null;
@ -7930,3 +7932,383 @@ describe('updateRestrictionStatus', () => {
}
});
});
describe('switchParent', () => {
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が行われるため注意
logger: new TestLogger('none'),
logging: true,
});
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 { account: newParent } = await makeTestAccount(source, {
tier: 3,
country: `AU`,
});
// 子アカウントを作成する
const { account: child1 } = await makeTestAccount(source, {
tier: 4,
country: `AU`,
parent_account_id: undefined,
delegation_permission: true,
});
const { account: child2 } = await makeTestAccount(source, {
tier: 4,
country: `NZ`, // 同じリージョンで切り替えできることの確認
parent_account_id: undefined,
delegation_permission: true,
});
// ライセンス注文作成
await createLicenseOrder(source, child1.id, 10, 1); // 注文先アカウントは何でもいいため適当
await createLicenseOrder(source, child1.id, 10, 1); // 親アカウントは何でもいいため適当
await createLicenseOrder(source, child2.id, 10, 1); // 親アカウントは何でもいいため適当
// テスト実行
const context = makeContext(`external_id`, 'requestId');
const service = module.get<AccountsService>(AccountsService);
await service.switchParent(context, newParent.id, [child1.id, child2.id]);
const child1Result = await getAccount(source, child1.id);
const child2Result = await getAccount(source, child2.id);
const child1LicenseOrderResult = await getLicenseOrders(source, child1.id);
const child2LicenseOrderResult = await getLicenseOrders(source, child2.id);
// アカウントテーブルの更新確認
expect(child1Result?.parent_account_id).toBe(newParent.id);
expect(child1Result?.delegation_permission).toBe(false);
expect(child2Result?.parent_account_id).toBe(newParent.id);
expect(child2Result?.delegation_permission).toBe(false);
// ライセンス注文が全てcancelされていることの確認
expect(child1LicenseOrderResult.length).toBe(2);
const child1LicenseOrderStatuses = child1LicenseOrderResult.every(
(x) => x.status === LICENSE_ISSUE_STATUS.CANCELED,
);
expect(child1LicenseOrderStatuses).toBeTruthy();
expect(child2LicenseOrderResult.length).toBe(1);
const child2LicenseOrderStatuses = child2LicenseOrderResult.every(
(x) => x.status === LICENSE_ISSUE_STATUS.CANCELED,
);
expect(child2LicenseOrderStatuses).toBeTruthy();
});
it('第四階層<->第五階層間の階層構造変更処理ができる', async () => {
if (!source) fail();
const module = await makeTestingModule(source);
if (!module) fail();
// 新規親アカウントのアカウントを作成する
const { account: newParent } = await makeTestAccount(source, {
tier: 4,
country: `AU`,
});
// 子アカウントを作成する
const { account: child1 } = await makeTestAccount(source, {
tier: 5,
country: `AU`,
parent_account_id: undefined,
delegation_permission: true,
});
const { account: child2 } = await makeTestAccount(source, {
tier: 5,
country: `AU`,
parent_account_id: undefined,
delegation_permission: true,
});
// ライセンス注文作成
await createLicenseOrder(source, child1.id, 10, 1); // 注文先アカウントは何でもいいため適当
await createLicenseOrder(source, child1.id, 10, 1); // 親アカウントは何でもいいため適当
await createLicenseOrder(source, child2.id, 10, 1); // 親アカウントは何でもいいため適当
// テスト実行
const context = makeContext(`external_id`, 'requestId');
const service = module.get<AccountsService>(AccountsService);
await service.switchParent(context, newParent.id, [child1.id, child2.id]);
const child1Result = await getAccount(source, child1.id);
const child2Result = await getAccount(source, child2.id);
const child1LicenseOrderResult = await getLicenseOrders(source, child1.id);
const child2LicenseOrderResult = await getLicenseOrders(source, child2.id);
// アカウントテーブルの更新確認
expect(child1Result?.parent_account_id).toBe(newParent.id);
expect(child1Result?.delegation_permission).toBe(false);
expect(child2Result?.parent_account_id).toBe(newParent.id);
expect(child2Result?.delegation_permission).toBe(false);
// ライセンス注文が全てcancelされていることの確認
expect(child1LicenseOrderResult.length).toBe(2);
const child1LicenseOrderStatuses = child1LicenseOrderResult.every(
(x) => x.status === LICENSE_ISSUE_STATUS.CANCELED,
);
expect(child1LicenseOrderStatuses).toBeTruthy();
expect(child2LicenseOrderResult.length).toBe(1);
const child2LicenseOrderStatuses = child2LicenseOrderResult.every(
(x) => x.status === LICENSE_ISSUE_STATUS.CANCELED,
);
expect(child2LicenseOrderStatuses).toBeTruthy();
});
it('切り替え先親アカウントが存在しない場合は400エラー親アカウント不在エラーを返す', async () => {
if (!source) fail();
const module = await makeTestingModule(source);
if (!module) fail();
// 子アカウントの取得では1件だけ返すようにする
const accountsRepositoryService = module.get<AccountsRepositoryService>(
AccountsRepositoryService,
);
const child = new Account();
child.id = 1;
accountsRepositoryService.findAccountsById = jest
.fn()
.mockResolvedValue([child]);
const context = makeContext('external_id', 'requestId');
const service = module.get<AccountsService>(AccountsService);
try {
// 切り替え先アカウントを作成せずに実行する
await service.switchParent(context, 10, [child.id]);
} catch (e) {
if (e instanceof HttpException) {
expect(e.getStatus()).toEqual(HttpStatus.BAD_REQUEST);
expect(e.getResponse()).toEqual(makeErrorResponse('E017001'));
} else {
fail();
}
}
});
it('切り替え先親アカウントが第三・第四以外の階層の場合は400エラー階層関係不適切エラーを返す', async () => {
if (!source) fail();
const module = await makeTestingModule(source);
if (!module) fail();
// 子アカウントの取得では1件だけ返すようにする
const accountsRepositoryService = module.get<AccountsRepositoryService>(
AccountsRepositoryService,
);
const child = new Account();
child.id = 1;
child.tier = 4;
accountsRepositoryService.findAccountsById = jest
.fn()
.mockResolvedValue([child]);
const context = makeContext('external_id', 'requestId');
const service = module.get<AccountsService>(AccountsService);
// 親アカウントの階層が第五階層の場合に失敗する
const parent = new Account();
parent.id = 10;
try {
parent.tier = 5;
accountsRepositoryService.findAccountById = jest
.fn()
.mockResolvedValue(parent);
await service.switchParent(context, parent.id, [child.id]);
} catch (e) {
if (e instanceof HttpException) {
expect(e.getStatus()).toEqual(HttpStatus.BAD_REQUEST);
expect(e.getResponse()).toEqual(makeErrorResponse('E017002'));
} else {
fail();
}
}
try {
// 親アカウントの階層が第二階層の場合に失敗する
parent.tier = 2;
accountsRepositoryService.findAccountById = jest
.fn()
.mockResolvedValue(parent);
await service.switchParent(context, parent.id, [child.id]);
} catch (e) {
if (e instanceof HttpException) {
expect(e.getStatus()).toEqual(HttpStatus.BAD_REQUEST);
expect(e.getResponse()).toEqual(makeErrorResponse('E017002'));
} else {
fail();
}
}
try {
// 親アカウントの階層が第一階層の場合に失敗する
parent.tier = 1;
accountsRepositoryService.findAccountById = jest
.fn()
.mockResolvedValue(parent);
await service.switchParent(context, parent.id, [child.id]);
} catch (e) {
if (e instanceof HttpException) {
expect(e.getStatus()).toEqual(HttpStatus.BAD_REQUEST);
expect(e.getResponse()).toEqual(makeErrorResponse('E017002'));
} else {
fail();
}
}
});
it('第五階層の子アカウントに対して、第三階層の切り替え先親アカウントを指定した場合は400エラー階層関係不適切エラーを返す', async () => {
if (!source) fail();
const module = await makeTestingModule(source);
if (!module) fail();
// 子アカウントの取得では1件だけ返すようにする
const accountsRepositoryService = module.get<AccountsRepositoryService>(
AccountsRepositoryService,
);
const child1 = new Account();
child1.id = 1;
child1.tier = 5;
const child2 = new Account();
child1.id = 1;
child1.tier = 4;
accountsRepositoryService.findAccountsById = jest
.fn()
.mockResolvedValue([child1, child2]);
const context = makeContext('external_id', 'requestId');
const service = module.get<AccountsService>(AccountsService);
const parent = new Account();
parent.id = 10;
parent.tier = 3;
try {
accountsRepositoryService.findAccountById = jest
.fn()
.mockResolvedValue(parent);
await service.switchParent(context, parent.id, [child1.id, child2.id]);
} catch (e) {
if (e instanceof HttpException) {
expect(e.getStatus()).toEqual(HttpStatus.BAD_REQUEST);
expect(e.getResponse()).toEqual(makeErrorResponse('E017002'));
} else {
fail();
}
}
});
it('第三<->第四の切り替えで、親子でリージョンが異なる場合は400エラーリージョン関係不一致エラーを返す', async () => {
if (!source) fail();
const module = await makeTestingModule(source);
if (!module) fail();
const accountsRepositoryService = module.get<AccountsRepositoryService>(
AccountsRepositoryService,
);
const child1 = new Account();
child1.id = 1;
child1.tier = 4;
child1.country = 'AU';
const child2 = new Account();
child2.id = 2;
child2.tier = 4;
child2.country = 'US'; // このアカウントだけリージョンが異なるようにしておく
accountsRepositoryService.findAccountsById = jest
.fn()
.mockResolvedValue([child1, child2]);
const context = makeContext('external_id', 'requestId');
const service = module.get<AccountsService>(AccountsService);
const parent = new Account();
parent.id = 10;
parent.tier = 3;
parent.country = 'AU';
try {
accountsRepositoryService.findAccountById = jest
.fn()
.mockResolvedValue(parent);
await service.switchParent(context, parent.id, [child1.id, child2.id]);
} catch (e) {
if (e instanceof HttpException) {
expect(e.getStatus()).toEqual(HttpStatus.BAD_REQUEST);
expect(e.getResponse()).toEqual(makeErrorResponse('E017003'));
} else {
fail();
}
}
});
it('第四<->第五の切り替えで、親子で国が異なる場合は400エラー国関係不一致エラーを返す', async () => {
if (!source) fail();
const module = await makeTestingModule(source);
if (!module) fail();
const accountsRepositoryService = module.get<AccountsRepositoryService>(
AccountsRepositoryService,
);
const child1 = new Account();
child1.id = 1;
child1.tier = 5;
child1.country = 'AU';
const child2 = new Account();
child2.id = 2;
child2.tier = 5;
child2.country = 'NZ'; // このアカウントだけ国が異なるようにしておく
accountsRepositoryService.findAccountsById = jest
.fn()
.mockResolvedValue([child1, child2]);
const context = makeContext('external_id', 'requestId');
const service = module.get<AccountsService>(AccountsService);
const parent = new Account();
parent.id = 10;
parent.tier = 4;
parent.country = 'AU';
try {
accountsRepositoryService.findAccountById = jest
.fn()
.mockResolvedValue(parent);
await service.switchParent(context, parent.id, [child1.id, child2.id]);
} catch (e) {
if (e instanceof HttpException) {
expect(e.getStatus()).toEqual(HttpStatus.BAD_REQUEST);
expect(e.getResponse()).toEqual(makeErrorResponse('E017004'));
} else {
fail();
}
}
});
});

View File

@ -15,6 +15,9 @@ import {
OPTION_ITEM_VALUE_TYPE,
MANUAL_RECOVERY_REQUIRED,
LICENSE_ISSUE_STATUS,
BLOB_STORAGE_REGION_AU,
BLOB_STORAGE_REGION_EU,
BLOB_STORAGE_REGION_US,
} from '../../constants';
import { makeErrorResponse } from '../../common/error/makeErrorResponse';
import {
@ -47,7 +50,10 @@ import { LicensesRepositoryService } from '../../repositories/licenses/licenses.
import {
AccountNotFoundError,
AdminUserNotFoundError,
CountryMismatchError,
DealerAccountNotFoundError,
HierarchyMismatchError,
RegionMismatchError,
} from '../../repositories/accounts/errors/types';
import { Context } from '../../common/log';
import {
@ -2633,4 +2639,198 @@ export class AccountsService {
);
}
}
/**
*
* @param context
* @param newParent
* @param children
* @returns parent
*/
async switchParent(
context: Context,
newParent: number,
children: number[],
): Promise<void> {
this.logger.log(
`[IN] [${context.getTrackingId()}] ${
this.switchParent.name
} | params: { ` +
`newParent: ${newParent}, ` +
`children: ${children.join(', ')}};`,
);
try {
// 切り替え対象の情報取得
const childrenAccounts = await this.accountRepository.findAccountsById(
context,
children,
);
if (childrenAccounts.length !== children.length) {
// 指定された子アカウントが一つでも存在しない場合は通常運用ではありえないので汎用エラー
throw new Error('Some children accounts are not found');
}
const parentAccount = await this.accountRepository.findAccountById(
context,
newParent,
);
if (!parentAccount) {
// 指定された親アカウントが存在しない場合は通常運用で起こりうるため、BAD_REQUEST
throw new AccountNotFoundError(
`Parent account is not found. accountId=${newParent}`,
);
}
// 切り替え可否チェック(階層関係)
if (
!this.isValidHierarchyRelation(
parentAccount.tier,
childrenAccounts.map((x) => x.tier),
)
) {
throw new HierarchyMismatchError(
`Invalid hierarchy relation. parentAccount.tier=${parentAccount.tier}`,
);
}
// 切り替え可否チェック(リージョン・国関係)
const { success, errorType } = this.isValidLocationRelation(
parentAccount,
childrenAccounts,
);
if (!success) {
throw errorType;
}
// 切り替え処理実施
await this.accountRepository.switchParentAccount(
context,
newParent,
children,
);
} catch (e) {
this.logger.error(`[${context.getTrackingId()}] error=${e}`);
if (e instanceof Error) {
switch (e.constructor) {
case AccountNotFoundError:
throw new HttpException(
makeErrorResponse('E017001'),
HttpStatus.BAD_REQUEST,
);
case HierarchyMismatchError:
throw new HttpException(
makeErrorResponse('E017002'),
HttpStatus.BAD_REQUEST,
);
case RegionMismatchError:
throw new HttpException(
makeErrorResponse('E017003'),
HttpStatus.BAD_REQUEST,
);
case CountryMismatchError:
throw new HttpException(
makeErrorResponse('E017004'),
HttpStatus.BAD_REQUEST,
);
}
}
throw new HttpException(
makeErrorResponse('E009999'),
HttpStatus.INTERNAL_SERVER_ERROR,
);
} finally {
this.logger.log(
`[OUT] [${context.getTrackingId()}] ${this.switchParent.name}`,
);
}
}
/**
*
* @param parentTier
* @param childrenTiers
* @returns true if valid hierarchy relation
*/
private isValidHierarchyRelation(
parentTier: number,
childrenTiers: number[],
): boolean {
// 全ての子アカウントの階層が、親アカウントの階層の一つ下であるかつ、第三<->第四または第四<->第五の切り替えの場合のみ判定OK。
if (
parentTier === TIERS.TIER3 &&
childrenTiers.every((child) => child === TIERS.TIER4)
) {
return true;
} else if (
parentTier === TIERS.TIER4 &&
childrenTiers.every((child) => child === TIERS.TIER5)
) {
return true;
}
return false;
}
/**
*
* @param parent
* @param children
* @returns valid location relation
*/
private isValidLocationRelation(
parent: Account,
children: Account[],
): {
success: boolean;
errorType: null | RegionMismatchError | CountryMismatchError;
} {
// 第三<->第四の切り替えはリージョンの一致を確認し、第四<->第五の切り替えは国の一致を確認する。
if (parent.tier === TIERS.TIER3) {
if (
!children.every(
(child) =>
this.getRegion(child.country) === this.getRegion(parent.country),
)
) {
return {
success: false,
errorType: new RegionMismatchError('Invalid region relation'),
};
}
return { success: true, errorType: null };
} else if (parent.tier === TIERS.TIER4) {
if (!children.every((child) => child.country === parent.country)) {
return {
success: false,
errorType: new CountryMismatchError('Invalid country relation'),
};
}
return { success: true, errorType: null };
} else {
// 親アカウントの階層が想定外の場合、本関数の使い方が間違っているので例外を投げる
throw new Error('Not implemented');
}
}
/**
*
* @param country
* @returns region
*/
private getRegion(country: string): string {
// OMDS様より、地域はBlobStorageのリージョンで判定するでOKとのこと。
if (BLOB_STORAGE_REGION_AU.includes(country)) {
return 'AU';
} else if (BLOB_STORAGE_REGION_EU.includes(country)) {
return 'EU';
} else if (BLOB_STORAGE_REGION_US.includes(country)) {
return 'US';
} else {
// ここに到達する場合は、国が想定外の値であるため、例外を投げる
throw new Error(`Invalid country. country=${country}`);
}
}
}

View File

@ -248,3 +248,15 @@ export const createAudioFile = async (
const audioFile = audioFileIdentifiers.pop() as AudioFile;
return { audioFileId: audioFile.id };
};
// ライセンス注文を取得する
export const getLicenseOrders = async (
datasource: DataSource,
accountId: number,
): Promise<LicenseOrder[]> => {
return await datasource.getRepository(LicenseOrder).find({
where: {
from_account_id: accountId,
},
});
};

View File

@ -280,6 +280,23 @@ export class AccountsRepositoryService {
return account;
}
/**
* IDのアカウント一覧を取得する
* @param context
* @param ids
* @returns accounts by id
*/
async findAccountsById(context: Context, ids: number[]): Promise<Account[]> {
const accounts = await this.dataSource.getRepository(Account).find({
where: {
id: In(ids),
},
comment: `${context.getTrackingId()}_${new Date().toUTCString()}`,
});
return accounts;
}
/**
* OMDSTokyoのアカウント情報を取得する
* @param id
@ -1476,4 +1493,58 @@ export class AccountsRepositoryService {
);
});
}
/**
*
* @param context
* @param newParent
* @param children
* @returns parent account
*/
async switchParentAccount(
context: Context,
newParent: number,
children: number[],
): Promise<void> {
return await this.dataSource.transaction(async (entityManager) => {
const accountRepo = entityManager.getRepository(Account);
const childrenAccounts = await accountRepo.find({
where: {
id: In(children),
},
comment: `${context.getTrackingId()}_${new Date().toUTCString()}`,
lock: { mode: 'pessimistic_write' },
});
await updateEntity(
accountRepo,
{ id: In(childrenAccounts.map((child) => child.id)) },
{
parent_account_id: newParent,
delegation_permission: false,
},
this.isCommentOut,
context,
);
const licenseOrderRepo = entityManager.getRepository(LicenseOrder);
const cancelTargets = await licenseOrderRepo.find({
where: {
from_account_id: In(children),
status: LICENSE_ISSUE_STATUS.ISSUE_REQUESTING,
},
comment: `${context.getTrackingId()}_${new Date().toUTCString()}`,
lock: { mode: 'pessimistic_write' },
});
await updateEntity(
licenseOrderRepo,
{ id: In(cancelTargets.map((cancelTarget) => cancelTarget.id)) },
{
status: LICENSE_ISSUE_STATUS.CANCELED,
canceled_at: new Date(),
},
this.isCommentOut,
context,
);
});
}
}

View File

@ -26,3 +26,31 @@ export class AccountLockedError extends Error {
this.name = 'AccountLockedError';
}
}
/**
*
*/
export class HierarchyMismatchError extends Error {
constructor(message: string) {
super(message);
this.name = 'HierarchyMismatchError';
}
}
/**
*
*/
export class RegionMismatchError extends Error {
constructor(message: string) {
super(message);
this.name = 'RegionMismatchError';
}
}
/**
*
*/
export class CountryMismatchError extends Error {
constructor(message: string) {
super(message);
this.name = 'CountryMismatchError';
}
}