From 0da6c6ed23de238d2138e8c03634698b2f282403 Mon Sep 17 00:00:00 2001 From: naman-contentstack Date: Wed, 27 Aug 2025 18:16:53 +0530 Subject: [PATCH 1/3] added negative test cases and improved overall coverage --- test/unit/commands/app/create.test.ts | 73 ++++++++ test/unit/commands/app/deploy.test.ts | 106 +++++++++++ test/unit/commands/app/uninstall.test.ts | 227 +++++++++++++++++++++++ test/unit/commands/app/update.test.ts | 76 ++++++++ 4 files changed, 482 insertions(+) diff --git a/test/unit/commands/app/create.test.ts b/test/unit/commands/app/create.test.ts index e0ab438..72a574e 100644 --- a/test/unit/commands/app/create.test.ts +++ b/test/unit/commands/app/create.test.ts @@ -397,4 +397,77 @@ describe("app:create", () => { expect(result.stdout).to.contain("App could not be registered"); }); }); + + describe("App creation with duplicate app name", () => { + beforeEach(() => { + nock(`https://${developerHubBaseUrl}`) + .post("/manifests", (body) => { + return body.name === "test-app" && body.target_type === "stack"; + }) + .reply(409, { + errorMessage: "App with this name already exists.", + }); + + sandbox.stub(cliux, "loader").callsFake(() => {}); + sandbox.stub(fs, "writeFileSync").callsFake(() => {}); + sandbox.stub(cliux, "inquire").callsFake((prompt: any) => { + const cases: Record = { + appName: "test-app", + cloneBoilerplate: true, + Organization: "test org 1", + }; + return Promise.resolve(cases[prompt.name]); + }); + }); + + it("should fail when app name already exists", async () => { + const result = await runCommand([ + "app:create", + "--name", + "test-app", + "--data-dir", + process.cwd(), + ]); + expect(result.stdout).to.contain("already exists"); + }); + }); + + describe("App creation with organization UID instead of app UID", () => { + beforeEach(() => { + nock(region.cma) + .get("/v3/organizations?limit=100&asc=name&include_count=true&skip=0") + .reply(200, { organizations: mock.organizations }); + + nock(`https://${developerHubBaseUrl}`) + .post("/manifests", (body) => { + return body.name === "test-app" && body.target_type === "stack"; + }) + .reply(400, { + errorMessage: + "Invalid app configuration. Organization UID provided instead of app data.", + }); + + sandbox.stub(cliux, "loader").callsFake(() => {}); + sandbox.stub(fs, "writeFileSync").callsFake(() => {}); + sandbox.stub(cliux, "inquire").callsFake((prompt: any) => { + const cases: Record = { + appName: "test-app", + cloneBoilerplate: true, + Organization: "test org 1", + }; + return Promise.resolve(cases[prompt.name]); + }); + }); + + it("should fail when organization UID is used instead of app data", async () => { + const result = await runCommand([ + "app:create", + "--name", + "test-app", + "--data-dir", + process.cwd(), + ]); + expect(result.stdout).to.contain("App could not be registered"); + }); + }); }); diff --git a/test/unit/commands/app/deploy.test.ts b/test/unit/commands/app/deploy.test.ts index ab0e905..69f7f0e 100644 --- a/test/unit/commands/app/deploy.test.ts +++ b/test/unit/commands/app/deploy.test.ts @@ -149,4 +149,110 @@ describe("app:deploy", () => { ); }); }); + + describe("Deploy app error handling", () => { + it("should fail with invalid hosting type", async () => { + sandbox.restore(); + sandbox = sinon.createSandbox(); + stubAuthentication(sandbox); + + sandbox.stub(cliux, "loader").callsFake(() => {}); + sandbox.stub(cliux, "inquire").callsFake((prompt: any) => { + const cases: Record = { + App: mock.apps[1].name, + Organization: mock.organizations[0].name, + "hosting types": "invalid-hosting", + }; + return Promise.resolve(cases[prompt.name]); + }); + + sandbox + .stub(require("../../../../src/util/common-utils"), "getProjects") + .resolves([]); + sandbox + .stub(require("../../../../src/util/common-utils"), "updateApp") + .resolves(); + + nock(region.cma) + .get( + "/v3/organizations?limit=100&asc=name&asc=name&include_count=true&skip=0" + ) + .reply(200, { organizations: mock.organizations }); + + nock(`https://${developerHubBaseUrl}`) + .get("/manifests?limit=50&asc=name&include_count=true&skip=0") + .reply(200, { data: mock.apps2 }); + + const { stdout } = await runCommand(["app:deploy"], { + root: process.cwd(), + }); + expect(stdout).to.contain("Please provide a valid Hosting Type."); + }); + + it("should handle new project creation with hosting-with-launch", async () => { + sandbox.restore(); + sandbox = sinon.createSandbox(); + stubAuthentication(sandbox); + + sandbox.stub(cliux, "loader").callsFake(() => {}); + sandbox.stub(cliux, "inquire").callsFake((prompt: any) => { + const cases: Record = { + App: mock.apps[1].name, + Organization: mock.organizations[0].name, + "hosting types": "hosting-with-launch", + "launch project": "new", + }; + return Promise.resolve(cases[prompt.name]); + }); + + sandbox + .stub(require("../../../../src/util/common-utils"), "getProjects") + .resolves([ + { + name: "new-project", + uid: "project-2", + url: "https://new-project.com", + environmentUid: "env-2", + }, + ]); + sandbox + .stub(require("../../../../src/util/common-utils"), "setupConfig") + .returns({ + name: "new-project", + type: "react", + environment: "production", + framework: "nextjs", + }); + sandbox + .stub( + require("../../../../src/util/common-utils"), + "handleProjectNameConflict" + ) + .resolves("new-project"); + sandbox + .stub(require("@contentstack/cli-launch").Launch, "run") + .resolves(); + + nock(region.cma) + .get( + "/v3/organizations?limit=100&asc=name&asc=name&include_count=true&skip=0" + ) + .reply(200, { organizations: mock.organizations }); + + nock(`https://${developerHubBaseUrl}`) + .get("/manifests?limit=50&asc=name&include_count=true&skip=0") + .reply(200, { data: mock.apps2 }); + + nock(`https://${developerHubBaseUrl}`) + .put(`/manifests/${mock.apps2[1].uid}`) + .reply(200, mock.deploy_launch_host); + + const { stdout } = await runCommand(["app:deploy"], { + root: process.cwd(), + }); + expect(stdout).to.contain( + $t(messages.APP_DEPLOYED, { app: mock.apps[1].name }) + ); + }); + }); }); diff --git a/test/unit/commands/app/uninstall.test.ts b/test/unit/commands/app/uninstall.test.ts index b90336e..2d5123d 100644 --- a/test/unit/commands/app/uninstall.test.ts +++ b/test/unit/commands/app/uninstall.test.ts @@ -123,4 +123,231 @@ describe("app:uninstall", () => { expect(stdout).to.contain("App with id wrong-uid not installed"); }); }); + describe("App uninstall with invalid installation UID", () => { + beforeEach(() => { + sandbox.stub(cliux, "inquire").callsFake(async (prompt: any) => { + const cases: Record = { + App: mock.apps[0].name, + Organization: mock.organizations[0].name, + appInstallation: "invalid-installation-uid", + }; + return cases[prompt.name]; + }); + + nock(`https://${developerHubBaseUrl}`) + .delete("/installations/invalid-installation-uid") + .reply(404, { + error: "Not Found", + message: "Installation not found", + }); + }); + + it("should fail when installation UID is invalid", async () => { + const { stdout } = await runCommand([ + "app:uninstall", + "--installation-uid", + "invalid-installation-uid", + ]); + expect(stdout).to.contain("Installation not found"); + }); + }); + describe("App uninstall with permission denied", () => { + beforeEach(() => { + sandbox.stub(cliux, "inquire").callsFake(async (prompt: any) => { + const cases: Record = { + App: mock.apps[0].name, + Organization: mock.organizations[0].name, + appInstallation: mock.installations[0].uid, + }; + return cases[prompt.name]; + }); + + nock(`https://${developerHubBaseUrl}`) + .delete(`/installations/${mock.installations[0].uid}`) + .reply(403, { + error: "Forbidden", + message: "You don't have permission to uninstall this app", + }); + }); + + it("should fail when user lacks permission", async () => { + const { stdout } = await runCommand([ + "app:uninstall", + "--installation-uid", + mock.installations[0].uid, + ]); + expect(stdout).to.contain("You don't have permission"); + }); + }); + + describe("App uninstall with organization UID instead of app UID", () => { + beforeEach(() => { + sandbox.stub(cliux, "inquire").callsFake(async (prompt: any) => { + const cases: Record = { + App: mock.apps[0].name, + Organization: mock.organizations[0].name, + appInstallation: mock.installations[0].uid, + }; + return cases[prompt.name]; + }); + + // Mock the uninstall API to return error when organization UID is used instead of app UID + nock(`https://${developerHubBaseUrl}`) + .delete(`/installations/${mock.installations[0].uid}`) + .reply(400, { + error: "Bad Request", + message: + "Organization UID provided instead of app UID. Cannot uninstall from organization.", + }); + }); + + it("should fail when organization UID is used instead of app UID", async () => { + const { stdout } = await runCommand([ + "app:uninstall", + "--installation-uid", + mock.installations[0].uid, + ]); + + // Should fail because organization UID is being used where app UID is expected + expect(stdout).to.contain("Organization UID provided instead of app UID"); + }); + }); + + describe("Uninstall all apps using --uninstall-all flag", () => { + beforeEach(() => { + sandbox.stub(cliux, "inquire").callsFake(async (prompt: any) => { + const cases: Record = { + App: mock.apps[0].name, + Organization: mock.organizations[0].name, + }; + return cases[prompt.name]; + }); + + // Mock the organizations API call (for getOrg) + nock(region.cma) + .get("/v3/organizations?limit=100&asc=name&include_count=true&skip=0") + .reply(200, { organizations: mock.organizations }); + + // Mock the app fetch API call (using manifests endpoint like existing tests) + nock(`https://${developerHubBaseUrl}`) + .get(`/manifests/${mock.apps[0].uid}`) + .reply(200, { data: mock.apps[0] }); + + // Mock the installations API call (same as existing tests) + nock(`https://${developerHubBaseUrl}`) + .get(`/manifests/${mock.apps[0].uid}/installations`) + .reply(200, { data: mock.installations }); + + // Mock the stacks API call (for getStacks in getInstallation) + nock(region.cma) + .get( + `/v3/organizations/${mock.organizations[0].uid}/stacks?include_count=true&limit=100&asc=name&skip=0` + ) + .reply(200, { items: mock.stacks }); + + // Mock the uninstall API for multiple installations + mock.installations.forEach((installation: any) => { + nock(`https://${developerHubBaseUrl}`) + .delete(`/installations/${installation.uid}`) + .reply(200, { data: {} }); + }); + }); + + it("should successfully uninstall all apps using uninstall-all strategy", async () => { + const { stdout } = await runCommand([ + "app:uninstall", + "--app-uid", + mock.apps[0].uid, + "--uninstall-all", + ]); + + expect(stdout).to.contain( + $t(messages.APP_UNINSTALLED, { app: mock.apps[0].name }) + ); + }); + + it("should handle uninstall-all with organization app", async () => { + // Mock the organizations API call (for getOrg) + nock(region.cma) + .get("/v3/organizations?limit=100&asc=name&include_count=true&skip=0") + .reply(200, { organizations: mock.organizations }); + + // Mock the app fetch API call for organization app (using manifests endpoint) + nock(`https://${developerHubBaseUrl}`) + .get(`/manifests/${mock.apps[1].uid}`) + .reply(200, { data: mock.apps[1] }); + + // Mock the installations API call for organization app + nock(`https://${developerHubBaseUrl}`) + .get(`/manifests/${mock.apps[1].uid}/installations`) + .reply(200, { data: [mock.installations[1]] }); + + const { stdout } = await runCommand([ + "app:uninstall", + "--app-uid", + mock.apps[1].uid, + "--uninstall-all", + ]); + + expect(stdout).to.contain( + $t(messages.APP_UNINSTALLED, { app: mock.apps[1].name }) + ); + }); + }); + + describe("Uninstall all apps error handling", () => { + beforeEach(() => { + sandbox.stub(cliux, "inquire").callsFake(async (prompt: any) => { + const cases: Record = { + App: mock.apps[0].name, + Organization: mock.organizations[0].name, + }; + return cases[prompt.name]; + }); + }); + + it("should handle partial uninstall failures in uninstall-all", async () => { + // Mock the organizations API call (for getOrg) + nock(region.cma) + .get("/v3/organizations?limit=100&asc=name&include_count=true&skip=0") + .reply(200, { organizations: mock.organizations }); + + // Mock the app fetch API call (using manifests endpoint) + nock(`https://${developerHubBaseUrl}`) + .get(`/manifests/${mock.apps[0].uid}`) + .reply(200, { data: mock.apps[0] }); + + // Mock the installations API to return multiple installation UIDs + nock(`https://${developerHubBaseUrl}`) + .get(`/manifests/${mock.apps[0].uid}/installations`) + .reply(200, { data: mock.installations }); + + // Mock the stacks API call (for getStacks in getInstallation) + nock(region.cma) + .get( + `/v3/organizations/${mock.organizations[0].uid}/stacks?include_count=true&limit=100&asc=name&skip=0` + ) + .reply(200, { items: mock.stacks }); + + nock(`https://${developerHubBaseUrl}`) + .delete(`/installations/${mock.installations[0].uid}`) + .reply(200, { data: {} }); + + nock(`https://${developerHubBaseUrl}`) + .delete(`/installations/${mock.installations[1].uid}`) + .reply(500, { + error: "Internal Server Error", + message: "Failed to uninstall app", + }); + + const { stdout } = await runCommand([ + "app:uninstall", + "--app-uid", + mock.apps[0].uid, + "--uninstall-all", + ]); + + expect(stdout).to.contain("Failed to uninstall app"); + }); + }); }); diff --git a/test/unit/commands/app/update.test.ts b/test/unit/commands/app/update.test.ts index fe3fc14..fbbdc72 100644 --- a/test/unit/commands/app/update.test.ts +++ b/test/unit/commands/app/update.test.ts @@ -205,4 +205,80 @@ describe("app:update", () => { expect(result.stdout).to.contain('"status":403'); }); }); + describe("Update app with duplicate app name (409 status)", () => { + beforeEach(() => { + nock(region.cma) + .get( + "/v3/organizations?limit=100&asc=name&asc=name&include_count=true&skip=0" + ) + .reply(200, { organizations: mock.organizations }); + + nock(`https://${developerHubBaseUrl}`) + .get("/manifests/app-uid-1") + .reply(200, { + data: { ...manifestData, name: "test-app", version: 1 }, + }); + + // Stub the updateAppOnDeveloperHub method to throw 409 error + sandbox + .stub( + require("../../../../src/commands/app/update").default.prototype, + "updateAppOnDeveloperHub" + ) + .callsFake(function (this: any) { + this.log( + this.$t(this.messages.DUPLICATE_APP_NAME, { + appName: this.manifestData.name, + }), + "warn" + ); + throw { status: 409 }; + }); + }); + + it("should fail with duplicate app name error (409 status)", async () => { + const result = await runCommand([ + "app:update", + "--app-manifest", + join(process.cwd(), "test", "unit", "config", "manifest.json"), + ]); + + expect(result.stdout).to.contain("test-app"); + expect(result.stdout).to.contain("already exists"); + }); + }); + + describe("Update app with organization UID instead of app UID", () => { + beforeEach(() => { + nock(region.cma) + .get( + "/v3/organizations?limit=100&asc=name&asc=name&include_count=true&skip=0" + ) + .reply(200, { organizations: mock.organizations }); + + // Mock the API to return an organization instead of an app + nock(`https://${developerHubBaseUrl}`) + .get("/manifests/app-uid-1") + .reply(200, { + data: { + uid: "test-uid-1", + name: "test-org", + version: 1, + target_type: "organization", + }, + }); + + sandbox.stub(cliux, "loader").callsFake(() => {}); + }); + + it("should fail when organization UID is passed instead of app UID", async () => { + const result = await runCommand([ + "app:update", + "--app-manifest", + join(process.cwd(), "test", "unit", "config", "manifest.json"), + ]); + + expect(result.stdout).to.contain(messages.APP_UID_NOT_MATCH); + }); + }); }); From c3595aa00b81197e8ff793528968e63c50405d4a Mon Sep 17 00:00:00 2001 From: naman-contentstack Date: Thu, 28 Aug 2025 11:53:02 +0530 Subject: [PATCH 2/3] updated workflow --- .github/workflows/unit-test.yml | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml index ac7d1f4..803176b 100644 --- a/.github/workflows/unit-test.yml +++ b/.github/workflows/unit-test.yml @@ -1,11 +1,12 @@ -name: Run Unit Tests +name: Unit Test & Reports on: pull_request: types: [opened, synchronize, reopened] jobs: - run-tests: + build-test: + name: Build & Test runs-on: ubuntu-latest steps: - name: Checkout code @@ -24,5 +25,18 @@ jobs: - name: Configure Region run: csdx config:set:region AWS-NA - - name: Run tests - run: npm run test + - name: Run tests with coverage + run: npm run test:unit:report:json + + - name: Test Report + uses: dorny/test-reporter@v1 + if: success() || failure() + with: + name: Mocha Unit Test + path: report.json + reporter: mocha-json + + - name: Coverage Report + uses: lucassabreu/comment-coverage-clover@main + with: + file: coverage/clover.xml From 86cdf7bbdadda7d8a5622594c8c4a4ff3fc2083f Mon Sep 17 00:00:00 2001 From: naman-contentstack Date: Thu, 28 Aug 2025 11:56:21 +0530 Subject: [PATCH 3/3] added command for coverage reports --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index bfdaa29..68c1981 100644 --- a/package.json +++ b/package.json @@ -84,7 +84,8 @@ "test": "mocha --forbid-only \"test/**/*.test.ts\"", "version": "oclif readme && git add README.md", "clean": "rm -rf ./lib tsconfig.tsbuildinfo oclif.manifest.json", - "test:unit:report": "nyc --extension .ts mocha --forbid-only \"test/unit/**/*.test.ts\"" + "test:unit:report": "nyc --extension .ts mocha --forbid-only \"test/unit/**/*.test.ts\"", + "test:unit:report:json": "mocha --reporter json --reporter-options output=report.json --forbid-only \"test/unit/**/*.test.ts\" && nyc --reporter=clover --extension .ts mocha --forbid-only \"test/unit/**/*.test.ts\"" }, "engines": { "node": ">=16"