From cb954bc628c1d576044974803a16671bae16b271 Mon Sep 17 00:00:00 2001 From: Krzysztof Jelski Date: Thu, 4 Feb 2021 09:16:23 +0100 Subject: [PATCH] Bounties - refactor tests (#4589) --- jest.config.js | 1 + jest/jest-setup.ts | 11 + packages/page-bounties/src/Bounties.spec.ts | 497 ++++++++++++ packages/page-bounties/src/Bounties.spec.tsx | 712 ------------------ .../page-bounties/src/BountyStatusView.tsx | 3 +- packages/page-bounties/test/hooks/defaults.ts | 16 +- .../page-bounties/test/pages/bountiesPage.tsx | 307 ++++++++ packages/test-support/package.json | 1 + packages/test-support/src/creation/hashes.ts | 15 + .../src/creation/treasury/defaults.ts | 12 + .../src/creation/treasury/proposalFactory.ts | 30 + packages/test-support/src/hooks/mockHooks.ts | 12 + .../test-support/src/keyring/addresses.ts | 7 + yarn.lock | 58 +- 14 files changed, 955 insertions(+), 727 deletions(-) create mode 100644 jest/jest-setup.ts create mode 100644 packages/page-bounties/src/Bounties.spec.ts delete mode 100644 packages/page-bounties/src/Bounties.spec.tsx create mode 100644 packages/page-bounties/test/pages/bountiesPage.tsx create mode 100644 packages/test-support/src/creation/hashes.ts create mode 100644 packages/test-support/src/creation/treasury/defaults.ts create mode 100644 packages/test-support/src/creation/treasury/proposalFactory.ts create mode 100644 packages/test-support/src/hooks/mockHooks.ts create mode 100644 packages/test-support/src/keyring/addresses.ts diff --git a/jest.config.js b/jest.config.js index 8102582d8a..c9d57594e6 100644 --- a/jest.config.js +++ b/jest.config.js @@ -21,6 +21,7 @@ const defaultConfig = { '\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$': 'empty/object', '\\.(md)$': '/jest/mocks/empty.js' }, + setupFilesAfterEnv: ['/jest/jest-setup.ts'], transformIgnorePatterns: [ '/node_modules' ] diff --git a/jest/jest-setup.ts b/jest/jest-setup.ts new file mode 100644 index 0000000000..b6b7465d74 --- /dev/null +++ b/jest/jest-setup.ts @@ -0,0 +1,11 @@ +// Copyright 2017-2021 @polkadot/apps authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import '@testing-library/jest-dom'; + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +// eslint-disable-next-line no-global-assign +CSS = { supports (): boolean { + return false; +} }; diff --git a/packages/page-bounties/src/Bounties.spec.ts b/packages/page-bounties/src/Bounties.spec.ts new file mode 100644 index 0000000000..511c77da7c --- /dev/null +++ b/packages/page-bounties/src/Bounties.spec.ts @@ -0,0 +1,497 @@ +// Copyright 2017-2021 @polkadot/app-bounties authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { SubmittableExtrinsic } from '@polkadot/api/types'; +import type { DeriveCollectiveProposal } from '@polkadot/api-derive/types'; +import type { Bounty, BountyIndex, BountyStatus } from '@polkadot/types/interfaces'; + +import { fireEvent } from '@testing-library/react'; +import BN from 'bn.js'; + +import { ApiPromise } from '@polkadot/api'; +import i18next from '@polkadot/react-components/i18n'; +import { createAugmentedApi } from '@polkadot/test-support/api'; +import { balanceOf } from '@polkadot/test-support/creation/balance'; +import { BountyFactory } from '@polkadot/test-support/creation/bounties/bountyFactory'; +import { proposalFactory } from '@polkadot/test-support/creation/treasury/proposalFactory'; +import { mockHooks } from '@polkadot/test-support/hooks/mockHooks'; +import { MemoryStore } from '@polkadot/test-support/keyring'; +import { alice, bob } from '@polkadot/test-support/keyring/addresses'; +import { keyring } from '@polkadot/ui-keyring'; + +import { defaultBountyUpdatePeriod, mockBountyHooks } from '../test/hooks/defaults'; +import { BountiesPage } from '../test/pages/bountiesPage'; +import { BLOCKS_PERCENTAGE_LEFT_TO_SHOW_WARNING } from './BountyInfos'; + +jest.mock('@polkadot/react-hooks/useTreasury', () => ({ + useTreasury: () => mockHooks.treasury +})); + +jest.mock('@polkadot/react-hooks/useMembers', () => ({ + useMembers: () => mockHooks.members +})); + +jest.mock('@polkadot/react-hooks/useBlockTime', () => ({ + useBlockTime: () => mockHooks.blockTime +})); + +jest.mock('./hooks/useBalance', () => ({ + useBalance: () => mockBountyHooks.balance +})); + +jest.mock('./hooks/useBounties', () => ({ + useBounties: () => mockBountyHooks.bountyApi +})); + +let aProposal: (extrinsic: SubmittableExtrinsic<'promise'>, ayes?: string[], nays?: string[]) => DeriveCollectiveProposal; +let augmentedApi: ApiPromise; +let aBounty: ({ status, value }?: Partial) => Bounty; +let aBountyIndex: (index?:number) => BountyIndex; +let bountyStatusWith: ({ curator, status, updateDue }: { curator?: string, status?: string, updateDue?: number}) => BountyStatus; +let bountyWith: ({ status, value }: { status?: string, value?: number }) => Bounty; + +describe('Bounties', () => { + let bountiesPage: BountiesPage; + + beforeAll(async () => { + await i18next.changeLanguage('en'); + keyring.loadAll({ isDevelopment: true, store: new MemoryStore() }); + augmentedApi = createAugmentedApi(); + ({ aBounty, aBountyIndex, bountyStatusWith, bountyWith } = new BountyFactory(augmentedApi)); + ({ aProposal } = proposalFactory(augmentedApi)); + }); + + beforeEach(() => { + bountiesPage = new BountiesPage(augmentedApi); + }); + + describe('list', () => { + it('shows message when no bounties', async () => { + bountiesPage.renderMany(); + + await bountiesPage.expectText('No open bounties'); + }); + + it('renders a bounty', async () => { + bountiesPage.renderOne(aBounty(), [], 'kusama comic book'); + + await bountiesPage.expectText('kusama comic book'); + bountiesPage.expectTextAbsent('No open bounties'); + }); + + it('renders bounties in order from newest to oldest', async () => { + bountiesPage.renderMany({ + bounties: [ + { bounty: aBounty(), description: 'bounty 2', index: aBountyIndex(2), proposals: [] }, + { bounty: aBounty(), description: 'bounty 1', index: aBountyIndex(1), proposals: [] }, + { bounty: aBounty(), description: 'bounty 3', index: aBountyIndex(3), proposals: [] } + ] + }); + + expect(await bountiesPage.findAllDescriptions()).toEqual(['bounty 3', 'bounty 2', 'bounty 1']); + }); + }); + + describe('bounty in a list', () => { + describe('has extended status', () => { + it('when voting on proposed curator', async () => { + const bounty = bountyWith({ status: 'Funded' }); + const proposals = [aProposal(augmentedApi.tx.bounties.proposeCurator(0, '5EYCAe5ijiYfyeZ2JJCGq56LmPyNRAKzpG4QkoQkkQNB5e6Z', 1))]; + + bountiesPage.renderOne(bounty, proposals); + + await bountiesPage.expectText('Curator under voting'); + }); + + it('when voting on bounty approval', async () => { + const bounty = bountyWith({ status: 'Proposed' }); + const proposals = [aProposal(augmentedApi.tx.bounties.approveBounty(0))]; + + bountiesPage.renderOne(bounty, proposals); + + await bountiesPage.expectText('Bounty approval under voting'); + }); + + it('when simultaneous close and approve motions exist, show approved', async () => { + const bounty = bountyWith({ status: 'Proposed' }); + const proposals = [ + aProposal(augmentedApi.tx.bounties.closeBounty(0)), + aProposal(augmentedApi.tx.bounties.approveBounty(0)) + ]; + + bountiesPage.renderOne(bounty, proposals); + + await bountiesPage.expectText('Bounty approval under voting'); + }); + + it('when voting on close bounty', async () => { + const bounty = bountyWith({ status: 'Active' }); + const proposals = [aProposal(augmentedApi.tx.bounties.closeBounty(0))]; + + bountiesPage.renderOne(bounty, proposals); + + await bountiesPage.expectText('Bounty rejection under voting'); + }); + + it('when voting on unassign curator', async () => { + const bounty = bountyWith({ status: 'Active' }); + const proposals = [aProposal(augmentedApi.tx.bounties.unassignCurator(0))]; + + bountiesPage.renderOne(bounty, proposals); + + await bountiesPage.expectText('Curator slash under voting'); + }); + + it('when a motion exists that would fail on execution, show nothing', async () => { + const bounty = bountyWith({ status: 'Active' }); + const proposals = [aProposal(augmentedApi.tx.bounties.approveBounty(0))]; + + const { findByTestId } = bountiesPage.renderOne(bounty, proposals); + + await expect(findByTestId('extendedVotingStatus')).rejects.toThrow(); + }); + + it('when bounty is claimable', async () => { + const bounty = bountyWith({ status: 'PendingPayout' }); + const { findByTestId } = bountiesPage.renderOne(bounty); + + expect((await findByTestId('extendedActionStatus')).textContent).toEqual('Claimable'); + }); + }); + + describe('has extended description for Curator', () => { + it('when propose curator motion is voted and bounty is in Funded state', async () => { + const bounty = bountyWith({ status: 'Funded' }); + const proposals = [aProposal(augmentedApi.tx.bounties.proposeCurator(0, alice, 1))]; + + bountiesPage.renderOne(bounty, proposals); + + await bountiesPage.expectText('Proposed Curator'); + }); + + it('when bounty is in Funded status, but there is no motion, show nothing', async () => { + const bounty = bountyWith({ status: 'Funded' }); + + bountiesPage.renderOne(bounty); + + await bountiesPage.rendered(); + bountiesPage.expectTextAbsent('Proposed Curator'); + }); + + it('when status is different, show nothing', async () => { + const bounty = bountyWith({ status: 'CuratorProposed' }); + + bountiesPage.renderOne(bounty); + + await bountiesPage.rendered(); + bountiesPage.expectTextAbsent('Proposed Curator'); + }); + }); + + describe('has Beneficiary description', () => { + it('in PendingPayout status', async () => { + const bounty = bountyWith({ status: 'PendingPayout' }); + const proposals = [aProposal(augmentedApi.tx.bounties.awardBounty(0, '5EYCAe5ijiYfyeZ2JJCGq56LmPyNRAKzpG4QkoQkkQNB5e6Z'))]; + + bountiesPage.renderOne(bounty, proposals); + + await bountiesPage.expectText('Beneficiary'); + }); + + it('not in other status', async () => { + const bounty = bountyWith({ status: 'Active' }); + + bountiesPage.renderOne(bounty); + + await bountiesPage.rendered(); + bountiesPage.expectTextAbsent('Beneficiary'); + }); + }); + + describe('has voting summary', () => { + it('is displayed when voting', async () => { + const bounty = bountyWith({ status: 'Proposed' }); + const proposals = [aProposal(augmentedApi.tx.bounties.approveBounty(0), [alice, bob], [])]; + + const { findByTestId } = bountiesPage.renderOne(bounty, proposals); + + expect((await findByTestId('voting-summary')).textContent).toEqual('Aye 2/4Nay 0/0Voting results'); + }); + + it('is not displayed when not voting', async () => { + const bounty = bountyWith({ status: 'Proposed' }); + const proposals: DeriveCollectiveProposal[] = []; + + const { findByTestId } = bountiesPage.renderOne(bounty, proposals); + + await expect(findByTestId('voting-summary')).rejects.toThrow(); + }); + }); + + describe('has voters', () => { + it('aye and nay', async () => { + const bounty = bountyWith({ status: 'Proposed' }); + const proposals = [aProposal(augmentedApi.tx.bounties.approveBounty(0))]; + const { findAllByTestId } = bountiesPage.renderOne(bounty, proposals); + const ayeVoters = await findAllByTestId((testId) => testId.startsWith('voters_ayes')); + const nayVoters = await findAllByTestId((testId) => testId.startsWith('voters_nays')); + + expect(ayeVoters).toHaveLength(1); + expect(nayVoters).toHaveLength(1); + expect(ayeVoters[0].getAttribute('data-testid')).toContain(alice); + expect(nayVoters[0].getAttribute('data-testid')).toContain(bob); + }); + + it('multiple ayes and no nay', async () => { + const bounty = bountyWith({ status: 'Proposed' }); + const proposals = [aProposal(augmentedApi.tx.bounties.approveBounty(0), [alice, bob], [])]; + + const { findAllByTestId } = bountiesPage.renderOne(bounty, proposals); + const ayeVoters = await findAllByTestId((testId) => testId.startsWith('voters_ayes')); + + expect(ayeVoters).toHaveLength(2); + await expect(findAllByTestId((testId) => testId.startsWith('voters_nays'))).rejects.toThrow(); + expect(ayeVoters[0].getAttribute('data-testid')).toContain(alice); + expect(ayeVoters[1].getAttribute('data-testid')).toContain(bob); + }); + + it('no voters when no voting', async () => { + const bounty = bountyWith({ status: 'Proposed' }); + const proposals: DeriveCollectiveProposal[] = []; + + const { findAllByTestId } = bountiesPage.renderOne(bounty, proposals); + + await expect(findAllByTestId((testId) => testId.startsWith('voters_ayes'))).rejects.toThrow(); + await expect(findAllByTestId((testId) => testId.startsWith('voters_nays'))).rejects.toThrow(); + }); + }); + }); + + describe('create bounty modal', () => { + it('validates bounty length', async () => { + bountiesPage.renderMany({ maximumReasonLength: 5 }); + + await bountiesPage.openAddBounty(); + await bountiesPage.enterBountyTitle('longer than 5'); + + await bountiesPage.expectText('Title too long'); + }); + + it('validates balance is enough for bond', async () => { + bountiesPage.renderMany( + { bountyDepositBase: new BN(10), dataDepositPerByte: new BN(1) }, + { balance: 10 } + ); + + await bountiesPage.openAddBounty(); + bountiesPage.expectTextAbsent('Account does not have enough funds.'); + await bountiesPage.enterBountyTitle('add bytes'); + + await bountiesPage.expectText('Account does not have enough funds.'); + }); + }); + + describe('propose curator modal', () => { + beforeEach(async () => { + bountiesPage.renderOne(bountyWith({ status: 'Funded', value: 5 })); + + await bountiesPage.openProposeCurator(); + }); + + it('shows an error if fee is greater than bounty value', async () => { + await bountiesPage.enterCuratorsFee('6'); + + await bountiesPage.expectText("Curator's fee can't be higher than bounty value."); + }); + + it('disables Assign Curator button if validation fails', async () => { + await bountiesPage.enterCuratorsFee('6'); + + expect(await bountiesPage.assignCuratorButton()).toHaveClass('isDisabled'); + }); + + it('queues propose extrinsic on submit', async () => { + await bountiesPage.enterCuratorsFee('0'); + bountiesPage.enterProposingAccount(alice); + bountiesPage.enterProposedCurator(alice); + + fireEvent.click(await bountiesPage.assignCuratorButton()); + bountiesPage.expectExtrinsicQueued({ accountId: alice, extrinsic: 'mockProposeExtrinsic' }); + }); + }); + + describe('close bounty modal', () => { + it('creates closeBounty proposal', async () => { + bountiesPage.renderOne(bountyWith({ status: 'Funded' })); + await bountiesPage.openCloseBounty(); + + bountiesPage.enterProposingAccount(alice); + await bountiesPage.clickButton('Close Bounty'); + + bountiesPage.expectExtrinsicQueued({ accountId: alice, extrinsic: 'mockProposeExtrinsic' }); + expect(mockBountyHooks.bountyApi.closeBounty).toHaveBeenCalledWith(aBountyIndex(0)); + }); + + it('is not available when close bounty motion already exists', async () => { + const bounty = bountyWith({ status: 'Funded' }); + const proposals = [aProposal(augmentedApi.tx.bounties.closeBounty(0))]; + + const { findByTestId } = bountiesPage.renderOne(bounty, proposals); + + await expect(findByTestId('extra-actions')).rejects.toThrow(); + }); + }); + + describe('Reject curator modal', () => { + it('creates extrinsic', async () => { + const bounty = aBounty({ status: bountyStatusWith({ curator: bob, status: 'CuratorProposed' }) }); + + bountiesPage.renderOne(bounty); + + await bountiesPage.openRejectCuratorRole(); + + await bountiesPage.clickButton('Reject'); + + bountiesPage.expectExtrinsicQueued({ accountId: bob }); + }); + + it('shows options for all roles', async () => { + const bounty = aBounty({ status: bountyStatusWith({ curator: bob, status: 'Active' }) }); + + bountiesPage.renderOne(bounty); + + await bountiesPage.openExtraActions(); + + await bountiesPage.expectText('Give Up'); + await bountiesPage.expectText('Slash Curator (Council)'); + }); + }); + + describe('Accept curator modal', () => { + it('creates extrinsic', async () => { + const bounty = aBounty({ + fee: balanceOf(20), + status: bountyStatusWith({ curator: bob, status: 'CuratorProposed' }) + }); + + bountiesPage.renderOne(bounty); + await bountiesPage.openAcceptCuratorRole(); + + expect(await bountiesPage.findCuratorsFee()).toEqual('20.0000'); + expect(await bountiesPage.findCuratorsDeposit()).toEqual('10.0000'); + + await bountiesPage.clickButton('Accept Curator Role'); + + bountiesPage.expectExtrinsicQueued({ accountId: bob }); + expect(mockBountyHooks.bountyApi.acceptCurator).toHaveBeenCalledWith(aBountyIndex(0)); + }); + }); + + describe('extend bounty expiry action modal', () => { + it('queues extend bounty expiry extrinsic on submit', async () => { + const bounty = aBounty({ status: bountyStatusWith({ curator: alice }) }); + + bountiesPage.renderOne(bounty); + await bountiesPage.openExtendExpiry(); + + await bountiesPage.enterExpiryRemark('The bounty extend expiry remark'); + await bountiesPage.clickButton('Accept'); + + bountiesPage.expectExtrinsicQueued({ accountId: alice, extrinsic: 'mockExtendExtrinsic' }); + expect(mockBountyHooks.bountyApi.extendBountyExpiry).toHaveBeenCalledWith(aBountyIndex(0), 'The bounty extend expiry remark'); + }); + }); + + describe('slash curator modal', () => { + it('gives up on the Curator role of an Active bounty', async () => { + const bounty = aBounty({ status: bountyStatusWith({ curator: alice }) }); + + bountiesPage.renderOne(bounty); + await bountiesPage.openGiveUpCuratorsRole(); + + bountiesPage.enterProposingAccount(alice); + await bountiesPage.clickButton('Approve'); + + bountiesPage.expectExtrinsicQueued({ accountId: alice, extrinsic: 'mockUnassignExtrinsic' }); + expect(mockBountyHooks.bountyApi.unassignCurator).toHaveBeenCalledWith(aBountyIndex(0)); + }); + + it('creates a motion when slashing a PendingPayout bounty', async () => { + bountiesPage.renderOne(bountyWith({ status: 'PendingPayout' })); + + await bountiesPage.openSlashCuratorByCouncil(); + + bountiesPage.enterProposingAccount(alice); + + await bountiesPage.clickButton('Approve'); + + bountiesPage.expectExtrinsicQueued({ accountId: alice, extrinsic: 'mockProposeExtrinsic' }); + }); + }); + + describe('award beneficiary action modal', () => { + it('awards the beneficiary ', async () => { + const bounty = aBounty({ status: bountyStatusWith({ curator: alice }) }); + + bountiesPage.renderOne(bounty); + + await bountiesPage.openAwardBeneficiary(); + bountiesPage.enterBeneficiary(bob); + await bountiesPage.clickButton('Approve'); + + bountiesPage.expectExtrinsicQueued({ accountId: alice, extrinsic: 'mockAwardExtrinsic' }); + expect(mockBountyHooks.bountyApi.awardBounty).toHaveBeenCalledWith(aBountyIndex(0), bob); + }); + }); + + describe('Show', () => { + it('warning when update time is close', async () => { + const bounty = aBounty({ status: bountyStatusWith( + { + curator: alice, + status: 'Active', + updateDue: defaultBountyUpdatePeriod.muln(BLOCKS_PERCENTAGE_LEFT_TO_SHOW_WARNING).divn(100).toNumber() - 1 + }) }); + + bountiesPage.renderOne(bounty); + + await bountiesPage.expectText('Warning'); + await bountiesPage.expectText('Close deadline'); + }); + + it('info when waiting for bounty funding', async () => { + const bounty = bountyWith({ status: 'Approved' }); + + bountiesPage.renderOne(bounty); + + await bountiesPage.expectText('Info'); + await bountiesPage.expectText('Waiting for Bounty Funding'); + }); + + it('info when waiting for curator acceptance', async () => { + const bounty = bountyWith({ status: 'CuratorProposed' }); + + bountiesPage.renderOne(bounty); + + await bountiesPage.expectText('Info'); + await bountiesPage.expectText('Waiting for Curator\'s acceptance'); + }); + + it('no warning or info when requirements are not met', async () => { + const bounty = aBounty({ status: bountyStatusWith({ + curator: alice, + status: 'Active', + updateDue: defaultBountyUpdatePeriod.muln(BLOCKS_PERCENTAGE_LEFT_TO_SHOW_WARNING).divn(100).toNumber() + 1 + }) }); + + bountiesPage.renderOne(bounty); + await bountiesPage.rendered(); + + bountiesPage.expectTextAbsent('Warning'); + bountiesPage.expectTextAbsent('Close deadline'); + bountiesPage.expectTextAbsent('Info'); + bountiesPage.expectTextAbsent('Waiting for Bounty Funding'); + bountiesPage.expectTextAbsent("Waiting for Curator's acceptance"); + }); + }); +}); diff --git a/packages/page-bounties/src/Bounties.spec.tsx b/packages/page-bounties/src/Bounties.spec.tsx deleted file mode 100644 index 01c3319c97..0000000000 --- a/packages/page-bounties/src/Bounties.spec.tsx +++ /dev/null @@ -1,712 +0,0 @@ -// Copyright 2017-2021 @polkadot/app-bounties authors & contributors -// SPDX-License-Identifier: Apache-2.0 - -import type { SubmittableExtrinsic } from '@polkadot/api/types'; -import type { DeriveCollectiveProposal } from '@polkadot/api-derive/types'; -import type { Bounty, BountyIndex, BountyStatus } from '@polkadot/types/interfaces'; - -import { fireEvent, render } from '@testing-library/react'; -import BN from 'bn.js'; -import React, { Suspense } from 'react'; -import { MemoryRouter } from 'react-router-dom'; -import { ThemeProvider } from 'styled-components'; - -import { ApiPromise } from '@polkadot/api'; -import { lightTheme } from '@polkadot/apps/themes'; -import { POLKADOT_GENESIS } from '@polkadot/apps-config'; -import { ApiContext } from '@polkadot/react-api'; -import { ApiProps } from '@polkadot/react-api/types'; -import i18next from '@polkadot/react-components/i18n'; -import { QueueProvider } from '@polkadot/react-components/Status/Context'; -import { QueueProps, QueueTxExtrinsicAdd } from '@polkadot/react-components/Status/types'; -import { createAugmentedApi } from '@polkadot/test-support/api'; -import { balanceOf } from '@polkadot/test-support/creation/balance'; -import { BountyFactory } from '@polkadot/test-support/creation/bounties/bountyFactory'; -import { MemoryStore } from '@polkadot/test-support/keyring'; -import { TypeRegistry } from '@polkadot/types/create'; -import { keyring } from '@polkadot/ui-keyring'; -import { extractTime } from '@polkadot/util'; - -import { BLOCKS_PERCENTAGE_LEFT_TO_SHOW_WARNING } from '../src/BountyInfos'; -import { alice, - bob, - defaultBalance, - defaultBountyApi, - defaultBountyUpdatePeriod, - defaultMembers, - defaultTreasury } from '../test/hooks/defaults'; -import { clickButtonWithName } from '../test/utils/clickButtonWithName'; -import { clickElementWithTestId } from '../test/utils/clickElementWithTestId'; -import { clickElementWithText } from '../test/utils/clickElementWithText'; -import Bounties from './Bounties'; -import { BountyApi } from './hooks'; - -const mockMembers = defaultMembers; -const mockTreasury = defaultTreasury; -let mockBountyApi = defaultBountyApi; -let mockBalance = defaultBalance; - -const mockBlockTime = [50, '', extractTime(1)]; - -jest.mock('./hooks/useBalance', () => ({ - useBalance: () => mockBalance -})); - -jest.mock('./hooks/useBounties', () => ({ - useBounties: () => mockBountyApi -})); - -jest.mock('@polkadot/react-hooks/useTreasury', () => ({ - useTreasury: () => mockTreasury -})); - -jest.mock('@polkadot/react-hooks/useMembers', () => ({ - useMembers: () => mockMembers -})); - -jest.mock('@polkadot/react-hooks/useBlockTime', () => ({ - useBlockTime: () => mockBlockTime -})); - -function aGenesisHash () { - return new TypeRegistry().createType('Hash', POLKADOT_GENESIS); -} - -function aProposal (extrinsic: SubmittableExtrinsic<'promise'>, ayes: string[] = [alice], nays: string[] = [bob]) { - return { - hash: new TypeRegistry().createType('Hash'), - proposal: augmentedApi - .registry.createType('Proposal', extrinsic), - votes: augmentedApi.registry.createType('Votes', { - ayes: ayes, - index: 0, - nays: nays, - threshold: 4 - }) - }; -} - -const propose = jest.fn().mockReturnValue('mockProposeExtrinsic'); - -let augmentedApi: ApiPromise; -let queueExtrinsic: QueueTxExtrinsicAdd; -let aBountyStatus: (status: string) => BountyStatus; -let aBountyIndex: (index?:number) => BountyIndex; -let aBounty: ({ fee, status, value }?: Partial) => Bounty; -let bountyWith: ({ status, value }: { status?: string, value?: number }) => Bounty; -let bountyStatusWith: ({ curator, status, updateDue }: { curator?: string, status?: string, updateDue?: number}) => BountyStatus; - -describe('Bounties', () => { - beforeAll(async () => { - await i18next.changeLanguage('en'); - keyring.loadAll({ isDevelopment: true, store: new MemoryStore() }); - augmentedApi = createAugmentedApi(); - ({ aBounty, aBountyIndex, aBountyStatus, bountyStatusWith, bountyWith } = new BountyFactory(augmentedApi)); - }); - beforeEach(() => { - queueExtrinsic = jest.fn() as QueueTxExtrinsicAdd; - }); - - const renderBounties = (bountyApi: Partial = {}, { balance = 1 } = {}) => { - mockBountyApi = { ...mockBountyApi, ...bountyApi }; - mockBalance = balanceOf(balance); - const mockApi: ApiProps = { - api: { - derive: { - accounts: { info: () => Promise.resolve(() => { /**/ }) } - }, - genesisHash: aGenesisHash(), - query: {}, - registry: { chainDecimals: [12], chainTokens: ['Unit'] }, - tx: { - council: { - propose - } - } - }, - systemName: 'substrate' - } as unknown as ApiProps; - - const queue = { - queueExtrinsic - } as QueueProps; - - return render( - - - - - - - - - - - - ); - }; - - function renderOneBounty (bounty: Bounty, proposals: DeriveCollectiveProposal[] = [], description = '', index = aBountyIndex()) { - return renderBounties({ bounties: [{ bounty, description, index, proposals }] }); - } - - it('creates correct bounty with proposal', () => { - const bounty = augmentedApi.registry.createType('Bounty', { - curatorDeposit: new BN(1), - status: 'Funded', - value: new BN(10) - }); - - expect(bounty.curatorDeposit.eq(new BN(1))).toBeTruthy(); - expect(bounty.status.isFunded).toBeTruthy(); - expect(bounty.value.eq(new BN(10))).toBeTruthy(); - }); - - it('creates correct proposal', () => { - const proposal = augmentedApi.registry.createType('Proposal', augmentedApi.tx.bounties.proposeCurator(0, '5EYCAe5ijiYfyeZ2JJCGq56LmPyNRAKzpG4QkoQkkQNB5e6Z', 1)); - - expect(proposal.args[0].eq(new BN(0))).toBeTruthy(); - expect(proposal.args[1].toString()).toEqual('5EYCAe5ijiYfyeZ2JJCGq56LmPyNRAKzpG4QkoQkkQNB5e6Z'); - expect(proposal.args[2].eq(new BN(1))).toBeTruthy(); - expect(proposal.method).toEqual('proposeCurator'); - }); - - it('creates correct votes', () => { - const votes = augmentedApi.registry.createType('Votes', { ayes: ['5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY'], index: 0, nays: ['5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty'], threshold: 4 }); - - expect(votes.ayes.length).toEqual(1); - expect(votes.ayes[0].toString()).toEqual('5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY'); - expect(votes.nays.length).toEqual(1); - expect(votes.nays[0].toString()).toEqual('5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty'); - expect(votes.index.toNumber()).toEqual(0); - expect(votes.threshold.toNumber()).toEqual(4); - }); - - it('shows empty list when no bounties', async () => { - const { findByText } = renderBounties(); - - expect(await findByText('No open bounties')).toBeTruthy(); - }); - - it('renders a bounty', async () => { - const { findByText, queryAllByText } = renderOneBounty(aBounty(), [], 'kusama comic book'); - - expect(await findByText('kusama comic book')).toBeTruthy(); - expect(queryAllByText('No open bounties')).toHaveLength(0); - }); - - it('renders bounties in order from newest to oldest', async () => { - const bounty1 = bountyWith({ status: 'Proposed' }); - const bounty2 = bountyWith({ status: 'Proposed' }); - const bounty3 = bountyWith({ status: 'Proposed' }); - - const { findAllByTestId } = renderBounties({ bounties: [ - { bounty: bounty1, description: 'bounty 2', index: aBountyIndex(2), proposals: [] }, - { bounty: bounty2, description: 'bounty 1', index: aBountyIndex(1), proposals: [] }, - { bounty: bounty3, description: 'bounty 3', index: aBountyIndex(3), proposals: [] } - ] }); - - const descriptions = await findAllByTestId('description'); - - expect(descriptions[0].textContent).toEqual('bounty 3'); - expect(descriptions[1].textContent).toEqual('bounty 2'); - expect(descriptions[2].textContent).toEqual('bounty 1'); - }); - - describe('create bounty modal', () => { - it('validates bounty length', async () => { - const { findByTestId, findByText } = renderBounties({ maximumReasonLength: 5 }); - - const addBountyButton = await findByText('Add Bounty'); - - fireEvent.click(addBountyButton); - - const titleInput = await findByTestId('bounty title'); - - fireEvent.change(titleInput, { target: { value: 'longer than 5' } }); - - expect(await findByText('Title too long')).toBeTruthy(); - }); - - it('validates balance is enough for bond', async () => { - const { findByRole, findByTestId, findByText, queryByText } = renderBounties( - { bountyDepositBase: new BN(10), dataDepositPerByte: new BN(1) }, - { balance: 10 } - ); - - await clickButtonWithName('Add Bounty', findByRole); - - expect(await findByText('Description of the Bounty (to be stored on-chain)')).toBeTruthy(); // wait for load - - expect(queryByText('Account does not have enough funds.')).toBeFalsy(); - - const titleInput = await findByTestId('bounty title'); - - fireEvent.change(titleInput, { target: { value: 'add bytes' } }); - - expect(await findByText('Account does not have enough funds.')).toBeTruthy(); - }); - }); - - describe('propose curator modal', () => { - it('shows an error if fee is greater than bounty value', async () => { - const bounty = { status: aBountyStatus('Funded'), value: balanceOf(5) }; - const { findByTestId, findByText } = renderOneBounty(aBounty(bounty)); - - const proposeCuratorButton = await findByText('Propose Curator'); - - fireEvent.click(proposeCuratorButton); - expect(await findByText('This action will create a Council motion to assign a Curator.')).toBeTruthy(); - - const feeInput = await findByTestId("curator's fee"); - - fireEvent.change(feeInput, { target: { value: '6' } }); - - expect(await findByText("Curator's fee can't be higher than bounty value.")).toBeTruthy(); - }); - - it('disables Assign Curator button if validation fails', async () => { - const bounty = { status: aBountyStatus('Funded'), value: balanceOf(5) }; - const { findByTestId, findByText } = renderOneBounty(aBounty(bounty)); - - const proposeCuratorButton = await findByText('Propose Curator'); - - fireEvent.click(proposeCuratorButton); - expect(await findByText('This action will create a Council motion to assign a Curator.')).toBeTruthy(); - - const feeInput = await findByTestId("curator's fee"); - - fireEvent.change(feeInput, { target: { value: '6' } }); - - const assignCuratorButton = await findByText('Assign curator'); - - expect(assignCuratorButton.classList.contains('isDisabled')).toBeTruthy(); - }); - - it('queues propose extrinsic on submit', async () => { - const bounty = { status: aBountyStatus('Funded') }; - const { findByTestId, findByText, getAllByRole } = renderOneBounty(aBounty(bounty)); - - const proposeCuratorButton = await findByText('Propose Curator'); - - fireEvent.click(proposeCuratorButton); - expect(await findByText('This action will create a Council motion to assign a Curator.')).toBeTruthy(); - - const feeInput = await findByTestId("curator's fee"); - - fireEvent.change(feeInput, { target: { value: '0' } }); - - const comboboxes = getAllByRole('combobox'); - - const proposingAccountInput = comboboxes[0].children[0]; - const proposingCuratorInput = comboboxes[1].children[0]; - - fireEvent.change(proposingAccountInput, { target: { value: alice } }); - fireEvent.change(proposingCuratorInput, { target: { value: alice } }); - - const assignCuratorButton = await findByText('Assign curator'); - - fireEvent.click(assignCuratorButton); - expect(queueExtrinsic).toHaveBeenCalledWith(expect.objectContaining({ accountId: alice, extrinsic: 'mockProposeExtrinsic' })); - }); - }); - - describe('close bounty modal', () => { - it('creates closeBounty proposal', async () => { - const bounty = bountyWith({ status: 'Funded' }); - const { findByRole, findByTestId, findByText, getByRole } = renderOneBounty(bounty); - - await clickElementWithTestId('extra-actions', findByTestId); - - await clickElementWithText('Close', findByText); - - expect(await findByText('This action will create a Council proposal to close the Bounty.')).toBeTruthy(); - - const combobox = getByRole('combobox'); - const proposingAccountInput = combobox.children[0]; - - fireEvent.change(proposingAccountInput, { target: { value: alice } }); - fireEvent.keyDown(proposingAccountInput, { code: 'Enter', key: 'Enter' }); - - await clickButtonWithName('Close Bounty', findByRole); - - expect(queueExtrinsic).toHaveBeenCalledWith(expect.objectContaining({ accountId: alice, extrinsic: 'mockProposeExtrinsic' })); - expect(mockBountyApi.closeBounty).toHaveBeenCalledWith(aBountyIndex(0)); - }); - - it('Not available when close bounty motion already exists', async () => { - const bounty = bountyWith({ status: 'Funded' }); - const proposals = [aProposal(augmentedApi.tx.bounties.closeBounty(0))]; - - const { findByTestId } = renderOneBounty(bounty, proposals); - - await expect(findByTestId('extra-actions')).rejects.toThrow(); - }); - }); - - describe('Reject curator modal', () => { - it('creates extrinsic', async () => { - const bounty = aBounty({ status: bountyStatusWith({ curator: bob, status: 'CuratorProposed' }) }); - - const { findByRole, findByTestId, findByText } = renderOneBounty(bounty); - - await clickElementWithTestId('extra-actions', findByTestId); - - await clickElementWithText('Reject Curator', findByText); - - await clickButtonWithName('Reject', findByRole); - - expect(queueExtrinsic).toHaveBeenCalledWith(expect.objectContaining({ accountId: bob })); - }); - - it('shows options for all roles', async () => { - const bounty = aBounty({ status: bountyStatusWith({ curator: bob, status: 'Active' }) }); - - const { findByTestId, findByText } = renderOneBounty(bounty); - - await clickElementWithTestId('extra-actions', findByTestId); - - expect(await findByText('Give Up')).toBeTruthy(); - expect(await findByText('Slash Curator (Council)')).toBeTruthy(); - }); - }); - - describe('Accept curator modal', () => { - it('creates extrinsic', async () => { - const bounty = aBounty({ fee: balanceOf(20), status: bountyStatusWith({ curator: bob, status: 'CuratorProposed' }) }); - - const { findByRole, findByTestId, findByText } = renderOneBounty(bounty); - - await clickButtonWithName('Accept', findByRole); - - expect(await findByText('This action will accept your candidacy for the curator of the bounty.')).toBeTruthy(); - - const fee = (await findByTestId("curator's fee")).getAttribute('value'); - - expect(fee).toEqual('20.0000'); - - const deposit = (await findByTestId("curator's deposit")).getAttribute('value'); - - expect(deposit).toEqual('10.0000'); - - await clickButtonWithName('Accept Curator Role', findByRole); - - expect(queueExtrinsic).toHaveBeenCalledWith(expect.objectContaining({ accountId: bob })); - expect(mockBountyApi.acceptCurator).toHaveBeenCalledWith(aBountyIndex(0)); - }); - }); - - describe('extend bounty expiry action modal', () => { - it('queues extend bounty expiry extrinsic on submit', async () => { - const bounty = aBounty({ status: bountyStatusWith({ curator: alice }) }); - - const { findByRole, findByTestId, findByText } = renderOneBounty(bounty); - - await clickElementWithTestId('extra-actions', findByTestId); - - await clickElementWithText('Extend Expiry', findByText); - - expect(await findByText('This action will extend expiry time of the selected bounty.')).toBeTruthy(); - - const remarkInput = await findByTestId('bounty remark'); - - fireEvent.change(remarkInput, { target: { value: 'The bounty extend expiry remark' } }); - - await clickButtonWithName('Accept', findByRole); - - expect(queueExtrinsic).toHaveBeenCalledWith(expect.objectContaining({ accountId: alice, extrinsic: 'mockExtendExtrinsic' })); - expect(mockBountyApi.extendBountyExpiry).toHaveBeenCalledWith(aBountyIndex(0), 'The bounty extend expiry remark'); - }); - }); - - describe('slash curator action modal', () => { - it('give up on a Curator function in Active state bounty', async () => { - const bounty = aBounty({ status: bountyStatusWith({ curator: alice }) }); - const { findByRole, findByTestId, findByText, getAllByRole } = renderOneBounty(bounty); - - await clickElementWithTestId('extra-actions', findByTestId); - - await clickElementWithText('Give Up', findByText); - - expect(await findByText('This action will unassign you from a curator role.')).toBeTruthy(); - - const comboboxes = getAllByRole('combobox'); - - const proposingAccountInput = comboboxes[0].children[0]; - - fireEvent.change(proposingAccountInput, { target: { value: alice } }); - - await clickButtonWithName('Approve', findByRole); - - expect(queueExtrinsic).toHaveBeenCalledWith(expect.objectContaining({ accountId: alice, extrinsic: 'mockUnassignExtrinsic' })); - expect(mockBountyApi.unassignCurator).toHaveBeenCalledWith(aBountyIndex(0)); - }); - - it('for bounty in PendingPayout state', async () => { - const bounty = bountyWith({ status: 'PendingPayout' }); - - const { findByRole, findByTestId, findByText, getAllByRole } = renderOneBounty(bounty); - - await clickElementWithTestId('extra-actions', findByTestId); - - await clickElementWithText('Slash Curator (Council)', findByText); - - expect(await findByText('This action will create a Council motion to slash the Curator.')).toBeTruthy(); - - const comboboxes = getAllByRole('combobox'); - - const proposingAccountInput = comboboxes[0].children[0]; - - fireEvent.change(proposingAccountInput, { target: { value: alice } }); - fireEvent.keyDown(proposingAccountInput, { code: 'Enter', key: 'Enter' }); - - await clickButtonWithName('Approve', findByRole); - - expect(queueExtrinsic).toHaveBeenCalledWith(expect.objectContaining({ accountId: alice, extrinsic: 'mockProposeExtrinsic' })); - }); - }); - - describe('award beneficiary action modal', () => { - it('awards the beneficiary ', async () => { - const bounty = aBounty({ status: bountyStatusWith({ curator: alice }) }); - const { findByText, getAllByRole } = renderOneBounty(bounty); - - const awardBeneficiaryButton = await findByText('Award Beneficiary'); - - fireEvent.click(awardBeneficiaryButton); - expect(await findByText('This action will award the Beneficiary and close the bounty after a delay.')).toBeTruthy(); - const comboboxes = getAllByRole('combobox'); - - const beneficiaryAccountInput = comboboxes[1].children[0]; - - fireEvent.change(beneficiaryAccountInput, { target: { value: bob } }); - fireEvent.keyDown(beneficiaryAccountInput, { code: 'Enter', key: 'Enter' }); - - const acceptButton = await findByText('Approve'); - - fireEvent.click(acceptButton); - - expect(queueExtrinsic).toHaveBeenCalledWith(expect.objectContaining({ accountId: alice, extrinsic: 'mockAwardExtrinsic' })); - expect(mockBountyApi.awardBounty).toHaveBeenCalledWith(aBountyIndex(0), bob); - }); - }); - - describe('status is extended', () => { - it('on proposed curator', async () => { - const bounty = bountyWith({ status: 'Funded' }); - const proposals = [aProposal(augmentedApi.tx.bounties.proposeCurator(0, '5EYCAe5ijiYfyeZ2JJCGq56LmPyNRAKzpG4QkoQkkQNB5e6Z', 1))]; - - const { findByText } = renderOneBounty(bounty, proposals); - - expect(await findByText('Curator under voting')).toBeTruthy(); - }); - - it('on bounty approval in proposed status', async () => { - const bounty = bountyWith({ status: 'Proposed' }); - const proposals = [aProposal(augmentedApi.tx.bounties.approveBounty(0))]; - - const { findByText } = renderOneBounty(bounty, proposals); - - expect(await findByText('Bounty approval under voting')).toBeTruthy(); - }); - - it('on parallel bounty approval and bounty close in proposed status', async () => { - const bounty = bountyWith({ status: 'Proposed' }); - const proposals = [ - aProposal(augmentedApi.tx.bounties.closeBounty(0)), - aProposal(augmentedApi.tx.bounties.approveBounty(0)) - ]; - - const { findByText } = renderOneBounty(bounty, proposals); - - expect(await findByText('Bounty approval under voting')).toBeTruthy(); - }); - - it('on close bounty in active status', async () => { - const bounty = bountyWith({ status: 'Active' }); - const proposals = [aProposal(augmentedApi.tx.bounties.closeBounty(0))]; - - const { findByText } = renderOneBounty(bounty, proposals); - - expect(await findByText('Bounty rejection under voting')).toBeTruthy(); - }); - - it('on unassign curator in active state', async () => { - const bounty = bountyWith({ status: 'Active' }); - const proposals = [aProposal(augmentedApi.tx.bounties.unassignCurator(0))]; - - const { findByText } = renderOneBounty(bounty, proposals); - - expect(await findByText('Curator slash under voting')).toBeTruthy(); - }); - - it('on bounty approval in active state', async () => { - const bounty = bountyWith({ status: 'Active' }); - const proposals = [aProposal(augmentedApi.tx.bounties.approveBounty(0))]; - - const { findByTestId } = renderOneBounty(bounty, proposals); - - await expect(findByTestId('extendedVotingStatus')).rejects.toThrow(); - }); - - it('when bounty is claimable', async () => { - const bounty = bountyWith({ status: 'PendingPayout' }); - const { findByTestId } = renderOneBounty(bounty); - - expect((await findByTestId('extendedActionStatus')).textContent).toEqual('Claimable'); - }); - }); - - describe('Proposed Curator extended status', () => { - it('Funded and propose curator motion', async () => { - const bounty = bountyWith({ status: 'Funded' }); - const proposals = [aProposal(augmentedApi.tx.bounties.proposeCurator(0, alice, 1))]; - - const { findByText } = renderOneBounty(bounty, proposals); - - expect(await findByText('Proposed Curator')).toBeTruthy(); - }); - - it('Funded and no propose curator motion', async () => { - const bounty = bountyWith({ status: 'Funded' }); - - const { findByText } = renderOneBounty(bounty); - - await expect(findByText('Proposed Curator')).rejects.toThrow(); - }); - - it('CuratorProposed', async () => { - const bounty = bountyWith({ status: 'CuratorProposed' }); - - const { findByText } = renderOneBounty(bounty); - - await expect(findByText('Proposed Curator')).rejects.toThrow(); - }); - }); - - describe('Voters', () => { - it('aye and nay', async () => { - const bounty = bountyWith({ status: 'Proposed' }); - const proposals = [aProposal(augmentedApi.tx.bounties.approveBounty(0))]; - const { findAllByTestId } = renderOneBounty(bounty, proposals); - const ayeVoters = await findAllByTestId((testId) => testId.startsWith('voters_ayes')); - const nayVoters = await findAllByTestId((testId) => testId.startsWith('voters_nays')); - - expect(ayeVoters).toHaveLength(1); - expect(nayVoters).toHaveLength(1); - expect(ayeVoters[0].getAttribute('data-testid')).toContain(alice); - expect(nayVoters[0].getAttribute('data-testid')).toContain(bob); - }); - - it('multiple aye and no nay', async () => { - const bounty = bountyWith({ status: 'Proposed' }); - const proposals = [aProposal(augmentedApi.tx.bounties.approveBounty(0), [alice, bob], [])]; - - const { findAllByTestId } = renderOneBounty(bounty, proposals); - const ayeVoters = await findAllByTestId((testId) => testId.startsWith('voters_ayes')); - - expect(ayeVoters).toHaveLength(2); - await expect(findAllByTestId((testId) => testId.startsWith('voters_nays'))).rejects.toThrow(); - expect(ayeVoters[0].getAttribute('data-testid')).toContain(alice); - expect(ayeVoters[1].getAttribute('data-testid')).toContain(bob); - }); - - it('not displayed when no voting', async () => { - const bounty = bountyWith({ status: 'Proposed' }); - const proposals: DeriveCollectiveProposal[] = []; - - const { findAllByTestId } = renderOneBounty(bounty, proposals); - - await expect(findAllByTestId((testId) => testId.startsWith('voters_ayes'))).rejects.toThrow(); - await expect(findAllByTestId((testId) => testId.startsWith('voters_nays'))).rejects.toThrow(); - }); - }); - - describe('Voting summary', () => { - it('is displayed when voting', async () => { - const bounty = bountyWith({ status: 'Proposed' }); - const proposals = [aProposal(augmentedApi.tx.bounties.approveBounty(0), [alice, bob], [])]; - - const { findByTestId } = renderOneBounty(bounty, proposals); - - expect((await findByTestId('voting-summary')).textContent).toEqual('Aye 2/4Nay 0/0Voting results'); - }); - - it('is not displayed when no voting', async () => { - const bounty = bountyWith({ status: 'Proposed' }); - const proposals: DeriveCollectiveProposal[] = []; - - const { findByTestId } = renderOneBounty(bounty, proposals); - - await expect(findByTestId('voting-summary')).rejects.toThrow(); - }); - }); - - describe('Beneficiary description', () => { - it('PendingPayout status', async () => { - const bounty = bountyWith({ status: 'PendingPayout' }); - const proposals = [aProposal(augmentedApi.tx.bounties.awardBounty(0, alice))]; - - const { findByText } = renderOneBounty(bounty, proposals); - - expect(await findByText('Beneficiary')).toBeTruthy(); - }); - - it('Other status', async () => { - const bounty = bountyWith({ status: 'Active' }); - - const { findByText } = renderOneBounty(bounty); - - await expect(findByText('Beneficiary')).rejects.toThrow(); - }); - }); - - describe('Show', () => { - it('warning when update time is close', async () => { - const bounty = aBounty({ status: bountyStatusWith( - { - curator: alice, - status: 'Active', - updateDue: defaultBountyUpdatePeriod.muln(BLOCKS_PERCENTAGE_LEFT_TO_SHOW_WARNING).divn(100).toNumber() - 1 - }) }); - - const { findByText } = renderOneBounty(bounty); - - expect(await findByText('Warning')).toBeTruthy(); - expect(await findByText('Close deadline')).toBeTruthy(); - }); - - it('info when waiting for bounty funding', async () => { - const bounty = bountyWith({ status: 'Approved' }); - const { findByText } = renderOneBounty(bounty); - - expect(await findByText('Info')).toBeTruthy(); - expect(await findByText('Waiting for Bounty Funding')).toBeTruthy(); - }); - - it('info when waiting for curator acceptance', async () => { - const bounty = bountyWith({ status: 'CuratorProposed' }); - const { findByText } = renderOneBounty(bounty); - - expect(await findByText('Info')).toBeTruthy(); - expect(await findByText("Waiting for Curator's acceptance")).toBeTruthy(); - }); - - it('no warning or info when requirements are not met', async () => { - const bounty = aBounty({ status: bountyStatusWith({ - curator: alice, - status: 'Active', - updateDue: defaultBountyUpdatePeriod.muln(BLOCKS_PERCENTAGE_LEFT_TO_SHOW_WARNING).divn(100).toNumber() + 1 - }) }); - - const { findByText } = renderOneBounty(bounty); - - await expect(findByText('Warning')).rejects.toThrow(); - await expect(findByText('Close deadline')).rejects.toThrow(); - - await expect(findByText('Info')).rejects.toThrow(); - await expect(findByText('Waiting for Bounty Funding')).rejects.toThrow(); - await expect(findByText("Waiting for Curator's acceptance")).rejects.toThrow(); - }); - }); -}); diff --git a/packages/page-bounties/src/BountyStatusView.tsx b/packages/page-bounties/src/BountyStatusView.tsx index ca57cd1cc1..f756720c10 100644 --- a/packages/page-bounties/src/BountyStatusView.tsx +++ b/packages/page-bounties/src/BountyStatusView.tsx @@ -37,7 +37,8 @@ function BountyStatusView ({ blocksUntilPayout, bountyStatus, className = '', pr return (
-
+
{insertSpaceBeforeCapitalLetter(bountyStatus)} Promise; +type FindManyWithMatcher = (match: string | ((match: string)=> boolean)) => Promise +type GetMany = (match: string) => HTMLElement[]; + +class NotYetRendered extends Error { + +} + +let queueExtrinsic: (value: PartialQueueTxExtrinsic) => void; +const propose = jest.fn().mockReturnValue('mockProposeExtrinsic'); + +interface RenderedBountiesPage { + findAllByTestId: FindManyWithMatcher; + findByText: FindOne, + findByRole: FindOne, + findByTestId: FindOne, + getAllByRole: GetMany, + queryAllByText: GetMany +} + +export class BountiesPage { + aBounty: ({ status, value }?: Partial) => Bounty; + aBountyIndex: (index?:number) => BountyIndex; + aBountyStatus: (status: string) => BountyStatus; + bountyStatusWith: ({ curator, status }: { curator?: string, status?: string, }) => BountyStatus; + bountyWith: ({ status, value }: { status?: string, value?: number }) => Bounty; + + findByRole?: FindOne; + findByText?: FindOne; + findByTestId?: FindOne; + getAllByRole?: GetMany; + findAllByTestId?: FindManyWithMatcher; + queryAllByText?: GetMany; + + constructor (api: ApiPromise) { + ({ aBounty: this.aBounty, aBountyIndex: this.aBountyIndex, aBountyStatus: this.aBountyStatus, bountyStatusWith: this.bountyStatusWith, bountyWith: this.bountyWith } = new BountyFactory(api)); + } + + renderOne (bounty: Bounty, proposals: DeriveCollectiveProposal[] = [], description = '', index = this.aBountyIndex()): RenderedBountiesPage { + return this.renderMany({ bounties: [{ bounty, description, index, proposals }] }); + } + + renderMany (bountyApi: Partial = {}, { balance = 1 } = {}): RenderedBountiesPage { + const { findAllByTestId, findByRole, findByTestId, findByText, getAllByRole, queryAllByText } = this.renderBounties(bountyApi, { balance }); + + this.findByRole = findByRole; + this.findByText = findByText; + this.findByTestId = findByTestId; + this.getAllByRole = getAllByRole; + this.findAllByTestId = findAllByTestId; + this.queryAllByText = queryAllByText; + + return { findAllByTestId, findByRole, findByTestId, findByText, getAllByRole, queryAllByText }; + } + + private renderBounties (bountyApi: Partial = {}, { balance = 1 } = {}) { + mockBountyHooks.bountyApi = { ...mockBountyHooks.bountyApi, ...bountyApi }; + mockBountyHooks.balance = balanceOf(balance); + const mockApi: ApiProps = { + api: { + derive: { + accounts: { + info: () => Promise.resolve(() => { /**/ + }) + } + }, + genesisHash: aGenesisHash(), + query: {}, + registry: { chainDecimals: [12], chainTokens: ['Unit'] }, + tx: { + council: { + propose + } + } + }, + systemName: 'substrate' + } as unknown as ApiProps; + + queueExtrinsic = jest.fn() as QueueTxExtrinsicAdd; + const queue = { + queueExtrinsic + } as QueueProps; + + return render( + + + + + + + + + + + + ); + } + + private assertRendered (): asserts this is RenderedBountiesPage { + if (this.findByText === undefined) { + throw new NotYetRendered(); + } + } + + async openProposeCurator (): Promise { + this.assertRendered(); + const proposeCuratorButton = await this.findByText('Propose Curator'); + + fireEvent.click(proposeCuratorButton); + await this.expectText('This action will create a Council motion to assign a Curator.'); + } + + async enterCuratorsFee (fee: string): Promise { + this.assertRendered(); + const feeInput = await this.findByTestId("curator's fee"); + + fireEvent.change(feeInput, { target: { value: fee } }); + } + + async expectText (expected: string): Promise { + this.assertRendered(); + expect(await this.findByText(expected)).toBeTruthy(); + } + + async assignCuratorButton (): Promise { + this.assertRendered(); + + return this.findByText('Assign curator'); + } + + enterProposingAccount (account: string): void { + this.assertRendered(); + const comboboxes = this.getAllByRole('combobox'); + + const proposingAccountInput = comboboxes[0].children[0]; + + fireEvent.change(proposingAccountInput, { target: { value: account } }); + fireEvent.keyDown(proposingAccountInput, { code: 'Enter', key: 'Enter' }); + } + + enterProposedCurator (curator: string): void { + this.assertRendered(); + const comboboxes = this.getAllByRole('combobox'); + + const proposedCuratorInput = comboboxes[1].children[0]; + + fireEvent.change(proposedCuratorInput, { target: { value: curator } }); + fireEvent.keyDown(proposedCuratorInput, { code: 'Enter', key: 'Enter' }); + } + + expectExtrinsicQueued (extrinsicPart: { accountId: string; extrinsic?: string }): void { + expect(queueExtrinsic).toHaveBeenCalledWith(expect.objectContaining(extrinsicPart)); + } + + expectTextAbsent (text: string): void { + this.assertRendered(); + expect(this.queryAllByText(text)).toHaveLength(0); + } + + async findAllDescriptions (): Promise { + this.assertRendered(); + const descriptions = await this.findAllByTestId('description'); + + return descriptions.map((d) => d.textContent || ''); + } + + async rendered (): Promise { + this.assertRendered(); + await this.findByTestId('bountyStatus'); + } + + async openAddBounty (): Promise { + this.assertRendered(); + await clickButtonWithName('Add Bounty', this.findByRole); + await this.expectText('This account will propose the bounty. Bond amount will be reserved on its balance.'); + } + + async enterBountyTitle (title: string): Promise { + this.assertRendered(); + const titleInput = await this.findByTestId('bounty title'); + + fireEvent.change(titleInput, { target: { value: title } }); + } + + async openCloseBounty (): Promise { + this.assertRendered(); + await this.openExtraActions(); + + await clickElementWithText('Close', this.findByText); + + await this.expectText('This action will create a Council proposal to close the Bounty.'); + } + + async clickButton (buttonName: string): Promise { + this.assertRendered(); + await clickButtonWithName(buttonName, this.findByRole); + } + + async clickButtonByTestId (buttonName: string): Promise { + this.assertRendered(); + await clickElementWithTestId(buttonName, this.findByTestId); + } + + async clickButtonByText (buttonName: string): Promise { + this.assertRendered(); + await clickElementWithText(buttonName, this.findByText); + } + + async openRejectCuratorRole (): Promise { + await this.openExtraActions(); + await this.clickButtonByText('Reject Curator'); + await this.expectText('This action will reject your candidacy for the curator of the bounty.'); + } + + async openExtraActions (): Promise { + await this.clickButtonByTestId('extra-actions'); + } + + async openAcceptCuratorRole (): Promise { + await this.clickButton('Accept'); + await this.expectText('This action will accept your candidacy for the curator of the bounty.'); + } + + async findCuratorsFee (): Promise { + this.assertRendered(); + + return (await this.findByTestId("curator's fee")).getAttribute('value') || ''; + } + + async findCuratorsDeposit (): Promise { + this.assertRendered(); + + return (await this.findByTestId("curator's deposit")).getAttribute('value') || ''; + } + + async openExtendExpiry (): Promise { + await this.openExtraActions(); + await this.clickButtonByText('Extend Expiry'); + await this.expectText('This action will extend expiry time of the selected bounty.'); + } + + async enterExpiryRemark (remark: string): Promise { + this.assertRendered(); + const remarkInput = await this.findByTestId('bounty remark'); + + fireEvent.change(remarkInput, { target: { value: remark } }); + } + + async openGiveUpCuratorsRole (): Promise { + await this.openExtraActions(); + await this.clickButtonByText('Give Up'); + await this.expectText('This action will unassign you from a curator role.'); + } + + async openSlashCuratorByCouncil (): Promise { + await this.openExtraActions(); + await this.clickButtonByText('Slash Curator (Council)'); + await this.expectText('This action will create a Council motion to slash the Curator.'); + } + + async openAwardBeneficiary (): Promise { + await this.clickButton('Award Beneficiary'); + await this.expectText('This action will award the Beneficiary and close the bounty after a delay.'); + } + + enterBeneficiary (beneficiary: string): void { + this.assertRendered(); + const comboboxes = this.getAllByRole('combobox'); + + const beneficiaryAccountInput = comboboxes[1].children[0]; + + fireEvent.change(beneficiaryAccountInput, { target: { value: beneficiary } }); + fireEvent.keyDown(beneficiaryAccountInput, { code: 'Enter', key: 'Enter' }); + } +} diff --git a/packages/test-support/package.json b/packages/test-support/package.json index 1c0ba47277..209694a074 100644 --- a/packages/test-support/package.json +++ b/packages/test-support/package.json @@ -12,6 +12,7 @@ "testcontainers": "^6.4.2" }, "devDependencies": { + "@testing-library/jest-dom": "^5.11.9", "ts-node": "^9.1.1", "tsconfig-paths": "^3.9.0" } diff --git a/packages/test-support/src/creation/hashes.ts b/packages/test-support/src/creation/hashes.ts new file mode 100644 index 0000000000..c925fff86f --- /dev/null +++ b/packages/test-support/src/creation/hashes.ts @@ -0,0 +1,15 @@ +// Copyright 2017-2021 @polkadot/test-support authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { Hash } from '@polkadot/types/interfaces'; + +import { POLKADOT_GENESIS } from '@polkadot/apps-config'; +import { TypeRegistry } from '@polkadot/types/create'; + +export function aGenesisHash (): Hash { + return new TypeRegistry().createType('Hash', POLKADOT_GENESIS); +} + +export function aHash (): Hash { + return new TypeRegistry().createType('Hash'); +} diff --git a/packages/test-support/src/creation/treasury/defaults.ts b/packages/test-support/src/creation/treasury/defaults.ts new file mode 100644 index 0000000000..1b05f798fa --- /dev/null +++ b/packages/test-support/src/creation/treasury/defaults.ts @@ -0,0 +1,12 @@ +// Copyright 2017-2021 @polkadot/test-support authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import BN from 'bn.js'; + +import { balanceOf } from '../balance'; + +export const defaultTreasury = { + burn: new BN(1), + spendPeriod: new BN(0), + value: balanceOf(1) +}; diff --git a/packages/test-support/src/creation/treasury/proposalFactory.ts b/packages/test-support/src/creation/treasury/proposalFactory.ts new file mode 100644 index 0000000000..9aa18cdc7e --- /dev/null +++ b/packages/test-support/src/creation/treasury/proposalFactory.ts @@ -0,0 +1,30 @@ +// Copyright 2017-2020 @polkadot/api authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { DeriveCollectiveProposal } from '@polkadot/api-derive/types'; + +import { ApiPromise } from '@polkadot/api'; +import { SubmittableExtrinsic } from '@polkadot/api/types'; +import { aHash } from '@polkadot/test-support/creation/hashes'; +import { alice, bob } from '@polkadot/test-support/keyring/addresses'; + +export interface ProposalFactory { + aProposal: (extrinsic: SubmittableExtrinsic<'promise'>, ayes?: string[], nays?: string[]) => DeriveCollectiveProposal +} + +export function proposalFactory (api: ApiPromise): ProposalFactory { + const registry = api.registry; + + return { + aProposal: (extrinsic, ayes = [alice], nays = [bob]) => ({ + hash: aHash(), + proposal: registry.createType('Proposal', extrinsic), + votes: registry.createType('Votes', { + ayes: ayes, + index: 0, + nays: nays, + threshold: 4 + }) + }) + }; +} diff --git a/packages/test-support/src/hooks/mockHooks.ts b/packages/test-support/src/hooks/mockHooks.ts new file mode 100644 index 0000000000..30b90d8726 --- /dev/null +++ b/packages/test-support/src/hooks/mockHooks.ts @@ -0,0 +1,12 @@ +// Copyright 2017-2021 @polkadot/test-support authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import { defaultTreasury } from '@polkadot/test-support/creation/treasury/defaults'; +import { defaultMembers } from '@polkadot/test-support/keyring/addresses'; +import { extractTime } from '@polkadot/util'; + +export const mockHooks = { + blockTime: [50, '', extractTime(1)], + members: defaultMembers, + treasury: defaultTreasury +}; diff --git a/packages/test-support/src/keyring/addresses.ts b/packages/test-support/src/keyring/addresses.ts new file mode 100644 index 0000000000..702f643410 --- /dev/null +++ b/packages/test-support/src/keyring/addresses.ts @@ -0,0 +1,7 @@ +// Copyright 2017-2021 @polkadot/test-support authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +export const alice = '5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY'; +export const bob = '5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty'; +export const ferdie = '5CiPPseXPECbkjWCa6MnjNokrgYjMqmKndv2rSnekmSK2DjL'; +export const defaultMembers = { isMember: true, members: [alice, bob, ferdie] }; diff --git a/yarn.lock b/yarn.lock index c71f4aed30..eae22a46a1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1307,7 +1307,7 @@ __metadata: languageName: node linkType: hard -"@babel/runtime@npm:^7.1.2, @babel/runtime@npm:^7.10.2, @babel/runtime@npm:^7.12.0, @babel/runtime@npm:^7.12.1, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.3.1, @babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.8.4": +"@babel/runtime@npm:^7.1.2, @babel/runtime@npm:^7.10.2, @babel/runtime@npm:^7.12.0, @babel/runtime@npm:^7.12.1, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.3.1, @babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.8.4, @babel/runtime@npm:^7.9.2": version: 7.12.5 resolution: "@babel/runtime@npm:7.12.5" dependencies: @@ -2633,6 +2633,7 @@ __metadata: version: 0.0.0-use.local resolution: "@polkadot/test-support@workspace:packages/test-support" dependencies: + "@testing-library/jest-dom": ^5.11.9 "@testing-library/react": ^11.2.3 testcontainers: ^6.4.2 ts-node: ^9.1.1 @@ -3062,6 +3063,22 @@ __metadata: languageName: node linkType: hard +"@testing-library/jest-dom@npm:^5.11.9": + version: 5.11.9 + resolution: "@testing-library/jest-dom@npm:5.11.9" + dependencies: + "@babel/runtime": ^7.9.2 + "@types/testing-library__jest-dom": ^5.9.1 + aria-query: ^4.2.2 + chalk: ^3.0.0 + css: ^3.0.0 + css.escape: ^1.5.1 + lodash: ^4.17.15 + redent: ^3.0.0 + checksum: c285c044edf42b9c4daffbeddd5489564af1c9d625f0ef3781e2e9864688239ccc2521badec9147fea66685c60b736c922e35ea69011aa577eabc156c0e1f5b6 + languageName: node + linkType: hard + "@testing-library/react@npm:^11.2.3": version: 11.2.3 resolution: "@testing-library/react@npm:11.2.3" @@ -3345,7 +3362,7 @@ __metadata: languageName: node linkType: hard -"@types/jest@npm:^26.0.20": +"@types/jest@npm:*, @types/jest@npm:^26.0.20": version: 26.0.20 resolution: "@types/jest@npm:26.0.20" dependencies: @@ -3633,6 +3650,15 @@ __metadata: languageName: node linkType: hard +"@types/testing-library__jest-dom@npm:^5.9.1": + version: 5.9.5 + resolution: "@types/testing-library__jest-dom@npm:5.9.5" + dependencies: + "@types/jest": "*" + checksum: 32aa324a83e67fd81eb7f0ba3e4c86406db86c1e5361b6e8091d224a1fefbfcb4e493039db3ce3faf8afa133a4f68cc7c42bcdbcae1dd91b13d741a040791038 + languageName: node + linkType: hard + "@types/tmp@npm:^0.2.0": version: 0.2.0 resolution: "@types/tmp@npm:0.2.0" @@ -6628,6 +6654,24 @@ __metadata: languageName: node linkType: hard +"css.escape@npm:^1.5.1": + version: 1.5.1 + resolution: "css.escape@npm:1.5.1" + checksum: 44fe5e93fee46fe60dbd0cdd078b14ef75697ee93519a7157f976b655463dd66eba598b0df16c16a897ac884c97845d2a3819cb8d370cbf91bc59bb557ebe826 + languageName: node + linkType: hard + +"css@npm:^3.0.0": + version: 3.0.0 + resolution: "css@npm:3.0.0" + dependencies: + inherits: ^2.0.4 + source-map: ^0.6.1 + source-map-resolve: ^0.6.0 + checksum: a62cc8350fa1f6805a81447dc4a1df30f37316bcb671e4669531b5ddf24a8dd7024324ce321a8ec0b1dd48baafb38781ae4b0d80fa7c7f5adab5f46b8b9e29c8 + languageName: node + linkType: hard + "cssesc@npm:^3.0.0": version: 3.0.0 resolution: "cssesc@npm:3.0.0" @@ -16614,6 +16658,16 @@ fsevents@~2.1.2: languageName: node linkType: hard +"source-map-resolve@npm:^0.6.0": + version: 0.6.0 + resolution: "source-map-resolve@npm:0.6.0" + dependencies: + atob: ^2.1.2 + decode-uri-component: ^0.2.0 + checksum: 9bbd2656937ce422f1526026638a91dacba0822f1ab954ac34728c9b4f2960dbfc22a8463af9fbf74f28f3722263444df79b2b5ffa07de07dbcda2ec5a8c7513 + languageName: node + linkType: hard + "source-map-support@npm:^0.5.16, source-map-support@npm:^0.5.17, source-map-support@npm:^0.5.19, source-map-support@npm:^0.5.6, source-map-support@npm:~0.5.12, source-map-support@npm:~0.5.19": version: 0.5.19 resolution: "source-map-support@npm:0.5.19"