Bladeren bron

Proposals discussion mappings & tests

Leszek Wiesner 3 jaren geleden
bovenliggende
commit
e5bef130a6
30 gewijzigde bestanden met toevoegingen van 1718 en 219 verwijderingen
  1. 96 0
      metadata-protobuf/compiled/index.d.ts
  2. 210 0
      metadata-protobuf/compiled/index.js
  3. 6 0
      metadata-protobuf/proto/ProposalsDiscussion.proto
  4. 2 2
      package.json
  5. 1 0
      query-node/manifest.yml
  6. 9 1
      query-node/mappings/common.ts
  7. 22 14
      query-node/mappings/proposals.ts
  8. 149 14
      query-node/mappings/proposalsDiscussion.ts
  9. 3 0
      query-node/schemas/proposalDiscussion.graphql
  10. 0 24
      query-node/schemas/proposalDiscussionEvents.graphql
  11. 24 1
      tests/integration-tests/src/Api.ts
  12. 83 1
      tests/integration-tests/src/QueryNodeApi.ts
  13. 2 0
      tests/integration-tests/src/consts.ts
  14. 1 0
      tests/integration-tests/src/fixtures/proposals/CreateProposalsFixture.ts
  15. 103 0
      tests/integration-tests/src/fixtures/proposalsDiscussion/ChangeThreadsModeFixture.ts
  16. 122 0
      tests/integration-tests/src/fixtures/proposalsDiscussion/CreatePostsFixture.ts
  17. 85 0
      tests/integration-tests/src/fixtures/proposalsDiscussion/DeletePostsFixture.ts
  18. 81 0
      tests/integration-tests/src/fixtures/proposalsDiscussion/UpdatePostsFixture.ts
  19. 4 0
      tests/integration-tests/src/fixtures/proposalsDiscussion/index.ts
  20. 127 0
      tests/integration-tests/src/flows/proposalsDiscussion/index.ts
  21. 346 0
      tests/integration-tests/src/graphql/generated/queries.ts
  22. 4 157
      tests/integration-tests/src/graphql/generated/schema.ts
  23. 6 0
      tests/integration-tests/src/graphql/queries/proposals.graphql
  24. 75 0
      tests/integration-tests/src/graphql/queries/proposalsDiscussion.graphql
  25. 82 0
      tests/integration-tests/src/graphql/queries/proposalsDiscussionEvents.graphql
  26. 3 1
      tests/integration-tests/src/scenarios/full.ts
  27. 9 3
      tests/integration-tests/src/scenarios/proposals.ts
  28. 8 0
      tests/integration-tests/src/scenarios/proposalsDiscussion.ts
  29. 17 1
      tests/integration-tests/src/types.ts
  30. 38 0
      tests/integration-tests/src/utils.ts

+ 96 - 0
metadata-protobuf/compiled/index.d.ts

@@ -209,6 +209,102 @@ export class MembershipMetadata implements IMembershipMetadata {
     public toJSON(): { [k: string]: any };
 }
 
+/** Properties of a ProposalsDiscussionPostMetadata. */
+export interface IProposalsDiscussionPostMetadata {
+
+    /** ProposalsDiscussionPostMetadata text */
+    text?: (string|null);
+
+    /** ProposalsDiscussionPostMetadata repliesTo */
+    repliesTo?: (number|null);
+}
+
+/** Represents a ProposalsDiscussionPostMetadata. */
+export class ProposalsDiscussionPostMetadata implements IProposalsDiscussionPostMetadata {
+
+    /**
+     * Constructs a new ProposalsDiscussionPostMetadata.
+     * @param [properties] Properties to set
+     */
+    constructor(properties?: IProposalsDiscussionPostMetadata);
+
+    /** ProposalsDiscussionPostMetadata text. */
+    public text: string;
+
+    /** ProposalsDiscussionPostMetadata repliesTo. */
+    public repliesTo: number;
+
+    /**
+     * Creates a new ProposalsDiscussionPostMetadata instance using the specified properties.
+     * @param [properties] Properties to set
+     * @returns ProposalsDiscussionPostMetadata instance
+     */
+    public static create(properties?: IProposalsDiscussionPostMetadata): ProposalsDiscussionPostMetadata;
+
+    /**
+     * Encodes the specified ProposalsDiscussionPostMetadata message. Does not implicitly {@link ProposalsDiscussionPostMetadata.verify|verify} messages.
+     * @param message ProposalsDiscussionPostMetadata message or plain object to encode
+     * @param [writer] Writer to encode to
+     * @returns Writer
+     */
+    public static encode(message: IProposalsDiscussionPostMetadata, writer?: $protobuf.Writer): $protobuf.Writer;
+
+    /**
+     * Encodes the specified ProposalsDiscussionPostMetadata message, length delimited. Does not implicitly {@link ProposalsDiscussionPostMetadata.verify|verify} messages.
+     * @param message ProposalsDiscussionPostMetadata message or plain object to encode
+     * @param [writer] Writer to encode to
+     * @returns Writer
+     */
+    public static encodeDelimited(message: IProposalsDiscussionPostMetadata, writer?: $protobuf.Writer): $protobuf.Writer;
+
+    /**
+     * Decodes a ProposalsDiscussionPostMetadata message from the specified reader or buffer.
+     * @param reader Reader or buffer to decode from
+     * @param [length] Message length if known beforehand
+     * @returns ProposalsDiscussionPostMetadata
+     * @throws {Error} If the payload is not a reader or valid buffer
+     * @throws {$protobuf.util.ProtocolError} If required fields are missing
+     */
+    public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): ProposalsDiscussionPostMetadata;
+
+    /**
+     * Decodes a ProposalsDiscussionPostMetadata message from the specified reader or buffer, length delimited.
+     * @param reader Reader or buffer to decode from
+     * @returns ProposalsDiscussionPostMetadata
+     * @throws {Error} If the payload is not a reader or valid buffer
+     * @throws {$protobuf.util.ProtocolError} If required fields are missing
+     */
+    public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): ProposalsDiscussionPostMetadata;
+
+    /**
+     * Verifies a ProposalsDiscussionPostMetadata message.
+     * @param message Plain object to verify
+     * @returns `null` if valid, otherwise the reason why it is not
+     */
+    public static verify(message: { [k: string]: any }): (string|null);
+
+    /**
+     * Creates a ProposalsDiscussionPostMetadata message from a plain object. Also converts values to their respective internal types.
+     * @param object Plain object
+     * @returns ProposalsDiscussionPostMetadata
+     */
+    public static fromObject(object: { [k: string]: any }): ProposalsDiscussionPostMetadata;
+
+    /**
+     * Creates a plain object from a ProposalsDiscussionPostMetadata message. Also converts values to other types if specified.
+     * @param message ProposalsDiscussionPostMetadata
+     * @param [options] Conversion options
+     * @returns Plain object
+     */
+    public static toObject(message: ProposalsDiscussionPostMetadata, options?: $protobuf.IConversionOptions): { [k: string]: any };
+
+    /**
+     * Converts this ProposalsDiscussionPostMetadata to JSON.
+     * @returns JSON object
+     */
+    public toJSON(): { [k: string]: any };
+}
+
 /** Properties of an OpeningMetadata. */
 export interface IOpeningMetadata {
 

+ 210 - 0
metadata-protobuf/compiled/index.js

@@ -512,6 +512,216 @@ $root.MembershipMetadata = (function() {
     return MembershipMetadata;
 })();
 
+$root.ProposalsDiscussionPostMetadata = (function() {
+
+    /**
+     * Properties of a ProposalsDiscussionPostMetadata.
+     * @exports IProposalsDiscussionPostMetadata
+     * @interface IProposalsDiscussionPostMetadata
+     * @property {string|null} [text] ProposalsDiscussionPostMetadata text
+     * @property {number|null} [repliesTo] ProposalsDiscussionPostMetadata repliesTo
+     */
+
+    /**
+     * Constructs a new ProposalsDiscussionPostMetadata.
+     * @exports ProposalsDiscussionPostMetadata
+     * @classdesc Represents a ProposalsDiscussionPostMetadata.
+     * @implements IProposalsDiscussionPostMetadata
+     * @constructor
+     * @param {IProposalsDiscussionPostMetadata=} [properties] Properties to set
+     */
+    function ProposalsDiscussionPostMetadata(properties) {
+        if (properties)
+            for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i)
+                if (properties[keys[i]] != null)
+                    this[keys[i]] = properties[keys[i]];
+    }
+
+    /**
+     * ProposalsDiscussionPostMetadata text.
+     * @member {string} text
+     * @memberof ProposalsDiscussionPostMetadata
+     * @instance
+     */
+    ProposalsDiscussionPostMetadata.prototype.text = "";
+
+    /**
+     * ProposalsDiscussionPostMetadata repliesTo.
+     * @member {number} repliesTo
+     * @memberof ProposalsDiscussionPostMetadata
+     * @instance
+     */
+    ProposalsDiscussionPostMetadata.prototype.repliesTo = 0;
+
+    /**
+     * Creates a new ProposalsDiscussionPostMetadata instance using the specified properties.
+     * @function create
+     * @memberof ProposalsDiscussionPostMetadata
+     * @static
+     * @param {IProposalsDiscussionPostMetadata=} [properties] Properties to set
+     * @returns {ProposalsDiscussionPostMetadata} ProposalsDiscussionPostMetadata instance
+     */
+    ProposalsDiscussionPostMetadata.create = function create(properties) {
+        return new ProposalsDiscussionPostMetadata(properties);
+    };
+
+    /**
+     * Encodes the specified ProposalsDiscussionPostMetadata message. Does not implicitly {@link ProposalsDiscussionPostMetadata.verify|verify} messages.
+     * @function encode
+     * @memberof ProposalsDiscussionPostMetadata
+     * @static
+     * @param {IProposalsDiscussionPostMetadata} message ProposalsDiscussionPostMetadata message or plain object to encode
+     * @param {$protobuf.Writer} [writer] Writer to encode to
+     * @returns {$protobuf.Writer} Writer
+     */
+    ProposalsDiscussionPostMetadata.encode = function encode(message, writer) {
+        if (!writer)
+            writer = $Writer.create();
+        if (message.text != null && Object.hasOwnProperty.call(message, "text"))
+            writer.uint32(/* id 1, wireType 2 =*/10).string(message.text);
+        if (message.repliesTo != null && Object.hasOwnProperty.call(message, "repliesTo"))
+            writer.uint32(/* id 2, wireType 0 =*/16).uint32(message.repliesTo);
+        return writer;
+    };
+
+    /**
+     * Encodes the specified ProposalsDiscussionPostMetadata message, length delimited. Does not implicitly {@link ProposalsDiscussionPostMetadata.verify|verify} messages.
+     * @function encodeDelimited
+     * @memberof ProposalsDiscussionPostMetadata
+     * @static
+     * @param {IProposalsDiscussionPostMetadata} message ProposalsDiscussionPostMetadata message or plain object to encode
+     * @param {$protobuf.Writer} [writer] Writer to encode to
+     * @returns {$protobuf.Writer} Writer
+     */
+    ProposalsDiscussionPostMetadata.encodeDelimited = function encodeDelimited(message, writer) {
+        return this.encode(message, writer).ldelim();
+    };
+
+    /**
+     * Decodes a ProposalsDiscussionPostMetadata message from the specified reader or buffer.
+     * @function decode
+     * @memberof ProposalsDiscussionPostMetadata
+     * @static
+     * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from
+     * @param {number} [length] Message length if known beforehand
+     * @returns {ProposalsDiscussionPostMetadata} ProposalsDiscussionPostMetadata
+     * @throws {Error} If the payload is not a reader or valid buffer
+     * @throws {$protobuf.util.ProtocolError} If required fields are missing
+     */
+    ProposalsDiscussionPostMetadata.decode = function decode(reader, length) {
+        if (!(reader instanceof $Reader))
+            reader = $Reader.create(reader);
+        var end = length === undefined ? reader.len : reader.pos + length, message = new $root.ProposalsDiscussionPostMetadata();
+        while (reader.pos < end) {
+            var tag = reader.uint32();
+            switch (tag >>> 3) {
+            case 1:
+                message.text = reader.string();
+                break;
+            case 2:
+                message.repliesTo = reader.uint32();
+                break;
+            default:
+                reader.skipType(tag & 7);
+                break;
+            }
+        }
+        return message;
+    };
+
+    /**
+     * Decodes a ProposalsDiscussionPostMetadata message from the specified reader or buffer, length delimited.
+     * @function decodeDelimited
+     * @memberof ProposalsDiscussionPostMetadata
+     * @static
+     * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from
+     * @returns {ProposalsDiscussionPostMetadata} ProposalsDiscussionPostMetadata
+     * @throws {Error} If the payload is not a reader or valid buffer
+     * @throws {$protobuf.util.ProtocolError} If required fields are missing
+     */
+    ProposalsDiscussionPostMetadata.decodeDelimited = function decodeDelimited(reader) {
+        if (!(reader instanceof $Reader))
+            reader = new $Reader(reader);
+        return this.decode(reader, reader.uint32());
+    };
+
+    /**
+     * Verifies a ProposalsDiscussionPostMetadata message.
+     * @function verify
+     * @memberof ProposalsDiscussionPostMetadata
+     * @static
+     * @param {Object.<string,*>} message Plain object to verify
+     * @returns {string|null} `null` if valid, otherwise the reason why it is not
+     */
+    ProposalsDiscussionPostMetadata.verify = function verify(message) {
+        if (typeof message !== "object" || message === null)
+            return "object expected";
+        if (message.text != null && message.hasOwnProperty("text"))
+            if (!$util.isString(message.text))
+                return "text: string expected";
+        if (message.repliesTo != null && message.hasOwnProperty("repliesTo"))
+            if (!$util.isInteger(message.repliesTo))
+                return "repliesTo: integer expected";
+        return null;
+    };
+
+    /**
+     * Creates a ProposalsDiscussionPostMetadata message from a plain object. Also converts values to their respective internal types.
+     * @function fromObject
+     * @memberof ProposalsDiscussionPostMetadata
+     * @static
+     * @param {Object.<string,*>} object Plain object
+     * @returns {ProposalsDiscussionPostMetadata} ProposalsDiscussionPostMetadata
+     */
+    ProposalsDiscussionPostMetadata.fromObject = function fromObject(object) {
+        if (object instanceof $root.ProposalsDiscussionPostMetadata)
+            return object;
+        var message = new $root.ProposalsDiscussionPostMetadata();
+        if (object.text != null)
+            message.text = String(object.text);
+        if (object.repliesTo != null)
+            message.repliesTo = object.repliesTo >>> 0;
+        return message;
+    };
+
+    /**
+     * Creates a plain object from a ProposalsDiscussionPostMetadata message. Also converts values to other types if specified.
+     * @function toObject
+     * @memberof ProposalsDiscussionPostMetadata
+     * @static
+     * @param {ProposalsDiscussionPostMetadata} message ProposalsDiscussionPostMetadata
+     * @param {$protobuf.IConversionOptions} [options] Conversion options
+     * @returns {Object.<string,*>} Plain object
+     */
+    ProposalsDiscussionPostMetadata.toObject = function toObject(message, options) {
+        if (!options)
+            options = {};
+        var object = {};
+        if (options.defaults) {
+            object.text = "";
+            object.repliesTo = 0;
+        }
+        if (message.text != null && message.hasOwnProperty("text"))
+            object.text = message.text;
+        if (message.repliesTo != null && message.hasOwnProperty("repliesTo"))
+            object.repliesTo = message.repliesTo;
+        return object;
+    };
+
+    /**
+     * Converts this ProposalsDiscussionPostMetadata to JSON.
+     * @function toJSON
+     * @memberof ProposalsDiscussionPostMetadata
+     * @instance
+     * @returns {Object.<string,*>} JSON object
+     */
+    ProposalsDiscussionPostMetadata.prototype.toJSON = function toJSON() {
+        return this.constructor.toObject(this, $protobuf.util.toJSONOptions);
+    };
+
+    return ProposalsDiscussionPostMetadata;
+})();
+
 $root.OpeningMetadata = (function() {
 
     /**

+ 6 - 0
metadata-protobuf/proto/ProposalsDiscussion.proto

@@ -0,0 +1,6 @@
+syntax = "proto2";
+
+message ProposalsDiscussionPostMetadata {
+  optional string text = 1; // Post text content (md-formatted)
+  optional uint32 repliesTo = 2; // Id of the post that given post replies to (if any)
+}

+ 2 - 2
package.json

@@ -60,11 +60,11 @@
     }
   },
   "engines": {
-    "node": ">=12.18.0",
+    "node": ">=14.17.1",
     "yarn": "^1.22.0"
   },
   "volta": {
-    "node": "12.18.2",
+    "node": "14.17.1",
     "yarn": "1.22.4"
   }
 }

+ 1 - 0
query-node/manifest.yml

@@ -65,6 +65,7 @@ typegen:
   calls:
     - members.updateProfile
     - members.updateAccounts
+    - proposalsDiscussion.addPost
   outDir: ./mappings/generated/types
   customTypes:
     lib: '@joystream/types/augment/all/types'

+ 9 - 1
query-node/mappings/common.ts

@@ -2,11 +2,19 @@ import { SubstrateEvent } from '@dzlzv/hydra-common'
 import { Network } from 'query-node/dist/src/modules/enums/enums'
 import { Event } from 'query-node/dist/src/modules/event/event.model'
 import { Bytes } from '@polkadot/types'
-import { WorkingGroup } from '@joystream/types/augment/all'
+import { WorkingGroup, ProposalId, ThreadId } from '@joystream/types/augment/all'
 import { BaseModel } from 'warthog'
 
 export const CURRENT_NETWORK = Network.OLYMPIA
 
+// FIXME: See issues like: https://github.com/Joystream/joystream/issues/2457
+type MappingsMemoryCache = {
+  lastCreatedProposalId?: ProposalId
+  lastCreatedProposalThreadId?: ThreadId
+}
+
+export const MemoryCache: MappingsMemoryCache = {}
+
 export function genericEventFields(substrateEvent: SubstrateEvent): Partial<BaseModel & Event> {
   const { blockNumber, indexInBlock, extrinsic, blockTimestamp } = substrateEvent
   const eventTime = new Date(blockTimestamp)

+ 22 - 14
query-node/mappings/proposals.ts

@@ -2,7 +2,7 @@
 eslint-disable @typescript-eslint/naming-convention
 */
 import { SubstrateEvent, DatabaseManager, EventContext, StoreContext } from '@dzlzv/hydra-common'
-import { ProposalDetails as RuntimeProposalDetails, ProposalId } from '@joystream/types/augment/all'
+import { ProposalDetails as RuntimeProposalDetails } from '@joystream/types/augment/all'
 import BN from 'bn.js'
 import {
   Proposal,
@@ -57,21 +57,15 @@ import {
   ProposalCancelledEvent,
   ProposalCreatedEvent,
   RuntimeWasmBytecode,
+  ProposalDiscussionThread,
+  ProposalDiscussionThreadModeOpen,
 } from 'query-node/dist/model'
-import { bytesToString, genericEventFields, getWorkingGroupModuleName, perpareString } from './common'
+import { bytesToString, genericEventFields, getWorkingGroupModuleName, MemoryCache, perpareString } from './common'
 import { ProposalsEngine, ProposalsCodex } from './generated/types'
 import { createWorkingGroupOpeningMetadata } from './workingGroups'
 import { blake2AsHex } from '@polkadot/util-crypto'
 import { Bytes } from '@polkadot/types'
 
-// FIXME: https://github.com/Joystream/joystream/issues/2457
-type ProposalsMappingsMemoryCache = {
-  lastCreatedProposalId: ProposalId | null
-}
-const proposalsMappingsMemoryCache: ProposalsMappingsMemoryCache = {
-  lastCreatedProposalId: null,
-}
-
 async function getProposal(store: DatabaseManager, id: string) {
   const proposal = await store.get(Proposal, { where: { id } })
   if (!proposal) {
@@ -325,7 +319,7 @@ export async function proposalsEngine_ProposalCreated({ event }: EventContext &
   const [, proposalId] = new ProposalsEngine.ProposalCreatedEvent(event).params
 
   // Cache the id
-  proposalsMappingsMemoryCache.lastCreatedProposalId = proposalId
+  MemoryCache.lastCreatedProposalId = proposalId
 }
 
 export async function proposalsCodex_ProposalCreated({ store, event }: EventContext & StoreContext): Promise<void> {
@@ -333,12 +327,16 @@ export async function proposalsCodex_ProposalCreated({ store, event }: EventCont
   const eventTime = new Date(event.blockTimestamp)
   const proposalDetails = await parseProposalDetails(event, store, runtimeProposalDetails)
 
-  if (!proposalsMappingsMemoryCache.lastCreatedProposalId) {
-    throw new Error('Unexpected state: proposalsMappingsMemoryCache.lastCreatedProposalId is empty')
+  if (!MemoryCache.lastCreatedProposalId) {
+    throw new Error('Unexpected state: MemoryCache.lastCreatedProposalId is empty')
+  }
+
+  if (!MemoryCache.lastCreatedProposalThreadId) {
+    throw new Error('Unexpected state: MemoryCache.lastCreatedProposalThreadId is empty')
   }
 
   const proposal = new Proposal({
-    id: proposalsMappingsMemoryCache.lastCreatedProposalId.toString(),
+    id: MemoryCache.lastCreatedProposalId.toString(),
     createdAt: eventTime,
     updatedAt: eventTime,
     details: proposalDetails,
@@ -354,6 +352,16 @@ export async function proposalsCodex_ProposalCreated({ store, event }: EventCont
   })
   await store.save<Proposal>(proposal)
 
+  // Thread is always created along with the proposal
+  const proposalThread = new ProposalDiscussionThread({
+    id: MemoryCache.lastCreatedProposalThreadId.toString(),
+    createdAt: eventTime,
+    updatedAt: eventTime,
+    mode: new ProposalDiscussionThreadModeOpen(),
+    proposal,
+  })
+  await store.save<ProposalDiscussionThread>(proposalThread)
+
   const proposalCreatedEvent = new ProposalCreatedEvent({
     ...genericEventFields(event),
     proposal: proposal,

+ 149 - 14
query-node/mappings/proposalsDiscussion.ts

@@ -1,24 +1,159 @@
 /*
 eslint-disable @typescript-eslint/naming-convention
 */
-import { SubstrateEvent, DatabaseManager } from '@dzlzv/hydra-common'
+import { EventContext, StoreContext, DatabaseManager } from '@dzlzv/hydra-common'
+import {
+  Membership,
+  ProposalDiscussionPostStatusActive,
+  ProposalDiscussionPostStatusLocked,
+  ProposalDiscussionPost,
+  ProposalDiscussionThread,
+  ProposalDiscussionPostCreatedEvent,
+  ProposalDiscussionPostUpdatedEvent,
+  ProposalDiscussionThreadModeClosed,
+  ProposalDiscussionWhitelist,
+  ProposalDiscussionThreadModeOpen,
+  ProposalDiscussionThreadModeChangedEvent,
+  ProposalDiscussionPostDeletedEvent,
+  ProposalDiscussionPostStatusRemoved,
+} from 'query-node/dist/model'
+import { bytesToString, deserializeMetadata, genericEventFields, MemoryCache } from './common'
+import { ProposalsDiscussion } from './generated/types'
+import { ProposalsDiscussionPostMetadata } from '@joystream/metadata-protobuf'
+import { In } from 'typeorm'
 
-export async function proposalsDiscussion_ThreadCreated(db: DatabaseManager, event_: SubstrateEvent): Promise<void> {
-  // TODO
+async function getPost(store: DatabaseManager, id: string) {
+  const post = await store.get(ProposalDiscussionPost, { where: { id } })
+  if (!post) {
+    throw new Error(`Proposal discussion post not found by id: ${id}`)
+  }
+
+  return post
 }
-export async function proposalsDiscussion_PostCreated(db: DatabaseManager, event_: SubstrateEvent): Promise<void> {
-  // TODO
+
+async function getThread(store: DatabaseManager, id: string) {
+  const thread = await store.get(ProposalDiscussionThread, { where: { id } })
+  if (!thread) {
+    throw new Error(`Proposal discussion thread not found by id: ${id}`)
+  }
+
+  return thread
 }
-export async function proposalsDiscussion_PostUpdated(db: DatabaseManager, event_: SubstrateEvent): Promise<void> {
-  // TODO
+
+export async function proposalsDiscussion_ThreadCreated({ event }: EventContext & StoreContext): Promise<void> {
+  const [threadId] = new ProposalsDiscussion.ThreadCreatedEvent(event).params
+  MemoryCache.lastCreatedProposalThreadId = threadId
 }
-export async function proposalsDiscussion_ThreadModeChanged(
-  db: DatabaseManager,
-  event_: SubstrateEvent
-): Promise<void> {
-  // TODO
+
+export async function proposalsDiscussion_PostCreated({ event, store }: EventContext & StoreContext): Promise<void> {
+  const { editable } = new ProposalsDiscussion.AddPostCall(event).args // FIXME: batch and sudo extrinsics handling
+  const [postId, memberId, threadId, metadataBytes] = new ProposalsDiscussion.PostCreatedEvent(event).params
+  const eventTime = new Date(event.blockTimestamp)
+
+  const metadata = deserializeMetadata(ProposalsDiscussionPostMetadata, metadataBytes)
+
+  const repliesTo =
+    typeof metadata?.repliesTo === 'number'
+      ? await store.get(ProposalDiscussionPost, { where: { id: metadata.repliesTo.toString() } })
+      : undefined
+
+  const text = typeof metadata?.text === 'string' ? metadata.text : bytesToString(metadataBytes)
+
+  const post = new ProposalDiscussionPost({
+    id: postId.toString(),
+    createdAt: eventTime,
+    updatedAt: eventTime,
+    author: new Membership({ id: memberId.toString() }),
+    status: editable.isTrue ? new ProposalDiscussionPostStatusActive() : new ProposalDiscussionPostStatusLocked(),
+    text,
+    repliesTo,
+    thread: new ProposalDiscussionThread({ id: threadId.toString() }),
+  })
+  await store.save<ProposalDiscussionPost>(post)
+
+  const postCreatedEvent = new ProposalDiscussionPostCreatedEvent({
+    ...genericEventFields(event),
+    post: post,
+    text,
+  })
+  await store.save<ProposalDiscussionPostCreatedEvent>(postCreatedEvent)
 }
 
-export async function proposalsDiscussion_PostDeleted(db: DatabaseManager, event_: SubstrateEvent): Promise<void> {
-  // TODO
+export async function proposalsDiscussion_PostUpdated({ event, store }: EventContext & StoreContext): Promise<void> {
+  const [postId, , , newTextBytes] = new ProposalsDiscussion.PostUpdatedEvent(event).params
+
+  const post = await getPost(store, postId.toString())
+  const newText = bytesToString(newTextBytes)
+
+  post.text = newText
+  post.updatedAt = new Date(event.blockTimestamp)
+  await store.save<ProposalDiscussionPost>(post)
+
+  const postUpdatedEvent = new ProposalDiscussionPostUpdatedEvent({
+    ...genericEventFields(event),
+    post,
+    text: newText,
+  })
+  await store.save<ProposalDiscussionPostUpdatedEvent>(postUpdatedEvent)
+}
+
+export async function proposalsDiscussion_ThreadModeChanged({
+  event,
+  store,
+}: EventContext & StoreContext): Promise<void> {
+  const [threadId, threadMode, memberId] = new ProposalsDiscussion.ThreadModeChangedEvent(event).params
+  const eventTime = new Date(event.blockTimestamp)
+
+  const thread = await getThread(store, threadId.toString())
+
+  if (threadMode.isClosed) {
+    const newMode = new ProposalDiscussionThreadModeClosed()
+    const whitelistMemberIds = threadMode.asClosed
+    const members = await store.getMany(Membership, {
+      where: { id: In(whitelistMemberIds.map((id) => id.toString())) },
+    })
+    const whitelist = new ProposalDiscussionWhitelist({
+      createdAt: eventTime,
+      updatedAt: eventTime,
+      members,
+    })
+    await store.save<ProposalDiscussionWhitelist>(whitelist)
+    newMode.whitelistId = whitelist.id
+    thread.mode = newMode
+  } else if (threadMode.isOpen) {
+    const newMode = new ProposalDiscussionThreadModeOpen()
+    thread.mode = newMode
+  } else {
+    throw new Error(`Unrecognized proposal thread mode: ${threadMode.type}`)
+  }
+
+  thread.updatedAt = eventTime
+  await store.save<ProposalDiscussionThread>(thread)
+
+  const threadModeChangedEvent = new ProposalDiscussionThreadModeChangedEvent({
+    ...genericEventFields(event),
+    actor: new Membership({ id: memberId.toString() }),
+    newMode: thread.mode,
+    thread: thread,
+  })
+  await store.save<ProposalDiscussionThreadModeChangedEvent>(threadModeChangedEvent)
+}
+
+export async function proposalsDiscussion_PostDeleted({ event, store }: EventContext & StoreContext): Promise<void> {
+  const [memberId, , postId, hide] = new ProposalsDiscussion.PostDeletedEvent(event).params
+  const eventTime = new Date(event.blockTimestamp)
+  const post = await getPost(store, postId.toString())
+
+  const postDeletedEvent = new ProposalDiscussionPostDeletedEvent({
+    ...genericEventFields(event),
+    post,
+    actor: new Membership({ id: memberId.toString() }),
+  })
+  await store.save<ProposalDiscussionPostDeletedEvent>(postDeletedEvent)
+
+  const newStatus = hide.isTrue ? new ProposalDiscussionPostStatusRemoved() : new ProposalDiscussionPostStatusLocked()
+  newStatus.deletedInEventId = postDeletedEvent.id
+  post.status = newStatus
+  post.updatedAt = eventTime
+  await store.save<ProposalDiscussionPost>(post)
 }

+ 3 - 0
query-node/schemas/proposalDiscussion.graphql

@@ -30,6 +30,9 @@ type ProposalDiscussionThread @entity {
 
   "Current thread mode"
   mode: ProposalDiscussionThreadMode!
+
+  "List of related thread mode change events"
+  modeChanges: [ProposalDiscussionThreadModeChangedEvent!] @derivedFrom(field: "thread")
 }
 
 "The post is visible and editable"

+ 0 - 24
query-node/schemas/proposalDiscussionEvents.graphql

@@ -1,27 +1,3 @@
-type ProposalDiscussionThreadCreatedEvent implements Event @entity {
-  ### GENERIC DATA ###
-
-  "(network}-{blockNumber}-{indexInBlock}"
-  id: ID!
-
-  "Hash of the extrinsic which caused the event to be emitted"
-  inExtrinsic: String
-
-  "Blocknumber of the block in which the event was emitted."
-  inBlock: Int!
-
-  "Network the block was produced in"
-  network: Network!
-
-  "Index of event in block from which it was emitted."
-  indexInBlock: Int!
-
-  ### SPECIFIC DATA ###
-
-  "The created thread"
-  thread: ProposalDiscussionThread!
-}
-
 type ProposalDiscussionPostCreatedEvent implements Event @entity {
   ### GENERIC DATA ###
 

+ 24 - 1
tests/integration-tests/src/Api.ts

@@ -2,7 +2,7 @@ import { ApiPromise, WsProvider, Keyring } from '@polkadot/api'
 import { u32, BTreeMap } from '@polkadot/types'
 import { ISubmittableResult } from '@polkadot/types/types'
 import { KeyringPair } from '@polkadot/keyring/types'
-import { AccountId, MemberId } from '@joystream/types/common'
+import { AccountId, MemberId, PostId } from '@joystream/types/common'
 
 import { AccountInfo, Balance, EventRecord, BlockNumber, BlockHash } from '@polkadot/types/interfaces'
 import BN from 'bn.js'
@@ -27,6 +27,8 @@ import {
   ProposalsEngineEventName,
   ProposalCreatedEventDetails,
   ProposalType,
+  ProposalDiscussionPostCreatedEventDetails,
+  ProposalsDiscussionEventName,
 } from './types'
 import {
   ApplicationId,
@@ -445,6 +447,27 @@ export class Api {
     }
   }
 
+  public async retrieveProposalsDiscussionEventDetails(
+    result: ISubmittableResult,
+    eventName: ProposalsDiscussionEventName
+  ): Promise<EventDetails> {
+    const details = await this.retrieveEventDetails(result, 'proposalsDiscussion', eventName)
+    if (!details) {
+      throw new Error(`${eventName} event details not found in result: ${JSON.stringify(result.toHuman())}`)
+    }
+    return details
+  }
+
+  public async retrieveProposalDiscussionPostCreatedEventDetails(
+    result: ISubmittableResult
+  ): Promise<ProposalDiscussionPostCreatedEventDetails> {
+    const details = await this.retrieveProposalsDiscussionEventDetails(result, 'PostCreated')
+    return {
+      ...details,
+      postId: details.event.data[0] as PostId,
+    }
+  }
+
   public async getMemberSigners(inputs: { asMember: MemberId }[]): Promise<string[]> {
     return await Promise.all(
       inputs.map(async ({ asMember }) => {

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

@@ -1,5 +1,5 @@
 import { ApolloClient, DocumentNode, NormalizedCacheObject } from '@apollo/client'
-import { MemberId } from '@joystream/types/common'
+import { MemberId, PostId } from '@joystream/types/common'
 import Debugger from 'debug'
 import { ApplicationId, OpeningId, WorkerId } from '@joystream/types/working-group'
 import { EventDetails, WorkingGroupModuleName } from './types'
@@ -187,6 +187,30 @@ import {
   GetProposalCancelledEventsByEventIdsQuery,
   GetProposalCancelledEventsByEventIdsQueryVariables,
   GetProposalCancelledEventsByEventIds,
+  ProposalDiscussionPostCreatedEventFieldsFragment,
+  GetProposalDiscussionPostCreatedEventsQuery,
+  GetProposalDiscussionPostCreatedEventsQueryVariables,
+  GetProposalDiscussionPostCreatedEvents,
+  ProposalDiscussionPostUpdatedEventFieldsFragment,
+  GetProposalDiscussionPostUpdatedEventsQuery,
+  GetProposalDiscussionPostUpdatedEventsQueryVariables,
+  GetProposalDiscussionPostUpdatedEvents,
+  ProposalDiscussionThreadModeChangedEventFieldsFragment,
+  GetProposalDiscussionThreadModeChangedEventsQuery,
+  GetProposalDiscussionThreadModeChangedEventsQueryVariables,
+  GetProposalDiscussionThreadModeChangedEvents,
+  ProposalDiscussionPostDeletedEventFieldsFragment,
+  GetProposalDiscussionPostDeletedEventsQuery,
+  GetProposalDiscussionPostDeletedEventsQueryVariables,
+  GetProposalDiscussionPostDeletedEvents,
+  ProposalDiscussionPostFieldsFragment,
+  GetProposalDiscussionPostsByIdsQuery,
+  GetProposalDiscussionPostsByIdsQueryVariables,
+  GetProposalDiscussionPostsByIds,
+  ProposalDiscussionThreadFieldsFragment,
+  GetProposalDiscussionThreadsByIdsQuery,
+  GetProposalDiscussionThreadsByIdsQueryVariables,
+  GetProposalDiscussionThreadsByIds,
 } from './graphql/generated/queries'
 import { Maybe } from './graphql/generated/schema'
 import { OperationDefinitionNode } from 'graphql'
@@ -714,4 +738,62 @@ export class QueryNodeApi {
       GetProposalCancelledEventsByEventIdsQueryVariables
     >(GetProposalCancelledEventsByEventIds, { eventIds }, 'proposalCancelledEvents')
   }
+
+  public async getProposalDiscussionPostCreatedEvents(
+    events: EventDetails[]
+  ): Promise<ProposalDiscussionPostCreatedEventFieldsFragment[]> {
+    const eventIds = events.map((e) => this.getQueryNodeEventId(e.blockNumber, e.indexInBlock))
+    return this.multipleEntitiesQuery<
+      GetProposalDiscussionPostCreatedEventsQuery,
+      GetProposalDiscussionPostCreatedEventsQueryVariables
+    >(GetProposalDiscussionPostCreatedEvents, { eventIds }, 'proposalDiscussionPostCreatedEvents')
+  }
+
+  public async getProposalDiscussionPostUpdatedEvents(
+    events: EventDetails[]
+  ): Promise<ProposalDiscussionPostUpdatedEventFieldsFragment[]> {
+    const eventIds = events.map((e) => this.getQueryNodeEventId(e.blockNumber, e.indexInBlock))
+    return this.multipleEntitiesQuery<
+      GetProposalDiscussionPostUpdatedEventsQuery,
+      GetProposalDiscussionPostUpdatedEventsQueryVariables
+    >(GetProposalDiscussionPostUpdatedEvents, { eventIds }, 'proposalDiscussionPostUpdatedEvents')
+  }
+
+  public async getProposalDiscussionThreadModeChangedEvents(
+    events: EventDetails[]
+  ): Promise<ProposalDiscussionThreadModeChangedEventFieldsFragment[]> {
+    const eventIds = events.map((e) => this.getQueryNodeEventId(e.blockNumber, e.indexInBlock))
+    return this.multipleEntitiesQuery<
+      GetProposalDiscussionThreadModeChangedEventsQuery,
+      GetProposalDiscussionThreadModeChangedEventsQueryVariables
+    >(GetProposalDiscussionThreadModeChangedEvents, { eventIds }, 'proposalDiscussionThreadModeChangedEvents')
+  }
+
+  public async getProposalDiscussionPostDeletedEvents(
+    events: EventDetails[]
+  ): Promise<ProposalDiscussionPostDeletedEventFieldsFragment[]> {
+    const eventIds = events.map((e) => this.getQueryNodeEventId(e.blockNumber, e.indexInBlock))
+    return this.multipleEntitiesQuery<
+      GetProposalDiscussionPostDeletedEventsQuery,
+      GetProposalDiscussionPostDeletedEventsQueryVariables
+    >(GetProposalDiscussionPostDeletedEvents, { eventIds }, 'proposalDiscussionPostDeletedEvents')
+  }
+
+  public async getProposalDiscussionPostsByIds(
+    ids: (PostId | number)[]
+  ): Promise<ProposalDiscussionPostFieldsFragment[]> {
+    return this.multipleEntitiesQuery<
+      GetProposalDiscussionPostsByIdsQuery,
+      GetProposalDiscussionPostsByIdsQueryVariables
+    >(GetProposalDiscussionPostsByIds, { ids: ids.map((id) => id.toString()) }, 'proposalDiscussionPosts')
+  }
+
+  public async getProposalDiscussionThreadsByIds(
+    ids: (PostId | number)[]
+  ): Promise<ProposalDiscussionThreadFieldsFragment[]> {
+    return this.multipleEntitiesQuery<
+      GetProposalDiscussionThreadsByIdsQuery,
+      GetProposalDiscussionThreadsByIdsQueryVariables
+    >(GetProposalDiscussionThreadsByIds, { ids: ids.map((id) => id.toString()) }, 'proposalDiscussionThreads')
+  }
 }

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

@@ -13,6 +13,8 @@ export const MINIMUM_STAKING_ACCOUNT_BALANCE = 200
 export const MIN_APPLICATION_STAKE = new BN(2000)
 export const MIN_UNSTANKING_PERIOD = 43201
 export const LEADER_OPENING_STAKE = new BN(2000)
+export const PROPOSALS_POST_DEPOSIT = new BN(2000)
+export const ALL_BYTES = '0x' + Array.from({ length: 256 }, (v, i) => Buffer.from([i]).toString('hex')).join('')
 
 export const lockIdByWorkingGroup: { [K in WorkingGroupModuleName]: string } = {
   storageWorkingGroup: '0x0606060606060606',

+ 1 - 0
tests/integration-tests/src/fixtures/proposals/CreateProposalsFixture.ts

@@ -316,6 +316,7 @@ export class CreateProposalsFixture extends StandardizedFixture {
       assert.equal(new Date(qProposal.statusSetAtTime).getTime(), e.blockTimestamp)
       assert.equal(qProposal.createdInEvent.inBlock, e.blockNumber)
       assert.equal(qProposal.createdInEvent.inExtrinsic, this.extrinsics[i].hash.toString())
+      assert.equal(qProposal.discussionThread.mode.__typename, 'ProposalDiscussionThreadModeOpen')
       this.assertProposalDetailsAreValid(proposalParams, qProposal)
     })
   }

+ 103 - 0
tests/integration-tests/src/fixtures/proposalsDiscussion/ChangeThreadsModeFixture.ts

@@ -0,0 +1,103 @@
+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 {
+  ProposalDiscussionThreadFieldsFragment,
+  ProposalDiscussionThreadModeChangedEventFieldsFragment,
+} from '../../graphql/generated/queries'
+import { assert } from 'chai'
+import { StandardizedFixture } from '../../Fixture'
+import { MemberId, ThreadId } from '@joystream/types/common'
+import { CreateInterface } from '@joystream/types'
+import { ThreadMode } from '@joystream/types/proposals'
+import _ from 'lodash'
+
+export type ThreadModeChangeParams = {
+  threadId: ThreadId | number
+  newMode: CreateInterface<ThreadMode>
+  asMember: MemberId
+}
+
+export class ChangeThreadsModeFixture extends StandardizedFixture {
+  protected threadsModeChangeParams: ThreadModeChangeParams[]
+
+  public constructor(api: Api, query: QueryNodeApi, threadsModeChangeParams: ThreadModeChangeParams[]) {
+    super(api, query)
+    this.threadsModeChangeParams = threadsModeChangeParams
+  }
+
+  protected async getSignerAccountOrAccounts(): Promise<string[]> {
+    return this.api.getMemberSigners(this.threadsModeChangeParams)
+  }
+
+  protected async getExtrinsics(): Promise<SubmittableExtrinsic<'promise'>[]> {
+    return this.threadsModeChangeParams.map((params) =>
+      this.api.tx.proposalsDiscussion.changeThreadMode(params.asMember, params.threadId, params.newMode)
+    )
+  }
+
+  protected async getEventFromResult(result: ISubmittableResult): Promise<EventDetails> {
+    return this.api.retrieveProposalsDiscussionEventDetails(result, 'ThreadModeChanged')
+  }
+
+  protected assertQueriedThreadsAreValid(
+    qThreads: ProposalDiscussionThreadFieldsFragment[],
+    qEvents: ProposalDiscussionThreadModeChangedEventFieldsFragment[]
+  ): void {
+    for (const [threadId, changes] of _.entries(
+      _.groupBy(this.threadsModeChangeParams, (p) => p.threadId.toString())
+    )) {
+      const finalUpdate = _.last(changes)
+      const qThread = qThreads.find((t) => t.id === threadId.toString())
+      Utils.assert(qThread, 'Query node: Thread not found!')
+      assert.includeDeepMembers(
+        qThread.modeChanges.map((e) => e.id),
+        qEvents.filter((e) => e.thread.id === qThread.id).map((e) => e.id)
+      )
+      Utils.assert(finalUpdate)
+      const newMode = this.api.createType('ThreadMode', finalUpdate.newMode)
+      if (newMode.isOfType('Closed')) {
+        Utils.assert(
+          qThread.mode.__typename === 'ProposalDiscussionThreadModeClosed',
+          `Invalid thread status ${qThread.mode.__typename}`
+        )
+        Utils.assert(qThread.mode.whitelist, 'Query node: Missing thread.mode.whitelist')
+        assert.sameDeepMembers(
+          qThread.mode.whitelist.members.map((m) => m.id),
+          newMode.asType('Closed').map((memberId) => memberId.toString())
+        )
+      } else if (newMode.isOfType('Open')) {
+        assert.equal(qThread.mode.__typename, 'ProposalDiscussionThreadModeOpen')
+      } else {
+        throw new Error(`Unknown thread mode: ${newMode.type}`)
+      }
+    }
+  }
+
+  protected assertQueryNodeEventIsValid(
+    qEvent: ProposalDiscussionThreadModeChangedEventFieldsFragment,
+    i: number
+  ): void {
+    const params = this.threadsModeChangeParams[i]
+    assert.equal(qEvent.thread.id, params.threadId.toString())
+    assert.equal(qEvent.actor.id, params.asMember.toString())
+  }
+
+  async runQueryNodeChecks(): Promise<void> {
+    await super.runQueryNodeChecks()
+    // Query the events
+    const qEvents = await this.query.tryQueryWithTimeout(
+      () => this.query.getProposalDiscussionThreadModeChangedEvents(this.events),
+      (qEvents) => this.assertQueryNodeEventsAreValid(qEvents)
+    )
+
+    // Query the posts
+    const qThreads = await this.query.getProposalDiscussionThreadsByIds(
+      this.threadsModeChangeParams.map((p) => p.threadId)
+    )
+    this.assertQueriedThreadsAreValid(qThreads, qEvents)
+  }
+}

+ 122 - 0
tests/integration-tests/src/fixtures/proposalsDiscussion/CreatePostsFixture.ts

@@ -0,0 +1,122 @@
+import { Api } from '../../Api'
+import { QueryNodeApi } from '../../QueryNodeApi'
+import { MetadataInput, ProposalDiscussionPostCreatedEventDetails } from '../../types'
+import { SubmittableExtrinsic } from '@polkadot/api/types'
+import { Utils } from '../../utils'
+import { ISubmittableResult } from '@polkadot/types/types/'
+import {
+  ProposalDiscussionPostCreatedEventFieldsFragment,
+  ProposalDiscussionPostFieldsFragment,
+} from '../../graphql/generated/queries'
+import { assert } from 'chai'
+import { StandardizedFixture } from '../../Fixture'
+import { MemberId, PostId, ThreadId } from '@joystream/types/common'
+import { PROPOSALS_POST_DEPOSIT } from '../../consts'
+import { ProposalsDiscussionPostMetadata, IProposalsDiscussionPostMetadata } from '@joystream/metadata-protobuf'
+
+export type PostParams = {
+  threadId: ThreadId | number
+  asMember: MemberId
+  editable?: boolean // defaults to true
+  metadata: MetadataInput<IProposalsDiscussionPostMetadata> & { expectReplyFailure?: boolean }
+}
+
+export class CreatePostsFixture extends StandardizedFixture {
+  protected events: ProposalDiscussionPostCreatedEventDetails[] = []
+  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 this.api.getMemberSigners(this.postsParams)
+  }
+
+  public async execute(): Promise<void> {
+    const accounts = await this.getSignerAccountOrAccounts()
+    // Send required funds to accounts (ProposalsPostDeposit)
+    await Promise.all(accounts.map((a) => this.api.treasuryTransferBalance(a, PROPOSALS_POST_DEPOSIT)))
+    await super.execute()
+  }
+
+  protected async getExtrinsics(): Promise<SubmittableExtrinsic<'promise'>[]> {
+    return this.postsParams.map((params) =>
+      this.api.tx.proposalsDiscussion.addPost(
+        params.asMember,
+        params.threadId,
+        Utils.getMetadataBytesFromInput(ProposalsDiscussionPostMetadata, params.metadata),
+        params.editable === undefined || params.editable
+      )
+    )
+  }
+
+  protected async getEventFromResult(result: ISubmittableResult): Promise<ProposalDiscussionPostCreatedEventDetails> {
+    return this.api.retrieveProposalDiscussionPostCreatedEventDetails(result)
+  }
+
+  protected getPostExpectedText(postParams: PostParams): string {
+    const expectedMetadata = Utils.getDeserializedMetadataFormInput(
+      ProposalsDiscussionPostMetadata,
+      postParams.metadata
+    )
+    const metadataBytes = Utils.getMetadataBytesFromInput(ProposalsDiscussionPostMetadata, postParams.metadata)
+    return typeof expectedMetadata?.text === 'string' ? expectedMetadata.text : Utils.bytesToString(metadataBytes)
+  }
+
+  protected assertQueriedPostsAreValid(
+    qPosts: ProposalDiscussionPostFieldsFragment[],
+    qEvents: ProposalDiscussionPostCreatedEventFieldsFragment[]
+  ): 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
+          ? 'ProposalDiscussionPostStatusActive'
+          : 'ProposalDiscussionPostStatusLocked'
+      const expectedMetadata = Utils.getDeserializedMetadataFormInput(
+        ProposalsDiscussionPostMetadata,
+        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()
+      )
+      assert.equal(qPost.createdInEvent.id, qEvent.id)
+    })
+  }
+
+  protected assertQueryNodeEventIsValid(qEvent: ProposalDiscussionPostCreatedEventFieldsFragment, 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))
+  }
+
+  async runQueryNodeChecks(): Promise<void> {
+    await super.runQueryNodeChecks()
+    // Query the events
+    const qEvents = await this.query.tryQueryWithTimeout(
+      () => this.query.getProposalDiscussionPostCreatedEvents(this.events),
+      (qEvents) => this.assertQueryNodeEventsAreValid(qEvents)
+    )
+
+    // Query the posts
+    const qPosts = await this.query.getProposalDiscussionPostsByIds(this.events.map((e) => e.postId))
+    this.assertQueriedPostsAreValid(qPosts, qEvents)
+  }
+}

+ 85 - 0
tests/integration-tests/src/fixtures/proposalsDiscussion/DeletePostsFixture.ts

@@ -0,0 +1,85 @@
+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 {
+  ProposalDiscussionPostDeletedEventFieldsFragment,
+  ProposalDiscussionPostFieldsFragment,
+} from '../../graphql/generated/queries'
+import { assert } from 'chai'
+import { StandardizedFixture } from '../../Fixture'
+import { MemberId, PostId, ThreadId } from '@joystream/types/common'
+
+export type DeletePostParams = {
+  threadId: ThreadId | number
+  postId: PostId | number
+  asMember: MemberId
+  hide?: boolean // defaults to true
+}
+
+export class DeletePostsFixture extends StandardizedFixture {
+  protected deletePostsParams: DeletePostParams[]
+
+  public constructor(api: Api, query: QueryNodeApi, deletePostsParams: DeletePostParams[]) {
+    super(api, query)
+    this.deletePostsParams = deletePostsParams
+  }
+
+  protected async getSignerAccountOrAccounts(): Promise<string[]> {
+    return this.api.getMemberSigners(this.deletePostsParams)
+  }
+
+  protected async getExtrinsics(): Promise<SubmittableExtrinsic<'promise'>[]> {
+    return this.deletePostsParams.map((params) =>
+      this.api.tx.proposalsDiscussion.deletePost(
+        params.asMember,
+        params.postId,
+        params.threadId,
+        params.hide === undefined || params.hide
+      )
+    )
+  }
+
+  protected async getEventFromResult(result: ISubmittableResult): Promise<EventDetails> {
+    return this.api.retrieveProposalsDiscussionEventDetails(result, 'PostDeleted')
+  }
+
+  protected assertQueriedPostsAreValid(
+    qPosts: ProposalDiscussionPostFieldsFragment[],
+    qEvents: ProposalDiscussionPostDeletedEventFieldsFragment[]
+  ): void {
+    this.events.map((e, i) => {
+      const params = this.deletePostsParams[i]
+      const qPost = qPosts.find((p) => p.id === params.postId.toString())
+      const qEvent = this.findMatchingQueryNodeEvent(e, qEvents)
+      const expectedStatus =
+        params.hide === undefined || params.hide
+          ? 'ProposalDiscussionPostStatusRemoved'
+          : 'ProposalDiscussionPostStatusLocked'
+      Utils.assert(qPost, 'Query node: Post not found')
+      Utils.assert(qPost.status.__typename === expectedStatus, `Invalid post status (${qPost.status.__typename})`)
+      assert.equal(qPost.status.deletedInEvent?.id, qEvent.id)
+    })
+  }
+
+  protected assertQueryNodeEventIsValid(qEvent: ProposalDiscussionPostDeletedEventFieldsFragment, i: number): void {
+    const params = this.deletePostsParams[i]
+    assert.equal(qEvent.post.id, params.postId.toString())
+    assert.equal(qEvent.actor.id, params.asMember.toString())
+  }
+
+  async runQueryNodeChecks(): Promise<void> {
+    await super.runQueryNodeChecks()
+    // Query the events
+    const qEvents = await this.query.tryQueryWithTimeout(
+      () => this.query.getProposalDiscussionPostDeletedEvents(this.events),
+      (qEvents) => this.assertQueryNodeEventsAreValid(qEvents)
+    )
+
+    // Query the posts
+    const qPosts = await this.query.getProposalDiscussionPostsByIds(this.deletePostsParams.map((p) => p.postId))
+    this.assertQueriedPostsAreValid(qPosts, qEvents)
+  }
+}

+ 81 - 0
tests/integration-tests/src/fixtures/proposalsDiscussion/UpdatePostsFixture.ts

@@ -0,0 +1,81 @@
+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 {
+  ProposalDiscussionPostFieldsFragment,
+  ProposalDiscussionPostUpdatedEventFieldsFragment,
+} from '../../graphql/generated/queries'
+import { assert } from 'chai'
+import { StandardizedFixture } from '../../Fixture'
+import { MemberId, PostId, ThreadId } from '@joystream/types/common'
+import _ from 'lodash'
+
+export type PostUpdateParams = {
+  threadId: ThreadId | number
+  postId: PostId | number
+  newText: string
+  asMember: MemberId // Cannot retrieve this information from the runtime currently
+}
+
+export class UpdatePostsFixture extends StandardizedFixture {
+  protected postsUpdates: PostUpdateParams[]
+  protected postsAuthors: MemberId[] = []
+
+  public constructor(api: Api, query: QueryNodeApi, postsUpdates: PostUpdateParams[]) {
+    super(api, query)
+    this.postsUpdates = postsUpdates
+  }
+
+  protected async getSignerAccountOrAccounts(): Promise<string[]> {
+    return this.api.getMemberSigners(this.postsUpdates)
+  }
+
+  protected async getExtrinsics(): Promise<SubmittableExtrinsic<'promise'>[]> {
+    return this.postsUpdates.map((params) =>
+      this.api.tx.proposalsDiscussion.updatePost(params.threadId, params.postId, params.newText)
+    )
+  }
+
+  protected async getEventFromResult(result: ISubmittableResult): Promise<EventDetails> {
+    return this.api.retrieveProposalsDiscussionEventDetails(result, 'PostUpdated')
+  }
+
+  protected assertQueriedPostsAreValid(
+    qPosts: ProposalDiscussionPostFieldsFragment[],
+    qEvents: ProposalDiscussionPostUpdatedEventFieldsFragment[]
+  ): void {
+    for (const [postId, updates] of _.entries(_.groupBy(this.postsUpdates, (p) => p.postId.toString()))) {
+      const finalUpdate = _.last(updates)
+      const qPost = qPosts.find((p) => p.id === postId.toString())
+      Utils.assert(qPost, 'Query node: Post not found!')
+      assert.includeDeepMembers(
+        qPost.updates.map((e) => e.id),
+        qEvents.filter((e) => e.post.id === qPost.id).map((e) => e.id)
+      )
+      Utils.assert(finalUpdate)
+      assert.equal(qPost.text, Utils.asText(finalUpdate.newText))
+    }
+  }
+
+  protected assertQueryNodeEventIsValid(qEvent: ProposalDiscussionPostUpdatedEventFieldsFragment, i: number): void {
+    const params = this.postsUpdates[i]
+    assert.equal(qEvent.post.id, params.postId.toString())
+    assert.equal(qEvent.text, Utils.asText(params.newText))
+  }
+
+  async runQueryNodeChecks(): Promise<void> {
+    await super.runQueryNodeChecks()
+    // Query the events
+    const qEvents = await this.query.tryQueryWithTimeout(
+      () => this.query.getProposalDiscussionPostUpdatedEvents(this.events),
+      (qEvents) => this.assertQueryNodeEventsAreValid(qEvents)
+    )
+
+    // Query the posts
+    const qPosts = await this.query.getProposalDiscussionPostsByIds(this.postsUpdates.map((u) => u.postId))
+    this.assertQueriedPostsAreValid(qPosts, qEvents)
+  }
+}

+ 4 - 0
tests/integration-tests/src/fixtures/proposalsDiscussion/index.ts

@@ -0,0 +1,4 @@
+export { ChangeThreadsModeFixture, ThreadModeChangeParams } from './ChangeThreadsModeFixture'
+export { CreatePostsFixture, PostParams } from './CreatePostsFixture'
+export { DeletePostsFixture, DeletePostParams } from './DeletePostsFixture'
+export { UpdatePostsFixture, PostUpdateParams } from './UpdatePostsFixture'

+ 127 - 0
tests/integration-tests/src/flows/proposalsDiscussion/index.ts

@@ -0,0 +1,127 @@
+import { FlowProps } from '../../Flow'
+import Debugger from 'debug'
+import { FixtureRunner } from '../../Fixture'
+import { BuyMembershipHappyCaseFixture } from '../../fixtures/membership'
+import { CreateProposalsFixture, ExpireProposalsFixture } from '../../fixtures/proposals'
+import {
+  ChangeThreadsModeFixture,
+  CreatePostsFixture,
+  DeletePostParams,
+  DeletePostsFixture,
+  PostParams,
+  PostUpdateParams,
+  ThreadModeChangeParams,
+  UpdatePostsFixture,
+} from '../../fixtures/proposalsDiscussion'
+import { Resource } from '../../Resources'
+import { ThreadId } from '../../../../../types/common'
+import { ALL_BYTES } from '../../consts'
+
+export default async function proposalsDiscussion({ api, query, lock }: FlowProps): Promise<void> {
+  const debug = Debugger('flow:proposals-discussion')
+  debug('Started')
+  api.enableDebugTxLogs()
+
+  const threadsN = 3
+  const accounts = (await api.createKeyPairs(threadsN)).map((kp) => kp.address)
+
+  const buyMembershipsFixture = new BuyMembershipHappyCaseFixture(api, query, accounts)
+  await new FixtureRunner(buyMembershipsFixture).run()
+  const memberIds = buyMembershipsFixture.getCreatedMembers()
+
+  const unlocks = await Promise.all(Array.from({ length: threadsN }, () => lock(Resource.Proposals)))
+  const createProposalFixture = new CreateProposalsFixture(
+    api,
+    query,
+    Array.from({ length: threadsN }, (v, i) => ({
+      type: 'Signal',
+      details: `Discussion test ${i}`,
+      asMember: memberIds[i],
+      title: `Discussion test proposal ${i}`,
+      description: `Proposals discussion test proposal ${i}`,
+    }))
+  )
+  await new FixtureRunner(createProposalFixture).run()
+  const proposalsIds = createProposalFixture.getCreatedProposalsIds()
+  const threadIds = await api.query.proposalsCodex.threadIdByProposalId.multi<ThreadId>(proposalsIds)
+
+  const createPostsParams: PostParams[] = threadIds.reduce(
+    (posts, threadId) =>
+      posts.concat([
+        // Standard case:
+        {
+          threadId,
+          asMember: memberIds[0],
+          metadata: { value: { text: 'Test' } },
+          editable: true,
+        },
+        // Invalid repliesTo case:
+        {
+          threadId,
+          asMember: memberIds[1],
+          metadata: { value: { text: 'Test', repliesTo: 9999 }, expectReplyFailure: true },
+          editable: true,
+        },
+        // ALL_BYTES metadata + non-editable case:
+        {
+          threadId,
+          asMember: memberIds[2],
+          metadata: { value: ALL_BYTES, expectFailure: true }, // expectFailure just means serialization failure, but the value will still be checked
+          editable: false,
+        },
+      ]),
+    [] as PostParams[]
+  )
+  const createPostsFixture = new CreatePostsFixture(api, query, createPostsParams)
+  await new FixtureRunner(createPostsFixture).runWithQueryNodeChecks()
+  const postIds = createPostsFixture.getCreatedPostsIds()
+
+  const threadModeChangesParams: ThreadModeChangeParams[] = [
+    { threadId: threadIds[0], asMember: memberIds[0], newMode: { Closed: memberIds } },
+    { threadId: threadIds[1], asMember: memberIds[1], newMode: { Closed: [memberIds[0]] } },
+    { threadId: threadIds[1], asMember: memberIds[1], newMode: 'Open' },
+  ]
+  const threadModeChanges = new ChangeThreadsModeFixture(api, query, threadModeChangesParams)
+  const threadModeChangesRunner = new FixtureRunner(threadModeChanges)
+  await threadModeChangesRunner.run()
+
+  const createPostRepliesParams: PostParams[] = createPostsParams.map((params, i) => ({
+    threadId: params.threadId,
+    asMember: memberIds[i % memberIds.length],
+    metadata: { value: { text: `Reply to post ${postIds[i].toString()}`, repliesTo: postIds[i].toNumber() } },
+  }))
+  const createRepliesFixture = new CreatePostsFixture(api, query, createPostRepliesParams)
+  const createRepliesRunner = new FixtureRunner(createRepliesFixture)
+  await createRepliesRunner.run()
+
+  const updatePostsParams: PostUpdateParams[] = [
+    { threadId: threadIds[0], postId: postIds[0], asMember: memberIds[0], newText: 'New text' },
+    { threadId: threadIds[0], postId: postIds[1], asMember: memberIds[1], newText: ALL_BYTES },
+  ]
+  const updatePostsFixture = new UpdatePostsFixture(api, query, updatePostsParams)
+  const updatePostsRunner = new FixtureRunner(updatePostsFixture)
+  await updatePostsRunner.run()
+
+  // TODO: Test anyone_can_delete_post (would require waiting PostLifetime)
+
+  const deletePostsParams: DeletePostParams[] = postIds
+    .map((postId, i) => ({ postId, ...createPostsParams[i] }))
+    .filter((p) => p.editable !== false)
+  const deletePostsFixture = new DeletePostsFixture(api, query, deletePostsParams)
+  const deletePostsRunner = new FixtureRunner(deletePostsFixture)
+  await deletePostsRunner.run()
+
+  // Run compound query-node checks
+  await Promise.all([
+    createRepliesRunner.runQueryNodeChecks(),
+    threadModeChangesRunner.runQueryNodeChecks(),
+    updatePostsRunner.runQueryNodeChecks(),
+    deletePostsRunner.runQueryNodeChecks(),
+  ])
+
+  // Wait until proposal expires and release locks
+  await new FixtureRunner(new ExpireProposalsFixture(api, query, proposalsIds)).run()
+  unlocks.forEach((unlock) => unlock())
+
+  debug('Done')
+}

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

@@ -658,6 +658,12 @@ export type ProposalFieldsFragment = {
     | ProposalStatusFields_ProposalStatusCancelled_Fragment
     | ProposalStatusFields_ProposalStatusCanceledByRuntime_Fragment
   createdInEvent: { id: string; inBlock: number; inExtrinsic?: Types.Maybe<string> }
+  discussionThread: {
+    id: string
+    mode:
+      | ProposalDiscussionThreadModeFields_ProposalDiscussionThreadModeOpen_Fragment
+      | ProposalDiscussionThreadModeFields_ProposalDiscussionThreadModeClosed_Fragment
+  }
 }
 
 export type GetProposalsByIdsQueryVariables = Types.Exact<{
@@ -666,6 +672,157 @@ export type GetProposalsByIdsQueryVariables = Types.Exact<{
 
 export type GetProposalsByIdsQuery = { proposals: Array<ProposalFieldsFragment> }
 
+type ProposalDiscussionThreadModeFields_ProposalDiscussionThreadModeOpen_Fragment = {
+  __typename: 'ProposalDiscussionThreadModeOpen'
+}
+
+type ProposalDiscussionThreadModeFields_ProposalDiscussionThreadModeClosed_Fragment = {
+  __typename: 'ProposalDiscussionThreadModeClosed'
+  whitelist?: Types.Maybe<{ members: Array<{ id: string }> }>
+}
+
+export type ProposalDiscussionThreadModeFieldsFragment =
+  | ProposalDiscussionThreadModeFields_ProposalDiscussionThreadModeOpen_Fragment
+  | ProposalDiscussionThreadModeFields_ProposalDiscussionThreadModeClosed_Fragment
+
+type ProposalDiscussionPostStatusFields_ProposalDiscussionPostStatusActive_Fragment = {
+  __typename: 'ProposalDiscussionPostStatusActive'
+}
+
+type ProposalDiscussionPostStatusFields_ProposalDiscussionPostStatusLocked_Fragment = {
+  __typename: 'ProposalDiscussionPostStatusLocked'
+  deletedInEvent?: Types.Maybe<{ id: string }>
+}
+
+type ProposalDiscussionPostStatusFields_ProposalDiscussionPostStatusRemoved_Fragment = {
+  __typename: 'ProposalDiscussionPostStatusRemoved'
+  deletedInEvent?: Types.Maybe<{ id: string }>
+}
+
+export type ProposalDiscussionPostStatusFieldsFragment =
+  | ProposalDiscussionPostStatusFields_ProposalDiscussionPostStatusActive_Fragment
+  | ProposalDiscussionPostStatusFields_ProposalDiscussionPostStatusLocked_Fragment
+  | ProposalDiscussionPostStatusFields_ProposalDiscussionPostStatusRemoved_Fragment
+
+export type ProposalDiscussionThreadFieldsFragment = {
+  id: string
+  proposal: { id: string }
+  posts: Array<{ id: string }>
+  mode:
+    | ProposalDiscussionThreadModeFields_ProposalDiscussionThreadModeOpen_Fragment
+    | ProposalDiscussionThreadModeFields_ProposalDiscussionThreadModeClosed_Fragment
+  modeChanges: Array<{ id: string }>
+}
+
+export type GetProposalDiscussionThreadsByIdsQueryVariables = Types.Exact<{
+  ids?: Types.Maybe<Array<Types.Scalars['ID']> | Types.Scalars['ID']>
+}>
+
+export type GetProposalDiscussionThreadsByIdsQuery = {
+  proposalDiscussionThreads: Array<ProposalDiscussionThreadFieldsFragment>
+}
+
+export type ProposalDiscussionPostFieldsFragment = {
+  id: string
+  text: string
+  thread: { id: string }
+  author: { id: string }
+  status:
+    | ProposalDiscussionPostStatusFields_ProposalDiscussionPostStatusActive_Fragment
+    | ProposalDiscussionPostStatusFields_ProposalDiscussionPostStatusLocked_Fragment
+    | ProposalDiscussionPostStatusFields_ProposalDiscussionPostStatusRemoved_Fragment
+  repliesTo?: Types.Maybe<{ id: string }>
+  updates: Array<{ id: string }>
+  createdInEvent: { id: string }
+}
+
+export type GetProposalDiscussionPostsByIdsQueryVariables = Types.Exact<{
+  ids?: Types.Maybe<Array<Types.Scalars['ID']> | Types.Scalars['ID']>
+}>
+
+export type GetProposalDiscussionPostsByIdsQuery = {
+  proposalDiscussionPosts: Array<ProposalDiscussionPostFieldsFragment>
+}
+
+export type ProposalDiscussionPostCreatedEventFieldsFragment = {
+  id: string
+  createdAt: any
+  inExtrinsic?: Types.Maybe<string>
+  inBlock: number
+  network: Types.Network
+  indexInBlock: number
+  text: string
+  post: { id: string }
+}
+
+export type GetProposalDiscussionPostCreatedEventsQueryVariables = Types.Exact<{
+  eventIds?: Types.Maybe<Array<Types.Scalars['ID']> | Types.Scalars['ID']>
+}>
+
+export type GetProposalDiscussionPostCreatedEventsQuery = {
+  proposalDiscussionPostCreatedEvents: Array<ProposalDiscussionPostCreatedEventFieldsFragment>
+}
+
+export type ProposalDiscussionPostUpdatedEventFieldsFragment = {
+  id: string
+  createdAt: any
+  inExtrinsic?: Types.Maybe<string>
+  inBlock: number
+  network: Types.Network
+  indexInBlock: number
+  text: string
+  post: { id: string }
+}
+
+export type GetProposalDiscussionPostUpdatedEventsQueryVariables = Types.Exact<{
+  eventIds?: Types.Maybe<Array<Types.Scalars['ID']> | Types.Scalars['ID']>
+}>
+
+export type GetProposalDiscussionPostUpdatedEventsQuery = {
+  proposalDiscussionPostUpdatedEvents: Array<ProposalDiscussionPostUpdatedEventFieldsFragment>
+}
+
+export type ProposalDiscussionThreadModeChangedEventFieldsFragment = {
+  id: string
+  createdAt: any
+  inExtrinsic?: Types.Maybe<string>
+  inBlock: number
+  network: Types.Network
+  indexInBlock: number
+  thread: { id: string }
+  newMode:
+    | ProposalDiscussionThreadModeFields_ProposalDiscussionThreadModeOpen_Fragment
+    | ProposalDiscussionThreadModeFields_ProposalDiscussionThreadModeClosed_Fragment
+  actor: { id: string }
+}
+
+export type GetProposalDiscussionThreadModeChangedEventsQueryVariables = Types.Exact<{
+  eventIds?: Types.Maybe<Array<Types.Scalars['ID']> | Types.Scalars['ID']>
+}>
+
+export type GetProposalDiscussionThreadModeChangedEventsQuery = {
+  proposalDiscussionThreadModeChangedEvents: Array<ProposalDiscussionThreadModeChangedEventFieldsFragment>
+}
+
+export type ProposalDiscussionPostDeletedEventFieldsFragment = {
+  id: string
+  createdAt: any
+  inExtrinsic?: Types.Maybe<string>
+  inBlock: number
+  network: Types.Network
+  indexInBlock: number
+  post: { id: string }
+  actor: { id: string }
+}
+
+export type GetProposalDiscussionPostDeletedEventsQueryVariables = Types.Exact<{
+  eventIds?: Types.Maybe<Array<Types.Scalars['ID']> | Types.Scalars['ID']>
+}>
+
+export type GetProposalDiscussionPostDeletedEventsQuery = {
+  proposalDiscussionPostDeletedEvents: Array<ProposalDiscussionPostDeletedEventFieldsFragment>
+}
+
 export type ProposalCreatedEventFieldsFragment = {
   id: string
   createdAt: any
@@ -1812,6 +1969,18 @@ export const ProposalStatusFields = gql`
     }
   }
 `
+export const ProposalDiscussionThreadModeFields = gql`
+  fragment ProposalDiscussionThreadModeFields on ProposalDiscussionThreadMode {
+    __typename
+    ... on ProposalDiscussionThreadModeClosed {
+      whitelist {
+        members {
+          id
+        }
+      }
+    }
+  }
+`
 export const ProposalFields = gql`
   fragment ProposalFields on Proposal {
     id
@@ -1846,9 +2015,138 @@ export const ProposalFields = gql`
       inBlock
       inExtrinsic
     }
+    discussionThread {
+      id
+      mode {
+        ...ProposalDiscussionThreadModeFields
+      }
+    }
   }
   ${ProposalDetailsFields}
   ${ProposalStatusFields}
+  ${ProposalDiscussionThreadModeFields}
+`
+export const ProposalDiscussionThreadFields = gql`
+  fragment ProposalDiscussionThreadFields on ProposalDiscussionThread {
+    id
+    proposal {
+      id
+    }
+    posts {
+      id
+    }
+    mode {
+      ...ProposalDiscussionThreadModeFields
+    }
+    modeChanges {
+      id
+    }
+  }
+  ${ProposalDiscussionThreadModeFields}
+`
+export const ProposalDiscussionPostStatusFields = gql`
+  fragment ProposalDiscussionPostStatusFields on ProposalDiscussionPostStatus {
+    __typename
+    ... on ProposalDiscussionPostStatusLocked {
+      deletedInEvent {
+        id
+      }
+    }
+    ... on ProposalDiscussionPostStatusRemoved {
+      deletedInEvent {
+        id
+      }
+    }
+  }
+`
+export const ProposalDiscussionPostFields = gql`
+  fragment ProposalDiscussionPostFields on ProposalDiscussionPost {
+    id
+    thread {
+      id
+    }
+    author {
+      id
+    }
+    status {
+      ...ProposalDiscussionPostStatusFields
+    }
+    text
+    repliesTo {
+      id
+    }
+    updates {
+      id
+    }
+    createdInEvent {
+      id
+    }
+  }
+  ${ProposalDiscussionPostStatusFields}
+`
+export const ProposalDiscussionPostCreatedEventFields = gql`
+  fragment ProposalDiscussionPostCreatedEventFields on ProposalDiscussionPostCreatedEvent {
+    id
+    createdAt
+    inExtrinsic
+    inBlock
+    network
+    indexInBlock
+    post {
+      id
+    }
+    text
+  }
+`
+export const ProposalDiscussionPostUpdatedEventFields = gql`
+  fragment ProposalDiscussionPostUpdatedEventFields on ProposalDiscussionPostUpdatedEvent {
+    id
+    createdAt
+    inExtrinsic
+    inBlock
+    network
+    indexInBlock
+    post {
+      id
+    }
+    text
+  }
+`
+export const ProposalDiscussionThreadModeChangedEventFields = gql`
+  fragment ProposalDiscussionThreadModeChangedEventFields on ProposalDiscussionThreadModeChangedEvent {
+    id
+    createdAt
+    inExtrinsic
+    inBlock
+    network
+    indexInBlock
+    thread {
+      id
+    }
+    newMode {
+      ...ProposalDiscussionThreadModeFields
+    }
+    actor {
+      id
+    }
+  }
+  ${ProposalDiscussionThreadModeFields}
+`
+export const ProposalDiscussionPostDeletedEventFields = gql`
+  fragment ProposalDiscussionPostDeletedEventFields on ProposalDiscussionPostDeletedEvent {
+    id
+    createdAt
+    inExtrinsic
+    inBlock
+    network
+    indexInBlock
+    post {
+      id
+    }
+    actor {
+      id
+    }
+  }
 `
 export const ProposalCreatedEventFields = gql`
   fragment ProposalCreatedEventFields on ProposalCreatedEvent {
@@ -2628,6 +2926,54 @@ export const GetProposalsByIds = gql`
   }
   ${ProposalFields}
 `
+export const GetProposalDiscussionThreadsByIds = gql`
+  query getProposalDiscussionThreadsByIds($ids: [ID!]) {
+    proposalDiscussionThreads(where: { id_in: $ids }) {
+      ...ProposalDiscussionThreadFields
+    }
+  }
+  ${ProposalDiscussionThreadFields}
+`
+export const GetProposalDiscussionPostsByIds = gql`
+  query getProposalDiscussionPostsByIds($ids: [ID!]) {
+    proposalDiscussionPosts(where: { id_in: $ids }) {
+      ...ProposalDiscussionPostFields
+    }
+  }
+  ${ProposalDiscussionPostFields}
+`
+export const GetProposalDiscussionPostCreatedEvents = gql`
+  query getProposalDiscussionPostCreatedEvents($eventIds: [ID!]) {
+    proposalDiscussionPostCreatedEvents(where: { id_in: $eventIds }) {
+      ...ProposalDiscussionPostCreatedEventFields
+    }
+  }
+  ${ProposalDiscussionPostCreatedEventFields}
+`
+export const GetProposalDiscussionPostUpdatedEvents = gql`
+  query getProposalDiscussionPostUpdatedEvents($eventIds: [ID!]) {
+    proposalDiscussionPostUpdatedEvents(where: { id_in: $eventIds }) {
+      ...ProposalDiscussionPostUpdatedEventFields
+    }
+  }
+  ${ProposalDiscussionPostUpdatedEventFields}
+`
+export const GetProposalDiscussionThreadModeChangedEvents = gql`
+  query getProposalDiscussionThreadModeChangedEvents($eventIds: [ID!]) {
+    proposalDiscussionThreadModeChangedEvents(where: { id_in: $eventIds }) {
+      ...ProposalDiscussionThreadModeChangedEventFields
+    }
+  }
+  ${ProposalDiscussionThreadModeChangedEventFields}
+`
+export const GetProposalDiscussionPostDeletedEvents = gql`
+  query getProposalDiscussionPostDeletedEvents($eventIds: [ID!]) {
+    proposalDiscussionPostDeletedEvents(where: { id_in: $eventIds }) {
+      ...ProposalDiscussionPostDeletedEventFields
+    }
+  }
+  ${ProposalDiscussionPostDeletedEventFields}
+`
 export const GetProposalCreatedEventsByEventIds = gql`
   query getProposalCreatedEventsByEventIds($eventIds: [ID!]) {
     proposalCreatedEvents(where: { id_in: $eventIds }) {

+ 4 - 157
tests/integration-tests/src/graphql/generated/schema.ts

@@ -1769,7 +1769,6 @@ export enum EventTypeOptions {
   ProposalDiscussionPostCreatedEvent = 'ProposalDiscussionPostCreatedEvent',
   ProposalDiscussionPostDeletedEvent = 'ProposalDiscussionPostDeletedEvent',
   ProposalDiscussionPostUpdatedEvent = 'ProposalDiscussionPostUpdatedEvent',
-  ProposalDiscussionThreadCreatedEvent = 'ProposalDiscussionThreadCreatedEvent',
   ProposalDiscussionThreadModeChangedEvent = 'ProposalDiscussionThreadModeChangedEvent',
   ProposalExecutedEvent = 'ProposalExecutedEvent',
   ProposalStatusUpdatedEvent = 'ProposalStatusUpdatedEvent',
@@ -6170,8 +6169,7 @@ export type ProposalDiscussionThread = BaseGraphQlObject & {
   posts: Array<ProposalDiscussionPost>
   /** Current thread mode */
   mode: ProposalDiscussionThreadMode
-  proposaldiscussionthreadcreatedeventthread?: Maybe<Array<ProposalDiscussionThreadCreatedEvent>>
-  proposaldiscussionthreadmodechangedeventthread?: Maybe<Array<ProposalDiscussionThreadModeChangedEvent>>
+  modeChanges: Array<ProposalDiscussionThreadModeChangedEvent>
 }
 
 export type ProposalDiscussionThreadConnection = {
@@ -6180,131 +6178,6 @@ export type ProposalDiscussionThreadConnection = {
   pageInfo: PageInfo
 }
 
-export type ProposalDiscussionThreadCreatedEvent = Event &
-  BaseGraphQlObject & {
-    /** Hash of the extrinsic which caused the event to be emitted */
-    inExtrinsic?: Maybe<Scalars['String']>
-    /** Blocknumber of the block in which the event was emitted. */
-    inBlock: Scalars['Int']
-    /** Network the block was produced in */
-    network: Network
-    /** Index of event in block from which it was emitted. */
-    indexInBlock: Scalars['Int']
-    /** Filtering options for interface implementers */
-    type?: Maybe<EventTypeOptions>
-    id: Scalars['ID']
-    createdAt: Scalars['DateTime']
-    createdById: Scalars['String']
-    updatedAt?: Maybe<Scalars['DateTime']>
-    updatedById?: Maybe<Scalars['String']>
-    deletedAt?: Maybe<Scalars['DateTime']>
-    deletedById?: Maybe<Scalars['String']>
-    version: Scalars['Int']
-    thread: ProposalDiscussionThread
-    threadId: Scalars['String']
-  }
-
-export type ProposalDiscussionThreadCreatedEventConnection = {
-  totalCount: Scalars['Int']
-  edges: Array<ProposalDiscussionThreadCreatedEventEdge>
-  pageInfo: PageInfo
-}
-
-export type ProposalDiscussionThreadCreatedEventCreateInput = {
-  inExtrinsic?: Maybe<Scalars['String']>
-  inBlock: Scalars['Float']
-  network: Network
-  indexInBlock: Scalars['Float']
-  thread: Scalars['ID']
-}
-
-export type ProposalDiscussionThreadCreatedEventEdge = {
-  node: ProposalDiscussionThreadCreatedEvent
-  cursor: Scalars['String']
-}
-
-export enum ProposalDiscussionThreadCreatedEventOrderByInput {
-  CreatedAtAsc = 'createdAt_ASC',
-  CreatedAtDesc = 'createdAt_DESC',
-  UpdatedAtAsc = 'updatedAt_ASC',
-  UpdatedAtDesc = 'updatedAt_DESC',
-  DeletedAtAsc = 'deletedAt_ASC',
-  DeletedAtDesc = 'deletedAt_DESC',
-  InExtrinsicAsc = 'inExtrinsic_ASC',
-  InExtrinsicDesc = 'inExtrinsic_DESC',
-  InBlockAsc = 'inBlock_ASC',
-  InBlockDesc = 'inBlock_DESC',
-  NetworkAsc = 'network_ASC',
-  NetworkDesc = 'network_DESC',
-  IndexInBlockAsc = 'indexInBlock_ASC',
-  IndexInBlockDesc = 'indexInBlock_DESC',
-  ThreadAsc = 'thread_ASC',
-  ThreadDesc = 'thread_DESC',
-}
-
-export type ProposalDiscussionThreadCreatedEventUpdateInput = {
-  inExtrinsic?: Maybe<Scalars['String']>
-  inBlock?: Maybe<Scalars['Float']>
-  network?: Maybe<Network>
-  indexInBlock?: Maybe<Scalars['Float']>
-  thread?: Maybe<Scalars['ID']>
-}
-
-export type ProposalDiscussionThreadCreatedEventWhereInput = {
-  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']>>
-  inExtrinsic_eq?: Maybe<Scalars['String']>
-  inExtrinsic_contains?: Maybe<Scalars['String']>
-  inExtrinsic_startsWith?: Maybe<Scalars['String']>
-  inExtrinsic_endsWith?: Maybe<Scalars['String']>
-  inExtrinsic_in?: Maybe<Array<Scalars['String']>>
-  inBlock_eq?: Maybe<Scalars['Int']>
-  inBlock_gt?: Maybe<Scalars['Int']>
-  inBlock_gte?: Maybe<Scalars['Int']>
-  inBlock_lt?: Maybe<Scalars['Int']>
-  inBlock_lte?: Maybe<Scalars['Int']>
-  inBlock_in?: Maybe<Array<Scalars['Int']>>
-  network_eq?: Maybe<Network>
-  network_in?: Maybe<Array<Network>>
-  indexInBlock_eq?: Maybe<Scalars['Int']>
-  indexInBlock_gt?: Maybe<Scalars['Int']>
-  indexInBlock_gte?: Maybe<Scalars['Int']>
-  indexInBlock_lt?: Maybe<Scalars['Int']>
-  indexInBlock_lte?: Maybe<Scalars['Int']>
-  indexInBlock_in?: Maybe<Array<Scalars['Int']>>
-  thread_eq?: Maybe<Scalars['ID']>
-  thread_in?: Maybe<Array<Scalars['ID']>>
-  thread?: Maybe<ProposalDiscussionThreadWhereInput>
-  AND?: Maybe<Array<ProposalDiscussionThreadCreatedEventWhereInput>>
-  OR?: Maybe<Array<ProposalDiscussionThreadCreatedEventWhereInput>>
-}
-
-export type ProposalDiscussionThreadCreatedEventWhereUniqueInput = {
-  id: Scalars['ID']
-}
-
 export type ProposalDiscussionThreadCreateInput = {
   proposal: Scalars['ID']
   mode: Scalars['JSONObject']
@@ -6560,12 +6433,9 @@ export type ProposalDiscussionThreadWhereInput = {
   posts_none?: Maybe<ProposalDiscussionPostWhereInput>
   posts_some?: Maybe<ProposalDiscussionPostWhereInput>
   posts_every?: Maybe<ProposalDiscussionPostWhereInput>
-  proposaldiscussionthreadcreatedeventthread_none?: Maybe<ProposalDiscussionThreadCreatedEventWhereInput>
-  proposaldiscussionthreadcreatedeventthread_some?: Maybe<ProposalDiscussionThreadCreatedEventWhereInput>
-  proposaldiscussionthreadcreatedeventthread_every?: Maybe<ProposalDiscussionThreadCreatedEventWhereInput>
-  proposaldiscussionthreadmodechangedeventthread_none?: Maybe<ProposalDiscussionThreadModeChangedEventWhereInput>
-  proposaldiscussionthreadmodechangedeventthread_some?: Maybe<ProposalDiscussionThreadModeChangedEventWhereInput>
-  proposaldiscussionthreadmodechangedeventthread_every?: Maybe<ProposalDiscussionThreadModeChangedEventWhereInput>
+  modeChanges_none?: Maybe<ProposalDiscussionThreadModeChangedEventWhereInput>
+  modeChanges_some?: Maybe<ProposalDiscussionThreadModeChangedEventWhereInput>
+  modeChanges_every?: Maybe<ProposalDiscussionThreadModeChangedEventWhereInput>
   AND?: Maybe<Array<ProposalDiscussionThreadWhereInput>>
   OR?: Maybe<Array<ProposalDiscussionThreadWhereInput>>
 }
@@ -7476,9 +7346,6 @@ export type Query = {
   proposalDiscussionPosts: Array<ProposalDiscussionPost>
   proposalDiscussionPostByUniqueInput?: Maybe<ProposalDiscussionPost>
   proposalDiscussionPostsConnection: ProposalDiscussionPostConnection
-  proposalDiscussionThreadCreatedEvents: Array<ProposalDiscussionThreadCreatedEvent>
-  proposalDiscussionThreadCreatedEventByUniqueInput?: Maybe<ProposalDiscussionThreadCreatedEvent>
-  proposalDiscussionThreadCreatedEventsConnection: ProposalDiscussionThreadCreatedEventConnection
   proposalDiscussionThreadModeChangedEvents: Array<ProposalDiscussionThreadModeChangedEvent>
   proposalDiscussionThreadModeChangedEventByUniqueInput?: Maybe<ProposalDiscussionThreadModeChangedEvent>
   proposalDiscussionThreadModeChangedEventsConnection: ProposalDiscussionThreadModeChangedEventConnection
@@ -8284,26 +8151,6 @@ export type QueryProposalDiscussionPostsConnectionArgs = {
   orderBy?: Maybe<Array<ProposalDiscussionPostOrderByInput>>
 }
 
-export type QueryProposalDiscussionThreadCreatedEventsArgs = {
-  offset?: Maybe<Scalars['Int']>
-  limit?: Maybe<Scalars['Int']>
-  where?: Maybe<ProposalDiscussionThreadCreatedEventWhereInput>
-  orderBy?: Maybe<Array<ProposalDiscussionThreadCreatedEventOrderByInput>>
-}
-
-export type QueryProposalDiscussionThreadCreatedEventByUniqueInputArgs = {
-  where: ProposalDiscussionThreadCreatedEventWhereUniqueInput
-}
-
-export type QueryProposalDiscussionThreadCreatedEventsConnectionArgs = {
-  first?: Maybe<Scalars['Int']>
-  after?: Maybe<Scalars['String']>
-  last?: Maybe<Scalars['Int']>
-  before?: Maybe<Scalars['String']>
-  where?: Maybe<ProposalDiscussionThreadCreatedEventWhereInput>
-  orderBy?: Maybe<Array<ProposalDiscussionThreadCreatedEventOrderByInput>>
-}
-
 export type QueryProposalDiscussionThreadModeChangedEventsArgs = {
   offset?: Maybe<Scalars['Int']>
   limit?: Maybe<Scalars['Int']>

+ 6 - 0
tests/integration-tests/src/graphql/queries/proposals.graphql

@@ -269,6 +269,12 @@ fragment ProposalFields on Proposal {
     inBlock
     inExtrinsic
   }
+  discussionThread {
+    id
+    mode {
+      ...ProposalDiscussionThreadModeFields
+    }
+  }
 }
 
 query getProposalsByIds($ids: [ID!]) {

+ 75 - 0
tests/integration-tests/src/graphql/queries/proposalsDiscussion.graphql

@@ -0,0 +1,75 @@
+fragment ProposalDiscussionThreadModeFields on ProposalDiscussionThreadMode {
+  __typename
+  ... on ProposalDiscussionThreadModeClosed {
+    whitelist {
+      members {
+        id
+      }
+    }
+  }
+}
+
+fragment ProposalDiscussionPostStatusFields on ProposalDiscussionPostStatus {
+  __typename
+  ... on ProposalDiscussionPostStatusLocked {
+    deletedInEvent {
+      id
+    }
+  }
+  ... on ProposalDiscussionPostStatusRemoved {
+    deletedInEvent {
+      id
+    }
+  }
+}
+
+fragment ProposalDiscussionThreadFields on ProposalDiscussionThread {
+  id
+  proposal {
+    id
+  }
+  posts {
+    id
+  }
+  mode {
+    ...ProposalDiscussionThreadModeFields
+  }
+  modeChanges {
+    id
+  }
+}
+
+query getProposalDiscussionThreadsByIds($ids: [ID!]) {
+  proposalDiscussionThreads(where: { id_in: $ids }) {
+    ...ProposalDiscussionThreadFields
+  }
+}
+
+fragment ProposalDiscussionPostFields on ProposalDiscussionPost {
+  id
+  thread {
+    id
+  }
+  author {
+    id
+  }
+  status {
+    ...ProposalDiscussionPostStatusFields
+  }
+  text
+  repliesTo {
+    id
+  }
+  updates {
+    id
+  }
+  createdInEvent {
+    id
+  }
+}
+
+query getProposalDiscussionPostsByIds($ids: [ID!]) {
+  proposalDiscussionPosts(where: { id_in: $ids }) {
+    ...ProposalDiscussionPostFields
+  }
+}

+ 82 - 0
tests/integration-tests/src/graphql/queries/proposalsDiscussionEvents.graphql

@@ -0,0 +1,82 @@
+fragment ProposalDiscussionPostCreatedEventFields on ProposalDiscussionPostCreatedEvent {
+  id
+  createdAt
+  inExtrinsic
+  inBlock
+  network
+  indexInBlock
+  post {
+    id
+  }
+  text
+}
+
+query getProposalDiscussionPostCreatedEvents($eventIds: [ID!]) {
+  proposalDiscussionPostCreatedEvents(where: { id_in: $eventIds }) {
+    ...ProposalDiscussionPostCreatedEventFields
+  }
+}
+
+fragment ProposalDiscussionPostUpdatedEventFields on ProposalDiscussionPostUpdatedEvent {
+  id
+  createdAt
+  inExtrinsic
+  inBlock
+  network
+  indexInBlock
+  post {
+    id
+  }
+  text
+}
+
+query getProposalDiscussionPostUpdatedEvents($eventIds: [ID!]) {
+  proposalDiscussionPostUpdatedEvents(where: { id_in: $eventIds }) {
+    ...ProposalDiscussionPostUpdatedEventFields
+  }
+}
+
+fragment ProposalDiscussionThreadModeChangedEventFields on ProposalDiscussionThreadModeChangedEvent {
+  id
+  createdAt
+  inExtrinsic
+  inBlock
+  network
+  indexInBlock
+  thread {
+    id
+  }
+  newMode {
+    ...ProposalDiscussionThreadModeFields
+  }
+  actor {
+    id
+  }
+}
+
+query getProposalDiscussionThreadModeChangedEvents($eventIds: [ID!]) {
+  proposalDiscussionThreadModeChangedEvents(where: { id_in: $eventIds }) {
+    ...ProposalDiscussionThreadModeChangedEventFields
+  }
+}
+
+fragment ProposalDiscussionPostDeletedEventFields on ProposalDiscussionPostDeletedEvent {
+  id
+  createdAt
+  inExtrinsic
+  inBlock
+  network
+  indexInBlock
+  post {
+    id
+  }
+  actor {
+    id
+  }
+}
+
+query getProposalDiscussionPostDeletedEvents($eventIds: [ID!]) {
+  proposalDiscussionPostDeletedEvents(where: { id_in: $eventIds }) {
+    ...ProposalDiscussionPostDeletedEventFields
+  }
+}

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

@@ -18,6 +18,7 @@ import electCouncil from '../flows/council/elect'
 import runtimeUpgradeProposal from '../flows/proposals/runtimeUpgradeProposal'
 import exactExecutionBlock from '../flows/proposals/exactExecutionBlock'
 import expireProposal from '../flows/proposals/expireProposal'
+import proposalsDiscussion from '../flows/proposalsDiscussion'
 import { scenario } from '../Scenario'
 
 scenario(async ({ job, env }) => {
@@ -44,12 +45,13 @@ scenario(async ({ job, env }) => {
   job('managing staking accounts', managingStakingAccounts).after(membershipSystemJob)
 
   // Proposals:
-  const proposalsJob = job('proposals', [
+  const proposalsJob = job('proposals & proposal discussion', [
     proposals,
     cancellingProposals,
     vetoProposal,
     exactExecutionBlock,
     expireProposal,
+    proposalsDiscussion,
   ]).requires(membershipSystemJob)
 
   // Working groups

+ 9 - 3
tests/integration-tests/src/scenarios/proposals.ts

@@ -5,6 +5,7 @@ import electCouncil from '../flows/council/elect'
 import runtimeUpgradeProposal from '../flows/proposals/runtimeUpgradeProposal'
 import exactExecutionBlock from '../flows/proposals/exactExecutionBlock'
 import expireProposal from '../flows/proposals/expireProposal'
+import proposalsDiscussion from '../flows/proposalsDiscussion'
 import { scenario } from '../Scenario'
 
 scenario(async ({ job, env }) => {
@@ -12,7 +13,12 @@ scenario(async ({ job, env }) => {
   const runtimeUpgradeProposalJob = env.RUNTIME_UPGRADE_TARGET_WASM_PATH
     ? job('runtime upgrade proposal', runtimeUpgradeProposal).requires(councilJob)
     : undefined
-  job('proposals', [proposals, cancellingProposals, vetoProposal, exactExecutionBlock, expireProposal]).requires(
-    runtimeUpgradeProposalJob || councilJob
-  )
+  job('proposals & proposal discussion', [
+    proposals,
+    cancellingProposals,
+    vetoProposal,
+    exactExecutionBlock,
+    expireProposal,
+    proposalsDiscussion,
+  ]).requires(runtimeUpgradeProposalJob || councilJob)
 })

+ 8 - 0
tests/integration-tests/src/scenarios/proposalsDiscussion.ts

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

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

@@ -1,4 +1,4 @@
-import { MemberId } from '@joystream/types/common'
+import { MemberId, PostId } from '@joystream/types/common'
 import { ApplicationId, OpeningId, WorkerId, ApplyOnOpeningParameters } from '@joystream/types/working-group'
 import { Event } from '@polkadot/types/interfaces/system'
 import { BTreeMap } from '@polkadot/types'
@@ -11,6 +11,11 @@ export type MemberContext = {
   memberId: MemberId
 }
 
+export type MetadataInput<T> = {
+  value: T | string
+  expectFailure?: boolean
+}
+
 export type AnyQueryNodeEvent = Pick<
   MembershipBoughtEvent,
   'createdAt' | 'updatedAt' | 'id' | 'inBlock' | 'inExtrinsic' | 'indexInBlock' | 'network'
@@ -105,6 +110,17 @@ export type ProposalsEngineEventName =
   | 'Voted'
   | 'ProposalCancelled'
 
+export type ProposalsDiscussionEventName =
+  | 'ThreadCreated'
+  | 'PostCreated'
+  | 'PostUpdated'
+  | 'ThreadModeChanged'
+  | 'PostDeleted'
+
+export interface ProposalDiscussionPostCreatedEventDetails extends EventDetails {
+  postId: PostId
+}
+
 export type ProposalType = keyof typeof ProposalDetails.typeDefinitions
 export type ProposalDetailsJsonByType<T extends ProposalType = ProposalType> = CreateInterface<
   InstanceType<ProposalDetails['typeDefinitions'][T]>

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

@@ -8,6 +8,7 @@ import { Bytes } from '@polkadot/types'
 import { createType } from '@joystream/types'
 import Debugger from 'debug'
 import { BLOCKTIME } from './consts'
+import { MetadataInput } from './types'
 
 export type AnyMessage<T> = T & {
   toJSON(): Record<string, unknown>
@@ -68,6 +69,43 @@ 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 asText(textOrHex: string): string {
+    return Utils.bytesToString(createType('Bytes', textOrHex))
+  }
+
   public static assert(condition: any, msg?: string): asserts condition {
     if (!condition) {
       throw new Error(msg || 'Assertion failed')