Browse Source

Merge branch 'query_node_active_video_counters_giza_staging' into vnft_schema_mappings_second

ondratra 3 years ago
parent
commit
229233cc4f
34 changed files with 1403 additions and 59 deletions
  1. 28 10
      cli/src/base/AccountsCommandBase.ts
  2. 0 3
      cli/src/base/ApiCommandBase.ts
  3. 2 2
      cli/src/base/ContentDirectoryCommandBase.ts
  4. 1 0
      cli/src/base/StateAwareCommandBase.ts
  5. 40 0
      cli/src/commands/account/chooseMember.ts
  6. 1 1
      cli/src/commands/content/createChannel.ts
  7. 1 1
      cli/src/commands/content/createVideo.ts
  8. 1 1
      cli/src/commands/content/updateChannel.ts
  9. 1 1
      cli/src/commands/content/updateVideo.ts
  10. 29 4
      query-node/mappings/content/channel.ts
  11. 149 1
      query-node/mappings/content/utils.ts
  12. 49 6
      query-node/mappings/content/video.ts
  13. 80 5
      query-node/mappings/storage/index.ts
  14. 3 7
      query-node/mappings/storage/utils.ts
  15. 29 0
      query-node/run-tests.sh
  16. 9 0
      query-node/schemas/content.graphql
  17. 6 0
      query-node/schemas/storage.graphql
  18. 3 0
      tests/network-tests/.gitignore
  19. 88 3
      tests/network-tests/src/Api.ts
  20. 244 0
      tests/network-tests/src/CliApi.ts
  21. 13 0
      tests/network-tests/src/Fixture.ts
  22. 8 1
      tests/network-tests/src/Flow.ts
  23. 8 1
      tests/network-tests/src/Job.ts
  24. 15 1
      tests/network-tests/src/JobManager.ts
  25. 41 4
      tests/network-tests/src/QueryNodeApi.ts
  26. 6 3
      tests/network-tests/src/Scenario.ts
  27. 2 0
      tests/network-tests/src/consts.ts
  28. 378 0
      tests/network-tests/src/fixtures/content/activeVideoCounters.ts
  29. 77 0
      tests/network-tests/src/fixtures/content/addWorkerToGroup.ts
  30. 50 0
      tests/network-tests/src/fixtures/content/contentTemplates.ts
  31. 1 0
      tests/network-tests/src/fixtures/content/index.ts
  32. 19 0
      tests/network-tests/src/flows/content/activeVideoCounters.ts
  33. 13 2
      tests/network-tests/src/flows/workingGroup/leaderSetup.ts
  34. 8 2
      tests/network-tests/src/scenarios/content-directory.ts

+ 28 - 10
cli/src/base/AccountsCommandBase.ts

@@ -25,6 +25,8 @@ export const KEYRING_OPTIONS: KeyringOptions = {
   type: DEFAULT_ACCOUNT_TYPE,
 }
 
+export type ISelectedMember = [MemberId, Membership]
+
 /**
  * Abstract base class for account-related commands.
  *
@@ -33,7 +35,7 @@ export const KEYRING_OPTIONS: KeyringOptions = {
  * Where: APP_DATA_PATH is provided by StateAwareCommandBase and ACCOUNTS_DIRNAME is a const (see above).
  */
 export default abstract class AccountsCommandBase extends ApiCommandBase {
-  private selectedMember: [MemberId, Membership] | undefined
+  private selectedMember: ISelectedMember | undefined
   private _keyring: KeyringInstance | undefined
 
   private get keyring(): KeyringInstance {
@@ -319,7 +321,13 @@ export default abstract class AccountsCommandBase extends ApiCommandBase {
     }
   }
 
-  async getRequiredMemberContext(useSelected = false, allowedIds?: MemberId[]): Promise<[MemberId, Membership]> {
+  async setSelectedMember(selectedMember: ISelectedMember): Promise<void> {
+    this.selectedMember = selectedMember
+
+    await this.setPreservedState({ selectedMemberId: selectedMember[0].toString() })
+  }
+
+  async getRequiredMemberContext(allowedIds?: MemberId[], useSelected = true): Promise<ISelectedMember> {
     if (
       useSelected &&
       this.selectedMember &&
@@ -328,12 +336,7 @@ export default abstract class AccountsCommandBase extends ApiCommandBase {
       return this.selectedMember
     }
 
-    const membersEntries = allowedIds
-      ? await this.getApi().memberEntriesByIds(allowedIds)
-      : await this.getApi().allMemberEntries()
-    const availableMemberships = await Promise.all(
-      membersEntries.filter(([, m]) => this.isKeyAvailable(m.controller_account.toString()))
-    )
+    const availableMemberships = await this.getKnownMembers(allowedIds)
 
     if (!availableMemberships.length) {
       this.error(
@@ -352,10 +355,21 @@ export default abstract class AccountsCommandBase extends ApiCommandBase {
     return this.selectedMember
   }
 
+  async getKnownMembers(allowedIds?: MemberId[]): Promise<ISelectedMember[]> {
+    const membersEntries = allowedIds
+      ? await this.getApi().memberEntriesByIds(allowedIds)
+      : await this.getApi().allMemberEntries()
+    const availableMemberships = await Promise.all(
+      membersEntries.filter(([, m]) => this.isKeyAvailable(m.controller_account.toString()))
+    )
+
+    return availableMemberships
+  }
+
   async promptForMember(
-    availableMemberships: [MemberId, Membership][],
+    availableMemberships: ISelectedMember[],
     message = 'Choose a member'
-  ): Promise<[MemberId, Membership]> {
+  ): Promise<ISelectedMember> {
     const memberIndex = await this.simplePrompt({
       type: 'list',
       message,
@@ -376,5 +390,9 @@ export default abstract class AccountsCommandBase extends ApiCommandBase {
       throw this.createDataDirInitError()
     }
     await this.initKeyring()
+
+    const availableMemberships = await this.getKnownMembers()
+    const memberId = this.getPreservedState().selectedMemberId
+    this.selectedMember = availableMemberships.find((item) => item[0].toString() === memberId) || undefined
   }
 }

+ 0 - 3
cli/src/base/ApiCommandBase.ts

@@ -89,9 +89,6 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
       if (this.requiresQueryNode && !queryNodeUri) {
         this.warn('Query node endpoint uri is required in order to run this command!')
         queryNodeUri = await this.promptForQueryNodeUri(true)
-      } else if (queryNodeUri === undefined) {
-        this.warn("You haven't provided a Joystream query node uri for the CLI to connect to yet!")
-        queryNodeUri = await this.promptForQueryNodeUri()
       }
 
       const { metadataCache } = this.getPreservedState()

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

@@ -80,7 +80,7 @@ export default abstract class ContentDirectoryCommandBase extends RolesCommandBa
         return this.getCuratorContext(channel.owner.asType('Curators'))
       }
     } else {
-      const [id, membership] = await this.getRequiredMemberContext(false, [channel.owner.asType('Member')])
+      const [id, membership] = await this.getRequiredMemberContext([channel.owner.asType('Member')])
       return [
         createType<ContentActor, 'ContentActor'>('ContentActor', { Member: id }),
         membership.controller_account.toString(),
@@ -89,7 +89,7 @@ export default abstract class ContentDirectoryCommandBase extends RolesCommandBa
   }
 
   async getChannelCollaboratorActor(channel: Channel): Promise<[ContentActor, string]> {
-    const [id, membership] = await this.getRequiredMemberContext(false, Array.from(channel.collaborators))
+    const [id, membership] = await this.getRequiredMemberContext(Array.from(channel.collaborators))
     return [
       createType<ContentActor, 'ContentActor'>('ContentActor', { Collaborator: id }),
       membership.controller_account.toString(),

+ 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

+ 40 - 0
cli/src/commands/account/chooseMember.ts

@@ -0,0 +1,40 @@
+import AccountsCommandBase, { ISelectedMember } from '../../base/AccountsCommandBase'
+import chalk from 'chalk'
+import { flags } from '@oclif/command'
+import ExitCodes from '../../ExitCodes'
+
+export default class AccountChoose extends AccountsCommandBase {
+  static description = 'Choose default account to use in the CLI'
+  static flags = {
+    address: flags.string({
+      description: 'Select account by address (if available)',
+      char: 'a',
+      required: false,
+    }),
+  }
+
+  async run() {
+    const { address } = this.parse(AccountChoose).flags
+
+    const memberData = address
+      ? await this.selectKnownMember(address)
+      : await this.getRequiredMemberContext(undefined, false)
+
+    await this.setSelectedMember(memberData)
+
+    this.log(chalk.greenBright(`\nAccount switched to ${chalk.magentaBright(address)} (MemberId: ${memberData[0]})!`))
+  }
+
+  async selectKnownMember(address: string): Promise<ISelectedMember> {
+    const knownMembersData = await this.getKnownMembers()
+    const memberData = knownMembersData.find(([, member]) => member.controller_account.toString() === address)
+
+    if (!memberData) {
+      this.error(`Selected account address not found among known members!`, {
+        exit: ExitCodes.AccessDenied,
+      })
+    }
+
+    return memberData
+  }
+}

+ 1 - 1
cli/src/commands/content/createChannel.ts

@@ -29,7 +29,7 @@ export default class CreateChannelCommand extends UploadCommandBase {
       context = await this.promptForChannelCreationContext()
     }
     const [actor, address] = await this.getContentActor(context)
-    const [memberId] = await this.getRequiredMemberContext(true)
+    const [memberId] = await this.getRequiredMemberContext()
     const keypair = await this.getDecodedPair(address)
 
     const channelInput = await getInputJson<ChannelInputParameters>(input, ChannelInputSchema)

+ 1 - 1
cli/src/commands/content/createVideo.ts

@@ -46,7 +46,7 @@ export default class CreateVideoCommand extends UploadCommandBase {
     // Get context
     const channel = await this.getApi().channelById(channelId)
     const [actor, address] = await this.getChannelManagementActor(channel, context)
-    const [memberId] = await this.getRequiredMemberContext(true)
+    const [memberId] = await this.getRequiredMemberContext()
     const keypair = await this.getDecodedPair(address)
 
     // Get input from file

+ 1 - 1
cli/src/commands/content/updateChannel.ts

@@ -82,7 +82,7 @@ export default class UpdateChannelCommand extends UploadCommandBase {
     // Context
     const channel = await this.getApi().channelById(channelId)
     const [actor, address] = await this.getChannelManagementActor(channel, context)
-    const [memberId] = await this.getRequiredMemberContext(true)
+    const [memberId] = await this.getRequiredMemberContext()
     const keypair = await this.getDecodedPair(address)
 
     const channelInput = await getInputJson<ChannelInputParameters>(input, ChannelInputSchema)

+ 1 - 1
cli/src/commands/content/updateVideo.ts

@@ -69,7 +69,7 @@ export default class UpdateVideoCommand extends UploadCommandBase {
     const video = await this.getApi().videoById(videoId)
     const channel = await this.getApi().channelById(video.in_channel.toNumber())
     const [actor, address] = await this.getChannelManagementActor(channel, context)
-    const [memberId] = await this.getRequiredMemberContext(true)
+    const [memberId] = await this.getRequiredMemberContext()
     const keypair = await this.getDecodedPair(address)
 
     const videoInput = await getInputJson<VideoInputParameters>(input, VideoInputSchema)

+ 29 - 4
query-node/mappings/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),

+ 149 - 1
query-node/mappings/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[],
@@ -492,9 +497,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) {
@@ -518,10 +526,150 @@ export async function unsetAssetRelations(store: DatabaseManager, dataObject: St
     })
     await store.save<Video>(video)
 
+    // update video active counters
+    await updateVideoActiveCounters(store, wasFullyActive as IVideoActiveStatus, 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 productionEnv = () => {
+    const isFullyActive =
+      !!video.isPublic && !video.isCensored && !!video.thumbnailPhoto?.isAccepted && !!video.media?.isAccepted
+
+    return isFullyActive
+  }
+  const testEnv = () => {
+    const isFullyActive = !!video.isPublic && !video.isCensored
+
+    return isFullyActive
+  }
+
+  const isFullyActive = process.env.QN_TEST_ENV ? testEnv() : productionEnv()
+
+  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> {
+  // definition of generic type for Hydra DatabaseManager's methods
+  type EntityType<T> = {
+    new (...args: any[]): T
+  }
+
+  async function updateSingleEntity<Entity extends VideoCategory | Channel>(
+    entity: Entity,
+    counterChange: number
+  ): Promise<void> {
+    entity.activeVideosCounter += counterChange
+
+    await store.save<EntityType<Entity>>(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) {
+      // if video was fully active before, prepare to decrease counter; increase counter otherwise
+      const counterChange = initFullyActive ? -1 : 1
+
+      await updateSingleEntity(oldEntity, counterChange)
+    }
+
+    if (newEntity) {
+      // if video is fully active now, prepare to increase counter; decrease counter otherwise
+      const counterChange = nowFullyActive ? 1 : -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
+) {
+  // 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)
+  }
 }

+ 49 - 6
query-node/mappings/content/video.ts

@@ -5,8 +5,14 @@ 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 { Channel, Video, VideoCategory } from 'query-node/dist/model'
+import {
+  processVideoMetadata,
+  getVideoActiveStatus,
+  updateVideoActiveCounters,
+  videoRelationsForCountersBare,
+  videoRelationsForCounters,
+} from './utils'
+import { Channel, ChannelCategory, Video, VideoCategory } from 'query-node/dist/model'
 import { VideoMetadata, VideoCategoryMetadata } from '@joystream/metadata-protobuf'
 import { integrateMeta } from '@joystream/metadata-protobuf/utils'
 import _ from 'lodash'
@@ -24,6 +30,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 +102,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 +130,12 @@ export async function content_VideoCreated(ctx: EventContext & StoreContext): Pr
   // save video
   await store.save<Video>(video)
 
+  // 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 +148,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 +156,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 +174,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 +186,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 +219,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 +241,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 })
 }

+ 80 - 5
query-node/mappings/storage/index.ts

@@ -18,9 +18,17 @@ import {
   StorageDataObject,
   StorageSystemParameters,
   GeoCoordinates,
+  Video,
 } from 'query-node/dist/model'
 import BN from 'bn.js'
 import { getById, inconsistentState } from '../common'
+import {
+  getVideoActiveStatus,
+  updateVideoActiveCounters,
+  IVideoActiveStatus,
+  videoRelationsForCounters,
+  unsetAssetRelations,
+} from '../content/utils'
 import {
   processDistributionBucketFamilyMetadata,
   processDistributionOperatorMetadata,
@@ -29,7 +37,6 @@ import {
 import {
   createDataObjects,
   getStorageSystem,
-  removeDataObject,
   getStorageBucketWithOperatorMetadata,
   getBag,
   getDynamicBagId,
@@ -42,6 +49,7 @@ import {
   distributionOperatorId,
   distributionBucketIdByFamilyAndIndex,
 } from './utils'
+import { In } from 'typeorm'
 
 // STORAGE BUCKETS
 
@@ -227,13 +235,56 @@ 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) => {
+    dataObjects.map(async (dataObject, index) => {
       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
+  await initialActiveStates.reduce(async (accPromise, initialActiveState) => {
+    await accPromise
+
+    // 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))
+  }, Promise.resolve())
 }
 
 export async function storage_DataObjectsMoved({ event, store }: EventContext & StoreContext): Promise<void> {
@@ -250,8 +301,32 @@ 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 initialVideoActiveStatusThumbnail = dataObject.videoThumbnail
+        ? getVideoActiveStatus(dataObject.videoThumbnail)
+        : null
+      const initialVideoActiveStatusMedia = dataObject.videoMedia ? getVideoActiveStatus(dataObject.videoMedia) : null
+
+      await unsetAssetRelations(store, dataObject)
+
+      // update video active counters
+      if (initialVideoActiveStatusThumbnail) {
+        await updateVideoActiveCounters(store, initialVideoActiveStatusThumbnail, undefined)
+      }
+      if (initialVideoActiveStatusMedia) {
+        await updateVideoActiveCounters(store, initialVideoActiveStatusMedia, undefined)
+      }
+    })
+  )
 }
 
 // DISTRIBUTION FAMILY

+ 3 - 7
query-node/mappings/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)

+ 29 - 0
query-node/run-tests.sh

@@ -0,0 +1,29 @@
+#!/usr/bin/env bash
+set -e
+
+SCRIPT_PATH="$(dirname "${BASH_SOURCE[0]}")"
+cd $SCRIPT_PATH
+
+set -a
+. ../.env
+set +a
+
+function cleanup() {
+    # Show tail end of logs for the processor and indexer containers to
+    # see any possible errors
+    (echo "## Processor Logs ##" && docker logs joystream_processor_1 --tail 50) || :
+    (echo "## Indexer Logs ##" && docker logs joystream_indexer_1 --tail 50) || :
+    (echo "## Indexer API Gateway Logs ##" && docker logs joystream_hydra-indexer-gateway_1 --tail 50) || :
+    (echo "## Graphql Server Logs ##" && docker logs joystream_graphql-server_1 --tail 50) || :
+    docker-compose down -v
+}
+
+trap cleanup EXIT
+
+# start node
+docker-compose up -d joystream-node
+
+./start.sh
+
+# run content directory tests
+yarn workspace network-tests run-test-scenario content-directory

+ 9 - 0
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
 
@@ -75,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!

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

@@ -213,6 +213,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 {

+ 3 - 0
tests/network-tests/.gitignore

@@ -1,2 +1,5 @@
 output.json
 data/
+src/__CliApi_tempfile.json
+src/__CliApi_tempfile__rejectedContent.json
+src/__CliApi_appdata

+ 88 - 3
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'
@@ -37,6 +39,7 @@ import { ChannelCategoryMetadata, VideoCategoryMetadata } from '@joystream/metad
 import { metadataToBytes } from '../../../cli/lib/helpers/serialization'
 import { assert } from 'chai'
 import { WorkingGroups } from './WorkingGroups'
+import { v4 as uuid } from 'uuid'
 
 const workingGroupNameByGroup: { [key in WorkingGroups]: string } = {
   'distributionWorkingGroup': 'Distribution',
@@ -130,7 +133,7 @@ export class ApiFactory {
     const keys: { key: KeyringPair; id: number }[] = []
     for (let i = 0; i < n; i++) {
       const id = this.keyId++
-      const key = this.createKeyPair(`${id}`)
+      const key = this.createCustomKeyPair(`${id}`)
       keys.push({ key, id })
       this.addressesToKeyId.set(key.address, id)
     }
@@ -141,7 +144,7 @@ export class ApiFactory {
     if (isCustom) {
       this.customKeys.push(suriPath)
     }
-    const uri = `${this.miniSecret}//testing//${suriPath}`
+    const uri = `${this.miniSecret}//testing//${suriPath}/${uuid().substring(0, 8)}`
     const pair = this.keyring.addFromUri(uri)
     this.addressesToSuri.set(pair.address, uri)
     return pair
@@ -942,6 +945,14 @@ export class Api {
     return (events.sort((a, b) => new BN(a.index).cmp(new BN(b.index))) as unknown) as EventType<S, M>[]
   }
 
+  public findStorageBucketCreated(events: EventRecord[]): DataObjectId | undefined {
+    const record = this.findEvent(events, 'storage', 'StorageBucketCreated')
+
+    if (record) {
+      return (record.data[0] as unknown) as DataObjectId
+    }
+  }
+
   // Subscribe to system events, resolves to an InvertedPromise or rejects if subscription fails.
   // The inverted promise wraps a promise which resolves when the Proposal with id specified
   // is executed.
@@ -1932,4 +1943,78 @@ 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[]
+  ) {
+    const bagId = { Dynamic: { Channel: channelId } }
+    const encodedStorageBucketIds = new BTreeSet<StorageBucketId>(
+      this.api.registry,
+      'StorageBucketId',
+      addStorageBuckets.map((item) => item.toString())
+    )
+    const noBucketsToRemove = new BTreeSet<StorageBucketId>(this.api.registry, 'StorageBucketId', [])
+
+    return this.sender.signAndSend(
+      this.api.tx.storage.updateStorageBucketsForBag(bagId, encodedStorageBucketIds, noBucketsToRemove),
+      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
+    )
+  }
 }

+ 244 - 0
tests/network-tests/src/CliApi.ts

@@ -0,0 +1,244 @@
+import * as path from 'path'
+import { spawnSync } from 'child_process'
+import * as fs from 'fs'
+import { KeyringPair } from '@polkadot/keyring/types'
+import { v4 as uuid } from 'uuid'
+
+export interface ICreatedVideoData {
+  videoId: number
+  assetContentIds: string[]
+}
+
+/**
+  Adapter for calling CLI commands from integration tests.
+*/
+export class CliApi {
+  private tmpFilePath: string // filepath for temporary file needed to transfer data from and to cli
+  readonly cliExamplesFolderPath: string
+
+  public constructor() {
+    this.tmpFilePath = path.join(__dirname, '/__CliApi_tempfile.json')
+    this.cliExamplesFolderPath = path.dirname(require.resolve('@joystream/cli/package.json')) + '/examples/content'
+  }
+
+  /**
+    Runs CLI command with specified arguments.
+  */
+  private runCommand(
+    parameters: string[],
+    env: Record<string, string> = {}
+  ): { error: boolean; stdout: string; stderr: string } {
+    // use sync spawn if that works without issues
+    const output = spawnSync('yarn', ['joystream-cli', ...parameters], {
+      env: {
+        ...env,
+        PATH: process.env.PATH,
+        APPDATA: path.join(__dirname, '/__CliApi_appdata/'),
+      },
+    })
+
+    return {
+      error: !!output.error,
+      stdout: (output.stdout || '').toString(),
+      stderr: (output.stderr || '').toString(),
+    }
+  }
+
+  /**
+    Saves data to temporary file that can be passed to CLI as data input.
+  */
+  private saveToTempFile(content: string) {
+    try {
+      fs.writeFileSync(this.tmpFilePath, content + '\n')
+    } catch (e) {
+      throw new Error(`Can't write to temporary file "${this.tmpFilePath}"`)
+    }
+  }
+
+  /**
+    Parses `id` of newly created content entity from CLI's stdout.
+  */
+  private parseCreatedIdFromStdout(stdout: string): number {
+    return parseInt((stdout.match(/with id (\d+) successfully created/) as RegExpMatchArray)[1])
+  }
+
+  /**
+    Checks if CLI's stderr contains warning about no storage provider available.
+  */
+  private containsWarningNoStorage(text: string): boolean {
+    return !!text.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!/)
+  }
+
+  /**
+    Setups API URI for the CLI.
+  */
+  async setApiUri(uri = 'ws://localhost:9944') {
+    const { stderr } = this.runCommand(['api:setUri', uri])
+
+    if (stderr) {
+      throw new Error(`Unexpected CLI failure on setting API URI: "${stderr}"`)
+    }
+  }
+
+  /**
+    Setups QN URI for the CLI.
+  */
+  async setQueryNodeUri(uri = 'http://localhost:8081/graphql') {
+    const { stderr } = this.runCommand(['api:setQueryNodeEndpoint', uri])
+
+    if (stderr) {
+      throw new Error(`Unexpected CLI failure on setting QN URI: "${stderr}"`)
+    }
+  }
+
+  /**
+    Imports an account from Polkadot's keyring keypair to CLI.
+  */
+  async importAccount(keyringPair: KeyringPair, password = ''): Promise<void> {
+    const importableAccount = JSON.stringify(keyringPair.toJson(password))
+    this.saveToTempFile(importableAccount)
+
+    const { stderr } = this.runCommand([
+      'account:import',
+      '--name',
+      (keyringPair.meta.name as string) || uuid().substring(0, 8),
+      '--password',
+      password,
+      '--backupFilePath',
+      this.tmpFilePath,
+    ])
+
+    if (stderr && !this.containsWarningEmptyPassword(stderr)) {
+      throw new Error(`Unexpected CLI failure on importing account: "${stderr}"`)
+    }
+  }
+
+  async chooseMemberAccount(accountAddress: string) {
+    const { stderr } = this.runCommand(['account:chooseMember', '--address', accountAddress])
+
+    if (stderr) {
+      throw new Error(`Unexpected CLI failure on choosing account: "${stderr}"`)
+    }
+  }
+
+  /**
+    Creates a new channel.
+  */
+  async createChannel(channel: unknown): Promise<number> {
+    this.saveToTempFile(JSON.stringify(channel))
+
+    const { stdout, stderr } = this.runCommand(
+      ['content:createChannel', '--input', this.tmpFilePath, '--context', 'Member'],
+      { AUTO_CONFIRM: 'true' }
+    )
+
+    if (stderr && !this.containsWarningNoStorage(stderr)) {
+      // ignore warnings
+      throw new Error(`Unexpected CLI failure on creating channel: "${stderr}"`)
+    }
+
+    return this.parseCreatedIdFromStdout(stdout)
+  }
+
+  /**
+    Creates a new channel category.
+  */
+  async createChannelCategory(channelCategory: unknown): Promise<number> {
+    this.saveToTempFile(JSON.stringify(channelCategory))
+
+    const { stdout, stderr } = this.runCommand(
+      ['content:createChannelCategory', '--input', this.tmpFilePath, '--context', 'Lead'],
+      { AUTO_CONFIRM: 'true' }
+    )
+
+    if (stderr) {
+      throw new Error(`Unexpected CLI failure on creating channel category: "${stderr}"`)
+    }
+
+    return this.parseCreatedIdFromStdout(stdout)
+  }
+
+  /**
+    Creates a new video.
+  */
+  async createVideo(channelId: number, video: unknown): Promise<ICreatedVideoData> {
+    this.saveToTempFile(JSON.stringify(video))
+
+    const { stdout, stderr } = this.runCommand(
+      ['content:createVideo', '--input', this.tmpFilePath, '--channelId', channelId.toString()],
+      { AUTO_CONFIRM: 'true' }
+    )
+
+    if (stderr && !this.containsWarningNoStorage(stderr)) {
+      // ignore warnings
+      throw new Error(`Unexpected CLI failure on creating video: "${stderr}"`)
+    }
+
+    const videoId = this.parseCreatedIdFromStdout(stdout)
+    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> {
+    this.saveToTempFile(JSON.stringify(videoCategory))
+
+    const { stdout, stderr } = this.runCommand(
+      ['content:createVideoCategory', '--input', this.tmpFilePath, '--context', 'Lead'],
+      { AUTO_CONFIRM: 'true' }
+    )
+
+    if (stderr) {
+      throw new Error(`Unexpected CLI failure on creating video category: "${stderr}"`)
+    }
+
+    return this.parseCreatedIdFromStdout(stdout)
+  }
+
+  /**
+    Updates an existing video.
+  */
+  async updateVideo(videoId: number, video: unknown): Promise<void> {
+    this.saveToTempFile(JSON.stringify(video))
+
+    const { stdout, stderr } = this.runCommand(
+      ['content:updateVideo', '--input', this.tmpFilePath, videoId.toString()],
+      { AUTO_CONFIRM: 'true' }
+    )
+
+    if (stderr && !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> {
+    this.saveToTempFile(JSON.stringify(channel))
+
+    const { stdout, stderr } = this.runCommand(
+      ['content:updateChannel', '--input', this.tmpFilePath, channelId.toString()],
+      { AUTO_CONFIRM: 'true' }
+    )
+
+    if (stderr && !this.containsWarningNoStorage(stderr)) {
+      // ignore warnings
+      throw new Error(`Unexpected CLI failure on creating video category: "${stderr}"`)
+    }
+  }
+}

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

@@ -2,6 +2,8 @@ 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'
+import { CliApi } from './CliApi'
 
 export abstract class BaseFixture {
   protected readonly api: Api
@@ -83,6 +85,17 @@ export abstract class BaseFixture {
   }
 }
 
+export abstract class BaseQueryNodeFixture extends BaseFixture {
+  protected readonly query: QueryNodeApi
+  protected readonly cli: CliApi
+
+  constructor(api: Api, query: QueryNodeApi, cli: CliApi) {
+    super(api)
+    this.query = query
+    this.cli = cli
+  }
+}
+
 // 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 {

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

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

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

@@ -5,8 +5,14 @@ import { QueryNodeApi } from './QueryNodeApi'
 import { Flow } from './Flow'
 import { InvertedPromise } from './InvertedPromise'
 import { ResourceManager } from './Resources'
+import { CliApi } from './CliApi'
 
-export type JobProps = { apiFactory: ApiFactory; env: NodeJS.ProcessEnv; query: QueryNodeApi }
+export type JobProps = {
+  apiFactory: ApiFactory
+  env: NodeJS.ProcessEnv
+  query: QueryNodeApi
+  cli: CliApi
+}
 
 export enum JobOutcome {
   Succeeded = 'Succeeded',
@@ -100,6 +106,7 @@ export class Job {
             api: jobProps.apiFactory.getApi(`${this.label}:${flow.name}-${index}`),
             env: jobProps.env,
             query: jobProps.query,
+            cli: jobProps.cli,
             lock: locker.lock,
           })
         } catch (err) {

+ 15 - 1
tests/network-tests/src/JobManager.ts

@@ -4,18 +4,31 @@ import { Job, JobOutcome, JobProps } from './Job'
 import { ApiFactory } from './Api'
 import { QueryNodeApi } from './QueryNodeApi'
 import { ResourceManager } from './Resources'
+import { CliApi } from './CliApi'
 
 export class JobManager extends EventEmitter {
   private _jobs: Job[] = []
   private readonly _apiFactory: ApiFactory
   private readonly _env: NodeJS.ProcessEnv
   private readonly _query: QueryNodeApi
+  private readonly _cli: CliApi
 
-  constructor({ apiFactory, env, query }: { apiFactory: ApiFactory; env: NodeJS.ProcessEnv; query: QueryNodeApi }) {
+  constructor({
+    apiFactory,
+    env,
+    query,
+    cli,
+  }: {
+    apiFactory: ApiFactory
+    env: NodeJS.ProcessEnv
+    query: QueryNodeApi
+    cli: CliApi
+  }) {
     super()
     this._apiFactory = apiFactory
     this._env = env
     this._query = query
+    this._cli = cli
   }
 
   public createJob(label: string, flows: Flow[] | Flow): Job {
@@ -32,6 +45,7 @@ export class JobManager extends EventEmitter {
       env: this._env,
       query: this._query,
       apiFactory: this._apiFactory,
+      cli: this._cli,
     }
   }
 

+ 41 - 4
tests/network-tests/src/QueryNodeApi.ts

@@ -1,5 +1,3 @@
-import { ApolloClient, DocumentNode, NormalizedCacheObject } from '@apollo/client/core'
-import { extendDebug, Debugger } from './Debugger'
 import {
   StorageDataObjectFieldsFragment,
   GetDataObjectsByIdsQuery,
@@ -12,6 +10,9 @@ import {
 } from './graphql/generated/queries'
 import { Maybe } from './graphql/generated/schema'
 import { OperationDefinitionNode } from 'graphql'
+import { gql, ApolloClient, ApolloQueryResult, DocumentNode, NormalizedCacheObject } from '@apollo/client'
+import { BLOCKTIME } from './consts'
+import { extendDebug, Debugger } from './Debugger'
 import { Utils } from './utils'
 
 export class QueryNodeApi {
@@ -30,7 +31,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 +59,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 +120,40 @@ 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 })
+  }
 }

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

@@ -9,7 +9,8 @@ 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'
+import { CliApi } from './CliApi'
 
 export type ScenarioProps = {
   env: NodeJS.ProcessEnv
@@ -65,7 +66,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 {
@@ -85,9 +86,11 @@ export async function scenario(scene: (props: ScenarioProps) => Promise<void>):
 
   const query = new QueryNodeApi(queryNodeProvider)
 
+  const cli = new CliApi()
+
   const debug = extendDebug('scenario')
 
-  const jobs = new JobManager({ apiFactory, query, env })
+  const jobs = new JobManager({ apiFactory, query, env, cli })
 
   await scene({ env, debug, job: jobs.createJob.bind(jobs) })
 

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

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

+ 378 - 0
tests/network-tests/src/fixtures/content/activeVideoCounters.ts

@@ -0,0 +1,378 @@
+import { assert } from 'chai'
+import { ApolloQueryResult } from '@apollo/client'
+import { Api } from '../../Api'
+import { WorkingGroups } from '../../WorkingGroups'
+import { BaseQueryNodeFixture, FixtureRunner } from '../../Fixture'
+import { BuyMembershipHappyCaseFixture } from '../membershipModule'
+import { KeyringPair } from '@polkadot/keyring/types'
+import { Bytes } from '@polkadot/types'
+import { QueryNodeApi } from '../../QueryNodeApi'
+import { CliApi, ICreatedVideoData } from '../../CliApi'
+import { PaidTermId, MemberId } from '@joystream/types/members'
+import { Debugger, extendDebug } from '../../Debugger'
+import BN from 'bn.js'
+import { addWorkerToGroup } from './addWorkerToGroup'
+import { Worker, WorkerId } from '@joystream/types/working-group'
+import { DataObjectId } from '@joystream/types/storage'
+import {
+  getMemberDefaults,
+  getChannelCategoryDefaults,
+  getChannelDefaults,
+  getVideoDefaults,
+  getVideoCategoryDefaults,
+} from './contentTemplates'
+
+interface IMember {
+  keyringPair: KeyringPair
+  account: string
+  memberId: MemberId
+}
+
+// QN connection paramaters
+const qnConnection = {
+  numberOfRepeats: 20, // QN can take some time to catch up with node - repeat until then
+  repeatDelay: 3000, // delay between failed QN requests
+}
+
+// settings
+const contentDirectoryWorkingGroupId = 1 // TODO: retrieve group id programmatically
+const sufficientTopupAmount = new BN(1000000) // some very big number to cover fees of all transactions
+
+/**
+  Fixture that test Joystream content can be created, is reflected in query node,
+  and channel and categories counts their active videos properly.
+*/
+export class ActiveVideoCountersFixture extends BaseQueryNodeFixture {
+  private paidTerms: PaidTermId
+  private debug: Debugger.Debugger
+  private env: NodeJS.ProcessEnv
+
+  constructor(api: Api, query: QueryNodeApi, cli: CliApi, env: NodeJS.ProcessEnv, paidTerms: PaidTermId) {
+    super(api, query, cli)
+    this.paidTerms = paidTerms
+    this.env = env
+    this.debug = extendDebug('fixture:ActiveVideoCountersFixture')
+  }
+
+  // this could be used by other modules in some shared fixture or whatnot; membership creation is common to many flows
+  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, accounts, this.paidTerms)
+
+    await new FixtureRunner(buyMembershipsFixture).run()
+
+    const memberIds = buyMembershipsFixture.getCreatedMembers()
+
+    return keyringPairs.map((item, index) => ({
+      keyringPair: item,
+      account: accounts[index],
+      memberId: memberIds[index],
+    }))
+  }
+
+  /*
+    Topup a bunch of accounts by specified amount.
+  */
+  private async topupAddresses(accounts: string[], amount: BN) {
+    await this.api.treasuryTransferBalanceToAccounts(accounts, amount)
+  }
+
+  /*
+    Execute this Fixture.
+  */
+  public async execute(): Promise<void> {
+    const videoCount = 2
+    const videoCategoryCount = 2
+    const channelCount = 2
+    const channelCategoryCount = 2
+
+    // prepare accounts for group leads, storage worker, and content author
+
+    this.debug('Loading working group leaders')
+    const { contentLeader, storageLeader } = await this.retrieveWorkingGroupLeaders()
+
+    // prepare memberships
+    this.debug('Creating members')
+    const members = await this.createMembers(1)
+
+    const authorMemberIndex = 0
+    const author = members[authorMemberIndex]
+    author.keyringPair.setMeta({
+      ...author.keyringPair.meta,
+      ...getMemberDefaults(authorMemberIndex),
+    })
+    const storageGroupWorker = author
+
+    this.debug('Top-uping accounts')
+    await this.topupAddresses(
+      [
+        ...members.map((item) => item.keyringPair.address),
+        contentLeader.role_account_id.toString(),
+        storageLeader.role_account_id.toString(),
+      ],
+      sufficientTopupAmount
+    )
+
+    // switch to lead and create category structure as lead
+
+    this.debug(`Choosing content working group lead's account`)
+    // this expects lead account to be already imported into CLI
+    await this.cli.chooseMemberAccount(contentLeader.role_account_id.toString())
+
+    this.debug('Creating channel categories')
+    const channelCategoryIds = await this.createChannelCategories(channelCategoryCount)
+
+    this.debug('Creating video categories')
+    const videoCategoryIds = await this.createVideoCategories(videoCategoryCount)
+
+    // switch to authors account
+
+    this.debug(`Importing author's account`)
+    await this.cli.importAccount(author.keyringPair)
+    await this.cli.chooseMemberAccount(author.keyringPair.address)
+
+    // create content entities
+
+    this.debug('Creating channels')
+    const channelIds = await this.createChannels(channelCount, channelCategoryIds[0], author.account)
+
+    this.debug('Creating videos')
+    const videosData = await this.createVideos(videoCount, channelIds[0], videoCategoryIds[0])
+
+    // add `storageGroupWorker` to storage group, storage bucket and accept all storage content
+    const { workerId: storageGroupWorkerId, storageBucketId } = await this.prepareAssetStorage(
+      storageLeader,
+      storageGroupWorker
+    )
+
+    this.debug('Adding storage bag to bucket')
+    await this.api.updateStorageBucketsForBag(storageLeader.role_account_id.toString(), channelIds[0].toString(), [
+      storageBucketId,
+    ])
+
+    this.debug('Accepting content to storage bag')
+    const allAssetIds = videosData.map((item) => item.assetContentIds).flat()
+    await this.api.acceptPendingDataObjects(
+      storageGroupWorker.keyringPair.address,
+      storageGroupWorkerId,
+      storageBucketId,
+      channelIds[0].toString(),
+      allAssetIds
+    )
+
+    // check channel and categories con are counted as active
+
+    this.debug('Checking channels active video counters')
+    await this.assertCounterMatch('channels', channelIds[0], videoCount)
+
+    this.debug('Checking channel categories active video counters')
+    await this.assertCounterMatch('channelCategories', channelCategoryIds[0], videoCount)
+
+    this.debug('Checking video categories active video counters')
+    await this.assertCounterMatch('videoCategories', videoCategoryIds[0], videoCount)
+
+    // move channel to different channel category and video to different videoCategory
+
+    const oneMovedVideoCount = 1
+    this.debug('Move channel to different channel category')
+    await this.cli.updateChannel(channelIds[0], {
+      category: channelCategoryIds[1], // move from category 1 to category 2
+    })
+
+    this.debug('Move video to different video category')
+    await this.cli.updateVideo(videosData[0].videoId, {
+      category: 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', channelCategoryIds[1], videoCount)
+
+    this.debug('Checking video categories active video counters (2)')
+    await this.assertCounterMatch('videoCategories', videoCategoryIds[1], oneMovedVideoCount)
+
+    /** Giza doesn't support changing channels - uncoment this on later releases where it's \\\
+
+    // 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 - oneMovedVideoCount)
+    await this.assertCounterMatch('channels', channelIds[1], oneMovedVideoCount)
+
+    // end
+    */
+
+    this.debug('Done')
+  }
+
+  /**
+    Prepares storage requisites.
+  */
+  private async prepareAssetStorage(storageLeader: Worker, storageGroupWorker: IMember) {
+    const noLimit = 10000000 // virtually no limit
+    const bucketSettings = {
+      sizeLimit: noLimit,
+      objectsLimit: noLimit,
+    }
+    const storageBucketsPerBag = 10 // something in boundaries of StorageBucketsPerBagValueConstraint (see runtime)
+
+    this.debug('Setting up storage buckets per bag limit')
+    await this.api.updateStorageBucketsPerBagLimit(storageLeader.role_account_id.toString(), storageBucketsPerBag)
+
+    this.debug('Setting up storage buckets voucher limits')
+    await this.api.updateStorageBucketsVoucherMaxLimits(
+      storageLeader.role_account_id.toString(),
+      bucketSettings.sizeLimit,
+      bucketSettings.objectsLimit
+    )
+
+    this.debug('Adding worker to content directory group')
+    const workerId = await addWorkerToGroup(
+      this.api,
+      this.env,
+      WorkingGroups.Storage,
+      storageGroupWorker.keyringPair.address
+    )
+
+    this.debug('Creating storage bucket')
+    const createBucketResult = await this.api.createStorageBucket(
+      storageLeader.role_account_id.toString(),
+      bucketSettings.sizeLimit,
+      bucketSettings.objectsLimit,
+      workerId
+    )
+
+    const storageBucketId = this.api.findEvent(createBucketResult.events, 'storage', 'StorageBucketCreated')?.data[0] as DataObjectId
+
+    this.debug('Accepting storage bucket invitation')
+    await this.api.acceptStorageBucketInvitation(storageGroupWorker.keyringPair.address, workerId, storageBucketId)
+
+    return { workerId, storageBucketId }
+  }
+
+  /**
+    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 qnConnectionNumberOfRepeats = 10
+
+    const getterName = `get${entityName[0].toUpperCase()}${entityName.slice(1)}`
+    await this.query.tryQueryWithTimeout(
+      () => (this.query as any)[getterName](),
+      (tmpEntity) => {
+        const entities = (tmpEntity as any).data[entityName]
+        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.activeVideosCounter === expectedCount)
+      },
+      qnConnection.repeatDelay,
+      qnConnection.numberOfRepeats
+    )
+  }
+
+  /**
+    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: WorkingGroups) => {
+      const leader = await this.api.getGroupLead(group)
+      if (!leader) {
+        throw new Error(`Working group leader for "${group}" is missing!`)
+      }
+      return leader
+    }
+
+    return {
+      contentLeader: await retrieveGroupLeader(WorkingGroups.Content),
+      storageLeader: await retrieveGroupLeader(WorkingGroups.Storage),
+    }
+  }
+
+  /**
+    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, this.cli.cliExamplesFolderPath),
+        category: videoCategoryId,
+      })
+    }
+    const newVideosData = (await this.createCommonEntities(count, createVideo)) as ICreatedVideoData[]
+
+    return newVideosData
+  }
+
+  /**
+    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) =>
+      this.cli.createVideoCategory({
+        ...getVideoCategoryDefaults(index),
+      })
+    )) as number[]
+
+    return createdIds
+  }
+
+  /**
+    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 channel category. Can only be executed as content group leader.
+  */
+  private async createChannelCategories(count: number): Promise<number[]> {
+    const createdIds = (await this.createCommonEntities(count, (index) =>
+      this.cli.createChannelCategory({
+        ...getChannelCategoryDefaults(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: number) => {
+      const acc = await accPromise
+      const createdId = await createPromise(index)
+
+      return [...acc, createdId]
+    }, Promise.resolve([]) as Promise<T[]>)
+
+    return createdIds
+  }
+}

+ 77 - 0
tests/network-tests/src/fixtures/content/addWorkerToGroup.ts

@@ -0,0 +1,77 @@
+// this file could be used by more fixtures - adding worker to group is quite common
+
+import {
+  AddWorkerOpeningFixture,
+  ApplyForOpeningFixture,
+  BeginApplicationReviewFixture,
+  FillOpeningFixture,
+  IncreaseStakeFixture,
+  UpdateRewardAccountFixture,
+} from '../../fixtures/workingGroupModule'
+import { FixtureRunner } from '../../Fixture'
+import { OpeningId } from '@joystream/types/hiring'
+import { Api } from '../../Api'
+import { WorkingGroups } from '../../WorkingGroups'
+import BN from 'bn.js'
+import { WorkerId } from '@joystream/types/working-group'
+
+export async function addWorkerToGroup(
+  api: Api,
+  env: NodeJS.ProcessEnv,
+  group: WorkingGroups,
+  applicant: string
+): Promise<WorkerId> {
+  const applicationStake: BN = new BN(env.WORKING_GROUP_APPLICATION_STAKE!)
+  const roleStake: BN = new BN(env.WORKING_GROUP_ROLE_STAKE!)
+  const firstRewardInterval: BN = new BN(env.LONG_REWARD_INTERVAL!)
+  const rewardInterval: BN = new BN(env.LONG_REWARD_INTERVAL!)
+  const payoutAmount: BN = new BN(env.PAYOUT_AMOUNT!)
+  const unstakingPeriod: BN = new BN(env.STORAGE_WORKING_GROUP_UNSTAKING_PERIOD!)
+  const openingActivationDelay: BN = new BN(0)
+  const paidTerms = api.createPaidTermId(new BN(+env.MEMBERSHIP_PAID_TERMS!))
+
+  const addWorkerOpeningFixture = new AddWorkerOpeningFixture(
+    api,
+    applicationStake,
+    roleStake,
+    openingActivationDelay,
+    unstakingPeriod,
+    group
+  )
+  // Add worker opening
+  await new FixtureRunner(addWorkerOpeningFixture).run()
+
+  // First apply for worker opening
+  const applyForWorkerOpeningFixture = new ApplyForOpeningFixture(
+    api,
+    [applicant],
+    applicationStake,
+    roleStake,
+    addWorkerOpeningFixture.getCreatedOpeningId() as OpeningId,
+    group
+  )
+  await new FixtureRunner(applyForWorkerOpeningFixture).run()
+  const applicationIdToHire = applyForWorkerOpeningFixture.getApplicationIds()[0]
+
+  // Begin application review
+  const beginApplicationReviewFixture = new BeginApplicationReviewFixture(
+    api,
+    addWorkerOpeningFixture.getCreatedOpeningId() as OpeningId,
+    group
+  )
+  await new FixtureRunner(beginApplicationReviewFixture).run()
+
+  // Fill worker opening
+  const fillOpeningFixture = new FillOpeningFixture(
+    api,
+    [applicationIdToHire],
+    addWorkerOpeningFixture.getCreatedOpeningId() as OpeningId,
+    firstRewardInterval,
+    rewardInterval,
+    payoutAmount,
+    group
+  )
+  await new FixtureRunner(fillOpeningFixture).run()
+
+  return fillOpeningFixture.getWorkerIds()[0]
+}

+ 50 - 0
tests/network-tests/src/fixtures/content/contentTemplates.ts

@@ -0,0 +1,50 @@
+// basic templates for content entities
+
+import { v4 as uuid } from 'uuid'
+
+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, cliExamplesFolderPath: string) {
+  return {
+    title: 'Active video counters Testing channel',
+    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',
+  }
+}
+
+export function getChannelDefaults(index: number, rewardAccountAddress: string) {
+  return {
+    title: 'Active video counters Testing channel',
+    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',
+  }
+}

+ 1 - 0
tests/network-tests/src/fixtures/content/index.ts

@@ -0,0 +1 @@
+export * from './activeVideoCounters'

+ 19 - 0
tests/network-tests/src/flows/content/activeVideoCounters.ts

@@ -0,0 +1,19 @@
+import { FlowProps } from '../../Flow'
+import { extendDebug } from '../../Debugger'
+import { FixtureRunner } from '../../Fixture'
+import { ActiveVideoCountersFixture } from '../../fixtures/content'
+import { PaidTermId } from '@joystream/types/members'
+import BN from 'bn.js'
+
+export default async function activeVideoCounters({ api, query, cli, env }: FlowProps): Promise<void> {
+  const debug = extendDebug('flow:active-video-counters')
+  debug('Started')
+  api.enableDebugTxLogs()
+
+  const paidTerms: PaidTermId = api.createPaidTermId(new BN(+env.MEMBERSHIP_PAID_TERMS!))
+
+  const activeVideoCountersFixture = new ActiveVideoCountersFixture(api, query, cli, env, paidTerms)
+  await new FixtureRunner(activeVideoCountersFixture).run()
+
+  debug('Done')
+}

+ 13 - 2
tests/network-tests/src/flows/workingGroup/leaderSetup.ts

@@ -8,10 +8,11 @@ import { assert } from 'chai'
 // import { KeyringPair } from '@polkadot/keyring/types'
 import { FixtureRunner } from '../../Fixture'
 import { extendDebug } from '../../Debugger'
+import { CliApi } from '../../CliApi'
 
 export default function (group: WorkingGroups, canSkip = false) {
-  return async function ({ api, env }: FlowProps): Promise<void> {
-    return leaderSetup(api, env, group, canSkip)
+  return async function ({ api, env, cli }: FlowProps): Promise<void> {
+    return leaderSetup(api, env, cli, group, canSkip)
   }
 }
 
@@ -19,6 +20,7 @@ export default function (group: WorkingGroups, canSkip = false) {
 async function leaderSetup(
   api: Api,
   env: NodeJS.ProcessEnv,
+  cli: CliApi,
   group: WorkingGroups,
   skipIfAlreadySet = false
 ): Promise<void> {
@@ -61,6 +63,15 @@ async function leaderSetup(
   assert.notEqual(hiredLead, undefined, `${group} group Lead was not hired!`)
   assert(hiredLead!.role_account_id.eq(leadKeyPair.address))
 
+  // setup (ensure) CLI connection to node and query node
+
+  await cli.setApiUri()
+
+  await cli.setQueryNodeUri()
+
+  debug(`Importing leader's account to CLI`)
+  await cli.importAccount(leadKeyPair)
+
   debug('Done')
 
   // Who ever needs it will need to get it from the Api layer

+ 8 - 2
tests/network-tests/src/scenarios/content-directory.ts

@@ -1,7 +1,13 @@
-import { WorkingGroups } from '../WorkingGroups'
 import leaderSetup from '../flows/workingGroup/leaderSetup'
+import activeVideoCounters from '../flows/content/activeVideoCounters'
+import { WorkingGroups } from '../WorkingGroups'
 import { scenario } from '../Scenario'
 
 scenario(async ({ job }) => {
-  job('setup content lead', leaderSetup(WorkingGroups.Content))
+  const setupContentLeaderSetup = job('setup content lead', leaderSetup(WorkingGroups.Content))
+  const setupStorageLeaderSetup = job('setup storage lead', leaderSetup(WorkingGroups.Storage))
+
+  job('check active video counters', activeVideoCounters)
+    .requires(setupContentLeaderSetup)
+    .requires(setupStorageLeaderSetup)
 })