Browse Source

SudoFillOpening - mappings and tests

Leszek Wiesner 3 years ago
parent
commit
22dc1ee6ac

+ 106 - 6
query-node/mappings/workingGroups.ts

@@ -24,7 +24,15 @@ import {
   AppliedOnOpeningEvent,
   Membership,
   ApplicationStatusPending,
+  ApplicationStatusAccepted,
+  ApplicationStatusRejected,
+  Worker,
+  WorkerStatusActive,
+  OpeningFilledEvent,
+  OpeningStatusFilled,
 } from 'query-node/dist/model'
+import { ApplicationId, OpeningId } from '@joystream/types/working-group'
+import { createType } from '@joystream/types'
 
 // Shortcuts
 type InputTypeMap = OpeningMetadata.ApplicationFormQuestion.InputTypeMap
@@ -41,11 +49,31 @@ async function getWorkingGroup(db: DatabaseManager, event_: SubstrateEvent): Pro
   return group
 }
 
+async function getOpening(
+  db: DatabaseManager,
+  id: OpeningId | string,
+  relations: string[] = []
+): Promise<WorkingGroupOpening> {
+  const opening = await db.get(WorkingGroupOpening, { where: { id: id.toString() }, relations })
+  if (!opening) {
+    throw new Error(`Opening not found by id ${id.toString()}`)
+  }
+
+  return opening
+}
+
+async function getApplication(db: DatabaseManager, id: ApplicationId): Promise<WorkingGroupApplication> {
+  const application = await db.get(WorkingGroupApplication, { where: { id: id.toString() } })
+  if (!application) {
+    throw new Error(`Application not found by id ${id.toString()}`)
+  }
+
+  return application
+}
+
 async function getApplicationFormQuestions(db: DatabaseManager, openingId: string): Promise<ApplicationFormQuestion[]> {
-  const openingWithQuestions = await db.get(WorkingGroupOpening, {
-    where: { id: openingId },
-    relations: ['metadata', 'metadata.applicationFormQuestions'],
-  })
+  const openingWithQuestions = await getOpening(db, openingId, ['metadata', 'metadata.applicationFormQuestions'])
+
   if (!openingWithQuestions) {
     throw new Error(`Opening not found by id: ${openingId}`)
   }
@@ -180,7 +208,7 @@ export async function workingGroups_AppliedOnOpening(db: DatabaseManager, event_
       member_id: memberId,
       reward_account_id: rewardAccount,
       role_account_id: roleAccout,
-      stake_parameters: { staking_account_id: stakingAccount },
+      stake_parameters: { stake, staking_account_id: stakingAccount },
     },
   } = new WorkingGroups.AppliedOnOpeningEvent(event_).data
   const group = await getWorkingGroup(db, event_)
@@ -197,6 +225,7 @@ export async function workingGroups_AppliedOnOpening(db: DatabaseManager, event_
     stakingAccount: stakingAccount.toString(),
     status: new ApplicationStatusPending(),
     answers: [],
+    stake,
   })
 
   await db.save<WorkingGroupApplication>(application)
@@ -217,8 +246,79 @@ export async function workingGroups_AppliedOnOpening(db: DatabaseManager, event_
 }
 
 export async function workingGroups_OpeningFilled(db: DatabaseManager, event_: SubstrateEvent): Promise<void> {
-  // TBD
+  event_.blockTimestamp = new BN(event_.blockTimestamp) // FIXME: Temporary fix for wrong blockTimestamp type
+  const {
+    openingId,
+    applicationId: applicationIdsSet,
+    applicationIdToWorkerIdMap,
+  } = new WorkingGroups.OpeningFilledEvent(event_).data
+
+  const group = await getWorkingGroup(db, event_)
+  const opening = await getOpening(db, openingId, ['applications', 'applications.applicant'])
+  const acceptedApplicationIds = createType('Vec<ApplicationId>', applicationIdsSet.toHex() as any)
+
+  // Save the event
+  const event = await createEvent(event_, EventType.OpeningFilled)
+  const openingFilledEvent = new OpeningFilledEvent({
+    createdAt: new Date(event_.blockTimestamp.toNumber()),
+    updatedAt: new Date(event_.blockTimestamp.toNumber()),
+    event,
+    group,
+    opening,
+  })
+
+  await db.save<Event>(event)
+  await db.save<OpeningFilledEvent>(openingFilledEvent)
+
+  // Update applications and create new workers
+  await Promise.all(
+    (opening.applications || []).map(async (application) => {
+      const isAccepted = acceptedApplicationIds.some((id) => id.toString() === application.id)
+      application.status = isAccepted ? new ApplicationStatusAccepted() : new ApplicationStatusRejected()
+      if (isAccepted) {
+        // Cannot use "applicationIdToWorkerIdMap.get" here,
+        // it only works if the passed instance is identical to BTreeMap key instance (=== instead of .eq)
+        const [, workerId] =
+          Array.from(applicationIdToWorkerIdMap.entries()).find(
+            ([applicationId]) => applicationId.toString() === application.id
+          ) || []
+        if (!workerId) {
+          throw new Error(
+            `Fatal: No worker id found by accepted application id ${application.id} when handling OpeningFilled event!`
+          )
+        }
+        const worker = new Worker({
+          createdAt: new Date(event_.blockTimestamp.toNumber()),
+          updatedAt: new Date(event_.blockTimestamp.toNumber()),
+          id: workerId.toString(),
+          hiredAtBlock: event_.blockNumber,
+          hiredAtTime: new Date(event_.blockTimestamp.toNumber()),
+          application,
+          group,
+          isLead: opening.type === WorkingGroupOpeningType.LEADER,
+          membership: application.applicant,
+          stake: application.stake,
+          roleAccount: application.roleAccount,
+          rewardAccount: application.rewardAccount,
+          stakeAccount: application.stakingAccount,
+          payouts: [],
+          status: new WorkerStatusActive(),
+          entry: openingFilledEvent,
+        })
+        await db.save<Worker>(worker)
+      }
+      await db.save<WorkingGroupApplication>(application)
+    })
+  )
+
+  // Set opening status
+  const openingFilled = new OpeningStatusFilled()
+  openingFilled.openingFilledEventId = openingFilledEvent.id
+  opening.status = openingFilled
+
+  await db.save<WorkingGroupOpening>(opening)
 }
+
 export async function workingGroups_LeaderSet(db: DatabaseManager, event_: SubstrateEvent): Promise<void> {
   // TBD
 }

+ 8 - 2
query-node/schema.graphql

@@ -495,6 +495,9 @@ type Worker @entity {
   "Time the worker was hired at"
   hiredAtTime: DateTime!
 
+  "The event that caused the worker to be hired"
+  entry: OpeningFilledEvent!
+
   "Related worker entry application"
   application: WorkingGroupApplication!
 
@@ -657,6 +660,9 @@ type WorkingGroupApplication @entity {
   "Applicant's membership"
   applicant: Membership!
 
+  "Application stake"
+  stake: BigInt!
+
   "Applicant's initial role account"
   roleAccount: String!
 
@@ -750,8 +756,8 @@ type OpeningFilledEvent @entity {
   "Related opening"
   opening: WorkingGroupOpening!
 
-  # TODO: Can we just get accepted applications via event.opening.applications(where: { status: 'Accepted' })?
-  # Otherwise we would need a field like "acceptedIn" filed in application or a separate table to make OneToMany
+  "Workers that have been hired as a result of filling the opening"
+  workersHired: [Worker!] @derivedFrom(field: "entry")
 }
 
 type LeaderSetEvent @entity {

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

@@ -1,5 +1,5 @@
 import { ApiPromise, WsProvider, Keyring } from '@polkadot/api'
-import { u32 } from '@polkadot/types'
+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'
@@ -23,8 +23,9 @@ import {
   WorkingGroupsEventName,
   WorkingGroupModuleName,
   AppliedOnOpeningEventDetails,
+  OpeningFilledEventDetails,
 } from './types'
-import { ApplicationId, Opening, OpeningId } from '@joystream/types/working-group'
+import { ApplicationId, Opening, OpeningId, WorkerId, ApplyOnOpeningParameters } from '@joystream/types/working-group'
 
 export enum WorkingGroups {
   StorageWorkingGroup = 'storageWorkingGroup',
@@ -196,6 +197,15 @@ export class Api {
     return accountData.data.free
   }
 
+  public async getStakedBalance(address: string | AccountId, lockId?: string): Promise<BN> {
+    const locks = await this.api.query.balances.locks(address)
+    if (lockId) {
+      const foundLock = locks.find((l) => l.id.eq(lockId))
+      return foundLock ? foundLock.amount : new BN(0)
+    }
+    return locks.reduce((sum, lock) => sum.add(lock.amount), new BN(0))
+  }
+
   public async transferBalance({
     from,
     to,
@@ -318,10 +328,22 @@ export class Api {
     const details = await this.retrieveWorkingGroupsEventDetails(result, moduleName, 'AppliedOnOpening')
     return {
       ...details,
+      params: details.event.data[0] as ApplyOnOpeningParameters,
       applicationId: details.event.data[1] as ApplicationId,
     }
   }
 
+  public async retrieveOpeningFilledEventDetails(
+    result: ISubmittableResult,
+    moduleName: WorkingGroupModuleName
+  ): Promise<OpeningFilledEventDetails> {
+    const details = await this.retrieveWorkingGroupsEventDetails(result, moduleName, 'OpeningFilled')
+    return {
+      ...details,
+      applicationIdToWorkerIdMap: details.event.data[1] as BTreeMap<ApplicationId, WorkerId>,
+    }
+  }
+
   public getErrorNameFromExtrinsicFailedRecord(result: ISubmittableResult): string | undefined {
     const failed = result.findRecord('system', 'ExtrinsicFailed')
     if (!failed) {

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

@@ -7,6 +7,7 @@ import {
   MembershipPriceUpdatedEvent,
   MembershipSystemSnapshot,
   OpeningAddedEvent,
+  OpeningFilledEvent,
   Query,
   ReferralCutUpdatedEvent,
 } from './QueryNodeApiSchema.generated'
@@ -32,7 +33,7 @@ export class QueryNodeApi {
     timeoutMs = 210000,
     retryTimeMs = 30000
   ): Promise<QueryResultT> {
-    const label = query.toString().replace(/^.*\./g, '')
+    const label = query.toString().replace(/^.*\.([A-za-z0-9]+\(.*\))$/g, '$1')
     const retryDebug = this.tryDebug.extend(label).extend('retry')
     const failDebug = this.tryDebug.extend(label).extend('failed')
     return new Promise((resolve, reject) => {
@@ -507,6 +508,9 @@ export class QueryNodeApi {
           }
           applications {
             id
+            status {
+              __typename
+            }
           }
           type
           status {
@@ -568,6 +572,7 @@ export class QueryNodeApi {
             }
             answer
           }
+          stake
         }
       }
     `
@@ -647,4 +652,64 @@ export class QueryNodeApi {
       })
     ).data.openingAddedEvents[0]
   }
+
+  public async getOpeningFilledEvent(
+    blockNumber: number,
+    indexInBlock: number
+  ): Promise<OpeningFilledEvent | undefined> {
+    const OPENING_FILLED_BY_ID = gql`
+      query($eventId: ID!) {
+        openingFilledEvents(where: { eventId_eq: $eventId }) {
+          event {
+            inBlock
+            inExtrinsic
+            indexInBlock
+            type
+          }
+          group {
+            name
+          }
+          opening {
+            id
+          }
+          workersHired {
+            id
+            group {
+              name
+            }
+            membership {
+              id
+            }
+            roleAccount
+            rewardAccount
+            stakeAccount
+            status {
+              __typename
+            }
+            isLead
+            stake
+            payouts {
+              id
+            }
+            hiredAtBlock
+            hiredAtTime
+            application {
+              id
+            }
+            storage
+          }
+        }
+      }
+    `
+
+    const eventId = `${blockNumber}-${indexInBlock}`
+    this.queryDebug(`Executing getOpeningFilledEvent(${eventId})`)
+
+    return (
+      await this.queryNodeProvider.query<Pick<Query, 'openingFilledEvents'>>({
+        query: OPENING_FILLED_BY_ID,
+        variables: { eventId },
+      })
+    ).data.openingFilledEvents[0]
+  }
 }

+ 21 - 0
tests/integration-tests/src/QueryNodeApiSchema.generated.ts

@@ -3457,6 +3457,7 @@ export type OpeningFilledEvent = BaseGraphQlObject & {
   groupId: Scalars['String']
   opening: WorkingGroupOpening
   openingId: Scalars['String']
+  workersHired: Array<Worker>
 }
 
 export type OpeningFilledEventConnection = {
@@ -5899,6 +5900,8 @@ export type Worker = BaseGraphQlObject & {
   hiredAtBlock: Scalars['Int']
   /** Time the worker was hired at */
   hiredAtTime: Scalars['DateTime']
+  entry: OpeningFilledEvent
+  entryId: Scalars['String']
   application: WorkingGroupApplication
   applicationId: Scalars['String']
   /** Worker's storage data */
@@ -5935,6 +5938,7 @@ export type WorkerCreateInput = {
   stake: Scalars['BigInt']
   hiredAtBlock: Scalars['Float']
   hiredAtTime: Scalars['DateTime']
+  entryId: Scalars['ID']
   applicationId: Scalars['ID']
   storage?: Maybe<Scalars['String']>
 }
@@ -6065,6 +6069,8 @@ export enum WorkerOrderByInput {
   HiredAtBlockDesc = 'hiredAtBlock_DESC',
   HiredAtTimeAsc = 'hiredAtTime_ASC',
   HiredAtTimeDesc = 'hiredAtTime_DESC',
+  EntryIdAsc = 'entryId_ASC',
+  EntryIdDesc = 'entryId_DESC',
   ApplicationIdAsc = 'applicationId_ASC',
   ApplicationIdDesc = 'applicationId_DESC',
   StorageAsc = 'storage_ASC',
@@ -6678,6 +6684,7 @@ export type WorkerUpdateInput = {
   stake?: Maybe<Scalars['BigInt']>
   hiredAtBlock?: Maybe<Scalars['Float']>
   hiredAtTime?: Maybe<Scalars['DateTime']>
+  entryId?: Maybe<Scalars['ID']>
   applicationId?: Maybe<Scalars['ID']>
   storage?: Maybe<Scalars['String']>
 }
@@ -6746,6 +6753,8 @@ export type WorkerWhereInput = {
   hiredAtTime_lte?: Maybe<Scalars['DateTime']>
   hiredAtTime_gt?: Maybe<Scalars['DateTime']>
   hiredAtTime_gte?: Maybe<Scalars['DateTime']>
+  entryId_eq?: Maybe<Scalars['ID']>
+  entryId_in?: Maybe<Array<Scalars['ID']>>
   applicationId_eq?: Maybe<Scalars['ID']>
   applicationId_in?: Maybe<Array<Scalars['ID']>>
   storage_eq?: Maybe<Scalars['String']>
@@ -6815,6 +6824,8 @@ export type WorkingGroupApplication = BaseGraphQlObject & {
   openingId: Scalars['String']
   applicant: Membership
   applicantId: Scalars['String']
+  /** Application stake */
+  stake: Scalars['BigInt']
   /** Applicant's initial role account */
   roleAccount: Scalars['String']
   /** Applicant's initial reward account */
@@ -6842,6 +6853,7 @@ export type WorkingGroupApplicationCreateInput = {
   createdAt: Scalars['DateTime']
   openingId: Scalars['ID']
   applicantId: Scalars['ID']
+  stake: Scalars['BigInt']
   roleAccount: Scalars['String']
   rewardAccount: Scalars['String']
   stakingAccount: Scalars['String']
@@ -6866,6 +6878,8 @@ export enum WorkingGroupApplicationOrderByInput {
   OpeningIdDesc = 'openingId_DESC',
   ApplicantIdAsc = 'applicantId_ASC',
   ApplicantIdDesc = 'applicantId_DESC',
+  StakeAsc = 'stake_ASC',
+  StakeDesc = 'stake_DESC',
   RoleAccountAsc = 'roleAccount_ASC',
   RoleAccountDesc = 'roleAccount_DESC',
   RewardAccountAsc = 'rewardAccount_ASC',
@@ -6886,6 +6900,7 @@ export type WorkingGroupApplicationUpdateInput = {
   createdAt?: Maybe<Scalars['DateTime']>
   openingId?: Maybe<Scalars['ID']>
   applicantId?: Maybe<Scalars['ID']>
+  stake?: Maybe<Scalars['BigInt']>
   roleAccount?: Maybe<Scalars['String']>
   rewardAccount?: Maybe<Scalars['String']>
   stakingAccount?: Maybe<Scalars['String']>
@@ -6922,6 +6937,12 @@ export type WorkingGroupApplicationWhereInput = {
   openingId_in?: Maybe<Array<Scalars['ID']>>
   applicantId_eq?: Maybe<Scalars['ID']>
   applicantId_in?: Maybe<Array<Scalars['ID']>>
+  stake_eq?: Maybe<Scalars['BigInt']>
+  stake_gt?: Maybe<Scalars['BigInt']>
+  stake_gte?: Maybe<Scalars['BigInt']>
+  stake_lt?: Maybe<Scalars['BigInt']>
+  stake_lte?: Maybe<Scalars['BigInt']>
+  stake_in?: Maybe<Array<Scalars['BigInt']>>
   roleAccount_eq?: Maybe<Scalars['String']>
   roleAccount_contains?: Maybe<Scalars['String']>
   roleAccount_startsWith?: Maybe<Scalars['String']>

+ 166 - 9
tests/integration-tests/src/fixtures/workingGroupsModule.ts

@@ -9,16 +9,27 @@ import {
   AppliedOnOpeningEvent,
   EventType,
   OpeningAddedEvent,
+  OpeningFilledEvent,
   WorkingGroupApplication,
   WorkingGroupOpening,
   WorkingGroupOpeningType,
+  Worker,
 } from '../QueryNodeApiSchema.generated'
 import { ApplicationMetadata, OpeningMetadata } from '@joystream/metadata-protobuf'
-import { WorkingGroupModuleName, MemberContext, AppliedOnOpeningEventDetails, OpeningAddedEventDetails } from '../types'
-import { OpeningId } from '@joystream/types/working-group'
+import {
+  WorkingGroupModuleName,
+  MemberContext,
+  AppliedOnOpeningEventDetails,
+  OpeningAddedEventDetails,
+  OpeningFilledEventDetails,
+  lockIdByWorkingGroup,
+} from '../types'
+import { Application, ApplicationId, Opening, OpeningId } from '@joystream/types/working-group'
 import { Utils } from '../utils'
 import _ from 'lodash'
 import { SubmittableExtrinsic } from '@polkadot/api/types'
+import { JoyBTreeSet } from '@joystream/types/common'
+import { registry } from '@joystream/types'
 
 // TODO: Fetch from runtime when possible!
 const MIN_APPLICATION_STAKE = new BN(2000)
@@ -44,7 +55,6 @@ export class SudoCreateLeadOpeningFixture extends BaseFixture {
   private group: WorkingGroupModuleName
   private debug: Debugger.Debugger
   private openingParams: OpeningParams
-  private createdOpeningId?: OpeningId
 
   private event?: OpeningAddedEventDetails
   private tx?: SubmittableExtrinsic<'promise'>
@@ -89,10 +99,10 @@ export class SudoCreateLeadOpeningFixture extends BaseFixture {
   }
 
   public getCreatedOpeningId(): OpeningId {
-    if (!this.createdOpeningId) {
+    if (!this.event) {
       throw new Error('Trying to get created opening id before it was created!')
     }
-    return this.createdOpeningId
+    return this.event.openingId
   }
 
   public constructor(
@@ -170,8 +180,6 @@ export class SudoCreateLeadOpeningFixture extends BaseFixture {
     const result = await this.api.signAndSend(this.tx, sudoKey)
     this.event = await this.api.retrieveOpeningAddedEventDetails(result, this.group)
 
-    this.createdOpeningId = this.event.openingId
-
     this.debug(`Lead opening created (id: ${this.event.openingId.toString()})`)
   }
 
@@ -202,6 +210,7 @@ export class ApplyOnOpeningHappyCaseFixture extends BaseFixture {
   private openingId: OpeningId
   private openingMetadata: OpeningMetadata.AsObject
 
+  private opening?: Opening
   private event?: AppliedOnOpeningEventDetails
   private tx?: SubmittableExtrinsic<'promise'>
 
@@ -224,6 +233,13 @@ export class ApplyOnOpeningHappyCaseFixture extends BaseFixture {
     this.openingMetadata = openingMetadata
   }
 
+  public getCreatedApplicationId(): ApplicationId {
+    if (!this.event) {
+      throw new Error('Trying to get created application id before the application was created!')
+    }
+    return this.event.applicationId
+  }
+
   private getMetadata(): ApplicationMetadata {
     const metadata = new ApplicationMetadata()
     this.openingMetadata.applicationFormQuestionsList.forEach((question, i) => {
@@ -247,6 +263,7 @@ export class ApplyOnOpeningHappyCaseFixture extends BaseFixture {
     assert.equal(qApplication.rewardAccount, this.applicant.account)
     assert.equal(qApplication.stakingAccount, this.stakingAccount)
     assert.equal(qApplication.status.__typename, 'ApplicationStatusPending')
+    assert.equal(qApplication.stake, eventDetails.params.stake_parameters.stake)
 
     const applicationMetadata = this.getMetadata()
     assert.deepEqual(
@@ -274,8 +291,8 @@ export class ApplyOnOpeningHappyCaseFixture extends BaseFixture {
   }
 
   async execute(): Promise<void> {
-    const opening = await this.api.getOpening(this.group, this.openingId)
-    const stake = opening.stake_policy.stake_amount
+    this.opening = await this.api.getOpening(this.group, this.openingId)
+    const stake = this.opening.stake_policy.stake_amount
     const stakingAccountBalance = await this.api.getBalance(this.stakingAccount)
     assert.isAbove(stakingAccountBalance.toNumber(), stake.toNumber())
 
@@ -315,3 +332,143 @@ export class ApplyOnOpeningHappyCaseFixture extends BaseFixture {
     this.assertQueriedOpeningAddedEventIsValid(eventDetails, tx.hash.toString(), qAppliedOnOpeningEvent)
   }
 }
+
+export class SudoFillLeadOpening extends BaseFixture {
+  private query: QueryNodeApi
+  private group: WorkingGroupModuleName
+  private debug: Debugger.Debugger
+  private openingId: OpeningId
+  private acceptedApplicationIds: ApplicationId[]
+
+  private acceptedApplications?: Application[]
+  private applicationStakes?: BN[]
+  private tx?: SubmittableExtrinsic<'promise'>
+  private event?: OpeningFilledEventDetails
+
+  public constructor(
+    api: Api,
+    query: QueryNodeApi,
+    group: WorkingGroupModuleName,
+    openingId: OpeningId,
+    acceptedApplicationIds: ApplicationId[]
+  ) {
+    super(api)
+    this.query = query
+    this.debug = Debugger('fixture:SudoFillLeadOpening')
+    this.group = group
+    this.openingId = openingId
+    this.acceptedApplicationIds = acceptedApplicationIds
+  }
+
+  async execute() {
+    // Query the applications data
+    this.acceptedApplications = await this.api.query[this.group].applicationById.multi(this.acceptedApplicationIds)
+    this.applicationStakes = await Promise.all(
+      this.acceptedApplications.map((a) =>
+        this.api.getStakedBalance(a.staking_account_id, lockIdByWorkingGroup[this.group])
+      )
+    )
+    // Fill the opening
+    this.tx = this.api.tx.sudo.sudo(
+      this.api.tx[this.group].fillOpening(
+        this.openingId,
+        new (JoyBTreeSet(ApplicationId))(registry, this.acceptedApplicationIds)
+      )
+    )
+    const sudoKey = await this.api.query.sudo.key()
+    const result = await this.api.signAndSend(this.tx, sudoKey)
+    this.event = await this.api.retrieveOpeningFilledEventDetails(result, this.group)
+  }
+
+  private assertQueriedOpeningFilledEventIsValid(
+    eventDetails: OpeningFilledEventDetails,
+    txHash: string,
+    qEvent?: OpeningFilledEvent
+  ) {
+    if (!qEvent) {
+      throw new Error('Query node: OpeningFilledEvent not found')
+    }
+    assert.equal(qEvent.event.inExtrinsic, txHash)
+    assert.equal(qEvent.event.type, EventType.OpeningFilled)
+    assert.equal(qEvent.opening.id, this.openingId.toString())
+    assert.equal(qEvent.group.name, this.group)
+    this.acceptedApplicationIds.forEach((acceptedApplId, i) => {
+      // Cannot use "applicationIdToWorkerIdMap.get" here,
+      // it only works if the passed instance is identical to BTreeMap key instance (=== instead of .eq)
+      const [, workerId] =
+        Array.from(eventDetails.applicationIdToWorkerIdMap.entries()).find(([applicationId]) =>
+          applicationId.eq(acceptedApplId)
+        ) || []
+      if (!workerId) {
+        throw new Error(`WorkerId for application id ${acceptedApplId.toString()} not found in OpeningFilled event!`)
+      }
+      const qWorker = qEvent.workersHired.find((w) => w.id === workerId.toString())
+      if (!qWorker) {
+        throw new Error(`Query node: Worker not found in OpeningFilled.hiredWorkers (id: ${workerId.toString()})`)
+      }
+      this.assertHiredWorkerIsValid(
+        eventDetails,
+        this.acceptedApplicationIds[i],
+        this.acceptedApplications![i],
+        this.applicationStakes![i],
+        qWorker
+      )
+    })
+  }
+
+  private assertHiredWorkerIsValid(
+    eventDetails: OpeningFilledEventDetails,
+    applicationId: ApplicationId,
+    application: Application,
+    applicationStake: BN,
+    qWorker: Worker
+  ) {
+    assert.equal(qWorker.group.name, this.group)
+    assert.equal(qWorker.membership.id, application.member_id.toString())
+    assert.equal(qWorker.roleAccount, application.role_account_id.toString())
+    assert.equal(qWorker.rewardAccount, application.reward_account_id.toString())
+    assert.equal(qWorker.stakeAccount, application.staking_account_id.toString())
+    assert.equal(qWorker.status.__typename, 'WorkerStatusActive')
+    assert.equal(qWorker.isLead, true)
+    assert.equal(qWorker.stake, applicationStake.toString())
+    assert.equal(qWorker.hiredAtBlock, eventDetails.blockNumber)
+    assert.equal(qWorker.application.id, applicationId.toString())
+  }
+
+  async runQueryNodeChecks(): Promise<void> {
+    await super.runQueryNodeChecks()
+    const eventDetails = this.event!
+    const tx = this.tx!
+    // Query the event and check event + hiredWorkers
+    await this.query.tryQueryWithTimeout(
+      () => this.query.getOpeningFilledEvent(eventDetails.blockNumber, eventDetails.indexInBlock),
+      (event) => this.assertQueriedOpeningFilledEventIsValid(eventDetails, tx.hash.toString(), event)
+    )
+
+    // Check opening status
+    const {
+      data: { workingGroupOpeningByUniqueInput: qOpening },
+    } = await this.query.getOpeningById(this.openingId)
+    if (!qOpening) {
+      throw new Error(`Query node: Opening ${this.openingId.toString()} not found!`)
+    }
+    assert.equal(qOpening.status.__typename, 'OpeningStatusFilled')
+
+    // Check application statuses
+    const acceptedApplications = this.acceptedApplicationIds.map((id) => {
+      const application = qOpening.applications.find((a) => a.id === id.toString())
+      if (!application) {
+        throw new Error(`Application not found by id ${id.toString()} in opening ${qOpening.id}`)
+      }
+      assert.equal(application.status.__typename, 'ApplicationStatusAccepted')
+      return application
+    })
+
+    qOpening.applications
+      .filter((a) => !acceptedApplications.some((acceptedA) => acceptedA.id === a.id))
+      .forEach((a) => assert.equal(a.status.__typename, 'ApplicationStatusRejected'))
+
+    // TODO: LeadSet event
+    // TODO: check WorkingGroup.lead
+  }
+}

+ 11 - 2
tests/integration-tests/src/flows/working-groups/leadOpening.ts

@@ -1,5 +1,9 @@
 import { FlowProps } from '../../Flow'
-import { ApplyOnOpeningHappyCaseFixture, SudoCreateLeadOpeningFixture } from '../../fixtures/workingGroupsModule'
+import {
+  ApplyOnOpeningHappyCaseFixture,
+  SudoCreateLeadOpeningFixture,
+  SudoFillLeadOpening,
+} from '../../fixtures/workingGroupsModule'
 
 import Debugger from 'debug'
 import { FixtureRunner } from '../../Fixture'
@@ -42,10 +46,15 @@ export default async function leadOpening({ api, query, env }: FlowProps): Promi
   )
   const applicationRunner = new FixtureRunner(applyOnOpeningFixture)
   await applicationRunner.run()
+  const applicationId = applyOnOpeningFixture.getCreatedApplicationId()
 
-  // Run query node checks once the flow is done
+  // Run query node checks once this part of the flow is done
   await openingRunner.runQueryNodeChecks()
   await applicationRunner.runQueryNodeChecks()
 
+  // Fill opening
+  const fillOpeningFixture = new SudoFillLeadOpening(api, query, 'storageWorkingGroup', openingId, [applicationId])
+  await new FixtureRunner(fillOpeningFixture).runWithQueryNodeChecks()
+
   debug('Done')
 }

+ 19 - 3
tests/integration-tests/src/types.ts

@@ -1,6 +1,7 @@
 import { MemberId } from '@joystream/types/common'
-import { ApplicationId, OpeningId } from '@joystream/types/working-group'
+import { ApplicationId, OpeningId, WorkerId, ApplyOnOpeningParameters } from '@joystream/types/working-group'
 import { Event } from '@polkadot/types/interfaces/system'
+import { BTreeMap } from '@polkadot/types'
 import { Event as GenericEventData } from './QueryNodeApiSchema.generated'
 
 export type MemberContext = {
@@ -46,6 +47,11 @@ export interface OpeningAddedEventDetails extends EventDetails {
 
 export interface AppliedOnOpeningEventDetails extends EventDetails {
   applicationId: ApplicationId
+  params: ApplyOnOpeningParameters
+}
+
+export interface OpeningFilledEventDetails extends EventDetails {
+  applicationIdToWorkerIdMap: BTreeMap<ApplicationId, WorkerId>
 }
 
 export type WorkingGroupsEventName =
@@ -69,5 +75,15 @@ export type WorkingGroupsEventName =
   | 'StatusTextChanged'
   | 'BudgetSpending'
 
-// TODO: Other groups...
-export type WorkingGroupModuleName = 'storageWorkingGroup'
+export type WorkingGroupModuleName =
+  | 'storageWorkingGroup'
+  | 'contentDirectoryWorkingGroup'
+  | 'forumWorkingGroup'
+  | 'membershipWorkingGroup'
+
+export const lockIdByWorkingGroup: { [K in WorkingGroupModuleName]: string } = {
+  storageWorkingGroup: '0x0606060606060606',
+  contentDirectoryWorkingGroup: '0x0707070707070707',
+  forumWorkingGroup: '0x0808080808080808',
+  membershipWorkingGroup: '0x0909090909090909',
+}