Browse Source

varaint relations + UpdateThreadTitle + MoveThread + AddPost

Leszek Wiesner 3 years ago
parent
commit
ada130043a
23 changed files with 1129 additions and 623 deletions
  1. 7 7
      query-node/manifest.yml
  2. 41 2
      query-node/mappings/forum.ts
  3. 25 31
      query-node/schemas/forum.graphql
  4. 50 1
      tests/integration-tests/src/QueryNodeApi.ts
  5. 122 0
      tests/integration-tests/src/fixtures/forum/AddPostsFixture.ts
  6. 4 2
      tests/integration-tests/src/fixtures/forum/CreateThreadsFixture.ts
  7. 2 1
      tests/integration-tests/src/fixtures/forum/DeleteThreadsFixture.ts
  8. 93 0
      tests/integration-tests/src/fixtures/forum/MoveThreadsFixture.ts
  9. 5 9
      tests/integration-tests/src/fixtures/forum/RemoveCategoriesFixture.ts
  10. 9 6
      tests/integration-tests/src/fixtures/forum/UpdateCategoriesStatusFixture.ts
  11. 103 0
      tests/integration-tests/src/fixtures/forum/UpdateThreadTitlesFixture.ts
  12. 0 1
      tests/integration-tests/src/fixtures/forum/VoteOnPollFixture.ts
  13. 10 1
      tests/integration-tests/src/fixtures/forum/WithForumWorkersFixture.ts
  14. 3 0
      tests/integration-tests/src/fixtures/forum/index.ts
  15. 115 0
      tests/integration-tests/src/flows/forum/posts.ts
  16. 47 6
      tests/integration-tests/src/flows/forum/threads.ts
  17. 269 46
      tests/integration-tests/src/graphql/generated/queries.ts
  18. 24 484
      tests/integration-tests/src/graphql/generated/schema.ts
  19. 93 26
      tests/integration-tests/src/graphql/queries/forum.graphql
  20. 66 0
      tests/integration-tests/src/graphql/queries/forumEvents.graphql
  21. 2 0
      tests/integration-tests/src/scenarios/full.ts
  22. 5 0
      tests/integration-tests/src/types.ts
  23. 34 0
      tests/integration-tests/src/utils.ts

+ 7 - 7
query-node/manifest.yml

@@ -61,14 +61,14 @@ typegen:
     - forum.ThreadDeleted
     - forum.ThreadMoved
     - forum.VoteOnPoll
-    # FIXME: https://github.com/Joystream/hydra/issues/373
-    # - forum.PostAdded
-    # - forum.PostModerated
+    - forum.PostAdded
+    - forum.PostModerated
+    # FIXME: https://github.com/Joystream/hydra/issues/398
     # - forum.PostDeleted
-    # - forum.PostTextUpdated
-    # - forum.PostReacted
-    # - forum.CategoryStickyThreadUpdate
-    # - forum.CategoryMembershipOfModeratorUpdated
+    - forum.PostTextUpdated
+    - forum.PostReacted
+    - forum.CategoryStickyThreadUpdate
+    - forum.CategoryMembershipOfModeratorUpdated
   calls:
     - members.updateProfile
     - members.updateAccounts

+ 41 - 2
query-node/mappings/forum.ts

@@ -2,7 +2,7 @@
 eslint-disable @typescript-eslint/naming-convention
 */
 import { SubstrateEvent, DatabaseManager } from '@dzlzv/hydra-common'
-import { bytesToString, genericEventFields } from './common'
+import { bytesToString, deserializeMetadata, genericEventFields } from './common'
 import {
   CategoryCreatedEvent,
   CategoryStatusActive,
@@ -29,9 +29,13 @@ import {
   PostStatusActive,
   PostOriginThreadInitial,
   VoteOnPollEvent,
+  PostAddedEvent,
+  PostStatusLocked,
+  PostOriginThreadReply,
 } from 'query-node/dist/model'
 import { Forum } from './generated/types'
 import { PrivilegedActor } from '@joystream/types/augment/all'
+import { ForumPostMetadata } from '@joystream/metadata-protobuf'
 
 async function getCategory(db: DatabaseManager, categoryId: string): Promise<ForumCategory> {
   const category = await db.get(ForumCategory, { where: { id: categoryId } })
@@ -311,7 +315,42 @@ export async function forum_VoteOnPoll(db: DatabaseManager, event_: SubstrateEve
 }
 
 export async function forum_PostAdded(db: DatabaseManager, event_: SubstrateEvent): Promise<void> {
-  // TODO
+  const [postId, forumUserId, , threadId, metadataBytes, isEditable] = new Forum.PostAddedEvent(event_).params
+  const eventTime = new Date(event_.blockTimestamp)
+
+  const metadata = deserializeMetadata(ForumPostMetadata, metadataBytes)
+  const postText = metadata ? metadata.text || '' : bytesToString(metadataBytes)
+  const repliesToPost =
+    typeof metadata?.repliesTo === 'number' &&
+    (await db.get(ForumPost, { where: { id: metadata.repliesTo.toString() } }))
+
+  const postStatus = isEditable.valueOf() ? new PostStatusActive() : new PostStatusLocked()
+  const postOrigin = new PostOriginThreadReply()
+
+  const post = new ForumPost({
+    id: postId.toString(),
+    createdAt: eventTime,
+    updatedAt: eventTime,
+    text: postText,
+    thread: new ForumThread({ id: threadId.toString() }),
+    status: postStatus,
+    author: new Membership({ id: forumUserId.toString() }),
+    origin: postOrigin,
+    repliesTo: repliesToPost || undefined,
+  })
+  await db.save<ForumPost>(post)
+
+  const postAddedEvent = new PostAddedEvent({
+    ...genericEventFields(event_),
+    post,
+    isEditable: isEditable.valueOf(),
+    text: postText,
+  })
+
+  await db.save<PostAddedEvent>(postAddedEvent)
+  // Update the other side of cross-relationship
+  postOrigin.postAddedEventId = postAddedEvent.id
+  await db.save<ForumPost>(post)
 }
 
 export async function forum_PostModerated(db: DatabaseManager, event_: SubstrateEvent): Promise<void> {

+ 25 - 31
query-node/schemas/forum.graphql

@@ -4,15 +4,13 @@ type CategoryStatusActive @variant {
 }
 
 type CategoryStatusArchived @variant {
-  # TODO: Variant relationship
-  "Id of the event the category was archived in"
-  categoryUpdatedEventId: ID!
+  "Event the category was archived in"
+  categoryUpdatedEvent: CategoryUpdatedEvent!
 }
 
 type CategoryStatusRemoved @variant {
-  # TODO: Variant relationship
-  "Id of the event the category was deleted in"
-  categoryDeletedEventId: ID!
+  "Event the category was deleted in"
+  categoryDeletedEvent: CategoryDeletedEvent!
 }
 
 union CategoryStatus = CategoryStatusActive | CategoryStatusArchived | CategoryStatusRemoved
@@ -51,24 +49,20 @@ type ThreadStatusActive @variant {
 
 "The thread is visible, but not editable - it was removed by the author from the runtime state, but the `hide` flag was set to FALSE"
 type ThreadStatusLocked @variant {
-  # TODO: Variant relationship
-  # TODO: May become optional if threads can be created as non-editable
-  "Id of the event the thread was deleted (locked) in"
-  threadDeletedEventId: ID!
+  "Event the thread was deleted (locked) in"
+  threadDeletedEvent: ThreadDeletedEvent!
 }
 
 "The thread is hidden - it was removed by the moderator and the associated stake was slashed"
 type ThreadStatusModerated @variant {
-  # TODO: Variant relationship
-  "Id of the event the thread was moderated in"
-  threadModeratedEventId: ID!
+  "Event the thread was moderated in"
+  threadModeratedEvent: ThreadModeratedEvent!
 }
 
 "The thread is hidden - it was removed by the author and the `hide` flag was set to TRUE"
 type ThreadStatusRemoved @variant {
-  # TODO: Variant relationship
-  "Id of the event the thread was removed in"
-  threadDeletedEventId: ID!
+  "Event the thread was removed in"
+  threadDeletedEvent: ThreadDeletedEvent!
 }
 
 union ThreadStatus = ThreadStatusActive | ThreadStatusLocked | ThreadStatusModerated | ThreadStatusRemoved
@@ -101,6 +95,9 @@ type ForumThread @entity {
   "Current thread status"
   status: ThreadStatus!
 
+  "Theread title update events"
+  titleUpdates: [ThreadTitleUpdatedEvent!] @derivedFrom(field: "thread")
+
   # Required to create Many-to-Many relation
   "The events the thred was made sticky in"
   madeStickyInEvents: [CategoryStickyThreadUpdateEvent!] @derivedFrom(field: "newStickyThreads")
@@ -164,37 +161,34 @@ type PostStatusActive @variant {
 
 "The post is visible but not editable - either it wasn't editable to begin with or it was removed from the runtime state, but with `hide` flag beeing set to FALSE"
 type PostStatusLocked @variant {
-  # TODO: Variant relationship
-  "Post deleted event id in case the post became locked through runtime removal"
-  postDeletedEventId: ID
+  "Post deleted event in case the post became locked through runtime removal"
+  postDeletedEvent: PostDeletedEvent
 }
 
 "The post is hidden - it was removed by the moderator and the associated stake was slashed"
 type PostStatusModerated @variant {
-  # TODO: Variant relationship
-  "Id of the event the post was moderated in"
-  postModeratedEventId: ID!
+  "Event the post was moderated in"
+  postModeratedEvent: PostModeratedEvent!
 }
 
 "The post is hidden - it was removed from the runtime state by the author and the `hide` flag was set to TRUE"
 type PostStatusRemoved @variant {
-  # TODO: Variant relationship
-  "Id of the event the post was removed in"
-  postDeletedEventId: ID!
+  "Event the post was removed in"
+  postDeletedEvent: PostDeletedEvent!
 }
 
 union PostStatus = PostStatusActive | PostStatusLocked | PostStatusModerated | PostStatusRemoved
 
 type PostOriginThreadInitial @variant {
-  # TODO: Variant relationship
-  "Id of the related thread creation event"
-  threadCreatedEventId: ID!
+  "Thread creation event"
+  # Must be optional because of post<->event cross-relationship
+  threadCreatedEvent: ThreadCreatedEvent
 }
 
 type PostOriginThreadReply @variant {
-  # TODO: Variant relationship
-  "Id of the related post added event"
-  postAddedEventId: ID!
+  "Related PostAdded event"
+  # Must be optional because of post<->event cross-relationship
+  postAddedEvent: PostAddedEvent
 }
 
 union PostOrigin = PostOriginThreadInitial | PostOriginThreadReply

+ 50 - 1
tests/integration-tests/src/QueryNodeApi.ts

@@ -1,5 +1,5 @@
 import { ApolloClient, DocumentNode, NormalizedCacheObject } from '@apollo/client'
-import { MemberId, ThreadId } from '@joystream/types/common'
+import { MemberId, PostId, ThreadId } from '@joystream/types/common'
 import Debugger from 'debug'
 import { ApplicationId, OpeningId, WorkerId } from '@joystream/types/working-group'
 import { EventDetails, WorkingGroupModuleName } from './types'
@@ -207,6 +207,22 @@ import {
   GetMemberInvitedEventsByEventIdsQuery,
   GetMemberInvitedEventsByEventIdsQueryVariables,
   GetMemberInvitedEventsByEventIds,
+  ForumPostFieldsFragment,
+  GetPostsByIdsQuery,
+  GetPostsByIdsQueryVariables,
+  GetPostsByIds,
+  PostAddedEventFieldsFragment,
+  GetPostAddedEventsByEventIdsQuery,
+  GetPostAddedEventsByEventIdsQueryVariables,
+  GetPostAddedEventsByEventIds,
+  ThreadTitleUpdatedEventFieldsFragment,
+  GetThreadTitleUpdatedEventsByEventIdsQuery,
+  GetThreadTitleUpdatedEventsByEventIdsQueryVariables,
+  GetThreadTitleUpdatedEventsByEventIds,
+  ThreadMovedEventFieldsFragment,
+  GetThreadMovedEventsByEventIdsQuery,
+  GetThreadMovedEventsByEventIdsQueryVariables,
+  GetThreadMovedEventsByEventIds,
 } from './graphql/generated/queries'
 import { Maybe } from './graphql/generated/schema'
 import { OperationDefinitionNode } from 'graphql'
@@ -752,6 +768,14 @@ export class QueryNodeApi {
     >(GetThreadCreatedEventsByEventIds, { eventIds }, 'threadCreatedEvents')
   }
 
+  public async getThreadTitleUpdatedEvents(events: EventDetails[]): Promise<ThreadTitleUpdatedEventFieldsFragment[]> {
+    const eventIds = events.map((e) => this.getQueryNodeEventId(e.blockNumber, e.indexInBlock))
+    return this.multipleEntitiesQuery<
+      GetThreadTitleUpdatedEventsByEventIdsQuery,
+      GetThreadTitleUpdatedEventsByEventIdsQueryVariables
+    >(GetThreadTitleUpdatedEventsByEventIds, { eventIds }, 'threadTitleUpdatedEvents')
+  }
+
   public async getThreadsWithPostsByIds(ids: ThreadId[]): Promise<ForumThreadWithPostsFieldsFragment[]> {
     return this.multipleEntitiesQuery<GetThreadsWithPostsByIdsQuery, GetThreadsWithPostsByIdsQueryVariables>(
       GetThreadsWithPostsByIds,
@@ -776,4 +800,29 @@ export class QueryNodeApi {
       GetThreadDeletedEventsByEventIdsQueryVariables
     >(GetThreadDeletedEventsByEventIds, { eventIds }, 'threadDeletedEvents')
   }
+
+  public async getPostsByIds(ids: PostId[]): Promise<ForumPostFieldsFragment[]> {
+    return this.multipleEntitiesQuery<GetPostsByIdsQuery, GetPostsByIdsQueryVariables>(
+      GetPostsByIds,
+      { ids: ids.map((id) => id.toString()) },
+      'forumPosts'
+    )
+  }
+
+  public async getPostAddedEvents(events: EventDetails[]): Promise<PostAddedEventFieldsFragment[]> {
+    const eventIds = events.map((e) => this.getQueryNodeEventId(e.blockNumber, e.indexInBlock))
+    return this.multipleEntitiesQuery<GetPostAddedEventsByEventIdsQuery, GetPostAddedEventsByEventIdsQueryVariables>(
+      GetPostAddedEventsByEventIds,
+      { eventIds },
+      'postAddedEvents'
+    )
+  }
+
+  public async getThreadMovedEvents(events: EventDetails[]): Promise<ThreadMovedEventFieldsFragment[]> {
+    const eventIds = events.map((e) => this.getQueryNodeEventId(e.blockNumber, e.indexInBlock))
+    return this.multipleEntitiesQuery<
+      GetThreadMovedEventsByEventIdsQuery,
+      GetThreadMovedEventsByEventIdsQueryVariables
+    >(GetThreadMovedEventsByEventIds, { eventIds }, 'threadMovedEvents')
+  }
 }

+ 122 - 0
tests/integration-tests/src/fixtures/forum/AddPostsFixture.ts

@@ -0,0 +1,122 @@
+import { Api } from '../../Api'
+import { QueryNodeApi } from '../../QueryNodeApi'
+import { MetadataInput, PostAddedEventDetails } from '../../types'
+import { SubmittableExtrinsic } from '@polkadot/api/types'
+import { Utils } from '../../utils'
+import { ISubmittableResult } from '@polkadot/types/types/'
+import { ForumPostFieldsFragment, PostAddedEventFieldsFragment } from '../../graphql/generated/queries'
+import { assert } from 'chai'
+import { StandardizedFixture } from '../../Fixture'
+import { CategoryId } from '@joystream/types/forum'
+import { MemberId, PostId, ThreadId } from '@joystream/types/common'
+import { POST_DEPOSIT } from '../../consts'
+import { ForumPostMetadata, IForumPostMetadata } from '@joystream/metadata-protobuf'
+
+export type PostParams = {
+  categoryId: CategoryId
+  threadId: ThreadId
+  asMember: MemberId
+  editable?: boolean // defaults to true
+  metadata: MetadataInput<IForumPostMetadata> & { expectReplyFailure?: boolean }
+}
+
+export class AddPostsFixture extends StandardizedFixture {
+  protected events: PostAddedEventDetails[] = []
+
+  protected postsParams: PostParams[]
+
+  public constructor(api: Api, query: QueryNodeApi, postsParams: PostParams[]) {
+    super(api, query)
+    this.postsParams = postsParams
+  }
+
+  public getCreatedPostsIds(): PostId[] {
+    if (!this.events.length) {
+      throw new Error('Trying to get created posts ids before they were created!')
+    }
+    return this.events.map((e) => e.postId)
+  }
+
+  protected async getSignerAccountOrAccounts(): Promise<string[]> {
+    return await Promise.all(
+      this.postsParams.map(async ({ asMember }) =>
+        (await this.api.query.members.membershipById(asMember)).controller_account.toString()
+      )
+    )
+  }
+
+  public async execute(): Promise<void> {
+    const accounts = await this.getSignerAccountOrAccounts()
+    // Send required funds to accounts (PostDeposit)
+    await Promise.all(accounts.map((a) => this.api.treasuryTransferBalance(a, POST_DEPOSIT)))
+    await super.execute()
+  }
+
+  protected async getExtrinsics(): Promise<SubmittableExtrinsic<'promise'>[]> {
+    return this.postsParams.map((params) =>
+      this.api.tx.forum.addPost(
+        params.asMember,
+        params.categoryId,
+        params.threadId,
+        Utils.getMetadataBytesFromInput(ForumPostMetadata, params.metadata),
+        params.editable === undefined || params.editable
+      )
+    )
+  }
+
+  protected async getEventFromResult(result: ISubmittableResult): Promise<PostAddedEventDetails> {
+    return this.api.retrievePostAddedEventDetails(result)
+  }
+
+  protected getPostExpectedText(postParams: PostParams): string {
+    const expectedMetadata = Utils.getDeserializedMetadataFormInput(ForumPostMetadata, postParams.metadata)
+    const metadataBytes = Utils.getMetadataBytesFromInput(ForumPostMetadata, postParams.metadata)
+    return typeof expectedMetadata?.text === 'string' ? expectedMetadata.text : Utils.bytesToString(metadataBytes)
+  }
+
+  protected assertQueriedPostsAreValid(
+    qPosts: ForumPostFieldsFragment[],
+    qEvents: PostAddedEventFieldsFragment[]
+  ): void {
+    this.events.map((e, i) => {
+      const qPost = qPosts.find((p) => p.id === e.postId.toString())
+      const qEvent = this.findMatchingQueryNodeEvent(e, qEvents)
+      const postParams = this.postsParams[i]
+      const expectedStatus =
+        postParams.editable === undefined || postParams.editable ? 'PostStatusActive' : 'PostStatusLocked'
+      const expectedMetadata = Utils.getDeserializedMetadataFormInput(ForumPostMetadata, postParams.metadata)
+      Utils.assert(qPost, 'Query node: Post not found')
+      assert.equal(qPost.thread.id, postParams.threadId.toString())
+      assert.equal(qPost.author.id, postParams.asMember.toString())
+      assert.equal(qPost.status.__typename, expectedStatus)
+      assert.equal(qPost.text, this.getPostExpectedText(postParams))
+      assert.equal(
+        qPost.repliesTo?.id,
+        postParams.metadata.expectReplyFailure ? undefined : expectedMetadata?.repliesTo?.toString()
+      )
+      Utils.assert(qPost.origin.__typename === 'PostOriginThreadReply', 'Query node: Invalid post origin')
+      Utils.assert(qPost.origin.postAddedEvent, 'Query node: PostAddedEvent missing in post origin')
+      assert.equal(qPost.origin.postAddedEvent.id, qEvent.id)
+    })
+  }
+
+  protected assertQueryNodeEventIsValid(qEvent: PostAddedEventFieldsFragment, i: number): void {
+    const params = this.postsParams[i]
+    assert.equal(qEvent.post.id, this.events[i].postId.toString())
+    assert.equal(qEvent.text, this.getPostExpectedText(params))
+    assert.equal(qEvent.isEditable, params.editable === undefined || params.editable)
+  }
+
+  async runQueryNodeChecks(): Promise<void> {
+    await super.runQueryNodeChecks()
+    // Query the events
+    const qEvents = await this.query.tryQueryWithTimeout(
+      () => this.query.getPostAddedEvents(this.events),
+      (qEvents) => this.assertQueryNodeEventsAreValid(qEvents)
+    )
+
+    // Query the posts
+    const qPosts = await this.query.getPostsByIds(this.events.map((e) => e.postId))
+    this.assertQueriedPostsAreValid(qPosts, qEvents)
+  }
+}

+ 4 - 2
tests/integration-tests/src/fixtures/forum/CreateThreadsFixture.ts

@@ -5,7 +5,6 @@ import { SubmittableExtrinsic } from '@polkadot/api/types'
 import { Utils } from '../../utils'
 import { ISubmittableResult } from '@polkadot/types/types/'
 import { ForumThreadWithPostsFieldsFragment, ThreadCreatedEventFieldsFragment } from '../../graphql/generated/queries'
-import { PostOriginThreadInitial } from '../../graphql/generated/schema'
 import { assert } from 'chai'
 import { StandardizedFixture } from '../../Fixture'
 import { CategoryId, Poll } from '@joystream/types/forum'
@@ -108,7 +107,10 @@ export class CreateThreadsFixture extends StandardizedFixture {
       const initialPost = qThread.posts.find((p) => p.origin.__typename === 'PostOriginThreadInitial')
       Utils.assert(initialPost, "Query node: Thread's initial post not found!")
       assert.equal(initialPost.text, threadParams.text)
-      assert.equal((initialPost.origin as PostOriginThreadInitial).threadCreatedEventId, qEvent.id)
+      Utils.assert(initialPost.origin.__typename === 'PostOriginThreadInitial')
+      // FIXME: Temporarly not working (https://github.com/Joystream/hydra/issues/396)
+      // Utils.assert(initialPost.origin.threadCreatedEvent, 'Query node: Post origin ThreadCreatedEvent ref missing')
+      // assert.equal(initialPost.origin.threadCreatedEvent.id, qEvent.id)
       assert.equal(initialPost.author.id, threadParams.asMember.toString())
       assert.equal(initialPost.status.__typename, 'PostStatusActive')
       if (threadParams.poll) {

+ 2 - 1
tests/integration-tests/src/fixtures/forum/DeleteThreadsFixture.ts

@@ -71,7 +71,8 @@ export class DeleteThreadsFixture extends StandardizedFixture {
       const qEvent = this.findMatchingQueryNodeEvent(e, qEvents)
       Utils.assert(qThread, 'Query node: Thread not found')
       Utils.assert(qThread.status.__typename === expectedStatus, `Invalid thread status. Expected: ${expectedStatus}`)
-      assert.equal(qThread.status.threadDeletedEventId, qEvent.id)
+      Utils.assert(qThread.status.threadDeletedEvent, 'Query node: Missing ThreadDeletedEvent ref')
+      assert.equal(qThread.status.threadDeletedEvent.id, qEvent.id)
     })
   }
 

+ 93 - 0
tests/integration-tests/src/fixtures/forum/MoveThreadsFixture.ts

@@ -0,0 +1,93 @@
+import { Api } from '../../Api'
+import { QueryNodeApi } from '../../QueryNodeApi'
+import { EventDetails } from '../../types'
+import { SubmittableExtrinsic } from '@polkadot/api/types'
+import { Utils } from '../../utils'
+import { ISubmittableResult } from '@polkadot/types/types/'
+import { ForumThreadWithPostsFieldsFragment, ThreadMovedEventFieldsFragment } from '../../graphql/generated/queries'
+import { assert } from 'chai'
+import { CategoryId } from '@joystream/types/forum'
+import { ThreadId } from '@joystream/types/common'
+import _ from 'lodash'
+import { WorkerId } from '@joystream/types/working-group'
+import { WithForumWorkersFixture } from './WithForumWorkersFixture'
+
+export type MoveThreadParams = {
+  categoryId: CategoryId
+  threadId: ThreadId
+  newCategoryId: CategoryId
+  asWorker?: WorkerId
+}
+
+export class MoveThreadsFixture extends WithForumWorkersFixture {
+  protected updates: MoveThreadParams[]
+
+  public constructor(api: Api, query: QueryNodeApi, updates: MoveThreadParams[]) {
+    super(api, query)
+    this.updates = updates
+  }
+
+  protected async getSignerAccountOrAccounts(): Promise<string[]> {
+    return this.getSignersFromInput(this.updates)
+  }
+
+  protected async getExtrinsics(): Promise<SubmittableExtrinsic<'promise'>[]> {
+    return this.updates.map((u) =>
+      this.api.tx.forum.moveThreadToCategory(
+        u.asWorker ? { Moderator: u.asWorker } : { Lead: null },
+        u.categoryId,
+        u.threadId,
+        u.newCategoryId
+      )
+    )
+  }
+
+  protected async getEventFromResult(result: ISubmittableResult): Promise<EventDetails> {
+    return this.api.retrieveForumEventDetails(result, 'ThreadMoved')
+  }
+
+  protected assertQueriedThreadsAreValid(
+    qThreads: ForumThreadWithPostsFieldsFragment[],
+    qEvents: ThreadMovedEventFieldsFragment[]
+  ): void {
+    // Check movedInEvents array
+    this.events.forEach((e, i) => {
+      const update = this.updates[i]
+      const qThread = qThreads.find((t) => t.id === update.threadId.toString())
+      const qEvent = this.findMatchingQueryNodeEvent(e, qEvents)
+      Utils.assert(qThread, 'Query node: Thread not found')
+      assert.include(
+        qThread.movedInEvents.map((e) => e.id),
+        qEvent.id
+      )
+    })
+
+    // Check updated categories (against lastest update per thread)
+    _.uniqBy([...this.updates].reverse(), (v) => v.threadId).map((update) => {
+      const qThread = qThreads.find((t) => t.id === update.threadId.toString())
+      Utils.assert(qThread, 'Query node: Thread not found')
+      assert.equal(qThread.category.id, update.newCategoryId.toString())
+    })
+  }
+
+  protected assertQueryNodeEventIsValid(qEvent: ThreadMovedEventFieldsFragment, i: number): void {
+    const { threadId, categoryId, newCategoryId, asWorker } = this.updates[i]
+    assert.equal(qEvent.thread.id, threadId.toString())
+    assert.equal(qEvent.oldCategory.id, categoryId.toString())
+    assert.equal(qEvent.newCategory.id, newCategoryId.toString())
+    assert.equal(qEvent.actor.id, `forumWorkingGroup-${asWorker ? asWorker.toString() : this.forumLeadId!.toString()}`)
+  }
+
+  async runQueryNodeChecks(): Promise<void> {
+    await super.runQueryNodeChecks()
+    // Query the events
+    const qEvents = await this.query.tryQueryWithTimeout(
+      () => this.query.getThreadMovedEvents(this.events),
+      (qEvents) => this.assertQueryNodeEventsAreValid(qEvents)
+    )
+
+    // Query the threads
+    const qThreads = await this.query.getThreadsWithPostsByIds(this.updates.map((u) => u.threadId))
+    this.assertQueriedThreadsAreValid(qThreads, qEvents)
+  }
+}

+ 5 - 9
tests/integration-tests/src/fixtures/forum/RemoveCategoriesFixture.ts

@@ -8,14 +8,14 @@ import { ISubmittableResult } from '@polkadot/types/types/'
 import { CategoryDeletedEventFieldsFragment, ForumCategoryFieldsFragment } from '../../graphql/generated/queries'
 import { assert } from 'chai'
 import { CategoryId } from '@joystream/types/forum'
-import { WithForumLeadFixture } from './WithForumLeadFixture'
+import { WithForumWorkersFixture } from './WithForumWorkersFixture'
 
 export type CategoryRemovalInput = {
   categoryId: CategoryId
   asWorker?: WorkerId
 }
 
-export class RemoveCategoriesFixture extends WithForumLeadFixture {
+export class RemoveCategoriesFixture extends WithForumWorkersFixture {
   protected removals: CategoryRemovalInput[]
 
   public constructor(api: Api, query: QueryNodeApi, removals: CategoryRemovalInput[]) {
@@ -24,12 +24,7 @@ export class RemoveCategoriesFixture extends WithForumLeadFixture {
   }
 
   protected async getSignerAccountOrAccounts(): Promise<string[]> {
-    return Promise.all(
-      this.removals.map(async (r) => {
-        const workerId = r.asWorker || (await this.getForumLeadId())
-        return (await this.api.query.forumWorkingGroup.workerById(workerId)).role_account_id.toString()
-      })
-    )
+    return this.getSignersFromInput(this.removals)
   }
 
   protected async getExtrinsics(): Promise<SubmittableExtrinsic<'promise'>[]> {
@@ -52,7 +47,8 @@ export class RemoveCategoriesFixture extends WithForumLeadFixture {
       const qEvent = this.findMatchingQueryNodeEvent(e, qEvents)
       Utils.assert(qCategory, 'Query node: Category not found')
       Utils.assert(qCategory.status.__typename === 'CategoryStatusRemoved', 'Invalid category status')
-      assert.equal(qCategory.status.categoryDeletedEventId, qEvent.id)
+      Utils.assert(qCategory.status.categoryDeletedEvent, 'Query node: Missing CategoryDeletedEvent ref')
+      assert.equal(qCategory.status.categoryDeletedEvent.id, qEvent.id)
     })
   }
 

+ 9 - 6
tests/integration-tests/src/fixtures/forum/UpdateCategoriesStatusFixture.ts

@@ -8,7 +8,8 @@ import { ISubmittableResult } from '@polkadot/types/types/'
 import { CategoryUpdatedEventFieldsFragment, ForumCategoryFieldsFragment } from '../../graphql/generated/queries'
 import { assert } from 'chai'
 import { CategoryId } from '@joystream/types/forum'
-import { WithForumLeadFixture } from './WithForumLeadFixture'
+import { WithForumWorkersFixture } from './WithForumWorkersFixture'
+import _ from 'lodash'
 
 export type CategoryStatusUpdate = {
   categoryId: CategoryId
@@ -16,7 +17,7 @@ export type CategoryStatusUpdate = {
   asWorker?: WorkerId
 }
 
-export class UpdateCategoriesStatusFixture extends WithForumLeadFixture {
+export class UpdateCategoriesStatusFixture extends WithForumWorkersFixture {
   protected updates: CategoryStatusUpdate[]
 
   public constructor(api: Api, query: QueryNodeApi, updates: CategoryStatusUpdate[]) {
@@ -51,14 +52,16 @@ export class UpdateCategoriesStatusFixture extends WithForumLeadFixture {
     qCategories: ForumCategoryFieldsFragment[],
     qEvents: CategoryUpdatedEventFieldsFragment[]
   ): void {
-    this.events.map((e, i) => {
-      const update = this.updates[i]
+    // Check against latest update per category
+    _.uniqBy([...this.updates].reverse(), (v) => v.categoryId).map((update) => {
+      const updateIndex = this.updates.indexOf(update)
       const qCategory = qCategories.find((c) => c.id === update.categoryId.toString())
-      const qEvent = this.findMatchingQueryNodeEvent(e, qEvents)
+      const qEvent = this.findMatchingQueryNodeEvent(this.events[updateIndex], qEvents)
       Utils.assert(qCategory, 'Query node: Category not found')
       if (update.archived) {
         Utils.assert(qCategory.status.__typename === 'CategoryStatusArchived', 'Invalid category status')
-        assert.equal(qCategory.status.categoryUpdatedEventId, qEvent.id)
+        Utils.assert(qCategory.status.categoryUpdatedEvent, 'Query node: Missing CategoryUpdatedEvent ref')
+        assert.equal(qCategory.status.categoryUpdatedEvent.id, qEvent.id)
       } else {
         assert.equal(qCategory.status.__typename, 'CategoryStatusActive')
       }

+ 103 - 0
tests/integration-tests/src/fixtures/forum/UpdateThreadTitlesFixture.ts

@@ -0,0 +1,103 @@
+import { Api } from '../../Api'
+import { QueryNodeApi } from '../../QueryNodeApi'
+import { EventDetails, MemberContext } from '../../types'
+import { SubmittableExtrinsic } from '@polkadot/api/types'
+import { Utils } from '../../utils'
+import { ISubmittableResult } from '@polkadot/types/types/'
+import {
+  ForumThreadWithPostsFieldsFragment,
+  ThreadTitleUpdatedEventFieldsFragment,
+} from '../../graphql/generated/queries'
+import { assert } from 'chai'
+import { CategoryId } from '@joystream/types/forum'
+import { StandardizedFixture } from '../../Fixture'
+import { ThreadId } from '@joystream/types/common'
+import _ from 'lodash'
+
+export type ThreadTitleUpdate = {
+  categoryId: CategoryId
+  threadId: ThreadId
+  newTitle: string
+}
+
+export class UpdateThreadTitlesFixture extends StandardizedFixture {
+  protected threadAuthors: MemberContext[] = []
+  protected updates: ThreadTitleUpdate[]
+
+  public constructor(api: Api, query: QueryNodeApi, updates: ThreadTitleUpdate[]) {
+    super(api, query)
+    this.updates = updates
+  }
+
+  protected async loadAuthors(): Promise<void> {
+    this.threadAuthors = await Promise.all(
+      this.updates.map(async (u) => {
+        const thread = await this.api.query.forum.threadById(u.categoryId, u.threadId)
+        const member = await this.api.query.members.membershipById(thread.author_id)
+        return { account: member.controller_account.toString(), memberId: thread.author_id }
+      })
+    )
+  }
+
+  protected async getSignerAccountOrAccounts(): Promise<string[]> {
+    return this.threadAuthors.map((a) => a.account)
+  }
+
+  protected async getExtrinsics(): Promise<SubmittableExtrinsic<'promise'>[]> {
+    return this.updates.map((u, i) =>
+      this.api.tx.forum.editThreadTitle(this.threadAuthors[i].memberId, u.categoryId, u.threadId, u.newTitle)
+    )
+  }
+
+  protected async getEventFromResult(result: ISubmittableResult): Promise<EventDetails> {
+    return this.api.retrieveForumEventDetails(result, 'ThreadTitleUpdated')
+  }
+
+  public async execute(): Promise<void> {
+    await this.loadAuthors()
+    await super.execute()
+  }
+
+  protected assertQueriedThreadsAreValid(
+    qThreads: ForumThreadWithPostsFieldsFragment[],
+    qEvents: ThreadTitleUpdatedEventFieldsFragment[]
+  ): void {
+    // Check titleUpdates array
+    this.events.forEach((e, i) => {
+      const update = this.updates[i]
+      const qThread = qThreads.find((t) => t.id === update.threadId.toString())
+      const qEvent = this.findMatchingQueryNodeEvent(e, qEvents)
+      Utils.assert(qThread, 'Query node: Thread not found')
+      assert.include(
+        qThread.titleUpdates.map((u) => u.id),
+        qEvent.id
+      )
+    })
+
+    // Check updated titles (against lastest update per thread)
+    _.uniqBy([...this.updates].reverse(), (v) => v.threadId).map((update) => {
+      const qThread = qThreads.find((t) => t.id === update.threadId.toString())
+      Utils.assert(qThread, 'Query node: Thread not found')
+      assert.equal(qThread.title, update.newTitle)
+    })
+  }
+
+  protected assertQueryNodeEventIsValid(qEvent: ThreadTitleUpdatedEventFieldsFragment, i: number): void {
+    const { threadId, newTitle } = this.updates[i]
+    assert.equal(qEvent.thread.id, threadId.toString())
+    assert.equal(qEvent.newTitle, newTitle)
+  }
+
+  async runQueryNodeChecks(): Promise<void> {
+    await super.runQueryNodeChecks()
+    // Query the events
+    const qEvents = await this.query.tryQueryWithTimeout(
+      () => this.query.getThreadTitleUpdatedEvents(this.events),
+      (qEvents) => this.assertQueryNodeEventsAreValid(qEvents)
+    )
+
+    // Query the threads
+    const qThreads = await this.query.getThreadsWithPostsByIds(this.updates.map((u) => u.threadId))
+    this.assertQueriedThreadsAreValid(qThreads, qEvents)
+  }
+}

+ 0 - 1
tests/integration-tests/src/fixtures/forum/VoteOnPollFixture.ts

@@ -9,7 +9,6 @@ import { StandardizedFixture } from '../../Fixture'
 import { CategoryId } from '@joystream/types/forum'
 import { MemberId, ThreadId } from '@joystream/types/common'
 import { Utils } from '../../utils'
-import _ from 'lodash'
 
 export type VoteParams = {
   categoryId: CategoryId

+ 10 - 1
tests/integration-tests/src/fixtures/forum/WithForumLeadFixture.ts → tests/integration-tests/src/fixtures/forum/WithForumWorkersFixture.ts

@@ -1,7 +1,7 @@
 import { WorkerId } from '@joystream/types/working-group'
 import { StandardizedFixture } from '../../Fixture'
 
-export abstract class WithForumLeadFixture extends StandardizedFixture {
+export abstract class WithForumWorkersFixture extends StandardizedFixture {
   protected forumLeadId?: WorkerId
 
   protected async getForumLeadId(): Promise<WorkerId> {
@@ -16,4 +16,13 @@ export abstract class WithForumLeadFixture extends StandardizedFixture {
 
     return this.forumLeadId
   }
+
+  protected async getSignersFromInput(input: { asWorker?: WorkerId }[]): Promise<string[]> {
+    return Promise.all(
+      input.map(async (r) => {
+        const workerId = r.asWorker || (await this.getForumLeadId())
+        return (await this.api.query.forumWorkingGroup.workerById(workerId)).role_account_id.toString()
+      })
+    )
+  }
 }

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

@@ -4,3 +4,6 @@ export { RemoveCategoriesFixture, CategoryRemovalInput } from './RemoveCategorie
 export { CreateThreadsFixture, ThreadParams } from './CreateThreadsFixture'
 export { DeleteThreadsFixture, ThreadRemovalInput } from './DeleteThreadsFixture'
 export { VoteOnPollFixture, VoteParams } from './VoteOnPollFixture'
+export { AddPostsFixture, PostParams } from './AddPostsFixture'
+export { UpdateThreadTitlesFixture, ThreadTitleUpdate } from './UpdateThreadTitlesFixture'
+export { MoveThreadsFixture, MoveThreadParams } from './MoveThreadsFixture'

+ 115 - 0
tests/integration-tests/src/flows/forum/posts.ts

@@ -0,0 +1,115 @@
+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'
+
+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()
+
+  // 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()
+
+  // Create posts
+  const posts: PostParams[] = [
+    // Valid cases:
+    {
+      threadId: threadIds[0],
+      categoryId: categoryIds[0],
+      metadata: { value: { text: 'Example post' } },
+      asMember: memberIds[0],
+    },
+    {
+      threadId: threadIds[1],
+      categoryId: categoryIds[0],
+      metadata: { value: { text: 'Non-editable post' } },
+      editable: false,
+      asMember: memberIds[1],
+    },
+    {
+      threadId: threadIds[memberIds.length],
+      categoryId: categoryIds[1],
+      metadata: { value: { text: null } },
+      asMember: memberIds[2],
+    },
+    {
+      threadId: threadIds[memberIds.length + 1],
+      categoryId: categoryIds[1],
+      metadata: { value: { text: '' } },
+      asMember: memberIds[3],
+    },
+    // Invalid cases
+    {
+      threadId: threadIds[0],
+      categoryId: categoryIds[0],
+      metadata: { value: '0x000001000100', expectFailure: true },
+      asMember: memberIds[0],
+    },
+  ]
+
+  const addPostsFixture = new AddPostsFixture(api, query, posts)
+  const addPostsRunner = new FixtureRunner(addPostsFixture)
+  await addPostsRunner.run()
+  const postIds = addPostsFixture.getCreatedPostsIds()
+
+  // Create replies
+  const postReplies: PostParams[] = [
+    // Valid reply case:
+    {
+      threadId: threadIds[0],
+      categoryId: categoryIds[0],
+      metadata: { value: { text: 'Reply post', repliesTo: postIds[0].toNumber() } },
+      asMember: memberIds[1],
+    },
+    // Invalid reply postId case:
+    {
+      threadId: threadIds[0],
+      categoryId: categoryIds[0],
+      metadata: { value: { text: 'Reply post', repliesTo: 999999 }, expectReplyFailure: true },
+      asMember: memberIds[1],
+    },
+  ]
+
+  const addRepliesFixture = new AddPostsFixture(api, query, postReplies)
+  const addRepliesRunner = new FixtureRunner(addRepliesFixture)
+  await addRepliesRunner.run()
+
+  await Promise.all([addPostsFixture.runQueryNodeChecks(), addRepliesRunner.runQueryNodeChecks()])
+
+  debug('Done')
+}

+ 47 - 6
tests/integration-tests/src/flows/forum/threads.ts

@@ -1,7 +1,16 @@
 import { FlowProps } from '../../Flow'
 import Debugger from 'debug'
 import { FixtureRunner } from '../../Fixture'
-import { CategoryParams, CreateCategoriesFixture, DeleteThreadsFixture, ThreadRemovalInput } from '../../fixtures/forum'
+import {
+  CategoryParams,
+  CreateCategoriesFixture,
+  DeleteThreadsFixture,
+  MoveThreadParams,
+  MoveThreadsFixture,
+  ThreadRemovalInput,
+  ThreadTitleUpdate,
+  UpdateThreadTitlesFixture,
+} from '../../fixtures/forum'
 import { CreateThreadsFixture, ThreadParams } from '../../fixtures/forum/CreateThreadsFixture'
 import { BuyMembershipHappyCaseFixture } from '../../fixtures/membership'
 
@@ -44,16 +53,48 @@ export default async function threads({ api, query }: FlowProps): Promise<void>
   await createThreadsRunner.runWithQueryNodeChecks()
   const threadIds = createThreadsFixture.getCreatedThreadsIds()
 
-  // TODO: Threads updates
+  // Update categories
+  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)
+
+  // 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[]
+  )
+
+  const updateThreadTitlesFixture = new UpdateThreadTitlesFixture(api, query, titleUpdates)
+  const updateThreadTitlesRunner = new FixtureRunner(updateThreadTitlesFixture)
+  await updateThreadTitlesRunner.run()
 
   // Remove threads
-  const threadRemovals: ThreadRemovalInput[] = threads.map((t, i) => ({
-    threadId: threadIds[i],
-    categoryId: t.categoryId,
+  const threadRemovals: ThreadRemovalInput[] = threadIds.map((threadId, i) => ({
+    threadId,
+    categoryId: threadCategories[i],
     hide: i >= 1, // Test both cases
   }))
   const removeThreadsFixture = new DeleteThreadsFixture(api, query, threadRemovals)
-  await new FixtureRunner(removeThreadsFixture).runWithQueryNodeChecks()
+  const removeThreadsRunner = new FixtureRunner(removeThreadsFixture)
+  await removeThreadsRunner.run()
+
+  // Run compound query node checks
+  await Promise.all([
+    moveThreadsRunner.runQueryNodeChecks(),
+    updateThreadTitlesRunner.runQueryNodeChecks(),
+    removeThreadsRunner.runQueryNodeChecks(),
+  ])
 
   debug('Done')
 }

+ 269 - 46
tests/integration-tests/src/graphql/generated/queries.ts

@@ -3,43 +3,49 @@ import * as Types from './schema'
 import gql from 'graphql-tag'
 export type ForumCategoryFieldsFragment = {
   id: string
+  createdAt: any
+  updatedAt?: Types.Maybe<any>
   title: string
   description: string
   parent?: Types.Maybe<{ id: string }>
-  threads: Array<{ id: string }>
+  threads: Array<{ id: string; isSticky: boolean }>
   moderators: Array<{ id: string }>
   createdInEvent: { id: string }
   status:
     | { __typename: 'CategoryStatusActive' }
-    | { __typename: 'CategoryStatusArchived'; categoryUpdatedEventId: string }
-    | { __typename: 'CategoryStatusRemoved'; categoryDeletedEventId: string }
+    | { __typename: 'CategoryStatusArchived'; categoryUpdatedEvent?: Types.Maybe<{ id: string }> }
+    | { __typename: 'CategoryStatusRemoved'; categoryDeletedEvent?: Types.Maybe<{ id: string }> }
 }
 
-export type GetCategoriesByIdsQueryVariables = Types.Exact<{
-  ids?: Types.Maybe<Array<Types.Scalars['ID']> | Types.Scalars['ID']>
-}>
-
-export type GetCategoriesByIdsQuery = { forumCategories: Array<ForumCategoryFieldsFragment> }
+export type ForumPostFieldsFragment = {
+  id: string
+  createdAt: any
+  updatedAt?: Types.Maybe<any>
+  text: string
+  author: { id: string }
+  thread: { id: string }
+  repliesTo?: Types.Maybe<{ id: string }>
+  status:
+    | { __typename: 'PostStatusActive' }
+    | { __typename: 'PostStatusLocked'; postDeletedEvent?: Types.Maybe<{ id: string }> }
+    | { __typename: 'PostStatusModerated'; postModeratedEvent?: Types.Maybe<{ id: string }> }
+    | { __typename: 'PostStatusRemoved'; postDeletedEvent?: Types.Maybe<{ id: string }> }
+  origin:
+    | { __typename: 'PostOriginThreadInitial'; threadCreatedEvent?: Types.Maybe<{ id: string }> }
+    | { __typename: 'PostOriginThreadReply'; postAddedEvent?: Types.Maybe<{ id: string }> }
+  edits: Array<{ id: string }>
+  reactions: Array<{ id: string; reaction: Types.PostReaction; member: { id: string } }>
+}
 
 export type ForumThreadWithPostsFieldsFragment = {
   id: string
+  createdAt: any
+  updatedAt?: Types.Maybe<any>
   title: string
   isSticky: boolean
   author: { id: string }
   category: { id: string }
-  posts: Array<{
-    id: string
-    text: string
-    author: { id: string }
-    status:
-      | { __typename: 'PostStatusActive' }
-      | { __typename: 'PostStatusLocked' }
-      | { __typename: 'PostStatusModerated' }
-      | { __typename: 'PostStatusRemoved' }
-    origin:
-      | { __typename: 'PostOriginThreadInitial'; threadCreatedEventId: string }
-      | { __typename: 'PostOriginThreadReply'; postAddedEventId: string }
-  }>
+  posts: Array<ForumPostFieldsFragment>
   poll?: Types.Maybe<{
     description: string
     endTime: any
@@ -48,17 +54,31 @@ export type ForumThreadWithPostsFieldsFragment = {
   createdInEvent: { id: string }
   status:
     | { __typename: 'ThreadStatusActive' }
-    | { __typename: 'ThreadStatusLocked'; threadDeletedEventId: string }
-    | { __typename: 'ThreadStatusModerated'; threadModeratedEventId: string }
-    | { __typename: 'ThreadStatusRemoved'; threadDeletedEventId: string }
+    | { __typename: 'ThreadStatusLocked'; threadDeletedEvent?: Types.Maybe<{ id: string }> }
+    | { __typename: 'ThreadStatusModerated'; threadModeratedEvent?: Types.Maybe<{ id: string }> }
+    | { __typename: 'ThreadStatusRemoved'; threadDeletedEvent?: Types.Maybe<{ id: string }> }
+  titleUpdates: Array<{ id: string }>
+  movedInEvents: Array<{ id: string }>
 }
 
+export type GetCategoriesByIdsQueryVariables = Types.Exact<{
+  ids?: Types.Maybe<Array<Types.Scalars['ID']> | Types.Scalars['ID']>
+}>
+
+export type GetCategoriesByIdsQuery = { forumCategories: Array<ForumCategoryFieldsFragment> }
+
 export type GetThreadsWithPostsByIdsQueryVariables = Types.Exact<{
   ids?: Types.Maybe<Array<Types.Scalars['ID']> | Types.Scalars['ID']>
 }>
 
 export type GetThreadsWithPostsByIdsQuery = { forumThreads: Array<ForumThreadWithPostsFieldsFragment> }
 
+export type GetPostsByIdsQueryVariables = Types.Exact<{
+  ids?: Types.Maybe<Array<Types.Scalars['ID']> | Types.Scalars['ID']>
+}>
+
+export type GetPostsByIdsQuery = { forumPosts: Array<ForumPostFieldsFragment> }
+
 export type CategoryCreatedEventFieldsFragment = {
   id: string
   createdAt: any
@@ -132,6 +152,25 @@ export type GetThreadCreatedEventsByEventIdsQueryVariables = Types.Exact<{
 
 export type GetThreadCreatedEventsByEventIdsQuery = { threadCreatedEvents: Array<ThreadCreatedEventFieldsFragment> }
 
+export type ThreadTitleUpdatedEventFieldsFragment = {
+  id: string
+  createdAt: any
+  inBlock: number
+  network: Types.Network
+  inExtrinsic?: Types.Maybe<string>
+  indexInBlock: number
+  newTitle: string
+  thread: { id: string }
+}
+
+export type GetThreadTitleUpdatedEventsByEventIdsQueryVariables = Types.Exact<{
+  eventIds?: Types.Maybe<Array<Types.Scalars['ID']> | Types.Scalars['ID']>
+}>
+
+export type GetThreadTitleUpdatedEventsByEventIdsQuery = {
+  threadTitleUpdatedEvents: Array<ThreadTitleUpdatedEventFieldsFragment>
+}
+
 export type VoteOnPollEventFieldsFragment = {
   id: string
   createdAt: any
@@ -165,6 +204,43 @@ export type GetThreadDeletedEventsByEventIdsQueryVariables = Types.Exact<{
 
 export type GetThreadDeletedEventsByEventIdsQuery = { threadDeletedEvents: Array<ThreadDeletedEventFieldsFragment> }
 
+export type PostAddedEventFieldsFragment = {
+  id: string
+  createdAt: any
+  inBlock: number
+  network: Types.Network
+  inExtrinsic?: Types.Maybe<string>
+  indexInBlock: number
+  isEditable?: Types.Maybe<boolean>
+  text: string
+  post: { id: string }
+}
+
+export type GetPostAddedEventsByEventIdsQueryVariables = Types.Exact<{
+  eventIds?: Types.Maybe<Array<Types.Scalars['ID']> | Types.Scalars['ID']>
+}>
+
+export type GetPostAddedEventsByEventIdsQuery = { postAddedEvents: Array<PostAddedEventFieldsFragment> }
+
+export type ThreadMovedEventFieldsFragment = {
+  id: string
+  createdAt: any
+  inBlock: number
+  network: Types.Network
+  inExtrinsic?: Types.Maybe<string>
+  indexInBlock: number
+  thread: { id: string }
+  oldCategory: { id: string }
+  newCategory: { id: string }
+  actor: { id: string }
+}
+
+export type GetThreadMovedEventsByEventIdsQueryVariables = Types.Exact<{
+  eventIds?: Types.Maybe<Array<Types.Scalars['ID']> | Types.Scalars['ID']>
+}>
+
+export type GetThreadMovedEventsByEventIdsQuery = { threadMovedEvents: Array<ThreadMovedEventFieldsFragment> }
+
 export type MemberMetadataFieldsFragment = { name?: Types.Maybe<string>; about?: Types.Maybe<string> }
 
 export type MembershipFieldsFragment = {
@@ -1009,6 +1085,8 @@ export type GetBudgetSpendingEventsByEventIdsQuery = { budgetSpendingEvents: Arr
 export const ForumCategoryFields = gql`
   fragment ForumCategoryFields on ForumCategory {
     id
+    createdAt
+    updatedAt
     parent {
       id
     }
@@ -1016,6 +1094,7 @@ export const ForumCategoryFields = gql`
     description
     threads {
       id
+      isSticky
     }
     moderators {
       id
@@ -1026,44 +1105,92 @@ export const ForumCategoryFields = gql`
     status {
       __typename
       ... on CategoryStatusArchived {
-        categoryUpdatedEventId
+        categoryUpdatedEvent {
+          id
+        }
       }
       ... on CategoryStatusRemoved {
-        categoryDeletedEventId
+        categoryDeletedEvent {
+          id
+        }
       }
     }
   }
 `
-export const ForumThreadWithPostsFields = gql`
-  fragment ForumThreadWithPostsFields on ForumThread {
+export const ForumPostFields = gql`
+  fragment ForumPostFields on ForumPost {
     id
+    createdAt
+    updatedAt
+    text
     author {
       id
     }
-    category {
+    thread {
       id
     }
-    title
-    posts {
+    repliesTo {
       id
-      text
-      author {
-        id
+    }
+    text
+    status {
+      __typename
+      ... on PostStatusLocked {
+        postDeletedEvent {
+          id
+        }
       }
-      text
-      status {
-        __typename
+      ... on PostStatusModerated {
+        postModeratedEvent {
+          id
+        }
+      }
+      ... on PostStatusRemoved {
+        postDeletedEvent {
+          id
+        }
       }
-      origin {
-        __typename
-        ... on PostOriginThreadInitial {
-          threadCreatedEventId
+    }
+    origin {
+      __typename
+      ... on PostOriginThreadInitial {
+        threadCreatedEvent {
+          id
         }
-        ... on PostOriginThreadReply {
-          postAddedEventId
+      }
+      ... on PostOriginThreadReply {
+        postAddedEvent {
+          id
         }
       }
     }
+    edits {
+      id
+    }
+    reactions {
+      id
+      member {
+        id
+      }
+      reaction
+    }
+  }
+`
+export const ForumThreadWithPostsFields = gql`
+  fragment ForumThreadWithPostsFields on ForumThread {
+    id
+    createdAt
+    updatedAt
+    author {
+      id
+    }
+    category {
+      id
+    }
+    title
+    posts {
+      ...ForumPostFields
+    }
     poll {
       description
       endTime
@@ -1084,16 +1211,29 @@ export const ForumThreadWithPostsFields = gql`
     status {
       __typename
       ... on ThreadStatusLocked {
-        threadDeletedEventId
+        threadDeletedEvent {
+          id
+        }
       }
       ... on ThreadStatusModerated {
-        threadModeratedEventId
+        threadModeratedEvent {
+          id
+        }
       }
       ... on ThreadStatusRemoved {
-        threadDeletedEventId
+        threadDeletedEvent {
+          id
+        }
       }
     }
+    titleUpdates {
+      id
+    }
+    movedInEvents {
+      id
+    }
   }
+  ${ForumPostFields}
 `
 export const CategoryCreatedEventFields = gql`
   fragment CategoryCreatedEventFields on CategoryCreatedEvent {
@@ -1154,6 +1294,20 @@ export const ThreadCreatedEventFields = gql`
     }
   }
 `
+export const ThreadTitleUpdatedEventFields = gql`
+  fragment ThreadTitleUpdatedEventFields on ThreadTitleUpdatedEvent {
+    id
+    createdAt
+    inBlock
+    network
+    inExtrinsic
+    indexInBlock
+    thread {
+      id
+    }
+    newTitle
+  }
+`
 export const VoteOnPollEventFields = gql`
   fragment VoteOnPollEventFields on VoteOnPollEvent {
     id
@@ -1190,6 +1344,43 @@ export const ThreadDeletedEventFields = gql`
     }
   }
 `
+export const PostAddedEventFields = gql`
+  fragment PostAddedEventFields on PostAddedEvent {
+    id
+    createdAt
+    inBlock
+    network
+    inExtrinsic
+    indexInBlock
+    post {
+      id
+    }
+    isEditable
+    text
+  }
+`
+export const ThreadMovedEventFields = gql`
+  fragment ThreadMovedEventFields on ThreadMovedEvent {
+    id
+    createdAt
+    inBlock
+    network
+    inExtrinsic
+    indexInBlock
+    thread {
+      id
+    }
+    oldCategory {
+      id
+    }
+    newCategory {
+      id
+    }
+    actor {
+      id
+    }
+  }
+`
 export const MemberMetadataFields = gql`
   fragment MemberMetadataFields on MemberMetadata {
     name
@@ -2005,6 +2196,14 @@ export const GetThreadsWithPostsByIds = gql`
   }
   ${ForumThreadWithPostsFields}
 `
+export const GetPostsByIds = gql`
+  query getPostsByIds($ids: [ID!]) {
+    forumPosts(where: { id_in: $ids }) {
+      ...ForumPostFields
+    }
+  }
+  ${ForumPostFields}
+`
 export const GetCategoryCreatedEventsByEventIds = gql`
   query getCategoryCreatedEventsByEventIds($eventIds: [ID!]) {
     categoryCreatedEvents(where: { id_in: $eventIds }) {
@@ -2037,6 +2236,14 @@ export const GetThreadCreatedEventsByEventIds = gql`
   }
   ${ThreadCreatedEventFields}
 `
+export const GetThreadTitleUpdatedEventsByEventIds = gql`
+  query getThreadTitleUpdatedEventsByEventIds($eventIds: [ID!]) {
+    threadTitleUpdatedEvents(where: { id_in: $eventIds }) {
+      ...ThreadTitleUpdatedEventFields
+    }
+  }
+  ${ThreadTitleUpdatedEventFields}
+`
 export const GetVoteOnPollEventsByEventIds = gql`
   query getVoteOnPollEventsByEventIds($eventIds: [ID!]) {
     voteOnPollEvents(where: { id_in: $eventIds }) {
@@ -2053,6 +2260,22 @@ export const GetThreadDeletedEventsByEventIds = gql`
   }
   ${ThreadDeletedEventFields}
 `
+export const GetPostAddedEventsByEventIds = gql`
+  query getPostAddedEventsByEventIds($eventIds: [ID!]) {
+    postAddedEvents(where: { id_in: $eventIds }) {
+      ...PostAddedEventFields
+    }
+  }
+  ${PostAddedEventFields}
+`
+export const GetThreadMovedEventsByEventIds = gql`
+  query getThreadMovedEventsByEventIds($eventIds: [ID!]) {
+    threadMovedEvents(where: { id_in: $eventIds }) {
+      ...ThreadMovedEventFields
+    }
+  }
+  ${ThreadMovedEventFields}
+`
 export const GetMemberById = gql`
   query getMemberById($id: ID!) {
     membershipByUniqueInput(where: { id: $id }) {

+ 24 - 484
tests/integration-tests/src/graphql/generated/schema.ts

@@ -1370,105 +1370,13 @@ export type CategoryStatusActiveWhereUniqueInput = {
 }
 
 export type CategoryStatusArchived = {
-  /** Id of the event the category was archived in */
-  categoryUpdatedEventId: Scalars['String']
-}
-
-export type CategoryStatusArchivedCreateInput = {
-  categoryUpdatedEventId: Scalars['String']
-}
-
-export type CategoryStatusArchivedUpdateInput = {
-  categoryUpdatedEventId?: Maybe<Scalars['String']>
-}
-
-export type CategoryStatusArchivedWhereInput = {
-  id_eq?: Maybe<Scalars['ID']>
-  id_in?: Maybe<Array<Scalars['ID']>>
-  createdAt_eq?: Maybe<Scalars['DateTime']>
-  createdAt_lt?: Maybe<Scalars['DateTime']>
-  createdAt_lte?: Maybe<Scalars['DateTime']>
-  createdAt_gt?: Maybe<Scalars['DateTime']>
-  createdAt_gte?: Maybe<Scalars['DateTime']>
-  createdById_eq?: Maybe<Scalars['ID']>
-  createdById_in?: Maybe<Array<Scalars['ID']>>
-  updatedAt_eq?: Maybe<Scalars['DateTime']>
-  updatedAt_lt?: Maybe<Scalars['DateTime']>
-  updatedAt_lte?: Maybe<Scalars['DateTime']>
-  updatedAt_gt?: Maybe<Scalars['DateTime']>
-  updatedAt_gte?: Maybe<Scalars['DateTime']>
-  updatedById_eq?: Maybe<Scalars['ID']>
-  updatedById_in?: Maybe<Array<Scalars['ID']>>
-  deletedAt_all?: Maybe<Scalars['Boolean']>
-  deletedAt_eq?: Maybe<Scalars['DateTime']>
-  deletedAt_lt?: Maybe<Scalars['DateTime']>
-  deletedAt_lte?: Maybe<Scalars['DateTime']>
-  deletedAt_gt?: Maybe<Scalars['DateTime']>
-  deletedAt_gte?: Maybe<Scalars['DateTime']>
-  deletedById_eq?: Maybe<Scalars['ID']>
-  deletedById_in?: Maybe<Array<Scalars['ID']>>
-  categoryUpdatedEventId_eq?: Maybe<Scalars['String']>
-  categoryUpdatedEventId_contains?: Maybe<Scalars['String']>
-  categoryUpdatedEventId_startsWith?: Maybe<Scalars['String']>
-  categoryUpdatedEventId_endsWith?: Maybe<Scalars['String']>
-  categoryUpdatedEventId_in?: Maybe<Array<Scalars['String']>>
-  AND?: Maybe<Array<CategoryStatusArchivedWhereInput>>
-  OR?: Maybe<Array<CategoryStatusArchivedWhereInput>>
-}
-
-export type CategoryStatusArchivedWhereUniqueInput = {
-  id: Scalars['ID']
+  /** Event the category was archived in */
+  categoryUpdatedEvent?: Maybe<CategoryUpdatedEvent>
 }
 
 export type CategoryStatusRemoved = {
-  /** Id of the event the category was deleted in */
-  categoryDeletedEventId: Scalars['String']
-}
-
-export type CategoryStatusRemovedCreateInput = {
-  categoryDeletedEventId: Scalars['String']
-}
-
-export type CategoryStatusRemovedUpdateInput = {
-  categoryDeletedEventId?: Maybe<Scalars['String']>
-}
-
-export type CategoryStatusRemovedWhereInput = {
-  id_eq?: Maybe<Scalars['ID']>
-  id_in?: Maybe<Array<Scalars['ID']>>
-  createdAt_eq?: Maybe<Scalars['DateTime']>
-  createdAt_lt?: Maybe<Scalars['DateTime']>
-  createdAt_lte?: Maybe<Scalars['DateTime']>
-  createdAt_gt?: Maybe<Scalars['DateTime']>
-  createdAt_gte?: Maybe<Scalars['DateTime']>
-  createdById_eq?: Maybe<Scalars['ID']>
-  createdById_in?: Maybe<Array<Scalars['ID']>>
-  updatedAt_eq?: Maybe<Scalars['DateTime']>
-  updatedAt_lt?: Maybe<Scalars['DateTime']>
-  updatedAt_lte?: Maybe<Scalars['DateTime']>
-  updatedAt_gt?: Maybe<Scalars['DateTime']>
-  updatedAt_gte?: Maybe<Scalars['DateTime']>
-  updatedById_eq?: Maybe<Scalars['ID']>
-  updatedById_in?: Maybe<Array<Scalars['ID']>>
-  deletedAt_all?: Maybe<Scalars['Boolean']>
-  deletedAt_eq?: Maybe<Scalars['DateTime']>
-  deletedAt_lt?: Maybe<Scalars['DateTime']>
-  deletedAt_lte?: Maybe<Scalars['DateTime']>
-  deletedAt_gt?: Maybe<Scalars['DateTime']>
-  deletedAt_gte?: Maybe<Scalars['DateTime']>
-  deletedById_eq?: Maybe<Scalars['ID']>
-  deletedById_in?: Maybe<Array<Scalars['ID']>>
-  categoryDeletedEventId_eq?: Maybe<Scalars['String']>
-  categoryDeletedEventId_contains?: Maybe<Scalars['String']>
-  categoryDeletedEventId_startsWith?: Maybe<Scalars['String']>
-  categoryDeletedEventId_endsWith?: Maybe<Scalars['String']>
-  categoryDeletedEventId_in?: Maybe<Array<Scalars['String']>>
-  AND?: Maybe<Array<CategoryStatusRemovedWhereInput>>
-  OR?: Maybe<Array<CategoryStatusRemovedWhereInput>>
-}
-
-export type CategoryStatusRemovedWhereUniqueInput = {
-  id: Scalars['ID']
+  /** Event the category was deleted in */
+  categoryDeletedEvent?: Maybe<CategoryDeletedEvent>
 }
 
 export type CategoryStickyThreadUpdateEvent = BaseGraphQlObject & {
@@ -2912,11 +2820,11 @@ export type ForumThread = BaseGraphQlObject & {
   createdInEvent: ThreadCreatedEvent
   /** Current thread status */
   status: ThreadStatus
+  titleUpdates: Array<ThreadTitleUpdatedEvent>
   madeStickyInEvents: Array<CategoryStickyThreadUpdateEvent>
   movedInEvents: Array<ThreadMovedEvent>
   threaddeletedeventthread?: Maybe<Array<ThreadDeletedEvent>>
   threadmoderatedeventthread?: Maybe<Array<ThreadModeratedEvent>>
-  threadtitleupdatedeventthread?: Maybe<Array<ThreadTitleUpdatedEvent>>
 }
 
 export type ForumThreadConnection = {
@@ -3007,6 +2915,9 @@ export type ForumThreadWhereInput = {
   posts_every?: Maybe<ForumPostWhereInput>
   poll?: Maybe<ForumPollWhereInput>
   createdInEvent?: Maybe<ThreadCreatedEventWhereInput>
+  titleUpdates_none?: Maybe<ThreadTitleUpdatedEventWhereInput>
+  titleUpdates_some?: Maybe<ThreadTitleUpdatedEventWhereInput>
+  titleUpdates_every?: Maybe<ThreadTitleUpdatedEventWhereInput>
   madeStickyInEvents_none?: Maybe<CategoryStickyThreadUpdateEventWhereInput>
   madeStickyInEvents_some?: Maybe<CategoryStickyThreadUpdateEventWhereInput>
   madeStickyInEvents_every?: Maybe<CategoryStickyThreadUpdateEventWhereInput>
@@ -3019,9 +2930,6 @@ export type ForumThreadWhereInput = {
   threadmoderatedeventthread_none?: Maybe<ThreadModeratedEventWhereInput>
   threadmoderatedeventthread_some?: Maybe<ThreadModeratedEventWhereInput>
   threadmoderatedeventthread_every?: Maybe<ThreadModeratedEventWhereInput>
-  threadtitleupdatedeventthread_none?: Maybe<ThreadTitleUpdatedEventWhereInput>
-  threadtitleupdatedeventthread_some?: Maybe<ThreadTitleUpdatedEventWhereInput>
-  threadtitleupdatedeventthread_every?: Maybe<ThreadTitleUpdatedEventWhereInput>
   AND?: Maybe<Array<ForumThreadWhereInput>>
   OR?: Maybe<Array<ForumThreadWhereInput>>
 }
@@ -6366,105 +6274,13 @@ export type PostModeratedEventWhereUniqueInput = {
 export type PostOrigin = PostOriginThreadInitial | PostOriginThreadReply
 
 export type PostOriginThreadInitial = {
-  /** Id of the related thread creation event */
-  threadCreatedEventId: Scalars['String']
-}
-
-export type PostOriginThreadInitialCreateInput = {
-  threadCreatedEventId: Scalars['String']
-}
-
-export type PostOriginThreadInitialUpdateInput = {
-  threadCreatedEventId?: Maybe<Scalars['String']>
-}
-
-export type PostOriginThreadInitialWhereInput = {
-  id_eq?: Maybe<Scalars['ID']>
-  id_in?: Maybe<Array<Scalars['ID']>>
-  createdAt_eq?: Maybe<Scalars['DateTime']>
-  createdAt_lt?: Maybe<Scalars['DateTime']>
-  createdAt_lte?: Maybe<Scalars['DateTime']>
-  createdAt_gt?: Maybe<Scalars['DateTime']>
-  createdAt_gte?: Maybe<Scalars['DateTime']>
-  createdById_eq?: Maybe<Scalars['ID']>
-  createdById_in?: Maybe<Array<Scalars['ID']>>
-  updatedAt_eq?: Maybe<Scalars['DateTime']>
-  updatedAt_lt?: Maybe<Scalars['DateTime']>
-  updatedAt_lte?: Maybe<Scalars['DateTime']>
-  updatedAt_gt?: Maybe<Scalars['DateTime']>
-  updatedAt_gte?: Maybe<Scalars['DateTime']>
-  updatedById_eq?: Maybe<Scalars['ID']>
-  updatedById_in?: Maybe<Array<Scalars['ID']>>
-  deletedAt_all?: Maybe<Scalars['Boolean']>
-  deletedAt_eq?: Maybe<Scalars['DateTime']>
-  deletedAt_lt?: Maybe<Scalars['DateTime']>
-  deletedAt_lte?: Maybe<Scalars['DateTime']>
-  deletedAt_gt?: Maybe<Scalars['DateTime']>
-  deletedAt_gte?: Maybe<Scalars['DateTime']>
-  deletedById_eq?: Maybe<Scalars['ID']>
-  deletedById_in?: Maybe<Array<Scalars['ID']>>
-  threadCreatedEventId_eq?: Maybe<Scalars['String']>
-  threadCreatedEventId_contains?: Maybe<Scalars['String']>
-  threadCreatedEventId_startsWith?: Maybe<Scalars['String']>
-  threadCreatedEventId_endsWith?: Maybe<Scalars['String']>
-  threadCreatedEventId_in?: Maybe<Array<Scalars['String']>>
-  AND?: Maybe<Array<PostOriginThreadInitialWhereInput>>
-  OR?: Maybe<Array<PostOriginThreadInitialWhereInput>>
-}
-
-export type PostOriginThreadInitialWhereUniqueInput = {
-  id: Scalars['ID']
+  /** Thread creation event */
+  threadCreatedEvent?: Maybe<ThreadCreatedEvent>
 }
 
 export type PostOriginThreadReply = {
-  /** Id of the related post added event */
-  postAddedEventId: Scalars['String']
-}
-
-export type PostOriginThreadReplyCreateInput = {
-  postAddedEventId: Scalars['String']
-}
-
-export type PostOriginThreadReplyUpdateInput = {
-  postAddedEventId?: Maybe<Scalars['String']>
-}
-
-export type PostOriginThreadReplyWhereInput = {
-  id_eq?: Maybe<Scalars['ID']>
-  id_in?: Maybe<Array<Scalars['ID']>>
-  createdAt_eq?: Maybe<Scalars['DateTime']>
-  createdAt_lt?: Maybe<Scalars['DateTime']>
-  createdAt_lte?: Maybe<Scalars['DateTime']>
-  createdAt_gt?: Maybe<Scalars['DateTime']>
-  createdAt_gte?: Maybe<Scalars['DateTime']>
-  createdById_eq?: Maybe<Scalars['ID']>
-  createdById_in?: Maybe<Array<Scalars['ID']>>
-  updatedAt_eq?: Maybe<Scalars['DateTime']>
-  updatedAt_lt?: Maybe<Scalars['DateTime']>
-  updatedAt_lte?: Maybe<Scalars['DateTime']>
-  updatedAt_gt?: Maybe<Scalars['DateTime']>
-  updatedAt_gte?: Maybe<Scalars['DateTime']>
-  updatedById_eq?: Maybe<Scalars['ID']>
-  updatedById_in?: Maybe<Array<Scalars['ID']>>
-  deletedAt_all?: Maybe<Scalars['Boolean']>
-  deletedAt_eq?: Maybe<Scalars['DateTime']>
-  deletedAt_lt?: Maybe<Scalars['DateTime']>
-  deletedAt_lte?: Maybe<Scalars['DateTime']>
-  deletedAt_gt?: Maybe<Scalars['DateTime']>
-  deletedAt_gte?: Maybe<Scalars['DateTime']>
-  deletedById_eq?: Maybe<Scalars['ID']>
-  deletedById_in?: Maybe<Array<Scalars['ID']>>
-  postAddedEventId_eq?: Maybe<Scalars['String']>
-  postAddedEventId_contains?: Maybe<Scalars['String']>
-  postAddedEventId_startsWith?: Maybe<Scalars['String']>
-  postAddedEventId_endsWith?: Maybe<Scalars['String']>
-  postAddedEventId_in?: Maybe<Array<Scalars['String']>>
-  AND?: Maybe<Array<PostOriginThreadReplyWhereInput>>
-  OR?: Maybe<Array<PostOriginThreadReplyWhereInput>>
-}
-
-export type PostOriginThreadReplyWhereUniqueInput = {
-  id: Scalars['ID']
+  /** Related PostAdded event */
+  postAddedEvent?: Maybe<PostAddedEvent>
 }
 
 export type PostReactedEvent = BaseGraphQlObject & {
@@ -6673,156 +6489,18 @@ export type PostStatusActiveWhereUniqueInput = {
 }
 
 export type PostStatusLocked = {
-  /** Post deleted event id in case the post became locked through runtime removal */
-  postDeletedEventId?: Maybe<Scalars['String']>
-}
-
-export type PostStatusLockedCreateInput = {
-  postDeletedEventId?: Maybe<Scalars['String']>
-}
-
-export type PostStatusLockedUpdateInput = {
-  postDeletedEventId?: Maybe<Scalars['String']>
-}
-
-export type PostStatusLockedWhereInput = {
-  id_eq?: Maybe<Scalars['ID']>
-  id_in?: Maybe<Array<Scalars['ID']>>
-  createdAt_eq?: Maybe<Scalars['DateTime']>
-  createdAt_lt?: Maybe<Scalars['DateTime']>
-  createdAt_lte?: Maybe<Scalars['DateTime']>
-  createdAt_gt?: Maybe<Scalars['DateTime']>
-  createdAt_gte?: Maybe<Scalars['DateTime']>
-  createdById_eq?: Maybe<Scalars['ID']>
-  createdById_in?: Maybe<Array<Scalars['ID']>>
-  updatedAt_eq?: Maybe<Scalars['DateTime']>
-  updatedAt_lt?: Maybe<Scalars['DateTime']>
-  updatedAt_lte?: Maybe<Scalars['DateTime']>
-  updatedAt_gt?: Maybe<Scalars['DateTime']>
-  updatedAt_gte?: Maybe<Scalars['DateTime']>
-  updatedById_eq?: Maybe<Scalars['ID']>
-  updatedById_in?: Maybe<Array<Scalars['ID']>>
-  deletedAt_all?: Maybe<Scalars['Boolean']>
-  deletedAt_eq?: Maybe<Scalars['DateTime']>
-  deletedAt_lt?: Maybe<Scalars['DateTime']>
-  deletedAt_lte?: Maybe<Scalars['DateTime']>
-  deletedAt_gt?: Maybe<Scalars['DateTime']>
-  deletedAt_gte?: Maybe<Scalars['DateTime']>
-  deletedById_eq?: Maybe<Scalars['ID']>
-  deletedById_in?: Maybe<Array<Scalars['ID']>>
-  postDeletedEventId_eq?: Maybe<Scalars['String']>
-  postDeletedEventId_contains?: Maybe<Scalars['String']>
-  postDeletedEventId_startsWith?: Maybe<Scalars['String']>
-  postDeletedEventId_endsWith?: Maybe<Scalars['String']>
-  postDeletedEventId_in?: Maybe<Array<Scalars['String']>>
-  AND?: Maybe<Array<PostStatusLockedWhereInput>>
-  OR?: Maybe<Array<PostStatusLockedWhereInput>>
-}
-
-export type PostStatusLockedWhereUniqueInput = {
-  id: Scalars['ID']
+  /** Post deleted event in case the post became locked through runtime removal */
+  postDeletedEvent?: Maybe<PostDeletedEvent>
 }
 
 export type PostStatusModerated = {
-  /** Id of the event the post was moderated in */
-  postModeratedEventId: Scalars['String']
-}
-
-export type PostStatusModeratedCreateInput = {
-  postModeratedEventId: Scalars['String']
-}
-
-export type PostStatusModeratedUpdateInput = {
-  postModeratedEventId?: Maybe<Scalars['String']>
-}
-
-export type PostStatusModeratedWhereInput = {
-  id_eq?: Maybe<Scalars['ID']>
-  id_in?: Maybe<Array<Scalars['ID']>>
-  createdAt_eq?: Maybe<Scalars['DateTime']>
-  createdAt_lt?: Maybe<Scalars['DateTime']>
-  createdAt_lte?: Maybe<Scalars['DateTime']>
-  createdAt_gt?: Maybe<Scalars['DateTime']>
-  createdAt_gte?: Maybe<Scalars['DateTime']>
-  createdById_eq?: Maybe<Scalars['ID']>
-  createdById_in?: Maybe<Array<Scalars['ID']>>
-  updatedAt_eq?: Maybe<Scalars['DateTime']>
-  updatedAt_lt?: Maybe<Scalars['DateTime']>
-  updatedAt_lte?: Maybe<Scalars['DateTime']>
-  updatedAt_gt?: Maybe<Scalars['DateTime']>
-  updatedAt_gte?: Maybe<Scalars['DateTime']>
-  updatedById_eq?: Maybe<Scalars['ID']>
-  updatedById_in?: Maybe<Array<Scalars['ID']>>
-  deletedAt_all?: Maybe<Scalars['Boolean']>
-  deletedAt_eq?: Maybe<Scalars['DateTime']>
-  deletedAt_lt?: Maybe<Scalars['DateTime']>
-  deletedAt_lte?: Maybe<Scalars['DateTime']>
-  deletedAt_gt?: Maybe<Scalars['DateTime']>
-  deletedAt_gte?: Maybe<Scalars['DateTime']>
-  deletedById_eq?: Maybe<Scalars['ID']>
-  deletedById_in?: Maybe<Array<Scalars['ID']>>
-  postModeratedEventId_eq?: Maybe<Scalars['String']>
-  postModeratedEventId_contains?: Maybe<Scalars['String']>
-  postModeratedEventId_startsWith?: Maybe<Scalars['String']>
-  postModeratedEventId_endsWith?: Maybe<Scalars['String']>
-  postModeratedEventId_in?: Maybe<Array<Scalars['String']>>
-  AND?: Maybe<Array<PostStatusModeratedWhereInput>>
-  OR?: Maybe<Array<PostStatusModeratedWhereInput>>
-}
-
-export type PostStatusModeratedWhereUniqueInput = {
-  id: Scalars['ID']
+  /** Event the post was moderated in */
+  postModeratedEvent?: Maybe<PostModeratedEvent>
 }
 
 export type PostStatusRemoved = {
-  /** Id of the event the post was removed in */
-  postDeletedEventId: Scalars['String']
-}
-
-export type PostStatusRemovedCreateInput = {
-  postDeletedEventId: Scalars['String']
-}
-
-export type PostStatusRemovedUpdateInput = {
-  postDeletedEventId?: Maybe<Scalars['String']>
-}
-
-export type PostStatusRemovedWhereInput = {
-  id_eq?: Maybe<Scalars['ID']>
-  id_in?: Maybe<Array<Scalars['ID']>>
-  createdAt_eq?: Maybe<Scalars['DateTime']>
-  createdAt_lt?: Maybe<Scalars['DateTime']>
-  createdAt_lte?: Maybe<Scalars['DateTime']>
-  createdAt_gt?: Maybe<Scalars['DateTime']>
-  createdAt_gte?: Maybe<Scalars['DateTime']>
-  createdById_eq?: Maybe<Scalars['ID']>
-  createdById_in?: Maybe<Array<Scalars['ID']>>
-  updatedAt_eq?: Maybe<Scalars['DateTime']>
-  updatedAt_lt?: Maybe<Scalars['DateTime']>
-  updatedAt_lte?: Maybe<Scalars['DateTime']>
-  updatedAt_gt?: Maybe<Scalars['DateTime']>
-  updatedAt_gte?: Maybe<Scalars['DateTime']>
-  updatedById_eq?: Maybe<Scalars['ID']>
-  updatedById_in?: Maybe<Array<Scalars['ID']>>
-  deletedAt_all?: Maybe<Scalars['Boolean']>
-  deletedAt_eq?: Maybe<Scalars['DateTime']>
-  deletedAt_lt?: Maybe<Scalars['DateTime']>
-  deletedAt_lte?: Maybe<Scalars['DateTime']>
-  deletedAt_gt?: Maybe<Scalars['DateTime']>
-  deletedAt_gte?: Maybe<Scalars['DateTime']>
-  deletedById_eq?: Maybe<Scalars['ID']>
-  deletedById_in?: Maybe<Array<Scalars['ID']>>
-  postDeletedEventId_eq?: Maybe<Scalars['String']>
-  postDeletedEventId_contains?: Maybe<Scalars['String']>
-  postDeletedEventId_startsWith?: Maybe<Scalars['String']>
-  postDeletedEventId_endsWith?: Maybe<Scalars['String']>
-  postDeletedEventId_in?: Maybe<Array<Scalars['String']>>
-  AND?: Maybe<Array<PostStatusRemovedWhereInput>>
-  OR?: Maybe<Array<PostStatusRemovedWhereInput>>
-}
-
-export type PostStatusRemovedWhereUniqueInput = {
-  id: Scalars['ID']
+  /** Event the post was removed in */
+  postDeletedEvent?: Maybe<PostDeletedEvent>
 }
 
 export type PostTextUpdatedEvent = BaseGraphQlObject & {
@@ -10858,156 +10536,18 @@ export type ThreadStatusActiveWhereUniqueInput = {
 }
 
 export type ThreadStatusLocked = {
-  /** Id of the event the thread was deleted (locked) in */
-  threadDeletedEventId: Scalars['String']
-}
-
-export type ThreadStatusLockedCreateInput = {
-  threadDeletedEventId: Scalars['String']
-}
-
-export type ThreadStatusLockedUpdateInput = {
-  threadDeletedEventId?: Maybe<Scalars['String']>
-}
-
-export type ThreadStatusLockedWhereInput = {
-  id_eq?: Maybe<Scalars['ID']>
-  id_in?: Maybe<Array<Scalars['ID']>>
-  createdAt_eq?: Maybe<Scalars['DateTime']>
-  createdAt_lt?: Maybe<Scalars['DateTime']>
-  createdAt_lte?: Maybe<Scalars['DateTime']>
-  createdAt_gt?: Maybe<Scalars['DateTime']>
-  createdAt_gte?: Maybe<Scalars['DateTime']>
-  createdById_eq?: Maybe<Scalars['ID']>
-  createdById_in?: Maybe<Array<Scalars['ID']>>
-  updatedAt_eq?: Maybe<Scalars['DateTime']>
-  updatedAt_lt?: Maybe<Scalars['DateTime']>
-  updatedAt_lte?: Maybe<Scalars['DateTime']>
-  updatedAt_gt?: Maybe<Scalars['DateTime']>
-  updatedAt_gte?: Maybe<Scalars['DateTime']>
-  updatedById_eq?: Maybe<Scalars['ID']>
-  updatedById_in?: Maybe<Array<Scalars['ID']>>
-  deletedAt_all?: Maybe<Scalars['Boolean']>
-  deletedAt_eq?: Maybe<Scalars['DateTime']>
-  deletedAt_lt?: Maybe<Scalars['DateTime']>
-  deletedAt_lte?: Maybe<Scalars['DateTime']>
-  deletedAt_gt?: Maybe<Scalars['DateTime']>
-  deletedAt_gte?: Maybe<Scalars['DateTime']>
-  deletedById_eq?: Maybe<Scalars['ID']>
-  deletedById_in?: Maybe<Array<Scalars['ID']>>
-  threadDeletedEventId_eq?: Maybe<Scalars['String']>
-  threadDeletedEventId_contains?: Maybe<Scalars['String']>
-  threadDeletedEventId_startsWith?: Maybe<Scalars['String']>
-  threadDeletedEventId_endsWith?: Maybe<Scalars['String']>
-  threadDeletedEventId_in?: Maybe<Array<Scalars['String']>>
-  AND?: Maybe<Array<ThreadStatusLockedWhereInput>>
-  OR?: Maybe<Array<ThreadStatusLockedWhereInput>>
-}
-
-export type ThreadStatusLockedWhereUniqueInput = {
-  id: Scalars['ID']
+  /** Event the thread was deleted (locked) in */
+  threadDeletedEvent?: Maybe<ThreadDeletedEvent>
 }
 
 export type ThreadStatusModerated = {
-  /** Id of the event the thread was moderated in */
-  threadModeratedEventId: Scalars['String']
-}
-
-export type ThreadStatusModeratedCreateInput = {
-  threadModeratedEventId: Scalars['String']
-}
-
-export type ThreadStatusModeratedUpdateInput = {
-  threadModeratedEventId?: Maybe<Scalars['String']>
-}
-
-export type ThreadStatusModeratedWhereInput = {
-  id_eq?: Maybe<Scalars['ID']>
-  id_in?: Maybe<Array<Scalars['ID']>>
-  createdAt_eq?: Maybe<Scalars['DateTime']>
-  createdAt_lt?: Maybe<Scalars['DateTime']>
-  createdAt_lte?: Maybe<Scalars['DateTime']>
-  createdAt_gt?: Maybe<Scalars['DateTime']>
-  createdAt_gte?: Maybe<Scalars['DateTime']>
-  createdById_eq?: Maybe<Scalars['ID']>
-  createdById_in?: Maybe<Array<Scalars['ID']>>
-  updatedAt_eq?: Maybe<Scalars['DateTime']>
-  updatedAt_lt?: Maybe<Scalars['DateTime']>
-  updatedAt_lte?: Maybe<Scalars['DateTime']>
-  updatedAt_gt?: Maybe<Scalars['DateTime']>
-  updatedAt_gte?: Maybe<Scalars['DateTime']>
-  updatedById_eq?: Maybe<Scalars['ID']>
-  updatedById_in?: Maybe<Array<Scalars['ID']>>
-  deletedAt_all?: Maybe<Scalars['Boolean']>
-  deletedAt_eq?: Maybe<Scalars['DateTime']>
-  deletedAt_lt?: Maybe<Scalars['DateTime']>
-  deletedAt_lte?: Maybe<Scalars['DateTime']>
-  deletedAt_gt?: Maybe<Scalars['DateTime']>
-  deletedAt_gte?: Maybe<Scalars['DateTime']>
-  deletedById_eq?: Maybe<Scalars['ID']>
-  deletedById_in?: Maybe<Array<Scalars['ID']>>
-  threadModeratedEventId_eq?: Maybe<Scalars['String']>
-  threadModeratedEventId_contains?: Maybe<Scalars['String']>
-  threadModeratedEventId_startsWith?: Maybe<Scalars['String']>
-  threadModeratedEventId_endsWith?: Maybe<Scalars['String']>
-  threadModeratedEventId_in?: Maybe<Array<Scalars['String']>>
-  AND?: Maybe<Array<ThreadStatusModeratedWhereInput>>
-  OR?: Maybe<Array<ThreadStatusModeratedWhereInput>>
-}
-
-export type ThreadStatusModeratedWhereUniqueInput = {
-  id: Scalars['ID']
+  /** Event the thread was moderated in */
+  threadModeratedEvent?: Maybe<ThreadModeratedEvent>
 }
 
 export type ThreadStatusRemoved = {
-  /** Id of the event the thread was removed in */
-  threadDeletedEventId: Scalars['String']
-}
-
-export type ThreadStatusRemovedCreateInput = {
-  threadDeletedEventId: Scalars['String']
-}
-
-export type ThreadStatusRemovedUpdateInput = {
-  threadDeletedEventId?: Maybe<Scalars['String']>
-}
-
-export type ThreadStatusRemovedWhereInput = {
-  id_eq?: Maybe<Scalars['ID']>
-  id_in?: Maybe<Array<Scalars['ID']>>
-  createdAt_eq?: Maybe<Scalars['DateTime']>
-  createdAt_lt?: Maybe<Scalars['DateTime']>
-  createdAt_lte?: Maybe<Scalars['DateTime']>
-  createdAt_gt?: Maybe<Scalars['DateTime']>
-  createdAt_gte?: Maybe<Scalars['DateTime']>
-  createdById_eq?: Maybe<Scalars['ID']>
-  createdById_in?: Maybe<Array<Scalars['ID']>>
-  updatedAt_eq?: Maybe<Scalars['DateTime']>
-  updatedAt_lt?: Maybe<Scalars['DateTime']>
-  updatedAt_lte?: Maybe<Scalars['DateTime']>
-  updatedAt_gt?: Maybe<Scalars['DateTime']>
-  updatedAt_gte?: Maybe<Scalars['DateTime']>
-  updatedById_eq?: Maybe<Scalars['ID']>
-  updatedById_in?: Maybe<Array<Scalars['ID']>>
-  deletedAt_all?: Maybe<Scalars['Boolean']>
-  deletedAt_eq?: Maybe<Scalars['DateTime']>
-  deletedAt_lt?: Maybe<Scalars['DateTime']>
-  deletedAt_lte?: Maybe<Scalars['DateTime']>
-  deletedAt_gt?: Maybe<Scalars['DateTime']>
-  deletedAt_gte?: Maybe<Scalars['DateTime']>
-  deletedById_eq?: Maybe<Scalars['ID']>
-  deletedById_in?: Maybe<Array<Scalars['ID']>>
-  threadDeletedEventId_eq?: Maybe<Scalars['String']>
-  threadDeletedEventId_contains?: Maybe<Scalars['String']>
-  threadDeletedEventId_startsWith?: Maybe<Scalars['String']>
-  threadDeletedEventId_endsWith?: Maybe<Scalars['String']>
-  threadDeletedEventId_in?: Maybe<Array<Scalars['String']>>
-  AND?: Maybe<Array<ThreadStatusRemovedWhereInput>>
-  OR?: Maybe<Array<ThreadStatusRemovedWhereInput>>
-}
-
-export type ThreadStatusRemovedWhereUniqueInput = {
-  id: Scalars['ID']
+  /** Event the thread was removed in */
+  threadDeletedEvent?: Maybe<ThreadDeletedEvent>
 }
 
 export type ThreadTitleUpdatedEvent = BaseGraphQlObject & {

+ 93 - 26
tests/integration-tests/src/graphql/queries/forum.graphql

@@ -1,5 +1,7 @@
 fragment ForumCategoryFields on ForumCategory {
   id
+  createdAt
+  updatedAt
   parent {
     id
   }
@@ -17,22 +19,80 @@ fragment ForumCategoryFields on ForumCategory {
   status {
     __typename
     ... on CategoryStatusArchived {
-      categoryUpdatedEventId
+      categoryUpdatedEvent {
+        id
+      }
     }
     ... on CategoryStatusRemoved {
-      categoryDeletedEventId
+      categoryDeletedEvent {
+        id
+      }
     }
   }
 }
 
-query getCategoriesByIds($ids: [ID!]) {
-  forumCategories(where: { id_in: $ids }) {
-    ...ForumCategoryFields
+fragment ForumPostFields on ForumPost {
+  id
+  createdAt
+  updatedAt
+  text
+  author {
+    id
+  }
+  thread {
+    id
+  }
+  repliesTo {
+    id
+  }
+  text
+  status {
+    __typename
+    ... on PostStatusLocked {
+      postDeletedEvent {
+        id
+      }
+    }
+    ... on PostStatusModerated {
+      postModeratedEvent {
+        id
+      }
+    }
+    ... on PostStatusRemoved {
+      postDeletedEvent {
+        id
+      }
+    }
+  }
+  origin {
+    __typename
+    ... on PostOriginThreadInitial {
+      threadCreatedEvent {
+        id
+      }
+    }
+    ... on PostOriginThreadReply {
+      postAddedEvent {
+        id
+      }
+    }
+  }
+  edits {
+    id
+  }
+  reactions {
+    id
+    member {
+      id
+    }
+    reaction
   }
 }
 
 fragment ForumThreadWithPostsFields on ForumThread {
   id
+  createdAt
+  updatedAt
   author {
     id
   }
@@ -41,24 +101,7 @@ fragment ForumThreadWithPostsFields on ForumThread {
   }
   title
   posts {
-    id
-    text
-    author {
-      id
-    }
-    text
-    status {
-      __typename
-    }
-    origin {
-      __typename
-      ... on PostOriginThreadInitial {
-        threadCreatedEventId
-      }
-      ... on PostOriginThreadReply {
-        postAddedEventId
-      }
-    }
+    ...ForumPostFields
   }
   poll {
     description
@@ -80,15 +123,33 @@ fragment ForumThreadWithPostsFields on ForumThread {
   status {
     __typename
     ... on ThreadStatusLocked {
-      threadDeletedEventId
+      threadDeletedEvent {
+        id
+      }
     }
     ... on ThreadStatusModerated {
-      threadModeratedEventId
+      threadModeratedEvent {
+        id
+      }
     }
     ... on ThreadStatusRemoved {
-      threadDeletedEventId
+      threadDeletedEvent {
+        id
+      }
     }
   }
+  titleUpdates {
+    id
+  }
+  movedInEvents {
+    id
+  }
+}
+
+query getCategoriesByIds($ids: [ID!]) {
+  forumCategories(where: { id_in: $ids }) {
+    ...ForumCategoryFields
+  }
 }
 
 query getThreadsWithPostsByIds($ids: [ID!]) {
@@ -96,3 +157,9 @@ query getThreadsWithPostsByIds($ids: [ID!]) {
     ...ForumThreadWithPostsFields
   }
 }
+
+query getPostsByIds($ids: [ID!]) {
+  forumPosts(where: { id_in: $ids }) {
+    ...ForumPostFields
+  }
+}

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

@@ -77,6 +77,25 @@ query getThreadCreatedEventsByEventIds($eventIds: [ID!]) {
   }
 }
 
+fragment ThreadTitleUpdatedEventFields on ThreadTitleUpdatedEvent {
+  id
+  createdAt
+  inBlock
+  network
+  inExtrinsic
+  indexInBlock
+  thread {
+    id
+  }
+  newTitle
+}
+
+query getThreadTitleUpdatedEventsByEventIds($eventIds: [ID!]) {
+  threadTitleUpdatedEvents(where: { id_in: $eventIds }) {
+    ...ThreadTitleUpdatedEventFields
+  }
+}
+
 fragment VoteOnPollEventFields on VoteOnPollEvent {
   id
   createdAt
@@ -122,3 +141,50 @@ query getThreadDeletedEventsByEventIds($eventIds: [ID!]) {
     ...ThreadDeletedEventFields
   }
 }
+
+fragment PostAddedEventFields on PostAddedEvent {
+  id
+  createdAt
+  inBlock
+  network
+  inExtrinsic
+  indexInBlock
+  post {
+    id
+  }
+  isEditable
+  text
+}
+
+query getPostAddedEventsByEventIds($eventIds: [ID!]) {
+  postAddedEvents(where: { id_in: $eventIds }) {
+    ...PostAddedEventFields
+  }
+}
+
+fragment ThreadMovedEventFields on ThreadMovedEvent {
+  id
+  createdAt
+  inBlock
+  network
+  inExtrinsic
+  indexInBlock
+  thread {
+    id
+  }
+  oldCategory {
+    id
+  }
+  newCategory {
+    id
+  }
+  actor {
+    id
+  }
+}
+
+query getThreadMovedEventsByEventIds($eventIds: [ID!]) {
+  threadMovedEvents(where: { id_in: $eventIds }) {
+    ...ThreadMovedEventFields
+  }
+}

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

@@ -1,6 +1,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 leadOpening from '../flows/working-groups/leadOpening'
 import creatingMemberships from '../flows/membership/creatingMemberships'
 import updatingMemberProfile from '../flows/membership/updatingProfile'
@@ -37,4 +38,5 @@ 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)
 })

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

@@ -22,6 +22,11 @@ export type MemberContext = {
   memberId: MemberId
 }
 
+export type MetadataInput<T> = {
+  value: T | string
+  expectFailure?: boolean
+}
+
 // Membership
 
 export interface MembershipBoughtEventDetails extends EventDetails {

+ 34 - 0
tests/integration-tests/src/utils.ts

@@ -6,6 +6,7 @@ import fs from 'fs'
 import { decodeAddress } from '@polkadot/keyring'
 import { Bytes } from '@polkadot/types'
 import { createType } from '@joystream/types'
+import { MetadataInput } from './types'
 
 export type AnyMessage<T> = T & {
   toJSON(): Record<string, unknown>
@@ -66,6 +67,39 @@ export class Utils {
     return metaClass.toObject(metaClass.decode(bytes.toU8a(true))) as T
   }
 
+  public static getDeserializedMetadataFormInput<T>(
+    metadataClass: AnyMetadataClass<T>,
+    input: MetadataInput<T>
+  ): T | null {
+    if (typeof input.value === 'string') {
+      try {
+        return Utils.metadataFromBytes(metadataClass, createType('Bytes', input.value))
+      } catch (e) {
+        if (!input.expectFailure) {
+          throw e
+        }
+        return null
+      }
+    }
+
+    return input.value
+  }
+
+  public static getMetadataBytesFromInput<T>(metadataClass: AnyMetadataClass<T>, input: MetadataInput<T>): Bytes {
+    return typeof input.value === 'string'
+      ? createType('Bytes', input.value)
+      : Utils.metadataToBytes(metadataClass, input.value)
+  }
+
+  public static bytesToString(b: Bytes): string {
+    return (
+      Buffer.from(b.toU8a(true))
+        .toString()
+        // eslint-disable-next-line no-control-regex
+        .replace(/\u0000/g, '')
+    )
+  }
+
   public static assert(condition: any, msg?: string): asserts condition {
     if (!condition) {
       throw new Error(msg || 'Assertion failed')