diff --git a/app/components/ak-svg/storeknox-playstore-logo.hbs b/app/components/ak-svg/storeknox-playstore-logo.hbs index a4aeb556f..127dadca9 100644 --- a/app/components/ak-svg/storeknox-playstore-logo.hbs +++ b/app/components/ak-svg/storeknox-playstore-logo.hbs @@ -4,6 +4,7 @@ viewBox='0 0 20 20' fill='none' xmlns='http://www.w3.org/2000/svg' + ...attributes > {{t 'storeknox.discoverHeader'}} @@ -23,6 +24,7 @@ @currentWhen={{item.activeRoutes}} @hasBadge={{item.hasBadge}} @badgeCount={{item.badgeCount}} + data-test-storeknox-discovery-tabs='{{item.id}}-tab' > {{item.label}} diff --git a/app/components/storeknox/discover/index.ts b/app/components/storeknox/discover/index.ts index 569170ebe..84d530ab6 100644 --- a/app/components/storeknox/discover/index.ts +++ b/app/components/storeknox/discover/index.ts @@ -29,19 +29,11 @@ export default class StoreknoxDiscoverComponent extends Component { route: 'authenticated.storeknox.discover.result', label: this.intl.t('storeknox.discoveryResults'), }, - this.me.org?.is_admin - ? { - id: 'pending-review', - route: 'authenticated.storeknox.discover.review', - label: this.intl.t('storeknox.pendingReview'), - hasBadge: true, - badgeCount: this.skPendingReview.totalCount, - } - : { - id: 'requested-apps', - route: 'authenticated.storeknox.discover.requested', - label: this.intl.t('storeknox.requestedApps'), - }, + !this.me.org?.is_admin && { + id: 'requested-apps', + route: 'authenticated.storeknox.discover.requested', + label: this.intl.t('storeknox.requestedApps'), + }, ].filter(Boolean); } diff --git a/app/components/storeknox/discover/requested-apps/index.hbs b/app/components/storeknox/discover/requested-apps/index.hbs index 733e85b5d..bf6fd88cc 100644 --- a/app/components/storeknox/discover/requested-apps/index.hbs +++ b/app/components/storeknox/discover/requested-apps/index.hbs @@ -3,17 +3,29 @@ @direction='column' @alignItems='center' local-class='empty-container' + data-test-storeknoxDiscover-requestedAppsTable-tableEmpty > - + - + {{t 'storeknox.noRequestedAppsFound'}} - + {{t 'storeknox.noRequestedAppsFoundDescription' htmlSafe=true}} + {{else}} - + {{#let (component r.columnValue.cellComponent) as |Component|}} <:icon> - + {{else}} - + {{this.statusDetails.text}} - + {{t 'by'}} {{this.statusDetails.by}} @@ -25,14 +35,21 @@ - + {{this.statusDetails.date}} <:default> - + diff --git a/app/components/storeknox/discover/results/empty/index.hbs b/app/components/storeknox/discover/results/empty/index.hbs index 3f47e6317..657578107 100644 --- a/app/components/storeknox/discover/results/empty/index.hbs +++ b/app/components/storeknox/discover/results/empty/index.hbs @@ -1,6 +1,12 @@ - - - + + {{t 'storeknox.searchForApps'}} diff --git a/app/components/storeknox/discover/results/index.hbs b/app/components/storeknox/discover/results/index.hbs index 724bc6d23..3a82f9be6 100644 --- a/app/components/storeknox/discover/results/index.hbs +++ b/app/components/storeknox/discover/results/index.hbs @@ -4,28 +4,48 @@ <:rightAdornment> {{#if this.searchQuery}} - + {{else}} - + {{/if}} - + {{t 'storeknox.discoverHeader'}} - + - + @@ -42,6 +62,7 @@ @typographyFontWeight='bold' class='ml-1' {{on 'click' this.viewMore}} + data-test-storeknoxDiscover-results-viewMoreDisclaimerInfo > {{t 'viewMore'}} @@ -68,9 +89,15 @@ class='pr-1' as |ab| > - + - + - + - + {{t 'storeknox.disclaimerHeader'}} - + {{t 'storeknox.disclaimerBody' htmlSafe=true}} diff --git a/app/components/storeknox/discover/results/table/action/index.hbs b/app/components/storeknox/discover/results/table/action/index.hbs index 36963fb18..3c9753690 100644 --- a/app/components/storeknox/discover/results/table/action/index.hbs +++ b/app/components/storeknox/discover/results/table/action/index.hbs @@ -10,7 +10,12 @@ {{else}} {{#if this.requested}} - + <:tooltipContent>
@@ -24,6 +29,7 @@ @iconName={{this.iconValue.iconName}} @size='small' local-class='{{this.iconValue.className}}' + data-test-storeknoxDiscover-resultsTable-addedOrRequestedIcon /> @@ -31,11 +37,20 @@ {{#if this.buttonLoading}} {{else}} - + {{#if this.isAdmin}} - + {{else}} - + {{/if}} {{/if}} diff --git a/app/components/storeknox/discover/results/table/index.hbs b/app/components/storeknox/discover/results/table/index.hbs index 55f73df07..33eeb8922 100644 --- a/app/components/storeknox/discover/results/table/index.hbs +++ b/app/components/storeknox/discover/results/table/index.hbs @@ -13,6 +13,7 @@ @justifyContent='space-between' @alignItems='center' local-class='result-header' + data-test-storeknoxDiscover-resultsTable-header > @@ -80,7 +81,11 @@ - + {{#let (component r.columnValue.cellComponent) as |Component|}} {{else}} {{#if @data.isIos}} - + {{/if}} {{#if @data.isAndroid}} - + {{/if}} {{/if}} \ No newline at end of file diff --git a/app/models/sk-app-metadata.ts b/app/models/sk-app-metadata.ts index 453d4ff92..8852e98c7 100644 --- a/app/models/sk-app-metadata.ts +++ b/app/models/sk-app-metadata.ts @@ -1,4 +1,8 @@ import Model, { attr } from '@ember-data/model'; +import Inflector from 'ember-inflector'; + +const inflector = Inflector.inflector; +inflector.irregular('sk-app-metadata', 'sk-app-metadata'); export interface Region { id: number; diff --git a/app/models/sk-discovery-result.ts b/app/models/sk-discovery-result.ts index 66942c652..b7ee3a9d0 100644 --- a/app/models/sk-discovery-result.ts +++ b/app/models/sk-discovery-result.ts @@ -39,6 +39,9 @@ export default class SkDiscoverySearchResultModel extends Model { @attr('string') declare description: string; + @attr('string') + declare devEmail: string; + @attr('string') declare devName: string; diff --git a/mirage/factories/sk-app-metadata.ts b/mirage/factories/sk-app-metadata.ts new file mode 100644 index 000000000..854985a23 --- /dev/null +++ b/mirage/factories/sk-app-metadata.ts @@ -0,0 +1,34 @@ +import { faker } from '@faker-js/faker'; +import { Factory } from 'miragejs'; +import ENUMS from 'irene/enums'; + +export default Factory.extend({ + doc_ulid: () => faker.string.uuid(), + doc_hash: () => faker.string.hexadecimal({ length: 64 }), + app_id: () => faker.string.uuid(), + url: () => faker.internet.url(), + icon_url: () => faker.image.url(), + package_name: () => faker.internet.domainWord(), + title: () => faker.company.name(), + platform: () => faker.number.int({ min: 0, max: 4 }), + dev_name: () => faker.name.fullName(), + dev_email: () => faker.internet.email(), + dev_website: () => faker.internet.url(), + dev_id: () => faker.string.uuid(), + rating: () => faker.number.float({ min: 0, max: 5, precision: 0.1 }), + rating_count: () => faker.number.int({ min: 1, max: 10000 }), + review_count: () => faker.number.int({ min: 1, max: 5000 }), + total_downloads: () => faker.number.int({ min: 1, max: 1000000 }), + upload_date: () => faker.date.past({ years: 1 }), + latest_upload_date: () => faker.date.recent(), + + region: () => ({ + id: faker.number.int({ min: 1, max: 100 }), + sk_store: faker.number.int({ min: 1, max: 100 }), + country_code: faker.location.countryCode(), + icon: faker.image.url(), + }), + + platform_display: () => + faker.helpers.arrayElement(ENUMS.PLATFORM.BASE_CHOICES.map((c) => c.key)), +}); diff --git a/mirage/factories/sk-app.ts b/mirage/factories/sk-app.ts new file mode 100644 index 000000000..b82e8f43f --- /dev/null +++ b/mirage/factories/sk-app.ts @@ -0,0 +1,61 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +// @ts-expect-error "trait" prop missing from miragejs +import { Factory, ModelInstance, Server, trait } from 'miragejs'; +import { faker } from '@faker-js/faker'; + +import ENUMS from 'irene/enums'; + +export default Factory.extend({ + approval_status: () => + faker.helpers.arrayElement(ENUMS.SK_APPROVAL_STATUS.VALUES), + + approval_status_display() { + const approval_status = this.approval_status as number; + + return ENUMS.SK_APPROVAL_STATUS?.BASE_CHOICES.find( + (c) => c.value === approval_status + )?.key; + }, + + app_status: () => faker.helpers.arrayElement(ENUMS.SK_APP_STATUS.VALUES), + + app_status_display() { + const app_status = this.app_status as number; + + return ENUMS.SK_APP_STATUS.BASE_CHOICES.find((c) => c.value === app_status) + ?.key; + }, + + monitoring_enabled: () => faker.datatype.boolean(), + monitoring_status: () => faker.number.int({ min: 0, max: 3 }), + + approved_on: () => faker.date.past(), + added_on: () => faker.date.past(), + updated_on: () => faker.date.recent(), + rejected_on: () => faker.date.past(), + + availability: () => ({ + storeknox: faker.datatype.boolean(), + appknox: faker.datatype.boolean(), + }), + + // @ts-expect-error + afterCreate(skApp: ModelInstance, server: Server) { + // @ts-expect-error + if (!skApp.app_metadata) { + skApp.update({ + app_metadata: server.create('sk-app-metadata'), + }); + } + }, + + withPendingReviewStatus: trait({ + approval_status: ENUMS.SK_APPROVAL_STATUS.PENDING_APPROVAL, + app_status: ENUMS.SK_APP_STATUS.ACTIVE, + }), + + withApprovedStatus: trait({ + approval_status: ENUMS.SK_APPROVAL_STATUS.APPROVED, + app_status: ENUMS.SK_APP_STATUS.ACTIVE, + }), +}); diff --git a/mirage/factories/sk-discovery-result.ts b/mirage/factories/sk-discovery-result.ts new file mode 100644 index 000000000..e0987ff49 --- /dev/null +++ b/mirage/factories/sk-discovery-result.ts @@ -0,0 +1,32 @@ +import { Factory } from 'miragejs'; +import { faker } from '@faker-js/faker'; + +export default Factory.extend({ + doc_ulid: () => faker.string.uuid(), + doc_hash: () => faker.string.hexadecimal({ length: 64 }), + app_id: () => faker.string.uuid(), + package_name: () => faker.lorem.word(), + title: () => faker.commerce.productName(), + store: () => faker.helpers.arrayElement(['playstore', 'appstore']), + platform: () => faker.number.int({ min: 0, max: 1 }), + region: () => faker.location.countryCode(), + app_size: () => faker.number.int(), + app_type: () => faker.helpers.arrayElement(['application', 'game']), + app_url: () => faker.internet.url(), + is_free: () => faker.datatype.boolean(), + description: () => faker.lorem.paragraphs(2), + dev_name: () => faker.company.name(), + dev_email: () => faker.internet.email(), + icon_url: () => faker.image.url(), + latest_upload_date: () => faker.date.past(), + rating: () => faker.number.int({ min: 0, max: 5 }), + rating_count: () => faker.number.int(), + screenshots: () => Array.from({ length: 8 }, () => faker.image.imageUrl()), + version: () => faker.string.numeric(), + doc_created_on: () => faker.date.recent(), + doc_updated_on: () => faker.date.recent(), + doc_updated_on_ts: () => faker.number.int(), + + min_os_required: () => + faker.helpers.arrayElement(['5.0 and up', '9.0 and up']), +}); diff --git a/mirage/factories/sk-discovery.ts b/mirage/factories/sk-discovery.ts new file mode 100644 index 000000000..df42b7487 --- /dev/null +++ b/mirage/factories/sk-discovery.ts @@ -0,0 +1,10 @@ +import { Factory } from 'miragejs'; +import { faker } from '@faker-js/faker'; + +export default Factory.extend({ + query: () => ({ + q: faker.word.sample(), + }), + + continuous_discovery: () => faker.datatype.boolean(), +}); diff --git a/mirage/factories/sk-requested-app.ts b/mirage/factories/sk-requested-app.ts new file mode 100644 index 000000000..04d1eb1aa --- /dev/null +++ b/mirage/factories/sk-requested-app.ts @@ -0,0 +1,41 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +// @ts-expect-error "trait" prop missing from miragejs +import { ModelInstance, Server, trait } from 'miragejs'; +import { faker } from '@faker-js/faker'; + +import ENUMS from 'irene/enums'; +import SkAppFactory from './sk-app'; + +export default SkAppFactory.extend({ + withPendingApproval: trait({ + approved_by: null, + approved_on: null, + rejected_by: null, + rejected_on: null, + approval_status: ENUMS.SK_APPROVAL_STATUS.PENDING_APPROVAL, + }), + + withApproval: trait({ + approved_by: () => faker.person.firstName(), + rejected_by: null, + rejected_on: null, + approval_status: ENUMS.SK_APPROVAL_STATUS.APPROVED, + }), + + withRejection: trait({ + approved_by: null, + approved_on: null, + rejected_by: () => faker.person.firstName(), + approval_status: ENUMS.SK_APPROVAL_STATUS.REJECTED, + }), + + // @ts-expect-error + afterCreate(skApp: ModelInstance, server: Server) { + // @ts-expect-error + if (!skApp.app_metadata) { + skApp.update({ + app_metadata: server.create('sk-app-metadata'), + }); + } + }, +}); diff --git a/mirage/models/sk-app-metadata.ts b/mirage/models/sk-app-metadata.ts new file mode 100644 index 000000000..db502f142 --- /dev/null +++ b/mirage/models/sk-app-metadata.ts @@ -0,0 +1,3 @@ +import { Model } from 'miragejs'; + +export default Model.extend({}); diff --git a/mirage/models/sk-app.ts b/mirage/models/sk-app.ts new file mode 100644 index 000000000..c67cfc619 --- /dev/null +++ b/mirage/models/sk-app.ts @@ -0,0 +1,5 @@ +import { Model, belongsTo } from 'miragejs'; + +export default Model.extend({ + app_metadata: belongsTo('sk-app-metadata'), +}); diff --git a/mirage/models/sk-discovery-result.ts b/mirage/models/sk-discovery-result.ts new file mode 100644 index 000000000..db502f142 --- /dev/null +++ b/mirage/models/sk-discovery-result.ts @@ -0,0 +1,3 @@ +import { Model } from 'miragejs'; + +export default Model.extend({}); diff --git a/mirage/models/sk-discovery.ts b/mirage/models/sk-discovery.ts new file mode 100644 index 000000000..db502f142 --- /dev/null +++ b/mirage/models/sk-discovery.ts @@ -0,0 +1,3 @@ +import { Model } from 'miragejs'; + +export default Model.extend({}); diff --git a/mirage/models/sk-requested-app.ts b/mirage/models/sk-requested-app.ts new file mode 100644 index 000000000..c67cfc619 --- /dev/null +++ b/mirage/models/sk-requested-app.ts @@ -0,0 +1,5 @@ +import { Model, belongsTo } from 'miragejs'; + +export default Model.extend({ + app_metadata: belongsTo('sk-app-metadata'), +}); diff --git a/tests/integration/components/storeknox/discover/index-test.js b/tests/integration/components/storeknox/discover/index-test.js new file mode 100644 index 000000000..2ba0454c7 --- /dev/null +++ b/tests/integration/components/storeknox/discover/index-test.js @@ -0,0 +1,50 @@ +import { module, test } from 'qunit'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; +import { setupRenderingTest } from 'ember-qunit'; +import { setupMirage } from 'ember-cli-mirage/test-support'; +import { setupIntl, t } from 'ember-intl/test-support'; + +module('Integration | Component | storeknox/discover/index', function (hooks) { + setupRenderingTest(hooks); + setupMirage(hooks); + setupIntl(hooks, 'en'); + + test.each('it renders', [true, false], async function (assert, is_admin) { + this.server.get('/organizations/:id/me', (schema, req) => + schema.organizationMes.find(`${req.params.id}`)?.toJSON() + ); + + this.server.createList('organization', 1); + + await this.owner.lookup('service:organization').load(); + + const orgMeData = this.server.create('organization-me', { is_admin }); + + await render(hbs``); + + assert + .dom('[data-test-storeknoxDiscover-header-infoTexts]') + .exists() + .containsText(t('storeknox.discoverHeader')) + .containsText(t('storeknox.discoverDescription')); + + const tabItems = [ + { + id: 'discovery-results', + label: t('storeknox.discoveryResults'), + }, + !orgMeData?.is_admin && { + id: 'requested-apps', + label: t('storeknox.requestedApps'), + }, + ].filter(Boolean); + + tabItems.forEach((tab) => + assert + .dom(`[data-test-storeknox-discovery-tabs='${tab.id}-tab']`) + .exists() + .containsText(tab.label) + ); + }); +}); diff --git a/tests/integration/components/storeknox/discover/requested-test.js b/tests/integration/components/storeknox/discover/requested-test.js new file mode 100644 index 000000000..78199160f --- /dev/null +++ b/tests/integration/components/storeknox/discover/requested-test.js @@ -0,0 +1,224 @@ +import { module, test } from 'qunit'; +import { find, findAll, render, triggerEvent } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; +import { setupRenderingTest } from 'ember-qunit'; +import { setupMirage } from 'ember-cli-mirage/test-support'; +import { setupIntl, t } from 'ember-intl/test-support'; +import Service from '@ember/service'; +import dayjs from 'dayjs'; + +import ENUMS from 'irene/enums'; + +class RouterStub extends Service { + currentRouteName = ''; + + transitionTo(routeName) { + this.currentRouteName = routeName; + } +} + +module( + 'Integration | Component | storeknox/discover/requested', + function (hooks) { + setupRenderingTest(hooks); + setupMirage(hooks); + setupIntl(hooks, 'en'); + + test('it renders empty state when no app exists', async function (assert) { + // Register router service + this.owner.unregister('service:router'); + this.owner.register('service:router', RouterStub); + + // Models/Test variables + this.server.createList('organization', 1); + + this.server.create('organization-me', { + is_admin: false, + }); + + await this.owner.lookup('service:organization').load(); + + this.setProperties({ queryParams: {} }); + + // Server mocks + this.server.get('/organizations/:id/me', (schema, req) => + schema.organizationMes.find(`${req.params.id}`)?.toJSON() + ); + + this.server.get('v2/sk_requested_apps', () => { + return { count: 0, next: null, previous: null, results: [] }; + }); + + await render( + hbs`` + ); + + assert + .dom('[data-test-storeknoxDiscover-requestedAppsTable-tableEmpty]') + .exists(); + }); + + test.each( + 'it renders requested apps and their correct statuses', + [{ approved: true }, { pending_approval: true }, { rejected: true }], + async function (assert, { approved, rejected }) { + const pending_approval = !approved && !rejected; + + // Register router service + this.owner.unregister('service:router'); + this.owner.register('service:router', RouterStub); + + // Models/Test variables + this.server.createList('organization', 1); + + this.server.create('organization-me', { + is_admin: false, + }); + + const skRequestedApp = this.server.create( + 'sk-requested-app', + approved + ? 'withApproval' + : rejected + ? 'withRejection' + : 'withPendingApproval' + ); + + await this.owner.lookup('service:organization').load(); + + this.setProperties({ queryParams: {} }); + + // Server mocks + this.server.get('/organizations/:id/me', (schema, req) => + schema.organizationMes.find(`${req.params.id}`)?.toJSON() + ); + + this.server.get('v2/sk_requested_apps', (schema) => { + const requestedApps = schema.skRequestedApps + .all() + .models.map((a) => ({ + ...a.toJSON(), + app_metadata: a.app_metadata, + })); + + return { + count: requestedApps.length, + next: null, + previous: null, + results: requestedApps, + }; + }); + + await render( + hbs`` + ); + + const appElementList = findAll( + '[data-test-storeknoxDiscover-requestedAppsTable-row]' + ); + + // Contains the right number of apps + assert.strictEqual(appElementList.length, 1); + + // Sanity check for requested app + const srElement = find( + `[data-test-storeknoxDiscover-requestedAppsTable-rowId='${skRequestedApp.id}']` + ); + + const skRequestedAppMetaData = skRequestedApp.app_metadata; + + assert + .dom(srElement) + .exists() + .containsText(skRequestedAppMetaData.title) + .containsText(skRequestedAppMetaData.dev_email) + .containsText(skRequestedAppMetaData.dev_name); + + assert + .dom('[data-test-applogo-img]', srElement) + .exists() + .hasAttribute('src', skRequestedAppMetaData.icon_url); + + if (skRequestedAppMetaData.platform === ENUMS.PLATFORM.ANDROID) { + assert + .dom( + '[data-test-storeknoxTableColumns-store-playStoreIcon]', + srElement + ) + .exists(); + } + + if (skRequestedAppMetaData.platform === ENUMS.PLATFORM.IOS) { + assert + .dom('[data-test-storeknoxTableColumns-store-iosIcon]', srElement) + .exists(); + } + + if (pending_approval) { + assert + .dom( + '[data-test-storeknoxDiscover-requestedAppsTable-row-waitingForApprovalChip]' + ) + .exists() + .containsText(t('storeknox.waitingForApproval')); + + assert + .dom( + '[data-test-storeknoxDiscover-requestedAppsTable-row-waitingForApprovalChipIcon]' + ) + .exists(); + } else { + assert + .dom( + '[data-test-storeknoxDiscover-requestedAppsTable-row-approvalOrRejectedInfoContainer]' + ) + .exists() + .containsText(t(approved ? 'storeknox.approved' : 'rejected')); + + assert + .dom( + '[data-test-storeknoxDiscover-requestedAppsTable-row-approvalOrRejectedUserInfo]' + ) + .exists() + .containsText(t('by')) + .containsText( + approved ? skRequestedApp.approved_by : skRequestedApp.rejected_by + ); + + // Check for approved/rejected date on tooltip + + const tooltipSelector = + '[data-test-storeknoxDiscover-requestedAppsTable-row-approvalOrRejectedDateTooltipIcon]'; + + const approveOrRejectedDateTooltipTriggerElement = + find(tooltipSelector); + + const tooltipContentSelector = '[data-test-ak-tooltip-content]'; + + assert.dom(tooltipSelector).exists(); + + await triggerEvent( + approveOrRejectedDateTooltipTriggerElement, + 'mouseenter' + ); + + assert + .dom(tooltipContentSelector) + .exists() + .hasText( + dayjs( + approved + ? skRequestedApp.approved_on + : skRequestedApp.rejected_on + ).format('MMMM D, YYYY, HH:mm') + ); + + await triggerEvent( + approveOrRejectedDateTooltipTriggerElement, + 'mouseleave' + ); + } + } + ); + } +); diff --git a/tests/integration/components/storeknox/discover/results-test.js b/tests/integration/components/storeknox/discover/results-test.js new file mode 100644 index 000000000..75e9a3c42 --- /dev/null +++ b/tests/integration/components/storeknox/discover/results-test.js @@ -0,0 +1,782 @@ +import { + click, + fillIn, + find, + findAll, + render, + triggerEvent, + waitFor, + waitUntil, +} from '@ember/test-helpers'; + +import Service from '@ember/service'; +import { module, test } from 'qunit'; +import { hbs } from 'ember-cli-htmlbars'; +import { setupRenderingTest } from 'ember-qunit'; +import { setupMirage } from 'ember-cli-mirage/test-support'; +import { setupIntl, t } from 'ember-intl/test-support'; +import { faker } from '@faker-js/faker'; +import { Response } from 'miragejs'; + +import { compareInnerHTMLWithIntlTranslation } from 'irene/tests/test-utils'; +import ENUMS from 'irene/enums'; + +class RouterStub extends Service { + currentRouteName = ''; + + transitionTo(routeName) { + this.currentRouteName = routeName; + } +} + +class NotificationsStub extends Service { + errorMsg = null; + successMsg = null; + + error(msg) { + this.errorMsg = msg; + } + + success(msg) { + this.successMsg = msg; + } +} + +module( + 'Integration | Component | storeknox/discover/results', + function (hooks) { + setupRenderingTest(hooks); + setupMirage(hooks); + setupIntl(hooks, 'en'); + + hooks.beforeEach(async function () { + // Server mocks + this.server.get('/organizations/:id/me', (schema, req) => + schema.organizationMes.find(`${req.params.id}`)?.toJSON() + ); + + // Uregister route to prevent internal lookups + this.owner.unregister('service:router'); + this.owner.register('service:router', RouterStub); + + this.owner.register('service:notifications', NotificationsStub); + + this.server.createList('organization', 1); + const organizationMe = this.server.create('organization-me'); + + await this.owner.lookup('service:organization').load(); + + this.setProperties({ + queryParams: {}, + organizationMe, + }); + }); + + test('it renders with empty state', async function (assert) { + await render( + hbs`` + ); + + assert + .dom('[data-test-storeknoxDiscover-results-searchQueryInput]') + .exists(); + + assert + .dom('[data-test-storeknoxDiscover-results-searchClearIcon]') + .doesNotExist(); + + assert.dom('[data-test-storeknoxDiscover-results-searchIcon]').exists(); + + assert + .dom('[data-test-storeknoxDiscover-results-searchTrigger]') + .exists() + .containsText(t('storeknox.discoverHeader')); + + assert + .dom('[data-test-storeknoxDiscover-results-disclaimerInfoSection]') + .exists() + .containsText(t('storeknox.disclaimer')) + .containsText(t('storeknox.disclaimerHeader')); + + assert + .dom('[data-test-storeknoxDiscover-results-disclaimerInfoWarningIcon]') + .exists(); + + assert + .dom('[data-test-storeknoxDiscover-results-viewMoreDisclaimerInfo]') + .exists() + .containsText(t('viewMore')); + + assert + .dom('[data-test-storeknoxDiscover-resultsEmptyIllustration]') + .exists(); + + assert + .dom('[data-test-storeknoxDiscover-resultsEmptyContainer]') + .exists() + .containsText(t('storeknox.searchForApps')) + .containsText(t('storeknox.searchForAppsDescription')); + }); + + test('it opens and closes diclaimer modal', async function (assert) { + assert.expect(12); + + await render( + hbs`` + ); + + assert + .dom('[data-test-storeknoxDiscover-results-searchQueryInput]') + .exists(); + + assert + .dom('[data-test-storeknoxDiscover-results-viewMoreDisclaimerInfo]') + .exists() + .containsText(t('viewMore')); + + await click( + '[data-test-storeknoxDiscover-results-viewMoreDisclaimerInfo]' + ); + + assert + .dom( + '[data-test-storeknoxDiscover-results-disclaimerModalHeaderContainer]' + ) + .exists() + .containsText(t('storeknox.disclaimer')); + + assert + .dom('[data-test-storeknoxDiscover-results-disclaimerModalWarningIcon]') + .exists(); + + assert + .dom('[data-test-storeknoxDiscover-results-disclaimerModalCloseBtn]') + .exists(); + + assert + .dom( + '[data-test-storeknoxDiscover-results-disclaimerModalCloseBtnIcon]' + ) + .exists(); + + assert + .dom('[data-test-storeknoxDiscover-results-disclaimerModalHeaderText]') + .exists() + .containsText(t('storeknox.disclaimerHeader')); + + compareInnerHTMLWithIntlTranslation(assert, { + selector: + '[data-test-storeknoxDiscover-results-disclaimerModalBodyText]', + message: t('storeknox.disclaimerBody'), + }); + + // Close modal + await click( + '[data-test-storeknoxDiscover-results-disclaimerModalCloseBtn]' + ); + + assert + .dom( + '[data-test-storeknoxDiscover-results-disclaimerModalHeaderContainer]' + ) + .doesNotExist(); + }); + + test.each( + 'it searches for an application', + [true, false], + async function (assert, is_admin) { + assert.expect(37); + + // role set to admin + this.organizationMe.update({ + is_admin, + }); + + const searchText = 'example_app'; + + this.server.post('v2/sk_discovery', (schema, req) => { + const { query_str, ...rest } = JSON.parse(req.requestBody); + + assert.strictEqual(query_str, searchText); + + this.set('queryParams', { app_query: query_str }); + + // Create results whose titles include search query + Array.from({ length: 3 }, () => + this.server.create('sk-discovery-result', { + title: `${searchText} ${faker.random.word()}`, + }) + ); + + // Create results whose titles do not include search query + Array.from({ length: 2 }, () => + this.server.create('sk-discovery-result', { + title: faker.random.word(), + }) + ); + + return { + id: 1, + ...rest, + query: { + q: query_str, + }, + }; + }); + + this.server.get('v2/sk_discovery/:id/search_results', (schema) => { + const results = schema.skDiscoveryResults.where((result) => + result.title.includes(searchText) + ).models; + + this.set('searchResults', results); + + return { + count: results.length, + next: null, + previous: null, + results, + }; + }); + + await render( + hbs`` + ); + + assert + .dom('[data-test-storeknoxDiscover-results-searchQueryInput]') + .exists(); + + assert + .dom('[data-test-storeknoxDiscover-results-searchClearIcon]') + .doesNotExist(); + + assert.dom('[data-test-storeknoxDiscover-results-searchIcon]').exists(); + + await fillIn( + '[data-test-storeknoxDiscover-results-searchQueryInput]', + searchText + ); + + assert + .dom('[data-test-storeknoxDiscover-results-searchClearIcon]') + .exists(); + + assert + .dom('[data-test-storeknoxDiscover-results-searchIcon]') + .doesNotExist(); + + await click('[data-test-storeknoxDiscover-results-searchTrigger]'); + + assert + .dom('[data-test-storeknoxDiscover-resultsTable-header]') + .exists() + .containsText(t('storeknox.showingResults')) + .containsText(searchText); + + // Sanity check for results + const appElementList = findAll( + '[data-test-storeknoxDiscover-resultsTable-row]' + ); + + // this.searchResults was saved in mock service in this test block + assert.strictEqual(appElementList.length, this.searchResults.length); + + for (let index = 0; index < this.searchResults.length; index++) { + const sr = this.searchResults[index]; + + const srElement = find( + `[data-test-storeknoxDiscover-resultsTable-rowId='${sr.doc_ulid}']` + ); + + assert + .dom(srElement) + .exists() + .containsText(sr.title) + .containsText(sr.dev_email) + .containsText(sr.dev_name); + + assert + .dom('[data-test-applogo-img]', srElement) + .exists() + .hasAttribute('src', sr.icon_url); + + if (sr.platform === ENUMS.PLATFORM.ANDROID) { + assert + .dom( + '[data-test-storeknoxTableColumns-store-playStoreIcon]', + srElement + ) + .exists(); + } + + if (sr.platform === ENUMS.PLATFORM.IOS) { + assert + .dom('[data-test-storeknoxTableColumns-store-iosIcon]', srElement) + .exists(); + } + + const addOrSendAddAppReqButton = + '[data-test-storeknoxDiscover-resultsTable-addOrSendAddAppReqButton]'; + + await waitUntil(() => find(addOrSendAddAppReqButton), { + timeout: 500, + }); + + assert.dom(addOrSendAddAppReqButton, srElement).exists(); + + if (is_admin) { + assert + .dom( + '[data-test-storeknoxDiscover-resultsTable-addIcon]', + srElement + ) + .exists(); + } else { + assert + .dom( + '[data-test-storeknoxDiscover-resultsTable-SendAddAppReqIcon]', + srElement + ) + .exists(); + } + } + } + ); + + test.each( + 'it adds/requests add an app to the inventory', + [{ is_admin: true }, { is_admin: false }], + async function (assert, { is_admin }) { + assert.expect(11); + + // role set to admin + this.organizationMe.update({ + is_admin, + }); + + const searchText = 'example_app'; + + this.server.post('v2/sk_discovery', (schema, req) => { + const { query_str, ...rest } = JSON.parse(req.requestBody); + const app_search_id = 1; + + this.set('queryParams', { app_query: query_str, app_search_id }); + + // Check if query matches search text + assert.strictEqual(query_str, searchText); + + // Create results whose titles which have search query + Array.from({ length: 1 }, () => + this.server.create('sk-discovery-result', { + title: `${searchText} ${faker.random.word()}`, + }) + ); + + return { + id: app_search_id, + ...rest, + query: { + q: query_str, + }, + }; + }); + + this.server.get('v2/sk_discovery/:id/search_results', (schema) => { + const results = schema.skDiscoveryResults.where((result) => { + return result.title.includes(searchText); + }).models; + + this.set('searchResults', results); + + return { + count: results.length, + next: null, + previous: null, + results, + }; + }); + + this.server.post('v2/sk_app', (schema, req) => { + const { doc_ulid, app_discovery_query } = JSON.parse(req.requestBody); + + const app_metadata = this.server.create('sk-app-metadata', { + doc_ulid, + }); + + const skApp = this.server + .create('sk-app', { + app_metadata, + approval_status: is_admin + ? ENUMS.SK_APPROVAL_STATUS.APPROVED + : ENUMS.SK_APPROVAL_STATUS.PENDING_APPROVAL, + app_status: ENUMS.SK_APP_STATUS.ACTIVE, + }) + .toJSON(); + + return { + ...skApp, + app_discovery_query, + app_metadata: app_metadata.toJSON(), + }; + }); + + await render( + hbs`` + ); + + assert + .dom('[data-test-storeknoxDiscover-results-searchQueryInput]') + .exists(); + + assert + .dom('[data-test-storeknoxDiscover-results-searchClearIcon]') + .doesNotExist(); + + assert.dom('[data-test-storeknoxDiscover-results-searchIcon]').exists(); + + await fillIn( + '[data-test-storeknoxDiscover-results-searchQueryInput]', + searchText + ); + + await click('[data-test-storeknoxDiscover-results-searchTrigger]'); + + assert + .dom('[data-test-storeknoxDiscover-resultsTable-header]') + .exists() + .containsText(t('storeknox.showingResults')) + .containsText(searchText); + + // Add app to inventory + const resultToAdd = this.searchResults[0]; + + const srElement = find( + `[data-test-storeknoxDiscover-resultsTable-rowId='${resultToAdd.doc_ulid}']` + ); + + const addToInventoryTriggerSelector = + '[data-test-storeknoxDiscover-resultsTable-addOrSendAddAppReqButton]'; + + await waitUntil(() => find(addToInventoryTriggerSelector), { + timeout: 300, + }); + + assert.dom(addToInventoryTriggerSelector, srElement).exists(); + + const triggerIconSelector = is_admin + ? '[data-test-storeknoxDiscover-resultsTable-addIcon]' + : '[data-test-storeknoxDiscover-resultsTable-SendAddAppReqIcon]'; + + assert.dom(triggerIconSelector, srElement).exists(); + + await click(addToInventoryTriggerSelector); + + const addOrRequestedIconSelector = + '[data-test-storeknoxDiscover-resultsTable-addedOrRequestedIcon]'; + + // Wait until loading state is finished + await waitUntil(() => find(addOrRequestedIconSelector), { + timeout: 300, + }); + + assert + .dom(addOrRequestedIconSelector, srElement) + .exists() + .hasClass(is_admin ? /ak-icon-inventory-2/ : /ak-icon-schedule-send/); + } + ); + + test('it renders the correct app request status', async function (assert) { + assert.expect(15); + + const searchText = 'example_app'; + + // Create results whose titles include search query + const resultList = Array.from({ length: 3 }, () => + this.server.create('sk-discovery-result', { + title: `${searchText} ${faker.random.word()}`, + }) + ); + + const resultIdWithPendindApproval = resultList[0].doc_ulid; + const resultIdWithApproval = resultList[1].doc_ulid; + + this.server.post('v2/sk_discovery', (schema, req) => { + const { query_str, ...rest } = JSON.parse(req.requestBody); + const app_search_id = 1; + + this.set('queryParams', { app_query: query_str, app_search_id }); + + // Check if search text and query values are the same + assert.strictEqual(query_str, searchText); + + return { + id: app_search_id, + ...rest, + query: { + q: query_str, + }, + }; + }); + + this.server.get('v2/sk_discovery/:id/search_results', (schema) => { + const results = schema.skDiscoveryResults.all().models; + + return { + count: results.length, + next: null, + previous: null, + results, + }; + }); + + this.server.get('v2/sk_app/check_approval_status', (schema, req) => { + const { doc_ulid } = req.queryParams; + + const is_approved = resultIdWithApproval === doc_ulid; + const is_pending_approval = resultIdWithPendindApproval === doc_ulid; + + if (!is_approved && !is_pending_approval) { + return new Response( + 404, + {}, + { detail: 'App not found in organization inventory.' } + ); + } + + return { + id: faker.number.int(), + + approval_status: is_approved + ? ENUMS.SK_APPROVAL_STATUS.APPROVED + : ENUMS.SK_APPROVAL_STATUS.PENDING_APPROVAL, + + approval_status_display: is_approved + ? 'APPROVED' + : 'PENDING_APPROVAL', + }; + }); + + await render( + hbs`` + ); + + assert + .dom('[data-test-storeknoxDiscover-results-searchQueryInput]') + .exists(); + + assert + .dom('[data-test-storeknoxDiscover-results-searchClearIcon]') + .doesNotExist(); + + assert.dom('[data-test-storeknoxDiscover-results-searchIcon]').exists(); + + await fillIn( + '[data-test-storeknoxDiscover-results-searchQueryInput]', + searchText + ); + + await click('[data-test-storeknoxDiscover-results-searchTrigger]'); + + assert + .dom('[data-test-storeknoxDiscover-resultsTable-header]') + .exists() + .containsText(t('storeknox.showingResults')) + .containsText(searchText); + + for (let index = 0; index < resultList.length; index++) { + const sr = resultList[index]; + const is_approved = resultIdWithApproval === sr.doc_ulid; + const is_pending_approval = resultIdWithPendindApproval === sr.doc_ulid; + + const srElement = find( + `[data-test-storeknoxDiscover-resultsTable-rowId='${sr.doc_ulid}']` + ); + + if (!is_pending_approval && !is_approved) { + const addToInventoryTriggerSelector = + '[data-test-storeknoxDiscover-resultsTable-addOrSendAddAppReqButton]'; + + await waitUntil(() => find(addToInventoryTriggerSelector), { + timeout: 300, + }); + + assert.dom(addToInventoryTriggerSelector, srElement).exists(); + + assert + .dom( + this.organizationMe.is_admin + ? '[data-test-storeknoxDiscover-resultsTable-addIcon]' + : '[data-test-storeknoxDiscover-resultsTable-SendAddAppReqIcon]', + srElement + ) + .exists(); + + // Check for tooltip message + const tooltipSelector = + '[data-test-storeknoxDiscover-resultsTable-addedOrRequestedTooltip]'; + + const tooltipContentSelector = '[data-test-ak-tooltip-content]'; + const actionTriggerBtnTooltip = find(tooltipSelector); + + await triggerEvent(actionTriggerBtnTooltip, 'mouseenter'); + + assert + .dom(tooltipContentSelector) + .exists() + .hasText( + t( + is_approved + ? 'storeknox.appAlreadyExists' + : 'storeknox.appAlreadyRequested' + ) + ); + + await triggerEvent(actionTriggerBtnTooltip, 'mouseleave'); + } else { + const addOrRequestedIconSelector = + '[data-test-storeknoxDiscover-resultsTable-addedOrRequestedIcon]'; + + // Wait until check approval is completed + await waitUntil(() => find(addOrRequestedIconSelector), { + timeout: 300, + }); + + assert + .dom(addOrRequestedIconSelector, srElement) + .exists() + .hasClass( + is_approved ? /ak-icon-inventory-2/ : /ak-icon-schedule-send/ + ); + } + } + }); + + test.each( + 'it throws error if app is already added to inventory or pending approval', + [ + 'An app with this ULID is already in the inventory in APPROVED status', + 'An app with this ULID is already in the inventory in PENDING_APPROVAL status', + ], + async function (assert, error) { + const searchText = 'example_app'; + + // server mocks + this.server.post('/v2/sk_discovery', (schema, req) => { + const { query_str, ...rest } = JSON.parse(req.requestBody); + const app_search_id = 1; + + this.set('queryParams', { app_query: query_str, app_search_id }); + + // Check if query matches search text + assert.strictEqual(query_str, searchText); + + // Create results whose titles which have search query + Array.from({ length: 1 }, () => + this.server.create('sk-discovery-result', { + title: `${searchText} ${faker.random.word()}`, + }) + ); + + return { + id: app_search_id, + ...rest, + query: { + q: query_str, + }, + }; + }); + + this.server.get('v2/sk_discovery/:id/search_results', (schema) => { + const results = schema.skDiscoveryResults.where((result) => { + return result.title.includes(searchText); + }).models; + + this.set('searchResults', results); + + return { + count: results.length, + next: null, + previous: null, + results, + }; + }); + + this.server.post('v2/sk_app', () => { + return new Response( + 400, + {}, + { + doc_ulid: [error], + } + ); + }); + + await render( + hbs`` + ); + + assert + .dom('[data-test-storeknoxDiscover-results-searchQueryInput]') + .exists(); + + assert + .dom('[data-test-storeknoxDiscover-results-searchClearIcon]') + .doesNotExist(); + + assert.dom('[data-test-storeknoxDiscover-results-searchIcon]').exists(); + + await fillIn( + '[data-test-storeknoxDiscover-results-searchQueryInput]', + searchText + ); + + await click('[data-test-storeknoxDiscover-results-searchTrigger]'); + + assert + .dom('[data-test-storeknoxDiscover-resultsTable-header]') + .exists() + .containsText(t('storeknox.showingResults')) + .containsText(searchText); + + // Add app to inventory + const resultToAdd = this.searchResults[0]; + + const srElement = find( + `[data-test-storeknoxDiscover-resultsTable-rowId='${resultToAdd.doc_ulid}']` + ); + + const addToInventoryTriggerSelector = + '[data-test-storeknoxDiscover-resultsTable-addOrSendAddAppReqButton]'; + + await waitUntil(() => find(addToInventoryTriggerSelector), { + timeout: 300, + }); + + assert.dom(addToInventoryTriggerSelector, srElement).exists(); + + const triggerIconSelector = this.organizationMe.is_admin + ? '[data-test-storeknoxDiscover-resultsTable-addIcon]' + : '[data-test-storeknoxDiscover-resultsTable-SendAddAppReqIcon]'; + + assert.dom(triggerIconSelector, srElement).exists(); + + await click(addToInventoryTriggerSelector); + + // Wait until loading state is finished + await waitUntil(() => find(addToInventoryTriggerSelector), { + timeout: 5000, + }); + + assert.dom(addToInventoryTriggerSelector, srElement).exists(); + assert.dom(triggerIconSelector, srElement).exists(); + + const notify = this.owner.lookup('service:notifications'); + + assert.strictEqual(notify.errorMsg, error); + } + ); + } +);