Prechádzať zdrojové kódy

Merge pull request #3195 from ondratra/olympia_vnft_schema_mappings

Olympia vnft schema mappings
Mokhtar Naamani 3 rokov pred
rodič
commit
bab3f8f47b
99 zmenil súbory, kde vykonal 5794 pridanie a 962 odobranie
  1. 0 0
      chain-metadata.json
  2. 1 1
      cli/src/Api.ts
  3. 1 1
      cli/src/base/ContentDirectoryCommandBase.ts
  4. 26 0
      cli/src/base/MembershipsCommandBase.ts
  5. 1 0
      cli/src/base/StateAwareCommandBase.ts
  6. 56 0
      cli/src/commands/membership/chooseMember.ts
  7. 1 2
      query-node/codegen/package.json
  8. 51 10
      query-node/manifest.yml
  9. 7 6
      query-node/mappings/.eslintrc.js
  10. 14 13
      query-node/mappings/src/common.ts
  11. 29 4
      query-node/mappings/src/content/channel.ts
  12. 40 6
      query-node/mappings/src/content/curatorGroup.ts
  13. 1 0
      query-node/mappings/src/content/index.ts
  14. 932 0
      query-node/mappings/src/content/nft.ts
  15. 133 1
      query-node/mappings/src/content/utils.ts
  16. 55 5
      query-node/mappings/src/content/video.ts
  17. 73 4
      query-node/mappings/src/storage/index.ts
  18. 3 7
      query-node/mappings/src/storage/utils.ts
  19. 6 5
      query-node/package.json
  20. 12 13
      query-node/schemas/content.graphql
  21. 218 0
      query-node/schemas/contentNft.graphql
  22. 402 0
      query-node/schemas/contentNftEvents.graphql
  23. 6 0
      query-node/schemas/membership.graphql
  24. 23 4
      query-node/schemas/storage.graphql
  25. 5 16
      runtime-modules/content/src/lib.rs
  26. 1 1
      runtime-modules/content/src/nft/mod.rs
  27. 3 3
      runtime-modules/content/src/nft/types.rs
  28. 1 4
      runtime-modules/content/src/tests/nft/issue_nft.rs
  29. 4 2
      tests/integration-tests/package.json
  30. 271 8
      tests/integration-tests/src/Api.ts
  31. 44 0
      tests/integration-tests/src/QueryNodeApi.ts
  32. 3 1
      tests/integration-tests/src/Scenario.ts
  33. 108 0
      tests/integration-tests/src/cli/base.ts
  34. 227 0
      tests/integration-tests/src/cli/joystream.ts
  35. 6 3
      tests/integration-tests/src/cli/utils.ts
  36. 2 0
      tests/integration-tests/src/consts.ts
  37. 140 0
      tests/integration-tests/src/fixtures/content/activeVideoCounters.ts
  38. 53 0
      tests/integration-tests/src/fixtures/content/contentTemplates.ts
  39. 180 0
      tests/integration-tests/src/fixtures/content/createChannelsAndVideos.ts
  40. 122 0
      tests/integration-tests/src/fixtures/content/createContentStructure.ts
  41. 62 0
      tests/integration-tests/src/fixtures/content/createMembers.ts
  42. 5 0
      tests/integration-tests/src/fixtures/content/index.ts
  43. 75 0
      tests/integration-tests/src/fixtures/content/nft/auctionCancelations.ts
  44. 48 0
      tests/integration-tests/src/fixtures/content/nft/buyNow.ts
  45. 44 0
      tests/integration-tests/src/fixtures/content/nft/createVideoWithAuction.ts
  46. 50 0
      tests/integration-tests/src/fixtures/content/nft/directOffer.ts
  47. 82 0
      tests/integration-tests/src/fixtures/content/nft/englishAuction.ts
  48. 6 0
      tests/integration-tests/src/fixtures/content/nft/index.ts
  49. 70 0
      tests/integration-tests/src/fixtures/content/nft/openAuction.ts
  50. 41 0
      tests/integration-tests/src/fixtures/content/nft/placeBidsInAuction.ts
  51. 25 0
      tests/integration-tests/src/fixtures/content/nft/utils.ts
  52. 74 0
      tests/integration-tests/src/flows/content/activeVideoCounters.ts
  53. 141 0
      tests/integration-tests/src/flows/content/nftAuctionAndOffers.ts
  54. 14 0
      tests/integration-tests/src/flows/storage/initStorage.ts
  55. 15 0
      tests/integration-tests/src/flows/utils.ts
  56. 103 0
      tests/integration-tests/src/graphql/generated/queries.ts
  57. 433 298
      tests/integration-tests/src/graphql/generated/schema.ts
  58. 53 0
      tests/integration-tests/src/graphql/queries/content.graphql
  59. 20 0
      tests/integration-tests/src/scenarios/content-directory.ts
  60. 1 1
      tests/integration-tests/src/scenarios/council.ts
  61. 1 1
      tests/integration-tests/src/scenarios/forum.ts
  62. 1 1
      tests/integration-tests/src/scenarios/forumPostDeletionsBug.ts
  63. 9 1
      tests/integration-tests/src/scenarios/full.ts
  64. 1 1
      tests/integration-tests/src/scenarios/memberships.ts
  65. 1 1
      tests/integration-tests/src/scenarios/olympia.ts
  66. 1 1
      tests/integration-tests/src/scenarios/proposals.ts
  67. 1 1
      tests/integration-tests/src/scenarios/proposalsDiscussion.ts
  68. 1 1
      tests/integration-tests/src/scenarios/setupNewChain.ts
  69. 1 1
      tests/integration-tests/src/scenarios/workingGroups.ts
  70. 1 1
      tests/integration-tests/src/sender.ts
  71. 14 1
      tests/integration-tests/src/types.ts
  72. 210 9
      tests/network-tests/src/Api.ts
  73. 10 0
      tests/network-tests/src/Fixture.ts
  74. 6 1
      tests/network-tests/src/Flow.ts
  75. 5 1
      tests/network-tests/src/Job.ts
  76. 52 3
      tests/network-tests/src/QueryNodeApi.ts
  77. 5 3
      tests/network-tests/src/Scenario.ts
  78. 0 54
      tests/network-tests/src/cli/base.ts
  79. 0 47
      tests/network-tests/src/cli/joystream.ts
  80. 2 0
      tests/network-tests/src/consts.ts
  81. 11 14
      tests/network-tests/src/flows/clis/createChannel.ts
  82. 5 1
      tests/network-tests/src/flows/storagev2/initStorage.ts
  83. 1 0
      tests/network-tests/src/flows/workingGroup/leaderSetup.ts
  84. 35 0
      tests/network-tests/src/graphql/generated/queries.ts
  85. 586 315
      tests/network-tests/src/graphql/generated/schema.ts
  86. 19 0
      tests/network-tests/src/graphql/queries/storagev2.graphql
  87. 10 1
      tests/network-tests/src/scenarios/combined.ts
  88. 0 7
      tests/network-tests/src/scenarios/content-directory.ts
  89. 1 1
      tests/network-tests/src/scenarios/giza-issue-reproduction-setup.ts
  90. 1 1
      tests/network-tests/src/scenarios/init-storage-and-distribution.ts
  91. 1 1
      tests/network-tests/src/scenarios/post-migration.ts
  92. 1 1
      tests/network-tests/src/scenarios/proposals.ts
  93. 1 1
      tests/network-tests/src/scenarios/setup-new-chain.ts
  94. 1 1
      tests/network-tests/src/scenarios/tests/resource-locks-1.ts
  95. 1 1
      tests/network-tests/src/scenarios/tests/resource-locks-2.ts
  96. 21 0
      types/augment/all/defs.json
  97. 84 56
      types/augment/all/types.ts
  98. 28 1
      types/src/content/index.ts
  99. 42 12
      yarn.lock

Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 0 - 0
chain-metadata.json


+ 1 - 1
cli/src/Api.ts

@@ -1,5 +1,5 @@
 import BN from 'bn.js'
-import { createType, types } from '@joystream/types/'
+import { createType, types } from '@joystream/types'
 import { ApiPromise, WsProvider } from '@polkadot/api'
 import { SubmittableExtrinsic, AugmentedQuery } from '@polkadot/api/types'
 import { formatBalance } from '@polkadot/util'

+ 1 - 1
cli/src/base/ContentDirectoryCommandBase.ts

@@ -291,7 +291,7 @@ export default abstract class ContentDirectoryCommandBase extends WorkingGroupCo
     context: Exclude<keyof typeof ContentActor.typeDefinitions, 'Collaborator'>
   ): Promise<[ContentActor, string]> {
     if (context === 'Member') {
-      const { id, membership } = await this.getRequiredMemberContext()
+      const { id, membership } = await this.getRequiredMemberContext(true)
       return [
         createType<ContentActor, 'ContentActor'>('ContentActor', { Member: id }),
         membership.controller_account.toString(),

+ 26 - 0
cli/src/base/MembershipsCommandBase.ts

@@ -83,4 +83,30 @@ export default abstract class MembershipsCommandBase extends AccountsCommandBase
 
     return availableMemberships[memberIndex]
   }
+
+  async setSelectedMember(selectedMember: MemberDetails): Promise<void> {
+    this.selectedMember = selectedMember
+
+    await this.setPreservedState({ selectedMemberId: selectedMember.id.toString() })
+  }
+
+  private async initSelectedMember(): Promise<void> {
+    const memberIdString = this.getPreservedState().selectedMemberId
+
+    const memberId = this.createType('MemberId', memberIdString)
+    const members = await this.getApi().membersDetailsByIds([memberId])
+
+    // ensure selected member exists
+    if (!members.length) {
+      return
+    }
+
+    this.selectedMember = members[0]
+  }
+
+  async init(): Promise<void> {
+    await super.init()
+
+    await this.initSelectedMember()
+  }
 }

+ 1 - 0
cli/src/base/StateAwareCommandBase.ts

@@ -14,6 +14,7 @@ type StateObject = {
   queryNodeUri: string | null | undefined
   defaultWorkingGroup: WorkingGroups
   metadataCache: Record<string, any>
+  selectedMemberId?: string
 }
 
 // State object default values

+ 56 - 0
cli/src/commands/membership/chooseMember.ts

@@ -0,0 +1,56 @@
+import MembershipsCommandBase from '../../base/MembershipsCommandBase'
+import { MemberDetails } from '../../Types'
+import chalk from 'chalk'
+import { flags } from '@oclif/command'
+import ExitCodes from '../../ExitCodes'
+
+export default class MembershipChooseMember extends MembershipsCommandBase {
+  static description = 'Choose default member to use in the CLI'
+  static flags = {
+    memberId: flags.string({
+      description: 'Select member (if available)',
+      char: 'm',
+      required: false,
+    }),
+    ...MembershipsCommandBase.flags,
+  }
+
+  async run() {
+    const { memberId } = this.parse(MembershipChooseMember).flags
+
+    const selectedMember = memberId
+      ? await this.selectKnownMember(memberId)
+      : await this.getRequiredMemberContext(false)
+
+    await this.setSelectedMember(selectedMember)
+
+    this.log(
+      chalk.greenBright(
+        `\nMember switched to id ${chalk.magentaBright(
+          selectedMember.id
+        )} (account: ${selectedMember.membership.controller_account.toString()})!`
+      )
+    )
+  }
+
+  async selectKnownMember(memberIdString: string): Promise<MemberDetails> {
+    const memberId = this.createType('MemberId', memberIdString)
+    const members = await this.getApi().membersDetailsByIds([memberId])
+
+    if (!members.length) {
+      this.error(`Selected member id not found among known members!`, {
+        exit: ExitCodes.AccessDenied,
+      })
+    }
+
+    const selectedMember = members[0]
+
+    if (!this.isKeyAvailable(selectedMember.membership.controller_account)) {
+      this.error(`Selected member's account is not imported to CLI!`, {
+        exit: ExitCodes.AccessDenied,
+      })
+    }
+
+    return selectedMember
+  }
+}

+ 1 - 2
query-node/codegen/package.json

@@ -4,8 +4,7 @@
   "description": "Hydra codegen tools for Joystream Query Node",
   "author": "",
   "license": "ISC",
-  "scripts": {
-  },
+  "scripts": {},
   "dependencies": {
     "@joystream/hydra-cli": "3.1.0-alpha.16",
     "@joystream/hydra-typegen": "3.1.0-alpha.16"

+ 51 - 10
query-node/manifest.yml

@@ -62,7 +62,7 @@ typegen:
     - proposalsDiscussion.PostUpdated
     - proposalsDiscussion.ThreadModeChanged
     - proposalsDiscussion.PostDeleted
-  # Forum
+    # Forum
     - forum.CategoryCreated
     - forum.CategoryArchivalStatusUpdated
     - forum.CategoryDeleted
@@ -79,7 +79,7 @@ typegen:
     - forum.PostReacted
     - forum.CategoryStickyThreadUpdate
     - forum.CategoryMembershipOfModeratorUpdated
-  # Content directory
+    # Content directory
     - content.CuratorGroupCreated
     - content.CuratorGroupStatusSet
     - content.CuratorAdded
@@ -140,6 +140,7 @@ typegen:
     # Not required:
     # - storage.NumberOfStorageBucketsInDynamicBagCreationPolicyUpdated
     # - storage.FamiliesInDynamicBagCreationPolicyUpdated
+
     # Council
     - council.AnnouncingPeriodStarted
     - council.NotEnoughCandidates
@@ -165,6 +166,22 @@ typegen:
     - referendum.VoteCast
     - referendum.VoteRevealed
     - referendum.StakeReleased
+
+    # content NFTs
+    - content.AuctionStarted
+    - content.NftIssued
+    - content.AuctionBidMade
+    - content.AuctionBidCanceled
+    - content.AuctionCanceled
+    - content.EnglishAuctionCompleted
+    - content.BidMadeCompletingAuction
+    - content.OpenAuctionBidAccepted
+    - content.OfferStarted
+    - content.OfferAccepted
+    - content.OfferCanceled
+    - content.NftSellOrderMade
+    - content.NftBought
+    - content.BuyNowCanceled
   calls:
     # Proposals discussion
     - proposalsDiscussion.addPost
@@ -688,13 +705,6 @@ mappings:
       handler: content_ChannelAssetsRemoved
     - event: content.ChannelCensorshipStatusUpdated
       handler: content_ChannelCensorshipStatusUpdated
-    # these events are defined in runtime but never emitted (at the time of writing)
-    #- event: content.ChannelOwnershipTransferRequested
-    #  handler: content_ChannelOwnershipTransferRequested
-    #- event: content.ChannelOwnershipTransferRequestWithdrawn
-    #  handler: content_ChannelOwnershipTransferRequestWithdrawn
-    #- event: content.ChannelOwnershipTransferred
-    #  handler: content_ChannelOwnershipTransferred
     - event: content.ChannelCategoryCreated
       handler: content_ChannelCategoryCreated
     - event: content.ChannelCategoryUpdated
@@ -719,7 +729,38 @@ mappings:
       handler: content_FeaturedVideosSet
     - event: content.ChannelDeleted
       handler: content_ChannelDeleted
-    # Storage
+
+    # content NFTs
+    - event: content.AuctionStarted
+      handler: contentNft_AuctionStarted
+    - event: content.NftIssued
+      handler: contentNft_NftIssued
+    - event: content.AuctionBidMade
+      handler: contentNft_AuctionBidMade
+    - event: content.AuctionBidCanceled
+      handler: contentNft_AuctionBidCanceled
+    - event: content.AuctionCanceled
+      handler: contentNft_AuctionCanceled
+    - event: content.EnglishAuctionCompleted
+      handler: contentNft_EnglishAuctionCompleted
+    - event: content.BidMadeCompletingAuction
+      handler: contentNft_BidMadeCompletingAuction
+    - event: content.OpenAuctionBidAccepted
+      handler: contentNft_OpenAuctionBidAccepted
+    - event: content.OfferStarted
+      handler: contentNft_OfferStarted
+    - event: content.OfferAccepted
+      handler: contentNft_OfferAccepted
+    - event: content.OfferCanceled
+      handler: contentNft_OfferCanceled
+    - event: content.NftSellOrderMade
+      handler: contentNft_NftSellOrderMade
+    - event: content.NftBought
+      handler: contentNft_NftBought
+    - event: content.BuyNowCanceled
+      handler: contentNft_BuyNowCanceled
+
+    # storage v2
     - event: storage.StorageBucketCreated
       handler: storage_StorageBucketCreated
     - event: storage.StorageBucketInvitationAccepted

+ 7 - 6
query-node/mappings/.eslintrc.js

@@ -8,15 +8,16 @@ module.exports = {
     // TODO: Remove all the rules below, they seem quite useful
     '@typescript-eslint/no-explicit-any': 'off',
     '@typescript-eslint/no-non-null-assertion': 'off',
-    '@typescript-eslint/ban-types': ["error",
+    '@typescript-eslint/ban-types': [
+      'error',
       {
-        "types": {
+        'types': {
           // enable usage of `Object` data type in TS; it has it's meaning(!) and it's disabled
           // by default only beacuse people tend to misuse it
-          "Object": false,
+          'Object': false,
         },
-        "extendDefaults": true
-      }
-    ]
+        'extendDefaults': true,
+      },
+    ],
   },
 }

+ 14 - 13
query-node/mappings/src/common.ts

@@ -31,6 +31,20 @@ class Logger {
 
 export const logger = new Logger()
 
+export function genericEventFields(substrateEvent: SubstrateEvent): Partial<BaseModel & Event> {
+  const { blockNumber, indexInBlock, extrinsic, blockTimestamp } = substrateEvent
+  const eventTime = new Date(blockTimestamp)
+  return {
+    createdAt: eventTime,
+    updatedAt: eventTime,
+    id: `${CURRENT_NETWORK}-${blockNumber}-${indexInBlock}`,
+    inBlock: blockNumber,
+    network: CURRENT_NETWORK,
+    inExtrinsic: extrinsic?.hash,
+    indexInBlock,
+  }
+}
+
 /*
   Reports that insurmountable inconsistent state has been encountered and throws an exception.
 */
@@ -169,19 +183,6 @@ type MappingsMemoryCache = {
 
 export const MemoryCache: MappingsMemoryCache = {}
 
-export function genericEventFields(substrateEvent: SubstrateEvent): Partial<BaseModel & Event> {
-  const { blockNumber, indexInBlock, extrinsic, blockTimestamp } = substrateEvent
-  const eventTime = new Date(blockTimestamp)
-  return {
-    createdAt: eventTime,
-    updatedAt: eventTime,
-    id: `${CURRENT_NETWORK}-${blockNumber}-${indexInBlock}`,
-    inBlock: blockNumber,
-    network: CURRENT_NETWORK,
-    inExtrinsic: extrinsic?.hash,
-    indexInBlock,
-  }
-}
 export function deserializeMetadata<T>(
   metadataType: AnyMetadataClass<T>,
   metadataBytes: Bytes

+ 29 - 4
query-node/mappings/src/content/channel.ts

@@ -3,13 +3,17 @@ eslint-disable @typescript-eslint/naming-convention
 */
 import { EventContext, StoreContext } from '@joystream/hydra-common'
 import { Content } from '../../generated/types'
-import { convertContentActorToChannelOwner, processChannelMetadata } from './utils'
+import {
+  convertContentActorToChannelOwner,
+  processChannelMetadata,
+  updateChannelCategoryVideoActiveCounter,
+  unsetAssetRelations,
+} from './utils'
 import { Channel, ChannelCategory, StorageDataObject, Membership } from 'query-node/dist/model'
 import { deserializeMetadata, inconsistentState, logger } from '../common'
 import { ChannelCategoryMetadata, ChannelMetadata } from '@joystream/metadata-protobuf'
 import { integrateMeta } from '@joystream/metadata-protobuf/utils'
 import { In } from 'typeorm'
-import { removeDataObject } from '../storage/utils'
 
 export async function content_ChannelCreated(ctx: EventContext & StoreContext): Promise<void> {
   const { store, event } = ctx
@@ -24,11 +28,15 @@ export async function content_ChannelCreated(ctx: EventContext & StoreContext):
     videos: [],
     createdInBlock: event.blockNumber,
     rewardAccount: channelCreationParameters.reward_account.unwrapOr(undefined)?.toString(),
+    activeVideosCounter: 0,
+
     // fill in auto-generated fields
     createdAt: new Date(event.blockTimestamp),
     updatedAt: new Date(event.blockTimestamp),
+
     // prepare channel owner (handles fields `ownerMember` and `ownerCuratorGroup`)
     ...(await convertContentActorToChannelOwner(store, contentActor)),
+
     collaborators: Array.from(channelCreationParameters.collaborators).map(
       (id) => new Membership({ id: id.toString() })
     ),
@@ -53,13 +61,18 @@ export async function content_ChannelUpdated(ctx: EventContext & StoreContext):
   const [, channelId, , channelUpdateParameters] = new Content.ChannelUpdatedEvent(event).params
 
   // load channel
-  const channel = await store.get(Channel, { where: { id: channelId.toString() } })
+  const channel = await store.get(Channel, {
+    where: { id: channelId.toString() },
+    relations: ['category'],
+  })
 
   // ensure channel exists
   if (!channel) {
     return inconsistentState('Non-existing channel update requested', channelId)
   }
 
+  const originalCategory = channel.category
+
   // prepare changed metadata
   const newMetadataBytes = channelUpdateParameters.new_meta.unwrapOr(null)
 
@@ -93,6 +106,9 @@ export async function content_ChannelUpdated(ctx: EventContext & StoreContext):
   // save channel
   await store.save<Channel>(channel)
 
+  // transfer video active counter value to new category
+  await updateChannelCategoryVideoActiveCounter(store, originalCategory, channel.category, channel.activeVideosCounter)
+
   // emit log event
   logger.info('Channel has been updated', { id: channel.id })
 }
@@ -104,7 +120,7 @@ export async function content_ChannelAssetsRemoved({ store, event }: EventContex
       id: In(Array.from(dataObjectIds).map((item) => item.toString())),
     },
   })
-  await Promise.all(assets.map((a) => removeDataObject(store, a)))
+  await Promise.all(assets.map((a) => unsetAssetRelations(store, a)))
   logger.info('Channel assets have been removed', { ids: dataObjectIds.toJSON() })
 }
 
@@ -132,6 +148,14 @@ export async function content_ChannelCensorshipStatusUpdated({
   // save channel
   await store.save<Channel>(channel)
 
+  // update active video counter for category (if any)
+  await updateChannelCategoryVideoActiveCounter(
+    store,
+    isCensored.isTrue ? channel.category : undefined,
+    isCensored.isTrue ? undefined : channel.category,
+    channel.activeVideosCounter
+  )
+
   // emit log event
   logger.info('Channel censorship status has been updated', { id: channelId, isCensored: isCensored.isTrue })
 }
@@ -151,6 +175,7 @@ export async function content_ChannelCategoryCreated({ store, event }: EventCont
     id: channelCategoryId.toString(),
     channels: [],
     createdInBlock: event.blockNumber,
+    activeVideosCounter: 0,
 
     // fill in auto-generated fields
     createdAt: new Date(event.blockTimestamp),

+ 40 - 6
query-node/mappings/src/content/curatorGroup.ts

@@ -1,12 +1,36 @@
 /*
 eslint-disable @typescript-eslint/naming-convention
 */
-import { EventContext, StoreContext } from '@joystream/hydra-common'
+import { DatabaseManager, EventContext, StoreContext } from '@joystream/hydra-common'
 import { FindConditions } from 'typeorm'
-import { CuratorGroup } from 'query-node/dist/model'
+import { Curator, CuratorGroup } from 'query-node/dist/model'
 import { Content } from '../../generated/types'
 import { inconsistentState, logger } from '../common'
 
+async function getCurator(store: DatabaseManager, curatorId: string): Promise<Curator | undefined> {
+  const existingCurator = await store.get(Curator, {
+    where: { id: curatorId.toString() } as FindConditions<Curator>,
+  })
+
+  return existingCurator
+}
+
+async function createCurator(store: DatabaseManager, curatorId: string): Promise<Curator> {
+  const curator = new Curator({
+    id: curatorId,
+
+    curatorGroups: [],
+  })
+
+  await store.save<Curator>(curator)
+
+  return curator
+}
+
+async function ensureCurator(store: DatabaseManager, curatorId: string): Promise<Curator> {
+  return (await getCurator(store, curatorId)) || (await createCurator(store, curatorId))
+}
+
 export async function content_CuratorGroupCreated({ store, event }: EventContext & StoreContext): Promise<void> {
   // read event data
   const [curatorGroupId] = new Content.CuratorGroupCreatedEvent(event).params
@@ -15,7 +39,7 @@ export async function content_CuratorGroupCreated({ store, event }: EventContext
   const curatorGroup = new CuratorGroup({
     // main data
     id: curatorGroupId.toString(),
-    curatorIds: [],
+    curators: [],
     isActive: false, // runtime creates inactive curator groups by default
 
     // fill in auto-generated fields
@@ -71,8 +95,11 @@ export async function content_CuratorAdded({ store, event }: EventContext & Stor
     return inconsistentState('Curator add to non-existing curator group requested', curatorGroupId)
   }
 
+  // load curator
+  const curator = await ensureCurator(store, curatorId.toString())
+
   // update curator group
-  curatorGroup.curatorIds.push(curatorId.toNumber())
+  curatorGroup.curators.push(curator)
 
   // set last update time
   curatorGroup.updatedAt = new Date(event.blockTimestamp)
@@ -98,7 +125,14 @@ export async function content_CuratorRemoved({ store, event }: EventContext & St
     return inconsistentState('Non-existing curator group removal requested', curatorGroupId)
   }
 
-  const curatorIndex = curatorGroup.curatorIds.indexOf(curatorId.toNumber())
+  // load curator
+  const curator = await getCurator(store, curatorId.toString())
+
+  if (!curator) {
+    return inconsistentState('Non-existing curator removal from curator group requested', curatorGroupId)
+  }
+
+  const curatorIndex = curatorGroup.curators.findIndex((item) => item.id.toString() === curator.toString())
 
   // ensure curator group exists
   if (curatorIndex < 0) {
@@ -106,7 +140,7 @@ export async function content_CuratorRemoved({ store, event }: EventContext & St
   }
 
   // update curator group
-  curatorGroup.curatorIds.splice(curatorIndex, 1)
+  curatorGroup.curators.splice(curatorIndex, 1)
 
   // save curator group
   await store.save<CuratorGroup>(curatorGroup)

+ 1 - 0
query-node/mappings/src/content/index.ts

@@ -1,3 +1,4 @@
 export * from './channel'
 export * from './curatorGroup'
 export * from './video'
+export * from './nft'

+ 932 - 0
query-node/mappings/src/content/nft.ts

@@ -0,0 +1,932 @@
+// TODO: solve events' relations to videos and other entites that can be changed or deleted
+
+import { DatabaseManager, EventContext, StoreContext, SubstrateEvent } from '@joystream/hydra-common'
+import { genericEventFields, inconsistentState, logger } from '../common'
+import {
+  // entities
+  Auction,
+  AuctionType,
+  AuctionTypeEnglish,
+  AuctionTypeOpen,
+  Bid,
+  Membership,
+  OwnedNft,
+  Video,
+  TransactionalStatus,
+  TransactionalStatusInitiatedOfferToMember,
+  TransactionalStatusIdle,
+  TransactionalStatusBuyNow,
+  TransactionalStatusAuction,
+  TransactionalStatusUpdate,
+  ContentActor,
+  ContentActorMember,
+  ContentActorCurator,
+  ContentActorLead,
+  Curator,
+
+  // events
+  AuctionStartedEvent,
+  NftIssuedEvent,
+  AuctionBidMadeEvent,
+  AuctionBidCanceledEvent,
+  AuctionCanceledEvent,
+  EnglishAuctionCompletedEvent,
+  BidMadeCompletingAuctionEvent,
+  OpenAuctionBidAcceptedEvent,
+  OfferStartedEvent,
+  OfferAcceptedEvent,
+  OfferCanceledEvent,
+  NftSellOrderMadeEvent,
+  NftBoughtEvent,
+  BuyNowCanceledEvent,
+} from 'query-node/dist/model'
+import * as joystreamTypes from '@joystream/types/augment/all/types'
+import { Content } from '../../generated/types'
+import { FindConditions } from 'typeorm'
+import BN from 'bn.js'
+
+// definition of generic type for Hydra DatabaseManager's methods
+type EntityType<T> = {
+  new (...args: any[]): T
+}
+
+async function getExistingEntity<Type extends Video | Membership>(
+  store: DatabaseManager,
+  entityType: EntityType<Type>,
+  id: string,
+  relations: string[] = []
+): Promise<Type | undefined> {
+  // load entity
+  const entity = await store.get(entityType, { where: { id }, relations })
+
+  return entity
+}
+
+async function getRequiredExistingEntity<Type extends Video | Membership>(
+  store: DatabaseManager,
+  entityType: EntityType<Type>,
+  id: string,
+  errorMessage: string,
+  relations: string[] = []
+): Promise<Type> {
+  const entity = await getExistingEntity(store, entityType, id, relations)
+
+  // ensure video exists
+  if (!entity) {
+    return inconsistentState(errorMessage, id)
+  }
+
+  return entity
+}
+
+async function getCurrentAuctionFromVideo(
+  store: DatabaseManager,
+  videoId: string,
+  errorMessageForVideo: string,
+  errorMessageForAuction: string,
+  relations: string[] = []
+): Promise<{ video: Video; auction: Auction }> {
+  // load video
+  const video = await getRequiredExistingEntity(store, Video, videoId.toString(), errorMessageForVideo, [
+    'nft',
+    'nft.auctions',
+    ...relations.map((item) => `nft.auctions.${item}`),
+  ])
+
+  // get auction
+  const allAuctions = video.nft?.auctions || []
+  const auction = allAuctions.length ? allAuctions[allAuctions.length - 1] : null
+
+  // ensure auction exists
+  if (!auction) {
+    return inconsistentState(errorMessageForAuction, videoId)
+  }
+
+  return {
+    video,
+    auction,
+  }
+}
+
+async function getNftFromVideo(
+  store: DatabaseManager,
+  videoId: string,
+  errorMessageForVideo: string,
+  errorMessageForNft: string
+): Promise<{ video: Video; nft: OwnedNft }> {
+  // load video
+  const video = await getRequiredExistingEntity(store, Video, videoId.toString(), errorMessageForVideo, ['nft'])
+
+  // get auction
+  const nft = video.nft
+
+  // ensure auction exists
+  if (!nft) {
+    return inconsistentState(errorMessageForNft, videoId)
+  }
+
+  return {
+    video,
+    nft,
+  }
+}
+
+async function resetNftTransactionalStatusFromVideo(
+  store: DatabaseManager,
+  videoId: string,
+  errorMessage: string,
+  blockNumber: number,
+  newOwner?: Membership
+) {
+  // load NFT
+  const nft = await store.get(OwnedNft, { where: { id: videoId.toString() } as FindConditions<OwnedNft> })
+
+  // ensure NFT
+  if (!nft) {
+    return inconsistentState(errorMessage, videoId.toString())
+  }
+
+  if (newOwner) {
+    nft.ownerMember = newOwner
+  }
+
+  // reset transactional status
+  const transactionalStatus = new TransactionalStatusIdle()
+  await setNewNftTransactionalStatus(store, nft, transactionalStatus, blockNumber)
+}
+
+async function getRequiredExistingEntites<Type extends Video | Membership>(
+  store: DatabaseManager,
+  entityType: EntityType<Type>,
+  ids: string[],
+  errorMessage: string
+): Promise<Type[]> {
+  // load entities
+  const entities = await store.getMany(entityType, { where: { id: ids } })
+
+  // assess loaded entity ids
+  const loadedEntityIds = entities.map((item) => item.id.toString())
+
+  // ensure all entities exists
+  if (loadedEntityIds.length !== ids.length) {
+    const missingIds = ids.filter((item) => !loadedEntityIds.includes(item))
+
+    return inconsistentState(errorMessage, missingIds)
+  }
+
+  // ensure entities are ordered as requested
+  entities.sort((a, b) => ids.indexOf(a.id.toString()) - ids.indexOf(b.id.toString()))
+
+  return entities
+}
+
+async function convertContentActor(
+  store: DatabaseManager,
+  contentActor: joystreamTypes.ContentActor
+): Promise<typeof ContentActor> {
+  if (contentActor.isMember) {
+    const memberId = contentActor.asMember.toNumber()
+    const member = await store.get(Membership, { where: { id: memberId.toString() } as FindConditions<Membership> })
+
+    // ensure member exists
+    if (!member) {
+      return inconsistentState(`Actor is non-existing member`, memberId)
+    }
+
+    const result = new ContentActorMember()
+    result.member = member
+
+    return result
+  }
+
+  if (contentActor.isCurator) {
+    const curatorId = contentActor.asCurator[1].toNumber()
+    const curator = await store.get(Curator, {
+      where: { id: curatorId.toString() } as FindConditions<Curator>,
+    })
+
+    // ensure curator group exists
+    if (!curator) {
+      return inconsistentState('Actor is non-existing curator group', curatorId)
+    }
+
+    const result = new ContentActorCurator()
+    result.curator = curator
+
+    return result
+  }
+
+  if (contentActor.isLead) {
+    return new ContentActorLead()
+  }
+
+  logger.error('Not implemented ContentActor type', { contentActor: contentActor.toString() })
+  throw new Error('Not-implemented ContentActor type used')
+}
+
+async function setNewNftTransactionalStatus(
+  store: DatabaseManager,
+  nft: OwnedNft,
+  transactionalStatus: typeof TransactionalStatus,
+  blockNumber: number
+) {
+  // update transactionalStatus
+  nft.transactionalStatus = transactionalStatus
+
+  // save NFT
+  await store.save<OwnedNft>(nft)
+
+  // create transactional status update record
+  const transactionalStatusUpdate = new TransactionalStatusUpdate({
+    nft,
+    transactionalStatus: nft.transactionalStatus,
+    changedAt: blockNumber,
+  })
+
+  // save update record
+  await store.save<TransactionalStatusUpdate>(transactionalStatusUpdate)
+}
+
+async function finishAuction(store: DatabaseManager, videoId: number, blockNumber: number) {
+  // load video and auction
+  const { video, auction } = await getCurrentAuctionFromVideo(
+    store,
+    videoId.toString(),
+    `Non-existing video's auction was completed`,
+    'Non-existing auction was completed',
+    ['lastBid', 'lastBid.bidder']
+  )
+
+  // load winner member
+  const winnerMemberId = (auction.lastBid as Bid).bidder.id
+  const winner = await getRequiredExistingEntity(
+    store,
+    Membership,
+    winnerMemberId.toString(),
+    'Non-existing auction winner'
+  )
+
+  // update NFT's transactional status
+  await resetNftTransactionalStatusFromVideo(
+    store,
+    videoId.toString(),
+    `Non-existing NFT's auction completed`,
+    blockNumber,
+    winner
+  )
+
+  // update auction
+  auction.isCompleted = true
+  auction.winningMember = winner
+  auction.endedAtBlock = blockNumber
+
+  // save auction
+  await store.save<Auction>(auction)
+
+  return { video, winner }
+}
+
+async function createBid(
+  event: SubstrateEvent,
+  store: DatabaseManager,
+  memberId: number,
+  videoId: number,
+  bidAmount?: string
+) {
+  // load member
+  const member = await getRequiredExistingEntity(
+    store,
+    Membership,
+    memberId.toString(),
+    'Non-existing member bid in auction'
+  )
+
+  // load video and auction
+  const { video, auction } = await getCurrentAuctionFromVideo(
+    store,
+    videoId.toString(),
+    'Non-existing video got bid',
+    'Non-existing auction got bid canceled'
+  )
+
+  const amount = bidAmount ? new BN(bidAmount.toString()) : (auction.buyNowPrice as BN)
+
+  // prepare bid record
+  const bid = new Bid({
+    auction,
+    bidder: member,
+    amount: amount,
+    createdAt: new Date(event.blockTimestamp),
+    createdInBlock: event.blockNumber,
+    isCanceled: false,
+  })
+
+  // save bid
+  await store.save<Bid>(bid)
+
+  // update last bid in auction
+  auction.lastBid = bid
+
+  await store.save<Auction>(auction)
+
+  return { auction, member, video }
+}
+
+export async function createNft(
+  store: DatabaseManager,
+  video: Video,
+  nftIssuanceParameters: joystreamTypes.NftIssuanceParameters,
+  blockNumber: number
+): Promise<OwnedNft> {
+  // load owner
+  const ownerMember = nftIssuanceParameters.non_channel_owner.isSome
+    ? await getExistingEntity(store, Membership, nftIssuanceParameters.non_channel_owner.unwrap().toString())
+    : undefined
+
+  // calculate some values
+  const creatorRoyalty = nftIssuanceParameters.royalty.isSome
+    ? nftIssuanceParameters.royalty.unwrap().toNumber()
+    : undefined
+  const decodedMetadata = nftIssuanceParameters.nft_metadata.toString()
+
+  // prepare nft record
+  const nft = new OwnedNft({
+    id: video.id.toString(),
+    video: video,
+    ownerMember,
+    creatorRoyalty,
+    metadata: decodedMetadata,
+    // always start with Idle status to prevent egg-chicken problem between auction+nft; update it later if needed
+    transactionalStatus: new TransactionalStatusIdle(),
+  })
+
+  // save nft
+  await store.save<OwnedNft>(nft)
+
+  // update NFT transactional status
+  const transactionalStatus = await convertTransactionalStatus(
+    nftIssuanceParameters.init_transactional_status,
+    store,
+    nft,
+    blockNumber
+  )
+  await setNewNftTransactionalStatus(store, nft, transactionalStatus, blockNumber)
+
+  return nft
+}
+
+async function createAuction(
+  store: DatabaseManager,
+  nft: OwnedNft, // expects `nft.ownerMember` to be available
+  auctionParams: joystreamTypes.AuctionParams,
+  blockNumber: number
+): Promise<Auction> {
+  const whitelistedMembers = await getRequiredExistingEntites(
+    store,
+    Membership,
+    Array.from(auctionParams.whitelist.values()).map((item) => item.toString()),
+    'Non-existing members whitelisted'
+  )
+
+  // prepare auction record
+  const auction = new Auction({
+    nft: nft,
+    initialOwner: nft.ownerMember,
+    startingPrice: auctionParams.starting_price,
+    buyNowPrice: new BN(auctionParams.buy_now_price.toString()),
+    auctionType: createAuctionType(auctionParams.auction_type),
+    minimalBidStep: auctionParams.minimal_bid_step,
+    startsAtBlock: auctionParams.starts_at.isSome ? auctionParams.starts_at.unwrap().toNumber() : blockNumber,
+    plannedEndAtBlock: auctionParams.auction_type.isEnglish
+      ? blockNumber + auctionParams.auction_type.asEnglish.auction_duration.toNumber()
+      : undefined,
+    isCanceled: false,
+    isCompleted: false,
+    whitelistedMembers,
+  })
+
+  // save auction
+  await store.save<Auction>(auction)
+
+  return auction
+}
+
+export async function convertTransactionalStatus(
+  transactionalStatus: joystreamTypes.InitTransactionalStatus,
+  store: DatabaseManager,
+  nft: OwnedNft,
+  blockNumber: number
+): Promise<typeof TransactionalStatus> {
+  if (transactionalStatus.isIdle) {
+    return new TransactionalStatusIdle()
+  }
+
+  if (transactionalStatus.isInitiatedOfferToMember) {
+    const status = new TransactionalStatusInitiatedOfferToMember()
+    status.memberId = transactionalStatus.asInitiatedOfferToMember[0].toNumber()
+    if (transactionalStatus.asInitiatedOfferToMember[1].isSome) {
+      status.price = transactionalStatus.asInitiatedOfferToMember[1].unwrap().toBn()
+    }
+
+    return status
+  }
+
+  if (transactionalStatus.isAuction) {
+    const auctionParams = transactionalStatus.asAuction
+
+    // create new auction
+    const auction = await createAuction(store, nft, auctionParams, blockNumber)
+
+    const status = new TransactionalStatusAuction()
+    status.auctionId = auction.id
+
+    return status
+  }
+
+  logger.error('Not implemented TransactionalStatus type', { contentActor: transactionalStatus.toString() })
+  throw new Error('Not-implemented TransactionalStatus type used')
+}
+
+export async function contentNft_AuctionStarted({ event, store }: EventContext & StoreContext): Promise<void> {
+  // common event processing
+
+  const [contentActor, videoId, auctionParams] = new Content.AuctionStartedEvent(event).params
+
+  // specific event processing
+
+  // load video
+  const video = await getRequiredExistingEntity(
+    store,
+    Video,
+    videoId.toString(),
+    `Non-existing video's auction started`,
+    ['nft', 'nft.ownerMember']
+  )
+
+  // ensure NFT has been issued
+  if (!video.nft) {
+    return inconsistentState('Non-existing NFT auctioned', video.id.toString())
+  }
+
+  const nft = video.nft
+
+  const auction = await createAuction(store, nft, auctionParams, event.blockNumber)
+
+  // update NFT transactional status
+  const transactionalStatus = new TransactionalStatusAuction()
+  transactionalStatus.auctionId = auction.id
+  await setNewNftTransactionalStatus(store, nft, transactionalStatus, event.blockNumber)
+
+  // common event processing - second
+
+  const announcingPeriodStartedEvent = new AuctionStartedEvent({
+    ...genericEventFields(event),
+
+    actor: await convertContentActor(store, contentActor),
+    video,
+    auction,
+  })
+
+  await store.save<AuctionStartedEvent>(announcingPeriodStartedEvent)
+}
+
+// create auction type variant from raw runtime auction type
+function createAuctionType(rawAuctionType: joystreamTypes.AuctionType): typeof AuctionType {
+  // auction type `english`
+  if (rawAuctionType.isEnglish) {
+    const rawType = rawAuctionType.asEnglish
+
+    // prepare auction variant
+    const auctionType = new AuctionTypeEnglish()
+    auctionType.duration = rawType.auction_duration.toNumber()
+    auctionType.extensionPeriod = rawType.extension_period.toNumber()
+    return auctionType
+  }
+
+  // auction type `open`
+  const rawType = rawAuctionType.asOpen
+
+  // prepare auction variant
+  const auctionType = new AuctionTypeOpen()
+  auctionType.bidLockingTime = rawType.bid_lock_duration.toNumber()
+  return auctionType
+}
+
+export async function contentNft_NftIssued({ event, store }: EventContext & StoreContext): Promise<void> {
+  // common event processing
+
+  const [actor, videoId, nftIssuanceParameters] = new Content.NftIssuedEvent(event).params
+
+  // specific event processing
+
+  // load video
+  const video = await getRequiredExistingEntity(store, Video, videoId.toString(), 'NFT for non-existing video issed')
+
+  // prepare and save nft record
+  const nft = await createNft(store, video, nftIssuanceParameters, event.blockNumber)
+
+  // common event processing - second
+
+  const announcingPeriodStartedEvent = new NftIssuedEvent({
+    ...genericEventFields(event),
+
+    contentActor: await convertContentActor(store, actor),
+    video,
+    royalty: nft.creatorRoyalty,
+    metadata: nft.metadata,
+    newOwner: nft.ownerMember,
+  })
+
+  await store.save<NftIssuedEvent>(announcingPeriodStartedEvent)
+}
+
+export async function contentNft_AuctionBidMade({ event, store }: EventContext & StoreContext): Promise<void> {
+  // common event processing
+
+  const [memberId, videoId, bidAmount, extendsAuction] = new Content.AuctionBidMadeEvent(event).params
+
+  // specific event processing
+
+  // create record for winning bid
+  const { member, video } = await createBid(event, store, memberId.toNumber(), videoId.toNumber(), bidAmount.toString())
+
+  // common event processing - second
+
+  const announcingPeriodStartedEvent = new AuctionBidMadeEvent({
+    ...genericEventFields(event),
+
+    member,
+    video,
+    bidAmount,
+    extendsAuction: extendsAuction.isTrue,
+  })
+
+  await store.save<AuctionBidMadeEvent>(announcingPeriodStartedEvent)
+}
+
+export async function contentNft_AuctionBidCanceled({ event, store }: EventContext & StoreContext): Promise<void> {
+  // common event processing
+
+  const [memberId, videoId] = new Content.AuctionBidCanceledEvent(event).params
+
+  // specific event processing
+
+  // load video and auction
+  const { video, auction } = await getCurrentAuctionFromVideo(
+    store,
+    videoId.toString(),
+    'Non-existing video got bid canceled',
+    'Non-existing auction got bid canceled',
+    ['lastBid']
+  )
+
+  // ensure bid exists
+  if (!auction.lastBid) {
+    return inconsistentState('Non-existing bid got canceled', auction.id.toString())
+  }
+
+  auction.lastBid.isCanceled = true
+
+  // save auction
+  await store.save<Bid>(auction.lastBid)
+
+  // unset auction's last bid
+  auction.lastBid = undefined
+
+  // save auction
+  await store.save<Auction>(auction)
+
+  // common event processing - second
+
+  const announcingPeriodStartedEvent = new AuctionBidCanceledEvent({
+    ...genericEventFields(event),
+
+    member: new Membership({ id: memberId.toString() }),
+    video,
+  })
+
+  await store.save<AuctionBidCanceledEvent>(announcingPeriodStartedEvent)
+}
+
+export async function contentNft_AuctionCanceled({ event, store }: EventContext & StoreContext): Promise<void> {
+  // common event processing
+
+  const [contentActor, videoId] = new Content.AuctionCanceledEvent(event).params
+
+  // specific event processing
+
+  // load video and auction
+  const { video, auction } = await getCurrentAuctionFromVideo(
+    store,
+    videoId.toString(),
+    'Non-existing video got bid canceled',
+    'Non-existing auction got bid canceled'
+  )
+
+  // update NFT's transactional status
+  await resetNftTransactionalStatusFromVideo(
+    store,
+    videoId.toString(),
+    `Non-existing NFT's auction canceled`,
+    event.blockNumber
+  )
+
+  // mark auction as canceled
+  auction.isCanceled = true
+
+  // save auction
+  await store.save<Auction>(auction)
+
+  // common event processing - second
+
+  const announcingPeriodStartedEvent = new AuctionCanceledEvent({
+    ...genericEventFields(event),
+
+    contentActor: await convertContentActor(store, contentActor),
+    video,
+  })
+
+  await store.save<AuctionCanceledEvent>(announcingPeriodStartedEvent)
+}
+
+export async function contentNft_EnglishAuctionCompleted({ event, store }: EventContext & StoreContext): Promise<void> {
+  // common event processing
+
+  // memberId ignored here because it references member that called extrinsic - that can be anyone!
+  const [, /* memberId */ videoId] = new Content.EnglishAuctionCompletedEvent(event).params
+
+  // specific event processing
+
+  const { winner, video } = await finishAuction(store, videoId.toNumber(), event.blockNumber)
+
+  // common event processing - second
+
+  const announcingPeriodStartedEvent = new EnglishAuctionCompletedEvent({
+    ...genericEventFields(event),
+
+    winner,
+    video,
+  })
+
+  await store.save<EnglishAuctionCompletedEvent>(announcingPeriodStartedEvent)
+}
+
+// called when auction bid's value is higher than buy-now value
+export async function contentNft_BidMadeCompletingAuction({
+  event,
+  store,
+}: EventContext & StoreContext): Promise<void> {
+  // common event processing
+
+  const [memberId, videoId] = new Content.BidMadeCompletingAuctionEvent(event).params
+
+  // specific event processing
+
+  // create record for winning bid
+  await createBid(event, store, memberId.toNumber(), videoId.toNumber())
+
+  // winish auction and transfer ownership
+  const { winner: member, video } = await finishAuction(store, videoId.toNumber(), event.blockNumber)
+
+  // common event processing - second
+
+  const announcingPeriodStartedEvent = new BidMadeCompletingAuctionEvent({
+    ...genericEventFields(event),
+
+    member,
+    video,
+  })
+
+  await store.save<BidMadeCompletingAuctionEvent>(announcingPeriodStartedEvent)
+}
+
+export async function contentNft_OpenAuctionBidAccepted({ event, store }: EventContext & StoreContext): Promise<void> {
+  // common event processing
+
+  const [contentActor, videoId] = new Content.OpenAuctionBidAcceptedEvent(event).params
+
+  // specific event processing
+
+  const { video } = await finishAuction(store, videoId.toNumber(), event.blockNumber)
+
+  // common event processing - second
+
+  const announcingPeriodStartedEvent = new OpenAuctionBidAcceptedEvent({
+    ...genericEventFields(event),
+
+    contentActor: await convertContentActor(store, contentActor),
+    video,
+  })
+
+  await store.save<OpenAuctionBidAcceptedEvent>(announcingPeriodStartedEvent)
+}
+
+export async function contentNft_OfferStarted({ event, store }: EventContext & StoreContext): Promise<void> {
+  // common event processing
+
+  const [videoId, contentActor, memberId, price] = new Content.OfferStartedEvent(event).params
+
+  // specific event processing
+
+  // load NFT
+  const { video, nft } = await getNftFromVideo(
+    store,
+    videoId.toString(),
+    'Non-existing video was offered',
+    'Non-existing nft was offered'
+  )
+
+  // update NFT transactional status
+  const transactionalStatus = new TransactionalStatusInitiatedOfferToMember()
+  transactionalStatus.memberId = memberId.toNumber()
+  transactionalStatus.price = price.unwrapOr(undefined)
+  await setNewNftTransactionalStatus(store, nft, transactionalStatus, event.blockNumber)
+
+  // common event processing - second
+
+  const announcingPeriodStartedEvent = new OfferStartedEvent({
+    ...genericEventFields(event),
+
+    video,
+    contentActor: await convertContentActor(store, contentActor),
+    member: new Membership({ id: memberId.toString() }),
+    price: price.unwrapOr(undefined),
+  })
+
+  await store.save<OfferStartedEvent>(announcingPeriodStartedEvent)
+}
+
+export async function contentNft_OfferAccepted({ event, store }: EventContext & StoreContext): Promise<void> {
+  // common event processing
+
+  const [videoId] = new Content.OfferAcceptedEvent(event).params
+
+  // specific event processing
+
+  // load NFT
+  const { video, nft } = await getNftFromVideo(
+    store,
+    videoId.toString(),
+    'Non-existing video sell offer was accepted',
+    'Non-existing nft sell offer was accepted'
+  )
+
+  // read member from offer
+  const memberId = (nft.transactionalStatus as TransactionalStatusInitiatedOfferToMember).memberId
+  const member = new Membership({ id: memberId.toString() })
+
+  // update NFT's transactional status
+  await resetNftTransactionalStatusFromVideo(
+    store,
+    videoId.toString(),
+    `Non-existing NFT's offer accepted`,
+    event.blockNumber,
+    member
+  )
+
+  // common event processing - second
+
+  const announcingPeriodStartedEvent = new OfferAcceptedEvent({
+    ...genericEventFields(event),
+
+    video,
+  })
+
+  await store.save<OfferAcceptedEvent>(announcingPeriodStartedEvent)
+}
+
+export async function contentNft_OfferCanceled({ event, store }: EventContext & StoreContext): Promise<void> {
+  // common event processing
+
+  const [videoId, contentActor] = new Content.OfferCanceledEvent(event).params
+
+  // specific event processing
+
+  // load video
+  const video = await getRequiredExistingEntity(
+    store,
+    Video,
+    videoId.toString(),
+    'Non-existing video sell offer was canceled'
+  )
+
+  // update NFT's transactional status
+  await resetNftTransactionalStatusFromVideo(
+    store,
+    videoId.toString(),
+    `Non-existing NFT's offer canceled`,
+    event.blockNumber
+  )
+
+  // common event processing - second
+
+  const announcingPeriodStartedEvent = new OfferCanceledEvent({
+    ...genericEventFields(event),
+
+    video,
+    contentActor: await convertContentActor(store, contentActor),
+  })
+
+  await store.save<OfferCanceledEvent>(announcingPeriodStartedEvent)
+}
+
+export async function contentNft_NftSellOrderMade({ event, store }: EventContext & StoreContext): Promise<void> {
+  // common event processing
+
+  const [videoId, contentActor, price] = new Content.NftSellOrderMadeEvent(event).params
+
+  // specific event processing
+
+  // load NFT
+  const { video, nft } = await getNftFromVideo(
+    store,
+    videoId.toString(),
+    'Non-existing video was offered',
+    'Non-existing nft was offered'
+  )
+
+  // update NFT transactional status
+  const transactionalStatus = new TransactionalStatusBuyNow()
+  transactionalStatus.price = new BN(price.toString())
+  await setNewNftTransactionalStatus(store, nft, transactionalStatus, event.blockNumber)
+
+  // common event processing - second
+
+  const announcingPeriodStartedEvent = new NftSellOrderMadeEvent({
+    ...genericEventFields(event),
+
+    video,
+    contentActor: await convertContentActor(store, contentActor),
+    price,
+  })
+
+  await store.save<NftSellOrderMadeEvent>(announcingPeriodStartedEvent)
+}
+
+export async function contentNft_NftBought({ event, store }: EventContext & StoreContext): Promise<void> {
+  // common event processing
+
+  const [videoId, memberId] = new Content.NftBoughtEvent(event).params
+
+  // specific event processing
+
+  // load video
+  const video = await getRequiredExistingEntity(store, Video, videoId.toString(), 'Non-existing video was bought')
+
+  // read member
+  const winner = new Membership({ id: memberId.toString() })
+
+  // update NFT's transactional status
+  await resetNftTransactionalStatusFromVideo(
+    store,
+    videoId.toString(),
+    `Non-existing NFT's auction completed`,
+    event.blockNumber,
+    winner
+  )
+
+  // common event processing - second
+
+  const announcingPeriodStartedEvent = new NftBoughtEvent({
+    ...genericEventFields(event),
+
+    video,
+    member: winner,
+  })
+
+  await store.save<NftBoughtEvent>(announcingPeriodStartedEvent)
+}
+
+export async function contentNft_BuyNowCanceled({ event, store }: EventContext & StoreContext): Promise<void> {
+  // common event processing
+
+  const [videoId, contentActor] = new Content.BuyNowCanceledEvent(event).params
+
+  // specific event processing
+
+  // load video
+  const video = await getRequiredExistingEntity(
+    store,
+    Video,
+    videoId.toString(),
+    'Non-existing video buy-now was canceled'
+  )
+
+  await resetNftTransactionalStatusFromVideo(
+    store,
+    videoId.toString(),
+    `Non-existing NFT's buy-now canceled`,
+    event.blockNumber
+  )
+
+  // common event processing - second
+
+  const announcingPeriodStartedEvent = new BuyNowCanceledEvent({
+    ...genericEventFields(event),
+
+    video,
+    contentActor: await convertContentActor(store, contentActor),
+  })
+
+  await store.save<BuyNowCanceledEvent>(announcingPeriodStartedEvent)
+}

+ 133 - 1
query-node/mappings/src/content/utils.ts

@@ -62,6 +62,11 @@ const ASSET_TYPES = {
   ],
 } as const
 
+// all relations that need to be loaded for updating active video counters when deleting content
+export const videoRelationsForCountersBare = ['channel', 'channel.category', 'category']
+// all relations that need to be loaded for full evalution of video active status to work
+export const videoRelationsForCounters = [...videoRelationsForCountersBare, 'thumbnailPhoto', 'media']
+
 async function processChannelAssets(
   { event, store }: EventContext & StoreContext,
   assets: StorageDataObject[],
@@ -487,9 +492,12 @@ export async function unsetAssetRelations(store: DatabaseManager, dataObject: St
         id: dataObject.id,
       },
     })),
-    relations: [...videoAssets],
+    relations: [...videoAssets, ...videoRelationsForCountersBare],
   })
 
+  // remember if video is fully active before update
+  const wasFullyActive = video && getVideoActiveStatus(video)
+
   if (channel) {
     channelAssets.forEach((assetName) => {
       if (channel[assetName] && channel[assetName]?.id === dataObject.id) {
@@ -513,10 +521,134 @@ export async function unsetAssetRelations(store: DatabaseManager, dataObject: St
     })
     await store.save<Video>(video)
 
+    // update video active counters
+    await updateVideoActiveCounters(store, wasFullyActive, undefined)
+
     // emit log event
     logger.info('Content has been disconnected from Video', {
       videoId: video.id.toString(),
       dataObjectId: dataObject.id,
     })
   }
+
+  // remove data object
+  await store.remove<StorageDataObject>(dataObject)
+}
+
+export interface IVideoActiveStatus {
+  isFullyActive: boolean
+  video: Video
+  videoCategory: VideoCategory | undefined
+  channel: Channel
+  channelCategory: ChannelCategory | undefined
+}
+
+export function getVideoActiveStatus(video: Video): IVideoActiveStatus {
+  const isFullyActive =
+    !!video.isPublic && !video.isCensored && !!video.thumbnailPhoto?.isAccepted && !!video.media?.isAccepted
+
+  const videoCategory = video.category
+  const channel = video.channel
+  const channelCategory = channel.category
+
+  return {
+    isFullyActive,
+    video,
+    videoCategory,
+    channel,
+    channelCategory,
+  }
+}
+
+export async function updateVideoActiveCounters(
+  store: DatabaseManager,
+  initialActiveStatus: IVideoActiveStatus | null | undefined,
+  activeStatus: IVideoActiveStatus | null | undefined
+): Promise<void> {
+  async function updateSingleEntity<Entity extends VideoCategory | Channel>(
+    entity: Entity,
+    counterChange: number
+  ): Promise<void> {
+    entity.activeVideosCounter += counterChange
+
+    await store.save(entity)
+  }
+
+  async function reflectUpdate<Entity extends VideoCategory | Channel>(
+    oldEntity: Entity | undefined,
+    newEntity: Entity | undefined,
+    initFullyActive: boolean,
+    nowFullyActive: boolean
+  ): Promise<void> {
+    if (!oldEntity && !newEntity) {
+      return
+    }
+
+    const didEntityChange = oldEntity?.id.toString() !== newEntity?.id.toString()
+    const didFullyActiveChange = initFullyActive !== nowFullyActive
+
+    // escape if nothing changed
+    if (!didEntityChange && !didFullyActiveChange) {
+      return
+    }
+
+    if (!didEntityChange) {
+      // && didFullyActiveChange
+      const counterChange = nowFullyActive ? 1 : -1
+
+      await updateSingleEntity(newEntity as Entity, counterChange)
+
+      return
+    }
+
+    // didEntityChange === true
+
+    if (oldEntity && initFullyActive) {
+      // if video was fully active before, prepare to decrease counter
+      const counterChange = -1
+
+      await updateSingleEntity(oldEntity, counterChange)
+    }
+
+    if (newEntity && nowFullyActive) {
+      // if video is fully active now, prepare to increase counter
+      const counterChange = 1
+
+      await updateSingleEntity(newEntity, counterChange)
+    }
+  }
+
+  const items = ['videoCategory', 'channel', 'channelCategory']
+  const promises = items.map(
+    async (item) =>
+      await reflectUpdate(
+        initialActiveStatus?.[item],
+        activeStatus?.[item],
+        initialActiveStatus?.isFullyActive || false,
+        activeStatus?.isFullyActive || false
+      )
+  )
+  await Promise.all(promises)
+}
+
+export async function updateChannelCategoryVideoActiveCounter(
+  store: DatabaseManager,
+  originalCategory: ChannelCategory | undefined,
+  newCategory: ChannelCategory | undefined,
+  videosCount: number
+): Promise<void> {
+  // escape if no counter change needed
+  if (!videosCount || originalCategory === newCategory) {
+    return
+  }
+
+  if (originalCategory) {
+    originalCategory.activeVideosCounter -= videosCount
+    await store.save<ChannelCategory>(originalCategory)
+  }
+
+  if (newCategory) {
+    newCategory.activeVideosCounter += videosCount
+    await store.save<ChannelCategory>(newCategory)
+  }
 }

+ 55 - 5
query-node/mappings/src/content/video.ts

@@ -5,11 +5,18 @@ import { EventContext, StoreContext } from '@joystream/hydra-common'
 import { In } from 'typeorm'
 import { Content } from '../../generated/types'
 import { deserializeMetadata, inconsistentState, logger } from '../common'
-import { processVideoMetadata } from './utils'
+import {
+  processVideoMetadata,
+  getVideoActiveStatus,
+  updateVideoActiveCounters,
+  videoRelationsForCountersBare,
+  videoRelationsForCounters,
+} from './utils'
 import { Channel, Video, VideoCategory } from 'query-node/dist/model'
 import { VideoMetadata, VideoCategoryMetadata } from '@joystream/metadata-protobuf'
 import { integrateMeta } from '@joystream/metadata-protobuf/utils'
 import _ from 'lodash'
+import { createNft } from './nft'
 
 export async function content_VideoCategoryCreated({ store, event }: EventContext & StoreContext): Promise<void> {
   // read event data
@@ -24,6 +31,8 @@ export async function content_VideoCategoryCreated({ store, event }: EventContex
     id: videoCategoryId.toString(),
     videos: [],
     createdInBlock: event.blockNumber,
+    activeVideosCounter: 0,
+
     // fill in auto-generated fields
     createdAt: new Date(event.blockTimestamp),
     updatedAt: new Date(event.blockTimestamp),
@@ -94,7 +103,10 @@ export async function content_VideoCreated(ctx: EventContext & StoreContext): Pr
   const [, channelId, videoId, videoCreationParameters] = new Content.VideoCreatedEvent(event).params
 
   // load channel
-  const channel = await store.get(Channel, { where: { id: channelId.toString() } })
+  const channel = await store.get(Channel, {
+    where: { id: channelId.toString() },
+    relations: ['category'],
+  })
 
   // ensure channel exists
   if (!channel) {
@@ -119,6 +131,18 @@ export async function content_VideoCreated(ctx: EventContext & StoreContext): Pr
   // save video
   await store.save<Video>(video)
 
+  if (videoCreationParameters.auto_issue_nft.isSome) {
+    const issuanceParameters = videoCreationParameters.auto_issue_nft.unwrap()
+
+    await createNft(store, video, issuanceParameters, event.blockNumber)
+  }
+
+  // update video active counters (if needed)
+  const videoActiveStatus = getVideoActiveStatus(video)
+  if (videoActiveStatus.isFullyActive) {
+    await updateVideoActiveCounters(store, undefined, videoActiveStatus)
+  }
+
   // emit log event
   logger.info('Video has been created', { id: videoId })
 }
@@ -131,7 +155,7 @@ export async function content_VideoUpdated(ctx: EventContext & StoreContext): Pr
   // load video
   const video = await store.get(Video, {
     where: { id: videoId.toString() },
-    relations: ['channel', 'license'],
+    relations: [...videoRelationsForCounters, 'license'],
   })
 
   // ensure video exists
@@ -139,6 +163,9 @@ export async function content_VideoUpdated(ctx: EventContext & StoreContext): Pr
     return inconsistentState('Non-existing video update requested', videoId)
   }
 
+  // remember if video is fully active before update
+  const initialVideoActiveStatus = getVideoActiveStatus(video)
+
   // prepare changed metadata
   const newMetadataBytes = videoUpdateParameters.new_meta.unwrapOr(null)
 
@@ -154,6 +181,9 @@ export async function content_VideoUpdated(ctx: EventContext & StoreContext): Pr
   // save video
   await store.save<Video>(video)
 
+  // update video active counters
+  await updateVideoActiveCounters(store, initialVideoActiveStatus, getVideoActiveStatus(video))
+
   // emit log event
   logger.info('Video has been updated', { id: videoId })
 }
@@ -163,16 +193,27 @@ export async function content_VideoDeleted({ store, event }: EventContext & Stor
   const [, videoId] = new Content.VideoDeletedEvent(event).params
 
   // load video
-  const video = await store.get(Video, { where: { id: videoId.toString() } })
+  const video = await store.get(Video, {
+    where: { id: videoId.toString() },
+    relations: [...videoRelationsForCountersBare],
+  })
 
   // ensure video exists
   if (!video) {
     return inconsistentState('Non-existing video deletion requested', videoId)
   }
 
+  // remember if video is fully active before update
+  const initialVideoActiveStatus = getVideoActiveStatus(video)
+
   // remove video
   await store.remove<Video>(video)
 
+  // update video active counters (if needed)
+  if (initialVideoActiveStatus.isFullyActive) {
+    await updateVideoActiveCounters(store, initialVideoActiveStatus, undefined)
+  }
+
   // emit log event
   logger.info('Video has been deleted', { id: videoId })
 }
@@ -185,13 +226,19 @@ export async function content_VideoCensorshipStatusUpdated({
   const [, videoId, isCensored] = new Content.VideoCensorshipStatusUpdatedEvent(event).params
 
   // load video
-  const video = await store.get(Video, { where: { id: videoId.toString() } })
+  const video = await store.get(Video, {
+    where: { id: videoId.toString() },
+    relations: [...videoRelationsForCounters],
+  })
 
   // ensure video exists
   if (!video) {
     return inconsistentState('Non-existing video censoring requested', videoId)
   }
 
+  // remember if video is fully active before update
+  const initialVideoActiveStatus = getVideoActiveStatus(video)
+
   // update video
   video.isCensored = isCensored.isTrue
 
@@ -201,6 +248,9 @@ export async function content_VideoCensorshipStatusUpdated({
   // save video
   await store.save<Video>(video)
 
+  // update video active counters
+  await updateVideoActiveCounters(store, initialVideoActiveStatus, getVideoActiveStatus(video))
+
   // emit log event
   logger.info('Video censorship status has been updated', { id: videoId, isCensored: isCensored.isTrue })
 }

+ 73 - 4
query-node/mappings/src/storage/index.ts

@@ -18,9 +18,16 @@ import {
   StorageDataObject,
   StorageSystemParameters,
   GeoCoordinates,
+  Video,
 } from 'query-node/dist/model'
 import BN from 'bn.js'
 import { getById, inconsistentState } from '../common'
+import {
+  getVideoActiveStatus,
+  updateVideoActiveCounters,
+  videoRelationsForCounters,
+  unsetAssetRelations,
+} from '../content/utils'
 import {
   processDistributionBucketFamilyMetadata,
   processDistributionOperatorMetadata,
@@ -29,7 +36,6 @@ import {
 import {
   createDataObjects,
   getStorageSystem,
-  removeDataObject,
   getStorageBucketWithOperatorMetadata,
   getBag,
   getDynamicBagId,
@@ -42,6 +48,7 @@ import {
   distributionOperatorId,
   distributionBucketIdByFamilyAndIndex,
 } from './utils'
+import { In } from 'typeorm'
 
 // STORAGE BUCKETS
 
@@ -228,13 +235,54 @@ export async function storage_DataObjectsUploaded({ event, store }: EventContext
 
 export async function storage_PendingDataObjectsAccepted({ event, store }: EventContext & StoreContext): Promise<void> {
   const [, , bagId, dataObjectIds] = new Storage.PendingDataObjectsAcceptedEvent(event).params
-  const dataObjects = await getDataObjectsInBag(store, bagId, dataObjectIds)
+  const dataObjects = await getDataObjectsInBag(store, bagId, dataObjectIds, ['videoThumbnail', 'videoMedia'])
+
+  // get ids of videos that are in relation with accepted data objects
+  const notUniqueVideoIds = dataObjects
+    .map((item) => [item.videoMedia?.id.toString(), item.videoThumbnail?.id.toString()])
+    .flat()
+    .filter((item) => item)
+  const videoIds = [...new Set(notUniqueVideoIds)]
+
+  // load videos
+  const videosPre = await store.getMany(Video, {
+    where: { id: In(videoIds) },
+    relations: videoRelationsForCounters,
+  })
+
+  // remember if videos are fully active before data objects update
+  const initialActiveStates = videosPre.map((video) => getVideoActiveStatus(video)).filter((item) => item)
+
+  // accept storage data objects
   await Promise.all(
     dataObjects.map(async (dataObject) => {
       dataObject.isAccepted = true
       await store.save<StorageDataObject>(dataObject)
     })
   )
+
+  /*
+    This approach of reloading videos one by one is not optimal, but it is straightforward algorithm.
+
+    This reduces otherwise complex situation caused by `store.get*` functions not return objects
+    shared by mutliple entities (at least now). Because of that when updating for example
+    `dataObject.videoThumnail.channel.activeVideoCounter` on dataObject A, this change is not
+    reflected on `dataObject.videoMedia.channel.activeVideoCounter` on dataObject B.
+
+    We can upgrade this algorithm in the future if this event mapping proves to have serious
+    performance issues. In that case, a unit test for this mapping will be required.
+  */
+  // load relevant videos one by one and update related active-video-counters
+  for (const initialActiveState of initialActiveStates) {
+    // load refreshed version of videos and related entities (channel, channel category, category)
+
+    const video = (await store.get(Video, {
+      where: { id: initialActiveState.video.id.toString() },
+      relations: videoRelationsForCounters,
+    })) as Video
+
+    await updateVideoActiveCounters(store, initialActiveState, getVideoActiveStatus(video))
+  }
 }
 
 export async function storage_DataObjectsMoved({ event, store }: EventContext & StoreContext): Promise<void> {
@@ -251,8 +299,29 @@ export async function storage_DataObjectsMoved({ event, store }: EventContext &
 
 export async function storage_DataObjectsDeleted({ event, store }: EventContext & StoreContext): Promise<void> {
   const [, bagId, dataObjectIds] = new Storage.DataObjectsDeletedEvent(event).params
-  const dataObjects = await getDataObjectsInBag(store, bagId, dataObjectIds)
-  await Promise.all(dataObjects.map((o) => removeDataObject(store, o)))
+  const dataObjects = await getDataObjectsInBag(store, bagId, dataObjectIds, [
+    'videoThumbnail',
+    ...videoRelationsForCounters.map((item) => `videoThumbnail.${item}`),
+    'videoMedia',
+    ...videoRelationsForCounters.map((item) => `videoMedia.${item}`),
+  ])
+
+  await Promise.all(
+    dataObjects.map(async (dataObject) => {
+      // remember if video is fully active before update
+      const initialVideoActiveStatus =
+        (dataObject.videoThumbnail && getVideoActiveStatus(dataObject.videoThumbnail)) ||
+        (dataObject.videoMedia && getVideoActiveStatus(dataObject.videoMedia)) ||
+        null
+
+      await unsetAssetRelations(store, dataObject)
+
+      // update video active counters
+      if (initialVideoActiveStatus) {
+        await updateVideoActiveCounters(store, initialVideoActiveStatus, undefined)
+      }
+    })
+  )
 }
 
 // DISTRIBUTION FAMILY

+ 3 - 7
query-node/mappings/src/storage/utils.ts

@@ -19,7 +19,6 @@ import {
 import BN from 'bn.js'
 import { bytesToString, inconsistentState, getById, RelationsArr } from '../common'
 import { In } from 'typeorm'
-import { unsetAssetRelations } from '../content/utils'
 
 import { BTreeSet } from '@polkadot/types'
 import _ from 'lodash'
@@ -38,13 +37,15 @@ import { Balance } from '@polkadot/types/interfaces'
 export async function getDataObjectsInBag(
   store: DatabaseManager,
   bagId: BagId,
-  dataObjectIds: BTreeSet<DataObjectId>
+  dataObjectIds: BTreeSet<DataObjectId>,
+  relations: string[] = []
 ): Promise<StorageDataObject[]> {
   const dataObjects = await store.getMany(StorageDataObject, {
     where: {
       id: In(Array.from(dataObjectIds).map((id) => id.toString())),
       storageBag: { id: getBagId(bagId) },
     },
+    relations,
   })
   if (dataObjects.length !== Array.from(dataObjectIds).length) {
     throw new Error(
@@ -244,11 +245,6 @@ export async function getMostRecentlyCreatedDataObjects(
   return objects.sort((a, b) => new BN(a.id).cmp(new BN(b.id)))
 }
 
-export async function removeDataObject(store: DatabaseManager, object: StorageDataObject): Promise<void> {
-  await unsetAssetRelations(store, object)
-  await store.remove<StorageDataObject>(object)
-}
-
 export function distributionBucketId(runtimeBucketId: DistributionBucketId): string {
   const { distribution_bucket_family_id: familyId, distribution_bucket_index: bucketIndex } = runtimeBucketId
   return distributionBucketIdByFamilyAndIndex(familyId, bucketIndex)

+ 6 - 5
query-node/package.json

@@ -1,7 +1,7 @@
 {
   "name": "query-node-root",
   "version": "0.1.0",
-  "description": "GraphQL server and mappings. Generated with \u2665 by Hydra-CLI",
+  "description": "GraphQL server and mappings. Generated with  by Hydra-CLI",
   "scripts": {
     "build": "./build.sh",
     "start": "./start.sh",
@@ -35,12 +35,13 @@
   "author": "",
   "license": "ISC",
   "dependencies": {
-    "tslib": "^2.0.0",
+    "@joystream/hydra-processor": "3.1.0-alpha.16",
+    "@polkadot/metadata": "^4.17.1",
     "@types/bn.js": "^4.11.6",
     "bn.js": "^5.1.2",
-    "@joystream/hydra-processor": "3.1.0-alpha.16"
+    "tslib": "^2.0.0"
   },
   "volta": {
-		"extends": "../package.json"
-	}
+    "extends": "../package.json"
+  }
 }

+ 12 - 13
query-node/schemas/content.graphql

@@ -5,6 +5,9 @@ type ChannelCategory @entity {
   "The name of the category"
   name: String @fulltext(query: "channelCategoriesByName")
 
+  "Count of channel's videos with an uploaded asset that are public and not censored."
+  activeVideosCounter: Int!
+
   channels: [Channel!]! @derivedFrom(field: "category")
 
   createdInBlock: Int!
@@ -41,6 +44,9 @@ type Channel @entity {
   "The description of a Channel"
   description: String
 
+  "Count of channel's videos with an uploaded asset that are public and not censored."
+  activeVideosCounter: Int!
+
   "Channel's cover (background) photo asset. Recommended ratio: 16:9."
   coverPhoto: StorageDataObject
 
@@ -68,19 +74,6 @@ type Channel @entity {
   collaborators: [Membership!]
 }
 
-type CuratorGroup @entity {
-  "Runtime identifier"
-  id: ID!
-
-  "Curators belonging to this group"
-  curatorIds: [Int!]!
-
-  "Is group active or not"
-  isActive: Boolean!
-
-  channels: [Channel!]! @derivedFrom(field: "ownerCuratorGroup")
-}
-
 type VideoCategory @entity {
   "Runtime identifier"
   id: ID!
@@ -88,6 +81,9 @@ type VideoCategory @entity {
   "The name of the category"
   name: String @fulltext(query: "videoCategoriesByName")
 
+  "Count of channel's videos with an uploaded asset that are public and not censored."
+  activeVideosCounter: Int!
+
   videos: [Video!]! @derivedFrom(field: "category")
 
   createdInBlock: Int!
@@ -132,6 +128,9 @@ type Video @entity {
   "Flag signaling whether a video is censored."
   isCensored: Boolean!
 
+  "Video NFT details"
+  nft: OwnedNft
+
   "Whether the Video contains explicit material."
   isExplicit: Boolean
 

+ 218 - 0
query-node/schemas/contentNft.graphql

@@ -0,0 +1,218 @@
+# TODO: add runtime ids to entities (`id: ID!`) where it's needed and possible
+
+# TODO: move `ContentActor*` to `content.graphql` after schema/mappings are finished
+#       keep it here for easier reviews
+type ContentActorCurator @variant {
+  "Type needs to have at least one non-relation entity. This value is not used."
+  dummy: Int
+
+  curator: Curator!
+}
+
+type ContentActorMember @variant {
+  "Type needs to have at least one non-relation entity. This value is not used."
+  dummy: Int
+
+  member: Membership!
+}
+
+type ContentActorLead @variant {
+  "Type needs to have at least one non-relation entity. This value is not used."
+  dummy: Int
+}
+
+type ContentActorCollaborator @variant {
+  "Type needs to have at least one non-relation entity. This value is not used."
+  dummy: Int
+
+  member: Membership!
+}
+
+union ContentActor = ContentActorCurator | ContentActorMember | ContentActorLead | ContentActorCollaborator
+
+type CuratorGroup @entity {
+  "Runtime identifier"
+  id: ID!
+
+  "Is group active or not"
+  isActive: Boolean!
+
+  "Curators belonging to this group"
+  curators: [Curator!]! @derivedFrom(field: "curatorGroups")
+
+  "Channels curated by this group"
+  channels: [Channel!]! @derivedFrom(field: "ownerCuratorGroup")
+}
+
+type Curator @entity {
+  "Runtime identifier"
+  id: ID!
+
+  "Type needs to have at least one non-relation entity. This value is not used."
+  dummy: Int
+
+  curatorGroups: [CuratorGroup!]!
+}
+
+"Represents NFT details"
+type OwnedNft
+  @entity { # NFT in name can't be UPPERCASE because it causes codegen errors
+  "NFT's video"
+  video: Video! @derivedFrom(field: "nft")
+
+  "Auctions done for this NFT"
+  auctions: [Auction!]! @derivedFrom(field: "nft")
+
+  "Member owning the NFT. Channel owner owns the NFT if not set."
+  ownerMember: Membership
+
+  "NFT's metadata"
+  metadata: String!
+
+  "NFT transactional status"
+  transactionalStatus: TransactionalStatus!
+
+  "History of transacional status changes"
+  transactionalStatusUpdates: [TransactionalStatusUpdate!]! @derivedFrom(field: "nft")
+
+  "Creator royalty"
+  creatorRoyalty: Float
+}
+
+type TransactionalStatusUpdate @entity {
+  "Video NFT details"
+  nft: OwnedNft!
+
+  "NFT transactional status"
+  transactionalStatus: TransactionalStatus!
+
+  "Block number at which change happened"
+  changedAt: Int!
+}
+
+"NFT transactional state"
+union TransactionalStatus =
+    TransactionalStatusIdle
+  | TransactionalStatusInitiatedOfferToMember
+  | TransactionalStatusAuction
+  | TransactionalStatusBuyNow
+
+"Represents TransactionalStatus Idle"
+type TransactionalStatusIdle @variant {
+  "Type needs to have at least one non-relation entity. This value is not used."
+  dummy: Int
+}
+
+"Represents TransactionalStatus InitiatedOfferToMember"
+type TransactionalStatusInitiatedOfferToMember @variant {
+  "Member identifier"
+  memberId: Int!
+
+  "Whether member should pay to accept offer (optional)"
+  price: BigInt
+}
+
+"Represents TransactionalStatus Auction"
+type TransactionalStatusAuction @variant {
+  "Type needs to have at least one non-relation entity. This value is not used."
+  dummy: Int
+
+  "Auction"
+  auction: Auction!
+}
+
+"Represents TransactionalStatus BuyNow"
+type TransactionalStatusBuyNow @variant {
+  price: BigInt!
+}
+
+"Represents various action types"
+union AuctionType = AuctionTypeEnglish | AuctionTypeOpen
+
+"Represents English auction details"
+type AuctionTypeEnglish @variant {
+  "English auction duration"
+  duration: Int!
+
+  "Auction extension time"
+  extensionPeriod: Int
+}
+
+"Represents Open auction details"
+type AuctionTypeOpen @variant {
+  "Auction bid lock duration"
+  bidLockingTime: Int!
+}
+
+"Represents NFT auction"
+type Auction @entity {
+  "Auctioned NFT"
+  nft: OwnedNft!
+
+  "Member starting NFT auction. If not set channel owner started auction"
+  initialOwner: Membership
+
+  "Member that won this auction"
+  winningMember: Membership
+
+  "Auction starting price"
+  startingPrice: BigInt!
+
+  "Whether auction can be completed instantly"
+  buyNowPrice: BigInt
+
+  # TODO: maybe there is a need to distinguish regular auction completion from buy now
+
+  "The type of auction"
+  auctionType: AuctionType!
+
+  "Minimal step between auction bids"
+  minimalBidStep: BigInt!
+
+  "Auction last bid (if exists)"
+  lastBid: Bid
+
+  bids: [Bid!]! @derivedFrom(field: "auction")
+
+  "Block when auction starts"
+  startsAtBlock: Int!
+
+  "Block when auction ended"
+  endedAtBlock: Int
+
+  "Block when auction is supposed to end"
+  plannedEndAtBlock: Int
+
+  "Is auction canceled"
+  isCanceled: Boolean!
+
+  "Is auction completed"
+  isCompleted: Boolean!
+
+  "Auction participants whitelist"
+  whitelistedMembers: [Membership!]! @derivedFrom(field: "whitelistedInAuctions")
+}
+
+"Represents bid in NFT auction"
+type Bid @entity {
+  "NFT's auction"
+  auction: Auction!
+
+  "Bidder membership"
+  bidder: Membership!
+
+  # TODO: probably remove that
+  #"Bidder account, used to pay for NFT"
+  #bidderAccount: String!
+
+  "Amount bidded"
+  amount: BigInt!
+
+  "Sign for canceled bid"
+  isCanceled: Boolean!
+
+  "Block in which the bid was placed"
+  createdInBlock: Int!
+}
+
+# TODO entity for (cancelable) offers; will be needed to see history of offers

+ 402 - 0
query-node/schemas/contentNftEvents.graphql

@@ -0,0 +1,402 @@
+type AuctionStartedEvent implements Event @entity {
+  ### GENERIC DATA ###
+
+  "(network}-{blockNumber}-{indexInBlock}."
+  id: ID!
+
+  "Hash of the extrinsic which caused the event to be emitted."
+  inExtrinsic: String
+
+  "Blocknumber of the block in which the event was emitted."
+  inBlock: Int!
+
+  "Network the block was produced in."
+  network: Network!
+
+  "Index of event in block from which it was emitted."
+  indexInBlock: Int!
+
+  ### SPECIFIC DATA ###
+
+  "Actor that started this auction."
+  actor: ContentActor!
+
+  "Video that's being auctioned."
+  video: Video!
+
+  "Auction started."
+  auction: Auction!
+}
+
+type NftIssuedEvent implements Event @entity {
+  ### GENERIC DATA ###
+
+  "(network}-{blockNumber}-{indexInBlock}."
+  id: ID!
+
+  "Hash of the extrinsic which caused the event to be emitted."
+  inExtrinsic: String
+
+  "Blocknumber of the block in which the event was emitted."
+  inBlock: Int!
+
+  "Network the block was produced in."
+  network: Network!
+
+  "Index of event in block from which it was emitted."
+  indexInBlock: Int!
+
+  ### SPECIFIC DATA ###
+
+  "Content actor that issued the NFT."
+  contentActor: ContentActor!
+
+  "Video represented via NFT."
+  video: Video!
+
+  "Royalty for the NFT/video."
+  royalty: Float
+
+  # TODO: inspect if metadata can be unpacked and mean something useful
+  "NFT's metadata."
+  metadata: String!
+
+  "Member NFT was originally issued to."
+  newOwner: Membership
+}
+
+type AuctionBidMadeEvent implements Event @entity {
+  ### GENERIC DATA ###
+
+  "(network}-{blockNumber}-{indexInBlock}."
+  id: ID!
+
+  "Hash of the extrinsic which caused the event to be emitted."
+  inExtrinsic: String
+
+  "Blocknumber of the block in which the event was emitted."
+  inBlock: Int!
+
+  "Network the block was produced in."
+  network: Network!
+
+  "Index of event in block from which it was emitted."
+  indexInBlock: Int!
+
+  ### SPECIFIC DATA ###
+
+  "Member bidding in the auction."
+  member: Membership!
+
+  "Video that's bidden on."
+  video: Video!
+
+  "Bid made."
+  bidAmount: BigInt!
+
+  "Sign of auction duration being extended by making this bid."
+  extendsAuction: Boolean!
+}
+
+type AuctionBidCanceledEvent implements Event @entity {
+  ### GENERIC DATA ###
+
+  "(network}-{blockNumber}-{indexInBlock}."
+  id: ID!
+
+  "Hash of the extrinsic which caused the event to be emitted."
+  inExtrinsic: String
+
+  "Blocknumber of the block in which the event was emitted."
+  inBlock: Int!
+
+  "Network the block was produced in."
+  network: Network!
+
+  "Index of event in block from which it was emitted."
+  indexInBlock: Int!
+
+  ### SPECIFIC DATA ###
+
+  "Auction that canceled the bid."
+  member: Membership!
+
+  "Auctioned video."
+  video: Video!
+}
+
+type AuctionCanceledEvent implements Event @entity {
+  ### GENERIC DATA ###
+
+  "(network}-{blockNumber}-{indexInBlock}."
+  id: ID!
+
+  "Hash of the extrinsic which caused the event to be emitted."
+  inExtrinsic: String
+
+  "Blocknumber of the block in which the event was emitted."
+  inBlock: Int!
+
+  "Network the block was produced in."
+  network: Network!
+
+  "Index of event in block from which it was emitted."
+  indexInBlock: Int!
+
+  ### SPECIFIC DATA ###
+
+  "Content actor canceling the event."
+  contentActor: ContentActor!
+
+  "Auctioned video."
+  video: Video!
+}
+
+type EnglishAuctionCompletedEvent implements Event @entity {
+  ### GENERIC DATA ###
+
+  "(network}-{blockNumber}-{indexInBlock}."
+  id: ID!
+
+  "Hash of the extrinsic which caused the event to be emitted."
+  inExtrinsic: String
+
+  "Blocknumber of the block in which the event was emitted."
+  inBlock: Int!
+
+  "Network the block was produced in."
+  network: Network!
+
+  "Index of event in block from which it was emitted."
+  indexInBlock: Int!
+
+  ### SPECIFIC DATA ###
+
+  "Member claiming the auctioned NFT."
+  winner: Membership!
+
+  "Auctioned video."
+  video: Video!
+}
+
+type BidMadeCompletingAuctionEvent implements Event @entity {
+  ### GENERIC DATA ###
+
+  "(network}-{blockNumber}-{indexInBlock}."
+  id: ID!
+
+  "Hash of the extrinsic which caused the event to be emitted."
+  inExtrinsic: String
+
+  "Blocknumber of the block in which the event was emitted."
+  inBlock: Int!
+
+  "Network the block was produced in."
+  network: Network!
+
+  "Index of event in block from which it was emitted."
+  indexInBlock: Int!
+
+  ### SPECIFIC DATA ###
+
+  "Member completing the auction."
+  member: Membership!
+
+  "Auctioned video."
+  video: Video!
+}
+
+type OpenAuctionBidAcceptedEvent implements Event @entity {
+  ### GENERIC DATA ###
+
+  "(network}-{blockNumber}-{indexInBlock}."
+  id: ID!
+
+  "Hash of the extrinsic which caused the event to be emitted."
+  inExtrinsic: String
+
+  "Blocknumber of the block in which the event was emitted."
+  inBlock: Int!
+
+  "Network the block was produced in."
+  network: Network!
+
+  "Index of event in block from which it was emitted."
+  indexInBlock: Int!
+
+  ### SPECIFIC DATA ###
+
+  "Content actor canceling the event."
+  contentActor: ContentActor!
+
+  "Auctioned video."
+  video: Video!
+}
+
+type OfferStartedEvent implements Event @entity {
+  ### GENERIC DATA ###
+
+  "(network}-{blockNumber}-{indexInBlock}."
+  id: ID!
+
+  "Hash of the extrinsic which caused the event to be emitted."
+  inExtrinsic: String
+
+  "Blocknumber of the block in which the event was emitted."
+  inBlock: Int!
+
+  "Network the block was produced in."
+  network: Network!
+
+  "Index of event in block from which it was emitted."
+  indexInBlock: Int!
+
+  ### SPECIFIC DATA ###
+
+  "NFT's video."
+  video: Video!
+
+  "Content actor acting as NFT owner."
+  contentActor: ContentActor!
+
+  "Member that receives the offer."
+  member: Membership!
+
+  "Offer's price."
+  price: BigInt
+}
+
+type OfferAcceptedEvent implements Event @entity {
+  ### GENERIC DATA ###
+
+  "(network}-{blockNumber}-{indexInBlock}."
+  id: ID!
+
+  "Hash of the extrinsic which caused the event to be emitted."
+  inExtrinsic: String
+
+  "Blocknumber of the block in which the event was emitted."
+  inBlock: Int!
+
+  "Network the block was produced in."
+  network: Network!
+
+  "Index of event in block from which it was emitted."
+  indexInBlock: Int!
+
+  ### SPECIFIC DATA ###
+
+  "NFT's video."
+  video: Video!
+}
+
+type OfferCanceledEvent implements Event @entity {
+  ### GENERIC DATA ###
+
+  "(network}-{blockNumber}-{indexInBlock}."
+  id: ID!
+
+  "Hash of the extrinsic which caused the event to be emitted."
+  inExtrinsic: String
+
+  "Blocknumber of the block in which the event was emitted."
+  inBlock: Int!
+
+  "Network the block was produced in."
+  network: Network!
+
+  "Index of event in block from which it was emitted."
+  indexInBlock: Int!
+
+  ### SPECIFIC DATA ###
+
+  "NFT's video."
+  video: Video!
+
+  "Content actor acting as NFT owner."
+  contentActor: ContentActor!
+}
+
+type NftSellOrderMadeEvent @entity {
+  ### GENERIC DATA ###
+
+  "(network}-{blockNumber}-{indexInBlock}."
+  id: ID!
+
+  "Hash of the extrinsic which caused the event to be emitted."
+  inExtrinsic: String
+
+  "Blocknumber of the block in which the event was emitted."
+  inBlock: Int!
+
+  "Network the block was produced in."
+  network: Network!
+
+  "Index of event in block from which it was emitted."
+  indexInBlock: Int!
+
+  ### SPECIFIC DATA ###
+
+  "NFT's video."
+  video: Video!
+
+  "Content actor acting as NFT owner."
+  contentActor: ContentActor!
+
+  "Offer's price."
+  price: BigInt!
+}
+
+type NftBoughtEvent implements Event @entity {
+  ### GENERIC DATA ###
+
+  "(network}-{blockNumber}-{indexInBlock}."
+  id: ID!
+
+  "Hash of the extrinsic which caused the event to be emitted."
+  inExtrinsic: String
+
+  "Blocknumber of the block in which the event was emitted."
+  inBlock: Int!
+
+  "Network the block was produced in."
+  network: Network!
+
+  "Index of event in block from which it was emitted."
+  indexInBlock: Int!
+
+  ### SPECIFIC DATA ###
+
+  "NFT's video."
+  video: Video!
+
+  "Member that bought the NFT."
+  member: Membership!
+}
+
+type BuyNowCanceledEvent implements Event @entity {
+  ### GENERIC DATA ###
+
+  "(network}-{blockNumber}-{indexInBlock}."
+  id: ID!
+
+  "Hash of the extrinsic which caused the event to be emitted."
+  inExtrinsic: String
+
+  "Blocknumber of the block in which the event was emitted."
+  inBlock: Int!
+
+  "Network the block was produced in."
+  network: Network!
+
+  "Index of event in block from which it was emitted."
+  indexInBlock: Int!
+
+  ### SPECIFIC DATA ###
+
+  "NFT's video."
+  video: Video!
+
+  "Content actor acting as NFT owner."
+  contentActor: ContentActor!
+}

+ 6 - 0
query-node/schemas/membership.graphql

@@ -109,6 +109,12 @@ type Membership @entity {
 
   "Elected councils' memberships of the member."
   councilMembers: [CouncilMember!] @derivedFrom(field: "member")
+
+  "Auctions in which is this user whitelisted to participate"
+  whitelistedInAuctions: [Auction!]! @derivedFrom(field: "whitelistedMembers")
+
+  "NFTs owned by this member"
+  ownedNfts: [OwnedNft!]! @derivedFrom(field: "ownerMember")
 }
 
 type MembershipSystemSnapshot @entity {

+ 23 - 4
query-node/schemas/storage.graphql

@@ -38,7 +38,10 @@ type StorageBucketOperatorStatusActive @variant {
   transactorAccountId: String!
 }
 
-union StorageBucketOperatorStatus = StorageBucketOperatorStatusMissing | StorageBucketOperatorStatusInvited | StorageBucketOperatorStatusActive
+union StorageBucketOperatorStatus =
+    StorageBucketOperatorStatusMissing
+  | StorageBucketOperatorStatusInvited
+  | StorageBucketOperatorStatusActive
 
 type GeoCoordinates @entity {
   latitude: Float!
@@ -143,7 +146,12 @@ type StorageBagOwnerDAO @variant {
   daoId: Int
 }
 
-union StorageBagOwner = StorageBagOwnerCouncil | StorageBagOwnerWorkingGroup | StorageBagOwnerMember | StorageBagOwnerChannel | StorageBagOwnerDAO
+union StorageBagOwner =
+    StorageBagOwnerCouncil
+  | StorageBagOwnerWorkingGroup
+  | StorageBagOwnerMember
+  | StorageBagOwnerChannel
+  | StorageBagOwnerDAO
 
 type StorageBag @entity {
   "Storage bag id"
@@ -186,7 +194,12 @@ type DataObjectTypeUnknown @variant {
   _phantom: Int
 }
 
-union DataObjectType = DataObjectTypeChannelAvatar | DataObjectTypeChannelCoverPhoto | DataObjectTypeVideoMedia | DataObjectTypeVideoThumbnail | DataObjectTypeUnknown
+union DataObjectType =
+    DataObjectTypeChannelAvatar
+  | DataObjectTypeChannelCoverPhoto
+  | DataObjectTypeVideoMedia
+  | DataObjectTypeVideoThumbnail
+  | DataObjectTypeUnknown
 
 type StorageDataObject @entity {
   "Data object runtime id"
@@ -213,6 +226,12 @@ type StorageDataObject @entity {
 
   "If the object is no longer used as an asset - the time at which it was unset (if known)"
   unsetAt: DateTime
+
+  "Video that has this data object associated as thumbnail photo."
+  videoThumbnail: Video @derivedFrom(field: "thumbnailPhoto")
+
+  "Video that has this data object associated as media."
+  videoMedia: Video @derivedFrom(field: "media")
 }
 
 type DistributionBucketFamilyGeographicArea @entity {
@@ -252,7 +271,7 @@ type DistributionBucketOperatorMetadata @entity {
 }
 
 enum DistributionBucketOperatorStatus {
-  INVITED,
+  INVITED
   ACTIVE
 }
 

+ 5 - 16
runtime-modules/content/src/lib.rs

@@ -514,8 +514,8 @@ decl_module! {
             channel_id: T::ChannelId,
             num_objects_to_delete: u64,
         ) -> DispatchResult {
-
             let sender = ensure_signed(origin)?;
+
             // check that channel exists
             let channel = Self::ensure_channel_exists(&channel_id)?;
 
@@ -722,7 +722,6 @@ decl_module! {
                 is_censored: false,
                 enable_comments: params.enable_comments,
                 video_post_id:  None,
-                /// Newly created video has no nft
                 nft_status,
             };
 
@@ -1294,6 +1293,7 @@ decl_module! {
             Self::deposit_event(RawEvent::MinCashoutUpdated(amount));
         }
 
+        /// Issue NFT
         #[weight = 10_000_000] // TODO: adjust weight
         pub fn issue_nft(
             origin,
@@ -1301,7 +1301,6 @@ decl_module! {
             video_id: T::VideoId,
             params: NftIssuanceParameters<T>
         ) {
-
             let sender = ensure_signed(origin)?;
 
             // Ensure given video exists
@@ -1333,10 +1332,7 @@ decl_module! {
             Self::deposit_event(RawEvent::NftIssued(
                 actor,
                 video_id,
-                params.royalty,
-                params.nft_metadata,
-                params.non_channel_owner,
-                params.init_transactional_status
+                params,
             ));
         }
 
@@ -2133,7 +2129,7 @@ decl_event!(
             CurrencyOf<T>,
             <T as common::MembershipTypes>::MemberId,
         >,
-        InitTransactionalStatus = InitTransactionalStatus<T>,
+        NftIssuanceParameters = NftIssuanceParameters<T>,
         Balance = BalanceOf<T>,
         CurrencyAmount = CurrencyOf<T>,
         ChannelCreationParameters = ChannelCreationParameters<T>,
@@ -2217,14 +2213,7 @@ decl_event!(
         MinCashoutUpdated(Balance),
         // Nft auction
         AuctionStarted(ContentActor, VideoId, AuctionParams),
-        NftIssued(
-            ContentActor,
-            VideoId,
-            Option<Royalty>,
-            Vec<u8>,
-            Option<MemberId>,
-            InitTransactionalStatus,
-        ),
+        NftIssued(ContentActor, VideoId, NftIssuanceParameters),
         AuctionBidMade(MemberId, VideoId, CurrencyAmount, IsExtended),
         AuctionBidCanceled(MemberId, VideoId),
         AuctionCanceled(ContentActor, VideoId),

+ 1 - 1
runtime-modules/content/src/nft/mod.rs

@@ -237,7 +237,7 @@ impl<T: Trait> Module<T> {
             &nft.transactional_status
         {
             // Authorize participant under given member id
-            ensure_member_auth_success::<T>(&participant_account_id, &member_id)?;
+            ensure_member_auth_success::<T>(participant_account_id, &member_id)?;
 
             if let Some(price) = price {
                 Self::ensure_sufficient_free_balance(participant_account_id, *price)?;

+ 3 - 3
runtime-modules/content/src/nft/types.rs

@@ -1,7 +1,7 @@
 use super::*;
 
-/// Metadata for Nft issuance
-pub type MetadataBytes = Vec<u8>;
+/// Metadata for NFT issuance
+pub type NftMetadata = Vec<u8>;
 
 /// Owner royalty
 pub type Royalty = Perbill;
@@ -515,7 +515,7 @@ pub struct NftIssuanceParametersRecord<MemberId, InitTransactionalStatus> {
     /// Roayalty used for the author
     pub royalty: Option<Royalty>,
     /// Metadata
-    pub nft_metadata: MetadataBytes,
+    pub nft_metadata: NftMetadata,
     /// member id Nft will be issued to
     pub non_channel_owner: Option<MemberId>,
     /// Initial transactional status for the nft

+ 1 - 4
runtime-modules/content/src/tests/nft/issue_nft.rs

@@ -51,10 +51,7 @@ fn issue_nft() {
             MetaEvent::content(RawEvent::NftIssued(
                 ContentActor::Member(DEFAULT_MEMBER_ID),
                 video_id,
-                nft_issue_params.royalty,
-                nft_issue_params.nft_metadata,
-                nft_issue_params.non_channel_owner,
-                nft_issue_params.init_transactional_status,
+                nft_issue_params,
             )),
             number_of_events_before_call + 1,
         );

+ 4 - 2
tests/integration-tests/package.json

@@ -18,14 +18,16 @@
     "@joystream/types": "^0.18.0",
     "@polkadot/api": "5.3.2",
     "@polkadot/keyring": "^7.1.1",
-    "@types/async-lock": "^1.1.2",
+    "@types/async-lock": "^1.1.3",
     "@types/bn.js": "^4.11.5",
     "@types/lowdb": "^1.0.9",
-    "async-lock": "^1.2.0",
+    "async-lock": "^1.3.1",
     "bn.js": "^4.11.8",
     "cross-fetch": "^3.0.6",
     "dotenv": "^8.2.0",
     "fs": "^0.0.1-security",
+    "bmp-js": "^0.1.0",
+    "@types/bmp-js": "^0.1.0",
     "uuid": "^7.0.3"
   },
   "devDependencies": {

+ 271 - 8
tests/integration-tests/src/Api.ts

@@ -1,8 +1,8 @@
 import { ApiPromise, WsProvider, Keyring } from '@polkadot/api'
-import { u32, BTreeMap } from '@polkadot/types'
+import { u32, BTreeMap, BTreeSet } from '@polkadot/types'
 import { IEvent, ISubmittableResult } from '@polkadot/types/types'
 import { KeyringPair } from '@polkadot/keyring/types'
-import { AccountId, MemberId, PostId, ThreadId } from '@joystream/types/common'
+import { AccountId, ChannelId, MemberId, PostId, ThreadId } from '@joystream/types/common'
 
 import { AccountInfo, Balance, EventRecord, BlockNumber, BlockHash, LockIdentifier } from '@polkadot/types/interfaces'
 import BN from 'bn.js'
@@ -36,9 +36,11 @@ import {
   CategoryCreatedEventDetails,
   PostAddedEventDetails,
   ThreadCreatedEventDetails,
+  VideoCreatedEventDetails,
   ProposalsCodexEventName,
   ProposalDiscussionPostCreatedEventDetails,
   ProposalsDiscussionEventName,
+  ContentEventName,
 } from './types'
 import {
   ApplicationId,
@@ -48,6 +50,14 @@ import {
   ApplyOnOpeningParameters,
   Worker,
 } from '@joystream/types/working-group'
+import { DataObjectId, StorageBucketId } from '@joystream/types/storage'
+import {
+  AuctionParams,
+  ContentActor,
+  VideoId,
+  VideoCategoryId,
+  VideoCreationParameters,
+} from '@joystream/types/content'
 import { DeriveAllSections } from '@polkadot/api/util/decorate'
 import { ExactDerive } from '@polkadot/api-derive'
 import { ProposalId, ProposalParameters } from '@joystream/types/proposals'
@@ -148,18 +158,18 @@ export class ApiFactory {
     return keys
   }
 
-  private createKeyPair(suriPath: string, isCustom = false): KeyringPair {
+  private createKeyPair(suriPath: string, isCustom = false, isFinalPath = false): KeyringPair {
     if (isCustom) {
       this.customKeys.push(suriPath)
     }
-    const uri = `${this.miniSecret}//testing//${suriPath}`
+    const uri = isFinalPath ? suriPath : `${this.miniSecret}//testing//${suriPath}`
     const pair = this.keyring.addFromUri(uri)
     this.addressesToSuri.set(pair.address, uri)
     return pair
   }
 
-  public createCustomKeyPair(customPath: string): KeyringPair {
-    return this.createKeyPair(customPath, true)
+  public createCustomKeyPair(customPath: string, isFinalPath = false): KeyringPair {
+    return this.createKeyPair(customPath, true, isFinalPath)
   }
 
   public keyGenInfo(): KeyGenInfo {
@@ -255,6 +265,10 @@ export class Api {
     return this.signAndSend(this.api.tx.sudo.sudo(tx), sudo)
   }
 
+  public getKeypair(address: string | AccountId): KeyringPair {
+    return this.factory.getKeypair(address)
+  }
+
   public enableDebugTxLogs(): void {
     this.sender.setLogLevel(LogLevel.Debug)
   }
@@ -271,8 +285,8 @@ export class Api {
     return pairs
   }
 
-  public createCustomKeyPair(path: string): KeyringPair {
-    return this.factory.createCustomKeyPair(path)
+  public createCustomKeyPair(path: string, isFinalPath = false): KeyringPair {
+    return this.factory.createCustomKeyPair(path, isFinalPath)
   }
 
   public keyGenInfo(): KeyGenInfo {
@@ -689,6 +703,17 @@ export class Api {
     }
   }
 
+  public async retrieveContentEventDetails(
+    result: ISubmittableResult,
+    eventName: ContentEventName
+  ): Promise<EventDetails> {
+    const details = await this.retrieveEventDetails(result, 'content', eventName)
+    if (!details) {
+      throw new Error(`${eventName} event details not found in result: ${JSON.stringify(result.toHuman())}`)
+    }
+    return details
+  }
+
   public async getMemberSigners(inputs: { asMember: MemberId }[]): Promise<string[]> {
     return await Promise.all(
       inputs.map(async ({ asMember }) => {
@@ -806,4 +831,242 @@ export class Api {
   lockIdByGroup(group: WorkingGroupModuleName): LockIdentifier {
     return this.api.consts[group].stakingHandlerLockId
   }
+
+  async acceptPendingDataObjects(
+    accountFrom: string,
+    workerId: WorkerId,
+    storageBucketId: StorageBucketId,
+    channelId: string,
+    dataObjectIds: string[]
+  ): Promise<ISubmittableResult> {
+    const bagId = { Dynamic: { Channel: channelId } }
+    const encodedDataObjectIds = new BTreeSet<DataObjectId>(this.api.registry, 'DataObjectId', dataObjectIds)
+
+    return this.sender.signAndSend(
+      this.api.tx.storage.acceptPendingDataObjects(workerId, storageBucketId, bagId, encodedDataObjectIds),
+      accountFrom
+    )
+  }
+
+  async issueNft(
+    accountFrom: string,
+    memberId: number,
+    videoId: number,
+    metadata = '',
+    royaltyPercentage?: number,
+    toMemberId?: number | null
+  ): Promise<ISubmittableResult> {
+    const perbillOnePercent = 10 * 1000000
+
+    const royalty = this.api.createType(
+      'Option<Royalty>',
+      royaltyPercentage ? royaltyPercentage * perbillOnePercent : null
+    )
+    // TODO: find proper way to encode metadata (should they be raw string, hex string or some object?)
+    // const encodedMetadata = this.api.createType('Metadata', metadata)
+    // const encodedMetadata = this.api.createType('Metadata', metadata).toU8a() // invalid type passed to Metadata constructor
+    // const encodedMetadata = this.api.createType('Vec<u8>', metadata)
+    // const encodedMetadata = this.api.createType('Vec<u8>', 'someNonEmptyText') // decodeU8a: failed at 0x736f6d654e6f6e45… on magicNumber: u32:: MagicNumber mismatch: expected 0x6174656d, found 0x656d6f73
+    // const encodedMetadata = this.api.createType('Bytes', 'someNonEmptyText') // decodeU8a: failed at 0x736f6d654e6f6e45… on magicNumber: u32:: MagicNumber mismatch: expected 0x6174656d, found 0x656d6f73
+    // const encodedMetadata = this.api.createType('Metadata', {})
+    // const encodedMetadata = this.api.createType('Bytes', '0x') // error
+    // const encodedMetadata = this.api.createType('NftMetadata', 'someNonEmptyText')
+    // const encodedMetadata = this.api.createType('NftMetadata', 'someNonEmptyText').toU8a() // createType(NftMetadata) // Vec length 604748352930462863646034177481338223 exceeds 65536
+    const encodedMetadata = this.api.createType('NftMetadata', '').toU8a() // THIS IS OK!!! but only for empty string :-\
+    // try this later on // const encodedMetadata = this.api.createType('Vec<u8>', 'someNonEmptyText').toU8a()
+    // const encodedMetadata = this.api.createType('Vec<u8>', 'someNonEmptyText').toU8a() // throws error in QN when decoding this (but mb QN error)
+
+    const encodedToAccount = this.api.createType('Option<MemberId>', toMemberId || memberId)
+
+    const issuanceParameters = this.api.createType('NftIssuanceParameters', {
+      royalty,
+      nft_metadata: encodedMetadata,
+      non_channel_owner: encodedToAccount,
+      init_transactional_status: this.api.createType('InitTransactionalStatus', { Idle: null }),
+    })
+
+    return await this.sender.signAndSend(
+      this.api.tx.content.issueNft({ Member: memberId }, videoId, issuanceParameters),
+      accountFrom
+    )
+  }
+
+  private async getAuctionParametersBoundaries() {
+    const boundaries = {
+      extensionPeriod: {
+        min: await this.api.query.content.minAuctionExtensionPeriod(),
+        max: await this.api.query.content.maxAuctionExtensionPeriod(),
+      },
+      auctionDuration: {
+        min: await this.api.query.content.minAuctionDuration(),
+        max: await this.api.query.content.maxAuctionDuration(),
+      },
+      bidLockDuration: {
+        min: await this.api.query.content.minBidLockDuration(),
+        max: await this.api.query.content.maxBidLockDuration(),
+      },
+      startingPrice: {
+        min: await this.api.query.content.minStartingPrice(),
+        max: await this.api.query.content.maxStartingPrice(),
+      },
+      bidStep: {
+        min: await this.api.query.content.minBidStep(),
+        max: await this.api.query.content.maxBidStep(),
+      },
+    }
+
+    return boundaries
+  }
+
+  async createAuctionParameters(
+    auctionType: 'English' | 'Open',
+    whitelist: string[] = []
+  ): Promise<{
+    auctionParams: AuctionParams
+    startingPrice: BN
+    minimalBidStep: BN
+    bidLockDuration: BN
+    extensionPeriod: BN
+    auctionDuration: BN
+  }> {
+    const boundaries = await this.getAuctionParametersBoundaries()
+
+    // auction duration must be larger than extension period (enforced in runtime)
+    const auctionDuration = BN.max(boundaries.auctionDuration.min, boundaries.extensionPeriod.min)
+
+    const encodedAuctionType =
+      auctionType === 'English'
+        ? {
+            English: {
+              extension_period: boundaries.extensionPeriod.min,
+              auction_duration: auctionDuration,
+            },
+          }
+        : {
+            Open: {
+              bid_lock_duration: boundaries.bidLockDuration.min,
+            },
+          }
+
+    const auctionParams = this.api.createType('AuctionParams', {
+      auction_type: this.api.createType('AuctionType', encodedAuctionType),
+      starting_price: this.api.createType('u128', boundaries.startingPrice.min),
+      minimal_bid_step: this.api.createType('u128', boundaries.bidStep.min),
+      buy_now_price: this.api.createType('Option<BlockNumber>', null),
+      starts_at: this.api.createType('Option<BlockNumber>', null),
+      whitelist: this.api.createType('BTreeSet<StorageBucketId>', whitelist),
+    })
+
+    return {
+      auctionParams,
+      startingPrice: boundaries.startingPrice.min,
+      minimalBidStep: boundaries.bidStep.min,
+      bidLockDuration: boundaries.bidLockDuration.min,
+      extensionPeriod: boundaries.extensionPeriod.min,
+      auctionDuration: auctionDuration,
+    }
+  }
+
+  async startNftAuction(
+    accountFrom: string,
+    memberId: number,
+    videoId: number,
+    auctionParams: AuctionParams
+  ): Promise<ISubmittableResult> {
+    return await this.sender.signAndSend(
+      this.api.tx.content.startNftAuction({ Member: memberId }, videoId, auctionParams),
+      accountFrom
+    )
+  }
+
+  async bidInNftAuction(
+    accountFrom: string,
+    memberId: number,
+    videoId: number,
+    bidAmount: BN
+  ): Promise<ISubmittableResult> {
+    return await this.sender.signAndSend(this.api.tx.content.makeBid(memberId, videoId, bidAmount), accountFrom)
+  }
+
+  async claimWonEnglishAuction(accountFrom: string, memberId: number, videoId: number): Promise<ISubmittableResult> {
+    return await this.sender.signAndSend(this.api.tx.content.claimWonEnglishAuction(memberId, videoId), accountFrom)
+  }
+
+  async pickOpenAuctionWinner(accountFrom: string, memberId: number, videoId: number): Promise<ISubmittableResult> {
+    return await this.sender.signAndSend(
+      this.api.tx.content.pickOpenAuctionWinner({ Member: memberId }, videoId),
+      accountFrom
+    )
+  }
+
+  async cancelOpenAuctionBid(accountFrom: string, participantId: number, videoId: number): Promise<ISubmittableResult> {
+    return await this.sender.signAndSend(this.api.tx.content.cancelOpenAuctionBid(participantId, videoId), accountFrom)
+  }
+
+  async cancelNftAuction(accountFrom: string, ownerId: number, videoId: number): Promise<ISubmittableResult> {
+    return await this.sender.signAndSend(
+      this.api.tx.content.cancelNftAuction({ Member: ownerId }, videoId),
+      accountFrom
+    )
+  }
+
+  async sellNft(accountFrom: string, videoId: number, ownerId: number, price: BN): Promise<ISubmittableResult> {
+    return await this.sender.signAndSend(this.api.tx.content.sellNft(videoId, { Member: ownerId }, price), accountFrom)
+  }
+
+  async buyNft(accountFrom: string, videoId: number, participantId: number): Promise<ISubmittableResult> {
+    return await this.sender.signAndSend(this.api.tx.content.buyNft(videoId, participantId), accountFrom)
+  }
+
+  async offerNft(
+    accountFrom: string,
+    videoId: number,
+    ownerId: number,
+    toMemberId: number,
+    price: BN | null = null
+  ): Promise<ISubmittableResult> {
+    return await this.sender.signAndSend(
+      this.api.tx.content.offerNft(videoId, { Member: ownerId }, toMemberId, price),
+      accountFrom
+    )
+  }
+
+  async acceptIncomingOffer(accountFrom: string, videoId: number): Promise<ISubmittableResult> {
+    return await this.sender.signAndSend(this.api.tx.content.acceptIncomingOffer(videoId), accountFrom)
+  }
+
+  async createVideoWithNftAuction(
+    accountFrom: string,
+    ownerId: number,
+    channeld: number,
+    auctionParams: AuctionParams
+  ): Promise<ISubmittableResult> {
+    const createParameters = this.createType('VideoCreationParameters', {
+      assets: null,
+      meta: null,
+      enable_comments: false,
+      auto_issue_nft: this.api.createType('NftIssuanceParameters', {
+        royalty: null,
+        nft_metadata: this.api.createType('NftMetadata', '').toU8a(),
+        non_channel_owner: ownerId,
+        init_transactional_status: this.api.createType('InitTransactionalStatus', { Auction: auctionParams }),
+      }),
+    })
+
+    return await this.sender.signAndSend(
+      this.api.tx.content.createVideo({ Member: ownerId }, channeld, createParameters),
+      accountFrom
+    )
+  }
+
+  public async retrieveVideoCreatedEventDetails(result: ISubmittableResult): Promise<VideoCreatedEventDetails> {
+    const details = await this.retrieveContentEventDetails(result, 'VideoCreated')
+    return {
+      ...details,
+      actor: details.event.data[0] as ContentActor,
+      channelId: details.event.data[1] as ChannelId,
+      videoId: details.event.data[2] as VideoId,
+      params: details.event.data[3] as VideoCreationParameters,
+    }
+  }
 }

+ 44 - 0
tests/integration-tests/src/QueryNodeApi.ts

@@ -295,6 +295,22 @@ import {
   GetProposalDiscussionThreadsByIdsQuery,
   GetProposalDiscussionThreadsByIdsQueryVariables,
   GetProposalDiscussionThreadsByIds,
+  OwnedNftFieldsFragment,
+  ChannelFieldsFragment,
+  GetChannelsQuery,
+  GetChannelsQueryVariables,
+  GetChannels,
+  ChannelCategoryFieldsFragment,
+  GetChannelCategoriesQuery,
+  GetChannelCategoriesQueryVariables,
+  GetChannelCategories,
+  VideoCategoryFieldsFragment,
+  GetVideoCategoriesQuery,
+  GetVideoCategoriesQueryVariables,
+  GetVideoCategories,
+  GetOwnedNftByVideoId,
+  GetOwnedNftByVideoIdQuery,
+  GetOwnedNftByVideoIdQueryVariables,
 } from './graphql/generated/queries'
 import { Maybe } from './graphql/generated/schema'
 import { OperationDefinitionNode } from 'graphql'
@@ -1071,4 +1087,32 @@ export class QueryNodeApi {
       GetProposalDiscussionThreadsByIdsQueryVariables
     >(GetProposalDiscussionThreadsByIds, { ids: ids.map((id) => id.toString()) }, 'proposalDiscussionThreads')
   }
+
+  public async getChannels(): Promise<ChannelFieldsFragment[]> {
+    return this.multipleEntitiesQuery<GetChannelsQuery, GetChannelsQueryVariables>(GetChannels, {}, 'channels')
+  }
+
+  public async getChannelCategories(): Promise<ChannelCategoryFieldsFragment[]> {
+    return this.multipleEntitiesQuery<GetChannelCategoriesQuery, GetChannelCategoriesQueryVariables>(
+      GetChannelCategories,
+      {},
+      'channelCategories'
+    )
+  }
+
+  public async getVideoCategories(): Promise<VideoCategoryFieldsFragment[]> {
+    return this.multipleEntitiesQuery<GetVideoCategoriesQuery, GetVideoCategoriesQueryVariables>(
+      GetVideoCategories,
+      {},
+      'videoCategories'
+    )
+  }
+
+  public async ownedNftByVideoId(videoId: string): Promise<Maybe<OwnedNftFieldsFragment>> {
+    return this.firstEntityQuery<GetOwnedNftByVideoIdQuery, GetOwnedNftByVideoIdQueryVariables>(
+      GetOwnedNftByVideoId,
+      { videoId },
+      'ownedNfts'
+    )
+  }
 }

+ 3 - 1
tests/integration-tests/src/Scenario.ts

@@ -42,7 +42,7 @@ function writeOutput(api: Api, miniSecret: string) {
   fs.writeFileSync(OUTPUT_FILE_PATH, JSON.stringify(output, undefined, 2))
 }
 
-export async function scenario(scene: (props: ScenarioProps) => Promise<void>): Promise<void> {
+export async function scenario(label: string, scene: (props: ScenarioProps) => Promise<void>): Promise<void> {
   // Load env variables
   config()
   const env = process.env
@@ -87,6 +87,8 @@ export async function scenario(scene: (props: ScenarioProps) => Promise<void>):
 
   const debug = extendDebug('scenario')
 
+  debug(label)
+
   const jobs = new JobManager({ apiFactory, query, env })
 
   await scene({ env, debug, job: jobs.createJob.bind(jobs) })

+ 108 - 0
tests/integration-tests/src/cli/base.ts

@@ -0,0 +1,108 @@
+import path from 'path'
+import { execFile, ChildProcess, PromiseWithChild, ExecFileException, ExecException } from 'child_process'
+import { promisify } from 'util'
+import { Sender } from '../sender'
+import { debuggingCli } from '../consts'
+
+export type CommandResult = {
+  exitCode: number
+  stdout: string
+  stderr: string
+  out: string
+}
+
+export abstract class CLI {
+  protected env: Record<string, string>
+  protected readonly rootPath: string
+  protected readonly binPath: string
+  protected defaultArgs: string[]
+
+  constructor(rootPath: string, defaultEnv: Record<string, string> = {}, defaultArgs: string[] = []) {
+    this.rootPath = rootPath
+    this.binPath = path.resolve(rootPath, './bin/run')
+    this.env = {
+      ...process.env,
+      AUTO_CONFIRM: 'true',
+      FORCE_COLOR: '0',
+      ...defaultEnv,
+    }
+    this.defaultArgs = [...defaultArgs]
+  }
+
+  protected getArgs(customArgs: string[]): string[] {
+    return [...this.defaultArgs, ...customArgs]
+  }
+
+  protected getFlagStringValue(args: string[], flag: string, alias?: string): string | undefined {
+    const flagIndex = args.lastIndexOf(flag)
+    const aliasIndex = alias ? args.lastIndexOf(alias) : -1
+    const flagOrAliasIndex = Math.max(flagIndex, aliasIndex)
+    if (flagOrAliasIndex === -1) {
+      return undefined
+    }
+    const nextArg = args[flagOrAliasIndex + 1]
+    return nextArg
+  }
+
+  async run(
+    command: string,
+    customArgs: string[] = [],
+    lockKeys: string[] = [],
+    requireSuccess = true,
+    timeoutMs = 2 * 60 * 1000 // prevents infinite execution time
+  ): Promise<CommandResult> {
+    const defaultError = 1
+
+    const pExecFile = promisify(execFile)
+    const { env } = this
+    const { stdout, stderr, exitCode } = await Sender.asyncLock.acquire(
+      lockKeys.map((k) => `nonce-${k}`),
+
+      async () => {
+        if (debuggingCli) {
+          console.log(
+            'Running CLI command: ',
+            `AUTO_CONFIRM=true HOME="${env.HOME}"`,
+            this.binPath,
+            [command, ...this.getArgs(customArgs)].join(' ')
+          )
+        }
+        try {
+          // execute command and wait for std outputs (or error)
+          const execOutputs = await pExecFile(this.binPath, [command, ...this.getArgs(customArgs)], {
+            env,
+            cwd: this.rootPath,
+          })
+
+          // return outputs and exit code
+          return {
+            ...execOutputs,
+            exitCode: 0,
+          }
+        } catch (error) {
+          const errorTyped = error as ExecFileException & { stdout: string; stderr: string }
+          // escape if command's success is required
+          if (requireSuccess) {
+            throw error
+          }
+
+          return {
+            exitCode: errorTyped.code || defaultError,
+            stdout: errorTyped.stdout || '',
+            stderr: errorTyped.stderr || '',
+          }
+        }
+      },
+      {
+        maxOccupationTime: timeoutMs, // sets execution timeout
+      } as any // needs cast to any because type `maxOccupation` is missing in types for async-lock v1.1.3
+    )
+
+    return {
+      exitCode,
+      stdout,
+      stderr,
+      out: stdout.trim(),
+    }
+  }
+}

+ 227 - 0
tests/integration-tests/src/cli/joystream.ts

@@ -0,0 +1,227 @@
+import { KeyringPair } from '@polkadot/keyring/types'
+import path from 'path'
+import { CLI, CommandResult } from './base'
+import { TmpFileManager } from './utils'
+import { MemberId } from '@joystream/types/common'
+
+const CLI_ROOT_PATH = path.resolve(__dirname, '../../../../cli')
+
+export interface ICreatedVideoData {
+  videoId: number
+  assetContentIds: string[]
+}
+
+export class JoystreamCLI extends CLI {
+  protected keys: string[] = []
+  protected tmpFileManager: TmpFileManager
+
+  constructor(tmpFileManager: TmpFileManager) {
+    const defaultEnv = {
+      HOME: tmpFileManager.tmpDataDir,
+    }
+
+    super(CLI_ROOT_PATH, defaultEnv)
+    this.tmpFileManager = tmpFileManager
+  }
+
+  /**
+    Inits all required connections, etc.
+  */
+  async init(): Promise<void> {
+    await this.run('api:setUri', [process.env.NODE_URL || 'ws://127.0.0.1:9944'])
+    await this.run('api:setQueryNodeEndpoint', [process.env.QUERY_NODE_URL || 'http://127.0.0.1:8081/graphql'])
+  }
+
+  /**
+    Imports accounts key to CLI.
+  */
+  async importAccount(pair: KeyringPair): Promise<void> {
+    const password = ''
+    const jsonFile = this.tmpFileManager.jsonFile(pair.toJson())
+    await this.run('account:import', [
+      '--backupFilePath',
+      jsonFile,
+      '--name',
+      `Account${this.keys.length}`,
+      '--password',
+      password,
+    ])
+    this.keys.push(pair.address)
+  }
+
+  /**
+    Runs Joystream CLI command.
+  */
+  async run(
+    command: string,
+    customArgs: string[] = [],
+    keyLocks?: string[],
+    requireSuccess = true
+  ): Promise<CommandResult> {
+    return super.run(command, customArgs, keyLocks || this.keys, requireSuccess)
+  }
+
+  /**
+    Getter for temporary-file manager.
+  */
+  public getTmpFileManager(): TmpFileManager {
+    return this.tmpFileManager
+  }
+
+  /**
+    Parses `id` of newly created content entity from CLI's stdout.
+  */
+  private parseCreatedIdFromOutput(text: string): number {
+    return parseInt((text.match(/with id (\d+) successfully created/) as RegExpMatchArray)[1])
+  }
+
+  /**
+    Checks if CLI's stderr contains warning about no storage provider available.
+  */
+  private containsWarningNoStorage(stderr: string): boolean {
+    return !!stderr.match(/^\s*\S\s*Warning: No storage provider is currently available!/m)
+  }
+
+  /**
+    Checks if CLI's stderr contains warning about no password used when importing account.
+  */
+  private containsWarningEmptyPassword(text: string): boolean {
+    return !!text.match(/^\s*\S\s*Warning: Using empty password is not recommended!/)
+  }
+
+  /**
+    Selects active member for CLI commands.
+  */
+  async chooseMemberAccount(memberId: MemberId) {
+    const { stderr, exitCode } = await this.run('membership:chooseMember', ['--memberId', memberId.toString()])
+
+    if (exitCode && !stderr.match(/^\s*Member switched to id/)) {
+      throw new Error(`Unexpected CLI failure on choosing account: "${stderr}"`)
+    }
+  }
+
+  /**
+    Creates a new channel.
+  */
+  async createChannel(channel: unknown): Promise<number> {
+    const jsonFile = this.tmpFileManager.jsonFile(channel)
+
+    const { stdout, stderr, exitCode } = await this.run('content:createChannel', [
+      '--input',
+      jsonFile,
+      '--context',
+      'Member',
+    ])
+
+    if (exitCode && !this.containsWarningNoStorage(stderr)) {
+      // ignore warnings
+      throw new Error(`Unexpected CLI failure on creating channel: "${stderr}"`)
+    }
+
+    return this.parseCreatedIdFromOutput(stderr)
+  }
+
+  /**
+    Creates a new channel category.
+  */
+  async createChannelCategory(channelCategory: unknown): Promise<number> {
+    const jsonFile = this.tmpFileManager.jsonFile(channelCategory)
+
+    const { stdout, stderr, exitCode } = await this.run('content:createChannelCategory', [
+      '--input',
+      jsonFile,
+      '--context',
+      'Lead',
+    ])
+
+    if (exitCode) {
+      throw new Error(`Unexpected CLI failure on creating channel category: "${stderr}"`)
+    }
+
+    return this.parseCreatedIdFromOutput(stderr)
+  }
+
+  /**
+    Creates a new video.
+  */
+  async createVideo(channelId: number, video: unknown, canOmitUpload = true): Promise<ICreatedVideoData> {
+    const jsonFile = this.tmpFileManager.jsonFile(video)
+
+    const { stdout, stderr, exitCode } = await this.run(
+      'content:createVideo',
+      ['--input', jsonFile, '--channelId', channelId.toString()],
+      undefined,
+      !canOmitUpload
+    )
+
+    // prevent error from CLI that create
+    if (canOmitUpload && exitCode && !this.containsWarningNoStorage(stderr)) {
+      // ignore warnings
+      throw new Error(`Unexpected CLI failure on creating video: "${stderr}"`)
+    }
+
+    const videoId = this.parseCreatedIdFromOutput(stderr)
+    const assetContentIds = Array.from(stdout.matchAll(/ objectId: '([a-z0-9]+)'/g)).map((item) => item[1])
+
+    return {
+      videoId,
+      assetContentIds,
+    }
+  }
+
+  /**
+    Creates a new video category.
+  */
+  async createVideoCategory(videoCategory: unknown): Promise<number> {
+    const jsonFile = this.tmpFileManager.jsonFile(videoCategory)
+
+    const { stdout, stderr, exitCode } = await this.run('content:createVideoCategory', [
+      '--input',
+      jsonFile,
+      '--context',
+      'Lead',
+    ])
+
+    if (exitCode) {
+      throw new Error(`Unexpected CLI failure on creating video category: "${stderr}"`)
+    }
+
+    return this.parseCreatedIdFromOutput(stderr)
+  }
+
+  /**
+    Updates an existing video.
+  */
+  async updateVideo(videoId: number, video: unknown): Promise<void> {
+    const jsonFile = this.tmpFileManager.jsonFile(video)
+
+    const { stdout, stderr, exitCode } = await this.run('content:updateVideo', [
+      '--input',
+      jsonFile,
+      videoId.toString(),
+    ])
+
+    if (exitCode && !this.containsWarningNoStorage(stderr)) {
+      // ignore warnings
+      throw new Error(`Unexpected CLI failure on creating video category: "${stderr}"`)
+    }
+  }
+
+  /**
+    Updates a channel.
+  */
+  async updateChannel(channelId: number, channel: unknown): Promise<void> {
+    const jsonFile = this.tmpFileManager.jsonFile(channel)
+
+    const { stdout, stderr, exitCode } = await this.run('content:updateChannel', [
+      '--input',
+      jsonFile,
+      channelId.toString(),
+    ])
+
+    if (exitCode && !this.containsWarningNoStorage(stderr)) {
+      // ignore warnings
+      throw new Error(`Unexpected CLI failure on creating video category: "${stderr}"`)
+    }
+  }
+}

+ 6 - 3
tests/network-tests/src/cli/utils.ts → tests/integration-tests/src/cli/utils.ts

@@ -6,6 +6,7 @@ import { Utils } from '../utils'
 import _ from 'lodash'
 import bmp from 'bmp-js'
 import nodeCleanup from 'node-cleanup'
+import { debuggingCli } from '../consts'
 
 export class TmpFileManager {
   tmpDataDir: string
@@ -17,9 +18,11 @@ export class TmpFileManager {
       uuid()
     )
     mkdirSync(this.tmpDataDir, { recursive: true })
-    nodeCleanup(() => {
-      rmSync(this.tmpDataDir, { recursive: true, force: true })
-    })
+    if (!debuggingCli) {
+      nodeCleanup(() => {
+        rmSync(this.tmpDataDir, { recursive: true, force: true })
+      })
+    }
   }
 
   public jsonFile(value: unknown): string {

+ 2 - 0
tests/integration-tests/src/consts.ts

@@ -3,6 +3,8 @@ import { AugmentedConsts } from '@polkadot/api/types'
 import BN from 'bn.js'
 import { ProposalType, WorkingGroupModuleName } from './types'
 
+export const debuggingCli = false // set to true to see CLI commands run
+
 // Dummy const type validation function (see: https://stackoverflow.com/questions/57069802/as-const-is-ignored-when-there-is-a-type-definition)
 export const validateType = <T>(obj: T) => obj
 

+ 140 - 0
tests/integration-tests/src/fixtures/content/activeVideoCounters.ts

@@ -0,0 +1,140 @@
+import { assert } from 'chai'
+import { ApolloQueryResult } from '@apollo/client'
+import { Api } from '../../Api'
+import { BaseQueryNodeFixture, FixtureRunner } from '../../Fixture'
+import { BuyMembershipHappyCaseFixture } from '../membership'
+import { KeyringPair } from '@polkadot/keyring/types'
+import { Bytes } from '@polkadot/types'
+import { QueryNodeApi } from '../../QueryNodeApi'
+import BN from 'bn.js'
+import { Worker, WorkerId } from '@joystream/types/working-group'
+
+import {
+  getMemberDefaults,
+  getChannelCategoryDefaults,
+  getChannelDefaults,
+  getVideoDefaults,
+  getVideoCategoryDefaults,
+} from './contentTemplates'
+import { JoystreamCLI, ICreatedVideoData } from '../../cli/joystream'
+import * as path from 'path'
+
+/**
+  Fixture that test Joystream content can be created, is reflected in query node,
+  and channel and categories counts their active videos properly.
+
+  Assuming all videos start in channel, video category, and channel category respectively
+  `channelIds[0]`, `channelCategoryIds[0]`, and `videoCategoryIds[0]`.
+*/
+export class ActiveVideoCountersFixture extends BaseQueryNodeFixture {
+  private cli: JoystreamCLI
+  private channelIds: number[]
+  private videosData: ICreatedVideoData[]
+  private channelCategoryIds: number[]
+  private videoCategoryIds: number[]
+
+  constructor(
+    api: Api,
+    query: QueryNodeApi,
+    cli: JoystreamCLI,
+    channelIds: number[],
+    videosData: ICreatedVideoData[],
+    channelCategoryIds: number[],
+    videoCategoryIds: number[]
+  ) {
+    super(api, query)
+    this.cli = cli
+    this.channelIds = channelIds
+    this.videosData = videosData
+    this.channelCategoryIds = channelCategoryIds
+    this.videoCategoryIds = videoCategoryIds
+  }
+
+  /*
+    Execute this Fixture.
+  */
+  public async execute(): Promise<void> {
+    const videoCount = this.videosData.length
+    const videoCategoryCount = this.videoCategoryIds.length
+    const channelCount = this.channelIds.length
+    const channelCategoryCount = this.channelCategoryIds.length
+
+    // check channel and categories con are counted as active
+
+    this.debug('Checking channels active video counters')
+    await this.assertCounterMatch('channels', this.channelIds[0], videoCount)
+
+    this.debug('Checking channel categories active video counters')
+    await this.assertCounterMatch('channelCategories', this.channelCategoryIds[0], videoCount)
+
+    this.debug('Checking video categories active video counters')
+    await this.assertCounterMatch('videoCategories', this.videoCategoryIds[0], videoCount)
+
+    // move channel to different channel category and video to different videoCategory
+
+    const oneMovedItemCount = 1
+    this.debug('Move channel to different channel category')
+    await this.cli.updateChannel(this.channelIds[0], {
+      category: this.channelCategoryIds[1], // move from category 1 to category 2
+    })
+
+    this.debug('Move video to different video category')
+    await this.cli.updateVideo(this.videosData[0].videoId, {
+      category: this.videoCategoryIds[1], // move from category 1 to category 2
+    })
+
+    // check counters of channel category and video category with newly moved in video/channel
+
+    this.debug('Checking channel categories active video counters (2)')
+    await this.assertCounterMatch('channelCategories', this.channelCategoryIds[1], videoCount)
+
+    this.debug('Checking video categories active video counters (2)')
+    await this.assertCounterMatch('videoCategories', this.videoCategoryIds[1], oneMovedItemCount)
+
+    /** Giza doesn't support changing channels - uncomment this on later releases where it's supported
+
+    // move one video to another channel
+
+    this.debug('Move video to different channel')
+    await this.cli.updateVideo(videosData[0].videoId, {
+      channel: channelIds[1], // move from channel 1 to channel 2
+    })
+
+    // check counter of channel with newly moved video
+
+    this.debug('Checking channels active video counters (2)')
+    await this.assertCounterMatch('channels', channelIds[0], videoCount - oneMovedItemCount)
+    await this.assertCounterMatch('channels', channelIds[1], oneMovedItemCount)
+
+    // end
+    */
+
+    this.debug('Done')
+  }
+
+  /**
+    Asserts a channel, or a video/channel categories have their active videos counter set properly
+    in Query node.
+  */
+  private async assertCounterMatch(
+    entityName: 'channels' | 'channelCategories' | 'videoCategories',
+    entityId: number,
+    expectedCount: number
+  ) {
+    const getterName = `get${entityName[0].toUpperCase()}${entityName.slice(1)}` as
+      | 'getChannels'
+      | 'getChannelCategories'
+      | 'getVideoCategories'
+    await this.query.tryQueryWithTimeout(
+      () => this.query[getterName](),
+      (entities) => {
+        assert(entities.length > 0) // some entities were loaded
+
+        const entity = entities.find((item: any) => item.id === entityId.toString())
+
+        // all videos created in this fixture should be active and belong to first entity
+        assert(entity && entity.activeVideosCounter === expectedCount)
+      }
+    )
+  }
+}

+ 53 - 0
tests/integration-tests/src/fixtures/content/contentTemplates.ts

@@ -0,0 +1,53 @@
+// basic templates for content entities
+
+import { v4 as uuid } from 'uuid'
+import * as path from 'path'
+
+export const cliExamplesFolderPath = path.dirname(require.resolve('@joystream/cli/package.json')) + '/examples/content'
+
+export function getMemberDefaults(index: number) {
+  return {
+    // member needs unique name due to CLI requirement for that
+    name: 'TestingActiveVideoCounters-' + uuid().substring(0, 8),
+  }
+}
+
+export function getVideoDefaults(index: number) {
+  return {
+    title: `Active video counters Testing channel - ${index} - ${uuid().substring(0, 8)}`,
+    description: 'Video for testing active video counters.',
+    videoPath: cliExamplesFolderPath + '/video.mp4',
+    thumbnailPhotoPath: cliExamplesFolderPath + '/avatar-photo-1.png',
+    language: 'en',
+    hasMarketing: false,
+    isPublic: true,
+    isExplicit: false,
+    // category: 1, - no category set by default
+    license: {
+      code: 1001,
+      attribution: 'by Joystream Contributors',
+    },
+  }
+}
+
+export function getVideoCategoryDefaults(index: number) {
+  return {
+    name: `Active video counters Testing video category - ${index}`,
+  }
+}
+
+export function getChannelDefaults(index: number, rewardAccountAddress: string) {
+  return {
+    title: `Active video counters Testing channel - ${index}`,
+    description: 'Channel for testing active video counters.',
+    isPublic: true,
+    language: 'en',
+    rewardAccount: rewardAccountAddress,
+  }
+}
+
+export function getChannelCategoryDefaults(index: number) {
+  return {
+    name: `Active video counters Testing channel category - ${index}`,
+  }
+}

+ 180 - 0
tests/integration-tests/src/fixtures/content/createChannelsAndVideos.ts

@@ -0,0 +1,180 @@
+import { BaseQueryNodeFixture, FixtureRunner } from '../../Fixture'
+import { JoystreamCLI, ICreatedVideoData } from '../../cli/joystream'
+import { MemberId } from '@joystream/types/common'
+import { QueryNodeApi } from '../../QueryNodeApi'
+import { Api } from '../../Api'
+import * as path from 'path'
+import { getMemberDefaults, getVideoDefaults, getChannelDefaults } from './contentTemplates'
+import { KeyringPair } from '@polkadot/keyring/types'
+import { BuyMembershipHappyCaseFixture } from '../membership'
+import BN from 'bn.js'
+import { DataObjectId, StorageBucketId } from '@joystream/types/storage'
+import { Worker, WorkerId } from '@joystream/types/working-group'
+import { createType } from '@joystream/types'
+import { singleBucketConfig } from '../../flows/storage/initStorage'
+import { IMember } from './createMembers'
+
+const cliExamplesFolderPath = path.dirname(require.resolve('@joystream/cli/package.json')) + '/examples/content'
+
+export class CreateChannelsAndVideosFixture extends BaseQueryNodeFixture {
+  private cli: JoystreamCLI
+  private channelCount: number
+  private videoCount: number
+  private channelCategoryId: number
+  private videoCategoryId: number
+  private author: IMember
+  private createdItems: {
+    channelIds: number[]
+    videosData: ICreatedVideoData[]
+  }
+
+  constructor(
+    api: Api,
+    query: QueryNodeApi,
+    cli: JoystreamCLI,
+    channelCount: number,
+    videoCount: number,
+    channelCategoryId: number,
+    videoCategoryId: number,
+    author: IMember
+  ) {
+    super(api, query)
+    this.cli = cli
+    this.channelCount = channelCount
+    this.videoCount = videoCount
+    this.channelCategoryId = channelCategoryId
+    this.videoCategoryId = videoCategoryId
+    this.author = author
+
+    this.createdItems = {
+      channelIds: [],
+      videosData: [],
+    }
+  }
+
+  public getCreatedItems() {
+    return this.createdItems
+  }
+
+  /*
+    Execute this Fixture.
+  */
+  public async execute(): Promise<void> {
+    this.debug('Setting author')
+    await this.cli.importAccount(this.author.keyringPair)
+    await this.cli.chooseMemberAccount(this.author.memberId)
+
+    this.debug('Creating channels')
+    this.createdItems.channelIds = await this.createChannels(
+      this.channelCount,
+      this.channelCategoryId,
+      this.author.account
+    )
+
+    this.debug('Creating videos')
+    this.createdItems.videosData = await this.createVideos(
+      this.videoCount,
+      this.createdItems.channelIds[0],
+      this.videoCategoryId
+    )
+
+    const { storageBucketId, storageGroupWorkerId, storageGroupWorkerAccount } = await this.retrieveBucket()
+
+    // TODO: remove this after "not enough balance" is solved for this worker
+    this.debug('Top-uping worker')
+    await this.api.treasuryTransferBalanceToAccounts([storageGroupWorkerAccount], new BN(1_000_000))
+
+    this.debug('Accepting content to storage bag')
+    const allAssetIds = this.createdItems.videosData.map((item) => item.assetContentIds).flat()
+    await this.api.acceptPendingDataObjects(
+      storageGroupWorkerAccount,
+      storageGroupWorkerId,
+      storageBucketId,
+      this.createdItems.channelIds[0].toString(),
+      allAssetIds
+    )
+  }
+
+  /**
+    Retrieves storage bucket info.
+  */
+  private async retrieveBucket(): Promise<{
+    storageBucketId: StorageBucketId
+    storageGroupWorkerId: WorkerId
+    storageGroupWorkerAccount: string
+  }> {
+    // read existing storage buckets from runtime
+    const bucketEntries = await this.api.query.storage.storageBucketById.entries()
+
+    // create WorkerId object
+    const storageGroupWorkerId = createType('WorkerId', singleBucketConfig.buckets[0].operatorId)
+
+    // find some bucket created by worker
+    const bucketTuple = bucketEntries.find(
+      ([, /* storageBucketId */ storageBucket]) =>
+        (storageBucket.operator_status as any).isStorageWorker &&
+        (storageBucket.operator_status as any).asStorageWorker[0].toString() === storageGroupWorkerId.toString()
+    )
+    if (!bucketTuple) {
+      throw new Error('Storage bucket not initialized')
+    }
+
+    // retrieve worker address
+    const storageGroupWorkerAccount = this.api.createCustomKeyPair(singleBucketConfig.buckets[0].transactorUri, true)
+      .address
+
+    // create StorageBucketId object
+    const storageBucketId = createType('StorageBucketId', bucketTuple[0].args[0])
+
+    return {
+      storageBucketId,
+      storageGroupWorkerId,
+      storageGroupWorkerAccount,
+    }
+  }
+
+  /**
+    Creates a new channel.
+  */
+  private async createChannels(count: number, channelCategoryId: number, authorAddress: string): Promise<number[]> {
+    const createdIds = (await this.createCommonEntities(count, (index) =>
+      this.cli.createChannel({
+        ...getChannelDefaults(index, authorAddress),
+        category: channelCategoryId,
+      })
+    )) as number[]
+
+    return createdIds
+  }
+
+  /**
+    Creates a new video.
+
+    Note: Assets have to be accepted later on for videos to be counted as active.
+  */
+  private async createVideos(count: number, channelId: number, videoCategoryId: number): Promise<ICreatedVideoData[]> {
+    const createVideo = async (index: number) => {
+      return await this.cli.createVideo(channelId, {
+        ...getVideoDefaults(index),
+        category: videoCategoryId,
+      })
+    }
+    const newVideosData = (await this.createCommonEntities(count, createVideo)) as ICreatedVideoData[]
+
+    return newVideosData
+  }
+
+  /**
+    Creates a bunch of content entities.
+  */
+  private async createCommonEntities<T>(count: number, createPromise: (index: number) => Promise<T>): Promise<T[]> {
+    const createdIds = await Array.from(Array(count).keys()).reduce(async (accPromise, index: number) => {
+      const acc = await accPromise
+      const createdId = await createPromise(index)
+
+      return [...acc, createdId]
+    }, Promise.resolve([]) as Promise<T[]>)
+
+    return createdIds
+  }
+}

+ 122 - 0
tests/integration-tests/src/fixtures/content/createContentStructure.ts

@@ -0,0 +1,122 @@
+import { BaseQueryNodeFixture, FixtureRunner } from '../../Fixture'
+import { JoystreamCLI } from '../../cli/joystream'
+import { QueryNodeApi } from '../../QueryNodeApi'
+import { MemberId } from '@joystream/types/common'
+import { Api } from '../../Api'
+import { WorkingGroupModuleName } from '../../types'
+import { Worker, WorkerId } from '@joystream/types/working-group'
+import { getVideoCategoryDefaults, getChannelCategoryDefaults } from './contentTemplates'
+import BN from 'bn.js'
+
+export class CreateContentStructureFixture extends BaseQueryNodeFixture {
+  private cli: JoystreamCLI
+  private channelCategoryCount: number
+  private videoCategoryCount: number
+  private createdItems: {
+    channelCategoryIds: number[]
+    videoCategoryIds: number[]
+  }
+
+  constructor(
+    api: Api,
+    query: QueryNodeApi,
+    cli: JoystreamCLI,
+    channelCategoryCount: number,
+    videoCategoryCount: number
+  ) {
+    super(api, query)
+    this.cli = cli
+    this.channelCategoryCount = channelCategoryCount
+    this.videoCategoryCount = videoCategoryCount
+
+    this.createdItems = {
+      channelCategoryIds: [],
+      videoCategoryIds: [],
+    }
+  }
+
+  public getCreatedItems() {
+    return this.createdItems
+  }
+
+  /*
+    Execute this Fixture.
+  */
+  public async execute(): Promise<void> {
+    // prepare accounts for working group leads
+
+    this.debug('Loading working group leaders')
+    const { contentLeader, storageLeader } = await this.retrieveWorkingGroupLeaders()
+
+    // switch to lead and create category structure as lead
+
+    this.debug(`Choosing content working group lead's account`)
+    const contentLeaderKeyPair = this.api.getKeypair(contentLeader.role_account_id.toString())
+    await this.cli.importAccount(contentLeaderKeyPair)
+    await this.cli.chooseMemberAccount(contentLeader.member_id)
+
+    this.debug('Creating channel categories')
+    this.createdItems.channelCategoryIds = await this.createChannelCategories(this.channelCategoryCount)
+
+    this.debug('Creating video categories')
+    this.createdItems.videoCategoryIds = await this.createVideoCategories(this.videoCategoryCount)
+  }
+
+  /**
+    Retrieves information about accounts of group leads for content and storage working groups.
+  */
+  private async retrieveWorkingGroupLeaders(): Promise<{ contentLeader: Worker; storageLeader: Worker }> {
+    const retrieveGroupLeader = async (group: WorkingGroupModuleName) => {
+      const leader = await this.api.getLeader(group)
+      if (!leader) {
+        throw new Error(`Working group leader for "${group}" is missing!`)
+      }
+      return leader[1]
+    }
+
+    return {
+      contentLeader: await retrieveGroupLeader('contentWorkingGroup'),
+      storageLeader: await retrieveGroupLeader('storageWorkingGroup'),
+    }
+  }
+
+  /**
+    Creates a new channel category. Can only be executed as content group leader.
+  */
+  private async createChannelCategories(count: number): Promise<number[]> {
+    const createdIds = (await this.createCommonEntities(count, (index: number) =>
+      this.cli.createChannelCategory({
+        ...getChannelCategoryDefaults(index),
+      })
+    )) as number[]
+
+    return createdIds
+  }
+
+  /**
+    Creates a new video category. Can only be executed as content group leader.
+  */
+  private async createVideoCategories(count: number): Promise<number[]> {
+    const createdIds = (await this.createCommonEntities(count, (index: number) =>
+      this.cli.createVideoCategory({
+        ...getVideoCategoryDefaults(index),
+      })
+    )) as number[]
+
+    return createdIds
+  }
+
+  /**
+    Creates a bunch of content entities.
+  */
+  private async createCommonEntities<T>(count: number, createPromise: (index: number) => Promise<T>): Promise<T[]> {
+    const createdIds = await Array.from(Array(count).keys()).reduce(async (accPromise, index) => {
+      const acc = await accPromise
+      const createdId = await createPromise(index)
+
+      return [...acc, createdId]
+    }, Promise.resolve([]) as Promise<T[]>)
+
+    return createdIds
+  }
+}

+ 62 - 0
tests/integration-tests/src/fixtures/content/createMembers.ts

@@ -0,0 +1,62 @@
+import { QueryNodeApi } from '../../QueryNodeApi'
+import { MemberId } from '@joystream/types/common'
+import { BaseQueryNodeFixture, FixtureRunner } from '../../Fixture'
+import { Api } from '../../Api'
+import { KeyringPair } from '@polkadot/keyring/types'
+import BN from 'bn.js'
+import { BuyMembershipHappyCaseFixture } from '../membership'
+
+export interface IMember {
+  keyringPair: KeyringPair
+  account: string
+  memberId: MemberId
+}
+
+export class CreateMembersFixture extends BaseQueryNodeFixture {
+  private memberCount: number
+  private topupAmount: BN
+  private createdItems: IMember[] = []
+
+  constructor(api: Api, query: QueryNodeApi, memberCount: number, topupAmount: BN) {
+    super(api, query)
+    this.memberCount = memberCount
+    this.topupAmount = topupAmount
+  }
+
+  public getCreatedItems() {
+    return this.createdItems
+  }
+
+  /*
+    Execute this Fixture.
+  */
+  public async execute(): Promise<void> {
+    this.debug('Creating member')
+    this.createdItems = await this.createMembers(this.memberCount)
+
+    this.debug('Top-uping accounts')
+    await this.api.treasuryTransferBalanceToAccounts(
+      this.createdItems.map((item) => item.keyringPair.address),
+      this.topupAmount
+    )
+  }
+
+  /**
+    Creates new accounts and registers memberships for them.
+  */
+  private async createMembers(numberOfMembers: number): Promise<IMember[]> {
+    const keyringPairs = (await this.api.createKeyPairs(numberOfMembers)).map((kp) => kp.key)
+    const accounts = keyringPairs.map((item) => item.address)
+    const buyMembershipsFixture = new BuyMembershipHappyCaseFixture(this.api, this.query, accounts)
+
+    await new FixtureRunner(buyMembershipsFixture).run()
+
+    const memberIds = buyMembershipsFixture.getCreatedMembers()
+
+    return keyringPairs.map((item, index) => ({
+      keyringPair: item,
+      account: accounts[index],
+      memberId: memberIds[index],
+    }))
+  }
+}

+ 5 - 0
tests/integration-tests/src/fixtures/content/index.ts

@@ -0,0 +1,5 @@
+export * from './activeVideoCounters'
+export * from './createChannelsAndVideos'
+export * from './createContentStructure'
+export * from './createMembers'
+export * from './nft'

+ 75 - 0
tests/integration-tests/src/fixtures/content/nft/auctionCancelations.ts

@@ -0,0 +1,75 @@
+import { Api } from '../../../Api'
+import { BaseQueryNodeFixture, FixtureRunner } from '../../../Fixture'
+import { JoystreamCLI } from '../../../cli/joystream'
+import { QueryNodeApi } from '../../../QueryNodeApi'
+import { IMember } from '../createMembers'
+import { PlaceBidsInAuctionFixture } from './placeBidsInAuction'
+import { Utils } from '../../../utils'
+import { assertNftOwner } from './utils'
+import BN from 'bn.js'
+
+export class AuctionCancelationsFixture extends BaseQueryNodeFixture {
+  private cli: JoystreamCLI
+  private videoId: number
+  private author: IMember
+  private participant: IMember
+
+  constructor(
+    api: Api,
+    query: QueryNodeApi,
+    cli: JoystreamCLI,
+    videoId: number,
+    author: IMember,
+    participant: IMember
+  ) {
+    super(api, query)
+    this.cli = cli
+    this.videoId = videoId
+    this.author = author
+    this.participant = participant
+  }
+
+  /*
+    Execute this Fixture.
+  */
+  public async execute(): Promise<void> {
+    this.debug('Issue video NFT')
+    await this.api.issueNft(this.author.keyringPair.address, this.author.memberId.toNumber(), this.videoId)
+
+    this.debug('Start NFT auction')
+    const { auctionParams, startingPrice, minimalBidStep, bidLockDuration } = await this.api.createAuctionParameters(
+      'Open'
+    )
+    await this.api.startNftAuction(
+      this.author.keyringPair.address,
+      this.author.memberId.toNumber(),
+      this.videoId,
+      auctionParams
+    )
+
+    this.debug('Place bid')
+    const placeBidsFixture = new PlaceBidsInAuctionFixture(
+      this.api,
+      this.query,
+      [this.participant],
+      startingPrice,
+      minimalBidStep,
+      this.videoId
+    )
+    await new FixtureRunner(placeBidsFixture).run()
+
+    this.debug('Wait for bid to be cancelable')
+
+    const waitBlocks = bidLockDuration.toNumber() + 1
+    await Utils.wait(this.api.getBlockDuration().muln(waitBlocks).toNumber())
+
+    this.debug('Cancel bid')
+    await this.api.cancelOpenAuctionBid(this.participant.account, this.participant.memberId.toNumber(), this.videoId)
+
+    this.debug('Cancel auction')
+    await this.api.cancelNftAuction(this.author.account, this.author.memberId.toNumber(), this.videoId)
+
+    this.debug(`Check NFT ownership haven't change`)
+    await assertNftOwner(this.query, this.videoId, this.author)
+  }
+}

+ 48 - 0
tests/integration-tests/src/fixtures/content/nft/buyNow.ts

@@ -0,0 +1,48 @@
+import { Api } from '../../../Api'
+import { BaseQueryNodeFixture, FixtureRunner } from '../../../Fixture'
+import { JoystreamCLI } from '../../../cli/joystream'
+import { QueryNodeApi } from '../../../QueryNodeApi'
+import { IMember } from '../createMembers'
+import { PlaceBidsInAuctionFixture } from './placeBidsInAuction'
+import BN from 'bn.js'
+import { assertNftOwner } from './utils'
+
+export class NftBuyNowFixture extends BaseQueryNodeFixture {
+  private cli: JoystreamCLI
+  private videoId: number
+  private author: IMember
+  private participant: IMember
+
+  constructor(
+    api: Api,
+    query: QueryNodeApi,
+    cli: JoystreamCLI,
+    videoId: number,
+    author: IMember,
+    participant: IMember
+  ) {
+    super(api, query)
+    this.cli = cli
+    this.videoId = videoId
+    this.author = author
+    this.participant = participant
+  }
+
+  /*
+    Execute this Fixture.
+  */
+  public async execute(): Promise<void> {
+    this.debug('Issue video NFT')
+    await this.api.issueNft(this.author.keyringPair.address, this.author.memberId.toNumber(), this.videoId)
+
+    this.debug('Start buy now auction')
+    const buyNowPrice = new BN(100) // value doesn't matter
+    await this.api.sellNft(this.author.keyringPair.address, this.videoId, this.author.memberId.toNumber(), buyNowPrice)
+
+    this.debug('Buy now')
+    await this.api.buyNft(this.participant.account, this.videoId, this.participant.memberId.toNumber())
+
+    this.debug('Check NFT ownership change')
+    await assertNftOwner(this.query, this.videoId, this.participant)
+  }
+}

+ 44 - 0
tests/integration-tests/src/fixtures/content/nft/createVideoWithAuction.ts

@@ -0,0 +1,44 @@
+import { Api } from '../../../Api'
+import { BaseQueryNodeFixture, FixtureRunner } from '../../../Fixture'
+import { JoystreamCLI } from '../../../cli/joystream'
+import { QueryNodeApi } from '../../../QueryNodeApi'
+import { IMember } from '../createMembers'
+import { PlaceBidsInAuctionFixture } from './placeBidsInAuction'
+import BN from 'bn.js'
+import { assertNftOwner } from './utils'
+import { assert } from 'chai'
+
+export class NftCreateVideoWithAuctionFixture extends BaseQueryNodeFixture {
+  private cli: JoystreamCLI
+  private author: IMember
+  private channelId: number
+
+  constructor(api: Api, query: QueryNodeApi, cli: JoystreamCLI, author: IMember, channelId: number) {
+    super(api, query)
+    this.cli = cli
+    this.author = author
+    this.channelId = channelId
+  }
+
+  /*
+    Execute this Fixture.
+  */
+  public async execute(): Promise<void> {
+    this.debug('Create video with NFT being auctioned')
+    const { auctionParams } = await this.api.createAuctionParameters('English')
+
+    const response = await this.api.createVideoWithNftAuction(
+      this.author.keyringPair.address,
+      this.author.memberId.toNumber(),
+      this.channelId,
+      auctionParams
+    )
+
+    const event = await this.api.retrieveVideoCreatedEventDetails(response)
+
+    this.debug('Check NFT ownership change')
+    await assertNftOwner(this.query, event.videoId.toNumber(), this.author, (ownedNft) => {
+      assert.equal(ownedNft.transactionalStatus.__typename, 'TransactionalStatusAuction')
+    })
+  }
+}

+ 50 - 0
tests/integration-tests/src/fixtures/content/nft/directOffer.ts

@@ -0,0 +1,50 @@
+import { Api } from '../../../Api'
+import { BaseQueryNodeFixture, FixtureRunner } from '../../../Fixture'
+import { JoystreamCLI } from '../../../cli/joystream'
+import { QueryNodeApi } from '../../../QueryNodeApi'
+import { IMember } from '../createMembers'
+import { assertNftOwner } from './utils'
+
+export class NftDirectOfferFixture extends BaseQueryNodeFixture {
+  private cli: JoystreamCLI
+  private videoId: number
+  private author: IMember
+  private participant: IMember
+
+  constructor(
+    api: Api,
+    query: QueryNodeApi,
+    cli: JoystreamCLI,
+    videoId: number,
+    author: IMember,
+    participant: IMember
+  ) {
+    super(api, query)
+    this.cli = cli
+    this.videoId = videoId
+    this.author = author
+    this.participant = participant
+  }
+
+  /*
+    Execute this Fixture.
+  */
+  public async execute(): Promise<void> {
+    this.debug('Issue video NFT')
+    await this.api.issueNft(this.author.keyringPair.address, this.author.memberId.toNumber(), this.videoId)
+
+    this.debug('Offer NFT')
+    await this.api.offerNft(
+      this.author.keyringPair.address,
+      this.videoId,
+      this.author.memberId.toNumber(),
+      this.participant.memberId.toNumber()
+    )
+
+    this.debug('Accept offer')
+    await this.api.acceptIncomingOffer(this.participant.keyringPair.address, this.videoId)
+
+    this.debug('Check NFT ownership change')
+    await assertNftOwner(this.query, this.videoId, this.participant)
+  }
+}

+ 82 - 0
tests/integration-tests/src/fixtures/content/nft/englishAuction.ts

@@ -0,0 +1,82 @@
+import { assert } from 'chai'
+import { Api } from '../../../Api'
+import { BaseQueryNodeFixture, FixtureRunner } from '../../../Fixture'
+import { JoystreamCLI } from '../../../cli/joystream'
+import { QueryNodeApi } from '../../../QueryNodeApi'
+import { IMember } from '../createMembers'
+import { BuyMembershipHappyCaseFixture } from '../../membership'
+import { PlaceBidsInAuctionFixture } from './placeBidsInAuction'
+import { Utils } from '../../../utils'
+import { assertNftOwner } from './utils'
+import BN from 'bn.js'
+
+// settings
+const sufficientTopupAmount = new BN(1000000) // some very big number to cover fees of all transactions
+
+export class NftEnglishAuctionFixture extends BaseQueryNodeFixture {
+  private cli: JoystreamCLI
+  private videoId: number
+  private author: IMember
+  private participants: IMember[]
+
+  constructor(
+    api: Api,
+    query: QueryNodeApi,
+    cli: JoystreamCLI,
+    videoId: number,
+    author: IMember,
+    participants: IMember[]
+  ) {
+    super(api, query)
+    this.cli = cli
+    this.videoId = videoId
+    this.author = author
+    this.participants = participants
+  }
+
+  /*
+    Execute this Fixture.
+  */
+  public async execute(): Promise<void> {
+    this.debug('Issue video NFT')
+    await this.api.issueNft(this.author.keyringPair.address, this.author.memberId.toNumber(), this.videoId)
+
+    this.debug('Start NFT auction')
+    const {
+      auctionParams,
+      startingPrice,
+      minimalBidStep,
+      auctionDuration,
+      extensionPeriod,
+    } = await this.api.createAuctionParameters('English')
+    await this.api.startNftAuction(
+      this.author.keyringPair.address,
+      this.author.memberId.toNumber(),
+      this.videoId,
+      auctionParams
+    )
+
+    const winner = this.participants[this.participants.length - 1]
+
+    this.debug('Place bids')
+    const placeBidsFixture = new PlaceBidsInAuctionFixture(
+      this.api,
+      this.query,
+      this.participants,
+      startingPrice,
+      minimalBidStep,
+      this.videoId
+    )
+    await new FixtureRunner(placeBidsFixture).run()
+
+    this.debug('Wait for auction to end')
+    const waitBlocks = Math.min(auctionDuration.toNumber(), extensionPeriod.toNumber() + this.participants.length) + 1
+    await Utils.wait(this.api.getBlockDuration().muln(waitBlocks).toNumber())
+
+    this.debug('Complete auction')
+    await this.api.claimWonEnglishAuction(winner.account, winner.memberId.toNumber(), this.videoId)
+
+    this.debug('Check NFT ownership change')
+    await assertNftOwner(this.query, this.videoId, winner)
+  }
+}

+ 6 - 0
tests/integration-tests/src/fixtures/content/nft/index.ts

@@ -0,0 +1,6 @@
+export * from './englishAuction'
+export * from './openAuction'
+export * from './buyNow'
+export * from './directOffer'
+export * from './auctionCancelations'
+export * from './createVideoWithAuction'

+ 70 - 0
tests/integration-tests/src/fixtures/content/nft/openAuction.ts

@@ -0,0 +1,70 @@
+import { Api } from '../../../Api'
+import { BaseQueryNodeFixture, FixtureRunner } from '../../../Fixture'
+import { JoystreamCLI } from '../../../cli/joystream'
+import { QueryNodeApi } from '../../../QueryNodeApi'
+import { IMember } from '../createMembers'
+import { BuyMembershipHappyCaseFixture } from '../../membership'
+import { PlaceBidsInAuctionFixture } from './placeBidsInAuction'
+import { assertNftOwner } from './utils'
+import BN from 'bn.js'
+
+// settings
+const sufficientTopupAmount = new BN(1000000) // some very big number to cover fees of all transactions
+
+export class NftOpenAuctionFixture extends BaseQueryNodeFixture {
+  private cli: JoystreamCLI
+  private videoId: number
+  private author: IMember
+  private participants: IMember[]
+
+  constructor(
+    api: Api,
+    query: QueryNodeApi,
+    cli: JoystreamCLI,
+    videoId: number,
+    author: IMember,
+    participants: IMember[]
+  ) {
+    super(api, query)
+    this.cli = cli
+    this.videoId = videoId
+    this.author = author
+    this.participants = participants
+  }
+
+  /*
+    Execute this Fixture.
+  */
+  public async execute(): Promise<void> {
+    this.debug('Issue video NFT')
+    await this.api.issueNft(this.author.keyringPair.address, this.author.memberId.toNumber(), this.videoId)
+
+    this.debug('Start NFT auction')
+    const { auctionParams, startingPrice, minimalBidStep } = await this.api.createAuctionParameters('Open')
+    await this.api.startNftAuction(
+      this.author.keyringPair.address,
+      this.author.memberId.toNumber(),
+      this.videoId,
+      auctionParams
+    )
+
+    const winner = this.participants[this.participants.length - 1]
+
+    this.debug('Place bids')
+    const memberSetFixture = new PlaceBidsInAuctionFixture(
+      this.api,
+      this.query,
+      this.participants,
+      startingPrice,
+      minimalBidStep,
+      this.videoId
+    )
+    await new FixtureRunner(memberSetFixture).run()
+
+    this.debug('Complete auction')
+    await this.api.pickOpenAuctionWinner(this.author.keyringPair.address, this.author.memberId.toNumber(), this.videoId)
+
+    this.debug('Check NFT ownership change')
+    await assertNftOwner(this.query, this.videoId, winner)
+  }
+}

+ 41 - 0
tests/integration-tests/src/fixtures/content/nft/placeBidsInAuction.ts

@@ -0,0 +1,41 @@
+import { Api } from '../../../Api'
+import { BaseQueryNodeFixture, FixtureRunner } from '../../../Fixture'
+import { QueryNodeApi } from '../../../QueryNodeApi'
+import { IMember } from '../createMembers'
+import BN from 'bn.js'
+
+export class PlaceBidsInAuctionFixture extends BaseQueryNodeFixture {
+  private participants: IMember[]
+  private startingPrice: BN
+  private minimalBidStep: BN
+  private videoId: number
+
+  constructor(
+    api: Api,
+    query: QueryNodeApi,
+    participants: IMember[],
+    startingPrice: BN,
+    minimalBidStep: BN,
+    videoId: number
+  ) {
+    super(api, query)
+    this.participants = participants
+    this.startingPrice = startingPrice
+    this.minimalBidStep = minimalBidStep
+    this.videoId = videoId
+  }
+
+  /*
+    Execute this Fixture.
+  */
+  public async execute(): Promise<void> {
+    this.debug('Put bids in auction')
+    const winner = this.participants[this.participants.length - 1]
+
+    for (const [index, participant] of this.participants.entries()) {
+      const bidAmount = this.startingPrice.add(this.minimalBidStep.mul(new BN(index)))
+      this.debug('Bid-' + index)
+      await this.api.bidInNftAuction(participant.account, participant.memberId.toNumber(), this.videoId, bidAmount)
+    }
+  }
+}

+ 25 - 0
tests/integration-tests/src/fixtures/content/nft/utils.ts

@@ -0,0 +1,25 @@
+import { IMember } from '../createMembers'
+import { QueryNodeApi } from '../../../QueryNodeApi'
+import { Utils } from '../../../utils'
+import { assert } from 'chai'
+import { OwnedNftFieldsFragment } from '../../../graphql/generated/queries'
+
+export async function assertNftOwner(
+  query: QueryNodeApi,
+  videoId: number,
+  owner: IMember,
+  customAsserts?: (ownedNft: OwnedNftFieldsFragment) => void
+) {
+  await query.tryQueryWithTimeout(
+    () => query.ownedNftByVideoId(videoId.toString()),
+    (ownedNft) => {
+      Utils.assert(ownedNft, 'NFT not found')
+      Utils.assert(ownedNft.ownerMember, 'Invalid NFT owner')
+      assert.equal(ownedNft.ownerMember.id.toString(), owner.memberId.toString())
+
+      if (customAsserts) {
+        customAsserts(ownedNft)
+      }
+    }
+  )
+}

+ 74 - 0
tests/integration-tests/src/flows/content/activeVideoCounters.ts

@@ -0,0 +1,74 @@
+import { FlowProps } from '../../Flow'
+import { extendDebug } from '../../Debugger'
+import { FixtureRunner } from '../../Fixture'
+import {
+  ActiveVideoCountersFixture,
+  CreateChannelsAndVideosFixture,
+  CreateContentStructureFixture,
+  CreateMembersFixture,
+} from '../../fixtures/content'
+import BN from 'bn.js'
+import { createJoystreamCli } from '../utils'
+
+export default async function activeVideoCounters({ api, query, env }: FlowProps): Promise<void> {
+  const debug = extendDebug('flow:active-video-counters')
+  debug('Started')
+  api.enableDebugTxLogs()
+
+  // create Joystream CLI
+  const joystreamCli = await createJoystreamCli()
+
+  // settings
+  const videoCount = 2
+  const videoCategoryCount = 2
+  const channelCount = 2
+  const channelCategoryCount = 2
+  const sufficientTopupAmount = new BN(1000000) // some very big number to cover fees of all transactions
+
+  // flow itself
+
+  // create channel categories and video categories
+  const createContentStructureFixture = new CreateContentStructureFixture(
+    api,
+    query,
+    joystreamCli,
+    videoCategoryCount,
+    channelCategoryCount
+  )
+  await new FixtureRunner(createContentStructureFixture).run()
+
+  const { channelCategoryIds, videoCategoryIds } = createContentStructureFixture.getCreatedItems()
+
+  // create author of channels and videos
+  const createMembersFixture = new CreateMembersFixture(api, query, 1, sufficientTopupAmount)
+  await new FixtureRunner(createMembersFixture).run()
+  const author = createMembersFixture.getCreatedItems()[0]
+
+  // create channels and videos
+  const createChannelsAndVideos = new CreateChannelsAndVideosFixture(
+    api,
+    query,
+    joystreamCli,
+    channelCount,
+    videoCount,
+    channelCategoryIds[0],
+    videoCategoryIds[0],
+    author
+  )
+  await new FixtureRunner(createChannelsAndVideos).run()
+  const { channelIds, videosData } = createChannelsAndVideos.getCreatedItems()
+
+  // check that active video counters are working
+  const activeVideoCountersFixture = new ActiveVideoCountersFixture(
+    api,
+    query,
+    joystreamCli,
+    channelCategoryIds,
+    videosData,
+    channelIds,
+    videoCategoryIds
+  )
+  await new FixtureRunner(activeVideoCountersFixture).run()
+
+  debug('Done')
+}

+ 141 - 0
tests/integration-tests/src/flows/content/nftAuctionAndOffers.ts

@@ -0,0 +1,141 @@
+import { FlowProps } from '../../Flow'
+import { extendDebug } from '../../Debugger'
+import { FixtureRunner } from '../../Fixture'
+import {
+  ActiveVideoCountersFixture,
+  CreateChannelsAndVideosFixture,
+  CreateContentStructureFixture,
+  CreateMembersFixture,
+  NftEnglishAuctionFixture,
+  NftBuyNowFixture,
+  NftDirectOfferFixture,
+  NftOpenAuctionFixture,
+  AuctionCancelationsFixture,
+  NftCreateVideoWithAuctionFixture,
+  IMember,
+} from '../../fixtures/content'
+import BN from 'bn.js'
+import { createJoystreamCli } from '../utils'
+
+export default async function nftAuctionAndOffers({ api, query, env }: FlowProps): Promise<void> {
+  const debug = extendDebug('flow:nft-auction-and-offers')
+  debug('Started')
+  api.enableDebugTxLogs()
+
+  // create Joystream CLI
+  const joystreamCli = await createJoystreamCli()
+
+  // settings
+  const videoCount = 5 // should be equal to number of uses of `nextVideo()` below
+  const videoCategoryCount = 1
+  const channelCount = 1
+  const channelCategoryCount = 1
+  const auctionParticipantsCount = 3
+  const sufficientTopupAmount = new BN(1_000_000) // some very big number to cover fees of all transactions
+
+  // prepare content
+
+  const createContentStructureFixture = new CreateContentStructureFixture(
+    api,
+    query,
+    joystreamCli,
+    videoCategoryCount,
+    channelCategoryCount
+  )
+  await new FixtureRunner(createContentStructureFixture).run()
+
+  const { channelCategoryIds, videoCategoryIds } = createContentStructureFixture.getCreatedItems()
+
+  // create author of channels and videos as well as auction participants
+  const createMembersFixture = new CreateMembersFixture(api, query, auctionParticipantsCount + 1, sufficientTopupAmount)
+  await new FixtureRunner(createMembersFixture).run()
+  const [author, ...auctionParticipants] = createMembersFixture.getCreatedItems()
+
+  const createChannelsAndVideos = new CreateChannelsAndVideosFixture(
+    api,
+    query,
+    joystreamCli,
+    channelCount,
+    videoCount,
+    channelCategoryIds[0],
+    videoCategoryIds[0],
+    author
+  )
+  await new FixtureRunner(createChannelsAndVideos).run()
+
+  const { channelIds, videosData } = createChannelsAndVideos.getCreatedItems()
+
+  const nextVideo = (() => {
+    let i = 0
+    return () => videosData[i++]
+  })()
+
+  // test NFT features
+
+  const nftAuctionFixture = new NftEnglishAuctionFixture(
+    api,
+    query,
+    joystreamCli,
+    nextVideo().videoId,
+    author as IMember,
+    auctionParticipants
+  )
+
+  await new FixtureRunner(nftAuctionFixture).run()
+
+  const openAuctionFixture = new NftOpenAuctionFixture(
+    api,
+    query,
+    joystreamCli,
+    nextVideo().videoId,
+    author,
+    auctionParticipants
+  )
+
+  await new FixtureRunner(openAuctionFixture).run()
+
+  const nftBuyNowFixture = new NftBuyNowFixture(
+    api,
+    query,
+    joystreamCli,
+    nextVideo().videoId,
+    author as IMember,
+    auctionParticipants[0]
+  )
+
+  await new FixtureRunner(nftBuyNowFixture).run()
+
+  const nftDirectOfferFixture = new NftDirectOfferFixture(
+    api,
+    query,
+    joystreamCli,
+    nextVideo().videoId,
+    author as IMember,
+    auctionParticipants[0]
+  )
+
+  await new FixtureRunner(nftDirectOfferFixture).run()
+
+  const auctionCancelationsFicture = new AuctionCancelationsFixture(
+    api,
+    query,
+    joystreamCli,
+    nextVideo().videoId,
+    author as IMember,
+    auctionParticipants[0]
+  )
+
+  await new FixtureRunner(auctionCancelationsFicture).run()
+
+  const createVideoWithAuctionFixture = new NftCreateVideoWithAuctionFixture(
+    api,
+    query,
+    joystreamCli,
+    author as IMember,
+    channelIds[0]
+  )
+
+  await new FixtureRunner(createVideoWithAuctionFixture).run()
+
+  debug('Done')
+}

+ 14 - 0
tests/integration-tests/src/flows/storage/initStorage.ts

@@ -14,6 +14,7 @@ type StorageBucketConfig = {
   objectsLimit: number
   operatorId: number
   transactorKey: string
+  transactorUri: string
   transactorBalance: BN
 }
 
@@ -48,6 +49,7 @@ export const singleBucketConfig: InitStorageConfig = {
       storageLimit: new BN(1_000_000_000_000),
       objectsLimit: 1000000000,
       transactorKey: process.env.COLOSSUS_1_TRANSACTOR_KEY || '5DkE5YD8m5Yzno6EH2RTBnH268TDnnibZMEMjxwYemU4XevU', // //Colossus1
+      transactorUri: process.env.COLOSSUS_1_TRANSACTOR_URI || '//Colossus1',
       transactorBalance: new BN(100_000),
     },
   ],
@@ -66,6 +68,7 @@ export const doubleBucketConfig: InitStorageConfig = {
       storageLimit: new BN(1_000_000_000_000),
       objectsLimit: 1000000000,
       transactorKey: process.env.COLOSSUS_1_TRANSACTOR_KEY || '5DkE5YD8m5Yzno6EH2RTBnH268TDnnibZMEMjxwYemU4XevU', // //Colossus1
+      transactorUri: process.env.COLOSSUS_1_TRANSACTOR_URI || '//Colossus1',
       transactorBalance: new BN(100_000),
     },
     {
@@ -75,6 +78,7 @@ export const doubleBucketConfig: InitStorageConfig = {
       storageLimit: new BN(1_000_000_000_000),
       objectsLimit: 1000000000,
       transactorKey: process.env.COLOSSUS_2_TRANSACTOR_KEY || '5FbzYmQ3HogiEEDSXPYJe58yCcmSh3vsZLodTdBB6YuLDAj7', // //Colossus2
+      transactorUri: process.env.COLOSSUS_2_TRANSACTOR_URI || '//Colossus2',
       transactorBalance: new BN(100_000),
     },
   ],
@@ -110,6 +114,16 @@ export default function createFlow({ buckets, dynamicBagPolicy }: InitStorageCon
     const setMaxVoucherLimitsTx = api.tx.storage.updateStorageBucketsVoucherMaxLimits(maxStorageLimit, maxObjectsLimit)
     const setBucketPerBagLimitTx = api.tx.storage.updateStorageBucketsPerBagLimit(Math.max(5, buckets.length))
 
+    // TODO: find a way how to remove this
+    debug('BUG WORKAROUND pretopup')
+    await api.treasuryTransferBalance(storageLeaderKey, new BN(100_000))
+
+    // extra topup for content leader
+    const [, contentLeader] = await api.getLeader('contentWorkingGroup')
+    const contentLeaderKey = contentLeader.role_account_id.toString()
+    await api.treasuryTransferBalance(contentLeaderKey, new BN(100_000))
+    /// ///////////////////////////////////
+
     await api.sendExtrinsicsAndGetResults(
       [...updateDynamicBagPolicyTxs, setMaxVoucherLimitsTx, setBucketPerBagLimitTx],
       storageLeaderKey

+ 15 - 0
tests/integration-tests/src/flows/utils.ts

@@ -0,0 +1,15 @@
+import { JoystreamCLI } from '../cli/joystream'
+import { TmpFileManager } from '../cli/utils'
+import { v4 as uuid } from 'uuid'
+
+export async function createJoystreamCli(): Promise<JoystreamCLI> {
+  const tmpFileManager = new TmpFileManager()
+
+  // create Joystream CLI
+  const joystreamCli = new JoystreamCLI(tmpFileManager)
+
+  // init CLI
+  await joystreamCli.init()
+
+  return joystreamCli
+}

+ 103 - 0
tests/integration-tests/src/graphql/generated/queries.ts

@@ -1,6 +1,43 @@
 import * as Types from './schema'
 
 import gql from 'graphql-tag'
+export type ChannelFieldsFragment = { id: string; activeVideosCounter: number }
+
+export type ChannelCategoryFieldsFragment = { id: string; activeVideosCounter: number }
+
+export type VideoCategoryFieldsFragment = { id: string; activeVideosCounter: number }
+
+export type OwnedNftFieldsFragment = {
+  id: string
+  metadata: string
+  creatorRoyalty?: Types.Maybe<number>
+  video: { id: string }
+  ownerMember?: Types.Maybe<{ id: string }>
+  transactionalStatus:
+    | { __typename: 'TransactionalStatusIdle' }
+    | { __typename: 'TransactionalStatusInitiatedOfferToMember' }
+    | { __typename: 'TransactionalStatusAuction' }
+    | { __typename: 'TransactionalStatusBuyNow' }
+}
+
+export type GetChannelsQueryVariables = Types.Exact<{ [key: string]: never }>
+
+export type GetChannelsQuery = { channels: Array<ChannelFieldsFragment> }
+
+export type GetChannelCategoriesQueryVariables = Types.Exact<{ [key: string]: never }>
+
+export type GetChannelCategoriesQuery = { channelCategories: Array<ChannelCategoryFieldsFragment> }
+
+export type GetVideoCategoriesQueryVariables = Types.Exact<{ [key: string]: never }>
+
+export type GetVideoCategoriesQuery = { videoCategories: Array<VideoCategoryFieldsFragment> }
+
+export type GetOwnedNftByVideoIdQueryVariables = Types.Exact<{
+  videoId: Types.Scalars['ID']
+}>
+
+export type GetOwnedNftByVideoIdQuery = { ownedNfts: Array<OwnedNftFieldsFragment> }
+
 export type CouncilMemberFieldsFragment = { id: string; member: { id: string } }
 
 export type ElectedCouncilFieldsFragment = { councilMembers: Array<CouncilMemberFieldsFragment> }
@@ -1900,6 +1937,40 @@ export type GetBudgetSpendingEventsByEventIdsQueryVariables = Types.Exact<{
 
 export type GetBudgetSpendingEventsByEventIdsQuery = { budgetSpendingEvents: Array<BudgetSpendingEventFieldsFragment> }
 
+export const ChannelFields = gql`
+  fragment ChannelFields on Channel {
+    id
+    activeVideosCounter
+  }
+`
+export const ChannelCategoryFields = gql`
+  fragment ChannelCategoryFields on ChannelCategory {
+    id
+    activeVideosCounter
+  }
+`
+export const VideoCategoryFields = gql`
+  fragment VideoCategoryFields on VideoCategory {
+    id
+    activeVideosCounter
+  }
+`
+export const OwnedNftFields = gql`
+  fragment OwnedNftFields on OwnedNft {
+    id
+    video {
+      id
+    }
+    ownerMember {
+      id
+    }
+    metadata
+    transactionalStatus {
+      __typename
+    }
+    creatorRoyalty
+  }
+`
 export const CouncilMemberFields = gql`
   fragment CouncilMemberFields on CouncilMember {
     id
@@ -3657,6 +3728,38 @@ export const BudgetSpendingEventFields = gql`
     rationale
   }
 `
+export const GetChannels = gql`
+  query getChannels {
+    channels {
+      ...ChannelFields
+    }
+  }
+  ${ChannelFields}
+`
+export const GetChannelCategories = gql`
+  query getChannelCategories {
+    channelCategories {
+      ...ChannelCategoryFields
+    }
+  }
+  ${ChannelCategoryFields}
+`
+export const GetVideoCategories = gql`
+  query getVideoCategories {
+    videoCategories {
+      ...VideoCategoryFields
+    }
+  }
+  ${VideoCategoryFields}
+`
+export const GetOwnedNftByVideoId = gql`
+  query getOwnedNftByVideoId($videoId: ID!) {
+    ownedNfts(where: { video: { id_eq: $videoId } }) {
+      ...OwnedNftFields
+    }
+  }
+  ${OwnedNftFields}
+`
 export const GetCurrentCouncilMembers = gql`
   query getCurrentCouncilMembers {
     electedCouncils(where: { endedAtBlock_eq: null }) {

Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 433 - 298
tests/integration-tests/src/graphql/generated/schema.ts


+ 53 - 0
tests/integration-tests/src/graphql/queries/content.graphql

@@ -0,0 +1,53 @@
+fragment ChannelFields on Channel {
+  id
+  activeVideosCounter
+}
+
+fragment ChannelCategoryFields on ChannelCategory {
+  id
+  activeVideosCounter
+}
+
+fragment VideoCategoryFields on VideoCategory {
+  id
+  activeVideosCounter
+}
+
+fragment OwnedNftFields on OwnedNft {
+  id
+  video {
+    id
+  }
+  ownerMember {
+    id
+  }
+  metadata
+  transactionalStatus {
+    __typename
+  }
+  creatorRoyalty
+}
+
+query getChannels {
+  channels {
+    ...ChannelFields
+  }
+}
+
+query getChannelCategories {
+  channelCategories {
+    ...ChannelCategoryFields
+  }
+}
+
+query getVideoCategories {
+  videoCategories {
+    ...VideoCategoryFields
+  }
+}
+
+query getOwnedNftByVideoId($videoId: ID!) {
+  ownedNfts(where: { video: { id_eq: $videoId } }) {
+    ...OwnedNftFields
+  }
+}

+ 20 - 0
tests/integration-tests/src/scenarios/content-directory.ts

@@ -0,0 +1,20 @@
+import leadOpening from '../flows/working-groups/leadOpening'
+import activeVideoCounters from '../flows/content/activeVideoCounters'
+import nftAuctionAndOffers from '../flows/content/nftAuctionAndOffers'
+import initStorage, { singleBucketConfig as storageConfig } from '../flows/storage/initStorage'
+import { workingGroups } from '../consts'
+import { scenario } from '../Scenario'
+
+scenario('Content directory', async ({ job }) => {
+  const leadSetupJob = job('Set WorkingGroup Leads', leadOpening)
+
+  // TOOD: topup content and storage leaders
+  // const [, storageLeader] = await api.getLeader('storageWorkingGroup')
+  // const storageLeaderKey = storageLeader.role_account_id.toString()
+  // await api.treasuryTransferBalance(storageLeaderKey, new BN(100_000)),
+
+  const initStorageJob = job('initialize storage system', initStorage(storageConfig)).requires(leadSetupJob)
+
+  const videoCountersJob = job('check active video counters', activeVideoCounters).requires(initStorageJob)
+  job('nft auction and offers', nftAuctionAndOffers).after(videoCountersJob)
+})

+ 1 - 1
tests/integration-tests/src/scenarios/council.ts

@@ -2,7 +2,7 @@ import electCouncil from '../flows/council/elect'
 import failToElectCouncil from '../flows/council/failToElect'
 import { scenario } from '../Scenario'
 
-scenario(async ({ job }) => {
+scenario('Council', async ({ job }) => {
   const councilJob = job('electing council', electCouncil)
   const secondCouncilJob = job('electing second council', electCouncil).requires(councilJob)
 

+ 1 - 1
tests/integration-tests/src/scenarios/forum.ts

@@ -7,7 +7,7 @@ import leadOpening from '../flows/working-groups/leadOpening'
 import threadTags from '../flows/forum/threadTags'
 import { scenario } from '../Scenario'
 
-scenario(async ({ job }) => {
+scenario('Forum', async ({ job }) => {
   const sudoHireLead = job('hiring working group leads', leadOpening)
   job('forum categories', categories).requires(sudoHireLead)
   job('forum threads', threads).requires(sudoHireLead)

+ 1 - 1
tests/integration-tests/src/scenarios/forumPostDeletionsBug.ts

@@ -2,7 +2,7 @@ import leadOpening from '../flows/working-groups/leadOpening'
 import multiplePostDeletionsBug from '../flows/forum/multiplePostDeletionsBug'
 import { scenario } from '../Scenario'
 
-scenario(async ({ job }) => {
+scenario('Forum post deletions bug', async ({ job }) => {
   const sudoHireLead = job('hiring working group leads', leadOpening)
   job('forum post deletions bug', multiplePostDeletionsBug).requires(sudoHireLead)
 })

+ 9 - 1
tests/integration-tests/src/scenarios/full.ts

@@ -26,9 +26,12 @@ import runtimeUpgradeProposal from '../flows/proposals/runtimeUpgradeProposal'
 import exactExecutionBlock from '../flows/proposals/exactExecutionBlock'
 import expireProposal from '../flows/proposals/expireProposal'
 import proposalsDiscussion from '../flows/proposalsDiscussion'
+import initStorage, { singleBucketConfig as storageConfig } from '../flows/storage/initStorage'
+import activeVideoCounters from '../flows/content/activeVideoCounters'
+import nftAuctionAndOffers from '../flows/content/nftAuctionAndOffers'
 import { scenario } from '../Scenario'
 
-scenario(async ({ job, env }) => {
+scenario('Full', async ({ job, env }) => {
   // Runtime upgrade should always be first job
   // (except councilJob, which is required for voting and should probably depend on the "source" runtime)
   const councilJob = job('electing council', electCouncil)
@@ -80,4 +83,9 @@ scenario(async ({ job, env }) => {
   job('forum polls', polls).requires(sudoHireLead)
   job('forum posts', posts).requires(sudoHireLead)
   job('forum moderation', moderation).requires(sudoHireLead)
+
+  // Content directory
+  const initStorageJob = job('initialize storage system', initStorage(storageConfig)).requires(sudoHireLead)
+  const videoCountersJob = job('check active video counters', activeVideoCounters).requires(initStorageJob)
+  job('nft auction and offers', nftAuctionAndOffers).after(videoCountersJob)
 })

+ 1 - 1
tests/integration-tests/src/scenarios/memberships.ts

@@ -7,7 +7,7 @@ import managingStakingAccounts from '../flows/membership/managingStakingAccounts
 import membershipSystem from '../flows/membership/membershipSystem'
 import { scenario } from '../Scenario'
 
-scenario(async ({ job }) => {
+scenario('Memberships', async ({ job }) => {
   const membershipSystemJob = job('membership system', membershipSystem)
   // All other job should be executed after, otherwise changing membershipPrice etc. may break them
   job('creating members', creatingMemberships).after(membershipSystemJob)

+ 1 - 1
tests/integration-tests/src/scenarios/olympia.ts

@@ -6,7 +6,7 @@ import transferringInvites from '../flows/membership/transferringInvites'
 import managingStakingAccounts from '../flows/membership/managingStakingAccounts'
 import { scenario } from '../Scenario'
 
-scenario(async ({ job }) => {
+scenario('Olympia', async ({ job }) => {
   job('creating members', creatingMemberships)
   job('updating member profile', updatingMemberProfile)
   job('updating member accounts', updatingMemberAccounts)

+ 1 - 1
tests/integration-tests/src/scenarios/proposals.ts

@@ -8,7 +8,7 @@ import expireProposal from '../flows/proposals/expireProposal'
 import proposalsDiscussion from '../flows/proposalsDiscussion'
 import { scenario } from '../Scenario'
 
-scenario(async ({ job, env }) => {
+scenario('Proposals', async ({ job, env }) => {
   const councilJob = job('electing council', electCouncil)
   const runtimeUpgradeProposalJob = env.RUNTIME_UPGRADE_TARGET_WASM_PATH
     ? job('runtime upgrade proposal', runtimeUpgradeProposal).requires(councilJob)

+ 1 - 1
tests/integration-tests/src/scenarios/proposalsDiscussion.ts

@@ -2,7 +2,7 @@ import electCouncil from '../flows/council/elect'
 import proposalsDiscussion from '../flows/proposalsDiscussion'
 import { scenario } from '../Scenario'
 
-scenario(async ({ job, env }) => {
+scenario('Proposals discussion', async ({ job, env }) => {
   const councilJob = job('electing council', electCouncil)
   job('proposal discussion', [proposalsDiscussion]).requires(councilJob)
 })

+ 1 - 1
tests/integration-tests/src/scenarios/setupNewChain.ts

@@ -5,7 +5,7 @@ import initStorage, { singleBucketConfig as defaultStorageConfig } from '../flow
 import initDistribution, { singleBucketConfig as defaultDistributionConfig } from '../flows/storage/initDistribution'
 import { scenario } from '../Scenario'
 
-scenario(async ({ job }) => {
+scenario('Setup new chain', async ({ job }) => {
   job('Elect Council', electCouncil)
   const leads = job('Set WorkingGroup Leads', leaderSetup)
   const updateWorkerAccounts = job('Update worker accounts', updateAccountsFlow).after(leads)

+ 1 - 1
tests/integration-tests/src/scenarios/workingGroups.ts

@@ -6,7 +6,7 @@ import workerActions from '../flows/working-groups/workerActions'
 import { scenario } from '../Scenario'
 import groupBudget from '../flows/working-groups/groupBudget'
 
-scenario(async ({ job }) => {
+scenario('Working groups', async ({ job }) => {
   const sudoHireLead = job('sudo lead opening', leadOpening)
   job('openings and applications', openingsAndApplications).requires(sudoHireLead)
   job('upcoming openings', upcomingOpenings).requires(sudoHireLead)

+ 1 - 1
tests/integration-tests/src/sender.ts

@@ -19,7 +19,7 @@ const nonceCacheByAccount = new Map<string, number>()
 
 export class Sender {
   private readonly api: ApiPromise
-  private static readonly asyncLock: AsyncLock = new AsyncLock()
+  static readonly asyncLock: AsyncLock = new AsyncLock()
   private readonly keyring: Keyring
   private readonly debug: Debugger.Debugger
   private logs: LogLevel = LogLevel.None

+ 14 - 1
tests/integration-tests/src/types.ts

@@ -1,10 +1,11 @@
-import { MemberId, PostId, ThreadId } from '@joystream/types/common'
+import { MemberId, PostId, ThreadId, ChannelId } from '@joystream/types/common'
 import { ApplicationId, OpeningId, WorkerId, ApplyOnOpeningParameters } from '@joystream/types/working-group'
 import { Event } from '@polkadot/types/interfaces/system'
 import { BTreeMap } from '@polkadot/types'
 import { CategoryId } from '@joystream/types/forum'
 import { MembershipBoughtEvent } from './graphql/generated/schema'
 import { ProposalDetails, ProposalId } from '@joystream/types/proposals'
+import { ContentActor, VideoId, VideoCreationParameters } from '@joystream/types/content'
 import { CreateInterface } from '@joystream/types'
 
 export type AnyQueryNodeEvent = Pick<
@@ -134,6 +135,18 @@ export type ProposalType = keyof typeof ProposalDetails.typeDefinitions
 export type ProposalDetailsJsonByType<T extends ProposalType = ProposalType> = CreateInterface<
   InstanceType<ProposalDetails['typeDefinitions'][T]>
 >
+
+// Content
+
+export type ContentEventName = 'VideoCreated'
+
+export interface VideoCreatedEventDetails extends EventDetails {
+  actor: ContentActor
+  channelId: ChannelId
+  videoId: VideoId
+  params: VideoCreationParameters
+}
+
 // Forum
 
 export type ThreadPath = {

+ 210 - 9
tests/network-tests/src/Api.ts

@@ -1,7 +1,8 @@
 import { ApiPromise, WsProvider, Keyring, SubmittableResult } from '@polkadot/api'
-import { Bytes, Option, u32, Vec, StorageKey } from '@polkadot/types'
+import { Bytes, BTreeSet, Option, u32, Vec, StorageKey } from '@polkadot/types'
 import { Codec, ISubmittableResult, IEvent } from '@polkadot/types/types'
 import { KeyringPair } from '@polkadot/keyring/types'
+import { decodeAddress } from '@polkadot/keyring'
 import { MemberId, PaidMembershipTerms, PaidTermId } from '@joystream/types/members'
 import { Mint, MintId } from '@joystream/types/mint'
 import {
@@ -12,6 +13,7 @@ import {
   Opening as WorkingGroupOpening,
 } from '@joystream/types/working-group'
 import { ElectionStake, Seat } from '@joystream/types/council'
+import { DataObjectId, StorageBucketId } from '@joystream/types/storage'
 import { AccountInfo, Balance, BalanceOf, BlockNumber, EventRecord, AccountId } from '@polkadot/types/interfaces'
 import BN from 'bn.js'
 import { AugmentedEvent, SubmittableExtrinsic } from '@polkadot/api/types'
@@ -28,10 +30,9 @@ import {
   OpeningId,
 } from '@joystream/types/hiring'
 import { FillOpeningParameters, ProposalId } from '@joystream/types/proposals'
-// import { v4 as uuid } from 'uuid'
 import { extendDebug } from './Debugger'
 import { InvertedPromise } from './InvertedPromise'
-import { VideoId, VideoCategoryId } from '@joystream/types/content'
+import { VideoId, VideoCategoryId, AuctionParams } from '@joystream/types/content'
 import { ChannelId } from '@joystream/types/common'
 import { ChannelCategoryMetadata, VideoCategoryMetadata } from '@joystream/metadata-protobuf'
 import { metadataToBytes } from '../../../cli/lib/helpers/serialization'
@@ -137,18 +138,18 @@ export class ApiFactory {
     return keys
   }
 
-  private createKeyPair(suriPath: string, isCustom = false): KeyringPair {
+  private createKeyPair(suriPath: string, isCustom = false, isFinalPath = false): KeyringPair {
     if (isCustom) {
       this.customKeys.push(suriPath)
     }
-    const uri = `${this.miniSecret}//testing//${suriPath}`
+    const uri = isFinalPath ? suriPath : `${this.miniSecret}//testing//${suriPath}`
     const pair = this.keyring.addFromUri(uri)
     this.addressesToSuri.set(pair.address, uri)
     return pair
   }
 
-  public createCustomKeyPair(customPath: string): KeyringPair {
-    return this.createKeyPair(customPath, true)
+  public createCustomKeyPair(customPath: string, isFinalPath: boolean): KeyringPair {
+    return this.createKeyPair(customPath, true, isFinalPath)
   }
 
   public keyGenInfo(): KeyGenInfo {
@@ -242,8 +243,8 @@ export class Api {
     return this.factory.createKeyPairs(n)
   }
 
-  public createCustomKeyPair(path: string): KeyringPair {
-    return this.factory.createCustomKeyPair(path)
+  public createCustomKeyPair(path: string, finalPath = false): KeyringPair {
+    return this.factory.createCustomKeyPair(path, finalPath)
   }
 
   public keyGenInfo(): KeyGenInfo {
@@ -1932,4 +1933,204 @@ export class Api {
     const setCouncilCall = this.api.tx.council.setCouncil(accounts)
     return this.makeSudoCall(setCouncilCall)
   }
+
+  // Storage
+
+  async createStorageBucket(
+    accountFrom: string, // group leader
+    sizeLimit: number,
+    objectsLimit: number,
+    workerId?: WorkerId
+  ): Promise<ISubmittableResult> {
+    return this.sender.signAndSend(
+      this.api.tx.storage.createStorageBucket(workerId || null, true, sizeLimit, objectsLimit),
+      accountFrom
+    )
+  }
+
+  async acceptStorageBucketInvitation(accountFrom: string, workerId: WorkerId, storageBucketId: StorageBucketId) {
+    return this.sender.signAndSend(
+      this.api.tx.storage.acceptStorageBucketInvitation(workerId, storageBucketId, accountFrom),
+      accountFrom
+    )
+  }
+
+  async updateStorageBucketsForBag(
+    accountFrom: string, // group leader
+    channelId: string,
+    addStorageBuckets: StorageBucketId[]
+  ) {
+    return this.sender.signAndSend(
+      this.api.tx.storage.updateStorageBucketsForBag(
+        this.api.createType('BagId', { Dynamic: { Channel: channelId } }),
+        this.api.createType('BTreeSet<StorageBucketId>', [addStorageBuckets.map((item) => item.toString())]),
+        this.api.createType('BTreeSet<StorageBucketId>', [])
+      ),
+      accountFrom
+    )
+  }
+
+  async updateStorageBucketsPerBagLimit(
+    accountFrom: string, // group leader
+    limit: number
+  ) {
+    return this.sender.signAndSend(this.api.tx.storage.updateStorageBucketsPerBagLimit(limit), accountFrom)
+  }
+
+  async updateStorageBucketsVoucherMaxLimits(
+    accountFrom: string, // group leader
+    sizeLimit: number,
+    objectLimit: number
+  ) {
+    return this.sender.signAndSend(
+      this.api.tx.storage.updateStorageBucketsVoucherMaxLimits(sizeLimit, objectLimit),
+      accountFrom
+    )
+  }
+
+  async acceptPendingDataObjects(
+    accountFrom: string,
+    workerId: WorkerId,
+    storageBucketId: StorageBucketId,
+    channelId: string,
+    dataObjectIds: string[]
+  ): Promise<ISubmittableResult> {
+    const bagId = { Dynamic: { Channel: channelId } }
+    const encodedDataObjectIds = new BTreeSet<DataObjectId>(this.api.registry, 'DataObjectId', dataObjectIds)
+
+    return this.sender.signAndSend(
+      this.api.tx.storage.acceptPendingDataObjects(workerId, storageBucketId, bagId, encodedDataObjectIds),
+      accountFrom
+    )
+  }
+
+  async issueNft(
+    accountFrom: string,
+    memberId: number,
+    videoId: number,
+    metadata = '',
+    royaltyPercentage?: number,
+    toMemberId?: number | null
+  ): Promise<ISubmittableResult> {
+    const perbillOnePercent = 10 * 1000000
+
+    const royalty = this.api.createType(
+      'Option<Royalty>',
+      royaltyPercentage ? royaltyPercentage * perbillOnePercent : null
+    )
+    // TODO: find proper way to encode metadata (should they be raw string, hex string or some object?)
+    // const encodedMetadata = this.api.createType('Metadata', metadata)
+    // const encodedMetadata = this.api.createType('Metadata', metadata).toU8a() // invalid type passed to Metadata constructor
+    // const encodedMetadata = this.api.createType('Vec<u8>', metadata)
+    // const encodedMetadata = this.api.createType('Vec<u8>', 'someNonEmptyText') // decodeU8a: failed at 0x736f6d654e6f6e45… on magicNumber: u32:: MagicNumber mismatch: expected 0x6174656d, found 0x656d6f73
+    // const encodedMetadata = this.api.createType('Bytes', 'someNonEmptyText') // decodeU8a: failed at 0x736f6d654e6f6e45… on magicNumber: u32:: MagicNumber mismatch: expected 0x6174656d, found 0x656d6f73
+    // const encodedMetadata = this.api.createType('Metadata', {})
+    // const encodedMetadata = this.api.createType('Bytes', '0x') // error
+    // const encodedMetadata = this.api.createType('NFTMetadata', 'someNonEmptyText')
+    // const encodedMetadata = this.api.createType('NFTMetadata', 'someNonEmptyText').toU8a() // createType(NFTMetadata) // Vec length 604748352930462863646034177481338223 exceeds 65536
+    const encodedMetadata = this.api.createType('NFTMetadata', '').toU8a() // THIS IS OK!!! but only for empty string :-\
+    // try this later on // const encodedMetadata = this.api.createType('Vec<u8>', 'someNonEmptyText').toU8a()
+    // const encodedMetadata = this.api.createType('Vec<u8>', 'someNonEmptyText').toU8a() // throws error in QN when decoding this (but mb QN error)
+
+    const encodedToAccount = this.api.createType('Option<MemberId>', toMemberId || memberId)
+
+    return await this.sender.signAndSend(
+      this.api.tx.content.issueNft({ Member: memberId }, videoId, royalty, encodedMetadata, encodedToAccount),
+      accountFrom
+    )
+  }
+
+  createAuctionParameters(
+    auctionType: 'English' | 'Open',
+    startingPrice: BN,
+    minimalBidStep: BN,
+    buyNowPrice?: BN,
+    startInBlock?: BN,
+    whitelist: string[] = []
+  ): AuctionParams {
+    const encodedAuctionType =
+      auctionType === 'English'
+        ? {
+            English: {
+              extension_period: 5, // TODO - read min/max bounds from runtime and set min value here (?)
+              auction_duration: 5, // TODO - read min/max bounds from runtime and set min value here (?)
+            },
+          }
+        : {
+            Open: {
+              bid_lock_duration: 2, // TODO - read min/max bounds from runtime and set min value here (?)
+            },
+          }
+
+    return this.api.createType('AuctionParams', {
+      auction_type: this.api.createType('AuctionType', encodedAuctionType),
+      starting_price: this.api.createType('u128', startingPrice),
+      minimal_bid_step: this.api.createType('u128', minimalBidStep),
+      buy_now_price: this.api.createType('Option<BlockNumber>', buyNowPrice),
+      starts_at: this.api.createType('Option<BlockNumber>', startInBlock),
+      whitelist: this.api.createType('BTreeSet<StorageBucketId>', whitelist),
+    })
+  }
+
+  async startNftAuction(
+    accountFrom: string,
+    memberId: number,
+    videoId: number,
+    auctionParams: AuctionParams
+  ): Promise<ISubmittableResult> {
+    return await this.sender.signAndSend(
+      this.api.tx.content.startNftAuction({ Member: memberId }, videoId, auctionParams),
+      accountFrom
+    )
+  }
+
+  async bidInNftAuction(
+    accountFrom: string,
+    memberId: number,
+    videoId: number,
+    bidAmount: BN
+  ): Promise<ISubmittableResult> {
+    return await this.sender.signAndSend(this.api.tx.content.makeBid(memberId, videoId, bidAmount), accountFrom)
+  }
+
+  async claimWonEnglishAuction(accountFrom: string, memberId: number, videoId: number): Promise<ISubmittableResult> {
+    return await this.sender.signAndSend(this.api.tx.content.claimWonEnglishAuction(memberId, videoId), accountFrom)
+  }
+
+  async pickOpenAuctionWinner(accountFrom: string, memberId: number, videoId: number) {
+    return await this.sender.signAndSend(
+      this.api.tx.content.pickOpenAuctionWinner({ Member: memberId }, videoId),
+      accountFrom
+    )
+  }
+
+  async cancelOpenAuctionBid(accountFrom: string, participantId: number, videoId: number) {
+    return await this.sender.signAndSend(this.api.tx.content.cancelOpenAuctionBid(participantId, videoId), accountFrom)
+  }
+
+  async cancelNftAuction(accountFrom: string, ownerId: number, videoId: number) {
+    return await this.sender.signAndSend(
+      this.api.tx.content.cancelNftAuction({ Member: ownerId }, videoId),
+      accountFrom
+    )
+  }
+
+  async sellNft(accountFrom: string, videoId: number, ownerId: number, price: BN) {
+    return await this.sender.signAndSend(this.api.tx.content.sellNft(videoId, { Member: ownerId }, price), accountFrom)
+  }
+
+  async buyNft(accountFrom: string, videoId: number, participantId: number) {
+    return await this.sender.signAndSend(this.api.tx.content.buyNft(videoId, participantId), accountFrom)
+  }
+
+  async offerNft(accountFrom: string, videoId: number, ownerId: number, toMemberId: number, price: BN | null = null) {
+    return await this.sender.signAndSend(
+      this.api.tx.content.offerNft(videoId, { Member: ownerId }, toMemberId, price),
+      accountFrom
+    )
+  }
+
+  async acceptIncomingOffer(accountFrom: string, videoId: number) {
+    return await this.sender.signAndSend(this.api.tx.content.acceptIncomingOffer(videoId), accountFrom)
+  }
 }

+ 10 - 0
tests/network-tests/src/Fixture.ts

@@ -2,6 +2,7 @@ import { Api } from './Api'
 import { assert } from 'chai'
 import { ISubmittableResult } from '@polkadot/types/types/'
 import { DispatchResult } from '@polkadot/types/interfaces/system'
+import { QueryNodeApi } from './QueryNodeApi'
 
 export abstract class BaseFixture {
   protected readonly api: Api
@@ -83,6 +84,15 @@ export abstract class BaseFixture {
   }
 }
 
+export abstract class BaseQueryNodeFixture extends BaseFixture {
+  protected readonly query: QueryNodeApi
+
+  constructor(api: Api, query: QueryNodeApi) {
+    super(api)
+    this.query = query
+  }
+}
+
 // Runs a fixture and measures how long it took to run
 // Ensures fixture only runs once, and asserts that it doesn't fail
 export class FixtureRunner {

+ 6 - 1
tests/network-tests/src/Flow.ts

@@ -2,5 +2,10 @@ import { Api } from './Api'
 import { QueryNodeApi } from './QueryNodeApi'
 import { ResourceLocker } from './Resources'
 
-export type FlowProps = { api: Api; env: NodeJS.ProcessEnv; query: QueryNodeApi; lock: ResourceLocker }
+export type FlowProps = {
+  api: Api
+  env: NodeJS.ProcessEnv
+  query: QueryNodeApi
+  lock: ResourceLocker
+}
 export type Flow = (args: FlowProps) => Promise<void>

+ 5 - 1
tests/network-tests/src/Job.ts

@@ -6,7 +6,11 @@ import { Flow } from './Flow'
 import { InvertedPromise } from './InvertedPromise'
 import { ResourceManager } from './Resources'
 
-export type JobProps = { apiFactory: ApiFactory; env: NodeJS.ProcessEnv; query: QueryNodeApi }
+export type JobProps = {
+  apiFactory: ApiFactory
+  env: NodeJS.ProcessEnv
+  query: QueryNodeApi
+}
 
 export enum JobOutcome {
   Succeeded = 'Succeeded',

+ 52 - 3
tests/network-tests/src/QueryNodeApi.ts

@@ -1,4 +1,5 @@
-import { ApolloClient, DocumentNode, NormalizedCacheObject } from '@apollo/client/core'
+import { BLOCKTIME } from './consts'
+import { gql, ApolloClient, ApolloQueryResult, DocumentNode, NormalizedCacheObject } from '@apollo/client/core'
 import { extendDebug, Debugger } from './Debugger'
 import {
   StorageDataObjectFieldsFragment,
@@ -9,6 +10,10 @@ import {
   GetChannelById,
   GetChannelByIdQuery,
   GetChannelByIdQueryVariables,
+  OwnedNftFieldsFragment,
+  GetOwnedNftByVideoId,
+  GetOwnedNftByVideoIdQuery,
+  GetOwnedNftByVideoIdQueryVariables,
 } from './graphql/generated/queries'
 import { Maybe } from './graphql/generated/schema'
 import { OperationDefinitionNode } from 'graphql'
@@ -30,7 +35,7 @@ export class QueryNodeApi {
   public async tryQueryWithTimeout<QueryResultT>(
     query: () => Promise<QueryResultT>,
     assertResultIsValid: (res: QueryResultT) => void,
-    retryTimeMs = 18000,
+    retryTimeMs = BLOCKTIME * 3,
     retries = 6
   ): Promise<QueryResultT> {
     const label = query.toString().replace(/^.*\.([A-za-z0-9]+\(.*\))$/g, '$1')
@@ -58,7 +63,7 @@ export class QueryNodeApi {
       try {
         assertResultIsValid(result)
       } catch (e) {
-        debug(`Unexpected query result${e instanceof Error ? ` (${e.message})` : ''}`)
+        debug(`Unexpected query result${e && (e as Error).message ? ` (${(e as Error).message})` : ''}`)
         await retry(e)
         continue
       }
@@ -119,4 +124,48 @@ export class QueryNodeApi {
       'storageDataObjects'
     )
   }
+
+  public async getChannels(): Promise<ApolloQueryResult<any>> {
+    const query = gql`
+      query {
+        channels {
+          id
+          activeVideosCounter
+        }
+      }
+    `
+    return await this.queryNodeProvider.query({ query })
+  }
+
+  public async getChannelCategories(): Promise<ApolloQueryResult<any>> {
+    const query = gql`
+      query {
+        channelCategories {
+          id
+          activeVideosCounter
+        }
+      }
+    `
+    return await this.queryNodeProvider.query({ query })
+  }
+
+  public async getVideoCategories(): Promise<ApolloQueryResult<any>> {
+    const query = gql`
+      query {
+        videoCategories {
+          id
+          activeVideosCounter
+        }
+      }
+    `
+    return await this.queryNodeProvider.query({ query })
+  }
+
+  public async ownedNftByVideoId(videoId: string): Promise<Maybe<OwnedNftFieldsFragment>> {
+    return this.firstEntityQuery<GetOwnedNftByVideoIdQuery, GetOwnedNftByVideoIdQueryVariables>(
+      GetOwnedNftByVideoId,
+      { videoId },
+      'ownedNfts'
+    )
+  }
 }

+ 5 - 3
tests/network-tests/src/Scenario.ts

@@ -9,7 +9,7 @@ import { Job } from './Job'
 import { JobManager } from './JobManager'
 import { ResourceManager } from './Resources'
 import fetch from 'cross-fetch'
-import fs, { readFileSync } from 'fs'
+import fs from 'fs'
 
 export type ScenarioProps = {
   env: NodeJS.ProcessEnv
@@ -42,7 +42,7 @@ function writeOutput(api: Api, miniSecret: string) {
   fs.writeFileSync(OUTPUT_FILE_PATH, JSON.stringify(output, undefined, 2))
 }
 
-export async function scenario(scene: (props: ScenarioProps) => Promise<void>): Promise<void> {
+export async function scenario(label: string, scene: (props: ScenarioProps) => Promise<void>): Promise<void> {
   // Load env variables
   config()
   const env = process.env
@@ -65,7 +65,7 @@ export async function scenario(scene: (props: ScenarioProps) => Promise<void>):
   let startKeyId: number
   let customKeys: string[] = []
   if (reuseKeys) {
-    const output = JSON.parse(readFileSync(OUTPUT_FILE_PATH).toString()) as TestsOutput
+    const output = JSON.parse(fs.readFileSync(OUTPUT_FILE_PATH).toString()) as TestsOutput
     startKeyId = output.keyIds.final
     customKeys = output.keyIds.custom
   } else {
@@ -87,6 +87,8 @@ export async function scenario(scene: (props: ScenarioProps) => Promise<void>):
 
   const debug = extendDebug('scenario')
 
+  debug(label)
+
   const jobs = new JobManager({ apiFactory, query, env })
 
   await scene({ env, debug, job: jobs.createJob.bind(jobs) })

+ 0 - 54
tests/network-tests/src/cli/base.ts

@@ -1,54 +0,0 @@
-import path from 'path'
-import { execFile } from 'child_process'
-import { promisify } from 'util'
-import { Sender } from '../sender'
-
-export type CommandResult = { stdout: string; stderr: string; out: string }
-
-export abstract class CLI {
-  protected env: Record<string, string>
-  protected readonly rootPath: string
-  protected readonly binPath: string
-  protected defaultArgs: string[]
-
-  constructor(rootPath: string, defaultEnv: Record<string, string> = {}, defaultArgs: string[] = []) {
-    this.rootPath = rootPath
-    this.binPath = path.resolve(rootPath, './bin/run')
-    this.env = {
-      ...process.env,
-      AUTO_CONFIRM: 'true',
-      FORCE_COLOR: '0',
-      ...defaultEnv,
-    }
-    this.defaultArgs = [...defaultArgs]
-  }
-
-  protected getArgs(customArgs: string[]): string[] {
-    return [...this.defaultArgs, ...customArgs]
-  }
-
-  protected getFlagStringValue(args: string[], flag: string, alias?: string): string | undefined {
-    const flagIndex = args.lastIndexOf(flag)
-    const aliasIndex = alias ? args.lastIndexOf(alias) : -1
-    const flagOrAliasIndex = Math.max(flagIndex, aliasIndex)
-    if (flagOrAliasIndex === -1) {
-      return undefined
-    }
-    const nextArg = args[flagOrAliasIndex + 1]
-    return nextArg
-  }
-
-  async run(command: string, customArgs: string[] = [], lockKeys: string[] = []): Promise<CommandResult> {
-    const pExecFile = promisify(execFile)
-    const { env } = this
-    const { stdout, stderr } = await Sender.asyncLock.acquire(
-      lockKeys.map((k) => `nonce-${k}`),
-      () =>
-        pExecFile(this.binPath, [command, ...this.getArgs(customArgs)], {
-          env,
-          cwd: this.rootPath,
-        })
-    )
-    return { stdout, stderr, out: stdout.trim() }
-  }
-}

+ 0 - 47
tests/network-tests/src/cli/joystream.ts

@@ -1,47 +0,0 @@
-import { KeyringPair } from '@polkadot/keyring/types'
-import path from 'path'
-import { CLI, CommandResult } from './base'
-import { TmpFileManager } from './utils'
-import { ChannelInputParameters } from '@joystream/cli/src/Types'
-
-const CLI_ROOT_PATH = path.resolve(__dirname, '../../../../cli')
-
-export class JoystreamCLI extends CLI {
-  protected keys: string[] = []
-  protected tmpFileManager: TmpFileManager
-
-  constructor(tmpFileManager: TmpFileManager) {
-    const defaultEnv = {
-      HOME: tmpFileManager.tmpDataDir,
-    }
-    super(CLI_ROOT_PATH, defaultEnv)
-    this.tmpFileManager = tmpFileManager
-  }
-
-  async init(): Promise<void> {
-    await this.run('api:setUri', [process.env.NODE_URL || 'ws://127.0.0.1:9944'])
-    await this.run('api:setQueryNodeEndpoint', [process.env.QUERY_NODE_URL || 'http://127.0.0.1:8081/graphql'])
-  }
-
-  async importKey(pair: KeyringPair): Promise<void> {
-    const jsonFile = this.tmpFileManager.jsonFile(pair.toJson())
-    await this.run('account:import', [
-      '--backupFilePath',
-      jsonFile,
-      '--name',
-      `Account${this.keys.length}`,
-      '--password',
-      '',
-    ])
-    this.keys.push(pair.address)
-  }
-
-  async run(command: string, customArgs: string[] = [], keyLocks?: string[]): Promise<CommandResult> {
-    return super.run(command, customArgs, keyLocks || this.keys)
-  }
-
-  async createChannel(inputData: ChannelInputParameters, args: string[]): Promise<CommandResult> {
-    const jsonFile = this.tmpFileManager.jsonFile(inputData)
-    return this.run('content:createChannel', ['--input', jsonFile, ...args])
-  }
-}

+ 2 - 0
tests/network-tests/src/consts.ts

@@ -0,0 +1,2 @@
+// Test chain blocktime
+export const BLOCKTIME = 6000

+ 11 - 14
tests/network-tests/src/flows/clis/createChannel.ts

@@ -8,6 +8,7 @@ import { TmpFileManager } from '../../cli/utils'
 import { assert } from 'chai'
 import { Utils } from '../../utils'
 import { statSync } from 'fs'
+import { createJoystreamCli } from '../utils'
 
 export default async function createChannel({ api, env, query }: FlowProps): Promise<void> {
   const debug = extendDebug('flow:createChannel')
@@ -18,22 +19,23 @@ export default async function createChannel({ api, env, query }: FlowProps): Pro
   const paidTermId = api.createPaidTermId(new BN(+(env.MEMBERSHIP_PAID_TERMS || 0)))
   const buyMembershipFixture = new BuyMembershipHappyCaseFixture(api, [channelOwnerKeypair.key.address], paidTermId)
   await new FixtureRunner(buyMembershipFixture).run()
+  const memberId = buyMembershipFixture.getCreatedMembers()[0]
 
   // Send some funds to pay the deletion_prize
   const channelOwnerBalance = api.consts.storage.dataObjectDeletionPrize.muln(2)
   await api.treasuryTransferBalance(channelOwnerKeypair.key.address, channelOwnerBalance)
 
-  // Create Joystream CLI
-  const tmpFileManager = new TmpFileManager()
-  const joystreamCli = new JoystreamCLI(tmpFileManager)
+  // Create and init Joystream CLI
+  const joystreamCli = await createJoystreamCli()
 
-  // Init CLI, import & select channel owner key
+  // Import & select channel owner key
   await joystreamCli.init()
-  await joystreamCli.importKey(channelOwnerKeypair.key)
+  await joystreamCli.importAccount(channelOwnerKeypair.key)
+  await joystreamCli.chooseMemberAccount(memberId)
 
   // Create channel
-  const avatarPhotoPath = tmpFileManager.randomImgFile(300, 300)
-  const coverPhotoPath = tmpFileManager.randomImgFile(1920, 500)
+  const avatarPhotoPath = joystreamCli.getTmpFileManager().randomImgFile(300, 300)
+  const coverPhotoPath = joystreamCli.getTmpFileManager().randomImgFile(1920, 500)
   const channelInput = {
     title: 'Test channel',
     avatarPhotoPath,
@@ -43,16 +45,11 @@ export default async function createChannel({ api, env, query }: FlowProps): Pro
     language: 'en',
     rewardAccount: channelOwnerKeypair.key.address,
   }
-  const { out: createChannelOut } = await joystreamCli.createChannel(channelInput, ['--context', 'Member'])
 
-  const channelIdMatch = /Channel with id ([0-9]+) successfully created/.exec(createChannelOut)
-  if (!channelIdMatch) {
-    throw new Error(`No channel id found in output:\n${createChannelOut}`)
-  }
-  const [, channelId] = channelIdMatch
+  const channelId = await joystreamCli.createChannel(channelInput)
 
   await query.tryQueryWithTimeout(
-    () => query.channelById(channelId),
+    () => query.channelById(channelId.toString()),
     (channel) => {
       Utils.assert(channel, 'Channel not found')
       assert.equal(channel.title, channelInput.title)

+ 5 - 1
tests/network-tests/src/flows/storagev2/initStorage.ts

@@ -15,6 +15,7 @@ type StorageBucketConfig = {
   objectsLimit: number
   operatorId: number
   transactorKey: string
+  transactorUri: string
 }
 
 type InitStorageConfig = {
@@ -48,6 +49,7 @@ export const singleBucketConfig: InitStorageConfig = {
       storageLimit: new BN(1_000_000_000_000),
       objectsLimit: 1000000000,
       transactorKey: process.env.COLOSSUS_1_TRANSACTOR_KEY || '5DkE5YD8m5Yzno6EH2RTBnH268TDnnibZMEMjxwYemU4XevU', // //Colossus1
+      transactorUri: process.env.COLOSSUS_1_TRANSACTOR_URI || '//Colossus1',
     },
   ],
 }
@@ -65,6 +67,7 @@ export const doubleBucketConfig: InitStorageConfig = {
       storageLimit: new BN(1_000_000_000_000),
       objectsLimit: 1000000000,
       transactorKey: process.env.COLOSSUS_1_TRANSACTOR_KEY || '5DkE5YD8m5Yzno6EH2RTBnH268TDnnibZMEMjxwYemU4XevU', // //Colossus1
+      transactorUri: process.env.COLOSSUS_1_TRANSACTOR_URI || '//Colossus1',
     },
     {
       metadata: { endpoint: process.env.STORAGE_2_URL || 'http://localhost:3335' },
@@ -73,12 +76,13 @@ export const doubleBucketConfig: InitStorageConfig = {
       storageLimit: new BN(1_000_000_000_000),
       objectsLimit: 1000000000,
       transactorKey: process.env.COLOSSUS_2_TRANSACTOR_KEY || '5FbzYmQ3HogiEEDSXPYJe58yCcmSh3vsZLodTdBB6YuLDAj7', // //Colossus2
+      transactorUri: process.env.COLOSSUS_2_TRANSACTOR_URI || '//Colossus2',
     },
   ],
 }
 
 export default function createFlow({ buckets, dynamicBagPolicy }: InitStorageConfig) {
-  return async function initDistribution({ api }: FlowProps): Promise<void> {
+  return async function initStorage({ api }: FlowProps): Promise<void> {
     const debug = extendDebug('flow:initStorage')
     debug('Started')
 

+ 1 - 0
tests/network-tests/src/flows/workingGroup/leaderSetup.ts

@@ -8,6 +8,7 @@ import { assert } from 'chai'
 // import { KeyringPair } from '@polkadot/keyring/types'
 import { FixtureRunner } from '../../Fixture'
 import { extendDebug } from '../../Debugger'
+import { createJoystreamCli } from '../utils'
 
 export default function (group: WorkingGroups, canSkip = false) {
   return async function ({ api, env }: FlowProps): Promise<void> {

+ 35 - 0
tests/network-tests/src/graphql/generated/queries.ts

@@ -72,6 +72,20 @@ export type GetChannelByIdQueryVariables = Types.Exact<{
 
 export type GetChannelByIdQuery = { channelByUniqueInput?: Types.Maybe<ChannelFieldsFragment> }
 
+export type OwnedNftFieldsFragment = {
+  id: string
+  metadata: string
+  creatorRoyalty: number
+  video: { id: string }
+  ownerMember?: Types.Maybe<{ id: string }>
+}
+
+export type GetOwnedNftByVideoIdQueryVariables = Types.Exact<{
+  videoId: Types.Scalars['ID']
+}>
+
+export type GetOwnedNftByVideoIdQuery = { ownedNfts: Array<OwnedNftFieldsFragment> }
+
 export const DataObjectTypeFields = gql`
   fragment DataObjectTypeFields on DataObjectType {
     __typename
@@ -140,6 +154,19 @@ export const ChannelFields = gql`
   }
   ${StorageDataObjectFields}
 `
+export const OwnedNftFields = gql`
+  fragment OwnedNftFields on OwnedNft {
+    id
+    video {
+      id
+    }
+    ownerMember {
+      id
+    }
+    metadata
+    creatorRoyalty
+  }
+`
 export const GetDataObjectsByIds = gql`
   query getDataObjectsByIds($ids: [ID!]) {
     storageDataObjects(where: { id_in: $ids }) {
@@ -156,3 +183,11 @@ export const GetChannelById = gql`
   }
   ${ChannelFields}
 `
+export const GetOwnedNftByVideoId = gql`
+  query getOwnedNftByVideoId($videoId: ID!) {
+    ownedNfts(where: { video: { id_eq: $videoId } }) {
+      ...OwnedNftFields
+    }
+  }
+  ${OwnedNftFields}
+`

Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 586 - 315
tests/network-tests/src/graphql/generated/schema.ts


+ 19 - 0
tests/network-tests/src/graphql/queries/storagev2.graphql

@@ -72,3 +72,22 @@ query getChannelById($id: ID!) {
     ...ChannelFields
   }
 }
+
+fragment OwnedNftFields on OwnedNft {
+  id
+  video {
+    id
+  }
+  ownerMember {
+    id
+  }
+  metadata
+  # transactionalStatus TODO
+  creatorRoyalty
+}
+
+query getOwnedNftByVideoId($videoId: ID!) {
+  ownedNfts(where: { video: { id_eq: $videoId } }) {
+    ...OwnedNftFields
+  }
+}

+ 10 - 1
tests/network-tests/src/scenarios/combined.ts

@@ -10,7 +10,7 @@ import createChannel from '../flows/clis/createChannel'
 import { scenario } from '../Scenario'
 import { WorkingGroups } from '../WorkingGroups'
 
-scenario(async ({ job }) => {
+scenario('Combined', async ({ job }) => {
   // These tests assume:
   // - storage setup (including hired lead)
   // - existing council
@@ -46,4 +46,13 @@ scenario(async ({ job }) => {
   job('init storage and distribution buckets via CLI', [initDistributionBucket, initStorageBucket]).after(
     createChannelJob
   )
+
+  /* TODO: delete this alternative from pre-olympia-merge NFT branch if not useful
+  const initBucketsJob = job('init storage and distribution buckets via CLI', [
+    initDistributionBucket,
+    initStorageBucket,
+  ]).requires(leadSetupJob)
+
+  const createChannelJob = job('create channel via CLI', createChannel).requires(initBucketsJob)
+  */
 })

+ 0 - 7
tests/network-tests/src/scenarios/content-directory.ts

@@ -1,7 +0,0 @@
-import { WorkingGroups } from '../WorkingGroups'
-import leaderSetup from '../flows/workingGroup/leaderSetup'
-import { scenario } from '../Scenario'
-
-scenario(async ({ job }) => {
-  job('setup content lead', leaderSetup(WorkingGroups.Content))
-})

+ 1 - 1
tests/network-tests/src/scenarios/giza-issue-reproduction-setup.ts

@@ -6,7 +6,7 @@ import initStorage, { doubleBucketConfig as storageConfig } from '../flows/stora
 import { WorkingGroups } from '../WorkingGroups'
 import { scenario } from '../Scenario'
 
-scenario(async ({ job }) => {
+scenario('Giza issue reproduction setup', async ({ job }) => {
   job('Make Alice a member', makeAliceMember)
 
   const leads = job('Set Storage Lead', leaderSetup(WorkingGroups.Storage))

+ 1 - 1
tests/network-tests/src/scenarios/init-storage-and-distribution.ts

@@ -5,7 +5,7 @@ import { scenario } from '../Scenario'
 import { WorkingGroups } from '../WorkingGroups'
 import updateAccountsFlow from '../misc/updateAllWorkerRoleAccountsFlow'
 
-scenario(async ({ job }) => {
+scenario('Init storage and distribution', async ({ job }) => {
   const setupLead = job('setup leads', [
     leaderSetup(WorkingGroups.Distribution, true),
     leaderSetup(WorkingGroups.Storage, true),

+ 1 - 1
tests/network-tests/src/scenarios/post-migration.ts

@@ -1,6 +1,6 @@
 import postMigrationAssertions from '../misc/postMigrationAssertionsFlow'
 import { scenario } from '../Scenario'
 
-scenario(async ({ job }) => {
+scenario('Post migration', async ({ job }) => {
   job('Verify post-migration chain state', postMigrationAssertions)
 })

+ 1 - 1
tests/network-tests/src/scenarios/proposals.ts

@@ -8,7 +8,7 @@ import validatorCountProposal from '../flows/proposals/validatorCountProposal'
 import wgMintCapacityProposal from '../flows/proposals/workingGroupMintCapacityProposal'
 import { scenario } from '../Scenario'
 
-scenario(async ({ job }) => {
+scenario('Proposals', async ({ job }) => {
   job('creating members', creatingMemberships)
 
   const councilJob = job('council setup', councilSetup)

+ 1 - 1
tests/network-tests/src/scenarios/setup-new-chain.ts

@@ -7,7 +7,7 @@ import initDistribution, { singleBucketConfig as defaultDistributionConfig } fro
 import { AllWorkingGroups } from '../WorkingGroups'
 import { scenario } from '../Scenario'
 
-scenario(async ({ job }) => {
+scenario('Setup new chain', async ({ job }) => {
   const COUNCIL_SIZE = 1
   job('Create Council', assignCouncil(COUNCIL_SIZE))
 

+ 1 - 1
tests/network-tests/src/scenarios/tests/resource-locks-1.ts

@@ -6,6 +6,6 @@ async function flow1({ lock }: FlowProps) {
   await lock(Resource.Council)
 }
 
-scenario(async ({ job }) => {
+scenario('Resource locks 1', async ({ job }) => {
   job('test', [flow1, flow1])
 })

+ 1 - 1
tests/network-tests/src/scenarios/tests/resource-locks-2.ts

@@ -6,7 +6,7 @@ async function flow({ lock }: FlowProps) {
   await lock(Resource.Proposals)
 }
 
-scenario(async ({ job }) => {
+scenario('Resource locks 2', async ({ job }) => {
   // Runtime is configured for MaxActiveProposalLimit = 5
   // So we should ensure we don't exceed that number of active proposals
   // which limits the number of concurrent tests that create proposals

+ 21 - 0
types/augment/all/defs.json

@@ -816,5 +816,26 @@
         "non_channel_owner": "Option<MemberId>",
         "init_transactional_status": "InitTransactionalStatus"
     },
+    "AuctionRecord": {
+        "starting_price": "u128",
+        "buy_now_price": "u128",
+        "auction_type": "AuctionType",
+        "minimal_bid_step": "u128",
+        "last_bid": "Option<Bid>",
+        "starts_at": "Option<u32>",
+        "whitelist": "BTreeSet<MemberId>"
+    },
+    "NFTOwner": {
+        "_enum": {
+            "ChannelOwner": "Null",
+            "Member": "MemberId"
+        }
+    },
+    "OwnedNFT": {
+        "owner": "NFTOwner",
+        "transactional_status": "TransactionalStatus",
+        "creator_royalty": "Option<Royalty>"
+    },
+    "NftMetadata": "Vec<u8>",
     "AccountInfo": "AccountInfoWithRefCount"
 }

+ 84 - 56
types/augment/all/types.ts

@@ -87,6 +87,17 @@ export interface AuctionParams extends Struct {
   readonly whitelist: BTreeSet<MemberId>;
 }
 
+/** @name AuctionRecord */
+export interface AuctionRecord extends Struct {
+  readonly starting_price: u128;
+  readonly buy_now_price: u128;
+  readonly auction_type: AuctionType;
+  readonly minimal_bid_step: u128;
+  readonly last_bid: Option<Bid>;
+  readonly starts_at: Option<u32>;
+  readonly whitelist: BTreeSet<MemberId>;
+}
+
 /** @name AuctionType */
 export interface AuctionType extends Enum {
   readonly isEnglish: boolean;
@@ -204,62 +215,6 @@ export interface Category extends Struct {
 /** @name CategoryId */
 export interface CategoryId extends u64 {}
 
-/** @name Channel */
-export interface Channel extends Struct {
-  readonly owner: ChannelOwner;
-  readonly num_videos: u64;
-  readonly is_censored: bool;
-  readonly reward_account: Option<GenericAccountId>;
-  readonly collaborators: BTreeSet<MemberId>;
-  readonly moderators: BTreeSet<MemberId>;
-  readonly cumulative_payout_earned: u128;
-}
-
-/** @name ChannelCategory */
-export interface ChannelCategory extends Struct {}
-
-/** @name ChannelCategoryCreationParameters */
-export interface ChannelCategoryCreationParameters extends Struct {
-  readonly meta: Bytes;
-}
-
-/** @name ChannelCategoryId */
-export interface ChannelCategoryId extends u64 {}
-
-/** @name ChannelCategoryUpdateParameters */
-export interface ChannelCategoryUpdateParameters extends Struct {
-  readonly new_meta: Bytes;
-}
-
-/** @name ChannelCreationParameters */
-export interface ChannelCreationParameters extends Struct {
-  readonly assets: Option<StorageAssets>;
-  readonly meta: Option<Bytes>;
-  readonly reward_account: Option<GenericAccountId>;
-  readonly collaborators: BTreeSet<MemberId>;
-  readonly moderators: BTreeSet<MemberId>;
-}
-
-/** @name ChannelId */
-export interface ChannelId extends u64 {}
-
-/** @name ChannelOwner */
-export interface ChannelOwner extends Enum {
-  readonly isMember: boolean;
-  readonly asMember: MemberId;
-  readonly isCurators: boolean;
-  readonly asCurators: CuratorGroupId;
-}
-
-/** @name ChannelUpdateParameters */
-export interface ChannelUpdateParameters extends Struct {
-  readonly assets_to_upload: Option<StorageAssets>;
-  readonly new_meta: Option<Bytes>;
-  readonly reward_account: Option<Option<GenericAccountId>>;
-  readonly assets_to_remove: BTreeSet<DataObjectId>;
-  readonly collaborators: Option<BTreeSet<MemberId>>;
-}
-
 /** @name Cid */
 export interface Cid extends Bytes {}
 
@@ -528,6 +483,62 @@ export interface GeneralProposalParameters extends Struct {
   readonly exact_execution_block: Option<u32>;
 }
 
+/** @name Channel */
+export interface Channel extends Struct {
+  readonly owner: ChannelOwner;
+  readonly num_videos: u64;
+  readonly is_censored: bool;
+  readonly reward_account: Option<GenericAccountId>;
+  readonly collaborators: BTreeSet<MemberId>;
+  readonly moderators: BTreeSet<MemberId>;
+  readonly cumulative_payout_earned: u128;
+}
+
+/** @name ChannelCategory */
+export interface ChannelCategory extends Struct {}
+
+/** @name ChannelCategoryCreationParameters */
+export interface ChannelCategoryCreationParameters extends Struct {
+  readonly meta: Bytes;
+}
+
+/** @name ChannelCategoryId */
+export interface ChannelCategoryId extends u64 {}
+
+/** @name ChannelCategoryUpdateParameters */
+export interface ChannelCategoryUpdateParameters extends Struct {
+  readonly new_meta: Bytes;
+}
+
+/** @name ChannelCreationParameters */
+export interface ChannelCreationParameters extends Struct {
+  readonly assets: Option<StorageAssets>;
+  readonly meta: Option<Bytes>;
+  readonly reward_account: Option<GenericAccountId>;
+  readonly collaborators: BTreeSet<MemberId>;
+  readonly moderators: BTreeSet<MemberId>;
+}
+
+/** @name ChannelId */
+export interface ChannelId extends u64 {}
+
+/** @name ChannelOwner */
+export interface ChannelOwner extends Enum {
+  readonly isMember: boolean;
+  readonly asMember: MemberId;
+  readonly isCurators: boolean;
+  readonly asCurators: CuratorGroupId;
+}
+
+/** @name ChannelUpdateParameters */
+export interface ChannelUpdateParameters extends Struct {
+  readonly assets_to_upload: Option<StorageAssets>;
+  readonly new_meta: Option<Bytes>;
+  readonly reward_account: Option<Option<GenericAccountId>>;
+  readonly assets_to_remove: BTreeSet<DataObjectId>;
+  readonly collaborators: Option<BTreeSet<MemberId>>;
+}
+
 /** @name InitTransactionalStatus */
 export interface InitTransactionalStatus extends Enum {
   readonly isIdle: boolean;
@@ -590,6 +601,9 @@ export interface NftIssuanceParameters extends Struct {
   readonly init_transactional_status: InitTransactionalStatus;
 }
 
+/** @name NftMetadata */
+export interface NftMetadata extends Bytes {}
+
 /** @name NftOwner */
 export interface NftOwner extends Enum {
   readonly isChannelOwner: boolean;
@@ -597,6 +611,13 @@ export interface NftOwner extends Enum {
   readonly asMember: MemberId;
 }
 
+/** @name NFTOwner */
+export interface NFTOwner extends Enum {
+  readonly isChannelOwner: boolean;
+  readonly isMember: boolean;
+  readonly asMember: MemberId;
+}
+
 /** @name OpenAuctionDetails */
 export interface OpenAuctionDetails extends Struct {
   readonly bid_lock_duration: u32;
@@ -649,6 +670,13 @@ export interface OwnedNft extends Struct {
   readonly creator_royalty: Option<Royalty>;
 }
 
+/** @name OwnedNFT */
+export interface OwnedNFT extends Struct {
+  readonly owner: NFTOwner;
+  readonly transactional_status: TransactionalStatus;
+  readonly creator_royalty: Option<Royalty>;
+}
+
 /** @name ParticipantId */
 export interface ParticipantId extends u64 {}
 

+ 28 - 1
types/src/content/index.ts

@@ -1,5 +1,5 @@
 import { Vec, Option, Tuple, BTreeSet, UInt } from '@polkadot/types'
-import { bool, u64, u32, Null, Bytes } from '@polkadot/types/primitive'
+import { bool, u8, u32, u64, u128, Null, Bytes } from '@polkadot/types/primitive'
 import { JoyStructDecorated, JoyEnum, ChannelId, MemberId, Balance, Hash, BlockNumber, BalanceOf } from '../common'
 
 import { GenericAccountId as AccountId } from '@polkadot/types/generic/AccountId'
@@ -236,6 +236,29 @@ export class PullPayment extends JoyStructDecorated({
 
 export class ModeratorSet extends BTreeSet.with(MemberId) {}
 
+export class NftMetadata extends Vec.with(u8) {}
+
+export class AuctionRecord extends JoyStructDecorated({
+  starting_price: u128, // Balance
+  buy_now_price: u128, // Balance
+  auction_type: AuctionType,
+  minimal_bid_step: u128, // Balance
+  last_bid: Option.with(Bid),
+  starts_at: Option.with(u32), // Option<BlockNumber>
+  whitelist: BTreeSet.with(MemberId),
+}) {}
+
+export class NFTOwner extends JoyEnum({
+  ChannelOwner: Null,
+  Member: MemberId,
+}) {}
+
+export class OwnedNFT extends JoyStructDecorated({
+  owner: NFTOwner,
+  transactional_status: TransactionalStatus,
+  creator_royalty: Option.with(Royalty),
+}) {}
+
 export const contentTypes = {
   CuratorId,
   CuratorGroupId,
@@ -287,6 +310,10 @@ export const contentTypes = {
   CurrencyAmount,
   InitTransactionalStatus,
   NftIssuanceParameters,
+  AuctionRecord,
+  NFTOwner,
+  OwnedNFT,
+  NftMetadata,
 }
 
 export default contentTypes

+ 42 - 12
yarn.lock

@@ -1072,6 +1072,13 @@
   dependencies:
     regenerator-runtime "^0.13.4"
 
+"@babel/runtime@^7.14.6":
+  version "7.16.5"
+  resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.16.5.tgz#7f3e34bf8bdbbadf03fbb7b1ea0d929569c9487a"
+  integrity sha512-TXWihFIS3Pyv5hzR7j6ihmeLkZfrXGxAr5UfSl8CHf+6q/wpiYDkUau0czckpYG8QmnCIuPpdLtuA9VmuGGyMA==
+  dependencies:
+    regenerator-runtime "^0.13.4"
+
 "@babel/runtime@^7.15.3", "@babel/runtime@^7.15.4":
   version "7.15.4"
   resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.15.4.tgz#fd17d16bfdf878e6dd02d19753a39fa8a8d9c84a"
@@ -2420,6 +2427,17 @@
     "@polkadot/util" "7.3.1"
     "@polkadot/util-crypto" "7.3.1"
 
+"@polkadot/metadata@^4.17.1":
+  version "4.17.1"
+  resolved "https://registry.yarnpkg.com/@polkadot/metadata/-/metadata-4.17.1.tgz#4da9ee5b2b816493910abfd302a50b58141ceca2"
+  integrity sha512-219isiCWVfbu5JxZnOPj+cV4T+S0XHS4+Jal3t3xz9y4nbgr+25Pa4KInEsJPx0u8EZAxMeiUCX3vd5U7oe72g==
+  dependencies:
+    "@babel/runtime" "^7.14.6"
+    "@polkadot/types" "4.17.1"
+    "@polkadot/types-known" "4.17.1"
+    "@polkadot/util" "^6.11.1"
+    "@polkadot/util-crypto" "^6.11.1"
+
 "@polkadot/networks@7.3.1", "@polkadot/networks@^7.3.1":
   version "7.3.1"
   resolved "https://registry.yarnpkg.com/@polkadot/networks/-/networks-7.3.1.tgz#4d4f7269ff9c285363946175ca95d6aaa08bdacc"
@@ -2483,7 +2501,7 @@
     websocket "^1.0.34"
     yargs "^17.1.1"
 
-"@polkadot/types-known@5.9.1":
+"@polkadot/types-known@4.17.1", "@polkadot/types-known@5.9.1":
   version "5.9.1"
   resolved "https://registry.yarnpkg.com/@polkadot/types-known/-/types-known-5.9.1.tgz#e52fc7b803bc7cb3f41028f88963deb4ccee40af"
   integrity sha512-7lpLuIVGaKziQRzPMnTxyjlYy3spL6WqUg3CcEzmJUKQeUonHglOliQh8JSSz1bcP+YuNHGXK1cKsTjHb+GYxA==
@@ -2501,7 +2519,7 @@
     "@babel/runtime" "^7.15.4"
     "@polkadot/util" "^7.3.1"
 
-"@polkadot/types@5.9.1":
+"@polkadot/types@4.17.1", "@polkadot/types@5.9.1":
   version "5.9.1"
   resolved "https://registry.yarnpkg.com/@polkadot/types/-/types-5.9.1.tgz#74cf4695795f2aa365ff85d3873e22c430100bc9"
   integrity sha512-30vcSlNBxPyWYZaxKDr/BoMhfLCRKB265XxpnnNJmbdZZsL+N4Zp2mJR9/UbA6ypmJBkUjD7b1s9AYsLwUs+8w==
@@ -2511,7 +2529,7 @@
     "@polkadot/util-crypto" "^7.3.1"
     rxjs "^7.3.0"
 
-"@polkadot/util-crypto@7.3.1", "@polkadot/util-crypto@^7.3.1":
+"@polkadot/util-crypto@7.3.1", "@polkadot/util-crypto@^6.11.1", "@polkadot/util-crypto@^7.3.1":
   version "7.3.1"
   resolved "https://registry.yarnpkg.com/@polkadot/util-crypto/-/util-crypto-7.3.1.tgz#a597145b061eddaafd69adc6c1ce19224542307f"
   integrity sha512-sR+BxlV2Da0xfQcCXQTz+ohTaagixM+qYHytaQzilytbKHgYIyvnOyk5wFrHDNFzcLuXo15AbULa3TCoNDvh5Q==
@@ -2534,7 +2552,7 @@
     tweetnacl "^1.0.3"
     xxhashjs "^0.2.2"
 
-"@polkadot/util@7.3.1", "@polkadot/util@^7.3.1":
+"@polkadot/util@7.3.1", "@polkadot/util@^6.11.1", "@polkadot/util@^7.3.1":
   version "7.3.1"
   resolved "https://registry.yarnpkg.com/@polkadot/util/-/util-7.3.1.tgz#1a33a8d4ef2dcbc3e14a9a919f30bb16360a6fae"
   integrity sha512-fjz5yjgZgfgRXZw9zMufmPBHhjAVtk/M2+lgl1a6Fck43Q4TG2Ux1haXMlaoe37cFeh8XgDAzDEQVIYBIPy6sg==
@@ -2860,10 +2878,10 @@
   resolved "https://registry.yarnpkg.com/@types/argparse/-/argparse-1.0.38.tgz#a81fd8606d481f873a3800c6ebae4f1d768a56a9"
   integrity sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA==
 
-"@types/async-lock@^1.1.2":
-  version "1.1.2"
-  resolved "https://registry.yarnpkg.com/@types/async-lock/-/async-lock-1.1.2.tgz#cbc26a34b11b83b28f7783a843c393b443ef8bef"
-  integrity sha512-j9n4bb6RhgFIydBe0+kpjnBPYumDaDyU8zvbWykyVMkku+c2CSu31MZkLeaBfqIwU+XCxlDpYDfyMQRkM0AkeQ==
+"@types/async-lock@^1.1.3":
+  version "1.1.3"
+  resolved "https://registry.yarnpkg.com/@types/async-lock/-/async-lock-1.1.3.tgz#0d86017cf87abbcb941c55360e533d37a3f23b3d"
+  integrity sha512-UpeDcjGKsYEQMeqEbfESm8OWJI305I7b9KE4ji3aBjoKWyN5CTdn8izcA1FM1DVDne30R5fNEnIy89vZw5LXJQ==
 
 "@types/babel__core@^7.1.0":
   version "7.1.18"
@@ -2910,6 +2928,13 @@
   resolved "https://registry.yarnpkg.com/@types/bluebird/-/bluebird-3.5.36.tgz#00d9301d4dc35c2f6465a8aec634bb533674c652"
   integrity sha512-HBNx4lhkxN7bx6P0++W8E289foSu8kO8GCk2unhuVggO+cE7rh9DhZUyPhUxNRG9m+5B5BTKxZQ5ZP92x/mx9Q==
 
+"@types/bmp-js@^0.1.0":
+  version "0.1.0"
+  resolved "https://registry.yarnpkg.com/@types/bmp-js/-/bmp-js-0.1.0.tgz#301afe2bb3ac7ef0f18465966e4166f0491b3332"
+  integrity sha512-uMU85ROcmlY1f4mVPTlNodRXa6Z5f0AIxvv5b0pvjty3KNg7ljf5lNSspHgaF6iFDCiGpLQmJna+VwEpUC9TyA==
+  dependencies:
+    "@types/node" "*"
+
 "@types/bn.js@^4.11.5", "@types/bn.js@^4.11.6":
   version "4.11.6"
   resolved "https://registry.yarnpkg.com/@types/bn.js/-/bn.js-4.11.6.tgz#c306c70d9358aaea33cd4eda092a742b9505967c"
@@ -4617,10 +4642,10 @@ async-limiter@~1.0.0:
   resolved "https://registry.yarnpkg.com/async-limiter/-/async-limiter-1.0.1.tgz#dd379e94f0db8310b08291f9d64c3209766617fd"
   integrity "sha1-3TeelPDbgxCwgpH51kwyCXZmF/0= sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ=="
 
-async-lock@^1.2.0:
-  version "1.3.0"
-  resolved "https://registry.yarnpkg.com/async-lock/-/async-lock-1.3.0.tgz#0fba111bea8b9693020857eba4f9adca173df3e5"
-  integrity sha512-8A7SkiisnEgME2zEedtDYPxUPzdv3x//E7n5IFktPAtMYSEAV7eNJF0rMwrVyUFj6d/8rgajLantbjcNRQYXIg==
+async-lock@^1.3.1:
+  version "1.3.1"
+  resolved "https://registry.yarnpkg.com/async-lock/-/async-lock-1.3.1.tgz#f2301c200600cde97acc386453b7126fa8aced3c"
+  integrity sha512-zK7xap9UnttfbE23JmcrNIyueAn6jWshihJqA33U/hEnKprF/lVGBDsBv/bqLm2YMMl1DnpHhUY044eA0t1TUw==
 
 async-retry@^1.2.1:
   version "1.3.1"
@@ -5058,6 +5083,11 @@ bluebird@~3.4.1:
   resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.4.7.tgz#f72d760be09b7f76d08ed8fae98b289a8d05fab3"
   integrity sha1-9y12C+Cbf3bQjtj66Ysomo0F+rM=
 
+bmp-js@^0.1.0:
+  version "0.1.0"
+  resolved "https://registry.yarnpkg.com/bmp-js/-/bmp-js-0.1.0.tgz#e05a63f796a6c1ff25f4771ec7adadc148c07233"
+  integrity sha1-4Fpj95amwf8l9Hcex62twUjAcjM=
+
 bn.js@4.12.0, bn.js@^4.11.8, bn.js@^4.11.9, bn.js@^4.12.0, bn.js@^5.1.2, bn.js@^5.1.3, bn.js@^5.2.0:
   version "4.12.0"
   resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.12.0.tgz#775b3f278efbb9718eec7361f483fb36fbbfea88"

Niektoré súbory nie sú zobrazené, pretože je v týchto rozdielových dátach zmenené mnoho súborov