From 01d20df628bc5631cdd86a7532cdc54e48b696b2 Mon Sep 17 00:00:00 2001 From: "maruyama.t" Date: Tue, 8 Aug 2023 10:01:02 +0000 Subject: [PATCH] =?UTF-8?q?Merged=20PR=20310:=20API=E5=AE=9F=E8=A3=85?= =?UTF-8?q?=EF=BC=88=E3=83=A9=E3=82=A4=E3=82=BB=E3=83=B3=E3=82=B9=E7=99=BA?= =?UTF-8?q?=E8=A1=8CAPI=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 概要 [Task2341: API実装(ライセンス発行API)](https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/fa4924a4-d079-4fab-9fb5-a9a11eb205f0/_workitems/edit/2341) - 元PBI or タスクへのリンク(内容・目的などはそちらにあるはず) - 何をどう変更したか、追加したライブラリなど - このPull Requestでの対象/対象外 - 影響範囲(他の機能にも影響があるか) 既存のaccounts.service.spec.tsのテスト ## レビューポイント - 特にレビューしてほしい箇所 第一階層の場合とそれ以外の処理の違い ## UIの変更 - Before/Afterのスクショなど - スクショ置き場 ## 動作確認状況 - ローカルで確認 ①第一階層→第二階層から100件のライセンス注文を行い、ライセンス発行APIを呼び出し、 ライセンス注文テーブルの注文状態が発行済になることを確認。 ライセンステーブルが登録されることを確認。 DBの状態は、 第二階層のStock Licensesが100になることを確認。 登録・更新されたデータは処理単位で現在時刻が同じものであること ②第二階層→第三階層から90件のライセンス注文を行い、ライセンス発行APIを呼び出し、 ライセンス注文テーブルの注文状態が発行済になることを確認。 ライセンステーブルが登録されること(第三階層に対してUnAllocated)を確認。 ライセンステーブルが更新されること(第二階層に対してDeleted)を確認。 DBの状態は、 第二階層のStock Licensesが10になることを確認。 Deletedに更新されたライセンスについて、更新順がライセンスIDの順になっていることを確認。 第三階層のStock Licensesが90になることを確認。 登録・更新されたすべてのデータは、処理単位で現在時刻が同じものであることを確認。 ③第五階層で呼び出した場合、エラーになることを確認。 ④第4階層→第5階層に100件のライセンス注文を行い、ライセンス発行APIを呼び出し、 ライセンス数不足エラーとなることを確認。 ⑤②で行ったライセンス発行APIを呼び出し、 ライセンス発行済みエラーとなることを確認。 ⑥DBを起動していない状態で、ライセンス発行APIを呼び出し、 Internal Server Error 500が返却されることを確認。 ## 補足 - 相談、参考資料などがあれば --- dictation_server/src/common/error/code.ts | 2 + dictation_server/src/common/error/message.ts | 2 + .../features/accounts/accounts.controller.ts | 15 +- .../accounts/accounts.service.spec.ts | 188 +++++++++++++++++- .../src/features/accounts/accounts.service.ts | 72 +++++++ .../accounts/test/accounts.service.mock.ts | 22 +- .../src/features/accounts/test/utility.ts | 29 +++ .../src/repositories/licenses/errors/types.ts | 7 + .../licenses/licenses.repository.service.ts | 95 ++++++++- 9 files changed, 422 insertions(+), 10 deletions(-) diff --git a/dictation_server/src/common/error/code.ts b/dictation_server/src/common/error/code.ts index 94fa41c..bf06b11 100644 --- a/dictation_server/src/common/error/code.ts +++ b/dictation_server/src/common/error/code.ts @@ -42,4 +42,6 @@ export const ErrorCodes = [ 'E010701', // Blobファイル不在エラー 'E010801', // ライセンス不在エラー 'E010802', // ライセンス取り込み済みエラー + 'E010803', // ライセンス発行済みエラー + 'E010804', // ライセンス不足エラー ] as const; diff --git a/dictation_server/src/common/error/message.ts b/dictation_server/src/common/error/message.ts index bbc1b40..3339120 100644 --- a/dictation_server/src/common/error/message.ts +++ b/dictation_server/src/common/error/message.ts @@ -31,4 +31,6 @@ export const errors: Errors = { E010701: 'File not found in Blob Storage Error.', E010801: 'License not exist Error', E010802: 'License already activated Error', + E010803: 'License already issued Error', + E010804: 'License shortage Error', }; diff --git a/dictation_server/src/features/accounts/accounts.controller.ts b/dictation_server/src/features/accounts/accounts.controller.ts index 70dbc76..3bc4aa0 100644 --- a/dictation_server/src/features/accounts/accounts.controller.ts +++ b/dictation_server/src/features/accounts/accounts.controller.ts @@ -40,7 +40,7 @@ import { RoleGuard } from '../../common/guards/role/roleguards'; import { retrieveAuthorizationToken } from '../../common/http/helper'; import { AccessToken } from '../../common/token'; import jwt from 'jsonwebtoken'; -import { Context } from '../../common/log'; +import { makeContext } from '../../common/log'; @ApiTags('accounts') @Controller('accounts') @@ -401,12 +401,17 @@ export class AccountsController { console.log(body); const { orderedAccountId, poNumber } = body; - /*await this.licensesService.issueLicense( - orderedAccountId + const token = retrieveAuthorizationToken(req); + const accessToken = jwt.decode(token, { json: true }) as AccessToken; + + const context = makeContext(accessToken.userId); + await this.accountService.issueLicense( + context, + orderedAccountId, + accessToken.userId, + accessToken.tier, poNumber, ); - */ - return {}; } diff --git a/dictation_server/src/features/accounts/accounts.service.spec.ts b/dictation_server/src/features/accounts/accounts.service.spec.ts index 7ef47f1..3c55958 100644 --- a/dictation_server/src/features/accounts/accounts.service.spec.ts +++ b/dictation_server/src/features/accounts/accounts.service.spec.ts @@ -14,12 +14,15 @@ import { createAccount, createLicense, createLicenseOrder, + createUser, createLicenseSetExpiryDateAndStatus, } from './test/utility'; import { DataSource } from 'typeorm'; import { makeTestingModule } from '../../common/test/modules'; import { AccountsService } from './accounts.service'; +import { makeContext } from '../../common/log'; import { TIERS } from '../../constants'; +import { License } from '../../repositories/licenses/entity/license.entity'; describe('AccountsService', () => { it('アカウントに紐づくライセンス情報を取得する', async () => { @@ -695,7 +698,7 @@ describe('getOrderHistories', () => { }); }); -describe('getDealers', () => { +describe('issueLicense', () => { let source: DataSource = null; beforeEach(async () => { source = new DataSource({ @@ -713,6 +716,189 @@ describe('getDealers', () => { source = null; }); + it('指定した注文を発行済みにする', async () => { + const module = await makeTestingModule(source); + const service = module.get(AccountsService); + const now = new Date(); + // 親と子アカウントを作成する + const { accountId: parentAccountId } = await createAccount( + source, + 0, + 2, + 'PARENTCORP', + ); + const { accountId: childAccountId } = await createAccount( + source, + parentAccountId, + 3, + 'CHILDCORP1', + ); + // 親と子のユーザーを作成する + const { externalId } = await createUser( + source, + parentAccountId, + 'userId-parent', + 'admin', + ); + + // 親のライセンスを作成する(3個) + await createLicense(source, parentAccountId); + await createLicense(source, parentAccountId); + await createLicense(source, parentAccountId); + // 子から親への注文を作成する(2個) + await createLicenseOrder( + source, + childAccountId, + parentAccountId, + 2, + 'TEST001', + new Date(now.getTime() + 60 * 60 * 1000), + ); + + const context = makeContext('userId-parent'); + + // 注文を発行済みにする + await service.issueLicense( + context, + childAccountId, + externalId, + 2, + 'TEST001', + ); + + const issuedLicenses = await source.getRepository(License).find({ + where: { + account_id: childAccountId, + status: 'Unallocated', + type: 'NORMAL', + }, + }); + expect(issuedLicenses.length).toEqual(2); + }); + it('既に注文が発行済みの場合、エラーとなる', async () => { + const module = await makeTestingModule(source); + const service = module.get(AccountsService); + const now = new Date(); + // 親と子アカウントを作成する + const { accountId: parentAccountId } = await createAccount( + source, + 0, + 2, + 'PARENTCORP', + ); + const { accountId: childAccountId } = await createAccount( + source, + parentAccountId, + 3, + 'CHILDCORP1', + ); + // 親と子のユーザーを作成する + const { externalId } = await createUser( + source, + parentAccountId, + 'userId-parent', + 'admin', + ); + + // 親のライセンスを作成する(3個) + await createLicense(source, parentAccountId); + await createLicense(source, parentAccountId); + await createLicense(source, parentAccountId); + // 子から親への注文を作成する(2個) + await createLicenseOrder( + source, + childAccountId, + parentAccountId, + 2, + 'TEST001', + new Date(now.getTime() + 60 * 60 * 1000), + ); + + const context = makeContext('userId-parent'); + + // 注文を発行済みにする + await service.issueLicense( + context, + childAccountId, + externalId, + 2, + 'TEST001', + ); + + //再度同じ処理を行う + await expect( + service.issueLicense(context, childAccountId, externalId, 2, 'TEST001'), + ).rejects.toEqual( + new HttpException(makeErrorResponse('E010803'), HttpStatus.BAD_REQUEST), + ); + }); + it('ライセンスが不足している場合、エラーとなる', async () => { + const module = await makeTestingModule(source); + const service = module.get(AccountsService); + const now = new Date(); + // 親と子アカウントを作成する + const { accountId: parentAccountId } = await createAccount( + source, + 0, + 2, + 'PARENTCORP', + ); + const { accountId: childAccountId } = await createAccount( + source, + parentAccountId, + 3, + 'CHILDCORP1', + ); + // 親と子のユーザーを作成する + const { externalId } = await createUser( + source, + parentAccountId, + 'userId-parent', + 'admin', + ); + + // 親のライセンスを作成する(3個) + await createLicense(source, parentAccountId); + await createLicense(source, parentAccountId); + await createLicense(source, parentAccountId); + // 子から親への注文を作成する(4個) + await createLicenseOrder( + source, + childAccountId, + parentAccountId, + 4, + 'TEST001', + new Date(now.getTime() + 60 * 60 * 1000), + ); + + const context = makeContext('userId-parent'); + + // 注文を発行済みにする + await expect( + service.issueLicense(context, childAccountId, externalId, 2, 'TEST001'), + ).rejects.toEqual( + new HttpException(makeErrorResponse('E010804'), HttpStatus.BAD_REQUEST), + ); + }); +}); + +describe('getDealers', () => { + let source: DataSource = null; + beforeEach(async () => { + source = new DataSource({ + type: 'sqlite', + database: ':memory:', + logging: false, + entities: [__dirname + '/../../**/*.entity{.ts,.js}'], + synchronize: true, // trueにすると自動的にmigrationが行われるため注意 + }); + return source.initialize(); + }); + + afterEach(async () => { + await source.destroy(); + source = null; + }); it('Dealerを取得できる', async () => { const module = await makeTestingModule(source); const { accountId: accountId_1 } = await createAccount( diff --git a/dictation_server/src/features/accounts/accounts.service.ts b/dictation_server/src/features/accounts/accounts.service.ts index 8db4527..5d08225 100644 --- a/dictation_server/src/features/accounts/accounts.service.ts +++ b/dictation_server/src/features/accounts/accounts.service.ts @@ -37,6 +37,12 @@ import { UserGroupsRepositoryService } from '../../repositories/user_groups/user import { makePassword } from '../../common/password'; import { LicensesRepositoryService } from '../../repositories/licenses/licenses.repository.service'; import { AccountNotFoundError } from '../../repositories/accounts/errors/types'; +import { Context } from '../../common/log'; +import { + LicensesShortageError, + AlreadyIssuedError, + OrderNotFoundError, +} from '../../repositories/licenses/errors/types'; @Injectable() export class AccountsService { constructor( @@ -579,6 +585,72 @@ export class AccountsService { } } + /** + * 対象の注文を発行する + * @param context + * @param orderedAccountId + * @param userId + * @param tier + * @param poNumber + */ + async issueLicense( + context: Context, + orderedAccountId: number, + userId: string, + tier: number, + poNumber: string, + ): Promise { + this.logger.log( + `[IN] [${context.trackingId}] ${this.issueLicense.name} | params: { ` + + `orderedAccountId: ${orderedAccountId}, ` + + `userId: ${userId}, ` + + `tier: ${tier}, ` + + `poNumber: ${poNumber} };`, + ); + try { + // アクセストークンからユーザーIDを取得する + const myAccountId = ( + await this.usersRepository.findUserByExternalId(userId) + ).account_id; + await this.licensesRepository.issueLicense( + orderedAccountId, + myAccountId, + tier, + poNumber, + ); + } catch (e) { + this.logger.error(`error=${e}`); + if (e instanceof Error) { + switch (e.constructor) { + case OrderNotFoundError: + throw new HttpException( + makeErrorResponse('E010801'), + HttpStatus.BAD_REQUEST, + ); + case AlreadyIssuedError: + throw new HttpException( + makeErrorResponse('E010803'), + HttpStatus.BAD_REQUEST, + ); + case LicensesShortageError: + throw new HttpException( + makeErrorResponse('E010804'), + HttpStatus.BAD_REQUEST, + ); + default: + throw new HttpException( + makeErrorResponse('E009999'), + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + } finally { + this.logger.log( + `[OUT] [${context.trackingId}] ${this.issueLicense.name}`, + ); + } + } + // dealersのアカウント情報を取得する async getDealers(): Promise { this.logger.log(`[IN] ${this.getDealers.name}`); diff --git a/dictation_server/src/features/accounts/test/accounts.service.mock.ts b/dictation_server/src/features/accounts/test/accounts.service.mock.ts index 9f964c4..88dec41 100644 --- a/dictation_server/src/features/accounts/test/accounts.service.mock.ts +++ b/dictation_server/src/features/accounts/test/accounts.service.mock.ts @@ -14,6 +14,7 @@ import { UserGroup } from '../../../repositories/user_groups/entity/user_group.e import { UserGroupsRepositoryService } from '../../../repositories/user_groups/user_groups.repository.service'; import { AdB2cUser } from '../../../gateways/adb2c/types/types'; import { LicensesRepositoryService } from '../../../repositories/licenses/licenses.repository.service'; +import { Context } from '../../../common/log'; export type LicensesRepositoryMockValue = { getLicenseOrderHistoryInfo: @@ -22,6 +23,7 @@ export type LicensesRepositoryMockValue = { orderHistories: LicenseOrder[]; } | Error; + issueLicense: undefined | Error; }; export type UsersRepositoryMockValue = { findUserById: User | Error; @@ -127,10 +129,10 @@ export const makeAccountsRepositoryMock = ( export const makeLicensesRepositoryMock = ( value: LicensesRepositoryMockValue, ) => { - const { getLicenseOrderHistoryInfo } = value; + const { getLicenseOrderHistoryInfo, issueLicense } = value; return { - findUserById: + getLicenseOrderHistoryInfo: getLicenseOrderHistoryInfo instanceof Error ? jest .fn, []>() @@ -141,6 +143,21 @@ export const makeLicensesRepositoryMock = ( [] >() .mockResolvedValue(getLicenseOrderHistoryInfo), + issueLicense: + issueLicense instanceof Error + ? jest.fn, []>().mockRejectedValue(issueLicense) + : jest + .fn< + Promise<{ + context: Context; + orderedAccountId: number; + myAccountId: number; + tier: number; + poNumber: string; + }>, + [] + >() + .mockResolvedValue(issueLicense), }; }; export const makeUsersRepositoryMock = (value: UsersRepositoryMockValue) => { @@ -402,5 +419,6 @@ export const makeDefaultLicensesRepositoryMockValue = }, ], }, + issueLicense: undefined, }; }; diff --git a/dictation_server/src/features/accounts/test/utility.ts b/dictation_server/src/features/accounts/test/utility.ts index c570a4b..b9f3d80 100644 --- a/dictation_server/src/features/accounts/test/utility.ts +++ b/dictation_server/src/features/accounts/test/utility.ts @@ -4,6 +4,7 @@ import { License, LicenseOrder, } from '../../../repositories/licenses/entity/license.entity'; +import { User } from '../../../repositories/users/entity/user.entity'; export const createAccount = async ( datasource: DataSource, @@ -98,3 +99,31 @@ export const createLicenseOrder = async ( }); identifiers.pop() as License; }; + +export const createUser = async ( + datasource: DataSource, + accountId: number, + external_id: string, + role: string, + author_id?: string | undefined, +): Promise<{ userId: number; externalId: string; authorId: string }> => { + const { identifiers } = await datasource.getRepository(User).insert({ + account_id: accountId, + external_id: external_id, + role: role, + accepted_terms_version: '1.0', + author_id: author_id, + email_verified: true, + auto_renew: true, + license_alert: true, + notification: true, + encryption: false, + prompt: false, + created_by: 'test_runner', + created_at: new Date(), + updated_by: 'updater', + updated_at: new Date(), + }); + const user = identifiers.pop() as User; + return { userId: user.id, externalId: external_id, authorId: author_id }; +}; \ No newline at end of file diff --git a/dictation_server/src/repositories/licenses/errors/types.ts b/dictation_server/src/repositories/licenses/errors/types.ts index 972f850..ae3ac02 100644 --- a/dictation_server/src/repositories/licenses/errors/types.ts +++ b/dictation_server/src/repositories/licenses/errors/types.ts @@ -6,3 +6,10 @@ export class LicenseNotExistError extends Error {} // 取り込むライセンスが既に取り込み済みのエラー export class LicenseKeyAlreadyActivatedError extends Error {} + +// 注文不在エラー +export class OrderNotFoundError extends Error {} +// 注文発行済エラー +export class AlreadyIssuedError extends Error {} +// ライセンス不足エラー +export class LicensesShortageError extends Error {} diff --git a/dictation_server/src/repositories/licenses/licenses.repository.service.ts b/dictation_server/src/repositories/licenses/licenses.repository.service.ts index 1d085be..0c3c6ce 100644 --- a/dictation_server/src/repositories/licenses/licenses.repository.service.ts +++ b/dictation_server/src/repositories/licenses/licenses.repository.service.ts @@ -1,5 +1,5 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { DataSource, In } from 'typeorm'; +import { HttpException, HttpStatus, Injectable, Logger } from '@nestjs/common'; +import { DataSource, FindManyOptions, In } from 'typeorm'; import { LicenseOrder, License, @@ -12,11 +12,15 @@ import { LICENSE_STATUS_ISSUE_REQUESTING, LICENSE_STATUS_ISSUED, LICENSE_TYPE, + TIERS, } from '../../constants'; import { PoNumberAlreadyExistError, LicenseNotExistError, LicenseKeyAlreadyActivatedError, + LicensesShortageError, + AlreadyIssuedError, + OrderNotFoundError, } from './errors/types'; @Injectable() @@ -296,4 +300,91 @@ export class LicensesRepositoryService { }; }); } + + /** + * 対象の注文を発行する + * @context Context + * @param orderedAccountId + * @param myAccountId + * @param tier + * @param poNumber + */ + async issueLicense( + orderedAccountId: number, + myAccountId: number, + tier: number, + poNumber: string, + ): Promise { + const nowDate = new Date(); + await this.dataSource.transaction(async (entityManager) => { + const licenseOrderRepo = entityManager.getRepository(LicenseOrder); + const licenseRepo = entityManager.getRepository(License); + + const issuingOrder = await licenseOrderRepo.findOne({ + where: { + from_account_id: orderedAccountId, + po_number: poNumber, + }, + }); + // 注文が存在しない場合、エラー + if (!issuingOrder) { + throw new OrderNotFoundError(`No order found for PONumber:${poNumber}`); + } + // 既に発行済みの注文の場合、エラー + if (issuingOrder.status !== LICENSE_STATUS_ISSUE_REQUESTING) { + throw new AlreadyIssuedError( + `An order for PONumber:${poNumber} has already been issued.`, + ); + } + + // ライセンステーブルのレコードを作成する + const newLicenses = Array.from({ length: issuingOrder.quantity }, () => { + const license = new License(); + license.account_id = orderedAccountId; + license.status = LICENSE_ALLOCATED_STATUS.UNALLOCATED; + license.type = LICENSE_TYPE.NORMAL; + license.order_id = issuingOrder.id; + return license; + }); + // ライセンス注文テーブルを更新(注文元) + await licenseOrderRepo.update( + { id: issuingOrder.id }, + { + issued_at: nowDate, + status: LICENSE_STATUS_ISSUED, + }, + ); + // ライセンステーブルを登録(注文元) + await licenseRepo.save(newLicenses); + + // 第一階層の場合はストックライセンスの概念が存在しないため、ストックライセンス変更処理は行わない + if (tier !== TIERS.TIER1) { + const licensesToUpdate = await licenseRepo.find({ + where: { + account_id: myAccountId, + status: LICENSE_ALLOCATED_STATUS.UNALLOCATED, + type: LICENSE_TYPE.NORMAL, + }, + order: { + id: 'ASC', + }, + take: newLicenses.length, + }); + + // 登録したライセンスに対して自身のライセンスが不足していた場合、エラー + if (newLicenses.length > licensesToUpdate.length) { + throw new LicensesShortageError( + `Shortage Licenses.Number of licenses attempted to be issued is ${newLicenses.length}.`, + ); + } + for (const licenseToUpdate of licensesToUpdate) { + licenseToUpdate.status = LICENSE_ALLOCATED_STATUS.DELETED; + licenseToUpdate.deleted_at = nowDate; + licenseToUpdate.delete_order_id = issuingOrder.id; + } + // 自身のライセンスを削除(論理削除)する + await licenseRepo.save(licensesToUpdate); + } + }); + } }