/ tests / fixtures / ProjectClaimManager.ts
ProjectClaimManager.ts
  1  /* eslint-disable no-console */
  2  /* eslint-disable @typescript-eslint/no-explicit-any */
  3  
  4  import { promises as fs } from 'fs';
  5  import path from 'path';
  6  import { lock } from 'proper-lockfile';
  7  import { TEST_ADDRESSES } from './ConnectedSession';
  8  import { gqlClient } from './gqlClient';
  9  
 10  export const ADDRESS_TO_REPO_MAP: Record<string, string> = {
 11    [TEST_ADDRESSES[0]]: 'https://github.com/efstajas/drips-test-repo-1',
 12    [TEST_ADDRESSES[1]]: 'https://github.com/efstajas/drips-test-repo-2',
 13    [TEST_ADDRESSES[2]]: 'https://github.com/efstajas/drips-test-repo-3',
 14    [TEST_ADDRESSES[3]]: 'https://github.com/efstajas/drips-test-repo-4',
 15    [TEST_ADDRESSES[4]]: 'https://github.com/efstajas/drips-test-repo-5',
 16    [TEST_ADDRESSES[5]]: 'https://github.com/efstajas/drips-test-repo-6',
 17  };
 18  
 19  const lockOptions = {
 20    retries: 20,
 21    retryWait: 1000,
 22    realpath: false,
 23  };
 24  
 25  type ClaimState = 'unclaimed' | 'claiming' | 'claimed';
 26  type RepoStates = Record<string, ClaimState>;
 27  
 28  const STATE_FILE_PATH = path.join(process.cwd(), 'test-data', 'project-states.json');
 29  
 30  class ProjectClaimManager {
 31    private static instance: ProjectClaimManager;
 32  
 33    private constructor() {
 34      // The constructor is now simpler. State is managed in the file.
 35    }
 36  
 37    public static getInstance(): ProjectClaimManager {
 38      if (!ProjectClaimManager.instance) {
 39        ProjectClaimManager.instance = new ProjectClaimManager();
 40      }
 41      return ProjectClaimManager.instance;
 42    }
 43  
 44    /**
 45     * Reads the state from the JSON file.
 46     * Initializes the file if it doesn't exist.
 47     */
 48    private async _readState(): Promise<RepoStates> {
 49      try {
 50        await fs.mkdir(path.dirname(STATE_FILE_PATH), { recursive: true });
 51        const fileContent = await fs.readFile(STATE_FILE_PATH, 'utf-8');
 52        return JSON.parse(fileContent);
 53      } catch (error: any) {
 54        if (error.code === 'ENOENT') {
 55          // File doesn't exist
 56          const initialState: RepoStates = {};
 57          Object.values(ADDRESS_TO_REPO_MAP).forEach((repoUrl) => {
 58            initialState[repoUrl] = 'unclaimed';
 59          });
 60          await fs.writeFile(STATE_FILE_PATH, JSON.stringify(initialState, null, 2));
 61          return initialState;
 62        }
 63        throw error;
 64      }
 65    }
 66  
 67    /**
 68     * Writes the given state to the JSON file.
 69     */
 70    private async _writeState(state: RepoStates): Promise<void> {
 71      await fs.writeFile(STATE_FILE_PATH, JSON.stringify(state, null, 2));
 72    }
 73  
 74    /**
 75     * Helper function to delay execution.
 76     */
 77    private _delay(ms: number) {
 78      return new Promise((resolve) => setTimeout(resolve, ms));
 79    }
 80  
 81    // Your GraphQL check function.
 82    private async _checkIfClaimed(repoUrl: string) {
 83      const isClaimedQueryResult = await gqlClient.request(
 84        `query isClaimed {
 85          projectByUrl(url: "${repoUrl}", chains: [LOCALTESTNET]) {
 86            chainData {
 87              ...on ClaimedProjectData {
 88                verificationStatus
 89              }
 90              ...on UnClaimedProjectData {
 91                verificationStatus
 92              }
 93            }
 94          }
 95        }`,
 96      );
 97  
 98      return (isClaimedQueryResult as any).projectByUrl.chainData[0].verificationStatus === 'Claimed';
 99    }
100  
101    public async getClaimedProject(
102      ownerAddress: string,
103      claimFunction: (repoUrl: string) => Promise<void>,
104    ): Promise<string> {
105      const repoUrl = ADDRESS_TO_REPO_MAP[ownerAddress];
106      if (!repoUrl) {
107        throw new Error(`No test repository is associated with address: ${ownerAddress}`);
108      }
109  
110      // First, check the actual backend state. This can help synchronize the state file.
111      const isAlreadyClaimedOnChain = await this._checkIfClaimed(repoUrl);
112      if (isAlreadyClaimedOnChain) {
113        const release = await lock(STATE_FILE_PATH, lockOptions);
114        const state = await this._readState();
115        if (state[repoUrl] !== 'claimed') {
116          console.log(`[Manager] Syncing state for ${repoUrl} to 'claimed' based on backend data.`);
117          state[repoUrl] = 'claimed';
118          await this._writeState(state);
119        }
120        await release();
121        return repoUrl;
122      }
123  
124      // Acquire a lock on the state file to coordinate workers.
125      const release = await lock(STATE_FILE_PATH, lockOptions);
126      const state = await this._readState();
127      const currentState = state[repoUrl];
128  
129      switch (currentState) {
130        case 'claimed':
131          await release();
132          console.log(`[Manager] Project ${repoUrl} is claimed (state file). Reusing.`);
133          return repoUrl;
134  
135        case 'claiming':
136          await release();
137          console.log(`[Manager] Project ${repoUrl} is being claimed. Waiting...`);
138          await this._delay(2000); // Wait for 2 seconds before retrying.
139          return this.getClaimedProject(ownerAddress, claimFunction); // Retry.
140  
141        case 'unclaimed':
142          console.log(`[Manager] Project ${repoUrl} is unclaimed. Starting claim process.`);
143          state[repoUrl] = 'claiming';
144          await this._writeState(state);
145          // Release the lock so other workers can check the 'claiming' state.
146          await release();
147  
148          try {
149            // Perform the actual claim operation.
150            await claimFunction(repoUrl);
151  
152            // Re-acquire lock to update state to 'claimed'.
153            const finalRelease = await lock(STATE_FILE_PATH, lockOptions);
154            const finalState = await this._readState();
155            finalState[repoUrl] = 'claimed';
156            await this._writeState(finalState);
157            await finalRelease();
158  
159            console.log(`[Manager] Successfully claimed ${repoUrl}.`);
160            return repoUrl;
161          } catch (error) {
162            console.error(`[Manager] Failed to claim ${repoUrl}. Resetting state.`, error);
163            // Re-acquire lock to reset state to 'unclaimed'.
164            const errorRelease = await lock(STATE_FILE_PATH, lockOptions);
165            const errorState = await this._readState();
166            errorState[repoUrl] = 'unclaimed';
167            await this._writeState(errorState);
168            await errorRelease();
169  
170            throw error; // Rethrow to fail the test.
171          }
172  
173        default:
174          await release();
175          throw new Error(`Unknown claim state for project: ${repoUrl}`);
176      }
177    }
178  }
179  
180  export type { ProjectClaimManager };
181  export const projectClaimManager = ProjectClaimManager.getInstance();