Browse Source

Merge branch 'forum-mappings' into olympia-staging

Leszek Wiesner 3 years ago
parent
commit
9e6105ccbb
50 changed files with 6867 additions and 378 deletions
  1. 189 0
      metadata-protobuf/compiled/index.d.ts
  2. 384 0
      metadata-protobuf/compiled/index.js
  3. 70 22
      metadata-protobuf/doc/index.md
  4. 1 1
      metadata-protobuf/package.json
  5. 14 0
      metadata-protobuf/proto/Forum.proto
  6. 54 1
      query-node/manifest.yml
  7. 18 2
      query-node/mappings/common.ts
  8. 568 0
      query-node/mappings/forum.ts
  9. 1 0
      query-node/mappings/index.ts
  10. 1 0
      query-node/mappings/init.ts
  11. 12 21
      query-node/mappings/workingGroups.ts
  12. 227 0
      query-node/schemas/forum.graphql
  13. 500 0
      query-node/schemas/forumEvents.graphql
  14. 3 0
      query-node/schemas/workingGroups.graphql
  15. 38 1
      tests/integration-tests/src/Api.ts
  16. 240 1
      tests/integration-tests/src/QueryNodeApi.ts
  17. 2 0
      tests/integration-tests/src/consts.ts
  18. 122 0
      tests/integration-tests/src/fixtures/forum/AddPostsFixture.ts
  19. 80 0
      tests/integration-tests/src/fixtures/forum/CreateCategoriesFixture.ts
  20. 140 0
      tests/integration-tests/src/fixtures/forum/CreateThreadsFixture.ts
  21. 100 0
      tests/integration-tests/src/fixtures/forum/DeletePostsFixture.ts
  22. 95 0
      tests/integration-tests/src/fixtures/forum/DeleteThreadsFixture.ts
  23. 234 0
      tests/integration-tests/src/fixtures/forum/InitializeForumFixture.ts
  24. 86 0
      tests/integration-tests/src/fixtures/forum/ModeratePostsFixture.ts
  25. 84 0
      tests/integration-tests/src/fixtures/forum/ModerateThreadsFixture.ts
  26. 93 0
      tests/integration-tests/src/fixtures/forum/MoveThreadsFixture.ts
  27. 114 0
      tests/integration-tests/src/fixtures/forum/ReactToPostsFixture.ts
  28. 73 0
      tests/integration-tests/src/fixtures/forum/RemoveCategoriesFixture.ts
  29. 85 0
      tests/integration-tests/src/fixtures/forum/SetStickyThreadsFixture.ts
  30. 90 0
      tests/integration-tests/src/fixtures/forum/UpdateCategoriesStatusFixture.ts
  31. 90 0
      tests/integration-tests/src/fixtures/forum/UpdateCategoryModeratorsFixture.ts
  32. 96 0
      tests/integration-tests/src/fixtures/forum/UpdatePostsTextFixture.ts
  33. 103 0
      tests/integration-tests/src/fixtures/forum/UpdateThreadTitlesFixture.ts
  34. 75 0
      tests/integration-tests/src/fixtures/forum/VoteOnPollFixture.ts
  35. 28 0
      tests/integration-tests/src/fixtures/forum/WithForumWorkersFixture.ts
  36. 17 0
      tests/integration-tests/src/fixtures/forum/index.ts
  37. 107 0
      tests/integration-tests/src/flows/forum/categories.ts
  38. 71 0
      tests/integration-tests/src/flows/forum/moderation.ts
  39. 72 0
      tests/integration-tests/src/flows/forum/polls.ts
  40. 199 0
      tests/integration-tests/src/flows/forum/posts.ts
  41. 92 0
      tests/integration-tests/src/flows/forum/threads.ts
  42. 972 18
      tests/integration-tests/src/graphql/generated/queries.ts
  43. 574 293
      tests/integration-tests/src/graphql/generated/schema.ts
  44. 166 0
      tests/integration-tests/src/graphql/queries/forum.graphql
  45. 352 0
      tests/integration-tests/src/graphql/queries/forumEvents.graphql
  46. 16 0
      tests/integration-tests/src/scenarios/forum.ts
  47. 16 1
      tests/integration-tests/src/scenarios/full.ts
  48. 59 7
      tests/integration-tests/src/types.ts
  49. 34 0
      tests/integration-tests/src/utils.ts
  50. 10 10
      yarn.lock

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

@@ -107,6 +107,195 @@ export class CouncilCandidacyNoteMetadata implements ICouncilCandidacyNoteMetada
     public toJSON(): { [k: string]: any };
 }
 
+/** Properties of a ForumPostReaction. */
+export interface IForumPostReaction {
+}
+
+/** Represents a ForumPostReaction. */
+export class ForumPostReaction implements IForumPostReaction {
+
+    /**
+     * Constructs a new ForumPostReaction.
+     * @param [properties] Properties to set
+     */
+    constructor(properties?: IForumPostReaction);
+
+    /**
+     * Creates a new ForumPostReaction instance using the specified properties.
+     * @param [properties] Properties to set
+     * @returns ForumPostReaction instance
+     */
+    public static create(properties?: IForumPostReaction): ForumPostReaction;
+
+    /**
+     * Encodes the specified ForumPostReaction message. Does not implicitly {@link ForumPostReaction.verify|verify} messages.
+     * @param message ForumPostReaction message or plain object to encode
+     * @param [writer] Writer to encode to
+     * @returns Writer
+     */
+    public static encode(message: IForumPostReaction, writer?: $protobuf.Writer): $protobuf.Writer;
+
+    /**
+     * Encodes the specified ForumPostReaction message, length delimited. Does not implicitly {@link ForumPostReaction.verify|verify} messages.
+     * @param message ForumPostReaction message or plain object to encode
+     * @param [writer] Writer to encode to
+     * @returns Writer
+     */
+    public static encodeDelimited(message: IForumPostReaction, writer?: $protobuf.Writer): $protobuf.Writer;
+
+    /**
+     * Decodes a ForumPostReaction message from the specified reader or buffer.
+     * @param reader Reader or buffer to decode from
+     * @param [length] Message length if known beforehand
+     * @returns ForumPostReaction
+     * @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): ForumPostReaction;
+
+    /**
+     * Decodes a ForumPostReaction message from the specified reader or buffer, length delimited.
+     * @param reader Reader or buffer to decode from
+     * @returns ForumPostReaction
+     * @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)): ForumPostReaction;
+
+    /**
+     * Verifies a ForumPostReaction 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 ForumPostReaction message from a plain object. Also converts values to their respective internal types.
+     * @param object Plain object
+     * @returns ForumPostReaction
+     */
+    public static fromObject(object: { [k: string]: any }): ForumPostReaction;
+
+    /**
+     * Creates a plain object from a ForumPostReaction message. Also converts values to other types if specified.
+     * @param message ForumPostReaction
+     * @param [options] Conversion options
+     * @returns Plain object
+     */
+    public static toObject(message: ForumPostReaction, options?: $protobuf.IConversionOptions): { [k: string]: any };
+
+    /**
+     * Converts this ForumPostReaction to JSON.
+     * @returns JSON object
+     */
+    public toJSON(): { [k: string]: any };
+}
+
+export namespace ForumPostReaction {
+
+    /** Reaction enum. */
+    enum Reaction {
+        CANCEL = 0,
+        LIKE = 1
+    }
+}
+
+/** Properties of a ForumPostMetadata. */
+export interface IForumPostMetadata {
+
+    /** ForumPostMetadata text */
+    text?: (string|null);
+
+    /** ForumPostMetadata repliesTo */
+    repliesTo?: (number|null);
+}
+
+/** Represents a ForumPostMetadata. */
+export class ForumPostMetadata implements IForumPostMetadata {
+
+    /**
+     * Constructs a new ForumPostMetadata.
+     * @param [properties] Properties to set
+     */
+    constructor(properties?: IForumPostMetadata);
+
+    /** ForumPostMetadata text. */
+    public text: string;
+
+    /** ForumPostMetadata repliesTo. */
+    public repliesTo: number;
+
+    /**
+     * Creates a new ForumPostMetadata instance using the specified properties.
+     * @param [properties] Properties to set
+     * @returns ForumPostMetadata instance
+     */
+    public static create(properties?: IForumPostMetadata): ForumPostMetadata;
+
+    /**
+     * Encodes the specified ForumPostMetadata message. Does not implicitly {@link ForumPostMetadata.verify|verify} messages.
+     * @param message ForumPostMetadata message or plain object to encode
+     * @param [writer] Writer to encode to
+     * @returns Writer
+     */
+    public static encode(message: IForumPostMetadata, writer?: $protobuf.Writer): $protobuf.Writer;
+
+    /**
+     * Encodes the specified ForumPostMetadata message, length delimited. Does not implicitly {@link ForumPostMetadata.verify|verify} messages.
+     * @param message ForumPostMetadata message or plain object to encode
+     * @param [writer] Writer to encode to
+     * @returns Writer
+     */
+    public static encodeDelimited(message: IForumPostMetadata, writer?: $protobuf.Writer): $protobuf.Writer;
+
+    /**
+     * Decodes a ForumPostMetadata message from the specified reader or buffer.
+     * @param reader Reader or buffer to decode from
+     * @param [length] Message length if known beforehand
+     * @returns ForumPostMetadata
+     * @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): ForumPostMetadata;
+
+    /**
+     * Decodes a ForumPostMetadata message from the specified reader or buffer, length delimited.
+     * @param reader Reader or buffer to decode from
+     * @returns ForumPostMetadata
+     * @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)): ForumPostMetadata;
+
+    /**
+     * Verifies a ForumPostMetadata 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 ForumPostMetadata message from a plain object. Also converts values to their respective internal types.
+     * @param object Plain object
+     * @returns ForumPostMetadata
+     */
+    public static fromObject(object: { [k: string]: any }): ForumPostMetadata;
+
+    /**
+     * Creates a plain object from a ForumPostMetadata message. Also converts values to other types if specified.
+     * @param message ForumPostMetadata
+     * @param [options] Conversion options
+     * @returns Plain object
+     */
+    public static toObject(message: ForumPostMetadata, options?: $protobuf.IConversionOptions): { [k: string]: any };
+
+    /**
+     * Converts this ForumPostMetadata to JSON.
+     * @returns JSON object
+     */
+    public toJSON(): { [k: string]: any };
+}
+
 /** Properties of a MembershipMetadata. */
 export interface IMembershipMetadata {
 

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

@@ -280,6 +280,390 @@ $root.CouncilCandidacyNoteMetadata = (function() {
     return CouncilCandidacyNoteMetadata;
 })();
 
+$root.ForumPostReaction = (function() {
+
+    /**
+     * Properties of a ForumPostReaction.
+     * @exports IForumPostReaction
+     * @interface IForumPostReaction
+     */
+
+    /**
+     * Constructs a new ForumPostReaction.
+     * @exports ForumPostReaction
+     * @classdesc Represents a ForumPostReaction.
+     * @implements IForumPostReaction
+     * @constructor
+     * @param {IForumPostReaction=} [properties] Properties to set
+     */
+    function ForumPostReaction(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]];
+    }
+
+    /**
+     * Creates a new ForumPostReaction instance using the specified properties.
+     * @function create
+     * @memberof ForumPostReaction
+     * @static
+     * @param {IForumPostReaction=} [properties] Properties to set
+     * @returns {ForumPostReaction} ForumPostReaction instance
+     */
+    ForumPostReaction.create = function create(properties) {
+        return new ForumPostReaction(properties);
+    };
+
+    /**
+     * Encodes the specified ForumPostReaction message. Does not implicitly {@link ForumPostReaction.verify|verify} messages.
+     * @function encode
+     * @memberof ForumPostReaction
+     * @static
+     * @param {IForumPostReaction} message ForumPostReaction message or plain object to encode
+     * @param {$protobuf.Writer} [writer] Writer to encode to
+     * @returns {$protobuf.Writer} Writer
+     */
+    ForumPostReaction.encode = function encode(message, writer) {
+        if (!writer)
+            writer = $Writer.create();
+        return writer;
+    };
+
+    /**
+     * Encodes the specified ForumPostReaction message, length delimited. Does not implicitly {@link ForumPostReaction.verify|verify} messages.
+     * @function encodeDelimited
+     * @memberof ForumPostReaction
+     * @static
+     * @param {IForumPostReaction} message ForumPostReaction message or plain object to encode
+     * @param {$protobuf.Writer} [writer] Writer to encode to
+     * @returns {$protobuf.Writer} Writer
+     */
+    ForumPostReaction.encodeDelimited = function encodeDelimited(message, writer) {
+        return this.encode(message, writer).ldelim();
+    };
+
+    /**
+     * Decodes a ForumPostReaction message from the specified reader or buffer.
+     * @function decode
+     * @memberof ForumPostReaction
+     * @static
+     * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from
+     * @param {number} [length] Message length if known beforehand
+     * @returns {ForumPostReaction} ForumPostReaction
+     * @throws {Error} If the payload is not a reader or valid buffer
+     * @throws {$protobuf.util.ProtocolError} If required fields are missing
+     */
+    ForumPostReaction.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.ForumPostReaction();
+        while (reader.pos < end) {
+            var tag = reader.uint32();
+            switch (tag >>> 3) {
+            default:
+                reader.skipType(tag & 7);
+                break;
+            }
+        }
+        return message;
+    };
+
+    /**
+     * Decodes a ForumPostReaction message from the specified reader or buffer, length delimited.
+     * @function decodeDelimited
+     * @memberof ForumPostReaction
+     * @static
+     * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from
+     * @returns {ForumPostReaction} ForumPostReaction
+     * @throws {Error} If the payload is not a reader or valid buffer
+     * @throws {$protobuf.util.ProtocolError} If required fields are missing
+     */
+    ForumPostReaction.decodeDelimited = function decodeDelimited(reader) {
+        if (!(reader instanceof $Reader))
+            reader = new $Reader(reader);
+        return this.decode(reader, reader.uint32());
+    };
+
+    /**
+     * Verifies a ForumPostReaction message.
+     * @function verify
+     * @memberof ForumPostReaction
+     * @static
+     * @param {Object.<string,*>} message Plain object to verify
+     * @returns {string|null} `null` if valid, otherwise the reason why it is not
+     */
+    ForumPostReaction.verify = function verify(message) {
+        if (typeof message !== "object" || message === null)
+            return "object expected";
+        return null;
+    };
+
+    /**
+     * Creates a ForumPostReaction message from a plain object. Also converts values to their respective internal types.
+     * @function fromObject
+     * @memberof ForumPostReaction
+     * @static
+     * @param {Object.<string,*>} object Plain object
+     * @returns {ForumPostReaction} ForumPostReaction
+     */
+    ForumPostReaction.fromObject = function fromObject(object) {
+        if (object instanceof $root.ForumPostReaction)
+            return object;
+        return new $root.ForumPostReaction();
+    };
+
+    /**
+     * Creates a plain object from a ForumPostReaction message. Also converts values to other types if specified.
+     * @function toObject
+     * @memberof ForumPostReaction
+     * @static
+     * @param {ForumPostReaction} message ForumPostReaction
+     * @param {$protobuf.IConversionOptions} [options] Conversion options
+     * @returns {Object.<string,*>} Plain object
+     */
+    ForumPostReaction.toObject = function toObject() {
+        return {};
+    };
+
+    /**
+     * Converts this ForumPostReaction to JSON.
+     * @function toJSON
+     * @memberof ForumPostReaction
+     * @instance
+     * @returns {Object.<string,*>} JSON object
+     */
+    ForumPostReaction.prototype.toJSON = function toJSON() {
+        return this.constructor.toObject(this, $protobuf.util.toJSONOptions);
+    };
+
+    /**
+     * Reaction enum.
+     * @name ForumPostReaction.Reaction
+     * @enum {number}
+     * @property {number} CANCEL=0 CANCEL value
+     * @property {number} LIKE=1 LIKE value
+     */
+    ForumPostReaction.Reaction = (function() {
+        var valuesById = {}, values = Object.create(valuesById);
+        values[valuesById[0] = "CANCEL"] = 0;
+        values[valuesById[1] = "LIKE"] = 1;
+        return values;
+    })();
+
+    return ForumPostReaction;
+})();
+
+$root.ForumPostMetadata = (function() {
+
+    /**
+     * Properties of a ForumPostMetadata.
+     * @exports IForumPostMetadata
+     * @interface IForumPostMetadata
+     * @property {string|null} [text] ForumPostMetadata text
+     * @property {number|null} [repliesTo] ForumPostMetadata repliesTo
+     */
+
+    /**
+     * Constructs a new ForumPostMetadata.
+     * @exports ForumPostMetadata
+     * @classdesc Represents a ForumPostMetadata.
+     * @implements IForumPostMetadata
+     * @constructor
+     * @param {IForumPostMetadata=} [properties] Properties to set
+     */
+    function ForumPostMetadata(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]];
+    }
+
+    /**
+     * ForumPostMetadata text.
+     * @member {string} text
+     * @memberof ForumPostMetadata
+     * @instance
+     */
+    ForumPostMetadata.prototype.text = "";
+
+    /**
+     * ForumPostMetadata repliesTo.
+     * @member {number} repliesTo
+     * @memberof ForumPostMetadata
+     * @instance
+     */
+    ForumPostMetadata.prototype.repliesTo = 0;
+
+    /**
+     * Creates a new ForumPostMetadata instance using the specified properties.
+     * @function create
+     * @memberof ForumPostMetadata
+     * @static
+     * @param {IForumPostMetadata=} [properties] Properties to set
+     * @returns {ForumPostMetadata} ForumPostMetadata instance
+     */
+    ForumPostMetadata.create = function create(properties) {
+        return new ForumPostMetadata(properties);
+    };
+
+    /**
+     * Encodes the specified ForumPostMetadata message. Does not implicitly {@link ForumPostMetadata.verify|verify} messages.
+     * @function encode
+     * @memberof ForumPostMetadata
+     * @static
+     * @param {IForumPostMetadata} message ForumPostMetadata message or plain object to encode
+     * @param {$protobuf.Writer} [writer] Writer to encode to
+     * @returns {$protobuf.Writer} Writer
+     */
+    ForumPostMetadata.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 ForumPostMetadata message, length delimited. Does not implicitly {@link ForumPostMetadata.verify|verify} messages.
+     * @function encodeDelimited
+     * @memberof ForumPostMetadata
+     * @static
+     * @param {IForumPostMetadata} message ForumPostMetadata message or plain object to encode
+     * @param {$protobuf.Writer} [writer] Writer to encode to
+     * @returns {$protobuf.Writer} Writer
+     */
+    ForumPostMetadata.encodeDelimited = function encodeDelimited(message, writer) {
+        return this.encode(message, writer).ldelim();
+    };
+
+    /**
+     * Decodes a ForumPostMetadata message from the specified reader or buffer.
+     * @function decode
+     * @memberof ForumPostMetadata
+     * @static
+     * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from
+     * @param {number} [length] Message length if known beforehand
+     * @returns {ForumPostMetadata} ForumPostMetadata
+     * @throws {Error} If the payload is not a reader or valid buffer
+     * @throws {$protobuf.util.ProtocolError} If required fields are missing
+     */
+    ForumPostMetadata.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.ForumPostMetadata();
+        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 ForumPostMetadata message from the specified reader or buffer, length delimited.
+     * @function decodeDelimited
+     * @memberof ForumPostMetadata
+     * @static
+     * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from
+     * @returns {ForumPostMetadata} ForumPostMetadata
+     * @throws {Error} If the payload is not a reader or valid buffer
+     * @throws {$protobuf.util.ProtocolError} If required fields are missing
+     */
+    ForumPostMetadata.decodeDelimited = function decodeDelimited(reader) {
+        if (!(reader instanceof $Reader))
+            reader = new $Reader(reader);
+        return this.decode(reader, reader.uint32());
+    };
+
+    /**
+     * Verifies a ForumPostMetadata message.
+     * @function verify
+     * @memberof ForumPostMetadata
+     * @static
+     * @param {Object.<string,*>} message Plain object to verify
+     * @returns {string|null} `null` if valid, otherwise the reason why it is not
+     */
+    ForumPostMetadata.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 ForumPostMetadata message from a plain object. Also converts values to their respective internal types.
+     * @function fromObject
+     * @memberof ForumPostMetadata
+     * @static
+     * @param {Object.<string,*>} object Plain object
+     * @returns {ForumPostMetadata} ForumPostMetadata
+     */
+    ForumPostMetadata.fromObject = function fromObject(object) {
+        if (object instanceof $root.ForumPostMetadata)
+            return object;
+        var message = new $root.ForumPostMetadata();
+        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 ForumPostMetadata message. Also converts values to other types if specified.
+     * @function toObject
+     * @memberof ForumPostMetadata
+     * @static
+     * @param {ForumPostMetadata} message ForumPostMetadata
+     * @param {$protobuf.IConversionOptions} [options] Conversion options
+     * @returns {Object.<string,*>} Plain object
+     */
+    ForumPostMetadata.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 ForumPostMetadata to JSON.
+     * @function toJSON
+     * @memberof ForumPostMetadata
+     * @instance
+     * @returns {Object.<string,*>} JSON object
+     */
+    ForumPostMetadata.prototype.toJSON = function toJSON() {
+        return this.constructor.toObject(this, $protobuf.util.toJSONOptions);
+    };
+
+    return ForumPostMetadata;
+})();
+
 $root.MembershipMetadata = (function() {
 
     /**

+ 70 - 22
metadata-protobuf/doc/index.md

@@ -5,10 +5,15 @@
 
 - [proto/Council.proto](#proto/Council.proto)
     - [CouncilCandidacyNoteMetadata](#.CouncilCandidacyNoteMetadata)
-  
+
+- [proto/Forum.proto](#proto/Forum.proto)
+    - [ForumPostMetadata](#.ForumPostMetadata)
+
+    - [ForumPostReaction](#.ForumPostReaction)
+
 - [proto/Membership.proto](#proto/Membership.proto)
     - [MembershipMetadata](#.MembershipMetadata)
-  
+
 - [proto/WorkingGroups.proto](#proto/WorkingGroups.proto)
     - [AddUpcomingOpening](#.AddUpcomingOpening)
     - [ApplicationMetadata](#.ApplicationMetadata)
@@ -19,9 +24,9 @@
     - [UpcomingOpeningMetadata](#.UpcomingOpeningMetadata)
     - [WorkingGroupMetadata](#.WorkingGroupMetadata)
     - [WorkingGroupMetadataAction](#.WorkingGroupMetadataAction)
-  
+
     - [OpeningMetadata.ApplicationFormQuestion.InputType](#.OpeningMetadata.ApplicationFormQuestion.InputType)
-  
+
 - [Scalar Value Types](#scalar-value-types)
 
 
@@ -50,13 +55,57 @@
 
 
 
- 
 
- 
 
- 
 
- 
+
+
+
+
+
+
+
+<a name="proto/Forum.proto"></a>
+<p align="right"><a href="#top">Top</a></p>
+
+## proto/Forum.proto
+
+
+
+<a name=".ForumPostMetadata"></a>
+
+### ForumPostMetadata
+
+
+
+| Field | Type | Label | Description |
+| ----- | ---- | ----- | ----------- |
+| text | [string](#string) | optional | Post text content (md-formatted) |
+| repliesTo | [uint32](#uint32) | optional | Id of the post that given post replies to (if any) |
+
+
+
+
+
+
+
+
+<a name=".ForumPostReaction"></a>
+
+### ForumPostReaction
+
+
+| Name | Number | Description |
+| ---- | ------ | ----------- |
+| CANCEL | 0 | This means cancelling any previous reaction |
+| LIKE | 1 |  |
+
+
+
+
+
+
+
 
 
 
@@ -83,13 +132,13 @@
 
 
 
- 
 
- 
 
- 
 
- 
+
+
+
+
 
 
 
@@ -222,10 +271,9 @@
 
 | Field | Type | Label | Description |
 | ----- | ---- | ----- | ----------- |
-| description | [string](#string) | optional | Group description text (md-formatted) |
-| about | [string](#string) | optional | Group about text (md-formatted) |
-| status | [string](#string) | optional | Current group status (expected to be 1-3 words) |
-| status_message | [string](#string) | optional | Short status message associated with the status |
+| set_group_metadata | [SetGroupMetadata](#SetGroupMetadata) | optional |  |
+| add_upcoming_opening | [AddUpcomingOpening](#AddUpcomingOpening) | optional |  |
+| remove_upcoming_opening | [RemoveUpcomingOpening](#RemoveUpcomingOpening) | optional |  |
 
 
 
@@ -248,7 +296,7 @@
 
 
 
- 
+
 
 
 <a name=".OpeningMetadata.ApplicationFormQuestion.InputType"></a>
@@ -262,11 +310,11 @@
 | TEXT | 1 |  |
 
 
- 
 
- 
 
- 
+
+
+
 
 
 
@@ -290,7 +338,7 @@
 | <a name="string" /> string | A string must always contain UTF-8 encoded or 7-bit ASCII text. | string | String | str/unicode | string | string | string | String (UTF-8) |
 | <a name="bytes" /> bytes | May contain any arbitrary sequence of bytes. | string | ByteString | str | []byte | ByteString | string | String (ASCII-8BIT) |
 
-<!-- 
+<!--
     This extra documentation will be appended to the generated docs.
 -->
 
@@ -330,4 +378,4 @@ meta = VideoMetadata {
     thumbnail_photo: 0,
     ...
 };
-```
+```

+ 1 - 1
metadata-protobuf/package.json

@@ -38,6 +38,6 @@
     "prettier": "2.0.2",
     "ts-node": "^8.8.1",
     "typescript": "^4.1.3",
-    "protobufjs": "^6.10.2"
+    "protobufjs": "^6.11.2"
   }
 }

+ 14 - 0
metadata-protobuf/proto/Forum.proto

@@ -0,0 +1,14 @@
+syntax = "proto2";
+
+// The enum must be wrapped inside "message", otherwide it breaks protobufjs
+message ForumPostReaction {
+  enum Reaction {
+    CANCEL = 0; // This means cancelling any previous reaction
+    LIKE = 1;
+  }
+}
+
+message ForumPostMetadata {
+  optional string text = 1; // Post text content (md-formatted)
+  optional uint32 repliesTo = 2; // Id of the post that given post replies to (if any)
+}

+ 54 - 1
query-node/manifest.yml

@@ -11,6 +11,7 @@ typegen:
   metadata:
     source: ${TYPEGEN_WS_URI}
   events:
+    # Membership
     - members.MembershipBought
     - members.MemberProfileUpdated
     - members.MemberAccountsUpdated
@@ -25,7 +26,7 @@ typegen:
     - members.ReferralCutUpdated
     - members.InitialInvitationBalanceUpdated
     - members.LeaderInvitationQuotaUpdated
-    # Use Storage Working Group as a reference group (all groups emit the same events)
+    # Working groups - use Storage Working Group as a reference group (all groups emit the same events)
     - storageWorkingGroup.OpeningAdded
     - storageWorkingGroup.AppliedOnOpening
     - storageWorkingGroup.OpeningFilled
@@ -62,9 +63,28 @@ typegen:
     - proposalsDiscussion.PostUpdated
     - proposalsDiscussion.ThreadModeChanged
     - proposalsDiscussion.PostDeleted
+  # Forum
+    - forum.CategoryCreated
+    - forum.CategoryUpdated
+    - forum.CategoryDeleted
+    - forum.ThreadCreated
+    - forum.ThreadModerated
+    # - forum.ThreadUpdated FIXME: Not emitted by the runtime
+    - forum.ThreadTitleUpdated
+    - forum.ThreadDeleted
+    - forum.ThreadMoved
+    - forum.VoteOnPoll
+    - forum.PostAdded
+    - forum.PostModerated
+    - forum.PostDeleted
+    - forum.PostTextUpdated
+    - forum.PostReacted
+    - forum.CategoryStickyThreadUpdate
+    - forum.CategoryMembershipOfModeratorUpdated
   calls:
     - members.updateProfile
     - members.updateAccounts
+    - forum.createThread
   outDir: ./mappings/generated/types
   customTypes:
     lib: '@joystream/types/augment/all/types'
@@ -312,6 +332,39 @@ mappings:
       handler: proposalsDiscussion_ThreadModeChanged
     - event: proposalsDiscussion.PostDeleted
       handler: proposalsDiscussion_PostDeleted
+    # Forum
+    - event: forum.CategoryCreated
+      handler: forum_CategoryCreated
+    - event: forum.CategoryUpdated
+      handler: forum_CategoryUpdated
+    - event: forum.CategoryDeleted
+      handler: forum_CategoryDeleted
+    - event: forum.ThreadCreated
+      handler: forum_ThreadCreated
+    - event: forum.ThreadModerated
+      handler: forum_ThreadModerated
+    - event: forum.ThreadTitleUpdated
+      handler: forum_ThreadTitleUpdated
+    - event: forum.ThreadDeleted
+      handler: forum_ThreadDeleted
+    - event: forum.ThreadMoved
+      handler: forum_ThreadMoved
+    - event: forum.PostAdded
+      handler: forum_PostAdded
+    - event: forum.PostModerated
+      handler: forum_PostModerated
+    - event: forum.PostDeleted
+      handler: forum_PostDeleted
+    - event: forum.PostTextUpdated
+      handler: forum_PostTextUpdated
+    - event: forum.PostReacted
+      handler: forum_PostReacted
+    - event: forum.VoteOnPoll
+      handler: forum_VoteOnPoll
+    - event: forum.CategoryStickyThreadUpdate
+      handler: forum_CategoryStickyThreadUpdate
+    - event: forum.CategoryMembershipOfModeratorUpdated
+      handler: forum_CategoryMembershipOfModeratorUpdated
   extrinsicHandlers:
     # infer defaults here
     #- extrinsic: Balances.Transfer

+ 18 - 2
query-node/mappings/common.ts

@@ -1,8 +1,10 @@
-import { SubstrateEvent } from '@dzlzv/hydra-common'
+import { DatabaseManager, 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, WorkerId } from '@joystream/types/augment/all'
+
+import { Worker } from 'query-node/dist/model'
 import { BaseModel } from 'warthog'
 
 export const CURRENT_NETWORK = Network.OLYMPIA
@@ -88,3 +90,17 @@ export function getWorkingGroupModuleName(group: WorkingGroup): WorkingGroupModu
 
   throw new Error(`Unsupported working group: ${group.type}`)
 }
+
+export async function getWorker(
+  store: DatabaseManager,
+  groupName: WorkingGroupModuleName,
+  runtimeId: WorkerId | number
+): Promise<Worker> {
+  const workerDbId = `${groupName}-${runtimeId}`
+  const worker = await store.get(Worker, { where: { id: workerDbId } })
+  if (!worker) {
+    throw new Error(`Worker not found by id ${workerDbId}`)
+  }
+
+  return worker
+}

+ 568 - 0
query-node/mappings/forum.ts

@@ -0,0 +1,568 @@
+/*
+eslint-disable @typescript-eslint/naming-convention
+*/
+import { EventContext, StoreContext, DatabaseManager, SubstrateEvent } from '@dzlzv/hydra-common'
+import { bytesToString, deserializeMetadata, genericEventFields, getWorker } from './common'
+import {
+  CategoryCreatedEvent,
+  CategoryStatusActive,
+  CategoryUpdatedEvent,
+  ForumCategory,
+  Worker,
+  CategoryStatusArchived,
+  CategoryDeletedEvent,
+  CategoryStatusRemoved,
+  ThreadCreatedEvent,
+  ForumThread,
+  Membership,
+  ThreadStatusActive,
+  ForumPoll,
+  ForumPollAlternative,
+  ThreadModeratedEvent,
+  ThreadStatusModerated,
+  ThreadTitleUpdatedEvent,
+  ThreadDeletedEvent,
+  ThreadStatusLocked,
+  ThreadStatusRemoved,
+  ThreadMovedEvent,
+  ForumPost,
+  PostStatusActive,
+  PostOriginThreadInitial,
+  VoteOnPollEvent,
+  PostAddedEvent,
+  PostStatusLocked,
+  PostOriginThreadReply,
+  CategoryStickyThreadUpdateEvent,
+  CategoryMembershipOfModeratorUpdatedEvent,
+  PostModeratedEvent,
+  PostStatusModerated,
+  ForumPostReaction,
+  PostReaction,
+  PostReactedEvent,
+  PostReactionResult,
+  PostReactionResultCancel,
+  PostReactionResultValid,
+  PostReactionResultInvalid,
+  PostTextUpdatedEvent,
+  PostDeletedEvent,
+  PostStatusRemoved,
+} from 'query-node/dist/model'
+import { Forum } from './generated/types'
+import { PostReactionId, PrivilegedActor } from '@joystream/types/augment/all'
+import { ForumPostMetadata, ForumPostReaction as SupportedPostReactions } from '@joystream/metadata-protobuf'
+import { Not, In } from 'typeorm'
+
+async function getCategory(store: DatabaseManager, categoryId: string, relations?: string[]): Promise<ForumCategory> {
+  const category = await store.get(ForumCategory, { where: { id: categoryId }, relations })
+  if (!category) {
+    throw new Error(`Forum category not found by id: ${categoryId}`)
+  }
+
+  return category
+}
+
+async function getThread(store: DatabaseManager, threadId: string): Promise<ForumThread> {
+  const thread = await store.get(ForumThread, { where: { id: threadId } })
+  if (!thread) {
+    throw new Error(`Forum thread not found by id: ${threadId.toString()}`)
+  }
+
+  return thread
+}
+
+async function getPost(store: DatabaseManager, postId: string): Promise<ForumPost> {
+  const post = await store.get(ForumPost, { where: { id: postId } })
+  if (!post) {
+    throw new Error(`Forum post not found by id: ${postId.toString()}`)
+  }
+
+  return post
+}
+
+async function getPollAlternative(store: DatabaseManager, threadId: string, index: number) {
+  const poll = await store.get(ForumPoll, { where: { thread: { id: threadId } }, relations: ['pollAlternatives'] })
+  if (!poll) {
+    throw new Error(`Forum poll not found by threadId: ${threadId.toString()}`)
+  }
+  const pollAlternative = poll.pollAlternatives?.find((alt) => alt.index === index)
+  if (!pollAlternative) {
+    throw new Error(`Froum poll alternative not found by index ${index} in thread ${threadId.toString()}`)
+  }
+
+  return pollAlternative
+}
+
+async function getActorWorker(store: DatabaseManager, actor: PrivilegedActor): Promise<Worker> {
+  const worker = await store.get(Worker, {
+    where: {
+      group: { id: 'forumWorkingGroup' },
+      ...(actor.isLead ? { isLead: true } : { runtimeId: actor.asModerator.toNumber() }),
+    },
+    relations: ['group'],
+  })
+
+  if (!worker) {
+    throw new Error(`Corresponding worker not found by forum PrivielagedActor: ${JSON.stringify(actor.toHuman())}`)
+  }
+
+  return worker
+}
+
+// Get standarized PostReactionResult by PostReactionId
+function parseReaction(reactionId: PostReactionId): typeof PostReactionResult {
+  switch (reactionId.toNumber()) {
+    case SupportedPostReactions.Reaction.CANCEL: {
+      return new PostReactionResultCancel()
+    }
+    case SupportedPostReactions.Reaction.LIKE: {
+      const result = new PostReactionResultValid()
+      result.reaction = PostReaction.LIKE
+      result.reactionId = reactionId.toNumber()
+      return result
+    }
+    default: {
+      console.warn(`Invalid post reaction id: ${reactionId.toString()}`)
+      const result = new PostReactionResultInvalid()
+      result.reactionId = reactionId.toNumber()
+      return result
+    }
+  }
+}
+
+export async function forum_CategoryCreated({ event, store }: EventContext & StoreContext): Promise<void> {
+  const [categoryId, parentCategoryId, titleBytes, descriptionBytes] = new Forum.CategoryCreatedEvent(event).params
+  const eventTime = new Date(event.blockTimestamp)
+
+  const category = new ForumCategory({
+    id: categoryId.toString(),
+    createdAt: eventTime,
+    updatedAt: eventTime,
+    title: bytesToString(titleBytes),
+    description: bytesToString(descriptionBytes),
+    status: new CategoryStatusActive(),
+    parent: parentCategoryId.isSome ? new ForumCategory({ id: parentCategoryId.unwrap().toString() }) : undefined,
+  })
+
+  await store.save<ForumCategory>(category)
+
+  const categoryCreatedEvent = new CategoryCreatedEvent({
+    ...genericEventFields(event),
+    category,
+  })
+  await store.save<CategoryCreatedEvent>(categoryCreatedEvent)
+}
+
+export async function forum_CategoryUpdated({ event, store }: EventContext & StoreContext): Promise<void> {
+  const [categoryId, newArchivalStatus, privilegedActor] = new Forum.CategoryUpdatedEvent(event).params
+  const eventTime = new Date(event.blockTimestamp)
+  const category = await getCategory(store, categoryId.toString())
+  const actorWorker = await getActorWorker(store, privilegedActor)
+
+  const categoryUpdatedEvent = new CategoryUpdatedEvent({
+    ...genericEventFields(event),
+    category,
+    newArchivalStatus: newArchivalStatus.valueOf(),
+    actor: actorWorker,
+  })
+  await store.save<CategoryUpdatedEvent>(categoryUpdatedEvent)
+
+  if (newArchivalStatus.valueOf()) {
+    const status = new CategoryStatusArchived()
+    status.categoryUpdatedEventId = categoryUpdatedEvent.id
+    category.status = status
+  } else {
+    category.status = new CategoryStatusActive()
+  }
+  category.updatedAt = eventTime
+  await store.save<ForumCategory>(category)
+}
+
+export async function forum_CategoryDeleted({ event, store }: EventContext & StoreContext): Promise<void> {
+  const [categoryId, privilegedActor] = new Forum.CategoryDeletedEvent(event).params
+  const eventTime = new Date(event.blockTimestamp)
+  const category = await getCategory(store, categoryId.toString())
+  const actorWorker = await getActorWorker(store, privilegedActor)
+
+  const categoryDeletedEvent = new CategoryDeletedEvent({
+    ...genericEventFields(event),
+    category,
+    actor: actorWorker,
+  })
+  await store.save<CategoryDeletedEvent>(categoryDeletedEvent)
+
+  const newStatus = new CategoryStatusRemoved()
+  newStatus.categoryDeletedEventId = categoryDeletedEvent.id
+
+  category.updatedAt = eventTime
+  category.status = newStatus
+  await store.save<ForumCategory>(category)
+}
+
+export async function forum_ThreadCreated({ event, store }: EventContext & StoreContext): Promise<void> {
+  const { forumUserId, categoryId, title, text, poll } = new Forum.CreateThreadCall(event).args
+  const [threadId] = new Forum.ThreadCreatedEvent(event).params
+  const eventTime = new Date(event.blockTimestamp)
+  const author = new Membership({ id: forumUserId.toString() })
+
+  const thread = new ForumThread({
+    createdAt: eventTime,
+    updatedAt: eventTime,
+    id: threadId.toString(),
+    author,
+    category: new ForumCategory({ id: categoryId.toString() }),
+    title: bytesToString(title),
+    isSticky: false,
+    status: new ThreadStatusActive(),
+  })
+  await store.save<ForumThread>(thread)
+
+  if (poll.isSome) {
+    const threadPoll = new ForumPoll({
+      createdAt: eventTime,
+      updatedAt: eventTime,
+      description: bytesToString(poll.unwrap().description_hash), // FIXME: This should be raw description!
+      endTime: new Date(poll.unwrap().end_time.toNumber()),
+      thread,
+    })
+    await store.save<ForumPoll>(threadPoll)
+    await Promise.all(
+      poll.unwrap().poll_alternatives.map(async (alt, index) => {
+        const alternative = new ForumPollAlternative({
+          createdAt: eventTime,
+          updatedAt: eventTime,
+          poll: threadPoll,
+          text: bytesToString(alt.alternative_text_hash), // FIXME: This should be raw text!
+          index,
+        })
+
+        await store.save<ForumPollAlternative>(alternative)
+      })
+    )
+  }
+
+  const threadCreatedEvent = new ThreadCreatedEvent({
+    ...genericEventFields(event),
+    thread,
+    title: bytesToString(title),
+    text: bytesToString(text),
+  })
+  await store.save<ThreadCreatedEvent>(threadCreatedEvent)
+
+  const postOrigin = new PostOriginThreadInitial()
+  postOrigin.threadCreatedEventId = threadCreatedEvent.id
+
+  const initialPost = new ForumPost({
+    // FIXME: The postId is unknown
+    createdAt: eventTime,
+    updatedAt: eventTime,
+    author,
+    thread,
+    text: bytesToString(text),
+    status: new PostStatusActive(),
+    origin: postOrigin,
+  })
+  await store.save<ForumPost>(initialPost)
+}
+
+export async function forum_ThreadModerated({ event, store }: EventContext & StoreContext): Promise<void> {
+  const [threadId, rationaleBytes, privilegedActor] = new Forum.ThreadModeratedEvent(event).params
+  const eventTime = new Date(event.blockTimestamp)
+  const actorWorker = await getActorWorker(store, privilegedActor)
+  const thread = await getThread(store, threadId.toString())
+
+  const threadModeratedEvent = new ThreadModeratedEvent({
+    ...genericEventFields(event),
+    actor: actorWorker,
+    thread,
+    rationale: bytesToString(rationaleBytes),
+  })
+
+  await store.save<ThreadModeratedEvent>(threadModeratedEvent)
+
+  const newStatus = new ThreadStatusModerated()
+  newStatus.threadModeratedEventId = threadModeratedEvent.id
+
+  thread.updatedAt = eventTime
+  thread.status = newStatus
+  await store.save<ForumThread>(thread)
+}
+
+export async function forum_ThreadTitleUpdated({ event, store }: EventContext & StoreContext): Promise<void> {
+  const [threadId, , , newTitleBytes] = new Forum.ThreadTitleUpdatedEvent(event).params
+  const eventTime = new Date(event.blockTimestamp)
+  const thread = await getThread(store, threadId.toString())
+
+  const threadTitleUpdatedEvent = new ThreadTitleUpdatedEvent({
+    ...genericEventFields(event),
+    thread,
+    newTitle: bytesToString(newTitleBytes),
+  })
+
+  await store.save<ThreadTitleUpdatedEvent>(threadTitleUpdatedEvent)
+
+  thread.updatedAt = eventTime
+  thread.title = bytesToString(newTitleBytes)
+  await store.save<ForumThread>(thread)
+}
+
+export async function forum_ThreadDeleted({ event, store }: EventContext & StoreContext): Promise<void> {
+  const [threadId, , , hide] = new Forum.ThreadDeletedEvent(event).params
+  const eventTime = new Date(event.blockTimestamp)
+  const thread = await getThread(store, threadId.toString())
+
+  const threadDeletedEvent = new ThreadDeletedEvent({
+    ...genericEventFields(event),
+    thread,
+  })
+
+  await store.save<ThreadDeletedEvent>(threadDeletedEvent)
+
+  const status = hide.valueOf() ? new ThreadStatusRemoved() : new ThreadStatusLocked()
+  status.threadDeletedEventId = threadDeletedEvent.id
+  thread.status = status
+  thread.updatedAt = eventTime
+  await store.save<ForumThread>(thread)
+}
+
+export async function forum_ThreadMoved({ event, store }: EventContext & StoreContext): Promise<void> {
+  const [threadId, newCategoryId, privilegedActor, oldCategoryId] = new Forum.ThreadMovedEvent(event).params
+  const eventTime = new Date(event.blockTimestamp)
+  const thread = await getThread(store, threadId.toString())
+  const actorWorker = await getActorWorker(store, privilegedActor)
+
+  const threadMovedEvent = new ThreadMovedEvent({
+    ...genericEventFields(event),
+    thread,
+    oldCategory: new ForumCategory({ id: oldCategoryId.toString() }),
+    newCategory: new ForumCategory({ id: newCategoryId.toString() }),
+    actor: actorWorker,
+  })
+
+  await store.save<ThreadMovedEvent>(threadMovedEvent)
+
+  thread.updatedAt = eventTime
+  thread.category = new ForumCategory({ id: newCategoryId.toString() })
+  await store.save<ForumThread>(thread)
+}
+
+export async function forum_VoteOnPoll({ event, store }: EventContext & StoreContext): Promise<void> {
+  const [threadId, alternativeIndex, forumUserId] = new Forum.VoteOnPollEvent(event).params
+  const pollAlternative = await getPollAlternative(store, threadId.toString(), alternativeIndex.toNumber())
+  const votingMember = new Membership({ id: forumUserId.toString() })
+
+  const voteOnPollEvent = new VoteOnPollEvent({
+    ...genericEventFields(event),
+    pollAlternative,
+    votingMember,
+  })
+
+  await store.save<VoteOnPollEvent>(voteOnPollEvent)
+}
+
+export async function forum_PostAdded({ event, store }: EventContext & StoreContext): Promise<void> {
+  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 store.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 store.save<ForumPost>(post)
+
+  const postAddedEvent = new PostAddedEvent({
+    ...genericEventFields(event),
+    post,
+    isEditable: isEditable.valueOf(),
+    text: postText,
+  })
+
+  await store.save<PostAddedEvent>(postAddedEvent)
+  // Update the other side of cross-relationship
+  postOrigin.postAddedEventId = postAddedEvent.id
+  await store.save<ForumPost>(post)
+}
+
+export async function forum_CategoryStickyThreadUpdate({ event, store }: EventContext & StoreContext): Promise<void> {
+  const [categoryId, newStickyThreadsIdsVec, privilegedActor] = new Forum.CategoryStickyThreadUpdateEvent(event).params
+  const eventTime = new Date(event.blockTimestamp)
+  const actorWorker = await getActorWorker(store, privilegedActor)
+  const newStickyThreadsIds = newStickyThreadsIdsVec.map((id) => id.toString())
+  const threadsToSetSticky = await store.getMany(ForumThread, {
+    where: { category: { id: categoryId.toString() }, id: In(newStickyThreadsIds) },
+  })
+  const threadsToUnsetSticky = await store.getMany(ForumThread, {
+    where: { category: { id: categoryId.toString() }, isSticky: true, id: Not(In(newStickyThreadsIds)) },
+  })
+
+  const setStickyUpdates = (threadsToSetSticky || []).map(async (t) => {
+    t.updatedAt = eventTime
+    t.isSticky = true
+    await store.save<ForumThread>(t)
+  })
+
+  const unsetStickyUpdates = (threadsToUnsetSticky || []).map(async (t) => {
+    t.updatedAt = eventTime
+    t.isSticky = false
+    await store.save<ForumThread>(t)
+  })
+
+  await Promise.all(setStickyUpdates.concat(unsetStickyUpdates))
+
+  const categoryStickyThreadUpdateEvent = new CategoryStickyThreadUpdateEvent({
+    ...genericEventFields(event),
+    actor: actorWorker,
+    category: new ForumCategory({ id: categoryId.toString() }),
+    newStickyThreads: threadsToSetSticky,
+  })
+
+  await store.save<CategoryStickyThreadUpdateEvent>(categoryStickyThreadUpdateEvent)
+}
+
+export async function forum_CategoryMembershipOfModeratorUpdated(
+  store: DatabaseManager,
+  event: SubstrateEvent
+): Promise<void> {
+  const [moderatorId, categoryId, canModerate] = new Forum.CategoryMembershipOfModeratorUpdatedEvent(event).params
+  const eventTime = new Date(event.blockTimestamp)
+  const moderator = await getWorker(store, 'forumWorkingGroup', moderatorId.toNumber())
+  const category = await getCategory(store, categoryId.toString(), ['moderators'])
+
+  if (canModerate.valueOf()) {
+    category.moderators.push(moderator)
+    category.updatedAt = eventTime
+    await store.save<ForumCategory>(category)
+  } else {
+    category.moderators.splice(category.moderators.map((m) => m.id).indexOf(moderator.id), 1)
+    category.updatedAt = eventTime
+    await store.save<ForumCategory>(category)
+  }
+
+  const categoryMembershipOfModeratorUpdatedEvent = new CategoryMembershipOfModeratorUpdatedEvent({
+    ...genericEventFields(event),
+    category,
+    moderator,
+    newCanModerateValue: canModerate.valueOf(),
+  })
+  await store.save<CategoryMembershipOfModeratorUpdatedEvent>(categoryMembershipOfModeratorUpdatedEvent)
+}
+
+export async function forum_PostModerated({ event, store }: EventContext & StoreContext): Promise<void> {
+  const [postId, rationaleBytes, privilegedActor] = new Forum.PostModeratedEvent(event).params
+  const eventTime = new Date(event.blockTimestamp)
+  const actorWorker = await getActorWorker(store, privilegedActor)
+  const post = await getPost(store, postId.toString())
+
+  const postModeratedEvent = new PostModeratedEvent({
+    ...genericEventFields(event),
+    actor: actorWorker,
+    post,
+    rationale: bytesToString(rationaleBytes),
+  })
+
+  await store.save<PostModeratedEvent>(postModeratedEvent)
+
+  const newStatus = new PostStatusModerated()
+  newStatus.postModeratedEventId = postModeratedEvent.id
+
+  post.updatedAt = eventTime
+  post.status = newStatus
+  await store.save<ForumPost>(post)
+}
+
+export async function forum_PostReacted({ event, store }: EventContext & StoreContext): Promise<void> {
+  const [userId, postId, reactionId] = new Forum.PostReactedEvent(event).params
+  const eventTime = new Date(event.blockTimestamp)
+
+  const reactionResult = parseReaction(reactionId)
+  const postReactedEvent = new PostReactedEvent({
+    ...genericEventFields(event),
+    post: new ForumPost({ id: postId.toString() }),
+    reactingMember: new Membership({ id: userId.toString() }),
+    reactionResult,
+  })
+  await store.save<PostReactedEvent>(postReactedEvent)
+
+  const existingUserPostReaction = await store.get(ForumPostReaction, {
+    where: { post: { id: postId.toString() }, member: { id: userId.toString() } },
+  })
+
+  if (reactionResult.isTypeOf === 'PostReactionResultValid') {
+    const { reaction } = reactionResult as PostReactionResultValid
+
+    if (existingUserPostReaction) {
+      existingUserPostReaction.updatedAt = eventTime
+      existingUserPostReaction.reaction = reaction
+      await store.save<ForumPostReaction>(existingUserPostReaction)
+    } else {
+      const newUserPostReaction = new ForumPostReaction({
+        createdAt: eventTime,
+        updatedAt: eventTime,
+        post: new ForumPost({ id: postId.toString() }),
+        member: new Membership({ id: userId.toString() }),
+        reaction,
+      })
+      await store.save<ForumPostReaction>(newUserPostReaction)
+    }
+  } else if (existingUserPostReaction) {
+    await store.remove<ForumPostReaction>(existingUserPostReaction)
+  }
+}
+
+export async function forum_PostTextUpdated({ event, store }: EventContext & StoreContext): Promise<void> {
+  const [postId, , , , newTextBytes] = new Forum.PostTextUpdatedEvent(event).params
+  const eventTime = new Date(event.blockTimestamp)
+  const post = await getPost(store, postId.toString())
+
+  const postTextUpdatedEvent = new PostTextUpdatedEvent({
+    ...genericEventFields(event),
+    post,
+    newText: bytesToString(newTextBytes),
+  })
+
+  await store.save<PostTextUpdatedEvent>(postTextUpdatedEvent)
+
+  post.updatedAt = eventTime
+  post.text = bytesToString(newTextBytes)
+  await store.save<ForumPost>(post)
+}
+
+export async function forum_PostDeleted({ event, store }: EventContext & StoreContext): Promise<void> {
+  const [rationaleBytes, userId, postsData] = new Forum.PostDeletedEvent(event).params
+  const eventTime = new Date(event.blockTimestamp)
+
+  const postDeletedEvent = new PostDeletedEvent({
+    ...genericEventFields(event),
+    actor: new Membership({ id: userId.toString() }),
+    rationale: bytesToString(rationaleBytes),
+  })
+
+  await store.save<PostDeletedEvent>(postDeletedEvent)
+
+  await Promise.all(
+    postsData.map(async ([, , postId, hideFlag]) => {
+      const post = await getPost(store, postId.toString())
+      const newStatus = hideFlag.valueOf() ? new PostStatusRemoved() : new PostStatusLocked()
+      newStatus.postDeletedEventId = postDeletedEvent.id
+      post.updatedAt = eventTime
+      post.status = newStatus
+      post.deletedInEvent = postDeletedEvent
+      await store.save<ForumPost>(post)
+    })
+  )
+}

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

@@ -2,3 +2,4 @@ export * from './membership'
 export * from './workingGroups'
 export * from './proposals'
 export * from './proposalsDiscussion'
+export * from './forum'

+ 1 - 0
query-node/mappings/init.ts

@@ -8,6 +8,7 @@ import path from 'path'
 async function init() {
   const provider = new WsProvider(process.env.WS_PROVIDER_ENDPOINT_URI)
   const api = await ApiPromise.create({ provider, types })
+  // Will be resolved relatively to mappings/lib
   const entitiesPath = path.resolve(__dirname, '../../generated/graphql-server/dist/src/modules/**/*.model.js')
   // We need to create db connection (and configure env) before importing any warthog models
   const dbConnection = await createDBConnection([entitiesPath])

+ 12 - 21
query-node/mappings/workingGroups.ts

@@ -16,7 +16,7 @@ import {
   WorkingGroupMetadataAction,
 } from '@joystream/metadata-protobuf'
 import { Bytes } from '@polkadot/types'
-import { deserializeMetadata, bytesToString, genericEventFields } from './common'
+import { deserializeMetadata, bytesToString, genericEventFields, getWorker, WorkingGroupModuleName } from './common'
 import BN from 'bn.js'
 import {
   WorkingGroupOpening,
@@ -111,15 +111,6 @@ async function getApplication(store: DatabaseManager, applicationstoreId: string
   return application
 }
 
-async function getWorker(store: DatabaseManager, workerstoreId: string): Promise<Worker> {
-  const worker = await store.get(Worker, { where: { id: workerstoreId } })
-  if (!worker) {
-    throw new Error(`Worker not found by id ${workerstoreId}`)
-  }
-
-  return worker
-}
-
 async function getApplicationFormQuestions(
   store: DatabaseManager,
   openingstoreId: string
@@ -344,7 +335,7 @@ async function handleWorkingGroupMetadataAction(
 async function handleTerminatedWorker({ store, event }: EventContext & StoreContext): Promise<void> {
   const [workerId, optPenalty, optRationale] = new WorkingGroups.TerminatedWorkerEvent(event).params
   const group = await getWorkingGroup(store, event)
-  const worker = await getWorker(store, `${group.name}-${workerId.toString()}`)
+  const worker = await getWorker(store, group.name as WorkingGroupModuleName, workerId)
   const eventTime = new Date(event.blockTimestamp)
 
   const EventConstructor = worker.isLead ? TerminatedLeaderEvent : TerminatedWorkerEvent
@@ -678,7 +669,7 @@ export async function workingGroups_WorkerRoleAccountUpdated({
 }: EventContext & StoreContext): Promise<void> {
   const [workerId, accountId] = new WorkingGroups.WorkerRoleAccountUpdatedEvent(event).params
   const group = await getWorkingGroup(store, event)
-  const worker = await getWorker(store, `${group.name}-${workerId.toString()}`)
+  const worker = await getWorker(store, group.name as WorkingGroupModuleName, workerId)
   const eventTime = new Date(event.blockTimestamp)
 
   const workerRoleAccountUpdatedEvent = new WorkerRoleAccountUpdatedEvent({
@@ -702,7 +693,7 @@ export async function workingGroups_WorkerRewardAccountUpdated({
 }: EventContext & StoreContext): Promise<void> {
   const [workerId, accountId] = new WorkingGroups.WorkerRewardAccountUpdatedEvent(event).params
   const group = await getWorkingGroup(store, event)
-  const worker = await getWorker(store, `${group.name}-${workerId.toString()}`)
+  const worker = await getWorker(store, group.name as WorkingGroupModuleName, workerId)
   const eventTime = new Date(event.blockTimestamp)
 
   const workerRewardAccountUpdatedEvent = new WorkerRewardAccountUpdatedEvent({
@@ -723,7 +714,7 @@ export async function workingGroups_WorkerRewardAccountUpdated({
 export async function workingGroups_StakeIncreased({ store, event }: EventContext & StoreContext): Promise<void> {
   const [workerId, increaseAmount] = new WorkingGroups.StakeIncreasedEvent(event).params
   const group = await getWorkingGroup(store, event)
-  const worker = await getWorker(store, `${group.name}-${workerId.toString()}`)
+  const worker = await getWorker(store, group.name as WorkingGroupModuleName, workerId)
   const eventTime = new Date(event.blockTimestamp)
 
   const stakeIncreasedEvent = new StakeIncreasedEvent({
@@ -744,7 +735,7 @@ export async function workingGroups_StakeIncreased({ store, event }: EventContex
 export async function workingGroups_RewardPaid({ store, event }: EventContext & StoreContext): Promise<void> {
   const [workerId, rewardAccountId, amount, rewardPaymentType] = new WorkingGroups.RewardPaidEvent(event).params
   const group = await getWorkingGroup(store, event)
-  const worker = await getWorker(store, `${group.name}-${workerId.toString()}`)
+  const worker = await getWorker(store, group.name as WorkingGroupModuleName, workerId)
   const eventTime = new Date(event.blockTimestamp)
 
   const rewardPaidEvent = new RewardPaidEvent({
@@ -771,7 +762,7 @@ export async function workingGroups_NewMissedRewardLevelReached({
 }: EventContext & StoreContext): Promise<void> {
   const [workerId, newMissedRewardAmountOpt] = new WorkingGroups.NewMissedRewardLevelReachedEvent(event).params
   const group = await getWorkingGroup(store, event)
-  const worker = await getWorker(store, `${group.name}-${workerId.toString()}`)
+  const worker = await getWorker(store, group.name as WorkingGroupModuleName, workerId)
   const eventTime = new Date(event.blockTimestamp)
 
   const newMissedRewardLevelReachedEvent = new NewMissedRewardLevelReachedEvent({
@@ -793,7 +784,7 @@ export async function workingGroups_NewMissedRewardLevelReached({
 export async function workingGroups_WorkerExited({ store, event }: EventContext & StoreContext): Promise<void> {
   const [workerId] = new WorkingGroups.WorkerExitedEvent(event).params
   const group = await getWorkingGroup(store, event)
-  const worker = await getWorker(store, `${group.name}-${workerId.toString()}`)
+  const worker = await getWorker(store, group.name as WorkingGroupModuleName, workerId)
   const eventTime = new Date(event.blockTimestamp)
 
   const workerExitedEvent = new WorkerExitedEvent({
@@ -842,7 +833,7 @@ export async function workingGroups_WorkerRewardAmountUpdated({
 }: EventContext & StoreContext): Promise<void> {
   const [workerId, newRewardPerBlockOpt] = new WorkingGroups.WorkerRewardAmountUpdatedEvent(event).params
   const group = await getWorkingGroup(store, event)
-  const worker = await getWorker(store, `${group.name}-${workerId.toString()}`)
+  const worker = await getWorker(store, group.name as WorkingGroupModuleName, workerId)
   const eventTime = new Date(event.blockTimestamp)
 
   const workerRewardAmountUpdatedEvent = new WorkerRewardAmountUpdatedEvent({
@@ -863,7 +854,7 @@ export async function workingGroups_WorkerRewardAmountUpdated({
 export async function workingGroups_StakeSlashed({ store, event }: EventContext & StoreContext): Promise<void> {
   const [workerId, slashedAmount, requestedAmount, optRationale] = new WorkingGroups.StakeSlashedEvent(event).params
   const group = await getWorkingGroup(store, event)
-  const worker = await getWorker(store, `${group.name}-${workerId.toString()}`)
+  const worker = await getWorker(store, group.name as WorkingGroupModuleName, workerId)
   const eventTime = new Date(event.blockTimestamp)
 
   const workerStakeSlashedEvent = new StakeSlashedEvent({
@@ -886,7 +877,7 @@ export async function workingGroups_StakeSlashed({ store, event }: EventContext
 export async function workingGroups_StakeDecreased({ store, event }: EventContext & StoreContext): Promise<void> {
   const [workerId, amount] = new WorkingGroups.StakeDecreasedEvent(event).params
   const group = await getWorkingGroup(store, event)
-  const worker = await getWorker(store, `${group.name}-${workerId.toString()}`)
+  const worker = await getWorker(store, group.name as WorkingGroupModuleName, workerId)
   const eventTime = new Date(event.blockTimestamp)
 
   const workerStakeDecreasedEvent = new StakeDecreasedEvent({
@@ -907,7 +898,7 @@ export async function workingGroups_StakeDecreased({ store, event }: EventContex
 export async function workingGroups_WorkerStartedLeaving({ store, event }: EventContext & StoreContext): Promise<void> {
   const [workerId, optRationale] = new WorkingGroups.WorkerStartedLeavingEvent(event).params
   const group = await getWorkingGroup(store, event)
-  const worker = await getWorker(store, `${group.name}-${workerId.toString()}`)
+  const worker = await getWorker(store, group.name as WorkingGroupModuleName, workerId)
   const eventTime = new Date(event.blockTimestamp)
 
   const workerStartedLeavingEvent = new WorkerStartedLeavingEvent({

+ 227 - 0
query-node/schemas/forum.graphql

@@ -0,0 +1,227 @@
+type CategoryStatusActive @variant {
+  # No additional information required
+  _phantom: Int
+}
+
+type CategoryStatusArchived @variant {
+  "Event the category was archived in"
+  categoryUpdatedEvent: CategoryUpdatedEvent!
+}
+
+type CategoryStatusRemoved @variant {
+  "Event the category was deleted in"
+  categoryDeletedEvent: CategoryDeletedEvent!
+}
+
+union CategoryStatus = CategoryStatusActive | CategoryStatusArchived | CategoryStatusRemoved
+
+type ForumCategory @entity {
+  "Runtime category id"
+  id: ID!
+
+  "Parent category (if none - this is a root category)"
+  parent: ForumCategory
+
+  "Category title"
+  title: String!
+
+  "Category description"
+  description: String!
+
+  "List of all threads in the category"
+  threads: [ForumThread!] @derivedFrom(field: "category")
+
+  "List of all moderators managing this category"
+  moderators: [Worker!]
+
+  "The event the category was created in"
+  createdInEvent: CategoryCreatedEvent! @derivedFrom(field: "category")
+
+  "Current category status"
+  status: CategoryStatus!
+}
+
+"The thread is visible and editable (unless belongs to archived category)"
+type ThreadStatusActive @variant {
+  # No additional information required
+  _phantom: Int
+}
+
+"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 {
+  "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 {
+  "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 {
+  "Event the thread was removed in"
+  threadDeletedEvent: ThreadDeletedEvent!
+}
+
+union ThreadStatus = ThreadStatusActive | ThreadStatusLocked | ThreadStatusModerated | ThreadStatusRemoved
+
+type ForumThread @entity {
+  "Runtime thread id"
+  id: ID!
+
+  "Author of the forum thread"
+  author: Membership!
+
+  "Category the thread belongs to"
+  category: ForumCategory!
+
+  "Thread title"
+  title: String! @fulltext(query: "threadsByTitle")
+
+  "All posts in the thread"
+  posts: [ForumPost!] @derivedFrom(field: "thread")
+
+  "Optional poll associated with the thread"
+  poll: ForumPoll @derivedFrom(field: "thread")
+
+  "Whether the thread is sticky in the category"
+  isSticky: Boolean!
+
+  "The event the thread was created in"
+  createdInEvent: ThreadCreatedEvent! @derivedFrom(field: "thread")
+
+  "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")
+
+  "List of events that moved the thread to a different category"
+  movedInEvents: [ThreadMovedEvent!] @derivedFrom(field: "thread")
+}
+
+type ForumPoll @entity {
+  "The thread the poll belongs to"
+  thread: ForumThread!
+
+  "Poll description"
+  description: String!
+
+  "The time at which the poll ends"
+  endTime: DateTime!
+
+  "List of poll alternatives"
+  pollAlternatives: [ForumPollAlternative!] @derivedFrom(field: "poll")
+}
+
+type ForumPollAlternative @entity {
+  "Index uniquely identifying the alternative in given poll"
+  index: Int!
+
+  "The related poll"
+  poll: ForumPoll!
+
+  "The alternative text"
+  text: String!
+
+  "List of all associated vote events"
+  votes: [VoteOnPollEvent!] @derivedFrom(field: "pollAlternative")
+}
+
+enum PostReaction {
+  LIKE
+  # We may support some other ones in the future...
+}
+
+type ForumPostReaction @entity {
+  "{memberId}-{postId}"
+  id: ID!
+
+  "The member that reacted"
+  member: Membership!
+
+  "The post that has been reacted to"
+  post: ForumPost!
+
+  "The reaction"
+  reaction: PostReaction!
+}
+
+"The post is visible and editable (unless belongs to archived category)"
+type PostStatusActive @variant {
+  # No additional information required
+  _phantom: Int
+}
+
+"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 {
+  "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 {
+  "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 {
+  "Event the post was removed in"
+  postDeletedEvent: PostDeletedEvent!
+}
+
+union PostStatus = PostStatusActive | PostStatusLocked | PostStatusModerated | PostStatusRemoved
+
+type PostOriginThreadInitial @variant {
+  "Thread creation event"
+  # Must be optional because of post<->event cross-relationship
+  threadCreatedEvent: ThreadCreatedEvent
+}
+
+type PostOriginThreadReply @variant {
+  "Related PostAdded event"
+  # Must be optional because of post<->event cross-relationship
+  postAddedEvent: PostAddedEvent
+}
+
+union PostOrigin = PostOriginThreadInitial | PostOriginThreadReply
+
+type ForumPost @entity {
+  "Runtime post id"
+  id: ID!
+
+  "Author of the forum post"
+  author: Membership!
+
+  "Thread the post was submitted in"
+  thread: ForumThread!
+
+  "Content of the post (md-formatted)"
+  text: String! @fulltext(query: "postsByText")
+
+  "A post that this post replies to (if any)"
+  repliesTo: ForumPost
+
+  "Current post status"
+  status: PostStatus!
+
+  "The origin of the post (either thread creation event or regular PostAdded event)"
+  origin: PostOrigin!
+
+  "List of all text update events (edits)"
+  edits: [PostTextUpdatedEvent!] @derivedFrom(field: "post")
+
+  "List of all current post reactions"
+  reactions: [ForumPostReaction!] @derivedFrom(field: "post")
+
+  # Required for PostDeletedEvent One-to-Many relation
+  "The event the post was deleted in (if any)"
+  deletedInEvent: PostDeletedEvent
+}

+ 500 - 0
query-node/schemas/forumEvents.graphql

@@ -0,0 +1,500 @@
+type CategoryCreatedEvent @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 category"
+  category: ForumCategory!
+
+  # The actor is always lead
+}
+
+type CategoryUpdatedEvent @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 category beeing updated"
+  category: ForumCategory!
+
+  "The new archival status of the category (true = archived)"
+  newArchivalStatus: Boolean!
+
+  "The moderator (possibly lead) responsible for updating the category"
+  actor: Worker!
+}
+
+type CategoryDeletedEvent @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 ###
+
+  "Category beeing deleted"
+  category: ForumCategory!
+
+  "The moderator (possibly lead) responsible for deleting the category"
+  actor: Worker!
+}
+
+type ThreadCreatedEvent @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 thread that was created"
+  thread: ForumThread!
+
+  "Thread's original title"
+  title: String!
+
+  "Thread's original text"
+  text: String!
+
+  # The author is already part of the Thread entity itself and is immutable
+}
+
+type ThreadModeratedEvent @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 thread beeing moderated"
+  thread: ForumThread!
+
+  "Rationale behind the moderation"
+  rationale: String!
+
+  "Actor responsible for the moderation"
+  actor: Worker!
+}
+
+# FIXME: Not emitted by the runtime
+# type ThreadUpdatedEvent @entity {
+#   "Generic event data"
+#   event: Event!
+
+#   "The thread beeing updated"
+#   thread: ForumThread!
+
+#   "The new archival status of the thread (true = archived)"
+#   newArchivalStatus: Boolean!
+
+#   "Actor responsible for the update"
+#   actor: Worker!
+# }
+
+type ThreadTitleUpdatedEvent @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 thread beeing updated"
+  thread: ForumThread!
+
+  "New title of the thread"
+  newTitle: String!
+
+  # Only author can update the thread title, so no actor information required
+}
+
+type ThreadDeletedEvent @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 thread beeing deleted"
+  thread: ForumThread!
+
+  # Only author can delete the thread, so no actor information required
+}
+
+type ThreadMovedEvent @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 thread beeing moved"
+  thread: ForumThread!
+
+  "Thread's previous category"
+  oldCategory: ForumCategory!
+
+  "Thread's new category"
+  newCategory: ForumCategory!
+
+  "The actor performing the transfer"
+  actor: Worker!
+}
+
+type PostAddedEvent @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 post that was added"
+  post: ForumPost!
+
+  "Whether the added post is editable"
+  isEditable: Boolean
+
+  "Post's original text"
+  text: String!
+}
+
+type PostModeratedEvent @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 post beeing moderated"
+  post: ForumPost!
+
+  "The rationale behind the moderation"
+  rationale: String!
+
+  "The actor responsible for the moderation"
+  actor: Worker!
+}
+
+type PostDeletedEvent @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 ###
+
+  "List of deleted posts"
+  posts: [ForumPost!] @derivedFrom(field: "deletedInEvent")
+
+  "The actor responsible for the removal"
+  actor: Membership!
+
+  "Posts deletion rationale"
+  rationale: String!
+}
+
+type PostTextUpdatedEvent @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 post beeing updated"
+  post: ForumPost!
+
+  "New post text"
+  newText: String!
+
+  # Only author can edit the post, so no actor context required
+}
+
+type PostReactionResultCancel @variant {
+  _phantom: Int
+}
+
+type PostReactionResultValid @variant {
+  reaction: PostReaction!
+  reactionId: Int!
+}
+
+type PostReactionResultInvalid @variant {
+  reactionId: Int!
+}
+
+union PostReactionResult = PostReactionResultCancel | PostReactionResultValid | PostReactionResultInvalid
+
+type PostReactedEvent @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 post beeing reacted to"
+  post: ForumPost!
+
+  "The reaction result - new valid reaction, cancelation of previous reaction or invalid reaction (which also cancels the previous one)"
+  reactionResult: PostReactionResult!
+
+  "The member reacting to the post"
+  reactingMember: Membership!
+}
+
+type VoteOnPollEvent @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 ###
+
+  "Poll alternative beeing voted on"
+  pollAlternative: ForumPollAlternative!
+
+  "The member that casted the vote"
+  votingMember: Membership!
+}
+
+type CategoryStickyThreadUpdateEvent @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 related category"
+  category: ForumCategory!
+
+  "List of the threads beeing made sticky"
+  newStickyThreads: [ForumThread!]
+
+  "The actor responsible for making the threads sticky"
+  actor: Worker!
+}
+
+type CategoryMembershipOfModeratorUpdatedEvent @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 moderator in question"
+  moderator: Worker!
+
+  "The category in question"
+  category: ForumCategory!
+
+  "The flag indicating whether the permissions to moderate the category are granted or revoked"
+  newCanModerateValue: Boolean!
+
+  # Actor is always lead
+}

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

@@ -70,6 +70,9 @@ type Worker @entity {
 
   "Worker's storage data"
   storage: String
+
+  "Forum categories managed by the worker (required for many-to-many relationship with ForumCategory)"
+  managedForumCategories: [ForumCategory!] @derivedFrom(field: "moderators")
 }
 
 type WorkingGroupMetadata @entity {

+ 38 - 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, ThreadId } from '@joystream/types/common'
 
 import { AccountInfo, Balance, EventRecord, BlockNumber, BlockHash } from '@polkadot/types/interfaces'
 import BN from 'bn.js'
@@ -27,6 +27,10 @@ import {
   ProposalsEngineEventName,
   ProposalCreatedEventDetails,
   ProposalType,
+  ForumEventName,
+  CategoryCreatedEventDetails,
+  PostAddedEventDetails,
+  ThreadCreatedEventDetails,
 } from './types'
 import {
   ApplicationId,
@@ -40,6 +44,7 @@ import { DeriveAllSections } from '@polkadot/api/util/decorate'
 import { ExactDerive } from '@polkadot/api-derive'
 import { ProposalId, ProposalParameters } from '@joystream/types/proposals'
 import { BLOCKTIME, proposalTypeToProposalParamsKey } from './consts'
+import { CategoryId } from '@joystream/types/forum'
 
 export class ApiFactory {
   private readonly api: ApiPromise
@@ -437,6 +442,14 @@ export class Api {
     return details
   }
 
+  public async retrieveForumEventDetails(result: ISubmittableResult, eventName: ForumEventName): Promise<EventDetails> {
+    const details = await this.retrieveEventDetails(result, 'forum', eventName)
+    if (!details) {
+      throw new Error(`${eventName} event details not found in result: ${JSON.stringify(result.toHuman())}`)
+    }
+    return details
+  }
+
   public async retrieveProposalCreatedEventDetails(result: ISubmittableResult): Promise<ProposalCreatedEventDetails> {
     const details = await this.retrieveProposalsEngineEventDetails(result, 'ProposalCreated')
     return {
@@ -525,4 +538,28 @@ export class Api {
   public proposalParametersByType(type: ProposalType): ProposalParameters {
     return this.api.consts.proposalsCodex[proposalTypeToProposalParamsKey[type]]
   }
+
+  public async retrieveCategoryCreatedEventDetails(result: ISubmittableResult): Promise<CategoryCreatedEventDetails> {
+    const details = await this.retrieveForumEventDetails(result, 'CategoryCreated')
+    return {
+      ...details,
+      categoryId: details.event.data[0] as CategoryId,
+    }
+  }
+
+  public async retrieveThreadCreatedEventDetails(result: ISubmittableResult): Promise<ThreadCreatedEventDetails> {
+    const details = await this.retrieveForumEventDetails(result, 'ThreadCreated')
+    return {
+      ...details,
+      threadId: details.event.data[0] as ThreadId,
+    }
+  }
+
+  public async retrievePostAddedEventDetails(result: ISubmittableResult): Promise<PostAddedEventDetails> {
+    const details = await this.retrieveForumEventDetails(result, 'PostAdded')
+    return {
+      ...details,
+      postId: details.event.data[0] as PostId,
+    }
+  }
 }

+ 240 - 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, ThreadId } from '@joystream/types/common'
 import Debugger from 'debug'
 import { ApplicationId, OpeningId, WorkerId } from '@joystream/types/working-group'
 import { EventDetails, WorkingGroupModuleName } from './types'
@@ -160,6 +160,38 @@ import {
   GetLeaderSetEventsByEventIdsQuery,
   GetLeaderSetEventsByEventIdsQueryVariables,
   GetLeaderSetEventsByEventIds,
+  ForumCategoryFieldsFragment,
+  GetCategoriesByIdsQuery,
+  GetCategoriesByIdsQueryVariables,
+  GetCategoriesByIds,
+  CategoryCreatedEventFieldsFragment,
+  GetCategoryCreatedEventsByEventIdsQuery,
+  GetCategoryCreatedEventsByEventIdsQueryVariables,
+  GetCategoryCreatedEventsByEventIds,
+  CategoryUpdatedEventFieldsFragment,
+  GetCategoryUpdatedEventsByEventIdsQuery,
+  GetCategoryUpdatedEventsByEventIdsQueryVariables,
+  GetCategoryUpdatedEventsByEventIds,
+  CategoryDeletedEventFieldsFragment,
+  GetCategoryDeletedEventsByEventIdsQuery,
+  GetCategoryDeletedEventsByEventIdsQueryVariables,
+  GetCategoryDeletedEventsByEventIds,
+  ThreadCreatedEventFieldsFragment,
+  GetThreadCreatedEventsByEventIdsQuery,
+  GetThreadCreatedEventsByEventIds,
+  GetThreadCreatedEventsByEventIdsQueryVariables,
+  VoteOnPollEventFieldsFragment,
+  GetVoteOnPollEventsByEventIdsQuery,
+  GetVoteOnPollEventsByEventIdsQueryVariables,
+  GetVoteOnPollEventsByEventIds,
+  ThreadDeletedEventFieldsFragment,
+  GetThreadDeletedEventsByEventIdsQuery,
+  GetThreadDeletedEventsByEventIdsQueryVariables,
+  GetThreadDeletedEventsByEventIds,
+  ForumThreadWithPostsFieldsFragment,
+  GetThreadsWithPostsByIdsQuery,
+  GetThreadsWithPostsByIdsQueryVariables,
+  GetThreadsWithPostsByIds,
   GetMembershipBoughtEventsByEventIdsQuery,
   GetMembershipBoughtEventsByEventIdsQueryVariables,
   GetMembershipBoughtEventsByEventIds,
@@ -187,11 +219,56 @@ import {
   GetProposalCancelledEventsByEventIdsQuery,
   GetProposalCancelledEventsByEventIdsQueryVariables,
   GetProposalCancelledEventsByEventIds,
+  ForumPostFieldsFragment,
+  GetPostsByIdsQuery,
+  GetPostsByIdsQueryVariables,
+  GetPostsByIds,
+  PostAddedEventFieldsFragment,
+  GetPostAddedEventsByEventIdsQuery,
+  GetPostAddedEventsByEventIdsQueryVariables,
+  GetPostAddedEventsByEventIds,
+  ThreadTitleUpdatedEventFieldsFragment,
+  GetThreadTitleUpdatedEventsByEventIdsQuery,
+  GetThreadTitleUpdatedEventsByEventIdsQueryVariables,
+  GetThreadTitleUpdatedEventsByEventIds,
+  ThreadMovedEventFieldsFragment,
+  GetThreadMovedEventsByEventIdsQuery,
+  GetThreadMovedEventsByEventIdsQueryVariables,
+  GetThreadMovedEventsByEventIds,
+  CategoryStickyThreadUpdateEventFieldsFragment,
+  GetCategoryStickyThreadUpdateEventsByEventIdsQuery,
+  GetCategoryStickyThreadUpdateEventsByEventIdsQueryVariables,
+  GetCategoryStickyThreadUpdateEventsByEventIds,
+  CategoryMembershipOfModeratorUpdatedEventFieldsFragment,
+  GetCategoryMembershipOfModeratorUpdatedEventsByEventIdsQuery,
+  GetCategoryMembershipOfModeratorUpdatedEventsByEventIdsQueryVariables,
+  GetCategoryMembershipOfModeratorUpdatedEventsByEventIds,
+  ThreadModeratedEventFieldsFragment,
+  GetThreadModeratedEventsByEventIdsQuery,
+  GetThreadModeratedEventsByEventIdsQueryVariables,
+  GetThreadModeratedEventsByEventIds,
+  PostModeratedEventFieldsFragment,
+  GetPostModeratedEventsByEventIdsQuery,
+  GetPostModeratedEventsByEventIdsQueryVariables,
+  GetPostModeratedEventsByEventIds,
+  PostReactedEventFieldsFragment,
+  GetPostReactedEventsByEventIdsQuery,
+  GetPostReactedEventsByEventIdsQueryVariables,
+  GetPostReactedEventsByEventIds,
+  PostTextUpdatedEventFieldsFragment,
+  GetPostTextUpdatedEventsByEventIdsQuery,
+  GetPostTextUpdatedEventsByEventIdsQueryVariables,
+  GetPostTextUpdatedEventsByEventIds,
+  PostDeletedEventFieldsFragment,
+  GetPostDeletedEventsByEventIdsQuery,
+  GetPostDeletedEventsByEventIdsQueryVariables,
+  GetPostDeletedEventsByEventIds,
 } from './graphql/generated/queries'
 import { Maybe } from './graphql/generated/schema'
 import { OperationDefinitionNode } from 'graphql'
 import { ProposalId } from '@joystream/types/proposals'
 import { BLOCKTIME } from './consts'
+import { CategoryId } from '@joystream/types/forum'
 import { Utils } from './utils'
 export class QueryNodeApi {
   private readonly queryNodeProvider: ApolloClient<NormalizedCacheObject>
@@ -714,4 +791,166 @@ export class QueryNodeApi {
       GetProposalCancelledEventsByEventIdsQueryVariables
     >(GetProposalCancelledEventsByEventIds, { eventIds }, 'proposalCancelledEvents')
   }
+
+  public async getCategoriesByIds(ids: CategoryId[]): Promise<ForumCategoryFieldsFragment[]> {
+    return this.multipleEntitiesQuery<GetCategoriesByIdsQuery, GetCategoriesByIdsQueryVariables>(
+      GetCategoriesByIds,
+      { ids: ids.map((id) => id.toString()) },
+      'forumCategories'
+    )
+  }
+
+  public async getCategoryCreatedEvents(events: EventDetails[]): Promise<CategoryCreatedEventFieldsFragment[]> {
+    const eventIds = events.map((e) => this.getQueryNodeEventId(e.blockNumber, e.indexInBlock))
+    return this.multipleEntitiesQuery<
+      GetCategoryCreatedEventsByEventIdsQuery,
+      GetCategoryCreatedEventsByEventIdsQueryVariables
+    >(GetCategoryCreatedEventsByEventIds, { eventIds }, 'categoryCreatedEvents')
+  }
+
+  public async getCategoryUpdatedEvents(events: EventDetails[]): Promise<CategoryUpdatedEventFieldsFragment[]> {
+    const eventIds = events.map((e) => this.getQueryNodeEventId(e.blockNumber, e.indexInBlock))
+    return this.multipleEntitiesQuery<
+      GetCategoryUpdatedEventsByEventIdsQuery,
+      GetCategoryUpdatedEventsByEventIdsQueryVariables
+    >(GetCategoryUpdatedEventsByEventIds, { eventIds }, 'categoryUpdatedEvents')
+  }
+
+  public async getCategoryDeletedEvents(events: EventDetails[]): Promise<CategoryDeletedEventFieldsFragment[]> {
+    const eventIds = events.map((e) => this.getQueryNodeEventId(e.blockNumber, e.indexInBlock))
+    return this.multipleEntitiesQuery<
+      GetCategoryDeletedEventsByEventIdsQuery,
+      GetCategoryDeletedEventsByEventIdsQueryVariables
+    >(GetCategoryDeletedEventsByEventIds, { eventIds }, 'categoryDeletedEvents')
+  }
+
+  public async getThreadCreatedEvents(events: EventDetails[]): Promise<ThreadCreatedEventFieldsFragment[]> {
+    const eventIds = events.map((e) => this.getQueryNodeEventId(e.blockNumber, e.indexInBlock))
+    return this.multipleEntitiesQuery<
+      GetThreadCreatedEventsByEventIdsQuery,
+      GetThreadCreatedEventsByEventIdsQueryVariables
+    >(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,
+      { ids: ids.map((id) => id.toString()) },
+      'forumThreads'
+    )
+  }
+
+  public async getVoteOnPollEvents(events: EventDetails[]): Promise<VoteOnPollEventFieldsFragment[]> {
+    const eventIds = events.map((e) => this.getQueryNodeEventId(e.blockNumber, e.indexInBlock))
+    return this.multipleEntitiesQuery<GetVoteOnPollEventsByEventIdsQuery, GetVoteOnPollEventsByEventIdsQueryVariables>(
+      GetVoteOnPollEventsByEventIds,
+      { eventIds },
+      'voteOnPollEvents'
+    )
+  }
+
+  public async getThreadDeletedEvents(events: EventDetails[]): Promise<ThreadDeletedEventFieldsFragment[]> {
+    const eventIds = events.map((e) => this.getQueryNodeEventId(e.blockNumber, e.indexInBlock))
+    return this.multipleEntitiesQuery<
+      GetThreadDeletedEventsByEventIdsQuery,
+      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')
+  }
+
+  public async getCategoryStickyThreadUpdateEvents(
+    events: EventDetails[]
+  ): Promise<CategoryStickyThreadUpdateEventFieldsFragment[]> {
+    const eventIds = events.map((e) => this.getQueryNodeEventId(e.blockNumber, e.indexInBlock))
+    return this.multipleEntitiesQuery<
+      GetCategoryStickyThreadUpdateEventsByEventIdsQuery,
+      GetCategoryStickyThreadUpdateEventsByEventIdsQueryVariables
+    >(GetCategoryStickyThreadUpdateEventsByEventIds, { eventIds }, 'categoryStickyThreadUpdateEvents')
+  }
+
+  public async getCategoryMembershipOfModeratorUpdatedEvents(
+    events: EventDetails[]
+  ): Promise<CategoryMembershipOfModeratorUpdatedEventFieldsFragment[]> {
+    const eventIds = events.map((e) => this.getQueryNodeEventId(e.blockNumber, e.indexInBlock))
+    return this.multipleEntitiesQuery<
+      GetCategoryMembershipOfModeratorUpdatedEventsByEventIdsQuery,
+      GetCategoryMembershipOfModeratorUpdatedEventsByEventIdsQueryVariables
+    >(
+      GetCategoryMembershipOfModeratorUpdatedEventsByEventIds,
+      { eventIds },
+      'categoryMembershipOfModeratorUpdatedEvents'
+    )
+  }
+
+  public async getThreadModeratedEvents(events: EventDetails[]): Promise<ThreadModeratedEventFieldsFragment[]> {
+    const eventIds = events.map((e) => this.getQueryNodeEventId(e.blockNumber, e.indexInBlock))
+    return this.multipleEntitiesQuery<
+      GetThreadModeratedEventsByEventIdsQuery,
+      GetThreadModeratedEventsByEventIdsQueryVariables
+    >(GetThreadModeratedEventsByEventIds, { eventIds }, 'threadModeratedEvents')
+  }
+
+  public async getPostModeratedEvents(events: EventDetails[]): Promise<PostModeratedEventFieldsFragment[]> {
+    const eventIds = events.map((e) => this.getQueryNodeEventId(e.blockNumber, e.indexInBlock))
+    return this.multipleEntitiesQuery<
+      GetPostModeratedEventsByEventIdsQuery,
+      GetPostModeratedEventsByEventIdsQueryVariables
+    >(GetPostModeratedEventsByEventIds, { eventIds }, 'postModeratedEvents')
+  }
+
+  public async getPostReactedEvents(events: EventDetails[]): Promise<PostReactedEventFieldsFragment[]> {
+    const eventIds = events.map((e) => this.getQueryNodeEventId(e.blockNumber, e.indexInBlock))
+    return this.multipleEntitiesQuery<
+      GetPostReactedEventsByEventIdsQuery,
+      GetPostReactedEventsByEventIdsQueryVariables
+    >(GetPostReactedEventsByEventIds, { eventIds }, 'postReactedEvents')
+  }
+
+  public async getPostTextUpdatedEvents(events: EventDetails[]): Promise<PostTextUpdatedEventFieldsFragment[]> {
+    const eventIds = events.map((e) => this.getQueryNodeEventId(e.blockNumber, e.indexInBlock))
+    return this.multipleEntitiesQuery<
+      GetPostTextUpdatedEventsByEventIdsQuery,
+      GetPostTextUpdatedEventsByEventIdsQueryVariables
+    >(GetPostTextUpdatedEventsByEventIds, { eventIds }, 'postTextUpdatedEvents')
+  }
+
+  public async getPostDeletedEvents(events: EventDetails[]): Promise<PostDeletedEventFieldsFragment[]> {
+    const eventIds = events.map((e) => this.getQueryNodeEventId(e.blockNumber, e.indexInBlock))
+    return this.multipleEntitiesQuery<
+      GetPostDeletedEventsByEventIdsQuery,
+      GetPostDeletedEventsByEventIdsQueryVariables
+    >(GetPostDeletedEventsByEventIds, { eventIds }, 'postDeletedEvents')
+  }
 }

+ 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 THREAD_DEPOSIT = new BN(30)
+export const POST_DEPOSIT = new BN(10)
 
 export const lockIdByWorkingGroup: { [K in WorkingGroupModuleName]: string } = {
   storageWorkingGroup: '0x0606060606060606',

+ 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 | number
+  threadId: ThreadId | number
+  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)
+  }
+}

+ 80 - 0
tests/integration-tests/src/fixtures/forum/CreateCategoriesFixture.ts

@@ -0,0 +1,80 @@
+import { Api } from '../../Api'
+import { QueryNodeApi } from '../../QueryNodeApi'
+import { CategoryCreatedEventDetails } from '../../types'
+import { SubmittableExtrinsic } from '@polkadot/api/types'
+import { Utils } from '../../utils'
+import { ISubmittableResult } from '@polkadot/types/types/'
+import { CategoryCreatedEventFieldsFragment, ForumCategoryFieldsFragment } from '../../graphql/generated/queries'
+import { assert } from 'chai'
+import { StandardizedFixture } from '../../Fixture'
+import { CategoryId } from '@joystream/types/forum'
+
+export type CategoryParams = {
+  title: string
+  description: string
+  parentId?: CategoryId
+}
+
+export class CreateCategoriesFixture extends StandardizedFixture {
+  protected events: CategoryCreatedEventDetails[] = []
+
+  protected categoriesParams: CategoryParams[]
+
+  public constructor(api: Api, query: QueryNodeApi, categoriesParams: CategoryParams[]) {
+    super(api, query)
+    this.categoriesParams = categoriesParams
+  }
+
+  public getCreatedCategoriesIds(): CategoryId[] {
+    if (!this.events.length) {
+      throw new Error('Trying to get created categories ids before they were created!')
+    }
+    return this.events.map((e) => e.categoryId)
+  }
+
+  protected async getSignerAccountOrAccounts(): Promise<string> {
+    return await this.api.getLeadRoleKey('forumWorkingGroup')
+  }
+
+  protected async getExtrinsics(): Promise<SubmittableExtrinsic<'promise'>[]> {
+    return this.categoriesParams.map((params) =>
+      this.api.tx.forum.createCategory(params.parentId || null, params.title, params.description)
+    )
+  }
+
+  protected async getEventFromResult(result: ISubmittableResult): Promise<CategoryCreatedEventDetails> {
+    return this.api.retrieveCategoryCreatedEventDetails(result)
+  }
+
+  protected assertQueriedCategoriesAreValid(qCategories: ForumCategoryFieldsFragment[]): void {
+    this.events.map((e, i) => {
+      const qCategory = qCategories.find((c) => c.id === e.categoryId.toString())
+      const categoryParams = this.categoriesParams[i]
+      Utils.assert(qCategory, 'Query node: Category not found')
+      assert.equal(qCategory.description, categoryParams.description)
+      assert.equal(qCategory.title, categoryParams.title)
+      if (categoryParams.parentId) {
+        Utils.assert(qCategory.parent, 'Query node: Category parent was expected, but not set')
+        assert.equal(qCategory.parent.id, categoryParams.parentId.toString())
+      }
+      assert.equal(qCategory.status.__typename, 'CategoryStatusActive')
+    })
+  }
+
+  protected assertQueryNodeEventIsValid(qEvent: CategoryCreatedEventFieldsFragment, i: number): void {
+    assert.equal(qEvent.category.id, this.events[i].categoryId.toString())
+  }
+
+  async runQueryNodeChecks(): Promise<void> {
+    await super.runQueryNodeChecks()
+    // Query the events
+    await this.query.tryQueryWithTimeout(
+      () => this.query.getCategoryCreatedEvents(this.events),
+      (qEvents) => this.assertQueryNodeEventsAreValid(qEvents)
+    )
+
+    // Query the categories
+    const qCategories = await this.query.getCategoriesByIds(this.events.map((e) => e.categoryId))
+    this.assertQueriedCategoriesAreValid(qCategories)
+  }
+}

+ 140 - 0
tests/integration-tests/src/fixtures/forum/CreateThreadsFixture.ts

@@ -0,0 +1,140 @@
+import { Api } from '../../Api'
+import { QueryNodeApi } from '../../QueryNodeApi'
+import { ThreadCreatedEventDetails } from '../../types'
+import { SubmittableExtrinsic } from '@polkadot/api/types'
+import { Utils } from '../../utils'
+import { ISubmittableResult } from '@polkadot/types/types/'
+import { ForumThreadWithPostsFieldsFragment, ThreadCreatedEventFieldsFragment } from '../../graphql/generated/queries'
+import { assert } from 'chai'
+import { StandardizedFixture } from '../../Fixture'
+import { CategoryId, Poll } from '@joystream/types/forum'
+import { MemberId, ThreadId } from '@joystream/types/common'
+import { CreateInterface } from '@joystream/types'
+import { POST_DEPOSIT, THREAD_DEPOSIT } from '../../consts'
+
+export type PollParams = {
+  description: string
+  endTime: Date
+  alternatives: string[]
+}
+
+export type ThreadParams = {
+  title: string
+  text: string
+  categoryId: CategoryId
+  asMember: MemberId
+  poll?: PollParams
+}
+
+export class CreateThreadsFixture extends StandardizedFixture {
+  protected events: ThreadCreatedEventDetails[] = []
+
+  protected threadsParams: ThreadParams[]
+
+  public constructor(api: Api, query: QueryNodeApi, threadsParams: ThreadParams[]) {
+    super(api, query)
+    this.threadsParams = threadsParams
+  }
+
+  public getCreatedThreadsIds(): ThreadId[] {
+    if (!this.events.length) {
+      throw new Error('Trying to get created threads ids before they were created!')
+    }
+    return this.events.map((e) => e.threadId)
+  }
+
+  protected async getSignerAccountOrAccounts(): Promise<string[]> {
+    return await Promise.all(
+      this.threadsParams.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 (ThreadDeposit + PostDeposit)
+    await Promise.all(accounts.map((a) => this.api.treasuryTransferBalance(a, THREAD_DEPOSIT.add(POST_DEPOSIT))))
+    await super.execute()
+  }
+
+  protected parsePollParams(pollParams?: PollParams): CreateInterface<Poll> | null {
+    if (!pollParams) {
+      return null
+    }
+
+    return {
+      description_hash: pollParams.description,
+      end_time: pollParams.endTime.getTime(),
+      poll_alternatives: pollParams.alternatives.map((a) => ({
+        alternative_text_hash: a,
+        vote_count: 0,
+      })),
+    }
+  }
+
+  protected async getExtrinsics(): Promise<SubmittableExtrinsic<'promise'>[]> {
+    return this.threadsParams.map((params) =>
+      this.api.tx.forum.createThread(
+        params.asMember,
+        params.categoryId,
+        params.title,
+        params.text,
+        this.parsePollParams(params.poll)
+      )
+    )
+  }
+
+  protected async getEventFromResult(result: ISubmittableResult): Promise<ThreadCreatedEventDetails> {
+    return this.api.retrieveThreadCreatedEventDetails(result)
+  }
+
+  protected assertQueriedThreadsAreValid(
+    qThreads: ForumThreadWithPostsFieldsFragment[],
+    qEvents: ThreadCreatedEventFieldsFragment[]
+  ): void {
+    this.events.map((e, i) => {
+      const qThread = qThreads.find((t) => t.id === e.threadId.toString())
+      const qEvent = this.findMatchingQueryNodeEvent(e, qEvents)
+      const threadParams = this.threadsParams[i]
+      Utils.assert(qThread, 'Query node: Thread not found')
+      assert.equal(qThread.title, threadParams.title)
+      assert.equal(qThread.category.id, threadParams.categoryId.toString())
+      assert.equal(qThread.author.id, threadParams.asMember.toString())
+      assert.equal(qThread.status.__typename, 'ThreadStatusActive')
+      assert.equal(qThread.isSticky, false)
+      assert.equal(qThread.createdInEvent.id, qEvent.id)
+      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)
+      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) {
+        Utils.assert(qThread.poll, 'Query node: Thread poll is missing')
+        assert.equal(qThread.poll.description, threadParams.poll.description)
+        assert.equal(new Date(qThread.poll.endTime).getTime(), threadParams.poll.endTime.getTime())
+      }
+    })
+  }
+
+  protected assertQueryNodeEventIsValid(qEvent: ThreadCreatedEventFieldsFragment, i: number): void {
+    assert.equal(qEvent.thread.id, this.events[i].threadId.toString())
+  }
+
+  async runQueryNodeChecks(): Promise<void> {
+    await super.runQueryNodeChecks()
+    // Query the events
+    const qEvents = await this.query.tryQueryWithTimeout(
+      () => this.query.getThreadCreatedEvents(this.events),
+      (qEvents) => this.assertQueryNodeEventsAreValid(qEvents)
+    )
+
+    // Query the threads
+    const qThreads = await this.query.getThreadsWithPostsByIds(this.events.map((e) => e.threadId))
+    this.assertQueriedThreadsAreValid(qThreads, qEvents)
+  }
+}

+ 100 - 0
tests/integration-tests/src/fixtures/forum/DeletePostsFixture.ts

@@ -0,0 +1,100 @@
+import { Api } from '../../Api'
+import { QueryNodeApi } from '../../QueryNodeApi'
+import { EventDetails, PostPath } from '../../types'
+import { SubmittableExtrinsic } from '@polkadot/api/types'
+import { Utils } from '../../utils'
+import { ISubmittableResult } from '@polkadot/types/types/'
+import { ForumPostFieldsFragment, PostDeletedEventFieldsFragment } from '../../graphql/generated/queries'
+import { assert } from 'chai'
+import { StandardizedFixture } from '../../Fixture'
+import { MemberId, PostId, ThreadId } from '@joystream/types/common'
+import { CategoryId } from '@joystream/types/forum'
+
+const DEFAULT_RATIONALE = 'State cleanup'
+
+type SinglePostRemovalInput = PostPath & {
+  hide?: boolean // defaults to "true"
+}
+
+export type PostsRemovalInput = {
+  posts: SinglePostRemovalInput[]
+  asMember: MemberId
+  rationale?: string
+}
+
+export class DeletePostsFixture extends StandardizedFixture {
+  protected removals: PostsRemovalInput[]
+
+  public constructor(api: Api, query: QueryNodeApi, removals: PostsRemovalInput[]) {
+    super(api, query)
+    this.removals = removals
+  }
+
+  protected async getSignerAccountOrAccounts(): Promise<string[]> {
+    return await Promise.all(
+      this.removals.map(async ({ asMember }) =>
+        (await this.api.query.members.membershipById(asMember)).controller_account.toString()
+      )
+    )
+  }
+
+  protected async getExtrinsics(): Promise<SubmittableExtrinsic<'promise'>[]> {
+    return this.removals.map((r) =>
+      this.api.tx.forum.deletePosts(
+        r.asMember,
+        r.posts.map(
+          ({ categoryId, threadId, postId, hide }) =>
+            [categoryId, threadId, postId, hide === undefined || hide] as [CategoryId, ThreadId, PostId, boolean]
+        ),
+        r.rationale || DEFAULT_RATIONALE
+      )
+    )
+  }
+
+  protected async getEventFromResult(result: ISubmittableResult): Promise<EventDetails> {
+    return this.api.retrieveForumEventDetails(result, 'PostDeleted')
+  }
+
+  protected assertQueriedPostsAreValid(
+    qPosts: ForumPostFieldsFragment[],
+    qEvents: PostDeletedEventFieldsFragment[]
+  ): void {
+    this.events.map((e, i) => {
+      const removal = this.removals[i]
+      const qEvent = this.findMatchingQueryNodeEvent(e, qEvents)
+      removal.posts.forEach((postRemoval) => {
+        const hidden = postRemoval.hide === undefined || postRemoval.hide
+        const expectedStatus = hidden ? 'PostStatusRemoved' : 'PostStatusLocked'
+        const qPost = qPosts.find((p) => p.id === postRemoval.postId.toString())
+        Utils.assert(qPost, 'Query node: Post not found')
+        Utils.assert(qPost.status.__typename === expectedStatus, `Invalid post status. Expected: ${expectedStatus}`)
+        Utils.assert(qPost.status.postDeletedEvent, 'Query node: Missing PostDeletedEvent ref')
+        assert.equal(qPost.status.postDeletedEvent.id, qEvent.id)
+      })
+    })
+  }
+
+  protected assertQueryNodeEventIsValid(qEvent: PostDeletedEventFieldsFragment, i: number): void {
+    assert.equal(qEvent.actor.id, this.removals[i].asMember.toString())
+    assert.sameMembers(
+      qEvent.posts.map((p) => p.id),
+      this.removals[i].posts.map((p) => p.postId.toString())
+    )
+    assert.equal(qEvent.rationale, this.removals[i].rationale || DEFAULT_RATIONALE)
+  }
+
+  async runQueryNodeChecks(): Promise<void> {
+    await super.runQueryNodeChecks()
+    // Query the events
+    const qEvents = await this.query.tryQueryWithTimeout(
+      () => this.query.getPostDeletedEvents(this.events),
+      (qEvents) => this.assertQueryNodeEventsAreValid(qEvents)
+    )
+
+    // Query the posts
+    const qPosts = await this.query.getPostsByIds(
+      this.removals.reduce((allPostsIds, { posts }) => allPostsIds.concat(posts.map((p) => p.postId)), [] as PostId[])
+    )
+    this.assertQueriedPostsAreValid(qPosts, qEvents)
+  }
+}

+ 95 - 0
tests/integration-tests/src/fixtures/forum/DeleteThreadsFixture.ts

@@ -0,0 +1,95 @@
+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, ThreadDeletedEventFieldsFragment } from '../../graphql/generated/queries'
+import { assert } from 'chai'
+import { StandardizedFixture } from '../../Fixture'
+import { CategoryId } from '@joystream/types/forum'
+import { ThreadId } from '@joystream/types/common'
+
+export type ThreadRemovalInput = {
+  threadId: ThreadId
+  categoryId: CategoryId
+  hide?: boolean // defaults to "true"
+}
+
+export class DeleteThreadsFixture extends StandardizedFixture {
+  protected removals: ThreadRemovalInput[]
+  protected threadAuthors: MemberContext[] = []
+
+  public constructor(api: Api, query: QueryNodeApi, removals: ThreadRemovalInput[]) {
+    super(api, query)
+    this.removals = removals
+  }
+
+  protected async loadAuthors(): Promise<void> {
+    this.threadAuthors = await Promise.all(
+      this.removals.map(async (r) => {
+        const thread = await this.api.query.forum.threadById(r.categoryId, r.threadId)
+        const member = await this.api.query.members.membershipById(thread.author_id)
+        return { account: member.controller_account.toString(), memberId: thread.author_id }
+      })
+    )
+  }
+
+  public async execute(): Promise<void> {
+    await this.loadAuthors()
+    await super.execute()
+  }
+
+  protected async getSignerAccountOrAccounts(): Promise<string[]> {
+    return this.threadAuthors.map((a) => a.account)
+  }
+
+  protected async getExtrinsics(): Promise<SubmittableExtrinsic<'promise'>[]> {
+    return this.removals.map((r, i) =>
+      this.api.tx.forum.deleteThread(
+        this.threadAuthors[i].memberId,
+        r.categoryId,
+        r.threadId,
+        r.hide === undefined ? true : r.hide
+      )
+    )
+  }
+
+  protected async getEventFromResult(result: ISubmittableResult): Promise<EventDetails> {
+    return this.api.retrieveForumEventDetails(result, 'ThreadDeleted')
+  }
+
+  protected assertQueriedThreadsAreValid(
+    qThreads: ForumThreadWithPostsFieldsFragment[],
+    qEvents: ThreadDeletedEventFieldsFragment[]
+  ): void {
+    this.events.map((e, i) => {
+      const removal = this.removals[i]
+      const hidden = removal.hide === undefined ? true : removal.hide
+      const expectedStatus = hidden ? 'ThreadStatusRemoved' : 'ThreadStatusLocked'
+      const qThread = qThreads.find((t) => t.id === removal.threadId.toString())
+      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}`)
+      Utils.assert(qThread.status.threadDeletedEvent, 'Query node: Missing ThreadDeletedEvent ref')
+      assert.equal(qThread.status.threadDeletedEvent.id, qEvent.id)
+    })
+  }
+
+  protected assertQueryNodeEventIsValid(qEvent: ThreadDeletedEventFieldsFragment, i: number): void {
+    assert.equal(qEvent.thread.id, this.removals[i].threadId.toString())
+  }
+
+  async runQueryNodeChecks(): Promise<void> {
+    await super.runQueryNodeChecks()
+    // Query the events
+    const qEvents = await this.query.tryQueryWithTimeout(
+      () => this.query.getThreadDeletedEvents(this.events),
+      (qEvents) => this.assertQueryNodeEventsAreValid(qEvents)
+    )
+
+    // Query the threads
+    const qThreads = await this.query.getThreadsWithPostsByIds(this.removals.map((r) => r.threadId))
+    this.assertQueriedThreadsAreValid(qThreads, qEvents)
+  }
+}

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

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

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

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

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

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

+ 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)
+  }
+}

+ 114 - 0
tests/integration-tests/src/fixtures/forum/ReactToPostsFixture.ts

@@ -0,0 +1,114 @@
+import { Api } from '../../Api'
+import { QueryNodeApi } from '../../QueryNodeApi'
+import { EventDetails, PostPath } from '../../types'
+import { SubmittableExtrinsic } from '@polkadot/api/types'
+import { Utils } from '../../utils'
+import { ISubmittableResult } from '@polkadot/types/types/'
+import { ForumPostFieldsFragment, PostReactedEventFieldsFragment } from '../../graphql/generated/queries'
+import { assert } from 'chai'
+import { StandardizedFixture } from '../../Fixture'
+import _ from 'lodash'
+import { MemberId } from '@joystream/types/common'
+import { ForumPostReaction } from '@joystream/metadata-protobuf'
+import { PostReaction } from '../../graphql/generated/schema'
+
+export type PostReactionParams = PostPath & {
+  reactionId: number
+  asMember: MemberId
+}
+
+export class ReactToPostsFixture extends StandardizedFixture {
+  protected reactions: PostReactionParams[]
+
+  public constructor(api: Api, query: QueryNodeApi, reactions: PostReactionParams[]) {
+    super(api, query)
+    this.reactions = reactions
+  }
+
+  protected async getSignerAccountOrAccounts(): Promise<string[]> {
+    return await Promise.all(
+      this.reactions.map(async ({ asMember }) =>
+        (await this.api.query.members.membershipById(asMember)).controller_account.toString()
+      )
+    )
+  }
+
+  protected async getExtrinsics(): Promise<SubmittableExtrinsic<'promise'>[]> {
+    return this.reactions.map((r) =>
+      this.api.tx.forum.reactPost(r.asMember, r.categoryId, r.threadId, r.postId, r.reactionId)
+    )
+  }
+
+  protected async getEventFromResult(result: ISubmittableResult): Promise<EventDetails> {
+    return this.api.retrieveForumEventDetails(result, 'PostReacted')
+  }
+
+  protected getExpectedReaction(reactionId: number): PostReaction | null {
+    if (reactionId === ForumPostReaction.Reaction.LIKE) {
+      return PostReaction.Like
+    }
+
+    return null
+  }
+
+  protected assertQueriedPostsAreValid(qPosts: ForumPostFieldsFragment[]): void {
+    // Check against lastest reaction per user per post
+    _.uniqBy([...this.reactions].reverse(), (v) => `${v.postId.toString()}:${v.asMember.toString()}`).map(
+      (reaction) => {
+        const expectedReaction = this.getExpectedReaction(reaction.reactionId)
+        const qPost = qPosts.find((p) => p.id === reaction.postId.toString())
+        Utils.assert(qPost, 'Query node: Post not found')
+
+        const qReaction = qPost.reactions.find((r) => r.member.id === reaction.asMember.toString())
+        if (expectedReaction) {
+          Utils.assert(
+            qReaction,
+            `Query node: Expected post reaction by member ${reaction.asMember.toString()} not found!`
+          )
+          assert.equal(qReaction.reaction, expectedReaction)
+        } else {
+          assert.isUndefined(qReaction)
+        }
+      }
+    )
+  }
+
+  protected assertQueryNodeEventIsValid(qEvent: PostReactedEventFieldsFragment, i: number): void {
+    const { postId, asMember, reactionId } = this.reactions[i]
+    const expectedReaction = this.getExpectedReaction(reactionId)
+    assert.equal(qEvent.post.id, postId.toString())
+    assert.equal(qEvent.reactingMember.id, asMember.toString())
+    if (reactionId && expectedReaction === null) {
+      Utils.assert(
+        qEvent.reactionResult.__typename === 'PostReactionResultInvalid',
+        'Query node: Invalid reaction result'
+      )
+      assert.equal(qEvent.reactionResult.reactionId, reactionId)
+    } else if (!reactionId) {
+      Utils.assert(
+        qEvent.reactionResult.__typename === 'PostReactionResultCancel',
+        'Query node: Invalid reaction result'
+      )
+    } else {
+      Utils.assert(
+        qEvent.reactionResult.__typename === 'PostReactionResultValid',
+        'Query node: Invalid reaction result'
+      )
+      assert.equal(qEvent.reactionResult.reaction, expectedReaction)
+      assert.equal(qEvent.reactionResult.reactionId, reactionId)
+    }
+  }
+
+  async runQueryNodeChecks(): Promise<void> {
+    await super.runQueryNodeChecks()
+    // Query the events
+    await this.query.tryQueryWithTimeout(
+      () => this.query.getPostReactedEvents(this.events),
+      (qEvents) => this.assertQueryNodeEventsAreValid(qEvents)
+    )
+
+    // Query the threads
+    const qPosts = await this.query.getPostsByIds(this.reactions.map((r) => r.postId))
+    this.assertQueriedPostsAreValid(qPosts)
+  }
+}

+ 73 - 0
tests/integration-tests/src/fixtures/forum/RemoveCategoriesFixture.ts

@@ -0,0 +1,73 @@
+import { Api } from '../../Api'
+import { QueryNodeApi } from '../../QueryNodeApi'
+import { EventDetails } from '../../types'
+import { WorkerId } from '@joystream/types/working-group'
+import { SubmittableExtrinsic } from '@polkadot/api/types'
+import { Utils } from '../../utils'
+import { ISubmittableResult } from '@polkadot/types/types/'
+import { CategoryDeletedEventFieldsFragment, ForumCategoryFieldsFragment } from '../../graphql/generated/queries'
+import { assert } from 'chai'
+import { CategoryId } from '@joystream/types/forum'
+import { WithForumWorkersFixture } from './WithForumWorkersFixture'
+
+export type CategoryRemovalInput = {
+  categoryId: CategoryId
+  asWorker?: WorkerId
+}
+
+export class RemoveCategoriesFixture extends WithForumWorkersFixture {
+  protected removals: CategoryRemovalInput[]
+
+  public constructor(api: Api, query: QueryNodeApi, removals: CategoryRemovalInput[]) {
+    super(api, query)
+    this.removals = removals
+  }
+
+  protected async getSignerAccountOrAccounts(): Promise<string[]> {
+    return this.getSignersFromInput(this.removals)
+  }
+
+  protected async getExtrinsics(): Promise<SubmittableExtrinsic<'promise'>[]> {
+    return this.removals.map((u) =>
+      this.api.tx.forum.deleteCategory(u.asWorker ? { Moderator: u.asWorker } : { Lead: null }, u.categoryId)
+    )
+  }
+
+  protected async getEventFromResult(result: ISubmittableResult): Promise<EventDetails> {
+    return this.api.retrieveForumEventDetails(result, 'CategoryDeleted')
+  }
+
+  protected assertQueriedCategoriesAreValid(
+    qCategories: ForumCategoryFieldsFragment[],
+    qEvents: CategoryDeletedEventFieldsFragment[]
+  ): void {
+    this.events.map((e, i) => {
+      const removal = this.removals[i]
+      const qCategory = qCategories.find((c) => c.id === removal.categoryId.toString())
+      const qEvent = this.findMatchingQueryNodeEvent(e, qEvents)
+      Utils.assert(qCategory, 'Query node: Category not found')
+      Utils.assert(qCategory.status.__typename === 'CategoryStatusRemoved', 'Invalid category status')
+      Utils.assert(qCategory.status.categoryDeletedEvent, 'Query node: Missing CategoryDeletedEvent ref')
+      assert.equal(qCategory.status.categoryDeletedEvent.id, qEvent.id)
+    })
+  }
+
+  protected assertQueryNodeEventIsValid(qEvent: CategoryDeletedEventFieldsFragment, i: number): void {
+    const { categoryId, asWorker } = this.removals[i]
+    assert.equal(qEvent.category.id, categoryId.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.getCategoryDeletedEvents(this.events),
+      (qEvents) => this.assertQueryNodeEventsAreValid(qEvents)
+    )
+
+    // Query the categories
+    const qCategories = await this.query.getCategoriesByIds(this.removals.map((r) => r.categoryId))
+    this.assertQueriedCategoriesAreValid(qCategories, qEvents)
+  }
+}

+ 85 - 0
tests/integration-tests/src/fixtures/forum/SetStickyThreadsFixture.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 {
+  CategoryStickyThreadUpdateEventFieldsFragment,
+  ForumCategoryFieldsFragment,
+} from '../../graphql/generated/queries'
+import { assert } from 'chai'
+import { CategoryId } from '@joystream/types/forum'
+import { ThreadId } from '@joystream/types/common'
+import { WorkerId } from '@joystream/types/working-group'
+import { WithForumWorkersFixture } from './WithForumWorkersFixture'
+import _ from 'lodash'
+
+export type StickyThreadsParams = {
+  categoryId: CategoryId
+  stickyTreads: ThreadId[]
+  asWorker?: WorkerId
+}
+
+export class SetStickyThreadsFixture extends WithForumWorkersFixture {
+  protected events: EventDetails[] = []
+
+  protected stickyThreadsParams: StickyThreadsParams[]
+
+  public constructor(api: Api, query: QueryNodeApi, stickyThreadsParams: StickyThreadsParams[]) {
+    super(api, query)
+    this.stickyThreadsParams = stickyThreadsParams
+  }
+
+  protected async getSignerAccountOrAccounts(): Promise<string[]> {
+    return this.getSignersFromInput(this.stickyThreadsParams)
+  }
+
+  protected async getExtrinsics(): Promise<SubmittableExtrinsic<'promise'>[]> {
+    return this.stickyThreadsParams.map((p) =>
+      this.api.tx.forum.setStickiedThreads(
+        p.asWorker ? { Moderator: p.asWorker } : { Lead: null },
+        p.categoryId,
+        p.stickyTreads
+      )
+    )
+  }
+
+  protected async getEventFromResult(result: ISubmittableResult): Promise<EventDetails> {
+    return this.api.retrieveForumEventDetails(result, 'CategoryStickyThreadUpdate')
+  }
+
+  protected assertQueriedCategoriesAreValid(qCategories: ForumCategoryFieldsFragment[]): void {
+    _.uniqBy([...this.stickyThreadsParams.reverse()], (v) => v.categoryId).forEach((params) => {
+      const qCategory = qCategories.find((c) => c.id === params.categoryId.toString())
+      Utils.assert(qCategory, 'Query node: Category not found')
+      assert.sameDeepMembers(
+        qCategory.threads.filter((t) => t.isSticky).map((t) => t.id),
+        params.stickyTreads.map((threadId) => threadId.toString())
+      )
+    })
+  }
+
+  protected assertQueryNodeEventIsValid(qEvent: CategoryStickyThreadUpdateEventFieldsFragment, i: number): void {
+    const { categoryId, stickyTreads, asWorker } = this.stickyThreadsParams[i]
+    assert.equal(qEvent.category.id, categoryId.toString())
+    assert.sameDeepMembers(
+      qEvent.newStickyThreads.map((t) => t.id),
+      stickyTreads.map((threadId) => threadId.toString())
+    )
+    assert.equal(qEvent.actor.id, `forumWorkingGroup-${asWorker ? asWorker.toString() : this.forumLeadId!.toString()}`)
+  }
+
+  async runQueryNodeChecks(): Promise<void> {
+    await super.runQueryNodeChecks()
+    // Query the events
+    await this.query.tryQueryWithTimeout(
+      () => this.query.getCategoryStickyThreadUpdateEvents(this.events),
+      (qEvents) => this.assertQueryNodeEventsAreValid(qEvents)
+    )
+
+    // Query the categories
+    const qCategories = await this.query.getCategoriesByIds(this.stickyThreadsParams.map((e) => e.categoryId))
+    this.assertQueriedCategoriesAreValid(qCategories)
+  }
+}

+ 90 - 0
tests/integration-tests/src/fixtures/forum/UpdateCategoriesStatusFixture.ts

@@ -0,0 +1,90 @@
+import { Api } from '../../Api'
+import { QueryNodeApi } from '../../QueryNodeApi'
+import { EventDetails } from '../../types'
+import { WorkerId } from '@joystream/types/working-group'
+import { SubmittableExtrinsic } from '@polkadot/api/types'
+import { Utils } from '../../utils'
+import { ISubmittableResult } from '@polkadot/types/types/'
+import { CategoryUpdatedEventFieldsFragment, ForumCategoryFieldsFragment } from '../../graphql/generated/queries'
+import { assert } from 'chai'
+import { CategoryId } from '@joystream/types/forum'
+import { WithForumWorkersFixture } from './WithForumWorkersFixture'
+import _ from 'lodash'
+
+export type CategoryStatusUpdate = {
+  categoryId: CategoryId
+  archived: boolean
+  asWorker?: WorkerId
+}
+
+export class UpdateCategoriesStatusFixture extends WithForumWorkersFixture {
+  protected updates: CategoryStatusUpdate[]
+
+  public constructor(api: Api, query: QueryNodeApi, updates: CategoryStatusUpdate[]) {
+    super(api, query)
+    this.updates = updates
+  }
+
+  protected async getSignerAccountOrAccounts(): Promise<string[]> {
+    return Promise.all(
+      this.updates.map(async (u) => {
+        const workerId = u.asWorker || (await this.getForumLeadId())
+        return (await this.api.query.forumWorkingGroup.workerById(workerId)).role_account_id.toString()
+      })
+    )
+  }
+
+  protected async getExtrinsics(): Promise<SubmittableExtrinsic<'promise'>[]> {
+    return this.updates.map((u) =>
+      this.api.tx.forum.updateCategoryArchivalStatus(
+        u.asWorker ? { Moderator: u.asWorker } : { Lead: null },
+        u.categoryId,
+        u.archived
+      )
+    )
+  }
+
+  protected async getEventFromResult(result: ISubmittableResult): Promise<EventDetails> {
+    return this.api.retrieveForumEventDetails(result, 'CategoryUpdated')
+  }
+
+  protected assertQueriedCategoriesAreValid(
+    qCategories: ForumCategoryFieldsFragment[],
+    qEvents: CategoryUpdatedEventFieldsFragment[]
+  ): void {
+    // 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(this.events[updateIndex], qEvents)
+      Utils.assert(qCategory, 'Query node: Category not found')
+      if (update.archived) {
+        Utils.assert(qCategory.status.__typename === 'CategoryStatusArchived', 'Invalid category status')
+        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')
+      }
+    })
+  }
+
+  protected assertQueryNodeEventIsValid(qEvent: CategoryUpdatedEventFieldsFragment, i: number): void {
+    const { categoryId, archived, asWorker } = this.updates[i]
+    assert.equal(qEvent.category.id, categoryId.toString())
+    assert.equal(qEvent.newArchivalStatus, archived)
+    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.getCategoryUpdatedEvents(this.events),
+      (qEvents) => this.assertQueryNodeEventsAreValid(qEvents)
+    )
+
+    // Query the categories
+    const qCategories = await this.query.getCategoriesByIds(this.updates.map((u) => u.categoryId))
+    this.assertQueriedCategoriesAreValid(qCategories, qEvents)
+  }
+}

+ 90 - 0
tests/integration-tests/src/fixtures/forum/UpdateCategoryModeratorsFixture.ts

@@ -0,0 +1,90 @@
+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 {
+  CategoryMembershipOfModeratorUpdatedEventFieldsFragment,
+  ForumCategoryFieldsFragment,
+} from '../../graphql/generated/queries'
+import { assert } from 'chai'
+import { CategoryId } from '@joystream/types/forum'
+import { WorkerId } from '@joystream/types/working-group'
+import { WithForumWorkersFixture } from './WithForumWorkersFixture'
+import _ from 'lodash'
+
+export type CategoryModeratorStatusUpdate = {
+  categoryId: CategoryId
+  moderatorId: WorkerId
+  canModerate: boolean
+}
+
+export class UpdateCategoryModeratorsFixture extends WithForumWorkersFixture {
+  protected events: EventDetails[] = []
+
+  protected updates: CategoryModeratorStatusUpdate[]
+
+  public constructor(api: Api, query: QueryNodeApi, updates: CategoryModeratorStatusUpdate[]) {
+    super(api, query)
+    this.updates = updates
+  }
+
+  protected async getSignerAccountOrAccounts(): Promise<string> {
+    return await this.api.getLeadRoleKey('forumWorkingGroup')
+  }
+
+  protected async getExtrinsics(): Promise<SubmittableExtrinsic<'promise'>[]> {
+    return this.updates.map((u) =>
+      this.api.tx.forum.updateCategoryMembershipOfModerator(u.moderatorId, u.categoryId, u.canModerate)
+    )
+  }
+
+  protected async getEventFromResult(result: ISubmittableResult): Promise<EventDetails> {
+    return this.api.retrieveForumEventDetails(result, 'CategoryMembershipOfModeratorUpdated')
+  }
+
+  protected assertQueriedCategoriesAreValid(qCategories: ForumCategoryFieldsFragment[]): void {
+    for (const [categoryId, updates] of _.entries(_.groupBy(this.updates, (u) => u.categoryId.toString()))) {
+      const finalUpdates = _.uniqBy([...updates.reverse()], (u) => u.moderatorId)
+      const qCategory = qCategories.find((c) => c.id === categoryId.toString())
+      Utils.assert(qCategory, 'Query node: Category not found!')
+      finalUpdates.forEach((finalUpdate) => {
+        if (finalUpdate.canModerate) {
+          assert.include(
+            qCategory.moderators.map((m) => m.id),
+            `forumWorkingGroup-${finalUpdate.moderatorId.toString()}`
+          )
+        } else {
+          assert.notInclude(
+            qCategory.moderators.map((m) => m.id),
+            `forumWorkingGroup-${finalUpdate.moderatorId.toString()}`
+          )
+        }
+      })
+    }
+  }
+
+  protected assertQueryNodeEventIsValid(
+    qEvent: CategoryMembershipOfModeratorUpdatedEventFieldsFragment,
+    i: number
+  ): void {
+    const { categoryId, moderatorId, canModerate } = this.updates[i]
+    assert.equal(qEvent.category.id, categoryId.toString())
+    assert.equal(qEvent.moderator.id, `forumWorkingGroup-${moderatorId.toString()}`)
+    assert.equal(qEvent.newCanModerateValue, canModerate)
+  }
+
+  async runQueryNodeChecks(): Promise<void> {
+    await super.runQueryNodeChecks()
+    // Query the events
+    await this.query.tryQueryWithTimeout(
+      () => this.query.getCategoryMembershipOfModeratorUpdatedEvents(this.events),
+      (qEvents) => this.assertQueryNodeEventsAreValid(qEvents)
+    )
+
+    // Query the categories
+    const qCategories = await this.query.getCategoriesByIds(this.updates.map((u) => u.categoryId))
+    this.assertQueriedCategoriesAreValid(qCategories)
+  }
+}

+ 96 - 0
tests/integration-tests/src/fixtures/forum/UpdatePostsTextFixture.ts

@@ -0,0 +1,96 @@
+import { Api } from '../../Api'
+import { QueryNodeApi } from '../../QueryNodeApi'
+import { EventDetails, MemberContext, PostPath } from '../../types'
+import { SubmittableExtrinsic } from '@polkadot/api/types'
+import { Utils } from '../../utils'
+import { ISubmittableResult } from '@polkadot/types/types/'
+import { ForumPostFieldsFragment, PostTextUpdatedEventFieldsFragment } from '../../graphql/generated/queries'
+import { assert } from 'chai'
+import { StandardizedFixture } from '../../Fixture'
+import _ from 'lodash'
+import { PostTextUpdatedEvent } from '../../graphql/generated/schema'
+
+export type PostTextUpdate = PostPath & {
+  newText: string
+}
+
+export class UpdatePostsTextFixture extends StandardizedFixture {
+  protected postAuthors: MemberContext[] = []
+  protected updates: PostTextUpdate[]
+
+  public constructor(api: Api, query: QueryNodeApi, updates: PostTextUpdate[]) {
+    super(api, query)
+    this.updates = updates
+  }
+
+  protected async loadAuthors(): Promise<void> {
+    this.postAuthors = await Promise.all(
+      this.updates.map(async (u) => {
+        const post = await this.api.query.forum.postById(u.threadId, u.postId)
+        const member = await this.api.query.members.membershipById(post.author_id)
+        return { account: member.controller_account.toString(), memberId: post.author_id }
+      })
+    )
+  }
+
+  protected async getSignerAccountOrAccounts(): Promise<string[]> {
+    return this.postAuthors.map((a) => a.account)
+  }
+
+  protected async getExtrinsics(): Promise<SubmittableExtrinsic<'promise'>[]> {
+    return this.updates.map((u, i) =>
+      this.api.tx.forum.editPostText(this.postAuthors[i].memberId, u.categoryId, u.threadId, u.postId, u.newText)
+    )
+  }
+
+  protected async getEventFromResult(result: ISubmittableResult): Promise<EventDetails> {
+    return this.api.retrieveForumEventDetails(result, 'PostTextUpdated')
+  }
+
+  public async execute(): Promise<void> {
+    await this.loadAuthors()
+    await super.execute()
+  }
+
+  protected assertQueriedPostsAreValid(
+    qPosts: ForumPostFieldsFragment[],
+    qEvents: PostTextUpdatedEventFieldsFragment[]
+  ): void {
+    // Check update events are included in posts One-to-Many relation
+    this.events.forEach((e, i) => {
+      const qEvent = this.findMatchingQueryNodeEvent(e, qEvents)
+      const qPost = qPosts.find((p) => p.id === this.updates[i].postId.toString())
+      Utils.assert(qPost, 'Query node: Post not found')
+      assert.include(
+        qPost.edits.map((e) => e.id),
+        qEvent.id
+      )
+    })
+
+    // Check post text against lastest update per post
+    _.uniqBy([...this.updates].reverse(), (v) => v.postId).map((update) => {
+      const qPost = qPosts.find((p) => p.id === update.postId.toString())
+      Utils.assert(qPost, 'Query node: Post not found')
+      assert.equal(qPost.text, update.newText)
+    })
+  }
+
+  protected assertQueryNodeEventIsValid(qEvent: PostTextUpdatedEvent, i: number): void {
+    const { postId, newText } = this.updates[i]
+    assert.equal(qEvent.post.id, postId.toString())
+    assert.equal(qEvent.newText, newText)
+  }
+
+  async runQueryNodeChecks(): Promise<void> {
+    await super.runQueryNodeChecks()
+    // Query the events
+    const qEvents = await this.query.tryQueryWithTimeout(
+      () => this.query.getPostTextUpdatedEvents(this.events),
+      (qEvents) => this.assertQueryNodeEventsAreValid(qEvents)
+    )
+
+    // Query the threads
+    const qPosts = await this.query.getPostsByIds(this.updates.map((u) => u.postId))
+    this.assertQueriedPostsAreValid(qPosts, qEvents)
+  }
+}

+ 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)
+  }
+}

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

@@ -0,0 +1,75 @@
+import { Api } from '../../Api'
+import { QueryNodeApi } from '../../QueryNodeApi'
+import { EventDetails } from '../../types'
+import { SubmittableExtrinsic } from '@polkadot/api/types'
+import { ISubmittableResult } from '@polkadot/types/types/'
+import { ForumThreadWithPostsFieldsFragment, VoteOnPollEventFieldsFragment } from '../../graphql/generated/queries'
+import { assert } from 'chai'
+import { StandardizedFixture } from '../../Fixture'
+import { CategoryId } from '@joystream/types/forum'
+import { MemberId, ThreadId } from '@joystream/types/common'
+import { Utils } from '../../utils'
+
+export type VoteParams = {
+  categoryId: CategoryId
+  threadId: ThreadId
+  index: number
+  asMember: MemberId
+}
+
+export class VoteOnPollFixture extends StandardizedFixture {
+  protected votes: VoteParams[]
+
+  public constructor(api: Api, query: QueryNodeApi, votes: VoteParams[]) {
+    super(api, query)
+    this.votes = votes
+  }
+
+  protected async getSignerAccountOrAccounts(): Promise<string[]> {
+    return await Promise.all(
+      this.votes.map(async ({ asMember }) =>
+        (await this.api.query.members.membershipById(asMember)).controller_account.toString()
+      )
+    )
+  }
+
+  protected async getExtrinsics(): Promise<SubmittableExtrinsic<'promise'>[]> {
+    return this.votes.map((params) =>
+      this.api.tx.forum.voteOnPoll(params.asMember, params.categoryId, params.threadId, params.index)
+    )
+  }
+
+  protected async getEventFromResult(result: ISubmittableResult): Promise<EventDetails> {
+    return this.api.retrieveForumEventDetails(result, 'VoteOnPoll')
+  }
+
+  protected assertQueryNodeEventIsValid(qEvent: VoteOnPollEventFieldsFragment, i: number): void {
+    assert.equal(qEvent.pollAlternative.poll.thread.id, this.votes[i].threadId.toString())
+    assert.equal(qEvent.pollAlternative.index, this.votes[i].index)
+    assert.equal(qEvent.votingMember.id, this.votes[i].asMember.toString())
+  }
+
+  protected assertQueriedThreadsAreValid(qThreads: ForumThreadWithPostsFieldsFragment[]): void {
+    this.votes.forEach(({ asMember, threadId, index }) => {
+      const qThread = qThreads.find((t) => t.id === threadId.toString())
+      Utils.assert(qThread, 'Query node: Thread not found')
+      Utils.assert(
+        qThread.poll?.pollAlternatives[index].votes.find((v) => v.votingMember.id === asMember.toString()),
+        'Query node: Member vote not found'
+      )
+    })
+  }
+
+  async runQueryNodeChecks(): Promise<void> {
+    await super.runQueryNodeChecks()
+    // Query the events
+    await this.query.tryQueryWithTimeout(
+      () => this.query.getVoteOnPollEvents(this.events),
+      (qEvents) => this.assertQueryNodeEventsAreValid(qEvents)
+    )
+
+    // Query the threads
+    const qThreads = await this.query.getThreadsWithPostsByIds(this.votes.map((v) => v.threadId))
+    this.assertQueriedThreadsAreValid(qThreads)
+  }
+}

+ 28 - 0
tests/integration-tests/src/fixtures/forum/WithForumWorkersFixture.ts

@@ -0,0 +1,28 @@
+import { WorkerId } from '@joystream/types/working-group'
+import { StandardizedFixture } from '../../Fixture'
+
+export abstract class WithForumWorkersFixture extends StandardizedFixture {
+  protected forumLeadId?: WorkerId
+
+  protected async getForumLeadId(): Promise<WorkerId> {
+    if (!this.forumLeadId) {
+      const optForumLeadId = await this.api.query.forumWorkingGroup.currentLead()
+      if (optForumLeadId.isNone) {
+        throw new Error('Forum working group lead not set!')
+      }
+
+      this.forumLeadId = optForumLeadId.unwrap()
+    }
+
+    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()
+      })
+    )
+  }
+}

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

@@ -0,0 +1,17 @@
+export { CreateCategoriesFixture, CategoryParams } from './CreateCategoriesFixture'
+export { UpdateCategoriesStatusFixture, CategoryStatusUpdate } from './UpdateCategoriesStatusFixture'
+export { RemoveCategoriesFixture, CategoryRemovalInput } from './RemoveCategoriesFixture'
+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'
+export { SetStickyThreadsFixture, StickyThreadsParams } from './SetStickyThreadsFixture'
+export { UpdateCategoryModeratorsFixture, CategoryModeratorStatusUpdate } from './UpdateCategoryModeratorsFixture'
+export { ModerateThreadsFixture, ThreadModerationInput } from './ModerateThreadsFixture'
+export { ModeratePostsFixture, PostModerationInput } from './ModeratePostsFixture'
+export { InitializeForumFixture, InitializeForumConfig } from './InitializeForumFixture'
+export { ReactToPostsFixture, PostReactionParams } from './ReactToPostsFixture'
+export { UpdatePostsTextFixture, PostTextUpdate } from './UpdatePostsTextFixture'
+export { DeletePostsFixture, PostsRemovalInput } from './DeletePostsFixture'

+ 107 - 0
tests/integration-tests/src/flows/forum/categories.ts

@@ -0,0 +1,107 @@
+import { FlowProps } from '../../Flow'
+import Debugger from 'debug'
+import { FixtureRunner } from '../../Fixture'
+import {
+  CategoryParams,
+  CreateCategoriesFixture,
+  CategoryStatusUpdate,
+  UpdateCategoriesStatusFixture,
+  RemoveCategoriesFixture,
+  CategoryModeratorStatusUpdate,
+  UpdateCategoryModeratorsFixture,
+} from '../../fixtures/forum'
+import { HireWorkersFixture } from '../../fixtures/workingGroups/HireWorkersFixture'
+
+export default async function categories({ api, query }: FlowProps): Promise<void> {
+  const debug = Debugger(`flow:cateogries`)
+  debug('Started')
+  api.enableDebugTxLogs()
+
+  // Create root categories
+  const categories: CategoryParams[] = [
+    { title: 'General', description: 'General stuff' },
+    { title: 'Working Groups', description: 'Working groups related discussions' },
+  ]
+
+  const createCategoriesFixture = new CreateCategoriesFixture(api, query, categories)
+  const createCategoriesRunner = new FixtureRunner(createCategoriesFixture)
+  await createCategoriesRunner.run()
+  const rootCategoryIds = createCategoriesFixture.getCreatedCategoriesIds()
+  const workingGroupsCategoryId = rootCategoryIds[1]
+
+  // Create subcategories
+  const workingGroupsSubcategories: CategoryParams[] = [
+    {
+      title: 'Forum Working Group',
+      description: 'Forum Working Group related discussions',
+      parentId: workingGroupsCategoryId,
+    },
+    {
+      title: 'Storage Working Group',
+      description: 'Storage Working Group related discussions',
+      parentId: workingGroupsCategoryId,
+    },
+    {
+      title: 'Membership Working Group',
+      description: 'Membership Working Group related discussions',
+      parentId: workingGroupsCategoryId,
+    },
+  ]
+  const createSubcategoriesFixture = new CreateCategoriesFixture(api, query, workingGroupsSubcategories)
+  const createSubcategoriesRunner = new FixtureRunner(createSubcategoriesFixture)
+  await createSubcategoriesRunner.run()
+  const subcategoryIds = createSubcategoriesFixture.getCreatedCategoriesIds()
+
+  await Promise.all([createCategoriesRunner.runQueryNodeChecks(), createSubcategoriesRunner.runQueryNodeChecks()])
+
+  // Create moderators and perform status updates
+  const createModeratorsFixture = new HireWorkersFixture(api, query, 'forumWorkingGroup', subcategoryIds.length + 1)
+  await new FixtureRunner(createModeratorsFixture).run()
+  const moderatorIds = createModeratorsFixture.getCreatedWorkerIds()
+
+  const moderatorUpdates: CategoryModeratorStatusUpdate[] = subcategoryIds.reduce(
+    (updates, categoryId, i) =>
+      (updates = updates.concat([
+        { categoryId, moderatorId: moderatorIds[i], canModerate: true },
+        { categoryId, moderatorId: moderatorIds[i + 1], canModerate: true },
+        { categoryId, moderatorId: moderatorIds[i + 1], canModerate: false },
+      ])),
+    [] as CategoryModeratorStatusUpdate[]
+  )
+  const updateCategoryModeratorsFixture = new UpdateCategoryModeratorsFixture(api, query, moderatorUpdates)
+  const updateCategoryModeratorsRunner = new FixtureRunner(updateCategoryModeratorsFixture)
+  await updateCategoryModeratorsRunner.run()
+
+  // Update archival status
+  const categoryUpdates: CategoryStatusUpdate[] = [
+    { categoryId: subcategoryIds[0], archived: true },
+    { categoryId: subcategoryIds[1], archived: true },
+    { categoryId: subcategoryIds[1], archived: false },
+  ]
+
+  const categoryUpdatesFixture = new UpdateCategoriesStatusFixture(api, query, categoryUpdates)
+  const categoryUpdatesRunner = new FixtureRunner(categoryUpdatesFixture)
+  await categoryUpdatesRunner.run()
+
+  // Run compound query node checks
+  await Promise.all([updateCategoryModeratorsFixture.runQueryNodeChecks(), categoryUpdatesRunner.runQueryNodeChecks()])
+
+  // Remove categories (make sure subcategories are removed first)
+  const removeSubcategoriesFixture = new RemoveCategoriesFixture(
+    api,
+    query,
+    subcategoryIds.map((categoryId) => ({ categoryId }))
+  )
+  const removeRootCategoriesFixture = new RemoveCategoriesFixture(
+    api,
+    query,
+    rootCategoryIds.map((categoryId) => ({ categoryId }))
+  )
+  const removeSubcategoriesRunner = new FixtureRunner(removeSubcategoriesFixture)
+  const removeRootCategoriesRunner = new FixtureRunner(removeRootCategoriesFixture)
+  await removeSubcategoriesRunner.run()
+  await removeRootCategoriesRunner.run()
+  await Promise.all([removeSubcategoriesRunner.runQueryNodeChecks(), removeRootCategoriesRunner.runQueryNodeChecks()])
+
+  debug('Done')
+}

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

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

+ 72 - 0
tests/integration-tests/src/flows/forum/polls.ts

@@ -0,0 +1,72 @@
+import { FlowProps } from '../../Flow'
+import Debugger from 'debug'
+import { FixtureRunner } from '../../Fixture'
+import {
+  CategoryParams,
+  CreateCategoriesFixture,
+  VoteParams,
+  CreateThreadsFixture,
+  ThreadParams,
+  VoteOnPollFixture,
+} from '../../fixtures/forum'
+import { BuyMembershipHappyCaseFixture } from '../../fixtures/membership'
+
+export default async function polls({ api, query }: FlowProps): Promise<void> {
+  const debug = Debugger(`flow:polls`)
+  debug('Started')
+  api.enableDebugTxLogs()
+
+  // Create test member(s)
+  const accounts = (await api.createKeyPairs(5)).map((kp) => kp.address)
+  const buyMembershipFixture = new BuyMembershipHappyCaseFixture(api, query, accounts)
+  await new FixtureRunner(buyMembershipFixture).run()
+  const memberIds = buyMembershipFixture.getCreatedMembers()
+
+  // Create some test category first
+  const categories: CategoryParams[] = [{ title: 'Polls', description: 'Testing the polls' }]
+  const createCategoriesFixture = new CreateCategoriesFixture(api, query, categories)
+  await new FixtureRunner(createCategoriesFixture).run()
+  const [categoryId] = createCategoriesFixture.getCreatedCategoriesIds()
+
+  // Create polls
+  const pollThreads: ThreadParams[] = memberIds.map((memberId, i) => ({
+    categoryId,
+    asMember: memberId,
+    title: `Poll ${i}`,
+    text: `Poll ${i} desc`,
+    poll: {
+      description: `Poll ${i} question?`,
+      alternatives: [`${i}:A`, `${i}:B`, `${i}:C`],
+      endTime: new Date(Date.now() + (i + 1) * 60 * 60 * 1000), // +(i+1) hours
+    },
+  }))
+
+  const createThreadsFixture = new CreateThreadsFixture(api, query, pollThreads)
+  const createThreadsRunner = new FixtureRunner(createThreadsFixture)
+  await createThreadsRunner.run()
+  const pollThreadIds = createThreadsFixture.getCreatedThreadsIds()
+
+  // Vote on polls
+  const votes: VoteParams[] = pollThreadIds.reduce(
+    (votesArray, threadId) =>
+      votesArray.concat(
+        memberIds.map((memberId, i) => {
+          const index = i % 3
+          return {
+            threadId,
+            categoryId,
+            asMember: memberId,
+            index,
+          }
+        })
+      ),
+    [] as VoteParams[]
+  )
+  const voteOnPollFixture = new VoteOnPollFixture(api, query, votes)
+  const voteOnPollRunner = new FixtureRunner(voteOnPollFixture)
+  await voteOnPollRunner.run()
+
+  await Promise.all([createThreadsRunner.runQueryNodeChecks(), voteOnPollRunner.runQueryNodeChecks()])
+
+  debug('Done')
+}

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

@@ -0,0 +1,199 @@
+import { FlowProps } from '../../Flow'
+import Debugger from 'debug'
+import { FixtureRunner } from '../../Fixture'
+import {
+  AddPostsFixture,
+  DeletePostsFixture,
+  InitializeForumFixture,
+  PostParams,
+  PostReactionParams,
+  PostsRemovalInput,
+  PostTextUpdate,
+  ReactToPostsFixture,
+  UpdatePostsTextFixture,
+} from '../../fixtures/forum'
+import { ForumPostReaction } from '@joystream/metadata-protobuf'
+
+export default async function threads({ api, query }: FlowProps): Promise<void> {
+  const debug = Debugger(`flow:threads`)
+  debug('Started')
+  api.enableDebugTxLogs()
+
+  const initializeForumFixture = new InitializeForumFixture(api, query, {
+    numberOfForumMembers: 5,
+    numberOfCategories: 2,
+    threadsPerCategory: 2,
+  })
+  await new FixtureRunner(initializeForumFixture).runWithQueryNodeChecks()
+
+  const memberIds = initializeForumFixture.getCreatedForumMemberIds()
+  const threadPaths = initializeForumFixture.getThreadPaths()
+
+  // Create posts
+  const posts: PostParams[] = [
+    // Valid cases:
+    {
+      ...threadPaths[0],
+      metadata: { value: { text: 'Example post' } },
+      asMember: memberIds[0],
+    },
+    {
+      ...threadPaths[1],
+      metadata: { value: { text: 'Non-editable post' } },
+      editable: false,
+      asMember: memberIds[1],
+    },
+    {
+      ...threadPaths[2],
+      metadata: { value: { text: null } },
+      asMember: memberIds[2],
+    },
+    {
+      ...threadPaths[3],
+      metadata: { value: { text: '' } },
+      asMember: memberIds[3],
+    },
+    {
+      ...threadPaths[3],
+      metadata: { value: { text: 'Second post by member 3' } },
+      asMember: memberIds[3],
+    },
+    // Invalid cases
+    {
+      ...threadPaths[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:
+    {
+      ...threadPaths[0],
+      metadata: { value: { text: 'Reply post', repliesTo: postIds[0].toNumber() } },
+      asMember: memberIds[1],
+    },
+    // Invalid reply postId case:
+    {
+      ...threadPaths[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()
+
+  // Post reactions
+  const postReactions: PostReactionParams[] = [
+    {
+      ...threadPaths[0],
+      postId: postIds[0],
+      reactionId: ForumPostReaction.Reaction.LIKE,
+      asMember: memberIds[0],
+    },
+    {
+      ...threadPaths[1],
+      postId: postIds[1],
+      reactionId: ForumPostReaction.Reaction.LIKE,
+      asMember: memberIds[1],
+    },
+    {
+      ...threadPaths[1],
+      postId: postIds[1],
+      reactionId: 0, // Cancel previous one
+      asMember: memberIds[1],
+    },
+    {
+      ...threadPaths[2],
+      postId: postIds[2],
+      reactionId: ForumPostReaction.Reaction.LIKE,
+      asMember: memberIds[2],
+    },
+    {
+      ...threadPaths[2],
+      postId: postIds[2],
+      reactionId: 999, // Cancel previous one by providing invalid id
+      asMember: memberIds[2],
+    },
+  ]
+  const reactToPostsFixture = new ReactToPostsFixture(api, query, postReactions)
+  const reactToPostsRunner = new FixtureRunner(reactToPostsFixture)
+  await reactToPostsRunner.run()
+
+  // Run compound query node checks
+  await Promise.all([
+    addPostsFixture.runQueryNodeChecks(),
+    addRepliesRunner.runQueryNodeChecks(),
+    reactToPostsRunner.runQueryNodeChecks(),
+  ])
+
+  // Post text updates
+  const postTextUpdates: PostTextUpdate[] = [
+    {
+      ...threadPaths[0],
+      postId: postIds[0],
+      newText: 'New post 0 text',
+    },
+    {
+      ...threadPaths[2],
+      postId: postIds[2],
+      newText: 'New post 2 text',
+    },
+    {
+      ...threadPaths[2],
+      postId: postIds[2],
+      newText: '',
+    },
+  ]
+  const updatePostsTextFixture = new UpdatePostsTextFixture(api, query, postTextUpdates)
+  const updatePostsTextRunner = new FixtureRunner(updatePostsTextFixture)
+  await updatePostsTextRunner.run()
+
+  const postRemovals: PostsRemovalInput[] = [
+    {
+      posts: [
+        {
+          ...threadPaths[0],
+          postId: postIds[0],
+          hide: true,
+        },
+      ],
+      asMember: memberIds[0],
+      rationale: 'Clearing first post test',
+    },
+    {
+      posts: [
+        {
+          ...threadPaths[3],
+          postId: postIds[3],
+          hide: true,
+        },
+        {
+          ...threadPaths[3],
+          postId: postIds[3],
+          hide: false,
+        },
+      ],
+      asMember: memberIds[3],
+      rationale: 'Lock+remove in one extrinsic test',
+    },
+  ]
+  const deletePostsFixture = new DeletePostsFixture(api, query, postRemovals)
+  const deletePostsRunner = new FixtureRunner(deletePostsFixture)
+  await deletePostsRunner.run()
+
+  // Run compound query node checks
+  await Promise.all([updatePostsTextRunner.runQueryNodeChecks(), deletePostsRunner.runQueryNodeChecks()])
+
+  // TODO: Delete posts as any member? Would require waiting PostLifetime (currently 3600 blocks)
+
+  debug('Done')
+}

+ 92 - 0
tests/integration-tests/src/flows/forum/threads.ts

@@ -0,0 +1,92 @@
+import { FlowProps } from '../../Flow'
+import Debugger from 'debug'
+import { FixtureRunner } from '../../Fixture'
+import {
+  DeleteThreadsFixture,
+  InitializeForumFixture,
+  MoveThreadParams,
+  MoveThreadsFixture,
+  SetStickyThreadsFixture,
+  StickyThreadsParams,
+  ThreadRemovalInput,
+  ThreadTitleUpdate,
+  UpdateThreadTitlesFixture,
+} from '../../fixtures/forum'
+import { CategoryId } from '@joystream/types/forum'
+
+export default async function threads({ api, query }: FlowProps): Promise<void> {
+  const debug = Debugger(`flow:threads`)
+  debug('Started')
+  api.enableDebugTxLogs()
+
+  // Initialize categories and threads
+  const initializeForumFixture = new InitializeForumFixture(api, query, {
+    numberOfForumMembers: 5,
+    numberOfCategories: 3,
+    threadsPerCategory: 3,
+  })
+  await new FixtureRunner(initializeForumFixture).runWithQueryNodeChecks()
+
+  const categoryIds = initializeForumFixture.getCreatedCategoryIds()
+
+  // Set threads as sticky (2 per category)
+  let stickyThreadsParams: StickyThreadsParams[] = []
+  categoryIds.forEach((categoryId) => {
+    const threadIds = initializeForumFixture.getCreatedThreadsByCategoryId(categoryId)
+    stickyThreadsParams = stickyThreadsParams.concat([
+      { categoryId, stickyTreads: [threadIds[0], threadIds[1]] },
+      { categoryId, stickyTreads: [threadIds[1], threadIds[2]] },
+    ])
+  })
+
+  const setStickyThreadsFixture = new SetStickyThreadsFixture(api, query, stickyThreadsParams)
+  const setStickyThreadsRunner = new FixtureRunner(setStickyThreadsFixture)
+  await setStickyThreadsRunner.run()
+
+  // Update titles
+  let titleUpdates: ThreadTitleUpdate[] = []
+  initializeForumFixture.getThreadPaths().forEach(
+    (threadPath, i) =>
+      (titleUpdates = titleUpdates.concat([
+        { ...threadPath, newTitle: '' },
+        { ...threadPath, newTitle: `Test updated title ${i}` },
+      ]))
+  )
+
+  const updateThreadTitlesFixture = new UpdateThreadTitlesFixture(api, query, titleUpdates)
+  const updateThreadTitlesRunner = new FixtureRunner(updateThreadTitlesFixture)
+  await updateThreadTitlesRunner.run()
+
+  // Run compound checks
+  await Promise.all([setStickyThreadsRunner.runQueryNodeChecks(), updateThreadTitlesRunner.runQueryNodeChecks()])
+
+  // Move threads to different categories
+  const newThreadCategory = (oldCategory: CategoryId) =>
+    categoryIds[(categoryIds.indexOf(oldCategory) + 1) % categoryIds.length]
+  const threadCategoryUpdates: MoveThreadParams[] = initializeForumFixture.getThreadPaths().map((threadPath) => ({
+    ...threadPath,
+    newCategoryId: newThreadCategory(threadPath.categoryId),
+  }))
+
+  const moveThreadsFixture = new MoveThreadsFixture(api, query, threadCategoryUpdates)
+  const moveThreadsRunner = new FixtureRunner(moveThreadsFixture)
+  await moveThreadsRunner.run()
+
+  // Remove threads
+  // TODO: Should removing / moving threads also "unstick" them?
+  const threadRemovals: ThreadRemovalInput[] = initializeForumFixture
+    .getThreadPaths()
+    .map(({ categoryId, threadId }, i) => ({
+      threadId,
+      categoryId: newThreadCategory(categoryId),
+      hide: !!(i % 2), // Test both cases
+    }))
+  const removeThreadsFixture = new DeleteThreadsFixture(api, query, threadRemovals)
+  const removeThreadsRunner = new FixtureRunner(removeThreadsFixture)
+  await removeThreadsRunner.run()
+
+  // Run compound query node checks
+  await Promise.all([moveThreadsRunner.runQueryNodeChecks(), removeThreadsRunner.runQueryNodeChecks()])
+
+  debug('Done')
+}

+ 972 - 18
tests/integration-tests/src/graphql/generated/queries.ts

@@ -1,6 +1,382 @@
 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; isSticky: boolean }>
+  moderators: Array<{ id: string }>
+  createdInEvent: { id: string }
+  status:
+    | { __typename: 'CategoryStatusActive' }
+    | { __typename: 'CategoryStatusArchived'; categoryUpdatedEvent?: Types.Maybe<{ id: string }> }
+    | { __typename: 'CategoryStatusRemoved'; categoryDeletedEvent?: Types.Maybe<{ id: string }> }
+}
+
+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<ForumPostFieldsFragment>
+  poll?: Types.Maybe<{
+    description: string
+    endTime: any
+    pollAlternatives: Array<{ index: number; text: string; votes: Array<{ votingMember: { id: string } }> }>
+  }>
+  createdInEvent: { id: string }
+  status:
+    | { __typename: 'ThreadStatusActive' }
+    | { __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
+  inBlock: number
+  network: Types.Network
+  inExtrinsic?: Types.Maybe<string>
+  indexInBlock: number
+  category: { id: string }
+}
+
+export type GetCategoryCreatedEventsByEventIdsQueryVariables = Types.Exact<{
+  eventIds?: Types.Maybe<Array<Types.Scalars['ID']> | Types.Scalars['ID']>
+}>
+
+export type GetCategoryCreatedEventsByEventIdsQuery = {
+  categoryCreatedEvents: Array<CategoryCreatedEventFieldsFragment>
+}
+
+export type CategoryUpdatedEventFieldsFragment = {
+  id: string
+  createdAt: any
+  inBlock: number
+  network: Types.Network
+  inExtrinsic?: Types.Maybe<string>
+  indexInBlock: number
+  newArchivalStatus: boolean
+  category: { id: string }
+  actor: { id: string }
+}
+
+export type GetCategoryUpdatedEventsByEventIdsQueryVariables = Types.Exact<{
+  eventIds?: Types.Maybe<Array<Types.Scalars['ID']> | Types.Scalars['ID']>
+}>
+
+export type GetCategoryUpdatedEventsByEventIdsQuery = {
+  categoryUpdatedEvents: Array<CategoryUpdatedEventFieldsFragment>
+}
+
+export type CategoryDeletedEventFieldsFragment = {
+  id: string
+  createdAt: any
+  inBlock: number
+  network: Types.Network
+  inExtrinsic?: Types.Maybe<string>
+  indexInBlock: number
+  category: { id: string }
+  actor: { id: string }
+}
+
+export type GetCategoryDeletedEventsByEventIdsQueryVariables = Types.Exact<{
+  eventIds?: Types.Maybe<Array<Types.Scalars['ID']> | Types.Scalars['ID']>
+}>
+
+export type GetCategoryDeletedEventsByEventIdsQuery = {
+  categoryDeletedEvents: Array<CategoryDeletedEventFieldsFragment>
+}
+
+export type ThreadCreatedEventFieldsFragment = {
+  id: string
+  createdAt: any
+  inBlock: number
+  network: Types.Network
+  inExtrinsic?: Types.Maybe<string>
+  indexInBlock: number
+  thread: { id: string }
+}
+
+export type GetThreadCreatedEventsByEventIdsQueryVariables = Types.Exact<{
+  eventIds?: Types.Maybe<Array<Types.Scalars['ID']> | Types.Scalars['ID']>
+}>
+
+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
+  inBlock: number
+  network: Types.Network
+  inExtrinsic?: Types.Maybe<string>
+  indexInBlock: number
+  pollAlternative: { id: string; index: number; text: string; poll: { thread: { id: string } } }
+  votingMember: { id: string }
+}
+
+export type GetVoteOnPollEventsByEventIdsQueryVariables = Types.Exact<{
+  eventIds?: Types.Maybe<Array<Types.Scalars['ID']> | Types.Scalars['ID']>
+}>
+
+export type GetVoteOnPollEventsByEventIdsQuery = { voteOnPollEvents: Array<VoteOnPollEventFieldsFragment> }
+
+export type ThreadDeletedEventFieldsFragment = {
+  id: string
+  createdAt: any
+  inBlock: number
+  network: Types.Network
+  inExtrinsic?: Types.Maybe<string>
+  indexInBlock: number
+  thread: { id: string }
+}
+
+export type GetThreadDeletedEventsByEventIdsQueryVariables = Types.Exact<{
+  eventIds?: Types.Maybe<Array<Types.Scalars['ID']> | Types.Scalars['ID']>
+}>
+
+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 CategoryStickyThreadUpdateEventFieldsFragment = {
+  id: string
+  createdAt: any
+  inBlock: number
+  network: Types.Network
+  inExtrinsic?: Types.Maybe<string>
+  indexInBlock: number
+  category: { id: string }
+  newStickyThreads: Array<{ id: string }>
+  actor: { id: string }
+}
+
+export type GetCategoryStickyThreadUpdateEventsByEventIdsQueryVariables = Types.Exact<{
+  eventIds?: Types.Maybe<Array<Types.Scalars['ID']> | Types.Scalars['ID']>
+}>
+
+export type GetCategoryStickyThreadUpdateEventsByEventIdsQuery = {
+  categoryStickyThreadUpdateEvents: Array<CategoryStickyThreadUpdateEventFieldsFragment>
+}
+
+export type CategoryMembershipOfModeratorUpdatedEventFieldsFragment = {
+  id: string
+  createdAt: any
+  inBlock: number
+  network: Types.Network
+  inExtrinsic?: Types.Maybe<string>
+  indexInBlock: number
+  newCanModerateValue: boolean
+  category: { id: string }
+  moderator: { id: string }
+}
+
+export type GetCategoryMembershipOfModeratorUpdatedEventsByEventIdsQueryVariables = Types.Exact<{
+  eventIds?: Types.Maybe<Array<Types.Scalars['ID']> | Types.Scalars['ID']>
+}>
+
+export type GetCategoryMembershipOfModeratorUpdatedEventsByEventIdsQuery = {
+  categoryMembershipOfModeratorUpdatedEvents: Array<CategoryMembershipOfModeratorUpdatedEventFieldsFragment>
+}
+
+export type ThreadModeratedEventFieldsFragment = {
+  id: string
+  createdAt: any
+  inBlock: number
+  network: Types.Network
+  inExtrinsic?: Types.Maybe<string>
+  indexInBlock: number
+  rationale: string
+  thread: { id: string }
+  actor: { id: string }
+}
+
+export type GetThreadModeratedEventsByEventIdsQueryVariables = Types.Exact<{
+  eventIds?: Types.Maybe<Array<Types.Scalars['ID']> | Types.Scalars['ID']>
+}>
+
+export type GetThreadModeratedEventsByEventIdsQuery = {
+  threadModeratedEvents: Array<ThreadModeratedEventFieldsFragment>
+}
+
+export type PostModeratedEventFieldsFragment = {
+  id: string
+  createdAt: any
+  inBlock: number
+  network: Types.Network
+  inExtrinsic?: Types.Maybe<string>
+  indexInBlock: number
+  rationale: string
+  post: { id: string }
+  actor: { id: string }
+}
+
+export type GetPostModeratedEventsByEventIdsQueryVariables = Types.Exact<{
+  eventIds?: Types.Maybe<Array<Types.Scalars['ID']> | Types.Scalars['ID']>
+}>
+
+export type GetPostModeratedEventsByEventIdsQuery = { postModeratedEvents: Array<PostModeratedEventFieldsFragment> }
+
+export type PostReactedEventFieldsFragment = {
+  id: string
+  createdAt: any
+  inBlock: number
+  network: Types.Network
+  inExtrinsic?: Types.Maybe<string>
+  indexInBlock: number
+  post: { id: string }
+  reactionResult:
+    | { __typename: 'PostReactionResultCancel' }
+    | { __typename: 'PostReactionResultValid'; reaction: Types.PostReaction; reactionId: number }
+    | { __typename: 'PostReactionResultInvalid'; reactionId: number }
+  reactingMember: { id: string }
+}
+
+export type GetPostReactedEventsByEventIdsQueryVariables = Types.Exact<{
+  eventIds?: Types.Maybe<Array<Types.Scalars['ID']> | Types.Scalars['ID']>
+}>
+
+export type GetPostReactedEventsByEventIdsQuery = { postReactedEvents: Array<PostReactedEventFieldsFragment> }
+
+export type PostTextUpdatedEventFieldsFragment = {
+  id: string
+  createdAt: any
+  inBlock: number
+  network: Types.Network
+  inExtrinsic?: Types.Maybe<string>
+  indexInBlock: number
+  newText: string
+  post: { id: string }
+}
+
+export type GetPostTextUpdatedEventsByEventIdsQueryVariables = Types.Exact<{
+  eventIds?: Types.Maybe<Array<Types.Scalars['ID']> | Types.Scalars['ID']>
+}>
+
+export type GetPostTextUpdatedEventsByEventIdsQuery = {
+  postTextUpdatedEvents: Array<PostTextUpdatedEventFieldsFragment>
+}
+
+export type PostDeletedEventFieldsFragment = {
+  id: string
+  createdAt: any
+  inBlock: number
+  network: Types.Network
+  inExtrinsic?: Types.Maybe<string>
+  indexInBlock: number
+  rationale: string
+  posts: Array<{ id: string }>
+  actor: { id: string }
+}
+
+export type GetPostDeletedEventsByEventIdsQueryVariables = Types.Exact<{
+  eventIds?: Types.Maybe<Array<Types.Scalars['ID']> | Types.Scalars['ID']>
+}>
+
+export type GetPostDeletedEventsByEventIdsQuery = { postDeletedEvents: Array<PostDeletedEventFieldsFragment> }
+
 export type MemberMetadataFieldsFragment = { name?: Types.Maybe<string>; about?: Types.Maybe<string> }
 
 export type MembershipFieldsFragment = {
@@ -1319,26 +1695,452 @@ export type GetBudgetSpendingEventsByEventIdsQueryVariables = Types.Exact<{
 
 export type GetBudgetSpendingEventsByEventIdsQuery = { budgetSpendingEvents: Array<BudgetSpendingEventFieldsFragment> }
 
-export const MemberMetadataFields = gql`
-  fragment MemberMetadataFields on MemberMetadata {
-    name
-    about
-  }
-`
-export const MembershipFields = gql`
-  fragment MembershipFields on Membership {
+export const ForumCategoryFields = gql`
+  fragment ForumCategoryFields on ForumCategory {
     id
-    handle
-    metadata {
-      ...MemberMetadataFields
+    createdAt
+    updatedAt
+    parent {
+      id
     }
-    controllerAccount
-    rootAccount
-    entry {
-      __typename
-      ... on MembershipEntryPaid {
-        membershipBoughtEvent {
-          id
+    title
+    description
+    threads {
+      id
+      isSticky
+    }
+    moderators {
+      id
+    }
+    createdInEvent {
+      id
+    }
+    status {
+      __typename
+      ... on CategoryStatusArchived {
+        categoryUpdatedEvent {
+          id
+        }
+      }
+      ... on CategoryStatusRemoved {
+        categoryDeletedEvent {
+          id
+        }
+      }
+    }
+  }
+`
+export const ForumPostFields = gql`
+  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
+    }
+  }
+`
+export const ForumThreadWithPostsFields = gql`
+  fragment ForumThreadWithPostsFields on ForumThread {
+    id
+    createdAt
+    updatedAt
+    author {
+      id
+    }
+    category {
+      id
+    }
+    title
+    posts {
+      ...ForumPostFields
+    }
+    poll {
+      description
+      endTime
+      pollAlternatives {
+        index
+        text
+        votes {
+          votingMember {
+            id
+          }
+        }
+      }
+    }
+    isSticky
+    createdInEvent {
+      id
+    }
+    status {
+      __typename
+      ... on ThreadStatusLocked {
+        threadDeletedEvent {
+          id
+        }
+      }
+      ... on ThreadStatusModerated {
+        threadModeratedEvent {
+          id
+        }
+      }
+      ... on ThreadStatusRemoved {
+        threadDeletedEvent {
+          id
+        }
+      }
+    }
+    titleUpdates {
+      id
+    }
+    movedInEvents {
+      id
+    }
+  }
+  ${ForumPostFields}
+`
+export const CategoryCreatedEventFields = gql`
+  fragment CategoryCreatedEventFields on CategoryCreatedEvent {
+    id
+    createdAt
+    inBlock
+    network
+    inExtrinsic
+    indexInBlock
+    category {
+      id
+    }
+  }
+`
+export const CategoryUpdatedEventFields = gql`
+  fragment CategoryUpdatedEventFields on CategoryUpdatedEvent {
+    id
+    createdAt
+    inBlock
+    network
+    inExtrinsic
+    indexInBlock
+    category {
+      id
+    }
+    newArchivalStatus
+    actor {
+      id
+    }
+  }
+`
+export const CategoryDeletedEventFields = gql`
+  fragment CategoryDeletedEventFields on CategoryDeletedEvent {
+    id
+    createdAt
+    inBlock
+    network
+    inExtrinsic
+    indexInBlock
+    category {
+      id
+    }
+    actor {
+      id
+    }
+  }
+`
+export const ThreadCreatedEventFields = gql`
+  fragment ThreadCreatedEventFields on ThreadCreatedEvent {
+    id
+    createdAt
+    inBlock
+    network
+    inExtrinsic
+    indexInBlock
+    thread {
+      id
+    }
+  }
+`
+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
+    createdAt
+    inBlock
+    network
+    inExtrinsic
+    indexInBlock
+    pollAlternative {
+      id
+      index
+      text
+      poll {
+        thread {
+          id
+        }
+      }
+    }
+    votingMember {
+      id
+    }
+  }
+`
+export const ThreadDeletedEventFields = gql`
+  fragment ThreadDeletedEventFields on ThreadDeletedEvent {
+    id
+    createdAt
+    inBlock
+    network
+    inExtrinsic
+    indexInBlock
+    thread {
+      id
+    }
+  }
+`
+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 CategoryStickyThreadUpdateEventFields = gql`
+  fragment CategoryStickyThreadUpdateEventFields on CategoryStickyThreadUpdateEvent {
+    id
+    createdAt
+    inBlock
+    network
+    inExtrinsic
+    indexInBlock
+    category {
+      id
+    }
+    newStickyThreads {
+      id
+    }
+    actor {
+      id
+    }
+  }
+`
+export const CategoryMembershipOfModeratorUpdatedEventFields = gql`
+  fragment CategoryMembershipOfModeratorUpdatedEventFields on CategoryMembershipOfModeratorUpdatedEvent {
+    id
+    createdAt
+    inBlock
+    network
+    inExtrinsic
+    indexInBlock
+    category {
+      id
+    }
+    moderator {
+      id
+    }
+    newCanModerateValue
+  }
+`
+export const ThreadModeratedEventFields = gql`
+  fragment ThreadModeratedEventFields on ThreadModeratedEvent {
+    id
+    createdAt
+    inBlock
+    network
+    inExtrinsic
+    indexInBlock
+    thread {
+      id
+    }
+    rationale
+    actor {
+      id
+    }
+  }
+`
+export const PostModeratedEventFields = gql`
+  fragment PostModeratedEventFields on PostModeratedEvent {
+    id
+    createdAt
+    inBlock
+    network
+    inExtrinsic
+    indexInBlock
+    post {
+      id
+    }
+    rationale
+    actor {
+      id
+    }
+  }
+`
+export const PostReactedEventFields = gql`
+  fragment PostReactedEventFields on PostReactedEvent {
+    id
+    createdAt
+    inBlock
+    network
+    inExtrinsic
+    indexInBlock
+    post {
+      id
+    }
+    reactionResult {
+      __typename
+      ... on PostReactionResultValid {
+        reaction
+        reactionId
+      }
+      ... on PostReactionResultInvalid {
+        reactionId
+      }
+    }
+    reactingMember {
+      id
+    }
+  }
+`
+export const PostTextUpdatedEventFields = gql`
+  fragment PostTextUpdatedEventFields on PostTextUpdatedEvent {
+    id
+    createdAt
+    inBlock
+    network
+    inExtrinsic
+    indexInBlock
+    post {
+      id
+    }
+    newText
+  }
+`
+export const PostDeletedEventFields = gql`
+  fragment PostDeletedEventFields on PostDeletedEvent {
+    id
+    createdAt
+    inBlock
+    network
+    inExtrinsic
+    indexInBlock
+    posts {
+      id
+    }
+    actor {
+      id
+    }
+    rationale
+  }
+`
+export const MemberMetadataFields = gql`
+  fragment MemberMetadataFields on MemberMetadata {
+    name
+    about
+  }
+`
+export const MembershipFields = gql`
+  fragment MembershipFields on Membership {
+    id
+    handle
+    metadata {
+      ...MemberMetadataFields
+    }
+    controllerAccount
+    rootAccount
+    entry {
+      __typename
+      ... on MembershipEntryPaid {
+        membershipBoughtEvent {
+          id
         }
       }
       ... on MembershipEntryInvited {
@@ -2439,6 +3241,158 @@ export const BudgetSpendingEventFields = gql`
     rationale
   }
 `
+export const GetCategoriesByIds = gql`
+  query getCategoriesByIds($ids: [ID!]) {
+    forumCategories(where: { id_in: $ids }) {
+      ...ForumCategoryFields
+    }
+  }
+  ${ForumCategoryFields}
+`
+export const GetThreadsWithPostsByIds = gql`
+  query getThreadsWithPostsByIds($ids: [ID!]) {
+    forumThreads(where: { id_in: $ids }) {
+      ...ForumThreadWithPostsFields
+    }
+  }
+  ${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 }) {
+      ...CategoryCreatedEventFields
+    }
+  }
+  ${CategoryCreatedEventFields}
+`
+export const GetCategoryUpdatedEventsByEventIds = gql`
+  query getCategoryUpdatedEventsByEventIds($eventIds: [ID!]) {
+    categoryUpdatedEvents(where: { id_in: $eventIds }) {
+      ...CategoryUpdatedEventFields
+    }
+  }
+  ${CategoryUpdatedEventFields}
+`
+export const GetCategoryDeletedEventsByEventIds = gql`
+  query getCategoryDeletedEventsByEventIds($eventIds: [ID!]) {
+    categoryDeletedEvents(where: { id_in: $eventIds }) {
+      ...CategoryDeletedEventFields
+    }
+  }
+  ${CategoryDeletedEventFields}
+`
+export const GetThreadCreatedEventsByEventIds = gql`
+  query getThreadCreatedEventsByEventIds($eventIds: [ID!]) {
+    threadCreatedEvents(where: { id_in: $eventIds }) {
+      ...ThreadCreatedEventFields
+    }
+  }
+  ${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 }) {
+      ...VoteOnPollEventFields
+    }
+  }
+  ${VoteOnPollEventFields}
+`
+export const GetThreadDeletedEventsByEventIds = gql`
+  query getThreadDeletedEventsByEventIds($eventIds: [ID!]) {
+    threadDeletedEvents(where: { id_in: $eventIds }) {
+      ...ThreadDeletedEventFields
+    }
+  }
+  ${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 GetCategoryStickyThreadUpdateEventsByEventIds = gql`
+  query getCategoryStickyThreadUpdateEventsByEventIds($eventIds: [ID!]) {
+    categoryStickyThreadUpdateEvents(where: { id_in: $eventIds }) {
+      ...CategoryStickyThreadUpdateEventFields
+    }
+  }
+  ${CategoryStickyThreadUpdateEventFields}
+`
+export const GetCategoryMembershipOfModeratorUpdatedEventsByEventIds = gql`
+  query getCategoryMembershipOfModeratorUpdatedEventsByEventIds($eventIds: [ID!]) {
+    categoryMembershipOfModeratorUpdatedEvents(where: { id_in: $eventIds }) {
+      ...CategoryMembershipOfModeratorUpdatedEventFields
+    }
+  }
+  ${CategoryMembershipOfModeratorUpdatedEventFields}
+`
+export const GetThreadModeratedEventsByEventIds = gql`
+  query getThreadModeratedEventsByEventIds($eventIds: [ID!]) {
+    threadModeratedEvents(where: { id_in: $eventIds }) {
+      ...ThreadModeratedEventFields
+    }
+  }
+  ${ThreadModeratedEventFields}
+`
+export const GetPostModeratedEventsByEventIds = gql`
+  query getPostModeratedEventsByEventIds($eventIds: [ID!]) {
+    postModeratedEvents(where: { id_in: $eventIds }) {
+      ...PostModeratedEventFields
+    }
+  }
+  ${PostModeratedEventFields}
+`
+export const GetPostReactedEventsByEventIds = gql`
+  query getPostReactedEventsByEventIds($eventIds: [ID!]) {
+    postReactedEvents(where: { id_in: $eventIds }) {
+      ...PostReactedEventFields
+    }
+  }
+  ${PostReactedEventFields}
+`
+export const GetPostTextUpdatedEventsByEventIds = gql`
+  query getPostTextUpdatedEventsByEventIds($eventIds: [ID!]) {
+    postTextUpdatedEvents(where: { id_in: $eventIds }) {
+      ...PostTextUpdatedEventFields
+    }
+  }
+  ${PostTextUpdatedEventFields}
+`
+export const GetPostDeletedEventsByEventIds = gql`
+  query getPostDeletedEventsByEventIds($eventIds: [ID!]) {
+    postDeletedEvents(where: { id_in: $eventIds }) {
+      ...PostDeletedEventFields
+    }
+  }
+  ${PostDeletedEventFields}
+`
 export const GetMemberById = gql`
   query getMemberById($id: ID!) {
     membershipByUniqueInput(where: { id: $id }) {

File diff suppressed because it is too large
+ 574 - 293
tests/integration-tests/src/graphql/generated/schema.ts


+ 166 - 0
tests/integration-tests/src/graphql/queries/forum.graphql

@@ -0,0 +1,166 @@
+fragment ForumCategoryFields on ForumCategory {
+  id
+  createdAt
+  updatedAt
+  parent {
+    id
+  }
+  title
+  description
+  threads {
+    id
+    isSticky
+  }
+  moderators {
+    id
+  }
+  createdInEvent {
+    id
+  }
+  status {
+    __typename
+    ... on CategoryStatusArchived {
+      categoryUpdatedEvent {
+        id
+      }
+    }
+    ... on CategoryStatusRemoved {
+      categoryDeletedEvent {
+        id
+      }
+    }
+  }
+}
+
+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
+  }
+  category {
+    id
+  }
+  title
+  posts {
+    ...ForumPostFields
+  }
+  poll {
+    description
+    endTime
+    pollAlternatives {
+      index
+      text
+      votes {
+        votingMember {
+          id
+        }
+      }
+    }
+  }
+  isSticky
+  createdInEvent {
+    id
+  }
+  status {
+    __typename
+    ... on ThreadStatusLocked {
+      threadDeletedEvent {
+        id
+      }
+    }
+    ... on ThreadStatusModerated {
+      threadModeratedEvent {
+        id
+      }
+    }
+    ... on ThreadStatusRemoved {
+      threadDeletedEvent {
+        id
+      }
+    }
+  }
+  titleUpdates {
+    id
+  }
+  movedInEvents {
+    id
+  }
+}
+
+query getCategoriesByIds($ids: [ID!]) {
+  forumCategories(where: { id_in: $ids }) {
+    ...ForumCategoryFields
+  }
+}
+
+query getThreadsWithPostsByIds($ids: [ID!]) {
+  forumThreads(where: { id_in: $ids }) {
+    ...ForumThreadWithPostsFields
+  }
+}
+
+query getPostsByIds($ids: [ID!]) {
+  forumPosts(where: { id_in: $ids }) {
+    ...ForumPostFields
+  }
+}

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

@@ -0,0 +1,352 @@
+fragment CategoryCreatedEventFields on CategoryCreatedEvent {
+  id
+  createdAt
+  inBlock
+  network
+  inExtrinsic
+  indexInBlock
+  category {
+    id
+  }
+}
+
+query getCategoryCreatedEventsByEventIds($eventIds: [ID!]) {
+  categoryCreatedEvents(where: { id_in: $eventIds }) {
+    ...CategoryCreatedEventFields
+  }
+}
+
+fragment CategoryUpdatedEventFields on CategoryUpdatedEvent {
+  id
+  createdAt
+  inBlock
+  network
+  inExtrinsic
+  indexInBlock
+  category {
+    id
+  }
+  newArchivalStatus
+  actor {
+    id
+  }
+}
+
+query getCategoryUpdatedEventsByEventIds($eventIds: [ID!]) {
+  categoryUpdatedEvents(where: { id_in: $eventIds }) {
+    ...CategoryUpdatedEventFields
+  }
+}
+
+fragment CategoryDeletedEventFields on CategoryDeletedEvent {
+  id
+  createdAt
+  inBlock
+  network
+  inExtrinsic
+  indexInBlock
+  category {
+    id
+  }
+  actor {
+    id
+  }
+}
+
+query getCategoryDeletedEventsByEventIds($eventIds: [ID!]) {
+  categoryDeletedEvents(where: { id_in: $eventIds }) {
+    ...CategoryDeletedEventFields
+  }
+}
+
+fragment ThreadCreatedEventFields on ThreadCreatedEvent {
+  id
+  createdAt
+  inBlock
+  network
+  inExtrinsic
+  indexInBlock
+  thread {
+    id
+  }
+}
+
+query getThreadCreatedEventsByEventIds($eventIds: [ID!]) {
+  threadCreatedEvents(where: { id_in: $eventIds }) {
+    ...ThreadCreatedEventFields
+  }
+}
+
+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
+  inBlock
+  network
+  inExtrinsic
+  indexInBlock
+  pollAlternative {
+    id
+    index
+    text
+    poll {
+      thread {
+        id
+      }
+    }
+  }
+  votingMember {
+    id
+  }
+}
+
+query getVoteOnPollEventsByEventIds($eventIds: [ID!]) {
+  voteOnPollEvents(where: { id_in: $eventIds }) {
+    ...VoteOnPollEventFields
+  }
+}
+
+fragment ThreadDeletedEventFields on ThreadDeletedEvent {
+  id
+  createdAt
+  inBlock
+  network
+  inExtrinsic
+  indexInBlock
+  thread {
+    id
+  }
+}
+
+query getThreadDeletedEventsByEventIds($eventIds: [ID!]) {
+  threadDeletedEvents(where: { id_in: $eventIds }) {
+    ...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
+  }
+}
+
+fragment CategoryStickyThreadUpdateEventFields on CategoryStickyThreadUpdateEvent {
+  id
+  createdAt
+  inBlock
+  network
+  inExtrinsic
+  indexInBlock
+  category {
+    id
+  }
+  newStickyThreads {
+    id
+  }
+  actor {
+    id
+  }
+}
+
+query getCategoryStickyThreadUpdateEventsByEventIds($eventIds: [ID!]) {
+  categoryStickyThreadUpdateEvents(where: { id_in: $eventIds }) {
+    ...CategoryStickyThreadUpdateEventFields
+  }
+}
+
+fragment CategoryMembershipOfModeratorUpdatedEventFields on CategoryMembershipOfModeratorUpdatedEvent {
+  id
+  createdAt
+  inBlock
+  network
+  inExtrinsic
+  indexInBlock
+  category {
+    id
+  }
+  moderator {
+    id
+  }
+  newCanModerateValue
+}
+
+query getCategoryMembershipOfModeratorUpdatedEventsByEventIds($eventIds: [ID!]) {
+  categoryMembershipOfModeratorUpdatedEvents(where: { id_in: $eventIds }) {
+    ...CategoryMembershipOfModeratorUpdatedEventFields
+  }
+}
+
+fragment ThreadModeratedEventFields on ThreadModeratedEvent {
+  id
+  createdAt
+  inBlock
+  network
+  inExtrinsic
+  indexInBlock
+  thread {
+    id
+  }
+  rationale
+  actor {
+    id
+  }
+}
+
+query getThreadModeratedEventsByEventIds($eventIds: [ID!]) {
+  threadModeratedEvents(where: { id_in: $eventIds }) {
+    ...ThreadModeratedEventFields
+  }
+}
+
+fragment PostModeratedEventFields on PostModeratedEvent {
+  id
+  createdAt
+  inBlock
+  network
+  inExtrinsic
+  indexInBlock
+  post {
+    id
+  }
+  rationale
+  actor {
+    id
+  }
+}
+
+query getPostModeratedEventsByEventIds($eventIds: [ID!]) {
+  postModeratedEvents(where: { id_in: $eventIds }) {
+    ...PostModeratedEventFields
+  }
+}
+
+fragment PostReactedEventFields on PostReactedEvent {
+  id
+  createdAt
+  inBlock
+  network
+  inExtrinsic
+  indexInBlock
+  post {
+    id
+  }
+  reactionResult {
+    __typename
+    ... on PostReactionResultValid {
+      reaction
+      reactionId
+    }
+    ... on PostReactionResultInvalid {
+      reactionId
+    }
+  }
+  reactingMember {
+    id
+  }
+}
+
+query getPostReactedEventsByEventIds($eventIds: [ID!]) {
+  postReactedEvents(where: { id_in: $eventIds }) {
+    ...PostReactedEventFields
+  }
+}
+
+fragment PostTextUpdatedEventFields on PostTextUpdatedEvent {
+  id
+  createdAt
+  inBlock
+  network
+  inExtrinsic
+  indexInBlock
+  post {
+    id
+  }
+  newText
+}
+
+query getPostTextUpdatedEventsByEventIds($eventIds: [ID!]) {
+  postTextUpdatedEvents(where: { id_in: $eventIds }) {
+    ...PostTextUpdatedEventFields
+  }
+}
+
+fragment PostDeletedEventFields on PostDeletedEvent {
+  id
+  createdAt
+  inBlock
+  network
+  inExtrinsic
+  indexInBlock
+  posts {
+    id
+  }
+  actor {
+    id
+  }
+  rationale
+}
+
+query getPostDeletedEventsByEventIds($eventIds: [ID!]) {
+  postDeletedEvents(where: { id_in: $eventIds }) {
+    ...PostDeletedEventFields
+  }
+}

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

@@ -0,0 +1,16 @@
+import categories from '../flows/forum/categories'
+import polls from '../flows/forum/polls'
+import threads from '../flows/forum/threads'
+import posts from '../flows/forum/posts'
+import moderation from '../flows/forum/moderation'
+import leadOpening from '../flows/working-groups/leadOpening'
+import { scenario } from '../Scenario'
+
+scenario(async ({ job }) => {
+  const sudoHireLead = job('hiring working group leads', leadOpening)
+  job('forum categories', categories).requires(sudoHireLead)
+  job('forum threads', threads).requires(sudoHireLead)
+  job('forum polls', polls).requires(sudoHireLead)
+  job('forum posts', posts).requires(sudoHireLead)
+  job('forum moderation', moderation).requires(sudoHireLead)
+})

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

@@ -1,3 +1,9 @@
+import categories from '../flows/forum/categories'
+import polls from '../flows/forum/polls'
+import threads from '../flows/forum/threads'
+import posts from '../flows/forum/posts'
+import moderation from '../flows/forum/moderation'
+import leadOpening from '../flows/working-groups/leadOpening'
 import creatingMemberships from '../flows/membership/creatingMemberships'
 import updatingMemberProfile from '../flows/membership/updatingProfile'
 import updatingMemberAccounts from '../flows/membership/updatingAccounts'
@@ -5,7 +11,6 @@ import invitingMebers from '../flows/membership/invitingMembers'
 import transferringInvites from '../flows/membership/transferringInvites'
 import managingStakingAccounts from '../flows/membership/managingStakingAccounts'
 import membershipSystem from '../flows/membership/membershipSystem'
-import leadOpening from '../flows/working-groups/leadOpening'
 import openingsAndApplications from '../flows/working-groups/openingsAndApplications'
 import upcomingOpenings from '../flows/working-groups/upcomingOpenings'
 import groupStatus from '../flows/working-groups/groupStatus'
@@ -18,6 +23,7 @@ import electCouncil from '../flows/council/elect'
 import { scenario } from '../Scenario'
 
 scenario(async ({ job }) => {
+  // Membership:
   const membershipSystemJob = job('membership system', membershipSystem)
   // All other membership jobs should be executed after membershipSystemJob,
   // otherwise changing membershipPrice etc. may break them
@@ -28,13 +34,22 @@ scenario(async ({ job }) => {
   job('transferring invites', transferringInvites).after(membershipSystemJob)
   job('managing staking accounts', managingStakingAccounts).after(membershipSystemJob)
 
+  // Proposals:
   const councilJob = job('electing council', electCouncil)
   const proposalsJob = job('proposals', [proposals, cancellingProposals, vetoProposal]).requires(councilJob)
 
+  // Working groups:
   const sudoHireLead = job('sudo lead opening', leadOpening).after(proposalsJob)
   job('openings and applications', openingsAndApplications).requires(sudoHireLead)
   job('upcoming openings', upcomingOpenings).requires(sudoHireLead)
   job('group status', groupStatus).requires(sudoHireLead)
   job('worker actions', workerActions).requires(sudoHireLead)
   job('group budget', groupBudget).requires(sudoHireLead)
+
+  // Forum:
+  job('forum categories', categories).requires(sudoHireLead)
+  job('forum threads', threads).requires(sudoHireLead)
+  job('forum polls', polls).requires(sudoHireLead)
+  job('forum posts', posts).requires(sudoHireLead)
+  job('forum moderation', moderation).requires(sudoHireLead)
 })

+ 59 - 7
tests/integration-tests/src/types.ts

@@ -1,21 +1,16 @@
-import { MemberId } from '@joystream/types/common'
+import { MemberId, PostId, ThreadId } from '@joystream/types/common'
 import { ApplicationId, OpeningId, WorkerId, ApplyOnOpeningParameters } from '@joystream/types/working-group'
 import { Event } from '@polkadot/types/interfaces/system'
 import { BTreeMap } from '@polkadot/types'
+import { CategoryId } from '@joystream/types/forum'
 import { MembershipBoughtEvent } from './graphql/generated/schema'
 import { ProposalDetails, ProposalId } from '@joystream/types/proposals'
 import { CreateInterface } from '@joystream/types'
 
-export type MemberContext = {
-  account: string
-  memberId: MemberId
-}
-
 export type AnyQueryNodeEvent = Pick<
   MembershipBoughtEvent,
   'createdAt' | 'updatedAt' | 'id' | 'inBlock' | 'inExtrinsic' | 'indexInBlock' | 'network'
 >
-
 export interface EventDetails {
   event: Event
   blockNumber: number
@@ -24,6 +19,18 @@ export interface EventDetails {
   indexInBlock: number
 }
 
+export type MemberContext = {
+  account: string
+  memberId: MemberId
+}
+
+export type MetadataInput<T> = {
+  value: T | string
+  expectFailure?: boolean
+}
+
+// Membership
+
 export interface MembershipBoughtEventDetails extends EventDetails {
   memberId: MemberId
 }
@@ -48,6 +55,8 @@ export type MembershipEventName =
   | 'InitialInvitationBalanceUpdated'
   | 'LeaderInvitationQuotaUpdated'
 
+// Working groups
+
 export interface OpeningAddedEventDetails extends EventDetails {
   openingId: OpeningId
 }
@@ -109,3 +118,46 @@ export type ProposalType = keyof typeof ProposalDetails.typeDefinitions
 export type ProposalDetailsJsonByType<T extends ProposalType = ProposalType> = CreateInterface<
   InstanceType<ProposalDetails['typeDefinitions'][T]>
 >
+// Forum
+
+export type ThreadPath = {
+  categoryId: CategoryId
+  threadId: ThreadId
+}
+
+export type PostPath = {
+  categoryId: CategoryId
+  threadId: ThreadId
+  postId: PostId
+}
+
+export interface CategoryCreatedEventDetails extends EventDetails {
+  categoryId: CategoryId
+}
+
+export interface ThreadCreatedEventDetails extends EventDetails {
+  threadId: ThreadId
+}
+
+export interface PostAddedEventDetails extends EventDetails {
+  postId: PostId
+}
+
+export type ForumEventName =
+  | 'CategoryCreated'
+  | 'CategoryUpdated'
+  | 'CategoryDeleted'
+  | 'ThreadCreated'
+  | 'ThreadModerated'
+  | 'ThreadUpdated'
+  | 'ThreadTitleUpdated'
+  | 'ThreadDeleted'
+  | 'ThreadMoved'
+  | 'PostAdded'
+  | 'PostModerated'
+  | 'PostDeleted'
+  | 'PostTextUpdated'
+  | 'PostReacted'
+  | 'VoteOnPoll'
+  | 'CategoryStickyThreadUpdate'
+  | 'CategoryMembershipOfModeratorUpdated'

+ 34 - 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,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')

+ 10 - 10
yarn.lock

@@ -5371,6 +5371,11 @@
   resolved "https://registry.yarnpkg.com/@types/node/-/node-11.11.6.tgz#df929d1bb2eee5afdda598a41930fe50b43eaa6a"
   integrity sha512-Exw4yUWMBXM3X+8oqzJNRqZSwUAaS4+7NdvHqQuFi/d+synz++xmX3QIf+BFqneW8N31R8Ky+sikfZUXq07ggQ==
 
+"@types/node@>=13.7.0":
+  version "15.0.3"
+  resolved "https://registry.yarnpkg.com/@types/node/-/node-15.0.3.tgz#ee09fcaac513576474c327da5818d421b98db88a"
+  integrity sha512-/WbxFeBU+0F79z9RdEOXH4CsDga+ibi5M8uEYr91u3CkT/pdWcV8MCook+4wDPnZBexRdwWS+PiVZ2xJviAzcQ==
+
 "@types/node@^10.1.0", "@types/node@^10.17.18":
   version "10.17.50"
   resolved "https://registry.yarnpkg.com/@types/node/-/node-10.17.50.tgz#7a20902af591282aa9176baefc37d4372131c32d"
@@ -5381,11 +5386,6 @@
   resolved "https://registry.yarnpkg.com/@types/node/-/node-12.19.11.tgz#9220ab4b20d91169eb78f456dbfcbabee89dfb50"
   integrity sha512-bwVfNTFZOrGXyiQ6t4B9sZerMSShWNsGRw8tC5DY1qImUNczS9SjT4G6PnzjCnxsu5Ubj6xjL2lgwddkxtQl5w==
 
-"@types/node@^13.7.0":
-  version "13.13.51"
-  resolved "https://registry.yarnpkg.com/@types/node/-/node-13.13.51.tgz#a424c5282f99fc1ca41f11b727b6aea80668bcba"
-  integrity sha512-66/xg5I5Te4oGi5Jws11PtNmKkZbOPZWyBZZ/l5AOrWj1Dyw+6Ge/JhYTq/2/Yvdqyhrue8RL+DGI298OJ0xcg==
-
 "@types/node@^9.6.4":
   version "9.6.61"
   resolved "https://registry.yarnpkg.com/@types/node/-/node-9.6.61.tgz#29f124eddd41c4c74281bd0b455d689109fc2a2d"
@@ -23048,10 +23048,10 @@ proto-list@~1.2.1:
   resolved "https://registry.yarnpkg.com/proto-list/-/proto-list-1.2.4.tgz#212d5bfe1318306a420f6402b8e26ff39647a849"
   integrity sha1-IS1b/hMYMGpCD2QCuOJv85ZHqEk=
 
-protobufjs@^6.10.2:
-  version "6.10.2"
-  resolved "https://registry.yarnpkg.com/protobufjs/-/protobufjs-6.10.2.tgz#b9cb6bd8ec8f87514592ba3fdfd28e93f33a469b"
-  integrity sha512-27yj+04uF6ya9l+qfpH187aqEzfCF4+Uit0I9ZBQVqK09hk/SQzKa2MUqUpXaVa7LOFRg1TSSr3lVxGOk6c0SQ==
+protobufjs@^6.11.2:
+  version "6.11.2"
+  resolved "https://registry.yarnpkg.com/protobufjs/-/protobufjs-6.11.2.tgz#de39fabd4ed32beaa08e9bb1e30d08544c1edf8b"
+  integrity sha512-4BQJoPooKJl2G9j3XftkIXjoC9C0Av2NOrWmbLWT1vH32GcSUHjM0Arra6UfTsVyfMAuFzaLucXn1sadxJydAw==
   dependencies:
     "@protobufjs/aspromise" "^1.1.2"
     "@protobufjs/base64" "^1.1.2"
@@ -23064,7 +23064,7 @@ protobufjs@^6.10.2:
     "@protobufjs/pool" "^1.1.0"
     "@protobufjs/utf8" "^1.1.0"
     "@types/long" "^4.0.1"
-    "@types/node" "^13.7.0"
+    "@types/node" ">=13.7.0"
     long "^4.0.0"
 
 protocol-buffers-schema@^3.3.1:

Some files were not shown because too many files changed in this diff