Merged PR 186: API実装(カードライセンス発行API)

## 概要
[Task1992: API実装(カードライセンス発行API)](https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/fa4924a4-d079-4fab-9fb5-a9a11eb205f0/_workitems/edit/1992)

- タスク 1992: API実装(カードライセンス発行API)
-カードライセンス発行APIを実装

## レビューポイント
- DB登録時の処理が適切かどうか

## UIの変更
なし

## 動作確認状況
ユニットテスト実施済み
ローカルでの動作確認実施済み

## 補足
なし
This commit is contained in:
maruyama.t 2023-07-04 08:58:28 +00:00
parent 3a7bf60f3e
commit ceea4920f6
9 changed files with 423 additions and 18 deletions

View File

@ -113,6 +113,15 @@ export const LICENSE_STATUS_ISSUE_REQUESTING = 'Issue Requesting';
*/
export const LICENSE_STATUS_ISSUED = 'Issued';
/**
*
* @const {string[]}
*/
export const LICENSE_TYPE = {
TRIAL: 'TRIAL',
NORMAL: 'NORMAL',
CARD: 'CARD',
} as const;
/**
*
* @const {string[]}
@ -130,6 +139,12 @@ export const LICENSE_ALLOCATED_STATUS = {
*/
export const LICENSE_EXPIRATION_THRESHOLD_DAYS = 14;
/**
*
* @const {number}
*/
export const CARD_LICENSE_LENGTH = 20;
/**
*
* @const {string}

View File

@ -5,6 +5,7 @@ import {
Post,
Req,
UseGuards,
HttpException,
} from '@nestjs/common';
import {
ApiResponse,
@ -27,6 +28,7 @@ import { AuthGuard } from '../../common/guards/auth/authguards';
import { RoleGuard } from '../../common/guards/role/roleguards';
import { ADMIN_ROLES } from '../../constants';
import jwt from 'jsonwebtoken';
import { makeErrorResponse } from '../../common/error/makeErrorResponse';
@ApiTags('licenses')
@Controller('licenses')
@ -95,6 +97,7 @@ export class LicensesController {
@ApiOperation({ operationId: 'issueCardLicenses' })
@ApiBearerAuth()
@UseGuards(AuthGuard)
@UseGuards(RoleGuard.requireds({ roles: [ADMIN_ROLES.ADMIN] }))
@Post('/cards')
async issueCardLicenses(
@Req() req: Request,
@ -103,13 +106,21 @@ export class LicensesController {
console.log(req.header('Authorization'));
console.log(body);
// レスポンス値のサンプル
const cardLicenseKeys: string[] = [
'3S5F9P7L4X1J6G2M8Q0Y',
'9R7K2U1H5V3B6M0D8W4C',
'2L0X5Y9P6U7Q1G4C3W8N',
];
const accessToken = retrieveAuthorizationToken(req);
const payload = jwt.decode(accessToken, { json: true }) as AccessToken;
return { cardLicenseKeys };
// 第一階層以外は401を返す後々UseGuardsで弾く
if (payload.tier != 1) {
throw new HttpException(
makeErrorResponse('E000108'),
HttpStatus.UNAUTHORIZED,
);
}
const cardLicenseKeys = await this.licensesService.issueCardLicenseKeys(
payload.userId,
body.createCount,
);
return cardLicenseKeys;
}
}

View File

@ -1,5 +1,9 @@
import { AccessToken } from '../../common/token';
import { CreateOrdersRequest } from './types/types';
import {
CreateOrdersRequest,
IssueCardLicensesRequest,
IssueCardLicensesResponse,
} from './types/types';
import {
makeDefaultAccountsRepositoryMockValue,
makeDefaultLicensesRepositoryMockValue,
@ -9,6 +13,14 @@ import {
import { makeErrorResponse } from '../../common/error/makeErrorResponse';
import { HttpException, HttpStatus } from '@nestjs/common';
import { PoNumberAlreadyExistError } from '../../repositories/licenses/errors/types';
import { LicensesService } from './licenses.service';
import { makeTestingModule } from '../../common/test/modules';
import { DataSource } from 'typeorm';
import {
createAccount,
createUser,
selectCardLicensesCount,
} from './test/utility';
describe('LicensesService', () => {
it('ライセンス注文が完了する', async () => {
@ -109,4 +121,97 @@ describe('LicensesService', () => {
),
);
});
it('カードライセンス発行が完了する', async () => {
const lisencesRepositoryMockValue =
makeDefaultLicensesRepositoryMockValue();
const usersRepositoryMockValue = makeDefaultUsersRepositoryMockValue();
const accountsRepositoryMockValue =
makeDefaultAccountsRepositoryMockValue();
const service = await makeLicensesServiceMock(
lisencesRepositoryMockValue,
usersRepositoryMockValue,
accountsRepositoryMockValue,
);
const body = new IssueCardLicensesRequest();
const token: AccessToken = { userId: '0001', role: '', tier: 5 };
body.createCount = 10;
const issueCardLicensesResponse: IssueCardLicensesResponse = {
cardLicenseKeys: [
'WZCETXC0Z9PQZ9GKRGGY',
'F0JD7EZEDBH4PQRQ83YF',
'H0HXBP5K9RW7T7JSVDJV',
'HKIWX54EESYL4X132223',
'363E81JR460UBHXGFXFI',
'70IKAPV9K6YMEVLTOXBY',
'1RJY1TRRYYTGF1LL9WLU',
'BXM0HKFO7IULTL0A1B36',
'XYLEWNY2LR6Q657CZE41',
'AEJWRFFSWRQYQQJ6WVLV',
],
};
expect(
await service.issueCardLicenseKeys(token.userId, body.createCount),
).toEqual(issueCardLicensesResponse);
});
it('カードライセンス発行に失敗した場合、エラーになる', async () => {
const lisencesRepositoryMockValue =
makeDefaultLicensesRepositoryMockValue();
lisencesRepositoryMockValue.createCardLicenses = new Error('DB failed');
const usersRepositoryMockValue = makeDefaultUsersRepositoryMockValue();
const accountsRepositoryMockValue =
makeDefaultAccountsRepositoryMockValue();
const service = await makeLicensesServiceMock(
lisencesRepositoryMockValue,
usersRepositoryMockValue,
accountsRepositoryMockValue,
);
const body = new IssueCardLicensesRequest();
const token: AccessToken = { userId: '0001', role: '', tier: 5 };
body.createCount = 1000;
await expect(
service.issueCardLicenseKeys(token.userId, body.createCount),
).rejects.toEqual(
new HttpException(
makeErrorResponse('E009999'),
HttpStatus.INTERNAL_SERVER_ERROR,
),
);
});
});
describe('DBテスト', () => {
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('カードライセンス発行が完了する(発行数が合っているか確認)', async () => {
const module = await makeTestingModule(source);
const { accountId } = await createAccount(source);
const { externalId } = await createUser(
source,
accountId,
'userId',
'admin',
);
const service = module.get<LicensesService>(LicensesService);
const issueCount = 1000;
await service.issueCardLicenseKeys(externalId, issueCount);
const dbSelectResult = await selectCardLicensesCount(source);
expect(dbSelectResult.count).toEqual(issueCount);
});
});

View File

@ -7,6 +7,7 @@ import { AccountNotFoundError } from '../../repositories/accounts/errors/types';
import { PoNumberAlreadyExistError } from '../../repositories/licenses/errors/types';
import { LicensesRepositoryService } from '../../repositories/licenses/licenses.repository.service';
import { UserNotFoundError } from '../../repositories/users/errors/types';
import { IssueCardLicensesResponse } from './types/types';
@Injectable()
export class LicensesService {
@ -38,6 +39,7 @@ export class LicensesService {
myAccountId = (await this.usersRepository.findUserByExternalId(userId))
.account_id;
} catch (e) {
this.logger.error(`error=${e}`);
switch (e.constructor) {
case UserNotFoundError:
throw new HttpException(
@ -58,6 +60,7 @@ export class LicensesService {
await this.accountsRepository.findAccountById(myAccountId)
).parent_account_id;
} catch (e) {
this.logger.error(`error=${e}`);
switch (e.constructor) {
case AccountNotFoundError:
throw new HttpException(
@ -94,4 +97,47 @@ export class LicensesService {
}
}
}
async issueCardLicenseKeys(
externalId: string,
createCount: number,
): Promise<IssueCardLicensesResponse> {
const issueCardLicensesResponse = new IssueCardLicensesResponse();
let myAccountId: number;
// ユーザIDからアカウントIDを取得する
try {
myAccountId = (
await this.usersRepository.findUserByExternalId(externalId)
).account_id;
} catch (e) {
this.logger.error(`error=${e}`);
switch (e.constructor) {
case UserNotFoundError:
throw new HttpException(
makeErrorResponse('E010204'),
HttpStatus.BAD_REQUEST,
);
default:
throw new HttpException(
makeErrorResponse('E009999'),
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
try {
const licenseKeys = await this.licensesRepository.createCardLicenses(
myAccountId,
createCount,
);
issueCardLicensesResponse.cardLicenseKeys = licenseKeys;
return issueCardLicensesResponse;
} catch (e) {
this.logger.error(`error=${e}`);
this.logger.error('get cardlicensekeys failed');
throw new HttpException(
makeErrorResponse('E009999'),
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
}

View File

@ -8,6 +8,7 @@ import { AccountsRepositoryService } from '../../../repositories/accounts/accoun
export type LicensesRepositoryMockValue = {
order: undefined | Error;
createCardLicenses: string[] | Error;
};
export type AccountsRepositoryMockValue = {
@ -44,12 +45,18 @@ export const makeLicensesServiceMock = async (
export const makeLicensesRepositoryMock = (
value: LicensesRepositoryMockValue,
) => {
const { order } = value;
const { order, createCardLicenses } = value;
return {
order:
order instanceof Error
? jest.fn<Promise<void>, []>().mockRejectedValue(order)
: jest.fn<Promise<void>, []>().mockResolvedValue(order),
createCardLicenses:
createCardLicenses instanceof Error
? jest.fn<Promise<void>, []>().mockRejectedValue(createCardLicenses)
: jest
.fn<Promise<string[]>, []>()
.mockResolvedValue(createCardLicenses),
};
};
@ -81,6 +88,18 @@ export const makeDefaultLicensesRepositoryMockValue =
(): LicensesRepositoryMockValue => {
return {
order: undefined,
createCardLicenses: [
'WZCETXC0Z9PQZ9GKRGGY',
'F0JD7EZEDBH4PQRQ83YF',
'H0HXBP5K9RW7T7JSVDJV',
'HKIWX54EESYL4X132223',
'363E81JR460UBHXGFXFI',
'70IKAPV9K6YMEVLTOXBY',
'1RJY1TRRYYTGF1LL9WLU',
'BXM0HKFO7IULTL0A1B36',
'XYLEWNY2LR6Q657CZE41',
'AEJWRFFSWRQYQQJ6WVLV',
],
};
};
export const makeDefaultUsersRepositoryMockValue =

View File

@ -0,0 +1,57 @@
import { DataSource } from 'typeorm';
import { User } from '../../../repositories/users/entity/user.entity';
import { Account } from '../../../repositories/accounts/entity/account.entity';
import { CardLicense } from '../../../repositories/licenses/entity/license.entity';
export const createAccount = async (
datasource: DataSource,
): Promise<{ accountId: number }> => {
const { identifiers } = await datasource.getRepository(Account).insert({
tier: 1,
country: 'JP',
delegation_permission: false,
locked: false,
company_name: 'test inc.',
verified: true,
deleted_at: '',
created_by: 'test_runner',
created_at: new Date(),
updated_by: 'updater',
updated_at: new Date(),
});
const account = identifiers.pop() as Account;
return { accountId: account.id };
};
export const createUser = async (
datasource: DataSource,
accountId: number,
external_id: string,
role: string,
author_id?: string | undefined,
): Promise<{ userId: number; externalId: 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,
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 };
};
export const selectCardLicensesCount = async (
datasource: DataSource,
): Promise<{ count: number }> => {
const count = await datasource.getRepository(CardLicense).count();
return { count: count };
};

View File

@ -40,7 +40,7 @@ export class License {
@PrimaryGeneratedColumn()
id: number;
@Column()
@Column({ nullable: true })
expiry_date: Date;
@Column()
@ -52,16 +52,16 @@ export class License {
@Column()
status: string;
@Column()
@Column({ nullable: true })
allocated_user_id: number;
@Column()
@Column({ nullable: true })
order_id: number;
@Column()
@Column({ nullable: true })
deleted_at: Date;
@Column()
@Column({ nullable: true })
delete_order_id: number;
}
@Entity({ name: 'licenses_history' })
@ -84,3 +84,27 @@ export class LicenseHistory {
@Column()
exchange_type: string;
}
@Entity({ name: 'card_license_issue' })
export class CardLicenseIssue {
@PrimaryGeneratedColumn()
id: number;
@Column()
issued_at: Date;
}
@Entity({ name: 'card_licenses' })
export class CardLicense {
@PrimaryGeneratedColumn()
license_id: number;
@Column()
issue_id: number;
@Column()
card_license_key: string;
@Column({ nullable: true })
activated_at: Date;
}

View File

@ -1,10 +1,10 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { LicenseOrder } from './entity/license.entity';
import { CardLicense, CardLicenseIssue, License, LicenseOrder } from './entity/license.entity';
import { LicensesRepositoryService } from './licenses.repository.service';
@Module({
imports: [TypeOrmModule.forFeature([LicenseOrder])],
imports: [TypeOrmModule.forFeature([LicenseOrder,License,CardLicense,CardLicenseIssue])],
providers: [LicensesRepositoryService],
exports: [LicensesRepositoryService],
})

View File

@ -1,9 +1,17 @@
import { Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { LicenseOrder } from './entity/license.entity';
import { DataSource, In } from 'typeorm';
import {
LicenseOrder,
License,
CardLicenseIssue,
CardLicense,
} from './entity/license.entity';
import {
CARD_LICENSE_LENGTH,
LICENSE_ALLOCATED_STATUS,
LICENSE_STATUS_ISSUE_REQUESTING,
LICENSE_STATUS_ISSUED,
LICENSE_TYPE,
} from '../../constants';
import { PoNumberAlreadyExistError } from './errors/types';
@ -57,4 +65,124 @@ export class LicensesRepositoryService {
);
return createdEntity;
}
/**
*
* @param accountId
* @param count
* @returns string[]
*/
async createCardLicenses(
accountId: number,
count: number,
): Promise<string[]> {
const licenseKeys: string[] = [];
await this.dataSource.transaction(async (entityManager) => {
const licensesRepo = entityManager.getRepository(License);
const cardLicenseRepo = entityManager.getRepository(CardLicense);
const cardLicenseIssueRepo =
entityManager.getRepository(CardLicenseIssue);
const licenses = [];
// ライセンステーブルを作成するBULK INSERT)
for (let i = 0; i < count; i++) {
const license = new License();
license.account_id = accountId;
license.status = LICENSE_ALLOCATED_STATUS.UNALLOCATED;
license.type = LICENSE_TYPE.CARD;
licenses.push(license);
}
const savedLicenses = await licensesRepo.save(licenses);
// カードライセンス発行テーブルを作成する
const cardLicenseIssue = new CardLicenseIssue();
cardLicenseIssue.issued_at = new Date();
const newCardLicenseIssue = cardLicenseIssueRepo.create(cardLicenseIssue);
const savedCardLicensesIssue = await cardLicenseIssueRepo.save(
newCardLicenseIssue,
);
let isDuplicateKeysExist = true;
let generateCount = count;
while (isDuplicateKeysExist) {
const generateKeys = await this.generateLicenseKeys(
generateCount,
licenseKeys,
);
// licenseKeysが既にカードライセンステーブルに存在するかチェック
const existingCardLicenses = await cardLicenseRepo.find({
where: {
card_license_key: In(generateKeys),
},
});
if (existingCardLicenses.length > 0) {
// 重複分を配列から削除
existingCardLicenses.forEach((existKey) => {
generateKeys.splice(
generateKeys.indexOf(existKey.card_license_key),
1,
);
});
// 重複がなかったものを格納
generateKeys.forEach((keys) => {
licenseKeys.push(keys);
});
// 重複分の再生成を行う
generateCount = existingCardLicenses.length;
continue;
}
// 重複がない場合は本ループで作成したkeyをすべて格納
generateKeys.forEach((keys) => {
licenseKeys.push(keys);
});
// 重複がない場合はループを終了
isDuplicateKeysExist = false;
}
const cardLicenses = [];
// カードライセンステーブルを作成するBULK INSERT)
for (let i = 0; i < count; i++) {
const cardLicense = new CardLicense();
cardLicense.license_id = savedLicenses[i].id; // Licenseテーブルの自動採番されたIDを挿入
cardLicense.issue_id = savedCardLicensesIssue.id; // CardLicenseIssueテーブルの自動採番されたIDを挿入
cardLicense.card_license_key = licenseKeys[i];
cardLicenses.push(cardLicense);
}
await cardLicenseRepo.save(cardLicenses);
});
return licenseKeys;
}
/**
*
* @param count
* @param existingLicenseKeys
* @returns licenseKeys
*/
async generateLicenseKeys(
count: number,
existingLicenseKeys: string[],
): Promise<string[]> {
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
const licenseKeys: string[] = [];
while (licenseKeys.length < count) {
let licenseKey = '';
for (let i = 0; i < CARD_LICENSE_LENGTH; i++) {
const randomIndex = Math.floor(Math.random() * characters.length);
licenseKey += characters[randomIndex];
}
// 重複しない一意のライセンスキーを生成するまで繰り返す
if (
!licenseKeys.includes(licenseKey) &&
!existingLicenseKeys.includes(licenseKey)
) {
licenseKeys.push(licenseKey);
}
}
return licenseKeys;
}
}