diff --git a/src/api/entities/NumberedPortfolio.ts b/src/api/entities/NumberedPortfolio.ts index b07ddf88e6..27e3658809 100644 --- a/src/api/entities/NumberedPortfolio.ts +++ b/src/api/entities/NumberedPortfolio.ts @@ -1,7 +1,7 @@ import BigNumber from 'bignumber.js'; import { Portfolio } from '~/api/entities'; -import { deletePortfolio } from '~/api/procedures'; +import { deletePortfolio, modifyNamePortfolio, ModifyNamePortfolioParams } from '~/api/procedures'; import { Context, TransactionQueue } from '~/base'; export interface UniqueIdentifiers { @@ -49,4 +49,18 @@ export class NumberedPortfolio extends Portfolio { } = this; return deletePortfolio.prepare({ did, id }, this.context); } + + /** + * Rename portfolio + */ + public async modifyName( + args: ModifyNamePortfolioParams + ): Promise> { + const { + id, + owner: { did }, + } = this; + const { name } = args; + return modifyNamePortfolio.prepare({ did, id, name }, this.context); + } } diff --git a/src/api/entities/__tests__/NumberedPortfolio.ts b/src/api/entities/__tests__/NumberedPortfolio.ts index 20d4c21071..e7459407c8 100644 --- a/src/api/entities/__tests__/NumberedPortfolio.ts +++ b/src/api/entities/__tests__/NumberedPortfolio.ts @@ -2,7 +2,7 @@ import BigNumber from 'bignumber.js'; import sinon from 'sinon'; import { Entity, Identity, NumberedPortfolio } from '~/api/entities'; -import { deletePortfolio } from '~/api/procedures'; +import { deletePortfolio, modifyNamePortfolio } from '~/api/procedures'; import { Context, TransactionQueue } from '~/base'; import { dsMockUtils } from '~/testUtils/mocks'; @@ -70,4 +70,23 @@ describe('Numberedortfolio class', () => { expect(queue).toBe(expectedQueue); }); }); + + describe('method: modifyName', () => { + test('should prepare the procedure and return the resulting transaction queue', async () => { + const id = new BigNumber(1); + const did = 'someDid'; + const name = 'newName'; + const numberedPortfolio = new NumberedPortfolio({ id, did }, context); + const expectedQueue = ('someQueue' as unknown) as TransactionQueue; + + sinon + .stub(modifyNamePortfolio, 'prepare') + .withArgs({ id, did, name }, context) + .resolves(expectedQueue); + + const queue = await numberedPortfolio.modifyName({ name }); + + expect(queue).toBe(expectedQueue); + }); + }); }); diff --git a/src/api/procedures/__tests__/modifyNamePortfolio.ts b/src/api/procedures/__tests__/modifyNamePortfolio.ts new file mode 100644 index 0000000000..d5ae6a9e8b --- /dev/null +++ b/src/api/procedures/__tests__/modifyNamePortfolio.ts @@ -0,0 +1,170 @@ +import { Bytes, u64 } from '@polkadot/types'; +import BigNumber from 'bignumber.js'; +import { IdentityId } from 'polymesh-types/types'; +import sinon from 'sinon'; + +import { Params, prepareModifyNamePortfolio } from '~/api/procedures/modifyNamePortfolio'; +import { Context } from '~/base'; +import { dsMockUtils, entityMockUtils, procedureMockUtils } from '~/testUtils/mocks'; +import { Mocked } from '~/testUtils/types'; +import { NumberedPortfolio } from '~/types'; +import { tuple } from '~/types/utils'; +import * as utilsModule from '~/utils'; + +jest.mock( + '~/api/entities/NumberedPortfolio', + require('~/testUtils/mocks/entities').mockNumberedPortfolioModule( + '~/api/entities/NumberedPortfolio' + ) +); + +describe('modifyNamePortfolio procedure', () => { + const id = new BigNumber(1); + const did = 'someDid'; + const identityId = dsMockUtils.createMockIdentityId(did); + const rawPortfolioNumber = dsMockUtils.createMockU64(id.toNumber()); + let mockContext: Mocked; + let stringToIdentityIdStub: sinon.SinonStub<[string, Context], IdentityId>; + let numberToU64Stub: sinon.SinonStub<[number | BigNumber, Context], u64>; + let bytesToStringStub: sinon.SinonStub<[Bytes], string>; + let stringToBytesStub: sinon.SinonStub<[string, Context], Bytes>; + + beforeAll(() => { + dsMockUtils.initMocks(); + procedureMockUtils.initMocks(); + entityMockUtils.initMocks(); + stringToIdentityIdStub = sinon.stub(utilsModule, 'stringToIdentityId'); + numberToU64Stub = sinon.stub(utilsModule, 'numberToU64'); + bytesToStringStub = sinon.stub(utilsModule, 'bytesToString'); + stringToBytesStub = sinon.stub(utilsModule, 'stringToBytes'); + }); + + beforeEach(() => { + mockContext = dsMockUtils.getContextInstance(); + stringToIdentityIdStub.withArgs(did, mockContext).returns(identityId); + numberToU64Stub.withArgs(id, mockContext).returns(rawPortfolioNumber); + entityMockUtils.configureMocks({ + numberedPortfolioOptions: { + isOwned: true, + }, + }); + }); + + afterEach(() => { + entityMockUtils.reset(); + procedureMockUtils.reset(); + dsMockUtils.reset(); + }); + + afterAll(() => { + entityMockUtils.cleanup(); + procedureMockUtils.cleanup(); + dsMockUtils.cleanup(); + }); + + test('should throw an error if the portfolio does not exist', async () => { + entityMockUtils.configureMocks({ + numberedPortfolioOptions: { + isOwned: false, + }, + }); + dsMockUtils.createQueryStub('portfolio', 'portfolios').returns(dsMockUtils.createMockBytes()); + dsMockUtils.createQueryStub('portfolio', 'portfolios', { + entries: [], + }); + + const proc = procedureMockUtils.getInstance(mockContext); + + return expect( + prepareModifyNamePortfolio.call(proc, { + id, + did, + name: 'newName', + }) + ).rejects.toThrow('You are not the owner of this Portfolio'); + }); + + test('should throw an error if new name is the same name currently in the pportfolio', async () => { + const newName = 'newName'; + const rawNewName = dsMockUtils.createMockBytes(newName); + + bytesToStringStub.withArgs(rawNewName).returns(newName); + dsMockUtils.createQueryStub('portfolio', 'portfolios').returns(rawNewName); + dsMockUtils.createQueryStub('portfolio', 'portfolios', { + entries: [], + }); + + const proc = procedureMockUtils.getInstance(mockContext); + + return expect( + prepareModifyNamePortfolio.call(proc, { + id, + did, + name: newName, + }) + ).rejects.toThrow('New name is the same as current name'); + }); + + test('should throw an error if there is a portfolio with the new name', async () => { + const portfolioName = 'portfolioName'; + const rawPortfolioName = dsMockUtils.createMockBytes(portfolioName); + const entriePortfolioName = 'someName'; + const rawEntriePortfolioName = dsMockUtils.createMockBytes(entriePortfolioName); + const portfolioNames = [ + { + name: entriePortfolioName, + }, + ]; + bytesToStringStub.withArgs(rawPortfolioName).returns(portfolioName); + bytesToStringStub.withArgs(rawEntriePortfolioName).returns(entriePortfolioName); + + const rawPortfolios = portfolioNames.map(({ name }) => + tuple(dsMockUtils.createMockBytes(name)) + ); + const portfolioEntries = rawPortfolios.map(([name]) => tuple([], name)); + + dsMockUtils.createQueryStub('portfolio', 'portfolios').returns(rawPortfolioName); + dsMockUtils.createQueryStub('portfolio', 'portfolios', { + entries: [portfolioEntries[0]], + }); + + const proc = procedureMockUtils.getInstance(mockContext); + + return expect( + prepareModifyNamePortfolio.call(proc, { + id, + did, + name: portfolioNames[0].name, + }) + ).rejects.toThrow('A portfolio with that name already exists'); + }); + + test('should add a rename portfolio transaction to the queue', async () => { + const portfolioName = 'portfolioName'; + const rawPortfolioName = dsMockUtils.createMockBytes(portfolioName); + const newName = 'newName'; + const rawNewName = dsMockUtils.createMockBytes(newName); + + bytesToStringStub.withArgs(rawPortfolioName).returns(portfolioName); + stringToBytesStub.returns(rawNewName); + + dsMockUtils.createQueryStub('portfolio', 'portfolios').returns(rawPortfolioName); + dsMockUtils.createQueryStub('portfolio', 'portfolios', { + entries: [], + }); + + const transaction = dsMockUtils.createTxStub('portfolio', 'renamePortfolio'); + const proc = procedureMockUtils.getInstance(mockContext); + + const result = await prepareModifyNamePortfolio.call(proc, { + id, + did, + name: newName, + }); + + const addTransactionStub = procedureMockUtils.getAddTransactionStub(); + + sinon.assert.calledWith(addTransactionStub, transaction, {}, rawPortfolioNumber, rawNewName); + expect(result.id).toBe(id); + }); +}); diff --git a/src/api/procedures/index.ts b/src/api/procedures/index.ts index 61a70e5ab1..a98d714cb5 100644 --- a/src/api/procedures/index.ts +++ b/src/api/procedures/index.ts @@ -40,3 +40,4 @@ export { transferTokenOwnership, TransferTokenOwnershipParams } from './transfer // export { voteOnProposal, VoteOnProposalParams } from './voteOnProposal'; export { removePrimaryIssuanceAgent } from './removePrimaryIssuanceAgent'; export { deletePortfolio } from './deletePortfolio'; +export { modifyNamePortfolio, ModifyNamePortfolioParams } from './modifyNamePortfolio'; diff --git a/src/api/procedures/modifyNamePortfolio.ts b/src/api/procedures/modifyNamePortfolio.ts new file mode 100644 index 0000000000..023b6fa86a --- /dev/null +++ b/src/api/procedures/modifyNamePortfolio.ts @@ -0,0 +1,80 @@ +import BigNumber from 'bignumber.js'; + +import { NumberedPortfolio } from '~/api/entities'; +import { PolymeshError, Procedure } from '~/base'; +import { ErrorCode } from '~/types'; +import { bytesToString, numberToU64, stringToBytes, stringToIdentityId } from '~/utils'; + +export type ModifyNamePortfolioParams = { name: string }; + +/** + * @hidden + */ +export type Params = { did: string; id: BigNumber } & ModifyNamePortfolioParams; + +/** + * @hidden + */ +export async function prepareModifyNamePortfolio( + this: Procedure, + args: Params +): Promise { + const { + context: { + polymeshApi: { + query: { portfolio: queryPortfolio }, + tx: { portfolio }, + }, + }, + context, + } = this; + + const { did, id, name: newName } = args; + + const numberedPortfolio = new NumberedPortfolio({ did, id }, context); + const identityId = stringToIdentityId(did, context); + const rawPortfolioNumber = numberToU64(id, context); + + const [isOwned, rawPortfolioName, rawPortfolios] = await Promise.all([ + numberedPortfolio.isOwned(), + queryPortfolio.portfolios(identityId, rawPortfolioNumber), + queryPortfolio.portfolios.entries(identityId), + ]); + + if (!isOwned) { + throw new PolymeshError({ + code: ErrorCode.ValidationError, + message: 'You are not the owner of this Portfolio', + }); + } + + if (bytesToString(rawPortfolioName) === newName) { + throw new PolymeshError({ + code: ErrorCode.ValidationError, + message: 'New name is the same as current name', + }); + } + + const portfolioNames = rawPortfolios.map(([, name]) => bytesToString(name)); + + if (portfolioNames.includes(newName)) { + throw new PolymeshError({ + code: ErrorCode.ValidationError, + message: 'A portfolio with that name already exists', + }); + } + + this.addTransaction( + portfolio.renamePortfolio, + {}, + rawPortfolioNumber, + stringToBytes(newName, context) + ); + + return new NumberedPortfolio({ did, id }, context); +} + +/** + * @hidden + */ +export const modifyNamePortfolio = new Procedure(prepareModifyNamePortfolio);