Repository: hashgraph/hedera-local-node (ts)

Issue:
Create unit test for AttachState

Pull request:
feat: Adds unit tests for attach state
**Description**:
<!--
One or two line summary of what this PR does and why it is needed, followed by a list
of changes in imperative, present tense for use in the commit message or changelog. Example:

This PR modifies ... in order to support ...
* Add config property
* Change column name
* Remove ...
-->
This PR adds unit tests for the AttachState
**Related issue(s)**:

Fixes #519 



Patch:
diff --git a/src/constants.ts b/src/constants.ts
index ae890e2..b0d2023 100644
--- a/src/constants.ts
+++ b/src/constants.ts
@@ -110,3 +110,7 @@ export const INIT_STATE_BOOTSTRAPPED_PROP_SET = 'Needed bootsrap properties were
 export const INIT_STATE_MIRROR_PROP_SET = 'Needed mirror node properties were set for this configuration.';
 export const INIT_STATE_NO_NODE_CONF_NEEDED = 'No additional node configuration needed.';
 export const INIT_STATE_NO_ENV_VAR_CONFIGURED = 'No new environment variables were configured.';
+
+// Debug state
+export const DEBUG_STATE_INIT_MESSAGE = 'Debug State Initialized!';
+export const DEBUG_STATE_STARTING_MESSAGE = 'Debug State Starting...';
diff --git a/src/state/AttachState.ts b/src/state/AttachState.ts
index b4a723a..0d2890d 100644
--- a/src/state/AttachState.ts
+++ b/src/state/AttachState.ts
@@ -84,11 +84,11 @@ export class AttachState implements IState{
         }
 
         await this.attachContainerLogs(CONSENSUS_NODE_LABEL);
-        await this.attachContainerLogs(MIRROR_NODE_LABEL)
+        await this.attachContainerLogs(MIRROR_NODE_LABEL);
         await this.attachContainerLogs(RELAY_LABEL);
 
         let i = 0;
-        while (i++ < Number.MAX_VALUE) {
+        while (i++ < this.loopIterations()) {
           await this.logger.updateStatusBoard();
           await new Promise((resolve) => setTimeout(resolve, 10000));
         }
@@ -133,4 +133,8 @@ export class AttachState implements IState{
           }
         );
     }
+
+    private loopIterations(): number {
+      return Number.MAX_VALUE;
+    }
 }
diff --git a/src/state/DebugState.ts b/src/state/DebugState.ts
index 4c45dbb..1e788d1 100644
--- a/src/state/DebugState.ts
+++ b/src/state/DebugState.ts
@@ -73,11 +73,11 @@ export class DebugState implements IState{
             const { timestamp, workDir } = ServiceLocator.Current.get<CLIService>(CLIService.name).getCurrentArgv();
             // DebugState.checkForDebugMode();
             this.logger.trace('Debug State Starting...', this.stateName);
-            const jsTimestampNum = DebugState.getAndValidateTimestamp(timestamp)
+            const jsTimestampNum = DebugState.getAndValidateTimestamp(timestamp);
 
             const tempDir = resolve(workDir, RELATIVE_TMP_DIR_PATH);
             const recordFilesDirPath = resolve(workDir, RELATIVE_RECORDS_DIR_PATH);
-            this.findAndCopyRecordFileToTmpDir(jsTimestampNum, recordFilesDirPath, tempDir)
+            this.findAndCopyRecordFileToTmpDir(jsTimestampNum, recordFilesDirPath, tempDir);
             // Perform the parsing
             await shell.exec(
                 'docker exec network-node bash /opt/hgcapp/recordParser/parse.sh'
@@ -156,8 +156,8 @@ export class DebugState implements IState{
                 files[i]
               ];
 
-              this.copyFilesToTmpDir(fileToCopy, tmpDirPath, recordFilesDirPath)
-              return
+              this.copyFilesToTmpDir(fileToCopy, tmpDirPath, recordFilesDirPath);
+              return;
             }
           }
             
@@ -169,23 +169,18 @@ export class DebugState implements IState{
      * 
      * This method takes a list of files or a single file and copies them from the record files directory to the temporary directory.
      * 
-     * @param {string | Array<string>} filesToCopy - The file or files to copy.
+     * @param {Array<string>} filesToCopy - The file or files to copy.
      * @param {string} tmpDirPath - The path to the temporary directory.
      * @param {string} recordFilesDirPath - The path to the directory containing the record files.
      */
     private copyFilesToTmpDir(
-      filesToCopy: string | Array<string>,
+      filesToCopy: Array<string>,
       tmpDirPath: string,
       recordFilesDirPath: string
     ): void {
-        if (Array.isArray(filesToCopy)) {
-          for (const file of filesToCopy) {
-            this.copyFileToDir(file, recordFilesDirPath, tmpDirPath)
-          }
-          return
-        }
-
-        this.copyFileToDir(filesToCopy, recordFilesDirPath, tmpDirPath)
+       for (const file of filesToCopy) {
+         this.copyFileToDir(file, recordFilesDirPath, tmpDirPath)
+       }
     }
 
     /**


Test patch:
diff --git a/test/unit/states/AttachState.spec.ts b/test/unit/states/AttachState.spec.ts
new file mode 100644
index 0000000..4fccd73
--- /dev/null
+++ b/test/unit/states/AttachState.spec.ts
@@ -0,0 +1,130 @@
+import { expect } from 'chai';
+import sinon, { SinonSandbox, SinonStubbedInstance } from 'sinon';
+import { AttachState } from '../../../src/state/AttachState';
+import { IOBserver } from '../../../src/controller/IObserver';
+import { CLIService } from '../../../src/services/CLIService';
+import { DockerService } from '../../../src/services/DockerService';
+import { LoggerService } from '../../../src/services/LoggerService';
+import { EventType } from '../../../src/types/EventType';
+import { getTestBed } from '../testBed';
+
+describe('AttachState', () => {
+  let attachState: AttachState,
+      loggerService: SinonStubbedInstance<LoggerService>,
+      dockerService: SinonStubbedInstance<DockerService>,
+      cliService: SinonStubbedInstance<CLIService>,
+      testSandbox: SinonSandbox;
+
+  before(() => {
+    const {
+        sandbox,
+        loggerServiceStub,
+        dockerServiceStub,
+        cliServiceStub
+    } = getTestBed({
+        workDir: 'testDir',
+    });
+
+    dockerService = dockerServiceStub
+    loggerService = loggerServiceStub
+    cliService = cliServiceStub
+    testSandbox = sandbox
+
+    // Create an instance of AttachState
+    attachState = new AttachState();
+  });
+
+  after(() => {
+    testSandbox.resetHistory();
+  })
+
+  describe('subscribe', () => {
+    it('should set the observer', () => {
+      const observer: IOBserver = { update: sinon.stub() };
+      attachState.subscribe(observer);
+
+      expect((attachState as any).observer).to.equal(observer);
+    });
+  });
+
+  describe('onStart', () => {
+    it('should call attachContainerLogs for consensus, mirror, and relay', async () => {
+      cliService.getCurrentArgv.returns({
+        async: false,
+        blocklisting: false,
+        balance: 1000,
+        accounts: 10,
+        startup: false,
+        detached: false
+      } as any);
+      const attachContainerLogsStub = testSandbox.stub(AttachState.prototype, <any>'attachContainerLogs');
+      const iterations = testSandbox.stub(AttachState.prototype, <any>'loopIterations');
+      iterations.returns(2);
+
+      await attachState.onStart();
+
+      expect(attachContainerLogsStub.calledThrice).to.be.true;
+      expect(attachContainerLogsStub.firstCall.calledWithExactly("network-node")).to.be.true;
+      expect(attachContainerLogsStub.secondCall.calledWithExactly("mirror-node-rest")).to.be.true;
+      expect(attachContainerLogsStub.thirdCall.calledWithExactly("json-rpc-relay")).to.be.true;
+      testSandbox.assert.calledTwice(loggerService.updateStatusBoard);
+
+      attachContainerLogsStub.restore();
+      iterations.restore();
+    }).timeout(25000);
+
+    it('should not call attachContainerLogs when detached', async () => {
+      (attachState as any).observer = { update: testSandbox.stub()};
+      cliService.getCurrentArgv.returns({
+        async: false,
+        blocklisting: false,
+        balance: 1000,
+        accounts: 10,
+        startup: false,
+        detached: true
+      } as any);
+      const attachContainerLogsStub = testSandbox.stub(AttachState.prototype, <any>'attachContainerLogs');
+      const iterations = testSandbox.stub(AttachState.prototype, <any>'loopIterations');
+      iterations.returns(2);
+
+      await attachState.onStart();
+
+      testSandbox.assert.calledOnceWithExactly(attachState.observer?.update, EventType.Finish);
+      attachContainerLogsStub.restore();
+      iterations.restore();
+    }).timeout(25000);
+  });
+
+  describe('attachContainerLogs', () => {
+    it('should attach container logs and filter out lines with "Transaction ID: 0.0.2-"', async () => {
+      cliService.getCurrentArgv.returns({
+        async: false,
+        blocklisting: false,
+        balance: 1000,
+        accounts: 10,
+        startup: false,
+        detached: false
+      } as any);
+      const logsSpy = testSandbox.spy();
+      const demuxSpy = testSandbox.spy();
+      dockerService.getContainer.resolves({
+        logs: logsSpy,
+        modem: { demuxStream: demuxSpy },
+      } as any);
+      const attachContainerLogsStub = testSandbox.stub(AttachState.prototype, <any>'attachContainerLogs');
+      const iterations = testSandbox.stub(AttachState.prototype, <any>'loopIterations');
+      iterations.returns(2);
+      attachContainerLogsStub.withArgs("mirror-node-rest").resolves();
+      attachContainerLogsStub.withArgs("json-rpc-relay").resolves();
+      attachContainerLogsStub.callThrough();
+
+      await attachState.onStart();
+
+      testSandbox.assert.calledOnce(dockerService.getContainer);
+      const spy1 = dockerService.getContainer.getCall(0);
+
+      testSandbox.assert.calledWithExactly(spy1, "network-node");
+      testSandbox.assert.calledOnce(logsSpy);
+    }).timeout(25000);
+  });
+});
diff --git a/test/unit/states/DebugState.spec.ts b/test/unit/states/DebugState.spec.ts
new file mode 100644
index 0000000..8d8e061
--- /dev/null
+++ b/test/unit/states/DebugState.spec.ts
@@ -0,0 +1,252 @@
+/*-
+ *
+ * Hedera Local Node
+ *
+ * Copyright (C) 2024 Hedera Hashgraph, LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+import { expect } from 'chai';
+import fs from 'fs';
+import { SinonSandbox, SinonStub, SinonStubbedInstance } from 'sinon';
+import { LoggerService } from '../../../src/services/LoggerService';
+import { CLIService } from '../../../src/services/CLIService';
+import { DebugState } from '../../../src/state/DebugState';
+import { getTestBed } from '../testBed';
+import {
+    DEBUG_STATE_INIT_MESSAGE,
+    DEBUG_STATE_STARTING_MESSAGE,
+    RELATIVE_TMP_DIR_PATH,
+    RELATIVE_RECORDS_DIR_PATH
+} from '../../../src/constants';
+import { resolve } from 'path';
+import { Errors } from '../../../src/Errors/LocalNodeErrors';
+
+describe('DebugState tests', () => {
+    let debugState: DebugState,
+        testSandbox: SinonSandbox, 
+        loggerService: SinonStubbedInstance<LoggerService>,
+        serviceLocator: SinonStub,
+        shellTestBed: {[key: string]: SinonStub},
+        cliService: SinonStubbedInstance<CLIService>;
+
+    before(() => {
+        const { 
+            sandbox,
+            loggerServiceStub,
+            serviceLocatorStub,
+            shellStubs,
+            cliServiceStub
+        } = getTestBed({
+            workDir: 'testDir',
+            timestamp: '1234567890.987654321'
+        });
+    
+        testSandbox = sandbox
+        loggerService = loggerServiceStub
+        serviceLocator = serviceLocatorStub
+        shellTestBed = shellStubs
+        cliService = cliServiceStub
+
+        debugState = new DebugState();
+    });
+
+    afterEach(() => {
+        testSandbox.resetHistory();
+    });
+
+    it('should initialize the Debug State', async () => {
+        expect(debugState).to.be.instanceOf(DebugState);
+        testSandbox.assert.calledWith(serviceLocator, 'LoggerService');
+        testSandbox.assert.calledOnce(loggerService.trace);
+        testSandbox.assert.calledWith(loggerService.trace, DEBUG_STATE_INIT_MESSAGE, DebugState.name);
+    })
+
+    it('should have a subscribe method', async () => {
+        expect(debugState.subscribe).to.be.a('function');
+    })
+
+    describe('onStart', () => {
+        it('should execute onStart properly', async () => {
+            const { shellExecStub } = shellTestBed;
+            const shellCommand = 'docker exec network-node bash /opt/hgcapp/recordParser/parse.sh';
+            const getAndValidateTimestampSub = testSandbox.stub(DebugState, <any>'getAndValidateTimestamp');
+            const findAndCopyRecordFileToTmpDirStub = testSandbox.stub(DebugState.prototype, <any>'findAndCopyRecordFileToTmpDir');
+            const cleanTempDirStub = testSandbox.stub(DebugState, <any>'cleanTempDir');
+
+            await debugState.onStart();
+            
+            testSandbox.assert.calledWith(shellExecStub, shellCommand);
+            testSandbox.assert.calledOnce(loggerService.trace)
+            testSandbox.assert.calledWith(loggerService.trace, DEBUG_STATE_STARTING_MESSAGE, DebugState.name);
+
+            getAndValidateTimestampSub.restore();
+            findAndCopyRecordFileToTmpDirStub.restore();
+            cleanTempDirStub.restore();
+        })
+    })
+
+    describe('utility methods', () => {
+        let unlinkSyncStub: SinonStub,
+            getAndValidateTimestampStub: SinonStub,
+            findAndCopyRecordFileToTmpDirStub: SinonStub,
+            readdirSyncStub: SinonStub,
+            tempDir: string,
+            cleanTempDirStub: SinonStub,
+            recordFilesDirPath: string;
+
+        beforeEach(() => {
+            unlinkSyncStub = testSandbox.stub(fs, <any>'unlinkSync');
+            getAndValidateTimestampStub = testSandbox.stub(DebugState, <any>'getAndValidateTimestamp');
+            findAndCopyRecordFileToTmpDirStub = testSandbox.stub(DebugState.prototype, <any>'findAndCopyRecordFileToTmpDir');
+            readdirSyncStub = testSandbox.stub(fs, <any>'readdirSync');
+            tempDir = resolve(cliService.getCurrentArgv().workDir, RELATIVE_TMP_DIR_PATH);
+            recordFilesDirPath = resolve(cliService.getCurrentArgv().workDir, RELATIVE_RECORDS_DIR_PATH);
+            cleanTempDirStub = testSandbox.stub(DebugState, <any>'cleanTempDir');
+        })
+        
+        afterEach(() => {
+            unlinkSyncStub.restore();
+            getAndValidateTimestampStub.restore();
+            findAndCopyRecordFileToTmpDirStub.restore();
+            readdirSyncStub.restore();
+            cleanTempDirStub.restore();
+        })
+
+        it('should test cleanTempDir', async () => {
+            cleanTempDirStub.restore();
+            readdirSyncStub.returns(['folder1', 'folder2']);
+            const unlinkSyncFirstArgument = resolve(tempDir, 'folder1');
+            const unlinkSyncSecondArgument = resolve(tempDir, 'folder2');
+            await debugState.onStart(); 
+
+            testSandbox.assert.calledOnceWithExactly(readdirSyncStub, tempDir);
+            testSandbox.assert.calledTwice(unlinkSyncStub);
+            testSandbox.assert.calledWithExactly(unlinkSyncStub.firstCall, unlinkSyncFirstArgument);
+            testSandbox.assert.calledWithExactly(unlinkSyncStub.secondCall, unlinkSyncSecondArgument);
+        })
+
+        it('should test cleanTempDir when tempfile is gitignore', async () => {
+            cleanTempDirStub.restore();
+            readdirSyncStub.returns(['.gitignore']);
+
+            await debugState.onStart(); 
+
+            testSandbox.assert.calledOnceWithExactly(readdirSyncStub, tempDir);
+            testSandbox.assert.notCalled(unlinkSyncStub) ;
+        })
+
+        it('should test getAndValidateTimestamp', async () => {
+            getAndValidateTimestampStub.restore();
+            const errorStub = testSandbox.stub(Errors, 'INVALID_TIMESTAMP_ERROR');
+
+            await debugState.onStart();
+
+            testSandbox.assert.notCalled(errorStub);
+
+            errorStub.restore();
+        })
+
+        it('should test getAndValidateTimestamp with wrong timestamp', async () => {
+            getAndValidateTimestampStub.restore();
+            cliService.getCurrentArgv.returns({
+                async: false,
+                blocklisting: false,
+                balance: 1000,
+                accounts: 10,
+                startup: false,
+                timestamp: 124544,
+                workDir: 'testDir'
+            } as any);
+            const errorSpy = testSandbox.spy(Errors, 'INVALID_TIMESTAMP_ERROR');
+
+            await debugState.onStart();
+
+            testSandbox.assert.called(errorSpy);
+        })
+
+        it('should test findAndCopyRecordFileToTmpDir', async () => {
+            findAndCopyRecordFileToTmpDirStub.restore();
+            getAndValidateTimestampStub.returns(1707081291338);
+            readdirSyncStub.returns(['file1', 'file2', 'file3']);
+            const copyFilesTempDirStub = testSandbox.stub(DebugState.prototype, <any>'copyFilesToTmpDir');
+            const dateStub = testSandbox.stub(Date.prototype, <any>'getTime');
+            dateStub.onSecondCall().returns(1707081291350);
+
+            await debugState.onStart();
+
+            testSandbox.assert.calledOnceWithExactly(copyFilesTempDirStub, ['file1', 'file3'], tempDir, recordFilesDirPath);
+            testSandbox.assert.calledOnceWithExactly(readdirSyncStub, recordFilesDirPath);
+            testSandbox.assert.calledTwice(dateStub);
+
+            copyFilesTempDirStub.restore();
+            dateStub.restore();
+        })
+        
+        it('should test findAndCopyRecordFileToTmpDir throws an error', async () => {
+            findAndCopyRecordFileToTmpDirStub.restore();
+            getAndValidateTimestampStub.returns(1707081291338);
+            readdirSyncStub.returns(['file1', 'file2', 'file3']);
+            const copyFilesTempDirStub = testSandbox.stub(DebugState.prototype, <any>'copyFilesToTmpDir');
+            const dateStub = testSandbox.stub(Date.prototype, <any>'getTime').returns(1707081291337);
+            const errorSpy = testSandbox.spy(Errors, 'NO_RECORD_FILE_FOUND_ERROR');
+
+            await debugState.onStart();
+
+            testSandbox.assert.calledOnceWithExactly(readdirSyncStub, recordFilesDirPath);
+            testSandbox.assert.calledOnce(errorSpy);
+            testSandbox.assert.notCalled(copyFilesTempDirStub);
+            testSandbox.assert.calledTwice(dateStub);
+
+            copyFilesTempDirStub.restore();
+            dateStub.restore();
+            errorSpy.restore();
+        })
+
+        it('should test copyFilesToTmpDir', async () => {
+            findAndCopyRecordFileToTmpDirStub.restore();
+            getAndValidateTimestampStub.returns(1707081291338);
+            readdirSyncStub.returns(['file1', 'file2', 'file3']);
+            const copyFileToDirStub = testSandbox.stub(DebugState.prototype, <any>'copyFileToDir');
+            const dateStub = testSandbox.stub(Date.prototype, <any>'getTime');
+            dateStub.onSecondCall().returns(1707081291350);
+
+            await debugState.onStart();
+
+            testSandbox.assert.calledTwice(copyFileToDirStub);
+            testSandbox.assert.calledWithExactly(copyFileToDirStub.getCall(0), 'file1', recordFilesDirPath, tempDir);
+            testSandbox.assert.calledWithExactly(copyFileToDirStub.getCall(1), 'file3', recordFilesDirPath, tempDir);
+
+            readdirSyncStub.restore();
+            dateStub.restore();
+            copyFileToDirStub.restore();
+        })
+
+        it('should test copyFileToDir', async () => {
+            findAndCopyRecordFileToTmpDirStub.restore();
+            getAndValidateTimestampStub.returns(1707081291338);
+            const copyFileSyncStub = testSandbox.stub(fs, <any>'copyFileSync');
+            readdirSyncStub.returns(['file1', 'file2', 'file3']);
+            const dateStub = testSandbox.stub(Date.prototype, <any>'getTime');
+            dateStub.onSecondCall().returns(1707081291350);
+
+            await debugState.onStart();
+
+            expect(copyFileSyncStub.callCount).to.be.eq(4);
+            dateStub.restore();
+            copyFileSyncStub.restore();
+        })
+    })
+});