Browse Source

Threads and posts moderation + IntiailizeForumFixture

Leszek Wiesner 3 years ago
parent
commit
047d40b8c5

+ 3 - 3
query-node/mappings/forum.ts

@@ -40,7 +40,7 @@ import {
 import { Forum } from './generated/types'
 import { PrivilegedActor } from '@joystream/types/augment/all'
 import { ForumPostMetadata } from '@joystream/metadata-protobuf'
-import { Not } from 'typeorm'
+import { Not, In } from 'typeorm'
 
 async function getCategory(db: DatabaseManager, categoryId: string, relations?: string[]): Promise<ForumCategory> {
   const category = await db.get(ForumCategory, { where: { id: categoryId }, relations })
@@ -373,10 +373,10 @@ export async function forum_CategoryStickyThreadUpdate(db: DatabaseManager, even
   const actorWorker = await getActorWorker(db, privilegedActor)
   const newStickyThreadsIds = newStickyThreadsIdsVec.map((id) => id.toString())
   const threadsToSetSticky = await db.getMany(ForumThread, {
-    where: { category: { id: categoryId.toString() }, id: newStickyThreadsIds },
+    where: { category: { id: categoryId.toString() }, id: In(newStickyThreadsIds) },
   })
   const threadsToUnsetSticky = await db.getMany(ForumThread, {
-    where: { category: { id: categoryId.toString() }, isSticky: true, id: Not(newStickyThreadsIds) },
+    where: { category: { id: categoryId.toString() }, isSticky: true, id: Not(In(newStickyThreadsIds)) },
   })
 
   const setStickyUpdates = (threadsToSetSticky || []).map(async (t) => {

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

@@ -231,6 +231,14 @@ import {
   GetCategoryMembershipOfModeratorUpdatedEventsByEventIdsQuery,
   GetCategoryMembershipOfModeratorUpdatedEventsByEventIdsQueryVariables,
   GetCategoryMembershipOfModeratorUpdatedEventsByEventIds,
+  ThreadModeratedEventFieldsFragment,
+  GetThreadModeratedEventsByEventIdsQuery,
+  GetThreadModeratedEventsByEventIdsQueryVariables,
+  GetThreadModeratedEventsByEventIds,
+  PostModeratedEventFieldsFragment,
+  GetPostModeratedEventsByEventIdsQuery,
+  GetPostModeratedEventsByEventIdsQueryVariables,
+  GetPostModeratedEventsByEventIds,
 } from './graphql/generated/queries'
 import { Maybe } from './graphql/generated/schema'
 import { OperationDefinitionNode } from 'graphql'
@@ -857,4 +865,20 @@ export class QueryNodeApi {
       'categoryMembershipOfModeratorUpdatedEvents'
     )
   }
+
+  public async getThreadModeratedEvents(events: EventDetails[]): Promise<ThreadModeratedEventFieldsFragment[]> {
+    const eventIds = events.map((e) => this.getQueryNodeEventId(e.blockNumber, e.indexInBlock))
+    return this.multipleEntitiesQuery<
+      GetThreadModeratedEventsByEventIdsQuery,
+      GetThreadModeratedEventsByEventIdsQueryVariables
+    >(GetThreadModeratedEventsByEventIds, { eventIds }, 'threadModeratedEvents')
+  }
+
+  public async getPostModeratedEvents(events: EventDetails[]): Promise<PostModeratedEventFieldsFragment[]> {
+    const eventIds = events.map((e) => this.getQueryNodeEventId(e.blockNumber, e.indexInBlock))
+    return this.multipleEntitiesQuery<
+      GetPostModeratedEventsByEventIdsQuery,
+      GetPostModeratedEventsByEventIdsQueryVariables
+    >(GetPostModeratedEventsByEventIds, { eventIds }, 'postModeratedEvents')
+  }
 }

+ 2 - 2
tests/integration-tests/src/fixtures/forum/AddPostsFixture.ts

@@ -13,8 +13,8 @@ import { POST_DEPOSIT } from '../../consts'
 import { ForumPostMetadata, IForumPostMetadata } from '@joystream/metadata-protobuf'
 
 export type PostParams = {
-  categoryId: CategoryId
-  threadId: ThreadId
+  categoryId: CategoryId | number
+  threadId: ThreadId | number
   asMember: MemberId
   editable?: boolean // defaults to true
   metadata: MetadataInput<IForumPostMetadata> & { expectReplyFailure?: boolean }

+ 234 - 0
tests/integration-tests/src/fixtures/forum/InitializeForumFixture.ts

@@ -0,0 +1,234 @@
+import { MemberId, PostId, ThreadId } from '@joystream/types/common'
+import { CategoryId } from '@joystream/types/forum'
+import { WorkerId } from '@joystream/types/working-group'
+import { Api } from '../../Api'
+import { BaseQueryNodeFixture, FixtureRunner } from '../../Fixture'
+import { QueryNodeApi } from '../../QueryNodeApi'
+import { PostPath, ThreadPath } from '../../types'
+import { Utils } from '../../utils'
+import { BuyMembershipHappyCaseFixture } from '../membership'
+import { HireWorkersFixture } from '../workingGroups/HireWorkersFixture'
+import { AddPostsFixture, PostParams } from './AddPostsFixture'
+import { CategoryParams, CreateCategoriesFixture } from './CreateCategoriesFixture'
+import { CreateThreadsFixture, ThreadParams } from './CreateThreadsFixture'
+import { CategoryModeratorStatusUpdate, UpdateCategoryModeratorsFixture } from './UpdateCategoryModeratorsFixture'
+
+export type InitializeForumConfig = {
+  numberOfForumMembers: number
+  numberOfCategories: number
+  threadsPerCategory?: number
+  postsPerThread?: number
+  moderatorsPerCategory?: number
+}
+
+export class InitializeForumFixture extends BaseQueryNodeFixture {
+  protected createCategoriesRunner?: FixtureRunner
+  protected createThreadsRunner?: FixtureRunner
+  protected addPostsRunner?: FixtureRunner
+  protected updateCategoryModeratorsRunner?: FixtureRunner
+
+  protected config: InitializeForumConfig
+
+  protected forumMemberIds: MemberId[] | undefined
+  protected postIds: PostId[] | undefined
+  protected threadIds: ThreadId[] | undefined
+  protected categoryIds: CategoryId[] | undefined
+  protected moderatorIds: WorkerId[] | undefined
+  protected threadIdsByCategoryId: Map<number, ThreadId[]> = new Map()
+  protected postIdsByThreadId: Map<number, PostId[]> = new Map()
+  protected moderatorIdsByCategoryId: Map<number, WorkerId[]> = new Map()
+
+  constructor(api: Api, query: QueryNodeApi, config: InitializeForumConfig) {
+    super(api, query)
+    this.config = config
+  }
+
+  public getCreatedPostsIds(): PostId[] {
+    Utils.assert(this.postIds, 'Posts not yet created!')
+    return this.postIds
+  }
+
+  public getCreatedPostsByThreadId(threadId: ThreadId): PostId[] {
+    const postsIds = this.postIdsByThreadId.get(threadId.toNumber())
+    Utils.assert(postsIds, `No posts found by threadId ${threadId}`)
+    return postsIds
+  }
+
+  public getCreatedThreadIds(): ThreadId[] {
+    Utils.assert(this.threadIds, 'Threads not yet created!')
+    return this.threadIds
+  }
+
+  public getCreatedThreadsByCategoryId(categoryId: CategoryId): ThreadId[] {
+    const threadIds = this.threadIdsByCategoryId.get(categoryId.toNumber())
+    Utils.assert(threadIds, `No threads found by categoryId ${categoryId}`)
+    return threadIds
+  }
+
+  public getCreatedCategoryIds(): CategoryId[] {
+    Utils.assert(this.categoryIds, 'Categories not yet created!')
+    return this.categoryIds
+  }
+
+  public getCreatedForumMemberIds(): CategoryId[] {
+    Utils.assert(this.forumMemberIds, 'Forum members not yet created!')
+    return this.forumMemberIds
+  }
+
+  public getCreatedForumModeratorIds(): WorkerId[] {
+    Utils.assert(this.moderatorIds, 'Forum moderators not yet created!')
+    return this.moderatorIds
+  }
+
+  public getCreatedForumModeratorsByCategoryId(categoryId: CategoryId): WorkerId[] {
+    const moderatorIds = this.moderatorIdsByCategoryId.get(categoryId.toNumber())
+    Utils.assert(moderatorIds, `No moderators found by categoryId ${categoryId}`)
+    return moderatorIds
+  }
+
+  public getThreadPaths(): ThreadPath[] {
+    Utils.assert(this.categoryIds, 'Threads not yet created')
+    let paths: ThreadPath[] = []
+    this.categoryIds.forEach((categoryId) => {
+      const threadIds = this.getCreatedThreadsByCategoryId(categoryId)
+      paths = paths.concat(threadIds.map((threadId) => ({ categoryId, threadId })))
+    })
+
+    return paths
+  }
+
+  public getPostsPaths(): PostPath[] {
+    let paths: PostPath[] = []
+    this.getThreadPaths().forEach(({ categoryId, threadId }) => {
+      const postIds = this.getCreatedPostsByThreadId(threadId)
+      paths = paths.concat(postIds.map((postId) => ({ categoryId, threadId, postId })))
+    })
+
+    return paths
+  }
+
+  public async execute(): Promise<void> {
+    const { api, query } = this
+    const {
+      numberOfForumMembers,
+      numberOfCategories,
+      threadsPerCategory,
+      postsPerThread,
+      moderatorsPerCategory,
+    } = this.config
+    // Create forum members
+    const accounts = (await api.createKeyPairs(numberOfForumMembers)).map((kp) => kp.address)
+    const buyMembershipFixture = new BuyMembershipHappyCaseFixture(api, query, accounts)
+    await new FixtureRunner(buyMembershipFixture).run()
+    const forumMemberIds = buyMembershipFixture.getCreatedMembers()
+    this.forumMemberIds = forumMemberIds
+
+    // Create categories
+    const categories: CategoryParams[] = Array.from({ length: numberOfCategories }, (v, i) => ({
+      title: `Category ${i}`,
+      description: `Initialize forum test category ${i}`,
+    }))
+    const createCategoriesFixture = new CreateCategoriesFixture(api, query, categories)
+    this.createCategoriesRunner = new FixtureRunner(createCategoriesFixture)
+    await this.createCategoriesRunner.run()
+    this.categoryIds = createCategoriesFixture.getCreatedCategoriesIds()
+
+    // Create and assign moderators
+    if (moderatorsPerCategory) {
+      const createModeratorsFixture = new HireWorkersFixture(
+        api,
+        query,
+        'forumWorkingGroup',
+        moderatorsPerCategory * numberOfCategories
+      )
+      await new FixtureRunner(createModeratorsFixture).run()
+      const moderatorIds = createModeratorsFixture.getCreatedWorkerIds()
+      this.moderatorIds = moderatorIds
+
+      let moderatorUpdates: CategoryModeratorStatusUpdate[] = []
+      this.categoryIds.forEach(
+        (categoryId, i) =>
+          (moderatorUpdates = moderatorUpdates.concat(
+            Array.from({ length: moderatorsPerCategory }, (v, j) => ({
+              canModerate: true,
+              categoryId,
+              moderatorId: moderatorIds[i * moderatorsPerCategory + j],
+            }))
+          ))
+      )
+      const updateCategoryModeratorsFixture = new UpdateCategoryModeratorsFixture(api, query, moderatorUpdates)
+      this.updateCategoryModeratorsRunner = new FixtureRunner(updateCategoryModeratorsFixture)
+      await this.updateCategoryModeratorsRunner.run()
+      this.moderatorIds.forEach((moderatorId, i) => {
+        const categoryId = moderatorUpdates[i].categoryId.toNumber()
+        this.moderatorIdsByCategoryId.set(categoryId, [
+          ...(this.moderatorIdsByCategoryId.get(categoryId) || []),
+          moderatorId,
+        ])
+      })
+    }
+
+    // Create threads
+    if (threadsPerCategory) {
+      let threads: ThreadParams[] = []
+      this.categoryIds.forEach(
+        (categoryId) =>
+          (threads = threads.concat(
+            Array.from({ length: threadsPerCategory }, (v, i) => ({
+              categoryId,
+              asMember: forumMemberIds[i % forumMemberIds.length],
+              title: `Thread ${i} in category ${categoryId.toString()}`,
+              text: `Initialize forum test thread ${i} in category ${categoryId.toString()}`,
+            }))
+          ))
+      )
+
+      const createThreadsFixture = new CreateThreadsFixture(api, query, threads)
+      this.createThreadsRunner = new FixtureRunner(createThreadsFixture)
+      await this.createThreadsRunner.run()
+      this.threadIds = createThreadsFixture.getCreatedThreadsIds()
+      this.threadIds.forEach((threadId, i) => {
+        const categoryId = threads[i].categoryId.toNumber()
+        this.threadIdsByCategoryId.set(categoryId, [...(this.threadIdsByCategoryId.get(categoryId) || []), threadId])
+      })
+    }
+
+    // Create posts
+    if (postsPerThread) {
+      let posts: PostParams[] = []
+      this.getThreadPaths().forEach(
+        (threadPath) =>
+          (posts = posts.concat(
+            Array.from({ length: postsPerThread || 0 }, (v, i) => ({
+              ...threadPath,
+              asMember: forumMemberIds[i % forumMemberIds.length],
+              metadata: {
+                value: { text: `Initialize forum test post ${i} in thread ${threadPath.threadId.toString()}` },
+              },
+              editable: true,
+            }))
+          ))
+      )
+
+      const addPostsFixture = new AddPostsFixture(api, query, posts)
+      this.addPostsRunner = new FixtureRunner(addPostsFixture)
+      await this.addPostsRunner.run()
+      this.postIds = addPostsFixture.getCreatedPostsIds()
+      this.postIds.forEach((postId, i) => {
+        const post = posts[i]
+        const threadId = typeof post.threadId === 'number' ? post.threadId : post.threadId.toNumber()
+        this.postIdsByThreadId.set(threadId, [...(this.postIdsByThreadId.get(threadId) || []), postId])
+      })
+    }
+  }
+
+  public async runQueryNodeChecks(): Promise<void> {
+    await super.runQueryNodeChecks()
+    await Promise.all([
+      this.createCategoriesRunner?.runQueryNodeChecks(),
+      this.createThreadsRunner?.runQueryNodeChecks(),
+      this.addPostsRunner?.runQueryNodeChecks(),
+      this.updateCategoryModeratorsRunner?.runQueryNodeChecks(),
+    ])
+  }
+}

+ 86 - 0
tests/integration-tests/src/fixtures/forum/ModeratePostsFixture.ts

@@ -0,0 +1,86 @@
+import { Api } from '../../Api'
+import { QueryNodeApi } from '../../QueryNodeApi'
+import { EventDetails } from '../../types'
+import { WorkerId } from '@joystream/types/working-group'
+import { SubmittableExtrinsic } from '@polkadot/api/types'
+import { Utils } from '../../utils'
+import { ISubmittableResult } from '@polkadot/types/types/'
+import { ForumPostFieldsFragment, PostModeratedEventFieldsFragment } from '../../graphql/generated/queries'
+import { assert } from 'chai'
+import { CategoryId } from '@joystream/types/forum'
+import { WithForumWorkersFixture } from './WithForumWorkersFixture'
+import { PostId, ThreadId } from '@joystream/types/common'
+
+export type PostModerationInput = {
+  categoryId: CategoryId
+  threadId: ThreadId
+  postId: PostId
+  rationale?: string
+  asWorker?: WorkerId
+}
+
+export const DEFAULT_RATIONALE = 'Bad post'
+
+export class ModeratePostsFixture extends WithForumWorkersFixture {
+  protected moderations: PostModerationInput[]
+
+  public constructor(api: Api, query: QueryNodeApi, moderations: PostModerationInput[]) {
+    super(api, query)
+    this.moderations = moderations
+  }
+
+  protected async getSignerAccountOrAccounts(): Promise<string[]> {
+    return this.getSignersFromInput(this.moderations)
+  }
+
+  protected async getExtrinsics(): Promise<SubmittableExtrinsic<'promise'>[]> {
+    return this.moderations.map((u) =>
+      this.api.tx.forum.moderatePost(
+        u.asWorker ? { Moderator: u.asWorker } : { Lead: null },
+        u.categoryId,
+        u.threadId,
+        u.postId,
+        u.rationale || DEFAULT_RATIONALE
+      )
+    )
+  }
+
+  protected async getEventFromResult(result: ISubmittableResult): Promise<EventDetails> {
+    return this.api.retrieveForumEventDetails(result, 'PostModerated')
+  }
+
+  protected assertQueriedPostsAreValid(
+    qPosts: ForumPostFieldsFragment[],
+    qEvents: PostModeratedEventFieldsFragment[]
+  ): void {
+    this.events.map((e, i) => {
+      const moderation = this.moderations[i]
+      const qPost = qPosts.find((p) => p.id === moderation.postId.toString())
+      const qEvent = this.findMatchingQueryNodeEvent(e, qEvents)
+      Utils.assert(qPost, 'Query node: Post not found')
+      Utils.assert(qPost.status.__typename === 'PostStatusModerated', 'Invalid post status')
+      Utils.assert(qPost.status.postModeratedEvent, 'Query node: Missing PostModeratedEvent ref')
+      assert.equal(qPost.status.postModeratedEvent.id, qEvent.id)
+    })
+  }
+
+  protected assertQueryNodeEventIsValid(qEvent: PostModeratedEventFieldsFragment, i: number): void {
+    const { postId, asWorker, rationale } = this.moderations[i]
+    assert.equal(qEvent.post.id, postId.toString())
+    assert.equal(qEvent.actor.id, `forumWorkingGroup-${asWorker ? asWorker.toString() : this.forumLeadId!.toString()}`)
+    assert.equal(qEvent.rationale, rationale || DEFAULT_RATIONALE)
+  }
+
+  async runQueryNodeChecks(): Promise<void> {
+    await super.runQueryNodeChecks()
+    // Query the events
+    const qEvents = await this.query.tryQueryWithTimeout(
+      () => this.query.getPostModeratedEvents(this.events),
+      (qEvents) => this.assertQueryNodeEventsAreValid(qEvents)
+    )
+
+    // Query the threads
+    const qPosts = await this.query.getPostsByIds(this.moderations.map((m) => m.postId))
+    this.assertQueriedPostsAreValid(qPosts, qEvents)
+  }
+}

+ 84 - 0
tests/integration-tests/src/fixtures/forum/ModerateThreadsFixture.ts

@@ -0,0 +1,84 @@
+import { Api } from '../../Api'
+import { QueryNodeApi } from '../../QueryNodeApi'
+import { EventDetails } from '../../types'
+import { WorkerId } from '@joystream/types/working-group'
+import { SubmittableExtrinsic } from '@polkadot/api/types'
+import { Utils } from '../../utils'
+import { ISubmittableResult } from '@polkadot/types/types/'
+import { ForumThreadWithPostsFieldsFragment, ThreadModeratedEventFieldsFragment } from '../../graphql/generated/queries'
+import { assert } from 'chai'
+import { CategoryId } from '@joystream/types/forum'
+import { WithForumWorkersFixture } from './WithForumWorkersFixture'
+import { ThreadId } from '@joystream/types/common'
+
+export type ThreadModerationInput = {
+  categoryId: CategoryId
+  threadId: ThreadId
+  rationale?: string
+  asWorker?: WorkerId
+}
+
+export const DEFAULT_RATIONALE = 'Bad thread'
+
+export class ModerateThreadsFixture extends WithForumWorkersFixture {
+  protected moderations: ThreadModerationInput[]
+
+  public constructor(api: Api, query: QueryNodeApi, moderations: ThreadModerationInput[]) {
+    super(api, query)
+    this.moderations = moderations
+  }
+
+  protected async getSignerAccountOrAccounts(): Promise<string[]> {
+    return this.getSignersFromInput(this.moderations)
+  }
+
+  protected async getExtrinsics(): Promise<SubmittableExtrinsic<'promise'>[]> {
+    return this.moderations.map((u) =>
+      this.api.tx.forum.moderateThread(
+        u.asWorker ? { Moderator: u.asWorker } : { Lead: null },
+        u.categoryId,
+        u.threadId,
+        u.rationale || DEFAULT_RATIONALE
+      )
+    )
+  }
+
+  protected async getEventFromResult(result: ISubmittableResult): Promise<EventDetails> {
+    return this.api.retrieveForumEventDetails(result, 'ThreadModerated')
+  }
+
+  protected assertQueriedThreadsAreValid(
+    qThreads: ForumThreadWithPostsFieldsFragment[],
+    qEvents: ThreadModeratedEventFieldsFragment[]
+  ): void {
+    this.events.map((e, i) => {
+      const moderation = this.moderations[i]
+      const qThread = qThreads.find((t) => t.id === moderation.threadId.toString())
+      const qEvent = this.findMatchingQueryNodeEvent(e, qEvents)
+      Utils.assert(qThread, 'Query node: Thread not found')
+      Utils.assert(qThread.status.__typename === 'ThreadStatusModerated', 'Invalid thread status')
+      Utils.assert(qThread.status.threadModeratedEvent, 'Query node: Missing ThreadModeratedEvent ref')
+      assert.equal(qThread.status.threadModeratedEvent.id, qEvent.id)
+    })
+  }
+
+  protected assertQueryNodeEventIsValid(qEvent: ThreadModeratedEventFieldsFragment, i: number): void {
+    const { threadId, asWorker, rationale } = this.moderations[i]
+    assert.equal(qEvent.thread.id, threadId.toString())
+    assert.equal(qEvent.actor.id, `forumWorkingGroup-${asWorker ? asWorker.toString() : this.forumLeadId!.toString()}`)
+    assert.equal(qEvent.rationale, rationale || DEFAULT_RATIONALE)
+  }
+
+  async runQueryNodeChecks(): Promise<void> {
+    await super.runQueryNodeChecks()
+    // Query the events
+    const qEvents = await this.query.tryQueryWithTimeout(
+      () => this.query.getThreadModeratedEvents(this.events),
+      (qEvents) => this.assertQueryNodeEventsAreValid(qEvents)
+    )
+
+    // Query the threads
+    const qThreads = await this.query.getThreadsWithPostsByIds(this.moderations.map((m) => m.threadId))
+    this.assertQueriedThreadsAreValid(qThreads, qEvents)
+  }
+}

+ 3 - 0
tests/integration-tests/src/fixtures/forum/index.ts

@@ -9,3 +9,6 @@ export { UpdateThreadTitlesFixture, ThreadTitleUpdate } from './UpdateThreadTitl
 export { MoveThreadsFixture, MoveThreadParams } from './MoveThreadsFixture'
 export { SetStickyThreadsFixture, StickyThreadsParams } from './SetStickyThreadsFixture'
 export { UpdateCategoryModeratorsFixture, CategoryModeratorStatusUpdate } from './UpdateCategoryModeratorsFixture'
+export { ModerateThreadsFixture, ThreadModerationInput } from './ModerateThreadsFixture'
+export { ModeratePostsFixture, PostModerationInput } from './ModeratePostsFixture'
+export { InitializeForumFixture, InitializeForumConfig } from './InitializeForumFixture'

+ 2 - 2
tests/integration-tests/src/flows/forum/categories.ts

@@ -61,11 +61,11 @@ export default async function categories({ api, query }: FlowProps): Promise<voi
 
   const moderatorUpdates: CategoryModeratorStatusUpdate[] = subcategoryIds.reduce(
     (updates, categoryId, i) =>
-      updates.concat([
+      (updates = updates.concat([
         { categoryId, moderatorId: moderatorIds[i], canModerate: true },
         { categoryId, moderatorId: moderatorIds[i + 1], canModerate: true },
         { categoryId, moderatorId: moderatorIds[i + 1], canModerate: false },
-      ]),
+      ])),
     [] as CategoryModeratorStatusUpdate[]
   )
   const updateCategoryModeratorsFixture = new UpdateCategoryModeratorsFixture(api, query, moderatorUpdates)

+ 71 - 0
tests/integration-tests/src/flows/forum/moderation.ts

@@ -0,0 +1,71 @@
+import { FlowProps } from '../../Flow'
+import Debugger from 'debug'
+import { FixtureRunner } from '../../Fixture'
+import {
+  InitializeForumFixture,
+  ModerateThreadsFixture,
+  ThreadModerationInput,
+  PostModerationInput,
+  ModeratePostsFixture,
+} from '../../fixtures/forum'
+
+export default async function threads({ api, query }: FlowProps): Promise<void> {
+  const debug = Debugger(`flow:threads`)
+  debug('Started')
+  api.enableDebugTxLogs()
+
+  // Initialize categories, threads and posts
+  const MODERATORS_PER_CATEGORY = 3
+  const initializeForumFixture = new InitializeForumFixture(api, query, {
+    numberOfForumMembers: 5,
+    numberOfCategories: 3,
+    threadsPerCategory: MODERATORS_PER_CATEGORY + 1, // 1 thread per moderator + 1 for the lead
+    postsPerThread: 3,
+    moderatorsPerCategory: MODERATORS_PER_CATEGORY,
+  })
+  await new FixtureRunner(initializeForumFixture).runWithQueryNodeChecks()
+
+  // Generate input (moderate posts and threads different moderators / lead)
+  const threadModerations: ThreadModerationInput[] = []
+  let postModerations: PostModerationInput[] = []
+  initializeForumFixture.getCreatedCategoryIds().forEach((categoryId) => {
+    const categoryModerators = initializeForumFixture.getCreatedForumModeratorsByCategoryId(categoryId)
+    const categoryThreads = initializeForumFixture.getCreatedThreadsByCategoryId(categoryId)
+    let i: number
+    for (i = 0; i < MODERATORS_PER_CATEGORY; ++i) {
+      const threadId = categoryThreads[i]
+      const genericModerationInput = { categoryId, threadId, asWorker: categoryModerators[i] }
+      threadModerations.push({
+        ...genericModerationInput,
+        rationale: `Moderate thread ${i} in category ${categoryId.toString()} rationale`,
+      })
+      postModerations = postModerations.concat(
+        initializeForumFixture.getCreatedPostsByThreadId(threadId).map((postId, j) => ({
+          ...genericModerationInput,
+          postId,
+          rationale: `Moderate post ${j} in thread ${i} in category ${categoryId.toString()} rationale`,
+        }))
+      )
+    }
+    // Moderate as lead
+    const threadId = categoryThreads[i]
+    threadModerations.push({ categoryId, threadId })
+    postModerations = postModerations.concat(
+      initializeForumFixture.getCreatedPostsByThreadId(threadId).map((postId) => ({ categoryId, threadId, postId }))
+    )
+  })
+
+  // Run fixtures
+  const moderateThreadsFixture = new ModerateThreadsFixture(api, query, threadModerations)
+  const moderateThreadsRunner = new FixtureRunner(moderateThreadsFixture)
+  await moderateThreadsRunner.run()
+
+  const moderatePostsFixture = new ModeratePostsFixture(api, query, postModerations)
+  const moderatePostsRunner = new FixtureRunner(moderatePostsFixture)
+  await moderatePostsRunner.run()
+
+  // Run query-node checks
+  await Promise.all([moderateThreadsFixture.runQueryNodeChecks(), moderatePostsFixture.runQueryNodeChecks()])
+
+  debug('Done')
+}

+ 16 - 50
tests/integration-tests/src/flows/forum/posts.ts

@@ -1,82 +1,50 @@
 import { FlowProps } from '../../Flow'
 import Debugger from 'debug'
 import { FixtureRunner } from '../../Fixture'
-import { AddPostsFixture, CategoryParams, CreateCategoriesFixture, PostParams } from '../../fixtures/forum'
-import { CreateThreadsFixture, ThreadParams } from '../../fixtures/forum/CreateThreadsFixture'
-import { BuyMembershipHappyCaseFixture } from '../../fixtures/membership'
+import { AddPostsFixture, InitializeForumFixture, PostParams } from '../../fixtures/forum'
 
 export default async function threads({ api, query }: FlowProps): Promise<void> {
   const debug = Debugger(`flow:threads`)
   debug('Started')
   api.enableDebugTxLogs()
 
-  // TODO: Refactor creating initial categories and threads to separate fixture
-  // Create test member(s)
-  const accounts = (await api.createKeyPairs(5)).map((kp) => kp.address)
-  const buyMembershipFixture = new BuyMembershipHappyCaseFixture(api, query, accounts)
-  await new FixtureRunner(buyMembershipFixture).run()
-  const memberIds = buyMembershipFixture.getCreatedMembers()
+  const initializeForumFixture = new InitializeForumFixture(api, query, {
+    numberOfForumMembers: 5,
+    numberOfCategories: 2,
+    threadsPerCategory: 2,
+  })
+  await new FixtureRunner(initializeForumFixture).runWithQueryNodeChecks()
 
-  // Create some test categories first
-  const categories: CategoryParams[] = [
-    { title: 'Test 1', description: 'Test category 1' },
-    { title: 'Test 2', description: 'Test category 2' },
-  ]
-  const createCategoriesFixture = new CreateCategoriesFixture(api, query, categories)
-  await new FixtureRunner(createCategoriesFixture).run()
-  const categoryIds = createCategoriesFixture.getCreatedCategoriesIds()
-
-  // Create threads
-  const threads: ThreadParams[] = categoryIds.reduce(
-    (threadsArray, categoryId) =>
-      threadsArray.concat(
-        memberIds.map((memberId) => ({
-          categoryId,
-          asMember: memberId,
-          title: `Thread ${categoryId}/${memberId}`,
-          text: `Example thread of member ${memberId.toString()} in category ${categoryId.toString()}`,
-        }))
-      ),
-    [] as ThreadParams[]
-  )
-
-  const createThreadsFixture = new CreateThreadsFixture(api, query, threads)
-  const createThreadsRunner = new FixtureRunner(createThreadsFixture)
-  await createThreadsRunner.run()
-  const threadIds = createThreadsFixture.getCreatedThreadsIds()
+  const memberIds = initializeForumFixture.getCreatedForumMemberIds()
+  const threadPaths = initializeForumFixture.getThreadPaths()
 
   // Create posts
   const posts: PostParams[] = [
     // Valid cases:
     {
-      threadId: threadIds[0],
-      categoryId: categoryIds[0],
+      ...threadPaths[0],
       metadata: { value: { text: 'Example post' } },
       asMember: memberIds[0],
     },
     {
-      threadId: threadIds[1],
-      categoryId: categoryIds[0],
+      ...threadPaths[1],
       metadata: { value: { text: 'Non-editable post' } },
       editable: false,
       asMember: memberIds[1],
     },
     {
-      threadId: threadIds[memberIds.length],
-      categoryId: categoryIds[1],
+      ...threadPaths[2],
       metadata: { value: { text: null } },
       asMember: memberIds[2],
     },
     {
-      threadId: threadIds[memberIds.length + 1],
-      categoryId: categoryIds[1],
+      ...threadPaths[3],
       metadata: { value: { text: '' } },
       asMember: memberIds[3],
     },
     // Invalid cases
     {
-      threadId: threadIds[0],
-      categoryId: categoryIds[0],
+      ...threadPaths[0],
       metadata: { value: '0x000001000100', expectFailure: true },
       asMember: memberIds[0],
     },
@@ -91,15 +59,13 @@ export default async function threads({ api, query }: FlowProps): Promise<void>
   const postReplies: PostParams[] = [
     // Valid reply case:
     {
-      threadId: threadIds[0],
-      categoryId: categoryIds[0],
+      ...threadPaths[0],
       metadata: { value: { text: 'Reply post', repliesTo: postIds[0].toNumber() } },
       asMember: memberIds[1],
     },
     // Invalid reply postId case:
     {
-      threadId: threadIds[0],
-      categoryId: categoryIds[0],
+      ...threadPaths[0],
       metadata: { value: { text: 'Reply post', repliesTo: 999999 }, expectReplyFailure: true },
       asMember: memberIds[1],
     },

+ 45 - 75
tests/integration-tests/src/flows/forum/threads.ts

@@ -2,9 +2,8 @@ import { FlowProps } from '../../Flow'
 import Debugger from 'debug'
 import { FixtureRunner } from '../../Fixture'
 import {
-  CategoryParams,
-  CreateCategoriesFixture,
   DeleteThreadsFixture,
+  InitializeForumFixture,
   MoveThreadParams,
   MoveThreadsFixture,
   SetStickyThreadsFixture,
@@ -13,110 +12,81 @@ import {
   ThreadTitleUpdate,
   UpdateThreadTitlesFixture,
 } from '../../fixtures/forum'
-import { CreateThreadsFixture, ThreadParams } from '../../fixtures/forum/CreateThreadsFixture'
-import { BuyMembershipHappyCaseFixture } from '../../fixtures/membership'
-import _ from 'lodash'
-import { ThreadId } from '@joystream/types/common'
+import { CategoryId } from '@joystream/types/forum'
 
 export default async function threads({ api, query }: FlowProps): Promise<void> {
   const debug = Debugger(`flow:threads`)
   debug('Started')
   api.enableDebugTxLogs()
 
-  // Create test member(s)
-  const accounts = (await api.createKeyPairs(5)).map((kp) => kp.address)
-  const buyMembershipFixture = new BuyMembershipHappyCaseFixture(api, query, accounts)
-  await new FixtureRunner(buyMembershipFixture).run()
-  const memberIds = buyMembershipFixture.getCreatedMembers()
+  // Initialize categories and threads
+  const initializeForumFixture = new InitializeForumFixture(api, query, {
+    numberOfForumMembers: 5,
+    numberOfCategories: 3,
+    threadsPerCategory: 3,
+  })
+  await new FixtureRunner(initializeForumFixture).runWithQueryNodeChecks()
 
-  // Create some test categories first
-  const categories: CategoryParams[] = [
-    { title: 'Test 1', description: 'Test category 1' },
-    { title: 'Test 2', description: 'Test category 2' },
-  ]
-  const createCategoriesFixture = new CreateCategoriesFixture(api, query, categories)
-  await new FixtureRunner(createCategoriesFixture).run()
-  const categoryIds = createCategoriesFixture.getCreatedCategoriesIds()
-
-  // Create threads
-  const threads: ThreadParams[] = categoryIds.reduce(
-    (threadsArray, categoryId) =>
-      threadsArray.concat(
-        memberIds.map((memberId) => ({
-          categoryId,
-          asMember: memberId,
-          title: `Thread ${categoryId}/${memberId}`,
-          text: `Example thread of member ${memberId.toString()} in category ${categoryId.toString()}`,
-        }))
-      ),
-    [] as ThreadParams[]
-  )
-
-  const createThreadsFixture = new CreateThreadsFixture(api, query, threads)
-  const createThreadsRunner = new FixtureRunner(createThreadsFixture)
-  await createThreadsRunner.runWithQueryNodeChecks()
-  const threadIds = createThreadsFixture.getCreatedThreadsIds()
-
-  // Move threads
-  const threadCategoryUpdates: MoveThreadParams[] = threadIds.map((threadId, i) => ({
-    threadId,
-    categoryId: threads[i].categoryId,
-    newCategoryId: categoryIds[(categoryIds.indexOf(threads[i].categoryId) + 1) % categoryIds.length],
-  }))
-
-  const moveThreadsFixture = new MoveThreadsFixture(api, query, threadCategoryUpdates)
-  const moveThreadsRunner = new FixtureRunner(moveThreadsFixture)
-  await moveThreadsRunner.run()
-  const threadCategories = threadCategoryUpdates.map((u) => u.newCategoryId)
-  const threadIdsByCategoryId = threadIds.reduce((ids, id, i) => {
-    const categoryId = threadCategories[i].toString()
-    return { ...ids, [categoryId]: [...(ids[categoryId] || []), id] }
-  }, {} as Record<string, ThreadId[]>)
+  const categoryIds = initializeForumFixture.getCreatedCategoryIds()
 
   // Set threads as sticky (2 per category)
-  const stickyThreadsParams: StickyThreadsParams[] = categoryIds.reduce((paramsArr, categoryId, i) => {
-    const threadIds = threadIdsByCategoryId[categoryId.toString()]
-    return paramsArr.concat([
+  let stickyThreadsParams: StickyThreadsParams[] = []
+  categoryIds.forEach((categoryId) => {
+    const threadIds = initializeForumFixture.getCreatedThreadsByCategoryId(categoryId)
+    stickyThreadsParams = stickyThreadsParams.concat([
       { categoryId, stickyTreads: [threadIds[0], threadIds[1]] },
       { categoryId, stickyTreads: [threadIds[1], threadIds[2]] },
     ])
-  }, [] as StickyThreadsParams[])
+  })
 
   const setStickyThreadsFixture = new SetStickyThreadsFixture(api, query, stickyThreadsParams)
   const setStickyThreadsRunner = new FixtureRunner(setStickyThreadsFixture)
   await setStickyThreadsRunner.run()
 
   // Update titles
-  const titleUpdates = threadIds.reduce(
-    (updates, threadId, i) =>
-      updates.concat([
-        { threadId, categoryId: threadCategories[i], newTitle: '' },
-        { threadId, categoryId: threadCategories[i], newTitle: `Test updated title ${i}` },
-      ]),
-    [] as ThreadTitleUpdate[]
+  let titleUpdates: ThreadTitleUpdate[] = []
+  initializeForumFixture.getThreadPaths().forEach(
+    (threadPath, i) =>
+      (titleUpdates = titleUpdates.concat([
+        { ...threadPath, newTitle: '' },
+        { ...threadPath, newTitle: `Test updated title ${i}` },
+      ]))
   )
 
   const updateThreadTitlesFixture = new UpdateThreadTitlesFixture(api, query, titleUpdates)
   const updateThreadTitlesRunner = new FixtureRunner(updateThreadTitlesFixture)
   await updateThreadTitlesRunner.run()
 
+  // Run compound checks
+  await Promise.all([setStickyThreadsRunner.runQueryNodeChecks(), updateThreadTitlesRunner.runQueryNodeChecks()])
+
+  // Move threads to different categories
+  const newThreadCategory = (oldCategory: CategoryId) =>
+    categoryIds[(categoryIds.indexOf(oldCategory) + 1) % categoryIds.length]
+  const threadCategoryUpdates: MoveThreadParams[] = initializeForumFixture.getThreadPaths().map((threadPath) => ({
+    ...threadPath,
+    newCategoryId: newThreadCategory(threadPath.categoryId),
+  }))
+
+  const moveThreadsFixture = new MoveThreadsFixture(api, query, threadCategoryUpdates)
+  const moveThreadsRunner = new FixtureRunner(moveThreadsFixture)
+  await moveThreadsRunner.run()
+
   // Remove threads
   // TODO: Should removing / moving threads also "unstick" them?
-  const threadRemovals: ThreadRemovalInput[] = threadIds.map((threadId, i) => ({
-    threadId,
-    categoryId: threadCategories[i],
-    hide: i >= 1, // Test both cases
-  }))
+  const threadRemovals: ThreadRemovalInput[] = initializeForumFixture
+    .getThreadPaths()
+    .map(({ categoryId, threadId }, i) => ({
+      threadId,
+      categoryId: newThreadCategory(categoryId),
+      hide: !!(i % 2), // Test both cases
+    }))
   const removeThreadsFixture = new DeleteThreadsFixture(api, query, threadRemovals)
   const removeThreadsRunner = new FixtureRunner(removeThreadsFixture)
   await removeThreadsRunner.run()
 
   // Run compound query node checks
-  await Promise.all([
-    moveThreadsRunner.runQueryNodeChecks(),
-    updateThreadTitlesRunner.runQueryNodeChecks(),
-    removeThreadsRunner.runQueryNodeChecks(),
-  ])
+  await Promise.all([moveThreadsRunner.runQueryNodeChecks(), removeThreadsRunner.runQueryNodeChecks()])
 
   debug('Done')
 }

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

@@ -281,6 +281,44 @@ export type GetCategoryMembershipOfModeratorUpdatedEventsByEventIdsQuery = {
   categoryMembershipOfModeratorUpdatedEvents: Array<CategoryMembershipOfModeratorUpdatedEventFieldsFragment>
 }
 
+export type ThreadModeratedEventFieldsFragment = {
+  id: string
+  createdAt: any
+  inBlock: number
+  network: Types.Network
+  inExtrinsic?: Types.Maybe<string>
+  indexInBlock: number
+  rationale: string
+  thread: { id: string }
+  actor: { id: string }
+}
+
+export type GetThreadModeratedEventsByEventIdsQueryVariables = Types.Exact<{
+  eventIds?: Types.Maybe<Array<Types.Scalars['ID']> | Types.Scalars['ID']>
+}>
+
+export type GetThreadModeratedEventsByEventIdsQuery = {
+  threadModeratedEvents: Array<ThreadModeratedEventFieldsFragment>
+}
+
+export type PostModeratedEventFieldsFragment = {
+  id: string
+  createdAt: any
+  inBlock: number
+  network: Types.Network
+  inExtrinsic?: Types.Maybe<string>
+  indexInBlock: number
+  rationale: string
+  post: { id: string }
+  actor: { id: string }
+}
+
+export type GetPostModeratedEventsByEventIdsQueryVariables = Types.Exact<{
+  eventIds?: Types.Maybe<Array<Types.Scalars['ID']> | Types.Scalars['ID']>
+}>
+
+export type GetPostModeratedEventsByEventIdsQuery = { postModeratedEvents: Array<PostModeratedEventFieldsFragment> }
+
 export type MemberMetadataFieldsFragment = { name?: Types.Maybe<string>; about?: Types.Maybe<string> }
 
 export type MembershipFieldsFragment = {
@@ -1457,6 +1495,40 @@ export const CategoryMembershipOfModeratorUpdatedEventFields = gql`
     newCanModerateValue
   }
 `
+export const ThreadModeratedEventFields = gql`
+  fragment ThreadModeratedEventFields on ThreadModeratedEvent {
+    id
+    createdAt
+    inBlock
+    network
+    inExtrinsic
+    indexInBlock
+    thread {
+      id
+    }
+    rationale
+    actor {
+      id
+    }
+  }
+`
+export const PostModeratedEventFields = gql`
+  fragment PostModeratedEventFields on PostModeratedEvent {
+    id
+    createdAt
+    inBlock
+    network
+    inExtrinsic
+    indexInBlock
+    post {
+      id
+    }
+    rationale
+    actor {
+      id
+    }
+  }
+`
 export const MemberMetadataFields = gql`
   fragment MemberMetadataFields on MemberMetadata {
     name
@@ -2368,6 +2440,22 @@ export const GetCategoryMembershipOfModeratorUpdatedEventsByEventIds = gql`
   }
   ${CategoryMembershipOfModeratorUpdatedEventFields}
 `
+export const GetThreadModeratedEventsByEventIds = gql`
+  query getThreadModeratedEventsByEventIds($eventIds: [ID!]) {
+    threadModeratedEvents(where: { id_in: $eventIds }) {
+      ...ThreadModeratedEventFields
+    }
+  }
+  ${ThreadModeratedEventFields}
+`
+export const GetPostModeratedEventsByEventIds = gql`
+  query getPostModeratedEventsByEventIds($eventIds: [ID!]) {
+    postModeratedEvents(where: { id_in: $eventIds }) {
+      ...PostModeratedEventFields
+    }
+  }
+  ${PostModeratedEventFields}
+`
 export const GetMemberById = gql`
   query getMemberById($id: ID!) {
     membershipByUniqueInput(where: { id: $id }) {

+ 44 - 0
tests/integration-tests/src/graphql/queries/forumEvents.graphql

@@ -234,3 +234,47 @@ query getCategoryMembershipOfModeratorUpdatedEventsByEventIds($eventIds: [ID!])
     ...CategoryMembershipOfModeratorUpdatedEventFields
   }
 }
+
+fragment ThreadModeratedEventFields on ThreadModeratedEvent {
+  id
+  createdAt
+  inBlock
+  network
+  inExtrinsic
+  indexInBlock
+  thread {
+    id
+  }
+  rationale
+  actor {
+    id
+  }
+}
+
+query getThreadModeratedEventsByEventIds($eventIds: [ID!]) {
+  threadModeratedEvents(where: { id_in: $eventIds }) {
+    ...ThreadModeratedEventFields
+  }
+}
+
+fragment PostModeratedEventFields on PostModeratedEvent {
+  id
+  createdAt
+  inBlock
+  network
+  inExtrinsic
+  indexInBlock
+  post {
+    id
+  }
+  rationale
+  actor {
+    id
+  }
+}
+
+query getPostModeratedEventsByEventIds($eventIds: [ID!]) {
+  postModeratedEvents(where: { id_in: $eventIds }) {
+    ...PostModeratedEventFields
+  }
+}

+ 4 - 0
tests/integration-tests/src/scenarios/forum.ts

@@ -1,6 +1,8 @@
 import categories from '../flows/forum/categories'
 import polls from '../flows/forum/polls'
 import threads from '../flows/forum/threads'
+import posts from '../flows/forum/posts'
+import moderation from '../flows/forum/moderation'
 import leadOpening from '../flows/working-groups/leadOpening'
 import { scenario } from '../Scenario'
 
@@ -9,4 +11,6 @@ scenario(async ({ job }) => {
   job('forum categories', categories).requires(sudoHireLead)
   job('forum threads', threads).requires(sudoHireLead)
   job('forum polls', polls).requires(sudoHireLead)
+  job('forum posts', posts).requires(sudoHireLead)
+  job('forum moderation', moderation).requires(sudoHireLead)
 })

+ 2 - 0
tests/integration-tests/src/scenarios/full.ts

@@ -2,6 +2,7 @@ import categories from '../flows/forum/categories'
 import polls from '../flows/forum/polls'
 import threads from '../flows/forum/threads'
 import posts from '../flows/forum/posts'
+import moderation from '../flows/forum/moderation'
 import leadOpening from '../flows/working-groups/leadOpening'
 import creatingMemberships from '../flows/membership/creatingMemberships'
 import updatingMemberProfile from '../flows/membership/updatingProfile'
@@ -39,4 +40,5 @@ scenario(async ({ job }) => {
   job('forum threads', threads).requires(sudoHireLead)
   job('forum polls', polls).requires(sudoHireLead)
   job('forum posts', posts).requires(sudoHireLead)
+  job('forum moderation', moderation).requires(sudoHireLead)
 })

+ 11 - 0
tests/integration-tests/src/types.ts

@@ -100,6 +100,17 @@ export type WorkingGroupModuleName =
 
 // Forum
 
+export type ThreadPath = {
+  categoryId: CategoryId
+  threadId: ThreadId
+}
+
+export type PostPath = {
+  categoryId: CategoryId
+  threadId: ThreadId
+  postId: PostId
+}
+
 export interface CategoryCreatedEventDetails extends EventDetails {
   categoryId: CategoryId
 }