Merged PR 383: API実装(パートナー取得API)

## 概要
[Task2540: API実装(パートナー取得API)](https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/fa4924a4-d079-4fab-9fb5-a9a11eb205f0/_workitems/edit/2540)

パートナー取得APIを実装しました。

## レビューポイント
・データ取得方法が適切かどうか。
以下の優先順位を意識して作成したが適切か?また、意識できていない実装になっていないか?
①QueryBuilderを使用せずに処理する
②RDB、adb2cへのアクセス回数を最小限にする

## UIの変更
なし
## 動作確認状況
ローカルで動作確認済み、UT実施済み

## 補足
プライマリ、セカンダリ管理者IDがない場合のテストはUTでは実装せず、ローカルでの動作確認で正常に動作することを確認しました。
(プライマリ、セカンダリ管理者IDを指定してアカウントを作成するテストユーティリティを作成する必要があるが、あまり汎用的には思えず作成する手間が惜しかったため)
This commit is contained in:
oura.a 2023-09-08 09:45:10 +00:00
parent 82fb224d67
commit 2812bc3d20
11 changed files with 358 additions and 46 deletions

View File

@ -9,6 +9,7 @@ import { UsersRepositoryService } from '../../repositories/users/users.repositor
import { BlobstorageService } from '../../gateways/blobstorage/blobstorage.service'; import { BlobstorageService } from '../../gateways/blobstorage/blobstorage.service';
import { AccountsRepositoryService } from '../../repositories/accounts/accounts.repository.service'; import { AccountsRepositoryService } from '../../repositories/accounts/accounts.repository.service';
import { Account } from '../../repositories/accounts/entity/account.entity'; import { Account } from '../../repositories/accounts/entity/account.entity';
import { AdB2cUser } from '../../gateways/adb2c/types/types';
// ### ユニットテスト用コード以外では絶対に使用してはいけないダーティな手段を使用しているが、他の箇所では使用しないこと ### // ### ユニットテスト用コード以外では絶対に使用してはいけないダーティな手段を使用しているが、他の箇所では使用しないこと ###
@ -28,6 +29,10 @@ export const overrideAdB2cService = <TService>(
username: string, username: string,
) => Promise<{ sub: string } | ConflictError>; ) => Promise<{ sub: string } | ConflictError>;
deleteUser?: (externalId: string, context: Context) => Promise<void>; deleteUser?: (externalId: string, context: Context) => Promise<void>;
getUsers?: (
context: Context,
externalIds: string[],
) => Promise<AdB2cUser[]>;
}, },
): void => { ): void => {
// テストコードでのみ許される強引な方法でprivateメンバ変数の参照を取得 // テストコードでのみ許される強引な方法でprivateメンバ変数の参照を取得
@ -44,6 +49,12 @@ export const overrideAdB2cService = <TService>(
writable: true, writable: true,
}); });
} }
if (overrides.getUsers) {
Object.defineProperty(obj, obj.getUsers.name, {
value: overrides.getUsers,
writable: true,
});
}
}; };
/** /**

View File

@ -149,6 +149,8 @@ export const makeTestAccount = async (
datasource: DataSource, datasource: DataSource,
defaultAccountValue?: AccountDefault, defaultAccountValue?: AccountDefault,
defaultAdminUserValue?: UserDefault, defaultAdminUserValue?: UserDefault,
isPrimaryAdminNotExist?: boolean,
isSecondaryAdminNotExist?: boolean,
): Promise<{ account: Account; admin: User }> => { ): Promise<{ account: Account; admin: User }> => {
let accountId: number; let accountId: number;
let userId: number; let userId: number;
@ -198,10 +200,15 @@ export const makeTestAccount = async (
} }
// Accountの管理者を設定する // Accountの管理者を設定する
let secondaryAdminUserId = null;
if (isPrimaryAdminNotExist && !isSecondaryAdminNotExist) {
secondaryAdminUserId = userId;
}
await datasource.getRepository(Account).update( await datasource.getRepository(Account).update(
{ id: accountId }, { id: accountId },
{ {
primary_admin_user_id: userId, primary_admin_user_id: isPrimaryAdminNotExist ? null : userId,
secondary_admin_user_id: secondaryAdminUserId,
}, },
); );

View File

@ -238,3 +238,11 @@ export const OPTION_ITEM_VALUE_TYPE = {
BLANK: 'Blank', BLANK: 'Blank',
LAST_INPUT: 'LastInput', LAST_INPUT: 'LastInput',
} as const; } as const;
/**
* ADB2Cユーザのidentity.signInType
* @const {string[]}
*/
export const ADB2C_SIGN_IN_TYPE = {
EAMILADDRESS: 'emailAddress',
} as const;

View File

@ -957,45 +957,13 @@ export class AccountsController {
const { userId } = jwt.decode(token, { json: true }) as AccessToken; const { userId } = jwt.decode(token, { json: true }) as AccessToken;
const context = makeContext(userId); const context = makeContext(userId);
// TODO: パートナー取得APIで実装 const response = await this.accountService.getPartners(
// await this.accountService.getPartners( context,
// context, userId,
// body.limit, limit,
// body.offset, offset,
// ); );
// 仮のreturn return response;
return {
total: 1,
partners: [
{
name: 'testA',
tier: 5,
accountId: 1,
country: 'US',
primaryAdmin: 'nameA',
email: 'aaa@example.com',
dealerManagement: true,
},
{
name: 'testB',
tier: 5,
accountId: 2,
country: 'US',
primaryAdmin: 'nameB',
email: 'bbb@example.com',
dealerManagement: false,
},
{
name: 'testC',
tier: 5,
accountId: 1,
country: 'US',
primaryAdmin: 'nothing',
email: 'nothing',
dealerManagement: false,
},
],
};
} }
} }

View File

@ -37,6 +37,7 @@ import {
import { AccountsService } from './accounts.service'; import { AccountsService } from './accounts.service';
import { Context, makeContext } from '../../common/log'; import { Context, makeContext } from '../../common/log';
import { import {
ADB2C_SIGN_IN_TYPE,
LICENSE_ALLOCATED_STATUS, LICENSE_ALLOCATED_STATUS,
LICENSE_ISSUE_STATUS, LICENSE_ISSUE_STATUS,
LICENSE_TYPE, LICENSE_TYPE,
@ -61,6 +62,7 @@ import {
selectOrderLicense, selectOrderLicense,
} from '../licenses/test/utility'; } from '../licenses/test/utility';
import { WorktypesRepositoryService } from '../../repositories/worktypes/worktypes.repository.service'; import { WorktypesRepositoryService } from '../../repositories/worktypes/worktypes.repository.service';
import { AdB2cUser } from '../../gateways/adb2c/types/types';
import { Worktype } from '../../repositories/worktypes/entity/worktype.entity'; import { Worktype } from '../../repositories/worktypes/entity/worktype.entity';
describe('createAccount', () => { describe('createAccount', () => {
@ -4155,3 +4157,144 @@ describe('ライセンス発行キャンセル', () => {
); );
}); });
}); });
describe('パートナー一覧取得', () => {
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 service = module.get<AccountsService>(AccountsService);
const { tier1Accounts: tier1Accounts, tier2Accounts: tier2Accounts } =
await makeHierarchicalAccounts(source);
const tier1Difference = await makeTestAccount(source, {
tier: 1,
});
const tier2_3 = await makeTestAccount(
source,
{
parent_account_id: tier1Accounts[0].account.id,
tier: 2,
},
{},
true,
false,
);
const tier2_4 = await makeTestAccount(
source,
{
parent_account_id: tier1Accounts[0].account.id,
tier: 2,
},
{},
true,
true,
);
await makeTestAccount(source, {
parent_account_id: tier1Difference.account.id,
tier: 2,
});
const adb2cReturn = [
{
id: tier2Accounts[0].users[0].external_id,
displayName: 'partner1',
identities: [
{
signInType: ADB2C_SIGN_IN_TYPE.EAMILADDRESS,
issuer: 'issuer',
issuerAssignedId: 'partner1@example.com',
},
],
},
{
id: tier2Accounts[1].users[0].external_id,
displayName: 'partner2',
identities: [
{
signInType: ADB2C_SIGN_IN_TYPE.EAMILADDRESS,
issuer: 'issuer',
issuerAssignedId: 'partner2@example.com',
},
],
},
] as AdB2cUser[];
overrideAdB2cService(service, {
getUsers: async (_context: Context, _externalIds: string[]) => {
return adb2cReturn;
},
});
const partners = await service.getPartners(
makeContext('trackingId'),
tier1Accounts[0].users[0].external_id,
15,
0,
);
// 違うアカウントのパートナーは取得していないこと
expect(partners.total).toBe(4);
// 会社名の昇順に取得できていること
expect(partners.partners[0].name).toBe(
tier2Accounts[1].account.company_name,
);
expect(partners.partners[1].name).toBe(
tier2Accounts[0].account.company_name,
);
expect(partners.partners[2].name).toBe(tier2_3.account.company_name);
expect(partners.partners[3].name).toBe(tier2_4.account.company_name);
expect(partners.partners[0].email).toBe('partner2@example.com');
expect(partners.partners[1].email).toBe('partner1@example.com');
expect(partners.partners[2].email).toBeUndefined;
expect(partners.partners[3].email).toBeUndefined;
expect(partners.partners[0].tier).toBe(tier2Accounts[1].account.tier);
expect(partners.partners[0].country).toBe(tier2Accounts[1].account.country);
expect(partners.partners[0].accountId).toBe(tier2Accounts[1].account.id);
expect(partners.partners[0].tier).toBe(tier2Accounts[1].account.tier);
expect(partners.partners[0].primaryAdmin).toBe('partner2');
expect(partners.partners[0].dealerManagement).toBe(
tier2Accounts[1].account.delegation_permission,
);
});
it('パートナー一覧を取得する(パートナーが0件の場合)', async () => {
const module = await makeTestingModule(source);
const service = module.get<AccountsService>(AccountsService);
const account = await makeTestAccount(source, {
tier: 1,
});
const adb2cReturn = [{}] as AdB2cUser[];
overrideAdB2cService(service, {
getUsers: async (_context: Context, _externalIds: string[]) => {
return adb2cReturn;
},
});
const partners = await service.getPartners(
makeContext('trackingId'),
account.admin.external_id,
15,
0,
);
// 結果が0件で成功となること
expect(partners.total).toBe(0);
});
});

View File

@ -10,7 +10,7 @@ import {
} from '../../gateways/adb2c/adb2c.service'; } from '../../gateways/adb2c/adb2c.service';
import { Account } from '../../repositories/accounts/entity/account.entity'; import { Account } from '../../repositories/accounts/entity/account.entity';
import { User } from '../../repositories/users/entity/user.entity'; import { User } from '../../repositories/users/entity/user.entity';
import { TIERS, USER_ROLES } from '../../constants'; import { TIERS, USER_ROLES, ADB2C_SIGN_IN_TYPE } from '../../constants';
import { makeErrorResponse } from '../../common/error/makeErrorResponse'; import { makeErrorResponse } from '../../common/error/makeErrorResponse';
import { import {
TypistGroup, TypistGroup,
@ -23,6 +23,7 @@ import {
GetMyAccountResponse, GetMyAccountResponse,
GetTypistGroupResponse, GetTypistGroupResponse,
GetWorktypesResponse, GetWorktypesResponse,
GetPartnersResponse,
} from './types/types'; } from './types/types';
import { import {
DateWithZeroTime, DateWithZeroTime,
@ -1312,4 +1313,85 @@ export class AccountsService {
); );
} }
} }
/**
*
* @param context
* @param externalId
* @param limit
* @param offset
* @returns GetPartnersResponse
*/
async getPartners(
context: Context,
externalId: string,
limit: number,
offset: number,
): Promise<GetPartnersResponse> {
this.logger.log(
`[IN] [${context.trackingId}] ${this.getPartners.name} | params: { ` +
`externalId: ${externalId}, ` +
`limit: ${limit}, ` +
`offset: ${offset}, };`,
);
try {
const { account_id: accountId } =
await this.usersRepository.findUserByExternalId(externalId);
const partners = await this.accountRepository.getPartners(
accountId,
limit,
offset,
);
// DBから取得したユーザーの外部IDをもとにADB2Cからユーザーを取得する
let externalIds = partners.partnersInfo.map(
(x) => x.primaryAccountExternalId,
);
externalIds = externalIds.filter((item) => item !== undefined);
const adb2cUsers = await this.adB2cService.getUsers(context, externalIds);
// DBから取得した情報とADB2Cから取得した情報をマージ
const response = partners.partnersInfo.map((db) => {
const adb2cUser = adb2cUsers.find(
(adb2c) => db.primaryAccountExternalId === adb2c.id,
);
let primaryAdmin = undefined;
let mail = undefined;
if (adb2cUser) {
primaryAdmin = adb2cUser.displayName;
mail = adb2cUser.identities.find(
(identity) =>
identity.signInType === ADB2C_SIGN_IN_TYPE.EAMILADDRESS,
).issuerAssignedId;
}
return {
name: db.name,
tier: db.tier,
accountId: db.accountId,
country: db.country,
primaryAdmin: primaryAdmin,
email: mail,
dealerManagement: db.dealerManagement,
};
});
return {
total: partners.total,
partners: response,
};
} catch (e) {
this.logger.error(`error=${e}`);
if (e instanceof Error) {
throw new HttpException(
makeErrorResponse('E009999'),
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
} finally {
this.logger.log(`[OUT] [${context.trackingId}] ${this.getPartners.name}`);
}
}
} }

View File

@ -480,3 +480,13 @@ export class GetPartnersResponse {
@ApiProperty({ type: [Partner] }) @ApiProperty({ type: [Partner] })
partners: Partner[]; partners: Partner[];
} }
// RepositoryからPartnerLicenseInfoに関する情報を取得する際の型
export type PartnerInfoFromDb = {
name: string;
tier: number;
accountId: number;
country: string;
primaryAccountExternalId: string;
dealerManagement: boolean;
};

View File

@ -17,6 +17,7 @@ import {
TaskListSortableAttribute, TaskListSortableAttribute,
} from '../../../common/types/sort'; } from '../../../common/types/sort';
import { AdB2cUser } from '../../../gateways/adb2c/types/types'; import { AdB2cUser } from '../../../gateways/adb2c/types/types';
import { ADB2C_SIGN_IN_TYPE } from '../../../constants';
export type SortCriteriaRepositoryMockValue = { export type SortCriteriaRepositoryMockValue = {
updateSortCriteria: SortCriteria | Error; updateSortCriteria: SortCriteria | Error;
@ -403,7 +404,7 @@ const AdB2cMockUsers: AdB2cUser[] = [
displayName: 'test1', displayName: 'test1',
identities: [ identities: [
{ {
signInType: 'emailAddress', signInType: ADB2C_SIGN_IN_TYPE.EAMILADDRESS,
issuer: 'issuer', issuer: 'issuer',
issuerAssignedId: 'test1@mail.com', issuerAssignedId: 'test1@mail.com',
}, },
@ -414,7 +415,7 @@ const AdB2cMockUsers: AdB2cUser[] = [
displayName: 'test2', displayName: 'test2',
identities: [ identities: [
{ {
signInType: 'emailAddress', signInType: ADB2C_SIGN_IN_TYPE.EAMILADDRESS,
issuer: 'issuer', issuer: 'issuer',
issuerAssignedId: 'test2@mail.com', issuerAssignedId: 'test2@mail.com',
}, },
@ -425,7 +426,7 @@ const AdB2cMockUsers: AdB2cUser[] = [
displayName: 'test3', displayName: 'test3',
identities: [ identities: [
{ {
signInType: 'emailAddress', signInType: ADB2C_SIGN_IN_TYPE.EAMILADDRESS,
issuer: 'issuer', issuer: 'issuer',
issuerAssignedId: 'test3@mail.com', issuerAssignedId: 'test3@mail.com',
}, },

View File

@ -33,6 +33,7 @@ import {
UserNotFoundError, UserNotFoundError,
} from '../../repositories/users/errors/types'; } from '../../repositories/users/errors/types';
import { import {
ADB2C_SIGN_IN_TYPE,
LICENSE_EXPIRATION_THRESHOLD_DAYS, LICENSE_EXPIRATION_THRESHOLD_DAYS,
USER_LICENSE_STATUS, USER_LICENSE_STATUS,
USER_ROLES, USER_ROLES,
@ -470,7 +471,7 @@ export class UsersService {
// メールアドレスを取得する // メールアドレスを取得する
const mail = adb2cUser.identities.find( const mail = adb2cUser.identities.find(
(identity) => identity.signInType === 'emailAddress', (identity) => identity.signInType === ADB2C_SIGN_IN_TYPE.EAMILADDRESS,
).issuerAssignedId; ).issuerAssignedId;
let status = USER_LICENSE_STATUS.NORMAL; let status = USER_LICENSE_STATUS.NORMAL;

View File

@ -7,6 +7,7 @@ import axios from 'axios';
import { Aadb2cUser, B2cMetadata, JwkSignKey } from '../../common/token'; import { Aadb2cUser, B2cMetadata, JwkSignKey } from '../../common/token';
import { AdB2cResponse, AdB2cUser } from './types/types'; import { AdB2cResponse, AdB2cUser } from './types/types';
import { Context } from '../../common/log'; import { Context } from '../../common/log';
import { ADB2C_SIGN_IN_TYPE } from '../../constants';
export type ConflictError = { export type ConflictError = {
reason: 'email'; reason: 'email';
@ -74,7 +75,7 @@ export class AdB2cService {
}, },
identities: [ identities: [
{ {
signinType: 'emailAddress', signinType: ADB2C_SIGN_IN_TYPE.EAMILADDRESS,
issuer: `${this.tenantName}.onmicrosoft.com`, issuer: `${this.tenantName}.onmicrosoft.com`,
issuerAssignedId: email, issuerAssignedId: email,
}, },

View File

@ -27,6 +27,7 @@ import {
import { import {
LicenseSummaryInfo, LicenseSummaryInfo,
PartnerLicenseInfoForRepository, PartnerLicenseInfoForRepository,
PartnerInfoFromDb,
} from '../../features/accounts/types/types'; } from '../../features/accounts/types/types';
import { AccountNotFoundError } from './errors/types'; import { AccountNotFoundError } from './errors/types';
import { import {
@ -685,4 +686,83 @@ export class AccountsRepositoryService {
await licenseRepo.delete({ order_id: targetOrder.id }); await licenseRepo.delete({ order_id: targetOrder.id });
}); });
} }
/**
* IDをもとに
* @param id
* @param limit
* @param offset
* @returns total: 総件数
* @returns partners: DBから取得できるパートナー一覧情報
*/
async getPartners(
id: number,
limit: number,
offset: number,
): Promise<{
total: number;
partnersInfo: PartnerInfoFromDb[];
}> {
return await this.dataSource.transaction(async (entityManager) => {
const accountRepo = entityManager.getRepository(Account);
// limit/offsetによらない総件数を取得する
const total = await accountRepo.count({
where: {
parent_account_id: id,
},
});
const partnerAccounts = await accountRepo.find({
where: {
parent_account_id: id,
},
order: {
company_name: 'ASC',
},
take: limit,
skip: offset,
});
// ADB2Cから情報を取得するための外部ユーザIDを取得する念のためプライマリ管理者IDが存在しない場合を考慮
const primaryUserIds = partnerAccounts.map((x) => {
if (x.primary_admin_user_id) {
return x.primary_admin_user_id;
} else if (x.secondary_admin_user_id) {
return x.secondary_admin_user_id;
}
});
const userRepo = entityManager.getRepository(User);
const primaryUsers = await userRepo.find({
where: {
id: In(primaryUserIds),
},
});
// アカウント情報とプライマリ管理者の外部ユーザIDをマージ
const partners = partnerAccounts.map((account) => {
const primaryUser = primaryUsers.find(
(user) =>
user.id === account.primary_admin_user_id ||
user.id === account.secondary_admin_user_id,
);
const primaryAccountExternalId = primaryUser
? primaryUser.external_id
: undefined;
return {
name: account.company_name,
tier: account.tier,
accountId: account.id,
country: account.country,
primaryAccountExternalId: primaryAccountExternalId,
dealerManagement: account.delegation_permission,
};
});
return {
total: total,
partnersInfo: partners,
};
});
}
} }